SSR 架構項目實現離線可用(思路&案例)
本系列文章將以一個實際項目作為研究對象,探討離線可用這個 PWA 的重要特性在 SSR 架構中的應用思路。最後結合 Vue SSR 進行實際應用。
本文作為第一部分,以 PWA-Directory 為例。這是一個陳列 PWA 項目的站點,同時展示項目 Lighthouse 分數及其他頁面性能數據。
在下一 Part 中,我們將順著這一思路,結合 Vue SSR 在項目中進行實際應用。
本文假設讀者對 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.jsconst 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.jstoolbox.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.jsimportScripts("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 的代碼,同時結合開發者的幾篇技術文章:
- 結合 App Shell 優化內容載入速度
- 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
推薦閱讀:
TAG:pwa |