本系列文章將以一個實際專案作為研究物件,探討離線可用這個 PWA 的重要特性在 SSR 架構中的應用思路。最後結合 Vue SSR 進行實際應用。
本文作為第一部分,以 PWA-Directory__ 為例。這是一個陳列 PWA 專案的站點,同時展示專案 Lighthouse 分數及其他頁面效能資料。
在下一 Part 中,我們將順著這一思路,結合 Vue SSR 在專案中進行實際應用(關注OpenWeb開發者,及時獲取文章)。
本文假設讀者對 PWA 相關技術尤其是 Service Worker 的基礎知識已有一定了解。
App Shell 模型
App Shell 是支援使用者介面所需的最小的 HTML、CSS 和 JavaScript。對其進行離線快取,可確保在使用者重複訪問時提供即時、可靠的良好效能。這意味著並不是每次使用者訪問時都要從網路載入 App Shell。 只需要從網路中載入必要的內容。
PWA-Directory 包括我們後續的討論都基於 App Shell 模型。下面我們需要了解一下快取的細節。
預快取
Service Worker 最重要的功能之一便是控制快取。這裡先簡單介紹下預快取或者說 sw-precache 外掛的基本工作原理。
在專案構建階段,將靜態資源列表(陣列形式)及本次構建版本號注入 Service Worker 程式碼中。在 SW 執行時(Install 階段)依次傳送請求獲取靜態資源列表中的資源(JS、CSS、HTML、IMG、FONT...),成功後放入快取並進入下一階段(Activated)。這個在實際請求之前由 SW 進行快取的過程就是預快取。
在 SPA/MPA 架構的應用中,App Shell 通常包含在 HTML 頁面中,連同頁面一併被預快取,保證了離線可訪問。但是在 SSR 架構場景下,情況就不一樣了。所有頁面首屏均是服務端渲染,預快取的頁面不再是有限且固定的。如果預快取全部頁面,SW 需要傳送大量請求不說,每個頁面都包含的 App Shell 部分都被重複快取,也造成了快取空間的浪費。
既然針對全部頁面的預快取行不通,我們能不能將 App Shell 剝離出來,單獨快取僅包含這個空殼的頁面呢?要實現這一點,就需要對後端模板進行修改,通過傳入引數控制返回包含 App Shell 的完整頁面 OR 程式碼片段。這樣首屏使用完整頁面,而後續頁面切換交給前端路由完成,請求程式碼片段進行填充。這也是基於 React、Vue 等技術實現的同構專案的基本思路。
對於後端模板的修改並不複雜,例如在 PWA-Directory 中,使用 Handlebars 作為後端模板,通過自定義的 contentOnly 引數就能適應首屏和後續 HTML 片段兩種請求。其餘模板語言例如 WordPress 使用的 php 也是同樣的思路。
// list.hbs
{{#unless contentOnly}}
<!DOCTYPE html>
<html lang="en">
<head>
{{> head}}
</head>
<body>
{{> header}}
<div class="page-holder">
<main class="page">
{{/unless}}
... 頁面具體內容
{{#unless contentOnly}}
</main>
<div class='page-loader'>
</div>
</div>
{{> footer}}
</body>
</html>
{{/unless}}複製程式碼
然後在 SW 中我們需要對 App Shell 頁面進行預快取,這裡使用了 sw-toolbox 。同時後端需要增加返回 App Shell 的路由規則,這裡是 /.app/shell。
// service-worker.js
const SHELL_URL = '/.app/shell';
const ASSETS = [
SHELL_URL,
'/favicons/android-chrome-72x72.png',
'/manifest.json',
...
];
// 使用 sw-toolbox 快取靜態資源
toolbox.precache(ASSETS);複製程式碼
最後我們攔截掉所有 HTML 請求,請求目標頁面的內容片段而非完整程式碼(getContentOnlyUrl 執行了 contentOnly 引數拼接工作),返回之前快取的 App Shell 頁面。
// service-worker.js
toolbox.router.default = (request, values, options) => {
// 攔截 HTML 請求
if (request.mode === 'navigate') {
// 請求 HTML 程式碼片段
toolbox.cacheFirst(new Request(getContentOnlyUrl(request.url)), values, options);
// 返回 App Shell 頁面
return getFromCache(SHELL_URL)
.then(response => response || gulliverHandler(request, values, options));
}
return gulliverHandler(request, values, options);
};複製程式碼
有一點值得注意,通常請求目標頁面內容片段是放在前端路由中完成的,而這裡放在了 SW 中,有什麼好處呢?這一點 PWA-Directory 開發者有一篇文章__進行了專門討論,這裡就直接使用文中的圖片進行說明了。
先看看之前的做法,也就是在前端路由中:
可以看出,app.js 載入並執行時才會發出 HTML 程式碼片段的請求,然後等待服務端響應。整個過程中 SW 處於空閒狀態,而事實上第一次攔截到 HTML 請求時,SW 就完全可以先請求程式碼片段了(拼上引數),拿到響應後放入快取中。這樣當 app.js 前端路由執行發出請求時,瀏覽器發現該片段已經在快取中,可以直接拿來使用。當然為了實現這一點,需要在服務端通過設定響應頭 Cache-Control: max-age 保證內容片段的快取時間。
總結一下這個思路:
- 改造後端模板以支援返回完整頁面和內容片段
- 服務端增加一條針對 App Shell 的路由規則,返回僅包含 App Shell 的 HTML 頁面
- 預快取 App Shell 頁面
- SW 攔截所有 HTML 請求,統一返回快取的 App Shell 頁面
- 前端路由負責程式碼片段的填充,完成前端渲染
實際效果是,使用者第一次訪問應用站點時,首屏由服務端渲染,隨後 SW 安裝成功後,後續的路由切換包括重新整理頁面都將由前端渲染完成,服務端將只負責提供 HTML 程式碼片段的響應。
解決了預快取問題,下面我們需要關注另外一個離線可用目標中涉及的關鍵問題。
資料統計
在衡量 PWA 效果時,至少有以下幾個指標可以考量:
- 當彈出新增到桌面的 banner 時,使用者選擇同意或是拒絕
- 當前的操作是否是來自新增到桌面之後
- 當前的操作是否發生在離線狀態下
通過 beforeinstallprompt__事件,可以輕易獲取使用者對新增到桌面 banner 的反應:
window.addEventListener('beforeinstallprompt', e => {
console.log(e.platforms); // e.g., ["web", "android", "windows"]
e.userChoice.then(outcome => {
console.log(outcome); // either "installed", "dismissed", etc.
}, handleError);
});複製程式碼
通過在 manifest.json 的 start_url 中新增引數,很容易標識出當前的使用者訪問來自新增後的桌面快捷方式。例如使用 GA Custom campaigns__:
// manifest.json
{
"start_url": "/?utm_source=homescreen"
}複製程式碼
最後,使用 navigator.onLine__ 就能夠判斷當前是否處於離線狀態。但是要注意,返回 true 時不代表真的可以訪問網際網路。
現在我們有了這些統計指標,接下來的問題就是如何保證離線狀態下產生的統計資料不丟失。一個很自然的想法是,在 SW 中攔截所有統計請求,離線時將統計資料儲存在本地 LocalStorage 或者 IndexedDB 中,上線後再進行資料的同步。
Google 之前針對 GA 開發了 sw-offline-google-analytics 類庫實現了這一功能,現在已經移到了 Workbox 中作為一個獨立模組 workbox-google-analytics__ 存在,可以很方便地使用:
// service-worker.js
importScripts('path/to/offline-google-analytics-import.js');
workbox.googleAnalytics.initialize();複製程式碼
這樣離線統計的問題就解決了。以上部分程式碼以 GA 為例,不過其他統計指令碼思路也是一致的。
離線使用者體驗
最後說說這個專案在離線使用者體驗上的亮點。PWA 中的離線使用者體驗絕不僅僅只是展示離線頁面代替瀏覽器“恐龍”而已。離線時,“我究竟能使用哪些功能?”往往是使用者最關心的。讓我們來看看 PWA-Directory 在這一點上是怎麼做的。
在離線時,可以彈出 Toast(圖中下方紅色部分)給予使用者提示。要實現這一點並不難,通過監聽 online/offline 事件就能做到,接下來才是亮點。
前面說過,離線時使用者很關心能訪問哪些內容,如果能通過樣式顯式標註就再好不過了。在上圖中,我訪問過第一個 Tab “New” 下列表中的第一個專案,所以此時離線時,頁面中其餘部分都被置灰且不可點選,只有快取過的內容被保留了下來,使用者將不再有四處點選遇到同樣離線頁面的挫敗感。
要實現這一點可以從兩方面入手,首先從全域性樣式上,離線時給 body 或者具體頁面容器加個自定義屬性,關心離線功能的元件在這個規則下定義自己的離線樣式就行了。
window.addEventListener('offline', () => {
// 給容器加上自定義屬性
document.body.setAttribute('offline', 'true');
});複製程式碼
另外具體到某些特定元件,例如這個專案中的列表項,點選每個 PWA 專案的連結都將進入對應的詳情頁,首次訪問會被加入 runtimeCache,因此只需要在快取中按連結地址進行查詢,就能知道這個列表項是否應該置灰。
// 判斷連結是否訪問過
isAvailable(href) {
if (!href || this.window.navigator.onLine) return Promise.resolve(true);
return caches.match(href)
.then(response => response.status === 200)
.catch(() => false);
}複製程式碼
總之,離線使用者體驗是需要根據實際專案情況進行精心設計的。
總結
從 PWA 特性尤其是離線快取來看,對於 SSR 架構的專案,進行 App Shell 的分離是很有必要的。相比 SPA/MPA 的預快取方案,SSR 需要對後端模板,前端路由進行一些改造。另外,對於 PWA 相關資料的統計和離線同步,可以借鑑應用 Google 的 Workbox 方案。最後,離線使用者體驗也是需要仔細考量的。
如果感興趣,可以深入瞭解一下 PWA-Directory 的程式碼__,同時結合開發者的幾篇技術文章:
在下一 Part 中,我們將使用 Vue SSR 結合 Workbox 在專案中實踐這一思路:)。
參考資料
- App Shell 模型 | Web Google Developers__
- Offline Cookbook | Web Google Developers__
- PWA-Directory 關於請求內容片段的優化__
- PWA-Directory 的設計思路__
- 基於 GA 的 PWA 指標統計__
- GA 離線統計__
- Workbox Codelab
Brilliant Open Web
BOW(Brillant Open Web)團隊,是一個專門的Web技術建設小組,致力於推動 Open Web 技術的發展,讓Web重新成為開發者的首選。
BOW 關注前端,關注Web;剖析技術、分享實踐;談談學習,也聊聊管理。
關注 OpenWeb開發者,點選“加群”,讓我們一起推動 OpenWeb技術的發展!