几乎所有语言最基本的功能之一就是能够储存变量当中的值,并且能够在之后对这个变量进行访问和修改,事实上正是这种储存和访问变量值的能力将状态带给了程序,那么因此程序语言需要制定制定这样一套规则来存储变量,同时能够方便在日后访问和修改这些变量,这套规则被称为作用域。
在介绍JavaScript中的作用域之前我们需要了解传统编译语言的编译过程:
事实上JavaScript引擎中的编译过程要比这复杂得多,例如延迟编译、重编译等等措施来提升性能,但是我们在进行宏观分析得时候可以认为JavaScript的编译也是这样3个步骤。
在了解了上述编译过程的基础上我们来介绍JavaScript的作用域:词法作用域,词法作用域就是定义在词法阶段的作用域,换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里决定的,因此当词法分析器处理代码时会保持作用域不变,因此词法作用域也被称为静态作用域。与之相对的就是动态作用域,词法作用域的定义过程发生在代码的定义阶段,而动态作用域是在运行时动态确定的,我们以如下代码进行区分:
function foo() {
console.log(a); // 2
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
上面示例中调用foo函数打印的a为2,这是因为词法作用域让foo函数内部的a通过RHS引用(如果查找的目的是对变量进行赋值,那么就会使用LHS引用,如果查找的目的是获取变量的值,就会使用RHS引用)获取到了全局作用域中的a,因此控制台输出2,而如果JavaScript中的作用域为动态作用域的话foo函数在执行时将会输出3.,这是因为动态作用域并不会关心函数和作用域是如何声明以及在何处声明的,只关心它们在何处被调用,因此在动态作用域中由于foo函数在bar函数中被调用,因此RHS引用获取到了bar函数的作用域中的a。
当一个块或函数嵌套在另一个块或函数中时就发生了作用域的嵌套,也就是作用域链,此时如果在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量或抵达最外层作用域(也就是全局作用域)为止。
至此我们进行总结:作用域本质上是编程语言储存及访问变量的具体规则,这个规则为编程语言带来了状态,JavaScript与大多数编程语言相同选择了词法作用域,也被称为静态作用域,作用域定义在词法分析阶段,与函数在何处调用无关而仅仅只由我们在写代码时将变量和块作用域写在哪里决定。接下来我们引入编程语中的另一个语法概念:上下文,上下文是一段程序运行时所需要的最小数据集合,大家对于这个描述也看到了作用域与上下文之间的本质区别,作用域关注的是标识符(变量)的可访问性,而上下文指代的是整体环境,同时对于大部分编程语言来说作用域在编译过程中的词法分析阶段生成(词法作用域),并且不会改变,而上下文在运行时确定,随时可以改变,因此两者之间拥有着本质性的不同,但是JavaScript中的执行上下文却又与之不同,我们在下面详细介绍JavaScript中的执行上下文。
执行上下文是评估和执行 JavaScript 代码的环境的抽象概念,也就是JavaScript执行一段代码时的运行环境,每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如this、变量、对象以及函数等等。
在引入JavaScript中执行上下文何时被创建的话题之前我们先关注另外一个问题:变量提升,所谓变量提升,是指在代码执行过程中,JavaScript引擎把变量声明部分和函数的声明部分提升到代码开头的“行为”,变量提升后会给变量设置默认值,这个默认值就是我们熟悉的undefined。对于变量提升我们已经很熟悉,那么我们来讨论它背后到底是怎么实现的?
从概念的字面意思来看,“变量提升”意味着变量和函数的声明会在物理层面移动到代码的最前面,正如我们所模拟的那样,但,这并不准确,实际上变量和函数声明在代码里的位置是不会改变的,而是在编译阶段被JavaScript引擎放入内存中。与传统的编译语言例如C、C++等不同,JavaScript并不会先被编译生成第三方脚本然后运行,事实上JavaScript作为一门解释型语言,它是在宿主环境直接解释执行,比如下载完一个js文件,JavaScript会先编译这个js文件,但是js文件内定义的函数是不会编译的,等调用到该函数的时候,JavaScript引擎才会去编译该函数(编译过程会随着宿主环境的不同而不同,目前认可度高使用范围最为广泛的是V8引擎是基于编译器和解释器实现的代码编译)。
在上边我们提到JavaScript代码被执行时,会先进行编译,变量以及函数声明在这个阶段被JavaScript引擎放入内存中,实际上变量提升的内容保存在执行上下文中变量环境的对象中,一段JavaScript代码在经过编译后会生成两部分内容:执行上下文和可执行代码。我们以如下代码示例:
showName();
console.log(myname);
var myname = 'xiaozhang';
function showName() {
console.log('我的名字是xiaozhang');
}
代码在经过编译后分为两个部分:
// 保存在执行上下文中变量环境中的变量提升部分
var myname = undefined;
function showName() {
console.log('我的名字是xiaozhang')
}
// 可执行代码
showName();
console.log(myname);
myname = 'xiaozhang';
至此,我们发现执行上下文是JavaScript执行一段代码时的运行环境,其在JavaScript代码的编译阶段生成,根据这段JavaScript代码的类型执行上下文可以分为下面三种:
执行栈,也就是在其它编程语言中所说的“调用栈”,JavaScript 引擎用以追踪函数执行流的一种机制,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。
当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。
当 JavaScript 引擎开始执行js脚本时,它会创建一个全局的执行上下文并且压入当前执行栈,之后每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部,当该函数执行结束时,执行上下文从栈中弹出,指针指向执行栈中的下一个上下文,我们以如下代码示例分析:
let a = 'Hello World!';
function first() {
console.log('Inside first function');
second();
console.log('Again inside first function');
}
function second() {
console.log('Inside second function');
}
first();
console.log('Inside Global Execution Context');
示例中的执行栈如下图所示:
当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文并把它压入当前执行栈。当遇到 first() 函数调用时,JavaScript 引擎为该函数创建一个新的执行上下文并把它压入当前执行栈的顶部。
当从 first() 函数内部调用 second() 函数时,JavaScript 引擎为 second() 函数创建了一个新的执行上下文并把它压入当前执行栈的顶部。当 second() 函数执行完毕,它的执行上下文会从当前栈弹出,并且控制流程到达下一个执行上下文,即 first() 函数的执行上下文。
当 first() 执行完毕,它的执行上下文从栈弹出,控制流程到达全局执行上下文。一旦所有代码执行完毕,JavaScript 引擎从当前栈中移除全局执行上下文。
JavaScript引擎创建执行上下文主要分为以下三步:
this的绑定:
在全局执行上下文中this 的值指向全局对象。(在浏览器中,this引用 Window 对象)。
在函数执行上下文中this的值只取决于该函数的调用方式,this值默认绑定全局对象(严格模式下指向undefined),如果函数以对象属性的方式调用那么this值指向该对象,如果函数通过call、apply直接绑定this值那么this值指向传入对象,如果该函数通过new操作符构造调用,那么函数内this值指向新创建的对象。
创建词法环境:
官方的 ES6 文档把词法环境定义为:
词法环境是一种规范类型,基于 ECMAScript 代码的词法嵌套结构来定义标识符和具体变量和函数的关联。一个词法环境由环境记录器和一个可能的引用外部词法环境的空值组成。
简单来说词法环境是一种持有标识符——变量映射的结构。(这里的标识符指的是变量/函数的名字,而变量是对实际对象或原始数据的引用)。
现在,在词法环境的内部有两个组件:环境记录器和一个外部环境的引用。
词法环境有两种类型:
实际上所有词法环境本质上都是相同的,其全局环境与函数环境的区分主要是环境记录器的不同,环境记录器一共有如下五种:
在全局环境中,环境记录器是Object Environment Records(即对象环境记录器),用来定义出现在全局上下文中的变量和函数的关系,在函数环境中,环境记录器是Declarative Environment Records(即声明式环境记录器),用来存储变量、函数和参数。
注意:对于函数环境,声明式环境记录器还包含了一个传递给函数的 arguments 对象(此对象存储索引和参数的映射)和传递给函数的参数的 length。
抽象地讲,词法环境在伪代码中看起来像这样:
GlobalExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
}
outer: <null>
}
}
FunctionExectionContext = {
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
}
outer: <Global or outer function environment reference>
}
}
创建变量环境:
变量环境同样是一个词法环境,其环境记录器持有变量声明语句在执行上下文中创建的绑定关系,所以它有着上面定义的词法环境的所有属性。
在 ES6 中,词法环境组件和变量环境之间的主要不同就是前者被用来存储函数声明和变量(let 和 const)绑定,而后者只用来存储 var 变量绑定,以下面代码示例:
let a = 20;
const b = 30;
var c;
function multiply(e, f) {
var g = 20;
return e * f * g;
}
c = multiply(20, 30);
解析生成的执行上下文如下所示:
GlobalExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
a: < uninitialized >,
b: < uninitialized >,
multiply: < func >
}
outer: <null>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Object",
// 在这里绑定标识符
c: undefined,
}
outer: <null>
}
}
FunctionExectionContext = {
ThisBinding: <Global Object>,
LexicalEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
Arguments: {0: 20, 1: 30, length: 2},
},
outer: <GlobalLexicalEnvironment>
},
VariableEnvironment: {
EnvironmentRecord: {
Type: "Declarative",
// 在这里绑定标识符
g: undefined
},
outer: <GlobalLexicalEnvironment>
}
}
大家应该注意到了变量a、b和c、g之间的区别,大家都知道let、const声明的变量并不会变量提升,事实上这种说法并不十分准确,let和const声明的变量依然会发生变量声明提升,不过相较于var声明的变量它并不会把变量的值初始化为undefined,这就是为什么你可以在声明之前访问 var 定义的变量(虽然是 undefined),但是在声明之前访问 let 和 const 的变量会得到一个引用错误。
下面我们以如下示例来分析执行上下文的创建及执行过程:
function foo() {
var a = 1;
let b = 2;
{
let b = 3;
var c = 4;
let d = 5;
console.log(a);
console.log(b);
}
console.log(b);
console.log(c);
console.log(d);
}
第一步:调用foo函数前先编译并创建执行上下文,函数内部通过var声明的变量被存放到变量环境中,通过let声明的变量在编译阶段被存放到词法环境中,此处需要注意在函数体内部块作用域中let声明的变量并没有被存放到词法环境中。这一步生成的执行上下文如下图所示(为了清楚展示执行上下文中的变量声明,只展示变量环境与词法环境中的环境记录器):
第二步:继续执行代码,当执行到代码块里面时,变量环境中的a的值已经被设置为1,词法环境中b的值已经被设置成了2,此时函数的执行上下文如图所示:
从图中就可以看出,当进入函数的作用域块时,作用域块中通过let声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,因此示例中在函数体内块作用域中声明的变量的b与函数作用域中声明的变量b都是独立的存在。
前文我们提到词法环境中的环境记录器是Declarative Environment Records类型,即声明式环境记录器,其内部实际上维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域内部的变量压到栈顶;当该块级作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。
第三步:当代码执行到作用域块中的console.log(a)时,就需要在词法环境和变量环境中通过RHS引用来查找变量a的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给JavaScript引擎,如果没有找到那么继续在变量环境中查找,如果在当前执行上下文没有找到那么会沿着当前执行上下文中外部环境引用outer指针指向的外部执行上下文中继续查找。
第四步:当函数体内块作用域执行结束之后,其内部变量就会从词法环境的栈顶弹出,此时执行上下文如下图所示:
第五步:当foo函数执行完毕后执行栈将foo函数的执行上下文弹出。
至此我们来总结JavaScript中的作用域和执行上下文之间到底是什么关系?
首先我们需要认识到作用域与作用域链是所有编程语言的基础,它们的存在提供给编程语言存储和访问变量的能力,因此作用域和作用域链是编程语言学习绕不开的语法概念,JavaScript中的作用域为词法作用域,即作用域只由代码中函数或变量声明的位置决定,变量查询在当前作用域查找不到对应变量的情况下会继续向外层词法作用域查找,词法作用域层层嵌套形成JavaScript中的作用域链。
但是,作用域与作用域链仅仅只是语法概念,其在不同编程语言中的实现是不同的,对于JavaScript来说作用域及作用域链的变量查询是通过存储在浏览器内存中的执行上下文实现的,JavaScript中的执行上下文在代码执行前的编译阶段生成,执行上下文内词法环境和变量环境中的环境记录器分别保存通过let、const声明的变量以及通过var声明的变量,外部环境引用outer指针指向其外层词法作用域的执行上下文,在代码执行过程中如果需要查找变量首先会在当前执行上下文中的词法环境中从上而下在不同栈中查找,如果在词法环境中未能访问到对应到对应变量则会查找变量环境,如果在当前执行上下文未能找到则会继续在outer指向的执行上下文中查找,至此JavaScript基于执行上下文实现了作用域及作用域链对应的变量储存及查询的对应规则。
对于大部分编译型语言来说其作用域往往也采用的是词法作用域,在代码的编译过程中的词法分析阶段生成,而上下文在代码执行过程中动态创建,因此两者之间存在着本质上的不同,但是对于JavaScript来说,其独特的边解释边执行使得JavaScript实际上是在代码执行前的编译阶段过程生成,往往这个过程可能只有几微秒,但是这无法掩盖其在编译阶段生成的本质,因此JavaScript中可以利用执行栈与执行上下文的机制来实现作用域与作用域链中变量的储存与查找的对应规则。