useMemo与useCallback最佳实践
为什么要使用 useMemo 和 useCallback?
useMemo(与 useCallback)的使用有两个出发点:
- 避免组件的重复渲染。
- 缓存一些复杂逻辑的计算结果,避免重复计算。
避免组件的重复渲染
首先明确 react 组件重渲染的触发条件:
- state 发生改变触发组件重渲染;
- 父组件的重渲染触发子组件的重渲染;
- hooks 内部 state 的改变引发组件重渲染;
- context 改变;
props 改变,其实不是引起组件重渲染的原因,本质还是父组件的 state 发生了变化,引起的子组件重渲染。除非使用了 memo.
如果想避免一个组件的无效重复渲染的话,既要保证父组件中对一些引用类型的 props 进行 useMemo/useCallback 缓存,还要通过 React.memo 缓存组件,以告诉 React,在决定组件要不要重新渲染之前对比 props 是否发生了改变,如果没变,就不要重新渲染了。这是唯一的场景:
// React.memo缓存组件——父组件渲染触发组件渲染之前,根据prop是否改变来决定是否重新渲染
const PageMemoized = React.memo(Page);
const App = () => {
const value = useMemo(() => [1, 2, 3], []);
const onClick = useCallback(() => {
console.log('Do something on click');
}, []);
return (
// page WILL NOT re-render because value and onClick are all memoized
<PageMemoized onClick={onClick} value={value} />
);
};
缓存昂贵计算
就像 React 官方文档所明确的,useMemo 的初衷在于缓存一些“昂贵”的计算:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b])
但是需要明确的一点是,js 逻辑的运算速度远比我们想象的快,而且很多时候,可能我们选择去通过缓存计算进行优化时,方向可能就已经走偏了,看如下 demo:
// Measure.jsx
function Measure({ before }) {
const renderTime = performance.now()
console.log(`render a button consume ${renderTime - before} ms`)
return <div></div>
}
function App() {
const startTime = performance.now()
// array初始值由小到大排列
const array = new Array(250).fill().map((value, index) => index)
// 冒泡排序O(n²)复杂度排序array数组为由大到小
for (let i = 0; i < array.length; i++) {
for (let j = 0; j < array.length - i - 1; j++) {
if (array[j] < array[j + 1]) {
;[array[j], array[j + 1]] = [array[j + 1], array[j]]
}
}
}
const endTime = performance.now()
console.log(`sort array consume ${endTime - startTime} ms`)
return (
<div>
<button>按钮文字</button>
<Measure before={endTime} />
</div>
)
}
export default App
上面,我们用 performance.now()计算出来应用渲染过程中的两段耗时:第一段时间在 App.jsx 中打印,是把一个 250 规模的数组进行排序,且效果最差(完全反转,耗时最多)情况的耗时;第二段我们借助一个 Measure 组件计算渲染一个只有文本的原生按钮的耗时,在 CPU 放慢 6 倍的情况下,运行结果如下:
可以直观的感受到两个问题:
测试机器为 M1 芯片 macbookPro 内存 16G
js 逻辑的运行速度非常快,远比我们想象得快:在 6 倍缓速的情况下,排序一个数组只用了 3.7 毫秒,而且 250 的数组规模加上低效的算法,应该堪比一些常见的业务场景了,足以说明问题。从数据来看, 与渲染相比,计算的消耗非常小。
所以说,考虑到 useMemo 进行缓存的过程本身也会在渲染初期带来一定的消耗,这必然会影响应用的首屏渲染速度,而且,缓存带来的优化是局部的,但是应用里泛滥的 useMemo 在渲染时却是一个不少的产生了消耗。
我知道你想用,但你先别用
在你准备使用这两个 hook 之前其实可以做:
- 把状态往下移,把可变的部分拆到平行组件里,比如:
<Changed/>
<Expansive/>
确保更新只在 Changed 组件里;
- 把内容往上提,把可变的部分拆到父级组件里,比如:
<Changed>
<Expansive />
</Changed>
在 Changed 变更时只要 props.children 不变化,就不会触发子组件的 re-render
那其他 props 属性呢?也是一样的,这叫 component as prop
<Changed left={<Expansive1 />} right={<Expansive2 />} />
再说两句
useCallback 与 useMemo 只在应用后续的重新渲染中发挥一定的作用;
对于初始渲染它们甚至是有害的,而且只有当每一个 prop 都被缓存,且组件本身也被缓存的情况下,重渲染才能被避免。稍有不慎,前功尽弃。不如简单点,把所有对 props 的缓存都删了吧。 (等到有了性能瓶颈再针对性的加)
把包裹了“纯 js 操作“的 useMemo 也都删了吧。与组件本身的渲染相比,它缓存数据带来的耗时减少是微不足道的,并且会在初始渲染时消耗额外的内存,造成可以被观察到的延迟。