这可能是你看过的最细的有关块级作用域的文章——用debug向你展示块级作用域

841 阅读9分钟

前言

我是标题党😥。但是还是希望各位大佬能够帮忙看看这篇文章还有无什么没有讲到或则有有误的地方,欢迎指出!

如果要深入学习JS,作用域是必须了解掌握的一个知识点。何为作用域,这里不做过多叙述。根据MDN简单的定义:当前的执行上下文(如果不了解什么是执行上下文,可以看看我写的另外一篇文章——从执行上下文到变量提升)。值和表达式在其中 "可见" 或可被访问到的上下文。通俗来讲,我们可以将作用域理解为一套控制着某一值或变量在当前上下文中是否可见或可访问的一套规则。今天就让我们一起了解下什么是块级作用域。

简析块级作用域

为什么要块级作用域

而在let出现之前,JS只有全局作用域和函数作用域。而只有这两个作用域,在某些情况下会对我们的代码编写过程中产生问题。这里举一个典型列子

var arr = []
for (var i = 0; i < 10; i++){
    arr[i] = function(){
        console.log(i);
    }
}
arr[2]()

这里,我们想要让每个数组保存一个函数,每个函数输出一个与下标相对应的值,比如这里我们应让其输出2。但实际结果却并不是这样。我们会得到以下结果:

PS D:\Code\LESSON_SS\js> node .\test.js
10

这是因为var声明了一个全局变量,而由于JS的运行机制,在函数内找不到i这个变量,便会向上级作用域查找。而当循环结束。i变成了10。不论函数数组中的哪个函数去输出i的时候,都会输出10。

因此,我们需要一个块级作用域使得我们能够将每次循环隔离开来,使其能够按照我们的想法进行输出。

怎么创建块级作用域

在ES6中,新添了let和const指令来创建一个块级作用域。如下,我们便创建了一个块级作用域

{
    let i;
    i = 10;
}
console.log(i)   //报错!ReferenceError: i is not defined,因为在全局作用域中找不到处于块级作用域的i

为了更加直观的看到我们的块级作用域,我们隆重的请上程序员的最佳工具人——debug调试工具。先在代码的最前端使用debugger设置一个断点,然后启动代码调试工具(这里我使用的是vscode+nodejs的调试环境,当然你也可以直接使用浏览器调试)。如下:

debugger
{
    let i 
    i = 10
}
console.log(i);

开始调试后,我们得到如下结果

image.png

这里我们可以看到当前Local作用域是没有i变量的声明的,接着我们向下执行,点击下一步:

image.png

这里,我们清楚的看到,代码进入到花括号内,新的作用域Block出现了,同时,我们在新作用域中,也能够看到变量i被声明了,且设置初始值为undefined。我们接着向下执行,会发现在执行完i = 10这一步后,Block作用域不见了。

这是因为,i在进行完赋值操作,块级作用域由于后续无其他代码执行,直接结束了,代码也对应的跳出了块级作用域,因此我们无法观察到块级作用域内的变量情况了。如果在i=10后续加入其他操作,我们便能观察到i的变化。这里我们将代码改成如下

{
    let i 
    i = 10
    i = 0
}
console.log(i);

执行结果如下:

image.png

通过调试工具我们可以看出,let确实在全局作用域中创建了一个块级作用域。同时从运行结果来看,全局作用域也无法访问到块级作用域的内容。

块级作用域的特点

无变量提升

块级作用域的提出,不单单只是为了弥补上述情况出现的问题,同时也解决了变量提升的问题。由于JS的运行机制,导致JS在执行过程中会产生一个叫做变量提升的行为,即所有变量的声明和函数声明都会被提升到当前作用域最前面。 使得我们编写代码过程中,可以先使用变量再对变量进行声明。

但是变量提升从理论上来看是不符合逻辑的,可以认为变量提升是JS运行机制的一个bug。而let和const很好的解决了该问题。

接下来,让我们通过调试,来看看变量提升是如何产生的以及let和const是如何弥补变量提升这个bug的。我们编写如下代码:

debugger
j = 10;
console.log(j);
var j;
{
    i = 10;
    console.log(i);
    let i;
}

使用编译器调试,得到如下结果

image.png

这里我们可以看到,我们并没有执行到var j这行代码,但是在当前作用域中便已经有了j这个变量,且值为undefined,这就是变量提升产生的效果,代码正式执行前,所有的变量声明都会被创建(这里如果不理解变量提升,也可以看看我之前写的那篇文章——从执行上下文到变量提升)。接着我们向下执行:

image.png

当执行到console.log(j)时,j的赋值操作已经完成,此时j的值变为10。由于变量提升的作用,这样的代码并没有产生错误。接着让我们进入块级作用域:

image.png 可以发现,这里并没有生成块级作用域,变量i的声明let i也并没有像var i一样被提前,接着往下执行,我们就能观察到,执行出错了:

ReferenceError: i is not defined
    at Object.<anonymous> (D:\Code\LESSON_SS\js\test.js:6:13)
    at Module._compile (internal/modules/cjs/loader.js:1063:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
    at Module.load (internal/modules/cjs/loader.js:928:32)
    at Function.Module._load (internal/modules/cjs/loader.js:769:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
    at internal/main/run_main_module.js:17:47

而这也说明了,let声明的变量,不会存在变量提升。

根据阮一峰老师在ES6的讲解中说到,在代码块内,使用let命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。也就是说,在i使用let声明之前,i都处于其暂时性死区,我们无法对改变了进行访问控制,这样就很好的弥补了变量提升这一问题。

不允许重复声明

在同一个作用域中,let声明的变量不允许被重复声明。如下代码

//产生报错:SyntaxError: Identifier 'a' has already been declared
{
    let a = 1;
    var a = 10;
}
//也会产生报错
{
    let b = 1;
    let b = 2;
}

循环中的块级作用域

经典测试用例

使用全局作用域

让我们回到文章开头的那个代码:

var arr = []
for (var i = 0; i < 10; i++){
    arr[i] = function(){
        console.log(i);
    }
}
arr[2]()

接着还是使用调试的方法,让我们看看到底发生了什么(别忘记下断点)。

image.png

最开始时,我们可以看到由于变量提升,这里的数组arr和变量i都被提升,在代码还未正式执行时,当前作用域中就能看到这两个变量,且初始值为undefined。接着往下执行,我们来到循环:

image.png

可以看到,当arr和i都被初始化之后,我们始终都是工作在一个作用域当中。这里判断出i<10(因为i为0),我们开始执行循环内容:

image.png

这里我们可以看到,在循环中,我们将函数保存到了数组当中,在保存的函数作用域链中,我们可以看到有一个闭包的作用域存在,保存了i的值。这似乎看起来还是比较正常的,毕竟此时i就是0。接着我们进入下一个循环,令i++。我们可以看到以下结果:

image.png

当i++之后,我们便可以看到其中的猫腻,arr[0]函数中要输出的i我们应该让其输出0的,但是在i++之后,函数内部的i也随之变成了1(这里称作函数内部的i或许不太合适,毕竟这里的i是由函数向外查找时得到的值)。接着我们继续执行(这里我多执行几次,这样效果更明显)。

image.png 这里我执行了5次,此时i为4,数组中也保存了5个函数,接下来,我们在观察下这五个函数里都有什么。

image.png

image.png

可以发现,不论是第几个函数,在作用域链当中,都保存了一个闭包的作用域,作用域当中保存着一个i,其值是跟随外部循环中的i一起变化的。这也是为什么最后输出的时候都是输出10的原因。

接下来,我们继续执行,直到循环执行完毕,开始调用数组中的函数:

image.png

我们可以清晰的看到,当开始执行函数时,在数组中的函数被压入调用堆栈,函数开始执行。这里我们可以看到,在作用域当中,出现了一个闭包的作用域,而那个作用域当中保存的内容就是i。而i的内容也因为循环累加变成了10,这样函数在执行时,在当前作用域(这里就是图中的Local:arr.<computed>)找不到变量i,于是便往上查询(这里的作用域显示方式是按照栈的方式显示的,当前作用域查询不到就会向下查)。于是找到闭包作用域Closure,在该作用域中找到i,于是输出。所以导致每个函数输出i时,都是输出保存在全局作用域和函数产生的闭包中的i。

使用块级作用域

这里我们只要将代码中的var声明改成let声明即可:

debugger
var arr = []
for (let i = 0; i < 10; i++){
    arr[i] = function(){
        console.log(i);
    }
}
arr[2]()

代码刚执行,我们看到如下情况:

image.png

这里我们可以看到一个与var声明i的很明显的区别。i并未在当前的作用域中找到声明。接着让我们继续下一步:

image.png

这里可以看到,当执行到循环语句时,因为let的声明,产生了一个块级作用域。而块级作用域中,便保存了i的值。 接下来,我们执行多次循环,来看看数组函数的变化

image.png

这里可以看到,每次执行都有一个块级作用域存在,同时,可以看到与var声明的不同的是,每个函数的作用域链中,不再是闭包的作用域,而是块级作用域。且每个块级作用域中的i值都不同。如下:

image.png

最后,还是,让我们继续调用执行数组函数,看看会发生什么?

image.png 形式上和上一份代码很像,但是内容不同,不再是闭包的作用域,而是块级的作用域,里面的i值也不会受全局变量i的影响了。代码如愿以偿的输出了2。

一段有意思的小代码

最后让我们再看一段有意思的小代码,深入了解下循环体中的块级作用域

debugger
for(let i = 0;i<5;i++){
    let i;
    i = 1;
    console.log(i);
}

观察下,这个代码的执行结果会是怎样?死循环?报错?

正确结果如下:

PS D:\Code\LESSON_SS> node "d:\Code\LESSON_SS\js\test.js"
1
1
1
1
1

JS向我们输出了5个1。这是为什么呢?接着,让我们继续使用debug工具,看看它到底发生了什么?

代码执行到循环中的圆括号语句时。我们看到了生成一个块级作用域。结果如下:

image.png

接着向下运行:

image.png

我们可以看到,当运行到循环体内部时,又创建了一个新的块级作用域,这也解释了执行结果为什么不会报错的原因,因为圆括号和花括号属于两个不同的作用域,虽然他们变量名相同,但是不是同一个作用域,因此并没有出现重复声明的情况。

我们继续执行:

image.png

i被如愿以偿的赋值为1,然后控制台输出,我们来到下一次循环:

image.png 发现在变量i声明前,i就已经存在,且被赋予了初始值1,这个1是圆括号中的i给的还是上次循环体执行传递的,我们好像无法很清楚的理清来路。于是,接着向下执行:

image.png 此时,圆括号内的块级作用域的i为2,但是此时花括号内的i依旧初始值为1。同时我们发现一个奇怪的现象,当代码继续执行,遇到let i声明,i又变成了undefined。如下:

image.png

这里也确实让我们迷惑了许久,但是经过查阅资料以及仔细查看阮一峰老师的ES6入门后发现,阮一峰老师提到:

JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i时,就在上一轮循环的基础上进行计算。

在网上查阅资料的时候也看到过,每轮循环中,循环的值会被传递给下轮循环中,这也是为什么每轮循环里,i的都能够在作用域中找到且有初始值。

同时,通过学习阮一峰老师的ES6入门一书,我们了解到,圆括号内有一个块级作用域,花括号内也有一个块级作用域。而两个作用域有成父子关系,当子作用域(即花括号包裹的作用域)中有些变量无法找到,就会去父作用域查找(即圆括号包裹部分)。

这也是最后为什么代码能够最后输出五个1而不是死循环也不是报错的原因了,因为(let i = 0; i < 5; i++)是一个作用域,而{code...}又是另外的作用域,两个作用域声明相同的变量名并不会产生影响。

小结

在学习块级作用域时,特别是循环里的块级作用域时,感觉对块级作用的认识并不是很清楚,通过debug工具,以及学习阮一峰老师的文档,使得块级作用域更加清晰明了。但上述内容都是本人在学习过程中,通过查询资料,询问老师得到的一些个人见解,很难保证文章内容100%正确,因此还有望各位老大哥们发现文章中的错误之后在评论区指出,谢谢!