第二章前置知识:2.8 useMemo 基础知识

169 阅读13分钟

本专栏致力于每周分享一个项目,如果本文对你有帮助的话,欢迎点赞或者关注☘️

React18 源码系列会随着学习 React 源码的实时进度而实时更新:约,两天一小改,五天一大改。

useMemo是一个 React Hook,可让您在重新渲染之间缓存计算结果。

const cachedValue = useMemo(calculateValue, dependencies)

在组件的顶层调用useMemo来缓存重新渲染之间的计算:

import { useMemo } from 'react';

function TodoList({ todos, tab }) {
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab]
  );
  // ...
}

参数:

  • calculateValue :计算要缓存的值的函数。它应该是纯的,不应该接受任何参数,并且应该返回任何类型的值。 React 将在初始渲染期间调用您的函数。在下一次渲染时,如果自上次渲染以来dependencies没有更改,React 将再次返回相同的值。否则,它将调用calculateValue ,返回其结果并存储它以便以后可以重用。
  • dependenciescalculateValue代码中引用的所有反应值的列表。反应性值包括 props、state 以及直接在组件体内声明的所有变量和函数。依赖项列表必须具有恒定数量的项目,并且像[dep1, dep2, dep3]那样内联编写。 React 将使用[Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)比较将每个依赖项与其之前的值进行比较。

return:

  • 在初始渲染时, useMemo返回不带参数调用calculateValue的结果。
  • 在下一次渲染期间,它将返回上次渲染中已存储的值(如果依赖项未更改),或者再次调用calculateValue ,并返回calculateValue返回的结果。

用法

跳过昂贵的重新计算

要缓存重新渲染之间的计算,请将其包装在组件顶层的useMemo调用中:

import { useMemo } from 'react';

function TodoList({ todos, tab, theme }) {
  const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
  // ...
}

您需要将两件事传递给useMemo:

  • 一个不带参数的计算函数,例如() => ,并返回您想要计算的内容。
  • 依赖项列表,包括计算中使用的组件内的每个值。

在初始渲染时,您从useMemo获得的值将是调用您的calculation的结果。

在每次后续渲染中,React 都会将依赖项与您在上次渲染期间传递的依赖项进行比较。如果没有任何依赖项发生更改(与[Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)相比), useMemo将返回您之前计算过的值。否则,React 将重新运行您的计算并返回新值。

换句话说, useMemo会在重新渲染之间缓存计算结果,直到其依赖项发生变化。

默认情况下,React 每次重新渲染时都会重新运行组件的整个主体。例如,如果此TodoList更新其状态或从其父级接收新的 props,则filterTodos函数将重新运行:

function TodoList({ todos, tab, theme }) {

  const visibleTodos = filterTodos(todos, tab);

  // ...

}

通常,这不是问题,因为大多数计算都非常快。但是,如果您正在过滤或转换大型数组,或者进行一些昂贵的计算,则在数据未更改的情况下,您可能需要跳过再次执行此操作。如果todostab都与上次渲染期间相同,则像之前一样将计算包装在useMemo中,这样您就可以重用之前已经计算过的visibleTodos

您应该仅依赖useMemo作为性能优化。 如果您的代码没有它就无法工作,请首先找到根本问题并修复它。然后你可以添加useMemo来提高性能。

如何判断计算是否昂贵

一般来说,除非您要创建或循环数千个对象,否则它可能并不昂贵。如果您想获得更多信心,可以添加控制台日志来测量一段代码所花费的时间:

console.time('filter array');

const visibleTodos = filterTodos(todos, tab);

console.timeEnd('filter array');

执行您正在测量的交互(例如,在输入中键入内容)。然后,您将在控制台中看到类似filter array: 0.15ms的日志。如果记录的总时间加起来很大(例如1ms或更多),那么记住该计算结果可能是有意义的。

您应该在各处添加 useMemo 吗

如果您的应用程序类似于此网站,并且大多数交互都很粗糙(例如替换页面或整个部分),则通常不需要记忆。另一方面,如果您的应用程序更像是绘图编辑器,并且大多数交互都是细粒度的(例如移动形状),那么您可能会发现记忆非常有用。

使用useMemo进行优化仅在少数情况下有价值:

  • 您在useMemo中进行的计算明显很慢,并且其依赖关系很少发生变化。
  • 您将它作为 prop 传递给包含在[memo](https://react.dev/reference/react/memo)中的组件。如果值没有更改,您希望跳过重新渲染。仅当依赖项不相同时,记忆化才允许您的组件重新渲染。
  • 您传递的值稍后将用作某些 Hook 的依赖项。例如,也许另一个useMemo计算值取决于它。或者您可能依赖于[useEffect.](https://react.dev/reference/react/useEffect)

在其他情况下,将计算包装在useMemo中没有任何好处。这样做也没有太大的危害,因此一些团队选择不考虑个别案例,并尽可能多地记住。这种方法的缺点是代码的可读性较差。此外,并非所有记忆都是有效的:“始终是新的”单个值足以破坏整个组件的记忆。

在实践中,您可以通过遵循以下几条原则来避免大量记忆:

  • 当一个组件在视觉上包装其他组件时,让它接受 JSX 作为子组件。这样,当包装器组件更新自己的状态时,React 知道它的子组件不需要重新渲染。
  • 保持渲染逻辑纯净。如果重新渲染组件导致问题或产生一些明显的视觉伪像,则这是组件中的错误!修复错误而不是添加记忆。
  • 更推荐保持本地状态,并且不要在不必要的情况下进一步提升状态
  • 避免update state 的不必要的 Effect。 React 应用程序中的大多数性能问题都是由源自 Effects 的更新链引起的,这些更新会导致组件反复渲染。
  • 尝试从Effect中删除不必要的依赖项。例如,将某些对象或函数移动到效果内部或组件外部通常比记忆更简单。

跳过组件的重新渲染

在某些情况下, useMemo还可以帮助您优化重新渲染子组件的性能。为了说明这一点,假设此TodoList组件将visibleTodos作为 prop 传递给子List组件:

export default function TodoList({ todos, tab, theme }) {

  // ...

  return (

    <div className={theme}>

      <List items={visibleTodos} />

    </div>

  );

}

您已经注意到,切换theme属性会使应用程序冻结一会儿,但如果您从 JSX 中删除<List /> ,感觉会很快。这告诉您值得尝试优化List组件。

默认情况下,当组件重新渲染时,React 会递归地重新渲染其所有子组件。 这就是为什么当TodoList使用不同的theme重新渲染时, List组件也会重新渲染。这对于不需要太多计算来重新渲染的组件来说很好。但是,如果您已经验证重新渲染速度很慢,则可以通过将其包装在 memo 中来告诉List当其 props 与上次渲染相同时跳过重新渲染[memo](https://react.dev/reference/react/memo)

import { memo } from 'react';

const List = memo(function List({ items }) {
  // ...
});

通过此更改,如果 List 的所有 props 与上次渲染时相同,则List将跳过重新渲染。

export default function TodoList({ todos, tab, theme }) {
  // Every time the theme changes, this will be a different array...
  const visibleTodos = filterTodos(todos, tab);
  return (
    <div className={theme}>
      {/* ... so List's props will never be the same, and it will re-render every time */}
      <List items={visibleTodos} />
    </div>
  );
}

在上面的示例中, filterTodos函数始终创建一个不同的数组, 类似于{}对象字面量始终创建新对象的方式。通常,这不会成为问题,但这意味着Listprop永远不会相同,并且您的[memo](https://react.dev/reference/react/memo)优化将不起作用。这就是useMemo派上用场的地方:

export default function TodoList({ todos, tab, theme }) {
  // Tell React to cache your calculation between re-renders...
  const visibleTodos = useMemo(
    () => filterTodos(todos, tab),
    [todos, tab] // ...so as long as these dependencies don't change...
  );
  return (
    <div className={theme}>
      {/* ...List will receive the same props and can skip re-rendering */}
      <List items={visibleTodos} />
    </div>
  );
}

记住各个 JSX 节点

您可以将<List /> JSX 节点本身包装在useMemo中,而不是将List包装在[memo](https://react.dev/reference/react/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不会被重新渲染。

<List items={visibleTodos} />这样的 JSX 节点是一个像这样的对象 { type: List, props: { items: visibleTodos } } 。创建这个对象非常便宜,但是React不知道它的内容是否与上次相同。这就是为什么默认情况下,React 会重新渲染List组件。

但是,如果 React 看到与之前渲染期间完全相同的 JSX,它不会尝试重新渲染您的组件。这是因为 JSX 节点是不可变的。 JSX 节点对象不可能随着时间的推移而改变,因此 React 知道跳过重新渲染是安全的。然而,要使其发挥作用,节点实际上必须是同一个对象,而不仅仅是在代码中看起来相同。这就是useMemo在此示例中所做的事情。

手动将 JSX 节点包装到useMemo中并不方便。例如,您不能有条件地执行此操作。这通常就是为什么你会用[memo](https://react.dev/reference/react/memo)包装组件而不是包装 JSX 节点。

防止Effect过于频繁地触发

有时,您可能想在Effect 中使用一个值:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = {
    serverUrl: 'https://localhost:1234',
    roomId: roomId
  }

  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    // ...

这就产生了一个问题。每个反应值都必须声明为 Effect 的依赖项。但是,如果您将options声明为依赖项,则会导致您的 Effect 不断重新连接到聊天室:

 useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 🔴 Problem: This dependency changes on every render
  // ...

为了解决这个问题,您可以将需要从 Effect 中调用的对象包装在useMemo中:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = useMemo(() => {
    return {
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    };
  }, [roomId]); // ✅ Only changes when roomId changes

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // ✅ Only changes when createOptions changes
  // ...

如果useMemo返回缓存的对象,这可以确保options对象在重新渲染之间是相同的。

然而,由于useMemo是性能优化,而不是语义保证,如果有特定原因, React 可能会丢弃缓存的值。这也会导致Effect重新触发,因此最好通过将对象移动到Effect来消除对函数依赖的需要:

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = { // ✅ No need for useMemo or object dependencies!
      serverUrl: 'https://localhost:1234',
      roomId: roomId
    }

    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ Only changes when roomId changes
  // ...

记住另一个 Hook 的依赖项

假设您有一个计算依赖于直接在组件主体中创建的对象:

function Dropdown({ allItems, text }) {
  const searchOptions = { matchMode: 'whole-word', text };

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // 🚩 Caution: Dependency on an object created in the component body
  // ...

依赖这样的对象就失去了记忆的意义。当组件重新渲染时,组件主体内部的所有代码都会再次运行。创建searchOptions对象的代码行也将在每次重新渲染时运行。 由于searchOptionsuseMemo调用的依赖项,并且每次都不同,React 知道依赖项不同,并且每次都会重新计算searchItems

要解决此问题,您可以在将searchOptions对象作为依赖项传递之前先记住

function Dropdown({ allItems, text }) {
  const searchOptions = useMemo(() => {
    return { matchMode: 'whole-word', text };
  }, [text]); // ✅ Only changes when text changes

  const visibleItems = useMemo(() => {
    return searchItems(allItems, searchOptions);
  }, [allItems, searchOptions]); // ✅ Only changes when allItems or searchOptions changes
  // ...

在上面的示例中,如果text没有更改,则searchOptions对象也不会更改。然而,更好的解决方法是将searchOptions对象声明移至useMemo计算函数内部

function Dropdown({ allItems, text }) {
  const visibleItems = useMemo(() => {
    const searchOptions = { matchMode: 'whole-word', text };
    return searchItems(allItems, searchOptions);
  }, [allItems, text]); // ✅ Only changes when allItems or text changes
  // ...

记忆一个函数

假设Form组件包装在[memo](https://react.dev/reference/react/memo)中。你想将一个函数作为 prop 传递给它:

export default function ProductPage({ productId, referrer }) {
  function handleSubmit(orderDetails) {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails
    });
  }

  return <Form onSubmit={handleSubmit} />;
}

正如{}创建不同的对象一样,函数声明(如function() {}和表达式(如() => {}在每次重新渲染时都会生成不同的函数。就其本身而言,创建新函数不是问题。这不是要避免的事情!但是,如果Form组件已被记忆,那么您可能希望在没有更改 props 时跳过重新渲染它。总是不同的prop会破坏记忆的意义

要使用useMemo记忆函数,您的计算函数必须返回另一个函数:

export default function Page({ productId, referrer }) {
  const handleSubmit = useMemo(() => {
    return (orderDetails) => {
      post('/product/' + productId + '/buy', {
        referrer,
        orderDetails
      });
    };
  }, [productId, referrer]);

  return <Form onSubmit={handleSubmit} />;
}

这看起来很笨重!记忆函数很常见,React 有一个专门用于此的内置 Hook。将函数包装到[useCallback](https://react.dev/reference/react/useCallback)而不是useMemo中, 以避免编写额外的嵌套函数:

export default function Page({ productId, referrer }) {
  const handleSubmit = useCallback((orderDetails) => {
    post('/product/' + productId + '/buy', {
      referrer,
      orderDetails
    });
  }, [productId, referrer]);

  return <Form onSubmit={handleSubmit} />;
}

上面的两个例子是完全等价的。 useCallback的唯一好处是它可以让您避免在内部编写额外的嵌套函数。它没有做任何其他事情

故障排除

严格模式下,React 将调用某些函数两次而不是一次:

function TodoList({ todos, tab }) {
  // This component function will run twice for every render.

  const visibleTodos = useMemo(() => {
    // This calculation will run twice if any of the dependencies change.
    return filterTodos(todos, tab);
  }, [todos, tab]);

  // ...

这是预期的结果,不会破坏您的代码。

例如,这个不纯的计算函数会改变您作为 prop 收到的数组:

  const visibleTodos = useMemo(() => {

    // 🚩 Mistake: mutating a prop

    todos.push({ id: 'last', text: 'Go for a walk!' });

    const filtered = filterTodos(todos, tab);

    return filtered;

  }, [todos, tab]);

您的计算不应更改任何现有对象,但可以更改您在计算期间创建的任何对象。例如,如果filterTodos函数始终返回不同的数组,您可以更改数组:

  const visibleTodos = useMemo(() => {

    const filtered = filterTodos(todos, tab);

    // ✅ Correct: mutating an object you created during the calculation

    filtered.push({ id: 'last', text: 'Go for a walk!' });

    return filtered;

  }, [todos, tab]);

每次我的组件渲染时, useMemo中的计算都会重新运行

确保您已将依赖项数组指定为第二个参数!

如果你忘记了依赖数组, useMemo每次都会重新运行计算:

当您发现哪个依赖项破坏了记忆化时,要么找到一种方法将其删除,要么也将其记忆化。

我需要为循环中的每个列表项调用useMemo ,但这是不允许的

假设Chart组件包装在[memo](https://react.dev/reference/react/memo)中。当ReportList组件重新呈现时,您希望跳过重新呈现列表中的每个Chart 。但是,您不能在循环中调用useMemo

function ReportList({ items }) {
  return (
    <article>
      {items.map(item => {
        // 🔴 You can't call useMemo in a loop like this:
        const data = useMemo(() => calculateReport(item), [item]);
        return (
          <figure key={item.id}>
            <Chart data={data} />
          </figure>
        );
      })}
    </article>
  );
}

相反,为每个项目提取一个组件并记住各个项目的数据:

function ReportList({ items }) {
  return (
    <article>
      {items.map(item =>
        <Report key={item.id} item={item} />
      )}
    </article>
  );
}

function Report({ item }) {
  // ✅ Call useMemo at the top level:
  const data = useMemo(() => calculateReport(item), [item]);
  return (
    <figure>
      <Chart data={data} />
    </figure>
  );
}

或者,您可以删除useMemo并将Report本身包装在[memo](https://react.dev/reference/react/memo)中。如果item属性没有改变, Report将跳过重新渲染,因此Chart也将跳过重新渲染:

function ReportList({ items }) {
  // ...
}

const Report = memo(function Report({ item }) {
  const data = calculateReport(item);
  return (
    <figure>
      <Chart data={data} />
    </figure>
  );
});

参考链接

关于作者

作者:Wandra

内容:算法 | 趋势 |源码|Vue | React | CSS | Typescript | Webpack | Vite | GithubAction | GraphQL | Uniqpp。

专栏:欢迎关注呀🌹

本专栏致力于每周分享一个项目,如果本文对你有帮助的话,欢迎点赞或者关注☘️