接手 "editor" 这个项目的原因其实很朴素:产品需要一个能嵌进现有后台系统的富文本编辑区,我先花了两周时间试现成的轮子,结果一路碰壁——包体积一个比一个大,gzip 之后动辄两三百 KB;更要命的是中文输入场景下光标乱跳,候选词框位置飘忽;等到想加一个自定义的标记功能时才发现,那类库的内部文档模型和 DOM 是绑死的,想扩展就得改它的源码。折腾到最后我决定自己写一个内核,目标压得很小,只做三件事:稳定的文档模型、可控的光标选区、干净的输入管线。这篇就把这几个月从零到一的过程摊开讲,包括中途踩过的那些坑和最后的取舍。
核心的几个概念绕不开:编辑器内核怎么切分层、文档模型用什么结构、光标选区如何跟浏览器对话、输入法合成期要怎么处理、撤销重做的边界划在哪、粘贴进来的脏 HTML 怎么洗。这些东西市面上的教程大多只讲一半,剩下的一半只能自己在控制台里试出来。适合读这篇的人:正在做或准备做编辑器的前端工程师、被 contenteditable 折磨过的同行、以及想搞清楚"输入框背后到底发生了什么"的开发者。技术栈我用的是 TypeScript 加原生 DOM,没有引入任何编辑器框架,所有代码都可以直接搬走。
1. 为什么浏览器自带的 contenteditable 撑不起一个真正的编辑器
1.1 从"能打字"到"能编辑"之间隔着一条鸿沟
给一个 div 加上contenteditable="true",你立刻就有了一个可输入区域,回车换行、加粗斜体、拖选复制全都自带,五分钟能做出一个 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 结构,<b>abc</b>和<span style="font-weight:700">abc</span>和<strong>abc</strong>在视觉上完全一致,但你的解析逻辑要写三套分支。第二个问题是无法表达语义,DOM 只能表达"这个节点加粗了",表达不了"这是一段引用块,它有引用来源和折叠状态"。第三个问题是离线处理困难,你没法在 Worker 里跑文档分析,因为 DOM 只存在于主线程。第四个问题是协同编辑完全无从下手,你没有稳定的位置坐标,服务端拿到两个操作都不知道该怎么合并。
我的调整很彻底:DOM 从"数据源"降级成"渲染产物"。所有真实状态存在一个纯 JS 对象树里,DOM 只是它的一次投影。这个思路转变之后,很多原本纠结的问题自动消失了——比如空标签清理,模型里根本不允许出现空的行内节点,渲染时自然就不会生成。
1.3 一次把文档结构彻底搞崩的粘贴事故
真正让我下定决心重写的是上线前的一次测试。测试同学从 Word 里复制了三段带格式的文字粘贴进来,然后整个编辑区就废了:光标跳到文档最上方,撤销一次直接回到两分钟前的状态,再撤销一次变空白。
我把那段剪贴板 HTML 打出来看,大概长这样:最外层一个<html>,里面是<style>块定义了十几个MsoNormal类,正文里每个段落包在<p class="MsoNormal">里,段内每个词组都被<span style="mso-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: Map<string, number>; } interface Block { id: string; type: "paragraph" | "heading" | "quote" | "code" | "list-item"; attrs: Record<string, 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 事务边界的划分:一次回车到底算几步
第一版的撤销是每次模型变更就压一次栈,结果是打一句"今天天气不错"然后按一次 Ctrl+Z,只回来一个"错"字,用户得按六次才能删掉整句。这个问题非常影响手感,必须解决。
我的方案是引入事务和合并窗口。每一个原子操作是一个事务,事务里可以包含多个细粒度变更。事务提交时带上一个类型标签,比如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: Record<string, 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: Record<string, 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 style="font-weight:bold">,递归下去会得到两个 bold mark。我的模型里 marks 是数组,重复添加前会去重,所以不会出问题,但如果你的模型是用嵌套节点表示格式的,就会出现双重加粗的嵌套结构,后面取消加粗时只能去掉一层,留下一个看起来没变化但结构已经污染的标签。
粘贴还有个边界必须处理:粘贴内容里的链接。用户从网页复制的时候,锚文本可能是图片也可能是一大段话,href可能是javascript:开头的伪协议。我会做一个协议白名单校验,只放行http、https、mailto和相对路径,其他一律降级成纯文本。这一步不做,后面渲染成<a>的时候就是安全隐患。
5.3 快捷键拦截的优先级要理清楚
快捷键这块的坑在于浏览器默认行为的优先级很难统一控制。Ctrl+B在 contenteditable 里会让浏览器直接执行加粗,跟我自己的格式变更逻辑打架,导致一条命令执行两次。Ctrl+Z更麻烦,它触发的是浏览器的原生撤销栈,跟我的历史记录完全是两套。
我的处理方式是在编辑容器上监听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隔开。
| 优化阶段 | 打开耗时 | 输入延迟 | 主要手段 |
|---|---|---|---|
| 初版全量渲染 | 3200ms | 180ms | 无 |
| 加虚拟滚动 | 800ms | 45ms | 只渲染视口内块 |
| 加高度缓存 | 520ms | 22ms | 避免重复测量 |
| 加渲染合帧与脏块更新 | 400ms | 16ms | rAF 合帧,最小更新 |
这里要说句实话:虚拟滚动和光标处理是天然冲突的。如果光标在视口外,用户按方向键往那里移动时,光标所在位置没有 DOM 节点,设置选区就会失败。我的处理是在根据模型位置设置选区之前先检查目标块是否已渲染,没有的话先滚动到那个位置、等渲染完成,再设置选区。这个链路会有一次异步跳转,视觉上感觉是"页面滚了一下然后光标出现",实际用起来反而比原生更符合预期。
6.3 编辑器怎么测:模型层单测加输入回放脚本
编辑器的测试难度在于,很多问题只在真实交互序列下才会出现,单纯的单元测试覆盖不到。我最后用的是一个两层策略。
底层是模型层的纯函数单测,覆盖所有结构性操作:插入文本、删除范围、切分块、合并块、应用 mark、拆分行内节点、序列化往返。这些都是纯数据变换,不涉及 DOM,跑起来飞快,我大概写了 200 多条用例,其中行内节点切分占了三分之一。这一层的目标是任何输入都不产生非法状态,比如出现空的 mark 数组、跨块的范围引用、指向不存在节点的 Point。
上层是输入回放脚本。我用 Puppeteer 记录真实的操作序列,比如"输入一段中文、选中中间几个字加粗、在末尾回车、粘贴一段文本、连续撤销四次",然后断言最终的模型序列化结果。这个层级的用例不多,二十条左右,但覆盖面很广,很多跨模块的 bug 都是在这里抓到的。比如那次"撤销后光标跑到文首"的问题,模型断言是全过的,只有加了选区断言之后才暴露出来。
提示:回放脚本里一定要包含中文输入法的模拟。Puppeteer 里可以直接
page.keyboard.sendCharacter()输入中文字符,虽然不能完整模拟候选词过程,但至少能覆盖compositionend之后的那条路径,比全用英文测试强得多。
最后再分享一个我自己用下来效果不错的小技巧:在开发期挂一个全局的调试开关,打开之后每次事务提交都把"变更前后、选区前后、历史栈长度"打成一个结构化对象塞进window.__editorLog,出问题的时候直接从控制台把这个数组复制出来看。比在代码里到处打console.log高效太多,尤其是排查那些"操作了十几步之后才出错"的问题,完整的日志链路基本一眼就能定位到是哪一步开始不对劲的。这个开关我到现在都留着,只是上线版本里关掉了。