TipTap 不是编辑器,是编辑器构造器:ProseMirror 模型驱动的富文本架构

从一次选型翻车说起

今年做个管理系统,需要富文本编辑器来编辑赛事规则。第一反应是上 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"

每个节点都有 typeattrs(属性)、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

这意味着即使没有那个判断,死循环也不会发生。但加上判断是更安全的做法,因为:

  1. 如果将来 TipTap 修改了默认值
  2. 如果有其他扩展在 setContent 时触发了更新
  3. 防止不必要的 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():返回纯文本

如果你的内容需要在前端解析处理(比如高亮特定段落),用 getJSONgetHTML 好得多。HTML 解析靠正则,脆弱且容易出错;JSON 是结构化数据,直接遍历即可。

我们项目用 HTML 是因为后端存储和展示都用 HTML。如果让我重新选,会推后端改成存 JSON。

七、TipTap 适合你吗

说了这么多,不是推荐所有人都上 TipTap。选型要看需求

选 Quill 如果

  • 只需要基础的文本格式化
  • 不需要复杂的嵌套结构
  • 追求快速上线
  • bundle 体积敏感

选 TipTap 如果

  • 需要自定义节点类型(表格、批注、嵌入内容)
  • 需要精细的选区控制
  • 需要协同编辑(ProseMirror 有 Yjs 集成)
  • 团队能接受学习成本

选 Monaco/CodeMirror 如果

  • 主要是代码编辑
  • 不需要富文本格式

没有最好的编辑器,只有最合适的。关键是搞清楚你的需求边界,别被"流行"和"先进"绑架。

相关推荐
小高0071 小时前
🔥🔥🔥TypeScript 7 正式版来了:别只看 10 倍速度,这 4 个迁移坑更值得注意
前端·javascript·面试
凉茶社1 小时前
shadcn/ui 默认改用 Base UI,Radix 被放弃了吗?
前端
Vuji1 小时前
ReAct 与 Plan-Execute:两种 Agent 范式的实战对比
前端·agent
用户61595868000221 小时前
从零原生搭建一个微前端简易框架
前端
小月土星1 小时前
React + TypeScript 企业级开发实战:从类型约束到组件设计
前端
沙洲1 小时前
Vite 环境变量终极指南:从原理到企业级实战
前端
刘婉晴1 小时前
【Web漏洞】SQL 注入实战技巧
前端·数据库·sql
di24k24k2 小时前
多个 el-form 共用同一 ref 导致表单校验部分失效
前端·javascript·vue.js·elementui
NutShell Wang2 小时前
每帧重建整条路径、每秒倾倒 48MB 给 GC:实时折线图渲染架构的实测复盘
前端·性能优化·架构·图形渲染·数据可视化·vibe coding