JS执行机制 - 块级作用域

33 阅读9分钟

为什么在JS中存在变量提升

先从作用域说起,作用域是指在程序中定义变量或函数的区域,其决定了变量和函数的可见性和生命周期。

ES6 之前,JS的作用域只有两种: 全局作用域函数作用域

  • 全局作用域 中的对象在代码中的任何地方都可以访问,其生命周期与页面的生命周期相同;
  • 函数作用域 就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部的变量会被销毁(闭包除外)。

ES6 之前,JS只支持这两种作用域,相较而言,其他语言则都普遍支持 块级作用域。 块级作用域就是使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句,甚至单独的一个 { } 也可以看作是一个块级作用域。

//if块
if(1){}

//while块
while(1){}

//函数块
function foo(){}
 
//for循环块
for(let i = 0; i<100; i++){}

//单独一个块
{}

如果一种语言支持块级作用域,那么其代码块内部定义的变量在代码块外部是访问不到的,并且等该代码块中的代码执行完毕之后,内部变量也随之被销毁。

char* myname = "极客时间";
void showName() {
  printf("%s \n",myname);
  if(0){
    char* myname = "极客邦";
  }
}

int main(){
   showName();
   return 0;
}

如上面C代码所示,最终打印的值为全局变量的 myname 的值。这是因为C语言是支持块级作用域的,所以 if 块里面定义的变量是不能被 if 块外面的语句访问到的。

与C语言不同,ES6之前 JS是不支持块级作用域的,大概率是因为当初设计这门语言的时候,并没有想到JS会火起来,所以只是按照最简单的方式来设计。没有块级作用域,再把作用域内部的变量统一提升无疑是最快速、简单的设计。

变量提升带来的缺陷

JS的变量提升特性,会导致代码的执行结果与其他语言(或直觉)的结果相违背。

这是因为无论函数中的变量在哪里声明,在编译阶段都会被提升到执行上下文的变量环境中,所以这些变量在整个函数体内部的任何地方都可以被访问。进而导致带来如下问题:

  • 变量容易在不被察觉的情况下被覆盖掉

    比如上面的C代码,用JS实现后

    var myname = "极客时间"
    function showName(){
      console.log(myname);
      if(0){
       var myname = "极客邦"
      }
      console.log(myname);
    }
    showName()
    

    其执行结果就会输出 undefined,而并不会像C函数那样输出"极客时间"。

    为什么输出undefined

    当执行到showName函数调用时,执行上下文和调用栈的状态如图所示:

    Screenshot 2024-04-16 at 22.00.09.png 在执行到console.log(myname);时, 调用栈中有两个myname变量,一个在全局上下文(极客时间),一个在showName函数上下文(undefined)。正是由于变量提升,showName函数上下文中就包含了变量myname,而其值是undefined, 所以输出的结果是undefined

    这样的结果与其他大部分支持块级作用域语言的结果都不一样,很容易造成误解。

  • 本应销毁的变量而未被销毁

    function foo(){
      for (var i = 0; i < 7; i++) {
      }
    console.log(i); 
    }
    foo()
    

    如果用C语言或其他大部分语言实现类似代码,在 for 循环结束之后,i 就已经被销毁了,但是在JS代码中,i 的值并未被销毁,所以最后打印结果为 7

    这也是由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。

ES6 如何解决变量提升带来的缺陷

为了解决变量提升带来的一系列问题, ES6 引入了 letconst 关键字,从而使JS也能像其他语言一样拥有了 块级作用域

let x = 5
const y = 6
x = 7
y = 9 //报错,const声明的变量不可以修改

使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的。

对于下面代码,用 var 声明的变量:

function varTest() {
  var x = 1;
  if (true) {
    var x = 2;  // 同样的变量!
    console.log(x);  // 2
  }
  console.log(x);  // 2
}

两个地方都定义了变量 x,第一个地方在函数块的顶部,第二个地方在 if 块的内部,由于 var 的作用范围是整个函数,所以在编译阶段,会生成如下的执行上下文:

Screenshot 2024-04-16 at 22.21.21.png

从执行上下文的变量环境中可以看出,最终只生成了一个变量 x,函数体内所有对 x 的赋值操作都会直接改变变量环境中的 x 值。最终输出的结果为 2。而对于相同逻辑的代码,其他语言最终输出的结果为 1。

ES6之后,如何得到像其他语言一样的结果呢?只需要把 var 关键字替换为 let 关键字。

function letTest() {
  let x = 1;
  if (true) {
    let x = 2;  // 不同的变量
    console.log(x);  // 2
  }
  console.log(x);  // 1
}

执行以上代码, if 块内部输出结果为 2, 最终输出结果为 1。

这是因为 let 关键字是支持块级作用域的,所以在编译阶段,JS引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块中通过 let 声明的关键字,并不会提升到全函数可见。所以在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这种就非常符合我们的编程习惯了:作用域块内声明的变量不影响块外面的变量。

JS 是如何支持块级作用域的

在同一段代码中, JS如何做到既要支持变量提升,又要支持块级作用域?

JS引擎是通过变量环境来实现函数级作用域的,在ES6之后,JS引入了词法环境来实现块级作用域。

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()

当执行这段代码时,JS引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码。

首先编译代码,并创建执行上下文,其执行上下文的内容如图所示:

Screenshot 2024-04-16 at 22.34.21.png

从图中可以看出:

  • 函数内通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。

  • 通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。

  • 在函数内的块级作用域内部,通过 let 声明的变量并没有(或者说还没有)被存放到词法环境中。

然后执行代码

当执行到foo函数的块级作用域内部时, 变量环境中 a 的值已被设为1,词法环境中 b 的值已被设为2,此时函数的执行上下文如下:

Screenshot 2024-04-16 at 22.41.31.png

块级作用域中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,这里所讲的变量是指通过 let 或者 const 声明的变量。

1️⃣ 这里在进入块级作用域后并不会再有编译的过程,抽象语法树在进入函数阶段就生成了,并且函数内部的作用域是已经明确的,早在进入foo函数时就已完成了整个函数的编译,只不过通过let或const声明的变量会在进入块级作用域的时候被创建,但是在该变量没有赋值之前,引用该变量JS引擎会抛出异常--暂时性死区
2️⃣ 编译时变量环境和词法环境最顶层数据已经确定了。当执行到块级作用域的时候,块级作用域中通过let和const申明的变量会被追加到词法环境中,当这个块执行结束之后,追加到词法作用域的内容又会销毁掉。

再接下来,当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境变量环境中查找变量 a 的值了。

具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JS引擎,如果没有查找到,那么继续在变量环境中查找。

这样一个变量查找过程就完成了,你可以参考下图:

Screenshot 2024-04-16 at 22.45.36.png

块级作用域执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:

Screenshot 2024-04-16 at 22.46.20.png

小结

由于JS 变量提升特性存在着变量覆盖、变量污染等设计缺陷,所以 ES6 引入了块级作用域关键字( let / const)来解决这些问题。

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

块级作用域是通过词法环境的栈结构来实现的,而变量提升是通过变量环境来实现,通过这两者的结合,JavaScript 引擎也就同时支持了变量提升和块级作用域了。

需要注意的是变量在创建和初始化之间,会存在一段暂时性死区,这时间段内,JS引擎不允许对该变量访问(也就是在赋值前读取),否则会抛出“Uncaught ReferenceError: Cannot access 'myname' before initialization”错误。所以,对于 let/const 声明的变量在初始化之前不允许访问。

let myname= '极客时间'
{
  console.log(myname) //Uncaught ReferenceError!!!
  let myname= '极客邦'
}