Topic | StructuredDiff 性能考古:注释里的 PR 历史¶
重要性:⭐⭐⭐⭐(大型 TUI 项目的"性能优化编年史") 出现位置:
src/components/StructuredDiff.tsx、src/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. 阅读清单¶
- ✅
src/components/StructuredDiff.tsx:1-50(性能注释 + 优化逻辑) - ✅
src/components/StructuredDiffList.tsx(多 hunk 列表 + 省略号分隔) - 📌
src/components/StructuredDiff/Fallback.tsx(降级方案) - 📌
src/components/StructuredDiff/colorDiff.ts(颜色对比度) - 📌
src/utils/sliceAnsi.ts(ANSI 切分)
8. 练习任务¶
- 画 PR 演进时间线 —— #20378 → #21439,每个 PR 加了什么、减了什么
- 手写一个 WeakMap 缓存 —— 验证 GC 后缓存自动清
- 设计你自己的优化 —— 给一个 1000 行的列表加缓存,测 mount/unmount 时间
- 思考:还有哪些 Claude Code 模块可能有类似的"性能考古"?在源码里找另一段有 PR 编号的注释