- 作者:老汪软件技巧
- 发表时间:2024-11-18 07:04
- 浏览量:
我们都知道,用var声明的量会存在声明提升,它在执行上下文中会存入变量环境。但我们没有提过用let和const声明的变量在编译时进行什么操作。例如下面这段代码:
function varTest() {
var x = 1;
if (true) {
let x = 2;
console.log(x);
}
console.log(x);
}
varTest()
请分析一下这段代码的输出结果。
让我们画图一步步分析。我们就只画varTest执行上下文了。调用varTest这个函数,所以生成一个varTest执行上下文,然后开始编译,找有效标识符。首先我们用var声明了一个变量x,于是它存入变量环境中,x = undefined,然后用let也声明了一个变量x。那么用let声明的量该怎么处理呢?
此时,它会存入右边的词法环境中。我们说过,let加上{}会形成一个块级作用域,而词法环境会维护一个新的栈,每一个块级作用域,会形成一个块级上下文并且入栈。如图所示:
这就是用let或const声明的量在编译时执行的操作。至此,编译结束,开始执行。先给第一个x赋值为1,然后给块级作用域中的x赋值为了2。然后执行一条输出语句。请注意,第一条输出语句是写在这个块级上下文中的,所以它理应先在自己的块级作用域中去找。所以第一条输出结果为2。第二条输出语句不在块级上下文中,所以它就去变量环境找,所以第二条输出结果就为1。
理解了这一点,我们再用一道稍微复杂一点的题目来加深一下认识。请看下面这段代码:
function foo() {
var a = 1
let b = 2
{
let b = 3
var c = 4
let d = 5
console.log(a);
console.log(b);
}
console.log(b);
console.log(c);
console.log(d);
}
foo()
看到这样一段代码不要害怕,我们画图一步步来分析。首先,调用了foo这个函数,于是生成一个foo执行上下文,然后用var声明了一个a,它存入变量环境中;然后用let声明了一个b,它会形成一个块级上下文,存入词法环境中并入栈;然后在一个大括号中用let声明了一个b,于是它生成另外一个块级上下文入栈;然后用var声明了一个c,它存入变量环境中;然后用let声明了一个d,因为它和b在同一对大括号里,所以它们在同一个块级上下文里。编译过程如下图所示:
至此,编译结束,开始执行。先是一串赋值语句,于是它变成下面这样:
于是它开始执行第一条输出语句,它要输出a。请注意,它要找a,它一定是先从词法环境中去找,并且从词法环境中维护的栈顶开始往下找。如果没找到就去变量环境中找。
在此法环境中没找到a,于是去变量环境中找,找到a为1,所以第一条输出语句为1。
然后第二条输出语句要输出b,于是先从此法环境中的栈顶找,找到b为3,所以第二条输出语句为3。
此时,请注意,词法环境中栈顶的这个块级上下文已经执行完毕,我们说过,在调用栈里,当一个函数执行上下文执行完毕时,它是会被销毁的。所以,在这里同理,在第一个块级上下文里,只有上面两条输出语句,而此时它已经执行完毕了,所以它应该出栈。所以foo执行上下文应该变成下面这样:
于是开始执行第三条输出语句,输出b。先去词法环境中找,找到了b为2,所以输出2。
第四条语句要输出c,同理,先去词法环境中找,没找到就去变量环境中找,找到了c为4,所以输出4。
第五条语句要输出d,此时,在词法环境和变量环境中都未找到d,所以应该报错。
输出结果如下图所示:
2. 作用域链
在上一小节,我们介绍了可以解决声明提升的let和const是怎么去编译的,在理解了这个的基础上,我们现在可以来聊聊什么是作用域链了。我们当然已经知道什么是作用域了,而作用域链其实就是各种作用域之间的嵌套关系。让我们先通过一个例子开始,请看下面这段代码:
function bar() {
console.log(myname);
}
function foo() {
var myname = '管总'
bar()
console.log(myname);
}
var myname = '阿伟'
foo()
我们同样通过画图来分析一下,首先有一个全局执行上下文,在执行前先进行编译,先找变量声明,先var声明了一个变量myname,于是它存入变量环境,再找函数声明,定义了两个函数bar和foo。然后开始执行,给myname赋值为‘阿伟’,然后调用了foo函数,于是又生成一个foo执行上下文入栈,对foo中的代码进行编译,编译结束后开始执行,又调用了bar这个函数,碰到了函数调用,首先停止手上的工作,先去编译这个函数,而这个函数里只有一条输出语句。所以,执行上下文如图:
开始执行代码,首先在bar中有一条输出语句,要输出myname。这时,问题来了,我们说过,当在自己的上下文中没找到变量值时,它应该会去外层上下文中找,那么这条输出语句它是去foo执行上下文中找还是去全局执行上下文中找呢?
这时我们又要引入一个新的概念了,叫词法作用域,也叫词法环境(为了避免与执行上下文中的词法环境搞混,本文统一称为词法作用域)。词法作用域其实就是指的这个函数定义在了哪个域中,这个域就叫该函数的词法作用域。例如,上面那段代码中的函数bar和foo,它们是不是定义在全局作用域中的,那全局作用域就是它们的词法作用域。而在每个执行上下文中,其实存在着这么一个指向outer,它就指向的是这个执行上下文所在的词法作用域,当在自己的执行上下文中没找到变量值时它就会去所在的词法作用域中找。如图所示:
因为bar和foo的词法作用域都是全局作用域,所以它们的outer理应指向全局上下文。所以在bar中,输出语句没有找到变量值时,它就会去它所指向的全局执行上下文中找,找到myname = ‘阿伟’,所以第一条输出语句输出值为‘阿伟’。执行完毕后,bar执行上下文出栈,在foo执行上下文中,也要输出myname,它会先在自己的上下文中找,找到了myname = ‘管总’,于是第二条输出语句输出‘管总’。输出结果如图所示:
其实这个指向的概念就是作用域链。让我们再通过一个例子来加深一下对作用域链的理解。请看下面这段代码:
function main() {
let count = 2;
function bar() {
let count = 3
function foo() {
let count = 4
}
foo()
}
bar()
}
main()
这个代码没有输出语句,我们只来分析一下各个函数的指向,把它的执行上下文画出来。
main函数是定义在全局作用域中的,所以它的词法作用域就是全局作用域,所以它的outer应该指向全局执行上下文,没错吧?再来看函数bar,注意看,他是不是定义在函数main里面的,所以它的词法作用域应该是main的作用域才对,所以它的outer应该指向main。再看函数foo,它是写在函数bar中的,所以它的词法作用域就是bar的作用域,所以他的outer应该指向bar。理解了这些,图应该就信手拈来了。
简单来说,作用域链其实指的就是这样的一种指向关系。
所以可以总结,对于作用域链,我们学习了一个新的概念:词法作用域,指的就是函数定义在了哪个域中,这个域就叫该函数的词法作用域。而v8在查找变量的过程中,顺着执行上下文中的 outer 指向查清一整根链,这种链状关系就叫作用域链。
3. 闭包
再学习完了作用域链之后,我们终于可以来好好聊聊今天的重头戏了————闭包。这个闭包到底是个什么东西呢?让我们来一步步拆解这个概念。
3.1 为什么会有闭包以及什么是闭包
我们先来通过一段代码感受一下闭包是什么。请看下面这段代码:
function foo() {
function bar() {
var a = 1
console.log(b);
}
var b = 2
return bar
}
const baz = foo()
baz()
请问输出结果是什么?
我们通过画图来一步步分析。首先有一个全局执行上下文,我们用const声明了一个变量baz,于是它存入词法环境中,然后声明了一个函数foo,它存入变量环境中。然后开始执行,先给baz赋值为了foo函数的返回值,于是此时先去编译函数foo的代码,会生成一个foo执行上下文入栈。在foo执行上下文中,我们用var声明了一个变量a,它存入变量环境中,还声明了一个函数bar。
然后开始执行foo中的代码,先给b赋值为2,然后返回了bar函数。此时,因为函数foo执行完毕。所以,它应该被销毁。然后跳到全局执行上下文中继续执行未执行的代码,给baz赋值为了一个函数,然后调用baz,于是baz执行上下文入栈,对函数baz也就是函数bar中的代码进行编译。先var声明了一个变量a,存入变量环境中,编译结束。
然后开始执行baz中的代码,先给a赋值为了1,然后要输出b。在baz的执行上下文中没有找到,就会去它outer所指的词法作用域中找,也就是foo执行上下文。那么问题来了,我们说过,当一个函数执行完毕后,它的执行上下文一定会出栈,所以现在调用栈里应该没有foo执行上下文才对,那么输出结果应该报错才对。我们来看一下输出结果;
它竟然输出了2,这是为什么呢?我们的分析在哪里出问题了吗?其实没有,我们的分析是对的,是JS这门语言自己设定的规则冲突了,而为了解决这一冲突,就设计了一个新的概念——闭包。你执行上下文执行完毕确实会被销毁,但你不要全销毁,你留下一点东西供别的执行上下文访问。因为baz的执行上下文要访问b的值,所以你把b = 2留下,这就是闭包。
所以为什么会有闭包呢?是因为词法作用域的规则和函数调用完毕它的执行上下文一定会被销毁这一规则冲突,闭包就是为了解决这个冲突存在的。
在JS中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量。当通过调用一个外部函数返回的一个内部函数时,即使外部函数调用完了,但是内部函数引用了外部函数的变量,那么这些变量依然需要保存在内存中,我们把这些变量的集合称为闭包。
3.2 闭包的缺点
为什么要先提闭包的缺点呢?因为了解了闭包的缺点能帮我们更好的使用它嘛。
其实,闭包就一个缺点。我们知道,调用栈的大小是有限的,而你产生了闭包,就意味着这个东西没有被销毁,而是一直囤积在调用栈中。当我们反复写了通过调用一个外部函数返回的一个内部函数时,而它会一直产生闭包,就有爆栈的风险,这就是内存泄漏。
所以在写代码的时候,太简单的代码就不要写一个闭包了。
3.3 闭包的优点
我们重点来聊一下闭包的优点,我们在写代码时能用它去完成什么样的功能,怎样去使用它,才是我们学习它的关键。
3.3.1 变量私有化
我们来完成这样一个场景:实现一个累加器的效果。例如下面这段代码:
function add() {
let num = 0
console.log(++num);
}
add()
add()
add()
它的输出结果应该是三个1。我们怎样使它的输出结果为1、2、3呢?
有朋友一下就指出了,把num声明到全局不就行了。
let num = 0
function add() {
console.log(++num);
}
add()
add()
add()
这样输出结果确实是1、2、3,但这样写代码一点也不优雅。因为我们多定义了一个全局变量,这几行代码确实不会出现大问题。那在工作场景时就可能出问题了,当我们写了上万行代码时,就不能随意在全局去定义变量了。首先,第一点,你想不出那么多语义化的变量名;第二,引用变量时很容易出错。
所以有没有什么更优雅的写法呢?能把num这个变量只放到函数中使用。这时,闭包就派上用场了。我们再写一个函数把console.log(++num)语句包裹起来,然后返回这个函数拿到全局使用不就好了。
function add() {
let num = 0
return function foo() {
console.log(++num);
}
}
const res = add();
res()
res()
res()
我们调用了一个函数add返回了一个函数foo,所以此时res就变成了一个函数,add执行上下文指向完毕出栈。然后我们调用函数res,它要求打印输出++num,它就会去add执行上下文中找,所以此时add执行上下文会生成一个闭包,里面存放着num = 0,于是调用第一次输出1,num值更新为1;然后第二次调用又会去闭包中找,所以第二次输出2,num值更新为2;第三次同理。这样我们就完成了一个简单的累加器了,并且实现了变量num的私有化,让它只在函数中发挥作用。
这就是闭包的第一个优点:能实现变量的私有化。
3.3.2 可以封装模块
当我们为了一个功能写了好几个相似的函数时,我们可以用一个函数将它们包裹起来,实现模块化。
function foo() {
var num = 100
function add() {
num++
console.log(num);
}
function pop() {
num--
console.log(num);
}
return {
add,
pop
}
}
var res = foo()
res.add()
res.pop()
正是因为有了闭包,我们在调用foo返回的函数add和pop时,可以拿到全局来使用,也能访问到num值,因为它在闭包里面。
所以闭包的第二个优点;可以实现模块化。
4.一道经典的闭包面试题
在面试时,闭包可谓是必考题。所以我们来看一道经典的面试题,加深一下对闭包的认识。
function fn() {
var arr = []
for (var i = 0; i < 5; i++) {
arr.push(function () {
console.log(i)
})
}
return arr
}
var funcs = fn()
for (var j = 0; j < 5; j++) {
funcs[j]()
}
请问这段代码的输出结果是什么?
我们先来分析一下。我们定义了一个函数fn,里面有一个空数组arr,然后有一个for循环,每循环一次向数组arr中添加一个函数,作用是要输出i。然后我们返回了数组arr,此时arr中应该是有五个函数的并且都要输出i。我们用一个变量funs接受了函数fn的返回值,于是funs变成了一个数组。然后我们又用一个for循环去调用funs中的每一个函数。
输出结果是5个5。
为什么会是5个5呢?因为在函数fn中的for循环结束后i的值是不是就变为5了,所以当我们在全局去调用fn中返回的函数时,此时fn的闭包里存的i的值就是5。我们每循环一次i都是在向数组arr中添加一个函数,但我们此时并没有调用它,而是准备拿到全局去调用,所以i为1、2、3、4的值不会保留到闭包中。当我们准备去调用arr里面的函数时,函数fn早就已经执行完毕了,所以最终闭包中留下的值为i = 5。
那么再问第二个问题,如果我想让它就要输出0、1、2、3、4,那该怎么办呢?
我们想要输出0-4,是不是就是意味着当我们去调用数组中的函数时,他能访问到0-4啊。所以闭包里面的值就应该为0~4,那我们再写一个函数去把arr.push(function () {console.log(i)})这段代码包裹起来并且在每次循环时把它触发掉不就行了吗。
function fn() {
var arr = []
for (var i = 0; i < 5; i++) {
function foo(n) {
arr.push(function () {
console.log(n)
})
}
foo(i)
}
return arr
}
var funcs = fn()
for (var j = 0; j < 5; j++) {
funcs[j]()
}
我们写了一个函数foo将arr.push包裹起来,并在每次循环时都触发这个函数。函数foo是不是就相当于arr中函数的词法作用域,当第一次循环时foo执行完毕出栈它就会留下一个闭包去供arr中的第一个函数去访问,而此时闭包中的值为0;当第二次循环时foo执行完毕出栈它也会留下一个闭包去供arr中的第二个函数去访问,而此时闭包中的值为1;第三、第四、第五次同理。所以在未来我们去调用arr中的函数时,它们就回去各自所执行的闭包中去找值,所以输出结果为0-4。
调用栈大概长这样: