从一次选型翻车说起
今年做个管理系统,需要富文本编辑器来编辑赛事规则。第一反应是上 Quill,API 简单,文档齐全,半小时就能跑起来。
结果用了两天就撑不住了。需求是这样的:评委需要给选手的答题内容打分,答题内容是富文本,评分时需要高亮特定段落、插入批注、标记颜色。Quill 的数据模型是扁平的 Delta 格式,处理嵌套结构和自定义节点非常痛苦。
后来换成了 TipTap。不是因为 TipTap 的文档多好看(其实文档挺一般的),而是因为它的底层是 ProseMirror------一个真正的文档模型驱动框架。
这篇文章不讲"TipTap 怎么用"(这种文章掘金一搜一大把),而是从 ProseMirror 的架构层面,讲清楚 TipTap 的本质,以及为什么它的 API 设计成这样。
一、ProseMirror:被低估的底层引擎
1.1 大多数人对 TipTap 的误解
很多开发者把 TipTap 当成一个"富文本编辑器组件",类似 Quill 或 TinyMCE。用的时候照着文档配几个扩展,绑定一下 v-model,完事。
但 TipTap 官方对自己的定位是 Headless Editor Framework------无头编辑器框架。关键词不是"编辑器",是"框架"。
打个比方:
- Quill 是一辆成品车,方向盘、座椅、空调都装好了,你只能换颜色和贴纸
- TipTap 是一个底盘 + 发动机,没有车身、没有座椅,你得自己装
TipTap 本身不提供任何 UI,连工具栏都没有。它只提供编辑器内核和一套扩展机制。所有的按钮、下拉菜单、样式,都需要你自己写。
这听起来很麻烦,但换来的自由度是 Quill 给不了的。
1.2 ProseMirror 的三个核心抽象
TipTap 的强大来自 ProseMirror。要理解 TipTap,必须先理解 ProseMirror 的三个核心概念:
Document(文档):不是 HTML 字符串,而是一棵树。
arduino
doc
├── paragraph
│ └── text "Hello "
│ └── mark: bold
├── heading { level: 2 }
│ └── text "World"
每个节点都有 type、attrs(属性)、content(子节点)、marks(标记)。这棵树是不可变的(Immutable),任何修改都会产生新的节点。
Schema(模式):定义文档的结构规则。
typescript
// 简化示意,非真实代码
const schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
heading: { group: 'block', content: 'inline*', attrs: { level: { default: 1 } } },
text: { group: 'inline' }
},
marks: {
bold: {},
italic: {}
}
})
Schema 规定了哪些节点可以嵌套在哪里、每个节点有哪些属性。这是 ProseMirror 区别于其他编辑器的根本------它不是用正则或 HTML parser 来解析内容,而是用 Schema 来约束和验证文档结构。
Transaction(事务):文档的变更记录。
css
状态 A → apply(transaction) → 状态 B
每次编辑操作(输入文字、加粗、删除)都会生成一个 Transaction,描述"在什么位置做了什么修改"。Transaction 是不可变的,可以被撤销和重做。
这就是为什么 TipTap 的命令是链式的:
typescript
editor.chain().focus().toggleBold().run()
chain() 创建一个 Transaction 构建器,每个命令向 Transaction 添加操作,run() 最终把 Transaction 应用到文档上。这不是为了 API 好看,而是 ProseMirror 的事务模型决定的。
1.3 为什么 Delta 格式不行
对比一下 Quill 的 Delta 格式:
json
{
"ops": [
{ "insert": "Hello " },
{ "insert": "World", "attributes": { "bold": true } }
]
}
Delta 是一个扁平的 ops 数组。它没有树的嵌套概念,无法表达"一个表格单元格里有一个列表,列表项里有一个图片"这种结构。
ProseMirror 的文档是树,天然支持嵌套。这就是我们换掉 Quill 的根本原因------竞赛系统里的评分批注需要嵌套在段落内部,Delta 格式表达不了。
二、TipTap 的 Extension 机制:对 ProseMirror Plugin 的封装
2.1 Extension 的本质
TipTap 的 Extension 不是什么新发明,它是对 ProseMirror Plugin 系统的封装和简化。
ProseMirror 的 Plugin 是这样的:
typescript
// ProseMirror 原生写法
const myPlugin = new Plugin({
state: {
init() { return ... },
apply(tr, value) { return ... }
},
props: {
decorations(state) { return ... },
handleKeyDown(view, event) { return ... }
}
})
写起来很底层,学习曲线陡。TipTap 用 Extension 把它包了一层:
typescript
// TipTap 写法
const ColorHighlighter = Extension.create({
name: 'colorHighlighter',
addProseMirrorPlugins() {
return [
new Plugin({
// 同样的 ProseMirror Plugin
})
]
}
})
Extension.create 本质上是一个配置对象的工厂函数。TipTap 在内部把它注册到 ProseMirror 的 Plugin 系统里。
2.2 StarterKit 里装了什么
typescript
import StarterKit from '@tiptap/starter-kit'
const editor = useEditor({
extensions: [StarterKit],
})
一行代码引入了 StarterKit,看起来很简洁。但你知道它里面装了多少东西吗?
bash
StarterKit
├── Document # 文档根节点
├── Paragraph # 段落
├── Text # 文本节点
├── Bold # 加粗 mark
├── Italic # 斜体 mark
├── Strike # 删除线 mark
├── Code # 行内代码 mark
├── Heading # 标题节点
├── BulletList # 无序列表
├── OrderedList # 有序列表
├── ListItem # 列表项
├── Blockquote # 引用块
├── CodeBlock # 代码块
├── HardBreak # 换行
├── HorizontalRule # 分割线
├── Dropcursor # 拖拽光标
├── Gapcursor # 间隙光标
└── History # 撤销/重做
18 个扩展。这就是为什么你只引入了一个 StarterKit,就什么功能都有了。
问题在于:你真的需要全部吗?如果你的编辑器不需要引用块、不需要分割线,这些扩展的代码还是会被打包进去。
TipTap 支持按需引入,但很多人偷懒直接用 StarterKit。这也是为什么 TipTap 项目的 bundle 体积经常偏大。
2.3 自定义扩展的真实结构
项目里有一个自定义的 ColorHighlighter 扩展:
typescript
import { Extension } from '@tiptap/core'
export const ColorHighlighter = Extension.create({
name: 'colorHighlighter',
// ...
})
一个完整的 Extension 可以包含以下部分:
typescript
Extension.create({
name: 'myExtension',
// 1. 配置项
addOptions() {
return { ... }
},
// 2. 全局属性
addGlobalAttributes() {
return { ... }
},
// 3. 注册 ProseMirror 插件
addProseMirrorPlugins() {
return [ ... ]
},
// 4. 定义命令
addCommands() {
return {
myCommand: () => ({ commands }) => { ... }
}
},
// 5. 定义快捷键
addKeyboardShortcuts() {
return {
'Mod-b': () => this.editor.commands.toggleBold()
}
},
// 6. 定义输入规则(输入特定字符触发)
addInputRules() {
return [ ... ]
},
// 7. 生命周期
onCreate() { ... },
onUpdate() { ... },
onDestroy() { ... },
})
这不是简单的配置对象,而是一个完整的扩展生命周期系统。每个方法都在特定的时机被 TipTap 调用。
三、useEditor 的异步陷阱
3.1 editor.value 为什么是 null
typescript
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello</p>',
})
// 此时 editor.value 是 null!
console.log(editor.value) // null
这是 TipTap Vue 3 集成中最常见的坑。useEditor 返回的是一个 ShallowRef<Editor | null>,初始值是 null。
为什么?因为 ProseMirror 的 Editor 实例创建需要在 DOM 挂载之后(它需要绑定到一个真实的 DOM 节点上)。Vue 3 的 setup 函数执行时,DOM 还不存在。
TipTap 内部的处理方式是在 onMounted 钩子里创建 Editor 实例:
typescript
// TipTap 源码简化逻辑
onMounted(() => {
editor.value = new Editor({
element: editorElement.value,
extensions: extensions.value,
content: content.value,
})
})
所以你的代码必须在 editor.value 有值之后才能调用命令:
typescript
// 错误:setup 阶段直接调用
editor.value.chain().focus().toggleBold().run() // TypeError: Cannot read property 'chain' of null
// 正确:在事件回调中调用
const toggleBold = () => {
editor.value?.chain().focus().toggleBold().run()
}
3.2 v-model 的循环更新陷阱
typescript
const editor = useEditor({
content: props.modelValue || '',
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
}
})
watch(() => props.modelValue, (newValue) => {
if (editor.value && newValue !== editor.value.getHTML()) {
editor.value.commands.setContent(newValue || '')
}
})
这段代码实现 v-model 双向绑定,看起来没问题。但如果没有 newValue !== editor.value.getHTML() 这个判断,就会陷入死循环:
scss
用户输入 → onUpdate 触发 → emit('update:modelValue')
→ 父组件更新 modelValue → watch 触发 → setContent
→ onUpdate 又触发 → emit 又触发 → ...
关键在于 setContent 是否会触发 onUpdate。
翻 TipTap 源码,setContent 方法的签名是:
typescript
commands.setContent(content: Content, emitUpdate?: boolean = false, parseOptions?: ParseOptions)
第二个参数 emitUpdate 默认是 false。也就是说,setContent 默认不会触发 onUpdate。
这意味着即使没有那个判断,死循环也不会发生。但加上判断是更安全的做法,因为:
- 如果将来 TipTap 修改了默认值
- 如果有其他扩展在
setContent时触发了更新 - 防止不必要的
setContent调用(性能优化)
3.3 watch 里的 setContent 会导致光标丢失
这是实际踩到的坑。用户正在编辑器里打字,外部数据变化触发了 watch,setContent 把整个内容重新设置了,光标跳到了文档开头。
用户体验:打字打到一半,光标突然跳走,心态爆炸。
解决方案是只在内容确实不同时才 setContent,而且要用更精细的比较:
typescript
watch(() => props.modelValue, (newValue) => {
if (!editor.value) return
const currentHTML = editor.value.getHTML()
if (newValue === currentHTML) return
// 保存当前光标位置
const selection = editor.value.state.selection
editor.value.commands.setContent(newValue || '')
// 尝试恢复光标位置
editor.value.commands.setTextSelection(selection.from)
})
不过光标恢复也不是完美的,因为 setContent 后文档结构可能变了,原来的位置可能不存在了。最佳实践是:用户正在编辑时,不要从外部 setContent。
四、chain 命令的执行原理
4.1 为什么是 chain + run
typescript
editor.chain().focus().toggleBold().run()
为什么不直接写 editor.toggleBold()?
因为 ProseMirror 的文档是不可变的。每次修改都会创建新的文档状态。如果每个命令都立即应用,多个连续操作会产生多个 Transaction,每个都触发一次视图更新------性能很差。
chain() 的作用是创建一个命令队列,所有命令都作用于同一个 Transaction:
typescript
// TipTap 源码简化
chain() {
const commands = []
const chained = {
focus() { commands.push(focusCommand); return chained },
toggleBold() { commands.push(boldCommand); return chained },
run() {
// 创建一个 transaction
let tr = state.tr
// 依次执行所有命令,作用于同一个 tr
commands.forEach(cmd => cmd(tr))
// 一次性应用
view.dispatch(tr)
}
}
return chained
}
一次 dispatch,一次视图更新。这就是 chain 存在的意义。
4.2 focus 的时序
typescript
editor.chain().focus().toggleBold().run()
focus() 通常是第一个调用。为什么?
因为 ProseMirror 的命令大多依赖当前选区 。如果编辑器没有焦点,选区信息是空的,toggleBold 不知道要给哪段文字加粗。
focus() 确保编辑器获得焦点和有效的选区,后续命令才能正确执行。
但 focus() 在 chain 里不是真的"聚焦",而是把"确保焦点"作为一个前置条件加入命令队列。真正执行时,它会先调用 view.focus(),再执行后续命令。
五、StarterKit 的按需拆解
5.1 按需引入的正确姿势
如果不想引入全部 18 个扩展,可以单独引入需要的:
typescript
import { Document, Paragraph, Text } from '@tiptap/extension-...'
const editor = useEditor({
extensions: [
Document,
Paragraph,
Text,
Bold,
Italic,
Heading,
// 只引入需要的
],
})
但问题是 TipTap v2 的扩展分散在多个包里,引入路径不统一:
typescript
// 有些在 @tiptap/extension-xxx
import Bold from '@tiptap/extension-bold'
// 有些在 @tiptap/starter-kit 里
import { Bold } from '@tiptap/starter-kit'
// 版本不同,路径可能不同
实际操作中,按需引入的成本不低。如果 bundle 体积不是瓶颈,用 StarterKit 更省心。
5.2 打包体积分析
用 rollup-plugin-visualizer 分析一下 TipTap 的打包体积:
less
@tiptap/core ~45 KB (gzip)
@tiptap/starter-kit ~30 KB (gzip)
prose mirror packages ~80 KB (gzip)
@tiptap/vue-3 ~5 KB (gzip)
---------------------------
总计 ~160 KB (gzip)
对比 Quill:
scss
quill ~60 KB (gzip)
TipTap 比 Quill 大了近 3 倍。这就是灵活性的代价。
但如果你需要自定义节点类型、复杂嵌套结构、精细的选区控制,Quill 根本做不到。这时候 TipTap 的 160KB 是值得的。
六、实战中的经验总结
6.1 editable 的响应式
typescript
const editable = inject('editable', true)
const editor = useEditor({
editable,
})
editable 是一个 Ref<boolean>。当它变化时,TipTap 内部会监听并调用 editor.setEditable():
typescript
// TipTap 源码简化
watch(editable, (value) => {
editor.value?.setEditable(value)
})
但 setEditable 只切换 contenteditable 属性,不会销毁重建 Editor 实例。这是好事------状态不会丢失。
6.2 编辑器销毁
typescript
onUnmounted(() => {
if (menuCloseTimer) {
clearTimeout(menuCloseTimer)
}
})
注意:useEditor 内部已经在 onUnmounted 时自动调用 editor.destroy(),你不需要手动销毁。但如果你在扩展里创建了定时器、事件监听器等,需要自己清理。
6.3 getHTML 不是唯一的输出格式
typescript
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
}
getHTML() 返回 HTML 字符串。但 ProseMirror 还支持:
getJSON():返回文档的 JSON 结构,更适合程序化处理getPlainText():返回纯文本
如果你的内容需要在前端解析处理(比如高亮特定段落),用 getJSON 比 getHTML 好得多。HTML 解析靠正则,脆弱且容易出错;JSON 是结构化数据,直接遍历即可。
我们项目用 HTML 是因为后端存储和展示都用 HTML。如果让我重新选,会推后端改成存 JSON。
七、TipTap 适合你吗
说了这么多,不是推荐所有人都上 TipTap。选型要看需求:
选 Quill 如果:
- 只需要基础的文本格式化
- 不需要复杂的嵌套结构
- 追求快速上线
- bundle 体积敏感
选 TipTap 如果:
- 需要自定义节点类型(表格、批注、嵌入内容)
- 需要精细的选区控制
- 需要协同编辑(ProseMirror 有 Yjs 集成)
- 团队能接受学习成本
选 Monaco/CodeMirror 如果:
- 主要是代码编辑
- 不需要富文本格式
没有最好的编辑器,只有最合适的。关键是搞清楚你的需求边界,别被"流行"和"先进"绑架。