EditorView 与 NodeView(渲染与 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。然后按本篇顺序建立闭环:
view.dispatch、dispatchTransaction与updateState分别做什么- 为什么 DOM diff 和浏览器隐藏状态决定了不能全量重画
- 输入事件与 IME composition 为什么必须先尊重浏览器
- 只有静态 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交给它;否则自动apply并updateState。该方法绑定到 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 区别于「每次 setState 就 innerHTML 全量替换」的玩具编辑器的地方,也是大文档下性能的来源。
输入事件与 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.composing为true表示当前正处于 IME compositionbeforeinput事件理论上可用,但跨浏览器并不可靠- 部分 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 处理子内容更新(当NodeView有contentDOM、或干脆没有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 相对节点起点,root 是 Document | ShadowRoot)。
⚠️ 定义了
selectNode()就必须配套定义deselectNode()来还原它的改动,否则选中样式会残留。
EditorView 的构造与挂载
new EditorView(place, props)place 支持多种挂载策略:
- DOM 节点:编辑器 append 到它内部。
- 函数
fn(editorDom):接收编辑器 DOM,自己决定怎么插入(用于 shadow DOM、iframe 等)。 { mount: domElement }:把mount指向的节点直接用作编辑器 DOM。null:不加入 DOM(用于测试或手动挂载)。
props 是 DirectEditorProps:必填 state,可选 dispatchTransaction,以及 plugins、nodeViews、decorations、handleDOMEvents 等 EditorProps。
⚠️
view.destroy()不会被自动调用 —— 销毁编辑器时必须手动调view.destroy(),否则各NodeView的destroy()不会执行,容易泄漏(事件监听、定时器、第三方组件实例)。在 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 更新。本篇是 dispatch、dispatchTransaction、updateState 的权威说明;dispatchTransaction 是接入外部状态的口子,省略它即得自动模式。智能 DOM diff 保留未变节点,从而守住选区、拼写检查和 IME 等浏览器隐藏状态。NodeView 是静态 HTML 不足时才使用的复杂节点机制,update() 给出复用还是重画的增量控制,contentDOM 决定子内容交给 PM 还是自己接管。所有取舍都服务于一个目标:在 contentEditable 中维持不可变 state 与真实 DOM 之间稳定、可预测、可扩展的同步。
参考
- ProseMirror Guide — The view component:https://prosemirror.net/docs/guide/#view
- ProseMirror Reference — EditorView:https://prosemirror.net/docs/ref/#view.EditorView
- ProseMirror Reference — NodeView:https://prosemirror.net/docs/ref/#view.NodeView
- ProseMirror Reference — EditorProps:https://prosemirror.net/docs/ref/#view.EditorProps
- ProseMirror discuss — contentEditable on Android is the absolute worst:https://discuss.prosemirror.net/t/contenteditable-on-android-is-the-absolute-worst/3810
- ProseMirror discuss — Using with React:https://discuss.prosemirror.net/t/using-with-react/904