让百万行 Pull Request 真正打开:超大 Diff 界面的重构思路

2026-09-24 31 预计阅读时间: 1 分钟
来源: github.blog AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

GitHub Copilot 应用重建了 Pull Request 的 diff 展示层,使其能够打开包含百万行变更、并挂载数百条行内评论的超大型 PR。这类问题并不是简单地“让列表滚得更快”:代码行、文件边界、折叠区域、评论线程和审查状态共同组成了一个高度动态的二维界面,任何一次全量渲染都可能耗尽内存或长时间阻塞主线程。

百万行 Diff 为什么会拖垮普通页面

最直接的问题是 DOM 数量。假设每一行代码只生成 3 个节点,百万行就会产生数百万个 DOM 节点;如果再加入行号、语法高亮、评论按钮和讨论线程,节点规模还会继续膨胀。

但 DOM 并不是唯一瓶颈:

  • 解析成本:一次性把完整补丁解析成对象数组,会制造大量短命对象并增加垃圾回收压力。
  • 布局成本:行内评论让行高变得不固定,滚动时浏览器需要不断重新计算布局。
  • 状态成本:折叠文件、展开上下文或新增评论后,后续所有行的垂直位置都可能变化。
  • 交互成本:搜索、文本选择、键盘导航和定位评论不能因为虚拟化而失效。
  • 网络成本:即使浏览器能渲染,等待服务端返回完整 diff 仍可能让首屏迟迟不可用。

因此,超大 diff 的核心目标不是“尽快渲染一百万行”,而是永远只渲染用户当前能看到的一小段内容

把 Diff 当成窗口,而不是完整文档

来源摘要确认了百万行 PR 与数百条行内评论这一目标,但没有披露具体内部数据结构。工程上可以采用下面的分层方式:

  1. 数据层按文件或块分页:保存行号、变更类型、文本位置和评论锚点,不立即创建视图对象。
  2. 索引层计算可见范围:根据滚动位置找到起止行,并在上下增加少量 overscan,避免快速滚动时出现白屏。
  3. 展示层复用有限节点:视口外的行被移除或复用,使 DOM 数量与屏幕高度相关,而不是与 PR 大小相关。
  4. 评论绑定语义坐标:评论应绑定文件、版本、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 才会从“理论上能加载”变成“实际上能审查”。


相关推荐