跳到正文
GitHub Blog · AI & ML· Alberto Gimeno·· 2026-09-24AI 评分22

GitHub Copilot 应用如何渲染超大 pull request

Rendering huge pull requests in the GitHub Copilot app

AI 导读

GitHub Copilot 应用重建了 pull request 视图,以流畅渲染含 2,200 个文件、超百万行改动和 400 多条行内评论的超大 PR。

摘要与补充说明

其做法是把文档高度拆成确定性的代码几何与动态评论块两套几何:代码行高提前精确计算,评论高度则惰性测量、按文件行号锚定,避免滚动跳变。

正文· AI 翻译

大规模重构和迁移往往必须作为一次变更落地。

堆叠式拉取请求是拆分工作的好方法,可以把工作拆成更小的变更,让评审更轻松,也帮助团队以更低的风险交付。但有些变更,比如这一次,无法干净地拆分。这就导致你只能用一个可能变得非常庞大的拉取请求,而评审对话又会让它继续膨胀。

即使 diff 及其对话极其庞大,评审体验也必须保持快速流畅。在 GitHub Copilot 应用中,我们正是带着这一要求重建了拉取请求视图。

为了看看它能走多远,我们打开了能找到的最大的拉取请求:一个开源项目中的拉取请求,包含 2,200 个文件、超过一百万行变更,以及 400 多条行内评审评论。下面是我们如何让这个极端规模的拉取请求也保持高性能的。

问题的范围

高速渲染大型 diff 已是成熟问题:虚拟化行、保持挂载的 DOM 精简,并利用每一行都是已知高度的代码行这一事实。

评论才是难点。评论的高度取决于其 markdown 如何换行、可展开区域、其中是否有回复框,以及图片是否已加载完成。这些都要到渲染时才能确定。这迫使采用不同的架构。

三个问题:

  1. 测量。在渲染之前,你无法知道一条评论有多高。这打破了让大型 diff 在滚动时保持响应流畅的设计。
  2. 数据管道。如果为快速 diff 界面提供数据的管道停滞,或者丢弃了已经完成的工作,那么再快的界面也毫无价值。
  3. 我们实际是如何发现这些 bug 的。这些问题会在负载下、在特定引擎上、在特定滚动位置浮现。因此我们定义了“健康”的含义,对界面进行埋点以回答这个问题,并无人值守地运行整个“变更 → 测量 → 改进”循环。

第 1 部分:虚拟化,以及评论为何会破坏它

第一步是理解让纯代码 diff 保持快速的几何结构。一旦评论进入画面,这种几何结构就不再够用。

是什么让大型 diff 保持快速

你不可能把一百万个 DOM 节点放到页面上。标准答案是虚拟化:只挂载屏幕上的行,外加一小段边距,并在用户滚动时复用这些相同的 DOM 元素。列表表现得就像一百万行都存在一样。滚动条尺寸正确,滚动到某一行也能工作。但同一时间真正存在的只有大约 100 行。

要让这个假象成立,必须有东西提供几何信息。滚动条高度是所有行高度之和。第 N 行的位置是它上方所有行的高度之和。跳转到某一行、绘制滚动条、决定屏幕上显示什么,全都是对一张高度表做算术。你可以用估算值构建这张表,并在行被测量后修正它,通用可变高度虚拟化器正是这么做的。

但如果每一行都是已知字号的代码行,你就不必这么做。你可以预先计算整张表,而且它永远不会变化,因此之后没有任何需要修正的地方。

把这称为“绘制前所有高度已知”契约。我们的 diff 界面正是围绕它构建的:

  • 命令式、可复用的代码行渲染器(每行没有 React 组件)
  • 用于偏移计算的类型化数组几何信息
  • 由后端拥有、结构优先流式传输的 diff 文档
  • 带精确“滚动到第 N 行”的命令式滚动 API

这些都不会出现严重的扩展性问题,因为没有任何逐帧工作会随总行数增长。就纯代码而言,这个设计是正确的,我们完整保留了它。

评论如何改变这一约定

现在把一条评审讨论串放到 diff 中间。它有多高?

你不知道,不渲染它也无法知道。它的高度取决于只在渲染时才会存在的东西,而且这些东西在首次绘制之后还可能继续变化:

  • 在不同宽度下换行方式不同的 Markdown
  • 用户可以在原地展开或折叠的 <details> 块
  • 在现有讨论串内部打开、并随着输入而增长的回复编辑器
  • 建议更改的 diff、表情回应、编辑模式、解决状态横幅
  • 加载完成后高度会变化的图片和异步资源

显而易见的答案是给每条评论预留一个固定高度的槽位,由估算器决定尺寸。放到大型 pull request 上,这个方案就会崩溃。一个平均而言准确的估算器,在极端情况下仍然是错的。它对大多数评论预留过多,留下空白间隙;对开销大的评论又预留不足,导致内容被裁剪或冒出嵌套滚动条。如果你在绘制后测量真实高度并写回共享偏移表,下面的一切都会移动,而用户已经在滚动了。这就是滚动跳动,在大型 pull request 上会是一次很大的跳动。

所以评论需要另一套约定。“所有高度在绘制前已知”对这类内容无法实现。我们转而可以承诺的是:高度有界、按需惰性测量,并且修正幅度小,且锚定在用户当前正在看的内容上。

两种几何,而非一种

让这件事变得可处理的想法,是停止强迫一种几何同时服务两类内容。我们把文档高度拆成两个独立的域:

total height = deterministic code height          (exact, known up front)
             + Σ dynamic block effective heights   (estimated, then measured)
             + scroll padding

代码几何保留原来的世界。它是确定性的、前缀和计算的、精确的,评论尺寸变化时从不重建。

动态块几何覆盖所有我们无法预测高度的内容,例如评审讨论串、草稿和回复编辑器。每一个都是一个块,由它是什么来标识,而不是由它当前位于哪里来标识。它有一个在内容加载后仍然存活的稳定键,并且锚定到文件、行和侧,而不是像素坐标,因此重排不会让它跟丢。我们还保留一个指纹,涵盖所有可能改变该块高度的因素:它的内容、某个 <details> 是否展开、是否有编辑器处于活动状态。并且我们记录它上次测量时的宽度,按桶取整,这样普通的窗口缩放不会让文档中的每个测量都失效。

一个块的有效高度于是很简单:如果有有效测量值就用测量高度,如果指纹和宽度仍然匹配就用缓存高度,否则用估算值。这些高度存放在它们自己的索引中,与代码行分离,因此调整评论尺寸永远不会迫使代码几何重建。而且块的数量由评论数量限定,而不是由行数限定。几千个块没问题,只要首次绘制时从不一次性挂载或测量全部块。

测量调度器,以及我们最初犯的错误

这部分花的时间最长才做对,因为我们最初的设计错得很有启发性。

衡量动态内容最直接的方式是为每个块配一个 ResizeObserver,由它监视元素,并在高度变化时把测得的高度写回布局。这是我们最初设计、随后在性能加固阶段否决的方案。它正是大型虚拟化界面必须避免的反馈回路。一个观察器如果把高度写回它所监视元素的布局,就可能再次触发自身,而且每挂载一个块,开销都会增长。

最终上线的是单次、受空闲与滚动状态门控的测量流程,并遵循与确定性一侧相同的纪律:

  • 避开热路径。 它在可见范围稳定后才运行,绝不按每个滚动帧运行,并且在滚动进行期间完全等待。滚动中途触发重排正是我们要避免的卡顿。滚动停止后它才会再次运行。
  • 限定在视口范围内。 只有距离视口约 2400px 以内的块才是候选,因此工作量是 O(视口)。远处的块继续沿用估算值,并在接近时得到修正。
  • 屏幕上的读取优先。 已挂载的块就在屏幕上,因此其渲染高度就是基准事实。该流程会在一批中读取每个已挂载的候选块,只做一次重排,中间不写入,并记录读取结果。已挂载的块绝不会因为过时的估算值而被跳过。这一条规则修复了我们遇到的最棘手的 bug:评论下方渲染出一条空白,因为某个已挂载的块被排除在测量之外,一直停留在过高的估算值上。
  • 屏幕外测量是有界的兜底手段。 对于附近尚未挂载的块,该流程最多做一次屏幕外渲染,以便在它滚入视野前修正其预留空间。高于视口的块连这一步也跳过。它们多预留的空间藏在首屏之下,因此不值得为此付出渲染成本。
  • 观察器负责其余情况。 有些高度变化不会改变指纹,也不与滚动同时发生:在回复框中输入、图片加载完成、切换 <details>。每个已挂载的块都保留一个 ResizeObserver,但默认情况下它只标记该块,让空闲流程重新读取它。它自身从不写入高度,因为那会闭合我们已否决的反馈回路。它会在卸载时断开连接,未激活的拉取请求标签页不会观察任何内容。
  • 但有一个刻意的例外。 对于你自己触发的尺寸变化,等待会明显出错:展开 <details>、打开回复框、图片加载完成。块立即变大,但其下方的代码要到下一次空闲流程才移动。有一帧里评论变高了,而它下面的一切仍停在旧位置,你能看到两步跳变。因此,当块已挂载且在屏幕上时,观察器现在会测量它,并在同一帧内、绘制之前应用修正。块变大,代码重新定位,下方的一切一起移动。两项保护措施防止这变成我们原本要避免的循环:每帧最多一次同步提交,因此一连串尺寸变化会合并为一次;并且在滚动进行期间绝不这样做,此时会回退到批处理流程。

滚动锚定:在不与用户对抗的情况下修正

当测得高度与估算值不同时,滚动条的计算会改变,而朴素的结果就是视口跳动。解决办法是按身份而非按像素来修正:

  1. 在应用高度更新之前,先记录用户当前锚定在什么位置(某一行或某个块,按身份识别),以及在该位置内的偏移量。
  2. 应用高度增量。
  3. 将同一锚点解析到其新的像素位置。
  4. 滚动页面,使锚点在视口中保持原位。

再加上几条规则,避免让人感觉不对劲:

  • 视口上方的块高度变化 → 按增量调整(保持你的位置)。
  • 视口下方的内容水合 → 不调整(你看不到它)。
  • 如果是你切换了 <details> 或在可见块中打开了回复 → 对该块抑制上方块的校正,让交互感觉直接,并让下方内容自然下移。
  • 绝不要与活跃的指针或滚轮动量对抗;在帧结束后批量执行校正。

最后一条规则有个尖锐的边界,而且它确实坑了我们。“用户滚动时不要校正”被实现为对最后一次观察到的滚动的守卫,而程序化滚动也会刷新该时间戳。切换文件树侧边栏会改变 diff 面板的宽度。开启自动换行时,你上方的每一行换行都会重排为不同数量的视觉行,整个坐标空间发生偏移,界面在稳定过程中会自行发出一次小幅滚动。守卫将其解读为“用户刚刚滚动过”,于是跳过了本应保持你位置的校正,导致你正在阅读的文件漂移出屏幕。修复方法是区分用户滚动和界面自身引发的滚动。任何“用户是否正在交互?”的检查,都必须是自身副作用无法满足的检查。

因此校正保持小幅,复用已有的测量结果,并跟随你正在查看的内容。

第 2 部分:界面背后的流水线

diff 界面的速度只能和喂给它的数据一样快,而这项工作那一侧的三个习惯决定了 UI 能做到什么。第一个是先有流结构,再有内容。diff 是增量请求的,因此文件树和元数据在文档仍在加载时就会绘制,而完整的评审线程集合会预先解析,而不是陆续到达。第二个是将逐项工作推迟到需要时再做。语法高亮在主线程之外运行,因此行会立即以纯文本出现,并在结果到达时着色。高亮是改善界面,而不是阻塞滚动。大型 markdown 正文和建议更改上下文也遵循同样方式:在接近视口之前不构建任何内容。

第三个习惯关乎哪些成本值得保留。导航离开时释放 diff 文档是正确的默认行为。这些文档很大,保留你访问过的每一个文档,正是长时间会话最终吃掉内存的方式。但 pull request 元数据会持久保留,因此 diff 周围的外壳、头部和文件树在你返回时会立即重绘,然后在那里停留数秒,等待一个片刻之前还完整的 diff。围绕空 diff 即时绘制的外壳看起来像是坏了,即使你总体等待的时间更短。因此策略保持不变,我们添加了缓存:保留最近几个 diff 驻留,淘汰超出部分,并让后台刷新注意到某个 diff 已过期。

第 3 部分:测量循环,或者说我们究竟是如何发现这些 bug 的

这个项目里的几乎每个 bug 在暴露之前都是不可见的,而手动复现一个 bug 简直痛苦不堪。一份典型的报告是这样写的:“某些评论下方会出现一条空白,但只是偶尔出现,只在大规模 pull request 上出现,而且如果你滚过去再滚回来,它就会自愈。”你没法靠盯着屏幕来调试这个问题,所以我们构建了工具来机械地调试它。

用应用的真实信号来埋点,而不是用一次性的日志

最朴素的做法是到处撒 console.log 调用,手动走一遍流程,复制输出,粘贴给能分析它的人(或东西),删掉日志,然后重复。这很慢,需要人在环路中,最糟糕的是,你最终测量的是自己手搓的埋点,而不是应用的真实行为。

因此,这个界面为其自身的不变量携带了永久的、结构化的探针。它们就是界面在每次渲染时对自身回答的简单问题:

  • 这个界面是否真的受视口约束?当前挂载了多少行和评论块?
  • 测量是否每帧合并为一次提交,这一帧耗时多久?
  • 我们做的滚动修正有多大?
  • 滚动开始后是否有任何评论块被插入?(一旦后端拓扑落地,必须为零。)
  • 每个块的观察者在卸载时是否真的被拆除,还是我们每个块泄漏了一个?

这些是客观的通过/失败信号,它们在一个针对合成的多评论巨型 pull request 夹具的端到端测试中作为预算被断言。CI 现在可以告诉我们这个界面是否健康。

让循环进入自动驾驶

核心是一个自主的 变更 → 测量 → 改进 循环。两条通道:

一条无头探针通道 针对 mock 服务器运行一个声明式流程(打开一个 pull request、滚动到某个比例、切换一个详情块、调整窗口大小),读取应用自身的生产埋点:React 渲染次数、性能时间线,以及一个用于检测卡顿的 requestAnimationFrame 采样器。它自己完成了整个埋点、驱动、收集、分析、排序的循环,并按顺序打印出瓶颈。因为流程只是在运行时交给探针的 JSON,所以一个 agent 可以用平实的英语描述任何流程来对其做性能分析,而无需修改一行源代码。

一个自动驾驶器 无人值守地、循环地驱动实际的桌面应用走完巨型 pull request 流程:先是冷启动,评论仍是骨架屏,然后是热态,评论已加载,切换 <details> 块,打开并取消回复编辑器,折叠和展开文件,切换侧边栏树,深入扫过文件列表,调整窗口大小。每一次测量都被镜像到应用的磁盘日志中,因此一个 agent 可以在无人值守的情况下读取运行时行为。每个样本都携带一个健康信号,这就是客观检查。一个热态样本只有在整个滚动范围内(包括深入文件扫描)评论之间没有未填充的空隙、没有留下空白的评论块、且真实线程内容确实已挂载时,才算作健康。

我们运行的循环是:

  1. 在真实引擎上无人值守地复现。 启动自动驾驶器,让它循环,读取磁盘日志。
  2. 用健康信号检测,而不是用眼睛。 信任样本字段。
  3. 探查可疑的接缝。 当某个信号变坏时,在那里添加一个狭窄的结构化探针,重新启动,重新读取。(编辑界面会热重载实时窗口并重新启动自动驾驶器,所以一次新的捕获大约只差一个循环。)
  4. 移除脚手架。一旦你理解了不变量,就把它固定在测试和设计文档中,只保留检测器级别的信号。

这让我们处于什么位置

审查如此庞大的拉取请求过去意味着两件事之一:等待,或者放弃并在别处阅读。审查不是一份尺寸已知的文档。它是一场在你阅读时不断改变形态的对话,而底层界面必须从一开始就为此构建,而不是事后修补。

结果是一个拉取请求视图:一个包含数十万行差异和数百条线程评论的拉取请求,能够像正常大小的拉取请求一样打开、滚动和运行。评论完整渲染,而不是被裁剪到可滚动框中。展开折叠区域只会移动下方的代码,不影响其他内容。回到你刚离开的拉取请求时,会回到你之前的位置。

如果你以审查代码为生,值得在一个你已知很痛苦的拉取请求上感受这种差异。打开你最糟糕的那个。

试用 GitHub Copilot 应用 >

文章 在 GitHub Copilot 应用中渲染巨型拉取请求 首次出现在 The GitHub Blog。

相关阅读

专题与标签

相关资讯专题

标签