加快網頁應用程式載入速度的技巧,就算是功能型手機也能快速載入

我們如何在 PROXX 中使用程式碼分割、程式碼內嵌和伺服器端轉譯功能。

在 2019 年 Google I/O 大會上,Mariko、Jake 和我推出了 PROXX,這是一款現代化的網頁版掃雷遊戲。PROXX 的特色在於重視無障礙功能 (您可以使用螢幕閱讀器遊玩遊戲),以及在功能型手機和高階電腦裝置上執行的效能。功能型手機受到多種限制:

  • 效能較低的 CPU
  • 效能較差或不存在的 GPU
  • 沒有觸控輸入功能的小螢幕
  • 記憶體量極為有限

但這些裝置可執行新式瀏覽器,而且價格實惠。因此,功能型手機在新興市場再度崛起。這類產品的價格讓先前無法負擔的全新目標對象,得以上網並使用現代網際網路。根據預估,2019 年單單在印度就會售出約 4 億部功能型手機,因此功能型手機使用者可能會成為您目標對象的重要族群。此外,新興市場的連線速度通常類似 2G 網路。我們如何讓 PROXX 在功能手機的條件下正常運作?

PROXX 遊戲畫面。

效能非常重要,包括載入效能和執行階段效能。研究顯示,良好的成效與使用者留存率提升、轉換率提升,以及最重要的包容性提升有關。Jeremy Wagner 提供更多資料和洞察,說明為何成效至關重要

本文為上下兩集系列文的上半部。第 1 部分著重於載入效能,第 2 部分則著重於執行階段效能。

擷取現況

實際裝置上測試載入效能至關重要。如果您沒有實體裝置,建議您使用 WebPageTest,特別是「簡易」設定WPT 會在實際裝置上執行一系列載入測試,並模擬 3G 連線。

3G 網路速度是測量速度的良好指標。雖然您可能習慣使用 4G、LTE 或即將推出的 5G,但行動網路的實際情況卻大不相同。也許你在火車上、參加會議、參加演唱會或搭乘飛機時,你在那裡的體驗很可能更接近 3G,有時甚至更糟。

不過,我們會在本文中著重討論 2G,因為 PROXX 的目標對象明確鎖定功能型手機和新興市場。WebPageTest 執行測試後,您會看到瀑布圖 (類似於在 DevTools 中看到的內容),以及頂端的膠卷。這條膠卷會顯示應用程式載入期間,使用者看到的內容。在 2G 網路上,未經最佳化處理的 PROXX 版本載入體驗相當糟糕:

這部短片展示,當 PROXX 透過模擬 2G 連線在實際低階裝置上載入時,使用者會看到什麼畫面。

透過 3G 載入時,使用者會看到 4 秒的空白畫面。超過 2G 時,使用者會在 8 秒內完全看不到任何內容。如果你看過「為何效能至關重要」一文,就知道我們現在因使用者缺乏耐心而失去許多潛在使用者。使用者必須下載所有 62 KB 的 JavaScript,畫面上才能顯示任何內容。在這種情況下,好消息是,只要畫面上出現任何內容,就會變成可互動內容。或是說有可能呢?

未經最佳化的 PROXX 版本中的 [第一個有意義的 Paint][FMP] 在_技術上_是 [互動式][TTI],但對使用者而言毫無用處。

在下載約 62 KB 的 gzip 壓縮 JS 並產生 DOM 之後,使用者就能看到應用程式。應用程式「技術上」是互動式的。不過,從圖片中可看出實際情況有所不同。網路字型仍在背景中載入,使用者無法看到文字,直到字型載入完成為止。雖然這個狀態符合第一個有意義的繪製 (FMP) 的條件,但由於使用者無法辨識任何輸入內容,因此不符合正確的互動狀態。在 3G 網路上,應用程式需要再等待 1 秒,在 2G 網路上則需要等待 3 秒,才能準備就緒。總而言之,應用程式在 3G 網路上需要 6 秒,在 2G 網路上則需要 11 秒才能就緒。

刊登序列分析

既然我們知道使用者看到的內容,就需要找出原因。為此,我們可以查看時間軸,並分析資源載入時間過晚的原因。在 PROXX 的 2G 追蹤記錄中,我們可以看到兩個主要的警訊:

  1. 有多條不同顏色的細線。
  2. JavaScript 檔案會形成鏈結。舉例來說,第二個資源只有在第一個資源完成後才會開始載入,第三個資源則是在第二個資源完成後才會開始。
瀑布圖可讓您瞭解哪些資源正在載入,以及所需時間。

減少連線數量

每條細線 (dnsconnectssl) 代表建立新的 HTTP 連線。設定新連線的成本較高,因為在 3G 網路上需要約 1 秒,在 2G 網路上則需要約 2.5 秒。在瀑布圖中,我們看到以下項目的新連結:

  • 要求 #1:我們的 index.html
  • 要求 #5:fonts.googleapis.com 的字型樣式
  • 要求 #8:Google Analytics
  • 要求 #9:來自 fonts.gstatic.com 的字型檔案
  • 要求 #14:網頁應用程式資訊清單

index.html 的新連線是不可避免的。瀏覽器必須建立與伺服器的連線,才能取得內容。您可以透過內嵌 最小 Analytics 等方式,避免建立新的 Google Analytics 連線,但 Google Analytics 不會阻止應用程式算繪或變成互動式,因此我們不太在意載入速度。理想情況下,Google Analytics 應在其他所有內容都已載入的空閒時間載入。這樣一來,系統在初始載入期間就不會占用頻寬或處理能力。網頁應用程式資訊清單的新連線是由擷取規格規定,因為資訊清單必須透過非憑證連線載入。再次強調,網頁應用程式資訊清單不會阻止應用程式算繪或變成互動式,因此我們不必太在意。

不過,這兩種字型和其樣式會阻擋算繪和互動功能,我們來看看 fonts.googleapis.com 提供的 CSS,只有兩個 @font-face 規則,每個字型各一個。字型樣式實在太小,因此我們決定將其內嵌到 HTML 中,移除一個不必要的連結。為了避免為字型檔案設定連線的成本,我們可以將這些檔案複製到自己的伺服器。

並行載入

從瀑布圖中,我們可以看到第一個 JavaScript 檔案載入完成後,新檔案就會立即開始載入。這是模組依附元件的常見情況。我們的主模組可能含有靜態匯入項目,因此必須載入這些匯入項目,JavaScript 才能執行。這裡的重要概念是,這類依附元件會在建構期間提供。我們可以使用 <link rel="preload"> 標記,確保在收到 HTML 的同時,所有依附元件都會開始載入。

結果

讓我們來看看這些變更帶來的成果。請務必不要在測試設定中變更任何其他可能導致結果偏差的變數,因此我們會在本文的其餘部分使用 WebPageTest 的簡易設定,並查看膠卷:

我們使用 WebPageTest 的膠卷,瞭解變更後的效果。

這些變更將 TTI 從 11 降低至 8.5,這大致是我們希望移除的連線設定時間 2.5 秒。我們做得好。

預先算繪

雖然我們剛剛降低了 TTI,但並未真正影響使用者必須忍受的無限長白幕畫面,時間長達 8.5 秒。可以說,透過在 index.html 中傳送樣式化標記,可以為 FMP帶來最大改善。達成這項目標的常見做法是預先轉譯和伺服器端轉譯,這兩者密切相關,詳情請參閱「在網路上進行轉譯」一文。這兩種方法都會在 Node 中執行網頁應用程式,並將產生的 DOM 序列化為 HTML。伺服器端算繪會在伺服器端針對每項要求執行此操作,而預先算繪會在建構期間執行此操作,並將輸出內容儲存為新的 index.html。由於 PROXX 是 JAMStack 應用程式,且沒有伺服器端,因此我們決定實作預先轉譯功能。

實作預先轉譯器的方法有很多種。在 PROXX 中,我們選擇使用 Puppeteer,這個工具可在沒有任何 UI 的情況下啟動 Chrome,並讓您透過 Node API 遠端控制該執行個體。我們會使用這個方法插入標記和 JavaScript,然後將 DOM 讀回為 HTML 字串。由於我們使用的是 CSS 模組,因此我們可以免費取得所需樣式的 CSS 內嵌。

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

有了這項功能,我們可以期待 FMP 的改善。我們仍需載入及執行與先前相同量的 JavaScript,因此 TTI 不太可能有太大變化。我們的 index.html 變得更大,可能會稍微延後 TTI。只有一個方法可以找出問題:執行 WebPageTest。

這部電影膠片顯示,我們的 FMP 指標明顯改善。TTI 幾乎不受影響。

畫面首次有效顯示所需時間從 8.5 秒縮短為 4.9 秒,大幅改善。我們的 TTI 仍會在 8.5 秒左右發生,因此這項異動對 TTI 的影響不大。我們在這裡做的是感知上的變更。有些人甚至會稱之為「手法」。透過呈現遊戲的中間視覺效果,我們可以改善載入效能。

內嵌

開發人員工具和 WebPageTest 提供的另一項指標是第一個位元組時間 (TTFB)。這是從發送要求的第一個位元組到收到回應的第一個位元組所需的時間。這段時間也常被稱為封包往返時間 (RTT),但從技術層面來說,這兩個數字之間存在差異:RTT 不包含伺服器端要求的處理時間。DevTools和 WebPageTest 會在要求/回應區塊中以淺色顯示 TTFB。

要求的淺色部分表示要求正在等待接收回應的第一個位元組。

從瀑布圖中,我們可以看到所有要求都將大部分時間花在等待回應的第一個位元組到達

這正是 HTTP/2 Push 最初的設計目的。應用程式開發人員知道需要特定資源,並可推送這些資源。當用戶端發現需要擷取其他資源時,這些資源可能已位於瀏覽器的快取中。HTTP/2 Push 的實作難度太高,因此不建議使用。我們會在 HTTP/3 標準化期間重新審視這個問題空間。目前最簡單的解決方案,就是將所有重要資源內嵌,但這樣會犧牲快取效率。

我們已透過 CSS 模組和以 Puppeteer 為基礎的預先算繪工具,將重要的 CSS 內嵌。針對 JavaScript,我們需要內嵌重要的模組及其依附元件。這項作業的難易度會因您使用的 bundler 而異。

透過 JavaScript 內嵌,我們將 TTI 從 8.5 秒縮短至 7.2 秒。

這讓 TTI 縮短了 1 秒。我們現在已達到 index.html 包含初始轉譯和互動所需的所有內容。HTML 可以在下載期間算繪,並建立我們的 FPM。一旦 HTML 完成剖析及執行,應用程式就會變成互動式。

積極分割程式碼

是的,我們的 index.html 包含所有互動功能所需的內容。但仔細檢查後,發現它也包含其他所有內容。我們的 index.html 約為 43 KB。我們來看看使用者一開始可以與哪些元素互動:我們有一個表單可用來設定遊戲,其中包含幾個元件、一個開始按鈕,以及可能會有一些程式碼,用於儲存及載入使用者設定。大致上就是這樣。43 KB 似乎太多了。

PROXX 的到達網頁。這裡只會使用重要元件。

如要瞭解套件大小的來源,我們可以使用原始碼對照圖探索工具或類似工具,將套件拆解為各個元件。如預期,我們的套件包含遊戲邏輯、算繪引擎、勝利畫面、失敗畫面和許多公用程式。到達網頁只需要少數幾個模組。將所有非互動性必要的內容移至延遲載入的模組,可大幅降低 TTI。

分析 PROXX 的 `index.html` 內容後,我們發現有許多不必要的資源。醒目顯示重要資源。

我們需要進行程式碼分割。程式碼分割功能會將單體套件分割成可視需要延遲載入的小型部分。WebpackRollupParcel 等熱門整合工具支援使用動態 import() 進行程式碼分割。Bundler 會分析您的程式碼,並以靜態方式匯入所有模組內嵌。您動態匯入的所有內容都會放入專屬檔案,且只有在執行 import() 呼叫時才會從網路擷取。當然,使用網路也會產生成本,因此請只在有空時才進行這項操作。這裡的要訣是,在載入時靜態匯入必要的模組,並動態載入其他所有模組。不過,請勿等到最後一刻才載入確定會使用的模組。Phil WaltonIdle Until Urgent模式,是懶載和急載之間的健康平衡點。

在 PROXX 中,我們建立了 lazy.js 檔案,用於靜態匯入「不需要」的所有內容。接著,我們可以在主要檔案中動態匯入 lazy.js。不過,部分 Preact 元件最終會出現在 lazy.js 中,這會造成一些複雜性,因為 Preact 無法處理延遲載入的元件。因此,我們編寫了一個小型 deferred 元件包裝函式,讓我們在實際元件載入前,先顯示預留位置。

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

有了這個,我們就可以在 render() 函式中使用元件的 Promise。舉例來說,在載入元件時,會將用於算繪動畫背景圖片的 <Nebula> 元件,替換為空白的 <div>。元件載入並準備就緒後,<div> 就會替換為實際元件。

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

完成所有這些步驟後,我們將 index.html 縮減至僅 20 KB,比原本的大小小了超過一半。這對 FMP 和 TTI 有何影響?WebPageTest 會告訴您!

影片帶證實:我們的 TTI 現已縮短至 5.4 秒。相較於原先的 11 秒,大幅改善。

我們的 FMP 和 TTI 之間只有 100 毫秒的差距,因為這只是剖析及執行內嵌 JavaScript 的問題。在 2G 網路上,應用程式只需 5.4 秒就能完全互動。所有其他較不重要的模組則會在背景載入。

更多手法

如上所述,您會發現轉譯引擎並非重要模組的一部分。當然,遊戲必須先有算繪引擎才能算繪遊戲。我們可以將「Start」按鈕停用,直到轉譯引擎準備好啟動遊戲為止,但根據我們的經驗,使用者通常需要花費相當長的時間設定遊戲設定,因此這項做法並非必要。在使用者按下「Start」時,算繪引擎和其他剩餘模組通常已完成載入。在少數情況下,如果使用者比網路連線速度快,我們會顯示簡單的載入畫面,等待其餘模組完成。

結論

評估成效非常重要。為了避免花時間處理不存在的問題,建議您一律先進行評估,再導入最佳化措施。此外,如果沒有實際裝置,則應在 3G 連線的實際裝置上進行測量,或使用 WebPageTest

這張膠卷可讓您深入瞭解使用者在應用程式載入期間的感受。瀑布圖可讓您瞭解哪些資源可能導致載入時間過長。以下是可改善載入效能的檢查清單:

  • 透過單一連線提供盡可能多的素材資源。
  • 預先載入或內嵌資源,這些資源是首次轉譯和互動所需的。
  • 預先算繪應用程式,以改善載入效能。
  • 善用積極的程式碼分割技巧,減少互動功能所需的程式碼量。

敬請期待第 2 部分,我們將討論如何在超限裝置上改善執行階段效能。