ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前端跨子域通讯实战:从postMessage到代理页的选型与踩坑

前端跨子域通讯实战:从postMessage到代理页的选型与踩坑 最近在做两个子系统的整合改造被前端跨子域通讯这件事实打实恶心了两周。场景并不复杂用户在 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-CookieDomain 属性写成.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.comiframe 加载 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.comlocalStorage 也应该按 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.comiframe 加载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 经常被人顺手写成*尤其在快速联调的时候。写法是省事了副作用却是不分对象广播。*的含义是发给任何接收者如果目标窗口碰巧被第三方页面代理或者被恶意站点嵌套你的消息内容就可能落到不该落的地方。生产中正确的做法是指定精确的 originiframe.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: uidxxx; Domain.example.com; Path/已经下发前端还是拿不到登录态先看 SameSite 和 Secure 两个属性。跨站上下文里cookie 想要带过去通常需要SameSiteNone; Secure配合光有 Domain 没有 SecureChrome 会直接拒绝这种组合。还有一个新时代的坑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、无日志、无业务依赖的独立页面。它的职责只有收消息、存数据、广播变更任何业务逻辑都不许写在里面。这样后续升级调优只动一个页面出问题影响面也可控。如果你想做多站点整合又不想被子域之间千奇百怪的问题缠住先从这个最小公共页开始搭是最稳的一条路。
返回列表