做微信小程序开发,canvas绘图基本是绕不开的一道坎。这段时间连续处理了好几个“微信小程序canvas绘图不显示、绘制内容错位”的需求,发现这类问题的排查路径非常固定,基本集中在接口选错、初始化时机不对、尺寸换算失误这三个方向上。这篇文章不会把API从头到尾罗列一遍,而是把真实的踩坑过程、排查思路和一套能稳定出图的绘制模板全部分享出来,给正在被canvas折磨的同行一个参考。
我最早接触这块的时候也是一头雾水,同样的代码在网页里跑得好好的,搬到小程序里就成了白屏;画个圆能跑到画布外;做个海报导出后图片边缘被裁掉。后来翻调试工具、查文档改动记录,才逐步理清问题背后的机制。看完这篇,至少你能做到:知道canvas为什么不显示,知道错位是谁造成的,以及拿到一个能直接落地的绘制方案。
1. 现象:不显示和错位到底长什么样
1.1 canvas区域空白不等于没绘制
很多人排查canvas不显示,第一反应是“我的代码没画上去”。但canvas默认背景是透明的,只要没执行fillRect填充,整个区域就是透明的;再加上绘制逻辑可能根本没触发,canvas看起来就是一块和页面底色融为一体的空白。判断到底是“没画”还是“画了但看不见”,有个很简单的办法:先在ctx里第一步就fillRect填充一个白色矩形。如果白色出现了,说明canvas节点本身没问题,问题出在后续绘制逻辑;如果连白色都没有,就要去查节点获取、组件渲染这些前置环节。
这个小技巧帮我省了大量时间,也是我建议所有刚开始用canvas的人先做的事。不夸张地说,八成“空白”问题的答案都藏在这两行代码之前。
1.2 错位的几种典型表现
错位比不显示更隐蔽,表现也更多样:文字跑到预期坐标的右下方、图片被裁剪掉一部分、绘制的内容只占画布左上角一小块、导出的海报和页面预览不一致。还有一种高频场景是:iPhone上正常,换到安卓就偏移;或者真机正常,开发者工具里错位。这类问题多半跟屏幕适配和像素比有关。
错位不是无规律的乱跑,而是存在一种可复现的偏移规律。比如“所有图形都变大了一倍往右下角移”,这背后往往就是scale和translate叠加导致的;比如“文字偏了但矩形在正确位置”,那大概率是字号或baseline的问题;比如“导出图片时边缘被切掉”,就要检查canvasToTempFilePath的坐标参数是不是没转成物理像素。把偏移规律摸清楚,基本就能定位是换算公式的问题还是坐标系设置的问题。
2. 不显示:接口版本、初始化时机和宽高设置
2.1 别混用新旧两套API
微信小程序的canvas接口经历过一次大迭代。早期用wx.createCanvasContext,在canvas标签上写canvas-id,提交绘制时调用ctx.draw();新版采用<canvas type="2d">,通过SelectorQuery获取node节点,再canvas.getContext('2d')得到一个标准的CanvasRenderingContext2D。
这两套API的写法差异很大。旧接口用ctx.setFillStyle('#fff')这种setter式调用,新接口直接ctx.fillStyle = '#fff';旧接口画完必须ctx.draw()才生效,新接口是即时的。如果你在项目里混用两套写法,会出现非常奇妙的bug:比如用旧接口初始化,又用新接口绘制,结果选择器查到node,执行到一半发现方法不存在,页面直接报错;再比如回调里拿到的res是undefined,所有绘制静默失败。
还有一个容易踩的坑:旧接口必须写canvas-id,新接口必须写id。如果在新接口下用了canvas-id,选择器用id依然能选到节点;但如果你在旧接口下只写了id没写canvas-id,wx.createCanvasContext会直接报错。我的建议很简单:新项目一律走type="2d"新接口,老项目迁移时彻底迁移,不要新旧共存。
2.2 绘制时机:onLoad拿不到节点
canvas的渲染时机和普通view不一样。在页面的onLoad钩子里,canvas节点可能还没挂到视图上,此时用wx.createSelectorQuery().select()查询,返回的是空数组,自然画不出任何内容。正确做法是在onReady之后再获取节点,因为onReady代表页面首次渲染完成。如果是自定义组件,则要在组件的ready生命周期里用this.createSelectorQuery()。
还有一个更容易忽略的场景:canvas被放在wx:if或动态显隐的容器里,一开始容器是隐藏状态,显示后立即去绘制,同样可能拿不到正确宽高。这种情况下需要等一个渲染帧,比如在显示后使用wx.nextTick或延迟50ms再初始化,虽然不优雅,但在实测中很稳。我甚至见过有人把canvas放在弹层组件里,弹层动画都还没结束就绘制,等动画执行完canvas区域被动画位移带跑,内容就错位了。
2.3 宽高属性:canvas.width和style.width是两回事
这个问题能解释80%以上的模糊和错位。canvas标签同时存在两套尺寸:
- 标签属性
canvas.width、canvas.height:代表画布实际的像素宽高,决定绘图缓冲区大小。 - 样式
style.width、style.height:代表画布在页面上的显示尺寸。
如果只设置style,不设置canvas.width和canvas.height,画布默认只有300x150的物理像素点,然后被CSS拉伸到显示尺寸,内容会模糊,坐标也容易乱。反过来,只设置canvas.width和canvas.height,却没有style宽高,canvas可能显示为0x0,也就是看不见。很多“canvas不显示”的工单,最后发现canvas标签上压根没有width和height属性。
正确做法是让两者对应起来:显示区域用CSS控制,物理像素用canvas属性控制,两者再通过像素比建立换算关系。新接口里一般先在fields({ node: true, size: true })里让系统返回size,再根据size动态设置canvas.width和canvas.height,这样就能做到完全一致。
3. 错位:rpx、CSS尺寸和像素比
3.1 rpx不能直接当px用
小程序的rpx是响应式单位,750rpx等于屏幕宽度。canvas绘图使用的是CSS像素,也就是px。直接把rpx数值填进fillRect、fillText这类API,在屏幕宽度刚好等于750px的机型上也许能对上,但换一台宽度更小或更大的设备,整个图形就会等比偏移或溢出。
这里给出一个换算函数,建议放到公共工具里:
function rpx2px(rpx) { const { windowWidth } = wx.getSystemInfoSync() return rpx / 750 * windowWidth }比如rpx2px(300),得到的就是当前设备下300rpx对应的px。反过来,如果设计师给的是375px的设计稿,在宽度375的设备上px数值就是设计稿数值,直接作图即可;如果需要在不同屏幕上等比缩放,就要按windowWidth / 375的比例缩放所有坐标。只要想清楚“canvas内部只认px”,就不会再被单位绕晕。
3.2 CSS尺寸与物理像素的“一对多”
前面说canvas.width和style.width不一致会导致模糊,这里把原理讲透。假设style.width为300px,canvas.width也为300时,在像素比为1的屏幕上,一个CSS像素对应一个物理像素,显示完全正常。但很多手机的dpr是2甚至3,一个CSS像素实际占用多个物理像素。如果不把canvas.width设置为物理像素宽度,canvas内部那300个像素点就会被拉伸到物理宽度上,结果是图形边缘发虚、文字不清,严重时坐标偏移。
解决方式很标准:把canvas.width设置为cssWidth * dpr,height同理,然后调用ctx.scale(dpr, dpr)。这样绘图代码里仍然按照CSS像素坐标填写,比如fillRect(0, 0, 300, 400)画的就是整块画布,但底层缓冲区是600x800,缩放后每个逻辑像素被渲染成dpr个物理像素,边缘就清晰了。
这里需要注意,ctx.scale(dpr, dpr)之后,线条宽度也会被放大。如果遇到stroke线条变粗的问题,可以按1/dpr设置lineWidth去补偿;或者在线条绘制时不缩放,手动用物理像素坐标。到底选哪种,取决于项目对清晰度和书写习惯的权衡。
3.3 偏移往往是被scale带偏的
很多人忽略一个点:ctx.scale不只影响大小,还会影响后续所有绘制命令的坐标系。假如你既手动把坐标做了rpx2px换算,又在ctx.scale(2, 2)之后按物理像素填坐标,内容就会偏到右下角,相当于被放大了2倍。
我在排查错位问题时,习惯在绘制之前先重置一下变换矩阵:
ctx.setTransform(1, 0, 0, 1, 0, 0)再把这句作为唯一的前置逻辑:
ctx.scale(dpr, dpr)这样能避免代码路径里残留的translate、rotate影响后续图形。实际项目里,如果先调用ctx.translate做了位移,之后又调用scale,坐标换算会叠加,造成非常难查的偏移。所以在canvas里维护变换上下文,值得形成肌肉记忆。
4. 一套可复用的稳定绘制流程
4.1 页面结构
直接给出一套可以抄的代码。WXML端:
<canvas type="2d" id="posterCanvas" style="width: 340px; height: 480px;"></canvas>注意type="2d"和id,不要写canvas-id。用style而非class有个好处:后续用fields取size时,返回的width和height就是这个样式尺寸,逻辑直观。如果你用rpx做样式,这里也可以写rpx,js里取到的size会是px,同样方便。
4.2 获取画布节点
在onReady之后写一个初始化函数:
initCanvas() { wx.createSelectorQuery() .select('#posterCanvas') .fields({ node: true, size: true }) .exec((res) => { if (!res || !res[0] || !res[0].node) { console.warn('canvas节点获取失败') return } const { node: canvas, width, height } = res[0] const dpr = wx.getSystemInfoSync().pixelRatio canvas.width = width * dpr canvas.height = height * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) this.canvas = canvas this.ctx = ctx this._draw() }) }如果在自定义组件里,把wx.createSelectorQuery()换成this.createSelectorQuery(),其他地方一样。每次页面隐藏再显示,canvas节点不会丢失,但绘制状态可能被重置,所以建议把初始化抽成方法,在onShow或需要重新绘制时调用。
4.3 执行绘制
这一步把所有绘制逻辑集中到一起,注意图片必须等加载完成再画:
_draw() { const ctx = this.ctx const dpr = wx.getSystemInfoSync().pixelRatio ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.scale(dpr, dpr) // 背景 ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, 340, 480) // 标题 ctx.fillStyle = '#333333' ctx.font = 'bold 18px sans-serif' ctx.fillText('这是一段测试文字', 20, 40) // 绘制本地图片,注意要等onload const img = this.canvas.createImage() img.onload = () => { ctx.drawImage(img, 20, 60, 80, 80) } img.src = '/assets/avatar.png' }Canvas 2D节点提供的createImage()和浏览器里的Image类似,但src必须是本地路径或已下载的临时路径,不能直接填外链域名,否则会遇到downloadFile合法域名校验的问题。另外,建议在drawImage之前先绘制背景占位,不然图片加载过程会有短暂闪烁。
4.4 导出成图片
绘制完成后导出,使用wx.canvasToTempFilePath:
exportCanvas() { const dpr = wx.getSystemInfoSync().pixelRatio wx.canvasToTempFilePath({ canvas: this.canvas, x: 0, y: 0, width: 340 * dpr, height: 480 * dpr, destWidth: 340 * dpr, destHeight: 480 * dpr, success(res) { console.log(res.tempFilePath) } }) }这里x、y、width、height用的是canvas物理像素坐标,很多人在导出时填CSS值,导致截取范围不对、出图被裁掉。新接口里,width和height填canvas.width和canvas.height最保险;如果想要更高清的图,destWidth和destHeight可以填更大值。生成海报场景,建议在wx.canvasToTempFilePath的success回调里再调用保存相册的方法。
5. 高频问题排查速查表
我把这段时间整理的排查结论汇总成一张表,方便遇到问题直接对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 全屏空白 | canvas默认透明,没填充背景 | 先执行fillRect铺色,确认是否出白底 |
| 全屏空白 | 选择器没拿到node | 检查type="2d"、id、绘制时机是否在onReady后 |
| 全屏空白 | wx:if容器尚未渲染 | 确保canvas可见后再初始化,必要时nextTick |
| 全屏空白 | 样式宽高为0 | 检查style.width和height是否设置 |
| 内容模糊 | canvas.width等于CSS数值而非物理像素 | canvas.width = cssWidth * dpr |
| 内容错位 | rpx直接当px用 | 用rpx2px换算函数 |
| 内容错位 | scale叠加了translate或residual transform | 绘制前setTransform重置 |
| 图片不显示 | 外链图片未配置downloadFile域名 | 先wx.downloadFile再用临时路径 |
| 图片不显示 | 图片未onload就drawImage | 等onload后再画 |
| 导出被裁 | canvasToTempFilePath传了CSS像素 | 传物理像素canvas.width |
| 新接口报错 | 新旧API混用 | 统一用type="2d"和getContext('2d') |
6. 几个容易被忽略的实践细节
6.1 图片加载的“隐形坑”
绘图里最常出现的问题不是图形,是图片。绘制头像、二维码、海报背景这类需求,图片资源加载是异步的。如果你在ctx.drawImage(img, x, y)之后立刻调用canvasToTempFilePath导出,很可能导出时图片还没画上,结果图片区域是空的。
踩过这个坑之后,我的做法是:不用单个img.onload回调里直接导出,而是把图片是否准备完成做成一个Promise数组,等所有图片onload结束,再进入统一的“绘制+导出”流程。代码结构大概是:
loadImages() { const tasks = [ this.loadImage('/assets/avatar.png'), this.loadImage('/assets/qrcode.png') ] return Promise.all(tasks) }还有一个细节:如果图片来自网络,需要先wx.downloadFile拿到临时路径,再把临时路径赋给img.src;在小程序里直接传https链接给img.src,在某些基础库版本会静默失败。这是典型的“看起来没显示、其实是没加载完”的源头。
6.2 多个canvas共存时的id冲突
一个页面里如果有多个canvas,比如列表页每个卡片都带一个小canvas,id绝对不要重复。SelectorQuery是按id选择的,如果重复,永远选到第一个,绘制错位或内容串台是必然的。
建议每个canvas用动态id:
<canvas type="2d" id="canvas-{{index}}" style="width: 100px; height: 100px;"></canvas>选择时用模板字符串拼接:
wx.createSelectorQuery() .select(`#canvas-${index}`) .fields({ node: true, size: true }) .exec(...)性能上还有一点:多个2d canvas同时活跃时,初始化最好逐串行,避免一次创建太多canvas导致页面掉帧。我实测过一个页面4个canvas的场景,改成串行初始化后,渲染卡顿明显改善。
6.3 隐藏容器里的canvas
canvas放在display:none的弹层里,是非常常见的坑。小程序里display:none的元素不参与布局,SelectorQuery查到的size可能是0x0,此时canvas.width=0,画布自然无效。
解决思路:不要在隐藏状态下初始化canvas,等弹层open事件触发、元素真正渲染后再初始化绘制。如果业务需要预生成海报,可以在页面onReady时先在屏幕外渲染一个canvas位置,绘制完再隐藏。具体做法是把canvas放到不可见的定位容器里,比如left: -9999px,这样元素参与了布局、尺寸有效,用户又看不到,等需要时再导出。这个技巧我用了很久,适配上没有遇到问题。
最后再分享一个小技巧:排查canvas问题的时候,先打开开发者工具,把SelectorQuery返回的res打出来看,确认node、width、height三个字段是否正常。这三个字段对了,大部分问题已经解决一半。canvas在小程序里没那么玄学,绝大多数失败案例都能归结为“建好了画布,却没按它的尺寸去画”或者“画完了,还没等图片加载就导出”。把上面这套模板吃透,再配合速查表逐项排查,基本不会翻车。