
🌈个人主页:前端青山
 🔥系列专栏:React篇
 🔖人终将被年少不可得之物困其一生
目录
6.9 永久存储的 类 localStorage 的工具 store2
1.创建项目
# 现在
npx create-react-app react-admin-app --template typescript熟悉目录结构
- react-admin-app
    -node_modules
    -public
    -src
        App.css
        App.test.tsx App.tsx的测试文件  npm run test 查看测试结果
        App.tsx
        index.css
        index.tsx react应用程序的入口文件
        logo.svg 
        react-app-env.d.ts // 声明文件 // 指令声明对包的依赖关系
        reportWebVitals.ts // 测试性能
        seupTests.ts // 使用jest做为测试工具
    .gitignore
    package-lock.json
    package.json
    README.md
    tsconfig.json2.改造目录结构
src
   api
    components
    layout
    store
    router
    utils
    views
    App.tsx
    index.tsx
    logo.svg
    react-app-env.d.ts
    reportWebVitals.ts 
  seupTests.ts // src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLDivElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
reportWebVitals();// src/App.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const App: FC<IAppProps> = (props) => {
  return (
    <>App</>
  )
}
export default App3.安装一些必须的模块
3.1 配置预处理器
两种方式:
-  抽离配置文件配置预处理器 
-  不抽离配置文件craco进行预处理器配置 
$ cnpm i @craco/craco @types/node -Dhttps://www.npmjs.com/package/@craco/craco
3.1.1 配置别名@
项目根目录创建 craco.config.js,代码如下:
// craco.config.js
const path = require('path')
module.exports = {
  webpack: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  }
}为了使 TS 文件引入时的别名路径能够正常解析,需要配置 tsconifg.json,在 compilerOptions选项里添加 path 等属性。为了防止配置被覆盖,需要单独创建一个文件 tsconfig.path.json,添加以下代码
// tsconfig.path.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "types": [
      "node"
    ]
  }
}在 tsconifg.json 引入配置文件:
// tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "extends": "./tsconfig.path.json",
  "include": [
    "src"
  ]
}修改 package.json 如下:
"scripts": {
  "start": "craco start",
  "build": "craco build",
  "test": "craco test"
},$ npm run start3.2安装状态管理器
根据项目需求 任选其一即可
$ cnpm i redux -S
$ cnpm i redux react-redux -S
$ cnpm i redux react-redux redux-thunk -S
$ cnpm i redux react-redux redux-saga -S
$ cnpm i redux react-redux redux-thunk immutable redux-immutable -S
$ cnpm i redux react-redux redux-saga immutable redux-immutable -S
$ cnpm i mobx mobx-react -S3.3 路由
2021年11月4日 发布了 react-router-dom的v6.0.0版本:Home v6.26.1 | React Router
如需使用v5版本:https://v5.reactrouter.com/web/guides/quick-start cnpm i react-router-dom@5 -S
cnpm i react-router-dom -S3.4 数据验证
cnpm i prop-types -S3.5数据请求
cnpm i axios -S3.6ui库
官网地址:Ant Design - 一套企业级 UI 设计语言和 React 组件库 5.2.0
国内官方镜像地址:Ant Design - 一套企业级 UI 设计语言和 React 组件库
国内gitee镜像地址:https://ant-design.gitee.io/index-cn
cnpm i antd @ant-design/icons -Ssrc/index.tsx
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import reportWebVitals from './reportWebVitals';
import 'antd/dist/reset.css'; // antd重置样式表
const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLDivElement
);
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
reportWebVitals();测试组件库
// src/App.tsx
import React, { FC } from 'react';
import { Button } from 'antd';
interface IAppProps {
}
const App: FC<IAppProps> = (props) => {
  return (
    <>
      App
      <Button type="primary">
        Primary
      </Button>
    </>
  )
}
export default App3.6.1 自定义主题
404 Not Found - Ant Design
antd 内建了深色主题和紧凑主题,你可以参照 使用暗色主题和紧凑主题 进行接入。
可以定制的变量列表如下:
@primary-color: #1890ff; // 全局主色
@link-color: #1890ff; // 链接色
@success-color: #52c41a; // 成功色
@warning-color: #faad14; // 警告色
@error-color: #f5222d; // 错误色
@font-size-base: 14px; // 主字号
@heading-color: rgba(0, 0, 0, 0.85); // 标题色
@text-color: rgba(0, 0, 0, 0.65); // 主文本色
@text-color-secondary: rgba(0, 0, 0, 0.45); // 次文本色
@disabled-color: rgba(0, 0, 0, 0.25); // 失效色
@border-radius-base: 2px; // 组件/浮层圆角
@border-color-base: #d9d9d9; // 边框色
@box-shadow-base: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
  0 9px 28px 8px rgba(0, 0, 0, 0.05); // 浮层阴影// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ConfigProvider } from 'antd';
import App from './App';
import reportWebVitals from './reportWebVitals';
import 'antd/dist/reset.css'; // antd重置样式表
const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLDivElement
);
root.render(
  <React.StrictMode>
    <ConfigProvider
      theme = { {
        token: {
          colorPrimary: '#1890ff'
        }
      } }
    >
      <App />
    </ConfigProvider>
  </React.StrictMode>
);
reportWebVitals();
3.7 其他第三方工具包
Lodash 简介 | Lodash中文文档 | Lodash中文网
Lodash 工具包,项目必装,它提供了很多使用的函数
$ cnpm i lodash -S
$ cnpm i @types/lodash -Dimport _ from 'lodash'
var users = [
  { 'user': 'barney',  'active': false },
  { 'user': 'fred',    'active': false },
  { 'user': 'pebbles', 'active': true }
];
console.log(_.findIndex(users, (item) => item.user === 'pebbles'))
console.log(users.findIndex((item) => item.user === 'pebbles'))4.创建主布局文件
预览模板:开箱即用的中台前端/设计解决方案 - Ant Design Pro
https://ant-design.gitee.io/components/layout-cn/#components-layout-demo-custom-trigger
// src/layout/Index.tsx
import React, { useState } from 'react';
import {
  MenuFoldOutlined,
  MenuUnfoldOutlined,
  UploadOutlined,
  UserOutlined,
  VideoCameraOutlined,
} from '@ant-design/icons';
import { Layout, Menu, theme } from 'antd';
const { Header, Sider, Content } = Layout;
const App: React.FC = () => {
  const [collapsed, setCollapsed] = useState(false);
  const {
    token: { colorBgContainer },
  } = theme.useToken();
  return (
    <Layout id="components-layout-demo-custom-trigger">
      <Sider trigger={null} collapsible collapsed={collapsed}>
        <div className="logo" />
        <Menu
          theme="dark"
          mode="inline"
          defaultSelectedKeys={['1']}
          items={[
            {
              key: '1',
              icon: <UserOutlined />,
              label: 'nav 1',
            },
            {
              key: '2',
              icon: <VideoCameraOutlined />,
              label: 'nav 2',
            },
            {
              key: '3',
              icon: <UploadOutlined />,
              label: 'nav 3',
            },
          ]}
        />
      </Sider>
      <Layout className="site-layout">
        <Header style={
  { padding: 0, background: colorBgContainer }}>
          {React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
            className: 'trigger',
            onClick: () => setCollapsed(!collapsed),
          })}
        </Header>
        <Content
          style={
  {
            margin: '24px 16px',
            padding: 24,
            minHeight: 280,
            background: colorBgContainer,
          }}
        >
          Content
        </Content>
      </Layout>
    </Layout>
  );
};
export default App;// src/App.tsx
import React, { FC } from 'react';
import Index from '@/layout/Index'
import './App.css'
interface IAppProps {
}
const App: FC<IAppProps> = (props) => {
  return (
    <>
      <Index />
    </>
  )
}
export default App/* src/App.css */
#root, #components-layout-demo-custom-trigger { height: 100%;}
#components-layout-demo-custom-trigger .trigger {
  padding: 0 24px;
  font-size: 18px;
  line-height: 64px;
  cursor: pointer;
  transition: color 0.3s;
}
#components-layout-demo-custom-trigger .trigger:hover {
  color: #1890ff;
}
#components-layout-demo-custom-trigger .logo {
  height: 32px;
  margin: 16px;
  background: rgba(255, 255, 255, 0.3);
}5.拆分主界面
// src/layout/components/SideBar.tsx
import React, { useState } from 'react';
import {
  UploadOutlined,
  UserOutlined,
  VideoCameraOutlined,
} from '@ant-design/icons';
import { Layout, Menu } from 'antd';
const { Sider } = Layout;
const App: React.FC = () => {
  const [collapsed] = useState(false);
  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" />
      <Menu
        theme="dark"
        mode="inline"
        defaultSelectedKeys={['1']}
        items={[
          {
            key: '1',
            icon: <UserOutlined />,
            label: 'nav 1',
          },
          {
            key: '2',
            icon: <VideoCameraOutlined />,
            label: 'nav 2',
          },
          {
            key: '3',
            icon: <UploadOutlined />,
            label: 'nav 3',
          },
        ]}
      />
    </Sider>
  );
};
export default App;
// src/layout/components/AppHeader.tsx
import React, { useState } from 'react';
import {
  MenuFoldOutlined,
  MenuUnfoldOutlined
} from '@ant-design/icons';
import { Layout, theme } from 'antd';
const { Header } = Layout;
const App: React.FC = () => {
  const [collapsed, setCollapsed] = useState(false);
  const {
    token: { colorBgContainer },
  } = theme.useToken();
  return (
    <Header style={
  { padding: 0, background: colorBgContainer }}>
      {React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
        className: 'trigger',
        onClick: () => setCollapsed(!collapsed),
      })}
    </Header>
  );
};
export default App;
// src/layout/components/AppMain.tsx
import React from 'react';
import { Layout, theme } from 'antd';
const { Content } = Layout;
const App: React.FC = () => {
  const {
    token: { colorBgContainer },
  } = theme.useToken();
  return (
    <Content
      style={
  {
        margin: '24px 16px',
        padding: 24,
        minHeight: 280,
        background: colorBgContainer,
      }}
    >
      Content
    </Content>
  );
};
export default App;整和组件资源
// src/layout/components/index.ts
export { default as SideBar } from './SideBar'
export { default as AppHeader } from './AppHeader'
export { default as AppMain } from './AppMain'// src/layout/Index.tsx
import React from 'react';
import { Layout } from 'antd';
// import SideBar from './components/SideBar'
// import AppHeader from './components/AppHeader'
// import AppMain from './components/AppMain'
import { SideBar, AppHeader, AppMain } from './components'
const App: React.FC = () => {
  return (
    <Layout id="components-layout-demo-custom-trigger">
      <SideBar />
      <Layout className="site-layout">
        <AppHeader />
        <AppMain />
      </Layout>
    </Layout>
  );
};
export default App;6.使用rtk来管理状态
Redux 中文官网 - JavaScript 应用的状态容器,提供可预测的状态管理。 | Redux 中文官网
参考链接:TypeScript 快速开始 | Redux 中文官网
6.1 定义State和Dispatch类型
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({
  reducer: {}
})
// 导出类型注解 // 从 store 本身推断出 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>// 推断出类型
export type AppDispatch = typeof store.dispatch
export default store6.2 定义 Hooks 类型
虽然可以将RootStateandAppDispatch类型导入到每个组件中,但最好创建useDispatchand useSelectorhooks 的类型化版本以在您的应用程序中使用。
// src/store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './index' // 在整个应用程序中使用,而不是简单的 `useDispatch` 和 `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector6.3 应用程序中使用
// src/store/modules/app.ts
import { createSlice } from '@reduxjs/toolkit'
interface IAppState {
  collapsed: boolean
}
const initialState: IAppState = {
  collapsed: false
}
export const appSlice = createSlice({
  name: 'app',
  initialState,
  reducers: {
    changeCollapsed (state) {
      state.collapsed = !state.collapsed
    }
  }
})
export const { changeCollapsed } = appSlice.actions
export default appSlice.reducer6.4 整合reducer
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import app from './modules/app'
const store = configureStore({
  reducer: {
    app
  }
})
// 导出类型注解
// 从 store 本身推断出 `RootState` 和 `AppDispatch` 类型
export type RootState = ReturnType<typeof store.getState>
// 推断出类型
export type AppDispatch = typeof store.dispatch
export default store6.5 入口文件配置状态管理器
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ConfigProvider } from 'antd';
import { Provider } from 'react-redux'
import App from './App';
import reportWebVitals from './reportWebVitals';
import store from './store'
import 'antd/dist/reset.css'; // antd重置样式表
const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLDivElement
);
root.render(
  <React.StrictMode>
    <ConfigProvider
      theme = { {
        token: {
          colorPrimary: '#1890ff'
        }
      } }
    >
      <Provider store = { store }>
        <App />
      </Provider>
    </ConfigProvider>
  </React.StrictMode>
);
reportWebVitals();
6.6 左侧菜单栏使用状态管理器
// src/layout/components/SideBar.tsx
import React from 'react';
import {
  UploadOutlined,
  UserOutlined,
  VideoCameraOutlined,
} from '@ant-design/icons';
import { Layout, Menu } from 'antd';
import { useAppSelector } from '@/store/hooks'
// import { useSelector } from 'react-redux'
// import type { RootState } from '@/store'
const { Sider } = Layout;
const App: React.FC = () => {
  
  const collapsed = useAppSelector(state => state.app.collapsed)
  // const collapsed = useSelector((state: RootState) => state.app.collapsed)
  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" />
      <Menu
        theme="dark"
        mode="inline"
        defaultSelectedKeys={['1']}
        items={[
          {
            key: '1',
            icon: <UserOutlined />,
            label: 'nav 1',
          },
          {
            key: '2',
            icon: <VideoCameraOutlined />,
            label: 'nav 2',
          },
          {
            key: '3',
            icon: <UploadOutlined />,
            label: 'nav 3',
          },
        ]}
      />
    </Sider>
  );
};
export default App;6.7 头部组件使用状态管理器
// src/layout/components/AppHeader.tsx
import React from 'react';
import {
  MenuFoldOutlined,
  MenuUnfoldOutlined
} from '@ant-design/icons';
import { Layout, theme } from 'antd';
import { useAppSelector, useAppDispatch } from '@/store/hooks'
import { changeCollapsed } from '@/store/modules/app'
const { Header } = Layout;
const App: React.FC = () => {
  // const [collapsed, setCollapsed] = useState(false);
  const collapsed = useAppSelector(state => state.app.collapsed)
  const dispatch = useAppDispatch()
  const {
    token: { colorBgContainer },
  } = theme.useToken();
  return (
    <Header style={
  { padding: 0, background: colorBgContainer }}>
      {React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
        className: 'trigger',
        // onClick: () => setCollapsed(!collapsed),
        onClick: () => dispatch(changeCollapsed())
      })}
    </Header>
  );
};
export default App;6.8保留用户习惯-可选
永久存储 用户习惯
数据持久化: redux-persist
此时发现 头部的 按钮可以控制左侧菜单栏了,但是还没有满足需求
需求如下:保留用户的使用习惯
// src/store/modules/app.ts
import { createSlice } from '@reduxjs/toolkit'
interface IAppState {
  collapsed: boolean
}
const initialState: IAppState = {
  // collapsed: false
  collapsed: localStorage.getItem('collapsed') === 'true'
}
export const appSlice = createSlice({
  name: 'app',
  initialState,
  reducers: {
    changeCollapsed (state) {
      state.collapsed = !state.collapsed
      localStorage.setItem('collapsed', String(state.collapsed))
    }
  }
})
export const { changeCollapsed } = appSlice.actions
export default appSlice.reducer6.9 永久存储的 类 localStorage 的工具 store2
$ cnpm i store2 -Shttps://www.npmjs.com/package/store2
推荐一个好用的永久存储的 类 localStorage 的工具 store2
// src/store/modules/app.ts
import { createSlice } from '@reduxjs/toolkit'
import store2 from 'store2'
interface IAppState {
  collapsed: boolean
}
const initialState: IAppState = {
  // collapsed: false
  // collapsed: localStorage.getItem('collapsed') === 'true'
  collapsed: store2.get('collapsed') === 'true'
}
export const appSlice = createSlice({
  name: 'app',
  initialState,
  reducers: {
    changeCollapsed (state) {
      state.collapsed = !state.collapsed
      // localStorage.setItem('collapsed', String(state.collapsed))
      store2.set('collapsed', String(state.collapsed))
    }
  }
})
export const { changeCollapsed } = appSlice.actions
export default appSlice.reducer7.左侧菜单栏
7.1.设计左侧菜单栏的数据
https://ant-design.gitee.io/components/menu-cn/#components-menu-demo-sider-current
// src/router/menus.tsx
import type { MenuProps } from 'antd';
import { HomeOutlined } from '@ant-design/icons'
type MenuItem = Required<MenuProps>['items'][number];
// 扩展固有的类型
type IMyMenuItem = MenuItem & {
  path: string; // 如果后期使用key值为跳转地址时,可以不添加 path 属性
  children?: IMyMenuItem[];
  redirect?: string // 多级菜单的默认地址
}
const menus: IMyMenuItem[] = [
  {
    path: '/',
    label: '系统首页',
    key: '/',
    icon: <HomeOutlined />
  },
  {
    path: '/banner',
    label: '轮播图管理',
    key: '/banner',
    redirect: '/banner/list',
    icon: <HomeOutlined />,
    children: [
      {
        path: '/banner/list',
        key: '/banner/list',
        label: '轮播图列表',
        icon: <HomeOutlined />,
      },
      {
        path: '/banner/add',
        key: '/banner/add',
        label: '添加轮播图',
        icon: <HomeOutlined />,
      }
    ]
  },
  {
    path: '/pro',
    label: '产品管理',
    key: '/pro',
    redirect: '/pro/list',
    icon: <HomeOutlined />,
    children: [
      {
        path: '/pro/list',
        key: '/pro/list',
        label: '产品列表',
        icon: <HomeOutlined />,
      },
      {
        path: '/pro/search',
        key: '/pro/search',
        label: '筛选列表',
        icon: <HomeOutlined />,
      }
    ]
  },
  {
    path: '/account',
    label: '账户管理',
    key: '/account',
    redirect: '/account/user',
    icon: <HomeOutlined />,
    children: [
      {
        path: '/account/user',
        key: '/account/user',
        label: '用户列表',
        icon: <HomeOutlined />,
      },
      {
        path: '/account/admin',
        key: '/account/admin',
        label: '管理员列表',
        icon: <HomeOutlined />,
      }
    ]
  }
]
export default menus7.2.渲染左侧菜单栏
左侧菜单栏的头部设定logo以及后台管理系统名称
// src/layout/components/SideBar.tsx
import React from 'react';
import { Layout, Menu, Image } from 'antd';
import menus from '@/router/menu'
import { useAppSelector } from '@/store/hooks'
import logo from '@/logo.svg'
const { Sider } = Layout;
const App: React.FC = () => {
  
  const collapsed = useAppSelector(state => state.app.collapsed)
  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" style={ { 
        display: 'flex', 
        justifyContent: 'center', 
        alignItems: 'center',
        color: '#fff'
      }}>
        <Image src = { logo } width="28px" height="28px" preview={ false }></Image>
        { !collapsed && <div style={
  {
          height: '32px', 
          overflow: 'hidden', 
          lineHeight: '32px'
        }}>嗨购后台管理系统</div> }
      </div>
      <Menu
        theme="dark"
        mode="inline"
        defaultSelectedKeys={['1']}
        items={ menus }
      />
    </Sider>
  );
};
export default App;7.3 低版本处理
以上菜单项的设置在antd 4.20.0版本以上好使,如果在4.20.0版本以下,应该使用 递归组件实现
// src/layout/components/SideBar.tsx
import React from 'react';
import { Layout, Menu, Image } from 'antd';
import menus from '@/router/menu'
import { useAppSelector } from '@/store/hooks'
import logo from '@/logo.svg'
const { Sider } = Layout;
const App: React.FC = () => {
  
  const collapsed = useAppSelector(state => state.app.collapsed)
  // 自定义左侧菜单栏 - 递归
  const renderMenus = (menus: any[]) => {
    return menus.map(item => {
      if (item.children) {
        return (
          <Menu.SubMenu title = { item.label } key = { item.key }>
            { renderMenus(item.children) }
          </Menu.SubMenu>
        )
      } else {
        return <Menu.Item key = { item.key }>{ item.label }</Menu.Item>
      }
    })
  }
  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" style={ { 
        display: 'flex', 
        justifyContent: 'center', 
        alignItems: 'center',
        color: '#fff'
      }}>
        <Image src = { logo } width="28px" height="28px" preview={ false }></Image>
        { !collapsed && <div style={
  {
          height: '32px', 
          overflow: 'hidden', 
          lineHeight: '32px'
        }}>嗨购后台管理系统</div> }
      </div>
      <Menu
        theme="dark"
        mode="inline"
        defaultSelectedKeys={['1']}
      >
        {
          renderMenus(menus)
        }
      </Menu>
      
    </Sider>
  );
};
export default App;7.4 菜单渲染优化
如果左侧菜单栏数据过于庞大,每个管理项里又有很多项,需要只展开一个菜单项
// src/layout/components/SideBar.tsx
import React, { useState } from 'react';
import { Layout, Menu, Image } from 'antd';
import type { MenuProps } from 'antd';
import menus from '@/router/menu'
import { useAppSelector } from '@/store/hooks'
import logo from '@/logo.svg'
const { Sider } = Layout;
// 获取哪些项具有二级菜单
const rootSubmenuKeys: string[] = []
menus.forEach(item => {
  if (item.children) {
    rootSubmenuKeys.push(item.key as string)
  }
})
const App: React.FC = () => {
  
  const collapsed = useAppSelector(state => state.app.collapsed)
  const [openKeys, setOpenKeys] = useState(['sub1']);
  const onOpenChange: MenuProps['onOpenChange'] = (keys) => {
    const latestOpenKey = keys.find((key) => openKeys.indexOf(key) === -1);
    if (rootSubmenuKeys.indexOf(latestOpenKey!) === -1) {
      setOpenKeys(keys);
    } else {
      setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
    }
  };
  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" style={ { 
        display: 'flex', 
        justifyContent: 'center', 
        alignItems: 'center',
        color: '#fff'
      }}>
        <Image src = { logo } width="28px" height="28px" preview={ false }></Image>
        { !collapsed && <div style={
  {
          height: '32px', 
          overflow: 'hidden', 
          lineHeight: '32px'
        }}>嗨购后台管理系统</div> }
      </div>
      <Menu
        theme="dark"
        mode="inline"
        defaultSelectedKeys={['1']}
        items={ menus }
        openKeys={openKeys}
        onOpenChange={onOpenChange}
      />
      
    </Sider>
  );
};
export default App;8.定义路由
8.1 官方文档
Home v6.26.1 | React Router
8.2 创建对应的页面
|-src | |- ... | |-views | |- banner | |- List.tsx #首页轮播图 | | |- Add.tsx #添加轮播图 | |- home | | |- Index.tsx #系统首页 | |- pro | | |- List.tsx #产品管理 | | |- Search.tsx #筛选列表 | |- account | | |- User.tsx #用户列表 | | |- Admin.tsx#管理员列表
// src/views/home/Index.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>系统首页</div>
  )
}
export default Com// src/views/account/Admin.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>管理员列表</div>
  )
}
export default Com// src/views/account/User.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>用户列表</div>
  )
}
export default Com// src/views/banner/Add.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>添加轮播图</div>
  )
}
export default Com// src/views/banner/List.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>轮播图列表</div>
  )
}
export default Com// src/views/pro/List.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>产品列表</div>
  )
}
export default Com// src/views/pro/Search.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>筛选列表</div>
  )
}
export defa8.3 定义菜单路由信息
v6的路由通过 element 属性定义匹配的组件
因此menus中可以添加一个 element 属性,值就为组件的引用即可
// src/router/menus.tsx
import type { MenuProps } from 'antd';
import { HomeOutlined } from '@ant-design/icons'
import { ReactNode } from 'react';
import Home from '@/views/home/Index'
import BannerList from '@/views/banner/List'
import BannerAdd from '@/views/banner/Add'
import ProList from '@/views/pro/List'
import SearchList from '@/views/pro/Search'
import UserList from '@/views/account/User'
import AdminList from '@/views/account/Admin'
type MenuItem = Required<MenuProps>['items'][number];
// 扩展固有的类型
export type IMyMenuItem = MenuItem & {
  path: string; // 如果后期使用key值为跳转地址时,可以不添加 path 属性
  children?: IMyMenuItem[];
  redirect?: string; // 多级菜单的默认地址
  element?: ReactNode
}
const menus: IMyMenuItem[] = [
  {
    path: '/',
    label: '系统首页',
    key: '/',
    icon: <HomeOutlined />,
    element: <Home />
  },
  {
    path: '/banner',
    label: '轮播图管理',
    key: '/banner',
    redirect: '/banner/list',
    icon: <HomeOutlined />,
    children: [
      {
        path: '/banner/list',
        key: '/banner/list',
        label: '轮播图列表',
        icon: <HomeOutlined />,
        element: <BannerList />
      },
      {
        path: '/banner/add',
        key: '/banner/add',
        label: '添加轮播图',
        icon: <HomeOutlined />,
        element: <BannerAdd />
      }
    ]
  },
  {
    path: '/pro',
    label: '产品管理',
    key: '/pro',
    redirect: '/pro/list',
    icon: <HomeOutlined />,
    children: [
      {
        path: '/pro/list',
        key: '/pro/list',
        label: '产品列表',
        icon: <HomeOutlined />,
        element: <ProList />
      },
      {
        path: '/pro/search',
        key: '/pro/search',
        label: '筛选列表',
        icon: <HomeOutlined />,
        element: <SearchList />
      }
    ]
  },
  {
    path: '/account',
    label: '账户管理',
    key: '/account',
    redirect: '/account/user',
    icon: <HomeOutlined />,
    children: [
      {
        path: '/account/user',
        key: '/account/user',
        label: '用户列表',
        icon: <HomeOutlined />,
        element: <UserList />
      },
      {
        path: '/account/admin',
        key: '/account/admin',
        label: '管理员列表',
        icon: <HomeOutlined />,
        element: <AdminList />
      }
    ]
  }
]
export default menus8.4.装载路由
在根组件添加 BrowserRouter 或者 HashRouter
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ConfigProvider } from 'antd';
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'
import App from './App';
import reportWebVitals from './reportWebVitals';
import 'antd/dist/reset.css'; // antd重置样式表
const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLDivElement
);
root.render(
  <React.StrictMode>
    <ConfigProvider
      theme = { {
        token: {
          colorPrimary: '#1890ff'
        }
      } }
    >
      <Provider store = { store }>
        <BrowserRouter>
          <App />
        </BrowserRouter>
      </Provider>
    </ConfigProvider>
  </React.StrictMode>
);
reportWebVitals();8.5 定义路由组件
在menu.tsx里已经定义好了请求的路径(其实就是数据中key属性)和路径对应组件(其实就是数据中的element属性),剩下就是定义路由组件了
组件渲染的区域 AppMain组件
// src/layout/components/AppMain.tsx
import React from 'react';
import { Layout, theme } from 'antd';
import { Routes, Route, Navigate } from 'react-router-dom';
// import BannerAdd from '@/views/banner/Add'
import { IMyMenuItem } from '@/router/menu';
import menus from '@/router/menu'
const { Content } = Layout;
const App: React.FC = () => {
  const {
    token: { colorBgContainer },
  } = theme.useToken();
  const renderRoute: any = (menus: IMyMenuItem[]) => {
    return menus.map(item => {
      if (item.children) {
        // React.Fragment 也为空标签,可以设置 key 属性
        // 实现 重定向 
        return (
          <React.Fragment key = { item.path }>
            <Route path = { item.path } element = { <Navigate to = { item.redirect! } />} />
            {
              renderRoute(item.children!)
            }
          </React.Fragment>
        )
      } else {
        return <Route key = { item.path } path = { item.path } element = { item.element } />
      }
    })
  }
  return (
    <Content
      style={
  {
        margin: '24px 16px',
        padding: 24,
        minHeight: 280,
        background: colorBgContainer,
      }}
    >
      <Routes>
        {/* <Route path="/banner" element = { <Navigate to="/banner/add" /> } /> */}
        {/* <Route path="/banner/add" element = { <BannerAdd /> } /> */}
        { renderRoute(menus) }
      </Routes>
    </Content>
  );
};
export default App;8.6 手动测试路由
可以在地址栏输入路径,测试是否正常
http://localhost:3000/ 					#系统首页
http://localhost:3000/banner			#轮播图管理
http://localhost:3000/banner/list		#轮播图列表
http://localhost:3000/banner/add		#添加轮播图
http://localhost:3000/pro				#产品管理
http://localhost:3000/pro/search		#筛选列表
http://localhost:3000/pro/list			#产品列表
http://localhost:3000/account			#账户管理
http://localhost:3000/account/user	#用户列表
http://localhost:3000/account/admin	#管理员列表8.7 设置404页面
// src/views/error/Page404.tsx
import React, { FC } from 'react';
interface IAppProps {
}
const Com: FC<IAppProps> = (props) => {
  return (
    <div>404</div>
  )
}
export default Com// src/layout/components/AppMain.tsx
import React from 'react';
import { Layout, theme } from 'antd';
import { Routes, Route, Navigate } from 'react-router-dom';
// import BannerAdd from '@/views/banner/Add'
import Page404 from '@/views/error/Page404'
import { IMyMenuItem } from '@/router/menu';
import menus from '@/router/menu'
const { Content } = Layout;
const App: React.FC = () => {
  const {
    token: { colorBgContainer },
  } = theme.useToken();
  const renderRoute: any = (menus: IMyMenuItem[]) => {
    return menus.map(item => {
      if (item.children) {
        // React.Fragment 也为空标签,可以设置 key 属性
        // 实现 重定向 
        return (
          <React.Fragment key = { item.path }>
            <Route path = { item.path } element = { <Navigate to = { item.redirect! } />} />
            {
              renderRoute(item.children!)
            }
          </React.Fragment>
        )
      } else {
        return <Route key = { item.path } path = { item.path } element = { item.element } />
      }
    })
  }
  return (
    <Content
      style={
  {
        margin: '24px 16px',
        padding: 24,
        minHeight: 280,
        background: colorBgContainer,
      }}
    >
      <Routes>
        {/* <Route path="/banner" element = { <Navigate to="/banner/add" /> } /> */}
        {/* <Route path="/banner/add" element = { <BannerAdd /> } /> */}
        { renderRoute(menus) }
        <Route path="*" element = { <Page404 /> } />
      </Routes>
    </Content>
  );
};
export default App;9 切换路由
上述项目中,切换路由都是手动输入的,实际上应该点击左侧菜单栏进行路由导航。
左侧菜单的逻辑交互,前面已经生成了(openKeys 以及 onOpenChanges 实现)
现在通过点击事件来切换导航
9.1 点击切换路由
// src/layout/components/SideBar.tsx
import React, { useState } from 'react';
import { Layout, Menu, Image } from 'antd';
import type { MenuProps } from 'antd';
import menus from '@/router/menu'
import { useAppSelector } from '@/store/hooks'
import logo from '@/logo.svg'
import { useNavigate } from 'react-router-dom';
const { Sider } = Layout;
// 获取哪些项具有二级菜单
const rootSubmenuKeys: string[] = []
menus.forEach(item => {
  if (item.children) {
    rootSubmenuKeys.push(item.key as string)
  }
})
const App: React.FC = () => {
  
  const collapsed = useAppSelector(state => state.app.collapsed)
  const [openKeys, setOpenKeys] = useState(['']);
  const onOpenChange: MenuProps['onOpenChange'] = (keys) => { 
    // console.log('keys', keys)
    const latestOpenKey = keys.find((key) => openKeys.indexOf(key) === -1);
    // console.log('latestOpenKey', latestOpenKey) // /banner /pro /account
    if (rootSubmenuKeys.indexOf(latestOpenKey!) === -1) {
      setOpenKeys(keys);
    } else {
      setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
    }
  };
  const navigate = useNavigate()
  const changeUrl = ({ key }: { key: string }) => {
    console.log(key)
    navigate(key)
  }
  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" style={ { 
        display: 'flex', 
        justifyContent: 'center', 
        alignItems: 'center',
        color: '#fff'
      }}>
        <Image src = { logo } width="28px" height="28px" preview={ false }></Image>
        { !collapsed && <div style={
  {
          height: '32px', 
          overflow: 'hidden', 
          lineHeight: '32px'
        }}>嗨购后台管理系统</div> }
      </div>
      <Menu
        theme="dark"
        mode="inline"
        defaultSelectedKeys={['1']}
        items={ menus }
        openKeys={openKeys}
        onOpenChange={onOpenChange}
        onClick={changeUrl}
      />
      
    </Sider>
  );
};
export default App;9.2 刷新保持左侧菜单状态
当页面刷新时,需要保证当前二级路由是展开的,且当前路由是被选中的状态
// src/layout/components/SideBar.tsx
import React, { useState } from 'react';
import { Layout, Menu, Image } from 'antd';
import type { MenuProps } from 'antd';
import menus from '@/router/menu'
import { useAppSelector } from '@/store/hooks'
import logo from '@/logo.svg'
import { useLocation, useNavigate } from 'react-router-dom';
const { Sider } = Layout;
// 获取哪些项具有二级菜单
const rootSubmenuKeys: string[] = []
menus.forEach(item => {
  if (item.children) {
    rootSubmenuKeys.push(item.key as string)
  }
})
const App: React.FC = () => {
  
  const collapsed = useAppSelector(state => state.app.collapsed)
  // /pro/search
  const { pathname } = useLocation() // /pro/search
  // console.log(location)
  const [selectedKeys, setSelectedKeys] = useState([ pathname ]) // ['/pro/search']
  const [openKeys, setOpenKeys] = useState(['/' + pathname.split('/')[1] ]); // ['/pro']
  const onOpenChange: MenuProps['onOpenChange'] = (keys) => { 
    // console.log('keys', keys)
    const latestOpenKey = keys.find((key) => openKeys.indexOf(key) === -1);
    // console.log('latestOpenKey', latestOpenKey) // /banner /pro /account
    if (rootSubmenuKeys.indexOf(latestOpenKey!) === -1) {
      setOpenKeys(keys);
    } else {
      setOpenKeys(latestOpenKey ? [latestOpenKey] : []);
    }
  };
  const navigate = useNavigate()
  const changeUrl = ({ key }: { key: string }) => {
    // console.log(key)
    navigate(key)
    setSelectedKeys([key]) // 点击时需要告诉程序哪一项被选中
  }
  return (
    <Sider trigger={null} collapsible collapsed={collapsed}>
      <div className="logo" style={ { 
        display: 'flex', 
        justifyContent: 'center', 
        alignItems: 'center',
        color: '#fff'
      }}>
        <Image src = { logo } width="28px" height="28px" preview={ false }></Image>
        { !collapsed && <div style={
  {
          height: '32px', 
          overflow: 'hidden', 
          lineHeight: '32px'
        }}>嗨购后台管理系统</div> }
      </div>
      <Menu
        theme="dark"
        mode="inline"
        selectedKeys={ selectedKeys }
        items={ menus }
        openKeys={openKeys}
        onOpenChange={onOpenChange}
        onClick={changeUrl}
      />
      
    </Sider>
  );
};
export default App;10 设置面包屑导航
10.1 参考文档
通过案例项目,得知 面包屑组件应该包含在 页面的头部 https://vvbin.cn/next/#/feat/breadcrumb/flat
参照组件库的面包屑 https://ant-design.g










