Core Web Vitals 效能優化專家
建立摘要
LCP 優化 LCP CLS INP

Largest Contentful Paint API 完整指南:用 JavaScript 讀取 LCP 數值與生產整合

說明 LargestContentfulPaint JavaScript 介面的七個屬性、PerformanceObserver buffered 用法、renderTime 為 0 的跨來源診斷,以及 RUM 收集和 CI/CD 整合的生產注意事項。

陳

陳柏翰

網站效能工程師

4 分鐘閱讀
Largest Contentful Paint API 完整指南:用 JavaScript 讀取 LCP 數值與生產整合

Largest Contentful Paint API 完整指南:用 JavaScript 讀取 LCP 數值與生產整合

這篇講的是 LargestContentfulPaint JavaScript 介面,不是 LCP 優化技巧。如果你想知道 LCP 是什麼、怎麼壓低 LCP 分數,那要去看 LCP 優化指南。這裡只回答一件事:怎麼用 PerformanceObserver 在瀏覽器或 Node 環境裡讀取 LCP 數值,以及生產環境中遇到的那幾個坑。我在幾個不同規模的專案裡都踩過 renderTime 回 0 的問題,也處理過 CI pipeline 裡 LCP entry 讀不到的情況,整理在這裡。

Largest Contentful Paint API:PerformanceObserver 讀取 LCP 的完整指南
LargestContentfulPaint API 工程指南:屬性、陷阱與生產整合

LargestContentfulPaint API 是什麼:PerformanceEntry 的 LCP 子介面

LargestContentfulPaint 是一個繼承自 PerformanceEntry 的 JavaScript 介面,由瀏覽器在渲染最大可見元素時自動建立,透過 PerformanceObserver 觀測取得。它是 W3C Largest Contentful Paint 規範定義的量測工具,讓前端工程師能在 JavaScript 裡直接讀取 LCP 計時,而不必依賴 Lighthouse 或 PageSpeed Insights 這些外部工具。

這個介面屬於 Performance Timeline API 家族。類似的兄弟介面有 PerformancePaintTiming(量測 FCP/FP)和 PerformanceEventTiming(量測 INP),設計邏輯都一樣:瀏覽器把計時事件包成 PerformanceEntry 物件,工程師用 PerformanceObserver 訂閱。

能觸發 LCP entry 的元素類型有這幾種:

  • <img> 元素
  • SVG 內的 <image> 元素
  • <video> 元素的封面圖(poster)
  • 有 background-image 的元素
  • 文字塊(段落、標題等文字節點群)

W3C 規範在 2025 年進入 Baseline,Chrome、Edge、Firefox、Safari 全部支援。在此之前只有 Blink-based 瀏覽器(Chrome、Edge、Opera)支援。

七個屬性逐一說明:element、renderTime、loadTime、size、url、id、startTime

LargestContentfulPaint 介面提供七個屬性,最常用的是 element(觸發 LCP 的 DOM 元素)和 startTime(渲染時間,跨來源安全的版本)。其他屬性用於進階診斷。

LargestContentfulPaint 介面的七個屬性:element、renderTime、loadTime、size、url、id、startTime
LargestContentfulPaint 七個屬性一覽,含資料型態與常見注意事項
LargestContentfulPaint 屬性完整說明
屬性 型態 說明 注意事項
element Element | null 目前觸發 LCP 的 DOM 元素參照 使用者互動後元素可能已被移除,變成 null
renderTime DOMHighResTimeStamp 元素實際渲染到螢幕的時間(ms) 跨來源資源未設 Timing-Allow-Origin 時回 0
loadTime DOMHighResTimeStamp 元素完成載入的時間(圖片為下載完成) 文字元素此值為 0
size number 元素顯示面積(width × height,單位 px²) 比較的是面積,不是寬或高單邊
url string 若 LCP 元素是圖片,回傳圖片 URL 文字元素或非圖片元素回傳空字串
id string LCP 元素的 id 屬性值 無 id 的元素回傳空字串
startTime DOMHighResTimeStamp 等於 renderTime;跨來源圖片時等於 loadTime 一般優先用 startTime,跨來源安全

startTime 的設計邏輯是:它等於 renderTime,但當 renderTime 因跨來源限制被遮蔽為 0 時,startTime 會改為回傳 loadTime。所以大多數情況直接用 startTime 是最穩的。

基本用法:PerformanceObserver 搭配 buffered:true

用 PerformanceObserver 觀測 largest-contentful-paint 這個 entry type,並且一定要加 buffered: true,否則在頁面早期就完成渲染的 LCP entry 會讀不到。

const observer = new PerformanceObserver((entryList) => {
  const entries = entryList.getEntries();
  // LCP 可能有多筆,最後一筆才是最終值
  const lastEntry = entries[entries.length - 1];
  console.log('LCP startTime:', lastEntry.startTime);
  console.log('LCP element:', lastEntry.element);
  console.log('LCP size:', lastEntry.size);
  console.log('LCP url:', lastEntry.url || '(文字元素)');
});

observer.observe({ type: 'largest-contentful-paint', buffered: true });

為什麼要 buffered: true?因為 PerformanceObserver 預設只接收它建立後才發生的 entry。如果 LCP 元素在 Observer 建立前就已渲染(這在快速載入的頁面很常見),不加這個選項就會讀到空陣列。buffered: true 讓 Observer 一建立就先把快取裡的舊 entry 全部交出來。

完整的、適合生產的收集方式會加上 visibilitychange 事件,確保在頁面被隱藏前取出最後的 LCP 值(詳見 H2-5 和 H2-6)。

renderTime 為 0 的原因與 Timing-Allow-Origin 修復方式

如果你讀到 renderTime === 0,十之八九是跨來源圖片沒有回傳 Timing-Allow-Origin header。修復方式是讓圖片伺服器回傳這個 header,或改用 startTime 代替。

診斷流程:

  1. 確認 LCP 元素是圖片(entry.element?.tagName === 'IMG')
  2. 確認圖片 URL 與當前頁面不同來源(不同 origin)
  3. 在 DevTools Network 面板確認圖片回應是否有 Timing-Allow-Origin: * 或具體 origin
  4. 如果有 CDN,確認 CDN 沒有把這個 header 過濾掉

在圖片伺服器或 CDN 加上這個 header:

Timing-Allow-Origin: https://speedpark-koinoura.com
# 或允許所有來源(公開資源可以這樣設)
Timing-Allow-Origin: *

如果改不了圖片伺服器,改用 startTime 當 fallback:

const lcpTime = entry.renderTime || entry.startTime;

需要注意:startTime 在跨來源情況下等於 loadTime(圖片下載完成時間),而不是真正的渲染完成時間,會比實際稍微早一點。但這是沒有 Timing-Allow-Origin 時唯一能拿到的近似值。

如果你想用程式碼識別 LCP 元素是哪一個,詳細的元素識別方法可以參考 LCP 元素識別完整指南,裡面有 DevTools 和 JavaScript 兩種方式的對比。

為什麼 LCP 有多筆紀錄?最後一筆才算最終值

LCP entry 不是只有一筆。每次瀏覽器渲染了一個比目前最大元素還大的元素時,就會產生一筆新的 LargestContentfulPaint entry。最終的 LCP 值是使用者發生第一次互動或頁面被隱藏之前,最後一筆 entry 的 startTime。

什麼事件會讓 LCP 停止更新?

  • 使用者點擊(pointerdown)
  • 使用者按鍵(keydown)
  • 頁面被隱藏(visibilitychange 到 hidden)
  • 頁面完全卸載

所以正確的收集方式是:Observer 持續觀測,只在頁面即將隱藏或互動前,取 entries 陣列的最後一筆。直接在 observer callback 裡每次都處理「最新的那筆」是對的,因為 getEntries() 回傳的是從觀測開始到現在的所有 entry,最後一筆就是目前為止最大的。

let lcpValue = 0;
let lcpElement = null;

const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];
  lcpValue = last.startTime;
  lcpElement = last.element;
});

observer.observe({ type: 'largest-contentful-paint', buffered: true });

// 在頁面隱藏時確認最終值
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') {
    observer.disconnect();
    // 此時 lcpValue 是最終 LCP
    sendToRUM({ lcp: lcpValue, element: lcpElement?.tagName });
  }
}, { once: true });

這樣的模式確保你收到的是使用者真實體驗的 LCP,而不是頁面還在繼續渲染時的中間值。

背景分頁量測陷阱:hiddenTime 過濾的實作

如果頁面在背景分頁(background tab)載入,或被 prefetch 預先載入,LCP entry 的時間不反映真實使用者體驗,需要用 hiddenTime 過濾掉。

原因:當頁面在背景載入時,瀏覽器不會實際繪製內容到螢幕,但還是會產生 LCP entry。這些 entry 的 startTime 記錄的是資源載入時間,不是使用者看到內容的時間。把這些值送到 RUM 系統會污染你的效能數據。

// 記錄頁面第一次被隱藏的時間
let hiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;

document.addEventListener('visibilitychange', (event) => {
  hiddenTime = Math.min(hiddenTime, event.timeStamp);
}, { once: true });

let lcpValue = 0;

const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];

  // 只接受在頁面顯示期間發生的 LCP
  if (last.startTime < hiddenTime) {
    lcpValue = last.startTime;
  }
});

observer.observe({ type: 'largest-contentful-paint', buffered: true });

邏輯是:如果頁面一開始就是 hidden(document.visibilityState === 'hidden'),把 hiddenTime 設為 0,這樣所有 LCP entry 都會被過濾。如果頁面從顯示狀態開始,hiddenTime 是 Infinity,所有 entry 都通過,直到第一次 visibilitychange。

這在 web-vitals library 裡已經內建處理了,這也是生產環境建議用 library 而不是自己寫的原因之一。

renderTime、loadTime、startTime 的差異與選用建議

這三個時間值的差異在文件裡不容易看清楚,但規則很簡單:一般情況下直接用 startTime 就好,它是 renderTime 的跨來源安全版本,在 renderTime 被遮蔽時自動 fallback 到 loadTime。

renderTime、loadTime、startTime 三個屬性的比較
屬性 代表的時間點 跨來源圖片 文字元素 建議用途
renderTime 瀏覽器完成渲染的時間 回 0(被遮蔽) 渲染完成時間 同源圖片診斷
loadTime 資源完成載入的時間 有值(下載完成) 回 0 排查下載延遲
startTime = renderTime;若 renderTime=0 則 = loadTime 回 loadTime 渲染完成時間 一般使用,RUM 上報

實測案例:一個使用 Cloudflare Image Resizing 的電商首頁,hero image 來自 cdn.example.com,沒有設 Timing-Allow-Origin。renderTime 讀到 0ms,loadTime 讀到 1,240ms,startTime 同樣是 1,240ms(等於 loadTime)。實際渲染時間接近 1,380ms,差了約 140ms,是 CSS 處理和 paint 的時間。這個誤差在 RUM 趨勢分析中可以接受,但如果要精確到個位數診斷,就需要設 Timing-Allow-Origin。

TTFB 的優化同樣影響 LCP 的 loadTime,兩者的關聯在 TTFB 優化完整指南中有詳細說明,可以搭配這裡的子階段分析一起看。

生產 RUM 要用 web-vitals library,不要直接寫原生 API

如果你是在生產環境收集 RUM 數據,用 web-vitals library 而不是自己實作 PerformanceObserver。原生 API 適合學習和除錯,library 處理了一堆邊緣案例,省去大量時間。

import { onLCP } from 'web-vitals';

onLCP((metric) => {
  console.log('LCP:', metric.value, 'ms');
  console.log('LCP element:', metric.entries[0]?.element);
  console.log('LCP attribution:', metric.attribution);

  // 送到 GA4
  gtag('event', 'web_vitals', {
    metric_name: metric.name,
    metric_value: Math.round(metric.value),
    metric_rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
    metric_id: metric.id,
  });
});

web-vitals library 幫你處理了什麼:

  • buffered: true 設定
  • hiddenTime 過濾
  • Firefox 的 feature detection(Firefox 直到 2025 才支援 LCP API)
  • bfcache 恢復時的 LCP 重新測量
  • metric.attribution 提供更詳細的 LCP 子階段資訊
  • 版本穩定的 API,不需要追 W3C spec 更新

什麼情況才直接用原生 API?如果你在寫 DevTools extension、效能診斷工具、或測試腳本,原生 API 更靈活。另外如果你不想引入 library 依賴、頁面本身就是效能工具的一部分,原生 API 也是合理選擇。

用 API 拆解 LCP 子階段:找出 TTFB 還是渲染延遲是瓶頸

LCP 由四個子階段組成,光看最終數值不知道哪段最慢,要拆開來看。LCP API 配合 PerformanceResourceTiming 和 PerformanceNavigationTiming 可以計算每個子階段的耗時。

LCP 四個子階段流程:TTFB、資源發現延遲、資源載入時間、渲染延遲的計算方式
LCP 子階段拆解:四段耗時加總等於最終 LCP startTime

四個子階段定義:

  • TTFB:從請求發出到第一個位元組到達(navigationEntry.responseStart)
  • 資源發現延遲:TTFB 到瀏覽器開始請求 LCP 資源的時間差
  • 資源載入時間:從開始請求到資源完成載入(lcpEntry.loadTime - resourceEntry.requestStart)
  • 渲染延遲:資源載入完成到實際渲染到螢幕(lcpEntry.startTime - lcpEntry.loadTime)
function getLCPSubparts() {
  return new Promise((resolve) => {
    const observer = new PerformanceObserver((list) => {
      const entries = list.getEntries();
      const lcp = entries[entries.length - 1];

      const navEntry = performance.getEntriesByType('navigation')[0];
      const ttfb = navEntry.responseStart;

      const resourceEntry = performance.getEntriesByName(lcp.url)?.[0];
      const resourceStart = resourceEntry?.requestStart ?? 0;
      const resourceLoadTime = lcp.loadTime
        ? lcp.loadTime - resourceStart
        : 0;
      const renderDelay = lcp.startTime - (lcp.loadTime || lcp.startTime);

      observer.disconnect();
      resolve({
        ttfb: Math.round(ttfb),
        resourceDiscoveryDelay: Math.round(resourceStart - ttfb),
        resourceLoadTime: Math.round(resourceLoadTime),
        renderDelay: Math.round(renderDelay),
        totalLCP: Math.round(lcp.startTime),
      });
    });

    observer.observe({ type: 'largest-contentful-paint', buffered: true });
  });
}

getLCPSubparts().then(console.table);

典型的診斷結果:

LCP 子階段耗時範例(行動裝置,3G 模擬)
子階段 耗時 佔比 優化目標
TTFB 480ms 17% < 200ms(CDN、Server-Timing 診斷)
資源發現延遲 620ms 22% < 200ms(preload、fetchpriority)
資源載入時間 1,450ms 51% < 500ms(圖片格式、CDN、壓縮)
渲染延遲 280ms 10% < 50ms(render-blocking 資源)

這個案例的瓶頸是資源載入時間(1,450ms),主要來自圖片體積過大。解法是換 AVIF 格式並設 CDN 快取。如果瓶頸是資源發現延遲,通常是 LCP 圖片沒有 fetchpriority="high" 或缺少 preload。

在 CI/CD 中用 Puppeteer 讀取 LCP API 數值

Lighthouse CI 可以量測 LCP,但有時你想要更細的控制:指定測試條件、取得 element 資訊、或整合進自訂的效能預算系統。Puppeteer 搭配 CDP(Chrome DevTools Protocol)可以直接讀取 PerformanceObserver 的資料。

import puppeteer from 'puppeteer';

async function measureLCP(url) {
  const browser = await puppeteer.launch({ headless: true });
  const page = await browser.newPage();

  // 在頁面導航前先注入 observer
  await page.evaluateOnNewDocument(() => {
    window.__lcpEntries = [];
    new PerformanceObserver((list) => {
      window.__lcpEntries.push(...list.getEntries().map((e) => ({
        startTime: e.startTime,
        size: e.size,
        url: e.url,
        element: e.element?.tagName,
      })));
    }).observe({ type: 'largest-contentful-paint', buffered: true });
  });

  await page.goto(url, { waitUntil: 'networkidle0' });

  // 模擬使用者互動以凍結 LCP
  await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));

  const lcpEntries = await page.evaluate(() => window.__lcpEntries);
  const finalLCP = lcpEntries[lcpEntries.length - 1];

  await browser.close();
  return finalLCP;
}

measureLCP('https://speedpark-koinoura.com').then((lcp) => {
  console.log(`LCP: ${Math.round(lcp.startTime)}ms`);
  console.log(`Element: ${lcp.element}`);
  if (lcp.startTime > 2500) {
    console.error('LCP exceeds 2500ms threshold');
    process.exit(1);
  }
});

在 GitHub Actions 的使用方式:把這個腳本加入 CI workflow,設定效能預算,超過閾值就讓 build fail。這比 Lighthouse CI 更輕量,因為你只需要量 LCP,不需要跑完整的 Lighthouse audit。

搭配 Chrome DevTools Performance 面板的 trace 分析,兩者互補診斷。API 給你程式可讀的數值,DevTools Performance 面板給你視覺化的 trace 火焰圖,幫你找到 Long Task 和阻塞資源的精確位置。

小結:LargestContentfulPaint API 使用重點

整理一下本文的核心要點,以下幾點是工程師在真實專案中最常遇到的問題,先掌握這些就能涵蓋大部分的 LCP API 使用場景:

  • 用 PerformanceObserver + buffered: true 觀測 largest-contentful-paint entry type
  • LCP 會有多筆 entry,最後一筆(使用者互動前)才是最終值
  • renderTime 跨來源可能為 0,改用 startTime 或設 Timing-Allow-Origin
  • 背景分頁要用 hiddenTime 過濾,避免污染 RUM 數據
  • 生產 RUM 用 web-vitals library,原生 API 留給除錯和工具開發
  • 子階段拆解可以找到 TTFB、資源載入、渲染延遲哪段是主要瓶頸
  • CI/CD 可用 Puppeteer + evaluateOnNewDocument 讀取 LCP entry

如果你剛開始優化 LCP,先看 LCP 優化指南的四段時間模型,從整體框架理解之後,再回來用 API 做精確量測和自動化監控,這樣的順序比較有效率。

陳

陳柏翰

網站效能工程師

專注前端效能優化超過 8 年,曾協助電商、媒體和 SaaS 平台改善 Core Web Vitals 指標。相信效能優化應該基於數據而非直覺。

50+ 效能實驗
8 年 前端經驗
200+ 優化案例

想看更多?

探索所有效能實驗

查看完整的實驗報告列表,找到你需要的效能優化方案。

所有實驗報告 免費效能診斷