2026/9/9 5:54:33

微信小程序canvas 2d海报生成实战:绘制、导出与性能优化

微信小程序canvas 2d海报生成实战:绘制、导出与性能优化 简介针对微信小程序开发者制作分享海报的常见痛点这份组件资源基于新版canvas2d接口实现。老版canvas官方已停止维护新版文档零散且上手门槛高组件采用高效绘制方式性能优于普通canvas并参考淘宝、京东、拼多多等主流电商的分享样式封装而成。包体为4个文件的精简结构含js逻辑、wxml结构、wxss样式与json配置合计仅4KB便于直接引入项目或二次改造。目前已有605人学习下载。开发者只需传入参数即可快速生成分享海报无需关心底层复杂坐标转换与绘制细节也支持按自身品牌风格调整尺寸、颜色和文案排版灵活适配多种裂变分享需求尤其适合需要用canvas2d重构海报能力的初级及进阶开发者。 前两天刚帮一个做电商的朋友把项目里的海报分享功能从老版 canvas 迁移到 canvas 2d正好赶上平台对旧接口的收紧算是踩了一路坑也把新版 canvas 2d 的完整用法摸了个透。这个能力做电商小程序的同学应该都不陌生——淘宝、京东、拼多多的小程序里那个生成海报分享给好友的功能本质都是先把商品图、价格、二维码画到一张 canvas 上再导出成图片让用户保存或转发。这篇文章不聊虚的直接把新版 canvas 2d 从画布初始化、单位换算、图片绘制到导出保存的全流程走一遍把我实测过的代码、踩过的坑、优化的思路都放出来。如果你正要实现类似的海报分享功能或者想把老项目里的 canvas 接口升级到 canvas 2d这篇应该能帮你少走不少弯路。1. 旧版 canvas 接口为什么必须淘汰canvas 2d 的底层差异先说一个很多人没搞明白的事微信小程序里一直有两套 canvas 接口。一套是旧版的canvas canvas-idxxx配合wx.createCanvasContext使用另一套是新版的canvas type2d idxxx配合wx.createSelectorQuery拿到节点后通过节点上的canvas对象获取 2d 上下文。那为什么说旧版该淘汰了最直接的原因是性能和清晰度。旧版 canvas 走的是 WebView 里的 canvas 实现绘制复杂海报时容易出现内存占用偏高、图片发糊的问题尤其在高分屏上导出图片的清晰度经常达不到运营要求。而 canvas 2d 是原生渲染绘制效率和出图清晰度都有明显提升这也是大厂小程序都在迁移的根本原因。另一个关键点是接口的维护方向。基础库 2.9.0 开始微信官方正式推荐使用 canvas 2d旧版接口虽然还能用但新功能基本不再往那边加后续的兼容性风险会越来越大。如果你现在还在新项目里用wx.createCanvasContext我的建议是早点改别等项目上线了再被迫迁移到时候改造成本更高。从 API 层面看canvas 2d 的绘图 API 和 Web 标准 Canvas API 基本对齐fillRect、drawImage、fillText、measureText这些方法名你都认识。这意味着如果你之前写过 H5 的 canvas 代码迁移成本很低大部分绘图逻辑可以直接复用只是初始化画布的姿势不太一样。2. 搭建画布节点获取、尺寸计算与单位换算2.1 WXML 里怎么声明画布节点新版 canvas 2d 在 WXML 里的声明方式很关键有一个必须注意的属性type2d。漏掉这个属性后面通过SelectorQuery查到的节点结构就不对拿不到canvas对象。canvas type2d idposterCanvas classposter-canvas/canvas还有个细节我在第一次写的时候也踩了canvas 的样式尺寸会直接影响绘制区域的尺寸。如果你在 WXSS 里不给 canvas 设置宽高它默认是 300px × 150px绘制内容会溢出看不到。所以需要显式设置宽高同时把定位设为 fixed 并移到屏幕外避免生成过程中画布闪现影响体验。.poster-canvas { width: 750rpx; height: 1334rpx; position: fixed; left: -9999rpx; top: 0; z-index: -1; }2.2 节点查询与画布初始化的正确姿势拿到 canvas 节点的代码大概是这样的initCanvas() { const query wx.createSelectorQuery() query.select(#posterCanvas) .fields({ node: true, size: true }) .exec((res) { if (!res[0]) { console.error(canvas 节点未找到) return } 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 }) }这段代码里有几个点要重点解释。首先是.fields({ node: true, size: true })node是获取 canvas 节点实例size是拿节点布局尺寸两者都要写缺一个后面都不好办。其次是canvas.width res[0].width * dpr这是 canvas 2d 和旧版最大的区别之一canvas 元素自身有像素宽度WXML 里的 CSS 尺寸是逻辑像素两者不一致时必须用设备像素比 dpr 去缩放否则绘制出来的图会发虚。2.3 rpx 转 px整个绘制过程最绕的一环小程序里我们习惯用 rpx 做响应式布局但 canvas 2d 的绘图坐标用的是物理像素所以画图之前必须把设计稿上的 rpx 数值转成 px。我的做法是在页面顶部定义一个全局的换算系数const sysInfo wx.getWindowInfo() const pxRatio sysInfo.windowWidth / 750 // windowWidth 是 px750 是设计稿基准宽度 const rpx2px (rpx) rpx * pxRatio之后所有绘制坐标和尺寸统一用rpx2px()包一层。比如画一个宽 690rpx 的海报主体代码里就是rpx2px(690)。这么做的好处是一套代码在不同屏幕宽度的机型上表现一致不会出现在 iPhone 15 Pro Max 上正常、在老旧安卓机上错位的尴尬情况。3. 核心绘制流程从背景图到二维码的完整 demo3.1 绘制前必须做的数据准备画海报之前我习惯先把所有需要的数据整理成一个对象再交给绘制函数处理。这样做的好处是绘制逻辑只关心怎么画不关心画什么数据和渲染层分离后期改版只需要改数据组装逻辑。一份典型的海报数据大概是这样的背景图、商品主图、商品标题、价格文本、促销标签、二维码图片地址。这里最麻烦的是图片资源。canvas 2d 直接画网络图片地址是不行的必须先把网络图片下载成本地临时文件这一块下面单独讲。3.2 绘制函数的主体结构下面这个函数是我项目里一直在用的精简版覆盖了背景、商品图、文本、二维码这几个核心元素async drawPoster(data) { const { ctx, canvas } this const W rpx2px(750) const H rpx2px(1334) ctx.clearRect(0, 0, W, H) // 1. 画背景色 ctx.fillStyle #ffffff ctx.fillRect(0, 0, W, H) // 2. 画背景图网络图片需先下载 const bgImg await this.loadImage(data.bgImage) ctx.drawImage(bgImg, 0, 0, W, H) // 3. 画商品主图居中裁剪 const mainImg await this.loadImage(data.mainImage) const mainW rpx2px(690) const mainH rpx2px(690) const mainX rpx2px(30) const mainY rpx2px(30) ctx.drawImage(mainImg, mainX, mainY, mainW, mainH) // 4. 画商品标题两行超出省略 ctx.fillStyle #333333 ctx.font normal ${rpx2px(30)}px sans-serif const titleLines this.truncateText(data.title, rpx2px(640)) titleLines.slice(0, 2).forEach((line, i) { ctx.fillText(line, rpx2px(30), rpx2px(760) i * rpx2px(44)) }) // 5. 画价格 ctx.fillStyle #ff3b30 ctx.font bold ${rpx2px(36)}px sans-serif ctx.fillText(¥${data.price}, rpx2px(30), rpx2px(850)) // 6. 画二维码 const qrImg await this.loadImage(data.qrCode) const qrSize rpx2px(160) ctx.drawImage(qrImg, rpx2px(530), rpx2px(850), qrSize, qrSize) // 7. 导出图片 const tempFilePath await this.canvasToTempFilePath() return tempFilePath }3.3 网络图片加载drawImage 的拦路虎loadImage是这个方案里绕不开的一个函数。canvas 2d 的drawImage不认网络地址只认本地路径。所以在每次绘制前必须先通过wx.getImageInfo或wx.downloadFile把图片下载到本地拿到临时文件路径后再用canvas.createImage()创建图片对象。我踩过最大的坑就在这一步wx.downloadFile拿到的临时路径不能直接用new Image()去加载canvas 2d 里图片对象必须是canvas.createImage()创建的。loadImage(url) { return new Promise((resolve, reject) { wx.getImageInfo({ src: url, success: (res) { const img this.canvas.createImage() img.onload () resolve(img) img.onerror reject img.src res.path }, fail: reject }) }) }这里用wx.getImageInfo而不是wx.downloadFile有个额外好处getImageInfo自带缓存机制同一个图片地址第二次访问时直接走缓存不用重新下载对海报这种频繁生成多次的场景优化效果很明显。3.4 导出图片canvasToTempFilePath 的参数陷阱绘制完成后导出图片用的 API 是wx.canvasToTempFilePath。在 canvas 2d 模式里这个 API 的调用方式和旧版不太一样必须把 canvas 对象作为参数传进去canvasToTempFilePath() { return new Promise((resolve, reject) { wx.canvasToTempFilePath({ canvas: this.canvas, success: (res) resolve(res.tempFilePath), fail: reject }) }) }注意canvas参数直接传节点实例不要传 canvas-id。另外导出图片默认格式是 png如果需要 jpg可以在参数里加fileType: jpg和quality: 1能够显著减小图片体积方便用户分享到聊天会话。4. 实战中一定会踩的坑文字排版与图片适配4.1 中文字体宽度计算与手动换行canvas 的fillText不会自动换行英文单词还能靠空格勉强断行中文文本如果不手动处理超长标题就会直接画出边界。这个问题不做处理的话测试时随便填几个字看不出来一旦上了真实商品数据标题多几个字就露馅。我的做法是先测量字符串宽度再按宽度截断。核心逻辑是用ctx.measureText逐字累加宽度超过最大宽度时手动插入换行truncateText(text, maxWidth) { const lines [] let currentLine for (let i 0; i text.length; i) { const char text[i] const testLine currentLine char const testWidth this.ctx.measureText(testLine).width if (testWidth maxWidth currentLine) { lines.push(currentLine) currentLine char } else { currentLine testLine } } if (currentLine) { lines.push(currentLine) } return lines }这个方法的时间复杂度是 O(n²)因为每次加一个字都重新 measure 整行。但海报标题一般不超过 50 个字实际性能影响可以忽略。如果遇到超长文案需要频繁绘制可以先在循环外把每个字符的宽度测好存进数组再按数组累加这样能优化成 O(n)但一般场景没必要这么较真。4.2 drawImage 的九宫格裁剪商品图比例不统一的解法电商海报的商品主图不一定是正方形有的是 1:1有的是 3:4硬塞进 690rpx × 690rpx 的方形容器里图片会被拉伸变形。这个问题我在第一次做的时候被运营怼过商品图看起来扁了。解决方案是覆盖模式object-fit: cover 的效果。先计算图片原始宽高和目标容器的宽高比按比例缩放后裁剪超出部分drawImageCover(img, dx, dy, dw, dh) { const iw img.width const ih img.height const scale Math.max(dw / iw, dh / ih) const sw dw / scale const sh dh / scale const sx (iw - sw) / 2 const sy (ih - sh) / 2 this.ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh) }drawImage 接收 9 个参数时前 4 个代表从源图片的哪个位置取多大的区域后 4 个代表绘制到目标区域的什么位置和尺寸。这里sx、sy取图片中心点sw、sh取按比例缩放后的宽高实现的效果就是居中裁剪和 CSS 的background-size: cover一模一样。4.3 离屏 Canvas 与多层绘制的性能考量如果海报上需要叠加的图片很多比如背景图、商品图、头像、二维码、优惠券角标一次drawImage接一次drawImage其实没问题但要注意避免在循环里反复查询节点或重复创建图片对象。另一个优化思路是用离屏 Canvas。把静态不变的层比如背景图 品牌 logo先在内存里的一个 canvas 上画好生成海报时直接把这张合成好的图 drawImage 过去可以减少主 canvas 的绘制次数。对性能要求高的场景这个收益非常明显尤其是一些配置比较低的安卓机绘制时间能缩短三分之一以上。5. 保存到相册与分享链路的权限细节5.1 saveImageToPhotosAlbum 的授权处理图片生成之后常规操作是弹窗让用户保存到相册。这个接口需要scope.writePhotosAlbum权限第一次调用会自动弹出授权框但用户一旦拒绝过之后调用会直接走 fail而且不再弹框。这也是很多人在热词里搜保存图片 fail的原因。正确做法是fail 回调里判断错误信息如果是权限拒绝就引导用户去设置页手动开启。我在项目里封装了一个通用的保存函数savePosterToAlbum(tempFilePath) { wx.saveImageToPhotosAlbum({ filePath: tempFilePath, success: () { wx.showToast({ title: 保存成功, icon: success }) }, fail: (err) { if (err.errMsg.includes(auth deny) || err.errMsg.includes(authorize)) { wx.showModal({ title: 提示, content: 需要您授权保存图片到相册, confirmText: 去授权, success: (res) { if (res.confirm) { wx.openSetting() } } }) } } }) }5.2 分享朋友圈与转发卡片的海报尺寸要求如果海报是给用户转发到朋友圈用的长宽比需要额外注意。朋友圈分享图片没有硬性尺寸限制但太长的图会被压缩得很厉害观感很差。实测下来750rpx × 1334rpx约 5:8.9这个比例在朋友圈里的展示效果比较理想不会显得太长也不会太方。转发卡片场景则不同onShareAppMessage里的imageUrl官方建议尺寸是 5:4不是你生成的海报原图。所以如果同时支持朋友圈和转发卡片通常需要生成两张图一张 5:8.9 的全幅海报一张 5:4 的方形卡片图。我见过不少团队在这块图省事直接用同一张图结果转发卡片里商品信息被截掉一半转化率上不去。5.3 临时文件并不是终点上传 CDN 的必要性wx.canvasToTempFilePath生成的临时文件路径有效期很短用户退出小程序后就失效了。如果后续有用户保存海报到相册后点击相册图片跳转小程序这种分享回流需求就得把临时文件上传到自己的 CDN换一个永久链接用于分享出去的落地页或朋友圈配文跳转。上传流程就是先wx.uploadFile把临时文件传到服务端服务端再转存到对象存储返回一个正式的 https 地址。这个地址后续可以放进生成二维码的数据里实现扫码识别用户身份、追踪分享链路的效果。我这边的做法是服务端用一个专门的上传接口接收图片流返回的 URL 再拼上参数作为二维码跳转地址整体链路就闭环了。6. 进阶优化Canvas 绘制性能与代码组织6.1 控制重绘频率与图片缓存海报生成是一个异步且相对耗时的操作如果用户反复点击生成海报按钮理论上会有多次绘制流程并发执行到头来后一次的结果可能被前一次覆盖。为了避免这个问题我做了两件事生成期间用 loading 态锁住按钮同一个页面实例上维护一个generating标记不满足条件直接 return。绘制完之后再把标记置空保证同一时刻只有一个生成任务。图片缓存方面wx.getImageInfo自带缓存机制是个好东西但它缓存在微信内部不受我们控制。如果海报里的商品图和二维码变化不频繁我建议自己在全局维护一个 Mapkey 是图片 URLvalue 是loadImage返回的 Promise。第二次请求同一个 URL 时直接复用之前的 Promise避免重复下载和重复解码const imageCache new Map() function loadWithCache(url) { if (!imageCache.has(url)) { imageCache.set(url, new Promise((resolve, reject) { // 原有 loadImage 逻辑 })) } return imageCache.get(url) }6.2 复杂海报抽离为独立的 Canvas 绘制模块海报绘制逻辑一旦长起来全堆在 Page 里会非常痛苦。商品信息的改动、运营位的替换、样式的微调都会牵扯到页面代码出 bug 的概率直线上升。我的做法是把整套海报绘制能力抽成一个独立的模块比如PosterBuilder类页面只负责传入数据对象拿回临时文件路径。上面代码里的initCanvas、loadImage、drawPoster、truncateText都归到模块内部对外只暴露一个build(data)方法。这样多个页面共用同一套海报能力时只需要各自维护自己的 canvas 节点绘制逻辑完全复用。我现在的项目里有商品详情页、分享有礼页、签到页三处会生成海报全部走同一个模块改一次样式三处生效。6.3 真机调试时容易忽略的环境差异最后提醒一个坑canvas 2d 在开发者工具里的表现和真机表现不完全一致。工具里图片加载快、绘制秒出真机上遇到弱网环境图片下载耗时会被明显放大。我的习惯是所有图片绘制前先做超时处理loadImage里加一个 10 秒的定时器超时直接 reject避免白屏等半天。另外真机上首次调用canvasToTempFilePath会比之后慢这是正常的不要误判为卡死。如果发现长时间无响应可以在生成前先调用一次空绘制提前完成 canvas 的初始化流程。海报布局的调试我也有个土办法canvas 在position: fixed移出屏幕外以后工具里没法实时预览绘制效果。所以我通常会先改成正常定位放在页面中间保留一个调试开关开发阶段打开预览发布前再关掉。这样能直接在页面上看到每一步绘制的位置对不对比在代码里盲调坐标高效得多。本文还有配套的精品资源点击获取