一、作用域是什么?
几乎所有的编程语言最基本的功能之一,就是能够存储变量的值,并且能访问和修改这些值。
修改变量值的过程我们通常在程序执行时,称为改变一个对象的状态。有了状态,让程序变得有非常有趣。
然而,这些变量存在哪里?程序又是如何找到它们的?
这些问题就说明需要一套设计良好的规则来存储变量,并且之后能方便的找到这些变量。这套规则就被称为作用域。
1.1编译原理
javasctrip通常被称为“动态“”和“解释执行”语言(脚本),但事实上它是一门语言。但是javascript不能像其他传统语言被通篇编译,编译结果更不能像传统语言一样在分布式系统中移植。但是编译方式任然和传统语言的编译方式非常相识,在某些环节可能比传统语言的编译还要复杂。
传统编程语言的编译流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。
1.1.1分词/词法分析(Tokenizing/Lexing)
这个过程将由字符组成的字符串分解成代码块(对编程语言来说有意义的代码块),这些代码块被称为词法单元(token)。
//以下列程序为例 var a = 2; //该程序将会被分解成下面这些词法单元 //var、a、=、2、; //空格是否有意义,取决于空格在这门语言中是否具有意义
分词(tokenizing)与词法分析(Lexing)之间的主要区别在于,词法单元的识别是通过有状态还是无状态的方式进行的。
也就是说词法单元生成器判断a是一个独立的词法单元,调用的就是无状态的解析规则,这个过程就叫做分词。
如果词法单元生成器判断a是其他词法单元的一部分,调用的就是有状态的解析规则,这个过程叫做词法分析。
1.1.2解析/语法分析(Parsing)
解析也就是语法分析过程,是将词法单元(数组)转换成一个有元素逐级嵌套所组成的代表程序语法的结构树。这个数被称为“抽象语法树”。
例如:var a = 2;大概可以用一下树形图表示。
深度理解抽象语法树:javascript编写一个简单的编译器(理解抽象语法树AST)
1.1.3代码生成
代码生成就是将AST转换成可执行代码。
简单的说(不讨论具体生成细节)就是将var a = 2 ;转化为一组机器指定,用来穿件一个叫a的变量(包括分配内存等),并将一个值存储在a中。
注:javascript编译生成的具体内容,待后面有相关文章分析,这一部分只对传统与编译与javascript编译做对比介绍。
然而,javascript相比较传统编程语言来说,javascript引擎要复杂的多。
例如:在语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化等。
javascript不会像传统语言那样在程序执行前通篇编译,而是在执行的前一刻进行编译,这个时间只有几微妙(甚至更短)。
这些都是我们讨论作用域的背后问题。
1.2理解javascript的作用域
在解析javascript作用域之前,我们先来了解一下引擎、编译器、作用域及三者之间的关系。
1.2.1关系
引擎,也就是浏览器的内核。负责整个javascriot程序的编译和执行过程。
编译器,负责语法分析及代码生成(1.1编译原理)。
作用域,负责收集维护所有声明的标识符(变量)组成的一系列查询,并且带有严格的规则,确定当前执行代码对这些标识符的访问权限。
1.2.1引擎与编译器
以var a = 2;为例,通常这段代码我们所理解的编译就是一句声明。
但是,引擎完全不这么看,引擎在编译这段代码时有两个完全不同的声明。
两个?是的,两个完全不同的声明。
从通常的理解来看,我们认为引擎编译处理这段代码是:
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。(这是1.1.1和1.1.2传统编程语言编译时的分词/词法分析和解析/语法分析)
如果是这样,也就可以将这段代码通俗的理解为:为一个变量分配内存,将其命名为a,然后将值2保存进这个变量。
然而,事实上引擎与编译器会这么处理:
1)遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一作用域的集合中。
如果是,编译器会忽略该声明,继续进行编译。
否则,它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。
~接下来,编译器会为引擎生成运行时所需要的代码,这些代码用来处理a = 2这个赋值操作。
2)遇到a = 2时,引擎首先会问作用域,在当前的作用域集合中是否存在一个叫做a的变量。
如果是,引擎就会使用该变量,在当前作用域找到的a变量,并给a的命名空间上赋值2。(如果找到a变量但赋值失败就会抛出异常)
否则,引擎会继续查找该变量(1.3作用域嵌套相关的内容)。
1.2.3编译器怎会如此简单
在编译过程的第二部生成代码,引擎执行它时,会通过查找变量a来判断它是否已经声明过。查找过程由作用域协助,但是引擎执行怎样的查找,会影响最终的查找结果。
在上面的例子中,引擎会为a进行LHS查询。另一个查找的类型叫做RHS。
我想大家能猜到“L”和“R”的含义,他们分别代表左侧和右侧。(什么东西的左侧和右侧?)
——赋值操作的左侧和右侧。
简单的说,就是当变量出现在左侧时进行LHS查询,出现在右侧时进行RHS查询。但是说到这里大家估计还是一头雾水,下面我们来看一些示例:
b = 10;
console.log(b);
这两行代码引擎在编译的过程中就执行了不同的查询。
抛开b变量的声明问题(此处埋雷了),我们假设b已经被声明过了。
在编译b = 10 的时候,引擎会执行LHS查询,编译器在编译这段代码时,会先去作用域上查找变量b,看有没有这个变量。
在编译console.log(b)时,b引用的是RHS查询,直接通过b变量去作用域上查找b变量的值。然后把查找到的值赋给console.log(...)。
通俗的说,b = 10引用LHS查询是试图找到变量容器的本身,
而console.log(b)引用的RHS查询是试图找到b变量容器里面的值。
注意:LSH和RHS的含义是“赋值操作的左侧或右侧”并不意味着就是“=赋值操作的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最好将其理解为“赋值操作的目标是谁(LSH)”以及“谁是赋值操作的源头(RHS)”。(如果这里有点懵可暂时跳过,上面的解释我认为可以解决99.9%的开发时遇到的问题)
继续来一个示例:
function foo(a){ console.log(a); } foo(2);
这里只关注foo(2)这部分代码编译时的赋值操作,这段代码编译时共存在4次赋值操作。(注意:这里赋值操作不是通常编码时的概念,而是编译器的赋值操作)
第一个赋值操作:foo(...)函数调用需要对foo进行RHS引用,意味着去找到foo的值,并把它交给引擎执行(相当于把foo的值赋给引擎)。
第二个赋值操作:a=2,这个操作隐式的发生在执行foo(...)的前一刻,编译器会给参数a(隐式的)分配值,需要进行一次LHS查询。
第三个赋值操作:当console.log(...)被调用时,编译器需要对console对象引用RHS查询,并检查得到的值中是否有一个叫做log的方法。
第四个赋值操作:在执行console.log(a)的前一刻,这里还需要对a进行一次RHS查询,并且把值传给console.log(...)。
1.2.4引擎与作用域
这一部分我就引用《你不知道的JavaScript:上卷》中的一段原文,书中的解析非常的人性、轻松、易懂。(这些词语可能还不足以描述他的好!!!)
function foo(a){ console.log(a); } foo(2);
书中对这段代码的编译描述,把引擎和作用域比作两个人对话,读完这段对话你就会明白引擎与作用域之间的关系了。
引擎:我说作用域,我需要foo进行RHS引用。你见过它吗?
作用域:别说,我真见过,编译器那小子刚刚声明了它。他是一个函数,给你。
引擎:哥们太够意思!好吧,大哥,我来执行foo。
引擎:作用域,还有个事儿。我需要为a进行LHS引用,这个你见过吗?
作用域:这个也见过,编译器最近把它声明为foo的一个形式参数,拿去吧。
引擎:大恩不言谢,你总是这么棒。现在我要把2赋值给a。
引擎:哥们,不好意思又来打扰你。我要为console进行RHS引用,你见过它吗?
作用域:咱两谁跟谁啊,在说我就是干这个的。这个我也有,console是个内置对象。
给你。
引擎:么么哒。我得看看这里面是不是有log(...)。太好了,找到了,是一个函数。
引擎:哥们,能帮我再找一下a的RHS引用吗?虽然我记得它,但想再确认一次。
作用域:放心吧,这个变量没动过,拿走,不谢。
引擎:真棒。我来把a的值,也就是2,传递进log(...)。
......
1.2.5这里需要来个小测试了
分别查找下列代码中有几个LHS查询和RHS查询,还可以试试编写一个引擎、编译器、作用域之间的对话。
function foo(a){ var b = a; return a + b; } var c = foo(2); //答案待整理完这个系列书的笔记后来添加。
1.3作用域嵌套
记得在1.2.1中我们介绍了作用域,也在1.2.4中介绍了作用域在代码编译执行时所承担的任务。如果只说前面介绍的作用域就是它的全部的话,那真的是太小看它了。
作用域因为嵌套机制让我们编写的程序如虎天翼,如果你对javascript很了解的话,没有作用域嵌套机制你一定会想死,但是有了嵌套机制你就更会想死,她有一个让你活着笑的方法,就是你必须征服她。
所以,加油。
在这里暂时对作用域嵌套做一个简单的介绍,后期一步一步深入的介绍。
1.3.1定义
前面说过,作用域是根据名称查找变量的一套规则,而这套规则通常都建立在几个作用域上。
当一个块或函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套关系。
1.3.2应用
在当前作用域无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(全局作用域)为止。
function foo(a){ console.log(a+b); } var b = 2; foo(2);//4
在这段代码中,对b进行RHS引用无法在函数foo内部完成,但可以在上一级作用域(全局作用域)中完成。(所以引擎与作用域有了这样一段对话)
引擎:foo的作用域兄弟,你见过b吗?我需要对它进行RHS引用。
作用域:听都没听过,走开。
引擎:foo的上级作用域兄弟,咦?有眼不识泰山,原来你是全局作用域大哥,太好了。你见过b吗?我需要对它进行RHS引用。
作用域:当然了,给你吧。
1.3.3遍历作用域链的规则
引擎从当前的执行作用域开始找变量,如果找不到,就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都会停止。
关于作用域链的嵌套,如果我们用可视化的图形来表示,可以象形的比作下图:
引擎从当前作用域开始;
然后到达上级作用域;(与当前作用域有嵌套关系的作用域,也可以叫做父级作用域)
...... (这中间可以被嵌套若干个节点)
直到全局作用域。
1.4异常
为什么我们在编译环节要花很大篇幅介绍LHS和RHS呢?
还记得在1.2.3中我标注了一个“此处埋雷了”。
在前端开发中,我们的编辑器是不会提示变量未声明的;而在其他后台编程语言中,这个提示是普遍存在的。
function foo(a){ console.log(a + b);
b = a;
}
foo(2);
执行的结果:Uncaught ReferenceError: b is not defined
报错ReferenceError表示非法或不能识别的引用数据。(暂时不讨论这个问题,我们再来看一段代码)
function foo(a){ b = a; console.log(a + b); } foo(2);
执行结果:4
我相信有编程经验的朋友都知道:
第一段代码是因为调用一个未经声明的变量的值会报错,第二段代码在执行b=a时内部隐式的创建了一个变量b,然后将a的值赋给b,所以没有报错。
的确是这样,但是为什么呢?同样是调用一个未经声明的变量,为什么第一段代码会报错,而第二段代码会内部隐式创建这个变量呢?
我们都知道这个问题发生在编译环节,那我们就来分析一下引擎在这个环节究竟有什么区别,那我们就看有区别的部分,跳过fon的RHS查询,跳过a的LHS查询,直接进入函数内部执行语句。
第一段代码先执行console.log(a + b),在这里b执行的是RHS查询,当前作用域和全局作用域都找不到这个变量,然后因为没办法引用这个变量的值,抛出错误。
第二段代码先执行b = a,在这里b执行的是LHS查询,当前作用域和全局作用域上都找不到b变量,这时候就会在全局作用域上创建一个b变量,并将其返还给引擎。引擎就可以执行正常的赋值操作了。
这就是编译环节中RHS与LHS查询的区别,当查询的变量没有定义时,编译器执行的LHS查询会隐式创建这个变量。而当编译器执行RHS查询时,因为无法获取到未声明变量的值会抛出错误。
为了避免这样一些低级的错误持续干扰我们的开发,ES5引入了“严格模式”。
在严格模式下,编译器执行LHS查询也会跟RHS查询一样,使用未经声明的变量同样会抛出ReferenceError错误提示。
关于严格模式下编译环节还有一些除了ReferenceError以外的错误提示。
1.对一个非函数类型的值执行函数调用。
2.引用null和undefined类型的值中的属性。
以上这种不合理的操作在严格模式下,编译环节会抛出TypeError错误提示。
ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功,但是对结果的操作是非法或不合理的。