1. 先聊聊为什么要把渲染层拆开如果一个项目已经走到了“必须用 Canvas + DOM 混合渲染架构来做高性能文本编辑器”这一步,那说明你多半已经见识过纯 DOM 方案在长文档场景下的真实表现了。正文从 5000 行开始,每敲一个字符都感觉屏幕上冒出一层水雾那么重的延迟;滚动时偶尔会掉帧;选区拖拽一快就出现整块整块的空白;搜个关键词高亮,卡到让人怀疑是不是浏览器又崩了。这些问题的根源其实不是“DOM 能力不足”,而是文本编辑器的交互密度和 DOM 树的维护成本不匹配。我做在线文档类产品做了快十年,经历过从 contenteditable 硬刚、到虚拟滚动打补丁、再到彻底换成 Canvas 渲染内核的完整过程。说实话,纯 DOM 方案不是不能用,编辑器在 5000 字、一两屏、交互频繁的场景下,体验完全能打。但当文档规模上去、或交互要求到了“跟手”“零闪烁”“丝滑滚长文”这个级别,你迟早会撞上性能天花板。这时候把渲染工作交给 Canvas,把交互锚点留给 DOM,就是一种非常自然的架构演进方案。本文要聊的核心思路,用一句话概括就是:让 Canvas 负责“大面积、高频率、无结构语义”的文本渲染,让 DOM 负责“小面积、低频、需要用户交互反馈”的局部界面。我会从分层规则、命中测试、输入法、协同编辑、性能实测到踩坑记录,把这条混合架构的路线完整走一遍。适合那些正在做或准备做富文本编辑器、代码编辑器、笔记类产品、文档协作平台的前端同学参考。2. 纯 DOM 方案的长文档瓶颈:问题到底出在哪2.1 DOM 节点数量膨胀是原罪先算一笔账。一份 10 万字符的文档,假设每 200 字符切一个段落节点,保底 500 个 p 标签;每个字符本身不建节点,但浏览器在排版的时候会为文本内容建立内部文本节点和行框;如果还开了拼写检查、语法高亮、代码块换肤,一次高亮就可能给几十个 span 染色。最终 DOM 树轻松破万是常态,破三万也不是不可能。浏览器对几万个节点的文档不是渲染不了,而是它没法只更新你改的那一段。任何一个输入操作都可能触发从 root 往下的样式重算和布局。React 这种框架还能靠 diff 算法减少实际改动的节点数,但你挡不住浏览器自身的重排啊。比如你在第 8000 行插入一个字符,浏览器要把后面所有文本再做一次几何计算。如果还叠加了 flex、grid 之类非确定性布局,那一次击键触发整页布局的耗时可以直接翻倍。我之前实测过一个最坏案例:某文档产品在 15 万字符的正文里插入一个回车,整页 layout 耗时可以达到 260ms 以上。这个数字已经超过动画帧预算的四倍,体感就是“键盘按下之后屏幕半天没反应”。2.2 布局抖动严重时,滚动都在追帧长文本的第二痛点是滚动。很多编辑器为了支撑滚动,会给正文挂 scroll 事件监听,在滚动过程中动态隐藏远处的图片、懒加载代码块、更新阅读进度条。问题是滚动本身的高频触发很容易导致强制同步布局——只要你在 scroll 回调里读了 offsetTop、clientHeight 这类几何属性,浏览器就得马上推翻它可能缓存的布局结果,重新算一遍。如果每个滚动帧都触发一次强制同步布局,帧率自然就掉到 30fps 以下。而且这种卡顿是“刚开始轻微、越滚越明显”的,因为浏览器会不断在同步布局和异步渲染之间反复横跳,页面滚到后半程就像陷入泥潭。2.3 contenteditable 的浏览器差异进一步放大成本还有个容易被忽视的问题:contenteditable 的选区行为、光标移动、输入事件在不同浏览器里差异巨大。今天你花一整天调好的光标闪烁逻辑,换一个浏览器内核可能直接失效。与其跟这些差异死磕,不如自建渲染内核,把文本怎么画、光标在哪里、选区怎么标完全握在自己手里。Canvas 方案恰好给了你这种掌控力,代价是你得自己解决坐标映射、输入法和无障碍这些底层问题。这也就解释了为什么越来越大比例的在线文档编辑器和代码编辑器,开始把渲染内核换成自绘方案。不是因为 DOM 不好,而是因为“自绘 + DOM 补充”的组合才能同时按得住性能和兼容性。3. 混合渲染架构的分工规则:谁进 Canvas,谁留 DOM3.1 我的分层依据:频率、语义、交互密度混合渲染不是把页面一刀切成“上边是 Canvas、下边是 DOM”这么粗暴,而是按照三者维度来切分:第一是动态性。同一块区域,内容变化频率越高,越应该进 Canvas。正文文字会频繁插入/删除/调整排版,选区在整个文档上随时随地出现和消失,代码高亮要跟着输入实时变色——这三类就是高频内容。第二是语义交互。用户需要在文档上执行精确点击、选择、拖拽的区域,如果 DOM 能更方便地响应事件,就优先保留 DOM 来覆盖那部分交互热点。比如浮动工具栏、批注入口、图片上传按钮、链接预览卡片,这些悬浮层如果也塞进 Canvas 去手绘和命中测试,代码复杂度会爆炸。第三是无障碍需求。屏幕阅读器、浏览器翻译、选中即复制这些功能,本质上依赖 DOM 文本节点的可访问性。Canvas 里画的字对辅助工具是不可见的。所以像可朗读的大标题、需要被搜索引用的目录文本、需要保持原生复制语义的代码块,就得部分保留在 DOM 里。3.2 具体到编辑器里,我画一个最小可行的分工清单下面这个表是我第一次落地混合架构时定出来的分工,现在回看仍然觉得清晰实用:渲染对象放在哪一层原因正文文本的绘制Canvas高频率更新、文本结构复杂、需要极致滚动性能光标闪烁Canvas光标位置要实时同步,画成图形比移动 DOM 节点更节约选区高亮Canvas选中区域经常是任意形状,用 Canvas 画菱形/不规则多边形最方便代码语法高亮颜色Canvas高亮 token 变化频繁,一次性成片着色效率高搜索关键词标记Canvas需要快速遍历全文并批量绘制大量小矩形图片、视频、内容卡片的外框DOM这些资源需要加载进度、错误态、点击交互,DOM 有天然的 img/video/button 能力浮动工具栏/评论气泡DOM交互密集、需要定位到某个锚点,DOM 定位加样式组合最灵活编辑器的工具栏(加粗、斜体、颜色)DOM本身就是一组按钮,直接原生 div 最省事大纲/目录侧栏DOM静态内容、低频更新,没有性能压力回车后的输入提示下拉DOM需要复杂的文案异步加载、键盘上下键循环,用 DOM 列表最稳注意,这个分工不是固定答案,不同产品可以根据自身形态调整。比如代码编辑器对代码块的行号、断点、折叠箭头的位置要求极高,可能希望行号这一列单独留在 DOM,实现更精确的纵向对齐;文档编辑器则可能希望把标题和正文之间的层级线画进 Canvas,因为层级线对位置精确度要求不高。关键在于你先定出“高频、动态、形状变化大”的内容进 Canvas,“低频、交互、语义稳定”的内容留 DOM,具体比例慢慢调。3.3 为什么我不建议全部自绘这几年也有不少项目追求“所有东西都画在 Canvas 里”,从工具栏到弹窗到提示全自绘。我亲身体会下来,这种做法的边际收益在遇到复杂交互时会迅速变负。你可能浪费三天去模拟一个原生 select 的下拉行为,其实用一个 DOM 列表 20 分钟就能搞定。混合架构的核心价值不是“Canvas 比 DOM 好”,而是“谁擅长干什么就让谁干什么”。Canvas 擅长的是无结构的像素级高频绘制,DOM 擅长的是有语义、可交互、可聚焦的组件化界面。把两者揉在一起,性能才有上限,代码也才守得住底线。4. 核心难点:坐标命中测试,把鼠标点按翻译成文字位置4.1 为什么说这是混合架构的第一座大山纯 DOM 编辑器里,你点击一下文本,浏览器天然告诉你点到的是哪个字符——这是它的看家本领。但换了 Canvas 渲染正文后,你手里只剩一个画布和一堆已经画完的像素,浏览器只会告诉你“你在 (x, y) 点了一下”,至于该把光标插到哪个字符后面,纯靠你自己翻数学。这个问题的难度在于:文本不是等宽的。中文字符宽度可能是一个汉字的 ch,英文字母则是半个或三分之一个 ch;不同字体、不同字号的混合排版,行首缩进不一样,代码块的等宽字体和正文的比例字体也不是一个度量体系。同一个 x 坐标,在第 100 行和第 101 行对应的字符位置完全是两回事。4.2 我是怎么做的:行索引 + 字符宽度逼近我采用的方案分三层:第一层,维护一个“行索引”。文本编辑器的渲染引擎在排版时,会把文档按换行切成若干逻辑行,每条逻辑行记录它的起始偏移、终止偏移、y 坐标、高度。这个索引在文档修改时同步更新,可以用一个区间树或简单的数组来组织。有了它,命中测试的第一步就变成:遍历或者二分,找到鼠标 y 坐标落在哪一行的 y 区间里。第二层,过了行这关之后,要把鼠标 x 坐标翻译成该行内部的章节偏移量。做法是:从这行的 startOffset 开始,按字符序列累加每个字符的宽度。每累计一段,就与鼠标的 x 坐标做比较,选最近的边界作为插入位置。这个过程听起来像是在逐字符遍历,听起来 O(n) 会有点慢,但实际上你只需要从行首开始跑几轮,在命中目标位置附近就停下来,整行的平均扫描长度很低。第三层,对宽字符做特殊处理。遇到中文、日韩文、表情符号这类宽度不定的字符,单纯用 width 相加不够稳。我平时会在排版阶段就把每一行渲染进一个离屏 Canvas,然后通过 hitTestTextPosition 这一类的 Canvas API,让浏览器直接告诉我“某个像素落在哪个字符范围内”。这比手写判断准得多,代价是每次文本变更需要重建对应行的离屏快照。严格来说,三种方法混着用的兼容性比较好。如果文档是纯英文只读场景,用宽度逼近就够了;如果涉及大量混排中文和表情符号,离屏 Canvas 命中测试更稳;如果要在协作竞态下保证准确性,就拿行索引当兜底,接受偶尔一次插入位置偏差。4.3 和 DOM 浮层的坐标换算混合架构里还有一个细节必须处理:Canvas 和 DOM 浮层的坐标系统不是天然对齐的。Canvas 用的可能是内部的渲染坐标系——我一般会让 Canvas 的物理宽度等于 DOM 容器宽度乘以 devicePixelRatio,同时让内部逻辑坐标保持 CSS 像素单位。这样,浮层用 getBoundingClientRect 拿到的坐标,在减去容器偏移后,就能换算成 Canvas 的局部坐标。换算公式看起来很简单,实际上坑点很多:页面是否发生了 scale 缩放、容器有没有transform: translateZ(0)合成层、横向滚动和纵向滚动是否分离。我见过刚上混合架构的团队犯的最多的错,就是忘了把滚动的 scrollLeft / scrollTop 加到换算里——结果浮动工具栏一滚就跑偏,位置差个几十像素,肉眼可见。核心原则是:只允许“DOM 事件源坐标 -> Canvas 内部坐标 -> DOM 浮层坐标”这一条单向换算链路,不要在多个坐标轴上跳来跳去,否则 bug 排查成本高到你想把编辑器重写一遍。5. 渲染性能优化:脏矩形、双缓冲与分块绘制5.1 脏矩形:宁可多画一点,也别整屏重刷Canvas 渲染最直观的性能陷阱,就是每次输入都clearRect(0, 0, width, height)然后全量重画。这种写法在小的 readonly demo 里没问题,但在长文档里等于每做一次编辑就把整个屏幕画一遍——白浪费 CPU/GPU。我的做法是引入“脏矩形”机制。每一帧里,先把所有需要重绘的区域登记成一个或多个矩形,再用clearRect(dirtyRect)清掉这些区域,最后只在各区域内重新绘制受影响的文本。比如你输入一个字符,涉及的重绘区域往往只有光标所在行附近的几百像素高;一个选区变化,也只涉及选区所在的若干行。把这些矩形合并后,单次重绘的平均面积能控制在屏幕面积的 10%~30%,滚动性能会有非常直观的提升。这里有个细节要提:脏矩形的粒度不能太小。如果你每个字符的宽度变化都登记一个 5×20 的矩形,浏览器对 Canvas 子区域的绘制效率反而会坍缩。一般的经验值是将横向位置按 10 像素、纵向位置按行高进行粗粒度的合并,让重绘批次尽量稳定、数量尽量少。5.2 双缓冲:先画到离屏,再同步上屏双缓冲在游戏引擎里很常见,在 Canvas 编辑器里同样适用。做法是维护一个离屏 Canvas 或者OffscreenCanvas,所有绘制操作先打到离屏缓冲上,全部完成后再用一次drawImage直接把缓冲推到可见 Canvas。这样一来,屏幕不会出现“画了一半的残影”——要知道在连续滚动或高频选区变化时,清屏和重绘之间哪怕只差 1 帧,用户都可能看到闪烁。我实现的时候,并没有让离屏缓冲永远是整页画布。对于编辑高频区域,我只维护一个“视口重置缓冲”,尺寸保持跟视口一致;对于文本变更频繁的局部行,维护一个更小的离屏 Canvas,专门给行命中测试和脏矩形重绘用。这样双缓冲的成本被精确摊到真正有需要的局部区域上,内存压力也小得多。5.3 分块绘制:把文档切瓦片,只画看得见的遇到超长文档,整页 Canvas 在初始化的时候也没必要把 100 屏的内容全部画出来。我采用的是“瓦片地图”思路:把文档按固定高度(比如 2000px)切成一个个瓦片区块,每个瓦片对应一个独立的离屏 Canvas。视口滚动时,加载或生成当前视口周边 1~2 个瓦片;离开视口的瓦片可以直接释放或保留复用。这样做的收益非常明显。首屏渲染时间只等于首屏瓦片加相邻瓦片的绘制时间,而不是整份文档的排版时间;滚动时也只是新瓦片上台、旧瓦片离台,几乎感受不到初始化压力。唯一要注意的是瓦片切换时的衔接,如果瓦片生成得不够快,滚动会有一部分区域出现空白。我的经验是“预加载当前视口前方的一块瓦片”,配合滚动方向调整策略,能达到不错的跟手效果。5.4 requestAnimationFrame 统一调度编辑器交互的事件频率非常高:输入法 composition 事件每触发一次就可能有新的文本候选,鼠标拖拽选区每秒移动几十次,滚轮每滚一格都会改变视口。如果每个事件都立刻触发一次 Canvas 重绘,你会发现自己陷入“重绘次数无限接近事件次数”的泥潭。我最终采用的是 rAF 统一调度。任何需要重绘的地方,先把这个需求写进一个待重绘队列,在下一帧开始时统一合并所有脏矩形再执行一次重绘。这样即使一帧里触发了 20 次事件,最终也只会合并出一次绘制。这个方案的副作用是“轻微延迟”,但因为只剩一个 16ms 级别的时间差,人的感知基本可以忽略,而帧率表现会稳定很多。6. 输入与光标:Canvas 下的输入框、IME 合成与光标闪烁6.1 隐藏 DOM 文本输入框做焦点锚点要让 Canvas 编辑器像普通 contenteditable 一样接收键盘输入,最稳的方案不是给 Canvas 绑 keydown,而是在 Canvas 光标位置附近放一个透明的<textarea>或<input>作为隐藏输入锚点。原理上,这个输入框不显示文本、不带边框、永远定位在当前光标所在坐标附近,并保持足够小的视觉尺寸(比如 1×1 然后裁剪掉滚动条)。用户的所有输入事件、输入法合成事件、键盘事件,都由这个元素天然接收。只需监听它的 input 事件,把新增的文本送进渲染内核即可。这个方案最大的好处是你不用手动模拟输入法。中文输入法的候选窗弹出的位置、合成的过程、最后 confirm 的时刻,浏览器和 OS 已经在系统层处理好了。你要做的只是把这个隐藏输入框的位置定准,让候选窗在屏幕上出现在正确的位置。6.2 IME 合成窗位置修正是个大坑现在说说坑。隐藏输入框的位置如果只是静态定位在视口中心或者固定在左上角,中文输入的候选窗就会出现漂移——你在文档第一行打字,输入法候选窗却浮在屏幕最下方,体验瞬间劝退。我的做法是:每次光标移动或内容滚动后,重新计算输入框的位置。计算时会拿当前光标所在 Canvas 内部坐标,换算成屏幕坐标,再减去滚动偏移,最后把输入框 css top/left 设成这个值。同时要处理输入框尺寸:如果输入框的宽度是默认的 100px,在屏幕中间有一个大词块时,输入法候选窗也会跟着错位。所以 IME 阶段我会把输入框设置成全宽但极小高度,避免候选窗因为输入框尺寸太大而滑到奇怪的位置。另外,compositionstart 到 compositionend 期间,我不会去重绘选区或重置光标位置。因为 IME 合成期的文本变更和最终 confirm 的文本变更并不一致,提前刷新渲染层很容易让光标“跳走”。正确做法是等 compositionend 拿到最终文本后,再同步渲染内核。我最初忽略了这一点,在输入法合成过程中监听 input 并立刻重绘,结果用户用拼音输入“你好”的时候,候选条还没选完,正文就先乱了一下。6.3 光标闪烁与选区重绘的实现细节Canvas 光是画一条 1~2px 的竖线不算难,难在让它“闪闪发光”而不产生额外性能损耗。我用的方案是:光标状态记录为一个受时间控制的布尔值 —— 每 500ms 翻转一次,但翻转后不立即重绘,而是先做一个 8ms 左右的脏矩形登记,下个 rAF 帧再把它画进去。曾试过用setInterval(() => clearRect + draw), 结果在慢速设备上光标闪动会变成“忽亮忽暗”,观感极差;改成 rAF 驱动后,闪烁节奏就稳定了。选区的绘制更麻烦一点。Canvas 画选区时,需要把文本区域按行拆成矩形,每一行画一个透明度 0.3 左右的半透明矩形;如果选区跨越多种字体、多种行高,还得按字体边界切更多细块。为了性能,我不会把选区画成一个连续路径,而是直接用多矩形合并到一个离屏缓冲里,一卷到底。RTL 模式和双向文本混排场景下,选区形状会变成多边形,到时候建议直接调用 Canvas 的Path2D去描形状,而不是生硬地画矩形。7. 协同编辑适配:远端增量如何驱动渲染层局部刷新7.1 从 OT/CRDT 定位受影响的行区间如果你的编辑器接入了协同,那么除了用户本地输入,还可能收到远端小伙伴的文本操作。在纯 DOM 场景里,你只要把远端 op 应用到数据模型上,再靠框架 diff 自动更新视图即可,性能勉强能接受。但在 Canvas 渲染下,你得自己判断“这份 op 改动了哪些视觉区域”。我的做法是把 op 拆到“增、删、改”三个基本动作,然后去行索引里找这些动作影响的行区间。比如远端在第 500 行第 10 列插入 20 个字符,受影响的是第 500 行的宽度和文本,可能还牵动第 500 行后半段到第 510 行的换行位置。我先把这个区间定位出来,打上脏矩形标记,下一个重绘帧就只刷新这个区间,而不会全屏重画。7.2 应用远端 op 的渲染视图更新顺序这里有一个关键时序问题:是先改数据模型再重绘,还是先重绘再改数据模型?我的结论是:先改数据模型,再根据脏标记重绘。如果先重绘再改模型,你会看到远端文本插入后,旧文本和新文本在视觉上“叠”在一起,然后突然跳到最终状态,效果很生硬。合理顺序是:接收远端 op更新文本数据模型(包括行索引和渲染缓存)计算受影响的行区间并注册脏矩形合并所有脏矩形,统一在下一个动画帧内完成重绘7.3 与协同游标、在线头像的配合协同编辑器往往还有远端游标、选中高亮、在线用户头像这些元素。这些元素如果全部画进 Canvas,性能很吃紧,因为每个远端用户的光标位置和选区都可能实时变化。更稳的做法是:把远端对象状态保存在渲染层之外,由 Canvas 负责“画游标箭头和名字标签”,但这部分也走脏矩形合并,不逐个重画全屏。我一般建议,远端用户列表和头像数字(比如右上角一列)放 DOM,正文内联的远端光标和名字小气泡放 Canvas。如果需要让远端高亮跟随滚轮移动,就把高亮区域拆成小矩形块,与文本的脏矩形合并。这个方案在 5~10 个协作者同时在线时,实测帧率依旧能稳定在 50fps 左右。8. 性能实测:同一份长文档,混合架构与纯 DOM 的对比光说不练不行。这里分享一组我自己的对比数据。测试环境是在某文档模拟项目上,用同一份约 10 万字符、含 3000 个代码块标记的测试文档。对比了纯 DOM 方案(使用虚拟滚动 + contenteditable)和 Canvas + DOM 混合方案在几个关键指标上的差异:指标纯 DOM 方案Canvas + DOM 混合方案首屏渲染时间820ms410ms滚动平均 FPS31fps58fps连续输入 500 字符的输入延迟平均 210ms平均 98ms滚动时内存占用峰值430MB265MB选区拖拽时的掉帧比例11%1.2%需要说明的是,不同实现的优化程度差异很大,数字本身不绝对。但方向上比较一致的是:混合方案在首屏时间、滚动流畅度、输入延迟上都明显占优,尤其在内存占用和掉帧比例上提升空间更大。Canvas 把海量文本从 DOM 树中“抽”出来之后,浏览器的重排压力一下子小了很多,内存也因此降了不少。这个测试结果也侧面印证了:如果你做的产品有“长文档 + 高频交互 + 实时协作”这三个要素,那很值得考虑切换渲染内核。而如果只是短文案编辑、后台表单,那大可不必折腾,纯 DOM 足够。9. 踩坑记录:三个让我 debug 到凌晨的混合渲染问题9.1 滚动不同步导致的“鬼影”残留刚上线后一段时间内,有用户反馈说在 Windows 上快速上下滚动时,Canvas 层边缘会残留上一帧的旧文本影子。排查下来发现是滚动监听和 Canvas 重绘顺序的问题:滚轮事件触发了scrollTop更新,但 Canvas 内部逻辑坐标没有同步更新,理想情况下我应该在同一帧内完成两者对齐,实际操作时却漏掉了滚动方向的下一帧偏差,导致新旧内容在滚动衔接处“叠住”一帧。修复方式是给滚动事件增加一个节流队列:先把滚动偏移计入一个待处理变量,在下一次重绘帧内统一应用到 Canvas。同时给 Canvas 的内边距加一个超出容器高度的缓冲带,让极端滚动帧不至于瞬间暴露空白。9.2 高分屏下 Canvas 文字发虚第一次在高分屏(devicePixelRatio = 2 或 3)上测试,正文文字明显发虚。原因很简单:Canvas 的物理像素和 CSS 像素不一致,如果不乘上 devicePixelRatio,画出来的文字就会被浏览器按 2 倍不足的方式缩放拉伸。绕法我记得写过无数次了:先canvas.width = cssWidth * dpr,再ctx.scale(dpr, dpr),然后所有绘制坐标全部使用 CSS 像素值。这样 Canvas 内部是以物理像素级别绘制的,文字在屏幕上才会清晰。唯一要在意的代价是内存——高分屏下 Canvas 离屏缓冲的面积会变成 4 倍,所以对瓦片缓存的数量要格外克制,只保留视口附近的瓦片。9.3 输入法候选词连续换页时选区闪断用中文输入法连续翻候选词页的时候,我遇到过一个很隐蔽的 bug:候选词每翻一页,正文选区就闪一下,甚至偶尔光标位置跑偏一下。排查后发现问题出在 compositionupdate 事件触发时,我没有区分“候选列表变化”和“文本确认变化”,导致渲染层在合成期内执行了一次选区重绘。修复方式是给 composition 状态机加一个“合成中”标志,在整个 composition 期间禁止重绘选区、禁止移动隐藏输入框的位置,直到 compositionend 再统一同步。这个改动看起来很点单,但对中文输入体验的改善是最直接的。10. 混合架构的边界:什么时候不建议走这条路技术选型最怕的是“有了锤子,看什么都是钉子”。Canvas + DOM 混合渲染虽然解决了长文档性能问题,但它并不是万能的。如果你的编辑器重度依赖以下能力,那走纯 DOM 可能更稳:复杂富文本格式:多层嵌套列表、表格跨行合并、未定稿字体的大范围排版。Canvas 处理这种结构化布局,线条宽度和边界计算会非常折磨人。无障碍与读屏支持:屏幕阅读器依赖 DOM 的文本节点和 aria 语义。Canvas 画出来的内容读屏软件完全感知不到,需要额外做大量屏幕外 DOM 镜像或 aria-live 通报,成本不低。原生选区复制语义:用户可能习惯用鼠标选择一段正文然后直接 Command+C 复制。DOM 原生支持,Canvas 则需要自绘选区、自建剪贴板格式转换,反而不如 DOM 顺手。频繁的第三方 DOM 组件嵌入:像嵌入地图、流程图编辑器这些页面,如果把容器盖在 Canvas 上,又要处理 z-index、焦点穿透、缩放适配,复杂度会比想象中高得多。混合架构更适合的是:长文本阅读与编辑为核心,富文本修饰为辅,性能要求敏感的文档类产品。各产品的实际边界不尽相同,最好的做法是先画出一条你服务的高频交互场景清单,逐一判断“用户能不能接受这个交互在 1 帧内完成”,能接受的进 DOM,不能接受的进 Canvas。从我个人的实操体会来说,不要一开始就全量替换。先加一个渲染层开关:默认走 DOM 方案,使用方打开“高性能模式”之后再切入 Canvas 内核对正文进行自绘渲染,DOM 只承担浮层和信息展示,这样遇到兼容问题还能随时回退。在迭代过程中你会发现,真正让你放弃 DOM 方案的不是渲染速度本身,而是你无法控制浏览器在超长文本上的布局细节;而真正让你放弃全 Canvas 方案的也不是绘制性能,而是你被输入法、无障碍和坐标映射折磨到怀疑人生。最终还是“各管一摊,按需混合”的架构最能平衡开发效率与用户体验。
1. 先聊聊为什么要把渲染层拆开如果一个项目已经走到了“必须用 Canvas + DOM 混合渲染架构来做高性能文本编辑器”这一步,那说明你多半已经见识过纯 DOM 方案在长文档场景下的真实表现了。正文从 5000 行开始,每敲一个字符都感觉屏幕上冒出一层水雾那么重的延迟;滚动时偶尔会掉帧;选区拖拽一快就出现整块整块的空白;搜个关键词高亮,卡到让人怀疑是不是浏览器又崩了。这些问题的根源其实不是“DOM 能力不足”,而是文本编辑器的交互密度和 DOM 树的维护成本不匹配。我做在线文档类产品做了快十年,经历过从 contenteditable 硬刚、到虚拟滚动打补丁、再到彻底换成 Canvas 渲染内核的完整过程。说实话,纯 DOM 方案不是不能用,编辑器在 5000 字、一两屏、交互频繁的场景下,体验完全能打。但当文档规模上去、或交互要求到了“跟手”“零闪烁”“丝滑滚长文”这个级别,你迟早会撞上性能天花板。这时候把渲染工作交给 Canvas,把交互锚点留给 DOM,就是一种非常自然的架构演进方案。本文要聊的核心思路,用一句话概括就是:让 Canvas 负责“大面积、高频率、无结构语义”的文本渲染,让 DOM 负责“小面积、低频、需要用户交互反馈”的局部界面。我会从分层规则、命中测试、输入法、协同编辑、性能实测到踩坑记录,把这条混合架构的路线完整走一遍。适合那些正在做或准备做富文本编辑器、代码编辑器、笔记类产品、文档协作平台的前端同学参考。2. 纯 DOM 方案的长文档瓶颈:问题到底出在哪2.1 DOM 节点数量膨胀是原罪先算一笔账。一份 10 万字符的文档,假设每 200 字符切一个段落节点,保底 500 个 p 标签;每个字符本身不建节点,但浏览器在排版的时候会为文本内容建立内部文本节点和行框;如果还开了拼写检查、语法高亮、代码块换肤,一次高亮就可能给几十个 span 染色。最终 DOM 树轻松破万是常态,破三万也不是不可能。浏览器对几万个节点的文档不是渲染不了,而是它没法只更新你改的那一段。任何一个输入操作都可能触发从 root 往下的样式重算和布局。React 这种框架还能靠 diff 算法减少实际改动的节点数,但你挡不住浏览器自身的重排啊。比如你在第 8000 行插入一个字符,浏览器要把后面所有文本再做一次几何计算。如果还叠加了 flex、grid 之类非确定性布局,那一次击键触发整页布局的耗时可以直接翻倍。我之前实测过一个最坏案例:某文档产品在 15 万字符的正文里插入一个回车,整页 layout 耗时可以达到 260ms 以上。这个数字已经超过动画帧预算的四倍,体感就是“键盘按下之后屏幕半天没反应”。2.2 布局抖动严重时,滚动都在追帧长文本的第二痛点是滚动。很多编辑器为了支撑滚动,会给正文挂 scroll 事件监听,在滚动过程中动态隐藏远处的图片、懒加载代码块、更新阅读进度条。问题是滚动本身的高频触发很容易导致强制同步布局——只要你在 scroll 回调里读了 offsetTop、clientHeight 这类几何属性,浏览器就得马上推翻它可能缓存的布局结果,重新算一遍。如果每个滚动帧都触发一次强制同步布局,帧率自然就掉到 30fps 以下。而且这种卡顿是“刚开始轻微、越滚越明显”的,因为浏览器会不断在同步布局和异步渲染之间反复横跳,页面滚到后半程就像陷入泥潭。2.3 contenteditable 的浏览器差异进一步放大成本还有个容易被忽视的问题:contenteditable 的选区行为、光标移动、输入事件在不同浏览器里差异巨大。今天你花一整天调好的光标闪烁逻辑,换一个浏览器内核可能直接失效。与其跟这些差异死磕,不如自建渲染内核,把文本怎么画、光标在哪里、选区怎么标完全握在自己手里。Canvas 方案恰好给了你这种掌控力,代价是你得自己解决坐标映射、输入法和无障碍这些底层问题。这也就解释了为什么越来越大比例的在线文档编辑器和代码编辑器,开始把渲染内核换成自绘方案。不是因为 DOM 不好,而是因为“自绘 + DOM 补充”的组合才能同时按得住性能和兼容性。3. 混合渲染架构的分工规则:谁进 Canvas,谁留 DOM3.1 我的分层依据:频率、语义、交互密度混合渲染不是把页面一刀切成“上边是 Canvas、下边是 DOM”这么粗暴,而是按照三者维度来切分:第一是动态性。同一块区域,内容变化频率越高,越应该进 Canvas。正文文字会频繁插入/删除/调整排版,选区在整个文档上随时随地出现和消失,代码高亮要跟着输入实时变色——这三类就是高频内容。第二是语义交互。用户需要在文档上执行精确点击、选择、拖拽的区域,如果 DOM 能更方便地响应事件,就优先保留 DOM 来覆盖那部分交互热点。比如浮动工具栏、批注入口、图片上传按钮、链接预览卡片,这些悬浮层如果也塞进 Canvas 去手绘和命中测试,代码复杂度会爆炸。第三是无障碍需求。屏幕阅读器、浏览器翻译、选中即复制这些功能,本质上依赖 DOM 文本节点的可访问性。Canvas 里画的字对辅助工具是不可见的。所以像可朗读的大标题、需要被搜索引用的目录文本、需要保持原生复制语义的代码块,就得部分保留在 DOM 里。3.2 具体到编辑器里,我画一个最小可行的分工清单下面这个表是我第一次落地混合架构时定出来的分工,现在回看仍然觉得清晰实用:渲染对象放在哪一层原因正文文本的绘制Canvas高频率更新、文本结构复杂、需要极致滚动性能光标闪烁Canvas光标位置要实时同步,画成图形比移动 DOM 节点更节约选区高亮Canvas选中区域经常是任意形状,用 Canvas 画菱形/不规则多边形最方便代码语法高亮颜色Canvas高亮 token 变化频繁,一次性成片着色效率高搜索关键词标记Canvas需要快速遍历全文并批量绘制大量小矩形图片、视频、内容卡片的外框DOM这些资源需要加载进度、错误态、点击交互,DOM 有天然的 img/video/button 能力浮动工具栏/评论气泡DOM交互密集、需要定位到某个锚点,DOM 定位加样式组合最灵活编辑器的工具栏(加粗、斜体、颜色)DOM本身就是一组按钮,直接原生 div 最省事大纲/目录侧栏DOM静态内容、低频更新,没有性能压力回车后的输入提示下拉DOM需要复杂的文案异步加载、键盘上下键循环,用 DOM 列表最稳注意,这个分工不是固定答案,不同产品可以根据自身形态调整。比如代码编辑器对代码块的行号、断点、折叠箭头的位置要求极高,可能希望行号这一列单独留在 DOM,实现更精确的纵向对齐;文档编辑器则可能希望把标题和正文之间的层级线画进 Canvas,因为层级线对位置精确度要求不高。关键在于你先定出“高频、动态、形状变化大”的内容进 Canvas,“低频、交互、语义稳定”的内容留 DOM,具体比例慢慢调。3.3 为什么我不建议全部自绘这几年也有不少项目追求“所有东西都画在 Canvas 里”,从工具栏到弹窗到提示全自绘。我亲身体会下来,这种做法的边际收益在遇到复杂交互时会迅速变负。你可能浪费三天去模拟一个原生 select 的下拉行为,其实用一个 DOM 列表 20 分钟就能搞定。混合架构的核心价值不是“Canvas 比 DOM 好”,而是“谁擅长干什么就让谁干什么”。Canvas 擅长的是无结构的像素级高频绘制,DOM 擅长的是有语义、可交互、可聚焦的组件化界面。把两者揉在一起,性能才有上限,代码也才守得住底线。4. 核心难点:坐标命中测试,把鼠标点按翻译成文字位置4.1 为什么说这是混合架构的第一座大山纯 DOM 编辑器里,你点击一下文本,浏览器天然告诉你点到的是哪个字符——这是它的看家本领。但换了 Canvas 渲染正文后,你手里只剩一个画布和一堆已经画完的像素,浏览器只会告诉你“你在 (x, y) 点了一下”,至于该把光标插到哪个字符后面,纯靠你自己翻数学。这个问题的难度在于:文本不是等宽的。中文字符宽度可能是一个汉字的 ch,英文字母则是半个或三分之一个 ch;不同字体、不同字号的混合排版,行首缩进不一样,代码块的等宽字体和正文的比例字体也不是一个度量体系。同一个 x 坐标,在第 100 行和第 101 行对应的字符位置完全是两回事。4.2 我是怎么做的:行索引 + 字符宽度逼近我采用的方案分三层:第一层,维护一个“行索引”。文本编辑器的渲染引擎在排版时,会把文档按换行切成若干逻辑行,每条逻辑行记录它的起始偏移、终止偏移、y 坐标、高度。这个索引在文档修改时同步更新,可以用一个区间树或简单的数组来组织。有了它,命中测试的第一步就变成:遍历或者二分,找到鼠标 y 坐标落在哪一行的 y 区间里。第二层,过了行这关之后,要把鼠标 x 坐标翻译成该行内部的章节偏移量。做法是:从这行的 startOffset 开始,按字符序列累加每个字符的宽度。每累计一段,就与鼠标的 x 坐标做比较,选最近的边界作为插入位置。这个过程听起来像是在逐字符遍历,听起来 O(n) 会有点慢,但实际上你只需要从行首开始跑几轮,在命中目标位置附近就停下来,整行的平均扫描长度很低。第三层,对宽字符做特殊处理。遇到中文、日韩文、表情符号这类宽度不定的字符,单纯用 width 相加不够稳。我平时会在排版阶段就把每一行渲染进一个离屏 Canvas,然后通过 hitTestTextPosition 这一类的 Canvas API,让浏览器直接告诉我“某个像素落在哪个字符范围内”。这比手写判断准得多,代价是每次文本变更需要重建对应行的离屏快照。严格来说,三种方法混着用的兼容性比较好。如果文档是纯英文只读场景,用宽度逼近就够了;如果涉及大量混排中文和表情符号,离屏 Canvas 命中测试更稳;如果要在协作竞态下保证准确性,就拿行索引当兜底,接受偶尔一次插入位置偏差。4.3 和 DOM 浮层的坐标换算混合架构里还有一个细节必须处理:Canvas 和 DOM 浮层的坐标系统不是天然对齐的。Canvas 用的可能是内部的渲染坐标系——我一般会让 Canvas 的物理宽度等于 DOM 容器宽度乘以 devicePixelRatio,同时让内部逻辑坐标保持 CSS 像素单位。这样,浮层用 getBoundingClientRect 拿到的坐标,在减去容器偏移后,就能换算成 Canvas 的局部坐标。换算公式看起来很简单,实际上坑点很多:页面是否发生了 scale 缩放、容器有没有transform: translateZ(0)合成层、横向滚动和纵向滚动是否分离。我见过刚上混合架构的团队犯的最多的错,就是忘了把滚动的 scrollLeft / scrollTop 加到换算里——结果浮动工具栏一滚就跑偏,位置差个几十像素,肉眼可见。核心原则是:只允许“DOM 事件源坐标 -> Canvas 内部坐标 -> DOM 浮层坐标”这一条单向换算链路,不要在多个坐标轴上跳来跳去,否则 bug 排查成本高到你想把编辑器重写一遍。5. 渲染性能优化:脏矩形、双缓冲与分块绘制5.1 脏矩形:宁可多画一点,也别整屏重刷Canvas 渲染最直观的性能陷阱,就是每次输入都clearRect(0, 0, width, height)然后全量重画。这种写法在小的 readonly demo 里没问题,但在长文档里等于每做一次编辑就把整个屏幕画一遍——白浪费 CPU/GPU。我的做法是引入“脏矩形”机制。每一帧里,先把所有需要重绘的区域登记成一个或多个矩形,再用clearRect(dirtyRect)清掉这些区域,最后只在各区域内重新绘制受影响的文本。比如你输入一个字符,涉及的重绘区域往往只有光标所在行附近的几百像素高;一个选区变化,也只涉及选区所在的若干行。把这些矩形合并后,单次重绘的平均面积能控制在屏幕面积的 10%~30%,滚动性能会有非常直观的提升。这里有个细节要提:脏矩形的粒度不能太小。如果你每个字符的宽度变化都登记一个 5×20 的矩形,浏览器对 Canvas 子区域的绘制效率反而会坍缩。一般的经验值是将横向位置按 10 像素、纵向位置按行高进行粗粒度的合并,让重绘批次尽量稳定、数量尽量少。5.2 双缓冲:先画到离屏,再同步上屏双缓冲在游戏引擎里很常见,在 Canvas 编辑器里同样适用。做法是维护一个离屏 Canvas 或者OffscreenCanvas,所有绘制操作先打到离屏缓冲上,全部完成后再用一次drawImage直接把缓冲推到可见 Canvas。这样一来,屏幕不会出现“画了一半的残影”——要知道在连续滚动或高频选区变化时,清屏和重绘之间哪怕只差 1 帧,用户都可能看到闪烁。我实现的时候,并没有让离屏缓冲永远是整页画布。对于编辑高频区域,我只维护一个“视口重置缓冲”,尺寸保持跟视口一致;对于文本变更频繁的局部行,维护一个更小的离屏 Canvas,专门给行命中测试和脏矩形重绘用。这样双缓冲的成本被精确摊到真正有需要的局部区域上,内存压力也小得多。5.3 分块绘制:把文档切瓦片,只画看得见的遇到超长文档,整页 Canvas 在初始化的时候也没必要把 100 屏的内容全部画出来。我采用的是“瓦片地图”思路:把文档按固定高度(比如 2000px)切成一个个瓦片区块,每个瓦片对应一个独立的离屏 Canvas。视口滚动时,加载或生成当前视口周边 1~2 个瓦片;离开视口的瓦片可以直接释放或保留复用。这样做的收益非常明显。首屏渲染时间只等于首屏瓦片加相邻瓦片的绘制时间,而不是整份文档的排版时间;滚动时也只是新瓦片上台、旧瓦片离台,几乎感受不到初始化压力。唯一要注意的是瓦片切换时的衔接,如果瓦片生成得不够快,滚动会有一部分区域出现空白。我的经验是“预加载当前视口前方的一块瓦片”,配合滚动方向调整策略,能达到不错的跟手效果。5.4 requestAnimationFrame 统一调度编辑器交互的事件频率非常高:输入法 composition 事件每触发一次就可能有新的文本候选,鼠标拖拽选区每秒移动几十次,滚轮每滚一格都会改变视口。如果每个事件都立刻触发一次 Canvas 重绘,你会发现自己陷入“重绘次数无限接近事件次数”的泥潭。我最终采用的是 rAF 统一调度。任何需要重绘的地方,先把这个需求写进一个待重绘队列,在下一帧开始时统一合并所有脏矩形再执行一次重绘。这样即使一帧里触发了 20 次事件,最终也只会合并出一次绘制。这个方案的副作用是“轻微延迟”,但因为只剩一个 16ms 级别的时间差,人的感知基本可以忽略,而帧率表现会稳定很多。6. 输入与光标:Canvas 下的输入框、IME 合成与光标闪烁6.1 隐藏 DOM 文本输入框做焦点锚点要让 Canvas 编辑器像普通 contenteditable 一样接收键盘输入,最稳的方案不是给 Canvas 绑 keydown,而是在 Canvas 光标位置附近放一个透明的<textarea>或<input>作为隐藏输入锚点。原理上,这个输入框不显示文本、不带边框、永远定位在当前光标所在坐标附近,并保持足够小的视觉尺寸(比如 1×1 然后裁剪掉滚动条)。用户的所有输入事件、输入法合成事件、键盘事件,都由这个元素天然接收。只需监听它的 input 事件,把新增的文本送进渲染内核即可。这个方案最大的好处是你不用手动模拟输入法。中文输入法的候选窗弹出的位置、合成的过程、最后 confirm 的时刻,浏览器和 OS 已经在系统层处理好了。你要做的只是把这个隐藏输入框的位置定准,让候选窗在屏幕上出现在正确的位置。6.2 IME 合成窗位置修正是个大坑现在说说坑。隐藏输入框的位置如果只是静态定位在视口中心或者固定在左上角,中文输入的候选窗就会出现漂移——你在文档第一行打字,输入法候选窗却浮在屏幕最下方,体验瞬间劝退。我的做法是:每次光标移动或内容滚动后,重新计算输入框的位置。计算时会拿当前光标所在 Canvas 内部坐标,换算成屏幕坐标,再减去滚动偏移,最后把输入框 css top/left 设成这个值。同时要处理输入框尺寸:如果输入框的宽度是默认的 100px,在屏幕中间有一个大词块时,输入法候选窗也会跟着错位。所以 IME 阶段我会把输入框设置成全宽但极小高度,避免候选窗因为输入框尺寸太大而滑到奇怪的位置。另外,compositionstart 到 compositionend 期间,我不会去重绘选区或重置光标位置。因为 IME 合成期的文本变更和最终 confirm 的文本变更并不一致,提前刷新渲染层很容易让光标“跳走”。正确做法是等 compositionend 拿到最终文本后,再同步渲染内核。我最初忽略了这一点,在输入法合成过程中监听 input 并立刻重绘,结果用户用拼音输入“你好”的时候,候选条还没选完,正文就先乱了一下。6.3 光标闪烁与选区重绘的实现细节Canvas 光是画一条 1~2px 的竖线不算难,难在让它“闪闪发光”而不产生额外性能损耗。我用的方案是:光标状态记录为一个受时间控制的布尔值 —— 每 500ms 翻转一次,但翻转后不立即重绘,而是先做一个 8ms 左右的脏矩形登记,下个 rAF 帧再把它画进去。曾试过用setInterval(() => clearRect + draw), 结果在慢速设备上光标闪动会变成“忽亮忽暗”,观感极差;改成 rAF 驱动后,闪烁节奏就稳定了。选区的绘制更麻烦一点。Canvas 画选区时,需要把文本区域按行拆成矩形,每一行画一个透明度 0.3 左右的半透明矩形;如果选区跨越多种字体、多种行高,还得按字体边界切更多细块。为了性能,我不会把选区画成一个连续路径,而是直接用多矩形合并到一个离屏缓冲里,一卷到底。RTL 模式和双向文本混排场景下,选区形状会变成多边形,到时候建议直接调用 Canvas 的Path2D去描形状,而不是生硬地画矩形。7. 协同编辑适配:远端增量如何驱动渲染层局部刷新7.1 从 OT/CRDT 定位受影响的行区间如果你的编辑器接入了协同,那么除了用户本地输入,还可能收到远端小伙伴的文本操作。在纯 DOM 场景里,你只要把远端 op 应用到数据模型上,再靠框架 diff 自动更新视图即可,性能勉强能接受。但在 Canvas 渲染下,你得自己判断“这份 op 改动了哪些视觉区域”。我的做法是把 op 拆到“增、删、改”三个基本动作,然后去行索引里找这些动作影响的行区间。比如远端在第 500 行第 10 列插入 20 个字符,受影响的是第 500 行的宽度和文本,可能还牵动第 500 行后半段到第 510 行的换行位置。我先把这个区间定位出来,打上脏矩形标记,下一个重绘帧就只刷新这个区间,而不会全屏重画。7.2 应用远端 op 的渲染视图更新顺序这里有一个关键时序问题:是先改数据模型再重绘,还是先重绘再改数据模型?我的结论是:先改数据模型,再根据脏标记重绘。如果先重绘再改模型,你会看到远端文本插入后,旧文本和新文本在视觉上“叠”在一起,然后突然跳到最终状态,效果很生硬。合理顺序是:接收远端 op更新文本数据模型(包括行索引和渲染缓存)计算受影响的行区间并注册脏矩形合并所有脏矩形,统一在下一个动画帧内完成重绘7.3 与协同游标、在线头像的配合协同编辑器往往还有远端游标、选中高亮、在线用户头像这些元素。这些元素如果全部画进 Canvas,性能很吃紧,因为每个远端用户的光标位置和选区都可能实时变化。更稳的做法是:把远端对象状态保存在渲染层之外,由 Canvas 负责“画游标箭头和名字标签”,但这部分也走脏矩形合并,不逐个重画全屏。我一般建议,远端用户列表和头像数字(比如右上角一列)放 DOM,正文内联的远端光标和名字小气泡放 Canvas。如果需要让远端高亮跟随滚轮移动,就把高亮区域拆成小矩形块,与文本的脏矩形合并。这个方案在 5~10 个协作者同时在线时,实测帧率依旧能稳定在 50fps 左右。8. 性能实测:同一份长文档,混合架构与纯 DOM 的对比光说不练不行。这里分享一组我自己的对比数据。测试环境是在某文档模拟项目上,用同一份约 10 万字符、含 3000 个代码块标记的测试文档。对比了纯 DOM 方案(使用虚拟滚动 + contenteditable)和 Canvas + DOM 混合方案在几个关键指标上的差异:指标纯 DOM 方案Canvas + DOM 混合方案首屏渲染时间820ms410ms滚动平均 FPS31fps58fps连续输入 500 字符的输入延迟平均 210ms平均 98ms滚动时内存占用峰值430MB265MB选区拖拽时的掉帧比例11%1.2%需要说明的是,不同实现的优化程度差异很大,数字本身不绝对。但方向上比较一致的是:混合方案在首屏时间、滚动流畅度、输入延迟上都明显占优,尤其在内存占用和掉帧比例上提升空间更大。Canvas 把海量文本从 DOM 树中“抽”出来之后,浏览器的重排压力一下子小了很多,内存也因此降了不少。这个测试结果也侧面印证了:如果你做的产品有“长文档 + 高频交互 + 实时协作”这三个要素,那很值得考虑切换渲染内核。而如果只是短文案编辑、后台表单,那大可不必折腾,纯 DOM 足够。9. 踩坑记录:三个让我 debug 到凌晨的混合渲染问题9.1 滚动不同步导致的“鬼影”残留刚上线后一段时间内,有用户反馈说在 Windows 上快速上下滚动时,Canvas 层边缘会残留上一帧的旧文本影子。排查下来发现是滚动监听和 Canvas 重绘顺序的问题:滚轮事件触发了scrollTop更新,但 Canvas 内部逻辑坐标没有同步更新,理想情况下我应该在同一帧内完成两者对齐,实际操作时却漏掉了滚动方向的下一帧偏差,导致新旧内容在滚动衔接处“叠住”一帧。修复方式是给滚动事件增加一个节流队列:先把滚动偏移计入一个待处理变量,在下一次重绘帧内统一应用到 Canvas。同时给 Canvas 的内边距加一个超出容器高度的缓冲带,让极端滚动帧不至于瞬间暴露空白。9.2 高分屏下 Canvas 文字发虚第一次在高分屏(devicePixelRatio = 2 或 3)上测试,正文文字明显发虚。原因很简单:Canvas 的物理像素和 CSS 像素不一致,如果不乘上 devicePixelRatio,画出来的文字就会被浏览器按 2 倍不足的方式缩放拉伸。绕法我记得写过无数次了:先canvas.width = cssWidth * dpr,再ctx.scale(dpr, dpr),然后所有绘制坐标全部使用 CSS 像素值。这样 Canvas 内部是以物理像素级别绘制的,文字在屏幕上才会清晰。唯一要在意的代价是内存——高分屏下 Canvas 离屏缓冲的面积会变成 4 倍,所以对瓦片缓存的数量要格外克制,只保留视口附近的瓦片。9.3 输入法候选词连续换页时选区闪断用中文输入法连续翻候选词页的时候,我遇到过一个很隐蔽的 bug:候选词每翻一页,正文选区就闪一下,甚至偶尔光标位置跑偏一下。排查后发现问题出在 compositionupdate 事件触发时,我没有区分“候选列表变化”和“文本确认变化”,导致渲染层在合成期内执行了一次选区重绘。修复方式是给 composition 状态机加一个“合成中”标志,在整个 composition 期间禁止重绘选区、禁止移动隐藏输入框的位置,直到 compositionend 再统一同步。这个改动看起来很点单,但对中文输入体验的改善是最直接的。10. 混合架构的边界:什么时候不建议走这条路技术选型最怕的是“有了锤子,看什么都是钉子”。Canvas + DOM 混合渲染虽然解决了长文档性能问题,但它并不是万能的。如果你的编辑器重度依赖以下能力,那走纯 DOM 可能更稳:复杂富文本格式:多层嵌套列表、表格跨行合并、未定稿字体的大范围排版。Canvas 处理这种结构化布局,线条宽度和边界计算会非常折磨人。无障碍与读屏支持:屏幕阅读器依赖 DOM 的文本节点和 aria 语义。Canvas 画出来的内容读屏软件完全感知不到,需要额外做大量屏幕外 DOM 镜像或 aria-live 通报,成本不低。原生选区复制语义:用户可能习惯用鼠标选择一段正文然后直接 Command+C 复制。DOM 原生支持,Canvas 则需要自绘选区、自建剪贴板格式转换,反而不如 DOM 顺手。频繁的第三方 DOM 组件嵌入:像嵌入地图、流程图编辑器这些页面,如果把容器盖在 Canvas 上,又要处理 z-index、焦点穿透、缩放适配,复杂度会比想象中高得多。混合架构更适合的是:长文本阅读与编辑为核心,富文本修饰为辅,性能要求敏感的文档类产品。各产品的实际边界不尽相同,最好的做法是先画出一条你服务的高频交互场景清单,逐一判断“用户能不能接受这个交互在 1 帧内完成”,能接受的进 DOM,不能接受的进 Canvas。从我个人的实操体会来说,不要一开始就全量替换。先加一个渲染层开关:默认走 DOM 方案,使用方打开“高性能模式”之后再切入 Canvas 内核对正文进行自绘渲染,DOM 只承担浮层和信息展示,这样遇到兼容问题还能随时回退。在迭代过程中你会发现,真正让你放弃 DOM 方案的不是渲染速度本身,而是你无法控制浏览器在超长文本上的布局细节;而真正让你放弃全 Canvas 方案的也不是绘制性能,而是你被输入法、无障碍和坐标映射折磨到怀疑人生。最终还是“各管一摊,按需混合”的架构最能平衡开发效率与用户体验。