从零实现一个React+Antd5.0后台管理系统-多页签及面包屑实现

前言

页面框架和内容区域都构建完毕,接下来准备实现一些细节的东西。

  • 多页签 ,在本系统中,当点击导航菜单时会切换路由,一般是在内容区域的Outlet路由视图中直接显示对应路由的页面组件,利用React Router配合Ant Designtab组件实现多页签功能 ,打开一个新的菜单路由时变成弹出一个新的tab页签,并且随时可以切换到之前的页面,保留住之前的组件状态。
  • 面包屑 ,当系统拥有超过两级以上的层级结构时或需要告知用户『你在哪里』时,并且需要向上导航的功能时就需要面包屑。利用React Router配合Ant DesignBreadcrumb组件实现面包屑功能。当打开一个菜单路由,会在页面上显示当前路由的层级结构,并且能返回上层。

多页签实现

为了实现多页签,我们需要一个数组记录路由的路径、标题信息等。但数组信息不止限定在多页签组件中使用,其它组件也可能用到,所以我们设置一个全局状态来存储。

全局缓存多页签数组

在store文件夹下的reducers文件夹新建tabSlice切片,里面存储状态tabs数组,并且在reducers配置项中添加新增和删除的方法。

src/store/reducers/tabSlice.js

javascript 复制代码
import { createSlice } from '@reduxjs/toolkit'
​
const tabSlice = createSlice({
  name: 'tabs',
  initialState: {
    tabs: []
  },
  reducers: {
    // 新增tab
    addTab: (state, action) => {
      state.tabs.push(action.payload)
    },
    // 删除tab
    removeTab: (state, action) => {
      state.tabs = state.tabs.filter((tab) => tab.key !== action.payload)
    }
  }
})
​
export const { setActiveKey, addTab, removeTab } = tabSlice.actions
export default tabSlice

然后在store中新增此切片

src/store/index.js

php 复制代码
// 创建store对象
const store = configureStore({
  reducer: {
    user: userSlice.reducer,
    permission: permissionSlice.reducer,
    // tabs切片
    tabs: tabSlice.reducer
  },
  ...
})

tabs组件展示及新增切换删除功能

Ant Design提供了Tabs组件,找一个功能差不多的,样式如下

所用到的配置项

配置项 类型 说明
type string 页签的基本样式,可选 linecard editable-card 类型
activeKey string 当前激活选项卡的 key
hideAdd boolean 是否隐藏加号图标,在 type="editable-card" 时有效
items {label:选项卡显示标题,key:选项卡对应key,children:选项卡显示内容,closable:是否显示选项卡的关闭按钮,在 type="editable-card" 时有效}[] 配置选项卡内容
onChange function(activeKey) {} 切换选项卡的回调,activeKey为选中选项卡的key
onEdit function(targetKey:选中选项卡key, action:新增add删除remove){} 新增和删除页签的回调,在 type="editable-card" 时有效

我们直接用它的代码,Tabs组件渲染配置项items使用全局状态的tabs数组 ,然后将其封装为一个组件在Layout组件使用。

src/Layout/components/TabsView.jsx

javascript 复制代码
import React, { useEffect, useMemo, useState } from 'react'
import { Tabs, ConfigProvider } from 'antd'
import { useDispatch, useSelector } from 'react-redux'
import { addTab, removeTab } from '@/store/reducers/tabSlice'
​
const TabsView = React.memo(({ pathname }) => {
  // 获取全局tabs
  const tabs = useSelector((state) => state.tabs.tabs)
  const dispatch = useDispatch()
  // 当前选中tab
  const [activeKey, setActiveKey] = useState()
  // Tabs渲染所用数组,当长度为1时Tab项不显示关闭
  const tabItems = useMemo(() => {
    return tabs.map((item) => ({...item,closable: tabs.length > 1}))
  }, [tabs])
  useEffect(() => {
    if (pathname !== '/') {
      setActiveKey(pathname)
      // 数组中无此项,进行添加
      if (!tabs.some((item) => item.key === pathname)) {
        onAddTab(pathname)
      }
    }
  }, [pathname])
  /** tab操作方法 */
  // tab切换事件
  const handleTabChange = (activeKey) => {
    ...
  }
  // 添加方法
  const onAddTab = (pathname) => {
    ...
  }
  // 点击关闭
  const closeTab = (targetKey) => {
     ...
  }
​
  const handleEdit = (targetKey, action) => {
    if (action === 'remove') {
      closeTab(targetKey)
    }
  }
  return (
    <ConfigProvider
      theme={{
        components: {
          Tabs: {
            // 横向标签页标签外间距
            horizontalMargin: 0
          }
        }
      }}>
      <div style={{ backgroundColor: '#fff' }}>
        <Tabs
          type="editable-card"
          onChange={handleTabChange}
          activeKey={activeKey}
          onEdit={handleEdit}
          items={tabItems}
          hideAdd
        />
      </div>
    </ConfigProvider>
  )
})
export default TabsView

获取所有菜单项

Tabs组件大致的框架已经完成了,但是现在还展示不出东西,原因是我们没有新增项到全局数据tabs中。但在新增之前,我们得要获取最底层的菜单项(无子菜单的菜单项) 的信息,这样对应可以获取当前路由路径的路由信息(标题等)。

在Layout组件中,我们有获取过全局的后端路由,可以通过它做结构转换,获取需要的底层菜单项数组 。后端路由结构类似[{title:'xx',children:[xx]},{...}],我们只需要把不存在children字段或children字段为空的提取出来即可。

src/Layout/index.jsx

javascript 复制代码
import TabsView from './components/TabsView'
// 提取底层路由方法
const getMenus = (routes) => {
  let menus = []
  function getMenuItem(route) {
    route.forEach((item) => {
      if (item.children && item.children.length) getMenuItem(item.children)
      else {
        // 排除默认路由
        if (item.path) menus.push(item)
      }
    })
  }
  getMenuItem(routes)
  return menus
}

然后我们用useMemohook缓存返回值赋值给一个变量,注意添加上首页路由,将其赋值给上一步创建的TabsView组件。我们再额外传递切换选项卡跳转路由的方法(注意传给React.memo包裹的子组件函数要用useCallbackhook包裹缓存,不然会报错

src/Layout/index.jsx

javascript 复制代码
const LayoutApp=()=>{
  const {pathname}=useLocation()
  // 获取后端权限路由
  const permissionRoutes = useSelector((state) => state.permission.permissionRoutes)
  // 格式化路由数组
  const formatRoutes = useMemo(() => {
    return [{ title: '首页', menuPath: '/home' }].concat(getMenus(permissionRoutes))
  }, [permissionRoutes])
  // 选择选项卡以后,跳转对应路由
  const selectTab = useCallback(
    (key) => {
      navigate(key)
    },
    [navigate]
  )
  ...
  return(
    ...
      <Content
      style={{
        // padding: 24,
        minHeight: 280
        // background: colorBgContainer
      }}>
      <TabsView pathname={pathname} formatRoutes={formatRoutes} selectTab={selectTab}/>
       {/* 显示layout子路由视图 */}
      <Outlet />
    </Content>
  )
}

新增事件

获取底层路由数组后,我们就可以进行选项卡的添加。

src/Layout/components/TabsView.jsx

javascript 复制代码
const TabsView = React.memo(({ pathname, formatRoutes, selectTab }) => {
  // 添加方法
  const onAddTab = (pathname) => {
    // 找到对应路径的菜单信息
    const menu = formatRoutes.find((item) => item.menuPath === pathname)
    if (menu) dispatch(addTab({ label: menu.title, key: menu.menuPath }))
  }
}

切换事件

切换选项卡会调用Tabs组件的onChange回调函数,传递的参数为切换的选项卡的key。随后调用父组件传来的跳转事件并传递跳转路径即key

scss 复制代码
// tab切换事件
const handleTabChange = (activeKey) => {
  selectTab(activeKey)
}

删除事件

删除事件是点击选项卡右上角的关闭按钮后会调用Tabs组件的onEdit回调,传递的参数为选项卡key和actionremove。随后有两个步骤

1.关闭当前选项卡后,左边是否有选项卡【tabs数组上一项】,有则选中跳转左边的选项卡,否则选中跳转右边的选项卡【tabs数组下一项】

javascript 复制代码
 // 点击选项卡关闭
const closeTab = (targetKey) => {
  // 获取删除后的数组
  const afterRemoveTabs = tabs.filter((item) => item.key !== targetKey)
  // 获取选中跳转的数组下标
  const selectIndex = tabs.findIndex((item) => item.key === targetKey) - 1
  if (selectIndex >= 0) {
    selectTab(afterRemoveTabs[selectIndex].key)
  } else {
    selectTab(afterRemoveTabs[selectIndex + 1].key)
  }
}

2.删除全局状态中的当前项

scss 复制代码
const closeTab = (targetKey) => {
  ...
  dispatch(removeTab(targetKey))
}

效果图

缓存组件

现在选项卡的添加、切换、删除事件都完成了,但每次切换路径时还是会销毁并重新加载 下个组件,比较直观的就是每次切换到某个路由时会重新请求接口。这不是我们所预期的,我们应该保留住页面的状态即缓存组件,这样下次到此选项卡时此组件不会重新加载。

keep-alive功能我们用到的是react-activation

React Activation

Vue 中 <keep-alive /> 功能在 React 中的黑客实现

配合 babel 预编译实现更稳定的 KeepAlive 功能

注意

  • 不要使用 <React.StrictMode /> 严格模式
  • (React v18+) 不要使用 ReactDOMClient.createRoot, 而是使用 ReactDOM.render(关于这点我没有发现使用上的问题)

安装

复制代码
npm install react-activation

使用

1.在不会被销毁的位置放置 <AliveScope> 外层,一般为应用入口处

src/index.js

javascript 复制代码
// 导入缓存外层组件
import { AliveScope } from 'react-activation'
...
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
  <Provider store={store}>
    <BrowserRouter>
      <AliveScope>
        <App />
      </AliveScope>
    </BrowserRouter>
  </Provider>
)

2.用 <KeepAlive> 包裹需要保持状态的组件

这里分两种情况

  • 路由已存储在全局状态tabs中,用 <KeepAlive> 包裹Outlet路由视图,此时已经是缓存状态
  • 路由未存储在全局状态tabs中,只用Outlet路由视图展示,此时未进入缓存状态,下次到改路由才是缓存状态

代码实现如下:

src/Layout/index.jsx

javascript 复制代码
...
<Content
  style={{
    // padding: 24,
    minHeight: 280
    // background: colorBgContainer
  }}>
    <TabsView pathname={pathname} formatRoutes={formatRoutes} selectTab={selectTab} />
    {/* 显示layout子路由视图 */}
    {/* <Outlet /> */}
    <div style={{ padding: '24px' }}>
      {tabs.find((item) => item.key === pathname) ? (
        <KeepAlive id={pathname} cacheKey={pathname}>
          <Suspense fallback={<Loading />}>
            <Outlet />
          </Suspense>
        </KeepAlive>
      ) : (
        <Outlet />
      )}
  </div>
</Content>
...

此时缓存功能实现。

但首次加载存在请求两次接口的问题,后续不会出现

面包屑实现

面包屑我们直接用Ant Design提供的Breadcrumb组件,我们用到其中的items来配置路由栈信息,items的配置项如下所示

我们只用到其中的title来实现跳转及展示功能。在这之前,我们得先获得路由的平铺对象 (即每一级路由url与标题一一对应的对象),例如{'/home':'首页','/system':'系统管理'},然后我们再获取页面当前路由的路由路径数组例如['system','user']一个个拼接去与这个对象作对应。

获得路由的平铺对象

我们用全局状态中的后端路由permissonRoutes拼接上首页后进行递归遍历。

src/utils/common.js

javascript 复制代码
/**
 * 面包屑获取路由平铺对象 ,
 * @param {*} routes
 * @returns object, 例:{"/home":"首页"}
 */
export const getBreadcrumbNameMap = (routes) => {
  //首先拼接上首页
  const list = [{ path: 'home', menuPath: '/home', title: '首页' }, ...routes]
  let breadcrumbNameObj = {}
  const getItems = (list) => {
    //先遍历数组
    list.forEach((item) => {
      //遍历数组项的对象
      if (item.children && item.children.length) {
        const menuPath = item.menuPath ? item.menuPath : '/' + item.path
        breadcrumbNameObj[menuPath] = item.title
        getItems(item.children)
      } else {
        breadcrumbNameObj[item.menuPath] = item.title
      }
    })
  }
  //调用一下递归函数
  getItems(list)
  //返回新数组
  return breadcrumbNameObj
}

然后在Layout组件中用useMemo hook来缓存方法返回值

src/Layout/index.jsx

javascript 复制代码
import {getBreadcrumbNameMap} from '@/utils/common'
...
// 获取全局状态中的后端路由
const permissionRoutes = useSelector((state) => state.permission.permissionRoutes)
// 缓存面包屑的路由平铺对象
const breadcrumbNameMap = useMemo(() => getBreadcrumbNameMap(permissionRoutes), [permissionRoutes])

获取页面路由路径数组

我们通过useLocation hook获取的pathname是类似/system/user这样的,我们需要将其转换为数组。

javascript 复制代码
const { pathname } = useLocation()
// 获取页面路由路径数组
const pathSnippets = pathname.split('/').filter((i) => i)

然后我们遍历这个数组,转换为面包屑items配置项所需格式

javascript 复制代码
/** 面包屑 */
const breadcrumbNameMap = useMemo(() => getBreadcrumbNameMap(permissionRoutes), [permissionRoutes])
const breadcrumbItems = pathSnippets.map((_, index) => {
  const url = `/${pathSnippets.slice(0, index + 1).join('/')}`
  // 如果是最后一项,即当前页面路由,渲染文本不可点击跳转
  if (index + 1 === pathSnippets.length)
    return {
      key: url,
      title: breadcrumbNameMap[url]
    }
  // 其余用link标签可点击跳转(注意:上级路由默认跳转到其定义的重定向路由,例如/system跳转至/system/user)
  return {
    key: url,
    title: <Link to={url}>{breadcrumbNameMap[url]}</Link>
  }
})

最后展示面包屑组件,先引入组件

javascript 复制代码
import { Breadcrumb } from 'antd'

Layout组件中展示

最终效果

相关推荐
potender1 分钟前
前端基础学习html+css+js
前端·css·学习·html·js
Hilaku9 分钟前
你以为的 Tailwind 并不高效,看看这些使用误区
前端·css·前端框架
帅夫帅夫11 分钟前
Vibe Coding从零开始教你打造一个WebLLM页面
前端·人工智能
Vonalien12 分钟前
Trae 深度体验:从怀疑到真香,AI 如何重塑我的开发流?
前端
刘白Live13 分钟前
【html】localStorage设置和获取局部存储的值
前端
白瓷梅子汤13 分钟前
跟着官方示例学习 @tanStack-table --- Basic
前端·react.js
openInula前端开源社区13 分钟前
【openInula茶话会】第三期:Vue转换到openInula技术揭秘
前端·javascript
哄哄57514 分钟前
Antd中Upload组件封装及使用:
前端
哄哄57514 分钟前
人工智能之web前端开发(deepSeek与文心一言结合版)
前端
哄哄57514 分钟前
js如何将deepSeek生成的报告添加封面并下载成word
前端