简介面向微信小程序开发者的新版分享海报组件针对旧版已停止维护、官方文档分散且实现门槛较高的问题提供可直接复用的封装方案。组件以参数化调用为核心开发者只需传入海报内容与样式配置即可完成绘制同时保留样式自定义空间便于适配电商、活动推广等分享场景。压缩包共4个文件包含逻辑、结构、样式与配置四类小程序基础文件整体约4KB体量轻便便于快速引入现有项目。Canvas 2D在绘制效率、资源占用与性能上优于普通canvas适合对分享海报生成速度有要求的页面。已有619人学习说明该实现受到一定关注。读者可获得一套经过大量开发实践后封装的组件源码理解新版海报绘制的组织方式、参数调用与样式调整思路并参考淘宝、京东、拼多多等商城常见分享海报的实现形态减少从零排查文档与调试的成本。1. 微信小程序海报分享老接口还能跑但新版 canvas 2d 已经是另一套东西微信小程序里的海报分享几乎每个带增长诉求的项目都绕不开商品图、二维码、头像昵称、一句文案拼成一张图让用户保存或转发。早期大家用wx.createCanvasContext那套接口是排队式的draw()之前所有指令都堆在队列里出了问题只能靠猜。现在主流电商类小程序基本都切到了canvas type2d它更接近浏览器原生 Canvas API同步执行、能拿到真实节点、支持canvas.createImage()。代价是写法几乎全变了直接把老代码搬过去会撞一堆墙。下面按我实际拆过的源码包把新版 canvas2d 海报绘制的完整链路、参数怎么设、真机上哪里翻车说清楚小程序和前端方向的同学可以照着改。2. 两代 canvas 接口对比为什么初始化这一步必须重写2.1 队列式绘制和即时绘制差的不只是 API 名字老接口的模型是「指令排队、一次性提交」。你调用setFillStyle、fillRect、drawImage这些调用本身不产生任何绘制效果只是往队列里塞指令直到ctx.draw()才真正提交给渲染层。这个模型带来几个连锁问题拿不到 canvas 的真实尺寸文字测量结果依赖提交时机图片路径写错了不会报错只是静默不画而且老版 canvas 是原生组件层级最高容易盖住页面上其他元素。// 老接口所有调用只是入队draw 才真正提交 const ctx wx.createCanvasContext(poster); ctx.setFillStyle(#ffffff); ctx.fillRect(0, 0, 300, 500); ctx.drawImage(bgPath, 0, 0, 300, 500); ctx.draw(); // 到这一行才真正画且无法同步知道画完没有新版换了模型。canvas type2d拿到的是一个真实的 canvas 节点getContext(2d)返回的是标准 Canvas 2D 上下文fillRect调用即生效measureText拿到的是当前真实的字体度量。这个差别听起来抽象落到海报场景上很实在多行文本要精确换行、要按文字宽度算居中位置、要判断标题会不会溢出这些都依赖同步的measureText老接口做起来就是玄学。!-- 模板里必须显式声明 type2d不写这行拿到的还是老上下文 -- canvas type2d idposter stylewidth: 375px; height: 600px;/canvas// 新版通过 SelectorQuery 拿到节点再取上下文 wx.createSelectorQuery() .select(#poster) .fields({ node: true, size: true }) // node 要节点size 要 CSS 尺寸 .exec((res) { if (!res[0] || !res[0].node) return; // 节点没拿到直接退出别硬往下走 const canvas res[0].node; const ctx canvas.getContext(2d); // 从这里开始所有绘制都是即时生效的 });两代接口的关键差异整理成一张表迁移时对着看维度老接口 createCanvasContext新版 canvas 2d绘制模型指令入队draw()提交即时生效同步节点获取只能靠 canvasId 字符串SelectorQuery 拿真实 node图片对象只能传路径字符串canvas.createImage()返回 Image文字测量结果依赖提交时机标准measureText可即时用图层关系原生组件层级最高同层渲染可被其他元素覆盖尺寸读取不支持fields({ size: true })直接给选型理由很直白只要这个项目还要维护、还要出高清图、还要做多套模板就值得迁到新版。老接口不是不能跑是调试成本太高一个「图没出来」的问题可能要排查半小时。2.2 初始化三步拿节点、设位图尺寸、缩放上下文新版 canvas 最容易翻车的地方就在初始化。核心是搞清三个尺寸的关系CSS 尺寸决定它在页面上占多大canvas.width/height决定位图有多少像素ctx.scale(dpr, dpr)决定你写坐标时用哪套单位。Page({ data: { posterW: 375, posterH: 600 }, onReady() { // 必须等 onReadyonLoad 阶段节点还没渲染出来 this.initCanvas(); }, initCanvas() { wx.createSelectorQuery() .select(#poster) .fields({ node: true, size: true }) .exec((res) { const info res[0]; if (!info || !info.node || !info.width) { // canvas 被 hidden 包住时尺寸为 0重试一次 setTimeout(() this.initCanvas(), 100); return; } const canvas info.node; const ctx canvas.getContext(2d); // pixelRatio 用新接口取getSystemInfoSync 已标记废弃 const dpr (wx.getWindowInfo wx.getWindowInfo().pixelRatio) || 2; // 位图尺寸 CSS 尺寸 × dpr不乘这一步真机导出必糊 canvas.width info.width * dpr; canvas.height info.height * dpr; // scale 之后后续所有绘制坐标仍按 CSS 像素写心智负担小 ctx.scale(dpr, dpr); this.canvas canvas; this.ctx ctx; this.dpr dpr; this.drawPoster(); }); } });逻辑说明fields同时要node和size前者是节点本身后者是布局后的 CSS 宽高。重试那一段不是保险起见是必须的——页面切换动画没结束、canvas 在wx:if里刚渲染、父容器display:none都会让info.width为 0这时候设canvas.width 0后面全白画。参数说明info.width/info.height是 CSS 像素一般等于你写在 style 里的值dpr在 iPhone 上常见 2 或 3安卓覆盖 1.5 到 3.5canvas.width是位图宽度直接决定导出图的清晰度上限。三者关系是「位图宽 CSS 宽 × dpr」scale只是让绘制坐标不用手动乘。注意重复初始化时ctx.scale会累加。第二次绘制前必须先ctx.setTransform(1, 0, 0, 1, 0, 0)复位否则所有元素会变成两倍大并且整体偏移。这是我在一个「换一版海报」的需求上踩过的坑表现是第二张图整个错位。2.3 图片加载封装createImage 只认本地路径新版绘制图片必须先把图变成 Image 对象而canvas.createImage()不接受网络地址。这一点和浏览器里的new Image()不一样直接传https://开头的 srconload永远不触发也不报错就是静默失败。// utils/image.js /** * 把网络图转成本地临时路径非网络地址原样返回 * param {string} src 图片地址 */ function toLocalPath(src) { if (!/^https?:/.test(src)) return Promise.resolve(src); return new Promise((resolve, reject) { wx.getImageInfo({ src, success: (res) resolve(res.path), // path 是本地临时文件路径 fail: (e) reject(new Error(getImageInfo fail: src)) }); }); } /** * 加载为 canvas 可用的 Image 对象 * param {Object} canvas canvas 节点 * param {string} src 本地路径临时文件 / 包内资源 / base64 */ function loadImage(canvas, src) { return new Promise((resolve, reject) { const img canvas.createImage(); // 必须先挂 onload 再赋 src顺序反了可能错过回调 img.onload () resolve(img); img.onerror () reject(new Error(image load fail: src)); img.src src; }); } module.exports { toLocalPath, loadImage };逻辑说明toLocalPath负责把网络地址降级成本地路径loadImage负责把路径变成可绘制的 Image。两步分开的好处是失败点清晰——报错来自哪一步一眼能看出来。参数说明wx.getImageInfo返回{ width, height, path, orientation, type }其中path是临时文件路径可以直接喂给createImagewidth/height是原图尺寸做 cover 裁切时要用。这里有个隐性依赖图片域名必须配在小程序后台的 downloadFile 合法域名里否则getImageInfo直接失败。头像类地址尤其容易漏配。提示如果是包内静态图比如兜底底图直接写/assets/poster-bg.png这种绝对路径即可不用走getImageInfo省一次异步。3. 海报的分层绘制从底图到二维码的顺序和坐标3.1 六层结构先定坐标再写绘制代码海报绘制最常见的返工原因是「边画边调坐标」。稳妥的做法是先把整张图切成固定层每层确定坐标来源再动手写代码。层内容坐标来源备注L1纯色/渐变底0, 0, W, H必须有防止导出带透明通道L2主图/背景图顶部通栏用九参 drawImage 做 coverL3白色圆角卡片居中留边距arcTo 手写圆角L4标题 正文卡片内 padding手写换行 省略L5头像 昵称卡片左下圆形 clipL6二维码 提示语卡片右下先铺白底再画码层顺序就是绘制顺序Canvas 没有 z-index后画的盖前面的。主绘制函数按层往下写中间不穿插无关逻辑。async drawPoster() { const { ctx, canvas } this; const W this.data.posterW; const H this.data.posterH; // L1 兜底底色PNG 导出时避免透明边 ctx.fillStyle #f5f6f8; ctx.fillRect(0, 0, W, H); // L2 主图cover 裁切 const bg await loadImage(canvas, this.bgPath); drawImageCover(ctx, bg, 0, 0, W, H * 0.55); // L3 白色圆角卡片 ctx.fillStyle #ffffff; roundRect(ctx, 20, H * 0.42, W - 40, H * 0.5, 16); ctx.fill(); // L4 ~ L6 依次绘制 this.drawTextBlock(ctx, W, H); this.drawUserBlock(ctx, canvas, W, H); await this.drawQrcode(ctx, canvas, W, H); }逻辑说明这个骨架的价值在于「顺序即层级」读代码的人从上往下就能还原出画面结构。异步加载的图在需要用到的那一层再await不要一次性全加载完再画否则某张图挂了整张海报都出不来。参数说明W、H是 CSS 尺寸和画布定义保持一致H * 0.55这类比例值建议抽成常量表换尺寸时只改一处。3.2 drawImage 的九参数模式cover 裁切不变形直接把原图拉伸到目标区域人像和商品图都会变形。正确做法是算源图裁切区用九参数版本。/** * 按 cover 方式绘制图片填满目标区超出部分居中裁掉 * param {CanvasRenderingContext2D} ctx * param {Image} img * param {number} x 目标区左上角 x * param {number} y 目标区左上角 y * param {number} w 目标区宽 * param {number} h 目标区高 */ function drawImageCover(ctx, img, x, y, w, h) { const iw img.width; const ih img.height; // Math.max cover填满并裁切Math.min contain完整显示留白 const scale Math.max(w / iw, h / ih); const sw w / scale; const sh h / scale; const sx (iw - sw) / 2; // 水平居中裁切 const sy (ih - sh) / 2; // 垂直居中裁切 ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h); }逻辑说明sx, sy, sw, sh描述的是从原图里抠哪一块x, y, w, h描述的是画到画布哪一块。scale取Math.max时缩放后的图至少有一边不小于目标区另一边超出后靠sx/sy居中裁掉这就是 cover。参数说明想让商品图完整显示不留白把Math.max换成Math.min同时把目标区底色铺成白色视觉上等于 contain 加留白。3.3 圆角矩形与圆形头像arcTo 和 clip 的两种写法圆角不要用ctx.roundRect新基础库虽然有老版本没有线上会静默变成直角。手写arcTo版本兼容性最好。/** * 生成圆角矩形路径只建路径不填充填充交给调用方 */ function roundRect(ctx, x, y, w, h, r) { const radius Math.min(r, w / 2, h / 2); // 半径不能超过半宽半高 ctx.beginPath(); ctx.moveTo(x radius, y); ctx.arcTo(x w, y, x w, y h, radius); ctx.arcTo(x w, y h, x, y h, radius); ctx.arcTo(x, y h, x, y, radius); ctx.arcTo(x, y, x w, y, radius); ctx.closePath(); }逻辑说明四个arcTo依次画四个角中间靠直线段自动连接。只建路径不填充是因为同一个路径后面可能既fill又stroke也可能拿来做clip。参数说明r是圆角半径Math.min那一步是保护——半径大于半宽时arcTo会画畸形。圆形头像用clip这是另一个高频翻车点function drawAvatar(ctx, img, cx, cy, r) { ctx.save(); // 保存当前状态必须有 ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip(); // 之后所有绘制都被限制在这个圆内 ctx.drawImage(img, cx - r, cy - r, r * 2, r * 2); ctx.restore(); // 不 restore后面所有元素都会消失 }逻辑说明save/restore是一对包住clip的作用范围。clip一旦生效会一直影响后续绘制忘记restore的表现是「头像画完之后整张海报只剩头像」——这个血泪经验我见过不止一次。参数说明cx/cy是圆心r是半径drawImage的目标区是外接正方形。3.4 多行文本measureText 逐字测量加省略号Canvas 没有自动换行。中英文混排如果按空格切中文会整段不换按固定字数切遇到英文和数字又会溢出。可靠做法是逐字符累加测量。/** * 文本换行超出最大行数时追加省略号 * param {CanvasRenderingContext2D} ctx 调用前必须已设置好 ctx.font * param {string} text * param {number} maxWidth 单行最大宽度CSS 像素 * param {number} maxLines 最多几行 */ function wrapText(ctx, text, maxWidth, maxLines) { const lines []; let line ; for (let i 0; i text.length; i) { const ch text[i]; const test line ch; if (ctx.measureText(test).width maxWidth line) { lines.push(line); line ch; if (lines.length maxLines) break; // 行数够了就停 } else { line test; } } if (line lines.length maxLines) lines.push(line); // 被截断时最后一行补省略号 if (lines.length maxLines) { let last lines[maxLines - 1]; while (ctx.measureText(last …).width maxWidth last.length) { last last.slice(0, -1); } lines[maxLines - 1] last …; } return lines; }逻辑说明逐字累加每次测量「当前行 新字符」是否超宽超了就换行。最后那段 while 是在给省略号腾位置从尾部一个字一个字删直到「内容 省略号」能塞进一行。参数说明ctx.font必须在调用前设置measureText用的是当前字体maxLines传 0 或不传表示不限行数。行高一般取字号的 1.4 到 1.6 倍中文密排取 1.5 比较舒服。注意这段逻辑会把 emoji 和组合字符拆坏一个 emoji 在 JS 里可能是两个 code unit。如果文案里可能有 emoji先用Array.from(text)转成字符数组再遍历。3.5 二维码白边留够尺寸别抠二维码这块没多少代码但参数抠错就是扫不出来。关键是两点白边quiet zone至少留 4 个模块宽度导出尺寸别低于 200×200 px。很多模板为了美观把二维码压到 120px再套一层深色底识别率直接掉。async drawQrcode(ctx, canvas, W, H) { const size 96; // 显示尺寸 const padding 8; // 白边 const x W - 24 - size; const y H - 24 - size; // 先铺白底避免二维码贴在深色卡片上 ctx.fillStyle #ffffff; ctx.fillRect(x - padding, y - padding, size padding * 2, size padding * 2); const qr await loadImage(canvas, this.qrPath); ctx.drawImage(qr, x, y, size, size); }参数说明size是显示尺寸padding是白边。导出时destWidth会乘 dpr所以 96px 显示尺寸在 dpr3 的设备上实际导出 288px够用。4. 异步编排与导出绘制完成不等于图片能保存4.1 图片预加载池并发拿图别串行等一张海报通常有底图、头像、二维码、若干商品图。串行await会让总耗时等于所有图加载时间之和并发则是取最慢的那张。/** * 并发预加载一组图片 * param {Object} canvas * param {Arraystring} srcList 可能是网络地址也可能是本地路径 * returns {PromiseObject} key 为原始 srcvalue 为 Image 对象 */ async function preloadImages(canvas, srcList) { const tasks srcList.map(async (src) { const localPath await toLocalPath(src); const img await loadImage(canvas, localPath); return [src, img]; }); // 单张失败不影响整体失败的置 null 由调用方决定降级 const results await Promise.all( tasks.map((p) p.catch(() null)) ); return results.filter(Boolean).reduce((acc, [src, img]) { acc[src] img; return acc; }, {}); }逻辑说明每个 src 走「转本地 → 加载」两步Promise.all并发。内层catch把单张失败吞掉返回 null是为了让一张商品图挂掉时整张海报还能出——这是资源包里的默认策略比整体 reject 更实用。参数说明小程序同时发起的网络请求有并发上限一般 10 个图片数量超过这个数时getImageInfo会排队。海报场景一般不会超做批量导出时要注意。4.2 绘制链包成 Promise成功和失败都要有出口绘制本身是同步的问题在于「哪一步失败」和「失败之后做什么」。把整条链包进一个 async 函数外层统一处理。async drawPoster() { this.setData({ drawing: true }); try { const imgs await preloadImages(this.canvas, [ this.data.bgSrc, this.data.avatarSrc, this.data.qrSrc ]); // 底图必须有缺了直接走兜底 if (!imgs[this.data.bgSrc]) throw new Error(bg missing); const ctx this.ctx; ctx.setTransform(1, 0, 0, 1, 0, 0); // 复位防止重绘时 scale 叠加 ctx.scale(this.dpr, this.dpr); ctx.clearRect(0, 0, this.data.posterW, this.data.posterH); this.paint(ctx, imgs); // 纯同步绘制见第 3 章 const tempPath await this.export(); this.setData({ posterPath: tempPath }); } catch (e) { // 降级用包内静态兜底图保证分享入口不空 this.setData({ posterPath: /assets/poster-fallback.png }); } finally { this.setData({ drawing: false }); } }逻辑说明setTransform复位那一步是重绘的关键。用户换模板、换商品时这个方法会被二次调用不复位的话第二次绘制会整体放大 dpr 倍。参数说明drawing这个状态位用来控制「生成中」的 loading也用来防止用户连点重复触发。4.3 canvasToTempFilePath参数决定导出的清晰度export() { return new Promise((resolve, reject) { wx.canvasToTempFilePath({ canvas: this.canvas, // 新版必须传节点不传 canvasId x: 0, y: 0, width: this.data.posterW, // 裁切区按 CSS 像素 height: this.data.posterH, destWidth: this.data.posterW * this.dpr, // 输出位图宽 destHeight: this.data.posterH * this.dpr, fileType: png, quality: 1, success: (res) resolve(res.tempFilePath), fail: (err) reject(err) }); }); }参数含义逐项说清参数作用常见错法canvas指定要导出的 canvas 节点传canvasId新基础库下会失败x/y从画布哪个位置开始裁默认 0全图导出不用改width/height裁切区尺寸按 CSS 像素写成位图尺寸导出只剩左上角destWidth/destHeight输出图片的实际像素不乘 dpr图糊fileTypepng或jpg带透明的图存 jpg 会变黑底quality仅 jpg 有效0~1对 png 无效别指望它压体积注意width/height和destWidth/destHeight是两套坐标系。前者是「从画布裁哪一块」按 CSS 像素后者是「输出多大」按位图像素。两个都写成posterW是新手最常见的糊图原因。4.4 保存相册的授权链路拒绝一次就不会再弹wx.saveImageToPhotosAlbum依赖scope.writePhotosAlbum授权。用户第一次拒绝之后再调用不会弹窗直接走 fail 分支表现是「点了保存没反应」。saveToAlbum(filePath) { wx.saveImageToPhotosAlbum({ filePath, success: () wx.showToast({ title: 已保存到相册 }), fail: (err) { const msg err.errMsg || ; // 拒绝过授权时只能引导去设置页不能靠再次调用 if (msg.indexOf(auth deny) -1 || msg.indexOf(authorize) -1) { wx.showModal({ title: 需要相册权限, content: 保存海报需要访问相册请在设置中开启, confirmText: 去设置, success: (r) { if (r.confirm) wx.openSetting(); // 打开设置页让用户手动勾选 } }); return; } wx.showToast({ title: 保存失败请重试, icon: none }); } }); }逻辑说明fail里要区分「权限被拒」和「其他错误」。权限被拒必须走openSetting引导其他错误磁盘满、路径失效提示重试即可。参数说明filePath只接受本地路径canvasToTempFilePath返回的tempFilePath可以直接用网络地址和包内路径都不行。4.5 超时与重试给绘制加一个上限图片加载挂住不回调的情况是存在的弱网、CDN 节点异常。不加超时用户看到的就是永远转圈。function withTimeout(promise, ms, label) { return Promise.race([ promise, new Promise((_, reject) setTimeout(() reject(new Error(timeout: label)), ms) ) ]); } // 用法单张图 8 秒没回来就当作失败走降级 const img await withTimeout(loadImage(canvas, path), 8000, avatar);参数说明ms建议 6000 到 8000太短会导致正常弱网用户拿不到图太长用户已经退出页面了。label只用于错误定位不影响逻辑。5. 真机才暴露的五个坑现象、原因和改法5.1 开发者工具正常真机导出全白现象模拟器里海报正常显示真机点保存出一张纯白图tempFilePath有值但打开什么都没有。原因初始化时fields({ size: true })拿到的info.width为 0canvas.width 0 * dpr 0整张画布是 0 像素。模拟器渲染时序和真机不同模拟器上经常能蒙混过关。另一个可能是在onLoad里初始化节点还没渲染完。解决初始化放到onReady并对info.width做非零校验为 0 时setTimeout重试。同时确认 canvas 没有被wx:if或display:none包住——隐藏状态下拿不到有效尺寸需要改成visibility:hidden; position:fixed; left:-9999px这种「隐藏但占位」的写法。5.2 图能出来但发虚字边缘毛糙现象真机上导出的海报整体模糊文字有毛边缩略图看着还行放大就糊。原因canvas.width直接等于 CSS 宽没乘 dpr。位图只有 375px 宽在 dpr3 的设备上被拉伸到 1125px 显示必然糊。另一个变体是canvasToTempFilePath的destWidth没乘 dpr画布是高清的但导出时又压回 375px。解决按 2.2 节的三步走canvas.width info.width * dprctx.scale(dpr, dpr)导出时destWidth posterW * dpr。三处任一漏掉都会糊改完在 dpr3 的设备上验证一次。5.3 网络图在电脑上能画手机上画不出来现象开发者工具里商品图正常绘制真机上那个位置是空白控制台也没有报错。原因canvas.createImage()的src直接传了https://地址。新版接口不接受网络地址onload不触发也不报错静默失败。开发者工具对这块有兼容处理所以看不出来。解决所有网络图先过wx.getImageInfo拿res.path本地路径再喂给createImage。同时检查图片域名是否配在 downloadFile 合法域名列表里头像类第三方域名最容易漏。5.4 保存按钮点了没反应也没有报错现象第一次点保存弹了授权框用户点了拒绝之后再点就完全没反馈。原因scope.writePhotosAlbum被拒绝后saveImageToPhotosAlbum不会再次弹窗直接走 fail而代码里 fail 分支只写了console.log用户侧无感知。解决fail 分支判断errMsg里是否含auth deny或authorize含就走wx.showModal引导用户确认后调wx.openSetting。设置页回来后再让用户点一次保存不要自动重试——openSetting的回调里直接调保存容易因为用户没勾选而再次失败。5.5 安卓上文字垂直位置偏上iOS 正常现象同一套代码iOS 上标题居中安卓上整体偏上几个像素字号越大偏得越明显。原因textBaseline的默认值是alphabetic不同平台的字体度量和基线计算有差异。另外行高如果按固定lineHeight累加而实际字体渲染高度不等于fontSize累积误差会更明显。解决所有文字绘制前显式设置ctx.textBaseline middle或top不要依赖默认值。要垂直居中就设middle并把y设成目标区中心要顶部对齐就设topy直接用行顶坐标。行高按fontSize * 1.5算不要写死像素值。6. 把绘制逻辑抽成 JSON 模板换一版海报不用改代码做到这一步单张海报已经稳了。真正的维护成本在「换一版」——运营每提一次新样式就改一次绘制函数改三五次之后代码就没法看了。比较省事的做法是把布局抽成 JSON绘制器只认「有哪些层、每层什么类型、画在哪」。// 模板配置每层只描述数据和位置不含绘制逻辑 const TEMPLATE { width: 375, height: 600, layers: [ { type: rect, x: 0, y: 0, w: 375, h: 600, fill: #f5f6f8 }, { type: image, key: bg, x: 0, y: 0, w: 375, h: 330, mode: cover }, { type: text, key: title, x: 32, y: 360, maxWidth: 311, font: bold 22px sans-serif, color: #1a1a1a, maxLines: 2, lineHeight: 34 }, { type: avatar, key: avatar, cx: 64, cy: 500, r: 28 }, { type: image, key: qr, x: 255, y: 452, w: 96, h: 96, mode: contain } ] }; // 绘制器按 type 分发新增样式只需在模板里加一条 async function renderTemplate(ctx, canvas, tpl, data) { for (const layer of tpl.layers) { if (layer.type rect) { ctx.fillStyle layer.fill; ctx.fillRect(layer.x, layer.y, layer.w, layer.h); } else if (layer.type image) { const img data[layer.key]; if (!img) continue; // 缺图跳过不阻塞其他层 drawImageCover(ctx, img, layer.x, layer.y, layer.w, layer.h); } else if (layer.type text) { ctx.font layer.font; ctx.fillStyle layer.color; ctx.textBaseline top; const lines wrapText(ctx, data[layer.key] || , layer.maxWidth, layer.maxLines); lines.forEach((line, i) { ctx.fillText(line, layer.x, layer.y i * layer.lineHeight); }); } else if (layer.type avatar) { drawAvatar(ctx, data[layer.key], layer.cx, layer.cy, layer.r); } } }这套结构的收益在第三版海报之后才显现新增一种样式只需要往layers里加一条配置和对应数据绘制器完全不动。代价是要提前把所有层类型想全遇到新类型得回来扩分发分支——但扩一次总比每次改整条绘制链划算。验证上我一般走三个动作一是模拟器里导出后把图下载到本地用看图软件放到 100% 看文字边缘糊不糊一眼能看出来二是故意把某个图片地址改成不存在的看海报是不是还能出降级是否生效三是把设备像素比手动切到 3 跑一遍很多只在高清屏上出现的问题这一步能提前抓到。从那以后我每次接海报需求都会先把模板配置和降级兜底这两件事定下来再开始写绘制代码。顺序反过来后面改起来就是无底洞。希望帮到你。本文还有配套的精品资源点击获取