基於 PageSpeed 的效能優化實踐

離獨逸發表於2022-01-03

前言

網站效能至關重要,會影響 SEO 排名、轉換率、使用者跳出率以及使用者體驗等。在瀏覽器中載入緩慢的網站可能會慢慢失去使用者,相反能夠快速響應的網站通常會有機會獲取更多流量,帶來更大效益。

最近我們在站點做了一版效能優化,把主要著陸頁面的 PageSpeed 分數從原本 30 左右提升到 80 分以上。

在這裡分享下在這個過程中的一些經驗,介紹下我們是如何達成這個結果,其中又涉及到哪些技術。

文章將從效能指標、效能測量與優化實踐方案三個方面展開,期望可以給大家提供一些思路與參考。

效能指標

效能指標的發展與演進

針對線上專案做效能優化,首先需要有一個確定的可量化的評判標準,用以來判斷優化工作是否有效。

傳統的效能指標最典型的是 DOM Ready 時間 和頁面載入時間(load time):前者指的是初始 HTML文件被完全載入和解析完成,一般是通過監聽 DOMContentLoaded 事件獲得;後者指的是整個頁面所需的資源(包括指令碼、樣式、圖片等)載入完成的時間,通過監聽全域性的 load 事件獲取。

在早先前後端耦合的時代,是通過在服務端使用模板引擎渲染出 HTML,能比較好地反映網站效能。後來前端領域的迅猛發展,尤其是隨著客戶端渲染方案的盛行,以及各種動態技術的大量運用,這兩個指標差不多已經失去其原有的意義,無法準確反映效能。

後來瀏覽器提供了 Navigation Timing API ,通過 perperformance.timing 可以獲取從頁面開始載入到結束整個過程中不同階段的時間點。這很不錯,開發者可以從多個維度去定義一些指標,通過簡單的差值計算去監控站點效能。

比如在內部的使用者行為追蹤指令碼(UBT)中就基於 timming API 主要定義了以下 7 個關鍵指標 DNS Connect Request Response Blank Domready Onload

  • DNS (domainLookupEnd - domainLookupStart)
  • Connect (connectEnd - connectStart)
  • Request (responseStart - requestStart)
  • Response (responseEnd - responseStart)
  • Blank (domInteractive - responseStart)
  • Domready (domContentLoadedEventEnd - navigationStart)
  • Onload (loadEventEnd - navigationStart)

同樣,這些指標更側重於技術細節,並不能很好地反映使用者真正關心的問題。在做效能優化的時候,很可能面臨的一種場景是,已經把某些特定指標如載入時間的數值大幅減少,但使用者體驗仍然很差。基於此,Chrome 團隊和 W3C 效能工作組推出了一組 以使用者為中心的效能指標,從使用者角度更好地去評判頁面效能。

這些主要指標包含:

指標介紹

FCP

FCP 指標測量的是頁面從開始載入到頁面內容的任何部分在螢幕上完成渲染的時間。“內容”可以是文字、影像(包括背景影像)、<svg> 元素或非白色的 <canvas> 元素。

這個指標回答了一個使用者問題,正在執行嗎

還有一個從名稱上很接近的指標,FP (首次繪製),它們之間的區別如下:

  • FP first-paint 大致可以認為是白屏時間
  • FCP first-contentful-paint 大致可以認為是首屏時間

LCP

這個指標對應的關鍵使用者問題是,是否有用,即頁面是否已經呈現出對使用者有用的內容。

早先有過一些類似的指標比如 FMP (首次有效繪製),但有效繪製的定義是什麼通常很難解釋,而且演算法常常容易出錯。

相反,最大內容繪製的定義簡單明瞭,這裡的“內容”和 FCP 中的定義基本一致,指的是在可視區域內的最大圖片或文字塊完成渲染的時間。

元素大小指的是內容佔據的面積大小,即 size = width * height ,不包含邊距邊框。

大多數情況下,頁面上最吸引使用者的內容往往就是最大元素,可以認為這就是頁面中最重要的元素。

TTI

可互動時間,對應的使用者關注點是 可以使用嗎

早期,關於可互動時間一直並沒有一個清晰明確的定義。刀耕火種的時代,開發者自定義時間節點,並在程式碼中埋點來獲取相關資料。

比如通過在 setTimeout 中放一個任務獲取執行時間點,再計算到頁面開始載入的差值。

setTimeout(function() {
    tti = new Date() - navigationStartTime
}, 0)

而在 Lighthouse 中,可互動時間指標有了更通用、標準化的定義。TTI 應從 FCP 時間點開始沿時間軸查詢,如果出現 5 秒的靜默視窗(沒有長任務並且不超過 2 個正著處理的 GET 請求),那麼最後一個長任務介紹的時間點即為可互動時間。

長任務指的是執行時間超過 50 ms 的任務。

主執行緒上若是存在導致阻塞狀態的長任務,將導致無法響應使用者互動。

tti

TBT

TBT 和 TTI 是一對配套指標,用於衡量在頁面可互動之前的阻塞程度。

TBT 是指在 FCP 和 TTI 之間所有長任務超過 50ms 的部分的時間總和(注意不是長任務的時間總和)。

tbt

CLS

累積佈局偏移指標用於衡量頁面視覺穩定性。

單次佈局偏移分數是影響分數(不穩定區域佔可是區域的百分比)與距離分數(不穩定元素最大位移距離佔比)的乘積。

CLS 指標本身一直在不斷進化 ,便於更加準確地去衡量佈局偏移對使用者的影響。

其他

效能測量

瞭解了需要關注的效能指標,那應該怎麼樣去有效測量呢?

效能測量分兩種型別,實驗室測量與現場測量(真實使用者監控)。有的指標只能通過實驗室測量,或是隻能現場測量。

實驗室測量

實驗室測量指的是在一個受控環境下,使用預定義的硬體裝置和網路配置等規則去執行網站頁面,進行效能資料採集,提取效能指標。

目前最流行的工具是 Google 的 Lighthouse ,最初作為一個獨立的瀏覽器擴充套件程式需要開發者自行安裝(支援 Firefox),目前已經整合到 Chrome DevTools 。

Lighthouse 不僅僅是一個效能測量工具,除此之外還提供 PWA 、SEO 、可訪問性、最佳實踐等審計報告。

在做效能優化的時候,如何有效評估優化方案的效果是一個問題,由於還沒有釋出到線上環境無法採集真實使用者效能資料,這時候使用工具進行實驗室測量就顯得至關重要。

同時,Lighthouse 提供開源 CI 工具 Lighthouse CI 開發者能自行部署服務,並整合到現有的 CI 體系中。

現場測量

現場測量,也稱真實使用者監控(RUM),即實時採集真實使用者效能資料。

實驗室測量的是在一系列特定條件下的效能資料,不能完全反映現實世界中使用者的真實情況。現場測量的優勢在於樣板量足夠大,包羅各種不同裝置不同網路環境下的資料,從統計上更能反映真實效能情況。另一方面,現場測量需基於瀏覽器提供的效能 Web API ,受限於當前裝置採集到的資料不及實驗室測量豐富。

定量評估的問題與方案

定量評估每一項優化方案的效果並不容易,原因包括環境差異問題,分數計算問題等。

解決方案是:

  • 開發模式啟動站點應用與生產模式差別較大,將應用釋出到測試伺服器再進行效能測量
  • 本地啟動 Lighthouse 進行測量,裝置在不同時間的系統狀態存在較大差異,應部署測量工具到固定伺服器
  • 由於環境影響單次測量的差異可能很大,基於 lighthouse NPM 包一次性跑 10 次,去除最大值和最小值之後再取中位數和平均值作為參考
  • 效能分數有六大效能指標計算而來,某些指標的數值優化最終在分數上體現幾乎沒有差異,分開看具體指標數值更合理

效能優化方案

確定優化方向,並且有了可定量評估的方案之後,接下來要做的就是如何實施具體的優化方案。

效能優化是一個老生常談,同時與時俱進的主題。早期大名鼎鼎的 雅虎 35 條效能軍規 到現在大部分仍然適用,另一方面隨著技術的發展,基於上述以使用者為中心的效能指標,能更有針對性地實施方案。同時藉助 Lighthouse 工具,能幫助我們有效評估具體方案的效果。

我們的應用是基於 React 技術棧,以下部分內容基於 React 來進行闡述。

減小包體積

網站應用與傳統客戶端應用很不同的一點在於,應用所需資原始檔都是存放在遠端伺服器上的,每次訪問都有相當大的效能開銷是用於資源載入。

如何讓資源高效載入成了一個非常重要的問題,其中最重要的一環是網路傳輸,專用的 CDN 伺服器包含就近訪問,資源快取和壓縮等功能,能節省大量網路傳輸時間,這是基礎設施的角度。

從開發者的角度,首先可以對應用包體積進行瘦身。

包體積的問題主要表現在:

  • 不再使用的冗餘程式碼
  • 複製貼上的重複程式碼
  • 非必要的大體積類庫
  • 未經優化的圖片檔案

冗餘程式碼

冗餘程式碼的產生有多種,比如是已經廢棄不用但仍然被匯入的功能模組,或者是在做 AB 實驗完成後未完全移除的版本程式碼等。

藉助相關工具,比如 Webapck 外掛 webpack-bundle-analyzer 能用一種視覺化的方式呈現每個包的具體模組資訊,大小、包含關係一目瞭然。而 Chrome DevTools Coverage 工具能分析出執行過程中檔案(指令碼和樣式)的使用情況,可作為參考更好地針對性地瘦身優化。

重複程式碼

重複程式碼很大一部分是實現相似功能的過程中,直接複製貼上一方程式碼進行修改導致,藉助 jsinspect 可以檢測到相同和相似程式碼,然後進行合理抽象。還有一種情況是,依賴 NPM 包提供多種方式的程式碼,比如 dist 目錄下的打包程式碼,lib 目錄下的 CommonJS 程式碼,和 es 目錄下的 ES Modules 程式碼。若是不小心在不同地方引入不同方式的包,就等同於是引入重複功能模組。更甚一步,在跨團隊合作中依賴包只提供打包版本,也會出現 babel polyfill 程式碼多次重複,並且無從分析。解決方案是制定統一的標準,推薦 NPM 包都提供僅 babel 編譯不打包版本。

類庫開銷

在類庫的使用上同樣需要注意,比如僅使用一兩個方法就引入整個 lodash 庫,推薦做法是按需引入,不用改變寫法加入 babel-plugin-lodash 這類外掛就能在程式碼構建時轉換。另外一種情況是引入 moment 這類體積較大的庫用作時間處理與格式化,可以視實際情況採用體積更小的替代品。對於更簡單的需求,則完全可以基於原生 API 自行實現封裝一些方法。

圖片檔案

未經優化的圖片可高達幾百 KB ,應在保證圖片清晰度的情況壓縮大小。

另一方面,為現代瀏覽器提供有更高效壓縮演算法的圖片格式,相比傳統的 PNG 和 JPG 格式,WebP 在同等質量下有更小的體積,注意做好降級方案。

優化資源載入

作為開發者做好包體積優化能節省網路傳輸時間,以及一部分程式碼執行時間,但更重要的是讓資源有效載入,可從資源載入順序和優先順序方面著手。

Resource Hints

為了使頁面可以快速載入,我們基於 PRPL 模式 進行優化。PRPL 是四個詞的首字母縮寫,分別代表:

  • Preload 預載入最重要的資源
  • Render 儘快渲染初始內容
  • Pre-cache 預快取其他資源
  • Lazy load 懶載入其他路由和非關鍵資源

首先,我們需要優化關鍵路徑資源,頁面中要呈現的內容很多,但不是所有內容都需要第一時間呈現,優先呈現最重要的內容。瀏覽器並不知道哪些資源是最重要的,基於 Resource Hints 可以告訴瀏覽器資源優先順序。常用的有以下幾類:

  • preconnect 啟動早期連線,包括 DNS 查詢,TCP 握手等
  • preload 預載入資源並快取,以便需要時立即使用
  • prefetch 預獲取資源,優先順序比 preload 低,瀏覽器自行判斷合理時間執行操作

在使用過程需要注意:

  • 不要無限制的濫用,因為其自身會消耗資源,尤其是新增了但卻未使用
  • 資源設定 crossorigin ,對應預處理提示也要設定,否則兩者不匹配導致重複載入

Service Worker

使用 Service worker 快取預載資源,對後續訪問會有極大的效能提升,能節省大量網路傳輸開銷。

在專案中推薦採用 Google 提供的 Workbox 庫,可以通過配置的方式對不同型別資源應用不同快取策略。

Service Worker 帶來的優化效果不能從 PageSpeed Insights 網站上的分數直接體現,因為 PageSpeed 總是單次分數並且不使用快取。

優化載入第三方指令碼

應用依賴的第三方指令碼通常會減慢頁面載入速度,一般採用以下方式:按需載入和延遲載入。

按需載入

需使用者互動才用到的功能模組應按需載入。舉個例子,使用者登入時要呼叫一個第三方驗證模組,就沒必要在頁面一開始就引入該指令碼,在使用者執行登入操作時引入更合理。

延遲載入

像是 Google analysis 和合作商營銷等第三方日誌埋點指令碼,業務需要無法移除,載入後佔用大量效能資源。

由於本身沒有依賴關係,可使用 defer script 延遲指令碼的解析執行。更進一步,延遲到在可互動時間之後載入就基本不會有任何影響。

元件懶載入

可視區域之外的內容,和需要使用者互動時才呈現的元件,都可採用懶載入,保證頁面首要內容快速呈現。

要做懶載入,首先需要合理定義拆分點進行程式碼分割,然後基於動態匯入和 React.lazy 即可實現。

對於大部分點選觸發的元件來說,這樣已經足夠,但針對頁面底部可視區域之外需常規滾動檢視的內容,還要做一些額外的工作。可以自行封裝實現一個元件,在內部進行判斷內容是否可視,並監聽 scroll 事件重新渲染。

實際中,我們結合 react-lazyload@loadable/component 實現所需功能,如下:

import React from 'react';
import loadable from '@loadable/component';
import LazyLoad from 'react-lazyload';

const LazyComponent = loadable(() => import(/* webpackChunkName: "home_lazy" */ './LazyComponent'));

export function HomePage() {
    return (
        <>
            <MainComponenet />
            <LazyLoad>
                <LazyComponent />
            </LazyLoad>
        </>
    );)
}

懶載入可能導致懶載入元件自身體驗下降,可對使用者比較頻繁使用的元件預載入。

過度拆分可能會產生很多體積很小的包,可以適當地進行合併。藉助 webpack magic comment ,配置相同的 chunk name 可以合併打包。

import loadable from '@loadable/component';

export const SortLayer = loadable(() => import(/* webpackChunkName: "depart_select_layer" */ './SortLayer'));
export const StopLayer = loadable(() => import(/* webpackChunkName: "depart_select_layer" */ './StopLayer'));
export const TimeLayer = loadable(() => import(/* webpackChunkName: "depart_select_layer" */ './TimeLayer'));

優化渲染方式

  • 服務端渲染
  • 預渲染

服務端渲染

CSR (客戶端渲染)的最大問題在於受使用者環境影響太大,一方面是網路層面指令碼檔案的載入,一方面是瀏覽器的執行效率,不同場景下差異可能非常大。

SSR (服務端渲染)則能解決這個問題,直出 HTML 能快速呈現頁面主要內容,能很好地改善 FCP 和 LCP 指標。

SSR 相對 CSR 本質上來講就兩點:

  • 將渲染(這裡是指 JavaScript 執行層面的)工作轉移到服務端,畢竟服務端相對更可控
  • 在首屏之前避免減少資源網路傳輸,從而減少耗時,因為網路是更不可控的一個因素

實際上,大部分時候都是結合二者,針對首屏採用服務端渲染,讓使用者更快看到內容,其他仍使用客戶端渲染的模式,減輕伺服器壓力,畢竟將大量使用者的渲染任務轉移到服務端會是一筆不小的開銷。這時,結合快取機制可以大大節省渲染時間。

預渲染

基於構建時的預渲染,是使用 webpack 和 babel 等工具提前生成對應的 HTML 以及引用的腳步和樣式檔案。還有一種方式是基於執行時的,使用 headless 瀏覽器。但預渲染並不適用於有大量動態內容的頁面。

優化長任務

Long Task (長任務)的定義是執行時間超過 50 ms 的任務。我們知道,JavaScript 是單程式單執行緒的模型,主執行緒上一旦有耗時長的任務存在時,就會造成阻塞,無法響應使用者輸入。

Long Task 跟 Lighthouse 中的兩個重要效能指標 TTI 和 TBT 息息相關,而這兩個指標占比為 40% ,可以說優化好 Long Task 能大幅提升頁面效能。

Long Task 可藉助對應的 Long Task Web API 進行監控,開發過程中則使用 Chrome DevTools Performace 皮膚檢視。需要注意的是,開發者的電腦配置可能很強,但使用者尤其是移動端的使用者環境並沒有那麼樂觀,應該適當調低硬體配置和網路速度,這樣能發現更多的 Long Task 。

任務型別有多種,除了最常見的指令碼執行之外,還包括指令碼解析編譯、HTML 解析、CSS 解析、佈局、渲染等。指令碼執行是長任務的主要表現形式,這裡著重說明在 JavaScript 執行上的一些優化方式:

  • requestIdleCallback API
  • Web Worker
  • 記憶函式
  • Debounce 和 Throttle

requestIdleCallback API

針對一些不重要的任務比如埋點日誌可以直接丟到 requestIdleCallback 中,瀏覽器會在空閒時間執行。在不支援的環境可使用 shim) ,基於 setTimeout 實現近似的功能。

idlize 中封裝了一些非常實用的幫助函式,使用這些方法可把任務延遲到需要的時候再執行。

Web Worker

如果專案中確實存在比較複雜的計算,可啟動 Web Worker 單獨另開一個執行緒來計算,並使用 message 通訊。

記憶函式

如果一個函式被大量呼叫,合理運用記憶函式一個很好的選擇,有大量的庫可供我們選擇,也可以根據使用場景自行實現。

Debounce 和 Throttle

針對 input change 和 scroll 等可能頻繁觸發的事件,避免無節制地呼叫。

React 效能優化

在 React 框架使用上有一些效能優化的實踐,個人認為比較重要的有:

  • shouldComponenetUpdate
  • useMemouseCallback
  • 不可變資料

預設的 shouldComponenetUpdate 總是返回 true 但開發者知道什麼時候應該更新,則可自行實現該生命週期方法。推薦大部分元件都使用 pureComponent 代替,函式元件則可使用 Memo

useMemouseCallback 都是記憶函式,可結合 Memo 避免不必要的重新渲染,或者是對昂貴計算的記憶。

state 和 props 都是不可變資料,在更新深層巢狀資料使用深拷貝不是一種好方式,可藉助 Immer 這類庫更好地編寫。

最後說明一點,在必要的時候進行效能優化,大部分時候無需考慮,而且濫用方法反而損害效能。

減少佈局偏移

如何除錯監控

有對應的 Layout Instability API 可以幫助收集使用者的佈局偏移資料。

在開發除錯中,Layout Shift 同樣可以使用 Chrome DevTools Performance 進行分析,能檢視每一次佈局偏移的分數,進行鍼對性優化。

常用的優化方案有:

  • 為動態元素預靜態預留空間
  • 圖片寬高尺寸固定

預留空間可減少其他頁面元素的偏移,比如出現在最頂部的廣告位,在資料還未獲取到的時候預先設定好一個容器,可避免後續大幅偏移。

針對整頁動態的內容,使用骨架屏是一種很好的模式,業界已有不少成熟方案可自動生成。

設定圖片寬高,則可以保證瀏覽器在載入圖片過程中始終能分配正確的空間大小。

總結反思

藉助上述中提到的效能測量方式,我們逐步實施優化方案併發布上線,經過近兩個月斷斷續續的時間,最終讓效能分數穩定在 80 分左右。

score

效能優化也適用於二八定律,優化方式很多,只是簡單地堆砌使用很可能適得其反。不同場景下的優化方案千差萬別,關鍵在於找準最核心的問題。以上僅提供一些思路作為參考。有些方案對特定指標效果很好,有些方案不會反映到指標分數,但有助提升使用者體驗。

再者,指標衡量的是單個頁面速度,而作為開發者還應衡量後續頁面,從整體的維度去平衡,真正從使用者角度考慮。

相關文章