2026.09.14

把挂载节点数从 22501 压到 672:一份文档编辑器的渲染预算

长文档编辑器最贵的一步不是解析,而是「改一个字就把整篇重新挂一遍」。这篇记录我怎么用安全切块 + 块级记忆化,把挂载节点数和数据量解耦。

编辑一个三千块的文档时,浏览器最怕的不是文本量,而是每次输入触发的整树重挂。改一个字,整篇重解析、整棵 DOM 重建——用户感知到的就是打字延迟。

先说结论,都是确定性指标(同一份文档、同一台机器、同一轮脚本量出来的):

场景改造前挂载节点改造后降幅
3000 块文档22501672−97.0%
5000 条消息流700011401−98.0%

问题的形状#

最初的实现非常直觉:一个 MarkdownRenderer 接收全文,useMemo 按内容做依赖,渲染出一个巨大的 ReactMarkdown。

function MarkdownRenderer({ content }: { content: string }) {
  // 只要 content 变一个字符,下面的整棵树全部重新构造
  const tree = useMemo(() => parseMarkdown(content), [content]);
  return <>{tree.map(renderBlock)}</>;
}

问题不在 parseMarkdown 慢,而在于:

  1. 解析粒度是全文。改一个字 → 全文重新解析 → 所有块的引用都变了 → React 认为每个块都变了。
  2. 挂载粒度也是全文。视口里只有十几块,但三千块全在 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 语义损坏。所以它值得单独的单元测试,而不是"大概能跑"。