Tiptap 富文本编辑器实战指南:从 ProseMirror 原理到 Notion 风格块编辑器

深入解析 Tiptap 与 ProseMirror 架构,手把手构建可扩展的富文本编辑器,涵盖 Schema 设计、自定义 Extension、Node View、协作编辑等核心实战,附完整代码与性能优化方案。

前端开发 2026-06-02 18 分钟

在所有前端工程中,富文本编辑器(Rich Text Editor)是公认的「前端最难问题」之一。Google Docs、Notion、飞书文档这些产品背后是数百万行编辑器代码。如果你正在为团队选型或自己从零搭建,Tiptap 已经成为 2025-2026 年最主流的 headless 富文本编辑器框架——它基于 ProseMirror,提供声明式 API、完整的 TypeScript 支持和开箱即用的协作编辑能力,GitHub Stars 超过 30K。

本文不会停留在「Hello World」层面,而是深入 ProseMirror 的数据模型、Schema 系统、命令机制,最终带你从零构建一个 Notion 风格的块编辑器(Block Editor)。如果你曾被 contentEditable 折磨过,这篇文章会让你理解为什么我们需要 ProseMirror,以及 Tiptap 如何把它变得可用。

🏗️ 一、ProseMirror 数据模型:理解编辑器的「地基」

在写任何 Tiptap 代码之前,必须先理解 ProseMirror 的核心数据结构。这是很多开发者踩坑的根源——不理解模型就写 Extension,最终被各种奇怪的 bug 折磨。

📄 Document、Node、Mark 三件套

ProseMirror 的文档模型是一个不可变树(Immutable Tree),每个节点(Node)有类型(type)、属性(attrs)、内容(content)和标记(marks):

// ProseMirror 文档的 JSON 表示 —— 这是编辑器的"真相来源"
{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 1 },
      "content": [
        { "type": "text", "text": "Hello World" }
      ]
    },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "这是" },
        {
          "type": "text",
          "text": "加粗",
          "marks": [{ "type": "bold" }]
        },
        { "type": "text", "text": "文本。" }
      ]
    }
  ]
}

📌 记住: ProseMirror 中所有的编辑操作都返回一个新的 Document 实例,而不是修改原对象。这个不可变设计是理解 Transaction 和 State 的关键。

三个核心概念的区别:

概念 作用 示例 类比
Node 文档树中的节点 heading、paragraph、image DOM 中的 Element
Mark 附加在文本上的装饰 bold、italic、link DOM 中的 inline style
Slice 文档的片段 粘贴时的中间数据 剪贴板中的 DOM 片段

⚠️ 为什么 contentEditable 不够用

直接使用浏览器原生 contentEditable 的问题在于:

  • ❌ 不同浏览器的 DOM 行为不一致(回车键、选区、格式化)
  • ❌ 没有结构化的数据模型,操作 DOM 后无法可靠地获取文档状态
  • ❌ 无法实现撤销/重做的可靠历史管理
  • ❌ 协作编辑几乎不可能从零实现

ProseMirror 的解决方案是:用一个结构化的 JSON 文档作为 Single Source of Truth,所有用户交互都转换为对这个 JSON 的 Transaction,再由编辑器映射到 DOM。

// ✅ ProseMirror 的核心循环:State → Transaction → New State → DOM
// 用户按键 → 创建 Transaction → 应用到 State → 重新渲染 DOM
// 这个模型让撤销、协作、序列化都变得可靠

🔧 二、Tiptap 核心 API 实战:从安装到自定义 Extension

理解了底层模型,我们来看 Tiptap 如何把这些复杂概念包装成开发者友好的 API。

🚀 快速搭建基础编辑器

# 安装核心依赖
npm install @tiptap/vue-3 @tiptap/starter-kit @tiptap/extension-link @tiptap/extension-image
<!-- Editor.vue —— 最小可运行的 Tiptap 编辑器 -->
<template>
  <div class="editor-wrapper">
    <div class="toolbar">
      <button
        @click="editor?.chain().focus().toggleBold().run()"
        :class="{ 'is-active': editor?.isActive('bold') }"
      >粗体</button>
      <button
        @click="editor?.chain().focus().toggleItalic().run()"
        :class="{ 'is-active': editor?.isActive('italic') }"
      >斜体</button>
      <button
        @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()"
        :class="{ 'is-active': editor?.isActive('heading', { level: 2 }) }"
      >H2</button>
      <button @click="editor?.chain().focus().undo().run()">撤销</button>
      <button @click="editor?.chain().focus().redo().run()">重做</button>
    </div>
    <EditorContent :editor="editor" />
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'

const editor = useEditor({
  content: '<h2>开始编辑</h2><p>这是一个 <strong>Tiptap</strong> 编辑器。</p>',
  extensions: [StarterKit],
})

// 获取 JSON 格式的文档数据 —— 可以直接存入数据库
const getDocumentJSON = () => editor.value?.getJSON()

// 获取 HTML 格式 —— 用于前端渲染
const getDocumentHTML = () => editor.value?.getHTML()
</script>

💡 提示: StarterKit 已经内置了 bold、italic、heading、codeBlock、blockquote、bulletList、orderedList 等常用扩展。生产环境中不需要重复安装这些。

🧩 自定义 Extension:任务列表(Task List)

Tiptap 的 Extension 系统是它最大的优势。下面是一个完整的自定义任务列表扩展,展示 Node、Node View 和 Commands 的用法:

// extensions/TaskItem.ts —— 自定义任务列表项
import { Node, mergeAttributes } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import TaskItemView from './TaskItemView.vue'

export const TaskItem = Node.create({
  name: 'taskItem',

  group: 'block',

  content: 'inline*',

  // 定义节点属性 —— checked 状态存储在 attrs 中
  addAttributes() {
    return {
      checked: {
        default: false,
        parseHTML: (element) => element.getAttribute('data-checked') === 'true',
        renderHTML: (attributes) => ({
          'data-checked': attributes.checked.toString(),
        }),
      },
    }
  },

  parseHTML() {
    return [{ tag: 'li[data-type="task-item"]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['li', mergeAttributes(HTMLAttributes, { 'data-type': 'task-item' }), 0]
  },

  // 使用 Vue 组件作为节点视图 —— 实现自定义渲染
  addNodeView() {
    return VueNodeViewRenderer(TaskItemView)
  },

  // 定义快捷键和命令
  addKeyboardShortcuts() {
    return {
      Enter: () => this.editor.commands.splitListItem('taskItem'),
    }
  },
})
<!-- TaskItemView.vue —— 任务列表项的 Vue 渲染组件 -->
<template>
  <NodeViewWrapper class="task-item" :data-checked="node.attrs.checked">
    <input
      type="checkbox"
      :checked="node.attrs.checked"
      @change="toggleCheck"
    />
    <NodeViewContent class="task-content" />
  </NodeViewWrapper>
</template>

<script setup lang="ts">
import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'

const props = defineProps(nodeViewProps)

const toggleCheck = () => {
  props.updateAttributes({
    checked: !props.node.attrs.checked,
  })
}
</script>

<style scoped>
.task-item {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  padding: 4px 0;
}
.task-item[data-checked="true"] .task-content {
  text-decoration: line-through;
  opacity: 0.6;
}
</style>

⚠️ 警告: 自定义 Node 的 content 属性决定了节点内可以放什么内容。设置为 'inline*' 表示只允许行内内容,'block*' 表示块级内容,'text*' 表示纯文本。设置错误会导致内容丢失或无法输入。

📊 常用 Extension 对比

Extension 内置(StarterKit) 适用场景 包大小
Bold 加粗文本 ~1KB
Heading 标题(H1-H6) ~2KB
CodeBlock 代码块 ~3KB
BulletList 无序列表 ~2KB
Link 超链接 ~4KB
Image 图片插入 ~3KB
Table 表格编辑 ~15KB
Placeholder 占位文字 ~1KB
Highlight 高亮标记 ~1KB
TaskList 任务列表 ~5KB

🧱 三、构建 Notion 风格块编辑器

Notion 的核心创新是「块(Block)」——每个段落、标题、图片、代码块都是一个独立的 Block,可以通过拖拽排序、转换类型。下面我们用 Tiptap 实现这个核心功能。

🎯 Block 架构设计

// extensions/BlockNode.ts —— 核心 Block 节点
import { Node, mergeAttributes } from '@tiptap/core'
import { VueNodeViewRenderer } from '@tiptap/vue-3'
import BlockView from './BlockView.vue'

export const BlockNode = Node.create({
  name: 'block',
  group: 'block',
  content: 'block+',  // 块内可以包含子块

  addAttributes() {
    return {
      blockId: {
        default: () => crypto.randomUUID(),
        parseHTML: (el) => el.getAttribute('data-block-id'),
        renderHTML: (attrs) => ({ 'data-block-id': attrs.blockId }),
      },
      blockType: {
        default: 'page',  // page | heading | paragraph | code | callout
        parseHTML: (el) => el.getAttribute('data-block-type'),
        renderHTML: (attrs) => ({ 'data-block-type': attrs.blockType }),
      },
    }
  },

  parseHTML() {
    return [{ tag: 'div[data-block]' }]
  },

  renderHTML({ HTMLAttributes }) {
    return ['div', mergeAttributes(HTMLAttributes, { 'data-block': '' }), 0]
  },

  addNodeView() {
    return VueNodeViewRenderer(BlockView)
  },
})
<!-- BlockView.vue —— Notion 风格的块视图 -->
<template>
  <NodeViewWrapper
    class="block-wrapper"
    :data-block-id="node.attrs.blockId"
    draggable="true"
    @dragstart="onDragStart"
    @dragover.prevent="onDragOver"
    @drop="onDrop"
  >
    <!-- 左侧拖拽手柄 + 添加按钮 -->
    <div class="block-handle">
      <button class="add-btn" @click="showMenu = !showMenu">+</button>
      <div class="drag-handle" draggable="true">⠿</div>
    </div>

    <!-- 块类型选择菜单 -->
    <div v-if="showMenu" class="block-menu">
      <button @click="convertTo('paragraph')">📝 文本</button>
      <button @click="convertTo('heading')">📌 标题</button>
      <button @click="convertTo('code')">💻 代码</button>
      <button @click="convertTo('callout')">💡 提示框</button>
    </div>

    <!-- 内容区域 -->
    <NodeViewContent class="block-content" />
  </NodeViewWrapper>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'

const props = defineProps(nodeViewProps)
const showMenu = ref(false)

const convertTo = (type: string) => {
  props.updateAttributes({ blockType: type })
  showMenu.value = false
}

// 拖拽排序的核心逻辑
const onDragStart = (e: DragEvent) => {
  e.dataTransfer?.setData('blockId', props.node.attrs.blockId)
  e.dataTransfer?.effectAllowed = 'move'
}

const onDragOver = (e: DragEvent) => {
  e.dataTransfer!.dropEffect = 'move'
}

const onDrop = (e: DragEvent) => {
  const draggedId = e.dataTransfer?.getData('blockId')
  if (draggedId && draggedId !== props.node.attrs.blockId) {
    // 通过 Editor 的 command 移动节点
    // 实际实现需要配合 pmView 插件
    console.log(`Move ${draggedId} to before ${props.node.attrs.blockId}`)
  }
}
</script>

🔗 Slash Command(斜杠命令)

Notion 的另一个标志性交互是输入 / 触发命令菜单。Tiptap 提供了 Suggestion 扩展来实现这个功能:

// extensions/SlashCommand.ts —— 斜杠命令扩展
import { Extension } from '@tiptap/core'
import Suggestion from '@tiptap/suggestion'

export const SlashCommand = Extension.create({
  name: 'slashCommand',

  addOptions() {
    return {
      suggestion: {
        char: '/',
        command: ({ editor, range, props }) => {
          // 删除 / 及后续输入的搜索文本
          editor.chain().focus().deleteRange(range).run()
          // 执行对应的转换命令
          props.command(editor)
        },
      },
    }
  },

  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        ...this.options.suggestion,
      }),
    ]
  },
})

// 命令列表定义
export const slashCommands = [
  {
    title: '标题 1',
    description: '大标题',
    icon: 'H1',
    command: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(),
  },
  {
    title: '标题 2',
    description: '中标题',
    icon: 'H2',
    command: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(),
  },
  {
    title: '代码块',
    description: '插入代码',
    icon: '💻',
    command: (editor) => editor.chain().focus().toggleCodeBlock().run(),
  },
  {
    title: '引用',
    description: '插入引用块',
    icon: '❝',
    command: (editor) => editor.chain().focus().toggleBlockquote().run(),
  },
  {
    title: '任务列表',
    description: '待办事项',
    icon: '☑️',
    command: (editor) => editor.chain().focus().toggleTaskList().run(),
  },
  {
    title: '表格',
    description: '插入表格',
    icon: '📊',
    command: (editor) => editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run(),
  },
]

💡 提示: Suggestion 扩展的 char 属性支持多字符触发,比如 :: 触发表情选择器。command 回调中 range 参数包含了触发字符的精确位置,用 deleteRange 清除后才能正确插入内容。

📡 协作编辑:接入 Yjs

多人实时协作是块编辑器的高级功能。Tiptap 通过 @tiptap/y-protocols 和 Yjs 实现 CRDT 协作:

npm install yjs @tiptap/y-protocols y-websocket
// setup/collaboration.ts —— 协作编辑配置
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'

export function setupCollaboration(roomId: string, userName: string) {
  // 创建 Yjs 文档
  const ydoc = new Y.Doc()

  // 连接 WebSocket 服务器(可替换为 Hocuspocus、Liveblocks 等)
  const provider = new WebsocketProvider(
    'wss://your-collab-server.com',
    roomId,
    ydoc
  )

  // 监听连接状态
  provider.on('status', (event) => {
    console.log('协作连接状态:', event.status) // connected | disconnected
  })

  return {
    extensions: [
      // 将 Yjs 文档同步到编辑器
      Collaboration.configure({
        document: ydoc,
      }),
      // 显示其他用户的光标和选区
      CollaborationCursor.configure({
        provider,
        user: {
          name: userName,
          color: `#${Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0')}`,
        },
      }),
    ],
    ydoc,
    provider,
  }
}

⚠️ 警告: 协作编辑时,不要同时使用 Collaboration 扩展和 onUpdate 回调中的 editor.commands.setContent()。Yjs 会管理文档状态,手动设置内容会导致冲突和数据丢失。获取最新内容应使用 editor.getHTML()editor.getJSON()

📊 四、性能优化与生产部署

编辑器的性能直接影响用户体验。以下是生产环境中必须关注的几个关键点。

⚡ 大文档性能对比

文档规模 Tiptap 渲染 Quill 渲染 原生 contentEditable
100 段落 ~15ms ~20ms ~5ms
1,000 段落 ~80ms ~200ms ~50ms
10,000 段落 ~400ms ~2s+ ~300ms(但不可控)
协作同步延迟 ~30ms N/A N/A
包大小(gzip) ~45KB ~35KB 0

关键结论: 对于超过 5,000 段落的超大文档,建议实现虚拟滚动(只渲染可见区域的节点)或分页加载。Tiptap 的 editable 属性可以在渲染期间临时禁用编辑以提升性能。

🚀 关键优化策略

// 优化 1:延迟加载非核心扩展
// 只有用户第一次使用时才加载 Table 扩展
let tableLoaded = false
const loadTableExtension = async () => {
  if (tableLoaded) return
  const { Table, TableRow, TableCell, TableHeader } = await import('@tiptap/extension-table')
  editor.value?.registerExtension([Table, TableRow, TableCell, TableHeader])
  tableLoaded = true
}

// 优化 2:防抖保存(避免每次按键都触发 API 调用)
import { useDebounceFn } from '@vueuse/core'

const debouncedSave = useDebounceFn(async (json) => {
  await fetch('/api/documents/save', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content: json }),
  })
}, 1000)  // 用户停止输入 1 秒后才保存

// 优化 3:使用 Transaction 过滤减少不必要的更新
const editor = useEditor({
  extensions: [StarterKit],
  onUpdate({ editor, transaction }) {
    // 只在文档内容实际变化时触发保存
    // 忽略光标移动、选区变化等
    if (transaction.docChanged) {
      debouncedSave(editor.getJSON())
    }
  },
})

🔐 安全注意事项

// ❌ 危险:直接渲染用户输入的 HTML(XSS 风险)
editor.commands.setContent(userInputHTML)

// ✅ 安全:使用 Tiptap 内置的 sanitize 机制
import { generateHTML } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'

// generateHTML 只处理已注册的扩展对应的标签
// 未注册的标签(如 <script>)会被自动过滤
const safeHTML = generateHTML(jsonContent, [StarterKit])

// ✅ 更安全:后端也做一次白名单过滤
import sanitizeHtml from 'sanitize-html'

const backendSanitized = sanitizeHtml(safeHTML, {
  allowedTags: ['p', 'h1', 'h2', 'h3', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'img'],
  allowedAttributes: {
    'a': ['href', 'target'],
    'img': ['src', 'alt'],
  },
})

⚠️ 警告: 永远不要信任前端传来的富文本 HTML。即使 Tiptap 的 generateHTML 会过滤未注册的标签,攻击者仍可能通过修改 API 请求注入恶意内容。前后端双重过滤是必须的。

💡 五、选型建议与替代方案

在决定使用 Tiptap 之前,你需要了解它和其他方案的差异。

📋 编辑器框架对比

维度 Tiptap Slate.js Lexical Quill
底层引擎 ProseMirror 自研 自研 自研
框架支持 Vue/React/Vanilla React React Vanilla
TypeScript 原生支持 社区维护 原生支持 社区维护
协作编辑 Yjs(官方) 需自建 需自建 不支持
学习曲线 中等 中等
扩展性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐
社区生态 活跃 活跃 增长中 成熟但下降
适用场景 生产级产品 React 重度项目 Meta 生态 简单需求

关键结论: 如果你的项目是 Vue 技术栈,Tiptap 几乎是唯一的专业级选择。React 项目可以在 Tiptap 和 Lexical 之间选择——Tiptap 生态更成熟,Lexical 性能更好但社区插件较少。

✅ 推荐使用 Tiptap 的场景

  • ✅ 需要高度自定义的编辑器(Notion、飞书、语雀风格)
  • ✅ 需要多人实时协作
  • ✅ Vue 3 项目
  • ✅ 需要将文档存储为结构化 JSON
  • ✅ 需要 Slash Command、拖拽排序等高级交互

❌ 不推荐使用 Tiptap 的场景

  • ❌ 只需要一个简单的评论框(用 <textarea> 就够了)
  • ❌ 对包大小极度敏感(核心包 ~45KB gzip)
  • ❌ 团队没有时间学习 ProseMirror 概念(学习曲线约 1-2 周)

🎯 总结

构建一个生产级富文本编辑器是一项系统工程。Tiptap 通过将 ProseMirror 的强大能力包装成声明式 API,让这个过程变得可控。核心要点回顾:

  1. 先理解数据模型:Document → Node → Mark,这是所有功能的地基
  2. Extension 是核心:90% 的定制需求都可以通过自定义 Extension 实现
  3. Node View 解锁复杂 UI:当需要在编辑器内嵌入 Vue 组件时(如任务列表、图片画廊),NodeViewRenderer 是唯一正确的方案
  4. 协作编辑选 Yjs:Tiptap + Yjs 是目前最成熟的前端协作方案
  5. 安全不能忘:前后端双重过滤,永远不信任客户端传来的 HTML

📦 相关工具推荐:

📚 相关文章