返回博客

多标签 SPA 里 usePathname() 永远返回 Shell URL

做桌面级 Web 后台时,我们常见一种"浏览器风格"多标签架构:顶层是一个 Workspace Shell,里面渲染 Tab 栏和若干 <iframe>,每个 Tab 对应一个 iframe,加载同一套 SPA(带 ?embed=1 标识)。这个架构在产品体验上很像浏览器,但给"路由感知型"全局组件埋了一个不动声色的坑。

现象

我在根布局 app/layout.tsx 里放了一个全局 Toast 组件,目的是:当用户已经在某个专用详情页时,不再重复弹出同样内容的 Toast(那个页面本身已经展示了完整信息)。逻辑很直觉:

const pathname = usePathname()
if (pathname === '/infra/updates') return null

TypeScript 通过,本地开发启动,打开专用页——Toast 依然弹出。加一行 console.log(pathname),输出是 /

Tab 栏高亮着 /infra/updates,面包屑也显示着对应页面,但 pathname 坚持是 /

根因

usePathname()(以及 window.location.pathname)读取的是当前文档的 URL。根布局组件运行在主框架(Main Frame),主框架的 URL 是 Workspace Shell 本身,通常是 //workspace,它不随用户切换 Tab 而改变。

用户感知的"当前页面"实际上在 iframe 内部。从主框架视角看,那是一个独立文档——你拿不到 iframe 的 URL,除非:

  • 持有 iframe 的 ref(根布局组件根本没有)
  • 同源跨帧读 iframeRef.current?.contentWindow?.location(需要 ref + 无跨域 + 还得自己搭事件触发重渲染,三座大山)
坑:usePathname() 在 iframe Shell 外永远是外层 URL

只要组件渲染在多标签 Shell 的主框架里(app/layout.tsx / _app.tsx),usePathname()useRouter().pathname(Pages Router)、useLocation().pathname(React Router / Remix)、$page.url.pathname(SvelteKit)——所有框架的路由 hook 都有同样的问题,根因不在框架,在于组件的渲染位置(主框架 vs iframe)。

正解

多标签 Shell 早就有一个 Tab Store(Zustand / Redux / Context)管理每个 Tab 的 href,用于控制 <iframe src> 的切换。这个 store 就住在主框架,直接从这里读 activeTabHref 即可。

// BEFORE — 在多标签架构里永远拿到外层 URL
import { usePathname } from 'next/navigation'
 
export function GlobalToast() {
  const pathname = usePathname()
  if (pathname === '/infra/updates') return null // 永远不匹配
  return <Toast />
}
 
// AFTER — 从 Tab Store 读活跃 Tab 的真实路径
import { useMultiTabStore } from '@/lib/multi-tab-store'
 
export function GlobalToast() {
  const activeTabHref = useMultiTabStore(s => {
    const t = s.tabs.find(x => x.id === s.activeId)
    return t?.href || ''
  })
  // 裁掉 ?embed=1 等 query 参数再比较
  const path = activeTabHref.split('?')[0].split('#')[0]
  if (path.startsWith('/infra/updates')) return null
  return <Toast />
}

两个细节不能省

  1. 裁掉 query 和 hash 再比较,因为 store 里存的是完整 iframe href(含 ?embed=1 等业务参数)。
  2. startsWith 而非 ===,子路由(如 /infra/updates/detail/123)也能命中静默条件。
正解:从 Tab Store 读 activeTabHref,不碰 router API

Tab Store 本就要持有各 iframe 的 href 来驱动 <iframe src> 切换——这个数据天然就在主框架里,并且通过 Zustand subscription 自动触发重渲染,无需额外 polling 或 postMessage。根布局全局组件只需 selector 取 activeTabHref,其他不用改。

排查备忘

遇到同类症状时可以快速定位:

# 找到 iframe src 是怎么赋值的
grep -rn "<iframe\|iframe.*src" src/
 
# 找到 Tab Store 和 activeTab selector
grep -rn "activeTabHref\|activeTabId\|activeId" src/

Store 找到之后,grep 所有消费 usePathname() 的根布局组件,逐一换掉即可。

一句话外卖

在多标签 iframe Shell 里,主框架组件的"当前路径"是 Shell 自己的 URL,不是用户看到的那个页面——只有 Tab Store 才是感知活跃页面的唯一可信来源。