useMemo是react用作性能优化的一个hook,但有一个现象,不知道的人一次不用,知道的人随时随地到处都用。本文就带你真正搞懂什么情况下可以使用useMemo。
useMemo 是一个 React Hook,它在每次重新渲染的时候能够缓存计算的结果 useMemo(calculateValue, dependencies)
import { useMemo } from 'react';
function TodoList({ todos, tab }) {
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
// ...
}
在初次渲染时,useMemo 返回不带参数调用 calculateValue 的结果。
在接下来的渲染中,如果依赖项没有发生改变,它将返回上次缓存的值;否则将再次调用 calculateValue,并返回最新结果。
本文将从以下几个方面带你深入了解useMemo:
- 使用方法
- 如何衡量计算过程的开销是否昂贵
- 什么情况下使用useMemo
- 如何避免滥用useMemo
- 常见问题
欢迎访问本人个人网站:https://www.dengzhanyong.com
关注公众号【前端筱园】,不错过每一篇推送。
加入【交流群】,共同学习成长
使用方法
默认情况下,React 会在每次重新渲染时重新运行整个组件。例如,如果 TodoList 更新了 state 或从父组件接收到新的 props,filterTodos 函数将会重新运行。
function TodoList({ todos, tab, theme }) {
const visibleTodos = filterTodos(todos, tab);
// ...
}
如果计算速度很快,这将不会产生问题。但是,当正在过滤转换一个大型数组,或者进行一些昂贵的计算,而数据没有改变,那么可能希望跳过这些重复计算。如果 todos 与 tab 都与上次渲染时相同,那么将计算函数包装在 useMemo 中,便可以重用已经计算过的 visibleTodos。
import { useMemo } from 'react';
function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// ...
}
你需要给 useMemo 传递两样东西:
一个没有任何参数的 calculation 函数,像这样 () =>,并且返回任何你想要的计算结果。
一个由包含在你的组件中并在 calculation 中使用的所有值组成的 依赖列表。
在初次渲染时,你从 useMemo 得到的值将会是你的 calculation 函数执行的结果。
在随后的每一次渲染中,React 将会比较前后两次渲染中的 所有依赖项是否相同。如果通过 Object.is 比较所有依赖项都没有发生变化,那么 useMemo 将会返回之前已经计算过的那个值。否则,React 将会重新执行 calculation 函数并且返回一个新的值。
如何衡量计算过程的开销是否昂贵?
一般来说,除非要创建或循环遍历数千个对象,否则开销可能并不大。如果你想获得更详细的信息,可以在控制台来测量花费这上面的时间:
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
通过打印这段计算内容所执行的时间来判断,如果执行时间>1ms,那么缓存这段计算结果就是有意义的。由于这里没有使用useMemo,所以每次重新渲染都会执行,每次渲染后看到的打印时间都差不多。
作为对比,你可以将计算过程包裹在 useMemo 中,以验证该交互的总日志时间是否减少了:
console.time('filter array');
const visibleTodos = useMemo(() => {
return filterTodos(todos, tab); // 如果 todos 和 tab 都没有变化,那么将会跳过渲染。
}, [todos, tab]);
console.timeEnd('filter array');
这里要值得注意的是,useMemo不会让首次渲染执行的更快,所以你会看到第一次渲染时,这段内容执行的时间与上面差不多,但后续的在后续的重新渲染中,观察打印时间是否明显的减少。
什么情况下使用useMemo?
-
useMemo中的内容计算很慢,并且依赖项很少改变
如果每次更新,依赖值都会发生变化,这种情况下使用useMemo并不会得到明显的收益小效果。
-
将useMemo的计算结果作为props传递给子组件,在依赖未改变的时候,不想子组件重新渲染
默认情况下,当一个组件重新渲染时,React 会递归地重新渲染它的所有子组件,这对于不需要太多计算来重新渲染的组件来说很好。但是如果你已经确认重新渲染很慢,你可以通过将它包装在 memo 中,这样当它的 props 跟上一次渲染相同的时候它就会跳过本次渲染:
import { memo } from 'react';
const List = memo(function List({ items }) {
// ...
});
export default function TodoList({ todos, tab, theme }) {
// 每当主题发生变化时,这将是一个不同的数组……
const visibleTodos = filterTodos(todos, tab);
return (
<div className={theme}>
/* ... 所以List的props永远不会一样,每次都会重新渲染 */
<List items={visibleTodos} />
</div>
);
}
在上面的示例中,filterTodos 函数总是创建一个不同数组,类似于 {} 总是创建一个新对象的方式。通常,这不是问题,但这意味着 List 属性永远不会相同,并且你的 memo 优化将不起作用。这就是 useMemo 派上用场的地方:
export default function TodoList({ todos, tab, theme }) {
// 告诉 React 在重新渲染之间缓存你的计算结果...
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab] // ...所以只要这些依赖项不变...
);
return (
<div className={theme}>
/* ... List 也就会接受到相同的 props 并且会跳过重新渲染 */
<List items={visibleTodos} />
</div>
);
}
还有另外一种方式,你可以将 JSX 节点本身包裹在 useMemo 中,而不是将 List 包裹在 memo 中:
export default function TodoList({ todos, tab, theme }) {
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
const children = useMemo(() => <List items={visibleTodos} />, [visibleTodos]);
return (
<div className={theme}>
{children}
</div>
);
}
他们的行为表现是一致的。如果 visibleTodos 没有改变,List 将不会重新渲染。
手动将 JSX 节点包裹到 useMemo 中并不方便,比如你不能在条件语句中这样做。所以通常会选择使用 memo 包装组件而不是使用 useMemo 包装 JSX 节点。
3. 传递的值稍后用作某些 Hook 的依赖项。例如,也许另一个 useMemo 计算值依赖它,或者 useEffect 依赖这个值
如何避免滥用useMemo?
- 减少不必要的依赖项
- 避免不必要的更新state的effect,有两种不必使用 Effect 的常见情况:
(1)你不必使用 Effect 来转换渲染所需的数据。
假设你有一个包含了两个 state 变量的组件:firstName 和 lastName。你想通过把它们联结起来计算出 fullName。此外,每当 firstName 和 lastName 变化时,你希望 fullName 都能更新。你的第一直觉可能是添加一个 state 变量:fullName,并在一个 Effect 中更新它:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 避免:多余的 state 和不必要的 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
大可不必这么复杂。而且这样效率也不高:它先是用 fullName 的旧值执行了整个渲染流程,然后立即使用更新后的值又重新渲染了一遍。让我们移除 state 变量和 Effect:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ 非常好:在渲染期间进行计算
const fullName = firstName + ' ' + lastName;
// ...
}
如果一个值可以基于现有的 props 或 state 计算得出,不要把它作为一个 state,而是在渲染期间直接计算这个值。这将使你的代码更快(避免了多余的 “级联” 更新)、更简洁(移除了一些代码)以及更少出错。
(2)你不必使用 Effect 来处理用户事件。例如,你想在用户购买一个产品时发送一个 /api/buy 的 POST 请求并展示一个提示。在这个购买按钮的点击事件处理函数中,你确切地知道会发生什么。但是当一个 Effect 运行时,你却不知道用户做了什么(例如,点击了哪个按钮)。这就是为什么你通常应该在相应的事件处理函数中处理用户事件。
- 非必要不进行状态提升
如果只是当前组件使用某个state,则不要将这个state提升到父组件去定义,然后通过props传递下来 - 使用打印日志或一些性能工具手段,来判断是否需要使用useMemo来进行缓存
在其他情况下,将计算过程包装在 useMemo 中没有任何好处。不过这样做也没有重大危害,所以很多人选择不考虑具体情况,尽可能多地使用 useMemo。不过这种做法会降低代码可读性,并且没有任何效果。
常见问题
- 依赖值为对象时,useMemo每次都会重新计算
假设你有一个计算函数依赖于直接在组件主体中创建的对象:
function Dropdown({ allItems, text }) {
const searchOptions = { matchMode: 'whole-word', text };
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // 🚩 提醒:依赖于在组件主体中创建的对象
// ...
依赖这样的对象会破坏记忆化。当组件重新渲染时,组件主体内的所有代码都会再次运行。创建 searchOptions 对象的代码行也将在每次重新渲染时运行。因为 searchOptions 是你的 useMemo 调用的依赖项,而且每次都不一样,React 知道依赖项是不同的,并且每次都重新计算 searchItems。
要解决此问题,你可以在将其作为依赖项传递之前记忆 searchOptions 对象 本身:
function Dropdown({ allItems, text }) {
const searchOptions = useMemo(() => {
return { matchMode: 'whole-word', text };
}, [text]); // ✅ 只有当 text 改变时才会发生改变
const visibleItems = useMemo(() => {
return searchItems(allItems, searchOptions);
}, [allItems, searchOptions]); // ✅ 只有当 allItems 或 serachOptions 改变时才会发生改变
// ...
在上面的例子中,如果 text 没有改变,searchOptions 对象也不会改变。然而,更好的解决方法是将 searchOptions 对象声明移到 useMemo 计算函数的内部:
function Dropdown({ allItems, text }) {
const visibleItems = useMemo(() => {
const searchOptions = { matchMode: 'whole-word', text };
return searchItems(allItems, searchOptions);
}, [allItems, text]); // ✅ 只有当 allItems 或者 text 改变的时候才会重新计算
// ...
这里依赖的是一个字符串类型的值,只有当值确实发生改变时才会重新执行
2. 未设置依赖数组时,useMemo每次都会重新计算
确保你已将依赖项数组指定为第二个参数!如果你忘记了依赖数组,useMemo 将每次重新运行计算:
function TodoList({ todos, tab }) {
// 🔴 每次都重新计算:没有依赖数组
const visibleTodos = useMemo(() => filterTodos(todos, tab));
// ...
})
- 不允许在循环中调用useMemo
假设 Chart 组件被包裹在 memo 中。当 ReportList 组件重新渲染时,你想跳过重新渲染列表中的每个 Chart。但是,你不能在循环中调用 useMemo:
function ReportList({ items }) {
return (
<article>
{items.map(item => {
// 🔴 你不能像这样在循环中调用 useMemo:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure key={item.id}>
<Chart data={data} />
</figure>
);
})}
</article>
);
}
相反,为每个 item 提取一个组件并为单个 item 记忆数据:
function ReportList({ items }) {
return (
<article>
{items.map(item =>
<Report key={item.id} item={item} />
)}
</article>
);
}
function Report({ item }) {
// ✅ 在顶层调用 useMemo:
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure>
<Chart data={data} />
</figure>
);
}
或者,你可以删除 useMemo 并将 Report 本身包装在 memo 中。如果 item props 没有改变,Report 将跳过重新渲染,因此 Chart 也会跳过重新渲染:
function ReportList({ items }) {
// ...
}
const Report = memo(function Report({ item }) {
const data = calculateReport(item);
return (
<figure>
<Chart data={data} />
</figure>
);
});
通过本文的学习,相信你应该真正的搞懂了useMemo,拒绝滥用useMemo