如何在SSR架構中實現離線可用?(一)

本系列文章將以一個實際項目做爲研究對象,探討離線可用這個 PWA 的重要特性在 SSR 架構中的應用思路。最後結合 Vue SSR 進行實際應用。php

本文做爲第一部分,以 PWA-Directory__ 爲例。這是一個陳列 PWA 項目的站點,同時展現項目 Lighthouse 分數及其餘頁面性能數據。html

在下一 Part 中,咱們將順着這一思路,結合 Vue SSR 在項目中進行實際應用(關注OpenWeb開發者,及時獲取文章)。前端


PWA-Directory

本文假設讀者對 PWA 相關技術尤爲是 Service Worker 的基礎知識已有必定了解。android

App Shell 模型

App Shell 是支持用戶界面所需的最小的 HTML、CSS 和 JavaScript。對其進行離線緩存,可確保在用戶重複訪問時提供即時、可靠的良好性能。這意味着並非每次用戶訪問時都要從網絡加載 App Shell。 只須要從網絡中加載必要的內容。git


App Shell 模型

PWA-Directory 包括咱們後續的討論都基於 App Shell 模型。下面咱們須要瞭解一下緩存的細節。github

預緩存

Service Worker 最重要的功能之一即是控制緩存。這裏先簡單介紹下預緩存或者說 sw-precache 插件的基本工做原理。web

在項目構建階段,將靜態資源列表(數組形式)及本次構建版本號注入 Service Worker 代碼中。在 SW 運行時(Install 階段)依次發送請求獲取靜態資源列表中的資源(JS、CSS、HTML、IMG、FONT...),成功後放入緩存並進入下一階段(Activated)。這個在實際請求以前由 SW 進行緩存的過程就是預緩存。chrome

在 SPA/MPA 架構的應用中,App Shell 一般包含在 HTML 頁面中,連同頁面一併被預緩存,保證了離線可訪問。可是在 SSR 架構場景下,狀況就不同了。全部頁面首屏均是服務端渲染,預緩存的頁面再也不是有限且固定的。若是預緩存所有頁面,SW 須要發送大量請求不說,每一個頁面都包含的 App Shell 部分都被重複緩存,也形成了緩存空間的浪費。shell

既然針對所有頁面的預緩存行不通,咱們能不能將 App Shell 剝離出來,單獨緩存僅包含這個空殼的頁面呢?要實現這一點,就須要對後端模板進行修改,經過傳入參數控制返回包含 App Shell 的完整頁面 OR 代碼片斷。這樣首屏使用完整頁面,然後續頁面切換交給前端路由完成,請求代碼片斷進行填充。這也是基於 React、Vue 等技術實現的同構項目的基本思路。json

對於後端模板的修改並不複雜,例如在 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 保證內容片斷的緩存時間。


SW 請求代碼片斷流程圖

總結一下這個思路:

  1. 改造後端模板以支持返回完整頁面和內容片斷
  2. 服務端增長一條針對 App Shell 的路由規則,返回僅包含 App Shell 的 HTML 頁面
  3. 預緩存 App Shell 頁面
  4. SW 攔截全部 HTML 請求,統一返回緩存的 App Shell 頁面
  5. 前端路由負責代碼片斷的填充,完成前端渲染

實際效果是,用戶第一次訪問應用站點時,首屏由服務端渲染,隨後 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.jsonstart_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 在這一點上是怎麼作的。


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 在項目中實踐這一思路:)。


參考資料

Brilliant Open Web

BOW(Brillant Open Web)團隊,是一個專門的Web技術建設小組,致力於推進 Open Web 技術的發展,讓Web從新成爲開發者的首選。

BOW 關注前端,關注Web;剖析技術、分享實踐;談談學習,也聊聊管理。

關注 OpenWeb開發者,點擊「加羣」,讓咱們一塊兒推進 OpenWeb技術的發展!

相關文章
相關標籤/搜索