一个React项目总结(toB)

3,663 阅读7分钟

技术栈

  • react16
  • react-router5.0
  • mobx4.0
  • antd1.0
  • axios(资源请求)

项目介绍

一个toB的智能制造项目,分为分析端和管理端。分析端涉及到各种图表展示,通过时间范围来控制显示内容;管理端主要是大量表单&表格。(第一次正经用react进行开发,学习了一个星期就开工咯( ╯□╰ ))

架构

  • create-react-app搭建整体框架
  • react-app-rewired进行自定义配置(需要安装customize-cra,并在根目录建一个config_override.js用于修改默认配置)
  • import react-router mobx并做好相应配置
  • github地址:github.com/fanxueqin/r…
  • (可以download到本地,install依赖,然后直接启动。我把业务代码抹掉了,这是一个比较完整的框架,可以直接进行业务开发)

项目目录overview

自定义配置——config-overrides.js

const path = require('path');
const { override, fixBabelImports, addLessLoader, addWebpackAlias, babelInclude ,useBabelRc  } = require('customize-cra');
const TerserPlugin = require('terser-webpack-plugin');

function resolve(dir) {
    return path.join(__dirname, '.', dir)
}
let addCustom = () => config => {    //屏蔽.map.js文件,防止被读到源码
  let optimization = {
      minimizer: [
        new TerserPlugin({
          sourceMap: false
        })
      ]
  }
  config.optimization.minimizer = optimization.minimizer;
  return config;
}

module.exports = override(
  fixBabelImports('import', {   //按需引入antd
    libraryName: 'antd',
    libraryDirectory: 'es',
    style: 'css',
  }),
 addLessLoader({             
    javascriptEnabled: true,
    modifyVars: { 
        // '@primary-color': '#1DA57A' ,
        // '@link-color': '#1DA57A',
    },
 }),
 addWebpackAlias({    //添加别名
    '@': resolve('src'),
    'components':path.resolve(__dirname,'src/components'),
    'views': path.resolve(__dirname,'src/views'),
    'layout': path.resolve(__dirname,'src/layout'),
    'router': path.resolve(__dirname,'src/router'),
    'api':path.resolve(__dirname,'src/api'),
    'store': path.resolve(__dirname,'src/store'),
    'assets': path.resolve(__dirname,'src/assets,'),
    'mock': path.resolve(__dirname,'src/mock'),
    'utils': path.resolve(__dirname,'src/utils')
 }),
 babelInclude([     
  path.resolve("src"), 
 ]),
 useBabelRc(),    //配置装饰器(mobx会用到)还需要.babelrc文件配合
 addCustom()
);

.babelrc配置装饰器

{
  "presets": ["module:metro-react-native-babel-preset"],
  "plugins": [
    [
      "@babel/plugin-proposal-decorators",
      {
        "legacy": true
      }
    ]
  ]
}

setupProxy.js设置代理——前后端分离

const proxy = require('http-proxy-middleware');
const target = 'http://123.4.5.6:8080';
module.exports = function(app) {
    app.use(proxy('/user', { target }))
    app.use(proxy('/login', { target }))
    app.use(proxy('/show', { target }))
    app.use(proxy('/back', { target })))
}; 

配置axios拦截器--aixos.js

import axios from "axios";
import qs from "qs";  //post请求时序列化
import { notification } from 'antd';
// http请求拦截器
axios.interceptors.request.use(
  config => {
    if (config.method.toUpperCase() === "GET") {
      config.url =
        config.url.indexOf("?") > 0
          ? config.url + "&clearCache=" + new Date().valueOf()
          : config.url + "?clearCache=" + new Date().valueOf();
    }
    if (config.method.toUpperCase() === "POST") {
      if (Object.prototype.toString.call(config.data) === "[object FormData]") {
        console.log("数据类型", Object.prototype.toString.call(config.data));
      } else {
        config.data = qs.stringify(config.data); //序列化
        config.headers["Content-Type"] = "application/x-www-form-urlencoded";
      }
    }
    config.headers["Authorization"] = window.localStorage.getItem("token") ? window.localStorage.getItem("token") : '';
    return config;
  },
  error => {
    return Promise.reject(error);
  }
);
// http响应拦截器
let loginTipLock = false;
axios.interceptors.response.use(
  data => {
    if (data.data["code"] && data.data["code"] === -2) {
      window.location.hash = "#/login";
    }
    window.localStorage.setItem('token', data.headers.authorization)  //token刷新机制
    return data;
  },
  err => {
    if (err && err.response) {
      switch (err.response.status) {
        case 400:
          err.message = "请求错误";
          break;
        case 401:
          err.message = "登录已过期,请重新登录!";
          window.location.hash = "#/login";   //token过期机制
          break;
        case 403:
          err.message = "拒绝访问";
          window.location.hash = "#/notAuth";
          break;
        case 404:
          err.message = `请求地址出错: ${err.response.config.url}`;
          break;
        case 408:
          err.message = "请求超时";
          break;
        case 500:
          err.message = "服务器内部错误";
          break;
        case 501:
          err.message = "服务未实现";
          break;
        case 502:
          err.message = "网关错误";
          break;
        case 503:
          err.message = "服务不可用";
          break;
        case 504:
          err.message = "网关超时";
          break;
        case 505:
          err.message = "HTTP版本不受支持";
          break;
        default:
      }
    }
    if (err.response.status === 401) {
      if (!loginTipLock) {    //避免同时多个请求都返回401时,弹出多个“未登录”提示框
        loginTipLock = true;
        notification.info({
          message: '提示',
          description: err.message
        })
        setTimeout(function () {
          loginTipLock = false;
        }, 1000)
      }
    } else {
      notification.error({
        message: '出错啦',
        description: err.message
      });
    }
    return Promise.reject(err);
  }
);
export default axios;




----对请求结果做一个统一处理----
import axios from "./axios";
import { notification } from 'antd';
const $http = (url, method = "GET", data, config = {}) => {
  const _config = Object.assign({ url, method, data }, config);
  return axios(_config).then(res => {
    if (res.status === 200) {
      if (res.data.code === -1) {
        notification.error({
          message: '出错啦',
          description: res.data.msg
        });
        throw new Error("请求出错啦");
      }
      return res;
    }
  });
};
export default {
  /*注册*/
  signIn: ({ username, password }) => $http(`/user/register`, "POST", { username,password }),
  /*登录 */
  login: ({ username, password }) => $http("/user/login", "POST", { username, password }),
   /*上传文件 */
  uploadFile: file => $http( "/upload/csv", "POST", {}, {headers: { "Content-Type": "multipart/form-data" }, processData: false, cache: false, data: file }),
}

开发过程中的小知识(包含新get & 需要加强的)

1.用layout文件划分项目区块

  • 按这个项目的需求来说,我将项目分割为“分析模块”、“管理模块”、“用户配置模块(包- 括登陆注册)”、“license管理模块”
  • 每一个模块的布局都是用共通性的,所以可以把每个模块内公用部分抽离成可复用的组件,比如说header、footer、Nav
  • 为每一个模块建立一个layout.js文件,内容包括模块内公共组件引用,和模块内子路由配置
  • 然后在App.js里面引入这模块文件的布局文件

2.路由鉴权组件

/*  定义 */
import React, { Component } from 'react'
import { withRouter } from 'react-router'
import { Route, Redirect } from 'react-router-dom'
import { inject, observer } from 'mobx-react';
@inject('appState')
@observer
class AuthorizedRoute extends Component {
    render() {
        const { component: Component, ...rest } = this.props;
        const isLogin = !!JSON.parse(window.localStorage.getItem("userInfo"));
        const userRole = JSON.parse(window.localStorage.getItem("userInfo")).role : 'user';
        const path = this.props.path.substring(1);
        const { authList } = this.props.appState;
        return (
            <Route {...rest} render={props => {
                return isLogin
                    ? (authList[path].includes(userRole) ? <Component {...props} /> : <Redirect to="/notAuth" />)
                    : <Redirect to="/login" />
            }} />
        )
    }
}
export default withRouter(AuthorizedRoute);


/*  引用 */
const AuthorizedRoute = lazy( () => import('router/auth'));  //react懒加载
class App extends Component{
  render(){
    return (
      <div className="App">
        <HashRouter>
          <Suspense fallback={PageLoading}>  //在Suspense组件中渲染lazy组件,我们可以在等待加载lazy组件时做优雅降级(如loading指示器)
              <Switch>
                <Redirect path='/' exact to="/show" />
                <AuthorizedRoute  path="/show" component={ShowHomeLayout} />
                <AuthorizedRoute  path="/back" component={BackHomeLayout} />
                <Route path="/login" component={Login}></Route>
                <Route path="/notAuth" component={NotAuth}></Route>
                <Route component={NotFound} />
              </Switch>
          </Suspense>
        </HashRouter>
        
      </div>
    );
  }
}
export default App;

3.路由带参数(/:id)

如果整个页面的数据依赖一个id,那么最好把id作为路由的参数。

原因:
1.要考虑用户复制当前url在新窗口打开的情况
2.要考虑用户刷新页面的情况

一个新的问题:
路由带参数会有一个极端情况,就是用户在这个导航上再点击一下,参数就会变成id= 'undefined'
(注: 路由参数会转变成字符串型)
解决方法: 
  if (_routerParam['id'] === 'undefined' || _routerParam['id'] === undefined) {
      //重新获取id相关数据
  }

4.canvas导出图片模糊

方法: canvas.toDataURL('image/jepg',1),这种jepg格式可以设置图片质量,将质量设置为1,可以变清晰。(虽然可以选择以jpg or png格式导出,但实际上都是jpeg格式,目前还没找到更好的办法( ╯□╰ ))

引用canvas2image.js,可在github上找到。
function getDataURL(canvas, type, width, height) {
	canvas = scaleCanvas(canvas, width, height);
	//return canvas.toDataURL(type,1);原
	return canvas.toDataURL('image/jpeg', 1);//改
}

5.antd-menu组件要保留“展开”和“选中”状态

  • 这一块处理起来比较麻烦,要保证刷新,新窗口打开都不出问题;另一方面,还要考虑折叠之后、折叠又展开的状态。

  • 思路: 将展开的submenu`s key存在sessionStorage;下次进入再取出;另外注意点击submenu标题时,做去重处理;另外注意折叠之后,子菜单的css。

    selectedKeys: [], //表示当前选中menu-item opendKeys: [], //表示当前展开的submenu

6.对所有可能会超长的文字做溢出省略操作

给这些需要溢出省略的,赋一个class,需要的带上这个class,并将相同的文字内容,赋给title属性
如:
.sampleNameCon{
    text-overflow: ellipsis;
    overflow: hidden;
    word-break: break-all;
    white-space: nowrap;
  }

7.日期范围统一处理

在这个项目中,有许多类似这样的控制按钮。

其实每一个button最后都会生成一个时间范围: start:xxx-xxxx end: xxx-xxx

封装一下: 
import moment from 'moment';
const culateTimeRange = function (val, unit) {  //数值(1),单位(day)
    let _timeRange = {}
    _timeRange['start'] = moment().subtract(unit, val).format('YYYY-MM-DD HH:mm:ss');
    _timeRange['end'] = moment().format('YYYY-MM-DD HH:mm:ss');
    return _timeRange;
}
export default culateTimeRange;

8.导出文件

将后端返回的数组导出为表(csv),用到一个库叫:saveAs

 var file = new Blob(['\uFEFF' + res.data]);  //\uFFEF是为了避免发生导出文件乱码现象
 saveAs(file, `name.csv`);

9.echarts表单统一处理

因为本项目会有多处的折线图表展示,所以考虑将该部分抽象成一个组件。 用到这个库—— import ReactEcharts from "echarts-for-react"; 有几个点:

  1. 当数据为null,折线会自然断开
  2. 会根据图中数据类型不同(这里表现会单位),做数据溢出处理。对某一种数据,范围0-100,溢出显示0 or 100,但是hover上去的详情要显示真实数据。
  3. 数据视图(静态显示数据)是一个很好的功能,但是样式比较丑,我们可以把它包装成表格。还要注意数据视图的top值,不要露出legend。

10.函数

  1. 在react中,我们在render的return中进行函数绑定时,最好不要在jsx中写箭头函数。因为这相当于定义了一个匿名函数,每一次render都要去定义一次,开销较大。
  2. 在render之外,为了避免this指代错误,最好都以箭头函数的方式写方法

11.react中类似于vue的watch

习惯在vue中用watch,刚从vue迁移到react很不适应。

可以用react的static getDerivedStateFromProps(nextProps,nextState) 和 componentDidUpdate(prevProps,prevState)配合,实现watch的功能。

12.性能优化相关

  • 代码分割react-loadable
import Loadable from 'react-loadable';
<!--加载中效果-->
const PageLoading = ({ isLoading, error }) => {
	if (isLoading) {
		return <Spin
			className="pageLoading"
			size="large"
			spinning={true}
		/>
	} else if (error) {
		return <div className="pageLoadingError">资源加载失败!</div>
	} else {
		return null
	}
}
<!--封装一下-->
const loadComponent = (loader,loading = PageLoading) =>{
    return Loadable({
        loader,
        loading
    })
}
// 路由
const home = loadComponent(() => import('views/show/home'))
export default{
    home
}
  • 代码压缩

    我们用webpack已经将代码压缩过了,但是如果开启gzip压缩,可以再压缩一半。

    开启gzip需要前后端一起配合。

    前端RequeshHeaders开启—— Accept-Encoding: gzip, deflate

    如果后端开启的gzip,可以在Response-Headers中——Content-Encoding: gzip

  • 图片压缩

    可以用tinyPNG对图片进行压缩,当然按照业务场景可以进行按需加载和雪碧图

    不考虑兼容性的话,推荐用谷歌新出的webp格式的图片,小而美

总结

第一次用react进行项目开发,学习时间很短,甚至react官网文档我都没读完。从框架搭建到一些详细的业务部分,这只是大概,我会抽空写个更详细的业务版。