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 如何换行、可展开区域、其中是否有回复框,以及图片是否已加载完成。这些都要到渲染时才能确定。这迫使采用不同的架构。
三个问题:
- 测量。在渲染之前,你无法知道一条评论有多高。这打破了让大型 diff 在滚动时保持响应流畅的设计。
- 数据管道。如果为快速 diff 界面提供数据的管道停滞,或者丢弃了已经完成的工作,那么再快的界面也毫无价值。
- 我们实际是如何发现这些 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>、打开回复框、图片加载完成。块立即变大,但其下方的代码要到下一次空闲流程才移动。有一帧里评论变高了,而它下面的一切仍停在旧位置,你能看到两步跳变。因此,当块已挂载且在屏幕上时,观察器现在会测量它,并在同一帧内、绘制之前应用修正。块变大,代码重新定位,下方的一切一起移动。两项保护措施防止这变成我们原本要避免的循环:每帧最多一次同步提交,因此一连串尺寸变化会合并为一次;并且在滚动进行期间绝不这样做,此时会回退到批处理流程。
滚动锚定:在不与用户对抗的情况下修正
当测得高度与估算值不同时,滚动条的计算会改变,而朴素的结果就是视口跳动。解决办法是按身份而非按像素来修正:
- 在应用高度更新之前,先记录用户当前锚定在什么位置(某一行或某个块,按身份识别),以及在该位置内的偏移量。
- 应用高度增量。
- 将同一锚点解析到其新的像素位置。
- 滚动页面,使锚点在视口中保持原位。
再加上几条规则,避免让人感觉不对劲:
- 视口上方的块高度变化 → 按增量调整(保持你的位置)。
- 视口下方的内容水合 → 不调整(你看不到它)。
- 如果是你切换了
<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 可以在无人值守的情况下读取运行时行为。每个样本都携带一个健康信号,这就是客观检查。一个热态样本只有在整个滚动范围内(包括深入文件扫描)评论之间没有未填充的空隙、没有留下空白的评论块、且真实线程内容确实已挂载时,才算作健康。
我们运行的循环是:
- 在真实引擎上无人值守地复现。 启动自动驾驶器,让它循环,读取磁盘日志。
- 用健康信号检测,而不是用眼睛。 信任样本字段。
- 探查可疑的接缝。 当某个信号变坏时,在那里添加一个狭窄的结构化探针,重新启动,重新读取。(编辑界面会热重载实时窗口并重新启动自动驾驶器,所以一次新的捕获大约只差一个循环。)
- 移除脚手架。一旦你理解了不变量,就把它固定在测试和设计文档中,只保留检测器级别的信号。
这让我们处于什么位置
审查如此庞大的拉取请求过去意味着两件事之一:等待,或者放弃并在别处阅读。审查不是一份尺寸已知的文档。它是一场在你阅读时不断改变形态的对话,而底层界面必须从一开始就为此构建,而不是事后修补。
结果是一个拉取请求视图:一个包含数十万行差异和数百条线程评论的拉取请求,能够像正常大小的拉取请求一样打开、滚动和运行。评论完整渲染,而不是被裁剪到可滚动框中。展开折叠区域只会移动下方的代码,不影响其他内容。回到你刚离开的拉取请求时,会回到你之前的位置。
如果你以审查代码为生,值得在一个你已知很痛苦的拉取请求上感受这种差异。打开你最糟糕的那个。
文章 在 GitHub Copilot 应用中渲染巨型拉取请求 首次出现在 The GitHub Blog。