useCallback使用踩雷场景模拟

206 阅读7分钟

说起useCallback,总是和性能挂在一起,但是本质上,性能优化只是它的一个作用。那除了性能方面,什么时候用useCallback什么时候不用。探究代码中,每个useCallback的使用原因;

先展示我的源码,省略无关代码。先从业务上(说人话版本)简述一下逻辑

  1. setBoard 初始化棋盘
  2. 监听按键(上下左右)事件并绑定handleKeyPress
  3. 点击按键后,在handleKepPress 中, 先判断 gameOver, 如果没有结束,就调用moveTile移动棋子
  4. moveTile中,调用checkGameOver检测游戏是否结束,如果没结束,再调用addNewTile增加一个棋子;
  5. checkGameOver当棋盘上没有空余位置后,setGameOver(true)
  6. 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

  1. 第一次渲染:handleKeyPress_v1 被创建
  2. 绑定 handleKeyPress_v1 到 window
  3. gameOver 变为 true 时,组件重新渲染
  4. 创建新的 handleKeyPress_v2
  5. 但事件监听器还是用的 handleKeyPress_v1
  6. 结果: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

此时会发生:

  1. 每次组件渲染都会创建新的 moveTiles 函数
  2. handleKeyPress 的闭包中捕获的是旧的 moveTiles 版本
  3. 实际效果:你按方向键时调用的可能是过期的 moveTiles 函数

2、为什么必须给 moveTiles 用 useCallback

原因:依赖传递链断裂

  • handleKeyPress 依赖 moveTiles
  • 如果 moveTiles 不用 useCallback,每次都是新函数
  • 导致 handleKeyPress 的依赖数组 [gameOver] 不准确
  • 结果:handleKeyPress 永远用着第一次渲染时的 moveTiles

3、具体会引发的 BUG

BUG 1:状态滞后

  1. 第一次渲染:moveTiles_v1 被创建
  2. handleKeyPress 捕获 moveTiles_v1
  3. 用户触发状态更新(如 gameOver 变化)
  4. 组件重新渲染,创建 moveTiles_v2
  5. handleKeyPress 仍然引用 moveTiles_v1
  6. 结果:后续键盘操作调用的是过期逻辑

BUG 2:内存泄漏

  1. 旧版 moveTiles 被事件监听器持有
  2. 新版 moveTiles 不断产生
  3. 旧函数无法被垃圾回收
  4. 长时间游戏后内存暴涨

4、原理级解释:闭包陷阱

JavaScript 的函数闭包特性:

  • 函数会捕获创建时的上下文

  • handleKeyPressuseCallback 缓存后:

    // 假设第一次渲染:
    const handleKeyPress = useCallback(() => {
        moveTiles(); // 这里捕获的是 moveTiles_v1
    }, [gameOver]);
    
  • 即使后续 moveTiles 更新到 v2/v3/v4...

  • handleKeyPress 里的 moveTiles 永远停留在 v1

5、为什么现在的代码"看似能运行"

假象原因:

  1. moveTiles 函数本身没有依赖外部变量
  2. 每次新建的 moveTiles 函数逻辑相同
  3. 所以调用旧版函数也能正常工作

但这种代码:

  • 是定时炸弹

  • 只要给 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 次键盘操作)

场景函数创建次数/秒内存占用事件监听变动次数
不用 useCallback40次40次
正确使用 useCallback0次0次

8、为什么代码中 moveTiles 看似"稳定"

错觉来源:

// 现在的 moveTiles 实现
const moveTiles = (direction) => {
    setBoard(prev => { // 使用了函数式更新
        // 所有逻辑都在这里
    });
};

由于:

  1. 没有直接使用外部状态(如 score
  2. 通过 setBoard(prev => ...) 访问最新状态
  3. addNewTile 被意外漏掉了依赖声明(代码中 addNewTile 也没用 useCallback)

但这本质是侥幸:

  • 一旦需要访问任何外部状态(如 speed
  • addNewTile 需要修改(比如根据难度生成不同棋子)
  • 代码会立即崩溃

看完这些后,再乖乖的给moveTilesaddNewTile加上了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 是否变化? 的判断结果为"否"。


为什么原代码能苟活?

幸存者偏差:

  1. checkGameOver 当前实现无状态依赖
  2. 参数 newBoard 总是最新值
  3. setGameOver 是稳定的(React 保证状态更新器稳定)

但这是脆弱的平衡:

  • 只要给 checkGameOver 添加一个状态依赖(如 maxTile 配置)
  • 整个逻辑立即崩溃

终极结论

场景是否安全建议
checkGameOver 永远无状态依赖安全但脆弱可暂时不加依赖
checkGameOver 未来可能有依赖危险必须加 useCallback 和依赖
团队协作项目极其危险严格遵循 React 官方规则

推荐修正代码:

const checkGameOver = useCallback((currentBoard) => {
  // ...
}, []); // ✅ 用 useCallback 包裹

const moveTiles = useCallback((direction) => {
  // ...
}, [addNewTile, checkGameOver]); // ✅ 声明所有依赖

这样既符合 React 规则,又为未来扩展留下安全通道。