浅谈 JavaScript 执行上下文

1,780 阅读10分钟

前言

说一下什么是执行上下文?曾经被面试官问到过这个问题,当时一脸懵逼,一个字也讲不出来。回来后我百度了一下,看完也是似懂非懂的状态,并没有真正理解,虽然不妨碍我死记硬背以应付面试~

后来“执行上下文”这个名词在我的学习过程中出现的频率越来越高,书籍也好博文也罢,“执行上下文”总是被摆在一个很高的地位。于是我下定决心,要彻底掌握它。

我前前后后看了十几篇文章,终于恍然大悟,“执行上下文”绝对称得上是 JavaScript 中最重要的概念之一,理解“执行上下文”是理解其它重要概念的前提,例如作用域、闭包、提升等等。

这篇文章是我对“执行上下文”的一个简单总结,内容算不上很深入,主要面向普通开发者。如果你有更好的见解,欢迎在评论区留言,帮助大家一起成长。

什么是执行上下文

简而言之,“执行上下文”就是当前 JS 代码被解析和执行时所处环境的抽象概念,JS 中任何代码都是在“执行上下文”中运行的。

执行上下文的类型

在 JS 中,运行环境主要有全局环境函数环境,在代码运行过程中,最先进入的是全局环境,而在函数被调用时则进入相应的函数环境。全局环境和函数环境所对应的执行上下文就是全局执行上下文函数执行上下文

全局执行上下文是最外层的执行上下文,任何不在函数中的代码都位于全局执行上下文,一个程序中只存在一个全局执行上下文。

每个函数都拥有自己的执行上下文,但只有在函数被调用时才会被创建,一个程序中可以存在任意数量的函数执行上下文。

运行在 eval 函数中的代码也拥有自己的执行上下文,由于 eval 函数并不常用,所以不再讨论,知道有这么回事就行了。

执行上下文栈

在一个 JS 文件中,经常会有多个函数被调用,也就是说 JS 代码运行过程中可能会产生多个执行上下文,那么如何去管理这么多的执行上下文呢?

执行上下文是以的方式被存放起来的,我们称之为执行上下文栈

在代码开始执行时,首先进入全局环境,此时全局执行上下文被创建并入栈,之后当函数被调用时则进入相应的函数环境,此时函数执行上下文被创建并入栈,当处于栈顶的执行上下文所有代码都执行完毕之后,执行上下文栈就会将该函数执行上下文弹出(出栈),将控制权交给之前的执行上下文。

所以在执行上下文栈中,栈底永远是全局执行上下文,而栈顶则是当前正在执行的函数执行上下文。

举个栗子:

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

function fn1() {
  console.log('fn1')
  fn2();
}

fn1();

上面这段代码在执行过程中,执行上下文栈的行为是什么样的呢?

/* 伪代码 以数组来表示执行上下文栈 ECStack=[] */

// 代码执行时最先进入全局环境,全局上下文被创建并入栈
ECStack.push(global_EC);
// fn1 被调用,fn1 函数上下文被创建并入栈
ECStack.push(fn1_EC);
// fn1 中调用 fn2,fn2 函数上下文被创建并入栈
ECStack.push(fn2_EC);
// fn2 执行完毕,fn2 函数上下文出栈
ECStack.pop();
// fn1 执行完毕,fn1 函数上下文出栈
ECStack.pop();
// 代码执行完毕,全局上下文出栈
ECStack.pop();

结合下图理解更加清晰直观:

执行上下文栈.webp

执行上下文的生命周期

一个执行上下文的生命周期分为创建阶段执行阶段,创建阶段的主要工作是生成变量对象建立作用域链确定this指向,而执行阶段的主要工作是变量赋值以及执行其它代码等。

变量对象

每个执行上下文都有一个关联的变量对象,而这个执行上下文中定义的所有变量和函数都存在于这个对象上。

我们已经知道,在执行上下文的创建阶段会生成变量对象,生成变量对象主要有以下三个过程:

  • 检索当前执行上下文中的参数。该过程生成 Arguments 对象,并建立以形参变量名为属性名,以形参变量值为属性值的属性。

  • 检索当前执行上下文中的函数声明。该过程建立以函数名为属性名,以函数所在内存地址引用为属性值的属性。

  • 检索当前执行上下文中的变量声明。该过程建立以变量名为属性名,以 undefined 为属性值的属性(如果变量名跟已声明的形参变量名或函数名相同,则该变量声明不会干扰已经存在的这类属性)。

我们可以通过以下伪代码来表示变量对象:

VO = {
    Argumnets: {},
    ParamVariable: 具体值, // 形参变量
    Function: <function reference>,
    Variable: undefined
}

当执行上下文进入执行阶段后,变量对象会变为活动对象。此时原先声明的变量会被赋值。

变量对象和活动对象都是指向同一个对象,只是处于执行上下文的不同阶段。

我们可以通过以下伪代码来表示活动对象:

AO = {
    Arguments: {},
    ParamVariable: 具体值, // 形参变量
    Function: <function reference>,
    Variable: 具体值
}

下面我们举例来说明,在代码执行过程中执行上下文中变量对象的变化情况:

function fn1(x) {
    var y = 666;
    function fn2() {};
    var z = function () {};
}

fn1(1);

当 fn1 函数被调用时,fn1 执行上下文被创建(创建阶段)并入栈,其变量对象如下所示:

fn1_EC = {
    VO = {
        Arguments: {
            '0': 1,
            length: 1
        },
        x: 1,
        y: undefined,
        fn2: <function fn2 reference>,
        z: undefined
    }
}

在 fn1 函数代码的执行过程中(执行阶段),变量对象变为活动对象,原先声明的变量会被赋值,其活动对象如下所示:

fn1_EC = {
    AO = {
        Arguments: {
            '0': 1,
            length: 1
        },
        x: 1,
        y: 666,
        fn2: <function fn2 reference>,
        z: <function express z reference>
    }
}

对于全局执行上下文来说,由于其不会有参数传递,所以在生成变量对象的过程中只有检索当前上下文中的函数声明和检索当前上下文中的变量声明两个步骤。

在浏览器环境中,全局执行上下文中的变量对象(全局对象)就是我们熟悉的 window 对象,通过该对象可以使用其预定义的变量和函数,在全局环境中声明的变量和函数,也会成为全局对象的属性。

搞明白变量对象的生成过程后,我们就能够更深入地理解函数提升变量提升的内在机制了,举个栗子:

console.log(x); // undefined
fn(); // 666
var x = 888;
function fn() {
    console.log(666);
}

上述代码,在全局执行上下文的创建阶段,会检索上下文中的函数声明以及变量声明,函数会被赋值具体的引用地址,变量会被赋值为 undefined。所以上述代码等价于:

function fn() {
    console.log(666);
}
var x = undefined;
console.log(x); // undefined
fn(); // 666
x = 888;

这就是函数提升变量提升的内在机制。

作用域链

作用域链是指当前执行上下文和上层执行上下文的一系列变量对象组成的层级链,它决定了各级执行上下文中的代码在访问变量和函数时的顺序。

我们已经知道,执行上下文分为创建阶段和执行阶段。在执行上下文的执行阶段,当需要查找某个函数或变量时,会在当前执行上下文的变量对象(活动对象)中查找,若没找到,就会沿着上层执行上下文的变量对象进行查找,如果一直查到全局执行上下文中的变量对象(全局对象)还没有找到,则说明该函数或变量不存在。

代码执行时的标识符(函数名和变量名)解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链的最前端开始(当前正在执行的上下文的变量对象永远位于作用域链的最前端),然后逐级往后,直到找到标识符(如果没有找到,那么通常会报错)。

内部执行上下文可以通过作用域链访问外部执行上下文中的一切,但外部执行上下文无法访问内部执行上下文中的任何东西。执行上下文中的连接是线性的、有序的,每个执行上下文都可以到上一级执行上下文中去搜索变量和函数,但任何执行上下文都不能到下一级执行上下文中去搜索:

var color1 = 'red';

function changeColor() {
    let color2 = 'pink';
    
    function swapColors() {
        let color3 = color2;
        color2 = color1;
        color1 = color3;
        
        // 这片区域可以访问 color1、color2、color3
    }
    
    // 这片区域可以访问 color1、color2,访问不到 color3
    swapColors();
}

// 这片区域只能访问 color1
changeColor();

this 指向

this 的指向,是在函数被调用时确定的,也就是执行上下文被创建时确定的。

关于 this 的指向,其实最主要的是三种场景,分别是全局执行上下文中的 this函数中的 this构造函数中的 this

  • 在全局执行上下文中,this 指向全局对象:
// 在浏览器环境下,全局对象就是 window 对象

console.log(this === window); // true

a = 666;
this.b = 888;
console.log(window.a); // 666
console.log(window.b); // 888
console.log(a); // 666
console.log(b); // 888
  • 函数中的 this,如果被调用的函数被某一个对象所拥有,那么其内部的 this 指向该对象;如果该函数被独立调用,那么其内部的 this 指向 undefined(非严格模式下指向 window):
var x = 666;
function fn() {
    console.log(this.x);
}
var obj = {
    x: 888,
    fn: fn
}
fn(); // 666 (fn 被独立调用,在非严格模式下 this 指向 window)
obj.fn(); // 888 (fn 被 obj 对象所拥有,所以 this 指向 obj 对象)
  • 构造函数中的 this,指向新创建的对象实例:
function Person(name, age) {
    this.name = name;
    this.age = age;
}
var dlrb = new Person('迪丽热巴', 18);
console.log(dlrb.name); // 迪丽热巴
console.log(dlrb.age); // 18

需要注意的是,在箭头函数中,this 的指向是在函数声明时确定的,详情可见这篇文章

最后

这篇文章概念性内容较多,不易理解,如果你看到这里还是云里雾里,那么强烈建议你再多看几遍(认真仔细的看),直到彻底理解为止,不然就失去了看这篇文章的意义。

如果文中有错误或者不足之处,欢迎大家在评论区指正。

你的点赞是对我莫大的鼓励!感谢阅读~