TipTap 架构(在 ProseMirror 之上的抽象)

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

阅读位置:这是一篇“从产品 API 回看内核”的架构篇,不是第一次上手 TipTap 的起点。

最小先修:知道 Schema 决定合法文档,Transaction 描述一次变更,EditorView 负责 DOM 同步,Plugin 是扩展点即可;不必先记住所有低层 API。

第一部分:TipTap 解决的问题:把「裸 PM 的高心智负担」收敛成一个声明单元

如果你读过前面几篇内核笔记会发现:用裸 ProseMirror 写一个「加粗」功能,配置散落在好几层——
PM Schema 里要写 MarkSpec(parseDOM / toDOM),
State 与 Transaction 里要写一个返回 boolean 的 Command,
keymap 插件里要绑 Mod-b,InputRule 里要写正则,
真正自定义渲染还得去 View 与 NodeViewNodeView。同一个特性,心智要在五个 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 / rulesHistory、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 / pasteRules Plugin。

设计意图:用户永远不直接构造 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)、selectedupdateAttributes(响应式回写属性)、editor 等 props。框架特定的 ReactNodeViewRenderer / VueNodeViewRenderer 负责把普通组件包成 NodeView

import { ReactNodeViewRenderer } from '@tiptap/react'
 
addNodeView() {
  return ReactNodeViewRenderer(CounterComponent)
}

关键设计点:NodeViewrenderHTML 相互独立。你可以在编辑器内用一个复杂交互组件,而 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 即可。
  • NodeViewrenderHTML 独立:编辑器内可以是复杂交互组件,而导出 HTML 可以很简单——这是刻意的「UI 与产物解耦」。

小结:抽象层次对照(回到内核)

维度裸 ProseMirrorTipTap
组织单元配置散落在 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 类、手动管 DOMReactNodeViewRenderer / VueNodeViewRenderer
复用复制粘贴或手动继承.extend() + this.parent?.()
渲染 UI自己接headless,自己接(但有官方 React/Vue 绑定)

核心结论:TipTap 没有重写编辑器内核,它仍然站在 PM 的不可变文档模型、Schema 约束、Transaction/Step、Plugin/Decoration 之上(见前面五篇内核笔记)。它做的是收敛接线复杂度——把意图声明集中到 Extension,用 ExtensionManager 在初始化期编译成 PM 运行时,再用可链式命令把命令式控制变得可读。理解 TipTap 的最佳方式,永远是把每个回调对应回它最终落到的那个 PM 概念。动手部分见 TipTap 实战;性能与大文档注意事项见 性能与可访问性

参考