GitHub Copilot 应用如何渲染超大 Pull Request
Rendering huge pull requests in the GitHub Copilot app
GitHub Copilot 应用重建了 Pull Request 视图,以应对超大 diff 与评论。团队用一份含 2,200 个文件、超 100 万行改动、400 多条行内评论的开源 PR 做验证:代码行沿用“绘制前已知全部高度”的虚拟化几何,评论则拆成独立的动态块几何,高度有界、延迟测量,修正只锚定在用户当前查看的位置。
正文 · AI 翻译
大规模重构和迁移往往必须作为一次变更落地。
堆叠式拉取请求是将工作拆分为更小变更的绝佳方式,这能让评审更轻松,并帮助团队以更低风险交付。但有些变更,比如这一个,无法干净地拆分。这就导致你只能用一个单一的拉取请求,而它可能变得非常庞大,评审对话又会让它继续增长。
即使 diff 及其对话极其庞大,评审体验也必须保持快速流畅。在 GitHub Copilot 应用中,我们正是带着这一要求重建了拉取请求视图。
为了看看它能做到什么程度,我们打开了能找到的最大的拉取请求:一个开源 PR,包含 2,200 个文件、超过一百万行改动,以及 400 多条行内评审评论。下面是我们如何让这个极端拉取请求也保持高性能的。
问题的范围
快速渲染大型 diff 已是成熟做法:虚拟化行、保持挂载的 DOM 精简,并利用每一行都是已知高度的代码行这一事实。
评论才是难点。评论的高度取决于其 markdown 如何换行、可展开区域、其中是否有回复框,以及图片是否已加载。这些都要到渲染时才能知道。这迫使采用不同的架构。
三个问题:
测量。在渲染之前,你无法知道评论有多高。这破坏了让大型 diff 在滚动时保持响应性的设计。
数据管道。如果为快速 diff 界面供数的数据管道停滞,或者丢弃已经完成的工作,那么再快的 diff 界面也毫无价值。
我们实际是如何找到这些 bug 的。这些问题会在负载下、在特定引擎上、在特定滚动位置暴露出来。因此我们定义了“健康”意味着什么,对界面进行埋点以回答这个问题,并无人值守地运行整个“变更 → 测量 → 改进”循环。
第 1 部分:虚拟化,以及评论为何会破坏它
第一步是理解让纯代码 diff 保持快速的几何结构。一旦评论进入画面,这种几何结构就不再够用。
是什么让大型 diff 保持快速
你不能把一百万个 DOM 节点放到页面上。标准答案是虚拟化:只挂载屏幕上的行,外加一小段边距,并在用户滚动时复用这些相同的 DOM 元素。列表表现得就像一百万行都存在一样。滚动条尺寸正确,滚动到某行也能工作。但同一时间真正存在的只有大约 100 行。
要让这种假象成立,必须有东西提供几何信息。滚动条高度是所有行高之和。第 N 行的位置是其上方所有行高之和。跳转到某一行、绘制滚动条、决定屏幕上显示什么,全都是对一张高度表做算术。你可以用估算值构建这张表,并在行被测量后修正它,通用可变高度虚拟化器正是这么做的。
但如果每一行都是已知字号的代码行,你就不必这么做。你可以预先计算整张表,而且它永远不会变,所以之后没有什么需要修正。
把这称为“绘制前已知全部高度”契约。我们的 diff 界面正是围绕它构建的:
命令式、可复用的代码行渲染器(每行没有 React 组件)
用于偏移量计算的类型化数组几何
后端拥有的 diff 文档,以结构优先的方式流式传输
带有精确“滚动到第 N 行”的命令式滚动 API
这些都不会随规模恶化,因为没有任何逐帧工作会随总行数增长。在纯代码上,这个设计是正确的,我们保留了它的全部。
评论如何改变契约
现在把一条评审线程放到 diff 中间。它有多高?
你不知道,而且不渲染就无法知道。它的高度取决于只在渲染时存在的东西,而且这些东西在首次绘制后还可能继续变化:
在不同宽度下换行方式不同的 markdown
用户可以在原地展开或折叠的块
在现有线程内打开、并随着输入而增长的回复编辑器
建议更改 diff、表情回应、编辑模式、解决状态横幅
图片和异步资源,加载完成后会改变高度
显而易见的答案是给每条评论预留一个固定高度的槽位,大小由估算器决定。但在大型拉取请求上这会崩溃。一个平均而言正确的估算器在极端情况下仍然是错的。它对大多数评论预留过多,留下空白间隙;对昂贵的评论又预留不足,导致裁剪或长出嵌套滚动条。如果你在绘制后测量真实高度并写回共享偏移表,那么下方的一切都会移动,而用户已经在滚动了。这就是滚动跳动,在大型拉取请求上还是很大的跳动。
所以评论需要不同的契约。“绘制前已知全部高度”对这类内容无法实现。我们反而可以承诺:高度有界、延迟测量,并且修正很小,且锚定在用户正在看的内容上。
两种几何结构,而非一种
让这件事变得可处理的想法,是停止强迫一种几何结构同时服务两类内容。我们把文档高度拆成两个独立的域:
总高度 = 确定性代码高度(精确,事先已知)+ Σ 动态块有效高度(先估算,后测量)+ 滚动内边距
代码几何保留原始世界。它是确定性的、前缀和式的、精确的,当某条评论改变尺寸时绝不会重建。
动态块几何涵盖所有我们无法预测高度的内容,例如评审线程、草稿和回复编辑器。每一个都是一个块,由它是什么来标识,而不是由它当前位于何处来标识。它有一个稳定的键,能在其内容加载后依然存活,并且它锚定到文件、行和侧,而不是像素坐标,因此重排不会让它丢失踪迹。我们还保留一个指纹,涵盖所有可能改变该块高度的因素:它的内容、某个是否展开、某个编辑器是否处于活动状态。我们还记录它上次被测量时的宽度,并取整分桶,这样普通的窗口缩放不会使文档中的每个测量值失效。
那么一个块的有效高度就很简单:如果有有效的测量值就用测量高度,如果指纹和宽度仍然匹配就用缓存高度,否则就用估算值。这些高度存放在它们自己的索引中,与代码行分离,因此一条改变尺寸的评论永远不会迫使代码几何被重建。而且块的数量受评论数量限制,而不是受行数限制。几千个块没问题,只要首次绘制时从不一次性挂载或测量全部块。
测量调度器,以及我们最初犯的错误
这部分花的时间最长才做对,因为我们最初的设计以一种很有启发性的方式错了。
测量动态内容最显而易见的方式是为每个块配一个 ResizeObserver,它监视元素,并在其变化时把测量到的高度写回布局。这就是我们设计出来、然后在性能加固期间否决的方案。这正是大型虚拟化表面必须避免的反馈循环。一个观察者把高度写回它所监视元素的布局,可能会重新触发自己,而且成本会随每个已挂载的块增长。
实际发布的是一个单一的、受空闲和滚动门控的测量过程,遵循与确定性一侧相同的纪律:
避开热路径。它在可见范围稳定时运行,绝不每滚动帧运行一次,并且在滚动进行中完全等待。滚动中途重排正是我们要避免的卡顿。滚动停止后它会再次运行。
限定在视口范围内。只有距离视口大约 2400px 以内的块才是候选,因此工作量是 O(视口)。远处的块继续沿用其估算值,并在接近时得到修正。
屏幕上的读取优先。已挂载的块就在屏幕上,所以它的渲染高度是基准真相。该过程在一个批次中读取每个已挂载的候选,一次重排,中间没有写入,并记录它发现的结果。已挂载的块绝不会为了一个过时的估算值而被跳过。这一条规则修复了我们遇到的最棘手的 bug:评论渲染后下方留有一条空白,因为一个已挂载的块被过滤出测量范围,留在了过高的估算值上。
屏幕外测量是有界的回退方案。对于一个尚未挂载的附近块,该过程最多执行一次屏幕外渲染,以便在它滚动进入视野之前修正其预留空间。比视口还高的块连这个都跳过。它们过度预留的空间隐藏在首屏之下,所以不值得为此付出渲染成本。
一个观察者兜住其余情况。有些高度变化不会改变指纹,也不会与滚动同时发生:在回复编辑器中输入、图片加载完成、切换某个。每个已挂载的块都保留一个 ResizeObserver,但默认情况下它所做的只是标记该块,以便空闲过程重新读取它。它自己从不写入高度,因为那会闭合我们已否决的反馈循环。它在卸载时断开连接,一个不活动的拉取请求标签页不观察任何东西。
但有一个刻意的例外。对于你自己造成的尺寸变化,等待是明显错误的:展开某个、打开回复编辑器、图片加载完成。块立即变大了,但它下面的代码只在下一次空闲过程时才移动。有一帧评论变高了,而它下面的一切还停在旧位置,你能看到这两步。所以当一个块已挂载且在屏幕上时,观察者现在会测量它,并在同一帧内、绘制之前应用修正。块变大,代码重新定位,下面的一切一起移动。两个保障措施防止这变成我们一直在避免的循环:每帧最多一次同步提交,这样一连串尺寸变化会合并为一次;并且在活动滚动期间绝不这样做,那时它会回退到批处理过程。
滚动锚定:修正而不与用户对抗
当测量高度与估算值不同时,滚动条的计算会改变,而朴素的结果就是视口跳动。修复方法是按身份而不是按像素来修正:
在应用高度更新之前,先记录用户当前锚定在什么上(某一行或某个块,按身份识别),以及它内部的偏移量。
应用高度增量。
将同一个锚点解析到它新的像素位置。
滚动,使锚点在视口中保持原位。
再加上几条规则,避免让它感觉不对劲:
视口上方的块改变高度 → 按增量调整(保持你的位置)。
视口下方的内容正在水合 → 不调整(你看不到它)。
如果你在可见块中切换了某个或打开了某条回复 → 对该块抑制上方块的修正,让交互感觉直接,并让下方内容自然下移。
绝不要与活跃的指针或滚轮动量对抗;在帧结束后批量执行修正。
最后这条规则有一个尖锐的棱角,而且它确实咬到了我们。“用户滚动时不要修正”被实现为对最后一次观察到的滚动的守卫,而程序化滚动也会刷新那个时间戳。切换文件树侧边栏会改变 diff 面板的宽度。开启自动换行后,你上方的每一行换行文本都会重排成不同数量的视觉行,整个坐标空间发生偏移,而界面在稳定过程中会发出一次自己的小幅滚动。守卫将其解读为“用户刚刚滚动过”,于是跳过了那个本应保持你位置的修正,结果你正在阅读的文件漂移出了屏幕。修复方法是把用户滚动与界面自身造成的滚动区分开来。任何“用户是否正在交互?”的检查,都必须是自身的副作用无法满足的检查。
所以修正保持很小,它们复用我们已经拥有的测量结果,并且它们跟随你正在查看的内容。
第 2 部分:界面背后的流水线
diff 界面的速度只能和喂给它的数据一样快,而那一侧工作中的三个习惯塑造了 UI 能做到什么。第一个是先流式传输结构,再传输内容。diff 是增量请求的,所以文件树和元数据在文档仍在加载时就绘制出来,而完整的评审线程集合会预先解析,而不是陆续到达。第二个是把逐项工作推迟到需要它的时候。语法高亮在主线程之外运行,所以行会立即以纯文本出现,并在结果到达时着色。高亮是在改进界面,而不是阻塞滚动。大型 markdown 正文和建议修改的上下文也以同样方式工作:在接近视口之前不构建任何东西。
第三个习惯关乎哪些成本值得保留。在导航离开时释放 diff 文档是正确的默认行为。这些文档很大,保留你访问过的每一份,就是长会话最终吃掉内存的方式。但拉取请求元数据会持久保留,所以 diff 周围的外壳、头部和文件树,在你返回时会立即重绘,然后在那里坐上好几秒,等待一个片刻之前还完整的 diff。一个围绕空 diff 的即时绘制外壳看起来像是坏了,即使你总体等待的时间更短。所以策略保留了下来,我们加了一个缓存:保留最近几份 diff 驻留,淘汰超出部分,并让后台刷新注意到某一份已经过期。
第 3 部分:测量循环,或者说我们实际上是如何找到这些 bug 的
这个项目里几乎每个 bug 在被发现之前都是不可见的,而手动复现一个 bug 非常痛苦。一份典型的报告写道:“某些评论下方会出现一条空白,但只是有时,只在大拉取请求上,而且如果你滚过去再滚回来它就会自愈。”你没法靠盯着屏幕来调试这个,所以我们构建了工具来机械地调试它。
用应用的真实信号来插桩,而不是一次性日志
朴素的工作流是撒下 console.log 调用,手动走一遍流程,复制输出,粘贴给能分析它的人(或东西),删掉日志,然后重复。它很慢,需要人在环路中,而最糟糕的是,你最终测量的是自己手搓的插桩,而不是应用的真实行为。
所以界面为其自身的不变量携带了永久的、结构化的探针。它们是它在每次渲染时对自身回答的简单问题:
界面是否真的受视口约束?现在挂载了多少行和评论块?
测量是否每帧合并为一次提交,那一帧耗时多久?
我们正在做的滚动修正有多大?
滚动开始后是否有任何评论块被插入?(一旦后端拓扑落地,必须为零。)
逐块观察者是否真的在卸载时拆除,还是我们每个块泄漏一个?
这些是客观的通过/失败信号,它们在一个针对合成的多评论巨型拉取请求夹具的端到端测试中作为预算被断言。CI 现在可以告诉我们界面是否健康。
让循环进入自动驾驶
核心是一个自主的变更 → 测量 → 改进循环。两条通道:
一条无头探针通道针对模拟服务器运行了一个声明式流程(打开一个拉取请求、滚动到某个比例位置、切换一个详情块、调整窗口大小),并读取应用自身的生产环境插桩数据:React 渲染次数、性能时间线,以及用于检测卡顿的 requestAnimationFrame 采样器。它自行完成了整个插桩、驱动、采集、分析、排序的循环,并按顺序打印出瓶颈。由于该流程只是在运行时交给探针的 JSON,智能体可以用平实的英语描述任意流程来对其做性能剖析,而无需改动一行源代码。
一个自动驾驶程序无人值守地循环驱动真实的桌面应用走完超大拉取请求流程:先是冷启动,评论仍是骨架屏;然后是热态,评论已加载,切换块、打开并取消回复编辑器、折叠和展开文件、切换侧边栏树、深入扫过文件列表、调整窗口大小。每一次测量都被镜像到应用的磁盘日志中,因此智能体可以在无人值守的情况下读取运行时行为。每个样本都携带一个健康信号,而这就是客观检查。一个热态样本只有在整个滚动范围内(包括深入文件的扫掠)评论之间没有未填充的空隙、没有留下空白的评论块、且真实线程内容确实已挂载时,才算作健康。
我们运行的循环是:
在真实引擎上无人值守地复现。启动自动驾驶,让它循环,读取磁盘日志。
用健康信号检测,而不是用眼睛。信任样本字段。
探查可疑的接缝。当某个信号变坏时,在那里添加一个窄范围的结构化探针,重新启动,重新读取。(编辑界面会热重载实时窗口并重新启动自动驾驶,因此一次新的采集大约只差一个周期。)
移除脚手架。一旦你理解了不变量,就把它固定在测试和设计文档中,并只保留检测器级别的信号。
这让我们处于什么位置
审查如此庞大的拉取请求过去意味着两件事之一:等待,或者放弃并到别处去读。审查不是一份尺寸已知的文档。它是一场在你阅读时不断改变形态的对话,而底层界面必须从一开始就为此构建,而不是事后打补丁。
结果是一个拉取请求视图:一个包含数十万行 diff 和数百条线程评论的百万行 diff 能够打开、滚动,并表现得像正常大小的拉取请求一样。评论完整渲染,而不是被裁剪进一个可滚动的框里。展开一个折叠的区块只会移动其下方的代码,别的什么都不动。回到你刚刚离开的拉取请求时,会把你放回原来的位置。
如果你以审查代码为生,那么在一个你早已知道很痛苦的拉取请求上感受这种差异是值得的。打开你手上最糟糕的那个。