React项目工程化业务封装实践 -- web端

419 阅读5分钟

前言

最近部门新开了一个业务线,需要一个内部的开发平台,前端部分是由我来负责的。正好借着这个机会记录一下整个项目架构开发部署的过程,以便查漏补缺。

项目目标:维护内部日常运营数据,财务信息,各种产品的对接信息等。对稳定性要求较高,故而选用较为保守的技术栈。

技术栈:

  • React 17
  • react-router-dom 5.2
  • momentjs 2.29.1
  • antd 4.17.3
  • @antv/g2plot 2.3.13
  • axios 0.20.0
  • react-scripts 3.4.3
  • ssf 0.11.2
  • http-proxy-middleware 1.0.6

初始化项目

使用 creat-react-app 创建

npx create-react-app my-app

创建过程跳过,直接看项目最终目录结构:

image.png

接下来一点一点介绍。

入口

index.js文件使用自动生成的即可,我们在App.js中做一些手脚:

import React from 'react';
import { HashRouter, Route, Switch } from "react-router-dom";
import { ConfigProvider } from 'antd'
import moment from 'moment';

// 兼容antd和moment的国际化
import zhCN from 'antd/es/locale/zh_CN';
import 'moment/locale/zh-cn';

// 布局组件
import BasicLayout from "./layouts";
// 登录组件
import Login from "./pages/Login";

import './App.css';
import './index.css';

function App() {
  // 注册moment国际化 - 中文默认
  moment.locale('zh-cn');

  return (
    // antd配置
    <ConfigProvider locale={zhCN}>
      <HashRouter>
        <Switch>
          <Route path="/login" component={Login} />
          <Route path="/" component={BasicLayout} />
        </Switch>
      </HashRouter>
    </ConfigProvider>
  );
}

export default App;

可以看到我把国际化注册为中文了,因为是内部系统,不需要处理英文的情况。在路由配置中,默认根路由会进入布局页面,在布局组件中,会构建左侧目录树,此时会获取登录状态:

// BasicLayout
...
function genRoute(menu) {
    if (!menu.component) return null
    return <AuthRoute path={menu.key} component={menu.component} key={menu.key} refreshStore={menu.refreshStore} />
}

在自定义的AuthRoute中来判断是否登录,若没有登陆,则跳转到登录界面:

// AuthRoute
return (
    <Route
        {...rest}
        render={props => {
            // isLogged 可从localStorage中获取
            return isLogged ? <Component {...props} /> : <Redirect to="/login" />
        }}
    />
)

再来看看登录页面怎么写的。在登录时,通过接口拿当前的用户,若拿不到则抛出用户不存在;如存在,写入localStorage,并跳转。登录信息的录入可以通过.env配置拿到,也可以通过前端表单输入拿到,这里不做过多展示。

...
function Login() {

    useEffect(() => {
        getCurrentUser();
        // eslint-disable-next-line
    }, []);

    const getCurrentUser = () => {
        setLoading(true);
        // API 调用
        GetUserInfo().then(res => {
            setLoading(false);
            if (res.RetCode === 0) {
                // 写入session 和 local 的函数
                setUserSession(res.Data);
                setSystemStore();
                // 跳转布局组件
                window.location = `/#/`;
            } else {
                setError(res.RetCode || true);
            }
        }, e => {
            setError(e.message);
            setLoading(false);
        });
    }
}
...

API请求

我们对axios封装一下,放在apis文件夹下。

使用axios拦截器:

const createService = baseURL => {
    ...
    if (process.env.REACT_APP_IS_ONLINE !== 'true') {
        // request拦截器
        service.interceptors.request.use(
            config => {
                config.headers['remote_user'] = remoteUser ? remoteUser : '';
                return config;
            },
            ...
        );
    }
}

然后拿到处理后的axios实例:

export const baseFetch = createService(process.env.REACT_APP_API_BACKEND);

至于这些全局变量,可以放在.env中:

...
REACT_APP_IS_ONLINE = false
REACT_APP_REMOTE_USER = xiaodududududuo
REACT_APP_API_BACKEND = /apis
REACT_APP_API_BACKEND_URL = http://192.168.122.21:8009
...

可以看到,REACT_APP_API_BACKEND 和 REACT_APP_API_BACKEND_URL 是不一样的,这里使用 setupProxy在代理一下:

const { createProxyMiddleware } = require('http-proxy-middleware');

const options = [
    {
        name: process.env.REACT_APP_API_BACKEND,
        target: process.env.REACT_APP_API_BACKEND_URL
    },
]

module.exports = function (app) {
    options.forEach(item => {
        const pathRewrite = {};
        pathRewrite[item.name] = "";
        app.use(createProxyMiddleware(item.name,
            {
                target: item.target,
                pathRewrite,
                changeOrigin: true,
            }
        ));
    })
};

这样绕一下,不直接使用REACT_APP_API_BACKEND_URL的意图是为了给API请求路径加上对应的后缀,按照上边这样的配置,请求路径就会是 localhost:3000/apis,而不是直接显示ip地址,同时也是为了下面nginx配置区分不同的请求。

关于env变量,可以在本地放置.env_pre 和 .env_prod,用户在打包部署时区分开发和生产环境。

转过头再看API的使用,我们写一个自己的fetch方法:

function fetchActions(data, fetchOptions) {
    return baseFetch({
        method: 'post',  // 可以定制请求方式
        data: { ...data },  // 这里可以定制参数
        ...fetchOptions // 可以方式 responseType 等定制化字段
    }).then(res => {
        if (res.RetCode !== 0) {
            const errorMessage = res.Message + res.Error || res.RetCode || "error";
            message.error(errorMessage);
        }
        return res
    })
}

由于我们后端定义的接口统一是post的,用一个字段Action来区分,所以我们可以这样定义请求了:

export const GetUserInfo = (params) => fetchActions({ Action: "GetUserInfo", ...params });

权限系统

平台先配置需要权限管理的资源,每一个用户可配置自己的角色,每一个角色配置相应的权限资源列表即可:

image.png

配置资源

建立一个资源配置管理的界面:

image.png

所有的界面、弹窗、按钮都可以放在这里边。

接着建立人员库:

image.png

每一个人员都配有自己的一组角色。

接着对每一个角色进行资源权限配置:

image.png

如此,便做到了权限配置。

在业务侧如何做到显示隐藏呢。在配置完权限后,刷新缓存,可通过接口拿到当前登录用户的所有权限:

image.png

image.png

在代码上,只需要写一个函数判断列表里有没有就可以了:

export function keyHasPermission(key, permissions = []) {
    return permissions.findIndex(item => item.resource === key) > -1;
}

布局

由于这个平台是适用于多个产品形态的,多个产品公用一套menu,所以在layout中需要有切换产品的地方:

image.png

在切换产品的时候,我设置清除本地localStorage,并写入切换产品的数据。在menu展示的时候,也可通过权限来判断是否显示:

function genMenus(menus) {
    return menus.reduce((prev, next) => {
      return prev.concat(
        hasChild(next)
          ? menuHasPermission(next, permissionList) && genSubMenu(next)
          : menuHasPermission(next, permissionList) && genMenItem(next)
      )
    }, [])
}

...

// 校验菜单权限
export function menuHasPermission(menu, permissions = []) {
    // 菜单设置aclKey,则使用acl获取权限,否则所有人都有权限
    if (menu.aclKey) {
        if (permissions) {
            return permissions.findIndex(item => item.resource === menu.aclKey) > -1;
        } else {
            return false
        }
    }
    return true
}

如此,产品架构便是搭好了!


业务封装

封装的公共组件如下:

image.png

常用的工具也做了整合:

image.png

由于这次篇幅已经较长,具体业务组件的封装,我会记录在本专栏后面的文章里。

部署

部署分为预发环境和线上环境两种。预发是给测试同学用的,线上是给真正的用户使用的。

在scripts下放置一个build.sh:

#!/usr/bin/env bash

env=$1

function help()
{
        echo "    please load this file to the host you will run uconf"
        echo "    build.sh env"
        echo "    env   : "pre" or "prod""
}

if [ $# -ne 1 ];then
        help
        exit 1
fi

echo "set dev: " $env

path=$(pwd)
echo $path

if [ $env = "pre" ];then
        cp .env_pre .env
elif [ $env = "prod" ];then
        cp .env_prod .env
else
        help
        exit 1
fi

# yarn打包
echo "start compiling..."

yarn build
cp .env_pre .env

echo "build finish"

# 打包docker镜像

version=`cat VERSION`
time=$(date "+%Y%m%d%H%M%S")
tag="$version""_""$time"

app_name=工程名"_""$env"
default_registry=公司的docker仓库
default_project=你的项目名称
default_user=默认用户
default_pwd=密码
dockerfile=Dockerfile
nginx_conf="./scripts/nginx_""$env"".conf"

echo $(date "+%Y-%m-%d %H:%M:%S")-开始制作镜像
# amd64可以直接打包,arm64用第二条命令打包
# docker build --rm --build-arg NGINX_CONF=$nginx_conf -t $app_name:latest . 
docker build --platform linux/amd64 --rm --build-arg NGINX_CONF=$nginx_conf -t $app_name:latest . 
echo $(date "+%Y-%m-%d %H:%M:%S")-镜像制作完成

echo $(date "+%Y-%m-%d %H:%M:%S")-开始打tag
docker tag $app_name:latest $default_registry/$default_project/$app_name\:$tag
echo $(date "+%Y-%m-%d %H:%M:%S")-打tag完成

echo $(date "+%Y-%m-%d %H:%M:%S")-开始push镜像
docker login -u $default_user -p $default_pwd $default_registry
docker push $default_registry/$default_project/$app_name\:$tag
echo $(date "+%Y-%m-%d %H:%M:%S")-push镜像完成

echo $(date "+%Y-%m-%d %H:%M:%S")-删除本地镜像
docker rmi $app_name\:latest
docker rmi $default_registry/$default_project/$app_name\:$tag

echo "执行发布脚本请输入tag:"
echo $tag

该文件会根据执行时传入的参数(pre或prod)来动态读取不同的配置问件,并本地build,最后会推送到本地docker镜像,最后会输出镜像的tag。你可以用这个tag来在远程服务器上部署。

上面文件中提到了nginx配置。这里说明一下:

image.png

项目里放置两种环境的nginx配置文件:

upstream test {
    server 映射的地址;
}

server {
    listen       80;
    server_name  localhost;
    underscores_in_headers  on;

    location / {
            try_files /index.html =404;
            root   /data/项目名/build;
    }

    location ~ .*\.(js|gif|png|map|jpg|css|jpegcss|swf|ico|txt|html|less|jar|tpl|tgz|woff|tff)$ {
            root      /data/项目名/build;
            proxy_redirect off;
            expires 30d;
            error_page 405 =200 http://$host$request_uri;
    }

    location /test {
        proxy_pass http://test/;
        proxy_set_header Host $Host;
    }
    
    ...
    
}

会根据不同的配置路径映射到不同的url上。

有了上述配置,只需执行指令即可完成打包:

sh build.sh pre

最后部署时,可以在服务器对应部署文件夹内新建一个脚本:

// deploy.sh
#!/bin/bash

if [ ! -n "$1" ]
then
    echo "You should give me a image tag."
    else

echo "The image tag is $1"

oldtag=`cat .env`

echo "The old image tag is $oldtag"

path=$(pwd)

# 对匹配到的路径字符进行替换:sed -i 's/原字符串/新字符串/' ab.txt
sed -i "s/$oldtag/TAG=$1/g" $path/.env

docker login -u 工程名 -p 密码 docker的hub

# docker自动化
docker-compose up -d

fi

然后执行sh deploy.sh [tag名称]即可完成部署!