萝三画室

深入理解JS-part3-作用域和闭包

本文是深入理解JS系列的part3,我们要讨论的是javscript中的一个十分重要的概念:闭包。闭包是这门语言中的特点和难点,借助闭包我们可以实现很多高级应用。闭包是和作用域紧紧结合起来的,因此在讨论闭包之前,我们简单的介绍一下javascript的作用域。

参考书籍:《你不知道的JavaScript》

作用域

首先,什么是作用域?简单来讲,一个作用域就是一个范围,在这个范围之内声明的变量就是局部变量。局部变量只在这个范围内存在,在范围之外无法访问。

1
2
3
4
function init(){
var a = 12;
}
a//ReferenceError

上面代码中init函数中声明的变量a,只存在于init函数的作用域中,在init函数外部访问变量a,会抛出ReferenceError错误(变量未声明)。

在ES6之前,javascript只有两种作用域

  • 全局作用域
    全局作用于是顶级作用域,在全局作用域中声明的对象,都是全局对象。
  • 函数作用域
    定义一个function(){}也就定义了一个作用域,作用域以{}包裹。

如果你使用过c++等其他语言,会认为for循环、if语句中声明的都是局部变量,这是因为这些语言本身是具有块级作用域的,被{}包裹的就是一个新的作用域。而在javascript中并非如此。

由于javascript只有全局作用域和函数作用域,即使在for循环和if语句中声明了新变量,这些变量的作用域也不限于循环体,而是函数体。实际上,js中for循环或if语句中声明的变量,与在它们外部声明的变量并无差异,同样也会出现变量提升现象。

1
2
3
4
5
6
function init(){
if(true){
var a = 12;
}
console.log(a); //12
}

ES6引入了第三种作用域

  • 块级作用域

ES6的let和const命令,为当前所处的环境增加了块级作用域。简单来说,let或const声明的变量,在包裹它的第一层{}之外无法被访问到。

1
2
3
4
5
6
7
8
9
10
11
12
function init(){
if (true){
if (true){
let a = 12;
const b = 13;
var c = 14;
}
a//ReferenceError
b//ReferenceError
c//14
}
}

作用域可嵌套

js中作用域可嵌套,嵌套的多个作用域形成了作用域链。

首先思考一个最最基本的事实:定义在全局作用域中的变量,可以在任何位置被访问到。

1
2
3
4
5
6
7
8
9
var a = 12;
function init(){
var b = 13;
console.log(a);
}
init();//12
console.log(b)//ReferenceError

上面的栗子中,变量a和函数init是定义在全局作用域中的全局对象,变量b是定义在init函数作用域中的局部变量。同时,init函数作用域嵌套在全局作用域中。

执行init函数后,控制台输出12,说明在init函数作用域内可以访问它上一级的全局作用域;而在全局作用域执行console.log(b)发现抛出ReferenceError,这说明全局作用域不可以访问到它下一级的init函数作用域。

这个最简单的栗子体现出作用域可嵌套,内层作用域可访问外层,外层作用域不可以访问内层

接下来我们看进阶一点的栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
var a = 12;
function init(){
var b = 13;
function add(){
var a = 7;
console.log(a+b);
}
add();
}
init();//20

执行init函数后,控制台输出20。
上面的栗子中,全局作用域中定义了变量a和函数init,函数init的作用域中定义了变量b和函数add,函数add中定义了另一个变量a。作用域的嵌套关系:add->init->全局作用域。

由输出结果我们发现两个事实:

  • add函数内可以访问到其外层作用域init中的变量b
  • 最终console.log(a+b)中的a,引用的是定义在add函数作用域中的变量a,并非全局变量a

第一个事实我们基于前面的论述可以理解。第二个事实告诉我们,在引用变量时,js采取就近原则,从当前作用域开始,逐步向外层作用域查找变量,到全局作用域为止。

因此,作用域的嵌套关系就形成了一个单向的作用域链。本栗中在add函数作用域内就找到了变量a,因此就取得了这个a,不再继续沿着作用域链查找。

我们可以将js作用域的这个特点简单描述为:如果作用域 a 定义在作用域 b 中,则在作用域 a 中可以访问作用域 b的对象

理解了这个特点之后我们几乎就明白闭包是什么回事了–因为这正是闭包产生的原因。

闭包

什么是闭包

很多文章会将上一节最后一个栗子当做闭包,但其实并不是真正的闭包,或者说没有确切使用闭包。
下面我们看看真正的闭包是什么样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
function init(){
var b =13;
function add(){
var a = 7;
console.log(a+b)
}
return add;
}
var closure = init();
closure();//20

本栗和前栗最终都是在控制台输出了20。注意到,前栗在init函数中定义add函数,并且在init函数的作用域内执行了add。而本栗并没有在init函数中执行add函数,而是将add函数做为init函数的返回值。在执行var closure = init();后,closure被赋值为add函数。然后执行closure();实际就是在执行add();。

看到这里我们是不是发现了神马奇怪的现象?没错,add函数竟然被全局作用域访问到!这完全违背了作用域链的原则。
除此之外还有更神奇的一点:在执行var closure = init();后,通常情况下它的整个作用域内部(局部变量)都会被销毁,然后再由引擎中的垃圾回收期自动回收并释放init曾使用的内存空间。然而我们在执行完这局之后,继续执行closure();,发现他依然可以访问到init函数作用域中的局部变量b。

以上两个神奇的正是闭包的特点。那么我们来对闭包下个定义:如果将一个函数被传递到其定义时的作用域之外运行,则它会持有对这个作用域的引用。这个函数和定义时所在的作用域就构成了闭包。

基于此,我们就知道为啥我说前栗不算闭包了,因为前栗并未将add传递出去,add依然在定义时所在的作用域内执行。

应用场景

  • 保存状态/缓存
    闭包更抽象点讲,就是带了状态的函数。他将定义时所在的作用域保存在内存中,作用域中的变量就得以保留。
  • 封装私有变量
    如上栗中的局部变量b,在外部我们无法通过init函数直接访问,但是可以通过闭包来访问。

注意事项

  • 由于闭包不会释放作用域,大量使用闭包会导致页面性能下降,并可能导致内存泄漏。因此请适度使用闭包并及时删除无用的局部变量
  • 闭包可以在定义时所在的作用域外改变局部变量的值。在应用场景2下,不要轻易改变局部变量的值。

经典的for循环题

很多人可能看过这道题或是实际遇到过这个问题:

1
2
3
4
5
6
7
function init(){
for(var i = 1; i<6;i++){
setTimeout(function output(){
console.log(i);
}, i*1000)
}
}

  • 请问init函数的执行结果是什么?
    答案是:控制台每秒一次输出6。
  • 如何实现控制台每秒一次输出1-5?
    答案是:看下面讨论O(∩_∩)O

这道题涵盖的知识点包括异步、作用域和闭包。异步复习这里

原函数的执行过程

首先分析下原函数的执行过程。

  • 声明init函数的局部变量i并赋值为1.
  • 开始for循环
  • i<6,true
  • 待同步代码执行完毕后1秒将output函数推入异步队列。
  • i++,i=2
  • i<6,true
  • 待同步代码执行完毕后2秒将output函数推入异步队列。
  • i++,i=3
  • ……
  • i++,i=6
  • i<6,false,结束for循环
  • 结束执行init但未释放
  • 1秒后向异步队列推入output
  • 执行output(i=6)
  • … …

发现输出6的原因了吗?

  • init函数中只有一个变量i,每次循环后i自加1
  • 根据异步原理,for循环全部执行完后才会执行output
  • output执行时,变量i已经变为6了

解题方法

为循环中的每个异步都创建一个闭包,用来保存变量i的状态

1
2
3
4
5
6
7
8
9
function init(){
for(var i = 1; i<6;i++){
(function(j){
setTimeout(function output(){
console.log(j);
}, j*1000)
})(i)
}
}

我们在for循环内定义了立即执行函数,它具有一个参数j用于保存i,每次循环都是一个新的立即执行函数。以一次循环为例,进入循环体后首先执行立即执行函数,并将当前i值赋值给立即执行函数的参数j。等到这个output函数执行时,保留对j的引用。这样就将i的状态保存到j中了。

所以这道题的解题思路是,为异步函数外部增加一个作用域,作用域中的局部变量保存当前i的值。在异步函数运行时,可以访问这个值。

用ES6还可以这么写

1
2
3
4
5
6
7
function init(){
for(let i = 1; i<6;i++){
setTimeout(function output(){
console.log(i);
}, i*1000)
}
}

本质是let相当于创建一个块级作用域,并且let用于for循环内计数时,每次迭代都会重新声明,重新声明后的变量的初始值是上一次迭代结束时的值。可以理解为,let用于for循环,每次的i都不是一个i,这样异步函数执行时访问的就是各自对应的i了。

——本节完——