工程實現不可能:如何在瀏覽器中渲染海量代碼 Diff
當你打開一個 pull request 時,你期望的是流暢的體驗。對於小規模的變更,工作流程非常直觀。但隨著規模增加——可能是由於 agent 生成的代碼、大規模重構或大量的測試快照——審查介面往往會退化。導航變得遲緩,文件逐個加載,瀏覽器開始感到吃力。
在大規模下渲染 diff 是一個具有欺騙性的問題。雖然代碼「僅僅是文本」,但專業的審查介面需要語法高亮、行號、註釋和靈活的佈局。當這些功能在數千個文件或數百萬行代碼中被成倍放大時,瀏覽器的 DOM、內存和處理能力限制會迅速達到。為了解决這個問題,Diffs 背後的團隊開發了 CodeView,這是一個以虛擬化為優先的組件,圍繞著一個單一且雄心勃勃的目標而設計:你應該能夠直接渲染任何 diff。
三重威脅:渲染、處理與內存
為了構建一個可擴展的審查介面,開發者確定了三個主要的瓶頸:
- 渲染: DOM 複雜度隨代碼量線性增長,在滾動和交互期間使瀏覽器負載過重。
- 處理: 像語法高亮這樣的操作,對於單個文件來說很便宜,但當重複數千次時,成本會變得極高。
- 內存: 將海量 patch 文件轉換為渲染數據結構可能會將瀏覽器推向內存限制,觸發頻繁且具破壞性的垃圾回收。
使用逆向粘性虛擬化解決「白屏」問題
虛擬化(或稱窗口化)是通過僅渲染視口附近的內容來減少 DOM 大小的標準方法。然而,傳統的虛擬化通常會遇到「白屏」問題——即用戶滾動的速度快於 JavaScript 渲染下一塊內容的速度,從而留下空白區域。
在評估了幾種技術——包括帶緩衝區的原生滾動和 requestAnimationFrame 粘性容器——之後,團隊開發了 Inverse Sticky Technique(逆向粘性技術)。
在典型的粘性定位中,元素會停留在視口的頂部。在 Inverse Sticky Technique 中,當向下滾動時,渲染區域的底邊會粘在視口的底部,而向上滾動時,頂邊會粘在視口的頂部。這是通過使用計算為 (contentHeight - viewportHeight) * -1 的負 top 和 bottom sticky offsets 來實現的。
這種混合方法保留了原生瀏覽器滾動和慣性,同時使白屏變得實際上不可能發生,因為如果 JavaScript 執行落後於滾動位置,渲染區域會直接「粘」在視口的邊緣。
可擴展的佈局與滾動錨定
為了讓虛擬化感覺流暢,引擎需要對總內容高度進行準確的估計,以避免滾動條「跳動」。CodeView 使用了一種廉價的第一輪估計:(lineHeight * totalLines) + (hunkSeparatorHeight * hunkCount)。
為了處理病態級別的大文件(例如,包含數十萬行的 hunks),團隊實現了一個緩存的 position-to-line checkpoint system(位置到行號檢查點系統),允許引擎使用二分查找來尋找起始渲染範圍,而不是從文件的開頭開始迭代。
此外,由於虛擬化的 DOM 會不斷變化,原生瀏覽器滾動錨定會失效。CodeView 實現了它自己的錨定邏輯:
- 它識別第一個完全可見的行或文件。
- 它將其存儲為一個帶有視口偏移量的錨點。
- 在提交 DOM 變更並協調高度差值後,它會調整滾動位置以確保錨點保持在相同的偏移量,從而防止視圖跳動。
針對病態案例的內存優化
通過對海量數據集(例如 Linux v6 與 v7 之間的 diff)進行測試,發現內存管理與渲染速度同樣關鍵。
分離解析後的字符串
在 JavaScript 中,子字符串有時會保留對原始較大字符串的引用。當解析一個 700 MB 的 patch 文件時,僅保留必要的行可能仍會意外地將整個源字符串保留在內存中。通過顯式地複製字符串以將其與原始輸入分離,團隊將 Linux diff 的內存使用量從 2.4 GB 降低到了 1.15 GB,並將解析時間縮短了 80%。
DOM 池化與共享狀態
為了減少滾動期間因不斷的 DOM 變動引起的垃圾回收停頓,CodeView 採用了 DOM pooling(DOM 池化)。引擎不再為每個文件銷毀並重新創建 Shadow DOM 包裝器(其中包含樣式表和 SVG 圖標),而是重複使用這些外殼,並僅更換內部內容。
此外,團隊優化了配置管理。最初,每個文件都有自己的 options 對象。對於數萬個文件,更新單個設置(如切換行換行)需要迭代遍歷所有實例。通過將狀態移至 CodeView 中的共享事實來源,並在項目中使用專門的 getter,外觀上的變更現在可以立即發生,而無需重寫整個審查介面的配置。
通過延遲高亮實現漸進式增強
語法高亮是最耗費計算資源的任務之一。為了防止它阻塞主線程,CodeView 使用了一個延遲流水線:
- 立即渲染: 代碼立即作為純文本渲染。
- Worker Offloading: 高亮請求被發送到一個運行 Shiki 的 web workers 池。
- LRU Caching: 結果被存儲在 Least Recently Used 緩存中,以避免重新處理滾動回視口內的代碼。
剩餘的挑戰與未來方向
儘管取得了這些成果,團隊承認瀏覽器環境仍然存在限制。在劇烈滾動時,CSS 佈局和繪製(paint)仍然很昂貴,且海量文件的語法高亮數據序列化可能會佔據主線程。
關於虛擬化也存在哲學上的爭論。正如一些 Hacker News 社群成員在討論中所指出的,有人認為現代硬件應該能夠處理大型 diff,而無需「瘋狂的技巧」,其他人則建議,diff 的未來在於 AST (Abstract Syntax Tree) diff,而不是基於行號的渲染。
無論如何,CodeView 的當前實現推向了極限,確保了無論 PR 是十行還是千萬行,審查者的焦點始終在代碼本身,而非工具本身。