SPA 迁 Next.js 路由时的子标签碰撞:两个入口撞向同一个 URL
把一个老 SPA 迁移到 Next.js App Router,最容易让人掉坑的地方不是 RSC、不是 hydration,而是路由映射:当你觉得「这个 Tab 名字和那条路由一样,直接连过去」,问题就来了。
本文还原一个真实的碰撞场景:迁完后点顶部导航,显示出来的却是完全不同的子级内容——两张面孔指向同一个 URL,互相覆盖。
现象
某内部管理后台,顶部导航有一个「合同模板」Tab,进入后是一个模板库列表视图。
另一个「录入」Tab 下,有一条子导航,第二项叫「合同模板录入」,进入后是文件上传表单。
迁到 Next.js 后,点顶部「合同模板」,进去却看到文件上传表单——根本不是模板库。而且页面 URL 确实变成了 /contract-templates,topbar 高亮的也是「合同模板」,看起来路由没问题,但内容完全对不上。
根因
SPA 原来用两个状态维度区分这两张面孔:
activeTab='entry' + entryMode='contract-template' → 合同模板录入(上传表单)
activeTab='templates' + (无 innerMode) → 合同模板库(列表视图)
渲染逻辑 dispatch 在 (activeTab, entryMode) 的组合上——同一个「合同模板」标签,在两套坐标里是完全不同的东西。
迁移时,开发者按标签名做了「自然映射」:
// 错误写法 — EntryModeTabs.tsx
const MODES = [
{ label: '社保文件录入', href: '/entry' },
{ label: '合同模板录入', href: '/contract-templates' }, // ❌ 指向了顶级路由
]而顶部导航里,「合同模板」Tab 也指向 /contract-templates。结果两个入口共用同一条路径,渲染时「录入」Tab 的子模式(上传表单)注入了 /contract-templates/page.tsx,模板库的内容反而消失了。
SPA 的导航是按状态对 (parentTab, innerMode) 区分视图的,不是按文案区分。迁移时如果只看标签名做映射,子 Tab 和顶级 Tab 很容易撞进同一个 URL——其中一张面孔会被永久覆盖,症状却像路由正常(URL 对、高亮对),只有内容对不上。
排查手法
不要靠视觉推断,从 legacy 源码里重建「状态二维表」:
# 找顶级 Tab 的所有 key
grep -n 'data-tab=' legacy/web/index.html
# 找子 Tab / innerMode 的所有 dispatch 逻辑
grep -n 'state.activeTab ===\|state.entryMode ===' legacy/web/app.js把结果整理成一张表——行是 activeTab,列是 entryMode。每个有值的格子就是一张独立视图,需要一条独立的 URL。如果两个入口都映射到同一个 URL,那就是碰撞。
「返回 / Back」按钮是另一个强信号:如果上传表单里的「返回录入」按钮跳的是 /entry 而不是 /contract-templates,说明这张面孔原本的父亲是 entry,不是 templates。
正解
子 Tab 的 href 不能是顶级兄弟路由,改用 query param 或嵌套路由把 innerMode 编码进 URL:
// 正确写法 — EntryModeTabs.tsx
const MODES = [
{ label: '社保文件录入', href: '/entry' },
{ label: '合同模板录入', href: '/entry?mode=template' }, // ✓ 挂在父路由下
]父路由 page.tsx 读 searchParams.mode 分发视图:
// src/app/(workspace)/entry/page.tsx
export default async function EntryPage({
searchParams,
}: { searchParams?: Promise<{ mode?: string }> }) {
const { mode } = (await searchParams) ?? {}
return (
<div className="entry-view">
<EntrySidebar />
{mode === 'template' ? <EntryTemplatePanel /> : <EntryStartPanel />}
</div>
)
}原来被「污染」的 /contract-templates/page.tsx 恢复成真正的模板库落地页——可以重定向到最近一条记录,或展示空状态引导用户去 /entry?mode=template 上传:
// src/app/(workspace)/contract-templates/page.tsx
export default async function ContractTemplatesIndex() {
const items = await fetchTemplates()
if (items.length) {
redirect(`/contract-templates/${items[0].id}`)
}
return <EmptyState ctaHref="/entry?mode=template" ctaLabel="去上传模板" />
}侧边栏里「+ 上传模板」的 href 也从 /contract-templates 改为 /entry?mode=template。
把 innerMode 编码为 ?mode=xxx(或嵌套子路由 /parent/inner),父路由 page.tsx 读参数分发视图。顶级路由还给它本来的主体内容。四个改动点:子 Tab 导航组件、父路由 page.tsx、顶级路由 page.tsx、侧边栏「新建」按钮。
一句话外卖
SPA 路由迁移的正确姿势是还原 legacy 的状态维度矩阵,不是按标签文案做一对一映射——同一个词在不同父级坐标下是两张完全不同的面孔。