html跨tab页通信(同源跨域)
# 目标
当前页面需要与当前浏览器已打开的的某个tab页通信,完成某些交互。其中,与当前页面待通信的tab页可以是与当前页面同域(相同的协议、域名和端口),也可以是跨域的。
要实现这个特殊的功能,单单使用HTML5的相关特性是无法完成的,需要有更加巧妙的设计。
# 方法
# 获取句柄 /postMessage
必须有一个页面(如A页面)可以获取另一个页面(如B页面)的window对象,这样才可以完成通信; **两个需要交互的tab页面具有依赖关系。**如 A页面中通过JavaScript的window.open打开B页面,或者B页面通过iframe嵌入至A页面
;
此种情形最简单,可以通过HTML5的 window.postMessage
API完成通信,由于postMessage函数是绑定在 window 全局对象下,因此通信的页面中必须有一个页面(如A页面)可以获取另一个页面(如B页面)的window对象,这样才可以完成单向通信;
B页面无需获取A页面的window对象,如果需要B页面对A页面的通信,只需要在B页面侦听message事件,获取事件中传递的source对象,该对象即为A页面window对象的引用:
// parent.html
const childPage = window.open('child.html', 'child')
childPage.onload = () => {
childPage.postMessage('hello', location.origin)
}
// child.html
window.onmessage = evt => {
// evt.data
}
2
3
4
5
6
7
8
9
10
postMessage的第一个参数为消息实体,它是一个结构化对象,即可以通过“JSON.stringify和JSON.parse”函数还原的对象;第二个参数为消息发送范围选择器,设置为“/”意味着只发送消息给同源的页面,设置为“*”则发送全部页面。
window.addEventListner('message',(e)=>{
let {data,source,origin} = e;
source.postMessage('message echo','/');
});
2
3
4
# localStorage/storageEvent
两个打开的页面属于同源范畴。同源的两个tab页面可以通过共享localStorage的方式通信;
localStorage的存储遵循同源策略,因此同源的两个tab页面可以通过这种共享localStorage的方式实现通信,通过约定localStorage的某一个itemName,基于该key值的内容作为“共享硬盘”方式通信。
// A 页面
window.addEventListener("storage", function(ev){
if (ev.key == 'message') {
// removeItem同样触发storage事件,此时ev.newValue为空
if(!ev.newValue)
return;
var message = JSON.parse(ev.newValue);
console.log(message);
}
});
function sendMessage(message){
localStorage.setItem('message',JSON.stringify(message));
localStorage.removeItem('message');
}
// 发送消息给B页面
sendMessage('this is message from A');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// B 页面
window.addEventListener("storage", function(ev){
if (ev.key == 'message') {
// removeItem同样触发storage事件,此时ev.newValue为空
if(!ev.newValue)
return;
var message = JSON.parse(ev.newValue);
// 发送消息给A页面
sendMessage('message echo from B');
}
});
function sendMessage(message){
localStorage.setItem('message',JSON.stringify(message));
localStorage.removeItem('message');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
发送消息采用sendMessage函数,该函数序列化消息,设置为localStorage的message字段值后,删除该message字段。这样做的目的是不污染localStorage空间,但是会造成一个无伤大雅的反作用,即触发两次storage事件,因此我们在storage事件处理函数中做了if(!ev.newValue) return;
判断。
当我们在A页面中执行sendMessage函数,其他同源页面会触发storage事件,而A页面却不会触发storage事件;而且连续发送两次相同的消息也只会触发一次storage事件,如果需要解决这种情况,可以在消息体体内加入时间戳:
sendMessage({
data: 'hello world',
timestamp: Date.now()
});
sendMessage({
data: 'hello world',
timestamp: Date.now()
});
2
3
4
5
6
7
8
通过这种方式,可以实现同源下的两个tab页通信,兼容性
通过caniuse网站查询storage事件发现,IE的浏览器支持非常的不友好,caniuse使用了“completely wrong”的形容词来表述这一程度。IE10的storage事件会在页面document文档对象构建完成后触发,这在嵌套iframe的页面中造成诸多问题;IE11的storage Event对象却不区分oldValue和newValue值,它们始终存储更新后的值
mydata.st = +(new Date);
window.localStorage.setItem('samy', JSON.stringify(mydata));
localStorage.removeItem('samy');
2
3
注意,这里有一个细节:我们在mydata上添加了一个取当前毫秒时间戳的.st
属性。这是因为,storage
事件只有在值真正改变时才会触发。 发送之后再移除信息数据;
# 扩展
“当同源页面的某个页面修改了localStorage,其余的同源页面只要注册了storage事件,就会触发”
所以,localStorage
的例子运行需要如下条件:
- 同一浏览器打开了两个同源页面
- 其中一个网页修改了
localStorage
- 另一网页注册了
storage
事件
很容易犯的错误是,在同一个网页修改本地存储,又在同一个网页监听,这样是没有效果的。
在同源的两个页面中,可以监听 storage
事件
window.addEventListener("storage", function (e) {
alert(e.newValue);
});
2
3
在同一个页面中,对 localStorage
的 setItem
方法进行重写
var orignalSetItem = localStorage.setItem;
localStorage.setItem = function(key,newValue){
var setItemEvent = new Event("setItemEvent");
setItemEvent.newValue = newValue;
window.dispatchEvent(setItemEvent);
orignalSetItem.apply(this,arguments);
}
window.addEventListener("setItemEvent", function (e) {
alert(e.newValue);
});
localStorage.setItem("name","wang");
2
3
4
5
6
7
8
9
10
11
# vue中使用
首先在main.js中给Vue.protorype注册一个全局方法,
其中,我们约定好了想要监听的sessionStorage的key值为’watchStorage’,
然后创建一个StorageEvent方法,当我在执行sessionStorage.setItem(k, val)这句话的时候,初始化事件,并派发事件。
Vue.prototype.resetSetItem = function (key, newVal) {
if (key === 'watchStorage') {
// 创建一个StorageEvent事件
var newStorageEvent = document.createEvent('StorageEvent');
const storage = {
setItem: function (k, val) {
sessionStorage.setItem(k, val);
// 初始化创建的事件
newStorageEvent.initStorageEvent('setItem', false, false, k, null, val, null, null);
// 派发对象
window.dispatchEvent(newStorageEvent)
}
}
return storage.setItem(key, newVal);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//如何触发
//在一个路由(比如路由A)存储值得时候,使用下面的方法:
this.resetSetItem('watchStorage', 11111);
//如何监听
//如果在另外一个路由(比如路由B)中,我们想根据watchStorage的变化来请求接口刷新页面数据的时候,可以在这个路由中created钩子函数中监听:
window.addEventListener('setItem', ()=> {
this.newVal = sessionStorage.getItem('watchStorage');
})
2
3
4
5
6
7
8
// 如果报prototype无法被识别的错误,可以把原型方法挂在到__proto__
Vue.prototype.$addStorageEvent = function (type, key, data) {
if (type === 1) {
// 创建一个StorageEvent事件
var newStorageEvent = document.createEvent('StorageEvent');
const storage = {
setItem: function (k, val) {
localStorage.setItem(k, val);
// 初始化创建的事件
newStorageEvent.initStorageEvent('setItem', false, false, k, null, val, null, null);
// 派发对象
window.dispatchEvent(newStorageEvent);
}
}
return storage.setItem(key, data);
} else {
// 创建一个StorageEvent事件
var newStorageEvent = document.createEvent('StorageEvent');
const storage = {
setItem: function (k, val) {
sessionStorage.setItem(k, val);
// 初始化创建的事件
newStorageEvent.initStorageEvent('setItem', false, false, k, null, val, null, null);
// 派发对象
window.dispatchEvent(newStorageEvent);
}
}
return storage.setItem(key, data);
}
}
//二、组件中调用:
this.$addStorageEvent(2, "user_info", data);
//三、在另一个组件中的 mounted 钩子函数中监听:
window.addEventListener('setItem', (e) => {
console.log(e); //获取参数
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# iframe + postMessage
**两个互不相关的tab页面通信。**通过在这两个tab页嵌入同一个iframe页实现“桥接”,最终完成两个不相关的tab页通信;
这种情况才是最急需解决的问题,如何实现两个没有任何关系的tab页面通信,这需要一些技巧,而且需要有同时修改这两个tab页面的权限,否则根本不可能实现这两个tab页的能力。
在上述条件满足的情况下,我们就可以使用case1 和 case2的技术完成case 3的需求,这需要我们巧妙的结合HTML5 postMessage API 和 storage事件实现这两个毫无关系的tab页面的连通。为此,我想到了iframe,通过在这两个tab页嵌入同一个iframe页实现“桥接”,最终完成通信:
tab A -----> iframe A[bridge.html]
|
|
\|/
iframe B[bridge.html] -----> tab B
2
3
4
5
// tab A:
// 向弹出的tab页面发送消息
window.sendMessageToTab = function(data){
// 由于[#J_bridge]iframe页面的源文件在vstudio服务器中,因此postMessage发向“同源”
document.querySelector('#J_bridge').contentWindow.postMessage(JSON.stringify(data),'/');
};
// 接收来自 [#J_bridge]iframe的tab消息
window.addEventListener('message',function(e){
let {data,source,origin} = e;
if(!data) return;
try{
let info = JSON.parse(JSON.parse(data));
if(info.type == 'BSays'){
console.log('BSay:',info);
}
}catch(e){
}
});
sendMessageToTab({
type: 'ASays',
data: 'hello world, B'
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// bridge.html
window.addEventListener("storage", function(ev){
if (ev.key == 'message') {
window.parent.postMessage(ev.newValue,'*');
}
});
function message_broadcast(message){
localStorage.setItem('message',JSON.stringify(message));
localStorage.removeItem('message');
}
window.addEventListener('message',function(e){
let {data,source,origin} = e;
// 接受到父文档的消息后,广播给其他的同源页面
message_broadcast(data);
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// tab B
window.addEventListener('message',function(e){
let {data,source,origin} = e;
if(!data) return;
let info = JSON.parse(JSON.parse(data));
if(info.type == 'ASays'){
document.querySelector('#J_bridge').contentWindow.postMessage(JSON.stringify({
type: 'BSays',
data: 'hello world echo from B'
}),'*');
}
});
// tab B主动发送消息给tab A
document.querySelector('button').addEventListener('click',function(){
document.querySelector('#J_bridge').contentWindow.postMessage(JSON.stringify({
type: 'BSays',
data: 'I am B'
}),'*');
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 其他方案
参考 跨页面通信的各种姿势 (opens new window)
# 实践场景
多Tab打开及登录后台管理系统,用户其中一个tabA登陆后,再切换到另外一个tabB登陆其他用户操作;再切换到tabA操作,顶部用户信息还是旧用户信息;而用户的当前登陆信息没有放在localStorage上,而是放在cookie及放在sessionStorage上,这个时候无法使用方案一及方案二;
方法一
全局reload即可;
// 防止多个tab打开用户数据不一致处理,统一监听用户信息发生变化后,重新刷新当前tab
const sessonUc = getSession('uc');
const cookieUc = CookieUtil.get('uc');
// isNeedListenUserLogin 是否要监听用户登陆信息变化,再登陆时设置为true; 用的window存储变量
if (window.isNeedListenUserLogin && sessonUc && cookieUc && sessonUc !== cookieUc) {
window.isNeedListenUserLogin = false;
window.location.reload()
return;
}
2
3
4
5
6
7
8
9
方法二
在用户请求时判断当前用户的cookie及session是否一致,如不一致就跳转到登陆页面;
function getSession(key) {
if (!sessionStorage) {
return;
}
// eslint-disable-next-line consistent-return
return sessionStorage.getItem(key);
}
const globalLogout = () => {
// 清除ete_token,全程调度与数据运营对接
clearEteToken();
if (THEME === 'bss') {
if (window.top !== window.self && window.parent && window.parent.location) {
window.setTimeout(() => {
// 集成到bss的时候,那么用bss的方法退出到登录
window.parent.location.href = '/portal-web/logout';
}, 1000);
return;
}
}
if (isInSaasIframe()) {
// 集成到saas的时候,那么用saas的方法退出到登录
window.setTimeout(() => {
window.parent.postMessage({ type: 'logout' }, '*');
}, 1000);
}
window.g_app._store.dispatch({
type: 'login/logout',
callback: () => {
window.g_app._store.dispatch({
type: 'user/formatUserInfo',
});
window.g_app._store.dispatch({
type: 'menu/clear',
});
},
});
};
// 防止多个tab打开用户数据不一致处理,统一跳转到登陆
if (getSession('uc') && (getSession('uc') !== CookieUtil.get('uc'))) {
const { hash } = window.location;
if (!(url.indexOf('logout') >= 0 || url.indexOf(LOGIN_API_URL) >= 0 || hash.startsWith('/user'))) {
globalLogout()
return
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
cookieutil
const cookie = {
set(name, value, days) {
if (days) {
const d = new Date();
d.setTime(d.getTime() + 24 * 60 * 60 * 1000 * days);
window.document.cookie = `${name}=${value};path=/;expires=${d.toGMTString()}`;
} else {
window.document.cookie = `${name}=${value};path=/`;
}
},
get(name) {
const v = window.document.cookie.match(`(^|;) ?${name}=([^;]*)(;|$)`);
return v ? v[2] : null;
},
delete(name) {
this.set(name, '', -1);
},
};
export default cookie;
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19