紀錄工作經驗、相關知識,解決技術相關問題。

HTML, SEO, 新手 Jamp Holes, 網站優化, 網站相關

Core Web Vitals LCP 最大內容繪製:Sub-parts 四階段拆解、fetchpriority 與首屏載入調校

Core Web Vitals LCP 最大內容繪製:Sub-parts 四階段拆解、fetchpriority 與首屏載入調校 特色圖片

Table of Contents

在上一篇「CSS Anchor Positioning 錨點定位開發要點:anchor-name、position-area 與 position-try-fallbacks 邊界翻轉機制」中,我們透過原生 CSS 錨點定位移除了浮動介面在主執行緒上的 JavaScript 座標運算。除了降低互動階段的執行緒負擔外,決定搜尋引擎使用者體驗評分與首屏載入體感的第一道關卡,則是 Core Web Vitals LCP(Largest Contentful Paint,最大內容繪製)。本文將依據 W3C Largest Contentful Paint 規範與 Google Chrome 團隊提出的 Sub-parts 四階段拆解法,從 HTML 標記結構、fetchpriority 優先權控制到 PerformanceObserver 量測腳本,逐步示範如何精準定位並縮短首屏最大元素的渲染時間。


常見問題(Q&A)與程式碼範本快速跳轉

如果目前正遇到 Google Search Console 通報 LCP 超過 2.5 秒、首屏橫幅圖片太晚開始下載,或本地 Lighthouse 與 CrUX 真實使用者數據不一致,可直接點選下方連結跳轉至對應解答與程式碼範本:

如果不趕時間,建議順著下方章節往下讀,我們將從傳統首屏橫幅的載入延遲問題開始,逐一講解 W3C LCP 候選元素的判定標準與 Google 官方 Sub-parts 四階段診斷原理。


一、 傳統首屏橫幅寫法的載入瓶頸與 Core Web Vitals LCP 四階段規範

1. 原本正常的標準寫法與瓶頸

在製作首頁或活動落地頁時,許多開發者習慣使用 <div> 搭配外部 CSS 的 background-image 來呈現首屏英雄橫幅(Hero Banner),或是對頁面上所有 <img> 統一套用 loading="lazy":

<!-- 常見瓶頸一:使用外部 CSS background-image 載入首屏大圖 -->
<div class="hero-banner-bg">
  <h1>企業雲端架構與高可用性解決方案</h1>
</div>

<!-- 常見瓶頸二:對首屏第一張可見大圖誤加 loading="lazy" -->
<img
  src="/images/hero-dashboard.webp"
  alt="系統監控儀表板預覽"
  loading="lazy"
  width="1200"
  height="630"
>

這種寫法雖然在視覺排版上沒有錯誤,但在瀏覽器載入管線(Loading Pipeline)中卻會引發嚴重的等待延遲:

1. 預載掃描器(Preload Scanner)無法發現 CSS 背景圖:當瀏覽器收到 HTML 首個位元組並開始解析時,內建的 Preload Scanner 只會掃描 HTML 標籤上的 src 與 srcset。寫在外部 .css 檔案裡的 background-image: url(...),必須等到 CSS 下載完成、解析為 CSSOM,並與 DOM 合併建立渲染樹(Render Tree)確認該元素可見後,瀏覽器才會開始發起圖片請求。

外部 CSS background-image 導致 Preload Scanner 無法提早發現圖片的載入延遲流程

圖:外部 CSS background-image 導致 Preload Scanner 無法提早發現圖片的載入延遲流程

2. 誤加 loading="lazy" 推遲下載時機:對首屏可視區(Above the fold)內的最大圖片加上 loading="lazy",會阻止 Preload Scanner 提早排程下載,強制瀏覽器等到完成首次佈局(Layout)並由 IntersectionObserver 判定圖片位於視窗內後,才以低優先權發送請求。

首屏圖片誤加 loading="lazy" 導致等待首次 Layout 後才低優先權下載的延遲流程

圖:首屏圖片誤加 loading=”lazy” 導致等待首次 Layout 後才低優先權下載的延遲流程

3. 只看壓縮檔案大小卻忽略排程延遲:根據 Google Chrome Web DevRel 團隊針對真實網站的統計,多數網站 LCP 不合格的主因並非「圖片下載時間太長」,而是「瀏覽器太晚才開始下載圖片(Resource Load Delay)」以及「圖片下載完卻被主執行緒腳本卡住無法繪製(Element Render Delay)」。唯有改用原生 <img> 搭配 fetchpriority="high",才能讓 Preload Scanner 第一時間啟動平行下載並消除排程等待。

使用原生 img 搭配 fetchpriority="high" 消除排程與渲染延遲的平行載入流程

圖:使用原生 img 搭配 fetchpriority=”high” 消除排程與渲染延遲的平行載入流程

2. Core Web Vitals LCP 評分門檻與 Sub-parts 四階段拆解

根據 W3C Largest Contentful Paint 規範 與 Google Search Central — Core Web Vitals 標準,LCP 衡量的是從使用者發起頁面導覽開始,直到可視區域(Viewport)內面積最大的內容元素完成繪製的時間。Google 以真實使用者第 75 百分位數(p75)作為判定基準:

  • 良好(Good):<= 2.5 秒(2500ms)
  • 需要改善(Needs Improvement):> 2.5 秒 且 <= 4.0 秒
  • 不良(Poor):> 4.0 秒(4000ms)

會被瀏覽器納入 LCP 計算的候選元素(Candidates)包含以下五類:

  1. <img> 元素(若為動態 GIF 或動圖 PNG,則以第一格畫面的呈現時間為準)。
  2. 內嵌於 <svg> 中的 <image> 元素。
  3. <video> 元素(以 poster 預覽圖載入時間,或影片第一格畫面繪製時間較早者為準)。
  4. 透過 CSS url() 載入背景圖片的元素(不包含 CSS 漸層如 linear-gradient)。
  5. 包含文字節點的區塊級(Block-level)或行內元素。

為了精準找出時間損耗在哪個環節,Google 官方建議將總 LCP 時間拆解為互不重疊的四個子階段(LCP Sub-parts)。下表整理四個階段的定義與官方建議耗時佔比:

LCP 子階段名稱時間區間定義建議佔比目標
1. 首位元組時間(TTFB)導覽開始 ➔ 收到 HTML 首個位元組約 40%(且 < 800ms)
2. 資源載入延遲(Resource Load Delay)收到首個位元組 ➔ 開始請求 LCP 資源< 10%(趨近於 0 最佳)
3. 資源載入耗時(Resource Load Duration)開始請求 LCP 資源 ➔ 資源下載完成約 40%
4. 元素渲染延遲(Element Render Delay)資源下載完成 ➔ 元素實際繪製到畫面上< 10%(趨近於 0 最佳)

下圖完整呈現了從瀏覽器發起請求到首屏最大元素繪製上屏的四階段時間軸,以及各階段對應的工程瓶頸:

Core Web Vitals LCP 四階段(Sub-parts)時間軸與工程瓶頸拆解圖

圖:Core Web Vitals LCP 四階段(Sub-parts)時間軸與工程瓶頸拆解圖


二、 程式碼範本:首屏 LCP 圖片標記優化與 PerformanceObserver 自動診斷

1. 範本一:將 CSS 背景橫幅改為可被 Preload Scanner 提早發現的響應式 img 與 fetchpriority 配置

要將第二階段「資源載入延遲(Resource Load Delay)」壓低至 10% 以內,最直接且有效的方法是:確保 LCP 目標圖片直接以 <img>(或 <picture>)寫在初始 HTML 中,搭配 object-fit: cover 取代原本的 CSS background-image,並明確加上 fetchpriority="high" 將網路下載優先權從預設的 Medium / Low 提升至 High。

以下為兼顧視覺覆蓋排版、零版面位移(CLS)與最高載入優先權的首屏橫幅標準寫法:

<section class="hero-section">
  <!-- 使用原生 picture + img 讓 HTML Preload Scanner 在第一時間發現並以最高優先級下載 -->
  <picture class="hero-media-wrapper">
    <source
      type="image/avif"
      srcset="/images/hero-cloud-800.avif 800w, /images/hero-cloud-1600.avif 1600w"
      sizes="100vw"
    >
    <img
      class="hero-bg-image"
      src="/images/hero-cloud-1600.webp"
      srcset="/images/hero-cloud-800.webp 800w, /images/hero-cloud-1600.webp 1600w"
      sizes="100vw"
      width="1600"
      height="900"
      fetchpriority="high"
      loading="eager"
      decoding="async"
      alt="多區域雲端伺服器架構監控視圖"
    >
  </picture>

  <div class="hero-content">
    <h1>企業雲端架構與高可用性解決方案</h1>
    <p>透過邊緣節點快取與自動化負載平衡,將全球首位元組回應時間控制在 100ms 以內。</p>
  </div>
</section>

對應的 CSS 樣式(利用 position: absolute 與 object-fit: cover 達成與 background-image 相同的背景填滿效果):

.hero-section {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 480px;
  padding: 64px 24px;
  overflow: hidden;
  color: #f8fafc;
  background-color: #0f172a;
}

.hero-media-wrapper,
.hero-bg-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.hero-bg-image {
  object-fit: cover;
  object-position: center;
  opacity: 0.45;
}

.hero-content {
  position: relative;
  z-index: 1;
  max-width: 720px;
  margin: 0 auto;
}

2. 範本二:使用原生 PerformanceObserver 自動拆解並輸出 LCP 四階段耗時

在實務除錯時,我們可以透過瀏覽器原生的 PerformanceObserver 監聽 largest-contentful-paint 事件,並結合 PerformanceNavigationTiming(讀取 responseStart 即 TTFB)與 PerformanceResourceTiming(讀取 LCP 圖片的 requestStart 與 responseEnd),在開發者工具 Console 中直接算出四個子階段的毫秒數與百分比。

下圖展示了瀏覽器載入管線如何透過 PerformanceObserver 將導覽時間、資源時間與繪製時間串聯計算出四階段指標:

PerformanceObserver 結合 Navigation 與 Resource Timing 計算 LCP 四階段流程圖

圖:PerformanceObserver 結合 Navigation 與 Resource Timing 計算 LCP 四階段流程圖

以下為可直接放入前端效能監控模組的診斷腳本:

/**
 * 監聽並拆解頁面的 LCP 四階段耗時(Sub-parts)
 */
function observeLcpSubParts() {
  if (
    typeof PerformanceObserver === 'undefined' ||
    !PerformanceObserver.supportedEntryTypes.includes('largest-contentful-paint')
  ) {
    return;
  }

  const observer = new PerformanceObserver((entryList) => {
    const entries = entryList.getEntries();
    // LCP 隨頁面載入會多次更新候選者,取最後一筆作為當前最終 LCP
    const lcpEntry = entries[entries.length - 1];
    if (!lcpEntry) return;

    const navEntry = performance.getEntriesByType('navigation')[0];
    const ttfb = navEntry ? navEntry.responseStart : 0;
    const lcpTime = lcpEntry.startTime;

    // 若 LCP 候選者為圖片或影片海報,從 Resource Timing 尋找對應請求紀錄
    const resourceEntries = lcpEntry.url
      ? performance.getEntriesByName(lcpEntry.url)
      : [];
    const lcpResource = resourceEntries[resourceEntries.length - 1];

    let resourceLoadDelay = 0;
    let resourceLoadDuration = 0;
    let elementRenderDelay = 0;

    if (lcpResource) {
      const requestStart = Math.max(ttfb, lcpResource.requestStart || lcpResource.startTime);
      const responseEnd = Math.max(requestStart, lcpResource.responseEnd);

      resourceLoadDelay = requestStart - ttfb;
      resourceLoadDuration = responseEnd - requestStart;
      elementRenderDelay = Math.max(0, lcpTime - responseEnd);
    } else {
      // 若 LCP 候選者為純文字區塊(無獨立圖片 URL)
      elementRenderDelay = Math.max(0, lcpTime - ttfb);
    }

    const formatStat = (ms) =>
      `${Math.round(ms)}ms (${((ms / lcpTime) * 100).toFixed(1)}%)`;

    console.table({
      '總計 LCP (startTime)': `${Math.round(lcpTime)}ms`,
      '1. TTFB (首位元組時間)': formatStat(ttfb),
      '2. Resource Load Delay (資源載入延遲)': formatStat(resourceLoadDelay),
      '3. Resource Load Duration (資源載入耗時)': formatStat(resourceLoadDuration),
      '4. Element Render Delay (元素渲染延遲)': formatStat(elementRenderDelay),
      'LCP 目標元素': lcpEntry.element ? lcpEntry.element.tagName : '未知',
      '資源網址': lcpEntry.url || '(文字節點)'
    });
  });

  // buffered: true 確保能讀取到觀察器註冊前就已發生的繪製紀錄
  observer.observe({ type: 'largest-contentful-paint', buffered: true });
}

observeLcpSubParts();

三、 針對 LCP 四階段的對症調優策略

當透過上述腳本或 Chrome DevTools Performance 面板找出佔比異常的子階段後,可依循以下四個方向逐一排除:

  1. 縮短 TTFB(目標 < 800ms):
    • 為靜態 HTML 或全頁快取配置 CDN 邊緣節點(搭配 Cache-Control: public, s-maxage=... 與 stale-while-revalidate)。
    • 減少伺服器端多餘的跨網域或 HTTP 轉址鏈(每一次 Redirect 都會額外耗費一輪 DNS、TCP 與 TLS 交握時間)。
  2. 消除 Resource Load Delay(目標 < 10%):
    • 將首屏最大圖片直接寫在伺服器輸出的初始 HTML 中,避免由客戶端 JavaScript(CSR)執行後才動態建立 <img>。
    • 若因既有版型限制無法修改 HTML 結構而必須保留 CSS background-image,請在 <head> 補上 <link rel="preload" as="image" href="..." fetchpriority="high">,讓 Preload Scanner 能提前啟動下載。
  3. 縮短 Resource Load Duration(目標 ~40%):
  4. 消除 Element Render Delay(目標 < 10%):
    • 為 <head> 內的第三方或非首屏必要 <script> 加上 defer 或 async,移除阻塞首次渲染的同步腳本。
    • 精簡首屏關鍵 CSS(Critical CSS),避免過大的外部樣式表或主執行緒長任務(Long Tasks > 50ms)延後圖片解碼後的繪製時機。

四、 國外開發者論壇(Stack Overflow / Reddit)最常遇到的 6 個 Core Web Vitals LCP 疑問

1. 疑問一:為什麼將首屏橫幅寫在外部 CSS 的 background-image 會導致 LCP 的「資源載入延遲」大幅增加?

因為瀏覽器在解析 HTML 時設有兩個階段:主解析器(Main HTML Parser)與輕量級的預載掃描器(Preload Scanner)。
Preload Scanner 只會掃描 HTML 原始碼中的 <img src>、<picture> 與 <link rel="preload">;如果首屏橫幅寫在外部 style.css 的 .hero { background-image: url(hero.webp); },瀏覽器必須先下載完 style.css、解析出 CSSOM、等待 DOM 建構完成,並在組裝 Render Tree 時確認頁面上真的存在 .hero 元素且並非 display: none,才會發起圖片下載請求。這段空窗期就會全部算進 Resource Load Delay。

2. 疑問二:為什麼開啟全站圖片延遲載入(loading="lazy")後,首屏 LCP 分數反而退步?

許多 CMS 外掛或前端圖片元件會預設為所有 <img> 加上 loading="lazy"。然而,一旦首屏可視區內的 LCP 圖片帶有 loading="lazy",瀏覽器會刻意將它排除在初期預載佇列之外,必須等到完成樣式計算與佈局定位(Layout)、確認圖片座標落在視窗範圍內後,才開始排程下載。
解法:首屏第一張最大圖片務必設為 loading="eager"(或省略 loading 屬性),並搭配 fetchpriority="high"。

3. 疑問三:為什麼跨網域 CDN 上的首屏圖片,在 PerformanceObserver 中讀取 renderTime 會變成 0?

根據 W3C Largest Contentful Paint — Cross-origin image rendering time 規範,為了防止跨站時序攻擊(Timing Attacks),當首屏圖片放在不同網域(如獨立的圖片 CDN)且屬於跨域請求時,若該圖片的 HTTP 回應標頭未包含 Timing-Allow-Origin,瀏覽器會將 lcpEntry.renderTime 強制設為 0,並將 lcpEntry.startTime 退回等同於 lcpEntry.loadTime(資源下載完成時間)。
解法:在圖片 CDN 的回應標頭加上:

Timing-Allow-Origin: *

同時在 <img> 標籤設定對應的 crossorigin="anonymous",即可在效能監控工具中獲取精確的 renderTime。

4. 疑問四:頁面上如果沒有大張圖片,只有大標題與段落文字,為什麼 LCP 還是可能超過 2.5 秒?

當首屏沒有大圖時,面積最大的 <h1> 或首段 <p> 文字區塊就會成為 LCP 候選者。此時導致文字 LCP 變慢的常見原因有兩個:

  1. 網路字型(Web Fonts)載入阻塞:若 @font-face 未設定 font-display,瀏覽器預設會在字型下載期間隱藏文字(FOIT,最長達 3 秒),直到字型檔下載完成才繪製文字,導致 LCP 直接突破 2.5 秒。
  2. 客戶端渲染(CSR)延遲:若文字內容需等待 React / Vue 主套件下載並透過 fetch() 取得 API 資料後才寫入 DOM,則所有的 JS 執行與 API 等待時間都會算入 Element Render Delay。

解法:在 @font-face 設定 font-display: swap; 或 font-display: optional;,並確保首屏核心標題由伺服器端(SSR / SSG)直接輸出於初始 HTML。

5. 疑問五:如果對首屏輪播的 5 張圖片全部加上 fetchpriority="high",為什麼反而沒有加速效果?

因為瀏覽器的網路管線頻寬是固定的,fetchpriority="high" 的作用是改變單一資源在下載佇列中的相對排程順序。如果同時將 5 張高解析度輪播圖全部設為 fetchpriority="high",這 5 張圖片會同時搶佔首屏頻寬,反而拖慢第一張可見圖片的完成時間。
解法:一整頁中,只對首屏最初可見的第一張輪播圖(Slide 1)設定 fetchpriority="high";第 2 至第 5 張尚未顯示的輪播圖應維持預設優先權或加上 loading="lazy"。

6. 疑問六:為什麼本地 Chrome Lighthouse 測出來的 LCP 只有 1.2 秒,但 Search Console(CrUX)卻顯示超過 2.8 秒?

因為本地 Lighthouse 屬於實驗室數據(Lab Data),而在 Google Search Console 中顯示的 Core Web Vitals 則是來自 Chrome 使用者體驗報告(CrUX)的真實欄位數據(Field Data)。兩者出現落差通常源於以下三個原因:

  1. 快取與網路環境差異:本地測試常在固定光纖網路與已預熱的 CDN 快取下進行;而 CrUX 衡量的是過去 28 天真實訪客在**第 75 百分位數(p75)**的耗時,包含行動網路訊號不佳、跨國連線或快取過期(Cache Miss)時的冷啟動 TTFB。
  2. 未登入與個人化頁面差異:Lighthouse 通常只測公開首頁,而真實使用者可能帶著購物車 Session 或動態查詢參數(如 ?utm_source=...)進站,導致繞過 CDN 快取並直接打穿後端資料庫。
  3. LCP 候選者隨使用者操作停止更新的時間點不同:根據規範,一旦使用者點擊或滾動頁面,瀏覽器就會停止紀錄新的 LCP 候選者;若大型全屏 Cookie 同意橫幅在載入較晚時才彈出,也會覆蓋原本的 LCP 數據。

五、 總結

優化 Core Web Vitals LCP 的關鍵,不在於盲目壓縮圖片,而是先透過 TTFB、Resource Load Delay、Resource Load Duration、Element Render Delay 四階段拆解找出真正的時間缺口:讓首屏最大元素直接出現在初始 HTML 中、移除誤加的 loading="lazy"、為唯一一張首屏核心圖片賦予 fetchpriority="high",並淨空 <head> 中的渲染阻塞腳本,即可穩定將第 75 百分位數的 LCP 控制在 2.5 秒綠燈標準內。

在了解如何透過 <img fetchpriority="high"> 加速首屏大圖後,如果頁面上還有跨域字型、第三方 CDN 連線或需要在背景預載的關鍵資源,又該如何精準指揮瀏覽器的網路排程器?在下一篇「HTML Resource Hints 資源預載入:preload、preconnect、prefetch 與 dns-prefetch 排程機制」中,我們將深入比較四大 Resource Hints 的底層網路交握差異與正確配置時機。


參考資料與延伸閱讀

發表迴響