Chapter 1: What is Scope?

"You Don't Know JS: Scope & Closures"

Posted by Wolfdu on July 1, 2017

本文为You don’t know JavaScript学习笔记

什么是作用域

1. 编译原理(compiler theory)

传统编译语言(compiled-language)流程中,源代码在执行前会经历如下三个步骤:

  • 分词/词法分析(Tokenizing/Lexing)

这个过程会将由字符组成的字符串分解为有意义的代码块,这些代码块被称为词法单元(token)。

如代码: var a = 2;

会被分解为如下词法单元: var, a, =, 2,;。

其中空格取决于在该语言中是否有意义。

  • 解析/语法分析(Parsing)

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

  • 代码生成(Code-Generation)

该过程将AST转换为可执行的代码,这个过程与语言,目标平台等有关。

具体为用某种方法讲var a = 2; 的AST转化为一组机器指令,来创建一个叫做a的变量,并将一个值存储在a中。

JavaScript引擎的编译相对如上几个步骤来说要复杂很多,如在语法分析和生成代码的阶段会对运行性能进行优化,对冗余元素进行优化等。

由于JavaScript的编译过程不是发生在构建之前的,所以js引擎不会有大量的时间用来进行优化。大部分的情况下代码的编译发生在代码执行前 的几微秒。

2. 理解作用域

以对程序var a = 2; 的处理过程来了解,作用域。

2.1 首先需要理解如下组件:

  • 引擎(Engine)

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

  • 编译器(Compiler)

负责词法分析和代码生成。

  • 作用域(Scope)

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

2.2 组件间的对话

下面我们将var a = 2; 分解,观察引擎,编译器,作用域是如何交流的。

  • 遇到 var a
  • step1: Compiler ->询问Scope是否有叫做a的变量在当前作用域下,如果Scope回答是,Compiler就会忽略该声明,继续编译; 否则Compiler会要求Scope在当前作用域下声明一个新的变量a。
  • step2: Compiler ->为Engine生成运行是所需要的代码,处理a = 2的操作。
  • step3: Engine ->询问Scope当前作用域是否存在a这个变量,如果存在Engine会使用这个变量;如果不存在,Engine会继续查找 名叫a的变量。
  • step4: Engine ->最终如果找到了a,就会将2赋值给他。否则Engine会抛出一个异常。

总结: 可以将上述4个步骤归结为两个动作,编译器在当前作用域下声明(如果当前作用域下没有该变量)一个变量, 然后引擎会在该作用域中查找该变量,如果存在那么进行赋值操作,如果不存在则抛出异常。

2.3 LHS&RHS

在上述例子中,Compiler生成代码后,Engine执行代码时,会通过查找当前Scope下的变量a判断他是否声明过。

例子中,Engine会对变量a进行LHS查询,另外一个查找类型叫做RHS。

可以简单理解为,当变量出现在一个赋值操作左侧的时候进行LHS查找,出现右侧时进行RHS查找。

准确点,RHS查询简单的理解为查询某个变量的值,而LHS查询则是试图找到变量的容器本身,从而可以对其赋值。

可以将RHS理解为retrieve his source value(获取他的源值)。

分析如下代码

console.log(a);

a的引用是一个RHS引用。需要获取a的源值才能将其传递给console.log(...)

对比 a = 2;

对a引用则为LHS引用。此时我们不关心a当前源值为多少,我们只想为= 2这个赋值操作找到赋值对象即可。

因此在概念上最好理解为:

  • LHS: 赋值操作的目标是谁? 即查询赋值操作的目标。
  • RHS: 谁是赋值操作的源头? 即查询赋值变量的源值。

分析如下代码:

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

foo(2);

我们从函数的调用开始:

  1. foo(...); Engine首先会在当前Scope中对foo进行RHS引用。
  2. foo(2) Engine会在当前Scope中对a进行LHS引用,给形式参数赋值,将2赋值给a。
  3. 进入foo函数内部,Engine在当前Scope下对console进行RHS引用。
  4. 查找console是否有log函数,此时对a进行RHS引用,并将a的源值传递进log(…)

3. 作用域嵌套(Nested Scope)

作用域是根据名称查找变量的一套规则。

当一个块或者一个函数嵌套在另一个块或者函数中时,就发生了作用域的嵌套。

因此在当前作用域中无法找到某个变量的时候,Engine就会在外层嵌套的作用域中进行查找,直到找到该变量 或者一直找到最外层作用域(全局作用域)为止。

分析如下代码:

function foo(a){
    console.log(a + b);
}

var b = 2;

foo(2); // 4

在执行console.lgo(a + b)时对a进行RHS引用找到a的值为2,对b进行RHS引用时在当前作用域中无法完成 但是在上一级作用域中可以完成引用。

遍历作用域的规则: Engine从当前执行的作用域下开始查找,如果没有找到,就会进入上一级作用域继续查找。当遍历到最外层 作用域的时候,无论找到与否都会停止查找。

4. 异常(Exception)

分析如下代码:

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

foo(2);

当第一次对b进行RHS引用时是无法找到该变量的,因此可以说这是一个未声明的变量。

如果RHS查询,遍历到了最外层作用域即全局作用域也没有找到目标变量时,Engine会抛出ReferenceError 异常。

相较之下,LHS查询,如果在全局作用域中没有找到目标变量,全局作用域下创建一个具有该名称的变量,并 返还给Engine(只有在非严格模式下才会这样,严格模式下会抛出异常)。

小结

JavaScript编译流程,大致分为几个步骤?

大致分为三个步骤,但是实际的编译过程比较复杂。

了解引擎,编译器,作用域如何工作,以及他们各自的作用。

了解作用域中的查找规则(LHS&RHS)以及在嵌套作用域中的查找规则。