把 LCP 拆成四個子部分、INP 拆成三個子部分,並能從數據判斷瓶頸落在哪一段。
針對每個子部分說出至少兩種具體的優化手法,並知道其背後的瀏覽器機制。
理解 CLS 的常見成因,並能用 API 找出「誰在跳」。
建立一套「量測 \(\rightarrow\) 定位子部分 \(\rightarrow\) 對症優化 \(\rightarrow\) 再量測」的工作流程。
LCP:四個子部分
web.dev 建議把 LCP 時間拆成連續的四段(圖 1.1)。任何 LCP 優化都應該先知道「時間花在哪一段」,再對症下藥。
max width=
理想上,②「資源載入延遲」與④「元素渲染延遲」都應接近 0(各低於 LCP 的 10%),時間幾乎全部花在「真的在等伺服器」(①)與「真的在下載」(③)。但根據 corewebvitals.io 對數百萬次載入的統計,中位數網站的分布約為:TTFB 48%、載入延遲 24%、載入時間 10%、渲染延遲 17%——也就是說,兩段「純等待」合計比真正下載時間多四倍。這兩段是最先該找的優化空間。Chrome DevTools 的「LCP by phase」insight 與 CrUX 自 2025 年起提供的 LCP 圖片子部分數據,都能直接看到這個分布()。
① TTFB
TTFB 高通常指向伺服器、網路或重導問題。這部分已在拆解,並會在(快取 HTML)、(邊緣節點、Early Hints)、(串流 SSR、靜態產生)延伸。這裡只強調一點:TTFB 是 LCP 的地板,TTFB 1.5 秒的頁面不可能有 2.5 秒內的 LCP,除非其他三段合計小於 1 秒。
② 資源載入延遲:讓瀏覽器「早點知道」
這一段量的是「HTML 已經回來了,但瀏覽器還沒開始下載 LCP 圖片」的空窗。成因幾乎都是發現太晚:
LCP 圖片由 JavaScript 動態插入(典型 SPA / CSR),要等 JS 下載、執行、抓資料之後才出現在 DOM。
LCP 圖片是 CSS ,要等 CSS 下載並建好 CSSOM、套用到元素後才會被發現;preload scanner 看不到它。
LCP 圖片被加了 ,瀏覽器故意延後到接近視窗才載入。
圖片雖在 HTML 中,但排在大量同步腳本之後,或 preload scanner 被某些結構卡住。
圖片以預設的低優先權排隊,被前面一堆低價值資源(第三方腳本、下方圖片)搶走頻寬與連線。
優化手法:
<!-- 1. LCP 圖片直接寫在 HTML 中,且不要 lazy-load;用 fetchpriority 提升優先權 -->
<img src="/hero-800.avif"
srcset="/hero-480.avif 480w, /hero-800.avif 800w, /hero-1200.avif 1200w"
sizes="(max-width: 600px) 100vw, 800px"
width="800" height="450"
alt="本季主打商品"
fetchpriority="high">
<!-- 2. 若 LCP 圖片只能放在 CSS 背景或由 JS 決定,改用 preload 讓 scanner 提早看到 -->
<link rel="preload" as="image" fetchpriority="high"
imagesrcset="/hero-480.avif 480w, /hero-800.avif 800w, /hero-1200.avif 1200w"
imagesizes="(max-width: 600px) 100vw, 800px">
<!-- 3. 反面教材:LCP 圖片被 lazy-load,且沒有尺寸 -->
<img src="/hero.jpg" loading="lazy">fetchpriority="high":圖片預設是低優先權(Chrome 會在版面計算後把視窗內圖片提升,但那已經晚了)。明確標記讓它在第一輪「關鍵資源階段」就開始下載。只標一張最可能是 LCP 的圖,標太多等於沒標。
preload 只給「晚發現」的資源:字型、 進來的 CSS、CSS 背景圖、JS 才會插入的 LCP 圖。已經在 HTML 中的 不需要再 preload。
若 LCP 圖片來自跨來源的 CDN,搭配 ()省下連線建立時間。
架構層面:對內容型頁面採用 SSR / SSG,讓 LCP 元素直接出現在 HTML 中()。
③ 資源載入時間:讓資源「變小、變近」
現代格式:AVIF 通常比 JPEG 小 30–50%,WebP 約 25–35%。用 提供退路。
正確尺寸:以 + 讓手機不要下載桌機版 1600 px 的圖;注意 DPR(2x 螢幕需要兩倍像素,但可用較高壓縮率)。
壓縮品質:多數照片在品質 60–75 之間肉眼難以分辨,但檔案差距可達數倍。Image CDN()可自動處理格式、尺寸與品質。
減少競爭:LCP 圖片下載時,若同時有十幾個第三方腳本與下方圖片在搶頻寬,它就會變慢。對非首屏圖片使用 、延後非關鍵腳本,是在「幫 LCP 讓路」。
快取與 CDN:回訪者直接命中快取();首訪者從最近的邊緣節點取得()。
影片作為 LCP:確保 poster 圖片小而快,或讓自動播放影片的第一幀盡早可用。
④ 元素渲染延遲:資源到了卻沒畫出來
這一段是「圖片已經下載完成,但元素遲遲沒有上屏」。常見原因:
Render-blocking 資源: 內還有 CSS 或同步 JS 沒載完,瀏覽器根本還沒開始第一次繪製。
等待 JavaScript 才顯示:元素一開始 或 ,靠 JS / 框架 hydration 之後才顯示;A/B 測試工具的「防閃爍」腳本把整頁藏起來幾百毫秒。
文字 LCP 等字型: 讓文字在字型下載前完全不畫(FOIT)。
主執行緒忙碌:大量 JS 執行(hydration、初始化)佔住主執行緒,導致排好的繪製工作被延後。
圖片解碼:超大圖片的解碼本身也需要時間; 可以避免阻塞其他繪製,但對 LCP 圖片通常維持預設即可。
優化手法:內嵌關鍵 CSS 並延後非關鍵 CSS;所有腳本加 或 (或用 ,預設即 defer);字型使用 或 並 preload 關鍵字型;避免用 JS 控制 LCP 元素的可見性;把非必要的初始化工作拆小、延後(見 INP 一節)。
輪播(carousel):第一張圖才是 LCP,卻因 JS 初始化才顯示。
把 hero 圖改成 CSS 背景「比較好排版」,結果載入延遲多了幾百毫秒。
為了 Lighthouse 分數把所有圖都加 ,包含首屏。
用超模糊 LQIP 佔位,Chrome 112 之後因低熵被忽略,LCP 反而變成後面才載入的大圖。
使用 在十張圖上,優先權形同虛設。
Cookie 同意橫幅或彈窗成為 LCP 元素(覆蓋大面積文字),實際內容反而沒被量到——這時要重新思考元素設計。
INP:三個子部分
max width=
長任務與 50 ms 法則
主執行緒一次只能做一件事。任何超過 50 ms 的任務稱為;超過 50 ms 的部分是它的「阻塞時間」。RAIL 模型建議在 50 ms 內處理完使用者輸入,才能在 100 ms 內給出可感知的回饋。INP 的三個子部分幾乎都能追溯到長任務:
互動發生時主執行緒正在跑長任務 \(\Rightarrow\) 輸入延遲變長。
事件處理器本身就是長任務 \(\Rightarrow\) 處理時間變長。
處理器改動大量 DOM 導致樣式、版面重算成為長任務 \(\Rightarrow\) 呈現延遲變長。
① 輸入延遲:別讓主執行緒在使用者點下去時正忙
常見成因:頁面載入期間的大量 JS(bundle 解析執行、hydration)、第三方腳本的週期性計時器、多個互動重疊(前一次互動的處理器還沒跑完)。
優化手法:
減少載入階段的 JS:code splitting、延後載入非首屏功能、移除未使用程式碼、審視第三方腳本。
把長任務拆小,讓瀏覽器有機會在任務之間處理輸入(見下方程式碼)。
把純運算(資料處理、排序、加密)搬到 Web Worker。
避免在 中做重工;用 或 安排低優先權工作。
// 反例:一次處理 5000 筆資料,形成一個 400 ms 的長任務
function renderAll(items) {
for (const item of items) renderRow(item);
}
// 改法:每工作約 50 ms 就把主執行緒讓出來(yield),讓瀏覽器處理輸入與繪製
async function renderAllChunked(items) {
let deadline = performance.now() + 50;
for (const item of items) {
renderRow(item);
if (performance.now() >= deadline) {
await yieldToMain();
deadline = performance.now() + 50;
}
}
}
// scheduler.yield() 會把「後續程式」排在較高優先權的佇列,
// 不會像 setTimeout(0) 那樣被其他排隊中的任務插隊
function yieldToMain() {
if (globalThis.scheduler?.yield) return scheduler.yield();
return new Promise((resolve) => setTimeout(resolve, 0));
}早期建議「只有在 為 true 時才 yield」。web.dev 現已不建議:它在某些情況會誤報 false、而且輸入不是唯一需要讓路的對象(動畫與畫面更新同樣重要)。請改為定期無條件 yield,並優先使用 。
② 處理時間:只做「這一幀需要的事」
事件處理器本身太重是 INP 最直接的成因。框架的 re-render(React 的 state 更新造成大量元件重繪)、同步的資料處理、在處理器內讀取版面屬性(、)造成的強制同步版面(forced reflow)都是常客。
優化原則:處理器只做下一幀畫面需要的最少工作,其餘延後。
button.addEventListener('click', () => {
// 1. 立刻做視覺回饋(這會進入下一幀)
button.classList.add('is-loading');
panel.textContent = '處理中…';
// 2. 等下一幀畫完之後,再做真正的重工
requestAnimationFrame(() => {
setTimeout(() => {
const result = heavyComputation(); // 或搬到 Worker
panel.textContent = result;
button.classList.remove('is-loading');
}, 0);
});
});其他手法:對輸入框的 / 事件做 debounce;React 中用 / 把非緊急更新降級;避免在處理器裡同步呼叫分析工具的大量程式碼。
③ 呈現延遲:讓渲染變便宜
處理器跑完之後,瀏覽器要重算樣式、版面、繪製、合成。這一段變慢的成因:
DOM 太大:數千個節點的頁面,每次樣式與版面重算都很貴。Lighthouse 對超過約 1,400 個節點會警告。
版面抖動(layout thrashing):在迴圈中交錯「寫樣式、讀版面屬性」,迫使瀏覽器每次都同步重算版面。
複雜的 CSS 選擇器與大量 、深層後代選擇器,讓樣式重算變慢(DevTools 有 CSS selector stats 可診斷)。
回呼中做太多事:rAF 回呼會在繪製前執行,一樣會延後上屏。
優化手法:用 讓視窗外的區塊跳過渲染;用 CSS 限制重算範圍;批次讀寫 DOM(先全部讀、再全部寫);虛擬化長列表(只渲染看得到的列);用 / 做動畫(只動用合成執行緒)。
找出兇手:Long Animation Frames
Chrome 123 起提供 :每個超過 50 ms 的動畫畫格會產生一筆 entry,內含 陣列,指出是哪個腳本、哪個函式、由誰觸發(、、)、以及強制版面重算的時間()。 套件的 attribution 版本會自動把 LoAF 資料對應到 INP 的那次互動。有完整範例。
CLS:找出誰在跳,並預留空間
再看一次公式
max width=
常見成因與對策
L4.6cm L9.6cm 成因 & 對策
、、 沒有尺寸,載入後把下方內容推開 & 一律加上 與 屬性(瀏覽器會據此推算 預留空間);響應式圖片用 CSS 。
廣告、嵌入內容、動態載入的推薦區塊沒有預留空間 & 為容器設定 (以最常見尺寸為準)或固定尺寸的骨架;沒廣告時顯示佔位而非收合。
在既有內容上方插入內容(通知橫幅、Cookie 同意條、「載入更多」放在頂端) & 只在使用者互動後插入(500 ms 內不計)、改為覆蓋層()、或預留位置。
網頁字型與後備字型尺寸差異大(FOUT 造成文字重排) & (不換字型就不跳)、或用 / 讓後備字型與網頁字型的度量一致;preload 關鍵字型。
動畫使用 / / / & 改用 ,它不觸發版面計算,也不計入 CLS。
客戶端 hydration 後的內容與伺服器 HTML 不一致(hydration mismatch) & 確保 SSR 與 CSR 輸出一致;避免只在客戶端才決定的版面(如依 )。
圖片載入後才知道方向(直式 / 橫式) & 在後端存好尺寸資訊,一併輸出到 HTML。
<!-- 預留空間的三種寫法 -->
<img src="/p.avif" width="600" height="400" alt=""> <!-- 比例 3:2,瀏覽器自動預留 -->
<div class="ad-slot" style="min-height: 250px"></div> <!-- 廣告容器固定高度 -->
<style>
.video-wrap { aspect-ratio: 16 / 9; } <!-- 響應式容器 -->
@font-face { font-family: Brand; src: url(/brand.woff2) format('woff2');
font-display: optional; }
@font-face { font-family: BrandFallback; src: local('Arial');
size-adjust: 104%; ascent-override: 92%; } <!-- 讓後備字型度量對齊 -->
</style>用 API 找出是哪個元素在跳
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // 使用者輸入後 500 ms 內不計
console.log('shift', entry.value.toFixed(4), 'at', Math.round(entry.startTime), 'ms');
for (const src of entry.sources ?? []) {
// sources 依影響面積排序,第一個就是最大兇手
console.log(' ', src.node, src.previousRect, '->', src.currentRect);
}
}
}).observe({ type: 'layout-shift', buffered: true });DevTools 的 Performance 面板有專屬的 Layout shifts 軌道與「Layout shift culprits」insight,會直接列出元素與前後截圖()。
綜合案例:一個電商商品頁的診斷流程
假設 CrUX 顯示某商品頁行動版 p75:LCP 4.2 s、INP 380 ms、CLS 0.21,三項全部不及格。建議的排查順序:
先看 TTFB(PSI 或 RUM):若 TTFB 已達 1.4 s,先處理伺服器快取與 CDN,否則後面怎麼調都撞到地板。
錄一段 trace 看 LCP by phase:發現 LCP 元素是商品主圖,由 React 在抓完 API 之後才渲染,資源載入延遲佔了 1.8 s。對策:改為 SSR 輸出主圖、或至少在 HTML 中 preload 該圖並加 。
看 INP breakdown:最慢的互動是點「加入購物車」,處理時間 290 ms,其中一半是重新渲染整個商品頁的 React tree。對策:縮小狀態更新範圍、將購物車數量的更新拆成獨立元件、把分析事件延後送出。
看 Layout shifts 軌道:最大的一次跳動是「其他人也買了」推薦區塊在 3 秒後插入、把下面的評論推開。對策:容器預留 ;若資料還沒到就顯示骨架。
再量一次,並在 RUM 中觀察 28 天內 p75 的變化。記得只改一件事、量一次,才能歸因。
課堂實作
建立一個簡單頁面:標題、一張沒有尺寸的大圖、一段文字。用實作 2-1 的 layout-shift 觀察程式碼,在 Network 面板設定 Slow 4G 後重新整理,記錄 CLS 值。接著為圖片加上 /,再量一次。最後試著在頁面頂部用 於 2 秒後插入一條通知橫幅,觀察分數變化,並改成 的覆蓋層比較差異。
寫一個按鈕,點擊後在迴圈中建立 20,000 個 。用 DevTools Performance 面板(CPU 4x slowdown)錄製點擊,觀察 Interactions 軌道顯示的互動延遲與紅色長任務。然後改用本章的 + 版本,再錄一次比較。再進一步:先只渲染前 50 筆並立刻回饋,其餘用 慢慢補。
準備一張 300 KB 的 hero 圖,分別以 (a) 直接寫在 HTML、(b) CSS 、(c) JS 在 後插入 三種方式載入。用 Performance 面板的「LCP by phase」比較資源載入延遲的差異;再為 (b)(c) 加上 看能否追上 (a)。
一個頁面的 LCP 子部分為:TTFB 600 ms、載入延遲 1,200 ms、載入時間 200 ms、渲染延遲 100 ms。最該先處理哪一段?請提出兩個假設性成因與對應的修法。
為什麼「捲動」不算 INP 的互動?如果使用者在捲動時感覺卡頓,那是哪個指標或工具可以反映?
與 在「讓出主執行緒」這件事上有何差別?在第三方腳本很多的頁面上,這個差別為什麼重要?
有人建議把所有動態內容都改成「使用者點擊後才載入」來規避 CLS(因為 500 ms 內不計)。這個做法在什麼情況下合理?什麼情況下只是把問題藏起來?
為什麼 能同時改善 CLS 與 LCP?它的代價是什麼?
LCP = TTFB + 資源載入延遲 + 資源載入時間 + 元素渲染延遲。先找最長的一段:載入延遲 \(\rightarrow\) 讓瀏覽器早點發現(HTML 內、preload、fetchpriority、不要 lazy);載入時間 \(\rightarrow\) 變小變近(格式、尺寸、CDN);渲染延遲 \(\rightarrow\) 移除阻塞、不要等 JS 才顯示。
INP = 輸入延遲 + 處理時間 + 呈現延遲。核心是長任務:拆小、yield、只做這一幀需要的事、把渲染變便宜。用 LoAF 找出兇手腳本。
CLS = impact \(\times\) distance,取最大 session window。對策是「預留空間」:圖片尺寸、容器 min-height、字型度量對齊、transform 動畫、不要在上方插內容。
工作流程永遠是:量測 \(\rightarrow\) 定位子部分 \(\rightarrow\) 對症優化 \(\rightarrow\) 再量測。