第一章 无处不在的 JavaScript
性能分析,可以使用 console.time
和 console.timeEnd
在 node 和 浏览器环境下可以看到运行的时间
1 | console.time("My operation"); |
这本书集中探讨了核心 JavaScript 的机制,例如函数、函数闭包和原型,还有一些新的 JavaScript 特性,例如生成器、promise、代理、映射、集合和模块。
第二章 运行时的页面构建过程
客户端 Web 应用的两个生命周期是页面构建和事件处理
页面构建
当Web应用能被展示或交互之前,其页面必须根据服务器获取的响应(通常是HTML、CSS和JavaScript代码)来构建。页面构建阶段的目标是建立Web应用的UI,其主要包括两个步骤:
- 解析HTML代码并构建文档对象模型(DOM);
- 执行JavaScript代码。
步骤1会在浏览器处理HTML节点的过程中执行,步骤二会在HTML解析到一种特殊节点——脚本节点(包含或引用JavaScript代码的节点)时执行。页面构建阶段中,只要还有没处理完的HTML元素和没执行完的JavaScript代码,两个步骤都会一直交替执行。
当浏览器处理完所有HTML元素后,页面构建阶段就结束了。随后浏览器就会进入应用生命周期的第二部分:事件处理。
事件处理
客户端 Web 应用是一种 GUI 应用,也就是说这种应用会对不同类型的事件作响应,如鼠标移动、单击和键盘按压等。因此,在页面构建阶段执行的JavaScript代码,除了会影响全局应用状态和修改DOM外,还会注册事件监听器(或处理器)。这类监听器会在事件发生时,由浏览器调用执行。有了这些事件处理器,我们的应用也就有了交互能力。
事件可能会以难以预计的时间和顺序发生,如以下的事件。
- 网络事件,例如来自服务器的响应(Ajax 事件和服务器端事件)
- 用户事件,例如鼠标单击、鼠标移动和键盘事件
- 计时器事件,当 timeout 时间到期或又触发了一次时间间隔
这里就是熟悉的 EventLoop,不再详述,与第十三章结合起来。个人认为第二章是从宏观上看,第十三章是从微观上看。
第三章 函数课
回调函数定义
1 | document.body.addEventListener('click', function () { |
上面是一个典型的回调函数,是异步的。但回调函数不是异步函数,是其他代码会在随后的某个合适时间点 “回过来调用” 的函数,下面的代码就是一个同步的回调函数。这里的概念要搞清楚。
1 | function example(cb) { |
函数作为对象的乐趣
存储函数
比如下面的代码,利用函数也可以拥有属性,可以存储唯一函数集合
1 | var store = { |
自记忆函数
利用函数也可以拥有属性实现简单的素数判断,如果已经有结果,就不用计算了,直接取出缓存的结果
1 | function isPrime(value) { |
第四章 函数进阶
构造函数返回值
执行下面的代码,会看到如果将 Ninja 作为一个函数调用,的确会返回 1,但如果通过 new 关键字将其作为构造函数调用,会构造并返回一个新的 ninja 对象。
1 | function Ninja() { |
但如果做一些改变,一个构造函数返回另一个对象,如下面代码所示:
1 | var puppet = { |
结果表明:puppet 对象最终作为构造函数调用的返回值,在构造函数中对函数上下文的操作都是无效的。最终返回的将是 puppet。
总结:
- 如果构造函数返回一个对象,则该对象将作为整个表达式的值返回,而传入构造函数的this将被丢弃。
- 如果构造函数返回的是非对象类型,则忽略返回值,返回新创建的对象。
函数的命名
函数和方法的命名通常以描述其行为的动词开头(doSomethingWonderful),且第一个字母小写。而构造函数则通常以描述所构造对象的名词命名,并以大写字母开头:Ninja
箭头函数的this
箭头函数没有单独的 this 值,箭头函数的 this 与声明所在的上下文的相同,在创建时就确定了 this 的指向。
在构造函数内部时
1 | <button id="test">Click Me!</button> |
如上所示,button.clicked 为 true。
调用箭头函数时,不会隐式传入 this 参数,而是从定义时的函数继承上下文,在上面的代码中,箭头函数在构造函数内部,this 指向新创建的对象本身,因此无论何时调用 click 函数,this 都将指向新创建的 button 对象。
使用对象字面量时
1 | <button id="test">Click Me!</button> |
click 箭头函数是作为对象字面量的属性定义的,对象字面量在全局代码中定义,因此,箭头函数内部的 this 值与全局代码的 this 值相同。
第五章 闭包
闭包是 JavaScript 作用域规则的副作用。当函数创建时所在的作用域消失后,仍然能够调用函数。
第六章 未来的函数:生成器和promise
对迭代器进行迭代
用 while 循环迭代生成器的结果
1 | // 定义一个生成器 |
上面的代码就是 for of 循环的原理。for of 循环不过是对迭代器进行迭代的语法糖。
1 | for(var item of WeaponGenerator()) { |
不同于手动调用迭代器的 next 方法,for of 循环同时还要查看生成器是否完成,在后台自动做了相同的工作。
把执行权交给下一个生成器
for of 循环不会关心 WarriorGenerator 委托到另一个生成器上,它只关心在 done 状态到来之前都一直调用 next 方法。
注意这里的 yield*
1 | function* WarriorGenerator() { |
用生成器生成ID序列
测试结果结尾 true,id 仅能在生成器中被访问,while 的每次迭代都能生成一个新的 id 值并挂起执行,直到下一次调用 next 方法。
1 | function* IdGenerator() { |
迭代器遍历 DOM 树
一般的写法
1 | <div id="subTree"> |
使用生成器写法,不用再写回调函数了,一个 for of 循环就可以了
1 | <div id="subTree"> |
与生成器交互
注意第二个 next 传递了参数,imposter 随之改变,进行了交互,可以想象到和 promise 结合时,next 传递的参数可以改变 imposter,然后进行下一步的请求(这个请求需要用到 imposter)。
1 | function* NinjaGenerator(action) { |
与promise结合
这只是个简单的示例
1 | async (function* () { |
第七章 面向对象与原型
理解原型
在 JavaScript 中,对象的原型属性是内置属性(使用标记[[prototype]]),无法直接访问。内置方法 Object.setPrototypeOf
需要传入两个对象作为参数,并将第二个对象设置为第一个对象的原型。
1 | const xiaoming = { |
每个对象都可以有一个原型,每个对象的原型也可以拥有一个原型,以此类推,形成一个原型链,查找特定属性将会被委托在整个原型链上,只有当没有更多的原型可以进行查找时,才会停止查找。
constructor
下面的代码中,我们使用第一个实例对象的 constructor 属性创建第二个实例。验证表明第二个 Ninja 对象被创建成功,并且与第一个是完全不同的两个实例。
有趣的是利用 constructor,我们不需要访问原始构造函数就可以直接创建对象,即使原始构造函数已经不在作用域内。
1 | function Ninja() {} |
instanceof
instanceof 操作符的真正语义:检测右边的函数原型是否存在于操作符左边的对象的原型链上
1 | function Ninja() {} |
使用关键字 class
class 是语法糖
1 | class Ninja { |
上面的代码可以转换成下面的ES5代码:
1 | function Ninja(name) { |
静态方法
1 | class Ninja { |
通过 static 关键字定义了一个静态方法 compare,实例不可访问 compare 方法,而 Ninja 类可以访问 compare 方法。
ES6 之前要像下面这样实现 “静态” 方法:
1 | function Ninja() {} |
class实现继承
super + extends
console.log 除了注释中的 false,其余全为 true。
1 | class Person { |
第八章 控制对象的访问
getter 和 setter
定义 getter 和 setter
如果一个属性具有 getter 和 setter 方法,访问该属性时将隐式调用 getter 方法,为该属性赋值时将隐式调用 setter 方法。
在对象字面量中使用
1 | const ninjaCollection = { |
在ES6的class中使用
1 | class NinjaCollection { |
通过Object.defineProperty定义getter和setter
在第五章中我们已经知道 Javascript 没有私有对象属性。我们可以通过闭包模拟私有属性,通过定义变量和指定对象包含这些变量。由于对象字面量与类、getter和setter方法不是在同一个作用域中定义的,因此那些希望作为私有对象属性的变量是无法实现的。幸运的是,可以通过Object.defineProperty方法实现。
在第7章中我们看到Object.defineProperty方法可以用于定义新的属性,传入属性描述对象即可。属性描述对象可以包含get和set来定义getter和setter方法。我们使用这种特性重新编写上面的示例,来实现内置的getter和setter,控制私有对象属性的访问
1 | function Ninja() { |
由于我们希望通过skillLevel属性控制访问私有变量,因此我们定义了set和get方法。
注意,与对象字面量和类中的getter和setter不同,通过Object.defineProperty创建的get和set方法,与私有skillLevel变量处于相同的作用域中。get和set方法分别创建了含有私有变量的闭包,我们只能通过get和set方法访问私有变量。
剩下的代码运行的效果与前面的示例一致。我们创建新的ninja实例,验证无法直接访问私有变量。所有的交互都必须通过getter和setter,与标准对象属性无差异。
正如你所看到的,Object.defineProperty方法比对象字面量或类更为复杂。但是,当我们需要实现私有对象属性时,Object.defineProperty方法派上了用场。
使用 getter 和 setter 校验属性值
下面这段代码展示了如何规避指定属性发生类型错误异常。
无论何时对skillLevel属性赋值,我们都会校验该值是否是整型。如果不是,则抛出异常,并且不会修改属性_skillLevel
的值。如果是整型,则对属性 _skillLevel
赋值
1 | function Ninja() { |
使用 getter 和 setter 定义计算属性值
下面的测试结果都是 true
1 | const shogun = { |
代理
使用代理控制访问
下面的测试结果都是 true
通过 emperor 直接访问 name 属性,则返回 Komei。但是,若通过代理对象访问,则隐式调用 get 方法。由于在目标对象上可以找到 name 属性,因此也会返回 Komei。
通过 emperor 直接访问不存在的属性 nickname,返回 undefined。但是如果通过代理对象访问不存在的属性 nickname,将会激活 get,由于目标对象不具有 nickname 属性,get 方法将会返回消息 Don’t bother the emperor!
要点:通过 Proxy 构造器创建代理对象,通过代理对象访问目标对象属性时要执行指定的操作(访问时 get 、赋值时 set)。
1 | const emperor = { |
使用代理记录日志
我们创建了 ninja 对象,将其传入 makeLoggable 函数,作为要创建的代理对象的目标对象,将代理对象重新赋值给 ninja 标识符。
这样做的好处就是不需要为每个对象属性添加单独的日志,记录日志的代码只需要写一个函数,解决了如果有很多个对象都要写单独日志的问题。
1 | function makeLoggable(target) { |
代理的性能消耗
代理效率不高,所以在需要执行多次的代码中,比如 for 循环很多次,建议谨慎使用代理。
使用代理可以实现 日志记录、性能测量、数据校验、自动填充对象、数组负索引,这里只记录了日志记录。
第九章 处理集合
数组
访问
如果试图访问数组长度范围之外的索引,不会抛出异常,而是返回 undefined,这个结果表明,Javascript 的数组是对象。
1 | let arr = [1, 2, 3] |
数组排序
array.sort((a, b) => a - b)
,JavaScript 提供了 sort 方法,我们需要提供回调函数,告诉排序算法相邻的两个数组元素的关系。可能的结果有如下几种。
- 如果回调函数的返回值 < 0,元素 a 应该出现在元素 b 之前
- 如果回调函数的返回值 = 0,元素 a 和元素 b 出现在相同位置
- 如果回调函数的返回值 > 0,元素 a 应该出现在元素 b 之后
reduce
1 | const numbers = [1, 2, 3, 4]; |
对于数组的求和可以使用 reduce
1 | const numbers = [1, 2, 3, 4]; |
复用内置数组函数
比如下面的代码,就复用了数组中的 push,find 方法
1 | <input id="first" /> |
Map
why use Map
为什么要使用 Map,是因为对象字面量存在一些问题,比如下面访问 constructor 属性发现不为空,原因是 {} 的原型上存在 constructor 属性
1 | const dictionary = {} |
比如映射 HTML 节点,因为 {} 的 key 必须是字符串,如果想映射为其他类型,会默默转化为字符串,没有任何提示,比如下面试图使用 HTML 元素作为 key 时,其值被 toString 方法静默转换为字符串类型。HTML 元素转换为字符串后的值为 [object HTMLDivElement]
1 | <div id="firstElement"></div> |
综上,由于这两个原因:原型继承属性以及key仅支持字符串,所以要用 Map 类型。
示例
firstLink 和 secondLink 的内容相同,但这两个对象仍然不相等。
1 | const map = new Map(); |
遍历 Map
1 | const directory = new Map(); |
Set
并集
1 | const ninjas = ["Kuma", "Hattori", "Yagyu"]; |
交集
[…ninjas] 将 Set 转换为数组
1 | const ninjas = new Set(["Kuma", "Hattori", "Yagyu"]); |
差集
1 | const ninjas = new Set(["Kuma", "Hattori", "Yagyu"]); |
笔记基本完成,除了第八章代理部分只记录了日志记录