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 篇),创建并启动 DOMObserver(第 28 篇),通过 initInput 注册事件处理器(第 29 篇),最后调用 updatePluginViews 创建插件视图。未使用插件时,最后一步没有实际操作;其余步骤构成这个最小编辑器的运行时。
dispatchTransaction 是第 25 篇介绍的约定:view 内部的修改通过这一回调更新状态。view.dispatch 使用 dispatchTransaction.call(this, tr) 调用它,因此函数体中的 this 指向 view。回调将 tr 应用为新 state,再调用 view.updateState。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
captureKeyDown 会拦截 Enter,因此拆段逻辑需要自行实现。最小版本只处理光标情形:
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,对应第 13、14 篇的 step 分类。第三,选中一段文字按 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 依赖 prosemirror-history:Step 的可逆性(第 13 篇)在核心层中,但栈管理、事件分组和远程修改时的 rebase 位于该扩展中。快捷键仅由两个 if 判断处理;按键规格、Mod 前缀的跨平台处理和多个 keymap 的组合顺序由 keymap 插件负责。Enter 仅处理光标情形,baseKeymap 还包含选区、空段落和嵌套块等分支。粘贴目前只依赖 parseDOM 规则匹配,未处理粘贴内容的样式、图片和外部 HTML。输入规则、占位符和协同功能也未包含。核心层提供更新循环,扩展层补充这些编辑能力。后续可从 keymap 开始分析官方扩展。
