HTML-in-Canvas:技术概念

借助 HTML-in-Canvas API,您可以将交互式、无障碍的 DOM 子树直接渲染到 2D、WebGL 或 WebGPU 画布上下文中。

本指南概述了所有画布上下文通用的 HTML-in-Canvas 技术概念:

准备画布以呈现 HTML

如需在 <canvas> 内呈现 HTML 元素,请配置以下两个属性:

  • 在 <canvas> 上设置 content="drawable":这会使 canvas 元素布局其后代 DOM 节点,而不是仅将其视为隐藏的内容。
  • 在目标元素上设置 drawable:这会将元素标记为符合画布渲染条件的独立绘制根。
<canvas id="mainCanvas" width="600" height="400" content="drawable">
  <div id="parentElement" class="card" drawable>
    <h2>Container Title</h2>
    <p>Descriptive text inside the parent element.</p>
    <!-- Nested drawable: excluded from parentElement and drawn independently -->
    <button id="childElement" class="btn" drawable>Action Button</button>
  </div>
</canvas>

了解可绘制子树

在准备要渲染到画布中的 HTML 时,您需要决定应独立绘制哪些元素。每个 drawable 元素都代表一个独立的绘制根及其可绘制子树。

可绘制子树由具有 drawable 属性的元素及其所有后代组成,不包括任何同样带有 drawable 的嵌套后代,例如:

<canvas content="drawable">
  <!-- Subtree A (Renders parent container and text) -->
  <div id="parentElement" drawable>
    <h2>Parent Card Heading</h2>
    <p>Card body text content.</p>
    <!-- Subtree B (Excluded from Subtree A and drawn separately) -->
    <button id="nestedChildElement" drawable>
      Clickable Button
    </button>
  </div>
</canvas>
元素 绘制边界 行为
#parentElement(子树 A) 背景面板、标题和段落 调用 drawElementImage(parentElement, ...) 时呈现
#nestedChildElement(子树 B) 按钮表面和文字 从 #parentElement 子树中排除;在单独的 drawElementImage(nestedChildElement, ...) 调用中呈现
drawable 按钮嵌套在 drawable 卡片内,每个按钮都由其自己的 drawElementImage() 调用绘制。

通过嵌套可绘制元素,您可以保持语义 HTML 关系,同时可以自由地在画布场景中独立地对子元素进行分层、定位或添加动画效果。

了解 paint 事件

HTML-in-Canvas API 引入了 paint 事件。每当 的任何 drawable 后代发生布局、文本或 CSS 样式更改时,浏览器都会自动在 <canvas> 上分派 paint 事件。

canvas.addEventListener("paint", (event) => {
  // Clear the viewport
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Redraw elements individually
  ctx.drawElementImage(parentElement, 20, 20);
  ctx.drawElementImage(childElement, 40, 90);
});

paint 事件会分派 CanvasPaintEvent 的实例,该实例会扩展标准 DOM Event,并具有一个 changedElements 属性,该属性仅包含自上次绘制以来在视觉上发生变化(例如,样式更改、布局偏移、文本编辑或焦点状态更改)的 drawable 后代。

canvas.addEventListener("paint", (event) => {
  // Array of changed elements
  for (const el of event.changedElements) {
    console.log(`Element #${el.id} visually changed and needs repainting`);
  }
});
每当可绘制后代的渲染发生变化时,浏览器都会在画布上触发绘制。

帧生命周期

在 paint 事件内执行的画布绘制命令会显示在当前渲染更新中,但在 paint 事件内进行的 DOM 修改要到后续帧才会显示:

canvas.addEventListener("paint", () => {
  // Renders in the CURRENT rendering update:
  ctx.drawElementImage(parentElement, 20, 20);

  // Modifying DOM styles inside paint schedules a new layout cycle;
  // changes will appear in the NEXT frame:
  parentElement.style.backgroundColor = "#e0f2fe";
});

嵌套画布

如果您的网页包含嵌套的 <canvas content="drawable"> 元素,浏览器会按照 DOM 树的相反顺序(后代在祖先之前触发)分派 paint 事件。这可确保子画布元素在父画布元素合成它们之前进行渲染。

在以下示例中,假设内部 nestedElement 元素必须在视觉上发生变化(例如,当用户将指针悬停在元素上时,其 CSS 背景颜色会发生变化)。首先,paint 事件在 nestedCanvas 画布上触发。其监听器会清除子缓冲区,并调用 ctx.drawElementImage(nestedElement, ...) 来呈现更新后的状态。然后,paint 事件在 parentCanvas 上触发。其监听器会将 wrapperElement(其中包含已更新的 nestedCanvas 画布)绘制到场景中。

<!-- Outer parent canvas -->
<canvas id="parentCanvas" width="800" height="600" content="drawable">
  <!-- Wrapper container for nested content -->
  <div id="wrapperElement" drawable>

    <!-- Nested child canvas -->
    <canvas id="nestedCanvas" width="300" height="200" content="drawable">
      <div id="nestedElement" class="badge" drawable>
        Inner Child Content
      </div>
    </canvas>

  </div>
</canvas>

<script>
const parentCanvas = document.getElementById("parentCanvas");
const parentCtx = parentCanvas.getContext("2d");

const nestedCanvas = document.getElementById("nestedCanvas");
const nestedCtx = nestedCanvas.getContext("2d");

const nestedElement = document.getElementById("nestedElement");
const wrapperElement = document.getElementById("wrapperElement");

// ============================================================================
// 1. Child Canvas Listener (Fires FIRST)
// ============================================================================
nestedCanvas.addEventListener("paint", (event) => {
  // Clear and redraw the child canvas with the updated inner element
  nestedCtx.clearRect(0, 0, nestedCanvas.width, nestedCanvas.height);
  nestedCtx.drawElementImage(nestedElement, 10, 10);

  console.log("1. Child nested canvas rendered fresh pixels.");
});

// ============================================================================
// 2. Parent Canvas Listener (Fires SECOND)
// ============================================================================
parentCanvas.addEventListener("paint", (event) => {
  // Clear and redraw the parent canvas scene
  parentCtx.clearRect(0, 0, parentCanvas.width, parentCanvas.height);

  // Draws the wrapper container (including the freshly painted nestedCanvas)
  parentCtx.drawElementImage(wrapperElement, 50, 50);

  console.log("2. Parent canvas composited the complete scene.");
});
</script>
当 <canvas content="drawable"> 元素嵌套时,绘制事件会按树的逆序触发:嵌套画布会先绘制,以便父级画布可以绘制其最新像素。

触发 paint 事件

调用 requestPaint() 可在下一帧安排单个 paint 事件,即使 DOM 文本或样式未发生变化也是如此。这可用于渲染包含移动元素的互动场景,例如:

let animatedX = 0;

canvas.addEventListener("paint", () => {
  animatedX += 1.5; // Move the element horizontally
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawElementImage(staticElement, 20, 20);
  ctx.drawElementImage(movingElement, animatedX, 200);
  // Request canvas paint on each rendering update for smooth animation
  canvas.requestPaint();
});

canvas.requestPaint();

更新元素的几何图形

将 HTML 元素绘制到画布中时,绘制代码会决定元素显示的位置和大小。与常规 HTML 内容不同,元素的屏幕位置和大小(即其几何图形)无法仅通过其计算样式来确定。浏览器需要元素的几何图形来执行以下操作:

  • 将点击、点按和指针移动等输入事件发送到正确的元素。这称为点击测试。
  • 从布局查询(例如 getBoundingClientRect())中返回准确结果。
  • 向辅助技术(例如屏幕放大镜)报告元素的边界。

在 2D 模式下,drawElementImage() 默认会自动更新几何图形。您传递给它的参数,加上元素的计算样式和上下文的当前转换,足以确定元素出现的位置。

在 WebGL 和 WebGPU 上下文中,浏览器无法确定几何图形。HTML 元素会被复制到纹理中,然后着色器程序可以任意方式将该纹理映射到场景中。如需告知浏览器元素显示的位置,请将转换传递给 updateElementGeometry()。

当代码停止调用 updateElementGeometry() 时,浏览器将无法再知道 3D 卡片上的 HTML 元素的位置。卡片会不断翻转,但点击仍会落在卡片原来的位置。

如需更新几何图形,请向该方法传递空间转换。了解如何针对不同上下文构建转换:

管理点击测试堆叠顺序

默认情况下,每当元素的几何图形更新时,浏览器都会将其置于互动堆栈的顶部(最后绘制 = 最顶部的点击目标)。您可以使用 preserveHitTestOrder 控制此行为:

// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
  canvasTransform: customTransform,
  preserveHitTestOrder: true
});
选项 行为 使用场景
preserveHitTestOrder: false(默认) 将元素移至点击测试堆栈的顶部。 应首先接收点击的前景元素。
preserveHitTestOrder: true 将元素保留在当前堆叠优先级。 需要在前景元素之后更新的背景元素。

重置几何图形

如需在不从 DOM 中移除元素的情况下取消绘制元素,请调用 canvas.clearElementGeometry()。这会重置元素的画布转换,并在无障碍树中将其标记为屏幕外:

// Remove from canvas hit-testing and mark as offscreen
canvas.clearElementGeometry(childElement);

尺寸和单位

在画布中绘制 HTML 时,浏览器会同时在三个坐标系中运行:CSS 像素、设备像素和画布网格像素。

CSS 像素用于在布局期间测量和定位内容。然后,浏览器会按 devicePixelRatio 缩放这些像素,以将网页光栅化为实体设备像素。由于 CSS 像素是逻辑单位,因此位置和尺寸通常会以设备像素的分数形式呈现(例如,10.5 CSS 像素 × 1.5 的 DPR = 15.75 设备像素)。屏幕只能为整个像素着色,因此浏览器必须解析介于两者之间的任何内容。它通过以下两种方式来实现这一目标:

  • 贴靠:框边界、边框和图层偏移量会四舍五入到最接近的设备像素整数,以便保持清晰。
  • 抗锯齿:对于文本字形、曲线和经过转换的边缘,部分覆盖会融入相邻像素,因此它们看起来仍然平滑且位于正确的位置。

<canvas> 有自己的像素网格,由其 width 和 height 属性设置,与 CSS 像素和设备像素分开。为了显示该画布,浏览器会将画布位图重新采样到物理像素网格上,并混合相邻的画布像素来填充每个设备像素。如需了解浏览器如何在 CSS、画布和设备像素之间进行映射,请参阅使用 devicePixelContentBox 实现像素级完美渲染。

任何 drawable HTML 元素都以 CSS 像素为单位进行布局。由于绘制到画布中的内容可能会经历两次转换(从 CSS 像素到画布网格,然后从画布网格到物理像素),因此每个未完全对齐的步骤都会增加一轮混合,结果可能会看起来模糊。为避免这种情况,请使画布网格与物理像素网格保持一致。这有以下两个用途:

  • 一致的渲染:如果画布与物理像素 1:1 映射,则将 CSS 像素转换为画布像素会产生与浏览器将 CSS 像素转换为常规 HTML 的物理像素相同的结果。
  • 无需额外混合:如果画布与物理像素 1:1 映射,则将其转换为设备像素不会增加任何模糊。

下图展示了导致内容模糊的像素转换。这三个网格不一致:画布元素为 360 × 180 CSS 像素,画布大小为 300 × 150 画布网格像素。在设备像素比为 1.5 时,画布覆盖 540 × 270 个设备像素。一个 CSS 像素跨越 1.5 个设备像素,一个画布像素跨越 1.2 个 CSS 像素,即 1.8 个设备像素(面板 1)。绘制圆形时,其位置和半径以画布像素(而非 CSS 像素)为单位,因此浏览器会将其绘制到 300 × 150 画布位图中,并对边缘进行抗锯齿处理(面板 2)。为了显示画布,浏览器随后会将此位图拉伸到 540 × 270 设备像素,因此浏览器必须进行插值:每个设备像素都会采用最近画布像素(已包含抗锯齿边缘)的加权混合。边缘会再次混合并分布在多个设备像素上,因此圆圈看起来很模糊(面板 3)。如果画布网格与设备像素 1:1 匹配,则可以避免这种情况。然后,浏览器会直接在设备网格上对边缘进行一次抗锯齿处理,并在不重新采样的情况下显示位图,从而使边缘保持清晰(面板 4):

四面板示意图,比较了曲线边缘的像素网格:1. 未对齐的 CSS(360x180)、画布(300x150)和设备(540x270)网格。2. 曲线在 300x150 的粗糙画布网格上进行了抗锯齿处理。3. 该位图会插值到 540x270 的设备网格上,进行第二次抗锯齿处理并使边缘模糊。4. 540x270 的画布与设备网格 1:1 匹配,经过一次抗锯齿处理,边缘清晰。
未对齐的 CSS、画布和设备像素网格如何导致双重抗锯齿(面板 1-3),以及 1:1 画布到设备网格(面板 4)。

为了实现清晰的渲染效果,画布网格必须与画布覆盖的设备像素数相匹配:在前面的示例中为 540 × 270。

对照比较:一个 HTML 卡片,上面写着“Hello from the DOM!此卡片是绘制到画布中的 HTML。左图:在默认的 300x150 画布网格上绘制,看起来很模糊。右:在 800x400 的画布网格上绘制,使用 ResizeObserver 调整大小,看起来很清晰。
在 DPR 为 1.5 的屏幕上,同一画布的大小为 400 x 200 CSS 像素。左侧:400 x 200 的画布网格。右图:根据 DPR 调整为 600 x 300 网格。

使用 ResizeObserver 调整画布网格的大小

合适的网格大小取决于设备,并且可能会在布局发生变化、用户缩放页面或用户将窗口移至其他显示屏时发生变化。因此,不要对网格大小进行硬编码,而是让浏览器告诉您网格大小。

为使画布网格与屏幕上的画布保持同步,请始终使用 ResizeObserver 调整其大小,并将画布网格调整为 devicePixelContentBoxSize:

const observer = new ResizeObserver(([entry]) => {
  const dpc = entry.devicePixelContentBoxSize;
  canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * window.devicePixelRatio);
  canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * window.devicePixelRatio);
});

const supportsDevicePixelContentBox =
  typeof ResizeObserverEntry !== 'undefined' &&
  'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);

将 CSS 像素转换为画布网格坐标

paint 处理程序通常会将 DOM 测量与绘制调用结合使用,例如,根据元素的大小来定位元素。两者使用不同的单位:

  • 画布像素描述画布网格,其大小由 canvas.width 和 canvas.height 决定。例如,drawElementImage() 绘制方法的 dx、dy、dwidth 和 dheight 参数,或其他画布 API 参数,例如 fillRect() 和 translate()。
  • CSS 像素用于描述网页。DOM 测量值(例如 offsetWidth 和 clientWidth)以及指针事件坐标(例如 offsetX)均以 CSS 像素为单位。

下面简要介绍了各种 API 参数使用的单位:

值 单位
canvas.width和canvas.height 画布网格像素
2D 绘图参数,包括 drawElementImage() 的 dx、dy、dwidth 和 dheight 画布网格像素
canvas.getElementImageDefaultSize() 返回了 width 和 height 画布网格像素
来源矩形:drawElementImage() 和 texElementSubImage2D() 的 sx、sy、swidth 和 sheight;drawElementImageToTexture() 的 sourceX、sourceY、sourceWidth 和 sourceHeight CSS 像素
纹理尺寸:texElementSubImage2D() 的 width 和 height 选项;drawElementImageToTexture() 的 size 纹理像素。默认情况下,元素的大小以画布网格像素为单位,向上取整
元素几何图形:getElementTransform(),以及 updateElementGeometry() 的 canvasTransform 和 canvasClip 选项 CSS 像素
DOM 测量值,例如 offsetWidth、clientWidth、getBoundingClientRect()、指针事件坐标以及调整大小观察器条目的 contentBoxSize CSS 像素
devicePixelContentBoxSize(调整大小观察器条目) 设备像素

如果您使用了错误的单位,绘制到画布中的 HTML 内容的大小可能不符合您的预期。将 HTML 绘制到画布中时,您需要将 CSS 像素值转换为画布网格像素。为此,请计算画布网格大小与画布 CSS 大小之间的比率:水平方向为 canvas.width / cssContentWidth,垂直方向为 canvas.height / cssContentHeight。您可以在调整大小观察器中方便地计算该比率,如下所示:

// The number of canvas grid pixels per CSS pixel
let scaleX = 1;
let scaleY = 1;

const observer = new ResizeObserver(([entry]) => {
  const dpc = entry.devicePixelContentBoxSize;
  const cssContentWidth = entry.contentRect.width;
  const cssContentHeight = entry.contentRect.height;

  // Best practice: make the canvas grid match the physical pixel grid
  if (dpc) {
    canvas.width = dpc[0].inlineSize;
    canvas.height = dpc[0].blockSize;
  } else {
    canvas.width = Math.round(cssContentWidth * window.devicePixelRatio);
    canvas.height = Math.round(cssContentHeight * window.devicePixelRatio);
  }

  scaleX = canvas.width / cssContentWidth;
  scaleY = canvas.height / cssContentHeight;
});

const supportsDevicePixelContentBox =
  typeof ResizeObserverEntry !== 'undefined' &&
  'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);

然后,将 CSS 像素值乘以 scaleX 和 scaleY,再将其传递给绘制方法。例如,如需在画布中将可绘制元素居中,请执行以下操作:

const ctx = canvas.getContext('2d');
const card = document.getElementById('card');

canvas.onpaint = () => {
  ctx.reset();

  // offsetWidth and offsetHeight are in CSS pixels.
  // Convert them to canvas grid coordinates.
  const width = card.offsetWidth * scaleX;
  const height = card.offsetHeight * scaleY;
  const x = (canvas.width - width) / 2;
  const y = (canvas.height - height) / 2;

  // drawElementImage() takes canvas grid coordinates.
  ctx.drawElementImage(card, x, y);
};

最后,由于 drawElementImage() 会绘制元素的边框,因此请使用 box-sizing: border-box 将内边距和边框包含在卡片的 width 和 height 中,以便按照您设置的大小绘制卡片:

#card {
  /* Make width and height include padding and border */
  box-sizing: border-box;
  width: 200px;
  padding: 16px;
}

了解 HTML-in-Canvas 的无障碍功能

通常,<canvas content="drawable"> 的 HTML 内容会以与常规 DOM 相同的方式向无障碍功能公开,这意味着它会显示在无障碍功能树中,除非使用 aria-hidden="true" 或 inert 等属性明确隐藏。不过,有一个关键例外情况:drawable 元素的渲染几何图形尚未建立或更新。

如果出现以下情况,<canvas content="drawable"> 内的 drawable 元素会缺少渲染的几何图形:

  • 它尚未通过 drawElementImage() 呈现,默认情况下,会自动更新几何图形。
  • 其几何图形尚未通过 updateElementGeometry() 明确更新。

如果 drawable 元素缺少渲染的几何图形,则系统会将此 drawable 元素及其 drawable 子树报告给无障碍树,但不包含几何图形信息。例如,这有助于让屏幕阅读器访问语义内容,同时向视觉辅助工具(如放大镜)发出信号,以滤除未绘制的元素。

以下示例在分别绘制图片和图表时,保留了图表、图片和标题的语义:

<canvas id="canvas" width="400" height="300" content="drawable">
  <figure id="figure">
    <img drawable id="image" src="..." alt="image alt text" />
    <figcaption drawable id="caption">A caption</figcaption>
  </figure>
</canvas>
<script>
  const ctx = canvas.getContext('2d');

  canvas.onpaint = () => {
    ctx.reset();
    // Note: Because `preserveElementGeometry` defaults to false, the
    // `drawElementImage` call automatically updates geometry, ensuring
    // the image and caption have geometry information for accessibility.
    ctx.drawElementImage(image, 0, 0);
    ctx.drawElementImage(caption, 0, 200);
  };
</script>

后备策略

HTML-in-Canvas API 已在 Chrome 157 中发布,但可能并非所有浏览器都支持。为确保您的解决方案对所有用户都能顺畅运行,请检查浏览器是否支持该 API,并在必要时回退到备用解决方案:

export function supportsHtmlInCanvas() {
  if (typeof document === 'undefined') return false;
  const canvas = document.createElement('canvas');
  canvas.setAttribute('content', 'drawable');
  return canvas.content === 'drawable';
}

如果该 API 不受支持,您可以根据自己的实现目标,选择以下任一替代方案:

后续步骤