面试官:讲讲这段react代码的输出(踩坑)

从一段看似正常的代码,到深入理解 React Hooks 的闭包陷阱

前言

之前面试,面试官递过来一段代码:"看看这段代码有啥问题?"

我扫了一眼------标准的 React 组件,用了 useStateuseEffect,设置了个定时器每秒打印计数。代码看起来挺规范的,没有明显的语法错误。点击按钮,UI 上的数字也正常更新:1、2、3...

但打开 Console 一看,我愣住了:

makefile 复制代码
Count: 0
Count: 0
Count: 0
Count: 0
...

UI 明明在变,为什么打印的永远是 0?

带着这个困惑,我回来后花了个晚上把 React Hooks 的闭包机制翻了个底朝天。没想到这个看起来简单的 bug,背后藏着的是 JavaScript 闭包和 React 渲染机制的深层交互。

先抛几个问题,看看你能答对几个:

  • 为什么 UI 正常更新,但 console 输出错误?
  • 闭包是怎么"困住"旧值的?
  • useEffect 的空依赖数组 [] 有什么影响?
  • 这个 bug 有几种修复方法?哪种最优?

这篇文章会详细讲解:

  • Bug 的完整复现和分析
  • JavaScript 闭包机制
  • useEffect 的依赖机制
  • 5 种解决方案的完整对比
  • 如何避免类似问题

目录


Bug 演示

完整代码

javascript 复制代码
import { useEffect, useState } from "react";
​
export function App() {
  const [count, setCount] = useState(0);
​
  function handleLog() {
    console.log("Count:", count);
  }
​
  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);
​
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

运行效果

UI 显示

csharp 复制代码
Count: 3  ← 点击了3次,显示正常
[Increment 按钮]

Console 输出

makefile 复制代码
Count: 0  ← 一直是0!
Count: 0
Count: 0
Count: 0
...

问题分析:陈旧闭包

什么是陈旧闭包(Stale Closure)?

这个 bug 的根源是 陈旧闭包(Stale Closure) ------函数"记住"了它创建时的环境,但这个环境里的值已经过时了。

为什么会出现?

复制代码

执行流程详解

  1. 初始渲染(count = 0)

    • 创建 handleLog 函数,捕获 count = 0
    • useEffect 执行,设置 setInterval(handleLog, 1000)
    • 注意:useEffect 的依赖是 [],所以只执行一次
  2. 用户点击按钮

    • setCount(1) → 触发重新渲染
    • 创建新的 handleLog 函数,捕获新的 count = 1
    • 但是! useEffect 不会再执行(依赖是 []
    • setInterval 调用的还是第一次渲染时的旧 handleLog
  3. 结果

    • UI 显示的是最新的 count(React 状态正常更新)
    • setInterval 调用的 handleLog 里的 count 永远是 0(闭包捕获的旧值)

原理深挖:闭包如何困住旧值

闭包基础

先用个简单例子理解闭包:

scss 复制代码
function createCounter() {
  let count = 0;  // 被"捕获"的变量
​
  return function() {
    console.log(count);  // 能访问外层的 count
  };
}
​
const counter1 = createCounter();
const counter2 = createCounter();
​
counter1();  // 输出: 0
counter2();  // 输出: 0
​
// 即使外层函数执行完了,内层函数还能访问 count

闭包说穿了就是:函数能"记住"它创建时的环境

React 中的闭包陷阱

scss 复制代码
// 第一次渲染(count = 0)
function App() {
  const count = 0;  // ← 这个值
​
  function handleLog() {
    console.log(count);  // ← 被这个函数捕获
  }
​
  useEffect(() => {
    setInterval(handleLog, 1000);  // ← interval 记住了这个 handleLog
  }, []);  // ← 空数组,只执行一次
​
  // ...
}
​
// 第二次渲染(count = 1)
function App() {
  const count = 1;  // ← 新的值
​
  function handleLog() {
    console.log(count);  // ← 新的函数,捕获新值
  }
​
  // useEffect 不执行(依赖是空数组)
  // interval 还在调用第一次渲染时的旧 handleLog
​
  // ...
}

关键点

  • 每次渲染都会创建新的 count 变量和新的 handleLog 函数
  • useEffect 只在首次渲染时执行(依赖是 []
  • setInterval 调用的是第一次渲染时的 handleLog
  • 那个 handleLog 里捕获的 count 永远是 0

解决方案对比

下面介绍 5 种修复方法,每种都有适用场景。

方案对比表

方案
1. 添加 count 依赖
2. 使用 useRef
3. 函数式更新
4. useLatest 自定义 Hook
5. useEffectEvent (React 18+)

方案 1:添加 count 依赖

思路 :让 useEffectcount 变化时重新执行。

scss 复制代码
useEffect(() => {
  const id = setInterval(handleLog, 1000);
  return () => clearInterval(id);
}, [count]);  // ← 添加 count 依赖

优点

  • 简单直接,一行改动

缺点

  • 性能差 :每次 count 变化都会:

    1. 清除旧的 interval
    2. 创建新的 interval
  • 对于快速更新的状态,会频繁重建 interval


方案 2:使用 useRef ⭐⭐⭐⭐

思路 :用 useRef 保存最新值,interval 读取 ref。

javascript 复制代码
import { useEffect, useState, useRef } from "react";
​
export function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(count);
​
  // 同步 count 到 ref
  useEffect(() => {
    countRef.current = count;
  }, [count]);
​
  function handleLog() {
    console.log("Count:", countRef.current);  // ← 读取 ref
  }
​
  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);  // ← 空数组,只设置一次
​
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

为什么有效?

  • ref.current 是可变的,修改它不会触发重新渲染
  • 每次 count 更新时,同步到 countRef.current
  • handleLog 读取 countRef.current,总是最新值

优点

  • 性能好(interval 只创建一次)
  • 总是读取最新值

缺点

  • 需要额外的 useEffect 同步值
  • 代码稍显冗余

方案 3:函数式更新

思路 :利用 setState 的函数式更新,不依赖闭包捕获的值。

javascript 复制代码
import { useEffect, useState } from "react";
​
export function App() {
  const [count, setCount] = useState(0);
​
  useEffect(() => {
    const id = setInterval(() => {
      setCount((prevCount) => {
        console.log("Count:", prevCount);  // ← 读取最新值
        return prevCount;  // 不修改,只打印
      });
    }, 1000);
​
    return () => clearInterval(id);
  }, []);
​
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

优点

  • 简单,不需要 ref
  • 不需要依赖数组

缺点

  • 只适合简单场景:如果需要访问多个状态,代码会很丑陋
  • 滥用 setState 作为"读取"手段,语义不清晰

方案 4:useLatest 自定义 Hook ⭐⭐⭐⭐⭐

思路:封装方案 2 的 ref 逻辑,提高复用性。

javascript 复制代码
import { useEffect, useState, useRef } from "react";
​
// 自定义 Hook:保存最新值
function useLatest(value) {
  const ref = useRef(value);
​
  useEffect(() => {
    ref.current = value;
  }, [value]);
​
  return ref;
}
​
export function App() {
  const [count, setCount] = useState(0);
  const countRef = useLatest(count);  // ← 封装成 Hook
​
  function handleLog() {
    console.log("Count:", countRef.current);
  }
​
  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);
​
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

优点

  • 复用性好,可在多个地方使用
  • 语义清晰:useLatest 明确表示"总是最新值"
  • 性能好

缺点

  • 需要额外维护自定义 Hook

方案 5:useEffectEvent (React 18+) ⭐⭐⭐⭐

思路:使用 React 官方的实验性 API。

javascript 复制代码
import { useEffect, useState, experimental_useEffectEvent as useEffectEvent } from "react";
​
export function App() {
  const [count, setCount] = useState(0);
​
  // useEffectEvent:创建一个"总是最新"的事件处理函数
  const handleLog = useEffectEvent(() => {
    console.log("Count:", count);
  });
​
  useEffect(() => {
    const id = setInterval(handleLog, 1000);
    return () => clearInterval(id);
  }, []);  // ← 不需要添加 handleLog 依赖
​
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

优点

  • 官方解决方案,专为此设计
  • 语义清晰
  • 不需要手动管理 ref

缺点

  • 实验性 API(React 18 中可用,但可能变动)
  • 需要 React 18+

类似陷阱举例

闭包陷阱不仅出现在 useEffect 中,下面是几个常见场景:

1. 事件监听器

javascript 复制代码
function App() {
  const [count, setCount] = useState(0);
​
  useEffect(() => {
    function handleClick() {
      console.log("Count:", count);  // ← 闭包捕获旧值
    }
​
    document.addEventListener("click", handleClick);
​
    return () => {
      document.removeEventListener("click", handleClick);
    };
  }, []);  // ← 空数组,只执行一次
​
  return <button onClick={() => setCount((c) => c + 1)}>Increment</button>;
}

修复 :添加 count 依赖,或使用 useRef


2. 异步回调

javascript 复制代码
function App() {
  const [count, setCount] = useState(0);
​
  function handleAsync() {
    setTimeout(() => {
      console.log("Count:", count);  // ← 3秒后打印,可能已经变了
    }, 3000);
  }
​
  return (
    <div>
      <button onClick={handleAsync}>异步打印</button>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
}

问题

  • 点击"异步打印"时 count = 0
  • 3秒内点击 Increment 多次,count = 5
  • 3秒后 setTimeout 执行,打印 Count: 0(闭包捕获的旧值)

修复 :使用 useRefuseLatest


3. 防抖/节流函数

scss 复制代码
function App() {
  const [searchTerm, setSearchTerm] = useState("");
​
  const handleSearch = useMemo(
    () =>
      debounce(() => {
        console.log("搜索:", searchTerm);  // ← 闭包捕获旧值
      }, 500),
    []  // ← 空数组,只创建一次
  );
​
  return <input onChange={(e) => setSearchTerm(e.target.value)} />;
}

修复 :添加 searchTerm 依赖,或使用 useLatest


避坑指南

1. 开启 ESLint 规则

json 复制代码
// .eslintrc.json
{
  "rules": {
    "react-hooks/exhaustive-deps": "warn"
  }
}

这个规则会检查:

  • useEffectuseCallbackuseMemo 的依赖数组
  • 如果函数内使用了外部变量,但没有加入依赖,会报警告

2. 检查清单

遇到闭包相关的 bug 时,问自己这几个问题:

  • 函数内是否使用了组件的 props 或 state?
  • useEffect 的依赖数组是否完整?
  • 是否有定时器、事件监听器、异步回调?
  • 是否需要总是读取最新值?

3. 快速识别方法

看到这些代码模式,立即警惕

scss 复制代码
useEffect(() => {
  // 使用了 state/props,但依赖是空数组
  console.log(someState);
}, []);  // ← 🚨 危险!
​
useEffect(() => {
  setInterval(() => {
    // 使用了 state/props
    console.log(someState);
  }, 1000);
}, []);  // ← 🚨 危险!
​
useEffect(() => {
  document.addEventListener("click", () => {
    // 使用了 state/props
    console.log(someState);
  });
}, []);  // ← 🚨 危险!

4. 最佳实践

推荐顺序(从简单到复杂):

  1. 首选:使用 ESLint,添加完整依赖
  2. 性能要求高 :使用 useLatest 自定义 Hook
  3. React 18+ :使用 useEffectEvent(实验性)
  4. 简单场景:使用函数式更新

总结

这个看起来简单的 bug,背后是 JavaScript 闭包和 React 渲染机制的交互:

核心原理

  • 每次渲染都会创建新的函数和变量
  • 闭包会"记住"函数创建时的环境
  • useEffect 的依赖数组决定何时重新执行
  • 空依赖数组 [] 导致 effect 只执行一次,捕获的是初始值

陈旧闭包的特征

  • UI 正常,但异步操作(定时器、事件监听、回调)读取到旧值
  • useEffect 依赖不完整
  • 函数内使用了外部变量,但没有加入依赖

推荐解决方案

  1. 通用场景 :使用 useLatest 自定义 Hook(复用性好)
  2. 简单场景 :使用 useRef(手动同步值)
  3. React 18+ :使用 useEffectEvent(官方方案,实验性)

面试启示: 这类题考察的是:

  • 对 JavaScript 闭包的理解
  • 对 React Hooks 机制的掌握
  • 解决实际问题的能力(多种方案对比)

下次写 useEffect 时,多问一句:这个函数用到的变量,是不是总是最新的? 养成这个习惯,就能避开大部分闭包陷阱。


相关资源

相关推荐
银安3 小时前
CSS排版布局篇(2):文档流(Normal Flow)
前端·css
jump6803 小时前
闭包详细解析
前端
观默3 小时前
AI看完你的微信,发现了些秘密?
前端·开源
一嘴一个橘子3 小时前
useTemplateRef Vue3.5
javascript·vue.js
林希_Rachel_傻希希3 小时前
《DOM元素获取全攻略:为什么 querySelectorAll() 拿不到新元素?一文讲透动态与静态集合》
前端·javascript
PHP武器库3 小时前
从零到一:用 Vue 打造一个零依赖、插件化的 JS 库
前端·javascript·vue.js
温宇飞3 小时前
CSS 内联布局详解
前端
excel4 小时前
深入理解 Slot(插槽)
前端·javascript·vue.js
GISer_Jing4 小时前
React中Element、Fiber、createElement和Component关系
前端·react.js·前端框架