vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

2,200 個檔案、超過一百萬行改動、400 多則審查留言——GitHub 拿現實中最病態的一個 PR 當壓力測試案例。結論卻是,程式碼列的虛擬化一點都不難,難的是審查留言區塊,因為你不知道它有多高,渲染完之後,高度還可能繼續變。

百萬行 diff 怎麼捲得動

同樣一套虛擬化技法,GitHub 把它搬進 Copilot App 的 diff 檢視器,套在程式碼列上很好用,套在審查留言區塊上就卡住,因為兩者的高度屬性完全不一樣。這篇文章拆的是背後那組設計決定:量測怎麼分工、修正什麼時候套用、捲動中能不能碰版面。

程式碼列好虛擬化,留言區塊不好虛擬化

虛擬化要解決的問題很直接:你不能把一百萬個 DOM 節點塞進一個頁面。標準做法是只掛載畫面上看得到的列,加一點緩衝範圍,捲出畫面的那個 DOM 元素換上新內容繼續用,省下再建立一次元素的成本。GitHub 拿來驗證這套做法的案例,是一個開源專案的 PR——2,200 個檔案、超過一百萬行改動,還有 400 多則審查留言。推測起來,選一個現實中已經存在的病態案例當基準,量出來的效能數字才會對應使用者真的會遇到的量級。可以想見,400 多則留言,每一則都需要單獨追蹤自己的狀態,加總起來的管理成本,比表面上「才 400 個」聽起來還要沉重。

程式碼列不難虛擬化,是因為它的高度早就確定:同一種字體大小的一行字,高度固定,整張表格的高度一次算完,之後不會變。把一個審查留言串塞進 diff 中間,它有多高?不渲染出來就不會知道。更麻煩的是,這個高度是渲染當下才存在的東西,渲染完之後還可能繼續變化。程式碼列跟留言區塊,表面上都是「畫面上的一列」。一個是常數,一個是隨時可能改變的變數。虛擬化列表原本假設每一列高度可預期,這個假設在留言區塊上直接失效。這也是整篇文章最反直覺的地方——照理說,留言區塊數量遠比程式碼行數少,應該是比較簡單的那一半;結果真正難處理的,反而是數量少很多的那一邊。如果照搬程式碼列的做法,把每個留言區塊都當成固定高度處理,量到的位置會跟畫面上實際看到的內容對不上——捲動的座標系統,從一開始就得為這兩種列分開設計。

兩套獨立的高度系統

GitHub 的做法,是把整份文件的高度拆成兩個互不干涉的系統來算。公式是:文件的總高度,等於確定的程式碼高度(精確,一開始就算好),加上所有動態區塊的有效高度總和(先估計、再量測),再加上捲動用的 padding。把所有動態區塊的高度加總,代表增加或移除任何一個留言串,都只影響這個加總的結果,不用動到公式的其他部分。padding 不屬於程式碼,也不屬於任何一個留言區塊,是設計上刻意保留的彈性。「程式碼幾何」維持原本那套世界:決定性、用 prefix sum 算好、精確,留言區塊改變高度也不需要重算。「動態區塊幾何」管的是所有沒辦法預先知道高度的東西,包括審查留言串、草稿,還有回覆輸入框。程式碼幾何一次算完、不再重算,這件事在破百萬行的規模下特別關鍵——如果每次留言變高度都要牽連重新掃過整份 prefix sum,靠近一百萬行的表格,重算一次的代價就不小。

每個動態區塊的「有效高度」怎麼取:有量測過的高度就用量測過的;沒有的話,如果 fingerprint 跟寬度都還吻合,就用快取住的高度;兩者都沒有,才退回估計值。使用者如果編輯了留言內容,fingerprint 會跟著變;寬度記錄的是量測當下、捲進桶裡的一個值,一般的視窗縮放不會讓這個值跟著動,也就不會讓整份文件的量測結果一次失效——fingerprint 或寬度桶其中一個對不上,快取才會失效。把程式碼那一半永遠當常數處理,重算的成本永遠只落在動態區塊那一半身上;這是兩套系統分開算的理由。這個 fingerprint 加寬度的判斷,等於幫每個區塊的快取高度加了一個過期條件:內容沒變、版面寬度也沒變,快取的高度才算數;只要有一邊變了,就得回頭重新估計或重新量測。如果程式碼跟留言的高度混在同一份資料結構裡,留言區塊一變,連帶要重新檢查的範圍可能牽連到程式碼那一半;拆成兩個系統,直接切斷這種牽連。

動態區塊幾何estimated → measured → cached
程式碼幾何deterministic · prefix-summed · exact
原文形容程式碼幾何「deterministic, prefix-summed, exact, never rebuilt when a comment resizes」;動態區塊幾何「covers everything whose height we can't predict, such as review threads, drafts, and reply composers」。兩層各自獨立計算,加總才是整份文件的高度。

被砍掉的 per-block ResizeObserver

量測動態區塊高度最直覺的做法,是每個區塊各自掛一個 ResizeObserver,量到新高度就寫回版面。這是他們一開始設計、後來在效能硬化階段整個推翻的做法。問題出在:一個 observer 如果把量到的高度寫回自己正在觀察的元素,就可能觸發自己重新觸發一次;掛載的區塊越多,成本就跟著往上疊——最壞情況下,如果 400 多則審查留言全部掛載在畫面上,就是 400 多個各自獨立、疊加在同一個分頁裡的觀察器;但成本只跟當下掛載的區塊數有關,虛擬化通常只掛載看得到的那一小段。任何一個觸發連鎖反應,都可能在使用者完全沒有操作的情況下,悄悄佔用主執行緒的時間。

最後上線的做法,是一個受相同紀律約束的單一量測 pass(原文稱為 idle- 與 scroll-gated):只在可視範圍穩定下來時跑一次,不是每個捲動 frame 都跑,捲動進行中完全不跑。這個 pass 也不會去量整份文件裡的每個區塊,候選範圍鎖在視窗附近大約 2400px 以內,工作量跟視窗大小成正比——原文直接寫成 O(viewport),不跟檔案數量或留言數量成正比。「穩定下來」指的是使用者停止捲動、可視範圍不再變動的那一刻。GitHub 把量測工作推到這個時間點:使用者捲動當下,主執行緒不用做量測這種額外工作,畫面才能維持流暢。以這次的測試案例來說,400 多個留言分散在 2,200 個檔案裡,任何時刻捲動視窗只看得到其中一小段——量測只鎖住這一小段,跟另外那幾百個留言完全無關。

拖曳滑桿改變掛載的留言區塊數量 · 比較兩種量測方式的工作量

20

per-block ResizeObserver

20

每個區塊各掛一個 observer,數量隨掛載的區塊數一路往上疊,寫回版面的動作還可能觸發自己重新觸發。

idle / scroll-gated 單一 pass

O(viewport)

只處理視窗附近約 2400px 內的候選區塊,工作量跟視窗大小成正比,不隨 N 繼續增加。

左側數字等於滑桿的 N;右側「O(viewport)」是原文的原話——不管 N 拉到多大,單一量測 pass 的候選範圍都只跟視窗大小有關。

原文沒有交代這套機制在邊界情況下會怎樣——比如估計值偏差大到需要好幾個 frame 才修正完,或使用者忽然快速跳轉捲動,讓候選窗口以外的區塊瞬間進入畫面。這塊,原文沒有給答案。

捲動中不能讓畫面跳動

量到的高度跟原本估計的不一樣時,套用這個修正,理論上會讓這個區塊底下的所有內容跟著往上或往下位移。原文給的解法,是先記住使用者當下錨定在哪一列或哪一個區塊——用身分而不是座標記住,再記下在那個元素裡的偏移量。等高度差套用完,再把同一個錨點解回它新的像素位置,最後捲動畫面,讓這個錨點留在使用者原本看到的地方。這個順序不能顛倒:先記住錨點,再套用高度差,最後才捲動;順序一顛倒,錨點記住的就是修正後的錯誤位置。記的是第幾個像素,高度一旦套用修正,那個像素早就代表別的內容;記身分,要找的就一直是同一個元素。偏移量記的是使用者在那個元素內部看到的位置,不只是元素本身要對齊,連使用者盯著的那一行字都要對齊。

沒有這套錨定順序會怎樣?合理的推測是,畫面會直接位移整段高度差——下面這個工具示範的就是這個對比,兩條線是根據上面那套機制推導出來的示意,不是原文給的實測數字。

拖曳滑桿改變估計誤差 · 比較有無錨定時畫面跳動的距離

60px
估計誤差 N(px) 畫面跳動距離(px) 沒有 scroll anchoring 有 scroll anchoring

N = 60px 時,沒有錨定畫面會跳 60px;有錨定畫面跳動 0px。

此圖依原文描述的錨定機制(先記住錨點身分與偏移量、套用高度差、把錨點解回新位置、再捲動畫面)畫成的示意——原文沒有給實際跳動距離的量測數字,兩條線是機制推導出來的示意,不是原文的實測值。

錨定解決的是修正生效後畫面該停在哪,但修正本身什麼時候套用,也有自己的紀律。已經掛載、且在畫面上的區塊,量到新高度時,會在同一個 frame、繪製前就地套用修正。這個做法不會退化回一開始被推翻的那個迴圈,靠兩個安全閥守住:每個 frame 最多套用一次同步 commit,一連串的 resize 收斂成一次;捲動進行中絕對不做,退回批次的那條 pass 處理。打個比方,一次展開好幾則折疊的留言,就是一串連續 resize 同時發生的例子——安全閥把同一個 frame 裡的這些動作收在一次 commit 裡處理,這一個 frame 因此最多卡一次,不會在同一幀裡連續疊加成看得出來的頓挫。除錯畫面跳動時,要先分清楚問題出在「修正的時機不對」還是「錨點記錯東西」——兩種症狀外觀很像,畫面都是無預警跳一下,但要修的地方不同。

漸進載入的 diff 與留著不放的 PR metadata

高度系統管的是畫面怎麼捲,diff 本身怎麼進來是另一層問題。diff 是漸進式請求的,檔案樹跟 metadata 在整份文件都還沒載入完就先畫出來,所有的審查留言串一次解析完,不會慢慢一則一則跑出來。

離開一份 diff 就把它整個釋放掉,是對的預設行為——但 PR 的 metadata 會留著,回到這個分頁時,外殼(header、檔案樹)可以馬上打開。後來團隊在這個預設上加了一層快取:留著最近瀏覽過的幾份 diff 常駐,超過的就淘汰,背景刷新也會自己發現哪一份已經過期。可以這樣理解:2,200 個檔案的檔案樹,如果每次回到分頁都要重新抓一次,光是這份清單本身就不是一個小請求,metadata 常駐省下的就是這次重抓。

誰留著、誰被放走——切分頁時的快取行為

誰留著、誰被放走——切分頁時的快取行為 現在看的 diff 常駐 上一份 diff 常駐 更早的一份 視容量而定 PR metadata 常駐 點一個方塊看說明

正在檢視的文件,理所當然留在記憶體裡,不會被這條規則影響。

原文寫道,「keep the last few diffs resident」——最近瀏覽過的幾份會留著常駐。

原文寫道,「evict anything beyond that, and let the background refresh notice when one has gone stale」——超過留著的份數上限就會被淘汰,背景刷新會自己發現哪一份已經過期,原文沒有寫死留幾份。

原文寫道,「pull request metadata persists, so the shell around the diff, the header and the file tree, repaints instantly when you go back」——metadata 會留著。

切換 PR 分頁時,diff 文件走「留著最近幾份、超過淘汰」的規則;PR 的 header 與檔案樹是另一層,會留著。

用一支無人值守的探針找瓶頸

怎麼找出、驗證這整套系統,原文也給了具體做法。一條 headless probe 通道,對著一個 mock server,跑一段固定的流程——打開一個 PR、捲到某個比例、展開一個 detail 區塊、調整視窗大小。這裡的解讀是:其中三個動作剛好對應前面提到的機制——捲動觸發量測 pass,展開 detail 區塊觸發同步修正,視窗改變觸發重新排版。另外還有一支 autopilot,在真正的桌面 App 上無人值守地反覆跑同一段流程。這支 autopilot 分兩階段跑:一開始留言還是骨架的冷啟動狀態,之後留言全部載入完的熱狀態都跑一輪,中途還會開合 detail 區塊、開關留言回覆框、收合展開檔案、切換側邊欄,把各種操作都覆蓋到。

headless probe 自己跑完整套 instrument、drive、collect、analyze、rank 的循環,直接印出排好序的瓶頸清單,不用人盯著錄影檔一格一格找。開發迴圈總結成四句話:在真引擎上無人值守地重現,用一個健康訊號而不是肉眼去偵測,鎖定可疑的接縫深入探測,最後把鷹架程式碼拆掉。判斷標準能被自動化重複執行——同一段流程換一台機器、換一次版本,都能用同一個訊號比對前後有沒有變差;跟人工盯著錄影檔相比,一個訊號能同時比對好幾輪測試的結果,人眼很難做到這種一致的比較。排序這個動作本身也有意義:瓶頸不會只有一個,排好序讓工程師先處理影響最大的那一個。

按播放看四個步驟怎麼一輪接一輪跑 · 4 個階段

四個階段直接對應原文,「Reproduce unattended, on the real engine. Detect with a health signal, not with your eyes. Probe the suspect seam. Remove the scaffolding.」

四個階段直接對應原文,「Reproduce unattended, on the real engine. Detec…

開發迴圈是重現、偵測、探測、拆鷹架四步驟不斷循環,重現不用人盯著看,偵測靠健康訊號,鷹架修完就拆掉,不留在正式版裡。

這套設計對任何一個要做「可捲動、列高不固定」清單的前端工程師,都可以直接抽出幾條原則來用。先分清楚哪些列的高度真的算得出來、哪些只能先估,不要把兩種列混在同一套邏輯裡量測;量測動態內容時,先問一次全部量測夠不夠,不要預設每個元素都得各自掛一個 observer;捲動進行中禁止任何會改變版面的同步修正,把它們留到閒置或捲動停下之後;修正套用前後,永遠先確認使用者眼睛盯著的是哪一個元素、記住它的身分,而不是記住它當下的座標。驗證這套設計有沒有生效,最直接的方法是拿一個類似的病態案例反覆跑。

What this enables:程式碼幾何精確算好、動態區塊先估後測、量測只在閒置或捲動停下時跑一次、修正套用前先用身分錨定住使用者的視線——這幾層紀律疊起來,才讓一個 2,200 個檔案、破百萬行、400 多則留言的 PR,在 GitHub Copilot App 裡開啟、捲動起來,表現得像一般大小的 PR,而不是每次量到新高度就跳一次畫面。