ReRender 问题
在 React 组件树中,组件重新渲染的情况主要有以下几种:
- 当组件的
state或者props发生变化,会触发组件的重新渲染。 - 类组件中,当
setState调用后,组件的render方法也会自动调用,并且会嵌套渲染所有子组件。 - 函数组件中,使用
useState,state改变导致组件重新渲染时,也会嵌套渲染所有子组件。
上述特性暴露出 React 一个典型的性能问题 - reRender 问题。确切的说,是子组件不必要的 reRender 问题。
Context中Provider的value值改变以后,所有对应的Consumer组件也会重新渲染。不过没有reRender问题。
Diff
reRender 问题是问题么,React 不是有 diff 算法么?我之前也有这个误解。
我们可以把重新渲染过程分为两个阶段:
- 阶段一:重新渲染,生成新的虚拟
Dom树。reRender问题发生在这个阶段。 - 阶段二:通过对比新旧虚拟
Dom树。如有差异,更新真实Dom。diff算法作用于这个阶段。
虽然不必要的 reRender 在 diff 的时候没有差异,所以不会更新真实 Dom。但是生成新的虚拟Dom,进行diff计算本身就消耗很多性能。
shouldComponentUpdate
在类组件中,我们一般通过 shouldComponentUpdate 生命周期来阻止 reRender 。shouldComponentUpdate 函数在重渲染时,会在 render() 函数调用前被调用,它接受两个参数:nextProps和nextState,分别表示下一个props和下一个state的值。并且,当函数返回false时候,阻止接下来的render()函数的调用,阻止组件重新渲染,而返回true时,组件照常重新渲染。
class Square extends Component {
shouldComponentUpdate(nextProps, nextState) {
if (this.props.number === nextProps.number) {
return false;
} else {
return true;
}
}
render() {
return <Item>{this.props.number * this.props.number}</Item>;
}
}
实际项目中,要考虑对象引用,复杂数据结构在 === 时候的坑。浅比较可能对比不出深层差异,深比较本身性能消耗也较大。要仔细权衡。
如果你不关心具体数据的变更,想对 props 和 state 整体对比的常规写法如下:
shouldComponentUpdate(nextProps, nextState) {
if (this.props === nextProps && this.state === nextState) {
return false;
} else {
return true;
}
}
这个时候,你可以考虑使用 PurComponent。
React.PurComponent
React.PurComponent “纯”组件,是自带 shouldComponentUpdate 生命周期函数实现的组件。它会在组件重新渲染的时候对 新旧 state 和 props 整体做浅层比较。如果都相等,就阻止重新渲染。
// PurComponent 实现源码
if (this._compositeType === CompositeTypes.PureClass) {
shouldUpdate = !shallowEqual(prevProps, nextProps) || ! shallowEqual(inst.state, nextState);
}
// shallowEqual 源码
const hasOwn = Object.prototype.hasOwnProperty
function is(x, y) {
if (x === y) {
return x !== 0 || y !== 0 || 1 / x === 1 / y
} else {
return x !== x && y !== y
}
}
export default function shallowEqual(objA, objB) {
//在 === 基础上 修复了 NaN 和 +-0 的情况
if (is(objA, objB)) return true
if (typeof objA !== 'object' || objA === null ||
typeof objB !== 'object' || objB === null) {
return false
}
const keysA = Object.keys(objA)
const keysB = Object.keys(objB)
if (keysA.length !== keysB.length) return false
for (let i = 0; i < keysA.length; i++) {
if (!hasOwn.call(objB, keysA[i]) ||
!is(objA[keysA[i]], objB[keysA[i]])) {
return false
}
}
return true
}
因为是浅层比较,一些不合理的写法,会导致 PurComponent中 shallowEqual 无法找出差异。
看下面代码:
const newObj = this.state.obj;
newObj.id = 1;
this.setState({
obj: newObj
})
newObj 和 obj 指向同一个引用地址,shallowEqual 比较结果是 true 阻止了重新渲染。正确的书写方式是通过 clone来定义newObj。
另外,因为是浅层比较,对于复杂数据结构,不建议使用 PurComponent。PurComponent 并不等于高性能,对于 props 经常改变的组件,PurComponent 频繁比较,反而性能会降低。
React.memo
React.memo 为高阶组件。它与 React.PureComponent 非常相似,但只适用于函数组件,而不适用类组件。React.memo 仅检查 props 变更。如果函数组件被 React.memo 包裹。其内部使用了 useState 或 useContext。当 state , context 发生变化时,它仍会重新渲染。
默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。
function MyComponent(props) {
/* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
/*
如果把 nextProps 传入 render 方法的返回结果与
将 prevProps 传入 render 方法的返回结果一致则返回 true,
否则返回 false
*/
}
export default React.memo(MyComponent, areEqual);
useCallback Hook
在函数组件中,我们经常会创建很多函数。还会把函数作为子组件的参数向下传递。那么当组件 reRender 的时候,这些函数每次都会重新定义。
所以我们会有这样的误解:这会不会很消耗性能,是不是应该缓存下来?而 useCallback 的功能就是在函数组件中做函数的缓存来优化性能。
所以很多人陷入一个误区:对于会频繁 reRender 的函数组件,我们定义的函数都应该使用 useCallback 来缓存下来,避免反复定义。
- 一方面,官方文档也指出,在现代浏览器中,创建函数和闭包的性能消耗,只有在个别极端情况下才会有明显差异。* 另一方面,在
javascript中,当组件刷新时,未被useCallback包裹的函数将被垃圾回收并重新定义,但被useCallback所制造的闭包将保持对回调函数和依赖项的引用,不利于垃圾回收。用的越多,反而负担越重。
useCallback 正确的用法是配合 React.memo 来避免渲染成本较高的子组件非必要 reRender 问题。比如这样一个典型的场景:
import React, { useState } from 'react';
const FatherComp = () => {
const [dataA, setDataA] = useState(0);
const [dataB, setDataB] = useState(0);
const onClickA = () => {
setDataA(o => o + 1);
};
const onClickB = () => {
setDataB(o => o + 1);
}
return (
<div>
<A data= {dataA} onClick={(onClickA)}/>
<B data= {dataB} onClick={(onClickB)}/>
</div>
)
}
export default FatherComp;
父组件 FatherComp 中有2个受控子组件,其中组件 A 交互频繁,而导致父组件频繁 reRender。子组件 B 也因此频繁进行不必要的 reRender。如果 B 是一个渲染成本非常高的组件,那就得优化其不必要reRender 的问题。
这个时候,你只是用 React.memo 包裹 B 组件是没有办法阻止其重新渲染的,因为每次 onClickB 都是重新定义的,B 组件的 props 是改变的。这个时候就需要 useCallback 来包裹 onClickB 来达到阻止的效果。
import React, { useState } from 'react';
const FatherComp = () => {
const [dataA, setDataA] = useState(0);
const [dataB, setDataB] = useState(0);
const onClickA = () => {
setDataA(o => o + 1);
};
const onClickB = useCallback(() => {
setDataB(o => o + 1);
},[])
return (
<div>
<A data= {dataA} onClick={(onClickA)}/>
<MemoB data= {dataB} onClick={(onClickB)}/>
</div>
)
}
const MemoB = React.memo(B);
export default FatherComp;
我们抽象 useCallback 的API为:const memoFun = useCallback(arrFun, depsArr);
- arrFun: 必须,是一个函数,首次渲染时,会赋给
memoFun并缓存。 - depsArr:可为空,依赖项,更新渲染时:
- 依赖项没有变化,会直接将上次缓存的
arrFun赋给memoFun(memoFun没有变)。 - 依赖项发生变化,会重新声明一个
arrFun赋给memoFun(memoFun改变了)。
- 依赖项没有变化,会直接将上次缓存的
useMemo Hook
useMemo 这个 Hook,用于函数组件重新渲染时,阻止无用方法的调用(无需重新调用),尤其一些高开销的计算逻辑。它用于局部优化,而不是阻止整个组件 Rerender。
import React, { useMemo, useState } from 'react'
export default function UseMemoPage() {
const [count, setCount] = useState(0)
const [value, setValue] = useState("")
//-----------当前的计算只和count有关------
const expensive = useMemo(() => {
console.log("compute");
let sum = 0;
for (let i = 0; i < count; i++) {
sum += i
}
return sum;
// 只有 count 发生改变的时候,才执行这个方法
}, [count])
//-------------重点!!局部优化---------
return (
<div>
<h3>UseMemo</h3>
<p>count:{count}</p>
<p>expensive:{expensive}</p>
<button onClick={() => setCount(count + 1)}>add</button>
<input value={value} onChange={event => setValue(event.target.value)} />
</div>
)
}
把 局部高开销计算封装成一个函数 , 和 依赖项数组 一起作为参数传入 useMemo,返回一个 memoized 值 。重新渲染时,它仅会在某个依赖项改变时才重新调用函数计算 memoized 值。
传入
useMemo的函数会在渲染期间执行。请不要在这个函数内部执行与渲染无关的操作,诸如副作用这类的操作属于useEffect的适用范畴,而不是useMemo。所有计算函数中引用的值都应该出现在依赖项数组中。如果没有提供依赖项数组,
useMemo在每次渲染时都会计算新的值,便没有任何意义。依赖项数组不会作为参数传给计算函数。
保险起见,先编写在没有
useMemo的情况下也可以执行的代码。之后再在你的代码中添加useMemo,以达到优化性能的目的。
总结
shouldComponentUpdate生命周期 和React.PurComponent用于类组件优化reRender。React.memo和useCallback用于函数组件优化reRender性能问题。适用于组件树中父组件交互频繁,而自身props较少修改而渲染消耗较大的子组件。useMemo用于局部包含复杂计算逻辑方法优化,相对其他API,更灵活,更放心。- 以上
API本身存在额外性能消耗。所以很多时候,reRender问题不一定要优化。 - 别不小心阻止了必要
reRender的情况。