經歷了一週多的瘋狂改造之後,開發中的新版 BP Studio 1 終於在 PageSpeed Insights 中刷出了行動裝置上的滿分成績了。這真的是有夠困難的一項挑戰,畢竟如果各位拿 Google 自家的頁面去測看看的話,連它的帳號登入頁這麼單純的頁面、在行動裝置上也都只能拿到六七十分左右的效能成績,更不要說是像 BPS 這種會載入許多程式庫的網頁應用程式了(而可想而知地,PageSpeed Insights 網站自己也拿不到滿分 🤣)。本篇就來分享一下這個可貴的滿分背後的心得。

基本概念

PageSpeed Insights 基本上就是檢測網站體驗指標(web vitals)和其它一些可以增進使用者體驗的檢查項目的工具。它一共有四大分類:效能(即網站體驗指標)、無障礙功能、最佳做法、和搜尋引擎最佳化,每一個分類的滿分是 100 分,然後會針對「行動裝置」和「電腦」兩種設備類型來進行測試、給出兩套分數。不過,除了效能之外的其餘三個分類原則上在兩種設備上都會是一樣的分數,且要拿滿分都並不困難,只要把 PageSpeed Insights 回報的缺失照著它說的補一補就好了,所以本篇不多談那些部份。重點是在於效能的這一項。

自此 2018 年以來,PageSpeed Insights 就一直是基於 Lighthouse 的測試項目和標準來計分的。Lighthouse 在今年稍早的二月推出了新的 v10 版本 2,這一版跟之前的 v9 只有一個差異,就是 TTI(Time to Interactive)指標不再納入計分、而其權重全部都移到 CLS(Cumulative Layout Shift)之上,除此之外其它的指標(FCP、SI、LCP、TBT)與計分權重都維持不變,於是我們一共有五個指標需要下功夫。每一個指標會隨著得分而有紅標、黃標和綠標三種顏色,當然綠標就是好的。

不過,這當中我在 CLS 與 SI(Speed Index) 這兩個指標之上並沒有下任何特別的功夫;基本上其它三個指標都綠標了之後、這兩個也就自然綠標了。一般而言是否都如此我不確定,但反正我因此並沒有這兩個指標的心得可以分享,所以本篇當中我會針對 FCP、LCP 和 TBT 三個指標來作解說。

如果各位跟我一樣想要追求效能滿分,那不只是要全部綠標,還是要「綠標中的綠標」才夠;這部份非常值得先參考一下官方的得分計算器來了解一下到底是怎麼配分的、以及各個指標要做到多好才能拿到滿分。需要注意的是每一個指標都要兼顧;例如如果 LCP 不夠低的話,就算把其它指標都一路改進到極限也拿不了滿分的。幫大家整理一下的話,滿分的最低標準原則上就是:

  • FCP 1580ms 以下。
  • SI 2960ms 以下。
  • LCP 1930ms 以下。
  • TBT 90ms 以下。
  • CLS 0.06 以下。

請注意我這邊說的是「各項的最低標準」,也就是說,如果任何一項超過對應的數字、即使其它指標全部都滿分、加總起來也不會滿分的意思。然而這些數字必要而不充分:要加總之後滿分的話,還要有某幾項比這些數字都要再低一點才夠;具體的組合就讓各位自行嘗試了。

FCP (First Contentful Paint)

FCP 講的是畫面上首次有「有內容的東西」被渲染出來的時間點(從網頁載入開始起算);在 FCP 之前,使用者是無法感受到網頁已經有被載入的。要加快 FCP 的話,第一個重點就是確保伺服器本身的速度夠快 3;如果伺服器配備太弱或網速太慢,光是連線下載資料的時間就可以把秒數用完了,更不要說渲染了。所以這部份是最起碼必須確保的。除此之外,幾個大重點則在於:

減少阻擋渲染的資源(render-blocking resource)

假設我們在網頁的 <head> 裡頭放了很多沒有特別處置的 CSS 和 JavaScript,那麼等到這些東西都被下載、解析、執行完畢之前,瀏覽器都沒辦法繼續處理 <body> 裡面的東西,因為瀏覽器沒有辦法確定那些 CSS 和 JavaScript 是否會影響到渲染的結果。

要減少阻擋的 script,主要的方法就是把 <script> 加上 asyncdefer 屬性,前者的意思是告訴瀏覽器「先繼續往下處理,等到這個 script 下載完了的時候再執行;這裡面不會有什麼影響到你渲染的程式碼啦」,而後者則是說「等到 DOM 載入完畢了再來執行它就好」,於是兩者都可以使得 FCP 提早發生。

至於 CSS 則稍微麻煩一點。固然是存在一些手法可以暫時使得匯入的外部樣式表不會阻擋渲染,但是這類方法沒用好的話、都有可能在短暫的瞬間使得渲染出來的頁面呈現跑版狀態,這可能會大幅增加 CLS。況且有採用底下的作法的話,也並不需要使用那些手法,所以重點還是應該擺在盡可能減少沒有用的 CSS 宣告、使得解析樣式表的速度加快:

  • 如果有使用像 BootstrapTailwind 之類的 CSS 框架,那就不要把它的 CSS 整包匯入、而應該用一些例如 PurgeCSS 之類的建置工具來針對頁面上實際有用到的 CSS 來進行客製化。
  • 更進一步的話,還可以把樣式規則分成「頁面初次顯示就會需要用到」(關鍵樣式)和「稍後的畫面才會用」(非關鍵樣式)兩部份,一開始只載入關鍵的樣式就好。(這部份 BPS 沒有用到,因為我做完前一則優化之後 FCP 已經夠快了;下同。)
  • 可以的話最好順便把樣式表直接寫在 <style> 裡頭,這樣的話還可以省下額外的下載時間。

預先載入關鍵資源

假如基於某些理由不適合把 <script> 加上 asyncdefer(例如在 BPS 當中,會在啟動的時候設置一個全域的錯誤處理機制,而這個程式碼需要在最早的時間點執行)那也未必不行,但是起碼我們可以避免讓這樣的程式阻擋到後面資源的下載,方法就是利用 <link rel="preload">(詳情可參見這裡)。在 <head> 的一開始就加入這些的話,即便瀏覽器正在處理些什麼、也可以同時繼續下載等一下會用到的資源,節省時間。

LCP (Largest Contentful Paint)

LCP 指的是「渲染出頁面上最大內容(圖片或文字區塊)的時間點」。視網頁的類型而定,這未必是一個很難壓低的指標;有些網頁上的最大內容就是一個標題圖片,那麼我們就只要確定這個圖片載入的速度夠快就好了。

然而對於 BPS 來說卻完全不是這麼回事。BPS 的最大內容是歡迎畫面上的文字,而這個歡迎文字很不巧地有做多國語系、所以我必須等到 Vue 以及 Vue-I18n 都準備就緒了之後才有辦法讓它出現在畫面上,如此一來要加快 LCP 就真的超級困難了。我最後把 LCP 壓低到幾乎跟 FCP 沒什麼差別、主要是透過下面幾個階段的改良:

使用 runtime only 的版本

包括 Vue 與 Vue-I18n 在內的一些程式庫都有分成一般版本與 runtime only 的版本,差別在於一般版本內建了 template 的編譯器、可以在執行階段把 template 字串編譯成渲染函數,而 runtime only 的版本則必須在建置的時候就先把渲染函數編譯好。雖然後者在建置階段會多一道工,但是因為渲染函數已經準備好了,執行起來自然效率較高,且 runtime only 版本本身的程式大小也小一些、載入也較快,所以對於追求效能來說,絕對是要選擇 runtime only 的。

其它的一些程式庫也常常會像這樣有「適合大眾的簡便使用方法」與「比較複雜但效能較好的用法」之區隔,所以文件都要看清楚,只要是有提昇效能的方法就要用。

類似地,能夠事先編譯而減少執行階段轉換的東西都是能做就要盡量多做;例如舊版的 BPS 當中,有些畫面內容是用先載入 Markdown 檔案、然後再用 Marked 這個套件去轉換成 HTML 以呈現到畫面之上,不過在新版中就是改成在建置時期就用 Marked 先把 Markdown 轉換好。下載 HTML 跟 Markdown 文件相比起來雖然大一點點但其實也差不了多少,然而前端就可以省下一整個套件的載入與執行成本。

Static Site Generation (SSG)

Vue 的渲染機制可以粗略分成 CSR(客戶端渲染)和 SSR(伺服端渲染)兩種,其中 CSR 是直接在客戶端根據 template 等等來建構出 DOM,而 SSR 則是在伺服端就先把 DOM 演算好、以 HTML 寫在頁面上,到了客戶端之後只需要執行一個較為快速的 client side hydration 的動作就可以讓互動機制上線了;之所以它比較快,是因為 hydration 的過程沒有牽涉到任何 DOM 的增減,頂多只是註冊一些事件什麼的而已。而 SSR 的一個變種稱為 SSG,它指的是生成出來的 HTML 是固定的、直接以靜態檔案的形式發佈,除此之外它在客戶端的 hydration 行為跟 SSR 是一樣的。BPS 採用的機制就是 SSG 4

雖然歡迎畫面的文字有多國語系、而且語系會在網頁首次載入時自動偵測、以至於這部份並沒有辦法預先生成固定的 HTML,但是至少應用程式的大骨架可以用 SSG 來做,而歡迎畫面等到 hydration 完成之後再載入即可。(必須等到 hydration 結束後才能載入,否則會發生 mismatch 的錯誤。)

分段渲染

但是光靠 SSG 還是完全不夠快的。再怎麼說 Vue 也是一個有一定複雜度的框架,有其固有的 overhead 存在,就算是只做 hydration、如果在載入的時候就一口氣把整個應用程式的所有元件都初始化的話,用上的時間還是太多了。

因此我發展出了一種「分段渲染」的機制,其概念簡單來說就是有一個變數 phase 在追蹤目前進行到第幾個階段了,只有當這個變數的值增加的時候,頁面上的某些元件才會真的被初始化(透過 v-if 來控制),而在那之前就只是一些單純的 HTML 先頂著該元件的位置(使得渲染出來的外觀一致)。在這個概念之下,最初的「第零階段」之中,我只全力拼跟歡迎畫面有關的元件載入、其它的全部都留到後面的階段再說,使得網頁載入時的工作量極小化。

非關鍵程式庫延後載入

除此之外,在 BPS 當中,非關鍵程式庫的載入不只是 <script defer> 這種程度的延遲而已,而是根本等到了歡迎畫面渲染完畢之後才加入新的 <script> 並且載入之。在那之前,唯一會被載入的就只有 Vue 和 Vue-I18n。

透過這些技巧,我一路把 LCP 壓低到了約 1.8 秒左右。本來我以為這樣差不多了,就繼續去跟 TBT 指標奮戰(見下一節),可是沒想到戰勝了 TBT 之後,反而變成是 LCP 不夠低導致我拿不了整體滿分。我這才意識到,假如我要依賴 Vue + Vue-I18n 來渲染出歡迎畫面的話,不管我怎麼努力都是不可能夠快的。

歡迎畫面完全 SSG 化

於是我最終極的解法就是:SSG 的部份先靜態生成英文版的內容,以便它能夠瞬間呈現在畫面上(因為一開始就寫死在 HTML 裡頭了),至於多國語系的部份則在 Vue 的 hydration 完成之後再瞬間切換語系即可。由於 PageSpeed Insights 的測試環境當然是英文版的,所以我這樣做的話 LCP 就會幾乎完全等於 FCP 了……開玩笑的啦!我怎麼可能會為了通過測試就故意犧牲別的環境?幸好在 BPS 的設計中,在語系確定之前,畫面上本來就會有一個滿版的 spinner 在遮蓋其它東西,所以語系的切換使用者是看不到的、不會有閃爍的問題。

TBT (Total Blocking Time)

所有指標裡面最難拉高分數的,絕對就是 TBT 了。當網頁上頭有 JavaScript 的 macrotask 正在執行的時候,是不會回應使用者事件的、也不會有新的渲染發生,所以在那段時間當中網頁看起來就像當機了一樣;當然如果 macrotask 的長度很短,使用者是不會感覺到任何異狀的,而超過 50ms 的就稱為 long task,是會開始讓使用者感覺到卡卡的。而 TBT 這個指標測量的就是「所有的 long task 各自超過 50ms 的部分之加總」,總超過量越少越好。

以 Lighthouse v10 的標準來說,TBT 必須要在 600ms 以內才能達到黃標,而 200ms 以內才能達到綠標;若是要達到整體滿分,則更是必須壓低到 90ms 以內才行。而這在行動裝置方面是真的有夠難達到的(連黃標都沒那麼容易),因為行動裝置的測試是以模擬慢速 CPU 的方式進行的,隨便一個稍微大一點的 script 別說 50ms 了、耗上個兩三百毫秒也一點都不稀奇。這個指標是我花了最久的時間才終於搞定的,其中的重點包括:

製造斷點

首先第一個重點就是我們要怎麼樣把 long task 拆解成執行時間較短的若干 macrotask。最簡單的方法當然是利用 setTimeout() 方法來做,不過瀏覽器對於 setTimeout() 的觸發時間間隔有 4ms 的最低限制,也就是即使你參數設為 0、最快也還是一樣要等 4ms 之後才會進入新的 macrotask(實務上會更久一點點)。這聽起來好像不多,但是這邊我們是要追求極致的效能,所以是錙銖必較的。

一個比較快一點的方法是利用 MessageChannel 的自我呼叫:

const channel = new MessageChannel();

export function doEvents(): Promise<void> {
    return new Promise(resolve => {
        channel.port1.onmessage = () => resolve();
        channel.port2.postMessage(null);
    });
}

這邊我順便把它包成了 Promise,以便我們可以在 async 函數當中用 await doEvents() 的方法來製造出 macrotask 之間的斷點,而且中間的間隔是可以到達只有零點幾個毫秒那麼快的。

分段渲染

剛才提到的分段渲染跟 TBT 也有很大的關係,因為我們不能夠一次讓 Vue 初始化太多元件,否則就會產生 long task。於是我就利用前述的 doEvents() 來製造斷點並且遞增 phase 的值,並利用該變數去觸發部份元件的載入。最後我大致上拆了六七個階段才把整個應用程式全部的元件都載入完畢,使得每一個 macrotask 的時間都充分地短。

使用 Worker

自己的程式碼要拆成小的 macrotask 當然是相對好解決的,比較頭大的則是第三方程式庫,因為這部份我們能夠控制的相對有限。如果程式庫的功能並不牽涉到 UI 的話,一個最簡單的解法其實就是把程式庫改成用 Worker 的方式來啟動和使用;一旦我們這麼做的話,不管該程式跑了多久,都不會被算在 TBT 當中。以 BPS 來說,它引用了兩個跟壓縮有關的程式庫 LZMA.js 和 jszip,這兩個都只是單純用來計算壓縮與解壓縮,無關乎 UI,所以都可以改用 worker 來跑 5

Code splitting

然而,我相信絕大部分的程式庫應該都是跟 UI 有些關係的。尤其有些第三方程式庫打包起來很大(特別是 BPS 新版本所採用的 Pixi.js),這樣的程式庫別說是裡面的程式碼執行了、光是瀏覽器載入(更精確來說是即時編譯) script 所需的時間就已經超過 50ms 了、而且這也是會算在 TBT 上頭的!6

要解決這個問題,唯一的解法就是:盡可能不要讓打包出來的 script 太大(我實驗的心得是,最好 minify 之後不要超過 200KiB 左右)。首先第一個能做的事情就是樹搖(tree shaking),這個機制許多打包工具都有,可以去掉程式庫裡面一些沒有用到的部份。不過在我的情況中,Pixi.js 即使做了樹搖之後也還是有 370KiB 左右,還是太大了。

此時我們剩下的一招就是想辦法把程式庫拆成兩個以上的檔案來打包,也就是所謂的 code splitting。不巧的是,這個機制在各種打包工具之間普遍地還沒有一個很好的支援——有些根本完全沒有這個功能,有些是只支援自動拆解而無法手動指定、有些則是沒辦法在建置目標為 IIFE 或 UMD 的時候進行拆解,限制很多。據我所知(雖然我必須承認我對建置工具的研究不廣)好像只有老牌的 Webpack 真的能比較輕鬆且高度自訂地做到這一點,可是我基於別的原因又不是很想用 Webpack。

所以最後我的辦法就是手動用 esbuild 搭配 @fal-works/esbuild-plugin-global-externals 這個套件來進行拆解打包(後者在各種 esbuild 的全域變數外掛當中,只有它可以生成出乾淨得許多、效能也較好的程式碼)。其概念大致是這樣的:Pixi.js 本身是由若干的組件打包而成的,我把其中最核心的 @pixi/core 組件獨立打包成一個檔案、輸出全域變數 PixiCore,而剩下其它的那些 @pixi/xxx 組件再打包成另一個檔案、並且叫它們去跟 PixiCore@pixi/core 裡面的東西這樣。如此一來的兩個檔案大約分別為 200KiB 和 170KiB 左右,編譯的時間就可以擠進 50ms 了。

盡可能不要有任何立即執行的程式碼

說到這裡,就值得補充另一個重點。常常我們會忍不住在一個模組或命名空間的最上層(或是類別的靜態欄位)當中執行一些初始化模組內部資料的程式碼;不要這麼做。好一點的作法應該是把那些初始化的程式碼包在一個例如叫 init() 的函數當中、並從模組中匯出,以便我們可以完全掌控它們應該要在什麼時候被執行、以及是以什麼樣的順序被執行。

這原因在於,任何那類最上層的程式碼都會在瀏覽器編譯完 script 之後立刻就被執行,而 script 的編譯本身就往往已經耗了很多時間了,繼續執行那一堆初始化的程式碼就把 long task 又拖得更久了。而藉由把所有初始化的程式碼都包在函數當中,我們就可以在程式碼載入之後先插入一個斷點再做初始化,而且要是這麼做了初始化還是花了太多時間,我們也可以很自由地控制哪些要先執行、斷點之後再執行哪些等等。

當然,這個要領是只適用於我們自己的程式碼的;幾乎所有的第三方程式庫都會有很多這種立即執行的初始化程式碼,而且這也沒什麼辦法,因為作為一個對象廣泛的程式庫,不太可能把我們這邊講的極致效能優化擺在使用上的便利性之前。所以如果整體執行時間太長,那就想辦法拆解吧!

程式庫非同步化

雖然 Pixi.js 的載入速度過關了,但是其初始化仍舊是一個很耗時的 long task。關於這個問題我有開了一個 issue 在建議改進,但是因為這個牽涉到很多他們程式碼的架構考量,短期之中肯定是不會馬上解決的,最快估計也要等到 Pixi.js v8 才有可能,所以暫時我也是只好自己想辦法魔改以解決問題。

先說明,基本上我是因為實在很想拿到 PageSpeed Insights 的行動裝置滿分才來做這一段的優化,不然一般而言我並不會建議這麼做,因為我這邊要講的作法非常之 hack、隨時可能因為程式庫的內部實作改變而發生問題,增加額外的維護成本。幸好,我覺得會遇到這種程式庫既是與 UI 相關、又是有 long task 的情況應該是不常見的(Pixi 會出現是因為它要進行 WebGL 的初始化的緣故),所以我認為一般而言各位也不會需要進行這種魔改,前面提到的幾招應該就夠用了。

我們這邊的需求再描述一次就是這樣:我們想讓本來是同步執行的程式碼、變成是中間有插入斷點的非同步程式碼。但另外一方面,我們希望盡可能地沿用程式庫本身的程式碼,而不是全部自己重寫一遍。先舉一個最簡單的例子,假設執行的程式有如下的粗略形式:

A();
B(); // 這兩個都是耗時的操作

此時事情當然很簡單,我們就直接插入斷點就好了:

(async function() {
    A();
    await doEvents();
    B();
})();

那如果耗時的程式是呼叫堆疊較深處的地方呢?例如,假設它是存在於某個類別的方法當中:

class C {
    public method(): void {
        // 這是一個耗時的方法
    }
}

而這個類別 C 會在 long task 中的某處被建構、接著其 method() 會被呼叫,那怎麼辦呢?由於我們可能很難抽換建構 C 的程式碼,所以去繼承這個類別並覆寫方法的意義不大,然而我們倒是可以去修改其 prototype

const oldMethod = C.prototype.method;
C.prototype.method = function(): void { // 注意方法的傳回類別維持不變
    doEvents().then(() => oldMethod.call(this));
}

暫時這當然 OK,但問題是如果 method() 的執行結果會在呼叫了該方法之後被用到呢?這個時候我們就需要多動一點手腳:

let methodPromise: Promise<void>;
const oldMethod = C.prototype.method;
C.prototype.method = function(): void {
    methodPromise = doEvents().then(() => oldMethod.call(this));
}

然後我們再看看如何讓後面相依的程式碼去 await 這個 methodPromise,這個就必須見招拆招了,但大方向是這樣的:

  1. 想辦法讓原本位於 method() 後面的程式碼先不要繼續執行。這可以藉由一層層修改 prototype 來做到,或者最後的手段是在 method() 後面丟出例外來強制終止當前的 macrotask 的執行(如果不想讓 console 太醜的話,在最上層接住即可)。
  2. await methodPromise 之後,再設法回到剛才中斷的地方繼續執行。方法可以是執行同樣的入口點、但這次反過來設法跳過前半段的程式碼,或者自己手動呼叫後續的方法或函數(如果不多的話)。

如果這種真正的中斷與繼續很難做到的話,有的時候,我們甚至可以允許後面的程式碼暫時取到錯誤的值,一直到最後我們才自己去 await methodPromise、然後再把正確的值寫入到該寫入的地方,視架構而定也是有機會的。

我用了類似這樣的作法把 Pixi.js 初始化的整個 long task 分解成五個 macrotask 之後,才終於把 TBT 徹底壓低到了最終的程度。

結語

最後值得說明的一點是,即使所有的努力都做足了,PageSpeed Insights 跑出來的效能分數也還是每次會有很大的浮動的,因為這要牽涉到很多諸如網路連線負荷與測試伺服器本身的負荷等等的問題。自從我幾天前首次成功刷出行動裝置效能滿分以來,至今我也只刷出過三四次滿分而已,其它時候如果差一點的話也是可能會掉到八十幾分的。不過這部份就不是咱們的問題了,所以很確信自己已經優化到極致的話,就努力地刷看看吧!

另外,如果一路改進到了行動裝置的測試偶爾能刷出滿分的程度的話,大致上來說電腦版的測試就會是常態的滿分了,所以也不能說這是完全沒意義的偏執追求(雖然很大的成份上來說確實是 🤣)。


  1. 新版本還在持續開發當中,目前預定六月左右正式上線。 

  2. 這部份在本文撰寫的時候 PageSpeed Insights 的官方文件還沒有更新相關資訊,閱讀文件時請特別注意。 

  3. 一個跟這個有關的指標是 TTFB(Time to First Byte)。 

  4. 因為 BPS 是純靜態的網站,所以無法採用 SSR。當然,如果可以採用的話,很多這邊會遇到的問題(包括多國語系)都可以有更好的解法,因為我可以根據瀏覽器請求送來的 accept-language header 來產生出不同的靜態內容。 

  5. jszip 本身並沒有內建 worker 的功能,所以要自己重新打包以加入這部份的功能;而 LZMA.js 提供的 lzma_worker.min.js 檔案有 mangling 上的錯誤使得它其實無法被使用,這我必須手動修正之。 

  6. 如果採用 ES module 的寫法的話,在 Chromium 瀏覽器當中可以使用 <link rel="modulepreload"> 的寫法來提前載入模組、並且另開執行緒來平行編譯,從而達到很好的效能,但是這個機制截至本文寫成為止、在 Firefox 和 Safari 當中都不支援,甚至它們連 preload 模組都做不到(如此一來,在那些瀏覽器當中的效能反而大打折扣)。 


分享此頁至:
最後修改日期: 2023/05/10

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。