GitHub重建Copilot应用PR视图,2200 文件百万行diff可流畅滚动
GitHub官方博客介绍,团队用两套几何体系与惰性测量调度器重写拉取请求视图,并用自主循环自动定位性能bug。
AI解读:这条新闻讲的是GitHub怎么让Copilot应用打开超大pull request不卡。他们找了个开源PR来测:2200 个文件、超过一百万行改动、400 多条行内评论。
难点不在代码diff——每行高度固定,可以提前算好、只渲染屏幕上那百来行。真正的麻烦是评论:Markdown换行、展开折叠、回复框、图片加载都会改变高度,渲染前算不出来。
GitHub的解法是把文档高度拆成两个域:代码几何仍然精确前缀和,评论这类动态块单独索引、用估算加惰性测量,窗口调整按宽度分桶避免全部失效。测量只在滚动停止后批量跑,且只覆盖视野附近。
他们还写了一个无人值守的自动循环,在真实引擎上跑超大PR流程,用健康信号而不是肉眼判断bug,再把不变量固化成测试。
对天天review代码的人来说,影响很具体:超长评论完整显示不再被裁进小滚动框,展开折叠只推动下方代码,离开再回来还能停在原地。
GitHub官方博客发布文章,介绍GitHub Copilot应用如何重建拉取请求(pull request)视图,让超大diff与大量评论也能流畅打开、滚动和操作。文章作者为首席设计工程师Alberto Gimeno。
团队用能找到的最大公开开源pull request做验证:2200 个文件、超过一百万行改动、400 多条行内评论。
文章把问题拆成三部分:渲染表面、背后的数据管线、以及用于自动发现bug的测量循环。以下内容依据该博客文章整理。
为什么代码diff好做、评论难做
文章解释,大型diff的常规做法是虚拟化:只挂载屏幕内及少量余量的行,滚动时复用DOM元素,页面看起来有全部一百万行,但同一时刻只有约 100 行是真实存在的。
这套做法依赖一个契约:每一行都是已知字体大小的一行代码,所有高度在绘制前就能算好,滚动条高度、第N行的位置、跳转到某行,都是对一张高度表的算术运算,且这张表不会变。
评论打破了这个契约。一条评论的高度取决于Markdown如何换行、可展开区块、是否含回复输入框、图片是否加载完成,这些只有渲染时才知道,而且首次绘制后还会继续变化。
文章指出,用固定高度加估算器预留位置在大PR上会失效:平均值准确的估算器在两端仍然错,大多数评论被过度预留留下空白,代价高的评论预留不足会被裁切或长出嵌套滚动条。若绘制后测出真实高度再写回共享偏移表,用户正看着的下方内容会整体跳动。
两套几何:代码精确、动态块惰性测量
团队的做法是不再强迫一套几何服务两类内容。文档总高度被拆成:确定性的代码高度(精确、提前已知)加各动态块的有效高度之和(先估算后测量)加滚动留白。
代码几何保持原样:确定性、前缀和、精确,评论尺寸变化时永不重建。动态块几何覆盖评论线程、草稿、回复框等无法预测高度的内容;每个块以“是什么”而非“当前在哪”标识,拥有在内容加载后仍稳定的key,并锚定到文件、行号和侧别,而不是像素坐标。
每个块还会保留一个指纹,记录内容、区块是否展开、回复框是否激活等可能改变高度的因素,并记录最近一次测量的宽度(按桶取整),使普通窗口调整不会让文档中所有测量失效。
块的有效高度取三者之一:有效测量值、指纹与宽度仍匹配时的缓存值、否则用估算值。这些高度存在独立于代码行的索引里,评论改变尺寸不会迫使代码几何重建;块的数量由评论数而非行数决定。
第一个测量设计被否掉了:给每个块配一个ResizeObserver,一旦变化就把高度写回布局。文章称这是大型虚拟化表面必须避免的反馈回路,观察者写入自己所观察元素的布局可能再次触发自身,成本随挂载块数增长。
最终采用单一、受空闲与滚动门控的测量批次:不在热路径上,只在可见范围稳定后运行,滚动进行中完全等待,滚动停止后再跑一次;范围限定在视口约 2400 像素内,远离视口的块继续使用估算值,靠近时再修正。
滚动锚定与一个被自己副作用咬到的bug
测量高度与估算不同会改变滚动条算术,朴素实现会让视口跳动。修正是按身份而非像素:先记录用户锚定的对象(某行或某块)及其内部偏移,应用高度增量,再把同一锚点解析到新的像素位置,并滚动使锚点留在视口中。
文章列出几条避免“手感不对”的规则:视口上方的块改变高度就按增量调整,保持位置;视口下方内容水合则不调整;如果用户刚切换了可见块内的折叠区或打开回复,则对该块抑制上方修正,让交互感觉直接,下方内容自然下流;绝不与进行中的指针或滚轮动量对抗,把修正放到帧后批量处理。
最后一条规则有个尖锐的边界,并且确实咬到了团队。“用户滚动时不要修正”被实现为对最近一次滚动时间戳的守卫,而程序化滚动也会刷新该时间戳。切换文件树侧边栏会改变diff面板宽度,开启换行时上方每个换行行会重排成不同视觉行数,整个坐标空间移动,表面自身在稳定过程中发出一次小滚动,守卫把它读成“用户刚滚动”,于是跳过了本该保持位置的修正,正在读的文件漂出屏幕。
修复办法是把用户滚动与表面自身造成的滚动区分开;文章总结,任何“用户是否在交互”的检查,都必须是自身副作用无法满足的检查。
数据管线:先结构后内容,以及保留最近几个diff
文章列出数据侧影响UI能力的三个习惯。第一,先流式传结构再传内容:diff增量请求,文件树和元数据在文档仍加载时就绘制,全部评审线程提前解析,而不是零星到达。
第二,把逐项工作推迟到需要时:语法高亮在主线程之外运行,行先以纯文本立即出现,结果到达后再着色,高亮是改进表面而非阻塞滚动;大型Markdown正文和建议修改上下文同理,靠近视口前什么都不构建。
第三,关于哪些成本值得保留。离开时释放diff文档是正确默认值,因为文档很大,留住访问过的每一个会让长会话吃掉内存。但PR元数据会保留,因此外壳、头部和文件树返回时能立即重绘,然后空等数秒等待一份片刻前还完整的diff;文章称“瞬间画出的外壳套着空diff看起来像坏了”,于是策略保留并加缓存:保留最近几个diff,超出则淘汰,后台刷新注意到某个变陈旧。
用自主循环自动找bug
文章称这个项目几乎每个bug都是“不出现就看不见”,手工复现很痛苦。典型报告是:某些评论下方出现一条空白,但只是有时,只在大PR上,滚过去再滚回来就自愈。
因此表面内置了持久的结构化探针,每次渲染回答固定问题:表面是否真的受视口约束?当前挂载了多少行和评论块?测量是否合并为每帧一次提交、该帧耗时多少?滚动修正有多大?后端拓扑落地后是否还有评论块在滚动开始后插入?卸载时每个块的观察者是否真的拆除?这些问题作为预算被写进针对合成的大评论量PR夹具的端到端测试。
循环分两条通道:一条无头探针通道在mock服务器上运行声明式流程(打开PR、滚到某个比例、切换details块、调整窗口),读取生产埋点,包括React渲染次数、性能时间线和requestAnimationFrame采样。由于流程只是运行时交给探针的JSON,代理可以用自然语言描述任意流程来做性能剖析,无需改源码。
另一条自动驾驶通道无人值守驱动真实桌面应用跑大PR流程:先是冷启动、评论仍是骨架,再是热状态、评论已加载,切换区块、打开并取消回复框、折叠展开文件、切换侧边栏树、深入文件列表、调整窗口大小。每次测量镜像到磁盘日志,代理无需有人坐在键盘前就能读取运行时行为。
热样本被判定为健康需要满足:整段滚动范围内,评论之间没有未填充空隙,没有留白的评论块,且真实线程内容确实挂载,包括深文件扫描。循环步骤是:在真实引擎上无人复现,读磁盘日志,用健康信号而不是肉眼检测,探查可疑接缝并加窄探针,最后移除脚手架,把不变量钉进测试和设计文档,只保留检测级信号。
结果与限制
文章描述最终体验:一百万行diff加数百条线程评论的PR能打开、滚动,行为像正常大小的PR;评论完整渲染而不被裁进可滚动框;展开折叠区域只移动下方代码、别的不动;刚离开的PR再回来仍在原来的位置。
文章建议以review代码为生的人在已知痛苦的PR上亲自试。文章未给出与旧实现的具体耗时对比数字,也未说明该视图的发布时间或适用平台范围。