笔者是一个 react
重度爱好者,在工作之余,也看了不少的 react
文章, 写了很多 react
项目 ,接下来笔者讨论一下 React 性能优化的主要方向和一些工作中的小技巧。送人玫瑰,手留余香,阅读的朋友可以给笔者点赞,关注一波 。 陆续更新前端文章。
本文篇幅较长,将从
编译阶段 ->
路由阶段 ->
渲染阶段 ->
细节优化 ->
状态管理 ->
海量数据源,长列表渲染
方向分别加以探讨。
一 不能输在起跑线上,优化babel配置,webpack配置为项
1 真实项目中痛点
当我们用create-react-app
或者webpack
构建react
工程的时候,有没有想过一个问题,我们的配置能否让我们的项目更快的构建速度,更小的项目体积,更简洁清晰的项目结构。
随着我们的项目越做越大,项目依赖越来越多,项目结构越来越来复杂,项目体积就会越来越大,构建时间越来越长,久而久之就会成了一个又大又重的项目,所以说我们要学会适当的为项目‘减负’,让项目不能输在起跑线上。
2 一个老项目
拿我们之前接触过的一个react
老项目为例。我们没有用dva
,umi
快速搭建react,而是用react
老版本脚手架构建的,这对这种老的react
项目,上述的问题都会存在,下面让我们一起来看看。
我们首先看一下项目结构。
再看看构建时间。
为了方便大家看构建时间,我简单写了一个webpack,plugin
ConsolePlugin
,记录了webpack
在一次compilation
所用的时间。
const chalk = require('chalk') /* console 颜色 */
var slog = require('single-line-log'); /* 单行打印 console */
class ConsolePlugin {
constructor(options){
this.options = options
}
apply(compiler){
/**
* Monitor file change 记录当前改动文件
*/
compiler.hooks.watchRun.tap('ConsolePlugin', (watching) => {
const changeFiles = watching.watchFileSystem.watcher.mtimes
for(let file in changeFiles){
console.log(chalk.green('当前改动文件:'+ file))
}
})
/**
* before a new compilation is created. 开始 compilation 编译 。
*/
compiler.hooks.compile.tap('ConsolePlugin',()=>{
this.beginCompile()
})
/**
* Executed when the compilation has completed. 一次 compilation 完成。
*/
compiler.hooks.done.tap('ConsolePlugin',()=>{
this.timer && clearInterval( this.timer )
const endTime = new Date().getTime()
const time = (endTime - this.starTime) / 1000
console.log( chalk.yellow(' 编译完成') )
console.log( chalk.yellow('编译用时:' + time + '秒' ) )
})
}
beginCompile(){
const lineSlog = slog.stdout
let text = '开始编译:'
/* 记录开始时间 */
this.starTime = new Date().getTime()
this.timer = setInterval(()=>{
text += '█'
lineSlog( chalk.green(text))
},50)
}
}
构建时间如下:
打包后的体积:
3 翻新老项目
针对上面这个react
老项目,我们开始针对性的优化。由于本文主要讲的是react
,所以我们不把太多篇幅给webpack优化
上。
① include 或 exclude 限制 loader 范围。
{
test: /\.jsx?$/,
exclude: /node_modules/,
include: path.resolve(__dirname, '../src'),
use:['happypack/loader?id=babel']
// loader: 'babel-loader'
}
② happypack多进程编译
除了上述改动之外,在plugin中
/* 多线程编译 */
new HappyPack({
id:'babel',
loaders:['babel-loader?cacheDirectory=true']
})
③缓存babel编译过的文件
loaders:['babel-loader?cacheDirectory=true']
④tree Shaking 删除冗余代码
⑤按需加载,按需引入。
优化后项目结构
优化构建时间如下:
一次 compilation
时间 从23秒优化到了4.89秒
优化打包后的体积:
由此可见,如果我们的react
是自己徒手搭建的,一些优化技巧显得格外重要。
关于类似antd UI库的瘦身思考
我们在做react
项目的时候,会用到antd
之类的ui库,值得思考的一件事是,如果我们只是用到了antd
中的个别组件,比如<Button />
,就要把整个样式库引进来,打包就会发现,体积因为引入了整个样式大了很多。我们可以通过.babelrc
实现按需引入。
瘦身前
.babelrc
增加对 antd
样式按需引入。
["import", {
"libraryName":
"antd",
"libraryDirectory": "es",
"style": true
}]
瘦身后
总结
如果想要优化react
项目,从构建开始是必不可少的。我们要重视从构建到打包上线的每一个环节。
二 路由懒加载,路由监听器
react
路由懒加载,是笔者看完dva
源码中的 dynamic
异步加载组件总结出来的,针对大型项目有很多页面,在配置路由的时候,如果没有对路由进行处理,一次性会加载大量路由,这对页面初始化很不友好,会延长页面初始化时间,所以我们想这用asyncRouter
来按需加载页面路由。
传统路由
如果我们没有用umi
等框架,需要手动配置路由的时候,也许路由会这样配置。
<Switch>
<Route path={
'/index'} component={
Index} ></Route>
<Route path={
'/list'} component={
List} ></Route>
<Route path={
'/detail'} component={
Detail } ></Route>
<Redirect from='/*' to='/index' />
</Switch>
或者用list保存路由信息,方便在进行路由拦截,或者配置路由菜单等。
const router = [
{
'path': '/index',
'component': Index
},
{
'path': '/list'',
'component': List
},
{
'path': '/detail',
'component': Detail
},
]
asyncRouter懒加载路由,并实现路由监听
我们今天讲的这种react
路由懒加载是基于import
函数路由懒加载, 众所周知 ,import
执行会返回一个Promise
作为异步加载的手段。我们可以利用这点来实现react
异步加载路由
好的一言不合上代码。。。
代码
const routerObserveQueue = [] /* 存放路由卫视钩子 */
/* 懒加载路由卫士钩子 */
export const RouterHooks = {
/* 路由组件加载之前 */
beforeRouterComponentLoad: function(callback) {
routerObserveQueue.push({
type: 'before',
callback
})
},
/* 路由组件加载之后 */
afterRouterComponentDidLoaded(callback) {
routerObserveQueue.push({
type: 'after',
callback
})
}
}
/* 路由懒加载HOC */
export default function AsyncRouter(loadRouter) {
return class Content extends React.Component {
constructor(props) {
super(props)
/* 触发每个路由加载之前钩子函数 */
this.dispatchRouterQueue('before')
}
state = {
Component: null}
dispatchRouterQueue(type) {
const {
history} = this.props
routerObserveQueue.forEach(item => {
if (item.type === type) item.callback(history)
})
}
componentDidMount() {
if (this.state.Component) return
loadRouter()
.then(module => module.default)
.then(Component => this.setState({
Component},
() => {
/* 触发每个路由加载之后钩子函数 */
this.dispatchRouterQueue('after')
}))
}
render() {
const {
Component} = this.state
return Component ? <Component {
...this.props
}
/> : null
}
}
}
asyncRouter
实际就是一个高级组件,将()=>import()
作为加载函数传进来,然后当外部Route
加载当前组件的时候,在componentDidMount
生命周期函数,加载真实的组件,并渲染组件,我们还可以写针对路由懒加载状态定制属于自己的路由监听器beforeRouterComponentLoad
和afterRouterComponentDidLoaded
,类似vue
中watch $route
功能。接下来我们看看如何使用。
使用
import AsyncRouter ,{
RouterHooks } from './asyncRouter.js'
const {
beforeRouterComponentLoad} = RouterHooks
const Index = AsyncRouter(()=>import('../src/page/home/index'))
const List = AsyncRouter(()=>import('../src/page/list'))
const Detail = AsyncRouter(()=>import('../src/page/detail'))
const index = () => {
useEffect(()=>{
/* 增加监听函数 */
beforeRouterComponentLoad((history)=>{
console.log('当前激活的路由是',history.location.pathname)
})
},[])
return <div >
<div >
<Router >
<Meuns/>
<Switch>
<Route path={
'/index'} component={
Index} ></Route>
<Route path={
'/list'} component={
List} ></Route>
<Route path={
'/detail'} component={
Detail } ></Route>
<Redirect from='/*' to='/index' />
</Switch>
</Router>
</div>
</div>
}
效果
这样一来,我们既做到了路由的懒加载,又弥补了react-router
没有监听当前路由变化的监听函数的缺陷。
三 受控性组件颗粒化 ,独立请求服务渲染单元
可控性组件颗粒化,独立请求服务渲染单元是笔者在实际工作总结出来的经验。目的就是避免因自身的渲染更新或是副作用带来的全局重新渲染。
1 颗粒化控制可控性组件
可控性组件和非可控性的区别就是dom
元素值是否与受到react
数据状态state
控制。一旦由react的state
控制数据状态,比如input
输入框的值,就会造成这样一个场景,为了使input
值实时变化,会不断setState
,就会不断触发render
函数,如果父组件内容简单还好,如果父组件比较复杂,会造成牵一发动全身,如果其他的子组件中componentWillReceiveProps
这种带有副作用的钩子,那么引发的蝴蝶效应不敢想象。比如如下demo
。
class index extends React.Component<any,any>{
constructor(props){
super(props)
this.state={
inputValue:''
}
}
handerChange=(e)=> this.setState({
inputValue:e.target.value })
render(){
const {
inputValue } = this.state
return <div>
{
/* 我们增加三个子组件 */ }
<ComA />
<ComB />
<ComC />
<div className="box" >
<Input value={
inputValue} onChange={
(e)=> this.handerChange(e) } />
</div>
{
/* 我们首先来一个列表循环 */}
{
new Array(10).fill(0).map((item,index)=>{
console.log('列表循环了' )
return <div key={
index} >{
item}</div>
})
}
{
/* 这里可能是更复杂的结构 */
/* ------------------ */
}
</div>
}
}
组件A
function index(){
console.log('组件A渲染')
return <div>我是组件A</div>
}
组件B,有一个componentWillReceiveProps钩子
class Index extends React.Component{
constructor(props){
super(props)
}
componentWillReceiveProps(){
console.log('componentWillReceiveProps执行')
/* 可能做一些骚操作 wu lian */
}
render(){
console.log('组件B渲染')
return <div>
我是组件B
</div>
}
}
组件C有一个列表循环
class Index extends React.Component{
constructor(props){
super(props)
}
render(){
console.log('组件c渲染')
return <div>
我是组件c
{
new Array(10).fill(0).map((item,index)=>{
console.log('组件C列表循环了' )
return <div key={
index} >{
item}</div>
})
}
</div>
}
}
效果
当我们在input输入内容的时候。就会造成如上的现象,所有的不该重新更新的地方,全部重新执行了一遍,这无疑是巨大的性能损耗。这个一个setState
触发带来的一股巨大的由此组件到子组件可能更深的更新流,带来的副作用是不可估量的。所以我们可以思考一下,是否将这种受控性组件颗粒化,让自己更新 -> 渲染过程由自身调度。
说干就干,我们对上面的input表单单独颗粒化处理。
const ComponentInput = memo(function({
notifyFatherChange }:any){
const [ inputValue , setInputValue ] = useState('')
const handerChange = useMemo(() => (e) => {
setInputValue(e.target.value)
notifyFatherChange && notifyFatherChange(e.target.value)
},[])
return <Input value={
inputValue} onChange={
handerChange } />
})
此时的组件更新由组件单元自行控制,不需要父组件的更新,所以不需要父组件设置独立state
保留状态。只需要绑定到this
上即可。不是所有状态都应该放在组件的 state 中. 例如缓存数据。如果需要组件响应它的变动, 或者需要渲染到视图中的数据才应该放到 state 中。这样可以避免不必要的数据变动导致组件重新渲染.
class index extends React.Component<any,any>{
formData :any = {
}
render(){
return <div>
{
/* 我们增加三个子组件 */ }
<ComA />
<ComB />
<ComC />
<div className="box" >
<ComponentInput notifyFatherChange={
(value)=>{
this.formData.inputValue = value } } />
<Button onClick={
()=> console.log(this.formData)} >打印数据</Button>
</div>
{
/* 我们首先来一个列表循环 */}
{
new Array(10).fill(0).map((item,index)=>{
console.log('列表循环了' )
return <div key={
index} >{
item}</div>
})
}
{
/* 这里可能是更复杂的结构 */
/* ------------------ */
}
</div>
}
}
效果
这样除了当前组件外,其他地方没有收到任何渲染波动,达到了我们想要的目的。
2 建立独立的请求渲染单元
建立独立的请求渲染单元,直接理解就是,如果我们把页面,分为请求数据展示部分(通过调用后端接口,获取数据),和基础部分(不需要请求数据,已经直接写好的),对于一些逻辑交互不是很复杂的数据展示部分,我推荐用一种独立组件,独立请求数据,独立控制渲染的模式。至于为什么我们可以慢慢分析。
首先我们看一下传统的页面模式。
页面有三个展示区域分别,做了三次请求,触发了三次setState
,渲染三次页面,即使用Promise.all
等方法,但是也不保证接下来交互中,会有部分展示区重新拉取数据的可能。一旦有一个区域重新拉取数据,另外两个区域也会说、受到牵连,这种效应是不可避免的,即便react有很好的ddiff
算法去调协相同的节点,但是比如长列表等情况,循环在所难免。
class Index extends React.Component{
state :any={
dataA:null,
dataB:null,
dataC:null
}
async componentDidMount(){
/* 获取A区域数据 */
const dataA = await getDataA()
this.setState({
dataA })
/* 获取B区域数据 */
const dataB = await getDataB()
this.setState({
dataB })
/* 获取C区域数据 */
const dataC = await getDataC()
this.setState({
dataC })
}
render(){
const {
dataA , dataB , dataC } = this.state
console.log(dataA,dataB,dataC)
return <div>
<div> {
/* 用 dataA 数据做展示渲染 */ } </div>
<div> {
/* 用 dataB 数据做展示渲染 */ } </div>
<div> {
/* 用 dataC 数据做展示渲染 */ } </div>
</div>
}
}
接下来我们,把每一部分抽取出来,形成独立的渲染单元,每个组件都独立数据请求到独立渲染。
function ComponentA(){
const [ dataA, setDataA ] = useState(null)
useEffect(()=>{
getDataA().then(res=> setDataA(res.data) )
},[])
return <div> {
/* 用 dataA 数据做展示渲染 */ } </div>
}
function ComponentB(){
const [ dataB, setDataB ] = useState(null)
useEffect(()=>{
getDataB().then(res=> setDataB(res.data) )
},[])
return <div> {
/* 用 dataB 数据做展示渲染 */ } </div>
}
function ComponentC(){
const [ dataC, setDataC ] = useState(null)
useEffect(()=>{
getDataC().then(res=> setDataC(res.data) )
},[])
return <div> {
/* 用 dataC 数据做展示渲染 */ } </div>
}
function Index (){
return <div>
<ComponentA />
<ComponentB />
<ComponentC />
</div>
}
这样一来,彼此的数据更新都不会相互影响。
总结
拆分需要单独调用后端接口的细小组件,建立独立的数据请求和渲染,这种依赖数据更新 -> 视图渲染的组件,能从整个体系中抽离出来 ,好处我总结有以下几个方面。
1 可以避免父组件的冗余渲染 ,react
的数据驱动,依赖于 state
和 props
的改变,改变state
必然会对组件 render
函数调用,如果父组件中的子组件过于复杂,一个自组件的 state
改变,就会牵一发动全身,必然影响性能,所以如果把很多依赖请求的组件抽离出来,可以直接减少渲染次数。
2 可以优化组件自身性能,无论从class
声明的有状态组件还是fun
声明的无状态,都有一套自身优化机制,无论是用shouldupdate
还是用 hooks
中 useMemo
useCallback
,都可以根据自身情况,定制符合场景的渲条
件,使得依赖数据请求组件形成自己一个小的,适合自身的渲染环境。
3 能够和redux
,以及redux
衍生出来 redux-action
, dva
,更加契合的工作,用 connect
包裹的组件,就能通过制定好的契约,根据所需求的数据更新,而更新自身,而把这种模式用在这种小的,需要数据驱动的组件上,就会起到物尽其用的效果。
总结
在下一节我们将继续介绍 React 中的优化细节。