理解 JavaScript 中的执行上下文和执行栈
在 JavaScript 中,执行上下文是一个基本的概念,但其中又包含了变量对象、作用域链、this 指向等更深入的内容,深入理解执行上下文以及其中的内容,对我们以后理解 JavaScript 中其它更深入的知识点(函数/变量提升、闭包等)会有很大的帮助
什么是执行上下文
执行上下文可以理解为当前代码的运行环境。在 JavaScript 中,运行环境主要包含了全局环境和函数环境。
执行上下文的类型
- 全局执行上下文 — 这是默认的或者说是基础的上下文,任何不在函数内部的代码都在全局上下文中。它会执行两件事: 创建一个全局的 window对象 (浏览器情况下),并且设置 this 将this的指针指向这个全局对象。
- 函数执行上下文 — 每一次调用函数的时候,都会为该函数创建一个新的执行上下文,每一个函数都拥有自己的执行上下文,但是只有在函数被调用的时候才会被创建,它都会按照特定的顺序执行一系列步骤。
- eval函数执行上下文 — 运行在eval函数都代码也有自己的执行上下文。
执行上下文栈
执行栈,也就是在其它编程语言中所说的“调用栈”,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。
我们可以定义一个数组来模拟这一个过程
当JavaScript执行的时候,js引擎会先创建一个栈来管理所有都执行上下文对象。下面我们用STACK数组来表示这个栈
STACK = [];
然后在执行js脚本最先遇到的就是全局对象window对象,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,STACK才会被清空,所以程序结束之前, STACK最底部永远有个 globalContext:
此时的数组是这个样子的
STACK = [
globalContext
];
然后JavaScript继续执行遇到下面这段代码
function fun3() {
console.log('fun3')
}
function fun2() {
fun3();
}
function fun1() {
fun2();
}
fun1();
每当执行一个函数的时候,就会创建一个执行上下文,并且添加到执行上下文栈,然后当函数执行完毕的时候,就会将该函数从执行上下文栈中弹出。
// 伪代码
// fun1()
STACK.push(<fun1> functionContext);
// fun1中竟然调用了fun2,还要创建fun2的执行上下文
STACK.push(<fun2> functionContext);
// 擦,fun2还调用了fun3!
STACK.push(<fun3> functionContext);
// fun3执行完毕 弹出
STACK.pop();
// fun2执行完毕 弹出
STACK.pop();
// fun1执行完毕 弹出
STACK.pop();
// javascript接着执行下面的代码,但是STACK底层永远有个globalContext
下面我们用看另一个例子
// 赋值语句执行并不代表函数执行!!!
// 先进入全局执行上下文栈
var a = 10;
var bar = function(x) {
var b = 5;
// var 函数执行时 foo函数的定义已经执行了
foo(x+b);// 进入foo函数的执行上下文栈
}
var foo = function(y) {
var c = 5;
console.log(a + c + y);
}
// 总共产生了 3 次执行上下文栈 (n + 1)
bar(10)// 进入bar函数的执行上下文栈
解答思考题
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();
两段代码执行的结果一样,但是两段代码究竟有哪些不同呢?
答案就是执行上下文栈的变化不一样。
让我们模拟第一段代码:
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();
让我们模拟第二段代码:
ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();
如下代码的输出结果是什么?
console.log('global begin', i);
var i = 1;
foo(i);
function foo(i) {
// 退出终止条件
if(i === 4) {
return;
}
console.log('foo() begin:', i);
foo(i + 1); // 递归调用:在函数内部调用自己
console.log('foo() end:', i);
}
console.log('global end', i);
执行结果
怎么创建执行上下文?
到现在,我们已经看过 JavaScript 怎样管理执行上下文了,现在让我们了解 JavaScript 引擎是怎样创建执行上下文的。
创建执行上下文有两个阶段:1 创建阶段 和 2 执行阶段。
创建阶段
执行上下文在创建阶段会发生以下事情
- 创建词法环境(LexicalEnvironment)组件
- 创建变量环境(VariableEnvironment)组件
因此,执行上下文可以在概念上表示为:
ExecutionContext = {
LexicalEnvironment = <ref. to LexicalEnvironment in memory>,
VariableEnvironment = <ref. to VariableEnvironment in memory>,
}
词法环境(Lexical Environment)
词法环境是一种规范类型,用于根据ECMAScript代码的词法嵌套结构定义标识符与特定变量和函数的关联。词法环境由一个环境记录和一个可能为空的外部词汇环境引用组成。
简单的说词法环境是一个保存标识符-变量映射的结构。
标识符:变量或者函数的名称 变量是对实际对象(包括函数对象和数组对象)或者原始数据的引用
例如下面的代码片段
var a = 20;
var b = 40;
function foo() {
console.log('bar');
}
上面片段的词法环境就是
lexicalEnvironment = {
a: 20,
b: 40,
foo: <ref. to foo function>
}
每个词法环境有三个组成部分:
- Environment Record(环境记录器)
- Reference to the outer environment(指向外部环境的引用)
- This binding. (this绑定)
Environment Record (环境记录器)
环境记录器是变量和函数声明存储在词法环境中的位置。
此外,环境记录器亦有两类:
声明性环境记录(Declarative environment record)—— 顾名思义,它存储变量和函数声明。函数代码的词法环境包含一个声明性环境记录。
对象环境记录(Object environment record)—— 全局代码(global code)的词法环境包含一个客观环境记录(objective environment record)。除了变量和函数声明,对象环境记录(the object environment record)还存储了一个全局绑定对象(浏览器中的window对象)。因此,对于每个绑定对象的属性(在浏览器中,它包含浏览器提供给window对象的属性和方法),记录中会创建一个新条目(new entry)。
注意:对于函数代码(function code),环境记录还包含一个参数对象(argument对象),该对象包含传递给函数的索引和参数之间的映射,以及传递给函数的参数的长度(数量)。例如,下面函数的参数对象是这样的:
function foo(a, b) {
var c = a + b;
}
foo(2, 3);
// argument object
Arguments: {0: 2, 1: 3, length: 2},
Reference to the Outer Environment(指向外部环境的引用)
Reference to the Outer Environment指的是它能够接触到外部的词法环境。这意味着,如果在当前词法环境中没有找到想要查找的变量,JavaScript引擎可以在外部环境中查找它们。
感觉像是作用域链
This Binding (this绑定)
在此组件中,this的值被确定或设置(determined or set)。
在全局执行上下文中,this的值指向全局对象。(在浏览器中,它指的是Window对象)。
在函数执行上下文中,this的值取决于函数的调用方式。如果它是通过对象引用调用的,那么this的值被设置为该对象,否则,this的值被设置为全局对象或未定义(在严格模式下)。例如:
const person = {
name: 'peter',
birthYear: 1994,
calcAge: function() {
console.log(2018 - this.birthYear);
}
}
person.calcAge();
// 'this' refers to 'person', because 'calcAge' was called with 'person' object reference
// 'this'指的是'person',因为'calcAge'是用'person'对象引用调用的
const calculateAge = person.calcAge;
calculateAge();
// 'this' refers to the global window object, because no object reference was given
// 'this'引用全局window对象,因为没有给出对象引用
抽象地说,伪代码中的词法环境是这样的:
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
// 标识符绑定到这里
}
outer: <null>,
this: <global object>
}
}
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
// 标识符绑定到这里
}
outer: <Global or outer function environment reference>,
this: <depends on how function is called>
}
}
变量环境 (Variable Environment)
它也是一个词法环境,它的环境记录器(EnvironmentRecord)保存由VariableStatements 在执行上下文中创建的绑定。
如上所述,变量环境也是一个词法环境,因此它具有上述定义的词法环境的所有属性和组件。
在ES6中,词法环境(LexicalEnvironment)组件和变量环境(VariableEnvironment)组件之间的一个区别是,前者用于存储函数声明和变量(let和const)绑定,而后者仅用于存储变量(var)绑定。
let和const声明的变量或者函数绑定在词法环境 var声明的绑定在变量环境
执行程序阶段(Execution Phase)
在这个阶段,所有这些变量的赋值都完成了,代码也最终执行了。
Example (例子)
让我们看一些例子来理解上述概念:
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
当执行上述代码时(the above code is executed),JavaScript引擎创建一个全局执行上下文来执行全局代码。所以在创建阶段,全局执行上下文看起来像这样:
应该就是函数提升和变量提升
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
a: < uninitialized > ,
b: < uninitialized > ,
multiply: < func >
}
outer: < null > ,
ThisBinding: < Global Object >
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
c: undefined,
}
outer: < null > ,
ThisBinding: < Global Object >
}
}
在执行阶段(During the execution phase),完成变量赋值。因此,在执行阶段,全局执行上下文将类似于以下内容。
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
a: 20,
b: 30,
multiply: < func >
}
outer: <null>,
ThisBinding: <Global Object>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// Identifier bindings go here
c: undefined,
}
outer: <null>,
ThisBinding: <Global Object>
}
}
当遇到对function multiply(20,30)
的调用时,将创建一个新的函数执行上下文来执行函数代码。所以在创建阶段,函数执行上下文看起来像这样:
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
g: undefined
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>
}
}
在此之后,执行上下文将经历执行阶段(the execution phase),这意味着完成对函数内变量的赋值。所以在执行阶段,函数的执行上下文看起来像这样:
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>,
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// Identifier bindings go here
// 标识符绑定在这里
g: 20
},
outer: <GlobalLexicalEnvironment>,
ThisBinding: <Global Object or undefined>
}
}
函数完成后,返回值被存储在c中。因此全局词法环境被更新。之后,全局代码完成,程序结束。
注意:你可能已经注意到let和const定义的变量在创建阶段没有任何关联的值,但是var定义的变量被设置为undefined。
这是因为,在创建阶段,代码被扫描以查找变量和函数声明,而函数声明被完整地存储在环境中,变量最初被设置为未定义(对于var)或保持未初始化(对于let和const)。
这就是为什么你可以在声明之前访问var定义的变量(虽然未定义),但在声明之前访问let和const变量时会得到引用错误的原因。
这就是我们所说的变量提升(hoisting)。
注意:在执行阶段,如果JavaScript引擎无法在源代码中声明let变量的实际位置找到它的值,那么它将给它赋值为undefined。