使用CRA搭建React脚手架

2,120 阅读3分钟

一 准备阶段

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 image.png

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 最终效果

image.png

image.png

五 单元测试

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