Largest Contentful Paint API 完整指南:用 JavaScript 讀取 LCP 數值與生產整合
說明 LargestContentfulPaint JavaScript 介面的七個屬性、PerformanceObserver buffered 用法、renderTime 為 0 的跨來源診斷,以及 RUM 收集和 CI/CD 整合的生產注意事項。
陳柏翰
網站效能工程師
Largest Contentful Paint API 完整指南:用 JavaScript 讀取 LCP 數值與生產整合
這篇講的是 LargestContentfulPaint JavaScript 介面,不是 LCP 優化技巧。如果你想知道 LCP 是什麼、怎麼壓低 LCP 分數,那要去看 LCP 優化指南。這裡只回答一件事:怎麼用 PerformanceObserver 在瀏覽器或 Node 環境裡讀取 LCP 數值,以及生產環境中遇到的那幾個坑。我在幾個不同規模的專案裡都踩過 renderTime 回 0 的問題,也處理過 CI pipeline 裡 LCP entry 讀不到的情況,整理在這裡。
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(渲染時間,跨來源安全的版本)。其他屬性用於進階診斷。
| 屬性 | 型態 | 說明 | 注意事項 |
|---|---|---|---|
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 代替。
診斷流程:
- 確認 LCP 元素是圖片(
entry.element?.tagName === 'IMG') - 確認圖片 URL 與當前頁面不同來源(不同 origin)
- 在 DevTools Network 面板確認圖片回應是否有
Timing-Allow-Origin: *或具體 origin - 如果有 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 |
瀏覽器完成渲染的時間 | 回 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 可以計算每個子階段的耗時。
四個子階段定義:
- 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);
典型的診斷結果:
| 子階段 | 耗時 | 佔比 | 優化目標 |
|---|---|---|---|
| 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-paintentry 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 做精確量測和自動化監控,這樣的順序比較有效率。