嘿,各位 React 的魔法师们!👋
在这个组件化横行的时代,我们每天都在和 State、Props 打交道。你是否遇到过这样的情况:明明只是修改了一个小小的输入框,结果整个页面像得了“多动症”一样疯狂重新渲染?或者明明数据已经更新了,定时器里打印出来的却还是“上个世纪”的旧值?
别慌,这不仅是你的困惑,也是 React 进阶之路上的必修课。今天,我们就结合实际代码,来一场硬核又不失风趣的 React 性能优化 之旅!我们将深入探讨 React 的性能优化三驾马车——useMemo、useCallback、memo,并顺手解决掉那个隐蔽的“闭包陷阱”。
准备好了吗?系好安全带,我们出发!🚗
🛑 第一站:拒绝无意义的“陪跑” —— 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]); // 👈 这里的数组就是依赖项
代码解析:
- 第一个参数:是一个函数,由于封装计算的过程。
- 第二个参数:是依赖数组
[keyword]。 - 效果: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)。
它会问:“哎,这次传进来的 count 和 handleClick,跟上次的一样吗?”
- 如果一样 👉 跳过渲染,直接复用上次的 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 闭包 的力量。
useEffect依赖项是[],说明它只在组件挂载(Mount)时执行一次。- 执行时,
count是 0。 setInterval创建了一个闭包,它“捕获”了那个时刻的count(也就是 0)。- 之后虽然组件更新了,
count变了,但定时器还是那个定时器,它手里攥着的依然是那个旧的count作用域。
✅ 破解之道:正确的依赖管理
要解决这个问题,我们需要告诉 useEffect:“嘿,count 变了,你得给我更新一下定时器!”
useEffect(() => {
const timer = setInterval(() => {
console.log('Current count:', count);
}, 1000)
// 清理函数
return () => {
clearInterval(timer);
}
}, [count]); // ✅ 把 count 加入依赖数组
执行流程大揭秘:
很多同学以为 return 里的清理函数只在组件卸载时执行,其实不然!
- Mount:
count为 0。启动定时器 A(打印 0)。 - Update: 用户点击,
count变为 1。 - React 发现
[count]变了,触发useEffect更新。 - 关键步骤:React 先执行上一次的清理函数 👉
clearInterval(timer)。定时器 A 被杀死了!👋 - React 执行新的 Effect 👉 启动定时器 B。此时定时器 B 捕获的是最新的
count(1)。
通过这种“销毁旧的,重建新的”机制,我们成功避开了闭包陷阱,保证了定时器里永远能拿到最新的数据。
📝 总结
React 的性能优化不仅仅是加几个 Hook 那么简单,它代表了我们对 React 底层渲染机制和 JavaScript 语言特性的理解。
- useMemo:是计算属性的缓存,拒绝重复劳动。
- memo:是组件的防弹衣,拒绝无谓渲染。
- useCallback:是函数的定身术,防止引用变化击穿防弹衣。
- 依赖数组:是 React Hooks 的灵魂,诚实地填写依赖,才能避开闭包的坑。
希望这篇文章能帮你打通 React 性能优化的任督二脉!如果你觉得有用,别忘了点赞收藏哦!你的支持是我输出硬核干货的最大动力!💖
Happy Coding! 💻