小程序里画图只有三步:拿到 Canvas 节点 → 拿 2d 上下文 → 按 dpr 缩放后正常调 API。 真正容易翻车的不是绘图 API,而是四个细节:节点获取方式、设备像素比、绘图状态残留、图片的异步加载。 把这几处处理好,案例里剩下的 9 个按钮本质上只是调用不同的 CanvasRenderingContext2D 方法。
一、界面与效果
页面结构很简单:顶部红色标题、一块浅灰画布(100% × 680rpx,背景 #e9ecf3)、下方 4 行绿色按钮。标题会跟随当前操作变化,比如点「绘制图片」后变成「绘图图片」。
画布用新版的 type="2d",不是老的 canvas-id 写法:
<canvas type="2d" id="my-canvas"></canvas>
这一个小改动决定了后面整条技术路线:新版 Canvas 必须拿到真实的 Canvas 节点实例,wx.createCanvasContext 那套已经不能用了。
二、初始化:节点、上下文和 dpr
onReady() {
wx.createSelectorQuery()
.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) // 之后按 CSS 像素绘图
this.canvas = canvas
this.ctx = ctx
this.cw = res[0].width // CSS 宽
this.ch = res[0].height // CSS 高
})
}
关键在于 canvas.width/height 单位是物理像素,而 fields 返回的 size 是逻辑(CSS)像素。 先按 dpr 放大画布尺寸,再 scale(dpr, dpr),后续代码就可以完全按设计稿的坐标写,真机上也不会糊。
如果屏幕是 3 倍屏,画布物理宽是逻辑宽的 3 倍;忘了 scale 的话,图形会挤在左上角一小块。
三、九个绘图按钮,其实就几行代码
每个按钮的处理函数都是同一个套路:清空 → save → 画 → restore → 改标题。
- 清空画布:beginPath() + clearRect()
- 实心圆:arc() + fillStyle + fill()
- 轮廓圆:strokeStyle + lineWidth + arc() + stroke()
- 虚线:setLineDash([5, 15])
- 端点交点:lineCap='round' + lineJoin='miter'
- 文字:font + textBaseline + fillText() + rotate()
- 径向渐变:createRadialGradient() + addColorStop()
- 阴影矩形:shadowOffsetX/Y + shadowBlur + shadowColor
- 半透明图:globalAlpha
几个值得记住的参数:
- setLineDash([5, 15]) 是「实线 5px、空白 15px」交替循环,不是总长度。
- createRadialGradient(x0, y0, r0, x1, y1, r1) 是两个圆:内圆是渐变起点,外圆是渐变终点,半径差决定渐变色的过渡范围。
- textBaseline 决定 fillText(x, y) 里的 y 指文字顶部、底部还是基线,画多行文字时不设它很容易叠在一起。
- shadowBlur 不是像素值,数值越大越模糊,且不受当前变换矩阵影响。
统一的清空函数:
_clear() {
const ctx = this.ctx
if (!ctx) return
ctx.beginPath()
ctx.clearRect(0, 0, this.cw, this.ch) // 注意:用 CSS 宽高
}
四、作业扩展:把图片画到画布上
需求:新增「绘制图片」按钮,点击后画布显示图片,标题跟着变。
drawImage() {
this._clear()
this.setTitle('绘图图片')
const img = this.canvas.createImage() // 用 Canvas 实例创建,不是 wx.createImage()
img.onload = () => {
ctx.save()
// 等比铺满画布并居中,避免直接拉伸变形
const scale = Math.max(this.cw / img.width, this.ch / img.height)
const dw = img.width * scale
const dh = img.height * scale
ctx.drawImage(img, (this.cw - dw) / 2, (this.ch - dh) / 2, dw, dh)
ctx.restore()
}
img.onerror = () => wx.showToast({ title: '图片加载失败', icon: 'none' })
img.src = '/images/landscape.jpg' // 本地包路径,相对项目根目录
}
三个要点:
- createImage() 挂在 canvas 实例上,页面里没有 wx.createImage 这个等价写法。
- 先挂 onload,最后设 src。src 一赋值就开始异步加载,加载完成前 drawImage 什么都不会画。
- drawImage(image, dx, dy, dWidth, dHeight) 的五参数版本里,dx/dy 是左上角坐标,dWidth/dHeight 是绘制后的尺寸。想做「裁切填充」就用 Math.max 算缩放比,想「完整显示」就用 Math.min。
图片放在 images/ 目录、用 /images/xxx.jpg 这种包内路径,不需要在后台配置域名;换成网络图片才需要 downloadFile 合法域名。
五、四个真实踩过的坑
1. 状态会残留。 fillStyle、lineWidth、shadowBlur、globalAlpha、rotate 都是上下文状态,画完阴影矩形再画圆,圆会带上阴影。所以每个绘图函数都 save() 开头、restore() 结尾,clearRect 之前再 beginPath() 清掉旧路径。
2. clearRect 用错单位。 复制老代码时容易写成 clearRect(0, 0, canvas.width, canvas.height),但 canvas.width 是物理像素,又已经 scale(dpr) 过,等于多清了一大片——本身不报错,但概念是错的,后面做导出、截图就会对不上。
3. 忘了 await 图片。 图片没加载完就 drawImage,真机上就是一块空白,而且不报错,很迷惑。
4. scroll-view 里的画布。 页面用了 scroll-view 承载内容,画布节点一定要在页面 onReady 之后再查,太早查 res[0] 是 undefined。
六、小结
这套代码的价值不在「画了一个圆」,而在于把 Canvas 2D 的通用流程走顺了:
- 节点 → 上下文 → dpr 缩放,是后面所有绘图、图表、海报合成的起点;
- save/restore 成对使用,是多人协作时最容易被删掉的「无用代码」,也是最不该删的;
- drawImage 的尺寸计算,是所有「生成分享海报」需求的必修课。
1万+



被折叠的 条评论
为什么被折叠?



