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
的情况。