ProseMirror view(上):EditorView,状态与 DOM 之间的桥

5 分钟阅读
·

state 阶段读完了 EditorState、Selection、Transaction 和插件系统,从这篇开始进入 prosemirror-view。view 是核心四层里代码量最大的一层,它要回答的问题是:一个不可变的 EditorState,怎么变成屏幕上可编辑的 DOM,浏览器对 DOM 的改动又怎么读回成 state。回答这个问题的总入口是 EditorView 类,定义在 prosemirror-view/src/index.ts,连类型声明一共八百多行。这一篇先不碰渲染细节,把这个类的骨架看清楚:构造时做了什么,props 怎么组织,transaction 怎么进出,updateState 走哪条路,插件的 view 怎么挂上去,销毁时清理什么。参考代码是 prosemirror-view 的 ca4c78e。

EditorView 整体结构

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥(本篇)

构造函数做了哪几件事

new EditorView(place, props) 的第一个参数 place 决定编辑器挂载到哪:一个 DOM 节点就 appendChild 进去,一个函数就把创建好的 div 交给它处理,一个 {mount} 对象就把 mount 直接当编辑器的 dom 用(此时 mounted 标记为 true,destroy 时的行为会不同,后面讲)。place 传 null 则不挂进文档,常用于测试。

构造函数主体是一段固定顺序的初始化,顺序不能随意调换:

  1. 存下 props,取出 props.state 作为自己的 state,取出 props.plugins 作为 directPlugins,并用 checkStateComponent 逐个校验。然后 this.dispatch = this.dispatch.bind(this),让 dispatch 可以脱离实例传来传去。
  2. 按 place 创建或挂载 this.dom,这是编辑器的最外层元素。
  3. this.editable = getEditable(this),再问 updateCursorWrapper 要不要为 markCursor 造一个占位 widget。
  4. buildNodeViews(this) 把直接 props 和各插件贡献的 nodeViews、markViews 合并成一张渲染表(同名键先出现者优先),docViewDesc(...) 用当前文档构建出整棵 ViewDesc 描述树并渲染进 dom。到这一步,DOM 已经画出来了。
  5. 创建 DOMObserver 并 start(),它的回调是 readDOMChange,负责把浏览器对 DOM 的改动读回成文档变更。接着 initInput(this) 在 dom 上注册全部事件处理器。观察者和事件都挂在渲染完成之后,否则初始化渲染会被当成外部变更读回来。
  6. 最后 updatePluginViews() 挂载插件的 view。

这个顺序里能看出 EditorView 持有的几个核心部件:state(当前状态)、dom(外层元素)、docView(文档到 DOM 的描述树)、nodeViews(自定义渲染表)、input(事件管线状态)、domObserver(DOM 变更观察者)、pluginViews(插件 view 实例数组)。view 阶段后面每一篇,基本都是在拆其中某一个部件。

props 与 DirectEditorProps

EditorView 的配置分两层。EditorProps 是通用层,这一层的 prop 既能直接传给 view,也能写进插件的 spec.props。按用途大致分四组:事件回调一组,handleKeyDown、handleKeyPress、handleTextInput,点击还有 handleClickOn 与 handleClick 的两段式设计(On 版本从内向外逐节点调用,普通版本在这之后再统一调一次),粘贴拖拽对应 handlePaste、handleDrop、handleScrollToSelection;剪贴板转换一组,transformPastedHTML、transformPastedText、transformPasted 在解析前后依次加工,clipboardParserclipboardTextParser 决定粘贴内容怎么进文档,clipboardSerializerclipboardTextSerializertransformCopied 管复制方向;渲染定制一组,nodeViews、markViews、decorations、attributes;行为开关一组,editable、scrollThreshold、scrollMargin、dragCopies、createSelectionBetween、domParser。DirectEditorProps 在它上面加了三个只能直接传给 view 的字段:state(当前 EditorState)、plugins(直接挂在 view 上的插件)、dispatchTransaction

plugins 这个直接传法有个硬限制,就是构造函数里那个 checkStateComponentsrc/index.ts):直接传给 view 的插件不许带 state 组件,也就是不能有 spec.statespec.filterTransactionspec.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 多了之后需要一个统一的查找规则,somePropsrc/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))
}

两行分支定下了整个惯例。不给 dispatchTransaction,view 是自治的:内部产生的 transaction 直接 apply 出一个新 state,再 updateState 写回自己,外部只在旁边看着。给了 dispatchTransaction,view 把每一个 transaction 交出来,自己什么都不做,状态管理权整个移交给外部。外部拿到 tr 之后通常要做的事仍然是 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 上的 domFromPosposFromDOM,描述树里每个节点记着自己的 DOM 对应物,翻译就是沿树下钻。posAtCoordscoordsAtPos 处理屏幕坐标这一头:前者回答「点在这个像素位置相当于点了文档哪里」,后者回答「这个位置在屏幕上画在哪」,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 透明。

剪贴板相关有三个便捷方法:pasteHTMLpasteText 不走真实剪贴板事件,直接调内部的 doPaste 跑粘贴管线;serializeForClipboard 则暴露复制方向的序列化。这三个方法的存在主要是给测试和外部的自定义粘贴入口用。

updateState 的完整路径

外部入口有三个:updateState(state) 只换状态,update(props) 换整套 props,setProps(partial) 合并着换。三个最后都汇到私有方法 updateStateInner(state, prevProps),这是整个 EditorView 里最关键的一个方法,值得按它的执行顺序走一遍。

第一步处理一个特例:新 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 变动引起的跳动。

第五步是主体。updateDocredrawthis.docView.matchesNode(...) 判定,文档、装饰和当前描述树对不上就需要更新文档;updateDoc 或选区变了都会置 updateSel。进入更新块之前先 domObserver.stop(),把自己即将对 DOM 做的修改从观察者的视线里摘掉,改完再 start(),这是状态到 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 修改这一段,是 view 层双向同步不自激的核心手段。

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 分 EditorProps 和 DirectEditorProps 两层,someProp 按「直接 props、directPlugins、state.plugins」的顺序统一查找;dispatchTransaction 惯例让状态管理可以在 view 内部和外部之间整体切换;updateStateInner 的路径是比对、停观察者、改 DOM、改选区、开观察者、更新插件 view、处理滚动;destroy 按挂载方式分两种清理并把 docView 置 null 作为销毁标记。

下一篇进入 view 阶段的主体,viewdesc.ts:docView 这棵描述树是怎么从文档构建出来的,NodeViewDesc、MarkView、TextViewDesc 各长什么样。


978 字 · 50 段落
xi ming

Written by xi ming You should follow him on Github