前言
最近重拾了一下JavaScript的一些基础,作用域就是其中一个比较重要的概念。理解它有助我们更好理解JavaScript代码的执行。我们知道,储存变量值几乎是所有编程语言最基本的功能之一,可以对变量的值进行访问和修改,正是这种储存和访问变量的能力使得程序有了状态。那么变量是如何储存,并且是如何去访问它们的?这些问题也说明了我们需要一套设计良好的规则来存储变量,并且之后可以方便的访问它们。没错,这套规则就是我们所说的作用域了。下面我们探讨一下,JavaScript是如何设置这些作用域规则的。
先讲讲编译
JavaScript是一门动态编译的语言,在讲作用域之前,简单的讲讲传统编译语言一般会经历的三个步骤:
- 词法分析
此过程会将由字符组成的字符串分解成有意义的代码块,即所谓的词法单元(token)。例如,var a= 1;这句简单的赋值语句,会被分解成:var、a、=、2、;等这些词法单元。结构大致如下,会生成tokens数组,其中每个token是词法分析的最小单元,不能再分解
var a = 1;tokens:[ { "type": "Keyword","value": "var" }, { "type": "Identifier","value": "a" }, { "type": "Punctuator","value": "=" }, { "type": "Numeric","value": "1" }, { "type": "Punctuator","value": ";" }]
- 语法分析(Parse)
此过程是将词法单元流即上面的tokens数组,转换成一个由元素逐级嵌套所组成的程序语法结构的树,即抽象语法树(AST)。在这个过程中会校验语法,有错误会抛出语法错误。
大致结构如下:
// AST的结构大致如下:{ type, sourceType, start, end, ... // body是一个数组,包含多个内容块对象,即statement,类型大致有:变量声明,函数定义,if语句,while循环等。 body: [{ type, start, end, kind, // 变量的内容块,也是个数组 declarations:[ { type, start, end ... } ] }]}var a = 1;// AST结构如下{ "type": "Program", "start": 0, "end": 12, "body": [ "type": "VariableDeclaration", //变量声明 "start": 0, "end": 10, "declarations": [ { "type": "VariableDeclarator", // 变量声明 "start": 4, "end": 9, "id": { "type": "Identifier", //标识符 "start": 4, "end": 5, "name": "a" }, "init": { "type": "Literal", "start": 8, "end": 9, "value": 1, "raw": "1" } } ], "kind": "var" } ], "sourceType": "module"}
- 代码生成
将AST转换为可执行的过程被称为代码生成,当然这个过程比较复杂,这里我们只是有个大概的意识,简单的讲就是可以将 var a = 1; 的AST转化为一组机器指令,用来创建一个叫做 a 的变量,分配内存并将一个值储存在a中。
这里讲编译只是大概的说明了解下代码大致编译过程,给我们有一个整体的代码运行的流程,简单了解引擎可根据需要创建存储变量即可,当然JavaScript编译过程要复杂得多,笔者也只是简单了解皮毛而已,没关系,我们这里讨论的重点还是作用域哈哈。
对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒,甚至更短的时间内,比如对简单的一句赋值语句 var a= 1,编译器会先进行编译,做好执行它的准备,然后会马上执行它。
理解作用域
1、三部曲
在JavaScript中对程序处理执行,一般是由引擎,编译器,以及作用域这三个配合完成的。
- 引擎
负责JavaScript程序的编译以及执行过程。 - 编译器
负责语法分析以及代码生成等。 - 作用域
负责收集维护所有声明的标识符(变量)组成的一系列查询,确定代码执行时对这些标识符的访问权限。
它们三者之间会协同工作,一起来完成JavaScript代码的执行。
比如处理var a = 1;这段简单的程序,大致是这么处理的:1、引擎编译执行的时候,遇到var a,编译器去会询问作用域是否已有该变量存在其中,若有编译器会忽略该声明继续编译,否则会在作用域中声明一个新的变量,并命名为a。2、接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 1 这个赋值操作,引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫做 a 的变量,若有引擎就会使用这个变量,否则,引擎就会继续查找该变量,最终找到就会赋值给它,若没有找到则会抛出异常。
总结:变量的赋值操作会有两个步骤:1、编译器在当前作用域声明一个变量,已声明则忽略;2、运行时引擎会在作用域查找该变量,若找到就会进行赋值,否则抛出异常。
2、变量查询
在上述了解代码执行的大致过程,我们知道引擎在执行代码时会通过查找变量a来判断它是否已声明过,而查找的过程中由作用域进行协助的,但引擎进行怎样的查找,会影响最终的查找结果。
比如在上述例子中,引擎会为变量a进行LHS查询,还有另外一个查找类型叫做RHS。顾名思义,“L”和“R”的含义,分别是左侧和右侧,即当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。只是从字面这么讲,但不一定意味着就是赋值操作符的左侧或者右侧。
下面代码来讲讲LHS与RHS的区别:
var a = 1; // 明显是LHS查询console.log(a); // 这里对变量a的查询就是一个RHS查询
我们可以看到console.log(a)中的变量a,并没有赋予任何值,只是需要查找并取得a的值,才能将值传递给console.log();而对于a=1;来说,对a的查询则是LHS查询,这里我们可以理解成只是要为=1这个赋值操作找到一个目标。
因此,我们可以进一步理解:去查找赋值操作的目标是谁就是LHS查询,而去查找已知目标(变量)是在哪里进行赋值操作的则是RHS查询
我们再看下面代码:
function foo(a) { console.log(a);}foo(2); // 2
这段代码首先声明了一个函数foo,接着调用它,这里的函数调用就是对foo进行RHS查询,意思是去查找foo的值,并把它给我,并且它最好是一个函数类型的值。
其实这段代码也有LHS查询,那就是代码中隐式的a = 2赋值操作,因为在foo调用时,2被当作参数传递给foo函数,此时分配给了参数a,这里需要一次LHS查询,接着console.log(a)就是我们上面说的RHS查询。
接下来我们来捋一捋上面那段代码在执行时会发生什么:1、引擎会在当前作用域中对foo进行RHS查询;而编译器编译过程中已经声明了foo函数,于是引擎找到了foo;2、接着继续在当前作用域对a进行LRS查询,编译器将a声明成了函数foo的一个形参了,于是也找到了a;3、以此类推,最终引擎完成了这段代码的解析执行。
3、作用域嵌套
接着我们在上面那段代码基础上再加一个变量:
function foo(a) { console.log(a + b);}var b = 2;foo(2); // 4
上面代码中多了一个变量b,这里对b进行的是RHS查询,但此时无法在函数foo内部完成,不过可以在其上一级作用域即全局作用域中完成。我们可以得出一个规则:引擎从当前执行的作用域中开始查找变量,若找不到就会逐级往上查找,当抵达最外层作用域时,无论找没找到,查找过程都会停止。其实,这种嵌套作用域一级一级的查找就是我们常说的作用域链了。这种作用域链就好比一幢高楼,引擎执行所在的作用域就是当前楼层,LHS和RHS查询都会在当前楼层进行查找,若没找到,就会上一层楼层(作用域)去查找,直到顶层(全局作用域)。
异常
上面我们了解的LHS和RHS查询变量的一些简单规则,也了解到两种查询规则的行为是不一样的,尤其是变量还没有进行声明的情况下,如以下代码:
function foo(a) { console.log(a + b); b = a;}foo(2);
上面代码在对b进行RHS查询时时在所有作用域中是无法找到该未声明的变量的,此时引擎就会抛出ReferenceError异常。
相比较之下,当引擎执行LHS查询时,如果直到在全局作用域都没有找到该目标变量,那么在非严格模式下就会创建一个具有该名称的变量,并将其给到引擎,注意是在非严格模式下,如果是在严格模式下,则会跟RHS查询那样抛出一样的异常。
接下来,如果RHS查询找到一个变量,但是你执行了不合理操作,比如对一个非函数类型的值进行函数调用,或者引用null或undefined类型的值中的属性,引擎就会抛出TypeError异常。
总结
作用域其实就是一套规则,用于确定在哪里以及如何查找变量,如果查找的目的是对变量赋值,那么就会使用LHS查询,如果目的是获取变量的值,就会使用RHS查询。并且这两种查询都会在当前作用域中开始,找不到时会向上一层作用域继续查找目标变量,直到全局作用域为止。这样一层一层的查询作用域,构成了我们所说的作用域链。不成功的RHS查询会导致抛出ReferenceError异常,而不成功的LHS查询会导致自动隐式创建一个全局变量(非严格模式下),在严格模式下会同RHS查询一样抛出异常。
感谢您阅读我分享创作的文章,文章会同步在本人公众号【前端精神小伙】中,可关注阅读往期文章。
欢迎一起学习和交流,debug佳也将持续为大家分享更多前端技术干货。