为什么现代富文本编辑器最终都会抛弃 DOM?

很多前端第一次接触富文本编辑器时,都会觉得:
<divcontenteditable="true"></div>
似乎已经够用了。浏览器已经帮你实现好了:
-
光标
-
输入
-
选区
-
粘贴
-
删除
-
undo
甚至还能直接 document.execCommand("bold");,于是很多人会天然认为:富文本编辑器本质上不就是“操作 DOM” 吗?
但真正开始做编辑器后,你会发现,现代编辑器最后会逐渐演变成:
状态机 + 事务系统 + 渲染引擎 + 协同系统 + 输入法兼容层
尤其 AI 出现之后,这种趋势越来越明显。今天这篇文章,我们就来聊聊为什么现代富文本编辑器,最终一定会从直接操作 DOM 演变成维护自己的文档状态。
1. 原生API所存在的问题
来看一个非常真实的场景,比如你正在做 AI 编辑器,用户选中 hello world,然后点击 AI 改写,模型返回 Hi world,很多人第一反应可能是:
const selection = window.getSelection();if (!selection.rangeCount) return;const range = selection.getRangeAt(0);range.deleteContents();range.insertNode(document.createTextNode("Hi world"));
看起来似乎没什么问题。但真正运行后,你很快会发现,整个编辑器开始逐渐失控。比如:
-
光标丢失
-
undo 失效
-
mention 被拆碎
-
输入法异常
-
selection 错乱
这里最重要的一点是你会第一次意识到,编辑器真正维护的,根本不只是 HTML。
为什么只靠 DOM 会越来越痛苦?
前面这种做法,本质上是 用户操作->直接修改 DOM。也就是说,DOM 既是页面展示结果,也是编辑器数据源。这是最传统的编辑器实现方式。
比如:
range.surroundContents(strong);
或者:
editor.innerHTML
直接作为最终文档保存。但问题在于:DOM 并不理解“文档”。它只理解:
-
div
-
span
-
textNode
-
strong
浏览器真正维护的是页面结构,而不是文档语义。这个差别特别重要。
一个特别经典的问题:Mention
比如:
hello @User1
浏览器里的 DOM 可能长这样:
<p>hello<spandata-id="user1">@User1</span></p>
从浏览器角度:
这只是一个 span。
但从编辑器角度,它其实是一个完整的 mention 节点。因为:
@User1
并不只是字符串。它背后还绑定着:
-
用户 ID
-
用户资料
-
通知关系
-
协同状态
这时候问题来了,如果用户按 Backspace 呢?浏览器默认行为可能会变成 @User1->@Us。因为浏览器只知道:删除字符,但业务真正想要的是整个 mention 一起删除。
这时候你会第一次发现 DOM 根本不理解“这是一个 mention”,浏览器看到的是 <span>@User1</span>,但编辑器真正关心的是:
{type: "mention",content: "@User1",data: {id: "user1"}}
这里其实已经暴露了现代编辑器最核心的问题:DOM 无法表达稳定语义。
来看另一个特别真实的问题。比如用户点击 Ctrl + B,浏览器可能生成:
<strong>hello</strong>
但有时候也可能生成:
<spanstyle="font-weight:bold">hello</span>
甚至:
<b>hello</b>
虽然视觉效果完全一样,但 DOM 结构已经不一样了,这时候问题就出现了。
编辑器真正想表达的其实是“这段文字是加粗”,而不是“用了哪种 HTML 标签”。
于是现代编辑器开始意识到 HTML 不是文档本身,HTML 只是展示结果。真正稳定的东西应该是:
{type: "text",content: "hello",styles: {bold: true}}
这里语义终于开始和 DOM 分离。
2. 现代编辑器的设计
现代编辑器内部通常会维护一套 AST 抽象语法树,不过更重要的,是需要理解 AST 是编辑器自己的文档语言。
比如浏览器看到的是 <strong>hello</strong>,编辑器看到的是:
{type: "text",content: "hello",styles: {bold: true}}
浏览器看到的是 <span data-id="user1">@User1</span>,编辑器看到的是:
{type: "mention",content: "@User1",data: {id: "user1"}}
浏览器看到的是 <div><br></div>,编辑器看到的是:
{type: "paragraph",children: []}
这里发生了一件特别重要的事情,编辑器开始拥有自己的“世界模型”。它终于不再只是操作 HTML,而是开始理解文档
现代编辑器真正维护的已经不是 DOM
这是现代编辑器最核心的一次变化。
-
旧世界:
DOM === Document -
新世界
-
EditorState === Document -
DOM === Render Target
也就是说 DOM 不再是真相。真正的文档开始保存在:
-
AST
-
EditorState
-
Node Tree
这些内部状态里。DOM 只是最终渲染结果而已。
输入过程开始变成:DOM -> AST
来看一个最小实现。
比如:
<div id="editor" contenteditable="true"></div>
监听输入:
editor.addEventListener("input", handleInput);
然后:
function handleInput() {state.ast = parseHTML(editor.innerHTML);}
这里发生的事情其实是:
-
浏览器先修改 DOM
-
编辑器再解析 DOM
-
转换成自己的文档状态
也就是说,DOM 只是输入层。真正保存的数据是 state.ast,这一步特别重要。因为编辑器终于开始“接管浏览器”。
Undo 为什么最终一定要自己实现?
浏览器其实已经有 Ctrl + Z,但现代编辑器最后还是会自己维护:
-
undoStack
-
redoStack
为什么?
因为浏览器根本不知道什么叫“一次编辑操作”。比如 AI 改写需求,用户只点击了一次 AI改写 按钮,但内部可能发生了:
-
删除
-
插入
-
mark 更新
-
selection 变化
如果依赖浏览器 undo,最后可能会变成一个字符一个字符撤销。但真正合理的行为应该是整个 AI 改写一次性回退。所以现代编辑器通常会这样:
undoStack.push(structuredClone(state.ast));
然后:
function undo() {state.ast = undoStack.pop();updateEditor();}
这里的核心思想其实是撤销的不是 DOM。而是文档状态。
输入法问题
现代编辑器,通常还需要考虑输入法的问题。
很多人第一次做中文输入法时,编辑器会直接崩掉。因为中文输入并不是按键后立刻产生字符,而是:
n↓ni↓nih↓nihao↓你好
中间会经历:
-
compositionstart
-
compositionupdate
-
compositionend
如果你在拼音组合阶段,不停修改文档状态,那么编辑器可能就会出现:
-
光标乱跳
-
拼音中断
-
selection 丢失
因此现代编辑器都会维护一个关于是否 composition 的开关值:
state.isComposing = true;
然后:
if (state.isComposing) return;
等输入法真正结束后,再更新 AST。
3. 现代编辑器越来越像 React
讲到这里,你一定会发现,现代编辑器已经越来越像一个运行时,而非简单的 textarea 文本框。
这也是为什么很多人第一次看:
-
Lexical
-
ProseMirror
-
Slate
这些现代编辑器的源码时,都会有一种感觉:“怎么越来越像 React?”
因为它们其实都在做:
State↓Diff↓Reconcile↓Commit DOM
这和 React 非常像。
React 的世界
-
State
-
Virtual DOM
-
Diff
-
Update DOM
编辑器的世界
-
EditorState
-
Node Tree
-
Reconcile
-
Update DOM
你会发现,现代编辑器本质上已经变成状态驱动 UI 系统。DOM 不再是直接操作对象,而是渲染结果。
AI 出现之后,这种架构设计就更加显得重要了,无论是哪一种操作:
-
AI Rewrite Paragraph
-
AI Insert Table
-
AI Generate Block
-
AI Rewrite Section
AI 操作的都不是简单的字符,而是对应的文档结构。
4. 写在最后
回头再看整个编辑器的发展过程,你会发现整个过程就是:
-
浏览器原生编辑系统
-
逐渐失控
-
编辑器开始接管控制权
-
最终建立自己的文档世界
于是:
-
DOM 不再是真相
-
HTML 不再是文档
-
Selection 不再只是浏览器能力
-
Undo 不再交给浏览器
-
Input 不再完全相信 contenteditable
最后,现代富文本编辑器逐渐变成一套独立文档 runtime。内部的 AST 的真正意义也不仅仅是一种树结构,而是编辑器理解文档的方式。
这也是为什么现代编辑器最后会越来越像 React 的原因。
RECOMMEND





夜雨聆风