深入理解 JavaScript——变量提升与作用域

duo-xing-zai-ru-han-shu.png

参考内容: lhs rhs是啥意思 《Javasript 高级程序设计(第三版)》 《你不知道的 JavaScript(上卷)》

几乎所有的编程语言都能够存储变量当中的值,并且可以在之后对该值进行访问或修改。很明显需要一套良好的规则来存储这些变量,并且之后可以方便的找到这些变量,这套规则我们称之为作用域

编译原理

我们一般把 js 归为「动态」或「解释执行」语言,但是它也会经历编译阶段,不过它不像传统语言那样是提前编译的,它的编译发生在代码执行前的几微秒内。

传统语言在执行之前会经历三个步骤:分词/词法分析、解析/语法分析、代码生成,关于这三个步骤的具体工作,可以查看编译原理相关的文献,我们可以把这三个步骤统称为编译。不过 js 引擎要复杂的多,它会在编译的时候对代码进行性能优化,尽管给 js 引擎优化的时间非常少,但是它用尽了各种办法来保证性能最佳。

我们需要先了解三个名词。引擎:从头到尾负责整个 js 程序的编译及执行过程;编译器:负责词法分析及代码生成;作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

var a = 2;,我们以这段程序为例,它首先声明了变量a,然后将2赋值给变量a。前一个阶段在编译器处理,后一个阶段由 js 引擎处理。

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

变量提升

用过 js 的人都知道 js 存在变量提升,那么它到底是如何提升的呢?我们看下面的一段代码

console.log(a);
var a = 2;

上述代码在a声明之前访问了变量a,按我们的逻辑它应该会抛出 ReferenceError 异常;或是变量提升直接输出 2。但是这两种答案都不对,输出的是undefined

回顾一下前文的关于编译的内容,引擎会在解释 js 代码之前对其进行编译,编译阶段的一个重要工作就是找到所有的声明,并用合适的作用域将它们关联起来,包括变量和函数在内的所有声明都会在任何代码被执行之前首先被处理。所以我们前面列出来的代码实际上会变成下面这个样子。

var a;
console.log(a);
a = 2;

这个过程就好像变量和函数声明会从它们的代码中出现的位置被移动到最上面一样,这个过程就是提升。但是需要注意的是,函数声明会首先被提升,然后才是变量提升。

foo(); // 1
var foo;

function foo() {
    console.info(1);
}

foo = function() {
    console.info(2);
}

这段代码输出 1 而不是 2 ,它会被引擎理解为下面的形式。

function foo() {
    console.log(1);
}

foo(); // 1

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

可以看到,虽然var foo出现在function foo()之前,但是它是重复的声明,因此会被忽略掉,因为函数函数声明会提升到普通变量前。所以在在同一个作用域中进行重复定义是一个很糟糕的做法,经常会导致各种奇怪的问题。

LHS 和 RHS 查询

LHS 和 RHS 是数学领域内的概念,意为等式左边和等式右边的意思,在我们现在的场景下就是赋值操作符的左侧和右侧。当变量出现在赋值操作符的左边时,就进行 LHS 查询;反之进行 RHS 查询。

RHS 查询与简单的查找某个变量的值没什么区别,它的意思是取得某某的值。而 LHS 查询则是试图找到变量容器的本身,从而可以对其进行赋值。

console.info(a);我们深入研究一下这句代码。这里对a的引用是 RHS 引用,因为这里a并没有赋予任何值,相应的需要查找并取得a的值,这样才能传递给console.info()

a = 2;a的引用则是一个 LHS 引用,因为实际上我们并关心a当前的值是什么,只是想为= 2这个赋值操作找到一个目标。

function foo(a) {
    console.info(a);
}
foo(2);

为了加深印象,我们再来分析一下上述代码中的 RHS 和 LHS 引用。最后一行foo()函数的调用需要对foo进行 RHS 引用。这里有一个很容易被忽略的细节,2 被当作参数传递给foo()函数时,2 会被分配给参数a,为了给参数a(隐式地)分配值,需要进行一次 LHS 查询,也就是说代码中隐含了a = 2的语句。

前文已经说过了console.info(a);会对a进行一次 RHS 查询,需要注意的是console.info()本身也需要一个引用才能执行,因此会对console对象进行 RHS 查询,并检查得到的值中是否有一个log方法。

为什么区分 LHS 和 RHS

我们考虑下面的一段代码,就可以为什么要区分 LHS 和 RHS 查询了,而且区分它们是分厂有必要的。

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

第一次对b进行 RHS 查询时是无法找到该变量的,这是一个未声明的变量,在任何相关的作用域中都无法找到它。如果 RHS 查询在所有嵌套作用域中都找不到该变量,引擎就会抛出 ReferenceError 异常。

引擎在执行 LHS 查询时,如果在全局作用域中也无法找到目标变量,全局作用域就会创建一个具有该名称的变量,并将其返还给引擎。

需要注意的是,在严格模式下是禁止自动或隐式地创建全局变量的,因此在严格模式中 LHS 查询失败时,引擎同样会抛出 ReferenceError 异常。

接下来,如果 RHS 查询找到了一个变量,但是你尝试对这个值进行不合理的操作,比如对一个非函数类型的值进行函数调用,那么引擎就会抛出另一种叫做 TypeError 的异常。

作用域链

执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中,在 Web 浏览器中,全局执行环境被认为是window对象,因此所有的全局变量和函数都是作为window对象的属性和方法创建的。

每个函数都有自己的执行环境,当执行流进入一个函数时,函数的环境就会被推入一个环境栈中,而函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境,这个函数调用的压栈出栈是一样的。

当代码在环境中执行时,会创建变量对象的一个作用域链。作用域链的用途是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端始终都是当前执行的代码所在环境的变量对象,说的比较抽象,我们可以看下面的示例。

var color = "blue";

function changeColor() {
    var anotherColor = "red";

    function swapColors() {
        var tempColor = anotherColor;
        anotherColor = color;
        color = tempColor;
        // 这里可以访问 color、anotherColor 和 tempColor
    }
    // 这里可以访问 color 和 anotherColor,但不能访问 tempColor
    swapColors();
}
// 这里只能访问 color
changeColor();

下面的图形象的展示了上述代码的作用域链,内部环境可以通过作用域链访问所有的外部环境,但是外部环境不能访问内部环境中的任何变量和函数。函数参数也被当做变量来对待,因此其访问规则与执行环境中的其它变量相同。

window
  |-----color
  |-----changeColor()
            |----------anotherColor
            |----------swapColors()
                           |----------tempColor

作用域链还用于查询标识符,当某个环境中为了读取或写入而引入一个标识符时,必须通过搜索来确定该标识符实际代表什么。搜索过程从作用域链的前端开始,向上逐级查询与给定名字匹配的标识符,如果在局部环境中找到了该标识符,搜索过程就停止,变量就绪;如果在局部环境没有找到这个标识符,则继续沿作用域链向上搜索,如下所示:

var color = "blue";

function getColor() {
    var color = "red";
    return color;
}

console.info(getColor()); // "red"

getColor()中沿着作用域链在局部环境中已经找到了color,所以搜索就停止了,也就是说任何位于局部变量color的声明之后的代码,如果不使用window.color都无法访问全局color变量。

前端JavaScript