参考答案:
useMemo 和 useCallback 是 React 的内置 Hook,通常作为优化性能的手段被使用。他们可以用来缓存函数、组件、变量,以避免两次渲染间的重复计算。但是实践过程中,他们经常被过度使用:担心性能的开发者给每个组件、函数、变量、计算过程都套上了 memo,以至于它们在代码里好像失控了一样,无处不在。
本文希望通过分析 useMemo/useCallback 的目的、方式、成本,以及具体使用场景,帮助开发者正确的决定如何适时的使用他们。赶时间的读者可以直接拉到底部看结论。
我们先从 useMemo/useCallback 的目的说起。
使用 memo 通常有三个原因:
后两种优化往往被误用,导致出现大量的无效优化或冗余优化。下面详细介绍这三个优化方式。
如果一个值被 useEffect 依赖,那它可能需要被缓存,这样可以避免重复执行 effect。
1const Component = () => { 2 // 在 re-renders 之间缓存 a 的引用 3 const a = useMemo(() => ({ test: 1 }), []); 4 5 useEffect(() => { 6 // 只有当 a 的值变化时,这里才会被触发 7 doSomething(); 8 }, [a]); 9 10 // the rest of the code 11};
useCallback 同理:
1const Component = () => { 2 // 在 re-renders 之间缓存 fetch 函数 3 const fetch = useCallback(() => { 4 console.log('fetch some data here'); 5 }, []); 6 7 useEffect(() => { 8 // 仅fetch函数的值被改变时,这里才会被触发 9 fetch(); 10 }, [fetch]); 11 12 // the rest of the code 13 14};
当变量直接或者通过依赖链成为 useEffect 的依赖项时,那它可能需要被缓存。这是 useMemo 和 useCallback 最基本的用法。
进入重点环节了🔔。正确的阻止 re-render 需要我们明确三个问题:
三种情况:
第三个 re-render 时机经常被开发者忽视,导致代码中存在大量的无效缓存。
例如:
1const App = () => { 2 const [state, setState] = useState(1); 3 4 const onClick = useCallback(() => { 5 console.log('Do something on click'); 6 }, []); 7 8 return ( 9 // 无论 onClick 是否被缓存,Page 都会 re-render 10 <Page onClick={onClick} /> 11 ); 12};
当使用 setState 改变 state 时,App 会 re-render,作为子组件的 Page 也会跟着 re-render。这里 useCallback 是完全无效的,它并不能阻止 Page 的 re-render。
必须同时缓存 onClick 和组件本身,才能实现 Page 不触发 re-render。
1const PageMemoized = React.memo(Page); 2 3const App = () => { 4 const [state, setState] = useState(1); 5 6 const onClick = useCallback(() => { 7 console.log('Do something on click'); 8 }, []); 9 10 return ( 11 // Page 和 onClick 同时 memorize 12 <PageMemoized onClick={onClick} /> 13 ); 14};
由于使用了React.memo,PageMemoized 会浅比较 props 的变化后再决定是否 re-render。onClick 被缓存后不会再变化,所以 PageMemoized 不再 re-render。
然而,如果 PageMemoized 再添加一个未被缓存的 props,一切就前功尽弃 🤯 :
1const PageMemoized = React.memo(Page); 2 3const App = () => { 4 const [state, setState] = useState(1); 5 6 const onClick = useCallback(() => { 7 console.log('Do something on click'); 8 }, []); 9 10 return ( 11 // page WILL re-render because value is not memoized 12 <PageMemoized onClick={onClick} value={[1, 2, 3]} /> 13 ); 14};
由于 value 会随着 App 的 re-render 重新定义,引用值发生变化,导致 PageMemoized 仍然会触发 re-render。
现在可以得出结论了,必须同时满足以下两个条件,子组件才不会 re-render:
我们已经了解,为了防止子组件 re-render,需要以下成本:
除此之外还有另外一个成本:性能成本。 组件的缓存是在初始化时进行,虽然每个组件缓存的性能耗费很低,通常不足1ms,但大型程序里成百上千的组件如果同时初始化缓存,成本可能会变得很可观。
所以局部使用 memo,比全局使用显的更优雅、性能更好,坏处是需要开发者主动去判断是否需要缓存该子组件。
🤨 那应该什么时候缓存组件,怎么判断一个组件的渲染是昂贵的?
很遗憾,似乎没有一个简单&无侵入&自动的衡量方式。通常来说有两个方式:
另外,React 在 16.5版本后提供了 Profiler API:它可以识别出应用中渲染较慢的部分,或是可以使用类似 memoization 优化的部分。所以可以通过 puppeteer 或 cypress 在自动化集成中测试组件性能,这很适合核心组件的性能测试。
如 React 文档所说,useMemo 的基本作用是,避免在每次渲染时都进行高开销的计算。
🤨 那什么是“高开销的计算”?
高开销的计算其实极少出现,如下示例,对包含 250 个 item 的数组 countries 进行排序、渲染,并计算耗时。
1const List = ({ countries }) => { 2 const before = performance.now(); 3 const sortedCountries = orderBy(countries, 'name', sort); 4 // this is the number we're after 5 const after = performance.now() - before; 6 7 return ( 8 // same 9 ) 10};
结果如图所示,排序耗时仅用了 4 毫秒,而渲染图中的 List 组件(仅仅只是 button + 文字)却用了 20 毫秒,5倍的差距,代码详见 codesandbox.。 大部分情况下,我们的计算量要比这个 250 个 item 的数组少,而组件渲染要比这个 List 组件复杂的多,所以真实程序中,计算和渲染的性能差距会更大。
可见,组件渲染才是性能的瓶颈,应该把 useMemo 用在程序里渲染昂贵的组件上,而不是数值计算上。当然,除非这个计算真的很昂贵,比如阶乘计算。
至于为什么不给所有的组件都使用 useMemo,上文已经解释了。useMemo 是有成本的,它会增加整体程序初始化的耗时,并不适合全局全面使用,它更适合做局部的优化。
关于这点 Dan Abramov 在推文上也给出了解释(虽然是个类比 😅):
评论区里 react 的另一位核心开发者 Christopher Chedeau 也参与了讨论。 简而言之,他们认为:
原因 2 的原文:correctness is not guaranteed for everything because people can mutate things. Christopher Chedeau 未给出进一步解释。或许他是指可能会导致跟 PureComponent相同的问题,即浅比较 mutate things 时,由于浅比较相等,导致组件未能 update 的问题。
讲到这里我们可以总结出 useMemo/useCallback 使用准则了:
关于第三点有相反观点,详见:Why We Memo All the Things,作者推荐默认给全部组件都加上 React.memo,并给所有 props 都套上 useMemo。他认为这样可以降低工程师心智负担,让工程师不必再自己判断什么时候使用 memorize。
最近更新时间:2024-08-10