闭包 (Closure):深度解剖
一、定锚
- 定义确认:闭包是一个函数及其对其周围状态(词法环境)的引用的组合。通俗误解认为它仅仅是“函数套函数”,实际上其核心在于:即便外部函数已经执行完毕,内部函数依然能“记住”并访问外部作用域的变量。
- 核心词素:函数(逻辑体)、环境(变量容器)、持久化(生命周期延伸)。
二、八刀剖析
-
历史: 起源于 1960 年代的 演算,由 Peter Landin 在 1964 年正式定义为“包含环境的表达式”。随后在 Scheme 语言中被发扬光大,最终成为现代 JavaScript、Python、Swift 等主流语言中实现函数式编程和模块化的灵魂。
-
辩证: 它是“函数”与“对象”的统一。在面向对象中,对象是附带过程的数据;而在闭包中,函数是附带数据的过程。它解决了纯函数的“无记忆性”与全局变量的“污染性”之间的矛盾。
-
现象: 闭包就像一个随身背囊。当一个函数(旅行者)从它的出生地(父作用域)出发时,它顺手抓起了一些必需品放入背囊。此后无论它走到世界的哪个角落,只要它还在,那个背囊及其中的物品就一直存在。
-
语言: “Closure” 源自“Closing over”,意为“包围/封闭”。它暗含了一种边界感:将原本易逝的局部变量“关”进了一个私有的时空胶囊,使其免于被垃圾回收机制(GC)销毁。
-
形式: 失效边界:当闭包过多或引用了巨大的数据结构而未被释放时,会引发“内存泄漏”;此外,闭包捕获的是变量的引用而非当前值,这在循环赋值中常导致直觉上的逻辑错误。
-
存在: 闭包重塑了代码的“生命观”。它证明了:一个事物的存在不取决于它所在的空间是否坍塌,而取决于它是否仍被需要。 它允许开发者在瞬时的执行流中,创造出永恒的私有状态。
-
美感: 闭包之美在于留白与隐藏。它像是一枚精致的琥珀,原本透明流动的变量(树脂)在特定瞬间凝固,包裹住了其中的逻辑(昆虫),形成一种静谧而深邃的封装结构。
-
元反思: 我们常将闭包比作“容器”,这暗示了某种静态性。若将其替换为“引力场”:函数并非装载变量,而是产生了一种引力,强行拉住了即将坠入虚无的变量。这提醒我们,闭包的代价是它对系统资源的“拖拽感”。
三、内观
我是一个拒绝遗忘的生命体。虽然我的母体(执行上下文)已经消亡,但我依然呼吸着她留下的空气。在外部世界看来,我只是一个普通的函数调用,但在我内部,我守着一处无人知晓的私人花园。只要我不消失,那里的花(变量)就永远不会凋零。
四、压缩总结
公式 闭包 = 逻辑函数 + 存活的上下文环境
核心逻辑 打破生命周期的束缚,实现私有状态的永续。
结构图
GLOBAL SPACE
+---------------------------------------+
| (Outer Function) - Died |
| +---------------------------+ |
| | Lexical Environment | |
| | [ Var A ] [ Var B ] <---+---+ |
| +-------------^-------------+ | |
| | | |
| (Inner Function/Closure) | |
| +---------------------------+ | |
| | Logic (Code) | | |
| | Reference to Env -------+ | |
| +-------------+-------------+ |
+------------------|--------------------+
|
Returned to Outer World五、先把一个误区打掉
很多人第一次学闭包时,会把它理解成“函数里 return 一个函数”。
这只是最经典的一种写法,但不是闭包的全部。
更准确地说:
只要一个函数在别处执行时,还能访问它创建时所在作用域里的变量,这里就有闭包。
所以这几种其实都是闭包:
- 返回内部函数
- 把函数传给
setTimeout - 把函数当事件回调
- 把函数传给
Promise.then - 在 React 里把函数交给
useEffect
也就是说,闭包的关键不是“return”,而是函数把外层变量记住了。
六、闭包在实战里到底出现在哪
1. 私有变量 / 工厂函数
这是最教科书式的用法。
function createCounter() {
let count = 0
return function () {
count++
return count
}
}
const counter = createCounter()
counter() // 1
counter() // 2这里的 count 本来只是 createCounter 内部的局部变量,但返回出去的函数一直还在使用它,所以它没有随着外层函数执行结束而消失。
这就是最标准的“私有状态”。
2. 防抖:为什么 timeoutId 不会丢?
看 防抖 (Debounce) 里的核心结构:
function debounce(func, wait) {
let timeout
return function (...args) {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}这里最重要的就是 timeout。
如果没有闭包,每次事件触发时,函数重新执行,timeout 都应该重新创建、重新丢失,那它就不可能记住“上一次的定时器是谁”。
但因为返回出去的函数形成了闭包,它一直引用着 timeout 所在的词法环境,所以:
- 第一次触发时,
timeout记录下第一次定时器 ID - 第二次触发时,还能拿到上一次的
timeout - 于是就可以
clearTimeout(timeout) - 然后重新设置一个新的定时器
所以防抖本质上是:
利用闭包,把
timeoutId从“某次调用的局部变量”变成“多次调用共享的私有状态”。
这也是你说的那个点: timeId 能够“脱离当前这一次函数调用而继续存在”。
3. 节流:为什么它能记住“冷却状态”?
看 节流 (Throttle) 的实现:
function throttle(func, wait) {
let timeout = null
return function (...args) {
if (!timeout) {
timeout = setTimeout(() => {
func.apply(this, args)
timeout = null
}, wait)
}
}
}或者时间戳版:
function throttle(func, wait) {
let lastTime = 0
return function (...args) {
const now = Date.now()
if (now - lastTime >= wait) {
lastTime = now
func.apply(this, args)
}
}
}这里的 timeout 或 lastTime,本质上都在做一件事:
- 记录“上一次有没有执行”
- 记录“上一次是什么时候执行的”
这类“跨多次调用共享的状态”,天然就是闭包的地盘。
所以:
- 防抖记住的是“上一次定时器”
- 节流记住的是“上一次执行资格”
它们背后都不是魔法,都是闭包在托住状态。
4. Promise:闭包到底参与了哪一层?
这个点要说严谨一点。
很多人会顺嘴说“Promise 的状态就是靠闭包保存的”,这句话不总是对。
比如在 构建自己的Promise 里,你现在写的是 class 版本:
this.state = "pending"
this.value = undefined
this.reason = undefined这里 pending / fulfilled / rejected 这些状态,严格来说是保存在 Promise 实例对象 上的,不是直接存在闭包里。
但是闭包仍然深度参与了 Promise 的实现,至少有这几层:
第一层:resolve / reject 记住当前 Promise 实例
const resolve = (value) => {
if (this.state !== "pending") return
this.state = "fulfilled"
this.value = value
}这里的 resolve 和 reject 虽然以后才会被调用,但它们依然知道自己应该去改哪一个 Promise 的状态。
为什么知道?
因为它们在创建时,就已经“带着”那一层上下文了。
你把它理解成:
Promise 把两个“带记忆的函数”交给了执行器。
第二层:异步回调记住 resolve / reject
new Promise((resolve, reject) => {
setTimeout(() => {
resolve("成功")
}, 1000)
})这里 setTimeout 里的回调,为什么 1 秒后还能调用 resolve?
因为这个回调本身就是闭包,它记住了外层的 resolve。
也就是说:
resolve是被 Promise 造出来的setTimeout里的函数又把resolve记住了
所以异步结束后,它还能回来改状态。
第三层:then 里的包装函数记住了回调和后续控制权
在 Promise底层讲解 里,then 通常会包装出这样的函数:
const fulfilledWrapper = (value) => {
const result = onFulfilled(value)
resolve(result)
}这个 fulfilledWrapper 其实也在闭包:
- 它记住了
onFulfilled - 它记住了新的
resolve - 它记住了新的
reject
这样等将来 Promise 真正 fulfilled 时,这个函数才能继续把链式调用往下推进。
第四层:如果不是 class 写法,状态本身也可以直接放在闭包里
比如函数式实现会写成:
function myPromise(executor) {
let state = "pending"
let value
let reason
function resolve(val) {
if (state !== "pending") return
state = "fulfilled"
value = val
}
}这时候 state、value、reason 就真的是闭包里的私有变量了。
所以更准确的说法应该是:
Promise 不一定把状态存在闭包里,但 Promise 的“异步回调还能改状态、then 还能接着链下去”这件事,一定大量依赖闭包。
5. 柯里化:为什么前面的参数不会丢?
看 函数柯里化:
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args)
} else {
return function (...args2) {
return curried.apply(this, args.concat(args2))
}
}
}
}这里最关键的是:
- 第一次调用拿到
args - 第二次调用还能拿到上一次的
args - 然后和新的
args2合并
为什么后一次还能记住前一次的参数?
因为返回出去的函数闭包住了前一次的 args。
所以柯里化本质上也是:
用闭包把“之前已经传过的参数”保存起来,等后面凑齐再执行。
6. 模块化 / IIFE:为什么有些变量像“私有成员”?
早期 JavaScript 在没有 ESM、CommonJS 之前,经常会这样做:
const counterModule = (function () {
let count = 0
return {
inc() {
count++
},
get() {
return count
},
}
})()这里的 count 外面拿不到,但 inc 和 get 永远能拿到。
这就是闭包在做“模块私有变量”。
也正因为这个思路,才有了后来的模块化封装演进。可以结合 从前端模块化历史到大厂面试题_牛客网 一起看。
7. React:为什么会有“闭包陷阱”?
在 React 里,闭包既是能力来源,也是常见坑点来源。
比如 React生命周期 里提到的这个例子:
useEffect(() => {
setCount(count + 1)
}, [])这里的问题是:
useEffect里的函数形成了闭包- 它拿到的是那次渲染时的
count - 依赖数组又写成
[] - 所以后面不会随着新的
count自动更新这份闭包里的值
这就是常说的“拿到旧值”或“stale closure”。
再结合 state 如同一张快照 看,就更好理解:
React 每次渲染都会生成一份新的“当次快照”,而闭包抓住的,正是某一次快照里的变量。
所以在 React 里常见两个结论:
- 为什么异步回调里经常拿到旧 state?因为闭包记住的是旧渲染快照
- 为什么函数式更新
setCount(c => c + 1)更稳?因为它不依赖旧闭包里的count
七、闭包到底帮我们解决了什么
如果把上面这些场景揉在一起看,会发现闭包几乎一直在解决同一类问题:
-
让状态跨调用存在 比如防抖里的
timeoutId、节流里的lastTime -
让数据对外部隐藏 比如模块化里的私有变量、工厂函数里的内部计数器
-
让异步回调还能回来继续工作 比如
setTimeout、Promise、事件回调 -
让函数能分步执行 比如柯里化、偏函数、bind 场景里的参数预置
所以一句更实战的话是:
闭包的价值,不只是“记住变量”,而是“让状态跨越当前执行栈继续活着,并且只暴露给需要它的函数”。
八、常见风险和误区
1. 闭包捕获的是“变量”,不是“那一刻的值”
这也是为什么循环里经常出问题:
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(i)
}, 0)
}
// 3 3 3因为三个回调闭包引用的是同一个 i。
如果想隔离每一次的值,可以用 let 或额外包一层函数作用域。
2. 闭包不是越多越好
闭包会延长变量生命周期。
如果闭包里挂着很大的对象、DOM 引用、定时器、订阅器,而你又没有清理,就会带来额外内存占用,严重时形成泄漏风险。
典型清理动作包括:
clearTimeoutclearInterval- 取消事件监听
- 断开订阅
- 把不再需要的闭包引用设为
null
3. 不是所有“访问外部变量”都值得用闭包包装
闭包很好用,但如果只是为了“能访问一个值”,却让作用域层级越来越深,代码会更难读。
所以闭包最值得出现的地方,往往是:
- 你真的需要私有状态
- 你真的需要跨调用保留数据
- 你真的需要异步回调在未来继续访问当前环境
九、把这篇笔记压成一句人话
闭包不是“函数套函数”这么简单,它真正厉害的地方在于:
它能让一个函数把自己出生时周围的变量一起带走,让这些变量即使脱离了原来的函数作用域,依然继续存在,并在未来的调用中持续发挥作用。
所以你在项目里看到的:
- 防抖记住
timeoutId - 节流记住
lastTime - Promise 回调在异步完成后还能改状态
- 柯里化记住前面的参数
- 模块化保留私有变量
- React 回调拿到某次渲染的快照
背后都能看见闭包的影子。
reference
Js中闭包的概念 防抖 (Debounce) 节流 (Throttle) 防抖与节流 构建自己的Promise Promise底层讲解 函数柯里化 React生命周期 state 如同一张快照 从前端模块化历史到大厂面试题_牛客网