在所有前端工程中,富文本编辑器(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,让这个过程变得可控。核心要点回顾:
- 先理解数据模型:Document → Node → Mark,这是所有功能的地基
- Extension 是核心:90% 的定制需求都可以通过自定义 Extension 实现
- Node View 解锁复杂 UI:当需要在编辑器内嵌入 Vue 组件时(如任务列表、图片画廊),NodeViewRenderer 是唯一正确的方案
- 协作编辑选 Yjs:Tiptap + Yjs 是目前最成熟的前端协作方案
- 安全不能忘:前后端双重过滤,永远不信任客户端传来的 HTML
📦 相关工具推荐:
- Tiptap 官方文档 — 最权威的参考资料
- ProseMirror 文档 — 理解底层原理的必读资料
- Yjs — CRDT 协作框架
- Hocuspocus — Tiptap 官方推荐的协作服务器
- jsjson.com 在线 JSON 工具 — 编辑器输出的 JSON 数据可以直接用我们的工具格式化、验证和转换