Skyphobia

打开新窗口的正确姿势

说起打开新窗口(标签页),大家最常用的大概是window.opentarget="_blank"。然而产品经理总有一万个理由说服你在各种场景下实现这个功能。于是在经历成百上千次的重复劳作后,你会发现“打开新窗口”这一件小事,似乎并没有说起来的那么轻巧。

Chrome 浏览器鼠标右键菜单

被浏览器阻止的新窗口页面

这个情况比较常见,大多数现代浏览器发现一个打开新窗口的操作不一定是由用户主动发起的时候,浏览器就有可能会阻止这个打开新窗口的操作。

以 Chrome 为例,对一个链接添加点击事件,并且在事件中用window.open实现直接打开某一个链接是完全 OK 的,因为 Chrome 认可这是用户主动点击后造成的结果;如果同样在这个点击事件中,我们通过 AJAX 从服务器获取到一个链接,然后用window.open在新窗口中打开这个链接,由于异步行为存在的不可信性,那么 Chrome 就会认为这个行为不一定是用户主动操作的结果,最后导致链接打开失败,Chrome 或许会给用户一个链接被拦截的提示 balabala……

就像这样

解决方案倒也不复杂,一言以蔽之,先打开页面后更改链接:

1
2
3
4
5
6
7
$a.onclick = () => {
const win = window.open()
fetch('<URL>')
.then(resp => {
win.location.href = resp.json().url
})
}

浏览器并不阻止父页面对字页面窗口对象的更改,通过这一点,我们就可以曲线救国绕过浏览器的安全机制异步打开新窗口了。

罪恶的窗口对象

安全漏洞

WEB 安全真的是一件令前端开发者头痛的事情。即便代码跑过了所有的业务逻辑测试用例,我们还是很难保证代码可以“安全”地运行在客户端浏览器上。譬如当我们用各种手段打开新窗口之时,殊不知一个叫作opener的恶魔正在悄无声息地从潘多拉魔盒跃然而出……

使用window.open或者通过 a 标签的target="_blank"打开新窗口子页面时,新页面会将一个叫作opener的全局对象指向源页面的窗口对象。实际上浏览器本身已经对opener做了诸多限制,不符合同源策略的新页面无法通过这个对象访问或操作父页面的内容——但是!凡事总有一个但是。虽然不能获取到opener.location.href的值,但是我们居然能够通过赋值或者opener.location.replace直接更改它!

这也就是说,即便是在浏览器的同源策略的保护下新窗口页面无法获取到源页面本地储存的用户敏感数据,但是不能保证第三方页面不通过这个“小特性”反过来篡改源页面的链接,以此达到钓鱼的目的:

1
2
3
4
5
// 源页面 https://a.com
window.open('https://b.com')

// 新页面(可能是个外部广告页) https://b.com
window.opener.location.replace('https://aa.com') // 直接把源页面地址悄悄替换成一个高仿钓鱼网站

性能问题

然而安全漏洞也仅仅只是opener带来的罪状之一,可预见的另一则罪状是性能问题。

如果新页面上正在执行开销较大的 JavaScript,源页面的脚本执行则有可能出现诸如断断续续的卡顿之类的性能影响。造成源页面性能问题的原因是,被打开的新页面会和源页面在同一个进程上运行,新页面脚本在阻塞自己的同时,也阻塞了源页面。

按理说,大多数现代浏览器应该会为每个新窗口(标签页)开辟不同的进程,每个进程也都有多个线程,为什么碰到window.opentarget="_blank"打开的新窗口就例外了呢?答案还是这个万恶的opener。由于 DOM 通过opener向我们提供同步跨窗口访问,为了方便起见就会把新窗口和源窗口放同一个进程上了。同样的问题在iframe上也存在,这里不扩展讨论。

跨站隔离

上述的两个问题均由opener带来,那我们的解决方案就是隔绝它。

对于通过 a 标签target="_blank",我们可以再为其加上rel="noopener"(低版本 Firefox 仅支持rel="noreferrer")的属性来隔绝opener

1
<a href="<URL>" target="_blank" rel="noopener noreferrer">Click me!</a>

对于window.open,版本较高的浏览器支持了noopenerwindowFeatures,较低版本的浏览器没有支持,只能尽早手动将opener置空解决安全问题,至于性能问题就……

1
2
3
4
5
6
7
// 较高版本
window.open('<URL>', 'windowName', 'noopener')

// 较低版本
const win = window.open()
win.opener = null
win.location = '<URL>'

总结

通过上述问题大家也应该发现,看似简单的问题背后总有一大串深坑等着人来跳。没有需求就没有伤害,但愿下一个需求不是打开新窗口。