【React 性能】性能优化第一课:搞懂 `React.memo`, `useCallback`, `useMemo`

发布于:2025-08-19 ⋅ 阅读:(21) ⋅ 点赞:(0)

【React 性能】性能优化第一课:搞懂 React.memo, useCallback, useMemo

所属专栏: 《前端小技巧集合:让你的代码更优雅高效》
上一篇: 【JS 对象】轻松实现深拷贝?StructuredClone API 了解一下!
作者: 码力无边


引言:你的 React 组件,是不是得了“过度活跃症”?

嘿,各位在 React 世界里构筑梦想的道友们,我是码力无边

欢迎来到我们专栏的全新篇章——React 性能优化。在前端开发的“三座大山”(HTML/CSS/JS)之后,框架的熟练运用与深度优化,是我们迈向更高境界的必经之路。

React 以其声明式 UI、组件化和高效的 Virtual DOM,为我们带来了前所未有的开发体验。但随着应用变得越来越复杂,一个幽灵般的问题开始浮现:不必要的重新渲染 (re-render)

想象一下你的 React 应用是一个庞大的公司:

  • App 组件是 CEO。
  • 子组件是各个部门的经理。
  • 孙组件是部门里的员工。

某天,CEO (App) 只是换了一下自己的领带 (一个无关紧要的 state 变化),React 的默认机制却像一个“耿直”的传令官,大声向全公司喊道:“CEO 有变动!所有人都给我动起来,检查一下自己需不需要改变!”

于是,整个公司的所有经理、所有员工(所有子组件),无论这个变动跟他们有没有半毛钱关系,都得停下手中的工作,进行一次“自我审查”(执行 render 函数,进行 diff 比较)。虽然 Virtual DOM 的 diff 算法最终可能会发现 DOM 无需更新,但这个“自我审查”的过程本身,尤其是对于复杂的组件,是消耗性能的。

当这种“过度活跃”的行为频繁发生时,你的应用就会出现卡顿、掉帧,用户体验急剧下降。

为了治愈组件的“过度活跃症”,让它们学会“偷懒”,React 官方为我们提供了三味“镇静剂”,它们就是 React 性能优化领域里大名鼎鼎的“三驾马车”:

  • React.memo(): 包裹组件的“金钟罩”,防止不必要的 props 更新导致 re-render。
  • useCallback(): 缓存函数的“记忆胶囊”,确保函数引用地址的稳定。
  • useMemo(): 缓存计算结果的“智能缓存器”,避免昂贵的计算重复执行。

这三者是面试中的绝对高频考点,也是实战中用得最多,但最容易被误用、滥用的性能优化工具。很多人把它们当成“万金油”,哪里卡顿就往哪里抹,结果可能适得其反。

今天,码力无边就要带你彻底勘破这“三驾马车”的迷雾,用最通俗的比喻和最直观的代码,让你明白它们各自的“药理”,以及何时该“对症下药”。

一、React.memo():给组件穿上“记忆外套”

React.memo 是一个高阶组件 (Higher-Order Component, HOC),它的作用是包裹一个函数组件,让它“记住”上一次渲染时的 props。当父组件重新渲染时,memo 会对新旧 props 进行一次浅比较 (Shallow Compare)。如果 props 没有发生变化memo 就会阻止该组件的重新渲染,直接复用上一次的渲染结果。

场景: 我们有一个父组件 Parent,它有一个自己的计数器 count。它还渲染了一个子组件 Child,并传递了一个固定的 name 属性。

import React, { useState } from 'react';

// 一个普通的子组件
function Child({ name }) {
  console.log('--- 子组件 Child 重新渲染了!---');
  return <div>你好,我是 {name}</div>;
}

function Parent() {
  const [count, setCount] = useState(0);
  
  console.log('父组件 Parent 重新渲染了!');
  
  return (
    <div>
      <p>父组件的计数器: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加 count</button>
      <hr />
      <Child name="码力无边" />
    </div>
  );
}

运行这段代码,每次点击按钮,你会发现控制台同时打印出“父组件…渲染了!”和“子组件…渲染了!”。
Child 组件的 name 属性明明是固定的 “码力无边”,根本没有变!它的渲染是完全多余的。

现在,给 Child 穿上 React.memo 这件“记忆外套”:

import React, { useState, memo } from 'react';

// 用 memo 包裹子组件
const MemoizedChild = memo(function Child({ name }) {
  console.log('--- 子组件 Child 重新渲染了!---');
  return <div>你好,我是 {name}</div>;
});

function Parent() {
  // ... (Parent 组件代码不变) ...
  return (
    <div>
      {/* ... */}
      <MemoizedChild name="码力无边" />
    </div>
  );
}

再次运行,你会神奇地发现,无论你如何点击按钮,只有“父组件…渲染了!”会被打印,而 Child 组件在首次渲染后,就再也不会重新渲染了!

memo 的浅比较陷阱:
memo 默认的比较是浅比较,它只会比较 props 对象的第一层。如果你的 props 是一个对象或数组,需要特别小心。

// 假设 Child 接收一个 user 对象
<MemoizedChild user={{ name: "码力无边" }} /> 

Parent 每次 re-render 时,{ name: "码力无边" } 都会创建一个新的对象,虽然内容一样,但引用地址变了。浅比较会认为这是一个新的 prop,导致 memo 失效

这就是为什么我们需要下面两位英雄登场的原因。

二、useCallback():给函数一个“稳定的身份”

useCallback 的核心作用是在组件多次渲染之间,缓存一个函数的定义,返回该函数的一个 memoized (记忆化) 版本。这个记忆化版本只有在它的依赖项数组 (dependency array) 中的某个值发生变化时,才会重新创建。

为什么需要它?
在函数组件中,每次渲染,组件内部定义的所有函数都会被重新创建。这意味着,即使函数体完全一样,它在内存中的引用地址也是全新的。

场景: 我们给 MemoizedChild 传入一个 onClick 函数。

import React, { useState, memo } from 'react';

const MemoizedChild = memo(function Child({ name, onClick }) {
  console.log('--- 子组件 Child 重新渲染了!---');
  return <button onClick={onClick}>你好,我是 {name}</button>;
});

function Parent() {
  const [count, setCount] = useState(0);
  
  // 每次 Parent 渲染,handleClick 都是一个全新的函数
  const handleClick = () => {
    console.log('子组件被点击了!');
  };
  
  return (
    <div>
      <p>父组件的计数器: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加 count</button>
      <hr />
      <MemoizedChild name="码力无边" onClick={handleClick} />
    </div>
  );
}

运行这段代码,你会发现 memo失效了!每次点击父组件的按钮,Child 都会重新渲染。原因就是我们上面说的,每次 Parent 渲染,都会生成一个新的 handleClick 函数,它的引用地址变了,memo 的浅比较认为 onClick prop 更新了。

现在,用 useCallbackhandleClick 一个“稳定的身份”:

import React, { useState, memo, useCallback } from 'react';
// ... MemoizedChild 不变 ...

function Parent() {
  const [count, setCount] = useState(0);
  
  // 使用 useCallback 缓存函数
  // 依赖项数组为空 [],表示这个函数永远不会被重新创建
  const handleClick = useCallback(() => {
    console.log('子组件被点击了!');
  }, []); // 关键!
  
  return (
    <div>
      {/* ... */}
      <MemoizedChild name="码力无边" onClick={handleClick} />
    </div>
  );
}

再次运行,memo 的效果又回来了!useCallback 保证了 handleClick 函数在多次渲染之间是同一个引用,从而通过了 memo 的浅比较。

useCallback 的依赖项:
如果你的函数依赖于组件内的某些 state 或 props,你必须把它们加入到依赖项数组中。

const [name, setName] = useState('码力无边');
const handleClick = useCallback(() => {
  console.log(`你好,${name}`);
}, [name]); // 只有当 name 变化时,才会创建新的 handleClick 函数

忘记添加依赖项是 useCallback 最常见的 bug 来源! 这会导致函数闭包捕获到的是旧的 state/props 值。

三、useMemo():给“昂贵计算”一个“智能缓存”

useCallback(fn, deps) 其实等价于 useMemo(() => fn, deps)。它们的关系是:useCallbackuseMemo 的一个语法糖,专门用来缓存函数。而 useMemo 的能力更通用:它可以缓存任何类型的值,特别是那些需要大量计算才能得出的结果。

useMemo 的核心作用是:在组件多次渲染之间,缓存一个计算的结果。 它会执行传入的“创建”函数并记忆其结果。只有当依赖项数组中的某个值发生变化时,它才会重新计算

场景: 我们有一个很长的列表 list,我们需要根据一个 filter 条件,找出一个非常耗时才能计算出的“最优项” optimalItem

import React, { useState, useMemo } from 'react';

// 一个模拟的昂贵计算函数
function findOptimalItem(list, filter) {
  console.log('--- 正在进行昂贵的计算... ---');
  // ... 假设这里有非常复杂的循环和计算 ...
  const result = list.find(item => item.name.includes(filter));
  return result ? result.name : '未找到';
}

function App() {
  const [list, setList] = useState([
    { id: 1, name: 'React' },
    { id: 2, name: 'Vue' },
    { id: 3, name: 'Angular' }
  ]);
  const [filter, setFilter] = useState('React');
  const [unrelatedState, setUnrelatedState] = useState(0);

  // 如果没有 useMemo,每次点击“更新无关状态”按钮,都会重新进行昂贵计算
  // const optimalItem = findOptimalItem(list, filter);
  
  // 使用 useMemo 缓存计算结果
  const optimalItem = useMemo(() => {
    return findOptimalItem(list, filter);
  }, [list, filter]); // 关键:只有当 list 或 filter 变化时才重新计算
  
  return (
    <div>
      <input value={filter} onChange={e => setFilter(e.target.value)} />
      <p>最优项是: {optimalItem}</p>
      <hr />
      <p>无关状态: {unrelatedState}</p>
      <button onClick={() => setUnrelatedState(c => c + 1)}>更新无关状态</button>
    </div>
  );
}

运行这段代码,当你修改输入框时,会看到“昂贵的计算…”被打印。但是,当你点击“更新无关状态”按钮时,App 组件虽然重新渲染了,但控制台不会有任何输出!因为 listfilter 都没有变,useMemo 直接返回了上一次缓存的结果,避免了不必要的昂贵计算。

useMemo 也可以用来缓存我们之前提到的那个会导致 memo 失效的对象 prop:

const user = useMemo(() => ({ name: '码力无边' }), []);
<MemoizedChild user={user} />

通过 useMemo,我们确保了 user 对象在多次渲染之间是同一个引用。

四、终极总结:何时使用,何时不使用?

理解了它们的原理,最关键的问题来了:我应该在所有地方都用上它们吗?绝对不是!

滥用这三者的危害:

  1. 增加心智负担:你需要时刻维护正确的依赖项数组,否则就会出现 bug。
  2. 增加代码复杂度:代码会变得更长、更难读。
  3. 带来性能开销useMemouseCallback 本身也需要进行依赖项比较,这本身也有一定的开销。如果你的计算或函数本身并不昂贵,或者你的子组件渲染成本极低,使用它们可能是“负优化”。

“用药”指导原则:

  1. React.memo:

    • :当你的组件经常因为父组件的 re-render 而不必要地重新渲染,且该组件自身的渲染成本较高时。
    • 不用:如果组件很简单,渲染飞快,或者它的 props 几乎每次都会变,那就没必要用。
  2. useCallback:

    • :当你需要把一个函数传递给一个被 React.memo 包裹的子组件时,用它来保证函数引用的稳定。
    • :当你的函数作为自定义 Hook 的依赖项时(比如在 useEffect 中),为了防止 effect 不必要地重复执行。
    • 不用:对于那些只在组件内部使用,不作为 prop 传递的普通函数,几乎永远不需要用 useCallback
  3. useMemo:

    • :当你有一个计算成本非常高的结果,且它的依赖项不经常变化时。
    • :当你需要向子组件传递一个对象或数组作为 prop,并且希望它在依赖项不变时保持引用稳定,以配合 React.memo 使用时。
    • 不用:对于简单的、计算飞快的表达式,直接写在 render 函数里就好。

黄金法则:不要过早优化! 先用 React Profiler 等工具找到应用的性能瓶颈,然后有针对性地使用这些 Hooks 进行优化。它们是手术刀,不是大补丸。

写在最后:从“耿直”到“智慧”

React.memo, useCallback, useMemo 是 React 赋予我们控制渲染的强大武器。它们的核心,都是**“用空间换时间”**的思想——通过缓存(占用内存),来避免不必要的计算或渲染(节省 CPU 时间)。

从默认的“耿直”渲染,到学会用这“三驾马车”进行“智慧”的渲染,是每一位 React 开发者从入门到精通的必经之路。理解它们的本质,遵循正确的使用原则,你就能打造出如丝般顺滑、性能卓越的 React 应用。


专栏预告与互动:

我们已经掌握了 React 组件级别的性能优化。但组件的状态管理本身,也大有文章可做。useState 虽然简单,但在处理复杂状态逻辑时,往往会显得力不从心,甚至导致 bug。

下一篇,我们将深入探讨 React 状态管理的另一个核心 Hook——useReducer。你将明白何时应该告别 useState 的滥用,并拥抱 useReducer 带来的更清晰、更可预测的状态管理模式!

感觉码力无边的“性能优化心法”让你功力大增?请用点赞、收藏、关注为我“注入真气”,你的每一次支持,都是我输出更深度内容的源泉!

今日论道: useCallback(fn, deps)useMemo(() => fn, deps) 在功能上是等价的。那么 React 为什么要单独提供一个 useCallback 呢?你认为这仅仅是为了语法方便,还是有更深层次的考量(比如可读性、意图表达)?在评论区留下你的见解,我们一起探讨!


网站公告

今日签到

点亮在社区的每一天
去签到