执行上下文

执行上下文是 ECMAScript 标准中定义的抽象概念,用来记录代码运行时的环境。每一次代码运行都至少会生成一个执行上下文。代码都是在执行上下文中运行的。

当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。一般情况下,有这么三种情况会创建执行上下文:

  • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份。
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。
  • 当使用eval函数的时候,eval的代码也会被编译,并创建执行上下文。

需要注意的是,在任意时刻只有一个执行上下文处于运行中,这也是常说 JavaScript 是单线程的原因。

执行上下文里包含的内容主要有:

  • 变量环境:主要维护编译阶段通过var声明的变量和函数声明
  • 词法环境:主要维护执行阶段块级作用域产生的变量和环境

调用栈(执行上下文栈)

执行栈Execution Context Stack是用来管理执行期间创建的所有执行上下文的数据结构,它是一个 LIFO (后进先出)的栈,它也是我们熟知的 JS 程序运行过程中的调用栈。

程序开始运行时,会先创建一个全局执行上下文并压入到执行栈中,之后每当有函数被调用,都会创建一个新的函数执行上下文并压入栈内。当前运行的执行上下文位于栈顶,当它内部的代码执行完毕之后出栈,然后将其下的执行上下文设置为当前运行的执行上下文。

变量环境

词法环境

词法环境,Lexical Environmnet

ECMAScript 6 中明确定义:词法环境是用来定义标识符和具体变量之间的关系以及基于词法嵌套结构的函数,它包括环境记录和指向外部词法环境的引用(有可能指向null)。

通常词法环境跟一些具体语法结构有关,比如

  • 函数声明(Function Declaration)
  • 块语句声明(Block Statement)
  • Try/Catch 语句,

这些代码在执行的时候都会生成一个新的词法环境。

具体解读如下:

  • 用来定义标识符的值:词法环境的目的就是管理代码中的数据。也就是说,它给标识符赋值,让标识符变得有意义。词法环境通过环境记录将标识符和具体的值联系在一起
  • 词法环境包含环境记录:环境记录完美地记录了词法环境中所有标识符和具体值之间的联系,并且每个词法环境都有自己的环境记录
  • 词法嵌套结构:内部环境引用包含它的外部环境,外部环境还可以有自己的外部环境。因此,一个环境可以作为多个内部环境的外部环境。全局环境是唯一一个没有外部环境的环境。

用伪代码抽象表示为:

LexicalEnviroment = {
    EnvironmnetRecord: {
        // 标识符的赋值操作
    },

    // 外部环境的引用
    outer: <>
}
1
2
3
4
5
6
7
8

总而言之,每个执行上下文都对应一个词法环境。这个词法环境中记录着变量和它对应的值,还有指向外部环境的引用。它可以是全局环境,模块环境(包括模块之间的引用关系),或函数环境(因函数调用而创建的环境)。

作用域

作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

ES6 开始,增加了块级作用域

作用域链

在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。而outer这个执行上下文里也会指向它自己的outer执行上下文,如此便形成了作用域链。

作用域链在函数创建的时候就保存起来了,也就是说,它是由源代码的位置静态定义的。(这就是我们所熟悉的词法作用域Lexical Scope

给作用于链顶端添加活动对象的方法

  • 新建闭包
  • catch
  • with

词法作用域

在 JavaScript 执行过程中,其作用域链是由词法作用域决定的。

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

示例代码:

function fn () {
    function innerFn() {
        alert(name);
    };
    var name = "inner";
    return innerFn;
}
var name = "out";
var outerFn = fn();
outerFn();   //  "inner"
1
2
3
4
5
6
7
8
9
10

动态作用域 vs 静态作用域

PS: JavaScript 采用的是词法作用域,即静态作用域。

动态作用域的实现是基于栈结构,局部变量和函数参数都保存在栈里。因此,变量的具体值是由运行时的当前的栈顶的执行上下文决定的。

而静态作用域是指变量在创建的时候就决定了它的值,也就是说,源代码的位置决定了变量的值。

可通过以下示例帮助理解。

var myVar = 100;

function foo() {
    console.log(myVar);
}

foo(); // 静态作用域:100;动态作用域:100

(function () {
    var myVar = 50;
    foo(); // 静态作用域:100;动态作用域:50
})();
1
2
3
4
5
6
7
8
9
10
11
12

动态作用域经常带来不确定性,它不能确定变量的值到底来自哪个作用域的。

闭包

闭包是指那些能够访问自由变量(既不是本地定义也不可作为参数的那些变量)的函数。换句话说,这些函数可以“记住”它被创建时的环境。定义在封闭函数中,即使在该封闭函数执行完之后仍然能被访问到。

原理

以上关于执行上下文、词法环境、作用域链的知识,是理解闭包所有的全部知识点:

每个函数都有一个包含词法环境的执行上下文,它的词法环境确定了函数内的变量赋值以及对外部环境的引用。对外部环境的引用使得所有的内部函数能访问到外部作用域的所有变量,无论这些内部函数是在它创建时的作用域内调用还是作用域外调用。

看上去函数“记住”了外部环境,但事实上是这个函数有个指向外部环境的引用。

为了实现闭包,我们不能用动态作用域的动态堆栈来存储变量。如果是这样,当函数返回时,变量就必须出栈,而不再存在,这与最初闭包的定义是矛盾的。事实上,外部环境的闭包数据被存在了“堆” 中,这样才使得即使函数返回之后内部的变量仍然一直存在(即使它的执行上下文已经出栈)。

function mysteriousCalculator(a, b) {
    var mysteriousVariable = 3;
    return {
        add: function () {
            var result = a + b + mysteriousVariable;
            return toFixedTwoPlaces(result);
        },
        substract: function () {
            var result = a - b -mysteriousVariable;
            return toFixedTwoPlaces(result);
        }
    };
}

function toFixedTwoPlaces(value) {
    return value.toFixed(2);
}

var myCalculator = mysteriousCalculator(10, 2);
myCalculator.add(); // 15
myCalculator.substract(); // 5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

基于我们对运行机制的理解,上述例子的环境定义可以抽象为如下(伪代码):

GlobalEnviroment = {
    EnvironmnetRecord: {
        // 内置标识符
        Array: '<func>',
        Object: '<func>',
        // 等等...

        // 自定义标识符
        mysteriousCalculator: '<func>',
        toFixedTwoPlaces: '<func>'
    },
    outer: null
};

mysteriousCalculatorEnvironment = {
    EnvironmnetRecord: {
        a: 10,
        b: 2,
        mysteriousVariable: 3
    },
    outer: GlobalEnviroment
};

addEnvironment = {
    EnvironmnetRecord: {
        result: 15
    },
    outer: mysteriousCalculatorEnvironment
};

substractEnvironment = {
    EnvironmnetRecord: {
        result: 5
    },
    outer: mysteriousCalculatorEnvironment
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

优缺点

优点

  • 保护内部变量,加强封装性
  • 减少不必要的全局变量

缺点

  • 闭包会携带包含它的函数的作用域,因此会比其他函数占用更多的内存
  • 过度使用闭包可能会导致内存占用过多。解决方法是,在退出函数之前,将不使用的局部变量全部删除。
  • 闭包会在父函数外部,改变父函数内部变量的值

理论角度/实践角度讨论

  • 从理论角度来说,所有的函数都是闭包:因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外层的作用域。
  • 从实践角度,以下函数才算是闭包:
    • 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
    • 在代码中引用了自由变量