浏览器父子窗口间通信

1,527 阅读6分钟

父子窗口通信需求背景

最近在实现一个接入QQ和微博第三方登陆的需求,使用的是基本OAuth2[1]流程和后端的基本对接流程(以QQ登陆为例):

  • 向后端发送请求,告诉后端要进行QQ登陆
  • 后端将QQ登陆授权页的地址下发到前端
  • 前端在子窗口中打开QQ登陆授权页(此时打开子窗口的页面暂且叫父页面)
  • 在用户授权完成后,QQ会重定向回自定义的URI(比如前端定义的一个空白页)并在参数中携带code
  • 子窗口在QQ重定向回空白页,即空白页挂载时将code从参数中提取出来,传给父页面,并关闭子页面
  • 父页面拿到code,将code传给后端
  • 父页面根据后端的返回执行下一步操作,如跳转到首页,或者更新页面数据等

其中,子页面向父页面传递code,这就涉及到父子间窗口的通信。父子间窗口通信一般分两种情况:

父子窗口同源

父窗口向子窗口通信

子窗口是由父窗口创建的。父窗口可以在打开子窗口后获取到子窗口的引用,通过这个引用可以触发子窗口的方法以此向子窗口传递消息

// parent code
let child_window_handle = null;
$('#open-child-win-btn').on('click', () => {
    child_window_handle = window.open('target_url.html', '_blank', 'width=700, height=500, left=200');
})

在vue中,可以在click的时候调用

// parent code
const child_window_handle = window.open('target_url.html', '_blank', 'width=700, height=500, left=200')
​
// 一般还会设置一下rel属性,优化一下SEO,告诉搜索引擎不对新页面进行爬取
child_window_handle.rel = 'nofollow'

这个时候有一个子窗口的句柄了(handler),而子窗口的页面下有如下方法

// child code
function ProcessParentMsg(msg) {
    // do something with the msg
}

在vue中,需要将相关方法暴露到window中

// child.vue
mounted() {
    window.ProcessParentMsg = this.ProcessParentMsg
},
​
methods: {
    ProcessParentMsg(msg) {
        // do something with the msg
    }
}

父窗口只需要在调用子窗口的对应方法就可以和子窗口完成通信

// parent code 注意:要确保子页面已打开的情况下再传
child_window_handle.ProcessParentMsg('msg_form_parent_window'); // ProcessParentMsg里面可以传对象

子窗口向父窗口通信

子窗口可以通过window对象的opener属性访问到父窗口。并且调用父窗口的方法来完成向上通信。

// child code
window.opener.ProcessChildMsg(); // ProcessChildMsg里面可以传对象

// 调用完之后可以自行关闭
window.close()

// parent code
function ProcessChildMsg(msg) {
    // do something with msg
}

同样,在vue中,需要将相关方法暴露到window中

// parent.vue
mounted() {
    window.ProcessChildMsg = this.ProcessChildMsg
},
​
methods: {
    ProcessChildMsg(msg) {
        // do something with the msg
    }
}

父子窗口同源的情况下,父窗口是可以很大程度的控制子窗口的。除了可以触发子窗口的方法,也可以监听子窗口的事件,onbeforeunloadonresize, focus等等, 但是父子窗口不同源的情况下。父窗口无法执行子窗口下的方法,也无法监听窗口下的事件。

父子窗口不同源

这种情况下父子窗口要通信就需要借助 postMessage 功能了。

父窗口向子窗口通信

在父窗口中向子窗口派发消息

// parent window
let child_window_handle = window.open('child_target.html', '_blank', 'width=700, height=500');
​
child_window_handle.postMessage('Msg to the child window', '*'); // 这里一般不写’*‘,而只写可信的源

在子窗口下监听消息

// child window
window.addEventListener('message', (e) => {
    ProcessParentMsg(e.data);
});
​
function ProcessParentMsg(msg) {
    // do something with the msg
}

在vue中, 一般在mounted的时候添加监听事件,在beforeDestroy的时候移除监听事件

// child.vue
mounted() {
    window.addEventListener('message', this.ProcessParentMsg)
},
beforeDestroy() {
    window.removeEventListener('message', this.ProcessParentMsg)
},
​
methods: {
    ProcessParentMsg(msg) {
        // do something with the msg
    }
}

子窗口向父窗口通信

// child window
window.opener.postMessage("Message to parent", "*"); // 这里一般不写’*‘,而只写可信的源// parent window
window.addEventListener('message', function(e) {
    ProcessChildMsg(e.data);
}, false);
​
function processChildMsg() {
    //  do something with the message
}

同样,在vue中一般在mounted的时候添加监听事件,在beforeDestroy的时候移除监听事件

// parent.vue
mounted() {
    window.addEventListener('message', this.ProcessChildMsg)
},
beforeDestroy() {
    window.removeEventListener('message', this.ProcessChildMsg)
},
​
methods: {
    processChildMsg(msg) {
        // do something with the msg
    }
}

值得注意的是

  • 如果点击按钮打开授权窗口的时候一直出现窗口被拦截的提示,无法直接打开授权弹窗口。这是因为点击window.open这个操作是在异步操作的回调里面执行的。默认这种情况下浏览器都会拦截这个新窗口,除非用户设定对这个域名允许任何弹窗。

image.png

stackoverflow上可以看到这个解释

The general rule is that popup blockers will engage if window.open or similar is invoked from javascript that is not invoked by direct user action. That is, you can call window.open in response to a button click without getting hit by the popup blocker, but if you put the same code in a timer event it will be blocked. Depth of call chain is also a factor - some older browsers only look at the immediate caller, newer browsers can backtrack a little to see if the caller's caller was a mouse click etc. Keep it as shallow as you can to avoid the popup blockers.

如果点击按钮的时候,先去通过网络请求接口获取授权页的连接。在异步回调里获取到了授权页链接。此时再去用window.open去打开这个链接。这个不是 direct user action。即使可以相信也是一个比较差的用户体验,因为造成了延迟。所以应该应该先拿到链接或者用户点击了登陆按钮马上打开一个blank窗口。同时异步去获取授权页链接。获取后reload打开的授权窗口的地址为获取到的连接。这样,这个操作就是用户主动点击执行的事件,这就不会导致 popup blocked 的现象发生了。

  • 这里还有个问题,就是子页面父页面跟子页面通讯时,必须等子页面加载完才能保证信息不丢失,目前想到的方法是,可以在子页面onload完之后主动去告诉父页面,如果还有其他方式,欢迎评论留言~

[1] OAuth2.0流程图

引用官方图片介绍OAuth2.0总体流程:
在这里插入图片描述

  • (A)客户端向从资源所有者请求授权。
  • (B)客户端收到授权许可,资源所有者给客户端颁发授权许可(比如授权码code)
  • (C)客户端与授权服务器进行身份认证并出示授权许可(比如授权码code)请求访问令牌。
  • (D)授权服务器验证客户端身份并验证授权许可,若有效则颁发访问令牌(accept token)。
  • (E)客户端从资源服务器请求受保护资源并出示访问令牌(accept token)进行身份验证。
  • (F)资源服务器验证访问令牌(accept token),若有效则满足该请求。

附: 如果要使用新的tab,打开新的页面,<a>标签请加上rel="noopener noreferrer"属性,将新页面的opener置为null,浏览器分配新的进程。window.open()标签请这么使用window.open(url, '_blank', 'noreferrer')属性。不添加相关属性,新的页面会和父页面公用渲染进程,如果新的页面卡主了,那么父页面也会受影响