Skip to content

JavaScript高级程序设计<弗里斯比>

1. 认识js

1.1 简短的历史回顾

为了解决表单数据提交与服务器的往返数据通信,网景公司为即将发布的Netscape Navigator 2 开发一个叫Mocha(后来改名为LiveScript)的脚本语言, 为了赶上发布时间,网景与Sun 公司结为开发联盟,共同完成LiveScript 的开发。就在Netscape Navigator 2 正式发布前,网景把LiveScript 改名为JavaScript,以便搭上媒体当时热烈炒作Java 的顺风车。Javascript的发布很成功,尚未成熟的Web的受欢迎程度达到了历史新高,而网景则稳居市场领导者的位置。此时,微软决定向IE投入更多的资源。在IE3中包含了自己名为Jscript的Javascript实现。1996年八月,微软进入Web浏览器领域。

1.2 JavasSript实现

  • 核心(ECMAScript)
  • 文档对象模型(DOM
  • 浏览器对象模型(BOM
1.2.1 ECMAScript

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

如果不涉及浏览器的话,ECMA-262 定义了:

  • 语法
  • 类型
  • 语句
  • 关键字
  • 保留字
  • 操作符
  • 全局对象

######## 1. ECMAScript 版本

######## 2. ECMAScript 符合性是什么意思

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

  • 支持ECMA-262 中描述的所有“类型、值、对象、属性、函数,以及程序语法与语义”;
  • 支持Unicode 字符标准。 此外,符合性实现还可以满足下列要求。
  • 增加ECMA-262 中未提及的“额外的类型、值、对象、属性和函数”。ECMA-262 所说的这些额 外内容主要指规范中未给出的新对象或对象的新属性。
  • 支持ECMA-262 中没有定义的“程序和正则表达式语法”(意思是允许修改和扩展内置的正则表达式特性)。

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

######## 3. 浏览器对ECMAScript 的支持

浏览器ECMAScript符合性
IE10~11第5版
Edge 12+第6版
Opera 36+第6版
Safari 9+第6版
iOS Safari 9.2+第6版
Chrome 49+第6版
Firefox 45+第6版
1.2.2 DOM

文档对象模型(DOM,Document Object Model)是一个应用编程接口(API),用于HTML 中使用扩展的XML。DOM 将整个页面抽象为一组分层节点。HTML 或XML 页面的每个组成部分都是一种节点,包含不同的数据。DOM 通过创建表示文档的树,让开发者可以随心所欲地控制网页的内容和结构。使用DOM API,可以轻松地删除、添加、替换、修改节点。

######## 1. 为什么DOM是必需的

因为网景和微软采用不同的思路开发DHTML,开发者写一个HTML页面可能在不同的浏览器有不同的运行结果,为了保持Web跨平台的本性,万维网联盟(W3C)开始了制定DOM的进程

######## 2. DOM级别

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

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

  • DOM 视图:描述追踪文档不同视图(如应用CSS 样式前后的文档)的接口
  • DOM 事件:描述事件及事件处理的接口。
  • DOM 样式:描述处理元素CSS 样式的接口。
  • DOM 遍历和范围:描述遍历和操作DOM 树的接口。

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。

######## 3. 其他DOM

除了DOM Core和DOM HTML接口,有其他语言也发布了自己的DOM标准。下面列出的语言是基于XML的,也是W3C推荐标准:

  • 可伸缩矢量图(SVG,Scalable Vector Graphics)
  • 数学标记语言(MathML,Mathematical Markup Language)
  • 同步多媒体集成语言(SMIL,Synchronized Multimedia Integration Language)

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

DOM 标准在Web 浏览器实现它之前就已经作为标准发布了。浏览器的兼容性的状态会随时间而变化。

1.2.3 BOM

IE3 和Netscape Navigator 3 提供了浏览器对象模型(BOM) API,用于支持访问和操作浏览器的窗口。使用BOM,开发者可以操控浏览器显示页面之外的部分。BOM没有相关标准的JavaScript实现。HTML5以正式规范的形式涵盖了尽可能多的BOM特性。

BOM 主要针对浏览器窗口和子窗口(frame),比如:

  • 弹出新浏览器窗口的能力;
  • 移动、缩放和关闭浏览器窗口的能力;
  • navigator 对象,提供关于浏览器的详尽信息;
  • location 对象,提供浏览器加载页面的详尽信息;
  • screen 对象,提供关于用户屏幕分辨率的详尽信息;
  • performance 对象,提供浏览器内存占用、导航行为和时间统计的详尽信息;
  • 对cookie 的支持;
  • 其他自定义对象,如XMLHttpRequest 和IE 的ActiveXObject。

因为在很长时间内都没有标准,所以每个浏览器实现的都是自己的BOM。

1.3 小结

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

  • ECMAScript:由ECMA-262 定义并提供核心功能。
  • 文档对象模型(DOM):提供与网页内容交互的方法和接口。
  • 浏览器对象模型(BOM):提供与浏览器交互的方法和接口。

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

2. HTML 中的JavaScript

2.1 <script> 元素

  • async: 可选。表示应该立即开始下载脚本,但不能阻止其他页面动作,比如下载资源或等待其他脚本加载。只对外部脚本文件有效。
  • charset: 可选。使用src 属性指定的代码字符集。这个属性很少使用,因为大多数浏览器不 在乎它的值。
  • crossorigin: 可选。配置相关请求的CORS(跨源资源共享)设置。默认不使用CORS。
  • defer: 可选。表示脚本可以延迟到文档完全被解析和显示之后再执行。只对外部脚本文件有效。在IE7 及更早的版本中,对行内脚本也可以指定这个属性。
  • integrity: 可选。允许比对接收到的资源和指定的加密签名以验证子资源完整性(SRI,Subresource Integrity)。如果接收到的资源的签名与这个属性指定的签名不匹配,则页面会报错,脚本不会执行。这个属性可以用于确保内容分发网络(CDN,Content Delivery Network)不会提供恶意内容。
  • language: 废弃。最初用于表示代码块中的脚本语言(如"JavaScript"、"JavaScript 1.2" 或"VBScript")。大多数浏览器都会忽略这个属性,不应该再使用它。
  • src: 可选。表示包含要执行的代码的外部文件。
  • type: 可选。代替language,表示代码块中脚本语言的内容类型(也称MIME 类型)。按照惯例,这个值始终都是"text/javascript",尽管"text/javascript"和"text/ecmascript"都已经废弃了。JavaScript 文件的MIME 类型通常是"application/x-javascript",不过给type 属性这个值有可能导致脚本被忽略。在非IE 的浏览器中有效的其他值还有"application/javascript"和"application/ecmascript"。如果这个值是module,则代码会被当成ES6 模块,而且只有这时候代码中才能出现import 和export 关键字。

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

包含在<script>内的代码会被从上到下解释。在<script>元素中的代码被计算完成之前,页面的其余内容不会被加载,也不会被显示。

要包含外部文件中的JavaScript,就必须使用src 属性。这个属性的值是一个URL,指向包含JavaScript 代码的文件,与解释行内JavaScript 一样,在解释外部JavaScript 文件时,页面也会阻塞。(阻塞时间也包含下载文件的时间。)在XHTML 文档中,可以忽略结束标签。

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

<script>还可以包含来自外部域的JavaScript文件,<script>元素的src 属性可以是一个完整的URL,而且这个URL 指向的资源可以跟包含它的HTML 页面不在同一个域中。浏览器在解析这个资源时,会向src 属性指定的路径发送一个GET 请求,以取得相应资源,假定是一JavaScript 文件。这个初始的请求不受浏览器同源策略限制,但返回并被执行的JavaScript 则受限制。当然,这个请求仍然受父页面HTTP/HTTPS 协议的限制。

来自外部域的代码会被当成加载它的页面的一部分来加载和解释。这个能力可以让我们通过不同的域分发JavaScript。不过在引用其他服务器中的JavaScript文件时需要小心,谨防恶意程序员替换JavaScript文件。

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

2.1.1 标签位置

将<script>标签放到<head>标签内,这意味着必须把所有JavaScript 代码都下载、解析和解释完成后,才能开始渲染页面(页面在浏览器解析到<body>的起始标签时开始渲染)。如果页面需要很多JavaScript的页面,会导致页面渲染慢,在此期间,页面会显示空白,所以现在会把<script>标签放到<body>元素中的页面内容后。

2.1.2 推迟执行脚本

HTML 4.01 为<script>元素定义了一个叫defer 的属性。这个属性表示脚本在执行的时候不会改变页面的结构。也就是说,脚本会被延迟到整个页面都解析完毕后再运行。因此,在<script>元素上设置defer 属性,相当于告诉浏览器立即下载,但延迟执行。按HTML5的规范,推迟的脚本会按照他们的顺序执行,但实际情况中,推迟的脚本不会按照推迟的顺序依次执行,所以最好只包含一个这样的脚本。

2.1.3 异步执行脚本

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

2.1.4 动态加载脚本

JavaScript 可以使用DOM API,通过向DOM中动态添加script 元素同样可以加载指定的脚本。只要创建一个script 元素并将其添加到DOM 即可。

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

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

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

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

javascript
<link rel="preload" href="XJS.js">
2.1.5 XHTML 中的变化

可扩展超文本标记语言(XHTML,Extensible HyperText Markup Language)是将HTML 作为XML的应用重新包装的结果。在XHTML 中使用JavaScript 必须指定type 属性且值为text/javascript,在XHTML 中编写代码的规则比HTML 中严格。

2.1.6 废弃的语法

部分浏览器不支持JavaScript而添加的语法

2.2 行内代码与外部文件

建议尽可能多的将JavaScript代码放在外部文件中引用,原因如下:

  • 可维护性。JavaScript 代码如果分散到很多HTML 页面,会导致维护困难。而用一个目录保存 所有JavaScript 文件,则更容易维护,这样开发者就可以独立于使用它们的HTML 页面来编辑 代码。
  • 缓存。浏览器会根据特定的设置缓存所有外部链接的JavaScript 文件,这意味着如果两个页面都 用到同一个文件,则该文件只需下载一次。这最终意味着页面加载更快。
  • 适应未来。通过把JavaScript 放到外部文件中,包含外部JavaScript 文件的语法在HTML 和XHTML 中是一样的。

2.3 文档模式

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

IE 初次支持文档模式切换后,其他浏览器也跟着实现了,随后又出现了第三种文档模式:准标准模式(almost standards mode) 。这种模式下的浏览器支持很多标准的特性,但是没有标准规定得那么严格。

使用混杂模式是以省略文档开头的doctype声明为开关。但使用混杂模式会使页面在不同浏览器中的差异极大。

2.4 <noscript>元素

<noscript>元素用于给不支持JavaScript的浏览器提供替代功能,<noscript>可以包含除<script>标签外的任何出现在<body>中的HTML元素。以下情况浏览器将显示<noscript>中的内容:

  • 浏览器不支持脚本;
  • 浏览器对脚本的支持被关闭。

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

2.5 小结

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

  • 要包含外部JavaScript 文件,必须将src 属性设置为要包含文件的URL。文件可以跟网页在同 一台服务器上,也可以位于完全不同的域。
  • 所有<script>元素会依照它们在网页中出现的次序被解释。在不使用defer 和async 属性的 情况下,包含在<script>元素中的代码必须严格按次序解释。
  • 对不推迟执行的脚本,浏览器必须解释完位于<script>元素中的代码,然后才能继续渲染页面 的剩余部分。为此,通常应该把<script>元素放到页面末尾,介于主内容之后及</body>标签 之前。
  • 可以使用defer 属性把脚本推迟到文档渲染完毕后再执行。推迟的脚本原则上按照它们被列出 的次序执行。
  • 可以使用async 属性表示脚本不需要等待其他脚本,同时也不阻塞文档渲染,即异步加载。异 步脚本不能保证按照它们在页面中出现的次序执行。
  • 通过使用<noscript>元素,可以指定在浏览器不支持脚本时显示的内容。如果浏览器支持并启 用脚本,则<noscript>元素中的任何内容都不会被渲染。

3. 语言基础

3.1 语法

3.1.1 区分大小写

ECMAScript 中一切都区分大小写。无论是变量、函数名还是操作符,都区分大小写。

3.1.2 标识符

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

  • 第一个字符必须是一个字母、下划线(_)或美元符号($);
  • 剩下的其他字符可以是字母、下划线、美元符号或数字。

标识符中的字母可以是ASCII中的字母,也可以是Unicode的字母字符。

ECMAScript 标识符建议使用驼峰大小写形式。

3.1.3 注释

// 单行注释

/*多航

注释*/

3.1.4 严格模式

ES5增加了严格模式,在严格模式中,ES3的一些不规范写法会被处理,不安全活动会抛出错误,使用方法:

//脚本开头 或 函数体开头

javascript
"use strict";
3.1.5 语句

使用分号是由程序员结束语句,不使用分号是由解析器结束语句。

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

3.2 关键字和保留字

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

3.3 变量

ECMAScript 变量是松散类型的,意思是变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符。

3.3.1 var关键字

var操作符可以定义变量,不初始化的情况下,变量会保存一个undefined的值。

######## 1. var声明作用域

var操作的变量会成为包含他函数的局部变量。使用var操作符在函数内定义一个变量,意味着该变量会在函数退出时被销毁。

省略var定义函数时,会定义一个全局变量。

如果需要定义多个变量,可以使用逗号分割每个变量。

######## 2. var声明提升

当var定义一个变量的时候, 会把变量声明拉到作用域的顶部,值为undefined。

3.3.2 let声明

let声明的范围是块作用域,var是函数作用域,let不允许在同一个块作用域中出现冗余声明,即对同一个变量名反复声明,无论是使用let还是var

######## 1. 暂时性死区

let不存在声明提升,在let 声明之前的执行瞬间被称为“暂时性死区”(temporal dead zone),如果在let声明前使用变量会抛出错误。

######## 2. 全局声明

使用let 在全局作用域中声明的变量不会成为window 对象的属性(var 声明的变量会)。在全局作用域下声明的let变量会在页面的生命周期内存续,应避免反复声明同一变量。

######## 3. 条件声明

因为条件块中let 声明的作用域仅限于该块,对于let 这个新的ES6 声明关键字,不能依赖条件声明模式。

######## 4. for 循环中的let 声明

可以在循环体中用let定义迭代变量,来避免变量渗透到外部或其他bug。

3.3.3 const声明

const在声明变量的时候必须初始化变量,且不能变更变量的值,此外const与let的性质基本相同。

3.3.4 声明风格及最佳实践
  1. 尽量不使用var
  2. const 优先,let 次之

3.4 数据类型

ECMAScript 有6 种简单数据类型(也称为原始类型):Undefined、Null、Boolean、Number、String 和Symbol。还有一种复杂数据类型叫Object(对象)。Object 是一种无序名值对的集合。

3.4.1 typeof 操作符

对一个值使用typeof 操作符会返回下列字符串之一:

  • "undefined"表示值未定义;
  • "boolean"表示值为布尔值;
  • "string"表示值为字符串;
  • "number"表示值为数值;
  • "object"表示值为对象(而不是函数)或null;
  • "function"表示值为函数;
  • "symbol"表示值为符号。

对null调用typeof操作符返回的是"object",因为特殊值null被认为是对一个空对象的引用。

3.4.2 Undefined类型

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

3.4.3 Null类型

Null 类型同样只有一个值,即特殊值null,代表了一个空对象指针。

3.4.4 Boolean类型

Boolean(布尔值)类型有两个字母值:true和false,区分大小写。所有值都可以转成Boolean类型。

3.4.5 Number类型

Number 类型使用IEEE 754 格式表示整数和浮点值(也可以称作双精度值)。可以用二进制,八进制(非严格模式),十六进制来表示数字。

######## 1. 浮点值

要定义浮点值,数值中必须包含小数点,而且小数点后面必须至少有一个数字。ECMAScript会把可以直接转成整数写法的浮点值转换成整数以减少储存空间。可以使用e来表示科学记数法。

######## 2. 值的范围

在多数浏览器中最小值为5e-324,最大值为1.797 693 134 862 315 7e+308,若超过数值的范围,会转换成Infinity(正无穷大)或-Infinity(负无穷大),且该值无法进行任何计算,该值可以用isFinite()函数确定。

######## 3. Nan

数值Nan的意思是"不是数值" (Not a Number),用于表示本来要返回数值的操作失败了(而不是抛出错误)。

NaN 不等于包括NaN 在内的任何值。可以通过isNan()函数确定Nan值。

######## 4. 数值转换

有3 个函数可以将非数值转换为数值:Number()、parseInt()和parseFloat()。Number()是转型函数,可用于任何数据类型。后两个函数主要用于将字符串转换为数值。

3.4.6 String类型

String(字符串)数据类型表示零或多个16 位Unicode 字符序列。字符串可以使用双引号(")、单引号(')或反引号(`)标示。

######## 1. 字符字面量

字符串数据类型包含一些字符字面量,用于表示非打印字符或有其他用途的字符,类似\n(换行),\t(制表)

######## 2. 字符串的特点

ECMAScript 中的字符串是不可变的(immutable),一旦创建,若想更改,只能销毁重新创建。

######## 3. 转换成字符串

########## 1. toString()

这个方法唯一的用途就是返回当前值的字符串等价物。可以接受一个底数参数(2,8,10,16进制)。

########## 2. String()

返回表示相应类型值的字符串,会调用值的toString()方法

######## 4. 模板字符串

` /*字符串

内容*/ `

######## 5. 字符串插值

$

######## 6. 模板字面量标签函数

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

######## 7. 原始字符串

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

3.4.7 Symbol 类型

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

######## 1. 符号的基本用法

符号需要使用Symbol()函数初始化,可以传入字符串参数作为对Symbol符号的描述,将来可以通过这个字符串来调试代码。Symbol()函数不能与new 关键字一起作为构造函数使用。

######## 2. 使用全局符号注册表

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

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

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

######## 3. 使用符号作为属性

######## 4. 常用内置符号

ECMAScript 6 也引入了一批常用内置符号(well-known symbol),用于暴露语言内部行为,开发者可以直接访问、重写或模拟这些行为。这些内置符号都以Symbol 工厂函数字符串属性的形式存在。这些内置符号最重要的用途之一是重新定义它们,从而改变原生结构的行为。

######## 5. Symbol.asyncIterator

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

######## 6. Symbol.hasInstance

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

######## 7. Symbol.isConcatSpreadable

根据ECMAScript 规范,这个符号作为一个属性表示“一个布尔值,如果是true,则意味着对象应该用Array.prototype.concat()打平其数组元素”。

######## 8. Symbol.iterator

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

######## 9. Symbol.match

根据ECMAScript 规范,这个符号作为一个属性表示“一个正则表达式方法,该方法用正则表达式去匹配字符串。由String.prototype.match()方法使用”。String.prototype.match()方法会使用以Symbol.match 为键的函数来对正则表达式求值。

######## 10. Symbol.replace

根据ECMAScript 规范,这个符号作为一个属性表示“一个正则表达式方法,该方法替换一个字符串中匹配的子串。由String.prototype.replace()方法使用”。String.prototype.replace()方法会使用以Symbol.replace 为键的函数来对正则表达式求值。

######## 11. Symbol.search

根据ECMAScript 规范,这个符号作为一个属性表示“一个正则表达式方法,该方法返回字符串中匹配正则表达式的索引。由String.prototype.search()方法使用”。String.prototype.search()方法会使用以Symbol.search 为键的函数来对正则表达式求值。

######## 12. Symbol.species

根据ECMAScript 规范,这个符号作为一个属性表示“一个函数值,该函数作为创建派生对象的构造函数”。这个属性在内置类型中最常用,用于对内置类型实例方法的返回值暴露实例化派生对象的方法。

######## 13. Symbol.split

根据ECMAScript 规范,这个符号作为一个属性表示“一个正则表达式方法,该方法在匹配正则表达式的索引位置拆分字符串。由String.prototype.split()方法使用”。String.prototype.split()方法会使用以Symbol.split 为键的函数来对正则表达式求值。

######## 14. Symbol.toPrimitive

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

######## 15. Symbol.toStringTag

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

######## 16. Symbol.unscopables

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

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

3.4.8 Object 类型

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

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

  • constructor:用于创建当前对象的函数。在前面的例子中,这个属性的值就是Object()函数。
  • hasOwnProperty(propertyName):用于判断当前对象实例(不是原型)上是否存在给定的属性。要检查的属性名必须是字符串(如o.hasOwnProperty("name"))或符号。
  • isPrototypeOf(object):用于判断当前对象是否为另一个对象的原型。
  • propertyIsEnumerable(propertyName):用于判断给定的属性是否可以使用for-in 语句枚举。与hasOwnProperty()一样,属性名必须是字符串。
  • toLocaleString():返回对象的字符串表示,该字符串反映对象所在的本地化执行环境。
  • toString():返回对象的字符串表示。
  • valueOf():返回对象对应的字符串、数值或布尔值表示。通常与toString()的返回值相同。

3.5 操作符

3.5.1 一元操作符

只操作一个值的操作符叫一元操作符(unary operator)。

######## 1. 递增/递减操作符

前缀版:先进行递增或递减操作,再进行运算。

后缀版:先进行运算,再进行递增或递减操作。

######## 2. 一元加和减

一元加由一个加号(+)表示,放在变量前头,对数值没有任何影响。

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

3.5.2 位操作符

ECMAScript中的所有数值都以IEEE 754 64 位格式存储,但位操作并不直接应用到64 位表示,而是先把值转换为32 位整数,再进行位操作,之后再把结果转换为64 位。

######## 1. 按位非

按位非操作符用波浪符(~)表示,它的作用是返回数值的一补数(反码)。按位非的最终效果是对数值取负并减1。

######## 2. 按位与

按位与操作符用和号(&)表示,有两个操作数。按位与就是将两个数的每一个位对齐,在两个位都是1 时返回1,在任何一位是0 时返回0。

######## 3. 按位或

按位或操作符用管道符(|)表示,有两个操作数。在两位中至少一位是1 时返回1,两位都是0 时返回0。

######## 4. 按位异或

按位异或用脱字符(^)表示,有两个操作数。在一位上是1 的时候返回1(两位都是1 或0,则返回0)。

######## 5. 左移(保留符号)

左移操作符用两个小于号(<<)表示,会按照指定的位数将数值的所有位向左移动(保留符号)。

######## 6. 有符号右移

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

######## 7. 无符号右移

无符号右移用3 个大于号表示(>>>),会将数值的所有32 位都向右移。对于正数,无符号右移与有符号右移结果相同。对于负数,差异会非常大,因为无符号右移操作符将负数的二进制表示当成正数的二进制表示来处理。

3.5.3 布尔操作符

######## 1. 逻辑非

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

逻辑非操作符也可以用于把任意值转换为布尔值。同时使用两个叹号(!!),相当于调用了转型函数Boolean()。

######## 2. 逻辑与

逻辑与操作符由两个和号**(&&)**表示,应用到两个值,只有当两个值都为true的时候,返回true。

逻辑与操作符是一种短路操作符,意思就是如果第一个操作数决定了结果,那么永远不会对第二个操作数求值。即第一个表达式为false的时候,不会执行第二个表达式。

######## 3. 逻辑或

逻辑或操作符由两个管道符**(||)**表示,当两个值中任意一个值为true时,返回true。

逻辑或也是短路操作符,即第一个表达式为true时,不会执行第二个表达式。

3.5.4 乘性操作符

######## 1. 乘性操作符

######## 2. 除法操作符

######## 3. 取模操作符

3.5.5 指数操作符

3 ** 2 等同于 Math.pow(3,2),值为3的平方9

3.5.6 加性操作符

######## 1. 加法操作符

当字符串和数值进行加法时,会进行将数值转换,进行拼接字符串。

######## 2. 减法操作符

3.5.7 关系操作符

关系操作符执行比较两个值的操作,包括小于(<)、大于(>)、小于等于(<=)和大于等于(>=)。

3.5.8 相等操作符

ECMAScript提供了两组操作符。第一组是等于不等于,它们在比较之前执行转换。第二组是全等不全等,它们 在比较之前不执行转换。

######## 1. 等于和不等于

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

######## 2. 全等和不全等

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

3.5.9 条件操作符
javascript
variable = boolean_expression ? true_value : false_value;
3.5.10 赋值操作符

简单赋值用等于号(=)表示,将右手边的值赋给左手边的变量,可与数学操作符合用

3.5.11 逗号操作符

逗号操作符可以用来在一条语句中执行多个操作,多用于声明多个变量

3.6 语句

3.6.1 if 语句
javascript
if (condition) statement1 else statement2

建议加上代码块使用:

javascript
if (condition) {

	statement1

}else{

	statement2

}

也可以多个if, 用else if替换除第一个以外的if同时执行:

javascript
if (condition) {

	statement1

}else if (condition2){

	statement2

}else {

	statement3
	
}
3.6.2 do-while 语句

do-while 语句是一种后测试循环语句,即循环体中的代码执行后才会对退出条件进行求值。

3.6.3 while 语句

while 语句是一种先测试循环语句,即先检测退出条件,再执行循环体内的代码。因此,while 循环体内的代码有可能不会执行。

3.6.4 for 语句

for 语句也是先测试语句,只不过增加了进入循环之前的初始化代码,以及循环执行后要执行的表达式。

3.6.5 for-in 语句

for-in 语句是一种严格的迭代语句,用于枚举对象中的非符号键属性。

3.6.6 for-of 语句

for-of 语句是一种严格的迭代语句,用于遍历可迭代对象的元素

3.6.7 标签语句

标签语句用于给语句加标签:

label:statement

可以在后面通过break 或continue 语句引用,标签语句的典型应用场景是嵌套循环。

3.6.8 break 和continue 语句

break 和continue 语句为执行循环代码提供了更严格的控制手段。其中,break 语句用于立即退出循环,强制执行循环后的下一条语句。而continue 语句也用于立即退出循环,但会再次从循环顶部开始执行。

3.6.9 with 语句

with 语句的用途是将代码作用域设置为特定的对象,其语法是:

javascript
with (expression) statement;

严格模式不允许使用with 语句,否则会抛出错误。由于with 语句影响性能且难于调试其中的代码,通常不推荐在产品代码中使用with 语句。

3.6.10 switch 语句

分支语句,当expression的值为某个value时,执行相应的statement。

javascript
switch (expression) {
    case value1:
        statement
        break;
    case value2:
        statement
        break;
    case value3:
        statement
        break;
    case value4:
        statement
        break;
    default:
        statement
}

3.7 函数

函数对任何语言来说都是核心组件,因为它们可以封装语句,然后在任何地方、任何时间执行。ECMAScript 中的函数使用function 关键字声明,后跟一组参数,然后是函数体。

3.8 小结

JavaScript 的核心语言特性在ECMA-262 中以伪语言ECMAScript 的形式来定义。ECMAScript 包含 所有基本语法、操作符、数据类型和对象,能完成基本的计算任务,但没有提供获得输入和产生输出的 机制。理解ECMAScript 及其复杂的细节是完全理解浏览器中JavaScript 的关键。下面总结一下 ECMAScript 中的基本元素。

  • ECMAScript 中的基本数据类型包括Undefined、Null、Boolean、Number、String 和Symbol。
  • 与其他语言不同,ECMAScript 不区分整数和浮点值,只有Number 一种数值数据类型。
  • Object 是一种复杂数据类型,它是这门语言中所有对象的基类。
  • 严格模式为这门语言中某些容易出错的部分施加了限制。
  • ECMAScript 提供了C 语言和类C 语言中常见的很多基本操作符,包括数学操作符、布尔操作符、 关系操作符、相等操作符和赋值操作符等。
  • 这门语言中的流控制语句大多是从其他语言中借鉴而来的,比如if 语句、for 语句和switch 语句等。

ECMAScript 中的函数与其他语言中的函数不一样。

  • 不需要指定函数的返回值,因为任何函数可以在任何时候返回任何值。
  • 不指定返回值的函数实际上会返回特殊值undefined。

4. 变量、作用域与内存

4.1 原始值与引用值

ECMAScript 变量可以包含两种不同类型的数据:原始值和引用值。原始值(primitive value)就是最简单的数据(包括Undefined、Null、Boolean、Number、String 和Symbol),引用值(reference value)则是由多个值构成的对象。在把一个值赋给变量时,JavaScript 引擎必须确定这个值是原始值还是引用值。如果是原始值,可以直接操作。而引用值是保存在内存中的对象,JavaScript不允许直接访问内存地址,即对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用而非实际的对象本身。

4.1.1 动态属性

对于引用值而言,可以随时添加,修改和删除其中的属性和方法。而原始值不能有属性(尽管不会报错)。原始类型的初始化可以只使用原始字面量形式。如果使用new关键字,实际上是创建了一个实例对象。

4.1.2 复制值

原始值和引用值在通过变量复制时会有所不同。

原始值:在通过变量把一个原始值赋值到另一个变量时,表面上复制的是变量,但实际上是赋值。

javascript
let num1=5
let num2=num1 //num2=5
console.log(num1) // 5
console.log(num2) // 5
num1=10
console.log(num1) // 10
console.log(num2) //5

这里num2的值会被赋值为5,且不随num1的改变而改变,两个变量互不影响(类似深拷贝)。

复制值:在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来。

javascript
let obj1 = new Object();
let obj2 = obj1;
obj1.name = "YUYU";
console.log(obj2.name); // "YUYU"
4.1.3 传递参数

ECMAScript 中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。在按值传递参数时,值会被复制到一个局部变量,即通过传参进入函数的变量,变量在函数体内的更改不会影响函数体外同名变量的值。

javascript
// 传递原始值参数
function addTen(num) {
    num += 10;
    return num;
}
let count = 20;
let result = addTen(count);
console.log(count); // 20,没有变化
console.log(result); // 30
javascript
// 传递引用值(对象)参数
function setName(obj) {
    obj.name = "YUYU";// 给传递进来的obj对象赋值
    obj = new Object(); //实例化一个新的对象
    obj.name = "Wang";// 如果函数参数是引用值,则会将obj.name更改成"Wang",实际是重						  新实例了一个Object对象,obj.name="Wang"
}
let person = new Object();
setName(person);
console.log(person.name); // "YUYU"

Note:ECMAScript 中函数的参数就是局部变量。

4.1.4 确定类型

typeof 可以用来确定原始值的类型,但无法确定对象类型。这时可以使用instanceof操作符:

javascript
result = variable instanceof constructor

如果变量是引用值,引用值一定是Object的实例,即原型链最后会返回Object对象,所以用instanceof操作符会返回true,而对原始值使用instanceof则会返回false。

4.2 执行上下文与作用域

每一个执行上下文都有一个关联的变量对象,而这个上下文中定义的所有变量和函数都存在于这个对象上,也可以理解成当前代码的执行环境,包括全局环境(window)和函数环境。在一个执行上下文中,最重要的三个属性分别是变量对象(Variable Object)、作用域链(Scope Chain)和 this 指向。执行上下文是以栈的方式被存放起来的,被称之为执行上下文栈(Execution Context Stack)。当一个页面运行的时候,全局执行上下文先被压入栈,随后根据函数的执行顺序依次入栈,也解释了当函数中需要某个变量的时候,可以从外部函数的执行环境中寻找变量,一直找到全局环境,即作用域链。而全局环境,则不可以使用函数环境中的变量。

4.2.1 作用域链增强

可以通过两种方式在作用域链前端临时添加一个上下文,这个上下文会在代码执行后被删除:

  • try/catch 语句的catch 块
  • with 语句

catch语句会创建一个新的变量对象,这个对象包含捕捉并要抛出的错误声明;

with语句,会将with语句中的参数作为指定对象添加到作用域链的前端。

4.2.2 变量声明

######## 1. 使用var的函数作用域声明

在函数内使用var声明变量时,变量会被自动添加到最接近的上下文,即函数的局部上下文,此时var声明的变量成为局部变量,如果不适用var声明,直接初始化变量,则会添加到全局上下文,成为全局变量。而添加到上下文的过程,会将变量声明拉到函数和全局作用域顶部,即**”提升“**(Hoisting)

######## 2. 使用let 的块级作用域声明

let关键字的作用域是块级的,由最近的花括号{ }界定,不能在一个块级作用域内,重复使用let声明同一变量。

######## 3. 使用const的常量声明

const 声明的变量必须同时初始化为某个值,一经声明,在其生命周期的任何时候都不能再重新赋予新值,如果const声明的是一个对象(引用值),则不能更换引用的地址,但可以更改对象内部的属性。

如果想让整个对象都不能修改,可以使用Object.freeze(),这样再给属性赋值时虽然不会报错, 但会静默失败(即调用属性时,显示undefined)。

######## 4. 标识符查找

javascript
var color = 'blue';
function getColor() {
    let color = 'red';
    {
        let color = 'green';
        return color;
    }
}
console.log(getColor()); // 'green'

类似事件冒泡的过程,由下往上,当找到需要查找的标识符的对应值,直接返回,如果没有找到,则会顺着作用域链,一直到全局上下文。

4.3 垃圾回收

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。当确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。当函数在内部使用了变量,当这个函数执行完毕,就不再需要这个局部变量,这个局部变量会被销毁,而占据的内存空间会被回收,但不是所有时候都这么明显说明可以回收。垃圾回收程序必须跟踪记录哪个变量仍会使用,哪些不会再使用。在浏览器的发展史中,使用过两种主要的标记未使用的变量的策略:标记清理和引用清理。

4.3.1 标记清理

JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文的时候,会被加上标记,而离开上下文的时候,会被删除标记。当垃圾回收程序需要回收的时候,会遍历环境中的变量,以及环境变量中引用的变量,给这些变量打上标记,遍历完成后,销毁回收那些没有标记的空间。

4.3.2 引用计数

另一种没那么常用的垃圾回收策略是引用计数(reference counting)。思路是对每一个值都记录它被引用的次数。当声明变量并给它赋一个引用值的时候,这个值的引用数是1。每被引用一次的时候,引用数+1。当失去某一个变量的引用的时候,引用数-1。当引用数为0的时候,说明没有对象引用这个值,那么这个值就会被销毁回收。但实际应用的时候发生了循环引用的情况,即:

javascript
function problem() {
let objectA = new Object();
let objectB = new Object();
objectA.someOtherObject = objectB;
objectB.anotherObject = objectA;
}

在以上代码中,对象A和对象B互相引用,对象A和对象B的引用数为2(被另一个对象和自身引用),当这个函数执行完毕的时候,对象A和对象B应该被回收。但在引用计数的策略下,他们并不会被回收,如果该函数被多次调用,会生成多组相同的对象,占据很多的空间而不会被回收。所以需要在函数结束是,将变量设置成null,来切断变量和其引用值的关系。这样这些变量在下次垃圾回收程序运行时,就会被回收。

4.3.3 性能

垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。现代垃圾回收程序会基于对JavaScript 运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。

4.3.4 内存管理

在软件开发中,开发者一般无需关心内存管理,但在一个系统中,分配给浏览器的内存往往很少,所以要及时回收不再使用的变量,局部变量会因为超出作用域而被自动解除引用,但全局变量不会,所以建议对不需要的变量,将它设置成null,从而释放引用。但解除引用不会导致它立刻被回收,被解除引用的变量会在下次垃圾回收程序执行的时候被回收。

######## 1. 通过const和let提升性能

因为const和let 都以块(而非函数)为作用域,所以相比于使用var,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。

######## 2. 隐藏类和删除操作(针对谷歌V8 Js引擎)

当我们定义了一个类并分别实例两个对象如:

javascript
function Article(opt_author) {
	this.title = 'Inauguration Ceremony Features Kazoo Band';
}
let a1 = new Article();
let a2 = new Article();

如果想动态的给对象添加属性,建议在构造函数里添加属性,然后通过调用实例对象的属性,将属性设置成null,来提高性能。

######## 3. 内存泄漏

  • 意外声明全局变量(不通过var,let,const声明)
  • 闭包(函数内部变量被反复引用)

######## 4. 静态分配与对象池

避免反复创建新对象。

4.4 小结

JavaScript 变量可以保存两种类型的值:原始值和引用值。原始值可能是以下6 种原始数据类型之一:Undefined、Null、Boolean、Number、String 和Symbol。原始值和引用值有以下特点。

  • 原始值大小固定,因此保存在栈内存上。
  • 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
  • 引用值是对象,存储在堆内存上。
  • 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
  • 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
  • typeof 操作符可以确定值的原始类型,而instanceof 操作符用于确保值的引用类型。

任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。执行上下文可以总结如下。

  • 执行上下文分全局上下文、函数上下文和块级上下文。
  • 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
  • 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
  • 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
  • 变量的执行上下文用于确定什么时候释放内存。

JavaScript 是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。JavaScript 的垃圾回收程序可以总结如下。

  • 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。

  • 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。

  • 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。JavaScript 引擎不再使用这种算法,但某些旧版本的IE 仍然会受这种算法的影响,原因是JavaScript 会访问非原生JavaScript 对象(如DOM元素)。

  • 引用计数在代码中存在循环引用时会出现问题。

  • 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。

5 基本引用类型

5.1 Date

Date类型将日期保存为自协调世界时时间,为1970年1月1日午夜(零时)至今所经过的毫秒数,可以表示1970年1月1日之前及之后285616年的日期。

javascript
let now = new Date() // 构造函数,默认当前时间

可以使用Date.parse()方法接受一个表示日期的字符串参数,并返回一个该日期的毫秒数:

  • “月/日/年”,如"5/23/2019";
  • “月名 日, 年”,如"May 23, 2019";
  • “周几 月名 日 年 时:分:秒 时区”,如"Tue May 23 2019 00:00:00 GMT-0700";
  • ISO 8601 扩展格式“YYYY-MM-DDTHH:mm:ss.sssZ”,如2019-05-23T00:00:00(只适用于 兼容ES5 的实现)。

如果传给Date.parse()的字符串并不表示日期,则该方法会返回NaN。如果直接把表示日期的字符串传给Date 构造函数,那么Date 会在后台调用Date.parse()。

Date.UTC()方法也返回日期的毫秒表示(GMT时间)

还可以使用Date.now()返回方法执行时的毫秒数

5.1.1 继承的方法

Date类型重写了toLocaleString()、toString()和valueOf()方法。

toLocaleString()方法返回与浏览器运行的本地环境一致的日期和时间;

toString()方法通常返回带时区信息的日期和时间,而时间也是以24 小时制(0~23)表示的;

valueOf()方法返回的是日期的毫秒表示。

5.1.2 日期格式化方法

Date 类型有几个专门用于格式化日期的方法,它们都会返回字符串:

  • toDateString()显示日期中的周几、月、日、年(格式特定于实现);
  • toTimeString()显示日期中的时、分、秒和时区(格式特定于实现);
  • toLocaleDateString()显示日期中的周几、月、日、年(格式特定于实现和地区);
  • toLocaleTimeString()显示日期中的时、分、秒(格式特定于实现和地区);
  • toUTCString()显示完整的UTC 日期(格式特定于实现)。

这些方法的输出与toLocaleString()和toString()一样,会因浏览器而异。因此不能用于在用户界面上一致地显示日期。

5.1.3 日期/时间组件方法

5.2 RegExp

ECMAScript 通过RegExp 类型支持正则表达式:

javascript
let expression = /pattern/flags;

这个正则表达式的pattern(模式)可以是任何简单或复杂的正则表达式,包括字符类、限定符、分组、向前查找和反向引用。每个正则表达式可以带零个或多个flags(标记),用于控制正则表达式的行为。下面给出了表示匹配模式的标记:

  • g:全局模式,表示查找字符串的全部内容,而不是找到第一个匹配的内容就结束。
  • i:不区分大小写,表示在查找匹配时忽略pattern 和字符串的大小写。
  • m:多行模式,表示查找到一行文本末尾时会继续查找。
  • y:粘附模式,表示只查找从lastIndex 开始及之后的字符串。
  • u:Unicode 模式,启用Unicode 匹配。
  • s:dotAll 模式,表示元字符.匹配任何字符(包括\n 或\r)。

注:元字符在模式中需要转义

5.2.1 RegExp 实例属性

每个RegExp 实例都有下列属性,提供有关模式的各方面信息。

  • global:布尔值,表示是否设置了g 标记。
  • ignoreCase:布尔值,表示是否设置了i 标记。
  • unicode:布尔值,表示是否设置了u 标记。
  • sticky:布尔值,表示是否设置了y 标记。
  • lastIndex:整数,表示在源字符串中下一次搜索的开始位置,始终从0 开始。
  • multiline:布尔值,表示是否设置了m 标记。
  • dotAll:布尔值,表示是否设置了s 标记。
  • source:正则表达式的字面量字符串(不是传给构造函数的模式字符串),没有开头和结尾的 斜杠。
  • flags:正则表达式的标记字符串。始终以字面量而非传入构造函数的字符串模式形式返回(没 有前后斜杠)。
5.2.2 RegExp 实例方法

RegExp 实例的主要方法是exec(),主要用于配合捕获组使用。这个方法只接收一个参数,即要应用模式的字符串。如果找到了匹配项,则返回包含第一个匹配信息的数组;如果没找到匹配项,则返回null。返回的数组虽然是Array 的实例,但包含两个额外的属性:index 和input。index 是字符串中匹配模式的起始位置,input 是要查找的字符串。这个数组的第一个元素是匹配整个模式的字符串,其他元素是与表达式中的捕获组匹配的字符串。如果模式中没有捕获组,则数组只包含一个元素。

5.2.3 RegExp 构造函数属性

RegExp 构造函数本身也有几个属性。(在其他语言中,这种属性被称为静态属性。)这些属性适用 于作用域中的所有正则表达式,而且会根据最后执行的正则表达式操作而变化。

5.2.4 模式局限

正则没其他语言完善

5.3 原始值包装类型

为了方便操作原始值,ECMAScript 提供了3 种特殊的引用类型:Boolean、Number 和String。。每当用到某个原始值的方法或属性时,后台都会创建一个相应原始包装类型的对象,从而暴露出操作原始值的各种方法。

javascript
let s1 = "some text"; // 给s1赋值字符串(原始值)
let s2 = s1.substring(2); // 调用s1的方法

后台将上述代码分成三步:(Boolean类型和Number类型同理)

​ (1) 创建一个String 类型的实例;

​ (2) 调用实例上的特定方法;

​ (3) 销毁实例。

javascript
let s1 = new String("some text"); // 以String为原型实例一个对象
let s2 = s1.substring(2);	// 调用String内部的方法
s1 = null; // 销毁实例

但使用这种方法的时候,可以发现s1这个实例对象在方法调用后被立即销毁,所以实际上无法操作s1这个实例对象。

5.3.1 Boolean

Boolean 是对应布尔值的引用类型。要创建一个Boolean 对象,就使用Boolean 构造函数并传入true 或false,如下例所示:

javascript
let booleanObject = new Boolean(true||false);

在使用时,容易曲解原始布尔值和布尔对象之间的区别,不建议使用布尔对象。

5.3.2 Number

Number 是对应数值的引用类型。要创建一个Number 对象,就使用Number 构造函数并传入一个数值,如下例所示:

javascript
let numberObject = new Number(Number);

Number类型中的valueOf()方法可以返回Number对象表示的原始数值,而toLocaleString()和toString()方法返回的是数字字符串。此外Number类型还提供了几个方法:

  • toFixed(num)方法返回包含num位小数点的数值字符串(一般支持0-20位)
  • toExponential(num)方法返回包含num位数字的科学记数法(一般支持1-21位)

Number.isInteger(num)是ES6新增的用于辨别一个数值是否保存为整数

Number.isSafeInteger(num)可以鉴别整数是否在IEEE 754数值范围中(-2^53+1~2^53-1)

5.3.3 String

String 是对应字符串的引用类型。要创建一个String 对象,使用String 构造函数并传入一个数值,如下例所示:

javascript
let stringObject = new String("hello world");

3 个继承的方法valueOf()、toLocaleString()和toString()都返回对象的原始字符串值。

可以通过length属性获得字符串的数量,此外String 类型提供了很多方法来解析和操作字符串。

######## 1. JavaScript 字符

JavaScript 字符串由16 位码元(code unit)组成。对多数字符来说,每16 位码元对应一个字符。

  • charAt() 返回指定字符的在字符串的下标
  • charCodeAt() 返回指定字符的字符编码
  • fromCharCode() 返回指定字符编码代表的字符,可以接受多个参数并拼接返回

######## 2. normalize()方法

可以判断字符串是否已经规范化了。[ 四种规范化形式:NFD(Normalization Form D)、NFC(Normalization Form C)、NFKD(Normalization Form KD)和NFKC(Normalization Form KC)]

######## 3. 字符串操作方法

  • concat() 方法用于将一个或多个字符串拼接成一个新字符串。(+号拼接更方便)
  • slice() 方法提取某个字符串的一部分,并返回一个新的字符串,且不会改动原字符串。
  • substr() 方法返回一个字符串中从指定位置开始到指定字符数的字符。
  • substring() 方法返回一个字符串在开始索引到结束索引之间的一个子集, 或从开始索引直到字符串的末尾的一个子集。

######## 4. 字符串位置方法

  • indexOf() 方法返回调用它的对象中第一次出现的指定值的索引,从 fromIndex处进行搜索。如果未找到该值,则返回 -1。
  • lastIndexOf() 方法返回调用对象的指定值最后一次出现的索引,在一个字符串中的指定位置 fromIndex处从后向前搜索。如果没找到这个特定值则返回-1 。

######## 5. 字符串包含方法

  • startsWith() 方法用来判断当前字符串是否以另外一个给定的子字符串开头,并根据判断结果返回 true 或 false。
  • endsWith() 方法用来判断当前字符串是否是以另外一个给定的子字符串“结尾”的,根据判断结果返回 true 或 false。
  • includes() 方法用来判断一个数组是否包含一个指定的值,根据情况,如果包含则返回true,否则返回 false。

######## 6. trim()方法

trim() 方法返回一个从两头去掉空白字符的字符串,并不影响原字符串本身。

######## 7. repeat()方法

repeat() 方法构造并返回一个新字符串,该字符串包含被连接在一起的指定数量的字符串的副本。

######## 8. padStart()和padEnd()方法

  • padStart() 方法用另一个字符串填充当前字符串(如果需要的话,会重复多次),以便产生的字符串达到给定的长度。从当前字符串的左侧开始填充。
  • padEnd() 方法会用一个字符串填充当前字符串(如果需要的话则重复填充),返回填充后达到指定长度的字符串。从当前字符串的末尾(右侧)开始填充。

######## 9. 字符串迭代与解构

字符串的原型上暴露了一个@@iterator 方法,表示可以迭代字符串的每个字符。

javascript
let message = "abcde";
console.log([...message]); // ["a", "b", "c", "d", "e"]

######## 10. 字符串大小写转换

  • toLowerCase() 会将调用该方法的字符串值转为小写形式,并返回。

  • toUpperCase() 会将调用该方法的字符串值转为大写形式,并返回。

######## 11. 字符串模式匹配方法

  • match() 方法检索返回一个字符串匹配正则表达式的结果。

  • replace() 方法返回一个由替换值(replacement)替换部分或所有的模式(pattern)匹配项后的新字符串。模式可以是一个字符串或者一个正则表达式,替换值可以是一个字符串或者一个每次匹配都要调用的回调函数。如果pattern是字符串,则仅替换第一个匹配项。

  • split() 方法使用指定的分隔符字符串将一个String对象分割成子字符串数组,以一个指定的分割字串来决定每个拆分的位置。

######## 12. localeCompare()方法

localeCompare() 方法返回一个数字来指示一个参考字符串是否在排序顺序前面或之后或与给定字符串相同。

######## 13. HTML 方法

实际上就是用JavaScript动态生成HTML标签。

5.4 单例内置对象

ECMA-262 对内置对象的定义是“任何由ECMAScript 实现提供、与宿主环境无关,并在ECMAScript程序开始执行时就存在的对象”。这就意味着,开发者不用显式地实例化内置对象,因为它们已经实例化好了。

5.4.1 Global

Global 对象是ECMAScript 中最特别的对象,因为代码不会显式地访问它。ECMA-262 规定Global对象为一种兜底对象,它所针对的是不属于任何对象的属性和方法。事实上,不存在全局变量或全局函数这种东西。在全局作用域中定义的变量和函数都会变成Global 对象的属性 。

######## 1. URL 编码方法

  • encodeURI()
  • encodeURIComponent()

######## 2. eval()方法

eval() 函数会将传入的字符串当做 JavaScript 代码进行执行。在严格模式下,在eval()内部创建的变量和函数无法被外部访问,给eval赋值也会导致错误。

######## 3. Global 对象属性

######## 4. window 对象

window对象是Global对象的代理。因此所有全局作用域中声明的变量和函数都变成了window的属性。

5.4.2 Math

ECMAScript 提供了Math 对象作为保存数学公式、信息和计算的地方。Math 对象提供了一些辅助计算的属性和方法。

######## 1. Math对象属性

属性说明
Math.E自然对数的基数e的值
Math.LN1010为底的自然对数
Math.LN22为底的自然对数
Math.LOG2E以2为底e的对数
Math.LOG10E以10为底e的对数
Math.PIπ的值
Math.SQRT1_21/2的平方根
Math.SQRT22的平方根

######## 2. min()和max()方法

  • Math.min() 函数返回一组数中的最小值。
  • Math.max() 函数返回一组数中的最大值

######## 3. 舍入方法

  • Math.ceil()方法始终向上舍入为最接近的整数。
  • Math.floor()方法始终向下舍入为最接近的整数。
  • Math.round()方法执行四舍五入。
  • Math.fround()方法返回数值最接近的单精度(32 位)浮点值表示。

######## 4. random()方法

Math.random()方法返回一个0~1 范围内的随机数,其中包含0 但不包含1。

######## 5. 其他方法

5.5 小结

  • 引用值与传统面向对象编程语言中的类相似,但实现不同。
  • Date 类型提供关于日期和时间的信息,包括当前日期、时间及相关计算。
  • RegExp 类型是ECMAScript 支持正则表达式的接口,提供了大多数基础的和部分高级的正则表 达式功能。

JavaScript 比较独特的一点是,函数实际上是Function 类型的实例,也就是说函数也是对象。因为函数也是对象,所以函数也有方法,可以用于增强其能力。 由于原始值包装类型的存在,JavaScript 中的原始值可以被当成对象来使用。有3 种原始值包装类型:Boolean、Number 和String。它们都具备如下特点。

  • 每种包装类型都映射到同名的原始类型。
  • 以读模式访问原始值时,后台会实例化一个原始值包装类型的对象,借助这个对象可以操作相 应的数据。
  • 涉及原始值的语句执行完毕后,包装对象就会被销毁。

当代码开始执行时,全局上下文中会存在两个内置对象:Global 和Math。其中,Global 对象在大多数ECMAScript 实现中无法直接访问。不过,浏览器将其实现为window 对象。所有全局变量和函数都是Global 对象的属性。Math 对象包含辅助完成复杂计算的属性和方法。

6. 集合引用类型

6.1 Object

访问object内属性的方式:

  • 点语法 object.propertyName,适用于属性名由字母数字组成;
  • 使用中括号 object['property name'],适用于所有情况。

6.2 Array

JavaScript中的数组可以保存一组有序的不同数据类型的数据。

6.2.1 创建数组
  • Array.from() 方法对一个类似数组或可迭代对象创建一个新的,浅拷贝的数组实例。可以对参数执行回调和改变回调函数中的this指向
  • Array.of() 方法创建一个具有可变数量参数的新数组实例,而不考虑参数的数量或类型。
6.2.2 数组空位

在使用数组字面量初始化数组的时候,可以使用一串逗号来创造空位。ES6将这些空位视为undefined值,在ES6之前的版本会忽略这些空位,具体行为视方法而定。

Note:由于行为不一致和存在性能隐患,实践中避免使用数组空位,如要使用,可使用undefined值替换。

6.2.3 数组索引

数组索引即数组下标,如果把一个值设置给超过数组最大索引的索引,则数组长度会自动扩展到该索引值加1。

数组中的length属性可以返回数组中的元素个数,可以通过更改length值来更改数组,如果设置的length值小于原始的length值,会删除包括array[array.length] 后的所有值;如果设置的length值大于原始的length值,则会填充undefined值直到 array[array.length-1]

6.2.4 检测数组

Array.isArray()方法判断传入的参数是否为数组

6.2.5 迭代器方法
  • keys() 方法返回一个包含数组中每个索引键的Array Iterator对象。
  • values() 方法返回一个新的 Array Iterator 对象,该对象包含数组每个索引的值。
  • entries() 方法返回一个新的Array Iterator对象,该对象包含数组中每个索引的键/值对。
6.2.6 复制和填充方法
  • fill() 方法用一个固定值填充一个数组中从起始索引到终止索引内的全部元素。不包括终止索引。
  • copyWithin() 方法浅复制数组的一部分到同一数组中的另一个位置,并返回它,不会改变原数组的长度。
6.2.7 转换方法

数组身上的valueOf()方法返回的是数组本身,toString()方法返回的是由数组中每个值的等效字符串拼接而成的一个逗号分割的字符串。

6.2.8 栈方法
  • push() 方法将一个或多个元素添加到数组的末尾,并返回该数组的新长度。
  • pop()方法从数组中删除最后一个元素,并返回该元素的值。此方法更改数组的长度。
6.2.9 队列方法
  • unshift() 方法将一个或多个元素添加到数组的开头,并返回该数组的新长度(该方法修改原有数组)。
  • shift() 方法从数组中删除第一个元素,并返回该元素的值。此方法更改数组的长度。
6.2.10 排序方法
  • reverse() 方法将数组中元素的位置颠倒,并返回该数组。数组的第一个元素会变成最后一个,数组的最后一个元素变成第一个。该方法会改变原数组。

  • sort() 方法用原地算法对数组的元素进行排序,并返回数组。默认排序顺序是在将元素转换为字符串,然后比较它们的UTF-16代码单元值序列时构建的。

    sort() 方法可以传入一个用来指定按某种顺序进行排列的函数(compareFunction),数组会按照调用该函数的返回值排序。即 a 和 b 是两个将要被比较的元素:

    • 如果 compareFunction(a, b) 小于 0 ,那么 a 会被排列到 b 之前;
    • 如果 compareFunction(a, b) 等于 0 , a 和 b 的相对位置不变。备注: ECMAScript 标准并不保证这一行为,而且也不是所有浏览器都会遵守(例如 Mozilla 在 2003 年之前的版本);
    • 如果 compareFunction(a, b) 大于 0 , b 会被排列到 a 之前。
    • compareFunction(a, b) 必须总是对相同的输入返回相同的比较结果,否则排序的结果将是不确定的。
6.2.11 操作方法
  • concat() 方法用于合并两个或多个数组。当这个方法调用的时候,它首先会创建一个当前数组的副本,然后把它的参数添加到副本末尾,最后返回这个新构建的数组。
  • slice() 方法返回一个新的数组对象,这一对象是一个由 begin 和 end 决定的原数组的浅拷贝(包括 begin,不包括end)。原始数组不会被改变。
  • splice() 方法通过删除或替换现有元素或者原地添加新的元素来修改数组,并以数组形式返回被修改的内容。此方法会改变原数组。
    • 删除。需要给splice()传2 个参数:要删除的第一个元素的位置和要删除的元素数量。可以从数组中删除任意多个元素,比如splice(0, 2)会删除前两个元素。
    • 插入。需要给splice()传3 个参数:开始位置、0(要删除的元素数量)和要插入的元素,可以在数组中指定的位置插入元素。第三个参数之后还可以传第四个、第五个参数,乃至任意多个要插入的元素。
    • 替换。splice()在删除元素的同时可以在指定位置插入新元素,同样要传入3 个参数:开始位置、要删除元素的数量和要插入的任意多个元素。要插入的元素数量不需要跟删除的元素数量一致。
6.2.12 搜索和位置方法

######## 1. 严格相等(即 ===)

  • indexOf()方法返回在数组中可以找到一个给定元素的第一个索引,如果不存在,则返回-1。可通过参数指定搜索起始位置。
  • lastIndexOf() 方法返回指定元素在数组中的最后一个的索引,如果不存在则返回 -1。从数组的后面向前查找,可以传入第二个参数来使方法从指定位置倒序开始。
  • includes() 方法用来判断一个数组是否包含一个指定的值,根据情况,如果包含则返回 true,否则返回 false。可通过参数指定搜索起始位置。

######## 2. 断言函数

ECMAScript 也允许按照定义的断言函数搜索数组,每个索引都会调用这个函数。断言函数的返回值决定了相应索引的元素是否被认为匹配。

断言函数接收3 个参数:元素、索引和数组本身。其中元素是数组中当前搜索的元素,索引是当前元素的索引,而数组就是正在搜索的数组。断言函数返回真值,表示是否匹配。

  • find() 方法返回数组中满足提供的测试函数的第一个元素的值。否则返回 undefined。
  • findIndex()方法返回数组中满足提供的测试函数的第一个元素的索引。若没有找到对应元素则返回-1。
6.2.13 迭代方法

迭代方法方法接收两个参数:以每一项为参数运行的函数,以及可选的作为函数运行上下文的作用域对象(影响函数中this 的值)。传给每个方法的函数接收3个参数:数组元素、元素索引和数组本身。迭代方法不影响原函数。

  • every():对数组每一项都运行传入的函数,如果对每一项函数都返回true,则这个方法返回true。
  • filter():对数组每一项都运行传入的函数,函数返回true 的项会组成数组之后返回。
  • forEach():对数组每一项都运行传入的函数,没有返回值。
  • map():对数组每一项都运行传入的函数,返回由每次函数调用的结果构成的数组。
  • some():对数组每一项都运行传入的函数,如果有一项函数返回true,则这个方法返回true。
6.2.14 归并方法

归并方法会迭代数组中的所有项,并在此基础上构建一个最终返回值。

  • reduce() 方法对数组中的每个元素执行一个由您提供的reducer函数(升序执行),将其结果汇总为单个返回值。reducer 函数接收4个参数:

    1. Accumulator (acc) (累计器)

    2. Current Value (cur) (当前值)

    3. Current Index (idx) (当前索引)

    4. Source Array (src) (源数组)

    reducer 函数的返回值分配给累计器,该返回值在数组的每个迭代中被记住,并最后成为最终的单个结果值。

  • reduceRight() 方法与 reduce() 方法类似,只是从数组的右边往左边遍历。

6.3 定型数组

6.3.1 历史

######## 1. WebGL

最后的JavaScript API 是基于OpenGL ES 2.0规范的。OpenGL ES是OpenGL专注于2D和3D计算机图形的子集。这个新API被命名为WebGL,它会被兼容WebGL的浏览器原生解释执行。在WebGL早期版本,JavaScript中的数组和原生数组之间不匹配,出现了性能问题。

######## 2. 定型数组

WebGL需要JavaScript处理更多的数据,而JavaScript现有的数据类型不适合处理大量的二进制数据,会出现大量的性能损耗。因此定型数组(typed arrays)诞生。

6.3.2 ArrayBuffer

ArrayBuffer 是所有定型数组及视图引用的基本单位。

ArrayBuffer()是一个普通的JavaScript 构造函数,可用于在内存中分配特定数量的字节空间。ArrayBuffer 一经创建就不能再调整大小。不过,可以使用slice()复制其全部或部分到一个新实例中。

6.3.3 DataView

第一种允许你读写ArrayBuffer 的视图是DataView。这个视图专为文件I/O 和网络I/O 设计,其API 支持对缓冲数据的高度控制,但相比于其他类型的视图性能也差一些。

DataView 对缓冲内容没有任何预设,也不能迭代。必须在对已有的ArrayBuffer 读取或写入时才能创建DataView 实例。这个实例可以使用全部或部分ArrayBuffer,且维护着对该缓冲实例的引用,以及视图在缓冲中开始的位置。

要通过DataView 读取缓冲,还需要几个组件。

  • 首先是要读或写的字节偏移量。可以看成DataView 中的某种“地址”。

  • DataView 应该使用ElementType 来实现JavaScript 的Number 类型到缓冲内二进制格式的转换。

  • 最后是内存中值的字节序。默认为大端字节序。

######## 1. ElementType

DataView 对存储在缓冲内的数据类型没有预设。它暴露的API 强制开发者在读、写时指定一个ElementType,然后DataView 就会忠实地为读、写而完成相应的转换。

######## 2. 字节序

“字节序”指的是计算系统维护的一种字节顺序的约定。DataView 只支持两种约定:大端字节序和小端字节序。大端字节序也称为“网络字节序”,意思是最高有效位保存在第一个字节,而最低有效位保存在最后一个字节。小端字节序正好相反,即最低有效位保存在第一个字节,最高有效位保存在最后一个字节。JavaScript 运行时所在系统的原生字节序决定了如何读取或写入字节,但DataView 并不遵守这个约定,所有API默认大端字节序。

######## 3. 边界情形

DataView 完成读、写操作的前提是必须有充足的缓冲区,否则就会抛出RangeError,在写入缓冲里会尽最大努力把一个值转成适当类型,后备为0,无法转换时会抛出错误。

6.3.4 定型数组

定型数组是另一种形式的ArrayBuffer 视图。它是特定于一种ElementType 且遵循系统原生的字节序。设计定型数组的目的就是提高与WebGL 等原生库交换二进制数据的效率。

######## 1. 定型数组行为

类似普通数组

######## 2. 合并、复制和修改定型数组

定型数组同样使用数组缓冲来存储数据,而数组缓冲无法调整大小。所以不能用普通数组的修改方式,使用特定的两种方法:

  • set() 从提供的数组或定型数组中把值复制到当前定型数组中指定的索引位置。
  • subarray() 会基于从原始定型数组中复制的值返回一个新定型数组。

######## 3. 下溢和上溢

定型数组中值的下溢和上溢不会影响到其他索引,但仍然需要考虑数组的元素应该是什么类型。定型数组对于可以存储的每个索引只接受一个相关位,而不考虑它们对实际数值的影响。

6.4 Map

在ES6前,在JavaScript中实现“键/值”式存储可以使用Object来方便高效地完成。ES6新增了Map集合类型,Map的大多数特性都可以通过Object类型实现。

6.4.1 基本API

构造方法:

javascript
const m = new Map(); // 创建一个空映射
  • set() 方法可以添加键/值对;
  • get() 方法可以指定某个键来返回某个 Map 对象中对应的值;
  • has() 方法返回一个bool值,用来表明 Map 中是否存在指定元素;
  • delete() 方法用于移除 Map 对象中指定的元素;
  • clear() 方法会移除 Map 对象中的所有元素;
  • size 属性可以获取 Map 对象中的键/值对的数量。

Map 可以使用任何JavaScript 数据类型作为键。Map 内部使用SameValueZero 比较操作(ECMAScript 规范内部定义,语言中不能使用),基本上相当于使用严格对象相等的标准来检查键的匹配性。与Object 类似,映射的值是没有限制的。

6.4.2 顺序与迭代

Map实例会维护键值对的插入顺序,因此可以根据插入顺序执行迭代操作。

  1. 迭代器:entries() 方法返回一个新的包含 [key, value] 对的 Iterator 对象,返回的迭代器的迭代顺序与 Map 对象的插入顺序相同。
  2. 回调:调用Map映射的forEach()方法并传入回调,依次迭代每个键/值对,也可以传入第二个参数用于重写回调内部this的值。

键和值在迭代器遍历的时候是可以修改的,但映射内部的引用则无法修改。

6.4.3 选择Object还是Map

######## 1. 内存占用

不同的浏览器对不同类型键值内存的分配不同,对于给定固定大小的内存, Map大约可以比Object多存储50%的键/值对。

######## 2. 插入性能

插入Map在所有浏览器中一般会稍微快一点,所以如果代码涉及大量插入操作,使用Map的性能更佳

######## 3. 查找速度

大型Object和Map中对查找键/值对的性能差异很小,如果只包含少量键/值对,Object有时的速度会更快。在Object当成数组使用的情况下,浏览器引擎会对此做出优化,因此Object的性能更好。如果代码涉及大量的查找操作,选择Object可能更好。

######## 4. 删除性能

Map中的delete()方法完爆Object中的删除操作。

6.5 WeakMap

ES6新增的“弱映射”(WeakMap)是一种新的集合类型,是Map的“兄弟”类型,其API也是Map的子集。WeakMap中的“weak”描述的是JavaScript垃圾回收程序对待“弱映射”中键的方式。

6.5.1 基本API
javascript
const wm = new WeakMap();

弱映射中的键只能是Object 或者继承自Object 的类型,尝试使用非对象设置键会抛出 TypeError。值的类型没有限制。API与Map类型类似。

6.5.2 弱键

WeakMap中存储的对象值/键名所引用的对象都是被弱引用的,当引用的对象身上没有强引用的时候,就会被垃圾回收机制回收,换言之弱引用不会阻止垃圾回收机制。

6.5.3 不可迭代键

因为WeakMap中的键/值随时都会被销毁,所以没有迭代键/值的能力。

6.5.4 使用弱映射

######## 1. 私有变量

用一个闭包把WeakMap 包装起来,可以把弱映射与外界完全隔离开了,成为私有变量。

######## 2. DOM节点元数据

使用Map保存Dom节点的时候,页面被JavaScript改变,导致原来的绑定DOM节点被删除,由于Map是强引用(申请特殊的储存空间),如果不在Map中声明删除,或删除Map,该DOM节点仍会保留在内存中,而WeakMap保存的DOM节点在DOM节点被页面删除的时候就会被垃圾回收机制回收。

6.6 Set

ES6新增的Set是一种新集合类型,类似加强的Map,大多数API和行为共通。

6.6.1 基本API
javascript
const m = new Set();

可以使用add()增加值,使用has()查询,通过size 取得元素数量,以及使用delete() 和clear()删除元素:

6.6.2 顺序和迭代
  • values() 方法按照元素插入顺序返回一个具有 Set 对象每个元素值的全新 Iterator 对象。
  • keys() 方法是这个方法的别名(与 Map 对象相似);他们的行为一致,都是返回Set 对象中的元素值。
6.6.3 定义正式集合操作

6.7 WeakSet

6.7.1 基本API
6.7.2 弱值
6.7.3 不可迭代值

以上特性都类似WeakMap

6.7.4 使用弱集合

可以使用弱集合保存DOM值来使页面删除DOM节点的时候释放其内存。

6.8 迭代与扩展操作

有4 种原生集合类型定义了默认迭代器:

  • Array
  • 所有定型数组
  • Map
  • Set

因为上述类型都支持顺序迭代,所以可以使用for-of循环,也可以使用很多方法来复制更改对象。

6.9 小结

JavaScript 中的对象是引用值,可以通过几种内置引用类型创建特定类型的对象。

  • 引用类型与传统面向对象编程语言中的类相似,但实现不同。
  • Object 类型是一个基础类型,所有引用类型都从它继承了基本的行为。
  • Array 类型表示一组有序的值,并提供了操作和转换值的能力。
  • 定型数组包含一套不同的引用类型,用于管理数值在内存中的类型。
  • Date 类型提供了关于日期和时间的信息,包括当前日期和时间以及计算。
  • RegExp 类型是ECMAScript 支持的正则表达式的接口,提供了大多数基本正则表达式以及一些高级正则表达式的能力。

在JavaScript中,函数是Function类型的实例,这意味着函数也是对象,有增强自身行为的方法。

因为原始值包装类型的存在,所以JavaScript 中的原始值可以拥有类似对象的行为。有3 种原始值包装类型:Boolean、Number 和String。它们都具有如下特点。

  • 每种包装类型都映射到同名的原始类型。
  • 在以读模式访问原始值时,后台会实例化一个原始值包装对象,通过这个对象可以操作数据。
  • 涉及原始值的语句只要一执行完毕,包装对象就会立即销毁。

JavaScript 还有两个在一开始执行代码时就存在的内置对象:Global 和Math。其中,Global 对象在大多数ECMAScript 实现中无法直接访问。不过浏览器将Global 实现为window 对象。所有全局变量和函数都是Global 对象的属性。Math 对象包含辅助完成复杂数学计算的属性和方法。

ECMAScript 6 新增了一批引用类型:Map、WeakMap、Set 和WeakSet。这些类型为组织应用程序数据和简化内存管理提供了新能力。

7. 迭代器与生成器

7.1 理解迭代

for循环是一种最简单的迭代,它可以指定迭代的次数,每次迭代执行的操作。每次迭代都会在下一次迭代开始前完成,迭代的顺序也是根据有序集合的顺序执行的。但因为两个原因,for循环来执行迭代并不理想:

  • **迭代之前需要事先知道如何使用数据结构。**数组中的每一项都只能先通过引用取得数组对象,然后再通过[]操作符取得特定索引位置上的项。这种情况并不适用于所有数据结构。
  • **遍历顺序并不是数据结构固有的。**通过递增索引来访问数据是特定于数组类型的方式,并不适用于其他具有隐式顺序的数据结构。

ES5新增了forEach()方法来更进一步实现迭代需求,但这个方法只适用于数组,同时无法标识迭代何时结束。所以JavaScript在ES6后支持了迭代器模式。

7.2 迭代器模式

迭代器模式描述了一个方案,即可以把有些结构称为“可迭代对象”,因为它们实现了正式的Iterable接口,可以通过迭代器Iterator消费。任何实现Iterable 接口的数据结构都可以被实现Iterator 接口的结构“消费”。迭代器是按需创建的一次性对象。每个迭代器都会关联一个可迭代对象,而迭代器会暴露迭代其关联可迭代对象的API。因此迭代器无需了解与其关联的可迭代对象的结构,只需要知道如何取得连续的值。

7.2.1 可迭代协议

实现Iterable 接口(可迭代协议)要求同时具备两种能力:支持迭代的自我识别能力和创建实现Iterator 接口的对象的能力。在ECMAScript 中,这意味着必须暴露一个属性作为“默认迭代器”,而且这个属性必须使用特殊的Symbol.iterator 作为键。这个默认迭代器属性必须引用一个迭代器工厂函数,调用这个工厂函数必须返回一个新迭代器。

实现Iterable接口的内置类型:

  • 字符串
  • 数组
  • 映射
  • 集合
  • arguments 对象
  • NodeList 等DOM 集合类型

实际写代码过程中,不需要显示调用工厂函数来生成迭代器,以下原生语言结构会在后台调用提供的可迭代对象的工厂函数,从而创建一个迭代器。

  • for-of 循环
  • 数组解构
  • 扩展操作符
  • Array.from()
  • 创建集合
  • 创建映射
  • Promise.all()接收由期约组成的可迭代对象
  • Promise.race()接收由期约组成的可迭代对象
  • yield*操作符,在生成器中使用
7.2.2 迭代器协议

迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象。迭代器API 使用next()方法在可迭代对象中遍历数据。每次成功调用next(),都会返回一个IteratorResult 对象,其中包含迭代器返回的下一个值。若不调用next(),则无法知道迭代器的当前位置。

next()方法返回的迭代器对象IteratorResult 包含两个属性:done 和value。done 是一个布尔值,表示是否还可以再次调用next()取得下一个值;value 包含可迭代对象的下一个值(done 为false),或者undefined(done 为true)。done: true 状态称为“耗尽”。只要迭代器到达done: true 状态,后续调用next()就一直返回同样的值了。

每一个迭代器都是独立的,当迭代器所关联的对象改变,迭代器也会随之改变。

迭代器维护着一个指向可迭代对象的引用,因此迭代器会阻止垃圾回收程序回收可迭代对象。

7.2.3 自定义迭代器

我们可以通过部署Iterator接口来将非可遍历数据改造成遍历数据。ES6规定,默认的 Iterator 接口部署在数据结构的Symbol.iterator属性,Symbol.iterator属性本身是一个函数,就是当前数据结构默认的遍历器生成函数,执行这个函数,就会返回一个带有next方法的遍历器对象。至于属性名Symbol.iterator,它是一个表达式,返回Symbol对象的iterator属性,这是一个预定义好的、类型为 Symbol 的特殊值,所以要放在方括号内。

javascript
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接口,但这个类的实例只能被迭代一次,为了让一个可迭代对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计数器。为此,可以把计数器变量放到闭包里,然后通过闭包返回迭代器:

javascript
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 };
                }
            }
        };
    }
}
7.2.4 提前终止迭代器

大多数情况下不用去特意关闭一个迭代器,但当我们要迭代的数据来自于其他文件,比如硬盘上的文件,或者网络上的文件,如果不关闭迭代器,就会一直占用资源,在使用迭代器的时候,可以通过 break continue returnthrow提前退出迭代器,在退出迭代器前,会调用迭代器中的 return()方法(可选),我们可以在这个方法中完成收尾工作。

如果迭代器没有关闭,下次迭代时则会从上次离开的地方继续迭代 。

7.3 生成器

生成器是ES6 新增的一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力。

7.3.1 生成器基础

生成器的形式是一个函数,函数名称前面加一个星号(*)表示它是一个生成器。只要是可以定义函数的地方,就可以定义生成器。

javascript
// 标识生成器函数的星号不受两侧空格的影响:
// 生成器函数声明
function* generatorFn() {}
// 生成器函数表达式
let generatorFn = function* () {}
// 作为对象字面量方法的生成器函数
let foo = {
	* generatorFn() {}
}

调用生成器函数会产生一个生成器对象。生成器对象一开始处于暂停执行(suspended)的状态。与迭代器相似,生成器对象也实现了Iterator 接口,因此具有next()方法。调用这个方法会让生成器开始或恢复执行。next()方法的返回值类似于迭代器,有一个done 属性和一个value 属性。value 属性是生成器函数的返回值,默认值为undefined,可以通过生成器函数的返回值指定。生成器函数只会在初次调用next()方法后开始执行。

7.3.2 通过yield中断执行

yield 关键字可以让生成器停止和开始执行,也是生成器最有用的地方。生成器函数在遇到yield关键字之前会正常执行。遇到这个关键字后,执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用next()方法来恢复执行。此时的yield 关键字有点像函数的中间返回语句,它生成的值会出现在next()方法返回的对象里。通过yield 关键字退出的生成器函数会处在done: false 状态;通过return 关键字退出的生成器函数会处于done: true 状态。yield 关键字只能在生成器函数内部使用,用在其他地方会抛出错误。

######## 1. 生成器对象作为可迭代对象

将生成器对象作为可迭代对象使用。

javascript
function* generatorFn() {
    yield 1;
    yield 2;
    yield 3;
}
for (const x of generatorFn()) {
	console.log(x);
}
// 1
// 2
// 3

######## 2. 使用yield 实现输入和输出

除了可以作为函数的中间返回语句使用,yield 关键字还可以作为函数的中间参数使用。上一次让 生成器函数暂停的yield 关键字会接收到传给next()方法的第一个值。第一次调用next()传入的值不会被使用,因为这一次调用是为了开始执行生成器函数:

javascript
function* generatorFn(initial) {
    console.log(initial);
    console.log(yield);
    console.log(yield);
}
let generatorObject = generatorFn('foo'); // 传入initial
generatorObject.next('bar'); // foo
generatorObject.next('baz'); // baz
generatorObject.next('qux'); // qux

######## 3. 产生可迭代对象

可以使用星号增强yield 的行为,让它能够迭代一个可迭代对象,从而一次产出一个值:

javascript
// 等价的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

######## 4. 使用yield*实现递归算法

yield*最有用的地方是实现递归操作,此时生成器可以产生自身。

javascript
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
7.3.3 生成器作为默认迭代器

将生成器作为自定义迭代器

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
7.3.4 提前终止生成器

一个实现Iterator 接口的对象一定有next()方法,还有一个可选的return()方法用于提前终止迭代器。生成器对象除了有这两个方法,还有第三个方法:throw(),用于强制生成器进入关闭状态。

######## 1. return()

return()方法会强制生成器进入关闭状态。提供给return()方法的值,就是终止迭代器对象的值。所有生成器内部提供return() 方法,只要通过return()进入关闭状态,就无法恢复,后续调用next()会显示done: true 状态,而提供的任何返回值都不会被存储或传播。

######## 2. throw()

throw()方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭。不过,假如生成器函数内部处理了这个错误,那么生成器就不会关闭,而且还可以恢复执行。错误处理会跳过对应的yield。

7.4 小结

迭代是一种所有编程语言中都可以看到的模式。ECMAScript 6 正式支持迭代模式并引入了两个新的语言特性:迭代器和生成器。

迭代器是一个可以由任意对象实现的接口,支持连续获取对象产出的每一个值。任何实现Iterable接口的对象都有一个Symbol.iterator 属性,这个属性引用默认迭代器。默认迭代器就像一个迭代器工厂,也就是一个函数,调用之后会产生一个实现Iterator 接口的对象。

迭代器必须通过连续调用next()方法才能连续取得值,这个方法返回一个IteratorObject。这个对象包含一个done 属性和一个value 属性。前者是一个布尔值,表示是否还有更多值可以访问;后 者包含迭代器返回的当前值。这个接口可以通过手动反复调用next()方法来消费,也可以通过原生消费者,比如for-of 循环来自动消费。

生成器是一种特殊的函数,调用之后会返回一个生成器对象。生成器对象实现了Iterable 接口, 因此可用在任何消费可迭代对象的地方。生成器的独特之处在于支持yield 关键字,这个关键字能够暂停执行生成器函数。使用yield 关键字还可以通过next()方法接收输入和产生输出。在加上星号之后,yield 关键字可以将跟在它后面的可迭代对象序列化为一连串值。

8. 对象、类与面向对象编程

ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。

8.1 理解对象

8.1.1 属性的类型

ECMA-252使用一些内部特性来描述属性的特征,这些特性是为了JavaScript实现引擎的规范定义的。因此开发者不能在JavaScript中直接访问这些特性。为了将某个特性标识为内部特性,规范会用两个中括号把特性的名称括起来,比如[[ Enumerable ]]。

######## 1. 数据属性

数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有4个特性描述它们的行为。

  • [[Configurable]]:表示属性是否可以通过delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为访问器属性。默认为true值。

  • [[Enumerable]]:表示属性是否可以通过for-in 循环返回。默认为true值。

  • [[Writable]]:表示属性的值是否可以被修改。默认为true值。

  • [[Value]]:包含属性实际的值。默认为undefined,当属性指定value时将undefined替换。

######## 2. 访问器属性

访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有4 个特性描述它们的行为。

  • [[Configurable]]:表示属性是否可以通过delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性 都是true。
  • [[Enumerable]]:表示属性是否可以通过for-in 循环返回。默认情况下,所有直接定义在对 象上的属性的这个特性都是true。
  • [[Get]]:获取函数,在读取属性时调用。默认值为undefined。
  • [[Set]]:设置函数,在写入属性时调用。默认值为undefined。
8.1.2 定义多个属性

Object.defineProperties() 方法直接在一个对象上定义新的属性或修改现有属性,并返回该对象。

8.1.3 读取属性的特性
  • Object.getOwnPropertyDescriptor() 方法返回指定对象上一个自有属性对应的属性描述符。(自有属性指的是直接赋予该对象的属性,不需要从原型链上进行查找的属性)
  • Object.getOwnPropertyDescriptors() 方法用来获取一个对象的所有自身属性的描述符。
8.1.4 合并对象

Object.assign()方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回true)和自有(Object.hasOwnProperty()返回true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的[[Get]]取得属性的值,然后使用目标对象上的[[Set]]设置属性的值。

Object.assign()实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数。

8.1.5 对象标识及相等判定

Object.is() 方法判断两个值是否为同一个值。

javascript
function recursivelyCheckEqual(x, ...rest) {
	return Object.is(x, rest[0]) && (rest.length < 2 ||recursivelyCheckEqual(...rest));
}
8.1.6 增强的对象语法

######## 1. 属性值简写

javascript
let name = 'Matt';
let person = {
	// name: name
    // 当属性和值相同时可以简写为属性:
    name
};
console.log(person); // { name: 'Matt' }

######## 2. 可计算属性

在对象中,属性名永远是字符串,ES6添加了可计算属性,可以使用[]内嵌表达式,来作为属性名。

######## 3. 简写方法名

javascript
//不使用简写
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

注:简写方法名可以和可计算属性互相兼容。

javascript
const methodKey = 'sayName';
let person = {
    [methodKey](name) {
        console.log(`My name is ${name}`);
	}
}
person.sayName('Matt'); // My name is Matt
8.1.7 对象解构

ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。

######## 1. 嵌套解构

解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性。复制对象属性采用的是浅拷贝。

######## 2. 部分解构

如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分。

######## 3. 参数上下文匹配

在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响arguments 对象,但可以在函数签名中声明在函数体内使用局部变量。

8.2 创建对象

8.2.1 概述

ES5.1没有正式支持面向对象编程的结构,比如类和继承,但可以运用原型式继承模拟同样的行为。ES6正式支持类和继承,不过,ES6的类都仅仅是封装了ES5.1构造函数加原型继承的语法糖而已。

8.2.2 工厂模式

工厂模式是设计模式,用于抽象创建特定对象的过程。可以解决创建多个类似对象的问题,但无法解决对象标识问题。

8.2.3 构造函数模式

可以创建一个构造函数,然后使用new操作符实例一个对象,定义自定义构造函数可以确保实例被标识为特定类型。

######## 1. 构造函数也是函数

构造函数与普通函数唯一的区别就是调用方式不同。任何函数只要使用new 操作符调用就是构造函数,而不使用new 操作符调用的函数就是普通函数。

######## 2. 构造函数的问题

构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。可以在外部定义函数,然后内部引用,但会打乱全局作用域,如果一个对象需要多个方法,则需要在全局作用域中定义多个函数,所以需要通过原型模式来解决。

8.2.4 原型模式

每个函数都会创造一个prototype属性,该属性是一个对象,是通过调用构造函数构造的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。

######## 1. 理解原型

无论何时,只要创建一个函数,就会按照特定的规则为这个函数创建一个prototype 属性(指向原型对象)。默认情况下,所有原型对象自动获得一个名为constructor 的属性,指回与之关联的构造函数。在自定义构造函数时,原型对象默认只会获得constructor 属性,其他的所有方法都继承自Object。每次调用构造函数创建一个新实例,这个实例的内部[[Prototype]]指针就会被赋值为构造函数的原型对象。脚本中没有访问这个[[Prototype]]特性的标准方式,但Firefox、Safari 和Chrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。在其他实现中,这个特性完全被隐藏了。关键在于理解这一点:实例与构造函数原型之间有直接的联系,但实例与构造函数之间没有。实例通过__proto__链接到原型对象,它实际上指向隐藏特性[[Prototype]]。构造函数通过prototype属性链接到原型对象。

  • isPrototypeOf() 方法用于测试一个对象是否存在于另一个对象的原型链上。
  • Object.getPrototypeOf() 方法返回指定对象的原型(内部[[Prototype]]属性的值)。
  • Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。

Object.setPrototypeOf() 方法设置一个指定的对象的原型 ( 即, 内部[[Prototype]]属性)到另一个对象或 null。 但修改继承关系容易引起不可知的后续问题,建议用create创造一个新对象,并为其指定原型。

######## 2. 原型层级

在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。

虽然实例可以读取原型对象上的值,但不能通过实例来重写原型对象的值,只能在实例中重写对象属性,来阻止从原型链上读取数据。可以使用delete来删除实例上的属性,从而从原型上读取属性值。

  • hasOwnProperty() 方法会返回一个布尔值,指示实例对象自身属性中是否具有指定的属性(也就是,是否有指定的键)

######## 3. 原型和in操作符

单独使用in操作符时,当对象含有指定属性的时候会返回true,无论该属性是在实例上还是在原型上。

javascript
function hasPrototypeProperty(object, name){
	return !object.hasOwnProperty(name) && (name in object);
}// 若返回true值说明是原型属性
  • Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。
  • Object.getOwnPropertyNames()方法返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括Symbol值作为名称的属性)组成的数组。

######## 4. 属性枚举顺序

for-in 循环和Object.keys() 的枚举顺序是不确定的,取决于JavaScript 引擎,可能因浏览器而异。

Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。在对象字面量中定义的键以它们逗号分隔的顺序插入。

8.2.5 对象迭代
  • Object.values()方法返回一个给定对象自身的所有可枚举属性的数组
  • Object.entries()方法返回一个给定对象自身可枚举属性的键值对数组

######## 1. 其他原型语法

为了减少代码冗余,可以使用一个包含所有属性和方法的对象字面量来重写原型:

javascript
function Person() {}
Person.prototype = {
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName() {
    	console.log(this.name);
    }
};

但这样重写后,Person.prototype的constructor属性就不再指向Person。在创建函数的时候,也会创建它的prototype对象,同时自动的给这个原型的constructor属性赋值。上面的写法相当于重写了默认的prototype对象,因此constructor属性也指向了完全不同的新对象(Objcet对象)。因此无法使用constructor属性来识别类型。

javascript
function Person() {
}
Person.prototype = {
    constructor: Person, // 指定constructor属性
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName() {
    	console.log(this.name);
	}
};

但上述方法恢复constructor属性时,会创建一个[[Enumerable]]为true的属性,而原生的constructor属性默认是false。如果使用兼容ECMAScript的JavaScript引擎,可以使用Object.defineProperty()方法来定义constructor 属性。

javascript
// 恢复constructor 属性
Object.defineProperty(Person.prototype, "constructor", {
    enumerable: false,
    value: Person
});

######## 2. 原型的动态性

因为从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对 象所做的修改也会在实例上反映出来。下面是一个例子:

javascript
let friend = new Person();
Person.prototype.sayHi = function() {
	console.log("hi");
};
friend.sayHi(); // "hi",没问题!

虽然随时能给原型添加属性和方法,并能够立即反映在所有对象实例上,但这跟重写整个原型是两回事。实例的[[Prototype]]指针是在调用构造函数时自动赋值的,这个指针即使把原型修改为不同的对象也不会变。重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。

javascript
function Person() {}
let friend = new Person();
Person.prototype = {
    constructor: Person,
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName() {
    	console.log(this.name);
    }
};
friend.sayName(); // 错误

重写构造函数上的原型之后再创建的实例才会引用新的原型。而在此之前创建的实例仍然会引用最初的原型。

######## 3. 原生对象类型

原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。所有原生引用类型的构造函数(包括Object、Array、String 等)都在原型上定义了实例方法。

通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以 像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法,但不建议修改原生对象类型,推荐的做法是创建一个自定义的类,继承原生类型。

######## 4. 原型的问题

原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共享特性。原型上的所有属性是在实例间共享的,当我们修改实例对象中的某个属性,如果实例对象中没有,则会修改原型上属性的值,导致其他其他实例对象中相应属性的变更。所以不同的实例应该有属于自己的属性副本,而不是修改原型属性。

8.3 继承

很多面向对象的语言支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。接口继承在ECMAScript中是不可能的,因为函数没有签名。实现继承是ECMAScript唯一支持的继承方式,而这主要是通过原型链实现的。

8.3.1 原型链

ECMA-262 把原型链定义为ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。如果原型是另一个类型的实例呢?那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。

######## 1. 默认原型

默认情况下,所有引用类型都继承自Object,这也是通过原型链实现的。任何函数的默认原型都是一个Object 的实例,这意味着这个实例有一个内部指针指向Object.prototype。这也是为什么自定义类型能够继承包括toString()、valueOf()在内的所有默认方法的原因。

######## 2. 原型与继承关系

原型与实例的关系可以通过两种方式来确定。

第一种方式是使用instanceof 操作符,如果一个实例的原型链中出现过相应的构造函数,则instanceof 返回true。

第二种方式是使用isPrototypeOf()方法。原型链中的每个原型都可以调用这个 方法,如下例所示,只要原型链中包含这个原型,这个方法就返回true。

######## 3. 关于方法

子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上。

######## 4. 原型链的问题

  1. 原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型的实例。这意味着原先的实例属性摇身一变成为了原型属性。

  2. 子类型在实例化时不能给父类型的构造函数传参。事实上,我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。

8.3.2 盗用构造函数

为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)。基本思路很简单:在子类构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()和call()方法以新创建的对象为上下文执行构造函数。

javascript
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"

######## 1. 传递参数

相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参:

javascript
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

在SubType构造函数中调用SuperType 构造函数时传入参数,实际上会在SubType 的实例上定义name 属性。为确保SuperType 构造函数不会覆盖SubType 定义的属性,可以在调用父类构造函数之后再给子类实例添加额外的属性。

######## 2. 盗用构造函数的问题

盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用。

8.3.3 组合继承

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

javascript
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 构造函数定义了两个属性,name 和colors,而它的原型上也定义了一个方法叫sayName()。SubType 构造函数调用了SuperType 构造函数,传入了name 参数,然后又定义了自己的属性age。此外,SubType.prototype 也被赋值为SuperType 的实例。原型赋值之后,又在这个原型上添加了新方法sayAge()。这样,就可以创建两个SubType 实例,让这两个实例都有自己的属性,包括colors,同时还共享相同的方法。

组合继承弥补了原型链和盗用构造函数的不足,是JavaScript 中使用最多的继承模式。而且组合继承也保留了instanceof 操作符和isPrototypeOf()方法识别合成对象的能力。

8.3.4 原型式继承

2006 年,Douglas Crockford 写了一篇文章:《JavaScript 中的原型式继承》(“Prototypal Inheritance in JavaScript”)。这篇文章介绍了一种不涉及严格意义上构造函数的继承方法。他的出发点是即使不自定义类型也可以通过原型实现对象之间的信息共享。文章最终给出了一个函数:

javascript
function object(o) {
    function F() {}
    F.prototype = o;
    return new F();
}

这个object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返回这个临时类型的一个实例。本质上,object()是对传入的对象执行了一次浅复制。

javascript
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"

这种原型式继承适用于某种情况:

你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给object(),然后再对返回的对象进行适当修改。在上面的例子中,person 对象定义了另一个对象也应该共享的信息,把它传给object()之后会返回一个新对象。这个新对象的原型是person,意味着它的原型上既有原始值属性又有引用值属性。这也意味着person.friends 不仅是person 的属性,也会跟anotherPerson 和yetAnotherPerson 共享。这里实际上克隆了两个person。

ES5通过增加Object.create()方法将原型式继承的概念规范化了。这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时,Object.create()与这里的object()方法效果相同。Object.create()的第二个参数与Object.defineProperties()的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性。

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。但要记住,属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

8.3.5 寄生式继承

与原型式继承比较接近的一种继承方式是寄生式继承(parasitic inheritance),也是Crockford 首倡的一种模式。寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种方式增强对象,然后返回这个对象。基本的寄生继承模式如下:

javascript
function createAnother(original){
    let clone = object(original); // 通过调用函数创建一个新对象
    clone.sayHi = function() { // 以某种方式增强这个对象
    	console.log("hi");
	};
	return clone; // 返回这个对象
}

在这段代码中,createAnother()函数接收一个参数,就是新对象的基准对象。这个对象original会被传给object()函数,然后将返回的新对象赋值给clone。接着给clone 对象添加一个新方法sayHi()。最后返回这个对象。可以像下面这样使用createAnother()函数:

javascript
let person = {
    name: "Nicholas",
    friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = createAnother(person);
anotherPerson.sayHi(); // "hi"

以上基于给定的person对象返回了一个新对象。新返回的anotherPerson对象具有person的所有属性和方法,以及一个新方法sayHi()。

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。object()函数不是寄生式 继承所必需的,任何返回新对象的函数都可以在这里使用。通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

8.3.6 寄生式组合继承

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次是在创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所有实例属性,子类构造函数只要在执行时重写自己的原型就行了。

javascript
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);
};

在上面的代码执行后,SubType.prototype上会有两个属性:name 和colors。它们都是SuperType 的实例属性,但现在成为了SubType 的原型属性。在调用SubType 构造函数时,也会调用SuperType 构造函数,这一次会在新对象上创建实例属性name 和colors。这两个实例属性会遮蔽原型上同名的属性。

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。寄生式组合继承的基本模式如下所示:

javascript
function inheritPrototype(subType, superType) {
    let prototype = object(superType.prototype); // 创建对象
    prototype.constructor = subType; // 增强对象
    subType.prototype = prototype; // 赋值对象
}

这个inheritPrototype()函数实现了寄生式组合继承的核心逻辑。这个函数接收两个参数:子类构造函数和父类构造函数。在这个函数内部,第一步是创建父类原型的一个副本。然后,给返回的prototype 对象设置constructor 属性,解决由于重写原型导致默认constructor 丢失的问题。最后将新创建的对象赋值给子类型的原型。调用inheritPrototype()就可以实现前面例子中的子类型原型赋值。

javascript
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()方法正常有效。寄生式组合继承可以算是引用类型继承的最佳模式。

8.4 类

ES6引入的关键字class具有正式定义类的能力。虽然ES6的类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

8.4.1 类定义

与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用class 关键字加大括号:

javascript
// 类声明
class Person {}
// 类表达式
const Animal = class {};

与函数表达式类似,类表达式在它们被求值前也不能引用。不过,与函数定义不同的是,虽然函数声明可以提升,但类定义不能。另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制。

######## 类的构成

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。与函数构造函数一样,多数编程风格都建议类名的首字母要大写,以区别于通过它创建的实例。类表达式的名称是可选的,在把类表达式赋值给变量后,可以通过name属性取得类表达式的名称字符串,但不能在类表达式作用域外部访问这个标识符。

javascript
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
8.4.2 类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。方法名constructor 会告诉解释器在使用new 操作符创建类的新实例时,应该调用这个函数。构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

######## 1. 实例化

使用new 操作符实例化Person 的操作等于使用new 调用其构造函数。唯一可感知的不同之处就是,JavaScript 解释器知道使用new 和类意味着应该使用constructor 函数进行实例化。

使用new 调用类的构造函数会执行如下操作:

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的[[Prototype]]指针被赋值为构造函数的prototype 属性。
  3. 构造函数内部的this 被赋值为这个新对象(即this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的。

默认情况下,类构造函数会在执行之后返回this 对象。构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的this 对象,那么这个对象会被销毁。不过,如果返回的不是this 对象,而是其他对象,那么这个对象不会通过instanceof 操作符检测出跟类有关联,因为这个对象的原型指针并没有被修改。

javascript
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 则会抛出错误。此外类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用new 调用)。

javascript
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操作符检测类标识符,返回函数类型。类标识符有prototype属性,这个原型也有一个constructor指向类自身。与普通构造函数一样,可以使用instanceof操作符来检查构造函数原型是否存在于实例的原型链中,由此可以检查一个对象与类构造函数,来确定这个对象是不是类的实例,检查是否属于类构造函数的时候要使用类标识符。

类本身有与普通构造函数一样的行为。在类的上下文中,类本身在使用new调用的时候会被当成构造函数。而类中定义的constructor方法不会被当成构造函数,在对它使用instanceof操作符的时候会返回false,但如果在创造实例的时候,将类构造函数当成普通构造函数来使用时,那么instanceof操作符的返回值为true。

javascript
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

与立即调用函数表达式相似,类也可以立即实例化。

javascript
// 因为是一个类表达式,所以类名是可选的
let p = new class Foo {
    constructor(x) {
    	console.log(x);
	}
}('bar'); // 立即调用
console.log(p); // Foo {}
8.4.3 实例、原型和类成员

类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员。

######## 1. 实例成员

每次通过new 调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this)添加“自有”属性。至于添加什么样的属性,则没有限制。另外,在构造函数执行完毕后,仍然可以给实例继续添加新成员。每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享。

javascript
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. 原型方法与访问器

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。

javascript
class Person {
    constructor() {
    // 添加到this 的所有内容都会存在于不同的实例上
        this.locate = () => console.log('instance');
    }
    // 在类块中定义的所有内容都会定义在类的原型上,定义“类”的方法的时候,前面不需要加上function这个关键字,直接把函数定义放进去了就可以了。另外,方法之间不需要逗号分隔,加了会报错。
    locate() {
    	console.log('prototype');
    }
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype

可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据。

javascript
class Person {
	name: 'Jake'
}
// Uncaught SyntaxError: Unexpected token

类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键。类定义也支持获取 (get) 和设置 (set) 访问器。语法与行为跟普通对象一样。

######## 3. 静态类方法

可以在类上定义静态方法。这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。与原型成员类似,静态成员每个类上只能有一个。静态类成员在类定义中使用static 关键字作为前缀。在静态成员中,this 引用类自身。其他所有约定跟原型成员一样。静态类方法可以在不实例对象的情况下直接使用。

######## 4. 非函数原型和类成员

虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

javascript
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

######## 5. 迭代器与生成器方法

类定义语法支持在原型和类本身上定义生成器方法,类似在对象上定义生成器。

8.4.4 继承

ES6原生支持了类继承机制,虽然类继承使用的是新语法,但背后仍旧是用的是原型链。

######## 1. 继承基础

ES6 类支持单继承。使用extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)。

javascript
class Vehicle {}
// 继承类
class Bus extends Vehicle {}
let b = new Bus();
console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true

######## 2. 构造函数、HomeObject和super()

派生类的方法可以通过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 调用继承的类上定义的静态方法。

ES6 给类构造函数和静态方法添加了内部特性[[HomeObject]],这个特性是一个指针,指向定义该方法的对象。这个指针是自动赋值的,而且只能在JavaScript 引擎内部访问。super 始终会定义为[[HomeObject]]的原型。

使用super时要注意的问题:

  • super 只能在派生类构造函数和静态方法中使用。
javascript
class Vehicle {
	constructor() {
        super();
        // SyntaxError: 'super' keyword unexpected
    }
}
  • 不能单独引用super 关键字,要么用它调用构造函数,要么用它引用静态方法。
javascript
class Vehicle {}
class Bus extends Vehicle {
	constructor() {
		console.log(super);
		// SyntaxError: 'super' keyword unexpected here
	}
}
  • 调用super()会调用父类构造函数,并将返回的实例赋值给this。
javascript
class Vehicle {}
class Bus extends Vehicle {
    constructor() {
        super();
        console.log(this instanceof Vehicle);
    }
}
new Bus(); // true
  • super()的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
javascript
class Vehicle {
    constructor(licensePlate) {
    this.licensePlate = licensePlate;
    }
}
class Bus extends Vehicle {
    constructor(licensePlate) {
    	super(licensePlate);
	}
}
console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }
  • 如果没有定义类构造函数,在实例化派生类时会调用super(),而且会传入所有传给派生类的参数。
javascript
class Vehicle {
	constructor(licensePlate) {
		this.licensePlate = licensePlate;
	}
}
class Bus extends Vehicle {}
console.log(new Bus('1337H4X')); // Bus { licensePlate: '1337H4X' }
  • 在类构造函数中,不能在调用super()之前引用this。
javascript
class Vehicle {}
class Bus extends Vehicle {
    constructor() {
    	console.log(this);
	}
}
new Bus();
// ReferenceError: Must call super constructor in derived class
// before accessing 'this' or returning from derived constructor
  • 如果在派生类中显式定义了构造函数,则要么必须在其中调用super(),要么必须在其中返回一个对象。
javascript
class Vehicle {}
class Car extends Vehicle {}
class Bus extends Vehicle {
    constructor() {
    super();
}
}
class Van extends Vehicle {
    constructor() {
    	return {};
	}
}
console.log(new Car()); // Car {}
console.log(new Bus()); // Bus {}
console.log(new Van()); // {}

######## 3. 抽象基类

有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。虽然ECMAScript 没有专门支持这种类的语法 ,但通过new.target 也很容易实现。new.target 保存通过new 关键字调用的类或函数。通过在实例化时检测new.target 是不是抽象基类,可以阻止对抽象基类的实例化:

javascript
// 抽象基类
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 关键字来检查相应的方法:

javascript
// 抽象基类
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 类为继承内置引用类型提供了顺畅的机制,开发者可以方便地扩展内置类型:

javascript
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]

有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的:

javascript
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 访问器,这个访问器决定在创建返回的实例时使用的类:

javascript
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 模式。混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。如果Person 类需要组合A、B、C,则需要某种机制实现B 继承A,C 继承B,而Person再继承C,从而把A、B、C 组合到这个超类中。实现这种模式有不同的策略。

8.5 小结

对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实体。下面的模式适用于创建对象。

  • 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个对象。这个模式在构造函数模式出现后就很少用了。
  • 使用构造函数模式可以自定义引用类型,可以使用new 关键字像创建内置类型实例一样创建自定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。
  • 原型模式解决了成员共享的问题,只要是添加到构造函数prototype 上的属性和方法就可以共享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。

JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。原型链的问题是所有继承的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承,即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。

除上述模式之外,还有以下几种继承模式。

  • 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操作的结果之后还可以再进一步增强。
  • 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。
  • 寄生组合继承被认为是实现基于类型继承的最有效方式。

9. 代理与反射

ES6新增的代理和反射为开发者提供了拦截并向基本操作嵌入额外行为的能力。具体地说,可以给目标对象定义一个关联的代理对象,而这个代理对象可以作为抽象的目标对象来使用。在对目标对象的各种操作影响目标对象之前,可以在代理对象中对这些操作加以控制。由于代理是一种新的基础性语言能力,很多转译程序都不能把代理行为转换为之前的ECMAScript 代码,因为代理的行为实际上是无可替代的。为此,代理和反射只在百分之百支持它们的平台上有用。可以检测代理是否存在,不存在则提供后备代码。不过这会导致代码冗余,因此并不推荐。

9.1 代理基础

9.1.1 创建空代理

最简单的代理是空代理,即除了作为一个抽象的目标对象,什么也不做。默认情况下,在代理对象上执行的所有操作都会无障碍地传播到目标对象。因此,在任何可以使用目标对象的地方,都可以通过同样的方式来使用与之关联的代理对象。

代理是使用Proxy 构造函数创建的。这个构造函数接收两个参数:目标对象和处理程序对象。缺少其中任何一个参数都会抛出TypeError。要创建空代理,可以传一个简单的对象字面量作为处理程序对象,从而让所有操作畅通无阻地抵达目标对象。

javascript
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
9.1.2 定义捕获器

使用代理的主要目的是可以定义捕获器(trap)。捕获器就是在处理程序对象中定义的“基本操作的拦截器”。每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应的行为。

例如,定义一个get()捕获器,在ECMAScript操作以某种形式调用get()时触发。

javascript
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

例子中,通过proxy[property]proxy.propertyObject.create(proxy)[property]等操作会触发基本的get()操作以获取属性。因为这些操作发生在代理对象上,就会触发get()捕获器。而在目标对象上执行这些操作则仍会产生正常的行为。

9.1.3 捕获器参数和反射API

所有捕获器都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为。比如,get()捕获器会接收到目标对象、要查询的属性和代理对象三个参数。

javascript
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 也可以像下面这样定义出空代理对象:

javascript
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
9.1.4 捕获器不变式

使用捕获器几乎可以改变所有基本方法的行为,但也不是没有限制。根据ECMAScript 规范,每个捕获的方法都知道目标对象上下文、捕获函数签名,而捕获处理程序的行为必须遵循“捕获器不变式”(trap invariant)。捕获器不变式因方法不同而异,但通常都会防止捕获器定义出现过于反常的行为。

比如,如果目标对象有一个不可配置且不可写的数据属性,那么在捕获器返回一个与该属性不同的值时,会抛出TypeError:

javascript
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
9.1.5 可撤销代理

有时候可能需要中断代理对象与目标对象之间的联系。对于使用new Proxy()创建的普通代理来说,这种联系会在代理对象的生命周期内一直持续存在。Proxy 也暴露了revocable()方法,这个方法支持撤销代理对象与目标对象的关联。撤销代理的操作是不可逆的。而且,撤销函数revoke()是幂等的,调用多少次的结果都一样。撤销代理之后再调用代理会抛出TypeError。

javascript
var revocable = Proxy.revocable({}, {
  	get(target, name) {
    	return "[[" + name + "]]";
  	}
});
var proxy = revocable.proxy;
proxy.foo;              // "[[foo]]"

revocable.revoke();

console.log(proxy.foo); // 抛出 TypeError
proxy.foo = 1           // 还是 TypeError
delete proxy.foo;       // 又是 TypeError
typeof proxy            // "object",因为 typeof 不属于可代理操作
9.1.6 实用反射API

某些情况下应该优先使用反射API,这是有一些理由的。

######## 1. 反射API和对象API

在使用反射API 时,要记住:

  1. 反射API 并不限于捕获处理程序;
  2. 反射API 并不限于捕获处理程序;

通常,Object 上的方法适用于通用程序,而反射方法适用于细粒度的对象控制与操作。

######## 2. 状态标记

很多反射方法返回称作“状态标记”的布尔值,表示意图执行的操作是否成功。有时候,状态标记比那些返回修改后的对象或者抛出错误(取决于方法)的反射API 方法更有用。例如,可以使用反射API 对下面的代码进行重构:

javascript
// 初始代码
const o = {};
try {
    Object.defineProperty(o, 'foo', 'bar');
    console.log('success');
} catch(e) {
	console.log('failure');
}

在定义新属性的时候,Object.defineProperty()发生错误时会抛出错误,而Reflect.defineProperty()会返回false,因此可以用这个反射方法来重构上面的代码。

javascript
// 重构后的代码
const o = {};
if(Reflect.defineProperty(o, 'foo', {value: 'bar'})) {
	console.log('success');
} else {
	console.log('failure');
}

以下反射方法都会提供状态标记:

  • Reflect.defineProperty()
  • Reflect.preventExtensions()
  • Reflect.setPrototypeOf()
  • Reflect.set()
  • Reflect.deleteProperty()

######## 3. 用一等函数替代操作符

以下反射方法提供只有通过操作符才能完成的操作。

  • Reflect.get():可以替代对象属性访问操作符。
  • Reflect.set():可以替代=赋值操作符。
  • Reflect.has():可以替代in 操作符或with()。
  • Reflect.deleteProperty():可以替代delete 操作符。
  • Reflect.construct():可以替代new 操作符。

######## 4. 安全地应用函数

在通过apply 方法调用函数时,被调用的函数可能也定义了自己的apply 属性(虽然可能性极小)。为绕过这个问题,可以使用定义在Function 原型上的apply 方法,比如:

Function.prototype.apply.call(myFunc, thisVal, argumentList);

这种可怕的代码完全可以使用Reflect.apply 来避免:

Reflect.apply(myFunc, thisVal, argumentsList);

9.1.7 代理另一个代理

代理可以拦截反射API 的操作,而这意味着完全可以创建一个代理,通过它去代理另一个代理。这样就可以在一个目标对象之上构建多层拦截网:

javascript
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
9.1.8 代理的问题与不足

代理是在ECMAScript 现有基础之上构建起来的一套新API,因此其实现已经尽力做到最好了。很 大程度上,代理作为对象的虚拟层可以正常使用。但在某些情况下,代理也不能与现在的ECMAScript机制很好地协同。

######## 1. 代理中的this

代理潜在的一个问题来源是this 值。方法中的this 通常指向调用这个方法的对象:

javascript
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()。多数情况下,这是符合预期的行为。可是,如果目标对象依赖于对象标识,那就可能碰到意料之外的问题。

javascript
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 实例的对象标识,在这个实例被代理的情况下就会出问题:

javascript
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 的键了:

javascript
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:

javascript
const target = new Date();
const proxy = new Proxy(target, {});
console.log(proxy instanceof Date); // true
proxy.getDate(); // TypeError: 'this' is not a Date object

9.2 代理捕获器与反射方法

代理可以捕获13 种不同的基本操作。这些操作有各自不同的反射API 方法、参数、关联ECMAScript操作和不变式。只要在代理上调用,所有捕获器都会拦截它们对应的反射API 操作。

9.2.1 get()

get()捕获器会在获取属性值的操作中被调用。对应的反射API 方法为Reflect.get()

javascript
const myTarget = {};
const proxy = new Proxy(myTarget, {
    get(target, property, receiver) {
        console.log('get()');
        return Reflect.get(...arguments)
	}
});
proxy.foo;
// get()

######## 1. 返回值

返回值无限制

######## 2. 拦截的操作

  • proxy.property
  • proxy[property]
  • Object.create(proxy)[property]
  • Reflect.get(proxy, property, receiver)

######## 3. 捕获器处理程序参数

  • target:目标对象。
  • property:引用的目标对象上的字符串键属性。
  • receiver:代理对象或继承代理对象的对象。

######## 4. 捕获器不变式

  • 如果target.property 不可写且不可配置,则处理程序返回的值必须与target.property 匹配。
  • 如果target.property 不可配置且[[Get]]特性为undefined,处理程序的返回值也必须是undefined。
9.2.2 set()

set()捕获器会在设置属性值的操作中被调用。对应的反射API 方法为Reflect.set()

javascript
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. 拦截的操作

  • proxy.property = value
  • proxy[property] = value
  • Object.create(proxy)[property] = value
  • Reflect.set(proxy, property, value, receiver)

######## 3. 捕获器处理程序参数

  • target:目标对象。
  • property:引用的目标对象上的字符串键属性。
  • value:要赋给属性的值。
  • receiver:接收最初赋值的对象。

######## 4. 捕获器不变式

  • 如果target.property 不可写且不可配置,则不能修改目标属性的值。
  • 如果target.property 不可配置且[[Set]]特性为undefined,则不能修改目标属性的值。在严格模式下,处理程序中返回false 会抛出TypeError。
9.2.3 has()

has()捕获器会在in 操作符中被调用。对应的反射API 方法为Reflect.has()

javascript
const myTarget = {};
const proxy = new Proxy(myTarget, {
    has(target, property) {
        console.log('has()');
        return Reflect.has(...arguments)
    }
});
'foo' in proxy;
// has()

######## 1. 返回值

has()必须返回布尔值,表示属性是否存在。返回非布尔值会被转型为布尔值。

######## 2. 拦截的操作

  • property in proxy
  • property in Object.create(proxy)
  • with(proxy) {(property);}
  • Reflect.has(proxy, property)

######## 3. 捕获器处理程序参数

  • target:目标对象。
  • property:引用的目标对象上的字符串键属性。

######## 4. 捕获器不变式

  • 如果target.property 存在且不可配置,则处理程序必须返回true。
  • 如果target.property 存在且目标对象不可扩展,则处理程序必须返回true。
9.2.4 defineProperty()

defineProperty()捕获器会在Object.defineProperty()中被调用。对应的反射API 方法为Reflect.defineProperty()

javascript
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. 拦截的操作

  • Object.defineProperty(proxy, property, descriptor)
  • Reflect.defineProperty(proxy, property, descriptor)

######## 3. 捕获器处理程序的参数

  • target:目标对象。
  • property:引用的目标对象上的字符串键属性。
  • descriptor:包含可选的enumerable、configurable、writable、value、get 和set 定义的对象。

######## 4. 捕获器不变式

  • 如果目标对象不可扩展,则无法定义属性。
  • 如果目标对象有一个可配置的属性,则不能添加同名的不可配置属性。
  • 如果目标对象有一个不可配置的属性,则不能添加同名的可配置属性。
9.2.5 getOwnPropertyDescriptor()

getOwnPropertyDescriptor()捕获器会在Object.getOwnPropertyDescriptor()中被调用。对应的反射API 方法为Reflect.getOwnPropertyDescriptor()。

javascript
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. 拦截的操作

  • Object.getOwnPropertyDescriptor(proxy, property)
  • Reflect.getOwnPropertyDescriptor(proxy, property)

######## 3. 捕获器处理程序参数

  • target:目标对象。
  • property:引用的目标对象上的字符串键属性。

######## 4. 捕获器不变式

  • 如果自有的target.property 存在且不可配置,则处理程序必须返回一个表示该属性存在的对象。
  • 如果自有的target.property 存在且可配置,则处理程序必须返回表示该属性可配置的对象。
  • 如果自有的target.property 存在且target 不可扩展,则处理程序必须返回一个表示该属性存在的对象。
  • 如果target.property 不存在且target 不可扩展,则处理程序必须返回undefined 表示该属性不存在。
  • 如果target.property 不存在,则处理程序不能返回表示该属性可配置的对象。
9.2.6 deleteProperty()

deleteProperty()捕获器会在delete 操作符中被调用。对应的反射API 方法为Reflect.deleteProperty()

javascript
const myTarget = {};
const proxy = new Proxy(myTarget, {
    deleteProperty(target, property) {
        console.log('deleteProperty()');
        return Reflect.deleteProperty(...arguments)
	}
});
delete proxy.foo
// deleteProperty()

######## 1. 返回值

deleteProperty()必须返回布尔值,表示删除属性是否成功。返回非布尔值会被转型为布尔值。

######## 2. 拦截的操作

  • delete proxy.property
  • delete proxy[property]
  • Reflect.deleteProperty(proxy, property)

######## 3. 捕获器处理程序参数

  • target:目标对象。
  • property:引用的目标对象上的字符串键属性。

######## 4. 捕获器不变式

如果自有的target.property 存在且不可配置,则处理程序不能删除这个属性。

9.2.7 ownKeys()

ownKeys()捕获器会在Object.keys()及类似方法中被调用。对应的反射API 方法为Reflect.ownKeys()

javascript
const myTarget = {};
const proxy = new Proxy(myTarget, {
    ownKeys(target) {
        console.log('ownKeys()');
        return Reflect.ownKeys(...arguments)
    }
});
Object.keys(proxy);
// ownKeys()

######## 1. 返回值

ownKeys()必须返回包含字符串或符号的可枚举对象。

######## 2. 拦截的操作

  • Object.getOwnPropertyNames(proxy)
  • Object.getOwnPropertySymbols(proxy)
  • Object.keys(proxy)
  • Reflect.ownKeys(proxy)

######## 3. 捕获器处理程序参数

  • target:目标对象。

######## 4. 捕获器不变式

返回的可枚举对象必须包含target 的所有不可配置的自有属性。

如果target 不可扩展,则返回可枚举对象必须准确地包含自有属性键。

9.2.8 getPrototypeOf()

getPrototypeOf()捕获器会在Object.getPrototypeOf()中被调用。对应的反射API 方法为Reflect.getPrototypeOf()

javascript
const myTarget = {};
const proxy = new Proxy(myTarget, {
    getPrototypeOf(target) {
        console.log('getPrototypeOf()');
        return Reflect.getPrototypeOf(...arguments)
    }
});
Object.getPrototypeOf(proxy);
// getPrototypeOf()

######## 1. 返回值

getPrototypeOf()必须返回对象或null。

######## 2. 拦截的操作

  • Object.getPrototypeOf(proxy)
  • Reflect.getPrototypeOf(proxy)
  • proxy.__proto__
  • Object.prototype.isPrototypeOf(proxy)
  • proxy instanceof Object

######## 3. 捕获器处理程序参数

  • target:目标对象。

######## 4. 捕获器不变式

如果target 不可扩展,则Object.getPrototypeOf(proxy)唯一有效的返回值就是Object.getPrototypeOf(target)的返回值。

9.2.9 setPrototypeOf()

setPrototypeOf()捕获器会在Object.setPrototypeOf()中被调用。对应的反射API 方法为Reflect.setPrototypeOf()

javascript
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. 拦截的操作

  • Object.setPrototypeOf(proxy)
  • Reflect.setPrototypeOf(proxy)

######## 3. 捕获器处理程序参数

  • target:目标对象。
  • prototype:target 的替代原型,如果是顶级原型则为null。

######## 4. 捕获器不变式

  • 如果target 不可扩展,则唯一有效的prototype 参数就是Object.getPrototypeOf(target) 的返回值。
9.2.10 isExtensible()

isExtensible()捕获器会在Object.isExtensible()中被调用。对应的反射API 方法为Reflect.isExtensible()

javascript
const myTarget = {};
const proxy = new Proxy(myTarget, {
    isExtensible(target) {
        console.log('isExtensible()');
        return Reflect.isExtensible(...arguments)
    }
});
Object.isExtensible(proxy);
// isExtensible()

######## 1. 返回值

isExtensible()必须返回布尔值,表示target 是否可扩展。返回非布尔值会被转型为布尔值。

######## 2. 拦截的操作

  • Object.isExtensible(proxy)
  • Reflect.isExtensible(proxy)

######## 3. 捕获器处理程序参数

  • target:目标对象。

######## 4. 捕获器不变式

如果target 可扩展,则处理程序必须返回true。

如果target 不可扩展,则处理程序必须返回false。

9.2.11 preventExtensions()

preventExtensions()捕获器会在Object.preventExtensions()中被调用。对应的反射API方法为Reflect.preventExtensions()

javascript
const myTarget = {};
const proxy = new Proxy(myTarget, {
    preventExtensions(target) {
        console.log('preventExtensions()');
        return Reflect.preventExtensions(...arguments)
    }
});
Object.preventExtensions(proxy);
// preventExtensions()

######## 1. 返回值

preventExtensions()必须返回布尔值,表示target 是否已经不可扩展。返回非布尔值会被转型为布尔值。

######## 2. 拦截的操作

  • Object.preventExtensions(proxy)
  • Reflect.preventExtensions(proxy)

######## 3. 捕获器处理程序参数

  • target:目标对象。

######## 4. 捕获器不变式

如果Object.isExtensible(proxy)是false,则处理程序必须返回true。

9.2.12 apply()

apply()捕获器会在调用函数时中被调用。对应的反射API 方法为Reflect.apply()。

javascript
const myTarget = () => {};
const proxy = new Proxy(myTarget, {
    apply(target, thisArg, ...argumentsList) {
        console.log('apply()');
        return Reflect.apply(...arguments)
    }
});
proxy();
// apply()

######## 1. 返回值

返回值无限制。

######## 2. 拦截的操作

  • proxy(...argumentsList)
  • Function.prototype.apply(thisArg, argumentsList)
  • Function.prototype.call(thisArg, ...argumentsList)
  • Reflect.apply(target, thisArgument, argumentsList)

######## 3. 捕获器处理程序参数

  • target:目标对象。
  • thisArg:调用函数时的this 参数。
  • argumentsList:调用函数时的参数列表

######## 4. 捕获器不变式

target 必须是一个函数对象。

9.2.13 construct()

construct()捕获器会在new 操作符中被调用。对应的反射API 方法为Reflect.construct()

javascript
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. 拦截的操作

  • new proxy(...argumentsList)
  • Reflect.construct(target, argumentsList, newTarget)

######## 3. 捕获器处理程序参数

  • target:目标构造函数。
  • argumentsList:传给目标构造函数的参数列表。
  • newTarget:最初被调用的构造函数。

######## 4. 捕获器不变式

target 必须可以用作构造函数。

9.3 代理模式

使用代理可以在代码中实现一些有用的编程模式。

9.3.1 跟踪属性访问

通过捕获get、set 和has 等操作,可以知道对象属性什么时候被访问、被查询。把实现相应捕获器的某个对象代理放到应用中,可以监控这个对象何时在何处被访问过。

javascript
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
9.3.2 隐藏属性

代理的内部实现对外部代码是不可见的,因此要隐藏目标对象上的属性也轻而易举。比如:

javascript
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
9.3.3 属性验证

因为所有赋值操作都会触发set()捕获器,所以可以根据所赋的值决定是允许还是拒绝赋值。

javascript
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
9.3.4 函数与构造函数参数验证

跟保护和验证对象属性类似,也可对函数和构造函数参数进行审查。比如,可以让函数只接收某种类型的值:

javascript
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

类似地,可以要求实例化时必须给构造函数传参:

javascript
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
9.3.5 数据绑定与可观察对象

通过代理可以把运行时中原本不相关的部分联系到一起。这样就可以实现各种模式,从而让不同的代码互操作。

比如,可以将被代理的类绑定到一个全局实例集合,让所有创建的实例都被添加到这个集合中:

javascript
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{}]

9.4 小结

代理是ES6 新增的令人兴奋和动态十足的新特性。尽管不支持向后兼容,但它开辟出了 一片前所未有的JavaScript 元编程及抽象的新天地。

从宏观上看,代理是真实JavaScript 对象的透明抽象层。代理可以定义包含捕获器的处理程序对象,而这些捕获器可以拦截绝大部分JavaScript 的基本操作和方法。在这个捕获器处理程序中,可以修改任何基本操作的行为,当然前提是遵从捕获器不变式。

与代理如影随形的反射API,则封装了一整套与捕获器拦截的操作相对应的方法。可以把反射API看作一套基本操作,这些操作是绝大部分JavaScript 对象API 的基础。

代理的应用场景是不可限量的。开发者使用它可以创建出各种编码模式,比如(但远远不限于)跟踪属性访问、隐藏属性、阻止修改或删除属性、函数参数验证、构造函数参数验证、数据绑定,以及可观察对象。

10. 函数

函数的本质实际上是对象。每个函数都是Function类型的实例,而Function 也有属性和方法,跟其他引用类型一样。因为函数是对象,所以函数名就是指向函数对象的指针,而且不一定与函数本身紧密绑定。

函数有以下几种定义方式:

javascript
// 函数声明
function sum (num1, num2) {
	return num1 + num2;
}
javascript
// 函数表达式
let sum = function(num1, num2) {
	return num1 + num2;
};
javascript
// 箭头函数
let sum = (num1, num2) => {
	return num1 + num2;
};
javascript
// Function构造函数,最后一个参数为函数体,之前的参数为函数参数(不推荐使用)
let sum = new Function("num1", "num2", "return num1 + num2");

10.1 箭头函数

ES6新增了使用胖箭头(=>)语法定义函数表达式的能力。很大程度上,箭头函数实例化的函数对象与正式的函数表达式创建的函数对象行为是相同的。任何可以使用函数表达式的地方,都可以使用箭头函数:

javascript
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

箭头函数虽然语法简洁,但也有很多场合不适用。箭头函数不能使用arguments、super 和new.target,也不能用作构造函数。此外,箭头函数也没有prototype 属性。

10.2 函数名

因为函数名就是指向函数的指针,所以它们跟其他包含对象指针的变量具有相同的行为。这意味着一个函数可以有多个名称,如下所示:

javascript
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

ECMAScript 6 的所有函数对象都会暴露一个只读的name 属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说是一个字符串化的变量名。即使函数没有名称,也会如实显示成空字符串。如果它是使用Function 构造函数创建的,则会标识成"anonymous":

javascript
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

10.3 理解参数

ECMAScript中的函数既不关心传入的参数个数,也不关心传入参数的数据类型,也不关心传入的参数是否被使用。这样的原因是因为ECMAScript函数的参数在内部表现为一个数组。在函数被调用的时候总会接受一个数组,函数并不关心数组的内容。在使用function关键字定义(非箭头)函数的时候,可以在函数内部访问arguments对象,来获得传入的每个参数值。arguments对象是一个类数组对象,因此可以使用中括号语法来访问其中的元素。可以通过arguments.length属性来获得函数的数量。

javascript
// 第一种写法
function sayHi(name, message) {
	console.log("Hello " + name + ", " + message);
}

// 第二种写法
function sayHi() {
	console.log("Hello " + arguments[0] + ", " + arguments[1]);
}

arguments对象中的值始终与对应的命名参数同步:

javascript
function doAdd(num1, num2) {
    arguments[1] = 10;
    console.log(arguments[0] + num2);
}

上方的代码会将arguments[1]对应的参数num2的在函数体中的值修改为10。但这并不意味着它们都访问同一块内存地址,它们在内存中分开存储,但始终保持同步。

对于命名参数而言,如果调用函数时没有传这个参数,那么它的值就是undefined。这就类似于定义了变量而没有初始化。比如,如果只给doAdd()传了一个参数,那么num2 的值就是undefined。

严格模式下,arguments 会有一些变化。首先,像前面那样给arguments[1]赋值不会再影响num2的值。就算把arguments[1]设置为10,num2 的值仍然还是传入的值。其次,在函数中尝试重写arguments 对象会导致语法错误。(代码也不会执行。)

######## 箭头函数中的参数

如果函数是使用箭头语法定义的,那么传给函数的参数将不能使用arguments 关键字访问,而只能通过定义的命名参数访问。

javascript
function foo() {
	console.log(arguments[0]);
}
foo(5); // 5
let bar = () => {
	console.log(arguments[0]);
};
bar(5); // ReferenceError: arguments is not defined

虽然箭头函数中没有arguments 对象,但可以在包装函数中把它提供给箭头函数:

javascript
function foo() {
    let bar = () => {
        console.log(arguments[0]); // 5
    };
    bar();
}
foo(5);

10.4 没有重载

ECMAScript 函数不能像传统编程那样重载。在其他语言比如Java 中,一个函数可以有两个定义,只要签名(接收参数的类型和数量)不同就行。如前所述,ECMAScript 函数没有签名,因为参数是由包含零个或多个值的数组表示的。没有函数签名,自然也就没有重载。如果在ECMAScript 中定义了两个同名函数,则后定义的会覆盖先定义的。如果想通过一个函数名实现多种操作,可以通过检查参数的类型和数量(arguments),来分别判断并执行不同的逻辑来模拟重载。也可以通过“函数名就是指向函数的指针”来理解为什么没有函数重载。

10.5 默认参数值

ES6之后可以显式定义默认参数,可以在传入参数后添加=来指定默认值。

function makeKing(name = 'Henry') {
	return `King ${name} VIII`;
}
console.log(makeKing('Louis')); // 'King Louis VIII'
console.log(makeKing()); // 'King Henry VIII'

在使用默认函数的时候,arguments对象的值不反应参数的默认值,只反映传给函数的参数。修改命名参数也不会影响arguments对象,它始终以调用函数传入的值为准:

javascript
function makeKing(name = 'Henry') {
    name = 'Louis';
    return `King ${arguments[0]}`;
}
console.log(makeKing()); // 'King undefined'
console.log(makeKing('Louis')); // 'King Louis'

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

默认参数可以定义为变量,也可以动态调用函数,因此函数参数是在某个作用域里求值的,给多个参数定义默认值实际上跟使用let关键字顺序声明变量一样。

javascript
// 默认参数
function makeKing(name = 'Henry', numerals = 'VIII') {
	return `King ${name} ${numerals}`;
}
console.log(makeKing()); // King Henry VIII
javascript
// 默认参数会按照定义它们的顺序依次被初始化,类似以下结果
function makeKing() {
    let name = 'Henry';
    let numerals = 'VIII';
    return `King ${name} ${numerals}`;
}

因为参数是按照顺序初始化的,所以后定义默认值的参数可以引用先定义的参数。

参数初始化的顺序遵循“暂时性死区”的规则,即前面定义的参数不能引用后面定义的,引用了会抛出错误。

参数也存在与自己的作用域里,它们不能引用函数体内部的作用域变量。

10.6 参数扩展与收集

ES6新增了扩展操作符,可以用来简洁地操作和组合集合数据。扩展操作符使用的场景是函数定义中的参数列表,既可以用于调用参数时传参,也可以用于定义函数参数。

10.6.1 扩展参数
javascript
// 将一个数组的元素分别传入函数
let values = [1, 2, 3, 4];
function getSum() {
    let sum = 0;
    for (let i = 0; i < arguments.length; ++i) {
    	sum += arguments[i];
    }
    return sum;
}

// 不使用扩展运算符
console.log(getSum.apply(null, values)); // 10
// 使用扩展运算符(直接拆分数组)
console.log(getSum(...values)); // 10

因为数组的长度是已知的,所以在使用扩展运算符传参的时候,可以在其前后传入其他值,包括使用扩展运算符传入其他参数。

javascript
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
10.6.2 收集参数

扩展运算符除了可以拆分数组,也可以收集参数合并成数组

javascript
function getSum(...values) {
// 顺序累加values 中的所有值
// 初始值的总和为0
	return values.reduce((x, y) => x + y, 0);
}
console.log(getSum(1,2,3)); // 6

收集参数的前面如果还有命名参数,则会收集传入的剩余参数,如果没有会得到一个空数组。因为收集参数的结果是可变的,所以只能作为最后一个参数。

javascript
// 不可以
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 一样的逻辑:

javascript
let getSum = (...values) => {
	return values.reduce((x, y) => x + y, 0);
}
console.log(getSum(1,2,3)); // 6

使用收集参数的时候也不影响arguments对象,它仍然会反映调用时传给函数的参数:

javascript
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));

10.7 函数声明与函数表达式

JavaScript引擎在加载数据的时候对函数声明和函数表达式是区别对待的。JavaScript 引擎在任何代码执行之前,会先读取函数声明,并在执行上下文中生成函数定义。而函数表达式必须等到代码执行到它那一行,才会在执行上下文中生成函数定义。

javascript
// 没问题
console.log(sum(10, 10));
function sum(num1, num2) {
	return num1 + num2;
}

函数声明会在任何代码执行之前先被读取并添加到执行上下文。这个过程被称为函数声明提升。在执行代码时,JavaScript引擎会先执行一遍扫描,把发现的函数声明提升到源代码树的顶部因此即使函数定义出现在调用它们的代码之后,引擎也会把函数声明提升到顶部。如果把前面代码中的函数声明改为等价的函数表达式,那么执行的时候就会出错:

javascript
// 会出错
console.log(sum(10, 10));
var sum = function(num1, num2) {
	return num1 + num2;
};

上面的代码之所以会出错,是因为这个函数定义包含在一个变量初始化语句中,而不是函数声明中。即使var可以变量声明提升,sum也会被初始化为undefined而不是函数。

除了函数什么时候真正有定义这个区别之外,这两种语法是等价的。

10.8 函数作为值

因为函数名在ECMAScript 中就是变量,所以函数可以用在任何可以使用变量的地方。这意味着不仅可以把函数作为参数传给另一个函数,而且还可以在一个函数中返回另一个函数。

javascript
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"

可以用在sort()函数中的比较函数

javascript
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;
        }
    };
}

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

10.9 函数内部

在ECMAScript 5 中,函数内部存在两个特殊的对象:arguments 和this。ECMAScript 6 又新增了new.target 属性。

10.9.1 arguments

arguments对象是一个类数组对象,包含调用函数时传入的参数。这个对象只有在使用function关键字定义函数(相对于箭头函数)的时候才会有。虽然主要用于包含函数参数,但arguments对象其实还有一个callee属性,是一个指向arguments对象所在函数的指针。

javascript
// 阶乘函数
function factorial(num) {
    if (num <= 1) {
    	return 1;
    } else {
    	return num * factorial(num - 1);
    }
}

上面的阶乘函数通过递归调用自身来实现,但这个函数想要正确执行就必须保证函数名为factorial,从而导致了紧密耦合。可以使用arguments.callee来让函数逻辑和函数名解耦:

javascript
function factorial(num) {
    if (num <= 1) {
    	return 1;
    } else {
    	return num * arguments.callee(num - 1);
    }
}

这个重写之后的factorial()函数已经用arguments.callee 代替了之前硬编码的factorial。这意味着无论函数叫什么名称,都可以引用正确的函数。考虑下面的情况:

javascript
let trueFactorial = factorial;
factorial = function() {
	return 0;
};
console.log(trueFactorial(5)); // 120
console.log(factorial(5)); // 0

上面的代码,通过trueFactorial变量来保存factorial函数的指针,后续factorial函数虽然被重写,但并不会影响调用trueFactorial()函数的返回结果。

10.9.2 this

另一个特殊对象是this,它在标准函数和箭头函数中有不同的行为。

在标准函数中,this 引用的是把函数当成方法调用的上下文对象,这时候通常称其为this 值(在 网页的全局上下文中调用函数时,this 指向windows)。

javascript
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()函数,那么此时的this指向window,输出为"red"。将sayColor()函数赋值给对象o,此时调用对象o中的sayColor()函数时,this指向o,因此输出o.color,为"blue"。

在箭头函数中,this 引用的是定义箭头函数的上下文。下面的例子演示了这一点。在对sayColor()的两次调用中,this 引用的都是window 对象,因为这个箭头函数是在window 上下文中定义的:

javascript
window.color = 'red';
let o = {
	color: 'blue'
};
let sayColor = () => console.log(this.color);
sayColor(); // 'red'
o.sayColor = sayColor;
o.sayColor(); // 'red'

因此,可以使用箭头函数来解决在事件回调或定时回调中调用某个函数时,this 值指向的并非想要的对象。

javascript
function King() {
this.royaltyName = 'Henry';
    // this 引用 King 的实例
    setTimeout(() => console.log(this.royaltyName), 1000);
}
function Queen() {
this.royaltyName = 'Elizabeth';
    // 标准函数setTimeout中this始终引用 window 对象
    setTimeout(function() { console.log(this.royaltyName); }, 1000);
}
new King(); // Henry
new Queen(); // undefined
10.9.3 caller

ES5会给函数对象上添加一个属性:caller。这个属性引用的是调用当前函数的函数,或者如果是在全局作用域中调用的则为null。如:

javascript
function outer() {
	inner();
}
function inner() {
	console.log(inner.caller);
    //非严格模式下可以使用arguments.callee.caller来降低耦合度
    // console.log(arguments.callee.caller);
}
outer(); // function outer() { inner(); }

在严格模式下访问arguments.callee 会报错。ECMAScript 5 也定义了arguments.caller,但在严格模式下访问它会报错,在非严格模式下则始终是undefined。这是为了分清arguments.caller和函数的caller 而故意为之的。而作为对这门语言的安全防护,这些改动也让第三方代码无法检测同一上下文中运行的其他代码。

严格模式下还有一个限制,就是不能给函数的caller 属性赋值,否则会导致错误。

10.9.4 new.target

ECMAScript 中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。ECMAScript 6 新增了检测函数是否使用new 关键字调用的new.target 属性。如果函数是正常调用的,则new.target 的值是undefined;如果是使用new 关键字调用的,则new.target 将引用被调用的构造函数。

javascript
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"

10.10 函数属性与方法

前面提到过,ECMAScript 中的函数是对象,因此有属性和方法。每个函数都有两个属性:length和prototype。其中,length 属性保存函数定义的命名参数的个数,如下例所示:

javascript
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

prototype 是保存引用类型所有实例方法的地方,这意味着toString()、valueOf()等方法实际上都保存在prototype 上,进而由所有实例共享。这个属性在自定义类型时特别重要。在ES5中,prototype属性是不可枚举的,因此使用for-in循环不会返回这个属性。

函数还提供两个方法:apply()和call()。这两个方法都会以指定的this 值来调用函数,即会设置调用函数时函数体内this 对象的值。

  • apply() 方法调用一个具有给定this值的函数,以及以一个数组(或类数组对象)的形式提供的参数。
  • call() 方法使用一个指定的 this 值和单独给出的一个或多个参数来调用一个函数。

除此之外,ES5还定义了一个新方法:bind()。bind()方法能创建一个新的函数实例,其中的this值会被绑定到传给bind()的对象。

对函数而言,继承的方法toLocaleString()和toString()始终返回函数的代码。返回代码的具体格式因浏览器而异。有的返回源代码,包含注释,而有的只返回代码的内部形式,会删除注释,甚至代码可能被解释器修改过。由于这些差异,因此不能在重要功能中依赖这些方法返回的值,而只应在调试中使用它们。继承的方法valueOf()返回函数本身。

10.11 函数表达式

定义函数有两种方式:函数声明和函数表达式。函数声明的特点是函数声明提升,即函数声明会在代码执行之前获得定义。函数表达式是创建一个函数再把它赋值给一个变量。这样创建的函数叫做匿名函数(也被称作兰姆达函数),因为function关键字后面没有标识符。未赋值给其他变量的匿名函数的name属性时空字符串。函数表达式需要先赋值才能使用。

10.12 递归

递归函数通常的形式为一个函数通过名称调用自己。之前介绍过通过arguments.callee来减少代码耦合度,但在严格模式下运行的代码是不能访问arguments.callee的。此时,可以通过命名函数表达式来解决。

javascript
const factorial = (function f(num) {
    if (num <= 1) {
        return 1;
    } else {
        return num * f(num - 1);
    }
});

10.13 尾调用优化

ES6规范新增了一项内存管理优化机制,让JavaScript引擎在满足条件的时候可以重用栈帧。具体来说,这项优化非常适合“尾调用”,即外部函数的返回值是一个内部函数的返回值。如:

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尾调用优化的关键:如果函数的逻辑允许基于尾调用将其销毁,则引擎就会那么做。

10.13.1 尾调用优化的条件

尾调用优化的条件就是确定外部栈帧真的没有必要存在了。设计的条件如下:

  • 代码在严格模式下执行;
  • 外部函数的返回值是对尾调用函数的调用;
  • 尾调用函数返回后不需要执行额外的逻辑;
  • 尾调用函数不是引用外部函数作用域中自由变量的闭包。

无论是递归尾调用还是非递归尾调用,都可以应用优化。引擎并不区分尾调用中调用的是函数自身还是其他函数。不过,这个优化在递归场景下的效果是最明显的,因为递归代码最容易在栈内存中迅速产生大量栈帧。

Notes:尾调用优化之所以要求代码在严格模式下执行,是因为在非严格模式中,函数调用允许使用f.arguments和f.caller,而它们都会引用外部函数的栈帧。这意味着无法应用优化。因此尾调用优化要求必须在严格模式下有效,以防止引用这些属性。

10.13.2 尾调用优化的代码
javascript
// 递归计算斐波那契数列
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),如果n值足够大的话,会给浏览器带来麻烦。可以通过尾调用来优化这个函数。

javascript
"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);
}

10.14 闭包

闭包指的是那些引用了另一个函数作用域中变量的函数,通常在嵌套函数中实现。闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。可以使用null来解除对函数的引用,从而让垃圾回收程序可以将内存释放掉。

10.14.1 this对象

在闭包中使用this 会让代码变复杂。如果内部函数没有使用箭头函数定义,则this 对象会在运行时绑定到执行函数的上下文。如果在全局函数中调用,则this 在非严格模式下等于window,在严格模式下等于undefined。如果作为某个对象的方法调用,则this 等于这个对象。匿名函数在这种情况下不会绑定到某个对象,这就意味着this 会指向window,除非在严格模式下this 是undefined。

javascript
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 的值。

每个函数在被调用时都会自动创建两个特殊变量:this 和arguments。内部函数永远不可能直接访问外部函数的这两个变量。因此可以把this保存到闭包可以访问的另一个变量中。

javascript
window.identity = 'The Window';
let object = {
    identity: 'My Object',
    getIdentityFunc() {
    	let that = this;
    	return function() {
        	return that.identity;
        };
    }
};
console.log(object.getIdentityFunc()()); // 'My Object'
10.14.2 内存泄露

由于IE 在IE9 之前对JScript 对象和COM对象使用了不同的垃圾回收机制,所以闭包在这些旧版本IE 中可能会导致问题。在这些版本的IE 中,把HTML 元素保存在某个闭包的作用域中,就相当于宣布该元素不能被销毁。

javascript
function assignHandler() {
    let element = document.getElementById('someElement');
    element.onclick = () => console.log(element.id);
}

以上代码创建了一个闭包,即使用element元素的事件处理程序,而这个处理程序又创建了一个循环引用,匿名函数(箭头函数)引用着assignHandler()的活动对象,阻止了对element 的引用计数归零。只要这个匿名函数存在,element 的引用计数就至少等于1。也就是说,内存不会被回收。

javascript
// 更改版本
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 对象的引用,其引用计数也会减少,从而确保其内存可以在适当的时候被回收。

10.15 立即调用的函数表达式

立即调用的匿名函数又被称作立即调用的函数表达式(IIFE,Immediately Invoked Function Expression)。它类似于函数声明,但由于被包含在括号中,所以会被解释为函数表达式。紧跟在第一组括号后面的第二组括号会立即调用前面的函数表达式。

javascript
(function() {
	// 块级作用域
})();

使用IIFE 可以模拟块级作用域,即在一个函数表达式内部声明变量,然后立即调用这个函数。这样位于函数体作用域的变量就像是在块级作用域中一样。ECMAScript 5 尚未支持块级作用域,使用IIFE模拟块级作用域是相当普遍的。

javascript
// 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 就没有那么必要了,因为块级作用域中的变量(let和const)无须IIFE 就可以实现同样的隔离。

10.16 私有变量

严格来讲,JavaScript 没有私有成员的概念,所有对象属性都公有的。不过,倒是有私有变量的概念。任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数。

特权方法(privileged method)是能够访问函数私有变量(及私有函数)的公有方法。在对象上有两种方式创建特权方法。第一种是在构造函数中实现,如:

javascript
function MyObject() {
    // 私有变量和私有函数
    let privateVariable = 10;
    function privateFunction() {
        return false;
    }
    // 特权方法
    this.publicMethod = function() {
        privateVariable++;
        return privateFunction();
    };
}

这个模式是把所有私有变量和私有函数都定义在构造函数中。然后,再创建一个能够访问这些私有成员的特权方法。这样做之所以可行,是因为定义在构造函数中的特权方法其实是一个闭包,它具有访问构造函数中定义的所有变量和函数的能力。使用构造函数来创建对象,每次调用构造函数的时候都会重新创建一套变量和方法,可以使用静态私有变量实现特权方法来避免这个问题。

10.16.1 静态私有变量

特权方法也可以通过使用私有作用域定义私有变量和函数来实现。如:

javascript
(function() {
    // 私有变量和私有函数
    let privateVariable = 10;
    function privateFunction() {
    	return false;
    }
    // 构造函数
    MyObject = function() {};
    // 公有和特权方法
    MyObject.prototype.publicMethod = function() {
        privateVariable++;
        return privateFunction();
    };
})();

在这个模式中,匿名函数表达式创建了一个包含构造函数及其方法的私有作用域。首先定义的是私有变量和私有函数,然后又定义了构造函数和公有方法。公有方法定义在构造函数的原型上,与典型的原型模式一样。注意,这个模式定义的构造函数没有使用函数声明,使用的是函数表达式。函数声明会创建内部函数,在这里并不是必需的。基于同样的原因(但操作相反),这里声明MyObject 并没有使用任何关键字。因为不使用关键字声明的变量会创建在全局作用域中,所以MyObject 变成了全局变量,可以在这个私有作用域外部被访问。注意在严格模式下给未声明的变量赋值会导致错误。

这个模式与前一个模式的主要区别就是,私有变量和私有函数是由实例共享的。因为特权方法定义在原型上,所以同样是由实例共享的。特权方法作为一个闭包,始终引用着包含它的作用域。

javascript
(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 变量设置为一个新值。而所有实例都会返回相同的值。

像这样创建静态私有变量可以利用原型更好地重用代码,只是每个实例没有了自己的私有变量。最终,到底是把私有变量放在实例中,还是作为静态私有变量,都需要根据自己的需求来确定。

10.16.2 模块模式

Douglas Crockford 所说的模块模式,在一个单例对象上实现了相同的隔离和封装。单例对象(singleton)就是只有一个实例的对象。按照惯例,JavaScript 是通过对象字面量来创建单例对象的,如下面的例子所示:

javascript
let singleton = {
    name: value,
    method() {
    	// 方法的代码
    }
};

模块模式是在单例对象基础上加以扩展,使其通过作用域链来关联私有变量和特权方法。模块模式的样板代码如下:

javascript
let singleton = function() {
    // 私有变量和私有函数
    let privateVariable = 10;
    function privateFunction() {
    	return false;
	}
// 特权/公有方法和属性
    return {
        publicProperty: true,
        publicMethod() {
            privateVariable++;
            return privateFunction();
    	}
    };
}();

模块模式使用了匿名函数返回一个对象。在匿名函数内部,首先定义私有变量和私有函数。之后,创建一个要通过匿名函数返回的对象字面量。这个对象字面量中只包含可以公开访问的属性和方法。因为这个对象定义在匿名函数内部,所以它的所有公有方法都可以访问同一个作用域的私有变量和私有函数。本质上,对象字面量定义了单例对象的公共接口。

在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。以这种方式创建的每个单例对象都是Object 的实例,因为最终单例都由一个对象字面量来表示。不过这无关紧要,因为单例对象通常是可以全局访问的,而不是作为参数传给函数的,所以可以避免使用instanceof 操作符确定参数是不是对象类型的需求。

10.16.3 模块增强模式

另一个利用模块模式的做法是在返回对象之前先对其进行增强。这适合单例对象需要是某个特定类型的实例,但又必须给它添加额外属性或方法的场景。

javascript
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;
}();

10.17 小结

函数是JavaScript 编程中最有用也最通用的工具。ECMAScript 6 新增了更加强大的语法特性,从而让开发者可以更有效地使用函数。

  • 函数表达式与函数声明是不一样的。函数声明要求写出函数名称,而函数表达式并不需要。没有名称的函数表达式也被称为匿名函数。
  • ES6 新增了类似于函数表达式的箭头函数语法,但两者也有一些重要区别。
  • JavaScript 中函数定义与调用时的参数极其灵活。arguments 对象,以及ES6 新增的扩展操作符,可以实现函数定义和调用的完全动态化。
  • 函数内部也暴露了很多对象和引用,涵盖了函数被谁调用、使用什么调用,以及调用时传入了什么参数等信息。
  • JavaScript 引擎可以优化符合尾调用条件的函数,以节省栈空间。
  • 闭包的作用域链中包含自己的一个变量对象,然后是包含函数的变量对象,直到全局上下文的变量对象。
  • 通常,函数作用域及其中的所有变量在函数执行完毕后都会被销毁。
  • 闭包在被函数返回之后,其作用域会一直保存在内存中,直到闭包被销毁。
  • 函数可以在创建之后立即调用,执行其中代码之后却不留下对函数的引用。
  • 立即调用的函数表达式如果不在包含作用域中将返回值赋给一个变量,则其包含的所有变量都会被销毁。
  • 虽然JavaScript 没有私有对象属性的概念,但可以使用闭包实现公共方法,访问位于包含作用域中定义的变量。
  • 可以访问私有变量的公共方法叫作特权方法。
  • 特权方法可以使用构造函数或原型模式通过自定义类型中实现,也可以使用模块模式或模块增强模式在单例对象上实现。

11. 期约与异步函数

11.1 异步编程

同步行为和异步行为的对立统一是计算机科学的一个基本概念。特别是在JavaScript 这种单线程事件循环模型中,同步操作与异步操作更是代码所要依赖的核心机制。异步行为是为了优化因计算量大而时间长的操作。如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定,那么这样做就是务实的。重要的是,异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个异步操作而阻塞线程执行,那么任何时候都可以使用。

11.1.1 同步与异步

同步行为对应内存中顺序执行的处理器指令。每条指令都会严格按照它们出现的顺序来执行,而每条指令执行后也能立即获得存储在系统本地(如寄存器或系统内存)的信息。这样的执行流程容易分析程序在执行到代码任意位置时的状态(比如变量的值)。

同步操作的例子可以是执行一次简单的数学计算:

javascript
let x = 3;
x = x + 4;

将这两行JavaScript 代码对应的低级指令放进系统中。首先,操作系统会在栈内存上分配一个存储浮点数值的空间,然后针对这个值做一次数学计算,再把计算结果写回之前分配的内存中。所有这些指令都是在单个线程中按顺序执行的。在低级指令的层面,有充足的工具可以确定系统状态。

相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作经常是必要的,因为强制进程等待一个长时间的操作通常是不可行的(同步操作则必须要等)。如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。

异步操作的例子可以是在定时回调中执行一次简单的数学计算:

javascript
let x = 3;
setTimeout(() => x = x + 4, 1000);

这段程序最终与同步代码执行的任务一样,都是把两个数加在一起,但这一次执行线程不知道x 值何时会改变,因为这取决于回调何时从消息队列出列并执行。

异步代码不容易推断。

但第二个指令块(加操作及赋值操作)是由系统计时器触发的,这会生成一个入队执行的中断。到底什么时候会触发这个中断,这对JavaScript 运行时来说是一个黑盒,因此实际上无法预知(尽管可以保证这发生在当前线程的同步代码执行之后,否则回调都没有机会出列被执行)。无论如何,在排定回调以后基本没办法知道系统状态何时变化。

为了让后续代码能够使用x,异步执行的函数需要在更新x 的值以后通知其他代码。如果程序不需要这个值,那么就只管继续执行,不必等待这个结果了。设计一个能够知道x 什么时候可以读取的系统是非常难的。JavaScript 在实现这样一个系统的过程中也经历了几次迭代。

11.1.2 以往的异步编程模式

异步行为是JavaScript 的基础,但以前的实现不理想。在早期的JavaScript 中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。

######## 1. 异步返回值

假设setTimeout 操作会返回一个有用的值。有什么好办法把这个值传给需要它的地方?广泛接受的一个策略是给异步操作提供一个回调,这个回调中包含要使用异步返回值的代码(作为回调的参数)。

javascript
function double(value, callback) {
	setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`I was given: ${x}`));
// I was given: 6(大约1000 毫秒之后)

######## 2. 失败处理

异步操作的失败处理在回调模型中也要考虑,因此自然就出现了成功回调和失败回调:

javascript
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. 嵌套异步回调

如果异步返值又依赖另一个异步返回值,那么回调的情况还会进一步变复杂。在实际的代码中,这就要求嵌套回调:

javascript
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 毫秒之后)

显然,随着代码越来越复杂,回调策略是不具有扩展性的。“回调地狱”这个称呼可谓名至实归。嵌套回调的代码维护起来就是噩梦。

11.2 期约

期约是对尚不存在结果的一个替身。

11.2.1 Promises/A+规范

早期的期约机制在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)也以期约为基础。

11.2.2 期约基础

ES6新增的引用类型Promise,可以通过new操作符来实例化。创建新期约时需要传入执行器(executor)函数作为参数。如:

javascript
let p = new Promise(() => {});
setTimeout(console.log, 0, p); // Promise <pending>

如果不给Promise提供执行器函数,会抛出语法错误。

######## 1. 期约状态机

在把一个期约实例传给console.log()时,控制台输出(可能因浏览器不同而略有差异)表明该实例处于待定(pending)状态。如前所述,期约是一个有状态的对象,可能处于如下3 种状态之一:

  • 待定(pending)
  • 兑现(fulfilled,有时候也称为“解决”,resolved)
  • 拒绝(rejected)

待定(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()也会抛出错误。

######## 4. Promise.resolve()

期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定状态。通过调用Promise.resolve()静态方法,可以实例化一个解决的期约。下面两个期约实例实际上是一样的:

javascript
let p1 = new Promise((resolve, reject) => resolve()); 
let p2 = Promise.resolve();

这个解决的期约的值对应着传给 Promise.resolve()的第一个参数。使用这个静态方法,实际上可以把任何值都转换为一个期约:

javascript
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()可以说是一个幂等方法,如下所示:

javascript
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

这个静态方法可以包装任何非期约值,包括错误对象,并将其转换为解决的期约。因此,可能导致不符合预期的行为:

javascript
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 捕获,而只能通过拒绝处理程序捕获)。下面的两个期约实例实际上是一样的:

javascript
let p1 = new Promise((resolve, reject) => reject()); 
let p2 = Promise.reject();

这个拒绝的期约的理由就是传给Promise.rejcet()的第一个参数。这个参数也会传给后续的拒绝处理程序。

javascript
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()的幂等逻辑。如果给它传入一个期约对象,则这个期约会成为它返回的拒绝期约的理由:

javascript
setTimeout(console.log, 0, Promise.reject(Promise.resolve())); 
// Promise <rejected>: Promise <resolved>

######## 6. 同步/异步执行的二元性

Promise的设计会导致一种完全不同于JavaScript的计算模式。

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 块并不能捕获该错误。代码一旦开始以异步模式执行,则唯一与之交互的方式就是使用异步结构——更具体地说,就是期约的方法。

11.2.3 期约的实例方法

期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。

######## 1. 实现Thenable接口

在ECMAScript暴露的异步结构中,任何的对象都有一个then()方法。这个方法被认为实现了Thenable接口。

javascript
// 实现这一接口的最简单的类
class MyThenable { 
 	then() {} 
}

ECMAScript 的 Promise 类型实现了 Thenable 接口。这个简化的接口跟 TypeScript 或其他包中的接口或类型定义不同,它们都设定了 Thenable 接口更具体的形式。

######## 2. Promise.prototype.then()

Promise.prototype.then()是为期约实例添加处理程序的主要方法。这个 then()方法接收最多两个参数:onResolved 处理程序和 onRejected 处理程序。这两个参数都是可选的,如果提供的话,则会在期约分别进入“兑现”和“拒绝”状态时执行。

javascript
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。这样有助于避免在内存中创建多余的对象,对期待函数参数的类型系统也是一个交代。

######## 3. Promise.prototype.catch()

Promise.prototype.catch()方法用于给期约添加拒绝处理程序。这个方法只接受一个参数:onRejected 处理程序。事实上,这个方法就是一个语法糖,调用它就相当于调用 Promise.prototype. then(null, onRejected)。

javascript
let p = Promise.reject(); 
let onRejected = function(e) { 
 	setTimeout(console.log, 0, 'rejected'); 
}; 
// 这两种添加拒绝处理程序的方式是一样的:
p.then(null, onRejected); // rejected 
p.catch(onRejected); // rejected

######## 4. Promise.prototype.finally()

Promise.prototype.finally()方法用于给期约添加 onFinally 处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

######## 5. 非重入期约方法

当期约进入落定状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。这个特性由 JavaScript 运行时保证,被称为“非重入”(non-reentrancy)特性。下面的例子演示了这个特性:

javascript
// 创建解决的期约
let p = Promise.resolve(); 
// 添加解决处理程序
// 直觉上,这个处理程序会等期约一解决就执行
p.then(() => console.log('onResolved handler')); 
// 同步输出,证明 then()已经返回
console.log('then() returns'); 

// 实际的输出:
// then() returns 
// onResolved handler

在这个例子中,在一个解决期约上调用 then()会把 onResolved 处理程序推进消息队列。但这个处理程序在当前线程上的同步代码执行完成前不会执行。因此,跟在 then()后面的同步代码一定先于处理程序执行。

个人认为这就是异步的特点,先执行同步代码,再执行异步代码。

######## 6. 邻近处理程序的执行顺序

如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是 then()、catch()还是 finally()添加的处理程序都是如此。

javascript
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. 传递解决值和拒绝理由

到了落定状态后,期约会提供其解决值(如果兑现)或其拒绝理由(如果拒绝)给相关状态的处理程序。拿到返回值后,就可以进一步对这个值进行操作。

在执行函数中,解决的值和拒绝的理由是分别作为 resolve()和 reject()的第一个参数往后传的。然后,这些值又会传给它们各自的处理程序,作为 onResolved 或 onRejected 处理程序的唯一参数:

javascript
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()在被调用时就会接收解决值和拒绝理由。同样地,它们返回的期约也会像执行器一样把这些值传给 onResolved 或 onRejected 处理程序:

javascript
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()表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。在期约的执行函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由。因此以下这些期约都会以一个错误对象为由被拒绝:

javascript
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
11.2.4 期约连锁与期约合成

多个期约组合在一起可以构成强大的代码逻辑。这种组合可以通过两种方式实现:期约连锁与期约合成。前者就是一个期约接一个期约地拼接,后者则是将多个期约组合为一个期约。

######## 1. 期约连锁

把期约逐个地串联起来是一种非常有用的编程模式。之所以可以这样做,是因为每个期约实例的方法(then()、catch()和 finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。

javascript
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

可以让每一个执行器都返回一个期约实例来真正执行异步任务。这样就可以让每个后续期约都等待之前的期约,也就是串行化异步任务。

javascript
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 秒后)

因为 then()、catch()和 finally()都返回期约,也可以串联使用这些方法。

javascript
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. 期约图

因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。这样,每个期约都是图中的一个节点,而使用实例方法添加的处理程序则是有向顶点。因为图中的每个节点都会等待前一个节点落定,所以图的方向就是期约的解决或拒绝顺序。

javascript
// 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

日志的输出语句是对二叉树的层序遍历。如前所述,期约的处理程序是按照它们添加的顺序执行的。由于期约的处理程序是先添加到消息队列,然后才逐个执行,因此构成了层序遍历。

树只是期约图的一种形式。考虑到根节点不一定唯一,且多个期约也可以组合成一个期约,所以有向非循环图是体现期约连锁可能性的最准确表达。

######## 3. Promise.all()和Promise.race()

Promise 类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()和 Promise.race()。而合成后期约的行为取决于内部期约的行为。

  • Promise.all()

​ Promise.all()静态方法创建的期约会在一组期约全部解决之后再解决。这个静态方法接收一个可迭代对象,返回一个新期约。合成的期约只会在每个包含的期约都解决之后才解决。如果至少有一个包含的期约待定,则合成的期约也会待定。如果有一个包含的期约拒绝,则合成的期约也会拒绝。如果所有期约都成功解决,则合成期约的解决值就是所有包含期约解决值的数组,按照迭代器顺序。如果有期约拒绝,则第一个拒绝的期约会将自己的理由作为合成期约的拒绝理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。合成的期约会静默处理所有包含期约的拒绝操作。

  • Promise.race()

​ Promise.race()静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约。Promise.race()不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,Promise.race()就会包装其解决值或拒绝理由并返回新期约。如果有一个期约拒绝,只要它是第一个落定的,就会成为拒绝合成期约的理由。之后再拒绝的期约不会影响最终期约的拒绝理由。不过,这并不影响所有包含期约正常的拒绝操作。与 Promise.all()类似,合成的期约会静默处理所有包含期约的拒绝操作。

######## 4. 串行期约合成

通过后续期约使用之前期约的返回值来串联期约,类似函数合成

javascript
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()来简写:

javascript
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

提炼一个通用公式,把任意多个函数作为处理程序合成一个连续传值的期约连锁。

javascript
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.2.5 期约拓展

ES6 期约实现是很可靠的,但它也有不足之处。比如,很多第三方期约库实现中具备而 ECMAScript规范却未涉及的两个特性:期约取消和进度追踪。

######## 1. 期约取消

ES6的期约只要期约的逻辑开始执行,就没有办法阻止它执行到完成。可以在现有实现基础上提供一种临时性的封装,来实现取消期约的功能。“取消令牌”生成的令牌实例提供了一个接口,利用这个接口可以取消期约;同时提供了一个期约的实例,来触发取消后的操作并求值取消状态。

javascript
// CancelToken 类的一个基本实例:
class CancelToken { 
     constructor(cancelFn) { 
         this.promise = new Promise((resolve, reject) => { 
         	cancelFn(resolve); 
         }); 
     } 
}

这个类包装了一个期约,把解决方法暴露给了 cancelFn 参数。这样,外部代码就可以向构造函数中传入一个函数,从而控制什么情况下可以取消期约。这里期约是令牌类的公共成员,因此可以给它添加处理程序以取消期约。

######## 2. 期约进度通知

ES6 不支持取消期约和进度通知,一个主要原因就是这样会导致期约连锁和期约合成过度复杂化。比如在一个期约连锁中,如果某个被其他期约依赖的期约被取消了或者发出了通知,那么接下来应该发生什么完全说不清楚。可以拓展Promise类或添加第三方库。

11.3 异步函数

异步函数,也称为“async/await”(语法关键字),是 ES6 期约模式在 ECMAScript 函数中的应用。async/await 是 ES8 规范新增的。这个特性从行为和语法上都增强了 JavaScript,让以同步方式写的代码能够异步执行。

11.3.1 异步函数

ES8 的 async/await 旨在解决利用异步结构组织代码的问题。为此,ECMAScript 对函数进行了扩展,为其增加了两个新关键字:async 和 await。

######## 1. async

async 关键字用于声明异步函数。这个关键字可以用在函数声明、函数表达式、箭头函数和方法上:

javascript
async function foo() {} 
let bar = async function() {}; 
let baz = async () => {}; 
class Qux { 
 	async qux() {} 
}

使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。而在参数或闭包方面,异步函数仍然具有普通 JavaScript 函数的正常行为。

异步函数如果使用 return 关键字返回了值(如果没有 return 则会返回 undefined),这个值会被 Promise.resolve()包装成一个期约对象。异步函数始终返回期约对象。在函数外部调用这个函数可以得到它返回的期约。

javascript
async function foo() { 
 	console.log(1); 
 	return 3;
    // 或可以直接返回一个解决程序对象
    // return Promise.resolve(3);
} 
// 给返回的期约添加一个解决处理程序
foo().then(console.log);
console.log(2); 
// 1 
// 2 
// 3

异步函数的返回值期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如果返回的是实现 thenable 接口的对象,则这个对象可以由提供给 then()的处理程序“解包”。如果不是,则返回值就被当作已经解决的期约。与在期约处理程序中一样,在异步函数中抛出错误(throw)会返回拒绝的期约,不过,拒绝期约的错误(Promise.reject())不会被异步函数捕获。

######## 2. await

因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。使用 await关键字可以暂停异步函数代码的执行,等待期约解决。

await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。这个行为与生成器函数中的 yield 关键字是一样的。await 关键字同样是尝试“解包”对象的值,然后将这个值传给表达式,再异步恢复异步函数的执行。

await 关键字期待(但实际上并不要求)一个实现 thenable 接口的对象,但常规的值也可以。如果是实现 thenable 接口的对象,则这个对象可以由 await 来“解包”。如果不是,则这个值就被当作已经解决的期约。

######## 3. await的限制

await 关键字必须在异步函数中使用,不能在顶级上下文如<script>标签或模块中使用。不过,定义并立即调用异步函数是没问题的。

11.3.2 停止和恢复执行

async/await 中真正起作用的是 await。async只是一个标识符用来标识异步函数。JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了,JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数的执行。因此,即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值。

javascript
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()返回。

11.3.3 异步函数策略

######## 1. 实现sleep()

javascript
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. 利用平行执行

######## 3. 串行执行期约

######## 4. 栈追踪与内存管理

11.4 小结

随着 ES6 新增了期约和 ES8 新增了异步函数,ECMAScript 的异步编程特性有了长足的进步。通过期约和 async/await,不仅可以实现之前难以实现或不可能实现的任务,而且也能写出更清晰、简洁,并且容易理解、调试的代码。

期约的主要功能是为异步代码提供了清晰的抽象。可以用期约表示异步执行的代码块,也可以用期约表示异步计算的值。在需要串行异步代码时,期约的价值最为突出。作为可塑性极强的一种结构,期约可以被序列化、连锁使用、复合、扩展和重组。

异步函数是将期约应用于 JavaScript 函数的结果。异步函数可以暂停执行,而不阻塞主线程。无论是编写基于期约的代码,还是组织串行或平行执行的异步代码,使用异步函数都非常得心应手。异步函数可以说是现代 JavaScript 工具箱中最重要的工具之一。

12. BOM

BOM是使用JavaScript开发Web应用项目的核心,提供了与网页无关的浏览器功能对象。不同的浏览器有不同的BOM操作方法。在HTML5规范中有一部分涵盖了BOM的主要内容,因为W3C希望将JavaScript在浏览器中最基本的部分标准化。

12.1 window对象

BOM的核心是window对象,表示浏览器的实例。window对象在浏览器中有两重身份,一个ECMAScript中的Global对象,另一个就是浏览器窗口的JavaScript接口。这就意味着网页中定义的所有对象,变量和函数都以window作为其Global对象,都可以访问其上定义的parseInt()等全局方法。

12.1.1 Global作用域

因为window对象被复用为ECMAScript中的Global对象,所以通过var声明的所有全局变量和函数都会成为window对象的属性和方法。

12.1.2 窗口关系

top 对象始终指向最上层(最外层)窗口,即浏览器窗口本身。而 parent 对象则始终指向当前窗口的父窗口。如果当前窗口是最上层窗口,则 parent 等于 top(都等于 window)。最上层的 window如果不是通过 window.open()打开的,那么其 name 属性就不会包含值。

还有一个 self 对象,它是终极 window 属性,始终会指向 window。实际上,self 和 window 就是同一个对象。之所以还要暴露 self,就是为了和 top、parent 保持一致。

这些属性都是 window 对象的属性,因此访问 window.parent、window.top 和 window.self都可以。这意味着可以把访问多个窗口的 window 对象串联起来,比如 window.parent.parent。

12.1.3 窗口位置与像素比

window 对象的位置可以通过不同的属性和方法来确定。现代浏览器提供了 screenLeft 和screenTop 属性,用于表示窗口相对于屏幕左侧和顶部的位置 ,返回值的单位是 CSS 像素。

可以使用 moveTo()和 moveBy()方法移动窗口。这两个方法都接收两个参数,其中 moveTo()接收要移动到的新位置的绝对坐标 xy;而 moveBy()则接收相对当前位置在两个方向上移动的像素数。

######## 像素比

CSS 像素是 Web 开发中使用的统一像素单位。这个单位的背后其实是一个角度:0.0213°。如果屏幕距离人眼是一臂长,则以这个角度计算的 CSS 像素大小约为 1/96 英寸。这样定义像素大小是为了在不同设备上统一标准。比如,低分辨率平板设备上 12 像素(CSS 像素)的文字应该与高清 4K 屏幕下12 像素(CSS 像素)的文字具有相同大小。这就带来了一个问题,不同像素密度的屏幕下就会有不同的缩放系数,以便把物理像素(屏幕实际的分辨率)转换为 CSS 像素(浏览器报告的虚拟分辨率)。

举个例子,手机屏幕的物理分辨率可能是 1920×1080,但因为其像素可能非常小,所以浏览器就需要将其分辨率降为较低的逻辑分辨率,比如 640×320。这个物理像素与 CSS 像素之间的转换比率由window.devicePixelRatio 属性提供。对于分辨率从 1920×1080 转换为 640×320 的设备,window. devicePixelRatio 的值就是 3。这样一来,12 像素(CSS 像素)的文字实际上就会用 36 像素的物理像素来显示。

window.devicePixelRatio 实际上与每英寸像素数(DPI,dots per inch)是对应的。DPI 表示单位像素密度,而 window.devicePixelRatio 表示物理像素与逻辑像素之间的缩放系数。

12.1.4 窗口大小

在不同浏览器中确定浏览器窗口大小没有想象中那么容易。所有现代浏览器都支持 4 个属性:innerWidth、innerHeight、outerWidth 和 outerHeight。outerWidth 和 outerHeight 返回浏览器窗口自身的大小(不管是在最外层 window 上使用,还是在窗格<frame>中使用)。innerWidth和 innerHeight 返回浏览器窗口中页面视口的大小(不包含浏览器边框和工具栏)。

document.documentElement.clientWidth 和 document.documentElement.clientHeight返回页面视口的宽度和高度。

12.1.5 视口位置

浏览器窗口尺寸通常无法满足完整显示整个页面,为此用户可以通过滚动在有限的视口中查看文档。度量文档相对于视口滚动距离的属性有两对,返回相等的值:window.pageXoffset/window. scrollX 和 window.pageYoffset/window.scrollY。

可以使用 scroll()、scrollTo()和 scrollBy()方法滚动页面。这 3 个方法都接收表示相对视口距离的 xy 坐标,这两个参数在前两个方法中表示要滚动到的坐标,在最后一个方法中表示滚动的距离。

这几个方法也都接收一个 ScrollToOptions 字典,除了提供偏移值,还可以通过 behavior 属性告诉浏览器是否平滑滚动。

12.1.6 导航与打开新窗口

window.open()方法可以用于导航到指定 URL,也可以用于打开新浏览器窗口。这个方法接收 4个参数:要加载的 URL、目标窗口、特性字符串和表示新窗口在浏览器历史记录中是否替代当前加载页面的布尔值。通常,调用这个方法时只传前 3 个参数,最后一个参数只有在不打开新窗口时才会使用。如果 window.open()的第二个参数是一个已经存在的窗口或窗格(frame)的名字,则会在对应的窗口或窗格中打开 URL。第二个参数也可以是一个特殊的窗口名,比如_self、_parent、_top 或_blank。

######## 1. 弹出窗口

如果 window.open()的第二个参数不是已有窗口,则会打开一个新窗口或标签页。第三个参数,即特性字符串,用于指定新窗口的配置。如果没有传第三个参数,则新窗口(或标签页)会带有所有默认的浏览器特性(工具栏、地址栏、状态栏等都是默认配置)。如果打开的不是新窗口,则忽略第三个参数。

######## 2. 安全限制

用来限制广告。不同的浏览器有不同的针对窗口的安全限制。

######## 3. 弹窗屏蔽程序

所有现代浏览器都内置了屏蔽弹窗的程序,因此大多数意料之外的弹窗都会被屏蔽。在浏览器屏蔽弹窗时,可能会发生一些事。如果浏览器内置的弹窗屏蔽程序阻止了弹窗,那么 window.open()很可能会返回 null。此时,只要检查这个方法的返回值就可以知道弹窗是否被屏蔽了。

12.1.7 定时器

JavaScript 在浏览器中是单线程执行的,但允许使用定时器指定在某个时间之后或每隔一段时间就执行相应的代码。setTimeout()用于指定在一定时间后执行某些代码,而 setInterval()用于指定每隔一段时间执行某些代码。

调用 setTimeout()时,会返回一个表示该超时排期的数值 ID。这个超时 ID 是被排期执行代码的唯一标识符,可用于取消该任务。要取消等待中的排期任务,可以调用 clearTimeout()方法并传入超时 ID。只要是在指定时间到达之前调用 clearTimeout(),就可以取消超时任务。在任务执行后再调用clearTimeout()没有效果。

setInterval()方法也会返回一个循环定时 ID,可以用于在未来某个时间点上取消循环定时。要取消循环定时,可以调用 clearInterval()并传入定时 ID。

所有超时执行的代码(函数)都会在全局作用域中的一个匿名函数中运行,因此函数中的 this 值在非严格模式下始终指向 window,而在严格模式下是 undefined。如果给 setTimeout()/setInterval()提供了一个箭头函数,那么 this 会保留为定义它时所在的词汇作用域。

12.1.8 系统对话框

使用 alert()、confirm()和 prompt()方法,可以让浏览器调用系统对话框向用户显示消息。这些对话框与浏览器中显示的网页无关,而且也不包含 HTML。它们的外观由操作系统或者浏览器决定,无法使用 CSS 设置。此外,这些对话框都是同步的模态对话框,即在它们显示的时候,代码会停止执行,在它们消失以后,代码才会恢复执行。

除此之外,JavaScript还可以显示另外两种对话框:find()和print()。这两种对话框是异步显示的,即控制权会立即返回给脚本。用户在浏览器菜单上选择“查找”(find)和“打印”(print)时显示的就是这两种对话框。这两个方法不会返回任何有关用户在对话框中执行了什么操作的信息,因此很难加以利用。此外,因为这两种对话框是异步的,所以浏览器的对话框计数器不会涉及它们,而且用户选择禁用对话框对它们也没有影响。

12.2 Location对象

location 对象提供了当前窗口中加载文档的信息,以及通常的导航功能。它既是 window 的属性,也是 document 的属性。window.location 和 document.location 指向同一个对象。location 对象不仅保存着当前加载文档的信息,也保存着把 URL 解析为离散片段后能够通过属性访问的信息。

12.2.1 查询字符串

URLSearchParams 提供了一组标准 API 方法,通过它们可以检查和修改查询字符串。给URLSearchParams 构造函数传入一个查询字符串,就可以创建一个实例。这个实例上暴露了 get()、set()和 delete()等方法,可以对查询字符串执行相应操作。

javascript
let qs = "?q=javascript&num=10"; 
let searchParams = new URLSearchParams(qs); 
alert(searchParams.toString()); // " q=javascript&num=10" 
searchParams.has("num"); // true 
searchParams.get("num"); // 10 
searchParams.set("page", "3"); 
alert(searchParams.toString()); // " q=javascript&num=10&page=3" 
searchParams.delete("q"); 
alert(searchParams.toString()); // " num=10&page=3"
12.2.2 操作地址

可以通过修改 location 对象修改浏览器的地址。

javascript
location.assign("http://www.wrox.com");
window.location = "http://www.wrox.com"; 
location.href = "http://www.wrox.com";

修改 location 对象的属性也会修改当前加载的页面。其中,hash、search、hostname、pathname和 port 属性被设置为新值之后都会修改当前 URL。除了 hash 之外,只要修改 location 的一个属性,就会导致页面重新加载新 URL。

12.3 navigator对象

navigator 对象的属性通常用于确定浏览器的类型。与其他 BOM 对象一样,每个浏览器都支持自己的属性。

12.3.1 检测插件

可以通过navigator对象中的plugins数组属性来确定浏览器是否安装了某个插件,该数组中的每一项包含如下属性:

  • name:插件名称。
  • description:插件介绍。
  • filename:插件的文件名。
  • length:由当前插件处理的 MIME 类型数量。
12.3.2 处理注册程序

现代浏览器支持 navigator 上的(在 HTML5 中定义的)registerProtocolHandler()方法。这个方法可以把一个网站注册为处理某种特定类型信息应用程序。随着在线 RSS 阅读器和电子邮件客户端的流行,可以借助这个方法将 Web 应用程序注册为像桌面软件一样的默认应用程序。

要使用 registerProtocolHandler()方法,必须传入 3 个参数:要处理的协议(如"mailto"或"ftp")、处理该协议的 URL,以及应用名称。比如,要把一个 Web 应用程序注册为默认邮件客户端,可以这样做:

javascript
navigator.registerProtocolHandler("mailto", "http://www.somemailclient.com?cmd=%s", "Some Mail Client");

这个例子为"mailto"协议注册了一个处理程序,这样邮件地址就可以通过指定的 Web 应用程序打开。注意,第二个参数是负责处理请求的 URL,%s 表示原始的请求。

12.4 screen对象

screen 对象保存的纯粹是客户端能力信息,也就是浏览器窗口外面的客户端显示器的信息,比如像素宽度和像素高度。每个浏览器都会在 screen 对象上暴露不同的属性。

12.5 history对象

history 对象表示当前窗口首次使用以来用户的导航历史记录。因为 history 是 window 的属性,所以每个 window 都有自己的 history 对象。出于安全考虑,这个对象不会暴露用户访问过的 URL,但可以通过它在不知道实际 URL 的情况下前进和后退。

12.5.1 导航

go()方法可以在用户历史记录中沿任何方向导航,可以前进也可以后退。这个方法只接收一个参数,这个参数可以是一个整数,表示前进或后退多少步。负值表示在历史记录中后退(类似点击浏览器的“后退”按钮),而正值表示在历史记录中前进(类似点击浏览器的“前进”按钮)。

go()有两个简写方法:back()和 forward()。这两个方法模拟了浏览器的后退按钮和前进按钮。

12.5.2 历史状态管理

12.6 小结

浏览器对象模型(BOM,Browser Object Model)是以 window 对象为基础的,这个对象代表了浏览器窗口和页面可见的区域。window 对象也被复用为 ECMAScript 的 Global 对象,因此所有全局变量和函数都是它的属性,而且所有原生类型的构造函数和普通函数也都从一开始就存在于这个对象之上。

  • 要引用其他 window 对象,可以使用几个不同的窗口指针。
  • 通过 location 对象可以以编程方式操纵浏览器的导航系统。通过设置这个对象上的属性,可以改变浏览器 URL 中的某一部分或全部。
  • 使用 replace()方法可以替换浏览器历史记录中当前显示的页面,并导航到新 URL。
  • navigator 对象提供关于浏览器的信息。提供的信息类型取决于浏览器,不过有些属性如userAgent 是所有浏览器都支持的。

BOM 中的另外两个对象也提供了一些功能。screen 对象中保存着客户端显示器的信息。这些信息通常用于评估浏览网站的设备信息。history 对象提供了操纵浏览器历史记录的能力,开发者可以确定历史记录中包含多少个条目,并以编程方式实现在历史记录中导航,而且也可以修改历史记录。

13. 客户端检测

13.1 能力检测

能力检测(又称特性检测)即在 JavaScript 运行时中使用一套简单的检测逻辑,测试浏览器是否支持某种特性。这种方式不要求事先知道特定浏览器的信息,只需检测自己关心的能力是否存在即可。能力检测的基本模式如下:

javascript
if (object.propertyInQuestion) { 
 	// 使用 object.propertyInQuestion 
}
13.1.1 安全能力检测

在除IE浏览器中,尽量使用typeof操作符来进行能力检测。

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

使用能力检测而非用户代理检测的优点在于,伪造用户代理字符串很简单,而伪造能够欺骗能力检测的浏览器特性却很难。

######## 1. 检测特性

可以按照能力将浏览器归类。如果应用程序需要使用特定的浏览器能力,最好集中检测所有能力,而不是等到用的时候再重复检测。

javascript
let hasNSPlugins = !!(navigator.plugins && navigator.plugins.length); 
// 检测浏览器是否具有 DOM Level 1 能力
let hasDOM1 = !!(document.getElementById && document.createElement && document.getElementsByTagName);

######## 2. 检测浏览器

可以根据对浏览器特性的检测并与已知特性对比,确认用户使用的是什么浏览器。这样可以获得比用户代码嗅探(稍后讨论)更准确的结果。但未来的浏览器版本可能不适用于这套方案。

######## 3. 能力检测的局限

通过检测一种或一组能力,并不总能确定使用的是哪种浏览器。能力检测最适合用于决定下一步该怎么做,而不一定能够作为辨识浏览器的标志。

13.2 用户代理检测

用户代理检测通过浏览器的用户代理字符串确定使用的是什么浏览器。用户代理字符串包含在每个HTTP 请求的头部,在 JavaScript 中可以通过 navigator.userAgent 访问。在服务器端,常见的做法是根据接收到的用户代理字符串确定浏览器并执行相应操作。而在客户端,用户代理检测被认为是不可靠的,只应该在没有其他选项时再考虑。

13.2.1 用户代理的历史

HTTP 规范(1.0 和 1.1)要求浏览器应该向服务器发送包含浏览器名称和版本信息的简短字符串。这个规范进一步要求用户代理字符串应该是“标记/版本”形式的产品列表。

######## 1. 早期浏览器

######## 2. Netscape Navigator 3 和 IE3

######## 3. Netscape Communicator 4 和 IE4~8

######## 4. Gecko

######## 5. WebKit

######## 6. Konqueror

######## 7. Chrome

######## 8. Opera

######## 9. iOS 与 Android

13.2.2 浏览器分析

通过window.navigator.userAgent返回的字符串值,可以让开发者知道代码运行在什么浏览器上。相比于能力检测,用户代理检测具有一定优势,。能力检测可以保证脚本不必理会浏览器而正常执行。现代浏览器用户代理字符串的过去、现在和未来格式都是有章可循的,我们能够利用它们准确识别浏览器。

######## 1. 伪造用户代理

通过检测用户代理来识别浏览器并不是完美的方式,毕竟这个字符串是可以造假的。只不过实现window.navigator 对象的浏览器(即所有现代浏览器)都会提供 userAgent 这个只读属性。因此,简单地给这个属性设置其他值不会有效。有些浏览器会提供伪私有的__defineGetter__方法,可以利用它来改变用户代理字符串。

######## 2. 分析浏览器

通过解析浏览器返回的用户代理字符串,可以极其准确地推断出下列相关的环境信息:

  • 浏览器
  • 浏览器版本
  • 浏览器渲染引擎
  • 设备类型(桌面/移动)
  • 设备生产商
  • 设备型号
  • 操作系统
  • 操作系统版本

13.3 软件与硬件检测

现代浏览器提供了一组与页面执行环境相关的信息,包括浏览器、操作系统、硬件和周边设备信息。这些属性可以通过暴露在 window.navigator 上的一组 API 获得。不过,这些 API 的跨浏览器支持还不够好,远未达到标准化的程度。

13.3.1 识别浏览器与操作系统

特性检测和用户代理字符串解析是当前常用的两种识别浏览器的方式。而 navigator 和 screen对象也提供了关于页面所在软件环境的信息。

######## 1. navigator.oscpu

navigator.oscpu属性是一个字符串,通常对应用户代理字符串中操作系统/系统架构相关信息。

######## 2. navigator.vendor

navigator.vendor 属性是一个字符串,通常包含浏览器开发商信息。返回这个字符串是浏览器navigator 兼容模式的一个功能。

######## 3. navigator.platform

navigator.platform 属性是一个字符串,通常表示浏览器所在的操作系统。

######## 4. screen.colorDepth 和 screen.pixelDepth

screen.colorDepth 和 screen.pixelDepth 返回一样的值,即显示器每像素颜色的位深。

######## 5. screen.orientation

screen.orientation 属性返回一个 ScreenOrientation 对象,其中包含 Screen Orientation API定义的屏幕信息。

13.3.2 浏览器元数据

navigator 对象暴露出一些 API,可以提供浏览器和操作系统的状态信息。

######## 1. Geolocation API

navigator.geolocation 属性暴露了 Geolocation API,可以让浏览器脚本感知当前设备的地理位置。这个 API 只在安全执行环境(通过 HTTPS 获取的脚本)中可用。这个 API 可以查询宿主系统并尽可能精确地返回设备的位置信息。根据宿主系统的硬件和配置,返回结果的精度可能不一样。手机 GPS 的坐标系统可能具有极高的精度,而 IP 地址的精度就要差很多。

######## 2. Connection State 和 NetworkInformation API

浏览器会跟踪网络连接状态并以两种方式暴露这些信息:连接事件和 navigator.onLine 属性。在设备连接到网络时,浏览器会记录这个事实并在 window 对象上触发 online 事件。相应地,当设备断开网络连接后,浏览器会在 window 对象上触发 offline 事件。任何时候,都可以通过 navigator. onLine 属性来确定浏览器的联网状态。这个属性返回一个布尔值,表示浏览器是否联网。不同的浏览器会有不同的实现。

13.3.3 硬件

浏览器检测硬件的能力相当有限。不过,navigator 对象还是通过一些属性提供了基本信息。

######## 1. 处理器核心数

navigator.hardwareConcurrency 属性返回浏览器支持的逻辑处理器核心数量,包含表示核心数的一个整数值(如果核心数无法确定,这个值就是 1)。关键在于,这个值表示浏览器可以并行执行的最大工作线程数量,不一定是实际的 CPU 核心数。

######## 2. 设备内存大小

navigator.deviceMemory 属性返回设备大致的系统内存大小,包含单位为 GB 的浮点数(舍入为最接近的 2 的幂:512MB 返回 0.5,4GB 返回 4)。

######## 3. 最大触点数

navigator.maxTouchPoints 属性返回触摸屏支持的最大关联触点数量,包含一个整数值。

######## 13.4 小结

客户端检测是 JavaScript 中争议最多的话题之一。因为不同浏览器之间存在差异,所以经常需要根据浏览器的能力来编写不同的代码。客户端检测有不少方式,但下面两种用得最多。

  • 能力检测,在使用之前先测试浏览器的特定能力。例如,脚本可以在调用某个函数之前先检查它是否存在。这种客户端检测方式可以让开发者不必考虑特定的浏览器或版本,而只需关注某些能力是否存在。能力检测不能精确地反映特定的浏览器或版本。
  • 用户代理检测,通过用户代理字符串确定浏览器。用户代理字符串包含关于浏览器的很多信息,通常包括浏览器、平台、操作系统和浏览器版本。用户代理字符串有一个相当长的发展史,很多浏览器都试图欺骗网站相信自己是别的浏览器。用户代理检测也比较麻烦,特别是涉及 Opera会在代理字符串中隐藏自己信息的时候。即使如此,用户代理字符串也可以用来确定浏览器使用的渲染引擎以及平台,包括移动设备和游戏机。

在选择客户端检测方法时,首选是使用能力检测。特殊能力检测要放在次要位置,作为决定代码逻辑的参考。用户代理检测是最后一个选择,因为它过于依赖用户代理字符串。

浏览器也提供了一些软件和硬件相关的信息。这些信息通过 screen 和 navigator 对象暴露出来。利用这些 API,可以获取关于操作系统、浏览器、硬件、设备位置、电池状态等方面的准确信息。

14. DOM

文档对象模型(DOM,Document Object Model)是 HTML 和 XML 文档的编程接口。DOM 表示由多层节点构成的文档,通过它开发者可以添加、删除和修改页面的各个部分。脱胎于网景和微软早期的动态 HTML(DHTML,Dynamic HTML),DOM 现在是真正跨平台、语言无关的表示和操作网页的方式。DOM Level 1 在 1998 年成为 W3C 推荐标准,提供了基本文档结构和查询的接口。

14.1 节点层级