ByteNoteByteNote
流光卡片从 Canvas 改回 DOM
字

字节笔记本

2026年10月7日 · 约 8 分钟读完

流光卡片从 Canvas 改回 DOM

API中转
¥120

流光卡片的第一版是 Fabric.js 画出来的。脚本从 cdnjs 引了 5.3.1,画布 300 乘 200。一张圆角深色矩形当卡片,一个半透明圆从左边移到右边,animate 结束就把圆放回起点再来一遍。文字、渐变、导出是后面一轮轮加上的。导出先是发糊,再是只截到一部分,点一下还会冒出一张很小的图。最后画布被拿掉,改成普通 DOM,右边输入,左边预览。

画布导出的像素不会自动变清晰

光效本身只是一个在动的圆

Fabric 里卡片是 fabric.Rect,宽高跟画布一样,rx 和 ry 做圆角,填充先是纯色 #2a2a2a。光是 fabric.Circle,半径 50,填充 rgba(255, 255, 255, 0.3),起点在画布外的负坐标。动画只改 left,时长 3 秒,onChange 里调用 canvas.renderAll(),onComplete 里把 left 设回 -50 再递归调用。

javascript
light.animate("left", canvas.width + 50, {
  duration: 3000,
  onChange: canvas.renderAll.bind(canvas),
  onComplete: function () {
    light.set({ left: -50 })
    animate()
  },
})

渐变是后加在矩形的 fill 上的。文字用 fabric.Text,字号曾经放到 24,并留了内边距,避免字贴边。中文显示再换成带中文的 Google 字体。这些都还在画布的像素网格里,后面的导出问题也从这里来。

导出模糊,以及 multiplier 设成 1

导出按钮做的是 canvas.toDataURL({ format: "png", quality: 1 }),再造一个 <a download> 点一下。画布只有 300 乘 200,导出的就是这张位图。屏幕上 CSS 把卡片放大显示时,位图被拉大,边缘就会糊。中文字号一大,糊得更明显。

后面为了“导出完整卡片”,另建了一块 exportCanvas,把对象克隆过去再 toDataURL。参数里写了 multiplier: 1。这个值是放大倍数,1 就是原尺寸,清晰度不会比原画布更高。要更清楚,倍数得大于 1,并且克隆时的字号、画布宽高一起放大。只加这个字段、值还是 1,解决不了发糊。

只截到一部分是另一件事。画布宽高写死,文字变多或字号变大之后,内容画出了画布,toDataURL 仍然只取画布矩形。点击后出现一张小图,是因为下载的是画布像素,而页面上看到的是被 CSS 放大的样子,两者尺寸不一致。

不用画布之后,高度和换行要自己算

用户要求丢掉 canvas。改成左右两栏:左边是卡片预览,右边是 textarea。输入事件里把 textarea 的值写到左边的 textContent,两边才同步。导出改走 html2canvas,对左边的卡片节点截图,不再对 Fabric 画布截图。

动画在这一步被拿掉了。光效不是这张卡片的阅读重点,而且动画进行中截图,容易截到光斑停在半路的那一帧。

新的问题换成了排版。右边文字变多时,左边高度不跟着长,多出来的字被裁掉,看起来和以前“只导出一部分”是同一类现象,只是发生在 DOM 上。字号从右边调大或调小之后,换行位置也要重算,不能沿用上一个字号的行宽。

左边需要三件事同时成立:

  • 宽度固定,white-space 允许自动换行,长词还要有断行规则,避免一行撑破背景
  • 高度随内容涨,背景跟着内容,而不是内容被背景裁掉
  • 字号一变就重新排,因为换行点取决于字号和容器宽度

textContent 赋值不会保留用户在输入框里敲的多段空行策略以外的富文本,这正是卡片要的:纯文本,样式只来自卡片自己的字号和内边距。内边距要算进可用宽度里。字变大时如果仍按整张卡片的外宽去换行,字会画进左右 padding,看起来就像超出了背景色。

画布适合动效,不适合跟着字号长

流光那一下,用一个圆在矩形上移动就够了。一旦卡片要中文、要导出、要跟着输入变高,画布的像素尺寸就变成负担:导出清晰度取决于 multiplier 和画布像素,裁切取决于内容有没有画出矩形。DOM 把这些交回给正常排版。导出如果还要位图,再对已经排好的节点截图,而不是先在小画布上把字栅格化。

改回 DOM 后高度要跟着文字长

文字、字体和那张突然出现的小图

光效跑通之后,卡片上才加上文字,并要求有合适的内边距。字号一度放到 24。中文显示不清时,换成带中文覆盖的网页字体,而不是只换一个拉丁字体的名字。字体文件没加载完就导出,导出来的会是回退字体,边缘看起来也像糊,这和画布分辨率不够是两件同时存在的事。导出前至少等字体就绪,否则你会一直在调 multiplier,其实糊的是回退字形。

toDataURL 的 quality 设到 1,只影响有损格式的压缩。格式已经是 png 时,这个字段不会把 300 乘 200 变成高清。清晰度只跟画布像素和 multiplier 有关。multiplier: 1 的那次修改等于没有放大。若导出目标是两倍屏,倍数应是 2,导出的像素宽高各翻倍,字号和线宽在克隆到导出画布时也要按同一倍数放大,否则只是一张更大的糊图。

点击导出后出现一张很小的图,是因为下载文件的像素就是画布像素,系统预览按真实像素打开,而设计时眼睛看的是页面上被放大的 CSS 盒子。两边不一致时,人们会以为导出被裁过。裁切是另一条:内容画出了画布矩形,toDataURL 不会把画布外面的像素带上。诊断时先看导出图片的像素宽高是不是等于画布宽高乘以倍数。等于,就是没放大。小于画布上可见内容的范围,才是被矩形裁了。

改成 DOM 之后,html2canvas 是对已经排好版的节点拍照,节点多高,图就多高,这才对得上“文字变多高度要跟着变”。左边如果设置了固定高度加隐藏溢出,拍照结果仍会被裁,和画布裁切是同一个错误,只是属性从 canvas 的 width 换成了 CSS 的 height。背景色要包住文字,包括内边距。字号变大后重新计算换行,可用宽度是容器宽度减去左右内边距,不是容器的外边框。用外宽去排字,字会画进内边距,看起来就像超出了背景。

左右同步只用 input 事件写 textContent。不要在左边用富文本编辑,否则右边的纯文本和左边的样式会各改各的,高度更算不准。导出按钮放在右边输入区附近即可,它不应该再触发 Fabric 的动画循环。动画已经去掉,就不要在截图前再把光斑复位,那个复位只对画布版本有意义。 内边距在画布版本里是文字对象的坐标,不是 CSS。fabric.Text 的 left 和 top 要大于 0,右边也要留下同样的空白,字号 24 时这个空白尤其不能省。导出被裁,有时不是画布比文字小,而是文字的原点还在画布内、字形已经画出了右边界。克隆到 exportCanvas 时如果没有把全部对象都克隆过去,导出就会少一块光斑或少一行字。对话里的导出路径是遍历对象再 clone 到新画布,漏掉一个对象,新画布就是不完整的。

DOM 版本把这个问题变成了普通布局。左边卡片是一个会随文字增高的块,右边输入框的值和它始终相同。字号控件改的是左边的字号,改完必须让浏览器重新计算换行,不能把上一版的高度缓存下来。用户看到的“字变大之后行没有折回来”,就是高度或换行被缓存住了。导出用 html2canvas 时,对这个已经增高的块拍照,图片高度应等于块的布局高度。若图片高度仍是最初的固定值,说明拍到的是外层那个设了死高度的容器,而不是里面的文字块。

相关文章

分享: