拨开迷雾:深入理解JavaScript的作用域与闭包 | 《你不知道的JavaScript》学习笔记(一)

43 阅读7分钟

前言

《你不知道的JavaScript》这套书以其独特的深度和清晰的阐述,成为了许多前端开发者进阶的必读之作。上卷开篇便直指语言的核心机制:作用域闭包。本文将围绕第一、二章的内容,分享我的学习心得与总结。

第一章:作用域是什么?

几乎所有编程语言最基本的功能之一,就是能够存储变量中的值,并且能在之后对这个值进行访问或修改。这套规则,就是作用域

1.1 编译原理

与传统认知不同,JavaScript并非纯粹的“动态”或“解释型”语言。它是一门编译语言。但这个编译过程不是发生在构建之前,而是发生在代码执行前的几微秒(甚至更短)的时间内。

引擎在解释JavaScript代码之前,会经历三个步骤:

  1. 分词/词法分析:将代码字符串分解成有意义的代码块(词法单元)。例如,var a = 2; 会被分解为 vara=2;
  2. 解析/语法分析:将词法单元流(数组)转换成一个由元素逐级嵌套所组成的“抽象语法树”(AST)。
  3. 代码生成:将AST转换为可执行代码的过程。

理解JavaScript需要编译,是理解其作用域管理的关键前提。

1.2 理解作用域

作者用一个非常形象的“对话”模型来讲解作用域的执行过程,参与者包括:

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

对于 var a = 2; 这条语句,我们通常会认为这是一个声明。但引擎并不这么看。它会将它拆成两个步骤:

  1. 编译阶段:编译器询问作用域是否已有一个名为 a 的变量存在于同一个作用域集合中。如果是,则忽略该声明;否则,编译器会在当前作用域中声明一个新变量,并命名为 a
  2. 执行阶段:引擎运行时,会先询问作用域,在当前作用域集合中是否存在一个叫作 a 的变量。如果是,引擎就会使用这个变量,并进行赋值操作(a = 2);如果否,引擎会继续向外层作用域查找(详见作用域链)。

这里的关键在于区分 LHS查询RHS查询

  • LHS(Left-hand Side):找到变量容器本身,以便对其赋值。例如 a = 2,我们只关心赋值操作的目标是谁。
  • RHS(Right-hand Side):获取变量的源值。例如 console.log(a),我们需要查找并取得 a 的值。

思考以下代码,区分LHS和RHS:

function foo(a) {
    console.log(a); // 2
}
foo(2);
  1. 执行 foo(2),这是一个RHS引用(查找foo的值)。
  2. 2 作为参数传递给 foo 时,隐含了一个 a = 2 的赋值操作,这是一个LHS引用。
  3. 执行 console.log(a),对 a 进行RHS引用。
  4. 执行 console.log(...),对 console 对象进行RHS引用,并检查是否有 log 方法。

1.3 作用域嵌套与异常

当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层(全局作用域)为止。

LHS和RHS查询在变量“未声明”(在所有作用域中都找不到)时的行为是不同的:

  • RHS查询失败:引擎会抛出 ReferenceError(引用错误)。
  • LHS查询失败:在非严格模式下,全局作用域会自动创建一个该名称的变量并返回给引擎;在严格模式下,它也会抛出 ReferenceError
  • 严格模式: ES5中引入了“严格模式”,严格模式禁止自动或隐式地创建全局变量

此外,如果RHS查询到了一个变量,但你尝试对这个变量的值进行不合理的操作(例如对一个非函数类型的值进行函数调用),引擎会抛出 TypeError(类型错误)。

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

第二章:词法作用域

作用域共有两种主要的工作模型:词法作用域动态作用域。JavaScript采用的就是前者。

2.1 词法阶段

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的。因此,在词法分析器处理代码时,作用域就已经基本确定了(大部分情况下)。

考虑以下代码:

function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log(a, b, c);
    }
    bar(b * 3);
}
foo(2); // 2, 4, 12

这个例子中有三个逐级嵌套的作用域:

  1. 全局作用域:包含一个标识符 foo
  2. foo所创建的作用域:包含三个标识符 abbar
  3. bar所创建的作用域:包含一个标识符 c

作用域气泡的结构和互相之间的位置关系,完全由代码的书写位置决定。查找变量时,作用域查找会在找到第一个匹配的标识符时停止。内部的标识符会“遮蔽”外部的同名标识符。

全局变量会自动成为全局对象的属性,因此可以通过 window.a 的方式访问被遮蔽的全局变量。但非全局的变量如果被遮蔽了,就无法被访问到。

2.2 欺骗词法(不推荐)

JavaScript中有两种机制可以在运行时“修改”词法作用域,但这两种机制都会导致性能下降,因为引擎无法在编译阶段对作用域查找进行优化。

  1. eval eval(...) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。它会动态地对词法作用域进行修改。

    function foo(str, a) {
        eval(str); // 欺骗!
        console.log(a, b);
    }
    var b = 2;
    foo("var b = 3;", 1); // 1, 3
    

    在严格模式下,eval 有其自身的作用域,无法修改所在的作用域。

  2. with with 通常被当作重复引用同一个对象中的多个属性的快捷方式。

    var obj = { a: 1, b: 2, c: 3 };
    // 单调乏味的重复 "obj"
    obj.a = 2;
    obj.b = 3;
    obj.c = 4;
    // 简单的快捷方式
    with (obj) {
        a = 3;
        b = 4;
        c = 5;
    }
    

    with 会将一个对象处理为一个完全隔离的词法作用域,对象的属性会被定义为这个作用域中的标识符。但它的副作用是:当对对象进行LHS引用,如果对象没有该属性,这个LHS引用会泄漏到全局作用域,意外地创建一个全局变量。(前提是在非严格模式下)with 在严格模式下被完全禁止

总结与启示

通过第一、二章的学习,我们清晰地认识到:

  1. JavaScript有编译过程:理解引擎、编译器、作用域的协作,是理解变量提升、作用域等概念的基础。
  2. LHS与RHS查询:这两种查询方式贯穿始终,它们的不同行为直接关系到错误类型和变量查找机制。
  3. 词法作用域是主导:代码的书写位置决定了作用域的嵌套关系,这使得程序在编译阶段就能进行大量优化,保证了执行效率。
  4. 避免欺骗词法evalwith 会破坏这种可预测性,导致性能损失和难以维护的代码,应避免使用。

这两章为我们打下了坚实的理论基础。理解了作用域的工作机制,就如同拿到了打开JavaScript世界大门的钥匙,为我们接下来深入理解闭包——这个看似神秘却又无处不在的概念,铺平了道路。