大家好!我是阿祎!做过管理系统的都知道,为了用户体验,我们会采用多标签页面架构,目的就是缓存用户在不同页面上的操作,比如刚刚填好的表单,可能用户想切换到其他任何页面进行核对,然后再切换回来继续填写和提交。
然后呢,你就会发现!这种基于react的多标签缓存架构管理系统开源模板好像没有!
更要命的是实现这个架构的核心组件类似Vue的Keepalive也是没有的(官方支持)那种。
有人说了整那么多,不如直接上Vue得了。
可是当你用过Ant Design ProComponents里面的ProTable组件,这玩意让你惊呼原来前端CRUD这么流畅,用了就回不去了...
最开始在没有Keepalive组件的情况下,我们采用antd的Tabs组件实现,but这种实现有很大性能开销,还没法实现嵌套路由。当标签页打开数量达到10个左右,页面操作交互就开始卡顿了,因为缓存住的页面dom还在真实dom树上,打开多个,直接导致dom数量猛增,真的会卡得难受。限制打开标签页面数量,业务同学会说,哎呀10个也太不够意思了吧!我要无限那种没有限制飞一般的感jio!!!
好啦废话不多说,我的上一篇文章已经讲了React Keepalive实现的方案,今天就简单讲讲如何实现管理系统多缓存标签页架构,让那些性能问题去见太奶吧!
开始整活
我把实现好的方案开源了,欢迎star!!!——> Super Admin
定义路由配置
首先我们需要定义一下页面路由配置
export interface RouteConfig {
path: string // 路由路径
component: Component // 路由组件
name: string // 唯一key
icon?: ReactNode // 菜单图标
cache?: boolean // 不填默认缓存
meta?: { title: string } // 路由元信息
notMenu?: boolean // 不在菜单显示
children?: Array<this> // 子路由
checkToken?: boolean // 是否需要验证token
redirect?: string // 重定向
authority?: string[] // 权限
authorityType?: "all" | "any" // 权限类型 all 全部通过 any 有一个通过 默认 all
search?: boolean // 是否可以被搜索到
searchParam?: string // 用于搜索跳转携带的参数 例如 type=search 会跳转到 /xxx?type=search
searchKeyWords?: string[] // 搜索关键字 用于搜索额外匹配 默认匹配 name 和 meta.title
}
定义根路由
export const routes: Array<RouteConfig> = [
{
path: "/404",
component: NotFound,
name: "404",
meta: { title: "404" },
},
{
path: "/login",
component: lazy(() => import("@/pages/login")),
name: "login",
meta: { title: "登录" },
},
{
path: "/about",
component: lazy(() => import("@/pages/about")),
name: "about",
meta: { title: "关于" },
},
{
path: "/*", // 其他路径默认进入Layout页面 由Layout接管
component: Layout,
name: "admin", // 这个名字在路由根组件会用到
meta: { title: "admin" },
children: adminRoutes,
},
]
定义我们的管理页面路由
const adminRoutes: Array<RouteConfig> = [
{
path: "",
name: "home",
meta: { title: "首页(带缓存)" },
search: true,
cache: true,
component: lazy(() => import("@/pages/admin")),
icon: <HomeOutlined />,
searchKeyWords: ["首页"],
},
{
path: "no-cache",
name: "no-cache",
search: true,
meta: { title: "无缓存页面" },
component: lazy(() => import("@/pages/admin/no-cache")),
icon: <ClearOutlined />,
},
{
path: "*", // 匹配不到就显示404
redirect: "/404",
notMenu: true,
component: NotFound,
meta: { title: "404" },
name: "404",
},
]
编写我们的路由根组件AppRouter
import { BrowserRouter, HashRouter, Route, Routes } from "react-router-dom"
import { routes } from "@/router/config"
import { map } from "ramda"
import { IsHashRouter } from "@/config"
import { LazyExoticComponent, JSX } from "react"
import { SuspenseLoading } from "@/components/SuspenseLoading"
import { PageManageProvider } from "@/providers/PageManageProvider"
const Router = IsHashRouter ? HashRouter : BrowserRouter
export const AppRouter = (): JSX.Element => {
return (
<Router>
<Routes>
{map(route => {
if (typeof route.component === "object") {
const Component = route.component as LazyExoticComponent<() => JSX.Element>
return (
<Route
path={route.path}
key={route.name}
element={<SuspenseLoading>{<Component></Component>}</SuspenseLoading>}
/>
)
} else {
if (route?.name === "admin") {
return (
<Route
path={route.path}
key={route.name}
element={
<PageManageProvider>
<route.component route={route} />
</PageManageProvider>
}
/>
)
}
return <Route path={route.path} key={route.name} element={<route.component route={route} />} />
}
}, routes)}
</Routes>
</Router>
)
}
上面代码我们会在Layout组件也就是name为admin的路由上套一个PageManageProvider组件,主要是管理页面路由的打开关闭和tab标签页用的。
然后在加入到App根组件内
import { Fragment } from "react"
import { StyleProvider } from "@ant-design/cssinjs"
import { AppThemeProvider } from "@/providers/ThemeProvider"
import { AppRouter } from "@/router"
import GlobalLoadingProvider from "@/providers/GlobalLoadingProvider"
import { Provider } from "react-redux"
import store from "@/store"
function App() {
return (
<Fragment>
<Provider store={store}>
<StyleProvider hashPriority="high">
<AppThemeProvider>
<GlobalLoadingProvider>
<AppRouter />
</GlobalLoadingProvider>
</AppThemeProvider>
</StyleProvider>
</Provider>
</Fragment>
)
}
export default App
接下来说一下PageManageProvider
import { createContext, ReactNode, RefObject, useContext, useMemo, useRef, useState } from "react"
import { message } from "antd"
import useSessionStorageState from "@/hooks/useSessionStorageState.ts"
import { KeepAliveRef } from "keepalive-for-react"
import { NavigateOptions, useLocation, useNavigate } from "react-router-dom"
export type PageConfig = {
// 路由的名称
label: string
// 路由的 path 值 例如 /home /user?id=1
key: string
// 路由的参数
state?: any
}
export interface PageManage {
// 当前激活的路由 key 值
active: string
// 所有存在的路由 tabs
pages: PageConfig[]
close: (key: string, cb?: () => void) => string | null | undefined
open: (page: PageConfig) => void
closeCurrent: (cb?: () => void) => string | null | undefined
getKeepAliveRef: () => RefObject<KeepAliveRef> | undefined
}
const PageContext = createContext<PageManage>({
active: "",
pages: [],
close: (key: string, cb?: () => void) => {
cb && cb()
console.log(key)
return key
},
open: (page: PageConfig) => {
console.log(page)
},
closeCurrent: (cb?: () => void) => {
cb && cb()
return null
},
getKeepAliveRef: () => {
return undefined
},
})
export const usePageContext = () => {
return useContext(PageContext)
}
const TabPageStorageKey = "admin_pages"
export function PageManageProvider(props: { children: ReactNode }) {
console.log("PageManageProvider render")
const location = useLocation()
// 当前激活的path包含search
const [active, setActive] = useState(location.pathname + location.search)
// keepalive 组件ref引用 我们需要使用keepalive组件的一些方法的 如清除缓存页面
const keepAliveRef = useRef<KeepAliveRef>(null)
// useSessionStorageState将打开的多个标签页签缓存起来,刷新避免丢失
const [pages, setPages] = useSessionStorageState<PageConfig[]>(TabPageStorageKey, [])
const [messageApi, messageEle] = message.useMessage()
// 上一次打开的路由 在关闭其他标签页面时会用到
const lastOpenKey = useRef<string>("")
const navigate = useNavigate()
// 重写navigate方法,因为我们传递的key(也就是路由路径)会带search参数
const navigateTo = (key: string, options?: NavigateOptions) => {
const pathname = key.indexOf("?") > -1 ? key.split("?")[0] : key
const search = key.indexOf("?") > -1 ? key.split("?")[1] : ""
navigate(
{
pathname,
search,
},
options,
)
}
const getKeepAliveRef = () => {
return keepAliveRef
}
/**
* 关闭一个标签页
* @param key 路由的 key
* @param cb 关闭后成功的回调
* @returns 返回下一个激活的路由 key
*/
const close = (key: string, cb?: () => void) => {
const index = pages.findIndex(item => item.key === key)
if (index === -1) return
const newPages = [...pages]
if (newPages.length <= 1) {
messageApi.error("至少保留一个标签页")
return null
}
cb && cb()
keepAliveRef.current?.removeCache(key)
newPages.splice(index, 1)
setPages(newPages)
let nextActiveKey = null
// if close current page
if (active === key) {
const lastKey = lastOpenKey.current
// if last open key is existed in pages
if (lastKey && newPages.some(item => item.key === lastKey)) {
// set last open key to active 直接打开上一个路由
nextActiveKey = lastKey
} else {
// if last open key is not existed in pages or last open key is not existed
// set the last page to active page
const activeKey = newPages[newPages.length - 1].key
nextActiveKey = activeKey
}
setActive(nextActiveKey)
}
// if nextActiveKey is existed, navigate to nextActiveKey
if (nextActiveKey) {
navigateTo(nextActiveKey, {
// 关闭时将当前路由栈顶进行替换
replace: true,
})
}
return nextActiveKey
}
const open = (page: PageConfig) => {
if (!page || !page.key) {
throw new Error(`路由信息不正确 ${JSON.stringify(page)}`)
}
// 记住上一个打开的路由
lastOpenKey.current = active
const newPages = [...pages]
// 如果已经存在,就不再添加
const existed = newPages.some(item => item.key === page.key)
if (!existed) {
newPages.push(page)
setPages(newPages)
}
// prevent navigate to same page
if (page.key === active) return
navigateTo(page.key, {
state: page.state,
})
setActive(page.key)
}
/**
* 关闭当前的标签页
* @param cb
* @returns 返回下一个激活的路由 key
*/
const closeCurrent = (cb?: () => void) => {
return close(active, cb)
}
// 暴露这些方法,和数据,使他们在Layout和管理子页面都能使用
const value = useMemo(() => {
return {
active,
pages,
close,
open,
closeCurrent,
getKeepAliveRef,
}
}, [active, pages])
return (
<PageContext.Provider value={value}>
{messageEle}
{props.children}
</PageContext.Provider>
)
}
Layout组件
...
import KeepAlive from "keepalive-for-react"
// to prevent re-rendering when user input a new url to navigate
// 防止KeepAlive重复渲染 我们只允许activeName发生变化时才进行必要的渲染
const MemoizedKeepAlive = memo(KeepAlive, (prev, next) => {
return prev.activeName === next.activeName
})
// 检查菜单页面权限
function checkAuthPass(route: RouteConfig) {
if (isNil(route.authority) || isEmpty(route.authority)) {
return true
}
const type = isNil(route.authorityType) ? "all" : route.authorityType
const authority = route.authority
if (type === "all") {
return hasAllAuth(authority)
} else {
return hasAnyAuth(authority)
}
}
// 渲染导航栏
function renderMenuItems(data: Array<RouteConfig>, open: (info: PageConfig) => void, path?: string) {
function renderMenu(data: Array<RouteConfig>, path?: string) {
return reduce(
(items, route) => {
// 不在菜单显示
if (route.notMenu) {
return items
}
// 权限验证 不通过不显示
if (!checkAuthPass(route)) {
return items
}
const thisPath = mergePath(route.path, path)
const children = filter(route => not(route.notMenu), route.children ?? [])
const hasChildren = isNil(children) || isEmpty(children)
items.push({
key: route.name,
title: route.meta?.title,
icon: route.icon,
label: !hasChildren ? (
<span className="a-black">{route.meta?.title}</span>
) : (
<a
onClick={() => {
open({
key: thisPath,
label: route.meta?.title as string,
})
}}
className="a-black"
>
{route.meta?.title}
</a>
),
children: hasChildren ? undefined : renderMenu(children, thisPath),
})
return items
},
[] as ItemType[],
data,
)
}
return renderMenu(data, path)
}
// 获取渲染路由的routeContext 就是之前的路由配置信息
function getRouteContext(data: any): any {
if (isNil(data.children)) {
return null
}
return isNil(data.routeContext) ? getRouteContext(data.children.props) : data.routeContext
}
// 获取渲染路由的路由组信息 例如嵌套路由
function getMatchRouteByEle(ele: ReactElement): RouteMatch[] | null {
if (ele) {
const data = getRouteContext(ele.props)
return isNil(data?.outlet) ? (data?.matches as RouteMatch[]) : getMatchRouteByEle(data?.outlet)
}
return null
}
function getMatchRouteObj(ele: ReactElement | null) {
if (isNil(ele)) {
return null
}
const matchRoutes = getMatchRouteByEle(ele)
if (isNil(matchRoutes)) {
return null
}
// 遍历嵌套路由组 获取菜单选中的keys
const selectedKeys: string[] = reduce(
(selectedKeys: string[], res) => {
const route = res.route as RouteObjectDto
if (route.name) {
selectedKeys.push(route.name)
}
return selectedKeys
},
[],
matchRoutes,
)
// 遍历路由组 获取面包屑导航的字段 路由层级信息
const crumbs = reduce(
(
crumbs: {
name: string
title: string
}[],
res,
) => {
const route = res.route as RouteObjectDto
if (route.name && route.meta?.title) {
crumbs.push({
name: route.name,
title: route.meta?.title,
})
}
return crumbs
},
[],
matchRoutes,
)
// 嵌套路由最终渲染的是最后一个
const matchRoute = last(matchRoutes)
const data = matchRoute?.route as RouteObjectDto
return {
key: data.layout ? matchRoute?.pathnameBase ?? "" : matchRoute?.pathname ?? "",
title: data?.meta?.title ?? "",
name: data?.name ?? "",
selectedKeys,
crumbs,
cache: data.cache,
}
}
export interface RouteObjectDto extends NonIndexRouteObject {
name: string
meta?: { title: string }
cache: boolean
layout?: boolean // 嵌套二次自定义布局
}
// 这里的路由我们需要手动控制 因此需要手动生成路由routes 传递给useRoutes hooks
function makeRouteObject(routes: RouteConfig[], upperPath?: string): Array<RouteObjectDto> {
const RouteObjectDtoList: Array<RouteObjectDto> = []
for (let i = 0; i < routes.length; i++) {
const route = routes[i]
const fullPath = mergePath(route.path, upperPath)
const cache = isNil(route.cache) ? false : route.cache
// 检查权限 不通过不渲染
if (!checkAuthPass(route)) {
continue
}
const routeObjectDto: RouteObjectDto = {
path: route.path,
name: route.name,
meta: route.meta,
cache,
element: <route.component meta={route.meta} />,
children: isNil(route.children) ? undefined : makeRouteObject(route.children, fullPath),
}
RouteObjectDtoList.push(routeObjectDto)
}
return RouteObjectDtoList
}
interface Props {
route: RouteConfig
}
function Layout({ route }: Props) {
console.log("Layout render")
const [showSearch, setShowSearch] = useState(false)
const eleRef = useRef<ReactElement<any, string | JSXElementConstructor<any>> | null>()
const location = useLocation()
const { pages, active, open, close, getKeepAliveRef } = usePageContext()
const keepAliveRef = getKeepAliveRef()
const navigate = useNavigate()
const routes = useMemo(() => {
if (isNil(route.children)) {
return [] as RouteObjectDto[]
}
return makeRouteObject(route.children)
}, [route])
const items = useMemo(() => {
if (isNil(route.children)) {
return [] as ItemType[]
}
return renderMenuItems(route.children, open)
}, [route, routes, open])
// 匹配 当前路径要渲染的路由
const ele = useRoutes(routes, location)
const matchRouteObj = useMemo(() => {
eleRef.current = ele
return getMatchRouteObj(ele)
}, [routes, location])
const routerPathKey = useMemo(() => {
return location.pathname + location.search
}, [location.pathname, location.search])
// listen url change to open page 监听url变化,打开新页面
useEffect(() => {
if (matchRouteObj) {
open({
key: routerPathKey,
label: matchRouteObj.title,
} as PageConfig)
}
}, [routerPathKey])
const [collapsed, setCollapsed] = useState(false)
const [showSide, setShowSide] = useState(true)
useLayoutEffect(() => {
function onResize() {
const width = window.innerWidth
if (width < 768) {
setCollapsed(true)
}
if (width > 1400) {
setCollapsed(false)
}
if (width < 660) {
setShowSide(false)
} else {
setShowSide(true)
}
}
onResize()
window.addEventListener("resize", onResize)
return () => {
window.removeEventListener("resize", onResize)
}
}, [setCollapsed, setShowSide])
return (
<Fragment>
<SearchBox
open={showSearch}
onClose={() => {
setShowSearch(false)
}}
route={route}
></SearchBox>
{!showSide && (
<Drawer
title={"Super Admin"}
placement={"left"}
width={240}
styles={{
body: {
padding: 0,
},
}}
onClose={() => {
setCollapsed(true)
}}
open={!collapsed}
>
<Menu
style={{
padding: "10px 10px",
}}
selectedKeys={matchRouteObj?.selectedKeys}
defaultOpenKeys={matchRouteObj?.selectedKeys}
items={items}
mode={"inline"}
/>
</Drawer>
)}
<ALayout className={"w-full h-screen"}>
<ALayout>
{showSide && (
<ALayout.Sider
style={{
overflow: "auto",
}}
collapsed={collapsed}
width={240}
theme="light"
>
<div
className={
"px-[10px] w-full whitespace-nowrap overflow-hidden text-[20px] pb-0 py-[10px] font-semibold text-center"
}
style={{
color: primaryColor,
}}
>
{collapsed ? "S" : "Super Admin"}
</div>
<Menu
style={{
padding: "10px 10px",
}}
selectedKeys={matchRouteObj?.selectedKeys}
defaultOpenKeys={matchRouteObj?.selectedKeys}
items={items}
mode={"inline"}
/>
</ALayout.Sider>
)}
<ALayout className={"bg-{#F0F2F5} dark:bg-[#191919]"}>
<div
style={{
height: 50,
display: "flex",
padding: "0 10px",
alignItems: "center",
justifyContent: "space-between",
}}
className="app-header flex-shrink-0 bg-white dark:bg-[#111]"
>
<div className={"header-left flex items-center"}>
<Button
onClick={() => {
setCollapsed(!collapsed)
}}
type={"link"}
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
></Button>
{showSide && (
<div className={"crumbs flex-shrink-0 pb-[2px] ml-[10px]"}>
<Breadcrumb items={matchRouteObj?.crumbs}></Breadcrumb>
</div>
)}
</div>
<div>
<Space>
{/*search*/}
<Button
size={"small"}
type={"link"}
icon={<SearchOutlined />}
onClick={() => {
setShowSearch(true)
}}
></Button>
<span className={"flex-shrink-0"}>
Hi, <span className={"font-bold"}>Rychen</span>
</span>
<Avatar
size={"small"}
style={{
backgroundColor: primaryColor,
}}
icon={<UserOutlined />}
/>
<Button
shape={"circle"}
danger
type={"primary"}
size={"small"}
icon={<PoweroffOutlined />}
onClick={() => {
navigate("/login")
}}
></Button>
</Space>
</div>
</div>
<Tabs
className="app-tabs"
style={{
margin: "0 5px",
marginTop: 5,
}}
destroyInactiveTabPane
size={"small"}
hideAdd
type="editable-card"
onChange={key => {
const page = pages.find(item => item.key === key)
if (page) {
open(page)
}
}}
onEdit={(targetKey, action) => {
if (action === "remove") {
close(targetKey as string)
}
}}
activeKey={active}
items={pages}
/>
<ALayout.Content
className="app-content p-[5px]"
style={{
overflow: "auto",
}}
>
<SuspenseLoading>
<MemoizedKeepAlive
errorElement={ErrorBoundary as any}
aliveRef={keepAliveRef}
cache={matchRouteObj?.cache}
activeName={active}
maxLen={20}
>
{eleRef.current}
</MemoizedKeepAlive>
</SuspenseLoading>
</ALayout.Content>
</ALayout>
</ALayout>
</ALayout>
</Fragment>
)
}
export default Layout
最后
基本核心功能差不多就是这样,还想看更多就请移步源码,
欢迎star!!!——> Super Admin
里面还有各种功能,比如菜单搜索🔍、滚动条恢复、全局Loading、主题切换、路由切换过渡动画、ErrorBoundary
等等你可能想要的!!!