
富文本编辑器内核Slate 与 ProseMirror 的文档模型及协同机制一、contenteditable 的陷阱为什么富文本编辑器需要自建文档模型去年我们给一个协作文档产品做内核升级原方案直接基于 contenteditable上线三个月 bug 单堆到两百多。最典型的是「换行幽灵」同样的回车操作Chrome 产生divSafari 产生pFirefox 产生brbr复制粘贴时还混入 Word 的私有标签。这事我见过太多团队栽进去——以为 contenteditable 是免费午餐最后全在补 DOM 差异。contenteditable 的根本问题在于它让浏览器持有文档真相。HTML DOM 既是数据模型又是渲染层任何浏览器的解析差异、粘贴脏数据、光标行为不一致都会直接污染业务数据。你无法保证「同一份文档在不同浏览器里结构一致」因为真相本就不在你手里。富文本编辑器要走出这个泥潭必须自建文档模型。核心思路是「数据与渲染分离」用一棵自描述的 JSON 树或不可变结构作为文档真相contenteditable 只负责把模型渲染成 DOM 并接收用户输入任何输入都先转换为模型变换Transformation再由模型驱动重渲染。DOM 退化为视图层不再被信任。Slate 与 ProseMirror 是这一思路的两个代表实现。Slate 用纯 JSON 树描述文档所有编辑操作是对 JSON 的不可变变换状态可回溯、可序列化。ProseMirror 则引入 Schema 约束文档结构配合 Decoration 做无副作用的临时装饰如高亮、批注光标更贴近数据库式的严谨建模。两者都把「选区」从 DOM Range 中抽离出来用纯数据结构表达。这样光标的移动、选区的扩展都变成可计算的变换不再依赖浏览器对 Range 的不一致实现。协同编辑也才有了基础所有编辑动作都被归约为「对文档模型的一次变换」可以序列化、传输、回放。二、文档模型与变换Slate 的 JSON 树与 ProseMirror 的 SchemaSlate 的文档模型是一棵嵌套 JSON。Document 包含一组 Block 节点Block 包含 Inline 节点Inline 包含 Text 节点。每个节点都是普通对象文本内容存在text字段样式以bold、italic等标记属性附在 Text 上。这棵树天然可序列化存数据库、传网络都无需中间格式。Slate 的编辑操作被归约为「变换」Transforms。插入文本、删除节点、设置标记都对应一个 Transform 函数。变换是纯函数输入旧文档与参数输出新文档旧文档不被修改。这种不可变设计带来三个直接好处撤销栈天然可建旧文档还在、状态可快照、协同时只需传输变换而非整篇文档。ProseMirror 走得更远。它用 Schema 定义合法的文档结构哪些节点能嵌套哪些、哪些标记能附着在哪些节点上。任何不在 Schema 内的节点会被拒绝写入模型从源头杜绝脏数据。文档本身是一棵不可变树修改通过Node.replace生成新实例配合 Transaction 记录变更步骤。ProseMirror 的 Decoration 是另一个精巧设计。它允许在不修改文档模型的前提下给视图层添加临时标记——比如协同场景下其他用户的光标位置、搜索高亮、拼写检查下划线。这些装饰是「视图态」不污染真相退出后文档回到干净状态。Slate 后期也引入了类似的概念但实现不如 ProseMirror 彻底。协同编辑的核心难题是「并发修改冲突」。两个用户同时编辑同一处文本若都基于旧版本做变换合并时就会互相覆盖。主流解法有两套OTOperational Transformation与 CRDTConflict-free Replicated Data Type。OT 通过变换函数调整并发操作的顺序与位置保证最终一致CRDT 则设计数据结构本身具备合并确定性无需中心化变换。综上协同编辑的关键在路径统一DOM 事件不直接改 DOM而是先转 Transform、过 Schema、改模型、再渲染协同端变更也走同一条路径本地与远端在模型层汇合DOM 始终是模型的镜像。这样冲突可合并、状态可回溯。三、生产级编辑器模型实现变换与选区下面实现一个轻量编辑器模型包含文档树、不可变变换、选区表达与基本的 Schema 校验。它不依赖任何框架可作为理解内核机制的参考。// 文档模型类型定义 interface TextNode { text: string; bold?: boolean; italic?: boolean; } interface BlockNode { type: paragraph | heading; children: TextNode[]; } interface DocState { blocks: BlockNode[]; } // 选区纯数据结构脱离 DOM Range interface Selection { blockIndex: number; offset: number; length: number; } // Schema 约束定义合法节点与标记 const SCHEMA { blocks: [paragraph, heading], marks: [bold, italic], } as const; export class EditorModel { constructor(private state: DocState) {} // 不可变插入文本返回新文档旧文档不动 insertText(sel: Selection, text: string): DocState { const { blockIndex, offset } sel; const block this.state.blocks[blockIndex]; if (!block) return this.state; // 越界保护不抛异常保证编辑不中断 const newBlocks [...this.state.blocks]; // 深拷贝受影响块其余块保持引用共享降低拷贝开销 const newBlock: BlockNode { ...block, children: [...block.children] }; // 定位到 offset 所在的 Text 节点并切分插入 let acc 0; for (let i 0; i newBlock.children.length; i) { const t newBlock.children[i]; if (acc t.text.length offset) { const pos offset - acc; const before t.text.slice(0, pos); const after t.text.slice(pos); // 插入的新文本继承当前节点标记符合用户对接着写的预期 const insertNode: TextNode { text, ...(t.bold ? { bold: true } : {}), ...(t.italic ? { italic: true } : {}), }; newBlock.children [ ...newBlock.children.slice(0, i), { ...t, text: before }, insertNode, { ...t, text: after }, ...newBlock.children.slice(i 1), ]; break; } acc t.text.length; } newBlocks[blockIndex] newBlock; return { blocks: newBlocks }; } // 切换标记加粗/斜体等作用于选区内所有 Text 节点 toggleMark(sel: Selection, mark: bold | italic): DocState { if (!SCHEMA.marks.includes(mark)) return this.state; // Schema 校验 const block this.state.blocks[sel.blockIndex]; if (!block) return this.state; const newBlock: BlockNode { ...block, children: block.children.map((t) ({ ...t })) }; // 简化实现对整个块切换生产实现需精确按 offset 拆分 const anyHas newBlock.children.some((t) t[mark]); newBlock.children.forEach((t) (anyHas ? delete t[mark] : (t[mark] true))); const newBlocks [...this.state.blocks]; newBlocks[sel.blockIndex] newBlock; return { blocks: newBlocks }; } // 生成变更步骤协同场景下只传 diff不传整篇 toChangeSteps(prev: DocState): ChangeStep[] { // 简化为按块对比生产实现可用 Myers diff 做细粒度差异 const steps: ChangeStep[] []; const maxLen Math.max(prev.blocks.length, this.state.blocks.length); for (let i 0; i maxLen; i) { if (JSON.stringify(prev.blocks[i]) ! JSON.stringify(this.state.blocks[i])) { steps.push({ type: replace, index: i, block: this.state.blocks[i] }); } } return steps; } // 应用远端变更步骤协同合并的核心入口 applyRemoteStep(step: ChangeStep): DocState { if (step.type replace) { // 越界时自动补位避免远端块序与本地不一致导致丢失 const newBlocks [...this.state.blocks]; while (newBlocks.length step.index) { newBlocks.push({ type: paragraph, children: [] }); } newBlocks[step.index] step.block; return { blocks: newBlocks }; } return this.state; } // 序列化模型转 HTML渲染层负责实际 DOM 操作 toHTML(): string { return this.state.blocks .map((b) { const tag b.type heading ? h2 : p; const inner b.children .map((t) { let s escapeHTML(t.text); if (t.bold) s strong${s}/strong; if (t.italic) s em${s}/em; return s; }) .join(); return ${tag}${inner}/${tag}; }) .join(); } } interface ChangeStep { type: replace; index: number; block: BlockNode; } function escapeHTML(s: string): string { return s.replace(/[]/g, (c) ({ : amp;, : lt;, : gt; }[c]!)); }关键点有四处。其一所有变换返回新 DocState旧 state 不变撤销栈与快照天然可建。其二越界访问做兜底返回原状态不抛异常保证编辑过程中断不会丢数据。其三变更步骤只传 diff协同带宽可控。其四toHTML 时统一转义从源头挡掉 XSS。四、协同的代价冲突解决与适用边界自建文档模型不是没有代价。OT 的实现复杂度远超想象。变换函数必须满足两个数学性质收敛性TP1与意图保持TP2。业界能用 OT 跑通协同的团队屈指可数大多数项目直接接入 Yjs、Automerge 这类成熟库。某团队曾自信手写 OT上线后偶发「文字被吞」排查发现是 TP2 不满足最后还是换成了 Yjs。CRDT 牺牲了内存与带宽换确定性。Yjs 的 RGA 结构会保留所有插入的元信息作者 ID、逻辑时钟文档越大元数据越膨胀长文档的内存占用可达纯文本的 3 到 5 倍。对超长文档如十万字以上需做分片协同否则内存压力会拖垮浏览器。Schema 约束是一把双刃剑。它从源头挡住非法结构但也意味着任何新功能如嵌套表格、内联代码块都要先改 Schema。Schema 升级时旧文档可能不兼容需要写迁移脚本。灵活性与严谨性在这里直接对冲。性能上富文本编辑器是 React 等虚拟 DOM 框架的重灾区。一次大范围选区删除会触发整棵文档树重建若用 React 做渲染diff 开销会肉眼可见地卡顿。ProseMirror 自己实现了细粒度的视图更新Slate 早期强依赖 React 导致性能瓶颈后期才支持按节点级更新。适用边界协作文档、知识库、富文本笔记类产品收益最高这些场景对数据严谨性与协同能力强依赖。轻量输入框、评论区纯文本、表单字段自建模型是过度工程直接 textarea 配合简单格式化即可。结论富文本编辑器的核心是「自建文档模型 不可变变换 选区数据化」协同则依赖 OT 或 CRDT 解决并发冲突。落地建议第一绝不信任 contenteditableDOM 只作视图层真相在模型。第二所有编辑操作归约为不可变 Transform旧状态保留以支持撤销与快照。第三用 Schema 约束合法结构从源头挡脏数据代价是功能扩展需改 Schema。第四协同优先接入 Yjs 等成熟 CRDT 库不要手写 OT。第五长文档做分片协同控制 CRDT 的元数据膨胀。最终在数据严谨性、协同实时性与性能之间取得平衡。这条路在协作文档场景下能跑通回报是值得的。