文章页的“分享”会生成一张海报:canvas 画好标题、摘要和二维码,存成图片发出去。三月写过一篇《博客文章分享卡片功能设计与实现》,记录了最初的做法。这次把海报重做了一遍,加上封面、分类、标签和头像。改的过程中,把旧实现的几个问题量了一下。
先放结果:
| 项目 | 改前 | 改后 |
|---|---|---|
| 排版出错的海报 | 288 / 372 篇 | 0 |
| 二维码灰色像素 | 32.0% | 0% |
| 封面 | 无 | 有 |
| 手机上看全海报 | 要在框里滑 | 一屏放下 |
下面按这四件事来写。文中的数据都是 2026-10-03 实测的,测试用的是本机 Chrome 和 Playwright 自带的 WebKit(模拟 iPhone)。
一、逐字换行:288 篇的海报排错了
canvas 不会自动换行,要自己量宽度、自己断行。旧版的做法是把文字拆成单个字符,一个个往行里加,超宽就换行:
function calculateTextLines(ctx, text, maxWidth, lineHeight) {
var words = text.split('');
var line = '';
var lines = [];
for (var n = 0; n < words.length; n++) {
var testLine = line + words[n];
if (ctx.measureText(testLine).width > maxWidth && n > 0) {
lines.push(line);
line = words[n];
} else {
line = testLine;
}
}
lines.push(line);
return lines;
}
纯中文没什么问题,中英混排就会出事:英文单词会从中间断开,逗号句号可能跑到下一行开头。到底有多少篇受影响?我把站里全部 372 篇已发布文章的标题和摘要拿出来,在 Chrome 里用和海报相同的字体、宽度和行数限制,分别跑旧版和新版的断行,统计海报上看得见的问题:
| 问题 | 旧版 | 新版 |
|---|---|---|
| 英文单词被拆开 | 193 篇 278 处 | 0 |
| 标点在行首 | 155 篇 193 处 | 0 |
| 行首多出一个空格 | 34 篇 37 处 | 0 |
| 省略号前留着标点 | 22 篇 22 处 | 0 |
| 开括号留在行尾 | 12 篇 12 处 | 0 |
| 至少有一处 | 288 篇 | 0 |
海报上的摘要取自页面的 description,也就是文章摘要截到 160 个字。统计时按同样的规则截取,没有拿全文去算。“单词被拆开”只算整个词本来放得进一行的情况;一行都放不下的长词只能拆,不算错。
被拆开的单词都是站里文章常见的词:Myba/tis、Ali/baba、Ki/bana、HashSe/t、C/oncurrentHashMap、QRCo/deWriter。行首空格是因为按字符拆的时候,空格也是一个“字”,刚好落在换行处就跑到了下一行的开头。
新版先把文字切成三种片段:英文和数字连在一起的一段算一个词,空格单独一段,其余每个字一段。
var tokens = text.replace(/\s+/g, ' ').trim()
.match(/[A-Za-z0-9@#][A-Za-z0-9_\-\u2010\u2011.\/+#%'@&]*| |[\s\S]/g) || [];
词可以用 @ 或 # 开头,中间允许出现 _ - . / + # % ' @ & 和不断行连字符(U+2011),所以 @RestController、C#、v1.2、SCOPE_PROTOTYPE、Mybatis‑Plus 都不会被拆开。然后照常往行里装,装不下的时候按这几条规则处理:
- 行首不能是闭合标点:
,。、;:?!)》」』】〕〉”’…—~·和对应的半角标点放不下时,把上一行的最后一个字(或最后一个英文词)连同它一起移到下一行。 - 行尾不能是开括号、开引号:
(《「『【〔〈“‘([{留在行尾时,跟着下一行走。 - 一行都放不下的长词(网址、包名、长常量名)只能拆,尽量在
/或.后面断,其次是_和-,都没有才按字符拆。 - 超出行数的最后一行加省略号,省略号前面的逗号、句号和开括号去掉。
- 摘要里的 Markdown 反引号去掉,海报上它只是一个个孤零零的符号。
处理“行首标点”的核心代码:
if (line && POSTER_NO_LINE_START.indexOf(token) >= 0) {
// 往回跳过行尾的空格和同样不能放行首的标点,找到前面那个字或英文词
var head = line.replace(/ +$/, '');
var cut = head.length;
while (cut > 0 && POSTER_NO_LINE_START.indexOf(head.charAt(cut - 1)) >= 0) cut--;
var unit = head.slice(0, cut).match(/(?:[A-Za-z0-9@#][A-Za-z0-9_\-\u2010\u2011.\/+#%'@&]*|[^ ])$/);
var start = unit ? cut - unit[0].length : 0;
// 它前面紧挨着的开括号也一起移走
while (start > 0 && POSTER_NO_LINE_END.indexOf(line.charAt(start - 1)) >= 0) start--;
if (start > 0) {
var moved = line.slice(start);
line = line.slice(0, start);
pushLine();
line = moved + token;
return;
}
// 整行就是一个词、前面没有能换下去的字:这一个标点挂在行尾
line += token;
return;
}
这段代码不是一次写对的,每改一版,就拿 372 篇重跑一遍统计。
第一版只移走了词本身,没管它前面的括号,还剩 4 篇出错。比如标题“NC100 把字符串转换成整数(atoi)”:第一行能放下“整数(atoi”,放不下后面的“)”,于是把“atoi”移到第二行,却把“(”留在了第一行的末尾。现在会往回跳过开括号,排成“整数”和“(atoi)”两行。
另一种是一整行只有一个带括号的长词,后面跟着的全角“)”放不下。往回找不到能移走的字,第一版的结果是“(”单独占一行。我先改成在长词的“.”后面断开,又把一个本来放得下一行的词拆开了。最后的做法是让这一个“)”挂在行尾,也就是排版里常说的标点悬挂:海报上那一行就是 (ConfigurableBeanFactory.SCOPE_PROTOTYPE) 加上一个全角“)”,比别的行宽出一个字。全角标点的笔画在左半边,挂出去的部分还在卡片的留白里。372 篇里只有这一处用到了它。
再往后,把统计的口径放严,带 @ 的词也算进去,又发现第一版会把 @RestController 拆成 @ 和 RestController,因为 @ 不能作为一个词的开头。于是改成了上面那个正则。最后一轮重跑,372 篇都是 0。
二、封面:跨域图片会让 canvas 导出失败
新海报要画文章封面和作者头像。封面在七牛 CDN 上,头像原来用的是 QQ 头像接口,两个都和博客不同源。
canvas 有一条安全规则:画过跨域图片、又没得到对方许可的 canvas 会被标记为“被污染”,之后 toDataURL 和 getImageData 都会直接抛异常。海报最后要导出成图片,一旦被污染就整张都存不了。
我在博客页面里用 Chrome 和 WebKit 各试了四种组合:七牛的图片带不带 crossOrigin,QQ 头像带不带 crossOrigin。两个浏览器的结果一样:
| 图片 | crossOrigin | 结果 |
|---|---|---|
| 七牛 | 设置 | 能导出 |
| 七牛 | 不设置 | 导出报错 |
| QQ 头像 | 设置 | 加载失败 |
| QQ 头像 | 不设置 | 导出报错 |
报错的写法不一样:
- Chrome:
Tainted canvases may not be exported.,完整的是SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement'加上这一句; - WebKit:
SecurityError: The operation is insecure.
原因看响应头就清楚了:七牛的响应带着 access-control-allow-origin: *,QQ 头像的响应没有任何跨域头。
能导出要同时满足两个条件:
- 服务器在响应里允许跨域;
- 页面在设置
src之前写上img.crossOrigin = 'anonymous'。
第二行说明,七牛就算允许跨域,不写这个属性也一样会污染画布:浏览器发的是不带跨域模式的普通请求,根本不检查跨域头。第三行说明,对不允许跨域的服务器写上这个属性,图片会直接加载失败,不会再污染画布。
所以海报这样处理:
- 头像:换成七牛上同一张图(和 QQ 头像是同一张照片)。
- 封面:按海报需要的尺寸从七牛取。封面在海报上是 630 个设备像素宽,取 640 宽、质量 80 的 JPEG。我抽了三张封面对比,平均 58 KB,原来 720 宽、质量 85 是 87 KB。
- 别的站的封面:也带上
crossOrigin试一次。对方不允许跨域时只是加载失败,海报改用渐变底,不会污染画布。头像加载失败就画一个“召”字。
这里还有一层防盗链:七牛会检查 Referer。带 crossOrigin 的请求照样会带上页面的来源,线上没有影响。但在本机 127.0.0.1 上测试时,七牛会拒绝这些请求,测试脚本要替浏览器补上站点的 Referer。
等多久
第一版给每张图最多等 2.5 秒,超时就用渐变底。在我这台电脑上,Chrome 第一次打开海报,三次都在 2.6 秒左右出来,用的都是渐变底,而 WebKit 1.6 秒就带着封面出来了。
我的网络到七牛要绕一层代理,建立一次加密连接要一两秒。按理说,打开文章页时页面已经和七牛建好了连接,取封面不用再握手。我把每个请求的连接耗时打出来看(海报的两个请求都带 crossOrigin):
| 请求 | Chrome | WebKit |
|---|---|---|
| 页面里的图片 | 复用连接 | 复用连接 |
| 海报封面 | 新建,握手 2.2 秒 | 复用连接 |
| 海报头像 | 复用封面那条 | 复用连接 |
Chrome 会按请求带不带凭据(Cookie 等)把连接分开:<img> 和 CSS 里的图片是带凭据的请求,crossOrigin="anonymous" 的请求不带凭据,用不了它们的连接。所以海报的第一张图要重新握手,这一下在我的网络上就是 2.2 秒,封面一共用了 2.9 秒,没赶上 2.5 秒的期限。<link rel="preconnect"> 要写 crossorigin 属性,也是这个原因。
用户的网络不一定比这好,所以改成了下面这样:
// 同一张图只下一次;超时不取消下载,关了再开就能用上
var posterImages = {};
function fetchPosterImage(src) {
if (!posterImages[src]) {
posterImages[src] = new Promise(function (resolve) {
var image = new Image();
image.crossOrigin = 'anonymous';
image.onload = function () { resolve(image); };
image.onerror = function () { delete posterImages[src]; resolve(null); };
image.src = src;
});
}
return posterImages[src];
}
// 每次生成海报最多等 6 秒
function loadPosterImage(src) {
return Promise.race([fetchPosterImage(src), new Promise(function (resolve) {
setTimeout(function () { resolve(null); }, 6000);
})]);
}
另外,鼠标移到、手指按到、键盘移到分享按钮上时,就开始下载封面和头像,那次握手也跟着提前了。没有在页面里加 preconnect,因为那样每个访问者都会多建一条连接,而会点分享的人并不多。
我用 Playwright 把封面请求人为延迟 8 秒,测了三件事:
- 鼠标移上去后,封面请求在 116 毫秒后发出;
- 第一次打开,6.1 秒出海报,用的是渐变底;
- 封面下完后关掉再打开,0.2 秒出海报,带封面,封面一共只请求了一次。
改完再在线上测:同样的网络,Chrome 第一次打开要 2.4 到 3.9 秒,WebKit 1.3 秒,两边都带着封面。图片已经缓存时再次打开,要 90 到 180 毫秒。
三、发虚的二维码
旧版把二维码生成在 84×84 的小 canvas 上,再画到海报对应的位置。海报导出是 2 倍分辨率,这一步相当于把二维码放大了一倍。
更糟的是,这篇文章的链接生成的是第 3 版二维码,每边 29 个模块。84 除以 29,每个模块约 2.9 个像素,本来就对不齐像素格,边缘已经是灰色的过渡像素,放大后就更虚了。
新版直接按导出的像素生成:二维码在海报上占 88×88,就生成 176×176,再一比一画上去,关掉图像平滑:
new QRCode(qrContainer, {
text: data.url,
width: qr.size * pixelRatio, // 88 × 2
height: qr.size * pixelRatio,
colorDark: '#151e2f',
colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// ……
ctx.imageSmoothingEnabled = false;
ctx.drawImage(qrCanvas, qr.x, qr.y, qr.size, qr.size);
同一篇文章新旧各生成一张海报,统计二维码区域里既不黑也不白的像素(亮度在 40 到 215 之间):旧版占 32.0%,新版是 0%。
两张都能被 jsQR 解出正确的链接,旧版只是边缘发虚。不过二维码常常被拍屏、被聊天软件压缩以后再扫,边缘越清楚,能扫的余量越大。
顺带一提,旧版生成二维码后要等 120 毫秒再取图。qrcode.js 在 canvas 模式下其实是同步画完的,这个等待可以去掉,现在只留 30 毫秒。
四、弹窗:整张海报一屏放下
旧的弹窗:
- 电脑上分左右两栏,右栏是几段说明文字;
- 手机上海报放在一个可以滚动的框里,框最高是
calc(94dvh - 218px)。以可视区 390×664 的手机为例,这个框最高 406 像素,海报却有 541 像素高,下面的二维码要在框里往下滑才能看到。
新弹窗只有标题、海报和“复制链接”“保存图片”两个按钮,说明文字全删了。海报的宽高直接由屏幕高度算出来:
#shareImageCanvas, #shareImagePreviewImg {
width: calc(min(600px, 100vh - 196px) * .625) !important;
height: min(600px, 100vh - 196px) !important;
width: calc(min(600px, 100dvh - 196px) * .625) !important;
height: min(600px, 100dvh - 196px) !important;
}
这里有几个细节:
- 196 像素是海报以外的部分:弹窗上下留白、标题栏、内边距、按钮。
- 宽高都写死,而不是用
max-width加max-height。脚本会给 canvas 写行内的width和height,所以要加!important。宽高一起写,比例就固定是 375∶600(0.625)。 - 先写一组
vh再写一组dvh:不认识dvh的旧内核会丢掉后一组,用前一组。
手机上的弹窗是贴在屏幕底部的抽屉。除了高度,还要按宽度限制:min(600px, 100dvh - 170px - 底部安全区, (100vw - 32px) * 1.6)。
几种屏幕实测下来,海报的显示尺寸是:
| 屏幕(CSS 像素) | 海报显示尺寸 |
|---|---|
| 电脑 1440×900 | 375×600 |
| 笔记本 1366×657 | 288×461 |
| Pixel 7(412×839) | 375×600 |
| iPhone 13(可视区 390×664) | 309×494 |
| iPhone SE(320×568) | 249×398 |
五种屏幕上,整个弹窗都没有出现滚动。
五、其他几处
- 微信里保存:微信内置浏览器长按 canvas 不会出现“保存图片”,
<a download>下载也没反应。所以海报画好以后转成<img>显示,长按就能存;在微信里点“保存图片”,按钮会提示“请长按图片保存”。 - 生成过程中关掉又打开:每次生成都有一个编号,图片下载回来时如果已经不是最新的那次,就不再往画布上画,免得两次生成互相覆盖。
- 海报上的数据从哪来:分类、标签、字数和阅读时长,由服务端渲染在分享弹窗的
data-*属性上。博客有六套主题,文章页的结构各不相同,从各自的页面里去抓这些信息并不可靠。
总结
- 中文断行不能逐字拆,要先把英文和数字切成词,再加上“行首不放闭合标点、行尾不放开括号”这两条规则。改完要拿真实数据跑一遍:旧算法在 372 篇里错了 288 篇,新算法的前几版也还漏了
(atoi)和@RestController这样的情况。 - canvas 画跨域图片,要服务器在响应里允许跨域,页面还要设置
crossOrigin,两者缺一不可。还要想好图片加载失败或太慢时海报怎么画。另外,在 Chrome 里这种请求要单独建连接。 - 二维码按导出的像素生成,一比一画上去,不要先生成小图再放大。
- 按屏幕尺寸算海报的宽高,比把海报放进一个能滚动的框里好用。

评论区 0