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
}
1
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','/');
});
1
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');
1
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');
}
1
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()
});
1
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');
1
2
3

注意,这里有一个细节:我们在mydata上添加了一个取当前毫秒时间戳的.st属性。这是因为,storage事件只有在值真正改变时才会触发。 发送之后再移除信息数据;

# 扩展

“当同源页面的某个页面修改了localStorage,其余的同源页面只要注册了storage事件,就会触发”

所以,localStorage 的例子运行需要如下条件:

  • 同一浏览器打开了两个同源页面
  • 其中一个网页修改了 localStorage
  • 另一网页注册了 storage 事件

很容易犯的错误是,在同一个网页修改本地存储,又在同一个网页监听,这样是没有效果的。

在同源的两个页面中,可以监听 storage 事件

window.addEventListener("storage", function (e) {
    alert(e.newValue);
});
1
2
3

在同一个页面中,对 localStoragesetItem 方法进行重写

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");
1
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);
  }
}
1
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');
})
1
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); //获取参数
});
1
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
1
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'
})
1
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);
});
1
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'
    }),'*');
})
1
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;
  }
1
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
  }
}
1
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;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 参考链接

跨浏览器tab页的通信解决方案尝试 (opens new window)

跨页面通信的各种姿势 (opens new window)

上次更新: 2022/04/15, 05:41:26
×