GitHub Copilot 应用如何渲染超大 pull request
Rendering huge pull requests in the GitHub Copilot app
GitHub Copilot 应用重建了 pull request 视图,用一个 2,200 个文件、超百万行改动、400 多条行内评论的开源 PR 做压力测试。它把文档高度拆成确定性的代码几何与动态评论块两套体系:代码行高提前算好,评论高度按需测量并锚定到文件、行和侧,避免滚动跳动。
大规模重构和迁移通常必须作为一次变更落地。
堆叠式拉取请求是将工作拆分为更小变更的好方法,这能让审查更轻松,并帮助团队以更低风险交付。但有些变更,比如这一个,无法干净地拆分。这让你只剩下一个可能变得非常庞大的拉取请求,而审查对话又会让它不断增长。
即使 diff 及其对话极其庞大,审查体验也需要保持快速流畅。在 GitHub Copilot 应用中,我们正是带着这一要求重建了拉取请求视图。
为了看看它能走多远,我们打开了能找到的最大的拉取请求:一个开源项目,包含 2,200 个文件、超过一百万行变更,以及 400 多条行内审查评论。以下是我们如何让这个极端拉取请求也能保持高性能的。
问题的范围
快速渲染大型 diff 已是成熟技术:虚拟化行、保持挂载的 DOM 精简,并利用每一行都是已知高度的代码行这一事实。
评论才是难点。评论的高度取决于其 markdown 如何换行、可展开区域、其中是否有回复框,以及图片是否已加载。所有这些都要在渲染时才能确定。这迫使架构必须不同。
三个问题:
- 测量。在渲染之前,你无法知道一条评论有多高。这打破了让大型 diff 在滚动时保持响应性的设计。
- 数据管道。如果为快速 diff 界面提供数据的管道停滞,或者丢弃已经完成的工作,那么再快的 diff 界面也毫无价值。
- 我们实际是如何发现这些 bug 的。这些问题会在负载下、特定引擎上、特定滚动位置浮现。因此我们定义了“健康”的含义,对界面进行埋点以回答这个问题,并无人值守地运行整个“变更 → 测量 → 改进”循环。
第 1 部分:虚拟化,以及评论为何会破坏它
第一步是理解让纯代码 diff 快速的几何结构。一旦评论进入画面,这种几何结构就不再够用。
是什么让大型 diff 快速
你不能把一百万个 DOM 节点放在页面上。标准答案是虚拟化:只挂载屏幕上可见的行,外加一小段边距,并在用户滚动时回收这些相同的 DOM 元素。列表表现得仿佛一百万行都存在。滚动条尺寸正确,滚动到指定行也能工作。但同一时间真正存在的只有大约 100 行。
要让这种假象成立,必须有东西提供几何信息。滚动条高度是所有行高之和。第 N 行的位置是其上方所有行高之和。跳转到某一行、绘制滚动条、决定屏幕上显示什么,全都是基于高度表的算术运算。你可以用估算值构建该表,并在行被测量后修正它,通用可变高度虚拟化器正是这样做的。
但如果每一行都是已知字体大小的代码行,你就不必如此。你可以预先计算整张表,而且它永远不会改变,因此之后无需修正。
把这称为“绘制前所有高度已知”契约。我们的 diff 界面正是围绕它构建的:
- 命令式、可回收的代码行渲染器(每行没有 React 组件)
- 用于偏移量数学计算的类型化数组几何信息
- 后端拥有的 diff 文档,以结构优先的方式流式传输
- 带有精确“滚动到第 N 行”的命令式滚动 API
它不会严重扩展,因为没有任何逐帧工作会随总行数增长。在纯代码场景下,这个设计是正确的,我们保留了全部。
评论如何改变契约
现在把一条评审讨论串放在 diff 中间。它有多高?
你不知道,不渲染就无法知道。它的高度取决于只在渲染时存在的东西,而且这些东西在首次绘制后还会继续变化:
- 在不同宽度下换行方式不同的 Markdown
- 用户可以就地展开或折叠的
<details>块 - 在现有讨论串内打开、并随输入增长的回复编辑器
- 建议更改的 diff、表情回应、编辑模式、解决状态横幅
- 加载完成后高度会变化的图片和异步资源
显而易见的答案是为每条评论预留一个固定高度的槽位,由估算器确定大小。但在大型拉取请求上这就会崩溃。平均而言正确的估算器在极端情况下仍然是错的。它对大多数评论预留过多,留下空白间隙;对开销大的评论预留不足,导致裁剪或冒出嵌套滚动条。如果你在绘制后测量真实高度并写回共享偏移表,下面的所有内容都会移动,而用户已经在滚动了。这就是滚动跳动,在大型拉取请求上跳动还很大。
所以评论需要不同的契约。“绘制前所有高度已知”对这种内容是无法实现的。我们能承诺的是:高度有界、惰性测量,并且修正幅度小,且锚定在用户正在看的内容上。
两种几何,而非一种
让这件事变得可行的想法是,不再强迫一种几何同时服务两类内容。我们把文档高度拆成两个独立的域:
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 文档是正确的默认行为。这些文档很大,保留你访问过的每一个,就是长会话最终吃掉内存的方式。但拉取请求元数据会持久保留,所以 diff 的外壳、头部和文件树在返回时会立即重绘,然后在那里坐等好几秒,等待一个它片刻之前还完整拥有的 diff。一个围绕空 diff 的即时绘制外壳看起来像是坏了,即使你总体等待的时间更短。所以策略保持不变,我们加了一个缓存:保留最近几个 diff 常驻,超出则驱逐,并让后台刷新注意到某个 diff 已经过期。
第 3 部分:测量循环,或者说我们究竟是如何发现这些 bug 的
这个项目里的几乎每个 bug 都是隐形的,直到它现形为止,而手动复现一个简直痛苦不堪。典型的报告是这样写的:“某些评论下方会出现一条空白,但只是偶尔,只在大 PR 上出现,而且如果你滚过去再滚回来,它就好了。”你没法盯着屏幕调试这个,所以我们构建了工具来机械地调试它。
用应用的真实信号来埋点,而不是用一次性日志
天真的工作流是撒下 console.log 调用,手动走一遍流程,复制输出,粘贴给能分析它的人(或东西),删掉日志,然后重复。这很慢,需要人在环,最糟糕的是你最终测量的是自己手搓的埋点,而不是应用的真实行为。
所以这个界面自带永久性的、结构化的探针来检查自身的不变量。它们是界面在每次渲染时对自己回答的简单问题:
- 界面真的受视口约束吗?现在挂载了多少行和评论块?
- 测量是否每帧合并为一次提交,那一帧耗时多久?
- 我们做的滚动修正有多大?
- 滚动开始后有没有评论块被插入?(后端拓扑落地后必须为零。)
- 每个块的观察者在卸载时真的拆除了吗,还是每个块泄漏一个?
这些是客观的通过/失败信号,它们作为预算在针对一个合成的多评论巨型 PR 夹具的端到端测试中被断言。CI 现在能告诉我们界面是否健康。
让循环自动驾驶
核心是一个自主的 变更 → 测量 → 改进 循环。两条通道:
一条无头探针通道 针对模拟服务器运行一个声明式流程(打开一个 PR,滚动到某个比例,切换一个详情块,调整窗口大小),读取应用自己的生产埋点:React 渲染次数、性能时间线,以及一个用于卡顿的 requestAnimationFrame 采样器。它自己完成了整个埋点、驱动、收集、分析、排序的循环,并按顺序打印出瓶颈。因为流程只是在运行时交给探针的 JSON,代理可以通过用平实的英语描述来对任何流程进行性能分析,而无需编辑一行源代码。
一个自动驾驶 无人值守地循环驱动实际的桌面应用走完巨型 PR 流程:先是冷启动,评论还是骨架屏,然后是热启动,评论已加载,切换 <details> 块,打开和取消回复编辑器,折叠和展开文件,切换侧边栏树,深入扫描文件列表,调整窗口大小。每个测量都镜像到应用的磁盘日志,所以代理可以在无人操作键盘的情况下读取运行时行为。每个样本都带有一个健康信号,这就是客观检查。一个热样本只有在整个滚动范围内(包括深度文件扫描)评论之间没有未填充的间隙、没有留下空白的评论块、并且真实的线程内容确实挂载了时,才算健康。
我们运行的循环是:
- 在真实引擎上无人值守地复现。 启动自动驾驶,让它循环,读取磁盘日志。
- 用健康信号检测,而不是用眼睛。 相信样本字段。
- 探查可疑的接缝。 当信号变坏时,在那里添加一个狭窄的结构化探针,重新启动,重新读取。(编辑界面会热重载实时窗口并重新启动自动驾驶,所以一次新的捕获大约一个周期就能拿到。)
- 移除脚手架。一旦你理解了不变量,就把它固定在测试和设计文档中,只保留检测器级别的信号。
这让我们处于什么位置
审查如此庞大的拉取请求过去意味着两件事之一:等待,或者放弃并去别处阅读。审查不是一份尺寸已知的文档。它是一场在你阅读时不断改变形态的对话,而底层界面必须从一开始就为此构建,而不是事后修补。
结果是一个拉取请求视图,其中包含数百条线程评论的百万行差异可以像正常大小的拉取请求一样打开、滚动和运行。评论完整渲染,而不是裁剪进可滚动框中。展开折叠的部分只会移动其下方的代码,不会影响其他内容。回到你刚离开的拉取请求时,会回到你之前的位置。
如果你以审查代码为生,值得在一个你已知很痛苦的拉取请求上感受这种差异。打开你最糟糕的那个。
文章 在 GitHub Copilot 应用中渲染巨大的拉取请求 首次出现在 The GitHub Blog。
来源:GitHub Blog · AI & ML · github.blog