前言
历时三个月,终于和小伙伴们 “肝” 完了一阶段的项目,收获还是挺多的。但是由于这是我们第一次在正式项目中完全使用 React
进行开发,前期项目搭建的时候没有足够的经验,导致我们在进行项目优化的时候遇到了一些局限性,只能选择一些退而求其次的方式。
下面我就根据这次的整个开发过程进行一次梳理和 “补缺”,希望它能对你也同样有所帮助,文中如果有不对或者不足之处还请大佬们不吝指教(本文主要讲中后台项目的搭建过程)。
另外现在前端各方面的技术迭代实在有点快,不同版本之间的一些写法都不尽相同,所以我会把当前我使用的依赖的版本都进行一个标注,以便区分。
准备
1. 初始化项目
npm 从 5.2 版开始,增加了 npx 命令,而 node 自带 npm,这里就默认大家都装过 node 啦(作为前端应该不存在没安装的情况吧)。
npx create-react-app react-demo
或
npm install -g create-react-app
create-react-app react-demo
2. 安装必要的依赖(可选)
-
react-router-dom
提供路由功能。
// react-router-dom@5.2.0 npm install react-router-dom
-
redux
、react-redux
、redux-thunk
redux
:管理程序状态(数据状态)。react-redux
:react-redux
是redux
的作者封装的一个react
专用的库。redux-thunk
:让原本只能接受对象的store.dispatch
变成可以接收对象/方法,并且如果接收了一个方法后自动执行该方法,而不触发redux
的store
更新。// redux@4.0.5、react-redux@7.2.1、redux-thunk@2.3.0 npm install redux react-redux redux-thunk
-
redux-devtools-extension
store
数据管理调试工具。// redux-devtools-extension@2.13.8 npm install redux-devtools-extension
-
immer
、use-immer
immer
:实现 js 的不可变数据结构。use-immer
:提供useImmer
方法。// immer@7.0.9、use-immer@0.4.1 npm install immer use-immer
-
react-app-rewired
、customize-cra
、react-app-rewire-multiple-entry
react-app-rewired
:是修改 CRA 配置的工具,提供在不暴露项目配置的情况,修改项目配置的功能。customize-cra
:提供帮助方法,用于修改webpack
配置。react-app-rewire-multiple-entry
:添加多页入口。// react-app-rewired@2.1.6、customize-cra@1.0.0、react-app-rewire-multiple-entry@2.2.0 npm install react-app-rewired customize-cra --save-dev
-
dotenv-cli
将
.env
文件中的环境变量加载到process.env
。// dotenv-cli@4.0. npm install dotenv-cli --save-dev
-
less
、less-loader
less
:CSS
预处理语言。less-loader
:webpack
将less
编译成css
的loader
。// less@3.12.2、less-loader@7.0.1 npm install less less-loader --save-dev
多环境配置
在开发过程中,项目会存在多个环境:开发、测试、uat、生产等,我们会根据各个环境对项目进行部署,这个时候就需要通过 dotenv
将对应环境的 .env
文件中的环境变量加载到 process.env
中,从而实现多环境配置(官网)。
-
在项目根目录下分别新建以下文件,并添加环境变量
BASE_URL
:.env
:用来设置一些公共的配置。.env.development
:开发环境配置,添加变量:REACT_APP_BASE_URL=development.api.com
.env.test
:测试环境配置,添加变量:REACT_APP_BASE_URL=test.api.com
.env.production
:生产环境配置,添加变量:REACT_APP_BASE_URL=production.api.com
-
修改
package.json
文件中的scripts
:
"scripts": {
"start": "react-app-rewired start",
"build:dev": "dotenv -e .env.development react-app-rewired build",
"build:test": "dotenv -e .env.test react-app-rewired build",
"build:prod": "dotenv -e .env.production react-app-rewired build",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},
-
在根目录下新建
config-overrides.js
文件。当我们通过
react-app-rewired
对项目进行运行/打包的时候,会先读取config-overrides.js
中的相关配置,对原有的webpack
配置规则进行修改,然后再进行打包。这里我们就先创建一个空的config-overrides.js
文件,后面再根据项目需要进行配置。 -
测试
通过上面的配置,我们可以通过
process.env
访问配置中的环境变量,并且根据不同的编译命令,运行/打包各环境的代码。执行
npm start
,启动项目。在App.js
中打印process.env.REACT_APP_BASE_URL
,我们可以看到控制台输出了development.api.com
(其他环境可以自行测试,这里就不赘述了。另外,如果修改环境变量的文件或config-overrides.js
中的内容,是需要重新执行npm start
才会生效哦)。
多入口配置
由于我们的项目分了很多个端(官网、两个 PC 中台、一个 PC 后台、一个移动端后台)。对于后台来说,在 PC 端和移动端都有的情况下(移动端就是 PC 版的阉割版本),一些帮助方法、业务逻辑等都是一致的,所以我们将这两个端放在同一个项目下进行开发。
为了实现这样的目的,我们需要对原有的 webpack
配置进行扩展,让它支持多入口。
1. 添加移动端相关的文件
在项目根目录下添加相关文件:
index-m.js
:移动端入口文件。public/mobile.html
:移动端html
模板文件。AppMobile.js
:添加移动端的相关配置构成组件,在index-m.js
中进行引入。
而原有的 index.js
、public/index.html
、App.js
则作为 PC 端的相关文件。
修改 config-overrides.js
:
const { override } = require('customize-cra')
const multipleEntry = require('react-app-rewire-multiple-entry')([
{
entry: 'src/index-m.js',
template: 'public/mobile.html',
outPath: '/mobile.html'
}
])
const customWebpack = () => config => {
multipleEntry.addMultiEntry(config)
return config
}
module.exports = {
webpack: override(
customWebpack(),
)
}
这里我们就已经将移动端的入口文件添加到原有的 webpack
配置中了,下面进行一下测试(可以分别在 App.js
和 AppMobile.js
中添加一些内容,只要方便自己区分是 PC 端还是移动端即可)。
重新执行 npm start
,分别访问 http://localhost:3002/index.html
和 http://localhost:3002/mobile.html
(这里大家根据自己项目运行的端口访问)。结果一切都是那么美好~。
2. 设置代理
但是问题来了,我们最初的目的是希望区分多个端,而不是将其变成多页应用,其本质还是单页应用,要怎么办呢?
不要急,慢慢来~
我们现在是通过 index.html
和 mobile.html
的后缀来进行区分的,如果可以改用端口或域名区分,那是不是就可以解决这个问题呢,我的思路是通过 Nginx
配置代理(Nginx
相关基础知识可以参照:Nginx基础知识入门,从零到一的实践),根据不同的端口访问不同的端。心动不如行动,尝试一下~
添加 Nginx
配置,并重启 sudo nginx -s reload
:
server {
listen 8888;
server_name localhost;
location / {
proxy_pass http://127.0.0.1:3002;
}
}
server {
listen 8889;
server_name localhost;
location ~ ^[^.]*$ {
rewrite /(.*) /mobile.html break;
proxy_pass http://127.0.0.1:3002;
}
location / {
proxy_pass http://127.0.0.1:3002;
}
}
访问 http://localhost:8888/
和 http://localhost:8889/
,看着好像没问题,but...
3. 修改 websocket
就在我暗自高兴的时候,现实狠狠地 “敲” 了我一下。
打开控制台,我看到了如下图所示的报错信息(为什么一个是 404,一个是 200,我也不知道,有知道的大佬还请赐教):
这个错误导致的结果就是热更新失效,所有的修改,都需要手动刷新,而且,所有的警告全部失效,这肯定是不能接受的(毕竟在 react
的开发过程中,相关警告的作用还是很大的)。
首先我们看一下 network
中 sockjs-node
请求的相关信息:
连接失败的原因就在这里,因为我们的项目实际运行是在 3002
端口上的,而当我们使用 Nginx
设置了代理后,我们访问的是 8888
端口和 8889
端口。所以,我们只需要保持发送 sockjs-node
请求的端口还是 3002
就能解决这个问题了。
也就是说,我们需要确认在创建 websocket
连接的时候,是怎么设置的 url
,进而将其设置为我们想要的值。
找到原因和解决方案之后,可以舒一口气了(至少有了方向)。下面我们看一下具体报错的位置(点击控制台输出的错误信息 webpackHotDevClient.js:60
):
看到这里是不是有种恍然大悟的感觉呢,成功离我们已经不远啦!从这段代码我们可以知道,如果设置了 WDS_SOCKET_HOST
和 WDS_SOCKET_PORT
,则直接取对应的值;如果没有设置,则取当前的访问域名和端口。最终拼接为 websocket
连接的 url
。
所以,我们只需要在环境配置里面指定 WDS_SOCKET_HOST
和 WDS_SOCKET_PORT
为我们想要的域名和端口就可以解决这个问题了。在 .env.development
文件中(热更新正常只需要在开发环境中使用)添加以下内容:
WDS_SOCKET_HOST=127.0.0.1
WDS_SOCKET_PORT=3002
重新运行项目 npm start
,分别访问 http://localhost:8888/
和 http://localhost:8889/
,没有报错,大功告成~
UI 组件库
项目中,PC端和移动端分别选择了 Ant Design 和 Ant Design Mobile 作为 UI 组件库。
安装:
// antd@4.6.6、antd-mobile@2.3.4
npm install antd antd-mobile --save
1. antd
在 index.js
中引入 antd
的样式文件 import 'antd/dist/antd.less'
。
antd 默认支持基于 ES modules 的 tree shaking,对于 js 部分,直接引入
import { Button } from 'antd'
就会有按需加载的效果。
2. antd-mobile
目前 antd-mobile
还需要手动实现按需加载。
修改 config-overrides.js
文件(官网):
const { override, fixBabelImports } = require('customize-cra')
// ...
module.exports = {
webpack: override(
customWebpack(),
fixBabelImports('import', {
libraryName: 'antd-mobile',
style: true
})
)
}
重新运行项目,然后只需从 antd-mobile
引入模块即可,无需单独引入样式。
3. 新增 less 支持和定制主题
新增 less 支持
完成上面两步后,此时引用组件进行测试会发现,组件样式没有起作用。这是因为 CRA 创建的项目,默认是不支持编译 less 文件的,所以我们需要通过 config-overrides.js
扩展 webpack
的相关配置。(这里是需要安装 less
和 less-loader
的,前面已经安装过的,这里就跳过啦)(另外最近 Antd
官网也在一直更新,原先官网的例子是 addLessLoader
,现在改成了引入 craco-less
来帮助加载 less 样式和修改变量,大家可以都尝试一下哦)。
完整的 config-overrides.js
:
const { override, addLessLoader, fixBabelImports } = require('customize-cra')
const multipleEntry = require('react-app-rewire-multiple-entry')([
{
entry: 'src/index-m.js',
template: 'public/mobile.html',
outPath: '/mobile.html'
}
])
const customWebpack = () => config => {
multipleEntry.addMultiEntry(config)
return config
}
module.exports = {
webpack: override(
customWebpack(),
addLessLoader({
lessOptions: {
javascriptEnabled: true,
modifyVars: {
}
}
}),
fixBabelImports('import', {
libraryName: 'antd-mobile',
style: true
})
)
}
重新运行项目,对应的组件样式就可以生效了。
定制主题
在上面的配置中,我们可以通过 modifyVars
修改 antd
和 antd-mobile
的主题样式,例如:主题色、文本颜色、圆角等。
样例:
// ...
addLessLoader({
lessOptions: {
javascriptEnabled: true,
modifyVars: {
'@primary-color': '#4085F5',
'@text-color': 'rgba(0, 0, 0, 0.65)',
'@brand-primary': '#4085F5',
'@fill-body': '#F7F8FA',
'@color-text-base': '#333333',
},
}
})
//...
CSS Module
使用 [name].module.css
文件命名约定支持 CSS Module
和常规 CSS 。 CSS Module
允许通过自动创建 [filename]\_[classname]\_\_[hash]
格式的唯一 classname 来确定 CSS 的作用域。
项目中我们使用了 Less 作为预处理器,而让其支持 CSS Module
需要修改 webpack
的配置。值得庆幸的是,上面我们使用了 react-app-rewired
的形式修改 CRA 的相关配置,同时也使用了 addLessLoader
方法来进行一些扩展。而 addLessLoader
默认已经帮我们修改了 webpack
中的相关 less-loader
的配置,所以我们可以直接使用 [name].module.less
。
我们可以简单看一下 addLessLoader
这部分的源码:
- 默认处理后的样式名,格式为
[local]--[hash:base64:5]
; - 通过两个正则来区分
.less
和.module.less
两种类型。
下面我们就直接进入测试。
新建 style/base.module.less
:
.baseContainer {
padding: 50px;
.title {
color: pink;
}
.fontSize {
font-size: 20px;
}
}
修改 App.js
:
import React from 'react'
import style from './style/base.module.less'
function App() {
return (
<div className={style.baseContainer}>
<div className={`${style.title} ${style.fontSize}`}>App</div>
</div>
)
}
export default App
查看运行结果:
更详细的使用方法参照官网:传送门。
路由
1. 静态路由配置
用过 Vue
的同学都知道,在 Vue
里面,vue-router
为我们提供了许多 便利,像静态路由配置和导航守卫等。而在 React
里,这些都需要我们自己手动实现。
React
静态路由配置的依赖在 github 上已经有大佬为我们提供了:react-router-config
。
安装 react-router-config
,这是一个 React
路由器的静态路由配置帮助程序。
// react-router-config@5.1.1
npm install react-router-config --save
它提供了两个方法:matchRoutes
和 renderRoutes
,我们主要看一下 renderRoutes
。
先看源码(node_modules/reactrouter-config/modules/renderRoutes.js
):
import React from "react";
import { Switch, Route } from "react-router";
function renderRoutes(routes, extraProps = {}, switchProps = {}) {
return routes ? (
<Switch {...switchProps}>
{routes.map((route, i) => (
<Route
key={route.key || i}
path={route.path}
exact={route.exact}
strict={route.strict}
render={props =>
route.render ? (
route.render({ ...props, ...extraProps, route: route })
) : (
<route.component {...props} {...extraProps} route={route} />
)
}
/>
))}
</Switch>
) : null;
}
export default renderRoutes;
源码的内容很简单,就是将 routes
进行 map
处理。可能有同学会有疑问:为什么我们要使用这种方式加载路由呢?我的理解是,让我们的程序更简洁、更可控。
更简洁:可以模拟 Vue
中路由的写法,实现静态路由配置,路由结构清晰明了;同时当项目中有多种布局时,我们可以更清晰地书写路由和注册路由。
更可控:在 React
里面,相信大家都遇到过内存泄露的问题,举一个最常见的例子:页面跳转的时候,接口请求未结束,同时在回调里面执行了页面相关的操作。这个时候就好产生内存泄露的问题。关于这个问题,我的思路是在页面跳转时,取消未结束的请求(具体还要看业务情况)。而 “取消” 的这一动作我们可以放在 renderRoutes
中进行(在我看来就是导航守卫了)。
新建 route/renderRoutes.js
,将依赖包中的源码复制进去,这样我们就可以根据需要进行扩展了。
新建 route/index.js
:
const routes = [
{ path: '/login', exact: true, component: Login},
{ path: '/404', component: NotFound},
{
path: '/goods', component: GoodsLayout,
routes: [
{ path: '/goods/list', component: GoodsList}
]
},
{
component: BasicLayout,
routes: [
{ path: '/', exact: true, component: Home},
{ path: '/home2', exact: true, component: Home2 },
{ path: '/home3', exact: true, component: Home3 }
]
}
]
到这里,静态路由配置已经完成了。关于使用方式,具体可以参照 github 上 react-router-config
的介绍。
2. 扩展
有了上面 renderRoutes.js
之后,我们就对路由的大部分功能进行统一实现和管理了,例如路由鉴权、取消请求等。
简单看一下路由鉴权的实现,修改 renderRoutes.js
:
import React from 'react'
import { Switch, Route } from 'react-router'
function renderRoutes(routes, authed, extraProps = {}, switchProps = {}) {
return routes ? (
<Switch {...switchProps}>
{routes.map((route, i) => (
<Route
key={route.key || i}
path={route.path}
exact={route.exact}
strict={route.strict}
render={props => {
if (route.auth && !route.auth.includes(authed)) {
// 进行拦截
}
return route.render ? (
route.render({ ...props, ...extraProps, route: route })
) : (
<route.component {...props} {...extraProps} route={route} />
)
}
}
/>
))}
</Switch>
) : null
}
export default renderRoutes
这里是通过传入 authed
(当前用户的权限),在 render
方法中进行判断:如果该用户具备权限进入目标页面的权限,则正常跳转,否则进行拦截并作出对应的处理。
至于真实情况下的路由鉴权应该是怎么判断的,还需要业务逻辑进行设计。
Axios 取消请求
在 react
里面,相信很多同学都遇到过内存泄露的问题,主要是由于在页面卸载之后,执行了状态更新。比较典型的就是异步请求回调之后执行了 setData
相关的操作。
目前项目中我们使用的比较多的是 axios
, axios
为我们提供了两种取消方式。直接看文档:
从文档中,我们可以知道第一种方式可以取消多个请求,而第二个只能取消某一个。所以这里就先直接选择第一种(个人觉得实际应用中大部分时候都是取消多个请求,所以主要看第一种)。
1. 简单封装 axios
安装:
// axios@0.19.2、express@4.17.1
npm install axios express --save
新建 server/index.js
:
先准备两个接口,并设置 2s 延迟,便于测试。
const express = require('express')
const app = express()
app.all('*', function (req, res, next) {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With , yourHeaderFeild');
res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS');
if (req.method == 'OPTIONS') {
res.send(200)
} else next()
})
app.get('/api/test', function (req, res) {
setTimeout(() => {
res.send('测试结果')
}, 2000)
})
app.post('/api/test2', function (req, res) {
setTimeout(() => {
res.send('测试结果')
}, 2000)
})
let server = app.listen(9999, function () {
let host = server.address().address
let port = server.address().port
console.log('访问地址为:', host, port)
})
新建 utils/request
:
import axios from 'axios'
const service = axios.create({
baseURL: 'http://127.0.0.1:9999',
timeout: 20000
})
service.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
service.interceptors.response.use(
(response) => {
return response
},
(error) => {
return Promise.reject(error)
}
)
export default service
新建 model/index.js
:
import fetch from '../utils/request.js'
class Model {
api(options = {}) {
if (!options.method) options.method = 'get'
return new Promise((resolve, reject) => {
let request
let config = {
method: options.method,
url: options.url,
}
switch (options.method) {
case 'get':
request = fetch({
...config,
params: options.params
})
break
case 'post':
request = fetch({
...config,
data: options.data
})
break
default:
}
request
.then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
}
get (options = {}) {
options.method = 'get'
return this.api(options)
}
post (options = {}) {
options.method = 'post'
return this.api(options)
}
}
export default Model
新建 model/common.js
:
import Model from './index'
class Common extends Model {
getTest(options = {}) {
options.url = '/api/test'
return this.get(options)
}
getTest2(options = {}) {
options.url = '/api/test2'
return this.post(options)
}
}
const commonModel = new Common()
export default commonModel
使用:
import React, { useState } from 'react'
import { Button } from 'antd'
import commonModel from '@/model/common'
function Index2() {
const [test, setTest] = useState(0)
const sendRequest = () => {
commonModel.getTest().then((data) => {
setTimeout(() => {
setTest(1)
}, 1000)
})
commonModel.getTest2().then((data) => {
setTest(123)
})
}
return (
<div>
<div>概览2</div>
<div>{test}</div>
<Button onClick={sendRequest}>send request</Button>
</div>
)
}
export default Index2
点击按钮发送请求,页面显示 test
值先为 123,后为 1,则 axios
配置成功。
模拟一下内存泄露:打开控制台,点击按钮发送请求,两秒之内跳转其他页面,我们可以看到以下错误:
这是因为当我们跳转页面时,上一个页面的请求没有结束,且触发了状态更新,然后导致了内存泄漏。下面我们就来解决这个问题。
2. axios.CancelToken
修改 utils/request.js
:
// ...
const getCancelToken = () => {
let CancelToken = axios.CancelToken
let source = CancelToken.source()
return source
}
// ...
service.interceptors.request.use(
(config) => {
config.cancelToken = config.cancel_token
return config
},
(error) => {
return Promise.reject(error)
}
)
// ...
export {
getCancelToken
}
修改 model/index.js
:
// ...
return new Promise((resolve, reject) => {
let request
let config = {
method: options.method,
url: options.url,
}
if (options.cancel_token) config.cancel_token = options.cancel_token
switch (options.method) {
//...
修改页面:
import React, { useEffect, useRef, useState } from 'react'
import { Button, Space } from 'antd'
import commonModel from '@/model/common'
import { getCancelToken } from '@/utils/request'
function Index2() {
const source = useRef(getCancelToken())
const [test, setTest] = useState(0)
useEffect(() => {
return () => {
source.current.cancel('取消请求')
source.current = null
}
}, [])
const sendRequest = () => {
commonModel.getTest({
cancel_token: source.current.token
}).then((data) => {
setTimeout(() => {
setTest(1)
}, 1000)
})
commonModel.getTest2({
cancel_token: source.current.token
}).then((data) => {
setTest(123)
})
}
return (
<div>
<div>概览2</div>
<div>{test}</div>
<Space>
<Button onClick={sendRequest}>send request</Button>
</Space>
</div>
)
}
export default Index2
重复上面的测试过程,我们会发现控制台打印了两次:“取消请求”,同时内存泄露的问题也没有再发生了。
简单梳理一下思路:
- 给需要 “取消请求” 的方法传入
cancel_token
; - 通过
model/index.js
传给utils/request.js
; utils/request.js
接收cancel_token
,并添加到axios
的请求拦截器的配置中。- 页面卸载时,调用
cancel
方法,取消请求。
如果每个页面都去做这样的处理,那就太麻烦了。是否可以有公共的地方去处理 “取消请求” 呢?
答案是肯定的~,首先取消请求的动作是在页面卸载(路由发生改变)的时候执行的,结合上面的 renderRoutes
,是不是就可以解决这个问题了呢。
3. 统一处理
思路就是通过一个标识,用于区分在路由切换的时候需要取消请求的 api;对这些标记过的 api 添加 cancel_token
;最后在路由切换时取消这些 api的请求。
新建 utils/global.js
,用于存储 cancel_token
和 cancel
方法。
let global = {
source: {
token: null,
cancel: null
},
timestamp: null
}
const changeGlobal = (key, value) => {
global[key] = value
}
export {
global,
changeGlobal
}
修改 modal/common.js
,通过变量 needCancel
标记哪些接口需要 “取消”。
import Model from './index'
class Common extends Model {
getTest(options = {}) {
options.url = '/api/test'
options.needCancel = true
return this.get(options)
}
getTest2(options = {}) {
options.url = '/api/test2'
options.needCancel = true
return this.post(options)
}
}
const commonModel = new Common()
export default commonModel
修改 modal/index.js
,对标记的接口添加 cancel_token
。
import fetch from '../utils/request.js'
import { global } from '../utils/global'
class Model {
// ...
if (options.needCancel && global.source) config.cancel_token = global.source.token
switch (options.method) {
// ...
修改 route/renderRoutes.js
,
import React from 'react'
import { Switch, Route, Redirect } from 'react-router'
import { getCancelToken } from '@/utils/request'
import { global, changeGlobal } from '../utils/global'
function renderRoutes(routes, authed, extraProps = {}, switchProps = {}) {
return routes ? (
<Switch {...switchProps}>
{routes.map((route, i) => (
<Route
key={route.key || i}
path={route.path}
exact={route.exact}
strict={route.strict}
render={props => {
if (route.auth && !route.auth.includes(authed)) {
// 进行拦截
}
// 生成新的 cancel_token
if (global.source.token && global.source.cancel) global.source.cancel('取消请求')
changeGlobal('source', getCancelToken())
changeGlobal('timestamp', new Date().getTime())
// ...
重复上面的测试过程,大功告成~
总结
事情一件接一件,项目一个接一个,忙的都没时间划水,┭┮﹏┭┮...不过值得庆幸的是,虽然在不停的写业务,但在项目里总归还是有所收获的。忙里偷闲,文章写的有些粗糙,知识点也比较零碎,后面有时间我会尽量完善。小伙伴们有什么建议直接评论区留言噢~
未完待续...