先说结论:闭包不会让整个函数执行栈一直留在内存里。外层函数执行完后,执行上下文仍然会出栈;只有被内部函数引用、并且仍然可达的变量,才可能作为词法环境的一部分继续保留。等闭包本身不可达后,这些变量才能被垃圾回收。【1】【W4】
1. 什么是闭包
我对闭包的理解是:
函数和它创建时所在的词法环境(Lexical Environment)的组合。
简单说,内部函数即使离开了外部函数,仍然可以访问外部函数作用域里的变量,这就是闭包体现出来的能力。
function createCounter() {
let count = 0
return function counter() {
count++
return count
}
}
const counter = createCounter()
console.log(counter()) // 1
console.log(counter()) // 2
createCounter() 已经执行结束了,但 counter() 仍然能访问 count。
原因不是 createCounter 的调用栈没有释放,而是返回的 counter 仍然引用着对应的词法环境,所以 count 仍然可达,暂时不会被垃圾回收【W4】。
可以简化为:
counter
↓
内部函数
↓
词法环境
↓
count = 2
2. 外层函数执行完,执行栈会不会释放
会释放。
调用函数时,会创建执行上下文并压入调用栈。函数执行结束后,对应的执行上下文会从调用栈中弹出。
function outer() {
const value = 100
function inner() {
return value
}
return inner
}
const fn = outer()
调用过程可以简化为:
调用 outer()
调用栈:
┌──────────────┐
│ outer context│
├──────────────┤
│ global │
└──────────────┘
outer() 返回之后:
调用栈:
┌──────────────┐
│ global │
└──────────────┘
outer 的执行上下文已经出栈,但 fn 还引用着 inner,而 inner 又引用了 value 所在的词法环境。因此需要保留的变量会继续存在。
这里要避免一个常见的错误说法:
因为形成闭包,所以外层函数的调用栈没有释放。
这不准确。**释放的是调用栈上的执行上下文;被闭包捕获的数据会通过引擎内部的 Context、Environment 等结构继续存活。**从教学模型看,可以把它理解为相关变量被保存到堆中【1】【2】。具体如何存储属于 JavaScript 引擎实现细节,不应该机械地理解成“所有局部变量永远都在栈”或“整个作用域都复制到了堆”。
程序运行同时需要栈和堆,不可能把所有数据都直接放进栈中【3】【4】。
3. 哪些内存会被保留
通常只有闭包实际需要访问的变量有必要被保留。不过具体引擎可能因为优化策略,把一部分关联环境一起保留,不能只凭源码断言精确的底层内存布局。
function createGetter() {
const used = { name: 'batype' }
const unused = new Array(100000).fill('data')
return function getName() {
return used.name
}
}
const getName = createGetter()
从语义上看:
used被内部函数引用,需要保留;unused没有被引用,之后可以被回收;createGetter的执行上下文会正常出栈。
但在分析真实内存时,仍然要以浏览器 DevTools 的 Heap Snapshot 为准,因为引擎优化会影响实际表现。
4. 闭包什么时候释放
核心判断只有一个:闭包及其关联的词法环境是否仍然可达。
function createCounter() {
let count = 0
return () => ++count
}
let counter = createCounter()
console.log(counter()) // 1
counter = null
执行:
counter = null
之后,如果没有其他地方引用返回的函数,那么:
返回的函数 → 不可达
词法环境 → 不可达
count → 可以被垃圾回收
注意是“可以被回收”,不是“这一行执行完立刻回收”。垃圾回收的具体时间由 JavaScript 引擎决定。词法环境只有在不可达时才会被清理【W4】。
5. 几个典型例子
例一:闭包一直被引用,变量不会释放
function createCache() {
const cache = new Array(1000000).fill('data')
return function getItem(index) {
return cache[index]
}
}
const getItem = createCache()
只要 getItem 一直可达,它引用的 cache 就必须保留。
console.log(getItem(0))
这不一定是内存泄漏,因为程序可能确实需要这个缓存。内存占用和内存泄漏不是一回事。
如果后续不再需要,应主动断开引用:
let getItem = createCache()
console.log(getItem(0))
getItem = null
例二:没有逃逸的闭包,执行后可以回收
function run() {
const message = 'hello'
function print() {
console.log(message)
}
print()
}
run()
print 没有被返回,也没有注册到其他长期存活的对象中。
run() 执行结束后,如果没有其他引用:
run的执行上下文出栈;print不再可达;message所在的环境可以被垃圾回收。
所以,出现嵌套函数不等于一定会长期占用内存。
例三:每次调用都会形成独立环境
function createCounter() {
let count = 0
return () => ++count
}
const counterA = createCounter()
const counterB = createCounter()
console.log(counterA()) // 1
console.log(counterA()) // 2
console.log(counterB()) // 1
console.log(counterB()) // 2
两次调用 createCounter(),会产生两套独立的词法环境:
counterA → countA
counterB → countB
因此两个计数器互不影响。多次调用外部函数并保存返回函数时,每次调用对应的词法环境都可能被保留【W4】。
例四:多个闭包共享同一个环境
function createCounter() {
let count = 0
return {
increment() {
count++
},
decrement() {
count--
},
getValue() {
return count
}
}
}
const counter = createCounter()
counter.increment()
counter.increment()
counter.decrement()
console.log(counter.getValue()) // 1
increment、decrement 和 getValue 都是在同一次 createCounter() 调用中创建的,因此共享同一个 count。
这也是闭包实现私有状态的一种常见方式。
例五:事件监听器导致闭包长期存活
function mount() {
const largeData = new Array(1000000).fill('data')
function handleClick() {
console.log(largeData.length)
}
window.addEventListener('click', handleClick)
return function unmount() {
window.removeEventListener('click', handleClick)
}
}
const unmount = mount()
引用关系是:
window
↓
handleClick
↓
largeData
即使 mount() 已经结束,window 仍然引用着 handleClick,因此 largeData 不能释放。
清理监听器:
unmount()
移除监听后,如果没有其他引用,handleClick 和 largeData 才可以被回收。
React 中的副作用清理,本质上也经常是在解决这类生命周期问题:
useEffect(() => {
const handleResize = () => {
console.log(window.innerWidth)
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
例六:定时器保留闭包
function startTimer() {
const data = new Array(1000000).fill('data')
const timerId = setInterval(() => {
console.log(data.length)
}, 1000)
return () => {
clearInterval(timerId)
}
}
const stopTimer = startTimer()
只要定时器还在运行,回调就仍然可达,回调引用的 data 也会继续存在。
stopTimer()
取消定时器后,如果没有其他引用,相关内存才有机会被回收。
6. 闭包和异步回调的关系
异步回调经常会形成闭包,但闭包不等于异步。
function request() {
const message = 'request finished'
setTimeout(() => {
console.log(message)
}, 1000)
}
request()
request() 很快执行结束并出栈,但定时器系统仍然持有回调函数。回调函数引用了 message,所以 message 在回调执行前不能被回收。
当定时器到期,回调会作为任务进入队列,等调用栈空闲后再执行。异步回调可以被安排为任务,也可以进入微任务队列;微任务会在当前任务末尾执行【7】。
7. var 循环中的经典闭包问题
const callbacks = []
for (var i = 0; i < 3; i++) {
callbacks.push(function () {
console.log(i)
})
}
callbacks[0]() // 3
callbacks[1]() // 3
callbacks[2]() // 3
三个函数共享同一个 i。等函数真正执行时,循环已经结束,i 的值是 3。
使用 let:
const callbacks = []
for (let i = 0; i < 3; i++) {
callbacks.push(function () {
console.log(i)
})
}
callbacks[0]() // 0
callbacks[1]() // 1
callbacks[2]() // 2
let 在 for 循环中会为每次迭代创建对应的词法绑定,因此三个闭包访问的是不同的 i。
8. 闭包一定会造成内存泄漏吗
不会。
闭包只是保留词法环境的一种语言机制。只有当程序已经不需要某些数据,但仍存在意外引用导致数据长期可达时,才属于内存泄漏。
常见风险包括:
- 未移除的事件监听器;
- 未取消的定时器;
- 长期存活的全局回调;
- 无上限增长的缓存;
- 已卸载组件仍被回调引用;
- DOM 节点被闭包或其他对象继续引用。
因此判断闭包是否有问题,不应该问:
它有没有占内存?
而应该问:
这段数据已经不用了,为什么它仍然可达?
最后可以记住这三句话:
- 外层函数执行结束,执行上下文正常出栈。
- 被仍然可达的闭包引用的词法环境会继续存在。
- 闭包不可达后,相关环境才可以被垃圾回收,但回收时间由引擎决定。