精读React hooks(七):用useMemo来减少性能开销

🧑‍💻
推荐全栈学习资源:
  • Next.js 中文文档:样式和官网一样的中文文档,创造沉浸式Next.js中文学习体验。
  • 《Chrome插件全栈开发》:真实出海项目的实战教学课,讲解Chrome插件和Next.js端的全栈开发,帮助你半个月内成为全栈出海工程师。
  • 在前端框架的激烈竞争中,性能往往被视为评判一个框架好坏的关键指标。拥有卓越性能的框架可以为开发者在面对数据密集或高度交互的应用时,提供更加流畅、高效的开发体验。

    为了更好地满足开发者对于细粒度性能控制的需求,React推出了useMemo这一Hook。这个工具为我们在函数组件内部提供了一个优雅的手段,允许我们针对复杂的计算进行精细化的优化,从而避免不必要的渲染重复。接下来的文章,我们将深入探讨useMemo的定义、使用方法以及如何在日常开发中最大化地发挥其潜力。

    useMemo定义

    useMemo是React框架中的一个重要Hook,它的核心目的是通过缓存计算结果,避免在组件渲染时进行不必要的重复计算,从而优化性能。这意味着只有当其依赖项发生变化时,useMemo才会重新计算这个值,否则它将重用之前的结果。

    它的基本使用格式如下:

    const cachedValue = useMemo(calculateValue, dependencies)
    • calculateValue:这是一个用于计算我们想要缓存的值的函数。为了确保结果的稳定性和预测性,这个函数应该是一个纯函数。这意味着,它在相同的输入下总是返回相同的输出,并且没有任何副作用。
    • dependencies:这是一个数组,包含useMemo所依赖的变量或值。当数组中的任何值发生变化时,calculateValue函数将被重新执行。

    useMemo基础用法

    useMemo 接受两个参数:一个函数和一个依赖项数组。

    const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

    在上面的例子中,computeExpensiveValue是一个可能需要很长时间来计算的函数。我们只有当ab改变时,才重新调用这个函数。否则,我们会使用之前缓存的值。

    用一个例子来看useMemo的执行时机:

    import React, { useMemo, useState } from "react";
     
    function filterUsers(users, searchTerm) {
        return users.filter((user) => user.name.includes(searchTerm));
    }
     
    function useMemoDemo() {
      const [searchTerm, setSearchTerm] = useState("");
      const [isDark, setIsDark] = useState(false);
     
      const allUsers = useMemo(() => {
        let list = [];
        for (let i = 1; i <= 500; i++) {
          list.push({ id: i, name: `User${i}` });
        }
        return list;
      }, []);
     
      const useMemoCurrentUsers = useMemo(() => {
        console.log('with useMemo')
        return filterUsers(allUsers, searchTerm);
      }, [allUsers, searchTerm]);
     
      return (
        <div>
          {/* 每一次更改查询框内容,都会触发useMemo */}
          <input
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            placeholder="Search by name..."
          />
     
          {/* 每一次更改背景色,都不会触发useMemo */}
          <button onClick={() => setIsDark((pre) => !pre)}>
            {isDark ? "Dark mode" : "Light mode"}
          </button>
     
          <div>
            <div>
              <h2>With useMemo</h2>
              <div style={{ background: isDark ? "#000" : "" }}>
                {useMemoCurrentUsers.map((user) => (
                  <div key={user.id}>
                    {user.name}
                  </div>
                ))}
              </div>
            </div>
          </div>
      </div>
      );
    }
     
    export default useMemoDemo;

    在这里简单的示例中,每次修改查询框的内容,都会触发searchTerm的变化,进而触发useMemo重新计算;而点击切换背景色的按钮,因为useMemo的依赖项没有更新,所以不会触发useMemo重新计算,而是直接使用上一次计算的返回值。

    是否使用useMemo的区别

    使用 useMemo 与否,究竟有何差异?很遗憾,得益于高效的现代 JavaScript 引擎和优秀的浏览器性能,大多数场景下,我们用肉眼几乎无法看出来区别。例如下面这个示例,你也可以到我的演示站体验。

    import React, { useMemo, useState } from "react";
     
    function filterUsers(users, searchTerm) {
        return users.filter((user) => user.name.includes(searchTerm));
    }
     
    function Comparison1() {
      const [searchTerm, setSearchTerm] = useState("");
      const [isDark, setIsDark] = useState(false);
     
      const allUsers = useMemo(() => {
        let list = [];
        for (let i = 1; i <= 500; i++) {
          list.push({ id: i, name: `User${i}` });
        }
        return list;
      }, []);
     
      const useMemoCurrentUsers = useMemo(() => {
        console.log('with useMemo')
        return filterUsers(allUsers, searchTerm);
      }, [allUsers, searchTerm]);
     
      console.log('without useMemo')
      const withoutUseMemoCurrentUsers = filterUsers(allUsers, searchTerm);
     
      return (
        <div>
          <input
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            placeholder="Search by name..."
          />
     
          <button onClick={() => setIsDark((pre) => !pre)}>
            {isDark ? "Dark mode" : "Light mode"}
          </button>
     
          <div>
            <div>
              <h2>With useMemo</h2>
              <div style={{ background: isDark ? "#000" : "" }}>
                {useMemoCurrentUsers.map((user) => (
                  <div key={user.id}>
                    {user.name}
                  </div>
                ))}
              </div>
            </div>
            <div>
              <h2>Without useMemo</h2>
                <div style={{ background: isDark ? "#000" : "" }}>
                  {withoutUseMemoCurrentUsers.map((user) => (
                    <div key={user.id}>
                      {user.name}
                    </div>
                  ))}
                </div>
            </div>
          </div>
        </div>
      );
    }
     
    export default Comparison1;

    这个示例实际效果是无论你修改查询框内容还是切换背景色,对照组的变化几乎是同步的。

    既然useMemo无法带来视觉上的差异,我们为什么还要使用useMemo?因为useMemo的可以在更细粒度的层面优化我们的应用性能:

    1. 重新计算的开销

      当我们面对涉及大量数据处理、循环或其他复杂逻辑的场景时,重复不必要的计算可能会导致浏览器的卡顿,从而影响用户体验。

    2. 渲染的开销

      当我们谈论 React 性能时,经常考虑的不仅仅是计算的速度,但更重要的是避免不必要的渲染。如果某个子组件依赖于一个对象或数组,并且这个对象或数组在每次父组件渲染时都重新创建,即使实际的数据没有改变,那么子组件也会不必要地重新渲染。

      在这种情况下,useMemo可以帮助我们确保对象或数组的引用在数据不变时保持不变,从而避免子组件的不必要渲染。

    打开浏览器控制台,我们可以看到页面首次渲染时,useMemoCurrentUserswithoutUseMemoCurrentUsers都有进行计算,但是点击切换背景的按钮时,useMemoCurrentUsers没有重新计算,而withoutUseMemoCurrentUsers是每次都重新计算的。这恰好印证了上面所说的“节省渲染开销”。

    541shots_so.png

    缓存组件

    useMemo的作用不知局限于缓存数据,它还可以缓存组件。

    如果你在渲染某个组件时有昂贵的计算,并且你想在某些依赖未改变时避免这些计算,那么也可以使用useMemo来缓存这个组件。用法如下:

    function ParentComponent(props) {
      const [someData, setSomeData] = useState(initialData);
     
      const MemoizedExpensiveComponent = useMemo(() => {
        return <ExpensiveComponent data={someData} />;
      }, [someData]);
     
      return (
        <div>
          {MemoizedExpensiveComponent}
          {/* 其他组件和逻辑 */}
        </div>
      );
    }

    如果上一节的示例,我们想把用户列表的组件缓存起来,可以这么做:

    function UserList({ allUsers, searchTerm }) {
      const filteredUsers = filterUsers(allUsers, searchTerm);
      return (
        <>
          {useMemoCurrentUsers.map((user) => (
            <div key={user.id}>
              {user.name}
            </div>
          ))}
        </>
      );
    }
     
    function Comparison1() {
      // ……
     
      const MemoizedUserList = useMemo(() => {
        return <UserList allUsers={allUsers} searchTerm={searchTerm} />;
      }, [allUsers, searchTerm]);
     
      return (
        <div>
          {/* …… */}
            <h2>With useMemo</h2>
            <div style={{ background: isDark ? "#000" : "" }}>
              {MemoizedUserList}
            </div>
          {/* …… */}
        </div>
      );
    }
     
    export default Comparison1;

    现在,整个UserList组件被缓存,只有当allUserssearchTermisDark发生变化时,才会重新计算。

    除了useMemo能够缓存组件,React 还提供了memo这个高阶组件来完成相似的工作。

    const UserList = memo(function UserList({ allUsers, searchTerm }) {
      const filteredUsers = filterUsers(allUsers, searchTerm);
      return (
        <>
          {filteredUsers.map((user) => (
            <div key={user.id}>
              {user.name}
            </div>
          ))}
        </>
      );
    });
     
    function Comparison1() {
      // ……
     
      return (
        <div>
          {/* …… */}
          <h2>With useMemo</h2>
            <div style={{ background: isDark ? "#000" : "" }}>
              <UserList allUsers={allUsers} searchTerm={searchTerm} />
            </div>
          {/* …… */}
        </div>
      );
    }
     
    export default Comparison1;

    使用memo高阶组件包裹后,只有当props发生变化时重新渲染。这种方式和上面使用useMemo缓存组件的作用是一样的,但代码可读性更强,也是React官方更推荐的方式。

    考虑到useMemomemo的特点,我们可以这么说:

    • 当你想避免因为数据变化而产生的不必要的计算时,使用**useMemo;**
    • 当你想避免因为props未变而产生的不必要的组件重渲染时,使用**memo**。

    有无使用memo的效果对比也可以在我的演示站体验。

    缓存函数

    除了缓存数据和组件,useMemo还能够缓存函数。你只需要在useMemoreturn一个函数即可,如下:

    const handleUserClick = useMemo(() => {
      return (userName) => {
        alert(`Clicked on: ${userName}`);
      };
    }, []);

    不过,React有专门缓存函数的hook——useCallback,也是下一篇文章要精读的hook,所以这里就不展开说了。

    反例:过渡优化

    useMemo是用于优化的工具,但有时,如果过度使用它们,可能会导致性能更差或代码更加复杂。下面看一个过度优化的例子:

    想象这个场景,我们有一个简单的组件,只显示一个数字和一个按钮,点击按钮会增加这个数字。

    import React, { useState, useMemo } from 'react';
     
    function Counter() {
      const [count, setCount] = useState(0);
     
      const handleIncrease = useMemo(() => {
        return () => {
          setCount(count + 1);
        };
      }, [count]);
     
      return (
        <div>
          <div>{count}</div>
          <button onClick={handleIncrease}>Increase</button>
        </div>
      );
    }
     
    export default Counter;

    这里的问题是:

    1. 不必要的复杂性:原始的组件是简单的,并且性能表现得很好。引入useMemo只是增加了代码的复杂性,而并没有带来任何实际的性能提升。
    2. 增加了性能开销useMemo本身也有开销。在这种情况下,任何由useMemo带来的小优势都被其自身的开销抵消了,因为每次count变化,handleIncrease都会重新计算。
    3. 可读性下降:对于后来查看代码的其他开发者来说,看到useMemo可能会引起困惑。他们可能会花费时间思考为什么这里需要优化,尽管实际上并不需要。

    综上所述,不是所有的组件都需要优化,特别是当它们已经足够简单和高效的时候。过度使用优化工具可能会导致更差的性能和更低的代码可读性。

    结语

    使用useMemo的关键是权衡。其目的是避免不必要的计算,但也要注意不要滥用,因为维持这些缓存值也是有开销的。最佳的做法是先写出清晰和可读的代码,然后在性能瓶颈出现时,再考虑优化。

    本文对照示例均可在我的演示站体验,TypeScript版源码在我的Github查看

    专栏资源

    专栏博客地址:精读React Hooks

    专栏演示站:React Hooks Demos

    专栏源码仓库:👉Github - Source Code

    交个朋友:👉加入「独立全栈交流群」

    专栏文章列表:

    精读React hooks(一):useState 的几个基础用法和进阶技巧

    精读React hooks(二):React状态管理的强大工具——useReducer

    精读React hooks(三):useContext从基础应用到性能优化

    精读React hooks(四):useRef的多维用途

    精读React hooks(五):useEffect使用细节知多少?

    精读React hooks(六):useLayoutEffect解决了什么问题?

    精读React hooks(七):用useMemo来减少性能开销

    精读React hooks(八):我们为什么需要useCallback

    精读React hooks(九):使用useTransition进行非阻塞渲染

    精读React hooks(十):使用useDeferredValue延迟状态更新

    精读React hooks(十一):useInsertionEffect——CSS-in-JS样式注入新方式

    精读React hooks(十二):使用useImperativeHandle能获得什么能力

    精读React hooks(十三):使用useSyncExternalStore获取实时数据

    精读React hooks(十四):总有一天你会需要useId为你生成唯一id

    精读React hooks(十五):把useDebugValue加入你的React调试工具库

    精读React hooks(十六):一个为代码优雅而生的hook——use