再论闭包

前言

长久以来,我对于js中的闭包的准确定义有所疑惑,我一直在寻找用于准确定义闭包的那个宾语,即『闭包是什么』中的那个『什么』。不过最近看过《你不知道的 JavaScript (上卷)》这本书之后算是解答了我的疑惑,为什么我会被困惑这么久(我相信有不少人也感到疑惑),可能是由于闭包平时使用的场景(显式使用)以及对js的作用域相关机制的不了解造成的。

重新认识闭包

闭包的定义

首先给出我所理解的闭包的『精准』定义:

闭包就是函数在执行时对其定义时的词法作用域的引用。

也就是说由于闭包的存在,可以使得函数无论在何处执行都可以访问定义时的词法作用域内的变量(包括函数,毕竟函数也是一种声明)。

闭包的显式触发条件

为啥要说显式的触发(或者说是『激活』闭包),因为一般隐式的闭包看起来好像没啥用,也就很容易被忽略:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
let a = 123

function bar() { // -->实际上bar函数此时已经具备闭包
console.log(a)
}

bar() // --> 但是bar函数执行的地方却位于定义时的词法作用域内,闭包与当前词法作用域实际上是一致的
}

foo()

上面的例子由于bar函数在执行的时候位于foo函数之内,也就是其定义时的词法作用域之内,闭包的范围就刚好与当前词法作用域是一致的,所以此时闭包的作用可以忽略不计(此时的闭包究竟存在与否无法确定,我姑且称之为『隐式的闭包』)。

因此,要想发挥闭包的作用需要两个条件

  1. 该函数要在其定义时的词法作用域之外执行;
  2. 该函数作用域内使用了其定义时的词法作用域的其他变量(其他变量指的是自身作用域外的变量

闭包与js垃圾收集机制

js中最常用的垃圾收集方法就是标记清除mark-and-sweep):

垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。
——《JavaScript高级程序设计》

也就是说一个变量在离开其声明的作用域时且没有被引用时会被垃圾收集器标记,然后被回收清除内存无法被访问。然而,由于闭包引用了其定义时的词法作用域,所以即便在离开作用域后该词法作用域内的变量仍然属于被引用的状态,因此这些变量都没有被回收,也就能够通过闭包进行访问!

常见的闭包

实际上,我们在写代码的时候可能无意中使用了闭包,但我们并非有意的去使用,所以就忽略了一些常见的闭包。

回调函数

回调函数由于并非立即执行,所以很容易在定义时的词法作用域外进行执行:

1
2
3
4
5
6
7
function say(text) {
setTimeout(function foo() {
console.log(text)
}, 1000)
}

say('hello')

在回调函数中使用一些词法作用域内的局部变量时,闭包就出现了,尽管有时我们并没有意识到这一点。

for循环

1
2
3
4
5
6
7
8
9
10
11
12
13
// code 1
for(var i = 0; i < 5; i++) {
setTimeout(function foo() {
console.log(i)
}, 0)
}

// code 2
for(let i = 0; i < 5; i++) {
setTimeout(function foo() {
console.log(i)
}, 0)
}

这两段代码经常出现在前端的面试中,虽然看似代码仅有一处细微差距,但是实际上却产生了完全不同的结果,背后隐藏着一些容易被忽视的原因:

  1. var有变量提升,而let没有变量提升;而var的变量提升会『穿透』块级作用域,也就是会提升到for语句的外层作用域!
  2. 变量在for循环过程中不止被声明一次,每次迭代都会声明!也就是说每次迭代都会创建一个新的块级作用域,但是块级作用域对于var声明不起作用,但是let声明会重新绑定到每个块级作用域。

根据以上的原因可以得知第一段代码中所有setTimeout中的回调函数共用了一个全局变量i,而第二段代码中所有回调函数则分别调用了不同的局部变量i

模块机制

js中的模块一般都是通过暴露一个对象来传递模块中的方法或变量,这里的方法就可以使用随意的使用模块内的任何变量,而没有暴露出去的变量和方法就可以看成是『私有』的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
function Moudle1() {
let name = 'abc'

function sayHello() {
console.log(`Hello! My name is ${name}`)
}

function changeName(val) {
name = val
}

return {
sayHello,
changeName
}
}

let m1 = Moudle1()

m1.sayHello() // Hello! My name is abc
m1.changeName('ok')
m1.sayHello() // Hello! My name is ok

let m2 = Moudle1()
m2.sayHello() // Hello! My name is abc

类似这种原理暴露出来的模块方法实际上都利用了闭包,闭包引用了整个模块的词法作用域,因此可以访问模块内的所有变量。从上面例子看到即使改变了闭包内的变量值也不会影响到原词法作用域内的变量,大概是因为函数每次执行的时候都是重新创建作用域的?

一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
let a = 1

function bar() {
console.log(a, b)
}

bar()

let b = 2
}

foo()

参考文档

  1. 《你不知道的 JavaScript(上卷)》 —— 第5章:作用域闭包
  2. 《JavaScript 高级程序设计(第3版)》 —— 4.3:垃圾收集