cxxjackie 发表于 2022-8-14 12:30:36

[油猴开发指南]关于脚本如何处理iframe的碎碎念

这应该是个很经典的问题了,首先要明确的是,iframe可以看做一个独立的页面,每个iframe都有自己的src,这个src就是页面的链接。你可以试着在iframe链接上右键,新标签页打开,就可以看到这个独立页面了。脚本@match可以填iframe链接,此时应当认为代码是在这个独立网页下运行的,处理方式与普通页面无异。如果主页面与iframe页面的处理没有交集,那只需同时@match两个链接,并根据location.href分别处理不同逻辑即可(或者写成2个脚本)。本文主要讨论有交集的情况,即主页面与iframe应当如何交互的问题。
## 同源iframe
如果你还不知道什么是同源跨域,请先阅读[此文](https://developer.mozilla.org/zh-CN/docs/Web/Security/Same-origin_policy)。同源的交互是比较简单的,所有代码可以全放在主页面下处理。主页面先获取iframe所在的元素,iframe.contentWindow即目标window,iframe.contentDocument即目标document,可以用iframe.contentDocument.querySelector获取iframe内的元素,也可以直接对其进行修改。这里要注意的是,iframe的加载时机与主页面并不同步,特别是运行于document-start阶段的脚本,有可能会出现iframe加载比主页面慢的情况,这时contentDocument会取到null。解决方法很简单,监听一下iframe的load事件即可,为便于处理,我们可以把这个过程封装一下:
```js
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站向百度发消息并影响百度页面的过程(打开百度执行脚本):
```js
// ==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,基于这一点,一个双向通信就可以被建立起来了:
```js
// ==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的情况,虽然使用上更自由,但局限性也更大。

脚本体验师001 发表于 2022-8-14 13:04:19

真的佩服,这种高质量的帖子越多越好啊

李恒道 发表于 2022-8-14 13:31:42

纳入脚本开发指南可以吗哥哥

王一之 发表于 2022-8-14 13:38:28

李恒道 发表于 2022-8-14 13:31
纳入脚本开发指南可以吗哥哥

不如先整理整理

Ne-21 发表于 2022-8-14 15:02:46

学知识的一天

李恒道 发表于 2022-8-14 15:19:04

王一之 发表于 2022-8-14 13:38
不如先整理整理

我现在就去学git
....

李恒道 发表于 2022-8-14 15:19:26

王一之 发表于 2022-8-14 13:38
不如先整理整理

话说画图的软件哥哥有推荐吗==
好多都是我手绘的...
感觉太草了

王一之 发表于 2022-8-14 15:21:40

李恒道 发表于 2022-8-14 15:19
话说画图的软件哥哥有推荐吗==
好多都是我手绘的...
感觉太草了

我一般用这个

https://draw.io/

cxxjackie 发表于 2022-8-14 20:23:53

李恒道 发表于 2022-8-14 13:31
纳入脚本开发指南可以吗哥哥

可以可以,哈哈,我记得你好几月前就说过要整理,结果到现在只有新建文件夹{:4_108:}

李恒道 发表于 2022-8-15 03:32:40

cxxjackie 发表于 2022-8-14 20:23
可以可以,哈哈,我记得你好几月前就说过要整理,结果到现在只有新建文件夹 ...

{:4_96:}说起来你可能我不信
我不知道为啥
我对git有一种天然的排斥
甚至svn四五天都能上手
git我从大学就开始学
前前后后学了四五遍了
上手就gg
页: [1] 2 3
查看完整版本: [油猴开发指南]关于脚本如何处理iframe的碎碎念