JavaScript中的执行上下文和堆栈
什么是执行上下文(execution context)和堆栈(stack)?
在本文中,我会深入分析Javascript中最基本的一个部分,执行上下文。读完本文,你会明白解析器是怎么去解析的,为什么有些函数和变量在没有申明之前就可以被调用。
什么是执行上下文?
Javascript代码在执行过程中,所处的执行环境是很重要的,这些执行环境可以分为以下几类:
- 全局代码(Global code) - 代码开始执行的默认环境
- 函数代码(Function code) - 函数体内部代码的执行环境
- Eval代码(Eval code) - Eval函数内部代码的执行环境
这里的执行环境就是作用域,以下我们看一个列子:
这里有1个紫色的全局上下文(Global Context), 有1个绿色,1个蓝色和1个黄色组成的函数上下文(Function Context). 全局上下文永远只有1个,程序里面任何一个上下文都可以访问全局上下文。
函数上下文可以有很多,每次调用函数,就会创建一个新的函数上下文,并且这个新创建的函数上下文是私有的,外部无法直接访问。在上面的例子中,一个函数可以直接访问那些声明在当前上下文之外的变量,外部上下文无法直接访问申明在内部上下文的函数和变量。为什么会这样呢?
执行上下文堆栈
Javascript解析器在浏览器中是单线程的。也就是说,浏览器同一时间只能做一件事情,其它的事件和动作会排队在执行堆栈(Execution Stack)中。下面是一个单线程堆栈图:
当浏览器载入Javascript脚本的时候,它默认先进入全局执行上下文(Global execution context)。如果全局代码里面有调用函数,那么执行流程就会进入到函数内部,创建一个相应的执行上下文,并推入到执行堆栈的最顶端。
如果当前函数里面调用另外一个函数,那同样的,执行流程会进入到内部函数里面,创建一个相应的执行上下文,并把它推入到执行堆栈的最顶端。浏览器永远只会执行堆栈里面最上面的当前执行上下文。一旦函数执行完毕当前上下文,它会从堆栈最顶端弹出,并将控制权交给下面一个上下文。下面是一个递归函数及其堆栈:
(function foo(i) {
if (i === 3) {
return;
}
else {
foo(++i);
}
}(0));
这个函数通过++i调用自己3次,每次调用foo函数,就会创建一个新的执行上下文。一旦上下文执行完毕,它会从堆栈中弹出,并将控制权交给下面一个上下文,如此循环直到回到全局上下文。
5个执行堆栈的要点:
- 单线程
- 同步执行
- 全局上下文只有一个
- 函数上下文没有个数限制
- 每次调用函数(包括自调用),都会创建一个执行上下文
执行上下文详解
在Javascript解析器中,每次调用执行上下文,都会经过2个阶段:
1. 创建阶段(Creation Stage) [次阶段处在函数被调用,但函数内部代码还未被执行的时候]
- 创建作用域链
- 创建变量,函数和参数
- 检测关键字this的值
2. 激活/代码执行阶段(Activation/Code Execution Stage)
- 赋值,引用函数,解析/执行代码
可以用一个带有3个property的object来表示执行上下文:
executionContextObj = {
scopeChain: { /* variableObject + all parent execution context's variableObject */ },
variableObject: { /* function arguments / parameters, inner variable and function declarations */ },
this: {}
}
Activation / Object Variable [AO/VO]
这里executionContextObj是在创建阶段生成的,也就是在函数被调用,但是函数内部代码还未被执行的时候。解析器通过扫描传入函数的参数,函数内部声明的变量和函数来生成executionContextObj里面的variableObject。
下面我们总结一下解析器是如何解析运行代码的:
- 找到调用函数的代码
- 在执行该函数代码之前,创建执行上下文
- 进入创建阶段
- 初始化作用域链
- 创建变量对象(variable Object)
- 创建参数对象(arguments object), 检查传入上下文的参数,初始化名值对,并创建一个引用副本
- 扫描上下文中的函数声明
- 每次找到函数,都会在变量对象中创建一个与函数名称一样的property,并让这个property指向该函数在内存中的位置
- 如果该函数名称已经存在了,那就覆盖指针,使property指向新函数在内存中的位置
- 扫描上下文中的变量声明
- 每次找到变量,都会在变量对象中创建一个与变量名一样的property, 并初始化默认值undefined
- 如果该变量已经存在,则跳过
- 检测this关键字在当前上下文中的值
- 进入激活/代码执行阶段
让我们看下面一个列子:
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: { ... }
}
可以看到,除了形参 i 以外,创建阶段只是定义了变量对象的property,并没有给他们赋值。在创建阶段之后,程序流程进入激活/代码执行阶段,下面是函数执行完毕之后的样子:
fooExecutionContext = {
scopeChain: { ... },
variableObject: {
arguments: {
0: 22,
length: 1
},
i: 22,
c: pointer to function c()
a: 'hello',
b: pointer to function privateB()
},
this: { ... }
}
谈谈Hoisting
网上有很多关于Javascript中Hoisting的定义,变量和函数声明都会被吊(hoisted)到函数作用域的最前端。但是没有解释为什么会这样,解析器是如何创建激活对象(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已经在激活对象中定义好了。
- foo被声明了2次,为什么foo被解析成function而不是undefined或者string?
- 尽管foo被声明了2次,但从创建阶段我们知道,函数会先于变量被创建出来。所以foo先以函数的形式存在于激活对象中。而在声明变量foo的时候,该名字已经在激活对象中存在了,就跳过了。所以最后foo是function。
- 为什么bar是undefined?
- bar其实是一个变量,赋值为一个函数。在创建阶段,bar被创建出来并赋值为undefined。
总结
希望现在你能领会Javascript解析器是如何解析代码的,从执行上下文和堆栈的角度去理解代码的运行结果。