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

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

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

PWA做爲時下最火熱的技術概念之一,對提高Web應用的安全、性能和體驗有着很大的意義,很是值得咱們去了解與學習。對PWA感興趣的朋友歡迎關注《PWA學習與實踐》系列文章。css

1. 引言

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

即便在離線狀態下,依然能夠訪問的PWA

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

  • 讓咱們的Web App在無網(offline)狀況下能夠訪問,甚至使用部分功能,而不是展現「無網絡鏈接」的錯誤頁;
  • 讓咱們在弱網的狀況下,能使用緩存快速訪問咱們的應用,提高體驗;
  • 在正常的網絡狀況下,也能夠經過各類自發控制的緩存方式來節省部分請求帶寬;
  • ……

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

那麼,Service Worker是什麼呢?你能夠把Service Worker簡單理解爲一個獨立於前端頁面,在後臺運行的進程。所以,它不會阻塞瀏覽器腳本的運行,同時也沒法直接訪問瀏覽器相關的API(例如:DOM、localStorage等)。此外,即便在離開你的Web App,甚至是關閉瀏覽器後,它仍然能夠運行。它就像是一個在Web應用背後默默工做的勤勞小蜜蜂,處理着緩存、推送、通知與同步等工做。因此,要學習PWA,繞不開的就是Service Worker。git

PWA背後的英雄 —— Service Worker

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

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

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

這一小節會告訴你們,Service Worker是如何讓咱們在離線的狀況下也能訪問Web App的。固然,離線訪問只是其中一種表現。chrome

首先,咱們想一下,當訪問一個web網站時,咱們實際上作了什麼呢?整體上來講,咱們經過與與服務器創建鏈接,獲取資源,而後獲取到的部分資源還會去請求新的資源(例如html中使用的css、js等)。因此,粗粒度來講,咱們訪問一個網站,就是在獲取/訪問這些資源。json

可想而知,當處於離線或弱網環境時,咱們沒法有效訪問這些資源,這就是制約咱們的關鍵因素。所以,一個最直觀的思路就是:若是咱們把這些資源緩存起來,在某些狀況下,將網絡請求變爲本地訪問,這樣是否能解決這一問題?是的。但這就須要咱們有一個本地的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技術學習與實踐》系列

參考資料

相關文章
相關標籤/搜索