说起useCallback,总是和性能挂在一起,但是本质上,性能优化只是它的一个作用。那除了性能方面,什么时候用useCallback什么时候不用。探究代码中,每个useCallback的使用原因;
先展示我的源码,省略无关代码。先从业务上(说人话版本)简述一下逻辑
- setBoard 初始化棋盘
- 监听按键(上下左右)事件并绑定
handleKeyPress - 点击按键后,在
handleKepPress中, 先判断gameOver, 如果没有结束,就调用moveTile移动棋子 - 在
moveTile中,调用checkGameOver检测游戏是否结束,如果没结束,再调用addNewTile增加一个棋子; - 在
checkGameOver当棋盘上没有空余位置后,setGameOver(true) - 在
addNewTile中,先调用getEmpty判断还有没有空位置,还有的话成功新增棋子,并通过setBoard更新键盘
import { useState, useEffect, useCallback } from "react";
export default function Game() {
const [board, setBoard] = useState();
const [gameOver, setGameOver] = useState(false);
useEffect(() => {
// 绑定按键事件
window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, []);
const getEmpty(current)=>{……}
// 添加
const addNewTile = (currentBoard) => {
// 函数实现
getEmpty(currentBoard)
};
// 移动逻辑
const moveTiles = (direction) => {
setBoard((prevBoard) => {
// 一顿操作
checkGameOver()
addNewTile()
return prevBoard;
});
};
// 游戏结束检测
const checkGameOver = () => {
// 一顿操作
setGameOver();
};
// 键盘事件处理
const handleKeyPress = (e) => {
if (gameOver) return;
if (["ArrowUp", "ArrowDown"].includes(e.key)) {
e.preventDefault();
moveTiles(e.key);
}
}
return (
<div className="game-container">
<div className="score">Score: {score}</div>
<div className={`grid-container ${gameOver ? "game-over" : ""}`}>
{board.map((row, i) => (<></>)}
</div>
</div>)
}
原理篇:React 函数组件的重生诅咒
1. 函数组件的本质
React 函数组件每次渲染时,整个函数会被重新执行。这意味着:
- 所有局部变量都会被重新创建
- 所有函数都会被重新声明
- 所有表达式都会被重新计算
比如 handleKeyPress 如果不用 useCallback:
// 每次渲染都是全新的函数!
const handleKeyPress = (e) => {
/* ... */
};
2. 事件监听的时空错乱
useEffect 注册事件监听时:
useEffect(() => {
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, []); // 空依赖数组
这里有个致命问题:绑定的是第一次渲染时的 handleKeyPress。如果后续 handleKeyPress 更新(比如 gameOver 变化),事件监听器里的函数还是旧版本!
假设不用
useCallback:
- 第一次渲染:
handleKeyPress_v1被创建- 绑定
handleKeyPress_v1到 window- 当
gameOver变为 true 时,组件重新渲染- 创建新的
handleKeyPress_v2- 但事件监听器还是用的
handleKeyPress_v1!- 结果:
handleKeyPress_v1里的gameOver始终是初始值 false,游戏结束后还能继续操作!
但是到这里,我想到【"把 handleKeyPress 放进 useEffect 的依赖数组不就好了?"】 试着重构代码:
useEffect(() => {
window.addEventListener('keydown', handleKeyPress);
return () => window.removeEventListener('keydown', handleKeyPress);
}, [handleKeyPress]); // 加入依赖
在这种情况下,可以不使用 useCallback 就能解决绑定问题。但:
- 每次渲染都会创建新的
handleKeyPress - 导致 useEffect 依赖变化
- 触发:移除旧监听器 → 添加新监听器
- 结果:每秒操作键盘时疯狂绑定/解绑事件,性能爆炸!
我觉得说的很有道理,于是乖乖的给handleKeyPress加上了useCallback,依赖gameOver的变化。但是此时,又提示我依赖需要加上moveTiles;不明白为什么,我的moveTiles函数里又没什么变化。如果你也这么想,那我们就一起掉入了闭包陷阱
1、现状分析(修改后的代码问题)
// 当前 handleKeyPress 的依赖数组
const handleKeyPress = useCallback(
(e) => { /* ... */ },
[gameOver] // ⚠️ 致命错误:漏掉了 moveTiles 依赖
);
// 当前 moveTiles 定义
const moveTiles = (direction) => { /* ... */ }; // ⚠️ 没有用 useCallback
此时会发生:
- 每次组件渲染都会创建新的
moveTiles函数 handleKeyPress的闭包中捕获的是旧的moveTiles版本- 实际效果:你按方向键时调用的可能是过期的
moveTiles函数
2、为什么必须给 moveTiles 用 useCallback
原因:依赖传递链断裂
handleKeyPress依赖moveTiles- 如果
moveTiles不用useCallback,每次都是新函数 - 导致
handleKeyPress的依赖数组 [gameOver] 不准确 - 结果:
handleKeyPress永远用着第一次渲染时的moveTiles
3、具体会引发的 BUG
BUG 1:状态滞后
- 第一次渲染:
moveTiles_v1被创建 handleKeyPress捕获moveTiles_v1- 用户触发状态更新(如
gameOver变化) - 组件重新渲染,创建
moveTiles_v2 - 但
handleKeyPress仍然引用moveTiles_v1 - 结果:后续键盘操作调用的是过期逻辑
BUG 2:内存泄漏
- 旧版
moveTiles被事件监听器持有 - 新版
moveTiles不断产生 - 旧函数无法被垃圾回收
- 长时间游戏后内存暴涨
4、原理级解释:闭包陷阱
JavaScript 的函数闭包特性:
-
函数会捕获创建时的上下文
-
handleKeyPress用useCallback缓存后:// 假设第一次渲染: const handleKeyPress = useCallback(() => { moveTiles(); // 这里捕获的是 moveTiles_v1 }, [gameOver]); -
即使后续
moveTiles更新到 v2/v3/v4... -
handleKeyPress里的moveTiles永远停留在 v1
5、为什么现在的代码"看似能运行"
假象原因:
moveTiles函数本身没有依赖外部变量- 每次新建的
moveTiles函数逻辑相同 - 所以调用旧版函数也能正常工作
但这种代码:
-
是定时炸弹
-
只要给
moveTiles添加状态依赖就会爆炸:// 假设未来需求:添加移动速度控制 const [speed, setSpeed] = useState(1); const moveTiles = (direction) => { // 使用 speed 状态 const actualSpeed = speed * 2; // ...逻辑 }; -
此时旧版
moveTiles捕获的speed永远是初始值 1
6、正确代码结构
// 正确写法:级联 useCallback
const addNewTile = useCallback((currentBoard) => {
// ...
}, []); // ✅ 无依赖
const moveTiles = useCallback((direction) => {
// 这里可以安全使用 addNewTile
setBoard(prev => { /* ... */ });
}, [addNewTile]); // ✅ 声明依赖
const handleKeyPress = useCallback((e) => {
// 这里可以安全使用 moveTiles
}, [moveTiles, gameOver]); // ✅ 声明依赖
依赖链的传递:
handleKeyPress → 依赖 moveTiles
↘ 依赖 gameOver
moveTiles → 依赖 addNewTile
7、性能对比(每秒触发 10 次键盘操作)
| 场景 | 函数创建次数/秒 | 内存占用 | 事件监听变动次数 |
|---|---|---|---|
| 不用 useCallback | 40次 | 高 | 40次 |
| 正确使用 useCallback | 0次 | 低 | 0次 |
8、为什么代码中 moveTiles 看似"稳定"
错觉来源:
// 现在的 moveTiles 实现
const moveTiles = (direction) => {
setBoard(prev => { // 使用了函数式更新
// 所有逻辑都在这里
});
};
由于:
- 没有直接使用外部状态(如
score) - 通过
setBoard(prev => ...)访问最新状态 addNewTile被意外漏掉了依赖声明(代码中addNewTile也没用 useCallback)
但这本质是侥幸:
- 一旦需要访问任何外部状态(如
speed) - 或
addNewTile需要修改(比如根据难度生成不同棋子) - 代码会立即崩溃
看完这些后,再乖乖的给moveTiles、addNewTile加上了useCallback,并且moveTiles依赖addNewTile的变化。但是此时,我又在想,凭啥addNewTile可以放到依赖里,但是checkGameOver不用,明明他们都在moveTiles里被引用了。那我们接着往下看
为什么 addNewTile 必须加入依赖?
对比 addNewTile 的调用方式:
-
它的实现直接操作外部变量:
const addNewTile = (currentBoard) => { const emptyCells = getEmptyCells(currentBoard); // 依赖外部函数 getEmptyCells // ... }; -
如果
getEmptyCells修改了(比如过滤条件变化),旧版addNewTile会使用过期逻辑
总结
原理篇:依赖项的本质
React 依赖数组的黄金规则:
所有在回调函数中直接使用的外部变量都必须声明依赖,包括:
- 状态 (
state)- 函数
- props
- context
违反规则的后果:
函数闭包会捕获函数创建时的变量值,导致逻辑中使用过期数据。
依赖项处理的三种境界
| 境界 | 处理方式 | 优点 | 缺点 |
|---|---|---|---|
| 青铜 | 完全忽略依赖 | 代码简单 | 闭包问题随机爆发 |
| 白银 | 只加必要依赖 | 部分优化 | 未来扩展易出错 |
| 王者 | eslint-plugin-react-hooks 全量依赖 | 绝对安全 | 需要配合 useCallback/useMemo 优化 |
最佳实践修正方案
步骤 1:给所有函数穿上"防弹衣"
// 用 useCallback 稳定所有函数引用
const getEmptyCells = useCallback((currentBoard) => {
// ...
}, []);
const checkGameOver = useCallback((currentBoard) => {
// ...
}, []); // 注意:如果未来添加依赖需更新此处
const addNewTile = useCallback((currentBoard) => {
// ...
}, [getEmptyCells]); // 声明依赖
步骤 2:完善 moveTiles 的依赖
const moveTiles = useCallback((direction) => {
// ...逻辑...
}, [addNewTile, checkGameOver]); // 声明所有依赖
性能优化示意图
graph TD
A[组件渲染] --> B{checkGameOver 是否变化?}
B -- 是 --> C[重建 moveTiles]
B -- 否 --> D[复用旧 moveTiles]
通过给 checkGameOver 添加 useCallback,可使 90% 的渲染中 checkGameOver 是否变化? 的判断结果为"否"。
为什么原代码能苟活?
幸存者偏差:
checkGameOver当前实现无状态依赖- 参数
newBoard总是最新值 setGameOver是稳定的(React 保证状态更新器稳定)
但这是脆弱的平衡:
- 只要给
checkGameOver添加一个状态依赖(如maxTile配置) - 整个逻辑立即崩溃
终极结论
| 场景 | 是否安全 | 建议 |
|---|---|---|
checkGameOver 永远无状态依赖 | 安全但脆弱 | 可暂时不加依赖 |
checkGameOver 未来可能有依赖 | 危险 | 必须加 useCallback 和依赖 |
| 团队协作项目 | 极其危险 | 严格遵循 React 官方规则 |
推荐修正代码:
const checkGameOver = useCallback((currentBoard) => {
// ...
}, []); // ✅ 用 useCallback 包裹
const moveTiles = useCallback((direction) => {
// ...
}, [addNewTile, checkGameOver]); // ✅ 声明所有依赖
这样既符合 React 规则,又为未来扩展留下安全通道。