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

BrilliantOpenWeb發表於2017-11-07

本系列文章將以一個實際專案作為研究物件,探討離線可用這個 PWA 的重要特性在 SSR 架構中的應用思路。最後結合 Vue SSR 進行實際應用。

本文作為第一部分,以 PWA-Directory__ 為例。這是一個陳列 PWA 專案的站點,同時展示專案 Lighthouse 分數及其他頁面效能資料。

在下一 Part 中,我們將順著這一思路,結合 Vue SSR 在專案中進行實際應用(關注OpenWeb開發者,及時獲取文章)。


PWA-Directory

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

App Shell 模型

App Shell 是支援使用者介面所需的最小的 HTML、CSS 和 JavaScript。對其進行離線快取,可確保在使用者重複訪問時提供即時、可靠的良好效能。這意味著並不是每次使用者訪問時都要從網路載入 App Shell。 只需要從網路中載入必要的內容。


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 保證內容片段的快取時間。


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技術的發展!

相關文章