对于 React 学习者来说,掌握基础的 JSX 和 useState 往往只是第一步。当你开始构建更复杂的应用时,你可能会遇到一些令人困惑的现象:为什么我的组件在疯狂重新渲染?为什么定时器里的数据永远是最旧的?
这篇文章将带你深入 React 的渲染机制,通过三个经典的实战场景,彻底搞懂 useMemo、useCallback 的核心作用,以及那个让无数新手“翻车”的闭包陷阱。
一、 为什么我们需要“缓存”?
首先,我们需要建立一个核心认知:React 组件本质上就是一个函数。
每当组件的状态(State)或属性(Props)发生变化时,这个函数就会从头到尾重新运行一次。这个过程被称为“重新渲染”(Re-render)。在大多数情况下,这非常快。但是,如果你的组件里包含了大量的计算逻辑,或者你的组件树非常深,无脑的“重算”就会导致页面卡顿。
React 提供了三个 Hook 来帮助我们“缓存”数据,避免无意义的消耗:useMemo、useCallback 和 React.memo。
二、 场景一:拒绝昂贵的重复计算 (useMemo)
想象这样一个场景:我们需要对一个包含大量数据的列表进行关键词过滤。同时,页面上还有一个毫无关联的计数器按钮。
1. 问题代码
在这个版本中,每次点击“计数器”按钮导致状态更新,组件函数都会重新执行。这意味着 slowList 的过滤逻辑和 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 list = ['apple', 'orange', 'peach', 'banana'];
// 🔴 性能问题:
// 每次点击 count+1,组件重新渲染,filter 都会重新执行
const filterList = list.filter(item => {
console.log('Filter 逻辑被触发了');
return item.includes(keyword);
});
// 🔴 性能问题:
// 每次组件渲染,这个昂贵的求和函数都会运行,阻塞页面
const result = slowSum(10);
return (
<div>
<h3>结果: {result}</h3>
{/* 这里的输入框改变会导致 keyword 更新 */}
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="输入关键词过滤"
/>
{/* 这里的点击会导致 count 更新,进而触发整个组件重绘 */}
<button onClick={() => setCount(count + 1)}>
Count: {count} (点我也许会卡顿)
</button>
<ul>
{filterList.map(item => <li key={item}>{item}</li>)}
</ul>
</div>
);
}
2. 优化方案:使用 useMemo
useMemo 的作用是缓存计算结果。它类似于 Vue 中的 computed 属性。它接收两个参数:
- 计算函数。
- 依赖项数组:只有数组里的变量变了,计算函数才会重新执行。
import { useState, useMemo } from 'react';
// ... slowSum 函数保持不变 ...
export default function App() {
const [count, setCount] = useState(0);
const [keyword, setKeyword] = useState('');
const [num, setNum] = useState(10);
const list = ['apple', 'orange', 'peach', 'banana'];
// ✅ 优化 1:缓存列表过滤结果
// 只有当 keyword 变化时,才会重新执行过滤逻辑
const filterList = useMemo(() => {
console.log('Filter 逻辑执行');
return list.filter(item => item.includes(keyword));
}, [keyword]); // 依赖项是 keyword
// ✅ 优化 2:缓存昂贵的数学计算
// 只有当 num 变化时,slowSum 才会重新运行
const result = useMemo(() => {
return slowSum(num);
}, [num]); // 依赖项是 num
return (
<div>
<p>结果: {result}</p>
{/* 这里的操作现在非常流畅,因为 filterList 和 result 都是直接取缓存值 */}
<button onClick={() => setCount(count + 1)}>
Count + 1 (不会触发重算)
</button>
{/* ... 省略渲染部分 ... */}
</div>
);
}
现在,当你点击 count + 1 时,控制台不会再打印 "Filter 逻辑执行" 或 "正在进行昂贵的计算",因为 React 直接复用了上一次的结果。
三、 场景二:防止子组件“无辜陪跑” (useCallback)
React 有一个默认行为:当父组件重新渲染时,所有的子组件也会跟着重新渲染,无论子组件的 Props 有没有变化。
1. 使用 React.memo 锁住子组件
为了阻止这种“连坐”效应,我们可以使用高阶组件 memo。它的作用是:只有当 Props 发生浅比较变化时,才允许子组件重新渲染。
import { useState, memo } from 'react';
// 使用 memo 包裹子组件
const Child = memo(({ count, handleClick }) => {
console.log('子组件渲染了'); // 只有 props 变了才会打印
return (
<div onClick={handleClick} style={{ border: '1px solid red', padding: 10 }}>
我是子组件,收到 Count: {count}
</div>
)
});
2. 引用类型的陷阱:为什么 memo 失效了?
即使加了 memo,如果你向子组件传递了一个函数,你可能会发现优化失效了。
export default function App() {
const [count, setCount] = useState(0);
const [otherNum, setOtherNum] = useState(0);
// 🔴 陷阱:
// 每次 App 重绘,都会创建一个全新的 handleClick 函数对象
// 虽然函数体代码没变,但内存地址变了!
const handleClick = () => {
console.log('点击了子组件');
}
return (
<div>
{/* 点击这个按钮,App 重绘 -> handleClick 变了 -> Child 重绘 */}
<button onClick={() => setOtherNum(otherNum + 1)}>
修改无关数据 ({otherNum})
</button>
{/* Child 虽然使用了 memo,但 props.handleClick 每次都是新的,所以依然会重绘 */}
<Child count={count} handleClick={handleClick} />
</div>
)
}
在 JavaScript 中,函数是引用类型。第一次渲染创建的 handleClick 和第二次渲染创建的 handleClick 是两个不同的对象(func1 !== func2)。memo 经过对比发现 Props 变了,于是允许子组件更新。
3. 终极解法:使用 useCallback
useCallback 的作用就是缓存函数引用。只要依赖项不变,它返回的永远是同一个函数引用。
import { useState, useCallback } from 'react';
export default function App() {
const [count, setCount] = useState(0);
const [otherNum, setOtherNum] = useState(0);
// ✅ 优化:
// 使用 useCallback 缓存函数
// 依赖项数组为空 [],或者包含需要的依赖
// 这里如果 handleClick 内部不依赖外部变量,依赖项可以是 []
const handleClick = useCallback(() => {
console.log('点击了子组件');
}, []); // 永远返回同一个函数引用
return (
<div>
<button onClick={() => setOtherNum(otherNum + 1)}>
修改无关数据 ({otherNum})
</button>
{/* 此时,handleClick 引用没变,count 也没变,Child 完全不会重新渲染! */}
<Child count={count} handleClick={handleClick} />
</div>
)
}
总结: React.memo 负责拦截组件更新,useCallback 负责提供稳定的函数引用,两者往往需要配合使用才能生效。
四、 场景三:令人头秃的“闭包陷阱”
在使用 useEffect 处理定时器或事件监听时,新手最容易遇到“数据不更新”的诡异 BUG。
1. BUG 复现
import { useState, useEffect } from 'react';
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// 🔴 陷阱:这里的 count 永远是 0
console.log('当前 Count 是:', count);
}, 1000);
return () => clearInterval(timer);
}, []); // 依赖项为空,只在组件挂载时执行一次
return (
<div>
<p>页面上的 Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</div>
);
}
现象: 点击按钮,页面上的数字变成了 1, 2, 3... 但控制台打印的永远是 Current count: 0。
原因: 这就是闭包陷阱(Stale Closure)。
- 组件第一次渲染,
count是 0。 useEffect执行,创建了一个定时器函数。这个函数“记住”了它诞生时的环境,也就是count = 0。- 依赖项是
[],所以useEffect再也没运行过。 - 不管后来组件重新渲染多少次,定时器里跑的永远是第一次那个“老旧”的函数,它眼里只有旧的
count。
2. 解决方案
解决闭包陷阱主要有两种方式:
方法 A:诚实地填写依赖项(推荐)
如果 Effect 内部用到了 count,就应该把它加入依赖数组。
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 Count 是:', count);
}, 1000);
// 每次 count 变化:
// 1. 执行清理函数 clearInterval
// 2. 重新运行 Effect,创建新闭包(捕获最新的 count)
return () => clearInterval(timer);
}, [count]);
方法 B:使用函数式更新(适用于 setState)
如果你只是想基于旧值更新状态,不需要读取值,可以使用 setCount(prev => prev + 1),这样就不需要依赖外部的 count 变量了。
总结
React 的性能优化并不神秘,核心就在于管理好依赖和引用。
| Hook | 核心作用 | 适用场景 |
|---|---|---|
| useMemo | 缓存值 | 这里的计算太贵了,不想每次渲染都算一遍 |
| useCallback | 缓存函数 | 这个函数要传给用 memo 包裹的子组件,不想破坏它的稳定性 |
| useEffect | 处理副作用 | 记得处理好依赖项,小心闭包陷阱 |
希望这篇文章能帮你构建出更高效、更健壮的 React 应用!如果你有任何疑问,欢迎在评论区讨论。