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

這類網站/網站應用程式 (如有需要) 通常會使用以下其中一種導覽配置:

  • 瀏覽器預設提供的瀏覽配置,也就是在瀏覽器的網址列中輸入網址,且「導覽要求」會傳回文件做為回應。接著點選連結,將目前的文件卸載至另一個 ad infinitum
  • 單頁應用程式模式,包括初始導覽要求以載入 application shell,以及依賴 JavaScript 在應用程式殼層中填入用戶端轉譯的標記,並針對每個「導覽」動作,使用後端 API 的內容。

每種方法的優點都是由供應商認可:

  • 瀏覽器預設提供的瀏覽機制較為彈性,因為路徑不需要 JavaScript 即可存取。依 JavaScript 用戶端轉譯標記的作業也可能是昂貴的程序,也就是說,低階裝置可能會因裝置封鎖了提供內容的處理指令碼而導致內容延遲,而造成內容延遲的情況。
  • 另一方面,單頁應用程式 (SPA) 可在初次載入後提供更快的瀏覽速度。他們不必透過瀏覽器卸載文件,打造全新的文件 (並在每次瀏覽時重複上述步驟),提供更快速、更「類似應用程式」的體驗體驗。

在這篇文章中,我們將介紹第三種方法,可以兼顧上述兩種做法:透過服務工作處理程序預先快取網站的常見元素 (例如標頭和頁尾標記),以及透過串流向用戶端提供 HTML 回應,同時仍保持使用瀏覽器的預設導覽配置。

為什麼要在 Service Worker 中串流 HTML 回應?

「串流」是網路瀏覽器發出要求時既有的功能。這對於導覽要求來說極為重要,因為這樣可確保瀏覽器在等待完整的回應後,才會開始剖析文件標記並轉譯網頁。

這張圖表說明非串流 HTML 與串流 HTML。如果是前者,整個標記酬載只有在送達時,才會進行處理。第二種情況則是標記從網路中分批接收標記時,以漸進方式處理標記。

對於服務工作站而言,串流會因使用 JavaScript Streams API 而略有不同。Service Worker 會執行最重要的工作,就是攔截及回應要求 (包括導航要求)。

這類要求可以透過多種方式與快取互動,但標記的常見快取模式就是採用網路回應,但在提供較舊的副本時,改為使用快取;如果快取中沒有可用的回應,可選擇提供一般備用回應

這個模式需要一段時間測試才能有效運作,但除了能提升離線存取功能,還無法提供任何固有效能優勢的導覽要求 (僅仰賴網路或僅限網路的策略)。串流這時就能派上用場。我們也會探討如何在 Workbox 服務工作處理程序中使用採用 Streams API 的 workbox-streams 模組,加快多網頁網站的導航要求。

解析一般網頁

因此,請謹記這個原則:網站通常在每個網頁上都具備相同的元素。常見的網頁元素排列方式如下:

  • 內容。
  • 頁尾。

web.dev 為例,常見元素細目如下:

web.dev 網站上常用元素的詳細分析。公共區域標示了「標題」、「內容」和「頁尾」。

識別網頁的部分目標,在於 Google 能判斷在不透過網路網路的情況下可預先快取及擷取哪些內容 (亦即所有網頁都通用的標頭和頁尾標記,以及我們一律會前往網路的部分,在本例中是指內容)。

我們知道如何區隔網頁各個部分及識別常見元素後,即可編寫服務工作處理程序,隨時從快取立即擷取標頭和頁尾標記,同時「只」向網路要求內容。

接著,透過 workbox-streams 使用 Streams API,我們就可以將這幾個部分合併在一起,並立即回應導覽要求,同時只要求網路最低必要的標記數量。

建構串流 Service Worker

要在 Service Worker 中串流部分內容,有許多工作要務,但隨著你開始串流,我們會先詳細探討流程的每個步驟,從如何建構網站開始。

按部分細分網站

開始編寫串流 Service Worker 前,您需要先完成以下三件事:

  1. 建立只包含網站標頭標記的檔案。
  2. 建立只包含網站頁尾標記的檔案。
  3. 將各網頁的主要內容提取至個別檔案中,或是設定後端,根據 HTTP 要求標頭有條件地只提供網頁內容。
,瞭解如何調查及移除這項存取權。

按照可能的情況,最後一步是最困難的,特別是網站靜態時。如果是這種情況,您必須為每個網頁產生兩個版本:一個版本含有「完整」網頁標記,另一個版本則只包含內容。

編寫串流 Service Worker

如果您尚未安裝 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 及其 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

如果您在 Service Worker 中使用了這個串流模式,請確認下列事項是否適用於您的情況:

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

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

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

這可能會導致一些非預期的行為,導致重複標頭和頁尾標記,因為服務 Worker 可能會從 CDN 快取擷取完整回應,並與您的預先快取標頭和頁尾標記合併。

如要解決這個問題,您需要依靠 Vary 標頭。這個標頭會將可快取的回應傳送至要求中的一或多個標頭,進而影響快取行為。我們會根據 Service-Worker-Navigation-Preload 和自訂 X-Content-Mode 要求標頭,改變對導覽要求的回應,因此必須在回應中指定下列 Vary 標頭:

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

透過這個標頭,瀏覽器可以區分瀏覽要求的完整和部分回應,避免雙重標頭和頁尾標記發生問題,如同任何中繼快取。

成果

大多數的載入時間效能建議都追溯到「顯示實際成果」。別先等候一切就緒,再向使用者顯示任何內容。

Jake Archibald 參閱「加快內容速度的趣味妙招」一文

瀏覽器擅長處理瀏覽要求的回應,即使是大型 HTML 回應內文也一樣。根據預設,瀏覽器會逐步將標記串流並處理成分塊,以免執行長時間的工作,這對啟動效能有利。

使用串流 Service Worker 模式時,這項優勢可發揮自身優勢。每當您從一開始就回應來自 Service Worker 快取的要求時,回應的開始時間幾乎是立刻抵達。將預先快取的標頭標記和頁尾標記結合使用網路回應,就能享有顯著的效能優勢:

  • 由於導覽要求的回應第一個位元組為即時,因此第一個位元組的時間 (TTFB) 通常會大幅減少。
  • 首次顯示內容所需時間 (FCP)非常快速,因為預快取標頭標記會包含快取樣式表的參照,因此網頁繪製速度非常快。
  • 在某些情況下,最大內容繪製 (LCP) 速度也可能會更快,特別是當畫面上最大的元素是由預先快取的標頭提供時。即使如此,在搭配較少的標記酬載時,若僅提供「部分」服務工作處理程序快取,則可能會改善 LCP。

串流多頁面架構在設定及反覆執行方面可能有些困難,但相關複雜性通常比 SPA 理論上更為複雜。主要優點是您不需要取代瀏覽器的預設瀏覽配置,而是將瀏覽器「強化」

Workbox 更棒的是,比起自行實作,Workbox 絕對能做到這點。歡迎在網站上試用看看,親眼見證您的多網頁網站能為使用者帶來多快的體驗。

資源