JS执行机制 - 变量提升

94 阅读9分钟

JS代码是按照顺序执行的吗?

先看代码, 以下代码输出的结果是什么?

showName()
console.log(myname)
var myname = '极客时间'
function showName() {
    console.log('函数showName被执行');
}

基本上所有的程序语言,都是自上而下,一行一行顺序执行的。若按照这个逻辑来理解的话,那么:

  • 当执行到第1行的时候,由于函数showName还没有定义,所以执行会报错;
  • 同样执行第2行的时候,由于变量myname也未定义,所以同样会报错;

然而,实际结果却并非如此

Screenshot 2024-03-31 at 15.42.25.png

第1行输出“函数showName被执行”,第2行输出“undefined”,这和预想的顺序执行结果不太一样!

通过以上结果,说明函数或变量可以在定义之前使用,那如果使用没有定义的变量或函数呢,JS代码还能继续执行吗?为了验证这一点,可以删除第3行变量myname的定义,如下:

showName()
console.log(myname)
function showName() {
    console.log('函数showName被执行');
}

再次执行这段代码时,Javascript引擎就会报错,结果如下:

Screenshot 2024-03-31 at 15.49.47.png

综上,可以得出以下结论:

    1. 在执行过程中, 若使用了未声明的变量,那么JS执行会报错;
    1. 在一个变量(var)定义之前使用,不会出错,但是该变量的值会为undefined,而不是定义时的值;而let/const这种常量还是会报错;
    1. 在一个函数定义之前使用,不会出错,且函数能正确执行;

第1个结论很好理解,因为变量没有定义,这样在执行JS代码时,就找不到该变量,所以JS会抛出错误。

但是第2和第3个结论,却让人产生疑惑:

  • 变量和函数为什么能在其定义之前使用?
  • 同样的方式,变量和函数的处理结果为什么不一样?比如上面的执行结果,提前使用的showName函数能打印出完整结果,但是提前使用的myname变量却是undefined,而不是定义时使用的“极客时间”这个值。

要解释这两个问题,就需要了解什么是变量提升。

变量提升(Hoisting)

在此之前,先看看JS中的声明赋值

var myname = '极客时间'

以上代码可以把它看成两行代码组成,声明部分和赋值部分。

var myname    //声明部分
myname = '极客时间'  //赋值部分

这是变量的声明与赋值,再看看函数的声明和赋值。

function foo(){
  console.log('foo')
}

var bar = function(){
  console.log('bar')
}

第一个函数foo是一个完整的函数声明,也就是说没有涉及到赋值操作;
第二个函数是先声明变量bar,再把function(){console.log('bar')}赋值给 bar。

Screenshot 2024-03-31 at 16.03.46.png

所谓变量提升,是指在JS代码执行过程中,JS引擎把变量和函数的声明部分提升到当前作用域的顶部的行为。且变量被提升后,会给变量设置默认值:undefined。

用代码来模拟一下此行为:

/*
* 变量提升部分
*/
// 把变量 myname提升到开头,
// 同时给myname赋值为undefined
var myname = undefined
// 把函数showName提升到开头
function showName() {
    console.log('showName被调用');
}

/*
* 可执行代码部分
*/
showName()
console.log(myname)
// 去掉var声明部分,保留赋值语句
myname = '极客时间'

模拟实现过程主要做了两处调整:

  • 第一处是把声明的部分都提升到了代码开头(变量mayname 和 函数showName),并且给变量设置了默认值undefined;
  • 第二处是移除了原本声明的变量和函数(var myname = '极客时间'的语句,移除了var声明,函数showName移除了整个声明)

通过这两处调整,就可以实现变量提升的效果,如果运行这段模拟代码,其输出结果和最开始的第一段代码是一样的。

JS代码的执行流程

从概念的字面意义来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面(如同模拟代码)。但,并非如此,实际上变量和函数声明在代码中的位置并不会改变,而是在编译阶段被JS引擎放入到了内存中

一段JS代码在执行之前需要被JS引擎编译,编译完成后,才进入执行阶段。

编译阶段

编译阶段是如何进行变量提升的?

继续看前面的模拟代码,它分为两个部分

第一部分:变量提升部分的代码

var myname = undefined
function showName() {
    console.log('函数showName被执行');
}

第二部分:执行部分的代码

showName()
console.log(myname)
myname = '极客时间'

其更为详细的JS执行流程如下图:

Screenshot 2024-03-31 at 16.23.59.png

输入一段代码后,经过编译后,会生成两部分内容:执行上下文可执行代码

执行上下文是JS执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如this、变量、对象以及函数等。

在执行上下文中存在一个变量环境(Viriable Environment)的对象,该对象保存了变量提升的内容。而对于let声明的变量保存在执行上下文的词法环境中。

详细的生成过程如下:

showName()
console.log(myname)
var myname = '极客时间'
function showName() {
    console.log('函数showName被执行');
}
  • 第1行和第2行,由于不是声明操作,所以并不处理;
  • 第3行,由于是经过var声明的,因此JS引擎将在变量环境对象中创建一个名为myname的属性,并使用undefined对其初始化;
  • 第4行,JS引擎发现了一个通过function定义的函数,所以它将函数的定义存储到堆中,并在变量环境对象中创建一个showName的属性,然后将该属性的值 指向堆中函数的位置。

对于前面的示例代码,可以粗略地把变量环境对象看成是如下结果:

VariableEnvironment: 
//函数声明的提升比变量声明提升的优先级高
     showName ->function : {console.log(myname),
     myname -> undefined

这样就生成了变量环境对象,接下来JS引擎会把声明以外的代码编译为字节码。类似如下:

showName()
console.log(myname)
myname = '极客时间'

执行阶段

JS引擎开始执行“可执行代码”,按照顺序一行行地执行。

  • 当执行到showName函数时,JS引擎便开始在变量环境对象中查找该函数,由于变量环境对象中存在该函数的引用,所以JS引擎便开始执行该函数,并输出“函数showName被执行”;
  • 接下来打印“myname”信息,JS引擎继续在变量环境对象中查找该对象,由于变量环境存在myname变量,其值为undefined,所以这时输出的结果为undefined;
  • 接下来执行第3行,把"极客时间”赋值给myname变量,赋值后,变量环境对象中的myname属性值就改变为“极客时间”,后续,如果再有访问myname属性,会返回新的值;
    VariableEnvironment:
     myname -> "极客时间", 
     showName ->function : {console.log(myname)
    

如果代码中出现相同的变量或函数

function showName() {
    console.log('极客邦');
}
showName();
function showName() {
    console.log('极客时间');
}
showName(); 

其两次打印结果分别是什么呢?

其粗略的执行流程为:

  • 首先是编译阶段
    遇到了第一个showName函数,会将该函数存放到变量环境中。 接下来是第二个showName函数,继续存放至变量环境中,但是变量环境中已经存在一个showName的函数了,此时,第二个showName函数会将第一个showName函数覆盖掉。这样变量环境中就只存在第二个showName函数了。

  • 接下来是执行阶段
    先执行第一个showName调用,再执行第二个showName调用,而此时变量环境中只存在第二个showName函数的声明,所以两次执行的结果是一样的,都是“极客时间”

所以,一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数。

如果是函数与变量同名的情况,如下代码:

showName()
var showName = function() {
    console.log(2)
}
function showName() {
    console.log(1)
}
  • 编译阶段:
    变量环境中只会保存function showName(){console.log(1)} 而不会保存 var showName
    这是因为编译到第2行时,会将var showName = undefined写入变量环境;
    编译到第5行时,在同一执行上下文,由于变量环境中存在同名的变量showName,函数声明会直接覆盖变量声明。(如果编译遇到var声明变量且变量环境对象中已存在同名变量时,会忽略这个var,不会覆盖)

  • 执行阶段:
    执行第1行,输出1;
    执行第2行,将变量环境对象中函数声明的实现部分指向function() { console.log(2) }
    后续再执行showName函数,输出结果将为2;

此处如果换成let或const声明变量,就会使用let/const规则,出现语法错误: SyntaxError: Identifier 'showName' has already been declared

综上,遇到同名的函数会覆盖之前定义的函数和变量

总结

JS代码被执行,可以粗略地分为:编译阶段执行阶段

编译阶段。变量和函数会涉及到变量提升过程,存储到JS执行上下文中。

变量或函数,存在创建、初始化、赋值这三个过程。

  • 对于var变量,其创建和初始化会被提升,赋值不会被提升;
  • 对于let变量,创建会被提升,初始化和赋值不会被提升;
  • 对于函数,其创建、初始化和赋值均会被提升;

对于同名函数或变量的定义:

  • 在同一个执行上下文中,函数声明的提升优先级高于变量声明的提升(先提升变量,再提升函数);
  • 如果是两个同名函数,则JS编译阶段会保留最后声明那个;
  • 如果是变量和函数同名,那么在编译阶段,变量的声明会被忽略;