前端效能量化標準

過客匆匆_發表於2018-05-30

我們經常能看到大量介紹前端如何進行效能優化的文章。然而很多文章只介紹瞭如何優化效能,卻未能給出一個可計算,可採集的效能量化標準。甚至看到一些文章,在介紹自己做了優化後的效能時,提到頁面載入速度提升了多少多少,但是當你去問他你怎麼測量效能的時,卻不能給出一個科學的、通用的方法。
其實,在進行效能優化前,首先需要確定效能衡量標準。前端效能大致分為兩塊,頁面載入效能和頁面渲染效能。頁面載入效能指的是我們通常所說的首屏載入效能。頁面渲染效能指的是使用者在操作頁面時頁面是否能流暢執行。滾動應與手指的滑動一樣快,並且動畫和互動應如絲般順滑。這兩種頁面效能,都需要有可量化的衡量標準。
本文參考了谷歌提出的效能衡量方式。首先確定以使用者體驗為中心的效能衡量標準。然後,針對這些效能標準,制定採集效能資料的方法,以及效能資料分析方法。最後,結合效能量化標準,提出優化效能的方法。

頁面載入效能

效能度量標準

下表是與頁面載入效能相關的使用者體驗。

使用者體驗 描述
它在發生嗎? 網頁瀏覽順利開始了嗎?服務端有響應嗎?
它是否有用? 使用者是否能看到足夠的內容?
它是否可用? 使用者是否可以和頁面互動,還是頁面仍在忙於載入?
它是否令人愉快的? 互動是否流程和自然,沒有卡段或閃爍?

與使用者體驗相關,制定以下度量標準:

  • First paint and first contentful paint (它在發生嗎?)
    FP 和 FCP 分別是頁面首次繪製和首次內容繪製。首次繪製包括了任何使用者自定義的背景繪製,它是首先將畫素繪製到螢幕的時刻。首次內容繪製是瀏覽器將第一個 DOM 渲染到螢幕的時間。該指標報告了瀏覽器首次呈現任何文字、影像、畫布或者 SVG 的時間。這兩個指標其實指示了我們通常所說的白屏時間。
    參考 api: https://w3c.github.io/paint-timing/
    在控制檯檢視 paint 效能:

    window.performance.getEntriesByType(`paint`)

    在程式碼中檢視 paint 效能:

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // `entry` is a PerformanceEntry instance.
        console.log(entry.entryType);
        console.log(entry.startTime);
        console.log(entry.duration);
      }
    });
    
    // register observer for long task notifications
    observer.observe({entryTypes: ["paint"]});

    ssr:

    csr:

  • First meaningful paint and hero element timing(它是否有用?)
    FMP(首次有意義繪製) 是回答“它是否有用?”的度量標準。因為很難有一個通用標準來指示所有的頁面當前時刻的渲染達是否到了有用的程度,所以當前並沒有制定標準。對於開發者,我們可以根據自己的頁面來確定那一部分是最重要的,然後度量這部分渲染出的時間作為FMP。

    chrome 提供的效能分析工具 Lighthouse 可以測量出頁面的 FMP,在查閱了一些資料後,發現 Lighthouse 使用的演算法是:頁面繪製佈局變化最大的那次繪製(根據 頁面高度/螢幕高度 調節權重)

    First meaningful paint = Paint that follows biggest layout change
    layout significance = number of layout objects added / max(1, page height / screen height)

    參考:Time to First Meaningful Paint: a layout-based approach

    ssr:

    csr:

  • Long tasks(它是否令人愉快的?)
    我們知道,js 是單執行緒的,js 用事件迴圈的方式來處理各個事件。當使用者有輸入時,觸發相應的事件,瀏覽器將相應的任務放入事件迴圈佇列中。js 單執行緒逐個處理事件迴圈佇列中的任務。
    如果有一個任務需要消耗特別長的時間,那麼佇列中的其他任務將被阻塞。同時,js 執行緒和 ui 渲染執行緒是互斥的,也就是說,如果 js 在執行,那麼 ui 渲染就被阻塞了。此時,使用者在使用時將會感受到卡頓和閃爍,這是當前 web 頁面不好的使用者體驗的主要來源。
    Lonag tasks API 認為一個任務如果超過了 50ms 那麼可能是有問題的,它會將這些任務展示給應用開發者。選擇 50ms 是因為這樣才能滿足RAIL 模型 中使用者響應要在 100ms 內的要求。

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        // `entry` is a PerformanceEntry instance.
        console.log(entry.entryType);
        console.log(entry.startTime); // DOMHighResTimeStamp
        console.log(entry.duration); // DOMHighResTimeStamp
      }
    });
    
    // register observer for long task notifications
    observer.observe({entryTypes: [`longtask`]});

    發散出去,React 最新的 Fiber 架構。就是為了解決 js 程式碼在執行過程中的 Long tasks 問題。reconciliation (協調器) 是 React 用於 diff 虛擬 dom 樹並決定哪一部分需要更新的演算法。協調器在不同的渲染平臺是可以共用的(web, native)。而 react 之前的設計中,是一次性計算完子樹的更新結果,然後立刻重新渲染出來。這樣就很容易造成 Long tasks 問題。Fiber 架構就是為了解決這個問題,Fiber 的核心就是把長任務拆成多個短任務,並分配有不同的優先順序,然後對這些任務進行排程執行,從而達將重要內容先渲染並且不阻塞 gui 渲染執行緒的目的。

  • Time to interactive(它是否可用?)
    TTI(可互動時間) 指的是應用既在視覺上都已渲染出了,又可以響應使用者的輸入了。應用不能響應使用者輸入的原因主要包括:

    • 使得頁面上的元件能工作的 js 還未載入
    • 長任務阻塞了主執行緒

    TTI 指明瞭頁面的 js 指令碼都被載入完成且主執行緒處於空閒狀態了的時間。

在使用者裝置中測量效能

下面是一段開發者經常用來 hack 檢查頁面中長任務的程式碼:

// detect long tasks hack
    
(function detectLongFrame() {
  var lastFrameTime = Date.now();
  requestAnimationFrame(function() {
    var currentFrameTime = Date.now();

    if (currentFrameTime - lastFrameTime > 50) {
      // Report long frame here...
    }

    detectLongFrame(currentFrameTime);
  });
}());    

hack 方式存在一些副作用:

  • 給每一幀渲染新增額外負擔
  • 它防止了空閒塊
  • 非常影響電池壽命

效能測量的程式碼最重要的準則是它不該使效能變差。

本地開發時效能的測量

LighthouseWeb Page Test 為我們本地開發提供了非常好的效能測試工具,而且對於我們前面提到的各項測量標準都有較好的支援。但是,這些工具不能在使用者的機器上執行,所以它們不能反映使用者真實的使用者體驗。

使用者裝置中效能的測量

幸運的是,隨著新 API 的推出,我們可以再使用者裝置上測量這些效能而不需要付出用可能使效能變差的 hack 的方式。
這些新的 API 是 PerformanceObserver, PerformanceEntry, 以及 DOMHighResTimeStamp

  • 測量 FP/FCP
// 效能度量結果物件陣列
const metrics = [];

if (`PerformanceLongTaskTiming` in window) {
  const observer = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      const metricName = entry.name;
      const time = Math.round(entry.startTime + entry.duration);
      metrics.push({
        eventCategory: `Performance Metrics`,
        eventAction: metricName,
        eventValue: time,
        nonInteraction: true
      });
    }
  });
  observer.observe({ entryTypes: [`paint`] });
}
  • 用關鍵元素測量 FMP

標準中並未定義 FMP,我們需要根據頁面的實際情況來定 FMP。一個較好的方式是測量頁面關鍵元素渲染的時間。參考文章 User Timing and Custom Metrics

測量 css 載入完成時間:

<link rel="stylesheet" href="/sheet1.css">
<link rel="stylesheet" href="/sheet4.css">
<script>
performance.mark("stylesheets done blocking");
</script>

測量關鍵圖片載入完成時間:

<img src="hero.jpg" onload="performance.clearMarks(`img displayed`); performance.mark(`img displayed`);">
<script>
performance.clearMarks("img displayed");
performance.mark("img displayed");
</script>

測量文字類元素載入完成時間:

<p>This is the call to action text element.</p>
<script>
performance.mark("text displayed");
</script>

計算載入時間:

function measurePerf() {
  var perfEntries = performance.getEntriesByType("mark");
  for (var i = 0; i < perfEntries.length; i++) {
    console.log("Name: " + perfEntries[i].name +
      " Entry Type: " + perfEntries[i].entryType +
      " Start Time: " + perfEntries[i].startTime +
      " Duration: "   + perfEntries[i].duration  + "
");
  }
}
  • 測量 TTI

採用谷歌提供的 tti-polyfill

import ttiPolyfill from `./path/to/tti-polyfill.js`;

ttiPolyfill.getFirstConsistentlyInteractive().then((tti) => {
  ga(`send`, `event`, {
    eventCategory: `Performance Metrics`,
    eventAction: `TTI`,
    eventValue: tti,
    nonInteraction: true,
  });
});

TTI 標準定義文件

  • 測量 Long Tasks
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    ga(`send`, `event`, {
      eventCategory: `Performance Metrics`,
      eventAction: `longtask`,
      eventValue: Math.round(entry.startTime + entry.duration),
      eventLabel: JSON.stringify(entry.attribution),
    });
  }
});

observer.observe({entryTypes: [`longtask`]});

資料分析

當我們收集了使用者側的效能資料,我們需要把這些資料用起來。真實使用者效能資料是十分有用的,原因包括:

  • 驗證應用效能是否達到了期望
  • 定位影響應用轉化率的糟糕效能的地方
  • 找到能提升使用者體驗的地方並使使用者愉快

下面是一個用圖表來分析資料的例子:

這個例子展示了 PC 端和移動端的 TTI 分佈。可以看到移動端的 TTI 普遍長於 PC 端。

PC 端:

比例 TTI(seconds)
50% 2.3
75% 4.7
90% 8.3

移動端:

比例 TTI(seconds)
50% 3.9
75% 8.0
90% 12.6

對這些圖表使的分析得我們能快速地瞭解到真實使用者的體驗。從上面的表格我們能看到,10% 的移動端使用者在 12s 後才能開始頁面互動!

效能是如何影響商業的

利用使用者側效能資料,我們可以分析效能是如何影響商業的。例如,如果你想分析目標達成率或者電商轉化率:

  • 有更快可互動時間的使用者是否會買更多商品
  • 在付款時如果有更多的 Long Tasks,使用者是否有更高的概率放棄

如果證明他們之間是有關聯的,那麼這就很容易闡述效能對業務的重要性,且效能是應該被優化的。

放棄載入

我們知道,如果頁面載入時間過長,使用者就會經常選擇放棄。不幸的是,這就意味著我們所有采集到的效能資料存在著倖存者偏差——效能資料不包括那些因為放棄載入頁面的使用者(一般都是因為載入時間過長)。
統計使用者放棄載入會比較麻煩,因為一般我們將埋點指令碼放在較後載入。使用者放棄載入頁面時,可能我們的埋點指令碼還未載入。但是谷歌資料分析服務提供了Measurement Protocol 。利用它可以進行資料上報:

<script>
window.__trackAbandons = () => {
  // Remove the listener so it only runs once.
  document.removeEventListener(`visibilitychange`, window.__trackAbandons);
  const ANALYTICS_URL = `https://www.google-analytics.com/collect`;
  const GA_COOKIE = document.cookie.replace(
    /(?:(?:^|.*;)s*_gas*=s*(?:w+.d.)([^;]*).*$)|^.*$/, `$1`);
  const TRACKING_ID = `UA-XXXXX-Y`;
  const CLIENT_ID =  GA_COOKIE || (Math.random() * Math.pow(2, 52));

  // Send the data to Google Analytics via the Measurement Protocol.
  navigator.sendBeacon && navigator.sendBeacon(ANALYTICS_URL, [
    `v=1`, `t=event`, `ec=Load`, `ea=abandon`, `ni=1`,
    `dl=` + encodeURIComponent(location.href),
    `dt=` + encodeURIComponent(document.title),
    `tid=` + TRACKING_ID,
    `cid=` + CLIENT_ID,
    `ev=` + Math.round(performance.now()),
  ].join(`&`));
};
document.addEventListener(`visibilitychange`, window.__trackAbandons);
</script>

需要注意的是,在頁面載入完成後,我們要移除監聽,因為此時監聽使用者放棄載入已經沒有意義,因為已經載入完成。

document.removeEventListener(`visibilitychange`, window.__trackAbandons);

優化頁面載入效能

我們定義了以使用者為中心的效能量化標準,就是為了指導我們優化效能。
最簡單的優化效能的方式是減少需要傳輸給客戶端的 js 程式碼。但是如果我們已經無法縮小 js 程式碼體積,那就需要思考如何傳輸我們的 js 程式碼。

優化 FP/FCP

  • <head> 移除影響 FP/FCP 的 css 和 js 程式碼
  • 將影響首屏渲染的關鍵 css 程式碼最小集合直接 inline 寫在 <head>
  • 對 react 這種客戶端渲染框架,做 ssr
  • 本地快取

優化 FMP/TTI

  • 首先需要確定頁面中的最關鍵元素,例如專題中的視訊元件,然後需要保證關鍵元件相關的程式碼最先載入並且使得關鍵元件在第一時間被渲染且可互動
  • 圖片懶載入,元件懶載入
  • 其他一些對渲染關鍵元件無用的程式碼可以延緩載入
  • 減少 html dom 個數和層數
  • 儘量縮減 FMP 和 TTI 的時間間隔,最好讓使用者知道當前頁面並未完全可互動。如果使用者想要互動但是頁面沒有響應,那麼使用者會感到不爽

防止 long tasks

  • 將程式碼分割,並對給不同程式碼分配不同的載入優先順序。不僅能加快頁面互動時間,而且可以減少 long tasks
  • 對於執行時間特別長的程式碼,可以嘗試讓他們分為幾個非同步執行的程式碼塊
if (`requestIdleCallback` in window) {
  // Use requestIdleCallback to schedule work.
} else {
  // Do what you’d do today.
}
  • 測試第三方的 js 庫,保證不影響執行時間

頁面渲染效能

TODO:

其他效能測量方式

Navigation Timing

load 事件與 DOMContentLoaded 事件

  • DOMContentLoaded 事件

    當初始的 HTML 文件被完全載入和解析完成之後,DOMContentLoaded 事件被觸發,而無需等待樣式表、影像和子框架的完成載入。
    
  • load 事件

    當頁面資源及其依賴資源已完成載入時,將觸發load事件。當 onload 事件觸發時,頁面上所有的DOM,樣式表,指令碼,圖片都已經載入完成了。
    

    順序是:DOMContentLoaded -> load。

單純地用 load 事件或者 DOMContentLoaded 事件來衡量頁面效能,並不能很好地反饋出站在使用者角度的頁面效能。


相關文章