Canvas+DOM混合渲染架构:突破浏览器长文档编辑器性能瓶颈 做浏览器端文本编辑器这么多年我最大的感受就是编辑器性能和体验的天花板往往不是浏览器本身而是渲染方案的选择。早期我用纯 DOM 实现过一个代码编辑器内核四千行以内的文档还能撑住一旦文档量上到一个中等规模——两万行以上、每行 120 个字符——输入延迟就开始变得明显拖动滚动条的时候甚至能感受到页面在“蠕动”。后来我转向了 Canvas DOM 混合渲染架构这个方案彻底解决了长文档渲染的性能瓶颈。简单来说就是把文本内容交给 Canvas 绘制把光标、选区装饰、IME 输入法浮层这些需要浏览器原生交互能力的部分交给 DOM 层处理。两种渲染方式各干各擅长的事这个组合让我在一个真实项目里拿到了接近原生编辑器的滚动和输入体验。这篇文章我会从架构设计的思路讲起一直到核心实现、踩坑记录和排查技巧尽量讲清楚每一步的“为什么”。如果你也在做复杂的富文本编辑器、代码编辑器、Markdown 编辑器或者任何需要渲染大量行文本的 Web 应用这篇文章值得你花二十分钟看完。1. 为什么需要混合渲染架构1.1 纯 DOM 渲染的瓶颈在哪里先交代一下背景。当时我在维护一个基于 contenteditable 的代码编辑器功能上没什么大毛病就是性能让人头疼。问题出在 DOM 节点的数量上——文档里每一行、每一个 token 高亮都需要创建节点一个 2000 行的 JS 文件渲染出来就是上万个 DOM 元素。浏览器的布局Layout和绘制Paint开销是和 DOM 节点数量直接挂钩的。当文档行数增加光插入文本这一操作就会触发整个容器区域的重新布局页面主线程的占用时间从原来的几毫秒飙升到几十毫秒。用户感知就是打字的时候卡顿、滚动的时候掉帧甚至点击光标位置要等半秒才有反应。还有一个隐蔽的问题contenteditable 本身依赖浏览器对 DOM 的选区管理。在复杂的嵌套结构里浏览器对选区边界的处理极易出 bug——比如按方向键时光标跳到不可预期的位置或者选区跨行时高亮计算错误。这类问题调试成本极高因为 DOM 结构一旦复杂浏览器内部对 Range 的压缩、合并逻辑会变得不可控。所以纯 DOM 方案最大的瓶颈不是“能不能显示”而是“显示了很多根本不需要的节点”。文本编辑器的核心需求是展示一段连续的、可交互的文字而 DOM 把这段文字拆散成上万个可独立定位的节点这从根本上就是一种结构性浪费。1.2 纯 Canvas 渲染又走了什么弯路既然 DOM 节点太多是问题那干脆不用 DOM全部用 Canvas 绘制行不行我尝试过一个纯 Canvas 的编辑器原型——所有文本、光标、选区一律绘制在 Canvas 上。纯 Canvas 有两个让人抓狂的地方第一个是文本输入。要让 IME 输入法正常工作你必须有 DOM 元素参与因为输入法需要绑定一个真实的文本域或 contenteditable 元素来接收组合输入事件。绕开这一点的方法是做一个隐藏的 textarea但这会引入新的问题——隐藏元素在移动端会反复弹键盘、光标位置对不齐、IME 候选框跟随位置错误。第二个是辅助功能。屏幕阅读器Screen Reader依赖 DOM 语义结构来朗读内容纯 Canvas 渲染对辅助功能基本是灾难无障碍专家看一眼就得摇头。还有一个看起来很“小”的问题Canvas 里的文字选中效果和浏览器原生的选中效果视觉上总有差异。你可以模拟出一个半透明的蓝色矩形但浏览器对选区、拼写错误波浪线、IME 预编辑区域下划线这些细节的处理人工模拟很难做到完全一致。所以纯 Canvas 方案虽然解决了“节点太多”的问题但引入了“交互能力缺失”的新问题。最合理的方案就是把两者混合起来用。1.3 混合架构的核心理念混合架构的思路一句话总结就是谁擅长什么就让谁干什么。Canvas 擅长批量绘制大量相似内容——几万行文本的渲染对它来说不过是循环调用 fillText 而已。DOM 擅长处理交互和语义——光标位置、选区高亮、输入法浮层、用户点击命中这些让浏览器原生能力来处理比手动模拟靠谱得多。这种分工带来的直接好处是文本层完全摆脱了 DOM 节点数的限制可以渲染无限行数的文档而交互层保持 DOM 原生能力兼容性、可访问性都得到了保留。我在实际项目中采用的架构是“三层渲染”一个 Canvas 负责文本区域的光栅绘制一层 DOM 负责光标与选区等装饰元素另有一层透明的 DOM 事件接收层负责捕获鼠标键盘事件。三层通过相同的坐标体系对齐互不干扰。开发这种架构的时候最核心的工作就是设计好三层之间的坐标映射关系一切问题都从坐标开始排查。2. 整体设计与图层划分2.1 三层结构的职责边界在多数混合渲染编辑器实现里框架层都采用了类似的“三层分离”设计渲染层技术载体核心职责典型元素文本层Canvas批量绘制文档内容文本字符、语法高亮底色、行号装饰层DOM绘制交互状态元素光标、选区、IME 预编辑下划线事件层DOM透明捕获用户交互事件鼠标点击、拖动、滚轮事件事件层在实际实现里可以和装饰层合并但要保证事件层的元素全程透明且不遮挡文本层。比如选区需要用户用鼠标拖拽如果 Canvas 文本层在最上方它会拦截所有鼠标事件所以事件层必须盖在最上面同时做事件穿透至 Canvas 的命中测试。装饰层的 DOM 元素不参与文本布局它们只是绝对定位的覆盖物。拿光标来举例编辑器每渲染一帧会计算出当前光标的 x/y 坐标以及高度然后 set 到一个 div 的 transform 属性上。选区渲染也是同样的逻辑——把选区范围切割成若干矩形区域每个矩形对应一个半透明 div。这种方法在代码层实现很简单但有一个非常重要的前提装饰层的位置必须时刻与 Canvas 绘制的字符位置严格对齐。这意味着文本测量的逻辑必须统一。2.2 文本测量体系的统一Canvas 的 fillText 和 DOM 的 textNode两者的字体渲染机制并不完全一致。即使设置了完全相同的 font-family、font-size、line-heightCanvas 的文本宽度和 DOM 的文本宽度也可能相差一到两个像素。如果装饰层用 DOM 测量宽度而文本层用 Canvas 测量宽度那光标和选区的位置就会在渲染时产生偏移。我在项目里的做法是所有文本测量统一走 Canvas 的 measureText()。光标和选区矩形的几何计算全部基于 Canvas 测量结果而不是依赖浏览器对 DOM 的文本排版。为了确保一致性我在字体加载完成后会主动清除 Canvas 缓存因为 Web Font 加载前后字形的宽度是变化的如果不在字体加载后刷新缓存文本会渲染错位。字体加载导致的宽度变化是一个经典的坑。你在开发环境用本机字体测试一切正常一旦上到生产环境浏览器加载远程字体的过程中文本已经画了一遍字体加载完成之后文字宽度瞬间变了所有行需要重排。所以字体加载完成之后必须触发一次全量重绘这是混合渲染架构的一个基本操作。2.3 为什么不用分块 DOM 渲染有朋友问过我既然 DOM 节点太多是问题用虚拟列表Virtual Scrolling只渲染可视区域的行不行这种方式其实也被很多编辑器采用比如当年的一些大厂在线文档就是这么做的。虚拟列表的问题在于它仍然依赖 DOM 节点渲染只是把节点数从「全量」降到了「可视区域」。看起来这解法挺聪明但它有几个绕不过去的坎滚动过程中每帧都需要动态创建和回收 DOM 节点如果滚动速度太快新节点还没来得及创建页面就会出现白屏闪烁。文本选择跨屏操作时浏览器原生的 Selection 会跨越大量未渲染的 DOM 节点导致 Range 对象失效甚至崩溃。当行高不一致或存在大型折叠块时虚拟列表的估算高度和实际高度容易失配出现滚动条长度跳变。Canvas 方案就不存在这些问题。Canvas 只关心“当前可视区域内应该画什么”不需要创建任何节点也没有节点被回收。滚动时只是重新执行绘制逻辑不会出现白屏。两者看似类似但实际运行时的平滑度和崩溃概率完全不是一个量级。这也是我最终放弃虚拟列表选择 Canvas 混合渲染的根本原因。3. 核心细节解析光标、选区与输入法3.1 光标渲染光标本质就是一个宽度为 2 像素的矩形。说起来简单但细节处有不少讲究。一个最基本的要求是光标必须闪烁。DOM 原生 textarea 的光标闪烁频率大约在每秒 500 到 700 毫秒切换一次开合态。我用 CSS animation 来实现装饰层光标的闪烁效果这也是混合架构的一个小甜点——你不需要自己写定时器控制闪烁直接给光标 div 加一个steps(1, end)的动画就行了。遇到一个比较隐蔽的问题光标在文档末尾或者空文档中时它应该显示成一个比行高略短的竖条。此时需要计算当前行的基线baseline位置再基于基线的 y 坐标减去一部分高度来放置光标否则光标会落在基线之下看起来像是“悬空”的。对于中文输入法光标还有一个额外的状态当用户正在输入拼音时光标要隐藏原来的竖条转而在预编辑区域下方显示下划线。这种状态切换需要事件层监听 compositionstart / compositionend 事件并动态调整光标 div 的样式。3.2 选区渲染选区渲染是混合架构里面最容易出问题的地方。Canvas 文本画完之后选区矩形如果在 DOM 层绘制必须先计算选区在每一行覆盖的字符位置再换算成矩形的几何坐标。我实现选区渲染的顺序是监听 selectionchange 事件编辑器内部维护自己的选区模型不依赖原生 Selection。遍历当前可视区域内的每一行判断选区边界与行首行尾的交集。对相交的部分生成矩形左边界和右边界。将矩形写入 DOM 装饰层。看似简单但有三个细节容易出错多行选区需要保证每一行的矩形高度与行高一致否则选中区域会露出缝隙。选区矩形必须允许pointer-events: none否则用户无法在选区覆盖的区域内进行拖拽、点击定位。选区尾部的矩形不能覆盖到行尾之后需要按照实际文字宽度裁剪。尤其是行尾有空白时选区矩形只能延伸到最后一个非空字符的位置不能直接拉满行宽。3.3 IME 输入法兼容IME 是混合渲染架构里最难啃的骨头之一。Canvas 不接收输入事件所以输入字符的过程必须依赖一个隐藏的 textarea 或 contenteditable 元素。这个元素既要能够接收焦点又不能被用户看到更不能影响布局。我的方案是在页面底部放置一个 1x1 像素的、透明的 textarea将其 opacity 设为 0并把它的位置动态移动到当前光标坐标处。这样 IME 的候选框会跟随光标位置显示而不是固定在屏幕角落。IME 组合文字的处理分为两个阶段在 compositionstart 之后输入法会持续产生 compositionupdate 事件此时未确认的拼音或笔画会被渲染为预编辑文本。预编辑文本的下划线和文字本身需要显示在光标所在的准确位置。在 compositionend 触发时最终的确认字符会通过 input 事件进入编辑器内部触发光标移动和文本重绘。这里有个反直觉的体验问题每次 compositionupdate 时如果直接清空 Canvas 重绘会看到候选文字闪烁、抖动。后来我改成只在 compositionend 后才触发 Canvas 重绘而 compositionupdate 阶段只更新 DOM 装饰层中预编辑文本的宽度。这样视觉上候选文字的更新是瞬间的不再有频闪。3.4 坐标映射与命中测试坐标映射是混合架构的另一个重要模块。用户点击 Canvas 区域时事件层接收到的是浏览器屏幕坐标clientX/clientY但编辑器内部需要的是文档坐标行号、列号。换算链路如下用 getBoundingClientRect() 取到 Canvas 容器相对于视口的偏移。用当前滚动条的 scrollLeft / scrollTop 计算出内容偏移。用“文档坐标 屏幕坐标 - 容器偏移 滚动偏移”得到内容坐标。根据行高换算出所在的行号。对所在行的文本内容进行逐字符宽度累加找到点击位置对应的列号。最后一步是关键。如果采用“左右半字符”的命中规则用户点击一个字符的左半部分定位到前一个字符点击右半部分定位到当前字符。这个规则我最早没做结果用户点击字符正中时位置总会偏移半个字符位。后来严格按照字符宽度累加的方式实现点击定位的准确率才让人满意。此外命中测试的性能也需要优化。逐字符累加在长行上会有性能问题——如果一行有 500 个字符每次点击都要计算 500 次 measureText页面会明显变慢。缓存每段文本的累积宽度数组并配合二分查找可以把命中测试从 O(n) 降为 O(log n)。这是经典的“用空间换时间”优化我在实现时收益非常明显。4. 实操过程构建一个混合渲染编辑器 Demo4.1 核心模块设计与渲染循环如果你要从零开始实现这套架构我建议先把核心模块拆清楚。否则边写边改结构后面会乱成一锅粥。我通常把编辑器拆成这几个模块TextModel文档内容的模型层维护文本行数组和行宽。LayoutEngine负责把文本行排版成可视区域的绘制命令。Renderer负责执行 Canvas 的绘制操作管理脏矩形区域。Decorator管理光标、选区、IME 预编辑文本等 DOM 装饰元素的创建和更新。InputController监听键盘、鼠标、剪贴板、IME 事件并把事件转换为模型修改。ViewportManager维护可视区域的偏移量触发 Renderer 和 Decorator 的更新。渲染循环我建议使用 requestAnimationFrame 作为调度中心。虽然 Canvas 绘制本身是同步的但 RAF 能确保把多次模型修改压缩在同一帧内绘制避免一次输入触发多次 paint。对于键盘快速输入的场景尤其重要——按一次键连续触发 10 次模型修改如果你每次都重绘那性能会很差RAF 可以把这 10 次修改合并到一帧绘制负担小很多。4.2 文本布局与行宽管理Canvas 绘制文本前必须先做布局。布局的核心任务是给定文档模型和可视区域算出每一行文本应该在 Canvas 上的 x、y 坐标。我用的布局参数有三个字体大小、行高、字符间距。行高的计算方式是以字体大小为基数乘上行高系数。举例来说如果字体大小是 14px、行高系数是 1.6那行高就是 22.4px。每行文本的 y 坐标就是行序号乘以行高再加上一个文档顶部 padding。每行文本的 x 坐标一般在左侧起始点。如果启用了行号栏要预留出行号栏的宽度。如果启用了缩进线indent guide还需要根据当前的缩进级别计算额外的 padding。关于每行的宽度管理我维护了一个行宽缓存。当文档修改某一行时只有那行的宽度会被重新计算。宽度的基准是 Canvas measureText 的结果我会额外加上一个像素的字符间距补偿。这一步看似无关紧要但对于光标在行尾的定位误差一两像素就会让人产生“光标没对齐”的感受。4.3 视口裁剪与脏矩形重绘Canvas 的重绘效率高不高完全取决于你有没有做视口裁剪。如果文档有五十万行而你每次重绘都遍历全部行去调用 fillText那性能还是会崩溃。我的做法每次重绘前先根据当前 scrollTop 和可视区域高度计算出需要绘制的行号范围。举例来说如果滚动到第 10000 行可视区高度 800px行高 22px那就只需要绘制从第 10000 行到第 10036 行、再加上上下各半行缓冲的区间。可视窗口之外的行一概不画。在此基础上我再做一层脏矩形优化。模型修改后并不是整个可视区域都需要重绘而是只有受影响的行需要重绘。当用户在第 100 行插入一个字符时只有第 100 行到文档末尾严格来说只需要到下一个硬换行符之前的那一段的文本宽度和绘制内容需要更新。但如果我采用的是等宽字体插入字符不会影响行内其他字符的宽度所以只需要重绘该行本身。而如果是比例字体后续所有行的宽度都需要重新计算。这个选择我在项目里做了很多权衡最终还是选定了等宽字体作为默认字体因为等宽字体在代码编辑场景下不仅语义清晰性能表现也更理想。4.4 渲染循环的核心代码演练下面给出一个简化版的渲染循环代码方便理解整体节奏。这里只展示关键骨架实际的字体缓存、脏矩形集合和模型管理会比这个复杂很多。function renderLoop(timestamp) { // 1. 检查是否有待绘制的脏矩形 if (renderer.hasDirtyRect()) { const viewport viewportManager.getViewport(); // 2. 清除旧内容 ctx.clearRect(0, 0, canvas.width, canvas.height); // 3. 根据视口范围计算需要绘制的行区间 const startLine Math.floor(viewport.scrollTop / lineHeight); const endLine Math.ceil((viewport.scrollTop viewport.height) / lineHeight); // 4. 遍历绘制每一行 for (let lineIndex startLine; lineIndex endLine; lineIndex) { const line textModel.getLine(lineIndex); const x marginLeft; const y lineIndex * lineHeight lineHeight - baselineOffset; // 5. 如果这一行有语法高亮按 token 分段绘制 for (const token of tokenizer.tokenize(line)) { ctx.fillStyle token.color; ctx.fillText(token.content, x token.offset, y); } } // 6. 绘制完文本后让装饰层同步更新光标和选区 decorator.syncWithViewport(viewport); // 7. 清除脏矩形集合 renderer.resetDirtyRect(); } requestAnimationFrame(renderLoop); }这段代码的核心思想是无论文档有多大canvas 绘制循环只关心可视窗口内的行。你可能会注意到代码里的renderer.hasDirtyRect()判断——这其实就是经典的脏矩形标记。每一帧开始前先去模型层的维护队列里拿标记。比如用户输入时模型层会被打上“第 520 行修改”的标记渲染循环就会专门清掉第 520 行对应区域的矩形然后重画那一行。4.5 Canvas 的 DPI 适配高 DPI 屏幕是 Canvas 渲染绕不开的话题。Canvas 的元素尺寸和绘图缓冲区尺寸是两个概念——如果不做适配在 Retina 屏幕上绘制出来的文字会模糊。原因在于 Canvas 的绘图缓冲区和你设置的 CSS 尺寸不一致。适配方法很简单读取window.devicePixelRatio将 Canvas 的实际缓冲尺寸设为 CSS 尺寸乘以 DPR然后调用 ctx.scale(DPR, DPR)这样绘制坐标仍然按照普通像素值计算。把所有涉及绘制尺寸的地方都乘以 DPR。需要注意 DPR 在页面缩放浏览器缩放快捷键时会变化所以需要监听 resize 事件在 DPR 变化时重建 canvas 缓冲区。这一步不做的话用户拖动浏览器缩放后所有文字会模糊成一团。5. 常见问题与排查技巧实录5.1 文字渲染模糊症状Canvas 中文字发虚、边缘不清晰。排查步骤检查 Canvas 缓冲尺寸和 CSS 尺寸是否一致。可以用canvas.width和canvas.getBoundingClientRect().width * devicePixelRatio比较。检查 ctx.scale 调用是否正确。如果设置了 scale(2, 2)所有绘制坐标在视觉上可以保持原值但如果不设置就相当于用一半分辨率绘制。检查字体加载统一切换。重新加载字体后必须要让 Canvas 的文本缓存失效否则字形宽度和绘制结果不一致导致同行文字出现叠影。经验说明在绝大多数情况下Canvas 的模糊问题都是 DPR 适配缺失导致的。在普通显示器上开发时往往看不出来一旦接到 4K 显示器或者手机测试问题立刻暴露。所以从项目开始就把 DPR 适配写进基础代码不要等出问题再补。5.2 光标与选区位置跳动症状光标看起来和字符不对齐选区高亮覆盖的区域与文字位置偏差 1-3 像素尤其在字体切换或页面缩放后。排查步骤检查文本测量是否统一。如果身份证测试使用 Canvas measureText而光标位置基于 DOM 计算那无论如何都会有偏差。检查字体加载后的重绘触发。使用 document.fonts.ready 监听字体加载完成并在此之后执行全量重绘。检查行高计算。Canvas 绘制文本时fillText 的 y 坐标是基线位置不是字符顶部。如果按字符顶部计算然后绘制高度和 DOM 位置就会有差。经验说明我踩过的最深的一个坑是把容器 padding 算进了滚动偏移。编辑器容器自身如果有 paddinggetBoundingClientRect 返回的矩形包含了 padding但滚动偏移的计算基准是不包含 padding 的。所以第一次适配光标的时候位置偏差了整个 padding 的宽度。这类问题很难靠视觉检查发现最好是写一套坐标验证测试用文档坐标反推出屏幕坐标再与 getBoundingClientRect 实测值比对。5.3 滚动卡顿症状滚动条拖动时画面掉帧严重滚轮滚动时文字闪白。排查步骤检查是否在滚动时全量重绘了所有行。必须用视口裁剪只绘制可视区的行。检查是否频繁调用了 measureText。measureText 是开销非常大的操作应该避免在绘制循环里调用。所有行的宽度必须在模型更新时缓存绘制时只查缓存。检查 Canvas 缓冲尺寸。如果 Canvas 尺寸过大比如超过 4096 像素宽度浏览器在合成时会增加额外开销。需要把 Canvas 改成分块tile方式每个块限制在 2048x2048 以内。检查是否使用了 shouldRasterize 之类的强制光栅化设置。Canvas 的合成属性如果设置不当会使浏览器强制对超大 Canvas 做光栅化拖慢滚动。经验说明滚动卡顿还有一个很少被人注意的诱因——在同一帧里既写了文本层 Canvas又同时去更新了很多个装饰 DOM 节点的 transform。浏览器每帧的绘制开销来自合成层。装饰节点数量如果超过可视行数比如线框、水波纹等效果合成层开始产生额外负担。解决办法就是控制装饰节点的数量最多只允许生成可视区域内的选区矩形和光标矩形其余一律不生成。5.4 长行渲染性能问题症状单行字符数量特别长的时候整行渲染很慢滚动卡顿。原因分析长行在前端编辑器里是常见场景——比如压缩过的 JS 代码或是一行超长的 base64 字符串。Canvas fillText 对单次调用能接收的字符串长度不设下限但实际渲染时超长的字符串会占据大量 GPU 填充时间。解决办法对超长行做分段绘制。把一行文本按照可视宽度切割成多个片段每个片段单独调用 fillText。这样浏览器在裁剪时可以直接丢弃视口之外的片段而不会傻傻地绘制一整行。经验说明默认情况下Canvas 的 fillText 会绘制整个字符串即使它大部分在视口外。浏览器虽然有一定的裁剪优化但面对极长字符串时裁剪并不彻底。所以手动分段是值得做的。分段还有一个额外的好处长行的 token 高亮可以按段粒度高亮这对语法高亮的渲染效率也有帮助。6. 更进一步如何扩展到富文本与代码编辑场景6.1 富文本的扩展路径混合渲染架构不止适用于纯文本编辑器扩展到富文本场景同样可行。常见的做法是用 Canvas 绘制所有文本内容用 DOM 绘制选区、光标以及浮动工具条而富文本所特有的斜体、加粗、链接色、删除线等样式在模型层上是行内的样式标记inline style绘制时由 tokenizer 把这些样式转化为 Canvas 的绘制参数。富文本场景有一个新的难题文本换行。富文本在屏幕宽度变化时需要动态换行这引入了“软换行”的概念。软换行需要用 Canvas 测量宽度当字符累积宽度超过可视宽度时余下的字符要移动到缓冲行的下一“视觉行”。这些视觉行不改变模型行的索引但改变了绘制位置和命中测试的计算。我在实际项目里用了“行索引 子索引”的两级定位来解决这个问题——模型行对应文档中的逻辑行子索引对应软换行之后的视觉行。光标命中时先定位逻辑行和子索引再换算成字符偏移。这套结构能较平稳地支撑长段落和大量文本的展示。6.2 语法高亮与装饰器扩展代码编辑器的语法高亮是 Canvas 文本层的天然应用场景。tokenizer 将每行文本切分为多个 token每个 token 有独立的颜色、粗体、斜体属性。绘制时对每个 token 设置颜色属性然后 fillText。语法高亮的优化点在于 token 的缓存。词法分析本身是 CPU 密集型任务如果每次重绘都重新跑一遍 tokenizer性能会很差。我维护了一个 token 缓存仅在文本修改时才重新分析对应行。配色方案变化时只需重跑一次的全局标识不用刷新整篇的 token 缓存。6.3 何时应该选择混合架构在文章的最后我想聊聊“什么时候应该用它”。混合渲染架构不是银弹它引入了 Canvas 绘制、装饰层同步、IME 处理、坐标映射等额外复杂度如果文档体量只有几十行用纯 DOM 的成本远低于混合架构。我建议你按下述标准来判断文档行数预期大于 5000 行或者单行长度可能超过 300 字符可以考虑混合渲染。对滚动流畅度有硬性要求不能接受滚动时白屏或掉帧优先考虑混合渲染。需要支持复杂的语法高亮、富文本样式或行内装饰且需要高性能混合架构优势明显。如果项目只需要简单的输入框场景或者面向低端移动设备的极简编辑需求纯 DOM 仍是第一选择。我个人在实际操作中的体会是混合渲染架构的难点不在“把字画出来”而在“让字和鼠标、键盘、输入法保持同步”。很多问题只有你真正开始用键盘敲字、用鼠标拖动跨屏选区的时候才会暴露出来。所以我建议你一旦决定采用这套方案先花时间把坐标映射、光标同步、IME 这三个核心模块打磨稳定再往上堆功能。这三块做好了后面的路会顺畅很多。最后再分享一个小技巧调试混合渲染架构的时候把可视区域的分块开关打开用不同颜色把每个 Canvas 分块的边界画出来非常有助于快速定位是哪个图层出了问题。这个调试方式初期看起来不起眼但排查滚动偏移、分层错位这类疑难杂症时省下的时间堪称惊人。