终极指南:彻底搞懂 React 的 useMemo 和 useCallback!(译)

🔗 原文链接:Understanding useMemo and useCallback

👨‍💻 原作者:Josh W. Comeau

📅 发布时间:2022年8月30日,最后更新:2025年5月9日

📢 译者说明

⚠️ 关于本译文

本文基于 Josh W. Comeau 的原文进行忠实翻译,力求准确传达原作者的技术观点和逻辑结构。

🎨 特色亮点

  • 保持原文的完整性和技术准确性
  • 采用自然流畅的中文表达,避免翻译腔
  • 添加画外音板块,提供译者的补充解读和实践心得
  • 使用生动比喻帮助理解复杂概念

💡 画外音说明 :文中标注为画外音的部分是译者基于实际开发经验添加的拓展解释,旨在帮助读者更好地理解和应用这些概念,不代表原作者观点。

📖 建议结合英文原版阅读,获得最完整的学习体验!

🚀 开篇:为什么这两个Hook让人头疼?

如果你在理解 useMemouseCallback 时遇到困难,恭喜你,你找到组织了!🎉 我遇到过太多太多的 React 开发者对这两个 hook 感到迷茫。

我写这篇文章的目标就是:让你彻底搞懂这两个神秘的Hook! 我们不仅要学会它们的用法,更要理解背后的原理和最佳实践。

准备好了吗?Let's Go!🚀

💡 适合人群

本教程专为初级/中级 React 开发者准备。如果你刚入门 React,建议先收藏,等掌握了基础概念再回来食用~


🧠 核心概念:React 重新渲染的秘密

🎭 useMemo:React 界的"记忆大师"

好的,让我们从 useMemo 开始。

useMemo 的基本思想是它允许我们在渲染之间记住一个计算值。

这个定义需要一些解释。事实上,它需要一个相当复杂的 React 工作原理的心理模型!所以让我们先解决这个问题。

React 的主要工作是让我们的 UI 与应用程序状态保持同步。它用来做到这一点的工具叫做"重新渲染"(re-render)。

每次重新渲染都是应用程序 UI 在给定时刻应该是什么样子的快照,基于当前的应用程序状态。我们可以把它想象成一叠照片,每张照片都捕捉了在特定状态变量值下事物的样子。

💡 原文互动提醒

在 Josh 的原文中,这个票务选择示例是一个可以实时互动的演示!你可以点击按钮看到状态实时变化。由于平台限制,这里只能展示静态的代码快照,建议有兴趣的同学去原文体验一下互动效果。

📸 镜头1: 用户还没选票

javascript 复制代码
{ ticketIds: [] }
// 渲染结果:红色提示"请至少选择2张票(已选0张)"

📸 镜头2: 用户选了1张票

javascript 复制代码
{ ticketIds: ['abc'] }
// 渲染结果:红色提示"请至少选择2张票(已选1张)"

📸 镜头3: 用户选了2张票

javascript 复制代码
{ ticketIds: ['abc', 'def'] }
// 渲染结果:绿色提示"✅ 已选择2张票"

每个"镜头"(重新渲染)都会基于当前状态产生一个关于 DOM 应该是什么样子的心理图像。在上面的小演示中,它被描绘为 HTML,但实际上它是一堆 JS 对象。如果你听说过这个术语,这有时被称为"虚拟 DOM"。

我们不直接告诉 React 哪些 DOM 节点需要改变。相反,我们告诉 React 基于当前状态 UI 应该是什么。通过重新渲染,React 创建一个新的快照,它可以通过比较快照来弄清楚需要改变什么,就像玩"找不同"游戏一样!🔍

就比如我们不需要手动告诉 React:"嘿,把这个按钮变红色,那个文字改一下。" 我们只要说:"现在数据是这样的,UI 应该长这样。"然后 React 自己去对比新旧两个快照,找出差异并更新。

⚠️ 小贴士等等,什么?

我最近发表了一篇博客文章,解释了什么是"重新渲染",以及它们为什么会发生。如果你感到有点困惑,可能先阅读那篇博客文章会有所帮助,然后再回到这一篇!

《Why React Re-Renders》

React 在开箱即用时就被高度优化,所以一般来说,重新渲染不是什么大问题。但是,在某些情况下,这些快照确实需要一段时间来创建。这可能导致性能问题,比如在用户执行操作后 UI 没有足够快地更新。比喻来说当你的计算量很大时,这些"拍照"过程就可能变慢,导致页面卡顿。

这就是 useMemouseCallback 的用武之地了!它们通过两种方式来拯救性能:

🚀 双重优化策略

  1. 减少单次渲染的工作量 - 避免重复计算
  2. 减少不必要的重新渲染 - 保持引用稳定

接下来,我们通过实战案例来看看这两个策略是怎么工作的!


💪 实战案例1:拯救重型计算

🧮 场景设定:质数计算器

假设我们正在构建一个工具来帮助用户找出 0 和 selectedNum 之间的所有质数,其中 selectedNum 是用户提供的值。质数是只能被 1 和自身整除的数,比如 17。

💡 画外音

这个例子很经典!质数计算是计算机科学中常见的性能测试场景,因为它既有实际意义,又能很好地展示计算密集型的特征。

以下是一个可能的实现:

jsx 复制代码
import React from 'react';

function App() {
  // 我们在状态中保存用户选择的数字。
  const [selectedNum, setSelectedNum] = React.useState(100);
  
  // 我们计算 0 和用户选择的数字 `selectedNum` 之间的所有质数:
  const allPrimes = [];
  for (let counter = 2; counter < selectedNum; counter++) {
    if (isPrime(counter)) {
      allPrimes.push(counter);
    }
  }
  
  return (
    <>
      <form>
        <label htmlFor="num">您的数字:</label>
        <input
          type="number"
          value={selectedNum}
          onChange={(event) => {
            // 为了防止计算机爆炸,
            // 我们最多设置为 100000
            let num = Math.min(100_000, Number(event.target.value));
            
            setSelectedNum(num);
          }}
        />
      </form>
      <p>
        在 1 和 {selectedNum} 之间有 {allPrimes.length} 个质数:
        {' '}
        <span className="prime-list">
          {allPrimes.join(', ')}
        </span>
      </p>
    </>
  );
}

// 计算给定数字是否为质数的辅助函数。
function isPrime(n){
  const max = Math.ceil(Math.sqrt(n));
  
  if (n === 2) {
    return true;
  }
  
  for (let counter = 2; counter <= max; counter++) {
    if (n % counter === 0) {
      return false;
    }
  }

  return true;
}

export default App;

我不期望你阅读这里的每一行代码,所以以下是相关的要点:

  • 我们有一个状态,一个叫做 selectedNum 的数字。
  • 使用 for 循环,我们手动计算 0 和 selectedNum 之间的所有质数。
  • 我们渲染一个受控的数字输入,所以用户可以改变 selectedNum
  • 我们向用户显示我们计算的所有质数。

这段代码需要大量的计算。如果用户选择一个大的 selectedNum,我们需要遍历数万个数字,检查每个数字是否为质数。而且,虽然比我上面使用的更高效的质数检查算法,但它总是计算密集型的。

画外音

想象一下,如果用户输入 25,000,我们需要检查 23,000+ 个数字,每个数字都要跑一遍质数检测算法。这可不是小菜一碟!

我们确实需要在某些时候执行这个计算,比如当用户选择新的 selectedNum 时。但如果我们在不必要的时候也重复做这项工作,就会遇到性能问题。

例如,假设我们的示例还有一个数字时钟:

jsx 复制代码
import React from 'react';
import format from 'date-fns/format';

function App() {
  const [selectedNum, setSelectedNum] = React.useState(100);
  
  // `time` 是一个每秒变化一次的状态变量,
  // 这样它总是与当前时间同步。
  const time = useTime();
  
  // 计算所有质数。
  // (与之前的例子相同。)
  const allPrimes = [];
  for (let counter = 2; counter < selectedNum; counter++) {
    if (isPrime(counter)) {
      allPrimes.push(counter);
    }
  }
  
  return (
    <>
      <p className="clock">
        {format(time, 'hh:mm:ss a')}
      </p>
      <form>
        <label htmlFor="num">您的数字:</label>
        <input
          type="number"
          value={selectedNum}
          onChange={(event) => {
            // 为了防止计算机爆炸,
            // 我们最多设置为100k
            let num = Math.min(100_000, Number(event.target.value));
            
            setSelectedNum(num);
          }}
        />
      </form>
      <p>
        在1和{selectedNum}之间有{allPrimes.length}个质数:
        {' '}
        <span className="prime-list">
          {allPrimes.join(', ')}
        </span>
      </p>
    </>
  );
}

function useTime() {
  const [time, setTime] = React.useState(new Date());
  
  React.useEffect(() => {
    const intervalId = window.setInterval(() => {
      setTime(new Date());
    }, 1000);
  
    return () => {
      window.clearInterval(intervalId);
    };
  }, []);
  
  return time;
}

// ... isPrime函数保持不变

现在我们的应用有两个状态:selectedNumtimetime 每秒更新一次来反映当前时间,用于右上角的数字时钟显示。

问题来了:无论哪个状态发生变化,都会触发那些昂贵的质数计算重新执行。 由于 time 每秒都在变,这意味着质数列表会被不断重新生成,哪怕用户选择的数字根本没变!

这就是典型的性能陷阱!JavaScript 只有一条主线程,现在我们让它忙得不可开交------每秒都要重复执行这些复杂计算。结果就是用户在操作其他功能时会感到明显的卡顿,尤其是在配置较低的设备上。

但如果我们能"跳过"这些计算会怎样?既然已经有了某个数字对应的质数列表,为什么不直接复用,而要每次都从头算起呢?

这正是 useMemo 的用武之地。来看看它的用法:

jsx 复制代码
const allPrimes = React.useMemo(() => {
  const result = [];
  for (let counter = 2; counter < selectedNum; counter++) {
    if (isPrime(counter)) {
      result.push(counter);
    }
  }
  return result;
}, [selectedNum]);

useMemo 需要两个参数:

  1. 一个函数,包含要执行的计算逻辑
  2. 依赖项数组

📝 画外音

这个设计很巧妙:第一个参数告诉 React "要做什么",第二个参数告诉它"什么时候重新做"。

组件首次渲染时,React 会调用这个函数执行所有计算逻辑,算出质数结果。函数的返回值会赋给 allPrimes 变量。

但在后续的每次渲染中,React 需要做个决定:

  1. 重新调用函数,计算新的值,还是
  2. 直接使用上次的计算结果

🧠 智能决策机制

React 就像一个聪明的助手:"老板,这次的需求跟上次一模一样吗?如果是的话,我直接把上次的结果给您!"

React 会检查依赖项数组,看看自上次渲染以来有没有任何值发生变化。如果有变化,React 就重新运行函数计算新值;如果没变化,就跳过计算直接返回缓存的结果。

说白了,useMemo 就是一个小型缓存系统,依赖项数组就是它的缓存刷新策略。

💡 画外音

这就是"记忆化"(memoization)的精髓------把计算结果"记住",避免无谓的重复计算。

在我们的例子中,相当于告诉 React:"只有当 selectedNum 改变时才重新计算质数"。当组件因为其他原因重新渲染时(比如 time 变化),useMemo 会忽略计算函数,直接返回缓存值。

这种技术叫做"记忆化"(memoization),也就是 "useMemo" 这个名字的由来。

下面这个是这个方案的完整代码:

jsx 复制代码
import React from 'react';
import format from 'date-fns/format';

function App() {
  const [selectedNum, setSelectedNum] = React.useState(100);
  const time = useTime();
  
  const allPrimes = React.useMemo(() => {
    const result = [];
    
    for (let counter = 2; counter < selectedNum; counter++) {
      if (isPrime(counter)) {
        result.push(counter);
      }
    }
    
    return result;
  }, [selectedNum]);
  
  return (
    <>
      <p className="clock">
        {format(time, 'hh:mm:ss a')}
      </p>
      <form>
        <label htmlFor="num">Your number:</label>
        <input
          type="number"
          value={selectedNum}
          onChange={(event) => {
            // To prevent computers from exploding,
            // we'll max out at 100k
            let num = Math.min(100_000, Number(event.target.value));
            
            setSelectedNum(num);
          }}
        />
      </form>
      <p>
        There are {allPrimes.length} prime(s) between 1 and {selectedNum}:
        {' '}
        <span className="prime-list">
          {allPrimes.join(', ')}
        </span>
      </p>
    </>
  );
}

function useTime() {
  const [time, setTime] = React.useState(new Date());
  
  React.useEffect(() => {
    const intervalId = window.setInterval(() => {
      setTime(new Date());
    }, 1000);
  
    return () => {
      window.clearInterval(intervalId);
    }
  }, []);
  
  return time;
}

function isPrime(n){
  const max = Math.ceil(Math.sqrt(n));
  
  if (n === 2) {
    return true;
  }
  
  for (let counter = 2; counter <= max; counter++) {
    if (n % counter === 0) {
      return false;
    }
  }

  return true;
}

export default App;

🔄 还有其他办法吗?

useMemo 确实能帮我们避免不必要的计算,但这真是最佳方案吗?

其实很多时候,通过重新设计应用架构,我们完全可以避免使用 useMemo

比如这样做:

jsx 复制代码
function App() {
  const time = useTime();
  
  return (
    <>
      <Clock time={time} />
      <PrimeCalculator />
    </>
  );
}

function Clock({ time }) {
  return (
    <p className="clock">
      {format(time, 'hh:mm:ss a')}
    </p>
  );
}

function PrimeCalculator() {
  const [selectedNum, setSelectedNum] = React.useState(100);
  
  // 计算所有质数:
  const allPrimes = [];
  for (let counter = 2; counter < selectedNum; counter++) {
    if (isPrime(counter)) {
      allPrimes.push(counter);
    }
  }
  
  return (
    <>
      <form>
        <label htmlFor="num">您的数字:</label>
        <input
          type="number"
          value={selectedNum}
          onChange={(event) => {
            let num = Math.min(100_000, Number(event.target.value));
            setSelectedNum(num);
          }}
        />
      </form>
      <p>
        在1和{selectedNum}之间有{allPrimes.length}个质数:
        {' '}
        <span className="prime-list">
          {allPrimes.join(', ')}
        </span>
      </p>
    </>
  );
}

我把功能拆成了两个独立组件:ClockPrimeCalculator。它们各自管理自己的状态,互不干扰。一个组件重新渲染时,不会波及到另一个。

🎯 画外音

这就是好架构的魅力!各司其职,界限分明,既提高了性能又增强了可维护性。

大家都在说"状态提升",但有时候"状态下沉"才是王道! 每个组件都该专注于一件事,而上面例子中的 App 组件却在同时管理两个毫不相干的功能。

当然,这招也不是万能的。在大型实际项目中,很多状态必须提升到较高层级,没法下沉。

💡 画外音

状态管理就像走钢丝------既不能过度集中导致不必要的重渲染,也不能过度分散让数据传递变得复杂。

碰到这种情况,我还有一个绝招。

比如说,我们必须把 time 变量提升到 PrimeCalculator 上面:

jsx 复制代码
// App.js
import React from 'react';
import { getHours } from 'date-fns';

import Clock from './Clock';
import PrimeCalculator from './PrimeCalculator';

// Transform our PrimeCalculator into a pure component:
const PurePrimeCalculator = React.memo(PrimeCalculator);

function App() {
  const time = useTime();

  // Come up with a suitable background color,
  // based on the time of day:
  const backgroundColor = getBackgroundColorFromTime(time);

  return (
    <div style={{ backgroundColor }}>
      <Clock time={time} />
      <PurePrimeCalculator />
    </div>
  );
}

const getBackgroundColorFromTime = (time) => {
  const hours = getHours(time);
  
  if (hours < 12) {
    // A light yellow for mornings
    return 'hsl(50deg 100% 90%)';
  } else if (hours < 18) {
    // Dull blue in the afternoon
    return 'hsl(220deg 60% 92%)'
  } else {
    // Deeper blue at night
    return 'hsl(220deg 100% 80%)';
  }
}

function useTime() {
  const [time, setTime] = React.useState(new Date());
  
  React.useEffect(() => {
    const intervalId = window.setInterval(() => {
      setTime(new Date());
    }, 1000);
  
    return () => {
      window.clearInterval(intervalId);
    }
  }, []);
  
  return time;
}

export default App;
jsx 复制代码
// PrimeCalculator.jsx
import React from 'react';

function PrimeCalculator() {
  const [selectedNum, setSelectedNum] = React.useState(100);

  const allPrimes = [];
  for (let counter = 2; counter < selectedNum; counter++) {
    if (isPrime(counter)) {
      allPrimes.push(counter);
    }
  }
  
  return (
    <>
      <form>
        <label htmlFor="num">Your number:</label>
        <input
          type="number"
          value={selectedNum}
          onChange={(event) => {
            // To prevent computers from exploding,
            // we'll max out at 100k
            let num = Math.min(100_000, Number(event.target.value));
            
            setSelectedNum(num);
          }}
        />
      </form>
      <p>
        There are {allPrimes.length} prime(s) between 1 and {selectedNum}:
        {' '}
        <span className="prime-list">
          {allPrimes.join(', ')}
        </span>
      </p>
    </>
  );
}

function isPrime(n){
  const max = Math.ceil(Math.sqrt(n));
  
  if (n === 2) {
    return true;
  }
  
  for (let counter = 2; counter <= max; counter++) {
    if (n % counter === 0) {
      return false;
    }
  }

  return true;
}

export default PrimeCalculator;
jsx 复制代码
// Clock.jsx
import React from 'react';
import format from 'date-fns/format';

function Clock({ time }) {
  return (
    <p className="clock">
      {format(time, 'hh:mm:ss a')}
    </p>
  );
}

export default Clock;

React.memo 就像一道防护罩,把组件包裹起来,阻挡无关的更新干扰。 我们的 PurePrimeCalculator 只会在收到新数据或内部状态变化时才重新渲染。

这就是所谓的"纯组件"。 说白了,就是告诉 React:"这个组件很乖,同样的输入必然产生同样的输出,没变化就别重新渲染了。"

🛡️ 画外音
React.memo 就像给组件装了个智能门卫,只有 props 真的变了才放行,否则一律拦截。

我在博客文章《Why React Re-Renders》中详细解释了 React.memo 的工作机制。

🏗️ 更常规的做法

在上面的例子中,我对导入的 PrimeCalculator 组件应用了 React.memo

说实话,这有点不寻常。我选择这样构建是为了让所有内容都在同一个文件中可见,更容易理解。

在实践中,我经常将 React.memo 应用到组件导出,像这样:

jsx 复制代码
// PrimeCalculator.js
function PrimeCalculator() {
 /* 组件内容 */
}
export default React.memo(PrimeCalculator);

现在我们的 PrimeCalculator 组件将始终是纯组件,无需在消费时进行调整。

如果我们需要 PrimeCalculator 的非纯版本,我们可以将底层组件作为命名导出。不过我认为我从不需要这样做。

这里有一个有趣的视角转换:之前,我们记忆化的是特定计算的结果(计算质数)。但是上面这种情况,我是记忆化了整个组件。

无论哪种方式,昂贵的计算只会在用户选择新的 selectedNum 时重新运行。但我们优化的是父组件,而不是特定的慢代码行。

我不是说一种方法比另一种更好;每种工具都有其用武之地。但在这种特定情况下,我更喜欢这种方法。

现在,如果你曾经在真实环境中尝试使用纯组件,你可能注意到了一些奇怪的现象:纯组件经常重新渲染,即使看起来什么都没变! 😬

这很好地引导我们进入 useMemo 解决的第二个问题。

🎯 更多替代方案

在 Dan Abramov 的 Before You memo() 文章中,他分享了另一种基于重构应用使用 children 的方法,以避免任何记忆化的需要。

感谢 Yuval Shimoni 指出这一点!


🔗 实战案例2:保持引用稳定

🤯 JavaScript 的引用相等性陷阱

在下面的例子中,我创建了一个 Boxes 组件。它显示一组彩色方块,用于某种装饰目的。

我还有一些无关的状态:用户的姓名。

jsx 复制代码
// App.js
import React from 'react';

import Boxes from './Boxes';

function App() {
  const [name, setName] =
    React.useState('');
  const [boxWidth, setBoxWidth] =
    React.useState(1);

  const id = React.useId();

  // 尝试改变这些值!
  const boxes = [
    {
      flex: boxWidth,
      background: 'hsl(345deg 100% 50%)',
    },
    {
      flex: 3,
      background: 'hsl(260deg 100% 40%)',
    },
    {
      flex: 1,
      background: 'hsl(50deg 100% 60%)',
    },
  ];

  return (
    <>
      <Boxes boxes={boxes} />

      <section>
        <div className="row">
          <label htmlFor={`${id}-name`}>
            姓名:
          </label>
          <input
            id={`${id}-name`}
            type="text"
            value={name}
            onChange={(event) => {
              setName(event.target.value);
            }}
          />
        </div>
        <label htmlFor={`${id}-box-width`}>
          第一个方块的宽度:
        </label>
        <input
          id={`${id}-box-width`}
          type="range"
          min={1}
          max={5}
          step={0.01}
          value={boxWidth}
          onChange={(event) => {
            setBoxWidth(
              Number(event.target.value)
            );
          }}
        />
      </section>
    </>
  );
}

export default App;
jsx 复制代码
// Boxes.js
import React from 'react';

const Boxes = React.memo(function Boxes({ boxes }) {
  return (
    <div className="boxes-wrapper">
      {boxes.map((boxStyles, index) => (
        <div
          key={index}
          className="box"
          style={boxStyles}
        />
      ))}
    </div>
  );
});

export default Boxes;

Boxes 是一个纯组件,这要归功于 Boxes.js 中默认导出时用 React.memo() 包装。这意味着它应该只在 props 改变时重新渲染。

然而 ,每当用户更改他们的姓名时,Boxes 也会重新渲染!

这到底是怎么回事?!为什么我们的 React.memo() 力场没有保护我们??

Boxes 组件只有一个 prop,boxes,看起来我们在每次渲染时都给它完全相同的数据。它总是相同的东西:一个红色方块,一个宽的紫色方块,一个黄色方块。我们确实有一个影响 boxes 数组的 boxWidth 状态变量,但我们没有改变它!

问题在于 : 每次 React 重新渲染时,我们都会产生一个全新的数组 。它们在 方面是等价的,但在引用方面不是!

我认为如果我们暂时忘记 React,谈论普通的 JavaScript,会很有帮助。让我们看一个类似的情况:

javascript 复制代码
function getNumbers() {
  return [1, 2, 3];
}

const firstResult = getNumbers();
const secondResult = getNumbers();

console.log(firstResult === secondResult);

你觉得呢?firstResult 等于 secondResult 吗?

从某种意义上说,它们是。两个变量都持有相同的结构 [1, 2, 3]。但这并不是 === 操作符实际检查的内容。

相反,=== 检查两个表达式是否是同一个东西

我们创建了两个不同的数组。它们可能持有相同的内容,但它们不是同一个数组,就像两个同卵双胞胎不是同一个人一样。

每次我们调用 getNumbers 函数时,我们都会创建一个全新的数组,一个存储在计算机内存中的独特事物。如果我们多次调用它,我们会在内存中存储这个数组的多个副本。

注意,简单数据类型------如字符串、数字和布尔值------可以按值比较。但当涉及到数组和对象时,它们只能按引用 比较。有关这种区别的更多信息,请查看 Dave Ceddia 的这篇精彩博客文章:《A Visual Guide to References in JavaScript》

回到 React : 我们的 Boxes React 组件也是一个 JavaScript 函数。当我们渲染它时,我们调用那个函数:

javascript 复制代码
// 每次我们渲染这个组件时,我们调用这个函数...
function App() {
  // ...最终创建一个全新的数组...
  const boxes = [
    { flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
    { flex: 3, background: 'hsl(260deg 100% 40%)' },
    { flex: 1, background: 'hsl(50deg 100% 60%)' },
  ];

  // ...然后作为 prop 传递给这个组件!
  return (
    <Boxes boxes={boxes} />
  );
}

name 状态改变时,我们的 App 组件重新渲染,这会重新运行所有代码。我们构造一个全新的 boxes 数组,并将其传递给我们的 Boxes 组件。

Boxes 重新渲染,是因为我们给了它一个全新的数组!

boxes 数组的结构 在渲染时并没有改变,但这并不重要。React 只知道 boxes prop 收到了一个新鲜创建的、从未见过的数组。

💊 useMemo 来解毒

为了解决这个问题,我们可以使用 useMemo hook:

jsx 复制代码
const boxes = React.useMemo(() => {
  return [
    { flex: boxWidth, background: 'hsl(345deg 100% 50%)' },
    { flex: 3, background: 'hsl(260deg 100% 40%)' },
    { flex: 1, background: 'hsl(50deg 100% 60%)' },
  ];
}, [boxWidth]);

与我们之前看到的质数例子不同,我们在这里不担心计算昂贵的计算。我们的唯一目标是保持对特定数组的引用

我们将 boxWidth 列为依赖项,因为我们确实 希望 Boxes 组件在用户调整红色方块宽度时重新渲染。

我认为一个快速草图会帮助说明。之前,我们在每个快照中创建一个全新的数组:

使用 useMemo后,我们其实是在重新使用之前创建的 boxes 数组:

通过在多次渲染中保持相同的引用,我们让纯组件能够按我们想要的方式工作,这样就能跳过那些不影响 UI 的渲染。


🎣 useCallback:函数的专属记忆术

好的,关于 useMemo 的内容就到这里了...那么 useCallback 呢?🤔

简单版本:它和 useMemo 完全一样,但是针对函数而不是数组/对象。就像给函数配了个专属的记忆管家!🧠

与数组和对象类似,函数也是通过引用而不是值来比较的:

javascript 复制代码
const functionOne = function() {
  return 5;
};
const functionTwo = function() {
  return 5;
};
console.log(functionOne === functionTwo); // false

这意味着如果我们在组件内定义函数,它会在每次渲染时重新生成,每次都产生一个相同但独特的函数。就像每次拍照都洗出一张新照片,虽然内容一样,但每张都是独立的!📸

让我们看一个例子:

jsx 复制代码
// App.js
import React from 'react';
import MegaBoost from './MegaBoost';

function App() {
  const [count, setCount] =
    React.useState(0);

  function handleMegaBoost() {
    setCount(
      (currentValue) => currentValue + 1234
    );
  }

  return (
    <>
      Count: {count}
      <button
        onClick={() => {
          setCount(count + 1);
        }}
      >
        Click me!
      </button>
      <MegaBoost
        handleClick={handleMegaBoost}
      />
    </>
  );
}

export default App;
jsx 复制代码
// MegaBoost.js
import React from 'react';

function MegaBoost({ handleClick }) {
  console.info(
    'Render Boxes ' +
      new Date().toLocaleTimeString(
        'en-US'
      )
  );
  
  return (
    <button
      className="mega-boost-button"
      onClick={handleClick}
    >
      MEGA BOOST!
    </button>
  );
}

export default React.memo(MegaBoost);

这个沙盒展示了一个典型的计数器应用,但有一个特殊的"超级加速"按钮。这个按钮可以大幅增加计数,以防你赶时间不想多次点击标准按钮。就像游戏里的"一键升级"功能!🚀

MegaBoost 组件是一个纯组件,这要归功于 React.memo。它不依赖于 count...但是每当 count 改变时,它也会重新渲染! 😱

就像我们看到的 boxes 数组一样,这里的问题是我们每次渲染都会生成一个全新的函数。如果我们渲染 3 次,我们会创建 3 个独立的 handleMegaBoost 函数,突破了 React.memo 的力场。就像每次都给同一个演员发了一张新的身份证!🎭

使用我们学到的关于 useMemo 的知识,我们可以这样解决问题:

jsx 复制代码
const handleMegaBoost = React.useMemo(() => {
  return function() {
    setCount(currentValue => currentValue + 1234);
  };
}, []);

我们不是返回一个数组,而是返回一个函数 。这个函数然后存储在 handleMegaBoost 变量中。

这确实有效...但是有更好的方法!就像用瑞士军刀切面包,虽然也能用,但专门的面包刀会更顺手!🔪

jsx 复制代码
const handleMegaBoost = React.useCallback(() => {
  setCount(currentValue => currentValue + 1234);
}, []);

useCallback 服务于与 useMemo 相同的目的,但它是专门为函数构建的。我们直接给它一个函数,它会记忆化那个函数,在渲染之间传递它。就像给函数配了个专属的"身份证管理系统"!🆔

换句话说,这两个表达式具有相同的效果:

jsx 复制代码
// 这个:
React.useCallback(function helloWorld(){}, []);

// ...在功能上等同于这个:
React.useMemo(() => function helloWorld(){}, []);

useCallback 是语法糖。 它的存在纯粹是为了让我们的生活在我们试图记忆化回调函数时变得更好一点。就像咖啡里的糖,不是必需的,但能让体验更甜蜜!☕


⚖️ 使用时机:什么时候才需要?

好的,我们已经看到了 useMemouseCallback 如何让我们在多次渲染之间传递引用,以重用复杂计算或避免破坏纯组件。问题是:我们应该多久使用一次?🤔

在我看来,将每个对象/数组/函数都包装在这些 hook 中会浪费时间。在大多数情况下,好处是微不足道的;React 是进行过高度优化的,重新渲染通常不像我们经常认为的那样慢或昂贵!就像给每粒米都包保鲜膜,虽然能保鲜,但太麻烦了!🍚

使用这些 hook 的最佳方式是对问题的响应。如果你注意到你的应用变得有点迟缓,你可以使用 React Profiler 来追踪慢渲染。在某些情况下,你可以通过重构应用程序来改善性能。在其他情况下,useMemouseCallback 可以帮助加速。

(如果你不确定如何使用 React profiler,我在最近的博客文章 《Why React Re-Renders》中介绍了它!)

话虽如此,有一些场景我还是会主动应用这些 hooks。

这在未来可能会改变! 🚀

React 团队正在积极研究是否有可能在编译步骤中"自动记忆化"代码。它仍处于研究阶段,但早期的实验看起来很有希望。

可能在未来,所有这些事情 React 都会自动为我们完成。但在此之前,我们仍然需要自己优化。就像自动驾驶还没完全普及前,我们还得自己开车一样!🚗

更多信息,请查看 Xuan Huang 的演讲《React without memo》

🛠️ 在通用自定义 hook 中

我最喜欢的自定义 hook 之一是 useToggle,它是一个友好的助手,几乎可以完全像 useState 一样工作,但只能在 truefalse 之间切换状态变量。就像给开关配了个智能遥控器!🔌

jsx 复制代码
function App() {
  const [isDarkMode, toggleDarkMode] = useToggle(false);

  return (
    <button onClick={toggleDarkMode}>
      Toggle color theme
    </button>
  );
}

这个自定义 hook 的定义如下:

jsx 复制代码
function useToggle(initialValue) {
  const [value, setValue] = React.useState(initialValue);

  const toggle = React.useCallback(() => {
    setValue(v => !v);
  }, []);

  return [value, toggle];
}

注意 toggle 函数用 useCallback 记忆化了。

当我构建这样的自定义可重用 hook 时,我喜欢让它们尽可能高效,因为我不知道它们将来会在哪里使用。虽然这样做在 95% 的情况下可能是过度优化,但如果我在项目中使用了这个 Hook 30 或 40 次,这种优化很可能会显著提升整个应用的性能。就像给工具箱里的每个工具都做了保养,虽然平时用不到,但关键时刻不会掉链子!🛠️

🌐 在 Context Provider 中

当我们通过 context 在应用程序中共享数据时,通常会将一个大对象作为 value 属性传递。

通常建议记忆化这个对象:

jsx 复制代码
const AuthContext = React.createContext({});

function AuthProvider({ user, status, forgotPwLink, children }){
  const memoizedValue = React.useMemo(() => {
    return {
      user,
      status,
      forgotPwLink,
    };
  }, [user, status, forgotPwLink]);

  return (
    <AuthContext.Provider value={memoizedValue}>
      {children}
    </AuthContext.Provider>
  );
}

为什么这样做有好处? 因为可能有几十个纯组件都在使用这个 context。如果没有 useMemo,当 AuthProvider 的父组件发生重新渲染时,所有这些消费 context 的组件都会被强制重新渲染。就像连锁反应,一个喷嚏传染了整个办公室!🤧


🌟 React 的乐趣

呼!你终于坚持到了最后。我知道这个教程涵盖的内容确实有些复杂。😅

我理解这两个 Hook 确实很棘手,React 本身也常常让人感到不知所措和困惑。这确实是一个有挑战性的工具!就像学骑自行车,一开始摇摇晃晃,但一旦掌握就自由飞翔!🚴‍♂️

但是,如果你能挺过最初的学习阶段,React 绝对会给你带来极大的乐趣。

我从 2015 年开始使用 React,现在它已经成为我构建复杂用户界面和 Web 应用程序的最爱。我几乎尝试过所有的 JS 框架,但都没有 React 让我感到如此高效。就像找到了最适合自己的编程语言,写代码就像写诗一样流畅!📝

如果你在 React 学习路上遇到困难,我随时在这里帮助你!

在过去的两年里,我一直在精心打造一个名为《React 的乐趣》的课程。这是一个专为初学者设计的自定进度互动在线课程,教你如何用 React 构建令人惊艳的酷炫应用。

如果你觉得这篇博客文章对你有一点点帮助,我相信我的课程会给你带来更多收获。与这篇博客文章不同,课程不仅包含这样的互动文章,还有视频教程、练习项目、迷你游戏,甚至一些真实世界风格的项目。这是一次真正的动手学习之旅。就像从看菜谱到真正下厨的升级版!👨‍🍳

你可以了解更多关于课程的信息,体验用 React 构建的无限乐趣:

🎯 知识解锁

恭喜你!现在你已经掌握了这两个强大的 Hook:

  • useMemo:记住计算结果
  • useCallback:记住函数本身
  • 共同目标:避免重复工作,提升性能

👏 感谢阅读

这篇文章翻译自 Josh W. Comeau 的优秀教程。Josh 是 React 社区的大神级人物,他的文章总是深入浅出,强烈推荐关注!

如果这篇文章帮助你理解了 useMemouseCallback,请:

  • 👍 点个赞支持一下
  • 💬 评论区分享你的看法
  • 🔄 转发给更多需要的小伙伴

让我们一起在学习 React 的道路上越走越远!🚀


🔖 关键词标签:React、useMemo、useCallback、性能优化、React Hooks、前端开发

相关推荐
cc蒲公英4 分钟前
uniapp x swiper/image组件mode=“aspectFit“ 图片有的闪现后黑屏
java·前端·uni-app
前端小咸鱼一条8 分钟前
React的介绍和特点
前端·react.js·前端框架
谢尔登20 分钟前
【React】fiber 架构
前端·react.js·架构
哈哈哈哈哈哈哈哈85324 分钟前
Vue3 的 setup 与 emit:深入理解 Composition API 的核心机制
前端
漫天星梦26 分钟前
Vue2项目搭建(Layout布局、全局样式、VueX、Vue Router、axios封装)
前端·vue.js
ytttr8731 小时前
5G毫米波射频前端设计:从GaN功放到混合信号集成方案
前端·5g·生成对抗网络
水鳜鱼肥1 小时前
Github Spark 革新应用,重构未来
前端·人工智能
前端李二牛1 小时前
现代CSS属性兼容性问题及解决方案
前端·css
贰月不是腻月1 小时前
凭什么说我是邪修?
前端
中等生2 小时前
一文搞懂 JavaScript 原型和原型链
前端·javascript