React管理系统多缓存标签页架构实践

1,424 阅读9分钟

大家好!我是阿祎!做过管理系统的都知道,为了用户体验,我们会采用多标签页面架构,目的就是缓存用户在不同页面上的操作,比如刚刚填好的表单,可能用户想切换到其他任何页面进行核对,然后再切换回来继续填写和提交。

然后呢,你就会发现!这种基于react的多标签缓存架构管理系统开源模板好像没有!

更要命的是实现这个架构的核心组件类似Vue的Keepalive也是没有的(官方支持)那种。

有人说了整那么多,不如直接上Vue得了。

可是当你用过Ant Design ProComponents里面的ProTable组件,这玩意让你惊呼原来前端CRUD这么流畅,用了就回不去了...

最开始在没有Keepalive组件的情况下,我们采用antd的Tabs组件实现,but这种实现有很大性能开销,还没法实现嵌套路由。当标签页打开数量达到10个左右,页面操作交互就开始卡顿了,因为缓存住的页面dom还在真实dom树上,打开多个,直接导致dom数量猛增,真的会卡得难受。限制打开标签页面数量,业务同学会说,哎呀10个也太不够意思了吧!我要无限那种没有限制飞一般的感jio!!!

好啦废话不多说,我的上一篇文章已经讲了React Keepalive实现的方案,今天就简单讲讲如何实现管理系统多缓存标签页架构,让那些性能问题去见太奶吧!

开始整活

8ad5c77a0c6d440985f64d40cb648f4a.jpeg

我把实现好的方案开源了,欢迎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等等你可能想要的!!!