• 理解 Performance Timeline 的整體架構: 物件、 與各種 entry type。

  • 熟練使用 ,並知道為什麼它比 更適合真實使用者量測。

  • 完整掌握 Navigation Timing(Level 1 vs. Level 2)與 Resource Timing 的每個時間點與衍生計算。

  • 認識 Paint Timing、LCP、Event Timing、Layout Instability、Long Tasks / LoAF、User Timing、Server Timing 等 API。

  • 用 套件建立一個最小可用的 RUM(真實使用者監控)。

整體架構:Performance Timeline

所有效能量測 API 都掛在全域的 物件()上,並共用一條。時間線上的每一筆資料都是一個 ,有四個共同欄位:

L2.6cm L10.8cm 欄位 & 意義
& 識別名稱:資源的 URL、、你自訂的 mark 名稱等。
& 類型:、、、、、、、、、、……。
& 相對於 的高解析時間戳(毫秒,含小數)。
& 持續時間(對某些類型為 0)。

各類型再各自擴充欄位(例如 有 , 有 )。

高解析時間

  • :從 (大致等於導覽開始)算起的毫秒數,微秒級精度、單調遞增、不受系統時鐘調整影響。量測程式執行時間請一律用它,不要用 。

  • :時間線的零點,以 Unix epoch 毫秒表示。要把 換成絕對時間就用 。

  • 出於防止時序攻擊(如 Spectre)的考量,瀏覽器會對精度做粗化(Chrome 一般約 100 微秒,跨來源隔離的頁面可達 5 微秒)。

兩種取得 entry 的方式

// 方式一:同步查詢(拉取)。只拿得到「現在已經在緩衝區內」的資料。
performance.getEntries();                               // 全部
performance.getEntriesByType('resource');               // 依類型
performance.getEntriesByName('first-contentful-paint'); // 依名稱

// 方式二:觀察者(推送)。新 entry 產生時呼叫回呼;buffered: true 可補拿註冊前已發生的資料。
const po = new PerformanceObserver((list, observer) => {
  for (const entry of list.getEntries()) console.log(entry.entryType, entry);
});
po.observe({ type: 'largest-contentful-paint', buffered: true });
// 一個 observer 可對多個 type 各呼叫一次 observe();舊寫法 entryTypes: [...] 不支援 buffered。

// 檢查瀏覽器支援哪些類型
console.log(PerformanceObserver.supportedEntryTypes);
// 例:["element", "event", "first-input", "largest-contentful-paint", "layout-shift",
//      "long-animation-frame", "longtask", "mark", "measure", "navigation", "paint",
//      "resource", "visibility-state"]

po.takeRecords();   // 立即取出尚未交付的 entry(送資料前 flush 用)
po.disconnect();    // 停止觀察
  1. 有些類型(、、、)只能透過 observer 取得, 拿不到或不完整。

  2. 的緩衝區預設只有 250 筆,滿了之後新資料就丟掉(可用 調大,或監聽 事件),observer 則不受此限。

  3. 讓你可以把量測腳本放在頁面最後、甚至延遲載入,仍能拿到載入初期的資料。

  4. 回呼是非同步、批次的,不會阻塞主執行緒。

Level 1 vs. Level 2

L2.6cm L5.2cm L5.8cm & Level 1(2012) & Level 2(現行)
存取方式 & 、 & ,型別為
時間格式 & Unix epoch 毫秒(整數),要自己減 & 相對於 的高解析毫秒, 恆為 0
可否觀察 & 否 & 可用 ()
繼承關係 & 獨立介面 & 繼承 ,擁有其所有欄位(大小、協定、Server-Timing……)
新增欄位 & – & 、、、(prerender)、、(bfcache)、(Early Hints)
狀態 & 已棄用,僅為相容保留 & 建議使用

處理模型:完整時間線

圖 1.1 是 Navigation Timing Level 2 規範的處理模型。上排是網路階段(與 Resource Timing 共用),下排是文件處理階段。

max width=

Navigation Timing Level 2 處理模型。各屬性皆為相對於 的毫秒數;若使用持久連線或命中快取,DNS / 連線相關時間點會等於 。

常用的衍生計算

const t = performance.getEntriesByType('navigation')[0];
const d = (a, b) => +(b - a).toFixed(1);

const timings = {
  redirect:        d(t.redirectStart, t.redirectEnd),
  serviceWorker:   t.workerStart > 0 ? d(t.workerStart, t.fetchStart) : 0,
  dns:             d(t.domainLookupStart, t.domainLookupEnd),
  tcp:             d(t.connectStart, t.secureConnectionStart || t.connectEnd),
  tls:             t.secureConnectionStart > 0 ? d(t.secureConnectionStart, t.connectEnd) : 0,
  request:         d(t.requestStart, t.responseStart),   // 送出請求 → 首位元組:主要是伺服器處理 + 1 RTT
  ttfb:            d(0, t.responseStart),                 // 導覽開始 → 首位元組
  response:        d(t.responseStart, t.responseEnd),     // 下載 HTML 本體
  domParsing:      d(t.responseEnd, t.domInteractive),
  dclHandlers:     d(t.domContentLoadedEventStart, t.domContentLoadedEventEnd),
  subresources:    d(t.domContentLoadedEventEnd, t.domComplete),
  loadHandlers:    d(t.loadEventStart, t.loadEventEnd),
  total:           d(0, t.loadEventEnd),
  // 品質檢查
  protocol:        t.nextHopProtocol,                     // 'h2' / 'h3' / 'http/1.1'
  navigationType:  t.type,                                // 'navigate' | 'reload' | 'back_forward' | 'prerender'
  redirects:       t.redirectCount,
  fromCache:       t.transferSize === 0 && t.decodedBodySize > 0,
  compressed:      t.encodedBodySize < t.decodedBodySize,
  earlyHints:      t.firstInterimResponseStart > 0,       // 有收到 103 Early Hints
  prerendered:     t.activationStart > 0,                 // 頁面是由 prerender 啟用
};
console.table(timings);
  • 為 0 表示沒有用 TLS(或重用了連線);不支援的瀏覽器會是 。

  • 若連線被重用(keep-alive / HTTP/2), 到 全部等於 ,算出來的 DNS / TCP / TLS 都是 0——這是正確的,不是 bug。

  • 在 load 事件跑完之前為 0。在 事件處理器裡讀它會拿到 0,請在下一個 task(如 )或用 observer 讀。

  • 對 prerender 啟用的頁面,web.dev 建議所有指標都應改以 為零點( 套件已自動處理)。

  • 對從 bfcache 還原的頁面,Navigation Timing 不會重新產生; 會以 事件時間為零點另外回報一組指標。

Resource Timing:每一個子資源的時間線

為頁面載入的每個資源(CSS、JS、圖片、字型、XHR / fetch、iframe……)提供與上排相同的網路時間點,加上以下重要欄位:

L4.4cm L9cm 欄位 & 用途
& 誰發起的:、、、(CSS 內的 url())、、、、。
& / / 。用來檢查 CDN 或第三方是否還在用舊協定。
& 實際經網路傳輸的位元組(含標頭)。0 表示命中本機快取(且 );很小的值(約 300 以下)通常是 304 驗證回應。
/ & 壓縮前後的 body 大小。兩者相等代表沒有壓縮(該啟用 Brotli / gzip)。
& 或 :這個資源有沒有阻塞渲染。快速找出該延後的 CSS / JS。
& HTTP 狀態碼(Chrome 109+)。
& (來自 HTTP 快取)、(來自 Speculation Rules 預取)、或空字串。
& 若被 Service Worker 處理,SW 開始的時間; 即 SW 啟動成本。
& 伺服器透過 標頭送回的自訂計時(見下文)。
/ & 收到 103 Early Hints 與最終標頭的時間;兩者差即 Early Hints 爭取到的「提前量」。

出於隱私,跨來源資源預設只提供 、、、,其餘時間點為 0、大小欄位為 0。若你控制該資源的伺服器(例如自家 CDN),請加上回應標頭 (或列出允許的來源),瀏覽器才會提供完整資料。第三方腳本若沒加此標頭,你就只能看到總時長。

// 找出所有 render-blocking 且沒有壓縮、或走 HTTP/1.1 的資源
new PerformanceObserver((list) => {
  for (const r of list.getEntries()) {
    const issues = [];
    if (r.renderBlockingStatus === 'blocking') issues.push('render-blocking');
    if (r.encodedBodySize > 0 && r.encodedBodySize === r.decodedBodySize) issues.push('uncompressed');
    if (r.nextHopProtocol === 'http/1.1') issues.push('http/1.1');
    if (r.transferSize === 0 && r.decodedBodySize > 0) issues.push('cache-hit');
    if (issues.length) console.log(r.name, issues.join(', '), Math.round(r.duration) + 'ms');
  }
}).observe({ type: 'resource', buffered: true });

其他 entry type 一覽

Paint Timing()

兩筆 entry: 與 , 即 FP / FCP。新版規範另提供 (繪製階段開始)與 (像素真正上屏,實作相依)。

Largest Contentful Paint()

每個新的更大候選都會產生一筆 entry;欄位有 (= 或 )、(可見面積 px²)、、、。跨來源圖片若未附 , 可能為 0 或被粗化,此時以 為準。使用者互動後不再產生新 entry。

Event Timing(、)

  • :第一次互動,永遠會回報(FID 的來源)。

  • :所有事件,但預設只回報 \(\geq\) 104 ms 者;可用 降低門檻(最小 16)。

  • 關鍵欄位:(輸入時間)、(處理器開始)、(處理器結束)、(到下一畫格上屏,取 8 ms 粗化)、(同一次互動的事件共用;為 0 者不算互動)、。

  • 三個子部分即:輸入延遲 = ;處理時間 = ;呈現延遲 = 。

Layout Instability()

欄位:(分數)、、、(每個含 、、,依影響面積排序)。CLS 的 session window 計算需自行實作,或交給 。

Long Tasks()與 Long Animation Frames()

  • :超過 50 ms 的任務,只有 、 與粗略的 (哪個 frame),無法得知是哪段程式碼。

  • (LoAF,Chrome 123+):以「動畫畫格」為單位,超過 50 ms 就回報。多了 、、、(此畫格內有無使用者輸入),以及最重要的 :每個腳本的 (如 )、( / / ……)、、、、、。這是目前在真實使用者環境定位 INP 兇手最有效的工具。

if (PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
  new PerformanceObserver((list) => {
    for (const loaf of list.getEntries()) {
      if (!loaf.firstUIEventTimestamp) continue;          // 只關心有使用者輸入的畫格
      const worst = [...loaf.scripts].sort((a, b) => b.duration - a.duration)[0];
      console.log(`LoAF ${Math.round(loaf.duration)}ms blocking ${Math.round(loaf.blockingDuration)}ms`,
        worst && `${worst.invoker} @ ${worst.sourceURL}:${worst.sourceFunctionName} ${Math.round(worst.duration)}ms`);
    }
  }).observe({ type: 'long-animation-frame', buffered: true });
}

User Timing(、)

自訂的時間點與區間,會顯示在 DevTools Performance 面板的 Timings 軌道,也可被 RUM 收集。

performance.mark('search:start');
const results = await fetchResults(query);
performance.mark('search:fetched', { detail: { count: results.length } });
renderResults(results);
performance.mark('search:rendered');

performance.measure('search:network', 'search:start', 'search:fetched');
performance.measure('search:render',  'search:fetched', 'search:rendered');
performance.measure('search:total',   { start: 'search:start', end: 'search:rendered',
                                        detail: { query } });

new PerformanceObserver((list) => {
  for (const m of list.getEntries()) console.log(m.name, Math.round(m.duration), 'ms', m.detail);
}).observe({ type: 'measure', buffered: true });

Server Timing

伺服器可在回應標頭夾帶自己的計時,瀏覽器會掛在 / entry 的 上(跨來源需 )。這是把「TTFB 裡面伺服器到底在做什麼」帶到前端量測的橋樑,也是 CDN 常用來回報快取命中狀態的方式。

Server-Timing: cache;desc="HIT", db;dur=53, render;dur=47.2, cdn-cache;desc="MISS"
const nav = performance.getEntriesByType('navigation')[0];
for (const { name, duration, description } of nav.serverTiming) {
  console.log(name, description, duration);   // cache HIT 0 / db  53 / render  47.2 ...
}

Element Timing()

LCP 只告訴你「最大的那個」;若你關心某個特定元素(例如商品價格、CTA 按鈕)何時畫出來,可在元素上加 屬性並觀察 。目前為 Chromium 專有。

把它們串起來:web-vitals 套件

Google 維護的 套件(目前 v5)把上述 API 的細節(session window、互動去重、bfcache、prerender 校正、頁面隱藏時送出最終值)全部封裝好,只留下簡單的回呼介面。

npm install web-vitals
// 標準版:只要數值
import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

function sendToAnalytics(metric) {
  // metric: { name, value, rating: 'good'|'needs-improvement'|'poor', delta, id,
  //           navigationType: 'navigate'|'reload'|'back-forward'|'back-forward-cache'|'prerender'|'restore',
  //           entries: PerformanceEntry[] }
  const body = JSON.stringify({ [metric.name]: metric.value, rating: metric.rating,
                                id: metric.id, nav: metric.navigationType, url: location.pathname });
  // sendBeacon 在頁面卸載時也能可靠送出
  (navigator.sendBeacon && navigator.sendBeacon('/rum', body)) ||
    fetch('/rum', { body, method: 'POST', keepalive: true });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

attribution 版本:知道「為什麼」

import { onLCP, onINP, onCLS } from 'web-vitals/attribution';

onLCP(({ value, attribution: a }) => {
  console.log('LCP', Math.round(value), {
    element: a.target,                    // CSS selector(v5 由 element 改名為 target)
    url: a.url,
    ttfb: a.timeToFirstByte,
    loadDelay: a.resourceLoadDelay,
    loadDuration: a.resourceLoadDuration,
    renderDelay: a.elementRenderDelay,
  });
});

onINP(({ value, attribution: a }) => {
  console.log('INP', Math.round(value), {
    target: a.interactionTarget,          // 例如 'button#add-to-cart'
    type: a.interactionType,              // 'pointer' | 'keyboard'
    inputDelay: a.inputDelay,
    processing: a.processingDuration,
    presentation: a.presentationDelay,
    loadState: a.loadState,               // 互動發生在載入的哪個階段
    longestScript: a.longestScript?.entry?.sourceURL,   // 來自 LoAF
  });
});

onCLS(({ value, attribution: a }) => {
  console.log('CLS', value.toFixed(3), {
    largestShiftTarget: a.largestShiftTarget,
    largestShiftValue: a.largestShiftValue,
    largestShiftTime: a.largestShiftTime,
    loadState: a.loadState,
  });
});
  • 預設在頁面隱藏或卸載時才呼叫回呼一次(LCP 也可能在使用者互動時提早定案)。若要每次變化都回報,傳入 並用 累加。

  • 讓你能區分一般導覽、重新整理、bfcache 還原與 prerender,分析時務必分開看。

  • v5 移除了 ;INP 的 attribution 新增 LoAF 的最長腳本資訊。

  • 在 Safari / Firefox 上,LCP、CLS、INP 的回呼可能永遠不會被呼叫(API 不支援),這不是錯誤;設計後端時要容忍缺值。

課堂實作

把上面標準版 web-vitals 的程式碼加到一個小專案(或直接在任意頁面用 從 unpkg 載入 )。後端用最簡單的方式接收(例如 Express 一個 POST 路由印出 body)。開啟頁面、互動幾次、切換分頁再切回,觀察各指標何時被送出、 是什麼。

在自己的專案首頁執行本章的 render-blocking / uncompressed / http1.1 檢查腳本。列出:(1) 有幾個 render-blocking 資源?哪些可以加 或改為非阻塞?(2) 有沒有沒壓縮的文字資源?(3) 第三方來源有哪些走 HTTP/1.1?(4) 哪些第三方沒有 ,導致你只看得到總時長?

在一段非同步流程(例如搜尋、送出表單)前後加入 與 ,然後在 Performance 面板錄製並找到 Timings 軌道中你的自訂區段。觀察它與主執行緒活動、網路請求的對應關係。

  1. 為什麼 不適合量測程式執行時間?請舉一個 會給出負數區間的情境。

  2. 某資源的 是 0、 是 48,000;另一個資源 是 280、 是 48,000。這兩個資源分別發生了什麼事?

  3. 你在 事件處理器中印出 ,得到 0。為什麼?該怎麼正確取得?

  4. Level 1 的 與 Level 2 的 有何關係?把 Level 1 的 轉成 Level 2 的相對時間該怎麼算?

  5. 為什麼 entry 不足以找出 INP 的成因,而 可以?

  • 所有效能 API 共用一條 Performance Timeline;每筆 有 、、、。

  • 真實使用者量測請用 + ; 只適合 Console 探索。

  • Navigation Timing Level 2 的 繼承 Resource Timing,時間點從 一路到 ;Level 1 的 已棄用。

  • Resource Timing 的大小欄位可判斷快取命中與壓縮、 可查協定、 可找阻塞資源;跨來源需 。

  • LCP、Event Timing、Layout Instability、LoAF 分別是 LCP、INP、CLS 與「找兇手」的資料來源; 套件把這些封裝成五個函式與 attribution 資料。