字数:4062
字符数:12662
推荐阅读时间:15分钟
大家好,我是 San 同学 ,专注输出精品原创技术文章,目前在浙大读研 & 字节实习
最近在工作期间频繁的遇到 iframe 标签,一想,iframe 不就是一个嵌套网页的东西吗?不过再一细想,一些细节又好像没摸清楚过,循环套娃浏览器会直接崩溃吗?随便挂个网站都可以展示吗?安全问题如何规避?知道 iframe 可以解决跨域问题吗?知道 iframe 还可以是微前端的一种方案吗?
好的,不知道,于是我开始认真学习 iframe...
iframe 到底是个啥?
The iframe element represents its nested browsing context. from WHATWG
乍一看,不就是嵌套的浏览器上下文嘛,来做几个简单实验瞅瞅
极简套娃
<!-- FirstPage/index.html -->
<body>
<h1>This is First Page!</h1>
<iframe src="../SecondPage/index.html" height="500px" width="100%">
</iframe>
</body>
<!-- SecondPage/index.html -->
<body>
<h2>This is the second page!</h2>
</body>
就这样一层嵌套的套娃就完成了 ✅,来简单看看效果
这就完事儿了?怎么可能,来看看中阶版本会发生什么...
中阶-娃中娃
<!-- FirstPage/index.html -->
<body>
<h1>This is First Page!</h1>
<iframe src="../SecondPage/index.html" height="500px" width="100%">
</iframe>
</body>
<!-- SecondPage/index.html -->
<body>
<h2>This is the second page!</h2>
<iframe src="../FirstPage/index.html" height="500px" width="100%"></iframe>
</body>
诶?怎么只套了两层,不应该是无限嵌套下去吗?这个时候就得去看下这个 iframe 的标准具体是怎么规定的了,在查找资料之后,发现在 97 年 W3C 发布的 frame 草案中有这么一段话
Infinite recursion is prevented. Any frame that attempts to assign as its SRC a URL used by any of its ancestors is treated as if it has no SRC URL at all (basically a blank frame). This doesn't prevent all malicious documents, but it eliminates a troublesome class of them.
虽然这是对 frame 的规定,但是草案中也提到了 iframe 提案,并且两者的区别可以简单理解为一个是需要放在 FRAMESET 中,一个是可以放在 BODY 中并加入文档流
那么这句话是什么意思的?简单的说就是为了避免无限递归,任何想要使用其祖先页面的 url 作为他的 src 的话,都会被当做一个 src 为空的 iframe 。这也就解释了为啥只套了两层就套不下去了
那么既然是根据 URL 判定是否为祖先页面,那么我们设置不同的 query 是否可以实现无限套娃呢?
<body>
<h1>This is First Page!</h1>
<iframe width="100%" height="800px"></iframe>
<script>
document.querySelector('iframe').src = `index.html?${Math.random() * 1000}`;
</script>
</body>
果不其然,这样就出现了无限套娃的现象 (冷知识++)
看到这里不知道大家是不是对与 iframe 是啥、iframe 的一些特性以及最基本的用途有个比较清晰的认识了?那么对应上面的问题,往前走一步,随便挂个网站都能显示?
iframe 的安全机制
来源限制
首先回到之前那个问题,随便挂个网站都能显示吗?那我挂一个知乎看看... 发现知乎拒绝了我们的连接请求,于是我打开了 devtools,我们可以看到有这么一个报错,由于知乎配置了 CSP 安全策略,导致我们的连接请求被拒绝了;同时我们观察知乎的返回的请求响应头,除了 CSP 之外,还有一个响应头是 X-Frame-Options,这个响应头设置的是 SAMEORIGIN,表明只有祖先页面的域名是同域的才允许嵌入
CSP iframe
🎯 通过配置 CSP 的一些策略,可以对 iframe 的嵌套进行一定的控制,保证一定的安全性;下边列举一部分 iframe 相关的 CSP 配置项
- frame-ancestors:指定可以使用<frame>, <iframe>, <object>, <embed>,或 <applet> 来嵌入本页面的有效父节点;像上边知乎配置的就是
frame-ancestors *.zhihu.com
;可以避免点击劫持的网络攻击 - frame-src:指定本页面能够嵌入网页的源,像知乎配置的是一大堆东西,
'self' *.zhihu.com mailto: tel: weixin: *.vzuu.com mo.m.taobao.com...
这俩的区别简单的说就是指定的对象是相反的,前者是指定父级,后者是指定子级
X-Frame-Options
🎯 这个 HTTP 请求头同样可以限制可嵌套的祖先源,同样可以避免点击劫持 ⚠️ 不过支持上边 CSP:frame-ancestors 的浏览器已经废弃了这个指令 具体的参数属性如下
- DENY:表示该页面不允许在 frame 中展示,即便是在相同域名的页面中嵌套也不允许
- SAMEORIGIN:表示该页面可以在相同域名父级页面通过 frame 进行嵌套展示;但是又个需要注意的点,这个属性一开始有个问题,它只支持去检测顶级的页面节点是否是同域,后来经过讨论后觉得这种检测比较鸡肋,恶意网站完全可以不在顶级节点;因此,到目前部分浏览器已经做出了更有效的实现,如 chrome61 版本后 SAMEORIGIN 会检测所有上层的节点是否同域
- ALLOW-FROM uri:允许指定特定域名的父级进行嵌套,不过目前基本都被浏览器废弃了
沙盒 - Sandbox
该属性对呈现在 iframe 框架中的内容启用一些额外的限制条件,此属性目前在绝大多数浏览器中都已经支持
咱们来设想一个 场景,如果你的网站嵌入了某一个第三方网站 A.com,只用做静态展示,平时一切正常,但是在某次第三方网站更新后,加入了一些 JS 脚本,该脚本有一个死循环的 bug;那么直接就导致你的网站卡死,崩溃;那么如何能够防护这个问题呢?
iframe 提供了一个 sandbox 模式,这个模式可以对 iframe 的内容进行一系列限制,如果 sandbox 的值是空字符串的话,那么会启动所有的限制;比如说上边的场景,由于只是静态展示的网站,并不需要脚本的执行,那么可以将其设置成 sandbox 模式,然后不开启 allow-scripts 即可,具体的一些常见的值和作用就在下边简单列举一下:
- allow-scripts:允许嵌入的浏览上下文运行脚本
- allow-downloads-without-user-activation:允许在没有征求用户同意的情况下下载文件
- allow-forms:允许嵌入的浏览上下文提交表单
- allow-modals:允许嵌入的浏览上下文打开 Modal
- allow-popups:允许弹窗,打开新窗口
- allow-same-origin:不设置该属性,任何与该网站的资源交互会被同源策略拦截
至此,大家应该对 iframe 的一个基本使用以及一些安全方面的机制有一定的了解了吧 接下来,继续整点有意思的,iframe 还能跨域?还能整微前端?
iframe 跨域
总所周知,跨域是指 protocol + host + port 其中任意一个不同,既表示两个资源是跨域的,那么这个小结主要总结一下 iframe 如何去处理跨域的问题,说实话以前还真没注意过这个事儿,但是在一次腾讯面试中,就被问到了 🤦♂️
window.domain + iframe
使用场景
假设一个使用场景,网站A first.doc.com/a.html 想要访问 网站B second.doc.com/b.html 文档中的 form 表单,那么可以通过在 script 设置 document.domain=doc.com 来实现跨域访问
原理
原理其实也比较简单就是通过改变 document.domain 来实现二级域名相同的网站进行跨域访问的方案,但是这个方案仅局限于两个网站的二级域名相同,就像上边的 first.doc.com <==> second.doc.com,原因是因为document.domain 仅支持更改为域名的二级域名 + 顶级域名;如果两个网站二级域名不一致想通过利用 document.domain 改二级域名进行跨域访问的话,就会报错如下
window.name + iframe
使用场景
<!-- localhost:8001/FirstPage/index.html -->
<body>
<iframe
src="http://localhost:8001/ThirdPage/index.html"
onload="load()"
></iframe>
<script>
let state = 0;
function load() {
const iframe = document.querySelector("iframe");
if (!state) {
iframe.contentWindow.name = '这里是我要传的数据';
iframe.src = "http://localhost:8002/SecondPage/index.html";
state = 1;
} else {
console.log(iframe.contentWindow.name);
}
}
</script>
</body>
<!-- localhost:8001/ThirdPage/index.html -->
<!-- 空界面 -->
原理
上述操作本质上, 就是在 iframe 中通过一个中间同域的代理界面,利用 iframe 的window.name 属性传递跨域的数据,为什么要用 window.name 这个属性来传递消息呢?因为它有一定的特性:
- 对于同一个窗口 window (包括 iframe 窗口) ,在加载过不同页面后同样会保存 (只要没修改过值,就不会改变,想要验证的话可以直接在控制台上找个网站进行验证),因此先将 iframe 的 src 设置为中间代理界面,通过 window.name 传递完数据后,再将src设置为目标界面,以达到跨域传输数据的目的;
- 并且,window.name 的长度可以达到 2M,足以来传递一些数据
location.hash + iframe
原理
本质上跟上边的 window.name 方案一样,同样是在 iframe 中通过一个中间代理的同域页面,然后利用其 location.hash 进行数据的跨域传递,差别就是 location.hash 和 window.name 的差别,不再多做赘述
iframe 实现简易的微前端方案
在讲之前,先简单说下微前端是个啥,解决的问题是啥
微前端
一句话解释微前端
一种架构风格,其中可独立交付的前端应用程序组合成一个更大的整体
微前端带来了什么好处
- 渐近增量升级的必要性:当使用一些过时技术栈的老旧项目想要使用新技术的时候,肯定不可能整体推翻重构,那样人力成本和时间成本都不可控,更合理的就是能够拆分成多个小模块,渐进的部分升级;像在字节的第一段实习经历中,一个历史项目用的是 vue2 ,当时想转向 react 技术栈,就得采用微前端的方案渐进升级
- 简单、解耦:微前端架构能够使一整个大项目,解耦成主工程 + n个子应用,能够使得开发人员对项目的理解成本大大降低,同时可维护性也会提升
- 独立部署:每个微前端子应用,都应该有自己的 CI/CD pipeline,不需要去考虑其他的子应用如何,只要管好自己这部分就可以
微前端注意事项
- 微应用的注册、异步加载和生命周期管理
- 微应用之间、主从之间的消息机制
- 微应用之间的安全隔离措施
- 微应用的发布流程
iframe 实现简单的微前端
实现效果
左边是 Vue 子应用工程,右边是 React 的子应用工程,通过上述机制进行互相间通信,同时保持两者相互独立、隔离,这样就实现了简单的微前端架构
核心代码
主工程 & 子工程
<!-- index.html 主工程入口 -->
<body>
<iframe src="./LeftModule/index.html" id="left" frameborder="0"></iframe>
<iframe src="./RightModule/index.html" id="right" frameborder="0"></iframe>
<script type="module">
const handleMessagePostToIframes = (event) => {
const {
data: { type },
source,
} = event;
document.querySelectorAll("iframe").forEach((iframe) => {
iframe.contentWindow.postMessage({ type });
});
};
window.addEventListener("message", handleMessagePostToIframes);
</script>
</body>
<!-- vue index.html vue 子工程入口 -->
<body>
<link rel="stylesheet" href="./style.css" />
<script
src="https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.38/vue.global.prod.min.js"
integrity="sha512-npQPwoPEoxzuLDSytF9RIdsHJd122lMGlUoLuQo2vCYtk6R1DEB03wIknFzHNQNHJKQlPjwcrEqflYWp417eVw=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<div id="app">
<img src="../statics/vue.png" id="image" alt="" />
<button @click="handleSendTomCoin">Send Tom a coin</button>
</div>
<script type="module">
import eventEmitter from "../EventEmitter.js";
const { createApp } = Vue;
createApp({
methods: {
handleSendTomCoin() {
eventEmitter.emit("sendCoin");
},
},
}).mount("#app");
</script>
</body>
<!-- React index.js react 子应用代码 -->
<script>
"use strict";
const e = React.createElement;
import eventEmitter from "../EventEmitter.js";
class TomPocket extends React.Component {
constructor(props) {
super(props);
this.state = { coins: 0 };
eventEmitter.subscribe("sendCoin", this.handleSolveMessage.bind(this));
}
handleSolveMessage() {
this.setState({
coins: this.state.coins + 1,
});
}
render() {
if (this.state.liked) {
return "You liked this.";
}
return e(
"div",
null,
e("img", {
src: "../statics/react.png",
}),
e(
"div",
{
style: {
color: "white",
},
},
`Tom have ${this.state.coins} coin${this.state.coins > 1 ? "s" : ""}`
)
);
}
}
const domContainer = document.querySelector("#reactApp");
const root = ReactDOM.createRoot(domContainer);
root.render(e(TomPocket));
</script>
EventEmitter
// EventEmitter.js
class EventEmitter {
static emitter = null;
constructor() {
this.subs = this.subs ?? new Map();
window.addEventListener("message", this.handleMessage.bind(this));
}
subscribe(type, cb) {
if (!this.subs.has(type)) {
this.subs.set(type, [cb]);
} else {
this.subs.get(type).push(cb);
}
}
emit(type) {
window.parent.postMessage({ type }); // 这里的 emit 跟通常的 emit 不太一样,需要通知主工程进行消息分发
}
remove(type, cb) {
if (this.subs.has(type)) {
const index = this.subs.get(type).indexOf(cb);
if (~index) {
this.subs.get(type).splice(index, 1);
}
if (!this.subs.get(type).length) {
this.subs.delete(type);
}
}
}
once(type, cb) {
// 只触发一次的发布订阅
const that = this;
function callback() {
cb(...arguments);
that.remove(type, callback);
}
this.subscribe(type, callback);
}
handleMessage(event) {
const {data: {type}} = event;
if (this.subs.has(type)) {
this.subs.get(type).forEach(sub => {
sub([...arguments].slice(1));
});
}
}
static getEmitter() {
if (!EventEmitter.emitter) {
EventEmitter.emitter = new EventEmitter();
}
return EventEmitter.emitter;
}
}
export default EventEmitter.getEmitter();
iframe 实现的微前端的局限性
- 路由状态丢失,每次刷新界面都会使得 iframe 内部的路有状态丢失
- DOM 割裂,iframe 内部的弹窗只能局限于 iframe 窗口内
- 通信相对来说存在一定局限性,因为 postMessage 只能传递可序列化的数据
- iframe 白屏时间长,因为得先等主工程加载完才开始加载
对于这些局限性,业界也做过很多尝试,比如说腾讯的无界微前端框架,就是继承iframe的优点,补足iframe的局限,配合 Web Component 实现的微前端,开源代码戳👉 wujie
参考
- WHATWG
- W3C
- iframe + window.name => cross origin
- Micro Frontend
- Adopting a Micro-frontends architecture
- cross-window-communication
- tencent wujie framework
👍 + ❤️ + 📨
如果觉得写得有什么不对的地方,欢迎讨论交流 👏 觉得写得好的可以 点赞+收藏+关注 ,San 同学 会继续定期更新优质文章,也可以关注我的公众号 前端 San 同学,希望可以一起进步,一起成长,争做鄙视链顶端的前端工程师 🐶