《你所不知道的JavaScript》读书笔记(一):作用域和闭包(上)

328 阅读8分钟

0. 前言

最近刚看完《你所不知道的JavsScript》的第一部分,为了防止自己遗忘,也是分享欲在作祟吧。想跟大家分享一下自己关于作用域和闭包的新认识。之后,随着阅读的深入,我还会跟继续分享这一系列丛书的其他学习体会。

1. 作用域是什么

几乎所有编程语言最基本的功能之一,就是能够存储变量当中的值,并且能在之后对这些值进行访问和修改。事实上,正式这种存储和访问变量的值的能力将状态带给了程序。
若没有了状态这个概念,程序虽然也能执行一些简单的任务,但它会受到高度限制,做不到非常有趣。
但是将变量引入程序会引起几个很有意思的问题,也正是我们将要讨论的:这些变量住在哪里?换句话说,它们储存在哪里?最重要的是,程序需要时如何找到它们? 这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域。 在第一章的开篇,就通过这样一段描述直接给作用域下了定义:作用域是用来存储变量和查找变量的一套规则。显然,任何一个编程语言都会有属于自己的作用域规则。这些规则与语言的编译和运行息息相关。因此,要想理解JavaScript的作用域,需要从JavaScript的编译原理开始聊起。

1.1 编译原理

编程语言有两大类:编译型语言和解释型语言。编译型语言可以通过编译生成一个可执行文件,然后不需要任何外界的辅助就可以运行;解释型语言则是一边编译一边运行的,在运行的时候需要有解释器来进行编译和运行。我们今天的主角,JS就是一门解释型语言。
我们知道,JS最早是运行在浏览器当中的,虽然nodeJS的出现使得它从浏览器当中被剥离出来,但是nodeJS的底层使用c++编写的V8引擎。而浏览器当中解析JS的也叫做引擎。因此,我们可以知道解析JavaScript的工具叫做引擎。 这本书很有意思的一个做法就是将编译的过程拟人化。它把引擎当作老大哥,引擎的下面还有两个小弟,分别叫做:编译器和作用域。为啥这么说呢?因为引擎是贯穿整个编译和运行过程的,是主导程序编译运行的主干,所以它是老大哥;而编译器主要负责编译过程,即将程序翻译成机器能够看懂的内容;作用域负责变量的存储和查找权限的控制。
介绍完JS编译的三个主角,接下来我们介绍JS的编译过程。JS的编译过程分为三步:

  • 分词/词法分析:将程序分解成为词法单元
  • 解析/语法分析:最终的结果是生成抽象语法树(AST,abstract syntax tree)
  • 代码生成:将AST转换成可执行代码的过程

JS的编译过程是在编译器中执行的。JS的完整编译过程如下图所示。首先,通过词法分析将JS代码变成对于程序而言有意义的代码块,叫做词法单元;接下来,对这些词法单元进行解析,变成抽象语法树;最后,将抽象语法树变成可执行的代码。

js编译过程.png

1.2 理解作用域

接下来,我们从以下四个方面理解作用域:查询变量的方式、作用域扮演的角色、作用域链、报错。

1.2.1 两种查询方式

在编译过程中,查询变量这个工作是由编译器发起的。被查询的对象是作用域,简单来讲,就是编译器向作用域查询某一个变量。而在运行的过程中,查询变量则是由引擎发起的。变量的查询方式有两种:LHS查询和RHS查询。分别代表查询赋值操作的左侧和右侧。那这里的左侧和右侧怎么理解呢?
要理解左侧和右侧,首先需要明白赋值操作的过程。赋值操作需要有两个要素: 容纳变量的容器、向容器中添加的值。赋值操作就是将值添加到变量容器当中。举个栗子:var a = 2;这行代码中,a就是容纳变量的容器,2就是容器中的值。这句代码就是将右侧的2放到左侧的a当中。想必到这里就可以理解左侧和右侧的含义了。RHS查找的是变量的值,LHS查找的是变量的存储空间
最后,我们以一个简单的例子来给这部分内容画一个句号:找出下列代码中的LHS和RHS

function foo(a) {
    var b = a;
    return a + b;
}
var c = foo( 2 );

结论:

  • LHS(三处):
    1. var c中的变量c
    2. foo(a)中的形参变量a
    3. var b中的变量b
  • RHS(四处):
    1. c=foo()中的foo()
    2. foo(2)中的2
    3. return的两个变量都是RHS

1.2.2 作用域扮演的角色

作用域无论在编译过程中还是在运行的过程中都扮演了一个变量管理者的角色,无论何时用到变量都无法离开作用域。

1.2.3 作用域链

当一个块或函数嵌套在另一个块或函数中时,就发生了作用于嵌套。因此,变量查找的规则是,在当前作用域中无法找到某个变量是,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或者抵达最外层的作用域(全局作用域)停止。作用域链就是某个变量由于块或函数进行嵌套,而形成的保存相关作用域的空间。在这里盗用书中的一张图来说明:

作用域链比喻.png

我们把作用域链比作一个高大的建筑,第一层楼代表当前的执行作用域;顶层代表全局作用域。当我们需要查找一个变量时(LHS和RHS都相同),需要在当前楼层进行查找;找不到就到上一层楼去查找;直到走到顶层。如果在这个过程中找到了该变量就返回,否则抛出异常。

1.2.4 两种类型的报错

  • ReferenceError:同作用域判别失败有关
    • 如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出RefrenceError异常
    • 当引擎执行LHS查询时,在非严格模式下,如果在全局作用域中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量
    • 在严格模式下,当引擎执行LHS查询时,如果找不到目标变量,则会抛出RefrenceError异常
  • TypeError:代表作用于判别成功了,但是对结果的操作是非法的或者不合理的

2. 词法作用域

作用域有两种主要的工作模型:

  • 词法作用域:这种模型比较普遍,大多数编程语言所采用这种模型
  • 动态作用域:少数编程语言在使用,比如Bash脚本、Perl等 这部分我们来介绍词法作用域。词法作用域是定义在此法阶段的作用域。换句话说,词法作用域是在写代码时将变量和块作用域写在哪里来决定的。举个栗子:
function foo(a){
    var b = a * 2
    
    function bar(c) {
        console.log(a,b,c)
    }
    bar(b*3)
}
foo(2)

在这段代码中有三个逐级嵌套的作用域:

  • 最外层的是全局作用域,包含一个函数:foo
  • 中间的一层是foo创建的作用域,包含变量a、b和一个函数bar
  • 最里面的一层是bar创建的作用域,包含变量c 这些作用域是在代码定义的时候就已经有的。也就是说,一个.js文件在创建的时候就已经定义了全局作用域;一个函数在定义的时候也会创建属于它的函数作用域;某些代码块也会创建在自己的作用域。每个作用域包含了所有的标识符,即变量名称。
    下一个关于词法作用域的话题是遮蔽效应。 作用域查找会在找到第一个匹配的标识符时停止。这就导致了多层嵌套作用域中只能匹配到在作用域链中相对靠前的变量的值。这就是遮蔽效应,内部的标识符遮蔽了外部的标识符。举个栗子:
var a = 3;
function foo(){
    var a = 2;
    console.log(a)
}

foo()

在这段代码中,输出的结果是2。显然,这段代码定义了两个a变量。在变量查找的时候,会首先找到foo()函数中的变量a=2。此时,查找停止,输出结果。而定义在全局作用域中的变量a则被遮蔽了。
书中词法作用域的最后介绍了两种欺骗词法作用域的方法

  • eval
  • with 但由于这种写法过于消耗性能,因此不推荐使用。在这里,我就不多赘述了。