ProseMirror富文本编辑器实战中的核心API与协作编辑踩坑总结

司马梓桑 交互 阅读 831
赞 13 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年下半年接了个内部知识库系统,需求挺典型:支持富文本编辑、版本对比、协作评论、导出 PDF。一开始团队讨论用 Quill,毕竟文档多、插件熟;但第三天我就把 demo 删了——因为要加「表格跨行合并」和「自定义节点拖拽排序」,Quill 的 Schema 太死,改半天发现得重写整个 BlockView。后来翻到 ProseMirror 官网那个「A collaborative editing system」的标语,顺手 clone 了几个例子跑起来,当天晚上就决定切过去。

ProseMirror富文本编辑器实战中的核心API与协作编辑踩坑总结

不是因为它多优雅,而是它真敢让你动底层:NodeSpec、MarkSpec、Transform、Plugin、Command……全是可替换、可拦截、可 patch 的。我心想,反正也得自己造轮子,不如在 ProseMirror 上造。

最大的坑:性能问题

项目上线前两周,测试同学提了个 bug:「输入一段长文本(3000 字左右),光标移动明显卡顿,删除最后一行要等半秒」。我第一反应是「是不是没用 debounce?」——结果加了也没用。Chrome Performance 面板一录,90% 时间耗在 view.dom.contains()view.posAtCoords() 里,再往下挖,发现是每次 keydown 后触发的 update 流程中,ProseMirror 默认会重新计算整个 document 的 layout 信息,哪怕只是光标跳了一下。

开始没想到这么狠。后来查 issue 发现这是个老问题:ProseMirror 1.0+ 默认启用 trackWrites: true,配合 decorations 或自定义 nodeViews 时,会高频调用 DOM 查询。我们用了自定义 code_block 节点 + 行号渲染,正好踩中这个组合拳。

解决方案不是关掉 trackWrites(那会丢光标同步),而是把装饰逻辑从 nodeView 搬到 decorateNode + DecorationSet,并手动控制更新时机:

// 在 editor view 初始化时传入
const view = new EditorView(dom, {
  state: EditorState.create({
    schema,
    plugins: [
      // 其他插件...
      decorationPlugin(), // 自定义插件,只在 contentChanged 时更新 decoration
    ],
  }),
  // 关键:禁用自动 track,手动控制
  dispatchTransaction: (tr) => {
    const newState = view.state.apply(tr)
    view.updateState(newState)
    // 只在真正需要时更新 decorations(比如粘贴、格式切换)
    if (tr.docChanged || tr.selectionSet) {
      updateDecorations(view)
    }
  }
})

另外,所有自定义 nodeView 的 dom 里,禁止用 querySelectorAll 做全量遍历——我们原来在 code block 里每次渲染都扫一遍 <span class="token"> 加 tooltip,改成用 MutationObserver 监听新增节点,只处理新进来的 token,帧率立马稳住了。

协作编辑的“伪实时”妥协

后端用的是 Yjs 做 CRDT 同步,本地状态用 y-prosemirror 插件桥接。理论上应该开箱即用,但实际跑起来发现:当两人同时编辑同一段文字,会出现「对方光标闪一下就消失」,或者「插入字符后位置偏移」。

折腾了半天发现不是 Yjs 的问题,而是 ProseMirror 的 selection 更新和 transaction 应用顺序不对。Yjs 的 update 事件是异步批量发的,而 ProseMirror 默认会在每个 transaction 后立即调用 setSelection,但此时 DOM 还没完全 render 完,导致光标定位错乱。

最终方案是加了一层节流:

// 在 y-prosemirror 插件基础上 patch
const originalApply = pluginKey.getState
pluginKey.getState = (state) => {
  const val = originalApply(state)
  // 缓存 selection,延迟 16ms 再应用,确保 DOM 已更新
  if (val && val.selection) {
    setTimeout(() => {
      if (view.hasFocus()) {
        view.dispatch(view.state.tr.setSelection(val.selection))
      }
    }, 16)
  }
  return val
}

效果是「光标不再乱跳」,但代价是协作光标会有 ~16ms 延迟。产品经理说「比卡顿强」,我们就上线了。现在回头看,其实可以结合 requestIdleCallback 做更精细调度,但当时时间紧,先保主流程。

最终的解决方案

整个编辑器最后打包出来约 280KB(gzip 后 95KB),比预估多了 15KB,主要是 prosemirror-model + prosemirror-transform + 我们写的 7 个自定义 node(表格、mermaid、toc、引用块、脚注、代码块、数学公式)加起来撑的。压缩空间有,但没动——因为改 tree-shaking 风险大,且用户反馈「打开快、打字不卡」,就没碰。

核心功能都落地了:表格支持 Ctrl+方向键调整行列、mermaid 图实时预览(用 mermaid.render 异步渲染并缓存 SVG)、导出 PDF 用的是 prosemirror-export-pdf + 自定义样式表(注意:它的 page-break-inside: avoid 在 Firefox 下不生效,我们补了 -moz-page-break-inside: avoid)。

唯一没搞定的是「移动端长按选词后无法触发自定义菜单」。iOS Safari 对 selectionchange 事件触发时机很怪,试了 touchstart + getSelection() 组合,还是偶尔拿不到 range。最后用了一个 dirty hack:在 touchendsetTimeout(() => { getSelection() }, 100),成功率提到 95% 以上。够用,就没深究。

回顾与反思

ProseMirror 是个好工具,但它不是「开箱即用」的富文本编辑器,而是一个「富文本编辑器框架」。你得自己搭 schema、自己写 command、自己管 history、自己兜底协作、自己优化性能。它给你的自由度,和它要求你承担的责任,是等价的。

做得好的地方:

  • 自定义节点扩展性极强,加一个新类型平均只要 1 小时(schema + nodeView + inputRules)
  • command 系统真的清晰,chainCommands + toggleMark 组合写快捷键几乎不用 debug
  • 文档虽然硬核,但每个 API 的 TS 类型定义都很准,VS Code 里 hover 看注释基本能猜出怎么用

还能优化的地方:

  • Table 插件太弱,prosemirror-tables 不支持合并单元格,我们自己写了 mergeCellsCommand,但没做 UI 按钮,靠快捷键 Ctrl+Alt+M 触发——后期应该补个 toolbar 按钮
  • 没有内置 spellcheck 支持,我们直接开了浏览器原生的 spellcheck="true",但中文纠错基本无效,这块打算下个迭代集成 cSpell 的 client-side 版本
  • 打印样式适配花了不少时间,@media print 里要手动 reset 所有 user-select: nonepointer-events: none,不然 PDF 里按钮和图标会变灰

以上是我踩坑后的总结,希望对你有帮助。如果你也在用 ProseMirror 做表格合并、移动端长按选区、或 Yjs 协作光标同步,欢迎评论区交流——有些坑,我可能还没踩到,但愿意一起填。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论