🚀 告别卡顿!React 性能优化三剑客与闭包陷阱的深度探秘

53 阅读8分钟

嘿,各位 React 的魔法师们!👋

在这个组件化横行的时代,我们每天都在和 StateProps 打交道。你是否遇到过这样的情况:明明只是修改了一个小小的输入框,结果整个页面像得了“多动症”一样疯狂重新渲染?或者明明数据已经更新了,定时器里打印出来的却还是“上个世纪”的旧值?

别慌,这不仅是你的困惑,也是 React 进阶之路上的必修课。今天,我们就结合实际代码,来一场硬核又不失风趣的 React 性能优化 之旅!我们将深入探讨 React 的性能优化三驾马车——useMemouseCallbackmemo,并顺手解决掉那个隐蔽的“闭包陷阱”。

准备好了吗?系好安全带,我们出发!🚗


🛑 第一站:拒绝无意义的“陪跑” —— useMemo

我们先来看一个非常经典的场景:列表过滤

假设我们有一个水果列表,用户可以在输入框输入关键词来查找。我们来看一看。

场景还原

import { useState, useMemo } from 'react'

export default function App() {
  // count 和 keyword 是两个互不相关的状态
  const [count, setCount] = useState(0);
  const [keyword, setKeyword] = useState('');
  const list = ['apple','banana','orange','pear'];

  // ❌ 糟糕的写法:
  // 每次 App 组件重新渲染(比如 count 变化),这个 filter 都会重新执行!
   const filterList = list.filter(item => {
     console.log('filter执行');
     return item.includes(keyword);
   })
  
  // ...省略后续代码
}

在这个组件里,我们有两个状态:count(计数器)和 keyword(搜索词)。

发现问题: 当我们在输入框打字时,keyword 改变,filterList 重新计算,这没问题。 但是! 当我们要点击按钮让 count + 1 时,组件会重新渲染。此时,虽然 keyword 根本没变,但 list.filter 这行代码依然会再次执行。

如果 list 有一万条数据,或者过滤逻辑非常复杂,那你每点一次计数器,CPU 都在默默流泪😭。这就是典型的性能浪费

✅ useMemo 救场

React 给了我们一个 Hook 叫 useMemo,它的作用就是:只有当依赖项改变时,才重新计算

  // ✅ useMemo 缓存计算结果
  const filterList = useMemo(() => {
    // computed
    console.log('filter执行'); // 只有 keyword 变的时候才会打印
    return list.filter(item => item.includes(keyword));
  }, [keyword]); // 👈 这里的数组就是依赖项

代码解析

  1. 第一个参数:是一个函数,由于封装计算的过程。
  2. 第二个参数:是依赖数组 [keyword]
  3. 效果:React 会“记住”上一次的计算结果。当组件重新渲染时,它会检查 keyword 变没变。如果没变,直接把上次存好的结果拿给你,不再执行函数体;如果变了,才重新计算。

💡 知识链接:Vue 选手的既视感

如果你写过 Vue,你会惊呼:“这不就是 computed 吗?!” 没错,思想是完全一致的。

// Vue 示例
computed: {
  filterList() {
    // Vue 会自动收集依赖,React 需要手动声明
    return this.list.filter(item => item.includes(this.keyword));
  }
}

🐢 模拟昂贵计算

为了让大家更直观地感受到 useMemo 的威力,我们在这里模拟了一个超级慢的计算函数 slowSum

// 模拟昂贵的计算
function slowSum(n) {
  console.log('计算中。。。');
  let sum = 0;
  // 假装这里有一个非常耗时的循环
  for(let i = 0; i < n * 1000000; i++) {
    sum += i;
  }
  return sum;
}

// 组件内
const [num, setNum] = useState(0);

// 缓存昂贵的计算
const result = useMemo(() => {
  return slowSum(num);
}, [num]); // 只有 num 变了,才允许执行那个耗时的 slowSum

如果没有 useMemo,你每次在输入框里打字(更新 keyword),页面都会卡顿,因为 React 会顺便把 slowSum 也跑一遍。用了 useMemo 后,无论你怎么折腾输入框,只要 num 没变,slowSum 就不会执行,页面丝般顺滑!✨


🛡️ 第二站:给子组件穿上“防弹衣” —— memo

解决了计算的性能问题,我们再来看渲染的性能问题。

场景还原

这里有一个父组件 App 和一个子组件 Child


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

// 普通的子组件
 function Child() {
   console.log('child 重新渲染');
   return <div>子组件</div>
 }

发现问题: React 的默认行为是:只要父组件重新渲染,子组件也会无条件跟着重新渲染。 在 App 中,我们点击 count + 1,父组件更新了。虽然子组件可能根本不依赖 count,或者它依赖的 props 根本没变,但它还是会打印 'child 重新渲染'

如果子组件是一个巨大的表格或图表,这种无意义的渲染就是性能杀手。

✅ memo 高阶组件

React 提供了 memo,它是一个高阶组件(HOC),专门用来优化函数组件的性能。

// 高阶组件:参数是一个组件,返回值是一个新的组件
const Child = memo(({ count, handleClick }) => {
    console.log('child 重新渲染');
    return (
        <div onClick={handleClick}>
            子组件 {count}
        </div>
    )
})

原理大揭秘memo 会在这个组件渲染前,做一个浅比较(Shallow Compare)。 它会问:“哎,这次传进来的 counthandleClick,跟上次的一样吗?”

  • 如果一样 👉 跳过渲染,直接复用上次的 DOM。
  • 如果不一样 👉 老实渲染

这就像给子组件穿上了一层防弹衣,父组件的普通更新伤不到它。🛡️


🔗 第三站:防弹衣失效之谜 —— useCallback

但是!事情往往没有这么简单。 细心的同学可能会发现,在 App 里,我们给 Child 传了一个函数 handleClick

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

  // ❌ 问题代码:
   const handleClick = () => {
     console.log('click');
   }
  
  // ...
  return <Child count={count} handleClick={handleClick} />
}

诡异现象: 即使 Child 用了 memo,即使我们只点击 num + 1(跟 Child 用的 count 没关系),Child 依然会重新渲染!😱 防弹衣失效了?

原因深度剖析: 这是 JS 引用类型的锅。 每次 App 组件重新渲染时,函数组件内部的代码都会重新执行一遍。 这意味着 const handleClick = ... 这行代码会重新运行,生成一个全新的函数地址

对于 memo 来说:

  • 旧的 props.handleClick 指向地址 A 🏠
  • 新的 props.handleClick 指向地址 B 🏠
  • memo 判断:地址变了,Props 变了,给我重绘!

✅ useCallback 锁定引用

为了解决这个问题,我们需要把这个函数“缓存”下来,保证它的地址不变。这就是 useCallback 的职责。

  // ✅ 缓存函数
  // 只有当 count 改变时,才生成新的函数地址
  // 否则永远返回同一个函数引用
  const handleClick = useCallback(() => {
    console.log('click');
  }, [count]);

useCallback vs useMemo

  • useMemo 缓存的是函数的返回值(结果)。
  • useCallback 缓存的是函数本身

现在,当我们点击 num + 1 时,因为 count 没变,useCallback 返回旧的函数地址。Child 组件发现 count 没变,handleClick 地址也没变,于是开心地拒绝了渲染。性能优化 Get!🎉


👻 终点站:隐形的幽灵 —— 闭包陷阱

最后,我们要聊一个稍微高深一点,但极其重要的话题:闭包陷阱。 这通常发生在 useEffect 和定时器配合使用的时候。

场景还原

我们想做一个每秒打印当前 count 值的定时器。

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

  // ❌ 闭包陷阱现场
   useEffect(() => {
     const timer = setInterval(() => {
       console.log('Current count:', count);
     }, 1000)
     return () => clearInterval(timer);
   }, []); // 👈 注意这里依赖是空的
  
  // ...
}

诡异现象: 你点击按钮把 count 加到了 100,页面上也显示 100。 但是!控制台里每秒打印的依然是:Current count: 0

为什么? 🤯 这就是 JavaScript 闭包 的力量。

  1. useEffect 依赖项是 [],说明它只在组件挂载(Mount)时执行一次。
  2. 执行时,count 是 0。
  3. setInterval 创建了一个闭包,它“捕获”了那个时刻的 count(也就是 0)。
  4. 之后虽然组件更新了,count 变了,但定时器还是那个定时器,它手里攥着的依然是那个旧的 count 作用域。

✅ 破解之道:正确的依赖管理

要解决这个问题,我们需要告诉 useEffect:“嘿,count 变了,你得给我更新一下定时器!”

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Current count:', count);
    }, 1000)

    // 清理函数
    return () => {
      clearInterval(timer);
    }
  }, [count]); // ✅ 把 count 加入依赖数组

执行流程大揭秘: 很多同学以为 return 里的清理函数只在组件卸载时执行,其实不然!

  1. Mount: count 为 0。启动定时器 A(打印 0)。
  2. Update: 用户点击,count 变为 1。
  3. React 发现 [count] 变了,触发 useEffect 更新。
  4. 关键步骤:React 先执行上一次的清理函数 👉 clearInterval(timer)。定时器 A 被杀死了!👋
  5. React 执行新的 Effect 👉 启动定时器 B。此时定时器 B 捕获的是最新的 count (1)。

通过这种“销毁旧的,重建新的”机制,我们成功避开了闭包陷阱,保证了定时器里永远能拿到最新的数据。


📝 总结

React 的性能优化不仅仅是加几个 Hook 那么简单,它代表了我们对 React 底层渲染机制和 JavaScript 语言特性的理解。

  • useMemo:是计算属性的缓存,拒绝重复劳动。
  • memo:是组件的防弹衣,拒绝无谓渲染。
  • useCallback:是函数的定身术,防止引用变化击穿防弹衣。
  • 依赖数组:是 React Hooks 的灵魂,诚实地填写依赖,才能避开闭包的坑。

希望这篇文章能帮你打通 React 性能优化的任督二脉!如果你觉得有用,别忘了点赞收藏哦!你的支持是我输出硬核干货的最大动力!💖

Happy Coding! 💻