前文已分析 EditorState、Selection、Transaction 和插件系统,本篇开始分析 prosemirror-view。view 层负责将不可变的 EditorState 渲染为可编辑 DOM,并将浏览器对 DOM 的改动读回 state。这个过程的入口是 prosemirror-view/src/index.ts 中的 EditorView 类,连同类型声明共八百多行。本文不展开渲染细节,先说明构造过程、props 的组织方式、transaction 的流向、updateState 的调用路径、插件 view 的挂载以及销毁时的清理。参考代码是 prosemirror-view 的 ca4c78e。
系列目录
构造函数做了哪几件事
new EditorView(place, props) 的第一个参数 place 决定编辑器挂载到哪:一个 DOM 节点就 appendChild 进去,一个函数就把创建好的 div 交给它处理,一个 {mount} 对象就把 mount 直接当编辑器的 dom 用(此时 mounted 标记为 true,destroy 时的行为会不同,后面讲)。place 传 null 则不挂进文档,常用于测试。
构造函数按以下顺序初始化,各步骤之间存在依赖:
- 存下 props,取出
props.state作为自己的 state,取出props.plugins作为 directPlugins,并用checkStateComponent逐个校验。然后this.dispatch = this.dispatch.bind(this),让 dispatch 可以脱离实例传来传去。 - 按 place 创建或挂载
this.dom,这是编辑器的最外层元素。 this.editable = getEditable(this),再问updateCursorWrapper要不要为 markCursor 造一个占位 widget。buildNodeViews(this)把直接 props 和各插件贡献的 nodeViews、markViews 合并成一张渲染表(同名键先出现者优先),docViewDesc(...)用当前文档构建出整棵 ViewDesc 描述树并渲染进 dom。此时文档对应的 DOM 已完成渲染。- 创建 DOMObserver 并
start(),它的回调是readDOMChange,负责把浏览器对 DOM 的改动读回成文档变更。接着initInput(this)在 dom 上注册全部事件处理器。观察者和事件都挂在渲染完成之后,否则初始化渲染会被当成外部变更读回来。 - 最后
updatePluginViews()挂载插件的 view。
由此可见,EditorView 持有 state(当前状态)、dom(外层元素)、docView(文档到 DOM 的描述树)、nodeViews(自定义渲染表)、input(事件管线状态)、domObserver(DOM 变更观察者)和 pluginViews(插件 view 实例数组)。后续文章将分别说明这些对象的实现。
props 与 DirectEditorProps
EditorView 的配置分两层。EditorProps 是通用层,这一层的 prop 既能直接传给 view,也能写进插件的 spec.props。按用途大致分四组:事件回调一组,handleKeyDown、handleKeyPress、handleTextInput,点击还有 handleClickOn 与 handleClick 的两段式设计(On 版本从内向外逐节点调用,普通版本在这之后再统一调一次),粘贴拖拽对应 handlePaste、handleDrop、handleScrollToSelection;剪贴板转换一组,transformPastedHTML、transformPastedText、transformPasted 在解析前后依次加工,clipboardParser、clipboardTextParser 决定粘贴内容怎么进文档,clipboardSerializer、clipboardTextSerializer、transformCopied 管复制方向;渲染定制一组,nodeViews、markViews、decorations、attributes;行为开关一组,editable、scrollThreshold、scrollMargin、dragCopies、createSelectionBetween、domParser。DirectEditorProps 在它上面加了三个只能直接传给 view 的字段:state(当前 EditorState)、plugins(直接挂在 view 上的插件)、dispatchTransaction。
直接传入 plugins 有明确限制。构造函数中的 checkStateComponent(src/index.ts)禁止这些插件携带 state 组件,即不能包含 spec.state、spec.filterTransaction 或 spec.appendTransaction,否则抛出 RangeError。这些组件在 EditorState.apply 的路径中执行,而 view 直接持有的插件不参与 state 构建。带状态组件的插件必须放入 state 的 plugins 数组。因此,state.plugins 中的插件可以参与状态和事务处理,directPlugins 中的插件只能提供 props 和 plugin view。
props 的读取有个值得一提的 hack。get props()(src/index.ts)返回前会检查 this._props.state != this.state,如果不相等,就把 _props 浅拷贝一份,把拷贝的 state 字段换成当前 state 再返回。view 内部为了支持外部接管 dispatchTransaction,允许 state 先走一步(view.state 已经换到新 state,_props.state 还是构造时的旧对象),这个 getter 保证外部任何时候读 view.props.state 拿到的都是当前状态。
prop 多了之后需要一个统一的查找规则,someProp(src/index.ts)就是这个入口。它按固定顺序找:先直接 props,再 directPlugins 的 props,再 state.plugins 的 props(按插件顺序),找到第一个非 undefined 的值就用回调处理,回调返回真值就短路返回。getEditable 用它问「有没有哪个 editable 返回了 false」,buildNodeViews 用它把各处的 nodeViews 表合并起来,computeDocDeco 用它收集 attributes。事件分派也走它,插件靠前就能截胡靠后的,这个顺序语义和第 23 篇讲的插件顺序一致。
dispatchTransaction 惯例
EditorView 与外界交换状态的通道只有一个方法:dispatch。它定义在原型上(src/index.ts 末尾):
EditorView.prototype.dispatch = function(tr: Transaction) {
let dispatchTransaction = this._props.dispatchTransaction
if (dispatchTransaction) dispatchTransaction.call(this, tr)
else this.updateState(this.state.apply(tr))
}这两个分支定义了 transaction 的处理方式。未提供 dispatchTransaction 时,view 对内部产生的 transaction 调用 apply 得到新 state,再通过 updateState 更新自身。提供 dispatchTransaction 时,view 将 transaction 交给外部处理,不自行更新状态。外部通常调用 state.apply(tr) 和 view.updateState(newState),也可以在两者之间将 tr 写入 store、发送到协作服务器或丢弃。
因此,EditorView 不要求特定的状态管理方案。无论 state 保存在普通变量、Redux 一类单一 store,还是框架的响应式状态中,均通过这个入口处理。第 24 篇的手写插件使用了外部处理模式:在 dispatchTransaction 中执行 state.apply(tr),再调用 view.updateState(newState),并更新字数统计 UI。后续 collab 篇会展示该模式的完整用法。
配合这个惯例,view.dispatch 在构造时绑定了实例,命令体系拿到的 (state, dispatch, view) 三件套里的 dispatch 就是它,命令只管造 transaction 然后 dispatch,不需要知道状态归谁管。
对外暴露的查询方法
除了状态更新这条主轴,EditorView 上还挂着一批查询方法,都是薄封装,真正实现分散在 viewdesc 和 domcoords 两个文件里,这里先把入口认全。
位置换算是一组。domAtPos(pos, side) 把文档位置翻译成 DOM 节点加偏移,posAtDOM(node, offset, bias) 反过来翻译,两者底层都是 docView 上的 domFromPos 和 posFromDOM,描述树里每个节点记着自己的 DOM 对应物,翻译就是沿树下钻。posAtCoords 和 coordsAtPos 处理屏幕坐标这一头:前者回答「点在这个像素位置相当于点了文档哪里」,后者回答「这个位置在屏幕上画在哪」,nodeDOM(pos) 则直接返回某个位置后面那个节点的 DOM 元素。写自定义 UI 时最常打交道的就是这组,比如把浮层定位到选区上,就是 coordsAtPos 拿矩形再自己摆放。endOfTextblock(dir) 判断选区是否处于文本块在某个方向上的尽头,keymap 类插件用它决定方向键该放行还是拦截。
焦点相关有两个。hasFocus() 在普通浏览器里就是比较 root.activeElement,IE 分支要处理 resize handle 抢走 activeElement 的情况,沿父链检查中间有没有 contentEditable 为 false 的节点。focus() 主动聚焦时会停开观察者夹住 selectionToDOM,和 updateStateInner 里的是同一套防护。root 这个 getter 也值得知道:编辑器可能在 shadow DOM 里,它沿父链找到 Document 或带 host 的 ShadowRoot 并缓存,shadow root 没有 getSelection 时还给它补一个指向 ownerDocument 的实现,取 DOM 选区的代码全部经由 root,对 shadow DOM 透明。
剪贴板相关有三个便捷方法:pasteHTML、pasteText 不走真实剪贴板事件,直接调内部的 doPaste 跑粘贴管线;serializeForClipboard 则暴露复制方向的序列化。这三个方法的存在主要是给测试和外部的自定义粘贴入口用。
updateState 的完整路径
外部入口有三个:updateState(state) 只替换状态,update(props) 替换整套 props,setProps(partial) 合并部分 props。三者最终调用私有方法 updateStateInner(state, prevProps),下面按其执行顺序说明。
第一步处理一个特例:新 state 带着 storedMarks 而编辑器正在 composition(中文输入过程中)时,先 clearComposition 结束输入法会话,因为 storedMarks 要通过 DOM 表达出来,而 composition 期间 DOM 是冻结的。
第二步替换 state,再检查插件和 nodeViews 是否变化。pluginsChanged 比较 state.plugins 数组引用和直接 props.plugins 的引用;nodeViews 或插件变化时,buildNodeViews 重建渲染表,changedNodeViews 逐键比较。比较结果有变化时设置 redraw = true,随后重新构建 docView。
第三步准备装饰。viewDecorations(this) 收集 decorations prop 给的内层装饰,computeDocDeco(this) 造外层那个包住整篇文档的 Decoration.node:固定带上 class: "ProseMirror" 和 contenteditable,再合并 attributes prop 里的值(class 拼接、style 追加、其余属性先到先得),最后补一个 translate: "no" 防止浏览器自动翻译文档内容。编辑器的根元素属性是这么来的。
第四步决定滚动策略,三选一:插件变了且文档也变了,scrollTop 归零(reset);state.scrollToSelection 计数器比旧 state 大,滚到选区(to selection,scrollIntoView 那个 meta 就是靠递增这个计数器起作用的);其余情况保持原位(preserve),必要时用 storeScrollPos 记下锚点元素的位置,更新完再 resetScrollPos 补回滚动差,抵消 DOM 变动引起的跳动。
第五步更新文档和选区。updateDoc 由 redraw 或 this.docView.matchesNode(...) 判定;文档、装饰和当前描述树不匹配时需要更新文档。updateDoc 或选区变化都会设置 updateSel。进入更新块前先调用 domObserver.stop(),使即将执行的 DOM 修改不被观察者记录;修改完成后再调用 start(),从而避免 state 到 DOM 的更新回流为 transaction。文档更新优先调用 this.docView.update(...);其返回 false 或 redraw 为真时,销毁并重建描述树。选区默认通过 selectionToDOM 同步。鼠标正在拖动选词、DOM 选区与观察者记录的选区一致且 anchor 位置未变时,不更新 DOM 选区,只用 syncNodeSelection 同步 NodeSelection 的 class。Chrome 和 IE 在两次选区位于不同上下文时可能误报选区,selectionContextChanged 会比较共享深度上的起点并强制重设。Chrome 在更新写入选区所在节点后也可能错误上报选区,代码以 trackWrites 记录更新前的 focusNode,更新后检查它是否仍在 DOM 中;不在时同样强制重设。第 30 篇将单独分析选区同步。
第六步调用 updatePluginViews(prev) 更新插件 view;文档变更移动拖拽中的节点时,以 updateDraggedNode 修正 dragging 的位置;最后按照第四步确定的策略处理滚动。
该路径依次执行比对、停止观察者、更新 DOM、更新选区、重新启动观察者、更新插件 view 和处理滚动。其中,停止并重新启动观察者包围 DOM 更新,避免 state 到 DOM 的更新再次被识别为外部 DOM 变更。
pluginViews 的挂载与更新
第 22 篇讲过插件可以带一个 spec.view,返回一个 plugin view 对象。它在 EditorView 这边的落点是 pluginViews 数组和 updatePluginViews 方法(src/index.ts),逻辑分两个分支:
- 首次调用、state.plugins 引用变了、或 directPlugins 引用变了:先
destroyPluginViews把旧实例逐个destroy(),然后按 directPlugins 在前、state.plugins 在后的顺序,给每个带spec.view的插件调spec.view(this)创建新实例。注意插件集合一变是全部重建,不做 diff。 - 其余情况:对每个实例调
update(this, prevState),把新旧两个状态交给插件自己处理。
StateField 和 plugin view 分别承担插件的两类职责:StateField 跟着 apply 走、纯函数、可序列化;plugin view 跟着 view 走、可以碰 DOM、可以挂定时器和外部订阅。需要一个浮层跟着选区动、需要监听外部数据源往编辑器里塞东西,都是 plugin view 的场景。它没有必须实现的接口,destroy 和 update 都是可选的,但实践里几乎每个 plugin view 都要实现 destroy 来清理自己加进文档的节点和监听器。
destroy 的清理面
destroy()(src/index.ts)的清理清单对应构造函数做的事,但有几处值得注意的细节:
destroy() {
if (!this.docView) return
destroyInput(this)
this.destroyPluginViews()
if (this.mounted) {
this.docView.update(this.state.doc, [], viewDecorations(this), this)
this.dom.textContent = ""
} else if (this.dom.parentNode) {
this.dom.parentNode.removeChild(this.dom)
}
this.docView.destroy()
;(this as any).docView = null
clearReusedRange()
}destroyInput 里先 domObserver.stop() 断开 MutationObserver 和 selectionchange 监听,再逐个移除 initInput 注册的事件处理器,清掉 composition 相关的定时器。destroyPluginViews 给每个 plugin view 调用 destroy。
随后根据挂载方式清理。以 {mount} 方式挂载时,dom 由外部提供,不能删除节点,因此先以空外层装饰重新渲染文档,移除 computeDocDeco 写入根元素的 ProseMirror class 和 contenteditable,再通过 textContent = "" 清空内容后交还元素。普通挂载则从父节点移除 dom。最后 docView.destroy() 销毁整棵描述树,并调用自定义 node view 的 destroy;随后将 docView 设为 null,isDestroyed 据此判断销毁状态,clearReusedRange 清理复用的 Range 对象。开头的 if (!this.docView) return 保证 destroy 可重复调用。
后续内容
EditorView 负责初始化 state、docView、观察者、事件处理器和插件 view;通过两层 props 与 someProp 组织配置;通过 dispatchTransaction 将状态更新交由 view 或外部管理;updateStateInner 在更新 DOM 时暂停观察者;destroy 则根据挂载方式释放 DOM 和相关资源。
下一篇分析 viewdesc.ts,说明 docView 描述树如何从文档构建,以及 NodeViewDesc、MarkView 和 TextViewDesc 的职责。
