返回
创建于
状态
公开

Tiptap / ProseMirror 开发踩坑补完计划:自定义 NodeView 更新与状态同步的两个陷阱

在使用 Tiptap 和 NodeView 开发自定义的富文本节点(比如基于 KaTeX 和 MathLive 的公式编辑器)的过程中,我们免不了要和 ProseMirror 底层的 Transactions 及原生的 DOM 事件打交道。

表面上看,Tiptap 封装的 API 让大多数开发都变得像调用对象方法一样简单,但当交互一旦变得复杂(例如:涉及弹窗表单编辑已有节点内容),我们就极易踩到下面这两个经典的坑中。


坑位一:使用 chain().setNodeSelection() 紧接更新命令导致不生效

场景还原

当外部组件(例如一个放置编辑器的弹窗 Dialog)确认修改公式后,我们需要根据存下来的定位 pos,去更新编辑器节点里的 latex 属性。 直觉上,Tiptap 支持便捷的链式调用,所以我们会写出这样的代码:

typescript
1editor.value
2  .chain()
3  .setNodeSelection(pos)
4  .updateInlineMath({ latex }) // 底层类似 updateAttributes
5  .focus()
6  .run()

chain command failure

为什么会失败?

这段代码不仅看似正确,而且控制台也没有什么报错。但实际上当我们点击确认时,原有的公式没有任何变化

在这个链式调度(chain)中,setNodeSelection(pos) 记录了选中意图,但它是一个即时的内部事务操作步骤。而紧接其后的命令(例如修改属性操作)依赖最新的 Selection 去计算要变更节点的边界和属性信息。因为整个 chain 还没有合并完成(.run() 这个触发器还没走完),ProseMirror 的 Document State 实际上处于一种“未完全提交”的脏状态中间层。 这个时候接着调用去更新属性的命令往往抓取不到最新正确的选区 Node,从而导致修改失效。

解决方案

既然高级命令封装的上下文同步存在这层真空期,我们不妨采用最为稳妥的降维打击——直接操作底层的 ProseMirror Transaction

typescript
1editor.value
2  .chain()
3  .command(({ tr }) => {
4    // 降级使用 ProseMirror 原生的 Node 渲染更新 API:
5    // 精确命中指定 pos 处并替换属性 (Attrs)
6    tr.setNodeMarkup(pos, undefined, { latex })
7    return true // 告诉 chain 命令已成功执行
8  })
9  .setNodeSelection(pos)
10  .focus()
11  .run()

利用 tr.setNodeMarkup 可以实现非常稳定、指名道姓的单点修改,不必再强行依赖 Selection 的配合,一切迎刃而解。


坑位二:NodeView 里的闭包陷阱(点击老是获取旧数据)

场景还原

addNodeView 中,当我们通过原生的 DOM 绑定点击事件,把节点选中的内容作为事件参数发射出去:

typescript
1// 行内公式节点定义
2addNodeView() {
3  return ({ node, getPos }) => {
4    const dom = document.createElement('span')
5    dom.innerHTML = renderLatex(node.attrs.latex)
6    
7    dom.addEventListener('click', (e) => {
8      // 触发外部的回调,将闭包中的 node 相关数据暴露出去了
9      this.options.onClick?.(node, pos)
10    })
11
12    return {
13      dom,
14      update: (updatedNode) => {
15        // ProseMirror 的 DOM 更新探测
16        dom.setAttribute('data-latex', updatedNode.attrs.latex)
17        dom.innerHTML = renderLatex(updatedNode.attrs.latex)
18        return true
19      },
20    }
21  }
22}

closure trap

为什么会失败?

如果你插入公式后马上点击它,它是好的。但如果你编辑和修改了它(触发 update 钩子,并在屏幕上看到内容如期变化了),再次点击它时,神奇的事情发生了——传入弹框里的 latex 又变回了你第一次输入的内容

罪魁祸首还是 Javascript 最经典的问题:闭包(Closure)。在 DOM 的 addEventListener('click', ...) 回调函数内部,捕获的是初次执行 addNodeView 函数时的原始 node 变量。即使节点后续经过事务修改并且触发了返回对象中的 update 钩子传入了崭新的 updatedNode,点击回调里面的 node 依然指向第一次初始化时的那片旧内存!

解决方案

打破闭包影响的最好方式之一,就是去寻找唯一真理层——将当前更新同步到 DOM 的 Attribute 上,并在回调事件里动态获取

因为我们在 update 内部已经通过 dom.setAttribute('data-latex', updatedNode.attrs.latex)DOM 进行了内容下发。因此点击事件回调内部不再去闭包中捞过时的 node.attrs,而是直接解剖最新的 DOM:

typescript
1dom.addEventListener('click', (e) => {
2  e.preventDefault()
3  e.stopPropagation()
4  const pos = typeof getPos === 'function' ? getPos() ?? 0 : 0
5  
6  // 从 dom.getAttribute 取最新的属性映射,否则降级取原始 node 的属性
7  const currentLatex = dom.getAttribute('data-latex') || node.attrs.latex;
8  
9  this.options.onClick?.({ attrs: { latex: currentLatex } }, pos)
10})

这不仅避免了维护冗余的变量地址更新状态,而且借助了 update 本身就会更新最新数据的机制,完美填上了由于 NodeView 引起的生命周期更新破损问题。


总结

我们在基于 Tiptap 做高度自定义编辑器交互的扩展时:

  1. 涉及外部精准修改节点 时,如果在 Selection 与 Update 混搭引发上下文错乱,优先推荐下钻去操作底层 tr.setNodeMarkup
  2. 在自渲染 NodeView 钩子挂载事件 时,务必提防 node 的闭包缓存,多利用 DOM Attributes 转存关键态数据,或者使用闭包外部 ref 的手段同步最新的节点数据引用。