React 状态闭阱(Stale Closures):从源码视角分析 useEffect 依赖项缺失导致状态获取过期的根本原因

各位前端同仁,大家好!欢迎来到今天的“React 深度解剖课”。

今天我们不聊 UI 设计,不聊 CSS 魔法,也不聊怎么把那个该死的 Flexbox 弄好。今天我们要聊的是 React 里最像“鬼故事”的东西——状态闭阱

听说过“鬼打墙”吗?就是你在迷宫里转圈,明明前面就是出口,你却怎么也走不出去。在 React 里,这就是当你写了一个 useEffect,却忘了写依赖项,导致你的函数“卡”在了上一轮渲染的状态里。

别急着翻白眼,我知道你们很多人都有过这种经历:你满怀信心地写下了代码,点击按钮,结果控制台打印的还是上上个版本的数字,或者是 undefined。你发誓你明明改了代码,为什么 React 就像个顽固的守财奴,死死抱着旧数据不放?

今天,我们就扒开 React 的源码,看看这个“守财奴”到底是怎么工作的。


第一部分:闭包——那个把你锁在时间胶囊里的幽灵

在进入 React 源码之前,我们要先搞清楚一个核心概念:闭包

很多教程会说:“闭包就是函数能访问外部变量。” 这太枯燥了,太教科书了。我们换个更有画面感的说法。

想象一下,你是个外卖小哥。你接到了一个订单,你把订单详情(变量)记在了脑子里,然后你骑着电动车出发了。到了顾客家楼下,你掏出订单一看——哎呀! 顾客搬家了,地址变了,但他还住在那个小区里。

你脑子里的那个“订单详情”,就是闭包

在 JavaScript 里,当你定义一个函数并把它赋值给一个变量(比如 const handleClick = () => { ... })时,这个函数就形成了一个闭包。它不仅仅是一个代码块,它还捕获了它诞生那一刻的上下文环境。

React 里的 useEffect 回调函数,本质上就是这样一个外卖小哥。它诞生于某一次渲染。当你写:

const [count, setCount] = useState(0);

useEffect(() => {
  console.log(count); // 这里的 count 是谁?
}, []);

注意那个空数组 []。这意味着你告诉 React:“嘿,这个外卖小哥(回调函数)只需要在页面刚加载的时候送一次餐(执行一次)。”

React 说:“好嘞。” 然后它把当时的环境——也就是 count = 0 的状态——打包,塞进了这个外卖小哥的脑子里。

然后,你疯狂地点击按钮,count 变成了 1,2,3…… React 重新渲染了页面,数据是对的。但是!React 回头一看,依赖项数组是空的 []。React 心想:“哦,外卖小哥没说要换地址,那就不用重新派单,继续用他脑子里的旧地址吧。”

于是,那个外卖小哥又来了,他脑子里还装着 count = 0,然后你问他:“现在的 count 是多少?” 他自信地回答:“0 啊,我刚拿单的时候就是 0。”

这就是状态闭阱。你的状态已经往前走了,但你的回调函数还停留在原地,抱着旧数据不肯撒手。


第二部分:源码视角——React 的“懒惰”哲学

既然我们知道了闭包是“幽灵”,那 React 是怎么制造这个幽灵的?又是怎么维持它的?这就涉及到 React 的核心调度逻辑了。

React 的渲染机制是按需的。它不会因为你改了一行 CSS 就重新渲染整个世界。它非常“懒”。

当你在组件里写 useEffect(() => { ... }, [deps]) 时,React 做了什么?

1. 挂载阶段:制造幽灵

React 在执行 render 阶段时,会调用 useEffect。它会执行回调函数,并把当前的依赖项(deps)保存下来。

为了方便理解,我们手写一个简化版的 useEffect 逻辑:

function useEffect(callback, deps) {
  // 1. 把当前的依赖项存起来(存到 Fiber 节点里,或者闭包里)
  const currentDeps = [...deps]; 

  // 2. 执行回调函数
  callback();
}

此时,callback 函数被创建。它捕获了当前的 countprops 等变量。这就是“幽灵”的诞生。

2. 更新阶段:幽灵的自我修养

这是最关键的一步。当组件重新渲染,count 变了。React 跑到 useEffect 这里,问自己:“喂,这个 useEffect 的依赖项变了吗?”

React 的依赖项检查逻辑非常简单粗暴,它使用的是 浅比较

// React 内部大概是这样的逻辑(伪代码)
function commitEffectList() {
  const nextDeps = getNewDeps(); // 获取当前渲染传入的新依赖项,比如 [count]
  const prevDeps = getOldDeps(); // 获取上一次渲染保存的旧依赖项,比如 []

  // 3. 比较新旧依赖项
  if (shallowCompare(nextDeps, prevDeps)) {
    // 如果相等,说明依赖没变,React 决定:**不重新创建回调函数!**
    // 那个外卖小哥继续骑他的旧电动车,去送他脑子里的旧订单。
    console.log("依赖没变,跳过更新");
  } else {
    // 如果不等,React 说:好,那我们重新雇个外卖小哥,这次让他带着新地址出发。
    callback = createCallback(); 
  }
}

浅比较 的意思是,React 只看数组里的东西是不是同一个引用

const [count, setCount] = useState(0);

useEffect(() => {
  console.log(count);
}, []); // 依赖项是 []

// 当你点击按钮,count 变成 1
// React 拿到的新依赖项是 [](还是空数组,引用没变)
// React 拿到的旧依赖项是 [](也是空数组,引用没变)
// React 一看:咦?一样啊!那就不动。
// 结果:回调函数里的 count 依然是 0。

这就是为什么你总是看到旧状态。React 为了性能,它不想每次渲染都重新创建函数、重新绑定闭包。它假设只要你没告诉它依赖项变了,那你的逻辑就不应该变。


第三部分:实战演练——幽灵的复仇

让我们看一个具体的例子,感受一下闭阱带来的痛苦。

场景一:计数器的“永动机”

这是最经典的入门错误。

import React, { useState, useEffect } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log('Count is:', count); // 期望打印 1, 2, 3...
    }, 1000);

    return () => clearInterval(timer);
  }, []); // 问题就在这里!依赖项是空的!

  // ...
}

分析:

  1. 初始渲染:count 是 0。useEffect 执行。setInterval 启动。回调函数捕获了 count = 0
  2. 点击按钮:count 变成 1。组件重新渲染。
  3. React 检查依赖项:[] vs []。相等。
  4. React 决定:不重新执行 useEffectsetInterval 还在跑。
  5. 1 秒后:回调函数再次触发。它看着自己捕获的 count 变量。React 的渲染机制里,虽然 count 变成了 1,但是这个闭包函数内部引用的 count 变量,依然是 0。

结果: 无论你怎么点,setInterval 打印的永远是 0。

怎么修?
count 加进依赖项:

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Count is:', count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]); // 依赖项变了,重新创建回调

但是,如果你这么改,你会遇到无限循环

useEffect(() => {
  console.log(count);
  setCount(c => c + 1); // 这行代码会触发重新渲染
}, [count]); // count 变了,触发 effect

渲染 -> effect 执行 -> count + 1 -> 渲染 -> effect 执行 -> count + 1…… 这是一个死循环。React 陷入了疯狂。


场景二:异步请求的“幽灵数据”

现在我们进阶一点,处理异步。

function UserProfile() {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetchUser(123) // 假设这是一个 API 调用
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => console.error(err));
  }, []); // 依赖项为空,只执行一次
}

这看起来没问题,对吧?fetchUser 只在挂载时执行一次。

但是,如果这个组件支持编辑用户 ID 呢?

function UserProfile() {
  const [userId, setUserId] = useState(123);
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetchUser(userId) // 这里用的是 userId
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, []); // 依然为空!
}

分析:

  1. 初始渲染:userId 是 123。fetchUser(123) 执行,获取用户 123。
  2. 用户修改 ID 为 456。userId 变成 456。组件重新渲染。
  3. 关键点来了: useEffect 的依赖项是空的。React 不会重新执行这个 useEffect
  4. 结果:你的 UI 更新了(显示 456),但后台还在死死抱着 123 的数据不放。或者,如果你有 setLoading(false)then 里面,你的 Loading 状态可能永远不会消失,因为那个 Promise 永远不会 resolve(如果 API 不支持 ID 123 的话)。

这就是闭阱在异步操作中的“恶作剧”。


第四部分:源码深挖——Fiber 节点与 Effect 链表

光说理论不够,我们得看看 React 在 Fiber 树里到底是怎么存这些依赖项的。

React 16 以后,渲染的核心是 Fiber 架构。每一个组件实例都是一个 Fiber 节点。

当你调用 useEffect 时,React 会把这个回调函数和它的依赖项数组,挂载到对应的 Effect Fiber 节点上。

Effect Fiber 的结构

// React 源码简化版结构
class EffectFiber {
  // ... 其他属性
  effectTag; // 标记这是不是一个 effect
  nextEffect; // 指向下一个 effect
  createCallback; // 保存那个闭包函数
  deps; // 保存依赖项数组
}

当渲染发生时,React 会遍历 Fiber 树,处理所有的 useEffect

  1. 比较:React 拿当前渲染时的 deps(比如 [userId])和 EffectFiber.memoizedDeps(上一次的 deps,比如 [])进行浅比较。
  2. 决定
    • 如果 shallowEqual 为真:React 会复用 EffectFiber.createCallback。这意味着那个闭包函数的内存地址没变,它捕获的变量引用也没变。
    • 如果 shallowEqual 为假:React 会创建一个新的函数,赋值给 EffectFiber.createCallback。然后执行它。

为什么 React 不做深层比较?
你可能会问:“能不能自动检测 userId 变了?”

React 团队做过权衡。如果做深层比较(比如 JSON.stringify),性能会非常差。每次渲染都要遍历整个依赖项数组,还要递归比较对象。React 的设计哲学是:相信开发者。如果你依赖项变了,你就写上。如果你不写,React 就默认你没变。

这就像是你开车,如果油门松了,车就会停。React 的闭阱机制就是那个油门。


第五部分:useRef 的救赎——打破闭阱的利器

既然闭阱这么讨厌,我们有什么办法对付它?

答案只有一个:useRef

为什么 useRef 能破局?

还记得我们说的外卖小哥吗?普通的闭包是“脑子里的地址”。而 useRef 返回的是一个盒子

const countRef = useRef(0);

useEffect(() => {
  const timer = setInterval(() => {
    console.log('Count is:', countRef.current);
  }, 1000);
}, []); // 依然为空

原理分析:

  1. 可变性countRef.current 是可变的。当你点击按钮,countRef.current++,这个值变了。
  2. 非依赖项:React 不会useRef 的返回值放入依赖项数组里进行比较。React 只比较 deps 数组里显式写的变量。
  3. 引用不变useRef 返回的对象引用永远不变。

当你点击按钮,countRef.current 变成了 1。下一次 setInterval 触发时,它读取的是 countRef.current,也就是 1。

useRef 就像是外卖小哥手里的一个便签本。你可以随时在便签本上写字(修改 current)。虽然 React 还是那个外卖小哥(闭包函数没变),但他手里的便签本已经更新了。

适用场景:

  • 保存 DOM 节点引用(inputRef.current.focus())。
  • 保存计算成本高的变量,避免在每次渲染时重新计算。
  • 解决闭阱问题(比如上面那个 userId 的例子)。

useRef 修复异步请求示例

function UserProfile() {
  const [userId, setUserId] = useState(123);
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);

  // 引入 ref
  const userIdRef = useRef(userId);

  // 每次渲染更新 ref,但 ref 不是依赖项
  useEffect(() => {
    userIdRef.current = userId;
  });

  useEffect(() => {
    const currentUserId = userIdRef.current; // 这里拿到的是最新的值
    setLoading(true);
    fetchUser(currentUserId)
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, []); // 依然依赖为空,但通过 ref 拿到了最新值
}

第六部分:依赖项地狱——当闭阱遇到副作用

如果你把所有东西都加进依赖项,会发生什么?

无限循环的诱惑

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('Effect running');
    setCount(count + 1); // 依赖项里包含了 count
  }, [count]); 
}

流程:

  1. 渲染:count=0。Effect 运行。setCount(0+1)。更新队列:count -> 1。
  2. 提交:React 更新 state。count 变成 1。
  3. 调度:React 重新渲染。useEffect 检查依赖项 [count]。现在 count 是 1,之前是 0。变了!
  4. Effect 重新运行。setCount(1+1)
  5. 无限循环。

这是 React 的“副作用”特性。useEffect 就像是一个副作用,它改变了数据流。如果你把产生副作用的数据作为依赖项,你就制造了一个闭环。

如何跳出循环?

  1. 使用函数式更新

    setCount(prevCount => prevCount + 1);

    这样,setCount 的依赖项就变成了 prevCount,而不是外部变量。React 不会因为 setCount 本身的变化而重新触发 effect(虽然 setCount 引用会变,但 React 懂得处理函数式更新的特殊情况)。

  2. 使用 useRef

    const countRef = useRef(0);
    useEffect(() => {
      countRef.current++;
      setCount(countRef.current);
    }, []); // 依赖为空,安全
  3. 使用 useReducer
    类似于函数式更新,把状态逻辑封装在 reducer 里。


第七部分:为什么 React 不自动检测依赖项?

这是社区里争论最激烈的话题之一。

很多开发者痛恨 useEffect 的依赖项,觉得它是个累赘,容易写漏,容易写死循环。

为什么 React 团队不搞个“智能检测”功能?

假设 React 能自动检测:

  1. 性能灾难:React 需要分析你的回调函数里到底引用了哪些变量。这意味着要解析 AST,要追踪引用。这会极大地增加渲染开销。
  2. 难以解释:JavaScript 的闭包是动态的。有时候你在一个对象里引用了一个变量,有时候又没引用。React 很难 100% 准确地判断“哦,这个函数其实不需要这个变量”。如果你依赖了,它没写,那是 Bug;如果你没依赖,它写了,那是浪费性能。
  3. 控制权:React 是声明式框架,它的核心是“数据驱动视图”。副作用(useEffect)是数据流的例外。React 想要把这个例外控制得很严格。如果你不显式声明,React 就认为你没有副作用,或者你的副作用不依赖外部状态。

专家建议:

与其抱怨 React 严苛的依赖检查,不如把它当成一种契约

  • 契约一:如果你在 useEffect 里用了组件里的变量,你必须在依赖项数组里写上它。这是责任。
  • 契约二:如果你不想把它写进依赖项(为了性能或避免循环),那你必须用 useRef 来保存这个变量。

第八部分:进阶技巧——如何调试闭阱?

如果你碰到了闭阱,控制台通常不会直接告诉你。它只会给你一个看起来完全正常的错误数据。

这时候,你需要一点“黑客技巧”。

技巧 1:在回调里打印依赖项

不要只打印结果,打印环境。

useEffect(() => {
  console.log('Effect Context:', {
    count: count,
    userId: userId,
    timestamp: Date.now()
  });
}, [count, userId]);

如果打印出来的 timestamp 和你的点击时间对不上,或者 count 是旧的,那就是闭阱。

技巧 2:使用 React DevTools Profiler

打开 Profiler,录制你的操作。看看那个 useEffect 到底有没有重新执行。如果它没执行,但数据变了,那绝对是闭阱。

技巧 3:使用 ESLint 插件

安装 eslint-plugin-react-hooks。它会强制检查你的依赖项。

npm install eslint-plugin-react-hooks --save-dev

然后在 .eslintrc 里配置。

{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/exhaustive-deps": "error"
  }
}

虽然这看起来很烦人(它会提示你 Missing dependency),但它能救你的命。它会告诉你:“嘿,你用了 count,但你没写 [count]。”


第九部分:总结——与幽灵共舞

好了,各位,我们今天从外卖小哥的类比,一路聊到了 Fiber 节点的内存结构,又谈到了性能权衡。

React 状态闭阱,本质上就是闭包机制React 渲染调度机制 共同作用的结果。

  • 闭包 锁定了函数的执行环境。
  • React 的优化策略(浅比较依赖项)决定了何时更新这个环境。

要解决这个问题,你不需要去黑 React 源码(虽然源码很有趣),你只需要理解 React 的逻辑:

  1. 显式声明:把所有在 useEffect 里用到的外部变量都写进依赖项数组。这是最稳妥的。
  2. 善用 useRef:如果你只是想在一个异步操作里用最新的状态,或者想保存一个值但不触发重渲染,用 useRef。它是打破时间胶囊的万能钥匙。
  3. 函数式更新:在 setXxx 里使用 prev => prev + 1,避免循环依赖。

React 的设计哲学是“声明式”的,这通常意味着它需要你付出一点“命令式”的思考——明确告诉它你的副作用依赖什么。

别让那个幽灵困住你。写好你的依赖项数组,或者把变量放进 useRef 里。当你下次点击按钮,看到数据精准地更新时,你会发现,战胜闭阱的感觉,比喝了一杯冰镇可乐还爽。

好了,今天的讲座就到这里。记住,代码写得越严谨,Bug 越少;闭包写得越小心,鬼故事越少。下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注