首页那颗土星旁边的字卡(碎念、手记那些),以前一直是浮在 3D 画面上面的 DOM。这周 Chrome 157 正式推出 HTML-in-Canvas,虽然只有 157 以上吃得到,但迟早都会支援,做好 fallback 就好,所以我一开始就打定主意把它们搬进土星那张 canvas。现在字跟卫星由同一个 worker 在同一帧画出来,首页闲置时主执行绪的工作少了将近七成。

145ms/s
主执行绪总工作
原 460,−69%
24ms/s
样式重算
原 310,−92%
157+
支援的浏览器
Chrome/Edge,其他走 DOM

上面的数字是在 Chrome 158、CPU 降速 4 倍(模拟慢手机)、首页闲置 10 秒量的,而且是 dev 模式,正式版的绝对数字会不一样,比例应该差不多。

HTML-in-Canvas 是什么#

我觉得这是近几年少数真的会改变 Web 开发方式的平台更新。上一次有这种感觉是 WebGPU(Chrome 113,2023 年),它让浏览器能直接用现代 GPU。HTML-in-Canvas 补的是另一半,让 GPU 画得出「真正的 HTML」。WebMCP 也是最近的大新闻,但那是让 AI 代理操作网站,跟画面怎么画是两回事。总之很乐见这种东西出现,它解掉的是一个存在了十几年的老问题。

以前只有两条路#

在这之前,canvas 跟 HTML 是两个世界。想在 canvas(2D、WebGL、WebGPU)里放一段有排版的文字或一个按钮,只有两种做法,各有各的代价:

做法做法说明代价
把字画成像素fillText(),或把字做成贴图、SDF 字型萤幕阅读器读不到,要另外维护一份隐形的 DOM。不能 Ctrl+F 找、不能选取复制、没有右键选单,浏览器翻译和拼字检查都失效。多国语言的换行、断字、直排得自己带一套排版引擎
把 DOM 叠在 canvas 上每帧用 JavaScript 算 3D 座标,移动 HTML 元素跟 canvas 不在同一帧,镜头一动就飘、会抖。HTML 永远盖在整张 canvas 上面,不能被 3D 物件挡住、不能套 shader。每帧改样式吃主执行绪

我的首页用的是第二种,这篇后面量到的问题全都来自它。

现在多了第三条#

HTML-in-Canvas 让浏览器把一般的 DOM 元素「拍」成一张图,交给 canvas 去画。重点是元素本身还留在 DOM 里,所以:

  • 浏览器本来就有的功能全部保留:点击、Tab 键焦点、萤幕阅读器、文字选取、Ctrl+F、翻译、表单自动填入,都照常运作。
  • 排版交还给浏览器:CSS、字型、换行、多国语言、:hover 这些样式变化,画进去的就是浏览器排好的结果,不用自己写排版。
  • 画的时候是 GPU 贴图:可以放进 3D 场景、被物件遮住、跟着透视变形、套 shader 做扭曲或光晕,这些 DOM 叠层做不到。
  • 可以交给 worker:主执行绪拍快照,画的工作可以在 worker 的 OffscreenCanvas 里做,跟其他 3D 内容同一帧出来,不再飘。
  • 点击位置跟得上:画在哪里可以告诉浏览器,点击和无障碍的位置就对到画出来的地方。

提案里列的用途包括:图表的图例和座标轴、创作工具里的文字框、游戏选单、3D 场景里的萤幕或看板、用 shader 处理 HTML,以及把 HTML 汇出成图片或影片。Babylon.js、PlayCanvas、luma.gl 都已经有对应的写法。

它也有限制:基于隐私,跨网域的图片或 iframe、系统色、拼字检查的红线、已造访连结的颜色都不会被画出来。另外文字没有,看起来会比原本的 DOM 稍微淡一点。

先确认浏览器跟 API#

正式版改过名字#

这个 API 经过 之后改了名字,我查资料时看到的教学、文章、甚至框架的实作,很多都还是旧写法:

用途正式版写法旧写法
开启<canvas content="drawable">,子元素加 drawablelayoutsubtree
2Dctx.drawElementImage(el, x, y)同名
WebGLgl.texElementSubImage2D(...)texElementImage2D
WebGPUdevice.queue.drawElementImageToTexture(...)copyElementImageToTexture
重画时机paint 事件,要重拍就呼叫 canvas.requestPaint()
3D 里的点击canvas.updateElementGeometry(el, { canvasTransform })
给 worker 用主执行绪 captureElementImage(el) 拍下快照,transfer 过去

旧名在 Chrome 158 里已经是 undefined,照旧教学写会直接没反应。侦测的写法是设一次属性,看它有没有反射回来:

ts
function supportsHtmlInCanvas(): boolean {
  const canvas = document.createElement('canvas');
  canvas.setAttribute('content', 'drawable');
  return canvas.content === 'drawable' && typeof canvas.captureElementImage === 'function';
}

TypeScript 的 lib.dom 还没有这些 API,型别要自己宣告,而且全部标成可有可无,不支援的浏览器上它们都不存在。

最简单的用法#

先看 2D 的版本,整个 API 的骨架就在这里。canvas 加上 content="drawable",要画的子元素加上 drawable:

html
<canvas id="c" width="600" height="300" content="drawable">
  <div id="card" drawable>
    <h2>手记 NOTES</h2>
    <a href="/notes">最新一篇</a>
  </div>
</canvas>
ts
const canvas = document.querySelector<HTMLCanvasElement>('#c')!;
const ctx = canvas.getContext('2d')!;
const card = document.querySelector<HTMLElement>('#card')!;

// 元素的内容或样式变了(包括 :hover)浏览器就会发 paint,在这里重画
canvas.addEventListener('paint', () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawElementImage(card, 40, 60);
});
canvas.requestPaint(); // 要每帧动的话,在 paint 里再呼叫一次

几个规则:

  • drawable 是一个独立的绘制单位。 它底下的子孙会一起被画进去,但子孙里如果又有 drawable,那个会被排除,要另外画。
  • 2D 会自动对上点击位置。 drawElementImage 的参数加上 canvas 目前的变形,浏览器就知道元素画在哪,点击、getBoundingClientRect()、放大镜都跟着对。
  • WebGL/WebGPU 要自己告诉浏览器。 贴图被 shader 贴到哪里浏览器不知道,得把矩阵交给 updateElementGeometry()。不更新的话,东西转走了,点击还落在原地。clearElementGeometry() 则是让它点不到,并在无障碍树里标成画面外。

本机没有能测的浏览器#

我伺服器上的 Edge 是 148,apt 的稳定版也才 156,都不支援。Edge 157 只有 beta,要 sudo 装,最后用 npx @puppeteer/browsers install chrome@beta 装了 Chrome for Testing 158,放在家目录、不需要 sudo。伺服器没有萤幕,所以用 xvfb 开有视窗的模式,加上 Vulkan 的参数,才拿得到真的 WebGPU。

three.js 的 HTMLTexture 不能用#

three.js 从 r184 起就有 HTMLTexture,照理说直接拿来用就好。我读了专案里 r186 的原始码,有两个问题:

它用的是旧命名

它会设 layoutsubtree,呼叫 texElementImage2D 和 copyElementImageToTexture,这些在 157 以后都不存在了。改名的修正在 three.js PR #34732,作者正是写 Chrome 157 公告的那位 Chrome DevRel,但到我做的时候还没人 review。我顺便把刚发布的 r187 下载下来看,一样是旧名。

它假设 canvas 在主执行绪

它会把元素 appendChild 进 renderer.domElement,再挂 paint 事件。但我首页的星空和土星跑在 加 上,worker 拿不到 DOM。就算那个 PR 合并了,它还是从 domElement 拍快照,在我的架构里那条路根本不会走到。

所以上传的流程得自己写:

paint 事件:captureElementImage(字卡)postMessage(快照,transfer)drawElementImageToTexture → 贴图跟卫星同一帧画出来字卡在画面上的位置updateElementGeometry(点击落点)主执行绪worker(WebGPU)主执行绪worker(WebGPU)

目的地我用 three 的 render target 的贴图,r187 刚好新增了 renderer.initRenderTarget(),可以先把 GPU 贴图配好再写进去,大约 40 行就取代了 HTMLTexture。核心是这段:

ts
const rt = new THREE.RenderTarget(texW, texH, { depthBuffer: false });
renderer.initRenderTarget(rt);
const gpuTexture = renderer.backend.get(rt.texture).texture;

device.queue.drawElementImageToTexture(
  { source: image },                       // 主执行绪传过来的 ElementImage
  { texture: gpuTexture, size: { width: texW, height: texH } },
);
image.close();                             // 用完要释放

image.close() 是从那个 PR 学来的,我原型里一开始没写,每次 paint 都会留下一份没释放的快照。

原型:字卡绕着土星转#

动站上的程式码之前,我先在暂存区做了一个独立原型,完整模拟首页的架构:worker 里的 three.js WebGPU 画一颗土星,几张 DOM 字卡绕着它转。

右边那张卡离镜头近,有透视、比较大,滑鼠停在上面所以边框变成琥珀色,这代表 CSS 的 :hover 会即时进到贴图里。背后那张被土星挡住,是深度测试自然做到的。点击也对:对投影后的位置点下去,elementFromPoint 回传的是正确的卡,被挡住的卡清掉几何之后,同一个位置只会点到 canvas。

原型也踩到几个坑:

  • 贴图的色彩空间和 UV。 一开始底色变亮偏蓝、字上下颠倒,要处理色彩空间,平面的 也要上下翻。
  • 偶发的错误。 drawElementImageToTexture 大约 9 次会有 1 次丢出「No context found for ElementImage」。元素刚放进去、还没画过的那一刻拍不到,catch 住之后再呼叫 requestPaint() 重拍就好。
  • Tab 焦点跑到土星背后。 键盘焦点会落在被挡住的那张卡上,这得另外决定行为。
  • 远处的字比较软。 卡片离镜头远时贴图被缩小,要用 2 倍解析度拍。

先搬小标注:好处只有一个#

原型可行,我开了一个新的 worktree,第一步先拿首页五个常驻小标注(WORK、NOTES 那些)试水温,看实际接进站上到底有什么好处,滑过去才出现的字卡暂时维持 DOM。跟 ?hic=0(强制走原本的 DOM 版)对照之后,结果有点出乎意料:

  • 主执行绪卡住时,标注还贴着卫星。 这是唯一明显的好处。我刻意让主执行绪卡住 3.5 秒,卫星在 worker 里照常绕:DOM 标注冻结在原地,点跟字分家,画进 canvas 的标注全程跟着卫星走。
  • 主执行绪只少了 13%,样式重算少 20%。
  • 「被星球真实遮住」在首页几乎用不到。 最内圈轨道的远侧只擦过星球上缘,标注又一律往远离土星的方向长,我等了 2 分钟(将近两圈)一次都没遇到。
0:00 / 0:00
刻意让主执行绪卡住 3.5 秒:左边 DOM 标注冻结在原地,右边画进 canvas 的标注跟着卫星走

另外还有代价:画进去的标注要点得到,canvas 上面的三层(.horbit、<main>、z-index 10 的内容容器)都得让滑鼠穿过去,这个改动动到满深的地方。

主执行绪真正卡在哪#

13% 太少了,我想知道剩下的时间花在哪。用 录效能追踪,打开样式失效追踪、CPU 降速 4 倍,再把每次归到触发它的元素,一个一个拿掉来量:

首页闲置,CPU 降速 4 倍(每秒 ms)主执行绪样式重算
原本(DOM 标注)580–660420–494
标注搬进 canvas560–570360–400
再把字卡拿掉约 175约 50

大头是字卡,不是标注。字卡占了样式重算的将近九成,其中约六成来自一个很蠢的原因:闲置时会轮流提示,字卡的 opacity 每帧都在变,而 CSS 写了 transition: opacity 0.3s,所以每写一次就取消旧转场、建立新转场。

这部分不需要 HTML-in-Canvas 也能修,我先把淡入淡出改成在 JavaScript 里算,所有浏览器都受惠。剩下的那一半,是每帧替十几个元素写 inline style 本身,这只有把字卡也交给 canvas 画才拿得掉。这也说明了为什么只搬小标注省得少:真正每帧被改样式的大户,是字卡。

把字卡也搬进去#

修完转场,接着就是把字卡也搬进去。受惠的暂时只有 Chrome 157 以上,但支援只会越来越多,fallback 做好,今天先拿到好处的是用新版 Chrome 的人,之后就是所有人。

原则是「画面跟现在一模一样」,所以正式版跟原型有一个关键差别:字不进 3D 场景。它画在整张图的最上层、用萤幕座标、对齐装置像素,快照一个像素对一个像素贴上去,字不会被重新取样而糊掉。反正首页几乎遇不到真实遮挡,换来的是跟 DOM 版分不出来的字。

位置、淡入淡出、什么时候让位,我抽成一个纯函式的模组(lib/orbitLabels),DOM 版和 worker 共用同一套,两边算出来一定一样。

每一行要各自对齐#

字卡里的每一行会随卫星的位置对齐:卫星在右边时靠左、在左边时靠右、在下面时置中,而且是连续变化的。如果照旧每帧改每一行的 translate,又会触发样式重算加重拍快照,等于白做。

我的做法是整张字卡只拍一次,连同每一行的上缘和宽度一起送给 worker。worker 画的时候把快照切成横条,每一行是同一张贴图里的一个子区块,各自往左右挪,外围那一圈(键盘焦点的外框画在那里)不挪。这样字卡内容没变就不用重拍,每帧只动顶点。

什么时候要重拍,交给 paint 事件决定。它会带上这次有变的元素(changedElements),所以滑鼠移上去、换主题、字卡内容更新时,只重拍变了的那几张:

ts
canvas.addEventListener('paint', (e) => {
  const changed = e.changedElements;
  const out = [];
  let missed = false;
  for (const el of labels) {
    if (changed?.length && !changed.includes(el)) continue;
    try {
      out.push({ image: canvas.captureElementImage(el), w: el.offsetWidth, h: el.offsetHeight });
    } catch {
      missed = true; // 刚放进去还没画过,下一帧再拍
    }
  }
  if (out.length) worker.postMessage({ type: 'labels', labels: out }, out.map((l) => l.image));
  if (missed) canvas.requestPaint();
});

ElementImage 是可以 transfer 的,丢给 worker 不用复制,worker 那边上传完就 close()。

颜色差了三成#

第一版画出来,小字比 DOM 版暗了大约三成。追下去有两件事:

  • 快照是,我却当成预乘过,透明度乘了两次。
  • three 把画面输出到 canvas 时,会先除掉透明度、转成 sRGB、再乘回去。DOM 的字叠在 canvas 上,是在 sRGB 的数值上混色,如果在线性空间混,浅色字的反锯齿边缘会变细。

所以叠字那一步要照 three 的输出规则反推:先算出 canvas 上原本的 sRGB 数值,在这个数值上跟字混,再转回输出要的线性值。验证用一块已知颜色的半透明底:白色 50% 叠上去,预期 134.5,实际 132。

上下两张一张是 DOM 版、一张是画进 canvas 的,放大之后几乎分不出来。唯一的差别是 DOM 的字会落在小数像素上,canvas 版对齐整数像素,边缘稍微锐利一点。

点击和键盘#

worker 每帧算出字卡在画面上的框,传回主执行绪交给 updateElementGeometry,点击就落在画出来的位置。框跟上一帧一样就不传。

键盘和萤幕阅读器改走页面上一份看不见的连结,Tab 顺序跟以前一样。画进 canvas 的字卡标成 aria-hidden,不会被读两次。焦点外框画在 canvas 里的字卡上。

有一个小地方差点漏掉:为了画焦点外框,快照外围多留了一圈,结果滑鼠的点击范围也大了 7px。后来改成只有键盘聚焦时才把外围算进去,滑鼠的点击范围就跟 DOM 版完全一样。

结果#

每秒主执行绪(Chrome 158,CPU 降速 4 倍,首页闲置)DOM 版(?hic=0)画进 canvas
总工作460 ms145 ms(−69%)
样式重算310 ms24 ms(−92%)
脚本60 ms35 ms

主执行绪每帧只剩算「哪颗卫星亮着」,值没变就不送。上线后在正式站确认过:Chrome 158 走 HTML-in-Canvas,Edge 148 照旧是 DOM,两边都没有错误。Firefox、Safari、只有 WebGL2 的装置、3D 没跑起来的情况,也都是 DOM 版。

字变成贴图之后#

效能做完,我更好奇的其实是另一件事:字现在是 GPU 上的一张贴图,能不能做出 DOM 叠层做不到的效果,让它真正融进这个宇宙?我列了五个点子,各做了一版试作。这部分还在试作分支上,还没上线。

换页时,字被吸进卫星

原本点下字卡,字先消失,再把土星收进那颗卫星。现在字跟着一起被吸进去,越收越细长,最后一小段才淡掉。反过来,按上一页回到首页时,每个标注从自己那颗卫星「吐」出来,先细长、再展开回原形,而且跟着卫星弹出的顺序一个接一个出来。

0:00 / 0:00
点下字卡,字跟土星一起被吸进卫星,按上一页回来,标注再从各自的卫星吐出来(试作中)
讯号解码式浮现

字卡出现时,像无线电讯号收进来一样从卫星那一点往外推开,推到的边缘带红蓝错位和扫描线,大约半秒解完。实际速度很快,下面是放大 2 倍、放慢 5 倍的版本。

0:00 / 0:00
讯号解码式浮现,放大 2 倍、放慢 5 倍(试作中)
发光

英文小标周围一圈柔和的金光,跟卫星的光晕同一种质感。原本想直接接星空的 ,结果完全没作用:光晕落在画布透明的地方,透明度是 0,输出那一步整个被吃掉。最后改成在字这一层自己做高斯模糊。

光照

字卡朝光源那侧稍亮、另一侧稍暗,跟土星的受光方向一致。效果很淡,放大比对才看得出来,但看起来比较真实。

最开始其实还有一个「黑洞重力透镜」:黑洞主题下,字经过黑洞附近时跟背后的星星一起被拉弯。技术上最有趣,因为透镜的计算本来就有,字只要改成在偏折过的位置取样。

但实际看起来太弯了,有点抽象,而且字会离开自己的卫星一段距离,点击范围还停在没弯之前的位置。我最后把它拿掉,换成「吐出来」的版本:被吸进去是因为引力,那回来时倒着吐出来,刚好接得上。回到首页、土星长出来时,每个标注在自己那颗卫星弹出的时候,从那一点先细长、再展开回原形,大约 0.7 秒。

第一版直接沿用卫星弹出的进度,但那只有 0.25 秒,标注又是从透明淡入,看起来跟没做一样。后来改成标注自己跑一段动画,出来时就是不透明的,只靠形状从一点展开。回来时通常只有 10px 的小标注,所以吐出来比吸进去含蓄,放大放慢才看得清楚:

0:00 / 0:00
标注从卫星吐出来,放大 2 倍、放慢 4 倍(试作中)

这些效果只有 Chrome 157 以上看得到,所以只能是加分,功能不能靠它。之后收成正式版时,吸入吐出和解码会在 prefers-reduced-motion 时关掉。

要注意的地方#

  • 只能当加分。 目前只有 Chrome/Edge 157 以上支援,一定要有 DOM 版的退回路径。我的侦测失败、WebGL2、3D 没跑起来,或网址带 ?hic=0,都走 DOM。
  • 别照旧教学写。 先确认是正式版的命名,layoutsubtree、texElementImage2D 那一套在 158 已经不存在。
  • three.js 的 HTMLTexture 先别用。 截至 r187 还是旧命名,而且 canvas 在 worker 里的话,修正后也用不到。
  • 快照要 close。 ElementImage 用完不释放会一直累积。
  • 拍不到要重拍。 偶发的「No context found for ElementImage」catch 住,requestPaint() 下一帧再拍。
  • 先量再搬。 搬进 canvas 之前,先确定主执行绪的时间真的花在那些元素上。我第一步只搬小标注,省下的就只有 13%。

这次最让我有感的,其实是那段主执行绪卡住的对照影片:字只要交给 worker 画,主执行绪再忙都拖不动它,「吸进卫星、吐出来」也是在 worker 里跑,完全不受影响。换页转场卡顿的原因正是同一个:新页面挂上去时占住主执行绪,而转场的点也在主执行绪画。所以我接下来照同样的做法,把转场的点和彗尾也搬进 worker,已经在分支上动工了,做完再另外写。