富文本编辑器
概述
富文本编辑器是 Web 应用中不可或缺的交互组件,它让用户能够直观地编辑和格式化内容,而无需掌握 Markdown 或 HTML 语法。在前端生态中,Tiptap、Quill、Slate、ProseMirror 是四款主流的富文本编辑器库,各自拥有不同的设计哲学和架构特点。本文将深入对比这四大编辑器的架构设计、扩展机制与性能表现。
一、四大编辑器架构对比
1.1 设计哲学
ProseMirror — Schema 驱动
ProseMirror 由 CodeMirror 的作者 Marijn Haverbeke 开发,采用严格的 Schema 驱动 架构。在 ProseMirror 中,文档结构由 Schema 明确定义——哪些节点类型(paragraph、heading 等)是允许的,它们如何嵌套,哪些内联标记(bold、italic 等)可以应用。这种设计使得文档结构始终合法,不会产生无效的嵌套或格式。
import { Schema } from 'prosemirror-model'
const mySchema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { content: 'inline*', group: 'block' },
heading: { content: 'inline*', group: 'block', attrs: { level: { default: 1 } } },
text: { group: 'inline' },
},
marks: {
strong: {},
em: {},
},
})Quill — 数据驱动
Quill 采用 数据驱动 的设计理念,以 Parchment 文档模型和 Delta 数据格式为核心。Delta 是一种描述文档内容和变化的 JSON 格式,每一处编辑操作都会被序列化为一个 Delta 对象。这使得撤销/重做、协作编辑、历史记录等功能天然就基于数据变更实现。
// Delta 格式示例
const delta = {
ops: [
{ insert: 'Hello ' },
{ insert: 'World', attributes: { bold: true } },
{ insert: '\n' },
],
}Tiptap — 插件化
Tiptap 基于 ProseMirror 构建,但提供了更友好的 插件化 API。Tiptap 将每一个编辑器功能封装为独立的 Extension(扩展),通过组合不同的扩展来组装编辑器。从基础的输入框样式到复杂的表格、图片上传,每个功能都是一个可插拔的 npm 包。
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Table from '@tiptap/extension-table'
const editor = new Editor({
extensions: [
StarterKit,
Image,
Link,
Table,
],
})Slate — React 状态驱动
Slate 是 React 状态驱动 的富文本编辑器框架。它将编辑器内容视为普通的 React State(JSON 对象),渲染完全由 React 控制。Slate 不强制任何数据模型,开发者可以自由定义文档结构,灵活性极高。它的核心理念是"编辑器内容就是数据,数据就是编辑器内容"。
import { createEditor } from 'slate'
import { Slate, Editable, withReact } from 'slate-react'
const Editor = () => {
const [editor] = useState(() => withReact(createEditor()))
const [value, setValue] = useState([
{ type: 'paragraph', children: [{ text: 'Hello World' }] },
])
return (
<Slate editor={editor} value={value} onChange={setValue}>
<Editable />
</Slate>
)
}1.2 对比总览
| 维度 | ProseMirror | Quill | Tiptap | Slate |
|---|---|---|---|---|
| 设计哲学 | Schema 驱动 | 数据驱动(Delta) | 插件化(基于 PM) | React 状态驱动 |
| 文档模型 | Schema 定义 | Parchment / Delta | ProseMirror Schema | 自定义 JSON |
| 扩展机制 | Plugin + Node/Mark | 模块 + 自定义格式 | Extension 类 | 自定义组件 |
| 框架依赖 | 无 | 无 | 无 | React |
| 学习曲线 | 陡峭 | 中等 | 低-中 | 中-高 |
| 包体积 (min) | ~300KB+ | ~125KB | ~200KB+ | ~150KB |
| 协作编辑 | 内置支持 | 需插件 | 需插件 | 需插件 |
| TypeScript | 支持 | 支持 | 原生支持 | 原生支持 |
二、扩展机制详解
2.1 ProseMirror Plugin 系统
ProseMirror 的扩展通过 Plugin 实现。插件可以注入新的状态、修改行为、添加命令或处理事件。每个插件通过 PluginKey 唯一标识,状态存储在 EditorState 中。
import { Plugin, PluginKey } from 'prosemirror-state'
const counterKey = new PluginKey('word-count')
const wordCountPlugin = new Plugin({
key: counterKey,
state: {
init() { return { words: 0, chars: 0 } },
apply(tr, value) {
const doc = tr.doc
const text = doc.textBetween(0, doc.content.size, ' ', ' ')
return { words: text.split(/\s+/).filter(Boolean).length, chars: text.length }
},
},
})2.2 Quill 模块与自定义格式
Quill 的扩展分为两种:
- 模块(Module):扩展编辑器的功能行为,如工具栏、悬浮提示、键盘快捷键
- 格式(Format):定义新的内容类型,可以覆盖渲染方式
// 自定义模块示例
class CharacterCount {
constructor(quill, options) {
this.quill = quill
this.container = document.querySelector(options.container)
quill.on('text-change', () => {
const text = quill.getText().trim()
this.container.textContent = `${text.length} 个字符`
})
}
}
Quill.register('modules/characterCount', CharacterCount)2.3 Tiptap Extension 体系
Tiptap 的 Extension 是对 ProseMirror Plugin 的高层封装,提供了更声明式的 API:
import { Extension } from '@tiptap/core'
const CustomExtension = Extension.create({
name: 'customExtension',
addOptions() {
return { threshold: 10 }
},
addCommands() {
return {
customAction: (value) => ({ commands }) => commands.insertContent(value),
}
},
addKeyboardShortcuts() {
return {
'Mod-Shift-c': () => this.editor.commands.customAction('★'),
}
},
})2.4 Slate 自定义组件
Slate 的扩展通过 React 组件 实现。自定义元素类型需要定义渲染组件和对应的数据转换逻辑:
const CodeElement = (props) => {
return (
<pre {...props.attributes}>
<code>{props.children}</code>
</pre>
)
}
const renderElement = (props) => {
switch (props.element.type) {
case 'code': return <CodeElement {...props} />
case 'heading': return <h2 {...props.attributes}>{props.children}</h2>
default: return <p {...props.attributes}>{props.children}</p>
}
}三、协作编辑支持
3.1 协作编辑的挑战
协作编辑的核心挑战是 冲突解决 和 一致性维护。常用的技术有两种:
- OT(Operational Transform):对每个操作进行转换,使得并发操作在不同客户端应用后得到相同结果
- CRDT(Conflict-Free Replicated Data Types):通过数据结构设计保证操作可交换、可合并
3.2 各编辑器的协作方案
ProseMirror 内置了 Collaborative Editing 模块,基于 OT 算法,通过 Collaborate Plugin 实现。配合 prosemirror-collab 包,可以快速搭建协作编辑服务端。
Quill 本身不内置协作功能,但可以通过 ShareDB 配合 Quill Delta 格式实现 OT 协作。由于 Quill 的数据模型基于 Delta,天然适合 OT 转换。
Tiptap 内置了 @tiptap/extension-collaboration 和 @tiptap/extension-collaboration-cursor,基于 Y.js(一种高性能 CRDT 库)实现协作编辑,开箱即用。
Slate 需要结合 Y.js 或 Liveblocks 等第三方库来实现协作。Slate 的 JSON 数据模型可以序列化后由 CRDT 库管理。
| 编辑器 | 协作方式 | 实现复杂度 | 推荐方案 |
|---|---|---|---|
| ProseMirror | 内置 OT | 中 | prosemirror-collab |
| Quill | Delta + OT | 高 | ShareDB + Quill |
| Tiptap | Y.js CRDT | 低 | @tiptap/collaboration |
| Slate | Y.js CRDT | 中 | slate-yjs |
四、性能对比
4.1 基准测试指标
在大文档场景下,各编辑器的性能表现差异明显:
- 初次渲染时间:Quill > Tiptap ≈ ProseMirror > Slate(Slate 因 React 渲染开销最慢)
- 输入延迟:ProseMirror ≈ Quill > Tiptap > Slate
- 大文档(10万+ 字符):ProseMirror > Quill > Tiptap > Slate
- 内存占用:Slate(最低)> Quill > Tiptap > ProseMirror(最高)
4.2 选择建议
- 内容型应用(博客、CMS、文档):推荐 Tiptap 或 ProseMirror,Schema 保障内容质量
- 工具型应用(邮件编辑器、评论区):推荐 Quill,轻量、API 简洁
- 定制化编辑器(自定义格式复杂):推荐 Slate,最大的灵活性
- 需要协作编辑:推荐 Tiptap,Y.js 支持最完善
五、自定义插件开发流程
以 Tiptap 为例,开发一个"字数统计"自定义扩展:
5.1 定义 Extension
import { Extension } from '@tiptap/core'
const WordCount = Extension.create({
name: 'wordCount',
addStorage() {
return { words: 0, chars: 0 }
},
onUpdate() {
const text = this.editor.state.doc.textContent
this.storage.words = text.split(/\s+/).filter(Boolean).length
this.storage.chars = text.length
this.editor.emit('wordCountUpdate', this.storage)
},
})5.2 注入编辑器
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
const editor = new Editor({
element: document.querySelector('#editor'),
extensions: [StarterKit, WordCount],
})
editor.on('wordCountUpdate', ({ words, chars }) => {
document.querySelector('#word-count').textContent = `${words} 词 / ${chars} 字`
})5.3 ProseMirror Plugin 方式
如果直接在 ProseMirror 层面开发,需要实现 Plugin 的 state 管理和 view 更新:
const wordCountPlugin = new Plugin({
view(editorView) {
const counter = document.createElement('div')
counter.className = 'word-count'
editorView.dom.parentNode.appendChild(counter)
return {
update(view) {
const text = view.state.doc.textContent
counter.textContent = `${text.split(/\s+/).filter(Boolean).length} 词`
},
destroy() { counter.remove() },
}
},
})六、选型决策指南
| 项目类型 | 推荐编辑器 | 理由 |
|---|---|---|
| 内容管理系统 | Tiptap / ProseMirror | Schema 确保内容结构一致 |
| 在线协作文档 | Tiptap | Y.js 内置协作支持 |
| 评论/表单编辑器 | Quill | 轻量、快速、易于集成 |
| 代码编辑器集成 | ProseMirror | 底层架构清晰,定制性强 |
| React 项目自定义编辑器 | Slate | 自然融入 React 生态 |
| 企业内部系统 | Tiptap | 生态丰富,文档完善 |
无论选择哪种方案,关键在于理解其设计哲学。Schema 驱动的内容更严谨但开发成本高,数据驱动的编辑更灵活但需要良好的 Delta 管理,插件化的架构扩展性强但需要注意性能,状态驱动的方案最灵活但要求团队有较强的 React 能力。
七、交互演示
以下 Demo 展示了四种编辑器的同屏对比。Quill 为真实加载运行,其余编辑器为结构模拟展示: