资讯动态

从零打造富文本编辑器内核:文档模型、光标与撤销重做的实战之路

发布时间:2026/9/15 21:31:56 来源:尧图企业网站定制
接手 editor 这个项目的原因其实很朴素产品需要一个能嵌进现有后台系统的富文本编辑区我先花了两周时间试现成的轮子结果一路碰壁——包体积一个比一个大gzip 之后动辄两三百 KB更要命的是中文输入场景下光标乱跳候选词框位置飘忽等到想加一个自定义的标记功能时才发现那类库的内部文档模型和 DOM 是绑死的想扩展就得改它的源码。折腾到最后我决定自己写一个内核目标压得很小只做三件事稳定的文档模型、可控的光标选区、干净的输入管线。这篇就把这几个月从零到一的过程摊开讲包括中途踩过的那些坑和最后的取舍。核心的几个概念绕不开编辑器内核怎么切分层、文档模型用什么结构、光标选区如何跟浏览器对话、输入法合成期要怎么处理、撤销重做的边界划在哪、粘贴进来的脏 HTML 怎么洗。这些东西市面上的教程大多只讲一半剩下的一半只能自己在控制台里试出来。适合读这篇的人正在做或准备做编辑器的前端工程师、被 contenteditable 折磨过的同行、以及想搞清楚输入框背后到底发生了什么的开发者。技术栈我用的是 TypeScript 加原生 DOM没有引入任何编辑器框架所有代码都可以直接搬走。1. 为什么浏览器自带的 contenteditable 撑不起一个真正的编辑器1.1 从能打字到能编辑之间隔着一条鸿沟给一个 div 加上contenteditabletrue你立刻就有了一个可输入区域回车换行、加粗斜体、拖选复制全都自带五分钟能做出一个 demo。这也是最坑人的地方——它给的体验太接近成品了让人误以为再补几个按钮就完事。真正上手做才发现这层能力是浏览器给的不是你控制的而浏览器从来不承诺它的行为在版本之间保持一致。举个我实测过的例子一段文字abc中间三个字符加粗光标放在c后面按退格键。Chrome 会删掉c并把b从加粗节点里挪出来DOM 变成加粗的 ab加普通文本Firefox 的处理方式是把整个加粗节点的文本改掉重新生成Safari 有时会留下一个空的b标签占位。三种结果对用户来说视觉上可能差不多但对你的代码来说是三种完全不同的结构你在它基础上做的任何统计、保存、协同都会跟着乱。更麻烦的是空标签这个隐患。b/b这种零宽节点在 DOM 里是合法的光标放进去之后你就会看到那个经典问题打一个字光标跑到最前面去了。我一开始的处理方式是每次操作后跑一遍清理函数把空的 span 删掉结果删完之后连光标一起没了因为光标本来就挂在这个节点上。维度直接用 contenteditable自建文档模型开发速度极快一天出 demo慢两周才到能用结构可控性由浏览器决定不可预测完全由自己定义跨浏览器一致性差需要大量兼容补丁好只有一层适配数据序列化需要解析 DOM易错直接序列化模型协同编辑几乎无法实现天然支持自定义节点要改源码或注入 hack加一个节点类型即可1.2 DOM 就是文档模型是一个危险的错觉很多人包括半年前的我在起步阶段会把 DOM 直接当数据源要取内容就遍历子节点要判断格式就看parentNode.tagName要保存就innerHTML直接丢给后端。这条路的代价会在三个月后集中爆发。第一个问题是结构不稳定。同一个语义状态可以对应无数种 DOM 结构babc/b和span stylefont-weight:700abc/span和strongabc/strong在视觉上完全一致但你的解析逻辑要写三套分支。第二个问题是无法表达语义DOM 只能表达这个节点加粗了表达不了这是一段引用块它有引用来源和折叠状态。第三个问题是离线处理困难你没法在 Worker 里跑文档分析因为 DOM 只存在于主线程。第四个问题是协同编辑完全无从下手你没有稳定的位置坐标服务端拿到两个操作都不知道该怎么合并。我的调整很彻底DOM 从数据源降级成渲染产物。所有真实状态存在一个纯 JS 对象树里DOM 只是它的一次投影。这个思路转变之后很多原本纠结的问题自动消失了——比如空标签清理模型里根本不允许出现空的行内节点渲染时自然就不会生成。1.3 一次把文档结构彻底搞崩的粘贴事故真正让我下定决心重写的是上线前的一次测试。测试同学从 Word 里复制了三段带格式的文字粘贴进来然后整个编辑区就废了光标跳到文档最上方撤销一次直接回到两分钟前的状态再撤销一次变空白。我把那段剪贴板 HTML 打出来看大概长这样最外层一个html里面是style块定义了十几个MsoNormal类正文里每个段落包在p classMsoNormal里段内每个词组都被span stylemso-bidi-font-family:...单独包一层还有一个o:p是 Word 私有标签浏览器不认。这一坨塞进 contenteditable 之后浏览器的默认粘贴行为会把它原样插进来然后你的撤销栈里就多了几百个无法理解的 DOM 变更浏览器的原生撤销栈被撑爆就出现了一次撤销倒退两分钟的现象。提示只要你的应用允许用户粘贴外部内容就必须自己接管paste事件preventDefault()之后走自己的清洗流程。指望浏览器默认行为在真实用户面前保持一致是不现实的。2. 自建文档模型把编辑器的心脏从浏览器手里抢回来2.1 为什么我选了扁平化的块结构而不是嵌套树第一版模型我写成了嵌套树类似{type:doc, children:[{type:p, children:[{type:text}]}]}这种标准的富文本结构看起来很符合直觉。用了一周我就改成扁平存储了原因是嵌套树在做定位的时候太痛苦你想表达第 37 个字符的位置得先遍历所有块统计前面块的长度这个操作在每次输入时都要做一遍文档一大就成了性能瓶颈。扁平化之后的模型长这样interface Doc { blocks: Block[]; // block 的 id 到索引的映射用于 O(1) 定位 index: Mapstring, number; } interface Block { id: string; type: paragraph | heading | quote | code | list-item; attrs: Recordstring, unknown; children: InlineNode[]; } interface InlineNode { id: string; text: string; marks: string[]; // [bold, italic, link:https://...] } interface Point { blockId: string; nodeId: string; offset: number; }关键改动是块结构只有一层块内是扁平的行内节点数组。文字不再嵌套在多层 span 里而是一段文本 一组标记。marks用数组存[bold,italic]就表示同时加粗斜体。这样做的好处是行内结构永远不嵌套加粗一段文字只是在 marks 里加一项不存在嵌套了多少层的问题。代价也很明确加粗和斜体如果作用于重叠但不同的范围需要在数组层面做切分。比如abcdef中abc加粗、cde斜体那就得切成ab(bold)、c(bold,italic)、de(italic)、f(空)四段。切分逻辑我单独写了一个splitInline函数它大概是整个内核里最需要测试覆盖的地方我给它写了三十多条用例。2.2 位置描述符为什么不能用全局字符偏移量这里有个设计选择值得细说。表达光标位置有两种方案一种是全局字符偏移文档里的第 127 个字符一种是块 ID 加节点内偏移块 p3 的第 2 个节点的第 5 个位置。全局偏移看起来更简洁序列化也方便但它有个致命缺点任何一次编辑都会让其后所有位置的数值失效。你正在输入的时候光标位置是固定的但如果此时有一个异步操作比如词法高亮的 Worker 返回了结果带着旧偏移量回来写装饰位置就全错了。我最后选的是Point { blockId, nodeId, offset }。块和节点都有稳定 ID插入删除只会影响当前块内部其他块的位置描述完全不受影响。定位到具体索引时通过indexMap 查一次是 O(1)再遍历块内节点累加 offset块内节点通常不超过几十个这个开销可以忽略。function resolvePoint(doc: Doc, p: Point): number | null { const bi doc.index.get(p.blockId); if (bi undefined) return null; const block doc.blocks[bi]; let acc 0; for (const node of block.children) { if (node.id p.nodeId) { return acc Math.min(p.offset, node.text.length); } acc node.text.length; } return null; // 节点已被删除调用方需要做降级处理 }返回null这个分支很重要。异步任务拿到的位置可能对应的节点已经被删了这时候绝对不能抛异常也不能强行夹到边界上乱写正确做法是丢弃这次结果。我一开始没做这个判断结果用户快速打字时偶发报错日志里全是cannot read property of undefined。2.3 模型到视图单向渲染与脏块级别的更新模型改完之后要投影到 DOM。最粗暴的方式是每次变更把整个编辑区的 innerHTML 重新生成这个方案在 2000 字以内的文档里看不出问题超过一万字就开始肉眼可见地卡而且每次重建都会丢光标——因为光标挂在被删掉的 DOM 节点上。我的方案是脏块标记加最小更新每个块在 DOM 里对应一个带>let composing false; el.addEventListener(compositionstart, () { composing true; }); el.addEventListener(compositionend, (e) { composing false; // 合成结束此时 e.data 是最终确定的文本 commitText(e.data); }); el.addEventListener(input, (e) { if (composing || (e as InputEvent).isComposing) { return; // 合成期间完全不动模型和 DOM } commitText((e as InputEvent).data ?? ); });合成期间让浏览器自由发挥等compositionend之后再一次性把结果写进模型、重新渲染、重置光标。这个方案的好处是兼容性最好代价是合成期间的高亮、字数统计是滞后的但这完全可以接受——用户在打字的时候不关心字数。注意判断合成状态不能只看自己的composing变量还要看InputEvent.isComposing。某些输入法在某些情况下不会触发compositionstart就直接发input只靠自己的标志位会漏掉。3.3 跨块拖选和反向选区怎么归一化用户从下往上拖选的时候anchorNode是后面的位置focusNode是前面的位置也就是说选区方向是反的。如果你不处理直接拿 anchor 当起点后续所有的删除、加粗、复制操作都会算错范围。我的做法是在读取选区的时候立刻归一化成一个{ from: Point, to: Point }结构from永远是文档顺序靠前的那个。同时单独记一个backward: boolean标记方向只有需要把选区还原给浏览器的时候才用它。这样上层逻辑只需要面对一种情况代码量少了一大截。另一个坑是拖选过程中的性能。用户按住鼠标滑动每移动一像素就触发一次selectionchange如果每次都提交事务、写历史栈按住拖两秒就能产生上百条记录撤销一次只回来一个字符。正确做法是把拖选当成一个事务mousedown时开启事务mousemove期间只更新内存里的选区状态并渲染高亮mouseup时才提交一次事务并且这次事务只记录选区变化不记录内容变更——因为拖选本来就不改内容。4. 撤销重做不是简单地压栈4.1 事务边界的划分一次回车到底算几步第一版的撤销是每次模型变更就压一次栈结果是打一句今天天气不错然后按一次 CtrlZ只回来一个错字用户得按六次才能删掉整句。这个问题非常影响手感必须解决。我的方案是引入事务和合并窗口。每一个原子操作是一个事务事务里可以包含多个细粒度变更。事务提交时带上一个类型标签比如text-input、delete、format、paste、split-block。历史栈在压入新记录时会检查如果上一条记录的标签是text-input新记录也是text-input且两条之间间隔小于 800ms且光标位置是连续的那就合并成一条。interface HistoryEntry { inverse: Patch[]; // 用于撤销 forward: Patch[]; // 用于重做 selectionBefore: Range | null; selectionAfter: Range | null; kind: text-input | delete | format | paste | split; ts: number; }合并窗口的时长是个手感问题没有标准答案。太短了用户打个长句要撤销很多次太长了用户想撤销半句话却整段消失。我最后定在 800ms并且在遇到以下情况时强制切断按了回车、光标位置不连续、切换了输入法状态、鼠标点击了别处。这几个切断点都是我实际用下来觉得用户心理上认为这里应该断的地方。回车为什么必须独立成事务因为回车会拆块拆块是个结构性变更它产生的 inverse patch 和文本变更完全不是一回事混在一起合并逻辑会很复杂而且用户的心理预期是按一次回车撤销一次回到上一行。4.2 存快照还是存操作日志我选了一个更笨但更稳的理论上存操作日志最省内存每个事务只存那几十个字符的增删。但操作日志的前提是你有一个绝对正确的 inverse 计算而 inverse 计算在结构化文档里非常容易出错——插入块的 inverse 是删除块但如果这个块在后续被改过删除它还是否安全加粗的 inverse 是取消加粗但如果原文本来就有斜体呢我一开始写的是操作日志测试阶段发现撤销三次之后文档状态和预期对不上排查起来极其痛苦因为错误可能出现在很多次操作之前反推路径很长。折腾了两天之后我决定换方案。现在用的是结构化的局部快照事务提交时只对被修改到的那几个块做深拷贝存起来而不是整个文档。一个块撑死几百字节一次事务最多影响两三个块内存开销完全可控。撤销的时候直接用快照替换当前块语义绝对正确不存在反推错误的问题。方案内存开销正确性实现难度我的评价全局快照极大万字文档一次几 MB绝对正确极简单文档小可以考虑局部块快照小每次几百字节绝对正确简单最终采用操作日志 inverse最小容易出错困难除非要做协同否则不建议这个选择看起来有点笨但我的判断是撤销功能的正确性优先级远高于内存优化用户遇到一次撤销之后文档坏了就再也不敢用了。而且如果后面真要做协同编辑那时候再补操作日志也不迟两套东西不是互斥的。4.3 撤销之后光标跑回文首是怎么修好的功能做出来之后有个体验问题撤销之后光标总是跑到文档最开头用户得手动点回去。原因是替换块的内容时DOM 被重建了浏览器发现原来的光标节点不存在了就把它丢到了容器的第一个位置。修法是在历史记录里把选区一起存。每个HistoryEntry除了内容快照还存了selectionBefore和selectionAfter。撤销时先做内容替换等下一帧 DOM 更新完成之后再把selectionAfter对应的位置还原回去。还原时要做一次有效性校验如果这个位置指向的块已经被删了就退到该块的起始位置而不是直接放弃。提示设置选区一定要放在 DOM 更新之后的下一帧。同一个同步流程里设置选区会因为浏览器还没完成重排而失效表现为有时候能恢复有时候不能非常难排查。5. 粘贴、快捷键与富文本净化5.1 剪贴板里拿到的东西比你想象的脏得多前面提到 Word 的例子其实不只是 Word。从浏览器网页复制一段文字会带上目标页面的所有内联样式从 Notion 或者语雀复制会带一堆自定义属性从 PDF 复制会产生每个字符一个 span 的极端情况我实测过一篇两页的 PDF粘贴进来生成了四千多个 span 节点直接让编辑区卡死。所以粘贴处理的原则很简单只保留你白名单里认识的东西其余全部丢掉。这里的关键是不要试图理解外部格式然后做映射因为外部格式是无限的你的映射规则永远不够。正确做法是反过来先定义自己支持什么剩下的都过不了。我的白名单大概是这样一个结构const TAG_WHITELIST: Recordstring, MarkSpec { strong: { mark: bold }, b: { mark: bold }, em: { mark: italic }, i: { mark: italic }, u: { mark: underline }, code: { mark: code }, a: { mark: link, attr: [href] }, }; const BLOCK_WHITELIST: Recordstring, string { p: paragraph, h1: heading, h2: heading, h3: heading, blockquote: quote, li: list-item, };处理流程是把粘贴的 HTML 塞进一个游离的 DOM 容器document.createElement(div)不挂到页面上然后递归遍历遇到白名单标签就记下对应的 mark 往下走遇到不在白名单里的标签就拆壳——也就是只保留它的子内容标签本身丢掉。文本节点直接收集遇到br转成换行遇到img判断 src 是不是合法协议再决定保不保留。最后把收集到的块和行内节点合并进文档。5.2 样式属性不能一概而论地扔掉有些信息藏在 style 属性里比如font-weight:700等价于加粗text-decoration:underline等价于下划线。完全忽略 style 会让从很多地方复制的内容丢掉格式。我的做法是只解析几个有限的属性映射成 mark其他属性一律丢弃。还有一个坑是看起来一样的嵌套。从某些编辑器复制出来的内容外层是b内层又有个span stylefont-weight:bold递归下去会得到两个 bold mark。我的模型里 marks 是数组重复添加前会去重所以不会出问题但如果你的模型是用嵌套节点表示格式的就会出现双重加粗的嵌套结构后面取消加粗时只能去掉一层留下一个看起来没变化但结构已经污染的标签。粘贴还有个边界必须处理粘贴内容里的链接。用户从网页复制的时候锚文本可能是图片也可能是一大段话href可能是javascript:开头的伪协议。我会做一个协议白名单校验只放行http、https、mailto和相对路径其他一律降级成纯文本。这一步不做后面渲染成a的时候就是安全隐患。5.3 快捷键拦截的优先级要理清楚快捷键这块的坑在于浏览器默认行为的优先级很难统一控制。CtrlB在 contenteditable 里会让浏览器直接执行加粗跟我自己的格式变更逻辑打架导致一条命令执行两次。CtrlZ更麻烦它触发的是浏览器的原生撤销栈跟我的历史记录完全是两套。我的处理方式是在编辑容器上监听keydown对需要接管的组合键直接preventDefault()并stopPropagation()。接管的清单如下快捷键浏览器默认行为我的处理Ctrl/Cmd B浏览器原生加粗拦截走自己的 mark 逻辑Ctrl/Cmd I浏览器原生斜体拦截Ctrl/Cmd Z浏览器原生撤销拦截走自己的历史栈Ctrl/Cmd Shift Z浏览器原生重做拦截Ctrl/Cmd V粘贴拦截走自己的清洗Tab焦点移出编辑区不拦截但在列表内缩进时例外Enter插入 div 或 br拦截走自己的拆块逻辑方向键光标移动不拦截交给浏览器这里有一条经验能不拦就别拦。方向键、Home、End、PageUp 这些交给浏览器处理体验比你自己实现好得多尤其是涉及跨行移动和页面滚动的时候。我自己实现过一版上下键跨行移动光标处理上一行比当前行短的情况时折腾了很久最后还是换回浏览器默认行为只在需要的时候做后置修正。6. 从能用到好用装饰层、大文档性能与测试6.1 装饰必须和文档内容分开存编辑器做到后面一定会遇到这类需求搜索关键词高亮、拼写检查波浪线、提及的蓝色标记、评论锚点。这些视觉标记有个共同特点——它们不改变文档内容。如果把它们写进模型里导出文档的时候就得剥掉协同的时候还得处理装饰的同步全是额外负担。我的做法是单独一个装饰层一个Decoration[]数组每一项是{ from: Point, to: Point, type: string, attrs: object }。渲染块的时候先把装饰按范围排序然后在生成 DOM 的过程中把装饰对应的样式类或元素插进去。搜索高亮切换时只更新装饰数组并重渲染受影响的块文档模型一点不动。这里有个细节要提醒装饰的范围要允许部分落在已删除区域。用户搜编辑器高亮了五处然后删掉了其中一段装饰数组里的区间可能就指向了不存在的节点。渲染时遇到无效范围直接跳过即可不要试图修正它——修正逻辑会很复杂而且用户下次搜索时会重新生成丢掉旧的完全没有副作用。6.2 十万字文档的实测数据和优化手段我拿一份十万字左右的中文文档做过压力测试最初的实现是全量渲染打开文档耗时 3.2 秒输入一个字符的响应延迟在 180ms 左右基本没法用。经过几轮优化之后打开耗时降到 400ms 以内输入延迟稳定在 16ms 以下也就是一帧之内。具体的优化手段按收益排序大概是这样第一是分块渲染加虚拟滚动。只渲染可视区域内的块上下各预渲染 20 块作为缓冲。滚动时通过IntersectionObserver检测进入视口的块动态补齐。这一步收益最大把 3.2 秒直接砍到 800ms 左右。第二是块高度估算加缓存。虚拟滚动需要知道每个块的高度才能算总高度和滚动位置如果每次都去测量真实高度滚动时会疯狂重排。我的做法是给每种块类型一个默认高度段落 28px、标题 40px 等已渲染过的块把真实高度缓存下来滚动位置根据已测量高度 未测量估算高度计算。第三是避免布局抖动。在计算高度的时候绝对不能交替执行读 layout 属性和写 DOM因为这会强制浏览器同步重排。我的做法是批量读、批量写中间用一次requestAnimationFrame隔开。优化阶段打开耗时输入延迟主要手段初版全量渲染3200ms180ms无加虚拟滚动800ms45ms只渲染视口内块加高度缓存520ms22ms避免重复测量加渲染合帧与脏块更新400ms16msrAF 合帧最小更新这里要说句实话虚拟滚动和光标处理是天然冲突的。如果光标在视口外用户按方向键往那里移动时光标所在位置没有 DOM 节点设置选区就会失败。我的处理是在根据模型位置设置选区之前先检查目标块是否已渲染没有的话先滚动到那个位置、等渲染完成再设置选区。这个链路会有一次异步跳转视觉上感觉是页面滚了一下然后光标出现实际用起来反而比原生更符合预期。6.3 编辑器怎么测模型层单测加输入回放脚本编辑器的测试难度在于很多问题只在真实交互序列下才会出现单纯的单元测试覆盖不到。我最后用的是一个两层策略。底层是模型层的纯函数单测覆盖所有结构性操作插入文本、删除范围、切分块、合并块、应用 mark、拆分行内节点、序列化往返。这些都是纯数据变换不涉及 DOM跑起来飞快我大概写了 200 多条用例其中行内节点切分占了三分之一。这一层的目标是任何输入都不产生非法状态比如出现空的 mark 数组、跨块的范围引用、指向不存在节点的 Point。上层是输入回放脚本。我用 Puppeteer 记录真实的操作序列比如输入一段中文、选中中间几个字加粗、在末尾回车、粘贴一段文本、连续撤销四次然后断言最终的模型序列化结果。这个层级的用例不多二十条左右但覆盖面很广很多跨模块的 bug 都是在这里抓到的。比如那次撤销后光标跑到文首的问题模型断言是全过的只有加了选区断言之后才暴露出来。提示回放脚本里一定要包含中文输入法的模拟。Puppeteer 里可以直接page.keyboard.sendCharacter()输入中文字符虽然不能完整模拟候选词过程但至少能覆盖compositionend之后的那条路径比全用英文测试强得多。最后再分享一个我自己用下来效果不错的小技巧在开发期挂一个全局的调试开关打开之后每次事务提交都把变更前后、选区前后、历史栈长度打成一个结构化对象塞进window.__editorLog出问题的时候直接从控制台把这个数组复制出来看。比在代码里到处打console.log高效太多尤其是排查那些操作了十几步之后才出错的问题完整的日志链路基本一眼就能定位到是哪一步开始不对劲的。这个开关我到现在都留着只是上线版本里关掉了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价