富文本编辑器选型对比(ProseMirror / TipTap vs Slate / Lexical / Quill)

返回 🗂 富文本编辑专题 · 相关私有笔记

阅读位置:这既可以是选栈前的决策入口,也可以是读完专题后的横向复盘。

  • 还没选栈:先读本页的“0. 编辑器到底在抽象什么”与“9. 决策建议”;不必预先掌握 ProseMirror 的内部 API。
  • 要理解取舍为什么存在:再回看 ProseMirror 入门PM SchemaPM 核心模型

本篇沿数据模型 → schema/约束 → 扩展机制 → 协同 → 框架耦合 → 上手曲线 → 生态与维护活跃度比较主流方案,最后给出“什么场景选什么”的决策建议。它只解释“为什么这样设计、这样设计带来什么”,不重复各自的内部 API 细节。

0. 先理清「编辑器」到底在抽象什么

所有现代富文本编辑器本质都在解决同一个问题:浏览器原生的 contentEditable 不可靠——它的 DOM 变更、光标、IME、撤销栈都是黑箱且跨浏览器不一致。于是各家都引入一层**自有数据模型(model)**作为「真相源」,再把 model 受控地投影到 DOM:

  • 用户输入 → 拦截/解释 → 转成对 model 的变更 → 重新渲染 DOM。
  • 这样撤销/重做、协同、序列化、校验都在 model 层完成,DOM 只是视图。

各家的根本分歧,就在于这个 model 长什么样、有多严格、由谁来约束。下面所有对比都可以归结到这一点。PM 一侧的「state 不可变、由 Transaction 驱动、View 受控渲染」三段式见 State 与 TransactionView 与 NodeView

1. 数据模型对比(最根本的差异)

方案模型形态可变性直观心智
ProseMirror严格的不可变树(Node / Mark),整数位置寻址不可变,只能经 Transaction 变更「带 schema 校验的文档树」
TipTap同 ProseMirror(它就是 PM 的包装层)同 PM「声明式 Extension 编译成 PM」
Lexical节点图 + 不可变 EditorState,双缓冲不可变,update 内取 getWritable() 克隆「监听器驱动的节点模型」
Slate普通 JS 对象树 {type, children:[{text}]}可变,Transforms 原地改「像 DOM 的可变 JSON」
QuillDelta(operation 数组)+ Parchment(Blot 树)Delta 描述内容与 diff「一串 insert/attributes 操作」

要点说明:

  • ProseMirror:文档是不可变的嵌套树,Node 表块级、Mark 表行内格式;位置用整数偏移寻址。不可变性让它与 React 等框架共存安全,也让 Step / 位置映射(Mapping)这套协同机制成立。细节见 PM 核心模型
  • Lexical:状态不可变且采用双缓冲——一个 frozen 的当前态 + 一个 work-in-progress 的 pending 态,update 期间在 pending 上工作再 reconcile。节点是面向对象的子类(ElementNode / TextNode 等)。
  • Slate:刻意用可变的纯 JS 对象,不依赖 immutable 库,结构镜像 DOM(children 递归)。对 React 开发者最「亲切」,但可变性是双刃剑(见 gotcha)。
  • Quill:文档即 Delta——一个 {ops:[{insert, attributes}]} 的 JSON 数组,既能表达内容也能表达变更(diff)。内存里由 Parchment 的 Blot 树 1:1 映射 DOM。Delta 人类可读、格式无关,但表达上存在歧义(同一段嵌套格式可有多种等价写法)。
// ProseMirror:文档是 schema 约束下的不可变树
import { EditorState } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
import { schema } from 'prosemirror-schema-basic';
 
const state = EditorState.create({ schema });
const view = new EditorView(document.querySelector('#editor'), { state });
// Quill:文档是 Delta(operation 数组)
const quill = new Quill('#editor', { theme: 'snow' });
quill.setContents([
  { insert: 'Gandalf' },
  { insert: ' the ' },
  { insert: 'Grey', attributes: { color: '#cccccc' } },
  { insert: '\n' },
]);
console.log(quill.getContents().ops); // 取回 Delta
// Slate:可变 JSON 模型,经命令式 Transforms 修改
const [editor] = useState(() => withReact(createEditor()));
Transforms.insertText(editor, 'Hello ');
// 选区/位置用 Path 数组定位:at 可接 Path、Point 或 Range
Transforms.insertText(editor, 'world', { at: { path: [0, 0], offset: 6 } });

2. Schema 与约束:谁来保证文档合法

「文档能不能出现非法结构」由这一层决定,直接影响长期可维护性。

  • ProseMirror —— 强 schema(最严格)。通过 new Schema({nodes, marks, topNode?}) 声明式定义:哪些 Node/Mark 允许出现、内容表达式(如 'block+''inline*')规定子节点。非法组合在早期就被拒绝。代价是:内容已存在后再改 schema 会引发解析/序列化问题,必须前期想清楚。约束细节见 PM Schema
  • Lexical —— 无正式 schema。约束散落在节点子类的方法里(如 canInsertAfter())。更 OOP、更灵活,但「文档合法性」靠开发者自律。
  • Slate —— schema-less。核心不带约束,扩展靠 plugin 函数与 normalizeNode 归一化;permissive 但把全部责任压给开发者。
  • Quill —— 介于中间。Parchment 提供标准化的 Blot 接口(松 schema),既不像 PM 那么死板,也比 Slate 有章法。
  • TipTap:沿用 PM 的强 schema,只是把它藏在 Extension 背后。
// ProseMirror 自定义 schema:内容表达式即约束
import { Schema } from 'prosemirror-model';
const mySchema = new Schema({
  nodes: {
    doc: { content: 'block+' },
    paragraph: { content: 'inline*', group: 'block', parseDOM: [{ tag: 'p' }], toDOM: () => ['p', 0] },
    text: { group: 'inline' },
  },
  marks: {
    strong: { parseDOM: [{ tag: 'strong' }], toDOM: () => ['strong', 0] },
  },
});

设计取舍一句话:强 schema 把错误提前到开发期(PM),弱/无 schema 把灵活性留给运行期但把风险留给团队(Slate/Lexical)。

3. 扩展机制:怎么加自定义能力

方案扩展单元心智
ProseMirrorPlugin(state + props:handleDOMEvents/decorations 等)状态机 + 视图钩子,见 [[03 - 前端 Frontend/富文本编辑器 Rich Text Editor/Plugin 系统与 Decoration
TipTapExtension / Node / Mark,声明式包装 PM plugineditor.chain().toggleBold().run() 命令链
Lexical节点子类 + 监听器(无统一 plugin 概念)registerXxxListener 注册回调
Slateplugin 函数(覆写 editor 方法)按注册顺序串联,顺序敏感
QuillParchment Blot + module自定义 Blot 注册进 registry
  • Lexical 的扩展是监听器驱动的:registerUpdateListener()(所有更新)、registerMutationListener(NodeClass, handler)(指定节点的 created/updated/destroyed)、registerTextContentListener()。这种解耦让不需要某能力的用户不必为它付出 bundle 体积。
// Lexical:在 update 内改、在监听器里读
editor.update(() => {
  const root = $getRoot();
  root.append($createParagraphNode());
});
editor.registerUpdateListener(({ editorState }) => {
  editorState.read(() => console.log($getRoot().getTextContent()));
});
 
// 针对特定节点类型的变更监听:mutations 是 Map<NodeKey, NodeMutation>
editor.registerMutationListener(MyCustomNode, (mutations) => {
  for (const [key, mutation] of mutations) {
    if (mutation === 'created') console.log('created', key);
    if (mutation === 'destroyed') console.log('destroyed', key);
  }
});
  • TipTap 的扩展是声明式的:一套 Extension/Node/Mark 被编译成 PM 的 schema + plugins + keymap,既给常见任务以简洁(命令链),又保留下沉到 PM 的口子。编译机制见 TipTap 架构,动手见 TipTap 实战
// TipTap:命令链是它对 PM 命令的人体工学包装
editor.chain().focus().toggleBold().toggleItalic().run();
if (editor.can().setBold()) {
  editor.chain().focus().setBold().run();
}

4. 协同编辑支持:OT 还是 CRDT

这条轴往往是「能不能选它」的硬门槛。

  • ProseMirror —— 原生 OT(中心化)。通过 Step / Transaction 表达原子变更,内置 prosemirror-collab 模块做中心化 rebasing,battle-tested,但依赖一台权威服务器做冲突解决。
  • Yjs(CRDT)—— 外部但事实标准y-prosemirror@lexical/yjs 把编辑器绑定到 CRDT;CRDT 离线优先、可 p2p,扩展性更好,因此被 TipTap、Lexical、Slate 普遍采用。代价:PM 里 decoration 与相对位置追踪较脆弱,嵌套/异步更新要小心。
  • Lexical 明确把协同外包给 @lexical/yjs,核心保持精简。
  • Quill 的 Delta 天生面向 OT,但仍需服务器处理冲突。
// ProseMirror + Yjs(去中心化 CRDT):y-prosemirror 把 Y.XmlFragment 绑到 PM state
import * as Y from 'yjs';
import { ySyncPlugin, yUndoPlugin, undo, redo } from 'y-prosemirror';
import { keymap } from 'prosemirror-keymap';
 
const ydoc = new Y.Doc();
const plugins = [
  ySyncPlugin(ydoc.getXmlFragment('prosemirror')),
  yUndoPlugin(),
  keymap({ 'Mod-z': undo, 'Mod-Shift-z': redo, 'Mod-y': redo }),
];
const state = EditorState.create({ schema, plugins });

选型口径:离线优先 / p2p → CRDT(Yjs);服务器权威、强一致 → OT(PM 内置)。按架构选,而不是按库选。 两条路线的原理与取舍见 协同编辑

5. 框架耦合与 headless 程度

  • 框架无关:ProseMirror、Lexical 核心、Quill 都是 vanilla JS,可跨 web/mobile/server。
  • 有框架绑定:TipTap 提供 @tiptap/react@tiptap/vue@tiptap/svelte——一套 schema 驱动多端 UI;Lexical 的 @lexical/react(LexicalComposer)是可选层,核心仍是 vanilla。
  • 强耦合:Slate 只支持 React,通过 <Slate> context 与 useSlate() 紧绑,可移植性受限。

含义:要多平台,选 ProseMirror/Quill;要框架无关又想要绑定,选 TipTap;只有当项目铁定 React-exclusive 时,Slate 的耦合才不构成问题。Lexical 核心 vanilla、React 层可选,介于两者之间。

6. 上手曲线、生态与可访问性

  • 上手曲线:Quill 最快(开箱即用、treats developers as users);TipTap 较快(命令链/StarterKit);Lexical 中等;Slate 概念简单但「坑多」,复杂结构实践上难做对;ProseMirror 最陡——Step/Transform/Mark 概念多,文档详尽但密。
  • 可访问性(a11y):Lexical 显式以 WCAG 合规与屏幕阅读器兼容为目标(Meta 为消费级亿级用户而建,a11y 是硬需求,且自带原生移动绑定)。PM 处理 contentEditable 基础,a11y 取决于 schema/plugin 选择;Slate 原生 a11y 薄弱,多需自行实现;Quill 取决于所用 module。a11y 实践见 性能与可访问性
  • bundle 体积:Lexical core 主打精简(只引入用到的 node/plugin),TipTap 在 PM 之上做了按需拆分,二者都更利于 code-splitting;ProseMirror、Quill 体积偏大且依赖较多。具体数字随版本与打包配置浮动,以实际构建产物为准,不在此处钉死。

7. 维护活跃度判断(截至 2026)

基于来源的现状(不臆造版本/状态):

  • ProseMirror —— 活跃维护。截至 2025 年仍在更新(如 prosemirror-view 1.40.1,2025-07;prosemirror-keymap 1.2.3,2025-05),核心成熟稳定,长期处于 1.x 系列。
  • TipTap —— 活跃开发。商业生态在扩张(Hocuspocus、TipTap Cloud),协同走 Yjs 或 TipTap Cloud(商业)。
  • Lexical —— Meta 专职团队维护,但截至 2026-06 仍为 0.x,未达 1.0,API 稳定性无保证、部分 plugin 仍在成熟期;生产中有「成长期阵痛」。
  • Slate —— 社区维护。生态较小、工具链有限,Android/CJK 支持弱。
  • Quill —— 成熟但发布节奏偏慢,2.x 已加入 TypeScript。
  • Draft.js —— 基本停更(maintenance mode),Facebook/Meta 已推荐新项目改用 Lexical。新项目不要选 Draft.js,本篇仅作历史参照。

8. 关键坑位速查(影响选型的现实约束)

  • ProseMirror:schema 必须前期设计好,内容存在后再改会引发解析/序列化问题;学习曲线陡。
  • Lexical:节点 reconcile 后被冻结,直接改会静默失败,必须在 editor.update() 内用 getWritable();没有 pure decorations——装饰会改文档,协同光标这类需求要用 HTML overlay 绕过,是已知痛点;未到 1.0。
  • Slate:可变模型若不小心会触发 React 重渲染问题;plugin 按注册顺序运行、相互干扰;Android/CJK 弱(Android 上 beforeInput.preventDefault 不生效)。
  • Quill:Delta 表达力强但有歧义;module 系统耦合紧,换 module(如 History)需懂内部;jsdom 测试困难,推荐 Cypress。
  • TipTap:高级定制仍需 PM plugin 的深入知识;Yjs 协同要手动搭。
  • Yjs in PM:decoration 开销、相对位置追踪脆弱,嵌套/异步更新需谨慎。

9. 决策建议:什么场景选什么(2026)

新项目优先级:

  • React + 看重可访问性 / 规模 / 移动端 → Lexical。代价:接受 0.x、部分 plugin 不成熟、协同光标需 overlay 绕过。
  • 要 ProseMirror 的能力 + 框架灵活性(多框架/未来可换) → TipTap。是「想要 PM 但不想啃内核」团队的务实解,商业协同(Hocuspocus / TipTap Cloud)成熟。
  • 追求简单 + 现成功能 + 快速上线 / 非 React → Quill。代价:定制受限、体积偏大。
  • 需要严格 schema / 复杂版式 / 长期维护 / 最强原生协同(OT) → ProseMirror(裸用或经 TipTap)。代价:学习曲线陡。
  • 避免用 Slate 做新项目(React-only、平台支持弱、协同与移动端短板),除非确为「纯 React + 简单 schema 的 greenfield 编辑器」。
  • 完全避免 Draft.js(已弃用)。

正交维度提醒:

  • 协同按架构选:离线优先/p2p 用 CRDT(Yjs);服务器权威用 OT。
  • 多平台:ProseMirror/Quill;框架无关 + 绑定:TipTap;Slate 仅当 React-exclusive;Lexical 核心 vanilla、React 层可选。
  • 本专题的栈选择:深入内核 + 强 schema + 可控协同,本仓库聚焦 ProseMirror / TipTap 一线,其余方案作为对比坐标系存在。

参考