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

AlienZHOU發表於2018-05-13

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

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

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

1. 引言

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

使用者拿出手機,瀏覽著我們的網站,發現了一個很有趣的資訊,點選了“提交”按鈕。然而不幸的是,這時使用者到了一個網路環境極差的地方,或者是根本沒有網路。他能夠做的只有看著頁面上的提示框和不斷旋轉的等待小圓圈。1s、5s、30s、1min……無盡的等待後,使用者將手機放回了口袋,而這一次的請求也被終止了——由於當下極差的網路終止在了客戶端。

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

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

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

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

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

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

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

其大致的流程如下:

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

  1. 首先,你需要在Service Worker中監聽sync事件;
  2. 然後,在瀏覽器中發起後臺同步sync(圖中第一步);
  3. 之後,會觸發Service Worker的sync事件,在該監聽的回撥中進行操作,例如向後端發起請求(圖中第二步)
  4. 最後,可以在Service Worker中對服務端返回的資料進行處理。

由於Service Worker在使用者關閉該網站後仍可以執行,因此該流程名為“後臺同步”實在是非常貼切。

怎麼樣,在我們已經有了一定的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的執行效果:

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

可以看到,在網路環境正常的情況下,點選“同步”按鈕會立即觸發Service Worker中的sync事件監聽,並向服務端傳送請求;而在斷網情況下,點選“同步”按鈕,控制檯雖然顯示註冊了同步事件,但是並不會觸發Service Worker的sync監聽回撥,指到恢復網路連線,才會在後臺(Service Worker)中進行相關處理。

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

【PWA學習與實踐】(8)使用Service Worker進行後臺同步 - Background 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;
            })
        );
    }
});
複製程式碼

是不是非常簡單?

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

進行後臺同步時,使用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的方案要更復雜一點。它比較適用於一些需要資料持久化的場景。

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

5. 相容性

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

先是Background Sync

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

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

然後是indexedDB

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

相較於Background Sync還是有著不錯的相容性的。而且在safari(包括iOS safari)中也得到了支援。

6. 寫在最後

從文中的內容以及google developer中的一些例項來看,Background Sync是一個非常有潛力的API。然而令人堪憂的相容性在一定程度上限制了它的發揮空間。不過,作為一項技術,還是非常值得我們學習與瞭解的。

本文中所有的程式碼示例均可以在learn-pwa/sync上找到。

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

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

《PWA學習與實踐》系列

參考資料

相關文章