本文深度剖析了现代富文本编辑器的底层实现原理与设计哲学。文章指出,尽管富文本编辑器随处可见,但其复杂性远超直观认知。通过对比浏览器原生的 `contenteditable` 弊端,引出现代编辑器以“文档模型”作为唯一真相来源的核心理念。文中详细阐述了文档模型(节点、标记、属性、结构规则)、语义化选区模型、通过“事务”管理状态变更(实现撤销、协同)、以及 DOM 最小化渲染机制。此外,文章还介绍了如何通过“理解用户意图”(Input Rule、Command)实现智能交互,并特别强调了自定义内联节点(如用户提及)在业务场景中的应用。最后,文章讨论了序列化(JSON、HTML、Markdown)在不同格式间转换的重要性,并强调了富文本编辑器与 React 集成的正确姿势,以及其“流畅”表现背后的性能优化策略。

前言
本文深入讲解现代富文本编辑器的底层原理,从文档模型到渲染机制,帮你彻底看懂 Tiptap、ProseMirror、Lexical 等编辑器的设计哲学。
今日前端早读课文章由 @Szymon Chudy 分享,@飘飘编译。
译文从这开始~~
富文本编辑器无处不在:博客和内容管理系统(CMS)后台、文档工具、聊天窗口…… 几乎每个地方都有它们的身影。我们每天都在使用它们,但大多数前端工程师只有在尝试自己实现哪怕一小部分富文本功能时,才会真正意识到它的复杂程度。
看似简单的问题 —— 让用户输入文字、添加样式、粘贴内容 —— 很快就会变成一连串的边界情况、光标错乱、DOM 输出不一致以及各种浏览器兼容性问题。
好消息是,无论你选择哪种库,所有现代富文本编辑器的核心思想其实都是相通的。只要理解了这些基础概念,你就能理解所有主流编辑器,比如 Tiptap、ProseMirror、Lexical、Slate、CKEditor 等等。
本文的目标,就是帮助你建立这种基础认知,构建一个我在最初接触富文本编辑器时希望自己能拥有的思维模型。
什么是 WYSIWYG
WYSIWYG 是一个常与 “RTE”(Rich Text Editor,富文本编辑器) 一起出现的缩写。根据维基百科的定义:
WYSIWYG(What You See Is What You Get)是一种软件,它允许用户在编辑内容时,以接近最终成品展示效果的方式进行操作。
简单来说,它的承诺非常直白:你在编辑时看到的内容,就是读者最终看到的样子。加粗的文字就是加粗的,标题会更大,列表带有项目符号,图片和视频可以内嵌展示,@szymon 这样的用户提及可以点击并交互。
这种体验非常直观,因为它符合我们使用文字处理软件时形成的思维模式 —— 不需要标记语言,也不用切换预览模式,只需直接操作内容,就能完成格式调整、插入媒体甚至自定义交互元素。
然而,在 Web 平台上实现这种体验却远非易事。我们先从浏览器本身能提供的能力说起。
为什么光靠浏览器还不够
一切的起点是浏览器的一个简单特性:
<div contenteditable="true">Hello!</div>
contenteditable 允许用户直接在 DOM 中输入文本。浏览器会自动处理光标移动、文本选中、基础格式化命令和粘贴操作。对于一个快速原型来说,这几乎像魔法一样 —— 一个 “免费” 的文本编辑器。
但当你尝试在此基础上构建一个真正的产品时,问题就接踵而至。
从 Google Docs 粘贴一段文字,你会得到一大堆嵌套的 <span>、行内样式和无关的类名;删除一部分加粗的单词时,不同浏览器可能会产生完全不同的 DOM:有的会分裂成多个文本节点,有的会合并样式不一致的节点,还有的会遗留空的标签。多次按下撤销(Undo)键,你会发现 Chrome 和 Firefox 的行为都不一样。
问题的根源在于:DOM 并不是内容的稳定、语义化表示。它太宽松,也太不规则。浏览器几乎接受任何 HTML 结构,即便它毫无语义意义。一个段落可以嵌套另一个段落,一个加粗标签可以只包裹半个单词,从而产生破碎的文本节点。缺乏约束的情况下,DOM 结构会在每次用户交互后逐渐 “漂移”,变得不可预测。
这就是为什么现代富文本编辑器会让浏览器负责渲染文字,而不是定义文字的语义。
编辑器的真正 “数据源”
通常,编辑器会抛弃浏览器的 DOM,使用自己的内部数据结构 —— 文档模型(document model)—— 来描述内容的逻辑结构。
附注:这里的 “文档模型” 指的是编辑器内部用于表达内容结构的数据模型,不是浏览器的 Document Object Model(DOM)。虽然名字很相似,但两者完全不同,这个区别非常重要。
从高层次来看,它大致是这样的:
doc
├─ paragraph
│ ├─ text("Hello ", [])
│ └─ text("world", ["bold"])
├─ mention(id="U123", name="szymon")
└─ paragraph
└─ text("Another paragraph", [])
这个模型由以下部分组成:
-
节点(Nodes):段落、标题、列表、图片、嵌入内容等
-
内联节点(Inline nodes):文本、链接、提及、占位符等
-
标记(Marks):加粗、斜体、下划线等
-
属性(Attributes):如 URL、ID 等元数据
-
结构规则(Schema rules):定义哪些节点可以嵌套哪些内容
正是这些 schema 规则,确保结构始终合理。比如列表只能包含列表项(list item),列表项可以包含段落或嵌套列表,但不能包含标题。这些约束防止了错误结构的出现。
最核心的理念是:
文档模型才是唯一的真相来源(source of truth),DOM 只是它的渲染结果。
选择模型(Selection Model)
浏览器的选择(selection)API 是基于 DOM 节点和偏移量(offset) 来工作的,例如:“第二个 div 中第三个文本节点的第 5 个偏移位置”。但问题来了 —— 如果 React 重新渲染并替换了这个 div,会发生什么?你的光标会跳动,用户会一头雾水。
富文本编辑器的做法不同,它们以语义方式追踪选区。编辑器不会说 “光标在这个具体 DOM 节点的第 5 个偏移位置”,而是会说 “光标在第二个段落的第 23 个位置”。这样即使 React 重新渲染、替换掉 DOM 节点,编辑器也能根据文档结构精确地还原光标所在的位置 —— 因为在它看来,“位置 23 仍然是位置 23”,哪怕底层 DOM 已经变了。
编辑器通常会追踪三种类型的选区:
-
光标(Cursor):折叠在单个位置的范围
-
文本范围(Text range):跨越多个字符或节点的选中区域
-
节点选区(Node selection):选择整个节点,比如图片
最后一种类型对于自定义节点尤其重要。举个例子,当你在 Slack 中点击一个用户提及(mention)时,整个 @szymon 都会被选中为一个整体,你不能把光标放在中间去逐字编辑。
事务(Transactions)
编辑器如何改变状态
在现代编辑器中,输入并不会直接修改 DOM。相反,编辑器会把每次变更都表示为一个 事务(transaction)(有时也称为 “操作” operation)。例如:
// 输入一个字符 "X"
insertText("X", position)
// 按下退格键
deleteRange(from, to)
// 应用加粗格式
addMark("bold", from, to)
// 插入一个 mention 节点
insertNode({ type: "mention", attrs… })
每个事务包含两部分内容:
-
1、对文档模型的修改
-
2、对选区状态的修改
例如:
-
插入 “X” 后,光标应向前移动一个位置;
-
插入一个 mention 后,光标应出现在它的后面。
这种设计带来了很多好处:
-
撤销 / 重做:只需保存每个事务的反向操作即可轻松实现;
-
更新可预测:相同输入总能得到相同输出;
-
协同编辑可行:其他用户的操作可以按事务顺序重放;
-
渲染更高效:编辑器确切知道哪些地方发生了变化。
如果你熟悉 React 的状态管理模式,可以这样理解:
事务就像一个个小而明确的 reducer,用来描述 “发生了什么”。
渲染(Rendering)
当事务生成新的编辑器状态后,编辑器会更新 DOM,使其与文档模型保持一致。不过,它不会重新渲染整个页面,而是计算出最小的必要改动。
例如:
-
你输入一个字符,编辑器只会更新对应的那一个文本节点;
-
你给一个单词加粗,它只会包裹那个单词;
-
你删除一个段落,它只会移除对应的 DOM 节点。
这样做非常关键,因为:
-
DOM 操作代价高昂;
-
粗暴的更新容易破坏光标位置或造成视觉跳动;
-
浏览器在更新期间需要保持选区状态;
这也解释了为什么不能仅靠 React 来实现富文本编辑。React 的 “协调算法(reconciliation)” 并没有针对 “在可编辑区域中保持光标稳定” 进行优化。编辑器必须对变化的粒度和时机有更细致的控制。
理解用户意图(Interpreting User Intent)
当用户在 WYSIWYG 编辑器中操作时,他们实际上是在表达意图(intent),而不是直接进行 DOM 操作。
编辑器的任务是把这种意图翻译成事务。
如果你用过 Notion,大概会同意:他们在 “理解用户意图” 这一块做得堪称典范。
例如:
当你在一行开头输入 #(注意后面的空格)时,这行文字会自动变成标题。这就是输入规则(input rule)。编辑器会监控输入内容,识别出特定模式;当理解了你的意图后,它会删除这些触发字符,并将当前块转换为标题。
当你在列表中按下 Enter 键时,浏览器默认会插入换行符,但你真正想要的是 “创建一个新的列表项”。编辑器会拦截这个按键,判断当前块的类型,并执行正确的命令。如果此时光标位于空的列表项中 —— 那就跳出列表;如果在中间 —— 则智能地拆分成两个列表项。
这样的逻辑几乎可以无限延伸。
比如:
-
输入
@会打开自动补全面板; -
输入
/会触发命令面板; -
在 “原子节点”(如图片或 mention)附近按退格键时,会先选中整个节点,然后再删除它。
这些行为都是基于文档模型和用户意图来定义的,而不是依赖原生的 DOM 操作。正是这一层逻辑,让一个 “编辑器” 真正成为 “产品”。同时,这一层还屏蔽了浏览器之间的差异:编辑器只需定义抽象行为,具体细节交给渲染层去处理。
有些意图已经成为约定俗成的交互模式:比如按下 Cmd+B 加粗、Cmd+I 斜体、Cmd+U 下划线。但对于更复杂的自定义元素,又该怎么处理呢?
自定义内联节点(Custom Inline Nodes)
当你在构建富文本编辑器时,很有可能需要处理一些与你业务领域相关的特殊内容。
以我们在 Lokalise 的场景为例,我们需要一个能够处理包含占位符(如 {username} 或 {date})的翻译字符串的编辑器。这些占位符不能被拆分、不能被单独加样式、也不能被部分选中 —— 它们必须作为原子单元(atomic units)存在于文本中。这正是自定义内联节点(custom inline nodes)发挥作用的地方。
不过,为了更通用地说明,我们来看看一个大家更熟悉的例子。
类 Slack 的用户提及(Slack-style Mentions)
以 “@szymon” 这样的 Slack 提及为例。
一开始你可能会想:“这不就是一个带特殊样式的 <span> 吗?” 但事实并非如此 —— 这不仅仅是样式,而是一个有语义和行为的对象。
要让提及功能正确运作,首先需要在编辑器的 schema(结构定义) 中声明它。一个简单的 mention 节点定义可能是这样的:
{
name: 'mention',
inline: true,
atom: true,
attrs: {
id: {},
name: {}
}
}
inline: true表示它可以像文字一样在文本流中出现(类似
<span>)。atom: true是关键属性 —— 它告诉编辑器,这个节点是不可分割的。
光标不能插入其中,也不能只删除一部分。
定义好 schema 后,在文档模型中,一个 mention 的结构可能是:
{
"type": "mention",
"attrs": {
"id": "U123",
"name": "szymon"
}
}
注意,它并不是简单的字符串,比如 attrs.name = "@szymon",而是一个带有类型和属性的结构化数据。这一点小小的区别,彻底改变了编辑器对它的处理方式。
因为它是定义好的原子内联节点,编辑器会自动应用一系列特殊规则:
-
方向键跳过整个节 —— 你无法把光标放在 “@szymon” 中间去修改;
-
删除操作更智能 —— 第一次按退格键选中整个 mention,第二次才真正删除;
-
复制粘贴保持语义 —— 复制到别处时,带的是完整的用户 ID,而不仅是显示文字;
-
可渲染为 React 组件 —— 可以加上头像、悬停卡片、点击交互等功能。
通过自定义内联节点,mention 成为了文档模型中的一等公民。同样的模式也适用于:翻译工具中的占位符(如 {username})、内联媒体、标记、徽章、行内评论…… 任何需要在文本中作为独立单元存在的东西,都可以用这种方式实现。
序列化:在不同格式之间转换(Serialization: Moving Between Formats)
在某个阶段,你的编辑器内部文档模型需要离开浏览器环境 —— 比如保存到数据库、通过 API 发送、或者让用户导出内容。这时就需要序列化(serialization)—— 即在编辑器格式与其他格式之间转换。
大多数编辑器支持多种序列化格式,例如:
editor.getJSON()
// 输出:
{
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'world', marks: [{ type: 'bold' }] }
]
}
]
}
editor.getHTML()
// 输出: "<p>Hello <strong>world</strong></p>"
不同格式的特点:
-
JSON:保留所有信息 —— 包括自定义节点、属性、元数据等,但需要编辑器自行解析回去;
-
HTML:通用且易展示,但语义模糊(例如
<i>和<em>是否等价?)并且粘贴外部内容时容易变脏; -
Markdown:简洁、可读性强,但表达能力有限(部分编辑器可通过扩展支持)。
在实际应用中,很多系统选择存储 HTML,因为这往往是已有的数据格式或接口要求。它的好处是:与具体编辑器无关,可在不同环境或编辑器间复用。但代价是:处理复杂标记或自定义节点时,序列化层的逻辑更复杂,容易出现模糊或不一致的体验。
一个重要的认识是:编辑器的序列化是双向的。
编辑器不仅能输出 HTML,还能解析 HTML,把它重新转化为符合自己 schema 的文档模型。这也是为什么编辑器能 “干净地” 处理从 Word 或 Google Docs 粘贴的内容 —— 它会解析混乱的 HTML,提取语义,再构建出整洁的内部文档结构。
正确集成 React(React Integration Done Right)
一个富文本编辑器其实是一个自包含的状态机(state machine)。如果你试图像 React 受控组件那样去驱动它 —— 比如在每次输入后触发 re-render—— 结果几乎一定会出问题:光标错乱、选区丢失、性能下降、撤销失效、输入法(IME)异常。
为什么?因为 React 想完全控制状态,而编辑器的状态与浏览器的 Selection API 高度耦合,
React 的重渲染机制(reconciliation)无法精确保持光标状态。当你输入一个字符时,React 重新渲染、替换 DOM 节点,浏览器的选区就丢了。
正确做法是:让编辑器自己管理状态,你只需订阅它的更新。
onUpdate: ({ editor }) => {
saveDraftDebounced(editor.getJSON())
}
React 只负责渲染编辑器容器,而不是控制内部内容。可以把它看作一个 视频播放器 或 canvas 元素 —— 它有自己的生命周期,只需暴露命令式(imperative)的 API。
为什么好的编辑器 “感觉快”(Why Good Editors Feel Fast)
现代富文本编辑器之所以性能出色,靠的是以下设计:
-
不可变状态(immutable state)与结构共享(structural sharing):只有发生变化的节点才会创建新对象;
-
最小化 DOM 差异(minimal DOM diff):只更新真正变动的文本节点;
-
批量渲染(batched updates):一次性渲染多个变更;
-
Schema 约束:避免异常结构(例如成千上万个嵌套节点);
-
避免 React 的不必要重渲染。
性能问题往往来源于误用:比如频繁重新初始化编辑器、在编辑器内部嵌入太多 React 组件、或把它放在一个每次输入都会触发重渲染的受控表单中。
只要把编辑器当作一个稳定且有状态的对象来对待,它就能在处理长文档时依然保持流畅。
结语(Closing Thoughts)
富文本编辑看起来简单,真正深入后才发现其中的复杂性。浏览器确实提供了强大的原生能力,但它们过于零散、不一致,无法直接支撑复杂编辑场景。现代编辑器的成功,正是建立在浏览器能力之上,再叠加结构化的语义层与可预测的逻辑层。
当你理解了 文档模型、选区、事务、渲染、自定义节点与序列化 这些核心概念后,整个富文本编辑生态都会变得清晰起来。
无论是 Tiptap、ProseMirror、Lexical 还是其他库,它们其实都是在用不同的方式表达同一套基础思想。
拥有了这样的思维模型,你就能更有信心地选择、集成、扩展富文本编辑器,并确保它在长期演进中依然稳定、可维护。
关于本文
译者:@飘飘
作者:@Szymon Chudy
原文:https://chudy.me/blog/fundamentals-of-rich-text-editors

