GitHub Copilot 应用如何流畅渲染超大 Pull Request
大范围的代码重构和迁移往往必须一次性提交。
堆叠式 pull request 是把工作拆成小改动的好方法,既方便评审,也能让团队更低风险地发布。但有些改动(比如这一次)没法干净地拆分,结果就是一个体量极大的 pull request,而且评审讨论还会让它继续膨胀。
即便 diff 和评审讨论非常庞大,评审体验也必须保持流畅。在 GitHub Copilot app 中,我们正是围绕这个要求重构了 pull request 视图。
为了验证效果到底如何,我们打开了能找到的最大一个 pull request:一个开源项目的 PR,包含 2,200 个文件、超过一百万行变更、400 多条行内评审评论。下面讲讲我们是如何让这个极端案例也保持高性能的。
问题的范围
如何快速渲染大 diff 已经是成熟问题:对行做虚拟化,保持挂载的 DOM 尽量小,并利用每行都是固定高度代码这一特点。
真正麻烦的是评论。评论的高度取决于 markdown 的换行方式、可展开的部分、有没有回复框、图片是否已加载——这些都要等到渲染时才能知道。这就迫使我们必须采用不同的架构。
三个问题:
- 测量。不渲染出来就不知道评论有多高,这破坏了让大 diff 滚动依然流畅的设计。
- 数据管线。如果数据管线卡顿,或者把已经完成的工作丢弃,再快的 diff 界面也没意义。
- 如何真正找到 bug。这些问题只在高负载、特定引擎、特定滚动位置下才会暴露。所以我们先定义了什么算"健康",给界面加上监控指标来回答这个问题,然后让"改动 → 测量 → 优化"的循环无人值守地跑起来。
第一部分:虚拟化,以及评论为何打破它
首先要理解纯代码 diff 能保持快速背后的几何原理。一旦评论加入进来,这套原理就不够用了。
大 diff 为什么能快
一个页面上不可能塞进一百万个 DOM 节点。标准解法是虚拟化:只渲染屏幕上的行,外加一个小边距,并在用户滚动时复用这些 DOM 元素。列表表现得好像一百万行全在那里,滚动条尺寸正确,跳转到指定行也没问题。但实际同时存在的真实行只有大约 100 行。
要维持这种错觉,必须有人提供几何数据。滚动条的总高度等于所有行高的总和,第 N 行的位置等于其上方所有行高的总和。无论是跳转到某行、绘制滚动条,还是判断当前屏幕上的内容,本质上都是在对一个高度表进行算术运算。你可以先用估值构建这个表,再在各行被实际测量时进行修正,通用的可变高度虚拟化方案正是这么做的。
但如果每一行都是代码,且字号已知,你根本不需要这么做。你可以提前计算好整张表,而且它永远不会变,也就无需后续修正。
我们把这种情形称为“绘制前已知所有高度”契约。我们的 diff 界面正是围绕这一契约构建的:
- 命令式、可复用的代码行渲染器(每行没有 React 组件)
- 使用类型化数组处理偏移量计算
- 后端管理的 diff 文档,优先流式传输结构
- 命令式滚动 API,支持精确的“滚动到第 N 行”
这套方案没有不良的扩展性问题,因为每帧的工作量不随总行数增长。对于纯代码场景,这是正确的设计,我们全部保留了下来。
评论如何改变这一契约
现在,把一条评审线程放进 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
代码几何保持原样。它是确定性的、前缀和化的、精确的,在评论高度变化时也无需重建。
动态块几何覆盖所有高度无法预测的内容,如评审线程、草稿和回复编辑器。每个块由其“是什么”来标识,而非“当前位于何处”。它有一个稳定 key,能在内容加载过程中存活,并锚定到文件、行号与侧边,而非像素坐标,因此重排不会让它丢失跟踪。我们还记录可能改变块高度的所有因素指纹:内容、<details>是否展开、回复编辑器是否激活。同时记录它上次被测量时的宽度,并按桶取整,这样普通的窗口缩放就不会使文档中的每个测量值都失效。
于是每个 block 的实际高度就很简单了:有有效测量值就用测量值,指纹和宽度都还匹配就用缓存值,否则用估算值。这些高度存在单独的索引里,与代码行数据分开,所以调整一条评论的大小不会导致代码布局重建。而且 block 的数量由评论数决定,而不是代码行数。几千个 block 完全没问题,只要首屏渲染时不会一次性挂载或测量全部内容。
测量调度器,以及我们最初犯的错误
这部分花的时间最长,因为最初的设计错得很有「教育意义」。
测量动态内容最直观的做法是给每个 block 配一个 ResizeObserver,监听元素并在高度变化时把测量结果写回布局。我们最初就是这么设计的,但在性能优化阶段放弃了。这正是大型虚拟化页面必须避免的反馈循环:一个 observer 把高度写回它所监听元素的布局后,可能会再次触发自己,而且开销会随着挂载的 block 数量不断增加。
最终上线的是一个由空闲和滚动状态控制的测量流程,与确定性渲染那套遵循同样的纪律:
- 避开热路径。只在可视范围稳定后运行,绝不会在每一帧滚动时执行,滚动进行中则完全暂停——滚动中途 reflow 正是我们要避免的卡顿。滚动停止后会再跑一次。
- 限定在视口附近。只有距视口约 2400px 以内的 block 才会被测量,所以工作量是 O(视口)。远处的 block 继续用估算值,靠近时再修正。
- 屏幕上的读取结果优先。已挂载的 block 就在屏幕上,其渲染高度就是真实值。测量流程会一次性批量读取所有已挂载的候选 block——只做一次 reflow,中间不夹杂任何写入——并记录结果。已挂载的 block 绝不会因为某个过期的估算值而被跳过。正是这条规则修掉了我们遇到的最棘手的 bug:评论渲染后底部留着一截空白,因为该 block 被排除在测量之外,一直卡在一个偏大的估算高度上。
- 离屏测量是一种有界的兜底机制。 对于尚未挂载的临近代码块,这一过程最多执行一次离屏渲染,以便在其滚动进入视野前修正其空间预留。高度超过视口的代码块甚至跳过这一步,因为它们的多预留部分隐藏在折叠线以下,不值得为此付出渲染开销。
- 观察器负责捕获其余情况。 有些高度变化既不会改变指纹,也不伴随滚动发生,例如在回复框中打字、图片加载完成或切换
<details>状态。每个已挂载的代码块都保留了一个ResizeObserver,但默认情况下它仅负责标记该代码块,以便空闲轮次重新读取它。它自身从不写入高度,这正是我们拒绝的那种会导致反馈闭环的情况。代码块卸载时观察器会断开连接,处于非激活状态的拉取请求标签页则不进行任何观察。 - 只有一个例外是有意为之。 对于用户主动触发的尺寸变化,等待显然不合适:展开
<details>、打开回复框、图片落地。此时代码块立即变高,但下方的代码只能在下一个空闲轮次移动。结果会出现一帧的时间差,你明明看到上方内容变高了,下方内容却还停在旧位置,肉眼可见的错位感油然而生。因此,当代码块已挂载且在屏幕内时,观察器现在会在同一帧内、在绘制之前测量并应用修正。代码块变高,代码重新定位,下方所有内容随之同步移动。为防止这变成我们要避免的闭环,设有两道安全防线:每帧最多执行一次同步提交,这样一连串尺寸变化会合并为一次处理;在活跃滚动期间绝不出手,此时回退到批量处理流程。
滚动锚定:修正而不与用户对抗
当测量高度与预估高度不一致时,滚动条的计算逻辑随之改变,直接结果就是视口跳动。解决方案是按身份而非像素进行修正:
- 在应用高度更新前,捕获用户当前锚定的对象(某行或某个代码块,基于其身份)及其内部偏移量。
- 应用高度增量。
- 将该锚定对象解析为其新的像素位置。
- 滚动视图,使该锚定对象在视口中的位置保持不变。
此外还有几条规则,避免修正体验显得怪异:
- 视口上方的代码块高度变化 → 按差值调整(保持你的位置不变)。
- 视口下方正在水合(Hydrating)的内容 → 无需调整(因为用户根本看不到)。
- 如果用户展开了某个可见区块中的
<details>或打开了回复 → 对该区块抑制“上方区块”的位置修正,让交互感觉更直接,同时让下方的内容自然向下流动。 - 绝不干扰活动中的指针或滚轮惯性;将修正操作批量处理到该帧之后执行。
最后这条规则有个锐利的边缘,而且真的咬了我们一口。“不要在用户滚动时进行修正”被实现为基于最后一次观测到滚动的守卫(guard),但程序化滚动也会刷新这个时间戳。切换文件树侧边栏会改变差异面板的宽度。在开启换行显示的情况下,上方每一行折行都会重新流式布局为不同数量的视觉行,整个坐标空间发生偏移,并且表面在稳定过程中会自行发出微小的滚动。守卫将此解读为“用户刚刚滚动”,从而跳过了本应帮你保持阅读位置的修正,导致你正在阅读的文件漂移出屏幕。修复方法是区分用户滚动与表面自身引起的滚动。任何“用户是否在交互?”的检查,都必须确保你自己的副作用无法满足该条件。
因此,修正操作保持微小,复用已有的测量数据,并始终跟随你当前关注的目标。
第二部分:表面背后的流水线
差异表面的速度取决于为其供数的数据速度,来自该工作侧边的三种习惯塑造了 UI 能做到的事。首先是先流式传输结构再传输内容。差异是增量请求的,因此文件树和元数据会在文档仍在加载时绘制,而完整的评论线程集合会提前一次性解析,而非涓涓流入。其次是延迟逐项工作直到需要时才执行。语法高亮在主线程之外运行,因此行首先以纯文本形式立即出现,待结果到达后再上色。高亮改善了表面体验,而不是阻塞滚动。大型 Markdown 正文和建议变更的上下文处理逻辑相同:在接近视口之前,什么都不构建。
第三个习惯关乎哪些成本值得保留。离开页面时释放 diff 文档是正确的默认行为。这些文档很大,把访问过的每一份都留在内存里,长时间使用下来内存就会被吃光。但 pull request 的元数据是持久化的,所以你回到页面时,diff 外层的壳——头部和文件树——能瞬间重绘出来,然后卡在那里好几秒,等着一份片刻之前还是完整状态的 diff。一个瞬间画好却空空如也的 diff 外壳,看起来就像坏了,哪怕总等待时间其实更短。所以释放策略保留了,我们另外加了缓存:把最近几份 diff 常驻内存,超出就淘汰,再让后台刷新机制去发现哪份已经过期。
第三部分:度量闭环,或者说我们究竟是怎么找到那些 bug 的
这个项目里几乎每个 bug 在暴露之前都毫无踪迹,而手动复现一个 bug 简直是折磨。典型的报告长这样:"某些评论下方出现一条空白,但只是偶尔出现,只在大 pull request 上出现,而且滚过去再滚回来就好了。"这种问题靠盯着屏幕是调不出来的,所以我们造了一套工具,用机械化的方式来调试。
用应用的真实信号做插桩,而不是用一次性的日志
最原始的做法是到处插 console.log,手动走一遍流程,复制输出,粘贴给能分析它的人(或工具),删掉日志,再来一遍。这样很慢,中间必须有人盯着,而且最糟的是,你度量到的其实是自己临时拼凑的插桩,而不是应用的真实行为。
所以我们在 surface 里内置了持久化、结构化的探针,用来检验它自身的不变量。它们就是一些简单的问题,每次渲染时 surface 都会自问自答:
- surface 真的绑定在视口范围内吗?当前挂载了多少行和评论块?
- 测量是否合并为每帧一次提交?那一帧耗时多久?
- 我们做的滚动修正幅度有多大?
- 滚动开始后有没有评论块被插入?(在后端拓扑落定之后,必须是零。)
- 每个评论块的 observer 在卸载时真的被清理了吗?还是每个块都在泄漏一个?
这些就是客观的通过/失败信号,我们把它作为预算断言写进了一个端到端测试,测试基于一个合成的、包含大量评论的超大 pull request 固定样本。现在 CI 能直接告诉我们 surface 是否健康。
让循环进入自动驾驶模式
核心是一个自主运行的变更 → 度量 → 改进循环,包含两条轨道:
无头探针轨道运行一条声明式流程(打开 Pull Request、滚动到特定比例、切换 details 块、调整窗口大小),并对接 Mock 服务器,读取应用自身的生产级监控数据:React 渲染次数、性能时间线,以及用于检测卡顿的 requestAnimationFrame 采样器。它自动完成“埋点、驱动、采集、分析、排名”全流程,并按顺序打印瓶颈。由于流程只是运行时传给探针的 JSON,代理(Agent)只需描述即可对任意流程进行性能剖析,无需修改一行源码。
自动驾驶模式无人值守地驱动真实桌面应用,循环执行大型 Pull Request 场景:先冷启动(评论仍为骨架屏),再热启动(评论已加载),切换 <details> 块,打开并取消回复编辑器,折叠并展开文件,切换侧边栏树,深度遍历文件列表,调整窗口大小。每次度量都镜像到应用的磁盘日志中,使代理无需人工干预即可读取运行时行为。每个样本携带健康信号,这是客观的检查标准。热样本仅在满足以下条件时才被视为健康:整个滚动范围(包括深度文件遍历)内,评论之间无未填充的空白,无空白的评论块,且真实的线程内容确实已挂载。
我们运行的循环如下:
- 在真实引擎上无人值守复现。启动自动驾驶模式,让它循环运行,读取磁盘日志。
- 用健康信号检测,而非肉眼观察。信任样本字段。
- 探查可疑接缝。当信号变差时,在该处添加一个窄范围的结构化探针,重新启动并读取结果。(编辑表面会热重载实时窗口并重新武装自动驾驶模式,因此新捕获只需一个循环周期。)
- 移除脚手架。一旦理解不变量,将其固定在测试和设计文档中,仅保留探测器级别的健康信号。
现状总结
过去,审阅一个体量如此庞大的 pull request 往往只有两种选择:要么干等,要么放弃并换个地方阅读。代码审阅不是一份尺寸固定的文档,而是一场在读者阅读过程中不断演变的对话。承载这场对话的界面必须从一开始就为这种动态性而设计,而非事后打补丁。
最终呈现的 pull request 视图可以如此流畅:即便面对百万行级的 diff 和数百条关联评论,它的打开、滚动和交互表现,与普通大小的 pull request 别无二致。评论内容会完整渲染,不再被裁剪进一个可滚动的固定框内。展开一个折叠区域时,只会推动下方的代码下移,其他部分纹丝不动。当你从其他地方返回这个 pull request 时,界面会精确恢复到你上次离开的位置。
如果你的工作就是审阅代码,不妨找一个你清楚知道让人头疼的 pull request 来体验一下差别。打开你手头那个最糟糕的案例。