Canvas 合成分享海报实战:文本换行、圆角头像、跨域图片与高清屏模糊
这个月接了个营销需求:大促活动页要加"生成分享海报"功能,用户点一下,把活动主图、自己的头像昵称、专属邀请二维码合成一张图,长按保存发朋友圈。设计稿给的是一张精美的竖版海报,产品的原话是"跟设计稿一模一样就行"。
方案没什么可选的:让后端用服务端图片库合成,接口开发和排队渲染的成本都高,活动又赶;前端 Canvas 2D 画一张导出,不占服务端资源,理论上完全可行。我此前对 Canvas 的使用停留在教程里画矩形的程度,这次从一个空白的 <canvas> 标签开始,把整条链路走了一遍。真做下来发现,Canvas 画海报的难点不在 API 本身,全在图片、文本、分辨率这些"素材进出画布"的边界上。
先把骨架搭起来:drawImage 和加载时序
海报的底是一张背景图,第一步就是把它画上去。drawImage 有三种签名,画背景用最长的九参数版本最灵活,可以从源图裁一块画到目标区域:
1var canvas = document.getElementById('poster'); 2var ctx = canvas.getContext('2d'); 3 4var bg = new Image(); 5bg.onload = function () { 6 ctx.drawImage(bg, 0, 0, canvas.width, canvas.height); 7}; 8bg.src = 'https://cdn.example.com/activity/poster-bg.png';
第一个坑立刻就来了:drawImage 传入一个还没加载完成的 Image,不报错,就是什么都不画。海报上有背景图、用户头像、二维码三张图,绘制顺序又有讲究(背景最底、二维码最上),如果各画各的 onload,先加载完的先画,层级就乱了。所以正确的结构是先把所有图片并行加载完,再按顺序同步绘制:
1function loadImage(src) { 2 return new Promise(function (resolve, reject) { 3 var img = new Image(); 4 img.crossOrigin = 'anonymous'; 5 img.onload = function () { resolve(img); }; 6 img.onerror = function () { reject(new Error('图片加载失败: ' + src)); }; 7 img.src = src; 8 }); 9} 10 11Promise.all([ 12 loadImage(bgUrl), 13 loadImage(avatarUrl), 14 loadImage(qrcodeUrl), 15]).then(function (images) { 16 drawPoster(images[0], images[1], images[2]); 17});
crossOrigin = 'anonymous' 这一行现在先记着,它是后面导出环节的命门,一会儿细说。
文本换行:Canvas 不管排版,measureText 自己算
背景画上去之后画文案。Canvas 的 fillText 是我见过最"原始"的文本 API:它不换行、不省略、不管溢出,你给多长的字符串它就往一行里画多长。活动标题和用户昵称都是变长文本,昵称还可能带 emoji 和各种奇怪字符,必须自己实现换行。
原理是用 measureText 逐字累加测量宽度,超过限宽就断行:
1function wrapText(ctx, text, x, y, maxWidth, lineHeight, maxLines) { 2 var chars = Array.from(text); // 处理代理对,避免把 emoji 劈成两半 3 var line = ''; 4 var lineCount = 0; 5 6 for (var i = 0; i < chars.length; i++) { 7 var testLine = line + chars[i]; 8 if (ctx.measureText(testLine).width > maxWidth && line !== '') { 9 lineCount++; 10 if (maxLines && lineCount === maxLines) { 11 // 最后一行放不下了,截断加省略号 12 while (ctx.measureText(line + '…').width > maxWidth) { 13 line = line.slice(0, -1); 14 } 15 ctx.fillText(line + '…', x, y); 16 return; 17 } 18 ctx.fillText(line, x, y); 19 line = chars[i]; 20 y += lineHeight; 21 } else { 22 line = testLine; 23 } 24 } 25 ctx.fillText(line, x, y); 26}
两个细节。一是 Array.from(text) 而不是 text.split(''):emoji 和一些生僻字在 JS 里是两个 code unit 的代理对,按下标劈开会得到两个乱码字符,Array.from 按 code point 迭代能避开大部分情况。二是 measureText 依赖当前 ctx.font 的设置,测量之前必须先把 font 设成实际绘制用的字号字体,不然量出来的宽度是错的——我第一版就是先测量后设字体,断行位置全部偏掉,查了半天才发现测量时用的是默认的 10px sans-serif。
中西文混排还有个小麻烦:逐字断行会把英文单词从中间劈开。海报场景文案基本是中文,我没有做完整的分词断行,只加了一条规则——断行点如果前后都是字母数字,就回退到最近的空格。够用了,没必要在海报里实现一个排版引擎。
对齐方式也值得说一句。fillText 的坐标锚点由 textAlign 和 textBaseline 共同决定,默认是 left 加 alphabetic——注意基线默认不是文字顶部也不是底部,是西文基线,中文字符会"坐"在这条线上方。从设计稿标注换算坐标时,把 textBaseline 设成 top,标注里"文字距离顶部多少像素"就能直接用,不用再心算基线偏移:
1ctx.font = 'bold 28px "PingFang SC", "Microsoft YaHei", sans-serif'; 2ctx.fillStyle = '#1f2329'; 3ctx.textAlign = 'left'; 4ctx.textBaseline = 'top'; 5ctx.fillText(title, 24, 200);
价格这类要右对齐的数字,把 textAlign 设成 right、x 传右边界坐标,比自己用 measureText 算宽度再倒推 x 省事得多。至于行高,Canvas 里没有 line-height 的概念,measureText 返回的对象在现在的浏览器里也基本只有 width 可用(规范里定义的一堆度量属性大多没实现),行高就按设计稿标注写死成常量传给 wrapText,简单直接。
圆角头像:clip 加 save/restore
设计稿里用户头像是圆的,drawImage 画出来是方的。裁圆用 clip:先画一个圆形路径,调用 clip() 把绘制区域限制在路径内,再 drawImage,图片就只会画在圆里:
1function drawCircleAvatar(ctx, img, x, y, size) { 2 ctx.save(); 3 ctx.beginPath(); 4 ctx.arc(x + size / 2, y + size / 2, size / 2, 0, Math.PI * 2); 5 ctx.clip(); 6 ctx.drawImage(img, x, y, size, size); 7 ctx.restore(); 8}
save 和 restore 必须成对包住,因为 clip 是累积性的、没有"取消裁剪"的 API,不 restore 的话后面所有绘制都被限制在这个圆里——我就干过这事,头像画完之后二维码怎么都画不出来,盯了十分钟才反应过来是裁剪区域没释放。圆角矩形(比如海报底部的白色信息卡片)同理,Canvas 没有现成的圆角矩形 API,得用 arcTo 自己拼路径,封装成一个 roundRect 工具函数:
1function roundRect(ctx, x, y, w, h, r) { 2 ctx.beginPath(); 3 ctx.moveTo(x + r, y); 4 ctx.arcTo(x + w, y, x + w, y + h, r); 5 ctx.arcTo(x + w, y + h, x, y + h, r); 6 ctx.arcTo(x, y + h, x, y, r); 7 ctx.arcTo(x, y, x + w, y, r); 8 ctx.closePath(); 9} 10 11// 白色信息卡片:圆角矩形 + 阴影 12ctx.save(); 13ctx.shadowColor = 'rgba(0, 0, 0, 0.08)'; 14ctx.shadowBlur = 12; 15ctx.shadowOffsetY = 4; 16roundRect(ctx, 24, 480, 327, 150, 12); 17ctx.fillStyle = '#fff'; 18ctx.fill(); 19ctx.restore();
阴影也要用 save/restore 包住,shadowBlur 一旦设置,后面画的每一笔都带阴影,文字会糊成一团。这类"设置即全局生效"的状态(fillStyle、font、shadow、globalAlpha、裁剪区域)是 Canvas 和 DOM 思维差别最大的地方:DOM 的样式跟着元素走,Canvas 的状态跟着画笔走,习惯了前者的人在后者手里翻车都翻在状态泄漏上。我最后给绘制代码定了条纪律:凡是要改画笔状态的绘制函数,一律 save 开头 restore 结尾。
头像绘制还有个画质细节。用户上传的头像原图可能是 800×800,画到海报上只占 96×96(乘上 dpr 也就 288×288),缩小比例接近三倍。Canvas 默认的图像平滑处理这种幅度的缩小效果还行,但如果原图更大、缩小超过四五倍,一步 drawImage 缩出来的图会有明显锯齿。处理办法是分步缩小:先把原图画到一个中间 canvas 上缩一半,再从中间 canvas 画到目标尺寸,两步各缩一半比一步缩四分之一平滑得多。我们的头像源头有尺寸上限,一步够用,但这个技巧在处理用户上传的原图时早晚用得上,记在工具函数库里了。
跨域图片与画布污染:toDataURL 报错的元凶
画布内容都齐了,最后一步导出。canvas.toDataURL('image/png') 拿 base64,或者 toBlob 拿二进制。我在本地开发时一切正常,部到测试环境点导出,控制台一行红字:
Uncaught DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement':
Tainted canvases may not be exported.
画布被"污染"了。规则是这样的:一旦有跨域且未通过 CORS 校验的图片被画进 canvas,这块画布就永久变成 tainted 状态,toDataURL、toBlob、getImageData 全部拒绝执行。绘制本身不报错,报错延迟到导出那一刻,特别有迷惑性。本地正常是因为图片挂在 devServer 代理的同域路径下,测试环境图片走 CDN,域就跨了。
解法和上个月做错误监控时处理 Script error 是同一套:图片请求端加 img.crossOrigin = 'anonymous'(前面 loadImage 里已经写了),CDN 侧配置 Access-Control-Allow-Origin 响应头,两边都齐了,浏览器验证通过,画布就不算被污染。这里还有个缓存相关的坑:如果这张图之前被不带 CORS 的 <img> 标签加载过,缓存里存的是没有 CORS 头的响应,带 crossOrigin 再请求可能直接命中缓存然后校验失败。稳妥的处理是给海报用图的 URL 拼一个无害的查询参数强制绕开缓存,比如 url + '?cors=1',不优雅,但省心。
用户头像存在我们自己的对象存储上,配 CORS 头一句话的事。麻烦的是微信头像这类第三方域名的图,人家的响应头我们控制不了,最后是让后端加了个图片转发接口,把第三方头像代理成同域资源,彻底绕开跨域。
导出:toDataURL、toBlob 和"长按保存"的真相
跨域理顺之后说导出本身。toDataURL 返回 base64 字符串,toBlob 返回二进制 Blob,两者怎么选取决于图往哪去。海报这个场景,最终形态是页面上一张可以长按保存的图——这里有个一开始没想到的点:canvas 元素本身长按是弹不出"保存图片"菜单的,微信内嵌浏览器和大部分手机浏览器只对 <img> 提供长按保存。所以合成完必须把画布内容转成 img 元素展示:
1canvas.toBlob(function (blob) { 2 var url = URL.createObjectURL(blob); 3 var img = document.getElementById('poster-result'); 4 img.onload = function () { URL.revokeObjectURL(url); }; 5 img.src = url; 6}, 'image/jpeg', 0.9);
换成 img 之后还有个意外收获:微信里长按这张海报图,除了"保存图片",菜单里会多出"识别图中二维码"——海报右下角的邀请二维码是能被长按直接识别的,这等于给"保存后转发"之外多开了一条"当场扫码"的转化路径。运营后来反馈说这条路径的转化占比不低,虽然这纯属 img 元素的原生待遇,我们一行代码都没写。
我先用的 toDataURL,跑通了,但看了一眼那个 base64 字符串的长度就换成了 toBlob:base64 编码比原始二进制大三分之一左右,一张 1125×2001 的海报编出来的字符串一兆多,作为 src 塞给 img 意味着这一大串字符串同时活在 JS 字符串、DOM 属性、解码后的位图三个地方。toBlob 配 createObjectURL 拿到的只是一个引用地址,内存友好得多,用完 revoke 掉就释放。低端安卓机的 WebView 内存本来就紧张,这种地方省一点是一点。
格式上选了 image/jpeg 质量 0.9 而不是默认的 PNG。海报底图是摄影图,PNG 无损编码出来接近 3M,JPEG 0.9 只有四百多 K,肉眼看不出差别,保存和转发都快。如果海报有透明区域才必须 PNG——JPEG 不支持透明,透明像素会被填成黑色,这个坑我在测试圆角卡片时撞见过一次,当时导出图四个角是黑的,一时没反应过来。
toBlob 有一个和 toDataURL 不同的地方要注意:它是异步的,回调风格,而且规范允许在某些情况下回调收到 null(比如画布尺寸为 0)。我把它包成了 Promise,跟前面的图片加载风格统一,reject 分支接全局的失败提示:
1function canvasToBlob(canvas, type, quality) { 2 return new Promise(function (resolve, reject) { 3 canvas.toBlob(function (blob) { 4 if (blob) { 5 resolve(blob); 6 } else { 7 reject(new Error('canvas 导出失败')); 8 } 9 }, type || 'image/jpeg', quality || 0.9); 10 }); 11}
二维码:别下图片,直接画进来
海报右下角的邀请二维码一开始走的是后端方案:后端生成二维码图片,前端当普通图片加载再 drawImage。跑起来没问题,但多了一次图片请求,而且二维码内容只是一个带邀请码参数的 URL,让后端专门起个生成接口有点杀鸡用牛刀。后来换成前端本地生成,用的 qrcode 这个 npm 包,它可以直接把二维码画到一个传入的 canvas 上:
1var QRCode = require('qrcode'); 2 3function drawQRCode(ctx, text, x, y, size) { 4 var qrCanvas = document.createElement('canvas'); 5 return QRCode.toCanvas(qrCanvas, text, { 6 width: size * dpr, 7 margin: 1, 8 errorCorrectionLevel: 'M', 9 }).then(function () { 10 ctx.drawImage(qrCanvas, x, y, size, size); 11 }); 12}
先画到一个临时 canvas,再把这个 canvas 当图片源 drawImage 到海报画布上——drawImage 的第一个参数除了 Image,也接受另一个 canvas,这个特性让"分模块离屏绘制、最后合成"成为可能。二维码生成是纯本地计算,没有网络请求,自然也没有跨域问题,少伺候一个 CORS 配置。容错级别选 M 够用了,海报上的二维码尺寸不小,不需要为了容错把码点密度提上去。
合成期间的交互也要兜住:点"生成海报"到 img 就绪之间有几百毫秒到一两秒(取决于图片加载),这段时间给按钮上 loading 并且防重复点击。测试同事最初一秒连点五次,页面同时跑起五个合成流程,低端机直接卡死——合成入口加一个进行中的标志位,进行中的点击直接忽略,这类问题就绝迹了。
导出来的图是糊的:devicePixelRatio
功能全通了,我拿自己手机一看,导出的海报文字边缘发虚,和设计稿的清晰度差一截。这是高清屏的经典问题:canvas 的 width/height 属性定义的是画布的像素数,CSS 尺寸定义的是显示大小,我把两者设成一样的值,在 dpr 为 3 的手机上,一个 CSS 像素背后是 3×3 个物理像素,画布像素被拉伸三倍显示,能不糊吗。
处理方式是画布像素尺寸按 devicePixelRatio 放大,CSS 尺寸维持设计尺寸,再把坐标系整体 scale,这样绘制代码还是按设计稿坐标写,不用到处乘系数:
1var dpr = window.devicePixelRatio || 1; 2var designWidth = 375; 3var designHeight = 667; 4 5canvas.width = designWidth * dpr; 6canvas.height = designHeight * dpr; 7canvas.style.width = designWidth + 'px'; 8canvas.style.height = designHeight + 'px'; 9ctx.scale(dpr, dpr);
导出的图自然也是放大后的像素尺寸,375×667 的设计稿在 dpr 3 的设备上导出 1125×2001 的图,保存到相册正好是清晰的。
不过有一点要留意:海报导出的清晰度跟着用户设备的 dpr 走不太合理,老安卓机 dpr 是 1,导出的图发朋友圈就糊了。所以我把导出用的 canvas 和预览的 canvas 分开:预览画布跟设备 dpr 走,保证屏幕上看着清楚;导出画布按固定倍数绘制,跟设备无关,谁生成的海报都是同一个分辨率。导出画布是一个不插入 DOM 的离屏元素,document.createElement('canvas') 创建完直接画。
固定倍数定多大也有讲究,放大不能无节制。测试时在一台 iPhone 上碰到过导出全黑的情况,查下来是 iOS 的 Safari/WebView 对单个 canvas 的像素面积有硬性上限(老一点的 iOS 大约在 1600 万像素上下,不同版本有差异),超限的 canvas 创建不报错,画上去的内容全丢,导出自然是黑的或空白。3 倍的长海报很容易就摸到这条线,所以导出倍数最终封顶在 2:清晰度上 2 倍和 3 倍在朋友圈那个展示尺寸下分不出差别,2 倍换来的是所有机型都稳。Canvas 的尺寸上限是隐形的,不主动防御就会在某台具体的手机上以"全黑"的形式冒出来。
1var EXPORT_SCALE = 2; // 导出固定 2 倍,预览画布才跟 devicePixelRatio
顺带说一句,Chrome 里有个真正的 OffscreenCanvas,可以把绘制挪到 Worker 里跑。看了一眼兼容性,Safari 和大部分安卓内嵌 WebView 都还不支持,海报合成也就一两百毫秒的事,不值得为它做双路径,先观望。
字体加载时序:fillText 不等 @font-face
最后一个坑是上线前 QA 测出来的:海报标题偶尔会以系统默认字体渲染,刷新一下又正常。原因是标题用了设计指定的一款 web font,@font-face 声明的字体是异步加载的,如果 fillText 执行时字体文件还没加载完,Canvas 会直接用降级字体绘制,而且不会在字体就绪后重绘——DOM 文本还能等字体到了自动重排,画布上的像素泼出去就收不回来。
好在现在有 CSS Font Loading API 可以显式等字体:
1document.fonts.load('bold 28px "HYQiHei"').then(function () { 2 drawPoster(images[0], images[1], images[2]); 3});
把它和图片的 Promise.all 并到一起,所有素材(图片加字体)就绪后才动笔。document.fonts 在 Chrome 和微信内嵌浏览器里都没问题,保险起见我还是加了个三秒超时兜底,超时就用降级字体画,不能让用户因为一个字体文件卡在加载弹窗上。字体文件本身也做了子集化——设计字体全量文件八兆多,海报上会出现的字符其实有限,标题文案是固定的几套,用 fontmin 按实际用到的字符抽了个子集出来,一百多 K,加载时间从"必然超时"变成"基本无感"。web font 用在 Canvas 里,子集化不是优化项,是前置条件。
为什么不直接用 html2canvas
组里同事问过一个合理的问题:社区有 html2canvas,用 HTML/CSS 把海报排出来再整体截图,为什么要手写绘制?我一开始也试过这条路,半天就退回来了。html2canvas 的原理是遍历 DOM 树、读取计算样式、在 canvas 里"重新实现"一遍 CSS 渲染,它支持的 CSS 特性是个子集,设计稿里的文字阴影和一处渐变叠加出来的效果和浏览器渲染的有肉眼可见的偏差;生成速度也慢,同一台手机上手写绘制一百多毫秒,html2canvas 要一秒多;跨域图片的问题它同样要面对,一个都绕不掉。
它真正适合的场景是"把用户当前看到的页面截下来"这种结构不可预知的需求,比如反馈系统的截图附件。海报是设计稿驱动的固定布局,坐标都是已知的,手写绘制代码量没多多少,效果精确、速度快、依赖为零。工具选型的分界线在于内容是"不可预知的 DOM"还是"已知的设计稿",我们属于后者。
上线前我给整条合成链路埋了计时打点:图片加载、字体加载、绘制、导出四段各自计时,连同成功失败一起上报到上个月刚搭的错误监控里。活动上线那天盯了一下数据,海报生成成功率 99% 出头,四段耗时里图片加载占了八成以上(绘制和导出加起来不到两百毫秒),失败集中在几台老旧安卓机的图片加载超时上,加了失败重试后也压下去了。合成链路的瓶颈在网络不在 Canvas,优化精力该花在图片尺寸和 CDN 上,而不是绘制代码里抠微秒——没有打点之前,我差点凭感觉去优化绘制。这个需求从空白 canvas 做到能交付的海报,真正写"画"的代码半天就够,剩下几天全花在换行测量、跨域、dpr、字体时序这些边界上——下次再有人说"前端生成一张图很简单吧",我打算把这篇笔记甩给他。