引入
状态管理是前端开发中非常重要的一部分概念,每个组件维护着自己的状态,并且在状态发生改变时进行re-render,不同层次的组件之间可能需要进行父子/兄弟/祖孙的状态通信,组件的状态又可以集中到一颗状态树进行统一管理维护,于是出现了一些前端状态管理框架如Redux,Flux,Vuex等。但是上面所说的都是单页面的状态,有些状态在业务场景下可能要提升为应用级,比如说登录态。当你在一个网站的某个标签页完成登录步骤以后,一般情况下该网站的其他标签页都不会自动进行登录态的同步,可能你要刷新一下,然后才能看到自己是已登录。又或者说深色模式,用户在一个网页应用的标签页上切换到深色模式时,他大概率会希望其他标签页也自动同步应用色调模式的变化,于是引出了我们要讨论的问题:
如何在不同的文档对象,也就是浏览器标签页之间,发送数据从而达到数据同步的目的?
otherWindow.postMessage
otherWindow.postMessage(msg, dest_url);
MDN对应文档: window.postMessage
postMessage方法支持跨源通信,并且可以直接发送复杂类型数据,因为它会被结构化克隆算法自动序列化,唯一的问题在于:
如何获取到otherWindow对象?
通过window.open或者嵌入iframe可以得到另一个窗口对象,但是这两种方法不符合用户的使用习惯,所以otherWindow.postMessage的适用范围十分有限。
EventSource
const es = new EventSource('/new_messages');
es.addEventListener('new_connection' ,e => console.log(e.data))
MDN对应文档: EventSource
EventSource方法可以看作是简化版的WebSocket,服务端可以通过EventSource向客户端推送消息,但是客户端没有办法向服务端发送消息。它需要服务端支持,客户端可以向服务端开出一个EventSource对象用于监听消息事件,每个EventSource对应一条客户端与服务端之间的持久连接。该方法虽然需要额外配置服务端,但是在服务端的帮助下,它甚至可以实现跨设备的数据同步,比如在自己的手机上将某个商品加入购物车,自己的电脑上(如果也是已登录状态)也会同步将对应商品加入购物车。但是这个办法有许多局限性:
- 在非HTTP/2条件下,对于同一个源,浏览器最多只允许开出6个持久连接,意味着最多只能创建6个同一域名下的EventSource对象进行消息监听
- 需要额外配置服务器以支持EventSouce通信,并且一个EventSource对象就占用一个服务端的持久连接,十分消耗服务端资源
- 需要自行处理复杂类型数据的序列化和反序列化
localStorage Event
window.addEventListener('storage', e => console.log(e.newValue))
MDN对应文档: storage event
当一个标签页修改了localStorage时,会在其他使用了相同localStorage区域的标签页中(也就是同源的标签页)触发storage事件。监听storage事件以获取localStorage变化信息,从而完成数据同步。
注意:
触发storage事件的标签页自己本身不会执行storage事件的监听回调
localStorage只能存储文本数据,对于复杂类型数据需要做另外处理,另外localStorage无法在worker中使用,因为它是同步的API(Google Devloper提倡为了提高性能表现,应当少用localStorage存储大容量数据,而改用Cache Storage以及IndexDB等异步的存储API)
BroadcastChannel
const channel = new BroadcastChannel('app');
channel.addEventListener('message', e => {
console.log(e.data)
})
MDN对应文档: BroadcastChannel
BroadcastChannel允许订阅者在同源的频道上进行消息广播,所有其他的订阅了该频道的文档对象都会收到广播消息,从而完成数据同步,它可以在worker中被使用,也支持复杂类型数据,唯一的问题在于...
Safari不支持BroadcastChannel
Service Worker
navigator.serviceWorker.addEventListener('message', e => console.log(e.data));
navigator.serviceWorker.controller.postMessage({msg: 'hi'});
MDN对应文档: Service Worker
当一个页面安装激活了一个Service Worker,它就变成了该Service Worker的客户(Client)。一个Service Worker可以有很多个Client,并且Service Worker与Client之间可以通过postMessage方法进行消息通信,从而达到数据同步的目的。Service Worker被目前主流浏览器兼容,并且支持复杂类型数据。除了作为PWA不可或缺的一部分,Service Worker在跨页面数据同步方面也提供了非常好的解决方案。
IDBObserver
const observer = new IDBObserver((changes) => {
console.log(changes.records.get('repo')[0].value);
})
observer.observe(
db,
db.transaction(['repo'], 'readwrite'),
{
operationTypes: ['put'],
values: true
}
)
官方文档: indexed-db-observers
IDBObserver目前仍然处于开发试验阶段,需要在设置界面主动打开Experimental Web Platform features才能使用。IDBObserver可以观察indexDB数据库的事务操作。多个文档对象可以设置observer观察同一数据库对同一对象仓库的同一类事务操作,在一个文档对象使用事务操作数据库使其发生状态变化以后,触发所有文档对象的回调,使得其他文档对象可以获取到最新的数据,从而达到数据同步的目的。indexDB可以在worker中被使用,浏览器兼容性良好,并且可以存储复杂类型数据,所以这个方案没有太明显的缺点———除去它目前仍然处于试验阶段的毛病
为什么需要IDBObserver?
之所以将一个尚处于试验阶段的技术摆上台面来讲,是因为IDBObserver除了可以观察数据库状态从而达到数据同步的目的,还有更长远的用途
想象一下假如把所有的redux state存入indexDB,并且让每个组件通过IDBObserver订阅它们只感兴趣的那一部分state,就好像
connect()方法做的那样,当通过IDBObserver观察的state发生更新,触发组件的回调执行重新渲染,于是我们以原生的方式实现了一个类似redux的状态管理框架。再进一步往前想,使用redux和vuex的时候,最怕遇到的就是页面刷新,页面一刷新,整颗状态树的数据就没了。所以之前的解决方案是在页面刷新以前加一个钩子,用来把redux和vuex中的所有state先迁移到本地的storage API,然后重载完毕以后再从本地storage恢复state,但是假如用indexDB+IDBObserver进行状态树管理,刷新就再也不是问题了。总而言之随着浏览器原生能力越来越强,我个人而言是越来越相信原生的力量
Demos
针对上面几种涉及到的方法我做了几个非常简陋的demo,具体是简陋到“能跑就行”的那种...有兴趣的同学可以看下
参考
本篇文章是对HTTP203系列最新视频3.143 ways to synchronize data across documents的一些总结,有兴趣的同学可以看下原视频