Skip to content

11.6 虚拟滚动:长输出的性能护栏 ​

路径:docs/part11-terminal-ui/06-virtual-scroll.md
系列:Claude Code 完全指南 V2 · 第 11 篇


学习目标 ​

完成本节学习后,你应该能够:

  1. 解释 虚拟滚动在终端 UI 中的目标:O(可见行) 而非 O(总行数) 的布局与绘制。
  2. 描述 窗口、项高、偏移量、缓冲区 四个核心变量如何联动。
  3. 关联 11.4 流式:追加内容时如何维护 stick-to-bottom 与 用户上翻阅读 的冲突。
  4. 识别 与 Yoga 布局的切分点:仅对可见子树做精细测量或使用估算高度。

生活类比:超市货架可视窗口 ​

仓库里有一万件货(十万行日志),但顾客只能透过一个橱窗看十几件。店员不必把一万件全摆进橱窗——只需根据你站的位置换上该段的样品。

虚拟滚动就是换样品:滚动时更换「当前窗口对应的数据切片」,而不是渲染全部行。


核心公式(行定向) ​

设:

  • totalLines:逻辑总行数(或消息条目的累计行数)
  • viewportHeight:可视行数
  • scrollTop:第一条可见逻辑行的索引
  • end = min(totalLines, scrollTop + viewportHeight)

则可见范围为 [scrollTop, end),仅这些行参与 TermNode 实例化 或 字符串拼接。


架构序列 ​


变高行与估算 ​

终端中每行高度常为 1,但若支持 换行消息 或 富文本块,条目高度 不等。

策略适用代价
固定行高纯日志最简单
测量缓存Markdown 渲染后高度已知需缓存每条目高度
二分 + 前缀和快速定位 scrollTop实现复杂
懒测量首次进入视口才测滚动时可能 跳动

源码片段:可见切片(示意) ​

typescript
type LineRef = { id: string; height: number; render: () => string };

function visibleSlice(
  lines: LineRef[],
  scrollTopPx: number,
  viewportH: number
): LineRef[] {
  let acc = 0;
  const out: LineRef[] = [];
  for (const line of lines) {
    const start = acc;
    const end = acc + line.height;
    if (end > scrollTopPx && start < scrollTopPx + viewportH) {
      out.push(line);
    }
    acc = end;
    if (start > scrollTopPx + viewportH) break;
  }
  return out;
}

与流式输出的协同 ​

用户状态策略
在底部「跟随」新行追加后 自动 scrollTop = max - viewport
向上阅读历史不自动滚动;可显示「新消息 N 条」提示
跳转到某工具结果scrollToLineId 程序化滚动

与 Fiber 更新粒度 ​

做法说明
整屏重绘实现简单,CPU 可能高
脏行合并仅变更行写缓冲
双缓冲前台缓冲展示,后台合成

内存与对象池 ​

长会话中,反复创建字符串与数组会加压 GC。可考虑:

  • 行对象池(注意内容失效)
  • Rope/大块缓冲(降低拼接次数)
  • 淘汰策略:仅保留最近 K 条 完整 内容,更早的只保留 摘要(产品决策)

无障碍与可搜索性 ​

虚拟滚动可能导致「屏幕阅读器/复制全文」困难。可选:

  • 「导出完整会话」命令
  • 专用 全缓冲视图 模式(性能换功能)

小结 ​

虚拟滚动把终端 UI 从「无限向下增长」变成「恒定窗口成本」,是 Agent 长输出 的必要护栏。实现要点是 scrollTop 单一数据源、变高测量缓存、以及与 流式 stick-to-bottom 的协同。下一节 11.7 Vim 模式。


性能预算(经验值) ​

指标目标直觉
单次滚动帧布局+绘制 < 16ms(理想)
可见行数通常 < 终端行数,留 status bar
缓存命中率高度前缀和 O(log n) 查询

与 Diff 视图冲突时的策略 ​

同时展示 Diff(11.8)与 对话流 时,建议 分面板 各自滚动模型,避免一个 scrollTop 驱动两套逻辑。


自测 ​

  1. 变高条目下,为何「仅按行号滚动」会错位?
  2. stickToBottom 与「用户轻微上滚」如何区分(阈值像素/行)?

术语表 ​

英文中文
viewport视口
overscan过扫描(上下多渲染几行防闪烁)
stick to bottom粘底

overscan 示意 ​

多渲染上下各 overscan=2 行,滚动快速时减少 空白闪动:

typescript
const start = Math.max(0, scrollTop - overscan);
const end = Math.min(totalLines, scrollTop + viewportHeight + overscan);

调试技巧 ​

  • 在 dev 模式打印 visibleCount / totalLines 比例。
  • 人为注入 1e5 行假数据压测。
  • 与 389 组件 中列表容器联调,确认 key 不抖动。

常见 bug ​

现象原因
滚动条跳高度测量前后不一致
空白洞slice 区间 off-by-one
内存仍涨逻辑缓冲无限增长无淘汰

与 Yoga 的边界 ​

虚拟滚动是 数据面 优化;Yoga 仍可对可见容器做 flex。避免对不可见子树调用昂贵 measure。


延伸阅读 ​

  • 前端虚拟列表经典文章(概念迁移到终端)
  • Rope 数据结构简介

实战题 ​

设计一个策略:当 totalLines 超过 100 万时,如何在不丢失可检索性的前提下控制内存?

提示:分段归档文件 + 索引 + 视窗只映射文件 offset。

本项目仅用于教育学习目的。Claude Code 源码版权归 Anthropic, PBC 所有。