在这往篇译文中,将会深入探讨 JS 最基础的一部分:Execution Context ,目的是理解 JS 引擎的运行,为什么一些函数和变量可以在声明之前就可以使用,并且他们的值是怎么确定下来的。
什么是运行上下文?
js 代码执行的时候,运行环境尤为重要,一般被概括为以下三种:
- 全局环境 : 代码第一次运行的默认环境
- 函数环境 : 执行到函数体的时候
- Eval 环境 : eval 函数所执行的文本
我们把术语 “运行上下文” 看成是当前代码所在的 “环境/作用域”。
下面看一个包含全局和函数上下文的例子
// global context
var sayHello = 'hello';
function person(){ // execution context
var first = 'David',
last = 'Shariff';
function firstName(){// execution context
return first;
}
function lastName(){// execution context
return last;
}
alert(sayHello+firstName()+''+lastName());
}
这里有一个全局上下文和三个不同的函数上下文。
其中全局上下文只能有一个,但函数上下文可以有任意多个,并可以访问全局上下文。
对于函数上下文来说,伴随着每一次函数调用,一个新的上下文就会被创建,同时一个私有的作用域也会被创建,这个私有作用域不能被外部直接访问。
在上图的例子中,一个函数可以访问当前上下文环境外部声明的变量,但是外部的上下文却不能访问此函数内部声明的变量和方法。为什么会这样?这些代码具体是怎么被解析的?
运行上下文栈
浏览器中的 JS 引擎是单线程的,也就是说它在某一时刻只能干一件事,其它的操作和事件需要以队列的形式呈现,这个队列就叫做运行栈或者执行栈。下图是一个单线程栈的抽象表示。
当浏览器第一次加载代码的时候,默认情况下就处于全局运行上下文,当有函数被调用的时,一个新的运行上下文就被创建并推入运行栈的栈顶。
如果在当前函数中调用另一个函数,同样的道理,执行流就会进入被调用的函数,新建运行上下文并推入执行栈。浏览器总是运行栈顶的上下文,一旦当前运行上下文运行完毕,它就会被推出栈顶,向下方的运行上下文交出控制权。一个递归调用的例子如下:
(function foo(i){
if(i===3){
return
}else{
foo(++i);
}
}(0))
上面的函数自己调了自己 3 次,每次 foo 方法被调用都会创建一个新的运行上下文,一旦当前的上下文运行完毕,它就会被弹出,然后控制权被转到它下面的运行上下文,直到再次回到全局运行上下文。
有 5 个需要注意的关键点:
- 单线程
- 同步执行
- 1 个全局上下文
- 不限个数的函数上下文
- 每次函数调用都创建一个新的运行上下文,不管是不是自己调用自己。
运行上下文详解
既然已经知道每次函数调用都会新建运行上下文,然而在引擎内部,从每次函数调用到运行上下文的创建成功需要经历两个步骤:
- 创建阶段 【函数被调用之后,运行之前】
- 初始化作用域链
- 创建需要的变量、函数和参数
- 确定 this 指向
- 代码运行阶段
- 对变量和函数赋值,解析并执行代码
可以用下面带有3个属性的对象来对运行上下文做一下概念上的描述
executionContextObj = {
'scopeChain':{/*variableObject + all parent execution contextis variableObject*/},
'variableObject':{/* function arguments/parameters,inner varibale and function declarations*/},
'this':{}
}
VariableObject 变量对象
在函数被调用之后的阶段一,也就是创建阶段,引擎遍历传入函数的参数,函数内其它函数、函数变量的声明,遍历的结果用于生成 executionContextObj 对象中的 variableObject
大致的遍历过程如下:
- 创建 arguments 对象, 初始化函数参数并创建一个引用副本。
- 遍历函数体中的函数声明
- 每次发现函数声明后都会在 variable object 中添加一个属性,属性名是函数名,属性的值是函数的引用
- 如果函数名称已经存在于 variable object 中,则函数的引用就会被后来者覆盖
- 遍历函数体中的变量声明
- 每次发现变量声明都会在 variable object 中添加属性,属性名是变量名,属性值是 undefined。
- 如果变量名已经存在于 variable object 中,直接忽略并继续遍历。
再看一个例子:
function foo(i){
var a = 'hello';
var b = function privateB(){
};
function c(){
}
}
如果执行 foo(22), 那么创建阶段的运行上下文就会是这样:
fooExecutionContext = {
scopeChain = {...},
variableObject:{
arguments:{
0:22,
length:1
},
i:22,
c:pointer to function c(),
a:undefined,
b:undefined
},
this:{}
}
可以看出来, 创建阶段主要是处理属性名的定义,且除了arguments 之外并不进行任何赋值操作。
当创建阶段完成后,执行流就进入函数内部开始运行阶段,运行阶段完成后的 运行上下文 如果下所示:
fooExecutionContext = {
scopeChain = {...},
variableObject:{
arguments:{
0:22,
length:1
},
i:22,
c:pointer to function c(),
a:'hello',
b:pointer to function privateB()
},
this:{}
}
变量提升
在很多地方都可以看到变量提升的概念,意思就是变量和函数的声明被提升到了函数作用域的顶部,但很少看到对此现象的详细解释。其实很容易解释这个现象,以下面的代码为例:
(function() {
console.log(typeof foo); // function pointer
console.log(typeof bar); // undefined
var foo = 'hello',
bar = function() {
return 'world';
};
function foo() {
return 'hello';
}
}());
-
为什么在 foo 函数声明前就可以被访问到?
如果回头看看在创建阶段js引擎做的事情,就会明白变量在代码运行阶段之前就已经被创建了,所以当代码开始运行时,foo 已经被定义了。
-
foo 被定义了两次,为什么最后它是函数而不是 undefined 或者 string?
即使 foo 被定义了两次,从创建阶可知:函数先于变量被遍历,并且对于变量来说如果名称已经存在于 variableObject 中 ,则直接忽略。 -
那为什么 bar 是undefined 呢?
bar 其实是一个拥有函数赋值的变量,并且变量是在创建阶段被创建,同时被初始化为 undefined