前言
上次发表过《使用 window.postMessage 进行跨域实现数据交互的一次实战》之后,发现阅读量惨淡,或许大家对跨域数据交互的兴趣程度不高,又或许是我写的文章比较差。虽然文笔比较差,但还是会继续写的。
背景
这次的需求遇到的不是跨域通讯了,而是同域下的跨Webview通讯。之前跨域可以通过 window.postMessage 去解决,是因为当时的需求是通过 iframe 打开另一个页面,这时候可以获取到另一个页面窗口的引用,从而通过 postMessage 实现数据通讯。而这次,是两个 Webview 之间的数据交互,所以没办法拿到另一个页面的窗口对象引用,因此, postMessage 在这种场景下是不合适的。
但是,本次的场景两个 Webview 加载的都是同域下的页面。同域的页面可以共享的有什么呢?其实很容易想到的是通过 localStorage。本次需求也是通过 localStorage 去解决的,并且基于发布订阅的模式封装了一个跨 Webview 通讯的 EventEmitter,作为后续需求的技术储备,提升下次需求的开发效率。
框架先搭好:EventEmitter
一般来说,对于一个 EventEmitter 而言,会有$on
、$emit
、$un
这几个方法,分别用于监听事件、触发事件、取消监听事件,然后内部维护一个 map
,map
中的每一个key
都有一个数组维护当前事件的回调函数。
EventEmitter
这个东西比较简单,相信很多人三两下就写完了。
class EventEmitter {
constructor(key) {
this.events = {};
}
$on(eventName, handler) {
const events = this.events[eventName] || (this.events[eventName] = []);
if (
events.every(fn => fn !== handler)
) {
events.push(handler);
}
}
$emit(eventName, args = [], callself = true) {
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
$un(eventName, handler) {
if (!eventName) {
this.events = {};
return;
}
if (!handler) {
this.events[eventName] = [];
return;
}
const eventsArray = this.events[eventName];
if (!Array.isArray(eventsArray)) {
return;
}
let i = 0;
while (i++ < eventsArray.length) {
if (eventsArray[i] === handler) {
break;
}
}
if (i < eventsArray.length) {
eventsArray.splice(i, 1);
}
}
}
这样一个 EventEmitter 使用起来是没什么问题的,但是我们现在需要的是把它跟 localStorage 结合起来,实现跨 Webview 的事件派发。
切入点: storage 事件
当同源页面中的其中一个页面修改了存储区域( localStorage 或 sessionStorage )的数据时,其他同源页面就会触发 storage 事件,而自身并不会触发这个事件。
对于同源页面来讲,localStorage 是数据共享的,存储的数据可以相互访问。如果通过“轮询”的方式获取 localStorage 的值,那么也是一种两个同源页面实现通讯的一种手段。但是这不是很好的办法。而 storage 事件的触发可以在需要的时候告知其他页面进行相应的操作,比轮询的方式好太多了,并且兼容性也很好。
要想使用 storage 事件,那么必不可少的就是了解事件处理程序中的 event 对象究竟有什么东西,我们主要关注一下三个:
key
设置或删除或修改的键,当调用clear
时,key
为null
。oldValue
对应key
改变之前的旧值,如果是新增的话,就是null
。newValue
对应key
改变之后的新值,如果是删除的话,就是null
。
因此,事件派发的逻辑就很清晰了:
- 监听 storage 事件的触发
- 依次执行对应事件的回调函数
$emit
事件之前已经实现了,那么就来编写监听storage
事件的方法。
_monitorStorageEvent() {
this.storageEventHandler = event => {
const { key, newValue } = event;
const events = this.events[key] || (this.events[key] = []);
events.forEach(handler => handler());
};
window.addEventListener('storage', this.storageEventHandler);
}
我们把key
当做事件的名字,那么当 storage 对象的数据发生修改时,就可以知道什么事件触发了,然后再去一次执行对应的回调函数。
但是现在,我们没有通过A页面的$emit
方法去派发事件,我们先添加一下:
$emit(eventName, args = [], callself = true) {
if (callself) {
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
localStorage.setItem(eventName, Date.now());
}
这时候我们是可以这样去使用的:
// B 页面监听 hello 事件
const bus = new EventEmitter();
bus.$on('hello', () => console.log('hello call'));
// A 页面 触发 hello 事件
const bus = new EventEmitter();
bus.$emit('hello');
// B 页面输出
hello call
现在,可以用了,但是却有几个问题需要解决一下:
- 命名冲突:同域之间的页面是共享 localStorage 的,我们直接通过
setItem(key, value)
修改的话,可能会不小心覆盖其他页面正在使用的有用的值。 - 事件没有过滤:不管是那个同域页面修改了 localStorage,都会被处理,其实我们根本就不关心除了事件以外的 storage 发生变化。
- 没法传参:我们调用
setItem(key, value)
的时候,value
是一个当前时间的时间戳,没有地方传参数了。至于为什么是时间戳,后面会介绍。
完善:命名空间
增加命名空间,是为了解决 localStorage 中的 key 命名冲突以及解决事件没有过滤的问题。
处理方法很简单,只需要在$emit
时自动加上命名空间前缀,在监听 storage 事件时根据命名空间过滤,然后除去命名空间前缀再遍历调用对应的事件处理程序即可。
const webviewBusContext = '@@WebviewBus_'; // 其实起什么都行,复杂点没那么容易冲突
// 监听时根据命名空间过滤
_monitorStorageEvent() {
this.storageEventHandler = event => {
const { key, newValue } = event;
// 只处理以命名空间开头的 key
if (key.startsWith(webviewBusContext)) {
// 除去命名空间前缀
const eventName = key.replace(webviewBusContext, '');
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler());
}
};
window.addEventListener('storage', this.storageEventHandler);
}
// 派发时增加命名空间
$emit(eventName, args = [], callself = true) {
if (callself) {
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
const key = `${webviewBusContext}${eventName}`;
localStorage.setItem(key, Date.now());
}
OK,这样子的话我们就可以在内部实现命名空间,避免了key
的命名冲突,以及过滤掉那些我们不需要的key
触发更新的事件。
继续完善:传参
之前在setItem(key, value)
中,我们传入的value
是一个当前时间的时间戳。如果我们需要传参,那么就需要占用这个参数,所以就不能直接传时间戳了,所以,一个很简单的传参方式就是:
$emit(eventName, args = [], callself = true) {
if (callself) {
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
const key = `${webviewBusContext}${eventName}`;
localStorage.setItem(key, JSON.stringify(args));
}
_monitorStorageEvent() {
this.storageEventHandler = event => {
const { key, newValue } = event;
if (key.startsWith(webviewBusContext)) {
// 解析参数
const args = JSON.parse(newValue);
const eventName = key.replace(webviewBusContext, '');
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
};
window.addEventListener('storage', this.storageEventHandler);
}
这样看起来没什么问题,但是,如果我们这样搞的话:
// B 页面监听 hello 事件
const bus = new EventEmitter();
bus.$on('hello', () => console.log('hello call'));
// A 页面 触发 hello 事件
const bus = new EventEmitter();
bus.$emit('hello', [1, 2]);
// B 页面输出
hello call
// A 页面 再次触发 hello 事件
bus.$emit('hello', [1, 2]);
// B 页面输出
(无输出)
不知道你看出来了没有,理论上hello call
是应该打印两次的,因为我们两次调用了$emit
,但实际上没有。为什么呢?因为对于同一个key
,storage 事件只在value
发生变化时触发,也就是说触发的条件是newValue !== oldValue
。之前的代码没有问题,是因为我们每次都传入一个时间戳,所以每次触发$emit
事件,都是有效的。
所以,我们不能直接通过value
传参,而是需要附加一个时间戳的标识,使得每次setItem
时value
的值都不一样。
$emit(eventName, args = [], callself = true) {
if (callself) {
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
const key = `${webviewBusContext}${eventName}`;
const value = JSON.stringify({
// 增加时间戳
timestamp: Date.now(),
args
});
localStorage.setItem(key, value);
}
_monitorStorageEvent() {
this.storageEventHandler = event => {
const { key, newValue } = event;
if (key.startsWith(webviewBusContext)) {
// 解析参数
let value;
try {
value = JSON.parse(newValue);
} catch (error) {
console.error('parse args error:', error);
value = {};
}
const { args = [] } = value;
const eventName = key.replace(webviewBusContext, '');
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
};
window.addEventListener('storage', this.storageEventHandler);
}
完成:WebviewBus
从基础框架、完善、继续完善,用于跨 Webview 的通讯机制终于完成了,我把他取名为 WebviewBus
。
const webviewBusContext = '@@WebviewBus_';
class WebviewBus {
constructor(key) {
this.events = {};
this._monitorStorageEvent();
}
_monitorStorageEvent() {
this.storageEventHandler = event => {
const { key, newValue } = event;
if (key.startsWith(webviewBusContext)) {
this._handleEvents(key, newValue);
}
};
window.addEventListener('storage', this.storageEventHandler);
}
_boardcast(eventName, args) {
const key = `${webviewBusContext}${eventName}`;
const value = JSON.stringify({
timestamp: Date.now(),
args
});
localStorage.setItem(key, value);
}
_handleEvents(eventName, value) {
try {
value = JSON.parse(value);
} catch (error) {
console.error('parse args error:', error);
value = {};
}
const { args } = value;
eventName = eventName.replace(webviewBusContext, '');
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
$on(eventName, handler) {
const events = this.events[eventName] || (this.events[eventName] = []);
if (
events.every(fn => fn !== handler)
) {
events.push(handler);
}
}
$emit(eventName, args = [], callself = true) {
if (callself) {
const events = this.events[eventName] || (this.events[eventName] = []);
events.forEach(handler => handler.apply(null, args));
}
this._boardcast(eventName, args);
}
$un(eventName, handler) {
if (!eventName) {
this.events = {};
return;
}
if (!handler) {
this.events[eventName] = [];
return;
}
const eventsArray = this.events[eventName];
if (!Array.isArray(eventsArray)) {
return;
}
let i = 0;
while (i++ < eventsArray.length) {
if (eventsArray[i] === handler) {
break;
}
}
if (i < eventsArray.length) {
eventsArray.splice(i, 1);
}
}
$destroy() {
window.removeEventListener('storage', this.storageEventHandler);
}
}
总结
之前工作中的需求,涉及了 iframe 的跨域通讯,使用了 postMessage + 监听 message 事件的方式;而这次的业务需求,则涉及了同域的跨页通讯,使用了 localStorage + storage event 的方式解决。
也总算是从工作中实践到一些不一样的东西:
- postMessage 虽好,但需要获取页面的 window 引用,适用于一些 iframe 和主页面的通讯,同域和跨域皆可。
- localStorage + storage event 不需要获取页面 window 的引用,但需要同域,适用于同域的不同页面之间的通讯。