在React框架下,Function Component必须重复地执行昂贵的计算,才能得到最终的render tree。React开发团队也意识到了这个问题,配套给出了一些优化手段,其本质是通过内部缓存来减少重复的代码执行,提高组件的性能。然而:
性能优化总是会有成本,并不总是带来好处。
本文将会来谈谈 useMemo
、useCallback
和memo
的成本和收益,并通过实例给出useMemo
、useCallback
和memo
的正确打开方式。
引例
看一段简单代码:
import React from 'react';
export default function Main() {
const data = ['apple', 'banana', 'pears', 'watermelon', 'coconut', 'mangosteen'];
return (
<div className={'main'}>
<ul>
{data.map((text) => (
<li>{text}</li>
))}
</ul>
</div>
);
}
复制代码
上述代码实现的是一个动态ul
列表,为了“提高”性能,我们引用useMemo
。使用useMemo
包裹data
,这样就可以缓存data
,防止data
重复定义。
import React, { useMemo } from 'react';
export default function Main() {
const data = useMemo(() => {
return ['apple', 'banana', 'pears', 'watermelon', 'coconut', 'mangosteen'];
}, []);
return (
<div className={'main'}>
<ul>
{data.map((text) => (
<li>{text}</li>
))}
</ul>
</div>
);
}
复制代码
所以我的问题是,在这个特定的例子中,哪一个对性能更好?原来的 or 使用 useMemo
后?
如果你选择的是 useMemo
,那么,恭喜你,回答错误。正确答案是:使用原来的代码性能会更好!
???useMemo可是React官方推荐的优化手段,怎么会引起性能下降呢?
接下来就好好讨论一下这个问题...
useMemo
先看一下定义:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
复制代码
useMemo
will only recompute the memoized value when one of the dependencies has changed. This optimization helps to avoid expensive calculations on every render.
useMemo只会在其中一个依赖项发生变化时重新计算记忆的值。这种优化有助于避免每次渲染的昂贵计算。
useMemo是一个React钩子,用来记忆函数的输出。useMemo接受两个参数:一个函数和一个依赖列表。useMemo将调用该函数并返回其返回值。因此,useMemo
的优化思路就是引用缓存。
看一下useMemo
的源码,
useMemo
的实现在mount阶段和update阶段是不同的,其中mountMemo<T>
在组件mount阶段执行:
function mountMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null,
): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
复制代码
updateMemo<T>
在组件update阶段执行:
function updateMemo<T>(
nextCreate: () => T,
deps: Array<mixed> | void | null,
): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
// Assume these are defined. If they're not, areHookInputsEqual will warn.
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
const nextValue = nextCreate();
hook.memoizedState = [nextValue, nextDeps];
return nextValue;
}
复制代码
从上述代码可知无论是在mountMemo<T>
还是updateMemo<T>
内部都存在依赖比较和引用更新的逻辑,这些逻辑的执行需要消耗时间吗?答案是肯定的!这是肯定会有人说useMemo
会记忆上一次cb(回调函数)运算的值啊,可以减少重复计算。
那如果该重复计算的耗时小于useMemo
本身进行Equals
判断的耗时呢?是不是就会出现因为使用useMemo
而增加了耗时的情况。下面用一个详细例子来进行分析:
// T1
const [count, setCount] = useState(0);
// T2
const add = () => {
setCount((prev) => prev + 1);
};
复制代码
上面代码定义了一个count
,其中add
函数通过调用setCount
让count
加1。假设定义count
耗时T1,定义add
耗时T2,那么上面代码的总耗时是T1+T2。
直接定义耗时:T1+T2
接下来使用useMemo
进行改写:
const [count, setCount] = useState(0);
const memoAdd = useMemo(() => {
return () => {
setCount((prev) => prev + 1);
};
}, []);
复制代码
上面代码定义了一个count
,其中memoAdd函数是一个用useMemo
包裹的函数,用来让count
加1。上面这段代码等价于下面的写法:
// T1
const [count, setCount] = useState(0);
// T2'
const add = () => {
return () => {
setCount((prev) => prev + 1);
};
};
// T3
const memoAdd = useMemo(add, []);
复制代码
改写后跟第一版未使用useMemo
的写法相比不仅有T1,T2'这两部分的耗时,还增加useMemo内部计算的耗时T3。
引入useMemo耗时:T1+T2'+T3
哪个耗时较大?即:Max(T1+T2, T1+T2'+T3) = ?
;
表达式两边去掉同样的耗时T1,所以变为 Max(T2, T2'+T3) = ?
,假设无论函数复杂度多少,js定义函数的耗时都是一样的,即有 T2 === T2'
;
又因为 T3 > 0
,所以得到 Max(T1+T2, T1+T2'+T3) = T1+T2'+T3
,即T1+T2'+T3 > T1+T2
。
使用useMemo
后代码整体耗时增加了!
当然上面的例子为了更加显著的对比,而采用了useMemo
直接返回结果的做法,因此cb
函数本身的运行耗时被抹平了。但这恰恰也是大量开发者在实际开发过程中犯的错误。明明是想获取cb里直接return的计算结果,中间没有任何额外的复杂运算,还喜欢用useMemo
进行包裹,其结果是费力不讨好,事倍功半!
useCallback
说完了useMemo
,再看一下useCallback
。
const memoizedCallback = useCallback(
() => {
doSomething(a, b);
},
[a, b],
);
复制代码
useCallback
只能返回一个callback function。
useCallback(fn, deps)
is equivalent touseMemo(() => fn, deps)
.
根据官方文档,可以知道useCallback
其实就是特异化的useMemo
。
看一下useCallback
的源码,同样分为mount阶段实现和update阶段实现,mountCallback<T>
在组件mount阶段执行:
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps];
return callback;
}
复制代码
updateCallback<T>
在组件update阶段执行:
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (prevState !== null) {
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}
复制代码
可以发现useCallback
的实现跟useMemo
非常相似,这也就意味着如果获取cb的耗时小于useCallback
本身引用相等判断的耗时,那么使用useCallback
不能带来性能提升,反而会增加性能损耗。
关键点
Performance optimizations are not free. They ALWAYS come with a cost but do NOT always come with a benefit to offset that cost.
性能优化不是免费的。 它们总是带来成本,并且优化带来的好处并不总是能抵消成本。
何时useMemo和useCallback
首先需要明白useMemo
和useCallback
内置于React的初衷:
- 保证引用相等
- 避免昂贵的计算
保证引用相等
引用相等这个问题较为基础,这里不过多展开,从下面例子就很好的展示了值相等和引用相等的区别:
// 判断值相等
true === true // true
false === false // true
1 === 1 // true
'a' === 'a' // true
// 判断引用相等
{} === {} // false
[] === [] // false
() => {} === () => {} // false
const obj = {}
obj === obj // true
复制代码
在react里保证引用相等可以带来什么好处呢?考虑一下useEffect
的依赖列表,看一下下面的这个例子:
组件Blub
使用了Foo
组件,其中Foo
组件的useEffect
依赖了传入的参数bar
, baz
。看起来很完美,只有当bar和baz改变时才会触发useEffect
里的计算逻辑。
function Foo({bar, baz}) {
React.useEffect(() => {
const options = {bar, baz}
buzz(options)
}, [bar, baz]) // we want this to re-run if bar or baz change
return <div>foobar</div>
}
function Blub() {
return <Foo bar="bar value" baz={3} />
}
复制代码
那如果bar
, baz
其中一个或者两个都是引用类型的值呢?
function Blub() {
return <Foo bar={['bar','value']} baz={() => {}} />
}
复制代码
那么Blub
每次渲染时 bar
, baz
都将是新的引用,所以当React测试依赖列表的值是否在渲染之间发生变化时,它将始终计算为 true
,意味着每次渲染后都会调用 useEffect
回调,而不是仅在 bar
和 baz
的值更改时调用。
问题已经明确了,怎么解决?
这时轮到 useCallback
和 useMemo
出场了。通过引用记忆可以这样解决这类问题:
function Foo({bar, baz}) {
React.useEffect(() => {
const options = {bar, baz}
buzz(options)
}, [bar, baz])
return <div>foobar</div>
}
function Blub() {
const bar = React.useMemo(() => [1, 2, 3], []);
const baz = React.useCallback(() => {}, []);
return <Foo bar={bar} baz={baz} />
}
复制代码
我们使用useMemo
包裹bar,使用useCallback
包裹baz,实现了对变更引用的记忆,有效避免了组件更新时因为引用不一致导致的依赖列表重刷,从而触发冗余计算的问题。
小提示:引用缓存技巧不仅对于
useEffect
,对于useLayoutEffect
,useCallback
和useMemo
的依赖列表元素也同样适用。
避免昂贵的计算
看下面的例子(这里只是举一个例子来表现“昂贵的计算”,不代表实际代码场景):
const [count, setCount] = useState(0);
const add = () => {
setCount((prev) => prev + 1);
};
const memoAdd = useMemo(() => {
for (let i = 0; i < 1000000; i++) {
console.log(i);
}
return add;
}, []);
复制代码
仍然是返回一个add方法,但是在返回之前增加了for (let i = 0; i < 1000000; i++)
,执行成本成倍增加。此时因为有useMemo
的缓存加持,for循环只会在初始阶段执行一次,后续将不在执行,极大提高了性能。
React.memo()
React.memo()
是 React v16.6 中引入的新功能。 它与 React.PureComponent
类似,它有助于控制 函数组件 的重新渲染。
React.memo()
对应的是函数式组件,React.PureComponent
对应的是类组件。关于memo()
的基本用法,网上已经有大量文档资料,此处不再展开。下面将重点对memo()使用中的误区进行剖析,并给出其解决方案。
先看一个例子
import React, { useState } from 'react';
interface ButtonProps {
onClick: (param: any) => void;
text: string;
}
const Button = function ({ onClick, text }: ButtonProps) {
return <button onClick={onClick}>{text}</button>;
};
function NoMemoCandy() {
const [count, setCount] = useState(0);
const addCount = () => {
setCount((prev) => prev + 1);
};
const clearCount = () => {
setCount(0);
};
return (
<div className={'memo-candy'}>
<div>{count}</div>
<div className={'button-container'}>
<Button onClick={addCount} text={'Add'} />
<Button onClick={clearCount} text={'Clear'} />
</div>
</div>
);
}
export default NoMemoCandy;
复制代码
上面代码是一个很简单的add/clear计数器,点击Add计数器加1,点击Clear计数器清零。 效果图:
上面代码中
Button
组件没有使用memo
包裹,因此每一次父组件的渲染都会引起Button
的渲染,造成额外的渲染开销(因此这里add和clear只是想更新<div>{count}</div>
部分)。
安装React Developer Tools插件后打开Chrome 的devtools
-> 选择 Components
-> 勾选 Highlight updates when components render
可以发现点击Add/Clear之后不仅父组件执行了render,两个Button
组件也执行了render。
引入memo
这里我们遇到了减少渲染的场景,根据上文的内容大家肯定会想到引入memo
进行优化。说干就干~
...
function MemoCandy() {
// 跟NoMemoCandy一致,此处不再贴出
const MemoButton = memo(Button);
return (
<div className={'memo-candy'}>
<div>{count}</div>
<div className={'button-container'}>
<MemoButton onClick={addCount} text={'Add'} />
<MemoButton onClick={clearCount} text={'Clear'} />
</div>
</div>
);
}
...
复制代码
优化的措施就是使用memo
对Button
组件进行包裹得到MemoButton
组件,然后在代码改用MemoButton
组件,其余代码完全一致。优化后再看效果:
额额,效果就是...没啥效果!? memo
没有起到应有的作用。
让memo真正作用起来
看一下memo作用的官方解释:
React.memo
仅检查 props 变更。如果函数组件被React.memo
包裹,且其实现中拥有useState
,useReducer
或useContext
的 Hook,当 state 或 context 发生变化时,它仍会重新渲染。
默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。
回到我们例子里,因为memo
检查props的变更时采用的是标准的js相等判断逻辑:对于基本数据类型执行值相等判断,对于引用数据类型执行引用相等判断。所以当组件props里的值为引用类型时,我们知道父组件的每一次render都会传递新的引用对象,memo进行相等判断时走引用相等逻辑判断,从而每次都会得到false的判断结果,也就不会阻止render过程,造成重复的渲染计算。
既然知道了问题所在,那就好办了,只需重点关注memo组件的引用属性,保证其引用相等即可。useMemo/useCallback
闪亮登场!
在上面的例子中,MemoButton组件的onClick属性是引用类型,传入的是React.Dispatch<React.SetStateAction<number>>
类型值(其实就是Function
),所以可以使用useCallback
包裹值保证其在每次render后引用值相等:
...
function MemoCandy() {
...
const MemoButton = memo(Button);
const addCount = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
const clearCount = useCallback(() => {
setCount(0);
}, []);
return (
<div className={'memo-candy'}>
<div>
<div>{count}</div>
</div>
<div className={'button-container'}>
<MemoButton onClick={addCount} text={'Add'} />
<MemoButton onClick={clearCount} text={'Clear'} />
</div>
</div>
);
}
...
复制代码
上面代码的主要改变是将原来的addCount/clearCount
函数用useCallback
包裹:
const addCount = () => {
setCount((prev) => prev + 1);
};
const clearCount = () => {
setCount(0);
};
复制代码
优化后:
const addCount = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
const clearCount = useCallback(() => {
setCount(0);
}, []);
复制代码
再看效果:
两个Button
组件不在重复执行render了,完美达到我们的既定目标。
总结
- 性能优化都是有代价的。直到确实需要抽象或优化时才去做,这样可以避免承担成本而不会获得收益的情况。
- 使用
useCallback
和useMemo
的成本是:对于你的同事来说,你使代码更复杂了;你可能在依赖项数组中犯了一个错误,并且你可能通过调用内置的 hook、并防止依赖项和 memoized 值被垃圾收集,而使性能变差。如果你获得了必要的性能收益,那么这些成本都是值得承担的,但请谨记非必要不优化这一原则。 - 单纯使用
memo
只是让组件拥有了 memoized 能力,而真正发挥出组件的 memoized 能力往往需要搭配useCallback
和useMemo
使用。
参考
useCallback
React.memo
When to useMemo and useCallback
You’re overusing useMemo: Rethinking Hooks memoization