乐于分享
好东西不私藏

React Hooks 闭包陷阱:在源码里寻找答案

React Hooks 闭包陷阱:在源码里寻找答案

理解 React Hooks 的闭包陷阱是怎么形成的,然后去源码里找到答案。

  • • 理解为什么 useEffect / setInterval 的回调永远读到旧值
  • • React 源码中与 Hooks 闭包相关的核心逻辑
  • • 四种以上解决闭包陷阱的方案,并理解每种方案的适用场景
  • • 建立起对 React 渲染模型的心智模型

一、那个让你抓狂的 bug

先看一个常见的代码:

importReact, { useState, useEffect } from'react';function Counter() {  const [count, setCount] = useState(0);  useEffect(() => {const timer = setInterval(() => {console.log('当前 count 是:', count);    }, 1000);return () => clearInterval(timer);  }, []); // 注意这里是空数组  return (<div><p>当前计数:{count}</p><buttononClick={() => setCount(count + 1)}>增加</button></div>  );}

这段代码看起来很正常,useEffect 的依赖数组是空的,意思是「这个 effect 只在组件第一次挂载的时候执行一次,之后不再执行」。setInterval 每秒打印一次 count 的值。

但当真的运行这段代码,点击几次「增加」按钮之后,会发现一个诡异的现象:

  • • 页面上显示的 count 已经变成了 3、4、5…
  • • 但控制台每秒打印的,始终是 0
当前 count 是: 0当前 count 是: 0当前 count 是: 0当前 count 是: 0当前 count 是: 0

这不对啊,明明点击了按钮,count 明明已经变了,为什么 setInterval 回调里读到的还是 0?

这就是 React Hooks 里最经典的闭包陷阱。

怎么修复这个问题应该不是主要思考的,应该考虑的是以下几个问题:

  1. 1. 为什么 setInterval 的回调函数读不到最新的 count
  2. 2. 组件明明重新渲染了,为什么这个回调没有更新?
  3. 3. React 底层到底在做什么?
  4. 4. 源码是什么样子的,可以从源码层面解释这个现象吗?

二、闭包到底是什么

闭包的表象是函数里面返回函数,实际上闭包的水远比想的要深。

2.1 闭包的本质定义

MDN 文档对闭包的定义:

闭包是由函数以及创建该函数时所在的词法环境(lexical environment)共同组成的关系。

通俗的翻译一下:

闭包 = 函数 + 它被创建时「记住」的那些变量

当创建一个函数的时候,这个函数会「记住」它创建时所在的那些变量。即使这个函数被传递到其他地方调用,它依然能够访问到那些变量。这就是闭包。

举个例子:

functionouter() {  const x = 10;  function inner() {console.log(x);  }  return inner;}const fn = outer(); // outer 已经执行完了fn(); // 打印 10

这里有个反直觉的点:outer 函数已经执行完了,它的局部变量 x 本应该被垃圾回收机制回收掉,但 inner 函数居然还能访问到 x

因为 inner 在创建的时候,连同 x 一起打包带走了。inner 函数对象身上有一个隐藏的「指针」,指向创建时的作用域。这个「指针」就是所谓的「闭包」。

2.2 闭包如何工作的

可以从 JavaScript 引擎方面尝试了解闭包

在 JavaScript 中,每个函数都有一个 [[Scope]] 属性,这是一个内部的隐藏属性,无法直接访问,但它确实存在。当创建一个函数时,JavaScript 引擎会把当前执行上下文(execution context)中的作用域链(Scope Chain)保存到函数的 [[Scope]] 属性中。

下图是我在掘金网站中打开开发者工具的例子

右侧可以看到Scope

执行上下文是什么?简单来说,就是代码执行时的「环境」。每次调用一个函数,都会创建一个新的执行上下文,里面包含:

  • • 当前函数的局部变量
  • • 指向外层作用域的引用(也就是所谓的 Scope Chain)
  • • this 的绑定

当在函数内部访问一个变量时,JavaScript 引擎会沿着作用域链从内向外查找:

  1. 1. 先在当前函数的局部变量里找
  2. 2. 如果找不到的话,会沿着作用域链去外层找
  3. 3. 外层找不到,继续找外层的外层,一直找到全局作用域
  4. 4. 如果到全局作用于还没找到的话,就会报错

闭包之所以能访问外层的变量,是因为函数创建时就把外层作用域「打包」进了自己的 [[Scope]] 属性里。无论这个函数最终被传递到哪里调用,只要它执行,就会沿着这个保存下来的作用域链去查找变量。

2.3 闭包在日常开发中的使用场景

闭包不仅仅是面试题里的概念,它在实际开发中有大量应用。举几个例子:

场景一:数据私有化

functioncreateCounter() {  let count = 0;  return {increment() {      count++;return count;    },decrement() {      count--;return count;    },getCount() {return count;    }  };}const counter = createCounter();console.log(counter.increment()); // 1console.log(counter.increment()); // 2console.log(counter.getCount()); // 2console.log(count); // 报错!count is not defined

这里 count 变量被闭包「私有化」了,外部无法直接访问,只能通过返回的对象方法来操作。

场景二:函数工厂

functioncreateAdder(x) {returnfunction(y) {return x + y;  };}const add5 = createAdder(5);const add10 = createAdder(10);console.log(add5(3)); // 8console.log(add10(3)); // 13

createAdder 返回的函数携带了参数 x,形成了闭包。这就是函数式编程中常见的偏函数(Partial Application)模式。

场景三:节流与防抖

functionthrottle(fn, delay) {let lastCall = 0;returnfunction(...args) {const now = Date.now();if (now - lastCall >= delay) {      lastCall = now;      fn.apply(this, args);    }  };}

这里返回的函数形成了对 lastCall 变量的闭包,从而实现了节流功能。


三、从React 渲染模型方面进一步理解闭包陷阱

如果觉得闭包难,那 React 里的闭包就是难上加难。因为 React 有自己的一套渲染模型,它会放大闭包的问题。

3.1 每次渲染都是一次独立的函数执行

React 函数组件本质上就是一个普通 JavaScript 函数,每次状态变化导致的重渲染,就是这个函数被重新调用一次。

把这个问题拆开来看:

functionApp() {const [count, setCount] = useState(0);// ... 组件逻辑return<div>{count}</div>;}

当点击按钮触发 setCount 之后,React 会:

  1. 1. 检测到状态变化
  2. 2. 重新调用 App 函数
  3. 3. 生成新的 React Element 树
  4. 4. 对比新旧树,找出差异
  5. 5. 更新 DOM

在这个过程中,App 函数被重新执行了。这和普通 JavaScript 函数的执行没有任何区别。

这意味着什么?每次渲染,函数组件内部创建的所有函数,都会形成新的闭包。

functionApp() {  const [count, setCount] = useState(0);  // 每次渲染都会创建新的 handleClick 函数  function handleClick() {console.log('count is:', count); // 这个 count 是本次渲染的值  }  // 每次渲染都会创建新的 useEffect  useEffect(() => {console.log('effect running, count is:', count);  }, []); // 空依赖  return <buttononClick={handleClick}>click me</button>;}
  • • 第 1 次渲染:count = 0handleClick 闭包里的 count 是 0
  • • 第 2 次渲染:count = 1handleClick 闭包里的 count 是 1
  • • 第 3 次渲染:count = 2handleClick 闭包里的 count 是 2

每次渲染都是独立的「快照」。这不是 React 的 bug,而是它的核心设计理念:确定性渲染

3.2 为什么 React 要这样设计?

为了保证渲染的可预测性。

在 React 的世界里,组件的输出(Render Output)应该只由当前的 props 和 state 决定。如果组件在同样的输入下产生不同的输出,React 的 diff 算法就无法正常工作,整个渲染体系就会崩溃。

每次渲染都使用新的函数闭包,确保了每次渲染都是「纯净」的。这次渲染看到的就是这次渲染的状态,不会受到之前渲染的影响。这种设计让 React 的渲染行为变得完全可预测、可追踪、可调试。

但问题也随之而来:当你把一个函数传递给子组件、或者传递给 useEffect、或者传递给 setTimeout/setInterval 时,你传递的是「那一次特定渲染」创建的函数,它的闭包里有「那一次特定渲染」的状态快照。

这就是闭包陷阱的根源。

3.3 Hooks 的依赖数组到底是干什么的

现在我们理解了「每次渲染都是独立快照」这个前提,再来看 useEffect 的依赖数组,你就知道它在干什么了。

useEffect(() => {// 这个函数}, [count]); // 依赖数组

依赖数组的本质是:告诉 React「什么时候重新创建这个 effect 函数」。

  • • 当 count 变化时,React 会发现「依赖变了」,于是重新执行 effect 函数,创建新的闭包
  • • 当 count 不变时,React 会跳过这次 effect 执行,复用上次的结果

回到我们开头例子:

useEffect(() => {const timer = setInterval(() => {console.log(count);  }, 1000);return() =>clearInterval(timer);}, []); // 空依赖 = 永远不重新执行

因为依赖数组是空的,React 认为「这个 effect 不依赖任何会变化的值」,所以只在组件第一次挂载时执行一次。这次执行时,count 是 0,所以 setInterval 的回调函数永远记住的是 count = 0

之后 count 变了,但 effect 没有重新执行,所以 timer 还是那个 timer,回调还是那个回调,闭包里的 count 还是 0。


四、React Hooks 闭包的底层实现

看看 React 是怎么处理这些问题的。理解源码,站在源码角度回过头来重新审视回来就知道问题是怎样出现的以及如何解决。

4.1 React Hooks 的核心数据结构

React Hooks 的实现主要在 ReactFiberHooks.js 文件中(这是 React Fiber 重写后的 Hooks 实现)。核心数据结构有两个:

1. Fiber 对象

Fiber 是 React 16 引入的新架构,它是 React 调和(Reconciliation)过程的核心。每个组件实例都对应一个 Fiber 节点,Fiber 节点保存了组件的相关信息。

2. Hook 对象

// 简化版的 Hook 数据结构{memoizedStatenull,    // 当前 state 的值(对于 useState 就是状态值)baseStatenull,       // 初始 statequeuenull,           // 更新队列baseUpdatenull,      // 基础更新nextnull,            // 指向下一个 Hook(支持多个 useState/useEffect)}

每次调用 useState 或 useEffect,都会创建一个 Hook 对象,并挂载到当前 Fiber 节点的 memoizedState 链表上。

4.2 useState 的实现:为什么 setState 是稳定的

useState(在源码中实际上是 mountState)是怎么实现的:

functionmountState(initialState) {  // 获取当前的 Hook 对象  const hook = mountWorkInProgressHook();  // 如果 initialState 是函数,执行它  if (typeof initialState === 'function') {    initialState = initialState();  }  // 保存初始状态  hook.memoizedState = hook.baseState = initialState;  // 创建更新队列  const queue = (hook.queue = {pendingnull,dispatchnull,lastRenderedReducer: basicStateReducer,lastRenderedState: initialState,  });  // 创建 dispatch 函数  const dispatch = (queue.dispatch = dispatchSetState.bind(null,    currentlyRenderingFiber,    queue,  ));  // 返回 [state, dispatch]  return [hook.memoizedState, dispatch];}

这里有一个关键点:dispatch(也就是 setCount)是在 mountState 时创建的,它绑定的是 currentlyRenderingFiber 和 queue,而不是具体的值。

这意味着 每次渲染返回的 dispatch 函数是同一个引用

functionApp() {const [count, setCount] = useState(0);// 每次渲染,setCount 都是同一个函数引用console.log(setCount === setCount); // true,永远是 true}

这就是 React 设计的高明之处,状态值(count)每次渲染都可能变,但更新函数(setCount)是稳定的。这让我们可以把 setCount 传递给子组件而不用担心引用变化导致的无限渲染。

但是也带来一个问题:如果在闭包中使用了 setCount,并且期望它能读到最新的状态,会发现读到的永远是旧值——因为闭包捕获的是创建时的那一次渲染的状态。

4.3 useEffect 的依赖对比:源码是怎么做的

useEffect 的核心逻辑在于它如何判断「是否需要重新执行」。这个逻辑在 areHookInputsEqual 函数中:

functionareHookInputsEqual(nextDeps, prevDeps) {  // 如果没有之前的依赖,说明是第一次  if (prevDeps === null) {returnfalse;  }  // 逐个比较依赖项  for (let i = 0; i < prevDeps.length; i++) {// 使用 Object.is 进行严格相等比较if (!Object.is(nextDeps[i], prevDeps[i])) {return false// 有变化,返回 false    }  }  return true// 所有依赖都没变化,返回 true}

这是 React 用来判断是否重新执行 effect 的核心逻辑。使用 Object.is 而不是 ===,是为了处理 NaN === NaN 返回 false 这种边界情况,以及 -0 和 +0 的区别。

当我们写 useEffect(() => { ... }, []) 时:

  • • 第一次渲染:prevDeps 是 null,直接返回 false,effect 被执行
  • • 后续渲染:prevDeps 是 [],nextDeps 也是 [],逐个比较都是「未定义 === 未定义」,返回 true,effect 不会被执行

这就是为什么空依赖数组的 effect 只执行一次。

4.4 Hooks 的调用顺序规则:为什么顺序不能乱

React Hooks 有一个铁律:Hooks 必须在顶层调用,不能在循环、条件判断、嵌套函数中调用。

为什么?因为 React 依赖调用顺序来找到对应的 Hook。

// React 内部维护了一个 "workInProgressHook" 指针let currentlyRenderingFiber = null;let workInProgressHook = null;function mountWorkInProgressHook() {  const hook = {memoizedStatenull,baseStatenull,queuenull,baseUpdatenull,nextnull,  };  if (workInProgressHook === null) {// 第一个 Hook    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;  } else {// 后续 Hook,追加到链表末尾    workInProgressHook = workInProgressHook.next = hook;  }  return workInProgressHook;}

React 通过链表来存储 Hook,每次调用 useState() 或 useEffect(),就沿着链表往下走一个。如果你改变调用顺序,React 就无法正确关联到对应的 Hook 数据。

//  错误:条件判断会导致 Hook 顺序不确定function App() {  const [show, setShow] = useState(true);  if (show) {const [count, setCount] = useState(0); // 可能在不同渲染下调用不到  }  const [name, setName] = useState('Tom'); // 这可能变成第一个 useState}//  正确:所有 Hook 都在顶层调用function App() {  const [show, setShow] = useState(true);  const [count, setCount] = useState(0); // 始终调用  const [name, setName] = useState('Tom'); // 始终调用  if (show) {// 在函数体内部使用状态,但 Hook 调用顺序不变  }}

五、闭包陷阱的解决方案

下面是解决方案的实现,由浅入深。

5.1 方案一:把依赖加进去

最直接的解决方案就是:既然闭包捕获的是旧值,那我们可以让闭包每次都用新值重建。

functionCounter() {  const [count, setCount] = useState(0);  useEffect(() => {const timer = setInterval(() => {console.log('count is:', count);    }, 1000);return () => clearInterval(timer);  }, [count]); // 依赖 count,每次 count 变化都重新执行 effect  return (<div><p>计数:{count}</p><buttononClick={() => setCount(count + 1)}>增加</button></div>  );}

当点击按钮,count 变化,依赖数组 [count] 检测到变化,于是:

  1. 1. 旧的 effect 清理函数执行,旧的 timer 被 clearInterval 清除
  2. 2. effect 重新执行,创建新的 timer
  3. 3. 新的 timer 里的回调函数闭包捕获了新的 count 值

优点:简单直接,代码清晰明了缺点:每次 count 变化,timer 都会被销毁并重建。对于简单的场景没问题,但如果你的 effect 里有复杂的初始化逻辑,或者定时器的间隔很短,频繁重建会有性能开销。

5.2 方案二:函数式更新

React 中的 setState 可以接收一个函数:

setCount(c => c + 1);

但其实这个函数式写法本身就是解决闭包陷阱的一种方式。

functionCounter() {  const [count, setCount] = useState(0);  useEffect(() => {const timer = setInterval(() => {// 使用函数式更新!setCount(c => c + 1);    }, 1000);return () => clearInterval(timer);  }, []); // 保持空依赖,timer 不重建  return<div>计数:{count}</div>;}

为什么这种方式可以?

因为 setCount(c => c + 1) 这个函数,不是在创建 effect 时被执行的,而是在真正触发更新时才执行的。不依赖闭包中的任何值,只需要上一个状态的「指针」——而这个指针是 React 内部维护的,总是能拿到最新值。

可以这样理解:

  • • 直接调用 setCount(count + 1):在创建回调的那一刻,就把 count 的当前值(快照)写进去了
  • • 调用 setCount(c => c + 1):回调函数里只写了一个「如何计算下一个状态」的公式,这个公式在执行时才会去读取最新的 c

优点:不需要重建 effect,timer 保持稳定缺点:只能解决「触发更新」的场景,不能解决「读取当前值」的场景。如果不是在 setState,而是真的需要读取当前值做一些计算,那么就不需要此种方式。

5.3 方案三:useRef 

useRef 是 React Hooks 中最被低估的一个。它看起来很简单:

const ref = useRef(initialValue);// ref.current 可以读写

核心特性是:返回的 ref 对象在每次渲染中都是同一个引用。

闭包的问题在于函数捕获了变量当时的值,但 ref 本身是一个对象,闭包捕获的是 ref 对象的「引用」,而不是 ref.current 的「值」。

functionCounter() {  const [count, setCount] = useState(0);  const countRef = useRef(count); // 创建 ref  // 每次渲染都把最新的 count 同步到 ref.current  useEffect(() => {    countRef.current = count;  }, [count]);  useEffect(() => {const timer = setInterval(() => {// 通过 ref.current 读取最新值console.log('count is:', countRef.current);    }, 1000);return () => clearInterval(timer);  }, []); // 依赖数组为空,timer 不会重建  return (<div><p>计数:{count}</p><buttononClick={() => setCount(count + 1)}>增加</button></div>  );}

这个方案的精妙之处在于:

  1. 1. countRef 是一个稳定的对象引用,每次渲染都返回同一个 { current: ... } 对象
  2. 2. setInterval 的回调函数闭包捕获了 countRef 这个引用
  3. 3. 每次渲染时,useEffect 把最新的 count 写入 countRef.current
  4. 4. 当 setInterval 回调执行时,读取 countRef.current,永远是最新的值

优点:解决需要读取最新值的场景,effect 不需要重建缺点:代码稍微多一行,而且需要理解 ref 的工作原理,有一定学习成本

5.4 方案四:useReducer

如果觉得 ref 的方式不够直观,可以考虑 useReducer。它的 dispatch 函数也是稳定的:

functionCounter() {  const [count, dispatch] = useReducer((state, action) => {switch (action.type) {case'increment':return state + 1;case'decrement':return state - 1;default:return state;    }  }, 0);  useEffect(() => {const timer = setInterval(() => {dispatch({ type'increment' });    }, 1000);return () => clearInterval(timer);  }, []); // 依赖数组为空  return<div>计数:{count}</div>;}

dispatch 和 setState 的函数式更新类似,都是 React 内部的稳定引用,不受闭包影响。

优点:代码结构清晰,适合状态逻辑复杂的场景缺点:对于简单场景有点杀鸡用牛刀的意思

5.5 方案五:自定义 Hook 封装

如果在项目中大量遇到这种场景,每次都写 ref + effect 是不是很烦?这时候应该封装一个通用的 Hook。

来自 Dan Abramov 的经典实现 use-interval

import { useEffect, useRef } from'react';function useInterval(callback, delay) {  const savedCallback = useRef(callback);  // 每次渲染都更新 ref 中保存的回调函数  useEffect(() => {    savedCallback.current = callback;  }, [callback]);  // 设置定时器  useEffect(() => {if (delay === null) {return;    }const id = setInterval(() => savedCallback.current(), delay);return () => clearInterval(id);  }, [delay]);}

使用它:

functionCounter() {const [count, setCount] = useState(0);useInterval(() => {console.log('count is:', count);  }, 1000);return <div>计数:{count}</button>;}

这个 Hook 做了两件事:

  1. 1. 用 ref 保存最新的回调函数,确保每次调用的都是最新版本
  2. 2. 用 effect 管理定时器的创建和清理,依赖数组只关心间隔是否变化,不关心回调内容

六、实战案例:在真实项目中如何处理比较合适

6.1 案例一:表单实时保存

functionEditor() {  const [content, setContent] = useState('');  const [saving, setSaving] = useState(false);  // 自动保存逻辑  useEffect(() => {const timer = setTimeout(async () => {setSaving(true);await saveToServer(content); // 这里需要读取最新的 contentsetSaving(false);    }, 1000);return () => clearTimeout(timer);  }, [content]); // 每次 content 变化都重新设置定时器  return (<div><textareavalue={content}onChange={e => setContent(e.target.value)} />      {saving && <span>保存中...</span>}</div>  );}

每次输入变化,都会重新设置一个 1 秒后的保存定时器。如果用户连续输入,只有最后一次输入后 1 秒才会真正保存,也就是防抖效果

6.2 案例二:WebSocket 实时消息

functionChatRoom() {  const [messages, setMessages] = useState([]);  const wsRef = useRef(null);  const messagesRef = useRef(messages);  // 同步最新消息到 ref  useEffect(() => {    messagesRef.current = messages;  }, [messages]);  useEffect(() => {    wsRef.current = new WebSocket('链接');    wsRef.current.onmessage = (event) => {const newMessage = JSON.parse(event.data);// 通过 ref 读取最新消息,而不是依赖闭包setMessages([...messagesRef.current, newMessage]);    };return() => wsRef.current.close();  }, []); // WebSocket 连接只需要建立一次  return <div>{/* 渲染消息列表 */}</div>;}

WebSocket 的 onmessage 回调是在连接建立时绑定的,它的闭包捕获的是建立时的 messages 值。通过 ref,可以确保每次收到新消息时,合并的是最新的消息列表。

6.3 案例三:动画循环

functionAnimatedComponent() {  const [progress, setProgress] = useState(0);  const requestRef = useRef();  const animate = (time) => {// 计算新的进度const newProgress = time / 10 % 100;setProgress(newProgress);    requestRef.current = requestAnimationFrame(animate);  };  useEffect(() => {    requestRef.current = requestAnimationFrame(animate);return () => cancelAnimationFrame(requestRef.current);  }, []); // 动画循环只需要启动一次  return <divstyle={{width: `${progress}%` }}>...</div>;}

requestAnimationFrame 的回调同样面临闭包问题。这里使用 ref 来存储 requestAnimationFrame 的 ID,确保清理时能取消正确的帧。


七、React 为什么这样设计

如果想要继续深挖,那就思考一下「为什么 React 要这样设计」。

函数式组件的核心哲学

React 函数式组件的核心哲学是:UI as a Function of State

functionButton({ count, onClick }) {return<buttononClick={onClick}>{count}</button>;}

这个函数的输出(UI)完全由输入(props: count, onClick)决定。同样的输入,永远产生同样的输出。

这种设计带来了巨大的好处:

  • • 可预测性:给定相同的状态,UI 永远是一致的
  • • 可测试性:不需要模拟整个 React 环境,直接调用函数就能测试输出
  • • 可调试性:任何 UI 问题都可以通过「找出对应的状态」来定位
  • • 并发模式:React 的 Concurrent Features 依赖于这种可中断、可恢复的渲染模型

这种设计也有代价:每次渲染都是一次全新的函数执行,所有函数都需要重建

这不是 React 的 bug,React 选择了「每次渲染重建函数」来换取「渲染的确定性和可预测性」。


八、简单总结

这篇文章太长了,做一个简单的总结:

每次渲染都是独立的快照。

React 函数组件每次渲染,都会重新执行整个函数。所有在这期间创建的函数,都会形成闭包,捕获那一次渲染时的状态值。这不是 bug,这是 React 的核心设计。

闭包陷阱的本质是创建时的环境和执行时的环境不一致。

在 useEffect 里创建的 setInterval 回调,它创建时 count 是 0,所以它永远读到的都是 0。要么让回调重新创建(加依赖),要么让回调不依赖具体值(用 ref 或函数式更新)。

解决思路要么是重新创建,要么是稳定引用。

  • • 重新创建:依赖数组变化时,effect 重新执行,回调函数重建,闭包里的值是最新的
  • • 稳定引用:用 useRef 或 dispatch,它们不依赖闭包中的值,而是通过其他方式获取最新状态

九、写在最后

我觉得写一篇文章也是一个深入学习的过程,有可能会这个知识点,但并没有形成过具体的文字,通过这篇文章也学习到了不少,会发现这不是一个单独的知识点,而是一个知识网络,要想吃透这个就要先学会这个知识点中联系到的知识。

所以说学习以及深入学习是一个很必要的事情,不能单单知道这个方法怎么用,而且要知道为什么设计这个方法,这个方法有什么优缺点以及解决了哪些问题,只要有这个不断学习的心态,学什么都会学的很好的,共勉!