《你不知道的JavaScript(上卷)》第一章读书笔记

337 阅读8分钟

第一章 作用域是什么

编译原理

尽管通常将JavaScript归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。但与传统的编译语言不同,它不是提前编译的。

JavaScript引擎进行编译的步骤和传统的编译语言非常类似,在某些环节可能比预想的要复杂。

在传统编译语言的流程中,程序中的一段源代码在执行前会经历三个步骤,统称为“编译”。

  • 分词/词法分析(Tokenizing/Lexing) 这个过程会将由字符组成的字符串分解成有意义的代码块,这些有意义的代码块被称为词法单元(token)。例如:对于var a = 2;这段程序。这段程序通常会被分解成下面这些词法单元:vara=2;。空格是否会被当作词法单元,却决于空格在这门语言中是否具有意义。
  • 解析/语法分析(Parsing) 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套,代表了程序语法结构的树。这个树被称为“抽象语法树”(AST)。对于var a = 2;这段程序,它所形成的抽象语法树中可能会有一个叫做VariableDeclaration的顶级节点,接下来是一个叫做Identifier(它的值是a)的子节点,以及一个叫作AssignmentExpression的子节点。AssignmentExpression节点有一个叫做NumericLiteral(它的值是2)的子节点。
  • 代码生成 将AST转化成可执行代码的过程被称为代码生成。 不谈具体细节,简单来说就是通过某种方法可以将var a = 2;这段程序所形成的AST转化为一组机器指令,用来创建一个名为a的变量(包括分配内存等),并将一个值储存在a中。

对于JavaScript来说,其在编译阶段所做的事情要复杂得多。例如,在语法分析和代码生成阶段中,有特定的步骤来对运行性能进行优化

同时,JavaScript引擎不会有大量的时间用来进行优化,因为JavaScript的编译过程不是发生在构建之前的,大部分情况下编译发生在代码执行前的几微妙

简单来说,任何JavaScript代码片段在执行前都要进行编译(编译通常就在执行前)。JavaScript编译器首先会对var a = 2;这段程序进行编译,然后做好执行它的准备,并且通常会马上执行它。

理解作用域

演员表

引擎:从头到尾负责整个JavaScript程序的编译及执行过程。

编译器:负责语法分析及代码生成。

作用域:负责收集维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严厉的规则,确定当前执行的代码对这些标识符的访问权限。

对话

对于var a = 2;这段程序,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个由引擎在运行时处理。

编译器首先会将这段程序分解为词法单元,然后将词法单元解析成抽象语法树。但是当编译器开始进行代码生成时,它对这段程序的处理方式和预期的有所不同。

  1. 遇到var a,编译器会询问作用域,是否存在一个该名称的变量,存在于当前作用域所管理的变量集合中。如果存在,编译器会忽略该声明,继续进行编译;否则它会要求作用域,在当前作用域所管理的集合中声明一个新的变量,并命名为a

  2. 接下来,编译器会为引擎生成引擎运行时所需的代码,这些代码用来处理a = 2这个赋值操作。引擎运行时,会先询问作用域,在当前作用域所管理的变量集合中是否存在一个叫做a的变量。如果存在,引擎就会使用这个变量;如果不存在,引擎会通过相应的查找规则继续查找该变量。

若引擎最后找到了a这个变量,则会将2赋值给它。如果没有找到,引擎就会举手示意并抛出一个异常!

这也解释了为什么使用var重复声明同一个变量不会报错。

同时,我们也该注意,在编译var a = 2;这段代码时,编译器会询问作用域,在当前作用域所管理的集合中是否存在一个名为a的变量。而对于a = 2;这段代码,编译器在编译时,它并不会询问作用域,而是直接生成引擎可执行的代码。

编译器有话说

书中所说,编译器在编译过程的第二步中生成了代码。但是我觉得应该是在编译过程的第三步代码生成中生成了相关的可执行代码。引擎执行代码时,会通过查找变量a来判断它是否已声明过,但是引擎执行怎样的查找,会影响最终的查找结果。

引擎查询变量的两种查询方式:LHS查询和RHS查询。

LHS查询是试图找到变量的容器本身,从而可以对其进行赋值。而RHS查询则是取到它的原值。

例如,对于这段程序console.log(a),其中对a的引用是一个RHS引用,因为引擎需要取到a的源值,然后将值传递给console.log(...)。同时console.log(...)本身也需要一个引用才能执行,因此会对console对象进行RHS查询,取到console对象的源值,并检查源值中是否存在一个名为log的方法。

相比之下,我们看看另外一段程序a = 2;,这里对a的引用是一个LHS查询,因为我们想要找到a这个变量所在的容器,然后对其进行赋值。

引擎和作用域的对话

这一段对话我觉得挺有趣的,特此进行记录。

function foo(a) {
    // var a;
    console.log(a); //2
}
foo(2);

引擎:作用域兄弟,我需要对foo进行RHS引用,你见过它吗?
作用域:我见过它,编译器小子刚声明了它。它是一个函数,给你!
引擎:太好了,我来执行一下foo
引擎:作用域兄弟,我还有个事,你见过a这个变量吗,我需要对它进行LHS引用。
作用域:我也见过,编译器最近把它声明为foo的一个形式参数了,来,给你!
引擎:谢谢。对了,我需要对console进行RHS引用,你有见过它吗?
作用域:找到了,console是一个内置对象,来,给你!
引擎:么么哒。我看看它里面有没有一个叫做log的方法,太好了,它有。
引擎:哥们,能帮我再找一个对a的RHS引用吗,虽然我记得它,但我想再确认一次。
作用域:放心吧,这个变量我没有动过,来,拿给你。
引擎:太棒了!现在我要把a的值传递进log这个方法中。
......

作用域嵌套

当一个块或者函数嵌套在另外一个块或者函数中时,就发生了作用域的嵌套。若引擎在当前作用域中无法查找到某个变量,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或者到达最外层的作用域(也就是全局作用域)为止。

异常

区分LHS查询和RHS查询是一件十分重要的事情。在变量还没有声明的情况下,两种查询的行为是不一样的。

如果RHS查询在所有嵌套的作用域中都找寻不到所需的变量,引擎就会抛出ReferenceError异常。

比如下面这段代码:

function foo(a) {
    console.log(a + b);
    b = a;
}
foo(2);

第一次在第二行对b进行RHS查询时,在所有的嵌套作用域中都查找不到该变量。引擎就抛出了一个ReferenceError的异常。请注意,在第三行中,编译器并不会询问当前作用域中是否存在一个名为b的变量,而是直接生成引擎执行时所需的代码。

相比之下,当引擎进行LHS查询时,如果在全局作用域中都没有找到目标变量,作用域会在全局作用域中创建一个具有该名称的变量,并将其返还给引擎。前提是程序运行在非严格模式下。这也是“暗示全局变量”的由来。

比如下面这段代码:

function foo() {
    b = 2;
}
foo();
console.log(b); // 2

上段代码中的b变量就是一个暗示全局变量。

在严格模式下,禁止自动地或者隐式地创建全局变量。因此,在严格模式中,若LHS查询失败,作用域并不会在全局作用域创建一个新变量,引擎同样会抛出ReferenceError的异常。

接下来,如果RHS查询找到了一个变量,但是当你尝试对该变量的值进行不合理的操作时,比如试图对一个非函数类型的值进行函数调用,或者引用null或者undefined类型的值中的属性时,那么引擎会抛出另外一种类型的异常,名为TypeError