上一篇讨论了 contenteditable 的限制和 ProseMirror 的应对方式。阅读源码前,先确认代码的位置。ProseMirror 的核心和扩展分成 22 个独立包,本地工作区根目录下有 22 个 prosemirror-* 目录;每个目录各有一个 git 仓库和一份 package.json,此外还有 buildhelper、rfcs、website 三个辅助目录。本文逐项核对各包的 dependencies 字段,明确依赖关系,为后续分层阅读提供参照。
系列目录
| 日期 | 标题 |
|---|---|
| 05-10 | ProseMirror 源码分析开篇:富文本编辑器到底难在哪 |
| 05-17 | ProseMirror 仓库全景:22 个包怎么分工(本篇) |
根目录:22 个包和三个辅助目录
22 个包中,prosemirror 目录本身(c7f2f1d)承担不同职责。它的 package.json 中 name 是 prosemirror、version 是 0.0.0、private 是 true;该包不发布,是整个项目的开发入口。README 说明这个仓库用于集中管理 issue,并提供脚本拉取其余包以便共同开发。脚本位于 bin/pm,workspaces 字段声明 [”*”],即根目录下每个子目录都是一个 workspace 包。README 的开发环境搭建命令是 bin/pm install,它负责安装各包依赖并构建一次。demo/ 目录包含官方演示页面和 benchmark;库代码位于其余 21 个目录。
bin/pm.js 是整个多包仓库的统一命令入口。help 信息列出的子命令覆盖常用维护操作:pm build 构建所有包,pm test 运行所有包的测试,pm watch 启动常驻进程并按改动增量构建,pm grep 在所有包源码中检索,pm run 在每个包目录执行同一命令,pm status、pm commit、pm push、pm pull 将 20 多个独立 git 仓库批量操作,pm release 为指定包发布新版本,pm mass-change 在所有包中执行正则替换。该脚本统一处理多包开发中的这些管理操作。文件开头还硬编码了一份核心模块名单,阅读具体包时可以使用这些命令。
三个辅助目录:
- buildhelper(60d1bac):发布为 @prosemirror/buildhelper,bin/ 下有两个脚本 pm-buildhelper.js 和 pm-runtests.js,依赖 @babel/core、@babel/preset-env、@marijn/buildtool、@marijn/testtool。它是各包共用的构建和测试工具,几乎出现在每个包的 devDependencies 中,版本统一为 ^0.1.5。22 个包通过它共用一套构建配置。
- rfcs:没有 package.json,只有 text/ 目录下 12 份编号的 RFC 文本(0001-rfc-process 到 0012-direct-view-plugins),用于存档设计改动提案。标题可对应具体模块,例如 0002-contentmatch-edges 对应 model 的内容表达式;需要追溯某个 API 的设计动机时可查阅该目录。
- website(a16b4ec):name 是 prosemirror-website,官网的源代码。它的 dependencies 几乎是全仓库清单:15 个 prosemirror-* 包,加上 CodeMirror 系列包(做示例代码的编辑和高亮)和 crelt。官网示例全部使用已发布的包搭建,因此该目录也可用于检验各包的集成状态。
核心四层:从 package.json 确认依赖方向
model、transform、state、view 的分层可通过 dependencies 字段验证。四个包的声明(参考代码是 prosemirror-model 的 6264de0、prosemirror-transform 的 662b7a9、prosemirror-state 的 ffad5d9、prosemirror-view 的 ca4c78e):
- prosemirror-model:dependencies 只有 orderedmap ^2.0.0,不依赖其他 prosemirror 包。orderedmap 是一个保持顺序的小型映射实现,model 的 src/schema.ts 中 NodeSpec 和 MarkSpec 的集合都使用 OrderedMap 存储,编译 schema 时按插入顺序遍历。
- prosemirror-transform:dependencies 只有 prosemirror-model ^1.21.0。修改原语只需要文档结构。
- prosemirror-state:dependencies 是 prosemirror-model ^1.0.0、prosemirror-transform ^1.0.0、prosemirror-view ^1.27.0。
- prosemirror-view:dependencies 是 prosemirror-model ^1.20.0、prosemirror-state ^1.0.0、prosemirror-transform ^1.1.0。
state 声明了 view,view 也声明了 state,package.json 层面形成环。再检查源码中的实际引用方向。prosemirror-state 对 view 的引用只有两行:src/plugin.ts 第 1 行的 import {type EditorView, type EditorProps} from "prosemirror-view",src/transaction.ts 第 3 行的 import {type EditorView} from "prosemirror-view"。都是带 type 标记的类型导入,编译后整条 import 被擦除,运行时代码里 state 对 view 的引用不存在。存在它的原因是 Command 的类型签名 (state, dispatch, view) => boolean,第三个参数需要 EditorView 这个类型。反方向,view 对 state 是真实的值导入,src/index.ts、input.ts、viewdesc.ts、selection.ts 等 8 个文件都在用 EditorState 和 Transaction 的实体。
排除这两条类型引用后,运行时依赖方向是单向的:model 位于底层,transform 依赖 model,state 依赖前两者,view 依赖前三者。下层包的代码中没有对上层包的值引用。由此可见,文档结构不处理修改,修改不处理状态,状态不处理 DOM。
四个包的 src/ 目录如下,后续文章主要涉及这些文件:
- model/src:node.ts、fragment.ts、mark.ts、schema.ts、content.ts、resolvedpos.ts、replace.ts、to_dom.ts、from_dom.ts、diff.ts、dom.ts、comparedeep.ts。
- transform/src:step.ts、map.ts、replace_step.ts、mark_step.ts、attr_step.ts、structure.ts、transform.ts、replace.ts、mark.ts。
- state/src:state.ts、selection.ts、transaction.ts、plugin.ts。
- view/src:index.ts、viewdesc.ts、domobserver.ts、domchange.ts、input.ts、selection.ts、clipboard.ts、decoration.ts、domcoords.ts、capturekeys.ts、browser.ts、dom.ts。
文件名对应的职责如下:model 包含文档数据结构,transform 包含修改原语,state 包含状态和插件,view 的文件分别处理不同的 DOM 交互。
各包的 src/index.ts 列出该层对外提供的 API。model 导出 Node、Fragment、Slice、Mark、Schema、NodeType、MarkType、ContentMatch,外加 DOMParser 和 DOMSerializer 两个方向的 DOM 转换;transform 导出 Transform、Step、StepResult、StepMap、Mapping 和 ReplaceStep、AddMarkStep、AttrStep 这些具体步骤,还有 structure.ts 里 canSplit、canJoin、liftTarget、findWrapping 一批结构判断函数;state 导出的项目较少,包括 EditorState、Transaction、Selection 族(TextSelection、NodeSelection、AllSelection)以及 Plugin、PluginKey、StateField;view 的 index.ts 以 EditorView 类为主,并导出 Decoration、NodeView、MarkView 等用于自定义渲染的类型。四份导出列表中,state 最少,model 和 view 最多。
后续阅读按依赖方向安排:先看 model 的数据结构,再看 transform 的修改原语,然后是 state 的状态和插件,最后是 view 的 DOM 交互;扩展包按依赖范围放在最后。阅读 transform 前需要理解 Slice 和 ResolvedPos,阅读 state 前需要理解 Step 和 Mapping,阅读 view 前需要理解 Transaction;这一顺序由 dependencies 字段体现。
再看一眼 devDependencies。四个核心包都有 @prosemirror/buildhelper,其中 model 还多带一个 jsdom ^20.0.0:model 的 to_dom.ts 和 from_dom.ts 要做 DOM 序列化和解析,在 node 里跑测试需要一个 DOM 实现。四个核心包的 devDependencies 里还都有 prosemirror-test-builder,测试文档的构造工具,这个包本身就是扩展包之一,下面会说到。
扩展包:按依赖范围分类
其余 16 个扩展包均不被核心包依赖。它们的 dependencies 只指向核心层或其他扩展包,依赖方向均指向下层。以下按最深依赖所在层级分类。
依赖到 model 层:
- schema-basic(756726f):dependencies 只有 prosemirror-model ^1.25.0。它提供官方基础 schema 定义,包括 paragraph、heading、code_block 等节点规格,内容为纯数据。
- markdown(6b95bfe):prosemirror-model ^1.25.0 加 markdown-it ^14.0.0,另有一个纯类型包 @types/markdown-it。文档和 Markdown 的双向转换,序列化和解析都只需要文档结构,解析侧直接复用 markdown-it。
- test-builder(629d824):prosemirror-model、prosemirror-schema-basic、prosemirror-schema-list。它是用于测试的文档构造器,被大多数包的 devDependencies 引用;在依赖图中依赖较少,并且靠近核心层。
依赖到 transform 层:
- changeset(3e1c666):只有 prosemirror-transform ^1.0.0。变更集只需要 step 和位置映射。
- inputrules(e3e5545):prosemirror-state ^1.0.0、prosemirror-transform ^1.0.0。输入规则处理输入「# 」后转换为标题等行为,需要拦截文本输入,因此依赖 state。
依赖到 state 层:
- collab(7736c6c):只有 prosemirror-state ^1.0.0。协作协议通过插件系统实现,收发 step 的 JSON。
- keymap(d60e244):prosemirror-state ^1.0.0 加 w3c-keyname ^2.2.0。快捷键插件,w3c-keyname 负责把键盘事件换算成标准键名。
- commands(52a84a8):model、transform、state 三层。内置编辑命令的集合。
- schema-list(1501619):同样三层,列表节点定义加列表编辑命令。
依赖到 view 层:
- history(445409b):state、transform、view,外加 rope-sequence ^1.3.0。undo、redo 栈。src/history.ts 里 Branch 的条目用 RopeSequence 存,这是一个 rope 结构,栈很深时切片和拼接的开销不随栈长线性增长。
- dropcursor(3003cfc):state、transform、view。拖拽时的插入位置指示线。
- gapcursor(72657d0):keymap、model、state、view。它为无法放入文本容器的位置提供特殊选区。
- menu(4f015c6):state、commands、history,外加 crelt ^1.0.0。菜单栏 UI 组件通过 commands 判断按钮是否可执行,通过 history 获取 undo、redo 按钮状态;crelt 用于创建 DOM,menu.ts 和 menubar.ts 开头均有使用。
- search(647a36f):model、state、view。查找替换,高亮命中位置用的是 view 的 Decoration。
- tables(eb522f2):keymap、model、state、transform、view,五个全要。表格包的依赖范围在扩展包中最广,包含 schema 定义、选区类型、编辑命令、列宽拖拽和键盘导航。
组合包:
- example-setup(b6fcf7a):dependencies 有 9 项,inputrules、schema-list、keymap、history、commands、state、menu、dropcursor、gapcursor。它不提供新的功能,而是按固定顺序组合这 9 个包的插件,返回一组可直接挂载到编辑器上的插件。下一篇搭建最小编辑器会使用它。
- schema-table(11fb92f):dependencies 锁定在 ^0.22.0 系列的 model、transform、state,与当前的 ^1.x 核心不能同时安装,阅读源码时可跳过该目录。
22 个包共引入 5 个运行时外部库:orderedmap(model)、w3c-keyname(keymap)、rope-sequence(history)、crelt(menu)、markdown-it(markdown);此外还有编译期使用的类型包 @types/markdown-it,其余依赖均为内部包。核心四层中只有 model 依赖 orderedmap,因此安装 model 还会安装该映射库。
扩展包之间也有少量向下的依赖:gapcursor 和 tables 依赖 keymap,menu 依赖 commands 和 history,test-builder 依赖 schema-basic 和 schema-list,example-setup 依赖 8 个扩展包。被依赖的包提供较小的功能单元:keymap 提供快捷键挂载点,commands 提供命令,history 提供 undo 状态,其他包在此基础上组合功能。将全部 dependencies 绘制成图(见上文 SVG)后,除 state 与 view 间仅限类型层面的互指外,任意两个包之间均不存在环;在运行时,整个仓库构成以 model 为根的有向无环图。
依赖声明中的版本下限也反映了所需 API 的版本。例如,history 需要 view ^1.31.0,search 需要 ^1.33.6,tables 需要 ^1.41.4,说明它们使用的 view API 版本不同。同样,transform 需要 model ^1.21.0,而 schema-list 只需要 ^1.0.0。某个扩展出现「方法不存在」错误时,可先核对这一版本下限。
依赖声明还可用于定位陌生 API 的归属。文档或报错中出现未知类名时,可先确定它所属的包,再查看该包的 dependencies,以了解其输入来源和可调用的下游包。例如,Decoration 定义在 view 中,而 view 依赖 state,因此装饰可由插件经 state 传入渲染层;changeset 只依赖 transform,它计算出的变更区间可用于任何持有 step 序列的场景,无需编辑器实例。package.json 记录了这些包的归属和上下游关系。22 个包的 dependencies 合计不足 60 条,可逐项核对。
拆分为 22 个包的工程原因
dependencies 体现出以下工程方面的原因。
第一,按需安装。dependencies 字段定义每个包的最小依赖闭包:只做文档解析和 diff 的服务端代码可安装 model;需要版本对比时再加入 transform;无需渲染时不必安装 view 及其浏览器兼容代码。changeset 只声明 transform 一个依赖,安装在服务端进行修订统计时,node_modules 中不会出现 DOM 相关代码。若合并为一个 monolith 包,这些场景需要安装完整包。
第二,边界约束。单仓库中「下层不依赖上层」通常依赖 code review 约束;在多包结构中,model 的 package.json 未声明 transform,model 源码导入 prosemirror-transform 会导致构建失败。state 对 view 的反向类型引用也必须使用会在编译期擦除的 import type 形式。分层关系由工具链约束,依赖图中的向下箭头是 package.json 声明的结果。
第三,版本和变更范围独立。dependencies 中的版本要求各不相同:history 需要 state ^1.2.2,search 需要 view ^1.33.6,tables 需要 view ^1.41.4。某个包需要下层的新 API 时,只需提高自身的版本声明,其他包不受影响。阅读一个包所需的前置背景也限于其声明的依赖。devDependencies 同样允许差异:大多数包使用 buildhelper 和 test-builder,tables 则自行声明测试工具 vitest、happy-dom 和构建工具 tsdown,且未引用 @prosemirror/buildhelper。单个包可调整工具链而不影响其他包。
第四,测试独立。各包 devDependencies 普遍包含 @prosemirror/buildhelper 和 prosemirror-test-builder,每个包运行自身 test/ 目录下的用例。transform 的测试不需要 view,修改 view 也不影响 model 的测试。
第五,发布粒度独立。每个包目录下都有自己的 CHANGELOG.md,记录该包每次发版的变更;发版也按包进行,bin/pm 的 release 子命令一次只发布一个模块。使用者升级时只需查看所用包的变更记录,无需查看覆盖 22 个模块的总记录。
相应的代价包括:跨包修改一个 API 需要改动多个仓库并发布多个版本;example-setup 的 9 个依赖包的组合顺序需要人工维护;各包还需分别维护依赖版本下限。该项目以这些成本换取前述特性。
本文梳理了仓库的静态结构:核心四层在运行时单向无环,扩展包均向下依赖,运行时第三方依赖共有 5 个。rfcs 的 12 份提案将在对应模块的文章中引用,搭建 demo 时可参照 website 的依赖清单。下一篇使用 example-setup 组合一个最小编辑器,观察文档的内存表示及一次输入产生的 transaction 结构,为阅读 model 做准备。
