白驹过隙,这篇文章距今已有一年以上的历史。技术发展日新月异,文中的观点或代码很可能过时或失效,请自行甄别:)

前言

对于很多初学者来说,JS的上下文和堆栈等信息对于初学者甚至很多写了很多年js的人来说都可能一知半解甚至一问三不知, 经常容易犯诸如为什么xx变量居然是undefined啊,为什么执行顺序不是我想的那样之类云云. 虽然大多使用了ES6避免了很多JS的坑, 但是了解一些必要的基础知识对于写出高质量的代码来说是有利无害. 恰逢发现这篇写于多年前的文章, 讲解地非常通熟易懂, 于是翻译出来希望能够帮助到阅读此文的你. 当然, 有条件还是建议尽可能阅读原文.(原文链接在文末)

在本文中, 我将会对Javascript的基础之一执行上下文(Execution context) 进行深入讲解. 阅读完本文后, 你应该会对解释器(js)如何工作,为什么一些函数和变量能够在其申明之前就能使用以及他们是如何被赋值会有一个清晰的理解和认识.

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

在js中, 当代码执行的环境非常重要, 其按照以下顺序进行执行:

  1. 全局作用域代码(Global code) - 你代码第一次被执行时的默认环境
  2. 函数作用域代码(Function code) - 当执行到一个函数体内时
  3. Eval作用域代码(Eval code) - 在eval函数内部执行中.

你能在网上读到很多提到作用域的文章, 而本文的目的是让你更清晰明了的理解它, 让我们把代码执行的地址,也就是执行上下文当作环境或者叫做作用域吧. 现在, 闲话少说, 让我们看一个包含了全局(global)和函数/本地上下文的代码吧.

Jietu20190807-224938.jpg

这里没有什么特殊的地方,紫色框住的地方是一个全局的上下文(Global context), 而剩下的绿色,蓝色,黄色框住的地方则是函数上下文(function context). 同时也只会存在一个全局上下文, 你能在你程序的任何上下文中对其进行访问.

你能有任意数量的函数上下文(function context), 同时每个函数的调用都会创建一个新的上下文并拥有私密的作用域. 在这个作用域下的所有声明都不能被该函数作用域外的地方直接访问. 在上面的例子中, 一个函数能够访问当前当前上下文之外的变量, 但是外部却不能访问当前上下文定义的任何变量和函数. 为什么会在这样呢? 这些代码具体是如何执行的呢?

执行上下文堆栈

在浏览器中的js解释器是一个单线程模式的实现. 也就是说,浏览器中同一时间只能做一件事情, 其他任何动作或者事件都会扔到一个叫做执行堆栈(Execution Stack)的队列中. 下面这幅示意图就是其线程堆栈的一个抽象描述.

Jietu20190807-230615.jpg

我们已经知道,当浏览器第一次载入脚本时,默认会进入全局执行上下文(Global execution context)中. 如果你的全局代码调用了一个函数,此时代码的执行流会进入这个被调用的函数,创建一个新的执行上下文,并且将其上下文放置在执行上下文堆栈的最顶端.

如果你在当前函数里面调用另外一个函数, 会执行上述同样的事情. 程序的执行流会进入这个函数, 创建一个新的执行上下文并且将其放置到执行上下文堆栈的最顶端. 浏览器将总是执行位于堆栈最顶端的执行上下文. 一旦执行完成, 该上下文会从堆栈中剔除掉, 接着执行堆栈中的下一个上下文. 下面的这个例子通过一个递归函数来展示程序的执行堆栈.

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

es1.gif

上面的代码只是简单地调用了自身3次, 每次将i的值增加1. 每次当foo函数被调用的时候,将会创建一个新的执行上下文. 当一个上下文被执行完成后,它将会被从堆栈中剔除, 接着执行下一个上下文,直到执行到全局上下文(Global context).

这里有5个执行上下文的关键点需要牢记:

  1. 单线程
  2. 同步执行
  3. 一个全局上下文
  4. 无限个函数上下文
  5. 每次调用一个函数都会新建一个新的执行上下文, 哪怕是调用它自身.

深入执行上下文

好了, 现在我们直到每次一个函数被调用的时候,都会创建一个新的执行上下文. 然后, 在Javascript解释器内部, 每次调用一个执行上下文(Execution context)都会分为两个阶段.

  1. 创建阶段.[当一个函数被调用,但是在执行任何内部代码之前]

    1. 创建一个作用域链(Scope chain)
    2. 创建变量,函数和函数的调用参数.
    3. 确定this的值
  2. 激活/代码执行阶段:

    1. 变量赋值, 函数引用以及代码块的执行.

我们也可以将每一个执行上下文(Execution context)概况为一个对象的三个属性.

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

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

当一个函数在被调用但是真正执行之前, 会创建上面说的executionContextObj. 这是第一步, 创建阶段. 此时, 解释器通过扫描函数的参数或者传入的参数, 内部函数的定义和变量的声明来创建这个对象(executionContextObj). 扫描的结果也就是executionContextObjvariableObject属性.

下面是解释器执行代码的一个伪过程:

  1. 找到调用这个函数的所有代码
  2. 在执行函数之前, 创建执行上下文(execution context)
  3. 进入创建阶段:

    1. 初始化作用域链(scope chain)
    2. 创建变量对象

      1. 创建参数对象,检查参数的上下文,初始化其名称和值并创建一个副本.
      2. 扫描上下文中所有的函数声明

        1. 每个函数被找到的时候, 在variable object中创建一个对应函数名称的属性, 其值是一内存中指向该函数的指针引用.
        2. 如果这个函数已经存在,该指针引用会被覆盖.
      3. 扫描上下文中所有对象的声明

        1. 当一个变量被找到的时候, 在variable object中创建其对应变量名的属性, 并将其值初始化为undefined
        2. 如果这个变量名的key已经存在于variable object中时, 跳过本次扫描.
    3. 确定this在上下文中的值
  4. 激活/代码执行阶段

    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: 函数c的指针地址,
        a: undefined,
        b: undefined
    },
    this: { ... }
}

如你所见, 创建阶段除了函数传入的参数外, 内部定义的变量知识定义其属性的名称但尚未赋值. 一旦创建阶段执行完毕, 程序的执行流程此时才进行函数内部并开始执行. 此时激活/执行阶段看来起如下所示:

fooExecutionContext = {
    scopeChain: { ... },
    variableObject: {
        arguments: {
            0: 22,
            length: 1
        },
        i: 22,
        c: 函数c的指针地址,
        a: 'hello',
        b: 函数privateB的指针地址,
    },
    this: { ... }
}

谈谈变量提升

你能在网上找到很多关于变量提升的文章, 告诉你函数内部声明的变量会被提升到函数作用域的顶部. 但是却鲜有对变量提升原因的解释. 如果用你刚学到的新知识(解释器如何创建activation object)来看的话, 很容易就知道原因. 让我们来看下面这个例子.

​(function() {

    console.log(typeof foo); // 函数指针
    console.log(typeof bar); // 未定义

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

    function foo() {
        return 'hello';
    }

}());​

下面的这些问题我们现在已经能够回答了.

1. 为什么foo变量声明之前我们就能够访问?

如果我们看上面的创建阶段, 我们知道变量在执行阶段(activation / code execution stage)之前就已经被创建了. 所以当我们的函数开始执行的时候,foo变量在activation object中存在了.

2. foo被定义了两次, 为什么foo被当作是函数而不是undefined或者是字符串呢?

  1. 尽管foo被定义了两次, 我们直到在创建阶段(creation stage)中,函数先于变量创建, 如果其属性名存在的话, 接下来的声明会被跳过.
  2. 因此, 函数foo的引用在创建阶段的时候先被创建, 解释器接下来发现了变量foo, 但是由于已经存在了foo的名称,因此什么都没有发生和执行.

3. 为什么bar是undefined?

bar实际上是一个函数变量, 我们变量在创建阶段初始化时的值为undefined.

写在最后的话

希望此时的你已经对Javascript解释器如何执行你的代码有一个清晰的认识了. 理解执行上下文和堆栈能让你明白代码执行的值和你最初想的为何不同背后的原因.

你认为了解解释器内部工作原理是多余的负担还是js的必修课呢? 你觉得了解执行上下文能让你写出更好的JS代码么?

原文: http://davidshariff.com/blog/what-is-the-execution-context-in-javascript/