
本提案旨在使用 2D 和 3D <canvas> 自定义 HTML 内容的渲染。
本文档为持续更新的说明文档,会根据收到的反馈不断调整。
本文档中描述的 API 已在 Chromium 中通过功能标记实现,可在 chrome://flags/#canvas-draw-element 中启用。
目前没有 Web API 能够便捷地将文本及其他内容的复杂布局渲染到 <canvas> 中。因此,基于 <canvas> 的内容在可访问性、国际化、性能与渲染质量上均存在短板。
<canvas> 可访问性的降级内容无法保证与实际渲染内容完全一致,且这类降级内容通常难以生成。使用本 API 后,绘制到 Canvas 中的元素将与其对应的 Canvas 降级内容保持一致。该方案引入三大核心基础能力:用于启用 Canvas 子元素的属性、将子元素绘制到 Canvas 的方法,以及用于处理更新的事件。
layoutsubtree 属性<canvas> 元素上的 layoutsubtree 属性会启用其后代元素的布局与命中测试。该属性会使 Canvas 的直接子元素创建层叠上下文、成为所有后代元素的包含块,并启用绘制隔离。Canvas 子元素默认可见,但其渲染结果对用户不可见,除非通过调用 drawElementImage()(见下文)将其显式绘制到 Canvas 中。
drawElementImage(及 WebGL/WebGPU 对应方法)drawElementImage() 方法将 Canvas 的子元素绘制到 Canvas 中,并返回一个可应用于 element.style.transform 的变换矩阵,用于将 DOM 位置与绘制位置对齐。
在 paint 事件触发前,浏览器会记录 Canvas 所有子元素的渲染快照。在 paint 事件期间调用时,drawElementImage() 会绘制子元素在当前帧的显示效果;在 paint 事件外调用时,则使用上一帧的快照。若在初始快照记录完成前调用 drawElementImage() 传入子元素,会抛出异常。
要求与约束:
<canvas> 上必须指定 layoutsubtree。element 必须是 <canvas> 的直接子元素。element 必须生成盒子(即不能为 display: none)。width/height 参数指定 Canvas 坐标系中的目标矩形。若省略,默认按元素在 Canvas 外的实际屏幕尺寸与比例在 Canvas 坐标系中渲染。WebGL/WebGPU 支持:
为 3D 上下文添加类似方法:WebGLRenderingContext.texElementImage2D 与 copyElementImageToTexture。
paint 事件paint 事件被添加到 canvas 元素,当任意 Canvas 子元素的渲染发生变化时触发。该事件在 update-the-rendering 流程中交叉观察器步骤执行完成后触发。事件包含发生变化的 Canvas 子元素列表。
由于 Canvas 子元素的 CSS 变换在绘制时被忽略,修改变换不会在下一帧触发 paint 事件。在 paint 事件中执行的 Canvas 绘制命令会在当前帧生效,但在 paint 事件中做出的 DOM 修改要到下一帧才会显示。
为支持逐帧更新的应用模式,新增 requestPaint() 函数,可强制 paint 事件触发一次,即使子元素未发生变化(作用类似 requestAnimationFrame())。
captureElementImage为支持 Worker 中的 OffscreenCanvas,可通过 canvas.captureElementImage(element) 将元素快照捕获为 ElementImage 对象。该对象可被转移到 Worker 并绘制到 OffscreenCanvas。
命中测试、交叉观察器、可访问性等浏览器功能依赖元素的 DOM 位置。为保证这些功能正常工作,应更新元素的 transform 属性,使 DOM 位置与绘制位置一致。
其中:
drawElementImage 而言,该矩阵为 $$CTM \cdot T_{(\text{x}, \text{y})} \cdot S_{(\text{destScale})}$$,其中 $$CTM$$ 为当前变换矩阵,$$T_{(\text{x}, \text{y})}$$ 为 x、y 参数对应的平移矩阵,$$S_{(\text{destScale})}$$ 为宽高参数对应的缩放矩阵。transform-origin 的平移矩阵。为辅助同步,drawElementImage() 会返回可应用于元素的 CSS 变换,使其位置保持同步。对 3D 上下文,提供 getElementTransform(element, drawTransform) 辅助方法,传入通用变换矩阵即可返回 CSS 变换。
Worker 线程中用于绘制元素的变换需要同步回 DOM;若位置固定,可直接通过 postMessage() 传回主线程。若位置动态变化,可在主线程计算位置,并在发送 ElementImage 对象到 Worker 线程的同时更新 element.style.transform。
<canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree> <form id="form_element"> <label for="name">name:</label> <input id="name"> </form></canvas> <script> const ctx = document.getElementById('canvas').getContext('2d'); canvas.onpaint = () => { ctx.reset(); const transform = ctx.drawElementImage(form_element, 100, 0); form_element.style.transform = transform.toString(); }; // 将 Canvas 网格尺寸适配设备像素比,避免模糊 const observer = new ResizeObserver(([entry]) => { canvas.width = entry.devicePixelContentBoxSize[0].inlineSize; canvas.height = entry.devicePixelContentBoxSize[0].blockSize; }); observer.observe(canvas, {box: 'device-pixel-content-box'});</script>
本示例在 Worker 中使用 OffscreenCanvas。在 paint 事件中,将 Canvas 子表单捕获为 ElementImage 对象并转移到 Worker 进行绘制。
<!DOCTYPE html><canvas id="canvas" style="width: 400px; height: 200px;" layoutsubtree> <form id="form_element"> <label for="name">name:</label> <input id="name"> </form></canvas><script> const workerCode = ` let ctx; self.onmessage = (e) => { if (e.data.canvas) { ctx = e.data.canvas.getContext('2d'); } if (e.data.width && e.data.height) { ctx.canvas.width = e.data.width; ctx.canvas.height = e.data.height; } if (e.data.elementImage) { ctx.reset(); const transform = ctx.drawElementImage(e.data.elementImage, 100, 0); self.postMessage({transform: transform}); } }; `; const worker = new Worker(URL.createObjectURL(new Blob([workerCode]))); const offscreen = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: offscreen }, [offscreen]); canvas.onpaint = (event) => { const elementImage = canvas.captureElementImage(form_element) worker.postMessage({ elementImage: elementImage }, [elementImage]); }; // 同步元素的 CSS 变换以匹配绘制位置 worker.onmessage = ({data}) => { form_element.style.transform = data.transform.toString(); }; // 将 Canvas 网格尺寸适配设备像素比,避免模糊 const observer = new ResizeObserver(([entry]) => { worker.postMessage({ width: entry.devicePixelContentBoxSize[0].inlineSize, height: entry.devicePixelContentBoxSize[0].blockSize }); canvas.requestPaint(); }); observer.observe(canvas, { box: 'device-pixel-content-box' });</script>
partial interface HTMLCanvasElement { [CEReactions, Reflect] attribute boolean layoutSubtree; attribute EventHandler onpaint; void requestPaint(); ElementImage captureElementImage(Element element); DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);}; partial interface OffscreenCanvas { DOMMatrix getElementTransform((Element or ElementImage) element, DOMMatrix drawTransform);}; interface mixin CanvasDrawElementImage { DOMMatrix drawElementImage((Element or ElementImage) element, unrestricted double dx, unrestricted double dy); DOMMatrix drawElementImage((Element or ElementImage) element, unrestricted double dx, unrestricted double dy, unrestricted double dwidth, unrestricted double dheight); DOMMatrix drawElementImage((Element or ElementImage) element, unrestricted double sx, unrestricted double sy, unrestricted double swidth, unrestricted double sheight, unrestricted double dx, unrestricted double dy); DOMMatrix drawElementImage((Element or ElementImage) element, unrestricted double sx, unrestricted double sy, unrestricted double swidth, unrestricted double sheight, unrestricted double dx, unrestricted double dy, unrestricted double dwidth, unrestricted double dheight);}; CanvasRenderingContext2D includes CanvasDrawElementImage;OffscreenCanvasRenderingContext2D includes CanvasDrawElementImage; partial interface WebGLRenderingContext { void texElementImage2D(GLenum target, GLint level, GLint internalformat, GLenum format, GLenum type, (Element or ElementImage) element);}; partial interface GPUQueue { void copyElementImageToTexture((Element or ElementImage) source, GPUImageCopyTextureTagged destination);}[Exposed=Window]interface PaintEvent : Event { constructor(DOMString type, optional PaintEventInit eventInitDict); readonly attribute FrozenArray<Element> changedElements;}; dictionary PaintEventInit : EventInit { sequence<Element> changedElements = [];};[Exposed=(Window,Worker), Transferable]interface ElementImage { readonly attribute unsigned long width; readonly attribute unsigned long height; undefined close();};
drawElementImage API 绘制旋转的复杂文本。drawElementImage API 绘制带多行标签的饼图。copyElementImage API 在果冻滑块下方绘制 div。texElementImage2D API 将 HTML 绘制到 3D 立方体上。基于 three.js 实验扩展实现的相同效果演示地址为此处。更多说明与背景信息见此处。
drawElementImage() 方法、其他所有绘制元素图像快照的方法,以及 paint 事件,均不得泄露任何对开发者代码不可见的安全或隐私敏感信息。
渲染(通过 Canvas 像素读取或计时攻击)与失效(通过 onpaint)均存在泄露敏感信息的风险,解决方案是在渲染与失效时排除敏感信息。
敏感信息包括:
<iframe>、<img>)、<url> 引用(如 background-image、clip-path)与 SVG(如 <use>)中的跨域数据。注:同源 iframe 仍可正常绘制,但其中的跨域内容不会被绘制。以下新增信息不视为敏感信息:
forced-colors 媒体查询与系统颜色在 JavaScript 中获取)。可在 Chrome Canary 中通过 chrome://flags/#canvas-draw-element 启用 HTML-in-Canvas 功能。
我们重点关注以下方向的反馈:
请在此处提交缺陷或设计问题。
paint 事件触发时机需要新增 paint 事件,让开发者有机会在绘制变化时更新 Canvas 渲染。该事件被整合进 update-the-rendering 流程,使 Canvas 更新与 DOM 同步。
在 update-the-rendering 步骤中,paint 事件有多个可触发时机:
16.2.1. 重新计算样式并更新布局。
16.2.6. 分发尺寸观察器回调,必要时循环回到 16.2.1。
选项 A:在尺寸观察器时机触发 paint,必要时循环回到 16.2.1。
绘制阶段:计算元素的最终绘制输出。该步骤在 update-the-rendering 中无显式命名。
选项 B:在绘制阶段后立即触发 paint,必要时循环回到 16.2.1。
选项 C:在绘制阶段后立即触发 paint。
提交/线程切换:将绘制输出传递到其他进程。该步骤在 update-the-rendering 中无显式命名。
注:本提案中的 paint 事件是 Canvas 上新增的事件,而绘制阶段是浏览器按绘制顺序记录渲染树绘制输出的现有操作。
paint,必要时循环执行与尺寸观察器类似,需要循环处理 paint 事件中可能产生的修改(包括 Canvas 外元素)。无法限制 JavaScript 对 DOM 的任意修改,因此需要比尺寸观察器更多的循环条件,如背景样式变化。循环的缺点是开发者的 Canvas 代码可能每帧执行多次。
一种方案是同步执行绘制阶段,快照 Canvas 子元素的绘制输出。缺点是绘制阶段开销可能较高,且可能需要多次执行。该方案在 Gecko 及其他引擎中因架构限制存在独特的实现难题。
另一种方案是不同步执行绘制阶段,而是记录一个占位符,表示元素在下一次渲染更新中的显示效果(见设计文档)。该模式可在 2D Canvas 中实现,通过缓冲 Canvas 命令直到下一次绘制阶段。当下一次绘制阶段执行时,占位符会被替换为实际渲染结果。getImageData 等需要同步刷新 Canvas 命令缓冲区的操作,会对占位符显示空白或旧数据。
遗憾的是,该方案对 WebGL 存在根本性缺陷——许多 API 需要刷新命令缓冲区(如 getError(),见 WaitForCmd 调用点),调用这类 API 会导致死锁或渲染不一致。因此,paint 事件必须在元素完整绘制显示列表已就绪的时机触发。
paint,必要时循环执行循环的原因与缺点见上文。 相比选项 A,选项 B 的优势是无需对 Canvas 子元素执行部分绘制;缺点是每次循环需要执行更多 update-the-rendering 步骤。
paint本 API 采用该设计。
该方案每帧仅执行一次 paint,与浏览器自身绘制阶段一致。为解决 JavaScript 可任意修改 DOM 的问题,需确保 paint 运行前已锁定本次渲染更新的内容,唯一例外是 Canvas 的绘制内容。paint 事件中产生的 DOM 失效会作用于下一帧,而非当前帧。
为支持线程化特效,我们探索了一种设计方案:将 Canvas 子元素“快照”发送到 Worker 线程。响应线程化滚动与动画时,Worker 线程可将最新快照渲染到 OffscreenCanvas。该模式要求 JavaScript 能在滚动与动画更新时同步调用,而在受限进程中执行线程化滚动更新的架构难以实现这一点。
为支持滚动、动画等线程化特效,我们计划在未来推出“自动更新 Canvas”模式。
在该模式下,drawElementImage 会记录代表最新渲染的占位符。Canvas 保留命令缓冲区,可在每次滚动或动画更新后自动重放。这使 Canvas 能使用更新后的占位符重新光栅化,整合线程化滚动与动画,无需阻塞主线程。该能力可实现与原生滚动或 Canvas 内动画完全同步的视觉特效,不受主线程影响。该设计适用于 2D 上下文,对 WebGPU 只需少量 API 补充即可实现。