最近重新学了一下 react 技术栈,顺便重构了去年一个管理后台,不得不说前端变化实在是太快了,几个月没用 react 代码都快看不懂了。
源码地址 👉https://github.com/xiaojundebug/react-admin-lite
技术栈
基于 cra ts 版
BASH
npx create-react-app my-app --typescript# oryarn create react-app my-app --typescript
- react + hooks
- react-router
- mobx
- typescript
- antd
- mockjs
需求分析
- antd 按需引入
- 按配置文件自动创建菜单
- 路由懒加载
- 必须登录才能访问,如果没登录直接重定向到登录页面
- 访问了一个不存在的路由跳转到 404 页面
- 实现路由级别和按钮级别的权限控制,根据登录用户展示对应菜单
- 使用 mock 数据
- mobx 状态持久化,避免刷新丢失状态
目录
TEXT
├── README.md├── config-overrides.js rewired配置├── global.d.ts 存放一些ts全局声明├── package.json├── public│ ├── favicon.ico│ ├── index.html│ ├── logo192.png│ ├── logo512.png│ ├── manifest.json│ └── robots.txt├── src│ ├── App.test.tsx│ ├── App.tsx│ ├── Layout 存放布局组件│ ├── assets 存放一些图片资源│ ├── common 存放一些公共变量、方法等│ ├── components 存放公共组件│ ├── config 存放一些配置文件│ ├── index.less│ ├── Table.tsx│ ├── mock 存放mock数据│ ├── pages│ ├── plugins 实际上就放了一个封装后的axios│ ├── react-app-env.d.ts│ ├── serviceWorker.ts│ └── store mobx仓库├── tsconfig.json└── yarn.lock
按需引入 antd
先安装这几个依赖 babel-plugin-import
customize-cra
less
less-loader
react-app-rewired
根目录下新建 config-overrides.js
并写入以下内容
JS
// config-overrides.jsconst { override, fixBabelImports, addLessLoader, addDecoratorsLegacy } = require('customize-cra')module.exports = override(fixBabelImports('import', {libraryName: 'antd',libraryDirectory: 'es',style: true}),addLessLoader({javascriptEnabled: true,modifyVars: {'@primary-color': '#8994DF'}}),addDecoratorsLegacy() // 配置以允许使用装饰器)
然后别忘了改一下 package.json
JSON
// ..."scripts": {"start": "react-app-rewired start","build": "react-app-rewired build","test": "react-app-rewired test","eject": "react-scripts eject"}// ...
登录控制
没登录自动跳到登录页面,这个功能通过封装 Route
组件实现,让我们先搞个 AuthRoute
。
TSX
// src/components/AuthRouteimport React, { useContext } from 'react'import { Route, Redirect, RouteProps } from 'react-router-dom'import * as stores from '../../store'type PickRequired<T, K extends keyof T> = T & Required<Pick<T, K>>const AuthRoute: React.FC<PickRequired<RouteProps, 'component'>> = props => {const { isLogged } = useContext(stores.userStore)const { component: Component, ...rest } = propsreturn (<Route{...rest}render={props => {return isLogged ? <Component {...props} /> : <Redirect to="/login" />}}/>)}export default AuthRoute
按配置文件自动生成菜单
以下教程会通过 lazy
Suspense
设置路由懒加载
配置文件
TS
// global.d.tsinterface IMenu {name: string // 菜单名key: string // 菜单对应路由icon?: string // 菜单图标component?: React.ComponentType<any> // 菜单路由对应页面children?: IMenu[] // 子菜单permissions?: Permission[] // 菜单权限}// src/config/menus.tsimport { lazy } from 'react'const menus: IMenu[] = [{name: '合作商户',key: '/information',icon: 'user',component: lazy(() => import('../pages/Information')),permissions: ['admin']},{name: '订单查询',key: '/order-query',icon: 'file-text',component: lazy(() => import('../pages/OrderQuery')),permissions: ['admin']},{name: '交易查询',key: '/trade-query',icon: 'pay-circle',component: lazy(() => import('../pages/TradeQuery')),permissions: ['admin']},{name: '报表导出',key: '/reports',icon: 'upload',component: lazy(() => import('../pages/Reports')),permissions: ['admin']},{name: '一级菜单',key: '/level1',icon: 'man',children: [{name: '二级菜单',key: '/level2',icon: 'woman',children: [{name: '三级菜单',key: '/level3',icon: 'api',component: lazy(() => import('../pages/Hello'))}]}]},{name: '权限测试',key: '/permission',icon: 'medicine-box',component: lazy(() => import('../pages/Permission'))}]export default menus
封装 Menus 组件
Menus 组件负责根据配置文件递归生成侧边菜单
TSX
// src/components/Menusimport React from 'react'import { Icon, Menu } from 'antd'import { ClickParam } from 'antd/lib/menu'import { withRouter, RouteComponentProps } from 'react-router-dom'import menus from '../../config/menus'import { usePermission } from '../../common/hooks'const Menus: React.FC<RouteComponentProps> = props => {const { location, history } = propsconst hasPermission = usePermission()function handleNavClick({ key }: ClickParam) {history.push(key)}function hasChild(menu: any) {return Array.isArray(menu.children) && menu.children.length > 0}function genSubMenu(menu: any) {return (<Menu.SubMenutitle={<span>{menu.icon && <Icon type={menu.icon} />}<span>{menu.name}</span></span>}key={menu.key}>{genMenus(menu.children)}</Menu.SubMenu>)}function genMenItem(menu: any) {return (<Menu.Item key={menu.key}>{menu.icon && <Icon type={menu.icon} />}<span>{menu.name}</span></Menu.Item>)}function genMenus(menus: any) {return menus.reduce((prev: any, next: any) => {return prev.concat(hasChild(next)? hasPermission(next) && genSubMenu(next): hasPermission(next) && genMenItem(next))}, [])}return (<Menu theme="dark" mode="inline" selectedKeys={[location.pathname]} onClick={handleNavClick}>{genMenus(menus)}</Menu>)}export default withRouter(Menus)
封装 Content 组件
Content 组件负责根据配置文件递归生成路由
TSX
// src/components/Contentimport React, { Suspense } from 'react'import { Layout, Index } from 'antd'import { Switch } from 'react-router-dom'import AuthRoute from '../AuthRoute'import menus from '../../config/menus'import NoMatch from '../../pages/404'import { usePermission } from '../../common/hooks'const Content: React.FC = props => {const hasPermission = usePermission()function hasChild(menu: any) {return Array.isArray(menu.children) && menu.children.length > 0}function genRoute(menu: any) {if (!menu.component) return nullreturn <AuthRoute path={menu.key} component={menu.component} key={menu.key} />}function genRoutes(menus: any) {return menus.reduce((prev: any, next: any) => {return prev.concat(hasChild(next)? hasPermission(next) && genRoutes(next.children): hasPermission(next) && genRoute(next))}, [])}return (<Layout.Contentstyle={{margin: '24px'}}><Suspensefallback={<div style={{ textAlign: 'center', marginTop: '50px' }}><Index tip="loading..." /></div>}><Switch>{genRoutes(menus)}<AuthRoute component={NoMatch} /></Switch></Suspense></Layout.Content>)}export default Content
按钮级别权限控制
上面我们已经实现了菜单级别权限控制,但是这还不够,可能有时候还需要按钮级别权限控制
自定义 hooks
TSX
// src/common/hooks.tsximport React, { useContext } from 'react'import { userStore } from '../store'// ...// 处理按钮级别权限,类似高阶组件function useAuthComponent(...permissions: Permission[]) {const { userinfo } = useContext(userStore)const hasPermission = permissions.includes(userinfo.permission)return <P extends {}>(BaseComponent: React.ComponentType<P>): React.FC<P> => props =>hasPermission ? <BaseComponent {...props} /> : null}export { usePermission, useAuthComponent }
这个 hooks 我封装的不是很好,以后看能否优化
使用教程
TSX
import React from 'react'import { Button } from 'antd'import { observer } from 'mobx-react-lite'import { useAuthComponent } from '../common/hooks'const Permission: React.FC = props => {// @ts-ignore 某些组件会报错,忽略它const AuthButton = useAuthComponent('admin')(Button)return <AuthButton type="primary">权限为admin该按钮才显示</AuthButton>}export default observer(Permission)
使用 mock 数据
需要安装 mockjs
简单示例,具体请移步官网
TS
import Mock from 'mockjs'Mock.setup({// 模拟延迟(200-600毫秒之间)timeout: '200-600'})Mock.mock(/login/, (val: any) => {const isAdmin = val.url.match(/admin/)return isAdmin? {t: {account: 'admin@xxx.com',name: 'admin',permission: 'admin'}}: {t: {account: 'guest@xxx.com',name: 'guest',permission: 'guest'}}})Mock.mock(/getMerchantInfo/, {t: {'account|3-8': [{name: '@cname',account: '@email'}],'product|5-10': [{'productId|5': /\d/,'productType|1': [10, 20, 30, 40, 50],'status|0-1': 0,'road|5-10': [{name: '测试商户',roadCode: /10021\d{6}/,'status|0-1': 0}]}]}})Mock.mock(/order-query/, {t: {}})Mock.mock(/trade-query/, {'t|10': [{'id|+1': 0,merOrderNo: /\d{12}/,payOrderId: /\d{12}/,roadCode: /10021\d{6}/,merName: '@word()','amount|10-10000.2-2': 10,'status|1': [10, 20, 30, 40, 50],createTime: "@date('yyyy-MM-dd hh:mm:ss')"}]})
之后在 App.tsx 引入它即可
mobx 持久化存储
很简陋,凑合用
TS
// src/common/utils.tsimport { autorun } from 'mobx'/*** mobx 状态持久化*/let is_first_run = truefunction mobxPersist<T, K extends keyof T>(store: T, fields: K[]): T {autorun(() => {fields.forEach(field => {if (is_first_run) {const data = window.sessionStorage.getItem(field as string)data && (store[field] = JSON.parse(data))}window.sessionStorage.setItem(field as string, JSON.stringify(store[field]))})is_first_run = false})return store}export { mobxPersist }
使用教程
TS
import { createContext } from 'react'import { observable, action, computed } from 'mobx'import { mobxPersist } from '../../common/utils'export class UserStore {@observable userinfo: any = {}@actionsaveUserinfo = (data: any) => {this.userinfo = data}@computedget isLogged() {return Object.keys(this.userinfo).length > 0}@actioncleanUserinfo = () => {this.userinfo = {}}}export default createContext(mobxPersist(new UserStore(), ['userinfo']))
结尾
暂时写这么多,感觉还有很大优化空间,等我慢慢完善