img

书名:JavaScript高级程序设计(第4版)

作者:[美] 马特 • 弗里斯比

译者:李松峰

ISBN:978-7-115-54538-1

本书由北京图灵文化发展有限公司发行数字版。版权所有,侵权必究。

您购买的图灵电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。



版权声明

献词

译者序

前言

读者对象

本书内容

组织结构

预备条件

电子书及附录

致谢

第 1 章 什么是JavaScript

1.1 简短的历史回顾

1.2 JavaScript实现

1.2.1 ECMAScript

1.2.2 DOM

1.2.3 BOM

1.3 JavaScript版本

1.4 小结

第 2 章 HTML中的JavaScript

2.1 <script>元素

2.1.1 标签占位符

2.1.2 推迟执行脚本

2.1.3 异步执行脚本

2.1.4 动态加载脚本

2.1.5 XHTML中的变化

2.1.6 废弃的语法

2.2 行内代码与外部文件

2.3 文档模式

2.4 <noscript>元素

2.5 小结

第 3 章 语言基础

3.1 语法

3.1.1 区分大小写

3.1.2 标识符

3.1.3 注释

3.1.4 严格模式

3.1.5 语句

3.2 关键字与保留字

3.3 变量

3.3.1 var关键字

3.3.2 let声明

3.3.3 const声明

3.3.4 声明风格及最佳实践

3.4 数据类型

3.4.1 typeof操作符

3.4.2 Undefined类型

3.4.3 Null类型

3.4.4 Boolean类型

3.4.5 Number类型

3.4.6 String类型

3.4.7 Symbol类型

3.4.8 Object类型

3.5 操作符

3.5.1 一元操作符

3.5.2 位操作符

3.5.3 布尔操作符

3.5.4 乘性操作符

3.5.5 指数操作符

3.5.6 加性操作符

3.5.7 关系操作符

3.5.8 相等操作符

3.5.9 条件操作符

3.5.10 赋值操作符

3.5.11 逗号操作符

3.6 语句

3.6.1 if语句

3.6.2 do-while语句

3.6.3 while语句

3.6.4 for语句

3.6.5 for-in语句

3.6.6 for-of语句

3.6.7 标签语句

3.6.8 break和continue语句

3.6.9 with语句

3.6.10 switch语句

3.7 函数

3.8 小结

第 4 章 变量、作用域与内存

4.1 原始值与引用值

4.1.1 动态属性

4.1.2 复制值

4.1.3 传递参数

4.1.4 确定类型

4.2 执行上下文与作用域

4.2.1 作用域链增强

4.2.2 变量声明

4.3 垃圾回收

4.3.1 标记清理

4.3.2 引用计数

4.3.3 性能

4.3.4 内存管理

4.4 小结

第 5 章 基本引用类型

5.1 Date

5.1.1 继承的方法

5.1.2 日期格式化方法

5.1.3 日期/时间组件方法

5.2 RegExp

5.2.1 RegExp实例属性

5.2.2 RegExp实例方法

5.2.3 RegExp构造函数属性

5.2.4 模式局限

5.3 原始值包装类型

5.3.1 Boolean

5.3.2 Number

5.3.3 String

5.4 单例内置对象

5.4.1 Global

5.4.2 Math

5.5 小结

第 6 章 集合引用类型

6.1 Object

6.2 Array

6.2.1 创建数组

6.2.2 数组空位

6.2.3 数组索引

6.2.4 检测数组

6.2.5 迭代器方法

6.2.6 复制和填充方法

6.2.7 转换方法

6.2.8 栈方法

6.2.9 队列方法

6.2.10 排序方法

6.2.11 操作方法

6.2.12 搜索和位置方法

6.2.13 迭代方法

6.2.14 归并方法

6.3 定型数组

6.3.1 历史

6.3.2 ArrayBuffer

6.3.3 DataView

6.3.4 定型数组

6.4 Map

6.4.1 基本API

6.4.2 顺序与迭代

6.4.3 选择Object还是Map

6.5 WeakMap

6.5.1 基本API

6.5.2 弱键

6.5.3 不可迭代键

6.5.4 使用弱映射

6.6 Set

6.6.1 基本API

6.6.2 顺序与迭代

6.6.3 定义正式集合操作

6.7 WeakSet

6.7.1 基本API

6.7.2 弱值

6.7.3 不可迭代值

6.7.4 使用弱集合

6.8 迭代与扩展操作

6.9 小结

第 7 章 迭代器与生成器

7.1 理解迭代

7.2 迭代器模式

7.2.1 可迭代协议

7.2.2 迭代器协议

7.2.3 自定义迭代器

7.2.4 提前终止迭代器

7.3 生成器

7.3.1 生成器基础

7.3.2 通过yield中断执行

7.3.3 生成器作为默认迭代器

7.3.4 提前终止生成器

7.4 小结

第 8 章 对象、类与面向对象编程

8.1 理解对象

8.1.1 属性的类型

8.1.2 定义多个属性

8.1.3 读取属性的特性

8.1.4 合并对象

8.1.5 对象标识及相等判定

8.1.6 增强的对象语法

8.1.7 对象解构

8.2 创建对象

8.2.1 概述

8.2.2 工厂模式

8.2.3 构造函数模式

8.2.4 原型模式

8.2.5 对象迭代

8.3 继承

8.3.1 原型链

8.3.2 盗用构造函数

8.3.3 组合继承

8.3.4 原型式继承

8.3.5 寄生式继承

8.3.6 寄生式组合继承

8.4 类

8.4.1 类定义

8.4.2 类构造函数

8.4.3 实例、原型和类成员

8.4.4 继承

8.5 小结

第 9 章 代理与反射

9.1 代理基础

9.1.1 创建空代理

9.1.2 定义捕获器

9.1.3 捕获器参数和反射API

9.1.4 捕获器不变式

9.1.5 可撤销代理

9.1.6 实用反射API

9.1.7 代理另一个代理

9.1.8 代理的问题与不足

9.2 代理捕获器与反射方法

9.2.1 get()

9.2.2 set()

9.2.3 has()

9.2.4 defineProperty()

9.2.5 getOwnPropertyDescriptor()

9.2.6 deleteProperty()

9.2.7 ownKeys()

9.2.8 getPrototypeOf()

9.2.9 setPrototypeOf()

9.2.10 isExtensible()

9.2.11 preventExtensions()

9.2.12 apply()

9.2.13 construct()

9.3 代理模式

9.3.1 跟踪属性访问

9.3.2 隐藏属性

9.3.3 属性验证

9.3.4 函数与构造函数参数验证

9.3.5 数据绑定与可观察对象

9.4 小结

第 10 章 函数

10.1 箭头函数

10.2 函数名

10.3 理解参数

箭头函数中的参数

10.4 没有重载

10.5 默认参数值

默认参数作用域与暂时性死区

10.6 参数扩展与收集

10.6.1 扩展参数

10.6.2 收集参数

10.7 函数声明与函数表达式

10.8 函数作为值

10.9 函数内部

10.9.1 arguments

10.9.2 this

10.9.3 caller

10.9.4 new.target

10.10 函数属性与方法

10.11 函数表达式

10.12 递归

10.13 尾调用优化

10.13.1 尾调用优化的条件

10.13.2 尾调用优化的代码

10.14 闭包

10.14.1 this对象

10.14.2 内存泄漏

10.15 立即调用的函数表达式

10.16 私有变量

10.16.1 静态私有变量

10.16.2 模块模式

10.16.3 模块增强模式

10.17 小结

第 11 章 期约与异步函数

11.1 异步编程

11.1.1 同步与异步

11.1.2 以往的异步编程模式

11.2 期约

11.2.1 Promises/A+规范

11.2.2 期约基础

11.2.3 期约的实例方法

11.2.4 期约连锁与期约合成

11.2.5 期约扩展

11.3 异步函数

11.3.1 异步函数

11.3.2 停止和恢复执行

11.3.3 异步函数策略

11.4 小结

第 12 章 BOM

12.1 window对象

12.1.1 Global作用域

12.1.2 窗口关系

12.1.3 窗口位置与像素比

12.1.4 窗口大小

12.1.5 视口位置

12.1.6 导航与打开新窗口

12.1.7 定时器

12.1.8 系统对话框

12.2 location对象

12.2.1 查询字符串

12.2.2 操作地址

12.3 navigator对象

12.3.1 检测插件

12.3.2 注册处理程序

12.4 screen对象

12.5 history对象

12.5.1 导航

12.5.2 历史状态管理

12.6 小结

第 13 章 客户端检测

13.1 能力检测

13.1.1 安全能力检测

13.1.2 基于能力检测进行浏览器分析

13.2 用户代理检测

13.2.1 用户代理的历史

13.2.2 浏览器分析

13.3 软件与硬件检测

13.3.1 识别浏览器与操作系统

13.3.2 浏览器元数据

13.3.3 硬件

13.4 小结

第 14 章 DOM

14.1 节点层级

14.1.1 Node类型

14.1.2 Document类型

14.1.3 Element类型

14.1.4 Text类型

14.1.5 Comment类型

14.1.6 CDATASection类型

14.1.7 DocumentType类型

14.1.8 DocumentFragment类型

14.1.9 Attr类型

14.2 DOM编程

14.2.1 动态脚本

14.2.2 动态样式

14.2.3 操作表格

14.2.4 使用NodeList

14.3 MutationObserver接口

14.3.1 基本用法

14.3.2 MutationObserverInit与观察范围

14.3.3 异步回调与记录队列

14.3.4 性能、内存与垃圾回收

14.4 小结

第 15 章 DOM扩展

15.1 Selectors API

15.1.1 querySelector()

15.1.2 querySelectorAll()

15.1.3 matches()

15.2 元素遍历

15.3 HTML5

15.3.1 CSS类扩展

15.3.2 焦点管理

15.3.3 HTMLDocument扩展

15.3.4 字符集属性

15.3.5 自定义数据属性

15.3.6 插入标记

15.3.7 scrollIntoView()

15.4 专有扩展

15.4.1 children属性

15.4.2 contains()方法

15.4.3 插入标记

15.4.4 滚动

15.5 小结

第 16 章 DOM2和DOM3

16.1 DOM的演进

16.1.1 XML命名空间

16.1.2 其他变化

16.2 样式

16.2.1 存取元素样式

16.2.2 操作样式表

16.2.3 元素尺寸

16.3 遍历

16.3.1 NodeIterator

16.3.2 TreeWalker

16.4 范围

16.4.1 DOM范围

16.4.2 简单选择

16.4.3 复杂选择

16.4.4 操作范围

16.4.5 范围插入

16.4.6 范围折叠

16.4.7 范围比较

16.4.8 复制范围

16.4.9 清理

16.5 小结

第 17 章 事件

17.1 事件流

17.1.1 事件冒泡

17.1.2 事件捕获

17.1.3 DOM事件流

17.2 事件处理程序

17.2.1 HTML事件处理程序

17.2.2 DOM0事件处理程序

17.2.3 DOM2事件处理程序

17.2.4 IE事件处理程序

17.2.5 跨浏览器事件处理程序

17.3 事件对象

17.3.1 DOM事件对象

17.3.2 IE事件对象

17.3.3 跨浏览器事件对象

17.4 事件类型

17.4.1 用户界面事件

17.4.2 焦点事件

17.4.3 鼠标和滚轮事件

17.4.4 键盘与输入事件

17.4.5 合成事件

17.4.6 变化事件

17.4.7 HTML5事件

17.4.8 设备事件

17.4.9 触摸及手势事件

17.4.10 事件参考

17.5 内存与性能

17.5.1 事件委托

17.5.2 删除事件处理程序

17.6 模拟事件

17.6.1 DOM事件模拟

17.6.2 IE事件模拟

17.7 小结

第 18 章 动画与Canvas图形

18.1 使用requestAnimationFrame

18.1.1 早期定时动画

18.1.2 时间间隔的问题

18.1.3 requestAnimationFrame

18.1.4 cancelAnimationFrame

18.1.5 通过requestAnimationFrame节流

18.2 基本的画布功能

18.3 2D绘图上下文

18.3.1 填充和描边

18.3.2 绘制矩形

18.3.3 绘制路径

18.3.4 绘制文本

18.3.5 变换

18.3.6 绘制图像

18.3.7 阴影

18.3.8 渐变

18.3.9 图案

18.3.10 图像数据

18.3.11 合成

18.4 WebGL

18.4.1 WebGL上下文

18.4.2 WebGL基础

18.4.3 WebGL1与WebGL2

18.5 小结

第 19 章 表单脚本

19.1 表单基础

19.1.1 提交表单

19.1.2 重置表单

19.1.3 表单字段

19.2 文本框编程

19.2.1 选择文本

19.2.2 输入过滤

19.2.3 自动切换

19.2.4 HTML5约束验证API

19.3 选择框编程

19.3.1 选项处理

19.3.2 添加选项

19.3.3 移除选项

19.3.4 移动和重排选项

19.4 表单序列化

19.5 富文本编辑

19.5.1 使用contenteditable

19.5.2 与富文本交互

19.5.3 富文件选择

19.5.4 通过表单提交富文本

19.6 小结

第 20 章 JavaScript API

20.1 Atomics与SharedArrayBuffer

20.1.1 SharedArrayBuffer

20.1.2 原子操作基础

20.2 跨上下文消息

20.3 Encoding API

20.3.1 文本编码

20.3.2 文本解码

20.4 File API与Blob API

20.4.1 File类型

20.4.2 FileReader类型

20.4.3 FileReaderSync类型

20.4.4 Blob与部分读取

20.4.5 对象URL与Blob

20.4.6 读取拖放文件

20.5 媒体元素

20.5.1 属性

20.5.2 事件

20.5.3 自定义媒体播放器

20.5.4 检测编解码器

20.5.5 音频类型

20.6 原生拖放

20.6.1 拖放事件

20.6.2 自定义放置目标

20.6.3 dataTransfer对象

20.6.4 dropEffect与effectAllowed

20.6.5 可拖动能力

20.6.6 其他成员

20.7 Notifications API

20.7.1 通知权限

20.7.2 显示和隐藏通知

20.7.3 通知生命周期回调

20.8 Page Visibility API

20.9 Streams API

20.9.1 理解流

20.9.2 可读流

20.9.3 可写流

20.9.4 转换流

20.9.5 通过管道连接流

20.10 计时API

20.10.1 High Resolution Time API

20.10.2 Performance Timeline API

20.11 Web组件

20.11.1 HTML模板

20.11.2 影子DOM

20.11.3 自定义元素

20.12 Web Cryptography API

20.12.1 生成随机数

20.12.2 使用SubtleCrypto对象

20.13 小结

第 21 章 错误处理与调试

21.1 浏览器错误报告

21.1.1 桌面控制台

21.1.2 移动控制台

21.2 错误处理

21.2.1 try/catch语句

21.2.2 抛出错误

21.2.3 error事件

21.2.4 错误处理策略

21.2.5 识别错误

21.2.6 区分重大与非重大错误

21.2.7 把错误记录到服务器中

21.3 调试技术

21.3.1 把消息记录到控制台

21.3.2 理解控制台运行时

21.3.3 使用JavaScript调试器

21.3.4 在页面中打印消息

21.3.5 补充控制台方法

21.3.6 抛出错误

21.4 旧版IE的常见错误

21.4.1 无效字符

21.4.2 未找到成员

21.4.3 未知运行时错误

21.4.4 语法错误

21.4.5 系统找不到指定资源

21.5 小结

第 22 章 处理XML

22.1 浏览器对XML DOM的支持

22.1.1 DOM Level 2 Core

22.1.2 DOMParser类型

22.1.3 XMLSerializer类型

22.2 浏览器对XPath的支持

22.2.1 DOM Level 3 XPath

22.2.2 单个节点结果

22.2.3 简单类型结果

22.2.4 默认类型结果

22.2.5 命名空间支持

22.3 浏览器对XSLT的支持

22.3.1 XSLTProcessor类型

22.3.2 使用参数

22.3.3 重置处理器

22.4 小结

第 23 章 JSON

23.1 语法

23.1.1 简单值

23.1.2 对象

23.1.3 数组

23.2 解析与序列化

23.2.1 JSON对象

23.2.2 序列化选项

23.2.3 解析选项

23.3 小结

第 24 章 网络请求与远程资源

24.1 XMLHttpRequest对象

24.1.1 使用XHR

24.1.2 HTTP头部

24.1.3 GET请求

24.1.4 POST请求

24.1.5 XMLHttpRequest Level 2

24.2 进度事件

24.2.1 load事件

24.2.2 progress事件

24.3 跨源资源共享

24.3.1 预检请求

24.3.2 凭据请求

24.4 替代性跨源技术

24.4.1 图片探测

24.4.2 JSONP

24.5 Fetch API

24.5.1 基本用法

24.5.2 常见Fetch请求模式

24.5.3 Headers对象

24.5.4 Request对象

24.5.5 Response对象

24.5.6 Request、Response及Body混入

24.6 Beacon API

24.7 Web Socket

24.7.1 API

24.7.2 发送和接收数据

24.7.3 其他事件

24.8 安全

24.9 小结

第 25 章 客户端存储

25.1 cookie

25.1.1 限制

25.1.2 cookie的构成

25.1.3 JavaScript中的cookie

25.1.4 子cookie

25.1.5 使用cookie的注意事项

25.2 Web Storage

25.2.1 Storage类型

25.2.2 sessionStorage对象

25.2.3 localStorage对象

25.2.4 存储事件

25.2.5 限制

25.3 IndexedDB

25.3.1 数据库

25.3.2 对象存储

25.3.3 事务

25.3.4 插入对象

25.3.5 通过游标查询

25.3.6 键范围

25.3.7 设置游标方向

25.3.8 索引

25.3.9 并发问题

25.3.10 限制

25.4 小结

第 26 章 模块

26.1 理解模块模式

26.1.1 模块标识符

26.1.2 模块依赖

26.1.3 模块加载

26.1.4 入口

26.1.5 异步依赖

26.1.6 动态依赖

26.1.7 静态分析

26.1.8 循环依赖

26.2 凑合的模块系统

26.3 使用ES6之前的模块加载器

26.3.1 CommonJS

26.3.2 异步模块定义

26.3.3 通用模块定义

26.3.4 模块加载器终将没落

26.4 使用ES6模块

26.4.1 模块标签及定义

26.4.2 模块加载

26.4.3 模块行为

26.4.4 模块导出

26.4.5 模块导入

26.4.6 模块转移导出

26.4.7 工作者模块

26.4.8 向后兼容

26.5 小结

第 27 章 工作者线程

27.1 工作者线程简介

27.1.1 工作者线程与线程

27.1.2 工作者线程的类型

27.1.3 WorkerGlobalScope

27.2 专用工作者线程

27.2.1 专用工作者线程的基本概念

27.2.2 专用工作者线程与隐式MessagePorts

27.2.3 专用工作者线程的生命周期

27.2.4 配置Worker选项

27.2.5 在JavaScript行内创建工作者线程

27.2.6 在工作者线程中动态执行脚本

27.2.7 委托任务到子工作者线程

27.2.8 处理工作者线程错误

27.2.9 与专用工作者线程通信

27.2.10 工作者线程数据传输

27.2.11 线程池

27.3 共享工作者线程

27.3.1 共享工作者线程简介

27.3.2 理解共享工作者线程的生命周期

27.3.3 连接到共享工作者线程

27.4 服务工作者线程

27.4.1 服务工作者线程基础

27.4.2 服务工作者线程缓存

27.4.3 服务工作者线程客户端

27.4.4 服务工作者线程与一致性

27.4.5 理解服务工作者线程的生命周期

27.4.6 控制反转与服务工作者线程持久化

27.4.7 通过updateViaCache管理服务文件缓存

27.4.8 强制性服务工作者线程操作

27.4.9 服务工作者线程消息

27.4.10 拦截fetch事件

27.4.11 推送通知

27.5 小结

第 28 章 最佳实践

28.1 可维护性

28.1.1 什么是可维护的代码

28.1.2 编码规范

28.1.3 松散耦合

28.1.4 编码惯例

28.2 性能

28.2.1 作用域意识

28.2.2 选择正确的方法

28.2.3 语句最少化

28.2.4 优化DOM交互

28.3 部署

28.3.1 构建流程

28.3.2 验证

28.3.3 压缩

28.4 小结

附录 A ES2018和ES2019

A.1 异步迭代

A.1.1 创建并使用异步迭代器

A.1.2 理解异步迭代器队列

A.1.3 处理异步迭代器的reject()

A.1.4 使用next()手动异步迭代

A.1.5 顶级异步循环

A.1.6 实现可观察对象

A.2 对象字面量的剩余操作符和扩展操作符

A.2.1 剩余操作符

A.2.2 扩展操作符

A.3 Promise.prototype.finally()

A.4 正则表达式相关特性

A.4.1 dotAll标志

A.4.2 向后查找断言

A.4.3 命名捕获组

A.4.4 Unicode属性转义

A.5 数组打平方法

A.5.1 Array.prototype.flatten()

A.5.2 Array.prototype.flatMap()

A.6 Object.fromEntries()

A.7 字符串修理方法

A.8 Symbol.prototype.description

A.9 可选的catch绑定

A.10 其他新增内容

附录 B 严格模式

B.1 选择使用

B.2 变量

B.3 对象

B.4 函数

B.4.1 函数参数

B.4.2 eval()

B.4.3 eval与arguments

B.5 this强制转型

B.6 类与模块

B.7 其他变化

附录 C JavaScript库和框架

C.1 框架

C.1.1 React

C.1.2  Angular

C.1.3 Vue

C.1.4 Ember

C.1.5 Meteor

C.1.6 Backbone.js

C.2 通用库

C.2.1 jQuery

C.2.2 Google Closure Library

C.2.3 Underscore.js

C.2.4 Lodash

C.2.5 Prototype

C.2.6 Dojo Toolkit

C.2.7 MooTools

C.2.8 qooxdoo

C.3 动画与特效

C.3.1 D3

C.3.2 three.js

C.3.3 moo.fx

C.3.4 Lightbox

附录 D JavaScript工具

D.1 包管理

D.1.1 npm

D.1.2 Bower

D.1.3 JSPM

D.1.4 Yarn

D.2 模块加载器

D.2.1 SystemJS

D.2.2 RequireJS

D.3 模块打包器

D.3.1 Webpack

D.3.2 JSPM

D.3.3 Browserify

D.3.4 Rollup

D.4 编译/转译工具及静态类型系统

D.4.1 Babel

D.4.2 Google Closure Compiler

D.4.3 CoffeeScript

D.4.4 TypeScript

D.4.5 Flow

D.5 高性能脚本工具

D.5.1 WebAssembly

D.5.2 asm.js

D.5.3 Emscripten与LLVM

D.6 编辑器

D.6.1 Sublime Text

D.6.2 Atom

D.6.3 Brackets

D.6.4 Visual Studio Code

D.6.5 WebStorm

D.7 构建工具、自动化系统和任务运行器

D.7.1 Grunt

D.7.2 Gulp

D.7.3 Brunch

D.7.4 npm

D.8 代码检查和格式化

D.8.1 ESLint

D.8.2 Google Closure Compiler

D.8.3 JSLint

D.8.4 JSHint

D.8.5 ClangFormat

D.9 压缩工具

D.9.1 Uglify

D.9.2 Google Closure Compiler

D.9.3 JSMin

D.9.4 Dojo ShrinkSafe

D.10 单元测试

D.10.1 Mocha

D.10.2 Jasmine

D.10.3 qUnit

D.10.4 JsUnit

D.10.5 Dojo Object Harness

D.11 文档生成器

D.11.1 ESDoc

D.11.2 documentation.js

D.11.3 Docco

D.11.4 JsDoc Toolkit

D.11.5 YUI Doc

D.11.6 AjaxDoc

作者简介


版权声明

All Rights Reserved. This translation published under license. Authorized translation from the English language edition, entitled Professional JavaScript for Web Developers, 4th Edition , ISBN 9781119366447, by Matt Frisbie, Published by John Wiley & Sons. No part of this book may be reproduced in any form without the written permission of the original copyrights holder.

Simplified Chinese translation edition published by POSTS & TELECOM PRESS Copyright © 2020.

 

本书简体中文版由 John Wiley & Sons, Inc. 授权人民邮电出版社独家出版。

本书封底贴有 John Wiley & Sons, Inc. 激光防伪标签,无标签者不得销售。

版权所有,侵权必究。


献词

献给 Jordan,感谢她无论听到多少次“快写完了”都仍然坚定地支持我。


译者序

七年弹指一挥间。2012 年到 2019 年是 JavaScript 蓬勃发展的七年,鼎鼎大名的 Stack Overflow 调查显示,截至 2019 年,JavaScript 已连续七年位居“最常用编程语言”(most commonly used programming language)榜首。事实上,2020 年的调查结果也毫无悬念,JavaScript 依旧独占鳌头。

2012 年是这本被誉为 JavaScript“红宝书”的著作第 3 版出版的时间。生逢其时,第 3 版狂销几十万册,影响深远,甚至改变了很多人的命运(包括本书译者)。随着 ECMAScript 2015(ES6)的发布, JavaScript 这门语言再次被注入新的生机与活力。2019 年 10 月,涵盖 ECMAScript 2019 的第 4 版面世。如今,跨过一个年头,中文版也要付梓了。

“红宝书”的这一版延续了上一版的框架和格局,删减了已经过时的内容,在此基础上又翔实地增补了 ES2015 到 ES2019 的全新内容,英文版篇幅也达到了前所未有的 1100 多页。

翻译期间,译者虽然尽最大努力确保译文准确、通顺,但错漏之处在所难免。为此特别感谢本书责任编辑温雪,感谢她对译稿认真细致的编辑和审校,以及对出版流程的卓越把控,确保了中文版的早日上市。

在本书印行前夕,为进一步确保出版质量、减少图书错误,我们邀请了数位一线前端开发工程师共同对本书进行了预读和勘误。在短短两周时间内,大家分工协作,筛查、发现并“消灭”了不少文字、排版、代码和技术上的问题,大大提升了本书首印质量。他们分别是(按审读章节顺序排序)饶占平、梁幸芝、陈方旭、林景宜、王欢、刘冰晶、邢洋洋、刘博文、刘观宇、王佳裕。特此致谢。特别感谢贺师俊(Hax)对“期约”(promise)及相关一系列术语翻译的建议。

最后,衷心祝愿罹患“莱姆病”(Lyme disease)的 Nicholas Zakas 早日康复。

2020 年 7 月 15 日


工业革命是钢铁铸就的,互联网革命则是 JavaScript 造就的。25 年的反复锻造与打磨,成就了 JavaScript 在今天的应用程序开发中毋庸置疑的统治地位,但并非一开始就是如此。

Brendan Eich 只用 10 天就写出了 JavaScript 的第一版。初生的 JavaScript 看似弱不禁风,但历史表明,第一印象并不代表一切。今天,这门语言的每个细节,也就是这本书所涉及的方方面面,都是反复推敲的产物。并非所有决定都让人满意,也没有完美的编程语言,不过单从无所不在这方面看,JavaScript 倒是很接近完美。它是目前唯一一个可以随处部署的语言:服务器、桌面浏览器、手机浏览器,甚至原生移动应用程序中都有它的身影。

JavaScript 目前的使用者有不同层次的软件工程师,他们的背景各异。无论是以开发设计精良、优雅的软件为目标,还是仅仅为了完成业绩而简单堆砌一个系统,JavaScript 都能派上用场。

怎么使用 JavaScript 完全取决于你。一切尽在你的掌握之中。

在我超过 15 年的软件开发生涯中,JavaScript 工具和最佳实践已经发生了天翻地覆的变化。2004 年,我开始接触这门语言,当时还是雅虎地球村(Geocities)、雅虎群组(Yahoo Groups)和 Macromedia Flash 播放器的天下。JavaScript 给人感觉像个玩具,当时我在 RSS、MySpace Profile Pages 等流行的沙盒环境中开始使用它。后来我又帮助一些个人网站修改和自定义功能,那种感觉就像在狂野的西部拓荒,而我也因此喜欢上了它。

当初我创建第一家公司的时候,配置主机装个数据库要花几天时间,而 JavaScript 只要扔到 HTML 里就可以跑起来。“前端应用程序”是不存在的,主要是零七碎八的函数。后来 Ajax 因为 jQuery 火了而变得更加流行,这才打开了通向新世界的大门,可靠、稳定的应用程序应运而生。这股风潮愈演愈烈,直到有一天遇到了发展瓶颈,但突然间,强大的框架诞生了。前端模型、数据绑定、路由管理、反应式视图,全都爆发出来了。我就在这个时候搬到硅谷,帮人打理一家公司。很快,使用我代码的用户达到了几百万。置身硅谷这么长时间以来,我也为开源做了一些贡献,培训了不计其数的软件工程师,也走了一点儿运。我的上一家公司在 2018 年被 Stripe 收购,我现在就供职于这家公司,致力于为互联网构建其经济基础设施。

我很高兴在马特第一次到帕洛阿尔托的一家小型创业公司领导工程化时结识了他。那家公司叫 Claco,当时我刚成为它的顾问。他追求伟大软件的活力和激情溢于言表,而这家羽翼未丰的公司很快就开发出一款漂亮的产品。一如为硅谷公司设立标杆的惠普,这家创业公司也诞生在一间平房里。但这可不是寻常的民房,而是一间“黑客屋”,里面十几位才华横溢的软件工程师经常通宵达旦地工作。虽然过的不是什么高档次生活——他们坐的都是别人扔在大街上的那种沙发床和旧椅子——他们在这间房子里每天所写代码的数量和质量却引人瞩目。连续工作几小时后,大多数人会把精力投入到公司的另一个子项目上,然后又是几个小时的工作。不太会写代码的人也常受启发,发现自己学习的渴望,然后仅仅几个星期后就变成了代码能手。

马特是促成这种开发效率的关键角色。他是“黑客屋”里经验最丰富的人,恰好也是思维最清晰、最专业的一个。拿到计算机工程学位并不能说明什么,只要在窗户或者白板上看到马特写的算法、性能计算以及代码,你就知道马特又在专注于他的下一个大项目。随着我对他了解的加深,我们成为了好朋友。他的领悟能力,他对培训工作的热爱,以及几乎可以把所有东西转化成笑话的能力,都是我所欣赏的品质。

虽然马特是一位极具才华的软件工程师和项目领导,但他之所以能成为本书作者独一无二的人选,还是凭借他独有的经验和知识。

他不仅仅花时间教别人,而且还把这本书写完了。

在 Claco,他开发了多款整体性产品,端到端地帮助教师在课堂上提供更好的学习体验。在 DoorDash,他是第一位工程师,开发了一个可靠的物流配送系统并实现了高速增长,目前公司估值超过了 120 亿美元。最后,在 Google,马特写的软件已经被这个星球上的数十亿人使用了。

全情投入,快速增长,誉满天下——多数软件工程师终其一生也只能体验到其中一项,而且还得运气好。马特不仅体验到了全部,还成为了畅销书作者。除了本书,他还写了两本 JavaScript 和 Angular 的书。说实话,我就想知道他什么时候能写一本书,把自己管理时间的奥秘分享出来。

本书是一部翔实的工具书,满满的都是 JavaScript 知识和实用技术。我热切希望本书读者能够不断学习,并亲手打造属于自己的梦想。欢迎大家多多挑错,多记笔记,别忘了打开代码编辑器,毕竟互联网革命才刚刚开始!

Zach Tratar

Stripe 软件工程师

Jobstart 前联合创始人兼 CEO


前言

关于 JavaScript,谷歌公司的一位技术经理曾经跟我分享过一个无法反驳的观点。他说 JavaScript 并不是一门真正有内聚力的编程语言,至少形式上不是。ECMA-262 规范定义了 JavaScript,但 JavaScript 没有唯一正确的实现。更重要的是,这门语言与其宿主关系密切。实际上宿主为 JavaScript 定义了与外界交互所需的全部 API:DOM、网络请求、系统硬件、存储、事件、文件、加密,还有数以百计的其他 API。各种浏览器及其 JavaScript 引擎都按照自己的理解实现了这些规范。Chrome 有 Blink/V8,Firefox 有 Gecko/SpiderMonkey,Safari 有 WebKit/JavaScriptCore,微软有 Trident/EdgeHTML/Chakra。浏览器以合规的方式运行绝大多数 JavaScript,但 Web 上随处可见迎合各种浏览器偏好的页面。因此,对 JavaScript 更准确的定位应该是一组浏览器实现。

Web 纯化论者可能认为 JavaScript 本身并非网页不可或缺的部分,但他们必须承认,如果没有 JavaScript,那么现代 Web 势必发生严重倒退。毫不夸张地讲,JavaScript 才是真正不可或缺的。如今,手机、计算机、平板设备、电视、游戏机、智能手表、冰箱,甚至连汽车都内置了可以执行 JavaScript 代码的 Web 浏览器。地球上有近 30 亿人在使用安装了 Web 浏览器的智能手机。这门语言迅速发展的社区催生了大量高质量的开源项目。浏览器也已经支持模拟原生移动应用程序的 API。Stack Overflow 2019 年的开发者调查显示,JavaScript 连续 7 年位于最流行编程语言榜首。

我们正迎来 JavaScript 的复兴。

本书将从 JavaScript 的起源讲起,从最初的 Netscape 浏览器直到今天各家浏览器支持的让人眼花缭乱的技术。全书对大量高级技术进行了鞭辟入里的剖析,以确保读者真正理解这些技术并掌握它们的应用场景。简而言之,通过学习本书,读者可以透彻地理解如何选择恰当的 JavaScript 技术,以解决现实开发中遇到的业务问题。

本书适合以下读者阅读。

此外,熟悉以下相关技术对阅读本书非常有帮助。

本书第 4 版全面深入地介绍了 JavaScript 开发者必须掌握的前端开发技术,涉及 JavaScript 的基础特性和高级特性。

本书从 JavaScript 的起源开始,逐步讲解到今天的最新技术。书中详尽讨论了 JavaScript 的各个方面,重点介绍 ECMAScript 和 DOM 标准。

在此基础上,接下来的各章揭示了 JavaScript 的基本概念,包括类、期约、迭代器、代理,等等。另外,书中还深入探讨了客户端检测、事件、动画、表单、错误处理及 JSON。

本书最后介绍近几年来涌现的最新和最重要的规范,包括 Fetch API、模块、工作者线程、服务线程以及大量新 API。

本书包含如下这些章。

第 1 章,介绍 JavaScript 的起源:从哪里来,如何发展,以及现今的状况。这一章会谈到 JavaScript 与 ECMAScript 的关系、DOM、BOM,以及 Ecma 和 W3C 相关的标准。

第 2 章,了解 JavaScript 如何与 HTML 结合来创建动态网页,主要介绍在网页中嵌入 JavaScript 的不同方式,还有 JavaScript 的内容类型及其与<script> 元素的关系。

第 3 章,介绍语言的基本概念,包括语法和流控制语句;解释 JavaScript 与其他类 C 语言在语法上的异同点。在讨论内置操作符时也会谈到强制类型转换。此外还将介绍所有的原始类型,包括 Symbol

第 4 章,探索 JavaScript 松散类型下的变量处理。这一章将涉及原始类型与引用类型的不同,以及与变量有关的执行上下文。此外,这一章也会讨论 JavaScript 中的垃圾回收,涉及在变量超出作用域时如何回收内存。

第 5 章,讨论 JavaScript 所有内置的引用类型,如 DateRegexp 、原始类型及其包装类型。每种引用类型既有理论上的讲解,也有相关浏览器实现的剖析。

第 6 章,继续讨论内置引用类型,包括 ObjectArrayMapWeakMapSetWeakSet 等。

第 7 章,介绍 ECMAScript 新版中引入的两个基本概念:迭代器和生成器,并分别讨论它们最基本的行为和在当前语言环境下的应用。

第 8 章,解释如何在 JavaScript 中使用类和面向对象编程。首先会深入讨论 JavaScript 的 Object 类型,进而探讨原型式继承,接下来全面介绍 ES6 类及其与原型式继承的紧密关系。

第 9 章,介绍两个紧密相关的概念:Proxy (代理)和 Reflect (反射) API。代理和反射用于拦截和修改这门语言的基本操作。

第 10 章,探索 JavaScript 最强大的一个特性:函数表达式,主要涉及闭包、this 对象、模块模式,创建私有对象成员、箭头函数、默认参数和扩展操作符。

第 11 章,介绍两个紧密相关的异步编程构造:Promise 类型和 async /await 。这一章讨论 JavaScript 的异步编程范式,进而介绍期约(promise)与异步函数的关系。

第 12 章,介绍 BOM,即浏览器对象模型,跟与浏览器本身交互的 API 相关。所有 BOM 对象都会涉及,包括 windowdocumentlocationnavigatorscreen 等。

第 13 章,解释检测客户端机器及其能力的不同手段,包括能力检测和用户代理字符串检测。这一章讨论每种手段的优缺点,以及适用的场景。

第 14 章,介绍 DOM,即文档对象模型,主要是 DOM Level 1 定义的 API。这一章将简单讨论 XML 及其与 DOM 的关系,进而全面探索 DOM 以及如何利用它操作网页。

第 15 章,解释其他 DOM API,包括浏览器本身对 DOM 的扩展,主要涉及 Selectors API、Element Traversal API 和 HTML5 扩展。

第 16 章,在之前两章的基础上,解释 DOM Level 2 和 Level 3 对 DOM 的扩展,包括新增的属性、方法和对象。这一章还会介绍 DOM4 的相关内容,比如 Mutation Observer。

第 17 章,解释事件在 JavaScript 中的本质,以及事件的起源及其在 DOM 中的运行方式。

第 18 章,围绕<canvas> 标签讨论如何创建动态图形,包括 2D 和 3D 上下文(WebGL)等动画和游戏开发所需的基础。这一章还会讨论 WebGL1 和 WebGL2。

第 19 章,探索使用 JavaScript 增强表单交互及突破浏览器限制,主要讨论文本框、选择框等表单元素及数据验证和操作。

第 20 章,介绍各种 JavaScript API,包括 Atomics、Encoding、File、Blob、Notifications、Streams、 Timing、Web Components 和 Web Cryptography。

第 21 章,讨论浏览器如何处理 JavaScript 代码中的错误及几种错误处理方式。这一章同时介绍了每种浏览器的调试工具和技术,包括简化调试过程的建议。

第 22 章,介绍通过 JavaScript 读取和操作 XML 数据的特性,解释了不同浏览器支持特性和对象的差异,提供了简化跨浏览器编码的建议。这一章也讨论了使用 XSLT 在客户端转换 XML 数据。

第 23 章,介绍作为 XML 替代的 JSON 数据格式,还讨论了浏览器原生解析和序列化 JSON,以及使用 JSON 时要注意的安全问题。

第 24 章,探讨浏览器请求数据和资源的常用方式,包括早期的 XMLHttpRequest 和现代的 Fetch API。

第 25 章,讨论应用程序离线时在客户端机器上存储数据的各种技术。先从 cookie 谈起,然后讨论 Web Storage 和 IndexedDB。

第 26 章,介绍模块模式在编码中的应用,进而讨论 ES6 模块之前的模块加载方式,包括 CommonJS、 AMD 和 UMD。最后介绍新的 ES6 模块及其正确用法。

第 27 章,深入介绍专用工作者线程、共享工作者线程和服务工作者线程。其中包括工作者线程在操作系统和浏览器层面的实现,以及使用各种工作者线程的最佳策略。

第 28 章,探讨在企业级开发中进行 JavaScript 编码的最佳实践。其中提到了提升代码可维护性的编码惯例,包括编码技巧、格式化及通用编码建议。深入讨论应用性能和提升速度的技术。最后介绍与上线部署相关的话题,包括项目构建流程。

要运行本书示例代码,需要如下条件。

本书完整的源代码可以扫描封底二维码,可以下载本书源代码,并加入图灵前端研发小组。1

1 读者也可访问本书图灵社区页面(https://www.ituring.com.cn/book/2472 )下载本书配套学习资源,并提交中文版勘误。——编者注

扫描下方二维码,即可购买本书中文版电子书,并从“随书下载”处获取本书附录。

{%}


致谢

感谢 Wiley 出版社让我接手这本书。编写本书第 4 版对我来说是前所未有的挑战,也让我收获非常大。来自 Wiley 的包容和支持是本书得以完成的前提。感谢 Wiley 的工作人员,特别是把这本书交到我手上并紧盯着整个流程的 Jim Minatel。

感谢本书前 3 版的作者 Nicholas C. Zakas,感谢他在我接手之前所做的一切。没有他之前打下的良好基础,就不会有本书今天的成就。衷心祝愿他早日康复。

特别感谢 Adaobi Obi Tulton 的指导。如果没有她对整个流程的把控,以及她的耐心和专业水准,我不可能写完这一版。

还要感谢对本书草稿给出反馈意见的所有人:Samuel Kallner、Chaim Krause、Marcia Wilbur、Nancy Rapoport、Athiyappan Lalith Kumar,还有 Evelyn Wellborn。这样一本书,少了你们任何人的帮助,都不会像现在这么完善。

最后,我想感谢 Zach Tratar 为本书作序。我非常幸运地在搬到旧金山的头一天就认识了 Zach Tratar。几年来,作为良师益友,他的求知若渴和博学多才一直感染着我,何况他还是一位杰出的软件工程师。他同意为本书作序是我的荣幸。


第 1 章 什么是JavaScript

本章内容

1995年,JavaScript问世。当时,它的主要用途是代替Perl等服务器端语言处理输入验证。在此之前,要验证某个必填字段是否已填写,或者某个输入的值是否有效,需要与服务器的一次往返通信。网景公司希望通过在其Navigator浏览器中加入JavaScript来改变这个局面。在那个普遍通过电话拨号上网的年代,由客户端处理某些基本的验证是让人兴奋的新功能。缓慢的网速让页面每次刷新都考验着人们的耐心。

从那时起,JavaScript逐渐成为市面上所有主流浏览器的标配。如今,JavaScript的应用也不再局限于数据验证,而是渗透到浏览器窗口及其内容的方方面面。JavaScript已被公认为主流的编程语言,能够实现复杂的计算与交互,包括闭包、匿名(lambda)函数,甚至元编程等特性。不仅是桌面浏览器,手机浏览器和屏幕阅读器也支持JavaScript,其重要性可见一斑。就连拥有自家客户端脚本语言VBScript的微软公司,也在其Internet Explorer(以下简称IE)浏览器最初的版本中包含了自己的JavaScript实现。

从简单的输入验证脚本到强大的编程语言,JavaScript的崛起没有任何人预测到。它很简单,学会用只要几分钟;它又很复杂,掌握它要很多年。要真正学好用好JavaScript,理解其本质、历史及局限性是非常重要的。

随着Web日益流行,对客户端脚本语言的需求也越来越强烈。当时,大多数用户使用28.8kbit/s的调制解调器上网,但网页变得越来越大、越来越复杂。为验证简单的表单而需要大量与服务器的往返通信成为用户的痛点。想象一下,你填写完表单,单击“提交”按钮,等30秒处理,然后看到一条消息,告诉你有一个必填字段没填。网景在当时是引领技术革新的公司,它将开发一个客户端脚本语言来处理这种简单的数据验证提上了日程。

1995年,网景公司一位名叫Brendan Eich的工程师,开始为即将发布的Netscape Navigator 2开发一个叫Mocha(后来改名为LiveScript)的脚本语言。当时的计划是在客户端和服务器端都使用它,它在服务器端叫LiveWire。

为了赶上发布时间,网景与Sun公司结为开发联盟,共同完成LiveScript的开发。就在Netscape Navigator 2正式发布前,网景把LiveScript改名为JavaScript,以便搭上媒体当时热烈炒作Java的顺风车。

由于JavaScript 1.0很成功,网景又在Netscape Navigator 3中发布了1.1版本。尚未成熟的Web的受欢迎程度创造了历史新高,而网景则稳居市场领导者的位置。这时候,微软决定向IE投入更多资源。就在Netscape Navigator 3发布后不久,微软发布了IE3,其中包含自己名为JScript(叫这个名字是为了避免与网景发生许可纠纷)的JavaScript实现。1996年8月,微软重磅进入Web浏览器领域,这是网景永远的痛,但它代表JavaScript作为一门语言向前迈进了一大步。

微软的JavaScript实现意味着出现了两个版本的JavaScript:Netscape Navigator中的JavaScript,以及IE中的JScript。与C语言以及很多其他编程语言不同,JavaScript还没有规范其语法或特性的标准,两个版本并存让这个问题更加突出了。随着业界担忧日甚,JavaScript终于踏上了标准化的征程。

1997年,JavaScript 1.1作为提案被提交给欧洲计算机制造商协会(Ecma)。第39技术委员会(TC39)承担了“标准化一门通用、跨平台、厂商中立的脚本语言的语法和语义”的任务(参见TC39-ECMAScript)。TC39委员会由来自网景、Sun、微软、Borland、Nombas和其他对这门脚本语言有兴趣的公司的工程师组成。他们花了数月时间打造出ECMA-262,也就是ECMAScript(发音为“ek-ma-script”)这个新的脚本语言标准。

1998年,国际标准化组织(ISO)和国际电工委员会(IEC)也将ECMAScript采纳为标准(ISO/IEC-16262)。自此以后,各家浏览器均以ECMAScript作为自己JavaScript实现的依据,虽然具体实现各有不同。

虽然JavaScript和ECMAScript基本上是同义词,但JavaScript远远不限于ECMA-262所定义的那样。没错,完整的JavaScript实现包含以下几个部分(见图1-1):

图 1-1

ECMAScript ,即ECMA-262定义的语言,并不局限于Web浏览器。事实上,这门语言没有输入和输出之类的方法。ECMA-262将这门语言作为一个基准来定义,以便在它之上再构建更稳健的脚本语言。Web浏览器只是ECMAScript实现可能存在的一种宿主环境 (host environment)。宿主环境提供ECMAScript的基准实现和与环境自身交互必需的扩展。扩展(比如DOM)使用ECMAScript核心类型和语法,提供特定于环境的额外功能。其他宿主环境还有服务器端JavaScript平台Node.js和即将被淘汰的Adobe Flash。

如果不涉及浏览器的话,ECMA-262到底定义了什么?在基本的层面,它描述这门语言的如下部分:

ECMAScript只是对实现这个规范描述的所有方面的一门语言的称呼。JavaScript实现了ECMAScript,而Adobe ActionScript同样也实现了ECMAScript。

  1. ECMAScript版本

    ECMAScript不同的版本以“edition”表示(也就是描述特定实现的ECMA-262的版本)。ECMA-262最近的版本是第10版,发布于2019年6月。ECMA-262的第1版本质上跟网景的JavaScript 1.1相同,只不过删除了所有浏览器特定的代码,外加少量细微的修改。ECMA-262要求支持Unicode标准(以支持多语言),而且对象要与平台无关(Netscape JavaScript 1.1的对象不是这样,比如它的Date 对象就依赖平台)。这也是JavaScript 1.1和JavaScript 1.2不符合ECMA-262第1版要求的原因。

    ECMA-262第2版只是做了一些编校工作,主要是为了更新之后严格符合ISO/IEC-16262的要求,并没有增减或改变任何特性。ECMAScript实现通常不使用第2版来衡量符合性(conformance)。

    ECMA-262第3版第一次真正对这个标准进行更新,更新了字符串处理、错误定义和数值输出。此外还增加了对正则表达式、新的控制语句、try /catch 异常处理的支持,以及为了更好地让标准国际化所做的少量修改。对很多人来说,这标志着ECMAScript作为一门真正的编程语言的时代终于到来了。

    ECMA-262第4版是对这门语言的一次彻底修订。作为对JavaScript在Web上日益成功的回应,开发者开始修订ECMAScript以满足全球Web开发日益增长的需求。为此,Ecma T39再次被召集起来,以决定这门语言的未来。结果,他们制定的规范几乎在第3版基础上完全定义了一门新语言。第4版包括强类型变量、新语句和数据结构、真正的类和经典的继承,以及操作数据的新手段。

    与此同时,TC39委员会的一个子委员会也提出了另外一份提案,叫作“ECMAScript 3.1”,只对这门语言进行了较少的改进。这个子委员会的人认为第4版对这门语言来说跳跃太大了。因此,他们提出了一个改动较小的提案,只要在现有JavaScript引擎基础上做一些增改就可以实现。最终,ES3.1子委员会赢得了TC39委员会的支持,ECMA-262第4版在正式发布之前被放弃。

    ECMAScript 3.1变成了ECMA-262的第5版,于2009年12月3日正式发布。第5版致力于厘清第3版存在的歧义,也增加了新功能。新功能包括原生的解析和序列化JSON数据的JSON 对象、方便继承和高级属性定义的方法,以及新的增强ECMAScript引擎解释和执行代码能力的严格模式。第5版在2011年6月发布了一个维护性修订版,这个修订版只更正了规范中的错误,并未增加任何新的语言或库特性。

    ECMA-262第6版,俗称ES6、ES2015或ES Harmony(和谐版),于2015年6月发布。这一版包含了大概这个规范有史以来最重要的一批增强特性。ES6正式支持了类、模块、迭代器、生成器、箭头函数、期约、反射、代理和众多新的数据类型。

    ECMA-262第7版,也称为ES7或ES2016,于2016年6月发布。这次修订只包含少量语法层面的增强,如Array.prototype.includes 和指数操作符。

    ECMA-262第8版,也称为ES8、ES2017,完成于2017年6月。这一版主要增加了异步函数(async/await)、SharedArrayBuffer 及Atomics API,以及Object.values() /Object.entries() /Object.getOwnPropertyDescriptors() 和字符串填充方法,另外明确支持对象字面量最后的逗号。

    ECMA-262第9版,也称为ES9、ES2018,发布于2018年6月。这次修订包括异步迭代、剩余和扩展属性、一组新的正则表达式特性、Promise finally() ,以及模板字面量修订。

    ECMA-262第10版,也称为ES10、ES2019,发布于2019年6月。这次修订增加了Array.prototype.flat() /flatMap()String.prototype.trimStart() /trimEnd()Object.fromEntries() 方法,以及Symbol.prototype.description 属性,明确定义了Function.prototype.toString() 的返回值并固定了Array.prototype.sort() 的顺序。另外,这次修订解决了与JSON字符串兼容的问题,并定义了catch 子句的可选绑定。
     

  2. ECMAScript符合性是什么意思

    ECMA-262阐述了什么是ECMAScript符合性。要成为ECMAScript实现,必须满足下列条件:

    此外,符合性实现还可以满足下列要求。

    以上条件为实现开发者基于ECMAScript开发语言提供了极大的权限和灵活度,也是其广受欢迎的原因之一。
     

  3. 浏览器对ECMAScript的支持

    1996年,Netscape Navigator 3发布时包含了JavaScript 1.1。JavaScript 1.1规范随后被提交给Ecma,作为对新的ECMA-262标准的建议。随着JavaScript迅速走红,网景非常愿意开发1.2版。可是有个问题:Ecma尚未接受网景的建议。

    Netscape Navigator 3发布后不久,微软推出了IE3。IE的这个版本包含了JScript 1.0,本意是提供与JavaScript 1.1相同的功能。不过,由于缺少很多文档,而且还有不少重复性功能,JScript 1.0远远没有JavaScript 1.1那么强大。

    JScript的再次更新出现在IE4中的JScript 3.0(2.0版是在Microsoft Internet Information Server 3.0中发布的,但从未包含在浏览器中)。微软发新闻稿称JScript 3.0是世界上第一门真正兼容Ecma标准的脚本语言。当时ECMA-262还没制定完成,因此JScript 3.0遭受了与JavaScript 1.2同样的命运,它同样没有遵守最终的ECMAScript标准。

    网景又在Netscape Navigator 4.06中将其JavaScript版本升级到1.3,因此做到了与ECMA-262第1版完全兼容。JavaScript 1.3增加了对Unicode标准的支持,并做到了所有对象都与平台无关,同时保留了JavaScript 1.2所有的特性。

    后来,当网景以Mozilla项目的名义向公众发布其源代码时,人们都期待Netscape Navigator 5中会包含JavaScript 1.4。可是,一个完全重新设计网景代码的激进决定导致了人们的希望落空。JavaScript 1.4只在Netscape Enterprise Server中作为服务器端语言发布了,从来就没有进入浏览器。

    到了2008年,五大浏览器(IE、Firefox、Safari、Chrome和Opera)全部兼容ECMA-262第3版。IE8率先实现ECMA-262第5版,并在IE9中完整支持。Firefox 4很快也做到了。下表列出了主要的浏览器版本对ECMAScript的支持情况。

    浏览器 ECMAScript符合性
    Netscape Navigator 2
    Netscape Navigator 3
    Netscape Navigator 4~4.05
    Netscape Navigator 4.06~4.79 第1版
    Netscape 6+(Mozilla 0.6.0+) 第3版
    IE3
    IE4
    IE5 第1版
    IE5.5~8 第3版
    IE9 第5版(部分)
    IE10~11 第5版
    Edge 12+ 第6版
    Opera 6~7.1 第2版
    Opera 7.2+ 第3版
    Opera 15~28 第5版
    Opera 29~35 第6版(部分)
    Opera 36+ 第6版
    Safari 1~2.0.x 第3版(部分)
    Safari 3.1~5.1 第5版(部分)
    Safari 6~8 第5版
    Safari 9+ 第6版
    iOS Safari 3.2~5.1 第5版(部分)
    iOS Safari 6~8.4 第5版
    iOS Safari 9.2+ 第6版
    Chrome 1~3 第3版
    Chrome 4~22 第5版(部分)
    Chrome 23+ 第5版
    Chrome 42~48 第6版(部分)
    Chrome 49+ 第6版
    Firefox 1~2 第3版
    Firefox 3.0.x ~20 第5版(部分)
    Firefox 21~44 第5版
    Firefox 45+ 第6版

文档对象模型 (DOM,Document Object Model)是一个应用编程接口(API),用于在HTML中使用扩展的XML。DOM将整个页面抽象为一组分层节点。HTML或XML页面的每个组成部分都是一种节点,包含不同的数据。比如下面的HTML页面:

<html>
    <head>
        <title>Sample Page</title>
    </head>
    <body>
        <p> Hello World!</p>
    </body>
</html>

这些代码通过DOM可以表示为一组分层节点,如图1-2所示。

图 1-2

DOM通过创建表示文档的树,让开发者可以随心所欲地控制网页的内容和结构。使用DOM API,可以轻松地删除、添加、替换、修改节点。

  1. 为什么DOM是必需的

    在IE4和Netscape Navigator 4支持不同形式的动态HTML(DHTML)的情况下,开发者首先可以做到不刷新页面而修改页面外观和内容。这代表了Web技术的一个巨大进步,但也暴露了很大的问题。由于网景和微软采用不同思路开发DHTML,开发者写一个HTML页面就可以在任何浏览器中运行的好日子就此终结。

    为了保持Web跨平台的本性,必须要做点什么。人们担心如果无法控制网景和微软各行其是,那么Web就会发生分裂,导致人们面向浏览器开发网页。就在这时,万维网联盟(W3C,World Wide Web Consortium)开始了制定DOM标准的进程。
     

  2. DOM级别

    1998年10月,DOM Level 1成为W3C的推荐标准。这个规范由两个模块组成:DOM Core和DOM HTML。前者提供了一种映射XML文档,从而方便访问和操作文档任意部分的方式;后者扩展了前者,并增加了特定于HTML的对象和方法。

    注意  DOM并非只能通过JavaScript访问,而且确实被其他很多语言实现了。不过对于浏览器来说,DOM就是使用ECMAScript实现的,如今已经成为JavaScript语言的一大组成部分。

    DOM Level 1的目标是映射文档结构,而DOM Level 2的目标则宽泛得多。这个对最初DOM的扩展增加了对(DHTML早就支持的)鼠标和用户界面事件、范围、遍历(迭代DOM节点的方法)的支持,而且通过对象接口支持了层叠样式表(CSS)。另外,DOM Level 1中的DOM Core也被扩展以包含对XML命名空间的支持。

    DOM Level 2新增了以下模块,以支持新的接口。

    DOM Level 3进一步扩展了DOM,增加了以统一的方式加载和保存文档的方法(包含在一个叫DOM Load and Save的新模块中),还有验证文档的方法(DOM Validation)。在Level 3中,DOM Core经过扩展支持了所有XML 1.0的特性,包括XML Infoset、XPath和XML Base。

    目前,W3C不再按照Level来维护DOM了,而是作为DOM Living Standard来维护,其快照称为DOM4。DOM4新增的内容包括替代Mutation Events的Mutation Observers。

    注意  在阅读关于DOM的资料时,你可能会看到DOM Level 0的说法。注意,并没有一个标准叫“DOM Level 0”,这只是DOM历史中的一个参照点。DOM Level 0可以看作IE4和Netscape Navigator 4中最初支持的DHTML。

     

  3. 其他DOM

    除了DOM Core和DOM HTML接口,有些其他语言也发布了自己的DOM标准。下面列出的语言是基于XML的,每一种都增加了该语言独有的DOM方法和接口:

    此外,还有一些语言开发了自己的DOM实现,比如Mozilla的XML用户界面语言(XUL,XML User Interface Language)。不过,只有前面列表中的语言是W3C推荐标准。
     

  4. Web浏览器对DOM的支持情况

    DOM标准在Web浏览器实现它之前就已经作为标准发布了。IE在第5版中尝试支持DOM,但直到5.5版才开始真正支持,该版本实现了DOM Level 1的大部分。IE在第6版和第7版中都没有实现新特性,第8版中修复了一些问题。

    网景在Netscape 6(Mozilla 0.6.0)之前都不支持DOM。Netscape 7之后,Mozilla把开发资源转移到开发Firefox浏览器上。Firefox 3+支持全部的Level 1、几乎全部的Level 2,以及Level 3的某些部分。(Mozilla开发团队的目标是打造百分之百兼容标准的浏览器,他们的工作也得到了应有的回报。)

    支持DOM是浏览器厂商的重中之重,每个版本发布都会改进支持度。下表展示了主流浏览器支持DOM的情况。

    浏览器 DOM兼容
    Netscape Navigator 1~4.x
    Netscape 6+(Mozilla 0.6.0+) Level 1、Level 2(几乎全部)、Level 3(部分)
    IE2~4.x
    IE5 Level 1(很少)
    IE5.5~8 Level 1(几乎全部)
    IE9+ Level 1、Level 2、Level 3
    Edge Level 1、Level 2、Level 3
    Opera 1~6
    Opera 7~8.x Level 1(几乎全部)、Level 2(部分)
    Opera 9~9.9 Level 1、Level 2(几乎全部)、Level 3(部分)
    Opera 10+ Level 1、Level 2、Level 3(部分)
    Safari 1.0.x Level 1
    Safari 2+ Level 1、Level 2(部分)、Level 3(部分)
    iOS Safari 3.2+ Level 1、Level 2(部分)、Level 3(部分)
    Chrome 1+ Level 1、Level 2(部分)、Level 3(部分)
    Firefox 1+ Level 1、Level 2(几乎全部)、Level 3(部分)

    注意  上表中兼容性的状态会随时间而变化,其中的内容仅反映本书写作时的状态。

IE3和Netscape Navigator 3提供了浏览器对象模型 (BOM) API,用于支持访问和操作浏览器的窗口。使用BOM,开发者可以操控浏览器显示页面之外的部分。而BOM真正独一无二的地方,当然也是问题最多的地方,就是它是唯一一个没有相关标准的JavaScript实现。HTML5改变了这个局面,这个版本的HTML以正式规范的形式涵盖了尽可能多的BOM特性。由于HTML5的出现,之前很多与BOM有关的问题都迎刃而解了。

总体来说,BOM主要针对浏览器窗口和子窗口(frame),不过人们通常会把任何特定于浏览器的扩展都归在BOM的范畴内。比如,下面就是这样一些扩展:

因为在很长时间内都没有标准,所以每个浏览器实现的都是自己的BOM。有一些所谓的事实标准,比如对于window 对象和navigator 对象,每个浏览器都会给它们定义自己的属性和方法。现在有了HTML5,BOM的实现细节应该会日趋一致。关于BOM,本书会在第12章再专门详细介绍。

作为网景的继承者,Mozilla是唯一仍在延续最初JavaScript版本编号的浏览器厂商。当初网景在将其源代码开源时(项目名为Mozilla Project),JavaScript在其浏览器中最后的版本是1.3。(前面提到过,1.4版是专门为服务器实现的。)因为Mozilla Foundation在持续开发JavaScript,为它增加新特性、关键字和语法,所以JavaScript的版本号也在不断递增。下表展示了Netscape/Mozilla浏览器发布的历代JavaScript版本。

浏览器

JavaScript版本

Netscape Navigator 2

1.0

Netscape Navigator 3

1.1

Netscape Navigator 4

1.2

Netscape Navigator 4.06

1.3

Netscape 6+(Mozilla 0.6.0+)

1.5

Firefox 1

1.5

Firefox 1.5

1.6

Firefox 2

1.7

Firefox 3

1.8

Firefox 3.5

1.8.1

Firefox 3.6

1.8.2

Firefox 4

1.8.5

这种版本编号方式是根据Firefox 4要发布JavaScript 2.0决定的,在此之前版本号的每次递增,反映的是JavaScript实现逐渐接近2.0建议。虽然这是最初的计划,但JavaScript的发展让这个计划变得不可能。JavaScript 2.0作为一个目标已经不存在了,而这种版本号编排方式在Firefox 4发布后就终止了。

注意  Netscape/Mozilla仍然沿用这种版本方案。而IE的JScript有不同的版本号规则。这些JScript版本与上表提到的JavaScript版本并不对应。此外,多数浏览器对JavaScript的支持,指的是实现ECMAScript和DOM的程度。

JavaScript是一门用来与网页交互的脚本语言,包含以下三个组成部分。

JavaScript的这三个部分得到了五大Web浏览器(IE、Firefox、Chrome、Safari和Opera)不同程度的支持。所有浏览器基本上对ES5(ECMAScript 5)提供了完善的支持,而对ES6(ECMAScript 6)和ES7(ECMAScript 7)的支持度也在不断提升。这些浏览器对DOM的支持各不相同,但对Level 3的支持日益趋于规范。HTML5中收录的BOM会因浏览器而异,不过开发者仍然可以假定存在很大一部分公共特性。


第 2 章 HTML中的JavaScript

本章内容

将JavaScript引入网页,首先要解决它与网页的主导语言HTML的关系问题。在JavaScript早期,网景公司的工作人员希望在将JavaScript引入HTML页面的同时,不会导致页面在其他浏览器中渲染出问题。通过反复试错和讨论,他们最终做出了一些决定,并达成了向网页中引入通用脚本能力的共识。当初他们的很多工作得到了保留,并且最终形成了HTML规范。

将JavaScript插入HTML的主要方法是使用<script> 元素。这个元素是由网景公司创造出来,并最早在Netscape Navigator 2中实现的。后来,这个元素被正式加入到HTML规范。<script> 元素有下列8个属性。

使用<script> 的方式有两种:通过它直接在网页中嵌入JavaScript代码,以及通过它在网页中包含外部JavaScript文件。

要嵌入行内JavaScript代码,直接把代码放在<script> 元素中就行:

<script>
  function sayHi() {
    console.log("Hi!");
  }
</script>

包含在<script> 内的代码会被从上到下解释。在上面的例子中,被解释的是一个函数定义,并且该函数会被保存在解释器环境中。在<script> 元素中的代码被计算完成之前,页面的其余内容不会被加载,也不会被显示。

在使用行内JavaScript代码时,要注意代码中不能出现字符串</script> 。比如,下面的代码会导致浏览器报错:

<script>
  function sayScript() {
    console.log("</script>");
  }
</script>

浏览器解析行内脚本的方式决定了它在看到字符串</script> 时,会将其当成结束的</script> 标签。想避免这个问题,只需要转义字符“\”1 即可:

1 此处的转义字符指在JavaScript中使用反斜杠“\ ”来向文本字符串添加特殊字符。——编者注

<script>
  function sayScript() {
    console.log("<\/script>");
  }
</script>

这样修改之后,代码就可以被浏览器完全解释,不会导致任何错误。

要包含外部文件中的JavaScript,就必须使用src 属性。这个属性的值是一个URL,指向包含JavaScript代码的文件,比如:

<script src="example.js"></script>

这个例子在页面中加载了一个名为example.js的外部文件。文件本身只需包含要放在<script> 的起始及结束标签中间的JavaScript代码。与解释行内JavaScript一样,在解释外部JavaScript文件时,页面也会阻塞。(阻塞时间也包含下载文件的时间。)在XHTML文档中,可以忽略结束标签,比如:

<script src="example.js"/>

以上语法不能在HTML文件中使用,因为它是无效的HTML,有些浏览器不能正常处理,比如IE。

注意  按照惯例,外部JavaScript文件的扩展名是.js。这不是必需的,因为浏览器不会检查所包含JavaScript文件的扩展名。这就为使用服务器端脚本语言动态生成JavaScript代码,或者在浏览器中将JavaScript扩展语言(如TypeScript,或React的JSX)转译为JavaScript提供了可能性。不过要注意,服务器经常会根据文件扩展来确定响应的正确MIME类型。如果不打算使用.js扩展名,一定要确保服务器能返回正确的MIME类型。

另外,使用了src 属性的<script> 元素不应该再在<script></script> 标签中再包含其他JavaScript代码。如果两者都提供的话,则浏览器只会下载并执行脚本文件,从而忽略行内代码。

<script> 元素的一个最为强大、同时也备受争议的特性是,它可以包含来自外部域的JavaScript文件。跟<img> 元素很像,<script> 元素的src 属性可以是一个完整的URL,而且这个URL指向的资源可以跟包含它的HTML页面不在同一个域中,比如这个例子:

<script src="http://www.somewhere.com/afile.js"></script>

浏览器在解析这个资源时,会向src 属性指定的路径发送一个GET 请求,以取得相应资源,假定是一个JavaScript文件。这个初始的请求不受浏览器同源策略限制,但返回并被执行的JavaScript则受限制。当然,这个请求仍然受父页面HTTP/HTTPS协议的限制。

来自外部域的代码会被当成加载它的页面的一部分来加载和解释。这个能力可以让我们通过不同的域分发JavaScript。不过,引用了放在别人服务器上的JavaScript文件时要格外小心,因为恶意的程序员随时可能替换这个文件。在包含外部域的JavaScript文件时,要确保该域是自己所有的,或者该域是一个可信的来源。<script> 标签的integrity 属性是防范这种问题的一个武器,但这个属性也不是所有浏览器都支持。

不管包含的是什么代码,浏览器都会按照<script> 在页面中出现的顺序依次解释它们,前提是它们没有使用deferasync 属性。第二个<script> 元素的代码必须在第一个<script> 元素的代码解释完毕才能开始解释,第三个则必须等第二个解释完,以此类推。

过去,所有<script> 元素都被放在页面的<head> 标签内,如下面的例子所示:

<!DOCTYPE html>
<html>
  <head>
  <title>Example HTML Page</title>
  <script src="example1.js"></script>
  <script src="example2.js"></script>
  </head>
  <body>
  <!-- 这里是页面内容 -->
  </body>
</html>

这种做法的主要目的是把外部的CSS和JavaScript文件都集中放到一起。不过,把所有JavaScript文件都放在<head> 里,也就意味着必须把所有JavaScript代码都下载、解析和解释完成后,才能开始渲染页面(页面在浏览器解析到<body> 的起始标签时开始渲染)。对于需要很多JavaScript的页面,这会导致页面渲染的明显延迟,在此期间浏览器窗口完全空白。为解决这个问题,现代Web应用程序通常将所有JavaScript引用放在<body> 元素中的页面内容后面,如下面的例子所示:

<!DOCTYPE html>
<html>
  <head>
  <title>Example HTML Page</title>
  </head>
  <body>
  <!-- 这里是页面内容 -->
  <script src="example1.js"></script>
  <script src="example2.js"></script>
  </body>
</html>

这样一来,页面会在处理JavaScript代码之前完全渲染页面。用户会感觉页面加载更快了,因为浏览器显示空白页面的时间短了。

HTML 4.01为<script> 元素定义了一个叫defer 的属性。这个属性表示脚本在执行的时候不会改变页面的结构。也就是说,脚本会被延迟到整个页面都解析完毕后再运行。因此,在<script> 元素中设置defer 属性,相当于告诉浏览器立即下载,但延迟执行。

<!DOCTYPE html>
<html>
  <head>
  <title>Example HTML Page</title>
  <script defer src="example1.js"></script>
  <script defer src="example2.js"></script>
  </head>
  <body>
  <!-- 这里是页面内容 -->
  </body>
</html>

虽然这个例子中的<script> 元素包含在页面的<head> 中,但它们会在浏览器解析到结束的</html> 标签后才会执行。HTML5规范要求脚本应该按照它们出现的顺序执行,因此第一个推迟的脚本会在第二个推迟的脚本之前执行,而且两者都会在DOMContentLoaded 事件之前执行(关于事件,请参考第17章)。不过在实际当中,推迟执行的脚本不一定总会按顺序执行或者在DOMContentLoaded 事件之前执行,因此最好只包含一个这样的脚本。

如前所述,defer 属性只对外部脚本文件才有效。这是HTML5中明确规定的,因此支持HTML5的浏览器会忽略行内脚本的defer 属性。IE4~7展示出的都是旧的行为,IE8及更高版本则支持HTML5定义的行为。

defer 属性的支持是从IE4、Firefox 3.5、Safari 5和Chrome 7开始的。其他所有浏览器则会忽略这个属性,按照通常的做法来处理脚本。考虑到这一点,还是把要推迟执行的脚本放在页面底部比较好。

注意  对于XHTML文档,指定defer 属性时应该写成defer="defer"

HTML5为<script> 元素定义了async 属性。从改变脚本处理方式上看,async 属性与defer 类似。当然,它们两者也都只适用于外部脚本,都会告诉浏览器立即开始下载。不过,与defer 不同的是,标记为async 的脚本并不保证能按照它们出现的次序执行,比如:

<!DOCTYPE html>
<html>
  <head>
  <title>Example HTML Page</title>
  <script async src="example1.js"></script>
  <script async src="example2.js"></script>
  </head>
  <body>
  <!-- 这里是页面内容 -->
  </body>
</html>

在这个例子中,第二个脚本可能先于第一个脚本执行。因此,重点在于它们之间没有依赖关系。给脚本添加async 属性的目的是告诉浏览器,不必等脚本下载和执行完后再加载页面,同样也不必等到该异步脚本下载和执行后再加载其他脚本。正因为如此,异步脚本不应该在加载期间修改DOM。

异步脚本保证会在页面的load 事件前执行,但可能会在DOMContentLoaded (参见第17章)之前或之后。Firefox 3.6、Safari 5和Chrome 7支持异步脚本。使用async 也会告诉页面你不会使用document.write ,不过好的Web开发实践根本就不推荐使用这个方法。

注意  对于XHTML文档,指定async 属性时应该写成async="async"

除了<script> 标签,还有其他方式可以加载脚本。因为JavaScript可以使用DOM API,所以通过向DOM中动态添加script 元素同样可以加载指定的脚本。只要创建一个script 元素并将其添加到DOM即可。

let script = document.createElement('script');
script.src = 'gibberish.js';
document.head.appendChild(script);

当然,在把HTMLElement 元素添加到DOM且执行到这段代码之前不会发送请求。默认情况下,以这种方式创建的<script> 元素是以异步方式加载的,相当于添加了async 属性。不过这样做可能会有问题,因为所有浏览器都支持createElement() 方法,但不是所有浏览器都支持async 属性。因此,如果要统一动态脚本的加载行为,可以明确将其设置为同步加载:

let script = document.createElement('script');
script.src = 'gibberish.js';
script.async = false;
document.head.appendChild(script);

以这种方式获取的资源对浏览器预加载器是不可见的。这会严重影响它们在资源获取队列中的优先级。根据应用程序的工作方式以及怎么使用,这种方式可能会严重影响性能。要想让预加载器知道这些动态请求文件的存在,可以在文档头部显式声明它们:

<link rel="preload" href="gibberish.js">

可扩展超文本标记语言(XHTML,Extensible HyperText Markup Language)是将HTML作为XML的应用重新包装的结果。与HTML不同,在XHTML中使用JavaScript必须指定type 属性且值为text/javascript ,HTML中则可以没有这个属性。XHTML虽然已经退出历史舞台,但实践中偶尔可能也会遇到遗留代码,为此本节稍作介绍。

在XHTML中编写代码的规则比HTML中严格,这会影响使用<script> 元素嵌入JavaScript代码。下面的代码块虽然在HTML中有效,但在XHML中是无效的。

<script type="text/javascript">
  function compare(a, b) {
    if (a < b) {
      console.log("A is less than B");
    } else if (a > b) {
      console.log("A is greater than B");
    } else {
      console.log("A is equal to B");
    }
  }
</script>

在HTML中,解析<script> 元素会应用特殊规则。XHTML中则没有这些规则。这意味着a < b 语句中的小于号(< )会被解释成一个标签的开始,并且由于作为标签开始的小于号后面不能有空格,这会导致语法错误。

避免XHTML中这种语法错误的方法有两种。第一种是把所有小于号(< )都替换成对应的HTML实体形式(&lt; )。结果代码就是这样的:

<script type="text/javascript">
  function compare(a, b) {
    if (a &lt; b) {
      console.log("A is less than B");
    } else if (a > b) {
      console.log("A is greater than B");
    } else {
      console.log("A is equal to B");
    }
  }
</script>

这样代码就可以在XHTML页面中运行了。不过,缺点是会影响阅读。好在还有另一种方法。

第二种方法是把所有代码都包含到一个CDATA块中。在XHTML(及XML)中,CDATA块表示文档中可以包含任意文本的区块,其内容不作为标签来解析,因此可以在其中包含任意字符,包括小于号,并且不会引发语法错误。使用CDATA的格式如下:

<script type="text/javascript"><![CDATA[
  function compare(a, b) {
    if (a < b) {
      console.log("A is less than B");
    } else if (a > b) {
      console.log("A is greater than B");
    } else {
      console.log("A is equal to B");
    }
  }
]]></script>

在兼容XHTML的浏览器中,这样能解决问题。但在不支持CDATA块的非XHTML兼容浏览器中则不行。为此,CDATA标记必须使用JavaScript注释来抵消:

<script type="text/javascript">
//<![CDATA[
  function compare(a, b) {
    if (a < b) {
      console.log("A is less than B");
    } else if (a > b) {
      console.log("A is greater than B");
    } else {
      console.log("A is equal to B");
    }
  }
//]]>
</script>

这种格式适用于所有现代浏览器。虽然有点黑科技的味道,但它可以通过XHTML验证,而且对XHTML之前的浏览器也能优雅地降级。

注意  XHTML模式会在页面的MIME类型被指定为"application/xhtml+xml" 时触发。并不是所有浏览器都支持以这种方式送达的XHTML。

自1995年Netscape 2发布以来,所有浏览器都将JavaScript作为默认的编程语言。type 属性使用一个MIME类型字符串来标识<script> 的内容,但MIME类型并没有跨浏览器标准化。即使浏览器默认使用JavaScript,在某些情况下某个无效或无法识别的MIME类型也可能导致浏览器跳过(不执行)相关代码。因此,除非你使用XHTML或<script> 标签要求或包含非JavaScript代码,最佳做法是不指定type 属性。

在最初采用script 元素时,它标志着开始走向与传统HTML解析不同的流程。对这个元素需要应用特殊的解析规则,而这在不支持JavaScript的浏览器(特别是Mosaic)中会导致问题。不支持的浏览器会把<script> 元素的内容输出到页面上,从而破坏页面的外观。

Netscape联合Mosaic拿出了一个解决方案,对不支持JavaScript的浏览器隐藏嵌入的JavaScript代码。最终方案是把脚本代码包含在一个HTML注释中,像这样:

<script><!--
  function sayHi(){
    console.log("Hi!");
  }
//--></script>

使用这种格式,Mosaic等浏览器就可以忽略<script> 标签中的内容,而支持JavaScript的浏览器则必须识别这种模式,将其中的内容作为JavaScript来解析。

虽然这种格式仍然可以被所有浏览器识别和解析,但已经不再必要,而且不应该再使用了。在XHTML模式下,这种格式也会导致脚本被忽略,因为代码处于有效的XML注释当中。

虽然可以直接在HTML文件中嵌入JavaScript代码,但通常认为最佳实践是尽可能将JavaScript代码放在外部文件中。不过这个最佳实践并不是明确的强制性规则。推荐使用外部文件的理由如下。

在配置浏览器请求外部文件时,要重点考虑的一点是它们会占用多少带宽。在SPDY/HTTP2中,预请求的消耗已显著降低,以轻量、独立JavaScript组件形式向客户端送达脚本更具优势。

比如,第一个页面包含如下脚本:

<script src="mainA.js"></script>
<script src="component1.js"></script>
<script src="component2.js"></script>
<script src="component3.js"></script>
...

后续页面可能包含如下脚本:

<script src="mainB.js"></script>
<script src="component3.js"></script>
<script src="component4.js"></script>
<script src="component5.js"></script>
...

在初次请求时,如果浏览器支持SPDY/HTTP2,就可以从同一个地方取得一批文件,并将它们逐个放到浏览器缓存中。从浏览器角度看,通过SPDY/HTTP2获取所有这些独立的资源与获取一个大JavaScript文件的延迟差不多。

在第二个页面请求时,由于你已经把应用程序切割成了轻量可缓存的文件,第二个页面也依赖的某些组件此时已经存在于浏览器缓存中了。

当然,这里假设浏览器支持SPDY/HTTP2,只有比较新的浏览器才满足。如果你还想支持那些比较老的浏览器,可能还是用一个大文件更合适。

IE5.5发明了文档模式的概念,即可以使用doctype 切换文档模式。最初的文档模式有两种:混杂模式 (quirks mode)和标准模式 (standards mode)。前者让IE像IE5一样(支持一些非标准的特性),后者让IE具有兼容标准的行为。虽然这两种模式的主要区别只体现在通过CSS渲染的内容方面,但对JavaScript也有一些关联影响,或称为副作用。本书会经常提到这些副作用。

IE初次支持文档模式切换以后,其他浏览器也跟着实现了。随着浏览器的普遍实现,又出现了第三种文档模式:准标准模式 (almost standards mode)。这种模式下的浏览器支持很多标准的特性,但是没有标准规定得那么严格。主要区别在于如何对待图片元素周围的空白(在表格中使用图片时最明显)。

混杂模式在所有浏览器中都以省略文档开头的doctype 声明作为开关。这种约定并不合理,因为混杂模式在不同浏览器中的差异非常大,不使用黑科技基本上就没有浏览器一致性可言。

标准模式通过下列几种文档类型声明开启:

<!-- HTML 4.01 Strict -->
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
"http://www.w3.org/TR/html4/strict.dtd">

<!-- XHTML 1.0 Strict -->
<!DOCTYPE html PUBLIC
"-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- HTML5 -->
<!DOCTYPE html>

准标准模式通过过渡性文档类型(Transitional )和框架集文档类型(Frameset )来触发:

<!-- HTML 4.01 Transitional -->
<!DOCTYPE HTML PUBLIC
"-//W3C//DTD HTML 4.01 Transitional//EN"
"http://www.w3.org/TR/html4/loose.dtd">

<!-- HTML 4.01 Frameset -->
<!DOCTYPE HTML PUBLIC
"-//W3C//DTD HTML 4.01 Frameset//EN"
"http://www.w3.org/TR/html4/frameset.dtd">

<!-- XHTML 1.0 Transitional -->
<!DOCTYPE html PUBLIC
"-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<!-- XHTML 1.0 Frameset -->
<!DOCTYPE html PUBLIC
"-//W3C//DTD XHTML 1.0 Frameset//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">

准标准模式与标准模式非常接近,很少需要区分。人们在说到“标准模式”时,可能指其中任何一个。而对文档模式的检测(本书后面会讨论)也不会区分它们。本书后面所说的标准模式 ,指的就是除混杂模式以外的模式。

针对早期浏览器不支持JavaScript的问题,需要一个页面优雅降级的处理方案。最终,<noscript> 元素出现,被用于给不支持JavaScript的浏览器提供替代内容。虽然如今的浏览器已经100%支持JavaScript,但对于禁用JavaScript的浏览器来说,这个元素仍然有它的用处。

<noscript> 元素可以包含任何可以出现在<body> 中的HTML元素,<script> 除外。在下列两种情况下,浏览器将显示包含在<noscript> 中的内容:

任何一个条件被满足,包含在<noscript> 中的内容就会被渲染。否则,浏览器不会渲染<noscript> 中的内容。

下面是一个例子:

<!DOCTYPE html>
<html>
  <head>
  <title>Example HTML Page</title>
  <script defer="defer" src="example1.js"></script>
  <script defer="defer" src="example2.js"></script>
  </head>
  <body>
  <noscript>
    <p>This page requires a JavaScript-enabled browser.</p>
  </noscript>
  </body>
</html>

这个例子是在脚本不可用时让浏览器显示一段话。如果浏览器支持脚本,则用户永远不会看到它。

JavaScript是通过<script> 元素插入到HTML页面中的。这个元素可用于把JavaScript代码嵌入到HTML页面中,跟其他标记混合在一起,也可用于引入保存在外部文件中的JavaScript。本章的重点可以总结如下。


第 3 章 语言基础

本章内容

任何语言的核心所描述的都是这门语言在最基本的层面上如何工作,涉及语法、操作符、数据类型以及内置功能,在此基础之上才可以构建复杂的解决方案。如前所述,ECMA-262以一个名为ECMAScript的伪语言的形式,定义了JavaScript的所有这些方面。

ECMA-262第5版(ES5)定义的ECMAScript,是目前为止实现得最为广泛(即受浏览器支持最好)的一个版本。第6版(ES6)在浏览器中的实现(即受支持)程度次之。到2017年底,大多数主流浏览器几乎或全部实现了这一版的规范。为此,本章接下来的内容主要基于ECMAScript第6版。

ECMAScript的语法很大程度上借鉴了C语言和其他类C语言,如Java和Perl。熟悉这些语言的开发者,应该很容易理解ECMAScript宽松的语法。

首先要知道的是,ECMAScript中一切都区分大小写。无论是变量、函数名还是操作符,都区分大小写。换句话说,变量test 和变量Test 是两个不同的变量。类似地,typeof 不能作为函数名,因为它是一个关键字(后面会介绍)。但Typeof 是一个完全有效的函数名。

所谓标识符 ,就是变量、函数、属性或函数参数的名称。标识符可以由一或多个下列字符组成:

标识符中的字母可以是扩展ASCII(Extended ASCII)中的字母,也可以是Unicode的字母字符,如À和Æ(但不推荐使用)。

按照惯例,ECMAScript标识符使用驼峰大小写形式,即第一个单词的首字母小写,后面每个单词的首字母大写,如:

firstSecond
myCar
doSomethingImportant

虽然这种写法并不是强制性的,但因为这种形式跟ECMAScript内置函数和对象的命名方式一致,所以算是最佳实践。

注意  关键字、保留字、truefalsenull 不能作为标识符。具体内容请参考3.2节。

ECMAScript采用C语言风格的注释,包括单行注释和块注释。单行注释以两个斜杠字符开头,如:

// 单行注释

块注释以一个斜杠和一个星号(/* )开头,以它们的反向组合(*/ )结尾,如:

/* 这是多行
注释 */

ECMAScript 5增加了严格模式(strict mode)的概念。严格模式是一种不同的JavaScript解析和执行模型,ECMAScript 3的一些不规范写法在这种模式下会被处理,对于不安全的活动将抛出错误。要对整个脚本启用严格模式,在脚本开头加上这一行:

"use strict";

虽然看起来像个没有赋值给任何变量的字符串,但它其实是一个预处理指令。任何支持的JavaScript引擎看到它都会切换到严格模式。选择这种语法形式的目的是不破坏ECMAScript 3语法。

也可以单独指定一个函数在严格模式下执行,只要把这个预处理指令放到函数体开头即可:

function doSomething() {
  "use strict";
  // 函数体
}

严格模式会影响JavaScript执行的很多方面,因此本书在用到它时会明确指出来。所有现代浏览器都支持严格模式。

ECMAScript中的语句以分号结尾。省略分号意味着由解析器确定语句在哪里结尾,如下面的例子所示:

let sum = a + b      // 没有分号也有效,但不推荐
let diff = a - b;    // 加分号有效,推荐

即使语句末尾的分号不是必需的,也应该加上。记着加分号有助于防止省略造成的问题,比如可以避免输入内容不完整。此外,加分号也便于开发者通过删除空行来压缩代码(如果没有结尾的分号,只删除空行,则会导致语法错误)。加分号也有助于在某些情况下提升性能,因为解析器会尝试在合适的位置补上分号以纠正语法错误。

多条语句可以合并到一个C语言风格的代码块中。代码块由一个左花括号({ )标识开始,一个右花括号(} )标识结束:

if (test) {
  test = false;
  console.log(test);
}

if之类的控制语句只在执行多条语句时要求必须有代码块。不过,最佳实践是始终在控制语句中使用代码块,即使要执行的只有一条语句,如下例所示:

// 有效,但容易导致错误,应该避免
if (test)
  console.log(test);

// 推荐
if (test) {  
  console.log(test);
}

在控制语句中使用代码块可以让内容更清晰,在需要修改代码时也可以减少出错的可能性。

ECMA-262描述了一组保留的关键字 ,这些关键字有特殊用途,比如表示控制语句的开始和结束,或者执行特定的操作。按照规定,保留的关键字不能用作标识符或属性名。ECMA-262第6版规定的所有关键字如下:

break       do          in            typeof
case        else        instanceof    var
catch       export      new           void
class       extends     return        while
const       finally     super         with
continue    for         switch        yield
debugger    function    this
default     if          throw
delete      import      try

规范中也描述了一组未来的保留字 ,同样不能用作标识符或属性名。虽然保留字在语言中没有特定用途,但它们是保留给将来做关键字用的。

以下是ECMA-262第6版为将来保留的所有词汇。

始终保留:

enum


严格模式下保留:

implements  package     public
interface   protected   static
let         private


模块代码中保留:

await

这些词汇不能用作标识符,但现在还可以用作对象的属性名。一般来说,最好还是不要使用关键字和保留字作为标识符和属性名,以确保兼容过去和未来的ECMAScript版本。

ECMAScript变量是松散类型的,意思是变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符。有3个关键字可以声明变量:varconstlet 。其中,var 在ECMAScript的所有版本中都可以使用,而constlet 只能在ECMAScript 6及更晚的版本中使用。

要定义变量,可以使用var 操作符(注意var 是一个关键字),后跟变量名(即标识符,如前所述):

var message;

这行代码定义了一个名为message 的变量,可以用它保存任何类型的值。(不初始化的情况下,变量会保存一个特殊值undefined ,下一节讨论数据类型时会谈到。)ECMAScript实现变量初始化,因此可以同时定义变量并设置它的值:

var message = "hi";

这里,message 被定义为一个保存字符串值hi 的变量。像这样初始化变量不会将它标识为字符串类型,只是一个简单的赋值而已。随后,不仅可以改变保存的值,也可以改变值的类型:

var message = "hi";
message = 100;  // 合法,但不推荐

在这个例子中,变量message 首先被定义为一个保存字符串值hi 的变量,然后又被重写为保存了数值100。虽然不推荐改变变量保存值的类型,但这在ECMAScript中是完全有效的。

  1. var 声明作用域

    关键的问题在于,使用var 操作符定义的变量会成为包含它的函数的局部变量。比如,使用var 在一个函数内部定义一个变量,就意味着该变量将在函数退出时被销毁:

    function test() {
      var message = "hi"; // 局部变量
    }
    test();
    console.log(message); // 出错!
    
    

    这里,message 变量是在函数内部使用var 定义的。函数叫test() ,调用它会创建这个变量并给它赋值。调用之后变量随即被销毁,因此示例中的最后一行会导致错误。不过,在函数内定义变量时省略var 操作符,可以创建一个全局变量:

    function test() {
      message = "hi";     // 全局变量
    }
    test();
    console.log(message); // "hi"
    
    

    去掉之前的var 操作符之后,message 就变成了全局变量。只要调用一次函数test() ,就会定义这个变量,并且可以在函数外部访问到。

    注意  虽然可以通过省略var 操作符定义全局变量,但不推荐这么做。在局部作用域中定义的全局变量很难维护,也会造成困惑。这是因为不能一下子断定省略var 是不是有意而为之。在严格模式下,如果像这样给未声明的变量赋值,则会导致抛出ReferenceError

    如果需要定义多个变量,可以在一条语句中用逗号分隔每个变量(及可选的初始化):

    var message = "hi",
        found = false,
        age = 29;
    
    

    这里定义并初始化了3个变量。因为ECMAScript是松散类型的,所以使用不同数据类型初始化的变量可以用一条语句来声明。插入换行和空格缩进并不是必需的,但这样有利于阅读理解。

    在严格模式下,不能定义名为evalarguments 的变量,否则会导致语法错误。
     

  2. var 声明提升

    使用var 时,下面的代码不会报错。这是因为使用这个关键字声明的变量会自动提升到函数作用域顶部:

    function foo() {
      console.log(age);
      var age = 26;
    }
    foo();  // undefined
    
    

    之所以不会报错,是因为ECMAScript运行时把它看成等价于如下代码:

    function foo() {
      var age;
      console.log(age);
      age = 26;
    }
    foo();  // undefined
    
    

    这就是所谓的“提升”(hoist),也就是把所有变量声明都拉到函数作用域的顶部。此外,反复多次使用var 声明同一个变量也没有问题:

    function foo() {
      var age = 16;
      var age = 26;
      var age = 36;
      console.log(age);
    }
    foo();  // 36
    
    

letvar 的作用差不多,但有着非常重要的区别。最明显的区别是,let 声明的范围是块作用域,而var 声明的范围是函数作用域。

if (true) {
  var name = 'Matt';
  console.log(name); // Matt
}
console.log(name);   // Matt

if (true) {
  let age = 26;
  console.log(age);   // 26
}
console.log(age);     // ReferenceError: age没有定义

在这里,age 变量之所以不能在if 块外部被引用,是因为它的作用域仅限于该块内部。块作用域是函数作用域的子集,因此适用于var 的作用域限制同样也适用于let

let 也不允许同一个块作用域中出现冗余声明。这样会导致报错:

var name;
var name;

let age;
let age;  // SyntaxError;标识符age已经声明过了

当然,JavaScript引擎会记录用于变量声明的标识符及其所在的块作用域,因此嵌套使用相同的标识符不会报错,而这是因为同一个块中没有重复声明:

var name = 'Nicholas';
console.log(name);    // 'Nicholas'
if (true) {
  var name = 'Matt';
  console.log(name);  // 'Matt'
}

let age = 30;
console.log(age);    // 30
if (true) {
  let age = 26;
  console.log(age);  // 26
}

对声明冗余报错不会因混用letvar 而受影响。这两个关键字声明的并不是不同类型的变量,它们只是指出变量在相关作用域如何存在。

var name;
let name; // SyntaxError

let age;
var age; // SyntaxError

  1. 暂时性死区

    letvar 的另一个重要的区别,就是let 声明的变量不会在作用域中被提升。

    // name会被提升
    console.log(name); // undefined
    var name = 'Matt';
    
    // age不会被提升
    console.log(age); // ReferenceError:age没有定义
    let age = 26;
    
    

    在解析代码时,JavaScript引擎也会注意出现在块后面的let 声明,只不过在此之前不能以任何方式来引用未声明的变量。在let 声明之前的执行瞬间被称为“暂时性死区”(temporal dead zone),在此阶段引用任何后面才声明的变量都会抛出ReferenceError
     

  2. 全局声明

    var 关键字不同,使用let 在全局作用域中声明的变量不会成为window 对象的属性(var 声明的变量则会)。

    var name = 'Matt';
    console.log(window.name); // 'Matt'
    
    let age = 26;
    console.log(window.age);  // undefined
    
    

    不过,let 声明仍然是在全局作用域中发生的,相应变量会在页面的生命周期内存续。因此,为了避免SyntaxError ,必须确保页面不会重复声明同一个变量。
     

  3. 条件声明

    在使用var 声明变量时,由于声明会被提升,JavaScript引擎会自动将多余的声明在作用域顶部合并为一个声明。因为let 的作用域是块,所以不可能检查前面是否已经使用let 声明过同名变量,同时也就不可能在没有声明的情况下声明它。

    <script>
      var name = 'Nicholas';
      let age = 26;
    </script>
    
    <script>
      // 假设脚本不确定页面中是否已经声明了同名变量
      // 那它可以假设还没有声明过
    
      var name = 'Matt';
      // 这里没问题,因为可以被作为一个提升声明来处理
      // 不需要检查之前是否声明过同名变量
    
      let age = 36;
      // 如果age之前声明过,这里会报错
    </script>
    
    

    使用try /catch 语句或typeof 操作符也不能解决,因为条件块中let 声明的作用域仅限于该块。

    <script>
      let name = 'Nicholas';
      let age = 36;
    </script>
    
    <script>
      // 假设脚本不确定页面中是否已经声明了同名变量
      // 那它可以假设还没有声明过
    
      if (typeof name === 'undefined') {
        let name;
      }
      // name被限制在if {} 块的作用域内
      // 因此这个赋值形同全局赋值
      name = 'Matt';
    
      try (age) {
        // 如果age没有声明过,则会报错
      }
      catch(error) {
        let age;
      }
      // age被限制在catch {}块的作用域内
      // 因此这个赋值形同全局赋值
      age = 26;
    </script>
    
    

    为此,对于let 这个新的ES6声明关键字,不能依赖条件声明模式。

    注意  不能使用let 进行条件式声明是件好事,因为条件声明是一种反模式,它让程序变得更难理解。如果你发现自己在使用这个模式,那一定有更好的替代方式。

     

  4. for 循环中的let 声明

    let 出现之前,for 循环定义的迭代变量会渗透到循环体外部:

    for (var i = 0; i < 5; ++i) {
      // 循环逻辑
    }
    console.log(i); // 5
    
    

    改成使用let 之后,这个问题就消失了,因为迭代变量的作用域仅限于for 循环块内部:

    for (let i = 0; i < 5; ++i) {
      // 循环逻辑
    }
    console.log(i); // ReferenceError: i没有定义
    
    

    在使用var 的时候,最常见的问题就是对迭代变量的奇特声明和修改:

    for (var i = 0; i < 5; ++i) {
        setTimeout(() => console.log(i), 0)
    }
    // 你可能以为会输出0、1、2、3、4
    // 实际上会输出5、5、5、5、5
    
    

    之所以会这样,是因为在退出循环时,迭代变量保存的是导致循环退出的值:5。在之后执行超时逻辑时,所有的i 都是同一个变量,因而输出的都是同一个最终值。

    而在使用let 声明迭代变量时,JavaScript引擎在后台会为每个迭代循环声明一个新的迭代变量。每个setTimeout 引用的都是不同的变量实例,所以console.log 输出的是我们期望的值,也就是循环执行过程中每个迭代变量的值。

    for (let i = 0; i < 5; ++i) {
        setTimeout(() => console.log(i), 0)
    }
    // 会输出0、1、2、3、4
    
    

    这种每次迭代声明一个独立变量实例的行为适用于所有风格的for 循环,包括for-infor-of 循环。

const 的行为与let 基本相同,唯一一个重要的区别是用它声明变量时必须同时初始化变量,且尝试修改const 声明的变量会导致运行时错误。

const age = 26;
age = 36; // TypeError: 给常量赋值
// const也不允许重复声明
const name = 'Matt';
const name = 'Nicholas'; // SyntaxError

// const声明的作用域也是块
const name = 'Matt';
if (true) {
  const name = 'Nicholas';
}
console.log(name); // Matt

const 声明的限制只适用于它指向的变量的引用。换句话说,如果const 变量引用的是一个对象,那么修改这个对象内部的属性并不违反const 的限制。

const person = {};
person.name = 'Matt';  // ok

JavaScript引擎会为for 循环中的let 声明分别创建独立的变量实例,虽然const 变量跟let 变量很相似,但是不能用const 来声明迭代变量(因为迭代变量会自增):

for (const i = 0; i < 10; ++i) {} // TypeError:给常量赋值

不过,如果你只想用const 声明一个不会被修改的for 循环变量,那也是可以的。也就是说,每次迭代只是创建一个新变量。这对for-offor-in 循环特别有意义:

let i = 0;
for (const j = 7; i < 5; ++i) {
  console.log(j);
}
// 7, 7, 7, 7, 7

for (const key in {a: 1, b: 2}) {
  console.log(key);
}
// a, b

for (const value of [1,2,3,4,5]) {
  console.log(value);
}
// 1, 2, 3, 4, 5

ECMAScript 6增加letconst 从客观上为这门语言更精确地声明作用域和语义提供了更好的支持。行为怪异的var 所造成的各种问题,已经让JavaScript社区为之苦恼了很多年。随着这两个新关键字的出现,新的有助于提升代码质量的最佳实践也逐渐显现。

  1. 不使用var

    有了letconst ,大多数开发者会发现自己不再需要var 了。限制自己只使用letconst 有助于提升代码质量,因为变量有了明确的作用域、声明位置,以及不变的值。
     

  2. const 优先,let 次之

    使用const 声明可以让浏览器运行时强制保持变量不变,也可以让静态代码分析工具提前发现不合法的赋值操作。因此,很多开发者认为应该优先使用const 来声明变量,只在提前知道未来会有修改时,再使用let 。这样可以让开发者更有信心地推断某些变量的值永远不会变,同时也能迅速发现因意外赋值导致的非预期行为。

ECMAScript有6种简单数据类型(也称为原始类型 ):UndefinedNullBooleanNumberStringSymbolSymbol (符号)是ECMAScript 6新增的。还有一种复杂数据类型叫Object (对象)。Object 是一种无序名值对的集合。因为在ECMAScript中不能定义自己的数据类型,所有值都可以用上述7种数据类型之一来表示。只有7种数据类型似乎不足以表示全部数据。但ECMAScript的数据类型很灵活,一种数据类型可以当作多种数据类型来使用。

因为ECMAScript的类型系统是松散的,所以需要一种手段来确定任意变量的数据类型。typeof 操作符就是为此而生的。对一个值使用typeof 操作符会返回下列字符串之一:

下面是使用typeof 操作符的例子:

let message = "some string";
console.log(typeof message);    // "string"
console.log(typeof(message));   // "string"
console.log(typeof 95);         // "number"

在这个例子中,我们把一个变量(message )和一个数值字面量传给了typeof 操作符。注意,因为typeof 是一个操作符而不是函数,所以不需要参数(但可以使用参数)。

注意typeof 在某些情况下返回的结果可能会让人费解,但技术上讲还是正确的。比如,调用typeof null 返回的是"object" 。这是因为特殊值null 被认为是一个对空对象的引用。

注意  严格来讲,函数在ECMAScript中被认为是对象,并不代表一种数据类型。可是,函数也有自己特殊的属性。为此,就有必要通过typeof 操作符来区分函数和其他对象。

Undefined 类型只有一个值,就是特殊值undefined 。当使用varlet 声明了变量但没有初始化时,就相当于给变量赋予了undefined 值:

let message;
console.log(message == undefined); // true

在这个例子中,变量message 在声明的时候并未初始化。而在比较它和undefined 的字面值时,两者是相等的。这个例子等同于如下示例:

let message = undefined;
console.log(message == undefined); // true

这里,变量message 显式地以undefined 来初始化。但这是不必要的,因为默认情况下,任何未经初始化的变量都会取得undefined 值。

注意  一般来说,永远不用显式地给某个变量设置undefined 值。字面值undefined 主要用于比较,而且在ECMA-262第3版之前是不存在的。增加这个特殊值的目的就是为了正式明确空对象指针(null )和未初始化变量的区别。

注意,包含undefined 值的变量跟未定义变量是有区别的。请看下面的例子:

let message;    // 这个变量被声明了,只是值为undefined

// 确保没有声明过这个变量
// let age

console.log(message); // "undefined"
console.log(age);     // 报错

在上面的例子中,第一个console.log 会指出变量message 的值,即"undefined" 。而第二个console.log 要输出一个未声明的变量age 的值,因此会导致报错。对未声明的变量,只能执行一个有用的操作,就是对它调用typeof 。(对未声明的变量调用delete 也不会报错,但这个操作没什么用,实际上在严格模式下会抛出错误。)

在对未初始化的变量调用typeof 时,返回的结果是"undefined" ,但对未声明的变量调用它时,返回的结果还是"undefined" ,这就有点让人看不懂了。比如下面的例子:

let message; // 这个变量被声明了,只是值为undefined

// 确保没有声明过这个变量
// let age

console.log(typeof message); // "undefined"
console.log(typeof age);     // "undefined"

无论是声明还是未声明,typeof 返回的都是字符串"undefined" 。逻辑上讲这是对的,因为虽然严格来讲这两个变量存在根本性差异,但它们都无法执行实际操作。

注意  即使未初始化的变量会被自动赋予undefined 值,但我们仍然建议在声明变量的同时进行初始化。这样,当typeof 返回"undefined" 时,你就会知道那是因为给定的变量尚未声明,而不是声明了但未初始化。

undefined 是一个假值。因此,如果需要,可以用更简洁的方式检测它。不过要记住,也有很多其他可能的值同样是假值。所以一定要明确自己想检测的就是undefined 这个字面值,而不仅仅是假值。


let message; // 这个变量被声明了,只是值为undefined
// age没有声明

if (message) {
  // 这个块不会执行
}

if (!message) {
  // 这个块会执行
}

if (age) {
  // 这里会报错
}

Null 类型同样只有一个值,即特殊值null 。逻辑上讲,null 值表示一个空对象指针,这也是给typeof 传一个null 会返回"object" 的原因:

let car = null;
console.log(typeof car);  // "object"

在定义将来要保存对象值的变量时,建议使用null 来初始化,不要使用其他值。这样,只要检查这个变量的值是不是null 就可以知道这个变量是否在后来被重新赋予了一个对象的引用,比如:

if (car != null) {
  // car是一个对象的引用
}

undefined 值是由null 值派生而来的,因此ECMA-262将它们定义为表面上相等,如下面的例子所示:

console.log(null == undefined);  // true

用等于操作符(== )比较nullundefined 始终返回true 。但要注意,这个操作符会为了比较而转换它的操作数(本章后面将详细介绍)。

即使nullundefined 有关系,它们的用途也是完全不一样的。如前所述,永远不必显式地将变量值设置为undefined 。但null 不是这样的。任何时候,只要变量要保存对象,而当时又没有那个对象可保存,就要用null 来填充该变量。这样就可以保持null 是空对象指针的语义,并进一步将其与undefined 区分开来。

null 是一个假值。因此,如果需要,可以用更简洁的方式检测它。不过要记住,也有很多其他可能的值同样是假值。所以一定要明确自己想检测的就是null 这个字面值,而不仅仅是假值。

let message = null;
let age;

if (message) {
  // 这个块不会执行
}

if (!message) {
  // 这个块会执行
}

if (age) {
  // 这个块不会执行
}

if (!age) {
  // 这个块会执行
}

Boolean (布尔值)类型是ECMAScript中使用最频繁的类型之一,有两个字面值:truefalse 。这两个布尔值不同于数值,因此true 不等于1,false 不等于0。下面是给变量赋布尔值的例子:

let found = true;
let lost = false;

注意,布尔值字面量truefalse 是区分大小写的,因此TrueFalse (及其他大小混写形式)是有效的标识符,但不是布尔值。

虽然布尔值只有两个,但所有其他ECMAScript类型的值都有相应布尔值的等价形式。要将一个其他类型的值转换为布尔值,可以调用特定的Boolean() 转型函数:

let message = "Hello world!";
let messageAsBoolean = Boolean(message);

在这个例子中,字符串message 会被转换为布尔值并保存在变量messageAsBoolean 中。Boolean() 转型函数可以在任意类型的数据上调用,而且始终返回一个布尔值。什么值能转换为truefalse 的规则取决于数据类型和实际的值。下表总结了不同类型与布尔值之间的转换规则。

数据类型

转换为true 的值

转换为false 的值

Boolean

true

false

String

非空字符串

"" (空字符串)

Number

非零数值(包括无穷值)

0NaN (参见后面的相关内容)

Object

任意对象

null

Undefined

N/A (不存在)

undefined

理解以上转换非常重要,因为像if 等流控制语句会自动执行其他类型值到布尔值的转换,例如:

let message = "Hello world!";
if (message) {
  console.log("Value is true");
}

在这个例子中,console.log 会输出字符串"Value is true" ,因为字符串message 会被自动转换为等价的布尔值true 。由于存在这种自动转换,理解流控制语句中使用的是什么变量就非常重要。错误地使用对象而不是布尔值会明显改变应用程序的执行流。

ECMAScript中最有意思的数据类型或许就是Number 了。Number 类型使用IEEE 754格式表示整数和浮点值(在某些语言中也叫双精度值)。不同的数值类型相应地也有不同的数值字面量格式。

最基本的数值字面量格式是十进制整数,直接写出来即可:

let intNum = 55;  // 整数

整数也可以用八进制(以8为基数)或十六进制(以16为基数)字面量表示。对于八进制字面量,第一个数字必须是零(0),然后是相应的八进制数字(数值0~7)。如果字面量中包含的数字超出了应有的范围,就会忽略前缀的零,后面的数字序列会被当成十进制数,如下所示:

let octalNum1 = 070;  // 八进制的56
let octalNum2 = 079;  // 无效的八进制值,当成79处理
let octalNum3 = 08;   // 无效的八进制值,当成8处理

八进制字面量在严格模式下是无效的,会导致JavaScript引擎抛出语法错误。1

1 ECMAScript 2015或ES6中的八进制值通过前缀0o 来表示;严格模式下,前缀0 会被视为语法错误,如果要表示八进制值,应该使用前缀0o 。——译者注

要创建十六进制字面量,必须让真正的数值前缀0x (区分大小写),然后是十六进制数字(0~9以及A~F)。十六进制数字中的字母大小写均可。下面是几个例子:

let hexNum1 = 0xA;   // 十六进制10
let hexNum2 = 0x1f;  // 十六进制31

使用八进制和十六进制格式创建的数值在所有数学操作中都被视为十进制数值。

注意  由于JavaScript保存数值的方式,实际中可能存在正零(+0)和负零(-0)。正零和负零在所有情况下都被认为是等同的,这里特地说明一下。

  1. 浮点值

    要定义浮点值,数值中必须包含小数点,而且小数点后面必须至少有一个数字。虽然小数点前面不是必须有整数,但推荐加上。下面是几个例子:

    let floatNum1 = 1.1;
    let floatNum2 = 0.1;
    let floatNum3 = .1;   // 有效,但不推荐
    
    

    因为存储浮点值使用的内存空间是存储整数值的两倍,所以ECMAScript总是想方设法把值转换为整数。在小数点后面没有数字的情况下,数值就会变成整数。类似地,如果数值本身就是整数,只是小数点后面跟着0(如1.0),那它也会被转换为整数,如下例所示:

    let floatNum1 = 1.;   // 小数点后面没有数字,当成整数1处理
    let floatNum2 = 10.0; // 小数点后面是零,当成整数10处理
    
    

    对于非常大或非常小的数值,浮点值可以用科学记数法来表示。科学记数法用于表示一个应该乘以10的给定次幂的数值。ECMAScript中科学记数法的格式要求是一个数值(整数或浮点数)后跟一个大写或小写的字母e,再加上一个要乘的10的多少次幂。比如:

    let floatNum = 3.125e7; // 等于31250000
    
    

    在这个例子中,floatNum 等于31 250 000,只不过科学记数法显得更简洁。这种表示法实际上相当于说:“以3.125作为系数,乘以10的7次幂。”

    科学记数法也可以用于表示非常小的数值,例如0.000 000 000 000 000 03。这个数值用科学记数法可以表示为3e-17。默认情况下,ECMAScript会将小数点后至少包含6个零的浮点值转换为科学记数法(例如,0.000 000 3会被转换为3e-7)。

    浮点值的精确度最高可达17位小数,但在算术计算中远不如整数精确。例如,0.1加0.2得到的不是0.3,而是0.300 000 000 000 000 04。由于这种微小的舍入错误,导致很难测试特定的浮点值。比如下面的例子:

    if (a + b == 0.3) {      // 别这么干!
      console.log("You got 0.3.");
    }
    
    

    这里检测两个数值之和是否等于0.3。如果两个数值分别是0.05和0.25,或者0.15和0.15,那没问题。但如果是0.1和0.2,如前所述,测试将失败。因此永远不要测试某个特定的浮点值。

    注意  之所以存在这种舍入错误,是因为使用了IEEE 754数值,这种错误并非ECMAScript所独有。其他使用相同格式的语言也有这个问题。

     

  2. 值的范围

    由于内存的限制,ECMAScript并不支持表示这个世界上的所有数值。ECMAScript可以表示的最小数值保存在Number.MIN_VALUE 中,这个值在多数浏览器中是5e-324;可以表示的最大数值保存在Number.MAX_VALUE 中,这个值在多数浏览器中是1.797 693 134 862 315 7e+308。如果某个计算得到的数值结果超出了JavaScript可以表示的范围,那么这个数值会被自动转换为一个特殊的Infinity (无穷)值。任何无法表示的负数以-Infinity (负无穷大)表示,任何无法表示的正数以Infinity (正无穷大)表示。

    如果计算返回正Infinity 或负Infinity ,则该值将不能再进一步用于任何计算。这是因为Infinity 没有可用于计算的数值表示形式。要确定一个值是不是有限大(即介于JavaScript能表示的最小值和最大值之间),可以使用isFinite() 函数,如下所示:

    let result = Number.MAX_VALUE + Number.MAX_VALUE;
    console.log(isFinite(result));  // false
    
    

    虽然超出有限数值范围的计算并不多见,但总归还是有可能的。因此在计算非常大或非常小的数值时,有必要监测一下计算结果是否超出范围。

    注意  使用Number.NEGATIVE_INFINITYNumber.POSITIVE_INFINITY 也可以获取正、负Infinity 。没错,这两个属性包含的值分别就是-InfinityInfinity

     

  3. NaN

    有一个特殊的数值叫NaN ,意思是“不是数值”(Not a Number),用于表示本来要返回数值的操作失败了(而不是抛出错误)。比如,用0除任意数值在其他语言中通常都会导致错误,从而中止代码执行。但在ECMAScript中,0、+0或-0相除会返回NaN

    console.log(0/0);    // NaN
    console.log(-0/+0);  // NaN
    
    

    如果分子是非0值,分母是有符号0或无符号0,则会返回Infinity-Infinity

    console.log(5/0);   // Infinity
    console.log(5/-0);  // -Infinity
    
    

    NaN 有几个独特的属性。首先,任何涉及NaN 的操作始终返回NaN (如NaN/10 ),在连续多步计算时这可能是个问题。其次,NaN 不等于包括NaN 在内的任何值。例如,下面的比较操作会返回false

    console.log(NaN == NaN); // false
    
    

    为此,ECMAScript提供了isNaN() 函数。该函数接收一个参数,可以是任意数据类型,然后判断这个参数是否“不是数值”。把一个值传给isNaN() 后,该函数会尝试把它转换为数值。某些非数值的值可以直接转换成数值,如字符串"10" 或布尔值。任何不能转换为数值的值都会导致这个函数返回true 。举例如下:

    console.log(isNaN(NaN));     // true
    console.log(isNaN(10));      // false,10是数值
    console.log(isNaN("10"));    // false,可以转换为数值10
    console.log(isNaN("blue"));  // true,不可以转换为数值
    console.log(isNaN(true));    // false,可以转换为数值1
    
    

    上述的例子测试了5个不同的值。首先测试的是NaN 本身,显然会返回true 。接着测试了数值10和字符串"10" ,都返回false ,因为它们的数值都是10。字符串"blue" 不能转换为数值,因此函数返回true 。布尔值true 可以转换为数值1,因此返回false

    注意  虽然不常见,但isNaN() 可以用于测试对象。此时,首先会调用对象的valueOf() 方法,然后再确定返回的值是否可以转换为数值。如果不能,再调用toString() 方法,并测试其返回值。这通常是ECMAScript内置函数和操作符的工作方式,本章后面会讨论。

     

  4. 数值转换

    有3个函数可以将非数值转换为数值:Number()parseInt()parseFloat()Number() 是转型函数,可用于任何数据类型。后两个函数主要用于将字符串转换为数值。对于同样的参数,这3个函数执行的操作也不同。

    Number() 函数基于如下规则执行转换。

    从不同数据类型到数值的转换有时候会比较复杂,看一看Number() 的转换规则就知道了。下面是几个具体的例子:

    let num1 = Number("Hello world!");  // NaN
    let num2 = Number("");              // 0
    let num3 = Number("000011");        // 11
    let num4 = Number(true);            // 1
    
    

    可以看到,字符串"Hello world" 转换之后是NaN ,因为它找不到对应的数值。空字符串转换后是0。字符串000011 转换后是11 ,因为前面的零被忽略了。最后,true 转换为1。

    注意  本章后面会讨论到的一元加操作符与Number() 函数遵循相同的转换规则。

    考虑到用Number() 函数转换字符串时相对复杂且有点反常规,通常在需要得到整数时可以优先使用parseInt() 函数。parseInt() 函数更专注于字符串是否包含数值模式。字符串最前面的空格会被忽略,从第一个非空格字符开始转换。如果第一个字符不是数值字符、加号或减号,parseInt() 立即返回NaN 。这意味着空字符串也会返回NaN (这一点跟Number() 不一样,它返回0)。如果第一个字符是数值字符、加号或减号,则继续依次检测每个字符,直到字符串末尾,或碰到非数值字符。比如,"1234blue" 会被转换为1234,因为"blue" 会被完全忽略。类似地,"22.5" 会被转换为22,因为小数点不是有效的整数字符。

    假设字符串中的第一个字符是数值字符,parseInt() 函数也能识别不同的整数格式(十进制、八进制、十六进制)。换句话说,如果字符串以"0x" 开头,就会被解释为十六进制整数。如果字符串以"0" 开头,且紧跟着数值字符,在非严格模式下会被某些实现解释为八进制整数。

    下面几个转换示例有助于理解上述规则:

    let num1 = parseInt("1234blue");  // 1234
    let num2 = parseInt("");          // NaN
    let num3 = parseInt("0xA");       // 10,解释为十六进制整数
    let num4 = parseInt(22.5);        // 22
    let num5 = parseInt("70");        // 70,解释为十进制值
    let num6 = parseInt("0xf");       // 15,解释为十六进制整数
    
    

    不同的数值格式很容易混淆,因此parseInt() 也接收第二个参数,用于指定底数(进制数)。如果知道要解析的值是十六进制,那么可以传入16作为第二个参数,以便正确解析:

    let num = parseInt("0xAF", 16); // 175
    
    

    事实上,如果提供了十六进制参数,那么字符串前面的"0x" 可以省掉:

    let num1 = parseInt("AF", 16);  // 175
    let num2 = parseInt("AF");      // NaN
    
    

    在这个例子中,第一个转换是正确的,而第二个转换失败了。区别在于第一次传入了进制数作为参数,告诉parseInt() 要解析的是一个十六进制字符串。而第二个转换检测到第一个字符就是非数值字符,随即自动停止并返回NaN

    通过第二个参数,可以极大扩展转换后获得的结果类型。比如:

    let num1 = parseInt("10", 2);   // 2,按二进制解析
    let num2 = parseInt("10", 8);   // 8,按八进制解析
    let num3 = parseInt("10", 10);  // 10,按十进制解析
    let num4 = parseInt("10", 16);  // 16,按十六进制解析
    
    

    因为不传底数参数相当于让parseInt() 自己决定如何解析,所以为避免解析出错,建议始终传给它第二个参数。

    注意  多数情况下解析的应该都是十进制数,此时第二个参数就要传入10。

    parseFloat() 函数的工作方式跟parseInt() 函数类似,都是从位置0开始检测每个字符。同样,它也是解析到字符串末尾或者解析到一个无效的浮点数值字符为止。这意味着第一次出现的小数点是有效的,但第二次出现的小数点就无效了,此时字符串的剩余字符都会被忽略。因此,"22.34.5" 将转换成22.34。

    parseFloat() 函数的另一个不同之处在于,它始终忽略字符串开头的零。这个函数能识别前面讨论的所有浮点格式,以及十进制格式(开头的零始终被忽略)。十六进制数值始终会返回0。因为parseFloat() 只解析十进制值,因此不能指定底数。最后,如果字符串表示整数(没有小数点或者小数点后面只有一个零),则parseFloat() 返回整数。下面是几个示例:

    let num1 = parseFloat("1234blue");  // 1234,按整数解析
    let num2 = parseFloat("0xA");       // 0
    let num3 = parseFloat("22.5");      // 22.5
    let num4 = parseFloat("22.34.5");   // 22.34
    let num5 = parseFloat("0908.5");    // 908.5
    let num6 = parseFloat("3.125e7");   // 31250000
    
    

String (字符串)数据类型表示零或多个16位Unicode字符序列。字符串可以使用双引号(")、单引号(')或反引号(`)标示,因此下面的代码都是合法的:

let firstName = "John";
let lastName = 'Jacob';
let lastName = `Jingleheimerschmidt`

跟某些语言中使用不同的引号会改变对字符串的解释方式不同,ECMAScript语法中表示字符串的引号没有区别。不过要注意的是,以某种引号作为字符串开头,必须仍然以该种引号作为字符串结尾。比如,下面的写法会导致语法错误:

let firstName = 'Nicholas"; // 语法错误:开头和结尾的引号必须是同一种

  1. 字符字面量

    字符串数据类型包含一些字符字面量,用于表示非打印字符或有其他用途的字符,如下表所示:

    字面量 含义
    \n 换行
    \t 制表
    \b 退格
    \r 回车
    \f 换页
    \\ 反斜杠(\
    \' 单引号(' ),在字符串以单引号标示时使用,例如'He said, \'hey.\''
    \" 双引号(" ),在字符串以双引号标示时使用,例如"He said, \"hey.\""
    \` 反引号(\` ),在字符串以反引号标示时使用,例如`He said, \`hey.\``
    \xnn 以十六进制编码 nn 表示的字符(其中 n 是十六进制数字0~F),例如\x41 等于"A"
    \unnnn 以十六进制编码 nnnn 表示的Unicode字符(其中 n 是十六进制数字0~F),例如\u03a3 等于希腊字符"Σ"

    这些字符字面量可以出现在字符串中的任意位置,且可以作为单个字符被解释:

    let text = "This is the letter sigma: \u03a3.";
    
    

    在这个例子中,即使包含6个字符长的转义序列,变量text 仍然是28个字符长。因为转义序列表示一个字符,所以只算一个字符。

    字符串的长度可以通过其length 属性获取:

    console.log(text.length); // 28
    
    

    这个属性返回字符串中16位字符的个数。

    注意  如果字符串中包含双字节字符,那么length 属性返回的值可能不是准确的字符数。第5章将具体讨论如何解决这个问题。

     

  2. 字符串的特点

    ECMAScript中的字符串是不可变的(immutable),意思是一旦创建,它们的值就不能变了。要修改某个变量中的字符串值,必须先销毁原始的字符串,然后将包含新值的另一个字符串保存到该变量,如下所示:

    let lang = "Java";
    lang = lang + "Script";
    
    

    这里,变量lang 一开始包含字符串"Java" 。紧接着,lang 被重新定义为包含"Java""Script" 的组合,也就是"JavaScript" 。整个过程首先会分配一个足够容纳10个字符的空间,然后填充上"Java""Script" 。最后销毁原始的字符串"Java" 和字符串"Script" ,因为这两个字符串都没有用了。所有处理都是在后台发生的,而这也是一些早期的浏览器(如Firefox 1.0之前的版本和IE6.0)在拼接字符串时非常慢的原因。这些浏览器在后来的版本中都有针对性地解决了这个问题。
     

  3. 转换为字符串

    有两种方式把一个值转换为字符串。首先是使用几乎所有值都有的toString() 方法。这个方法唯一的用途就是返回当前值的字符串等价物。比如:

    let age = 11;
    let ageAsString = age.toString();      // 字符串"11"
    let found = true;
    let foundAsString = found.toString();  // 字符串"true"
    
    

    toString() 方法可见于数值、布尔值、对象和字符串值。(没错,字符串值也有toString() 方法,该方法只是简单地返回自身的一个副本。)nullundefined 值没有toString() 方法。

    多数情况下,toString() 不接收任何参数。不过,在对数值调用这个方法时,toString() 可以接收一个底数参数,即以什么底数来输出数值的字符串表示。默认情况下,toString() 返回数值的十进制字符串表示。而通过传入参数,可以得到数值的二进制、八进制、十六进制,或者其他任何有效基数的字符串表示,比如:

    let num = 10;
    console.log(num.toString());     // "10"
    console.log(num.toString(2));    // "1010"
    console.log(num.toString(8));    // "12"
    console.log(num.toString(10));   // "10"
    console.log(num.toString(16));   // "a"
    
    

    这个例子展示了传入底数参数时,toString() 输出的字符串值也会随之改变。数值10可以输出为任意数值格式。注意,默认情况下(不传参数)的输出与传入参数10得到的结果相同。

    如果你不确定一个值是不是nullundefined ,可以使用String() 转型函数,它始终会返回表示相应类型值的字符串。String() 函数遵循如下规则。

    下面看几个例子:

    let value1 = 10;
    let value2 = true;
    let value3 = null;
    let value4;
    
    console.log(String(value1));  // "10"
    console.log(String(value2));  // "true"
    console.log(String(value3));  // "null"
    console.log(String(value4));  // "undefined"
    
    

    这里展示了将4个值转换为字符串的情况:一个数值、一个布尔值、一个null 和一个undefined 。数值和布尔值的转换结果与调用toString() 相同。因为nullundefined 没有toString() 方法,所以String() 方法就直接返回了这两个值的字面量文本。

    注意  用加号操作符给一个值加上一个空字符串"" 也可以将其转换为字符串(加号操作符本章后面会介绍)。

     

  4. 模板字面量

    ECMAScript 6新增了使用模板字面量定义字符串的能力。与使用单引号或双引号不同,模板字面量保留换行字符,可以跨行定义字符串:

    let myMultiLineString = 'first line\nsecond line';
    let myMultiLineTemplateLiteral = `first line
    second line`;
    
    console.log(myMultiLineString);
    // first line
    // second line"
    
    console.log(myMultiLineTemplateLiteral);
    // first line
    // second line
    
    console.log(myMultiLineString === myMultiLinetemplateLiteral); // true
    
    

    顾名思义,模板字面量在定义模板时特别有用,比如下面这个HTML模板:

    let pageHTML = `
    <div>
      <a href="#">
        <span>Jake</span>
      </a>
    </div>`;
    
    

    由于模板字面量会保持反引号内部的空格,因此在使用时要格外注意。格式正确的模板字符串可能会看起来缩进不当:

    // 这个模板字面量在换行符之后有25个空格符
    let myTemplateLiteral = `first line
                             second line`;
    console.log(myTemplateLiteral.length);  // 47
    
    // 这个模板字面量以一个换行符开头
    let secondTemplateLiteral = `
    first line
    second line`;
    console.log(secondTemplateLiteral[0] === '\n'); // true
    
    // 这个模板字面量没有意料之外的字符
    let thirdTemplateLiteral = `first line
    second line`;
    console.log(thirdTemplateLiteral);
    // first line
    // second line
    
    

     

  5. 字符串插值

    模板字面量最常用的一个特性是支持字符串插值,也就是可以在一个连续定义中插入一个或多个值。技术上讲,模板字面量不是字符串,而是一种特殊的JavaScript句法表达式,只不过求值后得到的是字符串。模板字面量在定义时立即求值并转换为字符串实例,任何插入的变量也会从它们最接近的作用域中取值。

    字符串插值通过在${} 中使用一个JavaScript表达式实现:

    let value = 5;
    let exponent = 'second';
    // 以前,字符串插值是这样实现的:
    let interpolatedString =
      value + ' to the ' + exponent + ' power is ' + (value * value);
    
    // 现在,可以用模板字面量这样实现:
    let interpolatedTemplateLiteral =
      `${ value } to the ${ exponent } power is ${ value * value }`;
    
    console.log(interpolatedString);           // 5 to the second power is 25
    console.log(interpolatedTemplateLiteral);  // 5 to the second power is 25
    
    

    所有插入的值都会使用toString() 强制转型为字符串,而且任何JavaScript表达式都可以用于插值。嵌套的模板字符串无须转义:

    console.log(`Hello, ${ `World` }!`);  // Hello, World!
    
    

    将表达式转换为字符串时会调用toString()

    let foo = { toString: () => 'World' };
    console.log(`Hello, ${ foo }!`);      // Hello, World!
    
    

    在插值表达式中可以调用函数和方法:

    function capitalize(word) {
      return `${ word[0].toUpperCase() }${ word.slice(1) }`;
    }
    console.log(`${ capitalize('hello') }, ${ capitalize('world') }!`); // Hello, World!
    
    

    此外,模板也可以插入自己之前的值:

    let value = '';
    function append() {
      value = `${value}abc`
      console.log(value);
    }
    append();  // abc
    append();  // abcabc
    append();  // abcabcabc
    
    

     

  6. 模板字面量标签函数

    模板字面量也支持定义标签函数 (tag function),而通过标签函数可以自定义插值行为。标签函数会接收被插值记号分隔后的模板和对每个表达式求值的结果。

    标签函数本身是一个常规函数,通过前缀到模板字面量来应用自定义行为,如下例所示。标签函数接收到的参数依次是原始字符串数组和对每个表达式求值的结果。这个函数的返回值是对模板字面量求值得到的字符串。

    最好通过一个例子来理解:

    let a = 6;
    let b = 9;
    
    function simpleTag(strings, aValExpression, bValExpression, sumExpression) {
      console.log(strings);
      console.log(aValExpression);
      console.log(bValExpression);
      console.log(sumExpression);
    
      return 'foobar';
    }
    
    let untaggedResult = `${ a } + ${ b } = ${ a + b }`;
    let taggedResult = simpleTag`${ a } + ${ b } = ${ a + b }`;
    // ["", " + ", " = ", ""]
    // 6
    // 9
    // 15
    
    console.log(untaggedResult);   // "6 + 9 = 15"
    console.log(taggedResult);     // "foobar"
    
    

    因为表达式参数的数量是可变的,所以通常应该使用剩余操作符(rest operator)将它们收集到一个数组中:

    let a = 6;
    let b = 9;
    
    function simpleTag(strings, ...expressions) {
      console.log(strings);
      for(const expression of expressions) {
        console.log(expression);
      }
    
      return 'foobar';
    }
    let taggedResult = simpleTag`${ a } + ${ b } = ${ a + b }`;
    // ["", " + ", " = ", ""]
    // 6
    // 9
    // 15
    
    console.log(taggedResult);  // "foobar"
    
    

    对于有n 个插值的模板字面量,传给标签函数的表达式参数的个数始终是n ,而传给标签函数的第一个参数所包含的字符串个数则始终是n+1 。因此,如果你想把这些字符串和对表达式求值的结果拼接起来作为默认返回的字符串,可以这样做:

    let a = 6;
    let b = 9;
    
    function zipTag(strings, ...expressions) {
      return strings[0] +
             expressions.map((e, i) => `${e}${strings[i + 1]}`)
                        .join('');
    }
    
    let untaggedResult =    `${ a } + ${ b } = ${ a + b }`;
    let taggedResult = zipTag`${ a } + ${ b } = ${ a + b }`;
    
    console.log(untaggedResult);  // "6 + 9 = 15"
    console.log(taggedResult);    // "6 + 9 = 15"
    
    

     

  7. 原始字符串

    使用模板字面量也可以直接获取原始的模板字面量内容(如换行符或Unicode字符),而不是被转换后的字符表示。为此,可以使用默认的String.raw 标签函数:

    // Unicode示例
    // \u00A9是版权符号
    console.log(`\u00A9`);            // ©
    console.log(String.raw`\u00A9`);  // \u00A9
    
    // 换行符示例
    console.log(`first line\nsecond line`);
    // first line
    // second line
    
    console.log(String.raw`first line\nsecond line`); // "first line\nsecond line"
    
    // 对实际的换行符来说是不行的
    // 它们不会被转换成转义序列的形式
    console.log(`first line
    second line`);
    // first line
    // second line
    
    console.log(String.raw`first line
    second line`);
    // first line
    // second line
    
    

    另外,也可以通过标签函数的第一个参数,即字符串数组的.raw 属性取得每个字符串的原始内容:

    function printRaw(strings) {
      console.log('Actual characters:');
      for (const string of strings) {
        console.log(string);
      }
    
      console.log('Escaped characters;');
      for (const rawString of strings.raw) {
        console.log(rawString);
      }
    }
    
    printRaw`\u00A9${ 'and' }\n`;
    // Actual characters:
    // ©
    //(换行符)
    // Escaped characters:
    // \u00A9
    // \n
    
    

Symbol (符号)是ECMAScript 6新增的数据类型。符号是原始值,且符号实例是唯一、不可变的。符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险。

尽管听起来跟私有属性有点类似,但符号并不是为了提供私有属性的行为才增加的(尤其是因为Object API提供了方法,可以更方便地发现符号属性)。相反,符号就是用来创建唯一记号,进而用作非字符串形式的对象属性。

  1. 符号的基本用法

    符号需要使用Symbol() 函数初始化。因为符号本身是原始类型,所以typeof 操作符对符号返回symbol

    let sym = Symbol();
    console.log(typeof sym); // symbol
    
    

    调用Symbol() 函数时,也可以传入一个字符串参数作为对符号的描述(description),将来可以通过这个字符串来调试代码。但是,这个字符串参数与符号定义或标识完全无关:

    let genericSymbol = Symbol();
    let otherGenericSymbol = Symbol();
    
    let fooSymbol = Symbol('foo');
    let otherFooSymbol = Symbol('foo');
    
    console.log(genericSymbol == otherGenericSymbol);  // false
    console.log(fooSymbol == otherFooSymbol);          // false
    
    

    符号没有字面量语法,这也是它们发挥作用的关键。按照规范,你只要创建Symbol() 实例并将其用作对象的新属性,就可以保证它不会覆盖已有的对象属性,无论是符号属性还是字符串属性。

    let genericSymbol = Symbol();
    console.log(genericSymbol);  // Symbol()
    
    let fooSymbol = Symbol('foo');
    console.log(fooSymbol);      // Symbol(foo);
    
    

    最重要的是,Symbol() 函数不能用作构造函数,与new 关键字一起使用。这样做是为了避免创建符号包装对象,像使用BooleanStringNumber 那样,它们都支持构造函数且可用于初始化包含原始值的包装对象:

    let myBoolean = new Boolean();
    console.log(typeof myBoolean); // "object"
    
    let myString = new String();
    console.log(typeof myString);  // "object"
    
    let myNumber = new Number();
    console.log(typeof myNumber);  // "object"
    
    let mySymbol = new Symbol(); // TypeError: Symbol is not a constructor
    
    

    如果你确实想使用符号包装对象,可以借用Object() 函数:

    let mySymbol = Symbol();
    let myWrappedSymbol = Object(mySymbol);
    console.log(typeof myWrappedSymbol);   // "object"
    
    

     

  2. 使用全局符号注册表

    如果运行时的不同部分需要共享和重用符号实例,那么可以用一个字符串作为键,在全局符号注册表中创建并重用符号。

    为此,需要使用Symbol.for() 方法:

    let fooGlobalSymbol = Symbol.for('foo');
    console.log(typeof fooGlobalSymbol); // symbol
    
    

    Symbol.for() 对每个字符串键都执行幂等操作。第一次使用某个字符串调用时,它会检查全局运行时注册表,发现不存在对应的符号,于是就会生成一个新符号实例并添加到注册表中。后续使用相同字符串的调用同样会检查注册表,发现存在与该字符串对应的符号,然后就会返回该符号实例。

    let fooGlobalSymbol = Symbol.for('foo');       // 创建新符号
    let otherFooGlobalSymbol = Symbol.for('foo');  // 重用已有符号
    
    console.log(fooGlobalSymbol === otherFooGlobalSymbol);  // true
    
    

    即使采用相同的符号描述,在全局注册表中定义的符号跟使用Symbol() 定义的符号也并不等同:

    let localSymbol = Symbol('foo');
    let globalSymbol = Symbol.for('foo');
    
    console.log(localSymbol === globalSymbol); // false
    
    

    全局注册表中的符号必须使用字符串键来创建,因此作为参数传给Symbol.for() 的任何值都会被转换为字符串。此外,注册表中使用的键同时也会被用作符号描述。

    let emptyGlobalSymbol = Symbol.for();
    console.log(emptyGlobalSymbol);    // Symbol(undefined)
    
    

    还可以使用Symbol.keyFor() 来查询全局注册表,这个方法接收符号,返回该全局符号对应的字符串键。如果查询的不是全局符号,则返回undefined

    // 创建全局符号
    let s = Symbol.for('foo');
    console.log(Symbol.keyFor(s));   // foo
    
    // 创建普通符号
    let s2 = Symbol('bar');
    console.log(Symbol.keyFor(s2));  // undefined
    
    

    如果传给Symbol.keyFor() 的不是符号,则该方法抛出TypeError

    Symbol.keyFor(123); // TypeError: 123 is not a symbol
    
    

     

  3. 使用符号作为属性

    凡是可以使用字符串或数值作为属性的地方,都可以使用符号。这就包括了对象字面量属性和Object.defineProperty() /Object.defineProperties() 定义的属性。对象字面量只能在计算属性语法中使用符号作为属性。

    let s1 = Symbol('foo'),
        s2 = Symbol('bar'),
        s3 = Symbol('baz'),
        s4 = Symbol('qux');
    
    let o = {
      [s1]: 'foo val'
    };
    // 这样也可以:o[s1] = 'foo val';
    
    console.log(o);
    // {Symbol(foo): foo val}
    
    Object.defineProperty(o, s2, {value: 'bar val'});
    
    console.log(o);
    // {Symbol(foo): foo val, Symbol(bar): bar val}
    
    Object.defineProperties(o, {
      [s3]: {value: 'baz val'},
      [s4]: {value: 'qux val'}
    });
    
    console.log(o);
    // {Symbol(foo): foo val, Symbol(bar): bar val,
    //  Symbol(baz): baz val, Symbol(qux): qux val}
    
    

    类似于Object.getOwnPropertyNames() 返回对象实例的常规属性数组,Object.getOwnPropertySymbols() 返回对象实例的符号属性数组。这两个方法的返回值彼此互斥。Object.getOwnPropertyDescriptors() 会返回同时包含常规和符号属性描述符的对象。Reflect.ownKeys() 会返回两种类型的键:

    let s1 = Symbol('foo'),
        s2 = Symbol('bar');
    
    let o = {
      [s1]: 'foo val',
      [s2]: 'bar val',
      baz: 'baz val',
      qux: 'qux val'
    };
    
    console.log(Object.getOwnPropertySymbols(o));
    // [Symbol(foo), Symbol(bar)]
    
    console.log(Object.getOwnPropertyNames(o));
    // ["baz", "qux"]
    
    console.log(Object.getOwnPropertyDescriptors(o));
    // {baz: {...}, qux: {...}, Symbol(foo): {...}, Symbol(bar): {...}}
    
    console.log(Reflect.ownKeys(o));
    // ["baz", "qux", Symbol(foo), Symbol(bar)]
    
    

    因为符号属性是对内存中符号的一个引用,所以直接创建并用作属性的符号不会丢失。但是,如果没有显式地保存对这些属性的引用,那么必须遍历对象的所有符号属性才能找到相应的属性键:

    let o = {
      [Symbol('foo')]: 'foo val',
      [Symbol('bar')]: 'bar val'
    };
    
    console.log(o);
    // {Symbol(foo): "foo val", Symbol(bar): "bar val"}
    
    let barSymbol = Object.getOwnPropertySymbols(o)
                  .find((symbol) => symbol.toString().match(/bar/));
    
    console.log(barSymbol);
    // Symbol(bar)
    
    

     

  4. 常用内置符号

    ECMAScript 6也引入了一批常用内置符号 (well-known symbol),用于暴露语言内部行为,开发者可以直接访问、重写或模拟这些行为。这些内置符号都以Symbol 工厂函数字符串属性的形式存在。

    这些内置符号最重要的用途之一是重新定义它们,从而改变原生结构的行为。比如,我们知道for-of 循环会在相关对象上使用Symbol.iterator 属性,那么就可以通过在自定义对象上重新定义Symbol.iterator 的值,来改变for-of 在迭代该对象时的行为。

    这些内置符号也没有什么特别之处,它们就是全局函数Symbol 的普通字符串属性,指向一个符号的实例。所有内置符号属性都是不可写、不可枚举、不可配置的。

    注意  在提到ECMAScript规范时,经常会引用符号在规范中的名称,前缀为@@ 。比如,@@iterator 指的就是Symbol.iterator

     

  5. Symbol.asyncIterator

    根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法返回对象默认的AsyncIterator 。由for-await-of 语句使用”。换句话说,这个符号表示实现异步迭代器API的函数。

    for-await-of 循环会利用这个函数执行异步迭代操作。循环时,它们会调用以Symbol.asyncIterator 为键的函数,并期望这个函数会返回一个实现迭代器API的对象。很多时候,返回的对象是实现该API的AsyncGenerator

    class Foo {
      async *[Symbol.asyncIterator]() {}
    }
    
    let f = new Foo();
    
    console.log(f[Symbol.asyncIterator]());
    // AsyncGenerator {<suspended>}
    
    

    技术上,这个由Symbol.asyncIterator 函数生成的对象应该通过其next() 方法陆续返回Promise 实例。可以通过显式地调用next() 方法返回,也可以隐式地通过异步生成器函数返回:

    class Emitter {
      constructor(max) {
        this.max = max;
        this.asyncIdx = 0;
      }
    
      async *[Symbol.asyncIterator]() {
        while(this.asyncIdx < this.max) {
          yield new Promise((resolve) => resolve(this.asyncIdx++));
        }
      }
    }
    
    async function asyncCount() {
      let emitter = new Emitter(5);
    
      for await(const x of emitter) {
        console.log(x);
      }
    }
    
    asyncCount();
    // 0
    // 1
    // 2
    // 3
    // 4
    
    

    注意  Symbol.asyncIterator 是ES2018规范定义的,因此只有版本非常新的浏览器支持它。关于异步迭代和for-await-of 循环的细节,参见附录A。

     

  6. Symbol.hasInstance

    根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法决定一个构造器对象是否认可一个对象是它的实例。由instanceof 操作符使用”。instanceof 操作符可以用来确定一个对象实例的原型链上是否有原型。instanceof 的典型使用场景如下:

    function Foo() {}
    let f = new Foo();
    console.log(f instanceof Foo); // true
    
    class Bar {}
    let b = new Bar();
    console.log(b instanceof Bar); // true
    
    

    在ES6中,instanceof 操作符会使用Symbol.hasInstance 函数来确定关系。以Symbol.hasInstance 为键的函数会执行同样的操作,只是操作数对调了一下:

    function Foo() {}
    let f = new Foo();
    console.log(Foo[Symbol.hasInstance](f)); // true
    
    class Bar {}
    let b = new Bar();
    console.log(Bar[Symbol.hasInstance](b)); // true
    
    

    这个属性定义在Function 的原型上,因此默认在所有函数和类上都可以调用。由于instanceof 操作符会在原型链上寻找这个属性定义,就跟在原型链上寻找其他属性一样,因此可以在继承的类上通过静态方法重新定义这个函数:

    class Bar {}
    class Baz extends Bar {
      static [Symbol.hasInstance]() {
        return false;
      }
    }
    
    let b = new Baz();
    console.log(Bar[Symbol.hasInstance](b)); // true
    console.log(b instanceof Bar);           // true
    console.log(Baz[Symbol.hasInstance](b)); // false
    console.log(b instanceof Baz);           // false
    
    

     

  7. Symbol.isConcatSpreadable

    根据ECMAScript规范,这个符号作为一个属性表示“一个布尔值,如果是true ,则意味着对象应该用Array.prototype.concat() 打平其数组元素”。ES6中的Array.prototype.concat() 方法会根据接收到的对象类型选择如何将一个类数组对象拼接成数组实例。覆盖Symbol.isConcatSpreadable 的值可以修改这个行为。

    数组对象默认情况下会被打平到已有的数组,false 或假值会导致整个对象被追加到数组末尾。类数组对象默认情况下会被追加到数组末尾,true 或真值会导致这个类数组对象被打平到数组实例。其他不是类数组对象的对象在Symbol.isConcatSpreadable 被设置为true 的情况下将被忽略。

    let initial = ['foo'];
    
    let array = ['bar'];
    console.log(array[Symbol.isConcatSpreadable]);  // undefined
    console.log(initial.concat(array));             // ['foo', 'bar']
    array[Symbol.isConcatSpreadable] = false;
    console.log(initial.concat(array));             // ['foo', Array(1)]
    
    let arrayLikeObject = { length: 1, 0: 'baz' };
    console.log(arrayLikeObject[Symbol.isConcatSpreadable]);  // undefined
    console.log(initial.concat(arrayLikeObject));             // ['foo', {...}]
    arrayLikeObject[Symbol.isConcatSpreadable] = true;
    console.log(initial.concat(arrayLikeObject));             // ['foo', 'baz']
    
    let otherObject = new Set().add('qux');
    console.log(otherObject[Symbol.isConcatSpreadable]);  // undefined
    console.log(initial.concat(otherObject));             // ['foo', Set(1)]
    otherObject[Symbol.isConcatSpreadable] = true;
    console.log(initial.concat(otherObject));             // ['foo']
    
    

     

  8. Symbol.iterator

    根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法返回对象默认的迭代器。由for-of 语句使用”。换句话说,这个符号表示实现迭代器API的函数。

    for-of 循环这样的语言结构会利用这个函数执行迭代操作。循环时,它们会调用以Symbol.iterator 为键的函数,并默认这个函数会返回一个实现迭代器API的对象。很多时候,返回的对象是实现该API的Generator

    class Foo {
      *[Symbol.iterator]() {}
    }
    
    let f = new Foo();
    
    console.log(f[Symbol.iterator]());
    // Generator {<suspended>}
    
    

    技术上,这个由Symbol.iterator 函数生成的对象应该通过其next() 方法陆续返回值。可以通过显式地调用next() 方法返回,也可以隐式地通过生成器函数返回:

    class Emitter {
      constructor(max) {
        this.max = max;
        this.idx = 0;
      }
    
      *[Symbol.iterator]() {
        while(this.idx < this.max) {
          yield this.idx++;
        }
      }
    }
    
    function count() {
      let emitter = new Emitter(5);
    
      for (const x of emitter) {
        console.log(x);
      }
    }
    
    count();
    // 0
    // 1
    // 2
    // 3
    // 4
    
    

    注意  迭代器的相关内容将在第7章详细介绍。

     

  9. Symbol.match

    根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法用正则表达式去匹配字符串。由String.prototype.match() 方法使用”。String.prototype.match() 方法会使用以Symbol.match 为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个String 方法的有效参数:

    console.log(RegExp.prototype[Symbol.match]);
    // f [Symbol.match]() { [native code] }
    
    console.log('foobar'.match(/bar/));
    // ["bar", index: 3, input: "foobar", groups: undefined]
    
    

    给这个方法传入非正则表达式值会导致该值被转换为RegExp 对象。如果想改变这种行为,让方法直接使用参数,则可以重新定义Symbol.match 函数以取代默认对正则表达式求值的行为,从而让match() 方法使用非正则表达式实例。Symbol.match 函数接收一个参数,就是调用match() 方法的字符串实例。返回的值没有限制:

    class FooMatcher {
      static [Symbol.match](target) {
        return target.includes('foo');
      }
    }
    
    console.log('foobar'.match(FooMatcher)); // true
    console.log('barbaz'.match(FooMatcher)); // false
    
    class StringMatcher {
      constructor(str) {
        this.str = str;
      }
    
      [Symbol.match](target) {
        return target.includes(this.str);
      }
    }
    
    console.log('foobar'.match(new StringMatcher('foo'))); // true
    console.log('barbaz'.match(new StringMatcher('qux'))); // false
    
    

     

  10. Symbol.replace

    根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法替换一个字符串中匹配的子串。由String.prototype.replace() 方法使用”。String.prototype.replace() 方法会使用以Symbol.replace 为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个String 方法的有效参数:

    console.log(RegExp.prototype[Symbol.replace]);
    // f [Symbol.replace]() { [native code] }
    
    console.log('foobarbaz'.replace(/bar/, 'qux'));
    // 'fooquxbaz'
    
    

    给这个方法传入非正则表达式值会导致该值被转换为RegExp 对象。如果想改变这种行为,让方法直接使用参数,可以重新定义Symbol.replace 函数以取代默认对正则表达式求值的行为,从而让replace() 方法使用非正则表达式实例。Symbol.replace 函数接收两个参数,即调用replace() 方法的字符串实例和替换字符串。返回的值没有限制:

    class FooReplacer {
      static [Symbol.replace](target, replacement) {
        return target.split('foo').join(replacement);
      }
    }
    
    console.log('barfoobaz'.replace(FooReplacer, 'qux'));
    // "barquxbaz"
    
    class StringReplacer {
      constructor(str) {
        this.str = str;
      }
    
      [Symbol.replace](target, replacement) {
        return target.split(this.str).join(replacement);
      }
    }
    
    console.log('barfoobaz'.replace(new StringReplacer('foo'), 'qux'));
    // "barquxbaz"
    
    

     

  11. Symbol.search

    根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法返回字符串中匹配正则表达式的索引。由String.prototype.search() 方法使用”。String.prototype.search() 方法会使用以Symbol.search 为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个String 方法的有效参数:

    console.log(RegExp.prototype[Symbol.search]);
    // f [Symbol.search]() { [native code] }
    
    console.log('foobar'.search(/bar/));
    // 3
    
    

    给这个方法传入非正则表达式值会导致该值被转换为RegExp 对象。如果想改变这种行为,让方法直接使用参数,可以重新定义Symbol.search 函数以取代默认对正则表达式求值的行为,从而让search() 方法使用非正则表达式实例。Symbol.search 函数接收一个参数,就是调用match() 方法的字符串实例。返回的值没有限制:

    class FooSearcher {
      static [Symbol.search](target) {
        return target.indexOf('foo');
      }
    }
    
    console.log('foobar'.search(FooSearcher)); // 0
    console.log('barfoo'.search(FooSearcher)); // 3
    console.log('barbaz'.search(FooSearcher)); // -1
    
    class StringSearcher {
      constructor(str) {
        this.str = str;
      }
    
      [Symbol.search](target) {
        return target.indexOf(this.str);
      }
    }
    
    console.log('foobar'.search(new StringSearcher('foo'))); // 0
    console.log('barfoo'.search(new StringSearcher('foo'))); // 3
    console.log('barbaz'.search(new StringSearcher('qux'))); // -1
    
    

     

  12. Symbol.species

    根据ECMAScript规范,这个符号作为一个属性表示“一个函数值,该函数作为创建派生对象的构造函数”。这个属性在内置类型中最常用,用于对内置类型实例方法的返回值暴露实例化派生对象的方法。用Symbol.species 定义静态的获取器(getter)方法,可以覆盖新创建实例的原型定义:

    class Bar extends Array {}
    class Baz extends Array {
      static get [Symbol.species]() {
        return Array;
      }
    }
    
    let bar = new Bar();
    console.log(bar instanceof Array); // true
    console.log(bar instanceof Bar);   // true
    bar = bar.concat('bar');
    console.log(bar instanceof Array); // true
    console.log(bar instanceof Bar);   // true
    
    let baz = new Baz();
    console.log(baz instanceof Array); // true
    console.log(baz instanceof Baz);   // true
    baz = baz.concat('baz');
    console.log(baz instanceof Array); // true
    console.log(baz instanceof Baz);   // false
    
    

     

  13. Symbol.split

    根据ECMAScript规范,这个符号作为一个属性表示“一个正则表达式方法,该方法在匹配正则表达式的索引位置拆分字符串。由String.prototype.split() 方法使用”。String.prototype.split() 方法会使用以Symbol.split 为键的函数来对正则表达式求值。正则表达式的原型上默认有这个函数的定义,因此所有正则表达式实例默认是这个String 方法的有效参数:

    console.log(RegExp.prototype[Symbol.split]);
    // f [Symbol.split]() { [native code] }
    
    console.log('foobarbaz'.split(/bar/));
    // ['foo', 'baz']
    
    

    给这个方法传入非正则表达式值会导致该值被转换为RegExp 对象。如果想改变这种行为,让方法直接使用参数,可以重新定义Symbol.split 函数以取代默认对正则表达式求值的行为,从而让split() 方法使用非正则表达式实例。Symbol.split 函数接收一个参数,就是调用match() 方法的字符串实例。返回的值没有限制:

    class FooSplitter {
      static [Symbol.split](target) {
        return target.split('foo');
      }
    }
    
    console.log('barfoobaz'.split(FooSplitter));
    // ["bar", "baz"]
    
    class StringSplitter {
      constructor(str) {
        this.str = str;
      }
    
      [Symbol.split](target) {
        return target.split(this.str);
      }
    }
    
    console.log('barfoobaz'.split(new StringSplitter('foo')));
    // ["bar", "baz"]
    
    

     

  14. Symbol.toPrimitive

    根据ECMAScript规范,这个符号作为一个属性表示“一个方法,该方法将对象转换为相应的原始值。由ToPrimitive 抽象操作使用”。很多内置操作都会尝试强制将对象转换为原始值,包括字符串、数值和未指定的原始类型。对于一个自定义对象实例,通过在这个实例的Symbol.toPrimitive 属性上定义一个函数可以改变默认行为。

    根据提供给这个函数的参数(stringnumberdefault ),可以控制返回的原始值:

    class Foo {}
    let foo = new Foo();
    
    console.log(3 + foo);       // "3[object Object]"
    console.log(3 - foo);       // NaN
    console.log(String(foo));   // "[object Object]"
    
    class Bar {
      constructor() {
        this[Symbol.toPrimitive] = function(hint) {
          switch (hint) {
            case 'number':
              return 3;
            case 'string':
              return 'string bar';
            case 'default':
            default:
              return 'default bar';
          }
        }
      }
    }
    let bar = new Bar();
    
    console.log(3 + bar);     // "3default bar"
    console.log(3 - bar);     // 0
    console.log(String(bar)); // "string bar"
    
    

     

  15. Symbol.toStringTag

    根据ECMAScript规范,这个符号作为一个属性表示“一个字符串,该字符串用于创建对象的默认字符串描述。由内置方法Object.prototype.toString() 使用”。

    通过toString() 方法获取对象标识时,会检索由Symbol.toStringTag 指定的实例标识符,默认为"Object" 。内置类型已经指定了这个值,但自定义类实例还需要明确定义:

    let s = new Set();
    
    console.log(s);                      // Set(0) {}
    console.log(s.toString());           // [object Set]
    console.log(s[Symbol.toStringTag]);  // Set
    
    class Foo {}
    let foo = new Foo();
    
    console.log(foo);                      // Foo {}
    console.log(foo.toString());           // [object Object]
    console.log(foo[Symbol.toStringTag]);  // undefined
    
    class Bar {
      constructor() {
        this[Symbol.toStringTag] = 'Bar';
      }
    }
    let bar = new Bar();
    
    console.log(bar);                      // Bar {}
    console.log(bar.toString());           // [object Bar]
    console.log(bar[Symbol.toStringTag]);  // Bar
    
    

     

  16. Symbol.unscopables

    根据ECMAScript规范,这个符号作为一个属性表示“一个对象,该对象所有的以及继承的属性,都会从关联对象的with 环境绑定中排除”。设置这个符号并让其映射对应属性的键值为true ,就可以阻止该属性出现在with 环境绑定中,如下例所示:

    let o = { foo: 'bar' };
    
    with (o) {
      console.log(foo); // bar
    }
    
    o[Symbol.unscopables] = {
      foo: true
    };
    
    with (o) {
      console.log(foo); // ReferenceError
    }
    
    

    注意  不推荐使用with ,因此也不推荐使用Symbol.unscopables

ECMAScript中的对象其实就是一组数据和功能的集合。对象通过new 操作符后跟对象类型的名称来创建。开发者可以通过创建Object 类型的实例来创建自己的对象,然后再给对象添加属性和方法:

let o = new Object();

这个语法类似Java,但ECMAScript只要求在给构造函数提供参数时使用括号。如果没有参数,如上面的例子所示,那么完全可以省略括号(不推荐):

let o = new Object;  // 合法,但不推荐

Object 的实例本身并不是很有用,但理解与它相关的概念非常重要。类似Java中的java.lang.Object ,ECMAScript中的Object 也是派生其他对象的基类。Object 类型的所有属性和方法在派生的对象上同样存在。

每个Object 实例都有如下属性和方法。

因为在ECMAScript中Object 是所有对象的基类,所以任何对象都有这些属性和方法。第8章将介绍对象间的继承机制。

注意  严格来讲,ECMA-262中对象的行为不一定适合JavaScript中的其他对象。比如浏览器环境中的BOM和DOM对象,都是由宿主环境定义和提供的宿主对象。而宿主对象不受ECMA-262约束,所以它们可能会也可能不会继承Object

ECMA-262描述了一组可用于操作数据值的操作符 ,包括数学操作符(如加、减)、位操作符、关系操作符和相等操作符等。ECMAScript中的操作符是独特的,因为它们可用于各种值,包括字符串、数值、布尔值,甚至还有对象。在应用给对象时,操作符通常会调用valueOf()/toString() 方法来取得可以计算的值。

只操作一个值的操作符叫一元操作符 (unary operator)。一元操作符是ECMAScript中最简单的操作符。

  1. 递增/递减操作符

    递增和递减操作符直接照搬自C语言,但有两个版本:前缀版和后缀版。顾名思义,前缀版就是位于要操作的变量前头,后缀版就是位于要操作的变量后头。前缀递增操作符会给数值加1,把两个加号(++ )放到变量前头即可:

    let age = 29;
    ++age;
    
    

    在这个例子中,前缀递增操作符把age 的值变成了30(给之前的值29加1)。因此,它实际上等于如下表达式:

    let age = 29;
    age = age + 1;
    
    

    前缀递减操作符也类似,只不过是从一个数值减1。使用前缀递减操作符,只要把两个减号(-- )放到变量前头即可:

    let age = 29;
    --age;
    
    

    执行操作后,变量age 的值变成了28(从29减1)。

    无论使用前缀递增还是前缀递减操作符,变量的值都会在语句被求值之前改变。(在计算机科学中,这通常被称为具有副作用 。)请看下面的例子:

    let age = 29;
    let anotherAge = --age + 2;
    
    console.log(age);         // 28
    console.log(anotherAge);  // 30
    
    

    在这个例子中,变量anotherAgeage 减1后的值再加2进行初始化。因为递减操作先发生,所以age 的值先变成28,然后再加2,结果是30。

    前缀递增和递减在语句中的优先级是相等的,因此会从左到右依次求值。比如:

    let num1 = 2;
    let num2 = 20;
    let num3 = --num1 + num2;
    let num4 = num1 + num2;
    console.log(num3);  // 21
    console.log(num4);  // 21
    
    

    这里,num3 等于21是因为num1 先减1之后才加num2 。变量num4 也是21,那是因为加法使用的也是递减后的值。

    递增和递减的后缀版语法一样(分别是++-- ),只不过要放在变量后面。后缀版与前缀版的主要区别在于,后缀版递增和递减在语句被求值后才发生。在某些情况下,这种差异没什么影响,比如:

    let age = 29;
    age++;
    
    

    把递增操作符放到变量后面不会改变语句执行的结果,因为递增是唯一的操作。可是,在跟其他操作混合时,差异就会变明显,比如:

    let num1 = 2;
    let num2 = 20;
    let num3 = num1-- + num2;
    let num4 = num1 + num2;
    console.log(num3);  // 22
    console.log(num4);  // 21
    
    

    这个例子跟前面的那个一样,只是把前缀递减改成了后缀递减,区别很明显。在使用前缀版的例子中,num3num4 的值都是21。而在这个例子中,num3 的值是22,num4 的值是21。这里的不同之处在于,计算num3 时使用的是num1 的原始值(2),而计算num4 时使用的是num1 递减后的值(1)。

    这4个操作符可以作用于任何值,意思是不限于整数——字符串、布尔值、浮点值,甚至对象都可以。递增和递减操作符遵循如下规则。

    下面的例子演示了这些规则:

    let s1 = "2";
    let s2 = "z";
    let b = false;
    let f = 1.1;
    let o = {
      valueOf() {
        return -1;
      }
    };
    
    s1++;  // 值变成数值3
    s2++;  // 值变成NaN
    b++;   // 值变成数值1
    f--;   // 值变成0.10000000000000009(因为浮点数不精确)
    o--;   // 值变成-2
    
    

     

  2. 一元加和减

    一元加和减操作符 对大多数开发者来说并不陌生,它们在ECMAScript中跟在高中数学中的用途一样。一元加由一个加号(+ )表示,放在变量前头,对数值没有任何影响:

    let num = 25;
    num = +num;
    console.log(num); // 25
    
    

    如果将一元加应用到非数值,则会执行与使用Number() 转型函数一样的类型转换:布尔值falsetrue 转换为0和1,字符串根据特殊规则进行解析,对象会调用它们的valueOf() 和/或toString() 方法以得到可以转换的值。

    下面的例子演示了一元加在应用到不同数据类型时的行为:

    let s1 = "01";
    let s2 = "1.1";
    let s3 = "z";
    let b = false;
    let f = 1.1;
    let o = {
      valueOf() {
        return -1;
      }
    };
    
    s1 = +s1;  // 值变成数值1
    s2 = +s2;  // 值变成数值1.1
    s3 = +s3;  // 值变成NaN
    b = +b;    // 值变成数值0
    f = +f;    // 不变,还是1.1
    o = +o;    // 值变成数值-1
    
    

    一元减由一个减号(- )表示,放在变量前头,主要用于把数值变成负值,如把1转换为-1。示例如下:

    let num = 25;
    num = -num;
    console.log(num);  // -25
    
    

    对数值使用一元减会将其变成相应的负值(如上面的例子所示)。在应用到非数值时,一元减会遵循与一元加同样的规则,先对它们进行转换,然后再取负值:

    let s1 = "01";
    let s2 = "1.1";
    let s3 = "z";
    let b = false;
    let f = 1.1;
    let o = {
      valueOf() {
        return -1;
      }
    };
    
    s1 = -s1;  // 值变成数值-1
    s2 = -s2;  // 值变成数值-1.1
    s3 = -s3;  // 值变成NaN
    b = -b;    // 值变成数值0
    f = -f;    // 变成-1.1
    o = -o;    // 值变成数值1
    
    

    一元加和减操作符主要用于基本的算术,但也可以像上面的例子那样,用于数据类型转换。

接下来要介绍的操作符用于数值的底层操作,也就是操作内存中表示数据的比特(位)。ECMAScript中的所有数值都以IEEE 754 64位格式存储,但位操作并不直接应用到64位表示,而是先把值转换为32位整数,再进行位操作,之后再把结果转换为64位。对开发者而言,就好像只有32位整数一样,因为64位整数存储格式是不可见的。既然知道了这些,就只需要考虑32位整数即可。

有符号整数使用32位的前31位表示整数值。第32位表示数值的符号,如0表示正,1表示负。这一位称为符号位 (sign bit),它的值决定了数值其余部分的格式。正值以真正的二进制格式存储,即31位中的每一位都代表2的幂。第一位(称为第0位)表示20 ,第二位表示21 ,依此类推。如果一个位是空的,则以0填充,相当于忽略不计。比如,数值18的二进制格式为00000000000000000000000000010010,或更精简的10010。后者是用到的5个有效位,决定了实际的值(如图3-1所示)。

图 3-1

负值以一种称为二补数 (或补码)的二进制编码存储。一个数值的二补数通过如下3个步骤计算得到:

(1) 确定绝对值的二进制表示(如,对于-18,先确定18的二进制表示);

(2) 找到数值的一补数(或反码),换句话说,就是每个0都变成1,每个1都变成0;

(3) 给结果加1。

基于上述步骤确定-18的二进制表示,首先从18的二进制表示开始:

0000  0000  0000  0000  0000  0000  0001  0010

然后,计算一补数,即反转每一位的二进制值:

1111  1111  1111  1111  1111  1111  1110  1101

最后,给一补数加1:

1111  1111  1111  1111  1111  1111  1110  1101
                        1
----------------------------------------------
1111  1111  1111  1111  1111  1111  1110  1110

那么,-18的二进制表示就是11111111111111111111111111101110。要注意的是,在处理有符号整数时,我们无法访问第31位。

ECMAScript会帮我们记录这些信息。在把负值输出为一个二进制字符串时,我们会得到一个前面加了减号的绝对值,如下所示:

let num = -18;
console.log(num.toString(2)); // "-10010"

在将-18转换为二进制字符串时,结果得到-10010。转换过程会求得二补数,然后再以更符合逻辑的形式表示出来。

注意  默认情况下,ECMAScript中的所有整数都表示为有符号数。不过,确实存在无符号整数。对无符号整数来说,第32位不表示符号,因为只有正值。无符号整数比有符号整数的范围更大,因为符号位被用来表示数值了。

在对ECMAScript中的数值应用位操作符时,后台会发生转换:64位数值会转换为32位数值,然后执行位操作,最后再把结果从32位转换为64位存储起来。整个过程就像处理32位数值一样,这让二进制操作变得与其他语言中类似。但这个转换也导致了一个奇特的副作用,即特殊值NaNInfinity 在位操作中都会被当成0处理。

如果将位操作符应用到非数值,那么首先会使用Number() 函数将该值转换为数值(这个过程是自动的),然后再应用位操作。最终结果是数值。

  1. 按位非

    按位非操作符用波浪符(~ )表示,它的作用是返回数值的一补数。按位非是ECMAScript中为数不多的几个二进制数学操作符之一。看下面的例子:

    let num1 = 25;      // 二进制00000000000000000000000000011001
    let num2 = ~num1;   // 二进制11111111111111111111111111100110
    console.log(num2);  // -26
    
    

    这里,按位非操作符作用到了数值25,得到的结果是-26。由此可以看出,按位非的最终效果是对数值取反并减1,就像执行如下操作的结果一样:

    let num1 = 25;
    let num2 = -num1 - 1;
    console.log(num2);   // "-26"
    
    

    实际上,尽管两者返回的结果一样,但位操作的速度快得多。这是因为位操作是在数值的底层表示上完成的。
     

  2. 按位与

    按位与操作符用和号(& )表示,有两个操作数。本质上,按位与就是将两个数的每一个位对齐,然后基于真值表中的规则,对每一位执行相应的与操作。

    第一个数值的位 第二个数值的位 结果
    1 1 1
    1 0 0
    0 1 0
    0 0 0

    按位与操作在两个位都是1时返回1,在任何一位是0时返回0。

    下面看一个例子,我们对数值25和3求与操作,如下所示:

    let result = 25 & 3;
    console.log(result); // 1
    
    

    25和3的按位与操作的结果是1。为什么呢?看下面的二进制计算过程:

     25 = 0000 0000 0000 0000 0000 0000 0001 1001
      3 = 0000 0000 0000 0000 0000 0000 0000 0011
    ---------------------------------------------
    AND = 0000 0000 0000 0000 0000 0000 0000 0001
    

    如上所示,25和3的二进制表示中,只有第0位上的两个数都是1。于是结果数值的所有其他位都会以0填充,因此结果就是1。
     

  3. 按位或

    按位或操作符用管道符(| )表示,同样有两个操作数。按位或遵循如下真值表:

    第一个数值的位 第二个数值的位 结果
    1 1 1
    1 0 1
    0 1 1
    0 0 0

    按位或操作在至少一位是1时返回1,两位都是0时返回0。

    仍然用按位与的示例,如果对25和3执行按位或,代码如下所示:

    let result = 25 | 3;
    console.log(result); // 27
    
    

    可见25和3的按位或操作的结果是27:

     25 = 0000 0000 0000 0000 0000 0000 0001 1001
      3 = 0000 0000 0000 0000 0000 0000 0000 0011
    ---------------------------------------------
     OR = 0000 0000 0000 0000 0000 0000 0001 1011
    

    在参与计算的两个数中,有4位都是1,因此它们直接对应到结果上。二进制码11011等于27。
     

  4. 按位异或

    按位异或用脱字符(^ )表示,同样有两个操作数。下面是按位异或的真值表:

    第一个数的位 第二个数的位 结果
    1 1 0
    1 0 1
    0 1 1
    0 0 0

    按位异或与按位或的区别是,它只在一位上是1的时候返回1(两位都是1或0,则返回0)。

    对数值25和3执行按位异或操作:

    let result = 25 ^ 3;
    console.log(result); // 26
    
    

    可见,25和3的按位异或操作结果为26,如下所示:

     25 = 0000 0000 0000 0000 0000 0000 0001 1001
      3 = 0000 0000 0000 0000 0000 0000 0000 0011
    ---------------------------------------------
    XOR = 0000 0000 0000 0000 0000 0000 0001 1010
    

    两个数在4位上都是1,但两个数的第0位都是1,因此那一位在结果中就变成了0。其余位上的1在另一个数上没有对应的1,因此会直接传递到结果中。二进制码11010等于26。(注意,这比对同样两个值执行按位或操作得到的结果小1。)
     

  5. 左移

    左移操作符用两个小于号(<< )表示,会按照指定的位数将数值的所有位向左移动。比如,如果数值2(二进制10)向左移5位,就会得到64(二进制1000000),如下所示:

    let oldValue = 2;              // 等于二进制10
    let newValue = oldValue << 5;  // 等于二进制1000000,即十进制64
    
    

    注意在移位后,数值右端会空出5位。左移会以0填充这些空位,让结果是完整的32位数值(见图3-2)。

    {%}

    图 3-2

    注意,左移会保留它所操作数值的符号。比如,如果-2左移5位,将得到-64,而不是正64。
     

  6. 有符号右移

    有符号右移由两个大于号(>> )表示,会将数值的所有32位都向右移,同时保留符号(正或负)。有符号右移实际上是左移的逆运算。比如,如果将64右移5位,那就是2:

    let oldValue = 64;             // 等于二进制1000000
    let newValue = oldValue >> 5;  // 等于二进制10,即十进制2
    
    

    同样,移位后就会出现空位。不过,右移后空位会出现在左侧,且在符号位之后(见图3-3)。ECMAScript会用符号位的值来填充这些空位,以得到完整的数值。

    {%}

    图 3-3
     

  7. 无符号右移

    无符号右移用3个大于号表示(>>> ),会将数值的所有32位都向右移。对于正数,无符号右移与有符号右移结果相同。仍然以前面有符号右移的例子为例,64向右移动5位,会变成2:

    let oldValue = 64;              // 等于二进制1000000
    let newValue = oldValue >>> 5;  // 等于二进制10,即十进制2
    
    

    对于负数,有时候差异会非常大。与有符号右移不同,无符号右移会给空位补0,而不管符号位是什么。对正数来说,这跟有符号右移效果相同。但对负数来说,结果就差太多了。无符号右移操作符将负数的二进制表示当成正数的二进制表示来处理。因为负数是其绝对值的二补数,所以右移之后结果变得非常之大,如下面的例子所示:

    let oldValue = -64;              // 等于二进制11111111111111111111111111000000
    let newValue = oldValue >>> 5;   // 等于十进制134217726
    
    

    在对-64无符号右移5位后,结果是134 217 726。这是因为-64的二进制表示是11111111111111111111111111000000,无符号右移却将它当成正值,也就是4 294 967 232。把这个值右移5位后,结果是00000111111111111111111111111110,即134 217 726。

对于编程语言来说,布尔操作符跟相等操作符几乎同样重要。如果没有能力测试两个值的关系,那么像if-else 和循环这样的语句也没什么用了。布尔操作符一共有3个:逻辑非、逻辑与和逻辑或。

  1. 逻辑非

    逻辑非操作符由一个叹号(! )表示,可应用给ECMAScript中的任何值。这个操作符始终返回布尔值,无论应用到的是什么数据类型。逻辑非操作符首先将操作数转换为布尔值,然后再对其取反。换句话说,逻辑非操作符会遵循如下规则。

    以下示例验证了上述行为:

    console.log(!false);   // true
    console.log(!"blue");  // false
    console.log(!0);       // true
    console.log(!NaN);     // true
    console.log(!"");      // true
    console.log(!12345);   // false
    
    

    逻辑非操作符也可以用于把任意值转换为布尔值。同时使用两个叹号(!! ),相当于调用了转型函数Boolean() 。无论操作数是什么类型,第一个叹号总会返回布尔值。第二个叹号对该布尔值取反,从而给出变量真正对应的布尔值。结果与对同一个值使用Boolean() 函数是一样的:

    console.log(!!"blue"); // true
    console.log(!!0);      // false
    console.log(!!NaN);    // false
    console.log(!!"");     // false
    console.log(!!12345);  // true
    
    

     

  2. 逻辑与

    逻辑与操作符由两个和号(&& )表示,应用到两个值,如下所示:

    let result = true && false;
    
    

    逻辑与操作符遵循如下真值表:

    第一个操作数 第二个操作数 结果
    true true true
    true false false
    false true false
    false false false

    逻辑与操作符可用于任何类型的操作数,不限于布尔值。如果有操作数不是布尔值,则逻辑与并不一定会返回布尔值,而是遵循如下规则。

    逻辑与操作符是一种短路操作符,意思就是如果第一个操作数决定了结果,那么永远不会对第二个操作数求值。对逻辑与操作符来说,如果第一个操作数是false ,那么无论第二个操作数是什么值,结果也不可能等于true 。看下面的例子:

    let found = true;
    let result = (found && someUndeclaredVariable); // 这里会出错
    console.log(result); // 不会执行这一行
    
    

    上面的代码之所以会出错,是因为someUndeclaredVariable 没有事先声明,所以当逻辑与操作符对它求值时就会报错。变量found 的值是true ,逻辑与操作符会继续求值变量someUndeclaredVariable 。但是由于someUndeclaredVariable 没有定义,不能对它应用逻辑与操作符,因此就报错了。假如变量found 的值是false ,那么就不会报错了:

    let found = false;
    let result = (found && someUndeclaredVariable);  // 不会出错
    console.log(result);  // 会执行
    
    

    这里,console.log 会成功执行。即使变量someUndeclaredVariable 没有定义,由于第一个操作数是false ,逻辑与操作符也不会对它求值,因为此时对&& 右边的操作数求值是没有意义的。在使用逻辑与操作符时,一定别忘了它的这个短路的特性。
     

  3. 逻辑或

    逻辑或操作符由两个管道符(|| )表示,比如:

    let result = true || false;
    
    

    逻辑或操作符遵循如下真值表:

    第一个操作数 第二个操作数 结果
    true true true
    true false true
    false true true
    false false false

    与逻辑与类似,如果有一个操作数不是布尔值,那么逻辑或操作符也不一定返回布尔值。它遵循如下规则。

    同样与逻辑与类似,逻辑或操作符也具有短路的特性。只不过对逻辑或而言,第一个操作数求值为true ,第二个操作数就不会再被求值了。看下面的例子:

    let found = true;
    let result = (found || someUndeclaredVariable); // 不会出错
    console.log(result); // 会执行
    
    

    跟前面的例子一样,变量someUndeclaredVariable 也没有定义。但是,因为变量found 的值为true ,所以逻辑或操作符不会对变量someUndeclaredVariable 求值,而直接返回true 。假如把found 的值改为false ,那就会报错了:

    let found = false;
    let result = (found || someUndeclaredVariable); // 这里会出错
    console.log(result); // 不会执行这一行
    
    

    利用这个行为,可以避免给变量赋值nullundefined 。比如:

    let myObject = preferredObject || backupObject;
    
    

    在这个例子中,变量myObject 会被赋予两个值中的一个。其中,preferredObject 变量包含首选的值,backupObject 变量包含备用的值。如果preferredObject 不是null ,则它的值就会赋给myObject ;如果preferredObjectnull ,则backupObject 的值就会赋给myObject 。这种模式在ECMAScript代码中经常用于变量赋值,本书后面的代码示例中也会经常用到。

ECMAScript定义了3个乘性操作符:乘法、除法和取模。这些操作符跟它们在Java、C语言及Perl中对应的操作符作用一样,但在处理非数值时,它们也会包含一些自动的类型转换。如果乘性操作符有不是数值的操作数,则该操作数会在后台被使用Number() 转型函数转换为数值。这意味着空字符串会被当成0,而布尔值true 会被当成1。

  1. 乘法操作符

    乘法操作符由一个星号(* )表示,可以用于计算两个数值的乘积。其语法类似于C语言,比如:

    let result = 34 * 56;
    
    

    不过,乘法操作符在处理特殊值时也有一些特殊的行为。

  2. 除法操作符

    除法操作符由一个斜杠(/ )表示,用于计算第一个操作数除以第二个操作数的商,比如:

    let result = 66 / 11;
    
    

    跟乘法操作符一样,除法操作符针对特殊值也有一些特殊的行为。

  3. 取模操作符

    取模(余数)操作符由一个百分比符号(% )表示,比如:

    let result = 26 % 5; // 等于1
    
    

    与其他乘性操作符一样,取模操作符对特殊值也有一些特殊的行为。

ECMAScript 7新增了指数操作符,Math.pow() 现在有了自己的操作符** ,结果是一样的:

console.log(Math.pow(3, 2);    // 9
console.log(3 ** 2);           // 9

console.log(Math.pow(16, 0.5); // 4
console.log(16** 0.5);         // 4

不仅如此,指数操作符也有自己的指数赋值操作符**= ,该操作符执行指数运算和结果的赋值操作:

let squared = 3;
squared **= 2;
console.log(squared); // 9

let sqrt = 16;
sqrt **= 0.5;
console.log(sqrt); // 4

加性操作符,即加法和减法操作符,一般都是编程语言中最简单的操作符。不过,在ECMAScript中,这两个操作符拥有一些特殊的行为。与乘性操作符类似,加性操作符在后台会发生不同数据类型的转换。只不过对这两个操作符来说,转换规则不是那么直观。

  1. 加法操作符

    加法操作符(+ )用于求两个数的和,比如:

    let result = 1 + 2;
    
    

    如果两个操作数都是数值,加法操作符执行加法运算并根据如下规则返回结果:

    不过,如果有一个操作数是字符串,则要应用如下规则:

    如果有任一操作数是对象、数值或布尔值,则调用它们的toString() 方法以获取字符串,然后再应用前面的关于字符串的规则。对于undefinednull ,则调用String() 函数,分别获取"undefined""null"

    看下面的例子:

    let result1 = 5 + 5;        // 两个数值
    console.log(result1);       // 10
    let result2 = 5 + "5";      // 一个数值和一个字符串
    console.log(result2);       // "55"
    
    

    以上代码展示了加法操作符的两种运算模式。正常情况下,5 + 5 等于10(数值),如前两行代码所示。但是,如果将一个操作数改为字符串,比如"5" ,则相加的结果就变成了"55" (原始字符串值),因为第一个操作数也会被转换为字符串。

    ECMAScript中最常犯的一个错误,就是忽略加法操作中涉及的数据类型。比如下面这个例子:

    let num1 = 5;
    let num2 = 10;
    let message = "The sum of 5 and 10 is " + num1 + num2;
    console.log(message);  // "The sum of 5 and 10 is 510"
    
    

    这里,变量message 中保存的是一个字符串,是执行两次加法操作之后的结果。有人可能会认为最终得到的字符串是"The sum of 5 and 10 is 15" 。可是,实际上得到的是"The sum of 5 and 10 is 510" 。这是因为每次加法运算都是独立完成的。第一次加法的操作数是一个字符串和一个数值(5),结果还是一个字符串。第二次加法仍然是用一个字符串去加一个数值(10),同样也会得到一个字符串。如果想真正执行数学计算,然后把结果追加到字符串末尾,只要使用一对括号即可:

    let num1 = 5;
    let num2 = 10;
    let message = "The sum of 5 and 10 is " + (num1 + num2);
    console.log(message); // "The sum of 5 and 10 is 15"
    
    

    在此,我们用括号把两个数值变量括了起来,意思是让解释器先执行两个数值的加法,然后再把结果追加给字符串。因此,最终得到的字符串变成了"The sum of 5 and 10 is 15"
     

  2. 减法操作符

    减法操作符(- )也是使用很频繁的一种操作符,比如:

    let result = 2 - 1;
    
    

    与加法操作符一样,减法操作符也有一组规则用于处理ECMAScript中不同类型之间的转换。

    以下示例演示了上面的规则:

    let result1 = 5 - true; // true被转换为1,所以结果是4
    let result2 = NaN - 1;  // NaN
    let result3 = 5 - 3;    // 2
    let result4 = 5 - "";   // ""被转换为0,所以结果是5
    let result5 = 5 - "2";  // "2"被转换为2,所以结果是3
    let result6 = 5 - null; // null被转换为0,所以结果是5
    
    

关系操作符执行比较两个值的操作,包括小于(< )、大于(> )、小于等于(<= )和大于等于(>= ),用法跟数学课上学的一样。这几个操作符都返回布尔值,如下所示:

let result1 = 5 > 3; // true
let result2 = 5 < 3; // false

与ECMAScript中的其他操作符一样,在将它们应用到不同数据类型时也会发生类型转换和其他行为。

在使用关系操作符比较两个字符串时,会发生一个有趣的现象。很多人认为小于意味着“字母顺序靠前”,而大于意味着“字母顺序靠后”,实际上不是这么回事。对字符串而言,关系操作符会比较字符串中对应字符的编码,而这些编码是数值。比较完之后,会返回布尔值。问题的关键在于,大写字母的编码都小于小写字母的编码,因此以下这种情况就会发生:

let result = "Brick" < "alphabet"; // true

在这里,字符串"Brick" 被认为小于字符串"alphabet" ,因为字母B的编码是66,字母a的编码是97。要得到确实按字母顺序比较的结果,就必须把两者都转换为相同的大小写形式(全大写或全小写),然后再比较:

let result = "Brick".toLowerCase() < "alphabet".toLowerCase(); // false

将两个操作数都转换为小写,就能保证按照字母表顺序判定"alphabet""Brick" 前头。

另一个奇怪的现象是在比较两个数值字符串的时候,比如下面这个例子:

let result = "23" < "3"; // true

这里在比较字符串"23""3" 时返回true 。因为两个操作数都是字符串,所以会逐个比较它们的字符编码(字符"2" 的编码是50,而字符"3" 的编码是51)。不过,如果有一个操作数是数值,那么比较的结果就对了:

let result = "23" < 3; // false

因为这次会将字符串"23" 转换为数值23,然后再跟3比较,结果当然对了。只要是数值和字符串比较,字符串就会先被转换为数值,然后进行数值比较。对于数值字符串而言,这样能保证结果正确。但如果字符串不能转换成数值呢?比如下面这个例子:

let result = "a" < 3; // 因为"a"会转换为NaN,所以结果是false

因为字符"a" 不能转换成任何有意义的数值,所以只能转换为NaN 。这里有一个规则,即任何关系操作符在涉及比较NaN 时都返回false 。这样一来,下面的例子有趣了:

let result1 = NaN < 3;  // false
let result2 = NaN >= 3; // false

在大多数比较的场景中,如果一个值不小于另一个值,那就一定大于或等于它。但在比较NaN 时,无论是小于还是大于等于,比较的结果都会返回false

判断两个变量是否相等是编程中最重要的操作之一。在比较字符串、数值和布尔值是否相等时,过程都很直观。但是在比较两个对象是否相等时,情形就比较复杂了。ECMAScript中的相等和不相等操作符,原本在比较之前会执行类型转换,但很快就有人质疑这种转换是否应该发生。最终,ECMAScript提供了两组操作符。第一组是等于不等于 ,它们在比较之前执行转换。第二组是全等不全等 ,它们在比较之前不执行转换。

  1. 等于和不等于

    ECMAScript中的等于操作符用两个等于号(== )表示,如果操作数相等,则会返回true 。不等于操作符用叹号和等于号(!= )表示,如果两个操作数不相等,则会返回true 。这两个操作符都会先进行类型转换(通常称为强制类型转换 )再确定操作数是否相等。

    在转换操作数的类型时,相等和不相等操作符遵循如下规则。

    在进行比较时,这两个操作符会遵循如下规则。

    下表总结了一些特殊情况及比较的结果。

    表达式 结果
    null == undefined true
    "NaN" == NaN false
    5 == NaN false
    NaN == NaN false
    NaN != NaN true
    false == 0 true
    true == 1 true
    true == 2 false
    undefined == 0 false
    null == 0 false
    "5" == 5 true

     

  2. 全等和不全等

    全等和不全等操作符与相等和不相等操作符类似,只不过它们在比较相等时不转换操作数。全等操作符由3个等于号(=== )表示,只有两个操作数在不转换的前提下相等才返回true ,比如:

    let result1 = ("55" == 55);   // true,转换后相等
    let result2 = ("55" === 55);  // false,不相等,因为数据类型不同
    
    

    在这个例子中,第一个比较使用相等操作符,比较的是字符串"55" 和数值55 。如前所述,因为字符串"55" 会被转换为数值55,然后再与数值55进行比较,所以返回true 。第二个比较使用全等操作符,因为没有转换,字符串和数值当然不能相等,所以返回false

    不全等操作符用一个叹号和两个等于号(!== )表示,只有两个操作数在不转换的前提下不相等才返回true 。比如:

    let result1 = ("55" != 55);  // false,转换后相等
    let result2 = ("55" !== 55); // true,不相等,因为数据类型不同
    
    

    这一次,第一个比较使用不相等操作符,它会把字符串"55" 转换为数值55 ,跟第二个操作数相等。既然转换后两个值相等,那就返回false 。第二个比较使用不全等操作符。这时候可以这么问:“字符串55和数值55有区别吗?”答案是“有”(true )。

    另外,虽然null == undefinedtrue (因为这两个值类似),但null === undefinedfalse ,因为它们不是相同的数据类型。

    注意  由于相等和不相等操作符存在类型转换问题,因此推荐使用全等和不全等操作符。这样有助于在代码中保持数据类型的完整性。

条件操作符是ECMAScript中用途最为广泛的操作符之一,语法跟Java中一样:

variable = boolean_expression ? true_value : false_value;

上面的代码执行了条件赋值操作,即根据条件表达式boolean_expression 的值决定将哪个值赋给变量variable 。如果boolean_expressiontrue ,则赋值true_value ;如果boolean_expressionfalse ,则赋值false_value 。比如:

let max = (num1 > num2) ? num1 : num2;

在这个例子中,max 将被赋予一个最大值。这个表达式的意思是,如果num1 大于num2 (条件表达式为true ),则将num1 赋给max 。否则,将num2 赋给max

简单赋值用等于号(= )表示,将右手边的值赋给左手边的变量,如下所示:

let num = 10;

复合赋值使用乘性、加性或位操作符后跟等于号(= )表示。这些赋值操作符是类似如下常见赋值操作的简写形式:

let num = 10;
num = num + 10;

以上代码的第二行可以通过复合赋值来完成:

let num = 10;
num += 10;

每个数学操作符以及其他一些操作符都有对应的复合赋值操作符:

这些操作符仅仅是简写语法,使用它们不会提升性能。

逗号操作符可以用来在一条语句中执行多个操作,如下所示:

let num1 = 1, num2 = 2, num3 = 3;

在一条语句中同时声明多个变量是逗号操作符最常用的场景。不过,也可以使用逗号操作符来辅助赋值。在赋值时使用逗号操作符分隔值,最终会返回表达式中最后一个值:

let num = (5, 1, 4, 8, 0); // num的值为0

在这个例子中,num 将被赋值为0,因为0是表达式中最后一项。逗号操作符的这种使用场景并不多见,但这种行为的确存在。

ECMA-262描述了一些语句(也称为流控制语句 ),而ECMAScript中的大部分语法都体现在语句中。语句通常使用一或多个关键字完成既定的任务。语句可以简单,也可以复杂。简单的如告诉函数退出,复杂的如列出一堆要重复执行的指令。

if 语句是使用最频繁的语句之一,语法如下:

if (condition) statement1 else statement2

这里的条件(condition )可以是任何表达式,并且求值结果不一定是布尔值。ECMAScript会自动调用Boolean() 函数将这个表达式的值转换为布尔值。如果条件求值为true ,则执行语句statement1 ;如果条件求值为false ,则执行语句statement2 。这里的语句可能是一行代码,也可能是一个代码块(即包含在一对花括号中的多行代码)。来看下面的例子:

if (i > 25)
  console.log("Greater than 25."); // 只有一行代码的语句
else {
  console.log("Less than or equal to 25."); // 一个语句块
}

这里的最佳实践是使用语句块,即使只有一行代码要执行也是如此。这是因为语句块可以避免对什么条件下执行什么产生困惑。

可以像这样连续使用多个if 语句:

if (condition1) statement1 else if (condition2) statement2 else statement3

下面是一个例子:

if (i > 25) {
  console.log("Greater than 25.");
} else if (i < 0) {
  console.log("Less than 0.");
} else {
  console.log("Between 0 and 25, inclusive.");
}

do-while 语句是一种后测试循环语句,即循环体中的代码执行后才会对退出条件进行求值。换句话说,循环体内的代码至少执行一次。do-while 的语法如下:

do {
  statement
} while (expression);

下面是一个例子:

let i = 0;
do {
  i += 2;
} while (i < 10);

在这个例子中,只要i 小于10,循环就会重复执行。i 从0开始,每次循环递增2。

注意  后测试循环经常用于这种情形:循环体内代码在退出前至少要执行一次。

while 语句是一种先测试循环语句,即先检测退出条件,再执行循环体内的代码。因此,while 循环体内的代码有可能不会执行。下面是while 循环的语法:

while(expression) statement

这是一个例子:

let i = 0;
while (i < 10) {
  i += 2;
}

在这个例子中,变量i 从0开始,每次循环递增2。只要i 小于10,循环就会继续。

for 语句也是先测试语句,只不过增加了进入循环之前的初始化代码,以及循环执行后要执行的表达式,语法如下:

for (initialization; expression; post-loop-expression) statement

下面是一个用例:

let count = 10;
for (let i = 0; i < count; i++) {
  console.log(i);
}

以上代码在循环开始前定义了变量i 的初始值为0。然后求值条件表达式,如果求值结果为truei < count ),则执行循环体。因此循环体也可能不会被执行。如果循环体被执行了,则循环后表达式也会执行,以便递增变量ifor 循环跟下面的while 循环是一样的:

let count = 10;
let i = 0;
while (i < count) {
  console.log(i);
  i++;
}

无法通过while 循环实现的逻辑,同样也无法使用for 循环实现。因此for 循环只是将循环相关的代码封装在了一起而已。

for 循环的初始化代码中,其实是可以不使用变量声明关键字的。不过,初始化定义的迭代器变量在循环执行完成后几乎不可能再用到了。因此,最清晰的写法是使用let 声明迭代器变量,这样就可以将这个变量的作用域限定在循环中。

初始化、条件表达式和循环后表达式都不是必需的。因此,下面这种写法可以创建一个无穷循环:

for (;;) { // 无穷循环
  doSomething();
}

如果只包含条件表达式,那么for 循环实际上就变成了while 循环:

let count = 10;
let i = 0;
for (; i < count; ) {
  console.log(i);
  i++;
}

这种多功能性使得for 语句在这门语言中使用非常广泛。

for-in 语句是一种严格的迭代语句,用于枚举对象中的非符号键属性,语法如下:

for (property in expression) statement

下面是一个例子:

for (const propName in window) {
  document.write(propName);
}

这个例子使用for-in 循环显示了BOM对象window 的所有属性。每次执行循环,都会给变量propName 赋予一个window 对象的属性作为值,直到window 的所有属性都被枚举一遍。与for 循环一样,这里控制语句中的const 也不是必需的。但为了确保这个局部变量不被修改,推荐使用const

ECMAScript中对象的属性是无序的,因此for-in 语句不能保证返回对象属性的顺序。换句话说,所有可枚举的属性都会返回一次,但返回的顺序可能会因浏览器而异。

如果for-in 循环要迭代的变量是nullundefined ,则不执行循环体。

for-of 语句是一种严格的迭代语句,用于遍历可迭代对象的元素,语法如下:

for (property of expression) statement

下面是示例:

for (const el of [2,4,6,8]) {
  document.write(el);
}

在这个例子中,我们使用for-of 语句显示了一个包含4个元素的数组中的所有元素。循环会一直持续到将所有元素都迭代完。与for 循环一样,这里控制语句中的const 也不是必需的。但为了确保这个局部变量不被修改,推荐使用const

for-of 循环会按照可迭代对象的next() 方法产生值的顺序迭代元素。关于可迭代对象,本书将在第7章详细介绍。

如果尝试迭代的变量不支持迭代,则for-of 语句会抛出错误。

注意  ES2018对for-of 语句进行了扩展,增加了for-await-of 循环,以支持生成期约(promise)的异步可迭代对象。相关内容将在附录A介绍。

标签语句用于给语句加标签,语法如下:

label: statement

下面是一个例子:

start: for (let i = 0; i < count; i++) {
  console.log(i);
}

在这个例子中,start 是一个标签,可以在后面通过breakcontinue 语句引用。标签语句的典型应用场景是嵌套循环。

breakcontinue 语句为执行循环代码提供了更严格的控制手段。其中,break 语句用于立即退出循环,强制执行循环后的下一条语句。而continue 语句也用于立即退出循环,但会再次从循环顶部开始执行。下面看一个例子:

let num = 0;

for (let i = 1; i < 10; i++) {
  if (i % 5 == 0) {
    break;
  }
  num++;
}

console.log(num); // 4

在上面的代码中,for 循环会将变量i 由1递增到10。而在循环体内,有一个if 语句用于检查i 能否被5整除(使用取模操作符)。如果是,则执行break 语句,退出循环。变量num 的初始值为0,表示循环在退出前执行了多少次。当break 语句执行后,下一行执行的代码是console.log(num) ,显示4。之所以循环执行了4次,是因为当i 等于5时,break 语句会导致循环退出,该次循环不会执行递增num 的代码。如果将break 换成continue ,则会出现不同的效果:

let num = 0;

for (let i = 1; i < 10; i++) {
  if (i % 5 == 0) {
    continue;
  }
  num++;
}

console.log(num); // 8

这一次,console.log 显示8,即循环被完整执行了8次。当i 等于5时,循环会在递增num 之前退出,但会执行下一次迭代,此时i 是6。然后,循环会一直执行到自然结束,即i 等于10。最终num 的值是8而不是9,是因为continue 语句导致它少递增了一次。

breakcontinue 都可以与标签语句一起使用,返回代码中特定的位置。这通常是在嵌套循环中,如下面的例子所示:

let num = 0;

outermost:
for (let i = 0; i < 10; i++) {
  for (let j = 0; j < 10; j++) {
    if (i == 5 && j == 5) {
      break outermost;
    }
    num++;
  }
}

console.log(num); // 55

在这个例子中,outermost 标签标识的是第一个for 语句。正常情况下,每个循环执行10次,意味着num++ 语句会执行100次,而循环结束时console.log 的结果应该是100。但是,break 语句带来了一个变数,即要退出到的标签。添加标签不仅让break 退出(使用变量j 的)内部循环,也会退出(使用变量i 的)的外部循环。当执行到ij 都等于5时,循环停止执行,此时num 的值是55。continue 语句也可以使用标签,如下面的例子所示:

let num = 0;

outermost:
for (let i = 0; i < 10; i++) {
  for (let j = 0; j < 10; j++) {
    if (i == 5 && j == 5) {
      continue outermost;
    }
    num++;
  }
}

console.log(num); // 95

这一次,continue 语句会强制循环继续执行,但不是继续执行内部循环,而是继续执行外部循环。当ij 都等于5时,会执行continue ,跳到外部循环继续执行,从而导致内部循环少执行5次,结果num 等于95。

组合使用标签语句和breakcontinue 能实现复杂的逻辑,但也容易出错。注意标签要使用描述性强的文本,而嵌套也不要太深。

with 语句的用途是将代码作用域设置为特定的对象,其语法是:

with (expression) statement;

使用with 语句的主要场景是针对一个对象反复操作,这时候将代码作用域设置为该对象能提供便利,如下面的例子所示:

let qs = location.search.substring(1);
let hostName = location.hostname;
let url = location.href;

上面代码中的每一行都用到了location 对象。如果使用with 语句,就可以少写一些代码:

with(location) {
  let qs = search.substring(1);
  let hostName = hostname;
  let url = href;
}

这里,with 语句用于连接location 对象。这意味着在这个语句内部,每个变量首先会被认为是一个局部变量。如果没有找到该局部变量,则会搜索location 对象,看它是否有一个同名的属性。如果有,则该变量会被求值为location 对象的属性。

严格模式不允许使用with 语句,否则会抛出错误。

警告  由于with 语句影响性能且难于调试其中的代码,通常不推荐在产品代码中使用with 语句。

switch 语句是与if 语句紧密相关的一种流控制语句,从其他语言借鉴而来。ECMAScript中switch 语句跟C语言中switch 语句的语法非常相似,如下所示:

switch (expression) {
  case value1:
    statement
    break;
  case value2:
    statement
    break;
  case value3:
    statement
    break;
  case value4:
    statement
    break;
  default:
    statement
}

这里的每个case (条件/分支)相当于:“如果表达式等于后面的值,则执行下面的语句。”break 关键字会导致代码执行跳出switch 语句。如果没有break ,则代码会继续匹配下一个条件。default 关键字用于在任何条件都没有满足时指定默认执行的语句(相当于else 语句)。

有了switch 语句,开发者就用不着写类似这样的代码了:

if (i == 25) {
  console.log("25");
} else if (i == 35) {
  console.log("35");
} else if (i == 45) {
  console.log("45");
} else {
  console.log("Other");
}

而是可以这样写:

switch (i) {
  case 25:
    console.log("25");
    break;
  case 35:
    console.log("35");
    break;
  case 45:
    console.log("45");
    break;
  default:
    console.log("Other");
}

为避免不必要的条件判断,最好给每个条件后面都加上break 语句。如果确实需要连续匹配几个条件,那么推荐写个注释表明是故意忽略了break ,如下所示:

switch (i) {
  case 25:
    /*跳过*/
  case 35:
    console.log("25 or 35");
    break;
  case 45:
    console.log("45");
    break;
  default:
    console.log("Other");
}

虽然switch 语句是从其他语言借鉴过来的,但ECMAScript为它赋予了一些独有的特性。首先,switch 语句可以用于所有数据类型(在很多语言中,它只能用于数值),因此可以使用字符串甚至对象。其次,条件的值不需要是常量,也可以是变量或表达式。看下面的例子:

switch ("hello world") {
  case "hello" + " world":
    console.log("Greeting was found.");
    break;
  case "goodbye":
    console.log("Closing was found.");
    break;
  default:
    console.log("Unexpected message was found.");
}

这个例子在switch 语句中使用了字符串。第一个条件实际上使用的是表达式,求值为两个字符串拼接后的结果。因为拼接后的结果等于switch 的参数,所以console.log 会输出"Greeting was found." 。能够在条件判断中使用表达式,就可以在判断中加入更多逻辑:

let num = 25;
switch (true) {
  case num < 0:
    console.log("Less than 0.");
    break;
  case num >= 0 && num <= 10:
    console.log("Between 0 and 10.");
    break;
  case num > 10 && num <= 20:
    console.log("Between 10 and 20.");
    break;
  default:
    console.log("More than 20.");
}

上面的代码首先在外部定义了变量num ,而传给switch 语句的参数之所以是true ,就是因为每个条件的表达式都会返回布尔值。条件的表达式分别被求值,直到有表达式返回true ;否则,就会一直跳到default 语句(这个例子正是如此)。

注意  switch 语句在比较每个条件的值时会使用全等操作符,因此不会强制转换数据类型(比如,字符串"10" 不等于数值10)。

函数对任何语言来说都是核心组件,因为它们可以封装语句,然后在任何地方、任何时间执行。ECMAScript中的函数使用function 关键字声明,后跟一组参数,然后是函数体。

注意  第10章会更详细地介绍函数。

以下是函数的基本语法:

function functionName(arg0, arg1,...,argN) {
  statements
}

下面是一个例子:

function sayHi(name, message) {
  console.log("Hello " + name + ", " + message);
}

可以通过函数名来调用函数,要传给函数的参数放在括号里(如果有多个参数,则用逗号隔开)。下面是调用函数sayHi() 的示例:

sayHi("Nicholas", "how are you today?");

调用这个函数的输出结果是"Hello Nicholas, how are you today?" 。参数namemessage 在函数内部作为字符串被拼接在了一起,最终通过console.log 输出到控制台。

ECMAScript中的函数不需要指定是否返回值。任何函数在任何时间都可以使用return 语句来返回函数的值,用法是后跟要返回的值。比如:

function sum(num1, num2) {
  return num1 + num2;
}

函数sum() 会将两个值相加并返回结果。注意,除了return 语句之外没有任何特殊声明表明该函数有返回值。然后就可以这样调用它:

const result = sum(5, 10);

要注意的是,只要碰到return 语句,函数就会立即停止执行并退出。因此,return 语句后面的代码不会被执行。比如:

function sum(num1, num2) {
  return num1 + num2;
  console.log("Hello world");  // 不会执行
}

在这个例子中,console.log 不会执行,因为它在return 语句后面。

一个函数里也可以有多个return 语句,像这样:

function diff(num1, num2) {
  if (num1 < num2) {
    return num2 - num1;
  } else {
    return num1 - num2;
  }
}

这个diff() 函数用于计算两个数值的差。如果第一个数值小于第二个,则用第二个减第一个;否则,就用第一个减第二个。代码中每个分支都有自己的return 语句,返回正确的差值。

return 语句也可以不带返回值。这时候,函数会立即停止执行并返回undefined 。这种用法最常用于提前终止函数执行,并不是为了返回值。比如在下面的例子中,console.log 不会执行:

function sayHi(name, message) {
  return;
  console.log("Hello " + name + ", " + message); // 不会执行
}

注意  最佳实践是函数要么返回值,要么不返回值。只在某个条件下返回值的函数会带来麻烦,尤其是调试时。

严格模式对函数也有一些限制:

如果违反上述规则,则会导致语法错误,代码也不会执行。

JavaScript的核心语言特性在ECMA-262中以伪语言ECMAScript的形式来定义。ECMAScript包含所有基本语法、操作符、数据类型和对象,能完成基本的计算任务,但没有提供获得输入和产生输出的机制。理解ECMAScript及其复杂的细节是完全理解浏览器中JavaScript的关键。下面总结一下ECMAScript中的基本元素。

ECMAScript中的函数与其他语言中的函数不一样。


第 4 章 变量、作用域与内存

本章内容

相比于其他语言,JavaScript中的变量可谓独树一帜。正如ECMA-262所规定的,JavaScript变量是松散类型的,而且变量不过就是特定时间点一个特定值的名称而已。由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。这样的变量很有意思,很强大,当然也有不少问题。本章会剖析错综复杂的变量。

ECMAScript变量可以包含两种不同类型的数据:原始值和引用值。原始值 (primitive value)就是最简单的数据,引用值 (reference value)则是由多个值构成的对象。

在把一个值赋给变量时,JavaScript引擎必须确定这个值是原始值还是引用值。上一章讨论了6种原始值:UndefinedNullBooleanNumberStringSymbol 。保存原始值的变量是按值 (by value)访问的,因为我们操作的就是存储在变量中的实际值。

引用值是保存在内存中的对象。与其他语言不同,JavaScript不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用 (reference)而非实际的对象本身。为此,保存引用值的变量是按引用 (by reference)访问的。

注意  在很多语言中,字符串是使用对象表示的,因此被认为是引用类型。ECMAScript打破了这个惯例。

原始值和引用值的定义方式很类似,都是创建一个变量,然后给它赋一个值。不过,在变量保存了这个值之后,可以对这个值做什么,则大有不同。对于引用值而言,可以随时添加、修改和删除其属性和方法。比如,看下面的例子:

let person = new Object();
person.name = "Nicholas";
console.log(person.name); // "Nicholas"

这里,首先创建了一个对象,并把它保存在变量person 中。然后,给这个对象添加了一个名为name 的属性,并给这个属性赋值了一个字符串"Nicholas" 。在此之后,就可以访问这个新属性,直到对象被销毁或属性被显式地删除。

原始值不能有属性,尽管尝试给原始值添加属性不会报错。比如:

let name = "Nicholas";
name.age = 27;
console.log(name.age);  // undefined

在此,代码想给字符串name 定义一个age 属性并给该属性赋值27。紧接着在下一行,属性不见了。记住,只有引用值可以动态添加后面可以使用的属性。

注意,原始类型的初始化可以只使用原始字面量形式。如果使用的是new 关键字,则JavaScript会创建一个Object 类型的实例,但其行为类似原始值。下面来看看这两种初始化方式的差异:

let name1 = "Nicholas";
let name2 = new String("Matt");
name1.age = 27;
name2.age = 26;
console.log(name1.age);    // undefined
console.log(name2.age);    // 26
console.log(typeof name1); // string
console.log(typeof name2); // object

除了存储方式不同,原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置。请看下面的例子:

let num1 = 5;
let num2 = num1;

这里,num1 包含数值5。当把num2 初始化为num1 时,num2 也会得到数值5。这个值跟存储在num1 中的5是完全独立的,因为它是那个值的副本。

这两个变量可以独立使用,互不干扰。这个过程如图4-1所示。

图 4-1

在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来,如下面的例子所示:

let obj1 = new Object();
let obj2 = obj1;
obj1.name = "Nicholas";
console.log(obj2.name); // "Nicholas"

在这个例子中,变量obj1 保存了一个新对象的实例。然后,这个值被复制到obj2 ,此时两个变量都指向了同一个对象。在给obj1 创建属性name 并赋值后,通过obj2 也可以访问这个属性,因为它们都指向同一个对象。图4-2展示了变量与堆内存中对象之间的关系。

图 4-2

ECMAScript中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。对很多开发者来说,这一块可能会不好理解,毕竟变量有按值和按引用访问,而传参则只有按值传递。

在按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用ECMAScript的话说,就是arguments 对象中的一个槽位)。在按引用传递参数时,值在内存中的位置会被保存在一个局部变量,这意味着对本地变量的修改会反映到函数外部。(这在ECMAScript中是不可能的。)来看下面这个例子:

function addTen(num) {
  num += 10;
  return num;
}

let count = 20;
let result = addTen(count);
console.log(count);  // 20,没有变化
console.log(result); // 30

这里,函数addTen() 有一个参数num ,它其实是一个局部变量。在调用时,变量count 作为参数传入。count 的值是20,这个值被复制到参数num 以便在addTen() 内部使用。在函数内部,参数num的值被加上了10,但这不会影响函数外部的原始变量count 。参数num 和变量count 互不干扰,它们只不过碰巧保存了一样的值。如果num 是按引用传递的,那么count 的值也会被修改为30。这个事实在使用数值这样的原始值时是非常明显的。但是,如果变量中传递的是对象,就没那么清楚了。比如,再看这个例子:

function setName(obj) {
  obj.name = "Nicholas";
}

let person = new Object();
setName(person);
console.log(person.name);  // "Nicholas"

这一次,我们创建了一个对象并把它保存在变量person 中。然后,这个对象被传给setName() 方法,并被复制到参数obj 中。在函数内部,objperson 都指向同一个对象。结果就是,即使对象是按值传进函数的,obj 也会通过引用访问对象。当函数内部给obj 设置了name 属性时,函数外部的对象也会反映这个变化,因为obj 指向的对象保存在全局作用域的堆内存上。很多开发者错误地认为,当在局部作用域中修改对象而变化反映到全局时,就意味着参数是按引用传递的。为证明对象是按值传递的,我们再来看看下面这个修改后的例子:

function setName(obj) {
  obj.name = "Nicholas";
  obj = new Object();
  obj.name = "Greg";
}

let person = new Object();
setName(person);
console.log(person.name);  // "Nicholas"

这个例子前后唯一的变化就是setName() 中多了两行代码,将obj 重新定义为一个有着不同name 的新对象。当person 传入setName() 时,其name 属性被设置为"Nicholas" 。然后变量obj 被设置为一个新对象且name 属性被设置为"Greg" 。如果person 是按引用传递的,那么person 应该自动将指针改为指向name"Greg" 的对象。可是,当我们再次访问person.name 时,它的值是"Nicholas" ,这表明函数中参数的值改变之后,原始的引用仍然没变。当obj 在函数内部被重写时,它变成了一个指向本地对象的指针。而那个本地对象在函数执行结束时就被销毁了。

注意  ECMAScript中函数的参数就是局部变量。

前一章提到的typeof 操作符最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一个变量是否为字符串、数值、布尔值或undefined 的最好方式。如果值是对象或null ,那么typeof 返回"object" ,如下面的例子所示:

let s = "Nicholas";
let b = true;
let i = 22;
let u;
let n = null;
let o = new Object();
console.log(typeof s); // string
console.log(typeof i); // number
console.log(typeof b); // boolean
console.log(typeof u); // undefined
console.log(typeof n); // object
console.log(typeof o); // object

typeof 虽然对原始值很有用,但它对引用值的用处不大。我们通常不关心一个值是不是对象,而是想知道它是什么类型的对象。为了解决这个问题,ECMAScript提供了instanceof 操作符,语法如下:

result = variable instanceof constructor

如果变量是给定引用类型(由其原型链决定,将在第8章详细介绍)的实例,则instanceof 操作符返回true 。来看下面的例子:

console.log(person instanceof Object);  // 变量person是Object吗?
console.log(colors instanceof Array);   // 变量colors是Array吗?
console.log(pattern instanceof RegExp); // 变量pattern是RegExp吗?

按照定义,所有引用值都是Object 的实例,因此通过instanceof 操作符检测任何引用值和Object 构造函数都会返回true 。类似地,如果用instanceof 检测原始值,则始终会返回false ,因为原始值不是对象。

注意  typeof 操作符在用于检测函数时也会返回"function" 。当在Safari(直到Safari 5)和Chrome(直到Chrome 7)中用于检测正则表达式时,由于实现细节的原因,typeof 也会返回"function" 。ECMA-262规定,任何实现内部[[Call]] 方法的对象都应该在typeof 检测时返回"function" 。因为上述浏览器中的正则表达式实现了这个方法,所以typeof 对正则表达式也返回"function" 。在IE和Firefox中,typeof 对正则表达式返回"object"

执行上下文(以下简称“上下文”)的概念在JavaScript中是颇为重要的。变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象 (variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。

全局上下文是最外层的上下文。根据ECMAScript实现的宿主环境,表示全局上下文的对象可能不一样。在浏览器中,全局上下文就是我们常说的window 对象(第12章会详细介绍),因此所有通过var 定义的全局变量和函数都会成为window 对象的属性和方法。使用letconst 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。

每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript程序的执行流就是通过这个上下文栈进行控制的。

上下文中的代码在执行的时候,会创建变量对象的一个作用域链 (scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象 (activation object)用作变量对象。活动对象最初只有一个定义变量:arguments 。(全局上下文中没有这个变量。)作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象。

代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符。(如果没有找到标识符,那么通常会报错。)

看一看下面这个例子:

var color = "blue";

function changeColor() {
  if (color === "blue") {
    color = "red";
  } else {
    color = "blue";
  }
}

changeColor();

对这个例子而言,函数changeColor() 的作用域链包含两个对象:一个是它自己的变量对象(就是定义arguments 对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量color ,就是因为可以在作用域链中找到它。

此外,局部作用域中定义的变量可用于在局部上下文中替换全局变量。看一看下面这个例子:

var color = "blue";

function changeColor() {
  let anotherColor = "red";

  function swapColors() {
    let tempColor = anotherColor;
    anotherColor = color;
    color = tempColor;

    // 这里可以访问color、anotherColor和tempColor
  }

  // 这里可以访问color和anotherColor,但访问不到tempColor
  swapColors();
}

// 这里只能访问color
changeColor();

以上代码涉及3个上下文:全局上下文、changeColor() 的局部上下文和swapColors() 的局部上下文。全局上下文中有一个变量color 和一个函数changeColor()changeColor() 的局部上下文中有一个变量anotherColor 和一个函数swapColors() ,但在这里可以访问全局上下文中的变量colorswapColors() 的局部上下文中有一个变量tempColor ,只能在这个上下文中访问到。全局上下文和changeColor() 的局部上下文都无法访问到tempColor 。而在swapColors() 中则可以访问另外两个上下文中的变量,因为它们都是父上下文。图4-3展示了前面这个例子的作用域链。

图 4-3

图4-3中的矩形表示不同的上下文。内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西。上下文之间的连接是线性的、有序的。每个上下文都可以到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。swapColors() 局部上下文的作用域链中有3个对象:swapColors() 的变量对象、changeColor() 的变量对象和全局变量对象。swapColors() 的局部上下文首先从自己的变量对象开始搜索变量和函数,搜不到就去搜索上一级变量对象。changeColor() 上下文的作用域链中只有2个对象:它自己的变量对象和全局变量对象。因此,它不能访问swapColors() 的上下文。

注意  函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则。

虽然执行上下文主要有全局上下文和函数上下文两种(eval() 调用内部存在第三种上下文),但有其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:

这两种情况下,都会在作用域链前端添加一个变量对象。对with 语句来说,会向作用域链前端添加指定的对象;对catch 语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。看下面的例子:

function buildUrl() {
  let qs = "?debug=true";

  with(location){
    let url = href + qs;
  }

  return url;
}

这里,with 语句将location 对象作为上下文,因此location 会被添加到作用域链前端。buildUrl() 函数中定义了一个变量qs 。当with 语句中的代码引用变量href 时,实际上引用的是location.href ,也就是自己变量对象的属性。在引用qs 时,引用的则是定义在buildUrl() 中的那个变量,它定义在函数上下文的变量对象上。而在with 语句中使用var 声明的变量url 会成为函数上下文的一部分,可以作为函数的值被返回;但像这里使用let 声明的变量url ,因为被限制在块级作用域(稍后介绍),所以在with 块之外没有定义。

注意  IE的实现在IE8之前是有偏差的,即它们会将catch 语句中捕获的错误添加到执行上下文的变量对象上,而不是catch 语句的变量对象上,导致在catch 块外部都可以访问到错误。IE9纠正了这个问题。

ES6之后,JavaScript的变量声明经历了翻天覆地的变化。直到ECMAScript 5.1,var 都是声明变量的唯一关键字。ES6不仅增加了letconst 两个关键字,而且还让这两个关键字压倒性地超越var 成为首选。

  1. 使用var 的函数作用域声明

    在使用var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。在with 语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文,如下面的例子所示:

    function add(num1, num2) {
      var sum = num1 + num2;
      return sum;
    }
    
    let result = add(10, 20); // 30
    console.log(sum);         // 报错:sum在这里不是有效变量
    
    

    这里,函数add() 定义了一个局部变量sum ,保存加法操作的结果。这个值作为函数的值被返回,但变量sum 在函数外部是访问不到的。如果省略上面例子中的关键字var ,那么sumadd() 被调用之后就变成可以访问的了,如下所示:

    function add(num1, num2) {
      sum = num1 + num2;
      return sum;
    }
    
    let result = add(10, 20); // 30
    console.log(sum);         // 30
    
    

    这一次,变量sum 被用加法操作的结果初始化时并没有使用var 声明。在调用add() 之后,sum 被添加到了全局上下文,在函数退出之后依然存在,从而在后面可以访问到。

    注意  未经声明而初始化变量是JavaScript编程中一个非常常见的错误,会导致很多问题。为此,读者在初始化变量之前一定要先声明变量。在严格模式下,未经声明就初始化变量会报错。

    var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”(hoisting)。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。可是在实践中,提升也会导致合法却奇怪的现象,即在变量声明之前使用变量。下面的例子展示了在全局作用域中两段等价的代码:

    var name = "Jake";
    
    // 等价于:
    
    name = 'Jake';
    var name;
    
    

    下面是两个等价的函数:

    function fn1() {
      var name = 'Jake';
    }
    
    // 等价于:
    function fn2() {
      var name;
      name = 'Jake';
    }
    
    

    通过在声明之前打印变量,可以验证变量会被提升。声明的提升意味着会输出undefined 而不是Reference Error

    console.log(name); // undefined
    var name = 'Jake';
    
    function() {
      console.log(name); // undefined
      var name = 'Jake';
    }
    
    

     

  2. 使用let 的块级作用域声明

    ES6新增的let 关键字跟var 很相似,但它的作用域是块级的,这也是JavaScript中的新概念。块级作用域由最近的一对包含花括号{} 界定。换句话说,if 块、while 块、function 块,甚至连单独的块也是let 声明变量的作用域。

    if (true) {
      let a;
    }
    console.log(a); // ReferenceError: a没有定义
    
    while (true) {
      let b;
    }
    console.log(b); // ReferenceError: b没有定义
    
    function foo() {
      let c;
    }
    console.log(c); // ReferenceError: c没有定义
                    // 这没什么可奇怪的
                    // var声明也会导致报错
    
    // 这不是对象字面量,而是一个独立的块
    // JavaScript解释器会根据其中内容识别出它来
    {
      let d;
    }
    console.log(d); // ReferenceError: d没有定义
    
    

    letvar 的另一个不同之处是在同一作用域内不能声明两次。重复的var 声明会被忽略,而重复的let 声明会抛出SyntaxError

    var a;
    var a;
    // 不会报错
    
    {
      let b;
      let b;
    }
    // SyntaxError: 标识符b已经声明过了
    
    

    let 的行为非常适合在循环中声明迭代变量。使用var 声明的迭代变量会泄漏到循环外部,这种情况应该避免。来看下面两个例子:

    for (var i = 0; i < 10; ++i) {}
    console.log(i); // 10
    
    for (let j = 0; j < 10; ++j) {}
    console.log(j); // ReferenceError: j没有定义
    
    

    严格来讲,let 在JavaScript运行时中也会被提升,但由于“暂时性死区”(temporal dead zone)的缘故,实际上不能在声明之前使用let 变量。因此,从写JavaScript代码的角度说,let 的提升跟var 是不一样的。
     

  3. 使用const 的常量声明

    除了let ,ES6同时还增加了const 关键字。使用const 声明的变量必须同时初始化为某个值。一经声明,在其生命周期的任何时候都不能再重新赋予新值。

    const a; // SyntaxError: 常量声明时没有初始化
    
    const b = 3;
    console.log(b); // 3
    b = 4; // TypeError: 给常量赋值
    
    

    const 除了要遵循以上规则,其他方面与let 声明是一样的:

    if (true) {
      const a = 0;
    }
    console.log(a); // ReferenceError: a没有定义
    
    while (true) {
      const b = 1;
    }
    console.log(b); // ReferenceError: b没有定义
    
    function foo() {
      const c = 2;
    }
    console.log(c); // ReferenceError: c没有定义
    
    {
      const d = 3;
    }
    console.log(d); // ReferenceError: d没有定义
    
    

    const 声明只应用到顶级原语或者对象。换句话说,赋值为对象的const 变量不能再被重新赋值为其他引用值,但对象的键则不受限制。

    const o1 = {};
    o1 = {}; // TypeError: 给常量赋值
    
    const o2 = {};
    o2.name = 'Jake';
    console.log(o2.name); // 'Jake'
    
    

    如果想让整个对象都不能修改,可以使用Object.freeze() ,这样再给属性赋值时虽然不会报错,但会静默失败:

    const o3 = Object.freeze({});
    o3.name = 'Jake';
    console.log(o3.name); // undefined
    
    

    由于const 声明暗示变量的值是单一类型且不可修改,JavaScript运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找。谷歌的V8引擎就执行这种优化。

    注意  开发实践表明,如果开发流程并不会因此而受很大影响,就应该尽可能地多使用const 声明,除非确实需要一个将来会重新赋值的变量。这样可以从根本上保证提前发现重新赋值导致的bug。

     

  4. 标识符查找

    当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。搜索开始于作用域链前端,以给定的名称搜索对应的标识符。如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。如果仍然没有找到标识符,则说明其未声明。

    为更好地说明标识符查找,我们来看一个例子:

    var color = 'blue';
    
    function getColor() {
      return color;
    }
    
    console.log(getColor()); // 'blue'
    
    

    在这个例子中,调用函数getColor() 时会引用变量color 。为确定color 的值会进行两步搜索。第一步,搜索getColor() 的变量对象,查找名为color 的标识符。结果没找到,于是继续搜索下一个变量对象(来自全局上下文),然后就找到了名为color 的标识符。因为全局变量对象上有color 的定义,所以搜索结束。

    对这个搜索过程而言,引用局部变量会让搜索自动停止,而不继续搜索下一级变量对象。也就是说,如果局部上下文中有一个同名的标识符,那就不能在该上下文中引用父上下文中的同名标识符,如下面的例子所示:

    var color = 'blue';
    
    function getColor() {
      let color = 'red';
      return color;
    }
    
    console.log(getColor()); // 'red'
    
    

    使用块级作用域声明并不会改变搜索流程,但可以给词法层级添加额外的层次:

    var color = 'blue';
    
    function getColor() {
      let color = 'red';
      {
        let color = 'green';
        return color;
      }
    }
    
    console.log(getColor()); // 'green'
    
    

    在这个修改后的例子中,getColor() 内部声明了一个名为color 的局部变量。在调用这个函数时,变量会被声明。在执行到函数返回语句时,代码引用了变量color 。于是开始在局部上下文中搜索这个标识符,结果找到了值为'green' 的变量color 。因为变量已找到,搜索随即停止,所以就使用这个局部变量。这意味着函数会返回'green' 。在局部变量color 声明之后的任何代码都无法访问全局变量color ,除非使用完全限定的写法window.color

    注意  标识符查找并非没有代价。访问局部变量比访问全局变量要快,因为不用切换作用域。不过,JavaScript引擎在优化标识符查找上做了很多工作,将来这个差异可能就微不足道了。

JavaScript是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在C和C++等语言中,跟踪内存使用对开发者来说是个很大的负担,也是很多问题的来源。JavaScript为开发者卸下了这个负担,通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。

我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。此时,栈(或堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用。这种情况下显然不再需要局部变量了,但并不是所有时候都会这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存。如何标记未使用的变量也许有不同的实现方式。不过,在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数。

JavaScript最常用的垃圾回收策略是标记清理 (mark-and-sweep)。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。

给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现并不重要,关键是策略。

垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理 ,销毁带标记的所有值并收回它们的内存。

到了2008年,IE、Firefox、Opera、Chrome和Safari都在自己的JavaScript实现中采用标记清理(或其变体),只是在运行垃圾回收的频率上有所差异。

另一种没那么常用的垃圾回收策略是引用计数 (reference counting)。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。

引用计数最早由Netscape Navigator 3.0采用,但很快就遇到了严重的问题:循环引用。所谓循环引用 ,就是对象A有一个指针指向对象B,而对象B也引用了对象A。比如:

function problem() {
  let objectA = new Object();
  let objectB = new Object();

  objectA.someOtherObject = objectB;
  objectB.anotherObject = objectA;
}

在这个例子中,objectAobjectB 通过各自的属性相互引用,意味着它们的引用数都是2。在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下,objectAobjectB 在函数结束后还会存在,因为它们的引用数永远不会变成0。如果函数被多次调用,则会导致大量内存永远不会被释放。为此,Netscape在4.0版放弃了引用计数,转而采用标记清理。事实上,引用计数策略的问题还不止于此。

在IE8及更早版本的IE中,并非所有对象都是原生JavaScript对象。BOM和DOM中的对象是C++实现的组件对象模型(COM,Component Object Model)对象,而COM对象使用引用计数实现垃圾回收。因此,即使这些版本IE的JavaScript引擎使用标记清理,JavaScript存取的COM对象依旧使用引用计数。换句话说,只要涉及COM对象,就无法避开循环引用问题。下面这个简单的例子展示了涉及COM对象的循环引用问题:

let element = document.getElementById("some_element");
let myObject = new Object();
myObject.element = element;
element.someObject = myObject;

这个例子在一个DOM对象(element )和一个原生JavaScript对象(myObject )之间制造了循环引用。myObject 变量有一个名为element 的属性指向DOM对象element ,而element 对象有一个someObject 属性指回myObject 对象。由于存在循环引用,因此DOM元素的内存永远不会被回收,即使它已经被从页面上删除了也是如此。

为避免类似的循环引用问题,应该在确保不使用的情况下切断原生JavaScript对象与DOM元素之间的连接。比如,通过以下代码可以清除前面的例子中建立的循环引用:

myObject.element = null;
element.someObject = null;

把变量设置为null 实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。

为了补救这一点,IE9把BOM和DOM对象都改成了JavaScript对象,这同时也避免了由于存在两套垃圾回收算法而导致的问题,还消除了常见的内存泄漏现象。

注意  还有其他一些可能导致循环引用的情形,本书后面会介绍到。

垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。

现代垃圾回收程序会基于对JavaScript运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。比如,根据V8团队2016年的一篇博文的说法:“在一次完整的垃圾回收之后,V8的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃圾回收。”

由于调度垃圾回收程序方面的问题会导致性能下降,IE曾饱受诟病。它的策略是根据分配数,比如分配了256个变量、4096个对象/数组字面量和数组槽位(slot),或者64KB字符串。只要满足其中某个条件,垃圾回收程序就会运行。这样实现的问题在于,分配那么多变量的脚本,很可能在其整个生命周期内始终需要那么多变量,结果就会导致垃圾回收程序过于频繁地运行。由于对性能的严重影响,IE7最终更新了垃圾回收程序。

IE7发布后,JavaScript引擎的垃圾回收程序被调优为动态改变分配变量、字面量或数组槽位等会触发垃圾回收的阈值。IE7的起始阈值都与IE6的相同。如果垃圾回收程序回收的内存不到已分配的15%,这些变量、字面量或数组槽位的阈值就会翻倍。如果有一次回收的内存达到已分配的85%,则阈值重置为默认值。这么一个简单的修改,极大地提升了重度依赖JavaScript的网页在浏览器中的性能。

警告  在某些浏览器中是有可能(但不推荐)主动触发垃圾回收的。在IE中,window.CollectGarbage() 方法会立即触发垃圾回收。在Opera 7及更高版本中,调用window.opera.collect() 也会启动垃圾回收程序。

在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。不过,JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这更多出于安全考虑而不是别的,就是为了避免运行大量JavaScript的网页耗尽系统内存而导致操作系统崩溃。这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量。

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为null ,从而释放其引用。这也可以叫作解除引用 。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用,如下面的例子所示:

function createPerson(name){
  let localPerson = new Object();
  localPerson.name = name;
  return localPerson;
}

let globalPerson = createPerson("Nicholas");

// 解除globalPerson对值的引用

globalPerson = null;

在上面的代码中,变量globalPerson 保存着createPerson() 函数调用返回的值。在createPerson() 内部,localPerson 创建了一个对象并给它添加了一个name 属性。然后,localPerson 作为函数值被返回,并被赋值给globalPersonlocalPersoncreatePerson() 执行完成超出上下文后会自动被解除引用,不需要显式处理。但globalPerson 是一个全局变量,应该在不再需要时手动解除其引用,最后一行就是这么做的。

不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。

  1. 通过constlet 声明提升性能

    ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为constlet 都以块(而非函数)为作用域,所以相比于使用var ,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。
     

  2. 隐藏类和删除操作

    根据JavaScript所在的运行环境,有时候需要根据浏览器使用的JavaScript引擎来采取不同的性能优化策略。截至2017年,Chrome是最流行的浏览器,使用V8 JavaScript引擎。V8在将解释后的JavaScript代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很重要。

    运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定总能够做到。比如下面的代码:

    function Article() {
      this.title = 'Inauguration Ceremony Features Kazoo Band';
    }
    
    let a1 = new Article();
    let a2 = new Article();
    
    

    V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。假设之后又添加了下面这行代码:

    a2.author = 'Jake';
    
    

    此时两个Article 实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有可能对性能产生明显影响。

    当然,解决方案就是避免JavaScript的“先创建再补充”(ready-fire-aim)式的动态属性赋值,并在构造函数中一次性声明所有属性,如下所示:

    function Article(opt_author) {
      this.title = 'Inauguration Ceremony Features Kazoo Band';
      this.author = opt_author;
    }
    
    let a1 = new Article();
    let a2 = new Article('Jake');
    
    

    这样,两个实例基本上就一样了(不考虑hasOwnProperty 的返回值),因此可以共享一个隐藏类,从而带来潜在的性能提升。不过要记住,使用delete 关键字会导致生成相同的隐藏类片段。看一下这个例子:

    function Article() {
      this.title = 'Inauguration Ceremony Features Kazoo Band';
      this.author = 'Jake';
    }
    
    let a1 = new Article();
    let a2 = new Article();
    
    delete a1.author;
    
    

    在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属性与动态添加属性导致的后果一样。最佳实践是把不想要的属性设置为null 。这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果。比如:

    function Article() {
      this.title = 'Inauguration Ceremony Features Kazoo Band';
      this.author = 'Jake';
    }
    
    let a1 = new Article();
    let a2 = new Article();
    
    a1.author = null;
    
    

     

  3. 内存泄漏

    写得不好的JavaScript可能出现难以察觉且有害的内存泄漏问题。在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。JavaScript中的内存泄漏大部分是由不合理的引用导致的。

    意外声明全局变量是最常见但也最容易修复的内存泄漏问题。下面的代码没有使用任何关键字声明变量:

    function setName() {
      name = 'Jake';
    }
    
    

    此时,解释器会把变量name 当作window 的属性来创建(相当于window.name = 'Jake' )。可想而知,在window 对象上创建的属性,只要window 本身不被清理就不会消失。这个问题很容易解决,只要在变量声明前头加上varletconst 关键字即可,这样变量就会在函数执行完毕后离开作用域。

    定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:

    let name = 'Jake';
    setInterval(() => {
      console.log(name);
    }, 100);
    
    

    只要定时器一直运行,回调函数中引用的name 就会一直占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。

    使用JavaScript闭包很容易在不知不觉间造成内存泄漏。请看下面的例子:

    let outer = function() {
      let name = 'Jake';
      return function() {
        return name;
      };
    };
    
    

    调用outer() 会导致分配给name 的内存被泄漏。以上代码执行后创建了一个内部闭包,只要返回的函数存在就不能清理name ,因为闭包一直在引用着它。假如name 的内容很大(不止是一个小字符串),那可能就是个大问题了。


     

  4. 静态分配与对象池

    为了提升JavaScript性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。

    浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度。如果有很多对象被初始化,然后一下子又都超出了作用域,那么浏览器就会采用更激进的方式调度垃圾回收程序运行,这样当然会影响性能。看一看下面的例子,这是一个计算二维矢量加法的函数:

    function addVector(a, b) {
      let resultant = new Vector();
      resultant.x = a.x + b.x;
      resultant.y = a.y + b.y;
      return resultant;
    }
    
    

    调用这个函数时,会在堆上创建一个新对象,然后修改它,最后再把它返回给调用者。如果这个矢量对象的生命周期很短,那么它会很快失去所有对它的引用,成为可以被回收的值。假如这个矢量加法函数频繁被调用,那么垃圾回收调度程序会发现这里对象更替的速度很快,从而会更频繁地安排垃圾回收。

    该问题的解决方案是不要动态创建矢量对象,比如可以修改上面的函数,让它使用一个已有的矢量对象:

    function addVector(a, b, resultant) {
      resultant.x = a.x + b.x;
      resultant.y = a.y + b.y;
      return resultant;
    }
    
    

    当然,这需要在其他地方实例化矢量参数resultant ,但这个函数的行为没有变。那么在哪里创建矢量可以不让垃圾回收调度程序盯上呢?

    一个策略是使用对象池。在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行。下面是一个对象池的伪实现:

    // vectorPool是已有的对象池
    let v1 = vectorPool.allocate();
    let v2 = vectorPool.allocate();
    let v3 = vectorPool.allocate();
    
    v1.x = 10;
    v1.y = 5;
    v2.x = -3;
    v2.y = -6;
    
    addVector(v1, v2, v3);
    
    console.log([v3.x, v3.y]); // [7, -1]
    
    vectorPool.free(v1);
    vectorPool.free(v2);
    vectorPool.free(v3);
    
    // 如果对象有属性引用了其他对象
    // 则这里也需要把这些属性设置为null
    v1 = null;
    v2 = null;
    v3 = null;
    
    

    如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。这个对象池必须使用某种结构维护所有对象,数组是比较好的选择。不过,使用数组来实现,必须留意不要招致额外的垃圾回收。比如下面这个例子:

    let vectorList = new Array(100);
    let vector = new Vector();
    vectorList.push(vector);
    
    

    由于JavaScript数组的大小是动态可变的,引擎会删除大小为100的数组,再创建一个新的大小为200的数组。垃圾回收程序会看到这个删除操作,说不定因此很快就会跑来收一次垃圾。要避免这种动态分配操作,可以在初始化时就创建一个大小够用的数组,从而避免上述先删除再创建的操作。不过,必须事先想好这个数组有多大。

    注意  静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不用考虑。

JavaScript变量可以保存两种类型的值:原始值和引用值。原始值可能是以下6种原始数据类型之一:UndefinedNullBooleanNumberStringSymbol 。原始值和引用值有以下特点。

任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。执行上下文可以总结如下。

JavaScript是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。JavaScript的垃圾回收程序可以总结如下。


第 5 章 基本引用类型

本章内容

引用值(或者对象)是某个特定引用类型 的实例。在ECMAScript中,引用类型是把数据和功能组织到一起的结构,经常被人错误地称作“类”。虽然从技术上讲JavaScript是一门面向对象语言,但ECMAScript缺少传统的面向对象编程语言所具备的某些基本结构,包括类和接口。引用类型有时候也被称为对象定义 ,因为它们描述了自己的对象应有的属性和方法。

注意  引用类型虽然有点像类,但跟类并不是一个概念。为避免混淆,本章后面不会使用术语“类”。

对象被认为是某个特定引用类型的实例 。新对象通过使用new 操作符后跟一个构造函数 (constructor)来创建。构造函数就是用来创建新对象的函数,比如下面这行代码:

let now = new Date();

这行代码创建了引用类型Date 的一个新实例,并将它保存在变量now 中。Date() 在这里就是构造函数,它负责创建一个只有默认属性和方法的简单对象。ECMAScript提供了很多像Date 这样的原生引用类型,帮助开发者实现常见的任务。

注意  函数也是一种引用类型,但有关函数的内容太多了,一章放不下,所以本书专门用第10章来介绍函数。

ECMAScript的Date 类型参考了Java早期版本中的java.util.Date 。为此,Date 类型将日期保存为自协调世界时(UTC,Universal Time Coordinated)时间1970年1月1日午夜(零时)至今所经过的毫秒数。使用这种存储格式,Date 类型可以精确表示1970年1月1日之前及之后285 616年的日期。

要创建日期对象,就使用new 操作符来调用Date 构造函数:

let now = new Date();

在不给Date 构造函数传参数的情况下,创建的对象将保存当前日期和时间。要基于其他日期和时间创建日期对象,必须传入其毫秒表示(UNIX纪元1970年1月1日午夜之后的毫秒数)。ECMAScript为此提供了两个辅助方法:Date.parse()Date.UTC()

Date.parse() 方法接收一个表示日期的字符串参数,尝试将这个字符串转换为表示该日期的毫秒数。ECMA-262第5版定义了Date.parse() 应该支持的日期格式,填充了第3版遗留的空白。所有实现都必须支持下列日期格式:

比如,要创建一个表示“2019年5月23日”的日期对象,可以使用以下代码:

let someDate = new Date(Date.parse("May 23, 2019"));

如果传给Date.parse() 的字符串并不表示日期,则该方法会返回NaN 。如果直接把表示日期的字符串传给Date 构造函数,那么Date 会在后台调用Date.parse() 。换句话说,下面这行代码跟前面那行代码是等价的:

let someDate = new Date("May 23, 2019");

这两行代码得到的日期对象相同。

注意  不同的浏览器对Date 类型的实现有很多问题。比如,很多浏览器会选择用当前日期替代越界的日期,因此有些浏览器会将"January 32, 2019" 解释为"February 1, 2019" 。Opera则会插入当前月的当前日,返回"January 当前日, 2019" 。就是说,如果是在9月21日运行代码,会返回"January 21, 2019"

Date.UTC() 方法也返回日期的毫秒表示,但使用的是跟Date.parse() 不同的信息来生成这个值。传给Date.UTC() 的参数是年、零起点月数(1月是0,2月是1,以此类推)、日(1~31)、时(0~23)、分、秒和毫秒。这些参数中,只有前两个(年和月)是必需的。如果不提供日,那么默认为1日。其他参数的默认值都是0。下面是使用Date.UTC() 的两个例子:

// GMT时间2000年1月1日零点
let y2k = new Date(Date.UTC(2000, 0));

// GMT时间2005年5月5日下午5点55分55秒
let allFives = new Date(Date.UTC(2005, 4, 5, 17, 55, 55));

这个例子创建了两个日期 。第一个日期是2000年1月1日零点(GMT),2000 代表年,0 代表月(1月)。因为没有其他参数(日取1 ,其他取0 ),所以结果就是该月第1天零点。第二个日期表示2005年5月5日下午5点55分55秒(GMT)。虽然日期里面涉及的都是5,但月数必须用4 ,因为月数是零起点的。小时也必须是17,因为这里采用的是24小时制,即取值范围是0~23。其他参数就都很直观了。

Date.parse() 一样,Date.UTC() 也会被Date 构造函数隐式调用,但有一个区别:这种情况下创建的是本地日期,不是GMT日期。不过Date 构造函数跟Date.UTC() 接收的参数是一样的。因此,如果第一个参数是数值,则构造函数假设它是日期中的年,第二个参数就是月,以此类推。前面的例子也可以这样来写:

// 本地时间2000年1月1日零点
let y2k = new Date(2000, 0);

// 本地时间2005年5月5日下午5点55分55秒
let allFives = new Date(2005, 4, 5, 17, 55, 55);

以上代码创建了与前面例子中相同的两个日期,但这次的两个日期是(由于系统设置决定的)本地时区的日期。

ECMAScript还提供了Date.now() 方法,返回表示方法执行时日期和时间的毫秒数。这个方法可以方便地用在代码分析中:

// 起始时间
let start = Date.now();

// 调用函数
doSomething();

// 结束时间
let stop = Date.now(),
result = stop - start;

与其他类型一样,Date 类型重写了toLocaleString()toString()valueOf() 方法。但与其他类型不同,重写后这些方法的返回值不一样。Date 类型的toLocaleString() 方法返回与浏览器运行的本地环境一致的日期和时间。这通常意味着格式中包含针对时间的AM(上午)或PM(下午),但不包含时区信息(具体格式可能因浏览器而不同)。toString() 方法通常返回带时区信息的日期和时间,而时间也是以24小时制(0~23)表示的。下面给出了toLocaleString()toString() 返回的2019年2月1日零点的示例(地区为"en-US" 的PST,即Pacific Standard Time,太平洋标准时间):

toLocaleString() - 2/1/2019 12:00:00 AM

toString() - Thu Feb 1 2019 00:00:00 GMT-0800 (Pacific Standard Time)

现代浏览器在这两个方法的输出上已经趋于一致。在比较老的浏览器上,每个方法返回的结果可能在每个浏览器上都是不同的。这些差异意味着toLocaleString()toString() 可能只对调试有用,不能用于显示。

Date 类型的valueOf() 方法根本就不返回字符串,这个方法被重写后返回的是日期的毫秒表示。因此,操作符(如小于号和大于号)可以直接使用它返回的值。比如下面的例子:

let date1 = new Date(2019, 0, 1);    // 2019年1月1日
let date2 = new Date(2019, 1, 1);    // 2019年2月1日

console.log(date1 < date2); // true
console.log(date1 > date2); // false

日期2019年1月1日在2019年2月1日之前,所以说前者小于后者没问题。因为2019年1月1日的毫秒表示小于2019年2月1日的毫秒表示,所以用小于号比较这两个日期时会返回true 。这也是确保日期先后的一个简单方式。

Date 类型有几个专门用于格式化日期的方法,它们都会返回字符串:

这些方法的输出与toLocaleString()toString() 一样,会因浏览器而异。因此不能用于在用户界面上一致地显示日期。

注意  还有一个方法叫toGMTString() ,这个方法跟toUTCString() 是一样的,目的是为了向后兼容。不过,规范建议新代码使用toUTCString()

Date 类型剩下的方法(见下表)直接涉及取得或设置日期值的特定部分。注意表中“UTC日期”,指的是没有时区偏移(将日期转换为GMT)时的日期。

方法

说明

getTime()

返回日期的毫秒表示;与valueOf() 相同

setTime(milliseconds )

设置日期的毫秒表示,从而修改整个日期

getFullYear()

返回4位数年(即2019而不是19)

getUTCFullYear()

返回UTC日期的4位数年

setFullYear(year )

设置日期的年( year 必须是4位数)

setUTCFullYear(year )

设置UTC日期的年( year 必须是4位数)

getMonth()

返回日期的月(0表示1月,11表示12月)

getUTCMonth()

返回UTC日期的月(0表示1月,11表示12月)

setMonth(month )

设置日期的月( month 为大于0的数值,大于11加年)

setUTCMonth(month )

设置UTC日期的月( month 为大于0的数值,大于11加年)

getDate()

返回日期中的日(1~31)

getUTCDate()

返回UTC日期中的日(1~31)

setDate(date )

设置日期中的日(如果 date 大于该月天数,则加月)

setUTCDate(date )

设置UTC日期中的日(如果 date 大于该月天数,则加月)

getDay()

返回日期中表示周几的数值(0表示周日,6表示周六)

getUTCDay()

返回UTC日期中表示周几的数值(0表示周日,6表示周六)

getHours()

返回日期中的时(0~23)

getUTCHours()

返回UTC日期中的时(0~23)

setHours(hours )

设置日期中的时(如果 hours 大于23,则加日)

setUTCHours(hours )

设置UTC日期中的时(如果 hours 大于23,则加日)

getMinutes()

返回日期中的分(0~59)

getUTCMinutes()

返回UTC日期中的分(0~59)

setMinutes(minutes )

设置日期中的分(如果 minutes 大于59,则加时)

setUTCMinutes(minutes )

设置UTC日期中的分(如果 minutes 大于59,则加时)

getSeconds()

返回日期中的秒(0~59)

getUTCSeconds()

返回UTC日期中的秒(0~59)

setSeconds(seconds )

设置日期中的秒(如果 seconds 大于59,则加分)

setUTCSeconds(seconds )

设置UTC日期中的秒(如果 seconds 大于59,则加分)

getMilliseconds()

返回日期中的毫秒

getUTCMilliseconds()

返回UTC日期中的毫秒

setMilliseconds(milliseconds )

设置日期中的毫秒

setUTCMilliseconds(milliseconds )

设置UTC日期中的毫秒

getTimezoneOffset()

返回以分钟计的UTC与本地时区的偏移量(如美国EST即“东部标准时间”返回300,进入夏令时的地区可能有所差异)

ECMAScript通过RegExp 类型支持正则表达式。正则表达式使用类似Perl的简洁语法来创建:

let expression = /pattern/flags;

这个正则表达式的pattern (模式)可以是任何简单或复杂的正则表达式,包括字符类、限定符、分组、向前查找和反向引用。每个正则表达式可以带零个或多个flags (标记),用于控制正则表达式的行为。下面给出了表示匹配模式的标记。

使用不同模式和标记可以创建出各种正则表达式,比如:

// 匹配字符串中的所有"at"
let pattern1 = /at/g;

// 匹配第一个"bat"或"cat",忽略大小写
let pattern2 = /[bc]at/i;

// 匹配所有以"at"结尾的三字符组合,忽略大小写
let pattern3 = /.at/gi;

与其他语言中的正则表达式类似,所有元字符 在模式中也必须转义,包括:

( [ { \ ^ $ | ) ] } ? * + .

元字符在正则表达式中都有一种或多种特殊功能,所以要匹配上面这些字符本身,就必须使用反斜杠来转义。下面是几个例子:

// 匹配第一个"bat"或"cat",忽略大小写
let pattern1 = /[bc]at/i;

// 匹配第一个"[bc]at",忽略大小写
let pattern2 = /\[bc\]at/i;

// 匹配所有以"at"结尾的三字符组合,忽略大小写
let pattern3 = /.at/gi;

// 匹配所有".at",忽略大小写
let pattern4 = /\.at/gi;

这里的pattern1 匹配"bat""cat" ,不区分大小写。要直接匹配"[bc]at" ,左右中括号都必须像pattern2 中那样使用反斜杠转义。在pattern3 中,点号表示"at" 前面的任意字符都可以匹配。如果想匹配".at" ,那么要像pattern4 中那样对点号进行转义。

前面例子中的正则表达式都是使用字面量形式定义的。正则表达式也可以使用RegExp 构造函数来创建,它接收两个参数:模式字符串和(可选的)标记字符串。任何使用字面量定义的正则表达式也可以通过构造函数来创建,比如:

// 匹配第一个"bat"或"cat",忽略大小写
let pattern1 = /[bc]at/i;

// 跟pattern1一样,只不过是用构造函数创建的
let pattern2 = new RegExp("[bc]at", "i");

这里的pattern1pattern2 是等效的正则表达式。注意,RegExp 构造函数的两个参数都是字符串。因为RegExp 的模式参数是字符串,所以在某些情况下需要二次转义。所有元字符都必须二次转义,包括转义字符序列,如\n\ 转义后的字符串是\\ ,在正则表达式字符串中则要写成\\\\ )。下表展示了几个正则表达式的字面量形式,以及使用RegExp 构造函数创建时对应的模式字符串。

字面量模式

对应的字符串

/\[bc\]at/

"\\[bc\\]at"

/\.at/

"\\.at"

/name\/age/

"name\\/age"

/\d.\d{1,2}/

"\\d.\\d{1,2}"

/\w\\hello\\123/

"\\w\\\\hello\\\\123"

此外,使用RegExp 也可以基于已有的正则表达式实例,并可选择性地修改它们的标记:

const re1 = /cat/g;
console.log(re1);  // "/cat/g"

const re2 = new RegExp(re1);
console.log(re2);  // "/cat/g"

const re3 = new RegExp(re1, "i");
console.log(re3);  // "/cat/i"

每个RegExp 实例都有下列属性,提供有关模式的各方面信息。

通过这些属性可以全面了解正则表达式的信息,不过实际开发中用得并不多,因为模式声明中包含这些信息。下面是一个例子:

let pattern1 = /\[bc\]at/i;

console.log(pattern1.global);      // false
console.log(pattern1.ignoreCase);  // true
console.log(pattern1.multiline);   // false
console.log(pattern1.lastIndex);   // 0
console.log(pattern1.source);      // "\[bc\]at"
console.log(pattern1.flags);       // "i"

let pattern2 = new RegExp("\\[bc\\]at", "i");

console.log(pattern2.global);      // false
console.log(pattern2.ignoreCase);  // true
console.log(pattern2.multiline);   // false
console.log(pattern2.lastIndex);   // 0
console.log(pattern2.source);      // "\[bc\]at"
console.log(pattern2.flags);       // "i"

注意,虽然第一个模式是通过字面量创建的,第二个模式是通过RegExp 构造函数创建的,但两个模式的sourceflags 属性是相同的。sourceflags 属性返回的是规范化之后可以在字面量中使用的形式。

RegExp 实例的主要方法是exec() ,主要用于配合捕获组使用。这个方法只接收一个参数,即要应用模式的字符串。如果找到了匹配项,则返回包含第一个匹配信息的数组;如果没找到匹配项,则返回null 。返回的数组虽然是Array 的实例,但包含两个额外的属性:indexinputindex 是字符串中匹配模式的起始位置,input 是要查找的字符串。这个数组的第一个元素是匹配整个模式的字符串,其他元素是与表达式中的捕获组匹配的字符串。如果模式中没有捕获组,则数组只包含一个元素。来看下面的例子:

let text = "mom and dad and baby";
let pattern = /mom( and dad( and baby)?)?/gi;

let matches = pattern.exec(text);
console.log(matches.index);   // 0
console.log(matches.input);   // "mom and dad and baby"
console.log(matches[0]);      // "mom and dad and baby"
console.log(matches[1]);      // " and dad and baby"
console.log(matches[2]);      // " and baby"

在这个例子中,模式包含两个捕获组:最内部的匹配项" and baby" ,以及外部的匹配项" and dad"" and dad and baby" 。调用exec() 后找到了一个匹配项。因为整个字符串匹配模式,所以matchs 数组的index 属性就是0。数组的第一个元素是匹配的整个字符串,第二个元素是匹配第一个捕获组的字符串,第三个元素是匹配第二个捕获组的字符串。

如果模式设置了全局标记,则每次调用exec() 方法会返回一个匹配的信息。如果没有设置全局标记,则无论对同一个字符串调用多少次exec() ,也只会返回第一个匹配的信息。

let text = "cat, bat, sat, fat";
let pattern = /.at/;

let matches = pattern.exec(text);
console.log(matches.index);      // 0
console.log(matches[0]);         // cat
console.log(pattern.lastIndex);  // 0

matches = pattern.exec(text);
console.log(matches.index);      // 0
console.log(matches[0]);         // cat
console.log(pattern.lastIndex);  // 0

上面例子中的模式没有设置全局标记,因此调用exec() 只返回第一个匹配项("cat" )。lastIndex 在非全局模式下始终不变。

如果在这个模式上设置了g 标记,则每次调用exec() 都会在字符串中向前搜索下一个匹配项,如下面的例子所示:

let text = "cat, bat, sat, fat";
let pattern = /.at/g;
let matches = pattern.exec(text);
console.log(matches.index);      // 0
console.log(matches[0]);         // cat
console.log(pattern.lastIndex);  // 3

matches = pattern.exec(text);
console.log(matches.index);      // 5
console.log(matches[0]);         // bat
console.log(pattern.lastIndex);  // 8

matches = pattern.exec(text);
console.log(matches.index);      // 10
console.log(matches[0]);         // sat
console.log(pattern.lastIndex);  // 13

这次模式设置了全局标记,因此每次调用exec() 都会返回字符串中的下一个匹配项,直到搜索到字符串末尾。注意模式的lastIndex 属性每次都会变化。在全局匹配模式下,每次调用exec() 都会更新lastIndex 值,以反映上次匹配的最后一个字符的索引。

如果模式设置了粘附标记y ,则每次调用exec() 就只会在lastIndex 的位置上寻找匹配项。粘附标记覆盖全局标记。

let text = "cat, bat, sat, fat";
let pattern = /.at/y;

let matches = pattern.exec(text);
console.log(matches.index);       // 0
console.log(matches[0]);          // cat
console.log(pattern.lastIndex);   // 3

// 以索引3对应的字符开头找不到匹配项,因此exec()返回null
// exec()没找到匹配项,于是将lastIndex设置为0
matches = pattern.exec(text);
console.log(matches);             // null
console.log(pattern.lastIndex);   // 0

// 向前设置lastIndex可以让粘附的模式通过exec()找到下一个匹配项:
pattern.lastIndex = 5;
matches = pattern.exec(text);
console.log(matches.index);       // 5
console.log(matches[0]);          // bat
console.log(pattern.lastIndex);   // 8

正则表达式的另一个方法是test() ,接收一个字符串参数。如果输入的文本与模式匹配,则参数返回true ,否则返回false 。这个方法适用于只想测试模式是否匹配,而不需要实际匹配内容的情况。test() 经常用在if 语句中:

let text = "000-00-0000";
let pattern = /\d{3}-\d{2}-\d{4}/;

if (pattern.test(text)) {
  console.log("The pattern was matched.");
}

在这个例子中,正则表达式用于测试特定的数值序列。如果输入的文本与模式匹配,则显示匹配成功的消息。这个用法常用于验证用户输入,此时我们只在乎输入是否有效,不关心为什么无效。

无论正则表达式是怎么创建的,继承的方法toLocaleString()toString() 都返回正则表达式的字面量表示。比如:

let pattern = new RegExp("\\[bc\\]at", "gi");
console.log(pattern.toString());       // /\[bc\]at/gi
console.log(pattern.toLocaleString()); // /\[bc\]at/gi

这里的模式是通过RegExp 构造函数创建的,但toLocaleString()toString() 返回的都是其字面量的形式。

注意  正则表达式的valueOf() 方法返回正则表达式本身。

RegExp 构造函数本身也有几个属性。(在其他语言中,这种属性被称为静态属性。)这些属性适用于作用域中的所有正则表达式,而且会根据最后执行的正则表达式操作而变化。这些属性还有一个特点,就是可以通过两种不同的方式访问它们。换句话说,每个属性都有一个全名和一个简写。下表列出了RegExp 构造函数的属性。

全名

简写

说明

input

$_

最后搜索的字符串(非标准特性)

lastMatch

$&

最后匹配的文本

lastParen

$+

最后匹配的捕获组(非标准特性)

leftContext

$`

input 字符串中出现在lastMatch 前面的文本

rightContext

$'

input 字符串中出现在lastMatch 后面的文本

通过这些属性可以提取出与exec()test() 执行的操作相关的信息。来看下面的例子:

let text = "this has been a short summer";
let pattern = /(.)hort/g;

if (pattern.test(text)) {
  console.log(RegExp.input);        // this has been a short summer
  console.log(RegExp.leftContext);  // this has been a
  console.log(RegExp.rightContext); // summer
  console.log(RegExp.lastMatch);    // short
  console.log(RegExp.lastParen);    // s
}

以上代码创建了一个模式,用于搜索任何后跟"hort" 的字符,并把第一个字符放在了捕获组中。不同属性包含的内容如下。

这些属性名也可以替换成简写形式,只不过要使用中括号语法来访问,如下面的例子所示,因为大多数简写形式都不是合法的ECMAScript标识符:

let text = "this has been a short summer";
let pattern = /(.)hort/g;

/*
 * 注意:Opera 不支持简写属性名
 * IE 不支持多行匹配
 */
if (pattern.test(text)) {
  console.log(RegExp.$_);      // this has been a short summer
  console.log(RegExp["$`"]);   // this has been a
  console.log(RegExp["$'"]);   // summer
  console.log(RegExp["$&"]);   // short
  console.log(RegExp["$+"]);   // s
}

RegExp 还有其他几个构造函数属性,可以存储最多9个捕获组的匹配项。这些属性通过RegExp.$1~RegExp.$9 来访问,分别包含第1~9个捕获组的匹配项。在调用exec()test() 时,这些属性就会被填充,然后就可以像下面这样使用它们:

let text = "this has been a short summer";
let pattern = /(..)or(.)/g;

if (pattern.test(text)) {
  console.log(RegExp.$1);  // sh
  console.log(RegExp.$2);  // t
}

在这个例子中,模式包含两个捕获组。调用test() 搜索字符串之后,因为找到了匹配项所以返回true ,而且可以打印出通过RegExp 构造函数的$1$2 属性取得的两个捕获组匹配的内容。

注意  RegExp 构造函数的所有属性都没有任何Web标准出处,因此不要在生产环境中使用它们。

虽然ECMAScript对正则表达式的支持有了长足的进步,但仍然缺少Perl语言中的一些高级特性。下列特性目前还没有得到ECMAScript的支持(想要了解更多信息,可以参考Regular-Expressions.info网站):

虽然还有这些局限,但ECMAScript的正则表达式已经非常强大,可以用于大多数模式匹配任务。

为了方便操作原始值,ECMAScript提供了3种特殊的引用类型:BooleanNumberString 。这些类型具有本章介绍的其他引用类型一样的特点,但也具有与各自原始类型对应的特殊行为。每当用到某个原始值的方法或属性时,后台都会创建一个相应原始包装类型的对象,从而暴露出操作原始值的各种方法。来看下面的例子:

let s1 = "some text";
let s2 = s1.substring(2);

在这里,s1 是一个包含字符串的变量,它是一个原始值。第二行紧接着在s1 上调用了substring() 方法,并把结果保存在s2 中。我们知道,原始值本身不是对象,因此逻辑上不应该有方法。而实际上这个例子又确实按照预期运行了。这是因为后台进行了很多处理,从而实现了上述操作。具体来说,当第二行访问s1 时,是以读模式访问的,也就是要从内存中读取变量保存的值。在以读模式访问字符串值的任何时候,后台都会执行以下3步:

(1) 创建一个String 类型的实例;

(2) 调用实例上的特定方法;

(3) 销毁实例。

可以把这3步想象成执行了如下3行ECMAScript代码:

let s1 = new String("some text");
let s2 = s1.substring(2);
s1 = null;

这种行为可以让原始值拥有对象的行为。对布尔值和数值而言,以上3步也会在后台发生,只不过使用的是BooleanNumber 包装类型而已。

引用类型与原始值包装类型的主要区别在于对象的生命周期。在通过new 实例化引用类型后,得到的实例会在离开作用域时被销毁,而自动创建的原始值包装对象则只存在于访问它的那行代码执行期间。这意味着不能在运行时给原始值添加属性和方法。比如下面的例子:

let s1 = "some text";
s1.color = "red";
console.log(s1.color);  // undefined

这里的第二行代码尝试给字符串s1 添加了一个color 属性。可是,第三行代码访问color 属性时,它却不见了。原因就是第二行代码运行时会临时创建一个String 对象,而当第三行代码执行时,这个对象已经被销毁了。实际上,第三行代码在这里创建了自己的String 对象,但这个对象没有color 属性。

可以显式地使用BooleanNumberString 构造函数创建原始值包装对象。不过应该在确实必要时再这么做,否则容易让开发者疑惑,分不清它们到底是原始值还是引用值。在原始值包装类型的实例上调用typeof 会返回"object" ,所有原始值包装对象都会转换为布尔值true

另外,Object 构造函数作为一个工厂方法,能够根据传入值的类型返回相应原始值包装类型的实例。比如:

let obj = new Object("some text");
console.log(obj instanceof String);  // true

如果传给Object 的是字符串,则会创建一个String 的实例。如果是数值,则会创建Number 的实例。布尔值则会得到Boolean 的实例。

注意,使用new 调用原始值包装类型的构造函数,与调用同名的转型函数并不一样。例如:

let value = "25";
let number = Number(value);    // 转型函数
console.log(typeof number);    // "number"
let obj = new Number(value);   // 构造函数
console.log(typeof obj);       // "object"

在这个例子中,变量number 中保存的是一个值为25的原始数值,而变量obj 中保存的是一个Number 的实例。

虽然不推荐显式创建原始值包装类型的实例,但它们对于操作原始值的功能是很重要的。每个原始值包装类型都有相应的一套方法来方便数据操作。

Boolean 是对应布尔值的引用类型。要创建一个Boolean 对象,就使用Boolean 构造函数并传入truefalse ,如下例所示:

let booleanObject = new Boolean(true);

Boolean 的实例会重写valueOf() 方法,返回一个原始值truefalsetoString() 方法被调用时也会被覆盖,返回字符串"true""false" 。不过,Boolean 对象在ECMAScript中用得很少。不仅如此,它们还容易引起误会,尤其是在布尔表达式中使用Boolean 对象时,比如:

let falseObject = new Boolean(false);
let result = falseObject && true;
console.log(result); // true

let falseValue = false;
result = falseValue && true;
console.log(result); // false

在这段代码中,我们创建一个值为falseBoolean 对象。然后,在一个布尔表达式中通过&& 操作将这个对象与一个原始值true 组合起来。在布尔算术中,false && true 等于false 。可是,这个表达式是对falseObject 对象而不是对它表示的值(false )求值。前面刚刚说过,所有对象在布尔表达式中都会自动转换为true ,因此falseObject 在这个表达式里实际上表示一个true 值。那么true && true 当然是true

除此之外,原始值和引用值(Boolean 对象)还有几个区别。首先,typeof 操作符对原始值返回"boolean" ,但对引用值返回"object" 。同样,Boolean 对象是Boolean 类型的实例,在使用instaceof 操作符时返回true ,但对原始值则返回false ,如下所示:

console.log(typeof falseObject);             // object
console.log(typeof falseValue);              // boolean
console.log(falseObject instanceof Boolean); // true
console.log(falseValue instanceof Boolean);  // false

理解原始布尔值和Boolean 对象之间的区别非常重要,强烈建议永远不要使用后者。

Number 是对应数值的引用类型。要创建一个Number 对象,就使用Number 构造函数并传入一个数值,如下例所示:

let numberObject = new Number(10);

Boolean 类型一样,Number 类型重写了valueOf()toLocaleString()toString() 方法。valueOf() 方法返回Number 对象表示的原始数值,另外两个方法返回数值字符串。toString() 方法可选地接收一个表示基数的参数,并返回相应基数形式的数值字符串,如下所示:

let num = 10;
console.log(num.toString());   // "10"
console.log(num.toString(2));  // "1010"
console.log(num.toString(8));  // "12"
console.log(num.toString(10)); // "10"
console.log(num.toString(16)); // "a"

除了继承的方法,Number 类型还提供了几个用于将数值格式化为字符串的方法。

toFixed() 方法返回包含指定小数点位数的数值字符串,如:

let num = 10;
console.log(num.toFixed(2)); // "10.00"

这里的toFixed() 方法接收了参数2 ,表示返回的数值字符串要包含两位小数。结果返回值为"10.00" ,小数位填充了0。如果数值本身的小数位超过了参数指定的位数,则四舍五入到最接近的小数位:

let num = 10.005;
console.log(num.toFixed(2)); // "10.01"

toFixed() 自动舍入的特点可以用于处理货币。不过要注意的是,多个浮点数值的数学计算不一定得到精确的结果。比如,0.1 + 0.2 = 0.30000000000000004

注意  toFixed() 方法可以表示有0~20个小数位的数值。某些浏览器可能支持更大的范围,但这是通常被支持的范围。

另一个用于格式化数值的方法是toExponential() ,返回以科学记数法(也称为指数记数法)表示的数值字符串。与toFixed() 一样,toExponential() 也接收一个参数,表示结果中小数的位数。来看下面的例子:

let num = 10;
console.log(num.toExponential(1));  // "1.0e+1"

这段代码的输出为"1.0e+1" 。一般来说,这么小的数不用表示为科学记数法形式。如果想得到数值最适当的形式,那么可以使用toPrecision()

toPrecision() 方法会根据情况返回最合理的输出结果,可能是固定长度,也可能是科学记数法形式。这个方法接收一个参数,表示结果中数字的总位数(不包含指数)。来看几个例子:

let num = 99;
console.log(num.toPrecision(1)); // "1e+2"
console.log(num.toPrecision(2)); // "99"
console.log(num.toPrecision(3)); // "99.0"

在这个例子中,首先要用1位数字表示数值99,得到"1e+2" ,也就是100。因为99不能只用1位数字来精确表示,所以这个方法就将它舍入为100,这样就可以只用1位数字(及其科学记数法形式)来表示了。用2位数字表示99得到"99" ,用3位数字则是"99.0" 。本质上,toPrecision() 方法会根据数值和精度来决定调用toFixed() 还是toExponential() 。为了以正确的小数位精确表示数值,这3个方法都会向上或向下舍入。

注意  toPrecision() 方法可以表示带1~21个小数位的数值。某些浏览器可能支持更大的范围,但这是通常被支持的范围。

Boolean 对象类似,Number 对象也为数值提供了重要能力。但是,考虑到两者存在同样的潜在问题,因此并不建议直接实例化Number 对象。在处理原始数值和引用数值时,typeofinstacnceof 操作符会返回不同的结果,如下所示:

let numberObject = new Number(10);
let numberValue = 10;
console.log(typeof numberObject);             // "object"
console.log(typeof numberValue);              // "number"
console.log(numberObject instanceof Number);  // true
console.log(numberValue instanceof Number);   // false

原始数值在调用typeof 时始终返回"number" ,而Number 对象则返回"object" 。类似地,Number 对象是Number 类型的实例,而原始数值不是。

isInteger() 方法与安全整数

ES6新增了Number.isInteger() 方法,用于辨别一个数值是否保存为整数。有时候,小数位的0可能会让人误以为数值是一个浮点值:

console.log(Number.isInteger(1));    // true
console.log(Number.isInteger(1.00)); // true
console.log(Number.isInteger(1.01)); // false

IEEE 754数值格式有一个特殊的数值范围,在这个范围内二进制值可以表示一个整数值。这个数值范围从Number.MIN_SAFE_INTEGER-2^{53}+1 )到Number.MAX_SAFE_INTEGER2^{53}-1 )。对超出这个范围的数值,即使尝试保存为整数,IEEE 754编码格式也意味着二进制值可能会表示一个完全不同的数值。为了鉴别整数是否在这个范围内,可以使用Number.isSafeInteger() 方法:

console.log(Number.isSafeInteger(-1 * (2 ** 53)));      // false
console.log(Number.isSafeInteger(-1 * (2 ** 53) + 1));  // true

console.log(Number.isSafeInteger(2 ** 53));             // false
console.log(Number.isSafeInteger((2 ** 53) - 1));       // true

String 是对应字符串的引用类型。要创建一个String 对象,使用String 构造函数并传入一个数值,如下例所示:

let stringObject = new String("hello world");

String 对象的方法可以在所有字符串原始值上调用。3个继承的方法valueOf()toLocaleString()toString() 都返回对象的原始字符串值。

每个String 对象都有一个length 属性,表示字符串中字符的数量。来看下面的例子:

let stringValue = "hello world";
console.log(stringValue.length); // "11"

这个例子输出了字符串"hello world" 中包含的字符数量:11 。注意,即使字符串中包含双字节字符(而不是单字节的ASCII字符),也仍然会按单字符来计数。

String 类型提供了很多方法来解析和操作字符串。

  1. JavaScript字符

    JavaScript字符串由16位码元(code unit)组成。对多数字符来说,每16位码元对应一个字符。换句话说,字符串的length 属性表示字符串包含多少16位码元:

    let message = "abcde";
    
    console.log(message.length); // 5
    
    

    此外,charAt() 方法返回给定索引位置的字符,由传给方法的整数参数指定。具体来说,这个方法查找指定索引位置的16位码元,并返回该码元对应的字符:

    let message = "abcde";
    
    console.log(message.charAt(2)); // "c"
    
    

    JavaScript字符串使用了两种Unicode编码混合的策略:UCS-2和UTF-16。对于可以采用16位编码的字符(U+0000~U+FFFF),这两种编码实际上是一样的。

    注意  要深入了解关于字符编码的内容,推荐Joel Spolsky写的博客文章:“The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)”。

    另一个有用的资源是Mathias Bynens的博文:“JavaScript's Internal Character Encoding: UCS-2 or UTF-16?”。

    使用charCodeAt() 方法可以查看指定码元的字符编码。这个方法返回指定索引位置的码元值,索引以整数指定。比如:

    let message = "abcde";
    
    // Unicode "Latin small letter C"的编码是U+0063
    console.log(message.charCodeAt(2));  // 99
    
    // 十进制99等于十六进制63
    console.log(99 === 0x63);            // true
    
    

    fromCharCode() 方法用于根据给定的UTF-16码元创建字符串中的字符。这个方法可以接受任意多个数值,并返回将所有数值对应的字符拼接起来的字符串:

    // Unicode "Latin small letter A"的编码是U+0061
    // Unicode "Latin small letter B"的编码是U+0062
    // Unicode "Latin small letter C"的编码是U+0063
    // Unicode "Latin small letter D"的编码是U+0064
    // Unicode "Latin small letter E"的编码是U+0065
    
    console.log(String.fromCharCode(0x61, 0x62, 0x63, 0x64, 0x65));  // "abcde"
    
    // 0x0061 === 97
    // 0x0062 === 98
    // 0x0063 === 99
    // 0x0064 === 100
    // 0x0065 === 101
    
    console.log(String.fromCharCode(97, 98, 99, 100, 101));          // "abcde"
    
    

    对于U+0000~U+FFFF范围内的字符,lengthcharAt()charCodeAt()fromCharCode() 返回的结果都跟预期是一样的。这是因为在这个范围内,每个字符都是用16位表示的,而这几个方法也都基于16位码元完成操作。只要字符编码大小与码元大小一一对应,这些方法就能如期工作。

    这个对应关系在扩展到Unicode增补字符平面时就不成立了。问题很简单,即16位只能唯一表示65 536个字符。这对于大多数语言字符集是足够了,在Unicode中称为基本多语言平面 (BMP)。为了表示更多的字符,Unicode采用了一个策略,即每个字符使用另外16位去选择一个增补平面 。这种每个字符使用两个16位码元的策略称为代理对

    在涉及增补平面的字符时,前面讨论的字符串方法就会出问题。比如,下面的例子中使用了一个笑脸表情符号,也就是一个使用代理对编码的字符:

    // "smiling face with smiling eyes" 表情符号的编码是U+1F60A
    // 0x1F60A === 128522
    let message = "ab☺de";
    
    console.log(message.length);          // 6
    console.log(message.charAt(1));       // b
    console.log(message.charAt(2));       // <?>
    console.log(message.charAt(3));       // <?>
    console.log(message.charAt(4));       // d
    
    console.log(message.charCodeAt(1));   // 98
    console.log(message.charCodeAt(2));   // 55357
    console.log(message.charCodeAt(3));   // 56842
    console.log(message.charCodeAt(4));   // 100
    
    console.log(String.fromCodePoint(0x1F60A)); // ☺
    
    console.log(String.fromCharCode(97, 98, 55357, 56842, 100, 101)); // ab☺de
    
    

    这些方法仍然将16位码元当作一个字符,事实上索引2和索引3对应的码元应该被看成一个代理对,只对应一个字符。fromCharCode() 方法仍然返回正确的结果,因为它实际上是基于提供的二进制表示直接组合成字符串。浏览器可以正确解析代理对(由两个码元构成),并正确地将其识别为一个Unicode笑脸字符。

    为正确解析既包含单码元字符又包含代理对字符的字符串,可以使用codePointAt() 来代替charCodeAt() 。跟使用charCodeAt() 时类似,codePointAt() 接收16位码元的索引并返回该索引位置上的码点(code point)。码点 是Unicode中一个字符的完整标识。比如,"c" 的码点是0x0063,而"☺"的码点是0x1F60A。码点可能是16位,也可能是32位,而codePointAt() 方法可以从指定码元位置识别完整的码点。

    let message = "ab☺de";
    
    console.log(message.codePointAt(1)); // 98
    console.log(message.codePointAt(2)); // 128522
    console.log(message.codePointAt(3)); // 56842
    console.log(message.codePointAt(4)); // 100
    
    

    注意,如果传入的码元索引并非代理对的开头,就会返回错误的码点。这种错误只有检测单个字符的时候才会出现,可以通过从左到右按正确的码元数遍历字符串来规避。迭代字符串可以智能地识别代理对的码点:

    console.log([..."ab☺de"]); // ["a", "b", "☺", "d", "e"]
    
    

    charCodeAt() 有对应的codePointAt() 一样,fromCharCode() 也有一个对应的fromCodePoint() 。这个方法接收任意数量的码点,返回对应字符拼接起来的字符串:

    console.log(String.fromCharCode(97, 98, 55357, 56842, 100, 101));  // ab☺de
    console.log(String.fromCodePoint(97, 98, 128522, 100, 101));       // ab☺de
    
    

     

  2. normalize() 方法

    某些Unicode字符可以有多种编码方式。有的字符既可以通过一个BMP字符表示,也可以通过一个代理对表示。比如:

    // U+00C5:上面带圆圈的大写拉丁字母A
    console.log(String.fromCharCode(0x00C5));          // Å
    
    // U+212B:长度单位“埃”
    console.log(String.fromCharCode(0x212B));          // Å
    
    // U+004:大写拉丁字母A
    // U+030A:上面加个圆圈
    console.log(String.fromCharCode(0x0041, 0x030A));  // Å
    
    

    比较操作符不在乎字符看起来是什么样的,因此这3个字符互不相等。

    let a1 = String.fromCharCode(0x00C5),
        a2 = String.fromCharCode(0x212B),
        a3 = String.fromCharCode(0x0041, 0x030A);
    
    console.log(a1, a2, a3); // Å, Å, Å
    
    console.log(a1 === a2);  // false
    console.log(a1 === a3);  // false
    console.log(a2 === a3);  // false
    
    

    为解决这个问题,Unicode提供了4种规范化形式,可以将类似上面的字符规范化为一致的格式,无论底层字符的代码是什么。这4种规范化形式是:NFD(Normalization Form D)、NFC(Normalization Form C)、NFKD(Normalization Form KD)和NFKC(Normalization Form KC)。可以使用normalize() 方法对字符串应用上述规范化形式,使用时需要传入表示哪种形式的字符串:"NFD""NFC""NFKD""NFKC"

    注意  这4种规范化形式的具体细节超出了本书范围,有兴趣的读者可以自行参考UAX 15#: Unicode Normalization Forms 中的1.2节“Normalization Forms”。

    通过比较字符串与其调用normalize() 的返回值,就可以知道该字符串是否已经规范化了:

    let a1 = String.fromCharCode(0x00C5),
        a2 = String.fromCharCode(0x212B),
        a3 = String.fromCharCode(0x0041, 0x030A);
    
    // U+00C5是对0+212B进行NFC/NFKC规范化之后的结果
    console.log(a1 === a1.normalize("NFD"));  // false
    console.log(a1 === a1.normalize("NFC"));  // true
    console.log(a1 === a1.normalize("NFKD")); // false
    console.log(a1 === a1.normalize("NFKC")); // true
    
    // U+212B是未规范化的
    console.log(a2 === a2.normalize("NFD"));  // false
    console.log(a2 === a2.normalize("NFC"));  // false
    console.log(a2 === a2.normalize("NFKD")); // false
    console.log(a2 === a2.normalize("NFKC")); // false
    
    // U+0041/U+030A是对0+212B进行NFD/NFKD规范化之后的结果
    console.log(a3 === a3.normalize("NFD"));  // true
    console.log(a3 === a3.normalize("NFC"));  // false
    console.log(a3 === a3.normalize("NFKD")); // true
    console.log(a3 === a3.normalize("NFKC")); // false
    
    

    选择同一种规范化形式可以让比较操作符返回正确的结果:

    let a1 = String.fromCharCode(0x00C5),
        a2 = String.fromCharCode(0x212B),
        a3 = String.fromCharCode(0x0041, 0x030A);
    
    console.log(a1.normalize("NFD") === a2.normalize("NFD"));    // true
    console.log(a2.normalize("NFKC") === a3.normalize("NFKC"));  // true
    console.log(a1.normalize("NFC") === a3.normalize("NFC"));    // true
    
    

     

  3. 字符串操作方法

    本节介绍几个操作字符串值的方法。首先是concat() ,用于将一个或多个字符串拼接成一个新字符串。来看下面的例子:

    let stringValue = "hello ";
    let result = stringValue.concat("world");
    
    console.log(result);      // "hello world"
    console.log(stringValue); // "hello"
    
    

    在这个例子中,对stringValue 调用concat() 方法的结果是得到"hello world" ,但stringValue 的值保持不变。concat() 方法可以接收任意多个参数,因此可以一次性拼接多个字符串,如下所示:

    let stringValue = "hello ";
    let result = stringValue.concat("world", "!");
    
    console.log(result);      // "hello world!"
    console.log(stringValue); // "hello"
    
    

    这个修改后的例子将字符串"world""!" 追加到了"hello " 后面。虽然concat() 方法可以拼接字符串,但更常用的方式是使用加号操作符(+ )。而且多数情况下,对于拼接多个字符串来说,使用加号更方便。

    ECMAScript提供了3个从字符串中提取子字符串的方法:slice()substr()substring() 。这3个方法都返回调用它们的字符串的一个子字符串,而且都接收一或两个参数。第一个参数表示子字符串开始的位置,第二个参数表示子字符串结束的位置。对slice()substring() 而言,第二个参数是提取结束的位置(即该位置之前的字符会被提取出来)。对substr() 而言,第二个参数表示返回的子字符串数量。任何情况下,省略第二个参数都意味着提取到字符串末尾。与concat() 方法一样,slice()substr()substring() 也不会修改调用它们的字符串,而只会返回提取到的原始新字符串值。来看下面的例子:

    let stringValue = "hello world";
    console.log(stringValue.slice(3));       // "lo world"
    console.log(stringValue.substring(3));   // "lo world"
    console.log(stringValue.substr(3));      // "lo world"
    console.log(stringValue.slice(3, 7));    // "lo w"
    console.log(stringValue.substring(3,7)); // "lo w"
    console.log(stringValue.substr(3, 7));   // "lo worl"
    
    

    在这个例子中,slice()substr()substring() 是以相同方式被调用的,而且多数情况下返回的值也相同。如果只传一个参数3 ,则所有方法都将返回"lo world" ,因为"hello""l" 位置为3。如果传入两个参数37 ,则slice()substring() 返回"lo w" (因为"world""o" 在位置7,不包含),而substr() 返回"lo worl" ,因为第二个参数对它而言表示返回的字符数。

    当某个参数是负值时,这3个方法的行为又有不同。比如,slice() 方法将所有负值参数都当成字符串长度加上负参数值。

    substr() 方法将第一个负参数值当成字符串长度加上该值,将第二个负参数值转换为0。substring() 方法会将所有负参数值都转换为0。看下面的例子:

    let stringValue = "hello world";
    console.log(stringValue.slice(-3));         // "rld"
    console.log(stringValue.substring(-3));     // "hello world"
    console.log(stringValue.substr(-3));        // "rld"
    console.log(stringValue.slice(3, -4));      // "lo w"
    console.log(stringValue.substring(3, -4));  // "hel"
    console.log(stringValue.substr(3, -4));     // "" (empty string)
    
    

    这个例子明确演示了3个方法的差异。在给slice()substr() 传入负参数时,它们的返回结果相同。这是因为-3 会被转换为8 (长度加上负参数),实际上调用的是slice(8)substr(8) 。而substring() 方法返回整个字符串,因为-3 会转换为0

    在第二个参数是负值时,这3个方法各不相同。slice() 方法将第二个参数转换为7 ,实际上相当于调用slice(3, 7) ,因此返回"lo w" 。而substring() 方法会将第二个参数转换为0 ,相当于调用substring(3, 0) ,等价于substring(0, 3) ,这是因为这个方法会将较小的参数作为起点,将较大的参数作为终点。对substr() 来说,第二个参数会被转换为0 ,意味着返回的字符串包含零个字符,因而会返回一个空字符串。
     

  4. 字符串位置方法

    有两个方法用于在字符串中定位子字符串:indexOf()lastIndexOf() 。这两个方法从字符串中搜索传入的字符串,并返回位置(如果没找到,则返回-1 )。两者的区别在于,indexOf() 方法从字符串开头开始查找子字符串,而lastIndexOf() 方法从字符串末尾开始查找子字符串。来看下面的例子:

    let stringValue = "hello world";
    console.log(stringValue.indexOf("o"));     // 4
    console.log(stringValue.lastIndexOf("o")); // 7
    
    

    这里,字符串中第一个"o" 的位置是4,即"hello" 中的"o" 。最后一个"o" 的位置是7,即"world" 中的"o" 。如果字符串中只有一个"o" ,则indexOf()lastIndexOf() 返回同一个位置。

    这两个方法都可以接收可选的第二个参数,表示开始搜索的位置。这意味着,indexOf() 会从这个参数指定的位置开始向字符串末尾搜索,忽略该位置之前的字符;lastIndexOf() 则会从这个参数指定的位置开始向字符串开头搜索,忽略该位置之后直到字符串末尾的字符。下面看一个例子:

    let stringValue = "hello world";
    console.log(stringValue.indexOf("o", 6));     // 7
    console.log(stringValue.lastIndexOf("o", 6)); // 4
    
    

    在传入第二个参数6 以后,结果跟前面的例子恰好相反。这一次,indexOf() 返回7 ,因为它从位置6(字符"w" )开始向后搜索字符串,在位置7找到了"o" 。而lastIndexOf() 返回4 ,因为它从位置6开始反向搜索至字符串开头,因此找到了"hello" 中的"o" 。像这样使用第二个参数并循环调用indexOf()lastIndexOf() ,就可以在字符串中找到所有的目标子字符串,如下所示:

    let stringValue = "Lorem ipsum dolor sit amet, consectetur adipisicing elit";
    let positions = new Array();
    let pos = stringValue.indexOf("e");
    
    while(pos > -1) {
      positions.push(pos);
      pos = stringValue.indexOf("e", pos + 1);
    }
    
    console.log(positions); // [3,24,32,35,52]
    
    

    这个例子逐步增大开始搜索的位置,通过indexOf() 遍历了整个字符串。首先取得第一个"e" 的位置,然后进入循环,将上一次的位置加1再传给indexOf() ,确保搜索到最后一个子字符串实例之后。每个位置都保存在positions 数组中,可供以后使用。
     

  5. 字符串包含方法

    ECMAScript 6增加了3个用于判断字符串中是否包含另一个字符串的方法:startsWith()endsWith()includes() 。这些方法都会从字符串中搜索传入的字符串,并返回一个表示是否包含的布尔值。它们的区别在于,startsWith() 检查开始于索引0的匹配项,endsWith() 检查开始于索引(string.length - substring.length) 的匹配项,而includes() 检查整个字符串:

    let message = "foobarbaz";
    
    console.log(message.startsWith("foo"));  // true
    console.log(message.startsWith("bar"));  // false
    
    console.log(message.endsWith("baz"));    // true
    console.log(message.endsWith("bar"));    // false
    
    console.log(message.includes("bar"));    // true
    console.log(message.includes("qux"));    // false
    
    

    startsWith()includes() 方法接收可选的第二个参数,表示开始搜索的位置。如果传入第二个参数,则意味着这两个方法会从指定位置向着字符串末尾搜索,忽略该位置之前的所有字符。下面是一个例子:

    let message = "foobarbaz";
    
    console.log(message.startsWith("foo"));     // true
    console.log(message.startsWith("foo", 1));  // false
    
    console.log(message.includes("bar"));       // true
    console.log(message.includes("bar", 4));    // false
    
    

    endsWith() 方法接收可选的第二个参数,表示应该当作字符串末尾的位置。如果不提供这个参数,那么默认就是字符串长度。如果提供这个参数,那么就好像字符串只有那么多字符一样:

    let message = "foobarbaz";
    
    console.log(message.endsWith("bar"));     // false
    console.log(message.endsWith("bar", 6));  // true
    
    

     

  6. trim() 方法

    ECMAScript在所有字符串上都提供了trim() 方法。这个方法会创建字符串的一个副本,删除前、后所有空格符,再返回结果。比如:

    let stringValue = "  hello world  ";
    let trimmedStringValue = stringValue.trim();
    console.log(stringValue);         // "  hello world "
    console.log(trimmedStringValue);  // "hello world"
    
    

    由于trim() 返回的是字符串的副本,因此原始字符串不受影响,即原本的前、后空格符都会保留。

    另外,trimLeft()trimRight() 方法分别用于从字符串开始和末尾清理空格符。
     

  7. repeat() 方法

    ECMAScript在所有字符串上都提供了repeat() 方法。这个方法接收一个整数参数,表示要将字符串复制多少次,然后返回拼接所有副本后的结果。

    let stringValue = "na ";
    console.log(stringValue.repeat(16) + "batman");
    // na na na na na na na na na na na na na na na na batman
    
    

     

  8. padStart()padEnd() 方法

    padStart()padEnd() 方法会复制字符串,如果小于指定长度,则在相应一边填充字符,直至满足长度条件。这两个方法的第一个参数是长度,第二个参数是可选的填充字符串,默认为空格(U+0020)。

    let stringValue = "foo";
    
    console.log(stringValue.padStart(6));       // "   foo"
    console.log(stringValue.padStart(9, "."));  // "......foo"
    
    console.log(stringValue.padEnd(6));         // "foo   "
    console.log(stringValue.padEnd(9, "."));    // "foo......"
    
    

    可选的第二个参数并不限于一个字符。如果提供了多个字符的字符串,则会将其拼接并截断以匹配指定长度。此外,如果长度小于或等于字符串长度,则会返回原始字符串。

    let stringValue = "foo";
    
    console.log(stringValue.padStart(8, "bar")); // "barbafoo"
    console.log(stringValue.padStart(2));        // "foo"
    
    console.log(stringValue.padEnd(8, "bar"));   // "foobarba"
    console.log(stringValue.padEnd(2));          // "foo"
    
    

     

  9. 字符串迭代与解构

    字符串的原型上暴露了一个@@iterator 方法,表示可以迭代字符串的每个字符。可以像下面这样手动使用迭代器:

    let message = "abc";
    let stringIterator = message[Symbol.iterator]();
    
    console.log(stringIterator.next());  // {value: "a", done: false}
    console.log(stringIterator.next());  // {value: "b", done: false}
    console.log(stringIterator.next());  // {value: "c", done: false}
    console.log(stringIterator.next());  // {value: undefined, done: true}
    
    

    for-of 循环中可以通过这个迭代器按序访问每个字符:

    for (const c of "abcde") {
      console.log(c);
    }
    
    // a
    // b
    // c
    // d
    // e
    
    

    有了这个迭代器之后,字符串就可以通过解构操作符来解构了。比如,可以更方便地把字符串分割为字符数组:

    let message = "abcde";
    
    console.log([...message]); // ["a", "b", "c", "d", "e"]
    
    

     

  10. 字符串大小写转换

    下一组方法涉及大小写转换,包括4个方法:toLowerCase()toLocaleLowerCase()toUpperCase()toLocaleUpperCase()toLowerCase()toUpperCase() 方法是原来就有的方法,与java.lang.String 中的方法同名。toLocaleLowerCase()toLocaleUpperCase() 方法旨在基于特定地区实现。在很多地区,地区特定的方法与通用的方法是一样的。但在少数语言中(如土耳其语),Unicode大小写转换需应用特殊规则,要使用地区特定的方法才能实现正确转换。下面是几个例子:

    let stringValue = "hello world";
    console.log(stringValue.toLocaleUpperCase());  // "HELLO WORLD"
    console.log(stringValue.toUpperCase());        // "HELLO WORLD"
    console.log(stringValue.toLocaleLowerCase());  // "hello world"
    console.log(stringValue.toLowerCase());        // "hello world"
    
    

    这里,toLowerCase()toLocaleLowerCase() 都返回hello world ,而toUpperCase()toLocaleUpperCase() 都返回HELLO WORLD 。通常,如果不知道代码涉及什么语言,则最好使用地区特定的转换方法。
     

  11. 字符串模式匹配方法

    String 类型专门为在字符串中实现模式匹配设计了几个方法。第一个就是match() 方法,这个方法本质上跟RegExp 对象的exec() 方法相同。match() 方法接收一个参数,可以是一个正则表达式字符串,也可以是一个RegExp 对象。来看下面的例子:

    let text = "cat, bat, sat, fat";
    let pattern = /.at/;
    
    // 等价于pattern.exec(text)
    let matches = text.match(pattern);
    console.log(matches.index);      // 0
    console.log(matches[0]);         // "cat"
    console.log(pattern.lastIndex);  // 0
    
    

    match() 方法返回的数组与RegExp 对象的exec() 方法返回的数组是一样的:第一个元素是与整个模式匹配的字符串,其余元素则是与表达式中的捕获组匹配的字符串(如果有的话)。

    另一个查找模式的字符串方法是search() 。这个方法唯一的参数与match() 方法一样:正则表达式字符串或RegExp 对象。这个方法返回模式第一个匹配的位置索引,如果没找到则返回-1。search() 始终从字符串开头向后匹配模式。看下面的例子:

    let text = "cat, bat, sat, fat";
    let pos = text.search(/at/);
    console.log(pos);  // 1
    
    

    这里,search(/at/) 返回1 ,即"at" 的第一个字符在字符串中的位置。

    为简化子字符串替换操作,ECMAScript提供了replace() 方法。这个方法接收两个参数,第一个参数可以是一个RegExp 对象或一个字符串(这个字符串不会转换为正则表达式),第二个参数可以是一个字符串或一个函数。如果第一个参数是字符串,那么只会替换第一个子字符串。要想替换所有子字符串,第一个参数必须为正则表达式并且带全局标记,如下面的例子所示:

    let text = "cat, bat, sat, fat";
    let result = text.replace("at", "ond");
    console.log(result);  // "cond, bat, sat, fat"
    
    result = text.replace(/at/g, "ond");
    console.log(result);  // "cond, bond, sond, fond"
    
    

    在这个例子中,字符串"at" 先传给replace() 函数,而替换文本是"ond" 。结果是"cat" 被修改为"cond" ,而字符串的剩余部分保持不变。通过将第一个参数改为带全局标记的正则表达式,字符串中的所有"at" 都被替换成了"ond"

    第二个参数是字符串的情况下,有几个特殊的字符序列,可以用来插入正则表达式操作的值。ECMA-262中规定了下表中的值。

    字符序列 替换文本
    $$ $
    $& 匹配整个模式的子字符串。与RegExp.lastMatch 相同
    $' 匹配的子字符串之前的字符串。与RegExp.rightContext 相同
    $` 匹配的子字符串之后的字符串。与RegExp.leftContext 相同
    $n 匹配第 n 个捕获组的字符串,其中 n 是0~9。比如,$1 是匹配第一个捕获组的字符串,$2 是匹配第二个捕获组的字符串,以此类推。如果没有捕获组,则值为空字符串
    $nn 匹配第 nn 个捕获组字符串,其中 nn 是01~99。比如,$01 是匹配第一个捕获组的字符串,$02 是匹配第二个捕获组的字符串,以此类推。如果没有捕获组,则值为空字符串

    使用这些特殊的序列,可以在替换文本中使用之前匹配的内容,如下面的例子所示:

    let text = "cat, bat, sat, fat";
    result = text.replace(/(.at)/g, "word ($1)");
    console.log(result);  // word (cat), word (bat), word (sat), word (fat)
    
    

    这里,每个以"at" 结尾的词都会被替换成"word" 后跟一对小括号,其中包含捕获组匹配的内容$1

    replace() 的第二个参数可以是一个函数。在只有一个匹配项时,这个函数会收到3个参数:与整个模式匹配的字符串、匹配项在字符串中的开始位置,以及整个字符串。在有多个捕获组的情况下,每个匹配捕获组的字符串也会作为参数传给这个函数,但最后两个参数还是与整个模式匹配的开始位置和原始字符串。这个函数应该返回一个字符串,表示应该把匹配项替换成什么。使用函数作为第二个参数可以更细致地控制替换过程,如下所示:

    function htmlEscape(text) {
      return text.replace(/[<>"&]/g, function(match, pos, originalText) {
        switch(match) {
          case "<":
            return "&lt;";
          case ">":
            return "&gt;";
          case "&":
            return "&amp;";
          case "\"":
            return "&quot;";
        }
      });
    }
    
    console.log(htmlEscape("<p class=\"greeting\">Hello world!</p>"));
    // "&lt;p class=&quot;greeting&quot;&gt;Hello world!</p>"
    
    

    这里,函数htmlEscape() 用于将一段HTML中的4个字符替换成对应的实体:小于号、大于号、和号,还有双引号(都必须经过转义)。实现这个任务最简单的办法就是用一个正则表达式查找这些字符,然后定义一个函数,根据匹配的每个字符分别返回特定的HTML实体。

    最后一个与模式匹配相关的字符串方法是split() 。这个方法会根据传入的分隔符将字符串拆分成数组。作为分隔符的参数可以是字符串,也可以是RegExp 对象。(字符串分隔符不会被这个方法当成正则表达式。)还可以传入第二个参数,即数组大小,确保返回的数组不会超过指定大小。来看下面的例子:

    let colorText = "red,blue,green,yellow";
    let colors1 = colorText.split(",");       // ["red", "blue", "green", "yellow"]
    let colors2 = colorText.split(",", 2);    // ["red", "blue"]
    let colors3 = colorText.split(/[^,]+/);   // ["", ",", ",", ",", ""]
    
    

    在这里,字符串colorText 是一个逗号分隔的颜色名称符串。调用split(",") 会得到包含这些颜色名的数组,基于逗号进行拆分。要把数组元素限制为2个,传入第二个参数2即可。最后,使用正则表达式可以得到一个包含逗号的数组。注意在最后一次调用split() 时,返回的数组前后包含两个空字符串。这是因为正则表达式指定的分隔符出现在了字符串开头("red" )和末尾("yellow" )。
     

  12. localeCompare() 方法

    最后一个方法是localeCompare() ,这个方法比较两个字符串,返回如下3个值中的一个。

    下面是一个例子:

    let stringValue = "yellow";
    console.log(stringValue.localeCompare("brick"));  // 1
    console.log(stringValue.localeCompare("yellow")); // 0
    console.log(stringValue.localeCompare("zoo"));    // -1
    
    

    在这里,字符串"yellow" 与3个不同的值进行了比较:"brick""yellow""zoo""brick" 按字母表顺序应该排在"yellow" 前头,因此localeCompare() 返回1。"yellow" 等于"yellow" ,因此"localeCompare()" 返回0 。最后,"zoo""yellow" 后面,因此localeCompare() 返回-1 。强调一下,因为返回的具体值可能因具体实现而异,所以最好像下面的示例中一样使用localeCompare()

    function determineOrder(value) {
      let result = stringValue.localeCompare(value);
      if (result < 0) {
        console.log(`The string 'yellow' comes before the string '${value}'.`);
      } else if (result > 0) {
        console.log(`The string 'yellow' comes after the string '${value}'.`);
      } else {
        console.log(`The string 'yellow' is equal to the string '${value}'.`);
      }
    }
    
    determineOrder("brick");
    determineOrder("yellow");
    determineOrder("zoo");
    
    

    这样一来,就可以保证在所有实现中都能正确判断字符串的顺序了。

    localeCompare() 的独特之处在于,实现所在的地区(国家和语言)决定了这个方法如何比较字符串。在美国,英语是ECMAScript实现的标准语言,localeCompare() 区分大小写,大写字母排在小写字母前面。但其他地区未必是这种情况。
     

  13. HTML方法

    早期的浏览器开发商认为使用JavaScript动态生成HTML标签是一个需求。因此,早期浏览器扩展了规范,增加了辅助生成HTML标签的方法。下表总结了这些HTML方法。不过,这些方法基本上已经没有人使用了,因为结果通常不是语义化的标记。

    方法 输出
    anchor(name ) <a name="name ">string </a>
    big() <big>string </big>
    bold() <b>string </b>
    fixed() <tt>string </tt>
    fontcolor(color ) <font color="color ">string </font>
    fontsize(size ) <font size="size ">string </font>
    italics() <i>string </i>
    link(url) <a href="url ">string </a>
    small() <small>string </small>
    strike() <strike>string </strike>
    sub() <sub>string </sub>
    sup() <sup>string </sup>

ECMA-262对内置对象的定义是“任何由ECMAScript实现提供、与宿主环境无关,并在ECMAScript程序开始执行时就存在的对象”。这就意味着,开发者不用显式地实例化内置对象,因为它们已经实例化好了。前面我们已经接触了大部分内置对象,包括ObjectArrayString 。本节介绍ECMA-262定义的另外两个单例内置对象:GlobalMath

Global 对象是ECMAScript中最特别的对象,因为代码不会显式地访问它。ECMA-262规定Global 对象为一种兜底对象,它所针对的是不属于任何对象的属性和方法。事实上,不存在全局变量或全局函数这种东西。在全局作用域中定义的变量和函数都会变成Global 对象的属性 。本书前面介绍的函数,包括isNaN()isFinite()parseInt()parseFloat() ,实际上都是Global 对象的方法。除了这些,Global 对象上还有另外一些方法。

  1. URL编码方法

    encodeURI()encodeURIComponent() 方法用于编码统一资源标识符(URI),以便传给浏览器。有效的URI不能包含某些字符,比如空格。使用URI编码方法来编码URI可以让浏览器能够理解它们,同时又以特殊的UTF-8编码替换掉所有无效字符。

    ecnodeURI() 方法用于对整个URI进行编码,比如"www.wrox.com/illegal value.js" 。而encodeURIComponent() 方法用于编码URI中单独的组件,比如前面URL中的"illegal value.js" 。这两个方法的主要区别是,encodeURI() 不会编码属于URL组件的特殊字符,比如冒号、斜杠、问号、井号,而encodeURIComponent() 会编码它发现的所有非标准字符。来看下面的例子:

    let uri = "http://www.wrox.com/illegal value.js#start";
    
    // "http://www.wrox.com/illegal%20value.js#start"
    console.log(encodeURI(uri));
    
    // "http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.js%23start"
    console.log(encodeURIComponent(uri));
    
    

    这里使用encodeURI() 编码后,除空格被替换为%20 之外,没有任何变化。而encodeURIComponent() 方法将所有非字母字符都替换成了相应的编码形式。这就是使用encodeURI() 编码整个URI,但只使用encodeURIComponent() 编码那些会追加到已有URI后面的字符串的原因。

    注意  一般来说,使用encodeURIComponent() 应该比使用encodeURI() 的频率更高,这是因为编码查询字符串参数比编码基准URI的次数更多。

    encodeURI()encodeURIComponent() 相对的是decodeURI()decodeURIComponent()decodeURI() 只对使用encodeURI() 编码过的字符解码。例如,%20 会被替换为空格,但%23 不会被替换为井号(# ),因为井号不是由encodeURI() 替换的。类似地,decodeURIComponent() 解码所有被encodeURIComponent() 编码的字符,基本上就是解码所有特殊值。来看下面的例子:

    let uri = "http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.js%23start";
    
    // http%3A%2F%2Fwww.wrox.com%2Fillegal value.js%23start
    console.log(decodeURI(uri));
    
    // http:// www.wrox.com/illegal value.js#start
    console.log(decodeURIComponent(uri));
    
    

    这里,uri 变量中包含一个使用encodeURIComponent() 编码过的字符串。首先输出的是使用decodeURI() 解码的结果,可以看到只用空格替换了%20 。然后是使用decodeURIComponent() 解码的结果,其中替换了所有特殊字符,并输出了没有包含任何转义的字符串。(这个字符串不是有效的URL。)

    注意  URI方法encodeURI()encodeURIComponent()decodeURI()decodeURIComponent() 取代了escape()unescape() 方法,后者在ECMA-262第3版中就已经废弃了。URI方法始终是首选方法,因为它们对所有Unicode字符进行编码,而原来的方法只能正确编码ASCII字符。不要在生产环境中使用escape()unescape()

     

  2. eval() 方法

    最后一个方法可能是整个ECMAScript语言中最强大的了,它就是eval() 。这个方法就是一个完整的ECMAScript解释器,它接收一个参数,即一个要执行的ECMAScript(JavaScript)字符串。来看一个例子:

    eval("console.log('hi')");
    
    

    上面这行代码的功能与下一行等价:

    console.log("hi");
    
    

    当解释器发现eval() 调用时,会将参数解释为实际的ECMAScript语句,然后将其插入到该位置。通过eval() 执行的代码属于该调用所在上下文,被执行的代码与该上下文拥有相同的作用域链。这意味着定义在包含上下文中的变量可以在eval() 调用内部被引用,比如下面这个例子:

    let msg = "hello world";
    eval("console.log(msg)");  // "hello world"
    
    

    这里,变量msg 是在eval() 调用的外部上下文中定义的,而console.log() 显示了文本"hello world" 。这是因为第二行代码会被替换成一行真正的函数调用代码。类似地,可以在eval() 内部定义一个函数或变量,然后在外部代码中引用,如下所示:

    eval("function sayHi() { console.log('hi'); }");
    sayHi();
    
    

    这里,函数sayHi() 是在eval() 内部定义的。因为该调用会被替换为真正的函数定义,所以才可能在下一行代码中调用sayHi() 。对于变量也是一样的:

    eval("let msg = 'hello world';");
    console.log(msg);  // Reference Error: msg is not defined
    
    

    通过eval() 定义的任何变量和函数都不会被提升,这是因为在解析代码的时候,它们是被包含在一个字符串中的。它们只是在eval() 执行的时候才会被创建。

    在严格模式下,在eval() 内部创建的变量和函数无法被外部访问。换句话说,最后两个例子会报错。同样,在严格模式下,赋值给eval 也会导致错误:

    "use strict";
    eval = "hi";  // 导致错误
    
    

    注意  解释代码字符串的能力是非常强大的,但也非常危险。在使用eval() 的时候必须极为慎重,特别是在解释用户输入的内容时。因为这个方法会对XSS利用暴露出很大的攻击面。恶意用户可能插入会导致你网站或应用崩溃的代码。

     

  3. Global 对象属性

    Global 对象有很多属性,其中一些前面已经提到过了。像undefinedNaNInfinity 等特殊值都是Global 对象的属性。此外,所有原生引用类型构造函数,比如ObjectFunction ,也都是Global 对象的属性。下表列出了所有这些属性。

    属性 说明
    undefined 特殊值undefined
    NaN 特殊值NaN
    Infinity 特殊值Infinity
    Object Object 的构造函数
    Array Array 的构造函数
    Function Function 的构造函数
    Boolean Boolean 的构造函数
    String String 的构造函数
    Number Number 的构造函数
    Date Date 的构造函数
    RegExp RegExp 的构造函数
    Symbol Symbol 的伪构造函数
    Error Error 的构造函数
    EvalError EvalError 的构造函数
    RangeError RangeError 的构造函数
    ReferenceError ReferenceError 的构造函数
    SyntaxError SyntaxError 的构造函数
    TypeError TypeError 的构造函数
    URIError URIError 的构造函数

     

  4. window 对象

    虽然ECMA-262没有规定直接访问Global 对象的方式,但浏览器将window 对象实现为Global 对象的代理。因此,所有全局作用域中声明的变量和函数都变成了window 的属性。来看下面的例子:

    var color = "red";
    
    function sayColor() {
      console.log(window.color);
    }
    
    window.sayColor(); // "red"
    
    

    这里定义了一个名为color 的全局变量和一个名为sayColor() 的全局函数。在sayColor() 内部,通过window.color 访问了color 变量,说明全局变量变成了window 的属性。接着,又通过window 对象直接调用了window.sayColor() 函数,从而输出字符串。

    注意  window 对象在JavaScript中远不止实现了ECMAScriptGlobal 对象那么简单。关于window 对象的更多介绍,请参考第12章。

    另一种获取Global 对象的方式是使用如下的代码:

    let global = function() {
      return this;
    }();
    
    

    这段代码创建一个立即调用的函数表达式,返回了this 的值。如前所述,当一个函数在没有明确(通过成为某个对象的方法,或者通过call() /apply() )指定this 值的情况下执行时,this 值等于Global 对象。因此,调用一个简单返回this 的函数是在任何执行上下文中获取Global 对象的通用方式。

ECMAScript提供了Math 对象作为保存数学公式、信息和计算的地方。Math 对象提供了一些辅助计算的属性和方法。

注意  Math 对象上提供的计算要比直接在JavaScript实现的快得多,因为Math 对象上的计算使用了JavaScript引擎中更高效的实现和处理器指令。但使用Math 计算的问题是精度会因浏览器、操作系统、指令集和硬件而异。

  1. Math 对象属性

    Math 对象有一些属性,主要用于保存数学中的一些特殊值。下表列出了这些属性。

    属性 说明
    Math.E 自然对数的基数e的值
    Math.LN10 10为底的自然对数
    Math.LN2 2为底的自然对数
    Math.LOG2E 以2为底e的对数
    Math.LOG10E 以10为底e的对数
    Math.PI π的值
    Math.SQRT1_2 1/2的平方根
    Math.SQRT2 2的平方根

    这些值的含义和用法超出了本书的范畴,但都是ECMAScript规范定义的,并可以在你需要时使用。
     

  2. min()max() 方法

    Math 对象也提供了很多辅助执行简单或复杂数学计算的方法。

    min()max() 方法用于确定一组数值中的最小值和最大值。这两个方法都接收任意多个参数,如下面的例子所示:

    let max = Math.max(3, 54, 32, 16);
    console.log(max);  // 54
    
    let min = Math.min(3, 54, 32, 16);
    console.log(min);  // 3
    
    

    在3、54、32和16中,Math.max() 返回54,Math.min() 返回3。使用这两个方法可以避免使用额外的循环和if 语句来确定一组数值的最大最小值。

    要知道数组中的最大值和最小值,可以像下面这样使用扩展操作符:

    let values = [1, 2, 3, 4, 5, 6, 7, 8];
    let max = Math.max(...val);
    
    

     

  3. 舍入方法

    接下来是用于把小数值舍入为整数的4个方法:Math.ceil()Math.floor()Math.round()Math.fround() 。这几个方法处理舍入的方式如下所述。

    以下示例展示了这些方法的用法:

    console.log(Math.ceil(25.9));   // 26
    console.log(Math.ceil(25.5));   // 26
    console.log(Math.ceil(25.1));   // 26
    
    console.log(Math.round(25.9));  // 26
    console.log(Math.round(25.5));  // 26
    console.log(Math.round(25.1));  // 25
    
    console.log(Math.fround(0.4));  // 0.4000000059604645
    console.log(Math.fround(0.5));  // 0.5
    console.log(Math.fround(25.9)); // 25.899999618530273
    
    console.log(Math.floor(25.9));  // 25
    console.log(Math.floor(25.5));  // 25
    console.log(Math.floor(25.1));  // 25
    
    

    对于25和26(不包含)之间的所有值,Math.ceil() 都会返回26,因为它始终向上舍入。Math.round() 只在数值大于等于25.5 时返回26,否则返回25。最后,Math.floor() 对所有25和26(不包含)之间的值都返回25。
     

  4. random() 方法

    Math.random() 方法返回一个0~1范围内的随机数,其中包含0但不包含1。对于希望显示随机名言或随机新闻的网页,这个方法是非常方便的。可以基于如下公式使用Math.random() 从一组整数中随机选择一个数:

    number = Math.floor(Math.random() * total_number_of_choices + first_possible_value)
    
    

    这里使用了Math.floor() 方法,因为Math.random() 始终返回小数,即便乘以一个数再加上一个数也是小数。因此,如果想从1~10范围内随机选择一个数,代码就是这样的:

    let num = Math.floor(Math.random() * 10 + 1);
    
    

    这样就有10个可能的值(1~10),其中最小的值是1。如果想选择一个2~10范围内的值,则代码就要写成这样:

    let num = Math.floor(Math.random() * 9 + 2);
    
    

    2~10只有9个数,所以可选总数(total_number_of_choices )是9,而最小可能的值(first_possible_value )是2。很多时候,通过函数来算出可选总数和最小可能的值可能更方便,比如:

    function selectFrom(lowerValue, upperValue) {
      let choices = upperValue - lowerValue + 1;
      return Math.floor(Math.random() * choices + lowerValue);
    }
    
    let num = selectFrom(2,10);
    console.log(num);  // 2~10范围内的值,其中包含2和10
    
    

    这里的函数selectFrom() 接收两个参数:应该返回的最小值和最大值。通过将这两个值相减再加1 得到可选总数,然后再套用上面的公式。于是,调用selectFrom(2,10) 就可以从2~10(包含)范围内选择一个值了。使用这个函数,从一个数组中随机选择一个元素就很容易,比如:

    let colors = ["red", "green", "blue", "yellow", "black", "purple", "brown"];
    let color = colors[selectFrom(0, colors.length-1)];
    
    

    在这个例子中,传给selecFrom() 的第二个参数是数组长度减1,即数组最大的索引值。

    注意  Math.random() 方法在这里出于演示目的是没有问题的。如果是为了加密而需要生成随机数(传给生成器的输入需要较高的不确定性),那么建议使用window.crypto.getRandomValues()

     

  5. 其他方法

    Math 对象还有很多涉及各种简单或高阶数运算的方法。讨论每种方法的具体细节或者它们的适用场景超出了本书的范畴。不过,下表还是总结了Math 对象的其他方法。

    方法 说明
    Math.abs(x ) 返回 x 的绝对值
    Math.ex p(x ) 返回Math.E x 次幂
    Math.ex pm1(x ) 等于Math.ex p(x ) - 1
    Math.log(x ) 返回 x 的自然对数
    Math.log1p(x ) 等于1 + Math.log(x )
    Math.pow(x , power ) 返回 x power 次幂
    Math.hypot(...nums ) 返回 nums 中每个数平方和的平方根
    Math.clz32(x ) 返回32位整数 x 的前置零的数量
    Math.sign(x ) 返回表示 x 符号的10-0-1
    Math.trunc(x ) 返回 x 的整数部分,删除所有小数
    Math.sqrt(x ) 返回 x 的平方根
    Math.cbrt(x ) 返回 x 的立方根
    Math.acos(x ) 返回 x 的反余弦
    Math.acosh(x ) 返回 x 的反双曲余弦
    Math.asin(x ) 返回 x 的反正弦
    Math.asinh(x ) 返回 x 的反双曲正弦
    Math.atan(x ) 返回 x 的反正切
    Math.atanh(x ) 返回 x 的反双曲正切
    Math.atan2(y , x ) 返回 y /x 的反正切
    Math.cos(x ) 返回 x 的余弦
    Math.sin(x ) 返回 x 的正弦
    Math.tan(x ) 返回 x 的正切

    即便这些方法都是由ECMA-262定义的,对正弦、余弦、正切等计算的实现仍然取决于浏览器,因为计算这些值的方式有很多种。结果,这些方法的精度可能因实现而异。

JavaScript中的对象称为引用值,几种内置的引用类型可用于创建特定类型的对象。

JavaScript比较独特的一点是,函数实际上是Function 类型的实例,也就是说函数也是对象。因为函数也是对象,所以函数也有方法,可以用于增强其能力。

由于原始值包装类型的存在,JavaScript中的原始值可以被当成对象来使用。有3种原始值包装类型:BooleanNumberString 。它们都具备如下特点。

当代码开始执行时,全局上下文中会存在两个内置对象:GlobalMath 。其中,Global 对象在大多数ECMAScript实现中无法直接访问。不过,浏览器将其实现为window 对象。所有全局变量和函数都是Global 对象的属性。Math 对象包含辅助完成复杂计算的属性和方法。


第 6 章 集合引用类型

本章内容

到目前为止,大多数引用值的示例使用的是Object 类型。Object 是ECMAScript中最常用的类型之一。虽然Object 的实例没有多少功能,但很适合存储和在应用程序间交换数据。

显式地创建Object 的实例有两种方式。第一种是使用new 操作符和Object 构造函数,如下所示:

let person = new Object();
person.name = "Nicholas";
person.age = 29;

另一种方式是使用对象字面量 (object literal)表示法。对象字面量是对象定义的简写形式,目的是为了简化包含大量属性的对象的创建。比如,下面的代码定义了与前面示例相同的person 对象,但使用的是对象字面量表示法:

let person = {
  name: "Nicholas",
  age: 29
};

在这个例子中,左大括号({ )表示对象字面量开始,因为它出现在一个表达式上下文 (expression context)中。在ECMAScript中,表达式上下文指的是期待返回值的上下文。赋值操作符表示后面要期待一个值,因此左大括号表示一个表达式的开始。同样是左大括号,如果出现在语句上下文 (statement context)中,比如if 语句的条件后面,则表示一个语句块的开始。

接下来指定了name 属性,后跟一个冒号,然后是属性的值。逗号用于在对象字面量中分隔属性,因此字符串"Nicholas" 后面有一个逗号,而29 后面没有,因为age 是这个对象的最后一个属性。在最后一个属性后面加上逗号在非常老的浏览器中会导致报错,但所有现代浏览器都支持这种写法。

在对象字面量表示法中,属性名可以是字符串或数值,比如:

let person = {
  "name": "Nicholas",
  "age": 29,
  5: true
};

这个例子会得到一个带有属性nameage5 的对象。注意,数值属性会自动转换为字符串。

当然也可以用对象字面量表示法来定义一个只有默认属性和方法的对象,只要使用一对大括号,中间留空就行了:

let person = {}; // 与new Object()相同
person.name = "Nicholas";
person.age = 29;

这个例子跟本节开始的第一个例子是等效的,虽然看起来有点怪。对象字面量表示法通常只在为了让属性一目了然时才使用。

注意  在使用对象字面量表示法定义对象时,并不会实际调用Object 构造函数。

虽然使用哪种方式创建Object 实例都可以,但实际上开发者更倾向于使用对象字面量表示法。这是因为对象字面量代码更少,看起来也更有封装所有相关数据的感觉。事实上,对象字面量已经成为给函数传递大量可选参数的主要方式,比如:

function displayInfo(args) {
  let output = "";

  if (typeof args.name == "string"){
    output += "Name: " + args.name + "\n";
  }

  if (typeof args.age == "number") {
    output += "Age: " + args.age + "\n";
  }

  alert(output);
}

displayInfo({
  name: "Nicholas",
  age: 29
});

displayInfo({
  name: "Greg"
});

这里,函数displayInfo() 接收一个名为args 的参数。这个参数可能有属性nameage ,也可能两个属性都有或者都没有。函数内部会使用typeof 操作符测试每个属性是否存在,然后根据属性有无构造并显示一条消息。然后,这个函数被调用了两次,每次都通过一个对象字面量传入了不同的数据。两种情况下,函数都正常运行。

注意  这种模式非常适合函数有大量可选参数的情况。一般来说,命名参数更直观,但在可选参数过多的时候就显得笨拙了。最好的方式是对必选参数使用命名参数,再通过一个对象字面量来封装多个可选参数。

虽然属性一般是通过点语法 来存取的,这也是面向对象语言的惯例,但也可以使用中括号来存取属性。在使用中括号时,要在括号内使用属性名的字符串形式,比如:

console.log(person["name"]); // "Nicholas"
console.log(person.name);    // "Nicholas"

从功能上讲,这两种存取属性的方式没有区别。使用中括号的主要优势就是可以通过变量访问属性,就像下面这个例子中一样:

let propertyName = "name";
console.log(person[propertyName]); // "Nicholas"

另外,如果属性名中包含可能会导致语法错误的字符,或者包含关键字/保留字时,也可以使用中括号语法。比如:

person["first name"] = "Nicholas";

因为"first name" 中包含一个空格,所以不能使用点语法来访问。不过,属性名中是可以包含非字母数字字符的,这时候只要用中括号语法存取它们就行了。

通常,点语法是首选的属性存取方式,除非访问属性时必须使用变量。

注意  第8章将更全面、深入地介绍Object 类型。

除了ObjectArray 应该就是ECMAScript中最常用的类型了。ECMAScript数组跟其他编程语言的数组有很大区别。跟其他语言中的数组一样,ECMAScript数组也是一组有序的数据,但跟其他语言不同的是,数组中每个槽位可以存储任意类型的数据。这意味着可以创建一个数组,它的第一个元素是字符串,第二个元素是数值,第三个是对象。ECMAScript数组也是动态大小的,会随着数据添加而自动增长。

有几种基本的方式可以创建数组。一种是使用Array 构造函数,比如:

let colors = new Array();

如果知道数组中元素的数量,那么可以给构造函数传入一个数值,然后length 属性就会被自动创建并设置为这个值。比如,下面的代码会创建一个初始length 为20的数组:

let colors = new Array(20);

也可以给Array 构造函数传入要保存的元素。比如,下面的代码会创建一个包含3个字符串值的数组:

let colors = new Array("red", "blue", "green");

创建数组时可以给构造函数传一个值。这时候就有点问题了,因为如果这个值是数值,则会创建一个长度为指定数值的数组;而如果这个值是其他类型的,则会创建一个只包含该特定值的数组。下面看一个例子:

let colors = new Array(3);     // 创建一个包含3个元素的数组
let names = new Array("Greg"); // 创建一个只包含一个元素,即字符串"Greg"的数组

在使用Array 构造函数时,也可以省略new 操作符。结果是一样的,比如:

let colors = Array(3);     // 创建一个包含3个元素的数组
let names = Array("Greg"); // 创建一个只包含一个元素,即字符串"Greg"的数组

另一种创建数组的方式是使用数组字面量 (array literal)表示法。数组字面量是在中括号中包含以逗号分隔的元素列表,如下面的例子所示:

let colors = ["red", "blue", "green"];  // 创建一个包含3个元素的数组
let names = [];                         // 创建一个空数组
let values = [1,2,];                    // 创建一个包含2个元素的数组

在这个例子中,第一行创建一个包含3个字符串的数组。第二行用一对空中括号创建了一个空数组。第三行展示了在数组最后一个值后面加逗号的效果:values 是一个包含两个值(1和2)的数组。

注意  与对象一样,在使用数组字面量表示法创建数组不会调用Array 构造函数。

Array 构造函数还有两个ES6新增的用于创建数组的静态方法:from()of()from() 用于将类数组结构转换为数组实例,而of() 用于将一组参数转换为数组实例。

Array.from() 的第一个参数是一个类数组对象,即任何可迭代的结构,或者有一个length 属性和可索引元素的结构。这种方式可用于很多场合:

// 字符串会被拆分为单字符数组
console.log(Array.from("Matt")); // ["M", "a", "t", "t"]

// 可以使用from()将集合和映射转换为一个新数组
const m = new Map().set(1, 2)
                   .set(3, 4);
const s = new Set().add(1)
                   .add(2)
                   .add(3)
                   .add(4);

console.log(Array.from(m)); // [[1, 2], [3, 4]]
console.log(Array.from(s)); // [1, 2, 3, 4]

// Array.from()对现有数组执行浅复制
const a1 = [1, 2, 3, 4];
const a2 = Array.from(a1);

console.log(a1);        // [1, 2, 3, 4]
alert(a1 === a2); // false


// 可以使用任何可迭代对象
const iter = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
    yield 3;
    yield 4;

  }
};
console.log(Array.from(iter)); // [1, 2, 3, 4]

// arguments对象可以被轻松地转换为数组
function getArgsArray() {
  return Array.from(arguments);
}
console.log(getArgsArray(1, 2, 3, 4)); // [1, 2, 3, 4]

// from()也能转换带有必要属性的自定义对象
const arrayLikeObject = {
  0: 1,
  1: 2,
  2: 3,
  3: 4,
  length: 4
};
console.log(Array.from(arrayLikeObject)); // [1, 2, 3, 4]

Array.from() 还接收第二个可选的映射函数参数。这个函数可以直接增强新数组的值,而无须像调用Array.from().map() 那样先创建一个中间数组。还可以接收第三个可选参数,用于指定映射函数中this 的值。但这个重写的this 值在箭头函数中不适用。

const a1 = [1, 2, 3, 4];
const a2 = Array.from(a1, x => x**2);
const a3 = Array.from(a1, function(x) {return x**this.exponent}, {exponent: 2});
console.log(a2);  // [1, 4, 9, 16]
console.log(a3);  // [1, 4, 9, 16]

Array.of() 可以把一组参数转换为数组。这个方法用于替代在ES6之前常用的Array.prototype.slice.call(arguments) ,一种异常笨拙的将arguments 对象转换为数组的写法:

console.log(Array.of(1, 2, 3, 4)); // [1, 2, 3, 4]
console.log(Array.of(undefined));  // [undefined]

使用数组字面量初始化数组时,可以使用一串逗号来创建空位(hole)。ECMAScript会将逗号之间相应索引位置的值当成空位,ES6规范重新定义了该如何处理这些空位。

可以像下面这样创建一个空位数组:

const options = [,,,,,]; // 创建包含5个元素的数组
console.log(options.length);   // 5
console.log(options);          // [,,,,,]

ES6新增的方法和迭代器与早期ECMAScript版本中存在的方法行为不同。ES6新增方法普遍将这些空位当成存在的元素,只不过值为undefined

const options = [1,,,,5];

for (const option of options) {
  console.log(option === undefined);
}
// false
// true
// true
// true
// false

const a = Array.from([,,,]); // 使用ES6的Array.from()创建的包含3个空位的数组
for (const val of a) {
  alert(val === undefined);
}
// true
// true
// true

alert(Array.of(...[,,,])); // [undefined, undefined, undefined]

for (const [index, value] of options.entries()) {
  alert(value);
}
// 1
// undefined
// undefined
// undefined
// 5

ES6之前的方法则会忽略这个空位,但具体的行为也会因方法而异:

const options = [1,,,,5];

// map()会跳过空位置
console.log(options.map(() => 6));  // [6, undefined, undefined, undefined, 6]

// join()视空位置为空字符串
console.log(options.join('-'));     // "1----5"

注意  由于行为不一致和存在性能隐患,因此实践中要避免使用数组空位。如果确实需要空位,则可以显式地用undefined 值代替。

要取得或设置数组的值,需要使用中括号并提供相应值的数字索引,如下所示:

let colors = ["red", "blue", "green"];  // 定义一个字符串数组
alert(colors[0]);                       // 显示第一项
colors[2] = "black";                    // 修改第三项
colors[3] = "brown";                    // 添加第四项

在中括号中提供的索引表示要访问的值。如果索引小于数组包含的元素数,则返回存储在相应位置的元素,就像示例中colors[0] 显示"red" 一样。设置数组的值方法也是一样的,就是替换指定位置的值。如果把一个值设置给超过数组最大索引的索引,就像示例中的colors[3] ,则数组长度会自动扩展到该索引值加1(示例中设置的索引3,所以数组长度变成了4)。

数组中元素的数量保存在length 属性中,这个属性始终返回0或大于0的值,如下例所示:

let colors = ["red", "blue", "green"];  // 创建一个包含3个字符串的数组
let names = [];                         // 创建一个空数组

alert(colors.length); // 3
alert(names.length);  // 0

数组length 属性的独特之处在于,它不是只读的。通过修改length 属性,可以从数组末尾删除或添加元素。来看下面的例子:

let colors = ["red", "blue", "green"]; // 创建一个包含3个字符串的数组
colors.length = 2;
alert(colors[2]);  // undefined

这里,数组colors 一开始有3个值。将length 设置为2,就删除了最后一个(位置2的)值,因此colors[2] 就没有值了。如果将length 设置为大于数组元素数的值,则新添加的元素都将以undefined 填充,如下例所示:

let colors = ["red", "blue", "green"];  // 创建一个包含3个字符串的数组
colors.length = 4;
alert(colors[3]);  // undefined

这里将数组colorslength 设置为4,虽然数组只包含3个元素。位置3在数组中不存在,因此访问其值会返回特殊值undefined

使用length 属性可以方便地向数组末尾添加元素,如下例所示:

let colors = ["red", "blue", "green"];  // 创建一个包含3个字符串的数组
colors[colors.length] = "black";        // 添加一种颜色(位置3)
colors[colors.length] = "brown";        // 再添加一种颜色(位置4)

数组中最后一个元素的索引始终是length - 1 ,因此下一个新增槽位的索引就是length 。每次在数组最后一个元素后面新增一项,数组的length 属性都会自动更新,以反映变化。这意味着第二行的colors[colors.length] 会在位置3添加一个新元素,下一行则会在位置4添加一个新元素。新的长度会在新增元素被添加到当前数组外部的位置上时自动更新。换句话说,就是length 属性会更新为位置加上1,如下例所示:

let colors = ["red", "blue", "green"];  // 创建一个包含3个字符串的数组
colors[99] = "black";                   // 添加一种颜色(位置99)
alert(colors.length);                   // 100

这里,colors 数组有一个值被插入到位置99,结果新length 就变成了100(99 + 1)。这中间的所有元素,即位置3~98,实际上并不存在,因此在访问时会返回undefined

注意  数组最多可以包含4 294 967 295个元素,这对于大多数编程任务应该足够了。如果尝试添加更多项,则会导致抛出错误。以这个最大值作为初始值创建数组,可能导致脚本运行时间过长的错误。

一个经典的ECMAScript问题是判断一个对象是不是数组。在只有一个网页(因而只有一个全局作用域)的情况下,使用instanceof 操作符就足矣:

if (value instanceof Array){
  // 操作数组
}

使用instanceof 的问题是假定只有一个全局执行上下文。如果网页里有多个框架,则可能涉及两个不同的全局执行上下文,因此就会有两个不同版本的Array 构造函数。如果要把数组从一个框架传给另一个框架,则这个数组的构造函数将有别于在第二个框架内本地创建的数组。

为解决这个问题,ECMAScript提供了Array.isArray() 方法。这个方法的目的就是确定一个值是否为数组,而不用管它是在哪个全局执行上下文中创建的。来看下面的例子:

if (Array.isArray(value)){
  // 操作数组
}

在ES6中,Array 的原型上暴露了3个用于检索数组内容的方法:keys()values()entries()keys() 返回数组索引的迭代器,values() 返回数组元素的迭代器,而entries() 返回索引/值对的迭代器:

const a = ["foo", "bar", "baz", "qux"];

// 因为这些方法都返回迭代器,所以可以将它们的内容
// 通过Array.from()直接转换为数组实例
const aKeys = Array.from(a.keys());
const aValues = Array.from(a.values());
const aEntries = Array.from(a.entries());

console.log(aKeys);     // [0, 1, 2, 3]
console.log(aValues);   // ["foo", "bar", "baz", "qux"]
console.log(aEntries);  // [[0, "foo"], [1, "bar"], [2, "baz"], [3, "qux"]]

使用ES6的解构可以非常容易地在循环中拆分键/值对:

const a = ["foo", "bar", "baz", "qux"];

for (const [idx, element] of a.entries()) {
  alert(idx);
  alert(element);
}
// 0
// foo
// 1
// bar
// 2
// baz
// 3
// qux

注意  虽然这些方法是ES6规范定义的,但在2017年底的时候仍有浏览器没有实现它们。

ES6新增了两个方法:批量复制方法copyWithin() ,以及填充数组方法fill() 。这两个方法的函数签名类似,都需要指定既有数组实例上的一个范围,包含开始索引,不包含结束索引。使用这个方法不会改变数组的大小。

使用fill() 方法可以向一个已有的数组中插入全部或部分相同的值。开始索引用于指定开始填充的位置,它是可选的。如果不提供结束索引,则一直填充到数组末尾。负值索引从数组末尾开始计算。也可以将负索引想象成数组长度加上它得到的一个正索引:

const zeroes = [0, 0, 0, 0, 0];

// 用5填充整个数组
zeroes.fill(5);
console.log(zeroes);  // [5, 5, 5, 5, 5]
zeroes.fill(0);       // 重置

// 用6填充索引大于等于3的元素
zeroes.fill(6, 3);
console.log(zeroes);  // [0, 0, 0, 6, 6]
zeroes.fill(0);       // 重置

// 用7填充索引大于等于1且小于3的元素
zeroes.fill(7, 1, 3);
console.log(zeroes);  // [0, 7, 7, 0, 0];
zeroes.fill(0);       // 重置

// 用8填充索引大于等于1且小于4的元素
// (-4 + zeroes.length = 1)
// (-1 + zeroes.length = 4)
zeroes.fill(8, -4, -1);
console.log(zeroes);  // [0, 8, 8, 8, 0];

fill() 静默忽略超出数组边界、零长度及方向相反的索引范围:

const zeroes = [0, 0, 0, 0, 0];

// 索引过低,忽略
zeroes.fill(1, -10, -6);
console.log(zeroes);  // [0, 0, 0, 0, 0]

// 索引过高,忽略
zeroes.fill(1, 10, 15);
console.log(zeroes);  // [0, 0, 0, 0, 0]

// 索引反向,忽略
zeroes.fill(2, 4, 2);
console.log(zeroes);  // [0, 0, 0, 0, 0]

// 索引部分可用,填充可用部分
zeroes.fill(4, 3, 10)
console.log(zeroes);  // [0, 0, 0, 4, 4]

fill() 不同,copyWithin() 会按照指定范围浅复制数组中的部分内容,然后将它们插入到指定索引开始的位置。开始索引和结束索引则与fill() 使用同样的计算方法:

let ints,
    reset = () => ints = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset();

// 从ints中复制索引0开始的内容,插入到索引5开始的位置
// 在源索引或目标索引到达数组边界时停止
ints.copyWithin(5);
console.log(ints);  // [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]
reset();

// 从ints中复制索引5开始的内容,插入到索引0开始的位置
ints.copyWithin(0, 5);
console.log(ints);  // [5, 6, 7, 8, 9, 5, 6, 7, 8, 9]
reset();

// 从ints中复制索引0开始到索引3结束的内容
// 插入到索引4开始的位置
ints.copyWithin(4, 0, 3);
alert(ints);  // [0, 1, 2, 3, 0, 1, 2, 7, 8, 9]
reset();

// JavaScript引擎在插值前会完整复制范围内的值
// 因此复制期间不存在重写的风险
ints.copyWithin(2, 0, 6);
alert(ints);  // [0, 1, 0, 1, 2, 3, 4, 5, 8, 9]
reset();

// 支持负索引值,与fill()相对于数组末尾计算正向索引的过程是一样的
ints.copyWithin(-4, -7, -3);
alert(ints);  // [0, 1, 2, 3, 4, 5, 3, 4, 5, 6]

copyWithin() 静默忽略超出数组边界、零长度及方向相反的索引范围:

let ints,
    reset = () => ints = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset();

// 索引过低,忽略
ints.copyWithin(1, -15, -12);
alert(ints);  // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset()

// 索引过高,忽略
ints.copyWithin(1, 12, 15);
alert(ints);  // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset();

// 索引反向,忽略
ints.copyWithin(2, 4, 2);
alert(ints);  // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
reset();

// 索引部分可用,复制、填充可用部分
ints.copyWithin(4, 7, 10)
alert(ints);  // [0, 1, 2, 3, 7, 8, 9, 7, 8, 9];

前面提到过,所有对象都有toLocaleString()toString()valueOf() 方法。其中,valueOf() 返回的还是数组本身。而toString() 返回由数组中每个值的等效字符串拼接而成的一个逗号分隔的字符串。也就是说,对数组的每个值都会调用其toString() 方法,以得到最终的字符串。来看下面的例子:

let colors = ["red", "blue", "green"]; // 创建一个包含3个字符串的数组
alert(colors.toString());   // red,blue,green
alert(colors.valueOf());    // red,blue,green
alert(colors);              // red,blue,green

首先是被显式调用的toString()valueOf() 方法,它们分别返回了数组的字符串表示,即将所有字符串组合起来,以逗号分隔。最后一行代码直接用alert() 显示数组,因为alert() 期待字符串,所以会在后台调用数组的toString() 方法,从而得到跟前面一样的结果。

toLocaleString() 方法也可能返回跟toString()valueOf() 相同的结果,但也不一定。在调用数组的toLocaleString() 方法时,会得到一个逗号分隔的数组值的字符串。它与另外两个方法唯一的区别是,为了得到最终的字符串,会调用数组每个值的toLocaleString() 方法,而不是toString() 方法。看下面的例子:

let person1 = {
  toLocaleString() {
    return "Nikolaos";
  },

  toString() {
    return "Nicholas";
  }
};

let person2 = {
  toLocaleString() {
    return "Grigorios";
  },

  toString() {
    return "Greg";
  }
};

let people = [person1, person2];
alert(people);                   // Nicholas,Greg
alert(people.toString());        // Nicholas,Greg
alert(people.toLocaleString());  // Nikolaos,Grigorios

这里定义了两个对象person1person2 ,它们都定义了toString()toLocaleString() 方法,而且返回不同的值。然后又创建了一个包含这两个对象的数组people 。在将数组传给alert() 时,输出的是"Nicholas,Greg" ,这是因为会在数组每一项上调用toString() 方法(与下一行显式调用toString() 方法结果一样)。而在调用数组的toLocaleString() 方法时,结果变成了"Nikolaos, Grigorios" ,这是因为调用了数组每一项的toLocaleString() 方法。

继承的方法toLocaleString() 以及toString() 都返回数组值的逗号分隔的字符串。如果想使用不同的分隔符,则可以使用join() 方法。join() 方法接收一个参数,即字符串分隔符,返回包含所有项的字符串。来看下面的例子:

let colors = ["red", "green", "blue"];
alert(colors.join(","));     // red,green,blue
alert(colors.join("||"));    // red||green||blue

这里在colors 数组上调用了join() 方法,得到了与调用toString() 方法相同的结果。传入逗号,结果就是逗号分隔的字符串。最后一行给join() 传入了双竖线,得到了字符串"red||green||blue" 。如果不给join() 传入任何参数,或者传入undefined ,则仍然使用逗号作为分隔符。

注意  如果数组中某一项是nullundefined ,则在join()toLocaleString()toString()valueOf() 返回的结果中会以空字符串表示。

ECMAScript给数组提供几个方法,让它看起来像是另外一种数据结构。数组对象可以像栈一样,也就是一种限制插入和删除项的数据结构。栈是一种后进先出(LIFO,Last-In-First-Out)的结构,也就是最近添加的项先被删除。数据项的插入(称为推入 ,push)和删除(称为弹出 ,pop)只在栈的一个地方发生,即栈顶。ECMAScript数组提供了push()pop() 方法,以实现类似栈的行为。

push() 方法接收任意数量的参数,并将它们添加到数组末尾,返回数组的最新长度。pop() 方法则用于删除数组的最后一项,同时减少数组的length 值,返回被删除的项。来看下面的例子:

let colors = new Array();                 // 创建一个数组
let count = colors.push("red", "green");  // 推入两项
alert(count);                             // 2

count = colors.push("black");  // 再推入一项
alert(count);                  // 3

let item = colors.pop();       // 取得最后一项
alert(item);                   // black
alert(colors.length);          // 2

这里创建了一个当作栈来使用的数组(注意不需要任何额外的代码,push()pop() 都是数组的默认方法)。首先,使用push() 方法把两个字符串推入数组末尾,将结果保存在变量count 中(结果为2 )。

然后,再推入另一个值,再把结果保存在count 中。因为现在数组中有3个元素,所以push() 返回3 。在调用pop() 时,会返回数组的最后一项,即字符串"black" 。此时数组还有两个元素。

栈方法可以与数组的其他任何方法一起使用,如下例所示:

let colors = ["red", "blue"];
colors.push("brown");        // 再添加一项
colors[3] = "black";         // 添加一项
alert(colors.length);        // 4

let item = colors.pop();     // 取得最后一项
alert(item);                 // black

这里先初始化了包含两个字符串的数组,然后通过push() 添加了第三个值,第四个值是通过直接在位置3上赋值添加的。调用pop() 时,返回了字符串"black" ,也就是最后添加到数组的字符串。

就像栈是以LIFO形式限制访问的数据结构一样,队列以先进先出(FIFO,First-In-First-Out)形式限制访问。队列在列表末尾添加数据,但从列表开头获取数据。因为有了在数据末尾添加数据的push() 方法,所以要模拟队列就差一个从数组开头取得数据的方法了。这个数组方法叫shift() ,它会删除数组的第一项并返回它,然后数组长度减1。使用shift()push() ,可以把数组当成队列来使用:

let colors = new Array();                 // 创建一个数组
let count = colors.push("red", "green");  // 推入两项
alert(count);                             // 2

count = colors.push("black"); // 再推入一项
alert(count);                 // 3

let item = colors.shift();  // 取得第一项
alert(item);                // red
alert(colors.length);       // 2

这个例子创建了一个数组并用push() 方法推入三个值。加粗的那行代码使用shift() 方法取得了数组的第一项,即"red" 。删除这一项之后,"green" 成为第一个元素,"black" 成为第二个元素,数组此时就包含两项。

ECMAScript也为数组提供了unshift() 方法。顾名思义,unshift() 就是执行跟shift() 相反的操作:在数组开头添加任意多个值,然后返回新的数组长度。通过使用unshift()pop() ,可以在相反方向上模拟队列,即在数组开头添加新数据,在数组末尾取得数据,如下例所示:

let colors = new Array();                    // 创建一个数组
let count = colors.unshift("red", "green");  // 从数组开头推入两项
alert(count);                                // 2

count = colors.unshift("black");  // 再推入一项
alert(count);                     // 3

let item = colors.pop();  // 取得最后一项
alert(item);              // green
alert(colors.length);     // 2

这里,先创建一个数组,再通过unshift() 填充数组。首先,给数组添加"red""green" ,再添加"black" ,得到["black","red","green"] 。调用pop() 时,删除最后一项"green" 并返回它。

数组有两个方法可以用来对元素重新排序:reverse()sort() 。顾名思义,reverse() 方法就是将数组元素反向排列。比如:

let values = [1, 2, 3, 4, 5];
values.reverse();
alert(values);  // 5,4,3,2,1

这里,数组values 的初始状态为[1,2,3,4,5] 。通过调用reverse() 反向排序,得到了[5,4,3,2,1] 。这个方法很直观,但不够灵活,所以才有了sort() 方法。

默认情况下,sort() 会按照升序重新排列数组元素,即最小的值在前面,最大的值在后面。为此,sort() 会在每一项上调用String() 转型函数,然后比较字符串来决定顺序。即使数组的元素都是数值,也会先把数组转换为字符串再比较、排序。比如:

let values = [0, 1, 5, 10, 15];
values.sort();
alert(values);  // 0,1,10,15,5

一开始数组中数值的顺序是正确的,但调用sort() 会按照这些数值的字符串形式重新排序。因此,即使5小于10,但字符串"10" 在字符串"5" 的前头,所以10还是会排到5前面。很明显,这在多数情况下都不是最合适的。为此,sort() 方法可以接收一个比较函数 ,用于判断哪个值应该排在前面。

比较函数接收两个参数,如果第一个参数应该排在第二个参数前面,就返回负值;如果两个参数相等,就返回0;如果第一个参数应该排在第二个参数后面,就返回正值。下面是使用简单比较函数的一个例子:

function compare(value1, value2) {
  if (value1 < value2) {
    return -1;
  } else if (value1 > value2) {
    return 1;
  } else {
    return 0;
  }
}

这个比较函数可以适用于大多数数据类型,可以把它当作参数传给sort() 方法,如下所示:

let values = [0, 1, 5, 10, 15];
values.sort(compare);
alert(values);  // 0,1,5,10,15

在给sort() 方法传入比较函数后,数组中的数值在排序后保持了正确的顺序。当然,比较函数也可以产生降序效果,只要把返回值交换一下即可:

function compare(value1, value2) {
  if (value1 < value2) {
    return 1;
  } else if (value1 > value2) {
    return -1;
  } else {
    return 0;
  }
}

let values = [0, 1, 5, 10, 15];
values.sort(compare);
alert(values);  // 15,10,5,1,0

此外,这个比较函数还可简写为一个箭头函数:

let values = [0, 1, 5, 10, 15];
values.sort((a, b) => a < b ? 1 : a > b ? -1 : 0);
alert(values); // 15,10,5,1,0

在这个修改版函数中,如果第一个值应该排在第二个值后面则返回1,如果第一个值应该排在第二个值前面则返回-1。交换这两个返回值之后,较大的值就会排在前头,数组就会按照降序排序。当然,如果只是想反转数组的顺序,reverse() 更简单也更快。

注意  reverse()sort() 都返回调用它们的数组的引用。

如果数组的元素是数值,或者是其valueOf() 方法返回数值的对象(如Date 对象),这个比较函数还可以写得更简单,因为这时可以直接用第二个值减去第一个值:

function compare(value1, value2){
  return value2 - value1;
}

比较函数就是要返回小于0、0和大于0的数值,因此减法操作完全可以满足要求。

对于数组中的元素,我们有很多操作方法。比如,concat() 方法可以在现有数组全部元素基础上创建一个新数组。它首先会创建一个当前数组的副本,然后再把它的参数添加到副本末尾,最后返回这个新构建的数组。如果传入一个或多个数组,则concat() 会把这些数组的每一项都添加到结果数组。如果参数不是数组,则直接把它们添加到结果数组末尾。来看下面的例子:

let colors = ["red", "green", "blue"];
let colors2 = colors.concat("yellow", ["black", "brown"]);

console.log(colors);   // ["red", "green","blue"]
console.log(colors2);  // ["red", "green", "blue", "yellow", "black", "brown"]

这里先创建一个包含3个值的数组colors 。然后colors 调用concat() 方法,传入字符串"yellow" 和一个包含"black""brown" 的数组。保存在colors2 中的结果就是["red", "green", "blue", "yellow", "black", "brown"] 。原始数组colors 保持不变。

打平数组参数的行为可以重写,方法是在参数数组上指定一个特殊的符号:Symbol.isConcatSpreadable 。这个符号能够阻止concat() 打平参数数组。相反,把这个值设置为true 可以强制打平类数组对象:

let colors = ["red", "green", "blue"];
let newColors = ["black", "brown"];
let moreNewColors = {
  [Symbol.isConcatSpreadable]: true,
  length: 2,
  0: "pink",
  1: "cyan"
};

newColors[Symbol.isConcatSpreadable] = false;

// 强制不打平数组
let colors2 = colors.concat("yellow", newColors);

// 强制打平类数组对象
let colors3 = colors.concat(moreNewColors);

console.log(colors);   // ["red", "green", "blue"]
console.log(colors2);  // ["red", "green", "blue", "yellow", ["black", "brown"]]
console.log(colors3);  // ["red", "green", "blue", "pink", "cyan"]

接下来,方法slice() 用于创建一个包含原有数组中一个或多个元素的新数组。slice() 方法可以接收一个或两个参数:返回元素的开始索引和结束索引。如果只有一个参数,则slice() 会返回该索引到数组末尾的所有元素。如果有两个参数,则slice() 返回从开始索引到结束索引对应的所有元素,其中不包含结束索引对应的元素。记住,这个操作不影响原始数组。来看下面的例子:

let colors = ["red", "green", "blue", "yellow", "purple"];
let colors2 = colors.slice(1);
let colors3 = colors.slice(1, 4);

alert(colors2);  // green,blue,yellow,purple
alert(colors3);  // green,blue,yellow

这里,colors 数组一开始有5个元素。调用slice() 传入1会得到包含4个元素的新数组。其中不包括"red" ,这是因为拆分操作要从位置1开始,即从"green" 开始。得到的colors2 数组包含"green""blue""yellow""purple"colors3 数组是通过调用slice() 并传入1和4得到的,即从位置1开始复制到位置3。因此colors3 包含"green""blue""yellow"

注意  如果slice() 的参数有负值,那么就以数值长度加上这个负值的结果确定位置。比如,在包含5个元素的数组上调用slice(-2,-1) ,就相当于调用slice(3,4) 。如果结束位置小于开始位置,则返回空数组。

或许最强大的数组方法就属splice() 了,使用它的方式可以有很多种。splice() 的主要目的是在数组中间插入元素,但有3 种不同的方式使用这个方法。

splice() 方法始终返回这样一个数组,它包含从数组中被删除的元素(如果没有删除元素,则返回空数组)。以下示例展示了上述3种使用方式。

let colors = ["red", "green", "blue"];
let removed = colors.splice(0,1);  // 删除第一项
alert(colors);                     // green,blue
alert(removed);                    // red,只有一个元素的数组

removed = colors.splice(1, 0, "yellow", "orange");   // 在位置1插入两个元素
alert(colors);                                       // green,yellow,orange,blue
alert(removed);                                      // 空数组

removed = colors.splice(1, 1, "red", "purple");  // 插入两个值,删除一个元素
alert(colors);                                   // green,red,purple,orange,blue
alert(removed);                                  // yellow,只有一个元素的数组

这个例子中,colors 数组一开始包含3个元素。第一次调用splice() 时,只删除了第一项,colors 中还有"green""blue" 。第二次调用slice() 时,在位置1插入两项,然后colors 包含"green""yellow""orange""blue" 。这次没删除任何项,因此返回空数组。最后一次调用splice() 时删除了位置1上的一项,同时又插入了"red""purple" 。最后,colors 数组包含"green""red""purple""orange""blue"

ECMAScript提供两类搜索数组的方法:按严格相等搜索和按断言函数搜索。

  1. 严格相等

    ECMAScript提供了3个严格相等的搜索方法:indexOf()lastIndexOf()includes() 。其中,前两个方法在所有版本中都可用,而第三个方法是ECMAScript 7新增的。这些方法都接收两个参数:要查找的元素和一个可选的起始搜索位置。indexOf()includes() 方法从数组前头(第一项)开始向后搜索,而lastIndexOf() 从数组末尾(最后一项)开始向前搜索。

    indexOf()lastIndexOf() 都返回要查找的元素在数组中的位置,如果没找到则返回-1。includes() 返回布尔值,表示是否至少找到一个与指定元素匹配的项。在比较第一个参数跟数组每一项时,会使用全等(=== )比较,也就是说两项必须严格相等。下面来看一些例子:

    let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];
    
    alert(numbers.indexOf(4));          // 3
    alert(numbers.lastIndexOf(4));      // 5
    alert(numbers.includes(4));         // true
    
    alert(numbers.indexOf(4, 4));       // 5
    alert(numbers.lastIndexOf(4, 4));   // 3
    alert(numbers.includes(4, 7));      // false
    
    let person = { name: "Nicholas" };
    let people = [{ name: "Nicholas" }];
    let morePeople = [person];
    
    alert(people.indexOf(person));      // -1
    alert(morePeople.indexOf(person));  // 0
    alert(people.includes(person));     // false
    alert(morePeople.includes(person)); // true
    
    

     

  2. 断言函数

    ECMAScript也允许按照定义的断言函数搜索数组,每个索引都会调用这个函数。断言函数的返回值决定了相应索引的元素是否被认为匹配。

    断言函数接收3个参数:元素、索引和数组本身。其中元素是数组中当前搜索的元素,索引是当前元素的索引,而数组就是正在搜索的数组。断言函数返回真值,表示是否匹配。

    find()findIndex() 方法使用了断言函数。这两个方法都从数组的最小索引开始。find() 返回第一个匹配的元素,findIndex() 返回第一个匹配元素的索引。这两个方法也都接收第二个可选的参数,用于指定断言函数内部this 的值。

    const people = [
      {
        name: "Matt",
        age: 27
      },
      {
        name: "Nicholas",
        age: 29
      }
    ];
    
    alert(people.find((element, index, array) => element.age < 28));
    // {name: "Matt", age: 27}
    
    alert(people.findIndex((element, index, array) => element.age < 28));
    // 0
    
    

    找到匹配项后,这两个方法都不再继续搜索。

    const evens = [2, 4, 6];
    
    // 找到匹配后,永远不会检查数组的最后一个元素
    evens.find((element, index, array) => {
      console.log(element);
      console.log(index);
      console.log(array);
      return element === 4;
    });
    // 2
    // 0
    // [2, 4, 6]
    // 4
    // 1
    // [2, 4, 6]
    
    

ECMAScript为数组定义了5个迭代方法。每个方法接收两个参数:以每一项为参数运行的函数,以及可选的作为函数运行上下文的作用域对象(影响函数中this 的值)。传给每个方法的函数接收3个参数:数组元素、元素索引和数组本身。因具体方法而异,这个函数的执行结果可能会也可能不会影响方法的返回值。数组的5个迭代方法如下。

这些方法都不改变调用它们的数组。

在这些方法中,every()some() 是最相似的,都是从数组中搜索符合某个条件的元素。对every() 来说,传入的函数必须对每一项都返回true ,它才会返回true ;否则,它就返回false 。而对some() 来说,只要有一项让传入的函数返回true ,它就会返回true 。下面是一个例子:

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];

let everyResult = numbers.every((item, index, array) => item > 2);
alert(everyResult);  // false

let someResult = numbers.some((item, index, array) => item > 2);
alert(someResult);   // true

以上代码调用了every()some() ,传入的函数都是在给定项大于2时返回trueevery() 返回false 是因为并不是每一项都能达到要求。而some() 返回true 是因为至少有一项满足条件。

下面再看一看filter() 方法。这个方法基于给定的函数来决定某一项是否应该包含在它返回的数组中。比如,要返回一个所有数值都大于2的数组,可以使用如下代码:

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];

let filterResult = numbers.filter((item, index, array) => item > 2);
alert(filterResult);  // 3,4,5,4,3

这里,调用filter() 返回的数组包含34543 ,因为只有对这些项传入的函数才返回true 。这个方法非常适合从数组中筛选满足给定条件的元素。

接下来map() 方法也会返回一个数组。这个数组的每一项都是对原始数组中同样位置的元素运行传入函数而返回的结果。例如,可以将一个数组中的每一项都乘以2,并返回包含所有结果的数组,如下所示:

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];

let mapResult = numbers.map((item, index, array) => item * 2);

alert(mapResult);  // 2,4,6,8,10,8,6,4,2

以上代码返回了一个数组,包含原始数组中每个值乘以2的结果。这个方法非常适合创建一个与原始数组元素一一对应的新数组。

最后,再来看一看forEach() 方法。这个方法只会对每一项运行传入的函数,没有返回值。本质上,forEach() 方法相当于使用for 循环遍历数组。比如:

let numbers = [1, 2, 3, 4, 5, 4, 3, 2, 1];

numbers.forEach((item, index, array) => {
  // 执行某些操作
});

数组的这些迭代方法通过执行不同操作方便了对数组的处理。

ECMAScript为数组提供了两个归并方法:reduce()reduceRight() 。这两个方法都会迭代数组的所有项,并在此基础上构建一个最终返回值。reduce() 方法从数组第一项开始遍历到最后一项。而reduceRight() 从最后一项开始遍历至第一项。

这两个方法都接收两个参数:对每一项都会运行的归并函数,以及可选的以之为归并起点的初始值。传给reduce()reduceRight() 的函数接收4个参数:上一个归并值、当前项、当前项的索引和数组本身。这个函数返回的任何值都会作为下一次调用同一个函数的第一个参数。如果没有给这两个方法传入可选的第二个参数(作为归并起点值),则第一次迭代将从数组的第二项开始,因此传给归并函数的第一个参数是数组的第一项,第二个参数是数组的第二项。

可以使用reduce() 函数执行累加数组中所有数值的操作,比如:

let values = [1, 2, 3, 4, 5];
let sum = values.reduce((prev, cur, index, array) => prev + cur);

alert(sum);  // 15

第一次执行归并函数时,prev 是1,cur 是2。第二次执行时,prev 是3(1 + 2),cur 是3(数组第三项)。如此递进,直到把所有项都遍历一次,最后返回归并结果。

reduceRight() 方法与之类似,只是方向相反。来看下面的例子:

let values = [1, 2, 3, 4, 5];
let sum = values.reduceRight(function(prev, cur, index, array){
  return prev + cur;
});
alert(sum); // 15

在这里,第一次调用归并函数时prev 是5,而cur 是4。当然,最终结果相同,因为归并操作都是简单的加法。

究竟是使用reduce() 还是reduceRight() ,只取决于遍历数组元素的方向。除此之外,这两个方法没什么区别。

定型数组(typed array)是ECMAScript新增的结构,目的是提升向原生库传输数据的效率。实际上,JavaScript并没有“TypedArray”类型,它所指的其实是一种特殊的包含数值类型的数组。为理解如何使用定型数组,有必要先了解一下它的用途。

随着浏览器的流行,不难想象人们会满怀期待地通过它来运行复杂的3D应用程序。早在2006年,Mozilla、Opera等浏览器提供商就实验性地在浏览器中增加了用于渲染复杂图形应用程序的编程平台,无须安装任何插件。其目标是开发一套JavaScript API,从而充分利用3D图形API和GPU加速,以便在<canvas> 元素上渲染复杂的图形。

  1. WebGL

    最后的JavaScript API是基于OpenGL ES(OpenGL for Embedded Systems)2.0规范的。OpenGL ES是OpenGL专注于2D和3D计算机图形的子集。这个新API被命名为WebGL(Web Graphics Library),于2011年发布1.0版。有了它,开发者就能够编写涉及复杂图形的应用程序,它会被兼容WebGL的浏览器原生解释执行。

    在WebGL的早期版本中,因为JavaScript数组与原生数组之间不匹配,所以出现了性能问题。图形驱动程序API通常不需要以JavaScript默认双精度浮点格式传递给它们的数值,而这恰恰是JavaScript数组在内存中的格式。因此,每次WebGL与JavaScript运行时之间传递数组时,WebGL绑定都需要在目标环境分配新数组,以其当前格式迭代数组,然后将数值转型为新数组中的适当格式,而这些要花费很多时间。
     

  2. 定型数组

    这当然是难以接受的,Mozilla为解决这个问题而实现了CanvasFloatArray 。这是一个提供JavaScript接口的、C语言风格的浮点值数组。JavaScript运行时使用这个类型可以分配、读取和写入数组。这个数组可以直接传给底层图形驱动程序API,也可以直接从底层获取到。最终,CanvasFloatArray 变成了Float32Array ,也就是今天定型数组中可用的第一个“类型”。

Float32Array 实际上是一种“视图”,可以允许JavaScript运行时访问一块名为ArrayBuffer 的预分配内存。ArrayBuffer 是所有定型数组及视图引用的基本单位。

注意  SharedArrayBufferArrayBuffer 的一个变体,可以无须复制就在执行上下文间传递它。关于这种类型,请参考第27章。

ArrayBuffer() 是一个普通的JavaScript构造函数,可用于在内存中分配特定数量的字节空间。

const buf = new ArrayBuffer(16);  // 在内存中分配16字节
alert(buf.byteLength);            // 16

ArrayBuffer 一经创建就不能再调整大小。不过,可以使用slice() 复制其全部或部分到一个新实例中:

const buf1 = new ArrayBuffer(16);
const buf2 = buf1.slice(4, 12);
alert(buf2.byteLength);  // 8

ArrayBuffer 某种程度上类似于C++的malloc() ,但也有几个明显的区别。

不能仅通过对ArrayBuffer 的引用就读取或写入其内容。要读取或写入ArrayBuffer ,就必须通过视图。视图有不同的类型,但引用的都是ArrayBuffer 中存储的二进制数据。

第一种允许你读写ArrayBuffer 的视图是DataView 。这个视图专为文件I/O和网络I/O设计,其API支持对缓冲数据的高度控制,但相比于其他类型的视图性能也差一些。DataView 对缓冲内容没有任何预设,也不能迭代。

必须在对已有的ArrayBuffer 读取或写入时才能创建DataView 实例。这个实例可以使用全部或部分ArrayBuffer ,且维护着对该缓冲实例的引用,以及视图在缓冲中开始的位置。

const buf = new ArrayBuffer(16);

// DataView默认使用整个ArrayBuffer
const fullDataView = new DataView(buf);
alert(fullDataView.byteOffset);      // 0
alert(fullDataView.byteLength);      // 16
alert(fullDataView.buffer === buf);  // true

// 构造函数接收一个可选的字节偏移量和字节长度
//   byteOffset=0表示视图从缓冲起点开始
//   byteLength=8限制视图为前8个字节
const firstHalfDataView = new DataView(buf, 0, 8);
alert(firstHalfDataView.byteOffset);      // 0
alert(firstHalfDataView.byteLength);      // 8
alert(firstHalfDataView.buffer === buf);  // true

// 如果不指定,则DataView会使用剩余的缓冲
//   byteOffset=8表示视图从缓冲的第9个字节开始
//   byteLength未指定,默认为剩余缓冲
const secondHalfDataView = new DataView(buf, 8);
alert(secondHalfDataView.byteOffset);      // 8
alert(secondHalfDataView.byteLength);      // 8
alert(secondHalfDataView.buffer === buf);  // true

要通过DataView 读取缓冲,还需要几个组件。

• 首先是要读或写的字节偏移量。可以看成DataView 中的某种“地址”。

DataView 应该使用ElementType 来实现JavaScript的Number 类型到缓冲内二进制格式的转换。

• 最后是内存中值的字节序。默认为大端字节序。

  1. ElementType

    DataView 对存储在缓冲内的数据类型没有预设。它暴露的API强制开发者在读、写时指定一个ElementType ,然后DataView 就会忠实地为读、写而完成相应的转换。

    ECMAScript 6支持8种不同的ElementType (见下表)。

    ElementType 字节 说明 等价的C类型 值的范围
    Int8 1 8位有符号整数 signed char -128~127
    Uint8 1 8位无符号整数 unsigned char 0~255
    Int16 2 16位有符号整数 short -32 768~32 767
    Uint16 2 16位无符号整数 unsigned short 0~65 535
    Int32 4 32位有符号整数 int -2 147 483 648~2 147 483 647
    Uint32 4 32位无符号整数 unsigned int 0~4 294 967 295
    Float32 4 32位IEEE-754浮点数 float -3.4e+38~+3.4e+38
    Float64 8 64位IEEE-754浮点数 double -1.7e+308~+1.7e+308

    DataView 为上表中的每种类型都暴露了getset 方法,这些方法使用byteOffset (字节偏移量)定位要读取或写入值的位置。类型是可以互换使用的,如下例所示:

    // 在内存中分配两个字节并声明一个DataView
    const buf = new ArrayBuffer(2);
    const view = new DataView(buf);
    
    // 说明整个缓冲确实所有二进制位都是0
    // 检查第一个和第二个字符
    alert(view.getInt8(0));  // 0
    alert(view.getInt8(1));  // 0
    // 检查整个缓冲
    alert(view.getInt16(0)); // 0
    
    // 将整个缓冲都设置为1
    // 255的二进制表示是11111111(2^8 - 1)
    view.setUint8(0, 255);
    
    // DataView会自动将数据转换为特定的ElementType
    // 255的十六进制表示是0xFF
    view.setUint8(1, 0xFF);
    
    // 现在,缓冲里都是1了
    // 如果把它当成二补数的有符号整数,则应该是-1
    alert(view.getInt16(0)); // -1
    
    

     

  2. 字节序

    前面例子中的缓冲有意回避了字节序的问题。“字节序”指的是计算系统维护的一种字节顺序的约定。DataView 只支持两种约定:大端字节序和小端字节序。大端字节序也称为“网络字节序”,意思是最高有效位保存在第一个字节,而最低有效位保存在最后一个字节。小端字节序正好相反,即最低有效位保存在第一个字节,最高有效位保存在最后一个字节。

    JavaScript运行时所在系统的原生字节序决定了如何读取或写入字节,但DataView 并不遵守这个约定。对一段内存而言,DataView 是一个中立接口,它会遵循你指定的字节序。DataView 的所有API方法都以大端字节序作为默认值,但接收一个可选的布尔值参数,设置为true 即可启用小端字节序。

    // 在内存中分配两个字节并声明一个DataView
    const buf = new ArrayBuffer(2);
    const view = new DataView(buf);
    
    // 填充缓冲,让第一位和最后一位都是1
    view.setUint8(0, 0x80); // 设置最左边的位等于1
    view.setUint8(1, 0x01); // 设置最右边的位等于1
    
    // 缓冲内容(为方便阅读,人为加了空格)
    // 0x8  0x0  0x0  0x1
    // 1000 0000 0000 0001
    
    // 按大端字节序读取Uint16
    // 0x80是高字节,0x01是低字节
    // 0x8001 = 2^15 + 2^0 = 32768 + 1 = 32769
    alert(view.getUint16(0)); // 32769
    
    // 按小端字节序读取Uint16
    // 0x01是高字节,0x80是低字节
    // 0x0180 = 2^8 + 2^7 = 256 + 128 = 384
    alert(view.getUint16(0, true)); // 384
    
    // 按大端字节序写入Uint16
    view.setUint16(0, 0x0004);
    
    // 缓冲内容(为方便阅读,人为加了空格)
    // 0x0  0x0  0x0  0x4
    // 0000 0000 0000 0100
    
    alert(view.getUint8(0)); // 0
    alert(view.getUint8(1)); // 4
    
    // 按小端字节序写入Uint16
    view.setUint16(0, 0x0002, true);
    
    // 缓冲内容(为方便阅读,人为加了空格)
    // 0x0  0x2  0x0  0x0
    // 0000 0010 0000 0000
    
    alert(view.getUint8(0)); // 2
    alert(view.getUint8(1)); // 0
    
    

     

  3. 边界情形

    DataView 完成读、写操作的前提是必须有充足的缓冲区,否则就会抛出RangeError

    const buf = new ArrayBuffer(6);
    const view = new DataView(buf);
    
    // 尝试读取部分超出缓冲范围的值
    view.getInt32(4);
    // RangeError
    
    // 尝试读取超出缓冲范围的值
    view.getInt32(8);
    // RangeError
    
    // 尝试读取超出缓冲范围的值
    view.getInt32(-1);
    // RangeError
    
    // 尝试写入超出缓冲范围的值
    view.setInt32(4, 123);
    // RangeError
    
    

    DataView 在写入缓冲里会尽最大努力把一个值转换为适当的类型,后备为0。如果无法转换,则抛出错误:

    const buf = new ArrayBuffer(1);
    const view = new DataView(buf);
    
    view.setInt8(0, 1.5);
    alert(view.getInt8(0)); // 1
    
    view.setInt8(0, [4]);
    alert(view.getInt8(0)); // 4
    
    view.setInt8(0, 'f');
    alert(view.getInt8(0)); // 0
    
    view.setInt8(0, Symbol());
    // TypeError
    
    

定型数组是另一种形式的ArrayBuffer 视图。虽然概念上与DataView 接近,但定型数组的区别在于,它特定于一种ElementType 且遵循系统原生的字节序。相应地,定型数组提供了适用面更广的API和更高的性能。设计定型数组的目的就是提高与WebGL等原生库交换二进制数据的效率。由于定型数组的二进制表示对操作系统而言是一种容易使用的格式,JavaScript引擎可以重度优化算术运算、按位运算和其他对定型数组的常见操作,因此使用它们速度极快。

创建定型数组的方式包括读取已有的缓冲、使用自有缓冲、填充可迭代结构,以及填充基于任意类型的定型数组。另外,通过<ElementType>.from()<ElementType>.of() 也可以创建定型数组:

// 创建一个12字节的缓冲
const buf = new ArrayBuffer(12);
// 创建一个引用该缓冲的Int32Array
const ints = new Int32Array(buf);
// 这个定型数组知道自己的每个元素需要4字节
// 因此长度为3
alert(ints.length); // 3

// 创建一个长度为6的Int32Array
const ints2 = new Int32Array(6);
// 每个数值使用4字节,因此ArrayBuffer是24字节
alert(ints2.length);             // 6
// 类似DataView,定型数组也有一个指向关联缓冲的引用
alert(ints2.buffer.byteLength);  // 24

// 创建一个包含[2, 4, 6, 8]的Int32Array
const ints3 = new Int32Array([2, 4, 6, 8]);
alert(ints3.length);            // 4
alert(ints3.buffer.byteLength); // 16
alert(ints3[2]);                // 6

// 通过复制ints3的值创建一个Int16Array
const ints4 = new Int16Array(ints3);
// 这个新类型数组会分配自己的缓冲
// 对应索引的每个值会相应地转换为新格式
alert(ints4.length);            // 4
alert(ints4.buffer.byteLength); // 8
alert(ints4[2]);                // 6

// 基于普通数组来创建一个Int16Array
const ints5 = Int16Array.from([3, 5, 7, 9]);
alert(ints5.length);            // 4
alert(ints5.buffer.byteLength); // 8
alert(ints5[2]);                // 7

// 基于传入的参数创建一个Float32Array
const floats = Float32Array.of(3.14, 2.718, 1.618);
alert(floats.length);            // 3
alert(floats.buffer.byteLength); // 12
alert(floats[2]);                // 1.6180000305175781

定型数组的构造函数和实例都有一个BYTES_PER_ELEMENT 属性,返回该类型数组中每个元素的大小:

alert(Int16Array.BYTES_PER_ELEMENT);  // 2
alert(Int32Array.BYTES_PER_ELEMENT);  // 4

const ints = new Int32Array(1),
      floats = new Float64Array(1);

alert(ints.BYTES_PER_ELEMENT);        // 4
alert(floats.BYTES_PER_ELEMENT);      // 8

如果定型数组没有用任何值初始化,则其关联的缓冲会以0填充:

const ints = new Int32Array(4);
alert(ints[0]);  // 0
alert(ints[1]);  // 0
alert(ints[2]);  // 0
alert(ints[3]);  // 0

  1. 定型数组行为

    从很多方面看,定型数组与普通数组都很相似。定型数组支持如下操作符、方法和属性:

    其中,返回新数组的方法也会返回包含同样元素类型(element type)的新定型数组:

    const ints = new Int16Array([1, 2, 3]);
    const doubleints = ints.map(x => 2*x);
    alert(doubleints instanceof Int16Array); // true
    
    

    定型数组有一个Symbol.iterator 符号属性,因此可以通过for..of 循环和扩展操作符来操作:

    const ints = new Int16Array([1, 2, 3]);
    for (const int of ints) {
      alert(int);
    }
    // 1
    // 2
    // 3
    
    alert(Math.max(...ints)); // 3
    
    

     

  2. 合并、复制和修改定型数组

    定型数组同样使用数组缓冲来存储数据,而数组缓冲无法调整大小。因此,下列方法不适用于定型数组:

    不过,定型数组也提供了两个新方法,可以快速向外或向内复制数据:set()subarray()

    set() 从提供的数组或定型数组中把值复制到当前定型数组中指定的索引位置:

    // 创建长度为8的int16数组
    const container = new Int16Array(8);
    // 把定型数组复制为前4个值
    // 偏移量默认为索引0
    container.set(Int8Array.of(1, 2, 3, 4));
    console.log(container);  // [1,2,3,4,0,0,0,0]
    // 把普通数组复制为后4个值
    // 偏移量4表示从索引4开始插入
    container.set([5,6,7,8], 4);
    console.log(container);  // [1,2,3,4,5,6,7,8]
    
    // 溢出会抛出错误
    container.set([5,6,7,8], 7);
    // RangeError
    
    

    subarray() 执行与set() 相反的操作,它会基于从原始定型数组中复制的值返回一个新定型数组。复制值时的开始索引和结束索引是可选的:

    const source = Int16Array.of(2, 4, 6, 8);
    
    // 把整个数组复制为一个同类型的新数组
    const fullCopy = source.subarray();
    console.log(fullCopy);  // [2, 4, 6, 8]
    
    // 从索引2开始复制数组
    const halfCopy = source.subarray(2);
    console.log(halfCopy);  // [6, 8]
    
    // 从索引1开始复制到索引3
    const partialCopy = source.subarray(1, 3);
    console.log(partialCopy);  // [4, 6]
    
    

    定型数组没有原生的拼接能力,但使用定型数组API提供的很多工具可以手动构建:

    // 第一个参数是应该返回的数组类型
    // 其余参数是应该拼接在一起的定型数组
    function typedArrayConcat(typedArrayConstructor, ...typedArrays) {
      // 计算所有数组中包含的元素总数
      const numElements = typedArrays.reduce((x,y) => (x.length || x) + y.length);
    
      // 按照提供的类型创建一个数组,为所有元素留出空间
      const resultArray = new typedArrayConstructor(numElements);
    
      // 依次转移数组
      let currentOffset = 0;
      typedArrays.map(x => {
        resultArray.set(x, currentOffset);
        currentOffset += x.length;
      });
    
      return resultArray;
    }
    
    const concatArray = typedArrayConcat(Int32Array,
                                         Int8Array.of(1, 2, 3),
                                         Int16Array.of(4, 5, 6),
                                         Float32Array.of(7, 8, 9));
    console.log(concatArray);  // [1, 2, 3, 4, 5, 6, 7, 8, 9]
    console.log(concatArray instanceof Int32Array); // true
    
    

     

  3. 下溢和上溢

    定型数组中值的下溢和上溢不会影响到其他索引,但仍然需要考虑数组的元素应该是什么类型。定型数组对于可以存储的每个索引只接受一个相关位,而不考虑它们对实际数值的影响。以下代码演示了如何处理下溢和上溢:

    // 长度为2的有符号整数数组
    // 每个索引保存一个二补数形式的有符号整数
    // 范围是-128(-1 * 2^7)~127(2^7 - 1)
    const ints = new Int8Array(2);
    
    // 长度为2的无符号整数数组
    // 每个索引保存一个无符号整数
    // 范围是0~255(2^7 - 1)
    const unsignedInts = new Uint8Array(2);
    
    // 上溢的位不会影响相邻索引
    // 索引只取最低有效位上的8位
    unsignedInts[1] = 256;      // 0x100
    console.log(unsignedInts);  // [0, 0]
    unsignedInts[1] = 511;      // 0x1FF
    console.log(unsignedInts);  // [0, 255]
    
    // 下溢的位会被转换为其无符号的等价值
    // 0xFF是以二补数形式表示的-1(截取到8位),
    // 但255是一个无符号整数
    unsignedInts[1] = -1        // 0xFF (truncated to 8 bits)
    console.log(unsignedInts);  // [0, 255]
    
    // 上溢自动变成二补数形式
    // 0x80是无符号整数的128,是二补数形式的-128
    ints[1] = 128;        // 0x80
    console.log(ints);    // [0, -128]
    
    // 下溢自动变成二补数形式
    // 0xFF是无符号整数的255,是二补数形式的-1
    ints[1] = 255;        // 0xFF
    console.log(ints);    // [0, -1]
    
    

    除了8种元素类型,还有一种“夹板”数组类型:Uint8ClampedArray ,不允许任何方向溢出。超出最大值255的值会被向下舍入为255,而小于最小值0的值会被向上舍入为0。

    const clampedInts = new Uint8ClampedArray([-1, 0, 255, 256]);
    console.log(clampedInts); // [0, 0, 255, 255]
    
    

    按照JavaScript之父Brendan Eich的说法:“Uint8ClampedArray 完全是HTML5canvas 元素的历史留存。除非真的做跟canvas 相关的开发,否则不要使用它。”

ECMAScript 6以前,在JavaScript中实现“键/值”式存储可以使用Object 来方便高效地完成,也就是使用对象属性作为键,再使用属性来引用值。但这种实现并非没有问题,为此TC39委员会专门为“键/值”存储定义了一个规范。

作为ECMAScript 6的新增特性,Map 是一种新的集合类型,为这门语言带来了真正的键/值存储机制。Map 的大多数特性都可以通过Object 类型实现,但二者之间还是存在一些细微的差异。具体实践中使用哪一个,还是值得细细甄别。

使用new 关键字和Map 构造函数可以创建一个空映射:

const m = new Map();

如果想在创建的同时初始化实例,可以给Map 构造函数传入一个可迭代对象,需要包含键/值对数组。可迭代对象中的每个键/值对都会按照迭代顺序插入到新映射实例中:

// 使用嵌套数组初始化映射
const m1 = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);
alert(m1.size); // 3

// 使用自定义迭代器初始化映射
const m2 = new Map({
  [Symbol.iterator]: function*() {
    yield ["key1", "val1"];
    yield ["key2", "val2"];
    yield ["key3", "val3"];
  }
});
alert(m2.size); // 3

// 映射期待的键/值对,无论是否提供
const m3 = new Map([[]]);
alert(m3.has(undefined));  // true
alert(m3.get(undefined));  // undefined

初始化之后,可以使用set() 方法再添加键/值对。另外,可以使用get()has() 进行查询,可以通过size 属性获取映射中的键/值对的数量,还可以使用delete()clear() 删除值。

const m = new Map();

alert(m.has("firstName"));  // false
alert(m.get("firstName"));  // undefined
alert(m.size);              // 0

m.set("firstName", "Matt")
 .set("lastName", "Frisbie");

alert(m.has("firstName")); // true
alert(m.get("firstName")); // Matt
alert(m.size);             // 2

m.delete("firstName");     // 只删除这一个键/值对

alert(m.has("firstName")); // false
alert(m.has("lastName"));  // true
alert(m.size);             // 1

m.clear(); // 清除这个映射实例中的所有键/值对

alert(m.has("firstName")); // false
alert(m.has("lastName"));  // false
alert(m.size);             // 0

set() 方法返回映射实例,因此可以把多个操作连缀起来,包括初始化声明:

const m = new Map().set("key1", "val1");

m.set("key2", "val2")
 .set("key3", "val3");

alert(m.size); // 3

Object 只能使用数值、字符串或符号作为键不同,Map 可以使用任何JavaScript数据类型作为键。Map 内部使用SameValueZero比较操作(ECMAScript规范内部定义,语言中不能使用),基本上相当于使用严格对象相等的标准来检查键的匹配性。与Object 类似,映射的值是没有限制的。

const m = new Map();

const functionKey = function() {};
const symbolKey = Symbol();
const objectKey = new Object();

m.set(functionKey, "functionValue");
m.set(symbolKey, "symbolValue");
m.set(objectKey, "objectValue");

alert(m.get(functionKey));  // functionValue
alert(m.get(symbolKey));    // symbolValue
alert(m.get(objectKey));    // objectValue

// SameValueZero比较意味着独立实例不冲突
alert(m.get(function() {})); // undefined

与严格相等一样,在映射中用作键和值的对象及其他“集合”类型,在自己的内容或属性被修改时仍然保持不变:

const m = new Map();

const objKey = {},
      objVal = {},
      arrKey = [],
      arrVal = [];

m.set(objKey, objVal);
m.set(arrKey, arrVal);

objKey.foo = "foo";
objVal.bar = "bar";
arrKey.push("foo");
arrVal.push("bar");

console.log(m.get(objKey)); // {bar: "bar"}
console.log(m.get(arrKey)); // ["bar"]

SameValueZero比较也可能导致意想不到的冲突:

const m = new Map();

const a = 0/"", // NaN
      b = 0/"", // NaN
      pz = +0,
      nz = -0;

alert(a === b);   // false
alert(pz === nz); // true

m.set(a, "foo");
m.set(pz, "bar");

alert(m.get(b));  // foo
alert(m.get(nz)); // bar

注意  SameValueZero是ECMAScript规范新增的相等性比较算法。关于ECMAScript的相等性比较,可以参考MDN文档中的文章“Equality Comparisons and Sameness”。

Object 类型的一个主要差异是,Map 实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作。

映射实例可以提供一个迭代器(Iterator ),能以插入顺序生成[key, value] 形式的数组。可以通过entries() 方法(或者Symbol.iterator 属性,它引用entries() )取得这个迭代器:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);

alert(m.entries === m[Symbol.iterator]); // true

for (let pair of m.entries()) {
  alert(pair);
}
// [key1,val1]
// [key2,val2]
// [key3,val3]

for (let pair of m[Symbol.iterator]()) {
  alert(pair);
}
// [key1,val1]
// [key2,val2]
// [key3,val3]

因为entries() 是默认迭代器,所以可以直接对映射实例使用扩展操作,把映射转换为数组:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);

console.log([...m]); // [[key1,val1],[key2,val2],[key3,val3]]

如果不使用迭代器,而是使用回调方式,则可以调用映射的forEach(callback, opt_thisArg) 方法并传入回调,依次迭代每个键/值对。传入的回调接收可选的第二个参数,这个参数用于重写回调内部this 的值:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);

m.forEach((val, key) => alert(`${key} -> ${val}`));
// key1 -> val1
// key2 -> val2
// key3 -> val3

keys()values() 分别返回以插入顺序生成键和值的迭代器:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);

for (let key of m.keys()) {
  alert(key);
}
// key1
// key2
// key3

for (let key of m.values()) {
  alert(key);
}
// value1
// value2
// value3

键和值在迭代器遍历时是可以修改的,但映射内部的引用则无法修改。当然,这并不妨碍修改作为键或值的对象内部的属性,因为这样并不影响它们在映射实例中的身份:

const m1 = new Map([
  ["key1", "val1"]
]);

// 作为键的字符串原始值是不能修改的
for (let key of m1.keys()) {
  key = "newKey";
  alert(key);             // newKey
  alert(m1.get("key1"));  // val1
}

const keyObj = {id: 1};

const m = new Map([
  [keyObj, "val1"]
]);

// 修改了作为键的对象的属性,但对象在映射内部仍然引用相同的值
for (let key of m.keys()) {
  key.id = "newKey";
  alert(key);            // {id: "newKey"}
  alert(m.get(keyObj));  // val1
}
alert(keyObj);           // {id: "newKey"}

对于多数Web开发任务来说,选择Object 还是Map 只是个人偏好问题,影响不大。不过,对于在乎内存和性能的开发者来说,对象和映射之间确实存在显著的差别。

  1. 内存占用

    ObjectMap 的工程级实现在不同浏览器间存在明显差异,但存储单个键/值对所占用的内存数量都会随键的数量线性增加。批量添加或删除键/值对则取决于各浏览器对该类型内存分配的工程实现。不同浏览器的情况不同,但给定固定大小的内存,Map 大约可以比Object 多存储50%的键/值对。

  2. 插入性能

    ObjectMap 中插入新键/值对的消耗大致相当,不过插入Map 在所有浏览器中一般会稍微快一点儿。对这两个类型来说,插入速度并不会随着键/值对数量而线性增加。如果代码涉及大量插入操作,那么显然Map 的性能更佳。

  3. 查找速度

    与插入不同,从大型ObjectMap 中查找键/值对的性能差异极小,但如果只包含少量键/值对,则Object 有时候速度更快。在把Object 当成数组使用的情况下(比如使用连续整数作为属性),浏览器引擎可以进行优化,在内存中使用更高效的布局。这对Map 来说是不可能的。对这两个类型而言,查找速度不会随着键/值对数量增加而线性增加。如果代码涉及大量查找操作,那么某些情况下可能选择Object 更好一些。

  4. 删除性能

    使用delete 删除Object 属性的性能一直以来饱受诟病,目前在很多浏览器中仍然如此。为此,出现了一些伪删除对象属性的操作,包括把属性值设置为undefinednull 。但很多时候,这都是一种讨厌的或不适宜的折中。而对大多数浏览器引擎来说,Mapdelete() 操作都比插入和查找更快。如果代码涉及大量删除操作,那么毫无疑问应该选择Map

ECMAScript 6新增的“弱映射”(WeakMap )是一种新的集合类型,为这门语言带来了增强的键/值对存储机制。WeakMapMap 的“兄弟”类型,其API也是Map 的子集。WeakMap 中的“weak”(弱),描述的是JavaScript垃圾回收程序对待“弱映射”中键的方式。

可以使用new 关键字实例化一个空的WeakMap

const wm = new WeakMap();

弱映射中的键只能是Object 或者继承自Object 的类型,尝试使用非对象设置键会抛出TypeError 。值的类型没有限制。

如果想在初始化时填充弱映射,则构造函数可以接收一个可迭代对象,其中需要包含键/值对数组。可迭代对象中的每个键/值都会按照迭代顺序插入新实例中:

const key1 = {id: 1},
      key2 = {id: 2},
      key3 = {id: 3};
// 使用嵌套数组初始化弱映射
const wm1 = new WeakMap([
  [key1, "val1"],
  [key2, "val2"],
  [key3, "val3"]
]);
alert(wm.get(key1)); // val1
alert(wm.get(key2)); // val2
alert(wm.get(key3)); // val3

// 初始化是全有或全无的操作
// 只要有一个键无效就会抛出错误,导致整个初始化失败
const wm2 = new WeakMap([
  [key1, "val1"],
  ["BADKEY", "val2"],
  [key3, "val3"]
]);
// TypeError: Invalid value used as WeakMap key
typeof wm2;
// ReferenceError: wm2 is not defined

// 原始值可以先包装成对象再用作键
const stringKey = new String("key1");
const wm3 = new WeakMap([
  stringKey, "val1"
]);
alert(wm3.get(stringKey)); // "val1"

初始化之后可以使用set() 再添加键/值对,可以使用get()has() 查询,还可以使用delete() 删除:

const wm = new WeakMap();

const key1 = {id: 1},
      key2 = {id: 2};

alert(wm.has(key1)); // false
alert(wm.get(key1)); // undefined

wm.set(key1, "Matt")
  .set(key2, "Frisbie");

alert(wm.has(key1)); // true
alert(wm.get(key1)); // Matt

wm.delete(key1);     // 只删除这一个键/值对

alert(wm.has(key1)); // false
alert(wm.has(key2)); // true

set() 方法返回弱映射实例,因此可以把多个操作连缀起来,包括初始化声明:

const key1 = {id: 1},
      key2 = {id: 2},
      key3 = {id: 3};

const wm = new WeakMap().set(key1, "val1");

wm.set(key2, "val2")
  .set(key3, "val3");

alert(wm.get(key1)); // val1
alert(wm.get(key2)); // val2
alert(wm.get(key3)); // val3

WeakMap 中“weak”表示弱映射的键是“弱弱地拿着”的。意思就是,这些键不属于正式的引用,不会阻止垃圾回收。但要注意的是,弱映射中值的引用可不是 “弱弱地拿着”的。只要键存在,键/值对就会存在于映射中,并被当作对值的引用,因此就不会被当作垃圾回收。

来看下面的例子:

const wm = new WeakMap();

wm.set({}, "val");

set() 方法初始化了一个新对象并将它用作一个字符串的键。因为没有指向这个对象的其他引用,所以当这行代码执行完成后,这个对象键就会被当作垃圾回收。然后,这个键/值对就从弱映射中消失了,使其成为一个空映射。在这个例子中,因为值也没有被引用,所以这对键/值被破坏以后,值本身也会成为垃圾回收的目标。

再看一个稍微不同的例子:

const wm = new WeakMap();

const container = {
  key: {}
};

wm.set(container.key, "val");

function removeReference() {
  container.key = null;
}

这一次,container 对象维护着一个对弱映射键的引用,因此这个对象键不会成为垃圾回收的目标。不过,如果调用了removeReference() ,就会摧毁键对象的最后一个引用,垃圾回收程序就可以把这个键/值对清理掉。

因为WeakMap 中的键/值对任何时候都可能被销毁,所以没必要提供迭代其键/值对的能力。当然,也用不着像clear() 这样一次性销毁所有键/值的方法。WeakMap 确实没有这个方法。因为不可能迭代,所以也不可能在不知道对象引用的情况下从弱映射中取得值。即便代码可以访问WeakMap 实例,也没办法看到其中的内容。

WeakMap 实例之所以限制只能用对象作为键,是为了保证只有通过键对象的引用才能取得值。如果允许原始值,那就没办法区分初始化时使用的字符串字面量和初始化之后使用的一个相等的字符串了。

WeakMap 实例与现有JavaScript对象有着很大不同,可能一时不容易说清楚应该怎么使用它。这个问题没有唯一的答案,但已经出现了很多相关策略。

  1. 私有变量

    弱映射造就了在JavaScript中实现真正私有变量的一种新方式。前提很明确:私有变量会存储在弱映射中,以对象实例为键,以私有成员的字典为值。

    下面是一个示例实现:

    const wm = new WeakMap();
    
    class User {
      constructor(id) {
        this.idProperty = Symbol('id');
        this.setId(id);
      }
    
      setPrivate(property, value) {
        const privateMembers = wm.get(this) || {};
        privateMembers[property] = value;
        wm.set(this, privateMembers);
      }
    
      getPrivate(property) {
        return wm.get(this)[property];
      }
    
      setId(id) {
        this.setPrivate(this.idProperty, id);
      }
    
      getId() {
        return this.getPrivate(this.idProperty);
      }
    }
    
    const user = new User(123);
    alert(user.getId()); // 123
    user.setId(456);
    alert(user.getId()); // 456
    
    // 并不是真正私有的
    alert(wm.get(user)[user.idProperty]); // 456
    
    

    慧眼独具的读者会发现,对于上面的实现,外部代码只需要拿到对象实例的引用和弱映射,就可以取得“私有”变量了。为了避免这种访问,可以用一个闭包把WeakMap 包装起来,这样就可以把弱映射与外界完全隔离开了:

    const User = (() => {
      const wm = new WeakMap();
    
      class User {
        constructor(id) {
          this.idProperty = Symbol('id');
          this.setId(id);
        }
    
        setPrivate(property, value) {
          const privateMembers = wm.get(this) || {};
          privateMembers[property] = value;
          wm.set(this, privateMembers);
        }
    
        getPrivate(property) {
          return wm.get(this)[property];
        }
    
        setId(id) {
          this.setPrivate(this.idProperty, id);
        }
    
        getId(id) {
          return this.getPrivate(this.idProperty);
        }
      }
      return User;
    })();
    
    const user = new User(123);
    alert(user.getId()); // 123
    user.setId(456);
    alert(user.getId()); // 456
    
    

    这样,拿不到弱映射中的健,也就无法取得弱映射中对应的值。虽然这防止了前面提到的访问,但整个代码也完全陷入了ES6之前的闭包私有变量模式。
     

  2. DOM节点元数据

    因为WeakMap 实例不会妨碍垃圾回收,所以非常适合保存关联元数据。来看下面这个例子,其中使用了常规的Map

    const m = new Map();
    
    const loginButton = document.querySelector('#login');
    
    // 给这个节点关联一些元数据
    m.set(loginButton, {disabled: true});
    
    

    假设在上面的代码执行后,页面被JavaScript改变了,原来的登录按钮从DOM树中被删掉了。但由于映射中还保存着按钮的引用,所以对应的DOM节点仍然会逗留在内存中,除非明确将其从映射中删除或者等到映射本身被销毁。

    如果这里使用的是弱映射,如以下代码所示,那么当节点从DOM树中被删除后,垃圾回收程序就可以立即释放其内存(假设没有其他地方引用这个对象):

    const wm = new WeakMap();
    
    const loginButton = document.querySelector('#login');
    
    // 给这个节点关联一些元数据
    wm.set(loginButton, {disabled: true});
    
    

ECMAScript 6新增的Set 是一种新集合类型,为这门语言带来集合数据结构。Set 在很多方面都像是加强的Map ,这是因为它们的大多数API和行为都是共有的。

使用new 关键字和Set 构造函数可以创建一个空集合:

const m = new Set();

如果想在创建的同时初始化实例,则可以给Set 构造函数传入一个可迭代对象,其中需要包含插入到新集合实例中的元素:

// 使用数组初始化集合
const s1 = new Set(["val1", "val2", "val3"]);

alert(s1.size); // 3

// 使用自定义迭代器初始化集合
const s2 = new Set({
  [Symbol.iterator]: function*() {
    yield "val1";
    yield "val2";
    yield "val3";
  }
});
alert(s2.size); // 3

初始化之后,可以使用add() 增加值,使用has() 查询,通过size 取得元素数量,以及使用delete()clear() 删除元素:

const s = new Set();

alert(s.has("Matt"));    // false
alert(s.size);           // 0

s.add("Matt")
 .add("Frisbie");

alert(s.has("Matt"));    // true
alert(s.size);           // 2

s.delete("Matt");

alert(s.has("Matt"));    // false
alert(s.has("Frisbie")); // true
alert(s.size);           // 1

s.clear(); // 销毁集合实例中的所有值

alert(s.has("Matt"));    // false
alert(s.has("Frisbie")); // false
alert(s.size);           // 0

add() 返回集合的实例,所以可以将多个添加操作连缀起来,包括初始化:

const s = new Set().add("val1");

s.add("val2")
 .add("val3");

alert(s.size); // 3

Map 类似,Set 可以包含任何JavaScript数据类型作为值。集合也使用SameValueZero操作(ECMAScript内部定义,无法在语言中使用),基本上相当于使用严格对象相等的标准来检查值的匹配性。

const s = new Set();

const functionVal = function() {};
const symbolVal = Symbol();
const objectVal = new Object();

s.add(functionVal);
s.add(symbolVal);
s.add(objectVal);

alert(s.has(functionVal));   // true
alert(s.has(symbolVal));     // true
alert(s.has(objectVal));     // true

// SameValueZero检查意味着独立的实例不会冲突
alert(s.has(function() {})); // false

与严格相等一样,用作值的对象和其他“集合”类型在自己的内容或属性被修改时也不会改变:

const s = new Set();

const objVal = {},
      arrVal = [];

s.add(objVal);
s.add(arrVal);

objVal.bar = "bar";
arrVal.push("bar");

alert(s.has(objVal)); // true
alert(s.has(arrVal)); // true

add()delete() 操作是幂等的。delete() 返回一个布尔值,表示集合中是否存在要删除的值:

const s = new Set();

s.add('foo');
alert(s.size); // 1
s.add('foo');
alert(s.size); // 1

// 集合里有这个值
alert(s.delete('foo')); // true

// 集合里没有这个值
alert(s.delete('foo')); // false

Set 会维护值插入时的顺序,因此支持按顺序迭代。

集合实例可以提供一个迭代器(Iterator ),能以插入顺序生成集合内容。可以通过values() 方法及其别名方法keys() (或者Symbol.iterator 属性,它引用values() )取得这个迭代器:

const s = new Set(["val1", "val2", "val3"]);

alert(s.values === s[Symbol.iterator]); // true
alert(s.keys === s[Symbol.iterator]);   // true

for (let value of s.values()) {
  alert(value);
}
// val1
// val2
// val3

for (let value of s[Symbol.iterator]()) {
  alert(value);
}
// val1
// val2
// val3

因为values() 是默认迭代器,所以可以直接对集合实例使用扩展操作,把集合转换为数组:

const s = new Set(["val1", "val2", "val3"]);

console.log([...s]); // ["val1", "val2", "val3"]

集合的entries() 方法返回一个迭代器,可以按照插入顺序产生包含两个元素的数组,这两个元素是集合中每个值的重复出现:

const s = new Set(["val1", "val2", "val3"]);

for (let pair of s.entries()) {
  console.log(pair);
}
// ["val1", "val1"]
// ["val2", "val2"]
// ["val3", "val3"]

如果不使用迭代器,而是使用回调方式,则可以调用集合的forEach() 方法并传入回调,依次迭代每个键/值对。传入的回调接收可选的第二个参数,这个参数用于重写回调内部this 的值:

const s = new Set(["val1", "val2", "val3"]);

s.forEach((val, dupVal) => alert(`${val} -> ${dupVal}`));
// val1 -> val1
// val2 -> val2
// val3 -> val3

修改集合中值的属性不会影响其作为集合值的身份:

const s1 = new Set(["val1"]);

// 字符串原始值作为值不会被修改
for (let value of s1.values()) {
  value = "newVal";
  alert(value);          // newVal
  alert(s1.has("val1")); // true
}

const valObj = {id: 1};

const s2 = new Set([valObj]);

// 修改值对象的属性,但对象仍然存在于集合中
for (let value of s2.values()) {
  value.id = "newVal";
  alert(value);           // {id: "newVal"}
  alert(s2.has(valObj));  // true
}
alert(valObj);            // {id: "newVal"}

从各方面来看,SetMap 都很相似,只是API稍有调整。唯一需要强调的就是集合的API对自身的简单操作。很多开发者都喜欢使用Set 操作,但需要手动实现:或者是子类化Set ,或者是定义一个实用函数库。要把两种方式合二为一,可以在子类上实现静态方法,然后在实例方法中使用这些静态方法。在实现这些操作时,需要考虑几个地方。

class XSet extends Set {
  union(...sets) {
    return XSet.union(this, ...sets)
  }

  intersection(...sets) {
    return XSet.intersection(this, ...sets);
  }

  difference(set) {
    return XSet.difference(this, set);
  }

  symmetricDifference(set) {
    return XSet.symmetricDifference(this, set);
  }

  cartesianProduct(set) {
    return XSet.cartesianProduct(this, set);
  }

  powerSet() {
    return XSet.powerSet(this);
  }

  // 返回两个或更多集合的并集
  static union(a, ...bSets) {
    const unionSet = new XSet(a);
    for (const b of bSets) {
      for (const bValue of b) {
        unionSet.add(bValue);
      }
    }
    return unionSet;
  }

  // 返回两个或更多集合的交集
  static intersection(a, ...bSets) {
    const intersectionSet = new XSet(a);
    for (const aValue of intersectionSet) {
      for (const b of bSets) {
        if (!b.has(aValue)) {
          intersectionSet.delete(aValue);
        }
      }
    }
    return intersectionSet;
  }

  // 返回两个集合的差集
  static difference(a, b) {
    const differenceSet = new XSet(a);
    for (const bValue of b) {
      if (a.has(bValue)) {
        differenceSet.delete(bValue);
      }
    }
    return differenceSet;
  }

  // 返回两个集合的对称差集
  static symmetricDifference(a, b) {
    // 按照定义,对称差集可以表达为
    return a.union(b).difference(a.intersection(b));
  }

  // 返回两个集合(数组对形式)的笛卡儿积
  // 必须返回数组集合,因为笛卡儿积可能包含相同值的对
  static cartesianProduct(a, b) {
    const cartesianProductSet = new XSet();
    for (const aValue of a) {
      for (const bValue of b) {
        cartesianProductSet.add([aValue, bValue]);
      }
    }
    return cartesianProductSet;
  }

  // 返回一个集合的幂集
  static powerSet(a) {
    const powerSet = new XSet().add(new XSet());
    for (const aValue of a) {
      for (const set of new XSet(powerSet)) {
        powerSet.add(new XSet(set).add(aValue));
      }
    }
    return powerSet;
  }
}

ECMAScript 6新增的“弱集合”(WeakSet )是一种新的集合类型,为这门语言带来了集合数据结构。WeakSetSet 的“兄弟”类型,其API也是Set 的子集。WeakSet 中的“weak”(弱),描述的是JavaScript垃圾回收程序对待“弱集合”中值的方式。

可以使用new 关键字实例化一个空的WeakSet

const ws = new WeakSet();

弱集合中的值只能是Object 或者继承自Object 的类型,尝试使用非对象设置值会抛出TypeError

如果想在初始化时填充弱集合,则构造函数可以接收一个可迭代对象,其中需要包含有效的值。可迭代对象中的每个值都会按照迭代顺序插入到新实例中:

const val1 = {id: 1},
      val2 = {id: 2},
      val3 = {id: 3};
// 使用数组初始化弱集合
const ws1 = new WeakSet([val1, val2, val3]);

alert(ws1.has(val1)); // true
alert(ws1.has(val2)); // true
alert(ws1.has(val3)); // true

// 初始化是全有或全无的操作
// 只要有一个值无效就会抛出错误,导致整个初始化失败
const ws2 = new WeakSet([val1, "BADVAL", val3]);
// TypeError: Invalid value used in WeakSet
typeof ws2;
// ReferenceError: ws2 is not defined

// 原始值可以先包装成对象再用作值
const stringVal = new String("val1");
const ws3 = new WeakSet([stringVal]);
alert(ws3.has(stringVal)); // true

初始化之后可以使用add() 再添加新值,可以使用has() 查询,还可以使用delete() 删除:

const ws = new WeakSet();

const val1 = {id: 1},
      val2 = {id: 2};

alert(ws.has(val1)); // false

ws.add(val1)
  .add(val2);

alert(ws.has(val1)); // true
alert(ws.has(val2)); // true

ws.delete(val1);     // 只删除这一个值

alert(ws.has(val1)); // false
alert(ws.has(val2)); // true

add() 方法返回弱集合实例,因此可以把多个操作连缀起来,包括初始化声明:

const val1 = {id: 1},
      val2 = {id: 2},
      val3 = {id: 3};

const ws = new WeakSet().add(val1);

ws.add(val2)
  .add(val3);

alert(ws.has(val1)); // true
alert(ws.has(val2)); // true
alert(ws.has(val3)); // true

WeakSet 中“weak”表示弱集合的值是“弱弱地拿着”的。意思就是,这些值不属于正式的引用,不会阻止垃圾回收。

来看下面的例子:

const ws = new WeakSet();

ws.add({});

add() 方法初始化了一个新对象,并将它用作一个值。因为没有指向这个对象的其他引用,所以当这行代码执行完成后,这个对象值就会被当作垃圾回收。然后,这个值就从弱集合中消失了,使其成为一个空集合。

再看一个稍微不同的例子:

const ws = new WeakSet();

const container = {
  val: {}
};

ws.add(container.val);

function removeReference() {
  container.val = null;
}

这一次,container 对象维护着一个对弱集合值的引用,因此这个对象值不会成为垃圾回收的目标。不过,如果调用了removeReference() ,就会摧毁值对象的最后一个引用,垃圾回收程序就可以把这个值清理掉。

因为WeakSet 中的值任何时候都可能被销毁,所以没必要提供迭代其值的能力。当然,也用不着像clear() 这样一次性销毁所有值的方法。WeakSet 确实没有这个方法。因为不可能迭代,所以也不可能在不知道对象引用的情况下从弱集合中取得值。即便代码可以访问WeakSet 实例,也没办法看到其中的内容。

WeakSet 之所以限制只能用对象作为值,是为了保证只有通过值对象的引用才能取得值。如果允许原始值,那就没办法区分初始化时使用的字符串字面量和初始化之后使用的一个相等的字符串了。

相比于WeakMap 实例,WeakSet 实例的用处没有那么大。不过,弱集合在给对象打标签时还是有价值的。

来看下面的例子,这里使用了一个普通Set

const disabledElements = new Set();

const loginButton = document.querySelector('#login');

// 通过加入对应集合,给这个节点打上“禁用”标签
disabledElements.add(loginButton);

这样,通过查询元素在不在disabledElements 中,就可以知道它是不是被禁用了。不过,假如元素从DOM树中被删除了,它的引用却仍然保存在Set 中,因此垃圾回收程序也不能回收它。

为了让垃圾回收程序回收元素的内存,可以在这里使用WeakSet

const disabledElements = new WeakSet();

const loginButton = document.querySelector('#login');

// 通过加入对应集合,给这个节点打上“禁用”标签
disabledElements.add(loginButton);

这样,只要WeakSet 中任何元素从DOM树中被删除,垃圾回收程序就可以忽略其存在,而立即释放其内存(假设没有其他地方引用这个对象)。

ECMAScript 6新增的迭代器和扩展操作符对集合引用类型特别有用。这些新特性让集合类型之间相互操作、复制和修改变得异常方便。

注意  第7章会更详细地介绍迭代器和生成器。

如本章前面所示,有4种原生集合类型定义了默认迭代器:

很简单,这意味着上述所有类型都支持顺序迭代,都可以传入for-of 循环:

let iterableThings = [
  Array.of(1, 2),
  typedArr = Int16Array.of(3, 4),
  new Map([[5, 6], [7, 8]]),
  new Set([9, 10])
];

for (const iterableThing of iterableThings) {
  for (const x of iterableThing) {
    console.log(x);
  }
}

// 1
// 2
// 3
// 4
// [5, 6]
// [7, 8]
// 9
// 10

这也意味着所有这些类型都兼容扩展操作符。扩展操作符在对可迭代对象执行浅复制时特别有用,只需简单的语法就可以复制整个对象:

let arr1 = [1, 2, 3];
let arr2 = [...arr1];

console.log(arr1);          // [1, 2, 3]
console.log(arr2);          // [1, 2, 3]
console.log(arr1 === arr2); // false

对于期待可迭代对象的构造函数,只要传入一个可迭代对象就可以实现复制:

let map1 = new Map([[1, 2], [3, 4]]);
let map2 = new Map(map1);

console.log(map1); // Map {1 => 2, 3 => 4}
console.log(map2); // Map {1 => 2, 3 => 4}

当然,也可以构建数组的部分元素:

let arr1 = [1, 2, 3];
let arr2 = [0, ...arr1, 4, 5];

console.log(arr2); // [0, 1, 2, 3, 4, 5]

浅复制意味着只会复制对象引用:

let arr1 = [{}];
let arr2 = [...arr1];

arr1[0].foo = 'bar';
console.log(arr2[0]); // { foo: 'bar' }

上面的这些类型都支持多种构建方法,比如Array.of()Array.from() 静态方法。在与扩展操作符一起使用时,可以非常方便地实现互操作:

let arr1 = [1, 2, 3];

// 把数组复制到定型数组
let typedArr1 = Int16Array.of(...arr1);
let typedArr2 = Int16Array.from(arr1);
console.log(typedArr1);   // Int16Array [1, 2, 3]
console.log(typedArr2);   // Int16Array [1, 2, 3]

// 把数组复制到映射
let map = new Map(arr1.map((x) => [x, 'val' + x]));
console.log(map);   // Map {1 => 'val 1', 2 => 'val 2', 3 => 'val 3'}

// 把数组复制到集合
let set = new Set(typedArr2);
console.log(set);   // Set {1, 2, 3}

// 把集合复制回数组
let arr2 = [...set];
console.log(arr2);  // [1, 2, 3]

JavaScript中的对象是引用值,可以通过几种内置引用类型创建特定类型的对象。

JavaScript比较独特的一点是,函数其实是Function 类型的实例,这意味着函数也是对象。由于函数是对象,因此也就具有能够增强自身行为的方法。

因为原始值包装类型的存在,所以JavaScript中的原始值可以拥有类似对象的行为。有3种原始值包装类型:BooleanNumberString 。它们都具有如下特点。

JavaScript还有两个在一开始执行代码时就存在的内置对象:GlobalMath 。其中,Global 对象在大多数ECMAScript实现中无法直接访问。不过浏览器将Global 实现为window 对象。所有全局变量和函数都是Global 对象的属性。Math 对象包含辅助完成复杂数学计算的属性和方法。

ECMAScript 6新增了一批引用类型:MapWeakMapSetWeakSet 。这些类型为组织应用程序数据和简化内存管理提供了新能力。


第 7 章 迭代器与生成器

本章内容

迭代的英文“iteration”源自拉丁文itero,意思是“重复”或“再来”。在软件开发领域,“迭代”的意思是按照顺序反复多次执行一段程序,通常会有明确的终止条件。ECMAScript 6规范新增了两个高级特性:迭代器和生成器。使用这两个特性,能够更清晰、高效、方便地实现迭代。

在JavaScript中,计数循环就是一种最简单的迭代:

for (let i = 1; i <= 10; ++i) {
  console.log(i);
}

循环是迭代机制的基础,这是因为它可以指定迭代的次数,以及每次迭代要执行什么操作。每次循环都会在下一次迭代开始之前完成,而每次迭代的顺序都是事先定义好的。

迭代会在一个有序集合上进行。(“有序”可以理解为集合中所有项都可以按照既定的顺序被遍历到,特别是开始和结束项有明确的定义。)数组是JavaScript中有序集合的最典型例子。

let collection = ['foo', 'bar', 'baz'];

for (let index = 0; index < collection.length; ++index) {
  console.log(collection[index]);
}

因为数组有已知的长度,且数组每一项都可以通过索引获取,所以整个数组可以通过递增索引来遍历。

由于如下原因,通过这种循环来执行例程并不理想。

ES5新增了Array.prototype.forEach() 方法,向通用迭代需求迈进了一步(但仍然不够理想):

let collection = ['foo', 'bar', 'baz'];

collection.forEach((item) => console.log(item));
// foo
// bar
// baz

这个方法解决了单独记录索引和通过数组对象取得值的问题。不过,没有办法标识迭代何时终止。因此这个方法只适用于数组,而且回调结构也比较笨拙。

在ECMAScript较早的版本中,执行迭代必须使用循环或其他辅助结构。随着代码量增加,代码会变得越发混乱。很多语言都通过原生语言结构解决了这个问题,开发者无须事先知道如何迭代就能实现迭代操作。这个解决方案就是迭代器模式 。Python、Java、C++,还有其他很多语言都对这个模式提供了完备的支持。JavaScript在ECMAScript 6以后也支持了迭代器模式。

迭代器模式 (特别是在ECMAScript这个语境下)描述了一个方案,即可以把有些结构称为“可迭代对象”(iterable),因为它们实现了正式的Iterable 接口,而且可以通过迭代器Iterator 消费。

可迭代对象是一种抽象的说法。基本上,可以把可迭代对象理解成数组或集合这样的集合类型的对象。它们包含的元素都是有限的,而且都具有无歧义的遍历顺序:

// 数组的元素是有限的
// 递增索引可以按序访问每个元素
let arr = [3, 1, 4];

// 集合的元素是有限的
// 可以按插入顺序访问每个元素
let set = new Set().add(3).add(1).add(4);

不过,可迭代对象不一定是集合对象,也可以是仅仅具有类似数组行为的其他数据结构,比如本章开头提到的计数循环。该循环中生成的值是暂时性的,但循环本身是在执行迭代。计数循环和数组都具有可迭代对象的行为。

注意  临时性可迭代对象可以实现为生成器,本章后面会讨论。

任何实现Iterable 接口的数据结构都可以被实现Iterator 接口的结构“消费”(consume)。迭代器 (iterator)是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象 ,而迭代器会暴露迭代其关联可迭代对象的API。迭代器无须了解与其关联的可迭代对象的结构,只需要知道如何取得连续的值。这种概念上的分离正是IterableIterator 的强大之处。

实现Iterable 接口(可迭代协议)要求同时具备两种能力:支持迭代的自我识别能力和创建实现Iterator 接口的对象的能力。在ECMAScript中,这意味着必须暴露一个属性作为“默认迭代器”,而且这个属性必须使用特殊的Symbol.iterator 作为键。这个默认迭代器属性必须引用一个迭代器工厂函数,调用这个工厂函数必须返回一个新迭代器。

很多内置类型都实现了Iterable 接口:

检查是否存在默认迭代器属性可以暴露这个工厂函数:

let num = 1;
let obj = {};

// 这两种类型没有实现迭代器工厂函数
console.log(num[Symbol.iterator]); // undefined
console.log(obj[Symbol.iterator]); // undefined

let str = 'abc';
let arr = ['a', 'b', 'c'];
let map = new Map().set('a', 1).set('b', 2).set('c', 3);
let set = new Set().add('a').add('b').add('c');
let els = document.querySelectorAll('div');

// 这些类型都实现了迭代器工厂函数
console.log(str[Symbol.iterator]); // f values() { [native code] }
console.log(arr[Symbol.iterator]); // f values() { [native code] }
console.log(map[Symbol.iterator]); // f values() { [native code] }
console.log(set[Symbol.iterator]); // f values() { [native code] }
console.log(els[Symbol.iterator]); // f values() { [native code] }

// 调用这个工厂函数会生成一个迭代器
console.log(str[Symbol.iterator]()); // StringIterator {}
console.log(arr[Symbol.iterator]()); // ArrayIterator {}
console.log(map[Symbol.iterator]()); // MapIterator {}
console.log(set[Symbol.iterator]()); // SetIterator {}
console.log(els[Symbol.iterator]()); // ArrayIterator {}

实际写代码过程中,不需要显式调用这个工厂函数来生成迭代器。实现可迭代协议的所有类型都会自动兼容接收可迭代对象的任何语言特性。接收可迭代对象的原生语言特性包括:

这些原生语言结构会在后台调用提供的可迭代对象的这个工厂函数,从而创建一个迭代器:

let arr = ['foo', 'bar', 'baz'];

// for-of循环
for (let el of arr) {
  console.log(el);
}
// foo
// bar
// baz

// 数组解构
let [a, b, c] = arr;
console.log(a, b, c); // foo, bar, baz

// 扩展操作符
let arr2 = [...arr];
console.log(arr2); // ['foo', 'bar', 'baz']

// Array.from()
let arr3 = Array.from(arr);
console.log(arr3); // ['foo', 'bar', 'baz']

// Set构造函数
let set = new Set(arr);
console.log(set); // Set(3) {'foo', 'bar', 'baz'}

// Map构造函数
let pairs = arr.map((x, i) => [x, i]);
console.log(pairs); // [['foo', 0], ['bar', 1], ['baz', 2]]
let map = new Map(pairs);
console.log(map); // Map(3) { 'foo'=>0, 'bar'=>1, 'baz'=>2 }

如果对象原型链上的父类实现了Iterable 接口,那这个对象也就实现了这个接口:

class FooArray extends Array {}
let fooArr = new FooArray('foo', 'bar', 'baz');

for (let el of fooArr) {
  console.log(el);
}
// foo
// bar
// baz

迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象。迭代器API使用next() 方法在可迭代对象中遍历数据。每次成功调用next() ,都会返回一个IteratorResult 对象,其中包含迭代器返回的下一个值。若不调用next() ,则无法知道迭代器的当前位置。

next() 方法返回的迭代器对象IteratorResult 包含两个属性:donevaluedone 是一个布尔值,表示是否还可以再次调用next() 取得下一个值;value 包含可迭代对象的下一个值(donefalse ),或者undefineddonetrue )。done: true 状态称为“耗尽”。可以通过以下简单的数组来演示:

// 可迭代对象
let arr = ['foo', 'bar'];

// 迭代器工厂函数
console.log(arr[Symbol.iterator]); // f values() { [native code] }

// 迭代器
let iter = arr[Symbol.iterator]();
console.log(iter); // ArrayIterator {}

// 执行迭代
console.log(iter.next()); // { done: false, value: 'foo' }
console.log(iter.next()); // { done: false, value: 'bar' }
console.log(iter.next()); // { done: true, value: undefined }

这里通过创建迭代器并调用next() 方法按顺序迭代了数组,直至不再产生新值。迭代器并不知道怎么从可迭代对象中取得下一个值,也不知道可迭代对象有多大。只要迭代器到达done: true 状态,后续调用next() 就一直返回同样的值了:

let arr = ['foo'];
let iter = arr[Symbol.iterator]();
console.log(iter.next()); // { done: false, value: 'foo' }
console.log(iter.next()); // { done: true, value: undefined }
console.log(iter.next()); // { done: true, value: undefined }
console.log(iter.next()); // { done: true, value: undefined }

每个迭代器都表示对可迭代对象的一次性有序遍历。不同迭代器的实例相互之间没有联系,只会独立地遍历可迭代对象:

let arr = ['foo', 'bar'];
let iter1 = arr[Symbol.iterator]();
let iter2 = arr[Symbol.iterator]();

console.log(iter1.next()); // { done: false, value: 'foo' }
console.log(iter2.next()); // { done: false, value: 'foo' }
console.log(iter2.next()); // { done: false, value: 'bar' }
console.log(iter1.next()); // { done: false, value: 'bar' }

迭代器并不与可迭代对象某个时刻的快照绑定,而仅仅是使用游标来记录遍历可迭代对象的历程。如果可迭代对象在迭代期间被修改了,那么迭代器也会反映相应的变化:

let arr = ['foo', 'baz'];
let iter = arr[Symbol.iterator]();

console.log(iter.next()); // { done: false, value: 'foo' }

// 在数组中间插入值
arr.splice(1, 0, 'bar');

console.log(iter.next()); // { done: false, value: 'bar' }
console.log(iter.next()); // { done: false, value: 'baz' }
console.log(iter.next()); // { done: true, value: undefined }

注意  迭代器维护着一个指向可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。

“迭代器”的概念有时候容易模糊,因为它可以指通用的迭代,也可以指接口,还可以指正式的迭代器类型。下面的例子比较了一个显式的迭代器实现和一个原生的迭代器实现。

// 这个类实现了可迭代接口(Iterable)
// 调用默认的迭代器工厂函数会返回
// 一个实现迭代器接口(Iterator)的迭代器对象
class Foo {
  [Symbol.iterator]() {
    return {
      next() {
        return { done: false, value: 'foo' };
      }
    }
  }
}
let f = new Foo();

// 打印出实现了迭代器接口的对象
console.log(f[Symbol.iterator]()); // { next: f() {} }

// Array类型实现了可迭代接口(Iterable)
// 调用Array类型的默认迭代器工厂函数
// 会创建一个ArrayIterator的实例
let a = new Array();

// 打印出ArrayIterator的实例
console.log(a[Symbol.iterator]()); // Array Iterator {}

Iterable 接口类似,任何实现Iterator 接口的对象都可以作为迭代器使用。下面这个例子中的Counter 类只能被迭代一定的次数:

class Counter {
  // Counter的实例应该迭代limit次
  constructor(limit) {
    this.count = 1;
    this.limit = limit;
  }

  next() {
    if (this.count <= this.limit) {
      return { done: false, value: this.count++ };
    } else {
      return { done: true, value: undefined };
    }
  }
  [Symbol.iterator]() {
    return this;
  }
}

let counter = new Counter(3);

for (let i of counter) {
  console.log(i);
}
// 1
// 2
// 3

这个类实现了Iterator 接口,但不理想。这是因为它的每个实例只能被迭代一次:

for (let i of counter) { console.log(i); }
// 1
// 2
// 3

for (let i of counter) { console.log(i); }
// (nothing logged)

为了让一个可迭代对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计数器。为此,可以把计数器变量放到闭包里,然后通过闭包返回迭代器:

class Counter {
  constructor(limit) {
    this.limit = limit;
  }

  [Symbol.iterator]() {
    let count = 1,
        limit = this.limit;
    return {
      next() {
        if (count <= limit) {
          return { done: false, value: count++ };
        } else {
          return { done: true, value: undefined };
        }
      }
    };
  }
}

let counter = new Counter(3);

for (let i of counter) { console.log(i); }
// 1
// 2
// 3

for (let i of counter) { console.log(i); }
// 1
// 2
// 3

每个以这种方式创建的迭代器也实现了Iterable 接口。Symbol.iterator 属性引用的工厂函数会返回相同的迭代器:

let arr = ['foo', 'bar', 'baz'];
let iter1 = arr[Symbol.iterator]();

console.log(iter1[Symbol.iterator]);  // f values() { [native code] }

let iter2 = iter1[Symbol.iterator]();

console.log(iter1 === iter2);         // true

因为每个迭代器也实现了Iterable 接口,所以它们可以用在任何期待可迭代对象的地方,比如for-of 循环:

let arr = [3, 1, 4];
let iter = arr[Symbol.iterator]();

for (let item of arr ) { console.log(item); }
// 3
// 1
// 4

for (let item of iter ) { console.log(item); }
// 3
// 1
// 4

可选的return() 方法用于指定在迭代器提前关闭时执行的逻辑。执行迭代的结构在想让迭代器知道它不想遍历到可迭代对象耗尽时,就可以“关闭”迭代器。可能的情况包括:

return() 方法必须返回一个有效的IteratorResult 对象。简单情况下,可以只返回{ done: true } 。因为这个返回值只会用在生成器的上下文中,所以本章后面再讨论这种情况。

如下面的代码所示,内置语言结构在发现还有更多值可以迭代,但不会消费这些值时,会自动调用return() 方法。

class Counter {
  constructor(limit) {
    this.limit = limit;
  }

  [Symbol.iterator]() {
    let count = 1,
      limit = this.limit;
    return {
      next() {
        if (count <= limit) {
          return { done: false, value: count++ };
        } else {
          return { done: true };
        }
      },
      return() {
        console.log('Exiting early');
        return { done: true };
      }
    };
  }
}


let counter1 = new Counter(5);

for (let i of counter1) {
  if (i > 2) {
    break;
  }
  console.log(i);
}
// 1
// 2
// Exiting early


let counter2 = new Counter(5);

try {
  for (let i of counter2) {
    if (i > 2) {
      throw 'err';
    }
    console.log(i);
  }
} catch(e) {}
// 1
// 2
// Exiting early


let counter3 = new Counter(5);

let [a, b] = counter3;
// Exiting early

如果迭代器没有关闭,则还可以继续从上次离开的地方继续迭代。比如,数组的迭代器就是不能关闭的:

let a = [1, 2, 3, 4, 5];
let iter = a[Symbol.iterator]();

for (let i of iter) {
  console.log(i);
  if (i > 2) {
    break
  }
}
// 1
// 2
// 3

for (let i of iter) {
  console.log(i);
}
// 4
// 5

因为return() 方法是可选的,所以并非所有迭代器都是可关闭的。要知道某个迭代器是否可关闭,可以测试这个迭代器实例的return 属性是不是函数对象。不过,仅仅给一个不可关闭的迭代器增加这个方法并不能 让它变成可关闭的。这是因为调用return() 不会强制迭代器进入关闭状态。即便如此,return() 方法还是会被调用。

let a = [1, 2, 3, 4, 5];
let iter = a[Symbol.iterator]();

iter.return = function() {
  console.log('Exiting early');
  return { done: true };
};

for (let i of iter) {
  console.log(i);
  if (i > 2) {
    break
  }
}
// 1
// 2
// 3
// 提前退出

for (let i of iter) {
  console.log(i);
}
// 4
// 5

生成器是ECMAScript 6新增的一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力。这种新能力具有深远的影响,比如,使用生成器可以自定义迭代器和实现协程。

生成器的形式是一个函数,函数名称前面加一个星号(* )表示它是一个生成器。只要是可以定义函数的地方,就可以定义生成器。

// 生成器函数声明
function* generatorFn() {}

// 生成器函数表达式
let generatorFn = function* () {}

// 作为对象字面量方法的生成器函数
let foo = {
  * generatorFn() {}
}

// 作为类实例方法的生成器函数
class Foo {
  * generatorFn() {}
}

// 作为类静态方法的生成器函数
class Bar {
  static * generatorFn() {}
}

注意  箭头函数不能用来定义生成器函数。

标识生成器函数的星号不受两侧空格的影响:

// 等价的生成器函数:
function* generatorFnA() {}
function *generatorFnB() {}
function * generatorFnC() {}

// 等价的生成器方法:
class Foo {
  *generatorFnD() {}
  * generatorFnE() {}
}

调用生成器函数会产生一个生成器对象 。生成器对象一开始处于暂停执行(suspended)的状态。与迭代器相似,生成器对象也实现了Iterator 接口,因此具有next() 方法。调用这个方法会让生成器开始或恢复执行。

function* generatorFn() {}

const g = generatorFn();

console.log(g);       // generatorFn {<suspended>}
console.log(g.next);  // f next() { [native code] }

next() 方法的返回值类似于迭代器,有一个done 属性和一个value 属性。函数体为空的生成器函数中间不会停留,调用一次next() 就会让生成器到达done: true 状态。

function* generatorFn() {}

let generatorObject = generatorFn();

console.log(generatorObject);         // generatorFn {<suspended>}
console.log(generatorObject.next());  // { done: true, value: undefined }

value 属性是生成器函数的返回值,默认值为undefined ,可以通过生成器函数的返回值指定:

function* generatorFn() {
  return 'foo';
}

let generatorObject = generatorFn();

console.log(generatorObject);         // generatorFn {<suspended>}
console.log(generatorObject.next());  // { done: true, value: 'foo' }

生成器函数只会在初次调用next() 方法后开始执行,如下所示:

function* generatorFn() {
  console.log('foobar');
}

// 初次调用生成器函数并不会打印日志
let generatorObject = generatorFn();

generatorObject.next();  // foobar

生成器对象实现了Iterable 接口,它们默认的迭代器是自引用的:

function* generatorFn() {}

console.log(generatorFn);
// f* generatorFn() {}
console.log(generatorFn()[Symbol.iterator]);
// f [Symbol.iterator]() {native code}
console.log(generatorFn());
// generatorFn {<suspended>}
console.log(generatorFn()[Symbol.iterator]());
// generatorFn {<suspended>}

const g = generatorFn();

console.log(g === g[Symbol.iterator]());
// true

yield 关键字可以让生成器停止和开始执行,也是生成器最有用的地方。生成器函数在遇到yield 关键字之前会正常执行。遇到这个关键字后,执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用next() 方法来恢复执行:

function* generatorFn() {
  yield;
}

let generatorObject = generatorFn();

console.log(generatorObject.next());  // { done: false, value: undefined }
console.log(generatorObject.next());  // { done: true, value: undefined }

此时的yield 关键字有点像函数的中间返回语句,它生成的值会出现在next() 方法返回的对象里。通过yield 关键字退出的生成器函数会处在done: false 状态;通过return 关键字退出的生成器函数会处于done: true 状态。

function* generatorFn() {
  yield 'foo';
  yield 'bar';
  return 'baz';
}

let generatorObject = generatorFn();

console.log(generatorObject.next());  // { done: false, value: 'foo' }
console.log(generatorObject.next());  // { done: false, value: 'bar' }
console.log(generatorObject.next());  // { done: true, value: 'baz' }

生成器函数内部的执行流程会针对每个生成器对象区分作用域。在一个生成器对象上调用next() 不会影响其他生成器:

function* generatorFn() {
  yield 'foo';
  yield 'bar';
  return 'baz';
}

let generatorObject1 = generatorFn();
let generatorObject2 = generatorFn();


console.log(generatorObject1.next()); // { done: false, value: 'foo' }
console.log(generatorObject2.next()); // { done: false, value: 'foo' }
console.log(generatorObject2.next()); // { done: false, value: 'bar' }
console.log(generatorObject1.next()); // { done: false, value: 'bar' }

yield 关键字只能在生成器函数内部使用,用在其他地方会抛出错误。类似函数的return 关键字,yield 关键字必须直接位于生成器函数定义中,出现在嵌套的非生成器函数中会抛出语法错误:

// 有效
function* validGeneratorFn() {
  yield;
}

// 无效
function* invalidGeneratorFnA() {
  function a() {
    yield;
  }
}

// 无效
function* invalidGeneratorFnB() {
  const b = () => {
    yield;
  }
}

// 无效
function* invalidGeneratorFnC() {
  (() => {
    yield;
  })();
}

  1. 生成器对象作为可迭代对象

    在生成器对象上显式调用next() 方法的用处并不大。其实,如果把生成器对象当成可迭代对象,那么使用起来会更方便:

    function* generatorFn() {
      yield 1;
      yield 2;
      yield 3;
    }
    
    for (const x of generatorFn()) {
      console.log(x);
    }
    // 1
    // 2
    // 3
    
    

    在需要自定义迭代对象时,这样使用生成器对象会特别有用。比如,我们需要定义一个可迭代对象,而它会产生一个迭代器,这个迭代器会执行指定的次数。使用生成器,可以通过一个简单的循环来实现:

    function* nTimes(n) {
      while(n--) {
        yield;
      }
    }
    
    for (let _ of nTimes(3)) {
      console.log('foo');
    }
    // foo
    // foo
    // foo
    
    

    传给生成器的函数可以控制迭代循环的次数。在n 为0时,while 条件为假,循环退出,生成器函数返回。


     

  2. 使用yield 实现输入和输出

    除了可以作为函数的中间返回语句使用,yield 关键字还可以作为函数的中间参数使用。上一次让生成器函数暂停的yield 关键字会接收到传给next() 方法的第一个值。这里有个地方不太好理解——第一次调用next() 传入的值不会被使用,因为这一次调用是为了开始执行生成器函数:

    function* generatorFn(initial) {
      console.log(initial);
      console.log(yield);
      console.log(yield);
    }
    
    let generatorObject = generatorFn('foo');
    
    generatorObject.next('bar');  // foo
    generatorObject.next('baz');  // baz
    generatorObject.next('qux');  // qux
    
    

    yield 关键字可以同时用于输入和输出,如下例所示:

    function* generatorFn() {
      return yield 'foo';
    }
    
    let generatorObject = generatorFn();
    
    console.log(generatorObject.next());       // { done: false, value: 'foo' }
    console.log(generatorObject.next('bar'));  // { done: true, value: 'bar' }
    
    

    因为函数必须对整个表达式求值才能确定要返回的值,所以它在遇到yield 关键字时暂停执行并计算出要产生的值:"foo" 。下一次调用next() 传入了"bar" ,作为交给同一个yield 的值。然后这个值被确定为本次生成器函数要返回的值。

    yield 关键字并非只能使用一次。比如,以下代码就定义了一个无穷计数生成器函数:

    function* generatorFn() {
      for (let i = 0;;++i) {
        yield i;
      }
    }
    
    let generatorObject = generatorFn();
    
    console.log(generatorObject.next().value);  // 0
    console.log(generatorObject.next().value);  // 1
    console.log(generatorObject.next().value);  // 2
    console.log(generatorObject.next().value);  // 3
    console.log(generatorObject.next().value);  // 4
    console.log(generatorObject.next().value);  // 5
    ...
    
    

    假设我们想定义一个生成器函数,它会根据配置的值迭代相应次数并产生迭代的索引。初始化一个新数组可以实现这个需求,但不用数组也可以实现同样的行为:

    function* nTimes(n) {
      for (let i = 0; i < n; ++i) {
        yield i;
      }
    }
    
    for (let x of nTimes(3)) {
      console.log(x);
    }
    // 0
    // 1
    // 2
    
    

    另外,使用while 循环也可以,而且代码稍微简洁一点:

    function* nTimes(n) {
      let i = 0;
      while(n--) {
        yield i++;
      }
    }
    
    for (let x of nTimes(3)) {
      console.log(x);
    }
    // 0
    // 1
    // 2
    
    

    这样使用生成器也可以实现范围和填充数组:

    function* range(start, end) {
      while(end > start) {
        yield start++;
      }
    }
    
    for (const x of range(4, 7)) {
      console.log(x);
    }
    // 4
    // 5
    // 6
    
    function* zeroes(n) {
      while(n--) {
        yield 0;
      }
    }
    
    console.log(Array.from(zeroes(8))); // [0, 0, 0, 0, 0, 0, 0, 0]
    
    

     

  3. 产生可迭代对象

    可以使用星号增强yield 的行为,让它能够迭代一个可迭代对象,从而一次产出一个值:

    // 等价的generatorFn:
    // function* generatorFn() {
    //   for (const x of [1, 2, 3]) {
    //     yield x;
    //   }
    // }
    function* generatorFn() {
      yield* [1, 2, 3];
    }
    
    let generatorObject = generatorFn();
    
    for (const x of generatorFn()) {
      console.log(x);
    }
    // 1
    // 2
    // 3
    
    

    与生成器函数的星号类似,yield 星号两侧的空格不影响其行为:

    function* generatorFn() {
      yield* [1, 2];
      yield *[3, 4];
      yield * [5, 6];
    }
    
    for (const x of generatorFn()) {
      console.log(x);
    }
    // 1
    // 2
    // 3
    // 4
    // 5
    // 6
    
    

    因为yield* 实际上只是将一个可迭代对象序列化为一连串可以单独产出的值,所以这跟把yield 放到一个循环里没什么不同。下面两个生成器函数的行为是等价的:

    function* generatorFnA() {
      for (const x of [1, 2, 3]) {
        yield x;
      }
    }
    
    for (const x of generatorFnA()) {
      console.log(x);
    }
    // 1
    // 2
    // 3
    
    function* generatorFnB() {
      yield* [1, 2, 3];
    }
    
    for (const x of generatorFnB()) {
      console.log(x);
    }
    // 1
    // 2
    // 3
    
    

    yield* 的值是关联迭代器返回done: true 时的value 属性。对于普通迭代器来说,这个值是undefined

    function* generatorFn() {
      console.log('iter value:', yield* [1, 2, 3]);
    }
    
    for (const x of generatorFn()) {
      console.log('value:', x);
    }
    // value: 1
    // value: 2
    // value: 3
    // iter value: undefined
    
    

    对于生成器函数产生的迭代器来说,这个值就是生成器函数返回的值:

    function* innerGeneratorFn() {
      yield 'foo';
      return 'bar';
    }
    function* outerGeneratorFn(genObj) {
      console.log('iter value:', yield* innerGeneratorFn());
    }
    
    for (const x of outerGeneratorFn()) {
      console.log('value:', x);
    }
    // value: foo
    // iter value: bar
    
    

     

  4. 使用yield* 实现递归算法

    yield* 最有用的地方是实现递归操作,此时生成器可以产生自身。看下面的例子:

    function* nTimes(n) {
      if (n > 0) {
        yield* nTimes(n - 1);
        yield n - 1;
      }
    }
    
    for (const x of nTimes(3)) {
      console.log(x);
    }
    // 0
    // 1
    // 2
    
    

    在这个例子中,每个生成器首先都会从新创建的生成器对象产出每个值,然后再产出一个整数。结果就是生成器函数会递归地减少计数器值,并实例化另一个生成器对象。从最顶层来看,这就相当于创建一个可迭代对象并返回递增的整数。

    使用递归生成器结构和yield* 可以优雅地表达递归算法。下面是一个图的实现,用于生成一个随机的双向图:

    class Node {
      constructor(id) {
        this.id = id;
        this.neighbors = new Set();
      }
    
      connect(node) {
        if (node !== this) {
          this.neighbors.add(node);
          node.neighbors.add(this);
        }
      }
    }
    
    class RandomGraph {
      constructor(size) {
        this.nodes = new Set();
    
        // 创建节点
        for (let i = 0; i < size; ++i) {
          this.nodes.add(new Node(i));
        }
    
        // 随机连接节点
        const threshold = 1 / size;
        for (const x of this.nodes) {
          for (const y of this.nodes) {
            if (Math.random() < threshold) {
              x.connect(y);
            }
          }
        }
      }
    
      // 这个方法仅用于调试
      print() {
        for (const node of this.nodes) {
          const ids = [...node.neighbors]
                          .map((n) => n.id)
                          .join(',');
    
          console.log(`${node.id}: ${ids}`);
        }
      }
    }
    
    const g = new RandomGraph(6);
    
    g.print();
    // 示例输出:
    // 0: 2,3,5
    // 1: 2,3,4,5
    // 2: 1,3
    // 3: 0,1,2,4
    // 4: 2,3
    // 5: 0,4
    
    

    图数据结构非常适合递归遍历,而递归生成器恰好非常合用。为此,生成器函数必须接收一个可迭代对象,产出该对象中的每一个值,并且对每个值进行递归。这个实现可以用来测试某个图是否连通,即是否没有不可到达的节点。只要从一个节点开始,然后尽力访问每个节点就可以了。结果就得到了一个非常简洁的深度优先遍历:

    class Node {
      constructor(id) {
        ...
      }
    
      connect(node) {
        ...
      }
    }
    
    class RandomGraph {
      constructor(size) {
        ...
      }
    
      print() {
        ...
      }
    
      isConnected() {
        const visitedNodes = new Set();
    
        function* traverse(nodes) {
          for (const node of nodes) {
            if (!visitedNodes.has(node)) {
              yield node;
              yield* traverse(node.neighbors);
            }
          }
        }
    
        // 取得集合中的第一个节点
        const firstNode = this.nodes[Symbol.iterator]().next().value;
    
        // 使用递归生成器迭代每个节点
        for (const node of traverse([firstNode])) {
          visitedNodes.add(node);
        }
    
        return visitedNodes.size === this.nodes.size;
      }
    }
    
    

因为生成器对象实现了Iterable 接口,而且生成器函数和默认迭代器被调用之后都产生迭代器,所以生成器格外适合作为默认迭代器。下面是一个简单的例子,这个类的默认迭代器可以用一行代码产出类的内容:

class Foo {
  constructor() {
    this.values = [1, 2, 3];
  }
  * [Symbol.iterator]() {
    yield* this.values;
  }
}

const f = new Foo();
for (const x of f) {
  console.log(x);
}
// 1
// 2
// 3

这里,for-of 循环调用了默认迭代器(它恰好又是一个生成器函数)并产生了一个生成器对象。这个生成器对象是可迭代的,所以完全可以在迭代中使用。

与迭代器类似,生成器也支持“可关闭”的概念。一个实现Iterator 接口的对象一定有next() 方法,还有一个可选的return() 方法用于提前终止迭代器。生成器对象除了有这两个方法,还有第三个方法:throw()

function* generatorFn() {}

const g = generatorFn();

console.log(g);         // generatorFn {<suspended>}
console.log(g.next);    // f next() { [native code] }
console.log(g.return);  // f return() { [native code] }
console.log(g.throw);   // f throw() { [native code] }

return()throw() 方法都可以用于强制生成器进入关闭状态。

  1. return()

    return() 方法会强制生成器进入关闭状态。提供给return() 方法的值,就是终止迭代器对象的值:

    function* generatorFn() {
      for (const x of [1, 2, 3]) {
        yield x;
      }
    }
    
    const g = generatorFn();
    
    console.log(g);            // generatorFn {<suspended>}
    console.log(g.return(4));  // { done: true, value: 4 }
    console.log(g);            // generatorFn {<closed>}
    
    

    与迭代器不同,所有生成器对象都有return() 方法,只要通过它进入关闭状态,就无法恢复了。后续调用next() 会显示done: true 状态,而提供的任何返回值都不会被存储或传播:

    function* generatorFn() {
      for (const x of [1, 2, 3]) {
        yield x;
      }
    }
    
    const g = generatorFn();
    
    console.log(g.next());     // { done: false, value: 1 }
    console.log(g.return(4));  // { done: true, value: 4 }
    console.log(g.next());     // { done: true, value: undefined }
    console.log(g.next());     // { done: true, value: undefined }
    console.log(g.next());     // { done: true, value: undefined }
    
    

    for-of 循环等内置语言结构会忽略状态为done: trueIteratorObject 内部返回的值。

    function* generatorFn() {
      for (const x of [1, 2, 3]) {
        yield x;
      }
    }
    
    const g = generatorFn();
    
    for (const x of g) {
      if (x > 1) {
        g.return(4);
      }
      console.log(x);
    }
    // 1
    // 2
    
    

     

  2. throw()

    throw() 方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭:

    function* generatorFn() {
      for (const x of [1, 2, 3]) {
        yield x;
      }
    }
    
    const g = generatorFn();
    
    console.log(g);   // generatorFn {<suspended>}
    try {
      g.throw('foo');
    } catch (e) {
      console.log(e); // foo
    }
    console.log(g);   // generatorFn {<closed>}
    
    

    不过,假如生成器函数内部 处理了这个错误,那么生成器就不会关闭,而且还可以恢复执行。错误处理会跳过对应的yield ,因此在这个例子中会跳过一个值。比如:

    function* generatorFn() {
      for (const x of [1, 2, 3]) {
        try {
          yield x;
        } catch(e) {}
      }
    }
    
    const g = generatorFn();
    
    console.log(g.next()); // { done: false, value: 1}
    g.throw('foo');
    console.log(g.next()); // { done: false, value: 3}
    
    

    在这个例子中,生成器在try /catch 块中的yield 关键字处暂停执行。在暂停期间,throw() 方法向生成器对象内部注入了一个错误:字符串"foo" 。这个错误会被yield 关键字抛出。因为错误是在生成器的try /catch 块中抛出的,所以仍然在生成器内部被捕获。可是,由于yield 抛出了那个错误,生成器就不会再产出值2 。此时,生成器函数继续执行,在下一次迭代再次遇到yield 关键字时产出了值3

    注意  如果生成器对象还没有开始执行,那么调用throw() 抛出的错误不会在函数内部被捕获,因为这相当于在函数块外部抛出了错误。

迭代是一种所有编程语言中都可以看到的模式。ECMAScript 6正式支持迭代模式并引入了两个新的语言特性:迭代器和生成器。

迭代器是一个可以由任意对象实现的接口,支持连续获取对象产出的每一个值。任何实现Iterable 接口的对象都有一个Symbol.iterator 属性,这个属性引用默认迭代器。默认迭代器就像一个迭代器工厂,也就是一个函数,调用之后会产生一个实现Iterator 接口的对象。

迭代器必须通过连续调用next() 方法才能连续取得值,这个方法返回一个IteratorObject 。这个对象包含一个done 属性和一个value 属性。前者是一个布尔值,表示是否还有更多值可以访问;后者包含迭代器返回的当前值。这个接口可以通过手动反复调用next() 方法来消费,也可以通过原生消费者,比如for-of 循环来自动消费。

生成器是一种特殊的函数,调用之后会返回一个生成器对象。生成器对象实现了Iterable 接口,因此可用在任何消费可迭代对象的地方。生成器的独特之处在于支持yield 关键字,这个关键字能够暂停执行生成器函数。使用yield 关键字还可以通过next() 方法接收输入和产生输出。在加上星号之后,yield 关键字可以将跟在它后面的可迭代对象序列化为一连串值。


第 8 章 对象、类与面向对象编程

本章内容

ECMA-262将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。正因为如此(以及其他还未讨论的原因),可以把ECMAScript的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。

创建自定义对象的通常方式是创建Object 的一个新实例,然后再给它添加属性和方法,如下例所示:

let person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";
person.sayName = function() {
  console.log(this.name);
};

这个例子创建了一个名为person 的对象,而且有三个属性(nameagejob )和一个方法(sayName() )。sayName() 方法会显示this.name 的值,这个属性会解析为person.name 。早期JavaScript开发者频繁使用这种方式创建新对象。几年后,对象字面量变成了更流行的方式。前面的例子如果使用对象字面量则可以这样写:

let person = {
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName() {
    console.log(this.name);
  }
};

这个例子中的person 对象跟前面例子中的person 对象是等价的,它们的属性和方法都一样。这些属性都有自己的特征,而这些特征决定了它们在JavaScript中的行为。

ECMA-262使用一些内部特性来描述属性的特征。这些特性是由为JavaScript实现引擎的规范定义的。因此,开发者不能在JavaScript中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[Enumerable]]

属性分两种:数据属性和访问器属性。

  1. 数据属性

    数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有4个特性描述它们的行为。

    在像前面例子中那样将属性显式添加到对象之后,[[Configurable]][[Enumerable]][[Writable]] 都会被设置为true ,而[[Value]] 特性会被设置为指定的值。比如:

    let person = {
      name: "Nicholas"
    };
    
    

    这里,我们创建了一个名为name 的属性,并给它赋予了一个值"Nicholas" 。这意味着[[Value]] 特性会被设置为"Nicholas" ,之后对这个值的任何修改都会保存这个位置。

    要修改属性的默认特性,就必须使用Object.defineProperty() 方法。这个方法接收3个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurableenumerablewritablevalue ,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。比如:

    let person = {};
    Object.defineProperty(person, "name", {
      writable: false,
      value: "Nicholas"
    });
    console.log(person.name); // "Nicholas"
    person.name = "Greg";
    console.log(person.name); // "Nicholas"
    
    

    这个例子创建了一个名为name 的属性并给它赋予了一个只读的值"Nicholas" 。这个属性的值就不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性的值会抛出错误。

    类似的规则也适用于创建不可配置的属性。比如:

    let person = {};
    Object.defineProperty(person, "name", {
      configurable: false,
      value: "Nicholas"
    });
    console.log(person.name); // "Nicholas"
    delete person.name;
    console.log(person.name); // "Nicholas"
    
    

    这个例子把configurable 设置为false ,意味着这个属性不能从对象上删除。非严格模式下对这个属性调用delete 没有效果,严格模式下会抛出错误。此外,一个属性被定义为不可配置之后,就不能再变回可配置的了。再次调用Object.defineProperty() 并修改任何非writable 属性会导致错误:

    let person = {};
    Object.defineProperty(person, "name", {
      configurable: false,
      value: "Nicholas"
    });
    
    // 抛出错误
    Object.defineProperty(person, "name", {
      configurable: true,
      value: "Nicholas"
    });
    
    

    因此,虽然可以对同一个属性多次调用Object.defineProperty() ,但在把configurable 设置为false 之后就会受限制了。

    在调用Object.defineProperty() 时,configurableenumerablewritable 的值如果不指定,则都默认为false 。多数情况下,可能都不需要Object.defineProperty() 提供的这些强大的设置,但要理解JavaScript对象,就要理解这些概念。
     

  2. 访问器属性

    访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有4个特性描述它们的行为。

    访问器属性是不能直接定义的,必须使用Object.defineProperty() 。下面是一个例子:

    // 定义一个对象,包含伪私有成员year_和公共成员edition
    let book = {
      year_: 2017,
      edition: 1
    };
    
    Object.defineProperty(book, "year", {
      get() {
        return this.year_;
      },
      set(newValue) {
        if (newValue > 2017) {
          this.year_ = newValue;
          this.edition += newValue - 2017;
        }
      }
    });
    book.year = 2018;
    console.log(book.edition); // 2
    
    

    在这个例子中,对象book 有两个默认属性:year_editionyear_ 中的下划线常用来表示该属性并不希望在对象方法的外部被访问。另一个属性year 被定义为一个访问器属性,其中获取函数简单地返回year_ 的值,而设置函数会做一些计算以决定正确的版本(edition)。因此,把year 属性修改为2018会导致year_ 变成2018,edition 变成2。这是访问器属性的典型使用场景,即设置一个属性值会导致一些其他变化发生。

    获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性是不能读取的,非严格模式下读取会返回undefined ,严格模式下会抛出错误。

    在不支持Object.defineProperty() 的浏览器中没有办法修改[[Configurable]][[Enumerable]]

    注意  在ECMAScript 5以前,开发者会使用两个非标准的访问创建访问器属性:__defineGetter__()__defineSetter__() 。这两个方法最早是Firefox引入的,后来Safari、Chrome和Opera也实现了。

在一个对象上同时定义多个属性的可能性是非常大的。为此,ECMAScript提供了Object.defineProperties() 方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。比如:

let book = {};
Object.defineProperties(book, {
  year_: {
    value: 2017
  },

  edition: {
    value: 1
  },

  year: {
    get() {
      return this.year_;
    },

    set(newValue) {
      if (newValue > 2017) {
        this.year_ = newValue;
        this.edition += newValue - 2017;
      }
    }
  }
});

这段代码在book 对象上定义了两个数据属性year_edition ,还有一个访问器属性year 。最终的对象跟上一节示例中的一样。唯一的区别是所有属性都是同时定义的,并且数据属性的configurableenumerablewritable 特性值都是false

使用Object.getOwnPropertyDescriptor() 方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含configurableenumerablegetset 属性,对于数据属性包含configurableenumerablewritablevalue 属性。比如:

let book = {};
Object.defineProperties(book, {
  year_: {
    value: 2017
  },

  edition: {
    value: 1
  },

  year: {
    get: function() {
      return this.year_;
    },

    set: function(newValue){
      if (newValue > 2017) {
        this.year_ = newValue;
        this.edition += newValue - 2017;
      }
    }
  }
});

let descriptor = Object.getOwnPropertyDescriptor(book, "year_");
console.log(descriptor.value);          // 2017
console.log(descriptor.configurable);   // false
console.log(typeof descriptor.get);     // "undefined"
let descriptor = Object.getOwnPropertyDescriptor(book, "year");
console.log(descriptor.value);          // undefined
console.log(descriptor.enumerable);     // false
console.log(typeof descriptor.get);     // "function"

对于数据属性year_value 等于原来的值,configurablefalsegetundefined 。对于访问器属性yearvalueundefinedenumerablefalseget 是一个指向获取函数的指针。

ECMAScript 2017新增了Object.getOwnPropertyDescriptors() 静态方法。这个方法实际上会在每个自有属性上调用Object.getOwnPropertyDescriptor() 并在一个新对象中返回它们。对于前面的例子,使用这个静态方法会返回如下对象:

let book = {};
Object.defineProperties(book, {
  year_: {
    value: 2017
  },

  edition: {
    value: 1
  },

  year: {
    get: function() {
      return this.year_;
    },

    set: function(newValue){
      if (newValue > 2017) {
        this.year_ = newValue;
        this.edition += newValue - 2017;
      }
    }
  }
});

console.log(Object.getOwnPropertyDescriptors(book));
// {
//   edition: {
//     configurable: false,
//     enumerable: false,
//     value: 1,
//     writable: false
//   },
//   year: {
//     configurable: false,
//     enumerable: false,
//     get: f(),
//     set: f(newValue),
//   },
//   year_: {
//     configurable: false,
//     enumerable: false,
//     value: 2017,
//     writable: false
//   }
// }

JavaScript开发者经常觉得“合并”(merge)两个对象很有用。更具体地说,就是把源对象所有的本地属性一起复制到目标对象上。有时候这种操作也被称为“混入”(mixin),因为目标对象通过混入源对象的属性得到了增强。

ECMAScript 6专门为合并对象提供了Object.assign() 方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable() 返回true )和自有(Object.hasOwnProperty() 返回true )属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]] 取得属性的值,然后使用目标对象上的[[Set]] 设置属性的值。

let dest, src, result;

/**
 * 简单复制
 */
dest = {};
src = { id: 'src' };

result = Object.assign(dest, src);

// Object.assign修改目标对象
// 也会返回修改后的目标对象
console.log(dest === result); // true
console.log(dest !== src);    // true
console.log(result);          // { id: src }
console.log(dest);            // { id: src }


/**
 * 多个源对象
 */
dest = {};

result = Object.assign(dest, { a: 'foo' }, { b: 'bar' });

console.log(result); // { a: foo, b: bar }


/**
 * 获取函数与设置函数
 */
dest = {
  set a(val) {
    console.log(`Invoked dest setter with param ${val}`);
  }
};
src = {
  get a() {
    console.log('Invoked src getter');
    return 'foo';
  }
};

Object.assign(dest, src);
// 调用src的获取方法
// 调用dest的设置方法并传入参数"foo"
// 因为这里的设置函数不执行赋值操作
// 所以实际上并没有把值转移过来
console.log(dest); // { set a(val) {...} }

Object.assign() 实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数。

let dest, src, result;

/**
 * 覆盖属性
 */
dest = { id: 'dest' };

result = Object.assign(dest, { id: 'src1', a: 'foo' }, { id: 'src2', b: 'bar' });

// Object.assign会覆盖重复的属性
console.log(result); // { id: src2, a: foo, b: bar }

// 可以通过目标对象上的设置函数观察到覆盖的过程:
dest = {
  set id(x) {
    console.log(x);
  }
};

Object.assign(dest, { id: 'first' }, { id: 'second' }, { id: 'third' });
// first
// second
// third


/**
 * 对象引用
 */

dest = {};
src = { a: {} };

Object.assign(dest, src);

// 浅复制意味着只会复制对象的引用
console.log(dest);              // { a :{} }
console.log(dest.a === src.a);  // true

如果赋值期间出错,则操作会中止并退出,同时抛出错误。Object.assign() 没有“回滚”之前赋值的概念,因此它是一个尽力而为、可能只会完成部分复制的方法。

let dest, src, result;

/**
 * 错误处理
 */
dest = {};
src = {
  a: 'foo',
  get b() {
    // Object.assign()在调用这个获取函数时会抛出错误
    throw new Error();
  },
  c: 'bar'
};

try {
  Object.assign(dest, src);
} catch(e) {}

// Object.assign()没办法回滚已经完成的修改
// 因此在抛出错误之前,目标对象上已经完成的修改会继续存在:
console.log(dest); // { a: foo }

在ECMAScript 6之前,有些特殊情况即使是=== 操作符也无能为力:

// 这些是===符合预期的情况
console.log(true === 1);  // false
console.log({} === {});   // false
console.log("2" === 2);   // false

// 这些情况在不同JavaScript引擎中表现不同,但仍被认为相等
console.log(+0 === -0);   // true
console.log(+0 === 0);    // true
console.log(-0 === 0);    // true

// 要确定NaN的相等性,必须使用极为讨厌的isNaN()
console.log(NaN === NaN); // false
console.log(isNaN(NaN));  // true

为改善这类情况,ECMAScript 6规范新增了Object.is() ,这个方法与=== 很像,但同时也考虑到了上述边界情形。这个方法必须接收两个参数:

console.log(Object.is(true, 1));  // false
console.log(Object.is({}, {}));   // false
console.log(Object.is("2", 2));   // false

// 正确的0、-0、+0相等/不等判定
console.log(Object.is(+0, -0));   // false
console.log(Object.is(+0, 0));    // true
console.log(Object.is(-0, 0));    // false

// 正确的NaN相等判定
console.log(Object.is(NaN, NaN)); // true

要检查超过两个值,递归地利用相等性传递即可:

function recursivelyCheckEqual(x, ...rest) {
  return Object.is(x, rest[0]) &&
         (rest.length < 2 || recursivelyCheckEqual(...rest));
}

ECMAScript 6为定义和操作对象新增了很多极其有用的语法糖特性。这些特性都没有改变现有引擎的行为,但极大地提升了处理对象的方便程度。

本节介绍的所有对象语法同样适用于ECMAScript 6的类,本章后面会讨论。

注意  相比于以往的替代方案,本节介绍的增强对象语法可以说是一骑绝尘。因此本章及本书会默认使用这些新语法特性。

  1. 属性值简写

    在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。例如:

    let name = 'Matt';
    
    let person = {
      name: name
    };
    
    console.log(person); // { name: 'Matt' }
    
    

    为此,简写属性名语法出现了。简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键。如果没有找到同名变量,则会抛出ReferenceError

    以下代码和之前的代码是等价的:

    let name = 'Matt';
    
    let person = {
      name
    };
    
    console.log(person); // { name: 'Matt' }
    
    

    代码压缩程序会在不同作用域间保留属性名,以防止找不到引用。以下面的代码为例:

    function makePerson(name) {
      return {
        name
      };
    }
    
    let person = makePerson('Matt');
    
    console.log(person.name);  // Matt
    
    

    在这里,即使参数标识符只限定于函数作用域,编译器也会保留初始的name 标识符。如果使用Google Closure编译器压缩,那么函数参数会被缩短,而属性名不变:

    function makePerson(a) {
      return {
        name: a
      };
    }
    
    var person = makePerson("Matt");
    
    console.log(person.name); // Matt
    
    

     

  2. 可计算属性

    在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。换句话说,不能在对象字面量中直接动态命名属性。比如:

    const nameKey = 'name';
    const ageKey = 'age';
    const jobKey = 'job';
    
    let person = {};
    person[nameKey] = 'Matt';
    person[ageKey] = 27;
    person[jobKey] = 'Software engineer';
    
    console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }
    
    

    有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为JavaScript表达式而不是字符串来求值:

    const nameKey = 'name';
    const ageKey = 'age';
    const jobKey = 'job';
    
    let person = {
      [nameKey]: 'Matt',
      [ageKey]: 27,
      [jobKey]: 'Software engineer'
    };
    
    console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }
    
    

    因为被当作JavaScript表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值:

    const nameKey = 'name';
    const ageKey = 'age';
    const jobKey = 'job';
    let uniqueToken = 0;
    
    function getUniqueKey(key) {
      return `${key}_${uniqueToken++}`;
    }
    
    let person = {
      [getUniqueKey(nameKey)]: 'Matt',
      [getUniqueKey(ageKey)]: 27,
      [getUniqueKey(jobKey)]: 'Software engineer'
    };
    
    console.log(person);  // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }
    
    

    注意  可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。

     

  3. 简写方法名

    在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式,如下所示:

    let person = {
      sayName: function(name) {
        console.log(`My name is ${name}`);
      }
    };
    
    person.sayName('Matt'); // My name is Matt
    
    

    新的简写方法的语法遵循同样的模式,但开发者要放弃给函数表达式命名(不过给作为方法的函数命名通常没什么用)。相应地,这样也可以明显缩短方法声明。

    以下代码和之前的代码在行为上是等价的:

    let person = {
      sayName(name) {
        console.log(`My name is ${name}`);
      }
    };
    
    person.sayName('Matt'); // My name is Matt
    
    

    简写方法名对获取函数和设置函数也是适用的:

    let person = {
      name_: '',
      get name() {
        return this.name_;
      },
      set name(name) {
        this.name_ = name;
      },
      sayName() {
        console.log(`My name is ${this.name_}`);
      }
    };
    
    person.name = 'Matt';
    person.sayName(); // My name is Matt
    
    

    简写方法名与可计算属性键相互兼容:

    const methodKey = 'sayName';
    
    let person = {
      [methodKey](name) {
        console.log(`My name is ${name}`);
      }
    }
    
    person.sayName('Matt'); // My name is Matt
    
    

    注意  简写方法名对于本章后面介绍的ECMAScript 6的类更有用。

ECMAScript 6新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。

下面的例子展示了两段等价的代码,首先是不使用对象解构的:

// 不使用对象解构
let person = {
  name: 'Matt',
  age: 27
};

let personName = person.name,
    personAge = person.age;

console.log(personName); // Matt
console.log(personAge);  // 27

然后,是使用对象解构的:

// 使用对象解构
let person = {
  name: 'Matt',
  age: 27
};

let { name: personName, age: personAge } = person;

console.log(personName);  // Matt
console.log(personAge);   // 27

使用解构,可以在一个类似对象字面量的结构中,声明多个变量,同时执行多个赋值操作。如果想让变量直接使用属性的名称,那么可以使用简写语法,比如:

let person = {
  name: 'Matt',
  age: 27
};

let { name, age } = person;

console.log(name);  // Matt
console.log(age);   // 27

解构赋值不一定与对象的属性匹配。赋值的时候可以忽略某些属性,而如果引用的属性不存在,则该变量的值就是undefined

let person = {
  name: 'Matt',
  age: 27
};

let { name, job } = person;

console.log(name);  // Matt
console.log(job);   // undefined

也可以在解构赋值的同时定义默认值,这适用于前面刚提到的引用的属性不存在于源对象中的情况:

let person = {
  name: 'Matt',
  age: 27
};

let { name, job='Software engineer' } = person;

console.log(name); // Matt
console.log(job);  // Software engineer

解构在内部使用函数ToObject() (不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据ToObject() 的定义),nullundefined 不能被解构,否则会抛出错误。

let { length } = 'foobar';
console.log(length);        // 6

let { constructor: c } = 4;
console.log(c === Number);  // true

let { _ } = null;           // TypeError

let { _ } = undefined;      // TypeError

解构并不要求变量必须在解构表达式中声明。不过,如果是给事先声明的变量赋值,则赋值表达式必须包含在一对括号中:

let personName, personAge;

let person = {
  name: 'Matt',
  age: 27
};

({name: personName, age: personAge} = person);

console.log(personName, personAge); // Matt, 27

  1. 嵌套解构

    解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性:

    let person = {
      name: 'Matt',
      age: 27,
      job: {
        title: 'Software engineer'
      }
    };
    let personCopy = {};
     
     
    ({
      name: personCopy.name,
      age: personCopy.age,
      job: personCopy.job
    } = person);
    
    // 因为一个对象的引用被赋值给personCopy,所以修改
    // person.job对象的属性也会影响personCopy
    person.job.title = 'Hacker'
    
    console.log(person);
    // { name: 'Matt', age: 27, job: { title: 'Hacker' } }
    
    console.log(personCopy);
    // { name: 'Matt', age: 27, job: { title: 'Hacker' } }
    
    

    解构赋值可以使用嵌套结构,以匹配嵌套的属性:

    let person = {
      name: 'Matt',
      age: 27,
      job: {
        title: 'Software engineer'
      }
    };
    
    // 声明title变量并将person.job.title的值赋给它
    let { job: { title } } = person;
    
    console.log(title); // Software engineer
    
    

    在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样:

    let person = {
      job: {
        title: 'Software engineer'
      }
    };
    let personCopy = {};
    
    // foo在源对象上是undefined
    ({
      foo: {
        bar: personCopy.bar
      }
    } = person);
    // TypeError: Cannot destructure property 'bar' of 'undefined' or 'null'.
    
    // job在目标对象上是undefined
    ({
      job: {
        title: personCopy.job.title
      }
    } = person);
    // TypeError: Cannot set property 'title' of undefined
    
    

     

  2. 部分解构

    需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分:

    let person = {
      name: 'Matt',
      age: 27
    };
    
    let personName, personBar, personAge;
    
    try {
      // person.foo是undefined,因此会抛出错误
      ({name: personName, foo: { bar: personBar }, age: personAge} = person);
    } catch(e) {}
    
    console.log(personName, personBar, personAge);
    // Matt, undefined, undefined
    
    

     

  3. 参数上下文匹配

    在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响arguments 对象,但可以在函数签名中声明在函数体内使用局部变量:

    let person = {
      name: 'Matt',
      age: 27
    };
    
    function printPerson(foo, {name, age}, bar) {
      console.log(arguments);
      console.log(name, age);
    }
    
    function printPerson2(foo, {name: personName, age: personAge}, bar) {
      console.log(arguments);
      console.log(personName, personAge);
    }
    
    printPerson('1st', person, '2nd');
    // ['1st', { name: 'Matt', age: 27 }, '2nd']
    // 'Matt', 27
    
    printPerson2('1st', person, '2nd');
    // ['1st', { name: 'Matt', age: 27 }, '2nd']
    // 'Matt', 27
    
    

虽然使用Object 构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。

综观ECMAScript规范的历次发布,每个版本的特性似乎都出人意料。ECMAScript 5.1并没有正式支持面向对象的结构,比如类或继承。但是,正如接下来几节会介绍的,巧妙地运用原型式继承可以成功地模拟同样的行为。

ECMAScript 6开始正式支持类和继承。ES6的类旨在完全涵盖之前规范设计的基于原型的继承模式。不过,无论从哪方面看,ES6的类都仅仅是封装了ES5.1构造函数加原型继承的语法糖而已。

注意  不要误会:采用面向对象编程模式的JavaScript代码还是应该使用ECMAScript 6的类。但不管怎么说,理解ES6类出现之前的惯例总是有益无害的。特别是ES6的类定义本身就相当于对原有结构的封装。因此,在介绍ES6的类之前,本书会循序渐进地介绍被类取代的那些底层概念。

工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。(本书后面还会讨论其他设计模式及其在JavaScript中的实现。)下面的例子展示了一种按照特定接口创建对象的方式:

function createPerson(name, age, job) {
  let o = new Object();
  o.name = name;
  o.age = age;
  o.job = job;
  o.sayName = function() {
    console.log(this.name);
  };
  return o;
}

let person1 = createPerson("Nicholas", 29, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");

这里,函数createPerson() 接收3个参数,根据这几个参数构建了一个包含Person 信息的对象。可以用不同的参数多次调用这个函数,每次都会返回包含3个属性和1个方法的对象。这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。

前面几章提到过,ECMAScript中的构造函数是用于创建特定类型对象的。像ObjectArray 这样的原生构造函数,运行时可以直接在执行环境中使用。当然也可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

比如,前面的例子使用构造函数模式可以这样写:

function Person(name, age, job){
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function() {
    console.log(this.name);
  };
}

let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");

person1.sayName();  // Nicholas
person2.sayName();  // Greg

在这个例子中,Person() 构造函数代替了createPerson() 工厂函数。实际上,Person() 内部的代码跟createPerson() 基本是一样的,只是有如下区别。

另外,要注意函数名Person 的首字母大写了。按照惯例,构造函数名称的首字母都是要大写的,非构造函数则以小写字母开头。这是从面向对象编程语言那里借鉴的,有助于在ECMAScript中区分构造函数和普通函数。毕竟ECMAScript的构造函数就是能创建对象的函数。

要创建Person 的实例,应使用new 操作符。以这种方式调用构造函数会执行如下操作。

(1) 在内存中创建一个新对象。

(2) 这个新对象内部的[[Prototype]] 特性被赋值为构造函数的prototype 属性。

(3) 构造函数内部的this 被赋值为这个新对象(即this 指向新对象)。

(4) 执行构造函数内部的代码(给新对象添加属性)。

(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

上一个例子的最后,person1person2 分别保存着Person 的不同实例。这两个对象都有一个constructor 属性指向Person ,如下所示:

console.log(person1.constructor == Person);  // true
console.log(person2.constructor == Person);  // true

constructor 本来是用于标识对象类型的。不过,一般认为instanceof 操作符是确定对象类型更可靠的方式。前面例子中的每个对象都是Object 的实例,同时也是Person 的实例,如下面调用instanceof 操作符的结果所示:

console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true

定义自定义构造函数可以确保实例被标识为特定类型,相比于工厂模式,这是一个很大的好处。在这个例子中,person1person2 之所以也被认为是Object 的实例,是因为所有自定义对象都继承自Object (后面再详细讨论这一点)。

构造函数不一定要写成函数声明的形式。赋值给变量的函数表达式也可以表示构造函数:

let Person = function(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function() {
    console.log(this.name);
  };
}

let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");

person1.sayName();  // Nicholas
person2.sayName();  // Greg

console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true

在实例化时,如果不想传参数,那么构造函数后面的括号可加可不加。只要有new 操作符,就可以调用相应的构造函数:

function Person() {
  this.name = "Jake";
  this.sayName = function() {
    console.log(this.name);
  };
}

let person1 = new Person();
let person2 = new Person;

person1.sayName();  // Jake
person2.sayName();  // Jake

console.log(person1 instanceof Object);  // true
console.log(person1 instanceof Person);  // true
console.log(person2 instanceof Object);  // true
console.log(person2 instanceof Person);  // true

  1. 构造函数也是函数

    构造函数与普通函数唯一的区别就是调用方式不同。除此之外,构造函数也是函数。并没有把某个函数定义为构造函数的特殊语法。任何函数只要使用new 操作符调用就是构造函数,而不使用new 操作符调用的函数就是普通函数。比如,前面的例子中定义的Person() 可以像下面这样调用:

    // 作为构造函数
    let person = new Person("Nicholas", 29, "Software Engineer");
    person.sayName();    // "Nicholas"
    
    // 作为函数调用
    Person("Greg", 27, "Doctor");   // 添加到window对象
    window.sayName();    // "Greg"
    
    // 在另一个对象的作用域中调用
    let o = new Object();
    Person.call(o, "Kristen", 25, "Nurse");
    o.sayName();   // "Kristen"
    
    

    这个例子一开始展示了典型的构造函数调用方式,即使用new 操作符创建一个新对象。然后是普通函数的调用方式,这时候没有使用new 操作符调用Person() ,结果会将属性和方法添加到window 对象。这里要记住,在调用一个函数而没有明确设置this 值的情况下(即没有作为对象的方法调用,或者没有使用call() /apply() 调用),this 始终指向Global 对象(在浏览器中就是window 对象)。因此在上面的调用之后,window 对象上就有了一个sayName() 方法,调用它会返回"Greg" 。最后展示的调用方式是通过call() (或apply() )调用函数,同时将特定对象指定为作用域。这里的调用将对象o 指定为Person() 内部的this 值,因此执行完函数代码后,所有属性和sayName() 方法都会添加到对象o 上面。
     

  2. 构造函数的问题

    构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言,person1person2 都有名为sayName() 的方法,但这两个方法不是同一个Function 实例。我们知道,ECMAScript中的函数是对象,因此每次定义函数时,都会初始化一个对象。逻辑上讲,这个构造函数实际上是这样的:

    function Person(name, age, job){
      this.name = name;
      this.age = age;
      this.job = job;
      this.sayName = new Function("console.log(this.name)"); // 逻辑等价
    }
    
    

    这样理解这个构造函数可以更清楚地知道,每个Person 实例都会有自己的Function 实例用于显示name 属性。当然了,以这种方式创建函数会带来不同的作用域链和标识符解析。但创建新Function 实例的机制是一样的。因此不同实例上的函数虽然同名却不相等,如下所示:

    console.log(person1.sayName == person2.sayName); // false
    
    

    因为都是做一样的事,所以没必要定义两个不同的Function 实例。况且,this 对象可以把函数与对象的绑定推迟到运行时。

    要解决这个问题,可以把函数定义转移到构造函数外部:

    function Person(name, age, job){
      this.name = name;
      this.age = age;
      this.job = job;
      this.sayName = sayName;
    }
    
    function sayName() {
      console.log(this.name);
    }
    
    let person1 = new Person("Nicholas", 29, "Software Engineer");
    let person2 = new Person("Greg", 27, "Doctor");
    
    person1.sayName();  // Nicholas
    person2.sayName();  // Greg
    
    

    在这里,sayName() 被定义在了构造函数外部。在构造函数内部,sayName 属性等于全局sayName() 函数。因为这一次sayName 属性中包含的只是一个指向外部函数的指针,所以person1person2 共享了定义在全局作用域上的sayName() 函数。这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决。

每个函数都会创建一个prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以直接赋值给它们的原型,如下所示:

function Person() {}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
  console.log(this.name);
};

let person1 = new Person();
person1.sayName(); // "Nicholas"

let person2 = new Person();
person2.sayName(); // "Nicholas"

console.log(person1.sayName == person2.sayName); // true

使用函数表达式也可以:

let Person = function() {};

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
  console.log(this.name);
};

let person1 = new Person();
person1.sayName();   // "Nicholas"

let person2 = new Person();
person2.sayName();   // "Nicholas"

console.log(person1.sayName == person2.sayName); // true

这里,所有属性和sayName() 方法都直接添加到了Personprototype 属性上,构造函数体中什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此person1person2 访问的都是相同的属性和相同的sayName() 函数。要理解这个过程,就必须理解ECMAScript中原型的本质。

  1. 理解原型

    无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为constructor 的属性,指回与之关联的构造函数。对前面的例子而言,Person.prototype.constructor 指向Person 。然后,因构造函数而异,可能会给原型对象添加其他属性和方法。

    在自定义构造函数时,原型对象默认只会获得constructor 属性,其他的所有方法都继承自Object 。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]] 指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]] 特性的标准方式,但Firefox、Safari和Chrome会在每个对象上暴露__proto__ 属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性完全被隐藏了。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。

    这种关系不好可视化,但可以通过下面的代码来理解原型的行为:

    /**
     * 构造函数可以是函数表达式
     * 也可以是函数声明,因此以下两种形式都可以:
     *   function Person() {}
     *   let Person = function() {}
     */
    function Person() {}
    
    /**
     * 声明之后,构造函数就有了一个
     * 与之关联的原型对象:
     */
    console.log(typeof Person.prototype);
    console.log(Person.prototype);
    // {
    //   constructor: f Person(),
    //   __proto__: Object
    // }
    
    /**
     * 如前所述,构造函数有一个prototype属性
     * 引用其原型对象,而这个原型对象也有一个
     * constructor属性,引用这个构造函数
     * 换句话说,两者循环引用:
     */
    console.log(Person.prototype.constructor === Person); // true
    
    /**
     * 正常的原型链都会终止于Object的原型对象
     * Object原型的原型是null
     */
    console.log(Person.prototype.__proto__ === Object.prototype);   // true
    console.log(Person.prototype.__proto__.constructor === Object); // true
    console.log(Person.prototype.__proto__.__proto__ === null);     // true
    
    console.log(Person.prototype.__proto__);
    // {
    //   constructor: f Object(),
    //   toString: ...
    //   hasOwnProperty: ...
    //   isPrototypeOf: ...
    //   ...
    // }
     
     
    let person1 = new Person(),
        person2 = new Person();
    
    /**
     * 构造函数、原型对象和实例
     * 是3个完全不同的对象:
     */
    console.log(person1 !== Person);           // true
    console.log(person1 !== Person.prototype); // true
    console.log(Person.prototype !== Person);  // true
    
    /**
      * 实例通过__proto__链接到原型对象,
      * 它实际上指向隐藏特性[[Prototype]]
      *
      * 构造函数通过prototype属性链接到原型对象
      *
      * 实例与构造函数没有直接联系,与原型对象有直接联系
      */
    console.log(person1.__proto__ === Person.prototype);   // true
    conosle.log(person1.__proto__.constructor === Person); // true
    
    /**
     * 同一个构造函数创建的两个实例
     * 共享同一个原型对象:
     */
    console.log(person1.__proto__ === person2.__proto__); // true
    
    /**
     * instanceof检查实例的原型链中
     * 是否包含指定构造函数的原型:
     */
    console.log(person1 instanceof Person);           // true
    console.log(person1 instanceof Object);           // true
    console.log(Person.prototype instanceof Object);  // true
    
    

    对于前面例子中的Person 构造函数和Person.prototype ,可以通过图8-1看出各个对象之间的关系。

    图 8-1

    图8-1展示了Person 构造函数、Person 的原型对象和Person 现有两个实例之间的关系。注意,Person.prototype 指向原型对象,而Person.prototype.contructor 指回Person 构造函数。原型对象包含constructor 属性和其他后来添加的属性。Person 的两个实例person1person2 都只有一个内部属性指回Person.prototype ,而且两者都与构造函数没有直接联系。另外要注意,虽然这两个实例都没有属性和方法,但person1.sayName() 可以正常调用。这是由于对象属性查找机制的原因。

    虽然不是所有实现都对外暴露了[[Prototype]] ,但可以使用isPrototypeOf() 方法确定两个对象之间的这种关系。本质上,isPrototypeOf() 会在传入参数的[[Prototype]] 指向调用它的对象时返回true ,如下所示:

    console.log(Person.prototype.isPrototypeOf(person1));  // true
    console.log(Person.prototype.isPrototypeOf(person2));  // true
    
    

    这里通过原型对象调用isPrototypeOf() 方法检查了person1person2 。因为这两个例子内部都有链接指向Person.prototype ,所以结果都返回true

    ECMAScript的Object 类型有一个方法叫Object.getPrototypeOf() ,返回参数的内部特性[[Prototype]] 的值。例如:

    console.log(Object.getPrototypeOf(person1) == Person.prototype);  // true
    console.log(Object.getPrototypeOf(person1).name);                 // "Nicholas"
    
    

    第一行代码简单确认了Object.getPrototypeOf() 返回的对象就是传入对象的原型对象。第二行代码则取得了原型对象上name 属性的值,即"Nicholas" 。使用Object.getPrototypeOf() 可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要(本章后面会介绍)。

    Object 类型还有一个setPrototypeOf() 方法,可以向实例的私有特性[[Prototype]] 写入一个新值。这样就可以重写一个对象的原型继承关系:

    let biped = {
      numLegs: 2
    };
    let person = {
      name: 'Matt'
    };
    
    Object.setPrototypeOf(person, biped);
    
    console.log(person.name);                              // Matt
    console.log(person.numLegs);                           // 2
    console.log(Object.getPrototypeOf(person) === biped);  // true
    
    

    警告  Object.setPrototypeOf() 可能会严重影响代码性能。Mozilla文档说得很清楚:“在所有浏览器和JavaScript引擎中,修改继承关系的影响都是微妙且深远的。这种影响并不仅是执行Object.setPrototypeOf() 语句那么简单,而是会涉及所有访问了那些修改过[[Prototype]] 的对象的代码。”

    为避免使用Object.setPrototypeOf() 可能造成的性能下降,可以通过Object.create() 来创建一个新对象,同时为其指定原型:

    let biped = {
      numLegs: 2
    };
    let person = Object.create(biped);
    person.name = 'Matt';
    
    console.log(person.name);                              // Matt
    console.log(person.numLegs);                           // 2
    console.log(Object.getPrototypeOf(person) === biped);  // true
    
    

     

  2. 原型层级

    在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。因此,在调用person1.sayName() 时,会发生两步搜索。首先,JavaScript引擎会问:“person1 实例有sayName 属性吗?”答案是没有。然后,继续搜索并问:“person1 的原型有sayName 属性吗?”答案是有。于是就返回了保存在原型上的这个函数。在调用person2.sayName() 时,会发生同样的搜索过程,而且也会返回相同的结果。这就是原型用于在多个对象实例间共享属性和方法的原理。

    注意  前面提到的constructor 属性只存在于原型对象,因此通过实例对象也是可以访问到的。

    虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性。下面看一个例子:

    function Person() {}
    
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    };
    
    let person1 = new Person();
    let person2 = new Person();
    
    person1.name = "Greg";
    console.log(person1.name);  // "Greg",来自实例
    console.log(person2.name);  // "Nicholas",来自原型
    
    

    在这个例子中,person1name 属性遮蔽了原型对象上的同名属性。虽然person1.nameperson2.name 都返回了值,但前者返回的是"Greg" (来自实例),后者返回的是"Nicholas" (来自原型)。当console.log() 访问person1.name 时,会先在实例上搜索个属性。因为这个属性在实例上存在,所以就不会再搜索原型对象了。而在访问person2.name 时,并没有在实例上找到这个属性,所以会继续搜索原型对象并使用定义在原型上的属性。

    只要给对象实例添加一个属性,这个属性就会遮蔽 (shadow)原型对象上的同名属性,也就是虽然不会修改它,但会屏蔽对它的访问。即使在实例上把这个属性设置为null ,也不会恢复它和原型的联系。不过,使用delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索原型对象。

    function Person() {}
    
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    };
    
    let person1 = new Person();
    let person2 = new Person();
    
    person1.name = "Greg";
    console.log(person1.name);  // "Greg",来自实例
    console.log(person2.name);  // "Nicholas",来自原型
    
    delete person1.name;
    console.log(person1.name);  // "Nicholas",来自原型
    
    

    这个修改后的例子中使用delete 删除了person1.name ,这个属性之前以"Greg" 遮蔽了原型上的同名属性。然后原型上name 属性的联系就恢复了,因此再访问person1.name 时,就会返回原型对象上这个属性的值。

    hasOwnProperty() 方法用于确定某个属性是在实例上还是在原型对象上。这个方法是继承自Object 的,会在属性存在于调用它的对象实例上时返回true ,如下面的例子所示:

    function Person() {}
    
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    };
    
    let person1 = new Person();
    let person2 = new Person();
    console.log(person1.hasOwnProperty("name")); // false
    
    person1.name = "Greg";
    console.log(person1.name); // "Greg",来自实例
    console.log(person1.hasOwnProperty("name")); // true
    
    console.log(person2.name); // "Nicholas",来自原型
    console.log(person2.hasOwnProperty("name")); // false
    
    delete person1.name;
    console.log(person1.name); // "Nicholas",来自原型
    console.log(person1.hasOwnProperty("name")); // false
    
    

    在这个例子中,通过调用hasOwnProperty() 能够清楚地看到访问的是实例属性还是原型属性。调用person1.hasOwnProperty("name") 只在重写person1name 属性的情况下才返回true ,表明此时name 是一个实例属性,不是原型属性。图8-2形象地展示了上面例子中各个步骤的状态。(为简单起见,图中省略了Person 构造函数。)

    图 8-2

    注意  ECMAScript的Object.getOwnPropertyDescriptor() 方法只对实例属性有效。要取得原型属性的描述符,就必须直接在原型对象上调用Object.getOwnPropertyDescriptor()

     

  3. 原型和in 操作符

    有两种方式使用in 操作符:单独使用和在for-in 循环中使用。在单独使用时,in 操作符会在可以通过对象访问指定属性时返回true ,无论该属性是在实例上还是在原型上。来看下面的例子:

    function Person() {}
    
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    };
    
    let person1 = new Person();
    let person2 = new Person();
    
    console.log(person1.hasOwnProperty("name")); // false
    console.log("name" in person1); // true
    
    person1.name = "Greg";
    console.log(person1.name); // "Greg",来自实例
    console.log(person1.hasOwnProperty("name")); // true
    console.log("name" in person1); // true
    
    console.log(person2.name); // "Nicholas",来自原型
    console.log(person2.hasOwnProperty("name")); // false
    console.log("name" in person2); // true
    
    delete person1.name;
    console.log(person1.name); // "Nicholas",来自原型
    console.log(person1.hasOwnProperty("name")); // false
    console.log("name" in person1); // true
    
    

    在上面整个例子中,name 随时可以通过实例或通过原型访问到。因此,调用"name" in persoon1 时始终返回true ,无论这个属性是否在实例上。如果要确定某个属性是否存在于原型上,则可以像下面这样同时使用hasOwnProperty()in 操作符:

    function hasPrototypeProperty(object, name){
      return !object.hasOwnProperty(name) && (name in object);
    }
    
    

    只要通过对象可以访问,in 操作符就返回true ,而hasOwnProperty() 只有属性存在于实例上时才返回true 。因此,只要in 操作符返回truehasOwnProperty() 返回false ,就说明该属性是一个原型属性。来看下面的例子:

    function Person() {}
    
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    };
    
    let person = new Person();
    console.log(hasPrototypeProperty(person, "name")); // true
    
    person.name = "Greg";
    console.log(hasPrototypeProperty(person, "name")); // false
    
    

    在这里,name 属性首先只存在于原型上,所以hasPrototypeProperty() 返回true 。而在实例上重写这个属性后,实例上也有了这个属性,因此hasPrototypeProperty() 返回false 。即便此时原型对象还有name 属性,但因为实例上的属性遮蔽了它,所以不会用到。

    for-in 循环中使用in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。遮蔽原型中不可枚举([[Enumerable]] 特性被设置为false )属性的实例属性也会在for-in 循环中返回,因为默认情况下开发者定义的属性都是可枚举的。

    要获得对象上所有可枚举的实例属性,可以使用Object.keys() 方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。比如:

    function Person() {}
    
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    };
    
    let keys = Object.keys(Person.prototype);
    console.log(keys);   // "name,age,job,sayName"
    let p1 = new Person();
    p1.name = "Rob";
    p1.age = 31;
    let p1keys = Object.keys(p1);
    console.log(p1keys); // "[name,age]"
    
    

    这里,keys 变量保存的数组中包含"name""age""job""sayName" 。这是正常情况下通过for-in 返回的顺序。而在Person 的实例上调用时,Object.keys() 返回的数组中只包含"name""age" 两个属性。

    如果想列出所有实例属性,无论是否可以枚举,都可以使用Object.getOwnPropertyNames()

    let keys = Object.getOwnPropertyNames(Person.prototype);
    console.log(keys);   // "[constructor,name,age,job,sayName]"
    
    

    注意,返回的结果中包含了一个不可枚举的属性constructorObject.keys()Object.getOwnPropertyNames() 在适当的时候都可用来代替for-in 循环。

    在ECMAScript 6新增符号类型之后,相应地出现了增加一个Object.getOwnPropertyNames() 的兄弟方法的需求,因为以符号为键的属性没有名称的概念。因此,Object.getOwnPropertySymbols() 方法就出现了,这个方法与Object.getOwnPropertyNames() 类似,只是针对符号而已:

    let k1 = Symbol('k1'),
        k2 = Symbol('k2');
    
    let o = {
      [k1]: 'k1',
      [k2]: 'k2'
    };
    
    console.log(Object.getOwnPropertySymbols(o));
    // [Symbol(k1), Symbol(k2)]
    
    

     

  4. 属性枚举顺序

    for-in 循环、Object.keys()Object.getOwnPropertyNames()Object.getOwnPropertySymbols() 以及Object.assign() 在属性枚举顺序方面有很大区别。for-in 循环和Object.keys() 的枚举顺序是不确定的,取决于JavaScript引擎,可能因浏览器而异。

    Object.getOwnPropertyNames()Object.getOwnPropertySymbols()Object.assign() 的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。

    let k1 = Symbol('k1'),
        k2 = Symbol('k2');
    
    let o = {
      1: 1,
      first: 'first',
      [k1]: 'sym2',
      second: 'second',
      0: 0
    };
    
    o[k2] = 'sym2';
    o[3] = 3;
    o.third = 'third';
    o[2] = 2;
    
    console.log(Object.getOwnPropertyNames(o));
    // ["0", "1", "2", "3", "first", "second", "third"]
    
    console.log(Object.getOwnPropertySymbols(o));
    // [Symbol(k1), Symbol(k2)]
    
    

在JavaScript有史以来的大部分时间内,迭代对象属性都是一个难题。ECMAScript 2017新增了两个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法Object.values()Object.entries() 接收一个对象,返回它们内容的数组。Object.values() 返回对象值的数组,Object.entries() 返回键/值对的数组。

下面的示例展示了这两个方法:

const o = {
  foo: 'bar',
  baz: 1,
  qux: {}
};

console.log(Object.values(o));
// ["bar", 1, {}]

console.log(Object.entries((o)));
// [["foo", "bar"], ["baz", 1], ["qux", {}]]

注意,非字符串属性会被转换为字符串输出。另外,这两个方法执行对象的浅复制:

const o = {
  qux: {}
};

console.log(Object.values(o)[0] === o.qux);
// true

console.log(Object.entries(o)[0][1] === o.qux);
// true

符号属性会被忽略:

const sym = Symbol();
const o = {
  [sym]: 'foo'
};

console.log(Object.values(o));
// []

console.log(Object.entries((o)));
// []

  1. 其他原型语法

    有读者可能注意到了,在前面的例子中,每次定义一个属性或方法都会把Person.prototype 重写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法的对象字面量来重写原型成为了一种常见的做法,如下面的例子所示:

    function Person() {}
    
    Person.prototype = {
      name: "Nicholas",
      age: 29,
      job: "Software Engineer",
      sayName() {
        console.log(this.name);
      }
    };
    
    

    在这个例子中,Person.prototype 被设置为等于一个通过对象字面量创建的新对象。最终结果是一样的,只有一个问题:这样重写之后,Person.prototypeconstructor 属性就不指向Person 了。在创建函数时,也会创建它的prototype 对象,同时会自动给这个原型的constructor 属性赋值。而上面的写法完全重写了默认的prototype 对象,因此其constructor 属性也指向了完全不同的新对象(Object 构造函数),不再指向原来的构造函数。虽然instanceof 操作符还能可靠地返回值,但我们不能再依靠constructor 属性来识别类型了,如下面的例子所示:

    let friend = new Person();
    
    console.log(friend instanceof Object);      // true
    console.log(friend instanceof Person);      // true
    console.log(friend.constructor == Person);  // false
    console.log(friend.constructor == Object);  // true
    
    

    这里,instanceof 仍然对ObjectPerson 都返回true 。但constructor 属性现在等于Object 而不是Person 了。如果constructor 的值很重要,则可以像下面这样在重写原型对象时专门设置一下它的值:

    function Person() {
    }
    
    Person.prototype = {
      constructor: Person,
      name: "Nicholas",
      age: 29,
      job: "Software Engineer",
      sayName() {
        console.log(this.name);
      }
    };
    
    

    这次的代码中特意包含了constructor 属性,并将它设置为Person ,保证了这个属性仍然包含恰当的值。

    但要注意,以这种方式恢复constructor 属性会创建一个[[Enumerable]]true 的属性。而原生constructor 属性默认是不可枚举的。因此,如果你使用的是兼容ECMAScript的JavaScript引擎,那可能会改为使用Object.defineProperty() 方法来定义constructor 属性:

    function Person() {}
    
    Person.prototype = {
      name: "Nicholas",
      age: 29,
      job: "Software Engineer",
      sayName() {
        console.log(this.name);
      }
    };
    
    // 恢复constructor属性
    Object.defineProperty(Person.prototype, "constructor", {
      enumerable: false,
      value: Person
    });
    
    

     

  2. 原型的动态性

    因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对象所做的修改也会在实例上反映出来。下面是一个例子:

    let friend = new Person();
    
    Person.prototype.sayHi = function() {
      console.log("hi");
    };
    
    friend.sayHi();   // "hi",没问题!
    
    

    以上代码先创建一个Person 实例并保存在friend 中。然后一条语句在Person.prototype 上添加了一个名为sayHi() 的方法。虽然friend 实例是在添加方法之前创建的,但它仍然可以访问这个方法。之所以会这样,主要原因是实例与原型之间松散的联系。在调用friend.sayHi() 时,首先会从这个实例中搜索名为sayHi 的属性。在没有找到的情况下,运行时会继续搜索原型对象。因为实例和原型之间的链接就是简单的指针,而不是保存的副本,所以会在原型上找到sayHi 属性并返回这个属性保存的函数。

    虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。实例的[[Prototype]] 指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。记住,实例只有指向原型的指针,没有指向构造函数的指针。来看下面的例子:

    function Person() {}
    
    let friend = new Person();
    Person.prototype = {
      constructor: Person,
      name: "Nicholas",
      age: 29,
      job: "Software Engineer",
      sayName() {
        console.log(this.name);
      }
    };
    
    friend.sayName();  // 错误
    
    

    在这个例子中,Person 的新实例是在重写原型对象之前创建的。在调用friend.sayName() 的时候,会导致错误。这是因为firend 指向的原型还是最初的原型,而这个原型上并没有sayName 属性。图8-3展示了这里面的原因。

    图 8-3

    重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。
     

  3. 原生对象原型

    原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(包括ObjectArrayString 等)都在原型上定义了实例方法。比如,数组实例的sort() 方法就是Array.prototype 上定义的,而字符串包装对象的substring() 方法也是在String.prototype 上定义的,如下所示:

    console.log(typeof Array.prototype.sort);       // "function"
    console.log(typeof String.prototype.substring); // "function"
    
    

    通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法。比如,下面的代码就给String 原始值包装类型的实例添加了一个startsWith() 方法:

    String.prototype.startsWith = function (text) {
      return this.indexOf(text) === 0;
    };
    
    let msg = "Hello world!";
    console.log(msg.startsWith("Hello"));  // true
    
    

    如果给定字符串的开头出现了调用startsWith() 方法的文本,那么该方法会返回true 。因为这个方法是被定义在String.prototype 上,所以当前环境下所有的字符串都可以使用这个方法。msg 是个字符串,在读取它的属性时,后台会自动创建String 的包装实例,从而找到并调用startsWith() 方法。

    注意  尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。这样做很可能造成误会,而且可能引发命名冲突(比如一个名称在某个浏览器实现中不存在,在另一个实现中却存在)。另外还有可能意外重写原生的方法。推荐的做法是创建一个自定义的类,继承原生类型。

     

  4. 原型的问题

    原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。

    我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,如前面例子中所示,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性。来看下面的例子:

    function Person() {}
    
    Person.prototype = {
      constructor: Person,
      name: "Nicholas",
      age: 29,
      job: "Software Engineer",
      friends: ["Shelby", "Court"],
      sayName() {
        console.log(this.name);
      }
    };
    
    let person1 = new Person();
    let person2 = new Person();
    
    person1.friends.push("Van");
    
    console.log(person1.friends);  // "Shelby,Court,Van"
    console.log(person2.friends);  // "Shelby,Court,Van"
    console.log(person1.friends === person2.friends);  // true
    
    

    这里,Person.prototype 有一个名为friends 的属性,它包含一个字符串数组。然后这里创建了两个Person 的实例。person1.friends 通过push 方法向数组中添加了一个字符串。由于这个friends 属性存在于Person.prototype 而非person1 上,新加的这个字符串也会在(指向同一个数组的)person2.friends 上反映出来。如果这是有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。

继承是面向对象编程中讨论最多的话题。很多面向对象语言都支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在ECMAScript中是不可能的,因为函数没有签名。实现继承是ECMAScript唯一支持的继承方式,而这主要是通过原型链实现的。

ECMA-262把原型链 定义为ECMAScript的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。重温一下构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。这就是原型链的基本构想。

实现原型链涉及如下代码模式:

function SuperType() {
  this.property = true;
}

SuperType.prototype.getSuperValue = function() {
  return this.property;
};

function SubType() {
  this.subproperty = false;
}

// 继承SuperType
SubType.prototype = new SuperType();

SubType.prototype.getSubValue = function () {
  return this.subproperty;
};

let instance = new SubType();
console.log(instance.getSuperValue()); // true

以上代码定义了两个类型:SuperTypeSubType 。这两个类型分别定义了一个属性和一个方法。这两个类型的主要区别是SubType 通过创建SuperType 的实例并将其赋值给自己的原型SubTtype.prototype 实现了对SuperType 的继承。这个赋值重写了SubType 最初的原型,将其替换为SuperType 的实例。这意味着SuperType 实例可以访问的所有属性和方法也会存在于SubType.prototype 。这样实现继承之后,代码紧接着又给SubType.prototype ,也就是这个SuperType 的实例添加了一个新方法。最后又创建了SubType 的实例并调用了它继承的getSuperValue() 方法。图8-4展示了子类的实例与两个构造函数及其对应的原型之间的关系。

图 8-4

这个例子中实现继承的关键,是SubType 没有使用默认原型,而是将其替换成了一个新的对象。这个新的对象恰好是SuperType 的实例。这样一来,SubType 的实例不仅能从SuperType 的实例中继承属性和方法,而且还与SuperType 的原型挂上了钩。于是instance (通过内部的[[Prototype]] )指向SubType.prototype ,而SubType.prototype (作为SuperType 的实例又通过内部的[[Prototype]] )指向SuperType.prototype 。注意,getSuperValue() 方法还在SuperType.prototype 对象上,而property 属性则在SubType.prototype 上。这是因为getSuperValue() 是一个原型方法,而property 是一个实例属性。SubType.prototype 现在是SuperType 的一个实例,因此property 才会存储在它上面。还要注意,由于SubType.prototypeconstructor 属性被重写为指向SuperType ,所以instance.constructor 也指向SuperType

原型链扩展了前面描述的原型搜索机制。我们知道,在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型。对前面的例子而言,调用instance.getSuperValue() 经过了3步搜索:instanceSubType.prototypeSuperType.prototype ,最后一步才找到这个方法。对属性和方法的搜索会一直持续到原型链的末端。

  1. 默认原型

    实际上,原型链中还有一环。默认情况下,所有引用类型都继承自Object ,这也是通过原型链实现的。任何函数的默认原型都是一个Object 的实例,这意味着这个实例有一个内部指针指向Object.prototype 。这也是为什么自定义类型能够继承包括toString()valueOf() 在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系。图8-5展示了完整的原型链。

    图 8-5

    SubType 继承SuperType ,而SuperType 继承Object 。在调用instance.toString() 时,实际上调用的是保存在Object.prototype 上的方法。
     

  2. 原型与继承关系

    原型与实例的关系可以通过两种方式来确定。第一种方式是使用instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则instanceof 返回true 。如下例所示:

    console.log(instance instanceof Object);     // true
    console.log(instance instanceof SuperType);  // true
    console.log(instance instanceof SubType);    // true
    
    

    从技术上讲,instanceObjectSuperTypeSubType 的实例,因为instance 的原型链中包含这些构造函数的原型。结果就是instanceof 对所有这些构造函数都返回true

    确定这种关系的第二种方式是使用isPrototypeOf() 方法。原型链中的每个原型都可以调用这个方法,如下例所示,只要原型链中包含这个原型,这个方法就返回true

    console.log(Object.prototype.isPrototypeOf(instance));     // true
    console.log(SuperType.prototype.isPrototypeOf(instance));  // true
    console.log(SubType.prototype.isPrototypeOf(instance));    // true
    
    

     

  3. 关于方法

    子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。来看下面的例子:

    function SuperType() {
      this.property = true;
    }
    
    SuperType.prototype.getSuperValue = function() {
      return this.property;
    };
    
    function SubType() {
      this.subproperty = false;
    }
    
    // 继承SuperType
    SubType.prototype = new SuperType();
    
    // 新方法
    SubType.prototype.getSubValue = function () {
      return this.subproperty;
    };
    
    // 覆盖已有的方法
    SubType.prototype.getSuperValue = function () {
      return false;
    };
    
    let instance = new SubType();
    console.log(instance.getSuperValue()); // false
    
    

    在上面的代码中,加粗的部分涉及两个方法。第一个方法getSubValue()SubType 的新方法,而第二个方法getSuperValue() 是原型链上已经存在但在这里被遮蔽的方法。后面在SubType 实例上调用getSuperValue() 时调用的是这个方法。而SuperType 的实例仍然会调用最初的方法。重点在于上述两个方法都是在把原型赋值为SuperType 的实例之后定义的。

    另一个要理解的重点是,以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写了原型链。下面是一个例子:

    function SuperType() {
      this.property = true;
    }
    
    SuperType.prototype.getSuperValue = function() {
      return this.property;
    };
    
    function SubType() {
      this.subproperty = false;
    }
    
    // 继承SuperType
    SubType.prototype = new SuperType();
    
    // 通过对象字面量添加新方法,这会导致上一行无效
    SubType.prototype = {
      getSubValue() {
        return this.subproperty;
      },
    
      someOtherMethod() {
        return false;
      }
    };
    
    let instance = new SubType();
    console.log(instance.getSuperValue()); // 出错!
    
    

    在这段代码中,子类的原型在被赋值为SuperType 的实例后,又被一个对象字面量覆盖了。覆盖后的原型是一个Object 的实例,而不再是SuperType 的实例。因此之前的原型链就断了。SubTypeSuperType 之间也没有关系了。
     

  4. 原型链的问题

    原型链虽然是实现继承的强大工具,但它也有问题。主要问题出现在原型中包含引用值的时候。前面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。下面的例子揭示了这个问题:

    function SuperType() {
      this.colors = ["red", "blue", "green"];
    }
    
    function SubType() {}
    
    // 继承SuperType
    SubType.prototype = new SuperType();
    
    let instance1 = new SubType();
    instance1.colors.push("black");
    console.log(instance1.colors); // "red,blue,green,black"
    
    let instance2 = new SubType();
    console.log(instance2.colors); // "red,blue,green,black"
    
    

    在这个例子中,SuperType 构造函数定义了一个colors 属性,其中包含一个数组(引用值)。每个SuperType 的实例都会有自己的colors 属性,包含自己的数组。但是,当SubType 通过原型继承SuperType 后,SubType.prototype 变成了SuperType 的一个实例,因而也获得了自己的colors 属性。这类似于创建了SubType.prototype.colors 属性。最终结果是,SubType 的所有实例都会共享这个colors 属性。这一点通过instance1.colors 上的修改也能反映到instance2.colors 上就可以看出来。

    原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。

为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)。基本思路很简单:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()call() 方法以新创建的对象为上下文执行构造函数。来看下面的例子:

function SuperType() {
  this.colors = ["red", "blue", "green"];
}

function SubType() {
  // 继承SuperType
  SuperType.call(this);
}

let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"

let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green"

示例中加粗的代码展示了盗用构造函数的调用。通过使用call() (或apply() )方法,SuperType 构造函数在为SubType 的实例创建的新对象的上下文中执行了。这相当于新的SubType 对象上运行了SuperType() 函数中的所有初始化代码。结果就是每个实例都会有自己的colors 属性。

  1. 传递参数

    相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参。来看下面的例子:

    function SuperType(name){
      this.name = name;
    }
    
    function SubType() {
      // 继承SuperType并传参
      SuperType.call(this, "Nicholas");
    
      // 实例属性
      this.age = 29;
    }
    
    let instance = new SubType();
    console.log(instance.name); // "Nicholas";
    console.log(instance.age);  // 29
    
    

    在这个例子中,SuperType 构造函数接收一个参数name ,然后将它赋值给一个属性。在SubType 构造函数中调用SuperType 构造函数时传入这个参数,实际上会在SubType 的实例上定义name 属性。为确保SuperType 构造函数不会覆盖SubType 定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。
     

  2. 盗用构造函数的问题

    盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用。

组合继承 (有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。来看下面的例子:

function SuperType(name){
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
  console.log(this.name);
};

function SubType(name, age){
  // 继承属性
  SuperType.call(this, name);

  this.age = age;
}

// 继承方法
SubType.prototype = new SuperType();

SubType.prototype.sayAge = function() {
  console.log(this.age);
};

let instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors);  // "red,blue,green,black"
instance1.sayName();            // "Nicholas";
instance1.sayAge();             // 29

let instance2 = new SubType("Greg", 27);
console.log(instance2.colors);  // "red,blue,green"
instance2.sayName();            // "Greg";
instance2.sayAge();             // 27

在这个例子中,SuperType 构造函数定义了两个属性,namecolors ,而它的原型上也定义了一个方法叫sayName()SubType 构造函数调用了SuperType 构造函数,传入了name 参数,然后又定义了自己的属性age 。此外,SubType.prototype 也被赋值为SuperType 的实例。原型赋值之后,又在这个原型上添加了新方法sayAge() 。这样,就可以创建两个SubType 实例,让这两个实例都有自己的属性,包括colors ,同时还共享相同的方法。

组合继承弥补了原型链和盗用构造函数的不足,是JavaScript中使用最多的继承模式。而且组合继承也保留了instanceof 操作符和isPrototypeOf() 方法识别合成对象的能力。

2006年,Douglas Crockford写了一篇文章:《JavaScript中的原型式继承》(“Prototypal Inheritance in JavaScript”)。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。文章最终给出了一个函数:

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

这个object() 函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质上,object() 是对传入的对象执行了一次浅复制。来看下面的例子:

let person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

let anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

let yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends);  // "Shelby,Court,Van,Rob,Barbie"

Crockford推荐的原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给object() ,然后再对返回的对象进行适当修改。在这个例子中,person 对象定义了另一个对象也应该共享的信息,把它传给object() 之后会返回一个新对象。这个新对象的原型是person ,意味着它的原型上既有原始值属性又有引用值属性。这也意味着person.friends 不仅是person 的属性,也会跟anotherPersonyetAnotherPerson 共享。这里实际上克隆了两个person

ECMAScript 5通过增加Object.create() 方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create() 与这里的object() 方法效果相同:

let person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

let anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");

let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");

console.log(person.friends);  // "Shelby,Court,Van,Rob,Barbie"

Object.create() 的第二个参数与Object.defineProperties() 的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。比如:

let person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

let anotherPerson = Object.create(person, {
  name: {
    value: "Greg"
  }
});
console.log(anotherPerson.name);  // "Greg"

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

与原型式继承比较接近的一种继承方式是寄生式继承 (parasitic inheritance),也是Crockford首倡的一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本的寄生继承模式如下:

function createAnother(original){
  let clone = object(original);  // 通过调用函数创建一个新对象
  clone.sayHi = function() {     // 以某种方式增强这个对象
    console.log("hi");
  };
  return clone;           // 返回这个对象
}

在这段代码中,createAnother() 函数接收一个参数,就是新对象的基准对象。这个对象original 会被传给object() 函数,然后将返回的新对象赋值给clone 。接着给clone 对象添加一个新方法sayHi() 。最后返回这个对象。可以像下面这样使用createAnother() 函数:

let person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
};

let anotherPerson = createAnother(person);
anotherPerson.sayHi();  // "hi"

这个例子基于person 对象返回了一个新对象。新返回的anotherPerson 对象具有person 的所有属性和方法,还有一个新方法叫sayHi()

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object() 函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。

注意  通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。再来看一看这个组合继承的例子:

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
  console.log(this.name);
};

function SubType(name, age){
  SuperType.call(this, name);   // 第二次调用SuperType()

  this.age = age;
}

SubType.prototype = new SuperType();   // 第一次调用SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
  console.log(this.age);
};

代码中加粗的部分是调用SuperType 构造函数的地方。在上面的代码执行后,SubType.prototype 上会有两个属性:namecolors 。它们都是SuperType 的实例属性,但现在成为了SubType 的原型属性。在调用SubType 构造函数时,也会调用SuperType 构造函数,这一次会在新对象上创建实例属性namecolors 。这两个实例属性会遮蔽原型上同名的属性。图8-6展示了这个过程。

图 8-6

如图8-6所示,有两组namecolors 属性:一组在实例上,另一组在SubType 的原型上。这是调用两次SuperType 构造函数的结果。好在有办法解决这个问题。

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。寄生式组合继承的基本模式如下所示:

function inheritPrototype(subType, superType) {
  let prototype = object(superType.prototype);  // 创建对象
  prototype.constructor = subType;              // 增强对象
  subType.prototype = prototype;                // 赋值对象
}

这个inheritPrototype() 函数实现了寄生式组合继承的核心逻辑。这个函数接收两个参数:子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的prototype 对象设置constructor 属性,解决由于重写原型导致默认constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。如下例所示,调用inheritPrototype() 就可以实现前面例子中的子类型原型赋值:

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function() {
  console.log(this.name);
};

function SubType(name, age) {
  SuperType.call(this, name);

  this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
  console.log(this.age);
};

这里只调用了一次SuperType 构造函数,避免了SubType.prototype 上不必要也用不到的属性,因此可以说这个例子的效率更高。而且,原型链仍然保持不变,因此instanceof 操作符和isPrototypeOf() 方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。

前几节深入讲解了如何只使用ECMAScript 5的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。

为解决这些问题,ECMAScript 6新引入的class 关键字具有正式定义类的能力。类(class)是ECMAScript中新的基础性语法糖结构,因此刚开始接触时可能会不太习惯。虽然ECMAScript 6类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用class 关键字加大括号:

// 类声明
class Person {}

// 类表达式
const Animal = class {};

与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能:

console.log(FunctionExpression);   // undefined
var FunctionExpression = function() {};
console.log(FunctionExpression);   // function() {}

console.log(FunctionDeclaration);  // FunctionDeclaration() {}
function FunctionDeclaration() {}
console.log(FunctionDeclaration);  // FunctionDeclaration() {}

console.log(ClassExpression);      // undefined
var ClassExpression = class {};
console.log(ClassExpression);      // class {}

console.log(ClassDeclaration);     // ReferenceError: ClassDeclaration is not defined
class ClassDeclaration {}
console.log(ClassDeclaration);     // class ClassDeclaration {}

另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制:

{
  function FunctionDeclaration() {}
  class ClassDeclaration {}
}

console.log(FunctionDeclaration); // FunctionDeclaration() {}
console.log(ClassDeclaration);    // ReferenceError: ClassDeclaration is not defined

类的构成

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。

与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例(比如,通过class Foo {} 创建实例foo ):

// 空类定义,有效
class Foo {}

// 有构造函数的类,有效
class Bar {
  constructor() {}
}

// 有获取函数的类,有效
class Baz {
  get myBaz() {}
}

// 有静态方法的类,有效
class Qux {
  static myQux() {}
}

类表达式的名称是可选的。在把类表达式赋值给变量后,可以通过name 属性取得类表达式的名称字符串。但不能在类表达式作用域外部访问这个标识符。

let Person = class PersonName {
  identify() {
    console.log(Person.name, PersonName.name);
  }
}

let p = new Person();

p.identify();               // PersonName PersonName

console.log(Person.name);   // PersonName
console.log(PersonName);    // ReferenceError: PersonName is not defined

constructor 关键字用于在类定义块内部创建类的构造函数。方法名constructor 会告诉解释器在使用new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

  1. 实例化

    使用new 操作符实例化Person 的操作等于使用new 调用其构造函数。唯一可感知的不同之处就是,JavaScript解释器知道使用new 和类意味着应该使用constructor 函数进行实例化。

    使用new调用类的构造函数会执行如下操作。

    (1) 在内存中创建一个新对象。

    (2) 这个新对象内部的[[Prototype]] 指针被赋值为构造函数的prototype 属性。

    (3) 构造函数内部的this 被赋值为这个新对象(即this 指向新对象)。

    (4) 执行构造函数内部的代码(给新对象添加属性)。

    (5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

    来看下面的例子:

    class Animal {}
    
    class Person {
      constructor() {
        console.log('person ctor');
      }
    }
    
    class Vegetable {
      constructor() {
        this.color = 'orange';
      }
    }
    
    let a = new Animal();
    
    let p = new Person();  // person ctor
    
    let v = new Vegetable();
    console.log(v.color);  // orange
    
    

    类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的:

    class Person {
      constructor(name) {
        console.log(arguments.length);
        this.name = name || null;
      }
    }
    
    let p1 = new Person;          // 0
    console.log(p1.name);         // null
    
    let p2 = new Person();        // 0
    console.log(p2.name);         // null
    
    let p3 = new Person('Jake');  // 1
    console.log(p3.name);         // Jake
    
    

    默认情况下,类构造函数会在执行之后返回this 对象。构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的this 对象,那么这个对象会被销毁。不过,如果返回的不是this 对象,而是其他对象,那么这个对象不会通过instanceof 操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改。

    class Person {
      constructor(override) {
        this.foo = 'foo';
        if (override) {
          return {
            bar: 'bar'
          };
        }
      }
    }
    
    let p1 = new Person(),
        p2 = new Person(true);
    
    console.log(p1);                    // Person{ foo: 'foo' }
    console.log(p1 instanceof Person);  // true
    
    console.log(p2);                    // { bar: 'bar' }
    console.log(p2 instanceof Person);  // false
    
    

    类构造函数与构造函数的主要区别是,调用类构造函数必须使用new 操作符。而普通构造函数如果不使用new 调用,那么就会以全局的this (通常是window )作为内部对象。调用类构造函数时如果忘了使用new 则会抛出错误:

    function Person() {}
    
    class Animal {}
    
    // 把window作为this来构建实例
    let p = Person();
    
    let a = Animal();
    // TypeError: class constructor Animal cannot be invoked without 'new'
    
    

    类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用new 调用)。因此,实例化之后可以在实例上引用它:

    class Person {}
    
    // 使用类创建一个新实例
    let p1 = new Person();
    
    p1.constructor();
    // TypeError: Class constructor Person cannot be invoked without 'new'
    
    // 使用对类构造函数的引用创建一个新实例
    let p2 = new p1.constructor();
    
    

     

  2. 把类当成特殊函数

    ECMAScript中没有正式的类这个类型。从各方面来看,ECMAScript类就是一种特殊函数。声明一个类之后,通过typeof 操作符检测类标识符,表明它是一个函数:

    class Person {}
    
    console.log(Person);         // class Person {}
    console.log(typeof Person);  // function
    
    

    类标识符有prototype 属性,而这个原型也有一个constructor 属性指向类自身:

    class Person{}
    
    console.log(Person.prototype);                         // { constructor: f() }
    console.log(Person === Person.prototype.constructor);  // true
    
    

    与普通构造函数一样,可以使用instanceof 操作符检查构造函数原型是否存在于实例的原型链中:

    class Person {}
    
    let p = new Person();
    
    console.log(p instanceof Person); // true
    
    

    由此可知,可以使用instanceof 操作符检查一个对象与类构造函数,以确定这个对象是不是类的实例。只不过此时的类构造函数要使用类标识符,比如,在前面的例子中要检查pPerson

    如前所述,类本身具有与普通构造函数一样的行为。在类的上下文中,类本身在使用new 调用时就会被当成构造函数。重点在于,类中定义的constructor 方法不会 被当成构造函数,在对它使用instanceof 操作符时会返回false 。但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么instanceof 操作符的返回值会反转:

    class Person {}
    
    let p1 = new Person();
    
    console.log(p1.constructor === Person);         // true
    console.log(p1 instanceof Person);              // true
    console.log(p1 instanceof Person.constructor);  // false
    
    let p2 = new Person.constructor();
    
    console.log(p2.constructor === Person);         // false
    console.log(p2 instanceof Person);              // false
    console.log(p2 instanceof Person.constructor);  // true
    
    

    类是JavaScript的一等公民,因此可以像其他对象或函数引用一样把类作为参数传递:

    // 类可以像函数一样在任何地方定义,比如在数组中
    let classList = [
      class {
        constructor(id) {
          this.id_ = id;
          console.log(`instance ${this.id_}`);
        }
      }
    ];
    
    function createInstance(classDefinition, id) {
      return new classDefinition(id);
    }
    
    let foo = createInstance(classList[0], 3141);  // instance 3141
    
    

    与立即调用函数表达式相似,类也可以立即实例化:

    // 因为是一个类表达式,所以类名是可选的
    let p = new class Foo {
      constructor(x) {
        console.log(x);
      }
    }('bar');        // bar
    
    console.log(p);  // Foo {}
    
    

类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员。

  1. 实例成员

    每次通过new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this )添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。

    每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:

    class Person {
      constructor() {
        // 这个例子先使用对象包装类型定义一个字符串
        // 为的是在下面测试两个对象的相等性
        this.name = new String('Jack');
    
        this.sayName = () => console.log(this.name);
    
        this.nicknames = ['Jake', 'J-Dog']
      }
    }
    
    let p1 = new Person(),
        p2 = new Person();
    
    p1.sayName(); // Jack
    p2.sayName(); // Jack
    
    console.log(p1.name === p2.name);            // false
    console.log(p1.sayName === p2.sayName);      // false
    console.log(p1.nicknames === p2.nicknames);  // false
    
    p1.name = p1.nicknames[0];
    p2.name = p2.nicknames[1];
    
    p1.sayName();  // Jake
    p2.sayName();  // J-Dog
    
    

     

  2. 原型方法与访问器

    为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。

    class Person {
      constructor() {
        // 添加到this的所有内容都会存在于不同的实例上
        this.locate = () => console.log('instance');
      }
    
      // 在类块中定义的所有内容都会定义在类的原型上
      locate() {
        console.log('prototype');
      }
    }
    
    let p = new Person();
    
    p.locate();                 // instance
    Person.prototype.locate();  // prototype
    
    

    可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据:

    class Person {
      name: 'Jake'
    }
    // Uncaught SyntaxError: Unexpected token
    
    

    类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键:

    const symbolKey = Symbol('symbolKey');
    
    class Person {
    
      stringKey() {
        console.log('invoked stringKey');
      }
       [symbolKey]() {
        console.log('invoked symbolKey');
      }
       ['computed' + 'Key']() {
        console.log('invoked computedKey');
      }
    }
    
    let p = new Person();
    
    p.stringKey();    // invoked stringKey
    p[symbolKey]();   // invoked symbolKey
    p.computedKey();  // invoked computedKey
    
    

    类定义也支持获取和设置访问器。语法与行为跟普通对象一样:

    class Person {
      set name(newName) {
        this.name_ = newName;
      }
    
      get name() {
        return this.name_;
      }
    }
    
    let p = new Person();
    p.name = 'Jake';
    console.log(p.name); // Jake
    
    

     

  3. 静态类方法

    可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。与原型成员类似,静态成员每个类上只能有一个。

    静态类成员在类定义中使用static 关键字作为前缀。在静态成员中,this 引用类自身。其他所有约定跟原型成员一样:

    class Person {
      constructor() {
        // 添加到this的所有内容都会存在于不同的实例上
        this.locate = () => console.log('instance', this);
      }
    
      // 定义在类的原型对象上
      locate() {
        console.log('prototype', this);
      }
    
      // 定义在类本身上
      static locate() {
        console.log('class', this);
      }
    }
    
    let p = new Person();
    
    p.locate();                 // instance, Person {}
    Person.prototype.locate();  // prototype, {constructor: ... }
    Person.locate();            // class, class Person {}
    
    

    静态类方法非常适合作为实例工厂:

    class Person {
      constructor(age) {
        this.age_ = age;
      }
    
      sayAge() {
        console.log(this.age_);
      }
    
      static create() {
        // 使用随机年龄创建并返回一个Person实例
        return new Person(Math.floor(Math.random()*100));
      }
    }
    
    console.log(Person.create()); // Person { age_: ... }
    
    

     

  4. 非函数原型和类成员

    虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

    class Person {
      sayName() {
        console.log(`${Person.greeting} ${this.name}`);
      }
    }
    
    // 在类上定义数据成员
    Person.greeting = 'My name is';
    
    // 在原型上定义数据成员
    Person.prototype.name = 'Jake';
    
    let p = new Person();
    p.sayName();  // My name is Jake
    
    

    注意  类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过this 引用的数据。

     

  5. 迭代器与生成器方法

    类定义语法支持在原型和类本身上定义生成器方法:

    class Person {
      // 在原型上定义生成器方法
      *createNicknameIterator() {
        yield 'Jack';
        yield 'Jake';
        yield 'J-Dog';
      }
    
      // 在类上定义生成器方法
      static *createJobIterator() {
        yield 'Butcher';
        yield 'Baker';
        yield 'Candlestick maker';
      }
    }
    
    let jobIter = Person.createJobIterator();
    console.log(jobIter.next().value);  // Butcher
    console.log(jobIter.next().value);  // Baker
    console.log(jobIter.next().value);  // Candlestick maker
    
    let p = new Person();
    let nicknameIter = p.createNicknameIterator();
    console.log(nicknameIter.next().value);  // Jack
    console.log(nicknameIter.next().value);  // Jake
    console.log(nicknameIter.next().value);  // J-Dog
    
    

    因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象:

    class Person {
      constructor() {
        this.nicknames = ['Jack', 'Jake', 'J-Dog'];
      }
    
      *[Symbol.iterator]() {
        yield *this.nicknames.entries();
      }
    }
    
    let p = new Person();
    for (let [idx, nickname] of p) {
      console.log(nickname);
    }
    // Jack
    // Jake
    // J-Dog
    
    

    也可以只返回迭代器实例:

    class Person {
      constructor() {
        this.nicknames = ['Jack', 'Jake', 'J-Dog'];
      }
    
      [Symbol.iterator]() {
        return this.nicknames.entries();
      }
    }
    
    let p = new Person();
    for (let [idx, nickname] of p) {
      console.log(nickname);
    }
    // Jack
    // Jake
    // J-Dog
    
    

本章前面花了大量篇幅讨论如何使用ES5的机制实现继承。ECMAScript 6新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链。

  1. 继承基础

    ES6类支持单继承。使用extends 关键字,就可以继承任何拥有[[Construct]] 和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容):

    class Vehicle {}
    
    // 继承类
    class Bus extends Vehicle {}
    
    let b = new Bus();
    console.log(b instanceof Bus);      // true
    console.log(b instanceof Vehicle);  // true
     
     
    function Person() {}
    
    // 继承普通构造函数
    class Engineer extends Person {}
    
    let e = new Engineer();
    console.log(e instanceof Engineer);  // true
    console.log(e instanceof Person);    // true
    
    

    派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反映调用相应方法的实例或者类:

    class Vehicle {
      identifyPrototype(id) {
        console.log(id, this);
      }
    
      static identifyClass(id) {
        console.log(id, this);
      }
    }
    
    class Bus extends Vehicle {}
    
    let v = new Vehicle();
    let b = new Bus();
    
    b.identifyPrototype('bus');       // bus, Bus {}
    v.identifyPrototype('vehicle');   // vehicle, Vehicle {}
    
    Bus.identifyClass('bus');         // bus, class Bus {}
    Vehicle.identifyClass('vehicle'); // vehicle, class Vehicle {}
    
    

    注意  extends 关键字也可以在类表达式中使用,因此let Bar = class extends Foo {} 是有效的语法。

     

  2. 构造函数、HomeObjectsuper()

    派生类的方法可以通过super 关键字引用它们的原型。这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。在类构造函数中使用super 可以调用父类构造函数。

    class Vehicle {
      constructor() {
        this.hasEngine = true;
      }
    }
    
    class Bus extends Vehicle {
      constructor() {
        // 不要在调用super()之前引用this,否则会抛出ReferenceError
    
        super(); // 相当于super.constructor()
    
        console.log(this instanceof Vehicle);  // true
        console.log(this);                     // Bus { hasEngine: true }
      }
    }
    
    new Bus();
    
    

    在静态方法中可以通过super 调用继承的类上定义的静态方法:

    class Vehicle {
      static identify() {
        console.log('vehicle');
      }
    }
    
    class Bus extends Vehicle {
      static identify() {
        super.identify();
      }
    }
    
    Bus.identify();  // vehicle
    
    

    注意  ES6给类构造函数和静态方法添加了内部特性[[HomeObject]] ,这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在JavaScript引擎内部访问。super 始终会定义为[[HomeObject]] 的原型。

    在使用super 时要注意几个问题。

  3. 抽象基类

    有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。虽然ECMAScript没有专门支持这种类的语法 ,但通过new.target 也很容易实现。new.target 保存通过new 关键字调用的类或函数。通过在实例化时检测new.target 是不是抽象基类,可以阻止对抽象基类的实例化:

    // 抽象基类
    class Vehicle {
      constructor() {
        console.log(new.target);
        if (new.target === Vehicle) {
          throw new Error('Vehicle cannot be directly instantiated');
        }
      }
    }
    
    // 派生类
    class Bus extends Vehicle {}
    
    new Bus();       // class Bus {}
    new Vehicle();   // class Vehicle {}
    // Error: Vehicle cannot be directly instantiated
    
    

    另外,通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法。因为原型方法在调用类构造函数之前就已经存在了,所以可以通过this 关键字来检查相应的方法:

    // 抽象基类
    class Vehicle {
      constructor() {
        if (new.target === Vehicle) {
          throw new Error('Vehicle cannot be directly instantiated');
        }
    
        if (!this.foo) {
          throw new Error('Inheriting class must define foo()');
        }
    
        console.log('success!');
      }
    }
    
    // 派生类
    class Bus extends Vehicle {
      foo() {}
    }
    
    // 派生类
    class Van extends Vehicle {}
    
    new Bus(); // success!
    new Van(); // Error: Inheriting class must define foo()
    
    

     

  4. 继承内置类型

    ES6类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:

    class SuperArray extends Array {
      shuffle() {
        // 洗牌算法
        for (let i = this.length - 1; i > 0; i--) {
          const j = Math.floor(Math.random() * (i + 1));
          [this[i], this[j]] = [this[j], this[i]];
        }
      }
    }
    
    let a = new SuperArray(1, 2, 3, 4, 5);
    
    console.log(a instanceof Array);       // true
    console.log(a instanceof SuperArray);  // true
    
    console.log(a);  // [1, 2, 3, 4, 5]
    a.shuffle();
    console.log(a);  // [3, 1, 4, 5, 2]
    
    

    有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的:

    class SuperArray extends Array {}
    
    let a1 = new SuperArray(1, 2, 3, 4, 5);
    let a2 = a1.filter(x => !!(x%2))
    
    console.log(a1);  // [1, 2, 3, 4, 5]
    console.log(a2);  // [1, 3, 5]
    console.log(a1 instanceof SuperArray);  // true
    console.log(a2 instanceof SuperArray);  // true
    
    

    如果想覆盖这个默认行为,则可以覆盖Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类:

    class SuperArray extends Array {
      static get [Symbol.species]() {
        return Array;
      }
    }
    
    let a1 = new SuperArray(1, 2, 3, 4, 5);
    let a2 = a1.filter(x => !!(x%2))
    
    console.log(a1);  // [1, 2, 3, 4, 5]
    console.log(a2);  // [1, 3, 5]
    console.log(a1 instanceof SuperArray);  // true
    console.log(a2 instanceof SuperArray);  // false
    
    

     

  5. 类混入

    把不同类的行为集中到一个类是一种常见的JavaScript模式。虽然ES6没有显式支持多类继承,但通过现有特性可以轻松地模拟这种行为。

    注意  Object.assign() 方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用Object.assign() 就可以了。

    在下面的代码片段中,extends 关键字后面是一个JavaScript表达式。任何可以解析为一个类或一个构造函数的表达式都是有效的。这个表达式会在求值类定义时被求值:

    class Vehicle {}
    
    function getParentClass() {
      console.log('evaluated expression');
      return Vehicle;
    }
    
    class Bus extends getParentClass() {}
    // 可求值的表达式
    
    

    混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。如果Person 类需要组合A、B、C,则需要某种机制实现B继承A,C继承B,而Person 再继承C,从而把A、B、C组合到这个超类中。实现这种模式有不同的策略。

    一个策略是定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式:

    class Vehicle {}
    
    let FooMixin = (Superclass) => class extends Superclass {
      foo() {
        console.log('foo');
      }
    };
    let BarMixin = (Superclass) => class extends Superclass {
      bar() {
        console.log('bar');
      }
    };
    let BazMixin = (Superclass) => class extends Superclass {
      baz() {
        console.log('baz');
      }
    };
    
    class Bus extends FooMixin(BarMixin(BazMixin(Vehicle))) {}
    
    let b = new Bus();
    b.foo();  // foo
    b.bar();  // bar
    b.baz();  // baz
    
    

    通过写一个辅助函数,可以把嵌套调用展开:

    class Vehicle {}
    
    let FooMixin = (Superclass) => class extends Superclass {
      foo() {
        console.log('foo');
      }
    };
    let BarMixin = (Superclass) => class extends Superclass {
      bar() {
        console.log('bar');
      }
    };
    let BazMixin = (Superclass) => class extends Superclass {
      baz() {
        console.log('baz');
      }
    };
    
    function mix(BaseClass, ...Mixins) {
      return Mixins.reduce((accumulator, current) => current(accumulator), BaseClass);
    }
    
    class Bus extends mix(Vehicle, FooMixin, BarMixin, BazMixin) {}
    
    let b = new Bus();
    b.foo();  // foo
    b.bar();  // bar
    b.baz();  // baz
    
    

    注意  很多JavaScript框架(特别是React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。

对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。下面的模式适用于创建对象。

JavaScript的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。

除上述模式之外,还有以下几种继承模式。

ECMAScript 6新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。


第 9 章 代理与反射

本章内容

ECMAScript 6新增的代理和反射为开发者提供了拦截并向基本操作嵌入额外行为的能力。具体地说,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制。

对刚刚接触这个主题的开发者而言,代理是一个比较模糊的概念,而且还夹杂着很多新术语。其实只要看几个例子,就很容易理解了。

注意  在ES6之前,ECMAScript中并没有类似代理的特性。由于代理是一种新的基础性语言能力,很多转译程序都不能把代理行为转换为之前的ECMAScript代码,因为代理的行为实际上是无可替代的。为此,代理和反射只在百分之百支持它们的平台上有用。可以检测代理是否存在,不存在则提供后备代码。不过这会导致代码冗余,因此并不推荐。

正如本章开头所介绍的,代理是目标对象的抽象。从很多方面看,代理类似C++指针,因为它可以用作目标对象的替身,但又完全独立于目标对象。目标对象既可以直接被操作,也可以通过代理来操作。但直接操作会绕过代理施予的行为。

注意  ECMAScript代理与C++指针有重大区别,后面会再讨论。不过作为一种有助于理解的类比,指针在概念上还是比较合适的结构。

最简单的代理是空代理,即除了作为一个抽象的目标对象,什么也不做。默认情况下,在代理对象上执行的所有操作都会无障碍地传播到目标对象。因此,在任何可以使用目标对象的地方,都可以通过同样的方式来使用与之关联的代理对象。

代理是使用Proxy 构造函数创建的。这个构造函数接收两个参数:目标对象和处理程序对象。缺少其中任何一个参数都会抛出TypeError 。要创建空代理,可以传一个简单的对象字面量作为处理程序对象,从而让所有操作畅通无阻地抵达目标对象。

如下面的代码所示,在代理对象上执行的任何操作实际上都会应用到目标对象。唯一可感知的不同就是代码中操作的是代理对象。

const target = {
  id: 'target'
};

const handler = {};

const proxy = new Proxy(target, handler);

// id属性会访问同一个值
console.log(target.id);  // target
console.log(proxy.id);   // target

// 给目标属性赋值会反映在两个对象上
// 因为两个对象访问的是同一个值
target.id = 'foo';
console.log(target.id); // foo
console.log(proxy.id);  // foo

// 给代理属性赋值会反映在两个对象上
// 因为这个赋值会转移到目标对象
proxy.id = 'bar';
console.log(target.id); // bar
console.log(proxy.id);  // bar

// hasOwnProperty()方法在两个地方
// 都会应用到目标对象
console.log(target.hasOwnProperty('id')); // true
console.log(proxy.hasOwnProperty('id'));  // true

// Proxy.prototype是undefined
// 因此不能使用instanceof操作符
console.log(target instanceof Proxy); // TypeError: Function has non-object prototype 'undefined' in instanceof check
console.log(proxy instanceof Proxy);  // TypeError: Function has non-object prototype 'undefined' in instanceof check

// 严格相等可以用来区分代理和目标
console.log(target === proxy); // false

使用代理的主要目的是可以定义捕获器 (trap)。捕获器就是在处理程序对象中定义的“基本操作的拦截器”。每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应的行为。

注意  捕获器(trap)是从操作系统中借用的概念。在操作系统中,捕获器是程序流中的一个同步中断,可以暂停程序流,转而执行一段子例程,之后再返回原始程序流。

例如,可以定义一个get() 捕获器,在ECMAScript操作以某种形式调用get() 时触发。下面的例子定义了一个get() 捕获器:

const target = {
  foo: 'bar'
};

const handler = {
  // 捕获器在处理程序对象中以方法名为键
  get() {
    return 'handler override';
  }
};

const proxy = new Proxy(target, handler);

这样,当通过代理对象执行get() 操作时,就会触发定义的get() 捕获器。当然,get() 不是ECMAScript对象可以调用的方法。这个操作在JavaScript代码中可以通过多种形式触发并被get() 捕获器拦截到。proxy[property]proxy.propertyObject.create(proxy)[property] 等操作都会触发基本的get() 操作以获取属性。因此所有这些操作只要发生在代理对象上,就会触发get() 捕获器。注意,只有在代理对象上执行这些操作才会触发捕获器。在目标对象上执行这些操作仍然会产生正常的行为。

const target = {
  foo: 'bar'
};

const handler = {
  // 捕获器在处理程序对象中以方法名为键
  get() {
    return 'handler override';
  }
};

const proxy = new Proxy(target, handler);

console.log(target.foo);                    // bar
console.log(proxy.foo);                     // handler override

console.log(target['foo']);                 // bar
console.log(proxy['foo']);                  // handler override

console.log(Object.create(target)['foo']);  // bar
console.log(Object.create(proxy)['foo']);   // handler override

所有捕获器都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为。比如,get() 捕获器会接收到目标对象、要查询的属性和代理对象三个参数。

const target = {
  foo: 'bar'
};

const handler = {
  get(trapTarget, property, receiver) {
    console.log(trapTarget === target);
    console.log(property);
    console.log(receiver === proxy);
  }
};

const proxy = new Proxy(target, handler);

proxy.foo;
// true
// foo
// true

有了这些参数,就可以重建被捕获方法的原始行为:

const target = {
  foo: 'bar'
};

const handler = {
  get(trapTarget, property, receiver) {
    return trapTarget[property];
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.foo);  // bar
console.log(target.foo); // bar

所有捕获器都可以基于自己的参数重建原始操作,但并非所有捕获器行为都像get() 那么简单。因此,通过手动写码如法炮制的想法是不现实的。实际上,开发者并不需要手动重建原始行为,而是可以通过调用全局Reflect 对象上(封装了原始行为)的同名方法来轻松重建。

处理程序对象中所有可以捕获的方法都有对应的反射(Reflect)API方法。这些方法与捕获器拦截的方法具有相同的名称和函数签名,而且也具有与被拦截方法相同的行为。因此,使用反射API也可以像下面这样定义出空代理对象:

const target = {
  foo: 'bar'
};

const handler = {
  get() {
    return Reflect.get(...arguments);
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.foo);   // bar
console.log(target.foo);  // bar

甚至还可以写得更简洁一些:

const target = {
  foo: 'bar'
};

const handler = {
  get: Reflect.get
};

const proxy = new Proxy(target, handler);

console.log(proxy.foo);  // bar
console.log(target.foo); // bar

事实上,如果真想创建一个可以捕获所有方法,然后将每个方法转发给对应反射API的空代理,那么甚至不需要定义处理程序对象:

const target = {
  foo: 'bar'
};

const proxy = new Proxy(target, Reflect);

console.log(proxy.foo);   // bar
console.log(target.foo);  // bar

反射API为开发者准备好了样板代码,在此基础上开发者可以用最少的代码修改捕获的方法。比如,下面的代码在某个属性被访问时,会对返回的值进行一番修饰:

const target = {
  foo: 'bar',
  baz: 'qux'
};

const handler = {
  get(trapTarget, property, receiver) {
    let decoration = '';
    if (property === 'foo') {
      decoration = '!!!';
    }

    return Reflect.get(...arguments) + decoration;
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.foo);   // bar!!!
console.log(target.foo);  // bar

console.log(proxy.baz);   // qux
console.log(target.baz);  // qux

使用捕获器几乎可以改变所有基本方法的行为,但也不是没有限制。根据ECMAScript规范,每个捕获的方法都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循“捕获器不变式”(trap invariant)。捕获器不变式因方法不同而异,但通常都会防止捕获器定义出现过于反常的行为。

比如,如果目标对象有一个不可配置且不可写的数据属性,那么在捕获器返回一个与该属性不同的值时,会抛出TypeError

const target = {};
Object.defineProperty(target, 'foo', {
  configurable: false,
  writable: false,
  value: 'bar'
});

const handler = {
  get() {
    return 'qux';
  }
};

const proxy = new Proxy(target, handler);

console.log(proxy.foo);
// TypeError

有时候可能需要中断代理对象与目标对象之间的联系。对于使用new Proxy() 创建的普通代理来说,这种联系会在代理对象的生命周期内一直持续存在。

Proxy 也暴露了revocable() 方法,这个方法支持撤销代理对象与目标对象的关联。撤销代理的操作是不可逆的。而且,撤销函数(revoke() )是幂等的,调用多少次的结果都一样。撤销代理之后再调用代理会抛出TypeError

撤销函数和代理对象是在实例化时同时生成的:

const target = {
  foo: 'bar'
};

const handler = {
  get() {
    return 'intercepted';
  }
};

const { proxy, revoke } = Proxy.revocable(target, handler);

console.log(proxy.foo);   // intercepted
console.log(target.foo);  // bar

revoke();

console.log(proxy.foo);   // TypeError

某些情况下应该优先使用反射API,这是有一些理由的。

  1. 反射API与对象API

    在使用反射API时,要记住:

    (1) 反射API并不限于捕获处理程序;

    (2) 大多数反射API方法在Object 类型上有对应的方法。

    通常,Object 上的方法适用于通用程序,而反射方法适用于细粒度的对象控制与操作。
     

  2. 状态标记

    很多反射方法返回称作“状态标记”的布尔值,表示意图执行的操作是否成功。有时候,状态标记比那些返回修改后的对象或者抛出错误(取决于方法)的反射API方法更有用。例如,可以使用反射API对下面的代码进行重构:

    // 初始代码
    
    const o = {};
    
    try {
      Object.defineProperty(o, 'foo', 'bar');
      console.log('success');
    } catch(e) {
      console.log('failure');
    }
    
    

    在定义新属性时如果发生问题,Reflect.defineProperty() 会返回false ,而不是抛出错误。因此使用这个反射方法可以这样重构上面的代码:

    // 重构后的代码
    
    const o = {};
    
    if(Reflect.defineProperty(o, 'foo', {value: 'bar'})) {
      console.log('success');
    } else {
      console.log('failure');
    }
    
    

    以下反射方法都会提供状态标记:

  3. 用一等函数替代操作符

    以下反射方法提供只有通过操作符才能完成的操作。

  4. 安全地应用函数

    在通过apply 方法调用函数时,被调用的函数可能也定义了自己的apply 属性(虽然可能性极小)。为绕过这个问题,可以使用定义在Function 原型上的apply 方法,比如:

    Function.prototype.apply.call(myFunc, thisVal, argumentList);
    
    

    这种可怕的代码完全可以使用Reflect.apply 来避免:

    Reflect.apply(myFunc, thisVal, argumentsList);
    
    

代理可以拦截反射API的操作,而这意味着完全可以创建一个代理,通过它去代理另一个代理。这样就可以在一个目标对象之上构建多层拦截网:

const target = {
  foo: 'bar'
};

const firstProxy = new Proxy(target, {
  get() {
    console.log('first proxy');
    return Reflect.get(...arguments);
  }
});

const secondProxy = new Proxy(firstProxy, {
  get() {
    console.log('second proxy');
    return Reflect.get(...arguments);
  }
});

console.log(secondProxy.foo);
// second proxy
// first proxy
// bar

代理是在ECMAScript现有基础之上构建起来的一套新API,因此其实现已经尽力做到最好了。很大程度上,代理作为对象的虚拟层可以正常使用。但在某些情况下,代理也不能与现在的ECMAScript机制很好地协同。

  1. 代理中的this

    代理潜在的一个问题来源是this 值。我们知道,方法中的this 通常指向调用这个方法的对象:

    const target = {
      thisValEqualsProxy() {
        return this === proxy;
      }
    }
    
    const proxy = new Proxy(target, {});
    
    console.log(target.thisValEqualsProxy());  // false
    console.log(proxy.thisValEqualsProxy());   // true
    
    

    从直觉上讲,这样完全没有问题:调用代理上的任何方法,比如proxy.outerMethod() ,而这个方法进而又会调用另一个方法,如this.innerMethod() ,实际上都会调用proxy.innerMethod() 。多数情况下,这是符合预期的行为。可是,如果目标对象依赖于对象标识,那就可能碰到意料之外的问题。

    还记得第6章中通过WeakMap 保存私有变量的例子吧,以下是它的简化版:

    const wm = new WeakMap();
    
    class User {
      constructor(userId) {
        wm.set(this, userId);
      }
    
      set id(userId) {
        wm.set(this, userId);
      }
    
      get id() {
        return wm.get(this);
      }
    }
    
    

    由于这个实现依赖User 实例的对象标识,在这个实例被代理的情况下就会出问题:

    const user = new User(123);
    console.log(user.id); // 123
    
    const userInstanceProxy = new Proxy(user, {});
    console.log(userInstanceProxy.id); // undefined
    
    

    这是因为User 实例一开始使用目标对象作为WeakMap 的键,代理对象却尝试从自身 取得这个实例。要解决这个问题,就需要重新配置代理,把代理User 实例改为代理User 类本身。之后再创建代理的实例就会以代理实例作为WeakMap 的键了:

    const UserClassProxy = new Proxy(User, {});
    const proxyUser = new UserClassProxy(456);
    console.log(proxyUser.id);
    
    

     

  2. 代理与内部槽位

    代理与内置引用类型(比如Array )的实例通常可以很好地协同,但有些ECMAScript内置类型可能会依赖代理无法控制的机制,结果导致在代理上调用某些方法会出错。

    一个典型的例子就是Date 类型。根据ECMAScript规范,Date 类型方法的执行依赖this 值上的内部槽位[[NumberDate]] 。代理对象上不存在这个内部槽位,而且这个内部槽位的值也不能通过普通的get()set() 操作访问到,于是代理拦截后本应转发给目标对象的方法会抛出TypeError

    const target = new Date();
    const proxy = new Proxy(target, {});
    
    console.log(proxy instanceof Date);  // true
    
    proxy.getDate();  // TypeError: 'this' is not a Date object
    
    

代理可以捕获13种不同的基本操作。这些操作有各自不同的反射API方法、参数、关联ECMAScript操作和不变式。

正如前面示例所展示的,有几种不同的JavaScript操作会调用同一个捕获器处理程序。不过,对于在代理对象上执行的任何一种操作,只会有一个捕获处理程序被调用。不会存在重复捕获的情况。

只要在代理上调用,所有捕获器都会拦截它们对应的反射API操作。

get() 捕获器会在获取属性值的操作中被调用。对应的反射API方法为Reflect.get()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  get(target, property, receiver) {
    console.log('get()');
    return Reflect.get(...arguments)
  }
});

proxy.foo;
// get()

  1. 返回值

    返回值无限制。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果target.property 不可写且不可配置,则处理程序返回的值必须与target.property 匹配。

    如果target.property 不可配置且[[Get]] 特性为undefined ,处理程序的返回值也必须是undefined

1 严格来讲,property 参数除了字符串键,也可能是符(symbol )键。后面几处也一样。——译者注

set() 捕获器会在设置属性值的操作中被调用。对应的反射API方法为Reflect.set()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  set(target, property, value, receiver) {
    console.log('set()');
    return Reflect.set(...arguments)
  }
});

proxy.foo = 'bar';
// set()

  1. 返回值

    返回true 表示成功;返回false 表示失败,严格模式下会抛出TypeError
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果target.property 不可写且不可配置,则不能修改目标属性的值。

    如果target.property 不可配置且[[Set]] 特性为undefined ,则不能修改目标属性的值。

    在严格模式下,处理程序中返回false 会抛出TypeError

has() 捕获器会在in 操作符中被调用。对应的反射API方法为Reflect.has()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  has(target, property) {
    console.log('has()');
    return Reflect.has(...arguments)
  }
});

'foo' in proxy;
// has()

  1. 返回值

    has() 必须返回布尔值,表示属性是否存在。返回非布尔值会被转型为布尔值。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果target.property 存在且不可配置,则处理程序必须返回true

    如果target.property 存在且目标对象不可扩展,则处理程序必须返回true

defineProperty() 捕获器会在Object.defineProperty() 中被调用。对应的反射API方法为Reflect.defineProperty()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  defineProperty(target, property, descriptor) {
    console.log('defineProperty()');
    return Reflect.defineProperty(...arguments)
  }
});

Object.defineProperty(proxy, 'foo', { value: 'bar' });
// defineProperty()

  1. 返回值

    defineProperty() 必须返回布尔值,表示属性是否成功定义。返回非布尔值会被转型为布尔值。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果目标对象不可扩展,则无法定义属性。

    如果目标对象有一个可配置的属性,则不能添加同名的不可配置属性。

    如果目标对象有一个不可配置的属性,则不能添加同名的可配置属性。

getOwnPropertyDescriptor() 捕获器会在Object.getOwnPropertyDescriptor() 中被调用。对应的反射API方法为Reflect.getOwnPropertyDescriptor()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  getOwnPropertyDescriptor(target, property) {
    console.log('getOwnPropertyDescriptor()');
    return Reflect.getOwnPropertyDescriptor(...arguments)
  }
});

Object.getOwnPropertyDescriptor(proxy, 'foo');
// getOwnPropertyDescriptor()

  1. 返回值

    getOwnPropertyDescriptor() 必须返回对象,或者在属性不存在时返回undefined
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果自有的target.property 存在且不可配置,则处理程序必须返回一个表示该属性存在的对象。

    如果自有的target.property 存在且可配置,则处理程序必须返回表示该属性可配置的对象。

    如果自有的target.property 存在且target 不可扩展,则处理程序必须返回一个表示该属性存在的对象。

    如果target.property 不存在且target 不可扩展,则处理程序必须返回undefined 表示该属性不存在。

    如果target.property 不存在,则处理程序不能返回表示该属性可配置的对象。

deleteProperty() 捕获器会在delete 操作符中被调用。对应的反射API方法为Reflect.deleteProperty()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  deleteProperty(target, property) {
    console.log('deleteProperty()');
    return Reflect.deleteProperty(...arguments)
  }
});

delete proxy.foo
// deleteProperty()

  1. 返回值

    deleteProperty() 必须返回布尔值,表示删除属性是否成功。返回非布尔值会被转型为布尔值。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果自有的target.property 存在且不可配置,则处理程序不能删除这个属性。

ownKeys() 捕获器会在Object.keys() 及类似方法中被调用。对应的反射API方法为Reflect.ownKeys()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  ownKeys(target) {
    console.log('ownKeys()');
    return Reflect.ownKeys(...arguments)
  }
});

Object.keys(proxy);
// ownKeys()

  1. 返回值

    ownKeys() 必须返回包含字符串或符号的可枚举对象。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    返回的可枚举对象必须包含target 的所有不可配置的自有属性。

    如果target 不可扩展,则返回可枚举对象必须准确地包含自有属性键。

getPrototypeOf() 捕获器会在Object.getPrototypeOf() 中被调用。对应的反射API方法为Reflect.getPrototypeOf()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  getPrototypeOf(target) {
    console.log('getPrototypeOf()');
    return Reflect.getPrototypeOf(...arguments)
  }
});

Object.getPrototypeOf(proxy);
// getPrototypeOf()

  1. 返回值

    getPrototypeOf() 必须返回对象或null
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果target 不可扩展,则Object.getPrototypeOf(proxy) 唯一有效的返回值就是Object.getPrototypeOf(target) 的返回值。

setPrototypeOf() 捕获器会在Object.setPrototypeOf() 中被调用。对应的反射API方法为Reflect.setPrototypeOf()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  setPrototypeOf(target, prototype) {
    console.log('setPrototypeOf()');
    return Reflect.setPrototypeOf(...arguments)
  }
});

Object.setPrototypeOf(proxy, Object);
// setPrototypeOf()

  1. 返回值

    setPrototypeOf() 必须返回布尔值,表示原型赋值是否成功。返回非布尔值会被转型为布尔值。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果target 不可扩展,则唯一有效的prototype 参数就是Object.getPrototypeOf(target) 的返回值。

isExtensible() 捕获器会在Object.isExtensible() 中被调用。对应的反射API方法为Reflect.isExtensible()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  isExtensible(target) {
    console.log('isExtensible()');
    return Reflect.isExtensible(...arguments)
  }
});

Object.isExtensible(proxy);
// isExtensible()

  1. 返回值

    isExtensible() 必须返回布尔值,表示target 是否可扩展。返回非布尔值会被转型为布尔值。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果target 可扩展,则处理程序必须返回true

    如果target 不可扩展,则处理程序必须返回false

preventExtensions() 捕获器会在Object.preventExtensions() 中被调用。对应的反射API方法为Reflect.preventExtensions()

const myTarget = {};

const proxy = new Proxy(myTarget, {
  preventExtensions(target) {
    console.log('preventExtensions()');
    return Reflect.preventExtensions(...arguments)
  }
});

Object.preventExtensions(proxy);
// preventExtensions()

  1. 返回值

    preventExtensions() 必须返回布尔值,表示target 是否已经不可扩展。返回非布尔值会被转型为布尔值。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    如果Object.isExtensible(proxy)false ,则处理程序必须返回true

apply() 捕获器会在调用函数时中被调用。对应的反射API方法为Reflect.apply()

const myTarget = () => {};

const proxy = new Proxy(myTarget, {
  apply(target, thisArg, ...argumentsList) {
    console.log('apply()');
    return Reflect.apply(...arguments)
  }
});

proxy();
// apply()

  1. 返回值

    返回值无限制。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    target 必须是一个函数对象。

construct() 捕获器会在new 操作符中被调用。对应的反射API方法为Reflect.construct()

const myTarget = function() {};

const proxy = new Proxy(myTarget, {
  construct(target, argumentsList, newTarget) {
    console.log('construct()');
    return Reflect.construct(...arguments)
  }
});

new proxy;
// construct()

  1. 返回值

    construct() 必须返回一个对象。
     

  2. 拦截的操作

  3. 捕获器处理程序参数

  4. 捕获器不变式

    target 必须可以用作构造函数。

使用代理可以在代码中实现一些有用的编程模式。

通过捕获getsethas 等操作,可以知道对象属性什么时候被访问、被查询。把实现相应捕获器的某个对象代理放到应用中,可以监控这个对象何时在何处被访问过:

const user = {
  name: 'Jake'
};

const proxy = new Proxy(user, {
  get(target, property, receiver) {
    console.log(`Getting ${property}`);

    return Reflect.get(...arguments);
  },
  set(target, property, value, receiver) {
    console.log(`Setting ${property}=${value}`);

    return Reflect.set(...arguments);
  }
});

proxy.name;     // Getting name
proxy.age = 27; // Setting age=27

代理的内部实现对外部代码是不可见的,因此要隐藏目标对象上的属性也轻而易举。比如:

const hiddenProperties = ['foo', 'bar'];
const targetObject = {
  foo: 1,
  bar: 2,
  baz: 3
};
const proxy = new Proxy(targetObject, {
  get(target, property) {
    if (hiddenProperties.includes(property)) {
      return undefined;
    } else {
      return Reflect.get(...arguments);
    }
  },
  has(target, property) {
    if (hiddenProperties.includes(property)) {
      return false;
    } else {
      return Reflect.has(...arguments);
    }
  }
});

// get()
console.log(proxy.foo);  // undefined
console.log(proxy.bar);  // undefined
console.log(proxy.baz);  // 3

// has()
console.log('foo' in proxy);  // false
console.log('bar' in proxy);  // false
console.log('baz' in proxy);  // true

因为所有赋值操作都会触发set() 捕获器,所以可以根据所赋的值决定是允许还是拒绝赋值:

const target = {
  onlyNumbersGoHere: 0
};

const proxy = new Proxy(target, {
  set(target, property, value) {
    if (typeof value !== 'number') {
      return false;
    } else {
      return Reflect.set(...arguments);
    }
  }
});

proxy.onlyNumbersGoHere = 1;
console.log(proxy.onlyNumbersGoHere);  // 1
proxy.onlyNumbersGoHere = '2';
console.log(proxy.onlyNumbersGoHere);  // 1

跟保护和验证对象属性类似,也可对函数和构造函数参数进行审查。比如,可以让函数只接收某种类型的值:

function median(...nums) {
  return nums.sort()[Math.floor(nums.length / 2)];
}

const proxy = new Proxy(median, {
  apply(target, thisArg, argumentsList) {
    for (const arg of argumentsList) {
      if (typeof arg !== 'number') {
        throw 'Non-number argument provided';
      }
    }
    return Reflect.apply(...arguments);
  }
});

console.log(proxy(4, 7, 1));  // 4
console.log(proxy(4, '7', 1));
// Error: Non-number argument provided

类似地,可以要求实例化时必须给构造函数传参:

class User {
  constructor(id) {
    this.id_ = id;
  }
}

const proxy = new Proxy(User, {
  construct(target, argumentsList, newTarget) {
    if (argumentsList[0] === undefined) {
      throw 'User cannot be instantiated without id';
    } else {
      return Reflect.construct(...arguments);
    }
  }
});

new proxy(1);

new proxy();
// Error: User cannot be instantiated without id

通过代理可以把运行时中原本不相关的部分联系到一起。这样就可以实现各种模式,从而让不同的代码互操作。

比如,可以将被代理的类绑定到一个全局实例集合,让所有创建的实例都被添加到这个集合中:

const userList = [];

class User {
  constructor(name) {
    this.name_ = name;
  }
}

const proxy = new Proxy(User, {
  construct() {
    const newUser = Reflect.construct(...arguments);
    userList.push(newUser);
    return newUser;
  }
});

new proxy('John');
new proxy('Jacob');
new proxy('Jingleheimerschmidt');

console.log(userList); // [User {}, User {}, User{}]

另外,还可以把集合绑定到一个事件分派程序,每次插入新实例时都会发送消息:

const userList = [];

function emit(newValue) {
  console.log(newValue);
}

const proxy = new Proxy(userList, {
  set(target, property, value, receiver) {
    const result = Reflect.set(...arguments);
    if (result) {
      emit(Reflect.get(target, property, receiver));
    }
    return result;
  }
});

proxy.push('John');
// John
proxy.push('Jacob');
// Jacob

代理是ECMAScript 6新增的令人兴奋和动态十足的新特性。尽管不支持向后兼容,但它开辟出了一片前所未有的JavaScript元编程及抽象的新天地。

从宏观上看,代理是真实JavaScript对象的透明抽象层。代理可以定义包含捕获器 的处理程序对象,而这些捕获器可以拦截绝大部分JavaScript的基本操作和方法。在这个捕获器处理程序中,可以修改任何基本操作的行为,当然前提是遵从捕获器不变式

与代理如影随形的反射API,则封装了一整套与捕获器拦截的操作相对应的方法。可以把反射API看作一套基本操作,这些操作是绝大部分JavaScript对象API的基础。

代理的应用场景是不可限量的。开发者使用它可以创建出各种编码模式,比如(但远远不限于)跟踪属性访问、隐藏属性、阻止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定,以及可观察对象。


第 10 章 函数

本章内容

函数是ECMAScript中最有意思的部分之一,这主要是因为函数实际上是对象。每个函数都是Function 类型的实例,而Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。函数通常以函数声明的方式定义,比如:

function sum (num1, num2) {
  return num1 + num2;
}

注意函数定义最后没有加分号。

另一种定义函数的语法是函数表达式。函数表达式与函数声明几乎是等价的:

let sum = function(num1, num2) {
  return num1 + num2;
};

这里,代码定义了一个变量sum 并将其初始化为一个函数。注意function 关键字后面没有名称,因为不需要。这个函数可以通过变量sum 来引用。注意函数定义最后没有加分号。

注意这里的函数末尾是有分号的,与任何变量初始化语句一样。

还有一种定义函数的方式与函数表达式很像,叫作“箭头函数”(arrow function),如下所示:

let sum = (num1, num2) => {
  return num1 + num2;
};

最后一种定义函数的方式是使用Function 构造函数。这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数。来看下面的例子:

let sum = new Function("num1", "num2", "return num1 + num2");  // 不推荐

我们不推荐使用这种语法来定义函数,因为这段代码会被解释两次:第一次是将它当作常规ECMAScript代码,第二次是解释传给构造函数的字符串。这显然会影响性能。不过,把函数想象为对象,把函数名想象为指针是很重要的。而上面这种语法很好地诠释了这些概念。

注意  这几种实例化函数对象的方式之间存在微妙但重要的差别,本章后面会讨论。无论如何,通过其中任何一种方式都可以创建函数。

ECMAScript 6新增了使用胖箭头(=> )语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数:

let arrowSum = (a, b) => {
  return a + b;
};

let functionExpressionSum = function(a, b) {
  return a + b;
};

console.log(arrowSum(5, 8)); // 13
console.log(functionExpressionSum(5, 8)); // 13

箭头函数简洁的语法非常适合嵌入函数的场景:

let ints = [1, 2, 3];

console.log(ints.map(function(i) { return i + 1; }));  // [2, 3, 4]
console.log(ints.map((i) => { return i + 1 }));        // [2, 3, 4]

如果只有一个参数,那也可以不用括号。只有没有参数,或者多个参数的情况下,才需要使用括号:

// 以下两种写法都有效
let double = (x) => { return 2 * x; };
let triple = x => { return 3 * x; };

// 没有参数需要括号
let getRandom = () => { return Math.random(); };

// 多个参数需要括号
let sum = (a, b) => { return a + b; };

// 无效的写法:
let multiply = a, b => { return a * b; };

箭头函数也可以不用大括号,但这样会改变函数的行为。使用大括号就说明包含“函数体”,可以在一个函数中包含多条语句,跟常规的函数一样。如果不使用大括号,那么箭头后面就只能有一行代码,比如一个赋值操作,或者一个表达式。而且,省略大括号会隐式返回这行代码的值:

// 以下两种写法都有效,而且返回相应的值
let double = (x) => { return 2 * x; };
let triple = (x) => 3 * x;

// 可以赋值
let value = {};
let setName = (x) => x.name = "Matt";
setName(value);
console.log(value.name); // "Matt"

// 无效的写法:
let multiply = (a, b) => return a * b;

箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用argumentssupernew.target ,也不能用作构造函数。此外,箭头函数也没有prototype 属性。

因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着一个函数可以有多个名称,如下所示:

function sum(num1, num2) {
  return num1 + num2;
}

console.log(sum(10, 10));         // 20

let anotherSum = sum;
console.log(anotherSum(10, 10));  // 20

sum = null;
console.log(anotherSum(10, 10));  // 20

以上代码定义了一个名为sum() 的函数,用于求两个数之和。然后又声明了一个变量anotherSum ,并将它的值设置为等于sum 。注意,使用不带括号的函数名会访问函数指针,而不会执行函数。此时,anotherSumsum 都指向同一个函数。调用anotherSum() 也可以返回结果。把sum 设置为null 之后,就切断了它与函数之间的关联。而anotherSum() 还是可以照常调用,没有问题。

ECMAScript 6的所有函数对象都会暴露一个只读的name 属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用Function 构造函数创建的,则会标识成"anonymous"

function foo() {}
let bar = function() {};
let baz = () => {};

console.log(foo.name);               // foo
console.log(bar.name);               // bar
console.log(baz.name);               // baz
console.log((() => {}).name);        //(空字符串)
console.log((new Function()).name);  // anonymous

如果函数是一个获取函数、设置函数,或者使用bind() 实例化,那么标识符前面会加上一个前缀:

function foo() {}

console.log(foo.bind(null).name);    // bound foo

let dog = {
  years: 1,
  get age() {
    return this.years;
  },
  set age(newAge) {
    this.years = newAge;
  }
}

let propertyDescriptor = Object.getOwnPropertyDescriptor(dog, 'age');
console.log(propertyDescriptor.get.name);  // get age
console.log(propertyDescriptor.set.name);  // set age

ECMAScript函数的参数跟大多数其他语言不同。ECMAScript函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就传两个参数。你可以传一个、三个,甚至一个也不传,解释器都不会报错。

之所以会这样,主要是因为ECMAScript函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题;如果数组的元素超出了要求,那也没问题。事实上,在使用function 关键字定义(非箭头)函数时,可以在函数内部访问arguments 对象,从中取得传进来的每个参数值。

arguments 对象是一个类数组对象(但不是Array 的实例),因此可以使用中括号语法访问其中的元素(第一个参数是arguments[0] ,第二个参数是arguments[1] )。而要确定传进来多少个参数,可以访问arguments.length 属性。

在下面的例子中,sayHi() 函数的第一个参数叫name

function sayHi(name, message) {
  console.log("Hello " + name + ", " + message);
}

可以通过arguments[0] 取得相同的参数值。因此,把函数重写成不声明参数也可以:

function sayHi() {
  console.log("Hello " + arguments[0] + ", " + arguments[1]);
}

在重写后的代码中,没有命名参数。namemessage 参数都不见了,但函数照样可以调用。这就表明,ECMAScript函数的参数只是为了方便才写出来的,并不是必须写出来的。与其他语言不同,在ECMAScript中的命名参数不会创建让之后的调用必须匹配的函数签名。这是因为根本不存在验证命名参数的机制。

也可以通过arguments 对象的length 属性检查传入的参数个数。下面的例子展示了在每调用一个函数时,都会打印出传入的参数个数:

function howManyArgs() {
  console.log(arguments.length);
}

howManyArgs("string", 45);  // 2
howManyArgs();              // 0
howManyArgs(12);            // 1

这个例子分别打印出2、0和1(按顺序)。既然如此,那么开发者可以想传多少参数就传多少参数。比如:

function doAdd() {
  if (arguments.length === 1) {
    console.log(arguments[0] + 10);
  } else if (arguments.length === 2) {
    console.log(arguments[0] + arguments[1]);
  }
}

doAdd(10);      // 20
doAdd(30, 20);  // 50

这个函数doAdd() 在只传一个参数时会加10,在传两个参数时会将它们相加,然后返回。因此doAdd(10) 返回20,而doAdd(30,20) 返回50。虽然不像真正的函数重载那么明确,但这已经足以弥补ECMAScript在这方面的缺失了。

还有一个必须理解的重要方面,那就是arguments 对象可以跟命名参数一起使用,比如:

function doAdd(num1, num2) {
  if (arguments.length === 1) {
    console.log(num1 + 10);
  } else if (arguments.length === 2) {
    console.log(arguments[0] + num2);
  }
}

在这个doAdd() 函数中,同时使用了两个命名参数和arguments 对象。命名参数num1 保存着与arugments[0] 一样的值,因此使用谁都无所谓。(同样,num2 也保存着跟arguments[1] 一样的值。)

arguments 对象的另一个有意思的地方就是,它的值始终会与对应的命名参数同步。来看下面的例子:

function doAdd(num1, num2) {
  arguments[1] = 10;
  console.log(arguments[0] + num2);
}

这个doAdd() 函数把第二个参数的值重写为10。因为arguments 对象的值会自动同步到对应的命名参数,所以修改arguments[1] 也会修改num2 的值,因此两者的值都是10。但这并不意味着它们都访问同一个内存地址,它们在内存中还是分开的,只不过会保持同步而已。另外还要记住一点:如果只传了一个参数,然后把arguments[1] 设置为某个值,那么这个值并不会反映到第二个命名参数。这是因为arguments 对象的长度是根据传入的参数个数,而非定义函数时给出的命名参数个数确定的。

对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是undefined 。这就类似于定义了变量而没有初始化。比如,如果只给doAdd() 传了一个参数,那么num2 的值就是undefined

严格模式下,arguments 会有一些变化。首先,像前面那样给arguments[1] 赋值不会再影响num2 的值。就算把arguments[1] 设置为10,num2 的值仍然还是传入的值。其次,在函数中尝试重写arguments 对象会导致语法错误。(代码也不会执行。)

如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用arguments 关键字访问,而只能通过定义的命名参数访问。

function foo() {
  console.log(arguments[0]);
}
foo(5); // 5

let bar = () => {
  console.log(arguments[0]);
};
bar(5);  // ReferenceError: arguments is not defined

虽然箭头函数中没有arguments 对象,但可以在包装函数中把它提供给箭头函数:

function foo() {
  let bar = () => {
    console.log(arguments[0]); // 5
  };
  bar();
}

foo(5);

注意  ECMAScript中的所有参数都按值传递的。不可能按引用传递参数。如果把对象作为参数传递,那么传递的值就是这个对象的引用。

ECMAScript函数不能像传统编程那样重载。在其他语言比如Java中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。如前所述,ECMAScript函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。

如果在ECMAScript中定义了两个同名函数,则后定义的会覆盖先定义的。来看下面的例子:

function addSomeNumber(num) {
  return num + 100;
}

function addSomeNumber(num) {
  return num + 200;
}

let result = addSomeNumber(100); // 300

这里,函数addSomeNumber() 被定义了两次。第一个版本给参数加100,第二个版本加200。最后一行调用这个函数时,返回了300,因为第二个定义覆盖了第一个定义。

前面也提到过,可以通过检查参数的类型和数量,然后分别执行不同的逻辑来模拟函数重载。

把函数名当成指针也有助于理解为什么ECMAScript没有函数重载。在前面的例子中,定义两个同名的函数显然会导致后定义的重写先定义的。而那个例子几乎跟下面这个是一样的:

let addSomeNumber = function(num) {
    return num + 100;
};

addSomeNumber = function(num) {
    return num + 200;
};

let result = addSomeNumber(100); // 300

看这段代码应该更容易理解发生了什么。在创建第二个函数时,变量addSomeNumber 被重写成保存第二个函数对象了。

在ECMAScript5.1及以前,实现默认参数的一种常用方式就是检测某个参数是否等于undefined ,如果是则意味着没有传这个参数,那就给它赋一个值:

function makeKing(name) {
  name = (typeof name !== 'undefined') ? name : 'Henry';
  return `King ${name} VIII`;
}

console.log(makeKing());         // 'King Henry VIII'
console.log(makeKing('Louis'));  // 'King Louis VIII'

ECMAScript 6之后就不用这么麻烦了,因为它支持显式定义默认参数了。下面就是与前面代码等价的ES6写法,只要在函数定义中的参数后面用= 就可以为参数赋一个默认值:

function makeKing(name = 'Henry') {
  return `King ${name} VIII`;
}

console.log(makeKing('Louis'));  // 'King Louis VIII'
console.log(makeKing());         // 'King Henry VIII'

给参数传undefined 相当于没有传值,不过这样可以利用多个独立的默认值:

function makeKing(name = 'Henry', numerals = 'VIII') {
  return `King ${name} ${numerals}`;
}

console.log(makeKing());                 // 'King Henry VIII'
console.log(makeKing('Louis'));          // 'King Louis VIII'
console.log(makeKing(undefined, 'VI'));  // 'King Henry VI'

在使用默认参数时,arguments 对象的值不反映参数的默认值,只反映传给函数的参数。当然,跟ES5严格模式一样,修改命名参数也不会影响arguments 对象,它始终以调用函数时传入的值为准:

function makeKing(name = 'Henry') {
  name = 'Louis';
  return `King ${arguments[0]}`;
}

console.log(makeKing());         // 'King undefined'
console.log(makeKing('Louis'));  // 'King Louis'

默认参数值并不限于原始值或对象类型,也可以使用调用函数返回的值:

let romanNumerals = ['I', 'II', 'III', 'IV', 'V', 'VI'];
let ordinality = 0;

function getNumerals() {
  // 每次调用后递增
  return romanNumerals[ordinality++];
}

function makeKing(name = 'Henry', numerals = getNumerals()) {
  return `King ${name} ${numerals}`;
}

console.log(makeKing());                // 'King Henry I'
console.log(makeKing('Louis', 'XVI'));  // 'King Louis XVI'
console.log(makeKing());                // 'King Henry II'
console.log(makeKing());                // 'King Henry III'

函数的默认参数只有在函数被调用时才会求值,不会在函数定义时求值。而且,计算默认值的函数只有在调用函数但未传相应参数时才会被调用。

箭头函数同样也可以这样使用默认参数,只不过在只有一个参数时,就必须使用括号而不能省略了:

let makeKing = (name = 'Henry') => `King ${name}`;

console.log(makeKing()); // King Henry

因为在求值默认参数时可以定义对象,也可以动态调用函数,所以函数参数肯定是在某个作用域中求值的。

给多个参数定义默认值实际上跟使用let 关键字顺序声明变量一样。来看下面的例子:

function makeKing(name = 'Henry', numerals = 'VIII') {
  return `King ${name} ${numerals}`;
}

console.log(makeKing()); // King Henry VIII

这里的默认参数会按照定义它们的顺序依次被初始化。可以依照如下示例想象一下这个过程:

function makeKing() {
  let name = 'Henry';
  let numerals = 'VIII';

  return `King ${name} ${numerals}`;
}

因为参数是按顺序初始化的,所以后定义默认值的参数可以引用先定义的参数。看下面这个例子:

function makeKing(name = 'Henry', numerals = name) {
  return `King ${name} ${numerals}`;
}

console.log(makeKing()); // King Henry Henry

参数初始化顺序遵循“暂时性死区”规则,即前面定义的参数不能引用后面定义的。像这样就会抛出错误:

// 调用时不传第一个参数会报错
function makeKing(name = numerals, numerals = 'VIII') {
  return `King ${name} ${numerals}`;
}

参数也存在于自己的作用域中,它们不能引用函数体的作用域:

// 调用时不传第二个参数会报错
function makeKing(name = 'Henry', numerals = defaultNumeral) {
  let defaultNumeral = 'VIII';
  return `King ${name} ${numerals}`;
}

ECMAScript 6新增了扩展操作符,使用它可以非常简洁地操作和组合集合数据。扩展操作符最有用的场景就是函数定义中的参数列表,在这里它可以充分利用这门语言的弱类型及参数长度可变的特点。扩展操作符既可以用于调用函数时传参,也可以用于定义函数参数。

在给函数传参时,有时候可能不需要传一个数组,而是要分别传入数组的元素。

假设有如下函数定义,它会将所有传入的参数累加起来:

let values = [1, 2, 3, 4];

function getSum() {
  let sum = 0;
  for (let i = 0; i < arguments.length; ++i) {
    sum += arguments[i];
  }
  return sum;
}

这个函数希望将所有加数逐个传进来,然后通过迭代arguments 对象来实现累加。如果不使用扩展操作符,想把定义在这个函数这面的数组拆分,那么就得求助于apply() 方法:

console.log(getSum.apply(null, values)); // 10

但在ECMAScript 6中,可以通过扩展操作符极为简洁地实现这种操作。对可迭代对象应用扩展操作符,并将其作为一个参数传入,可以将可迭代对象拆分,并将迭代返回的每个值单独传入。

比如,使用扩展操作符可以将前面例子中的数组像这样直接传给函数:

console.log(getSum(...values)); // 10

因为数组的长度已知,所以在使用扩展操作符传参的时候,并不妨碍在其前面或后面再传其他的值,包括使用扩展操作符传其他参数:

console.log(getSum(-1, ...values));          // 9
console.log(getSum(...values, 5));           // 15
console.log(getSum(-1, ...values, 5));       // 14
console.log(getSum(...values, ...[5,6,7]));  // 28

对函数中的arguments 对象而言,它并不知道扩展操作符的存在,而是按照调用函数时传入的参数接收每一个值:

let values = [1,2,3,4]

function countArguments() {
  console.log(arguments.length);
}

countArguments(-1, ...values);          // 5
countArguments(...values, 5);           // 5
countArguments(-1, ...values, 5);       // 6
countArguments(...values, ...[5,6,7]);  // 7

arguments 对象只是消费扩展操作符的一种方式。在普通函数和箭头函数中,也可以将扩展操作符用于命名参数,当然同时也可以使用默认参数:

function getProduct(a, b, c = 1) {
  return a * b * c;
}

let getSum = (a, b, c = 0) => {
  return a + b + c;
}

console.log(getProduct(...[1,2]));      // 2
console.log(getProduct(...[1,2,3]));    // 6
console.log(getProduct(...[1,2,3,4]));  // 6

console.log(getSum(...[0,1]));          // 1
console.log(getSum(...[0,1,2]));        // 3
console.log(getSum(...[0,1,2,3]));      // 3

在构思函数定义时,可以使用扩展操作符把不同长度的独立参数组合为一个数组。这有点类似arguments 对象的构造机制,只不过收集参数的结果会得到一个Array 实例。

function getSum(...values) {
  // 顺序累加values中的所有值
  // 初始值的总和为0
  return values.reduce((x, y) => x + y, 0);
}

console.log(getSum(1,2,3)); // 6

收集参数的前面如果还有命名参数,则只会收集其余的参数;如果没有则会得到空数组。因为收集参数的结果可变,所以只能把它作为最后一个参数:

// 不可以
function getProduct(...values, lastValue) {}

// 可以
function ignoreFirst(firstValue, ...values) {
  console.log(values);
}

ignoreFirst();       // []
ignoreFirst(1);      // []
ignoreFirst(1,2);    // [2]
ignoreFirst(1,2,3);  // [2, 3]

箭头函数虽然不支持arguments 对象,但支持收集参数的定义方式,因此也可以实现与使用arguments 一样的逻辑:

let getSum = (...values) => {
  return values.reduce((x, y) => x + y, 0);
}

console.log(getSum(1,2,3)); // 6

另外,使用收集参数并不影响arguments 对象,它仍然反映调用时传给函数的参数:

function getSum(...values) {
  console.log(arguments.length);  // 3
  console.log(arguments);         // [1, 2, 3]
  console.log(values);            // [1, 2, 3]
}

console.log(getSum(1,2,3));

本章到现在一直没有把函数声明和函数表达式区分得很清楚。事实上,JavaScript引擎在加载数据时对它们是区别对待的。JavaScript引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。来看下面的例子:

// 没问题
console.log(sum(10, 10));
function sum(num1, num2) {
  return num1 + num2;
}

以上代码可以正常运行,因为函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程叫作函数声明提升 (function declaration hoisting)。在执行代码时,JavaScript引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部。因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升 到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:

// 会出错
console.log(sum(10, 10));
let sum = function(num1, num2) {
  return num1 + num2;
};

上面的代码之所以会出错,是因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。这意味着代码如果没有执行到加粗的那一行,那么执行上下文中就没有函数的定义,所以上面的代码会出错。这并不是因为使用let 而导致的,使用var 关键字也会碰到同样的问题:

console.log(sum(10, 10));
var sum = function(num1, num2) {
  return num1 + num2;
};

除了函数什么时候真正有定义这个区别之外,这两种语法是等价的。

注意  在使用函数表达式初始化变量时,也可以给函数一个名称,比如let sum = function sum() {} 。这一点在10.11节讨论函数表达式时会再讨论。

因为函数名在ECMAScript中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。来看下面的例子:

function callSomeFunction(someFunction, someArgument) {
  return someFunction(someArgument);
}

这个函数接收两个参数。第一个参数应该是一个函数,第二个参数应该是要传给这个函数的值。任何函数都可以像下面这样作为参数传递:

function add10(num) {
  return num + 10;
}

let result1 = callSomeFunction(add10, 10);
console.log(result1);  // 20

function getGreeting(name) {
  return "Hello, " + name;
}

let result2 = callSomeFunction(getGreeting, "Nicholas");
console.log(result2);  // "Hello, Nicholas"

callSomeFunction() 函数是通用的,第一个参数传入的是什么函数都可以,而且它始终返回调用作为第一个参数传入的函数的结果。要注意的是,如果是访问函数而不是调用函数,那就必须不带括号,所以传给callSomeFunction() 的必须是add10getGreeting ,而不能是它们的执行结果。

从一个函数中返回另一个函数也是可以的,而且非常有用。例如,假设有一个包含对象的数组,而我们想按照任意对象属性对数组进行排序。为此,可以定义一个sort() 方法需要的比较函数,它接收两个参数,即要比较的值。但这个比较函数还需要想办法确定根据哪个属性来排序。这个问题可以通过定义一个根据属性名来创建比较函数的函数来解决。比如:

function createComparisonFunction(propertyName) {
  return function(object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];

    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

这个函数的语法乍一看比较复杂,但实际上就是在一个函数中返回另一个函数,注意那个return 操作符。内部函数可以访问propertyName 参数,并通过中括号语法取得要比较的对象的相应属性值。取得属性值以后,再按照sort() 方法的需要返回比较值就行了。这个函数可以像下面这样使用:

let data = [
  {name: "Zachary", age: 28},
  {name: "Nicholas", age: 29}
];

data.sort(createComparisonFunction("name"));
console.log(data[0].name);  // Nicholas

data.sort(createComparisonFunction("age"));
console.log(data[0].name);  // Zachary

在上面的代码中,数组data 中包含两个结构相同的对象。每个对象都有一个name 属性和一个age 属性。默认情况下,sort() 方法要对这两个对象执行toString() ,然后再决定它们的顺序,但这样得不到有意义的结果。而通过调用createComparisonFunction("name") 来创建一个比较函数,就可以根据每个对象name 属性的值来排序,结果name 属性值为"Nicholas"age 属性值为29 的对象会排在前面。而调用createComparisonFunction("age") 则会创建一个根据每个对象age 属性的值来排序的比较函数,结果name 属性值为"Zachary"age 属性值为28 的对象会排在前面。

在ECMAScript 5中,函数内部存在两个特殊的对象:argumentsthis 。ECMAScript 6又新增了new.target 属性。

arguments 对象前面讨论过多次了,它是一个类数组对象,包含调用函数时传入的所有参数。这个对象只有以function 关键字定义函数(相对于使用箭头语法创建函数)时才会有。虽然主要用于包含函数参数,但arguments 对象其实还有一个callee 属性,是一个指向arguments 对象所在函数的指针。来看下面这个经典的阶乘函数:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * factorial(num - 1);
  }
}

阶乘函数一般定义成递归调用的,就像上面这个例子一样。只要给函数一个名称,而且这个名称不会变,这样定义就没有问题。但是,这个函数要正确执行就必须保证函数名是factorial ,从而导致了紧密耦合。使用arguments.callee 就可以让函数逻辑与函数名解耦:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * arguments.callee(num - 1);
  }
}

这个重写之后的factorial() 函数已经用arguments.callee 代替了之前硬编码的factorial 。这意味着无论函数叫什么名称,都可以引用正确的函数。考虑下面的情况:

let trueFactorial = factorial;

factorial = function() {
  return 0;
};

console.log(trueFactorial(5));  // 120
console.log(factorial(5));      // 0

这里,trueFactorial 变量被赋值为factorial ,实际上把同一个函数的指针又保存到了另一个位置。然后,factorial 函数又被重写为一个返回0 的函数。如果像factorial() 最初的版本那样不使用arguments.callee ,那么像上面这样调用trueFactorial() 就会返回0 。不过,通过将函数与名称解耦,trueFactorial() 就可以正确计算阶乘,而factorial() 则只能返回0。

另一个特殊的对象是this ,它在标准函数和箭头函数中有不同的行为。

在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为this 值(在网页的全局上下文中调用函数时,this 指向windows )。来看下面的例子:

window.color = 'red';
let o = {
  color: 'blue'
};

function sayColor() {
  console.log(this.color);
}

sayColor();    // 'red'

o.sayColor = sayColor;
o.sayColor();  // 'blue'

定义在全局上下文中的函数sayColor() 引用了this 对象。这个this 到底引用哪个对象必须到函数被调用时才能确定。因此这个值在代码执行的过程中可能会变。如果在全局上下文中调用sayColor() ,这结果会输出"red" ,因为this 指向window ,而this.color 相当于window.color 。而在把sayColor() 赋值给o 之后再调用o.sayColor()this 会指向o ,即this.color 相当于o.color ,所以会显示"blue"

在箭头函数中,this 引用的是定义箭头函数的上下文。下面的例子演示了这一点。在对sayColor() 的两次调用中,this 引用的都是window 对象,因为这个箭头函数是在window 上下文中定义的:

window.color = 'red';
let o = {
  color: 'blue'
};

let sayColor = () => console.log(this.color);

sayColor();    // 'red'

o.sayColor = sayColor;
o.sayColor();  // 'red'

有读者知道,在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。此时将回调函数写成箭头函数就可以解决问题。这是因为箭头函数中的this 会保留定义该函数时的上下文:

function King() {
  this.royaltyName = 'Henry';
  // this引用King的实例
  setTimeout(() => console.log(this.royaltyName), 1000);
}

function Queen() {
  this.royaltyName = 'Elizabeth';

  // this引用window对象
  setTimeout(function() { console.log(this.royaltyName); }, 1000);
}

new King();  // Henry
new Queen(); // undefined

注意  函数名只是保存指针的变量。因此全局定义的sayColor() 函数和o.sayColor() 是同一个函数,只不过执行的上下文不同。

ECMAScript 5也会给函数对象上添加一个属性:caller 。虽然ECMAScript 3中并没有定义,但所有浏览器除了早期版本的Opera都支持这个属性。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null 。比如:

function outer() {
  inner();
}

function inner() {
  console.log(inner.caller);
}
outer();

以上代码会显示outer() 函数的源代码。这是因为ourter() 调用了inner()inner.caller 指向outer() 。如果要降低耦合度,则可以通过arguments.callee.caller 来引用同样的值:

function outer() {
  inner();
}

function inner() {
  console.log(arguments.callee.caller);
}

outer();

在严格模式下访问arguments.callee 会报错。ECMAScript 5也定义了arguments.caller ,但在严格模式下访问它会报错,在非严格模式下则始终是undefined 。这是为了分清arguments.caller 和函数的caller 而故意为之的。而作为对这门语言的安全防护,这些改动也让第三方代码无法检测同一上下文中运行的其他代码。

严格模式下还有一个限制,就是不能给函数的caller 属性赋值,否则会导致错误。

ECMAScript中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6新增了检测函数是否使用new 关键字调用的new.target 属性。如果函数是正常调用的,则new.target 的值是undefined ;如果是使用new 关键字调用的,则new.target 将引用被调用的构造函数。

function King() {
  if (!new.target) {
    throw 'King must be instantiated using "new"'
  }
  console.log('King instantiated using "new"');
}

new King(); // King instantiated using "new"
King();     // Error: King must be instantiated using "new"

前面提到过,ECMAScript中的函数是对象,因此有属性和方法。每个函数都有两个属性:lengthprototype 。其中,length 属性保存函数定义的命名参数的个数,如下例所示:

function sayName(name) {
  console.log(name);
}

function sum(num1, num2) {
  return num1 + num2;
}

function sayHi() {
  console.log("hi");
}

console.log(sayName.length);  // 1
console.log(sum.length);      // 2
console.log(sayHi.length);    // 0

以上代码定义了3个函数,每个函数的命名参数个数都不一样。sayName() 函数有1个命名参数,所以其length 属性为1。类似地,sum() 函数有两个命名参数,所以其length 属性是2。而sayHi() 没有命名参数,其length 属性为0。

prototype 属性也许是ECMAScript核心中最有趣的部分。prototype 是保存引用类型所有实例方法的地方,这意味着toString()valueOf() 等方法实际上都保存在prototype 上,进而由所有实例共享。这个属性在自定义类型时特别重要。(相关内容已经在第8章详细介绍过了。)在ECMAScript 5中,prototype 属性是不可枚举的,因此使用for-in 循环不会返回这个属性。

函数还有两个方法:apply()call() 。这两个方法都会以指定的this 值来调用函数,即会设置调用函数时函数体内this 对象的值。apply() 方法接收两个参数:函数内this 的值和一个参数数组。第二个参数可以是Array 的实例,但也可以是arguments 对象。来看下面的例子:

function sum(num1, num2) {
  return num1 + num2;
}

function callSum1(num1, num2) {
  return sum.apply(this, arguments); // 传入arguments对象
}

function callSum2(num1, num2) {
  return sum.apply(this, [num1, num2]); // 传入数组
}

console.log(callSum1(10, 10));  // 20
console.log(callSum2(10, 10));  // 20

在这个例子中,callSum1() 会调用sum() 函数,将this 作为函数体内的this 值(这里等于window ,因为是在全局作用域中调用的)传入,同时还传入了arguments 对象。callSum2() 也会调用sum() 函数,但会传入参数的数组。这两个函数都会执行并返回正确的结果。

注意  在严格模式下,调用函数时如果没有指定上下文对象,则this 值不会指向window 。除非使用apply()call() 把函数指定给一个对象,否则this 的值会变成undefined

call() 方法与apply() 的作用一样,只是传参的形式不同。第一个参数跟apply() 一样,也是this 值,而剩下的要传给被调用函数的参数则是逐个传递的。换句话说,通过call() 向函数传参时,必须将参数一个一个地列出来,比如:

function sum(num1, num2) {
  return num1 + num2;
}

function callSum(num1, num2) {
  return sum.call(this, num1, num2);
}

console.log(callSum(10, 10)); // 20

这里的callSum() 函数必须逐个地把参数传给call() 方法。结果跟apply() 的例子一样。到底是使用apply() 还是call() ,完全取决于怎么给要调用的函数传参更方便。如果想直接传arguments 对象或者一个数组,那就用apply() ;否则,就用call() 。当然,如果不用给被调用的函数传参,则使用哪个方法都一样。

apply()call() 真正强大的地方并不是给函数传参,而是控制函数调用上下文即函数体内this 值的能力。考虑下面的例子:

window.color = 'red';
let o = {
  color: 'blue'
};

function sayColor() {
  console.log(this.color);
}

sayColor();             // red

sayColor.call(this);    // red
sayColor.call(window);  // red
sayColor.call(o);       // blue

这个例子是在之前那个关于this 对象的例子基础上修改而成的。同样,sayColor() 是一个全局函数,如果在全局作用域中调用它,那么会显示"red" 。这是因为this.color 会求值为window.color 。如果在全局作用域中显式调用sayColor.call(this) 或者sayColor.call(window) ,则同样都会显示"red" 。而在使用sayColor.call(o) 把函数的执行上下文即this 切换为对象o 之后,结果就变成了显示"blue" 了。

使用call()apply() 的好处是可以将任意对象设置为任意函数的作用域,这样对象可以不用关心方法。在前面例子最初的版本中,为切换上下文需要先把sayColor() 直接赋值为o 的属性,然后再调用。而在这个修改后的版本中,就不需要这一步操作了。

ECMAScript 5出于同样的目的定义了一个新方法:bind()bind() 方法会创建一个新的函数实例,其this 值会被绑定 到传给bind() 的对象。比如:

window.color = 'red';
var o = {
  color: 'blue'
};

function sayColor() {
  console.log(this.color);
}
let objectSayColor = sayColor.bind(o);
objectSayColor();  // blue

这里,在sayColor() 上调用bind() 并传入对象o 创建了一个新函数objectSayColor()objectSayColor() 中的this 值被设置为o ,因此直接调用这个函数,即使是在全局作用域中调用,也会返回字符串"blue"

对函数而言,继承的方法toLocaleString()toString() 始终返回函数的代码。返回代码的具体格式因浏览器而异。有的返回源代码,包含注释,而有的只返回代码的内部形式,会删除注释,甚至代码可能被解释器修改过。由于这些差异,因此不能在重要功能中依赖这些方法返回的值,而只应在调试中使用它们。继承的方法valueOf() 返回函数本身。

函数表达式虽然更强大,但也更容易让人迷惑。我们知道,定义函数有两种方式:函数声明和函数表达式。函数声明是这样的:

function functionName(arg0, arg1, arg2) {
  // 函数体
}

函数声明的关键特点是函数声明提升 ,即函数声明会在代码执行之前获得定义。这意味着函数声明可以出现在调用它的代码之后:

sayHi();
function sayHi() {
  console.log("Hi!");
}

这个例子不会抛出错误,因为JavaScript引擎会先读取函数声明,然后再执行代码。

第二种创建函数的方式就是函数表达式。函数表达式有几种不同的形式,最常见的是这样的:

let functionName = function(arg0, arg1, arg2) {
  // 函数体
};

函数表达式看起来就像一个普通的变量定义和赋值,即创建一个函数再把它赋值给一个变量functionName 。这样创建的函数叫作匿名函数 (anonymous funtion),因为function 关键字后面没有标识符。(匿名函数有也时候也被称为兰姆达函数 )。未赋值给其他变量的匿名函数的name 属性是空字符串。

函数表达式跟JavaScript中的其他表达式一样,需要先赋值再使用。下面的例子会导致错误:

sayHi();  // Error! function doesn't exist yet
let sayHi = function() {
  console.log("Hi!");
};

理解函数声明与函数表达式之间的区别,关键是理解提升。比如,以下代码的执行结果可能会出乎意料:

// 千万别这样做!
if (condition) {
  function sayHi() {
    console.log('Hi!');
  }
} else {
  function sayHi() {
    console.log('Yo!');
  }
}

这段代码看起来很正常,就是如果conditiontrue ,则使用第一个sayHi() 定义;否则,就使用第二个。事实上,这种写法在ECAMScript中不是有效的语法。JavaScript引擎会尝试将其纠正为适当的声明。问题在于浏览器纠正这个问题的方式并不一致。多数浏览器会忽略condition 直接返回第二个声明。Firefox会在conditiontrue 时返回第一个声明。这种写法很危险,不要使用。不过,如果把上面的函数声明换成函数表达式就没问题了:

// 没问题
let sayHi;
if (condition) {
  sayHi = function() {
    console.log("Hi!");
  };
} else {
  sayHi = function() {
    console.log("Yo!");
  };
}

这个例子可以如预期一样,根据condition 的值为变量sayHi 赋予相应的函数。

创建函数并赋值给变量的能力也可以用于在一个函数中把另一个函数当作值返回:

function createComparisonFunction(propertyName) {
  return function(object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];

    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

这里的createComparisonFunction() 函数返回一个匿名函数,这个匿名函数要么被赋值给一个变量,要么可以直接调用。但在createComparisonFunction() 内部,那个函数是匿名的。任何时候,只要函数被当作值来使用,它就是一个函数表达式。本章后面会介绍,这并不是使用函数表达式的唯一方式。

递归函数 通常的形式是一个函数通过名称调用自己,如下面的例子所示:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * factorial(num - 1);
  }
}

这是经典的递归阶乘函数。虽然这样写是可以的,但如果把这个函数赋值给其他变量,就会出问题:

let anotherFactorial = factorial;
factorial = null;
console.log(anotherFactorial(4));  // 报错

这里把factorial() 函数保存在了另一个变量anotherFactorial 中,然后将factorial 设置为null ,于是只保留了一个对原始函数的引用。而在调用anotherFactorial() 时,要递归调用factorial() ,但因为它已经不是函数了,所以会出错。在写递归函数时使用arguments.callee 可以避免这个问题。

arguments.callee 就是一个指向正在执行的函数的指针,因此可以在函数内部递归调用,如下所示:

function factorial(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * arguments.callee(num - 1);
  }
}

像这里加粗的这一行一样,把函数名称替换成arguments.callee ,可以确保无论通过什么变量调用这个函数都不会出问题。因此在编写递归函数时,arguments.callee 是引用当前函数的首选。

不过,在严格模式下运行的代码是不能访问arguments.callee 的,因为访问会出错。此时,可以使用命名函数表达式(named function expression)达到目的。比如:

const factorial = (function f(num) {
  if (num <= 1) {
    return 1;
  } else {
    return num * f(num - 1);
  }
});

这里创建了一个命名函数表达式f() ,然后将它赋值给了变量factorial 。即使把函数赋值给另一个变量,函数表达式的名称f 也不变,因此递归调用不会有问题。这个模式在严格模式和非严格模式下都可以使用。

ECMAScript 6规范新增了一项内存管理优化机制,让JavaScript引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。比如:

function outerFunction() {
  return innerFunction(); // 尾调用
}

在ES6优化之前,执行这个例子会在内存中发生如下操作。

(1) 执行到outerFunction 函数体,第一个栈帧被推到栈上。

(2) 执行outerFunction 函数体,到return 语句。计算返回值必须先计算innerFunction

(3) 执行到innerFunction 函数体,第二个栈帧被推到栈上。

(4) 执行innerFunction 函数体,计算其返回值。

(5) 将返回值传回outerFunction ,然后outerFunction 再返回值。

(6) 将栈帧弹出栈外。

在ES6优化之后,执行这个例子会在内存中发生如下操作。

(1) 执行到outerFunction 函数体,第一个栈帧被推到栈上。

(2) 执行outerFunction 函数体,到达return 语句。为求值返回语句,必须先求值innerFunction

(3) 引擎发现把第一个栈帧弹出栈外也没问题,因为innerFunction 的返回值也是outerFunction 的返回值。

(4) 弹出outerFunction 的栈帧。

(5) 执行到innerFunction 函数体,栈帧被推到栈上。

(6) 执行innerFunction 函数体,计算其返回值。

(7) 将innerFunction 的栈帧弹出栈外。

很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下无论调用多少次嵌套函数,都只有一个栈帧。这就是ES6尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

注意  现在还没有办法测试尾调用优化是否起作用。不过,因为这是ES6规范所规定的,兼容的浏览器实现都能保证在代码满足条件的情况下应用这个优化。

尾调用优化的条件就是确定外部栈帧真的没有必要存在了。涉及的条件如下:

下面展示了几个违反上述条件的函数,因此都不符号尾调用优化的要求:

"use strict";

// 无优化:尾调用没有返回
function outerFunction() {
  innerFunction();
}

// 无优化:尾调用没有直接返回
function outerFunction() {
  let innerFunctionResult = innerFunction();
  return innerFunctionResult;
}

// 无优化:尾调用返回后必须转型为字符串
function outerFunction() {
  return innerFunction().toString();
}

// 无优化:尾调用是一个闭包
function outerFunction() {
  let foo = 'bar';
  function innerFunction() { return foo; }

  return innerFunction();
}

下面是几个符合尾调用优化条件的例子:

"use strict";

// 有优化:栈帧销毁前执行参数计算
function outerFunction(a, b) {
  return innerFunction(a + b);
}

// 有优化:初始返回值不涉及栈帧
function outerFunction(a, b) {
  if (a < b) {
    return a;
  }
  return innerFunction(a + b);
}

// 有优化:两个内部函数都在尾部
function outerFunction(condition) {
  return condition ? innerFunctionA() : innerFunctionB();
}

差异化尾调用和递归尾调用是容易让人混淆的地方。无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。

注意  之所以要求严格模式,主要因为在非严格模式下函数调用中允许使用f.argumentsf.caller ,而它们都会引用外部函数的栈帧。显然,这意味着不能应用优化了。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

可以通过把简单的递归函数转换为待优化的代码来加深对尾调用优化的理解。下面是一个通过递归计算斐波纳契数列的函数:

function fib(n) {
  if (n < 2) {
    return n;
  }

  return fib(n - 1) + fib(n - 2);
}

console.log(fib(0));  // 0
console.log(fib(1));  // 1
console.log(fib(2));  // 1
console.log(fib(3));  // 2
console.log(fib(4));  // 3
console.log(fib(5));  // 5
console.log(fib(6));  // 8

显然这个函数不符合尾调用优化的条件,因为返回语句中有一个相加的操作。结果,fib(n) 的栈帧数的内存复杂度是O(2^n) 。因此,即使这么一个简单的调用也可以给浏览器带来麻烦:

fib(1000);

当然,解决这个问题也有不同的策略,比如把递归改写成迭代循环形式。不过,也可以保持递归实现,但将其重构为满足优化条件的形式。为此可以使用两个嵌套的函数,外部函数作为基础框架,内部函数执行递归:

"use strict";

// 基础框架
function fib(n) {
  return fibImpl(0, 1, n);
}

// 执行递归
function fibImpl(a, b, n) {
  if (n === 0) {
    return a;
  }
  return fibImpl(b, a + b, n - 1);
}

这样重构之后,就可以满足尾调用优化的所有条件,再调用fib(1000) 就不会对浏览器造成威胁了。

匿名函数经常被人误认为是闭包(closure)。闭包 指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。比如,下面是之前展示的createComparisonFunction() 函数,注意其中加粗的代码:

function createComparisonFunction(propertyName) {
  return function(object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];

    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

这里加粗的代码位于内部函数(匿名函数)中,其中引用了外部函数的变量propertyName 。在这个内部函数被返回并在其他地方被使用后,它仍然引用着那个变量。这是因为内部函数的作用域链包含createComparisonFunction() 函数的作用域。要理解为什么会这样,可以想想第一次调用这个函数时会发生什么。

本书在第4章曾介绍过作用域链的概念。理解作用域链创建和使用的细节对理解闭包非常重要。在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。然后用arguments 和其他命名参数来初始化这个函数的活动对象。外部函数的活动对象是内部函数作用域链上的第二个对象。这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。

在函数执行时,要从作用域链中查找变量,以便读、写值。来看下面的代码:

function compare(value1, value2) {
  if (value1 < value2) {
    return -1;
  } else if (value1 > value2) {
    return 1;
  } else {
    return 0;
  }
}

let result = compare(5, 10);

这里定义的compare() 函数是在全局上下文中调用的。第一次调用compare() 时,会为它创建一个包含argumentsvalue1value2 的活动对象,这个对象是其作用域链上的第一个对象。而全局上下文的变量对象则是compare() 作用域链上的第二个对象,其中包含thisresultcompare 。图10-1展示了以上关系。

{%}

图 10-1

函数执行时,每个执行上下文中都会有一个包含其中变量的对象。全局上下文中的叫变量对象,它会在代码执行期间始终存在。而函数局部上下文中的叫活动对象,只在函数执行期间存在。在定义compare() 函数时,就会为它创建作用域链,预装载全局变量对象,并保存在内部的[[Scope]] 中。在调用这个函数时,会创建相应的执行上下文,然后通过复制函数的[[Scope]] 来创建其作用域链。接着会创建函数的活动对象(用作变量对象)并将其推入作用域链的前端。在这个例子中,这意味着compare() 函数执行上下文的作用域链中有两个变量对象:局部变量对象和全局变量对象。作用域链其实是一个包含指针的列表,每个指针分别指向一个变量对象,但物理上并不会包含相应的对象。

函数内部的代码在访问变量时,就会使用给定的名称从作用域链中查找变量。函数执行完毕后,局部活动对象会被销毁,内存中就只剩下全局作用域。不过,闭包就不一样了。

在一个函数内部定义的函数会把其包含函数的活动对象添加到自己的作用域链中。因此,在createComparisonFunction() 函数中,匿名函数的作用域链中实际上包含createComparisonFunction() 的活动对象。图10-2展示了以下代码执行后的结果。

let compare = createComparisonFunction('name');
let result = compare({ name: 'Nicholas' }, { name: 'Matt' });

{%}

图 10-2

createComparisonFunction() 返回匿名函数后,它的作用域链被初始化为包含createComparisonFunction() 的活动对象和全局变量对象。这样,匿名函数就可以访问到createComparisonFunction() 可以访问的所有变量。另一个有意思的副作用就是,createComparisonFunction() 的活动对象并不能在它执行完毕后销毁,因为匿名函数的作用域链中仍然有对它的引用。在createComparisonFunction() 执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留在内存中,直到匿名函数被销毁后才会被销毁:

// 创建比较函数
let compareNames = createComparisonFunction('name');

// 调用函数
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' });

// 解除对函数的引用,这样就可以释放内存了
compareNames = null;

这里,创建的比较函数被保存在变量compareNames 中。把compareNames 设置为等于null 会解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。作用域链也会被销毁,其他作用域(除全局作用域之外)也可以销毁。图10-2展示了调用compareNames() 之后作用域链之间的关系。

注意  因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8等优化的JavaScript引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。

在闭包中使用this 会让代码变复杂。如果内部函数没有使用箭头函数定义,则this 对象会在运行时绑定到执行函数的上下文。如果在全局函数中调用,则this 在非严格模式下等于window ,在严格模式下等于undefined 。如果作为某个对象的方法调用,则this 等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着this 会指向window ,除非在严格模式下thisundefined 。不过,由于闭包的写法所致,这个事实有时候没有那么容易看出来。来看下面的例子:

window.identity = 'The Window';

let object = {
  identity: 'My Object',
  getIdentityFunc() {
    return function() {
      return this.identity;
    };
  }
};

console.log(object.getIdentityFunc()()); // 'The Window'

这里先创建了一个全局变量identity ,之后又创建一个包含identity 属性的对象。这个对象还包含一个getIdentityFunc() 方法,返回一个匿名函数。这个匿名函数返回this.identity 。因为getIdentityFunc() 返回函数,所以object.getIdentityFunc()() 会立即调用这个返回的函数,从而得到一个字符串。可是,此时返回的字符串是"The Winodw" ,即全局变量identity 的值。为什么匿名函数没有使用其包含作用域(getIdentityFunc() )的this 对象呢?

前面介绍过,每个函数在被调用时都会自动创建两个特殊变量:thisarguments 。内部函数永远不可能直接访问外部函数的这两个变量。但是,如果把this 保存到闭包可以访问的另一个变量中,则是行得通的。比如:

window.identity = 'The Window';

let object = {
  identity: 'My Object',
  getIdentityFunc() {
    let that = this;
    return function() {
      return that.identity;
    };
  }
};

console.log(object.getIdentityFunc()()); // 'My Object'

这里加粗的代码展示了与前面那个例子的区别。在定义匿名函数之前,先把外部函数的this 保存到变量that 中。然后在定义闭包时,就可以让它访问that ,因为这是包含函数中名称没有任何冲突的一个变量。即使在外部函数返回之后,that 仍然指向object ,所以调用object.getIdentityFunc()() 就会返回"My Object"

注意  thisarguments 都是不能直接在内部函数中访问的。如果想访问包含作用域中的arguments 对象,则同样需要将其引用先保存到闭包能访问的另一个变量中。

在一些特殊情况下,this 值可能并不是我们所期待的值。比如下面这个修改后的例子:

window.identity = 'The Window';
let object = {
  identity: 'My Object',
  getIdentity () {
    return this.identity;
  }
};

getIdentity() 方法就是返回this.identity 的值。以下是几种调用object.getIdentity() 的方式及返回值:

object.getIdentity();                         // 'My Object'
(object.getIdentity)();                       // 'My Object'
(object.getIdentity = object.getIdentity)();  // 'The Window'

第一行调用object.getIdentity() 是正常调用,会返回"My Object" ,因为this.identity 就是object.identity 。第二行在调用时把object.getIdentity 放在了括号里。虽然加了括号之后看起来是对一个函数的引用,但this 值并没有变。这是因为按照规范,object.getIdentity(object.getIdentity) 是相等的。第三行执行了一次赋值,然后再调用赋值后的结果。因为赋值表达式的值是函数本身,this 值不再与任何对象绑定,所以返回的是"The Window"

一般情况下,不大可能像第二行和第三行这样调用对象上的方法。但通过这个例子,我们可以知道,即使语法稍有不同,也可能影响this 的值。

由于IE在IE9之前对JScript对象和COM对象使用了不同的垃圾回收机制(第4章讨论过),所以闭包在这些旧版本IE中可能会导致问题。在这些版本的IE中,把HTML元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。来看下面的例子:

function assignHandler() {
  let element = document.getElementById('someElement');
  element.onclick = () => console.log(element.id);
}

以上代码创建了一个闭包,即element 元素的事件处理程序(事件处理程序将在第13章讨论)。而这个处理程序又创建了一个循环引用。匿名函数引用着assignHandler() 的活动对象,阻止了对element 的引用计数归零。只要这个匿名函数存在,element 的引用计数就至少等于1。也就是说,内存不会被回收。其实只要这个例子稍加修改,就可以避免这种情况,比如:

function assignHandler() {
  let element = document.getElementById('someElement');
  let id = element.id;

  element.onclick = () => console.log(id);

  element = null;
}

在这个修改后的版本中,闭包改为引用一个保存着element.id 的变量id ,从而消除了循环引用。不过,光有这一步还不足以解决内存问题。因为闭包还是会引用包含函数的活动对象,而其中包含element 。即使闭包没有直接引用element ,包含函数的活动对象上还是保存着对它的引用。因此,必须再把element 设置为null 。这样就解除了对这个COM对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。

立即调用的匿名函数又被称作立即调用的函数表达式 (IIFE,Immediately Invoked Function Expression)。它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。下面是一个简单的例子:

(function() {
  // 块级作用域
})();

使用IIFE可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。ECMAScript 5尚未支持块级作用域,使用IIFE模拟块级作用域是相当普遍的。比如下面的例子:

// IIFE
(function () {
  for (var i = 0; i < count; i++) {
    console.log(i);
  }
})();

console.log(i);  // 抛出错误

前面的代码在执行到IIFE外部的console.log() 时会出错,因为它访问的变量是在IIFE内部定义的,在外部访问不到。在ECMAScript 5.1及以前,为了防止变量定义外泄,IIFE是个非常有效的方式。这样也不会导致闭包相关的内存问题,因为不存在对这个匿名函数的引用。为此,只要函数执行完毕,其作用域链就可以被销毁。

在ECMAScript 6以后,IIFE就没有那么必要了,因为块级作用域中的变量无须IIFE就可以实现同样的隔离。下面展示了两种不同的块级作用域形式:

// 内嵌块级作用域
{
  let i;
  for (i = 0; i < count; i++) {
    console.log(i);
  }
}
console.log(i); // 抛出错误

// 循环的块级作用域
for (let i = 0; i < count; i++) {
  console.log(i);
}

console.log(i); // 抛出错误

说明IIFE用途的一个实际的例子,就是可以用它锁定参数值。比如:

let divs = document.querySelectorAll('div');

// 达不到目的!
for (var i = 0; i < divs.length; ++i) {
  divs[i].addEventListener('click', function() {
    console.log(i);
  });
}

这里使用var 关键字声明了循环迭代变量i ,但这个变量并不会被限制在for 循环的块级作用域内。因此,渲染到页面上之后,点击每个<div> 都会弹出元素总数。这是因为在执行单击处理程序时,迭代变量的值是循环结束时的最终值,即元素的个数。而且,这个变量i 存在于循环体外部,随时可以访问。

以前,为了实现点击第几个<div> 就显示相应的索引值,需要借助IIFE来执行一个函数表达式,传入每次循环的当前索引,从而“锁定”点击时应该显示的索引值:

let divs = document.querySelectorAll('div');

for (var i = 0; i < divs.length; ++i) {
  divs[i].addEventListener('click', (function(frozenCounter) {
    return function() {
      console.log(frozenCounter);
    };
  })(i));
}

而使用ECMAScript块级作用域变量,就不用这么大动干戈了:

let divs = document.querySelectorAll('div');

for (let i = 0; i < divs.length; ++i) {
  divs[i].addEventListener('click', function() {
    console.log(i);
  });
}

这样就可以让每次点击都显示正确的索引了。这里,事件处理程序执行时就会引用for 循环块级作用域中的索引值。这是因为在ECMAScript 6中,如果对for 循环使用块级作用域变量关键字,在这里就是let ,那么循环就会为每个循环创建独立的变量,从而让每个单击处理程序都能引用特定的索引。

但要注意,如果把变量声明拿到for 循环外部,那就不行了。下面这种写法会碰到跟在循环中使用var i = 0 同样的问题:

let divs = document.querySelectorAll('div');

// 达不到目的!
let i;
for (i = 0; i < divs.length; ++i) {
  divs[i].addEventListener('click', function() {
    console.log(i);
  });
}

严格来讲,JavaScript没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量 的概念。任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。来看下面的例子:

function add(num1, num2) {
  let sum = num1 + num2;
  return sum;
}

在这个函数中,函数add() 有3个私有变量:num1num2sum 。这几个变量只能在函数内部使用,不能在函数外部访问。如果这个函数中创建了一个闭包,则这个闭包能通过其作用域链访问其外部的这3个变量。基于这一点,就可以创建出能够访问私有变量的公有方法。

特权方法 (privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。第一种是在构造函数中实现,比如:

function MyObject() {
  // 私有变量和私有函数
  let privateVariable = 10;

  function privateFunction() {
    return false;
  }

  // 特权方法
  this.publicMethod = function() {
    privateVariable++;
    return privateFunction();
  };
}

这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法。这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。在这个例子中,变量privateVariable 和函数privateFunction() 只能通过publicMethod() 方法来访问。在创建MyObject 的实例后,没有办法直接访问privateVariableprivateFunction() ,唯一的办法是使用publicMethod()

如下面的例子所示,可以定义私有变量和特权方法,以隐藏不能被直接修改的数据:

function Person(name) {
  this.getName = function() {
    return name;
  };

  this.setName = function (value) {
    name = value;
  };
}

let person = new Person('Nicholas');
console.log(person.getName());  // 'Nicholas'
person.setName('Greg');
console.log(person.getName());  // 'Greg'

这段代码中的构造函数定义了两个特权方法:getName()setName() 。每个方法都可以构造函数外部调用,并通过它们来读写私有的name 变量。在Person 构造函数外部,没有别的办法访问name 。因为两个方法都定义在构造函数内部,所以它们都是能够通过作用域链访问name 的闭包。私有变量name 对每个Person 实例而言都是独一无二的,因为每次调用构造函数都会重新创建一套变量和方法。不过这样也有个问题:必须通过构造函数来实现这种隔离。正如第8章所讨论的,构造函数模式的缺点是每个实例都会重新创建一遍新方法。使用静态私有变量实现特权方法可以避免这个问题。

特权方法也可以通过使用私有作用域定义私有变量和函数来实现。这个模式如下所示:

(function() {
  // 私有变量和私有函数
  let privateVariable = 10;

  function privateFunction() {
    return false;
  }

  // 构造函数
  MyObject = function() {};

  // 公有和特权方法
  MyObject.prototype.publicMethod = function() {
    privateVariable++;
    return privateFunction();
  };
})();

在这个模式中,匿名函数表达式创建了一个包含构造函数及其方法的私有作用域。首先定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。公有方法定义在构造函数的原型上,与典型的原型模式一样。注意,这个模式定义的构造函数没有使用函数声明,使用的是函数表达式。函数声明会创建内部函数,在这里并不是必需的。基于同样的原因(但操作相反),这里声明MyObject 并没有使用任何关键字。因为不使用关键字声明的变量会创建在全局作用域中,所以MyObject 变成了全局变量,可以在这个私有作用域外部被访问。注意在严格模式下给未声明的变量赋值会导致错误。

这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。来看下面的例子:

(function() {
  let name = '';

  Person = function(value) {
    name = value;
  };

  Person.prototype.getName = function() {
    return name;
  };

  Person.prototype.setName = function(value) {
    name = value;
  };
})();

let person1 = new Person('Nicholas');
console.log(person1.getName());  // 'Nicholas'
person1.setName('Matt');
console.log(person1.getName());  // 'Matt'

let person2 = new Person('Michael');
console.log(person1.getName());  // 'Michael'
console.log(person2.getName());  // 'Michael'

这里的Person 构造函数可以访问私有变量name ,跟getName()setName() 方法一样。使用这种模式,name 变成了静态变量,可供所有实例使用。这意味着在任何实例上调用setName() 修改这个变量都会影响其他实例。调用 setName() 或创建新的Person 实例都要把name 变量设置为一个新值。而所有实例都会返回相同的值。

像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。

注意  使用闭包和私有变量会导致作用域链变长,作用域链越长,则查找变量所需的时间也越多。

前面的模式通过自定义类型创建了私有变量和特权方法。而下面要讨论的Douglas Crockford所说的模块模式,则在一个单例对象上实现了相同的隔离和封装。单例对象(singleton)就是只有一个实例的对象。按照惯例,JavaScript是通过对象字面量来创建单例对象的,如下面的例子所示:

let singleton = {
  name: value,
  method() {
    // 方法的代码
  }
};

模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。模块模式的样板代码如下:

let singleton = function() {
  // 私有变量和私有函数
  let privateVariable = 10;

  function privateFunction() {
    return false;
  }

  // 特权/公有方法和属性
  return {
    publicProperty: true,

    publicMethod() {
      privateVariable++;
      return privateFunction();
    }
  };
}();

模块模式使用了匿名函数返回一个对象。在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。本质上,对象字面量定义了单例对象的公共接口。如果单例对象需要进行某种初始化,并且需要访问私有变量时,那就可以采用这个模式:

let application = function() {
  // 私有变量和私有函数
  let components = new Array();

  // 初始化
  components.push(new BaseComponent());

  // 公共接口
  return {
    getComponentCount() {
      return components.length;
    },
    registerComponent(component) {
      if (typeof component == 'object') {
        components.push(component);
      }
    }
  };
}();

在Web开发中,经常需要使用单例对象管理应用程序级的信息。上面这个简单的例子创建了一个application 对象用于管理组件。在创建这个对象之后,内部就会创建一个私有的数组components ,然后将一个BaseComponent 组件的新实例添加到数组中。(BaseComponent 组件的代码并不重要,在这里用它只是为了说明模块模式的用法。)对象字面量中定义的getComponentCount()registerComponent() 方法都是可以访问components 私有数组的特权方法。前一个方法返回注册组件的数量,后一个方法负责注册新组件。

在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。以这种方式创建的每个单例对象都是Object 的实例,因为最终单例都由一个对象字面量来表示。不过这无关紧要,因为单例对象通常是可以全局访问的,而不是作为参数传给函数的,所以可以避免使用instanceof 操作符确定参数是不是对象类型的需求。

另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。来看下面的例子:

let singleton = function() {
  // 私有变量和私有函数
  let privateVariable = 10;

  function privateFunction() {
    return false;
  }

  // 创建对象
  let object = new CustomType();

  // 添加特权/公有属性和方法
  object.publicProperty = true;

  object.publicMethod = function() {
    privateVariable++;
    return privateFunction();
  };

  // 返回对象
  return object;
}();

如果前一节的application 对象必须是BaseComponent 的实例,那么就可以使用下面的代码来创建它:

let application = function() {
  // 私有变量和私有函数
  let components = new Array();

  // 初始化
  components.push(new BaseComponent());

  // 创建局部变量保存实例
  let app = new BaseComponent();

  // 公共接口
  app.getComponentCount = function() {
    return components.length;
  };

  app.registerComponent = function(component) {
    if (typeof component == "object") {
      components.push(component);
    }
  };

  // 返回实例
  return app;
}();

在这个重写的application 单例对象的例子中,首先定义了私有变量和私有函数,跟之前例子中一样。主要区别在于这里创建了一个名为app 的变量,其中保存了BaseComponent 组件的实例。这是最终要变成application 的那个对象的局部版本。在给这个局部变量app 添加了能够访问私有变量的公共方法之后,匿名函数返回了这个对象。然后,这个对象被赋值给application

函数是JavaScript编程中最有用也最通用的工具。ECMAScript 6新增了更加强大的语法特性,从而让开发者可以更有效地使用函数。


第 11 章 期约与异步函数

本章内容

ECMAScript 6及之后的几个版本逐步加大了对异步编程机制的支持,提供了令人眼前一亮的新特性。ECMAScript 6新增了正式的Promise (期约)引用类型,支持优雅地定义和组织异步逻辑。接下来几个版本增加了使用asyncawait 关键字定义异步函数的机制。

注意  本章示例将大量使用异步日志输出的方式setTimeout(console.log, 0, .. params) ,旨在演示执行顺序及其他异步行为。异步输出的内容看起来虽然像是同步输出的,但实际上是异步打印的。这样可以让期约等返回的值达到其最终状态。

此外,浏览器控制台的输出经常能打印出JavaScript运行中无法获取的对象信息(比如期约的状态)。这个特性在示例中广泛使用,以便辅助读者理解相关概念。

同步行为和异步行为的对立统一是计算机科学的一个基本概念。特别是在JavaScript这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。异步行为是为了优化因计算量大而时间长的操作。如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做就是务实的。

重要的是,异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。

同步行为 对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。

同步操作的例子可以是执行一次简单的数学计算:

let x = 3;
x = x + 4;

在程序执行的每一步,都可以推断出程序的状态。这是因为后面的指令总是在前面的指令完成后才会执行。等到最后一条指定执行完毕,存储在x 的值就立即可以使用。

这两行JavaScript代码对应的低级指令(从JavaScript到x86)并不难想象。首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态。

相对地,异步行为 类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。

异步操作的例子可以是在定时回调中执行一次简单的数学计算:

let x = 3;
setTimeout(() => x = x + 4, 1000);

这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道x 值何时会改变,因为这取决于回调何时从消息队列出列并执行。

异步代码不容易推断。虽然这个例子对应的低级代码最终跟前面的例子没什么区别,但第二个指令块(加操作及赋值操作)是由系统计时器触发的,这会生成一个入队执行的中断。到底什么时候会触发这个中断,这对JavaScript运行时来说是一个黑盒,因此实际上无法预知(尽管可以保证这发生在当前线程的同步代码执行之后 ,否则回调都没有机会出列被执行)。无论如何,在排定回调以后基本没办法知道系统状态何时变化。

为了让后续代码能够使用x ,异步执行的函数需要在更新x 的值以后通知其他代码。如果程序不需要这个值,那么就只管继续执行,不必等待这个结果了。

设计一个能够知道x 什么时候可以读取的系统是非常难的。JavaScript在实现这样一个系统的过程中也经历了几次迭代。

异步行为是JavaScript的基础,但以前的实现不理想。在早期的JavaScript中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。

假设有以下异步函数,使用了setTimeout 在一秒钟之后执行某些操作:

function double(value) {
  setTimeout(() => setTimeout(console.log, 0, value * 2), 1000);
}

double(3);
// 6(大约1000毫秒之后)

这里的代码没什么神秘的,但关键是理解为什么说它是一个异步函数。setTimeout 可以定义一个在指定时间之后会被调度执行的回调函数。对这个例子而言,1000毫秒之后,JavaScript运行时会把回调函数推到自己的消息队列上去等待执行。推到队列之后,回调什么时候出列被执行对JavaScript代码就完全不可见了。还有一点,double() 函数在setTimeout 成功调度异步操作之后会立即退出。

  1. 异步返回值

    假设setTimeout 操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。

    function double(value, callback) {
      setTimeout(() => callback(value * 2), 1000);
    }
    
    double(3, (x) => console.log(`I was given: ${x}`));
    // I was given: 6(大约1000毫秒之后)
    
    

    这里的setTimeout 调用告诉JavaScript运行时在1000毫秒之后把一个函数推到消息队列上。这个函数会由运行时负责异步调度执行。而位于函数闭包中的回调及其参数在异步执行时仍然是可用的。
     

  2. 失败处理

    异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调:

    function double(value, success, failure) {
      setTimeout(() => {
        try {
          if (typeof value !== 'number') {
            throw 'Must provide number as first argument';
          }
          success(2 * value);
        } catch (e) {
          failure(e);
        }
      }, 1000);
    }
    
    const successCallback = (x) => console.log(`Success: ${x}`);
    const failureCallback = (e) => console.log(`Failure: ${e}`);
    
    double(3, successCallback, failureCallback);
    double('b', successCallback, failureCallback);
    
    // Success: 6(大约1000毫秒之后)
    // Failure: Must provide number as first argument(大约1000毫秒之后)
    
    

    这种模式已经不可取了,因为必须在初始化异步操作时定义回调。异步函数的返回值只在短时间内存在,只有预备好将这个短时间内存在的值作为参数的回调才能接收到它。
     

  3. 嵌套异步回调

    如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这就要求嵌套回调:

    function double(value, success, failure) {
      setTimeout(() => {
        try {
          if (typeof value !== 'number') {
            throw 'Must provide number as first argument';
          }
          success(2 * value);
        } catch (e) {
          failure(e);
        }
      }, 1000);
    }
    
    const successCallback = (x) => {
      double(x, (y) => console.log(`Success: ${y}`));
    };
    const failureCallback = (e) => console.log(`Failure: ${e}`);
    
    double(3, successCallback, failureCallback);
    
    // Success: 12(大约1000毫秒之后)
    
    

    显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓名至实归。嵌套回调的代码维护起来就是噩梦。

期约是对尚不存在结果的一个替身。期约(promise)这个名字最早是由Daniel Friedman和David Wise在他们于1976年发表的论文“The Impact of Applicative Programming on Multiprocessing”中提出来的。但直到十几年以后,Barbara Liskov和Liuba Shrira在1988年发表了论文“Promises—Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems”,这个概念才真正确立下来。同一时期的计算机科学家还使用了“终局”(eventual)、“期许”(future)、“延迟”(delay)和“迟付”(deferred)等术语指代同样的概念。所有这些概念描述的都是一种异步程序执行的机制。

早期的期约机制在jQuery和Dojo中是以Deferred API的形式出现的。到了2010年,CommonJS项目实现的Promises/A规范日益流行起来。Q和Bluebird等第三方JavaScript期约库也越来越得到社区认可,虽然这些库的实现多少都有些不同。为弥合现有实现之间的差异,2012年Promises/A+组织分叉(fork)了CommonJS的Promises/A建议,并以相同的名字制定了Promises/A+规范。这个规范最终成为了ECMAScript 6规范实现的范本。

ECMAScript 6增加了对Promises/A+规范的完善支持,即Promise 类型。一经推出,Promise 就大受欢迎,成为了主导性的异步编程机制。所有现代浏览器都支持ES6期约,很多其他浏览器API(如fetch() 和Battery Status API)也以期约为基础。

ECMAScript 6新增的引用类型Promise ,可以通过new 操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数(后面马上会介绍),下面的例子使用了一个空函数对象来应付一下解释器:

let p = new Promise(() => {});
setTimeout(console.log, 0, p);  // Promise <pending>

之所以说是应付解释器,是因为如果不提供执行器函数,就会抛出SyntaxError

  1. 期约状态机

    在把一个期约实例传给console.log() 时,控制台输出(可能因浏览器不同而略有差异)表明该实例处于待定 (pending)状态。如前所述,期约是一个有状态的对象,可能处于如下3种状态之一:

    待定 (pending)是期约的最初始状态。在待定状态下,期约可以落定 (settled)为代表成功的兑现 (fulfilled)状态,或者代表失败的拒绝 (rejected)状态。无论落定为哪种状态都是不可逆的。只要从待定转换为兑现或拒绝,期约的状态就不再改变。而且,也不能保证期约必然会脱离待定状态。因此,组织合理的代码无论期约解决(resolve)还是拒绝(reject),甚至永远处于待定(pending)状态,都应该具有恰当的行为。

    重要的是,期约的状态是私有的,不能直接通过JavaScript检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。另外,期约的状态也不能被外部JavaScript代码修改。这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。
     

  2. 解决值、拒绝理由及期约用例

    期约主要有两大用途。首先是抽象地表示一个异步操作。期约的状态代表期约是否完成。“待定”表示尚未开始或者正在执行中。“兑现”表示已经成功完成,而“拒绝”则表示没有成功完成。

    某些情况下,这个状态机就是期约可以提供的最有用的信息。知道一段异步代码已经完成,对于其他代码而言已经足够了。比如,假设期约要向服务器发送一个HTTP请求。请求返回200~299范围内的状态码就足以让期约的状态变为“兑现”。类似地,如果请求返回的状态码不在200~299这个范围内,那么就会把期约状态切换为“拒绝”。

    在另外一些情况下,期约封装的异步操作会实际生成某个值,而程序期待期约状态改变时可以访问这个值。相应地,如果期约被拒绝,程序就会期待期约状态改变时可以拿到拒绝的理由。比如,假设期约向服务器发送一个HTTP请求并预定会返回一个JSON。如果请求返回范围在200~299的状态码,则足以让期约的状态变为兑现。此时期约内部就可以收到一个JSON字符串。类似地,如果请求返回的状态码不在200~299这个范围内,那么就会把期约状态切换为拒绝。此时拒绝的理由可能是一个Error 对象,包含着HTTP状态码及相关错误消息。

    为了支持这两种用例,每个期约只要状态切换为兑现,就会有一个私有的内部 (value)。类似地,每个期约只要状态切换为拒绝,就会有一个私有的内部理由 (reason)。无论是值还是理由,都是包含原始值或对象的不可修改的引用。二者都是可选的,而且默认值为undefined 。在期约到达某个落定状态时执行的异步代码始终会收到这个值或理由。
     

  3. 通过执行函数控制期约状态

    由于期约的状态是私有的,所以只能在内部进行操作。内部操作在期约的执行器函数中完成。执行器函数主要有两项职责:初始化期约的异步行为和控制状态的最终转换。其中,控制期约状态的转换是通过调用它的两个函数参数实现的。这两个函数参数通常都命名为resolve()reject() 。调用resolve() 会把状态切换为兑现,调用reject() 会把状态切换为拒绝。另外,调用reject() 也会抛出错误(后面会讨论这个错误)。

    let p1 = new Promise((resolve, reject) => resolve());
    setTimeout(console.log, 0, p1); // Promise <resolved>
    
    let p2 = new Promise((resolve, reject) => reject());
    setTimeout(console.log, 0, p2); // Promise <rejected>
    // Uncaught error (in promise)
    
    

    在前面的例子中,并没有什么异步操作,因为在初始化期约时,执行器函数已经改变了每个期约的状态。这里的关键在于,执行器函数是同步 执行的。这是因为执行器函数是期约的初始化程序。通过下面的例子可以看出上面代码的执行顺序:

    new Promise(() => setTimeout(console.log, 0, 'executor'));
    setTimeout(console.log, 0, 'promise initialized');
    
    // executor
    // promise initialized
    
    

    添加setTimeout 可以推迟切换状态:

    let p = new Promise((resolve, reject) => setTimeout(resolve, 1000));
    
    // 在console.log打印期约实例的时候,还不会执行超时回调(即resolve())
    setTimeout(console.log, 0, p);  // Promise <pending>
    
    

    无论resolve()reject() 中的哪个被调用,状态转换都不可撤销了。于是继续修改状态会静默失败,如下所示:

    let p = new Promise((resolve, reject) => {
      resolve();
      reject(); // 没有效果
    });
    
    setTimeout(console.log, 0, p); // Promise <resolved>
    
    

    为避免期约卡在待定状态,可以添加一个定时退出功能。比如,可以通过setTimeout 设置一个10秒钟后无论如何都会拒绝期约的回调:

    let p = new Promise((resolve, reject) => {
      setTimeout(reject, 10000);  // 10秒后调用reject()
      // 执行函数的逻辑
    });
    
    setTimeout(console.log, 0, p);      // Promise <pending>
    setTimeout(console.log, 11000, p);  // 11秒后再检查状态
    
    // (After 10 seconds) Uncaught error
    // (After 11 seconds) Promise <rejected>
    
    

    因为期约的状态只能改变一次,所以这里的超时拒绝逻辑中可以放心地设置让期约处于待定状态的最长时间。如果执行器中的代码在超时之前已经解决或拒绝,那么超时回调再尝试拒绝也会静默失败。
     

  4. Promise.resolve()

    期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve() 静态方法,可以实例化一个解决的期约。下面两个期约实例实际上是一样的:

    let p1 = new Promise((resolve, reject) => resolve());
    let p2 = Promise.resolve();
    
    

    这个解决的期约的值对应着传给Promise.resolve() 的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个期约:

    setTimeout(console.log, 0, Promise.resolve());
    // Promise <resolved>: undefined
    
    setTimeout(console.log, 0, Promise.resolve(3));
    // Promise <resolved>: 3
    
    // 多余的参数会忽略
    setTimeout(console.log, 0, Promise.resolve(4, 5, 6));
    // Promise <resolved>: 4
    
    

    对这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似于一个空包装。因此,Promise.resolve() 可以说是一个幂等方法,如下所示:

    let p = Promise.resolve(7);
    
    setTimeout(console.log, 0, p === Promise.resolve(p));
    // true
    
    setTimeout(console.log, 0, p === Promise.resolve(Promise.resolve(p)));
    // true
    
    

    这个幂等性会保留传入期约的状态:

    let p = new Promise(() => {});
    
    setTimeout(console.log, 0, p);                   // Promise <pending>
    setTimeout(console.log, 0, Promise.resolve(p));  // Promise <pending>
    
    setTimeout(console.log, 0, p === Promise.resolve(p)); // true
    
    

    注意,这个静态方法能够包装任何非期约值,包括错误对象,并将其转换为解决的期约。因此,也可能导致不符合预期的行为:

    let p = Promise.resolve(new Error('foo'));
    
    setTimeout(console.log, 0, p);
    // Promise <resolved>: Error: foo
    
    

     

  5. Promise.reject()

    Promise.resolve() 类似,Promise.reject() 会实例化一个拒绝的期约并抛出一个异步错误(这个错误不能通过try /catch 捕获,而只能通过拒绝处理程序捕获)。下面的两个期约实例实际上是一样的:

    let p1 = new Promise((resolve, reject) => reject());
    let p2 = Promise.reject();
    
    

    这个拒绝的期约的理由就是传给Promise.reject() 的第一个参数。这个参数也会传给后续的拒绝处理程序:

    let p = Promise.reject(3);
    setTimeout(console.log, 0, p); // Promise <rejected>: 3
    
    p.then(null, (e) => setTimeout(console.log, 0, e)); // 3
    
    

    关键在于,Promise.reject() 并没有照搬Promise.resolve() 的幂等逻辑。如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由:

    setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
    // Promise <rejected>: Promise <resolved>
    
    

     

  6. 同步/异步执行的二元性

    Promise 的设计很大程度上会导致一种完全不同于JavaScript的计算模式。下面的例子完美地展示了这一点,其中包含了两种模式下抛出错误的情形:

    try {
      throw new Error('foo');
    } catch(e) {
      console.log(e); // Error: foo
    }
    
    try {
      Promise.reject(new Error('bar'));
    } catch(e) {
      console.log(e);
    }
    // Uncaught (in promise) Error: bar
    
    

    第一个try /catch 抛出并捕获了错误,第二个try /catch 抛出错误却没有 捕获到。乍一看这可能有点违反直觉,因为代码中确实是同步创建了一个拒绝的期约实例,而这个实例也抛出了包含拒绝理由的错误。这里的同步代码之所以没有捕获期约抛出的错误,是因为它没有通过异步模式 捕获错误。从这里就可以看出期约真正的异步特性:它们是同步对象(在同步执行模式中使用),但也是异步 执行模式的媒介。

    在前面的例子中,拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。因此,try /catch 块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法。

期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。

  1. 实现Thenable 接口

    在ECMAScript暴露的异步结构中,任何对象都有一个then() 方法。这个方法被认为实现了Thenable 接口。下面的例子展示了实现这一接口的最简单的类:

    class MyThenable {
      then() {}
    }
    
    

    ECMAScript的Promise 类型实现了Thenable 接口。这个简化的接口跟TypeScript或其他包中的接口或类型定义不同,它们都设定了Thenable 接口更具体的形式。

    注意  本章后面再介绍异步函数时还会再谈到Thenable 接口的用途和目的。

     

  2. Promise.prototype.then()

    Promise.prototype.then() 是为期约实例添加处理程序的主要方法。这个then() 方法接收最多两个参数:onResolved 处理程序和onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。

    function onResolved(id) {
      setTimeout(console.log, 0, id, 'resolved');
    }
    function onRejected(id) {
      setTimeout(console.log, 0, id, 'rejected');
    }
    
    let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));
    let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));
    
    p1.then(() => onResolved('p1'),
            () => onRejected('p1'));
    p2.then(() => onResolved('p2'),
            () => onRejected('p2'));
    
    //(3秒后)
    // p1 resolved
    // p2 rejected
    
    

    因为期约只能转换为最终状态一次,所以这两个操作一定是互斥的。

    如前所述,两个处理程序参数都是可选的。而且,传给then() 的任何非函数类型的参数都会被静默忽略。如果想只提供onRejected 参数,那就要在onResolved 参数的位置上传入undefined 。这样有助于避免在内存中创建多余的对象,对期待函数参数的类型系统也是一个交代。

    function onResolved(id) {
      setTimeout(console.log, 0, id, 'resolved');
    }
    function onRejected(id) {
      setTimeout(console.log, 0, id, 'rejected');
    }
    
    let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));
    let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));
    
    // 非函数处理程序会被静默忽略,不推荐
    p1.then('gobbeltygook');
    
    // 不传onResolved处理程序的规范写法
    p2.then(null, () => onRejected('p2'));
    
    // p2 rejected(3秒后)
    
    

    Promise.prototype.then() 方法返回一个新的期约实例:

    let p1 = new Promise(() => {});
    let p2 = p1.then();
    setTimeout(console.log, 0, p1);         // Promise <pending>
    setTimeout(console.log, 0, p2);         // Promise <pending>
    setTimeout(console.log, 0, p1 === p2);  // false
    
    

    这个新期约实例基于onResovled 处理程序的返回值构建。换句话说,该处理程序的返回值会通过Promise.resolve() 包装来生成新期约。如果没有提供这个处理程序,则Promise.resolve() 就会包装上一个期约解决之后的值。如果没有显式的返回语句,则Promise.resolve() 会包装默认的返回值undefined

    let p1 = Promise.resolve('foo');
    
    // 若调用then()时不传处理程序,则原样向后传
    let p2 = p1.then();
    setTimeout(console.log, 0, p2); // Promise <resolved>: foo
    
    // 这些都一样
    let p3 = p1.then(() => undefined);
    let p4 = p1.then(() => {});
    let p5 = p1.then(() => Promise.resolve());
    
    setTimeout(console.log, 0, p3);  // Promise <resolved>: undefined
    setTimeout(console.log, 0, p4);  // Promise <resolved>: undefined
    setTimeout(console.log, 0, p5);  // Promise <resolved>: undefined
    
    

    如果有显式的返回值,则Promise.resolve() 会包装这个值:

    ...
    
    // 这些都一样
    let p6 = p1.then(() => 'bar');
    let p7 = p1.then(() => Promise.resolve('bar'));
    
    setTimeout(console.log, 0, p6);  // Promise <resolved>: bar
    setTimeout(console.log, 0, p7);  // Promise <resolved>: bar
    
    // Promise.resolve()保留返回的期约
    let p8 = p1.then(() => new Promise(() => {}));
    let p9 = p1.then(() => Promise.reject());
    // Uncaught (in promise): undefined
    
    setTimeout(console.log, 0, p8);  // Promise <pending>
    setTimeout(console.log, 0, p9);  // Promise <rejected>: undefined
    
    

    抛出异常会返回拒绝的期约:

    ...
    
    let p10 = p1.then(() => { throw 'baz'; });
    // Uncaught (in promise) baz
    
    setTimeout(console.log, 0, p10);  // Promise <rejected> baz
    
    

    注意,返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的期约中:

    ...
    
    let p11 = p1.then(() => Error('qux'));
    
    setTimeout(console.log, 0, p11); // Promise <resolved>: Error: qux
    
    

    onRejected 处理程序也与之类似:onRejected 处理程序返回的值也会被Promise.resolve() 包装。乍一看这可能有点违反直觉,但是想一想,onRejected 处理程序的任务不就是捕获异步错误吗?因此,拒绝处理程序在捕获错误后不抛出异常是符合期约的行为,应该返回一个解决期约。

    下面的代码片段展示了用Promise.reject() 替代之前例子中的Promise.resolve() 之后的结果:

    let p1 = Promise.reject('foo');
    
    // 调用then()时不传处理程序则原样向后传
    let p2 = p1.then();
    // Uncaught (in promise) foo
    
    setTimeout(console.log, 0, p2);  // Promise <rejected>: foo
    
    // 这些都一样
    let p3 = p1.then(null, () => undefined);
    let p4 = p1.then(null, () => {});
    let p5 = p1.then(null, () => Promise.resolve());
    
    setTimeout(console.log, 0, p3); // Promise <resolved>: undefined
    setTimeout(console.log, 0, p4); // Promise <resolved>: undefined
    setTimeout(console.log, 0, p5); // Promise <resolved>: undefined
     
     
     
    // 这些都一样
    let p6 = p1.then(null, () => 'bar');
    let p7 = p1.then(null, () => Promise.resolve('bar'));
    
    setTimeout(console.log, 0, p6); // Promise <resolved>: bar
    setTimeout(console.log, 0, p7); // Promise <resolved>: bar
    
    // Promise.resolve()保留返回的期约
    let p8 = p1.then(null, () => new Promise(() => {}));
    let p9 = p1.then(null, () => Promise.reject());
    // Uncaught (in promise): undefined
    
    setTimeout(console.log, 0, p8); // Promise <pending>
    setTimeout(console.log, 0, p9); // Promise <rejected>: undefined
     
     
     
    let p10 = p1.then(null, () => { throw 'baz'; });
    // Uncaught (in promise) baz
    
    setTimeout(console.log, 0, p10); // Promise <rejected>: baz
     
     
     
    let p11 = p1.then(null, () => Error('qux'));
    
    setTimeout(console.log, 0, p11); // Promise <resolved>: Error: qux
    
    

     

  3. Promise.prototype.catch()

    Promise.prototype.catch() 方法用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用Promise.prototype.then(null, onRejected)

    下面的代码展示了这两种同样的情况:

    let p = Promise.reject();
    let onRejected = function(e) {
      setTimeout(console.log, 0, 'rejected');
    };
    
    // 这两种添加拒绝处理程序的方式是一样的:
    p.then(null, onRejected);  // rejected
    p.catch(onRejected);       // rejected
    
    

    Promise.prototype.catch() 返回一个新的期约实例:

    let p1 = new Promise(() => {});
    let p2 = p1.catch();
    setTimeout(console.log, 0, p1);         // Promise <pending>
    setTimeout(console.log, 0, p2);         // Promise <pending>
    setTimeout(console.log, 0, p1 === p2);  // false
    
    

    在返回新期约实例方面,Promise.prototype.catch() 的行为与Promise.prototype.then()onRejected 处理程序是一样的。
     

  4. Promise.prototype.finally()

    Promise.prototype.finally() 方法用于给期约添加onFinally 处理程序,这个处理程序在期约转换为解决 拒绝状态时都会执行。这个方法可以避免onResolvedonRejected 处理程序中出现冗余代码。但onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

    let p1 = Promise.resolve();
    let p2 = Promise.reject();
    let onFinally = function() {
      setTimeout(console.log, 0, 'Finally!')
    }
    
    p1.finally(onFinally); // Finally
    p2.finally(onFinally); // Finally
    
    

    Promise.prototype.finally() 方法返回一个新的期约实例:

    let p1 = new Promise(() => {});
    let p2 = p1.finally();
    setTimeout(console.log, 0, p1);         // Promise <pending>
    setTimeout(console.log, 0, p2);         // Promise <pending>
    setTimeout(console.log, 0, p1 === p2);  // false
    
    

    这个新期约实例不同于then()catch() 方式返回的实例。因为onFinally 被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。

    let p1 = Promise.resolve('foo');
    
    // 这里都会原样后传
    let p2 = p1.finally();
    let p3 = p1.finally(() => undefined);
    let p4 = p1.finally(() => {});
    let p5 = p1.finally(() => Promise.resolve());
    let p6 = p1.finally(() => 'bar');
    let p7 = p1.finally(() => Promise.resolve('bar'));
    let p8 = p1.finally(() => Error('qux'));
    
    setTimeout(console.log, 0, p2);  // Promise <resolved>: foo
    setTimeout(console.log, 0, p3);  // Promise <resolved>: foo
    setTimeout(console.log, 0, p4);  // Promise <resolved>: foo
    setTimeout(console.log, 0, p5);  // Promise <resolved>: foo
    setTimeout(console.log, 0, p6);  // Promise <resolved>: foo
    setTimeout(console.log, 0, p7);  // Promise <resolved>: foo
    setTimeout(console.log, 0, p8);  // Promise <resolved>: foo
    
    

    如果返回的是一个待定的期约,或者onFinally 处理程序抛出了错误(显式抛出或返回了一个拒绝期约),则会返回相应的期约(待定或拒绝),如下所示:

    ...
    
    // Promise.resolve()保留返回的期约
    let p9 = p1.finally(() => new Promise(() => {}));
    let p10 = p1.finally(() => Promise.reject());
    // Uncaught (in promise): undefined
    
    setTimeout(console.log, 0, p9);  // Promise <pending>
    setTimeout(console.log, 0, p10); // Promise <rejected>: undefined
    
    let p11 = p1.finally(() => { throw 'baz'; });
    // Uncaught (in promise) baz
    
    setTimeout(console.log, 0, p11); // Promise <rejected>: baz
    
    

    返回待定期约的情形并不常见,这是因为只要期约一解决,新期约仍然会原样后传初始的期约:

    let p1 = Promise.resolve('foo');
    
    // 忽略解决的值
    let p2 = p1.finally(
      () => new Promise((resolve, reject) => setTimeout(() => resolve('bar'), 100)));
    
    setTimeout(console.log, 0, p2); // Promise <pending>
    
    setTimeout(() => setTimeout(console.log, 0, p2), 200);
    
    // 200毫秒后:
    // Promise <resolved>: foo
    
    

     

  5. 非重入期约方法

    当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期 ,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由JavaScript运行时保证,被称为“非重入”(non-reentrancy)特性。下面的例子演示了这个特性:

    // 创建解决的期约
    let p = Promise.resolve();
    
    // 添加解决处理程序
    // 直觉上,这个处理程序会等期约一解决就执行
    p.then(() => console.log('onResolved handler'));
    
    // 同步输出,证明then()已经返回
    console.log('then() returns');
    
    // 实际的输出:
    // then() returns
    // onResolved handler
    
    

    在这个例子中,在一个解决期约上调用then() 会把onResolved 处理程序推进消息队列。但这个处理程序在当前线程上的同步代码执行完成前不会执行。因此,跟在then() 后面的同步代码一定先于处理程序执行。

    先添加处理程序后解决期约也是一样的。如果添加处理程序后,同步代码才改变期约状态,那么处理程序仍然会基于该状态变化表现出非重入特性。下面的例子展示了即使先添加了onResolved 处理程序,再同步调用resolve() ,处理程序也不会进入同步线程执行:

    let synchronousResolve;
    
    // 创建一个期约并将解决函数保存在一个局部变量中
    let p = new Promise((resolve) => {
      synchronousResolve = function() {
        console.log('1: invoking resolve()');
        resolve();
        console.log('2: resolve() returns');
      };
    });
    
    p.then(() => console.log('4: then() handler executes'));
    
    synchronousResolve();
    console.log('3: synchronousResolve() returns');
    
    // 实际的输出:
    // 1: invoking resolve()
    // 2: resolve() returns
    // 3: synchronousResolve() returns
    // 4: then() handler executes
    
    

    在这个例子中,即使期约状态变化发生在添加处理程序之后,处理程序也会等到运行的消息队列让它出列时才会执行。

    非重入适用于onResolved /onRejected 处理程序、catch() 处理程序和finally() 处理程序。下面的例子演示了这些处理程序都只能异步执行:

    let p1 = Promise.resolve();
    p1.then(() => console.log('p1.then() onResolved'));
    console.log('p1.then() returns');
    
    let p2 = Promise.reject();
    p2.then(null, () => console.log('p2.then() onRejected'));
    console.log('p2.then() returns');
    
    let p3 = Promise.reject();
    p3.catch(() => console.log('p3.catch() onRejected'));
    console.log('p3.catch() returns');
    
    let p4 = Promise.resolve();
    p4.finally(() => console.log('p4.finally() onFinally'));
    
    console.log('p4.finally() returns');
    
    // p1.then() returns
    // p2.then() returns
    // p3.catch() returns
    // p4.finally() returns
    // p1.then() onResolved
    // p2.then() onRejected
    // p3.catch() onRejected
    // p4.finally() onFinally
    
    

     

  6. 邻近处理程序的执行顺序

    如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是then()catch() 还是finally() 添加的处理程序都是如此。

    let p1 = Promise.resolve();
    let p2 = Promise.reject();
    
    p1.then(() => setTimeout(console.log, 0, 1));
    p1.then(() => setTimeout(console.log, 0, 2));
    // 1
    // 2
    
    p2.then(null, () => setTimeout(console.log, 0, 3));
    p2.then(null, () => setTimeout(console.log, 0, 4));
    // 3
    // 4
    
    p2.catch(() => setTimeout(console.log, 0, 5));
    p2.catch(() => setTimeout(console.log, 0, 6));
    // 5
    // 6
    
    p1.finally(() => setTimeout(console.log, 0, 7));
    p1.finally(() => setTimeout(console.log, 0, 8));
    // 7
    // 8
    
    

     

  7. 传递解决值和拒绝理由

    到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可以进一步对这个值进行操作。比如,第一次网络请求返回的JSON是发送第二次请求必需的数据,那么第一次请求返回的值就应该传给onResolved 处理程序继续处理。当然,失败的网络请求也应该把HTTP状态码传给onRejected 处理程序。

    在执行函数中,解决的值和拒绝的理由是分别作为resolve()reject() 的第一个参数往后传的。然后,这些值又会传给它们各自的处理程序,作为onResolvedonRejected 处理程序的唯一参数。下面的例子展示了上述传递过程:

    let p1 = new Promise((resolve, reject) => resolve('foo'));
    p1.then((value) => console.log(value));    // foo
    
    let p2 = new Promise((resolve, reject) => reject('bar'));
    p2.catch((reason) => console.log(reason));  // bar
    
    

    Promise.resolve()Promise.reject() 在被调用时就会接收解决值和拒绝理由。同样地,它们返回的期约也会像执行器一样把这些值传给onResolvedonRejected 处理程序:

    let p1 = Promise.resolve('foo');
    p1.then((value) => console.log(value));   // foo
    
    let p2 = Promise.reject('bar');
    p2.catch((reason) => console.log(reason)); // bar
    
    

     

  8. 拒绝期约与拒绝错误处理

    拒绝期约类似于throw() 表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝:

    let p1 = new Promise((resolve, reject) => reject(Error('foo')));
    let p2 = new Promise((resolve, reject) => { throw Error('foo'); });
    let p3 = Promise.resolve().then(() => { throw Error('foo'); });
    let p4 = Promise.reject(Error('foo'));
    
    setTimeout(console.log, 0, p1);  // Promise <rejected>: Error: foo
    setTimeout(console.log, 0, p2);  // Promise <rejected>: Error: foo
    setTimeout(console.log, 0, p3);  // Promise <rejected>: Error: foo
    setTimeout(console.log, 0, p4);  // Promise <rejected>: Error: foo
     
     
    // 也会抛出4个未捕获错误
    
    

    期约可以以任何理由拒绝,包括undefined ,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的。例如,前面例子中抛出的4个错误的栈追踪信息如下:

    Uncaught (in promise) Error: foo
        at Promise (test.html:5)
        at new Promise (<anonymous>)
        at test.html:5
    Uncaught (in promise) Error: foo
        at Promise (test.html:6)
        at new Promise (<anonymous>)
        at test.html:6
    Uncaught (in promise) Error: foo
        at test.html:8
    Uncaught (in promise) Error: foo
        at Promise.resolve.then (test.html:7)
    
    

    所有错误都是异步抛出且未处理的,通过错误对象捕获的栈追踪信息展示了错误发生的路径。注意错误的顺序:Promise.resolve().then() 的错误最后才出现,这是因为它需要在运行时消息队列中添加 处理程序;也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。

    这个例子同样揭示了异步错误有意思的副作用。正常情况下,在通过throw() 关键字抛出错误时,JavaScript运行时的错误处理机制会停止执行抛出错误之后的任何指令:

    throw Error('foo');
    console.log('bar'); // 这一行不会执行
    
    // Uncaught Error: foo
    
    

    但是,在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令:

    Promise.reject(Error('foo'));
    console.log('bar');
    // bar
    
    // Uncaught (in promise) Error: foo
    
    

    如本章前面的Promise.reject() 示例所示,异步错误只能通过异步的onRejected 处理程序捕获:

    // 正确
    Promise.reject(Error('foo')).catch((e) => {});
    
    // 不正确
    try {
      Promise.reject(Error('foo'));
    } catch(e) {}
    
    

    这不包括捕获执行函数中的错误,在解决或拒绝期约之前,仍然可以使用try /catch 在执行函数中捕获错误:

    let p = new Promise((resolve, reject) => {
      try {
        throw Error('foo');
      } catch(e) {}
    
      resolve('bar');
    });
    
    setTimeout(console.log, 0, p); // Promise <resolved>: bar
    
    

    then()catch()onRejected 处理程序在语义上相当于try /catch 。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。为此,onRejected 处理程序的任务应该是在捕获异步错误之后返回一个解决 的期约。下面的例子中对比了同步错误处理与异步错误处理:

    console.log('begin synchronous execution');
    try {
      throw Error('foo');
    } catch(e) {
      console.log('caught error', e);
    }
    console.log('continue synchronous execution');
    
    // begin synchronous execution
    // caught error Error: foo
    // continue synchronous execution
     
     
     
    new Promise((resolve, reject) => {
      console.log('begin asynchronous execution');
      reject(Error('bar'));
    }).catch((e) => {
      console.log('caught error', e);
    }).then(() => {
      console.log('continue asynchronous execution');
    });
    
    // begin asynchronous execution
    // caught error Error: bar
    // continue asynchronous execution
    
    

多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。

  1. 期约连锁

    把期约逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个期约实例的方法(then()catch()finally() )都会返回一个新的 期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。比如:

    let p = new Promise((resolve, reject) => {
      console.log('first');
      resolve();
    });
    p.then(() => console.log('second'))
     .then(() => console.log('third'))
     .then(() => console.log('fourth'));
    
    // first
    // second
    // third
    // fourth
    
    

    这个实现最终执行了一连串同步 任务。正因为如此,这种方式执行的任务没有那么有用,毕竟分别使用4个同步函数也可以做到:

    (() => console.log('first'))();
    (() => console.log('second'))();
    (() => console.log('third'))();
    (() => console.log('fourth'))();
    
    

    要真正执行异步 任务,可以改写前面的例子,让每个执行器都返回一个期约实例。这样就可以让每个后续期约都等待之前的期约,也就是串行化异步任务。比如,可以像下面这样让每个期约在一定时间后解决:

    let p1 = new Promise((resolve, reject) => {
      console.log('p1 executor');
      setTimeout(resolve, 1000);
    });
    
    p1.then(() => new Promise((resolve, reject) => {
        console.log('p2 executor');
        setTimeout(resolve, 1000);
      }))
      .then(() => new Promise((resolve, reject) => {
        console.log('p3 executor');
        setTimeout(resolve, 1000);
      }))
      .then(() => new Promise((resolve, reject) => {
        console.log('p4 executor');
        setTimeout(resolve, 1000);
      }));
    
    // p1 executor(1秒后)
    // p2 executor(2秒后)
    // p3 executor(3秒后)
    // p4 executor(4秒后)
    
    

    把生成期约的代码提取到一个工厂函数中,就可以写成这样:

    function delayedResolve(str) {
      return new Promise((resolve, reject) => {
        console.log(str);
        setTimeout(resolve, 1000);
      });
    }
    
    delayedResolve('p1 executor')
      .then(() => delayedResolve('p2 executor'))
      .then(() => delayedResolve('p3 executor'))
      .then(() => delayedResolve('p4 executor'))
    
    // p1 executor(1秒后)
    // p2 executor(2秒后)
    // p3 executor(3秒后)
    // p4 executor(4秒后)
    
    

    每个后续的处理程序都会等待前一个期约解决,然后实例化一个新期约并返回它。这种结构可以简洁地将异步任务串行化,解决之前依赖回调的难题。假如这种情况下不使用期约,那么前面的代码可能就要这样写了:

    function delayedExecute(str, callback = null) {
      setTimeout(() => {
        console.log(str);
        callback && callback();
      }, 1000)
    }
    
    delayedExecute('p1 callback', () => {
      delayedExecute('p2 callback', () => {
        delayedExecute('p3 callback', () => {
          delayedExecute('p4 callback');
        });
      });
    });
    
    // p1 callback(1秒后)
    // p2 callback(2秒后)
    // p3 callback(3秒后)
    // p4 callback(4秒后)
    
    

    心明眼亮的开发者会发现,这不正是期约所要解决的回调地狱问题吗?

    因为then()catch()finally() 都返回期约,所以串联这些方法也很直观。下面的例子同时使用这3个实例方法:

    let p = new Promise((resolve, reject) => {
      console.log('initial promise rejects');
      reject();
    });
    
    p.catch(() => console.log('reject handler'))
     .then(() => console.log('resolve handler'))
     .finally(() => console.log('finally handler'));
    
    // initial promise rejects
    // reject handler
    // resolve handler
    // finally handler
    
    

     

  2. 期约图

    因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图 的结构。这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。

    下面的例子展示了一种期约有向图,也就是二叉树:

    //     A
    //    / \
    //   B   C
    //   /\  /\
    //  D  E   F  G
    
    let A = new Promise((resolve, reject) => {
      console.log('A');
      resolve();
    });
    
    let B = A.then(() => console.log('B'));
    let C = A.then(() => console.log('C'));
    
    B.then(() => console.log('D'));
    B.then(() => console.log('E'));
    C.then(() => console.log('F'));
    C.then(() => console.log('G'));
    
    // A
    // B
    // C
    // D
    // E
    // F
    // G
    
    

    注意,日志的输出语句是对二叉树的层序遍历。如前所述,期约的处理程序是按照它们添加的顺序执行的。由于期约的处理程序是 添加到消息队列,然后 才逐个执行,因此构成了层序遍历。

    树只是期约图的一种形式。考虑到根节点不一定唯一,且多个期约也可以组合成一个期约(通过下一节介绍的Promise.all()Promise.race() ),所以有向非循环图是体现期约连锁可能性的最准确表达。
     

  3. Promise.all()Promise.race()

    Promise类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()Promise.race() 。而合成后期约的行为取决于内部期约的行为。

  4. 串行期约合成

    到目前为止,我们讨论期约连锁一直围绕期约的串行执行,忽略了期约的另一个主要特性:异步产生值并将其传给处理程序。基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。这很像函数合成 ,即将多个函数合成为一个函数,比如:

    function addTwo(x) {return x + 2;}
    function addThree(x) {return x + 3;}
    function addFive(x) {return x + 5;}
    
    function addTen(x) {
      return addFive(addTwo(addThree(x)));
    }
    
    console.log(addTen(7)); // 17
    
    

    在这个例子中,有3个函数基于一个值合成为一个函数。类似地,期约也可以像这样合成起来,渐进地消费一个值,并返回一个结果:

    function addTwo(x) {return x + 2;}
    function addThree(x) {return x + 3;}
    function addFive(x) {return x + 5;}
    
    function addTen(x) {
      return Promise.resolve(x)
        .then(addTwo)
        .then(addThree)
        .then(addFive);
    }
    
    addTen(8).then(console.log); // 18
    
    

    使用Array.prototype.reduce() 可以写成更简洁的形式:

    function addTwo(x) {return x + 2;}
    function addThree(x) {return x + 3;}
    function addFive(x) {return x + 5;}
    
    function addTen(x) {
      return [addTwo, addThree, addFive]
          .reduce((promise, fn) => promise.then(fn), Promise.resolve(x));
    }
    
    addTen(8).then(console.log); // 18
    
    

    这种模式可以提炼出一个通用函数,可以把任意多个函数作为处理程序合成一个连续传值的期约连锁。这个通用的合成函数可以这样实现:

    function addTwo(x) {return x + 2;}
    function addThree(x) {return x + 3;}
    function addFive(x) {return x + 5;}
    
    function compose(...fns) {
      return (x) => fns.reduce((promise, fn) => promise.then(fn), Promise.resolve(x))
    }
    
    let addTen = compose(addTwo, addThree, addFive);
    
    addTen(8).then(console.log); // 18
    
    

    注意  本章后面的11.3节在讨论异步函数时还会涉及这个概念。

ES6期约实现是很可靠的,但它也有不足之处。比如,很多第三方期约库实现中具备而ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。

  1. 期约取消

    我们经常会遇到期约正在处理过程中,程序却不再需要其结果的情形。这时候如果能够取消期约就好了。某些第三方库,比如Bluebird,就提供了这个特性。实际上,TC39委员会也曾准备增加这个特性,但相关提案最终被撤回了。结果,ES6期约被认为是“激进的”:只要期约的逻辑开始执行,就没有办法阻止它执行到完成。

    实际上,可以在现有实现基础上提供一种临时性的封装,以实现取消期约的功能。这可以用到Kevin Smith提到的“取消令牌”(cancel token)。生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时也提供了一个期约的实例,可以用来触发取消后的操作并求值取消状态。

    下面是CancelToken 类的一个基本实例:

    class CancelToken {
      constructor(cancelFn) {
        this.promise = new Promise((resolve, reject) => {
          cancelFn(resolve);
        });
      }
    }
    
    

    这个类包装了一个期约,把解决方法暴露给了cancelFn 参数。这样,外部代码就可以向构造函数中传入一个函数,从而控制什么情况下可以取消期约。这里期约是令牌类的公共成员,因此可以给它添加处理程序以取消期约。

    这个类大概可以这样使用:

    <button id="start">Start</button>
    <button id="cancel">Cancel</button>
    
    <script>
    class CancelToken {
      constructor(cancelFn) {
        this.promise = new Promise((resolve, reject) => {
          cancelFn(() => {
            setTimeout(console.log, 0, "delay cancelled");
            resolve();
          });
        });
      }
    }
    
    const startButton = document.querySelector('#start');
    const cancelButton = document.querySelector('#cancel');
    
    function cancellableDelayedResolve(delay) {
      setTimeout(console.log, 0, "set delay");
    
      return new Promise((resolve, reject) => {
        const id = setTimeout((() => {
          setTimeout(console.log, 0, "delayed resolve");
          resolve();
        }), delay);
    
        const cancelToken = new CancelToken((cancelCallback) =>
          cancelButton.addEventListener("click", cancelCallback));
    
        cancelToken.promise.then(() => clearTimeout(id));
      });
    }
    
    startButton.addEventListener("click", () => cancellableDelayedResolve(1000));
    </script>
    
    

    每次单击“Start”按钮都会开始计时,并实例化一个新的CancelToken 的实例。此时,“Cancel”按钮一旦被点击,就会触发令牌实例中的期约解决。而解决之后,单击“Start”按钮设置的超时也会被取消。
     

  2. 期约进度通知

    执行中的期约可能会有不少离散的“阶段”,在最终解决之前必须依次经过。某些情况下,监控期约的执行进度会很有用。ECMAScript 6期约并不支持进度追踪,但是可以通过扩展来实现。

    一种实现方式是扩展Promise 类,为它添加notify() 方法,如下所示:

    class TrackablePromise extends Promise {
      constructor(executor) {
        const notifyHandlers = [];
    
          super((resolve, reject) => {
          return executor(resolve, reject, (status) => {
            notifyHandlers.map((handler) => handler(status));
          });
        });
    
        this.notifyHandlers = notifyHandlers;
      }
    
      notify(notifyHandler) {
        this.notifyHandlers.push(notifyHandler);
        return this;
      }
    }
    
    

    这样,TrackablePromise 就可以在执行函数中使用notify() 函数了。可以像下面这样使用这个函数来实例化一个期约:

    let p = new TrackablePromise((resolve, reject, notify) => {
      function countdown(x) {
        if (x > 0) {
          notify(`${20 * x}% remaining`);
          setTimeout(() => countdown(x - 1), 1000);
        } else {
          resolve();
        }
      }
    
      countdown(5);
    });
    
    

    这个期约会连续5次递归地设置1000毫秒的超时。每个超时回调都会调用notify() 并传入状态值。假设通知处理程序简单地这样写:

    ...
    
    let p = new TrackablePromise((resolve, reject, notify) => {
      function countdown(x) {
        if (x > 0) {
          notify(`${20 * x}% remaining`);
          setTimeout(() => countdown(x - 1), 1000);
        } else {
          resolve();
        }
      }
    
      countdown(5);
    });
    
    p.notify((x) => setTimeout(console.log, 0, 'progress:', x));
    
    p.then(() => setTimeout(console.log, 0, 'completed'));
    
    // (约1秒后)80% remaining
    // (约2秒后)60% remaining
    // (约3秒后)40% remaining
    // (约4秒后)20% remaining
    // (约5秒后)completed
    
    

    notify() 函数会返回期约,所以可以连缀调用,连续添加处理程序。多个处理程序会针对收到的每条消息分别执行一遍,如下所示:

    ...
    
    p.notify((x) => setTimeout(console.log, 0, 'a:', x))
     .notify((x) => setTimeout(console.log, 0, 'b:', x));
    
    p.then(() => setTimeout(console.log, 0, 'completed'));
    
    // (约1秒后) a: 80% remaining
    // (约1秒后) b: 80% remaining
    // (约2秒后) a: 60% remaining
    // (约2秒后) b: 60% remaining
    // (约3秒后) a: 40% remaining
    // (约3秒后) b: 40% remaining
    // (约4秒后) a: 20% remaining
    // (约4秒后) b: 20% remaining
    // (约5秒后) completed
    
    

    总体来看,这还是一个比较粗糙的实现,但应该可以演示出如何使用通知报告进度了。

    注意  ES6不支持取消期约和进度通知,一个主要原因就是这样会导致期约连锁和期约合成过度复杂化。比如在一个期约连锁中,如果某个被其他期约依赖的期约被取消了或者发出了通知,那么接下来应该发生什么完全说不清楚。毕竟,如果取消了Promise.all() 中的一个期约,或者期约连锁中前面的期约发送了一个通知,那么接下来应该怎么办才比较合理呢?

异步函数,也称为“async/await”(语法关键字),是ES6期约模式在ECMAScript函数中的应用。async/await是ES8规范新增的。这个特性从行为和语法上都增强了JavaScript,让以同步方式写的代码能够异步执行。下面来看一个最简单的例子,这个期约在超时之后会解决为一个值:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));

这个期约在1000毫秒之后解决为数值3。如果程序中的其他代码要在这个值可用时访问它,则需要写一个解决处理程序:

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));

p.then((x) => console.log(x));  // 3

这其实是很不方便的,因为其他代码都必须塞到期约处理程序中。不过可以把处理程序定义为一个函数:

function handler(x) { console.log(x); }

let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));

p.then(handler); // 3

这个改进其实也不大。这是因为任何需要访问这个期约所产生值的代码,都需要以处理程序的形式来接收这个值。也就是说,代码照样还是要放到处理程序里。ES8为此提供了async/await关键字。

ES8的async/await旨在解决利用异步结构组织代码的问题。为此,ECMAScript对函数进行了扩展,为其增加了两个新关键字:asyncawait

  1. async

    async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

    async function foo() {}
    
    let bar = async function() {};
    
    let baz = async () => {};
    
    class Qux {
      async qux() {}
    }
    
    

    使用async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通JavaScript函数的正常行为。正如下面的例子所示,foo() 函数仍然会在后面的指令之前被求值:

    async function foo() {
      console.log(1);
    }
    
    foo();
    console.log(2);
    
    // 1
    // 2
    
    

    不过,异步函数如果使用return 关键字返回了值(如果没有return 则会返回undefined ),这个值会被Promise.resolve() 包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约:

    async function foo() {
      console.log(1);
      return 3;
    }
    
    // 给返回的期约添加一个解决处理程序
    foo().then(console.log);
    
    console.log(2);
    
    // 1
    // 2
    // 3
    
    

    当然,直接返回一个期约对象也是一样的:

    async function foo() {
      console.log(1);
      return Promise.resolve(3);
    }
    
    // 给返回的期约添加一个解决处理程序
    foo().then(console.log);
    
    console.log(2);
    
    // 1
    // 2
    // 3
    
    

    异步函数的返回值期待(但实际上并不要求)一个实现thenable 接口的对象,但常规的值也可以。如果返回的是实现thenable 接口的对象,则这个对象可以由提供给then() 的处理程序“解包”。如果不是,则返回值就被当作已经解决的期约。下面的代码演示了这些情况:

    // 返回一个原始值
    async function foo() {
      return 'foo';
    }
    foo().then(console.log);
    // foo
    
    // 返回一个没有实现thenable接口的对象
    async function bar() {
      return ['bar'];
    }
    bar().then(console.log);
    // ['bar']
    
    // 返回一个实现了thenable接口的非期约对象
    async function baz() {
      const thenable = {
        then(callback) { callback('baz'); }
      };
      return thenable;
    }
    baz().then(console.log);
    // baz
    
    // 返回一个期约
    async function qux() {
      return Promise.resolve('qux');
    }
    qux().then(console.log);
    // qux
    
    

    与在期约处理程序中一样,在异步函数中抛出错误会返回拒绝的期约:

    async function foo() {
      console.log(1);
      throw 3;
    }
    
    // 给返回的期约添加一个拒绝处理程序
    foo().catch(console.log);
    console.log(2);
    
    // 1
    // 2
    // 3
    
    

    不过,拒绝期约的错误不会被异步函数捕获:

    async function foo() {
      console.log(1);
      Promise.reject(3);
    }
    
    // Attach a rejected handler to the returned promise
    foo().catch(console.log);
    console.log(2);
    
    // 1
    // 2
    // Uncaught (in promise): 3
    
    

     

  2. await

    因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用await 关键字可以暂停异步函数代码的执行,等待期约解决。来看下面这个本章开始就出现过的例子:

    let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
    
    p.then((x) => console.log(x)); // 3
    
    

    使用async/await可以写成这样:

    async function foo() {
      let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
      console.log(await p);
    }
    
    foo();
    // 3
    
    

    注意,await 关键字会暂停执行异步函数后面的代码,让出JavaScript运行时的执行线程。这个行为与生成器函数中的yield 关键字是一样的。await 关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。

    await 关键字的用法与JavaScript的一元操作一样。它可以单独使用,也可以在表达式中使用,如下面的例子所示:

    // 异步打印"foo"
    async function foo() {
      console.log(await Promise.resolve('foo'));
    }
    foo();
    // foo
     
     
    // 异步打印"bar"
    async function bar() {
      return await Promise.resolve('bar');
    }
    bar().then(console.log);
    // bar
    
    // 1000毫秒后异步打印"baz"
    async function baz() {
      await new Promise((resolve, reject) => setTimeout(resolve, 1000));
      console.log('baz');
    }
    baz();
    // baz(1000毫秒后)
    
    

    await 关键字期待(但实际上并不要求)一个实现thenable 接口的对象,但常规的值也可以。如果是实现thenable 接口的对象,则这个对象可以由await 来“解包”。如果不是,则这个值就被当作已经解决的期约。下面的代码演示了这些情况:

    // 等待一个原始值
    async function foo() {
      console.log(await 'foo');
    }
    foo();
    // foo
    
    // 等待一个没有实现thenable接口的对象
    async function bar() {
      console.log(await ['bar']);
    }
    bar();
    // ['bar']
    
    // 等待一个实现了thenable接口的非期约对象
    async function baz() {
      const thenable = {
        then(callback) { callback('baz'); }
      };
      console.log(await thenable);
    }
    baz();
    // baz
    
    // 等待一个期约
    async function qux() {
      console.log(await Promise.resolve('qux'));
    }
    qux();
    // qux
    
    

    等待会抛出错误的同步操作,会返回拒绝的期约:

    async function foo() {
      console.log(1);
      await (() => { throw 3; })();
    }
    
    // 给返回的期约添加一个拒绝处理程序
    foo().catch(console.log);
    console.log(2);
    
    // 1
    // 2
    // 3
    
    

    如前面的例子所示,单独的Promise.reject() 不会被异步函数捕获,而会抛出未捕获错误。不过,对拒绝的期约使用await 则会释放(unwrap)错误值(将拒绝期约返回):

    async function foo() {
      console.log(1);
      await Promise.reject(3);
      console.log(4); // 这行代码不会执行
    }
    
    // 给返回的期约添加一个拒绝处理程序
    foo().catch(console.log);
    console.log(2);
    
    // 1
    // 2
    // 3
    
    

     

  3. await 的限制

    await 关键字必须在异步函数中使用,不能在顶级上下文如<script> 标签或模块中使用。不过,定义并立即调用异步函数是没问题的。下面两段代码实际是相同的:

    async function foo() {
      console.log(await Promise.resolve(3));
    }
    foo();
    // 3
    
    // 立即调用的异步函数表达式
    (async function() {
      console.log(await Promise.resolve(3));
    })();
    // 3
    
    

    此外,异步函数的特质不会扩展到嵌套函数。因此,await 关键字也只能直接出现在异步函数的定义中。在同步函数内部使用await 会抛出SyntaxError

    下面展示了一些会出错的例子:

    // 不允许:await出现在了箭头函数中
    function foo() {
      const syncFn = () => {
        return await Promise.resolve('foo');
      };
      console.log(syncFn());
    }
    
    // 不允许:await出现在了同步函数声明中
    function bar() {
      function syncFn() {
        return await Promise.resolve('bar');
      }
      console.log(syncFn());
    }
    
    // 不允许:await出现在了同步函数表达式中
    function baz() {
      const syncFn = function() {
        return await Promise.resolve('baz');
      };
      console.log(syncFn());
    }
    
    // 不允许:IIFE使用同步函数表达式或箭头函数
    function qux() {
      (function () { console.log(await Promise.resolve('qux')); })();
      (() => console.log(await Promise.resolve('qux')))();
    }
    
    

使用await 关键字之后的区别其实比看上去的还要微妙一些。比如,下面的例子中按顺序调用了3个函数,但它们的输出结果顺序是相反的:

async function foo() {
  console.log(await Promise.resolve('foo'));
}

async function bar() {
  console.log(await 'bar');
}

async function baz() {
  console.log('baz');
}

foo();
bar();
baz();

// baz
// bar
// foo

async/await中真正起作用的是awaitasync 关键字,无论从哪方面来看,都不过是一个标识符。毕竟,异步函数如果不包含await 关键字,其执行基本上跟普通函数没有什么区别:

async function foo() {
  console.log(2);
}

console.log(1);
foo();
console.log(3);

// 1
// 2
// 3

要完全理解await 关键字,必须知道它并非只是等待一个值可用那么简单。JavaScript运行时在碰到await 关键字时,会记录在哪里暂停执行。等到await 右边的值可用了,JavaScript运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。

因此,即使await 后面跟着一个立即可用的值,函数的其余部分也会被异步 求值。下面的例子演示了这一点:

async function foo() {
  console.log(2);
  await null;
  console.log(4);
}

console.log(1);
foo();
console.log(3);

// 1
// 2
// 3
// 4

控制台中输出结果的顺序很好地解释了运行时的工作过程:

(1) 打印1;

(2) 调用异步函数foo()

(3)(在foo() 中)打印2;

(4)(在foo() 中)await 关键字暂停执行,为立即可用的值null 向消息队列中添加一个任务;

(5) foo()退出;

(6) 打印3;

(7) 同步线程的代码执行完毕;

(8) JavaScript运行时从消息队列中取出任务,恢复异步函数执行;

(9)(在foo() 中)恢复执行,await 取得null 值(这里并没有使用);

(10)(在foo() 中)打印4;

(11) foo() 返回。

如果await 后面是一个期约,则问题会稍微复杂一些。此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值。下面的例子虽然看起来很反直觉,但它演示了真正的执行顺序:1

1 TC39 对await 后面是期约的情况如何处理做过一次修改。修改后,本例中的Promise.resolve(8) 只会生成一个异步任务。因此在新版浏览器中,这个示例的输出结果为123458967 。实际开发中,对于并行的异步操作我们通常更关注结果,而不依赖执行顺序。——译者注

async function foo() {
  console.log(2);
  console.log(await Promise.resolve(8));
  console.log(9);
}

async function bar() {
  console.log(4);
  console.log(await 6);
  console.log(7);
}

console.log(1);
foo();
console.log(3);
bar();
console.log(5);

// 1
// 2
// 3
// 4
// 5
// 6
// 7
// 8
// 9

运行时会像这样执行上面的例子:

(1) 打印1;

(2) 调用异步函数foo()

(3)(在foo() 中)打印2;

(4)(在foo() 中)await 关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务;

(5) 期约立即落定,把给await 提供值的任务添加到消息队列;

(6) foo() 退出;

(7) 打印3;

(8) 调用异步函数bar()

(9)(在bar() 中)打印4;

(10)(在bar() 中)await 关键字暂停执行,为立即可用的值6向消息队列中添加一个任务;

(11) bar() 退出;

(12) 打印5;

(13) 顶级线程执行完毕;

(14) JavaScript运行时从消息队列中取出解决await 期约的处理程序,并将解决的值8提供给它;

(15) JavaScript运行时向消息队列中添加一个恢复执行foo() 函数的任务;

(16) JavaScript运行时从消息队列中取出恢复执行bar() 的任务及值6;

(17)(在bar() 中)恢复执行,await 取得值6;

(18)(在bar() 中)打印6;

(19)(在bar() 中)打印7;

(20) bar() 返回;

(21) 异步任务完成,JavaScript从消息队列中取出恢复执行foo() 的任务及值8;

(22)(在foo() 中)打印8;

(23)(在foo() 中)打印9;

(24) foo() 返回。

因为简单实用,所以异步函数很快成为JavaScript项目使用最广泛的特性之一。不过,在使用异步函数时,还是有些问题要注意。

  1. 实现sleep()

    很多人在刚开始学习JavaScript时,想找到一个类似Java中Thread.sleep() 之类的函数,好在程序中加入非阻塞的暂停。以前,这个需求基本上都通过setTimeout() 利用JavaScript运行时的行为来实现的。

    有了异步函数之后,就不一样了。一个简单的箭头函数就可以实现sleep():

    async function sleep(delay) {
      return new Promise((resolve) => setTimeout(resolve, delay));
    }
    
    async function foo() {
      const t0 = Date.now();
      await sleep(1500); // 暂停约1500毫秒
      console.log(Date.now() - t0);
    }
    foo();
    // 1502
    
    

     

  2. 利用平行执行

    如果使用await 时不留心,则很可能错过平行加速的机会。来看下面的例子,其中顺序等待了5个随机的超时:

    async function randomDelay(id) {
      // 延迟0~1000毫秒
      const delay = Math.random() * 1000;
      return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
      }, delay));
    }
    
    async function foo() {
      const t0 = Date.now();
      await randomDelay(0);
      await randomDelay(1);
      await randomDelay(2);
      await randomDelay(3);
      await randomDelay(4);
      console.log(`${Date.now() - t0}ms elapsed`);
    }
    foo();
    
    // 0 finished
    // 1 finished
    // 2 finished
    // 3 finished
    // 4 finished
    // 877ms elapsed
    
    

    用一个for 循环重写,就是:

    async function randomDelay(id) {
      // 延迟0~1000毫秒
      const delay = Math.random() * 1000;
      return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
      }, delay));
    }
    
    async function foo() {
      const t0 = Date.now();
      for (let i = 0; i < 5; ++i) {
        await randomDelay(i);
      }
    
      console.log(`${Date.now() - t0}ms elapsed`);
    }
    foo();
    
    // 0 finished
    // 1 finished
    // 2 finished
    // 3 finished
    // 4 finished
    // 877ms elapsed
    
    

    就算这些期约之间没有依赖,异步函数也会依次暂停,等待每个超时完成。这样可以保证执行顺序,但总执行时间会变长。

    如果顺序不是必需保证的,那么可以先一次性初始化所有期约,然后再分别等待它们的结果。比如:

    async function randomDelay(id) {
      // 延迟0~1000毫秒
      const delay = Math.random() * 1000;
      return new Promise((resolve) => setTimeout(() => {
        setTimeout(console.log, 0, `${id} finished`);
        resolve();
      }, delay));
    }
    
    async function foo() {
      const t0 = Date.now();
    
      const p0 = randomDelay(0);
      const p1 = randomDelay(1);
      const p2 = randomDelay(2);
      const p3 = randomDelay(3);
      const p4 = randomDelay(4);
    
      await p0;
      await p1;
      await p2;
      await p3;
      await p4;
    
      setTimeout(console.log, 0, `${Date.now() - t0}ms elapsed`);
    }
    foo();
    
    // 1 finished
    // 4 finished
    // 3 finished
    // 0 finished
    // 2 finished
    // 877ms elapsed
    
    

    用数组和for 循环再包装一下就是:

    async function randomDelay(id) {
      // 延迟0~1000毫秒
      const delay = Math.random() * 1000;
      return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
      }, delay));
    }
    
    async function foo() {
      const t0 = Date.now();
    
      const promises = Array(5).fill(null).map((_, i) => randomDelay(i));
    
      for (const p of promises) {
        await p;
      }
    
      console.log(`${Date.now() - t0}ms elapsed`);
    }
    foo();
    
    // 4 finished
    // 2 finished
    // 1 finished
    // 0 finished
    // 3 finished
    // 877ms elapsed
    
    

    注意,虽然期约没有按照顺序执行,但await 按顺序 收到了每个期约的值:

    async function randomDelay(id) {
      // 延迟0~1000毫秒
      const delay = Math.random() * 1000;
      return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve(id);
      }, delay));
    }
    
    async function foo() {
      const t0 = Date.now();
    
      const promises = Array(5).fill(null).map((_, i) => randomDelay(i));
    
      for (const p of promises) {
        console.log(`awaited ${await p}`);
      }
    
      console.log(`${Date.now() - t0}ms elapsed`);
    }
    foo();
    
    // 1 finished
    // 2 finished
    // 4 finished
    // 3 finished
    // 0 finished
    // awaited 0
    // awaited 1
    // awaited 2
    // awaited 3
    // awaited 4
    // 645ms elapsed
    
    

     

  3. 串行执行期约

    在11.2节,我们讨论过如何串行执行期约并把值传给后续的期约。使用async/await,期约连锁会变得很简单:

    function addTwo(x) {return x + 2;}
    function addThree(x) {return x + 3;}
    function addFive(x) {return x + 5;}
    
    async function addTen(x) {
      for (const fn of [addTwo, addThree, addFive]) {
        x = await fn(x);
      }
      return x;
    }
    
    addTen(9).then(console.log); // 19
    
    

    这里,await 直接传递了每个函数的返回值,结果通过迭代产生。当然,这个例子并没有使用期约,如果要使用期约,则可以把所有函数都改成异步函数。这样它们就都返回期约了:

    async function addTwo(x) {return x + 2;}
    async function addThree(x) {return x + 3;}
    async function addFive(x) {return x + 5;}
    
    async function addTen(x) {
      for (const fn of [addTwo, addThree, addFive]) {
        x = await fn(x);
      }
      return x;
    }
    
    addTen(9).then(console.log); // 19
    
    

     

  4. 栈追踪与内存管理

    期约与异步函数的功能有相当程度的重叠,但它们在内存中的表示则差别很大。看看下面的例子,它展示了拒绝期约的栈追踪信息:

    function fooPromiseExecutor(resolve, reject) {
      setTimeout(reject, 1000, 'bar');
    }
    
    function foo() {
      new Promise(fooPromiseExecutor);
    }
    
    foo();
    // Uncaught (in promise) bar
    //   setTimeout
    //   setTimeout (async)
    //   fooPromiseExecutor
    //   foo
    
    

    根据对期约的不同理解程度,以上栈追踪信息可能会让某些读者不解。栈追踪信息应该相当直接地表现JavaScript引擎当前栈内存中函数调用之间的嵌套关系。在超时处理程序执行时和拒绝期约时,我们看到的错误信息包含嵌套函数的标识符,那是被调用以创建最初期约实例的函数。可是,我们知道这些函数已经返回 了,因此栈追踪信息中不应该看到它们。

    答案很简单,这是因为JavaScript引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。

    如果在前面的例子中使用的是异步函数,那又会怎样呢?比如:

    function fooPromiseExecutor(resolve, reject) {
      setTimeout(reject, 1000, 'bar');
    }
    
    async function foo() {
      await new Promise(fooPromiseExecutor);
    }
    foo();
    
    // Uncaught (in promise) bar
    //   foo
    //   async function (async)
    //   foo
    
    

    这样一改,栈追踪信息就准确地反映了当前的调用栈。fooPromiseExecutor() 已经返回,所以它不在错误信息中。但foo() 此时被挂起了,并没有退出。JavaScript运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样就不会像之前的例子那样带来额外的消耗,因此在重视性能的应用中是可以优先考虑的。

长期以来,掌握单线程JavaScript运行时的异步行为一直都是个艰巨的任务。随着ES6新增了期约和ES8新增了异步函数,ECMAScript的异步编程特性有了长足的进步。通过期约和async/await,不仅可以实现之前难以实现或不可能实现的任务,而且也能写出更清晰、简洁,并且容易理解、调试的代码。

期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用、复合、扩展和重组。

异步函数是将期约应用于JavaScript函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代JavaScript工具箱中最重要的工具之一。


第 12 章 BOM

本章内容

虽然ECMAScript把浏览器对象模型(BOM,Browser Object Model)描述为JavaScript的核心,但实际上BOM是使用JavaScript开发Web应用程序的核心。BOM提供了与网页无关的浏览器功能对象。多年来,BOM是在缺乏规范的背景下发展起来的,因此既充满乐趣又问题多多。毕竟,浏览器开发商都按照自己的意愿来为它添砖加瓦。最终,浏览器实现之间共通的部分成为了事实标准,为Web开发提供了浏览器间互操作的基础。HTML5规范中有一部分涵盖了BOM的主要内容,因为W3C希望将JavaScript在浏览器中最基础的部分标准化。

BOM的核心是window 对象,表示浏览器的实例。window 对象在浏览器中有两重身份,一个是ECMAScript中的Global 对象,另一个就是浏览器窗口的JavaScript接口。这意味着网页中定义的所有对象、变量和函数都以window 作为其Global 对象,都可以访问其上定义的parseInt() 等全局方法。

注意  因为window 对象的属性在全局作用域中有效,所以很多浏览器API及相关构造函数都以window 对象属性的形式暴露出来。这些API将在全书各章中介绍,特别是第20章。

另外,由于实现不同,某些window 对象的属性在不同浏览器间可能差异很大。本章不会介绍已经废弃的、非标准化或特定于浏览器的window 属性。

因为window 对象被复用为ECMAScript的Global 对象,所以通过var 声明的所有全局变量和函数都会变成window 对象的属性和方法。比如:

var age = 29;
var sayAge = () => alert(this.age);

alert(window.age); // 29
sayAge();          // 29
window.sayAge();   // 29

这里,变量age 和函数sayAge() 被定义在全局作用域中,它们自动成为了window 对象的成员。因此,变量age 可以通过window.age 来访问,而函数sayAge() 也可以通过window.sayAge() 来访问。因为sayAge() 存在于全局作用域,this.age 映射到window.age ,所以就可以显示正确的结果了。

如果在这里使用letconst 替代var ,则不会把变量添加给全局对象:

let age = 29;
const sayAge = () => alert(this.age);

alert(window.age);  // undefined
sayAge();           // undefined
window.sayAge();    // TypeError: window.sayAge is not a function

另外,访问未声明的变量会抛出错误,但是可以在window 对象上查询是否存在可能未声明的变量。比如:

// 这会导致抛出错误,因为oldValue没有声明
var newValue = oldValue;
// 这不会抛出错误,因为这里是属性查询
// newValue会被设置为undefined
var newValue = window.oldValue;

记住,JavaScript中有很多对象都暴露在全局作用域中,比如locationnavigator (本章后面都会讨论),因而它们也是window 对象的属性。

top 对象始终指向最上层(最外层)窗口,即浏览器窗口本身。而parent 对象则始终指向当前窗口的父窗口。如果当前窗口是最上层窗口,则parent 等于top (都等于window )。最上层的window 如果不是通过window.open() 打开的,那么其name 属性就不会包含值,本章后面会讨论。

还有一个self 对象,它是终极window 属性,始终会指向window 。实际上,selfwindow 就是同一个对象。之所以还要暴露self ,就是为了和topparent 保持一致。

这些属性都是window 对象的属性,因此访问window.parentwindow.topwindow.self 都可以。这意味着可以把访问多个窗口的window 对象串联起来,比如window.parent.parent

window 对象的位置可以通过不同的属性和方法来确定。现代浏览器提供了screenLeftscreenTop 属性,用于表示窗口相对于屏幕左侧和顶部的位置 ,返回值的单位是CSS像素。

可以使用moveTo()moveBy() 方法移动窗口。这两个方法都接收两个参数,其中moveTo() 接收要移动到的新位置的绝对坐标<