如何向一个对JavaScript闭包所包含的概念(例如函数、变量等)有一定了解,但不了解闭包本身的人解释这个概念?
我在维基百科上看到了Scheme示例,但不幸的是它并没有帮助。
闭包是由以下两部分组成的:
- 一个函数和
- 该函数的外部作用域(词法环境)的引用。
词法环境是每个执行上下文(堆栈帧)的一部分,是标识符(即局部变量名)和值之间的映射。
JavaScript中的每个函数都维护着对外部词法环境的引用。此引用用于在调用函数时配置创建的执行上下文。此引用使函数内部的代码能够“看到”在函数外部声明的变量,而不管函数何时以及在哪里被调用。
如果一个函数被另一个函数调用,然后又被另一个函数调用,则会创建一个指向外部词法环境的引用链。这个链被称为作用域链。
在以下代码中,inner
与在调用foo
时创建的执行上下文的词法环境形成一个闭包,覆盖了变量secret
:
function foo() {
const secret = Math.trunc(Math.random() * 100)
return function inner() {
console.log(`The secret number is ${secret}.`)
}
}
const f = foo() // `secret`无法直接从`foo`的外部访问
f() // 检索`secret`的唯一方法是调用`f`
换句话说:在JavaScript中,函数携带着对私有“状态盒子”的引用,只有它们(以及在同一词法环境中声明的其他所有函数)可以访问。这个状态盒子对于调用函数的用户是不可见的,提供了一个出色的数据隐藏和封装机制。
并且要记住:JavaScript中的函数可以像变量一样(第一类函数)被传递,这意味着这些功能和状态的配对可以在程序中被传递,类似于如何在C++中传递类的实例。
如果JavaScript没有闭包,那么更多的状态将不得不显式地在函数之间传递,使参数列表更长,代码更嘈杂。
因此,如果您希望函数始终能够访问私有状态,则可以使用闭包。
通常,我们确实希望将状态与函数关联起来。例如,在Java或C++中,当您向类添加私有实例变量和方法时,您就是在将状态与功能关联起来。
在C和大多数其他常见语言中,一旦函数返回,所有局部变量都不再可访问,因为堆栈帧被销毁。在JavaScript中,如果您在另一个函数中声明一个函数,则外部函数的局部变量可以在返回后仍然可访问。这样,在上面的代码中,secret
在从foo
返回后仍然可用于函数对象inner
。
闭包的用途
闭包在需要将与函数关联的私有状态时非常有用。这是一个非常常见的场景 - 请记住:JavaScript直到2015年才有类语法,并且仍然没有私有字段语法。闭包满足了这一需求。
私有实例变量
在以下代码中,函数toString
关闭了汽车的细节。
function Car(manufacturer, model, year, color) {
return {
toString() {
return `${manufacturer} ${model} (${year}, ${color})`
}
}
}
const car = new Car('Aston Martin', 'V8 Vantage', '2012', 'Quantum Silver')
console.log(car.toString())
函数式编程
在以下代码中,函数inner
关闭了fn
和args
。
function curry(fn) {
const args = []
return function inner(arg) {
if(args.length === fn.length) return fn(...args)
args.push(arg)
return inner
}
}
function add(a, b) {
return a + b
}
const curriedAdd = curry(add)
console.log(curriedAdd(2)(3)()) // 5
事件驱动编程
在下面的代码中,函数onClick
关闭了变量BACKGROUND_COLOR
。
const $ = document.querySelector.bind(document)
const BACKGROUND\_COLOR = 'rgba(200, 200, 242, 1)'
function onClick() {
$('body').style.background = BACKGROUND\_COLOR
}
$('button').addEventListener('click', onClick)
<button>设置背景颜色</button>
模块化
在以下示例中,所有实现细节都隐藏在立即执行的函数表达式中。函数tick
和toString
关闭了它们需要的私有状态和函数以完成工作。闭包使我们能够模块化和封装我们的代码。
let namespace = {};
(function foo(n) {
let numbers = []
function format(n) {
return Math.trunc(n)
}
function tick() {
numbers.push(Math.random() * 100)
}
function toString() {
return numbers.map(format)
}
n.counter = {
tick,
toString
}
}(namespace))
const counter = namespace.counter
counter.tick()
counter.tick()
console.log(counter.toString())
示例
示例1
这个示例表明局部变量在闭包中没有被复制:闭包保持对原始变量本身的引用。就好像栈帧在内存中即使在外部函数退出后仍然存在一样。
function foo() {
let x = 42
let inner = () => console.log(x)
x = x + 1
return inner
}
foo()() // 输出 43
示例2
在以下代码中,三个方法log
、increment
和update
都关闭了相同的词法环境。
每次调用createObject
时,都会创建一个新的执行上下文(栈帧),并创建一个全新的变量x
,以及一组全新的函数(log
等),这些函数关闭了新的变量。
function createObject() {
let x = 42;
return {
log() { console.log(x) },
increment() { x++ },
update(value) { x = value }
}
}
const o = createObject()
o.increment()
o.log() // 43
o.update(5)
o.log() // 5
const p = createObject()
p.log() // 42
示例3
如果你使用var
声明的变量,请确保你了解你正在关闭的变量。使用var
声明的变量是提升的。由于引入了let
和const
,这在现代JavaScript中不再是一个大问题。
在以下代码中,每次循环都会创建一个名为inner
的新函数,它关闭了i
。但由于var i
在循环外被提升,所有这些内部函数都关闭了相同的变量,这意味着最终打印的i
(3)的值,会打印三次。
function foo() {
var result = []
for (var i = 0; i < 3; i++) {
result.push(function inner() { console.log(i) } )
}
return result
}
const result = foo()
// 下面将会打印出 `3`,三次...
for (var i = 0; i < 3; i++) {
result[i]()
}
最后一点:
- 每当在JavaScript中声明一个函数时,都会创建一个闭包。
- 从另一个函数返回
function
是闭包的经典示例,因为外部函数内部的 state 隐式地可用于返回的 inner function,即使在外部函数执行完成后也是如此。 - 每当你在函数内部使用
eval()
时,都会使用闭包。你eval
的文本可以引用函数的局部变量,并且在非严格模式下,你甚至可以通过使用eval('var foo = …')
来创建新的局部变量。 - 当你在函数内部使用
new Function(…)
(Function构造函数)时,它不会关闭其词法环境:而是关闭全局上下文。新函数无法引用外部函数的局部变量。 - JavaScript中的闭包就像在函数声明点保留对作用域的引用(不是复制),这反过来又保留了对其外部作用域的引用,依此类推,一直到作用域链顶部的全局对象。
- 当声明一个函数时,会创建一个闭包;在调用函数时,使用此闭包来配置执行上下文。
- 每次调用函数时,都会创建一组新的局部变量。
链接
- Douglas Crockford模拟对象私有属性和私有方法的文章,使用了闭包。
- 如果你不小心,闭包可能会导致IE内存泄漏的精彩解释。查看这里。
- MDN关于JavaScript闭包的文档。
- 初学者JavaScript闭包指南。