一 准备阶段
1.1 cra安装
npx create-react-app react-template
yarn
1.2 去除多余文件
1.3 引入antd
yarn add antd
在src/index.css => src/index.less 并在文件头加入三行代码,第三行用于覆盖默认样式
@import '~antd/lib/style/themes/default.less';
@import '~antd/dist/antd.less';
@import './owerwrite-antd.less';
1.4 引入craco
覆盖cra的webpack默认配置
yarn add @craco/craco
修改package.json
/* package.json */
"scripts": {
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
+ "start": "cross-env PORT=3001 craco start",
+ "build": "craco build",
+ "test": "craco test",
}
/* 全局安装cross-env 用于覆盖默认开发端口号 */
yarn add cross-env -g
然后在项目根目录创建一个 craco.config.js 用于修改默认配置
// node.sass
yarn add node-sass
// craco-less
yarn add craco-less
// 支持装饰器
yarn add @babel/plugin-proposal-decorators -s
// 参考配置
const CracoLessPlugin = require('craco-less');
const path = require('path')
const pathResolve = pathUrl => path.join(__dirname, pathUrl)
module.exports = {
webpack: {
// 别名配置
alias: {
src: pathResolve('src'),
asserts: pathResolve('src/assets'),
common: pathResolve('src/common'),
components: pathResolve('src/components'),
pages: pathResolve('src/pages'),
model: pathResolve('src/model'),
util: pathResolve('src/util'),
service: pathResolve('src/service'),
layout: pathResolve('src/layout')
},
},
// 装饰器
babel: {
plugins: [["@babel/plugin-proposal-decorators", { legacy: true }]]
},
// antd 引入 craco-less 来帮助加载 less 样式和修改变量
plugins: [
{
plugin: CracoLessPlugin,
options: {
lessLoaderOptions: {
lessOptions: {
modifyVars: {
// '@primary-color': '#1DA57A'
},
javascriptEnabled: true,
},
},
},
},
],
// 开发服务器后台端口
devServer: {
proxy: {
'/api': {
target: 'http://llb.rubyliu.top:8006',//frp 搭建的内网穿透
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
}
}
}
二 状态管理与请求
2.1 选用只有两个api的状态管理库hox,基于fetch 的 umi-request
yarn add hox umi-request
在src/util 新建request.js封装
import { extend } from "umi-request";
import { notification } from "antd";
const codeMessage = {
200: "服务器成功返回请求的数据。",
201: "新建或修改数据成功。",
202: "一个请求已经进入后台排队(异步任务)。",
204: "删除数据成功。",
400: "发出的请求有错误,服务器没有进行新建或修改数据的操作。",
401: "用户没有权限(令牌、用户名、密码错误)。",
403: "用户得到授权,但是访问是被禁止的。",
404: "发出的请求针对的是不存在的记录,服务器没有进行操作。",
406: "请求的格式不可得。",
410: "请求的资源被永久删除,且不会再得到的。",
422: "当创建一个对象时,发生一个验证错误。",
500: "服务器发生错误,请检查服务器。",
502: "网关错误。",
503: "服务不可用,服务器暂时过载或维护。",
504: "网关超时。",
};
/**
* 异常处理程序
*/
const errorHandler = error => {
const { response } = error
if (response && response.status) {
const errorText = codeMessage[response.status] || response.statusText
const { status, url } = response
notification.error({
message: `请求错误 ${status}: ${url}`,
description: errorText,
});
} else if (!response) {
notification.error({
description: "您的网络发生异常,无法连接服务器",
message: "网络异常",
});
}
return response
};
/**
* 配置request请求时的默认参数
*/
const request = extend({
errorHandler,
charset: 'utf8',
prefix: '/api/ahapi',
headers:{
'SM_USER': sessionStorage.getItem('SM_USER')
},
// 默认错误处理
credentials: "include", // 默认请求是否带上cookie
});
export default request;
2.2 使用node.js模拟数据
这一步在实际开发中由后台同事完成,这里只是为了展示权限使用,你也可以用mock来模拟数据
使用egg.js 新建一个node项目
$ mkdir egg-example && cd egg-example
$ npm init egg --type=simple
$ npm i
$ npm run dev
$ open http://localhost:7001
创建 src/controller/auth.js
const Controller = require('egg').Controller;
class AuthController extends Controller {
async index() {
const { ctx } = this;
ctx.body = {
code: 0,
messsage: 'get auth success',
data: [ 'page1', 'page2', 'example' ],
};
}
}
module.exports = AuthController;
修改router.js
'use strict';
/**
* @param {Egg.Application} app - egg application
*/
module.exports = app => {
const { router, controller } = app;
router.get('/auth', controller.auth.index);
};
三 路由与权限
3.1 创建路由表
新建 src/router/index.js,并在src/pages/ 下建几个事例文件
/*
* name: 在菜单栏显示的名称
* icon:在菜单栏显示的图标没有则不显示
* components: 要渲染的组件
* key:权限key
* showMenu 是否展示
* auth 是否需要权限验证
* parent 是否是父级菜单
* exact 路由匹配是否为严格模式
*/
import Example from '../pages/Example'
import ChildA from '../pages/Example/ChildA'
import ChildB from '../pages/Example/ChildB'
const AppRouter = [
{
key: 'page1',
name: 'page1',
showMenu: true,
auth: false,
parent: true,
children: [
{
components: ChildA,
key: 'children1',
name: '新建',
icon: 'icon-add',
showMenu: true,
auth: false,
path: '/approvalhub/create',
parent: false,
exact: true
},
{
components: ChildB,
key: 'children2',
name: '新建',
icon: 'icon-add',
showMenu: true,
auth: false,
path: '/approvalhub/create',
parent: false,
exact: true
},
]
},
{
components: Example,
key: 'page2',
name: 'page2',
showMenu: true,
auth: true,
path: '/page2/index',
parent: false,
exact: true
}
]
export default AppRouter
3.2 根据路由表渲染路由
根据router.js 过滤有权限的路由,新建src/model/useRouterModel.js
import { useEffect, useState } from "react"
import { createModel } from "hox"
import { message } from "antd"
import AppRouter from '../../router/index'
import { translate } from '../util/translate'
import { getAuth } from '../mock/auth'
function useRouterModel () {
const [routerList,setRouterList] = useState([])
useEffect(() => {
getAuth().then(res => {
if (res.code === 0) {
setRouterList(translate(AppRouter,res.data))
} else {
message.error('获取权限失败,请联系管理员')
}
})
},[])
return {
routerList,
}
}
export default createModel(useRouterModel);
getAuth接口会返回一个权限key数组,用深拷贝去遍历router数组的key值,生成一个新数组
import request from "../util/request";
/**
* 获取权限接口
* @returns
*/
export async function getAuth() {
return request.get('/auth')
}
/**
* 取有权限的router
* @param {*} sourceArr 源数组
* @param {*} authArr 权限key数组
* @returns 有权限的router
*/
export const translate = (sourceArr,authArr) => {
let routerList = []
const getRouterList = () => {
const skb = (obj = {}) => {
if(typeof obj !== 'object'){
return obj
}
let result
if ( obj instanceof Array ) {
result = []
}else{
result = {}
}
for(let i in obj){
let item = obj[i]
// 主要是这里的判断
if( (item.showMenu && authArr.indexOf((item.key)) > -1) || !item.auth){
result[i] = skb(item)
}
}
return result
}
sourceArr.forEach(element => {
if(authArr.indexOf(element.key) > -1 || !element.auth){
routerList.push(skb(element))
}
})
}
getRouterList()
return routerList
}
再到app.js中渲染model中的routerList 安装react-router-dom
yarn add react-router-dom
// App.js
import React, { useEffect, useState } from 'react'
import Style from './app.module.scss'
import { BrowserRouter as Router, Route,Switch } from 'react-router-dom'
import { Layout,ConfigProvider } from 'antd'
import zhCN from 'antd/lib/locale/zh_CN';
import useRouterModel from './model/useRouterModel'
import CustomSlider from './layout/CustomSlider'
import {
MenuUnfoldOutlined,
MenuFoldOutlined
} from '@ant-design/icons';
const { Header, Content, Sider } = Layout;
const App = () => {
const [list, setList ] = useState([])
const [collapsed,setCollapsed] = useState(false)
const { routerList } = useRouterModel()
useEffect(() => {
initRouter(routerList)
},[routerList])
const initRouter = (arr) => {
let result = []
const subsequent = (menuList) => {
menuList.forEach((route) => {
if(route.children){
subsequent(route.children)
}
if(route.path){
console.log(route.path)
let tmp = <Route
exact={route.exact}
key={route.key}
path={route.path}
component={route.components}
/>
result.push(tmp)
}
})
}
subsequent(arr)
setList(result)
}
return (
<Router>
<Layout className={Style.app}>
<Sider className={Style.left} width={225} trigger={null} collapsible collapsed={collapsed} collapsedWidth={0}>
<div className={Style.content}>
<div className={Style.menu}>
<CustomSlider />
</div>
</div>
</Sider>
<Layout className={Style.right}>
<Header className={Style.header}>
{React.createElement(collapsed ? MenuUnfoldOutlined : MenuFoldOutlined, {
className: 'trigger',
onClick: () => {setCollapsed(!collapsed)},
})}
</Header>
<Content style={{ margin: '16px 16px '}}>
<ConfigProvider locale={zhCN}>
<Switch>
{ list }
</Switch>
</ConfigProvider>
</Content>
</Layout>
</Layout>
</Router>
);
}
export default App;
app.module.scss
.app{
min-height: 100vh;
.left{
background-color:#222323;
.content{
display: flex;
flex-direction: column;
justify-content: space-around;
min-height: 100%;
.menu{
display: flex;
flex-grow: 1;
}
.logoBox{
margin-bottom: 30px;
text-align: center;
.logo{
width: 100px;
}
}
}
}
.right{
.header :global{
background-color: #fff;
padding: 0 24px;
.trigger{
font-size: 18px;
line-height: 64px;
cursor: pointer;
transition: color 0.3s;
}
.trigger:hover{
color: #1890ff;
}
}
}
}
/layout/CustomSlider.js
import React, { useEffect, useState } from 'react'
import { Menu } from 'antd'
import { Link } from 'react-router-dom';
import { PlusCircleOutlined, FileOutlined, UsergroupAddOutlined, EditOutlined, UsergroupDeleteOutlined,AppstoreOutlined,ApartmentOutlined } from '@ant-design/icons';
import Style from './index.module.scss'
import useRouterModel from '../model/useRouterModel'
import useLangModel from '../model/useLangModel'
const { SubMenu, Item } = Menu
const CustomSlider = () => {
const { routerList, activeMenuKey,setActiveMenuKey } = useRouterModel()
const { intl } = useLangModel()
const [dataSource,setDataSource] = useState([])
useEffect(() => {
setDataSource(routerList)
},[routerList])
const getIcon = (str) => {
if(!str){
return null
}
if(str === 'icon-add'){
return <PlusCircleOutlined className={Style.icon} />
}else if (str === 'icon-file') {
return <FileOutlined className={Style.icon} />
}else if (str === 'icon-user') {
return <UsergroupAddOutlined className={Style.icon} />
}else if (str === 'icon-apply') {
return <EditOutlined className={Style.icon} />
}else if (str === 'icon-black') {
return <UsergroupDeleteOutlined className={Style.icon} />
}else if (str === 'icon-ywfl-type') {
return <AppstoreOutlined className={Style.icon} />
}else if (str === 'icon-process-type') {
return <ApartmentOutlined className={Style.icon} />
}
}
const menuChange = (item) => {
const { key } = item
setActiveMenuKey(key)
}
const createMenu = (menuList) => {
return(
menuList.map((menu,index) => {
if(!menu.parent){
return(
menu.showMenu && <Item className='menu-item' key={menu.key}>
<Link className={Style.menuItem} to={menu.path}>
{
menu.icon && getIcon(menu.icon)
}
{intl.get(menu.name)}
</Link>
</Item>)
}else{
return(
<SubMenu icon={getIcon(menu.icon)} className={Style.parentItem} key={menu.key} title={intl.get(menu.name)} >
{ createMenu (menu.children)}
</SubMenu>
)
}
})
)
}
return(
<Menu className={Style.customMenu} theme="dark" mode="inline" onSelect={(item) => {menuChange(item)}} selectedKeys={[activeMenuKey]} >
{createMenu(dataSource)}
</Menu>
)
}
export default CustomSlider
/layout/index.module.scss
.icon{
font-size: 17px !important;
}
.menuItem{
display: flex;
align-items: center;
font-size: 16px;
span{
font-size: 16px;
}
}
.parentItem :global{
font-size: 16px;
.ant-menu-submenu-title{
display: flex;
justify-content: center;
}
}
.customMenu :global{
.ant-menu-item-selected{
background-color: #00adef !important;
}
// .ant-menu-submenu-open{
// background-color: #00adef !important;
// }
}
四 国际化
4.1 安装
我经历过的多个项目都是用的react-intl
但是在react hooks中配置较为繁琐,而且最佳实践应该与redux集成,由于我用的是hox,所以这里选用阿里的react-intl-universal
yarn add react-intl-universal
将 react-intl-universal 封装到hox中,并用locale存储当前语言,在Lang文件夹中放置多语言文件
4.2 创建一个全局状态
src/model/useLangModel.js
4.2 intl切换语言
修改 App.js的代码,加入一个中英文切换的按钮
// 引入useLangModel
import useLangModel from './model/useLangModel'
const { intl,setLocale,langLoading } = useLangModel()
<Spin spinning={langLoading} >
<Button
onClick={() => setLocale( locale === 'zh-CN' ? 'en-US' : 'zh-CN')}
shape='round'>{intl.get('change-language')}
</Button>
</Spin>
4.3 intl使用
- 修改 router.js 把name字段变为Lang/zh.js 中的key值
- 修改 侧边栏组件 用useLangModel 里的 intl 状态获取name
const { intl } = useLangModel()
intl.get(menu.name)
4.4 最终效果
五 单元测试
5.1 集成Jest
cra 官方原生支持Jest只要满足以下三个条件
- Files with
.js
suffix in__tests__
folders. - Files with
.test.js
suffix. - Files with
.spec.js
suffix.
六 打包发布
6.1 nginx配置
在根目录下新建nginx.conf 文件
events {
worker_connections 1024;
}
http{
include /etc/nginx/mime.types; #文件扩展名与文件类型映射表,否则前端不加载css
default_type application/octet-stream; #默认文件类型
proxy_buffer_size 128k;
proxy_buffers 32 128k;
proxy_busy_buffers_size 128k;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
server {
listen 3001;
root /app; # 工作目录是/app 这里跟Dockerfile里相同
underscores_in_headers on;
add_header X-Frame-Options "SAMEORIGIN";
try_files /$uri /index.html;
location /api/ {
proxy_connect_timeout 2s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
proxy_pass http://10.48.202.95:7001/api/;
client_max_body_size 1000m;
internal;
}
}
}
6.2 docker配置
根目录下新建Dockerfile 文件
FROM nginx:latest
COPY build/ /app/ # 复制build目录下的文件到/app下
RUN mkdir /cnf && chown 777 /cnf
COPY nginx.conf /cnf/nginx.conf
EXPOSE 3001
WORKDIR /app #工作目录
CMD [ "sh", "-c", "nginx -g 'daemon off;' -c /cnf/nginx.conf"]
6.3 修改package.json
增加一条命令
"docker:build":
"
craco build &&
docker build -t rubyliu/react-template . &&
docker run -d -p 6001:3001 --name react-app rubyliu/react-template
",
运行 yarn docker:build
即可查看效果
七 目录结构
.
├── Dockerfile
├── README.md
├── build
├── craco.config.js
├── nginx.conf
├── package.json
├── public
│ ├── index.html
│ └── robots.txt
├── src
│ ├── App.js
│ ├── App.test.js
│ ├── app.module.scss
│ ├── components
│ ├── index.js
│ ├── index.less
│ ├── lang
│ │ ├── en.js
│ │ ├── index.js
│ │ └── zh.js
│ ├── layout
│ │ ├── CustomSlider.js
│ │ └── index.module.scss
│ ├── model
│ │ ├── useLangModel.js
│ │ └── useRouterModel.js
│ ├── pages
│ │ └── Example
│ ├── router
│ │ └── index.js
│ ├── service
│ │ └── RouterService.js
│ ├── setupTests.js
│ └── util
│ ├── request.js
│ └── translate.js
├── yarn-error.log
└── yarn.lock