state 收官:动手写三个插件验证理解

5 分钟阅读
·

state 阶段到这里讲了五篇机制:EditorState 的装配与应用、Selection 体系、Transaction、Plugin 系统上下两篇。机制讲完了,这篇换个验证方式:动手写三个插件,每个对准插件系统的一类能力。字符统计插件用 StateField,只读开关用 filterTransaction,选区上报用 view 规格。三个加在一起,PluginSpec 的主要字段基本都过了一遍手。挑这三个需求也有讲究:它们分别对应「插件要存自己的数据」「插件要拦截别人的修改」「插件要对更新做反应」三类最常见的诉求,真实项目里的插件大多能归进其中一类或几类的组合。参考代码是 prosemirror-state 的 ffad5d9。示例里数文档字符用到 prosemirror-model 的 descendants 与 textContent,参考代码是 prosemirror-model 的 6264de0。装配演示用了 EditorView 做胶水(prosemirror-view 的 ca4c78e),view 的细节是下一阶段的内容,这里只用它的 dispatch 与 updateState;搭建 schema 用的 prosemirror-schema-basic 参考代码是 756726f。

系列目录

日期 标题
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 收官:动手写三个插件验证理解(本篇)

插件一:字符统计,StateField 的完整生命周期

需求:编辑器页脚显示当前文档字符数。字符数是文档的派生数据,文档变了它才变,适合放进一个 StateField。完整代码:

import {Plugin, PluginKey} from "prosemirror-state"

const charCountKey = new PluginKey("charCount")

function countChars(doc) {
  let count = 0
  doc.descendants(node => {
    if (node.isText) count += node.text.length
  })
  return count
}

const charCountPlugin = new Plugin({
  key: charCountKey,

  state: {
    init(config, instance) {
      return {count: countChars(instance.doc)}
    },

    apply(tr, value, oldState, newState) {
      if (!tr.docChanged) return value
      return {count: countChars(newState.doc)}
    },

    toJSON(value) {
      return value.count
    },

    fromJSON(config, value) {
      return {count: value}
    }
  }
})

外部读数走 key:charCountKey.getState(state).count

countChars 用 doc.descendants 遍历整棵树,遇到文本节点就累加 text.length。这是 prosemirror-model 里 Node 上的遍历方法,回调返回 false 可以跳过某棵子树,这里全部走到底。同一件事也可以用 doc.textContent 一行做完,那是把所有文本拼成字符串。两种写法数出来的结果一样,选 descendants 是因为以后想排除某类节点(比如代码块不算字数)时,回调里加个判断就行,textContent 没有这个机会。

这份代码把 StateField 的四个方法都用上了,对应 src/plugin.ts 里 StateField 接口的完整签名。几个点值得展开。

init 的时机在 EditorState.create 里:src/state.ts 的 create 先建好 Configuration,再按 fields 数组顺序逐个调 init,把返回值直接写到 state 实例的同名属性上。内置字段 doc、selection、storedMarks、scrollToSelection 放在 baseFields 里排最前,插件字段排在后面,所以 init 里读 instance.doc 是安全的。反过来,插件的 init 不能去读其他插件的字段:接口注释写明了 instance 是个半成品 state,排在后面的字段这时还没有值。apply 同理,第四个参数 newState 也是半成品,只能依赖 doc 这类内置字段和排在自己前面的插件字段。字符统计只依赖 doc,没有这个顾虑;真要跨插件依赖,手段只有一个,调整 plugins 数组的顺序。

apply 里的 tr.docChanged 短路值得养成习惯。移动光标、设置 meta 都会产生 transaction,文档没动就没必要重算。字段值跟着 state 走不可变路线,没变化时直接返回旧引用是正常做法,新旧 state 共享同一份字段值没有任何问题。

这里的重算策略是全量:文档变了就重新遍历一遍。对字符统计这种便宜操作,全量足够。如果字段维护的是昂贵结构,比如全文的搜索结果,全量重算就跟不上了,得换成增量路线:apply 里用 tr.mapping 把旧结果的位置映射到新文档,只重扫受影响的区间。prosemirror-search(参考代码 647a36f)的 SearchState.apply 就是这么做的:activeRange 先经 tr.mapping.map 映射,再只在区间内重新查找匹配、重建装饰。Mapping 的用法第 16 篇讲过,这里提一句是为了说明 StateField 的 apply 不限制你用什么策略,它只要求你返回一个新值。

toJSON 和 fromJSON 是可选项,不配这对方法,字段就不参与序列化。配上之后的用法:

let json = state.toJSON({charCount: charCountPlugin})
let restored = EditorState.fromJSON(
  {schema, plugins: [charCountPlugin]},
  json,
  {charCount: charCountPlugin}
)

state.toJSON 的参数是「JSON 属性名到插件实例」的映射,src/state.ts 的 toJSON 会拿映射里的插件去找它 spec.state 上的 toJSON 方法,把字段值写进指定的属性名。doc 和 selection 两个名字被保留,映射里写了会抛 RangeError。fromJSON 一侧靠 plugin.key == field.name 对号,把 JSON 属性读回来交给字段的 fromJSON。所以插件想参与序列化有个前提:必须挂 key。这个插件里 key 的两个作用都用到了,对外 getState 取数,对内序列化对号。另外传给 PluginKey 的名字不要求全局唯一:src/plugin.ts 的 createKey 遇到重名会在名字后追加 $ 和序号,两个同叫 “charCount” 的 key 各取各的字段,互不干扰。

插件二:只读开关,filterTransaction 加 meta 通信

需求:编辑器有个只读模式,开着的时候任何文档修改都不生效,选区移动不受影响。开关本身存进一个 StateField,拦截交给 filterTransaction:

import {Plugin, PluginKey} from "prosemirror-state"

const readonlyKey = new PluginKey("readonly")

const readonlyPlugin = new Plugin({
  key: readonlyKey,

  state: {
    init() {
      return false
    },
    apply(tr, value) {
      let flag = tr.getMeta(readonlyKey)
      return flag === undefined ? value : flag
    }
  },

  filterTransaction(tr, state) {
    if (!tr.docChanged) return true
    return !readonlyKey.getState(state)
  }
})

拨动开关靠 meta:

view.dispatch(view.state.tr.setMeta(readonlyKey, true))

开关值是个布尔 StateField。改动它要走 meta,不能直接赋值:state 不可变,插件字段的所有变化都得搭 transaction 的车,而 meta 是 transaction 上专门留给这类指令的位子。src/transaction.ts 的 setMeta/getMeta 接受 string、Plugin、PluginKey 三种 key。用 PluginKey 最稳:字符串有撞名风险,插件实例则要求调用方拿得到引用。meta 不进文档,也不进 toJSON 的输出,transaction 应用完就消散,正好承担一次性指令。

filterTransaction 的签名是 (tr, state) => boolean,注意这里的 state 是应用这笔 transaction 之前的 state。src/state.ts 的 applyTransaction 第一步就是 this.filterTransaction(rootTr),在旧 state 上调用。所以 readonlyKey.getState(state) 读到的是改动发生之前的开关值,语义正好:先查当前是不是只读,再决定放不放行。如果写成读 tr 之后的值就错了,同一笔 transaction 里先 setMeta 关掉只读再插文字,会搞不清该按哪个值判。

否决的粒度是整笔 transaction。返回 false 后,applyTransaction 直接返回 {state: this, transactions: []},state 原样不动,transaction 里顺带携带的选区变化也一起作废。对这个插件无所谓,它本来就先放行了所有非文档修改。但如果需求是「拦文档修改、保住这笔交易里的其他效果」,filterTransaction 就太粗了,得换 appendTransaction 里补一笔修正的思路。filterTransaction 适合整笔交易要么过要么不过的场景,比如只读,比如权限控制。

还有两个细节。第一,判断用 tr.docChanged,它来自 prosemirror-transform(参考代码 662b7a9)的 Transform 类,只动了选区或 meta 的 transaction 不会被误伤,只读模式下光标照常用。拨开关本身那笔 transaction 也只带 meta,能顺利通过自己的过滤器,不会出现「开了只读就关不回来」的死锁。第二,filterTransaction 对其他插件追加出来的 transaction 同样生效:appendTransaction 循环里每追加一笔,都要过一遍 newState.filterTransaction(tr, i),第二个参数只豁免追加者自己。也就是说只读模式开着的时候,任何插件想借 appendTransaction 改文档也会被拦下。这正是想要的行为,拦截点收在一个地方,不用挨个防。

被否决之后调用方看到什么也值得知道。applyTransaction 原样返回 {state: this, transactions: []},state 没变,事务列表是空的。dispatchTransaction 里接着调 view.updateState(newState),新 state 和旧 state 是同一个对象,view 一侧走的还是正常更新路径,只是什么都没变。也就是说否决对 view 是透明的,不需要调用方特判。想给用户一个「现在是只读」的提示,得另想办法,比如在界面上监听开关字段的变化,filterTransaction 本身不带任何通知渠道。

插件三:选区变化上报,view 规格

需求:编辑器外面有个面板,实时显示当前选区位置。这活不改文档,也不改任何状态,只需要每次 state 更新后被告知一声。对应的挂点是 PluginSpec.view:

import {Plugin} from "prosemirror-state"

function reportSelection(sel) {
  console.log(`selection: ${sel.from} -> ${sel.to}, empty: ${sel.empty}`)
}

const selectionReporterPlugin = new Plugin({
  view(editorView) {
    reportSelection(editorView.state.selection)
    return {
      update(view, prevState) {
        let sel = view.state.selection
        if (!sel.eq(prevState.selection)) reportSelection(sel)
      },
      destroy() {
        console.log("reporter detached")
      }
    }
  }
})

src/plugin.ts 里 view 规格的签名是 (view: EditorView) => PluginView。插件状态第一次和某个 EditorView 关联时这个函数被调用,返回的对象就是 PluginView,类型上只有两个可选方法:update 和 destroy。这个时机比想象中早:prosemirror-view 的 src/index.ts 里,EditorView 构造函数收尾就调 updatePluginViews,编辑器一创建 view 规格就执行,不用等任何操作。view 函数拿到的 editorView 参数可以直接用,代码里一进来就先报了一次初始选区,免得面板在第一次操作之前是空的。

update(view, prevState) 在 view 每次更新 state 后都会被调到,包括文档没变、只有插件字段变了的情况。所以上报选区要自己去重,否则拖一次光标旁边的字数统计刷新也会跟着报一遍。去重用 src/selection.ts 的 Selection.eq,它做值比较:文本选区比 anchor 和 head,节点选区比选中的位置,和 === 不同,两个不同的 selection 对象内容一致也算相等。字段 from、to、empty 是 Selection 上的通用访问器,四种选区类型都有。

顺便分清 view 规格和 props 的边界。props 是声明式的:插件把 handleDOMEvents、decorations 这类属性交出去,由 view 层决定什么时候调用、怎么合并多个插件的同名 prop。view 规格是命令式的落脚点:插件拿到 EditorView 实例,自己注册监听、自己维护 DOM、自己决定 update 里做什么。上报、浮层、外部面板同步这类「插件主动做事」的需求归 view 规格;「view 在某个事件点问插件意见」的需求归 props。这个插件只需要被告知,连 DOM 都不碰,用 view 规格里最薄的一层。

destroy 在两种时机被调:view 销毁,或者 view 收到一份插件集合不同的新 state(reconfigure 场景,旧插件的 view 会被摘除)。上报插件没有资源要清,留一行日志占位;真实场景里在这里解绑 DOM 监听、清定时器。

这个需求为什么不能用 appendTransaction 做。它的签名是 (transactions, oldState, newState) => Transaction | null | undefined,语义是看完这批 transaction 之后要不要再补一笔。拿它做上报有两个问题。一是调用次数不受控:src/state.ts 的 applyTransaction 循环里,任何插件追加了一笔新 transaction,所有插件的 appendTransaction 都会拿着新到的交易再跑一轮,副作用会跟着重复执行,同一个选区可能被上报好几遍。二是时机不对:appendTransaction 跑在 state 装配阶段,view 的 updateState 还没发生,PluginView 的 update 更轮不到,对面板来说这不是「这次编辑完成」的信号。上报是典型的视图侧副作用,挂点在 view 规格。appendTransaction 的正确定位是返回一笔真正要应用的修改,它的循环细节第 23 篇已经拆过。

装配起来跑一遍

三个插件加上一段最小装配代码:

import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {schema} from "prosemirror-schema-basic"

let state = EditorState.create({
  schema,
  plugins: [charCountPlugin, readonlyPlugin, selectionReporterPlugin]
})

let view = new EditorView(document.querySelector("#editor"), {
  state,
  dispatchTransaction(tr) {
    let newState = view.state.apply(tr)
    view.updateState(newState)
    let {count} = charCountKey.getState(newState)
    document.querySelector("#counter").textContent = `${count}`
  }
})

dispatchTransaction 是 view 侧的惯例入口,每笔 transaction 都交到这里。state.apply(tr) 内部走 applyTransaction:先过 filterTransaction,再 applyInner 逐字段应用,最后 appendTransaction 循环收尾。三个插件各在其中一个环节被调到,PluginView.update 则由随后的 updateState 统一触发:

三个插件在一次 dispatch 中的挂接点

验证清单:输入文字,页脚计数跟着变;dispatch 一笔 setMeta(readonlyKey, true) 之后再输入,文档不动,计数不变,光标照样能移动;拖动选区,控制台持续打印 from 和 to;再 dispatch 一笔 setMeta(readonlyKey, false),输入恢复。几轮下来,每个钩子的行为和前面五篇讲的机制一一对得上。

两个装配细节顺带交代。三个插件在 plugins 数组里的顺序随意:它们互不读对方的字段,两个带 StateField 的插件之间没有依赖,第 22 篇讲的顺序约束在这里用不上。页脚计数直接写在 dispatchTransaction 里是演示写法,图的是少搭一层;真实项目里一般让页脚组件订阅编辑器的更新事件,UI 刷新不揉进 dispatch。

state 阶段收尾

回头对一下能力面。StateField 的 init、apply、toJSON、fromJSON 四个方法在插件一里全走了一遍,插件二又用它存了个布尔开关;PluginKey 的 getState 在插件一对外取数、插件二对内读开关两处用到;setMeta/getMeta 的 meta 通信承担了开关指令;filterTransaction 的否决权在插件二;view 规格和 PluginView 的 update、destroy 在插件三。PluginSpec 上没单独演示的剩两个:props 的绑定机制第 22 篇讲过,消费在 view 层,下个阶段见真章;appendTransaction 的循环细节第 23 篇拆过。

prosemirror-state 这个包的内容就这么多:一个不可变 state,一套选区,一种 transaction,一个插件系统。五篇机制加这一篇动手,state 阶段翻篇。下一阶段进 prosemirror-view,看 state 怎么变成屏幕上的 DOM,以及 PluginView.update 到底在 updateState 的哪一步被调到。


912 字 · 37 段落
xi ming

Written by xi ming You should follow him on Github