闭包和 useState 是两个知识点?你可能只学了半条作用域链

闭包和 useState 是两个知识点?你可能只学了半条作用域链

摘要:从一道答错的静态作用域输出题出发,把闭包、堆内存和 React 的状态快照连成同一条链,并给出 useState 的三条使用规则与一份泄漏判断清单。

面试里闭包的定义人人都背得出,但把 setInterval 换成 useEffect 之后,同样的代码就开始出问题:计数停在 1,怎么点都没反应。多数人会把这两件事当成两个知识点分别记,于是背了两套结论,却始终答不出第二层追问。

本文的判断是:它们本来就是同一条链的两端。 词法作用域决定变量在哪查找,闭包决定这些变量活多久,React 的每次渲染决定你拿到的是哪一份快照。材料是一份小米前端复习目录,里面 4 个闭包 demo 加 1 个 React demo,刚好够把这条链走完。

边界先说清楚:代码片段来自这份目录,输出均按语言规则推导,本次没有执行,属于"运行未验证"。

先看一个三行的反例

js 复制代码
function bar(){ console.log(myName) }
function foo(){
  var myName = 'jikeban'
  bar()
}
var myName = 'xiaomi'
foo()

这题在面试里翻车率很高,因为直觉太顺了:在 foo 里调用 bar,那 bar 应该看到 foo 的 myName。

答案是 xiaomi。

bar 写在全剧最外层,它的查找路径从那句声明开始,压根不经过 foo。调用位置只是"谁按了开关",跟"线接到哪"是两回事。

这句判断可以迁移到几乎所有作用域题:先找定义位置,再看调用位置。

误解一:闭包不是"函数套函数"

函数嵌套只是闭包最常见的写法,不是定义。

真正的定义是:内部函数被带到外部之后,仍然能访问它定义时所在作用域的变量。关键在"被带出去"这个动作,不在"套了几层"。

来看最干净的版本:

js 复制代码
function foo() {
  var b = 2
  return function bar() { console.log(b) }
}
const baz = foo()
baz()      // 推导输出:2

foo 已经执行完了,按"上下文用完即销毁"的规则,b 应该跟着消失。但 bar 被返回、被 baz 持有,它还需要 b,所以 b 没死。

死掉的是栈上的执行上下文 ,活下来的是堆上的变量。

误解二:变量不是"留在栈里",是被搬到了堆

很多人解释闭包时会说"函数出栈了,但变量还在栈里",这句话在机制上是错的------栈帧弹出后,那块栈空间就被回收了,不可能还留着你的变量。

实际发生的是:引擎发现 bar 引用了 b,就把 b 收进一个 Context 对象,放到堆上。栈帧该弹就弹,堆里的 Context 因为有引用而不会被 GC 回收。

材料里把它比喻成"背包":函数离开时把自己要用的变量装进背包,背包放在堆里,所以人走了包还在。

这个区别看似抠字眼,但它直接决定你怎么解释内存泄漏:闭包涨的是堆,不是栈。 栈只会因为递归太深而溢出。

误解三:React 每次渲染都在拍一张新快照

把上面三条串起来,React 的行为就非常自然了。

组件是一个函数。每次渲染,就是重新调用一次这个函数,生成一份新的局部变量。你在某一次渲染里写下的 count,是那一刻的常量,之后不会再变。

useEffect 依赖数组为空,意味着里面的定时器只在挂载时创建一次。那个回调属于第一次渲染 ,它捕获的 count 始终是 0。

jsx 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1)              // 每秒都是 0 + 1
  }, 1000)
  return () => clearInterval(timer)
}, [])

每秒都算出 1,React 发现新旧值相同,跳过重渲染。页面就停在 Count: 1。

修复只需要换一种更新方式:

jsx 复制代码
setCount(prevCount => prevCount + 1)

prevCount 不再来自闭包,而是 React 从更新队列里给你的最新值。快照过期的问题被绕开了。

材料里的 App.jsx 把这行错误写法注释掉、留着修复写法,等于把"错误证据"和"正确答案"并排放在一起,很适合直接当讲稿。

由此倒推 useState 的三条规则

不用背 API,从快照机制就能推出来。

一、初始值只在首次渲染生效。 所以昂贵计算要用 useState(() => heavy()) 惰性初始化,函数只被调用一次;写成 useState(heavy()) 则每次渲染都白算。

二、更新是批量的。 setState 只是登记,React 在事件结束后合并成一次重渲染。它不是"值延迟变",而是"当前这次渲染的变量根本不会变"。

三、依赖旧值必须函数式更新。 连写三次 setCount(count + 1) 只加 1,因为三次读的是同一个快照;改成 c => c + 1 才会依次累加。

判断闭包会不会泄漏,只看一个位置

不用记复杂的规则,只看持有闭包的那个变量在哪:

持有者 生命周期 风险
全局变量 到页面关闭 不再使用就是泄漏
局部变量 函数销毁后可回收 风险可控

这就是全部判断标准。至于"闭包有额外上下文开销"那类说法,属于高频创建时的性能成本,和泄漏是两件事,不要混为一谈。

一页速查卡

问题 一句话答案
变量去哪找 沿 outer 走作用域链,起点是定义位置
为什么闭包不销毁 被引用的变量搬到了堆上的 Context
闭包泄漏在哪 堆,不是栈
this 和变量查找的区别 this 看调用方式,变量看书写位置
计数停在 1 空依赖 effect 捕获了首次渲染的快照
怎么修 setCount(prev => prev + 1),或把值放进依赖
惰性初始化 useState(() => compute()) 只跑首次渲染

最后留一个可执行的检查

下次读到任何一段闭包代码,先做这个动作:找到内部函数,圈出它引用的每一个外部变量,然后问自己"这个函数会被带出定义它的函数吗"。

答案是"会",那就是闭包,变量就在堆上;答案是"不会",那只是一次普通的嵌套调用,用完就回收。

这个判断比背定义有用,因为它能同时解释三件事:为什么 foo 返回后 b 还在、为什么计数停在 1、以及为什么全局持有的闭包会一直占着内存。

相关推荐
祥Sir的SRE笔记1 小时前
CPU 100% 不一定是故障:压测打满、业务饱和、突变打满的判断与排查
linux·面试·性能优化·dba·sre·故障定位·性能排查
2601_958352901 小时前
一款模块通吃15类语音产品?AN-93如何实现录音笔、门禁、IPC的快速适配
前端·人工智能·嵌入式硬件·回声消除·降噪处理
子兮曰1 小时前
Bun v1.4.4 实测:2,025 个文件,bun fmt 2.6 秒跑完
前端·后端·bun
Liudef061 小时前
用 DeepSeek Search API 做一个干净的单文件搜索页面(附完整源码)
前端·人工智能·工具·搜索·deepseek
忆挽篱笙歌1 小时前
进程替换函数与shenll命令行解释器
前端·javascript·算法
计算机魔术师1 小时前
砸了2200亿美元还是不够用,AWS说2027年也卖完了
前端
2601_965305391 小时前
智慧农用的农业四情分别是什么
服务器·前端·人工智能
老徐聊GEO2 小时前
把“安徽“写进题面之后:多平台AI推荐名单的区域扰动测量(2026)
前端·人工智能·算法·geo优化
moshuying2 小时前
DeppInfer系列之 - 矩阵、注意力与 GPU:拆解 DeepGEMM 与 FlashMLA
前端·javascript·面试