【翻译】富文本编辑器基础

5 阅读13分钟

原文链接:playfulprogramming.com/posts/funda…

作者:Szymon Chudy

前言

富文本编辑器无处不在:博客和CMS控制面板、文档工具、聊天窗口——不胜枚举。我们时刻与它们互动,然而大多数前端工程师直到尝试实现最基础的富文本功能时,才惊觉其复杂程度。

看似简单的问题——让用户输入、格式化文本并粘贴内容——却演变成各种边界情况、光标异常、DOM输出不一致以及浏览器怪癖的集合体。

好消息是,无论选用何种库,所有现代编辑器都基于相同的核心理念构建。掌握这些理念后,你便能理解所有编辑器的工作原理:无论是Tiptap、ProseMirror、Lexical、Slate、CKEditor还是其他工具。

本文旨在为你奠定基础,构建我初涉富文本编辑器时就渴望拥有的思维模型。

所见即所得(WYSIWYG)

WYSIWYG通常与RTE富文本编辑器)并列出现。根据维基百科解释:

**所见即所得(WYSIWYG)**是一种软件,它允许用户以类似最终打印或显示效果的形式编辑内容。

其承诺简单明了:编辑时所见即为读者所见。粗体文字呈现粗体效果,标题字号放大,列表带项目符号,图片视频内嵌显示,用户提及如@szymon可点击互动。

这种设计直观易用,因为它复刻了我们通过文字处理器形成的思维模式。无需标记语言,无需预览模式,只需直接操作内容——不仅包含格式设置,还可嵌入多媒体和定制交互元素。

然而,在网页平台上构建类似体验绝非易事。让我们先看看浏览器提供的基础功能。

为何浏览器远远不够

一切始于浏览器的一项功能。

<div contenteditable="true">Hello!</div>

contenteditable 允许用户直接在 DOM 中输入内容。浏览器负责处理光标移动、文本选择、基础格式化命令及粘贴操作。对于快速原型开发而言,这几乎堪称神奇——相当于"免费"获得了一个文本编辑器。

但当你试图基于它构建真实产品时,问题便显现出来。

从谷歌文档粘贴一段文字,你会得到数十个嵌套的span标签,附带与内容无关的内联样式和类名。尝试删除部分加粗的单词——根据浏览器不同,结果可能是两个独立的文本节点、样式不一致的合并节点,或是悬空的空格式标签。反复点击撤销按钮时,你会发现Chrome和Firefox的恢复逻辑截然不同。

根本症结何在?DOM并非内容的稳定语义化呈现。它过于宽容且缺乏规范——浏览器几乎能接受任何HTML结构,即便这些结构毫无语义意义。段落可嵌套段落,粗体标签可能只包裹半个单词,导致文本节点支离破碎。在缺乏约束的情况下,每次用户交互都会使DOM结构漂移至不可预测的领域。

正因如此,现代编辑器通过让浏览器渲染文本而非定义其含义来实现功能。

编辑器的真实数据源

通常编辑器会用自身的内部表示替换DOM——即描述内容结构的文档模型。

:此处文档模型指编辑器的内容结构,而非浏览器的文档对象模型(DOM)。术语略显混淆,但区分二者至关重要。

从宏观层面看,其结构大致如下:

doc
├─ paragraph
│    ├─ text("Hello ", [])
│    └─ text("world", ["bold"])
├─ mention(id="U123", name="szymon")
└─ paragraph
     └─ text("Another paragraph", [])

该模型包含:

  • 节点 - 段落、标题、列表、图片、嵌入内容
  • 内联节点 - 文本、链接、提及、占位符
  • 标记 - 粗体、斜体、下划线
  • 属性 - 网址或ID等元数据
  • 结构规则 - 规定节点在何处出现

结构规则确保整体逻辑一致性。列表仅可包含列表项,列表项可包含段落或嵌套列表,但不可包含标题。这些约束可防止结构错误的形成。核心要点在于:

文档模型是真实数据的来源,DOM 仅是其投影。

选择模型

浏览器的选择API基于DOM节点和偏移量:"第二个div内部第三个文本节点中的偏移量5"。但当React重新渲染并替换该div时会发生什么?光标位置会跳跃,用户因此感到困惑。

而富文本编辑器采用语义化选择追踪机制。编辑器不会说"光标位于特定DOM文本节点偏移量5处",而是说"光标位于第二段落第23位"。当React重新渲染并替换DOM节点时,编辑器能通过文档结构精确重建光标位置——它知道第23位始终是第23位,即使底层DOM节点已被替换。

编辑器追踪三类选区:

  • 光标——单点位置的折叠范围
  • 文本范围——跨越多个字符或节点的选区
  • 节点选区——定位整个元素(如图片)

最后一种对自定义节点至关重要。例如点击 Slack 提及时,整个 @szymon 作为整体被选中。你无法将光标置于其中进行逐字符编辑。

事务

编辑器如何改变状态

在现代编辑器中输入内容不会直接修改 DOM。相反,编辑器将每次更改都表示为事务(有时称为操作)。例如:

// typing "X" becomes
insertText("X", position)

// pressing Backspace becomes
deleteRange(from, to)

// applying bold becomes
addMark("bold", from, to)

// inserting a mention becomes
insertNode({ type: "mention", attrs… })

每笔事务包含两项内容:文档模型的变更与选区范围的变更。插入X后,光标需向前移动一位;插入提及内容后,光标应出现在提及内容之后。这种方法使一切变得更完善:

  • 撤销/重做变得简单——只需存储每笔事务的逆操作
  • 更新具有确定性——相同输入始终产生相同输出
  • 协作编辑可行——其他用户的修改可作为事务回放
  • 渲染可预测——编辑器始终精确知晓变更范围

若你熟悉React状态管理模式...

不妨将事务视为微型显式reducer,用于描述发生的变化。

渲染

当编辑器状态发生变更时,编辑器会更新DOM以匹配文档模型。但编辑器不会重新渲染所有内容,而是计算所需的最小修改集。

输入字符时,编辑器仅定位需要更新的文本节点并修改该节点;应用加粗效果时,仅包裹目标单词。删除段落时,仅移除该段落的DOM节点。这至关重要,因为:

  • DOM操作开销巨大
  • 粗放式更新会破坏光标定位或引发视觉跳跃
  • 浏览器需要辅助机制来维持更新过程中的选区状态

这正是无法直接用React处理所有场景的原因。

React的协调机制并未针对可编辑区域内的光标保留更新进行优化。编辑器需要对变更内容和时机进行精细化控制。

解读用户意图

当用户与所见即所得编辑器交互时,他们表达的是意图,而非执行原始DOM操作。编辑器将这种意图转化为事务。

有Notion爱好者吗?问这个问题是因为我认为他们在解读用户意图方面做得比任何人都出色。

当你在行首输入#(注意井号后需空格)时,文本瞬间转化为标题。这就是输入规则。编辑器实时监测输入内容并识别模式。当它理解你的意图后,会删除这些字符并将文本块转换为标题。

或者在列表中按下Enter键。浏览器默认会插入换行符。但你其实想创建新列表项。编辑器会拦截按键操作,根据当前文本块类型执行对应指令:若在空列表项末尾?则退出列表结构;若在列表项中间?则智能拆分内容。

这类操作选项几乎无穷无尽。

@ 键可调出自动补全面板,/ 键可访问命令行。在原子节点附近按Backspace时,系统会先选中该节点再执行删除操作。这些行为均基于文档模型和用户意图定义,而非原始DOM操作。功能列表可以无限延伸。

这一层才是编辑器真正成为产品的所在。

它也是抽象掉浏览器差异性的层级。编辑器不再针对Chrome、Firefox和Safari分别处理,而是基于文档模型定义行为,让渲染层处理具体细节。


某些意图似乎已成为成熟的用户体验模式。我们预期按下 Cmd+B 时文字会加粗,按下 Cmd+I 时变为斜体,按下 Cmd+U 时出现下划线。但对于更专业的元素呢?

自定义内联节点

若涉及构建富文本编辑器,极有可能需要开发特定领域的功能。

在Lokalise,这意味着要构建能处理含{username}{date}等占位符的翻译字符串编辑器。这些占位符不可拆分、不可样式化、不可部分选中,必须作为文本中的原子单元存在——这正是自定义内联节点发挥作用之处。

不过本文讨论将聚焦更通用的场景。

Slack风格的提及功能

让我们来聊聊类似@szymon的Slack风格提及功能。

乍看之下,你可能会认为:只需用特殊类包裹文本即可。但关键在于:这不仅是样式化的文本,而是具有语义和行为的对象。要使提及功能正常运作,首先需要在编辑器的架构中定义它们。提及节点定义可能如下所示:

{
  name: 'mention',
  inline: true,
  atom: true,
  attrs: {
    id: {},
    name: {}
  }
}

inline: true 表示该元素在文本中内联显示(类似于粗体或普通span标签)。关键在于 atom: true 属性——它告知编辑器该节点不可分割。用户无法在其中插入光标或部分删除内容。

在模式中定义后,文档模型中的提及将呈现如下形式:

{
  "type": "mention",
  "attrs": {
    "id": "U123",
    "name": "szymon"
  }
}

请注意,它并非像 attrs.name = '@szymon' 这样的字符串。这是具有类型和属性的结构化数据。这个看似细微的区别彻底改变了编辑器对其的处理方式。

由于其被定义为原子内联节点,编辑器会强制执行特殊规则:

  • 方向键会完全跳过该元素——你无法将光标意外置于@szymon中间并将其改为@szmon
  • 删除操作干净利落——首次Backspace选中整个提及,第二次Backspace直接删除
  • 复制粘贴完整保留数据——将提及复制到其他消息时,会携带用户ID而非仅显示文本
  • 可作为React组件渲染——显示头像、添加悬停卡片、实现可点击功能等任意需求

通过自定义内联节点,提及元素成为文档模型中的第一类公民。该模式同样适用于翻译工具中的占位符(如Lokalise的{username}示例)、内联媒体、标记、徽章、内联评论——任何需要在流动文本中作为独立单元呈现的内容。

序列化:格式转换

在编辑器的文档模型需要离开浏览器时,序列化便派上用场——它负责在编辑器格式与其他格式之间进行转换。这种转换可能发生在保存至数据库、通过API传输内容或允许用户导出作品等场景。大多数编辑器都支持多种序列化格式:

editor.getJSON()
// Returns:
// {
//     type: 'doc',
//     content: [
//         {
//             type: 'paragraph',
//             content: [
//                 { type: 'text', text: 'Hello ' },
//                 { type: 'text', text: 'world', marks: [{ type: 'bold' }] }
//             ]
//         }
//     ]
// }
editor.getHTML()
// Returns: "<p>Hello <strong>world</strong></p>"

JSON能完整保留所有内容——自定义节点、属性、元数据——但需要编辑器重新解析。HTML具有普适性且易于显示,但存在歧义(<i><em>是否相同?)且从外部粘贴时易显杂乱。Markdown(部分编辑器通过扩展支持)简洁易读,但表达能力有限。

实际应用中,多数程序选择存储HTML,因其既是现有数据格式,也符合API预期。这种方式还能使内容独立于编辑器实现,支持编辑器切换或跨场景渲染。但这将复杂性转嫁至客户端序列化层,当遇到模糊标记或自定义节点类型时,可能导致用户体验恶化。

关键洞见在于编辑器的序列化是双向的:编辑器能将HTML解析回文档模型,进行清理并根据模式规范化处理。这正是编辑器处理Word或Google Docs粘贴内容的方式——解析混乱的HTML,提取语义信息,最终重建为规范的文档结构。

正确实现 React 集成

富文本编辑器本质上是一个自包含的状态机。若将其作为受控 React 组件驱动,将导致所有功能失效:光标移动、选区操作、性能表现、撤销历史记录以及输入法组合功能。

原因何在?React 要求独占状态管理权,并在状态变更时触发重渲染。但编辑器的状态与浏览器的选区 API 紧密耦合,二者无法与 React 的状态协调机制良好配合。当用户输入字符时,React 重渲染可能重建 DOM 节点,导致浏览器丢失选区标记。

正确做法是让编辑器自主管理状态。通过订阅更新而非在 React 中镜像状态来实现。

onUpdate: ({ editor }) => {
  saveDraftDebounced(editor.getJSON())
}

React 应当渲染编辑器容器,而非管理其内部内容。将其视为视频播放器或画布元素——拥有独立生命周期并暴露命令式 API 的组件。

优质编辑器为何流畅

现代编辑器通过以下方式保持高效:

  • 结构化共享的不可变状态(仅变更节点生成新对象)
  • 最小化 DOM 差异(仅更新变更的文本节点)
  • 批量更新(多次变更一次渲染)
  • 模式强制(防止数千层嵌套节点等退化情况)
  • 避免不必要的 React 重渲染

多数性能问题源于误用:过频重置编辑器状态、在编辑器内渲染过多 React 组件,或将其置于每次按键就重渲染的受控表单中。

将编辑器视为稳定的有状态实体,即使处理长文档也能保持性能稳定。

结语

富文本编辑看似简单,深入研究后才发现其复杂性。浏览器提供了强大的工具,但它们过于不稳定,无法直接构建其上。现代编辑器的成功之道,在于为浏览器的基础功能叠加结构与可预测性。

当你理解文档模型、选区机制、事务处理、渲染逻辑、自定义节点与序列化原理后,整个生态系统便豁然开朗。无论是Tiptap、ProseMirror、Lexical还是其他库,本质上都是对这些核心理念的不同诠释。

掌握这种思维模型后,你就能自信评估各类库,无缝集成至React生态,并以持久稳定的方式进行扩展。