本篇是深入分析和理解作用域的第一篇——内部原理和工作模型。
我们知道作用域是变量,对象,函数可访问的一个范围。这说明了我们需要一套良好的规则来存储变量,之后方便查找。所以我们首先要理解的是在哪里而且怎么设置这些规则。要了解这些我们首先要知道以下原理。
一、编译原理
事实上JavaScript是一门编译语言,但它与传统的编译语言不同,它不是提前编译的,编译的结果也不能在分布式系统中进行移植。JavaScript引擎进行编译的步骤和传统的编译语言非常的相似,在某些环节可能更加的复杂。
在传统的编译语言中程序一般在执行之前会经历分词,解析,代码生成三个步骤。
1、 分词
这个过程会将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元。如:var a = 2; 被分解成为下面的词法单元:var、a、=、2、 ;。这些词法单元组成了一个词法单元流数组。空格是否会被当作记法单元,取决于空格在这门语言中是否具有意义。
2、解析
这个过程是将词法单元流数组转换成一个由元素逐级嵌套组成的代表了程序语法结构的树,这个树被称为“抽象语法树”。
var a = 2; 的抽象语法树中有一个叫VariableDeclaration的顶级节点,接下来是一个叫Identifier(它的值是a)的子节点,以及一个叫AssignmentExpression的子节点,且该节点有一个叫Numericliteral(它的值是2)的子节点。
3、代码生成
将AST转换为可执行代码的过程被称为代码生成。这个过程与语言,目标平台等信息相关。简单来说就是有某种方法可以将var a=2;的抽象语法树转化为一组机器指令,用来创建一个叫作a的变量(包括分配内存等),并将值2储存在a中。
实际上,javascript引擎的编译过程要复杂得多,如在分词和代码生成阶段有特定的步骤来对运行性能进行优化。
简单来说,任何JavaScript代码片在执行前都要进行编译,大部分情况下编译发生在代码执行前的几微秒。因此,JavaScript编译器首先会对var a = 2;这段程序进行编译,然后做好执行它的准备且一般来说马上就会执行它。
简而言之,编译过程就是编译器把程序分解成词法单元,然后把词法单元解析成语法树,再把语法树变成机器指令等待执行的过程。
二、执行阶段
要理解作用域我们首先要知道以下几个名词及它的意义:
引擎:从头到尾负责整个JavaScript程序的编译及执行过程
编译器:负责代码分析及代码生成
作用域:负责收集维护由所有声明的变量组成的一系列查询,且实施一套非常严格的规则,确定当前执行的代码对这些变量的访问权限。
以var a = 2;为例:
遇到var a,编译器查找作用域是否已经有一个名称为a的变量存在于同一个作用域的集合中,如果有,编译器会忽略这个声明,继续进行编译,如是没有它会要求作用域在当前作用域的集合中声明一个新的命名为a的变量;
接下来编器会为引擎生成运行时所需的代码,这些代码被用来处理a = 2这个赋值操作。引擎运行时会首先查询作用域,在当前的作用域集合中是否存在一个叫做a的变量,如果有,引擎就会使用这个变量,如果没有,引擎会继续查找这个变量。在最终如果引擎找到了变量a,就会将2赋值给它,否则引擎抛出一个异常。
变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明的话),然后在运行时引擎会在作用中查找该变量,如果能找到就会对它赋值。
三、查询阶段
编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量a来判断它是否已声明过,查找的过程由作用域进行协助,但是引擎执行怎样的查找会影响最终的查找结果。
实际上,引擎查询为两种:LHS查询和RHS查询。当变量出现在赋值操作左侧时进行LHS查询,出现在右侧时进行RHS查询。RHS查询与简单查找某个变量没有什么区别,LHS查询则是试图找到变量的容器本身,从而对其可以进行赋值。从这个角度来说,RHS并不是真正意义上的”赋值操作的右侧“更准确的说是”非左侧“。在我们的例子中,引擎会为变量a进行LHS查询。
<script>
console.log(a);
//这里对a的引用是一个RHS引用,因为这里a并没有赋予任何值,相应的,需要查找并取得到a的值,这样才能传递给console.log(...)
a = 2;
//这里对a的引用是一个LHS引用,因为实际上我们不关心当前的值是什么,只是想要为=2这个赋值操作找到目标
</script
<script>
function fn(a) {
console.log(a);
}
fn(2);
// 这里总共包括4个查询,分别是:
// 1、fn(...)对fn进行RHS引用
// 2、函数传参a = 2进行了LHS引用
// 3、console.log(...)对console对象进行了RHS引用,并检查其是否有一个log的方法
// 4、console.log(a)对a进行了RHS引用,并把值传给了console.log(...)
</script>
四、嵌套
虽然我们说过作用域是根据名称查找变量的一套规则,实际情况中,通常需要同时顾及几个作用域。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套,因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续的查找,直到找到该变量,或者抵达全局作用域为止。
<script>
function fn(a) {
console(a + b);
}
var b = 2;
fn(2); //4
</script>
在上面代码中,作用域fn()函数嵌套在全局作用域中,引擎首先在fn()函数的作用域中查找变量b,并尝试对其进行RHS引用,没有找到,接着,引擎在全局作用域中查找b,找到后对其进行RHS引用,将2赋值给b。
五、异常
区分LSH和RHS是一件很重要的事,在变量没有声明的时候,这两种的查询行为完全不一样
如果RHS查询失败,引擎会抛出异常(ReferenceError)引用有误;
如果RHS查询找到了一个变量,但尝试对变量的值进行不合理的操作,比如对一个非函数类型的值进行函数调用,或者引用null和undefined中的属性,引擎会抛出另一种错误(TypeError)类型错误。
<script>
//对b进行了RHS查询,无法找到这个变量,也就是说这是一个没有声明的变量
function fn(a){
a=b;
}
fn();// ReferenceError: b is not defined
function fn1(){
var b=0;
b();
}
fn1(); //TypeError: b is not a function
</script>
当引擎执行LHS查询时,如果无法找到变量,全局做用域会创建一个具有这个名称的变量,并将其返还给引擎;
如果在严格模式中LHS查询失败时,并不会创建并返回一个全局变量,引擎会抛出ReferenceError异常。
<script>
function fn() {
a = 1;
}
fn();
console.log(a); //1
function fn1() {
'use strict';
b = 1;
}
fn1();
console.log(b); //ReferenceError: b is not defined
</script>
<script>
function fn(a) {
console(a);
}
fn(2);
//以上代码分为以下几步:
// 1、引擎需要fn(...)函数进行RHS引用,在全局作用域中查找fn, 成功找到并执行
// 2、引擎需要进行fn函数传参a = 2, 为a进行LHS引用,在fn函数作用域中查找a,成功找到,把2赋值给a
// 3、引擎需要执行console.log(...), 为console对象进行RHS引用,在fn函数作用域中查找console对象,由于console是个内置对象,被成功找到
// 4、引擎在console对象中查找log(...)方法,成功找到
// 5、引擎需要执行console.log(a)对a进行RHS引用,在fn函数作用域中查找a,成功找到并执行
// 6、引擎把a的值,也就是2传入到console.log(...)中
// </script>
六、工作原型
作用域共有两种主要的工作模型,分别是:词法作用域和动态作用域。下面我们来分别进行讨论。
1、词法作用域
在前面我们介绍过了,大部分标准编译器的第一个工作阶段是词法,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。
简单来说,词法作用域就是定义在词法阶段的作用域,换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此,当词法分析器处理代码时会保持作用域不变(大部分情况如此)。
关系
无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定的。
<script>
function foo(a) {
var b = a * 2;
function bar(c) {
console.log(a, b, c); //2 4 12
}
bar(b * 3);
}
foo(2);
</script>
在上面的这个例子中有三个依次嵌套的作用域,为了帮助理解,可以将它们想象成几个逐级包含的气泡。
作用域气泡由其对应的作用哉代码写在哪里决定的,它们是逐级包含的:
气泡1包含着整个全局作用域,其中只有一个标识符:foo; 气泡2包含着foo所创建的作用域,其中有三个标识符:a,bar,b ; 气泡3包含着bar所创建的作用域,其中只有一个标识符:c
查找
作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置位置,引擎用这些信息来查找标识符的位置。
在代码片段中,引擎执行console.log(...)声明,并查找a,b,c三个变量的引用,它首先从最内部的作用域也就是bar(...)函数的作用哉开始查找,引擎无法在这里找到a,因此会去向上一级到所嵌套的foo(...)的作用域中继续查找,在这里找到了a,因此引擎使用了这个引用,对b来讲也一样,而对c来说,引擎在bar(...)中找到它。如果a,c都存在于bar(...)和foo(...)的内部,console.log(...)就可以直接使用bar(...)中的变量,而无需到外面的foo(..)中查找。
遮蔽
作用域查找会在找到第一个匹配的标识符停止,在多层的嵌套作用域中可以定义同名的标识符,这叫做”遮蔽效应“。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者向上进行,直到遇见第一个匹配的标识符为止。
全局变量会自动为全局对象的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局属性的引用来对其进行访问。
<script>
var a = 0;
function test() {
var a = 1;
console.log(window.a); //0
}
test();
</script>
通过这种方法可以访问那些被同名变量所遮蔽的全局变量。
2、动态作用域
动态作用域它并不关心函数和作用域是如何声明以及在何处声明,它只关心从何处调用,换句话说,作用域是基于栈,而不是代码中的作用域嵌套。总面言之,词法作用域是在定义时确定的,动态作用哉是在运行时确定的。