这几天一直被微信小程序的 canvas 绘图折腾,一会在开发者工具里画得好好的,真机上一片空白;一会安卓正常、iOS 错位;调了半天发现居然是 canvas 的锅。如果你也被“绘图不显示”“绘制内容错位”这两个问题折磨过,这篇文章应该能帮你省掉几个晚上的排查时间。微信小程序 canvas 绘图不显示、绘制内容错位,绝大多数时候不是你的绘图逻辑写错了,而是 canvas 节点的获取方式、初始化时机、坐标系转换这几环出了问题。这篇文章会从根因讲起,把新旧两套 canvas 接口的差异、rpx 与 px 换算的坑、图片加载时序问题全部拆开,再给出一套可以直接抄作业的标准实现方案,最后附上我实测过程中踩过的坑和排查思路。适合刚接触小程序 canvas 的新手,也适合被兼容性问题折腾到头秃的进阶开发者。
1. 内容整体设计与思路拆解
1.1 为什么会“不显示”:先搞清楚 canvas 的两种活法
微信小程序里 canvas 组件有两条完全不同的技术路线:一条是传统的老式 canvas 接口,拿到的 context 是wx.createCanvasContext(canvasId)创建的,操作方式偏命令式,最后要调用ctx.draw()把指令批量提交上去才会真正渲染;另一条是基础库 2.9.0 之后推荐的 Canvas 2D 接口,通过wx.createSelectorQuery().select('#canvas').fields({ node: true })拿到 canvas 节点,再调用node.getContext('2d')获得一个与 Web Canvas 几乎一致的 context,绘图指令即时生效,不需要再手动调用draw()。
很多“不显示”的案例,根源就在于混用了这两套体系。比如你用新接口去getContext,却还是在代码里调用ctx.draw(),新接口的 context 压根没有这个方法,调用时报错,但报错往往被吞掉了,结果就是画布一片空白。反过来,你用老接口wx.createCanvasContext,却用ctx.fillRect画完不调用ctx.draw(),同样不会有任何内容。
更隐蔽的情况是,两种接口同时出现在页面上。有些项目为了兼容低版本基础库,会在老接口里做降级处理,结果初始化逻辑执行了两遍,后一次初始化把前一次画的内容清掉了。我在实际项目中就遇到过一次,页面里明明显示 canvas 组件,代码逻辑也走了,但就是白屏,最后排查发现是某个公共组件里也初始化了一次同一个 canvasId 的 context,后初始化的 context 覆盖了前面的绘制结果。
所以第一步,你要先确认自己用的是哪一套接口体系。如果项目基础库版本允许,我强烈建议直接用 Canvas 2D 接口,它的行为更贴近 Web 标准,调试起来心智负担小,而且真机兼容性比老接口稳定得多。老接口虽然代码写起来短,但 doc 里很多隐藏的时序坑,真机表现又各不相同,能不用就别用了。
1.2 为什么会“错位”:坐标系乱了,内容自然就飘了
内容错位的本质是:canvas 的实际像素尺寸、CSS 显示尺寸、绘图坐标三者之间没有对齐。小程序里 canvas 组件默认宽高是 300 乘 150 像素,你在 wxml 里写style="width: 375px; height: 300px;",只是拉伸了显示区域,画布的内部像素尺寸还是 300 乘 150。如果你按 375 的宽度去算绘图坐标,画出来的内容自然偏到姥姥家去了。
Canvas 2D 接口下这种情况更明显:node.width和node.height才是画布真正的像素尺寸,必须手动设置成与显示尺寸一致(或者乘以 pixelRatio),否则所有坐标都会按错误的比例缩放。老接口下,wx.createCanvasContext会把 CSS 像素直接当作绘图坐标,但又容易受到 rpx 转 px 不准确的影响,导致不同机型上错位幅度不一样。
错位的另一个高发原因是图片加载时序。ctx.drawImage要求图片必须先加载完成,如果你在image.onload之前就把 drawImage 塞进绘制队列,图片宽度高度都是 0,draw 出来的结果要么不显示,要么显示在错误的位置。很多人在开发者工具里一切正常,因为本地图片加载太快,onload 回调已经在绘制逻辑执行前跑完了,但真机上网络图片异步加载,时序完全乱掉,错位和空白就全冒出来了。
还有一个容易忽略的点:canvas 在页面里如果用了hidden="{{true}}"或者 display:none 隐藏,等再显示的时候,节点虽然还在,但在某些安卓机型上 context 会丢失,后续绘制全部无效。这个问题在“显示/隐藏切来切去”的场景里特别常见,比如弹窗里的海报 canvas。
2. 核心细节解析与实操要点
2.1 初始化的正确姿势:节点 ready 了才能画
canvas 绘制不显示,最常见的一个原因是初始化时机太早。很多人在onLoad里就去createSelectorQuery().select('#canvas').fields({ node: true }),但此时页面还没完成渲染,canvas 节点根本不存在,查到的结果为空,后续代码全部白跑。
正确做法是在onReady里执行初始化,因为 onReady 时页面首次渲染完成,节点已经真实存在。这里有个细节:onReady只是页面渲染完成的回调,并不代表 canvas 内部已经准备好接受绘制指令,所以在查询节点后,最好再包一层wx.nextTick,确保当前渲染帧结束再拿 context,实测下来这样最稳,能减少偶发的空白问题。
初始化代码建议封装成一个 Promise,这样后续绘图逻辑可以链式等待,不用嵌套回调。比如这样写:
function initCanvas(selector) { return new Promise((resolve, reject) => { wx.createSelectorQuery() .select(selector) .fields({ node: true, size: true }) .exec((res) => { if (!res || !res[0] || !res[0].node) { reject(new Error('canvas 节点未找到')); return; } const canvas = res[0].node; const ctx = canvas.getContext('2d'); resolve({ canvas, ctx, info: res[0] }); }); }); }fields({ node: true, size: true })里node是拿画布节点,size是拿宽高信息。这里拿到的宽高是 CSS 像素尺寸,后面设置画布像素尺寸会用到。注意这个查询默认是在当前页面作用域内,如果在组件里用,要加上.in(this)。
2.2 Canvas 2D 的像素设置:别偷懒跳过这一步
拿到 canvas 节点后,必须显式设置canvas.width和canvas.height。Canvas 2D 接口下,canvas 的默认像素尺寸是 300 乘 150,不设置的话,绘图坐标全按这个尺寸来算,而显示尺寸可能是屏幕宽度,错位几乎是必然的。
推荐的设置方式是:
const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = info.width * dpr; canvas.height = info.height * dpr; ctx.scale(dpr, dpr);设置成物理像素并配合ctx.scale(dpr, dpr),是为了让画布在所有机型上都保持清晰,避免字体和线条发虚。如果你不追求高清,直接用 CSS 尺寸也可以,即canvas.width = info.width,但这样在部分安卓机型上会出现轻微模糊,文字边缘有锯齿。追求海报级清晰度的话,建议直接上 dpr 方案。
注意wx.getSystemInfoSync()在较新基础库里返回的pixelRatio是合法的,不需要额外授权。而且info.width是查询节点时返回的显示宽度,因为 wxml 里已经用 rpx 或 px 设置了 canvas 的样式宽度,这个值就是最终布局宽度,直接拿来用即可。
还有一个细节:设置canvas.width会清空画布当前内容。如果你在初始化后又动态改了 canvas 的宽高(比如横竖屏切换),之前画的内容会全部消失,需要重新走一遍绘制流程。这个行为跟 Web 端 canvas 一致,但小程序里很多人会忽略,导致切换后白屏,排查时容易绕弯路。
2.3 老接口的坑与补救:把你手头的旧代码救回来
如果你的项目还在用老接口wx.createCanvasContext,也不是不能救,但有几个点必须注意。
老接口通过wx.createCanvasContext(canvasId, this)创建,创建后调用ctx.draw()批量提交绘制指令。一个非常容易踩的坑是:ctx.draw()是异步的,第二个参数可以传回调,如果不传,绘制完成后没有任何通知,你紧接着去做导出图片或测量文本宽度的操作,拿到的结果都是错的。
老接口下文本测量要这样处理:
const ctx = wx.createCanvasContext('myCanvas'); ctx.setFontSize(16); ctx.fillText('测试文字', 10, 10); ctx.draw(false, () => { // 等绘制完成后再做后续操作 console.log('绘制完成'); });draw的第一个参数reserve,传true表示不清空画布继续叠加绘制,传false表示每次重绘前先清空。很多人用reserve做动画帧绘制,结果越画越花,就是这个参数理解错了。
老接口在 rpx 适配上有天然缺陷:它的绘图坐标直接就是 CSS 像素,而 CSS 像素在小程序里可以通过wx.upx2px()或wx.getSystemInfo().windowWidth换算,但换算过程会丢失精度,在宽屏和窄屏机型上错位幅度不同。我测下来,老接口 +wx.upx2px在 iPhone 和某些安卓机上,同一个坐标点会偏差 1-2px,虽然不算大,但做精细绘图(比如海报上的分割线)就很明显。
所以我的建议是:新项目直接用 Canvas 2D,旧项目如果要继续用老接口,尽量把坐标换算逻辑统一封装,避免每个页面各写一套换算逻辑,后面维护起来全是坑。
2.4 图片加载时序:一个封装函数解决百分之八十的错位
图片不显示、或者显示位置不对,绝大多数情况都是因为drawImage执行时图片根本没加载完。很多教程会让你直接用wx.getImageInfo,但getImageInfo本身也有坑:它拿到的图片路径如果是本地临时路径还好,如果是网络图片,在部分安卓机型上返回的宽高会延迟,或者直接不触发 success 回调。
我在项目里用过最稳的方案,是直接用 Canvas 2D 自带的图片对象:
function loadImage(url) { return new Promise((resolve, reject) => { const img = wx.createImage(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('图片加载失败: ' + url)); img.src = url; }); }wx.createImage()是 Canvas 2D 环境下用来创建图片对象的 API,它创建的图片对象可以直接传给ctx.drawImage,而且在 onload 回调里img.width和img.height一定是准确的。配合 Promise 后,你可以这样串起整个绘制流程:
async function drawPoster() { const { canvas, ctx, info } = await initCanvas('#poster'); canvas.width = info.width * dpr; canvas.height = info.height * dpr; ctx.scale(dpr, dpr); const bg = await loadImage('/images/bg.png'); const avatar = await loadImage('/images/avatar.png'); ctx.drawImage(bg, 0, 0, info.width, info.height); ctx.drawImage(avatar, 20, 20, 80, 80); }这里要注意:await loadImage()时页面最好有 loading 提示,不然用户看到的是白屏,体验很差。而且网络图片的域名必须在小程序后台配置到 downloadFile 合法域名里,否则 onerror 会触发,这在之前排查时也是一大坑,画了半天发现是域名没配白名单。
用drawImage(img, dx, dy, dWidth, dHeight)这组参数时,dWidth和dHeight一定要传,不传的话,部分机型上图片会按原始尺寸绘制,直接超出画布边界,看起来就像内容错位到屏幕外了。
3. 实操过程与核心环节实现
3.1 标准可复现方案:一个生成海报的完整案例
前面讲了这么多原理和细节,这一节我把一套完整的、踩过坑之后沉淀下来的标准方案写出来。这个方案我称之为“三件套”:初始化封装、图片加载封装、绘制流程编排。按照这个结构写,后续扩展任何绘图需求都很顺手,不会每次都在时序上返工。
先看 wxml 部分:
<view class="poster-wrap"> <canvas id="posterCanvas" class="poster-canvas" type="2d" style="width: 300px; height: 400px;" ></canvas> </view>关键点:type="2d"必须写上,这是启用 Canvas 2D 接口的开关。style 里的宽高可以用 rpx,但建议在 js 里通过查询节点拿具体 px 值,避免手动换算。canvas 不要加hidden属性,宁可放在屏幕外或者用 opacity 隐藏,也不要直接隐藏节点,原因前面说过,隐藏再显示 context 会丢。
然后是 js 部分:
Page({ data: {}, onReady() { this.initAndDraw(); }, async initAndDraw() { try { const { canvas, ctx, info } = await this.initCanvas('#posterCanvas'); const dpr = wx.getSystemInfoSync().pixelRatio; canvas.width = info.width * dpr; canvas.height = info.height * dpr; ctx.scale(dpr, dpr); this.canvas = canvas; this.ctx = ctx; this.info = info; await this.drawPoster(ctx, info); } catch (err) { console.error('海报绘制失败', err); } }, initCanvas(selector) { return new Promise((resolve, reject) => { wx.createSelectorQuery() .select(selector) .fields({ node: true, size: true }) .exec((res) => { if (!res || !res[0] || !res[0].node) { reject(new Error('canvas 节点未找到')); return; } resolve({ canvas: res[0].node, ctx: res[0].node.getContext('2d'), info: res[0], }); }); }); }, async drawPoster(ctx, info) { const dpr = wx.getSystemInfoSync().pixelRatio; const width = info.width; const height = info.height; // 绘制背景 ctx.fillStyle = '#f5f5f5'; ctx.fillRect(0, 0, width, height); // 绘制标题 ctx.fillStyle = '#333333'; ctx.font = `bold ${Math.round(20 * dpr)}px sans-serif`; ctx.textBaseline = 'top'; ctx.fillText('项目标题示例', 20, 20); // 绘制图片 const img = await loadImage('/images/banner.png'); ctx.drawImage(img, 0, 80, width, 200); // 绘制用户信息 const avatar = await loadImage('/images/avatar.png'); ctx.save(); ctx.beginPath(); ctx.arc(40, 300, 25, 0, Math.PI * 2); ctx.clip(); ctx.drawImage(avatar, 15, 275, 50, 50); ctx.restore(); }, });3.2 关键操作背后的原因:为什么每一步都不能省
上面的代码里,有几个步骤容易被当成“多余操作”删掉,我逐一解释为什么不能省。
第一,canvas.width = info.width * dpr和ctx.scale(dpr, dpr)。这里有一个容易混淆的点:ctx.scale会影响后续所有绘图坐标。比如画布物理尺寸是 600 乘 800(375 显示宽度乘 2 倍 dpr),执行ctx.scale(2, 2)后,你仍然用 375 乘 800 的逻辑坐标去画,绘制引擎会自动放大两倍。这样做的好处是,你在写坐标时不用在脑内进行像素换算,所有数字跟设计稿上的 px 值一致。很多人错位,就是因为少写了这一步,然后又在每一处坐标手动乘以 dpr,结果图片绘制时传的宽高参数跟画布实际尺寸对不上。
第二,ctx.font = 'bold 20px sans-serif'。Canvas 2D 接口下,字号必须写在 font 属性里,写成ctx.fontSize = 20是无效的。而且这个字号是逻辑像素,在 dpr 缩放场景下,字体大小 20 最终会以 40 物理像素渲染,所以你在设计稿里看到 20px 的文字,就写 20,不要自行乘以 dpr。如果你不设置 font,默认字号是 10px,画出来文字小到你怀疑人生。
第三,ctx.textBaseline = 'top'。这个属性控制文字在垂直方向上的对齐基准。默认是alphabetic,文字底部会对齐到 y 坐标,看上去文字位置偏下。做海报时通常希望文字顶部对准坐标,设成top最直观。同理,水平对齐用ctx.textAlign,默认start,在大部分场景够用,但如果你做居中对齐,记得显式设置,不然文字位置会偏移。
第四,头像裁剪那段用了ctx.save()、ctx.beginPath()、ctx.clip()、ctx.restore()。clip会把后续绘制限制在圆形路径内,save和restore把 clip 的影响隔离起来,避免影响后面其他内容的绘制。这个写法参考的是 Web Canvas 的标准做法,小程序 Canvas 2D 完全兼容。如果不做save/restore,裁剪效果会一直生效,后面画什么都只剩一个圆里能看到。
第五,await loadImage()分批加载。有人会想“我直接在绘制前把所有图片用wx.preloadImage预加载不就行了”,实际上小程序没有preloadImageAPI,wx.getImageInfo虽然能缓存图片,但缓存的是网络请求,不是图片解码结果。用wx.createImage+ Promise 串联,是保证图片在绘制的瞬间处于可用状态的最稳妥方式。
3.3 自适应布局的坐标计算:告别硬编码的错位
如果你的海报要在不同尺寸机型上展示,硬编码坐标会带来大量错位问题。常见做法是以设计稿宽度为基准,按实际宽度等比缩放。
假设设计稿宽度是 375px,你的代码里所有坐标都是按 375 宽度写的。真机实际宽度是 390px 或 320px,换算公式是:
function scaleValue(value, designWidth = 375) { const screenWidth = wx.getSystemInfoSync().windowWidth; return (value / designWidth) * screenWidth; }然后所有坐标都通过scaleValue(20)这样取。这种方式在宽度变化不大的机型上效果很好,但如果宽度差异过大(比如平板和手机),按宽度等比缩放会导致纵向布局拉伸过度。更精细的方案是用min函数同时参考宽高比缩放,但海报场景通常就是宽度适配,高度要么固定,要么超出部分裁切,用比例缩放即可。
我实际项目里还会在 JS 里统一维护一份“设计稿坐标模型”,用对象描述每个元素的类型、位置、尺寸,然后用一个绘制函数去遍历执行。这样做的好处是,后续如果要调整海报布局,只改配置对象,不用动绘制函数。适合内容较多的海报,但如果你只是快速画个简单图,直接按上文示例硬编码坐标更快,不用过度设计。
3.4 导出图片时的陷阱:画得出来,导不出来
绘制完成后的下一步通常是生成图片分享给好友,这里也有两个容易踩的坑。
第一个是导出途径差异。Canvas 2D 的canvas.createImage()配合canvas.toTempFilePath可以直接把画布内容导出为临时文件,这个 API 与老接口的wx.canvasToTempFilePath行为略有不同。Canvas 2D 下的导出要在canvas节点上调用:
wx.canvasToTempFilePath({ canvas, x: 0, y: 0, width: info.width * dpr, height: info.height * dpr, destWidth: info.width * dpr, destHeight: info.height * dpr, success(res) { console.log('导出成功', res.tempFilePath); }, });注意width和height是画布物理像素尺寸,如果你设置了 dpr,这里必须乘以 dpr,否则导出图片只有画布左上角一小块区域。destWidth和destHeight决定输出图片的像素大小,一般与物理尺寸一致就行。
第二个坑更隐蔽:如果你在drawPoster里 await 了图片加载,但导出时没有等待整个绘制流程结束,导出的临时文件可能是一张空白图。最简单的做法是把导出逻辑放在drawPoster的最后一个 await 之后,或者在 Promise 化的绘制流程外面包一层 then。我习惯在页面级别维护一个drawingPromise:
this.drawingPromise = this.drawPoster(ctx, info); // 用户点击导出按钮时 await this.drawingPromise; wx.canvasToTempFilePath({ ... });这样能保证任意时刻点击导出,拿到的都是已经绘制完整的画布,不会出现“按钮点了但图片是白的”这种状态。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
把我在各种项目里遇到的“canvas 不显示/错位”问题整理成一张速查表,你可以照着逐项排查。这几个问题基本上覆盖了九成以上的故障场景。
| 现象 | 最常见原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 整块画布白屏 | 未设置 canvas 像素尺寸 | 检查canvas.width/height是否为 300/150 | 显式设置像素尺寸 |
| 整块画布白屏 | 初始化时机早于节点 ready | 在 onReady 后再查询节点 | 封装 Promise 并等待节点 |
| 画布只有一部分内容 | 忘记 dpr 缩放 | 检查是否有ctx.scale(dpr, dpr) | 设置 Canvas 2D 像素尺寸并缩放 |
| 图片不显示 | drawImage 时图片未加载 | 打印 img.width 是否为 0 | 用 wx.createImage + onload 等待 |
| 图片位置偏移 | 用了 rpx 做坐标 | 检查坐标换算方式 | 统一用 px 或查询节点后的宽高 |
| 文字太小或太大 | 字号单位与物理像素混淆 | 检查 ctx.font 设置 | font 里的字号按逻辑像素写 |
| 已有绘制被清空 | 重新设置 canvas.width/height | 检查是否有二次初始化 | 只初始化一次,不在绘制中改尺寸 |
| 真机空白、工具正常 | 网络图片域名未配置 | 查看 onerror 日志 | 配置合法域名 |
| 裁剪后其他内容消失 | clip 未用 save/restore 包裹 | 检查绘制的 ctx 状态 | save 后再 clip,最后 restore |
| 导出图片空白 | 导出时机早于绘制完成 | 检查导出是否等待绘制流程 | 用 drawingPromise 串行化 |
4.2 排查实录:花了三个小时找到的错位原因
分享一个具体的排查过程,这种问题日志很难定位,得靠推理和分段测试。
某个跨平台项目里,海报绘制在安卓上一切正常,但 iPhone 上图片位置整体右移且偏下,文字倒是位置正常。第一个直觉是 rpx 换算问题,但文字和图片用的是同一套坐标模型,文字不偏图片偏,这就不太对了。后来在 iPhone 上打印img.width和img.height,发现本地图片的宽度值跟设计稿一致,但ctx.drawImage(img, 0, 0, width, height)之后,图片右侧明显超出画布。
进一步排查发现,问题出在loadImage的封装上:我用wx.createImage()创建图片对象后,img.src传的是本地路径/images/banner.png,iOS 上本地路径解析后变成了一个绝对路径,图片实际尺寸被解析成 750 乘 500(即逻辑像素的 2 倍)。而我在 drawImage 时传了目标尺寸width和height,这个width是 CSS 像素宽度,在 iOS 上绘制引擎用了物理像素去解释目标尺寸,导致图片整体放大两倍,视觉上就像错位与溢出。
解决方案是在drawImage前统一用ctx.save(),然后按1 / dpr缩放回逻辑坐标,或者干脆把 drawImage 的目标尺寸也乘以 dpr。最后我选择了在loadImage返回的图片对象上做归一化:
function loadImage(url) { return new Promise((resolve, reject) => { const img = wx.createImage(); img.onload = () => { // 确保图片按设计稿像素尺寸理解 resolve(img); }; img.onerror = () => reject(new Error('图片加载失败')); img.src = url; }); }然后在绘制前,对图片做一次“尺寸归一化”逻辑:
async function drawImageScaled(ctx, url, dx, dy, dWidth, dHeight) { const img = await loadImage(url); const dpr = wx.getSystemInfoSync().pixelRatio; // 图片对象 src 是本地路径时,宽高可能是物理像素,需要按 dpr 归一化 const logicalWidth = img.width / dpr; const logicalHeight = img.height / dpr; ctx.drawImage( img, dx, dy, logicalWidth, logicalHeight, dx, dy, dWidth, dHeight ); }这里用了drawImage的 9 参数版本,前四个参数是源图像里截取的区域,后四个是目标绘图区域。通过把源区域按逻辑尺寸归一化,再绘制到目标区域,就能保证不同机型上图片的位置和大小完全一致。
这个案例给我的启发是:canvas 错位问题往往不是单一原因,而是多个因素的叠加。排查时不要猜,直接在关键节点打印相关数值,对照设计稿逐步确认坐标、尺寸、像素比每一项是否对齐,比空想要高效得多。
4.3 三个独门避坑技巧
第一个技巧:在开发阶段把 canvas 节点添加一个纯色背景色,比如background: red。如果你看到 canvas 区域显示红色,说明节点本身是存在的,问题出在绘制逻辑;如果连红色都看不到,说明 canvas 的层级被遮挡或者节点尺寸为 0,问题出在布局和样式。这个二分定位法能帮你快速缩小问题范围,是排查 canvas 白屏的第一板斧。
第二个技巧:把 canvas 相关日志封装成一个debugDrawInfo函数,每次绘制前打印画布尺寸、像素比、当前上下文状态。你真机上调试不方便,但通过vConsole可以看到这些输出,能快速发现“context 状态被上一次绘制污染”的问题。我遇到过ctx.globalAlpha忘了重置,导致后续所有内容半透明,排查了半小时,最后靠打印排查才发现是单次绘制没有 restore 状态。
第三个技巧:在真机调试时,先把涉及canvas的样式简化到最小。比如先删除所有位置、圆角、阴影相关的样式,只保留宽高和背景色,确认绘图正常后再逐步加回样式。这样做可以快速定位“错位是布局引起的还是绘图坐标引起的”。我见过不少项目,把 canvas 放在position: fixed容器里,结果容器自身的transform平移影响了 canvas 的内部坐标映射,样式越复杂越难查。
4.4 组件与自定义 tabBar 场景的额外注意事项
如果你的 canvas 不是放在页面根部,而是嵌在组件里,或者页面用了自定义 tabBar,需要注意查询节点的作用域问题。
组件内初始化 canvas 时,wx.createSelectorQuery()必须改为this.createSelectorQuery(),并且查询后要用.in(this)限定组件作用域,否则查不到节点。这是一个很容易被忽略的差异,页面环境下不写.in(this)也能查到,但组件环境下必坑。
自定义 tabBar 场景下,如果 tabBar 上有一个需要动态生成的图标或角标,处理 canvas 的方式最好和普通页面一致,不要直接在 tabBar 组件里写多块 canvas。原因是 tabBar 的显示频率高,canvas 频繁切换显示状态,会碰到前面说的“隐藏后 context 丢失”问题。我在一个项目里踩过这个坑,最后方案是在 tabBar 页面底部用一个固定的透明 canvas,动态内容更新时重新绘制,而不是创建多个 canvas 实例切换。这样实例始终存在,绘制状态稳定,性能和稳定性都更好。
还有,如果你的页面里同时存在多个 canvas,每个 canvas 节点的id必须唯一,createSelectorQuery会根据id查找。如果多个 canvas 用了同一个 id,后创建的节点会覆盖前者的上下文绑定,结果就是两个画布互相干扰,一会这个不显示一会那个不显示。这个问题在动态渲染列表的场景里尤其常见,建议指定 canvas 的id时带上索引,或者直接用唯一字符串。
4.5 不同基础库版本的兼容性
最后提一下基础库版本对 canvas 行为的影响。Canvas 2D 接口从基础库 2.9.0 开始支持,但一直到 2.19.0 左右才比较成熟稳定。如果你的用户群体里还有很多低版本基础库,建议在代码里做一次能力判断:
if (wx.createSelectorQuery().select('#canvas').fields) { // 支持 Canvas 2D,走新接口 } else { // 降级走 wx.createCanvasContext 老接口 }但说实话,双接口维护成本很高,两套逻辑的坐标体系、绘制时机都不一致,测试工作量是翻倍的。我的建议是:明确业务用户的基础库版本分布,如果绝大多数用户基础库都在 2.9.0 以上,就只维护 Canvas 2D 一套逻辑省心;如果确实需要兼容老版本,就在能力判断后把绘制逻辑拆成两个文件,每个文件内部自成体系,不要在一个函数里穿插混用两套 API。
从基础库 2.30 开始,微信小程序对 canvas 的错误提示也变得更清晰了,比如context不存在时会直接报错而不是静默失败,这对排查问题帮助很大。如果你用的基础库比较新,留意一下控制台输出的报错信息,很多问题其实都写在了报错里,只不过以前被吞掉了。
最后再分享一个经验
我之前给一个同事排查了很久的 canvas 不显示问题,他的代码逻辑完全正确,最后发现是 wxml 里写了hidden="{{!showCanvas}}",而showCanvas在初始数据里是false,导致 canvas 节点在第一次渲染时就被隐藏了。后面虽然把showCanvas改成true,但部分机型上重新显示的 canvas 无法触发初始化,画布永远是空白。解决办法是页面加载时始终让 canvas 处于显示状态,通过覆盖层或透明度控制视觉上的隐藏,而不是直接在节点上使用 hidden 属性。这个经验后来帮我在不少项目里少走了弯路。
canvas 绘图不显示和错位的问题,说到底就是“节点、时机、尺寸、坐标系”这四件事。把每一件都对照检查一遍,大部分问题都能解决。如果你按照这篇文章里的方案做了一遍仍然有问题,建议在真机上用vConsole把 canvas 的宽、高、像素比、图片加载状态全部打出来,对照设计稿逐项比对,很快就能定位到具体环节。祝大家都能顺利画出满意的内容。