本文将详细地分析创建作用域的过程。分析创建作用域的过程有助于我们理解一些概念,比如闭包和IIFEs(立即执行函数)。
执行上下文
执行上下文不是一个具体的东西,而是一个抽象的概念,它对于描述JavaScript编译和执行代码的过程的某些部分非常有用。虽然我们都知道JavaScript是一门解释性语言,但是浏览器执行JavaScript的过程也是需要一个编译阶段的。
与作用域一样,只要浏览器加载脚本,我们就进入全局执行上下文,每次调用一个函数(即使递归调用相同的函数),都会创建它自己的函数执行上下文。
JavaScript是单线程的,这意味着一次只能发生一件事。由于执行上下文是在嵌套函数中创建的,因此不能同时执行它们,因此将它们添加到所谓的执行上下文堆栈中,以等待轮到它们执行。
执行上下文堆栈
观察下面的代码:
function one() {
function two() {
//some code
function three() {
//some code
console.log("We are in function three!");
}
}
}
在第10行执行到“We are in function three!”的时候,我们已经有4个执行上下文:
1. function three's Execution Context
-------------------------------------
3. function two's Execution Context
-------------------------------------
2. function one's Execution Context
-------------------------------------
1. Global Execution Context
-------------------------------------
堆栈是后进先出LIFO结构,这意味着最后进入堆栈的将会第一个出来。因此,在创建了4个执行上下文之后,第一个要处理的上下文将是函数three()的上下文;第二个函数是function two();等等.
这样解释起来是很简单,但是当我们创建一个执行上下文时具体做了什么事情呢?
执行上下文做了什么?
执行上下文的生命周期大体上缩减为两个步骤:
- 创建阶段
- 代码执行阶段
完成这两个步骤后,执行上下文被销毁,JavaScript的解释器(负责“读取”代码并处理代码的解释器)将继续处理堆栈中的下一个执行上下文。创建阶段与编译密切相关,因此,为了理解它,我们将了解一下JavaScript的编译器。
JavaScript的编译过程
虽然我们常说JavaScript不是一门编译性的语言,而是一门解释性语言。但这并不是因为JavaScript没有编译过程。JavaScript与其他传统编译语言的最大区别在于,JavaScript在执行代码之前会编译代码。JavaScript的编译过程包括通常由其他编程语言提前完成的常见步骤:
- 分/词法分析 (Tokenizing / Lexing):将一串代码分成若干块(标记)并赋予它们语义。例如,如果编译器遇到var some_variable = 1;,它会(在某些情况下)将这段代码分为var、some_variable、=、1和;。在JavaScript中,这些标记中的每一个都有不同的含义(对于上面的示例,关键字、变量名、赋值操作符、整数和分号)。也许在其他一些编程语言中,编译器会以其他方式分隔这些标记(例如,它可以确定空格也有意义)。标记和词法是同一个过程;区别在于它们用来决定如何将代码分隔为分词的规则。JavaScript使用词法分析,这意味着它使用有状态解析规则来实现其目的。
- 语法分析(parsing):创建一个名为AST(抽象语法树)的数据结构,它表示脚本的语法结构。AST是一个具有节点和边的树,它们显式地表示代码中标记之间存在的关系。
- 代码生成(Code-Generation):获取AST并将其转换为运行脚本的机器可以执行的代码。
当然,这个过程还包括其他步骤,但是以上步骤与我们的讨论最为相关。总的来说,我们可以说JavaScript的编译是这样一个过程:变量和函数被声明,词法范围被定义,this 关键字被分配。更确切地说,创建阶段发生在编译期间,它可以分为以下步骤:
- 创建激活对象(Activation Object)
- 创建参数对象(Arguments Object)
- Scope赋值
- 变量实例化
- this关键字的赋值
让我们通过一个例子来分析这个问题:
var var1 = 1;
var var2 = 2;
function example(argument1, argument2) {
var var3 = 3;
function innerFunc() {
var4 = 4;
console.log(var1 + var2 + var3 + var4);
}
innerFunc();
}
example(var1, var2); //10
在第15行执行example()时,我们进入上述函数的执行上下文并开始创建阶段。让我们看看每一步:
创建激活对象(Activation Object)
如果我们把执行上下文想象成一个JSON对象(记住,执行上下文只是一个抽象的概念,但是把它想象成一个具体的对象会有助于分析),那么现在我们就会得到:
exampleExecutionContext = {
activationObject = {}
}
激活对象Activation Object当前为空。我们很快就会把它写满。
创建参数对象(Arguments Object)
arguments对象作为激活对象的属性创建。它是一个数组,包含传递给函数的参数及其对应长度:
exampleExecutionContext = {
activationObject = {
arguments: [
{argument: 1, length: 1},
{argument: 2, length: 1}
]
}
}
Scope赋值
scope被赋值给当前执行上下文。这是通过查找父函数的作用域(在本例中是全局作用域)并将当前函数的作用域添加到父函数的作用域中来实现的。作用域的连接称为作用域链,它是一个数组,包含到目前为止积累的所有作用域,以便当前函数可以使用它们。每个范围都表示为一个变量对象Variable Object(VO)。变量对象是一个激活对象,它包含声明和分配的变量和函数。当前函数的变量对象被添加到其父作用域链的开头。
exampleExecutionContext = {
activationObject = {
arguments: [
{argument: 1, length: 1},
{argument: 2, length: 1}
]
},
scopeChain: [example function VO, global VO]
}
函数和变量的声明
激活对象被转换为变量对象(这只是改变名称的问题,没有真正发生“转换”)。存在于example()中的函数和变量在VO中声明。首先,通过写下函数的名称(一个指向函数存储位置的指针)并声明它们确实是一个函数来声明函数。然后,通过写下变量的名称并为它们分配一个临时undefined的值来声明变量。
exampleExecutionContext = {
variableObject = {
arguments: [
{ argument: 1, length: 1 },
{ argument: 2, length: 1 }
],
innerFunc: pointer to innerFunc(),
var3: undefined
},
scopeChain: [example function VO, global VO]
}
“this”关键字的赋值
分配this关键字。现在,让我们假设它被添加到执行上下文:
exampleExecutionContext = {
variableObject = {
arguments: [
{argument: 1, length: 1},
{argument: 2, length: 1}
],
innerFunc: pointer to innerFunc(),
var3: undefined
},
scopeChain: [example function VO, global VO],
this: {...}
}
编译现在已经结束,创建阶段已经完成。到目前为止,我们应该注意以下几点:
- 相对于函数和变量,Argument已经被分配。这意味着一旦编译完成,解释器就可以使用Argument的值。
- 作用域链包括已经在全局范围中分配的变量和函数,以及在当前函数中声明的变量和函数。但是作用域链不包含在内部作用域中声明的变量和函数,所以var4(在innerfunc()中)还不存在。
- 函数首先被声明,然后声明变量。这是非常重要的,因为它可能会混淆我们,我们将在稍后探讨。
词法作用域
还记得词法作用域吗?根据我们目前掌握的知识,我们有可能理解为什么这样叫它。词法作用域是指词法分析过程中创建的范围。由于词法分析是在编译和创建阶段完成的,这意味着在执行代码时,已经创建了所有可用的范围。编译器已经决定了哪个标识符属于哪个范围,并且没有办法绕过它(或者存在吗?)。作为词法作用域创建的范围只取决于它们在编写代码中的位置;程序员通过在其他函数中编写函数或在全局范围内编写函数来决定不同作用域之间的嵌套关系。
与词法作用域相反的是,动态作用域的范围是由函数执行的位置定义的,而不是函数编写的位置。JavaScript不使用动态范围,因此我们将不再讨论这个主题。
代码执行阶段
编译和创建阶段之后,执行代码。这意味着解释器逐行读取代码,并开始为变量赋值。一旦遇到函数调用,它就停止执行当前函数,“进入”新函数,并开始创建新的执行上下文。
var var1 = 1;
var var2 = 2;
function example(argument1, argument2) {
var var3 = 3;
function innerFunc() {
var4 = 4;
console.log(var1 + var2 + var3 + var4);
}
innerFunc();
}
example(var1, var2); //10
在执行阶段的第11行,就在调用innerFunc()之前,example的执行上下文如下:
exampleExecutionContext = {
variableObject = {
arguments: [
{argument: 1, length: 1},
{argument: 2, length: 1}
],
innerFunc: pointer to innerFunc(),
var3: 3
},
scopeChain: [example function VO, global VO],
this: {...}
}
如你所见,var3已经被分配了它的值。然后,调用innerFunc()并创建一个新的执行上下文。
为什么执行上下文很重要呢?
好吧,接下来让我们通过一个更加实际的例子来进行解释。
ReferenceError(引用错误) and TypeError(类型错误)
我们通过下面的例子来看看这两种错误类型是如何产生的。
ReferenceError(引用错误)
我们看看下面这段代码:
var var2 = 2;
function first() {
var var3 = 3;
function second() {
var var4 = 4;
console.log(var4 + var3 + var2 + var1); //ReferenceError
var var5 = 5;
}
second();
}
first();
当解释器到达第7行console.log()时,当前执行上下文的作用域链包括以下项。注意,这里的作用域是一个变量对象数组,如上所定义。
scopeChain = [
{
nameOfScope: second,
var4: 4,
var5: undefined
},
{
nameOfScope: first,
second: pointer to second(),
var3: 3
},
{
nameOfScope: window,
first: pointer to first(),
var2: 2
}
]
解释器执行console.log(var4 + var3 + var2 + var1):
- 为了能够对变量求和,它首先查找var4。它从作用域链的索引0开始,这是second的变量对象(请记住,当前范围链是通过将当前变量对象添加到父范围链的开头来构建的)。它找到var4并得到它的值(4)。
- 然后,它寻找var3。它没有在作用域链的索引0中找到它,所以它继续在索引1中查找(first的变量对象)。var3在那里,因此解释器得到它的值(3)。
- 然后,它寻找var2。它在索引2中(global的变量对象)中找到它,并得到它的值(2)。
- 最后,它寻找var1。它不在索引0中,也不在索引1或2中。因此,解释器耗尽了变量对象(作用域),并意识到从未声明过var1。结果是ReferenceError,这意味着解释器无法在没有声明的情况下找到脚本中使用的变量。
TypeError(类型错误)
类型错误是不同的。观察下面代码:
first(); //TypeError
var first = function aFunction() {
//some code
};
当我们试图以意想不到的方式使用对象时,会发生类型错误。要理解这个例子,我们必须记住函数声明与函数表达式是不同的。函数声明如下:
function aFunction() {
//some code
}
当编译器遇到函数声明时,它在变量对象中写下它的名称,创建一个指向函数存储位置的指针,并清楚地声明它是一个函数。因此,下面的代码将完美地执行:
aFunction(); //”It works!”
function aFunction() {
console.log(“It works!”);
}
当执行阶段开始并遇到aFunction()时,解释器查找标识符aFunction,发现它是一个函数,运行该函数,执行其中的代码,并将“it works”打印到控制台。
但是函数表达式的工作方式不同:
var first = function aFunction() {
//some code
};
当遇到这段代码时,编译器只声明一个名为first的变量,并赋值为undefined。被分配给first的函数只会在执行阶段被JavaScript的解释器读取。所以:
first(); //TypeError
var first = function aFunction() {
//some code
};
当我们尝试运行first()时,解释器首先查找并找到它,但是它意识到它不是一个函数,而是一个值undefined的变量。它不能执行变量,因此会抛出一个类型错误。
没有var关键字的变量
当我们不向变量添加var关键字时会发生什么?
function first() {
var1 = 1;
}
first();
很简单。在编译中,变量没有声明,因为没有var关键字,编译器就没有理由关注它。因此,在执行阶段,当解释器遇到var1 = 1时,它开始查找它。由于没有找到该变量,因此在全局范围内声明该变量,并将其赋值为1。
为什么不抛出ReferenceError?当解释器试图检索变量的值时,会发生referenceerror。在本例中,我们试图为未声明的变量赋值。当这种情况发生时,JavaScript只创建变量并继续赋值。
变量提升
最后我们谈一下变量提升的概念。还记得函数和变量声明的顺序吗?编译器总是先从函数开始,然后再从变量开始。这种对变量和函数声明的重新排序称为提升。这意味着当一个函数和一个变量用相同的名称声明时,总是先声明函数,然后忽略变量:
first(); //“I am indeed a function”
var first = 1;
function first() {
console.log(“I am indeed a function”);
}
即使变量是在函数之前编写的,提升也会确保先声明函数。因此,对first()的调用非常有效。
类似地,当先声明一个函数,然后用相同的名称声明一个变量时,变量声明将被忽略。相反,如果两个函数用相同的名称声明,则第二个函数将被声明。
first(); //2
function first() {
console.log(1);
}
function first() {
console.log(2);
}
总结
我们在写代码的时候必须非常小心命名事物的方式,以及编写事物的顺序。考虑下面这段代码:
first(); //undefined, 2
function first() {
console.log(var1);
var var1 = 1;
console.log(var1);
}
function first() {
console.log(var1);
var var1 = 2;
console.log(var1);
}
var first = 1;
回顾一下,即使变量first在两个函数之后声明,编译器也会忽略它,因为我们已经在变量对象中有一个名为first的标识符。不过,已经存储的标识符是指向第二个函数的指针,因为函数first()的第二个声明覆盖了第一个声明。
在第二个函数first()中,我们有一个console.log(var1),它打印undefined。这是因为变量已经在变量对象中声明,但是仍然有一个undefined的值赋给它(默认值)。当调用第二个console.log(var1)时,控制台将输出2,因为在前面的步骤中已经为var1分配了一个2。