《PWA學習與實踐》系列文章已整理至 gitbook - PWA學習手冊,文字內容已同步至 learning-pwa-ebook。轉載請註明做者與出處。
本文是《PWA學習與實踐》系列的第八篇文章。本文中的代碼能夠在learning-pwa的sync分支上找到(git clone
後注意切換到sync分支)。javascript
PWA做爲時下最火熱的技術概念之一,對提高Web應用的安全、性能和體驗有着很大的意義,很是值得咱們去了解與學習。對PWA感興趣的朋友歡迎關注《PWA學習與實踐》系列文章。前端
生活中常常會有這樣的場景:java
用戶拿出手機,瀏覽着咱們的網站,發現了一個頗有趣的信息,點擊了「提交」按鈕。然而不幸的是,這時用戶到了一個網絡環境極差的地方,或者是根本沒有網絡。他可以作的只有看着頁面上的提示框和不斷旋轉的等待小圓圈。1s、5s、30s、1min……無盡的等待後,用戶將手機放回了口袋,而這一次的請求也被終止了——因爲當下極差的網絡終止在了客戶端。git
上面的場景其實暴露了兩個問題:github
然而,Service Worker的後臺同步功能規避了這些缺陷。web
下面就讓咱們先來了解下後臺同步(Background Sync)的工做原理。chrome
後臺同步應該算是Service Worker相關功能(API)中比較易於理解與使用的一個。數據庫
其大體的流程以下:json
因爲Service Worker在用戶關閉該網站後仍能夠運行,所以該流程名爲「後臺同步」實在是很是貼切。c#
怎麼樣,在咱們已經有了必定的Service Worker基礎以後,後臺同步這一功能相比以前的功能,是否是很是易於理解?
既然已經理解了該功能的大體流程,那麼接下來就讓咱們來實際操做一下吧。
// 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
對象,其上包含register
和getTags
兩個方法:
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) { // …… }
當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會持續在後臺運行,等待該請求完成。
實際上,通過上面兩小節,咱們的大體工做已經完成。不過還缺乏一個小環節:咱們的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 }; });
下面就來看一下這個demo的運行效果:
能夠看到,在網絡環境正常的狀況下,點擊「同步」按鈕會當即觸發Service Worker中的sync事件監聽,並向服務端發送請求;而在斷網狀況下,點擊「同步」按鈕,控制檯雖然顯示註冊了同步事件,可是並不會觸發Service Worker的sync監聽回調,指到恢復網絡鏈接,纔會在後臺(Service Worker)中進行相關處理。
下面再來看一下觸發sync事件後,關閉網站的效果:
能夠看到,即便在關閉網站後再從新鏈接網絡,服務端依然能夠收到來自客戶端的請求(說明Service Worker在後臺進行了相關處理)。
其實上一節結束,咱們就已經能夠了解最基礎的後臺同步功能了。而這部分則會進一步探討後臺同步中的一個重要問題:如何在後臺同步時獲取併發送client中的數據?
例如在咱們的上一個Demo中,用戶的姓名name是硬編碼在Service Worker中的,而實際上,咱們但願能在頁面上提供一個輸入框,將用戶的輸入內容在後臺同步中進行發送。
實現的方式有兩種:使用postMessage或使用indexedDB。
咱們知道,在瀏覽器主線程與Web Worker線程之間能夠經過postMessage來進行通訊。所以,咱們也可使用這個方法來向Service Worker「傳輸」數據。
大體思路以下:
// 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的傳輸數據,方便與直觀,是一個不錯的方法。
在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的方案要更復雜一點。它比較適用於一些須要數據持久化的場景。
依照慣例,咱們仍是來簡單看一下文中相關功能的兼容性。
使人悲傷的是,基本只有Google自家的Chrome可用。
而後是indexedDB:
相較於Background Sync仍是有着不錯的兼容性的。並且在safari(包括iOS safari)中也獲得了支持。
從文中的內容以及google developer中的一些實例來看,Background Sync是一個很是有潛力的API。然而使人堪憂的兼容性在必定程度上限制了它的發揮空間。不過,做爲一項技術,仍是很是值得咱們學習與瞭解的。
本文中全部的代碼示例都可以在learn-pwa/sync上找到。
若是你喜歡或想要了解更多的PWA相關知識,歡迎關注我,關注《PWA學習與實踐》系列文章。我會總結整理本身學習PWA過程的遇到的疑問與技術點,並經過實際代碼和你們一塊兒實踐。
到目前爲止,咱們已經學習了PWA中的多個知識點,在其基礎上,已經能夠幫助咱們進行原有站點的PWA升級。學習是一方面,實踐是另外一方面。在下一篇文章裏,我會整理一些在業務中升級PWA時碰到的問題,以及對應的解決方案。