闭包和 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、以及为什么全局持有的闭包会一直占着内存。