GitHub博客详解Copilot应用重构diff渲染引擎,以处理含数百条评论的百万行PR,涵盖虚拟化渲染和增量加载技术细节。
大型重构和迁移往往不得不作为一次变更落地。
堆叠式 Pull Request 是将工作拆分为更小变更的好方法,它让 Code Review 更容易,也能帮助团队以更低风险交付。但有些变更(就像这一个)无法干净地拆分。这就导致你得到一个可能非常大的单一 Pull Request,而 Review 对话还会让它继续膨胀。
即使差异和对话都非常庞大,Review 体验仍需保持快速流畅。在 GitHub Copilot 应用中,我们围绕这一需求重建了 Pull Request 视图。
为了验证能做到什么程度,我们找到了能找到的最大 Pull Request:一个开源项目,包含 2,200 个文件、超过 100 万行变更,以及 400 多条行内 Review 评论。以下是我们如何让这个极端的 Pull Request 保持高性能的。
高速渲染大差异(diff)的方法已经很好理解了:虚拟化行数,保持已挂载的 DOM 节点精简,并利用每一行都是已知高度的代码行这一事实。
评论才是困难的部分。评论的高度取决于其 Markdown 如何换行、可展开区域、是否存在回复框,以及图片是否已加载。这些信息只有在渲染时才能确定。这迫使我们采用不同的架构。
测量。 在渲染之前无法确定评论的高度。这打破了让大差异在滚动时保持响应的设计。
数据管道。 如果供给快速差异界面的数据管道停滞,或者丢弃了已完成的工作,那么快速就毫无价值。
我们如何真正定位这些 bug。 这些问题在负载下、特定引擎上、特定滚动位置才会浮现。所以我们定义了什么是健康状态,为界面添加了检测手段来回答这个问题,然后无人值守地运行整个变更 → 测量 → 改进循环。
第一步是理解使纯代码差异快速的几何结构。一旦评论加入,这个几何结构就不够了。
你无法在页面上放置一百万个 DOM 节点。标准解决方案是虚拟化:只挂载屏幕上可见的行,加上一个小边距,并在用户滚动时回收这些相同的 DOM 元素。列表表现得好像所有百万行都存在。滚动条大小正确,滚动到行功能有效。但实际上一次只有约 100 行是真实存在的。
为了让这个幻象成立,必须有人提供几何结构。滚动条高度是所有行高度的总和。第 N 行的位置是它上面所有行高度的总和。跳转到某一行、绘制滚动条、判断屏幕上有什么——都是对高度表的算术运算。你可以从估算值构建这个表,然后在行被测量时进行修正,通用变高虚拟化器正是这样做的。
但如果每一行都是已知字体大小的代码行,你就不需要这样。你可以从一开始就计算整个表,之后永不改变,所以之后不需要修正任何东西。
将此称为"绘制前已知所有高度"契约。我们的差异界面围绕它构建:
这一切都不会随着行总数增长而变差,因为每帧工作不会随总行数增长。在纯代码上这个设计是正确的,我们保留了所有这些。
现在在差异中间放一个 Review 线程。它有多高?
你不知道,不渲染就无法知道。它的高度取决于只在渲染时存在的东西,而且它们在首次绘制后还会继续变化:
<details> 块显而易见的解决方案是为每条评论预留一个固定高度的槽位,用估算器确定大小。但这在大 Pull Request 上会崩溃。平均正确的估算器在极端情况下仍然是错的。它对大多数评论过度预留,留下空白间隙,而对高成本的评论预留不足,导致内容被截断或出现嵌套滚动条。如果在绘制后测量真实高度并写回共享偏移表,下面的一切都会移动,而用户已经在滚动了。这是一个滚动跳跃,而在大 Pull Request 上这是一个很大的跳跃。
所以评论需要不同的契约。"绘制前已知所有高度"对这个内容是不可实现的。我们可以承诺的是:高度有界,延迟测量,修正量小且锚定在用户正在查看的内容上。
让这个变得可处理的想法是停止强迫一种几何结构同时服务两种内容。我们将文档高度分为两个独立的域:
total height = deterministic code height (exact, known up front)
+ Σ dynamic block effective heights (estimated, then measured)
+ scroll padding
代码几何保持原来的世界。它是确定性的、前缀和的、精确的,在评论调整大小时从不重建。
动态块几何覆盖所有高度不可预测的内容,比如 Review 线程、草稿和回复编辑器。每一项都是一个块,通过它是什么来标识,而不是它当前在哪里。它有一个在其内容加载后仍能保持的稳定键,并且锚定在文件、行和侧边,而不是像素坐标,这样重排就不会丢失它。我们还保留一个指纹,记录所有可能改变块高度的东西:它的内容、<details> 是否打开、编辑器是否活跃。我们还记录它上次测量的宽度,四舍五入到桶中,这样普通的窗口大小调整不会使文档中的每个测量都失效。
一个块的有效高度计算很简单:如果有有效的高度就用测量的高度,如果指纹和宽度仍然匹配就用缓存的高度,否则用估算值。这些高度存在于它们自己的索引中,与代码行分开,所以调整大小的评论永远不会强制重建代码几何。块的数量由评论数量决定,而不是行数。几千个块没问题,只要首次绘制从不同时挂载或测量所有块。
这部分花了最长时间才正确,因为我们的第一个设计以一种有教育意义的方式错了。
测量动态内容最直接的方式是每个块一个 ResizeObserver,监视元素并在高度变化时将测量到的高度写回布局。我们这样设计了,然后在性能优化期间拒绝了它。这是一个大虚拟化表面必须避免的反馈循环。向它监视的元素布局写入高度的观察者可以重新触发自己,而成本随着每个挂载的块增长。
实际发布的是一个单一的空闲和滚动门控测量通道,与确定性端相同的约束:
远离热路径。 它在可见范围稳定时运行,每滚动帧从不运行一次,在滚动进行中完全等待。滚动中发生重排正是我们要避免的卡顿。滚动停止后它会再次运行。
限定在视口内。 只有在视口大约 2400px 内的块才作为候选,工作量是 O(viewport)。远处的块继续使用它们的估算值,并在接近时得到修正。
屏上读取优先。 已挂载的块在屏幕上,所以它的渲染高度是基本事实。该通道批量读取每个挂载的候选块,在单次重排中完成,中间没有写入,并记录发现的内容。已挂载的块永远不会因为陈旧估算而被跳过。这条规则修复了我们遇到的最棘手的 bug:评论渲染后底部有一条空白空间,因为挂载的块被排除在测量之外,一直停留在过高的估算值上。
屏幕外测量是有界的回退。 对于尚未挂载的附近块,该通道最多执行一次屏幕外渲染,以在滚动到视口前修正其预留空间。超过视口高度的块甚至跳过这一步。它们的过度预留隐藏在折叠下方,所以不值得付出渲染代价。
观察者捕获剩余部分。一些高度变化不会移动指纹,也不会与滚动重合:回复撰写框中输入、图片加载完成、切换 <details>。每个挂载的块都保留一个 ResizeObserver,但默认情况下它只标记该块,以便空闲时重新读取它的高度。它本身从不写入高度——这正是我们拒绝的那种闭合反馈循环的做法。它在卸载时断开连接,非活跃的 pull request 分支不观察任何内容。
有一个故意的例外。对于自身引起的大小调整,等待效果明显不对:展开 <details>、打开回复撰写框、图片落地。块立即变大,但下方的代码只在下一个空闲时才会移动。有一帧中评论变高了,而其下方的所有内容仍停留在原来的位置,你能看出这两个步骤。因此当一个块被挂载且在屏幕上时,观察者现在会在同一帧中测量它并应用修正,在 paint 之前。块增长,代码重新定位,下方的所有内容一起移动。有两个保护措施防止它变成我们一直在避免的那种循环:每帧最多一次同步提交,因此一系列大小调整会合并为一次;在滚动过程中永不如此,它会退回批处理阶段。
滚动锚定:在不与用户对抗的情况下修正
当测量的高度与估计值不同时,滚动条计算会改变,天真的结果就是视口跳动。修复方法是通过身份而非像素来修正:
在应用高度更新之前,捕获用户所锚定的东西(按身份,即某一行或某个块),以及它内部的偏移量。
应用高度增量。
将该锚点解析到其新的像素位置。
滚动使锚点在视口中保持不动。
再加上一些规则防止感觉不对:
视口上方的块高度变化 → 按增量调整(保持你的位置)。
视口下方内容 hydrate → 不调整(你看不到它)。
如果在可见块中切换了 <details> 或打开了回复撰写框 → 抑制该块的上方块修正,让交互感觉直接,让下方内容自然流下。
绝不对抗活跃的指针或滚轮动量;在该帧之后批处理修正。
最后一条规则有一个尖锐的边缘,它咬了我们一口。"不要在用户滚动时修正"被实现为对最后一次观察到的滚动的守卫,程序化滚动也会刷新该时间戳。切换文件树侧边栏会改变 diff 面板的宽度。在启用行换行的情况下,你上方的每个换行行都会重新流到不同数量的可视行,整个坐标空间移动,页面在稳定下来时会发出自己的一个小滚动。守卫读取到"用户刚刚滚动"并跳过了本应保持你位置的那个修正,所以你正在阅读的文件漂移出了屏幕。修复方法是将用户滚动与页面自身引起的滚动区分开来。任何"用户是否在交互?"的检查都必须是你自己的副作用无法满足的。
因此修正保持小规模,复用我们已经拥有的测量值,并跟随你所看的内容。
Part 2: 表层背后的管线
diff 表面只能和数据供给它的一样快,工作那侧的三个习惯塑造了 UI 能做什么。第一个是流结构先于内容。diff 是增量请求的,因此文件树和元数据在文档仍在加载时就绘制了,完整的评论线程集是在开始时就解析好的,而不是逐步流入。第二个是推迟每个条目的工作直到需要它。语法高亮在主线程外运行,所以行立即以纯文本出现,收到结果时才着色。高亮改善了表层而不是阻塞滚动。大型 markdown 正文和建议更改上下文的工作方式相同:在接近视口之前什么都不构建。
第三个习惯是关于哪些成本值得保留。导航离开时释放 diff 文档是正确的默认值。这些文档很大,保留你访问过的每一个是长会话最终会消耗内存的方式。但 pull request 元数据是持久化的,所以 diff 周围的 shell(头部和文件树)在你返回时会立即重绘,然后在几秒钟内坐在那里等待一个片刻前它还拥有完整内容的 diff。一个空 diff 周围立即绘制的 shell 看起来是坏的,即使你总体上等待的时间更少。因此策略保持不变,我们添加了缓存:保留最后几个 diff,任何超出此范围的都驱逐,让后台刷新在某个变得陈旧时通知。
Part 3: 测量循环,或者我们实际上如何找到 bug
这个项目中几乎每个 bug 在变得可见之前都是不可见的,而手动重现一个是非常痛苦的。典型的报告读起来像:"某些评论下方出现一条空白,但只是有时,只在大 pull request 上,如果你滚动过去再滚回来它就会自愈。"你不能通过盯着屏幕来调试它,所以我们构建了工具来机械地调试它。
用应用的实际信号而非一次性日志来检测
天真的工作流是散布 console.log 调用,手动执行流程,复制输出,粘贴给能分析它的人(或东西),删除日志,然后重复。它很慢,需要人在循环中,最糟糕的是你最终测量的是你自己手写的检测工具而不是应用的实际行为。
因此表层携带了永久的、结构化的探针来检测它自己的不变量。它们是它在每次渲染时回答的关于自身的问题:
表层实际上是视口绑定的吗?现在挂载了多少行和评论块?
测量是否合并到每帧一次提交,那一帧需要多长时间?
我们所做的滚动修正有多大?
是否有任何评论块在滚动开始后被插入?(后端拓扑落地后必须为零。)
每个块的观察者是否在卸载时实际被清理了,还是我们每个块都在泄漏一个?
这些是客观的通过/失败信号,它们在针对合成的大评论量大 pull request fixture 的端到端测试中被断言为预算。CI 现在可以告诉我们表层是否健康。
让循环自动驾驶
核心是一个自主的 change → measure → improve 循环。两条车道:
一条无头探针车道针对模拟服务器运行声明式流程(打开一个 pull request,滚动到某个比例,切换一个 details 块,调整窗口大小),读取应用自己的生产检测数据:React 渲染计数、性能时间线,以及 requestAnimationFrame 的卡顿采样器。它自己完成整个检测、驱动、收集、分析、排名循环,并按顺序打印瓶颈。因为流程只是在运行时交给探针的 JSON,所以 agent 可以通过用纯英文描述它来对任何流程进行性能分析,而无需编辑一行源代码。
自动驾驶仪在循环中无人值守地驱动实际桌面应用通过大 pull request 流程:首先冷启动,评论仍是骨架,然后热启动,评论已加载,切换 <details> 块,打开和取消回复撰写框,折叠和展开文件,切换侧边栏树,深层扫描文件列表,调整窗口大小。每次测量都被镜像到应用的磁盘日志,这样 agent 可以读取运行时行为而无需有人在键盘前。每个样本都带有健康信号,那就是客观检查。热样本只有在整个滚动范围内(包括深层文件扫描)评论之间没有未填充的间隙、没有评论块留空、真正的线程内容实际挂载时,才算健康。
无人值守地重现,在真实引擎上。启动自动驾驶仪,让它循环,读取磁盘日志。
用健康信号而非眼睛检测。相信样本字段。
探查可疑接缝。当信号变坏时,在那里添加一个狭窄的结构化探针,重新启动,重新读取。(热重载编辑表层会重新激活实时窗口并重新启动自动驾驶仪,所以新的捕获大约一个周期后就能得到。)
移除脚手架。一旦你理解了不变量,就在测试和设计文档中固定它,只保留检测级别的信号。
审查这么大的 pull request 以前意味着两件事之一:等待,或者放弃并在别处阅读。审查不是具有已知尺寸的文档。它是一场在你阅读时不断变化形状的对话,底层的表层必须从一开始就为此构建,而不是事后修补进去。
最终成果是一个 Pull Request 视图,面对包含上百万行差异和数百条嵌套评论的 PR,它的打开、滚动和行为都如同处理一个正常大小的 Pull Request。评论完整渲染而不是截断进滚动框。展开一个折叠区域只会移动其下方的代码,不会影响其他内容。回到一个你刚离开的 Pull Request 会恢复到之前的浏览位置。
如果你靠评审代码为生,值得在一个你已经知道很痛苦的 Pull Request 上感受一下差异。打开你最头疼的那一个。
试用 GitHub Copilot 应用 >
Principal Design Engineer
是否应该阅读代码,RAG 已死吗,Skills 是否杀死了 MCP?
在最新一期的 GitHub Podcast 中,我们深入探讨这些问题以及其他 AI 热门观点。
将 GitHub Copilot 运行时迁移到 Rust,使用 Copilot
在 AI 智能体出现之前,这种规模的重写是不可承受的。以下是将 Copilot 智能体运行时移植到 80 万行生产级 Rust 代码的实际过程。
作为代码的营销运营:从规划到后续跟踪的 GitHub 活动自动化
如果你能把工作方式写下来,你就能把它自动化。以下是我为支持 GitHub 亚太地区营销团队所做的工作。