[CODE]

假裝延後的延後:requestIdleCallback 在 Lighthouse 模擬器裡其實是立刻執行

兩個在真實瀏覽器裡看起來完全正確的延後載入策略,在 Lighthouse 模擬器裡都會立刻觸發——把 317KB 的 gtag 腳本和 17MB 的影片拉到 LCP 關鍵路徑上。四個民宿廣告到達頁衝到 90 分的過程。

2 min read AI 生成
astro performance lighthouse web-vitals bnb

那個房型頁分數卡在七十幾分,TBT 高到 3,336ms——而照我自己寫的程式碼註解,這頁根本不該下載任何一個影片位元組。

VideoThumb.astro 裡的註解講得很白:「Lighthouse 不會捲動,所以影片位元組永遠不會被抓。」這句話是錯的,而且已經錯了一陣子。把它為什麼錯查清楚,就是這篇的全部。

這四頁為什麼重要

這是一個更大系統裡的一個節點。這家民宿的直訂通路靠 Google Ads 把付費流量送進四個到達頁,而每一個訪客都是在被節流的手機網路上進來的。Google 的品質分數——決定客戶每次點擊要付多少錢——會直接讀到達頁的速度。慢頁不只是體驗問題,它是這檔廣告每一次點擊都要繳的稅,而且永遠繳下去。

客戶的報價裡寫明 Lighthouse 手機 ≥ 90。所以需求很具體:四頁、手機、≥ 90,LCP 低於 2.5 秒,TBT 低於 200ms。這四頁是首頁、介紹頁、交通頁,還有單一房型頁——漏斗最深、也最慢的那一頁。

沒有延後到任何東西的延後

原本的 gtag.js 載入器做的是教科書寫法:先同步塞一個 gtag stub,讓點擊處理器持續把事件排進 dataLayer,再用 requestIdleCallback 載入真正的腳本,讓它在 TTI 之後才跑、不會墊高 TBT。在真實瀏覽器裡,這沒問題。idle 回呼會等到主執行緒沒有更該做的事才動。

Lighthouse 的模擬節流不是這樣運作的。模擬器在初次解析後幾乎立刻就進入「idle」——沒有真正的互動,沒有真實的 CPU 爭用讓主執行緒忙著。所以 requestIdleCallback 在解析完就立刻觸發,把真正的 gtag.js 拉進網路,剛好就在 hero 圖最需要頻寬的那個窗口。

數字攤開來看:Google Ads 標籤 140KB,GA4 子腳本再 177KB,合計 317KB。在模擬的慢速 4G 上,這些頻寬直接跟 hero AVIF 搶,把 LCP 從大約 1 秒推到大約 3 秒。idle 回呼策略沒有降低成本,它只是把成本搬到時間軸上最糟的位置。

修法是別再把 idle 當訊號,改綁一個模擬器會認帳的事件:window.load 再加 1,500ms 延遲。這保證 LCP 繪製完成之後,那 317KB 才開始下載。同步 stub 和 dataLayer 重播完全不動,所以不會掉任何一個轉換事件——佇列只是晚一點才排空而已。

IntersectionObserver 得的是同一種病

影片縮圖是更大的兇手,根本原因卻是同一個錯覺。原本的程式用 IntersectionObserverrootMargin: '200px 0px',在影片捲動接近視窗時自動靜音播放。註解背後的推理是:Lighthouse 不會捲動,所以 observer 不會觸發,所以影片不會下載。

但 Lighthouse 手機視窗高 812px,200px 的 root margin 把觸發區延伸到足夠遠,遠到影片縮圖在載入時就已經在區域裡了——根本不用捲動。observer 在第一幀就觸發。這啟動了兩個影片檔的預載,在模擬慢速 4G 上合計大約 17MB。光這一筆下載,就是整個 3,336ms 的 TBT。

這裡讓人意外的不是延後載入會失準。是兩個完全不同的延後機制——一個靠 idle,一個靠視窗——在同一個模擬器底下、因為同一個底層原因,往同一個方向壞掉:模擬器對「idle」和「在視窗外」的模型,跟真實的瀏覽 session 不一樣。一個資深工程師讀到原本那兩段註解,多半會點頭認同;兩個說法聽起來都對。

我把 observer 換成單純的點擊播放。訪客沒點海報之前,一個位元組都不抓。順帶一個好處:點擊本身就是使用者手勢,所以 video.play() 在第一次互動就算不靜音也能放——這是自動播放路線拿不到的乾淨手勢,因為瀏覽器在載入時會拒絕不靜音的自動播放。

真正關於 LCP 而不是 TBT 的兩個改動

把搶頻寬的兩個兇手清掉之後,兩個比較小的改動收尾。

房型模板加了一個 heroPreload 提示,在 HTML 解析階段就 emit 一個 <link rel="preload" as="image" type="image/avif">,讓 preload scanner 在排版引擎發現 LCP 圖之前就開始抓。那個檔名 stem 是把 hero 圖路徑的副檔名剝掉算出來的,所以不管內容集合指向哪張圖,它都會跟著對齊。

交通頁原本有一個 Google Maps iframe,在頁面一開始就載入它的 JavaScript。我換成點擊載入的 facade——一個有地標圖示的樣式化佔位框,只有被點時才把真正的 iframe 換進來。大多數來看民宿位置的訪客要的是地址,不是互動地圖;要地圖的人按需求拿。Google Maps 的 JS 現在完全離開關鍵路徑。

最後落點

頁面分數LCPTBT
房型頁971.9s180ms
首頁981.6s130ms
介紹頁1001.1s60ms
交通頁1001.2s90ms

四頁全部過 ≥ 90,LCP 低於 2.5 秒,TBT 低於 200ms。誠實的但書:這些是本機跑的,沒有真實網路節流、也沒有真實 CPU 節流,所以對比 PageSpeed Insights 會偏樂觀一點。房型頁的 180ms TBT 就壓在 200ms 線下面,在 PSI 的真實條件下可能會翻過去——這該在上線後盯著看,而不是現在就宣布完成。

我會帶走的一句:延後策略的好壞,取決於這個 runtime 怎麼定義「待會」,而 Lighthouse 對「待會」的定義,有時候就是「現在」。