英语原文:地址
本文将会深入讲解Javascript中最重要的概念的之一,执行环境(Execution Context)
。读完本文你将对解析器所做的事情有更深的理解,也知道为什么有些变量/函数可以先执行后声明以及它们的值是如何确定的。
执行环境(Execution Context)的含义
JavaScript代码执行的环境非常重要,而执行环境可以归纳为以下三种:
- 全局代码(Global Code) - 代码首次执行时的默认环境
- 函数代码(Function Code) - 程序执行到函数体内时
- Eval函数代码(Eval Code) - 内置Eval函数计算的字符串
网上很多资料都提到了作用域(scope)
这个词,为了便于理解在此我们暂且把执行环境(execution context)
理解为代码运行时所处的环境(environment / scope )
。闲话少叙,先来看一个同时包含全局环境(global context)和函数/局部环境(funtion / local context)的代码范例:
类似的代码很常见。上图中全局环境(global context)用紫色框表示,三个函数环境(function context)分别用蓝橙绿框表示。在JavaScript代码中只能有一个全局环境,它可以被其他执行环境访问。
函数环境(function context)则可以有任意多个。函数的每次调用都会新建一个新的执行环境,当前函数环境外的代码无法访问在该函数内声明的变量或函数。上图的代码范例中,函数可以访问其环境外声明的变量(父环境中的变量),但是环境外的代码则无法访问该函数体内声明的变量或函数。这是为何?上图的代码又是如何运行的呢?
执行环境栈(Execution Context Stack)
浏览器中的JavaScript解析器是单线程的。浏览器一次只能执行一个任务,其他操作或事件只能在一个叫执行栈(Execution Stack)
的地方排队等待执行。下图为单线程栈的抽象表示:
正如上文所说,浏览器加载代码时会默认进入全局执行环境(global execution context)
。在全局环境下如果调用函数,执行流就会进入该函数体内,创建一个新的执行环境并将其置于执行栈的顶部。
如果在该函数体内再调用一个函数,同样的事情会再发生一次。代码执行流会进入最里的函数体内,创建新的执行环境并将其推入执行栈。浏览器总会先运行执行栈顶部的环境里的代码,一旦代码执行完毕,该环境就会被推出并将控制权交给下一个执行环境。下方的范例代码展示了一个递归函数及其执行栈:
(function foo(i) {
if (i === 3) {
return;
}
else {
foo(++i);
}
}(0));
上述代码调用了自己3次,每次将参数递增1。每次函数foo被调用,就会创建一个新的执行环境。每次环境中的代码执行完毕,其就会被从执行栈中推出并将控制权交给栈中下一个环境,直至控制权回到全局环境为止。
关于执行栈有5点需要记牢:
- 单线程
- 同步执行
- 只能有1个全局环境
- 任意多个函数环境
- 每次调用函数就会新建一个环境,即使函数调用自身也是如此
执行环境详解
我们已经知道每次调用函数都会新建一个执行环境。但是在JavaScript解析器内部,调用执行环境要经历两个阶段:
第一阶段: 创建阶段(Creation Stage) - 当函数被调用,但尚未执行函数体内的代码时:
- 创建作用域链(Scope Chain)
- 创建变量、函数和参数
- 确定
this
的值
第二阶段: 活动 / 代码执行阶段(Activation / Code Execution Stage) :
- 将变量和引用分配给函数并解析/执行代码
可以用带有三个属性的对象来概念化地表示执行环境:
executionContextObj = {
'scopeChain': { /* 变量对象 + 所有父级执行环境的变量对象(variableObject + all parent execution context's variableObject) */ },
'variableObject': { /*函数形参实参、局部变量和函数声明(function arguments / parameters, inner variable and function declarations) */ },
'this': {}
}
活动/变量对象(Activation / Variable Object [AO/VO])
译者注:关于activation object和variable object的同异,可以查看一下两个链接的内容:
What is an Activation object in JavaScript?
Activation and Variable Object in JavaScript?
在函数被调用但尚未执行前,executionContextObj
对象会被创建,也就是所谓的名为创建阶段
的第一阶段。此时解析器会扫描被传参的函数、局部函数和变量的声明,然后创建executionContextObj
对象。扫描的结果就成为了executionContextObj
对象里的variableObject
属性的内容。
下面是对JavaScript解析器如何解析代码的概览:
- 找到调用函数的代码段
- 在执行函数体内代码前,创建执行环境
- 进入
创建阶段
- 初始化作用域链
- 创建变量对象
- 创建
参数对象(arguments object)
,在环境里查找参数并初始化参数名值,创建引用的拷贝。 - 在环境里查找函数声明
- 每找到一个函数,就在变量对象里创建一个同名属性,属性中有引用指针指向该函数在内存中的位置
- 如果在变量对象中该函数名已经存在,引用指针的值将会被覆盖
- 在环境里查找变量声明
- 每找到一个变量,就在变量对象里创建一个同名属性,并将该变量初始化为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: { ... }
}
如你所见,除了函数形参,创建阶段
只定义变量对象的属性,而不会对其赋值。创建阶段
结束后,执行流进入函数体,函数执行完后活动/代码执行阶段
的代码如下:
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)进行了定义,对其解释是变量和函数声明被提升到了函数作用域的顶部。然后并没有人详细解释为什么会发生变量提升。用本文提出的解析器如何创建活动对象(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';
}
}());
我们可以回答下列问题:
1. 为什么在foo被声明前就可以访问它?
我们知道变量在创建阶段
已经被创建,在活动 / 代码执行阶段
之前。所以当函数体开始被执行时,变量foo已经在活动对象
中被定义。
2. foo被声明了两次,为什么它的值打印出来是“function”而不是“undefined”或者“string”呢?
尽管foo被声明了两次,但在创建阶段
时活动对象
中函数名对应的属性先于变量被创建。而对于变量来说,如果在活动对象
中对应的属性名已经存在,则不会再进行声明。因此在活动对象
中函数foo()对应的属性先被创建。而解析器碰到变量foo时,由于属性foo已经存在,所以它会略过不作处理。
3. 为什么bar打印出来是undefined?
bar实际上是一个值为函数返回值的变量。在创建阶段
变量已经被创建,但其值被初始化为undefined。
总结
希望现在你已经掌握了解析器是如何解析你的代码的。理解执行环境和执行环境栈有助于理解为何代码的执行结果和你预期的不同。
你觉得解析器内部工作原理是学习JavaScriptb必须掌握的知识还是已超出你的承受程度?理解执行环境调用的不同阶段是否有助于你写出更好的JavaScript代码?