Published on

从 Hello World 入门 HTML-in-Canvas

Authors
  • avatar
    Name
    葛颂
    Twitter

HTML-in-Canvas 试图解决一个老问题:用 Canvas / WebGL / WebGPU 做高性能渲染时,往往要放弃 HTML 自带的排版、选区、表单和无障碍能力。新的实验 API 允许把真实 DOM 元素画进 canvas,同时尽量保住这些能力。

官方最简示例是 Hello World:一个带渐变字的 div,被反复绘制成旋转光环,中间还有一份可编辑、可选中的「主角」副本。

在线 Demo

下面是把官方 Hello World 嵌进本站的交互版本。组件会先检测当前浏览器是否支持 HTML-in-Canvas:支持则直接运行;不支持则提示开启方式(详见下文「先决条件」)。

正在检测 HTML-in-Canvas 支持…
Hello, I'm 58Ge!

先决条件

HTML-in-Canvas 还不是全平台默认能力,跑 demo 前需要同时满足下面几条。

1. 浏览器版本

目前主要可用环境(见 Browser Support):

浏览器要求
Chrome Canary开启实验 flag
Brave StableChromium 147+(文档确认过约 Brave 1.89.132 / Chromium 147.0.7727.56),并开启 flag
Firefox / Safari暂无实现
其他 Chromium 分支取决于底层 Chromium 是否已合入相关代码,可自行尝试同名 flag

没有匹配版本时,页面通常会提示 API Unavailable,demo 无法真正绘制。

2. 打开实验 Flag

在地址栏打开(Chrome 出于安全考虑不能直接从网页跳转到 chrome://,需要手动粘贴):

  • Chrome Canary:chrome://flags/#canvas-draw-element
  • Brave:brave://flags/#canvas-draw-element

将 Enable the new drawElement API for Canvas 设为 Enabled,然后重启浏览器。

该 flag 对应 Chromium 的 CanvasDrawElement 实验,细节可参考 Chrome Platform Status 与 Intent to Experiment。

3. 环境自检(推荐)

跑自己的页面时,先确认 API 是否存在:

const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')

const drawElementInto =
  ctx.drawElementImage?.bind(ctx) ?? ctx.drawElement?.bind(ctx)

if (!drawElementInto) {
  console.warn('HTML-in-Canvas 不可用:请检查浏览器版本与 canvas-draw-element flag')
}

Hello World demo 里也做了 drawElementImage / drawElement 双名兼容——Canary 早期可能同时暴露两套命名,规范侧更偏向 drawElementImage。

Demo 在做什么

Hello World 的结构可以概括成三层:

  1. 一个真实 DOM:<div id="content">Hello, Canvas!</div>,用户可改文字、可选中。
  2. 一块带 layoutsubtree 的 canvas:子树在 canvas 内参与布局,才能被「画进去」。
  3. 每帧 onpaint:用同一个 DOM 画多份环上的副本,再画中心 hero,并把返回的变换写回 DOM,让命中测试 / 选区和像素对齐。

核心标记大致是:

<canvas id="canvas" layoutsubtree>
  <div id="content">Hello, Canvas!</div>
</canvas>

layoutsubtree 是 opt-in:告诉浏览器「请布局并维护这块 canvas 里的子树」,否则就不会有可供 drawElementImage 使用的绘制记录。

最小绘制循环

注册绘制回调

canvas.onpaint = () => {
  ctx.reset()
  // …变换、透明度、drawElementInto(content, dx, dy)
}

动画不要只靠自己瞎画 bitmap,而应通过 canvas.requestPaint?.() 走进浏览器的 paint 管道,让 drawElementImage 依赖的缓存绘制记录保持更新:

function loop(now) {
  phase += ((now - lastFrame) / 1000) * speed * 0.6
  lastFrame = now
  canvas.requestPaint?.()
  requestAnimationFrame(loop)
}

把同一个元素画很多次

环上的装饰副本只负责「看起来花」,不必同步 DOM:

ctx.save()
ctx.translate(px, py)
ctx.rotate(angle + Math.PI / 2)
ctx.scale(scale, scale)
ctx.globalAlpha = 0.25 + depth * 0.75
drawElementInto(content, -halfW, -halfH)
ctx.restore()

中心 hero 最后画,并把返回的 DOMMatrix 写回元素,这样文字选区仍能对上画面:

const heroTransform = drawElementInto(content, -halfW, -halfH)
if (heroTransform) {
  content.style.transform = heroTransform.toString()
}

尺寸:按 CSS 像素 1:1,而不是 DPR 放大

Chrome 的 layoutsubtree 会把 canvas.width / canvas.height 当作子树的布局视口。若按传统 Retina 写法把 bitmap 乘上 devicePixelRatio,子树布局宽度会被拉大,百分比 padding、换行都会错。

Demo 的做法是:

new ResizeObserver(([entry]) => {
  const { width, height } = entry.contentRect
  canvas.width = Math.round(width)
  canvas.height = Math.round(height)
  canvas.requestPaint?.()
}).observe(canvas)

代价是高分屏上可能略糊一点,但布局语义是对的。

三个关键原语(记这组就够)

结合 Spec Overview 与 Hello World,日常会碰到的是:

原语作用
layoutsubtree(属性)让 canvas 子树参与布局,成为可绘制源
drawElementImage(2D)把子元素画进 2D context(另有 WebGL / WebGPU 对应接口)
onpaint + requestPaint在浏览器绘制管线中更新画面

动机很直接:高性能自定义渲染,又不必从零重做排版、交互和无障碍。详见 ChromeStatus 概述。

小结

要玩 HTML-in-Canvas,先确认:

  1. Chrome Canary 或 Brave Stable(Chromium 147+)
  2. 打开 chrome://flags/#canvas-draw-element(Brave 用 brave://)并重启
  3. 代码侧探测 drawElementImage / drawElement 是否存在

然后按 Hello World 的模式:layoutsubtree 挂子树 → onpaint 里 drawElementImage → 用 requestPaint 驱动帧循环 → bitmap 与 CSS 像素 1:1。