引言
你一定遇到过类似这样的场景:
当用手机填写完一张信息表单点击"提交"时,恰好手机网络很差或没有网络,这时候只能盯着手机看着旋转的小圆圈。经过长时间等待后依然没有结果,这时候关闭浏览器,请求也被终止
上面的场景其实暴露了两个问题:
- 普通的页面发起的请求会随着浏览器进程的结束或者 Tab 页面的关闭而终止
- 无网环境下,没有一种机制能“维持”住该请求,以待有网情况下再进行请求
然而,Service Worker 的后台同步功能规避了这些缺陷
下面就让我们先来了解下后台同步(Background Sync)的工作原理
工作原理
后台同步应该算是 Service Worker 相关功能(API)中比较易于理解与使用的一个
其大致的流程如下:
- 你需要在 Service Worker 中监听 sync 事件
- 在浏览器中发起后台同步 sync(图中第一步)
- 之后会触发 Service Worker 的 sync 事件,在该监听的回调中进行操作,例如向后端发起请求(图中第二步)
- 可以在 Service Worker 中对服务端返回的数据进行处理
由于 Service Worker 在用户关闭该网站后仍可以运行,因此该流程名为“后台同步”实在是非常贴切
使用
client 触发 sync 事件
- public/index.js
// background sync 基础版
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()
方法可以注册一个后台同步事件,其中接收的参数tag
用于作为这个后台同步的唯一标识
当然,如果想要代码更健壮的话,我们还需要在调用前进行特性检测:
- public/index.js
if ('serviceWorker' in navigator && 'SyncManager' in window) {
// ……
}
在 Service Worker 中监听 sync 事件
当 client 触发了sync事件后,剩下的就交给 Service Worker。理论上此时就不需要 client(前端站点)参与了。例如另一个经典场景:用户离开时页面(unload)时在 client 端触发 sync 事件,剩下的操作交给 Service Worker,Service Worker 的操作可以在离开页面后正常进行
像添加 fetch 和 push 事件监听那样,我们可以为 Service Worker 添加 sync 事件的监听:
- public/sw.js
self.addEventListener('sync', function (e) {
// ……
});
在 sync 事件的 event 对象上可以取到 tag 值,该值就是我们在上一节注册 sync 时的唯一标识。通过这个 tag 就可以区分出不同的后台同步事件。例如,当该值为 'sample_sync' 时我们向后端发送一个请求:
- public/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 可以正常运行:
router.get('/sync', async (ctx, next) => {
console.log(`Hello ${ctx.request.query.name}, I have received your msg`);
ctx.response.body = {
status: 0
};
});
Demo 效果展示
下面就来看一下这个 demo 的运行效果:
可以看到,在网络环境正常的情况下,点击“同步”按钮会立即触发 Service Worker 中的 sync 事件监听,并向服务端发送请求;而在断网情况下,点击“同步”按钮,控制台虽然显示注册了同步事件,但是并不会触发 Service Worker 的 sync 监听回调,指到恢复网络连接,才会在后台(Service Worker)中进行相关处理
下面再来看一下触发 sync 事件后,关闭网站的效果:
可以看到,即使在关闭网站后再重新连接网络,服务端依然可以收到来自客户端的请求(说明 Service Worker 在后台进行了相关处理)
在后台同步时获取所需的数据
其实上一节结束,我们就已经可以了解最基础的后台同步功能了。而这部分则会进一步探讨后台同步中的一个重要问题:如何在后台同步时获取并发送 client 中的数据?
例如在我们的上一个 Demo 中,用户的姓名 name 是硬编码在 Service Worker 中的,而实际上我们希望能在页面上提供一个输入框,将用户的输入内容在后台同步中进行发送。
实现的方式有两种:使用 postMessage 或使用 indexedDB
使用 postMessage
我们知道,在浏览器主线程与 Web Worker 线程之间可以通过 postMessage 来进行通信。因此,我们也可以使用这个方法来向 Service Worker “传输”数据
大致思路如下:
- client 触发 sync 事件
- 在 sync 注册完成后,使用 postMessage 和 Service Worker 通信
- 在 Service Worker 的 sync 事件回调中等待 message 事件的消息
- 收到 message 事件的消息后,将其中的信息提交到服务端
- public/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 事件
- public/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 通知所有监听者
- public/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
- public/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 的传输数据,是一个不错的方法
使用indexedDB
在 client 与 Servcie Worker 之间同步数据,还有一个可行的思路:client 先将数据存在某处,待 Servcie Worker 需要时再读取使用即可
为此需要找一个存数据的地方。你第一个想到的可能是 localStorage
然而,在最开始介绍 Service Worker 时提到过,为了保证性能,实现部分操作的非阻塞,在 Service Worker 中我们经常会碰到异步操作(因此大多数 API 都是 Promise 形式的)。那么像 localStorage 这样的同步 API 会变成异步化么?答案很简单:不会,并且 localStorage 在 Servcie Worker 中无法调用
不过我们还另一个强大的数据存储方式——indexedDB。它是可以在 Service Worker 中使用的
首先,需要一个方法用于连接数据库并创建相应的 store:
- public/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:
- public/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 中也需要相应的数据库连接方法:
- public/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 中对应的数据,最后再向后端发送请求:
- public/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 的方案要更复杂一点。它比较适用于一些需要数据持久化的场景
兼容性
依照惯例,我们还是来简单看一下文中相关功能的兼容性
先是 Background Sync:
然后是 indexedDB:
相较于 Background Sync 还是有着不错的兼容性的。而且在 safari(包括iOS safari)中也得到了支持
本章分支: sync