跳到正文
GitHub Blog · AI & ML· Alberto Gimeno·· 15 天前精选AI 评分61

GitHub Copilot app 如何渲染超大 pull request

Rendering huge pull requests in the GitHub Copilot app

AI 导读

GitHub Blog 介绍了 GitHub Copilot app 为超大 pull request 重建视图的工程方案。团队用一个包含 2,200 个文件、超过 100 万行变更、400 多条行内评论的开源 pull request 压测,通过代码行虚拟化、评论动态高度测量、滚动锚定、增量数据管线和自动化测量循环,让大 diff 与多线程评论保持可滚动和可阅读。

推荐理由

原文拆解了大规模 diff 与评论渲染的工程取舍,可迁移到其他长列表和动态内容界面。

正文 · AI 翻译

大型重构和迁移通常必须作为一次变更合并。

堆叠式拉取请求是将工作拆分成较小变更的好方法,这样可以简化审查,并帮助团队以更低的风险交付。但有些变更就像这次一样,无法干净地拆分。这会让你只能使用一个可能变得非常庞大的拉取请求,而审查讨论又会使它进一步膨胀。

即使差异及其讨论规模庞大,审查体验也需要保持快速流畅。在 GitHub Copilot 应用中,我们围绕这一要求重建了拉取请求视图。

为了了解这种方式的极限,我们打开了能找到的最大拉取请求:一个开源拉取请求,包含 2,200 个文件、超过一百万行变更,以及 400 多条行内审查评论。下面介绍我们如何让这样极端的拉取请求也能保持高性能。

问题的范围

快速渲染大型差异已经是一个广为人知的问题:将行虚拟化、保持已挂载的 DOM 较小,并利用每一行都是高度已知的代码行这一事实。

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

三个问题:

  1. 测量。 在渲染评论之前,你无法知道它有多高。这破坏了让大型差异在滚动时保持响应的设计。
  2. 数据管线。 如果为快速差异界面提供数据的数据管线发生停顿,或者丢弃了已经完成的工作,那么快速的差异界面也毫无价值。
  3. 我们究竟如何找到这些 bug。 这些问题会在高负载、特定引擎和特定滚动位置下出现。因此,我们定义了什么才算健康,为界面添加检测以回答这个问题,并在无人值守的情况下运行完整的“变更 → 测量 → 改进”循环。

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

第一步是理解让纯代码差异保持快速的几何原理。一旦加入评论,这种几何原理就不再足够。

是什么让大型差异保持快速

你不可能将一百万个 DOM 节点放到页面上。标准答案是虚拟化:只挂载屏幕上显示的行,再加上一小段边距,并在用户滚动时回收和复用相同的 DOM 元素。列表的表现就像一百万行都存在一样。滚动条大小正确,也可以滚动到指定行。但实际上同时存在的真实行只有约 100 行。

要维持这种假象,就必须有东西提供几何信息。滚动条的高度是所有行高之和。第 N 行的位置是其上方所有行的高度之和。跳转到某一行、绘制滚动条、判断屏幕上显示哪些内容,这一切都是对高度表执行的算术运算。你可以根据估算值构建这张表,并在行完成测量后进行修正;通用的可变高度虚拟化器正是这样做的。

但如果每一行都是采用已知字号的代码行,就不必如此。你可以预先计算完整的表,而且它永远不会改变,因此之后无需再修正。

我们称之为“绘制前已知所有高度”契约。我们的差异界面围绕这一契约构建:

  • 命令式、可回收的代码行渲染器(每行不使用一个 React 组件)
  • 用于偏移量计算的类型化数组几何数据
  • 由后端负责、以结构优先方式流式传输的差异文档
  • 带有精确“滚动到第 N 行”功能的命令式滚动 API

这些都不会出现糟糕的扩展性问题,因为没有任何逐帧工作会随着总行数增长。在纯代码场景下,这个设计是正确的,我们也完整保留了它。

评论如何改变契约

现在在 diff 中间放入一个 review 讨论串。它有多高?

你不知道,而且不渲染它就不可能知道。它的高度取决于只在渲染时才存在的因素,而且这些因素在首次绘制之后还可能持续变化:

  • 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

代码几何保留原来的世界。它是确定性的、前缀求和的、精确的,并且永远不会在评论改变大小时重建。

动态块几何覆盖所有我们无法预测高度的内容,比如 review 讨论串、草稿和回复编辑器。每一个都是一个块,由它是什么来标识,而不是由它当前所在的位置来标识。它有一个稳定的 key,能在其内容加载期间保持不变;它锚定到文件、行和 side,而不是像素坐标,因此重排不会让我们丢失对它的跟踪。我们还会保留所有可能改变该块高度的内容的指纹:它的内容、某个 <details> 是否打开、编辑器是否处于活动状态。并且我们会记录它上次被测量时的宽度,四舍五入到若干分桶中,这样普通的窗口调整大小就不会使文档中的每一次测量都失效。

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

测量调度器,以及我们一开始犯的错误

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

衡量动态内容的显而易见的方法是每个区块使用一个 ResizeObserver,它监视元素,并在元素变化时把测得的高度写回布局。这是我们设计过、随后在性能强化期间否决的方案。它正是大型虚拟化界面必须避免的反馈回路。一个把高度写回其所监视元素布局的 observer,可能会重新触发自身,而且成本会随着每个已挂载区块而增长。

最终上线的做法则是一次由空闲状态和滚动状态门控的测量流程,并遵循与确定性那一侧相同的约束:

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

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

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

  1. 在应用高度更新之前,捕获用户锚定到的对象(一行或一个块,按身份标识),以及其中的偏移量。
  2. 应用高度增量。
  3. 将同一个锚点解析到它新的像素位置。
  4. 滚动,使锚点在视口中保持不动。

再加上一些规则,避免感觉不对:

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

最后一条规则有个棘手的边缘情况,我们就被它坑了。“用户正在滚动时不要校正”被实现为对最后一次观察到的滚动时间的保护,而程序化滚动也会刷新这个时间戳。切换文件树侧边栏会改变 diff 面板的宽度。开启自动换行后,你上方的每一行换行都会重新排布成不同数量的可视行,整个坐标空间随之偏移,而界面在稳定下来时会自行发出一个小幅滚动。这个保护把它解读为“用户刚刚滚动过”,于是跳过了本应保持你位置的校正,所以你正在阅读的文件就漂出了屏幕。修复办法是区分用户滚动和界面自身造成的滚动。任何“用户是否正在交互?”的检查,都必须是你自己的副作用无法满足的检查。

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

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

diff 界面的速度只可能和提供给它的数据一样快,而这部分工作的三个习惯塑造了 UI 能做到的事情。第一个是先流式传输结构,再传输内容。diff 是增量请求的,因此文件树和元数据会在文档仍在加载时绘制出来,而完整的审查线程集会预先解析完成,而不是一点点流入。第二个是把逐项工作推迟到真正需要它时再做。语法高亮在主线程之外运行,所以行会先以纯文本形式立即出现,并在结果到达时着色。高亮是改善界面,而不是阻塞滚动。大型 markdown 正文和建议更改的上下文也是同样的方式:在接近视口之前什么都不会构建。

第三个习惯关乎哪些成本值得保留。当你离开页面时释放 diff 文档是正确的默认做法。这些文档很大,保留你访问过的每一个文档,会让一次长会话最终吞掉内存。但拉取请求元数据会保留下来,所以当你返回时,diff 周围的外壳、页眉和文件树会立即重新绘制,然后在那里等上几秒,等待一个刚刚还完整存在的 diff。围着空 diff 即刻绘制出来的外壳看起来像坏了一样,尽管你总体等待的时间更短。因此策略保持不变,我们增加了一个缓存:让最近几个 diff 常驻,淘汰超出数量的任何内容,并让后台刷新在某个 diff 变旧时发现这一点。

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

这个项目中几乎每个 bug 在出现之前都是不可见的,而手动复现其中任何一个都令人痛苦。典型的报告会这样写:“某些评论下方会出现一条空白区域,但只是偶尔出现,只在大型拉取请求中出现,而且滚动过去再滚回来就会自行恢复。”你不可能靠盯着屏幕来调试这种问题,所以我们构建了工具,以机械化方式进行调试。

使用应用的真实信号进行检测,而不是临时日志

最简单的工作流是到处插入 console.log 调用,手动执行流程,复制输出,将其粘贴给能够分析它的人(或某个工具),删除日志,然后重复这一过程。这很慢,需要人工参与;更糟的是,你最终测量的是自己手写的检测代码,而不是应用的真实行为。

因此,这个界面为自身的不变量保留了永久的结构化探针。它们是一些界面在每次渲染时都会回答的简单问题:

  • 这个界面是否确实受视口约束?当前挂载了多少行和评论块?
  • 测量是否会合并为每帧一次提交,这一帧耗时多久?
  • 我们执行的滚动校正幅度有多大?
  • 滚动开始后是否插入过任何评论块?(后端拓扑结构加载完成后必须为零。)
  • 每个评论块的观察器在卸载时是否确实被拆除,还是我们每个块泄漏了一个观察器?

这些就是客观的通过/失败信号,并且会在针对合成的多评论大型拉取请求测试装置的端到端测试中,以预算的形式进行断言。现在,CI 可以告诉我们这个界面是否运行正常。

让循环自动运行

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

无头探针通道针对一个模拟服务器执行声明式流程(打开一个拉取请求,滚动到某个比例,切换详情块,调整窗口大小),读取应用自身的生产环境检测数据:React 渲染次数、性能时间线,以及用于检测卡顿的 requestAnimationFrame 采样器。它会自行完成检测、驱动、收集、分析和排序这一整套循环,并按顺序打印出瓶颈所在。由于该流程只是运行时传递给探针的 JSON,代理只需用自然语言描述流程,就能对任意流程进行性能分析,无需修改一行源代码。

自动驾驶无人值守地循环驱动实际的桌面应用执行大型拉取请求流程:先进行冷启动,此时评论仍是骨架屏;然后进行热运行,此时评论已加载,并切换 <details> 块、打开和取消回复编辑器、折叠和展开文件、切换侧边栏树、深入遍历文件列表,以及调整窗口大小。每次测量都会同步写入应用的磁盘日志,因此代理无需有人操作键盘即可读取运行时行为。每个样本都带有一个健康信号,这就是客观检查标准。只有在整个滚动范围内(包括深入文件的遍历)都不存在评论之间未填充的空白、没有留下空白的评论块,并且确实挂载了真实的线程内容时,热运行样本才会被视为健康。

我们运行的循环是:

  1. 在真实引擎上无人值守地复现。启动自动驾驶,让它循环运行,然后读取磁盘日志。
  2. 使用健康信号进行检测,而不是靠肉眼观察。相信样本字段。
  3. 探查可疑环节。当某个信号变差时,就在那里添加一个范围狭窄的结构化探针,重新启动,再次读取。(编辑界面会让实时窗口热重载,并重新启动自动驾驶,因此距离新一轮采集只需一个循环。)
  4. 移除脚手架。 一旦你理解了这个不变量,就把它固定在测试和设计文档中,并且只保留检测器级别的信号。

这让我们走到哪一步

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

其结果是一个拉取请求视图:包含数百万行差异和数百条线程评论的拉取请求能够打开、滚动,并且表现得像一个正常大小的拉取请求。评论会完整渲染,而不是被裁切进一个可滚动的框中。展开一个折叠的部分时,只会移动它下方的代码,其他内容不受影响。回到你刚刚离开的拉取请求时,会把你带回原来的位置。

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

试用 GitHub Copilot 应用 >

文章 在 GitHub Copilot 应用中渲染巨大的拉取请求 最先发布于 The GitHub Blog。

来源:GitHub Blog · AI & ML · github.blog