细读《你不知道的JavaScript·上卷》1-1 作用域是什么?

4,395 阅读6分钟

墨言妹带你细读《你不知道的 JavaScript 》系列的世界,深入 JavaScript 语言内部,弄清楚 JavaScript 每一个零部件的用途,知其然更要知其所以然。

作用域是什么

  • 问题1:变量存储在哪里?
  • 问题2:程序需要时如何找到它们?

1.1 编译原理

通常,把 JavaScript 归类为 “ 动态 ” 或 “ 解释执行 ” 的语言,但是事实上它是一门 编译语言,不提前编译,编译结果也不在分布式系统中进行移植。

JavaScript 引擎进行编译的步骤和传统的编译语言非常相似,在某些环节比它要复杂。

传统编译语言,在执行之前的三个步骤,统称为 “ 编译 ” 。

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

    将有字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元( token )。

    var a = 2;
    

    被分解成词法单元:vara=2; 。空格在该语言中有意义,则会被当做词法单元,否则不是。

  • 解析/语法分析( Parsing

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

    var a = 2;
    

    以上代码的抽象语法树如下所示:

    • VariableDeclaration 顶级节点
      • Identifer 子节点,值为 a
      • AssignmentExpression 子节点
        • NumericLiteral 子节点,值为 2
  • 代码生成

    AST 转换为可执代码的过程被称为代码生成。这个过程与语言、目标平台等相关。

    即通过某种方法,将 var a = 2 ;AST 转化为一组机器指令,用来创建一个变量 a ,并将值存储在 a 中。

    引擎,可以根据需要创建并存储变量。

1.2 理解作用域

1.2.1 演员表

  • 引擎,从头到尾负责整个 JavaScript 程序的编译及执行过程。
  • 编译器,负责语法分析及代码生成等脏活累活。
  • 作用域,负责收集并维护由所有声明的标识符(变量)组成一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

1.2.2 对话

JavaScript 引擎是如何处理 JavaScript 代码的?

比如 var a = 2; 存在2个不同的声明,变量的赋值操作会执行两个动作。

  • 遇到 var a ,在编译阶段,编译器先询问当前作用域,在作用域集合中是否存在变量 a ,若不存在则声明一个新变量名为 a;接着编译器会为引擎生成运行时所需的代码,处理 a = 2 这个赋值操作。
  • 遇到 ( a = 2 ),在执行阶段,引擎运行时先询问作用域,在作用域中查找该变量 a,如果找到就将值 2 赋值给变量 a,否则引擎就会举手示意并抛出一个异常。

1.2.3 编译器有话说

  • 如何理解引擎、编译器、作用域的关系

    • 代码先编译后执行,当编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量 a 来判断它是否已经声明过。
    • 查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查找结果。对 JavaScript 引擎的性能要求很高。
  • 引擎查找的两种方式: RHSLHS

    • LHS 查询(左侧):找到变量的容器本身,然后对其赋值,即赋值操作的目标是谁。比如 a = 2; ,为 = 2 这个赋值操作找到一个目标。
    • RHS查询(非左侧):查找某个变量的值,理解为 retrieve his source value ,即谁是赋值操作的源头。比如: console.log( a ); ,需要获取到变量 a 的值,则对变量 aRHS 查询,并传值给 console.log(...)
function foo(a){
	console.log( a ); //2
}
foo(2);

上述代码共有1处 LHS 查询,3处 RHS 查询。

  • LHS 查询有:
    • 隐式的 a = 2 中,在 2 被当做参数传递给 foo(...) 函数时,需要对参数 a 进行 LHS 查询
  • RHS 查询有:
    • 最后一行 foo(...) 函数的调用需要对 foo 进行 RHS 查询,意味着 “去找到 foo 的值,并把它给我 ” ,并且 (...) 意味着 foo 的值需要被执行,因此它最好真的示意函数类型的值。
    • console.log( a ); 中对 a 进行 RHS查询,并且将得到的值传给了 console.log(...)
    • console.log(...) 本身对 console 对象进行 RHS 查询,并且检查得到的值中是否有一个叫作 log 的方法。

1.3 作用域嵌套

作用域是一套规则,用于确定在何处以及如何查找变量(标识符)。当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。

  • 如果查找的目的是对变量进行赋值,那么就会使用 LHS 查询;如果目的是获取变量的值,就会使用 RHS 查询。
  • 赋值操作符会导致 LHS查询。= 操作符或调用函数时传入参数的操作都会导致关联作用域的赋值操作。

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

1.4 异常

为什么区分 LHSRHS ?变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。

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

对一个 “未声明 ” 的变量 b 进行 RHS 查询时,在任何相关的作用域中都无法找到它。

ReferenceError 和作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

  • RHS 查询在作用域链中搜索不到所需的变量,引擎会抛出 ReferenceError 异常。
  • 非严格模式下,LHS 查询在作用域链中搜索不到所需的变量,全局作用域中会创建一个具有该名称的变量并返还给引擎。
  • 严格模式下( ES5 开始,禁止自动或隐式地创建全局变量), LHS 查询失败并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常。
  • RHS 查询成功情况下,对变量进行不合理的操作,引擎会抛出 TypeError 异常。(比如对非函数类型的值进行函数调用,或者引用 nullundefined 类型的值中的属性)。

最后, 书读百遍其义自见,抱着以教为学的初衷,不断反思、刻意练习,若对你有帮助,请点个赞,谢谢您的支持与指教。

参考文献: 木易杨博客

历史文章: 【译】30 Seconds of ES6 (一)