在构建企业级后台管理系统(Admin Dashboard)时,多页签导航(TabList) 几乎是一个标配需求。它允许用户保留访问过的页面记录,并在不同任务间快速切换,就像浏览器的标签页一样。
如果你正在使用 React 和新兴的 TanStack Router ,你可能会遇到一个困惑:路由器自带的历史记录(History)似乎并不适合直接拿来做 Tabs。
本文将剖析为什么直接使用 Router History 是错误的,并提供一套结合 TanStack Router + Zustand 的完美解决方案,实现一个具备去重、数量限制、持久化和自动标题提取的 Tab 系统。
1. 痛点:是"日志"还是"集合"?
很多开发者第一反应是:"我监听路由变化,把 URL push 进一个数组不就行了吗?"
这种做法通常会遇到以下逻辑陷阱:
- 重复堆叠 :用户反复点击"用户管理"菜单 10 次,你的数组里就会有 10 个重复的记录。而 TabList 要求的是去重------如果存在,只需高亮,不需新增。
- 顺序问题:浏览器历史是基于时间线的(Log),而 TabList 是基于空间的(Set)。已存在的 Tab 应该保持在原位,而不是每次点击都跳到队尾。
- 标题缺失 :
window.location只有 URL,没有"用户管理"这样的中文标题。 - 无效路由:如果用户触发了重定向(如未登录跳转),我们不希望中间过程的 URL 出现在 Tabs 上。
因此,我们需要从单纯的"记录历史"转向"管理状态"。
2. 架构设计
我们将使用以下技术栈:
- TanStack Router : 负责路由定义、元数据配置 (
staticData) 和生命周期监听。 - Zustand: 负责管理 Tabs 数组的增删改查、持久化存储。
核心逻辑流程
- 定义 :在路由文件中配置
staticData定义页面标题。 - 监听 :利用 Router 的
onResolved事件,确保只捕获加载成功的路由。 - 处理:Zustand Store 接收路由信息,执行"去重"、"FIFO 淘汰(超限清理)"逻辑。
- 渲染:UI 组件订阅 Store 进行渲染。
3. 代码实现
第一步:在路由中配置标题
TanStack Router 提供了 staticData 属性,这是存放页面静态配置(如标题、图标、权限角色)的最佳位置。
TypeScript
// src/routes/users.tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/users')({
component: UsersPage,
// ✨ 关键点:在这里定义 Tab 的标题
staticData: {
title: '用户管理',
affix: false // 可选:是否固定(如首页不可关闭)
}
})
第二步:构建 TabStore (Zustand)
我们需要一个强大的 Store 来处理业务逻辑。这里我们使用 sessionStorage 进行持久化,这样用户刷新页面 Tab 还在,但关闭浏览器后会自动清理。
TypeScript
// src/store/useTabStore.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import { type RouterState } from '@tanstack/react-router'
export interface TabItem {
title: string
fullPath: string // 完整路径 (包含 search params),作为唯一 Key
closable: boolean
}
interface TabState {
tabs: TabItem[]
activeTab: string
addTab: (routeState: RouterState) => void
removeTab: (fullPath: string, navigate: (opts: any) => void) => void
clearTabs: () => void
}
const MAX_TABS = 20 // 限制最大 Tab 数量
export const useTabStore = create<TabState>()(
persist(
(set, get) => ({
tabs: [],
activeTab: '',
// 核心动作:添加/激活 Tab
addTab: (state) => {
const { location, matches } = state
const fullPath = location.href
// 1. 提取标题:从匹配链中找到最后一个定义了 title 的路由
const matchWithTitle = [...matches].reverse().find(m => m.staticData?.title)
const title = (matchWithTitle?.staticData?.title as string) || '未命名页面'
set({ activeTab: fullPath })
const { tabs } = get()
// 2. 去重:如果 Tab 已存在,只需激活,不需添加
if (tabs.some(t => t.fullPath === fullPath)) {
return
}
// 3. 数量限制 (FIFO):超过 20 个,移除第一个非固定的 Tab
let newTabs = [...tabs]
if (newTabs.length >= MAX_TABS) {
const deleteIndex = newTabs.findIndex(t => t.closable !== false)
if (deleteIndex !== -1) newTabs.splice(deleteIndex, 1)
}
// 4. 添加
newTabs.push({
title,
fullPath,
closable: true // 可以根据 staticData 动态设置
})
set({ tabs: newTabs })
},
// 移除 Tab
removeTab: (targetPath, navigate) => {
const { tabs, activeTab } = get()
if (tabs.length <= 1) return // 至少保留一个
// 如果关闭的是当前激活的 Tab,需要计算跳转目标
if (activeTab === targetPath) {
const index = tabs.findIndex(t => t.fullPath === targetPath)
const nextTab = tabs[index + 1] || tabs[index - 1]
if (nextTab) {
navigate({ to: nextTab.fullPath })
}
}
set({ tabs: tabs.filter(t => t.fullPath !== targetPath) })
},
clearTabs: () => set({ tabs: [] })
}),
{
name: 'admin-tabs-session',
storage: createJSONStorage(() => sessionStorage), // 使用 SessionStorage
}
)
)
第三步:连接 Router 与 Store
这是最关键的一步。我们需要在 Router 确认页面加载完毕 (onResolved) 后,通知 Store。不要使用 onLoad,因为它会在重定向发生前触发,导致记录脏数据。
建议在你的路由入口文件(如 router.tsx 或 main.tsx)中添加:
TypeScript
// src/router.tsx
import { createRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
import { useTabStore } from './store/useTabStore'
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
})
// ✨ 核心:订阅路由变化
// onResolved 意味着:Loader 执行完毕,重定向已处理,确定要渲染页面了
router.subscribe('onResolved', (state) => {
useTabStore.getState().addTab(state)
})
第四步:渲染 TabList 组件
最后,在你的布局文件(__root.tsx 或 Layout.tsx)中渲染这个组件。
TypeScript
// src/components/LayoutTabs.tsx
import { useTabStore } from '../store/useTabStore'
import { useNavigate } from '@tanstack/react-router'
export const LayoutTabs = () => {
const { tabs, activeTab, removeTab } = useTabStore()
const navigate = useNavigate()
return (
<div className="flex gap-2 border-b border-gray-200 bg-gray-50 px-4 pt-2 overflow-x-auto">
{tabs.map((tab) => {
const isActive = activeTab === tab.fullPath
return (
<div
key={tab.fullPath}
onClick={() => navigate({ to: tab.fullPath })}
className={`
group relative flex items-center gap-2 px-4 py-2 text-sm cursor-pointer rounded-t-md transition-colors border-t border-x border-transparent
${isActive
? 'bg-white text-blue-600 border-gray-200 font-medium'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'
}
`}
>
<span>{tab.title}</span>
{/* 关闭按钮:仅在 Hover 或 激活时显示,或总是显示 */}
{tab.closable && (
<button
className={`ml-1 rounded-full p-0.5 hover:bg-gray-200 ${!isActive ? 'opacity-0 group-hover:opacity-100' : ''}`}
onClick={(e) => {
e.stopPropagation() // 阻止冒泡,避免触发 Tab 跳转
removeTab(tab.fullPath, navigate)
}}
>
✕
</button>
)}
</div>
)
})}
</div>
)
}
4. 总结与优化建议
通过这套方案,我们成功解决了一开始提出的所有痛点:
- 去重 :
useTabStore中的逻辑保证了同一个 URL 不会被重复添加。 - 准确性 :
onResolved保证了只有有效页面才会被记录。 - 持久化:刷新页面 Tabs 不丢失。
- 解耦:Router 负责导航,Zustand 负责状态,UI 负责渲染,各司其职。
进阶小贴士:
- 右键菜单 :可以给 Tab 添加右键菜单,实现"关闭其他"、"关闭右侧"等功能(只需在 Store 中添加对应
filter逻辑即可)。 - 参数敏感性 :目前的逻辑是
fullPath(包含 Query 参数)敏感的。如果不希望?page=1和?page=2分成两个 Tab,可以在addTab中改用location.pathname作为 Key。
希望这篇实战指南能帮助你在 TanStack Router 中构建出丝滑的后台交互体验!