前两篇讲了为什么读这个库、22 个包怎么分工。这篇不碰源码细节,先把编辑器跑起来,然后在控制台里盯两样东西:文档的 JSON 长什么样,敲一个键产生的 Transaction 长什么样。后面读 model 和 state 的源码时,每个字段都能对应回这次见过的东西,比干读类型定义省力得多。参考代码是 prosemirror-example-setup 的 b6fcf7a、prosemirror-model 的 6264de0、prosemirror-state 的 ffad5d9、prosemirror-view 的 ca4c78e、prosemirror-transform 的 662b7a9,schema 来自 prosemirror-schema-basic 的 756726f 和 prosemirror-schema-list 的 1501619。
系列目录
| 日期 | 标题 |
|---|---|
| 05-10 | ProseMirror 源码分析开篇:富文本编辑器到底难在哪 |
| 05-17 | ProseMirror 仓库全景:22 个包怎么分工 |
| 05-24 | 跑通一个最小 ProseMirror:先看文档长什么样(本篇) |
几十行代码把编辑器跑起来
example-setup 是官方装配好的插件包,一次 exampleSetup({schema}) 调用返回一组插件,配上 EditorState 和 EditorView 就是一个能输入、能撤销、带菜单栏的编辑器。完整代码如下:
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"
import {Schema} from "prosemirror-model"
import {schema} from "prosemirror-schema-basic"
import {addListNodes} from "prosemirror-schema-list"
import {exampleSetup} from "prosemirror-example-setup"
const mySchema = new Schema({
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
marks: schema.spec.marks
})
const state = EditorState.create({
schema: mySchema,
plugins: exampleSetup({schema: mySchema})
})
window.view = new EditorView(document.querySelector("#editor"), {
state,
dispatchTransaction(tr) {
console.log(tr)
this.updateState(this.state.apply(tr))
}
})把 view 挂到 window 上是为了能在控制台里随手访问,view.state.doc.toJSON() 这类检查后面一直要用。
这段代码里真正需要解释的有三处。一是 Schema 那两行:prosemirror-schema-basic 提供基础节点和 mark 的定义,prosemirror-schema-list 的 addListNodes 把列表节点插进 nodes 集合,第一个参数是节点集合,第二个是列表项的内容表达式,第三个是列表归属的 group。Schema 是文档的类型表,后面 JSON 里出现的每个 type 字段都必须在这里注册过,反序列化时查不到类型名会直接抛错。
二是 EditorState.create(prosemirror-state src/state.ts)。config 里 doc 和 schema 二选一,给了 doc 就从 doc 上取 schema;doc 不给也能跑,state.ts 里 doc 字段的 init 会调 config.schema.topNodeType.createAndFill(),按内容表达式造一份填满必需子节点的默认文档,最小 demo 打开时那份空段落就是这么来的。plugins 数组交给 Configuration 统一整理,每个插件声明的 StateField 在这里完成初始化。也就是说 state 一出生就携带了文档、选区和全部插件状态,之后每次 apply 产出一个全新的 state 实例,旧实例不变。
三是 dispatchTransaction。prosemirror-view 的 EditorView.prototype.dispatch(src/index.ts)是这么写的:props 里给了 dispatchTransaction 就调用它,否则自己执行 this.updateState(this.state.apply(tr))。也就是说我们写的 dispatchTransaction 只是把默认行为显式化,中间插了一句 console.log。这个函数是所有修改的必经出口,后面观察 Transaction 全靠它。EditorView 构造时的第一个参数也有讲究,可以是 DOM 节点(编辑器插进去)、一个回调,或者 {mount} 对象,这里直接传了选择器查到的节点。
exampleSetup 本身(src/index.ts 的 exampleSetup 函数)没有任何编辑逻辑,纯做组合。它按顺序装这些插件:
buildInputRules(schema):输入规则。src/inputrules.ts 里能看到具体规则:smartQuotes、ellipsis、emDash 打底,schema 里有 blockquote 就注册/^\s*>\s$/的包裹规则,有 code_block 就注册/^```$/的转换规则,有 heading 就按#{1,6}加空格转标题。规则跟着 schema 走,schema 里没有的节点不会生成对应规则。keymap(buildKeymap(schema, mapKeys)):根据 schema 生成快捷键,src/keymap.ts 里 Mod-b 绑 toggleMark(strong)、Mod-i 绑 em、Mod-z 绑 undo、Shift-Mod-z 绑 redo(非 Mac 再加 Mod-y)。mapKeys 参数可以把某个键重绑或用 false 禁用。keymap(baseKeymap):prosemirror-commands 提供的基础按键处理,Enter、Backspace 这些。dropCursor()和gapCursor():拖拽指示线和块级位置光标。menuBar({...}):菜单栏,menuBar: false可以关掉,floatingMenu控制是否浮动,菜单内容默认来自buildMenuItems(schema).fullMenu。history():撤销重做,history: false可以关掉。
最后再追加一个只干一件事的 Plugin:给编辑器 DOM 挂上 ProseMirror-example-setup-style 这个 class,让包自带的样式表生效。插件数组的顺序不是随便排的,按键处理按数组顺序匹配,先匹配的先消费事件,这个语义到插件系统那篇(第 22、23 篇)再细看。每个插件背后都对应一个独立的包,后续都有专篇,这篇只需要记住装配顺序和「编辑器 = 核心 state/view + 一组插件」这个结构。
文档的 JSON 表示
编辑器跑起来后,在控制台执行 view.state.doc.toJSON()。这一步值得先做,因为 ProseMirror 的文档对象本身是个带方法的类实例,直接 console.log 打出 Node 对象只能看到一堆内部字段,toJSON 之后的纯数据才是文档的规范长相,也是入库、过接口、做快照测试时实际流转的形态。以「一个二级标题加一段带加粗的文字」为例,输出是这样的:
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {"level": 2},
"content": [{"type": "text", "text": "标题"}]
},
{
"type": "paragraph",
"content": [
{"type": "text", "text": "前面"},
{"type": "text", "marks": [{"type": "strong"}], "text": "加粗"},
{"type": "text", "text": "后面"}
]
}
]
}整棵树只有四种字段:type、attrs、content、marks,文本节点多一个 text。这个结构对照 prosemirror-model 的 src/node.ts 里 Node.prototype.toJSON 逐条核实:
- 每个节点先放
{type: this.type.name},类型名是字符串。 - attrs 只有在存在任意一个键时才带上,实现是个值得一看的写法:
for (let _ in this.attrs) { obj.attrs = this.attrs; break },循环本身不取任何值,只为探测对象是否为空。 - content 非空(
this.content.size为真)才带 content,值是 Fragment 的 toJSON 结果,即子节点数组。 - marks 数组非空才带 marks。
- TextNode 覆写 toJSON,在父类结果上补一个 text 字段。
所以 JSON 的省略规则是固定的:paragraph 这种没有 attrs 的节点不会出现 attrs 键,没有内容的节点不会出现 content 键。attrs 的例子看 heading,值是 {level: 2}。schema-basic 里 heading 的 attrs 声明带 default: 1,默认值在节点创建时就会被填进 attrs,因此序列化时 level 一定存在。还有一个约束容易踩:toJSON 直接引用 attrs 对象本身,不做任何转换,attrs 里如果塞了函数或 DOM 节点这类不可序列化的值,存储环节就会出问题。
反序列化走同文件的 Node.fromJSON(schema, json)。type 为 “text” 时先校验 text 字段是字符串,再调 schema.text(json.text, marks);其余类型先 Fragment.fromJSON 递归还原子节点数组,再用 schema.nodeType(json.type) 查出类型并 create,最后 checkAttrs 校验属性。Mark 的序列化在 src/mark.ts,结构只有 type 和可选 attrs;Mark.fromJSON 在 schema.marks 里按名字查类型,查不到抛 There is no mark type ... in this schema。schema 实例上还挂了两个便捷函数 nodeFromJSON 和 markFromJSON(src/schema.ts),存取文档的完整往返就是两句:
const json = state.doc.toJSON()
// 入库、传接口,随你处置
const doc = Node.fromJSON(mySchema, json) // 或 mySchema.nodeFromJSON(json)子节点数组这一层由 Fragment 负责(src/fragment.ts),两个细节值得注意。Fragment.toJSON 在数组为空时返回 null,配合 Node.toJSON 里「content 非空才带」的判断,空节点在 JSON 里就是干干净净的一个 type 键。还原一侧更微妙:Fragment.fromJSON 收到空值直接返回 Fragment.empty,收到数组则交给 Fragment.fromArray,而 fromArray 会把相邻且 marks 相同的文本节点合并成一个。这意味着往返不保证逐节点一一对应:如果你手工拼了一份 JSON,里面连着写两个没有 marks 的 text 节点,fromJSON 之后它们是一个节点,再 toJSON 出来的结构和你喂进去的已经不同了。TextNode 的构造函数还禁止空字符串,JSON 里出现 "text": "" 会在还原时直接抛 Empty text nodes are not allowed。存库的数据如果来自外部拼接,这两处是最常见的翻车点。
有一个设计上的点值得在这里记住:marks 不嵌套。加粗文字在 JSON 里没有「strong 节点包 text 节点」的结构,加粗信息放在 text 节点自己的 marks 数组里。Mark 不在文档树上,它挂在 inline 节点身上,这是 model 层一处基本的不对称设计,第 5 篇专门展开,这里先记住它在 JSON 里的长相。
除了从既有文档往外导,也可以在控制台反向构造。schema 实例本身是个工厂:mySchema.text("hello", [mySchema.marks.strong.create()]) 造带 mark 的文本,mySchema.nodes.heading.create({level: 3}, 内容) 造节点,造出来 toJSON 一对照,就能确认自己拼的结构和预期一致。后面读 model 源码时我会一直用这种手法:对某个方法的行为有疑问,先在控制台造最小输入跑一遍,再回头读实现,效率高过干瞪眼。fromJSON 内部走的也是同一组工厂方法(schema.text、schema.nodeType(name).create),JSON 只是这套 API 的一种外部表示。
敲一个键,控制台里发生了什么
demo 里的 console.log 现在开始工作。假设当前文档是 doc(paragraph(“hell”)),光标在末尾,敲入字母 “o”,打印出的 Transaction 值得逐字段拆开看:
tr.steps.length // 1
tr.steps[0] // ReplaceStep {from: 5, to: 5, slice: Slice}
tr.steps[0].slice.toJSON()
// {content: [{type: "text", text: "o"}]}
tr.before.toJSON() // 修改前:doc > paragraph > "hell"
tr.doc.toJSON() // 修改后:doc > paragraph > "hello"
tr.selection // TextSelection {anchor: 6, head: 6}steps。Transaction 继承自 prosemirror-transform 的 Transform 类,一次输入产生的修改被记录成 Step 数组。敲一个字符是一步 ReplaceStep:from 和 to 相等表示纯插入,slice 里装着插入的内容;删一个字符则是 from、to 相差 1、slice 为空。删除这一步的 JSON 也值得看一眼:replace_step.ts 的 toJSON 先判 this.slice.size,slice 为空时连 slice 键都不写,打出来只有 {stepType: "replace", from: 4, to: 5} 三个键,省略风格和文档的 toJSON 是同一路数。所有文档修改都归约成若干 Step,没有第二种通道,这是 transform 阶段六篇文章的全部主题。Step 和文档一样是可序列化的。tr.steps[0].toJSON() 打出 {stepType: "replace", from: 5, to: 5, slice: {...}},stepType 是个注册表里的字符串 id,每种 Step 类用 Step.jsonID 登记自己(prosemirror-transform src/step.ts),Step.fromJSON 按 stepType 查回对应的类。slice 的 toJSON 延续同样的省略风格:openStart 和 openEnd 为 0 时不写进 JSON,空 slice 直接序列化成 null(prosemirror-model src/replace.ts 的 Slice.toJSON)。这两个字段记录切片两端「打开」的深度,粘贴跨段落内容时才会非零,第 8 篇专门讲。文档、步骤、选区全部能变成纯 JSON,协作编辑把本地修改发给服务端时发的就是 steps 的 JSON,这是后话,第 47 篇展开。
before 和 doc。Transform 内部维护两个平行数组(src/transform.ts):steps 记录每一步,docs 记录每步应用前的文档,before 取 docs[0] 即起步时的文档,doc 是所有步应用完之后的当前文档。文档对象不可变,before 和 doc 是两份独立引用,未改动的子树在两者之间共享。所以 ProseMirror 里 diff 不需要事后计算,steps 本身就是 diff。before 还有一个实际的校验用途:EditorState.apply 走到 applyInner(src/state.ts)时,第一句就是检查 tr.before.eq(this.doc),不一致直接抛 Applying a mismatched transaction。事务只能应用在它被创建时的那份文档上,这也是为什么 dispatchTransaction 里必须拿 view 当前的 state 去 apply,拿一个缓存的旧 state 就会在这里炸掉。这个校验在控制台里一秒就能复现:把同一个 tr 连续 apply 两次,第二次必抛,因为第一次 apply 之后 state.doc 已经换成新文档,而 tr.before 还指着旧的。
selection。Transaction 在 Transform 之上加了状态语义(src/transaction.ts)。内部字段 curSelection 跟随 steps 推进:取 tr.selection 时如果发现步数已经超前(curSelectionFor < this.steps.length),先把选区 map 过新增的步骤再返回。上面的例子里光标位置从 5 自动挪到 6,就是这个 map 的结果。事务被 apply 之后,这个 selection 会成为新 state 的选区。打印出来的 TextSelection 有 anchor 和 head 两个字段,锚点和活动端,光标没有拖选时两者相等;选中一段文字时它们拉开,方向由谁大谁小体现。顺带说明一下位置编号:文档里每个开闭 token 和每个字符都占一个位置,段落里的文本从 1 开始数,“hell” 的末尾是 5,这套约定的完整规则在第 7 篇 ResolvedPos 里展开。
这次事务里没出现但值得知道的字段是 meta。Transaction 可以挂任意 meta 数据,插件之间靠它传话,history 插件就靠 meta 区分用户输入和 undo 产生的修改,第 21 篇展开。
除了这次打印的字段,Transform 上还有一个 mapping(src/transform.ts),类型是 Mapping,它把 steps 里每一步的位置映射攒成一条链,能把旧文档里的任意位置换算成新文档里的位置。tr.selection 的自动跟随用的是它,协作 rebase 和历史回放用的也是它,transform 阶段有整整两篇留给这个数据结构。在控制台里可以顺手验证:tr.mapping.map(5) 对上面的例子返回 6,和选区的移动一致。
换一个操作再看一次,能把「一切修改都是 Step」这件事坐实。选中一个词按 Mod-b 加粗,这次 steps 里躺的是一步 AddMarkStep(prosemirror-transform src/mark_step.ts),toJSON 的结果是 {stepType: "addMark", mark: {type: "strong"}, from: ..., to: ...}:不动文档内容,只给 from 到 to 区间内的文本加一个 mark。对照 before 和 doc 的 JSON,变化精确落在对应 text 节点的 marks 数组里,多出一个 {type: "strong"}。插入、删除、加粗、改属性,每种操作对应一种 Step 类型,类型注册表里就这么几种,全部修改行为被收敛到一个可以枚举的集合里。
回到 dispatchTransaction 里的两行代码,这是 ProseMirror 的固定循环:拿到 tr,apply 出新 state,updateState 塞回 view。view 自己不决定文档怎么变,它只负责发出事务;怎么应用、应用后怎么渲染,各有明确入口。第一篇说的「Transaction 驱动」,在控制台里看到的就是这个循环。
这篇建立的直观模型
到这里,后面几十篇要展开的东西都已经露过一次面:
- 文档是一棵不可变的 Node 树,JSON 表示只有 type/attrs/content/marks/text 五种键,往返序列化依赖 schema 提供类型表。
- 修改文档的唯一方式是构造 Transaction,内容是一组 Step 加一个选区,before 和 doc 给出修改前后的两份文档。
- 编辑器本体是 state 加 view:state 持有文档、选区和插件状态,view 负责 DOM 渲染,并把浏览器事件翻译成事务。
- example-setup 这类扩展包只是往 state 里塞插件,核心四包之外没有额外的机制。
下一篇进入 model 包,从这棵树本身读起:Node 和 Fragment 的数据结构、Fragment 为什么不直接用数组,以及 nodeSize 这套计数约定怎么支撑上面看到的位置编号。

