更快速的多頁應用程式串流功能

目前的瀏覽配置、網站或網頁應用程式 (如有需要),可以採用下列其中一種導覽配置:

  • 根據預設,瀏覽器會提供瀏覽配置瀏覽器,也就是在瀏覽器的網址列中輸入網址,瀏覽要求會傳回文件做為回應。接著點選連結,卸載其他文件的「ad infinitum」
  • 單頁應用程式模式,需要初始瀏覽要求載入應用程式殼層,並透過 JavaScript 為每個「導覽」的後端 API 內容,以用戶端算繪的標記填入應用程式殼層。

這兩種做法的優點都深受其王國的啟發:

  • 瀏覽器預設提供的瀏覽配置方式具有彈性,因為即使路徑不需要使用者專用的 JavaScript 也能存取。透過 JavaScript 來轉譯標記也可能耗用大量資源,也就是說,低階裝置最後可能會因為封鎖提供內容的處理指令碼,導致低階裝置發生內容延遲的情形。
  • 另一方面,單頁應用程式 (SPA) 可在初始載入後加快瀏覽速度。與其依賴瀏覽器卸載全新的文件 (每次瀏覽時重複此操作),即使需要 JavaScript 才能正常運作,他們也能享有更快速、更「類似應用程式」的使用體驗。

在本文中,我們將介紹第三種做法是在這兩種方法之間取得平衡的方法:透過「服務工作處理程序」預先快取網站的常用元素 (例如標頭和頁尾標記),以及使用串流以盡快將 HTML 回應提供給用戶端,同時仍使用瀏覽器的預設瀏覽配置。

為什麼要在服務工作處理程序中串流 HTML 回應?

網路瀏覽器在提出要求時已採用串流功能。就導覽要求而言,這一點非常重要,因為瀏覽器不會在回應所有回應之前就阻止瀏覽器開始剖析文件標記並轉譯網頁。

圖表說明非串流 HTML 與串流 HTML。就前者的情況而言,系統必須等到完整標記酬載送達後,才會處理這類酬載。在後者中,標記會在從網路區塊傳入時逐步處理。

Service Worker 的串流功能是使用 JavaScript Streams API 而有些許差異。服務工作站執行的重要工作是攔截及回應要求,包括導航要求。

雖然這些要求可以透過多種方式與快取互動,但常見的快取模式是優先使用網路的回應,但如果可以使用較舊的副本,就會改為使用快取中的回應;如果可用的回應不在快取中,則可選擇提供一般備用回應

這是經過時間測試的模式,才能正常運作,但對於離線存取的可靠性,對於仰賴網路優先或僅限網路策略的導覽要求,這項功能本身無法帶來任何效能優勢。這時串流功能就能派上用場,我們也會探討如何在 Workbox Service Worker 中使用採用 Streams API 的 workbox-streams 模組,加快多網頁網站上的導覽要求。

細分一般網頁

具體來說,網站希望在每個網頁上都具備共通元素。網頁元素典型的排列方式通常如下:

  • 標題。
  • 內容。
  • 頁尾。

web.dev 為範例,常見元素的細目如下所示:

web.dev 網站上常見元素的細目。標有「標題」、「內容」和「頁尾」的共同區域會標有「標題」、「內容」和「頁尾」字樣。

識別網頁部分背後的目標在於,我們必須先判斷哪些網頁可供預先快取及擷取 (亦即所有網頁通用的標頭和頁尾標記,以及本例中必需先前往網路的部分),

知道如何區隔網頁各個部分並找出常見元素後,我們可以編寫 Service Worker,讓服務工作人員一律從快取即時擷取頁首和頁尾標記,但「只」要求來自網路的內容。

然後透過 workbox-streams 使用 Streams API,我們就能將這幾個部分拼接起來,並立即回應導覽要求,同時從網路要求最低限度的標記量。

建構串流服務工作站

在服務 Worker 中串流部分內容時,有許多動態部分,但我們會從你開始建構網站結構開始時,詳細說明流程的每個步驟。

將網站劃分為部分

您必須先完成以下三件事,才能開始編寫串流服務 Worker:

  1. 建立只包含網站標頭標記的檔案。
  2. 建立只包含網站頁尾標記的檔案。
  3. 將各個網頁的主要內容衍生到個別檔案中,或設定後端,根據 HTTP 要求標頭有條件地只提供網頁內容。

正如您所想,最後一步是最難的,尤其是如果網站屬於靜態性質時更是如此。如果是這種情況,您必須為每個網頁產生兩種版本:一個包含「完整版」網頁標記,另一個則只包含內容。

編寫串流服務工作站

如果尚未安裝 workbox-streams 模組,除了目前安裝的 Workbox 模組以外,還需執行此操作。在這個具體範例中,涉及下列套件:

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

接下來,請建立新的 Service Worker,並預先快取部分標頭和頁尾部分。

預先快取部分

首先,請在專案的根目錄中,建立名稱為 sw.js (或任何您偏好的檔案名稱) 的 Service Worker。讓我們先從以下內容著手:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

這段程式碼會執行以下幾項操作:

  1. 支援導覽功能的瀏覽器啟用導覽預先載入功能。
  2. 預先快取標頭和頁尾標記。也就是說,系統會立即擷取每個網頁的標頭和頁尾標記,因為該標記不會遭到網路封鎖。
  3. 使用 injectManifest 方法預先快取 __WB_MANIFEST 預留位置中的靜態資產。

串流回應

讓 Service Worker 串流串連回應,是這項工作最重要的環節。即便如此,Workbox 和 Google 的 workbox-streams 也讓這項工作更簡潔,比您自行執行所有作業來得簡單:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

此程式碼包含符合下列要求的三個主要部分:

  1. NetworkFirst 策略可用於處理內容部分的要求。使用這項策略時,content 的自訂快取名稱會指定內容部分,以及用於處理是否要為不支援瀏覽預先載入功能的瀏覽器設定 X-Content-Mode 要求標頭 (因此不會傳送 Service-Worker-Navigation-Preload 標頭) 的自訂外掛程式。這個外掛程式也會判斷是否要傳送部分內容的最後快取版本,或是在未儲存目前要求且沒有快取版本的情況下,傳送離線備用網頁。
  2. workbox-streams 中的 strategy 方法 (在此別名為 composeStrategies) 是用來串連預先快取標頭和頁尾部分,以及從網路要求的部分內容。
  3. 整個配置會透過 registerRoute 針對導航要求啟動。

備妥這個邏輯後,我們便能設定串流回應。不過,您可能要在後端執行某些作業,以確保來自網路的內容是可與預快取部分合併的部分網頁。

如果網站設有後端

您會發現,在導覽預先載入功能已啟用時,瀏覽器會傳送值為 trueService-Worker-Navigation-Preload 標頭。不過,在上述程式碼範例中,我們已不支援在瀏覽器預先載入事件導覽預先載入中傳送 X-Content-Mode 的自訂標頭。在後端,您必須根據這些標頭的存在變更回應。在 PHP 後端,某個網頁可能如下所示:

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

在上述範例中,系統將以函式的形式叫用內容部分,並採用 $isPartial 的值來變更部分片段的算繪方式。舉例來說,content 轉譯器函式可能只在擷取部分時納入特定標記,這稍後會說明。

考量重點

在部署 Service Worker 以進行串流及拼接部分資料之前,您必須先考量以下事項。以這種方式使用 Service Worker 確實不會徹底改變瀏覽器的預設導覽行為,但仍有一些事項可能需要處理。

瀏覽時更新網頁元素

這種方法最棘手的部分,就是必須更新用戶端的部分內容。舉例來說,預先快取標頭標記代表網頁的 <title> 元素中會有相同的內容,甚至每次瀏覽時,都必須更新導覽項目的開啟/關閉狀態。每次導航要求時,這些項目 (和其他項目) 都需要在用戶端上更新。

解決方法可能是將內嵌 <script> 元素置入來自網路的部分內容,以便更新幾項重要事項:

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

如果您決定採用這個 Service Worker 設定,可能需要完成的許多設定,以上僅是其中之一。例如,如果是包含使用者資訊的較複雜的應用程式,您可能需要將一些相關資料儲存在網路商店 (例如 localStorage),然後更新網頁。

處理速度緩慢的網路

網路連線速度較慢時,使用預先快取標記進行串流回應的缺點之一。問題是,預先快取的標頭標記會立即取得,但網路的部分內容在標頭標記的首次繪製後可能需要一些時間才會送達。

這樣可能會讓使用者感到困惑,如果網路速度很慢,甚至會覺得網頁損毀,甚至無法繼續顯示。在這種情況下,您可以選擇在內容部分的標記中加入載入圖示或訊息,在內容載入後將其隱藏。

其中一種方法是透過 CSS。假設標頭部分結尾有空白 <article> 開頭元素,直到內容部分送達時再填入。您可以編寫類似以下的 CSS 規則:

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

這項功能運作正常,但無論網路速度為何,用戶端都會在用戶端顯示載入訊息。如要避免顯示不尋常的訊息,您可以嘗試這個方法,也就是在 slow 類別中,將上述程式碼片段以巢狀結構嵌入上述程式碼片段中:

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

您可以在這裡在標頭部分使用 JavaScript 讀取有效連線類型 (至少使用 Chromium 瀏覽器),將 slow 類別新增至特定連線類型的 <html> 元素中:

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

這麼做可確保速度低於 4g 類型的有效連線類型會收到載入訊息。然後,在大部分內容中,您可以使用內嵌 <script> 元素,從 HTML 移除 slow 類別,移除載入訊息:

<script>
  document.documentElement.classList.remove('slow');
</script>

提供備用回應

假設您對部分內容使用網路優先策略,如果使用者處於離線狀態,並前往他們先前瀏覽的頁面,系統會自動涵蓋他們。不過,如果他們前往的是「尚未」瀏覽的網頁,就不會看到任何資訊。如要避免這種情況,您必須提供備用回應。

達成備用回應所需的程式碼如先前的程式碼範例所示。這項程序需要兩個步驟:

  1. 預先快取離線備用回應。
  2. 在外掛程式中設定 handlerDidError 回呼,以便使用網路優先策略,檢查上次存取網頁版本的快取。如果從未存取網頁,則必須使用 workbox-precaching 模組中的 matchPrecache 方法,才能從預先快取中擷取備用回應。

快取與 CDN

如果您在服務工作站中使用這個串流模式,請評估以下情況是否適用您的情況:

  • 使用 CDN 或任何其他類型的中繼/公開快取。
  • 您指定的 Cache-Control 標頭含有非零的 max-age 和/或 s-maxage 指令,並搭配使用 public 指令

如果您同時遇到這兩種情況,中繼快取可能會保留導覽要求的回應。不過請注意,當您使用這個模式時,可能會針對特定網址提供兩種不同的回應:

  • 完整回應,內含標頭、內容和頁尾標記。
  • 只包含內容的部分回應。

服務工作站可能會從 CDN 快取擷取完整回應,並與預先快取的標頭和頁尾標記合併,因此可能導致一些非預期的行為,產生雙重標頭和頁尾標記。

如要解決這個問題,您需要仰賴 Vary 標頭,這會對要求中顯示的一或多個標頭建立可快取的回應,藉此影響快取行為。由於我們會根據 Service-Worker-Navigation-Preload 和自訂 X-Content-Mode 要求標頭改變導航要求的回應方式,因此必須在回應中指定以下 Vary 標頭:

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

有了這個標頭,瀏覽器就能區分導覽要求的完整性和部分回應,避免重複標頭與頁尾標記的問題,如同任何中繼快取。

成果

大多數的載入時間效能建議縮減了「展示您的成果」部分,千萬不要退縮,別等到一切就緒,再向使用者顯示任何內容。

Jake Archibald 的Fun Hacks 縮短內容

瀏覽器在處理瀏覽要求的回應時表現良好,即使是大型的 HTML 回應主體也是如此。根據預設,瀏覽器會逐步串流及處理標記,避免長時間執行工作,這對啟動效能很有幫助。

我們使用串流服務工作處理程序模式時,這項功能就能派上用場。只要您從 Go-go 回應來自 Service Worker 快取的要求,回應的開始時間幾乎就會立即送達。將預先快取的標頭和頁尾標記與網路的回應合併後,可帶來顯著的效能優勢:

  • First Byte (TTFB) 值通常會大幅減少,因為對導航要求的回應第一個位元組可立即執行。
  • 首次顯示內容繪製 (FCP) 的流程將非常快,因為預先快取標頭標記會包含快取樣式表的參照,也就是說,網頁的繪製速度非常快。
  • 在某些情況下,最大內容繪製 (LCP) 的速度也較快,尤其是在預先快取標頭部分提供畫面中最大的畫面元素時。即便如此,如果能同時在具有較小的標記酬載的情況下,盡快透過 Service Worker 快取提供「某些內容」,這種做法可以帶來更好的 LCP。

串流多頁架構的設定及疊代作業可能有些困難,但實際複雜程度通常不會比理論上更複雜。主要的好處是,系統不會取代瀏覽器的預設導覽配置,而是為了「強化」

更棒的是,Workbox 讓這個架構不但可行,也比您自行實作時更加容易。在自己的網站上試一下,看看您的多網頁網站能提升多少實際速度。

資源