react render没更新_关于 React 性能优化中 re-render 的那些事

关键词: re-render、不可变数据、浅比较

什么是不必要的(需要优化的) re-render

首先假设下面是一个没有优化 re-render 的 React 组件树 (使用 Component 类组件):

864d2b0cbabfb74ca4d02be446bf3c31.png

根组件 A 维护一个 state:

a: {
        b: {
          e: 0
        },
        c: {
          g: 0
        }
    }

其中 A 的子组件 B 和 C 分别通过 props 从 A 拿到 a.ba.c,B 的子组件 E 通过 props 从 B 拿到 a.b.e,E 和 C 组件会分别渲染 a.b.ea.c。如果我们调用 this.setState() 改变 a.b.e 的值:

this.setState(({a}) => ({
      a: {
        ...a,
        b: {
          e: a.b.e + 1
        }, 
      }
    }))

这样只是改变了 a.b.e,应该只需要重渲染依赖 a.b.e 的组件 E ,以及 E 的 父组件 B 和 A,但是却导致了整棵树包括组件 C 和 F 都重渲染了。组件 C 拿到的 a.c 并没有改变,而组件 F 没有依赖任何 props ,他们都不该发生重渲染。

我们使用 PureComponent 来优化这个 re-render:

  • 对于组件 C,我们可以使用 PureComponent 代替 component即可阻止 C 做不必要的重渲染,PureComponent浅比较新旧 props.a.c的引用,只有比较结果不同才会重渲染,props.a.c引用没有改变所以不会重渲染组件 C。
  • 对于组件 F, 和组件 C 同理使用 PureComponent即可

为什么使用 Component 就不能阻止 C 和 F 重渲染呢,这是因为 Component 默认 shouldComponentUpdate 返回 true,只要父组件 A 、B 重新渲染,子组件 C、F 也会重渲染。而 PureComponent 改写了 shouldComponentUpdate, 使用浅比较分别比较新旧 state 和新旧 props,只要 state 和 props 各自引用没变,组件就不会重渲染。

除了使用 PureComponent,也可以使用 shouldComponentUpdateReact.memo() 来做相同的优化, shouldComponentUpdate 需要我们自定义浅比较逻辑。 React.memo() 第一个参数是我们想要缓存复用的组件,第二个参数是一个比较函数,这个比较函数里我们可以控制 React.memo() 是否需要更新组件,如果比较函数返回 false 就更新组件,如果返回 true 那么 React.memo() 就返回上一次计算出来的组件。如果我们不传入 React.memo() 的第二个参数,默认会使用浅对比,因此我们只要使用 React.memo(C) ,优化效果与组件 C 继承 PureComponent 的写法是一样的 。

需要注意的是我们使用 PureComponent 能成功优化 re-render 的前提是,我们在上面的 this.setState() 方法那种修改状态的写法是正确的,如果使用:

const _e = this.state.a.b.e + 1
     this.setState({
       a: {
         b: {
           e: _e
         },
         c: {
           g: 0
         }
       }
     })

虽然 a.c 的值没变,但是改变了其引用,完全新建了一个 a.c, 尽管我们在组件 C 使用了 PureComponent,还是会引起组件 C 重渲染。

值得一提的是,这样的导致 PureComponent 失效的有几个很生动的场景,父组件传给子组件的 props 尽管没有修改,但是在每一次父组件重渲染的时候都会新建一份,导致子组件也跟着做不必要的渲染,比如下面的组件 B,它把 style 传给子组件 F ,style 的值是不变的,但是每一次 B 更新的时候,子组件拿到的 props.style 都是新的,因此 F 会不必要的更新。

class B extends React.PureComponent {
  render () {
    return <F style={{width:'400px', height: '300px'}}/>
  }
}
/*
或者这样:
class B extends React.PureComponent {
  render () {
    style = {width:'400px', height: '300px'}
    return <F style={style}/>
  }
}
*/

class F extends React.PureComponent {
  render(){
    return <div style={this.props.style}>组件 F</div>
  }
}

这个时候我们可以将 B 中的 style 作为组件的属性初始化,而不是放在 render 里面,导致每一次 B render 都赋值一次 style。像这样就可以避免 F 重渲染了:

class B extends React.PureComponent {
  style = {width:'400px', height: '300px'}
  render () {
    return <F style={this.style}/>
  }
}

class F extends React.PureComponent {
  render(){
    return <div style={this.props.style}>组件 F</div>
  }
}

和这个类似的容易引起 React 组件 re-render 的就是子组件中传入的内联方法用箭头函数定义,组件 B 中的子组件 <F onChange={()=>this.setState({...})}/>,那么组件 B 如果发生了重渲染,onChang 也会重建一个新方法,引起组件 F 发生不必要重渲染。解决方法是把 onChange 方法的定义变成组件 B 的属性初始化:

class B extends React.PureComponent {
  change= () => {
    this.setState({...})
   }
  render () {
    return <F onChange={this.change}/>
  }
}

这样组件 B 重渲染的时候不会新建方法 onChange,所以 F 不会重渲染了。

如果组件 B 写成函数组件:

function B () {
   const change= () => {
    this.setState({...})
   }
   return <F onChange={this.change}/>
}

这时组件 B 重渲染,F 也会跟着重渲染,因为 B 重渲染的时候 change 方法也重建了。我们可以使用 useCallback 来优化:

import {usecallback} from 'react'

function B () {
   const change = useCallback(() => {
    this.setState({...})
   }, [])
   return <F onChange={this.change}/>
}

我们给 useCallback 的第二个参数传入一个空数组,这样每次 B 重渲染,useCallback 返回的 change 方法都是第一次初始化的那个方法,而不是重新计算出来一个新方法,也就不会引起组件 F 重渲染了。

为什么 React 要求 state 是不能直接修改的

在 React 应用中常常利用 不可变数据和 PureComponent (浅比较) 做性能优化。在 React 应用中利用不可变数据的场景有 React 的 setState() 方法,React 要求不能直接修改 state,比如 this.state.a.b.d.e ++,而是应该使用 setState(newState) 传入一个新的 state。因为 state 的更新会让组件更新,而如果每次 state 更新都要更新一次组件,然后再经过一次新旧组件树的 diff,最后才渲染到 DOM,那么最后将产生大量 re-render,每次 re-render 还带着diff , 应用性能可想而知。

React 为了优化组件可能的多次更新带来的多次 DOM 重渲染,内部有一套批量更新 state 的策略,即把处于同一个批量更新阶段的多次 setState 合并为一次,异步地更新 state,所以最后只有一次 re-render。

在 React 这样的设计下,如果我们直接修改 state,那么很有可能被前面的 setState 覆盖掉,因为 setState 可能是异步的。

另外假设有一个组件用的是 PureComponent,如果它的 state 是一个引用类型的数据结构,比如:

a: {
        b: {
          e: 0
        },
        c: {
          g: 0
        }
    }

我们修改 a.b.e 时,就不能这样修改:

const _a = this.state.a
   _a.b.e ++
   this.setState({
     a: _a
   })

这样写组件将不会更新(因为浅比较 state.a 引用没变)。

下面我们列出一些修改 state 的方法,并比较它们的优劣

为了配合 PureComponent 浅比较来优化 re-render,不能直接修改 state,为了不改变原 state,现在写法有以下几种:

1. 使用深拷贝拷贝 state,在副值上修改,再把副值作为新 state 传给 setState:

import _ from 'lodash'

const _a = _.cloneDeep(this.state.a)
_a.b.e ++
this.setState({
   a: _a
})

再来看打印出的新旧 state:

cb56d44a1f60dfaea1a838e72220403a.png

这种方法虽然实现了 state 不可变,但是深拷贝生成新的 state,在断开了修改部分的引用的同时,也断开了没有修改的部分的引用,我们只想要修改 a.b.e, 预期只需要重渲染组件 A、B、E,但是此时组件 C 也重渲染了,因为深拷贝把 a.c 的引用也修改了,PureComponent 的浅比较得出 a.c 改变了,所以就会重新渲染。

所以深拷贝的方法不仅本身耗性能,也可能导致 React 的 PureComponent 性能优化失效。

2. 使用 ...Object.asssing() 等方法解构,只修改需要修改的部分,没有修改的部分会与原来共享引用

this.setState(({a}) => ({
      a: {
        ...a,
        b: {
          ...a.b,
          e: a.b.e + 1
        }, 
      }
 }))

这种写法里,我们返回了新 state,修改了 a.b.e,其他的部分如 a.ca.b 会分别与旧 state 的 a.ca.b 共享引用,不会引起组件 C 重渲染了。但是这种写法容易写出面条代码,半天看不出哪里做了修改。

3. 使用 Immer.js 不可变数据结构的写法来修改 state

immer.js 用于实现 js 的不可变数据结构。什么是不可变数据呢?在操作引用类型的数据的时候,在某些情况下我们可能需要基于它产生一个新的数据,但是这个过程中不会改变原来的数据,这就是不可变数据。

常规写法:

const nextState = produce(this.state, draftState => {       
    draftState.a.b.e ++     
})     
this.setState(nextState)

更简洁的写法:

this.setState(produce(draftState=>{ draftState.a.b.e ++  }))

使用 Immer.js 可以把修改的部分用新的内存来保存,这样修改的时候不会引起原数据对应部分被修改,同时没有修改的部分和原数据保留同一块引用。这样就可以避免 state 中不想要修改的引用类型的数据被修改,从而引起无关组件的不必要的渲染。immer 的这种深层嵌套对象的结构共享特性和 React 的 PureComponent 结合起来可以说是很完美的优化了 re-render。

我们可以验证一下 immer 的这种特性,下面我们定义一个对象 obj,它有两个引用类型的内部对象 obj.aobj.b,我们首先使用 immer 修改 obj.a.e.f,并得到一个新的 newObj,然后不通过 immer 而是直接修改 obj.a.e.fobj.b.c.d,可以从打印出的结果看到,newObj.a.e.f 没有变动,而 newObj.b.c.d 被修改了,说明通过 immer 修改的 newObj.a.e.f 已经和 obj.a.e.f 断开了引用关系,而没有用 immer 修改的 newObj.b.c.d 依然保持和 obj.b.c.d 的引用。

var immer = require("immer")
const obj = {
  a:{ 
   e: {
     f: 1
    }
  }, 
  b:{
    c:{
      d:2
    }
  }
 }

// 使用 immer 修改 obj.a.e.f
const newObj = immer.produce(obj, draftObj=>{draftObj.a.e.f = 0})

console.log({obj, newObj})
/*
newObj: {
  a: {e: {f: 0}}
  b: {c: {d: 2}}
}
obj: {
  a: {e: {f: 1}}
  b: {c: {d: 2}}
}
*/

obj.a.e.f = 7 // 修改已经用 immer 修改过的部分
obj.b.c.d = 3 // 修改没有用 immer 修改过的部分

console.log({obj, newObj})
/*
newObj: {
  a: {e: {f: 0}}
  b: {c: {d: 3}}
}
obj:{
  a: {e: {f: 7}}
  b: {c: {d: 3}}
}
*/
有几个工具帮助优化 React 的 re-render: @welldone-software/why-did-you-render 这个工具可以追踪 React 组件渲染的信息,把导致无关组件不必要重渲染的原因打印到控制台,这样就可以对症下药,对这些地方做优化。还有如果使用 react-devtools,可以打开 Highlight updates when components render这个选项,这样就可以在页面上直观的看到有哪些组件发生了更新,如果更新了组件会闪现一下绿色的边框。

Redux 中的纯函数 reducer 也是需要不可变数据的

Redux 中的 reducer 是一个纯函数,不能执行带有副作用的操作。类似 React 的 setState 一样,我们需要返回一个新的 store 来更新 store,而不是在旧 store 上修改。Redux 也是利用了对象浅比较来优化性能。Redux 中我们需要监听 store 的更新 (store.subscribe(listener)),然后才能通知 UI 更新。这个实现监听的过程中我们需要知道 store 是否发生了改变,如果 store 是一个深度嵌套的对象,那么常规的判断 store 是否改变的方法就是深比较这个 store, 自然这样的方法是很耗性能的。因此 Redux 没有选择这样的方法,而是像 React 一样,保留旧的 store,在 reducer 里返回新的 store,Redux 内部只需要 浅比较 新旧 store 就可以知道 store 是否更新。所以我们会看到 Redux 建议开发者在 reducer 里使用 {...oldStore, ...newStore}Object.assing({}, oldStore, newStore)这样的方法来返回新 store。

这里 详细介绍了 Redux、React-redux 中为何需要不可变数据。下面是从其中抄录的简短的说明:

为什么 Redux 需要不变性?
  • Redux 和 React-Redux 都使用了浅比较。具体来说: Redux 的 combineReducers 方法 浅比较它调用的 reducer 的引用是否发生变化。 React-Redux 的 connect 方法生成的组件通过 浅比较 根 state 的引用变化与 mapStateToProps 函数的返回值,来判断包装的组件是否需要重新渲染。 以上浅比较需要不变性才能正常工作。
  • 不可变数据的管理极大地提升了数据处理的安全性。
  • 进行时间旅行调试要求 reducer 是一个没有副作用的纯函数,以此在不同 state 之间正确的移动。

因此在 reducer 里面更新 store 也需要使用不可变数据,此时我们一样可以使用 immer 来最简洁地优化 reducer。

不使用 immer 的 reducer:

const byId = (state = {}, action) => {
    switch (action.type) {
        case RECEIVE_PRODUCTS:
            return {
                ...state,
                ...action.products.reduce((obj, product) => {
                    obj[product.id] = product
                    return obj
                }, {})
            }
        default:
            return state
    }
}

使用 immer 的 reducer,更简短易懂,只需要关注 state 中需要修改的部分:

import produce from "immer"

const byId = produce((draft, action) => {
    switch (action.type) {
        case RECEIVE_PRODUCTS:
            action.products.forEach(product => {
                draft[product.id] = product
            })
    }
}, {})

Redux 应用中如何避免 re-render

Redux 的整个应用的状态最终都会由一个 store 来管理,然后在组件中,我们可以使用store.getState()的方法获取组件需要的 state,为了在 state 改变后让组件更新,可以把 state 使用 setState() 存到组件的 state,但是这种方法就重复 state 缓存了,所以可以在组件外部包一层容器组件,在容器组件中获取 Redux 的 store,再把 state 取出来通过 props 传给被包组件,这样 state 改变,组件也就更新了。这就是 React-redux 的 connect 做的事情。每一次 store 改变,mapStateToProps 就会重新计算,但是可能组件需要的 state 并没有改变,所以这部分重新计算是不必要的。如果在 mapStateToProps 里面需要执行一些大量衍生数据的计算,那么很可能这部分计算就会影响性能。为了解决这个问题,我们可以使用 reselector 在 mapStateToProps 中缓存第一次的计算结果,以后当根 store 更新的时候,如果需要的 state 没有改变,就复用之前缓存的计算结果,减少那部分衍生数据的计算。

如下我们有两个组件 <Com1/><Com2/> ,他们分别依赖根 store 中的 com1State 和 com2State 来渲染。我们改变 com1State.count 的值,那么自然 <Com1/> 会更新,mapStateToProps 中的 count1 = store.com1State.count + 1 会执行,最终把 count1 渲染到 <Com1/>。对于 <Com2/>, 虽然 com2State.count 没有变,但是根 store 更新了 , 所以 <Com2/> 的 mapStateToProps 中会重新计算 count2 = store.com2State.count + 1 这个过程,虽然这个计算结果和之前一样。如果 count2 = store.com2State.count + 1 换成更加复杂的计算,那么这个不必要的重复计算过程就影响了应用性能。

d37c1a3eff6486402342ea1076edf0f3.png

mapStateToProps 如下:

const getCount = (store) => { 
  const count2 = store.com2State.count + 1 //每一次 store 更新都会执行
  return count2
}

const mapStateToProps = (store) => {
    return {
        count2: getCount(store)
    }
}

现在用 reselector 优化衍生数据的计算逻辑,我们把上面代码改写为如下形式:

import { createSelector } from 'reselect'

const selectCom2Count = createSelector(
  store => store.com2State,
  com2State => { // 衍生数据计算过程,结果会被缓存,只有 com2State 更新才会重新计算
    const count2 = com2State.count + 1
    return count2
  }
)

const mapStateToProps = (store) => {
    return {
        count2: selectCom2Count(store)
    }
}

现在当 store 更新,但是因为 store.com2State 并没有更新,所以不会执行衍生数据计算那一段代码,自然就避免了一部分不必要的计算。

那么 reselector 是怎么判断组件需要的 state 是否更新的呢?这里我们来看一下 reselector 的原理(不带源码):

以下面这段为例:

// selector
const selectCom2Count = createSelector(
  store => store.com2State, // 第一个 inputSelector
  com2State => {  // 第二个 inputSelector
    const count2 = com2State.count + 1
    return count2
  }
)

其中 createSelector 里传入的两个箭头函数我们叫它们 inputSelector 函数,reselector 内部使用闭包来缓存这些 inputSelector 函数的返回值,只有当这些 inputSelector 函数数量改变,或者某个 inputSelector 函数的参数改变的时候才重新执行 inputSelector 函数更新缓存。这时候又用到浅比较了,reselector 会浅比较 inputSelector 函数的参数,比如说上面的第二个 inputSelector 函数, 如果参数 com2State 的引用没有变,就认为它没有更新,于是 count2 就复用上一次计算缓存的结果,不会执行第二个 inputSelector 造成重复计算。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值