【PWA學習與實踐】(3) 讓你的WebApp離線可用

AlienZHOU發表於2018-04-08

《PWA學習與實踐》系列文章已整理至gitbook - PWA學習手冊,文字內容已同步至learning-pwa-ebook。轉載請註明作者與出處。

本文是《PWA學習與實踐》系列的第三篇文章。文中的程式碼都可以在learning-pwa的sw-cache分支上找到(git clone後注意切換到sw-cache分支)。

PWA作為時下最火熱的技術概念之一,對提升Web應用的安全、效能和體驗有著很大的意義,非常值得我們去了解與學習。對PWA感興趣的朋友歡迎關注《PWA學習與實踐》系列文章。

1. 引言

PWA其中一個令人著迷的能力就是離線(offline)可用。

即使在離線狀態下,依然可以訪問的PWA

離線只是它的一種功能表現而已,具體說來,它可以:

  • 讓我們的Web App在無網(offline)情況下可以訪問,甚至使用部分功能,而不是展示“無網路連線”的錯誤頁;
  • 讓我們在弱網的情況下,能使用快取快速訪問我們的應用,提升體驗;
  • 在正常的網路情況下,也可以通過各種自發控制的快取方式來節省部分請求頻寬;
  • ……

而這一切,其實都要歸功於PWA背後的英雄 —— Service Worker

那麼,Service Worker是什麼呢?你可以把Service Worker簡單理解為一個獨立於前端頁面,在後臺執行的程式。因此,它不會阻塞瀏覽器指令碼的執行,同時也無法直接訪問瀏覽器相關的API(例如:DOM、localStorage等)。此外,即使在離開你的Web App,甚至是關閉瀏覽器後,它仍然可以執行。它就像是一個在Web應用背後默默工作的勤勞小蜜蜂,處理著快取、推送、通知與同步等工作。所以,要學習PWA,繞不開的就是Service Worker。

PWA背後的英雄 —— Service Worker

在接下來的幾篇文章裡,我會從如何使用Service Worker來實現資源的快取、訊息的推送、訊息的通知以及後臺同步這幾個角度,來介紹相關原理與技術實現。這些部分會是PWA技術的重點。需要特別注意的是,由於Service Worker所具有的強大能力,因此規範規定,Service Worker只能執行在HTTPS域下。然而我們開發時候沒有HTTPS怎麼辦?彆著急,還有一個貼心的地方——為方便本地開發,Service Worker也可以執行在localhost(127.0.0.1)域下

好了,簡單瞭解了Service Worker與它能實現的功能後,我們還是要回到這一篇的主題,也就是Service Worker的第一部分——如何利用Service Worker來實現前端資源的快取,從而提升產品的訪問速度,做到離線可用。

2. Service Worker是如何實現離線可用的?

這一小節會告訴大家,Service Worker是如何讓我們在離線的情況下也能訪問Web App的。當然,離線訪問只是其中一種表現。

首先,我們想一下,當訪問一個web網站時,我們實際上做了什麼呢?總體上來說,我們通過與與伺服器建立連線,獲取資源,然後獲取到的部分資源還會去請求新的資源(例如html中使用的css、js等)。所以,粗粒度來說,我們訪問一個網站,就是在獲取/訪問這些資源。

可想而知,當處於離線或弱網環境時,我們無法有效訪問這些資源,這就是制約我們的關鍵因素。因此,一個最直觀的思路就是:如果我們把這些資源快取起來,在某些情況下,將網路請求變為本地訪問,這樣是否能解決這一問題?是的。但這就需要我們有一個本地的cache,可以靈活地將各類資源進行本地存取。

如何獲取所需的資源?

有了本地的cache還不夠,我們還需要能夠有效地使用快取、更新快取與清除快取,進一步應用各種個性化的快取策略。而這就需要我們有個能夠控制快取的“worker”——這也就是Service Worker的部分工作之一。順便多說一句,可能有人還記得 ApplicationCache 這個API。當初它的設計同樣也是為了實現Web資源的快取,然而就是因為不夠靈活等各種缺陷,如今已被Service Worker與cache API所取代了。

Service Worker有一個非常重要的特性:你可以在Service Worker中監聽所有客戶端(Web)發出的請求,然後通過Service Worker來代理,向後端服務發起請求。通過監聽使用者請求資訊,Service Worker可以決定是否使用快取來作為Web請求的返回。

下圖展示普通Web App與新增了Service Worker的Web App在網路請求上的差異:

普通Web請求(上)與使用Service Worker代理(下)的區別

這裡需要強調一下,雖然圖中好像將瀏覽器、SW(Service Worker)與後端服務三者並列放置了,但實際上瀏覽器(你的Web應用)和SW都是執行在你的本機上的,所以這個場景下的SW類似一個“客戶端代理”。

瞭解了基本概念之後,就可以具體來看下,我們如何應用這個技術來實現一個離線可用的Web應用。

3. 如何使用Service Worker實現離線可用的“秒開”應用

還記得我們之前的那個圖書搜尋的demo Web App麼?不瞭解的朋友可以看下本系列的第一篇文章,當然你可以忽略細節,繼續往下了解技術原理。

沒錯,這次我仍然會基於它進行改造。在上一篇新增了manifest後,它已經擁有了自己的桌面圖示,並有一個很像Native App的外殼;而今天,我會讓它變得更酷。

如果想要跟著文章內容一起實踐,可以在這裡下載到所需的全部程式碼。 記得切換到manifest分支,因為本篇內容,是基於上一篇的最終程式碼進行相應的開發與升級。畢竟我們的最終目標是將這個普通的“圖書搜尋”demo升級為PWA。

3.1. 註冊Service Worker

注意,我們的應用始終應該是漸進可用的,在不支援Service Worker的環境下,也需要保證其可用性。要實現這點,可以通過特性檢測,在index.js中來註冊我們的Service Worker(sw.js):

// index.js
// 註冊service worker,service worker指令碼檔案為sw.js
if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('./sw.js').then(function () {
        console.log('Service Worker 註冊成功');
    });
}
複製程式碼

這裡我們將sw.js檔案註冊為一個Service Worker,注意檔案的路徑不要寫錯了。

值得一提的是,Service Worker的各類操作都被設計為非同步,用以避免一些長時間的阻塞操作。這些API都是以Promise的形式來呼叫的。所以你會在接下來的各段程式碼中不斷看到Promise的使用。如果你完全不瞭解Promise,可以先在這裡瞭解基本的Promise概念:Promise(MDN)JavaScript Promise:簡介

3.2. Service Worker的生命週期

當我們註冊了Service Worker後,它會經歷生命週期的各個階段,同時會觸發相應的事件。整個生命週期包括了:installing --> installed --> activating --> activated --> redundant。當Service Worker安裝(installed)完畢後,會觸發install事件;而啟用(activated)後,則會觸發activate事件。

Service Worker生命週期

下面的例子監聽了install事件:

// 監聽install事件
self.addEventListener('install', function (e) {
    console.log('Service Worker 狀態: install');
});
複製程式碼

self是Service Worker中一個特殊的全域性變數,類似於我們最常見的window物件。self引用了當前這個Service Worker。

3.3. 快取靜態資源

通過上一節,我們已經學會了如何新增事件監聽,來在合適的時機觸發Service Worker的相應操作。現在,要使我們的Web App離線可用,就需要將所需資源快取下來。我們需要一個資源列表,當Service Worker被啟用時,會將該列表內的資源快取進cache。

// sw.js
var cacheName = 'bs-0-2-0';
var cacheFiles = [
    '/',
    './index.html',
    './index.js',
    './style.css',
    './img/book.png',
    './img/loading.svg'
];

// 監聽install事件,安裝完成後,進行檔案快取
self.addEventListener('install', function (e) {
    console.log('Service Worker 狀態: install');
    var cacheOpenPromise = caches.open(cacheName).then(function (cache) {
        return cache.addAll(cacheFiles);
    });
    e.waitUntil(cacheOpenPromise);
});
複製程式碼

可以看到,首先在cacheFiles中我們列出了所有的靜態資源依賴。注意其中的'/',由於根路徑也可以訪問我們的應用,因此不要忘了將其也快取下來。當Service Worker install時,我們就會通過caches.open()cache.addAll()方法將資源快取起來。這裡我們給快取起了一個cacheName,這個值會成為這些快取的key。

上面這段程式碼中,caches是一個全域性變數,通過它我們可以操作Cache相關介面。

Cache 介面提供快取的 Request / Response 物件對的儲存機制。Cache 介面像 workers 一樣, 是暴露在 window 作用域下的。儘管它被定義在 service worker 的標準中, 但是它不必一定要配合 service worker 使用。——MDN

3.4 使用快取的靜態資源

到目前為止,我們僅僅是註冊了一個Service Worker,並在其install時快取了一些靜態資源。然而,如果這時執行這個demo你會發現——“圖書搜尋”這個Web App依然無法離線使用。

為什麼呢?因為我們僅僅快取了這些資源,然而瀏覽器並不知道需要如何使用它們;換言之,瀏覽器仍然會通過向伺服器傳送請求來等待並使用這些資源。那怎麼辦?

聰明的你應該想起來了,我們在文章前半部分介紹Service Worker時提到了“客戶端代理”——用Service Worker來幫我們決定如何使用快取。

下圖是一個簡單的策略:

有cache時的靜態資源請求流程

無cache時的靜態資源請求流程

  1. 瀏覽器發起請求,請求各類靜態資源(html/js/css/img);
  2. Service Worker攔截瀏覽器請求,並查詢當前cache;
  3. 若存在cache則直接返回,結束;
  4. 若不存在cache,則通過fetch方法向服務端發起請求,並返回請求結果給瀏覽器
// sw.js
self.addEventListener('fetch', function (e) {
    // 如果有cache則直接返回,否則通過fetch請求
    e.respondWith(
        caches.match(e.request).then(function (cache) {
            return cache || fetch(e.request);
        }).catch(function (err) {
            console.log(err);
            return fetch(e.request);
        })
    );
});
複製程式碼

fetch事件會監聽所有瀏覽器的請求。e.respondWith()方法接受Promise作為引數,通過它讓Service Worker向瀏覽器返回資料。caches.match(e.request)則可以檢視當前的請求是否有一份本地快取:如果有快取,則直接向瀏覽器返回cache;否則Service Worker會向後端服務發起一個fetch(e.request)的請求,並將請求結果返回給瀏覽器。

到目前為止,執行我們的demo:當第一聯網開啟“圖書搜尋”Web App後,所依賴的靜態資源就會被快取在本地;以後再訪問時,就會使用這些快取而不發起網路請求。因此,即使在無網情況下,我們似乎依舊能“訪問”該應用。

3.5. 更新靜態快取資源

然而,如果你細心的話,會發現一個小問題:當我們將資源快取後,除非登出(unregister)sw.js、手動清除快取,否則新的靜態資源將無法快取。

解決這個問題的一個簡單方法就是修改cacheName。由於瀏覽器判斷sw.js是否更新是通過位元組方式,因此修改cacheName會重新觸發install並快取資源。此外,在activate事件中,我們需要檢查cacheName是否變化,如果變化則表示有了新的快取資源,原有快取需要刪除。

// sw.js
// 監聽activate事件,啟用後通過cache的key來判斷是否更新cache中的靜態資源
self.addEventListener('activate', function (e) {
    console.log('Service Worker 狀態: activate');
    var cachePromise = caches.keys().then(function (keys) {
        return Promise.all(keys.map(function (key) {
            if (key !== cacheName) {
                return caches.delete(key);
            }
        }));
    })
    e.waitUntil(cachePromise);
    return self.clients.claim();
});
複製程式碼

3.6. 快取API資料的“離線搜尋”

到這裡,我們的應用基本已經完成了離線訪問的改造。但是,如果你注意到文章開頭的圖片就會發現,離線時我們不僅可以訪問,還可以使用搜尋功能。

離線/無網環境下普通Web App(左)與PWA(右)的差異

這是怎麼回事呢?其實這背後的祕密就在於,這個Web App也會把XHR請求的資料快取一份。而再次請求時,我們會優先使用本地快取(如果有快取的話);然後向服務端請求資料,服務端返回資料後,基於該資料替換展示。大致過程如下:

圖書查詢介面的快取與使用策略

首先我們改造一下前一節的程式碼在sw.js的fetch事件裡進行API資料的快取

// sw.js
var apiCacheName = 'api-0-1-1';
self.addEventListener('fetch', function (e) {
    // 需要快取的xhr請求
    var cacheRequestUrls = [
        '/book?'
    ];
    console.log('現在正在請求:' + e.request.url);

    // 判斷當前請求是否需要快取
    var needCache = cacheRequestUrls.some(function (url) {
        return e.request.url.indexOf(url) > -1;
    });

    /**** 這裡是對XHR資料快取的相關操作 ****/
    if (needCache) {
        // 需要快取
        // 使用fetch請求資料,並將請求結果clone一份快取到cache
        // 此部分快取後在browser中使用全域性變數caches獲取
        caches.open(apiCacheName).then(function (cache) {
            return fetch(e.request).then(function (response) {
                cache.put(e.request.url, response.clone());
                return response;
            });
        });
    }
    /* ******************************* */

    else {
        // 非api請求,直接查詢cache
        // 如果有cache則直接返回,否則通過fetch請求
        e.respondWith(
            caches.match(e.request).then(function (cache) {
                return cache || fetch(e.request);
            }).catch(function (err) {
                console.log(err);
                return fetch(e.request);
            })
        );
    }
});
複製程式碼

這裡,我們也為API快取的資料建立一個專門的快取位置,key值為變數apiCacheName。在fetch事件中,我們首先通過對比當前請求與cacheRequestUrls來判斷是否是需要快取的XHR請求資料,如果是的話,就會使用fetch方法向後端發起請求。

fetch.then中我們以請求的URL為key,向cache中更新了一份當前請求所返回資料的快取:cache.put(e.request.url, response.clone())。這裡使用.clone()方法拷貝一份響應資料,這樣我們就可以對響應快取進行各類操作而不用擔心原響應資訊被修改了。

3.7. 應用離線XHR資料,完成“離線搜尋”,提升響應速度

如果你跟著做到了這一步,那麼恭喜你,距離我們酷酷的離線應用還差最後一步了!

目前為止,我們對Service Worker(sw.js)的改造已經完畢了。最後只剩下如何在XHR請求時有策略的使用快取了,這一部分的改造全部集中於index.js,也就是我們的前端指令碼。

還是回到上一節的這張圖:

圖書查詢介面的快取與使用策略

和普通情況不同,這裡我們的前端瀏覽器會首先去嘗試獲取快取資料並使用其來渲染介面;同時,瀏覽器也會發起一個XHR請求,Service Worker通過將請求返回的資料更新到儲存中的同時向前端Web應用返回資料(這一步分就是上一節提到的快取策略);最終,如果判斷返回的資料與最開始取到的cache不一致,則重新渲染介面,否則忽略。

為了是程式碼更清晰,我們將原本的XHR請求部分單獨剝離出來,作為一個方法getApiDataRemote()以供呼叫,同時將其改造為了Promise。為了節省篇幅,我部分的程式碼比較簡單,就不單獨貼出了。

這一節最重要的部分其實是讀取快取。我們知道,在Service Worker中是可以通過caches變數來訪問到快取物件的。令人高興的是,在我們的前端應用中,也仍然可以通過caches來訪問快取。當然,為了保證漸進可用,我們需要先進行判斷'caches' in window。為了程式碼的統一,我將獲取該請求的快取資料也封裝成了一個Promise方法:

function getApiDataFromCache(url) {
    if ('caches' in window) {
        return caches.match(url).then(function (cache) {
            if (!cache) {
                return;
            }
            return cache.json();
        });
    }
    else {
        return Promise.resolve();
    }
}
複製程式碼

而原本我們在queryBook()方法中,我們會請求後端資料,然後渲染頁面;而現在,我們加上基於快取的渲染:

function queryBook() {
    // ……
    // 遠端請求
    var remotePromise = getApiDataRemote(url);
    var cacheData;
    // 首先使用快取資料渲染
    getApiDataFromCache(url).then(function (data) {
        if (data) {
            loading(false);
            input.blur();            
            fillList(data.books);
            document.querySelector('#js-thanks').style = 'display: block';
        }
        cacheData = data || {};
        return remotePromise;
    }).then(function (data) {
        if (JSON.stringify(data) !== JSON.stringify(cacheData)) {
            loading(false);                
            input.blur();
            fillList(data.books);
            document.querySelector('#js-thanks').style = 'display: block';
        }
    });
    // ……
}
複製程式碼

如果getApiDataFromCache(url).then返回快取資料,則使用它先進行渲染。而當remotePromise的資料返回時,與cacheData進行比對,只有在資料不一致時需要重新渲染頁面(注意這裡為了簡便,粗略地使用了JSON.stringify()方法進行物件間的比較)。這麼做有兩個優勢:

  1. 離線可用。如果我們之前訪問過某些URL,那麼即使在離線的情況下,重複相應的操作依然可以正常展示頁面;
  2. 優化體驗,提高訪問速度。讀取本地cache耗時相比於網路請求是非常低的,因此就會給我們的使用者一種“秒開”、“秒響應”的感覺。

4. 使用Lighthouse測試我們的應用

至此,我們完成了PWA的兩大基本功能:Web App Manifest和Service Worker的離線快取。這兩大功能可以很好地提升使用者體驗與應用效能。我們用Chrome中的Lighthouse來檢測一下目前的應用:

Lighthouse檢測結果

Lighthouse檢測結果 - PWA

可以看到,在PWA評分上,我們的這個Web App已經非常不錯了。其中唯一個扣分項是在HTTPS協議上:由於是本地除錯,所以使用了http://127.0.0.1:8085,在生產肯定會替換為HTTPS。

5. 這太酷了,但是相容性呢?

隨著今年(2018年)年初,Apple在iOS 11.3中開始支援Service Worker,加上Apple一直以來較為良好的系統升級率,整個PWA在相容性問題上有了重大的突破。

雖然Service Worker中的一些其他功能(例如推送、後臺同步)Apple並未表態,但是Web App Manifest和Service Worker的離線快取是iOS 11.3所支援的。這兩大核心功能不僅效果拔群,而且目前看來具有還不錯的相容性,非常適合投入生產。

更何況,作為漸進式網頁應用,其最重要的一個特點就是在相容性支援時自動升級功能與體驗;而在不支援時,會靜默回退部分新功能。在保證我們的正常服務情況下,儘可能利用瀏覽器特性,提供更優質的服務。

Service Worker相容性

6. 寫在最後

本文中所有的程式碼示例均可以在learn-pwa/sw-cache上找到。注意在git clone之後,切換到sw-cache分支,本文所有的程式碼均存在於該分支上。切換其他分值可以看到不同的版本:

  • basic分支:基礎專案demo,一個普通的圖書搜尋應用(網站);
  • manifest分支:基於basic分支,新增manifest等功能,具體可以看上一篇文章瞭解;
  • sw-cache分支:基於manifest分支,新增快取與離線功能;
  • master分支:應用的最新程式碼。

如果你喜歡或想要了解更多的PWA相關知識,歡迎關注我,關注《PWA學習與實踐》系列文章。我會總結整理自己學習PWA過程的遇到的疑問與技術點,並通過實際程式碼和大家一起實踐。

最後宣告一下,文中的程式碼作為demo,主要是用於瞭解與學習PWA技術原理,可能會存在一些不完善的地方,因此,不建議直接使用到生產環境。

《PWA技術學習與實踐》系列

參考資料

相關文章