為什麼蓋一座標本館,而不是再寫一篇效能技巧文

前端效能病理標本館 · 第三篇 · 入口總覽 本文不產生新數字。每個數字都引自已發出的兩篇文章、六份病理報告或 docs/ 的登記檔, 逐處標明來源與 session。已發出的文章不回頭改數字(本站規矩), 所以本文引用它們時,連同「後來被翻案」的部分一起引。

我蓋了一座前端效能的病理標本館。六個標本,每個是一個效能反模式: 同一頁可以切換病變版與治療版,旁邊的面板顯示實測指標。

它是一份實驗,不是一個應用程式。整座館只做一種宣稱:

其他條件全部凍結時,翻動這一個變因,產生這個差距。perf-pathology-museum-spec.md §1)

這篇是入口,回答三個問題:為什麼是標本館,不是再一篇技巧文; 這裡的數字憑什麼可信;你能從這裡拿走什麼。


一、技巧文的問題不是建議錯,是它發現不了自己錯

網路上的效能文章大多長這樣:「長列表請使用虛擬滾動」,附一張優化前後的截圖,講完了。

本站也量了虛擬滾動。第一輪的結果是 LCP 1360 → 680ms,2.0 倍,發表在第一篇文章裡 (《十二段治療處方,站得住的有四段》,原始資料 docs/measurements/2026-07-25-reproducibility-4x.json)。

後來查出來,真實答案是 23.3 倍(第二篇,2026-07-26-reproducibility-4x-5.json)。

差在哪?不在標本——標本一個字都沒改。差在量測方法:切換 mode 時, 前一份 document 的拆除工作落在新 document 的時鐘裡,LCP 因此帶著一個由 「前一個 mode 有多重」決定的加項。同一輪之內實測,這個污染項約 790ms,是訊號的八倍; 最刺眼的一組對照是 141 個節點的 document,載入耗時是 40,021 個節點那份的五倍 (DCL 8,5279,553ms 對 8191,907ms,第二篇第五節)。

這個缺陷沒有症狀,而且它讓數字更漂亮:污染是常數,常數不製造離散度, 於是那一輪標本 #2 的離散度 2.9%,全站最漂亮的數字之一。 單次量測的截圖抓不到這種錯;連「連跑三輪」都抓不到—— 一個穩定的錯誤,可重現性判準對它一無所知(第二篇第五節)。

第二個例子更直接。第一輪量測跑完時,面板上 12 段治療處方有 11 段看起來都在改善。 拿判準和護欄計數器逐段過濾之後,效果既乾淨又可歸因的只剩 4 段(第一篇)。 有的治療同時翻了兩個變因,有的從未進入作用區間, 有一段的核心程式碼從未執行過——rendersSkipped 三輪恆為 0, 要不是去看這個護欄計數器,它可以永遠躲著(第一篇第二節)。 只看面板數字的話,這 12 段每一段都可以寫成一篇像樣的技巧文。

所以差別不在結論,在機制:技巧文發表之後就結束了; 標本館發表之後,錯誤還會被自己的流程抓回來,翻案紀錄跟原始結論擺在一起。 第一篇的多處數字後來失效了,但那篇文章一個字不改——它是有日期、附原始資料連結的快照, 第二篇講的正是它哪裡錯了、為什麼錯。


二、館的形狀

六個標本(specimens/),每個是獨立頁面,外殼用 iframe 載入,面板顯示實測指標。 標本分兩類,這個分法會影響整條量測路徑:

館裡還有一個不算標本的東西:校準件specimens/00-calibration)。 它的每個負載都有解析解可以反推——忙迴圈 300ms,Event Timing 的 processing 段就該量到 300.x。 實測 300.8~303.1ms(docs/HANDOFF.md,路由對調後的煙霧測)。 它的功能是回答一個技巧文從來不問的問題:憑什麼相信量測層自己沒說謊?

驗收也是實跑出來的:tools/acceptance.mjs 用真實瀏覽器派送真實輸入跑 13 條檢查 (合成的 element.click() 沒有 interactionId,Event Timing 一筆都不回報, 所以必須走 CDP 的真實輸入)。這個 repo 沒有單元測試框架,驗收就是測試套件


三、數字憑什麼可信:五道防線

不是因為沒出過錯。出過的錯兩篇文章加起來寫了三萬多字。 可信的理由是:每一道防線都在檔案裡留下它攔到了什麼。

1. 凍結並宣告

viewport 凍結在 800×600(CLS 與 LCP 都是 viewport 相對量);build 設定固定; 零外部請求——不准 CDN 字體、不准第三方分析(spec §4.7), 量測站點自己去要外部資源,等於把污染源寫進實驗器材。

凍結不了的就宣告:CPU throttle 從 JS 偵測不到,所以 4x 是宣告值, 印在面板和每一筆資料裡,不假裝是偵測值。驅動方式也宣告—— 全部數字是 CDP 機器驅動,不是人手,這件事每篇文章的頭三行都寫。

2. 動工前登記預期

每個標本動工前,預期的數字、兇手段、風險先寫進 docs/phase1-expected-results.md / docs/phase2-expected-results.md登記完不准回頭改——要改只能在檔尾追加修正紀錄,寫明改了什麼、作廢哪些數字。 量完再寫預期的話,預期永遠是對的,這個檔案就沒有存在意義。

3. 判準在看資料之前定案

「可重現」不是形容詞,是三條機械判準:三輪比值落在同一個量級帶、三輪兇手段一致、 病變值相對離散度 ≤ 30%(spec §1,原則 4)。

比值判準有四條是後來補定案的,定案過程值得單獨講,因為它誠實到有點難堪: 定案時已經看過兩輪實測,無法假裝沒看過。所以防線改成——每個數字都附推導、 推導不引用「剛好過關」的落點,而且其中一條(#4 的 ≥ 2×)在已看過的資料上 邊際只有 12%,下一輪落空是真實可能。挑數字的人若在迎合實測, 不會挑一條可能打臉自己的線(docs/phase2-expected-results.md 檔尾追記「比值判準四條定案」)。

下一輪掃描(2026-07-26-reproducibility-4x-6.json)四條首次生效,四條全過—— 包括那條邊際最薄的,實測 3.5×(phase2 檔尾「儀器修正後的完整掃描」)。

4. 負載問責

機器負載會讓數字撒謊:同一套驗收,load < 2 時 13/13 全綠, load 3.75.8 時掉到 811,失敗項還會游移(CLAUDE.md)。 所以量測工具開跑前檢查 load,≥ 2 直接拒跑;每一筆 record 都帶 loadAvg1m 欄位 (phase2「儀器修正五條」)。現行正典掃描全程 load 0.82~1.61,寫在檔頭—— 以後「是機器還是標本」看欄位,不用吵。

5. 修變因,不修結論

量到不符預期,改的是實驗設計,不是把結論改成符合實測。 這條規矩的必然結果,就是下一節那些翻案——它們不是防線失守的證據, 是防線在工作的證據。


四、防線的功能不是擋掉失敗,是把失敗變成資料:三個真實案例

案例一:八格判決,翻了六格

第一篇判了八段治療有問題。修掉實驗設計的缺陷、重量一輪之後,八格翻了六格—— 而且往四個不同的方向翻(第二篇):

歸零      #4 passive       0.63×  →  1.0×      隔離變因之後療效完全消失
從無到有  #4 rAF 節流      沒發生 →  2.7×      合併閘門先前一次都沒觸發過
變大      #1 scheduler.yield  17×  →  32×      混淆變因移除,效果反而更大
沒動      #6 批次化        1.04×  →  1.2×      進了作用區間,還是零效果

「混淆變因一定在幫治療臂作弊」是個直覺,而這一輪有兩格是反過來的: 工作量對齊之後 scheduler.yield 從 17 倍變 32 倍,量測污染修掉之後虛擬滾動從 2 倍變 23.3 倍。 混淆變因往哪個方向偏,不能用直覺猜(第二篇第四、五節)。

案例二:#1 是交界標本——判準對它結構性不適用,照實登記

標本 #1(主執行緒阻塞)的兇手歸因,在較慢的機器狀態下三輪一致是 presentation; 機器變快之後變成 presentation / inputDelay / inputDelay——三輪不一致,判準亮警示。 質量在兩段之間整塊搬家(同一輪內:run1 presentation 951ms / inputDelay 0.8ms; run2 presentation 120ms / inputDelay 798ms),不是雜訊抖動 (docs/phase1-expected-results.md 修正紀錄「標本 #1 兇手改判為交界標本」)。

機制:INP 看不看得見排隊,不取決於有沒有排隊, 取決於排隊期間瀏覽器有沒有機會畫。這支標本正好卡在那條界線上, 兇手是機器速度的函數,不是標本的性質。

裁決有三條,每一條都是誠實原則的實作:登記值不改(規矩 2),改成明文例外; 兇手宣稱換成合成判準(inputDelay + presentation 合計 918~952ms,壓倒 processing); 分析器的警示保留不消——讀者應該看到它翻,然後被指到這一條裁決。 交界本身就是展品,不是要修掉的缺陷。

案例三:#4 passive——登記的判準落空,照實記,不改判準

第二篇量到:把混淆變因隔離之後,passive: true 的掉幀 113 → 112,歸零。 但那篇文章特意留了一句邊界警語:這把尺量的是主執行緒的出幀節奏, 量不到 passive 真正買的東西(compositor 側不等 handler 就能先捲)—— 「量到一個零」是尺的解析度邊界的結論,不是「passive 沒用」的一般結論(第二篇第二節)。

同一天晚上的下一輪掃描(-4x-6.json),這一臂三輪穩定優於病變版 7~8 幀 (92/94/91 對 99/102/99),超出 5 幀的同格帶—— 登記的「fixed-passive 不得優於 broken落空。 可比性護欄全部對稱(wheelEvents 六筆皆 20、passes 兩臂同帶、load 同帶), 「臂間工作量不對稱」和「機器狀態」兩個便宜解釋都被排除 (phase2 檔尾「#4 fixed-passive 方向性判準落空」)。

處置:落空照實記,判準不改。機制假說(passive 省下的是等待、不是工作, 所以先前判它「不該有可量的效果」的推理可能才是錯的那個)明文標示未驗證, 留給下一輪先驗機制再談改登記。可能錯的是登記時的推理,不是資料。

一個判準的價值,恰恰在於它落空時你敢不敢把落空印出來。


五、你能拿走的(一):六個標本,各一句話

下表的實測全部出自同一份正典原始資料 docs/measurements/2026-07-26-reproducibility-4x-5.json(4x 宣告節流、CDP 機器驅動、 800×600、每 mode 三輪),完整推導在 docs/reports/01~06 六份病理報告, 每個數字在報告裡都附了 JSON 欄位路徑。

標本 病變(一句話) 病變實測 治療實測
#1 主執行緒阻塞 十連發點擊,click handler 同步排序五萬筆訂單,把主執行緒整段佔住 INP 三輪 median 1028 / 896 / 796ms 切 chunk + 讓出:24~32ms
#2 長列表 5000 列一次渲染成 40,021 個 DOM 節點,最大那塊文字的繪製被擋住 LCP 1864ms content-visibility 520ms(3.6×);虛擬滾動 80ms(23.3×,只建 141 個節點)
#3 強制同步版面 800 列交替「讀 offsetHeight、寫 width」,每列都逼瀏覽器重算版面 forced median 829~964ms 讀寫分離:三輪零強制版面幀
#4 未節流事件 每個 scroll/wheel 都做一整輪 8000 次 getBoundingClientRect() 掉幀峰值 99~119 rAF 節流:median 113 → 42
#5 版面位移 零互動,三個位移源(無尺寸圖、字族換入、插入橫幅)落進同一個 session window 累加 CLS 0.2694(三輪逐位元相同) 逐一預留空間:一筆 layout-shift entry 都沒有
#6 重繪風暴 每 25ms 推一批更新,每批重建整張 1000 列清單 掉幀峰值 275~281(300 幀的窗,幾乎每幀都掉) 只改變動的節點:14~18,幀距貼回 16.7ms

三條必讀的警語,六份報告各自展開:

六、你能拿走的(二):一套可以偷走的方法論

這部分跟前端無關,量任何東西都用得上。

1. 主指標之外要有護欄計數器。主指標告訴你快了多少, 護欄計數器告訴你這個數字能不能拿來說話(第一篇第七節)。 layoutChecksum 兩臂逐位元相同,證明治療沒偷工——順帶一則本篇才更正的錯: 第一篇印的 checksum 值 54168 其實出自另一支 1x、四次點擊的校準探針, 三份正典 JSON 的 18 筆實測全部是 54888(報告 03)。已發文章不回頭改(規矩 5), 所以更正寫在這裡——護欄計數器連自己被引錯都有辦法被抓到,因為每個值都有出處可對; rendersSkipped 恆為 0, 抓到一段從未執行的程式;passesrectReads 逐輪相同,抓到兩臂做著同一件事。 第一篇抓出那八段有問題的治療,靠的全部是這種計數器(第一篇第七節)。

**2. 先量你自己的雜訊底線。**一段位元級相同的路徑跑六輪,掉幀全距 5 幀(第一篇第二節)—— 這就是本站的尺。小於底線的差距一律不宣稱;「治療臂 A 比治療臂 B 好 1 幀」在這裡不是句子。

3. 相對離散度在治療有效時會把方向搞反。(max−min)/median 在 median 趨零時爆炸: 掉幀 4/1/1 算出 300%,判不可重現——治療越成功,越判它不可重現(第一篇第六節)。 判準需要一條絕對底線,不能只有相對值。

**4. 可重現性判準抓不到穩定的錯。**它只看三輪之間一不一致, 一個每輪都在的常數偏差,它給你全站最漂亮的離散度。 要抓它,得有一條直接問「同一個 mode 量兩次,數字一樣嗎」的測試 (tools/b-class-isolation.mjs,第二篇第九節)——而它本來應該從第一天就存在。

**5. 解析度下限印在 UI 上,不放在心裡。**Event Timing 的 durationThreshold 最低 16ms、 duration 四捨五入到 8ms、LoAF 的 blockingDuration 是整幀的——三條全部印在面板與文章裡 (spec §1 誠實原則)。連「標本 #2 的 LCP 標的其實是『請不要動』那段操作說明、 不是那 5000 列清單」這種會讓故事變醜的事實,也照實登記成凍結變因並寫進報告 (phase2 檔尾追記、報告 02)。標明限制之後,就大方地下結論。


七、怎麼讀這座館

  1. **親手按。**站在 https://frontendmuseum.pages.dev —— 首頁是標本索引,量測台在 /measure.html。每個標本頁都能切病變/治療,面板即時顯示指標。 量測功能需要 Chromium 系瀏覽器(interactionId 與 LoAF 都是 Chromium-only)。 面板出現紅色 banner 表示在 dev server 上——那時的數字不算數。
  2. **兩篇文章按順序讀。**第一篇是第一輪的判決,第二篇是修完實驗設計之後的翻案紀錄。 第二篇讓第一篇多處數字失效,而第一篇一個字不改——這個順序本身就是方法論。
  3. 六份病理報告docs/reports/01~06):每份八節,凍結條件、兇手歸因、 治療梯度、誠實揭露,每個數字附欄位路徑。
  4. 原始資料docs/measurements/*.json):每篇文章頭三行都寫了它用哪一份, 可以自行覆算。臂間比值只在同一份 JSON 內部成立。
  5. 登記檔docs/phase*-expected-results.md):預期、實測、修正紀錄、作廢清單。 想看「登記完不准回頭改」實際上長什麼樣子,看檔尾那一串追記。

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