在上篇《JavaScript闭包之提升》我们提到过,JavaScript引擎是一段一段的分析和执行代码,那么这个’一段一段’是怎么划分的呢?


执行上下文

这就要说到JavaScript的执行上下文(execution context)了, 当JavaScript代码运行时,执行环境很重要,主要分为三种:

  • 全局代码 – 代码默认执行环境。
  • 函数代码 – 函数内部执行环境。
  • eval代码 – eval内部的文本被执行时创建的执行环境。
    前面我们也说过,JavaScript在执行一段代码前,会进行一系列的准备工作,而这里的‘准备工作’,就是创建执行上下文。

执行上下文栈

在JavaScript中,你可以有任意多个执行上下文,而且在JavaScript代码中,执行上下文的个数也会有很多,比如说每次调用一个函数,就会创建一个新的执行上下文,并且还会多一个私有作用域,这么多的执行上下文,又该怎么管理呢?
所以JavaScript引入了执行上下文栈(ECStack)来专门管理执行上下文。
现在我们来看看这样的一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let a = 'Hello World!';

function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}

function second() {
console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

为了模拟执行上下文栈的行为,让我们定义执行上下文栈是一个数组

1
ECStack = []

当JavaScript引擎开始解释执行代码时,首先它会创建一个全局的执行上下文并压入执行上下文栈,而且只有当整个代码执行完毕后,执行上下文栈才会被清空,所有执行上下文栈中总有一个全局上下文。

1
2
3
ECStack = [
globalContext
]

当函数被调用后,就会创建一个函数的执行上下文,并且压入执行上下文栈。

1
2
3
4
5
ECStack = [
secondContext,
firstContext,
globalContext
]

当函数执行完毕后,执行上下文从栈中弹出。

1
2
3
ECStack = [
globalContext
]

JavaScript继续执行其它的代码(如果有的话),但是在执行上下文栈中总有一个globalContext。
为了更加让人容易理解,这里附上执行上下文栈变化过程。

怎么创建执行上下文

到这里,我们已经知道JavaScript如何来管理执行上下文了,现在我们就来说说JavaScript引擎怎么来创建执行上下文的。
对于每个执行上下文,都有三个重要属性

  • 变量对象
  • 作用域链
  • this
    下面我们分别来说说这三个属性的创建。

变量对象

变量对象存储了执行上下文中的变量和函数声明。

变量对象(Variable Object,缩写为VO)是一个与执行上下文相关的特殊对象,它存储着在上下文中声明的以下内容:

  • 变量 (var, 变量声明);
  • 函数声明 (FunctionDeclaration, 缩写为FD);
  • 函数的形参

对于所有的执行上下文来说,变量对象的一些操作(如变量初始化)和行为都是共通的。下面我们来聊聊全局上下文中的变量对象和函数上下文中的变量对象。

全局上下文

在讲全局上下文之前,我们先来了解一个概念–全局对象,在W3C中对全局上下文有这样的定义

全局对象是预定义的对象,作为 JavaScript 的全局函数和全局属性的占位符。通过使用全局对象,可以访问所有其他所有预定义的对象、函数和属性。
在顶层 JavaScript 代码中,可以用关键字 this 引用全局对象。因为全局对象是作用域链的头,这意味着所有非限定性的变量和函数名都会作为该对象的属性来查询。
例如,当JavaScript 代码引用 parseInt() 函数时,它引用的是全局对象的 parseInt 属性。全局对象是作用域链的头,还意味着在顶层 JavaScript 代码中声明的所有变量都将成为全局对象的属性。

如果看不懂的话没关系,下面我们来仔细说说
全局对象(Global object) 是在进入任何执行上下文之前就已经创建了的对象;
这个对象只存在一份,它的属性在程序中任何地方都可以访问,全局对象的生命周期终止于程序退出那一刻。
全局对象初始在初始创建的时候将Math、String、Date等作为自身属性初始化,同时也可以创建额外创建其它对象作为属性。

1
2
3
4
5
6
7
8
9
10
global = {
Math,
String,
Date,
a: undefined
b: reference to function b(){},
...
...
window: global //引用自身
}

当访问全局对象属性时通常会省略前缀,这是因为全局对象时不能直接通过名称访问的,但是我们仍然可以通过this来访问全局对象,也可以递归引用自身,例如上面的window

1
2
3
4
window.a = 20
console.log(a) // 20
console.log(this.a) // 20
String(10); // 就是global.String(10);

花了这么长时间介绍全局对象,其实就是想说:
全局上下文中的变量对象就是全局对象

1
VO(globalContext) === global;

函数上下文

在函数上下文中,变量对象(VO)是不能直接访问的,我们用活动对象(Activation Object, 简称AO)来扮演VO的角色。

1
VO(functionContext) === AO;

活动对象是在进入函数上下文之后被创建的,只有当进入到一个函数上下文中,这个执行上下文的变量对象才会被激活,所以叫做活动对象。
活动对象通过arguments属性初始化的,arguments属性的值是Arguments对象。

执行顺序

之前我们说过,JavaScript执行代码会分为编译和运行两个阶段,执行上下文也是这样。执行上下文代码被分为

  1. 进入执行上下文
  2. 执行代码

进入执行上下文

在进入执行上下文阶段,还没有执行代码,但是变量的定义和提升都是在这时候完成的。
此时VO中包含以下属性:

  • 函数所有的形参
  • 函数声明
  • 变量声明

下面我们来看一个例子:

1
2
3
4
5
6
7
function foo(a, b) {
var c = 3;
function d() {...}
var e = function e() {...}
....
}
foo(2)

在进入执行上下文后,这时候的AO可以表示为:

1
2
3
4
5
6
7
8
9
10
11
AO = {
arguments: {
0: 2;
length: 1
},
d: reference to function d(){},
a: 2,
b: undefined,
c: undefined,
e: undefined
}

在这之后,会进入处理执行上下文的第二阶段–执行代码。

代码执行

在代码执行阶段,会顺序执行代码,该赋值的赋值,该引用的应用,还是以上面的代码为例,当代码执行完:

1
2
3
4
5
6
7
8
9
10
11
AO = {
arguments: {
0: 2,
length: 1
},
d: reference to function d(){},
a: 2,
b: undefined,
c: 3,
e: reference to function e(){},
}

到这里变量对象的创建过程就介绍完了。
注意: 不是使用var声明的变量不会存在于变量对象中,例如:

1
2
3
4
a = 10;
var b = 20;
console.log(a); // 10
console.log(b); // 20

上面例子很容易让人产生误解,虽然同样是声明一个变量,然后给变量赋值,但是此时的变量对象表示为:

1
2
3
VO = {
b: undefined
}

这是因为变量对象是在进入执行上下文后初始化的,这个阶段JavaScript引擎会编译代码,提升函数声明和变量声明,此时不存在变量a,变量a是在代码执行时声明并且赋值的。所以变量对象中不会存在a。

作用域链(Scope Chain)

在JavaScript中,函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供JavaScript引擎访问的内部属性,在这些内部属性中,其中有一个是[[scope]],由 ECMA-262 标准第三版定义,该内部属性包含了函数被创建的作用域中变量对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问。
下面我们来仔细说说作用域链的初始化过程,这与函数的生命周期有关

函数的生命周期

函数的声明周期分为创建和激活两个阶段

函数创建

上面作用域链的定义中指出,作用域链就是所有变量对象的集合。之前我们也说过,函数的作用域是在函数定义的时候就决定的,所以在函数创建的时候作用域链就已经存在了。
举个栗子:

1
2
3
4
5
6
7
8
var a = 10;
function bar() {
...
function foo() {
...
}
}
bar()

函数创建时,各自的[[scope]]为:

1
2
3
4
5
6
7
bar.[[scope]] = {
globalContext.VO
}
foo.[[scope]] = {
barContext.AO,
globalContext.VO
}

函数激活

进入上下文创建VO/AO之后,就会将活动对象添加到作用域链的前端。

1
Scope = [AO].concat([[Scope]]);

最里层的变量对象总是在最前面,这样就解释了在查找变量时从里到外的过程。
我们用一个稍微复杂的例子描述作用域链变化过程:

1
2
3
4
5
6
7
8
9
10
var a = 10;
var b = 20;
function foo(c) {
function bar() {
var d = 30;
console.log(a+b+c+d)
}
bar()
}
foo(6)

执行过程如下:

  1. 全局上下文变量对象:
1
2
3
4
5
globalContext.VO = {
a: 10,
b: 20,
foo: reference to function foo() {...}
}
  1. 执行foo函数,创建foo函数执行上下文,foo函数执行上下文被压入执行上下文栈
1
2
3
4
ECStack = [
globalContext,
fooContext
]
  1. foo函数不会立即执行,需要做一系列准备工作,首先利用
    [[scope]]属性创建作用域链
1
2
3
fooContext = {
Scope: foo.[[scope]]
}
  1. 用arguments对象创建活动对象,随后初始化活动对象
1
2
3
4
5
6
7
8
9
fooContext = {
AO: {
arguments: {
0: 6,
length: 1
},
bar: reference to function bar() {...}
}
}
  1. 将活动对象压入foo作用域链顶端
1
2
3
4
5
6
7
8
9
10
fooContext = {
AO: {
arguments: {
0: 6,
length: 1
},
bar: reference to function bar() {...}
}
Scope: [AO, globalContext.VO]
}
  1. 执行bar函数,创建bar函数执行上下文,将bar函数执行上下文压入执行上下文栈。
1
2
3
4
5
ECStack = [
barContext,
fooContext,
globalContext
]
  1. bar函数执行上下文初始化,步骤和初始化foo函数执行上下文一样:
    1. 复制[[scope]]属性创建作用域链
    2. 利用arguments创建活动对象
    3. 初始化活动对象
    4. 将活动对象压入作用域链顶端
1
2
3
4
5
6
7
8
9
barContext = {
AO: {
arguments: {
length: 0
},
d: undefined
},
Scope: [AO, fooContext.AO, globalContext.VO]
}
  1. 函数开始执行,完成变量的赋值,并沿着作用域查找所需变量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-'a'
--- barContext.AO // not found
--- fooContext.AO // not found
--- globalContext.VO // found -> 10

-'b'
--- barContext.AO // not found
--- fooContext.AO // not found
--- globalContext.VO // found -> 20

-'c'
--- barContext.AO // not found
--- fooContext.AO // found -> 6

-'d'
--- barContext.AO //found -> 30
  1. 函数执行完毕,函数执行上下文依次从执行上下文栈中弹出。
1
2
3
ECStack = [
globalContext
]

以上就是作用域链的创建和初始化过程,需要重点理解,不过在闭包中我们也会重点讲解,理解了作用域链闭包就容易多了。

this

关于this,可以参考之前的博客《理解JavaScript中的this、call、apply、bind》,由于篇幅有限,这里就不做累述了。

示例

说完了执行上下文,我们再分析一段代码加深理解:

1
2
3
4
5
6
7
8
9
var scope = 'global scope'
function checkScope() {
var scope = 'local scope'
function foo () {
return scope
}
return foo();
}
checkScope()

以上代码来自《JavaScript权威指南》,借用这个例子我们来分析它的执行过程

  1. 执行全局代码,创建全局执行上下文。
1
2
3
4
5
6
7
8
9
10
11
globalContext = {
VO: {
arguments: {
length: 0
},
scope: undefined,
checkScope: reference to function checkScope(){...}
},
Scope: globalContext.VO,
this: undefined
}
  1. 全局上下文压入执行上下文栈
1
2
3
ECStack = [
globalContext
]
  1. 全局上下文初始化
1
2
3
4
5
6
7
8
9
10
11
globalContext = {
VO: {
arguments: {
length: 0
},
scope: 'global scope',
checkScope: reference to function checkScope(){...}
},
Scope: globalContext.VO,
this: window
}
  1. 初始化的同时,checkScope函数上下文被创建
1
2
3
4
5
6
7
8
9
10
11
checkScopeContext = {
AO: {
arguments:{
length: 0
},
scope: undefined,
foo: reference to function foo() {...}
},
Scope: [AO, globalContext.VO],
this: undefined
}
  1. checkScope函数执行上下文被压入执行上下文栈
1
2
3
4
ECStack = [
checkScopeContext,
globalContext
]
  1. checkScope执行上下文初始化
1
2
3
4
5
6
7
8
9
10
11
checkScopeContext = {
AO: {
arguments:{
length: 0
},
scope: 'local scope,
foo: reference to function foo() {...}
},
Scope: [AO, globalContext.VO],
this: window
}
  1. checkScope函数上下文初始化的同时,foo函数执行上下文被创建
1
2
3
4
5
6
7
8
9
fooContext = {
AO: {
arguments: {
length: 0
}
},
Scope: [AO, checkScopeContext.AO, globalContext.VO],
this: undefined
}
  1. foo函数执行上下文被压入执行栈
1
2
3
4
5
ECStack = [
fooContext,
checkScopeContext,
globalContext
]
  1. foo函数执行上下文初始化
1
2
3
4
5
6
7
8
9
fooContext = {
AO: {
arguments: {
length: 0
}
},
Scope: [AO, checkScopeContext.AO, globalContext.VO],
this: checkScope
}
  1. 执行foo函数,查找变量scope
1
2
3
-'scope'
--- fooContext.AO // not found
--- checkScope.AO // found -> 'local scope'
  1. foo函数执行完毕,函数执行上下文依次从执行栈中弹出
1
2
3
ECStack = [
globalContext
]

以上分析过程是这一系列文章的重中之重,希望小伙伴们一定要弄懂。


本篇文章主要谈了下函数中一个非常重要的概念-执行上下文,由执行上下文又延伸到了执行上下文的管理,以及执行上下文的创建。下篇文章我们就来说说闭包,拿下这个‘巨无霸’。

本文完

参考文章:
深入理解JavaScript系列(12):变量对象(Variable Object)
深入理解JavaScript系列(14):作用域链(Scope Chain)