GitHub

當 PR 大到無法拆分:GitHub Copilot 如何重新設計 diff 渲染的幾何

GitHub Copilot 用雙幾何架構與延遲測量,讓百萬行 diff 與大量評論也能保持捲動流暢。

當 PR 大到無法拆分:GitHub Copilot 如何重新設計 diff 渲染的幾何 — 文章封面
本頁內容6 個段落
  1. 程式碼行可以虛擬化,評論不行
  2. 把高度拆成兩個獨立幾何域
  3. 測量排程器:一個 ResizeObserver 的教訓
  4. 捲動錨定:用身分修正,不是用像素
  5. 資料管線:結構先於內容
  6. 參考來源

大型重構或遷移往往必須以單一變更落地。Stacked PR 能拆小工作,但有些改動就是拆不開,結果是一個巨大的 pull request,加上審查討論又讓它繼續膨脹。GitHub Copilot app 團隊在重建 PR 檢視時,把「diff 與討論極大時仍要快速流暢」當成硬需求。他們甚至找來一個開源 PR 做壓力測試:2,200 個檔案、超過一百萬行變更、400 多則 inline 評論。

程式碼行可以虛擬化,評論不行

純程式碼的 diff 之所以快,是因為每一行高度固定,可以在繪製前就建立完整的 offset 表。GitHub 的 diff 表面圍繞這個「所有高度在 paint 前已知」的契約設計:命令式、回收的 code-row renderer、typed-array 幾何運算、結構先行的 diff 文件串流。每幀的工作量不會隨總行數成長。

但評論打破了這個契約。評論高度取決於 markdown 換行、可展開的 <details>、回覆輸入框、圖片載入狀態——這些都只在 render 時才知道,而且第一次 paint 後還會繼續變。用固定高度預留槽位,在大型 PR 上會出問題:平均準確的估計在極端值上就是錯的,過度預留造成空白,低估則裁切或冒出巢狀捲軸。事後測量並寫回 offset 表,會讓使用者正在捲動時發生跳躍。

把高度拆成兩個獨立幾何域

團隊的解法是停止用單一幾何服務兩種內容。文件總高度變成:

total height = 確定性程式碼高度(精確、預先已知)
             + Σ 動態區塊有效高度(先估計、後測量)
             + scroll padding

程式碼幾何保持原樣,永遠不會因為評論 resize 而重建。動態區塊幾何涵蓋所有無法預測高度的內容:review threads、drafts、回覆輸入框。每個區塊以「它是什麼」而非「它現在在哪」來識別,有穩定 key,錨定在檔案、行號與 side,而不是像素座標。區塊也記錄了指紋(內容、<details> 是否開啟、composer 是否啟用)與上次測量時的寬度桶,讓一般視窗縮放不會使所有測量失效。

區塊的有效高度很簡單:有有效測量值就用測量值;指紋與寬度仍相符就用快取值;否則用估計值。這些高度存在獨立索引,與程式碼行分開,所以評論 resize 不會強制重建程式碼幾何。區塊數量受限於評論數而非行數,幾千個區塊沒問題,只要首次 paint 不要一次掛載並測量全部。

測量排程器:一個 ResizeObserver 的教訓

團隊最初設計是每個區塊一個 ResizeObserver,觀察元素並把測得高度寫回 layout。這個設計在效能強化階段被否決,因為觀察器寫回自己正在觀察的元素高度,可能形成自我觸發的回饋迴圈,成本隨掛載區塊數成長。

最後出貨的是一個 idle 與 scroll 閘控的單一測量 pass:

  • 離開熱路徑:在可見範圍穩定後執行,捲動進行中完全等待,捲動停止後再跑。
  • 限定 viewport:只有距離 viewport 約 2400px 內的區塊才是候選,工作量是 O(viewport)。
  • 螢幕上的讀取優先:已掛載區塊的渲染高度就是 ground truth,一次批次讀取所有掛載候選,單一 reflow、中間不寫入。這條規則修掉了最棘手的 bug——評論下方出現空白條,因為掛載區塊被過濾出測量,停在過高的估計值上。
  • 螢幕外測量是有界的 fallback:對尚未掛載的鄰近區塊,最多做一次螢幕外 render,在它捲入視窗前修正預留。比 viewport 高的區塊直接跳過。

每個掛載區塊仍保留一個 ResizeObserver,但預設只做標記,讓 idle pass 重新讀取,自己永遠不寫高度。唯一例外是使用者自己觸發的 resize:展開 <details>、開啟回覆輸入框、圖片載入完成。此時觀察器在同一幀內測量並套用修正,避免「評論先變高、下方內容慢一步」的兩段式跳動。兩個防護:每幀最多一次同步 commit,且捲動中絕不執行,退回批次 pass。

捲動錨定:用身分修正,不是用像素

測量值與估計值不同時,捲軸算術會變,天真的結果是 viewport 跳躍。修正方式是按身分而非像素:

  1. 套用高度更新前,先捕捉使用者錨定的對象(某一行或某個區塊,以身分識別)加上內部偏移。
  2. 套用高度差。
  3. 將同一錨點解析到新的像素位置。
  4. 捲動讓錨點留在 viewport 原位。

幾條規則讓它不覺得怪:viewport 上方的區塊變高就按差值調整;下方內容 hydrate 就不調整;使用者自己切換 <details> 或開回覆時,抑制該區塊的上方修正,讓互動感覺直接;絕不與主動指標或滾輪動量對抗,修正延後到幀後批次處理。

最後一條規則有個尖角。團隊原本用「最後觀察到的捲動」做 guard,但程式化捲動也會刷新時間戳。切換檔案樹側欄改變 diff pane 寬度,開啟換行時上方每一行都重新折行,整個座標空間位移,表面自己發出一個小捲動。guard 誤判為「使用者剛捲動」,跳過了本該保持位置的修正,正在閱讀的檔案就漂出螢幕。修法是區分使用者捲動與表面自己造成的捲動——任何「使用者是否在互動」的檢查,都不能被自己的副作用滿足。

資料管線:結構先於內容

diff 表面只能跟餵它的資料一樣快。團隊從管線側學到三個習慣,其中第一個是結構先於內容:diff 以增量方式請求,檔案樹與 metadata 先畫出來,文件還在載入。這讓 UI 能先呈現骨架,而不是等整份 diff 到位。

這與把 Vary 從快取殺手變成可調參數的思維相通:把不可預測的部分隔離成可管理的參數,而不是讓它污染整個系統。在 Copilot 的案例裡,不可預測的是評論高度,解法是讓程式碼幾何保持確定性,動態內容走自己的測量與修正路徑。

對產品建構者來說,這篇文章的價值不在於「虛擬化很厲害」,而在於當內容打破你的幾何契約時,不要硬修估計器,而是承認有兩種幾何,讓它們各自遵守自己的紀律。測量要離開熱路徑、限定範圍、批次讀取;修正要按身分錨定、區分使用者意圖與系統副作用。這些原則適用於任何需要同時處理確定性與動態內容的大型介面。

參考來源

本文由 AI 協助自上述來源整理,經人工審核後發布。

這篇內容對你有幫助嗎?

支持本站繼續整理實用的 AI 文章、教學與開發筆記。

請我喝杯咖啡
分享X電郵