处理 iframe 的碎碎念
请大家观看的时候默念 cxxjackie 永远的神!
正文
这应该是个很经典的问题了,首先要明确的是,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 的情况,虽然使用上更自由,但局限性也更大。