useMemo 可以带来性能优化,但是你的项目中 useMemo 带来过什么性能提升吗?你写的 memo 确实带来了优化效果吗,还是仅仅自我安慰?
你为什么要用 useMemo?
我用 useMemo 是为了减少不必要的重复渲染,这应该是一个很好的优化手段。
加了 useMemo 以后我的代码重复渲染的成本变小了,太棒了。
好吧好吧,就是这样吗?希望今天这篇文章看完以后,你可以很有信心地把现在代码中 95% 的 useMemo 删掉,接着你会发现项目可能会跑得更快,维护成本会更低。
useMemo 是什么?
从官方文档我们可以看到 useMemo 这个 Hook 的定义:它可以缓存每次渲染期间计算所得的结果。
官方文档定义
很多人对 useMemo 的理解,可能就止步于这句话,利用 useMemo 可以缓存计算结果。
如果你再深入了解 useMemo,你会知道它不能帮助提高组件初次渲染的速度。它只能潜在地提高你重新渲染(前提是你正确使用 useMemo)之后的重新渲染速度。
对于那些已经很熟悉并且长期在使用 useMemo 的人来说,上述信息他们可能已经知道。那么我们继续来看官方文档中对 useMemo 的使用场景的描述:
- 跳过昂贵的重新计算
- 跳过组件的重复渲染
- 记忆另一个 Hook 的依赖
- 记忆函数
核心源码
这里仅关注源码的关键部分。重新渲染的时候 useMemo 会逐个比较依赖,这里采用具体的比较参照
function areHookInputsEqual( nextDeps: Array<mixed>, prevDeps: Array<mixed> | null, ): boolean { // 省略的部分 ... // $FlowFixMe[incompatible-use] found when upgrading Flow for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) { // $FlowFixMe[incompatible-use] found when upgrading Flow if (is(nextDeps[i], prevDeps[i])) { continue; } return false; } return true; }
function is(x: any, y: any) { return ( (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare ); }
为什么组件要重新渲染自己?
众所周知,state 或者 props 变化的时候,组件会重新渲染自己。
那么如果 props 和 state 没变,组件就不会重新渲染了,对吗?
A 是 B 的充分条件,并不意味着
这里还有一种导致组件重新渲染的可能,那就是父组件的重新渲染。我们来看一段代码:
const Page = () => <Item />; const App = () => { const [state, setState] = useState(1); return ( <div> <button onClick={() => setState(state + 1)}> 点击重新渲染 {state} </button> // Page 是一个没有 props 的子组件,里面也没有 state <Page /> </div> ); };
Page 是一个没有
const Page = () => <Item />; const PageMemoized = React.memo(Page); const App = () => { const [state, setState] = useState(1); return ( // ... 与之前相同的代码 <PageMemoized /> ); };
当做完这所有的事情之后,此时你再考虑你的 Page 的 props 才有意义。
那么从上面的例子我们可以得出结论,caching props 只有在一种场景下才有意义:当组件的所有 props 以及组件本身都打上缓存的时候。
如果组件代码中存在以下任意一种情况,我们可以很安心地移除掉
- 它们被用作
attr 直接使用或者作为依赖树上层传给未缓存的组件 - 它们被用作
props 直接使用或者传给未缓存的组件 - 它们被用作
props 直接使用或者传给至少有一个 props 没有缓存的组件
“那就给每个都打上缓存呗,保证整个链路可以正确 memo ”
如果你还是这么想的话,那么你已经被 useMemo 绑架,还在为它数钱了。如果你真的有性能问题,你一定已经注意到问题出在什么地方然后解决了。既然已经没有性能问题了,就不需要再解决了。移除没有用到的 useMemo 和 useCallback 会稍微简化你的代码,同时初始渲染会稍快一点,对现有的重新渲染性能没有任何负面影响。
总体来说,不要为了用才用 useMemo;等真的有性能问题的时候再使用它。
避免每次渲染的昂贵计算
这里采用本文的数据计算为例:https://www.developerway.com/posts/how-to-use-memo-use-callback
代码片段:https://codesandbox.io/s/measure-without-memo-tnhggk?file=/src/page.tsx
读到这里,读者应该已经知道 useMemo 的作用了,就像小标题写的——useMemo 的主要目标是为了避免每次渲染的昂贵计算。那么什么算是昂贵的计算呢?
我不知道,似乎官网上没有写,或者你没有找到。那么就别在意了,直接用吧。创建一个新日期?数组的过滤、映射或排序?创建一个对象?都用 useMemo 缓存吧!useMemo 终将主宰所有的 React 项目!
好吧,举个例子。比如,我有 250 个国家和地区的数据,你要对它们进行排序并展示。
const Item = ({ country }: { country: Country }) => { return <button>{country.name}</button>; }; const List = ({ countries }) => { // 在这里对国家列表进行排序 const sortedCountries = orderBy(countries, 'name', sort); return ( <> {sortedCountries.map((country) => ( <Item country={country} key={country.id} /> ))} </> ); };
渲染出来的按钮列表。
不用
const List = ({ countries }) => { const content = useMemo(() => { const sortedCountries = orderBy(countries, 'name', sort); return sortedCountries.map((country) => <Item country={country} key={country.id} />); }, [countries, sort]); return content; };
当我们对组件打上
实际场景中,数组的规模通常更小,渲染的内容也比这个例子中的复杂得多,所以会更慢一些。因此,一般来说,“计算”和“渲染”之间的时间往往相差 10 倍以上。
那么这里就会冒出另一个问题了:为什么一定要删掉呢? memo 起来不都是好事吗?即使这里只是优化了 2ms 的重新渲染速度,当积少成多的时候也很可观了吧。从另一个角度想,如果一个都不用 memo 的话,应用每这里每那里会慢 2ms,积少成多最后应用会变慢很多,比本来能达到的效果差很多吧。
的确,这种推理听起来很有道理。然而,如果不考虑之前提到的那个点的话,这种推理的确可以得到完全的理由支持。那个点就是:caching 是有一定开销的。如果我们使用 useMemo,React 需要在初次渲染的过程中缓存它的值——这过程当然是要花时间的。没错,这个时间消耗非常小;在我们的应用中,缓存上面提到的排好序的国家列表用不了 1 毫秒。但是!这会产生真正的积少成多效应!在应用初次出现在屏幕上的那个初始渲染过程中,当前页面的每个元素都要经历这个过程。这就导致了不必要的 10-20 毫秒甚至接近 100 毫秒的延时。
与初始渲染相比,重新渲染只发生在某些部分变化的时候。在一个架构良好的应用中,只有这些特定的区域/组件会发生重新渲染,而不是整个应用(页面)。所以普通的重新渲染中所有“计算”的总成本会比上面提到的例子(指拍好序的 250 个元素的列表)高出多少呢?2-3 倍?我们就假设它是原来的 5 倍吧,那么它只会节省大概 10 毫秒的渲染时间。像这样短的时间区间,裸眼很难分辨出来,而且在十倍以上的渲染时间面前,这 10 毫秒显得微不足道。然而,作为一个代价,它确实会拖慢每一次发生的那个初始渲染过程??。
常见误用(重点)
初级级别
这里的
const Component = () => { const onClick = useCallback(() => { /* do something */ }, []); return <button onClick={onClick}>Click me</button> };
此时,你的子组件被
const Item = () => <div> ... </div> const MemoItem = React.memo(Item) const Component = () => { const onClick = useCallback(() => { /* do something */ }, []); return <MemoItem onClick={onClick} value={[1,2,3]}/> };
中级级别
是不是看着很应该没问题?onClick 被“useCallback”包了起来,MemoItem 也 memo 过了。这次就算天塌下来,也不应该重新渲染了吧,不然我学的知识全白学了。
const Item = () => <div> ... </div> const MemoItem = React.memo(Item) const Component = () => { const onClick = useCallback(() => { /* do something */ }, []); return <MemoItem onClick={onClick}> <div>something</div> </MemoItem> };
是的,这还是会重新渲染的。上面的代码片段等价于:
// 下面的写法是等价的,意味着传 children 和直接嵌套子元素是一致的 React.createElement('div',{ children:'Hello World' }) React.createElement('div',null,'Hello World') <div>Hello World</div>
const Item = () => <div> ... </div> const MemoItem = React.memo(Item) // 无用的 const Component = () => { const onClick = useCallback(() => { //无用的 /* do something */ }, []); return <MemoItem onClick={onClick} children={<div>something</div>} /> };
有的同学看到这里还不理解:“你说子组件相当于 children,我的 div 明明还是原来的,你怎么说我的 props 改变了?”有这样想法的同学请暂时把它放在一边,我们来看最后一个。
高级级别
好吧好吧,你就是想让我这么写是不是,行,这次我把一切都裹起来。这次就算玉帝他老人家也拦不住我了。这次,留个 memo 我走!
const Item = () => <div> ... </div> const Child = () => <div>sth</div> const MemoItem = React.memo(Item) const MemoChild = React.memo(Child) const Component = () => { const onClick = useCallback(() => { /* do something */ }, []); return ( <MemoItem onClick={onClick}> <MemoChild /> </MemoItem> ) };
答案还是没有 memo 住,为什么呢?我们单独拎出 MemoChild 来分析它是怎么执行的:
const child = <MemoChild />;
const child = React.createElement(MemoChild,props,childen);
const child = { type: MemoChild, props: {}, // 同样的 props ... // 同样的 react 间隔物 }
之前的问题也可以轻松解决了。每次创建的时候创建出来的 child 是一个不同的对象,所以比较的时候触发重新渲染。
终极解决思路
如果你想 memo,你的 memo 目标应该是 Element 本身,而不是 Component。useMemo 会缓存之前的值,如果依赖没有变化就直接返回缓存的数据。
const Child = () => <div>sth</div> const MemoItem = React.memo(Item) const Component = () => { const onClick = useCallback(() => { /* do something */ }, []); const child = useMemo(()=> <Child /> ,[]) return ( <MemoItem onClick={onClick}> {child} </MemoItem> ) };
我们的 memo 组件终于成功了!
如果你之前对这个特性一无所知的话也不要灰心。React-Query 的作者 Dominik 也没有很长时间知道这个特性。这个领域的知识点还有很多,涉及 JSX 的本质和 React 自身的 diff 机制。这里我就不展开讲解了,如果你感兴趣的话可以查看这篇文章:
《一个简单的技巧来优化 React 的重复渲染》 https://kentcdodds.com/blog/optimize-react-re-renders
不管怎么说,成功从来都不容易。现在你还觉得 useMemo 有用吗?你艰辛建立的王国可以通过只传一些 props 就很容易传给下一任。我们又回到了起点。
是否要到处添加 useMemo?
总体来说,对于基础的后端应用,大部分交互相对比较粗暴,通常不需要。如果你的应用类似图形编辑器,大多数交互很细粒度(比如移动图形),这时 useMemo 可以提供非常大的帮助。
useMemo 的优化作用只有在少数场景下才有价值:
- 你知道计算很昂贵而且它的依赖很少改变
- 当前的计算结果会作为 props 传给 memo 包裹的组件。通过 useMemo 缓存结果,如果结果没有变化可以跳过重新渲染
- 当前的计算结果是其他一些 hook 的依赖,比如其他
useMemo/useEffect 的依赖。这几句话可能看着很熟悉,因为它们就是官方文档中如何使用useMemo 的提到的场景。
在其他情况下,给计算流程套一个 useMemo 并没有什么好处,但是这样做也不会造成明显的伤害,所以有些团队选择不考虑具体情况就尽可能多地使用 useMemo,这降低了代码的可读性。而且不是所有的 useMemo 使用都是有效的:一个“始终新的”单值可以打破整个组件的 memo 化效果。
没有 useMemo 我不知道该怎么做了
示例
这是一个渲染性能很有问题的组件。ExpensiveTree 是一个渲染非常昂贵的组件。
import { useState } from 'react'; export default function App() { let [color, setColor] = useState('red'); return ( <div> <input value={color} onChange={(e) => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> <ExpensiveTree /> </div> ); } function ExpensiveTree() { let now = performance.now(); while (performance.now() - now < 100) { // 人为延时,不做任何事只是消耗 100ms } return <p>I am a very slow component tree.</p>; }
在线试一下:https://codesandbox.io/s/frosty-glade-m33km?file=/src/App.js:23-513
当颜色变化的时候,ExpensiveTree 也会跟着重新渲染,而 ExpensiveTree 的渲染非常耗时。
经过我们前面的学习,我们知道这种情况非常适合用 useMemo 来解决,因为它确实是一个昂贵的计算,而且我确实感受到了卡顿,影响了我项目的正常渲染。
但是我们一定非要用 useMemo 吗?
方案 1:状态迁移
如果你仔细看这段代码的话,会发现返回的结果中只有一部分和 color 有关。
export default function App() { let [color, setColor] = useState('red'); return ( <div> <input value={color} onChange={(e) => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> <ExpensiveTree /> </div> ); }
所以我们可以提取这一部分并下移状态到其他组件中:
export default function App() { return ( <> <Form /> <ExpensiveTree /> </> ); } function Form() { let [color, setColor] = useState('red'); return ( <> <input value={color} onChange={(e) => setColor(e.target.value)} /> <p style={{ color }}>Hello, world!</p> </> ); }
此时,只有 Form 会随着 color 的改变重新渲染,问题解决!
在线试一下:https://codesandbox.io/s/billowing-wood-1tq2u?file=/src/App.js:64-380
方案 2:内容增强
如果我们在最外层的 div 中也使用 color 的话,方案 1 就行不通了。
export default function App() { let [color, setColor] = useState('red'); return ( <div style={{ color }}> <input value={color} onChange={(e) => setColor(e.target.value)} /> <p>Hello, world!</p> <ExpensiveTree /> </div> ); }
完了,这次怎么提取呢?最外层的父级
还是需要用到 color 的,难道只能用
export default function App() { return ( <ColorPicker> <p>Hello, world!</p> <ExpensiveTree /> </ColorPicker> ); } function ColorPicker({ children }) { let [color, setColor] = useState("red"); return ( <div style={{ color }}> <input value={color} onChange={(e) => setColor(e.target.value)} /> {children} </div> ); }
在线试一下:https://codesandbox.io/s/wonderful-banach-tyfr1?file=/src/App.js:58-423
我们把程序分割成两部分,依赖
总结
在使用像
为什么一定要移除?
有人可能会说,我就是喜欢用
技术上来说,的确可以。
但是如果你至今没有发现
如果你坚持使用它们,很好。你完美地理解了使用规则,并把你的程序严丝合缝地
React 团队的看法
原视频链接:https://www.youtube.com/watch?v=lGEMwh32soc&t=620s
React 团队也发现了不使用
色彩选择器优化
如果有什么东西可以帮我们正确地 memo 起所有需要 memo 的东西,不是很美好吗?
自动记忆
代码:React Forget 目前还在研究中。它是一个可以帮助你自动 memo 组件的编译器。他们也在解决自动
React Forget
文末
最后,我们来看下之前提到的几个想法,你会如何考虑这些情况:
- “我不确定是否可行,但我觉得这里需要用 useMemo。即使不起作用,它也有可能优化性能。”
- “这里似乎有大量的数据处理,而且数据变化不大,很适合用 memo。”
- “数据处理很麻烦,我不想写方法。用 memo 可以帮我用方法式的写法包装数据返回,很顺手。”
我的看法是,如果你发现项目没有明显的卡顿或拖慢行为,请不要使用 memo;也不要指望你当前编写的 memo 能为项目带来长期收益,因为它实在太容易被打乱。一旦有不熟悉 memo 的同事加入维护新的项目,他们很容易打破整个 memo 链。然而,如果确实存在卡慢的表现,请合理使用 memo 的缓存特性(参考常见误用)来帮助优化性能问题或延迟。