技术速递|在 GitHub Copilot App 中渲染超大型拉取请求
作者:Alberto Gimeno
排版:Alan Wang
我们如何重构 GitHub Copilot App 中的差异对比界面,使其能够打开包含数百万行代码、数百条行内审查评论的拉取请求。
大规模重构和迁移往往必须作为一个整体变更合并。
堆叠式拉取请求是将工作拆分为多个较小变更的好方法,这样既能简化代码审查,也能帮助团队以更低的风险交付代码。但有些变更,比如本文介绍的这个案例,很难进行合理拆分。最终,你只能创建一个规模庞大的拉取请求,而代码审查过程中产生的讨论还会让它变得更大。
即使差异内容和相关讨论规模庞大,代码审查体验也必须保持快速、流畅。为此,我们在 GitHub Copilot App 中重构了拉取请求视图。
为了验证这一设计的极限,我们打开了能够找到的最大拉取请求:一个开源项目的拉取请求,包含 2,200 个文件、超过 100 万行变更,以及 400 多条行内代码审查评论。下面介绍我们如何让这个极端规模的拉取请求依然保持高性能。
在 GitHub Copilot App 中渲染超大拉取请求
问题的范围
如何快速渲染大型代码差异,这个问题早已有成熟的解决方案:对行进行虚拟化,尽可能减少挂载的 DOM 节点,并利用每一行代码高度已知这一特点。
真正棘手的是评论。评论的高度取决于 Markdown 文本如何换行、可展开区域的状态、其中是否包含回复输入框,以及图片是否已经加载完成。所有这些信息都只能在渲染时确定,因此我们需要采用不同的架构。
这里有三个问题:
-
测量。 在渲染评论之前,你无法知道它的实际高度。这打破了原有设计赖以保持大型差异视图滚动响应速度的前提。
-
数据管道。 如果为差异视图提供数据的管道出现阻塞,或者丢弃了已经完成的工作,那么再快的差异视图也毫无意义。
-
如何真正发现 Bug。 这些问题往往只会在高负载、特定浏览器引擎和特定滚动位置下出现。因此,我们定义了性能健康状态的判断标准,为界面添加检测机制,并让整个“修改 → 测量 → 改进”循环在无人干预的情况下自动运行。
第一部分:虚拟化,以及评论为何会破坏它
第一步是理解纯代码差异视图能够快速运行的几何基础。一旦加入评论,这套几何模型就不再足够。
大型差异视图为何能够快速运行
你不可能在一个页面上放置 100 万个 DOM 节点。标准解决方案是虚拟化:只挂载当前屏幕上可见的行,以及周围一小段缓冲区域,并在用户滚动时重复利用这些 DOM 元素。从表现上看,这个列表仿佛包含全部 100 万行。滚动条的长度正确,滚动到指定行的功能也能正常工作。但实际上,同一时间真正存在的行可能只有约 100 行。
要维持这种视觉上的完整性,就必须有一套几何信息作为支撑。滚动条的高度等于所有行高度之和,第 N 行的位置则等于它上方所有行的高度之和。无论是跳转到指定行、绘制滚动条,还是判断哪些内容位于屏幕内,本质上都是基于一张高度表进行计算。这张表可以先通过估算建立,再随着行被测量而不断修正。通用的可变高度虚拟化方案就是这样工作的。
但如果每一行都是字体大小已知的代码行,就没有必要这么做。你可以提前计算整张高度表,而且它永远不会变化,因此后续无需修正。
我们将其称为“绘制前已知所有高度”的约定。我们的差异视图就是围绕这一约定构建的:
-
使用命令式、可复用的代码行渲染器,而不是为每一行创建一个 React 组件。
-
使用类型化数组存储几何信息,以便计算偏移量。
-
由后端负责生成差异文档,并优先流式传输结构信息。
-
提供命令式滚动 API,精确支持“滚动到第 N 行”。
这些设计都不会随着行数增加而出现糟糕的性能表现,因为每一帧的工作量不会随总行数增长。对于纯代码内容,这套设计是正确的,因此我们完整保留了它。
评论如何改变这一约定
现在,在差异内容中间加入一个代码审查讨论串。它有多高?
你无法提前知道,也不可能在渲染之前知道。它的高度取决于一些只有在渲染时才会出现的信息,而且首次绘制之后,这些信息仍可能继续变化:
-
Markdown 文本会根据不同的宽度以不同方式换行。
-
用户可以就地展开或折叠
<details>区块。 -
回复编辑器会在现有讨论串内部打开,并随着输入内容的增加而变高。
-
建议修改的差异内容、表情回应、编辑模式和解决状态提示条。
-
图片及其他异步资源加载完成后,会改变内容的高度。
一个直观的解决方案是为每条评论预留固定高度的空间,并通过估算器计算所需高度。但在大型拉取请求中,这种方式很快就会失效。即使估算器在平均情况下足够准确,在极端情况下仍然可能出现偏差。它会为大多数评论预留过多空间,留下大片空白;而对于少数高度较大的评论,又会预留不足,导致内容被裁切,或者出现嵌套滚动条。如果在首次绘制后测量实际高度,再将结果写回共享偏移量表,那么下方的所有内容都会移动,而此时用户可能正在滚动。这就会造成滚动跳动。在大型拉取请求中,这种跳动可能非常明显。
因此,评论需要采用另一套约定。对于这类内容,“绘制前已知所有高度”是不可能实现的。我们能够保证的是:高度存在合理边界,按需进行延迟测量,并将修正幅度控制在较小范围内,同时始终锚定用户当前正在查看的内容。
使用两套几何模型,而不是一套
让问题变得可控的关键,是不再强迫同一套几何模型同时服务于两种不同类型的内容。我们将文档的高度拆分为两个相互独立的部分:
总高度 = 确定性的代码高度(精确,提前已知)
\+ Σ 动态区块的有效高度(先估算,再测量)
\+ 滚动内边距
代码几何信息保留了原有设计:确定性、基于前缀和计算、精确,并且不会因为评论高度发生变化而重建。
动态区块几何信息则负责处理所有无法预测高度的内容,例如代码审查讨论串、草稿和回复编辑器。每个动态区块都有一个稳定标识,用于标记它是什么,而不是它当前位于什么位置。即使内容正在加载,这个标识也不会改变。区块通过文件、行号和左右侧信息进行锚定,而不是依赖像素坐标,因此即使发生重新布局,也不会丢失对它的定位。我们还会为所有可能改变区块高度的因素记录指纹,包括内容本身、<details> 是否处于展开状态,以及回复编辑器是否处于激活状态。此外,我们会记录上次测量时的宽度,并将宽度划分为若干区间。这样,普通的窗口尺寸调整就不会导致整个文档中的所有测量结果全部失效。
因此,一个区块的有效高度可以简单确定:
-
如果存在有效的测量结果,就使用实际测量高度。
-
如果指纹和宽度仍然匹配,就使用缓存的高度。
-
否则,就使用估算高度。
这些高度存储在独立于代码行的索引中,因此评论尺寸变化不会迫使我们重建代码几何信息。而且,动态区块的数量取决于评论数量,而不是代码行数。即使有几千个区块也没有问题,前提是首次绘制时不会一次性挂载或测量所有区块。
测量调度器,以及我们最初犯下的错误
这一部分花费了我们最长的时间才最终完善,因为我们最初的设计存在一个很有启发性的错误。
测量动态内容最直接的方法,是为每个区块创建一个 ResizeObserver,持续监听元素,并在元素尺寸发生变化时将测量高度写回布局。我们最初采用了这种设计,但在性能加固阶段将其否决。这正是大型虚拟化界面必须避免的反馈循环:监听元素尺寸变化的观察器,如果将新高度写回它所观察的元素对应的布局,就可能再次触发自身,而且随着挂载区块数量增加,成本也会不断上升。
最终上线的方案,是一个由空闲状态和滚动状态共同控制的统一测量流程,并遵循与确定性布局相同的原则。
-
不进入滚动关键路径。 只有在可见区域稳定下来后才运行,不会在每一帧滚动过程中执行,并且在滚动进行期间完全暂停。我们要避免的正是滚动过程中发生重新布局。滚动停止后,测量流程才会再次运行。
-
限定在视口附近。 只有距离视口约 2,400 像素以内的区块才会进入候选范围,因此工作量与视口范围相关。较远的区块继续使用估算值,等它们接近视口时再进行修正。
-
优先读取屏幕上实际显示的内容。 已挂载的区块可能就在屏幕上,因此其实际渲染高度才是真实依据。测量流程会一次性读取所有已挂载的候选区块,在中间不执行任何写入的情况下完成一次统一的重新布局,然后记录测量结果。
-
绝不会为了使用过时的估算值而跳过已挂载区块。 这条规则解决了我们遇到的最棘手问题之一:评论下方出现一条空白区域,因为一个已经挂载的区块被测量流程过滤掉,仍然沿用了过高的估算值。
-
屏幕外测量是一种受限的兜底机制。 对于附近尚未挂载的区块,测量流程最多执行一次屏幕外渲染,在它进入视口之前修正预留空间。高度超过视口的区块甚至不会执行这一步,因为多预留的空间会隐藏在屏幕下方,不值得为此付出渲染成本。
-
观察器负责捕获其他变化。 有些高度变化不会改变指纹,也不会恰好发生在滚动期间,例如用户在回复编辑器中输入内容、图片加载完成,或者切换
<details>的展开状态。每个已挂载区块仍然保留一个ResizeObserver,但默认情况下,它只会标记该区块,提醒空闲测量流程重新读取尺寸。它本身不会写入高度,从而避免形成我们之前否决的反馈循环。区块卸载时,观察器会断开连接;未激活的拉取请求标签页则不会观察任何内容。 -
不过,这里有一个经过深思熟虑的例外。 对于用户主动触发的尺寸变化,等待测量并不合适,例如展开
<details>、打开回复编辑器,或者图片加载完成。区块立即变高,但下方代码要等到下一次空闲测量才会移动。于是,在一帧时间内,评论已经变高,而下方所有内容仍然停留在原来的位置,用户能够明显看到两次跳变。因此,当区块已经挂载且位于屏幕内时,观察器现在会在同一帧中直接测量其高度,并在绘制前应用修正。评论变高,代码随之重新定位,下方所有内容同步移动。我们通过两项保护措施避免再次引入反馈循环:每帧最多执行一次同步提交,因此一连串尺寸变化会合并为一次操作;同时,滚动过程中绝不执行这一操作,而是退回批量测量流程。
滚动锚定:修正布局,而不干扰用户
当实际测量高度与估算高度不一致时,滚动条的计算结果就会发生变化。如果直接应用新的高度,视口就会跳动。解决方法是根据内容的身份进行修正,而不是根据像素坐标进行修正:
-
应用高度更新之前,记录用户当前锚定的内容——某一行或某个区块——以及它在视口中的偏移量。
-
应用高度差值。
-
根据同一个锚点,重新计算它现在所在的像素位置。
-
调整滚动位置,让这个锚点在视口中保持原位。
此外,还有几条规则能够让整个过程更加自然:
-
如果视口上方的区块发生高度变化,就按照高度差值调整滚动位置,以保持用户当前的阅读位置。
-
如果视口下方的内容完成加载,则不调整滚动位置,因为用户看不到这些变化。
-
如果用户展开了
<details>,或者在可见区块中打开了回复编辑器,就取消针对该区块的上方内容修正,让交互直接生效,并允许下方内容自然向下移动。 -
绝不与用户正在进行的指针操作或滚轮惯性滚动争抢控制权;布局修正应延迟到当前帧结束后批量执行。
最后一条规则存在一个微妙的问题,而我们确实因此踩过坑。最初,“用户正在滚动时不要修正”这一逻辑,是通过检查最近一次被观察到的滚动事件来实现的。但程序主动触发的滚动也会更新时间戳。当用户切换文件树侧边栏时,差异视图区域的宽度会发生变化。如果启用了代码换行,那么上方每一行代码都可能重新排列为不同数量的视觉行,整个坐标空间随之变化。界面在稳定过程中还会自行产生一小段滚动。此时,保护逻辑会将其误判为“用户刚刚滚动过”,于是跳过原本用于保持阅读位置的修正,导致用户正在查看的文件逐渐移出屏幕。解决方法是区分用户主动触发的滚动和界面自身产生的滚动。任何用于判断“用户是否正在交互”的检测逻辑,都必须确保不会被系统自身的副作用误触发。
这样,我们就能将修正幅度控制在较小范围内,复用已有的测量结果,并始终跟随用户当前正在查看的内容。
第二部分:支撑差异视图的数据管道
差异视图的速度取决于为它提供数据的管道。在这部分工作中,有三个原则决定了界面最终能够实现怎样的性能。
第一个原则是:先传输结构,再传输内容。差异内容采用增量请求的方式获取,因此,即使整个文档仍在加载,文件树和元数据也可以先行显示。与此同时,所有代码审查讨论串都会提前完成解析,而不是随着加载过程陆续出现。
第二个原则是:将每个项目的处理工作推迟到真正需要时再执行。语法高亮在主线程之外运行,因此代码行可以立即以纯文本形式显示,等高亮结果返回后再添加颜色。高亮功能由此成为界面体验的增强,而不是阻塞滚动的前置条件。大型 Markdown 正文和建议修改的上下文也采用相同的策略:只有当它们接近视口时,才会真正构建相应内容。
第三个原则是:判断哪些成本值得保留。当用户离开某个拉取请求时,释放它的差异文档,是合理的默认策略。这些文档体积庞大,如果保留访问过的所有文档,长时间使用后就会消耗大量内存。但拉取请求的元数据会持续保留。因此,当用户返回时,差异视图外围的界面——包括标题栏和文件树——能够立即重新绘制,却还要等待几秒钟,才能重新获取刚刚还完整存在过的差异内容。即使总体等待时间减少了,一个已经绘制完成、内部却空空如也的界面,仍然会让人觉得出了问题。因此,我们保留了释放文档的基本策略,同时增加了一层缓存:保留最近访问的少数几个差异文档,淘汰超出数量限制的文档,并让后台刷新机制识别已经过期的缓存。
第三部分:测量循环,或者说我们究竟如何发现这些 Bug
这个项目中几乎所有的 Bug,在真正出现之前都难以察觉,而手动复现它们又十分痛苦。一份典型的问题报告可能是这样的:“某些评论下方偶尔会出现一条空白区域,但只在大型拉取请求中出现,而且滚动过去再回来后又会自行恢复。”盯着屏幕看,无法有效调试这类问题。因此,我们构建了一套工具,通过机制化的方式进行排查。
使用应用程序的真实信号进行检测,而不是临时日志
一种直观但低效的工作方式是:到处添加 console.log 调用,手动执行操作流程,复制输出结果,交给其他人或工具分析,然后删除日志并重复这一过程。这种方式速度慢,需要人工参与。更糟糕的是,你最终测量的可能是自己手工添加的检测逻辑,而不是应用程序的真实行为。
因此,我们在差异视图中保留了一组长期存在的结构化检测点,用于检查自身是否满足关键约束。每次渲染时,它们都会回答一些明确的问题:
-
界面是否真正将工作量限制在视口范围内?当前挂载了多少行和评论区块?
-
测量操作是否合并为每帧一次提交?每帧耗时多少?
-
我们执行的滚动位置修正幅度有多大?
-
滚动开始后,是否还有评论区块被插入?(后端确定好内容结构之后,这个数量必须为零。)
-
每个区块对应的观察器是否会在区块卸载时正确清理,还是会逐渐泄漏并累积?
这些都是客观的通过与失败判断指标。我们会将它们设定为端到端测试中的性能预算,并针对一个包含大量评论的大型拉取请求合成测试样例进行验证。现在,持续集成就能告诉我们,差异视图是否处于健康状态。
让整个循环自动运行
整个方案的核心,是一个自主运行的“修改 → 测量 → 改进”循环,包含两条工作路径。
第一条是无头探测流程。 它通过一个模拟服务器,按照声明式流程执行操作,例如打开拉取请求、滚动到指定比例的位置、切换 <details> 区块,以及调整窗口尺寸。与此同时,它会读取应用程序自身的生产环境检测数据,包括 React 渲染次数、性能时间线,以及用于检测卡顿的 requestAnimationFrame 采样器。整个流程能够自动完成检测、驱动操作、收集数据、分析结果和瓶颈排序,并按照严重程度输出性能瓶颈。由于整个操作流程只是运行时传递给探测器的一份 JSON 描述,因此智能体只需用自然语言描述操作流程,就能对任意流程进行性能分析,而无需修改任何源代码。
第二条是自动驾驶式测试流程。 它会在无人值守的情况下,反复驱动真正的桌面应用程序执行大型拉取请求的操作流程。首先进行冷启动测试,此时评论仍然是骨架占位内容;随后进行热状态测试,此时评论已经加载完成。测试过程中会反复展开和折叠 <details> 区块、打开并取消回复编辑器、折叠和展开文件、切换文件树侧边栏、深入滚动到文件列表的后部,以及调整窗口尺寸。每次测量结果都会同步写入应用程序本地磁盘上的日志,因此智能体能够在没有人操作键盘的情况下读取运行时行为。每个样本都会包含一个健康状态信号,用于客观判断测试结果。只有同时满足以下条件,热状态样本才会被判定为健康:
-
评论之间不存在未填充的空白区域。
-
不存在内容为空的评论区块。
-
实际讨论串内容确实已经挂载。
这些条件必须在整个滚动范围内成立,包括深入文件列表后部的测试过程。
我们运行的循环如下:
-
在真实运行引擎中无人值守地复现问题。 启动自动驾驶式测试,让它持续循环运行,然后读取磁盘上的日志。
-
通过健康状态信号检测问题,而不是依靠肉眼观察。 以采样数据中的字段为准。
-
针对可疑环节进行探测。 一旦某个信号出现异常,就在对应位置添加一个范围明确的结构化检测点,然后重新启动测试并读取结果。由于修改差异视图后,实时窗口会自动热重载,自动驾驶式测试也会重新启动,因此通常只需再运行一个循环,就能获得新的采样结果。
-
移除临时辅助代码。 理解关键约束之后,就将它固定到测试和设计文档中,只保留足以用于检测问题的信号。
最终成果
过去,审查如此庞大的拉取请求,往往意味着两种选择:等待,或者放弃当前界面,换个地方阅读。代码审查并不是一份尺寸固定的文档。它是一场会随着阅读过程不断改变形态的对话。底层界面从一开始就必须围绕这一特点进行设计,而不是等问题出现后再打补丁。
最终,我们打造了一个全新的拉取请求视图:即使差异内容达到 100 万行,并包含数百条带有回复的评论,也能像普通规模的拉取请求一样打开、滚动和交互。评论会完整渲染,而不是被裁切在带有独立滚动条的容器中。展开折叠区域时,只有下方代码随之移动,不会引起其他不必要的跳动。重新打开刚刚离开的拉取请求时,界面也会恢复到之前的阅读位置。
如果你的日常工作就是代码审查,不妨亲自感受一下这一变化,尤其是针对那些你早已知道审查起来非常痛苦的拉取请求。打开你遇到过的、最难审查的那个拉取请求,试试看。
更多推荐



所有评论(0)