前言

几乎所有的编程语言最基本的功能之一,就是能够储存变量中的值,并且能在之后对这个值进行访问和修改,正是这种储存和访问变量的能力将状态带给了程序。因此需要一套设计良好的规则来储存变量,而这套规则被称为作用域。

正文

1.1 编译原理

JavaScript与传统编译语言的区别

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

  • 不是提前编译的
  • 编译结果不能在分布式系统中进行移植
  • JS引擎进行编译的步骤和传统编译语言相似,但更复杂

    传统编译语言流程

  1. 分词/词法分析
    1. 将字符组成的字符串分解成有意义的代码块(词法单元)。例如, var a = 2; --->> vara2;空格是否被当做词法单元,取决于其在这门语言中是否具有意义。

分词和词法分析的区别主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。
词法分析: 词法单元生成器在判断a是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,这个过程就是词法分析。这个过程生成了词法单元流(数组)

  1. 解析/语法分析

    1. 将词法分析生成的词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树就是抽象语法树(AST
  2. 代码生成

    1. AST转换为可执行代码的过程被称为代码生成(过程与语言、目标平台等息息相关)。

    可以将var a = 2;的AST转化为一组机器指令,用来创建变量a,并将一个值存储在a中。

    比起那些编译过程只有三个步骤的语言编译器,JS引擎要复杂得多,例如在语法分析和代码生成阶段有特定的步骤对运行性能进行优化,包括对冗余元素进行优化等。JS引擎不会有大量的时间用来进行优化,他的编译发生在代码执行前的几微秒,在我们所要讨论的作用域背后,JS引擎用尽了各种办法(比如JIT,可以延时编译甚至实施重编译)来保证性能最佳。

1.2 理解作用域

1.2.1成员表

在理解作用域之前,需要了解参与对var a = 2;处理过程的成员。

  • 引擎

负责整个JS程序的编译和执行

  • 编译器

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

  • 作用域

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

1.2.2 对话

对于var a = 2;很可能认为这是一句声明,事实上引擎认为有两个完全不同的声明:

  • 一个在编译器在编译时处理
  • 一个由引擎在运行时处理

对于这段代码编译器做了什么?
编译器首先将这段程序分解成词法单元,然后将词法单元解析成一个树结构,然后编译器开始进行代码生成:

  1. 声明变量: 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域集合中。如果有,编译器会忽略该声明,继续进行编译;如果编译器会要求作用域在当前作用域集合中声明一个新的变量,并命名为a。
  2. 编译器为引擎生成运行时所需的代码: 这些代码被用来处理a = 2这个赋值操作。引擎运行时会首先询问作用域,在当前作用域集合中是否存在一个叫做a的变量。如果有,引擎引擎就会使用这个变量,如果没有,引擎会继续查找该变量。

总结: 变量的赋值操作会执行两个动作:

  1. 首先编译器会在当前作用域中声明一个变量(如果之前没有声明过)
  2. 运行时引擎会在作用域中查找该变量(查找过程需要作用域协助),如果能找到就会对他赋值。

    1.2.3 编译器有话说

    基本概念

    编译器在编译过程中的第二步中生成了代码,JS引擎执行时,会通过查找变量a来判断他是否已经声明过,查找的过程由作用域进行协助,但是引擎的查找由两种方式: LHS(变量在赋值操作的左侧)、RHS(变量在右侧)。

RHS与简单地查找某个变量别无二致,而LHS查询则是试图找到变量容器的本身,从而对其赋值。这样理解:
LHS: 赋值操作的目标是谁
RHS: 赋值操作的源头

例子

  1. function foo(a) {
  2. console.log(a); //2
  3. }
  4. foo(2)
  5. /*
  6. 1. 最后一行foo(..)函数的调用需要对foo进行RHS引用,意味着“去找到foo的值(因为(..),所以最好是一个函数类型,因为要执行),并把它给我“。-----进行一次LHS查询
  7. 2. 值2会被分配给参数a,------进行一次LHS查询
  8. 3. 对console对象进行RHS查询,检查得到的值中是否有一个叫做log的方法;对a 进行RHS引用,将得到的值传给console.log(..)
  9. */

注意点

你可能倾向将函数声明function foo(a) {...概念化为普通的变量声明和赋值,比如var foo、foo = function(a) {...,如果这样理解的话,会进行LHS查询,因此不是这样。
编译器可以在生成代码的同时处理声明和值的定义,也就是在引擎执行代码时,并不会有专门的线程将一个值分配给”foo”,因此函数声明并不能理解成前面讨论的LHS查询和赋值的形式。

1.2.4 引擎和作用域的对话

  1. function foo(a) {
  2. console.log(a); //2
  3. }
  4. foo(2)

引擎: 我需要为foo进行RHS引用,你见过它吗?
作用域:见过,编译器刚刚声明了它,他是一个函数,给你。
引擎: 够意思,我来执行以下foo
引擎: 还有一个事情,我需要为a进行LHS引用,你见过吗?
作用域: 这个我见过,编译器最近把他声明为foo的一个形式参数了,拿去吧。
引擎: 大恩不言谢,现在我要把2赋值给a。
引擎: 哥们不好意思,又来打扰你了。我要为console进行RHS引用,你见过它吗?
作用域: console是个内置对象,给你。
引擎: 好的,我得看一下这里面是不是有log(..)。太好了,找到了,是一个函数。
引擎: 能再帮我找一下对a的RHS引用吗?虽然我记得他,但想再确认一下。
作用域: 放心吧,这个变量没有动过,拿走不谢。
引擎: 真棒。我来吧a的值,也就是2,传递进log(..)。

1.3 作用域嵌套

概念

我们说过,作用域是根据名称查找变量的一套规则。实际情况中,通常需要同时顾及几个作用域。
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域嵌套。

规则

1遍历嵌套作用域的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到就逐级查找。当抵达最外层的全局作用域时,无论找到还是没有找到,查找过程都会停止。

1.4 异常

为什么区分RHS和LHS

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

第一次对b进行RHS查询,在任何相关的作用域中都无法找到所需的变量,引擎抛出ReferenceError异常;当进行LHS查询的时候,如果在顶层(全局作用域)中无法找到该目标变量,全局作用域就会创建一个具有该名称的变量,前提是程序运行在非严格模式下。

但是在严格模式下,禁止自动或者隐式地创建全局变量,而会抛出RHS查询时类似的错误ReferenceError异常

写在后面

小结

  • 作用域及LHS/RHS概念

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

  • 编译做了什么

JavaScript引擎首先会在代码执行前对其进行编译,以var a = 2为例:

  1. 首先var a 在其作用域中声明新变量(最开始的阶段,即代码执行前进行)
  2. a = 2会查询(LHS)变量a并对其进行赋值
  • 作用域查找

LHS和RHS都会在当前执行作用域中开始,如果有需要(没有找到所需的标识符或者变量),就会向上级作用域继续查找目标标识符,直到最后抵达全局作用域,抛出异常。

下一篇

词法作用域(进行中)

上一篇

深入javascript系列—-系列介绍