我不知道的JS

当开发者感到迷惑时,他们通常会责怪语言本身,而不是怪自己对语言缺乏了解。

作用域是什么

几乎所有的编程语言最基本的功能之一,就是能够储存变量中的值,并且能在之后对这个值进行访问或者修改。事实上,正是这种储存和访问变量的能力将 状态 带给了程序。

若没有了状态这个概念,程序虽然也能够执行一些简单的任务,但它会受到极大的限制,绝不像现在这么有趣。

但是将变量引入程序会引起几个很有意思的问题,也正是我们将要讨论的:这些 变量 住在哪里?换句话说,它们储存在哪里?最重要的是,程序需要时如何找到它们?

这些问题说明需要一套设计良好的规则来储存变量,并且之后可以方便地找到这些变量。这套规则被称为 作用域。但是,究竟在哪里而且怎样设置这些作用域规则呢?

编译原理

尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上他是一门编译语言。这个事实对你来说可能显而易见,也可能闻所未闻,取决于你接触过多少编程语言,具有多少经验。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。

尽管如此,JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些缓解可能比预想的要复杂。

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

分词/词法分析(Tokenizing Lexing)

这个过程会将字符组成的字符串分解成(对编程语言而言)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑var a = 2;,这段程序通常会被分解为下面这些词法单元:vara=2;。空格是否会被当作词法单元取决于空格在这门语言中是否具有意义。

分词(tokenizing)和词法分析(lexing)之间的区别是非常微妙、晦涩的,主要差异在于词法单元的识别十通过 有状态无状态 的方式进行的。简单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为 词法分析

解析/语法分析(Parsing)

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree AST)。

var a = 2;的抽象语法树中可能会有一个叫作VariableDeclaration的顶级节点,接下来时一个叫作Identifier的子节点,以及一个叫做AssignmentExpression的子节点。AssignmentExpression节点有一个叫作NumericLiteral的子节点。

代码生成

将 AST 转换为可执行代码的过程被称为代码生成。这个过程与语言、目标平台息息相关。抛开具体细节,简单来说就是由某种方法可以将var a = 2;的 AST 转化为一组机器指令,用来 创建 一个叫做 a 的变量,并将一个值储存在这个变量中。

关于引擎如何管理系统资源超出了我们的讨论范围,因此只需要简单地了解引擎可以根据需要创建并储存变量即可。

比起哪些编译过程只有三个步骤的语言的编译器,JS 引擎要复杂的多。例如,在语法分析和代码生成的阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行清理等。因此在这里只进行宏观、简单的介绍,接下来你会发现我们介绍的这些看起来有点高深的内容与所要讨论的事情究竟有什么样的关联。

首先,JS 引擎不会有大量的时间来进行优化,因此与其他语言不同,JS 的编译过程不是发生在构建之前的。对于 JS 来说,大部分情况下编译发生在代码执行的前几微秒的时间内。在我们所要讨论的作用域背后,JS 引擎用尽了各种办法来保证性能。简单的说,任何 JS 代码片段在执行前都要进行编译。因此 JS 编译器首先会对var a = 2;这段程序进行编译,然后做好执行它的准备,并且通常马上会执行它。

理解作用域

我们学习作用域的方式是将过程模拟成几个人物之间的对话。那么由谁进行这场对话呢?

演员表

首先介绍将要参与对示例代码进行处理过程的演员们,这样才能理解接下来将要听到的对话。

引擎

从头到尾负责整个 JS 程序的编译及执行过程。

编译器

引擎的好朋友之一,负责语法分析以及代码生成等脏活累活

作用域

引擎的另一位好朋友,负责收集并维护由所有声明的标示符组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标示符的访问权限。

为了能够完全理解 JS 的工作原理,你需要开始像引擎(和它的朋友们)那样思考,从它们的角度提出问题,并从它们的角度回答这些问题。

对话

?????????

作用域潜逃

我们说过,作用域是根据名称查找变量的一套规则。实际情况中,通常需要同时顾及几个作用域。

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到这个变量为止,或者抵达作用域的尽头。考虑一下代码:

1
2
3
4
5
function foo(a) {
console.log(a + b);
}
var b = 2;
foo(2); //4

对 b 进行的 RHS 引用无法在函数 foo 内部完成,但可以在上一级作用域(在这个例子中就是全局作用域)中完成。

因此,回顾一下引擎和作用域之间的对话,会进一步听到:

引擎:foo 的作用域兄弟,你见过 b 吗?我需要对它进行 RHS 引用。作用域:听都没听过,走开!引擎:foo 的上一级作用域兄弟,咦!有眼不识泰山,原来你是全局作用域大哥,太好了,你一定见过 b 吧?我需要对他进行 RHS 引用。作用域:拿去吧。

遍历潜逃作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到,就向上一级继续查找。直到找到或者抵达全局作用域为止。

作用域房子

为了将作用域处理的过程可视化,我希望你在脑中想象一个画面:作用域房子。这个房子代表程序中的嵌套作用域链。第一层楼代表当前的执行作用域,也就是你所在的位置。房子的顶层代表全局作用域。

LHS 和 RHS 引用都会在当前楼层进行查找,如果没找到,就会上一层楼。一旦抵达顶层,可能你找到了你想要的变量,也可能什么也没找到,但是这次搜查行动都要宣告结束。

异常

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

因为在变量还没有声明(在任何作用域中都无法找到变量)的情况下,这两种查询行为的结果是不一样的。考虑如下代码:

1
2
3
4
5
function foo(a) {
console.log(a + b);
b = a;
}
foo(2);

第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个未声明的变量,因为在任何相关的作用域中都无法找到它。

如果 RHS 查询在所有嵌套的作用域中遍历不到所需要的变量,引擎就会抛出ReferenceError异常。值的注意的是,ReferenceRrror是非常重要的异常处理。

相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标,全局作用域就会自建一个同名变量,并将其返回给引擎,前提是运行在非严格模式下。

“不,这个变量之前并不存在,但是我很热心地帮你创建了一个。” ES5 中引入了 严格模式 ,这个其实是正常模式。和宽松/慵懒模式相比,严格模式在很多行为处理上都有所不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出ReferenceError异常。

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

小结

作用域是一套规则,用于确定在何处以及如何查找变量(标示符)。如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的只是获取变量储存的值那么会使用 RHS 查询。

赋值操作符会调用 LHS 查询。=操作符或调用函数时传入参数的操作都会引发关联作用域的赋值操作。

JavaScript 引擎首先会在代码执行前对其编译,在这个过程中,像var=2这样的声明会被分解成两个独立的步骤:

  1. 首先var a会在当前作用域声明一个新的变量,这会在代码执行前完成。
  2. 接着a = 2会查询(LHS)这个变量并对其进行赋值操作。

LHS 和 RHS 查询都会在当前执行作用域中开始。如果有需要(也就是说它们没有找到所需要的标示符)就会向上一级作用域继续查找目标标示符,直到找到或者抵达最顶层作用域为止。

不成功的 RHS 会抛出ReferenceError异常。不成功的 LHS 会使得引擎隐式地创建一个全局变量。严格模式可以避免这种情况发生。

词法作用域

在第一章中,我们将作用域定义为一套规则,用来管理引擎如何在当前域或者函数内根据标示符查找变量。作用域有两种主要工作模型。第一种是最为普遍的,被大多是编程语言所采用的 词法作用域 ,我们会对这种模型进行深入探讨。另一种叫做 动态作用域

词法阶段

第一章介绍过,大部分标准语言编译器的第一个工作阶段叫做词法化(也就是单词化)。回忆一下,词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。这个概念是理解词法作用域及其名称由来的基础。

简单的说,词法作用域就是定义在词法阶段的作用域。即词法作用域是由你在写代码时将变量和块写在哪里决定的,因此当词法分析器处理代码时会保持作用域不变。后面会介绍一些欺骗词法作用域的方法,这些方法在词法分析器处理过后依然可以修改作用域,但是这种机制可能有些难以理解。事实上,让词法作用域根据词法关系保持书写时的自然关系不变才是一个非常良好的习惯。考虑以下代码:

1
2
3
4
5
6
7
8
function foo(a) {
var b = a + 2;
function bar(c) {
console.log(a, b, c);
}
bar(b * 3);
}
foo(2); //2 4 12

在这个例子中有三个逐级嵌套的作用域。为了帮助理解我们可以想象它们是几个逐渐包含的气泡。大气泡包含着整个全局作用域。它里面只有一个标示符:foo。中气泡包含着 foo 创建的函数作用域,其中有三个标示符a b bar。小气泡包含着bar创建的函数作用域,其中有一个标示符:c。

作用域气泡由其对应的作用域块代码写的位置决定,它们是逐级包含的下一章会讨论不同类型的作用域,但现在只要假设每一个函数都会创建一个新的气泡作用域就行了。

bar 的气泡被完全包含在foo所创建的气泡中,,唯一的原因是那里就是我们希望