速读《你不知道的JavaScript(上卷)》

7,240 阅读23分钟

文章首发自公众号:程序员Sunday

关注公众号,与我一对一交流~

WX20230703-163208@2x.png

不想看文字?没有关系,博客提供视频版了!点击直接进入

这是一本由 180 名程序员 共同完成的,并且在 github 上开源的书籍。也是一度被前端开发者奉为 “神作” 的 JavaScript 进阶必读书籍。

前言

hello,大家好,我是 Sunday

《你不知道的JavaScript》,一共被分为了上、中、下 三卷。其中上卷应该是其中的佼佼者,在豆瓣评分高达 9.4

image-20230312162056644.png

同时这本书也在 github 上进行了开源,共有 180 位贡献者

image-20230312162351296.png

可以说,这本书是集合了众多优秀的开发者的智慧来共同完成的。

本书是在 2015 年 4 月 出版,我手中的这本是 2016年 3 月 的第四次印刷版

image-20230312162810799.png

那么在当下(2023 年)这个时间点,我们再次打开这本当年的 “神作”,它还能够给我们带来什么启示呢?让我们拭目以待吧!

正文

大纲

整本书一共分为 两个部分,共计 十一个章节

第一部分:作用域与闭包

首先是第一部分 作用域与闭包。在这一部分中,作者从底层入手,来为我们介绍了什么是作用域。通过对 JS编译原理的分析,引出 词法作用域 的概念,进而牵扯出 函数作用域与块作用域。

然后又通过编辑器的解析流程,引出了 JS 中变量提升的概念,从而解释了最终的 作用域与闭包

第二部分:this 和对象原型

接下来是第二部分 this 和对象原型this 的指向问题一直是前端领域的一个痛点问题,作者在这里对 this 的指向进行了全面的解析,从 4 个方向分析了 this 的绑定规则,并且给出了绑定规则之间的优先级关系。同时作者还对 对象、类 进行了一些进阶的分析,比如 对象的属性描述符、冻结对象属性的方式、getter 行为和 setter 行为。同时也提出了一些别具一格的理论,比如 类在 JS 中是一种可选的设计模式

综上所述,其实我们就可以知道,在上卷中,其实作者主要就介绍了 5个 知识点:作用域、闭包、this 、 对象 和 类

第一部分:① 作用域是什么

说起作用域相信很多同学肯定都已经不陌生了。但是从 JS 编译原理 开始讲作用域的,大家应该都是头一次见吧。

作者告诉我们,想要了解作用域啊,你首先得先 “搞明白 JS 编译原理”。

那么下面咱们就来看看 JS 编译原理 是什么。

JS 的编译原理

所谓编译指的是:源代码执行之前进行的操作。

而对于 JS 而言,它的整个编译过程被粗略分为三大步:

  1. 分词 - 词法分析
  2. 解析 - 语法分析
  3. 代码生成

首先对于 词法分析 而言,它的主要作用就是: 把一段 JS 代码,解析成多个词法单元(token 。我们以 var a = 2; 为例,他会被解析成 5tokenvar、a、=、2、;

其次是 语法分析,它的作用是: token 流转化为 AST (抽象语法树) 。所谓抽象语法树就是一个 树形结构的 JS 对象

最后是 代码生成,它的作用是:AST 解析成可执行的代码(机器指令)

理解作用域

明确好了这三步基础的 JS 编译原理 之后,那么下面我们来尝试理解一下作用域。

作者告诉我们:“作用域的理解需要从一个故事开始~~”。

既然是故事嘛,那肯定得有演员。咱们这次出动了三个演员:

  1. 引擎:负责整个 JavaScript 程序的编译及执行过程(核心)
  2. 编译器:负责语法分析及代码生成等(编译三步)
  3. 作用域:负责收集并维护由所有变量查询,并确定访问权限

明确好这些演员之后,接下来咱们来看这个故事:

引擎 有一天看见了一段代码 var a = 2;,这段代码在引擎看来是两段完全不同的内容,所以引擎把这段代码拆成了两部分:

  1. var a
  2. a = 2

然后把第一段代码交给了 编译器,编译器就拿着这段代码问 作用域,你那有 a 这个变量吗?作用域如果说有,那么编译器就会忽略掉这段声明。否则,则进行 a 变量声明。

接下来,编译器 会为 引擎 生成运行时所需的代码,这些代码被用来处理 a = 2 这个赋值操作。

引擎 会首先询问 作用域:在当前的作用域集合中是否存在一个叫作 a 的 变量。如果是,引擎 就会使用这个变量。否则,引擎 会继续查找该变量(这就涉及到另外一个概念 作用域嵌套)。

在这样的一个故事中,会涉及到两个关键术语:LHSRHS

  • LHS:赋值操作的左侧查询。这并不意味着 LHS 就是赋值符号左侧的操作。大家可以用这句话进行理解 找到变量,对其赋值
  • RHS:赋值操作的右侧查询。同样的道理,它也并不是赋值符号的右侧操作。大家可以用这句话进行理解 取得某变量的值

如果只是这么说,可能大多数同学依然听不懂,咱们下面通过一个例子来看一下。

现在有这样一段代码:

image-20230312195211200.png

在这段代码中涉及到了 一次 LHS 查询,三次 RHS 查询。然后我们通过对话的形式来看一下:

image-20230312195325623.png

查询步骤为:

  1. RHS:foo(2)
  2. LHS:a = 2
  3. RHS:console
  4. RHS:xxx.log(a)

OK,如果大家能够理解 LHS、RHS 的话,那么作者还 “很贴心的” 给我们准备了一个测试例子

F3D8D8E2-F0B9-4B2B-ABC5-963789F4C08F.jpeg

在这个例子中,一共存在 3 次 LHS、4 次 RHS ,大家看看能不能找到的呢:

image-20230312195731249.png

操作流程如下:

  • LHS:var c = xx
  • RHS:foo(2)
  • LHS:a = 2
  • LHS:var b = xx
  • RHS: xxx = a
  • RHS:a
  • RHS:b

作用域嵌套

作用域嵌套的概念,相信大多数同学应该都比较熟悉。咱们来看这段代码:

image-20230313093931755.png

在这段代码中,我们在 foo 函数中写入了一个 bar 函数。那么此时在这段代码中就存在 3 个作用域:

  1. 全局作用域
  2. foo 函数作用域
  3. bar 函数作用域

这三个作用域组合在一起,就叫做 作用域嵌套

当我们在 bar 函数中访问变量 a 的时候,bar 函数作用域中没有 a 变量,所以会向上层进行查找,直到在 foo 作用域中找到 a 变量。 那么这种 逐层向上层作用域查找 的机制,就是作用域嵌套时变量查找机制。

作用在书中给了我们一张图来表示这种 逐层向上 的查找机制:

image-20230313095212901.png

异常

逐层向上 的查找过程中,引擎会从变量当前作用域开始,一直查找到全局作用域。如果到全局作用域还如法查找到变量的话,那么就会抛出 ReferenceError 异常

ReferenceError 异常 表示 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量

而对于 LHS 查询 而言,如果在非严格模式下(例 a = 2),编译器会在全局作用于下声明该变量,然后再为其赋值

而如果在严格模式下,同样会抛出 ReferenceError 异常

总结

那么在第一章中,我们了解了 引擎、编译器、作用域 三者之间的关系,同时也知道了 LHS 和 RHS 的区别。 基于以上的内容,我们对 作用域 做一个定义:

所谓作用域指的是一套规则:这套规则规定如何存储变量,并且之后可以方便的找到这些变量

第一部分:② 词法作用域

那么了解了作用域的基本概念之后,接下来咱们来看 作用域下词法作用域 的概念。

所谓词法作用域指的是:定义表达式并能被访问的区间

我们还是来看刚才的那段代码:

image-20230313093931755.png

在这段代码中,我们知道它包含了三级作用域:

  • ① 包含着整个全局作用域,其中只有一个标识符:foo
  • ② 包含着 foo 所创建的作用域,其中有三个标识符:abarb
  • ③ 包含着 bar 所创建的作用域,其中只有一个标识符:c

这三个作用域其实就是词法作用域的概念。

同时 JS 中为我们提供了两个 API 来 “欺骗” 作用域,也就是 欺骗词法

第一个欺骗词法是 evaleval() 函数会将传入的字符串当做 JavaScript 代码进行执行。利用该语法我们可以把 指定代码,指定在局部作用域下执行

image-20230313103141530.png

第二个欺骗词法是 with:它的作用是 扩展一个语句的作用域链。比如在如下代码中 with 内的代码,会自动指向 obj 对象:

image-20230313103342338.png

但是要注意,with 语法可能会导致内存泄漏:

image-20230313103411871.png

并且 with 会让作用域变得混乱,所以 它是一个不被推荐使用的语法

不光是 with,包括 eval ,它们两个都不应该是我们在日常开发时的首选项,因为它们改变作用域的特性,会导致 引擎无法在编译时对作用域查找进行优化 ,所以我们应该尽量避免使用 evalwith

第一部分:③ 函数作用域与块作用域

了解了词法作用域之后,接下来咱们来看下函数作用于和块级作用域。

首先针对函数作用于而言,它表示 一个函数的作用域范围。属于这个函数的全部变量都可以在整个函数的范围内使用及复用

我们之前很多次的说过 函数是 js 世界的第一公民。创建函数的目的,本质上其实就是为了 把代码 “隐藏” 起来。也就是 最小特权原则

所谓 最小特权原则,指的是:最小限度地暴露必要内容,而将其他内容都“隐藏”起来

但是在某些情况下,如果我们的代码不够完善的话,那么虽然创建了函数,但是依然不符合最小特权原则。比如下面这段代码:

image-20230313104428805.png

在这段代码中,我们声明了一个全局变量 var b。然后在函数中对 b 进行了操作。但是因为 b 是全局变量,所以我们可以在任意位置修改 b 的值,那么这样的一个操作就是 “非常危险” 的。此时的代码就不符合最小特权原则。

我们可以对当前代码进行下修改,把 b 的定义放到函数之后,以避免被全局访问 :

image-20230313105604789.png

同时大家需要注意,因为 var 关键字 不包含块级作用域,所以大家使用的时候要避免出现冲突的问题。

image-20230313105759893.png

ECMAScriptES6 之后新增了 letconst 两个声明变量的关键字,这两个关键字具备块级作用域({} 组成块级作用域),同时 var 也不再被推荐使用了。所以冲突问题倒是可以比较轻松的避免。

第一部分:④ 提升

所谓提升指的是 变量提升 的问题,什么是变量提升呢?咱们来看这两段代码:

image-20230313113036308.png

image-20230313113042179.png

大家可以猜一下这两段代码输出的内容是什么?

第一段代码的输出结果是 2

第二段代码的输出结果是 undefined

如果我们从一个标准的程序设计角度,这样的代码是肯定不能正常运行的。但是因为 var 存在变量提升的问题,所以我们得到了以上两个对应的输出结果。

那么这个变量提升到底是怎么提升的呢?此时啊,编译器就有话说了。

整个 var a = 2; 的代码编译器在处理会分成两部分:

  1. 在 编译阶段,进行定义声明:var a
  2. 在 执行阶段,进行赋值声明:a = 2

根据声明提升,第一段代码会被解析为以下代码:

image-20230313114708410.png

第二段代码会被解析为以下代码:

image-20230313114722831.png

查看变量提升之后的代码,我们就可以很清楚的知道为什么会打印出刚才看到的结果了。

而对于函数而言,同样存在变量提升的问题,同时 当函数和变量同时需要提升时,遵循 函数优先原则。例如,以下代码:

image-20230313115016100.png

被提升之后的内容为:

image-20230313115033555.png

总结

对于变量提升而言,它其实是一个 JS 中的糟粕,新的 letconst 关键字已经不存在变量提升的问题。但是变量提升在面试中依然会经常被问到,所以我们还是需要有所了解的。

当然,也仅仅只是了解一下就可以了。

第一部分:⑤ 作用域与闭包

到这里,对作用域咱们了解的其实就差不多了。作者分别从 词法作用域、函数作用域、块作用域 三个方面对作用域进行了解释。

在第一部分的最后,作者提到了闭包的概念。闭包在我们现在看来已经是一个老生常谈的话题了,如果大家了解闭包的话,那么可以知道,在前面我们所讲到的代码中,很多代码都存在闭包。

那么什么是闭包呢?所为闭包一定是一个函数。通常情况下我们把 能够访问其它函数作用域中变量的函数 叫做闭包函数。

闭包函数在前端开发中是非常常见的,比如:

image-20230313143336452.png

image-20230313143343811.png

这两个函数,都可以被叫做闭包函数。

var 时代,因为 var 不存在块级作用域,所以如果我们进行以下代码输出的话,那么会得到 5 次 6 的输出结果。

image-20230313143457807.png

而想要解决这个问题,就可以利用闭包进行解决:

image-20230313143525400.png

而对于现在而言,如果我们通过 let 代替 var 的话,那么就不会在出现这样的问题了。

第一部分:总结

那么到这里,整个第一部分就说完了。整本书中第一部分所涉及到的东西其实还是很多的。所以我们把重点给大家列一下。

整个第一部分,重点一共有 5 点:

  • ① 编译流程:整个编译的三大步,包括 词法单元 tokenAST 这些东西大家最好可以有一个印象。因为如果你想要成为高级开发者,那么这些东西是需要有一定的了解的。
  • ② 引擎、编译器、作用域:三者的作用,以及他们之间配合协作的方式,也需要有一个大致的印象。
  • LHS && RHS:这也是偏底层的内容了,有所了解就可以。
  • eval && with:这两个欺骗语法,需要有一个了解,至少要知道他们的作用。我在一次录制 面试视频 的时候,就被问到了 with 的作用,如果回答不上来就很尴尬了。
  • let && const 可以解决块级作用域与变量提升:这个在现时代看来应该算是一个常识操作了,如果不了解的话,可以看下咱们之前讲过的 一小时读完《深入理解现代 JavaScript》,彻底掌握 ES6 之后 JavaScript 新特性! ,这里咱们就不去多数说了哈。

第二部分:① 关于 this

接下来,咱们来看第二部分 this 和对象原型

说起 this,它也是一个老生常谈的话题了。作者在书中先描述了一堆错误的 this 指向问题,然后再描述了正确 this 指向问题。

对于错误的 this 描述,我就不给大家重复了。毕竟很多时候我们只需要知道正确的答案就可以了。

所谓 this ,大家首先需要知道 它是在运行时进行绑定的,它的上下文取决于函数调用时的各种条件。 也就是说, this 的值到底是什么,取决于它所在的函数被调用时的上下文,而和它所在的函数定义时没有关系。

同时大家要注意,以上这些描述 仅针对于 function 声明的普通函数,因为我们知道 箭头函数是不会修改 this 指向的。

第二部分:② this 全面解析

那么明确好了 this 的概念之后,下面咱们就来看看 this 指向的相关问题。

刚才我们说过, this 的指向取决于调用函数时的上下文,所以想要确定 this 的指向,我们需要先能够判定函数的调用位置:

image-20230313153225432.png

这段代码为我们描述了函数的调用位置。

明确好了什么叫做函数的调用位置之后,下面咱们来看 this 的绑定规则。

作者把 this 的绑定规则分为了 4 类,虽然类数有点多,但是并不复杂,咱们一起来看一下:

  1. 默认绑定:这是在全局作用域下 this 的指向。全局作用域下 this 指向 window image-20230313153419016转存失败,建议直接上传图片文件
  2. 隐式绑定:以对象方法的形式进行的函数调用,此时 this 指向调用该函数的对象 image-20230313153536270转存失败,建议直接上传图片文件
  3. 显式绑定:call、apply、bind 这三个方法。这三个方法的第一个参数,为函数内 this 的指向。
  4. new 绑定:主要针对构造函数。在这种情况下 this 指向构造生成的实例对象 image-20230313153856425转存失败,建议直接上传图片文件

明确好了 this 的绑定规则之后,接下来作者告诉了我们如何来去判定 this 的指向,也就是 this 指向的判定规则,一共也是分为 4 步:

  1. var bar = new foo():函数是否在new中调用(new绑定)?如果是的话,this 绑定的是新创建的对象。
  2. var bar = foo.call(obj2):函数是否通过call、apply(显式绑定)调用?如果是的话,this 绑定的是指定的对象。
  3. var bar = obj1.foo():函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。
  4. var bar = foo():如果以上都不是的话,则使用默认绑定。如果在严格模式下,就绑定到undefined,否则绑定到 全局对象(window)。

通过以上这四步,我们基本上可以把 this 所涉及到的所有场景都描述下来了。

但是大家一定要注意,以上所有的内容,仅针对 function 函数。因为 箭头函数永不修改 this 指向

第二部分:③ 对象

this 说完之后,接下来我们来看对象。

JS 中,数据类型一共分为两大类:

  • 基本数据类型
  • 复杂数据类型

其中基本数据类型包括:string、number、boolean、null、undefinedES6 之后又新增了两种,书中没有提到,这两种是 symbol、bigint

而基本数据类型,指的其实就是对象,具体可以细分为 ObjectFunctionArray 三大类(有些同学会在复杂数据类型中加上 data 等其他的类型,这个主要看个人理解)。

而我们接下来要说的对象,指的仅为 object

JS 中,想要创建一个对象的话,那么有两种方式:

  1. 字面量形式:image-20230313155434922.png
  2. 构造形式:image-20230313155447173.png

而对于对象的使用(内容),作者在本章中花费了很多的笔墨来进行讲解,整个讲解的内容大致分为了 7 点。

  1. 对象的属性名永远是 字符串。这个不用多说,大家应该是知道的。

  2. 其次对象的访问有两种形式:属性访问:obj.key键访问:obj[key] 。其中第一种是常用的方式,第二种主要针对 key 是变量时使用

  3. 第三:数组也是对象。这个应该也不需要多说才对。

  4. 第四拷贝:对象的拷贝分为 浅拷贝深拷贝 两种。

    1. 浅拷贝表示多个变量引用了同一块内存地址。操作方式也比较简单,可以直接通过 = 赋值符Object.assgin 进行实现。

    2. 深拷贝又分为两种:

      1. 浅层的深拷贝(对象下不再包含复杂数据类型):对于这种可以直接通过 JSON.parse( JSON.stringify( obj ) ); 的方式完成
      2. 深层的深拷贝(对象下还包含其他复杂数据类型数据):对于这种必须要通过 递归 的方式完成深拷贝。比较简单的方式就是直接使用 Lodash.cloneDeep 方法
  5. 第五属性描述符:对象中每个属性,都存在属性描述符。可以通过 Object.getOwnPropertyDescriptor() 方法来获取对应的属性描述符。不同的属性描述符代表了不同的作用:

    1. value:该属性的值 (仅针对数据属性描述符有效)
    2. writable:当且仅当属性的值可以被改变时为 true
    3. configurable:当且仅当指定对象的属性描述可以被改变或者属性可被删除时,为 true
    4. enumerable:当且仅当指定对象的属性可以被枚举出时,为 true

    也可以通过 Object.defineProperty() 方法修改指定属性的属性描述符。

  6. 第六是 对象属性不可变的方法:作者在这里一共列举出了四个方法,大家可以作为了解:

    1. 对象常量:结合 writable:falseconfigurable:false 就可以创建一个真正的常量属性
    2. 禁止扩展:Object.preventExtensions(..)
    3. 密封:Object.seal
    4. 冻结:Object.freeze()
  7. 第七是 getter 与 setter:这里主要是两个标记符,他可以让我们像调用属性一样触发方法

image-20230313174231848.png

关于对象的最后一个环节就是枚举了。本书在讲解枚举的时候,并不贴近现在的 JS 使用场景,如果大家想要了解对象枚举的方式的话,可以看下我讲解的另一本书 《深入理解现代 JavaScript》

第二部分:④ 混合对象 “类”

了解完了对象之后,接下来我们来看 “类”。

想要了解类的话,那么大家需要首先搞明白 对象和类的区别

对象永远指的是一个具体的实例,而类指的是某一类事物。

比如:

以汽车为类,具体的某一辆汽车可以称之为对象(实例对象)。

以人为类,具体的某一个人可以称之为对象(实例对象)。

类本质上是一种设计模式。所以在 JS 中类是可选的模式。大家可以想一想在大家的日常项目开发中,如果你是以 vue 为主的话,那么应该几乎没有使用过类的概念才对。 react 的同学可能对 class 的概念使用的会多一些。

基于类的设计模式,我们可以在 JS 中构建出类似的代码逻辑:

image-20230313174903812.png

这个代码主要使用到了 ES6class 关键字构建了类,然后通过 new 创建了对应的实例对象。

同时针对于类而言,它是存在继承的概念的。因为存在继承,所以延伸出了多态的概念。所谓多态指的是 从一个父类派生出多个子类,可以使子类之间有不同的行为,这种行为称之为多态

而具体在 JS 中如何进行继承,作者在书中描述的并不详尽,所以大家如果想要了解 JS 中类继承的语法 ,我更建议大家去看 《深入理解现代 JavaScript》

第二部分:⑤ 原型

而对于 JS 来说,原型和原型链同样也是一个经久不衰的话题。

所谓的原型指的就是 [[prototype]],每个 JS 对象都存在 [[prototype]] 属性,该属性就是该对象的原型对象。原型对象也是对象,所以它也存在 [[prototype]] 属性,也就是 原型对象的原型对象。同样原型对象的原型对象也存在 [[prototype]]。这就连成了一个链,这个链被叫做 原型链。整个原型链一直到 null 为止。

第二部分:剩余内容

到这里其实咱们整个第二部分的关键内容就说的差不多了,剩余的内容在本书中的讲解都是基于 不存在 class 的前提条件 进行的。对于咱们现在的前端开发而言,其实意义不大。

如果大家想要了解其他的 类、原型对象、对象关联... 等等概念的话,还是建议大家去看 《深入理解现代 JavaScript》

第二部分总结

那么对于本书的第二部分而言,核心内容主要有 4 点:

  1. this 的绑定规则:这个绑定规则作者在书中介绍的还是非常详尽的
  2. 对象:作者对于对象的使用介绍的也非常详细,值得一读。
  3. 混合对象 “类”:类在 JS 中是一种可选的设计模式,这句话我感觉说的非常好,在这里分享给大家
  4. [[prototype]]:原型链是一个面试的时候经常被问的概念,大家需要有所了解。

总结

OK,那么到这里咱们的 《你不知道的 JavaScript (上卷)》 就全部看完了。

通过书中的内容,我们可以知道这本当年的神作,确实有很多经典的讲解,比如 LHS && RHS引擎、编辑器、作用域 的对话 都堪称经典内容。

但是对于现时代的前端开发而言,也确实存在了很多时代的局限问题,比如 行为委托原型 中很多的概念。

那么咱们这次的《你不知道的 JavaScript (上卷)》就说到这里。

我是 Sunday,陪大家一起读书,一起分享技术知识,咱们下次再见,88~~~