blog
blog copied to clipboard
我是如何一步步踏入 iframe 的陷阱的
关注划词翻译更新日志的朋友可能会注意到,最近的划词翻译版本号成了四位数 8.6.7.x
这种形式,而平常都只有三位数。
只有在进行一些测试的时候,我才会将版本号用四位数的形式来表示,这意味着这个版本不会被发给所有用户。之所以会这么做,是因为最近我一直在尝试修复一个跟 相关的 bug,这个 bug 横跨了好几个版本、影响了至少上万人。
接下来,我会尝试将这个过程记录下来。
划词翻译与 <iframe>
的关系
划词翻译是一个浏览器扩展程序,当用户在网页上划选一段文本之后,划词翻译会在文本附近弹出一个小窗口并显示翻译结果。
但是,如果用户是在 <iframe>
里划词的话,就有一点麻烦了。
最开始,我会直接在 <iframe>
内显示窗口,但这样会有下面这些问题:
- 弹窗的显示范围会被限制在 iframe 内,导致可视区域很小,经常显示不全
- 顶层窗口(即
window.top
)的元素会遮挡住 iframe 里的弹窗 - 有的 iframe 宽度很小,导致被划词翻译的弹窗撑开了宽度,e.g. B 站的消息菜单被撑开出现了横向滚动条
思来想去,最好的办法是不要在 iframe 里显示弹窗,而是在 iframe 内划词后,通知顶层窗口显示。
经过一番努力,我终于在 v7.4.2 版本 中完成了这一技术改造。然后,我与 iframe 之间的斗争拉开了序幕……
第一次改进
v7.4.2 版本发上去之后没多久,就有一个用户反馈在某个网站上,随便点个地方就会导致登录窗口消失掉。
检查之后,我发现这个网站的登录窗口是一个 iframe
,而这个网站在顶层窗口注册了一个 message 事件,只要收到 message 就会关闭登录窗口;另一方面,当用户在 iframe 里产生点击事件之后,划词翻译会通过 window.postMessage()
发送一个消息给顶层窗口里的内容脚本,而这条消息同时也被这个网站注册的 message 事件捕捉到了,从而导致触发了关闭登录弹窗的行为。
详细情况见 https://github.com/lmk123/crx-selection-translate/issues/1023
好在这个网站会用到 JSON.parse()
解析收到的消息,于是我做了第一次改进,将划词翻译发送的消息改为了非 JSON 的形式,这样这个网站在收到划词翻译的消息时,JSON.parse()
就会报错导致代码无法继续运行下去,也就不会关闭弹窗了。
我自己也知道这个改进的作用是很有限的——如果网站接收的消息格式是纯字符串,那么划词翻译的消息还是会影响到网站。不过之后很长一段时间,我都没有收到用户的相关反馈,于是我也就没有再去思考后续的改进了。
但是,该来的总是会来的……
第二次以及之后的第三次、第四次改进
三个月后,越来越多的用户开始反馈安装划词翻译会导致部分网站出问题,详情见 https://github.com/lmk123/crx-selection-translate/issues/1150
于是我不得不考虑解决这个问题了。
有两种办法可以解决这个问题:
- 第一种:不要用
window.postMessage()
来传递消息。扩展程序其实本身有一个内置的消息传递方法browser.runtime.sendMessage()
,但是需要花费一点时间研究一下如何将它用在 iframe 之间传递消息,也有可能做不到。 - 第二种:拦截
message
事件。具体点讲,就是拦截掉onmessage
和window.addEventListener('message')
,将这俩替换成一个我自己的函数,将网站注册的事件收集起来,然后当接收到 message 事件时,如果不是划词翻译的消息,我就把这个事件转发给所有网站注册的处理函数。
当我的脑海中冒出第二个方案时,我就好像是想到了什么邪恶的念头一样,条件反射般的否决掉了这个方案。我深知这种拦截行为很可能造成无法预料的后果。
但是,当时我忙着改进划词翻译的其它问题,只会在偶尔闲暇的时候会思考一下要怎么实现第一种方案,而每当我发现似乎无法实现时,我的脑海里都会冒出一条伊甸园里的蛇,不断的诱惑我说:“拦截它、拦截它……”
终于,我决定用第二种方案!很显然,我当时低估了可能造成的影响,我以为第二种方案是红苹果,而实际上它是潘多拉魔盒……
实现的过程其实也不简单,总的来说就好像自己写了个 window.addEventListener()
函数,需要注意的点有:
- 注册事件时需要区分
capture: false
和capture: true
这两种情况。同一个事件处理函数不会被重复注册,但它可以同时注册为capture: false
和capture: true
的; - 需要处理
options.signal
传了AbortSignal
的情况;
实现这个拦截方案就是第二次改进了。本来以为完成这两点就够了,然而代码发上去后,有用户反馈问题,发现是有的网站会给 options
显式的传 null
……然后我做了第三次改进将这个情况考虑进去。
改好之后发上去,结果又出现了问题,有用户反馈说大部分的 Dizcus 论坛在发表回复后页面没有任何反应,检查之后发现这些论坛发表回复时用的是 iframe 的方式。
这里就得解释一下这个久远的技术了…现代网站一般用的都是 Ajax 了。
通过 iframe 完成类似 Ajax 的功能是这么做到的:
首先准备一个 iframe 元素,给它标上 name
属性比如 myCallbackIframe
;
然后,构建一个发表回复的 form
:
<!-- 注意这里的 target -->
<form action="/path/to/api" method="POST" target="myCallbackIframe">
<input value="这里是回复的内容" />
<button type="submit">提交</button>
</form>
当用户点击提交之后,浏览器会将接口 /path/to/api
返回的数据填充到前面准备好的 iframe 里,这时代码只需要通过 iframe.contentWindow
来读取数据就好了,这样就做到了无需刷新页面也能提交数据,跟 Ajax 达到的效果是一样的。
问题就出在这个 iframe 里:为了拦截 message 事件,划词翻译会注入一个 <script>
元素到 iframe 里,然后网站在读取 iframe 里的数据时,把划词翻译的这段 script 的内容也读走了,然后就会由于格式不符合预期而抛错。
于是我又做了第四次改进,只有当页面有 <html>
元素时才注入 <script>
。
还没完,第五次改进
改了这么多次之后,我心想应该没问题了吧,哪知马上就又有用户反馈问题了。
当网站调用了 document.open()
之后,所有事件处理函数都会被清空,而由于我没有考虑到这一点,所以导致 document.open()
之后再注册的 message 事件不会被触发。
要解决这个问题,只能拦截 document.open()
、document.write()
和 document.close()
这三个方法了——我当时的心情怎么描述呢,用个不恰当的比喻就好比一个强盗打家劫舍时被人目击到了然后不得不杀人灭口一样……
我安慰我自己“这应该是最后一次了”然后狠下心拦截了这三个方法——然而……更多的问题来了:
- https://github.com/lmk123/crx-selection-translate/issues/1280
- https://github.com/lmk123/crx-selection-translate/issues/1272
痛改前非
我终于意识到,这根本就是个无底洞!
我终于开始正视起这个问题,在公众号用户“Grimm”(大佬很谦虚,不想留下他的联系方式 :joy:)的指点下,实现了第一种方案,也就是彻底避免使用 window.postMessage()
来传递消息的方案。
至此,我终于敢放心的将版本号重新改回三位数、也终于不再担心这个问题了。
真心不容易