文章首发自公众号:程序员Sunday
关注公众号,与我一对一交流~
序
这是一本由 180 名程序员
共同完成的,并且在 github 上开源的书籍。也是一度被前端开发者奉为 “神作” 的 JavaScript
进阶必读书籍。
前言
hello
,大家好,我是 Sunday
。
《你不知道的JavaScript》,一共被分为了上、中、下 三卷。其中上卷应该是其中的佼佼者,在豆瓣评分高达 9.4
分
同时这本书也在 github
上进行了开源,共有 180
位贡献者
可以说,这本书是集合了众多优秀的开发者的智慧来共同完成的。
本书是在 2015 年 4 月
出版,我手中的这本是 2016年 3 月
的第四次印刷版
那么在当下(2023 年
)这个时间点,我们再次打开这本当年的 “神作”,它还能够给我们带来什么启示呢?让我们拭目以待吧!
正文
大纲
整本书一共分为 两个部分,共计 十一个章节。
第一部分:作用域与闭包
首先是第一部分 作用域与闭包。在这一部分中,作者从底层入手,来为我们介绍了什么是作用域。通过对 JS
编译原理的分析,引出 词法作用域 的概念,进而牵扯出 函数作用域与块作用域。
然后又通过编辑器的解析流程,引出了 JS
中变量提升的概念,从而解释了最终的 作用域与闭包。
第二部分:this 和对象原型
接下来是第二部分 this 和对象原型。this
的指向问题一直是前端领域的一个痛点问题,作者在这里对 this
的指向进行了全面的解析,从 4
个方向分析了 this
的绑定规则,并且给出了绑定规则之间的优先级关系。同时作者还对 对象、类 进行了一些进阶的分析,比如 对象的属性描述符、冻结对象属性的方式、getter
行为和 setter
行为。同时也提出了一些别具一格的理论,比如 类在 JS 中是一种可选的设计模式
综上所述,其实我们就可以知道,在上卷中,其实作者主要就介绍了 5个
知识点:作用域、闭包、this 、 对象 和 类
第一部分:① 作用域是什么
说起作用域相信很多同学肯定都已经不陌生了。但是从 JS 编译原理 开始讲作用域的,大家应该都是头一次见吧。
作者告诉我们,想要了解作用域啊,你首先得先 “搞明白 JS 编译原理”。
那么下面咱们就来看看 JS 编译原理 是什么。
JS 的编译原理
所谓编译指的是:源代码执行之前进行的操作。
而对于 JS
而言,它的整个编译过程被粗略分为三大步:
- 分词 - 词法分析
- 解析 - 语法分析
- 代码生成
首先对于 词法分析 而言,它的主要作用就是: 把一段 JS
代码,解析成多个词法单元(token
) 。我们以 var a = 2;
为例,他会被解析成 5
个 token
:var、a、=、2、;
其次是 语法分析,它的作用是: 把 token
流转化为 AST (抽象语法树)
。所谓抽象语法树就是一个 树形结构的 JS
对象
最后是 代码生成,它的作用是:把 AST
解析成可执行的代码(机器指令)
理解作用域
明确好了这三步基础的 JS 编译原理
之后,那么下面我们来尝试理解一下作用域。
作者告诉我们:“作用域的理解需要从一个故事开始~~”。
既然是故事嘛,那肯定得有演员。咱们这次出动了三个演员:
- 引擎:负责整个 JavaScript 程序的编译及执行过程(核心)
- 编译器:负责语法分析及代码生成等(编译三步)
- 作用域:负责收集并维护由所有变量查询,并确定访问权限
明确好这些演员之后,接下来咱们来看这个故事:
引擎 有一天看见了一段代码
var a = 2;
,这段代码在引擎看来是两段完全不同的内容,所以引擎把这段代码拆成了两部分:
var a
a = 2
然后把第一段代码交给了 编译器,编译器就拿着这段代码问 作用域,你那有
a
这个变量吗?作用域如果说有,那么编译器就会忽略掉这段声明。否则,则进行a
变量声明。接下来,编译器 会为 引擎 生成运行时所需的代码,这些代码被用来处理
a = 2
这个赋值操作。引擎 会首先询问 作用域:在当前的作用域集合中是否存在一个叫作
a
的 变量。如果是,引擎 就会使用这个变量。否则,引擎 会继续查找该变量(这就涉及到另外一个概念 作用域嵌套)。
在这样的一个故事中,会涉及到两个关键术语:LHS
和 RHS
。
LHS
:赋值操作的左侧查询。这并不意味着LHS
就是赋值符号左侧的操作。大家可以用这句话进行理解 找到变量,对其赋值RHS
:赋值操作的右侧查询。同样的道理,它也并不是赋值符号的右侧操作。大家可以用这句话进行理解 取得某变量的值
如果只是这么说,可能大多数同学依然听不懂,咱们下面通过一个例子来看一下。
现在有这样一段代码:
在这段代码中涉及到了 一次
LHS
查询,三次RHS
查询。然后我们通过对话的形式来看一下:
查询步骤为:
- RHS:foo(2)
- LHS:a = 2
- RHS:console
- RHS:xxx.log(a)
OK
,如果大家能够理解 LHS、RHS
的话,那么作者还 “很贴心的” 给我们准备了一个测试例子
在这个例子中,一共存在 3 次 LHS
、4 次 RHS
,大家看看能不能找到的呢:
操作流程如下:
- LHS:var c = xx
- RHS:foo(2)
- LHS:a = 2
- LHS:var b = xx
- RHS: xxx = a
- RHS:a
- RHS:b
作用域嵌套
作用域嵌套的概念,相信大多数同学应该都比较熟悉。咱们来看这段代码:
在这段代码中,我们在 foo
函数中写入了一个 bar
函数。那么此时在这段代码中就存在 3
个作用域:
- 全局作用域
foo
函数作用域bar
函数作用域
这三个作用域组合在一起,就叫做 作用域嵌套。
当我们在 bar
函数中访问变量 a
的时候,bar
函数作用域中没有 a
变量,所以会向上层进行查找,直到在 foo
作用域中找到 a
变量。 那么这种 逐层向上层作用域查找 的机制,就是作用域嵌套时变量查找机制。
作用在书中给了我们一张图来表示这种 逐层向上 的查找机制:
异常
在 逐层向上 的查找过程中,引擎会从变量当前作用域开始,一直查找到全局作用域。如果到全局作用域还如法查找到变量的话,那么就会抛出 ReferenceError 异常
。
ReferenceError 异常
表示 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量。
而对于 LHS 查询
而言,如果在非严格模式下(例 a = 2
),编译器会在全局作用于下声明该变量,然后再为其赋值。
而如果在严格模式下,同样会抛出 ReferenceError 异常
总结
那么在第一章中,我们了解了 引擎、编译器、作用域 三者之间的关系,同时也知道了 LHS 和 RHS 的区别。 基于以上的内容,我们对 作用域
做一个定义:
所谓作用域指的是一套规则:这套规则规定如何存储变量,并且之后可以方便的找到这些变量。
第一部分:② 词法作用域
那么了解了作用域的基本概念之后,接下来咱们来看 作用域下词法作用域 的概念。
所谓词法作用域指的是:定义表达式并能被访问的区间
我们还是来看刚才的那段代码:
在这段代码中,我们知道它包含了三级作用域:
- ① 包含着整个全局作用域,其中只有一个标识符:
foo
- ② 包含着
foo
所创建的作用域,其中有三个标识符:a
、bar
和b
- ③ 包含着
bar
所创建的作用域,其中只有一个标识符:c
这三个作用域其实就是词法作用域的概念。
同时 JS
中为我们提供了两个 API
来 “欺骗” 作用域,也就是 欺骗词法。
第一个欺骗词法是 eval
:eval()
函数会将传入的字符串当做 JavaScript
代码进行执行。利用该语法我们可以把 指定代码,指定在局部作用域下执行:
第二个欺骗词法是 with
:它的作用是 扩展一个语句的作用域链。比如在如下代码中 with
内的代码,会自动指向 obj
对象:
但是要注意,with
语法可能会导致内存泄漏:
并且 with
会让作用域变得混乱,所以 它是一个不被推荐使用的语法。
不光是 with
,包括 eval
,它们两个都不应该是我们在日常开发时的首选项,因为它们改变作用域的特性,会导致 引擎无法在编译时对作用域查找进行优化 ,所以我们应该尽量避免使用 eval
和 with
。
第一部分:③ 函数作用域与块作用域
了解了词法作用域之后,接下来咱们来看下函数作用于和块级作用域。
首先针对函数作用于而言,它表示 一个函数的作用域范围。属于这个函数的全部变量都可以在整个函数的范围内使用及复用
我们之前很多次的说过 函数是 js 世界的第一公民。创建函数的目的,本质上其实就是为了 把代码 “隐藏” 起来。也就是 最小特权原则。
所谓 最小特权原则,指的是:最小限度地暴露必要内容,而将其他内容都“隐藏”起来。
但是在某些情况下,如果我们的代码不够完善的话,那么虽然创建了函数,但是依然不符合最小特权原则。比如下面这段代码:
在这段代码中,我们声明了一个全局变量 var b
。然后在函数中对 b
进行了操作。但是因为 b
是全局变量,所以我们可以在任意位置修改 b
的值,那么这样的一个操作就是 “非常危险” 的。此时的代码就不符合最小特权原则。
我们可以对当前代码进行下修改,把 b
的定义放到函数之后,以避免被全局访问 :
同时大家需要注意,因为 var
关键字 不包含块级作用域,所以大家使用的时候要避免出现冲突的问题。
ECMAScript
在 ES6
之后新增了 let
和 const
两个声明变量的关键字,这两个关键字具备块级作用域({}
组成块级作用域),同时 var
也不再被推荐使用了。所以冲突问题倒是可以比较轻松的避免。
第一部分:④ 提升
所谓提升指的是 变量提升 的问题,什么是变量提升呢?咱们来看这两段代码:
大家可以猜一下这两段代码输出的内容是什么?
第一段代码的输出结果是 2
。
第二段代码的输出结果是 undefined
。
如果我们从一个标准的程序设计角度,这样的代码是肯定不能正常运行的。但是因为 var
存在变量提升的问题,所以我们得到了以上两个对应的输出结果。
那么这个变量提升到底是怎么提升的呢?此时啊,编译器就有话说了。
整个 var a = 2;
的代码编译器在处理会分成两部分:
- 在 编译阶段,进行定义声明:
var a
- 在 执行阶段,进行赋值声明:
a = 2
根据声明提升,第一段代码会被解析为以下代码:
第二段代码会被解析为以下代码:
查看变量提升之后的代码,我们就可以很清楚的知道为什么会打印出刚才看到的结果了。
而对于函数而言,同样存在变量提升的问题,同时 当函数和变量同时需要提升时,遵循 函数优先原则。例如,以下代码:
被提升之后的内容为:
总结
对于变量提升而言,它其实是一个 JS
中的糟粕,新的 let
和 const
关键字已经不存在变量提升的问题。但是变量提升在面试中依然会经常被问到,所以我们还是需要有所了解的。
当然,也仅仅只是了解一下就可以了。
第一部分:⑤ 作用域与闭包
到这里,对作用域咱们了解的其实就差不多了。作者分别从 词法作用域、函数作用域、块作用域 三个方面对作用域进行了解释。
在第一部分的最后,作者提到了闭包的概念。闭包在我们现在看来已经是一个老生常谈的话题了,如果大家了解闭包的话,那么可以知道,在前面我们所讲到的代码中,很多代码都存在闭包。
那么什么是闭包呢?所为闭包一定是一个函数。通常情况下我们把 能够访问其它函数作用域中变量的函数 叫做闭包函数。
闭包函数在前端开发中是非常常见的,比如:
这两个函数,都可以被叫做闭包函数。
在 var
时代,因为 var
不存在块级作用域,所以如果我们进行以下代码输出的话,那么会得到 5 次 6
的输出结果。
而想要解决这个问题,就可以利用闭包进行解决:
而对于现在而言,如果我们通过 let
代替 var
的话,那么就不会在出现这样的问题了。
第一部分:总结
那么到这里,整个第一部分就说完了。整本书中第一部分所涉及到的东西其实还是很多的。所以我们把重点给大家列一下。
整个第一部分,重点一共有 5
点:
- ① 编译流程:整个编译的三大步,包括
词法单元 token
、AST
这些东西大家最好可以有一个印象。因为如果你想要成为高级开发者,那么这些东西是需要有一定的了解的。 - ② 引擎、编译器、作用域:三者的作用,以及他们之间配合协作的方式,也需要有一个大致的印象。
- ③
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
的指向,我们需要先能够判定函数的调用位置:
这段代码为我们描述了函数的调用位置。
明确好了什么叫做函数的调用位置之后,下面咱们来看 this
的绑定规则。
作者把 this
的绑定规则分为了 4
类,虽然类数有点多,但是并不复杂,咱们一起来看一下:
- 默认绑定:这是在全局作用域下
this
的指向。全局作用域下this
指向window
- 隐式绑定:以对象方法的形式进行的函数调用,此时
this
指向调用该函数的对象 - 显式绑定:
call、apply、bind
这三个方法。这三个方法的第一个参数,为函数内this
的指向。 new
绑定:主要针对构造函数。在这种情况下this
指向构造生成的实例对象
明确好了 this
的绑定规则之后,接下来作者告诉了我们如何来去判定 this
的指向,也就是 this
指向的判定规则,一共也是分为 4
步:
var bar = new foo()
:函数是否在new
中调用(new绑定)?如果是的话,this
绑定的是新创建的对象。var bar = foo.call(obj2)
:函数是否通过call、apply
(显式绑定)调用?如果是的话,this
绑定的是指定的对象。var bar = obj1.foo()
:函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this
绑定的是那个上 下文对象。var bar = foo()
:如果以上都不是的话,则使用默认绑定。如果在严格模式下,就绑定到undefined
,否则绑定到 全局对象(window
)。
通过以上这四步,我们基本上可以把 this
所涉及到的所有场景都描述下来了。
但是大家一定要注意,以上所有的内容,仅针对 function
函数。因为 箭头函数永不修改 this
指向
第二部分:③ 对象
this
说完之后,接下来我们来看对象。
在 JS
中,数据类型一共分为两大类:
- 基本数据类型
- 复杂数据类型
其中基本数据类型包括:string、number、boolean、null、undefined
, ES6
之后又新增了两种,书中没有提到,这两种是 symbol、bigint
。
而基本数据类型,指的其实就是对象,具体可以细分为 Object
、Function
、Array
三大类(有些同学会在复杂数据类型中加上 data
等其他的类型,这个主要看个人理解)。
而我们接下来要说的对象,指的仅为 object
。
在 JS
中,想要创建一个对象的话,那么有两种方式:
- 字面量形式:
- 构造形式:
而对于对象的使用(内容),作者在本章中花费了很多的笔墨来进行讲解,整个讲解的内容大致分为了 7
点。
-
对象的属性名永远是 字符串。这个不用多说,大家应该是知道的。
-
其次对象的访问有两种形式:
属性访问:obj.key
和键访问:obj[key]
。其中第一种是常用的方式,第二种主要针对key
是变量时使用 -
第三:数组也是对象。这个应该也不需要多说才对。
-
第四拷贝:对象的拷贝分为 浅拷贝 和 深拷贝 两种。
-
浅拷贝表示多个变量引用了同一块内存地址。操作方式也比较简单,可以直接通过
= 赋值符
或Object.assgin
进行实现。 -
深拷贝又分为两种:
- 浅层的深拷贝(对象下不再包含复杂数据类型):对于这种可以直接通过
JSON.parse( JSON.stringify( obj ) );
的方式完成 - 深层的深拷贝(对象下还包含其他复杂数据类型数据):对于这种必须要通过 递归 的方式完成深拷贝。比较简单的方式就是直接使用 Lodash.cloneDeep 方法
- 浅层的深拷贝(对象下不再包含复杂数据类型):对于这种可以直接通过
-
-
第五属性描述符:对象中每个属性,都存在属性描述符。可以通过 Object.getOwnPropertyDescriptor() 方法来获取对应的属性描述符。不同的属性描述符代表了不同的作用:
value
:该属性的值 (仅针对数据属性描述符有效)writable
:当且仅当属性的值可以被改变时为true
。configurable
:当且仅当指定对象的属性描述可以被改变或者属性可被删除时,为true
。enumerable
:当且仅当指定对象的属性可以被枚举出时,为true
。
也可以通过 Object.defineProperty() 方法修改指定属性的属性描述符。
-
第六是 对象属性不可变的方法:作者在这里一共列举出了四个方法,大家可以作为了解:
- 对象常量:结合
writable:false
和configurable:false
就可以创建一个真正的常量属性 - 禁止扩展:Object.preventExtensions(..)
- 密封:Object.seal
- 冻结:Object.freeze()
- 对象常量:结合
-
第七是
getter 与 setter
:这里主要是两个标记符,他可以让我们像调用属性一样触发方法
关于对象的最后一个环节就是枚举了。本书在讲解枚举的时候,并不贴近现在的 JS
使用场景,如果大家想要了解对象枚举的方式的话,可以看下我讲解的另一本书 《深入理解现代 JavaScript》
第二部分:④ 混合对象 “类”
了解完了对象之后,接下来我们来看 “类”。
想要了解类的话,那么大家需要首先搞明白 对象和类的区别。
对象永远指的是一个具体的实例,而类指的是某一类事物。
比如:
以汽车为类,具体的某一辆汽车可以称之为对象(实例对象)。
以人为类,具体的某一个人可以称之为对象(实例对象)。
类本质上是一种设计模式。所以在 JS
中类是可选的模式。大家可以想一想在大家的日常项目开发中,如果你是以 vue
为主的话,那么应该几乎没有使用过类的概念才对。 react
的同学可能对 class
的概念使用的会多一些。
基于类的设计模式,我们可以在 JS
中构建出类似的代码逻辑:
这个代码主要使用到了 ES6
的 class
关键字构建了类,然后通过 new
创建了对应的实例对象。
同时针对于类而言,它是存在继承的概念的。因为存在继承,所以延伸出了多态的概念。所谓多态指的是 从一个父类派生出多个子类,可以使子类之间有不同的行为,这种行为称之为多态 。
而具体在 JS
中如何进行继承,作者在书中描述的并不详尽,所以大家如果想要了解 JS
中类继承的语法 ,我更建议大家去看 《深入理解现代 JavaScript》
第二部分:⑤ 原型
而对于 JS
来说,原型和原型链同样也是一个经久不衰的话题。
所谓的原型指的就是 [[prototype]]
,每个 JS
对象都存在 [[prototype]]
属性,该属性就是该对象的原型对象。原型对象也是对象,所以它也存在 [[prototype]]
属性,也就是 原型对象的原型对象。同样原型对象的原型对象也存在 [[prototype]]
。这就连成了一个链,这个链被叫做 原型链。整个原型链一直到 null
为止。
第二部分:剩余内容
到这里其实咱们整个第二部分的关键内容就说的差不多了,剩余的内容在本书中的讲解都是基于 不存在 class
的前提条件 进行的。对于咱们现在的前端开发而言,其实意义不大。
如果大家想要了解其他的 类、原型对象、对象关联... 等等概念的话,还是建议大家去看 《深入理解现代 JavaScript》
第二部分总结
那么对于本书的第二部分而言,核心内容主要有 4
点:
this
的绑定规则:这个绑定规则作者在书中介绍的还是非常详尽的- 对象:作者对于对象的使用介绍的也非常详细,值得一读。
- 混合对象 “类”:类在
JS
中是一种可选的设计模式,这句话我感觉说的非常好,在这里分享给大家 [[prototype]]
:原型链是一个面试的时候经常被问的概念,大家需要有所了解。
总结
OK
,那么到这里咱们的 《你不知道的 JavaScript
(上卷)》 就全部看完了。
通过书中的内容,我们可以知道这本当年的神作,确实有很多经典的讲解,比如 LHS && RHS
,引擎、编辑器、作用域 的对话
都堪称经典内容。
但是对于现时代的前端开发而言,也确实存在了很多时代的局限问题,比如 行为委托
和 原型
中很多的概念。
那么咱们这次的《你不知道的 JavaScript
(上卷)》就说到这里。
我是 Sunday
,陪大家一起读书,一起分享技术知识,咱们下次再见,88~~~