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