我在金融科技公司用 Tiptap 一年:选型、架构与踩坑实录
引言
一年前,我所在的 KMS(知识管理平台)需要从旧的 textarea + Markdown 方案升级到真正的富文本编辑器。需求很明确:支持复杂排版、多人协作、自定义业务组件嵌入、以及金融行业特有的审计留痕。
一年后,Tiptap 已经在生产环境稳定运行,支撑着日均数千篇知识文档的编辑。这篇文章记录我从选型到落地踩过的坑,希望给同样在编辑器方向探索的同学一些参考。
一、为什么是 Tiptap
市面上的 React 富文本编辑器,主流的有这四个:
1.1 选型对比
| 维度 | Tiptap | Slate | Quill | Draft.js |
|---|---|---|---|---|
| 底层引擎 | ProseMirror | 自研 | 自研 | 自研 |
| TypeScript 支持 | 一流 | 良好 | 一般 | 一般 |
| 扩展体系 | 插件化 Node/Mark | 插件化 | 模块化 | 有限 |
| 协作编辑 | ProseMirror 原生 | Yjs 绑定 | Delta 方案 | 需自研 |
| 文档模型 | 强 schema 约束 | 灵活无约束 | Delta (JSON) | ContentState |
| 社区生态 | 活跃增长中 | 活跃 | 停滞 | Meta 维护不积极 |
| 学习曲线 | 陡峭(ProseMirror) | 中等 | 低 | 中等 |
1.2 我排除其他选项的原因
Quill:社区基本停滞,自定义 Block 嵌入(比如我们要插入金融图表组件)极其痛苦,Parchment 的文档少得可怜。
Draft.js:Meta 已经放弃积极维护。不可变数据模型在复杂编辑场景下的性能是硬伤,而且没有原生协作方案。
Slate:一度是最有力的竞争者。灵活性极强,但这也是它的致命伤——没有 Schema 约束意味着多人协作时的冲突解决几乎全要自己写。对于一个金融系统来说,数据一致性不可妥协。
1.3 Tiptap 的关键优势
Tiptap 基于 ProseMirror,而 ProseMirror 是学术界出身的编辑器框架,核心作者 Marijn Haverbeke 也是 CodeMirror 的作者。它的几个设计决策恰好打中了企业级场景:
- 强 Schema:文档结构有明确约束,这对金融场景是刚需——你不能让用户随便插入一个不符合规范的内容块。
- 原生协作模型:ProseMirror 的
collab模块提供了基于 OT 的协作基础,不需要从零造轮子。 - 扩展即一等公民:Tiptap 的 Node/Mark/Extension 三层扩展体系清晰,自定义业务组件就像搭积木。
二、架构设计:Tiptap 扩展体系
2.1 三层扩展模型
Tiptap 的扩展体系分为三层:
Extension (基础层)
├── Node (文档节点)
│ ├── Block Node: 段落、标题、代码块、自定义组件
│ └── Inline Node: 提及、标签、内联公式
├── Mark (文本标记)
│ ├── 加粗、斜体、链接
│ └── 自定义标记:金融术语高亮、合规标注
└── Extension (功能插件)
├── 快捷键、粘贴处理
├── 协作同步
└── 自定义交互
2.2 核心扩展架构
我们的编辑器实例配置:
// editor.config.ts
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { Collaboration } from '@tiptap/extension-collaboration'
import { CustomBlockNode } from './nodes/custom-block'
import { AuditTrailExtension } from './extensions/audit-trail'
import { FinanceMention } from './nodes/finance-mention'
import { ComplianceMark } from './marks/compliance-mark'
export function createEditor(doc: Uint8Array, roomId: string) {
return new Editor({
extensions: [
StarterKit.configure({
// 关闭不需要的默认扩展,精简体积
codeBlock: false,
horizontalRule: false,
}),
Collaboration.configure({
document: doc, // Yjs 文档实例
}),
CustomBlockNode,
AuditTrailExtension,
FinanceMention,
ComplianceMark,
],
})
}
2.3 自定义 Node 实战:金融产品卡片
KMS 中最典型的业务定制需求——在文档中嵌入金融产品信息卡片:
// nodes/finance-product-card.ts
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer } from '@tiptap/react'
import { FinanceProductCard } from '@/components/editor/FinanceProductCard'
export interface FinanceProductAttributes {
productId: string
productName: string
riskLevel: 'R1' | 'R2' | 'R3' | 'R4' | 'R5'
}
export const FinanceProductNode = Node.create({
name: 'financeProduct',
group: 'block',
atom: true, // 不可编辑内部内容,作为整体操作
addAttributes() {
return {
productId: { default: '' },
productName: { default: '' },
riskLevel: { default: 'R1' },
}
},
parseHTML() {
return [{ tag: 'finance-product' }]
},
renderHTML({ HTMLAttributes }) {
return ['finance-product', mergeAttributes(HTMLAttributes)]
},
addNodeView() {
return ReactNodeViewRenderer(FinanceProductCard)
},
})
配合 React 组件渲染:
// components/editor/FinanceProductCard.tsx
import { NodeViewWrapper } from '@tiptap/react'
import { Tag } from 'antd'
const riskColorMap: Record<string, string> = {
R1: 'green', R2: 'cyan', R3: 'orange', R4: 'red', R5: 'magenta',
}
export function FinanceProductCard({ node }: { node: any }) {
const { productId, productName, riskLevel } = node.attrs
return (
<NodeViewWrapper className="finance-product-card">
<div className="card" contentEditable={false}>
<span className="product-name">{productName}</span>
<Tag color={riskColorMap[riskLevel]}>{riskLevel}</Tag>
<a href={`/product/${productId}`} target="_blank">
查看详情
</a>
</div>
</NodeViewWrapper>
)
}
三、协作编辑落地
3.1 技术选型:Yjs + Tiptap Collaboration
协作方案我们评估了两个方向:
| 方案 | 通信协议 | 冲突解决 | 复杂度 |
|---|---|---|---|
| ProseMirror collab | 自定义 | OT(中心化) | 高,需自建服务端 |
| Yjs + Hocuspocus | WebSocket | CRDT(支持去中心化) | 中等,有现成服务端 |
选 Yjs 的核心原因:Hocuspocus 提供了开箱即用的 WebSocket 服务端,且 Yjs 的 CRDT 天然支持离线编辑后同步——金融场景下用户可能在弱网环境(比如营业部内网)工作。
3.2 同步架构
┌────────────────────────────────┐
│ KMS Frontend │
│ ┌──────────────────────────┐ │
│ │ Tiptap Editor │ │
│ │ ┌──────────────────────┐│ │
│ │ │ Yjs UndoManager ││ │
│ │ │ Y.Text (文档内容) ││ │
│ │ │ Y.Map (光标位置) ││ │
│ │ │ Y.Map (用户状态) ││ │
│ │ └──────────┬──── ──────┘│ │
│ │ │WebSocket │ │
│ └─────────────┼────────────┘ │
└────────────────┼───────────────┘
│
┌────────▼────────┐
│ Hocuspocus │
│ Server (Node) │
│ ┌────────────┐ │
│ │ 文档持久化 │ │
│ │ 变更日志 │ │
│ │ 审计留痕 │ │
│ └────────────┘ │
└────────┬────────┘
│
┌────────▼────────┐
│ S3 / MinIO │
│ Yjs 文档快照 │
└─────────────────┘
四、踩坑实战
4.1 坑一:NodeView React 组件中的事件冒泡
现象:在 FinanceProductCard 中点击"查看详情"链接时,编辑器会捕获点击事件,导致链接无法正常跳转。
根因:ProseMirror 默认接管了编辑区域内的所有点击事件,contentEditable={false} 只能阻止编辑,不能阻止事件捕获。
解决:
export function FinanceProductCard({ node }: { node: any }) {
const handleClick = (e: React.MouseEvent) => {
// 阻止事件冒泡到 ProseMirror
e.stopPropagation()
}
return (
<NodeViewWrapper>
<div className="card" onClick={handleClick}>
{/* ... */}
<a
href={`/product/${productId}`}
target="_blank"
onClick={(e) => e.stopPropagation()}
>
查看详情
</a>
</div>
</NodeViewWrapper>
)
}
4.2 坑二:中文输入法在协作模式下的光标跳动
现象:协作模式下,使用中文输入法(拼音)时,光标会在每次同步后跳到文档开头。
根因:Yjs 的 Y.UndoManager 在收到远端更新时默认会重置选区。中文输入法的 composition 事件与远端同步的时序冲突,导致 ProseMirror 无法正确恢复光标位置。
解决:
// extensions/composition-fix.ts
import { Extension } from '@tiptap/core'
import { Plugin } from 'prosemirror-state'
export const CompositionFix = Extension.create({
name: 'compositionFix',
addProseMirrorPlugins() {
const composingState = { isComposing: false }
return [
new Plugin({
props: {
handleDOMEvents: {
compositionstart: () => {
composingState.isComposing = true
return false
},
compositionend: () => {
composingState.isComposing = false
return false
},
},
},
appendTransaction: (_transactions, oldState, newState) => {
// 输入法激活时,阻止外部同步导致的选区重置
if (composingState.isComposing) return null
return null
},
}),
]
},
})
4.3 坑三:粘贴 Word 文档带来的脏样式
现象:用户从 Word 粘贴内容后,编辑器充满了 MS Office 的冗余样式,导致文档体积从 2KB 膨胀到 200KB。
解决:自定义粘贴处理,白名单过滤样式:
// extensions/paste-cleaner.ts
import { Extension } from '@tiptap/core'
import { Plugin } from 'prosemirror-state'
export const PasteCleaner = Extension.create({
name: 'pasteCleaner',
addProseMirrorPlugins() {
return [
new Plugin({
props: {
transformPastedHTML(html: string) {
// 移除所有 MS Office 专属标签和属性
return html
.replace(/<o:p>.*?<\/o:p>/g, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/\s(mso-[a-z-]+)\s*:\s*[^;"]+;?/gi, '')
.replace(/\sstyle\s*=\s*"[^"]*"/gi, (match) => {
// 只保留白名单样式
const allowedStyles = ['font-weight', 'font-style', 'text-decoration']
const filtered = match.replace(
/([a-z-]+)\s*:\s*[^;"]+/gi,
(prop: string) => {
const name = prop.split(':')[0].trim()
return allowedStyles.includes(name) ? prop : ''
}
)
return filtered.includes(':') ? filtered : ''
})
},
},
}),
]
},
})
4.4 坑四:大数据量文档的首屏渲染
现象:一篇超过 5 万字的文档,编辑器初始化耗时超过 3 秒。
根因:ProseMirror 在初始化时需要将整个 Yjs 文档解析为 DOM,长文档的同步解析阻塞了主线程。
解决:虚拟滚动 + 延迟初始化:
// 方案一:只加载可视区域
import { VirtualScrollExtension } from '@/extensions/virtual-scroll'
// 方案二:Yjs 文档分片加载
async function loadDocumentInChunks(docId: string) {
const response = await fetch(`/api/docs/${docId}/chunks`)
const chunks = await response.json()
for (const chunk of chunks) {
// 使用 requestIdleCallback 在空闲时间逐片应用
requestIdleCallback(() => {
Y.applyUpdate(ydoc, new Uint8Array(chunk.data))
})
}
}
4.5 坑五:撤销重做与协作编辑的冲突
现象:用户 A 撤销了自己的编辑操作后,用户 B 看到的内容出现不一致。
根因:Yjs 的 UndoManager 默认是基于本地操作历史栈的,而 ProseMirror 的 undo 命令无法区分"自己的操作"和"别人的操作"。
解决:
// 使用 Yjs 原生 UndoManager,限制为只撤销自己的操作
import { UndoManager } from 'yjs'
const undoManager = new UndoManager(ydoc.getText('content'), {
// 只跟踪本地操作
trackedOrigins: new Set([localUserId]),
// 限制历史栈大小,避免内存泄漏
captureTimeout: 500,
deleteFilter: (item) => {
// 过滤掉远端变更,只撤销本地操作
return item.origin === localUserId
},
})
五、性能优化清单
经过一年的迭代,这是我们的优化清单:
| 优化项 | 手段 | 效果 |
|---|---|---|
| 首屏加载 | 文档分片 + requestIdleCallback | 3s → 0.8s |
| 长文档编辑 | 虚拟滚动扩展 | 打字延迟 200ms → 16ms |
| 粘贴大文本 | Web Worker 清洗 | 不阻塞主线程 |
| 协作同步 | Hocuspocus 消息合并 | 减少 60% WebSocket 消息量 |
| 内存泄漏 | NodeView 生命周期管理 | 稳定运行 8+ 小时不增长 |
| 包体积 | 按需引入扩展 | 编辑器 chunk 从 480KB → 120KB |
六、总结
Tiptap 不是最好上手的编辑器框架,但确实是最适合"长期主义"的选择。它的扩展体系让你在需求变更时不至于推到重来,ProseMirror 的 Schema 约束让数据一致性有了保障,Yjs 的 CRDT 让协作编辑不再黑盒。
但我也要说实话:前两个月的踩坑期很痛苦。ProseMirror 的概念模型(Transaction、Plugin、Decoration)需要时间内化,Yjs 的调试工具不够直观,中文输入法相关的问题只能靠社区 issue 和源码阅读来解决。
如果让我给还在犹豫的你一个建议:如果你的编辑器需求只是"可以加粗和插图",选 Quill;如果需要协作编辑和深度业务定制,Tiptap 是目前的最优解。
这篇文章的所有代码都来自我们在 KMS 生产环境的真实实践,希望对你有所帮助。
更多推荐


所有评论(0)