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

我們如何在 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 秒時出現,因此基本上不會受到這項異動影響。我們在這裡做的是感知上的變更。有些人甚至會稱之為「手法」。透過呈現遊戲的中間視覺效果,我們可以改善載入效能。

內嵌

開發人員工具和 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 部分,我們將討論如何在超受限裝置上改善執行階段效能。