引言

一年前,我所在的 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 生产环境的真实实践,希望对你有所帮助。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐