编译器的预解析与引擎的查询


代码解析参与者

需要了解变量是如何进行预解析的,首先要知道解析代码的参与者,有三个:引擎编译器作用域

  • 编译器

    对 JavaScript 源代码通过某种编译原理进行解析,并生成可供引擎执行的代码

  • 作用域

    负责保存变量以及提供对变量的查询与访问,并通过一套非常严格的规则,确定当前执行的代码对这些变量的访问权限

  • 引擎

    负责执行编译器生成的代码

现在我有如下的代码 var a = 1; 下面我们将这段代码分解,来看看浏览器是如何对这段代码进行解析的:

  1. 遇到 var a,编译器会询问作用域命名为 a 的这个变量是是否已经有一个存在于当前作用域中

    如果含有,则编译器会忽略 var a ,继续进行编译
    
    如果没有,编译器会要求作用域在当前作用域中声明一个新的变量,命名为 a
    
  2. 接下来,编译器会开始生成可供引擎执行的代码,这些代码被用来处理 a = 2 这个赋值操作,引擎会在当前作用域中开始查找是否有 a 这个变量(从当前作用域开始,层层向上查找,这就是作用域链

    如果查找到了,就对 a 进行赋值
    
    如果直到全局作用域都没查找到,则会抛出异常
    

缩略版:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值

可以看到,步骤1是编译器对代码进行的处理,步骤2是引起对代码进行的处理,步骤2才是 JavaScript 代码真正开始执行的时刻,而步骤1则被称为预解析

预解析

定义

在代码开始按照顺序从上到下执行之前( JavaScript 代码运行之前),当前作用域下,会把带有 varfunction 关键字的事先声明,并在当前作用域的内存中安排好(这也就是变量声明提升和函数声明提升的原因

  • 如果是变量,则赋值为 undefined

  • 如果是函数声明,则将整个函数块放在代码的最顶端

为何这里要说是在当前作用域呢?
因为存在函数,每声明一个函数,就会为它自身新生成一个单独的作用域,不通过特殊手段,外部是无法访问到内部的变量的,这里又分为函数声明和函数表达式两种情况:
1. 当编译器第一次遇到函数声明时,只会对函数名进行预解析,并进行一次函数声明提升,并不会对函数的内容进行处理,当引擎执行代码到函数时,编译器会再次开启,对函数内的代码进行一次新的预解析,且函数内也会存在变量声明提升和函数声明提升
2. 当引擎执行代码时,遇到函数表达式然后对变量进行赋值,这里也会再次开启编译器,对函数内的代码进行一次预解析,且函数内也会存在变量声明提升和函数声明提升

注意:其实这里使用“当前作用域”来描述不太合理,后面会讲到这点

实例

介绍完了定义,那我们来看如下代码:

实例1:变量的预解析

console.log(a);     // undefined
var a = 1;

这里是对变量进行的预解析,实际上代码的执行步骤是这样的

/* ----- 编译器进行的处理 ----- */
var a;
/* ----- 处理完毕,引擎开始执行整段代码 ----- */
console.log(a);
a = 1;

实例2:变量的预解析和函数声明的预解析

console.log(fn);    // undefined
var fn = function () {
    return 1;
}
console.log(fn);    // 输出函数的代码片段
function fn () {
    return 1;
}

同为函数,为什么输出的结果不同呢?

因为一个是函数表达式赋值给变量,一个是函数声明,这也就是定义里面说到的两种情况,遇到了 var 关键字和遇到了 function 关键字

第一段代码解析过程如下:

/* ----- 编译器进行的处理 ----- */
var fn;
/* ----- 处理完毕,引擎开始执行整段代码 ----- */
console.log(fn);
fn = function () {
    return 1;
}

可以看到,是对 var fn 进行了一个处理,直接忽视了后面的代码

第二段代码解析过程如下:

/* ----- 编译器进行的处理 ----- */
function fn () {
    return 1;
}
/* ----- 处理完毕,引擎开始执行整段代码 ----- */
console.log(fn);

可以看到,是将整个函数都放在了代码的顶部,然后再去执行打印的操作

注意:说句题外话,这里打印的是函数本身,并不是函数的执行结果,所以这里输出的是函数的代码片段,而不是1

变量声明提升和函数声明提升的顺序

既然这两货都会进行预解析,那肯定得来判断一下它们是否存在先后顺序,如果有,那谁的优先级更高呢?

首先来看看这段代码

function fn () {
    return 1;
}
console.log(fn());  // 2
function fn () {
    return 2;
}
console.log(fn());  // 2
var fn = function () {
    return 1;
}
console.log(fn());  // 1
var fn = function () {
    return 2;
}
console.log(fn());  // 2

第一个 console 的输出结果是不同的,原因就是变量声明提升和函数声明提升它们的解析机制是不同的(回顾一下上面的知识,与这里的知识无关)

第二个 console 的输出结果都为2,由于都为函数声明或者函数表达式,那么它们不存在预解析时的先后顺序,而是从上往下,代码依次执行时,进行的重新赋值

那么我们再来看看看看这两段代码

var fn = function () {
    return 1;
}
function fn () {
    return 2;
}
console.log(fn());
function fn () {
    return 2;
}
var fn = function () {
    return 1;
}
console.log(fn());

根据上面的结果,如果说函数声明提升和变量声明提升不存在预解析的排序,而是按照代码执行时的执行顺序来进行重新赋值的,那么这两段代码 console 出来的内容应该不同

如果尝试在浏览器中 console 出结果,会发现结果是相同的,都为1,这说明变量声明提升和函数声明提升是存在先后顺序的,而且函数声明提升是在变量声明提升之前,优先级更高

我们再来看一段误导性很强代码

console.log(fn);    // 函数代码片段
var fn = 1;
function fn () {
    return 2;
}

我们来对代码进行解析

/* ----- 编译器进行的处理 ----- */
function fn () {
    return 2;
}
var fn;
/* ----- 处理完毕,引擎开始执行整段代码 ----- */
console.log(fn);
fn = 1;

很多人以为,变量声明提升既然是在函数声明之前,那么这段代码的输出结果应该为 undefined,那么我在这里告诉你,var fn 并不会覆盖掉原来的函数声明,其实你可以使用另外一种方式验证一下

var a = 1;
var a;
console.log(a); // 1

这里在对变量 a 赋值后,又重新声明了一次,可是输出结果还是为1

所以那段误导性很强的代码并不能说明变量声明提升比函数声明提升的优先级高,这是由 ECMAScript 制定的代码规范,那就是函数声明提升比变量声明提升优先级高

遗留问题

简单介绍完了代码的解析模式,那么来说一下上面遗留的问题,之前在定义里面提到说使用“当前作用域”这个说法不太合理,那么请看下面的代码

<script>
var a = 1;
</script>

<script>
console.log(a);     // 1
</script>

通过这行代码我们可以知道,实际上这两个 <script> 标签共享的是同一个作用域,那么看看下面这个代码

<script>
console.log(a);     // 报错:Uncaught ReferenceError: a is not defined
</script>

<script>
var a = 1;
</script>

虽然它们都是共享的同一个作用域,但是进行代码预解析的步骤是不同的,首先会对第一个 <script> 标签内的代码进行预解析,然后去执行它;执行完后才会对第二个 <script> 标签内的代码进行预解析,再执行

<script>
    var a = 1;
</script>
<script>
    console.log(a); // 1
</script>

这里可以获得 a 的结果,说明不同 <script> 标签,它们共享的是同一个作用域

那么如果修改一下代码顺序,又会是什么样子呢?

<script>
    console.log(a); // Uncaught ReferenceError: a is not defined
</script>
<script>
    var a = 1;
</script>

虽然存在预解析,但是这里却报出了异常,这是因为不同 <script> 标签,它们要分别去进行预解析,在第一个script标签执行的时候,并没有声明 a 这个变量,所以会导致报错
在不同的script标签内,代码的执行顺序是不同的,如果处于不同的script标签内,虽
由于这两个代码存在不同的代码块,虽然他们的作用域是公用的,但是在解析上面这块代码时,并没有开始进行下面一块代码的预解析,所以会导致报错

再来看一段代码

console.log(a); // Uncaught ReferenceError: a is not defined
function fn () {
    a = 1;
}
fn();

刚刚也说过,如果在直接使用一个未声明的变量,在非严格模式下,会导致这个变量成为一个全局变量从而污染全局作用域,那么在这里,为什么 console.log(a) 会报错呢?
这里也存在一个预解析的问题,在script标签进行预解析的时候,只会对fn进行一个预解析,并不会去对函数内部的变量进行解析,只有当执行函数的时候,才会对函数内部的作用域进行一次新的预解析,所以这里 a 变量没有声明且根本无法获取到,所以报错

小结

  • 只有声明本身会被提升,而赋值或其他运行逻辑会留在原地

  • 每个作用域都会进行提升操作

  • 函数声明先被提升,然后才是变量声明

  • 不同 script 标签是分开解析的

引擎的查询方式

预解析洋洋洒洒的写了这么多,也只是讲解了 var a = 1 这段代码里面的 var a 的机制,并没有讲解到赋值,那么接下来就来说说引擎又是如何在作用域中对变量进行查询以及赋值的

分类

引擎查询方式分为两种:

  • RHS 查询:查找某个变量的值

  • LHS 查询:找到变量的容器本身,从而可以对其赋值

如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询

光说定义不太好理解,那么来看看这段代码,从实例中理解一下引擎的查询机制

实例

实例一

a = 2;

这里是对 a 进行了一次 LHS 查询,因为我需要获取到这个变量本身,然后才能对其进行赋值操作

实例二

console.log(a);

这里进行了两次 RHS 查询,第一次对 console 这个对象进行 RHS 查询,获取它身上是否有 log 这个方法,然后对 a 的值进行一次 RHS 查询,获取到 a 的值,从而使值在控制台显示

实例三

function fn (a) {
    return a;
}
fn(1);

这里就是既有 LHS 查询也有 RHS 查询,引擎执行到 fn(1) 这里,得知要去执行一段函数,便会去找这个函数,由于需要获取到函数的值才能执行,所以会对函数进行一次 RHS 查询;接着得知函数内有参数,要对函数的参数赋值,所以需要进行一次 LHS 查询,拿到函数作用域中的变量 a 然后对其赋值;最后碰到了 return a,得知需要将 a 的值返回,那么这里还要进行一次 RHS 查询去获取 a 的值

这些例子也简单的说明了上述的定义,那就是,需要获取变量本身时,执行 LHS 查询;需要获取到变量的值时,执行 RHS 查询

那么花了这么大篇幅去讲解引擎的查询机制到底对我们学习 JavaScript 有没有帮助呢?答案是有的。理解查询方式不仅可以让我们去理解代码的执行机制,也可以轻松的理解浏览器抛出的异常信息,下来我们就来看看

为什么区分 LHS 和 RHS 是一件重要的事情?

因为变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。不一样的行为会带来不同的结果,出现错误时浏览器的报错信息也会不同,理解这个对于理解浏览器的报错会有很大的帮助

ReferenceError异常

RHS 查询实例

考虑如下代码

function fn (a) {
    console.log( a + b );   // Uncaught ReferenceError: b is not defined
    b = a;
}
fn( 2 );

执行函数时,需要输出 a + b 的值,所以需要对 b 进行一次 RHS 查询,可是一层层的往上级查找,直到全局作用域都找不到这个变量,此时引擎就会抛出 ReferenceError 异常

LHS 查询实例

上面是有关于 RHS 查询的,那么再看看关于 LHS 查询的这段代码

function fn () {
    a = 1;
}
fn();
console.log(a);     // 1
function fn () {
    "use strict";
    a = 1;
}
fn();
console.log(a);     // Uncaught ReferenceError: a is not defined

可以看到,函数内的变量 a 没有使用 var 来声明,是直接进行使用的,当在执行 console.log 的时候,会对 a 进行一次 LHS 查询。在非严格模式下,LHS 查询会逐级向上查找,找到全局作用域时就会停止查找,如果没有找到该变量,则会自动在全局作用域声明一个这个变量(这也是为什么不使用 var,直接声明变量会导致该变量污染全局作用域的原理);在严格模式下,LHS 查询会逐级向上查找,找到全局作用域时就会停止查找,如果没有找到该变量,则会抛出 ReferenceError 异常

总结:不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛出 ReferenceError 异常(严格模式下)

TypeError异常

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

小结

在了解了这些机制以后,就可以知道异常的根本原因了:

  • ReferenceError 异常同作用域判别失败相关

  • TypeError 异常则代表作用域判别成功了,但是对结果的操作是非法或者不合理的

这样了解了引擎对变量的查询机制,以后在看到浏览器报错信息时,就可以从根本出发,找到问题的根源了

本文大部分内容来自《you don't know JavaScript》,经过自己的理解和整理记录成的笔记

推荐阅读更多精彩内容