你有没有想过一个问题为什么 Canvas 里的文字永远那么丑为什么游戏里的 UI 只能用 Canvas API 手画而不能直接写个div上去为什么每次做图表都要在ctx.fillText和 CSS 字体之间反复拉扯今天一个正在 Chromium 中孵化的 WICG 提案要彻底终结这个延续了二十年的困局。 目录背景Canvas 二十年的「盲人」困境前传为什么 Canvas 一直渲染不了 HTML核心原语一layoutsubtree——一纸委任状核心原语二drawElementImage——画布上的复印机核心原语三paint 事件——智能触发器BonuscaptureElementImage——通往 Worker 的传送门深水区事件循环中的时序博弈同步公式CSS Transform 背后的线性代数隐私保护看不见的边界生态地图谁已经上车了未解之谜与未来方向总结背景Canvas 二十年的「盲人」困境2004 年Apple 在 Safari 中引入了canvas元素随后被 WHATWG 和 W3C 标准化。二十多年来Canvas 成为 Web 上最强大的 2D/3D 图形基元——游戏、图表、数据可视化、创意工具、图像编辑……几乎一切像素级别的操作都跑在 Canvas 上。但它有一个致命的短板渲染不了真正的 HTML 内容。这听起来像是一个不应该存在的问题——我明明有一个div为什么不能把它「画」到 Canvas 上但现实是开发者们二十年来的解决方案只有这些方案原理问题ctx.fillText()手写文字排版不支持复杂文本、RTL、国际化排版html2canvas/ dom-to-image用 JS 重绘整个渲染树慢JS 模拟渲染引擎、不完整、不全准SVGforeignObject在 SVG 中嵌入 HTML无法与 Canvas 2D API 交互、不支持 WebGL/WebGPU截图上传你没看错手动截图当纹理根本不能算方案这些问题带来的连锁反应非常具体可访问性灾难Canvas fallback 内容和实际画出来的像素本质上没有约束关系。开发者写了一个披着aria-label外衣的canvas但里面到底画了什么屏幕阅读器和实际视觉内容完全是两套。国际化短板ctx.fillText不处理 RTL阿拉伯语/希伯来语、竖排文字、复杂脚本连字。如果你的图表需要显示阿拉伯语标签要么自己实现排版引擎要么放弃。游戏 UI 的割裂3D 场景中的 2D 界面菜单、对话气泡、HUD要么用 3D 引擎的内置 UI 系统学习成本高要么用 Canvas 手绘质量差要么用 DOM 覆盖层无法与 3D 场景融合。HTML-in-Canvas 提案的目标就是让开发者能把真正的 DOM 元素渲染到 Canvas 上用浏览器的原生排版引擎干活。前传为什么 Canvas 一直渲染不了 HTML在深入 API 之前先搞明白一个核心问题为什么这件事以前做不到浏览器的渲染流水线大致是这样的JS → Style → Layout → Paint → CompositeStyle计算 CSS 规则Layout计算盒模型位置Paint生成绘制指令列表display listComposite合成图层Canvas 的渲染是脱离这个流水线的。Canvas 的内容通过 JS 调用 Canvas APIfillRect、drawImage等写入一个位图缓冲区然后直接作为一个纹理交给 GPU。浏览器的渲染引擎Paint/Composite对 Canvas 内部发生了什么一无所知。而 HTML 元素的渲染走的是完整的 Style → Layout → Paint → Composite 管道。所以要把 HTML 渲染到 Canvas 上本质上是要让浏览器的渲染管道和 Canvas 的像素缓冲区之间建立一座桥——而且这座桥不能破坏安全模型不能引入性能问题还要支持可访问性。这比听起来难得多。直到 WICG/html-in-canvas 提出了一套优雅的解决方案。核心原语一layoutsubtree——一纸「委任状」提案的第一个原语是一个 HTML 属性——layoutsubtree。canvasidmyCanvaslayoutsubtreedividmyContenth2Hello Canvas!/h2p我可以在 Canvas 里用 HTML 渲染了/p/div/canvas加上这个属性的瞬间发生了三件关键的事情Canvas 的子元素获得了 stacking context成为了其后代元素的 containing blockCanvas 的子元素拥有了 paint containment绘制包含Canvas 的子元素参与了正常布局和 hit testing翻译成人话Canvas 的孩子虽然还在 DOM 树里但它们的视觉渲染被「截胡」了——浏览器的 Paint 阶段不会再把这些孩子渲染到屏幕上而是把它们的绘制结果存起来等着开发者用 API 取走。同时它们仍然参与布局、可访问性树和事件命中测试。这个设计有一个非常精妙的双重角色同一个元素既是视觉内容被绘制到 Canvas 中也是可访问性内容作为 Canvas fallback。不像现在的canvasfallback——画出来的东西和 fallback 内容是两套永远有不同步的风险。在 HTML-in-Canvas 中它们就是同一个东西。layoutsubtree就像是一纸「委任状」告诉浏览器「这些孩子交给我来画但请帮我把它们的布局和绘制结果准备好。」核心原语二drawElementImage——画布上的「复印机」有了layoutsubtree把布局和可访问性安排好下一步就是把子元素「印」到 Canvas 上。这就是drawElementImage()constctxcanvas.getContext(2d);canvas.onpaint(){ctx.reset();// 把 form_element 画到 Canvas 的 (100, 0) 位置consttransformctx.drawElementImage(form_element,100,0);// 同步 DOM 位置form_element.style.transformtransform.toString();};核心行为只接受 Canvas 的直接子元素这就是layoutsubtree标记的那些孩子调用时返回一个DOMMatrixCSS transform 矩阵你需要把这个矩阵应用到元素的style.transform上让 DOM 位置和画上去的位置保持一致Canvas 的当前变换矩阵CTMCurrent Transformation Matrix会作用于绘制——也就是说你可以在画布上ctx.rotate(45)然后drawElementImage元素就会旋转子元素的 CSS transform被忽略原因见下文——如果不忽略会导致双重变换溢出内容被裁切到元素的 border boxdestination rect 参数和drawImage一模一样// 最简形式在 (x, y) 处以原始尺寸绘制ctx.drawElementImage(element,x,y);// 指定目标尺寸ctx.drawElementImage(element,x,y,width,height);// 带 source rect 裁剪ctx.drawElementImage(element,sx,sy,sw,sh,dx,dy,dw,dh);WebGL 版本的接口是texElementImage2D把元素渲染到纹理// 当你需要把 HTML 内容作为 3D 纹理时gl.texElementImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,myElement);WebGPU 版本的接口是copyElementImageToTexturequeue.copyElementImageToTexture(myElement,destination);一个 API覆盖 2D Canvas、WebGL、WebGPU 三大图形上下文。形象一点理解drawElementImage就像是把浏览器的渲染引擎当作一台复印机你传一个 DOM 元素进去它返回一页「复印件」——而且附带一个坐标映射表DOMMatrix告诉你怎么把这页「复印件」在画布上的位置同步给 DOM。核心原语三paint 事件——智能触发器drawElementImage画的是快照。问题来了当元素的内容发生变化时比如输入框里有文字输入开发者怎么知道需要重新绘制这就是paint事件的用武之地canvas.addEventListener(paint,(event){// event.changedElements 包含渲染发生了变化的子元素ctx.reset();for(constelofevent.changedElements){consttctx.drawElementImage(el,0,0);el.style.transformt.toString();}});关键特性智能触发只有当子元素的视觉渲染真正发生变化时才会触发而不是 60fps 无脑循环。省电、省 CPU。时机在浏览器每一帧渲染管线的update-the-rendering阶段中紧跟在 intersection observer 步骤之后、Paint 步骤之前触发。... → IntersectionObserver → paint event → Paint → Composite → ...CSS transform 变化不触发因为 transform 影响的是位置而非渲染内容改变 transform 不会重新生成 paint 指令所以不触发paint事件。paint 内的 DOM 改动推迟到下一帧你在paint回调里改了元素的 class/文本这一帧不会生效下一帧才会。requestPaint()如果你需要每帧都重绘类似游戏循环可以调用canvas.requestPaint()强制触发 paint 事件行为和requestAnimationFrame类似。BonuscaptureElementImage——通往 Worker 的传送门有一个问题上面的所有 API 都依赖 DOM 元素引用但Web Worker 中无法访问 DOM。解决方案是captureElementImage()// 主线程捕获快照并传送到 Workercanvas.onpaint(){constelementImagecanvas.captureElementImage(form_element);worker.postMessage({elementImage},[elementImage]);// Transferable};// Worker直接绘制self.onmessage(e){if(e.data.elementImage){ctx.drawElementImage(e.data.elementImage,100,0);}};ElementImage是一个Transferable对象和ImageBitmap、ArrayBuffer一样支持零拷贝传输。这为OffscreenCanvas 在 Worker 中高性能渲染 HTML 内容铺平了道路。整个对象只有三个方法/属性width/height快照的尺寸close()释放资源轻量、简洁、高效。深水区事件循环中的时序博弈如果说前面的 API 是皮毛那下面这部分才是 HTML-in-Canvas 最深的设计决策——paint事件到底应该在哪一刻触发规范文档中记录了三种方案我们逐一分析Option A在 ResizeObserver 时机触发带循环位置在update-the-rendering流程的第 16.2.6 步Deliver resize observations如果 paint 事件中又修改了样式就循环回到第 16.2.1 步Recalculate styles and update layout。问题需要在这个时间点同步执行 Paint 步骤来生成子元素的绘制快照。Paint 本身很消耗性能还要可能跑多次。GeckoFirefox 的渲染引擎的架构导致了这里实现困难——某些引擎在这个时间点根本拿不到完整的绘制结果。最致命的问题WebGL。WebGL 的gl.getError()、gl.getParameter()等 API 需要触发 GPU 命令缓冲区刷新flush如果在 Paint 完成之前调用会导致死锁或不一致的渲染状态。Option B紧接在 Paint 步骤之后触发带循环位置在浏览器的 Paint 步骤完成后立即触发。优势不需要上面那种同步 Paint Canvas 子元素的操作——因为 Paint 已经跑完了每个元素的绘制结果是可用的。问题仍然需要循环。如果 paint event 中改动了 DOM又得回退到 style recalc → layout → paint 的完整循环可能在一次帧中跑多次十分昂贵。Option C紧接在 Paint 步骤之后触发不循环——被选中的方案核心思想paint event 在一帧中只跑一次。如果开发者在 paint event 中修改了 DOM改了就改了但这一帧已经锁死了——DOM 修改的效果留到下一帧的渲染管线去处理。这带来一个非常有趣的对称性浏览器的 Paint 步骤也是不可循环的——你无法在一个帧内让浏览器画两次。paint event 的行为和浏览器原生的 Paint 步骤完全对齐。方案 | 循环 | 是否需要同步 Paint | 兼容 WebGL | 复杂度 A | 是 | 是 | 否死锁 | 高 B | 是 | 否 | 是 | 高 C | 否 | 否 | 是 | 低 ✅这个决策过程是 HTML-in-Canvas 提案中最精妙的设计之一。它不强求开发者改了我就立刻刷新而是承认一帧内做到绝对实时是不现实的通过延迟到下一帧来换取架构的简洁性和跨浏览器兼容性。就像 React 的虚拟 DOM 不追求每次修改立刻更新真实 DOM一样HTML-in-Canvas 也不追求每个 CSS 变化都立刻刷新 Canvas 绘制。延迟带来一致性。同步公式CSS Transform 背后的线性代数前面提到drawElementImage()返回一个DOMMatrix需要设置到元素的style.transform上。为什么要这么做因为浏览器的 hit testing点击命中测试、intersection observer、可访问性功能都依赖元素的 DOM 位置。如果你把一个div画到了 Canvas 的 (100, 200) 位置但它在 DOM 树中还在原始位置点击 (100, 200) 就命中不了这个元素。解决方案是把 DOM 元素通过 CSS transform 移动到与绘制位置匹配。drawElementImage()返回的DOMMatrix就是按如下公式计算的T_sync T_origin⁻¹ · S_css→grid⁻¹ · T_draw · S_css→grid · T_origin其中T_draw绘制到 Canvas 上的变换矩阵等于CTM · T(x, y) · S(destScale)CTM 位置偏移 缩放T_origin元素的transform-origin矩阵S_css→gridCSS 像素到 Canvas 网格像素的缩放矩阵直观理解这个公式做的事情就是把在 Canvas 网格坐标系中的绘制位置反向映射回DOM 中的 CSS 像素位置。对于 WebGL/WebGPU 中的 3D 场景还有一个辅助方法canvas.getElementTransform(element, drawTransform)让你传入任意变换矩阵并计算出对应的 CSS transform。// 2D Canvas 直接返回consttransformctx.drawElementImage(element,x,y);// WebGL/WebGPU 需要手动计算constdrawTransformnewDOMMatrix([...]);// 你自定义的 3D 变换constcssTransformcanvas.getElementTransform(element,drawTransform);element.style.transformcssTransform.toString();重要提醒CSS transform 的变化不会触发paint事件——因为 transform 只影响位置不影响绘制内容所以paintevent 不会因为你在同步 transform 而反复触发。这避免了死循环。隐私保护看不见的边界drawElementImage()能让 Canvas 读取 DOM 元素的像素这就带来了一个安全问题如果 Canvas 能读取任何元素的内容那跨域保护怎么办提案的隐私模型遵循一个核心理念drawElementImage不会暴露任何 JavaScript 当前不可访问的信息。换句话说它不会打开新的攻击面。被排除在绘制之外的敏感内容排除项原因跨域 iframe、跨域图片同 CanvasdrawImage的跨域保护一致CSSurl()引用的跨域资源如background-image同上系统颜色/主题/偏好否则可通过像素读取猜出系统主题拼写/语法检查标记可能暴露用户的拼写习惯已访问链接的颜色经典的隐私泄露向量自动填充autofill预览内容包含敏感个人信息次像素抗锯齿可用作浏览器指纹不被视为敏感允许绘制的内容保留项理由页面查找Find in Page高亮低安全性影响滚动条和表单控件外观已可通过 SVGforeignObject检测光标闪烁频率低熵信息forced-colors模式已可通过 CSS media query 获取注意这是预防性设计——在提案还处于 WICG 孵化阶段就考虑了完整的安全模型。这与 W3C TAG 审查issue #1204和 WHATWG 标准化讨论中的安全关注点保持一致。生态地图谁已经上车了虽然提案还在孵化中浏览器端只有 Chrome Canary 和 Brave Stable (Chromium 147) 通过 flag 支持但开源社区已经在积极适配three.js — 原生 WebGL 纹理集成mrdoob/three.js 已经在 WebGL 和 WebGPU 两个渲染后端中集成了 HTML-in-Canvas// three.js 内部实现简化if(texElementImage2Dingl){constcanvasgl.canvas;if(!canvas.hasAttribute(layoutsubtree)){canvas.setAttribute(layoutsubtree,true);}// HTML 元素直接作为纹理源gl.texElementImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,htmlElement);}这意味你可以把任意的 HTML 内容作为 three.js 的纹理直接贴到 3D 模型上。相关 PR: mrdoob/three.js#31233PlayCanvas — 3D 产品配置器 HtmlSyncPlayCanvas 引擎甚至在官方示例中完整实现了基于 HTML-in-Canvas 的交互式 3D 产品配置器。一个 HTML 面板被渲染为 WebGL 纹理用户点击 3D 场景中的 HTML 按钮时的 hit testing 完全由浏览器的原生 DOM 事件处理——通过getElementTransform同步位置。关键助手类HtmlSync被设计为可复用的工具类处理 canvas ↔ 3D 平面的坐标映射。VFX-JS — 视觉特效框架fand/vfx-js 提供了一个优雅的addHTML()方法constvfxnewVFX();awaitvfx.addHTML(element,{shader:liquidGlass});它内部先检查supportsHtmlInCanvas()如果可用就使用原生 API否则优雅降级到传统的dom-to-canvas方案——渐进增强的最佳实践。three-html-render — 纯 JS Polyfill最令人兴奋的生态项目之一是 repalash/three-html-render——一个在浏览器不支持原生 API 时的 Polyfill。它通过 CSSmatrix3d()变换和iframe/embed技术模拟了drawElementImage的核心行为。即使你的用户没有启用chrome://flags/#canvas-draw-element这个 Polyfill 也能工作。这是一个很聪明的策略——用 Polyfill 降低采用门槛让框架生产环境可用。未解之谜与未来方向提案仍处于活跃讨论中仓库中有 16 个 open issues以下几个话题值得关注Open Issues 选读Issue核心问题#94 — Hit testing and layer orderingdraw 多个元素时z-index 如何与 hit testing 协调#85 —removedElements当子元素被删除paint 事件是否需要提供单独的removedElements列表#82 — 新的指纹向量onpaint事件即使不读取像素也能通过监听事件频率来获取指纹信息如光标闪烁频率#31 — 动图/视频支持GIF、WebP 动画、视频元素如何支持#47 —mix-blend-mode与backdrop-filter效果在 Canvas 中未正确反映未来自动更新 Canvas规范文档中提到了一个令人兴奋的未来方向——auto-updating canvas。目前的模型是你在paint事件中调用drawElementImage浏览器绘制快照。但如果支持了「自动更新模式」drawElementImage会在 Canvas 的命令缓冲区中记录一个占位符浏览器可以在滚动或动画更新时自动重新执行绘制无需阻塞 JS 主线程。这意味着 Canvas 中的 HTML 内容可以和原生滚动完美同步不再受 JS 事件循环的延迟影响。这个模式对 2D Canvas 已可行对 WebGPU 也只需少量 API 扩展。标准化进程提案正处于标准化流程的以下位置WICG 孵化当前→ WHATWG Stage 2 → WHATWG Standard → 浏览器默认启用WHATWG Spec PR: #11588W3C TAG 早期审查: #12042026年3月启动跨浏览器共识Chromium / Gecko / WebKit 已在设计上达成一致paint事件 Option C 时序总结HTML-in-Canvas 不只是 Canvas 的一个新功能——它是 Web 图形平台二十年来最重要的一次基础能力补全。它的核心贡献不是加了几个 API而是在浏览器的渲染流水线和 Canvas 的像素缓冲区之间架起了一座精心设计的桥梁layoutsubtree用属性声明边界drawElementImage用返回值解决同步paint用精妙的时序设计避免死循环和性能灾难captureElementImage用 Transferable 搞定 Worker 并行Three primitives one helper四个接口把把 DOM 渲染到 Canvas从不可能变成了可能——而且是在不破坏现有安全模型、不影响性能、保持可访问性的前提下。三个核心判断技术设计质量很高从事件时序的选择Option C到隐私模型的预防性设计到drawElementImage的返回值用作style.transform每个决策都有清晰的权衡分析。这不是一个先上线再说的功能。生态已经开始拥抱three.js、PlayCanvas、VFX-JS 等知名图形项目的积极适配远超预期。尤其在 3D 游戏和可视化领域需求非常强烈。还有一段路要走目前只在 Chromium flag 后可用Firefox 和 Safari 还没有明确的实现计划。标准化进程仍在 WICG 阶段。如果你是图形/可视化方向的开发者建议立刻打开 Chrome Canary启用chrome://flags/#canvas-draw-element跑一下官方 Demo。虽然它还不是正式标准但方向已经明确——而且这个方向可能改变前端图形生态的底层逻辑。关注 【iDao技术魔方】获取更多全栈到AI可落地的实战干货。