十二段治療處方,站得住的有四段

前端效能病理標本館 · 第一篇 全部數字:宣告 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 完全相同。 沒有這個檢核,「治療版比較快」最無聊的解釋就是它少做了事。

由此立下四條判準,一段治療要全過才算數:

  1. 這一臂與前一臂只差一個變因
  2. 效果有一條寫得出來的算式
  3. 三輪穩定
  4. 差距大於本站實測的雜訊底噪

第四條需要一個數字。它是意外撿到的。


二、雜訊底噪不是猜的——一段從未執行的程式碼替我量了它

標本 #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 這個護欄計數器,它可以永遠躲著。


三、零效果的那一段:批次化 1.04 倍

標本 #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-raffixed-passivepassesrectReads 逐輪一模一樣。 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 監聽器 (scrollEventsrectReads 都是 0),43 → 1 是真的。 但那不是「治療一再加一點」,是換了一個完全不同的機制


四之二、17 倍裡有多少是「少做了九成的事」

標本 #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 段全部靠的是同一種東西:護欄計數器—— rendersSkippedpassesrectReadsrenderRatiobatchesReceivedlayoutChecksumdomNodeCount。它們不是主指標, 它們的功能是回答「這一臂到底做了什麼」。

rendersSkipped 恆為 0 抓到一段從未執行的程式碼。 passesrectReads 逐輪相同抓到兩臂做著一樣的事。 layoutChecksum 相同證明治療沒有偷工。

主指標告訴你快了多少。護欄計數器告訴你這個數字能不能拿來說話。 只有前者的效能報告,跟沒有一樣。


附錄:這篇文章沒有做到的事