TipTap 架构(在 ProseMirror 之上的抽象)
返回 🗂 富文本编辑专题 · 相关私有笔记
阅读位置:这是一篇“从产品 API 回看内核”的架构篇,不是第一次上手 TipTap 的起点。
- 先做产品:先读 TipTap 实战 的“第一轮”,跑起一个
StarterKit编辑器;遇到“为什么一个扩展能同时定义 schema、命令和快捷键”时再回到这里。- 先学内核:按 ProseMirror 入门 → PM Schema → PM 核心模型 → State 与 Transaction → View 与 NodeView → Plugin 与 Decoration 的顺序读完,本篇会成为一张“TipTap 回调 → PM 原语”的翻译表。
最小先修:知道 Schema 决定合法文档,
Transaction描述一次变更,EditorView负责 DOM 同步,Plugin 是扩展点即可;不必先记住所有低层 API。
第一部分:TipTap 解决的问题:把「裸 PM 的高心智负担」收敛成一个声明单元
如果你读过前面几篇内核笔记会发现:用裸 ProseMirror 写一个「加粗」功能,配置散落在好几层——
PM Schema 里要写 MarkSpec(parseDOM / toDOM),
State 与 Transaction 里要写一个返回 boolean 的 Command,
keymap 插件里要绑 Mod-b,InputRule 里要写正则,
真正自定义渲染还得去 View 与 NodeView 写 NodeView。同一个特性,心智要在五个 API 层之间来回切换。
TipTap 的核心抽象只有一个词:Extension。它把上述所有关注点收进一个声明对象——你在一处把「这个特性是什么 / 怎么解析 / 怎么渲染 / 有哪些命令 / 快捷键 / 输入规则 / 自定义视图」全部声明出来,框架负责把它们翻译/分发到 PM 对应的底层概念上。这是一个典型的 declarative facade(声明式门面):用户表达意图,框架处理接线。
TipTap 是 headless 的:它只提供「编辑器引擎 + 内容结构」,不提供任何预制 UI 组件(工具栏、按钮都由你自己写)。所有渲染逻辑要么走 renderHTML,要么走 NodeView,要么在你的应用层。
一句话定位:TipTap = ProseMirror 的声明式封装层 + 可链式命令 API + React/Vue 绑定,内核仍然是 PM。理解 TipTap 的关键,是把每个扩展回调对应回它最终落到的那个 PM 概念。
第二部分:声明如何变成 PM 运行时
三类声明式扩展:Extension / Node / Mark
TipTap 把扩展分成三类,它们都是「扩展」,只是承载的 PM 概念不同:
| TipTap 类型 | 承载内容 | 对应 PM 概念 | 例子 |
|---|---|---|---|
Extension | 纯行为,不进 schema | 只贡献 plugins / keymap / commands / rules | History、Placeholder、全局快捷键 |
Node | 块级 / 行级内容节点 | NodeSpec(进 Schema 的 nodes) | paragraph、heading、bulletList、image |
Mark | 行内格式标记 | MarkSpec(进 Schema 的 marks) | bold、italic、link、highlight |
三者用对应的工厂创建:
import { Extension, Node, Mark } from '@tiptap/core'
// 纯行为:不进 schema,只贡献 plugins / commands / shortcuts
const MyBehavior = Extension.create({ name: 'myBehavior', /* ... */ })
// 节点:会被编译成一条 NodeSpec
const Heading = Node.create({ name: 'heading', group: 'block', content: 'inline*', /* ... */ })
// 标记:会被编译成一条 MarkSpec
const Bold = Mark.create({ name: 'bold', /* ... */ })关键心智:Node / Mark 是 Extension 的特化。它们多出来的是「会进 Schema」这件事(因此有 addAttributes / parseHTML / renderHTML / content / group 等 schema 相关字段),其余 API(commands、shortcuts、rules、plugins)三类通用。
ExtensionManager:把扩展数组「编译」成 PM 运行时产物
这是整篇笔记的核心机制。new Editor({ extensions: [...] }) 时,内部的 ExtensionManager 会把扩展数组在初始化阶段一次性编译出 PM 的不可变运行时配置:
extensions: Extension[]
│ ExtensionManager 聚合编译(初始化时一次性)
▼
┌──────────────┬──────────────┬──────────────┬──────────────────┐
│ Schema │ Keymap │ Plugins │ InputRules / │
│ (nodes+marks) │ (合并快捷键) │ (PM Plugin[]) │ PasteRules │
└──────────────┴──────────────┴──────────────┴──────────────────┘
│
▼ new EditorState({ schema, plugins }) → new EditorView(...)
它具体做了这些事:
- 合并 Schema:把所有 Node 的
NodeSpec、所有 Mark 的MarkSpec收集成一个统一 Schema。负责排序(例如保证topNode/doc处理在前)、校验唯一性(node/mark 名字不能冲突),并把addAttributes的声明接到 PM 的NodeSpec.attrs/MarkSpec.attrs机制上。 - 合并 Keymap:把每个扩展的
addKeyboardShortcuts汇总成一个 keymap,编译成一个 PM 的 keymap Plugin。 - 汇总 Plugins:把每个扩展
addProseMirrorPlugins返回的原生 Plugin、以及由 commands/rules 派生出来的 Plugin,拼成最终的plugins列表。 - 合并 Rules:把所有
addInputRules/addPasteRules合并进单个inputRules/pasteRulesPlugin。
设计意图:用户永远不直接构造 PM 的 Schema spec(那需要手写 parseDOM / toDOM / attrs / group / content)。扩展只声明意图,Manager 批量翻译成 PM 底层格式。正因为有这一层聚合,TipTap 才能提供裸 PM 没有的便利,比如 global attributes(一个属性同时挂到多个 node/mark 上)。
记住这是个 compile-time(初始化期) 步骤,产物是不可变的运行时配置。这也解释了一条 React 踩坑(见“常见坑”):每次渲染传新的
extensions数组会触发重新编译,导致编辑器被重置。
第三部分:按需回查 Extension API
扩展 API 全貌:每个回调最终落到哪个 PM 概念
下面是理解 TipTap 的「翻译表」。左边是你在扩展里写的回调,右边是它在 PM 内核里最终对应的东西(对照前面的内核笔记):
| TipTap 回调 | 作用 | 最终落到的 PM 概念 |
|---|---|---|
addOptions() | 声明式配置(不可变) | 无(框架内部,经 this.options 读取) |
addStorage() | 运行时可变状态 | 无(editor.storage.<name>,实例生命周期内可变) |
addAttributes() | 节点/标记的附加属性 | NodeSpec.attrs / MarkSpec.attrs({default} 字典) |
parseHTML() | HTML → 文档(导入/粘贴) | NodeSpec.parseDOM / MarkSpec.parseDOM(ParseRule[]) |
renderHTML() | 文档 → HTML(复制/导出) | toDOM(返回 DOMOutputSpec) |
addCommands() | 可链式命令 | 一组返回 boolean 的 PM Command(操作 Transaction) |
addKeyboardShortcuts() | 快捷键 | keymap Plugin |
addInputRules() | 输入时自动转换 | InputRule(合进 inputRules Plugin) |
addPasteRules() | 粘贴时自动转换 | PasteRule(合进 pasteRules Plugin) |
addProseMirrorPlugins() | 直接挂原生插件 | PM Plugin[](逃生舱) |
addNodeView() | 节点的交互式渲染 | PM NodeView |
addOptions / addStorage:配置 vs 运行时状态
两者刻意分开关注点:addOptions 是不可变配置(用 editor.configure({ ExtName: { option: value } }) 注入,扩展内用 this.options 读);addStorage 是可变状态(editor.storage.<name>,扩展内用 this.storage 读),贯穿编辑器实例的整个生命周期,用来存「打开了哪个弹窗」这类运行时数据。分开是为了避免「配置」和「状态」相互污染。
const Heading = Node.create({
name: 'heading',
addOptions() {
return { levels: [1, 2, 3], HTMLAttributes: {} } // 不可变配置
},
addStorage() {
return { lastEditedAt: null } // 可变运行时状态
},
})addAttributes:把属性声明 + 序列化逻辑就近放一起
PM 要求属性必须在 schema 里声明。TipTap 让每个属性可以就近带上自己的 parseHTML(从 HTML 反序列化)和 renderHTML(序列化回 HTML),最终编译进 NodeSpec.attrs / MarkSpec.attrs。属性会自动写入 editor 的 JSON 表示,并在 HTML round-trip 中保留。
addAttributes() {
return {
level: {
default: 1,
parseHTML: (el) => Number(el.tagName[1]), // 从 <h2> 读出 level=2
renderHTML: (attrs) => ({ class: `h-${attrs.level}` }),
},
}
}parseHTML / renderHTML:双向 HTML 映射(两个独立关注点)
这是经常被搞混的点:parseHTML 管「进来」(粘贴、HTML 导入),renderHTML 管「出去」(复制、HTML 导出)。要让一个节点支持完整的复制/粘贴 round-trip,两个都得写。
// 解析:声明 HTML 模式 + 属性提取 → 编译成 PM 的 ParseRule[]
parseHTML() {
return [
{ tag: 'h1', attrs: { level: 1 } },
{ tag: 'h2', attrs: { level: 2 } },
]
}
// 渲染:返回 [tag, attributes, contentSlot] → 编译成 PM 的 DOMOutputSpec
renderHTML({ node, HTMLAttributes }) {
return [`h${node.attrs.level}`, mergeAttributes(HTMLAttributes), 0]
// 第三个 0 是「内容洞」(content hole),PM 把子内容渲染到这里
}裸 PM 这边是 parseDOM(ParseRule 数组,含 tag / getAttrs)和 toDOM(返回原始 [tag, attrs, 0] 元组)。TipTap 只是给了更高层的声明接口,底层机制还是 PM 的。
addCommands:可链式命令(对照 PM 的 Command 约定)
PM 的 Command 是函数式约定:(state, dispatch?, view?) => boolean——dispatch 缺省时只做探测(返回能否执行),不修改状态(详见 State 与 Transaction)。TipTap 在其上加了一层:每个命令是一个工厂,接收 { editor, view, state, dispatch, chain, can, get, commands, tr } 上下文,返回一个「接收参数 → 返回 boolean」的函数。
addCommands() {
return {
// 工厂:options → ctx → boolean
toggleHighlight: () => ({ commands }) => {
return commands.toggleMark(this.name) // 复用内建命令
},
}
}链式调用 editor.chain().a().b().run() 是「把多个命令组合进同一个 Transaction 原子提交」的语法糖;命令内部拿到的 chain 参数可以在同一事务里继续链其它命令。can() 则是 PM「dispatch 缺省即探测」语义的封装——见下一节。
addKeyboardShortcuts:声明式快捷键
返回「快捷键字符串 → 命令执行函数」的映射,编译成一个 keymap Plugin。TipTap 负责跨平台归一化:Mod 在 Mac 上是 Cmd、在 Win/Linux 上是 Ctrl。所有扩展的快捷键由 ExtensionManager 合并,且在 .extend() 时可被内省/覆盖——避免了裸 PM 需要单独维护一个全局 keymap 注册表。
addKeyboardShortcuts() {
return { 'Mod-Shift-h': () => this.editor.commands.toggleHighlight() }
}addInputRules / addPasteRules:隐式内容转换
InputRule 在输入时触发(打 # 变标题),PasteRule 在粘贴时触发。底层就是 PM 的 InputRule / PasteRule 机制(正则匹配 + handler)。多个扩展的规则会被 Manager 合并进单个 Plugin。常见做法是用 TipTap 的辅助函数 textblockTypeInputRule / wrappingInputRule / markInputRule / nodeInputRule,也可直接用 InputRule 配置 find / handler:
import { wrappingInputRule } from '@tiptap/core'
addInputRules() {
return [
// 行首 "- " / "* " / "+ " → 包裹成项目列表
wrappingInputRule({ find: /^\s*([-+*])\s$/, type: this.type }),
]
}注意规则是有序的,按数组顺序匹配,多个匹配时第一个胜出。这把「Heading 的快捷键 + 输入规则 + 命令」这类相关功能放在了同一个扩展里,而不是散落到全局。
addProseMirrorPlugins:逃生舱,直达 PM 全部能力
TipTap 的高层 API 只覆盖常见模式。需要 Decoration、自定义插件状态机、appendTransaction、view 钩子等高级能力时,用 addProseMirrorPlugins() 直接挂原生 PM Plugin(详见 Plugin 与 Decoration)。这是「专家逃生舱」——大多数需求应优先用 commands / input rules 等高层 API 解决。
import { Plugin } from '@tiptap/pm/state'
addProseMirrorPlugins() {
return [ new Plugin({ /* 原生 PM 插件:props / state / appendTransaction ... */ }) ]
}addNodeView:交互式节点
addNodeView() 让一个节点以交互组件(React / Vue / vanilla)渲染,底层映射到 PM 的 NodeView(拦截特定节点类型的渲染)。返回组件会拿到 node(含 attrs)、selected、updateAttributes(响应式回写属性)、editor 等 props。框架特定的 ReactNodeViewRenderer / VueNodeViewRenderer 负责把普通组件包成 NodeView。
import { ReactNodeViewRenderer } from '@tiptap/react'
addNodeView() {
return ReactNodeViewRenderer(CounterComponent)
}关键设计点:NodeView 与 renderHTML 相互独立。你可以在编辑器内用一个复杂交互组件,而 renderHTML 输出一个朴素的 <div data-type="...">。这是刻意的——把「编辑器内 UI」与「导出产物」解耦。
第四部分:在应用代码中调用与接入
Editor 类与可链式命令:chain() / can()
Editor 是框架无关的核心类。三种命令调用方式:
// 1) 单条立即执行
editor.commands.setHeading({ level: 2 })
// 2) 链式:多条命令打包进「一个原子 Transaction」后 .run()
editor.chain().focus().toggleBold().insertContent('文字').run()
// 3) can():dry-run 探测,返回 true/false,不修改状态
const canBold = editor.can().toggleBold().run()
if (canBold) { /* 工具栏按钮置为可用 */ }chain() 返回一个链式代理,把后续命令攒进同一个 Transaction,直到 .run() 才真正派发——这正是把 PM「手动 batch transaction」变成可读的命令式语法糖,并鼓励原子更新。can() 对应 PM「dispatch 缺省即探测」的语义:它干跑命令链而不落地,让 UI 能在「无副作用」前提下判断按钮 / 菜单是否该高亮、是否禁用——这是构建智能工具栏的关键。
易错:
editor.can()和editor.chain()一样返回链式代理,必须.run()才能拿到 boolean。editor.can().toggleBold本身不是布尔值。
读写文档内容:
const json = editor.getJSON() // { type: 'doc', content: [...] }(TipTap JSON)
const html = editor.getHTML() // '<p>Hello</p>'(经 renderHTML 序列化)组合复用:.extend()
声明式模型最大的红利是组合。extension.extend(overrides) 复制一个扩展并定制,无需从头重写。但有一条硬规则:覆盖 addOptions / addKeyboardShortcuts 等时,务必调 this.parent?.() 继承父级配置,否则会丢功能。
import BulletList from '@tiptap/extension-bullet-list'
const CustomBulletList = BulletList.extend({
addKeyboardShortcuts() {
return { 'Shift-Ctrl-b': () => this.editor.commands.toggleBulletList() }
},
addOptions() {
return {
...this.parent?.(), // ← 必须:继承父级配置
HTMLAttributes: { class: 'custom-list' },
}
},
})与 React / Vue 的绑定层
TipTap 核心 Editor 框架无关;框架包只是薄包装,把命令式的 Editor 适配进框架的生命周期与响应式模型。
React(@tiptap/react)
useEditor(options) 在组件内初始化并惰性持有 editor;<EditorContent editor={editor} /> 渲染编辑器的 DOM(那个 contentEditable 容器);若用 EditorProvider 提供上下文,子组件可用 useCurrentEditor() 拿到 editor;useEditorState() 用选择器订阅状态变化,只在选中的派生值变化时才重渲染,避免每次事务都触发整组件重渲染。
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
export function MyEditor() {
const editor = useEditor({
extensions: [StarterKit],
content: '<p>Hello World!</p>',
immediatelyRender: false, // SSR 下避免水合不一致
})
if (!editor) return null
return (
<div>
<button onClick={() => editor.chain().focus().toggleBold().run()}>Bold</button>
<EditorContent editor={editor} />
</div>
)
}Vue 3(@tiptap/vue-3)
模式与 React 镜像,但用 composition function + 响应式 ref。useEditor(options) 返回响应式 editor(editor.value);<EditorContent> 是渲染 contentEditable 的 Vue 组件。
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
export default {
components: { EditorContent },
setup() {
const editor = useEditor({ extensions: [StarterKit], content: '<p>Hello World!</p>' })
const toggleBold = () => editor.value?.chain().focus().toggleBold().run()
return { editor, toggleBold }
},
}两个框架的 NodeView 桥接也对称:React 用 ReactNodeViewRenderer,Vue 用 VueNodeViewRenderer,核心都是把普通组件接到 PM 的 NodeView 上,并用 updateAttributes 实现「组件 props ↔ schema attrs」的响应式双向绑定。
常见坑
editor.can()返回链式代理,必须.run()才得 boolean;别期望editor.can().toggleBold直接是布尔。注意can()是 dry-run 探测,不保证真正执行时不抛错。parseHTML(导入/粘贴)与renderHTML(复制/导出)是两个独立关注点;要支持完整复制粘贴,两者都要写。addAttributes声明的属性会自动落进 JSON 和 HTML——若属性需要 round-trip,记得带上对应的parseHTML/renderHTML。NodeView返回的组件若不调updateAttributes去改 attrs,组件不会响应式更新。.extend()覆盖addOptions/addKeyboardShortcuts时,不调this.parent?.()会丢父级功能。- InputRules / PasteRules 按数组顺序匹配,多个命中时第一个胜出。
editor.chain()把命令打包进一个事务,中间态不可见;若命令间需要副作用,拆成多次chain()。- React 的
EditorProvider会缓存 editor 实例;别在每次渲染传新的extensions数组,否则编辑器被重置(呼应 ExtensionManager 的“编译产物不可变”)。 addProseMirrorPlugins给的是原生 Plugin API,强大但复杂;多数需求用高层 API 即可。NodeView与renderHTML独立:编辑器内可以是复杂交互组件,而导出 HTML 可以很简单——这是刻意的「UI 与产物解耦」。
小结:抽象层次对照(回到内核)
| 维度 | 裸 ProseMirror | TipTap |
|---|---|---|
| 组织单元 | 配置散落在 schema / state / view / keymap / rules 五层 | 单个声明式 Extension |
| Schema 构造 | 手写 NodeSpec / MarkSpec(parseDOM / toDOM / attrs) | addAttributes / parseHTML / renderHTML,Manager 编译 |
| 命令 | (state, dispatch?, view?) => boolean 手动拼事务 | editor.chain().a().b().run() + can() 探测 |
| 输入转换 | 手写 InputRule / PasteRule 接进插件 | 扩展内 addInputRules / addPasteRules,Manager 合并 |
| 自定义渲染 | 手写 NodeView 类、手动管 DOM | ReactNodeViewRenderer / VueNodeViewRenderer |
| 复用 | 复制粘贴或手动继承 | .extend() + this.parent?.() |
| 渲染 UI | 自己接 | headless,自己接(但有官方 React/Vue 绑定) |
核心结论:TipTap 没有重写编辑器内核,它仍然站在 PM 的不可变文档模型、Schema 约束、Transaction/Step、Plugin/Decoration 之上(见前面五篇内核笔记)。它做的是收敛接线复杂度——把意图声明集中到 Extension,用 ExtensionManager 在初始化期编译成 PM 运行时,再用可链式命令把命令式控制变得可读。理解 TipTap 的最佳方式,永远是把每个回调对应回它最终落到的那个 PM 概念。动手部分见 TipTap 实战;性能与大文档注意事项见 性能与可访问性。
参考
- Tiptap Concepts — Introduction
- Extensions in Tiptap
- Schema | Tiptap Editor Docs
- Node API | Tiptap Editor Docs
- Mark API | Tiptap Editor Docs
- Commands | Tiptap Editor Docs
- Input Rules in Tiptap
- Editor Instance API — Tiptap
- React node views | Tiptap Editor Docs
- React | Tiptap Editor Docs
- Vue 3 | Tiptap Editor Docs