view 阶段从 EditorView 总览讲到浏览器差异补丁,十二篇机制把核心四包全部过了一遍。这篇收官换个做法:动手。只用 prosemirror-model、prosemirror-transform、prosemirror-state、prosemirror-view 四个核心包,拼一个最小可用编辑器,keymap、commands、history 这些官方扩展一概不用。功能目标定得很小:能输入、能删除、能把选中文字加粗。目标小是故意的,功能越少,每一行代码对应哪一篇讲的机制就越清楚。参考代码是 prosemirror-model 的 6264de0、prosemirror-transform 的 662b7a9、prosemirror-state 的 ffad5d9、prosemirror-view 的 ca4c78e。
系列目录
先确认哪些行为是免费的
装配 example-setup 时要引入 keymap、history、inputrules、dropcursor、gapcursor 一堆扩展包。全部砍掉之后核心层还剩多少能力,这是动笔前要先想清楚的。先把四个核心包的分工摆出来:model 定义文档结构和校验规则,transform 提供修改文档的最小单位与组合 API,state 把文档、选区、插件状态合成一个不可变的整体,view 负责渲染和事件。更新循环本身(state 加 tr 等于新 state,新 state 驱动 DOM 补丁)是核心层闭合的,不依赖任何扩展。
逐项核对三个目标行为。输入:免费。view 的 DOMObserver 会把浏览器对 contenteditable 的改动读回成 transaction(第 28 篇),这条被动路径在 EditorView 的构造函数里就挂好了。删除:免费,同一条路径。Enter:不免费。第 29 篇提过 captureKeyDown 这道兜底,看 src/capturekeys.ts 的 captureKeyDown 会发现 Enter(keyCode 13)和 Esc 被无条件拦下,函数直接返回 true,keydown 处理器跟着 preventDefault。拦它的原因是 Enter 的浏览器默认行为差异太大,有的拆段落、有的插 div、有的只插 br,读回都未必对得齐。代价是:没有任何 handleKeyDown 处理 Enter 时,它就是一颗死键。所以真正要手写的有四样:Schema、装配代码、加粗命令、Enter 拆段。
Schema:三个节点一个 mark
import {Schema} from "prosemirror-model"
const schema = new Schema({
nodes: {
doc: {content: "paragraph+"},
paragraph: {
content: "text*",
toDOM() { return ["p", 0] },
parseDOM: [{tag: "p"}]
},
text: {}
},
marks: {
bold: {
toDOM() { return ["strong", 0] },
parseDOM: [{tag: "strong"}, {tag: "b"}]
}
}
})new Schema 做的是编译(src/schema.ts 的 Schema 类):节点和 mark 的规格各存一份,content: "paragraph+" 这样的内容表达式在第 6 篇讲过,会编成一台小型自动机,之后每次建节点都拿它校验。paragraph 的 toDOM 返回 ["p", 0],那个 0 是内容洞,子节点渲染进洞里(第 9 篇)。bold 的 parseDOM 给了两条规则,strong 和 b 两种标签都认,粘贴带格式的文本时会用到(第 10 篇)。
两个细节值得说明。text 节点的规格是个空对象:内联叶子节点不需要 content 表达式,也没写 marks 字段。schema.ts 里 markSet 的默认逻辑是 marks 缺省时允许挂所有 mark,所以 bold 直接可用;如果哪天加了斜体又想限制代码文本不挂格式,就得回来给这个字段写排除规则。doc 的规格里没有 toDOM,顶层的渲染由 view 接管,view.dom 本身就是 doc 的容器(第 26 篇的 topNode 与 docViewDesc)。
装配:state 进 view 出
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
const state = EditorState.create({
schema,
doc: schema.node("doc", null, [
schema.node("paragraph", null, [
schema.text("选中我,按 Mod-B")
])
])
})
const view = new EditorView(document.querySelector("#editor"), {
state,
dispatchTransaction(tr) {
this.updateState(this.state.apply(tr))
},
handleKeyDown(view, event) {
if ((event.metaKey || event.ctrlKey) && event.key == "b") {
return toggleBold(view.state, view.dispatch)
}
if (event.key == "Enter") {
return splitParagraph(view.state, view.dispatch)
}
return false
}
})EditorState.create(src/state.ts)按 schema 和初始 doc 建出第一份状态。没传 plugins,状态字段只有 doc、selection、storedMarks 等内置的几个。schema.node 和 schema.text 是 Schema 上的工厂方法,最终走 NodeType.createChecked 校验(第 6 篇),内容表达式不满足会直接抛错。
EditorView 的构造函数做的几件事在前面各篇都拆过:按 props 算出 editable,用 docViewDesc 把初始文档渲染成 ViewDesc 树铺进 view.dom(第 26 篇),new 一个 DOMObserver 并 start(第 28 篇),initInput 注册全部事件处理器(第 29 篇),最后 updatePluginViews 挂插件视图。没有插件时最后一步是空转,其余四步就是这个最小编辑器全部的运行时。
dispatchTransaction 是第 25 篇讲的惯例:view 内部所有修改都汇到这一个出口。view.dispatch 里用 dispatchTransaction.call(this, tr) 调用,所以函数体里的 this 就是 view 本身。函数体只有一行,把 tr 应用成新 state 再交还给 view。state.apply 内部是两阶段(第 19 篇):先把 transaction 里的 step 逐个应用出新 doc,再跑一遍所有状态字段的 apply。
handleKeyDown 直接挂在 EditorView 的 props 上。第 29 篇讲过 someProp 的查找顺序:先查构造时传入的直接 props,再查直接插件,最后查 state 里的插件。keymap 插件做的事就是把这个 prop 换成插件形式注册,这里直接挂构造 props,不走插件。返回 true 表示已处理,view 会调 preventDefault 拦住浏览器的默认行为;返回 false 事件继续往下走,走到底还有 captureKeyDown 兜底。这里挂了两个键:Mod-B 走 toggleBold,Enter 走 splitParagraph,两个命令的写法下面分别说。
加粗:手写一个 toggleBold
官方的 toggleMark 在 prosemirror-commands 里,这里不用它,直接用 Transform API 写一个。命令签名借用 commands 的约定:(state, dispatch),dispatch 不传就是干跑,只回答这个命令当前可不可用。
function toggleBold(state, dispatch) {
const bold = schema.marks.bold
const {empty, from, to} = state.selection
if (empty) {
// 光标情形:切换 storedMarks,下一个输入的字符生效
const active = bold.isInSet(state.storedMarks || state.selection.$from.marks())
if (dispatch) {
const tr = active
? state.tr.removeStoredMark(bold)
: state.tr.addStoredMark(bold.create())
dispatch(tr)
}
return true
}
const has = state.doc.rangeHasMark(from, to, bold)
if (dispatch) {
const tr = has
? state.tr.removeMark(from, to, bold)
: state.tr.addMark(from, to, bold.create())
dispatch(tr.scrollIntoView())
}
return true
}两个分支各对应一组前面讲过的机制。
光标分支操作 storedMarks。这是 state 上的一个内置字段(第 21 篇),表示光标停在这里、下一个字符该带哪些 mark。addStoredMark 和 removeStoredMark 在 src/transaction.ts 里,前者收 Mark 实例,后者 Mark 实例和 MarkType 都收。判断当前是否已激活用 bold.isInSet(...)(src/mark.ts,第 5 篇):storedMarks 为空时退到 $from.marks(),也就是光标所在位置实际带有的 mark。storedMarks 的存活规则写在 state.ts 这个内置字段的 apply 里:新选区是 $cursor(折叠的 TextSelection)就保留 tr.storedMarks,否则清成 null。所以方向键或点击移动光标不会丢 pending 状态,拖出一段选区或者选中节点才会清掉,这也是普通编辑器里加粗按钮跟着光标走的行为来源。
选区分支分两步。先用 doc.rangeHasMark(from, to, bold)(src/node.ts)判断选区里是不是已经有 bold,有就 removeMark,没有就 addMark。这两个方法在 src/transform.ts(第 18 篇),内部遍历范围内的内联节点:addMark 会跳过父节点不允许挂这个 mark 的节点,mark.addToSet 把互斥的 mark 挤掉时会补发对应的 RemoveMarkStep;removeMark 传 MarkType 时会把范围内该类型的 mark 全部清掉。结尾的 tr.scrollIntoView() 打一个内置标记,view 应用后把选区滚进可视区(第 21 篇)。
dry-run 语义值得保留。dispatch 为空时不构造 transaction,直接返回可行性。将来加工具栏按钮,按钮的禁用态就可以用 toggleBold(state) 干跑出来。这个编辑器里还用不上,但签名先留对,接到 keymap 或菜单上不用改。
Enter:手写一个 splitParagraph
前面说过 Enter 被 captureKeyDown 拦死,所以拆段也要自己写。最小版本只处理光标情形:
function splitParagraph(state, dispatch) {
const {$from, empty} = state.selection
if (!empty) return false // 选区情形要先删再拆,留给读者
if (dispatch) {
const tr = state.tr.split($from.pos).scrollIntoView()
dispatch(tr)
}
return true
}tr.split(src/transform.ts)是 Transform 上的结构修改方法,签名是 split(pos, depth = 1, typesAfter?),默认在指定位置把父节点拆成两个。它的实现(structure.ts 的 split)不预判合法性:把位置两侧各包一层父节点,拼成 openStart、openEnd 都等于 depth 的 Slice,用一个带 structure 标记的 ReplaceStep 塞回去。内容不合法会在 step 应用时失败,Transform.step 直接抛 TransformError,所以正式命令会先用 structure.ts 的 canSplit(第 17 篇)预判再动手。这个 schema 只有 paragraph 一种块,光标在段落文本内任何位置都能拆。选区非空时返回 false 是把难题推掉了:正式实现要先 deleteRange 删掉选区再拆,baseKeymap 里的 splitBlock 还要处理代码块末尾跳出、列表项提升等一堆情形,那是 commands 包的篇幅。
到这里主动路径的两个命令都齐了。整个 handleKeyDown 加起来不到二十行,已经覆盖了这个编辑器的全部键盘语义。
输入和删除走被动路径
加粗走的是主动路径,输入和删除走另一条。用户敲一个字符,浏览器直接改 contenteditable 里的 DOM,编辑器此刻并不知情。MutationObserver 触发后 DOMObserver 择机 flush,readDOMChange 拿当前 DOM 和内存里的文档做对齐,用 findDiffStart 和 findDiffEnd(第 11 篇)圈出变化区间,把变化处的 DOM 解析成 Slice,生成 transaction,最后一样走 dispatchTransaction。这套读回逻辑在第 28 篇完整讲过,composition 期间的抑制在第 31 篇,浏览器差异补丁在第 36 篇。三篇机制合起来,才换来这里一行代码都不用写。
删除同理,Backspace 在浏览器侧删掉字符或节点,读回对齐成 ReplaceStep。输入还有一条小的直接路径:editHandlers.keypress 里,当选区不是同一父节点下的 TextSelection 时(比如光标跨在两个段落边界上),view 不等浏览器动手,直接 dispatch 一个 tr.insertText(text).scrollIntoView() 并 preventDefault,省掉一次读回。同段落内的普通输入不走这条,还是浏览器先改 DOM、再被动对齐。
被动读回有一个前提:view 渲染出的 DOM 结构必须能被自己的 DOMParser 读回来,所以前面 Schema 里 paragraph 的 toDOM 和 parseDOM 必须配对,写岔了会出现输入一个字符、视图重建一片的症状。
图上两条路径在 dispatchTransaction 汇合。这个汇合点是编辑器唯一的更新入口,所有修改无论来源,最后都是旧 state 加 tr 等于新 state。汇合之后的半段(ViewDesc 增量更新、DOM 补丁、选区回写)两条路径也完全共用。以 Mod-B 为例顺一遍全链路:keydown 到达 view.dom,input.ts 的事件管线先记下 shiftKey、lastKeyCode 等输入状态,再调 props 上的 handleKeyDown;toggleBold 构造出带 AddMarkStep 的 transaction 交给 view.dispatch;dispatchTransaction 里 state.apply 算出新 state;updateState 驱动 ViewDesc 树做增量更新,matchesNode 判定复用,只有 dirty 的子树重绘;最后 strong 标签落进 DOM,selectionToDOM 把光标写回去。Enter 走同一条主动路径,区别只是 transaction 里装的是带 structure 标记的 ReplaceStep。
跑起来之后验证什么
代码跑起来,开控制台做三个检查,把前面的机制对照一遍。第一,输入一个字符后看 view.state.doc.toJSON(),文档 JSON 里多出来的就是这个字符,确认读回路径工作正常。第二,在 dispatchTransaction 里打一行 console.log(tr.steps):普通输入产生的是 ReplaceStep,加粗产生的是 AddMarkStep,Enter 拆段产生的是带 structure 标记的 ReplaceStep,step 家族的分类对应第 13、14 篇。第三,选中一段文字按 Mod-B,再检查 view.state.storedMarks 是 null、对应文本节点的 marks 数组里有 bold,确认走的是选区分支而不是 storedMarks 分支。三个检查都过了,说明这条最小链路每一环都按预期工作。
核心层 API 速查
四包在这篇里用到的,加上前面三十六篇覆盖的主要 API,收在一张表里:
| 包 | API | 位置 | 作用 |
|---|---|---|---|
| model | new Schema(spec) | src/schema.ts | 编译节点与 mark 规格 |
| model | schema.node / text / mark | src/schema.ts | 按规格建节点,走 createChecked 校验 |
| model | doc.resolve(pos) | src/resolvedpos.ts | 扁平位置转路径(第 7 篇) |
| model | doc.rangeHasMark | src/node.ts | 范围内 mark 存在性判定 |
| model | markType.isInSet / create | src/mark.ts | mark 集合判定与构造 |
| model | DOMSerializer / DOMParser | src/to_dom.ts / from_dom.ts | 文档与 DOM、HTML 互转 |
| model | findDiffStart / findDiffEnd | src/diff.ts | 两份文档求差 |
| transform | tr.replace / delete / insert | src/transform.ts | 内容修改,攒 ReplaceStep |
| transform | tr.addMark / removeMark | src/transform.ts | 范围 mark 修改 |
| transform | tr.split / join / lift / wrap | src/transform.ts | 结构修改,消费 structure.ts |
| transform | Step 族 / StepMap / Mapping | src/step.ts / map.ts | 可逆步骤与位置映射 |
| state | EditorState.create | src/state.ts | 装配初始状态 |
| state | state.tr / state.apply | src/state.ts | 开 transaction,应用出新 state |
| state | tr.addStoredMark / removeStoredMark | src/transaction.ts | 光标处的 pending mark |
| state | tr.setSelection / scrollIntoView | src/transaction.ts | 选区与滚动标记 |
| state | TextSelection / Selection.near | src/selection.ts | 选区构造 |
| state | Plugin / PluginKey / StateField | src/plugin.ts | 扩展点三件套 |
| view | new EditorView(place, props) | src/index.ts | 挂载编辑器 |
| view | dispatchTransaction prop | src/index.ts | 唯一更新出口 |
| view | handleKeyDown 等事件 props | src/index.ts | 事件拦截点,someProp 分发 |
| view | view.updateState / dispatch | src/index.ts | 状态进出 |
| view | nodeViews / decorations props | src/index.ts | 渲染扩展(第 32、33 篇) |
| view | posAtCoords / coordsAtPos | src/domcoords.ts | 坐标换算(第 35 篇) |
缺的东西
这个编辑器能跑,离好用还有距离,缺的每一块正好对应一个扩展包。没有 undo:Step 的可逆性(第 13 篇)已经在核心层里,但栈的管理、事件分组、远程修改时的 rebase 都在 prosemirror-history。快捷键只有两个 if 判断,不成体系:按键规格、Mod 前缀的跨平台处理、多个 keymap 的组合顺序,这些是 keymap 插件要解决的。Enter 只处理了光标情形,选区、空段落、嵌套块的分支在 baseKeymap 里还有一长串。粘贴能工作,但只有 parseDOM 规则匹配这一层收敛,粘贴进来的样式、图片、外部 HTML 都没有处理。输入规则、占位符、协同这些更谈不上。核心层负责把更新循环闭合,好用这件事归扩展层。下一阶段从 keymap 开始,把官方扩展逐个拆开。

