作用域是什么

尽管通常将 JavaScript 归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。
这个事实对你来说可能显而易见,也可能你闻所未闻,取决于你接触过多少编程语言,具
有多少经验。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系
统中进行移植。
尽管如此,JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能
比预想的要复杂。
作用域是什么 | 5
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编
译”。
• 分词/词法分析(Tokenizing/Lexing)
这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代
码块被称为词法单元(token)。例如,考虑程序 var a = 2;。这段程序通常会被分解成
为下面这些词法单元:var、a、=、2 、;。空格是否会被当作词法单元,取决于空格在
这门语言中是否具有意义。
分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的,
主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简
单来说,如果词法单元生成器在判断 a 是一个独立的词法单元还是其他词法
单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法
分析。
• 解析/语法分析(Parsing)
这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法
结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。
var a = 2; 的抽象语法树中可能会有一个叫作 VariableDeclaration 的顶级节点,接下
来是一个叫作 Identifier(它的值是 a)的子节点,以及一个叫作 AssignmentExpression
的子节点。AssignmentExpression 节点有一个叫作 NumericLiteral(它的值是 2)的子
节点。
• 代码生成
将 AST 转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息
息相关。
抛开具体细节,简单来说就是有某种方法可以将 var a = 2; 的 AST 转化为一组机器指
令,用来创建一个叫作 a 的变量(包括分配内存等),并将一个值储存在 a 中。
关于引擎如何管理系统资源超出了我们的讨论范围,因此只需要简单地了解
引擎可以根据需要创建并储存变量即可。
比起那些编译过程只有三个步骤的语言的编译器,JavaScript 引擎要复杂得多。例如,在
语法分析和代码生成阶段有特定的步骤来对运行性能进行优化,包括对冗余元素进行优化
等。
6 | 第 1 章
因此在这里只进行宏观、简单的介绍,接下来你就会发现我们介绍的这些看起来有点高深
的内容与所要讨论的事情有什么关联。
首先,JavaScript 引擎不会有大量的(像其他语言编译器那么多的)时间用来进行优化,因
为与其他语言不同,JavaScript 的编译过程不是发生在构建之前的。
对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短!)的时
间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种办法(比如 JIT,可以延
迟编译甚至实施重编译)来保证性能最佳。
简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。因此,
JavaScript 编译器首先会对 var a = 2; 这段程序进行编译,然后做好执行它的准备,并且
通常马上就会执行它。
1.2 理解作用域
我们学习作用域的方式是将这个过程模拟成几个人物之间的对话。那么,由谁进行这场对
话呢?
1.2.1 演员表
首先介绍将要参与到对程序 var a = 2; 进行处理的过程中的演员们,这样才能理解接下来
将要听到的对话。
• 引擎
从头到尾负责整个 JavaScript 程序的编译及执行过程。
• 编译器
引擎的好朋友之一,负责语法分析及代码生成等脏活累活(详见前一节的内容)。
• 作用域
引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查
询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
为了能够完全理解 JavaScript 的工作原理,你需要开始像引擎(和它的朋友们)一样思考,
从它们的角度提出问题,并从它们的角度回答这些问题。
1.2.2 对话
当你看见 var a = 2; 这段程序时,很可能认为这是一句声明。但我们的新朋友引擎却不这
么看。事实上,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一
作用域是什么 | 7
个则由引擎在运行时处理。
下面我们将 var a = 2; 分解,看看引擎和它的朋友们是如何协同工作的。
编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编
译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。
可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内
存,将其命名为 a,然后将值 2 保存进这个变量。”然而,这并不完全正确。
事实上编译器会进行如下处理。

  1. 遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的
    集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作
    用域的集合中声明一个新的变量,并命名为 a。
  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值
    操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作 a 的
    变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(查看 1.3
    节)。
    如果引擎最终找到了 a 变量,就会将 2 赋值给它。否则引擎就会举手示意并抛出一个异
    常!
    总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如
    果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对
    它赋值。
    1.2.3 编译器有话说
    为了进一步理解,我们需要多介绍一点编译器的术语。
    编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是
    否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查
    找结果。
    在我们的例子中,引擎会为变量 a 进行 LHS 查询。另外一个查找的类型叫作 RHS。
    我打赌你一定能猜到“L”和“R”的含义,它们分别代表左侧和右侧。
    什么东西的左侧和右侧?是一个赋值操作的左侧和右侧。
    换句话说,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。
    8 | 第 1 章
    讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图
    找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋
    值操作的右侧”,更准确地说是“非左侧”。
    你可以将 RHS 理解成 retrieve his source value(取到它的源值),这意味着“得到某某的
    值”。
    让我们继续深入研究。
    考虑以下代码:
    console.log( a );
    其中对 a 的引用是一个 RHS 引用,因为这里 a 并没有赋予任何值。相应地,需要查找并取
    得 a 的值,这样才能将值传递给 console.log(…)。
    相比之下,例如:
    a = 2;
    这里对 a 的引用则是 LHS 引用,因为实际上我们并不关心当前的值是什么,只是想要为 =
    2 这个赋值操作找到一个目标。
    LHS 和 RHS 的含义是“赋值操作的左侧或右侧”并不一定意味着就是“=
    赋值操作符的左侧或右侧”。赋值操作还有其他几种形式,因此在概念上最
    好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头
    (RHS)”。
    考虑下面的程序,其中既有 LHS 也有 RHS 引用:
    function foo(a) {
    console.log( a ); // 2
    }
    foo( 2 );
    最后一行 foo(…) 函数的调用需要对 foo 进行 RHS 引用,意味着“去找到 foo 的值,并把
    它给我”。并且 (…) 意味着 foo 的值需要被执行,因此它最好真的是一个函数类型的值!
    这里还有一个容易被忽略却非常重要的细节。
    代码中隐式的 a=2 操作可能很容易被你忽略掉。这个操作发生在 2 被当作参数传递给
    foo(…) 函数时,2 会被分配给参数 a。为了给参数 a(隐式地)分配值,需要进行一次
    LHS 查询。
    作用域是什么 | 9
    这里还有对 a 进行的 RHS 引用,并且将得到的值传给了 console.log(…)。console.
    log(…) 本身也需要一个引用才能执行,因此会对 console 对象进行 RHS 查询,并且检查
    得到的值中是否有一个叫作 log 的方法。
    最后,在概念上可以理解为在 LHS 和 RHS 之间通过对值 2 进行交互来将其传递进 log(…)
    (通过变量 a 的 RHS 查询)。假设在 log(…) 函数的原生实现中它可以接受参数,在将 2 赋
    值给其中第一个(也许叫作 arg1)参数之前,这个参数需要进行 LHS 引用查询。
    你可能会倾向于将函数声明 function foo(a) {… 概念化为普通的变量声明
    和赋值,比如 var foo、foo = function(a) {…。如果这样理解的话,这
    个函数声明将需要进行 LHS 查询。
    然而还有一个重要的细微差别,编译器可以在代码生成的同时处理声明和值
    的定义,比如在引擎执行代码时,并不会有线程专门用来将一个函数值“分
    配给”foo。因此,将函数声明理解成前面讨论的 LHS 查询和赋值的形式并
    不合适。
    1.2.4 引擎和作用域的对话
    function foo(a) {
    console.log( a ); // 2
    }
    foo( 2 );
    让我们把上面这段代码的处理过程想象成一段对话,这段对话可能是下面这样的。
    引擎:我说作用域,我需要为 foo 进行 RHS 引用。你见过它吗?
    作用域:别说,我还真见过,编译器那小子刚刚声明了它。它是一个函数,给你。
    引擎:哥们太够意思了!好吧,我来执行一下 foo。
    引擎:作用域,还有个事儿。我需要为 a 进行 LHS 引用,这个你见过吗?
    作用域:这个也见过,编译器最近把它声名为 foo 的一个形式参数了,拿去吧。
    引擎:大恩不言谢,你总是这么棒。现在我要把 2 赋值给 a。
    引擎:哥们,不好意思又来打扰你。我要为 console 进行 RHS 引用,你见过它吗?
    作用域:咱俩谁跟谁啊,再说我就是干这个。这个我也有,console 是个内置对象。
    给你。
    引擎:么么哒。我得看看这里面是不是有 log(…)。太好了,找到了,是一个函数。
    引擎:哥们,能帮我再找一下对 a 的 RHS 引用吗?虽然我记得它,但想再确认一次。
    作用域:放心吧,这个变量没有变动过,拿走,不谢。
    引擎:真棒。我来把 a 的值,也就是 2,传递进 log(…)。
    ……
    10 | 第 1 章
    1.2.5 小测验
    检验一下到目前的理解程度。把自己当作引擎,并同作用域进行一次“对话”:
    function foo(a) {
    var b = a;
    return a + b;
    }
    var c = foo( 2 );
  3. 找到其中所有的 LHS 查询。(这里有 3 处!)
  4. 找到其中所有的 RHS 查询。(这里有 4 处!)

你可能感兴趣的:(作用域是什么)