这应该是个很经典的问题了,首先要明确的是,iframe可以看做一个独立的页面,每个iframe都有自己的src,这个src就是页面的链接。你可以试着在iframe链接上右键,新标签页打开,就可以看到这个独立页面了。脚本@match可以填iframe链接,此时应当认为代码是在这个独立网页下运行的,处理方式与普通页面无异。如果主页面与iframe页面的处理没有交集,那只需同时@match两个链接,并根据location.href分别处理不同逻辑即可(或者写成2个脚本)。本文主要讨论有交集的情况,即主页面与iframe应当如何交互的问题。
同源iframe
如果你还不知道什么是同源跨域,请先阅读此文。同源的交互是比较简单的,所有代码可以全放在主页面下处理。主页面先获取iframe所在的元素,iframe.contentWindow即目标window,iframe.contentDocument即目标document,可以用iframe.contentDocument.querySelector获取iframe内的元素,也可以直接对其进行修改。这里要注意的是,iframe的加载时机与主页面并不同步,特别是运行于document-start阶段的脚本,有可能会出现iframe加载比主页面慢的情况,这时contentDocument会取到null。解决方法很简单,监听一下iframe的load事件即可,为便于处理,我们可以把这个过程封装一下:
function getIframeDocument(iframe) {
return new Promise(resolve => {
if (iframe.contentDocument) {
resolve(iframe.contentDocument);
} else {
iframe.addEventListener('load', e => {
resolve(iframe.contentDocument);
});
}
});
}
跨域iframe
对于跨域的情况,iframe.contentDocument无论如何都是null,contentWindow虽然存在,但上面的大多数属性都不可用。跨域访问是有限制的,这种限制不难理解,不过我们的脚本有办法绕过,主要就是靠postMessage和GM_addValueChangeListener两种方法(后者实际上并不适合于iframe,后文细说)。绕过跨域限制并不是说我们可以直接操作另一个页面的元素,而是让脚本同时运行在两个页面下,通过消息机制在不同页面间交换信息,“指挥”另一个页面该做什么。
postMessage
先来看看用法:
targetWindow.postMessage(message, targetOrigin)
targetWindow
接收消息的window引用
message
发送的消息内容
targetOrigin
接收消息的目标origin
如果主页面要向iframe发消息,targetWindow就是iframe.contentWindow;iframe向主页面发消息,targetWindow则是window.top。message的类型没有限制,但一般推荐用对象,以便于接收方的处理。targetOrigin是一个字符串(不知道是啥可以输出location.origin看看),填'*'表示无限制,否则就必须完全匹配才发送,一般都建议指定明确的origin。这可能是个令人困惑的参数,我都有目标window了,为何还需要origin?这主要是出于安全考虑,举个例子,A网站中有一个iframe B,B向A发敏感消息,某钓鱼网站也嵌套了这个B,如果B不指定origin,那消息就会发到钓鱼网站那里去,从而造成信息泄露。当然如果我们的脚本消息无关紧要,那用通配符也没事。
接收消息的一方直接监听message事件就行,消息内容在data属性上。下面这个例子展示了b站向百度发消息并影响百度页面的过程(打开百度执行脚本):
// ==UserScript==
// @name 跨域交互
// @description ...
// @namespace ...
// @author ...
// @version 1.0
// @match https://www.baidu.com/*
// @match https://www.bilibili.com/*
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
if (location.href.includes('baidu')) {
const iframe = document.createElement('iframe');
iframe.src = 'https://www.bilibili.com/';
iframe.style.display = 'none';
document.body.appendChild(iframe);
window.addEventListener('message', e => {
if (e.data.magic === true) {
document.body.innerHTML = 'Magic!';
}
});
}
if (location.href.includes('bilibili')) {
window.top.postMessage({
magic: true
}, 'https://www.baidu.com');
}
})();
可以看到,b站并没有直接操作百度页面(实际上也没有这个权限),而是通过一条消息,让百度自己改变了自己的页面。当然实际运营中百度不会理睬b站的消息,处理消息的实际上是我们的脚本,做出改变的也是脚本,这就是脚本绕过跨域限制的原理。
上面的例子如果我们反过来,让百度向b站发消息会怎样(还记得iframe.contentWindow吗)?测试一下就会发现,iframe根本收不到。这其实是运行时机的问题,当iframe刚刚创建时,脚本还没有注入(注意iframe中的脚本与主页面的脚本是不同实例),监听是在注入后才开始的,而消息早在注入前就已发出,所以被错过了。解决方法也不难,让iframe先向主页面发个消息,告诉他我准备好了,主页面收到以后再发送相关指令即可。
现在来设想一个比较复杂的情况:脚本运行于所有域名,我们需要让所有iframe都获取到主页面的标题(或者是别的什么有用的东西),应该怎么做?这个问题好像不难,主页面下window.frames保存了所有iframe的window引用,遍历一下逐个发送就行了。这个做法的缺陷在于没有考虑iframe嵌套的情况,是的,iframe是可以嵌套iframe的,而window.frames只保存第一级的子页面,再往下就没有了。相比之下,iframe的window.top直接指向最顶级的主页面(window.parent才是上一级),所以这个问题应该反过来处理,由iframe向主页面发消息,主页面收到后逐个回应。剩下的问题是怎么回应,即主页面怎么得到targetWindow,这也很好解决,在收到消息时,e.source即消息来源方的window,基于这一点,一个双向通信就可以被建立起来了:
// ==UserScript==
// @name 获取主页面标题
// @description ...
// @namespace ...
// @author ...
// @version 1.0
// @include *
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
if (window === window.top) {
window.addEventListener('message', e => {
if (e.data.myMessage && e.data.myMessage.command === 'getTitle') {
e.source.postMessage({
myMessage: {
command: 'sendTitle',
data: document.title
}
}, '*');
}
});
} else {
window.addEventListener('message', e => {
if (e.data.myMessage && e.data.myMessage.command === 'sendTitle') {
console.log("主页面标题是:" + e.data.myMessage.data);
}
});
window.top.postMessage({
myMessage: {
command: 'getTitle'
}
}, '*');
}
})();
GM_addValueChangeListener
GM_addValueChangeListener是油猴提供的API,他无需取得目标window,而是靠监听存储空间的变化(通过GM_setValue改变),由于同一个脚本共用同一存储空间,只要主页面和iframe轮流修改数据,监听数据的变化就约等于是在通信(就好比在同一块留言板上聊天)。
GM_addValueChangeListener(name, callback)
name
数据名称,就是GM_setValue的第一个参数
callback
回调函数
回调函数的参数:
name
数据名称
oldValue
旧值
newValue
新值
remote
修改是否来自另一个脚本实例
对于第4个参数,什么叫另一个实例呢?iframe中的脚本与主页面的脚本就被认为是不同实例,可以据此来判断修改来源。当主页面监听value变化时,他自己写的“留言”也会被自己监听到,判断remote就可以过滤掉自己的消息。这看起来没有问题,那如果页面中有2个iframe会怎样?主页面需要分别对2个iframe做出应答,如何判断消息来自谁?发给谁?这就是GM_addValueChangeListener的局限性,当“参会”人员超过2个时,身份识别就会变得困难起来。鉴于消息是可以自定义的,所以有一个解决思路就是:在发送的消息里附带身份信息,比如把location.href放进消息里,这总不会弄混吧?为了回答我是谁和发给谁的问题,自身和目标的href都得放进去。这样虽然可行,但我们的消息会变得很臃肿,代码上也需要做额外的判断,不可谓不麻烦。
还有另一个隐患:假如我们在不同标签页打开同一个网站,所有这些对value的修改都是在同一存储空间里进行,GM_addValueChangeListener会同时观察到所有修改,这时候依据href来识别身份就不再可靠了,你该如何判断链接来自哪个标签页?这个问题基本无解,即使有办法,也只能往消息里塞进更多内容,制造更多麻烦,结果上只是对postMessage拙劣的模仿。这就是为什么我一开始说,GM_addValueChangeListener做iframe交互并不合适。事实上,他更适用于你无法获取到window的情况,比如跨标签页的交互,而且最好是一对一的交互。
postMessage也能跨标签页?
答案是可以,但仅限于你用window.open打开的标签页。window.open是有返回值的,就是新标签页的window,自然也可以拿来postMessage。这里要注意的问题是,一个新页面从打开到脚本注入是需要时间的(即使是document-start),如果你open完马上postMessage,另一边肯定收不到,听起来是不是跟前面的iframe一样?解决方式也一样,由被打开的页面向主页面发消息即可,window.opener指向主页面。
总结
postMessage的前提是取得目标window的引用,由于目标明确,在跨域交互上更可靠,同时要注意两边加载时机的问题,应由后加载的一方先发消息。
GM_addValueChangeListener适用于无法取得window的情况,虽然使用上更自由,但局限性也更大。