最近在做两个子系统的整合改造,被"前端跨子域通讯"这件事实打实恶心了两周。场景并不复杂:用户在 a.example.com 登录,跳转到 b.example.com 后要保持登录态、同步用户偏好,同时 b 站点修改的资料要反写回 a 的展示层。听起来就是"把 token 带过去"的小事,真动手才发现,子域之间的数据互通不是一条链路,而是一张网,网上每个洞都等着给你攒 bug。
这篇文章不打算从同源策略的历史讲起,直接聚焦踩坑点、方案取舍和排查路径。适合正在被跨子域通讯卡住的前端开发,也适合准备做多站点整合的架构同学参考。基础概念我会一笔带过,重点放在"文档里不会细写、但生产环境一定会遇到"的那些问题上。
1. 跨子域通讯的真实场景:什么时候你会被它卡住
1.1 三个子域,各怀鬼胎
做电商类产品的朋友应该很熟悉这套布局:主站 www.example.com 负责商品展示,手机站 m.example.com 负责移动端转化,支付站 pay.example.com 处理交易。三个站点前端完全独立部署,域名也不一样,但后台账号体系是同一套。用户在主站登录完,跳到支付站还要重新登录一次,这种体验放在现在的用户预期里基本属于劝退。
公司规模一大,这种"多子域协同"的问题会越来越多。除了电商,还有企业级应用的经典组合:admin.example.com 管后台配置,api.example.com 出接口,sso.example.com 做统一认证。表面上看是几个域名之间跳来跳去,本质上是同一群用户在多个隔离的前端应用之间交换状态。
被卡住的高频点有三个:登录态怎么带过去、用户偏好数据怎么实时同步、一个站点里的操作怎么通知另一个站点刷新。前两个问题大家通常会想到 cookie 和 localStorage,但 cookie 能解决登录态,解决不了业务数据的实时同步;localStorage 干脆连跨子域读写都做不到。这时候就需要专门的前端跨子域通讯方案。
1.2 "同主域"不等于"同源"
先说清楚一个最容易被混淆的概念。a.example.com 和 b.example.com,从域名结构上看大家都在 example.com 这个主域底下,但浏览器的"源"是由三层组成的:协议、主机名、端口。只要有一层不一样,两个页面就是跨源。
所以 a.example.com 和 b.example.com 的关系是:同主域、跨子域、跨源。浏览器默认会拦死两者之间的直接 DOM 访问、localStorage 共享和 Ajax 跨源请求。
这一点直接决定了方案设计的方向。你没法假装大家是同一个地方,必须用某种机制把源之间的墙打破。打破的手段无非两类:一类是放宽同源判定,让浏览器认为它们相同;另一类是走事件通道,不直接碰对方的 DOM,而是通过消息互相传递数据。这两类思路分别对应 document.domain 降域和 postMessage,后面我会逐一说清楚各自的边界。
1.3 我踩的第一个坑:以为 Cookie 能搞定一切
最早接手这类需求时,我的第一反应是"登录态用 cookie 就行"。在服务端响应里加一条Set-Cookie,Domain 属性写成.example.com,子域之间跳转时浏览器会自动带上 cookie,后端解析出来就能识别用户。这个做法对登录态确实有效,但只处理了"认证"这一层。
我当时在 a 站和 b 站各维护了一份用户角色和偏好数据,放在各自的 localStorage 里。用户在 a 站把主题色改了,跳到 b 站一看还是旧的,两边的展示完全对不上。cookie 只管你是谁,不管你现在是什么状态。业务数据要想跨子域同步,要么走后端接口,要么就得在前端做一套跨域通讯机制。
为什么峰要绕过后端直接在前端做通讯?因为很多操作是纯前端的,比如"b 站保存了某个配置,a 站的列表页需要立即刷新"。这种场景如果每次都走后端轮询,延迟高、压力大,还会把简单的前端状态问题复杂化。所以跨子域通讯是真实存在的工程需求,不是技术上的炫技。
2. 两个主流方案和一个中间介质:选型的底层逻辑
2.1 document.domain 降域:适用范围就限定在同一个主域
document.domain 降域的核心思路是:把页面原本的源从一个子域"放宽"到父域。a.example.com 的页面执行document.domain = 'example.com'之后,浏览器会认为它的源和 example.com 是同一个;如果 b.example.com 同样这么做,两者之间的同源判定就会被放宽。
这个方案有两个硬性前提。第一,所有涉及页面必须属于同一个可追溯的父级域,想从 a.example.com 降成 b.example.com 是不行的,那属于横向跨域,浏览器不允许;从 example.com 降成 a.example.com 这种往子域方向降的也禁止,只会越降越细。第二,两边都要执行同样的一句代码,缺任何一边,跨域拦截照样生效。
我见到很多项目在这个方案上栽跟头,原因就是以为"父页面设置了就行,iframe 里的子页面不用管"。后面第 3 章我会把这部分的坑铺开说。
2.2 postMessage:所有跨域场景的通用通道
postMessage 是沟通任何两个源的通用通道,不管它们同不同主域、协议是否一致、端口是否相同,只要两边都愿意监听消息,就能通。它的工作方式不像 document.domain 那样"让浏览器以为你们同源",而是"我们本来异构,但通过事件机制把数据包传过去"。
好处是安全边界更清晰,你想让谁收到就发给谁,接收方也能校验发送方。坏处是一切都要自己来:消息格式要自己设计,丢失要自己处理,握手要自己写。用 document.domain 你可以直接调用对方窗口里的函数、读对方窗口里的变量;用 postMessage 则只能传一个能被结构化克隆的数据对象,然后对方根据消息内容执行相应逻辑。
如果工程里涉及两个以上子域,我通常建议把 postMessage 作为主力通道。因为它不依赖页面之间的"亲缘关系",哪怕将来某个子域迁移到完全独立的域名,通讯代码也不用重写。
2.3 中间介质:代理 iframe 与公共页
还有一种思路是"找一个大家都够得着的公共页面"做中间人。比如所有子域都嵌入一个指向 common.example.com 的隐藏 iframe,这个公共页和各子域之间通过 postMessage 通讯,同时它自己维护一份统一的 localStorage 或内存状态。
这个设计的巧妙之处在于:localStorage 是按源隔离的,但公共页有自己完整的 localStorage 权限,所有子域把数据写到公共页,再由公共页广播出去,就绕开了"每个子域自己存一份、互相看不到"的格局。相当于在前端搭了一个小型的"消息总线",而公共页就是总线上的数据中心。
在我参与的项目里,这种模式被用来同步用户偏好、站点级配置、以及跨站点操作通知。它的代价是多引入一个页面和一套通讯协议,但换来的好处是存储与通讯都集中化,后续再接入一个新的子域,只需适配协议,不用重新发明轮子。
2.4 我最终怎么选型
| 方案 | 适用场景 | 主要局限 | 我的建议 |
|---|---|---|---|
| document.domain 降域 | 同主域下的几个子域,且以 DOM 互操作为主 | 不能跨主域、存在浏览器兼容风险、部分版本存储不同步 | 仅限内部简单场景,新项目不建议作为主方案 |
| postMessage | 任意源之间传递结构化消息 | 消息时序、格式、安全校验都需要自己处理 | 推荐作为主力通讯手段 |
| 代理 iframe 公共页 | 多子域共享 localStorage、统一数据广播 | 需要额外维护公共页和协议 | 适合中大型多站点整合项目 |
实际项目很少只用其中一种。我的做法是:能用 postMessage 解决的,一律用 postMessage;确实需要直接持有对端页面对象的,才考虑降域;有多个子域要共享存储的,则引入公共页作为代理。三者不是对立关系,而是互补关系。
3. document.domain 降域:三个文档不会写的硬伤
3.1 两端都要写,少一行就静默失败
先看一个最典型的"我看着没问题但就是不通"的写法。父页面在 a.example.com,iframe 加载 b.example.com:
// 父页面 a.example.com document.domain = 'example.com';子页面 inside iframe 如果没有执行对应的设置,父页面在 iframe.onload 之后直接访问iframe.contentWindow里的变量仍然会被拦截,控制台会报"Blocked a frame with origin ... from accessing a cross-origin frame"。
正确的做法是两边都设置,而且值必须一致:
// 父页面 a.example.com document.domain = 'example.com'; const iframe = document.getElementById('childFrame'); iframe.onload = function () { // 降域成功后,这里才能直接访问对方文档对象 console.log(iframe.contentDocument.title); };// 子页面 b.example.com document.domain = 'example.com';这个坑之所以常见,是因为很多时候父页面是长驻的,iframe 是动态创建的。动态创建时你容易记得给自己设置 domain,却忘了在 iframe 的 HTML 模板里维护另一句代码。还有更隐蔽的:子页面里通过window.open打开的新窗口也要执行同样设置,否则新窗口和父页面仍然跨域。
3.2 最大的错觉:降域之后 localStorage 就通了
这是我从文档和实践中得到的最被低估的坑。很多人以为 document.domain 一降,页面的源就等于 example.com,localStorage 也应该按 example.com 共享。实测下来完全不是这么回事。
document.domain 放宽的是"同源判定",而同源判定服务于 DOM 访问和跨窗口访问。localStorage 的存储分区在很多浏览器里仍然按页面实际加载的完整源来划分,也就是说你在 a.example.com 写入的 localStorage,在降域后直接读window.localStorage,读到大概率还是 a.example.com 自己的那份,而不是 example.com 的一份。不同浏览器在这个行为上还有差异,所以最安全的结论就是:不要指望降域能自动共享 localStorage。
降域真正能给你的是什么?是跨 iframe 直接访问对方窗口对象的能力。既然 DOM 访问已经放开,那你可以通过iframe.contentWindow.localStorage去读写对端自己的 localStorage,或通过window.parent.localStorage让父页面读写子页面的数据。这是一种可行的操作路径,但代码会比较绕,而且在某些浏览器里 stable 性一般。我的经验是:能不动对端 localStorage 就尽量别动,把数据交给统一的代理页更省心。
3.3 协议、端口、安全策略这三座大山
降域不是万能的,有几种情况它完全无能为力。
第一,协议不一致。父页面是https://a.example.com,iframe 加载http://b.example.com,浏览器出于安全限制直接拦截混合内容,降域代码根本来不及生效。现代浏览器里这个问题尤其突出,地址栏的小锁图标一变,iframe 内容就加载不进来,跨域通讯完全瘫痪。
第二,端口不一致。a.example.com:8080和b.example.com:9090,虽然主机名在同一个主域下,但端口不同会让同源判定出现差异。不同浏览器对"降域后端口是否还参与判定"的行为不一致,我实测下来 Chrome 较新版本的表现和 Safari 就有差别。这种场景不建议赌浏览器行为,直接用 postMessage 更稳。
第三,CSP 限制。如果页面配置了Content-Security-Policy,里面frame-src只允许加载同源页面,那你跨子域的 iframe 压根加载不出来,所有通讯方案都无从谈起。排查时需要先确认 CSP 是否允许对应子域作为 frame 源。
另外提醒一句,MDN 对 document.domain 的定位已经偏向"deprecated"状态,理由是它会把同源策略的门开得太大,权限收不回来。浏览器厂商的态度是尽量别用,但存量项目里它确实还有它的市场。如果你负责的是长期维护的旧系统,尽量逐步切换到 postMessage 方向。
3.4 若要共享 localStorage:用代理页接管
我还是建议用一个公共代理页来收拾存储这块烂摊子。做法很直白:选定一个所有子域都够得着的主域页面,比如common.example.com/agent.html,所有子域页面都用隐藏 iframe 加载它,由它来统一管理一份 localStorage。
基本链路是这样的:
// 子域页面 a.example.com const agent = document.getElementById('agentFrame'); agent.onload = function () { agent.contentWindow.postMessage({ type: 'STORAGE_SET', key: 'userTheme', value: 'dark' }, 'https://common.example.com'); };// 代理页 common.example.com/agent.html window.addEventListener('message', function (event) { if (event.origin !== 'https://a.example.com' && event.origin !== 'https://b.example.com') { return; } const msg = event.data; if (msg.type === 'STORAGE_SET') { localStorage.setItem(msg.key, msg.value); } });代理页自己监听 localStorage 的storage事件,一旦数据变化,就向注册过的子域广播通知。这样 a 站写的数据,b 站能第一时间收到"配置已更新"的消息,然后主动拉取新值。每次新接入一个子域,只需要在代理页的白名单里加一个 origin,然后把这个子域页面对应的消息监听注册好,扩展性比到处改 localStorage 要好很多。
4. postMessage 全链路:从消息抵达业务成功的中间地带
4.1 发送侧:targetOrigin 是安全边界,不是摆设
postMessage 的第二个参数 targetOrigin 经常被人顺手写成*,尤其在快速联调的时候。写法是省事了,副作用却是"不分对象广播"。
*的含义是"发给任何接收者",如果目标窗口碰巧被第三方页面代理或者被恶意站点嵌套,你的消息内容就可能落到不该落的地方。生产中正确的做法是指定精确的 origin:
iframe.contentWindow.postMessage(payload, 'https://b.example.com');这里指定的是目标窗口的 origin,不是完整 URL,也不是路径。写错成'https://b.example.com/path/page.html'这类做法浏览器会直接忽略参数,消息发不出去。还有一种常见误用是把目标窗口的window.location.origin拿过来,这个值本身没问题,但要注意它必须在 iframe 还没发生跨域跳转前才可靠。
4.2 接收侧:event.origin 校验和消息协议
接收侧最大的危险是"来者不拒"。任何一个页面都能给你的 window 发消息,如果不校验对方身份,等于把你页面内的操作接口裸奔在公网上。
我的标准写法是这样:
const ALLOWED_ORIGINS = ['https://a.example.com', 'https://b.example.com']; window.addEventListener('message', function (event) { if (!ALLOWED_ORIGINS.includes(event.origin)) { console.warn('[sync-engine] 已阻止来自非白名单纯域的消息:', event.origin); return; } const msg = event.data; if (!msg || typeof msg.type !== 'string') { return; } switch (msg.type) { case 'SYNC_USER_INFO': handleSyncUserInfo(msg.payload); break; case 'REFRESH_LIST': handleRefreshList(); break; } });消息协议建议统一为{ type, payload, from, msgId }四件套。type 固定成字符串,payload 放业务数据,from 标识来源站点,msgId 用于追踪消息生命周期。别小看 msgId,没有它,后面做超时重试和消息去重时会痛苦到怀疑人生。
4.3 时序问题:onload 之前发的消息等于没发
postMessage 是即时投递的,如果发送时对方窗口还没准备好监听,消息就直接消失在网络层。这个坑在多 iframe 场景里几乎必现。
我的处理习惯是"先握手,再发数据"。子页面在完成监听注册后主动向父页面发一条READY消息,父页面收到 READY 才认为通道可用:
// 子页面 b.example.com window.addEventListener('message', function (event) { // ...校验和分发逻辑 }); window.parent.postMessage({ type: 'READY', from: 'b.example.com' }, 'https://a.example.com');// 父页面 a.example.com window.addEventListener('message', function (event) { if (event.data && event.data.type === 'READY') { sendPendingMessage(); } });如果连 READY 都超时了,通常不是消息问题,而是 iframe 根本没加载出来或者被 CSP 拦了。排查方向要转到 Network 面板确认 iframe 的加载状态,而不是继续在代码里加日志。
4.4 数据序列化与大小限制
postMessage 使用结构化克隆算法传递数据,意味着它比 JSON.stringify 更宽容,可以传 Date、Map、Set、ArrayBuffer 这些复杂类型,但函数、DOM 节点、Symbol 这类东西传不了。还会有一个容易被忽视的问题:每次复制大对象,浏览器都要做完整的深拷贝,消息体太大时主线程会卡顿。
我踩过这样的性能坑:某个站点把整棵菜单树和各页面的权限码拼成一个超大对象,通过 postMessage 广播给所有子域,结果低端移动设备上窗口切换时明显掉帧。后来把数据拆成"变更通知 + 主动拉取"模型,postMessage 只传{ type: 'MENU_CHANGED', version: 12 },真正的内容由子域在收到通知后走接口拉取,性能问题迎刃而解。
另外,如果要传 ArrayBuffer 这类二进制大块数据,可以借助 postMessage 的第三参数 transferable 列表,把所有权转移过去,避免深拷贝。不过这也意味着发送方在消息发出后不能再碰那个 buffer,使用时要权衡好,别把自己坑了。
5. 踩坑实录:五个高频问题的定位链路
5.1 现象:document.domain 设置后还是跨域
这类问题我会按下面顺序排查,基本三分钟内能定位:
先看协议。父页面和 iframe 是否一个是 https 一个是 http,如果是,把 iframe 源改成与父页面一致。再看端口。两边端口是否完全相同,不确定就暂时统一成默认 80/443。然后看两侧代码里的document.domain是否都执行了、值是否完全一致,别多一个空格。最后再看 iframe 加载的时机,有没有可能在 iframe 还没开始执行脚本时父页面就尝试访问它。
浏览器控制台的典型报错是"Blocked a frame with origin ... from accessing a cross-origin frame"。重点看报错里的 origin 是什么、目标是什么,两者之间差了哪一层,一眼就能看出来。
5.2 现象:postMessage 发出去了,对方没反应
这种问题先确认消息真的发出去了吗,在发送侧打个日志看一下postMessage是否被调用。然后确认接收侧监听注册了吗,尤其检查监听是否在 iframe 每次加载时重新执行,还是只在页面初始化时执行过一次。
接下来确认 targetOrigin 写的是不是正确。写了'*'一定不会因为 targetOrigin 问题而丢失,但写了精确 origin 而 iframe 实际地址有偏差,消息就会丢。再检查接收侧是否第一时间校验了event.origin,如果白名单没放行,消息会被静默丢弃,控制台干净得像什么都没发生。最后检查 iframe 在内存里是否被替换过,很多框架会用同一个 id 不断创建和销毁 iframe,旧的监听器绑在旧窗口上,新窗口收不到消息。
5.3 现象:cookie 设置了 Domain 还是识别不了用户
如果后端确认Set-Cookie: uid=xxx; Domain=.example.com; Path=/已经下发,前端还是拿不到登录态,先看 SameSite 和 Secure 两个属性。跨站上下文里,cookie 想要带过去,通常需要SameSite=None; Secure配合,光有 Domain 没有 Secure,Chrome 会直接拒绝这种组合。
还有一个新时代的坑:Safari 等浏览器对第三方 cookie 的限制越来越严格,即使 Domain 和 Secure 配置正确,跨域 iframe 里的 cookie 也不一定稳定传递。这种场景的稳妥方案是改用 postMessage 手动传递 token,比如在 iframe 加载完成后由 parent 把 auth token 通过 postMessage 发给子页面,子页面自己持有并在请求时带上。
5.4 现象:消息重复执行、消息风暴
多子域同时监听同一条广播时,最常见的业务事故是"一次操作被重复执行"。原因通常是两个方面:一是 iframe 多次加载,每次 load 都注册一个新 listener,旧 listener 没有移除;二是接收侧没有按 msgId 去重,同一消息被不同路径路由了两遍。
我的解决套路是双保险:监听器注册前先removeEventListener清理旧实例;业务处理函数里用processedMsgIds集合保存最近处理过的 msgId,超过一定数量后清空,既防重复,又不让集合无限增长。广播侧则尽量少发大批量数据,能发变更通知就不发全量快照,避免多个子域同时响应造成消息风暴。
5.5 我常用的调试工具集
跨域通讯的排查难点在于不知道消息在哪一步消失。我的习惯是在所有发送和接收入口加带前缀的日志:
console.info('[sync-send]', targetOrigin, payload); console.info('[sync-recv]', event.origin, msg.type, msg.msgId);这样看控制台就能还原一条消息从发出到被接收的完整旅行。配合 Network 面板查看 iframe 加载情况和接口请求,再在 Application 面板里查 localStorage、cookie 的具体数值,绝大多数问题都能定位到具体环节。真到卡住的程度,就在接收函数里打一个断点,看事件有没有真正触发,这一步能直接区分"消息没到"和"消息到了被吞掉"。
6. 工程上的落地习惯:协议、容错与自检清单
6.1 把消息协议固定下来
多站点整合的项目,通讯一定会从两三个消息发展成几十个消息。我建议把消息类型集中到一个独立的常量文件管理,比如sync-message-types.js,名称统一用SYNC_开头,子域之间不许自行扩展格式。
每次新增一个业务消息,都必须补齐"谁发、谁收、payload 结构、失败策略"四个要素。用 TypeScript 的话可以让消息体走类型约束,JSON 结构有个明显的毛病是"写错了不报错",类型推导能提前把大部分低级错误拦截住。
6.2 容错要有兜底
前端跨子域通讯做得再完善,也不能假设它永远可靠。我在线上项目里的兜底策略有三层:
第一层是超时重试。关键消息设置 500ms 到 1s 的超时,超时后重发两次,重发时用同一个 msgId,接收侧自然去重。第二层是回退到本地上次缓存。子域站点加载时先展示 localStorage 里的旧数据,等通讯通道建好后再刷新为最新,用户感知上是"秒开",不会看到白屏或加载失败。第三层是后端兜底。重要数据对账全部走后端接口,前端通讯只负责实时通知,就算消息全丢,用户刷新页面或者隔一段时间后数据也能恢复一致。
6.3 个人项目习惯
做完整套方案后我最大的体会是:跨子域通讯的技术难点本身不算高,难在"你以为通了,其实没通"和"当时通了,换个浏览器就断了"这两类问题。所以我一律会把方案的兼容性打到最低公分母,即 postMessage 加代理页,而不是赌某个浏览器对 document.domain 的收放行为。
每接入一个新子域,我的自检清单就这么几项:iframe 能否正常加载(看 Network)、事件监听是否只注册一次(看控制台重复日志)、发送方 targetOrigin 是否精确(看源码)、接收方白名单是否收录(看配置)、消息 msgId 是否完整(看协议)。这套清单看着不起眼,但这几项恰恰是生产环境里绝大多数事故的来源。
最后说一个我坚持了很久的小习惯:把公共代理页做成"无 UI、无日志、无业务依赖"的独立页面。它的职责只有收消息、存数据、广播变更,任何业务逻辑都不许写在里面。这样后续升级调优只动一个页面,出问题影响面也可控。如果你想做多站点整合,又不想被子域之间千奇百怪的问题缠住,先从这个最小公共页开始搭,是最稳的一条路。