ProseMirror 架构总结
ai生成
1. 一句话概括
ProseMirror 是一个偏底层、模块化、可扩展的富文本编辑器框架。它不直接提供完整产品级编辑器 UI,而是提供文档模型、状态管理、事务变换、插件系统、DOM 视图、选区、历史、快捷键、输入规则、表格等基础能力,让业务方可以在它之上搭建自己的编辑器内核。
如果用一句面试话术概括:
ProseMirror 的核心价值是把富文本编辑从“操作 DOM”抽象成“操作受 schema 约束的不可变文档树”,所有修改通过 transaction 和 step 表达,再由 view 层把新的 editor state 同步到 DOM。
在 Hawk 项目里,ProseMirror 不是直接从 node_modules 使用,而是 vendored 到 packages/prosemirror,当前对应 2025.06.03 左右的版本,主要模块版本大致包括:
| 模块 | 版本 |
|---|---|
prosemirror-model |
^1.25.1 |
prosemirror-state |
^1.4.3 |
prosemirror-view |
^1.40.0 |
prosemirror-transform |
^1.10.4 |
prosemirror-commands |
^1.7.1 |
prosemirror-schema-basic |
^1.2.4 |
2. ProseMirror 的核心架构
2.1 总体分层
1 | |
ProseMirror 的几个关键模块职责很清晰:
| 模块 | 职责 |
|---|---|
model |
定义文档树、schema、node、mark、fragment、slice、DOM 解析和序列化 |
state |
定义 editor state、selection、transaction、plugin 和 plugin state |
transform |
定义 step、step map、mapping 和 transform,用于描述文档变更 |
view |
把 editor state 渲染到 DOM,处理输入事件、DOM selection、NodeView、Decoration |
commands |
提供常见编辑命令,如 split block、join、delete selection、toggle mark 等 |
history |
基于 transaction/step 实现 undo/redo |
keymap |
把键盘事件映射为 command |
input-rules |
根据输入文本触发自动转换,如 # 转 heading |
tables |
表格 schema、cell selection、table map、列宽、复制粘贴等能力 |
2.2 文档模型:受 Schema 约束的树
ProseMirror 文档不是 HTML 字符串,也不是浏览器 DOM,而是一棵结构化的不可变文档树:
1 | |
核心概念:
| 概念 | 说明 |
|---|---|
Schema |
定义允许出现哪些 node/mark,以及它们之间的嵌套规则 |
Node |
文档树中的结构节点,例如 doc、paragraph、heading、table、image |
Mark |
行内格式或语义,例如 bold、italic、link、comment |
Fragment |
一组子节点的不可变集合 |
Slice |
剪切/粘贴/替换时使用的文档片段,带 open depth 信息 |
ResolvedPos |
对整数 position 的结构化解释,可获取父节点、深度、offset 等 |
这套模型的优点是编辑行为可以严格受 schema 约束。例如 paragraph 里能放 inline,table_cell 里能放 block,image 是 inline atom 还是 block node,都可以由 schema 明确描述。
2.3 坐标系统:整数 position + ResolvedPos
ProseMirror 用整数 position 表示文档位置。这个坐标不是 DOM offset,也不是纯文本下标,而是树结构里的位置索引。
典型操作:
1 | |
这套 position 系统是 ProseMirror 难学的地方之一,但也是它强大的基础。因为 transform、selection、collaboration、history、decoration 都可以基于同一套 position mapping 机制工作。
2.4 状态模型:不可变 EditorState
EditorState 包含重建当前编辑器所需的核心状态:
- 当前文档
doc - 当前选区
selection - 当前 stored marks
- 插件状态
plugin state - schema 和 plugins 配置
ProseMirror 不鼓励直接改 DOM,也不鼓励直接 mutate state。标准流程是:
1 | |
这个流程让所有编辑动作都可追踪、可测试、可回放。
2.5 Transaction 和 Step:编辑行为的最小表达
Transaction 是一次编辑意图的载体。它继承自 Transform,内部可以包含多个 Step。
常见 step 包括:
| Step | 说明 |
|---|---|
ReplaceStep |
替换一段内容 |
ReplaceAroundStep |
包裹、提升、复杂结构替换 |
AddMarkStep |
给范围添加 mark |
RemoveMarkStep |
移除范围内的 mark |
AttrStep |
修改节点属性 |
DocAttrStep |
修改 doc 属性 |
Step 的关键价值不只是“修改文档”,还包括:
- 可以 apply 到旧文档生成新文档
- 可以 invert,用于 undo
- 可以生成
StepMap,把旧 position 映射到新 position - 可以序列化,方便协同、回放和持久化
在协同编辑、评论锚点、远程光标、历史记录、装饰层更新里,StepMap 和 Mapping 非常关键。
2.6 Selection:不只文本光标
ProseMirror 的 selection 是状态的一部分,并且支持扩展。
内置常见类型:
| Selection | 说明 |
|---|---|
TextSelection |
文本光标或文本范围 |
NodeSelection |
选中一个完整节点,如图片、附件 |
AllSelection |
选中整个文档 |
CellSelection |
表格插件提供的单元格范围选区 |
这比 window.getSelection() 更适合复杂编辑器,因为它可以跟随 transaction 映射,也可以参与 undo/redo 和插件状态计算。
2.7 Plugin:扩展编辑器行为的核心机制
ProseMirror 的插件系统是它最重要的扩展点之一。一个 plugin 可以提供:
state:自己的插件状态props:拦截 DOM 事件、处理 paste/drop、提供 decorations、nodeViews 等appendTransaction:在 transaction 之后追加修正 transactionfilterTransaction:阻止某些 transactionview:创建和销毁 plugin viewkey:通过PluginKey定位插件实例和状态
典型插件能力:
- 快捷键
- 历史 undo/redo
- 复制粘贴
- 表格选择
- placeholder
- 评论锚点
- 协同光标
- 目录
- slash menu
- format painter
- find/replace
面试时可以强调:ProseMirror 的插件更接近“编辑器中间件 + 状态切片 + 事件扩展点”的组合,而不是简单的 UI plugin。
2.8 View:DOM 只是 EditorState 的投影
EditorView 负责把 EditorState 展示到 DOM,并处理用户输入事件。
重要能力:
| 能力 | 说明 |
|---|---|
| DOM diff/update | 根据 state 更新 DOM |
| DOM event handling | 处理键盘、鼠标、输入法、粘贴、拖拽 |
| DOM selection sync | 同步浏览器 selection 和 ProseMirror selection |
| NodeView | 自定义复杂节点渲染和交互 |
| MarkView | 自定义 mark 渲染 |
| Decoration | 不改文档内容的临时渲染,如搜索高亮、远程光标 |
NodeView 是做复杂富文档节点的关键,例如图片、附件、表格、公式、mention、网页卡片等。它允许业务方自己控制节点 DOM、事件、更新逻辑和销毁逻辑。
3. ProseMirror 的优点
3.1 数据模型严谨,适合复杂文档
ProseMirror 使用 schema 约束文档结构,天然适合复杂文档编辑器。列表、表格、嵌套块、块级卡片、行内卡片、评论、协同锚点等能力可以通过 node/mark/attrs 建模,而不是混在 HTML 里靠约定解析。
3.2 Transaction/Step 机制适合协同、历史和可测试性
每次变更都能表示为 transaction 和 step,使编辑行为具备可追踪、可回放、可映射、可撤销的基础。这对协同编辑、undo/redo、评论位置跟随、远程光标、操作日志分析都很重要。
3.3 插件机制强,可做产品级扩展
插件可以介入状态、事件、transaction 生命周期和视图渲染,适合构建大型编辑器。业务能力可以拆到不同 plugin,避免把所有逻辑堆在一个 editor class 里。
3.4 视图层相对解耦
ProseMirror 自己管理 DOM,但不绑定 React/Vue。业务可以在 NodeView 中接入任意 UI 框架,也可以把核心编辑能力做成 SDK,被不同产品壳复用。
3.5 对结构化文档更友好
相比以 Delta 或 HTML 为中心的方案,ProseMirror 的树形文档模型更自然地表达:
- 多级嵌套结构
- 表格
- task list
- block quote
- code block
- block card
- heading hierarchy
- rich document object
3.6 生态成熟
ProseMirror 的底层能力稳定,Tiptap、Milkdown、Remirror 等框架都基于 ProseMirror 做了更高层封装。对于需要自研编辑器内核的团队,ProseMirror 是比较可靠的底座。
4. ProseMirror 的缺点
4.1 学习曲线陡
ProseMirror 的核心概念多,而且抽象偏底层。新人需要理解 schema、node、mark、position、ResolvedPos、transaction、step、mapping、selection、plugin、NodeView 等一整套体系。
尤其是 position 坐标系统和 step mapping,对没有编辑器经验的人不直观。
4.2 API 偏底层,产品能力需要自建
ProseMirror 不提供开箱即用的完整编辑器产品。工具栏、菜单、图片上传、评论、协同 UI、slash menu、目录、权限、移动端适配等都需要业务自己实现。
这对中大型团队是灵活性,对小团队则是成本。
4.3 插件之间容易产生复杂交互
多个 plugin 同时处理 paste、keydown、appendTransaction、decorations、selection 时,顺序和边界很重要。大型编辑器如果缺少规范,容易出现:
- transaction 被多个 plugin 反复修正
- selection mapping 错误
- decoration 位置漂移
- NodeView update 与 DOMObserver 冲突
- paste 规则互相覆盖
4.4 DOM 和状态同步问题难排查
ProseMirror 自己维护 DOM view tree。如果 NodeView 写得不规范,或者业务直接操作 ProseMirror 管理的 DOM,可能导致 selection、composition、mutation observer、view update 出现复杂问题。
输入法、移动端 Safari、复制粘贴、表格拖拽尤其容易踩坑。
4.5 Schema 设计前期压力大
schema 一旦用于线上文档数据,就会变成协议。node/mark/attrs 的设计如果前期不稳定,后续迁移成本很高。复杂编辑器必须提前考虑兼容、降级、未知节点、序列化和跨端解析。
5. 与 Quill、Slate、Lexical、Tiptap 的对比
5.1 总览对比
| 维度 | ProseMirror | Quill | Slate | Lexical | Tiptap |
|---|---|---|---|---|---|
| 定位 | 底层编辑器框架 | 开箱即用富文本编辑器 | 高度可定制的 React 编辑器框架 | Meta 出品的现代编辑器框架 | ProseMirror 的上层封装 |
| 数据模型 | Schema 约束的树 | Delta 线性操作模型 + Parchment Blot | JSON 树,操作通过 operations | EditorState + node tree | 本质仍是 ProseMirror schema/doc |
| UI 绑定 | 不绑定框架 | 自带编辑器实例和主题 | 强依赖 React 使用习惯 | 支持 React 但核心独立 | 常与 Vue/React 使用 |
| 扩展方式 | Plugin、NodeView、Schema | Module、Blot、Clipboard matcher | Plugin pattern、normalization、renderElement | Node、Command、Transform、Plugin-like extension | Extension |
| 复杂结构 | 强 | 中等,复杂嵌套成本高 | 强,但需要自己处理很多约束 | 强 | 强 |
| 协同基础 | Step/Mapping 适合协同 | Delta 天然适合 OT 表达 | 需自行设计或接入生态 | 有现代协作生态,但选型需评估 | 依赖 ProseMirror/Yjs 等 |
| 学习成本 | 高 | 中低 | 中高 | 中高 | 中 |
| 开箱程度 | 低 | 高 | 中低 | 中 | 高于 ProseMirror |
| 适合场景 | 自研复杂文档、长期演进 SDK | 标准富文本、轻量编辑 | React 深度定制编辑器 | 现代 Web 编辑器、性能敏感场景 | 快速基于 ProseMirror 做产品 |
5.2 ProseMirror vs Quill
Quill 的核心数据格式是 Delta。Delta 本质上是一个线性的操作序列,常见形式是 insert/retain/delete 加 attributes。
Quill 的优势:
- API 更简单,上手更快
- 默认主题、toolbar、clipboard 等能力更开箱
- Delta 模型适合表达文本编辑操作和 OT
- 对常规富文本场景足够高效
Quill 的劣势:
- 面对复杂树形结构时表达力弱于 ProseMirror
- 深度嵌套、复杂表格、块级卡片、复杂 schema 约束会变得困难
- 很多能力依赖 Parchment/Blot 扩展,复杂后调试成本上升
- 数据模型更接近线性文档,对结构化富文档不如树模型自然
ProseMirror 相比 Quill 的核心优势:
- 文档结构表达能力更强
- schema 可以严格约束文档合法性
- step map 对 selection、decoration、协同锚点跟随很有价值
- plugin 生命周期更适合大型编辑器内核拆分
面试回答可以这样说:
如果做一个中等复杂度的富文本输入框,Quill 更省事;如果做类似在线文档、表格、评论、协同、复杂块结构、长期协议兼容的编辑器,ProseMirror 的架构更稳。
5.3 ProseMirror vs Slate
Slate 也是树形文档模型,并且非常强调可定制。它和 React 结合紧密,开发体验对 React 团队比较友好。
Slate 的优势:
- JSON 数据结构直观
- React renderElement/renderLeaf 模式容易理解
- 自定义节点和渲染很灵活
- 适合做深度定制的编辑体验
Slate 的劣势:
- 很多约束需要应用层自己维护 normalization
- 历史、协同、复杂 schema、复杂表格等能力需要更多自建或依赖生态
- 对大型文档和复杂插件体系,工程纪律要求高
ProseMirror 相比 Slate 更强的点:
- schema 约束更系统
- transform/step/mapping 更成熟
- 插件和 transaction 生命周期更完整
- 对复杂编辑器底层行为的可预测性更强
Slate 相比 ProseMirror 更舒服的点:
- React 心智更自然
- 文档 JSON 更容易直接读写
- 简单定制时心智负担小一些
5.4 ProseMirror vs Lexical
Lexical 是 Meta 推出的现代编辑器框架,强调性能、可扩展、命令机制和不可变 editor state。
Lexical 的优势:
- 架构现代,更新批处理和状态模型清晰
- 性能表现好
- 命令、节点、transform 等机制清楚
- React 集成体验好
- 对现代前端团队比较友好
Lexical 的潜在劣势:
- 生态和历史沉淀相比 ProseMirror 仍需要结合具体场景评估
- 如果已有 ProseMirror 生态、插件、数据协议和协同链路,迁移成本高
- 一些复杂文档能力需要团队自己验证成熟度
ProseMirror 的优势在于成熟和底层能力完整,Lexical 的优势在于现代化和性能体验。新项目如果没有历史包袱,可以评估 Lexical;但如果目标是复杂在线文档内核,ProseMirror 仍然是非常稳的选择。
5.5 ProseMirror vs Tiptap
Tiptap 不是 ProseMirror 的竞争对手,而是 ProseMirror 的上层封装。它把 schema、command、plugin、NodeView 等能力包装成 extension 体系,并提供更现代的 API。
Tiptap 的优势:
- 开箱体验比 ProseMirror 好
- extension 写法更统一
- 文档和社区更贴近产品开发
- React/Vue 集成更方便
Tiptap 的劣势:
- 底层问题仍然要理解 ProseMirror
- 高度定制时可能需要绕过封装
- 对大型自研 SDK,封装层可能限制底层控制力
如果团队目标是快速做业务编辑器,可以选 Tiptap;如果目标是掌控底层协议、事务、插件、协同、渲染和跨产品 SDK,直接使用 ProseMirror 更可控。
6. 为什么 Hawk 这类项目适合 ProseMirror
结合当前仓库,Hawk 是一个新一代文档编辑器 SDK,而不是简单页面组件。它需要处理:
- richdoc 协议适配
- schema 长期演进
- 表格、图片、附件、公式、mention、未知节点
- 复制粘贴和跨应用兼容
- 命令 API
- NodeView/MarkView 渲染
- 协同位置映射
- 插件化编辑行为
- Jest/Playwright/Storybook 测试体系
这些目标更接近“编辑器内核工程”,而不是“富文本输入框”。因此 ProseMirror 的强 schema、transaction、step mapping、plugin、NodeView 机制更适合长期演进。
可以把 Hawk 的分层理解为:
1 | |
7. 面试官可能会问的问题与答案
Q1:ProseMirror 的核心架构是什么?
答:ProseMirror 可以分为 model、state、transform、view 四层。model 定义 schema、node、mark 和文档树;state 保存不可变的 editor state、selection 和 plugin state;transform 用 step/transaction 表达文档修改,并通过 step map 做位置映射;view 负责把 state 渲染到 DOM 并处理用户输入。业务能力主要通过 plugin、command、NodeView、Decoration 扩展。
Q2:为什么说 ProseMirror 不是直接操作 DOM?
答:因为 ProseMirror 的真实数据源是 EditorState.doc,DOM 只是 view 层的投影。编辑时应该创建 transaction 修改文档树,再 dispatch 生成新 state,最后由 view 更新 DOM。如果绕过 ProseMirror 直接改 DOM,容易破坏 selection、mutation observer 和 view desc 的一致性。
Q3:Schema 在 ProseMirror 里解决什么问题?
答:Schema 定义文档允许有哪些 node 和 mark,以及它们的嵌套关系和属性。它保证文档结构合法,也决定 DOM parse/serialize 规则。对在线文档来说,schema 也是数据协议的一部分,设计不稳定会带来兼容和迁移成本。
Q4:Transaction 和 Step 有什么区别?
答:Step 是最小的文档修改单元,例如 replace、add mark、remove mark、改属性。Transaction 是一次编辑动作的容器,可以包含多个 step,同时还可以携带 selection、stored marks 和 meta。可以理解为 step 描述“怎么改文档”,transaction 描述“一次编辑行为及其上下文”。
Q5:StepMap / Mapping 为什么重要?
答:文档变化后,旧 position 可能失效。StepMap 描述一次 step 如何把旧位置映射到新位置,Mapping 则串联多个 StepMap。它用于更新 selection、decoration、评论锚点、远程光标、协同位置和 undo/redo。复杂编辑器里位置映射是底层关键能力。
Q6:ProseMirror 插件能做什么?
答:插件可以维护自己的状态、处理 DOM 事件、提供 decorations、注册 NodeView、拦截或追加 transaction、实现 plugin view。快捷键、历史、输入规则、复制粘贴、表格选择、评论、协同光标、目录、搜索高亮都可以用 plugin 实现。
Q7:appendTransaction 和 filterTransaction 有什么区别?
答:filterTransaction 在 transaction 应用前执行,用来判断是否允许这次 transaction;appendTransaction 在一批 transaction 应用后执行,可以根据新旧 state 追加一个新的 transaction,常用于结构修正、补齐属性、清理非法状态等。
Q8:NodeView 适合解决什么问题?
答:NodeView 用于自定义节点的 DOM 渲染和交互,适合图片、附件、公式、mention、网页卡片、复杂表格单元等不适合用普通 DOMSerializer 表达的节点。NodeView 可以控制 update、ignoreMutation、stopEvent、destroy 等生命周期。
Q9:Decoration 和 Mark 有什么区别?
答:Mark 是文档内容的一部分,会进入 doc 数据,例如 bold、link、comment mark。Decoration 是 view 层临时渲染,不改变文档数据,例如搜索高亮、选区提示、远程光标、拼写错误波浪线。需要持久化的语义用 mark,不需要进入文档的数据用 decoration。
Q10:ProseMirror 和 Quill 最大区别是什么?
答:Quill 以 Delta 线性操作模型为核心,上手更快、开箱程度更高,适合常规富文本。ProseMirror 以 schema 约束的树形文档模型为核心,transaction/step/mapping 更强,适合复杂结构化文档、表格、块级节点、协同锚点和长期协议演进。
Q11:为什么复杂在线文档更倾向 ProseMirror?
答:复杂在线文档需要严格结构、嵌套块、表格、卡片节点、评论、协同、历史、复制粘贴和协议兼容。ProseMirror 的 schema、step、mapping、plugin、NodeView 能把这些能力拆成可组合的底层机制,而不是堆在 DOM 或字符串处理里。
Q12:ProseMirror 的主要缺点是什么?
答:学习曲线高,API 偏底层;产品能力不够开箱,需要业务自建;插件之间可能产生复杂交互;NodeView 和 DOM 同步问题难排查;schema 设计前期压力大,一旦线上数据依赖后迁移成本高。
Q13:ProseMirror 如何支持协同编辑?
答:ProseMirror 的 step 可以序列化、应用、反转,并且每个 step 有 StepMap,可以做位置映射。这为协同编辑提供了底层基础。但 ProseMirror 本身不是完整协同系统,仍需要 OT/CRDT 服务、冲突处理、权限、网络同步和业务协议适配。
Q14:为什么复制粘贴在 ProseMirror 编辑器里很复杂?
答:复制粘贴涉及 Slice、open depth、schema 合法性、DOMParser、DOMSerializer、clipboard text/html/plain、自定义内部格式、表格结构、外部 HTML 清洗、图片和文件、跨浏览器差异。复杂编辑器还要保证内部复制保真、外部粘贴安全和结构合法。
Q15:ProseMirror 里如何设计一个新节点?
答:通常要先确定节点语义和层级,是 block、inline、atom 还是 selectable;再设计 attrs 和兼容策略;然后写 NodeSpec,包括 content、group、marks、parseDOM、toDOM;如果交互复杂再实现 NodeView;最后补 command、paste 规则、序列化、测试和旧数据兼容。
Q16:什么时候用 Mark,什么时候用 Node?
答:Mark 适合描述一段 inline 内容上的附加语义或样式,例如 bold、link、comment。Node 适合描述文档结构或独立对象,例如 paragraph、heading、image、table、mention。判断标准是它是否改变文档结构、是否需要独立选中、是否有复杂属性和生命周期。
Q17:怎么避免 ProseMirror 插件变得混乱?
答:要明确插件边界:一个插件只负责一类状态和行为;避免多个插件同时改同一类 transaction;把 command、state、view、utils 分层;对 appendTransaction 保持克制;关键行为补 transaction 级单测;复杂 UI 通过 plugin state 和 NodeView/外层 React 状态建立清晰同步关系。
Q18:如果面试官问“为什么不用 Tiptap”怎么答?
答:Tiptap 是 ProseMirror 的上层封装,适合快速做产品编辑器。但如果项目需要深度控制 schema、transaction、step、mapping、插件生命周期、协议适配、协同和底层补丁,直接基于 ProseMirror 更可控。Hawk 这类 SDK 项目更关注内核能力和长期维护,因此直接掌控 ProseMirror 底层更合理。
Q19:ProseMirror 和 Slate 怎么选?
答:如果团队强 React、需求是高度自定义但结构复杂度可控,Slate 开发心智更自然。如果需求是复杂文档内核、强 schema、协同位置映射、表格和长期协议稳定,ProseMirror 更成熟。Slate 灵活,ProseMirror 更系统。
Q20:如何向非编辑器背景的人解释 ProseMirror?
答:可以类比 Redux。EditorState 类似 store state,transaction 类似 action 加 reducer 上下文,plugin 类似中间件和状态切片,view 类似根据 state 渲染 UI。区别是 ProseMirror 的 state 是文档树和选区,transaction 内部还有 step 和位置映射,专门解决富文本编辑问题。
8. 面试表达建议
回答 ProseMirror 相关问题时,不要只背 API 名词,最好按下面结构回答:
- 先讲核心抽象:schema 约束的文档树。
- 再讲编辑流程:command/input -> transaction -> step -> new state -> view update。
- 再讲扩展机制:plugin、NodeView、Decoration。
- 最后结合业务:复杂文档、表格、复制粘贴、协同、协议兼容为什么需要这些机制。
一个比较完整的回答模板:
我理解 ProseMirror 的核心是 model/state/transform/view 四层。model 用 schema 定义合法文档树,state 保存不可变编辑器状态,transform 用 transaction 和 step 表达每次修改,view 负责 DOM 渲染和输入事件。它的难点在 position、mapping、plugin 交互和 NodeView,但这些能力也让它适合复杂在线文档。相比 Quill,ProseMirror 更底层、更难上手,但结构表达、事务追踪、协同位置映射和大型插件化能力更强。