前端效能病理標本館 · 第一篇 全部數字:宣告 4x CPU throttle、CDP 機器驅動、每個 mode 三輪。 原始資料
docs/measurements/2026-07-25-reproducibility-4x.json,可自行覆算。
我蓋了一座前端效能的病理標本館。六個標本,每個一組病變版和一到三段治療版, 規矩是動工前先登記預期,量到不符就修變因、不修結論。
三輪可重現量測跑完了。六個病變版全部可重現——離散度最差的一個是 18.6%, 判準是 30%。病的部分沒有問題。
問題在藥。12 段治療處方,效果既乾淨又可歸因的只有 4 段。
| 標本 | 治療臂 | 病變 → 治療 | 判定 |
|---|---|---|---|
| #3 強制同步版面 | 讀寫分離 | forced 716ms → 零幀 | ✅ 乾淨有效 |
| #4 未節流事件 | IntersectionObserver | 掉幀 43 → 1 | ✅ 乾淨有效 |
| #6 重繪風暴 | 只改變動的節點 | 掉幀 231 → 7 | ✅ 乾淨有效 |
| #2 長列表 | content-visibility |
LCP 1360 → 592ms | ✅ 乾淨有效 |
| #1 主執行緒阻塞 | scheduler.yield |
INP 1368 → 80ms | ❌ 治療臂只做了十分之一的工作 |
| #1 主執行緒阻塞 | Web Worker | INP 1368 → 576ms | ❌ 同上,且成本搬到了搬運上 |
| #2 長列表 | 虛擬滾動 | LCP 1360 → 680ms | ⚠️ 只有這一臂被儀器加了負載 |
| #4 未節流事件 | wheel 改 passive |
掉幀 43 → 27 | ❌ 同時翻了兩個變因 |
| #4 未節流事件 | rAF 節流 | 掉幀 43 → 20 | ❌ 未進入作用區間 |
| #6 重繪風暴 | 批次化 + rAF | 掉幀 231 → 222 | ❌ 未進入作用區間 |
| #6 重繪風暴 | 背壓丟中間狀態 | 掉幀 231 → 4 | ❌ 那段程式從未執行 |
| #5 版面位移 | 全部預留空間 | CLS 0.127 → 0 | ❌ 治的是不存在的病 |
這篇文章講的是下面那八格。
治療有效的最強形態不是數字變小,是數字消失。
標本 #3 是交替讀寫的經典病變:對 800 列的清單,每一列先讀 offsetHeight
再寫 width。讀寫交錯逼瀏覽器在每一列都把版面重算一次。
病變(交替讀寫) forced style&layout median 745 / 716 / 678 ms 離散度 9.2%
治療(讀寫分離) 三輪裡兩輪連一幀 LoAF entry 都沒有
這件事在動工前就登記過:「那不是『數字很小』,是『沒有數字』。」 這是全文唯一一次預言在方向與量級上都成真。
兩臂的 layoutChecksum 都是 54168——讀寫分離沒有偷工,最終 DOM 完全相同。
沒有這個檢核,「治療版比較快」最無聊的解釋就是它少做了事。
由此立下四條判準,一段治療要全過才算數:
第四條需要一個數字。它是意外撿到的。
標本 #6 的治療三叫「背壓:丟掉中間狀態」。判斷式長這樣:
// specimens/06-rerender-storm.ts:179
renderNotBefore = now + Math.max(0, lastRenderMs - RENDER_BUDGET_MS);
RENDER_BUDGET_MS 寫死 16.7。而治療三走的是細粒度更新路徑,
實測 lastRenderMs 只有 1.7~2.7ms。於是 Math.max(0, 負數) 恆等於 0,
renderNotBefore 恆等於 now,下一幀的 now < renderNotBefore 永遠是 false。
要讓它成真,lastRenderMs 得大於 33.4ms(一幀 16.67 + 預算 16.7),差了 12 倍以上。
實測交叉證實:
batchesReceived batchesRendered rendersSkipped updatesApplied
fixed-granular 100 ×3 99 ×3 0 ×3 17943 ×3
fixed-backpressure 100 ×3 99 ×3 0 ×3 17943 ×3
六輪逐欄完全相同。 治療二和治療三跑的是位元級同一條路徑。
所以「治療三掉幀 3/4/4,治療二 8/7/4」不是治療三比較好, 是同一份工作量跑六次的抖動。
這給了一把免費的尺:droppedFramesPeak 在本站、同一份工作量下,
六輪的全距是 5 幀(3 到 8)。往後所有小於這個幅度的差距,一律不宣稱。
這個缺陷最惡劣的地方是它沒有症狀。面板上治療三看起來只是「跟治療二差不多,
大概是收斂了」。要不是去比 rendersSkipped 這個護欄計數器,它可以永遠躲著。
標本 #6 的治療一是「批次化 + rAF」:
病變(每批重建整表) 掉幀峰值 225 / 237 / 231
治療一(批次 + rAF) 掉幀峰值 219 / 222 / 223 比值 1.04×,離散度 1.8%
高度可重現的零效果。登記寫的是 4x 下 20~120,實測 222,超出上限 1.85 倍。
但誠實的結論不是「批次化無效」,是「它從未進入作用區間」。
推送源是 setInterval(50ms),也就是 20 次/秒。60Hz 一幀 16.67ms,
一幀之內平均只有 0.33 筆推送——沒有東西可以合併。
實測 renderRatio 是 0.91 / 1.0 / 1.0,54 次推送裡總共只合併掉 6 次。
更關鍵的是這個假 WebSocket 會反向節流。setInterval 錯過的週期是被丟棄,
不是排隊補發。主執行緒一忙,推送率就自動塌陷到跟渲染率一樣慢:
broken 五秒收到 48 / 47 / 50 批(名目 100)
fixed-granular 五秒收到 100 / 100 / 100 批
於是「每幀待處理筆數」被鎖在 1 附近,批次化在這個模擬裡結構上不可能生效。 真 WebSocket 的訊息會照原速堆進佇列,這個抵銷不存在。
順帶更正 repo 自己的兩處錯誤:程式註解和登記的風險 #3 都預測
setInterval 卡頓後會「補償性連續回呼」、批次數會超出 20/秒。
實測是 48 批,方向性推翻。而修正紀錄卻把它記成「順帶證實了風險 #3」。
方向性預測失敗比預測成功更值錢,但前提是要記對。
標本 #4 登記成一條疊加梯度:病變 → wheel 改 passive → 再加上 rAF 節流 → 換成 IntersectionObserver。
實測:
scrollEvents wheelEvents passes rectReads 掉幀峰值
broken 10/11/11 10/10/10 20/21/21 160k/168k/168k 38/46/43
fixed-passive 10/11/11 10/10/10 10/11/11 80k/ 88k/ 88k 20/29/27
fixed-raf 10/11/11 10/10/10 10/11/11 80k/ 88k/ 88k 20/27/18
fixed-observer 0/ 0/ 0 0/ 0/ 0 11/11/11 0/ 0/ 0 4/ 1/ 1
看第三、四欄。fixed-raf 和 fixed-passive 的 passes 與 rectReads 逐輪一模一樣。
rAF 合併閘門一次都沒有觸發——量測驅動器每 500ms 才派一次滾輪事件,
一幀之內永遠不會有第二個 scroll 可以合併。跟標本 #6 的治療一同一個病:
它沒有失效,它從未進入作用區間。
再看 fixed-passive 這一臂。它宣稱「只把 wheel 改成 passive: true」,
但程式碼是這樣:
// specimens/04-unthrottled-events.ts:245
scroller.addEventListener('wheel', countWheelOnly, { signal, passive: true });
// 其他 mode:
scroller.addEventListener('wheel', scanOnEveryWheel, { signal, passive: false });
換旗標的同時把 handler 也換掉了,順手拿掉一整輪 8000 次 getBoundingClientRect()。
passes 從 21 掉到 11、rectReads 從 168,000 砍到 88,000,剛好對半。
而掉幀只從 43 降到 27(0.63×),小於工作量的降幅(0.52×)——
扣掉「少做一次全掃」之後,passive 旗標沒有留下任何可歸因的殘差。
這個標本裡不存在能隔離 passive 的模式。要有,得再開一臂:
wheel 仍跑全掃、只翻 passive 旗標。
只有 fixed-observer 是乾淨的:它根本不掛 scroll 監聽器
(scrollEvents 和 rectReads 都是 0),43 → 1 是真的。
但那不是「治療一再加一點」,是換了一個完全不同的機制。
標本 #1 的兩段治療看起來是全站最漂亮的數字:INP 1368ms → 80ms,17 倍。
然後看護欄計數器:
completedSorts cancelledSorts sortMs
broken 10 0 116~122
fixed-yield 1 9 122~135
fixed-worker 1 9 (見下)
病變版做完十次排序,兩段治療各只做完一次、取消九次。
這不是作弊——非同步的實作本來就該取消被後續操作蓋掉的工作, 只有最後一次的結果有意義;而同步實作沒有這個選項。 「可以取消」正是讓出主執行緒買到的東西之一。
但它意味著 17 倍不是同一份工作量的對照。誠實的拆法是兩句話: 「讓出主執行緒把 INP 從 1368ms 降到 80ms,其中一部分來自它現在能丟掉 過期的工作——十次點擊只有一次真的跑完。」 標本自己的註解宣稱這個混淆變因往對治療不利的方向偏,實測是相反方向。
Web Worker 那一臂更值得看:
workerSortMs 28.4 / 29.0 / 28.8 ms ← 排序本身
workerSerializeMs 57.0 / 52.9 / 52.5 ms
workerTransferMs 589.7 / 566.7 / 585.5 ms ← 把資料搬過去
workerRoundTripMs 618.3 / 597.9 / 614.8 ms
排序 28ms,搬運 590ms。移動這批資料比排序它貴 20 倍。 「把重活丟給 Worker」在這個負載下是負優化—— 50,000 筆物件的 structured clone 就是那 590ms。 這一臂的 576ms INP 幾乎全部是搬運費。
這是本文唯一一個「治療比病變更值得寫」的案例, 但它成立的前提是先承認取消九成工作這件事。
標本 #5 排了三個位移源:300ms 圖片載入、900ms 字族換入、1500ms 橫幅插入。 間隔都是 600ms。標本頁面上印給讀者看的是這句:
三次位移的間隔都是 600ms,小於 CLS session window 的 1000ms 間隔上限, 所以它們會落進同一個 session 並累加。
登記的預期因此是 sessionCount = 1。
三輪實測都是 2。
原因:位移源二不產生任何位移。
// specimens/05-layout-shift.ts:93-94
proseEl.style.fontFamily = 'monospace';
proseEl.style.fontSize = '19px';
#ls-prose 是模板的最後一個元素,下方沒有任何內容可以被推動。
換字族只讓它自己往下長高,左上角不動——不符合 layout shift 的記錄條件。
於是實際只有兩筆 entry:300ms 和 1500ms。間隔 1200ms > 1000ms, 開兩個 session window。登記的 1 是錯的,實測的 2 完全正確, 而正確的原因是這個標本比它自己以為的少了三分之一。
治療版的 min-height: 168px 因此是空操作——它預留的是一個不存在的位移的空間。
面板上的 shiftSourcesFired 三輪都顯示 3/3,因為它數的是排程數不是 entry 數。
兩個 mode 都顯示 3/3。這個缺陷在 UI 上完全隱形。
上面五節講的是標本。這一節講量測工具——同一批問題,四個案例。
(一)拿峰值跨輪比,把可重現的標本判成不可重現。 標本 #3 第一次分析用 forced 的峰值跨輪比較:871 / 792 / 1245ms,離散度 52%, 判定不可重現。改用每輪的 median:721 / 709 / 751ms,離散度 5.9%。 凍結契約裡本來就寫著「抗離群。可重現性判定用這個,不用 max」,是我沒照做。 不可重現的是儀器。
(二)驅動器的保真度會翻轉兇手。
標本 #1 用 CDP 派送十次點擊。第一版每次 await CDP 回應——
而主執行緒正被排序擋住,回覆要等它做完才送得出來。
於是「盡快連續點十次」變成「做完一次才點下一次」,事件永遠排不了隊:
INP median 124ms,input delay 0.7~2.6ms。
改成一次灌完不等回應:INP median 1368ms,11 倍。
但兇手仍然不是登記的 inputDelay——變成了 presentation。因為不等回應等於
十次點擊同時發生,十個 handler 連續跑完才輪到一次 paint,
每一筆的 duration 都是「同一起點到同一次 paint」(三輪輪內 spread 都是 0~1%)。
人手連打是 150ms 一下,不是 0ms。兩種機器驅動法都錯,而且錯在相反方向。
這個標本的 intervalMs: null(盡快連續)根本不是一個凍結的變因,
動工前就登記過這條風險,實測讓它成真。
(三)判準在治療有效時會把方向搞反。
相對離散度 (max−min) / median 在 median 趨近零時會爆炸。
治療版掉幀 4 / 1 / 1 算出 300%,判定不可重現——但絕對差只有 3 幀,
是五秒窗約 300 幀裡的 1%。治療有效正是讓分母趨零,
於是「治療越成功,越判它不可重現」。 判準需要一條絕對雜訊底線,
而那條線正是第二節那段從未執行的程式碼替我量出來的。
(四)一行選幀邏輯,在該指標恆為零時退化成隨機取樣。
// tools/reproducibility.mjs
if (best === null || f.specimenForcedStyleAndLayoutDuration > best.specimenForcedStyleAndLayoutDuration) best = f;
嚴格大於在全零時永遠不成立,best 固定停在 frames[0]——面板最近六幀裡
最舊的那一幀,不是最壞的那一幀。對標本 #1、#4、#6 這些本來就沒有強制版面的標本,
連帶輸出的 specimenScript 欄位全部不可信。同一個 mode 同一組條件之間差了 18 倍,
那不是抖動,是選到不同幀。
六個病變版全部可重現。這座館子關於「病」的部分是可信的。
關於「藥」的部分,12 段裡有 4 段可信,1 段有保留(#2 虛擬滾動),7 段不能照原樣寫。
如果只看面板上的數字,這 12 段有 11 段看起來都在改善。
抓出那 8 段全部靠的是同一種東西:護欄計數器——
rendersSkipped、passes、rectReads、renderRatio、batchesReceived、
layoutChecksum、domNodeCount。它們不是主指標,
它們的功能是回答「這一臂到底做了什麼」。
rendersSkipped 恆為 0 抓到一段從未執行的程式碼。
passes 和 rectReads 逐輪相同抓到兩臂做著一樣的事。
layoutChecksum 相同證明治療沒有偷工。
主指標告訴你快了多少。護欄計數器告訴你這個數字能不能拿來說話。 只有前者的效能報告,跟沒有一樣。
domNodeCount 是 40,021。
反解兩式(21 + 5000×8 = 40021、21 + 15×8 = 141)證明每列是 8 個節點不是 7,
漏算了一個 badges 包裝層。文案與註冊表都還沒改。