useMemo 可以带来性能优化,但是你的项目中 useMemo 带来过什么性能提升吗?你写的 memo 确实带来了优化效果吗,还是仅仅自我安慰?
我用 useMemo 是为了减少不必要的重复渲染,这应该是一个很好的优化手段。
加了 useMemo 以后我的代码重复渲染的成本变小了,太棒了。
好吧好吧,就是这样吗?希望今天这篇文章看完以后,你可以很有信心地把现在代码中 95% 的 useMemo 删掉,接着你会发现项目可能会跑得更快,维护成本会更低。
从官方文档我们可以看到 useMemo 这个 Hook 的定义:它可以缓存每次渲染期间计算所得的结果。
官方文档定义
很多人对 useMemo 的理解,可能就止步于这句话,利用 useMemo 可以缓存计算结果。
如果你再深入了解 useMemo,你会知道它不能帮助提高组件初次渲染的速度。它只能潜在地提高你重新渲染(前提是你正确使用 useMemo)之后的重新渲染速度。
对于那些已经很熟悉并且长期在使用 useMemo 的人来说,上述信息他们可能已经知道。那么我们继续来看官方文档中对 useMemo 的使用场景的描述:
这里仅关注源码的关键部分。重新渲染的时候 useMemo 会逐个比较依赖,这里采用具体的比较参照 Object.is()
。尽管这样的比较很快,但是我这里想给大家的概念是,使用 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 的充分条件,并不意味着 !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 是一个没有 props
也没有内部 state
的组件,但是当我点击按钮的时候,App
重新渲染了(因为 state
改变),这个时候 page
也跟着重新渲染了,里面的 Item
也重新渲染了,整个链路都重新渲染了。我要怎么阻断这种重新渲染呢?—— React.memo
const Page = () => <Item />;
const PageMemoized = React.memo(Page);
const App = () => {
const [state, setState] = useState(1);
return (
// ... 与之前相同的代码
<PageMemoized />
);
};
当做完这所有的事情之后,此时你再考虑你的 Page 的 props 才有意义。
那么从上面的例子我们可以得出结论,caching props 只有在一种场景下才有意义:当组件的所有 props 以及组件本身都打上缓存的时候。
如果组件代码中存在以下任意一种情况,我们可以很安心地移除掉 useMemo
和 useCallback
,没有任何心理负担:
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} />
))}
</>
);
};
渲染出来的按钮列表。
不用 memo
的情况下,把整个 CPU 速度降低 6 倍,排序这个 250 条数据的列表只用了不到 2 毫秒。相比之下,渲染整个列表(只是文本按钮)用了 20 多毫秒。日常开发中,我们很少需要处理这么大规模的数据。而且这里我们只是在渲染常规的按钮。所以你需要做的是对 memo 数组进行操作或者渲染并更新 memo 组件。
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;
};
当我们对组件打上 memo
之后 ,发现整体渲染这个列表的时间从原先的 20 毫秒 降低到了不到 2 毫秒(大概 18 毫秒)。
实际场景中,数组的规模通常更小,渲染的内容也比这个例子中的复杂得多,所以会更慢一些。因此,一般来说,“计算”和“渲染”之间的时间往往相差 10 倍以上。
那么这里就会冒出另一个问题了:为什么一定要删掉呢? memo 起来不都是好事吗?即使这里只是优化了 2ms 的重新渲染速度,当积少成多的时候也很可观了吧。从另一个角度想,如果一个都不用 memo 的话,应用每这里每那里会慢 2ms,积少成多最后应用会变慢很多,比本来能达到的效果差很多吧。
的确,这种推理听起来很有道理。然而,如果不考虑之前提到的那个点的话,这种推理的确可以得到完全的理由支持。那个点就是:caching 是有一定开销的。如果我们使用 useMemo,React 需要在初次渲染的过程中缓存它的值——这过程当然是要花时间的。没错,这个时间消耗非常小;在我们的应用中,缓存上面提到的排好序的国家列表用不了 1 毫秒。但是!这会产生真正的积少成多效应!在应用初次出现在屏幕上的那个初始渲染过程中,当前页面的每个元素都要经历这个过程。这就导致了不必要的 10-20 毫秒甚至接近 100 毫秒的延时。
与初始渲染相比,重新渲染只发生在某些部分变化的时候。在一个架构良好的应用中,只有这些特定的区域/组件会发生重新渲染,而不是整个应用(页面)。所以普通的重新渲染中所有“计算”的总成本会比上面提到的例子(指拍好序的 250 个元素的列表)高出多少呢?2-3 倍?我们就假设它是原来的 5 倍吧,那么它只会节省大概 10 毫秒的渲染时间。像这样短的时间区间,裸眼很难分辨出来,而且在十倍以上的渲染时间面前,这 10 毫秒显得微不足道。然而,作为一个代价,它确实会拖慢每一次发生的那个初始渲染过程😔。
初级级别
这里的 useCallback
是无用的。当 Component
重新渲染的时候,无论 props
如何都会重新渲染相关的子组件。这种情况下 click
的 memo
无意义。
const Component = () => {
const onClick = useCallback(() => {
/* do something */
}, []);
return <button onClick={onClick}>Click me</button>
};
此时,你的子组件被 memo
包裹,onClick
也被 useCallback
包裹了,但是这个值没有包裹。这时候当你的 Component 重新渲染,你的 MemoItem 还是会重新渲染。这时候 useCallback 还啥都没做呢。
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/useEffect
的依赖。这几句话可能看着很熟悉,因为它们就是官方文档中如何使用 useMemo
的提到的场景。在其他情况下,给计算流程套一个 useMemo 并没有什么好处,但是这样做也不会造成明显的伤害,所以有些团队选择不考虑具体情况就尽可能多地使用 useMemo,这降低了代码的可读性。而且不是所有的 useMemo 使用都是有效的:一个“始终新的”单值可以打破整个组件的 memo 化效果。
示例
这是一个渲染性能很有问题的组件。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>
);
}
完了,这次怎么提取呢?最外层的父级
memo
了吗?
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
我们把程序分割成两部分,依赖 color
的部分和 color
这个变量本身都放到了 ColorPicker
中。不依赖 color
的部分留在 App
中作为 ColorPicker
的 children
。当 color
改变的时候,ColorPicker
会重新渲染,但是它的 children
的 props
没有改变。因此,React 会复用之前的 children
。ExpensiveTree
不会重新渲染,问题得到解决!
总结
在使用像 useMemo
和 memo
这样的优化解决方案之前,考虑下是否可以分割变化部分和不受影响部分,这可能更有意义。使用分割方法的有趣之处在于,我们不依赖任何性能工具,分割本身与性能无关。利用 children
也遵循了自上而下的数据流,减少了树中需要搜索的属性数量。在这个例子中,提高性能只是额外的收益而不是最终目标,真正做到了意想不到的双赢。
有人可能会说,我就是喜欢用 useMemo
和 useCallback
,为什么要删掉它们呢?只要我理解之前提到的逻辑,确保我的 useMemo 真正有用就行了!
技术上来说,的确可以。
但是如果你至今没有发现 useMemo
和 useCallback
的使用中有任何问题的话,那说明你目前编写的程序没有性能问题。
如果你坚持使用它们,很好。你完美地理解了使用规则,并把你的程序严丝合缝地 memo
起来没有任何漏洞。你时刻提醒自己未来开发或者添加需求的时候要当心,不要打破整个 memo 链。你能保证和你一起工作的同事在开发中也会注意这一点吗?你能确保在项目交接给下一任的时候,他/她也会坚持你的维护方法吗?
原视频链接:https://www.youtube.com/watch?v=lGEMwh32soc&t=620s
React 团队也发现了不使用 memo
可能会导致一些性能问题。但是如果我们要使用 memo 的话,会有非常大的心智负担,因为我们需要考虑多个依赖关系是否被正确使用和包裹。
色彩选择器优化
如果有什么东西可以帮我们正确地 memo 起所有需要 memo 的东西,不是很美好吗?
自动记忆
代码:React Forget 目前还在研究中。它是一个可以帮助你自动 memo 组件的编译器。他们也在解决自动 memo
的问题。
React Forget
最后,我们来看下之前提到的几个想法,你会如何考虑这些情况:
我的看法是,如果你发现项目没有明显的卡顿或拖慢行为,请不要使用 memo;也不要指望你当前编写的 memo 能为项目带来长期收益,因为它实在太容易被打乱。一旦有不熟悉 memo 的同事加入维护新的项目,他们很容易打破整个 memo 链。然而,如果确实存在卡慢的表现,请合理使用 memo 的缓存特性(参考常见误用)来帮助优化性能问题或延迟。