ProseMirror 仓库全景:22 个包怎么分工

3 分钟阅读
·

上一篇谈了 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 tracker,以及放一个脚本把其余包拉下来一起开发。脚本在 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。

ProseMirror 模块依赖关系

四个包的 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 是公共面的清单,扫一眼就知道这层向外提供什么。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,undo、redo 按钮的状态来自 history,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,其余依赖全部内部消化。核心四层里只有一个 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 为根的有向无环图。

依赖声明里的版本下限也值得看。同为依赖 view,history 要 ^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 的话,这些场景只能整包背走。

第二,边界强制。单仓库里「下层不依赖上层」靠 review 自觉,多包结构里它是物理约束:model 的 package.json 没有声明 transform,model 源码里写一行 import prosemirror-transform 就直接构建失败。state 那种类型层面的反向引用,也必须用 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。单个包换工具链不惊动兄弟包,这在 monolith 里很难做到。

第四,测试独立。各包 devDependencies 里普遍是 @prosemirror/buildhelper 加 prosemirror-test-builder,每个包跑自己 test/ 目录下的用例。transform 的测试不需要 view 存在,改 view 也不会惊动 model 的测试。

第五,发布粒度细。每个包目录下有自己的 CHANGELOG.md,记录这个包每次发版改了什么;发版动作也按包走,bin/pm 的 release 子命令一次只发一个模块。使用者升级时只需要读自己用到的那几个包的变更记录,不用翻一份覆盖 22 个模块的总账。

代价也有:跨包改一个 API 要动多个仓库、发多个版本;example-setup 那种 9 个依赖的拼装顺序得人工维护;依赖下限像上面说的那样需要各自盯着。这个项目接受这些代价,换上面四条。

这一篇把仓库的静态结构看完了:核心四层在运行意义上单向无环,扩展全部朝下依赖,第三方依赖只有 5 个。rfcs 的 12 份提案留到对应模块的篇章里引用,website 的依赖清单在搭 demo 时可以当参照。下一篇动手,用 example-setup 拼一个最小编辑器跑起来,看文档在内存里长什么样、一次输入产生的 transaction 是什么结构,给后面读 model 先建立一个直观印象。


1116 字 · 65 段落
xi ming

Written by xi mingFollow onGitHub