《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时碰到的问题,以及对应的解决方案。