GitHub Copilot 应用重建了 Pull Request 的 diff 展示层,使其能够打开包含百万行变更、并挂载数百条行内评论的超大型 PR。这类问题并不是简单地“让列表滚得更快”:代码行、文件边界、折叠区域、评论线程和审查状态共同组成了一个高度动态的二维界面,任何一次全量渲染都可能耗尽内存或长时间阻塞主线程。
百万行 Diff 为什么会拖垮普通页面
最直接的问题是 DOM 数量。假设每一行代码只生成 3 个节点,百万行就会产生数百万个 DOM 节点;如果再加入行号、语法高亮、评论按钮和讨论线程,节点规模还会继续膨胀。
但 DOM 并不是唯一瓶颈:
- 解析成本:一次性把完整补丁解析成对象数组,会制造大量短命对象并增加垃圾回收压力。
- 布局成本:行内评论让行高变得不固定,滚动时浏览器需要不断重新计算布局。
- 状态成本:折叠文件、展开上下文或新增评论后,后续所有行的垂直位置都可能变化。
- 交互成本:搜索、文本选择、键盘导航和定位评论不能因为虚拟化而失效。
- 网络成本:即使浏览器能渲染,等待服务端返回完整 diff 仍可能让首屏迟迟不可用。
因此,超大 diff 的核心目标不是“尽快渲染一百万行”,而是永远只渲染用户当前能看到的一小段内容。
把 Diff 当成窗口,而不是完整文档
来源摘要确认了百万行 PR 与数百条行内评论这一目标,但没有披露具体内部数据结构。工程上可以采用下面的分层方式:
- 数据层按文件或块分页:保存行号、变更类型、文本位置和评论锚点,不立即创建视图对象。
- 索引层计算可见范围:根据滚动位置找到起止行,并在上下增加少量 overscan,避免快速滚动时出现白屏。
- 展示层复用有限节点:视口外的行被移除或复用,使 DOM 数量与屏幕高度相关,而不是与 PR 大小相关。
- 评论绑定语义坐标:评论应绑定文件、版本、side 和行号等稳定标识,而不是绑定像素位置。
可变行高是最容易被低估的部分。普通代码行可能只有 20 像素,但一条展开的讨论线程可能高达数百像素。生产实现通常需要高度缓存、前缀和索引、分段树或 Fenwick Tree 一类结构,以便快速回答两个问题:某一行从哪里开始,以及某个滚动偏移对应哪一行。
同时,数据获取和渲染需要解耦。用户打开 PR 时,可以先加载文件目录、统计信息和首个可见块;滚动接近边界时再预取后续块。正在进行的解析或请求还应支持取消,避免用户切换文件后旧任务继续占用 CPU。
一个可运行的百万行虚拟 Diff
下面是一个独立的实验页面。它不调用 GitHub API,而是按需生成一百万个逻辑代码行,并每 4000 行插入一条模拟评论。页面实际只创建视口附近的 DOM 节点。
将以下内容保存为 index.html:
<!doctype html>
<meta charset='utf-8'>
<title>Million-line virtual diff</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 13px ui-monospace, monospace; }
#viewport { position: relative; height: 100vh; overflow: auto; background: #0d1117; color: #c9d1d9; }
#spacer { width: 1px; opacity: 0; }
#layer { position: absolute; inset: 0 0 auto 0; }
.row { position: absolute; left: 0; right: 0; overflow: hidden; border-bottom: 1px solid #21262d; }
.line { height: 20px; line-height: 20px; white-space: pre; }
.number { display: inline-block; width: 90px; padding-right: 12px; color: #6e7681; text-align: right; user-select: none; }
.comment { height: 36px; padding: 8px 12px 8px 102px; color: #e3b341; background: #161b22; }
</style>
<div id='viewport'>
<div id='spacer'></div>
<div id='layer'></div>
</div>
<script>
const LINES = 1_000_000;
const BASE_HEIGHT = 20;
const COMMENT_HEIGHT = 36;
const COMMENT_EVERY = 4_000;
const OVERSCAN = 25;
const viewport = document.querySelector('#viewport');
const spacer = document.querySelector('#spacer');
const layer = document.querySelector('#layer');
function hasComment(index) {
return (index + 1) % COMMENT_EVERY === 0;
}
function rowHeight(index) {
return BASE_HEIGHT + (hasComment(index) ? COMMENT_HEIGHT : 0);
}
function offsetOf(index) {
const commentsBefore = Math.floor(index / COMMENT_EVERY);
return index * BASE_HEIGHT + commentsBefore * COMMENT_HEIGHT;
}
function lineAt(offset) {
let low = 0;
let high = LINES;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (offsetOf(middle + 1) <= offset) low = middle + 1;
else high = middle;
}
return Math.min(low, LINES - 1);
}
spacer.style.height = `${offsetOf(LINES)}px`;
let scheduled = false;
function render() {
scheduled = false;
const firstVisible = lineAt(viewport.scrollTop);
const lastVisible = lineAt(viewport.scrollTop + viewport.clientHeight);
const first = Math.max(0, firstVisible - OVERSCAN);
const last = Math.min(LINES - 1, lastVisible + OVERSCAN);
const fragment = document.createDocumentFragment();
for (let index = first; index <= last; index++) {
const row = document.createElement('div');
row.className = 'row';
row.style.top = `${offsetOf(index)}px`;
row.style.height = `${rowHeight(index)}px`;
const line = document.createElement('div');
line.className = 'line';
const number = document.createElement('span');
number.className = 'number';
number.textContent = index + 1;
const code = document.createElement('span');
code.textContent = `+ const value_${index + 1} = ${index + 1};`;
line.append(number, code);
row.append(line);
if (hasComment(index)) {
const comment = document.createElement('div');
comment.className = 'comment';
comment.textContent = `Review comment anchored to line ${index + 1}`;
row.append(comment);
}
fragment.append(row);
}
layer.replaceChildren(fragment);
}
viewport.addEventListener('scroll', () => {
if (!scheduled) {
scheduled = true;
requestAnimationFrame(render);
}
}, { passive: true });
window.addEventListener('resize', render);
render();
</script>
在文件所在目录启动静态服务器:
python3 -m http.server 8000
然后访问 http://localhost:8000。打开开发者工具可以看到:逻辑上存在一百万行,但 DOM 中通常只有几十到一百多个 .row 节点。
这个示例假设评论高度固定,因此能用公式计算偏移。真实产品中的评论高度会随文本、回复和窗口宽度变化,需要在元素完成布局后测量实际高度,并更新高度索引。对于不可信代码和评论,也应继续使用 textContent 或经过验证的转义流程,不能直接拼接到 innerHTML。
上线前要守住的边界
虚拟化解决的是渲染规模,不会自动解决所有审查体验。落地时可以检查以下事项:
- 用超大文件、数千个小文件和评论密集型 PR 分别压测,避免只优化一种形态。
- 记录首个可见 diff 的时间、滚动期间长任务数量、峰值内存和可见 DOM 节点数。
- 测试文件开头、文件末尾、折叠区展开以及评论创建后的定位稳定性。
- 为请求、解析任务和语法高亮加入取消机制与时间预算。
- 验证键盘导航、屏幕阅读器、浏览器内搜索和文本复制是否仍然可用。
- 注意浏览器最大滚动高度;超过安全范围后,应采用分段滚动或重新映射滚动坐标。
百万行 PR 本身通常意味着变更需要进一步拆分,但客户端仍不能因为输入异常庞大而失去响应。可靠的 diff surface 应把数据规模、视觉规模和交互状态分开管理:后端分块交付,索引结构负责定位,前端只绘制当前窗口。这样,极端 PR 才会从“理论上能加载”变成“实际上能审查”。