上节课中我们完成了页面的大致布局的编写,今天我们主要把重点放在菜单配置中,为什么这么一个简单的菜单配置要单独写一篇文章来说明呢?
因为他在后续的“动态菜单”,权限校验等环节都有很重要的作用。
首先你要先把菜单数据上升到页面级数据,虽然它只是一个组件,但是它里面的数据需要和页面数据(主要是路由)关联上,几乎所有的导航组件,都需要有这一点的意识。
首先我们需要获取到当前项目的所有路由信息,这有两种方式,一种是配置式,自己整理出一个清单,每增加一个页面都更新这个清单,有个好处就是后续菜单交由服务端管控的时候,可以直接将这份数据给他。坏处就是页面数是固定的,后续想做动态菜单,有点困难,因为你配置的路由信息需要是一个“最大值”,否则未配置的页面没有被引用则不会被编译。
另一种就是约定式的,新建一个页面,即增加一个菜单信息,我们通过 Umi 提供的 API 获取到最新的页面路由信息,调用一些工具类,将他们转换成菜单数据,后面维护心智很低。约定式的方式,所有的页面都会被构建,只是通过菜单加权限来控制页面是否可访问,可以实现类似动态路由这样的需求。缺点就是需要独立维护一份菜单数据,主要是页面名称的“翻译文档“。比如首页 ”/home“ 在菜单中应该显示 “首页”。
获取当前页面数据
Umi@4 中要获取页面配置非常的简单,只需要使用 useAppData
即可,它返回全局的应用数据。
declare function useAppData(): { routes: Record<id, Route>; routeComponents: Record<id, Promise<React.ReactComponent>>; clientRoutes: ClientRoute[]; pluginManager: any; rootElement: string; basename: string; clientLoaderData: { [routeKey: string]: any }; preloadRoute: (to: string) => void; }; 复制代码
routes
和 clientRoutes
这两个数据都是路由数据,前者是对象,以 pathname
为 key
,以 parentId
来标记层级和嵌套关系。后者是一个数组,以 children
来表示树形结构。
const routes = { 'a':{ parentId: "b" path: "a" }, 'b':{ path: "b" }, } 复制代码
const clientRoutes = [{ path: "b", children:[{ path: "a" }] }] 复制代码
以上两个数据“对等”。
所以我们要取到当前的所有的路由配置信息,则
import { useAppData } from "umi"; const App = ()=>{ const { clientRoutes } = useAppData(); const { children } = clientRoutes[0]; } 复制代码
将路由转化成菜单数据
const clientRoutes = [{ path: "b", children:[{ path: "a" }] }]; // 转化为 const menuData = [{ key:"/b", icon:<PieChartOutlined />, label:"首页", children:[{ key:"/a", icon:<UserOutlined />, label:"用户", }] }]; 复制代码
通过观察分析,我们发现,其实路由数据中,我们只有 path
和 children
数据有用,而菜单数据中,我们还需要 icon
和 label
,这时候就需要引入我们前面提到的 翻译文档
了。
const menuHash: any = { "/": { label: "首页", icon: <PieChartOutlined />, }, user: { label: "用户", icon: <UserOutlined />, }, }; 复制代码
至此我们的 路由转菜单的工具类
为:
const getItem = (path: string, children?: MenuItem[]) => { const route = menuHash[path]; return { key: path.startsWith("/") ? path : `/${path}`, icon: route?.icon || <></>, children, label: route?.label || path, } as MenuItem; }; const routesToMenu = (routes: any[]): MenuItem[] => { return routes .map((route) => { const { path, children } = route; if (children) { return getItem(path, routesToMenu(children)); } return getItem(path); }); }; 复制代码
运行项目,访问 http://127.0.0.1:8888/
这是你会发现,菜单中有很多我们之前写的 demo 页面,我们并不想让他们展示出来。所以我们需要增加一个访问权限的黑名单。
const unaccessible = ["/hooks", "/useEffect", "/usemodel", "/useState"]; 复制代码
只要简单的修改一下,我们的 routesToMenu
方法即可。
const routesToMenu = (routes: any[]): MenuItem[] => { return routes .filter((i) => { const path = i.path.startsWith("/") ? i.path : `/${i.path}`; return !unaccessible.includes(path); }) .map((route) => { const { path, children } = route; if (children) { return getItem(path, routesToMenu(children)); } return getItem(path); }); }; 复制代码
保存代码,你讲看到菜单中只有两个数据了。
增加页面权限
但是这只是将路由入口隐藏了,如果用户知道你的路由信息,比如此时我们直接当问 http://127.0.0.1:8888/usemodel
,虽然菜单已经过滤了但是我们依旧可以直达页面。
其实原理也很简单,只要判断当前页面 pathname 在我们的不可访问清单就返回 403 页面即可,这个要看你们项目中的权限采用的是黑名单模式还是白名单模式了,黑名单模式匹配上拦截,白名单模式匹配上放行。
import { Result, Button } from "antd"; if (unaccessible.includes(location.pathname)) { return ( <Result status="403" title="403" subTitle="抱歉,你没有权限访问这个页面!" extra={ <Button type="primary" onClick={() => navigate(-1)}> 返回上一个页面 </Button> } /> ); } 复制代码
菜单与路由跳转
需要实现的功能,点击菜单触发路由跳转,当前页面对应的菜单项需要高亮显示。
import { useAppData, useNavigate, useLocation } from "umi"; const App = ()=>{ const navigate = useNavigate(); const location = useLocation(); const { clientRoutes } = useAppData(); const { children } = clientRoutes[0]; const items = routesToMenu(children); return <Menu theme="dark" onClick={(e) => { navigate(e?.key); }} defaultSelectedKeys={[location.pathname]} mode="inline" items={items} />; } 复制代码
至此,我们的菜单与权限部分的所有功能都开发完毕。这里面需要引申到项目的权限管控上,将 unaccessible
和用户的登录信息关联上,就可以了。如果有面试官问你,你们项目中的权限部分是怎么做的?如果用户知道你的页面url是否可以直接访问页面,如何拦截?你应该可以回答的很明白了。当然这节课只是为了讲明白原理,在实际项目开发中我们可以用上 layout 和 access 插件的组合来更合理的完成权限和菜单。