# 变量对象

JavaScript 执行一段可执行代码时,会创建对应的执行上下文,那么每个执行上下文中都有哪些内容?

执行上下文 3 个重要的属性:

  • 变量对象(Variable Object,VO)
  • 作用域链(Scope Chain)
  • this

这节主讲 变量对象

# 变量对象

变量对象 是与执行上下文相关的 数据作用域,存储了上下文中定义的 变量函数声明

变量对象有全局上下文中变量对象和函数上下文下的变量对象之分。

因为不同执行上下文下的变量对象稍有不同,所以我们来聊聊全局上下文下的变量对象和函数上下文下的变量对象。

# 全局上下文的变量对象

全局上下文的变量对象就是全局对象(GO),比如 web 浏览器中,全局对象就有 window 属性指向自身,window 对象。全局对象是由 Object 构造函数实例化的一个对象。预定义一大堆函数和属性。作为全局变量的宿主。

全局上下文的变量对象就是全局对象,web 浏览器是 window、self 或者 frames ,node 中是 global, Web Workers 中是 self,不同环境下的统一标准的全局变量是 globalThis。

在松散模式下,可以在函数中返回 this 来获取全局对象,但是在严格模式和模块环境下,this 会返回 undefined。

# 函数上下文的变量对象

函数上下文中用 活动对象(Activation Object) 表示变量对象。活动对象在进入函数上下文时被创建,通过函数的 arguments 属性初始化。分析一个函数上下文的 AO 对象很重要。

活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问,只有当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,才叫 activation object。

活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化。arguments 属性值是 Arguments 对象。即,调用函数时,会为其创建一个 Arguments 对象,并自动初始化局部变量 arguments,指代该 Arguments 对象。所有作为参数传入的值都会成为 Arguments 对象的数组元素。

# 执行过程

执行上下文生命周期 3 阶段:

  • (1)进入执行上下文(分析)
  • (2)代码执行(执行)
  • (3)回收阶段(GC)

# 进入执行上下文

进入执行上下文时,还没有执行代码。进入执行上下文时,初始化的规则如下,从上到下就是一种顺序,变量对象会包括:

  • 函数的所有形参 (如果是函数上下文)

    • 由名称和对应值组成的一个变量对象的属性被创建
    • 没有实参,属性值设为 undefined
  • 函数声明

    • 由名称和对应值(函数对象(function-object))组成一个变量对象的属性被创建
    • 如果变量对象已经存在相同名称的属性,则完全替换这个属性
  • 变量声明

    • 由名称和对应值(undefined)组成一个变量对象的属性被创建
    • 如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
console.log(foo);

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

var foo = 1;
1
2
3
4
5
6
7

会打印函数,而不是 undefined

这是因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

# 代码执行

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值

举例:

function foo(a) {
  var b = 2;
  function c() {}
  var d = function() {};

  b = 3;

}

foo(1);

在进入执行上下文后,这时候的 AO 是:
AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: undefined,
    c: reference to function c(){},
    d: undefined
}

当代码执行完后,这时候的 AO 是:
AO = {
    arguments: {
        0: 1,
        length: 1
    },
    a: 1,
    b: 3,
    c: reference to function c(){},
    d: reference to FunctionExpression "d"
}
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

VO 与 AO

未进入执行阶段之前,变量对象(VO)中的属性都不能访问!但是进入执行阶段之后,变量对象(VO)转变为了活动对象(AO),里面的属性都能被访问了,然后开始进行执行阶段的操作。它们其实都是同一个对象,只是处于执行上下文的不同生命周期。

变量对象的创建

变量对象的创建,依次经历了以下几个过程:

  • 建立 arguments 对象。检查当前上下文中的参数,建立该对象下的属性与属性值(全局环境下没有这步)。
  • 检查当前上下文的函数声明,也就是使用 function 关键字声明的函数。在变量对象中以函数名建立一个属性,属性值为指向该函数所在内存地址的引用。如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。
  • 检查当前上下文中的变量声明,每找到一个变量声明,就在变量对象中以变量名建立一个属性,属性值 undefined。如果该变量名的属性已经存在,为了防止同名的函数被修改为 undefined,则会直接跳过,原属性值不会被修改

块级作用域中的函数声明:

function test() {
  console.log("out");
}
(function () {
  if (false) {
    // ES5 规定,函数在块中声明,是非法的
    function test() {
      console.log("in");
    }
  }
  test();
})();
// 为什么这里输出的结果不是out,而是直接报错呢?
1
2
3
4
5
6
7
8
9
10
11
12
13

ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。但是,浏览器没有遵守这个规定,为了兼容以前的旧代码,还是支持在块级作用域之中声明函数,因此上面两种情况实际都能运行,不会报错。报错可能是浏览器版本问题,不同 JS 引擎实现可能有所不同。

ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于 let,在块级作用域之外不可引用。

总的来说,应遵循这一原则:函数声明请在全局或函数作用域内声明,若在块内,请使用函数表达式。在 ESLint 中专门有一个规则去检查这种情况:no-inner-declarations

参考ECMAScript 6 入门 - let与const块级作用域与函数声明 (opens new window)

# 总结

  1. 全局上下文的变量对象初始化是 全局对象
  2. 函数上下文的变量对象初始化 只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值。
  4. 在代码执行阶段,会 再次修改变量对象的属性值
  5. 在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性
  6. AO = VO + function parameters + arguments
  7. AO 实际上是包含了 VO 的。因为除了 VO 之外,AO 还包含函数的 parameters,以及 arguments 这个特殊对象。也就是说 AO 的确是在进入到执行阶段的时候被激活,但是激活的除了 VO 之外,还包括函数执行时传入的参数和 arguments 这个特殊对象。
  8. 同一作用域下,函数提升比变量提升得更靠前
  9. 一个执行上下文的生命周期可以分为两个阶段:
    • 创建阶段:在这个阶段中,执行上下文会分别创建变量对象,建立作用域链,以及确定 this 的指向。
    • 代码执行阶段:创建完成之后,就会开始执行代码,这个时候,会完成变量赋值,函数引用,以及执行其他代码。
  10. 进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。 即,函数提升优先级高于变量提升,且不会被同名变量声明时覆盖,但是会被同名变量赋值后覆盖。