深入理解《你不知道的 JavaScript》1-2 章:作用域与词法作用域学习笔记

51 阅读6分钟

在前端开发的学习旅程中,JavaScript 的作用域和词法作用域是基石般的存在,却也常常被开发者一知半解。近期学习了《你不知道的 JavaScript》前两章,对这部分知识有了更系统的认知,特此整理学习笔记,与大家分享。

第 1 章 作用域是什么

1.1 编译原理

JavaScript 虽然是一门解释型语言,但它的执行也离不开 “编译” 环节,只是这个过程非常迅速且隐蔽。传统编译语言的编译过程分为分词 / 词法分析、解析 / 语法分析、代码生成三个阶段,JavaScript 的编译过程与之类似。

  • 分词 / 词法分析:将代码中的字符序列分解成有意义的词法单元,比如var a = 2;会被分解为vara=2;这些词法单元。
  • 解析 / 语法分析:将词法单元转换为抽象语法树(AST),这是一种对代码结构的抽象表示。
  • 代码生成:将抽象语法树转换为可执行的机器码。

理解这一过程,能帮助我们从底层视角看待变量的声明、赋值等操作。

1.2 理解作用域

作者用了非常形象的类比来帮助读者理解作用域。

  • 演员表:将变量、函数等标识符类比为演员,它们在代码中扮演特定的角色。

  • 对话:代码的执行过程就像一场对话,引擎(负责执行代码)、编译器(负责词法分析、编译等)和作用域(负责管理标识符的查找)三者之间不断交流。

    • 当遇到var a = 2;时,编译器首先会询问作用域:“是否已经有一个叫做a的变量在当前作用域中?” 如果没有,编译器会要求作用域声明一个新的变量a,然后编译器会生成代码,让引擎在运行时将2赋值给a
  • 编译器有话说:编译器在编译阶段会对变量进行声明处理,这也就解释了 JavaScript 中的 “变量提升” 现象,即变量的声明会被提升到作用域的顶部,而赋值操作则保留在原地。

  • 引擎和作用域的对话:当引擎执行代码时,遇到变量引用,会向作用域查询该变量的值。如果当前作用域没有,就会向父级作用域查询,直到全局作用域,这就是作用域的查找机制。

  • 小测验:通过一些示例题目,比如a = 2; var a; console.log(a);,来验证对变量提升的理解,答案是2,因为var a的声明被提升,而a = 2的赋值在运行时执行。

1.3 作用域嵌套

在 JavaScript 中,作用域是可以嵌套的,就像俄罗斯套娃。如果一个作用域嵌套在另一个作用域中,那么内部作用域可以访问外部作用域的变量,反之则不行。

例如:

javascript

var a = 1;
function outer() {
    var b = 2;
    function inner() {
        var c = 3;
        console.log(a, b, c); // 1, 2, 3
    }
    inner();
    console.log(a, b); // 1, 2
}
outer();
console.log(a); // 1

inner函数作用域中,可以访问outer作用域的b和全局作用域的a

1.4 异常

当引擎在作用域链中查找一个变量,直到全局作用域都没有找到时,就会抛出ReferenceError异常;而如果找到了变量,但对其进行了不合理的操作,比如对一个非函数变量进行函数调用,就会抛出TypeError异常。

1.5 小结

第 1 章从宏观角度介绍了作用域的概念,通过编译原理、形象类比等方式,让我们理解了作用域是如何管理标识符的声明和查找的,为后续学习词法作用域奠定了基础。

第 2 章 词法作用域

2.1 词法阶段

词法作用域是由代码的书写位置决定的,在词法分析阶段就已经确定了,因此也称为静态作用域。

换句话说,当我们编写代码时,每个函数被定义的位置就决定了它的词法作用域,也就是它能访问哪些变量。

例如:

javascript

var a = 10;
function foo() {
    var b = 20;
    function bar() {
        var c = 30;
        console.log(a, b, c); // 10, 20, 30
    }
    bar();
}
foo();

bar函数的词法作用域由它在代码中被定义的位置决定,它可以访问foo作用域的b和全局作用域的a

2.2 欺骗词法

虽然词法作用域是静态的,但 JavaScript 中有一些机制可以 “欺骗” 词法作用域,不过这些机制并不推荐使用,因为会破坏代码的可读性和性能。

  • evaleval函数可以在运行时动态执行一段代码,并且这段代码会被视为在当前作用域中书写的,从而修改词法作用域。

    例如:

    javascript

    var a = 1;
    function foo() {
        eval("var a = 2;");
        console.log(a); // 2
    }
    foo();
    console.log(a); // 1
    

    foo函数中,eval执行的代码var a = 2;被视为在foo作用域中书写的,因此修改了foo作用域中的a,而全局作用域的a不受影响。

  • withwith语句用于将一个对象的属性视为当前作用域中的变量。

    例如:

    javascript

    var obj = {a: 1, b: 2};
    with(obj) {
        a = 3;
        b = 4;
        c = 5;
    }
    console.log(obj.a, obj.b); // 3, 4
    console.log(c); // 5(在全局作用域中)
    

    withobj的属性ab视为当前作用域的变量,对c的赋值因为在obj中找不到,所以被赋值到了全局作用域。

  • 性能:使用evalwith会导致 JavaScript 引擎无法在编译阶段对作用域进行优化,因为它们会动态改变词法作用域,使得引擎无法确定变量的查找位置,从而降低代码的执行性能。

2.3 小结

第 2 章详细介绍了词法作用域,它是 JavaScript 作用域的核心机制,由代码的书写位置决定。同时也了解了evalwith这两个可以 “欺骗” 词法作用域的机制,以及它们对性能的负面影响,在实际开发中应尽量避免使用。

个人总结

通过对这两章的学习,我对 JavaScript 的作用域有了更深入、更系统的理解,这对于后续学习闭包、模块化等知识至关重要。在日常开发中,清晰的作用域认知能帮助我们避免变量污染、理解代码的执行流程,从而写出更健壮、更高效的代码。