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 不见了,于是渲染了 OuterShell;OuterShell 渲染 MultiTabHost;MultiTabHost 又挂载了一个新 <iframe>——递归开始。
<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,用户点了一个链接,历史栈里只留一条记录。
完整的 ShellRouter 只需三十行左右,将三步合并:useState(false) 初始化 → useEffect 检测 iframe → 第二个 useEffect 自愈 URL → 渲染时双信号取或。SSR 无 mismatch,客户端导航后不再递归,顶层上下文始终渲染外壳,刷新也能正确恢复。
实测验证
- 顶层页面打开:出现顶栏 + 标签条(
OuterShell),URL 无embed。 - 在任意标签内点击导航链接,DevTools Elements 里
<iframe>数量保持不变,无嵌套层级增加。 - iframe 导航后,
iframe.contentWindow.location.search包含?embed=1。 - 单独在新标签页打开 iframe 的 URL(带
?embed=1):因为inIframe为 false,渲染的是外壳,行为符合预期。
一句话外卖
用 query 参数做 iframe 信号很脆弱,框架
<Link>的任何一次导航都会把它丢掉;window.parent !== window才是运行时可靠的 iframe 检测手段,应作为 query flag 的兜底信号。