从 JS 执行机制理解变量提升等特性

976 阅读6分钟

对于 Javascript 的执行机制的认识还比较浅,恰好最近有看到李兵老师浏览器的课程(得付费哟),了解了 JavaScript 的执行机制,对于变量提升,暂时性死区的理解能够加深,也能够初步复刻出代码的执行。

showName()
console.log(myName)
var myName = '小林别闹'
console.log(yourName)
let yourName = '小林加油'
function showName() {
    console.log('函数showName被执行');
}

image-20210508200203787.png

如果您也对上边代码的运行结果感到疑惑或者感兴趣,那么请阅读下去吧

1.编译

首先可以明确,代码先编译再执行

编译的过程比较复杂,俺暂时也不是很清楚,感兴趣的朋友可以去学一下,然后告诉我(笑),引用一段李兵老师的话

编译先是生成字节码,然后解释器可以直接执行字节码,输出结果。 但是通常 Javascript 还有个编译器,会把那些频繁执行的字节码编译为二进制,这样那些经常被运行的函数就可以快速执行了,通常又把这种解释器和编译器混合使用的技术称为 JIT

我们可以暂时将这段代码的编译结果可以分为两部分:执行上下文可执行代码

另外 var myName = '小林别闹' 这个语句就可以分为声明和赋值两部分

var myName    //声明部分
myName = '小林别闹'  //赋值部分

1.1执行上下文

执行上下文有三种(开始学习的时候误以为块作用域都有执行上下文):

  • 全局执行上下文:只有一个,程序首次运行时创建,一直压在调用栈的底部,直到程序运行结束

  • 函数执行上下文:函数被调用时创建,每次调用都会为该函数创建一个新的执行上下文,不管这个函数是不是重复调用

  • Eval 函数执行上下文:运行eval函数中的代码时创建的执行上下文,少用且不建议使用,俺也不太清楚

执行上下文可以暂时理解为包括****变量环境词法环境两个对象

文章开头的代码编译后的执行上下文大致如图:

image-20210508201124664.png

编译的时候,JavaScript 引擎把由 var 声明的变量和函数的声明部分,提到了变量环境对象中,给定默认值 undefined。JavaScript 引擎发现了一个通过 function 定义的函数,所以它将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性

将由 letconst 声明的变量的声明部分提到了词法环境对象词法环境由一个栈结构管理。同样给定默认值 undefined。当执行上下文没有切换时,当遇到新的块作用域,用 letconst 声明的变量会创建新的词法环境。新创建的词法环境会压入这个栈中,并伴随相关语句的结束弹出销毁。

1.2可执行代码

编译完后就是执行了,是一行一行的执行的哦,执行代码大致长这样:

showName()//函数声明提升到变量环境中了,所以是可以执行的,输出那句'函数showName被执行'
console.log(myname)//var 定义的变量也提升到变量环境中,并默认为undefined,所以输出undefined
myname = '小林别闹'//将变量环境中myname的值改成 "小林别闹"
console.log(yourName)//let 定义的变量再词法环境中,默认为undefined,但是javascript不允许 let 定义的变量在声明前使用,所以会报错

这样对于文章开头代码的执行结果是不是清楚了呢

2.调用栈

函数只有在调用的时候才会被编译。调用一个函数就会有一个新的执行上下文,通过一个栈结构来管理这些上下文,进而管理函数之间的调用关系。这个栈就叫调用栈,文章开头的代码其实就有两个执行上下文showName 函数执行完毕,该执行上下文就从调用栈内出来。

image-20210508203339901.png

2.1栈溢出

栈的大小是有限的,超过栈的大小就会造成栈溢出,比如说递归调用

// function foo(){
//    return setTimeout(foo, 0);
//}
function foo(){
    return foo();
}
foo()

image-20210508212633663.png

为啥 setTimeout 能够避免栈溢出呢,这是因为 setTimeout 是异步的,会被交给 Web API,时间触发时,回调函数被送到任务队列,事件循环不断地监视任务队列,并按它们排队的顺序一次处理一个回调。每当调用堆栈为空时,事件循环获取回调并将其放入堆栈中进行处理。请记住,如果调用堆栈不是空的,则事件循环不会将任何回调推入堆栈

还可以,设置一个变量depth用来表示调用的层数,当超过一定层数时,则终止递归

3.作用域链

在编写代码的时候,如果你使用了一个在当前作用域中不存在的变量,这时 JavaScript 引擎就需要按照作用域链在其他作用域中查找该变量

这是因为 JavaScript 语言的作用域链是由词法作用域决定的,而词法作用域是由代码结构来确定的,也就是代码中函数声明的位置来决定的

在执行上下文中寻找变量按照的是先词法环境,再变量环境,调用栈的顺序就是函数的调用顺序,执行上下文之间,查找变量就沿着作用域链来查找,作用域的顺序和调用顺序无关,仅和声明顺序有关

var a = 'A'
let b = "B"
let c = "C"
function foo() {
    console.log( a ); 
}
function bar() {
    var a = "a";
    foo();
    console.log(c)
    let c = "c" 
}
bar();

结果如图:

image-20210508204705599.png foobar 函数都声明在全局作用域,它们沿着作用域链找变量的时候当然就找到了全局执行上下文这里了,但是在bar 函数里面通过 let 声明了 c 所以 bar 找到的是自己的词法环境里面的 c 但是在赋值前调用,Javascript 引擎不允许,所以报错了。下面的图应该画错了(哭),bar函数执行上下文的词法环境里面,c 应该还是 undefined ,因为报错了,下面的代码应该不会执行下去了。

image-20210508203339901.png

顺带提一下,执行上下文里边东西挺多的,可以参考下图:

image-20210508213439494.png

outer就是一个外部引用,用来指向外部的执行上下,这个外部是沿着作用域链的,和调用没关系哦

this 应该不陌生了,非严格模式下,普通函数中的this都是window,当然你可以这样记住:this总是指向调用者,咱们的箭头函数都没有this,都是外部的

参考:

8个问题看你是否真的懂 JS

浏览器工作原理与实践