DOM 被改乱了怎么办?Notedeep 编辑器的 normalize 设计

DOM 被改乱了怎么办?Notedeep 编辑器的 normalize 设计

Notedeep 编辑器直接把 contenteditable 的 DOM 当作数据模型,浏览器、粘贴和各种命令都能改它。本文分享我怎样用一个 normalize 层,把任意 DOM 收敛回标准的 block 结构,以及这个兜底层的代价和边界。
使用 NoteDeep 整理你的技术实践

背景与目标

Notedeep 的编辑器基于 contenteditable,没有另外维护一份文档模型,DOM 本身就是 model。好处是浏览器自带的输入、输入法、选区和拼写检查都能直接用;代价是 DOM 会被很多来源改动:
  • 用户在编辑区里输入、删除、回车,浏览器会自己插入 `<span>`、`<br>`,有时还会留下空标签。
  • 从网页或 AI 对话界面粘贴的 HTML,可能带着 `<p>`、`<b>`、自定义标签、按钮图标和注释节点。
  • 表格增删行列、插入公式、切换代码块等命令,会用程序直接修改 DOM。
后面的链路却要求 DOM 形态固定。MutationObserver 收集到的变化会被转换成 JSON0 操作,用 JsonML 路径定位修改位置;撤销、重做和协同同步都依赖这些路径。同一段内容如果有多种 DOM 写法,本地路径和协同文档里的路径就可能对不上。
所以我给 normalize 定了两个目标:
  • DOM 可以先被改乱,但在生成操作之前,一定要收回到编辑器认可的结构。
  • 已经收敛的 DOM,再跑一遍 normalize 不能产生任何变化。

核心架构

normalize 插在 DOM 变化和操作生成之间。MutationObserver 收集的变化先缓冲,500ms 防抖后执行 normalize,再立刻用 `takeRecords()` 取出 normalize 自身产生的变化,和之前缓冲的变化一起转换成操作。这样进入撤销栈、发给协作者的,都是收敛后的结构。
除了这条主链路,初始化、整页设置内容、粘贴、Markdown 转换,以及表格、公式、代码块等命令执行后,也会主动调用 normalize。
flowchart TD IN[输入 / 粘贴 / 命令修改 DOM] --> MO[MutationObserver 缓冲 500ms] MO --> A[保底:容器非空,删除注释节点] A --> B[preProcess:修正 block 骨架] B --> C[processContainer:按 schema 清理] C --> D[postProcess:合并、补 br、过滤样式] D --> Q{DOM 还在变化?} Q -- 是,且未满 10 轮 --> A Q -- 否 --> T[takeRecords 合并 normalize 产生的变化] T --> OP[转换为 JSON0 操作:撤销栈与协同同步]
规则来自一份 schema。根容器只允许 `div` 形式的 block,并列出 21 种已注册的 block 类型;每种 block 再声明自己允许的子标签、属性,以及能否直接包含文字。比如普通段落只允许 `span`、`br`、`a` 和文字,标题行只允许 `h1`~`h6` 与 `blockquote`。
export const rootSchema: HTMLStructureRule = {
childAllowedTags: ["div"],
childAllowText: false,
attributes: ["class", "contenteditable"],
childAllowedBlocks: [BlockType.Basic, BlockType.Line, BlockType.List, /* ... */],
}

关键实现

一套 schema 规则处理所有来源

`processContainer` 从根容器开始递归遍历,对每个节点按父级 schema 判断:
  • block 类型未注册,或当前位置不允许出现,就 unwrap:去掉这层标签,保留里面的子节点。
  • 普通标签不在允许列表里,同样 unwrap。
  • 属性不在白名单里,直接删除。
  • 在不允许出现文字的位置,删除文本节点。
非法标签选择 unwrap 而不是删除,因为标签本身没有意义,里面的文字却是用户的内容。normalize 不为每种来源的 HTML 写单独的转换器,只负责应用规则;新增一种 block,主要工作是注册它的 schema。

结构型 block 先修骨架

逐层清理之前,`preProcess` 先处理 block 本身的形状:
  • 图片、附件、列表这类结构型 block,内部骨架不合法时降级为普通段落。
  • 标题、图片、表格 block 里混入了其他内容时,按片段拆成多个 block。我用当前代码验证过:`<h2>标题</h2>正文<table>…</table>` 这样一个混合块,会被拆成标题、段落、表格三个 block。
  • 折叠面板、提示块缺少头部或正文区域时,按标准结构重建。

数据层和预览层分开

Mermaid、视频、白板、LikeC4、HTML 等 block 都有渲染出来的预览区,里面可能是一大段 SVG。normalize 只保证外层骨架和数据层:例如 HTML、LikeC4 和 Archify 块固定为“预览区 + 隐藏数据区”两个子节点,数据区只保留一段纯文本源码;样式过滤会跳过各类预览区。
这样既不必每次深入复杂的预览 DOM,也避免预览结构混进协同数据。

已经合法的 DOM 一个字节都不动

样式按白名单过滤,只保留粗体、斜体、下划线、删除线、预设字号和预设颜色,以及图片的宽高。值本身已经合法时,不会把 `style` 属性重新写一遍。
这一点很关键。normalize 运行在 MutationObserver 链路里,哪怕写回的是相同的值,也会产生一条 mutation,进而变成一次多余的操作,进入撤销栈并发给协作者。测试里专门有一条用例:对已经规范的代码块连续执行两次 normalize,MutationObserver 记录必须为空。

多轮执行,直到不再变化

while (iterations < BlockNormalizer.MAX_NORMALIZE_ITERATIONS) {
const before = container.innerHTML;
this.normalizeOnce(container);
iterations++;
if (before === container.innerHTML) break;
}
单轮不够,是因为遍历时先拿子节点快照。某个元素被 unwrap 后,它的子节点被提到父级,却不在这一轮的快照里,要到下一轮才会被检查。
我用当前代码数了一下轮数。段落里嵌套的 `<p><b><i>abc</i></b></p>`:第一轮剩下 `<i>abc</i>`,第二轮剩下纯文本,第三轮确认没有变化。一段从 AI 对话界面复制的列表项,里面嵌着段落、加粗、自定义元素、按钮图标和代码块,需要 5 轮才稳定。上限设为 10 轮,防止意外的死循环。

踩坑:注释节点让路径漂移

从部分网页复制的 HTML 带有 `<!---->` 这样的空注释节点。它们留在实时 DOM 里,但 JsonML 序列化会忽略注释。结果本地按 DOM 计算出来的节点下标,比协同文档里的实际下标更大,生成的操作索引越界。
修复方式很直接:normalize 每一轮都删除注释节点,只保留 Archify 预览区内部的注释。这次修复和“多轮直到不再变化”是同一次提交,目标都是让一次 normalize 调用之后,DOM 进入固定形态。

方案取舍

另一种常见做法是自己维护文档模型,每次输入都先改模型、再渲染 DOM。这样结构始终合法,但需要接管所有输入路径,包括输入法、浏览器的各种编辑行为和粘贴。
我选择让 DOM 先自由变化,再用 normalize 收回来。输入实现简单很多,代价是必须有一个可靠的收敛层,而且这个层的正确性直接决定协同和撤销是否稳定。
在 normalize 内部也有几处取舍:
  • 宁可丢样式,也不留非法结构。外部 HTML 的字体、非预设颜色、布局都会被去掉,换来稳定的 block 形态。
  • 多轮执行而不是更聪明的单轮遍历。实现朴素,容易验证;代价是每一轮都要完整遍历,并序列化两次 `innerHTML` 做比较。

结果与边界

从当前实现可以确认:任意 DOM 变化在转换成操作之前都会先收敛;收敛后的结构再执行 normalize 不产生变化;注释节点不会再造成路径漂移。normalize 的 36 条单元测试全部通过,覆盖骨架降级、混合块拆分、列表合并、代码块、空节点、样式白名单、预览区忽略等场景,上文的轮数和拆分结果我也用当前代码单独验证过。
需要说清楚的边界:
  • normalize 只删除,不猜测。没有放在合法位置的文字会被删掉,比如根容器下裸露的 `<span>`,或直接写在表格单元格里、没有包进 block 的文字,测试也明确期望如此。所以把外部内容识别成 block 的工作必须在粘贴阶段完成,例如粘贴会先把根级的行内内容包进普通段落,normalize 只做最后收敛。
  • 达到 10 轮仍未稳定时会直接停止,没有告警。
  • 不保留外部 HTML 的完整视觉信息。
  • 我没有大文档下 normalize 耗时的测量数据。

后续规划

改进内部的遍历和 unwrap 算法,让单轮 normalize 就能收敛,而不是依赖重复执行。
DOM 当 model 的前提,不是 DOM 永远干净,而是每次生成操作之前,它一定会被收回同一种形态。
使用 NoteDeep 开始整理你的知识库
#架构设计 #技术实现 #产品设计 #Notedeep