website icon indicating copy to clipboard operation
website copied to clipboard

JavaScript中的执行上下文&栈

Open natee opened this issue 8 years ago • 0 comments

这篇文章中,我们将深入了解JavaScript中的『执行上下文』。在文章结束的时候,你应该对”解释器要做什么“、”为什么有些函数和变量可以在被声明之前就使用,它们的值是如何被确定的“有一个清楚的认识。

什么是执行上下文?

JavaScript中代码运行时的执行环境是非常重要的,通常是以下情况的一种:

  • Global代码-代码第一次执行时默认的环境。
  • Function代码-执行到一个函数中。
  • Eval代码-文本在eval函数内部执行。

网上有很多关于作用域的文章,本文的目的是让你更容易理解它,我们用下面的代码来作说明。这段代码同时包含了Global和Function的上下文。

image

这段代码没什么特别的,紫色框圈起来的是全局上下文,绿色框、蓝色框、橙色框圈起来的代码包含了3个函数上下文。全局上下文是唯一的,程序中的任何地方均可以访问。函数上下文可以有多个,每个函数上下文会创建一个私有上下文,这个私有上下文可以访问这个函数外部上下文中声明的变量,然而外部上下文却不能被外部作用域直接访问。

执行上下文栈

浏览器中JavaScript解释器是单线程的,这就是说同一时间代码只会做一件事,有其他行为或事件处于队列中的情况被称作执行上下文栈。下图示意了单线程栈: image

正如大家所知,浏览器初次加载脚本时,默认进入全局上下文。如果你的全局代码中调用了一个函数,那么程序将会进入这个被调用函数的上下文,创建一个新的执行上下文,并把当前上下文放到执行队列的顶端。

如果你在当前函数中又调用了另外一个函数,会发生和上面同样的过程。浏览器总是会把当前执行上下文放到栈的顶部,一旦函数执行完成,这个执行上下文就会从栈中移除,返回到栈中的下一个上下文。

下面的例子展示了一个递归函数的执行栈:

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

image

这段代码只是简单调用自身3次,每次执行了foo函数都会创建一个新的执行上下文。一旦一个上下文执行完毕,它就被移除了并自动开始下一个执行上下文,直到返回到_全局上下文_。

执行栈的关键5点:

  • 单线程
  • 同步执行
  • 只有1个全局上下文
  • 无穷个函数上下文
  • 每个函数调用都会产生一个新的执行上下文

详解执行上下文

现在我们知道了,每次函数调用都会产生一个新的执行上下文。然而,JavaScript解释器内部对于这个过程却是有2个阶段:

  1. 创建阶段:
    • 创建作用域链
    • 创建变量、函数和参数
    • 确定this
  2. 激活(代码执行)阶段:
    • 分配 值、函数的引用和解释/执行代码。

可以用一个有3个属性值的对象来表示执行上下文的概念:

executionContextObj = {
    scopeChain: { 
        /* variableObject + all parent execution context's variableObject */ 
    },
variableObject: { 
        /* function arguments / parameters, inner variable and function declarations */ 
    },
this: {
    }
}

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

函数被调用时被执行前executionContextObj对象就被创建了,这就是阶段1(创建阶段)。解释器通过扫描函数的参数及传入的参数、局部函数声明和局部变量声明来创建executionContextObj,得到的结果就是executionContextObj中的variableObject

下面是对这个过程的伪概述:

  1. 找到将调用函数的代码。
  2. 在执行函数之前,创建一个执行上下文。
  3. 进入创建阶段:
  • 初始化作用域链。
  • 创建variableObject:
    • 创建arguments对象,检查参数的上下文,初始化名称、值,并且创建一个引用的副本。
    • 扫描函数声明的上下文:
    • 对于每一个找到的函数,在variableObject中创建一个以函数名为key的属性,这个属性在内存中有一个指向函数的引用指针。
    • 如果函数名已经存在,那么引用指针的值将会被覆盖。
    • 扫描变量声明的上下文:
      • 对于每一个找到的变量声明,在variableObject中创建一个以变量名为key的属性,并初始化一个undefined的值。
      • 如果变量名已经存在,什么也不用做继续扫描。
  • 确定上下文中this的值。
  1. 激活(代码执行)阶段:
    • 在上下文中运行/解释函数代码,并在函数执行时给变量赋值。

来看个例子:

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: { ... }
}

如你所见,创建阶段处理“给属性定义名称但是不赋值,但是形式参数除外(既定义名称又赋值)”,创建阶段完毕后,就开始执行函数了,第2阶段函数执行完毕后如下:

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

一个词——置顶

网上有很多关于JavaScript中置顶的文章,都阐述了置顶即是把函数声明和变量声明都放到函数作用域的顶部。然而并没有文章详细解释为什么会这样,其实用一个新的视角去解释翻译器如何创建activation object是非常简单的。看看下面的例子:

(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已经存在于activation object中了。
  • 声明了两次foo,为什么foo的展示结果是function而不是undefined或string?
    • 即便是foo被定义了两次,在创建阶段我们可以看到函数在activation object中是优先于变量被创建的,如果属性名已经存在,则直接跳过声明。
    • 因此,function foo()的一个引用第一次是在activation object中被创建的,当解释器运行到var foo时,activation object中已经存在了一个foo的名称,因此解释器跳过直接继续执行。
  • 为什么bar的值时undefined?
    • bar是一个变量,只是它的值时一个函数,我们知道变量在创建阶段被初始化一个undefeind的值。

总结

现在希望你对JavaScript是如何解释代码有一个清晰的认识,理解了执行上下文和栈就可以让你清楚地明白为什么你的代码和你开始预期的执行结果不一样。 你是否认为理解解释器内部工作原理需要花费很大的代价呢? 理解了执行上下文是否帮助你写出更好的JavaScript代码呢?

原文

natee avatar Jun 17 '16 07:06 natee