【PWA學習與實踐】(8)使用Service Worker進行後臺同步 - Background Sync

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

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

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

1. 引言

生活中常常會有這樣的場景:java

用戶拿出手機,瀏覽着咱們的網站,發現了一個頗有趣的信息,點擊了「提交」按鈕。然而不幸的是,這時用戶到了一個網絡環境極差的地方,或者是根本沒有網絡。他可以作的只有看着頁面上的提示框和不斷旋轉的等待小圓圈。1s、5s、30s、1min……無盡的等待後,用戶將手機放回了口袋,而這一次的請求也被終止了——因爲當下極差的網絡終止在了客戶端。git

上面的場景其實暴露了兩個問題:github

  1. 普通的頁面發起的請求會隨着瀏覽器進程的結束/或者Tab頁面的關閉而終止;
  2. 無網環境下,沒有一種機制能「維持」住該請求,以待有網狀況下再進行請求。

然而,Service Worker的後臺同步功能規避了這些缺陷。web

下面就讓咱們先來了解下後臺同步(Background Sync)的工做原理。chrome

2. 後臺同步(Background Sync)是如何工做的?

後臺同步應該算是Service Worker相關功能(API)中比較易於理解與使用的一個。數據庫

其大體的流程以下:json

  1. 首先,你須要在Service Worker中監聽sync事件;
  2. 而後,在瀏覽器中發起後臺同步sync(圖中第一步);
  3. 以後,會觸發Service Worker的sync事件,在該監聽的回調中進行操做,例如向後端發起請求(圖中第二步)
  4. 最後,能夠在Service Worker中對服務端返回的數據進行處理。

因爲Service Worker在用戶關閉該網站後仍能夠運行,所以該流程名爲「後臺同步」實在是很是貼切。c#

怎麼樣,在咱們已經有了必定的Service Worker基礎以後,後臺同步這一功能相比以前的功能,是否是很是易於理解?

3. 如何使用後臺同步功能?

既然已經理解了該功能的大體流程,那麼接下來就讓咱們來實際操做一下吧。

3.1 client觸發sync事件

// index.js
navigator.serviceWorker.ready.then(function (registration) {
    var tag = "sample_sync";
    document.getElementById('js-sync-btn').addEventListener('click', function () {
        registration.sync.register(tag).then(function () {
            console.log('後臺同步已觸發', tag);
        }).catch(function (err) {
            console.log('後臺同步觸發失敗', err);
        });
    });
});

因爲後臺同步功能須要在Service Worker註冊完成後觸發,所以較好的一個方式是在navigator.serviceWorker.ready以後綁定相關操做。例如上面的代碼中,咱們在ready後綁定了按鈕的點擊事件。當按鈕被點擊後,會使用registration.sync.register()方法來觸發Service Worker的sync事件。

registration.sync返回一個SyncManager對象,其上包含registergetTags兩個方法:

register() Create a new sync registration and return a Promise.

getTags() Return a list of developer-defined identifiers for SyncManager registration.

register()方法能夠註冊一個後臺同步事件,其中接收的參數tag用於做爲這個後臺同步的惟一標識。

固然,若是想要代碼更健壯的話,咱們還須要在調用前進行特性檢測:

// index.js
if ('serviceWorker' in navigator && 'SyncManager' in window) {
    // ……
}

3.2 在Service Worker中監聽sync事件

當client觸發了sync事件後,剩下的就交給Service Worker。理論上此時就不須要client(前端站點)參與了。例如另外一個經典場景:用戶離開時頁面(unload)時在client端觸發sync事件,剩下的操做交給Service Worker,Service Worker的操做能夠在離開頁面後正常進行。

像添加fetch和push事件監聽那樣,咱們能夠爲Service Worker添加sync事件的監聽:

// sw.js
self.addEventListener('sync', function (e) {
    // ……
});

在sync事件的event對象上能夠取到tag值,該值就是咱們在上一節註冊sync時的惟一標識。經過這個tag就能夠區分出不一樣的後臺同步事件。例如,當該值爲'sample_sync'時咱們向後端發送一個請求:

// sw.js
self.addEventListener('sync', function (e) {
    console.log(`service worker須要進行後臺同步,tag: ${e.tag}`);
    var init = {
        method: 'GET'
    };
    if (e.tag === 'sample_sync') {
        var request = new Request(`sync?name=AlienZHOU`, init);
        e.waitUntil(
            fetch(request).then(function (response) {
                response.json().then(console.log.bind(console));
                return response;
            })
        );
    }
});

這裏我經過e.tag來判斷client觸發的不一樣sync事件,並在監聽到tag爲'sample_sync'的sync事件後,構建了一個request對象,使用fetch API來進行後端請求。

須要特別注意的是,fetch請求必定要放在e.waitUntil()內。由於咱們要保證「後臺同步」,將Promise對象放在e.waitUntil()內能夠確保在用戶離開咱們的網站後,Service Worker會持續在後臺運行,等待該請求完成。

3.3 完善咱們的後端服務

實際上,通過上面兩小節,咱們的大體工做已經完成。不過還缺乏一個小環節:咱們的KOA服務器上尚未sync路由和接口。添加一下,以保證demo能夠正常運行:

// app.js
router.get('/sync', async (ctx, next) => {
    console.log(`Hello ${ctx.request.query.name}, I have received your msg`);
    ctx.response.body = {
        status: 0
    };
});

3.4 Demo效果展現

下面就來看一下這個demo的運行效果:

能夠看到,在網絡環境正常的狀況下,點擊「同步」按鈕會當即觸發Service Worker中的sync事件監聽,並向服務端發送請求;而在斷網狀況下,點擊「同步」按鈕,控制檯雖然顯示註冊了同步事件,可是並不會觸發Service Worker的sync監聽回調,指到恢復網絡鏈接,纔會在後臺(Service Worker)中進行相關處理。

下面再來看一下觸發sync事件後,關閉網站的效果:

能夠看到,即便在關閉網站後再從新鏈接網絡,服務端依然能夠收到來自客戶端的請求(說明Service Worker在後臺進行了相關處理)。

4. 如何在後臺同步時獲取所需的數據?

其實上一節結束,咱們就已經能夠了解最基礎的後臺同步功能了。而這部分則會進一步探討後臺同步中的一個重要問題:如何在後臺同步時獲取併發送client中的數據?

例如在咱們的上一個Demo中,用戶的姓名name是硬編碼在Service Worker中的,而實際上,咱們但願能在頁面上提供一個輸入框,將用戶的輸入內容在後臺同步中進行發送。

實現的方式有兩種:使用postMessage或使用indexedDB。

4.1 使用postMessage

咱們知道,在瀏覽器主線程與Web Worker線程之間能夠經過postMessage來進行通訊。所以,咱們也可使用這個方法來向Service Worker「傳輸」數據。

大體思路以下:

  1. client觸發sync事件;
  2. 在sync註冊完成後,使用postMessage和Service Worker通訊;
  3. 在Service Worker的sync事件回調中等待message事件的消息;
  4. 收到message事件的消息後,將其中的信息提交到服務端。
// index.js
// 使用postMessage來傳輸sync數據
navigator.serviceWorker.ready.then(function (registration) {
    var tag = 'sample_sync_event';

    document.getElementById('js-sync-event-btn').addEventListener('click', function () {
        registration.sync.register(tag).then(function () {
            console.log('後臺同步已觸發', tag);

            // 使用postMessage進行數據通訊
            var inputValue = document.querySelector('#js-search-input').value;
            var msg = JSON.stringify({type: 'bgsync', msg: {name: inputValue}});
            navigator.serviceWorker.controller.postMessage(msg);
        }).catch(function (err) {
            console.log('後臺同步觸發失敗', err);
        });
    });
});

registration.sync.register完成後,調用navigator.serviceWorker.controller.postMessage來向Service Worker Post數據。

爲了提升代碼的可維護性,我在sw.js中建立了一個SimpleEvent類,你能夠把它看作一個最簡單的EventBus。用來解耦Service Worker的message事件和sync事件。

// sw.js
class SimpleEvent {
    constructor() {
        this.listenrs = {};
    }

    once(tag, cb) {
        this.listenrs[tag] || (this.listenrs[tag] = []);
        this.listenrs[tag].push(cb);
    }

    trigger(tag, data) {
        this.listenrs[tag] = this.listenrs[tag] || [];
        let listenr;
        while (listenr = this.listenrs[tag].shift()) {
            listenr(data)
        }
    }
}

在message事件中監聽client發來的消息,並經過SimpleEvent通知全部監聽者。

// sw.js
const simpleEvent = new SimpleEvent();
self.addEventListener('message', function (e) {
    var data = JSON.parse(e.data);
    var type = data.type;
    var msg = data.msg;
    console.log(`service worker收到消息 type:${type};msg:${JSON.stringify(msg)}`);

    simpleEvent.trigger(type, msg);
});

在sync事件中,使用SimpleEvent監聽bgsync來獲取數據,而後再調用fetch方法。注意,因爲e.waitUntil()須要接收Promise做爲參數,所以須要對SimpleEvent.once進行Promisfy。

// sw.js
self.addEventListener('sync', function (e) {
    if (e.tag === xxx) {
        // ……
    }

    // sample_sync_event同步事件,使用postMessage來進行數據通訊
    else if (e.tag === 'sample_sync_event') {
        // 將SimpleEvent.once封裝爲Promise調用
        let msgPromise = new Promise(function (resolve, reject) {
            // 監聽message事件中觸發的事件通知
            simpleEvent.once('bgsync', function (data) {
                resolve(data);
            });
            // 五秒超時
            setTimeout(resolve, 5000);
        });

        e.waitUntil(
            msgPromise.then(function (data) {
                var name = data && data.name ? data.name : 'anonymous';
                var request = new Request(`sync?name=${name}`, init);
                return fetch(request)
            }).then(function (response) {
                response.json().then(console.log.bind(console));
                return response;
            })
        );
    }
});

是否是很是簡單?

進行後臺同步時,使用postMessage來實現client向Service Worker的傳輸數據,方便與直觀,是一個不錯的方法。

4.2 使用indexedDB

在client與Servcie Worker之間同步數據,還有一個可行的思路:client先將數據存在某處,待Servcie Worker須要時再讀取使用便可。

爲此須要找一個存數據的地方。你第一個想到的可能就是localStorage了。

然而,不知道你是否還記得我在最開始介紹Service Worker時所提到的,爲了保證性能,實現部分操做的非阻塞,在Service Worker中咱們常常會碰到異步操做(所以大多數API都是Promise形式的)。那麼像localStorage這樣的同步API會變成異步化麼?答案很簡單:不會,而且localStorage在Servcie Worker中沒法調用。

不過不要氣餒,咱們還另外一個強大的數據存儲方式——indexedDB。它是能夠在Service Worker中使用的。對於indexedDB的使用方式,本系列後續會有文章具體介紹,所以在這裏的就不重點講解indexedDB的使用方式了。

首先,須要一個方法用於鏈接數據庫並建立相應的store:

// index.js
function openStore(storeName) {
    return new Promise(function (resolve, reject) {
        if (!('indexedDB' in window)) {
            reject('don\'t support indexedDB');
        }
        var request = indexedDB.open('PWA_DB', 1);
        request.onerror = function(e) {
            console.log('鏈接數據庫失敗');
            reject(e);
        }
        request.onsuccess = function(e) {
            console.log('鏈接數據庫成功');
            resolve(e.target.result);
        }
        request.onupgradeneeded = function (e) {
            console.log('數據庫版本升級');
            var db = e.srcElement.result;
            if (e.oldVersion === 0) {
                if (!db.objectStoreNames.contains(storeName)) {
                    var store = db.createObjectStore(storeName, {
                        keyPath: 'tag'
                    });
                    store.createIndex(storeName + 'Index', 'tag', {unique: false});
                    console.log('建立索引成功');
                }
            }
        }
    });
}

而後,在navigator.serviceWorker.ready中打開該數據庫鏈接,並在點擊按鈕時,先將數據存入indexedDB,再註冊sync:

// index.js
navigator.serviceWorker.ready.then(function (registration) {
    return Promise.all([
        openStore(STORE_NAME),
        registration
    ]);
}).then(function (result) {
    var db = result[0];
    var registration = result[1];
    var tag = 'sample_sync_db';

    document.getElementById('js-sync-db-btn').addEventListener('click', function () {
        // 將數據存儲進indexedDB
        var inputValue = document.querySelector('#js-search-input').value;
        var tx = db.transaction(STORE_NAME, 'readwrite');
        var store = tx.objectStore(STORE_NAME);
        var item = {
            tag: tag,
            name: inputValue
        };
        store.put(item);

        registration.sync.register(tag).then(function () {
            console.log('後臺同步已觸發', tag);
        }).catch(function (err) {
            console.log('後臺同步觸發失敗', err);
        });
    });
});

一樣的,在Service Worker中也須要相應的數據庫鏈接方法:

// sw.js
function openStore(storeName) {
    return new Promise(function (resolve, reject) {
        var request = indexedDB.open('PWA_DB', 1);
        request.onerror = function(e) {
            console.log('鏈接數據庫失敗');
            reject(e);
        }
        request.onsuccess = function(e) {
            console.log('鏈接數據庫成功');
            resolve(e.target.result);
        }
    });
}

而且在sync事件的回調中,get到indexedDB中對應的數據,最後再向後端發送請求:

// index.js
self.addEventListener('sync', function (e) {
    if (e.tag === xxx) {
        // ……
    }
    else if (e.tag === yyy) {
        // ……
    }
    
    // sample_sync_db同步事件,使用indexedDB來獲取須要同步的數據
    else if (e.tag === 'sample_sync_db') {
        // 將數據庫查詢封裝爲Promise類型的請求
        var dbQueryPromise = new Promise(function (resolve, reject) {
            var STORE_NAME = 'SyncData';
            // 鏈接indexedDB
            openStore(e.tag).then(function (db) {
                try {
                    // 建立事務進行數據庫查詢
                    var tx = db.transaction(STORE_NAME, 'readonly');
                    var store = tx.objectStore(STORE_NAME);
                    var dbRequest = store.get(e.tag);
                    dbRequest.onsuccess = function (e) {
                        resolve(e.target.result);
                    };
                    dbRequest.onerror = function (err) {
                        reject(err);
                    };
                }
                catch (err) {
                    reject(err);
                }
            });
        });

        e.waitUntil(
            // 經過數據庫查詢獲取須要同步的數據
            dbQueryPromise.then(function (data) {
                console.log(data);
                var name = data && data.name ? data.name : 'anonymous';
                var request = new Request(`sync?name=${name}`, init);
                return fetch(request)
            }).then(function (response) {
                response.json().then(console.log.bind(console));
                return response;
            })
        );
    }
});

相比於postMessage,使用indexedDB的方案要更復雜一點。它比較適用於一些須要數據持久化的場景。

5. 兼容性

依照慣例,咱們仍是來簡單看一下文中相關功能的兼容性。

先是Background Sync

使人悲傷的是,基本只有Google自家的Chrome可用。

而後是indexedDB

相較於Background Sync仍是有着不錯的兼容性的。並且在safari(包括iOS safari)中也獲得了支持。

6. 寫在最後

從文中的內容以及google developer中的一些實例來看,Background Sync是一個很是有潛力的API。然而使人堪憂的兼容性在必定程度上限制了它的發揮空間。不過,做爲一項技術,仍是很是值得咱們學習與瞭解的。

本文中全部的代碼示例都可以在learn-pwa/sync上找到。

若是你喜歡或想要了解更多的PWA相關知識,歡迎關注我,關注《PWA學習與實踐》系列文章。我會總結整理本身學習PWA過程的遇到的疑問與技術點,並經過實際代碼和你們一塊兒實踐。

到目前爲止,咱們已經學習了PWA中的多個知識點,在其基礎上,已經能夠幫助咱們進行原有站點的PWA升級。學習是一方面,實踐是另外一方面。在下一篇文章裏,我會整理一些在業務中升級PWA時碰到的問題,以及對應的解決方案。

《PWA學習與實踐》系列

參考資料

相關文章
相關標籤/搜索