Description
闭包的定义
引用《你不知道的 JavaScript》中的定义:
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前作用域外执行的。
上面提到了词法作用域,那什么是词法作用域呢?
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
理解闭包,首先要理解执行上下文和作用域链。先从一个例子看起:
function foo() {
let name = 'hello'
return function bar() {
debugger
console.log(name)
}
}
const baz = foo()
baz()
打开 Chrome 控制台,执行这段代码,然后点击 Source 面板,会看到:
左边圈出来的是调用栈,里面有 bar 执行上下文还有 anoymous 也就是全局执行上下文;
右边圈出来的极速作用域链,其中 Local 就是当前执行函数(baz)的作用域,Closure 就是 foo 函数的闭包了,Script 表示全局词法作用域里含有 baz,最后一个 Global 就是 window 啦。我们知道在全局代码里通过 var 定义的变量会有全局作用域也就是挂载到最后的 Global 里,而 ES6 的 const 和 let 具有块级作用域,所以通过 const 声明的 baz 变量是放在 Script 而非 Global 里。
这个例子很简单,foo 函数返回了一个 bar 函数, bar 里保持了对 foo 中 name 变量的引用。当执行 baz()
这句代码的时候,内层函数 bar 在全局作用域里执行,此时 foo 函数已经返回了,那为啥还能访问到 name 变量呢?
首先要看下这段代码是怎么执行的:首先会初始化全局上下文,里面的变量对象包含 this 还有 foo 标识符。当执行到 foo 函数后,就创建了 foo 函数的执行上下文,推入执行上下文栈顶,然后初始化 foo 函数的活动对象,this 还有作用域链。作用域链是由内部属性 [[scope]] 保存的,此时 foo 函数的作用域链如下:
[foo 的 AO,全局上下文的 VO]
foo 函数执行完后就出栈了,接着就执行 bar 函数,创建 bar 函数的执行上下文,推入执行上下文栈顶,然后初始化 bar 函数的活动对象,创建 bar 函数的作用域链,此时 bar 的作用域链如下:
[bar 的 AO,foo 的 AO,全局上下文的 VO]
接下来引擎遇到 console.log(name)
,发现要查找 name 变量,于是沿着作用域链,先从当前 bar 当前的执行上下文的活动对象里找,发现没有这个变量,于是往上去到 foo 函数的活动对象里去找,找到了 name 为「张三」。然后 bar 函数也执行完毕出栈,最后全局执行上下文出栈,整段代码就执行完毕了。
上面这个过程里,搜索变量 name 的过程就体现了闭包的特性:虽然此时 foo 函数已经出栈,执行上下文被垃圾回收了,但是它对应的活动对象并不会被销毁,因为内部函数 bar 还保留着对它的引用,所以 JS 引擎依然将 foo 函数的活动对象保存在内存堆中,这就是 foo 函数的闭包。
这里还要提一个词法作用域的概念:词法作用域就是函数的作用域是由它定义的位置决定的,而不是调用时决定。根据词法作用域的规则,内部函数总是可以访问外部函数作用域里的变量,所以即使 bar 函数里没有声明 name,这段代码也不会报错,引擎执行的时候会沿着作用域往上一层去查找。
闭包的应用
基于 JS 闭包的特性,可以说很多地方都不知不觉应用了闭包:定时器,事件监听回调,Ajax 请求等等。在使用闭包的时候要注意 内存泄露
的问题,因为稍不小心,一旦被当做闭包,就会一直保留在内存中,垃圾回收不会处理掉它,这对内存是一种负担。所以在使用全局变量的时候,记得及时解除变量引用,以便让垃圾回收器回收掉它。
一道经典的闭包应用题:
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i)
}, 1000)
}
执行后发现输出都为 5。这是因为for 循环同步执行完后,此时全局上下文的变量对象里, i 的值是 5。而 setTimeout 匿名函数上下文里引用了 i 变量,根据作用域的查找规则,匿名函数的活动对象里并没有 i ,所以会去到全局上下文的变量对象里去找,于是找到了 i 的值为 5。
解决方法有几种:
- 将 var 改为 let,这主要借助 ES6 let 的块级作用域特性,有了 let 后,i 就不是全局变量了,而是每次循环都会创建一个作用域块
- 使用立即执行函数包裹,将 for 循环里的 i 作为参数传入。这就相当于在全局上下文和 setTimeout 匿名函数上下文里加上了一层立即执行函数作用域,里面可以找到 i
彩蛋
通过下面这个代码片段,理解 JS 中的词法作用域:
function bar() {
console.log(myName)
}
function foo() {
var myName = "hello world"
bar()
}
var myName = "hello JavaScript"
foo()
执行 foo,最终打印的是什么?