返回博客

SPA 的 <Link> 会悄悄丢掉 ?embed=1,然后 iframe 套娃递归了

我们在一个内部工具里实现了「浏览器式多标签」功能:每个标签是一个 <iframe>,加载的是同一个 SPA,通过 URL 参数 ?embed=1 来切换「外壳(含顶栏 + 标签条)」和「内嵌壳(只渲染页面内容)」。初始加载完全正常——外壳给 iframe.src 拼上 ?embed=1,内页渲染内嵌壳,一切看起来都对。

直到用户在某个标签里点了一次面包屑导航……

现象

点击 iframe 内部的任意 <Link> 后,标签条突然出现在标签内部。再点一次,又多嵌套一层。DevTools Elements 面板显示 <iframe> 两层甚至三层嵌套;React DevTools 里 <MultiTabHost> 被挂载了多次;CPU 和内存随着每次导航持续攀升;浏览器后退键也无法逃离内层壳。

团队第一个"快速修法":把 isEmbedded 判断注掉,无条件渲染内嵌壳。递归确实消失了,但顶层页面的菜单也随之消失——所有用户打开系统都看不到导航栏了,问题反而更大。

根因

Shell 决策代码大致如下:

const isEmbedded = useSearchParams().get('embed') === '1'
return isEmbedded ? <EmbeddedShell>{children}</EmbeddedShell> : <OuterShell />

Next.js(以及 React Router、Remix 等)的 <Link href="/foo"> 在做客户端路由跳转时,不会自动保留当前 URL 的 query string。iframe 里的 URL 从 /foo?embed=1 变成了 /foo;SPA 重新走一遍 Shell 决策,embed 不见了,于是渲染了 OuterShellOuterShell 渲染 MultiTabHostMultiTabHost 又挂载了一个新 <iframe>——递归开始

坑:框架 <Link> 不保留 query,embed 标志被悄悄吞掉

<Link href="/foo"> 只携带 pathname,不携带当前页面的 query string。如果你用 query 参数做「我在 iframe 里」的信号,那么任何一次客户端导航都会让这个信号消失,进而触发外壳重渲染 → 新 iframe 挂载 → 递归套娃。

正解

三步组合缺一不可,任何单项都不够。

第一步:用 window.parent !== window 检测 iframe 上下文

不能用 useState(() => window.parent !== window) 惰性初始化——这会在客户端首次渲染时产生与 SSR 不同的值,触发 hydration mismatch。正确姿势是先初始化为 false(与 SSR 一致),在 useEffect 里再赋值:

const [inIframe, setInIframe] = useState(false)
useEffect(() => {
  if (typeof window !== 'undefined') setInIframe(window.parent !== window)
}, [])

第二步:Shell 决策改为「双信号取或」

只要 URL 参数 OR inIframe 任一为真,就渲染内嵌壳。inIframe 是递归防护闸——即使 URL 丢了 embed,只要我们确实在 iframe 里,就不会再走外壳逻辑:

if (isEmbeddedParam || inIframe) return <EmbeddedShell>{children}</EmbeddedShell>
return <OuterShell />

第三步:用 router.replace 自愈 URL

检测到「在 iframe 但 URL 丢了 embed」时,静默补回去,保证刷新和外部同步逻辑拿到的是规范 URL:

useEffect(() => {
  if (!inIframe || isEmbeddedParam) return
  const params = new URLSearchParams(search?.toString() || '')
  params.set('embed', '1')
  router.replace(pathname + '?' + params.toString(), { scroll: false })
}, [inIframe, isEmbeddedParam, pathname, search, router])

replace 而非 push,用户点了一个链接,历史栈里只留一条记录。

正解:window.parent 检测 + 双信号取或 + router.replace 自愈

完整的 ShellRouter 只需三十行左右,将三步合并:useState(false) 初始化 → useEffect 检测 iframe → 第二个 useEffect 自愈 URL → 渲染时双信号取或。SSR 无 mismatch,客户端导航后不再递归,顶层上下文始终渲染外壳,刷新也能正确恢复。

实测验证

  1. 顶层页面打开:出现顶栏 + 标签条(OuterShell),URL 无 embed
  2. 在任意标签内点击导航链接,DevTools Elements 里 <iframe> 数量保持不变,无嵌套层级增加。
  3. iframe 导航后,iframe.contentWindow.location.search 包含 ?embed=1
  4. 单独在新标签页打开 iframe 的 URL(带 ?embed=1):因为 inIframe 为 false,渲染的是外壳,行为符合预期。

一句话外卖

用 query 参数做 iframe 信号很脆弱,框架 <Link> 的任何一次导航都会把它丢掉;window.parent !== window 才是运行时可靠的 iframe 检测手段,应作为 query flag 的兜底信号。