2026.09.14
把挂载节点数从 22501 压到 672:一份文档编辑器的渲染预算
长文档编辑器最贵的一步不是解析,而是「改一个字就把整篇重新挂一遍」。这篇记录我怎么用安全切块 + 块级记忆化,把挂载节点数和数据量解耦。
编辑一个三千块的文档时,浏览器最怕的不是文本量,而是每次输入触发的整树重挂。改一个字,整篇重解析、整棵 DOM 重建——用户感知到的就是打字延迟。
先说结论,都是确定性指标(同一份文档、同一台机器、同一轮脚本量出来的):
| 场景 | 改造前挂载节点 | 改造后 | 降幅 |
|---|---|---|---|
| 3000 块文档 | 22501 | 672 | −97.0% |
| 5000 条消息流 | 70001 | 1401 | −98.0% |
问题的形状#
最初的实现非常直觉:一个 MarkdownRenderer 接收全文,useMemo 按内容做依赖,渲染出一个巨大的 ReactMarkdown。
function MarkdownRenderer({ content }: { content: string }) {
// 只要 content 变一个字符,下面的整棵树全部重新构造
const tree = useMemo(() => parseMarkdown(content), [content]);
return <>{tree.map(renderBlock)}</>;
}问题不在 parseMarkdown 慢,而在于:
- 解析粒度是全文。改一个字 → 全文重新解析 → 所有块的引用都变了 → React 认为每个块都变了。
- 挂载粒度也是全文。视口里只有十几块,但三千块全在 DOM 里。
两件事必须分开治:解析按块记忆化,挂载按视口切。
第一刀:在不可拆结构之外断开#
直接按 \n\n 切块会切坏代码块、表格、列表——一段代码中间断开,两侧各自解析都成了残缺的 Markdown。
所以切块器要先扫一遍结构栈,只在栈为空的位置下刀:
type Frame = "fence" | "table" | "list" | "blockquote";
function splitBlocks(src: string): string[] {
const lines = src.split("\n");
const stack: Frame[] = [];
const blocks: string[] = [];
let buf: string[] = [];
const flush = () => {
const text = buf.join("\n").trim();
if (text) blocks.push(text);
buf = [];
};
for (const line of lines) {
// 围栏代码块:``` 或 ~~~ 成对出现,内部一切原样吞掉
if (/^\s*(```|~~~)/.test(line)) {
toggle(stack, "fence");
buf.push(line);
continue;
}
if (stack.includes("fence")) {
buf.push(line);
continue;
}
// 空行 = 一个天然候选断点,但只有栈为空时才真的断
if (!line.trim() && stack.length === 0) {
flush();
continue;
}
pruneStack(stack, line);
buf.push(line);
}
flush();
return blocks;
}注意栈为空才断这条规则。表格和列表中间没有空行,所以天然不会被切开;而一旦出现空行且栈已清空,就是安全的边界。
切完之后每个块带一个稳定的键:内容哈希 + 出现序号。内容没变的块,键就不变。
第二刀:块级记忆化#
键稳定之后,把每个块包成 memo 组件,React 就只会重渲染真正变了的那一块:
const Block = memo(
function Block({ source }: { source: string }) {
return <Markdown source={source} />;
},
(a, b) => a.source === b.source
);改一个字,只有它所在的那一块重新解析和渲染——从 O(全文) 降到 O(一块)。
这一步之后,"改一个字的成本"就与文档总长度无关了。这才是关键收益,节点数下降是它的副产品。
第三刀:挂载与数据量脱钩#
解析便宜了,但三千块仍然全在 DOM 里。上虚拟列表,按视口挂载:
const virtualizer = useVirtualizer({
count: blocks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 96,
overscan: 6,
});难点是高度不可预测:代码块、表格、图片的高度差着数量级,estimateSize 只能给个初值。常见的做法是渲染后用 ResizeObserver 回填实测高度——但在长文档里这会造成大量重排,滚动时出现"跳一下"。
我把回填做成了一次性的:块首次渲染后测量并缓存,之后不再重测。文档是只读浏览为主、编辑集中在局部,所以这个取舍成立。
结果与边界#
- 3000 块文档挂载节点 22501 → 672
- 5000 条消息流 70001 → 1401
两条都是确定性指标,不是采样估计。
需要说清楚的边界:这套做法对"整篇替换式"的更新无效。如果内容是从服务端整体覆盖下来的,所有块的哈希都会变,记忆化和虚拟列表都救不了。它成立的前提是编辑是局部的——这也是我给编辑器定的产品约束,而不是实现细节。
另外,切块器本身必须严格:切错了位置不只是渲染难看,还会让 Markdown 语义损坏。所以它值得单独的单元测试,而不是"大概能跑"。