ProseMirror — schema 先定 DOM 后服从的富文本编辑器框架
是什么
ProseMirror 是 Marijn Haverbeke 写的富文本编辑器框架——不是开箱即用的编辑器组件,是一套让你自己定义文档结构再让浏览器服从的协议。日常类比:像办报纸排版——主编先定”标题字号 + 段落间距 + 引文格式”的版式规则,作者写稿子时只能在版式里填内容,不能擅自加一种新格式。
浏览器里有个原生 API 叫 contentEditable:给任意 div 加 contenteditable="true",用户就能在里面输入和粘贴。问题是它没规矩——用户粘进来一段 Word 文本,浏览器自己生成一堆 <font> 和嵌套到第六层的 <span>,谁也保证不了文档结构合法。
ProseMirror 的反转是:你先用 schema 声明合法形态(“段落里只能有文本和加粗,标题只能有文本不能有加粗”),然后任何编辑都被拆成 Step 序列——每一步都能 apply、能 invert(撤销)、能 map(远端来的 step 重新对齐到本地最新位置)。Tiptap、Atlassian Editor、The New York Times、早期 Notion 都站在它上面。
为什么重要
不理解 ProseMirror,下面这些事都没法解释:
- 为什么 Notion / Atlassian / Linear 这种富文本能做协同编辑,但很多自研编辑器加协同就崩——Step 抽象让 rebase 几乎免费
- 为什么 Tiptap 不自己重写一个编辑器内核——因为重写就要重新证明 schema 约束、协同 rebase、undo/redo 全都不出 bug
- 为什么 Slate.js 文档看着更友好但生产环境踩坑更多——它直接 patch DOM,没有 Step 这层抽象
- 为什么 contentEditable 二十年了还没被一个”更好的 API”取代——浏览器知道它有问题但没人提得出更好的替代
核心要点
ProseMirror 的设计可以拆成 三件抽象:
-
Schema(合法形态判定器):你声明”什么节点能套什么节点”——doc 里能有 paragraph 和 heading,paragraph 里能有 text 和加粗 mark,heading 里只能有 text。类比:办报纸的版式手册,违反规则的内容直接被拒。
-
Step(原子修改的语法):每一次编辑——插入字符、删除一段、加粗一个词——都是一个 Step 对象。它必须实现三件事:apply(在文档上执行)、invert(生成反向 step 用于 undo)、map(把自己重新对齐到一个新的位置链上)。类比:会计分录,每一笔都能正向记和反向冲。
-
State + View(不可变快照 + 浏览器薄壳):State 是某一刻的完整文档 + 选区 + plugin 状态,不可变。View 把当前 State 渲染到一块 contentEditable,并用 MutationObserver 捕获浏览器对 DOM 的偷改,把它翻译回 Step。类比:React 的 state + render,但加了一层”浏览器在偷偷改 DOM 我得抓回来”的反向通道。
三件抽象合起来的效果:协同编辑不是后挂的功能,是 Step 抽象的副产品——远端发来的不是 DOM 而是 Step 序列,本地把它 map 过自己最近的 step 链,就能干净 apply。
实践案例
案例 1:最小可用的段落编辑器
import { Schema } from 'prosemirror-model'import { EditorState } from 'prosemirror-state'import { EditorView } from 'prosemirror-view'
const schema = new Schema({ nodes: { doc: { content: 'paragraph+' }, paragraph: { content: 'text*', toDOM: () => ['p', 0] }, text: {}, },})
const state = EditorState.create({ schema })new EditorView(document.querySelector('#editor'), { state })逐部分解释:
content: 'paragraph+'是 content 表达式——doc 里必须有至少一个 paragraphtoDOM: () => ['p', 0]告诉 ProseMirror 怎么把 paragraph 渲染成 DOM,0 是子节点占位符- 没传 dispatch 也能跑,但用户的输入会被直接吃掉——下一个案例补上
案例 2:自己写一个 ReplaceStep 看三件套
import { ReplaceStep } from 'prosemirror-transform'import { Slice } from 'prosemirror-model'
// 在位置 5 处插入一段 sliceconst step = new ReplaceStep(5, 5, slice)const result = step.apply(doc) // 得到新 docconst inverse = step.invert(doc) // 得到撤销 stepconst remapped = step.map(otherMap) // 远端到来时重新对齐位置逐部分解释:
apply在旧 doc 上跑得到新 doc,旧 doc 不变(immutable)invert给 history plugin 用,撤销时把 inverse 反过来 apply 一次map是协同编辑的灵魂——本地刚 typed 了几个字,远端发来一个 step,map 让远端 step 的位置自动避开本地的新字
案例 3:协同编辑的最小骨架
import { collab, sendableSteps, receiveTransaction } from 'prosemirror-collab'
const state = EditorState.create({ schema, plugins: [collab()] })// 本地有未提交 step 时const sendable = sendableSteps(state)if (sendable) socket.send(JSON.stringify(sendable.steps))// 远端来 step 时socket.on('message', steps => { view.dispatch(receiveTransaction(view.state, steps, clientIds))})逐部分解释:
collab()plugin 帮你管 step version 号和未确认 step 队列sendableSteps拿出本地未确认的 step 发到服务端receiveTransaction把远端 step 应用到本地——内部会 rebase 本地未确认 step
踩过的坑
- schema content 表达式 ‘inline’ 和 ‘inline+’ 行为差异巨大*——前者允许空段落,后者不允许,编辑空段落时表现完全不同
- 协同编辑不能直接发 DOM diff——Slate.js 的设计在并发删除+插入场景会让两端文档分裂,必须走 Step 序列化
- View 层 contentEditable 兼容性是永远的苦活——Safari shadow root、Chrome IME、Firefox space-eaten 各自要专门补丁,看 prosemirror-view 的 commit 历史就知道
- 自定义 NodeView 忘了 update / destroy 会内存泄漏——React 包装层尤其容易出现 stale closure,每次 dispatch 都把旧组件留在内存里
适用 vs 不适用场景
适用:
- 需要协同编辑的富文本(Notion / Atlassian / Linear 这类)——Step 抽象天然适配 OT/CRDT
- 文档结构需要强约束的场景——医疗病历、法律合同、技术写作工具
- 已经有自己的 schema 设计且不愿被现成编辑器锁死的团队
- 需要可预测的 undo / redo 行为——Step.invert 让历史栈干净
不适用:
- 只需要简单评论框 / 帖子输入框——直接 textarea 或 contentEditable 即可,ProseMirror 上手成本太高
- 团队没人能维护 schema 和 plugin——这是个框架不是组件,必须自己写胶水
- 需要 React/Vue 风格 declarative API——直接用 tiptap 这种包装层
- 富文本但完全没有结构(如纯日记本无格式)——Lexical 或纯 markdown 编辑器更轻
历史小故事(可跳过)
- 2013 年:Marijn Haverbeke 已经在维护 CodeMirror,意识到富文本场景需要类似的”plugin + immutable state”范式
- 2014-2016 年:ProseMirror 雏形迭代,2016 年发布首个稳定版本,分成 6 个独立 npm 包
- 2018 年:全面 TypeScript 重写
- 2020 年:Tiptap v2 在它之上做 React/Vue 包装,让前端开发者能用而不是只有编辑器专家能用
- 2022 年:Atlassian 把内部 Editor 抽出来公开发布,证明它能扛超大型 SaaS 的复杂需求
学到什么
- schema 先定 DOM 后服从是反直觉但威力巨大——把”我接受任何 DOM”反过来变成”DOM 必须满足 schema”,整套约束链就稳了
- 协同编辑应该是 Step 抽象的副产品而不是后挂功能——任何先做编辑器再加协同的项目都会被并发场景反噬
- immutable + apply/invert/map 三件套是事件溯源思想在富文本场景的一次成功落地
- bus factor 的现实:核心维护者一个人(Marijn)同时挑 CodeMirror、Lezer、ProseMirror,重要项目要警惕单点依赖
延伸阅读
- 官方文档:ProseMirror Guide(最权威,作者亲笔,但学术腔重)
- 作者博客:The Architecture of ProseMirror(30 分钟讲完整体设计哲学)
- 视频:ProseMirror Deep Dive(社区讲座,多个版本可选)
- codemirror —— 同作者的代码编辑器,分包思路一脉相承
- tiptap —— ProseMirror 的 React/Vue 包装层,多数前端真正在用的是它
关联
- codemirror —— 同作者的代码编辑器,6 包架构和 plugin 范式如出一辙
- tiptap —— ProseMirror 上层包装,让 React/Vue 项目零成本接入
- lezer —— 同作者的增量语法分析器,CodeMirror 6 用它做高亮,思路上和 ProseMirror 的 ContentMatch DFA 互通
- slate-js —— 设计上的反面教材:直接 patch DOM 没有 Step 抽象,协同场景吃亏
- lexical —— Meta 写的新一代富文本框架,借鉴了 ProseMirror 的不可变思路但 API 更 declarative
反向链接
- affine —— AFFiNE — 文档和白板共用同一棵 block 树的开源知识库
- codemirror —— CodeMirror — 编辑器不是一个类,是一组扩展的合奏
- excalidraw —— Excalidraw — 手绘风协作白板
- fabric-js —— Fabric.js — 给 Canvas 加一层”对象模型”,让画布图形可以拖
- hocuspocus —— Hocuspocus — 给 Yjs 配一个能直接上线的协作后端
- monaco-editor —— monaco-editor — 把 VSCode 编辑器搬进浏览器的 SDK
- yjs —— Yjs — 让任何编辑器都能接的协同编辑内核