­

網易雲音樂前端性能監控實踐

  • 2019 年 11 月 20 日
  • 筆記

前言

雲音樂前端性能監控平台,底層使用了 Lighthouse 進行審計評分,在實踐過程中我們積累了一些 Lighthouse 內部實現的研究經驗,希望通過這篇文章可以分享給各位讀者。

本篇文章基於 Lighthouse 5.2.0 版本,介紹了 Lighthouse 的測試流程、架構模塊實現、性能指標的計算等。通過這篇文章,讀者可以了解到 Lighthouse 是如何做自動化測試的、如何在 Lighthouse 的框架上自定義一些審計項、關鍵的性能指標是如何模擬計算的。

本篇文章會按以下四個部分展開:

  • Lighthouse 簡介
  • Lighthouse 測試流程
  • Lighthouse 模塊實現
  • Lighthouse 性能指標計算

Lighthouse 簡介

Lighthouse 是一個開源的自動化工具,用於改進網絡應用的質量。只要為 Lighthouse 提供一個需要審查的網址,它將針對此頁面運行一連串的測試,然後生成一個有關頁面性能的報告。

Lighthouse 使用方式

目前官方提供了4種使用方式:

  • Chrome 開發者工具(DevTools)
  • Chrome 擴展
  • Node CLI
  • Node Module

以 Chrome 開發者工具為例,在 Audits 面板下,用戶可以配置測試平台、測試類目、限速方式等,可以方便快捷地發起一次測試。

image

Lighthouse 測試報告

測試結束後,默認會生成 HTML 格式的報告,如下圖所示,在報告中涵蓋了 5 大類別(categories)的測試評分:

image

每個類別都包含一系列的審計項(audit),針對審計項的運行結果,Lighthouse 會給出特定的優化建議與診斷結果幫助開發者有針對性地進行優化。

本節簡要介紹了 Lighthouse 的使用方式與測試報告組成,下一節將介紹 Lighthouse 的測試流程。

Lighthouse 測試流程

我們以 Node CLI 的方式進行測試,分析 Lighthouse 的測試流程。

參考官方文檔,安裝好 CLI 後,輸入如下命令,可以進行一次測試

lighthouse --only-categories=performance https://google.com

註:以上命令只進行 performance 類別測試。

在 CLI 中會輸出測試過程中的日誌,截圖顯示如下,在日誌中,可以看出測試大致分為如下幾個階段:

image

通過輸出的日誌,可以畫出 Lighthouse 的測試流程圖:

image

  1. Lighthouse 與瀏覽器建立連接。
  2. 測試的初始化配置與加載待測試頁面。
  3. 在頁面加載過程中,運行一系列的採集器(gatherers),每個採集器都會收集自己的目標信息,並生成中間產物(artifacts)。
  4. 運行一系列的審計項(audits),每個審計項都會從中間產物(artifacts)中獲取所需的數據,計算出各自的評分。
  5. 基於審計項的評分計算出大類的評分,匯總生成報告。

本節基於 Lighthouse 的測試日誌,介紹了 Lighthouse 的測試流程,下節將介紹流程中的模塊實現。

Lighthouse 模塊實現

初步了解了基本的測試流程後,我們再看下官方給出的 Lighthouse 架構圖:

image

這張圖中體現了測試的主要流程,從中也可以圈出 4 個主要模塊,下文會對這幾個模塊做逐個講解。

Driver 模塊

雙向通信與 DevTools 協議

Chrome 瀏覽器在啟動的時候,可以通過 --remote-debugging-port 參數設置遠程調試端口,如以下命令可以打開 Chrome 並設置遠程調試端口為9222。

chrome.exe --remote-debugging-port=9222

之後就可以使用地址 http://localhost:9222 進行遠程調試了,比如以下命令可以讓 Chrome 瀏覽器打開一個新的 Tab。

curl http://localhost:9222/json/new

該命令還會返回此 Tab 的相關信息,其中需要關注的是 webSocketDebuggerUrl,這是該 Tab 的 WebSocket 連接地址。

{      "id": "29989D...",      "url": "about:blank",      "webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/29989D...",      ...  }

Driver 模塊持有 Connection 實例(負責與瀏覽器進行通信),該實例在初始化的時候,正是通過調用遠程調試端口的/json/new指令打開一個新的 Tab,並使用返回的 webSocketDebuggerUrl 與瀏覽器建立 WebSocket 連接,之後就可以進行雙向通信。

  1. 新開一個Tab

image

  1. 建立 WebSocket 連接

image

雙方建立 WebSocket 連接後,必須使用一種數據格式協議進行通信,該協議就是 Chrome DevTools Protocol,此協議以 JSON 為格式,定義指令的方法名與參數。

如下圖所示,發送 Page.navigate 指令可以讓 Chrome 導航至目標頁面。發送 Page.captureScreenshot 指令可以讓 Chrome 生成當前頁面的截圖數據。

image

在該協議的文檔中,所有的控制指令和事件被劃分至多個領域(Domains)如 Page、Network 等。打開 Page 領域,可以找到示例指令 Page.navigate 的詳細說明:

image

除了 navigate、captureScreenshot 等主動調用的指令外,當我們調用某個領域的 enable 指令後,後續就可以接收到該領域推送的通知事件。

Lighthouse 通過 Chrome DevTools Protocol 定義的主動指令與事件通知,就實現了操控 Chrome 瀏覽器,和感知頁面加載過程中的各個事件。

日誌記錄

Driver 模塊中的另外2個重要實例是 DevtoolsLog、NetworkRecorder,他們用於將瀏覽器發出的通知事件進行結構化的存儲。其中 DevtoolsLog 會記錄各個領域的全量日誌,NetworkRecorder 只存儲網絡相關日誌,並會分析出當前網絡請求狀態(繁忙、空閑)等。

image

存儲的日誌信息將在後續的審計(Audits)模塊中使用,下文會繼續提到他們。

仿真器(emulation)

Driver 模塊中最後值得一提的部分是仿真器(emulation),該模塊的作用是模擬測試設備,如模擬 移動端 / PC 端、屏幕的尺寸,模擬設備的 UserAgent、Cookie、網絡限速等。

這些模擬功能的設置,也是通過 Connection 模塊向 Chrome 瀏覽器發送對應領域的操控指令實現的。

image

至此我們分析完了 Driver 模塊的主要組成部分,我們來簡單總結下:負責與瀏覽器的雙向通信、記錄事件日誌、模擬器的設置等。

Gatherer 模塊

image

該模塊的一個重要概念是 pass,官方是這樣定義 pass 的:

controls how to load the requested URL and what information to gather about the page while loading.

即控制頁面如何加載,並決定在頁面加載過程中採集哪些信息

defines basic settings such as how long to wait for the page to load and whether to record a trace file. Additionally a list of gatherers to use is defined per pass. Gatherers can read information from the page to generate artifacts which are later used by audits to provide you with a Lighthouse report.

即定義頁面加載等待時間、是否記錄 trace 文件等配置。每個 pass 還定義了一個 gatherers 列表,gatherers 可以從頁面中讀取需要的信息並生成一個中間產物, 中間產物將會用於後續的審計分析,並最終生成測試報告。

了解了 pass 的定義,來看一個具體的 pass 配置:

{    passes: [{      passName: 'defaultPass',      recordTrace: true, // 是否記錄Trace信息      useThrottling: true, // 是否使用限速      gatherers: [ // gatherers列表        'css-usage',        'viewport-dimensions',        'runtime-exceptions',        'console-messages',        'anchor-elements',        'image-elements',        'link-elements',        'meta-elements',        'script-elements',        'iframe-elements',        ... // 省略      ],    },    ... // 省略  }

其中的 gatherers 是我們需要關注的重點,每一個 gatherer,在代碼倉庫中都有與之對應的同名實現文件,並且都繼承自相同的父類 Gatherer,其中定義了三個模板方法,子類只需實現關心的模板方法即可。

class Gatherer {      // 在頁面導航前      beforePass(passContext) { }        // 在頁面loaded後      pass(passContext){ }        // 在頁面加載完畢,且trace信息收集完畢後      afterPass(passContext, loadData) { }  }

以一個比較簡單的 Gatherer 具體實現 RuntimeExceptions 為例,該實例實現了 beforePass 、afterPass 兩個生命周期模板方法,其中 driver.on 正是通過上文介紹的 Driver 模塊實現的事件監聽。

const Gatherer = require('./gatherer.js');    class RuntimeExceptions extends Gatherer {    constructor() {      super();      this._exceptions = [];      this._onRuntimeExceptionThrown = this.onRuntimeExceptionThrown.bind(this);    }      onRuntimeExceptionThrown(entry) {      this._exceptions.push(entry);    }      // 在頁面導航前,註冊事件監聽器,採集錯誤信息    beforePass(passContext) {      const driver = passContext.driver;      driver.on('Runtime.exceptionThrown', this._onRuntimeExceptionThrown);    }      // 在頁面加載完畢後,解除事件監聽    async afterPass(passContext) {      await passContext.driver.off('Runtime.exceptionThrown', this._onRuntimeExceptionThrown);      return this._exceptions;    }  }

有了這個參考示例,我們也可以輕鬆地寫一個自定義的 Gatherer,比如用於採集頁面標題的 gatherer:

const Gatherer = require('./gatherer.js');    function getPageTitle() {      return document.title;  }    class PageTitle extends Gatherer {        afterPass(passContext) {          return passContext.driver.evaluateAsync(`(${getPageTitle.toString()}())`);      }  }

我們只重寫了 afterPass 方法,在該生命中期中,將腳本通過 driver 模塊發送給瀏覽器執行,並獲取到執行結果。

當 pass 中定義的所有 gatherers 運行完後,就會生成一個中間產物 artifacts,此後 Lighthouse 就可以斷開與瀏覽器的連接,只使用 artifacts 進行後續的分析。

總結下 Gatherer 模塊,該模塊會通過 pass 這個配置,定義頁面如何加載,並運行配置的所有 gatherers 來採集頁面加載過程中的信息,並生成中間產物 artifacts。有了 artifacts,就可以進入下一步的 Audits 模塊。

Audits 模塊

與 gatherers 類似,在配置文件中也會定義需要運行的 audits,每一個 audits 也都有與之對應的同名實現文件。

{    audits: [      'errors-in-console',      'metrics/first-contentful-paint',      'metrics/first-meaningful-paint',      'metrics/speed-index',      'metrics/first-cpu-idle',      'metrics/interactive',      'screenshot-thumbnails',      'final-screenshot',      // 省略    ],    // 省略  }

我們還是從最為簡單的 errors-in-console 入手,了解下一個 audit 是如何實現的。

在每個 audit 中都會定義一個靜態方法meta(),對該 audit 進行描述,並聲明所需的 artifacts,ErrorLogs 這項 audit 就聲明了其需要上文提到的 RuntimeExceptions 所生成的中間產物。

class ErrorLogs extends Audit {    static get meta() {      return {        id: 'errors-in-console',        title: str_(UIStrings.title),        failureTitle: str_(UIStrings.failureTitle),        description: str_(UIStrings.description),        requiredArtifacts: ['ConsoleMessages', 'RuntimeExceptions'],      };    }  }

Audit 實例需要實現的另一個模板方法是audit(),在該方法中可以拿到所需的中間產物,並基於中間產物計算出本項 audit 的得分與詳情。

static audit(artifacts) {      // 獲取所需的中間產物      const runtimeExceptions = artifacts.RuntimeExceptions;        // 數據的過濾與轉換      const runtimeExRows =        runtimeExceptions.filter(entry => entry.exceptionDetails !== undefined)        .map(entry => {          const description = entry.exceptionDetails.exception ?            entry.exceptionDetails.exception.description : entry.exceptionDetails.text;            return {            source: 'Runtime.exception',            description,            url: entry.exceptionDetails.url,          };        });        // 省略表格詳情生成代碼      ...        // 計算出審計項的得分      const numErrors = tableRows.length;      return {        score: Number(numErrors === 0),        numericValue: numErrors,        details,      };    }

有了上面的示例,我們就可以參照實現一個自定義審計項,如審計頁面標題:

class PageTitle extends Audit {      static get meta() {          return {              id: 'page-title',              title: 'title of page document',              failureTitle: 'Does not have page title',              description: 'This audit get document.title when page loaded',              requiredArtifacts: ['PageTitle'],          };      }        static audit(artifacts) {          return {              score: artifacts.PageTitle ? 1 : 0,              displayValue: artifacts.PageTitle || 'none'          };      }  }

當運行完配置文件中定義的所有審計項後,就得到了每個審計項的評分與詳情,後續就進入 Report 模塊。

Report 模塊

在配置文件中,會定義每個測試類別所需的審計項,以及每個審計項所佔的權重。如下所示的為性能(performance)這項測試類別所需的審計項:

{    'performance': {      title: str_(UIStrings.performanceCategoryTitle),      auditRefs: [      {id: 'first-contentful-paint', weight: 3, group: 'metrics'},      {id: 'first-meaningful-paint', weight: 1, group: 'metrics'},      {id: 'speed-index', weight: 4, group: 'metrics'},      {id: 'interactive', weight: 5, group: 'metrics'},      {id: 'first-cpu-idle', weight: 2, group: 'metrics'},      {id: 'max-potential-fid', weight: 0, group: 'metrics'},      // 省略      ]    },  }

在最終匯總階段,Lighthouse 會基於該配置文件以及上一個環節中計算出的每個審計項的評分,加權計算出 performance 的評分。並基於每個審計項的評分與種類,將審計項劃分為通過與不通過,對於不通過的審計項會給出詳細的測試詳情與優化指引。

FCP 等性能指標審計項的實現

在上文介紹整體測試流程的過程中,我選擇了最為簡單的審計項展開介紹,本節會挑選大家更為關心的性能審計指標如 FCP 展開介紹。

FCP(First Contentful Paint) 首次內容繪製時間,是從頁面導航開始,到瀏覽器從 DOM 中渲染出首個內容的時間。

限速模擬

由於頁面性能受宿主機網絡與 CPU 頻率等參數的影響較大,Lighthouse 提供了三種方式供模擬較差的宿主機環境,其背後的邏輯是,如果頁面能夠在較差的環境下達到一個較好的測試分數、那麼大部分用戶對頁面的直觀感受都會較好。

在 Chrome Devtools 的 Audits 面板中,可以看到三種限速方式:

image

上圖配置項分別對應下面三種限速方式的介紹

simulated

Throttling is simulated, resulting in faster audit runs with similar measurement accuracy

即限速是模擬的(加載頁面時不進行限速,加載完頁面後,模擬計算出在限速條件下的性能指標值),所以可以在較快的速度下地完成審計並有相似的測試精度。

devtools

Typical DevTools throttling, with actual traffic shaping and CPU slowdown applied

即通過 DevTools 進行限速,頁面是在一個真實受限的網絡與降速 CPU 條件下加載的。

no throttling

No network or CPU throttling used. (Useful when not evaluating performance)

即 Lighthouse 不進行額外的限速,通常在不進行性能測試、或開發者自行對宿主機進行限速時使用該項。

在三種限速方式中,Lighthouse 真正對網絡與 CPU 進行限速的只有 devtools 這種限速方式,實現的方式是通過上文提到的 Driver 模塊發送對應領域的指令給 Chrome 瀏覽器:

// 開啟CPU限速  function enableCPUThrottling(driver, throttlingSettings) {    const rate = throttlingSettings.cpuSlowdownMultiplier;    return driver.sendCommand('Emulation.setCPUThrottlingRate', {rate});  }    // 開啟網絡限速  function enableNetworkThrottling(driver, throttlingSettings) {    // 省略部分代碼    return driver.sendCommand('Network.emulateNetworkConditions', conditions);  }

Trace 信息

在上文介紹 pass 時,我們提到其中有一個參數用來控制是否收集 Trace 信息,Trace 信息是什麼?它又有什麼用呢?

其實我們大部分同學都已經接觸過 Trace 信息,它的可視化展示就在 Chrome devtools 中 Performance 面板:

image

在這個可視化面板中,可以看到頁面加載過程中關鍵渲染節點 FP、FCP、FMP 等,並可以看到主線程進行的 Parse HTML、Layout、JS 的執行依賴情況等。

當 pass 中配置了開啟收集 Trace 信息時,Lighthouse 在頁面加載完畢後,就可以拿到完整的 Trace 信息, 從中可以知道頁面加載時的 FCP、FMP 等關鍵渲染節點。

FCP 的模擬計算

當使用 devtools、no throttling 這兩種方式進行限速時,由於頁面就是在真實受限的網絡條件下加載的,Trace 信息中給出的 FCP 值就是限速條件下的 FCP 值,所以 Lighthouse 無需進行任何額外的加工處理。

但在 simulated 這種限速方式下,頁面是在沒有限速的條件下加載,所以 Trace 中的 FCP 是不限速時的 FCP,Lighthouse 需要通過模擬計算的方式,得出在給定限速條件下的 FCP 估算值。接下來我們重點介紹 simulated 這種模擬方式下,FCP 的計算。

上文我們提到在 Driver 模塊中有個 NetworkRecorder,這個模塊會記錄頁面加載過程中的所有網絡請求詳情,Lighthouse 會為每個有效的網絡請求事件建立一個對應的 Network Node 節點。

Trace 信息中也會記錄頁面加載過程中 CPU 執行事件,Lighthouse 會為每個有效的 CPU 事件建立一個對應的 CPU Node 節點。

image

緊接着,Lighthouse 會從 Network 請求節點中找出根節點(請求 Document 的節點),並根據節點依賴算法,建立起 CPU 節點與 Network 節點之間的依賴,最終生成頁面加載依賴的有向無環圖:

image

建立了頁面加載所需的完整依賴圖後,Lighthouse 會結合 Trace 信息中的 FCP 事件時間,分析出頁面 FCP 所需的的依賴圖:

image

有了頁面 FCP 所需的依賴圖後,Lighthouse 模擬計算出,在限速條件下,請求依賴圖中的資源,執行依賴圖中的 CPU 事件,所需的耗時,以此得出在特定限速條件下的 FCP 估算值。

image

模擬 HTTP 請求

Lighthouse 通過模擬 HTTP 的方式,計算出在特定網絡條件下的資源下載耗時,而不是真實地發起網絡請求,我們來看下 Lighthouse 是如何做模擬的。

image

image

在上述的代碼中,我們看到 Lighthouse 完全是通過模擬 HTTP 的方式,計算出了一個資源在特定網絡條件下,所需要的耗時。並且這個模擬考慮了 HTTP2 多路復用技術、 請求是否 KeepAlive、TCP 三次握手、擁塞窗口等細節。

我們用一張圖來總結和對比一下,兩種限速方式計算 FCP 的流程差異:

image

可以看出兩種限速方式,都是以 DevTools 給出的 Trace 信息為基礎,在 Simulate 限速方式下,在拿到 FCP 值後,還需要模擬計算在限速條件下的估算值。在 Simulate 限速方式下,其他性能指標如 FMP、SpeedIndex 等也是通過類似的方式進行模擬計算,至此我們分析完了 Lighthouse 性能指標 FCP 審計項的實現原理。

總結

本篇文章為大家簡要介紹了 Lighthouse、並分析了 Lighthouse 的測試流程與主要的模塊實現,最後向大家介紹了關鍵性能指標 FCP 的模擬計算方式,希望能對大家有所收穫。文末會貼出文章中提到的模塊的源碼導航,有興趣的朋友可以看下,歡迎大家進行交流。

源碼導航

Driver 模塊

  • driver
  • connection
  • emulation
  • network-recorder

Gatherer模塊

  • gather-runner
  • gatherer
  • runtime-exceptions

Audit模塊

  • audit
  • error-in-console

FCP計算

  • audit/fcp
  • computed/fcp
  • computed/lantern-fcp
  • computed/lantern-metric
  • computed/page-dependency-graph
  • dependency-graph/base-node
  • dependency-graph/tcp-connection

參考文獻

  • Lighthouse-architecture
  • Chrome DevTools Protocol
  • Lighthouse Scoring Guide