EditorViewNodeView(渲染与 DOM 同步)

返回 🗂 富文本编辑专题 · 相关私有笔记

EditorView 是 ProseMirror 的「视图层」:它接收一份不可变的 EditorState,把文档渲染成一棵 contentEditable 的 DOM 树,同时监听用户在 DOM 上的操作并把它们翻译回 Transaction。它是整个数据流的下游端点:state 流进来、transaction 流出去,中间夹着一次最小化的 DOM 更新。理解 View 的关键不是 API 清单,而是两个内核命题:为什么 View 必须受控,以及为什么浏览器的 contentEditable 需要被克制地接管,而不是彻底重画。

本笔记承接 State 与 Transaction 的不可变状态模型,聚焦渲染与 DOM 同步;Decoration 的细节归 Plugin 与 Decoration,这里只讲它与 NodeView 的协作。

阅读边界:View 是派发与 DOM 同步的权威说明

先读 State 与 Transaction,至少理解 state.apply(tr)、Selection 与 transaction。然后按本篇顺序建立闭环:

  1. view.dispatchdispatchTransactionupdateState 分别做什么
  2. 为什么 DOM diff 和浏览器隐藏状态决定了不能全量重画
  3. 输入事件与 IME composition 为什么必须先尊重浏览器
  4. 只有静态 DOM 不够时,何时使用 NodeView

State 文只解释新 state 如何产生;本篇是派发、DOM 同步、事件和 IME 的唯一权威说明。Plugin 的状态、Decoration 语义与事务 hooks 归 Plugin 与 Decoration

View 是「受控组件」:state 进、transaction 出

EditorView 不原地修改 document state。它持有当前 EditorState 的引用,并在收到下一份 state 时用 updateState 替换这个引用;任何文档变化仍只能通过 Transaction 表达。这条单向数据流是它和传统 contentEditable 编辑器的根本区别:后者直接在 DOM 上读写、再用一堆命令式回调去“补救”浏览器的副作用,逻辑很快变成回调地狱;ProseMirror 把这条线性化成“state → 渲染 → 用户操作 → transaction → 新 state”。这是一个清晰的环形数据流(cyclic data flow),与“一堆命令式事件处理器织成的网状数据流”相对。

围绕 dispatchTransaction 这个 prop,View 有两种工作方式:

  • 自动模式(默认):你不提供 dispatchTransaction,view.dispatch(tr) 会自动 apply(tr) 得到新 state,再调用 updateState 重渲染。注意:即便是默认模式,View 依然是「受控」的 —— state 仍是唯一事实源,只是 apply 与重渲染由 View 替你串起来。适合独立使用,开箱即用。
  • 手动接管(自定义 dispatch):你提供 dispatchTransaction(tr),所有 transaction 都先经过你。你可以选择 apply、过滤、或先把它送进 Redux / Pinia 等中心化 store,再回头调用 view.updateState,把 PM 的环形数据流嵌进应用的更大循环里。
// 手动接管:把 transaction 接到外部 store
let appState = { editor: EditorState.create({ schema }), score: 0 }
 
let view = new EditorView(document.body, {
  state: appState.editor,
  dispatchTransaction(tr) {
    // 1. 自己决定如何 apply
    appState.editor = appState.editor.apply(tr)
    // 2. 必须自己回调 updateState,View 才会重渲染
    view.updateState(appState.editor)
    // 3. 此处可同步派发给 Redux、更新其它应用状态等
  },
})

与 React 受控组件的类比与区别

「受控」一词来自 React <input value={...} onChange={...}>:数据是单一事实源,UI 永远渲染那份数据,变化必须经由回调上抛。EditorView精神上完全一致 —— state 是单一事实源,变化经由 dispatch 上抛。但机制不同:

  • React 是声明式的,prop 一变就重新 render、由 reconciler 算 diff;你不主动触发渲染。
  • ProseMirror 是命令式触发的:updateState(newState) 是显式的「请用这份新 state 重画」调用。无论走自动路径还是你自己的 dispatchTransaction,最终都要落到 updateState
  • 事件模型也不同:React 是声明式回调;PM 的 handler 用「返回 true 表示拦截、阻止默认处理」的早退语义,顺序敏感(详见后文事件三件套)。

这也解释了为什么把 ProseMirror 直接塞进 React 受控渲染里会别扭:React 想用 value 受控重渲染,PM 想自己管 contentEditable 的最小更新,两套「受控」会互相打架(社区长期讨论的 react-prosemirror 难点正源于此)。常见做法是让 React 只负责挂载容器,把 EditorView 当作一个「不受 React reconciliation 管」的 imperative 子系统。

dispatch / dispatchTransaction / updateState 三者的分工

这三个名字容易混,但分工非常清晰,是理解受控视图的关键:

方法角色谁来调
view.dispatch(tr)对外 API,发起一个 transaction命令、输入规则、你的代码
dispatchTransaction(tr) prop可选拦截钩子dispatch 内部调用
view.updateState(state)底层:替换 view.state 并重渲染 DOM自动路径或你在 dispatchTransaction 里手动调
  • dispatch(tr):如果 props 里定义了 dispatchTransaction,就把 tr 交给它;否则自动 applyupdateState。该方法绑定到 view 实例,可以放心地到处传递。
  • dispatchTransaction拦截点,不是必需。一旦定义,你就有责任在需要时自己调用 updateState,否则视图不会反映变化。
  • updateState(newState) 是真正的「换 state + 重画」入口,任何路径最终都经过它。

这套分层把「用户 API / 拦截逻辑 / 实际状态变更」三件事解耦,既能让独立使用者享受简单默认,又能让需要中心化状态管理的应用从中间插一脚。

⚠️ updateState 不等于 dispatch 一个 transaction。它只是「拿这份 state 重画」,不会创建 transaction、不会驱动 plugin 的状态机(plugin state 不会更新)。要让 plugin 跟上,必须走真正的 transaction。直接改 view.state 也无效 —— 应当一律走 updateState(更常见是走 transaction)。

DOM diff 与最小化更新:为什么不重画

updateState 被调用时,View 不会重画整棵 DOM。它拿旧文档树和新文档树做对比,沿两棵树往下走,定位发生变化的节点,只动受影响的 DOM 子树;没变的节点原封不动。树形结构让 diff 高效:只重建变化的那部分。

为什么如此执着于「保留未变 DOM」?因为 contentEditable 里有大量浏览器自己管理、JS 读不到也存不住的隐藏状态:

  • 光标 / selection 的精确位置(尤其 selection 内部的 caret)
  • 上下箭头移动时记住的「目标横坐标」(goal column)
  • IME composition 的进行中状态
  • 拼写检查的下划线高亮、上下文

一旦把对应 DOM 重建,这些状态就被销毁,用户体验断裂。所以 PM 的策略是:

  • 用户刚敲进去的文字,浏览器已经替你写进 DOM 了 —— 此时 View 根本不动 DOM,只是回头重新解析(re-parse)那段文本来同步 state。这也保住了拼写检查、移动端自动大写等浏览器原生特性。
  • DOM selection 只在与 state 不一致时才更新,避免无谓地搅动浏览器的隐藏选区状态。

这正是 ProseMirror 区别于「每次 setStateinnerHTML 全量替换」的玩具编辑器的地方,也是大文档下性能的来源。

输入事件与 IME / composition:先尊重浏览器,再定制渲染

EditorProps 提供三类层次不同的事件钩子,服务不同需求。共同的解析规则是:handler 从基础 props 开始、再按顺序遍历各 plugin,逐个调用,直到某个返回 true 为止。

  • handleDOMEvents:{ 事件名: (view, event) => boolean } 的对象,在 PM 处理事件之前被调用,是最原始的拦截层。你必须自己调 preventDefault(),这点和其它 handler 不同,PM 不替你调。
  • handleKeyDown(view, event):keydown 事件,服务键盘快捷键与 input rule。返回 true 抑制默认处理,并让 PM 替你调 preventDefault
  • handleTextInput(view, from, to, text, deflt):用户直接输入文本时,非粘贴、非键命令调用。返回 true 抑制插入;deflt() 返回默认的插入 transaction。它与 keydown 解耦,正是为了正确处理 IME 与粘贴。
// 把 "---" 自动替换成 em-dash
let view = new EditorView(document.body, {
  state,
  handleTextInput(view, from, to, text, deflt) {
    if (text === "-" && view.state.doc.textBetween(from - 2, from) === "--") {
      let tr = view.state.tr
      tr.replaceWith(from - 2, from, view.state.schema.text("—"))
      view.dispatch(tr)
      return true // 抑制默认插入
    }
    return false // 走默认行为
  },
})

为什么要分三层?原始 DOM 事件需要最早的拦截点(handleDOMEvents);键盘快捷键有自己的语义(handleKeyDown);用户文本录入必须从 keydown 解耦,才能在 IME / 粘贴下正确工作(handleTextInput)。这套“返回 true 抑制默认”的回调模型,关键在于顺序与早退语义。

composition(CJK 等语言的 IME 输入)是 contentEditable 里最脆弱的环节。用户正在用 IME 组字时,任何对光标附近 DOM 的改动都会打断 composition:重复文字、丢字,甚至彻底破坏输入法。

ProseMirror 的核心策略是:composition 进行期间,冻结光标所在的文本节点,在 composition 结束前不更新它,把组字过程交给浏览器;直到 composition 结束、或别的 transaction 改了文档时,再同步 state。

  • view.composingtrue 表示当前正处于 IME composition
  • beforeinput 事件理论上可用,但跨浏览器并不可靠
  • 部分 IME(如拼音)会把光标放在已组字符之前,过早改动 DOM 容易意外结束 composition
  • composition 期间的 DOM mutation 必须和真正的用户编辑小心区分,否则 re-parse 会搅乱 IME 状态
  • 默认情况下,handleTextInput 在 composition 期间不会被调用;不要为了“统一输入处理”而强行从 keydown 推断文本
let view = new EditorView(document.body, {
  state,
  handleDOMEvents: {
    compositionstart() {
      return false // 交给 PM 处理
    },
    compositionend() {
      return false // PM 会 re-parse DOM 同步 state
    },
  },
})
 
if (view.composing) console.log("正处于 IME composition")

把事件和 IME 放在 NodeView 前,是因为任何自定义渲染都必须先服从这些浏览器输入约束。Android 的 contentEditable 尤其需要真机和真实输入法验证。这也是 React 受控 <input> 在 composition 下仍会遇到困难的原因:浏览器必须暂时主导组字,而应用只能在合适时机重新同步状态。

NodeView:对单类节点的自定义渲染与更新控制

不是所有节点都能用静态 HTML 表达:可调整大小的图片、嵌入式 widget、表单、甚至一个 React / Vue 组件。NodeView 就是为这类「复杂 / 交互式节点」准备的逃生舱 —— 它是一个对象(或构造函数),让你接管某个节点类型的渲染与更新。

一个 NodeView 的核心成员:

  • dom:代表该节点的最外层 DOM 元素。
  • contentDOM(可选):PM 把该节点的子节点渲染进这个元素;省略它则子内容由你全权负责。
  • update(node, decorations, innerDecorations):节点需要更新时被调用。返回 true 表示「我处理了」,返回 false 让 View 走完整重画。这是性能与状态保留的关键开关 —— 只是属性变化的节点通常可以 update 后返回 true,避免重建。
  • destroy():节点被移除时的清理。
  • stopEvent(event):返回 true 阻止编辑器处理从该节点冒泡上来的事件(如图片 / widget 上的点击)。
  • ignoreMutation(mutation):返回 true 告诉编辑器忽略该节点内部的 DOM 变动(当你自己在管 DOM 时必需)。
// 图片节点:点击交给自己处理,不让编辑器插手
let view = new EditorView(document.body, {
  state,
  nodeViews: {
    image(node) { return new ImageView(node) },
  },
})
 
class ImageView {
  constructor(node) {
    this.dom = document.createElement("img")
    this.dom.src = node.attrs.src
    this.dom.addEventListener("click", (e) => {
      console.log("Image clicked")
      e.preventDefault()
    })
  }
  // 阻止编辑器把这次点击当成选区操作
  stopEvent() { return true }
}

update() 的设计意义:复用还是重画

update() 把「是否重建 DOM」的决定权交给了你,这是 NodeView 最有价值的能力。复杂节点往往携带内部状态(widget 内部选区、动画进度、第三方组件实例),全量重画会把它们一起冲掉。让 update 返回 true 复用现有 dom,就能在节点属性变化时做增量更新而不丢失内部状态。

update() 收到的 node 不一定和构造时的 node 相同(同位置可能换了内容或属性),所以第一件事永远是校验:能处理就更新并返回 true,不能处理(类型不符,或属性变化大到必须重建)就返回 false 触发重画。

class ParagraphView {
  constructor(node) {
    this.node = node
    // contentDOM = dom:子节点直接渲染进最外层元素
    this.dom = this.contentDOM = document.createElement("p")
    if (node.content.size == 0) this.dom.classList.add("empty")
  }
 
  update(node) {
    if (node.type.name !== "paragraph") return false // 类型不符 → 重画
    if (node.content.size > 0) this.dom.classList.remove("empty")
    else this.dom.classList.add("empty")
    this.node = node
    return true // 复用 DOM;PM 会通过 contentDOM 更新子内容
  }
 
  destroy() { /* 需要时清理 */ }
}

默认情况下,update() 只在「同一位置出现同类型节点」时被调用。设置 multiType: true 后,任何类型出现在该位置都会调用 update(),由你检查 node.type 并对不能处理的类型返回 false。这让一个通用 NodeView 构造器能服务多种节点类型(如各类 embed 用同一个「widget」视图),代价是 update 里每次都得校验类型,因为节点可能换了类型。

contentDOM:把子内容交给 PM,还是自己接管

NodeView 的两种内容管理模式,是「易用」与「灵活」之间的取舍:

  • 定义了 contentDOM:PM 自动把节点的子节点渲染进这个元素,并通过 diff 处理子内容更新(当 NodeViewcontentDOM、或干脆没有 dom 时,子节点的更新由 PM 负责)。contentDOM 在构造时设定一次、跨更新保持不变,PM 用它定位「子节点该画在哪」。绝大多数场景应该用它。
  • 没定义 contentDOM:节点内容成为编辑器眼里的「黑盒」,你必须自己渲染并管理全部子内容。适合内容不是简单子元素、需要复杂布局的场景。

混合用法也成立:外层是自定义 widget,但某个内部元素作为可编辑内容的 contentDOM。注意:contentDOM 必须是 dom 的后代,否则 PM 找不到它,渲染会崩。

与 Decoration 的交接:跨链,不是本篇主线

update(node, decorations, innerDecorations) 的后两个参数来自 Plugin:前者作用在节点外层,后者作用在节点内容。NodeView 的职责只有一条:不要在自定义渲染时丢掉本应显示的装饰。

  • contentDOM 时,落在该范围内的 decoration 会由 PM 自动应用
  • 没有 contentDOM 时,NodeView 必须自己处理 innerDecorations

Decoration 为什么不进入文档、三种类型如何选择、DecorationSet 如何映射,统一在 Plugin 与 Decoration。首次阅读本篇时可以先跳过这条跨链。

自己管 DOM 时:ignoreMutation 的契约

如果 NodeView 自己往内部写了 DOM,编辑器的 DOM 观察器会看到这些变动、试图把它们 re-parse 回 state —— 这会打架。ignoreMutation 就是用来声明「这块 DOM 归我管,别理它」:返回 true 表示「可以安全忽略」,返回 false 表示「编辑器应当重读选区或重新解析这块区域」。

class CustomWidgetView {
  constructor(node, view, getPos) {
    this.node = node; this.view = view; this.getPos = getPos
    this.dom = document.createElement("div")
    this.dom.contentEditable = "false"
    this.content = document.createElement("div")
    this.dom.appendChild(this.content)
    this.renderContent()
  }
  renderContent() { this.content.innerHTML = `<p>Custom: ${this.node.attrs.data}</p>` }
  update(node) {
    if (node.type !== this.node.type) return false
    this.node = node; this.renderContent(); return true
  }
  // 自管区域内的 mutation 一律忽略
  ignoreMutation(mutation) { return this.content.contains(mutation.target) }
}

⚠️ ignoreMutation 的参数是 ViewMutationRecord(即 MutationRecord | {type: "selection", target: DOMNode}),所以判断要兼顾选区类型的 mutation。返回 true 等于让 PM「信任你的 DOM」。如果你撒谎 —— DOM 实际发生了 PM 应当知道的变化却被忽略 —— state 与 DOM 会失去同步。这是把双刃剑。

节点级选区:selectNode / deselectNode / setSelection

当一个节点被整体选中(node selection,而非选中节点内的文本)时,若 NodeView 定义了 selectNode() 就会被调用,用来给出视觉反馈(高亮、边框);deselectNode() 撤销之。它们覆盖默认的选区样式,适合需要自定义选中 UI 的节点(可调整大小的图片、引用块)。

class ImageView {
  // ...
  selectNode() { this.dom.classList.add("ProseMirror-selectednode") }
  deselectNode() { this.dom.classList.remove("ProseMirror-selectednode") }
}

当节点内部有文本选区时,setSelection(anchor, head, root) 让你自定义节点内的光标 / 选区定位(anchor / head 相对节点起点,rootDocument | ShadowRoot)。

⚠️ 定义了 selectNode()必须配套定义 deselectNode() 来还原它的改动,否则选中样式会残留。

EditorView 的构造与挂载

new EditorView(place, props)

place 支持多种挂载策略:

  • DOM 节点:编辑器 append 到它内部。
  • 函数 fn(editorDom):接收编辑器 DOM,自己决定怎么插入(用于 shadow DOM、iframe 等)。
  • { mount: domElement }:把 mount 指向的节点直接用作编辑器 DOM。
  • null:不加入 DOM(用于测试或手动挂载)。

propsDirectEditorProps:必填 state,可选 dispatchTransaction,以及 pluginsnodeViewsdecorationshandleDOMEventsEditorProps

⚠️ view.destroy() 不会被自动调用 —— 销毁编辑器时必须手动调 view.destroy(),否则各 NodeViewdestroy() 不会执行,容易泄漏(事件监听、定时器、第三方组件实例)。在 React / Vue 里,这通常对应卸载时的清理钩子。

易错点速查

  • contentDOM 必须是 dom 的后代,否则 PM 找不到它,渲染崩溃。
  • 省略 contentDOM 但节点有子内容时,你必须自己渲染并管理子内容,PM 不会代劳。
  • update() 永远先校验(类型 + 是否可复用),返回 false 才会触发完整重画;漏判会导致渲染错乱。
  • handleDOMEvents 的 handler 里你必须自己调 preventDefault()(不同于其它 handler)。
  • IME composition 期间绝不要碰光标处的文本节点,否则破坏组字、重复或丢字。
  • updateState ≠ dispatch transaction:前者只重画,不会更新 plugin state。
  • 定义了 selectNode() 就必须定义 deselectNode()
  • ignoreMutation 返回 true 是「信任契约」,撒谎会破坏 state-DOM 同步;注意参数是 ViewMutationRecord,可能是选区类型的 mutation。
  • view.state 只读,一律走 updateState(更常见是走 transaction)。
  • Decoration 范围部分重叠时可能产生意外的级联效果,谨慎设计范围。

一句话总结

EditorView 是受控组件:state 流进、transaction 流出,中间做最小化 DOM 更新。本篇是 dispatchdispatchTransactionupdateState 的权威说明;dispatchTransaction 是接入外部状态的口子,省略它即得自动模式。智能 DOM diff 保留未变节点,从而守住选区、拼写检查和 IME 等浏览器隐藏状态。NodeView 是静态 HTML 不足时才使用的复杂节点机制,update() 给出复用还是重画的增量控制,contentDOM 决定子内容交给 PM 还是自己接管。所有取舍都服务于一个目标:在 contentEditable 中维持不可变 state 与真实 DOM 之间稳定、可预测、可扩展的同步。

参考