借助 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):
为了实现清晰的渲染效果,画布网格必须与画布覆盖的设备像素数相匹配:在前面的示例中为 540 × 270。
使用 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 不受支持,您可以根据自己的实现目标,选择以下任一替代方案:
后续步骤
- 了解如何在 2D 上下文中呈现 HTML。
- 了解如何在 WebGL 画布中渲染 HTML。
- 了解如何在 WebGPU 画布中渲染 HTML。
- 了解回退策略。
- 在 GitHub 上提交问题,分享您的反馈。
- 查看社区提供的 HTML-in-Canvas 演示集合。