别再误用useMemo了!这才是最佳实践的正确打开方式

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?

  1. useMemo中的内容计算很慢,并且依赖项很少改变

    如果每次更新,依赖值都会发生变化,这种情况下使用useMemo并不会得到明显的收益小效果。

  2. 将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?

  1. 减少不必要的依赖项
  2. 避免不必要的更新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 运行时,你却不知道用户做了什么(例如,点击了哪个按钮)。这就是为什么你通常应该在相应的事件处理函数中处理用户事件。

  1. 非必要不进行状态提升
    如果只是当前组件使用某个state,则不要将这个state提升到父组件去定义,然后通过props传递下来
  2. 使用打印日志或一些性能工具手段,来判断是否需要使用useMemo来进行缓存

在其他情况下,将计算过程包装在 useMemo 中没有任何好处。不过这样做也没有重大危害,所以很多人选择不考虑具体情况,尽可能多地使用 useMemo。不过这种做法会降低代码可读性,并且没有任何效果。

常见问题

  1. 依赖值为对象时,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));
// ...
})
  1. 不允许在循环中调用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

写在最后

欢迎加入前端筱园交流群:点击加入交流群
关注我的公众号【前端筱园】,不错过每一篇推送

描述文字
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

前端筱园

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值