Skip to content

侧边栏渲染 ​

(一) 修改路由文件 ​

  • 这里最重要的就是handle,他会作为判断的依据,hidden为true时,不会在侧边栏中显示
ts
import HomePage from '@/views/home';
import LoginPage from '@/views/login';
import ErrorPage from '@/views/error';
import { Navigate } from 'react-router-dom';
import AuthGuard from './auth';
import LayoutPage from '@/layouts';
// 引入user模块
import UserPage from '@/views/user';
// 引入blog模块
import ArticlePage from '@/views/blog/aritcle';
import TagesPage from '@/views/blog/tags';

const commonRoutes = [
  {
    // 需要认证的路由:由 AuthGuard 统一拦截
    element: <AuthGuard />,
    children: [
      {
        path: '/',
        element: <LayoutPage />,
        children: [
          {
            path: 'admin',
            element: <HomePage />,
            handle: { title: '首页' },
          },
          {
            path: 'user',
            element: <UserPage />,
            handle: { title: '用户管理' },
          },
          {
            path: 'blog',
            handle: { title: '博客管理' },
            children: [
              {
                path: 'article',
                element: <ArticlePage />,
                handle: { title: '文章管理', hidden: true },
              },
              {
                path: 'tags',
                element: <TagesPage />,
                handle: { title: '标签管理' },
              },
            ],
          },
        ],
      },
    ],
  },
  {
    // 公开路由:无需 token
    path: '/login',
    element: <LoginPage />,
  },
  {
    path: '*',
    element: <Navigate to="/404" />,
  },
  {
    path: '/404',
    element: <ErrorPage />,
  },
];

export default commonRoutes;

(二) 修改侧边栏组件 ​

  • components/SideBar/index.tsx
tsx
import React, { useMemo } from "react";
import { PieChartOutlined } from "@ant-design/icons";
import type { MenuProps } from "antd";
import { Menu } from "antd";
import { useLocation, useNavigate } from "react-router-dom";
import { useSnapshot } from "valtio";

import { collapseStore } from "@/store/collapse";
import commonRoutes from "@/routes/modules/common";

type MenuItem = Required<MenuProps>["items"][number];

interface RouteHandle {
  title?: string;
  hidden?: boolean;
}

interface RouteConfig {
  path?: string;
  handle?: RouteHandle;
  children?: RouteConfig[];
}

/** 从路由配置中提取菜单路由(LayoutPage 下的子路由) */
function extractMenuRoutes(routes: RouteConfig[]): RouteConfig[] {
  // 直接遍历找到包含 handle.title 的路由层
  for (const route of routes) {
    // 如果当前层有带 title 的路由,说明是菜单层,直接返回
    if (route.handle?.title) {
      return routes;
    }
    // 否则递归查找子路由
    if (route.children) {
      const result = extractMenuRoutes(route.children);
      if (result.length > 0) return result;
    }
  }
  return [];
}

/** 递归生成菜单项 */
function buildMenuItems(routes: RouteConfig[], parentPath = ""): MenuItem[] {
  return routes
    .filter(
      (route) => route.path && route.handle?.title && !route.handle?.hidden,
    )
    .map((route) => {
      // 拼接完整路径
      const fullPath = route.path!.startsWith("/")
        ? route.path!
        : `${parentPath}/${route.path}`.replace(/\/+/g, "/");

      const icon = <PieChartOutlined />;
      const label = route.handle!.title!;

      // 过滤子路由中 hidden 的项
      const visibleChildren = route.children?.filter(
        (child) => !child.handle?.hidden,
      );

      if (visibleChildren && visibleChildren.length > 0) {
        return {
          key: fullPath,
          icon,
          label,
          children: buildMenuItems(visibleChildren, fullPath),
        };
      }

      return { key: fullPath, icon, label };
    });
}

const SideBar: React.FC = () => {
  const { collapsed } = useSnapshot(collapseStore);
  const location = useLocation();
  const navigate = useNavigate();

  // 从路由配置中提取菜单项
  const items = useMemo(() => {
    const menuRoutes = extractMenuRoutes(commonRoutes as RouteConfig[]);
    return buildMenuItems(menuRoutes);
  }, []);

  // 当前选中的菜单项
  const selectedKeys = useMemo(() => {
    return [location.pathname];
  }, [location.pathname]);

  // 菜单点击跳转
  const handleClick: MenuProps["onClick"] = ({ key }) => {
    navigate(key);
  };

  return (
    <Menu
      selectedKeys={selectedKeys}
      mode="inline"
      theme="dark"
      inlineCollapsed={collapsed}
      items={items}
      onClick={handleClick}
    />
  );
};

export default SideBar;

详细的解构这个文件 ​

流程 ​

注意

  1. 先利用 import commonRoutes from '@/routes/modules/common'; 引入路由配置
  2. 从路由配置中提取菜单路由(LayoutPage 下的子路由)
  3. 递归生成菜单项
  4. 处理选中菜单项
  5. 处理点击跳转

第一步 提取数据 ​

这样能获取到路由文件里面所有的数据

tsx
import commonRoutes from "@/routes/modules/common";

第二步 提取菜单路由 extractMenuRoutes 这是一个递归函数 ​

tsx
/** 从路由配置中提取菜单路由(LayoutPage 下的子路由) */
function extractMenuRoutes(routes: RouteConfig[]): RouteConfig[] {
  // 直接遍历找到包含 handle.title 的路由层
  for (const route of routes) {
    // 如果当前层有带 title 的路由,说明是菜单层,直接返回
    if (route.handle?.title) {
      return routes;
    }
    // 否则递归查找子路由
    if (route.children) {
      const result = extractMenuRoutes(route.children);
      if (result.length > 0) return result;
    }
  }
  return [];
}

注意

  1. 第1次调用:extractMenuRoutes(commonRoutes)

routes = 最外层数组,包含 4 个路由:

遍历顺序 route 检查结果 操作 1 { element: <AuthGuard />, children: [...] } 无path ,无handle 有children → 递归调用

  1. 第2次调用:extractMenuRoutes(AuthGuard.children)

routes = AuthGuard 的 children,包含 1 个路由:

遍历顺序 route 检查结果 操作 1 { path: '/', element: <LayoutPage />, children: [...] } 有path ,但 无handle 有children → 递归调用

  1. 第3次调用:extractMenuRoutes(LayoutPage.children)

routes = LayoutPage 的 children,包含 4 个路由:

遍历顺序 route 检查结果 操作 1 { path: '/', handle: { title: '首页' } } 有handle.title ! 返回整个routes 数组

  1. 函数结束后返回

[ { path: '/', handle: { title: '首页' } }, { path: 'admin', handle: { title: '首页' } }, { path: 'user', handle: { title: '用户管理' } }, { path: 'blog', handle: { title: '博客管理' }, children: [...] }, ]

  • 关键点总结
tsx
第1层 (commonRoutes)     → 无 handle,继续向下找
    ↓
第2层 (AuthGuard.children) → 无 handle,继续向下找
    ↓
第3层 (LayoutPage.children) → 第1个路由就有 handle.title,找到!返回整层
  • 核心逻辑

一旦在某一层发现 任意一个 带handle.title 的路由,就认为这一层是菜单层, 立即返回该层的所有路由 (包括没有 title 的,但后续buildMenuItems 会过滤掉)。

第三步 递归生成菜单项 buildMenuItems ​

  • 函数源代码
tsx
function buildMenuItems(routes: RouteConfig[], parentPath = ""): MenuItem[] {
  return routes
    .filter(
      (route) => route.path && route.handle?.title && !route.handle?.hidden,
    )
    .map((route) => {
      // 拼接完整路径
      const fullPath = route.path!.startsWith("/")
        ? route.path!
        : `${parentPath}/${route.path}`.replace(/\/+/g, "/");

      const icon = <PieChartOutlined />;
      const label = route.handle!.title!;

      // 过滤子路由中 hidden 的项
      const visibleChildren = route.children?.filter(
        (child) => !child.handle?.hidden,
      );

      if (visibleChildren && visibleChildren.length > 0) {
        return {
          key: fullPath,
          icon,
          label,
          children: buildMenuItems(visibleChildren, fullPath),
        };
      }

      return { key: fullPath, icon, label };
    });
}
  • 第一步过滤路由
tsx
.filter((route) => route.path && route.handle?.title && !route.handle?.hidden)

需要同时满足三个条件才保留:

  • route.path — 必须有路径
  • route.handle?.title — 必须有菜单标题
  • !route.handle?.hidden — 不能是隐藏菜单

示例 :article 路由有hidden: true ,在这一步被过滤掉。

  • 第二步 映射转换

对过滤后的路由进行映射转换,将每个路由转换为一个菜单项。

  • 第三步 拼接完整的路径
tsx
const fullPath = route.path!.startsWith("/")
  ? route.path! // 绝对路径:直接使用
  : `${parentPath}/${route.path}`.replace(/\/+/g, "/"); // 相对路径:拼接父路径
场景route.pathparentPathfullPath
一级菜单admin'admin''''/admin'
一级菜单user'user''''/user'
二级菜单article'article''/blog''/blog/article'
二级菜单tags'tags''/blog''/blog/tags'
  • 第四步 图标
tsx
const icon = <PieChartOutlined />; // 统一图标
const label = route.handle!.title!; // 从 handle 读取菜单名
  • 第五步 处理子菜单(递归)
tsx
const visibleChildren = route.children?.filter(
  (child) => !child.handle?.hidden,
);

if (visibleChildren && visibleChildren.length > 0) {
  return {
    key: fullPath,
    icon,
    label,
    children: buildMenuItems(visibleChildren, fullPath), // 递归
  };
}

注意

  • 先过滤子路由中hidden: true 的项
  • 如果有可见子路由 → 生成 带子菜单的父菜单 ,递归调用buildMenuItems
  • 递归时parentPath 传当前fullPath ,用于子路由拼接路径
  • 无子菜单
tsx
return { key: fullPath, icon, label };