JavaScript 闭包的工作原理
你在一个函数内部编写了另一个函数,而内部函数竟然仍然可以访问外部函数的变量——即使外部函数已经执行完毕。这种行为让许多开发者感到困惑,但它遵循一个简单的规则:闭包捕获的是绑定,而不是值。
本文将解释 JavaScript 闭包的工作原理、词法作用域的真正含义,以及如何避免因误解这些机制而产生的常见错误。
核心要点
- 闭包是函数与其词法环境的组合——即函数创建时存在的变量绑定。
- 闭包捕获的是绑定(引用),而不是值,因此对闭包变量的修改仍然可见。
- 在循环中使用
let或const来为每次迭代创建新的绑定,避免经典的循环问题。 - 内存问题源于保留不必要的引用,而不是闭包本身。
什么是闭包?
闭包是函数与其词法环境的组合——即函数创建时存在的变量绑定集合。JavaScript 中的每个函数在创建时都会形成一个闭包。
function createGreeter(greeting) {
return function(name) {
return `${greeting}, ${name}`
}
}
const sayHello = createGreeter('Hello')
sayHello('Alice') // "Hello, Alice"
当 createGreeter 返回时,其执行上下文结束。然而返回的函数仍然可以访问 greeting。内部函数并没有复制字符串 “Hello”——它保留了对绑定本身的引用。
JavaScript 词法作用域详解
词法作用域意味着变量访问是由函数在源代码中的定义位置决定的,而不是由它们的执行位置决定。JavaScript 引擎通过从最内层作用域向外遍历作用域链来解析变量名。
const multiplier = 2
function outer() {
const multiplier = 10
function inner(value) {
return value * multiplier
}
return inner
}
const calculate = outer()
calculate(5) // 50, 不是 10
inner 函数使用的是其词法环境中的 multiplier——即它被定义时所在的作用域——而不管是否存在同名的全局变量。
闭包捕获绑定,而非快照
一个常见的误解是闭包会”冻结”变量值。事实并非如此。闭包持有的是对绑定的引用,因此修改仍然可见:
function createCounter() {
let count = 0
return {
increment() { count++ },
getValue() { return count }
}
}
const counter = createCounter()
counter.increment()
counter.increment()
counter.getValue() // 2
两个方法共享同一个 count 绑定。increment 所做的修改对 getValue 可见,因为它们引用的是同一个变量。
经典循环问题:var 与 let
这种区别在循环中最为重要。使用 var 时,所有迭代共享一个绑定:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// 输出: 3, 3, 3
每个回调都闭包了同一个 i,当回调执行时,i 的值为 3。
使用 let 时,每次迭代都会创建一个新的绑定:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100)
}
// 输出: 0, 1, 2
块级作用域为每个闭包提供了自己的 i。
Discover how at OpenReplay.com.
实用模式:工厂函数和事件处理器
闭包使工厂函数能够产生特定的行为:
function createValidator(minLength) {
return function(input) {
return input.length >= minLength
}
}
const validatePassword = createValidator(8)
validatePassword('secret') // false
validatePassword('longenough') // true
事件处理器自然地使用闭包来保留上下文:
function setupButton(buttonId, message) {
document.getElementById(buttonId).addEventListener('click', () => {
console.log(message)
})
}
回调在 setupButton 返回后很久仍然保留对 message 的访问。
内存考虑:闭包实际保留什么
闭包本身不会导致内存泄漏。问题出现在函数无意中保留了对大型对象或长期存在的数据结构的引用时。
function processData(largeDataset) {
const summary = computeSummary(largeDataset)
return function() {
return summary // 只保留 summary,而不是 largeDataset
}
}
如果你的闭包只需要一小部分数据,在创建内部函数之前先提取它。原始的大型对象就可以被垃圾回收。
现代 JavaScript 引擎会积极优化闭包。它们是正常的语言特性,在典型使用中不会造成性能问题。问题不在于闭包本身——而在于保留了你不需要的引用。
建立可靠的心智模型
这样理解闭包:当一个函数被创建时,它会捕获对其周围作用域的引用。该作用域包含绑定——名称和值之间的连接——而不是值本身。函数可以在其整个生命周期中读取和修改这些绑定。
这个模型解释了为什么修改是可见的、为什么 let 能解决循环问题,以及为什么闭包可以跨越异步边界工作。函数和它的词法环境是一起传递的。
结论
闭包是函数与其词法环境的捆绑。它们捕获的是绑定而不是值,因此对闭包变量的修改仍然可见。在循环中使用 let 或 const 来为每次迭代创建新的绑定。内存问题源于保留不必要的引用,而不是闭包本身。
理解 JavaScript 作用域和闭包为你推理变量生命周期、数据封装和回调行为提供了基础——这些是你在前端开发中每天都会遇到的模式。
常见问题
JavaScript 中的每个函数从技术上讲都是闭包,因为它在创建时会捕获其词法环境。闭包这个术语通常指在外部作用域执行完毕后仍能访问该作用域变量的函数。只使用自己的局部变量或全局变量的函数不会以有意义的方式展现闭包行为。
闭包本身不会导致内存泄漏。问题发生在闭包无意中保留了对应该被垃圾回收的大型对象或数据结构的引用时。为避免这种情况,在创建内部函数之前只提取闭包需要的数据。现代 JavaScript 引擎会积极优化闭包,因此在典型使用中它们不会造成性能问题。
这发生在循环中使用 var 时,因为 var 是函数作用域而非块级作用域。所有迭代共享同一个绑定,因此回调执行时看到的是最终值。通过使用 let 来解决这个问题,let 会为每次迭代创建一个新的绑定。这样每个闭包就捕获了自己的循环变量副本。
可以。闭包捕获的是绑定(对变量的引用),而不是值的快照。如果被闭包的变量发生变化,闭包会看到更新后的值。这就是为什么共享同一个闭包的多个函数可以通过共享变量进行通信,就像计数器模式中 increment 和 getValue 方法共享同一个 count 绑定一样。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Check our GitHub repo and join the thousands of developers in our community.