iframe 有哪些优缺点?父页面和 iframe 之间怎样通信?
iframe 在当前页面里嵌入一个完整的独立文档,适合嵌第三方内容、做隔离;代价是每个 iframe 都要额外的内存和加载,跨域时脚本互相访问受同源策略限制。同源时可以直接通过 contentWindow 访问,跨域时用 postMessage 并校验 origin,长期通信可以用 MessageChannel。
简答
页面里放一个地图、一段视频播放器或者一个支付表单,常用的就是
<iframe>。它在当前页面里嵌入另一个完整的文档,这个文档有自己的window、document和导航历史。好处来自“独立”:被嵌的页面跟父页面的样式和脚本互不干扰;跨域的话,双方脚本还被同源策略隔开;配合
sandbox属性可以进一步禁止脚本、表单、弹窗。所以嵌第三方内容、展示用户生成的 HTML、老系统接入新页面,都会用它。代价也来自“独立”:MDN 提醒每个 iframe 都是一个完整的文档环境,需要额外的内存和计算资源;它的内容不会自动撑开高度;屏幕阅读器用户需要
title才知道里面是什么;对方站点还可能用X-Frame-Options或 CSP 的frame-ancestors禁止被嵌入。通信分两种情况。同源时,父页面通过
iframe.contentWindow拿到子页面的window,子页面用window.parent拿到父页面,可以直接调用。跨域时只能用postMessage发消息,发送时写明目标origin,接收时检查event.origin。
iframe 适合用在哪里
最常见的是嵌入别的站点的内容,比如地图、视频、广告、第三方登录或支付页面。对方的页面在自己的文档里运行,父页面不用关心它用了什么技术。
另一类是隔离不可信的内容。MDN 的示例里就有用 srcdoc 加 sandbox 展示一段 HTML 源码的写法,用来防止展示用户内容时被注入脚本。sandbox 为空时启用全部限制,再用 allow-scripts、allow-forms 这类令牌逐项放开。可是同源内容同时放开 allow-scripts 和 allow-same-origin 时,被嵌的页面能把 sandbox 属性自己删掉,等于没加,详见《iframe sandbox 的权限组合有什么风险?》。
iframe 的缺点有哪些
首先是开销。每个 iframe 都要加载并维护一个完整的文档,数量多了会占内存、拖慢页面。对不在首屏的 iframe,可以加 loading="lazy",等它接近视口再加载。
其次是尺寸。出于安全和隐私原因,iframe 默认不会把内容的尺寸告诉父页面,所以高度不会自动跟着内容变。常见的做法是让子页面量好高度,用 postMessage 告诉父页面去改 iframe 的高度。
还有无障碍和可嵌入性。MDN 建议给 iframe 加 title,否则屏幕阅读器用户得进到里面才知道是什么内容。另外,对方站点返回了 X-Frame-Options: DENY,或者用 CSP 的 frame-ancestors 限制了嵌入方,浏览器就不会在你的页面里显示它,见《怎样用 frame-ancestors 限制页面被嵌入?》。
最后是加载状态不可靠。MDN 说明,为了防止有人用 iframe 探测内网地址,浏览器不会在 iframe 上触发 error 事件,即使内容加载失败也会触发 load。所以不能靠 onerror 判断嵌入是否成功。
同源时怎样直接访问
父页面和子页面同源时,可以像访问普通对象一样互相访问:
// 父页面
const frame = document.querySelector('iframe');
frame.contentWindow.someFunction();
frame.contentDocument.body.style.background = '#fff';
// 子页面
window.parent.document.title = '子页面改了标题';
contentDocument 和 contentWindow.document 是同一个东西。可是只要不同源,这些属性里的大部分内容脚本都碰不到,contentDocument 会是 null,这是同源策略的限制。
跨域时用 postMessage
跨域时只剩 postMessage 这条路。它把消息用结构化克隆复制一份,在对方窗口上派发一个 message 事件。
// 父页面:第二个参数写子页面的确切源
frame.contentWindow.postMessage({ type: 'init', userId: 42 }, 'https://child.example');
// 子页面:先看消息是从哪来的
window.addEventListener('message', (event) => {
if (event.origin !== 'https://parent.example') return;
if (event.data?.type !== 'init') return;
event.source.postMessage({ type: 'ready' }, event.origin);
});
为什么发送时不写 '*'?因为 iframe 里的页面可能已经被导航到了别的站点,写 '*' 消息就会发给当前在那里的任何页面。写确切的源,源对不上浏览器就不派发。
为什么接收时要查 event.origin?因为 MDN 指出,iframe 层级里的任何窗口(包括恶意站点的)都可以给其他窗口发消息。确认来源以后,还要校验消息的格式,不能把收到的内容直接拿去执行或插进页面。更多细节见《跨窗口 postMessage 怎样避免把消息发给错误对象?》。
消息多了可以用 MessageChannel
postMessage 所有消息都走同一个 message 事件,类型一多就要自己区分。MessageChannel 能建一条专用通道,它有两个端口,一端留在父页面,另一端通过 postMessage 的第三个参数转交给 iframe,之后双方只在这条通道上收发。
下面在 node 里演示两个端口之间的一问一答,浏览器里的用法相同,只是 port2 会先被转交给子页面:
const { port1, port2 } = new MessageChannel();
port2.onmessage = (event) => {
console.log('子页面收到:', event.data);
port2.postMessage({ type: 'pong', id: event.data.id });
};
port1.onmessage = (event) => {
console.log('父页面收到:', event.data);
port1.close();
port2.close();
};
port1.postMessage({ type: 'ping', id: 1 });
console.log('消息已发出');
用 Node.js 22 运行,输出依次是:
消息已发出
子页面收到: { type: 'ping', id: 1 }
父页面收到: { type: 'pong', id: 1 }
“消息已发出”最先打印,说明 postMessage 不会同步调用对方的处理函数,消息是异步送达的。转交给 iframe 时的写法是 frame.contentWindow.postMessage('init', 'https://child.example', [channel.port2]),端口转交以后父页面这边就不能再用 port2 了。
常见追问
子页面能把整个页面跳走吗?
同源的子页面可以设置
window.top.location。跨域的子页面只有在用户和它有过交互之后才能让顶层页面跳转,加了sandbox的 iframe 则需要allow-top-navigation或allow-top-navigation-by-user-activation才行。
同一域名下的多个标签页也能这样通信吗?
标签页之间拿不到彼此的
window引用(window.open打开的除外),一般用BroadcastChannel或storage事件,见《浏览器多个标签页之间怎样通信?》。