引言
微信小程序的<canvas>组件可以画出图片、图表、游戏画面,但最直观的入门方式,是做一个「手指涂鸦」的小画板:手指按下滑动,画布上留下线条;点按钮换颜色,点另一个按钮清空。
这篇文章基于一个真实的绘图板 demo,逐段拆解它用到的 Canvas 2D 知识:获取 canvas 原生节点、处理设备像素比、坐标变换、路径绘制、清屏、触摸事件和颜色切换。如果你刚开始学小程序的 canvas,跟着这篇走一遍,基本就能上手。
一、页面结构:canvas + 两个按钮
WXML 结构非常干净:
xml
<navigation-bar title="canvas组件" back="{{false}}" color="black" background="#FFF"></navigation-bar> <scroll-view class="scrollarea" scroll-y type="list"> <view class="box"> <view class="title">自由绘图</view> <canvas type="2d" id="my-canvas" bind:touchstart="paintStart" bind:touchmove="paintMove" /> <view class="btn-layout"> <button style="color: {{colors[colorIndex]}}; border-color: {{colors[colorIndex]}};" size="mini" bind:tap="changeColor">画笔颜色:{{colors[colorIndex]}}</button> <button type="primary" size="mini" bind:tap="clear">清空画布</button> </view> </view> </scroll-view>几个关键点:
type="2d"是必须的。小程序 canvas 有两种模式:旧的 native canvas 和新的 2D canvas。新项目一律用type="2d",它对应 Web 标准的CanvasRenderingContext2D,API 和 H5 基本一致。id="my-canvas"用于后面通过SelectorQuery找到这个节点。bind:touchstart和bind:touchmove分别对应手指按下和手指移动。注意这里不需要touchend,因为线条是通过moveTo+lineTo逐段画出来的,手指抬起时自然停止。按钮颜色跟随当前画笔颜色,通过
style动态绑定,视觉反馈很直观。
样式上,canvas 被固定为全宽、800rpx高、背景cornflowerblue:
css
canvas { width: 100%; height: 800rpx; background-color: cornflowerblue; } .btn-layout { margin-top: 20rpx; display: flex; justify-content: center; gap: 20rpx; }
gap在小程序里需要基础库支持,较新版本都没问题。如果遇到不生效,可以改用margin兼容。
二、拿到 canvas 原生节点(最关键的一步)
Canvas 2D 的绘图上下文不能直接在onLoad里拿,必须等页面渲染完成。所以初始化放在onReady:
js
onReady() { const query = wx.createSelectorQuery() query.select('#my-canvas') .fields({ node: true, size: true }) .exec((res) => { const canvas = res[0].node const ctx = canvas.getContext('2d') const dpr = wx.getWindowInfo().pixelRatio canvas.width = res[0].width * dpr canvas.height = res[0].height * dpr ctx.scale(dpr, dpr) this.canvas = canvas this.ctx = ctx }) }这段代码虽然短,但每一步都不能少,逐个解释:
①wx.createSelectorQuery()创建一个选择器查询实例,用来在页面里按选择器找节点。
②.select('#my-canvas')选中 id 为my-canvas的节点,返回一个NodesRef。
③.fields({ node: true, size: true })声明要获取哪些字段:
node: true→ 拿到节点对应的Node 实例,这是关键,只有拿到它才能getContext('2d');size: true→ 拿到节点的宽高(逻辑像素)。
④.exec(callback)执行所有查询,结果按顺序放在res数组里。这里只有一个查询,所以是res[0]。
⑤canvas.getContext('2d')拿到 2D 绘图上下文ctx,之后所有绘图指令都走它。
⑥ 设备像素比dpr与画布尺寸——这是最容易踩坑的地方。
canvas.width/height是画布的物理像素尺寸,而 CSS 里的width/height是逻辑像素尺寸。如果两者相等,在高分屏(dpr = 2 或 3)上画出来的线会模糊,因为一个 CSS 像素实际由多个物理像素组成。
正确做法是:画布物理尺寸 = 逻辑尺寸 × dpr,然后调用ctx.scale(dpr, dpr)把坐标系也放大同样倍数。这样你后面写moveTo(x, y)时用的仍然是逻辑坐标,但实际绘制精度提升了 dpr 倍,线条清晰锐利。
js
canvas.width = 逻辑宽 × dpr canvas.height = 逻辑高 × dpr ctx.scale(dpr, dpr) // 坐标系缩放,之后继续用逻辑坐标
⑦ 把canvas和ctx挂到this上,因为paintStart、paintMove、clear这些方法里都要用,而它们是页面对象的其他方法,不能通过闭包访问局部变量。
三、手指按下:设置画笔起点
js
paintStart(e) { let x = e.touches[0].x let y = e.touches[0].y let ctx = this.ctx ctx.strokeStyle = this.data.colors[this.data.colorIndex] ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.lineWidth = 5 ctx.moveTo(x, y) }e.touches[0].x / y是触摸点相对于canvas 盒子左上角的坐标。因为 canvas 是全宽、没有 padding,所以这个坐标可以直接用于绘图(如果 canvas 有 padding 或偏移,需要换算)。strokeStyle设置线条颜色,取自当前选中的颜色。lineCap = 'round'让线条两端是圆头,否则单点触摸会画不出点。lineJoin = 'round'让拐角圆滑,避免折线出现尖角。lineWidth = 5线宽 5 个逻辑像素。moveTo(x, y)把画笔移动到起点,注意这一步只是移动,不画线。真正的线在paintMove里画。
四、手指移动:逐段画线
js
paintMove(e) { let x = e.touches[0].x let y = e.touches[0].y let ctx = this.ctx ctx.strokeStyle = this.data.colors[this.data.colorIndex] ctx.lineTo(x, y) ctx.stroke() }lineTo(x, y)从当前点画一条直线到(x, y),把点加入当前路径。stroke()把当前路径描边渲染出来。
注意strokeStyle在每次paintMove里都重新设置了一次。这是为了让切换颜色后立刻生效——如果只在paintStart里设一次,那么一次触摸过程中换颜色不会影响当前这一笔。虽然实际体验中用户不会在一笔中途换色,但这样写更稳妥,也避免了strokeStyle被其他操作意外改掉。
还有一个性能细节:每次touchmove都触发一次stroke(),在低端机上如果触发频率很高,可能会有掉帧。优化思路是用requestAnimationFrame节流,或者把点收集起来批量绘制。这个 demo 为了简单没有做,日常涂鸦够用。
五、清空画布
js
clear() { let canvas = this.canvas let ctx = this.ctx ctx.beginPath() ctx.clearRect(0, 0, canvas.width, canvas.height) }beginPath()开始一条新路径,丢弃之前的路径。这一步必须做,否则clearRect之后,如果还有旧的路径残留,下次stroke()可能把旧路径也画出来。clearRect(0, 0, canvas.width, canvas.height)把整个画布区域变成透明。
这里传入的是canvas.width/height(物理像素尺寸),而不是逻辑尺寸。因为clearRect的坐标是在缩放后的坐标系里,而ctx.scale(dpr, dpr)之后坐标系已经是逻辑单位,理论上传逻辑尺寸也行……但为了确保清干净整个画布(包括超出逻辑尺寸的物理区域),传物理尺寸更保险。实际上由于scale的作用,传canvas.width/height会清掉dpr倍大的区域,完全覆盖画布,不会残留。
六、扩展功能:点击按钮循环切换颜色
js
data: { colors: ['#ff0000', '#0000ff', '#00aa00', '#000000', '#ff8c00'], colorIndex: 0 }, changeColor() { let nextIndex = (this.data.colorIndex + 1) % this.data.colors.length this.setData({ colorIndex: nextIndex }) wx.showToast({ title: '画笔颜色:' + this.data.colors[nextIndex], icon: 'none' }) }(colorIndex + 1) % colors.length是循环取模的经典写法,走到最后一个自动回到第一个。setData更新colorIndex,页面上按钮的文字和边框颜色会跟着变(因为 WXML 里用了{{colors[colorIndex]}})。wx.showToast给用户一个轻提示,确认颜色已切换。
这个功能虽然小,但完整体现了「数据驱动视图」:颜色状态存在data里,绘图时读data,按钮展示也读data,单一数据源,没有不一致的问题。
七、踩过的坑
1. 在onLoad里拿 canvas 节点拿不到。onLoad时页面还没渲染完,SelectorQuery返回空。必须放在onReady。
2. 不处理 dpr,线条发虚。第一次写的时候直接canvas.width = 逻辑宽,在高分屏上线条边缘有锯齿、发糊。加上dpr和ctx.scale后立刻清晰。
3. 忘记beginPath,清屏后旧线又出现。clearRect只清像素,不清路径。如果不清路径,下一次stroke()可能把之前的路径重新描出来。清屏前先beginPath()。
4. 单点触摸画不出点。没有设lineCap = 'round'时,moveTo之后没有lineTo的单个点不会显示。圆头线帽能让单点也渲染成一个圆点。
5. 坐标取错。e.touches[0].x是相对 canvas 盒子的坐标,如果 canvas 外层有 padding 或滚动偏移,直接用来绘图会偏。这个 demo 里 canvas 是scroll-view里全宽、无内边距的元素,所以可以直接用。
八、小结
这个绘图板虽小,但把 Canvas 2D 的核心流程走全了:
| 知识点 | 在 demo 中的体现 |
|---|---|
| 获取原生节点 | SelectorQuery + fields({ node: true, size: true }) |
| 2D 上下文 | canvas.getContext('2d') |
| 高分屏适配 | dpr+canvas.width/height+ctx.scale |
| 路径绘制 | moveTo/lineTo/stroke |
| 线条样式 | strokeStyle/lineCap/lineJoin/lineWidth |
| 清屏 | beginPath+clearRect |
| 触摸事件 | touchstart/touchmove+e.touches[0].x/y |
| 数据驱动 | colors+colorIndex+setData |
下一步可以扩展的方向:撤销/重做(保存路径历史)、保存为图片(wx.canvasToTempFilePath)、橡皮擦(用背景色画线或globalCompositeOperation)、线宽调节。这些都能在这个骨架上直接加。