TanStack Router 实战:如何优雅地实现后台管理系统的“多页签” (TabList) 功能

在构建企业级后台管理系统(Admin Dashboard)时,多页签导航(TabList) 几乎是一个标配需求。它允许用户保留访问过的页面记录,并在不同任务间快速切换,就像浏览器的标签页一样。

如果你正在使用 React 和新兴的 TanStack Router ,你可能会遇到一个困惑:路由器自带的历史记录(History)似乎并不适合直接拿来做 Tabs。

本文将剖析为什么直接使用 Router History 是错误的,并提供一套结合 TanStack Router + Zustand 的完美解决方案,实现一个具备去重、数量限制、持久化和自动标题提取的 Tab 系统。

1. 痛点:是"日志"还是"集合"?

很多开发者第一反应是:"我监听路由变化,把 URL push 进一个数组不就行了吗?"

这种做法通常会遇到以下逻辑陷阱:

  1. 重复堆叠 :用户反复点击"用户管理"菜单 10 次,你的数组里就会有 10 个重复的记录。而 TabList 要求的是去重------如果存在,只需高亮,不需新增。
  2. 顺序问题:浏览器历史是基于时间线的(Log),而 TabList 是基于空间的(Set)。已存在的 Tab 应该保持在原位,而不是每次点击都跳到队尾。
  3. 标题缺失window.location 只有 URL,没有"用户管理"这样的中文标题。
  4. 无效路由:如果用户触发了重定向(如未登录跳转),我们不希望中间过程的 URL 出现在 Tabs 上。

因此,我们需要从单纯的"记录历史"转向"管理状态"。

2. 架构设计

我们将使用以下技术栈:

  • TanStack Router : 负责路由定义、元数据配置 (staticData) 和生命周期监听。
  • Zustand: 负责管理 Tabs 数组的增删改查、持久化存储。

核心逻辑流程

  1. 定义 :在路由文件中配置 staticData 定义页面标题。
  2. 监听 :利用 Router 的 onResolved 事件,确保只捕获加载成功的路由。
  3. 处理:Zustand Store 接收路由信息,执行"去重"、"FIFO 淘汰(超限清理)"逻辑。
  4. 渲染: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.tsxmain.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.tsxLayout.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. 总结与优化建议

通过这套方案,我们成功解决了一开始提出的所有痛点:

  1. 去重useTabStore 中的逻辑保证了同一个 URL 不会被重复添加。
  2. 准确性onResolved 保证了只有有效页面才会被记录。
  3. 持久化:刷新页面 Tabs 不丢失。
  4. 解耦:Router 负责导航,Zustand 负责状态,UI 负责渲染,各司其职。

进阶小贴士:

  • 右键菜单 :可以给 Tab 添加右键菜单,实现"关闭其他"、"关闭右侧"等功能(只需在 Store 中添加对应 filter 逻辑即可)。
  • 参数敏感性 :目前的逻辑是 fullPath(包含 Query 参数)敏感的。如果不希望 ?page=1?page=2 分成两个 Tab,可以在 addTab 中改用 location.pathname 作为 Key。

希望这篇实战指南能帮助你在 TanStack Router 中构建出丝滑的后台交互体验!

相关推荐
Trae1ounG1 天前
Vue Iframe
前端·javascript·vue.js
阿部多瑞 ABU1 天前
`tredomb`:一个面向「思想临界质量」初始化的 Python 工具
前端·python·ai写作
比特森林探险记1 天前
React API集成与路由
前端·react.js·前端框架
爱上妖精的尾巴1 天前
8-1 WPS JS宏 String.raw等关于字符串的3种引用方式
前端·javascript·vue.js·wps·js宏·jsa
hvang19881 天前
某花顺隐藏了重仓涨幅,通过chrome插件计算基金的重仓涨幅
前端·javascript·chrome
Async Cipher1 天前
TypeScript 的用法
前端·typescript
web打印社区1 天前
vue页面打印:printjs实现与进阶方案推荐
前端·javascript·vue.js·electron·html
We་ct1 天前
LeetCode 30. 串联所有单词的子串:从暴力到高效,滑动窗口优化详解
前端·算法·leetcode·typescript
木卫二号Coding1 天前
Docker-构建自己的Web-Linux系统-Ubuntu:22.04
linux·前端·docker
CHU7290351 天前
一番赏盲盒抽卡机小程序:解锁惊喜体验与社交乐趣的多元功能设计
前端·小程序·php