JavaScript 中的执行上下文和调用栈是什么?

3,420 阅读8分钟
原文链接: www.zcfy.cc

在这篇文章里,我会深入地探讨 JavaScript 中最基本概念之一,那就是执行上下文。通过这篇文章,你应该能够清楚地了解到 JS 解释器究竟在干嘛,为什么可以在一些函数和变量声明之前就能使用,以及它们的值是怎样被决定的。

什么是执行上下文(Execution Context)?

当 JavaScript 代码在运行的时候, 它所在的执行环境是非常重要的, 通常认为是以下其中之一:

  • Global code – 默认环境,你的代码首次执行的地方。
  • Function code – 当代码执行进入到函数体当中。
  • Eval code – 在 eval 函数内部执行的文本。

在网上你可以找到很多关于 作用域 的文章, 本文的目的就是让你更加轻松地理解这些概念。让我们想象术语 执行上下文 就是当前代码的执行环境 / 作用域。 不多说了, 让我们看看一个代码既在 全局 又在 函数 / 局部 上下文中执行的例子。

这里没什么特别的地方, 我们有 1 个 全局上下文(用紫色边框标注)和 3 个不同的 函数上下文 (分别用绿色、蓝色、橙色边框标注)。有且只能有 1 个 全局上下文, 并且可以被程序中其他的上下文访问到。

你可以有很多个 函数上下文, 每个函数调用都创造一个新的上下文, 并创建出一个局部作用域,任何在作用域内部声明的东西都不能被当前函数作用域外部访问到。在上面的例子当中,函数可以访问到当前上下文外部的声明的变量,反之却不行。这是为什么呢?这些代码到底是怎样执行的?

执行上下文栈(Execution Context Stack)

在浏览器中的 JavaScript 解释器是单线程的。这实际上意味着,在浏览器中一次只会发生一件事,其他行为或者事件在所谓的执行栈 中排队等待。下面的这个图标是单线程的栈的一个抽象的表示:

我们已经知道, 浏览器第一次加载脚本, 它将默认进入 全局执行上下文 中。 如果,你在全局环境中调用了一个函数, 你的程序序列流会进入被调用的函数的当中,创建一个新的 执行上下文 并且将这个上下文压入执行栈之中。

如果你在当前函数里面又调用了另外一个函数, 也会发生同样的事情。代码的执行流进入内部函数,这将创建一个新的执行上下文,它被压入现有栈的顶部。浏览器永远会执行当前栈中顶部的执行上下文 一旦函数在当前执行上下文执行完毕,它会被从栈的顶部弹出,然后将控制权移交给当前栈的下一个上下文当中。 下面的代码展示了一个递归函数以及程序的 执行上下文:

(function foo(i) {
    if (i === 3) {
        return;
    }
    else {
        foo(++i);
    }
}(0));

这段代码调用自己自身3次, 每次将 i 的值增加 1。 每次函数 foo 被调用的时候, 就会创建一个新的执行上下文。 一旦上下文执行完毕之后, 它就会从栈中弹出并且返回控制权到下一个上下文当中,直到全局上下文 又再次被访问。

关于 执行上下文 有五个要点是要记住的:

  • 单线程。

  • 同步执行。

  • 只有一个全局上下文。

  • 可有无数个函数上下文。

  • 每个函数调用都会创建一个新的 执行上下文,哪怕是递归调用。

执行上下文中的细节

现在我们已经知道了每个函数调用都会创建一个新的 执行上下文 。 然而,在 JavaScript 解释器内部,对每个执行上下文的调用会经历两个阶段:

  1. 创建阶段 [当函数被调用, 但内部的代码还没开始执行]:
  2. 创建 作用域链.
  3. 创建变量、函数以及参数

  4. 决定 "this"的值
  5. 激活 / 代码执行阶段:
  6. 赋值, 寻找函数引用以及解释 /执行代码

我们可以用一个具有三个属性的概念性对象来代表 执行上下文

executionContextObj = {
    'scopeChain': { /* 变量对象 + 所有父级执行上下文中的变量对象 */ },
    'variableObject': { /*  函数参数 / 参数, 内部变量以及函数声明 */ },
    'this': {}
}

活动对象 / 变量对象 [AO/VO]

这个executionContextObj 对象在函数调用的时候创建,但是实在函数真正执行之前。这就是我们所说的第 1 阶段 创建阶段。 在这个阶段,解释器通过扫描传入函数的参数,局部函数声明和局部变量声明来创建 executionContextObj 对象。这个扫描的结果就变成了 executionContextObj 中的 variableObject 对象。

这是解释器执行代码时的伪概述:

  1. 寻找调用函数的代码

  2. 在执行 函数 代码之前, 创建 执行上下文.
  3. 进入创建阶段:

  4. 初始化 作用域链.
  5. 创建变量对象
  6. 创建 参数对象, 检查参数的上下文, 初始化其名称和值并创建一个引用拷贝。
  7. 扫描上下文中的函数声明:

  8. 对于每个被发现的函数, 在 变量对象 中创建一个和函数名同名的属性,这是函数在内存中的引用。
  9. 如果函数名已经存在, 引用值将会被覆盖。

  10. 扫描上下文中的变量声明:

  11. 对于每个被发现的变量声明,在变量对象中创建一个同名属性并初始化值为 undefined
  12. 如果变量名在 变量对象 中已经存在, 什么都不做,继续扫描。
  13. 确定上下文中的 "this"
  14. 激活 / 代码执行阶段:

  15. 执行 / 在上下文中解释函数代码,并在代码逐行执行时给变量赋值。

让我们来看一个例子:

function foo(i) {
    var a = 'hello';
    var b = function privateB() {

    };
    function c() {

    }
}

foo(22);

在调用foo(22) 的时候, 创建阶段 看起来像是这样:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: undefined,
        b: undefined
    },
    this: { ... }
}

你可以发现, 创建阶段 掌管着属性名的定义,而不是给它们赋值,不过参数除外。 一旦 创建阶段 完成之后,执行流就会进入函数中。 在函数执行完之后,激活 / 代码 执行阶段 看起来像是这样:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: pointer to function c()
        a: 'hello',
        b: pointer to function privateB()
    },
    this: { ... }
}

有关提升(Hoisting)

在网上你能找到非常多关于JS 中 提升 这个术语的信息, 用来解释变量和函数声明被"提升"到它们的函数作用的顶部的机制。 然而, 这些都没有详细地解释为什么会发生这样的事情,用你刚刚学到新知识,关于解释器是如何创建 活动对象, 很容易就能理解。 看接下的的这个例子:

​(function() {

    console.log(typeof foo); // function pointer
    console.log(typeof bar); // undefined

    var foo = 'hello',
        bar = function() {
            return 'world';
        };

    function foo() {
        return 'hello';
    }

}());​

这些问题我们现在能回答了:

  • 为什么在 foo 声明之前我们就能访问它?
  • 遵循创造阶段, 我们知道在激活 / 代码执行阶段 之前,变量就被创建了。所以当函数被执行的时候, foo 已经在 活动对象 中定义了。
  • Foo 被声明了两次, 为什么最后它显示为 function 而不是 undefinedstring?*
  • 虽然 foo 被声明了两次, 但是从 创建阶段 我们都知道函数在变量之前被创建在 活动对象 当中,并且如果属性名已经存在 活动对象 当中, 重复声明会被忽略。
  • 因此 function foo() 的引用首先在活动对象 中创建, 而当解释器遇到 var foo, 我们发现 foo 属性名已经存在所以解释器什么都不做继续运行。
  • 为什么 bar 是 undefined ?
  • bar 实际上是一个被赋值为函数的变量,我们都知道变量在 创建阶段 创建,但是它们被初始化为 undefined

总结

希望现在你已经理解了 JavaScript 解释器是如何执行你的代码。理解执行上下文和调用栈能够让你清楚地知道你的代码为什么你的代码执行的时候得到的结果和你预期的不一样。

你认为了解JS 解释器的内部工作原理太过多余了还是对你的 JavaScript 知识非常有帮助 ? 了解执行上下文的阶段能帮助你书写更好的 JavaScript 代码吗 ?

注: 有些人曾问我关于闭包,回调函数,定时器等相关问题,我会在 下篇文章中阐述, 阅读 作用域链了解更多和 执行上下文 有关的内容。

衍生阅读