跳转至

Topic | StructuredDiff 性能考古:注释里的 PR 历史

重要性:⭐⭐⭐⭐(大型 TUI 项目的"性能优化编年史") 出现位置src/components/StructuredDiff.tsxsrc/components/StructuredDiffList.tsx 关联phase-04-components.md 的 diff 性能优化


1. 注释里的"血泪史"

打开 src/components/StructuredDiff.tsx,顶部有一段 50 行的注释,引用了 4 个 PR 编号:

// REPL.tsx renders <Messages> at two disjoint tree positions (transcript
// early-return vs prompt-mode nested in FullscreenLayout), so ctrl+o
// unmounts/remounts the entire message tree and React's memo cache is lost.
// Keep both the NAPI result AND the pre-split gutter/content columns at
// module level so the only work on remount is a WeakMap lookup plus two
// <ink-raw-ansi> leaves — not a fresh syntax highlight, nor N sliceAnsi
// calls + 6N Yoga nodes.
//
// PR #21439 (fullscreen default-on) made gutterWidth>0 the default path,
// reactivating the per-line <DiffLine> branch that PR #20378 had bypassed.

这一段注释比代码本身更值钱。它告诉读者: - 为什么这里要 module-level cache - 为什么要预切分 gutter/content - PR #20378 之前发生了什么(bypass 了什么) - PR #21439 又改回来(reactivate 了什么)

2. 性能问题三层叠加

2.1 第 1 层:REPL 双重挂载

// src/screens/REPL.tsx 推测
function REPL() {
  if (transcriptMode) {
    return <Messages messages={...} />  // 位置 A
  } else {
    return (
      <FullscreenLayout>
        <Messages messages={...} />  // 位置 B
      </FullscreenLayout>
    )
  }
}

问题<Messages>两个不同位置渲染。Ctrl+O 切换 transcript 模式: - 位置 A 的 <Messages> unmount - 位置 B 的 <Messages> mount

React memo 缓存: - <Messages> 是 memo 化的(消息没变就不重渲染) - 但位置变了 = 新组件实例 = 重新 mount = memo cache 失效

2.2 第 2 层:每次 mount 重做语法高亮

// 优化前的 StructuredDiff(PR #20378 之前的版本)
function StructuredDiff({ patch, filePath }) {
  // 每次 mount 都重新做:
  // 1. 调 NAPI 语法高亮(~50ms / 100 行)
  // 2. 计算每行宽度
  // 3. 切分 gutter + content
  // 4. 分配 ~6N 个 Yoga 节点(每个 hunk 平均 30 行 = 180 节点)
}

累计成本(Ctrl+O 切换一次): - 假设有 5 个 diff 消息,每个 100 行 - 5 × (50ms 高亮 + 30 行 × 6 节点) = 250ms + 900 节点 - 用户感受到 ~250ms 卡顿

2.3 第 3 层:per-line DiffLine 组件

// 优化前的 StructuredDiff(PR #20378 之前的版本)
function StructuredDiff({ patch, width, filePath }) {
  return patch.lines.map((line, i) => (
    <DiffLine
      key={i}
      line={line}
      width={width}
      filePath={filePath}
    />
  ))
}

问题: - N 行 = N 个 React 组件实例 - 每个 DiffLine 内部调 <ink-raw-ansi> + 切分 + 测量 - N 次 sliceAnsi 调用 + 6N 个 Yoga 节点

3. 优化一:module-level cache

// src/components/StructuredDiff.tsx(推测)
const napiCache = new WeakMap<StructuredPatchHunk, HighlightedOutput>()
const splitCache = new WeakMap<StructuredPatchHunk, { gutter: string, content: string }>()

function getHighlighted(patch: StructuredPatchHunk): HighlightedOutput {
  let result = napiCache.get(patch)
  if (!result) {
    result = runNapiHighlight(patch)
    napiCache.set(patch, result)
  }
  return result
}

关键设计: - WeakMap —— patch 对象 GC 时缓存自动清 - 同时缓存 NAPI 结果 + 切分结果 —— 重 mount 时两个都不用重算 - module-level —— 跨组件实例共享

优化后: - 5 个 diff 消息的 Ctrl+O 切换 - 每个只是 WeakMap 查询 + 2 个 <ink-raw-ansi> 叶子 - 从 250ms 降到 ~5ms

4. 优化二:PR #20378 bypass

问题:per-line <DiffLine> 太慢,但当时还有别的性能问题

PR #20378 方案: - 绕过 per-line 渲染 - 整个 hunk 一次渲染 - 不再分配 N 个 DiffLine 组件

// PR #20378 之后
function StructuredDiff({ patch, width, filePath }) {
  // 1. 拿高亮结果(从 cache)
  const highlighted = getHighlighted(patch)

  // 2. 一次性切分 gutter + content
  const { gutter, content } = splitGutterAndContent(highlighted, width)

  // 3. 渲染为 2 个 ink-raw-ansi 叶子(不再 N 个)
  return (
    <Box flexDirection="row">
      <RawAnsi>{gutter}</RawAnsi>
      <RawAnsi>{content}</RawAnsi>
    </Box>
  )
}

收益: - 0 个 DiffLine 组件实例 - 0 次 sliceAnsi 调用 - 0 个额外 Yoga 节点 - 2 个 ink-raw-ansi 叶子(固定成本)

5. 优化三:PR #21439 reactivate

问题:gutterWidth=0 的默认(PR #20378 的设计)用户体验差 —— gutter 是"修改前的行号",用户需要它做 diff 阅读。

PR #21439 方案: - 把 gutterWidth > 0 设成默认 - 但保留 PR #20378 的优化 - per-line 重新启用,但用 module-level cache 避免重算

// PR #21439 之后
function StructuredDiff({ patch, width, filePath }) {
  const highlighted = getHighlighted(patch)  // cache 命中
  const { gutter, content } = splitGutterAndContent(highlighted, width)  // cache 命中

  // 重新启用 per-line 渲染(用 <DiffLine>),但因为有 cache 所以不慢
  return (
    <Box flexDirection="row">
      {patch.lines.map((line, i) => (
        <DiffLine
          key={i}
          line={line}
          gutter={gutter[i]}      // 从预切分拿
          content={content[i]}    // 从预切分拿
        />
      ))}
    </Box>
  )
}

收益: - 用户体验提升(看到行号) - 性能不退化(cache 命中) - "两全其美"

6. 关键洞察

6.1 性能优化是"踩坑→修坑→再踩"循环

注释里 PR 编号的演进揭示了: - PR #20378:发现 per-line 慢 → bypass - PR #21439:发现 bypass 体验差 → 重新启用 + 缓存

这就是大型项目的真实性能优化路径没有银弹,只有"权衡"

6.2 WeakMap 是"自动清理的 cache"

  • 不会内存泄漏
  • 不需要手动 invalidate
  • 最适合"短命对象"的高频缓存

6.3 注释即文档

这段注释的价值 = 1000 行 git log。
未来维护者不需要翻 git 历史就能理解为什么这么写。

6.4 "内存中" vs "计算中" 的选择

  • pre-split gutter/content:在 NAPI 高亮后立即切分,存 module-level
  • 切换重 mount 时只读不重算
  • 空间换时间

7. 阅读清单

  1. src/components/StructuredDiff.tsx:1-50(性能注释 + 优化逻辑)
  2. src/components/StructuredDiffList.tsx(多 hunk 列表 + 省略号分隔)
  3. 📌 src/components/StructuredDiff/Fallback.tsx(降级方案)
  4. 📌 src/components/StructuredDiff/colorDiff.ts(颜色对比度)
  5. 📌 src/utils/sliceAnsi.ts(ANSI 切分)

8. 练习任务

  1. 画 PR 演进时间线 —— #20378 → #21439,每个 PR 加了什么、减了什么
  2. 手写一个 WeakMap 缓存 —— 验证 GC 后缓存自动清
  3. 设计你自己的优化 —— 给一个 1000 行的列表加缓存,测 mount/unmount 时间
  4. 思考:还有哪些 Claude Code 模块可能有类似的"性能考古"?在源码里找另一段有 PR 编号的注释