别让一次 setState 引发雪崩:React 性能优化实战全解

41 阅读8分钟

驯服 React 的“蝴蝶效应”:精准使用 useMemo 与 useCallback 的实战心法

在大厂面试或高阶前端开发中,性能优化往往是区分“API 调用工程师”与“真正理解 React 的开发者”的关键分水岭。

我们都知道,React 的核心哲学是  “状态驱动视图”  ——状态一变,组件重跑。这套机制让数据流清晰可控,却也埋下了一个隐形陷阱:蝴蝶效应

父组件里一个微不足道的状态更新(比如用户点了个赞),可能像推倒第一块多米诺骨牌,引发连锁反应:

  • 昂贵的计算逻辑被重复执行;
  • 本不该更新的子组件被迫重绘;
  • 主线程卡顿,用户体验骤降。

于是,你翻出文档,祭出 useMemo 和 useCallback,信心满满地加上 React.memo……
结果却发现:

  • 子组件依然在重渲染
  • 页面性能毫无改善,甚至更差;
  • 代码变得晦涩难懂,同事 review 时直摇头。

问题出在哪?

很多人把这三个 API 当成“万能胶水”,以为只要包上就能提速。但真相是:

它们不是性能加速器,而是“精准控制缓存”的工具。用错场景,反而会拖慢应用。

今天,我们就抛开源码黑话,通过两个真实业务场景,彻底讲透这“三剑客”的协作逻辑、适用边界与常见陷阱
读完本文,你将不再盲目加 useCallback,而是能精准识别瓶颈、合理使用缓存、写出既高效又可维护的代码

实战演练 I:从“算力浪费”到“按需计算” —— 详解 useMemo

1. 由于“连坐”导致的性能浪费

看看下面这段代码,我们模拟了一个非常耗时的计算函数 slowSum,以及一个列表过滤功能。

🔴 问题代码:

import { useState } from 'react';

// 模拟一个昂贵的计算过程(比如处理大数据或复杂数学公式)
function slowSum(n) {
  console.log('计算中...');
  let sum = 0;
  // 这里的循环次数非常多,会阻塞主线程
  for (let i = 0; i < n * 10000000; i++) {
    sum += i;
  }
  return sum;
}

export default function App() {
  const [count, setCount] = useState(0); // 一个无关紧要的计数器
  const [keyword, setKeyword] = useState(''); // 搜索关键词
  const [num, setNum] = useState(0); // 参与昂贵计算的数字
  
  const list = ['apple', 'banana', 'orange', 'pear'];

  // 【问题点 1】列表过滤
  // 即使 keyword 没变,只要 count 变了,filter 都会重新执行
  const filterList = list.filter(item => {
    console.log('filter 执行'); 
    return item.includes(keyword);
  });
  
  // 【问题点 2】昂贵计算
  // 即使 num 没变,只要 count 变了,slowSum 都会重新跑一遍
  const result = slowSum(num);

  return (
    <div>
      <p>结果:{result}</p>
      <input value={keyword} onChange={e => setKeyword(e.target.value)} />
      {/* 点击这里,会导致整个组件重新运行 */}
      <button onClick={() => setCount(count + 1)}>count+ 1</button>
      {/* ...省略列表渲染代码... */}
    </div>
  )
}

20260107-0705-02.0160776.gif

2. 深度剖析

当你点击 count + 1 按钮时,React 会重新执行 App 函数。

  • 对于 filterList:JS 引擎会再次遍历数组。虽然 includes("") 对于空字符串返回 true 是符合预期的,但如果列表有几千条数据,每次点赞都过滤一遍,显然是不合理的。
  • 对于 result:灾难发生了。slowSum 会再次执行千万次循环。用户会感觉到明显的页面卡顿,仅仅是因为改了一个无关的 count

3. 解决办法:使用 useMemo 建立缓存

🟢 优化后代码:

import { useState, useMemo } from 'react';

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

export default function App() {
  const [count, setCount] = useState(0); 
  const [keyword, setKeyword] = useState('');
  const [num, setNum] = useState(0);
  const list = ['apple', 'banana', 'orange', 'pear'];

  // ✅ 优化 1:缓存过滤结果
  const filterList = useMemo(() => {
    // 只有当 keyword 变化时,才重新计算
    return list.filter(item => item.includes(keyword))
  }, [keyword]); 
  
  // ✅ 优化 2:缓存昂贵计算
  const result = useMemo(() => {
    // 只有当 num 变化时,才重新运行 slowSum
    return slowSum(num)
  }, [num]);

  return (
    <div>
       {/* UI 代码保持不变 */}
    </div>
  )
}

4. 原理与比喻:老板的“智能记账本”

你可以把 useMemo 想象成老板(组件)手里的一个智能记账本

  • 场景:老板问:“1000 万次循环的结果是多少?”
  • 没有 useMemo:员工每次都要拿出计算器重新算一遍,满头大汗。
  • 有 useMemo:老板在记账本上写下 [num] 作为索引。
    • 老板问第二次,如果 num 没变,直接看记账本:“上次算过了,结果是 X,不用算了。”
    • 如果 num 变了,老板才会说:“数字变了,这一页作废,重新算。”

核心知识点:

  • 依赖项数组useMemo 的第二个参数至关重要。React 依靠它来判断是否复用缓存。
  • 使用前提:组件中有明显的“计算属性”需求,且计算成本较高。

实战演练 II:穿越“引用陷阱” —— useCallback 与 React.memo 的组合拳

1. 组件的“无辜”重绘

React 的数据流是单向的:父组件管理数据,子组件展示数据。通常父组件更新,子组件也会跟着更新。为了性能,我们给子组件穿上了“防弹衣” —— React.memo

但是,请看下面的代码,为什么“防弹衣”失效了?

🔴 问题代码:

import { useState, memo } from 'react';

// 子组件使用 memo 包裹,理论上 Props 没变就不该渲染
const Child = memo(({ count, handleClick }) => {
    console.log('child 重新渲染');
    handleClick(); // 这里的调用仅作演示
    return <div>子组件 count: {count}</div>
})

export default function App() {
    const [count, setCount] = useState(0);
    const [num, setNum] = useState(0);

    // 【问题点】:定义在组件内部的普通函数
    // 每次 App 重新渲染,handleClick 都会被重新创建,生成新的内存地址
    const handleClick = () => {
       console.log('Click');
    }

    return (
        <div>
            {/* 修改 num,App 重绘 */}
            <button onClick={() => setNum(num + 1)}>num+1</button>
            
            {/* 把函数传给子组件 */}
            <Child count={count} handleClick={handleClick} />
        </div>
    )
}

image.png

2. 深度剖析

当你点击 num + 1 时:

  1. App 组件重新挂载。
  2. handleClick 变量被重新赋值。在 JavaScript 中,函数是引用类型。虽然代码逻辑没变,但handleClick 指向了一个全新的内存地址
  3. Child 组件的 memo 开始工作,它对比 Props:
    • prevProps.count vs nextProps.count -> 没变。
    • prevProps.handleClick vs nextProps.handleClick -> 变了!(引用地址不同)
  4. memo 判定 Props 发生变化,允许子组件重新渲染。

结果:即使 Child 不依赖 num,它也被迫重新渲染了。

3. 解决办法:使用 useCallback 锁定引用

🟢 优化后代码:

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

// Child 组件保持不变,依然需要 memo 包裹
const Child = memo(({count, handleClick}) => {
    console.log('child 重新渲染');
    handleClick();
    return <div>子组件 count: {count}</div>
})

export default function App(){
    const [count, setCount] = useState(0);
    const [num, setNum] = useState(0);

    // ✅ 优化:使用 useCallback 缓存函数引用
    // 依赖项是 [count],意味着只要 count 不变,handleClick 永远是同一个引用
    const handleClick = useCallback(() => {
       console.log('Click');
    }, [count]);

    return(
        <div>
            {count}
            <button onClick={() => setCount(count+1)}>count+1</button>
            {num}
            {/* 点击 num+1,App 重绘,但 Child 不会重绘! */}
            <button onClick={() => setNum(num+1)}>num+1</button>

            <Child count={count} handleClick={handleClick} />
        </div>
    )
}

4. 原理与比喻:门卫与身份证

我们可以把 React.memo 比作子组件门口的严格门卫,而 handleClick 函数就是父组件发给子组件的身份证

  • 没有 useCallback (引用变动): 每次父组件刷新,都给子组件重新印了一张身份证。虽然上面的名字、照片都一样,但**证件编号(内存地址)**变了。 门卫(memo)一看:“编号不对,这是假证!或者是新证!进去重新登记(渲染)!”

  • 有 useCallback (引用稳定)useCallback 就像是给函数办了一张永久身份证。 只要依赖项(count)没变,父组件不管刷新多少次,发给子组件的永远是同一张旧身份证(同一个内存引用)。 门卫(memo)一看:“哦,老熟人了,证件编号没变。你不用重新登记了,直接复用上次的状态吧。”


终极心法:性能优化的决策模型与反模式

通过上面的代码剖析,我们可以清晰地界定这三个工具的职责:

  1. useMemo:是脑力节省者

    • 职责:缓存计算结果(Value)。
    • 场景:当你不想因为无关渲染而重复执行昂贵的计算(如大数组过滤、复杂数学运算)时使用。
  2. React.memo:是组件门卫

    • 职责:决定子组件是否需要渲染。
    • 场景:当子组件渲染开销较大,且经常被父组件的无关更新“连累”时使用。
  3. useCallback:是身份签证官

    • 职责:缓存函数引用(Function Reference)。
    • 场景专门配合 React.memo 使用。如果你把函数传给一个加了 memo 的子组件,必须用 useCallback 包裹该函数,否则 memo 将形同虚设。

🚀 总结:一份可落地的“避坑指南”

通过上面的实战剖析,我们终于可以给这三个 Hook 发放“工牌”了。下次在代码中遇到性能瓶颈时,请对照这张决策表:

工具 (Hook)角色比喻核心职责最佳适用场景
useMemo智能记账本缓存结果 (Value)1. 及其昂贵的计算(如万级数据过滤、复杂图形算法)
2. 避免因对象引用变化导致的 useEffect 无限循环
React.memo严格门卫拦截渲染 (Component)组件渲染成本较高,且经常被父组件的无关更新“误伤”时
useCallback永久身份证锁定引用 (Function)必须配合 React.memo 使用。将函数作为 Props 传给子组件时,防止因引用变化导致 memo 失效

⚠️ 最后的警示:不要为了优化而优化

性能优化从来都不是免费的午餐,它是有成本的。

  1. 内存换时间:useMemo 和 useCallback 都会在内存中持有对旧值的引用,滥用会导致内存飙升。
  2. 代码复杂度:满屏的依赖项数组(Dependency Array)会极大地增加代码的阅读门槛,稍有不慎还会引入“闭包陷阱”。

我的建议:
在 React 中,大部分轻量级的计算和组件渲染其实是非常快的(毫秒级)。请抵制“过早优化”的诱惑。
只有当你真正感觉到页面交互有肉眼可见的卡顿(Lag) ,或者通过 React DevTools Profiler 抓到了明确的性能红点时,才是这“三剑客”拔剑出鞘的高光时刻。

如果这篇文章帮你理清了思路,欢迎点赞收藏,让更多被 React 重绘折磨的开发者看到!