除 SPA 之外 - PWA 的替代架構

接著來談談... 架構?

我會說明一個重要但可能被誤解的主題:您用於網頁應用程式的架構,特別是在建構漸進式網頁應用程式時,架構決策如何發揮作用。

「架構」聽起來可能有點模糊,而且可能不知道為什麼這樣很重要。建立架構時,您可以問自己下列問題:使用者造訪自家網站上的網頁時,系統載入了哪種 HTML? 那麼,當使用者造訪其他網頁時,瀏覽器會載入哪些資料?

這些問題的答案不一定總是這麼簡單,而且當您開始思考漸進式網頁應用程式後,應用程式可能會變得更加複雜。我的目標是逐一介紹一個我認為有效的架構在本文中,我做出的決定是「我的方針」,是建構漸進式網頁應用程式。

您可以在建構自己的 PWA 時自由運用我的方法,但在此同時,也有其他有效的替代方案。希望看到所有元件如何相輔相成,能夠帶來啟發,您也很有能力根據自身需求加以自訂。

Stack Overflow PWA

為搭配本文,我建構了 Stack Overflow PWA。我花費大量時間閱讀 Stack Overflow 並進行貢獻,而且想要建構一個網頁應用程式,方便他們瀏覽特定主題的常見問題。此應用程式以公開的 Stack Exchange API 為基礎建構而成。這套開放原始碼為開放原始碼,如要瞭解詳情,請造訪 GitHub 專案

多頁面應用程式 (MPA)

在深入說明之前 我們先定義一些術語並解釋其基礎技術首先,我將說明 「多重網頁應用程式」或「MPA」

自網路發展以來,MPA 是傳統架構的精密名稱。每次使用者前往新網址時,瀏覽器都會逐步轉譯該網頁專屬的 HTML。系統不會嘗試保留網頁的狀態或切換瀏覽之間的內容。每次造訪新頁面時,都是從頭開始。

這與建構網頁應用程式的單頁應用程式 (SPA) 模式不同,後者會在使用者造訪新區段時,瀏覽器執行 JavaScript 程式碼來更新現有頁面。SPA 和 MPA 都是有效的使用模型,但在本文中,我想探索多頁面應用程式的 PWA 概念。

速度穩定

也有人知道使用「漸進式網頁應用程式」或 PWA 這句話。您可能已經熟悉一些背景資料,也就是這個網站的其他位置

您可以將 PWA 想像成網頁應用程式,能提供一流的使用者體驗,而且真正可以在使用者的主畫面上賺取收益。縮寫為「FIRE」,標示為「F」、「已」、「可靠」和「運算」,即為建構 PWA 時應考量的所有屬性。

在本文中,我會著重於這些屬性的子集:「快速」和「可靠」

快: 雖然「快速」意思在不同情境下代表不同之處,但我會盡量少用在網路載入的速度優勢。

穩定:但原始速度還不夠。如要體驗如同 PWA,網頁應用程式應該擁有可靠的穩定性。它必須具備彈性,才能始終載入內容,即使它只是自訂的錯誤頁面,不論網路狀態為何。

穩定迅速:最後,我會稍微改寫 PWA 的定義,並檢視建構運作快速可靠的應用程式所代表的意義。只有在使用低延遲網路時,彼此的快速穩定度不理想。穩定快速的意思是,無論基礎網路狀況為何,網頁應用程式的速度都很穩定。

啟用技術:服務工作處理程序 + Cache Storage API

PWA 將導入速度與彈性極高,幸運的是,網路平台提供一些建構模塊,以實現此類效能。也就是服務工作站Cache Storage API

您可以透過 Cache Storage API 建構 Service Worker,來監聽傳入要求、將部分傳送至網路,並儲存回應副本供日後使用。

使用 Cache Storage API 儲存網路回應副本的 Service Worker。

下次網頁應用程式發出相同的要求時,服務工作站就能檢查其快取,並直接傳回先前快取的回應。

使用 Cache Storage API 來回應 (繞過網路) 的 Service Worker。

盡可能避免使用網路,是提供穩定快速效能的重要環節。

「不變形」JavaScript

我想說明的另一個概念,就是所謂的「半體態」或「通用」JavaScript。簡單來說,就是可在不同的執行階段環境之間共用相同的 JavaScript 程式碼。建構 PWA 時,我想在後端伺服器和 Service Worker 之間共用 JavaScript 程式碼。

有很多都可透過這種方式共用程式碼的有效方法,但我的做法是使用 ES 模組做為最終原始碼。接著,我使用 BabelRollup 的組合來轉譯並組合用於伺服器和 Service Worker 的模組。在我的專案中,副檔名為 .mjs 的檔案是位於 ES 模組中的程式碼。

伺服器

請記住這些概念和術語,讓我們來深入瞭解我實際建構 Stack Overflow PWA 的方式。我要先介紹我們的後端伺服器 並說明這種做法在整體架構中的運用方式

我需要使用動態後端和靜態託管的組合,而我的做法是使用 Firebase 平台。

收到傳入要求時,Firebase Cloud Functions 會自動啟動以節點為基礎的環境,並與我熟悉的主流 Express HTTP 架構整合。並且針對我網站的所有靜態資源提供立即可用的託管功能。我們來看看伺服器如何處理要求

瀏覽器向我們的伺服器發出導航要求時,會經歷以下流程:

產生伺服器端導覽回應的總覽。

伺服器會根據網址轉送要求,並使用範本邏輯建立完整的 HTML 文件。我同時使用了 Stack Exchange API 的資料,以及伺服器儲存在本機的部分 HTML 片段。服務工作人員知道如何回應後,就能開始將 HTML 串流傳回我們的網頁應用程式。

這張圖有兩部分值得一探究竟:轉送和範本。

路線

處理轉送問題時,我的做法是使用 Express 架構的原生轉送語法。在路徑中比對簡式網址前置字串以及含有參數的網址,在路徑中十分有彈性。在此,我會為要比對的基礎 Express 模式,在路徑名稱之間建立對應

const routes = new Map([
  ['about', '/about'],
  ['questions', '/questions/:questionId'],
  ['index', '/'],
]);

export default routes;

然後我就能直接從伺服器程式碼參照這個對應關係。 當特定 Express 模式有相符項目時,適當的處理常式會以比對路徑專屬的範本邏輯回應。

import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
  // Templating logic.
});

伺服器端範本

這個範本邏輯又是什麼樣子?於是,我想過一個方法 依序拼湊部分 HTML 片段這個模型非常適合串流方式。

伺服器會立即傳回一些初始 HTML 樣板,瀏覽器就能立即轉譯該部分網頁。伺服器將其餘資料來源一起串流至瀏覽器中,直到文件完成為止。

如要瞭解這代表什麼,請參閱我們其中一條路線的快速程式碼

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

使用 response 物件的 write() 方法並參照本機儲存的部分範本,就能立即啟動回應串流,而不會封鎖任何外部資料來源。瀏覽器會接收初始 HTML 程式碼,並立即顯示有意義的介面並載入訊息。

下一部分會使用來自 Stack Exchange API 的資料。取得資料,即表示伺服器必須發出網路要求網頁應用程式必須等到取得回應並進行處理,才能轉譯其他任何內容,但至少使用者在等待期間,至少不會看畫面空白。

網頁應用程式收到 Stack Exchange API 的回應後,就會呼叫自訂範本函式,將 API 的資料轉譯成對應的 HTML。

範本語言

引文本來是一個令人出乎意料的主題 我提供的資訊就只是種種做法建議您替換自己的解決方案,特別是如果您的舊版範本與現有範本架構相關聯。

我的用途最合理,只要依賴 JavaScript 的範本常值,並將部分邏輯拆分為輔助函式。建構 MPA 的一大優點是,您不需要追蹤狀態更新和重新轉譯 HTML,因此產生靜態 HTML 的基本方法就是使用。

以下舉例說明如何建立網頁應用程式索引的動態 HTML 部分。與我的路徑相同,範本邏輯會儲存在 ES 模組中,可同時匯入伺服器和 Service Worker。

export function index(tag, items) {
  const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
  const form = `<form method="GET">...</form>`;
  const questionCards = items
    .map(item =>
      questionCard({
        id: item.question_id,
        title: item.title,
      })
    )
    .join('');
  const questions = `<div id="questions">${questionCards}</div>`;
  return title + form + questions;
}

這些範本函式是純 JavaScript,可協助您視情況將邏輯拆解為較小的輔助函式。在這裡,我會將 API 回應中傳回的每個項目傳遞至這類函式,建立具有所有適當屬性組合的標準 HTML 元素。

function questionCard({id, title}) {
  return `<a class="card"
             href="/questions/${id}"
             data-cache-url="${questionUrl(id)}">${title}</a>`;
}

「請特別注意」是一項資料屬性,可新增至每個連結 data-cache-url,設為顯示相應問題所需的 Stack Exchange API 網址。請留意這一點。我稍後再試。

完成範本作業後,回到路徑處理常式後,我會將網頁的最後一部分串流到瀏覽器中,並結束串流。這是瀏覽器接收漸進式轉譯的提示。

app.get(routes.get('index'), async (req, res) => {
  res.write(headPartial + navbarPartial);
  const tag = req.query.tag || DEFAULT_TAG;
  const data = await requestData(...);
  res.write(templates.index(tag, data.items));
  res.write(footPartial);
  res.end();
});

以上就是我的伺服器設定簡介使用者首次造訪我的網頁應用程式時,一律都會收到來自伺服器的回應,但當訪客返回我的網頁應用程式時,我的服務工作站就會開始回應。那就讓我們一探究竟吧

Service Worker

在 Service Worker 中產生導航回應的總覽。

這張圖表看起來應該很熟悉,我先前介紹的許多相同部分則略有不同。現在來看看要求流程,並將 Service Worker 納入考量。

我們的服務工作站會處理特定網址的傳入導覽要求,就像我的伺服器一樣,這項服務會使用轉送和範本邏輯的組合來找出回應方式。

做法與先前相同,但採用不同的低層級基本功能,例如 fetch()Cache Storage API。我使用這些資料來源建構 HTML 回應,而服務工作處理程序會將該回應傳回網頁應用程式。

Workbox

我不會從低階原始元件從頭開始建構服務工作站,而是在一組稱為 Workbox 的高階程式庫之上建構服務工作站。這項服務為任何服務工作站的快取、轉送和回應產生邏輯,提供堅實的基礎。

路線

就像我的伺服器端程式碼一樣,服務工作站必須知道如何將傳入要求與適當的回應邏輯進行比對。

我的做法是將各個 Express 路徑轉譯為對應的規則運算式,利用名為 regexparam 的實用程式庫。完成翻譯後,我就能利用 Workbox 內建的規則運算式轉送支援。

匯入具有規則運算式的模組後,我會使用 Workbox 的路由器註冊每個規則運算式。在每個路徑中,我都能提供自訂範本邏輯來產生回應。在 Service Worker 中編寫範本比在後端伺服器中還多,但 Workbox 有助於解決許多繁重的工作。

import regExpRoutes from './regexp-routes.mjs';

workbox.routing.registerRoute(
  regExpRoutes.get('index')
  // Templating logic.
);

靜態資產快取

範本故事的重要環節之一,就是確保部分 HTML 範本可透過 Cache Storage API 在本機使用,並在我將變更部署至網頁應用程式時保持最新狀態。用手操作時,快取維護可能容易出錯,所以我轉而使用 Workbox 處理建構過程中的預先快取

我會使用設定檔告知 Workbox 要預先快取哪些網址,指向包含所有本機資產以及一組要比對模式的目錄。Workbox 的 CLI 會自動讀取這個檔案,每次重新建構網站時,都會run

module.exports = {
  globDirectory: 'build',
  globPatterns: ['**/*.{html,js,svg}'],
  // Other options...
};

Workbox 會拍攝每個檔案內容的快照,並自動將該網址和修訂版本清單插入最終的 Service Worker 檔案中。Workbox 現在可以提供一切所需功能,讓預先快取檔案隨時可用,並保持最新狀態。結果是一個 service-worker.js 檔案,其中包含類似下列的內容:

workbox.precaching.precacheAndRoute([
  {
    url: 'partials/about.html',
    revision: '518747aad9d7e',
  },
  {
    url: 'partials/foot.html',
    revision: '69bf746a9ecc6',
  },
  // etc.
]);

如果是使用較複雜的建構程序,Workbox 除了指令列介面外,還提供 webpack 外掛程式一般節點模組和。

直播

接著,我想讓服務工作處理程序立即將預先快取的部分 HTML 串流回網頁應用程式。這是確保「快速運作」的關鍵要素,我總是能立即看到畫面上有意義的內容。幸好,在服務工作站中使用 Streams API 就能實現這一點。

您現在可能已經聽說過 Streams API。我的同事 Jake Archibald 就一直在唱他們的讚美他做出粗體的預測,指出 2016 年將會是網站串流的年份。Streams API 和過去兩年一樣出色 但其中還是有重大差異

當時只有 Chrome 才支援 Streams,但現在更廣泛支援 Streams API。整體故事是正面的,而且有了適當的備用程式碼,您現在就可以繼續在服務工作站中使用串流。

嗯...可能還有一件事阻止您,這就是 Streams API 的實際運作方式。不僅提供一組強大的基本功能,也相當有彈性的開發人員能夠建立複雜的資料流程,例如:

const stream = new ReadableStream({
  pull(controller) {
    return sources[0]
      .then(r => r.read())
      .then(result => {
        if (result.done) {
          sources.shift();
          if (sources.length === 0) return controller.close();
          return this.pull(controller);
        } else {
          controller.enqueue(result.value);
        }
      });
  },
});

但是,要瞭解程式碼的整體影響,不一定每個人都有所瞭解。接下來我們要說明服務工作站串流的方式,而不是透過這個邏輯進行剖析。

我使用全新的高階包裝函式 workbox-streams。有了這些資料,我就能混合串流來源的串流來源,包括快取和執行階段資料,傳遞可能來自網路的快取和執行階段資料。Workbox 會負責統整個別來源,並將這些來源整合為單一串流回應。

此外,Workbox 會自動偵測它是否支援 Streams API,如果不支援,則會建立對等的非串流回應。 也就是說,您不必擔心撰寫備用項,因為串流大小已接近 100% 瀏覽器支援。

執行階段快取

現在來看看我的服務工作站如何處理來自 Stack Exchange API 的執行階段資料。我正在運用 Workbox 的內建過時/重新驗證快取策略支援,以及到期時間,確保網頁應用程式的儲存空間不會無限增加。

我在 Workbox 中設定兩種策略,處理建立串流回應的不同來源。在某些函式呼叫和設定下,Workbox 可讓我們處理其他耗時數百行的手寫程式碼。

const cacheStrategy = workbox.strategies.cacheFirst({
  cacheName: workbox.core.cacheNames.precache,
});

const apiStrategy = workbox.strategies.staleWhileRevalidate({
  cacheName: API_CACHE_NAME,
  plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});

第一種策略會讀取已友善載入的資料,例如部分 HTML 範本。

另一項策略會實作過時的時重新驗證快取邏輯,並在達到 50 個項目時,將最近最少使用的快取到期時間設為過去。

我已部署這些策略,最後再指示 Workbox 如何使用這些策略建構完整的串流回應。我將來源陣列做為函式傳遞,每個函式將立即執行。Workbox 會擷取每個來源的結果,並依序串流至網頁應用程式,而且只會延遲陣列中的下一個函式尚未完成時。

workbox.streams.strategy([
  () => cacheStrategy.makeRequest({request: '/head.html'}),
  () => cacheStrategy.makeRequest({request: '/navbar.html'}),
  async ({event, url}) => {
    const tag = url.searchParams.get('tag') || DEFAULT_TAG;
    const listResponse = await apiStrategy.makeRequest(...);
    const data = await listResponse.json();
    return templates.index(tag, data.items);
  },
  () => cacheStrategy.makeRequest({request: '/foot.html'}),
]);

前兩個來源是預先快取的部分範本,可直接從 Cache Storage API 讀取,因此隨時都能立即使用。這可確保我們的 Service Worker 實作能夠穩定快速地回應要求,就像我的伺服器端程式碼一樣。

下一個來源函式會從 Stack Exchange API 擷取資料,然後將回應處理到網頁應用程式預期的 HTML。

「過時時重新驗證」策略是指如果已有此 API 呼叫的快取回應,在下次要求時更新快取項目時,可立即將回應串流至頁面。

最後,我串流頁尾的快取副本,並關閉最終的 HTML 標記來完成回應。

分享代碼可確保內容保持同步

您會發現某些 Service Worker 程式碼看起來很熟悉。Service Worker 使用的部分 HTML 和範本邏輯與伺服器端處理常式相同。無論使用者是首次造訪我的網頁應用程式,還是返回服務工作站轉譯的網頁,只要使用這組程式碼共用功能,就能確保使用者獲得一致的體驗。這正是異形 JavaScript 的美妙之處

動態、漸進式增強

我已介紹過 PWA 的伺服器和 Service Worker,但還有最後一項要涵蓋的邏輯:我的每個網頁都執行了少量 JavaScript,在網頁完全串流後執行。

雖然程式碼能逐步提升使用者體驗,但不一定如此;網頁應用程式在不執行的情況下仍可運作。

網頁中繼資料

我的應用程式會使用用戶端 JavaScipt,根據 API 回應來更新頁面的中繼資料。因為我為每個網頁使用相同的快取 HTML 初始位元,所以網頁應用程式最後會在文件標頭中加上通用標記。但是,透過範本和用戶端程式碼之間的協調,我可以使用頁面專屬的中繼資料來更新視窗標題。

做為範本程式碼的一部分,我的做法是加入包含適當逸出字串的指令碼標記。

const metadataScript = `<script>
  self._title = '${escape(item.title)}';
</script>`;

接著,頁面載入後,我就會讀取該字串並更新文件標題。

if (self._title) {
  document.title = unescape(self._title);
}

如果您想在自己的網頁應用程式中更新其他頁面專屬中繼資料,也可以遵循相同方法。

離線使用者體驗

我新增的其他漸進式增強功能是用在吸引大家注意我們的離線功能。我已建構可靠的 PWA,讓使用者瞭解離線時仍可載入先前造訪的網頁。

首先,我使用 Cache Storage API 取得先前快取 API 要求的完整清單,然後將其轉譯為網址清單。

還記得這些特殊資料屬性我剛才提到的,每個屬性都包含顯示問題所需的 API 要求網址嗎?我可以將這些資料屬性與快取網址清單交叉比對,並建立所有不相符的問題連結陣列。

當瀏覽器進入離線狀態時,「我要循環處理」未快取的連結清單,並調暗無效的連結。請注意,這只是一個視覺提示,說明使用者預期網頁的內容,我實際上並未停用這些連結,也沒有阻止使用者進行瀏覽。

const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);

const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
  return !cachedUrls.includes(card.dataset.cacheUrl);
});

const offlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '0.3';
  }
};

const onlineHandler = () => {
  for (const uncachedCard of uncachedCards) {
    uncachedCard.style.opacity = '1.0';
  }
};

window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);

常見陷阱

我已大致說明我們如何建構多頁 PWA。 構思自己的方法時,要考量許多因素,您最終所做的選擇可能與我不同。這樣的彈性是建構網路世界的一大重點。

制定自己的架構決策時,可能會遇到一些常見的陷阱,我想為您省下一些麻煩。

不要快取完整 HTML

建議您不要將完整的 HTML 文件儲存在快取中。對一件事來說,這是浪費空間的東西。如果網頁應用程式在各個網頁都使用相同的基本 HTML 結構,就會一再重複儲存相同的標記副本。

更重要的是,如果在網站共用的 HTML 結構部署變更,每個先前快取的網頁仍會卡住舊版面配置。假設回訪訪客同時看到新舊網頁,因而感到不悅,

伺服器 / Service Worker 偏離

另一個要避免的陷阱,牽涉到伺服器和服務工作站無法同步。我的方法是在使用變形 JavaScript,以便在兩個位置執行相同的程式碼。視您現有的伺服器架構而定 您不一定要如此

無論您做出何種架構決策,都應在伺服器和服務工作站中執行同等的轉送和範本程式碼。

最糟情況

版面配置 / 設計不一致

Google 忽略這些錯誤會造成什麼影響?那麼,各種失敗情形都不成問題,但最糟的情況是,回訪的使用者造訪了版面配置非常舊的快取網頁,例如網頁含有過時的標題文字,或是使用已經失效的 CSS 類別名稱。

最糟糕情況:轉送中斷

或者,使用者可能會看見您的伺服器處理的網址,而非服務工作站處理的網址。充滿殭屍版面配置和死結的網站並非可靠的 PWA。

成功秘訣

但你並不孤單!下列提示可協助您避免這些錯誤:

使用支援多語言實作的範本和路由程式庫

嘗試使用含有 JavaScript 實作的範本和轉送程式庫。我知道,並不是每個開發人員,都沒有餘裕從目前的網路伺服器遷移,並改用範本語言。

但許多熱門的範本和轉送架構,已提供多種語言的實作方式。如果您發現可以使用 JavaScript 和目前伺服器語言的語言,就距離服務工作站和伺服器同步距離只差一步。

偏好依序而非巢狀範本

接著,建議使用一系列依序處理的範本,這些範本可連續串流。如果網頁的後續部分使用較複雜的範本邏輯,只要能夠盡快串流在 HTML 的初始部分,也沒關係。

在 Service Worker 中快取靜態和動態內容

為獲得最佳效能,建議您預先快取網站的所有重要靜態資源。此外,您也應該要設定執行階段快取邏輯來處理動態內容,例如 API 要求。使用 Workbox 意味著您可以使用經過充分測試並可用於實際工作環境的策略進行建構,而非從頭開始實作。

只在絕對必要時於聯播網進行封鎖

與此相關的作業,建議您只在無法從快取串流回應時,才在網路上進行封鎖。立即顯示快取 API 回應,通常相較於等待最新資料,通常能改善使用者體驗。

資源