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

很多前端开发第一次接触富文本编辑器时,都会觉得:
<div contenteditable="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 <span data-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>
但有时候也可能生成:
<span style="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 === DocumentDOM === 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 的原因。
以上关于现代富文本编辑器为什么最终都会抛弃 DOM?的文章就介绍到这了,更多相关内容请搜索码云笔记以前的文章或继续浏览下面的相关文章,希望大家以后多多支持码云笔记。
如若内容造成侵权/违法违规/事实不符,请将相关资料发送至 admin@mybj123.com 进行投诉反馈,一经查实,立即处理!
重要:如软件存在付费、会员、充值等,均属软件开发者或所属公司行为,与本站无关,网友需自行判断
码云笔记 » 现代富文本编辑器为什么最终都会抛弃 DOM?
微信
支付宝