「JavaScript进阶」一文吃透执行上下文和执行栈

449 阅读8分钟

前言

学习 JavaScript ,理解其内部执行逻辑是很重要的,今天这篇文章就来讲讲 JavaScript 执行上下文执行栈

JavaScript 系列文章:JavaScript进阶

什么是执行上下文和执行栈?

执行上下文

执行上下文就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念。

全局执行上下文函数执行上下文Eval 函数执行上下文三种执行上下文类型。

类型定义
全局执行上下文首次运行程序时创建的默认环境。一个程序只能有一个全局上下文
函数执行上下文每次调用函数时,都会创建一个新的函数执行上下文
Eval 函数执行上下文eval 函数中执行的代码。很少用且不建议使用,本文不做详细介绍。

注:全局上下文 做了两件事:

  • 定义一个全局对象,在浏览器中全局对象是 window

  • this 指向这个全局对象。关于 this 的详细介绍,建议去看 一文吃透五种this绑定方式

执行栈

执行栈具有栈结构(LIFO 后进先出),用于存储在代码执行期间创建的所有执行上下文。

  • 程序执行时,先创建一个全局上下文 push 到执行栈中,每调用一个函数,会为该函数创建一个执行上下文并 push 到执行栈的顶部

  • 当执行栈中的函数执行完成后,会把该函数从执行栈的顶部 pop 出去,然后继续运行执行栈中的下一个函数,以此类推。

  • 所有程序执行完毕,Javascript 引擎把全局执行上下文从执行栈中移除。

我们来运行下案例试试

function first() {
   // 调用 second 函数
   second();

   console.log('first() 函数执行上下文结束')
}

function second() {
   console.log('second() 函数执行上下文结束')
}
// 调用函数
first();

console.log('全局执行上下文结束')

在这个案例里,执行程序时,会先创建一个全局上下文,然后先调用函数 first() 创建函数执行上下文 push 到执行栈,再调用 second() 创建执行函数上下文 push 到执行栈。因此,这个程序 push 到执行栈的顺序如下:

全局执行上下文 => first() 执行上下文 => second() 执行上下文

second()函数执行结束后,会先行执行栈中 pop 出去,然后执行将函数 first()从执行栈中 pop 出去,程序执行结束时,将全局执行上下文 pop 出去。案例中,从执行栈 pop 出去的顺序(LIFO 后进先出)如下:

image.png

执行上下文的生命周期

通过前面执行栈的介绍,我们理解了程序是怎么管理执行上下文的,那么执行上下文是如何创建的呢?我们接下来看看执行上下文的生命周期

执行上下文的生命周期分为 创建阶段 => 执行阶段 => 回收阶段 三个阶段

创建阶段

当函数被调用,但未执行任何其内部代码之前,都处于执行上下文的创建阶段。

创建阶段主要做变量环境(创建变量对象)词法环境(创建作用域链)this 绑定(确定 this 指向)三件事。

1.词法环境(创建作用域链)

词法环境这一步,也可以理解为创建作用域链

词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符与特定变量与函数的关系。简单理解,就是一个包含 标识符变量映射 的结构。

词法环境由 环境记录对外部环境的引用两部分组成。

  • 环境记录 存储变量和函数声明。环境记录分为声明性环境记录对象环境记录全局环境记录
类型定义
声明性环境记录用于记录标识符与变量的映射,只记录非var声明的标识符(let、const、function……),没有关联的绑定对象
对象环境记录用于记录标识符与变量的映射,只记录var声明的标识符,有一个关联的绑定对象
全局环境记录声明性环境记录对象环境记录的组合

我们来看一个经典的问题: 暂存死区

var name = '谷底飞龙';
var f = ()=>{
   console.log(name);
   let name = '天下无敌';
}
f();

在函数 f 外部定义了变量 name,在函数内部用 let也定义了变量 name,此时在 let定义之前访问变量 name时,会报未初始化的错误。

image.png

这就是一种暂存死区的现象。出现这个问题的原因是啥呢?这就跟我们讲的环境记录有关。let定义的变量属于声明性环境记录,没有关联的绑定对象(也就是预留了内存空间,但是没有与标识符进行绑定),处于未初始化状态。

  • 对外部环境的引用 用于访问其外部词法环境

词法环境分为全局环境函数环境两种类型

类型环境记录对外部环境的引用
全局环境拥有一个全局对象(window)及其关联的方法和属性以及任何用户自定义的全局变量,this 的值指向这个全局对象没有外部环境,对外部环境的引用是null
函数环境用户在函数中定义的变量被存储在函数环境记录中,还包含一个 arguments对象可以是全局环境,也可以是外部函数环境

注:函数环境 中包含的arguments对象包函数的参数的索引与参数的映射和参数的长度。如下面的案例的arguments{0:'谷底飞龙', 1: 28, length: 2}

function f(name, age){
   console.log(`my name is ${name}, my age is ${age}`);
}

f('谷底飞龙', 28);

// Arguments: {0:'谷底飞龙', 1: 28, length: 2}

2.变量环境(创建变量对象)

变量环境这一步,也可以理解为创建变量对象(包含变量,函数和参数)

变量环境也是一个词法环境,具有词法环境的所有属性。

ES6 中,变量环境与词法环境的区别: 词法环境用于存储函数声明和变量绑定(letconst);变量环境仅用于存储变量绑定(var

在创建阶段,代码会被扫描并解析变量和函数声明,其中函数声明存储在环境中,而变量会被设置为 undefined(在 var 的情况下)或保持未初始化(在 letconst 的情况下)。

  • 变量提升

创建变量对象时,会依次获取执行上下文的变量声明(通过var声明的变量),如果没有变量没有定义,会默认设置为undefined,这就是变量提升

console.log(name); // 输出 undefined
var name = '谷底飞龙';

通过var定义变量 name,在定义之前使用,会默认设置为undefined

变量提升后,相当于下面的代码

var name;
console.log(name); // 输出 undefined
name = '谷底飞龙';

但如果把 var改成let 或者const,变量会保持未初始化,程序会报错: image.png

  • 函数声明提升 创建一个函数的方法有两种:通过函数声明 function f(){}或者变量声明var f = function(){}。这两种在函数提升有什么区别呢?
console.log('通过函数声明的方式:' + f1);
console.log('通过变量声明的方式:' + f2);

// 通过函数声明的方式
function f1() {}
// 通过变量声明的方式
var f2 = function() {}

执行结果如下: image.png

小结: 通过函数声明的方式创建函数,函数声明会被提升,可以在函数定义之前执行函数,也就是函数声明提升;通过变量声明的方式创建函数,属于变量声明提升,默认设置为undefined

有个细节需要注意:如果函数声明和变量声明同名,函数声明提升时,函数声明的优先级高于变量声明。

// 函数定义之前执行函数,以函数声明创建的方式为主
// 输出 “执行通过函数声明创建的函数”
f();
// 通过函数声明的方式
function f() {
   console.log('执行通过函数声明创建的函数')
}
// 通过变量声明的方式
var f = function() {
    console.log('执行通过变量声明创建的函数')
}
// 函数定义之后执行函数,以最近创建的声明(这里是变量声明)为准
// 输出 “执行通过变量声明创建的函数”
f();

3.this 绑定(确定 this 指向)

this 绑定,实际上就是确定 this 的指向。建议去看看我这篇文章 一文吃透五种 this 绑定方式

  • 在全局执行上下文中,this 绑定的是全局对象,在浏览器中,全局对象是window
  • 在函数执行上下文中,this 绑定的值取决于调用函数的对象。如果它被一个对象引用调用,那么 this 的值被设置为该对象,否则 this 的值被设置为全局对象或 undefined(严格模式下)

执行阶段

执行阶段完成对所有变量的分配,最后执行代码

在执行阶段,JavaScript 引擎如果在源代码中声明的实际位置找不到let变量的值,那么将为其分配undefined

回收阶段

执行上下文出栈,等待虚拟机回收执行上下文

结语

耗时三天整理编写,如果你喜欢这篇文章,可以帮忙点个

欢迎关注我的公众号 「谷底飞龙」~

参考: