JavaScript 是按顺序执行的吗?聊聊 JavaScript 中的变量提升
作为一位前端开发者,我们经常会听到这么一句话:“JavaScript 的执行是按照顺序自上而下依次执行的。”这句话说的并没有错。但是它似乎又好像不完全对。我们先来看以下这段代码。你觉得结果会输出什么?
若是按照之前说的自上而下依次执行的逻辑话,那么应该输出的结果应该是:
1、因为函数 showName 执行时,其并未定义,因此会报错
2、同样的,因为变量 myName 也并未定义,因此也是会报错
然而当我们在浏览器控制台执行的时候,其实际的结果却如下图所示。
代码竟然没有报错!第一行输出了“我的名字叫修谦”,第 2 行则输出了“undefined”,这时候你是否会有疑问:“这怎么和前面想象中的顺序执行有点不一样啊!怎么结果会是这样的呢?”
到这里,我想你应该想到了一点什么。那就是:“函数和变量是可以在定义之前使用的”。但是我们如果执行未定义的函数和变量的话,又会是一个什么样的结果呢?
我们尝试着将之前的第三行代码删掉,然后执行。
运行代码后,如下图所示,这一次我们看到的结果是函数已经执行了,但是 console 函数输出的已经报错了,输出了“myName is not defined”。
到这里,对于以上的两个结果,你是否又能得到了一些新的启示呢?事实上,通过上面的两次代码执行,我们至少可以得到以下几个结论:
1、JavaScript 在执行的过程中,如果使用了未定义的变量,则会报错
2、在一个变量定义之前使用,不会报错,只是其值是 undefined。
3、在一个函数定义前使用它,并不会报错,而是会正确执行
第一个结论我们很容易理解,因为变量未被定义,所以在使用的时候肯定是找不到,因此必然会报错。但是对于第二个和第三个结论,确实让人费解的:变量和函数为什么能在其定义之前使用?这似乎表明 JS 代码并不是按之前说的自上而下依次执行的。
另外一点,就是同样的方式,变量和函数的处理结果为什么不一样?如上面的执行结果,提前使用的 showName 函数能打印出来完整结果,但是提前使用的 myName 变量值却是 undefined,而不是我们定义时使用的“修谦”的这个值。要解释这个,就不得不说到 JavaScript 中的一个很重要的概念:变量提升
1、什么是 JavaScript 的变量提升(Hoisting)
在说 JavaScript 的变量提升之前,我们得要先说一下 JavaScript 中的声明和赋值操作,对于如下的这行代码
实际上,这句代码你可以把它分为两部分来看,即声明和赋值
以上的这个是 JavaScript 中变量的声明和赋值,我们再来看一下 JavaScript 中的函数声明和赋值操作是什么样的,我们还是看以下这段代码
我们可以看出第一个函数 showName 是一个完整的函数声明,它没有涉及到赋值操作;第二个函数是先声明变量 showName,再把 function(){console.log('我的名字叫修谦')}赋值给了 showName。到这里你应该知道了 JavaScript 中的变量声明和赋值是怎么回事了。
说完了 JavaScript 中的变量声明和赋值是怎么回事后,我们再来说 JavaScript 中的变量提升。
在 JavaScript 中,所谓的变量提升:是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的一种“行为”。当变量被提升后,会给变量设置默认值,而其所设置的默认值就是我们最为熟悉的 undefined。从这个概念的字面意义上来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面。
但其实这样说也并不准确。因为实际上,在 JavaScript 中,变量和函数的声明在代码里的位置是不会改变的。为什么呢?因为在 JavaScript 中,一段代码的执行是需要先经过 JavaScript 引擎先编译的,当代码编译完后,才会进入到代码的执行阶段(下图所示)。说变量和函数的声明在代码里的位置是不会改变的原因,是因为代码在编译阶段便已经被 JavaScript 引擎放入到了内存中(既然放到了内存当中,那么其位置当然就已经固定)。
那既然在编译阶段就在内存中固定了位置,为什么又会出现提升呢?编译阶段和变量提升存在什么关系呢?这里我们就不得不说到另外一个概念:执行上下文(Execution context)
2、执行上下文(Execution context)
所谓执行上下文,我们可以简单的理解为就是 JavaScript 执行一段代码时的运行环境,比如当我们在 JavaScript 文件中调用一个函数,那么就会进入这个函数的执行上下文,就会确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。并且在执行上下文中还存在一个变量环境的对象(Viriable Environment),这是非常重要的。因为该对象中保存了变量提升的内容,比如上面代码中的变量 myName 和函数 showName,都会保存在该对象中(我们先用下面的这段代码模拟一下,后面在详细讲解)。
在 JavaScript 中,执行上下文一般分为以下三种:
1、全局执行上下文:当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
2、函数执行上下文:当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,在一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
3、eval :当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文。
但是我们现在常接触或者说的一般都是指前面两者。了解完执行上下文的概念和分类后,我们再来了解一下另外的两个知识点:函数执行(调用)和栈
3、函数执行(调用)
函数调用概念很简单,简单一点来说就是运行一个函数,具体使用方式是使用函数名称跟着一对小括号。我们举个例子来说一下
这段代码很简单。首先我们创建了一个名叫 myName 的变量,接着又创建了一个 showName 的函数。完后紧接着在最后面调用执行了该方法。下面我们就以这段简单的代码来说一下函数调用的过程。
当执行到函数 showName()之前,JavaScript 引擎会为上面这段代码创建全局执行上下文,包含声明的函数和变量,如下图所示:
从图中可以看出,上面那段代码中全局变量和函数都保存在全局上下文的变量环境中。当执行上下文准备好之后,JavaScript 引擎便开始执行全局代码,当执行到 showName 函数时,JavaScript 判断出这是一个函数调用,于是便开始了以下操作:
1、首先,从全局执行上下文中,取出 showName 函数代码。
2、其次,对 showName 函数的这段代码进行编译,并创建该函数的执行上下文和可执行代码。
3、最后,执行代码,输出结果。
我们可以用一张相对完整的图来描述
当执行到 showName 函数的时候,我们就有了两个执行上下文了——全局执行上下文和 showName 函数本身的执行上下文(函数执行上下文)。这也就是说在执行 JavaScript 时,会存在多个执行上下文。那当有多个上下文的时候,JavaScript 引擎是如何管理的呢?这就是我们下面要说到的一种数据结构——栈
4、栈(Stack)
栈(Stack)是一种线性数据结构,遵循后进先出(Last In, First Out, LIFO)的原则。这意味着最后进入栈的元素会最先被移除。如下图所示,最先进入的是 A,但是最先出的却是 E。而 avaScript 引擎正是利用栈的这种结构来管理执行上下文的。在执行上下文创建好后,JavaScript 引擎会将执行上下文压入栈中,然后进行执行,而通常把这种用来管理执行上下文的栈称为执行上下文栈,或者叫 JavaScript 调用栈。
4、执行上下文栈(JavaScript 调用栈)
下面我们就来具体的用代码和图来模拟 JS 执行上下文栈是如何执行代码的,如下面一段代码(以 ES5 来演示)
第一步:创建全局上下,并将其压入栈底(如图所示)此时变量 a、函数 add 以及 addAll 都保存到了全局上下文的变量环境对象中。
当全局执行上下文压入到调用栈后,紧接着,JavaScript 引擎便开始执行全局代码了。首先会执行 a=2 的赋值操作,赋值完后,此前 a 的值就从 undefined 变成了 2。因为此时的 add 函数和 addAll 函数都还没有执行,因此状态还是之前的。这一步完成后,我们再来看全局上下文的状态,如下图所示:
第二步:执行 addAll 函数,此时 JavaScript 引擎会编译该函数,并为其创建一个执行上下文,然后将其执行上下文压入栈中,如图所示:
同样的,当 addAll 函数的执行上下文创建好之后,就会进入了函数代码的执行阶段了,因为函数中有一个变量 d,因此还是先执行赋值操作,即将 d 的值从之前的 d=undefined 设置成 d=10 。然后接着往下执行。
第三步:执行 add 函数,当执行到 add 函数调用语句时,JavaScript 引擎同样又会为其创建执行上下文,并将其压入调用栈,此时的调用栈的状态如下图所示:
然后 add 函数执行,将返回结果赋值给变量 result,此时的 result 的值便从之前的 undefined 变成了 6。随后该函数的执行上下文便从从栈顶弹出。此时的调用栈如下图所示:
紧接着 addAll 执行最后一个相加操作后并返回,完成之后,addAll 的执行上下文也会从栈顶部弹出,此时调用栈中就只剩下全局上下文了。最终如下图所示。 至此,整个 JavaScript 的执行便完成了。
通过以上的分析,我们可以知道,正是由于 JavaScript 存在变量提升这种特性,从而导致了我们在日常的学习或者工作中,总是能看到很多与直觉不符或者说与我们思习惯不一样的代码,而这也是 JavaScript 的一个重要设计缺陷。为此, ECMAScript6 引入块级作用域的概念并配合 let、const 关键字,来避开了这种设计缺陷(这个我们接下来就会说)。但是在说之前,我们还要继续说变量提升剩余的两个问题:为什么 JS 中会出现变量提升?变量提升有什么缺点?
5、JS 中变量提升的原因
我们都知道在 ES6 之前,JavaScript 是不支持块级作用域的。因为当初设计这门语言的时候,只是按照最简单的方式来设计的。即只设计了全局作用域和函数作用域。以此来简化 JavaScript 代码的解析和执行过程。可没有想到的是 JavaScript 后面会这么火,最后其没有块级作用域的缺陷便慢慢暴露了出来。
既然问题已经暴露出来了的话,那就解决问题。但是你不可能贸然的立马增加块级作用域吧!毕竟已经用 JavaScript 这门语言开发了那么多应用。于是就采取了一个不是特别激进的方法——把作用域内部的变量统一提升。这也是彼时最快速,也是最简单的方式。
当然了任何事物都有两面性。这一做法的一个很大的缺点就是直接导致了函数中的变量无论是在哪里声明的,在编译阶段都会被提取到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都是能被访问的,而这也就是我们通常说的 JS 中的变量提升。
6、JS 中变量提升的问题
1、变量在不知不觉中就被覆盖
我们先来看下面的一段代码,你认为会输出什么结果?是修谦?是吴门山人?
其实你把代码执行的话,会发现其输出的结果两者都不是。而是输出了 undefined。为什么会这样呢?你可以参照前面举的那个 JS 执行的例子来自己试着画一下过程图。这里我们就直接贴最后的执行栈图。
当 showName 函数的执行上下文创建后,JavaScript 引擎便开始执行其内部的代码。首先执行的是 console.log(myName)。而执行这段代码需要使用变量 myName,而从图上我们可以看到,这里有两个 myName 变量:一个是在全局执行上下文中,其值是“修谦”;另外一个则是在 showName 函数的执行上下文中,其值是 undefined。这个时候 JS 到底要使用哪一个输出呢?作为一个前端开发人员,我想绝大部分人都会说出正确的答案:“肯定是先使用 showName 函数执行上下文里面的变量啦!”
的确是这样,因为函数执行过程中,JavaScript 会优先从当前所在的执行上下文中查找变量,但是因为变量提升的原因,当前的执行上下文中就包含了变量 myName,而其值是 undefined,所以获取到的 myName 的值就是 undefined。而不是如其它语言一样,会输出“修谦”。
2. 本应销毁的变量没有被销毁
那既然在 JavaScript 中,变量提升会带来上面说到的那些个问题?最后的解决方案又是什么呢?答案就是在 2015 年的时候发布了新的 JS 标准——ECMAScript6(简称 ES6)。在 该标准中,正式引入了块级作用域的概念。并且还引入了 let 和 const 关键字来声明块级作用域,至此,JavaScript 也能像其他语言一样拥有了块级作用域。
7、ES6 中的 let 和 const
关于 let 和 const。我们还是先来看如下的代码
这段代码输出的结果,我觉得只要是写过 JavaScript 的人都应该知道结果是啥。第一个输出的是“山人”;而第二个则输出一个错误。从这里我们可以看出,虽然两者都是用来声明块级作用域的,但是两者之间还是有区别的,使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的。说到这里我们也顺带说一下面试中常被问到的一个问题:在 JavaScript 中,什么是暂时性死区?
还是先看代码
当我们把这段代码复制到到浏览器控制台的时候会报这样一个错误: “ReferenceError: Cannot access 'x' before initialization”。这个错误翻译过来是:引用错误:初始化之前无法访问“x”(翻译的可能不准,但是意思差不多)。从这个错误我们知道了在 ES6 中,当我们用 let 和 const 声明的变量在声明之前是处于一种“未初始化”状态,而这种状态被称为暂时性死区(官方的定义是:在 JavaScript 中,"暂时性死区"(Temporal Dead Zone, TDZ)是指在块级作用域(如 let 和 const 声明的变量所在的代码块)中,在变量声明之前访问该变量会导致引用错误(ReferenceError))。
说完 let 和 const,我们再来看以下的这两行简单的代码
这两行代码其实并没有什么特别的,我用其来就只是为了引出一个问题,即:JavaScript 是怎么样在支持变量提升特性的同时又支持块级作用域的呢?因为我们在项目中,有时候你会发现有的人在代码中即会用 var 关键字来声明变量,同时又用 let 和 const 来声明变量。虽然这种方式不推荐,但是总归是不可避免的。前面我们已经谈到了变量提升特性。所以接下来我们重点谈的就是 JavaScript 是如何支持块级作用域的。
8、JavaScript 是如何支持块级作用域的?
前面我们说到,在 JavaScript 引擎中是通过变量环境实现函数级作用域的,那么在 ES6 中,又是如何在其基础之上,实现对块级作用域的支持呢?我们还是先来看下面的一段代码
当执行上面这段代码的时候,JavaScript 引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码,之前我们的例子是没有使用 ES6 中的关键字 let。但是现在引入了 let 关键字,它会创建块级作用域,那么它是如何影响执行上下文的呢?这里我们就不得不提到一个名词——词法环境。你应该还记得之前的例子中,右边一直有一块空着的,名叫词法环境的块。而 JavaScript 之所以支持块级作用域,就是与它有关。
下面我们还是按照之前的方式来梳理一下这段代码的执行。
第一步:编译并创建全局执行上下文
第二步:执行 showName 函数,为其创建函数执行上下文
到 showName 函数执行这一步。我么可以从调用栈中看出:
1、函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了(这个和之前的一样)
2、通过 let 声明的变量,在编译阶段则会被存放到词法环境(Lexical Environment)中
3、在函数的作用域块内部,通过 let 声明的变量并没有被存放到词法环境中
第三步:继续往下执行代码。当执行到代码块里面时,变量环境中 myName 的值已经被设置成了"修谦",而词法环境中 myAag 的值则被设置成了 35,此时的函数的执行上下文如下图所示:
从第三步的图中我们可以看出,当进入函数内部的作用域块时,作用域块中通过 let 声明的变量(myAag 和 heAag),会被存放在词法环境的一个单独的区域中,且不影响作用域块外面的变量(之前的 myAag)。因此它们都是独立的存在。另外我们从中也可以看出,其实在词法环境内部,也是维护了一个小型栈结构,栈底是函数最外层的变量(即内部作用域块外边的变量,这里就是 myAag),当进入某一个作用域块后,就会把该作用域块内部的变量压到栈顶(myAag 和 heAag);当作用域执行完成之后,该作用域的信息就会从栈顶弹出,而这就是词法环境的结构(前提就是必须用 let 或者 const 关键字定义)。
第四步:继续往下执行代码。将作用块中的 myAag 和 heAag 分别赋值为 16,63,同时也将环境变量中的 heName 的值赋值为“华仔”。如图所示
第四步:继续往下执行代码。当执行到作用域块中的 console.log(myName)这行代码时,此时就需要在词法环境和变量环境中查找变量 myName 的值了,而具体查找方式是:沿着当前词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找(同样的,作用域块中的 console.log(myAag)也是这样的规则)。此时如下图所示:因为在词法环境中没有找到 myName 的这个变量,因此就会去变量环境中去找,最终在变量环境中找到了 myName(黄色箭头所指),因此输出“修谦”。同样 console.log(myAag)因为在词法环境中找到了 myAag(深蓝色箭头所指),因此输出 18。而将上面的代码在浏览器里执行,也是这样的结果
当函数内部作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终的执行上下文如下图所示:
通过上面的分析,我们基本已经理解了词法环境的结构和工作机制:ES6 中的块级作用域就是通过词法环境的栈结构来实现的,而之前的变量提升是通过变量环境来实现,通过这两者的结合,JS 引擎也就同时支持了变量提升和块级作用域了。
至此,我想关于变量提升,你应该有一个比较深刻的印象了。当然了,上面写的可能并不完全正确。也欢迎大家指正批评。
文章转载自:修谦得益
评论