概述

  1. 介绍JavaScript的起源:从哪里来,如何发展,以及现今的状况。这一章会谈到JavaScript与ECMAScript的关系,DOM,BOM,以及ECMA和W3C相关的标准。
  2. 介绍JavaScript如何与HTML结合起来创建动态网页,主要介绍在网页中嵌入JavaScript的不同方式,还有JavaScript的内容类型及其与<script>元素的关系。
  3. 介绍语言的基本概念,包括语法和流控制语句;解释JavaScript与其他类C语言在语法上的异同点。在讨论内置操作符时也会谈到强制类型转换。此外还将介绍所有的原始类型,包括Symbol。
  4. 探索JavaScript松散类型下的变量处理。这一章将涉及原始类型和引用类型的不同,以及与变量有关的执行上下文。此外,这一章也会讨论JavaScript中的垃圾回收,涉及在变量超出作用域时如何回收内存。
  5. 讨论JavaScript所有内置的引用类型。如Date, Regexp,原始类型及其包装类型。每种引用类型既有理论上的讲解,也有相关浏览器实现的剖析。
  6. 继续讨论内置引用类型,包括Object,Array,Map,WeakMap,Set和WeakSet等。
  7. 介绍ECMAScript新版中引入的两个基本概念:迭代器和生成器,并分别讨论他们最基本的行为和在当前语言环境下的应用。
  8. 解释如何在JavaScript中使用类和面向对象编程。首先会深入讨论JavaScript的Object类型,进而探讨原型式继承,接下来全面介绍ES6类及其与原型式继承的紧密关系。
  9. 介绍两个紧密相关的概念:Proxy(代理)和反射(Reflect)API。代理和反射用于拦截和修改这门语言的基本操作。
  10. 探索JavaScript最强大的一个特性:函数表达式,主要涉及闭包,this对象,模块模式,创建私有对象成员,箭头函数,默认参数和扩展操作符。
  11. 介绍两个紧密相关的异步编程构造:Promise类型和async/await。这一章讨论JavaScript的异步编程范式,进而介绍promise与异步函数的关系。
  12. 介绍BOM,即浏览器对象模型,跟与浏览器本身交互的API相关。所有BOM对象都会涉及,包括window,document,location,navigator和screen等。
  13. 解释检测客户端机器及其能力的不同手段,包括能力检测和用户代理字符串检测。这一章讨论每种手段的优缺点,以及适用场景。
  14. 介绍DOM,即文档对象模型,主要是DOM Level 1定义的API。这一章将简单讨论XML及其与DOM的关系,进而全面探索DOM以及如何利用它操作网页。
  15. 解释其他DOM API,包括浏览器本身对DOM的扩展,主要涉及Selectors API和Element Traversal API 和HTML5扩展。
  16. 在之前两章的基础上,解释DOM Level 2和Level 3对DOM的扩展,包括新增的属性,方法和对象。这一章还会介绍DOM4的相关内容,比如Mutaion Observer。
  17. 解释事件在JavaScript中的本质,以及事件的起源及其在DOM中的运行方式。
  18. 围绕<canvas>标签讨论如何创建动态图形,包括2D和3D上下文(WebGL)等动画和游戏开发所需的基础。这一章还会讨论WebGL1和WebGL2.
  19. 探索使用JavaScript增强表单交互及突破浏览器限制,主要讨论文本框,选择框等表单元素及数据验证和操作。
  20. 介绍各种JavaScript API,包括Atomics,Encoding,File,Blob,Notifications,Streams,Timing,Web Components和Web Cryptography。
  21. 讨论浏览器如何处理JavaScript代码中的错误及集中错误处理方式。这一章同时介绍了每种浏览器的调试工具和技术,包括简化调试过程的建议。
  22. 介绍通过JavaScript读取和操作XML数据的特性,解释了不同浏览器支持特性和对象的差异,提供了简化跨浏览器编码的建议。这一章也讨论了使用XSLT在客户端转换XML数据。
  23. 介绍作为XML替代的JSON数据格式,还讨论了浏览器原生解析和序列化JSON,以及适用JSON时要注意的安全问题。
  24. 讨论浏览器请求数据和资源的常用方式,包括早期的XMLHttpRequest对象,以及现代的Fetch API。
  25. 讨论应用程序离线时在客户端机器上存储数据的各种技术。先从cookie谈起,然后讨论Web Storage 和 IndexedDB。
  26. 介绍模块模式在编码中的应用,进而讨论ES6模块之前的模块加载方式,包括CommonJS,AMD和UMD。最后介绍新的ES6模块及其正确用法。
  27. 深入介绍专用工作者线程,共享工作者线程和服务工作者线程。其中包括工作者线程在操作系统和浏览器层面的实现,以及使用各种工作者线程的最佳策略。
  28. 讨论在企业级开发中进行JavaScript编码的最佳实践。其中提到了提升代码可维护性的编码惯例,包括编码技巧,格式化及通用编码建议。深入讨论应用性能和提升速度的技术。最后介绍上线与部署相关的话题,包括项目构建流程。

第1章 什么是JavaScript

本章内容

  • JavaScript历史回顾
  • JavaScript是什么
  • JavaScript与ECMAScript的关系
  • JavaScript的不同版本

20250616215345

这里这张图个人认为有点问题,DOM应该属于BOM,DOM是BOM的一部分,不应该和BOM并列。

ECMA-262到底定义了什么?

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

DOM

20250616220143

20250616220153

DOM级别

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

注意DOM并非只能通过JavaScript操作,也可以通过其他语言操作。不过对于浏览器来说,DOM就是使用ECMAScript实现的,如今已经成为JavaScript语言的一大组成部分。

DOM Level 1的目标是映射文档结构,而DOM Level2的目标则宽泛的多。增加了对鼠标和用户界面事件,范围,遍历(迭代DOM节点的方法)的支持,而且通过对戏那个接口支持了CSS。另外,DOM Level1 中的DOM Core也被扩展以包含对XML命名空间的支持。

DOM Level 2新增了以下模块:

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

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

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

其他DOM

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

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

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

BOM

HTML5涵盖了尽可能多的BOM特性。

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

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

小结

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

  • ECMAScript:由ECMA-262定义并提供的核心功能
  • DOM:提供与网页内容交互的方法和接口
  • BOM:提供与浏览器交互的方法和接口

第2章 HTML中的JavaScript

本章内容

  • 使用<script>元素
  • 行内脚本与外部脚本比较
  • 文档模式对JavaScript有什么影响
  • 确保JavaScript不可用时的用户体验

将JavaScript插入HTML的主要方法是使用<script>元素,这个元素已经被正式加入HTML规范。

<script>元素有下列8个属性:

  • src:可选。表示包含要执行的代码的外部文件。
  • async:可选。表示应该立即开始下载脚本,但是不能阻止页面其他动作,比如下载资源或等待其他脚本加载。只对外部脚本有效。
  • charset:可选。使用src属性指定的代码字符集。这个属性很少使用,因为大多数浏览器不在乎它的值。
  • crossorigin:可选。配置相关请求的CORS(跨域资源共享)设置。默认不使用CORS。crossorigin="anonymous"配置文件请求不必设置凭据标志。crossorigin="use-credentials"设置凭据标志,意味着出站请求会包含凭据。
  • defer:可选。表示脚本可以延迟到文档完全被解析和显示之后再执行。只对外部脚本有效。
  • integrity:可选。允许比对接收到的资源和指定的加密签名以验证子资源完整性(SRI, Subresource Integrity)。如果接收到的资源的签名与这个属性指定的签名不匹配,则页面会报错,脚本不会执行。这个属性可用于确保CDN不会提供恶意内容
  • language: 废弃。最初用于表示代码块中的脚本语言(如”JavaScript”、”JavaScript 1.2”
    或”VBScript”)。大多数浏览器都会忽略这个属性,不应该再使用它。
  • 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>的方式有两种:

  • 行内脚本:将脚本放在<script>元素的内容中。
  • 外部脚本:将脚本放在外部文件中,并使用src属性将外部脚本引入HTML文档中。

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

1
2
3
<script>
console.log("<\/script>")
</script>

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

<script>标签的src属性可以引入外部域的脚本文件,不受浏览器同源策略的限制。
但是这个可能会导致安全问题,使用<script>标签的integrity属性是防范这种问题的一个武器,但这个属性也不是所有浏览器都支持。

在没有使用asyncdefer的情况下,浏览器会按照<script>标签在页面中出现的顺序依次解释他们。

使用了defer的脚本会在浏览器解析到结束的</html>标签后才会执行。HTML5规范要求脚本应该按照他们出现的顺序执行,因此第一个推迟的脚本会在第二个推迟的脚本之前执行,而且两者都在DOMContentLoaded事件之前执行。

使用了async的脚本会在页面的load事件之前执行,但可能会在DOMContentLoaded事件之前或之后执行,而且无法保证脚本的顺序。所以,在async脚本中,不要对DOM进行操作。

动态加载脚本

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

1
2
3
let script = document.createElement('script');
script.src = 'gibberish.js';
document.body.appendChild(script);

默认情况下,以这种方式创建的<script>元素是异步加载的,相当于添加了async属性。

以这种方式获取的资源对浏览器预加载器是不可见的。这会严重影响他们在资源获取队列中的优先级。这种方式可能会严重影响性能。要想让浏览器预加载器直到这些动态请求文件的存在,可以在文档头部显式声明他们:

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

文档模式

IE5.5发明了文档模式的概念,即可以使用DOCTYPE来切换文档模式。最初的文档模式有两种:

  • 怪异模式/混杂模式(quirks mode)
  • 标准模式(standards mode)

在HTML文件中第一行的代码,目的就是告诉浏览器使用标准模式解析文档,不要使用怪异模式。

1
<!DOCTYPE html>

<noscript>元素

针对早期浏览器不支持 JavaScript 的问题,需要一个页面优雅降级的处理方案。最终,<noscript>
元素出现,被用于给不支持 JavaScript 的浏览器提供替代内容。虽然如今的浏览器已经 100%支持
JavaScript,但对于禁用 JavaScript 的浏览器来说,这个元素仍然有它的用处。
<noscript>元素可以包含任何可以出现在<body>中的 HTML 元素,<script>除外。在下列两种
情况下,浏览器将显示包含在<noscript>中的内容:

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html>
<head>
<title>Example HTML Page</title>
<script defer="defer" src="example1.js"></script>
<script defer="defer" src="example2.js"></script>
</head>
<body>
<noscript>
<p>This page requires a JavaScript-enabled browser.</p>
</noscript>
</body>
</html>

第3章 语言基础

3.1 语法

  1. 变量名区分大小写
  2. 标识符:变量,函数,属性或函数参数的名称:
    • 第一个字符必须是字母,下划线,或美元符
    • 后续字符可以是字母,数字,下划线,或美元符
      标识符中的字母可以是扩展ASCII(Extended ASCII)中的字母,也可以是Unicode的字母字符。

3.2 严格模式

ECMAScript 5 引入了严格模式(strict mode)。严格模式是一种不同的JavaScript解析和执行模型,ECMAScript3的一些不规范写法在这种模式下会被处理,对于不安全的活动将抛出错误。

可以在整个文件或某个函数中开启严格模式,只需要加上"use strict";

1
"use strict"
1
2
3
4
function doSomething() {
"use strict"
// 函数体
}

3.3 分号插入

在JavaScript中,分号;是可选的,但是为了避免错误,还是建议加上分号。

3.4 关键字与保留字

20250625182346

20250625182415

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

3.5 变量

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

关键字 var let const
作用域 函数作用域 块级作用域 块级作用域
初始化 可以不初始化,值为undefined 可以不初始化,值为undefined 必须初始化
提升 提升到函数作用域顶部 TDZ暂时性死区 TDZ暂时性死区
重复声明 后者覆盖前者 ReferenceError ReferenceError
重复赋值 更新为新值 更新为新值 重新赋值会导致运行时错误,但是可以改变对象的属性值
全局声明 称为window对象的属性 不会挂载到全局对象 不会挂载到全局对象

for循环中的let声明

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

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

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

1
2
3
4
for (let i = 0; i < 5; i++) {
// 循环逻辑
}
console.log(i); // ReferenceError:i is not defined

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

1
2
3
4
5
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i)
}, 0)
}

你可能以为会输出0,1,2,3,4

实际上会输出5,5,5,5,5

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

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

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

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

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

1
for (const i = 0; i < 10; i++)	// TypeError: Assignment to constant variable

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let i = 0;
for (const j = 7; i < 5; i++) {
console.log(j);
}
// 7,7,7,7,7

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

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

3.6 声明风格最佳实践

  1. 不使用var
  2. const优先,let次之

3.7 数据类型

ECMAScript中有7中简单数据类型(也称为原始类型):

  • Undefined
  • Null
  • Boolean
  • Number
  • BigInt
  • String
  • Symbol

还有一种复杂数据类型叫Object.

Object是一种无序键值对的集合。

typeof 操作符

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

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

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

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

在这个例子中,我们把一个变量(message)和一个数值字面量传给了 typeof 操作符。注意,因为 typeof 是一个操作符而不是函数,所以不需要参数(但可以使用参数)。 注意typeof在某些情况下返回的结果可能会让人费解,但技术上讲还是正确的。比如,调用typeof null 返回的是”object”。这是因为特殊值 null 被认为是一个对空对象的引用

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

Undefined类型

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

1
2
let message;
console.log(message === undefied); // true

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

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

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

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

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

1
2
3
4
5
6
7
let message;	// 这个变量被声明了,只是值是undefined

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

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

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

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

1
2
3
4
5
6
7
let message;	// 这个变量被声明了,只是值是undefined

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
let message; // 这个变量被声明了,只是值为 undefined
// age 没有声明
if (message) {
// 这个块不会执行
}
if (!message) {
// 这个块会执行
}
if (age) {
// 这里会报错
}

Null类型

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

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

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let message = null;
let age;
if (message) {
// 这个块不会执行
}
if (!message) {
// 这个块会执行
}
if (age) {
// 这个块不会执行
}
if (!age) {
// 这个块会执行
}

Boolean类型

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

1
2
let found = true;
let lost = false;

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

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

1
2
3
let message = "Hello World";
let messageAsBoolean = Boolean(message);
console.log(messageAsBoolean); // true

在这个例子中,字符串message会被转换为布尔值并保存在变量messageAsBoolean中。

Boolean()转型函数可以在任意类型的数据上调用,而且始终返回一个布尔值。什么值能转换为 true 或 false 的规则取决于数据类型和实际的值。下表总结了不同类型与布尔值之间的转换规则。

数据类型 转换为true的值 转换为false的值
Boolean true false
String 非空字符串 “”(空字符串)
Number 非零数值(包括无穷值) 0, NaN
BigInt 非零的任意值 0
Object 任意对象 null
Undefined —- undefined

Number类型

Number类型使用IEEE754格式表示整数和浮点数。

最基本的是十进制,直接写出来即可。

1
let intNum = 55;

整数也可以使用8进制,或16进制。

  • 8进制:以0o开头,然后是相应的的八进制数字(0-7),如果字面量中包含的数字超出了应有的范围,就会忽略前缀的零,后面的数字序列会被当成十进制,如下所示:
1
2
3
let octalNum1 = 0o70;		// 八进制的56
let octalNum2 = 0o79; // 无效的八进制,当成十进制的79处理
let octalNum3 = 0o8; // 无效的八进制,当成十进制的8处理

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

  • 16进制:以0x(区分大小写)开头,然后是16进制的数字(0-9,A-F).16进制中数字中的字母大小写均可。下面是例子:
1
2
let hexNum = 0xA;		// 十六进制10
let hexNum = 0x1f; // 十六进制31

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

浮点值

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

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

因为存储浮点数使用的内存空间是存储整数值的两倍,所以ECMAScript总是想方设法把值转换为整数。

在小数点后面没有数字的情况下,数值就会变成整数。类似地,如果数值本身就是整数,只是小数点后面跟着0(如1.0),那它也会被转换为整数。

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

对于非常大或者非常小的数值,浮点值可以用科学计数法来表示。

1
let floatNum = 3.1235e7;		// 等于31250000

0.1 + 0.2 !== 0.3

永远不要测试某个浮点数的值!!!

值的范围

由于内存限制,ECMAScript并不支持表示这个世界上的所有数值。ECMAScript可以表示的最小数值保存在Number.MIN_VALUE中,这个值在大多数浏览器中是5e-324;可以表示的最大数值保存在Nubmer.MAX_VALUE中,这个值在多数浏览器中是1.7976931348623157e+308.

如果某个计算得到的结果超出了JavaScript可以表示的范围,那么这个数值会被自动转换为一个特殊的Infinity(无穷值).任何无法表示的负数以-Infinity来表示,任何无法表示的正数以Infinity来表示。

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

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

在计算非常大和非常小的数的时候,有必要检测一下是否超出范围。

NaN

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

1
2
3
4
5
6
7
8
9
10
11
console.log(0 / 0);
console.log(-0 / +0);
console.log(5 / 0);
console.log(5 / -0);
console.log(0 / 3);

// NaN
// NaN
// Infinity
// -Infinity
// 0

NaN有几个独特的属性:

  1. 任何涉及NaN的操作始终返回NaN(例如NaN/10).
  2. NaN不等于包括NaN在内的任何值。
1
console.log(NaN == NaN);	// false

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

1
2
3
4
5
6
7
console.log(isNaN(NaN));  // true
console.log(isNaN(10)); // false,转换为数值10
console.log(isNaN("10")); // false,转换为数值10
console.log(isNaN("10.33")); // false,转换为数值10.33
console.log(isNaN("blue")); // true,不能转换为数值
console.log(isNaN(true)); // false,转换为数值1
console.log(isNaN(false)); // false,转换为数值0

NaN也可以用来测试对象。对象会首先调用[Symbol.toPrimitive]()方法,如果未定义,则接着调用valueOf()方法,然后再确定返回的值能否转换为数值。如果不能,再调用toString()方法,并测试其返回值。

1
2
3
4
5
6
7
8
9
10
let obj = {
[Symbol.toPrimitive](hint) {
if (hint === "string") return "Hello";
if (hint === "number") return 100;
return "default";
}
};
console.log(String(obj)); // "Hello"(hint="string")
console.log(+obj); // 100(hint="number")
console.log(obj + ""); // "default"(hint="default")

数值转换

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

Number()函数基于如下规则进行转换:

  • 布尔值,true转换为1,false转换为0

  • 数值,直接返回

  • null,返回0

  • undefined,返回NaN

  • 字符串,应用如下规则:

    • 如果字符串包含数值字符,包括数值字符前面带加减号的情况,则转换为一个十进制数值。

      因此,Number(“1”)返回1,Number(“123”)返回123,Number(“011”)返回11(忽略前面的0)

    • 如果字符串包含有效的浮点值格式如“1.1”,则会转换为相应的浮点值(同样,忽略前面的零)

    • 如果字符串包含有效的十六进制格式如“0xf”,则会转换为与该十六进制对应的十进制整数值

    • 如果是空字符串(不包含字符)则返回0

    • 如果字符串包含上述情况之外的字符,则返回NaN

  • 对象,依次调用[Symbol.toPrimitive](),valueOf(),toString()方法,并按照上述规则转换返回的值。

一元加操作符与Number()函数遵循相同的转换规则

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

假设字符串中的第一个字符是数值字符,parseInt()函数也能识别不同的整数格式(十进制,十六进制).换句话说,如果字符串以”0x”开头,就会被解释为16进制整数。

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

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

1
2
3
let num = parseInt("0xAF", 16);	// 175
let num1 = parseInt("AF", 16); // 175
let num2 = parseInt("AF"); // NaN

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

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

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

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

String类型

String数据类型表示0或多个16位Unicode字符序列。字符串可以使用双引号,单引号或反引号标识。

1
2
3
let firstName = "John";
let lastName = "Jacob";
let lastName = `Jingleheimershcmidt`

引号的开头和结尾要匹配,使用同一种引号

1
let firstName = 'lksdjfkljds";	// 语法错误

字符串字面量

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

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

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

在这个例子中,即使包含 6 个字符长的转义序列,变量 text 仍然是 28 个字符长。因为转义序列表示一个字符,所以只算一个字符。字符串的长度可以通过其 length 属性获取: console.log(text.length); // 28 这个属性返回字符串中 16 位字符的个数。

返回值 属性/方法 适用场景
UTF-16 码元数量 string.length 基础字符串操作(需注意代理对)
实际字符数(码点数) [...str].length 精确字符统计(含辅助平面字符)
物理字节数(需指定编码) Buffer.byteLength() 网络传输、存储空间计算

字符串特点

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

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

这里,变量 lang 一开始包含字符串”Java”。紧接着,lang 被重新定义为包含”Java”和”Script” 的组合,也就是”JavaScript”。

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

转换为字符串

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

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

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

null和undefined没有toString()方法。

多数情况下,toString()方法不接收任何参数。不过,在数值调用这个方法时,可以接收一个底数参数,即以什么底数来输出数值的字符串表示。默认情况下,toString()返回数值的十进制字符串表示。可以通过传入参数修改。

1
2
3
4
5
6
let num = 10;
num.toString(); // "10"
num.toString(2); // "1010"
num.toString(8); // "12"
num.toString(10); // "10"
num.toString(1)

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

  • 如果值有toString()方法,则调用该方法(不传参数)并返回结果。
  • 如果值是null, 返回"null"
  • 如果值是undefined,返回"undefined"
1
2
3
4
5
6
7
8
let value1 = 10;
let value2 = true;
let value3 = null;
let value4;
console.log(String(value1)); // "10"
console.log(String(value2)); // "true"
console.log(String(value3)); // "null"
console.log(String(value4)); // "undefined"

模板字面量

模板字面量与单引号和双引号不同,模板字面量会保留换行字符,可以跨行定义字符串。

1
2
3
4
5
6
7
8
9
10
let myMultiLineString = 'first line\nsecond line';
let myMultiLineTemplateLiteral = `first line
second line`;
console.log(myMultiLineString);
// first line
// second line"
console.log(myMultiLineTemplateLiteral);
// first line
// second line"
console.log(myMultiLineString === myMultiLinetemplateLiteral); // true

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 这个模板字面量在换行符之后有 25个空格符
let myTemplateLiteral = `first line
second line`;
console.log(myTemplateLiteral.length); // 47
// 这个模板字面量以一个换行符开头
let secondTemplateLiteral = `
first line
second line`;
console.log(secondTemplateLiteral[0] === '\n'); // true
// 这个模板字面量没有意料之外的字符
let thirdTemplateLiteral = `first line
second line`;
console.log(thirdTemplateLiteral);
// first line
// second line

字符串插值

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

所有插入的值都会使用String()强制转型为字符串

模板字面量标签函数

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

let a = 6;
let b = 9;

function simpleTag(strings, ...values) {
console.log(strings);
console.log(values);
return "simpleTag";
}

const result = simpleTag`Hello ${a + b} world ${a * b}`;

// [ 'Hello ', ' world ', '' ]
// [ 15, 54 ]


console.log(result);
// simpleTag

如果想返回默认的字符串,可以这样做:

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

原始字符串

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Unicode 示例
// \u00A9 是版权符号
console.log(`\u00A9`); // ©
console.log(String.raw`\u00A9`); // \u00A9
// 换行符示例
console.log(`first line\nsecond line`);
// first line
// second line
console.log(String.raw`first line\nsecond line`); // "first line\nsecond line"
// 对实际的换行符来说是不行的
// 它们不会被转换成转义序列的形式
console.log(`first line
second line`);

// first line
// second line
console.log(String.raw`first line
second line`);
// first line
// second line

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

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

Symbol类型

符号是原始值,且符号实例是唯一,不可变的。符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险

符号就是用来创建唯一记号,进而用作非字符串形式的对象属性。

1. 符号的基本用法

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

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

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

1
2
3
4
5
6
7
8
let genericSymbol = Symbol();
let otherGenericSymbol = Symbol();

let fooSymbol = Symbol('foo');
let otherFooSymbol = Symbol('foo');

console.log(genericSymbol === otherGenericSymbol); // false
console.log(fooSymbol === otherFooSymbol); // false

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

1
2
3
4
let genericSymbol = Symbol();
console.log(genericSymbol); // Symbol()
let fooSymbol = Symbol('foo');
console.log(fooSymbol); // Symbol(foo);

更重要的是,Symbol()函数不能直接与new关键字一起作为构造函数使用。这样做是为了避免创建符号包装对象,戏那个使用Boolean, String, Number那样,他们都支持构造函数且可用于初始化包含原始值的包装对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let myBoolean = new Boolean();
console.log(myBoolean); // [Boolean: false]
console.log(typeof myBoolean); // "object"
console.log(myBoolean.toString()); // false

let myString = new String();
console.log(myString); // [String: '']
console.log(typeof myString); // "object"
console.log(myString.toString()); // ""

let myNumber = new Number();
console.log(myNumber); // [Number: 0]
console.log(typeof myNumber); // "object"
console.log(myNumber.toString()); // 0

let myArray = new Array();
console.log(myArray); // []
console.log(typeof myArray); // "object"
console.log(myArray.toString()); // ""

let mySymbol = new Symbol(); // TypeError: Symbol is not a constructor

如果你确实想使用包装对象,可以借用Object函数

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

2. 使用全局符号注册表

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

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

1
2
3
4
5
6
let fooGlobalSymbol = Symbol.for('foo');
console.log(typeof fooGlobalSymbol); // symbol


let otherFooGlobalSymbol = Symbol.for('foo');
console.log(fooGlobalSymbol === otherFooGlobalSymbol); // true

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

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

1
2
3
4
let localSymbol = Symbol('foo');
let globalSymbol = Symbol.for('foo');

console.log(localSymbol === globalSymbol); // false

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

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

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

1
2
3
4
5
6
7
8
// 创建全局符号

let s = Symbol.for('foo');
console.log(Symbol.keyFor(s)); // "foo"

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

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

1
2
// 传递参数非符号
Symbol.keyFor(123); // TypeError: 123 is not a symbol

3. 使用符号作为属性

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let s1 = Symbol('foo'),
s2 = Symbol('bar'),
s3 = Symbol('baz'),
s4 = Symbol('qux');
let o = {
[s1]: 'foo val'
};
// 这样也可以:o[s1] = 'foo val';
console.log(o);
// {Symbol(foo): foo val}
Object.defineProperty(o, s2, {value: 'bar val'});
console.log(o);
// {Symbol(foo): foo val, Symbol(bar): bar val}
Object.defineProperties(o, {
[s3]: {value: 'baz val'},
[s4]: {value: 'qux val'}
});
console.log(o);
// {Symbol(foo): foo val, Symbol(bar): bar val,
// Symbol(baz): baz val, Symbol(qux): qux val}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let s1 = Symbol('foo'),
s2 = Symbol('bar');
let o = {
[s1]: 'foo val',
[s2]: 'bar val',
baz: 'baz val',
qux: 'qux val'
};
console.log(Object.getOwnPropertySymbols(o));
// [Symbol(foo), Symbol(bar)]
console.log(Object.getOwnPropertyNames(o));
// ["baz", "qux"]
console.log(Object.getOwnPropertyDescriptors(o));
// {baz: {...}, qux: {...}, Symbol(foo): {...}, Symbol(bar): {...}}
console.log(Reflect.ownKeys(o));
// ["baz", "qux", Symbol(foo), Symbol(bar)]

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

1
2
3
4
5
6
7
8
9
10
let o = {
[Symbol('foo')]: 'foo val',
[Symbol('bar')]: 'bar val'
};
console.log(o);
// {Symbol(foo): "foo val", Symbol(bar): "bar val"}
let barSymbol = Object.getOwnPropertySymbols(o)
.find((symbol) => symbol.toString().match(/bar/));
console.log(barSymbol);
// Symbol(bar)

4. 内置的符号

内置符号都以Symbol工厂函数字符串属性的形式存在。

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

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

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

1. Symbol.asyncIterator

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

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

1
2
3
4
5
6
7
8
class Foo {
async *[Symbol.asyncIterator]() {}
}

let f = new Foo();

console.log(f[Symbol.asyncIterator]());
// AsyncGenerator {<suspended>}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Emitter {
constructor(max) {
this.max = max;
this.asyncIdx = 0;
}

async *[Symbol.asyncIterator]() {
while (this.asyncIdx < this.max) {
yield new Promise((resolve) => resolve(this.asyncIdx++));
}
}
}

async function asyncCount() {
let emitter = new Emitter(5);

for await (const x of emitter) {
console.log(x);
}
}

asyncCount();

// 0
// 1
// 2
// 3
// 4
2. Symbol.hasInstance

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

1
2
3
4
5
6
7
8
9
10
function Foo() {};
let f = new Foo();

console.log(f instanceof Foo); // true

class Bar {};

let b = new Bar();

console.log(b instanceof Bar); // true

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

1
2
3
4
5
6
7
function Foo() {};
let f = new Foo();
console.log(Foo[Symbol.hasInstance](f)); // true

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

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

1
2
3
4
5
6
7
8
9
10
11
12
class Bar {}
class Baz extends Bar {
static [Symbol.hasInstance]() {
return false;
}
}

let b = new Baz();
console.log(Bar[Symbol.hasInstance](b)); // true
console.log(b instanceof Bar); // true
console.log(Baz[Symbol.hasInstance](b)); // false
console.log(b instanceof Baz); // false
3. Symbol.isConcatSpreadable

作为一个属性表示**一个布尔值,如果是true,则意味着对象应该用Array.prototype.concat()打平其数组元素。

ES6中的Array.prototype.concat方法会根据接收到的对象类型选择如何将一个类数组对戏那个拼接成数组实例。覆盖Symbol.isConcatSpreadable的值可以修改这个行为.

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let initial = ['foo'];
let array = ['bar'];

console.log(array[Symbol.isConcatSpreadable]); // undefined
console.log(initial.concat(array)); // ['foo', 'bar']

array[Symbol.isConcatSpreadable] = false;
console.log(initial.concat(array)); // ['foo', Array(1)]

let arrayLikeObject = { length: 1, 0: 'baz' };
console.log(arrayLikeObject[Symbol.isConcatSpreadable]); // undefined

console.log(initial.concat(arrayLikeObject)); // ['foo', {...}]
arrayLikeObject[Symbol.isConcatSpreadable] = true;

console.log(initial.concat(arrayLikeObject)); // ['foo', 'baz']

let otherObject = new Set().add('qux');
console.log(otherObject[Symbol.isConcatSpreadable]); // undefined
console.log(initial.concat(otherObject)); // ['foo', Set(1)]

otherObject[Symbol.isConcatSpreadable] = true;
console.log(initial.concat(otherObject)); // ['foo']
4. Symbol.iterator

作为一个属性表示一个方法,该方法返回对象默认的迭代器

for-of语句使用。换句话说,这个符号实现迭代器API的函数。

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

1
2
3
4
5
6
7
8
class Foo {
*[Symbol.iterator]() {};
}

let f = new Foo();

console.log(f[Symbol.iterator]()); // Generator {<suspended>}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Emitter {
constructor(max) {
this.max = max;
this.idx = 0;
}

*[Symbol.iterator]() {
while (this.idx < this.max) {
yield this.idx++;
}
}
}

function count() {
let emitter = new Emitter(5);

for (const x of emitter) {
console.log(x);
}
}

count();

// 0
// 1
// 2
// 3
// 4
5. Symbol.match

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class FooMatcher {
static [Symbol.match](target) {
return target.includes("foo");
}
}

console.log("foobar".match(FooMatcher)); // true
console.log("barbaz".match(FooMatcher)); // false

class StringMatcher {
constructor(str) {
this.str = str;
}
[Symbol.match](target) {
return target.includes(this.str);
}
}

console.log("foobar".match(new StringMatcher("foo"))); // true
console.log("barbaz".match(new StringMatcher("qux"))); // false
6. Symbol.replace

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

1
2
3
4
5
6
7
console.log(RegExp.prototype[Symbol.replace]);

// f [Symbol.replace]() { [native code] }

console.log('foobarbaz'.replace(/bar/, 'qux'));
// foobazqux

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FooReplacer {
static [Symbol.replace](target, repalcement) {
return target.split('foo').join(repalcement);
}
}

console.log('barfoobaz'.replace(FooReplacer, 'qux')); // barquxbaz

class StringReplacer {
constructor(str) {
this.str = str;
}
[Symbol.replace](target, repalcement) {
return target.split(this.str).join(repalcement);
}
}

console.log('barfoobaz'.replace(new StringReplacer('foo'), 'qux')); // barquxbaz

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class FooSearcher {
static [Symbol.search](target) {
return target.indexOf('foo');
}
}
console.log('foobar'.search(FooSearcher)); // 0
console.log('barfoo'.search(FooSearcher)); // 3
console.log('barbaz'.search(FooSearcher)); // -1
class StringSearcher {
constructor(str) {
this.str = str;
}
[Symbol.search](target) {
return target.indexOf(this.str);
}
}
console.log('foobar'.search(new StringSearcher('foo'))); // 0
console.log('barfoo'.search(new StringSearcher('foo'))); // 3
console.log('barbaz'.search(new StringSearcher('qux'))); // -1
8. Symbol.species

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

简单来说,例如map,slice等返回新的对象的方法,默认返回原来的类型,但是如果修改了Symbol.species,则会返回其中定义的新类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Bar extends Array { };
class Baz extends Array {
static get [Symbol.species]() {
return Array;
}
};

let bar = new Bar();
console.log(bar instanceof Array); // true
console.log(bar instanceof Bar); // true
bar = bar.concat('bar');
console.log(bar instanceof Array); // true
console.log(bar instanceof Bar); // true

let baz = new Baz();
console.log(baz instanceof Array); // true
console.log(baz instanceof Baz); // true
baz = baz.concat('baz');
console.log(baz instanceof Array); // true
console.log(baz instanceof Baz); // false
9. Symbol.split

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

1
2
3
4
console.log(RegExp.prototype[Symbol.split]);
// f [Symbol.split]() { [native code] }

console.log('foobarbaz'.split(/bar/)); // ["foo", "baz"]

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class FooSplitter {
static [Symbol.split](target) {
return target.split('foo');
}
}
console.log('barfoobaz'.split(FooSplitter));
// ["bar", "baz"]
class StringSplitter {
constructor(str) {
this.str = str;
}
[Symbol.split](target) {
return target.split(this.str);
}
}
console.log('barfoobaz'.split(new StringSplitter('foo')));
// ["bar", "baz"]
10. Symbol.toPrimitive

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Foo { }
let foo = new Foo();
console.log(3 + foo); // "3[object Object]"
console.log(3 - foo); // NaN
console.log(String(foo)); // "[object Object]"
class Bar {
constructor() {
this[Symbol.toPrimitive] = function (hint) {
switch (hint) {
case 'number':
return 3;
case 'string':
return 'string bar';
case 'default':
default:
return 'default bar';
}
}
}
}

let bar = new Bar();
console.log(3 + bar); // "3default bar"
console.log(3 - bar); // 0
console.log(String(bar)); // "string bar"
11. Symbol.toStringTag

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let s = new Set();
console.log(s); // Set(0) {}
console.log(s.toString()); // [object Set]
console.log(s[Symbol.toStringTag]); // Set
class Foo {}
let foo = new Foo();
console.log(foo); // Foo {}
console.log(foo.toString()); // [object Object]
console.log(foo[Symbol.toStringTag]); // undefined
class Bar {
constructor() {
this[Symbol.toStringTag] = 'Bar';
}
}
let bar = new Bar();
console.log(bar); // Bar {}
console.log(bar.toString()); // [object Bar]
console.log(bar[Symbol.toStringTag]); // Bar
12. Symbol.unscopables

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

1
2
3
4
5
6
7
8
9
10
let o = { foo: 'bar' };
with (o) {
console.log(foo); // bar
}
o[Symbol.unscopables] = {
foo: true
};
with (o) {
console.log(foo); // ReferenceError
}

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

Object类型

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

1
let o = new Object();

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

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

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

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

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

20250704164203

3.8 操作符

  • 一元操作符
  • 位操作符
  • 布尔操作符
  • 乘性操作符
  • 指数操作符
  • 加性操作符
  • 关系操作符
  • 相等操作符
  • 条件操作符
  • 赋值操作符
  • 逗号操作符

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

本章内容

  • 通过变量使用原始值与引用值
  • 理解执行上下文
  • 理解垃圾回收

相比于其他语言,JavaScript 中的变量可谓独树一帜。正如 ECMA-262 所规定的,JavaScript 变量是松散类型的,而且变量不过就是特定时间点一个特定值的名称而已。由于没有规则定义变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。这样的变量很有意思,很强大,当然也有不少问题。本章会剖析错综复杂的变量。

4.1 原始值与引用值

4.1.1 动态属性

4.1.2 复制值

4.1.3 传递参数

ECMAScript中所有函数的参数都是按值传递的

1
2
3
4
5
6
7
8
9
function setName(obj) {
obj.name = "Nicholas";
obj = new Object(); // 不会影响外部的obj
obj.name = "Greg";
}

let person = new Object();
setName(person);
console.log(person.name); // Nicholas

对象类型传递的也是值,只不过值的内容是一个地址。修改函数内部对象本身的值,不会影响到外部,只有通过函数内部对象值的引用访问堆内存,进行修改才会影响到外部值。

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

4.2 执行上下文和作用域

执行上下文(简称“上下文”)在JavaScript中非常重要。变量或函数的上下文决定了他们可以访问哪些数据,以及他们的行为。每个上下文都有一个关联的变量对象。而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。

全局上下文时最外层的上下文。根据ECMAScript实现的宿主环境,表示全局上下文的对象可能不一样。在浏览器中,全局上下文就是我们常说的window对象,因此所有通过var定义的全局变量和函数都会成为window对象的属性和方法

使用let和const的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。上下文在其所有代码都执行完毕后会被销毁。包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。

每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该上下文,将控制权返还给之前的执行上下文。ECMAScript程序的执行流就是通过这个上下文栈进行控制的。

上下文中的代码在执行的时候,会创建一个对象的作用域链。这个作用域链决定了各级上下中的代码在访问变量和函数时的顺序。代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象用足变量对象。活动对象最初只有一个定义变量:arguments(全局上下文中没有这个变量).作用域链中的下一个变量对象来自包含上下文,再下一个对象来自下一个包含上下文。以此类推直至全局上下文。全局上下文的变量对象始终是作用域链的最后一个变量对象。

代码执行时的标识符解析是通过眼作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符。(如果没有标识符,通常会报错)

1
2
3
4
5
6
7
8
9
var color = "blue";
function changeColor() {
if (color === "blue") {
color = "red";
} else {
color = "blue";
}
}
changeColor();

对于这个例子而言,函数changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就是定义arguments对象的那个),另一个就全局上下文的变量对象。这个函数内部之所以能够访问变量color,就是因为可以在作用域链中找到它。

此外,局部作用域中定义的变量可用于在局部上下文中替换全局变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var color = "blue";
function changeColor() {
let anotherColor = "red";
function swapColors() {
let tempColor = anotherColor;
anotherColor = color;
color = tempColor;
// 这里可以访问 color、anotherColor 和 tempColor
}
// 这里可以访问 color 和 anotherColor,但访问不到 tempColor
swapColors();
}
// 这里只能访问 color
changeColor();

以上代码涉及 3 个上下文:全局上下文、changeColor()的局部上下文和 swapColors()的局部上下文。全局上下文中有一个变量 color和一个函数 changeColor()。 changeColor()的局部上下文中有一个变量 anotherColor和一个函数 swapColors(),但在这里可以访问全局上下文中的变量 color。swapColors()的局部上下文中有一个变量 tempColor,只能在这个上下文中访问到。全局上下文和changeColor()的局部上下文都无法访问到 tempColor。而在 swapColors()中则可以访问另外两个上下文中的变量,因为它们都是父上下文。图 4-3 展示了前面这个例子的作用域链。

20250704185427

图 4-3 中的矩形表示不同的上下文。内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西。上下文之间的连接是线性的、有序的。每个上下文都可以到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。swapColors()局部上下文的作用域链中有 3 个对象: swapColors()的变量对象、 changeColor()的变量对象和全局变量对象。swapColors()的局部上下文首先从自己的变量对象开始搜索变量和函数,搜不到就去搜索上一级变量对象。changeColor()上下文的作用域链中只有 2 个对象:它自己的变量对象和全局变量对象。因此,它不能访问 swapColors()的上下文。

函数参数被认为是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同的访问规则。

4.2.1 作用域链增强

虽然执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:

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

这两种情况下,都会在作用域链前端添加一个变量对象。对with语句来说,会向作用域链前端添加指定的对象;对catch语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。

1
2
3
4
5
6
7
function buildUrl() {
let qs = "?debug=true";
with (location) {
let url = href + qs;
}
return url;
}

这里,with 语句将 location 对象作为上下文,因此 location 会被添加到作用域链前端。buildUrl()函数中定义了一个变量 qs。当 with 语句中的代码引用变量 href 时,实际上引用的是location.href,也就是自己变量对象的属性。在引用 qs时,引用的则是定义在 buildUrl()中的那个变量,它定义在函数上下文的变量对象上。而在 with 语句中使用 var 声明的变量url 会成为函数上下文的一部分,可以作为函数的值被返回;但像这里使用 let声明的变量 url,因为被限制在块级作用域(稍后介绍),所以在 with块之外没有定义。

4.2.2 变量声明

在使用 var 声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。在 with 语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文,如下面的例子所示:

1
2
3
4
5
6
function add(num1, num2) {
var sum = num1 + num2;
return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 报错:sum 在这里不是有效变量

这里,函数 add()定义了一个局部变量 sum,保存加法操作的结果。这个值作为函数的值被返回,但变量 sum 在函数外部是访问不到的。如果省略上面例子中的关键字 var,那么 sum 在 add()被调用之后就变成可以访问的了,如下所示:

1
2
3
4
5
6
function add(num1, num2) {
sum = num1 + num2;
return sum;
}
let result = add(10, 20); // 30
console.log(sum); // 30

这一次,变量 sum 被用加法操作的结果初始化时并没有使用 var 声明。在调用 add()之后,sum被添加到了全局上下文,在函数退出之后依然存在,从而在后面可以访问到。

var 声明会被拿到函数或全局作用域的顶部,位于作用域中所有代码之前。这个现象叫作“提升”(hoisting)。提升让同一作用域中的代码不必考虑变量是否已经声明就可以直接使用。可是在实践中,提升也会导致合法却奇怪的现象,即在变量声明之前使用变量。下面的例子展示了在全局作用域中两段等价的代码:

4.3 垃圾回收

JavaScript是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在C和C++等语言中,跟踪内存使用对开发者来说是一个很大的负担,也是很多问题的来源。JavaScript为开发者卸下了这个负担,通过自动内存管理实现内存分配和闲置资源的回收。基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于不可判定的问题,意味着靠算法是解决不了的。

主要有两种方式:标记清理和引用计数

4.3.1 标记清理

JavaScript最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数内部声明一个变量时,这个变量就会被加上存在于上下文中的标记。而在上下文中的变量,逻辑上讲,永远不应该释放他们的内存,因为只要上下文中的代码在运行,就有可能用到他们。当变量离开上下文时,也会被加上离开该上下文的标记。

给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现方式并不重要,关键是策略。

垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后还有标记的变量就是待删除的了,原因是任何上下文中的变量都访问不到他们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并回收他们的内存。

4.3.2 引用计数

另一种没那么常用的垃圾回收策略是引用计数(reference counting)。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为 1。如果同一个值又被赋给另一个变量,那么引用数加 1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。
引用计数最早由 Netscape Navigator 3.0 采用,但很快就遇到了严重的问题:循环引用。所谓循环引用,就是对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A。比如:

1
2
3
4
5
6
function problem() {
let objectA = new Object();
let objectB = new Object();
objectA.someOtherObject = objectB;
objectB.anotherObject = objectA;
}

4.3.3 性能

垃圾回收程序会周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。
开发者不知道什么时候运行时会收集垃圾,因此最好的办法是在写代码时就要做到:无论什么时候开始收集垃圾,都能让它尽快结束工作。
现代垃圾回收程序会基于对 JavaScript 运行时环境的探测来决定何时运行。探测机制因引擎而异,但基本上都是根据已分配对象的大小和数量来判断的。比如,根据 V8 团队 2016 年的一篇博文的说法:
“在一次完整的垃圾回收之后, V8 的堆增长策略会根据活跃对象的数量外加一些余量来确定何时再次垃
圾回收。”
由于调度垃圾回收程序方面的问题会导致性能下降, IE 曾饱受诟病。它的策略是根据分配数,比如分配了 256 个变量、4096 个对象/数组字面量和数组槽位(slot) ,或者 64KB 字符串。只要满足其中某个条件,垃圾回收程序就会运行。这样实现的问题在于,分配那么多变量的脚本,很可能在其整个生命周期内始终需要那么多变量,结果就会导致垃圾回收程序过于频繁地运行。由于对性能的严重影响,IE7最终更新了垃圾回收程序。IE7 发布后, JavaScript 引擎的垃圾回收程序被调优为动态改变分配变量、字面量或数组槽位等会触发垃圾回收的阈值。 IE7 的起始阈值都与 IE6 的相同。如果垃圾回收程序回收的内存不到已分配的 15%,这些变量、字面量或数组槽位的阈值就会翻倍。如果有一次回收的内存达到已分配的 85%,则阈值重置为默认值。这么一个简单的修改,极大地提升了重度依赖 JavaScript 的网页在浏览器中的性能。

4.3.4 内存管理

在使用垃圾回收的编程环境中,开发者通常无需关心内存管理。不过,JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这更多出于安全考虑而不是别的,就睡位了避免运行大量JavaScript的网页耗尽系统内存而导致操作系统崩溃。这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量。

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执代码时只保存必要的数据。如果数据不在需要,就把它设置为null,从而释放其引用。这也可以叫做解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用,如下面的例子:

1
2
3
4
5
6
7
8
9
10
function createPerson(name) {
let localPerson = new Object();
localPerson.name = name;
return localPerson;
}

let globalPerson = createPerson("Nicholas");

// 解除globalPerson对值的引用
globalPerson = null;

在上面的代码中,变量globalPerson保存着createPerson()函数调用返回的值。在createPerson()内部,localPerson创建了一个对象并给它添加了一个name属性。然后,localPerson作为函数值被返回,并赋值给globalPerson。localPerson在 createPerson()执行完成超出上下文后会自动被解除引用,不需要显式处理。但 globalPerson 是一个全局变量,应该在不再需要时手动解除其引用,最后一行就是这么做的。

第5章 基本引用类型

本章内容

  • 理解对象
  • 基本JavaScript数据类型
  • 原始值与原始值包装类型

5.1 Date

静态方法

  • Date.parse()
  • Date.UTC()
  • Date.now()

实例方法

  • toDateString()
  • toTimeString()
  • toLocalDateString()
  • toLocalTimeString()
  • toUTCString()

第6章 集合引用类型

6.1 Object

6.2 Array

6.3 定型数组

6.3.1 历史

6.3.2 ArrayBuffer

Float32Array实际上是一种“视图”,可以允许JavaScript在运行时访问一块名为ArrayBuffer的预分配内存。ArrayBuffer是所有定型数组及视图引用的基本单位。

ArrayBuffer()是一个普通的JavaScript构造函数,可以用于在内存中分配特定数量的字节空间。

1
2
const buf = new ArrayBuffer(16);    // 在内存中分配16字节
console.log(buf.byteLength); // 16

ArrayBuffer一经创建就不能再调整大小。不过,可以使用slice()复制其全部或部分到一个新的实例中:

1
2
3
const buf1 = new ArrayBuffer(16);
const buf2 = new buf1.slice(4, 12);
console.log(buf2.byteLength); // 8

不能仅仅通过对ArrayBuffer的引用就读取或写入其内容。要读取或写入ArrayBuffer,就必须通过视图。视图有不同的类型,但引用的都是ArrayBuffer中存储的二进制数据。

6.3.3 DataView

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

必须对已有的ArrayBuffer读取或写入时才能创建DataView实例。这个实例可以使用全部或部分ArrayBuffer,且维护着对缓冲实例的引用,以及视图在缓冲中开始的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const buf = new ArrayBuffer(16);

// DataView默认使用整个ArrayBuffer

const fullDataView = new DataView(buf);

console.log(fullDataView.byteOffset); // 0
console.log(fullDataView.byteLength); // 16
console.log(fullDataView.buffer === buf); // true

// 构造函数接收一个可选的字节偏移量和字节长度
// byteOffset=0 表示视图从缓冲起点开始
// byteLength=8 表示视图只使用缓冲区的前8个字节

const partialDataView = new DataView(buf, 0, 8);

console.log(partialDataView.byteOffset); // 0
console.log(partialDataView.byteLength); // 8
console.log(partialDataView.buffer === buf); // true

// 如果不指定,则DataView会使用剩余的缓冲
// byteOffset=8 表示视图从缓冲区的第8个字节开始
// byteLength未指定,所以视图将使用缓冲区的剩余部分

const anotherPartialDataView = new DataView(buf, 8);

console.log(anotherPartialDataView.byteOffset); // 8
console.log(anotherPartialDataView.byteLength); // 8
console.log(anotherPartialDataView.buffer === buf); // true

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

  • 首先是要读或写的字节偏移量。可以用看成DataView中的某种地址。
  • DateView应该使用ElementType来实现JavaScript的Number类型到缓冲内二进制格式的转换
  • 最后是内存中值的字节序。默认为大端字节序。
  1. ElementType

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

20250705211901

DataView为上表中的每种类型都暴露了get和set方法,这些方法使用byteOffset(字节偏移量)定位要读取或写入值的位置。类型是可以互换使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 在内存中分配两个字节并声明一个DataView
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);

// 说明整个缓冲确实所有二进制位都为0
// 检查第一个和第二个字符

console.log(view.getInt8(0)); // 0
console.log(view.getInt8(1)); // 0

// 检查整个缓冲
console.log(view.getInt16(0)); // 0

// 将整个缓冲都设置为1
// 255的二进制表示是11111111

view.setUint8(0, 255);

// DataView会自动将数据转换为特定的ElementType
// 255的十六进制表示是0xFF

view.setUint8(1, 0xff);

// 现在,缓冲区里都是1了
// 如果把它当成二进制补码的有符号整数,则应该是-1
console.log(view.getInt16(0)); // -1
  1. 字节序

前面例子中的缓冲回避了字节序的问题。字节序是计算系统维护的一种字节顺序的约定。DataView只支持两种约定:大端字节序和小端字节序。大端字节序也称为“网络字节序”,意思是最高有效位保存在第一个字节,而最低有效位保存在最后一个字节。小端字节序正好相反,即最低有效位保存在第一个字节,最高有效位保存在最后一个字节。

JavaScript 运行时所在系统的原生字节序决定了如何读取或写入字节,但 DataView 并不遵守这个约定。对一段内存而言,DataView 是一个中立接口,它会遵循你指定的字节序。DataView 的所
有 API 方法都以大端字节序作为默认值,但接收一个可选的布尔值参数,设置为 true 即可启用小端字节序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 在内存中分配两个字节并声明一个 DataView
const buf = new ArrayBuffer(2);
const view = new DataView(buf);
// 填充缓冲,让第一位和最后一位都是 1
view.setUint8(0, 0x80); // 设置最左边的位等于 1
view.setUint8(1, 0x01); // 设置最右边的位等于 1
// 缓冲内容(为方便阅读,人为加了空格)
// 0x8 0x0 0x0 0x1
// 1000 0000 0000 0001
// 按大端字节序读取 Uint16
// 0x80 是高字节,0x01是低字节
// 0x8001 = 2^15 + 2^0 = 32768 + 1 = 32769
console.log(view.getUint16(0)); // 32769
// 按小端字节序读取 Uint16
// 0x01 是高字节,0x80是低字节
// 0x0180 = 2^8 + 2^7 = 256 + 128 = 384
console.log(view.getUint16(0, true)); // 384
// 按大端字节序写入 Uint16
view.setUint16(0, 0x0004);
// 缓冲内容(为方便阅读,人为加了空格)
// 0x0 0x0 0x0 0x4
// 0000 0000 0000 0100
console.log(view.getUint8(0)); // 0
console.log(view.getUint8(1)); // 4
// 按小端字节序写入 Uint16
view.setUint16(0, 0x0002, true);
// 缓冲内容(为方便阅读,人为加了空格)
// 0x0 0x2 0x0 0x0
// 0000 0010 0000 0000
console.log(view.getUint8(0)); // 2
console.log(view.getUint8(1)); // 0

6.4 Map

6.5 WeakMap

6.6 Set

6.7 WeakSet

第7章 迭代器和生成器

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

本章内容

理解对象
理解对象创建过程
理解继承
理解类

8.1 理解对象

8.2 创建对象

8.2.1 概述

8.2.2 工厂模式

8.2.3 构造函数模式

8.2.4 原型模式

8.2.5 对象迭代

8.3 继承

8.4 类

8.4.1 类定义

8.4.2 类构造函数

1. 实例化

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

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

8.4.3 实例,原型和类成员

8.4.4 继承

  1. 抽象基类

new.target保存通过new关键字调用的类或函数。

第9章 代理与反射

本章内容

  • 代理基础
  • 代码捕获器与反射方法
  • 代理模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const target = {
id: 'target'
};

const handler = {};

const proxy = new Proxy(target, handler);

// id属性会访问同一个值
console.log(target.id); // target
console.log(proxy.id); // target
console.log(target.id === proxy.id); // true

// 给目标属性赋值会反映在两个对象上
// 因为两个对象访问的是同一个值

proxy.id = 'foo';
console.log(target.id); // foo
console.log(proxy.id); // foo
console.log(target.id === proxy.id); // true

// hasOwnProperty()方法在两个地方
// 都会返回true(在target对象上)
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 代理基础

9.1.1 创建空代理

9.1.2 定义捕获器

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const target = {
foo: 'bar'
};

const handler = {
get() {
return 'handler override';
}
};

const proxy = new Proxy(target, handler);

console.log(target.foo);
console.log(proxy.foo);

console.log(target['foo']);
console.log(proxy['foo']);

console.log(Object.create(target)['foo']);
console.log(Object.create(proxy)['foo']);

9.1.3 捕获器参数和反射API

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const target = {
foo: 'bar'
};

const handler = {
get(trapTarget, property, receiver) {
console.log(trapTarget === target);
console.log(property);
console.log(receiver === proxy);
}
};

const proxy = new Proxy(target, handler);

proxy.foo;

有了这些参数,就可以重建别捕获的方法的原始行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const target = {
foo: 'bar'
};

const handler = {
get(trapTarget, property, receiver) {
console.log(trapTarget === target);
console.log(property);
console.log(receiver === proxy);
return trapTarget[property];
}
};

const proxy = new Proxy(target, handler);

console.log(proxy.foo);

9.1.4 捕获器不变式

9.1.5 可撤销的代理

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

1
2
3
4
5
6
7
8
9
10
11
12
13
const target = {
foo: 'bar'
};
const handler = {
get() {
return 'intercepted';
}
};
const { proxy, revoke } = Proxy.revocable(target, handler);
console.log(proxy.foo); // intercepted
console.log(target.foo); // bar
revoke();
console.log(proxy.foo); // TypeError

9.1.6 实用反射API

某些情况下应该优先使用反射API。

  1. 反射API与对象API

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

  1. 反射API并不限于捕获处理程序
  2. 大多数反射API在Object类型上都有对应的方法

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

  1. 状态标记

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

1
2
3
4
5
6
7
8
// 初始代码
const o = {};
try {
Object.defineProperty(o, 'foo', 'bar');
console.log('success');
} catch(e) {
console.log('failure');
}

在定义新属性时如果发生问题,Reflect.defineProperty()会返回 false,而不是抛出错误。因此使用这个反射方法可以这样重构上面的代码:

1
2
3
4
5
6
7
// 重构后的代码
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()

以下反射方法可以代替操作符

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

9.1.7 代理另一个代理

9.1.8 代理的问题与不足

9.2 代理捕获器与反射方法

9.3 代理模式

第10章 函数

10.1 箭头函数

10.2 函数名

所有函数对象都会暴露一个只读的name属性,其中包含关于函数的信息。多数情况下,这个属性中保存的就是一个函数标识符,或者说一个字符串化的变量名。即使函数没有名称,也会如实显示为空字符串。如果他是使用Function构造函数创建的,则会被标识为“anonymous”

如果函数是一个获取函数、设置函数,或者使用 bind()实例化,那么标识符前面会加上一个前缀:

10.3 理解参数

ECMAScript函数的参数跟大多数其他语言不同。ECMAScript函数既不关心传入的参数个数,也不关心这些参数的数据类型。定义函数时要接收两个参数,并不意味着调用时就要传递两个参数。可以传一个,三个,甚至一个都不传,解释器也不会报错。

之所以会这样,是因为ECMAScript函数的参数在内部表现为一个数组。函数被调用时总会接收一个数组,但函数并不关心这个数组中包含什么。如果数组中什么也没有,那没问题,如果数组中元素超出了要求,那也没问题。事实上,在使用function关键字定义(非箭头)函数时,可以在函数内部访问arguments对象,从中取得传进来的每个参数值。

arguments对象是一个类数组对象(但不是Array的实例),因此可以利用中括号语法访问其中的元素。而要确定传进来多少个参数,可以访问arguments.length属性。

10.4 没有重载

10.5 默认参数值

10.6 参数扩展与收集

10.7 函数声明与函数表达式

10.8 函数作为值

10.9 函数内部

10.9.1 arguments

arguments.callee指向函数

10.9.2 this

10.9.3 caller

10.9.4 new.target

10.10 函数属性与方法

ECMAScript中函数是对象,因此有属性和方法。每个函数都有两个属性:length和prototype.其中,length属性保存函数定义的命名参数的个数,如下例:

1
2
3
4
5
6
7
8
9
10
11
12
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

10.12 递归

10.13 尾调用优化

尾调用优化是一项内存管理优化机制,让JavaScript引擎在满足条件时可以重用栈帧。具体来说,类似外部函数的返回值是一个内部函数的返回值的优化非常适合“尾调用”。比如:

1
2
3
function outerFunction() {
return innerFucntion(); // 尾调用
}

在ES6优化之前,执行这个例子会在内存中发生如下操作。

  1. 执行到outerFunction函数体,第一个栈帧被推到栈上。
  2. 执行outerFunction函数体,到return语句。计算返回值必须先计算innnerFunction
  3. 执行到innerFunction函数体,第二个栈帧被推到栈上
  4. 执行innerFunctiton函数体,计算其返回值
  5. 将返回值传回outerFunction,然后outerFunction再返回值
  6. 将栈帧弹出栈外

在ES6优化之后,执行这个例子会在内存中发生如下操作。

  1. 执行outerFunction函数体,第一个栈帧被推到栈上。
  2. 执行outerFunction函数体,到达return语句。为求值返回语句,必须先求值innerFunction。
  3. 引擎发现把第一个栈帧弹出栈外也没有问题,因为innerFunction的返回值也是outerFunction的返回值
  4. 弹出outerFunction的栈帧
  5. 执行到innerFunction函数体,栈帧被推到栈上
  6. 执行innerFunction函数体,计算其返回值
  7. 将innerFunction的栈帧弹出栈外

很明显,第一种情况下每多调用一次嵌套函数,就会多增加一个栈帧。而第二种情况下,无论调用多少次嵌套函数,都只有一个栈帧。

10.13.1 尾调用优化的条件

  1. 代码在严格模式下运行
  2. 外部函数的返回值是对尾调用函数的调用
  3. 尾调用函数返回后不需要执行额外的逻辑
  4. 尾调用函数不是引用外部函数作用域中自由变量的闭包

10.14 闭包

10.15 this对象

10.14 内存泄漏

10.15 立即执行函数表达式IIFE

10.16 私有变量

第11章 Promise与异步函数

本章内容

  • 异步编程
  • Promise
  • 异步函数

11.1 异步编程

11.2 Promise

11.2.1 Promise/A+规范

11.2.2 Promise基础

Promise通过new操作符来实例化。创建Promise时需要传入执行器(executor)函数作为参数。

  1. Promise状态机

  2. 拒绝值,拒绝理由及Promise用例

  3. 通过执行函数控制Proimse的状态

  4. Promise.resolve()

  5. Proimse.reject()

  6. 同步/异步执行的二元性

11.2.3 Promise实例的方法

  1. 实现Thenable接口

  2. Promise.prototype.then()

  3. Promise.prototype.catch()

  4. Promise.prototype.finally()

  5. 非重入Proimse方法

  6. 邻近处理程序的执行顺序

  7. 传递解决值和拒绝理由

  8. 拒绝Promise与拒绝错误处理

11.2.4 Promise的连锁与Proimse合成

  1. Promise连锁

  2. Promise图

  3. Promise.all()

  4. Promise.race()

11.2.5 Promise扩展

  1. Proimse取消

  2. Promise进度通知

11.3 异步函数

第12章 BOM

本章内容
理解BOM核心,window对象
控制窗口及弹窗
通过location对象获取页面信息
通过navigator对象了解浏览器
通过history对象操作浏览器历史

12.1 window对象

BOM的核心是window对象,代表浏览器实例。window对象在浏览器中有两重身份,一个是ECMAScript中的Global对象,另一个就是浏览器窗口的JavaScript接口。这意味着网页中定义的所有对象,变量和函数都以window作为其Global对象,都可以访问其上定义的parseInt()等全局方法。

12.1.1 Global作用域

12.1.2 窗口关系

top对象始指向最上层(最外层)窗口,即浏览器窗口本身。而parent对象则始终指向当前窗口的父窗口。如果当前窗口是最上层窗口,则parent等于top(都等于window)。最上层的window如果不是通过window.open()打开的,那么其name属性就不会包含值。

self对象,他是终极window属性,始终指向window。实际上,selft就是window。之所以要暴露self,是为了和top,parent一致

12.1.3 窗口位置与像素比

window对象的位置可以通过不同的属性和方法来确定。现在浏览器提供了screenLeft和screeTop属性,用于表示窗口相对于屏幕左侧和顶部的位置,返回值的单位是CSS像素。

可以使用moveTo()和moveBy()方法移动窗口。这两个方法都接受两个参数,其中moveTo()接收要移动到的新位置的绝对坐标x和y。而moveBy()则接收相对当前位置在两个方向上移动的像素数。

devicePixelRatio就是物理像素/逻辑像素

12.1.4 窗口大小

window的属性

  • innerHeight
  • innerWidth
  • outerHeight
  • outerWidth

调整大小

  • window.resizeTo(width, height)
  • window.resizeBy(width, height);

12.1.5 视口位置

度量文档相对于视口滚动距离的属性有两对,返回相等的值

  • window.pageXoffset/window.scrollX
  • window.pageYoffset/window.scrollY

可以使用scroll(),scrollTo()scrollBy()方法滚动页面。

1
2
3
4
5
6
7
8
// 相对于当前视口向下滚动 100 像素
window.scrollBy(0, 100);
// 相对于当前视口向右滚动 40 像素
window.scrollBy(40, 0);
// 滚动到页面左上角
window.scrollTo(0, 0);
// 滚动到距离屏幕左边及顶边各 100 像素的位置
window.scrollTo(100, 100);

这几个方法也都接收一个 ScrollToOptions字典,除了提供偏移值,还可以通过 behavior属性告诉浏览器是否平滑滚动。

1
2
3
4
5
6
7
8
9
10
11
12
// 正常滚动
window.scrollTo({
left: 100,
top: 100,
behavior: 'auto'
});
// 平滑滚动
window.scrollTo({
left: 100,
top: 100,
behavior: 'smooth'
});

12.1.6 导航与打开新窗口

window.open()方法可以用于导航到制定的URL,也可以用于打开新浏览器窗口。

`window.open(targetURL, targetWindow, specialString, isReplaced).

这个方法接收 4
个参数:要加载的 URL、目标窗口、特性字符串和表示新窗口在浏览器历史记录中是否替代当前加载页面的布尔值。通常,调用这个方法时只传前 3 个参数,最后一个参数只有在不打开新窗口时才会使用。

如果 window.open()的第二个参数是一个已经存在的窗口或窗格(frame)的名字,则会在对应的窗口或窗格中打开 URL。下面是一个例子:

1
2
// 与<a href="http://www.wrox.com" target="topFrame"/>相同
window.open("http://www.wrox.com/", "topFrame");

执行这行代码的结果就如同用户点击了一个 href 属性为”http://www.wrox.com",target 属性为”topFrame”的链接。如果有一个窗口名叫”topFrame”,则这个窗口就会打开这个 URL;否则就会打开一个新窗口并将其命名为”topFrame”。第二个参数也可以是一个特殊的窗口名,比如_self、_parent、_top或_blank。

  1. 弹出窗口

如果 window.open()的第二个参数不是已有窗口,则会打开一个新窗口或标签页。第三个参数,即特性字符串,用于指定新窗口的配置。如果没有传第三个参数,则新窗口(或标签页)会带有所有默认的浏览器特性(工具栏、地址栏、状态栏等都是默认配置)。如果打开的不是新窗口,则忽略第三个参数。
特性字符串是一个逗号分隔的设置字符串,用于指定新窗口包含的特性。下表列出了一些选项。

window.open()方法返回一个对新建窗口的引用。这个对象与普通 window对象没有区别,只是为控制新窗口提供了方便。例如,某些浏览器默认不允许缩放或移动主窗口,但可能允许缩放或移动通过window.open()创建的窗口。跟使用任何 window 对象一样,可以使用这个对象操纵新打开的窗口。

1
2
3
4
5
6
7
let wroxWin = window.open("http://www.wrox.com/",
"wroxWindow",
"height=400,width=400,top=10,left=10,resizable=yes");
// 缩放
wroxWin.resizeTo(500, 500);
// 移动
wroxWin.moveTo(100, 100);

还可以使用 close()方法像这样关闭新打开的窗口:

1
wroxWin.close();

这个方法只能用于 window.open()创建的弹出窗口。虽然不可能不经用户确认就关闭主窗口,但弹出窗口可以调用 top.close()来关闭自己。关闭窗口以后,窗口的引用虽然还在,但只能用于检查其 closed 属性了:

1
2
wroxWin.close();
alert(wroxWin.closed); // true

新创建窗口的 window 对象有一个属性 opener,指向打开它的窗口。这个属性只在弹出窗口的最上层 window 对象(top)有定义,是指向调用 window.open()打开它的窗口或窗格的指针。例如:

1
2
3
4
5
let wroxWin = window.open("http://www.wrox.com/",
"wroxWindow",
"height=400,width=400,top=10,left=10,resizable=yes");

alert(wroxWin.opener === window); // true

虽然新建窗口中有指向打开它的窗口的指针,但反之则不然。窗口不会跟踪记录自己打开的新窗口,因此开发者需要自己记录。
在某些浏览器中,每个标签页会运行在独立的进程中。如果一个标签页打开了另一个,而 window对象需要跟另一个标签页通信,那么标签便不能运行在独立的进程中。在这些浏览器中,可以将新打开的标签页的 opener属性设置为 null,表示新打开的标签页可以运行在独立的进程中。比如:

1
2
3
4
let wroxWin = window.open("http://www.wrox.com/",
"wroxWindow",
"height=400,width=400,top=10,left=10,resizable=yes");
wroxWin.opener = null;

把 opener 设置为 null 表示新打开的标签页不需要与打开它的标签页通信,因此可以在独立进程中运行。这个连接一旦切断,就无法恢复了。

  1. 安全限制

  2. 弹窗屏蔽程序

12.1.7 定时器

所有setTimeout的回调函数都会在全局作用域中的一个匿名函数中运行,因此函数中的this值在非严格模式下始终指向window,而在严格模式下是undefined。如果给setTimeout()提供了一个箭头函数,那么this将会保留为定义它时所在的词法作用域。

这里的关键点是,第二个参数,也就是间隔时间,指的是向队列添加新任务之前等待的时间。比如,调用 setInterval()的时间为 01:00:00,间隔时间为 3000 毫秒。这意
味着 01:00:03 时,浏览器会把任务添加到执行队列。浏览器不关心这个任务什么时候执行或者执行要花多长时间。因此,到了 01:00:06,它会再向队列中添加一个任务。由此可看
出,执行时间短、非阻塞的回调函数比较适合 setInterval()。

12.1.8 系统对话框

使用alert(), confirm()和prompt()方法,可以让浏览器调用系统对话框向用户显示信息。这些对话框与浏览器中显示的网页无关,而且也不包含HTML。他们的外观由操作系统或者是浏览器来决定,无法使用CSS设置。此外,这些对话框都是同步的模态对话框,即在显示他们的时候,代码会停止执行,在他们消失后,代码才恢复运行。

alert()方法在本书示例中经常用到。它接收一个要显示给用户的字符串。与 console.log 可以接收任意数量的参数且能一次性打印这些参数不同, alert()只接收一个参数。调用 alert()时,传入的字符串会显示在一个系统对话框中。对话框只有一个“OK” (确定)按钮。如果传给 alert()的参数不是一个原始字符串,则会调用这个值的 toString()方法将其转换为字符串。

警告框(alert)通常用于向用户显示一些他们无法控制的消息,比如报错。用户唯一的选择就是在看到警告框之后把它关闭。图 12-1 展示了一个警告框。

第二种对话框叫确认框,通过调用 confirm()来显示。确认框跟警告框类似,都会向用户显示消息。但不同之处在于,确认框有两个按钮:“Cancel” (取消)和“OK”(确定)。用户通过单击不同的按钮表明希望接下来执行什么操作。比如,confirm(“Are you sure?”)会显示图 12-2 所示的确认框。

1
2
3
4
5
if (confirm("Are you sure?")) {
alert("I'm so glad you're sure!");
} else {
alert("I'm sorry to hear you're not sure.");
}

在这个例子中,第一行代码向用户显示了确认框,也就是 if语句的条件。如果用户单击了 OK 按钮,则会弹出警告框显示”I’m so glad you’re sure!”。如果单击了 Cancel,则会显示”I’m sorry tohear you’re not sure.”。确认框通常用于让用户确认执行某个操作,比如删除邮件等。因为这种对话框会完全打断正在浏览网页的用户,所以应该在必要时再使用。

最后一种对话框是提示框,通过调用 prompt()方法来显示。提示框的用途是提示用户输入消息。除了 OK 和 Cancel 按钮,提示框还会显示一个文本框,让用户输入内容。 prompt()方法接收两个参数:要显示给用户的文本,以及文本框的默认值(可以是空字符串)。调用 prompt(“What is your name?”,”Jake”)会显示图 12-3 所示的提示框。

如果用户单击了 OK 按钮,则 prompt()会返回文本框中的值。如果用户单击了 Cancel 按钮,或者对话框被关闭,则 prompt()会返回 null。下面是一个例子:

1
2
3
4
let result = prompt("What is your name? ", "");
if (result !== null) {
alert("Welcome, " + result);
}

这些系统对话框可以向用户显示消息、确认操作和获取输入。由于不需要 HTML 和 CSS,所以系统对话框是 Web 应用程序最简单快捷的沟通手段。
很多浏览器针对这些系统对话框添加了特殊功能。如果网页中的脚本生成了两个或更多系统对话框,则除第一个之外所有后续的对话框上都会显示一个复选框,如果用户选中则会禁用后续的弹框,直到页面刷新。

如果用户选中了复选框并关闭了对话框,在页面刷新之前,所有系统对话框(警告框、确认框、提示框)都会被屏蔽。开发者无法获悉这些对话框是否显示了。对话框计数器会在浏览器空闲时重置,因此如果两次独立的用户操作分别产生了两个警告框,则两个警告框上都不会显示屏蔽复选框。如果一次独立的用户操作连续产生了两个警告框,则第二个警告框会显示复选框。

JavaScript 还可以显示另外两种对话框:find()和 print()。这两种对话框都是异步显示的,即控制权会立即返回给脚本。用户在浏览器菜单上选择“查找” (find)和“打印” (print)时显示的就是这两种对话框。通过在 window对象上调用 find()和 print()可以显示它们,比如:

1
2
3
4
// 显示打印对话框
window.print();
// 显示查找对话框
window.find();

这两个方法不会返回任何有关用户在对话框中执行了什么操作的信息,因此很难加以利用。此外,
因为这两种对话框是异步的,所以浏览器的对话框计数器不会涉及它们,而且用户选择禁用对话框对它
们也没有影响。

12.2 location对象

location是最有用的BOM对象之一,提供了当前窗口中加载文档的信息,以及通常的导航功能。

这个对象独特的地方在于,它既是window的属性,也是document的属性。也就是说window.location和document.location指向同一个对象。location对象不仅保存着当前加载文档的信息,也保存着把URL解析为离散片段后能够通过属性访问的信息。这些解析后的属性在下表中有详细的说明(location前缀是必须的).

假设浏览器当前加载的URL是http://foouser:barpassword@www.wrox.com:80/WileyCDA/?q=javascript#contents,location对象的内容如下表所示。

20250707141954

12.2.1 查询字符串

location 的多数信息都可以通过上面的属性获取。但是 URL 中的查询字符串并不容易使用。虽然location.search返回了从问号开始直到 URL 末尾的所有内容,但没有办法逐个访问每个查询参数。下面的函数解析了查询字符串,并返回一个以每个查询参数为属性的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let getQueryStringArgs = function() {
// 取得没有开头问号的查询字符串
let qs = (location.search.length > 0 ? location.search.substring(1) : ""),
// 保存数据的对象
args = {};
// 把每个参数添加到 args对象
for (let item of qs.split("&").map(kv => kv.split("="))) {
let name = decodeURIComponent(item[0]),
value = decodeURIComponent(item[1]);
if (name.length) {
args[name] = value;
}
}
return args;
}

这个函数首先删除了查询字符串开头的问号,当然前提是 location.search必须有内容。解析后的参数将被保存到 args 对象,这个对象以字面量形式创建。接着,先把查询字符串按照&分割成数组,每个元素的形式为 name=value。for 循环迭代这个数组,将每一个元素按照=分割成数组,这个数组第一项是参数名,第二项是参数值。参数名和参数值在使用 decodeURIComponent()解码后(这是因为查询字符串通常是被编码后的格式)分别保存在 name和 value 变量中。最后,name 作为属性而 value
作为该属性的值被添加到 args对象。这个函数可以像下面这样使用:

1
2
3
4
// 假设查询字符串为?q=javascript&num=10
let args = getQueryStringArgs();
alert(args["q"]); // "javascript"
alert(args["num"]); // "10"

URLSearchParams

URLSearchParams 提供了一组标准 API 方法,通过它们可以检查和修改查询字符串。给URLSearchParams 构造函数传入一个查询字符串,就可以创建一个实例。这个实例上暴露了 get()、set()和 delete()等方法,可以对查询字符串执行相应操作。下面来看一个例子:

1
2
3
4
5
6
7
8
9
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"

大多数支持 URLSearchParams的浏览器也支持将 URLSearchParams的实例用作可迭代对象:

1
2
3
4
5
6
7
let qs = "?q=javascript&num=10";
let searchParams = new URLSearchParams(qs);
for (let param of searchParams) {
console.log(param);
}
// ["q", "javascript"]
// ["num", "10"]

12.2.2 操作地址

可以通过修改 location 对象修改浏览器的地址。首先,最常见的是使用 assign()方法并传入一个 URL,如下所示:

1
location.assign("http://www.wrox.com");

这行代码会立即启动导航到新 URL 的操作,同时在浏览器历史记录中增加一条记录。如果给location.href 或 window.location 设置一个 URL,也会以同一个 URL 值调用 assign()方法。比如,下面两行代码都会执行与显式调用 assign()一样的操作:

1
2
window.location = "http://www.wrox.com";
location.href = "http://www.wrox.com";

在这 3 种修改浏览器地址的方法中,设置 location.href 是最常见的。修改 location对象的属性也会修改当前加载的页面。其中, hash、 search、 hostname、 pathname
和 port属性被设置为新值之后都会修改当前 URL,如下面的例子所示:

1
2
3
4
5
6
7
8
9
10
11
// 假设当前 URL 为 http://www.wrox.com/WileyCDA/
// 把 URL 修改为 http://www.wrox.com/WileyCDA/#section1
location.hash = "#section1";
// 把 URL 修改为 http://www.wrox.com/WileyCDA/?q=javascript
location.search = "?q=javascript";
// 把 URL 修改为 http://www.somewhere.com/WileyCDA/
location.hostname = "www.somewhere.com";
// 把 URL 修改为 http://www.somewhere.com/mydir/
location.pathname = "mydir";
// 把 URL 修改为 http://www.somewhere.com:8080/WileyCDA/
location.port = 8080;

除了 hash 之外,只要修改 location 的一个属性,就会导致页面重新加载新 URL。

在以前面提到的方式修改 URL 之后,浏览器历史记录中就会增加相应的记录。当用户单击“后退”按钮时,就会导航到前一个页面。如果不希望增加历史记录,可以使用 replace()方法。这个方法接收一个 URL 参数,但重新加载后不会增加历史记录。调用 replace()之后,用户不能回到前一页。比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<title>You won't be able to get back here</title>
</head>
<body>
<p>Enjoy this page for a second, because you won't be coming back here.</p>
<script>
setTimeout(() => location.replace("http://www.wrox.com/"), 1000);
</script>
</body>
</html>

浏览器加载这个页面 1 秒之后会重定向到 www.wrox.com。此时,“后退”按钮是禁用状态,即不能返回这个示例页面,除非手动输入完整的 URL。

最后一个修改地址的方法是 reload(),它能重新加载当前显示的页面。调用 reload()而不传参数,页面会以最有效的方式重新加载。也就是说,如果页面自上次请求以来没有修改过,浏览器可能会从缓存中加载页面。如果想强制从服务器重新加载,可以像下面这样给 reload()传个 true:

1
2
location.reload(); // 重新加载,可能是从缓存加载
location.reload(true); // 重新加载,从服务器加载

脚本中位于 reload()调用之后的代码可能执行也可能不执行,这取决于网络延迟和系统资源等因素。为此,最好把 reload()作为最后一行代码。

12.3 navigator对象

navigator 是由 Netscape Navigator 2 最早引入浏览器的,现在已经成为客户端标识浏览器的标准。只要浏览器启用 JavaScript,navigator 对象就一定存在。但是与其他 BOM 对象一样,每个浏览器都支持自己的属性。

navigator 对 象 实 现 了 NavigatorID、 NavigatorLanguage、 NavigatorOnLine、NavigatorContentUtils、 NavigatorStorage、 NavigatorStorageUtils、 Navigator-ConcurrentHardware、NavigatorPlugins和 NavigatorUserMedia 接口定义的属性和方法。
下表列出了这些接口定义的属性和方法:

20250707145152

20250707145341

12.3.1 检测插件

检测浏览器是否安装了某个插件是开发中常见的需求。除 IE10 及更低版本外的浏览器,都可以通过 plugins数组来确定。这个数组中的每一项都包含如下属性。

  • name: 插件名称
  • description: 插件介绍
  • filename: 插件的文件名
  • length: 由当前插件处理的MIME类型数量

plugins 数组中的每个插件对象还有一个 MimeType对象,可以通过中括号访问。
每个 MimeType 对象有 4 个属性:description 描述 MIME 类型,enabledPlugin 是
指向插件对象的指针, suffixes是该 MIME 类型对应扩展名的逗号分隔的字符串, type
是完整的 MIME 类型字符串。

12.3.2 注册处理程序

现代浏览器支持 navigator 上的(在 HTML5 中定义的)registerProtocolHandler()方法。
这个方法可以把一个网站注册为处理某种特定类型信息应用程序。随着在线 RSS 阅读器和电子邮件客户端的流行,可以借助这个方法将 Web 应用程序注册为像桌面软件一样的默认应用程序。
要使用 registerProtocolHandler()方法,必须传入 3 个参数:要处理的协议(如”mailto”或”ftp”)、处理该协议的 URL,以及应用名称。比如,要把一个 Web 应用程序注册为默认邮件客户端,可以这样做:

1
2
3
navigator.registerProtocolHandler("mailto",
"http://www.somemailclient.com?cmd=%s",
"Some Mail Client");

这个例子为”mailto”协议注册了一个处理程序,这样邮件地址就可以通过指定的 Web 应用程序打开。注意,第二个参数是负责处理请求的 URL,%s表示原始的请求。

12.4 screen对象

window 的另一个属性 screen对象,是为数不多的几个在编程中很少用的 JavaScript 对象。这个对象中保存的纯粹是客户端能力信息,也就是浏览器窗口外面的客户端显示器的信息,比如像素宽度和像素高度。每个浏览器都会在 screen 对象上暴露不同的属性。下表总结了这些属性。

20250707150341

12.5 history对象

history 对象表示当前窗口首次使用以来用户的导航历史记录。因为 history是 window 的属性,所以每个 window 都有自己的 history 对象。出于安全考虑,这个对象不会暴露用户访问过的 URL,但可以通过它在不知道实际 URL 的情况下前进和后退。

12.5.1 导航

go()方法可以在用户历史记录中沿任何方向导航,可以前进也可以后退。这个方法只接收一个参数,这个参数可以是一个整数,表示前进或后退多少步。负值表示在历史记录中后退(类似点击浏览器的“后退”按钮),而正值表示在历史记录中前进(类似点击浏览器的“前进”按钮)。下面来看几个例子:

1
2
3
4
5
6
// 后退一页
history.go(-1);
// 前进一页
history.go(1);
// 前进两页
history.go(2);

go()有两个简写方法:back()和 forward()。顾名思义,这两个方法模拟了浏览器的后退按钮和前进按钮:

1
2
3
4
// 后退一页
history.back();
// 前进一页
history.forward();

history对象还有一个 length属性,表示历史记录中有多个条目。这个属性反映了历史记录的数量,包括可以前进和后退的页面。对于窗口或标签页中加载的第一个页面,history.length等于 1。通过以下方法测试这个值,可以确定用户浏览器的起点是不是你的页面:

1
2
3
if (history.length == 1){
// 这是用户窗口中的第一个页面
}

history对象通常被用于创建“后退”和“前进”按钮,以及确定页面是不是用户历史记录中的第一条记录。

注意 如果页面 URL 发生变化,则会在历史记录中生成一个新条目。对于 2009 年以来发
布的主流浏览器,这包括改变 URL 的散列值(因此,把 location.hash 设置为一个新
值会在这些浏览器的历史记录中增加一条记录) 。这个行为常被单页应用程序框架用来模
拟前进和后退,这样做是为了不会因导航而触发页面刷新。

12.5.2 历史状态管理

现代 Web 应用程序开发中最难的环节之一就是历史记录管理。用户每次点击都会触发页面刷新的时代早已过去,“后退”和“前进”按钮对用户来说就代表“帮我切换一个状态”的历史也就随之结束
了。为解决这个问题,首先出现的是 hashchange 事件(第 17 章介绍事件时会讨论)。HTML5 也为history对象增加了方便的状态管理特性。

hashchange 会在页面 URL 的散列变化时被触发,开发者可以在此时执行某些操作。而状态管理API 则可以让开发者改变浏览器 URL 而不会加载新页面。为此,可以使用 history.pushState()方法。这个方法接收 3 个参数:一个 state对象、一个新状态的标题和一个(可选的)相对 URL。例如:

1
2
3
let stateObject = {foo: "bar"};

history.pushState(stateObject, "My title", "baz.html");

pushState()方法执行后,状态信息就会被推到历史记录中,浏览器地址栏也会改变以反映新的相对 URL。除了这些变化之外,即使 location.href返回的是地址栏中的内容,浏览器页不会向服务器发送请求。第二个参数并未被当前实现所使用,因此既可以传一个空字符串也可以传一个短标题。第一个参数应该包含正确初始化页面状态所必需的信息。为防止滥用,这个状态的对象大小是有限制的,通常在 500KB~1MB 以内。

因为 pushState()会创建新的历史记录,所以也会相应地启用“后退”按钮。此时单击“后退”按钮,就会触发 window对象上的 popstate事件。 popstate事件的事件对象有一个 state属性,其中包含通过 pushState()第一个参数传入的 state对象:

1
2
3
4
5
6
window.addEventListener("popstate", (event) => {
let state = event.state;
if (state) { // 第一个页面加载时状态是 null
processState(state);
}
});

基于这个状态,应该把页面重置为状态对象所表示的状态(因为浏览器不会自动为你做这些) 。记住,页面初次加载时没有状态。因此点击“后退”按钮直到返回最初页面时, event.state会为 null。可以通过 history.state 获取当前的状态对象,也可以使用 replaceState()并传入 与pushState()同样的前两个参数来更新状态。更新状态不会创建新历史记录,只会覆盖当前状态:

1
history.replaceState({newFoo: "newBar"}, "New title");

传给 pushState()和 replaceState()的 state 对象应该只包含可以被序列化的信息。因此,DOM 元素之类并不适合放到状态对象里保存。

使用 HTML5 状态管理时,要确保通过 pushState()创建的每个“假”URL 背后
都对应着服务器上一个真实的物理 URL。否则,单击“刷新”按钮会导致 404 错误。所有
单页应用程序(SPA,Single Page Application)框架都必须通过服务器或客户端的某些配
置解决这个问题。

第13章 客户端检测

本章内容

  • 使用能力检测
  • 用户代理检测的历史
  • 软件与硬件检测
  • 检测策略

虽然浏览器厂商齐心协力想要实现一致的接口,但事实上仍然是每家浏览器都有自己的长处与不足。跨平台的浏览器尽管版本相同,但总会存在不同的问题。这些差异迫使 Web 开发者要么面向最大公约数而设计,要么(更常见地)使用各种方法来检测客户端,以克服或避免这些缺陷。

客户端检测一直是 Web 开发中饱受争议的话题,这些话题普遍围绕所有浏览器应支持一系列公共特性,理想情况下是这样的。而现实当中,浏览器之间的差异和莫名其妙的行为,让客户端检测变成一种补救措施,而且也成为了开发策略的重要一环。如今,浏览器之间的差异相对 IE 大溃败以前已经好很多了,但浏览器间的不一致性依旧是 Web 开发中的常见主题。

要检测当前的浏览器有很多方法,每一种都有各自的长处和不足。问题的关键在于知道客户端检测应该是解决问题的最后一个举措。任何时候,只要有更普适的方案可选,都应该毫不犹豫地选择。首先要设计最常用的方案,然后再考虑为特定的浏览器进行补救。

13.1 能力检测

能力检测(又称特性检测)即在 JavaScript 运行时中使用一套简单的检测逻辑,测试浏览器是否支持某种特性。这种方式不要求事先知道特定浏览器的信息,只需检测自己关心的能力是否存在即可。能力检测的基本模式如下:

1
2
3
if (object.propertyInQuestion) {
// 使用 object.propertyInQuestion
}

13.1.1 安全能力检测

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

13.2 用户代理检测

用户代理检测通过浏览器的用户代理字符串确定使用的是什么浏览器。用户代理字符串包含在每个HTTP请求的头部,在JavaScript中可以通过navigator.userAgent访问。在服务端,常见的做法是根据接收到的用户代理字符串确定浏览器并执行相应操作。而在客户端,用户代理检测被认为是不可靠的,只应该在没有其他选项时再考虑。

用户代理字符串最受争议的地方就是,在很长一段时间里,浏览器都通过在用户代理字符串包含错误或误导性信息来欺骗服务器。要理解背后的原因,必须回顾一下自 Web 出现之后用户代理字符串的历史。

13.2.1 用户代理的历史

13.3 软件与硬件检测

现代浏览器提供了一组与页面执行环境相关的信息,包括浏览器、操作系统、硬件和周边设备信息。
这些属性可以通过暴露在 window.navigator 上的一组 API 获得。不过,这些 API 的跨浏览器支持还不够好,远未达到标准化的程度。

13.3.1 识别浏览器和操作系统

特性检测和用户代理字符串解析是当前常用的两种识别浏览器的方式。而 navigator 和 screen对象也提供了关于页面所在软件环境的信息。

13.3.2 浏览器元数据

第14章 DOM

本章内容

  • 理解文档对象模型(DOM)的构成
  • 节点类型
  • 浏览器兼容性
  • MutationObserver接口

文档对象模型(DOM, Document Object Model)是HTML和XML文档的编程接口。DOM表示由多层节点构成的文档,通过它开发者可以添加,删除和修改页面的各个部分。

脱胎于网景和微软早期的动态 HTML(DHTML,Dynamic HTML),DOM 现在是真正跨平台、语言无关的表示和操作网页的方式。

DOM Level 1 在 1998 年成为 W3C 推荐标准,提供了基本文档结构和查询的接口。本章之所以介绍DOM,主要因为它与浏览器中的 HTML 网页相关,并且在 JavaScript 中提供了 DOM API。

14.1 节点层级

<html>元素我们称之为文档元素(documentElement).

14.1.1 Node类型

DOM Level1 描述了名为Node的接口,这个接口是所有DOM节点类型都必须实现的。Node接口在JavaScript中被实现为Node类型。JavaScript中,所有节点类型都继承Node类型,因此所有类型都共享相同的基本属性和方法。

每个节点都有nodeType属性,表示该节点的类型。节点类型由定义在Node类型上的12个数值常量表示

  • Node.ELEMENT_NODE: 1
  • Node.ATTRIBUTE_NODE: 2
  • Node.TEXT_NODE: 3
  • Node.CDATA_SECTION_NODE: 4
  • Node.ENTITY_REFERENCE_NODE: 5
  • Node.ENTITY_NODE: 6
  • Node.PROCESSING_INSTRUCTION_NODE: 7
  • Node.COMMENT_NODE: 8
  • Node.DOCUMENT_NODE: 9
  • Node.DOCUMENT_TYPE_NODE: 10
  • Node.DOCUMENT_FRAGMENT_NODE: 11
  • Node.NOTATION_NODE: 12

节点类型可通过与这些常量比较来确定,比如:

1
2
3
if (someNode.nodeType == Node.ELEMENT_NODE){
alert("Node is an element.");
}

这个例子比较了 someNode.nodeType 与 Node.ELEMENT_NODE 常量。如果两者相等,则意味着someNode 是一个元素节点。

浏览器并不支持所有节点类型。开发者最常用到的是元素节点和文本节点。本章后面会讨论每种节点受支持的程度及其用法。

  1. nodeName与nodeValue

nodeName 与 nodeValue 保存着有关节点的信息。这两个属性的值完全取决于节点类型。在使用
这两个属性前,最好先检测节点类型,如下所示:

1
2
3
if (someNode.nodeType == 1){
value = someNode.nodeName; // 会显示元素的标签名
}

在这个例子中,先检查了节点是不是元素。如果是,则将其 nodeName 的值赋给一个变量。对元素而言,nodeName始终等于元素的标签名,而 nodeValue则始终为 null。

  1. 节点关系

文档中的所有节点都与其他节点有关系。这些关系可以形容为家族关系,相当于把文档树比作家谱。在 HTML 中, <body>元素是<html>元素的子元素,而<html>元素则是<body>元素的父元素。 <head>元素是<body>元素的同胞元素,因为它们有共同的父元素<html>

每个节点都有一个 childNodes属性,其中包含一个 NodeList的实例。 NodeList是一个类数组对象,用于存储可以按位置存取的有序节点。注意,NodeList并不是 Array 的实例,但可以使用中括号访问它的值,而且它也有 length 属性。NodeList 对象独特的地方在于,它其实是一个对 DOM 结构的查询,因此 DOM 结构的变化会自动地在 NodeList中反映出来。我们通常说 NodeList 是实时的活动对象,而不是第一次访问时所获得内容的快照。(通过document.querySelectorAll()获取的是快照)

下面的例子展示了如何使用中括号或使用 item()方法访问 NodeList中的元素:

1
2
3
let firstChild = someNode.childNodes[0];
let secondChild = someNode.childNodes.item(1);
let count = someNode.childNodes.length;

无论是使用中括号还是 item()方法都是可以的,但多数开发者倾向于使用中括号,因为它是一个类数组对象。注意,length 属性表示那一时刻 NodeList 中节点的数量。使用 Array.prototype.slice()可以像前面介绍 arguments时一样把 NodeList对象转换为数组。比如

1
2
3
let arrayOfNodes = Array.prototype.slice.call(someNode.childNodes,0);
// 当然,使用 ES6 的 Array.from()静态方法,可以替换这种笨拙的方式:
let arrayOfNodes = Array.from(someNode.childNodes);

每个节点都有一个 parentNode 属性,指向其 DOM 树中的父元素。childNodes中的所有节点都有同一个父元素,因此它们的 parentNode属性都指向同一个节点。此外, childNodes列表中的每个节点都是同一列表中其他节点的同胞节点。而使用 previousSibling和 nextSibling 可以在这个列表的节点间导航。这个列表中第一个节点的 previousSibling 属性是 null,最后一个节点的nextSibling 属性也是 null,如下所示:

1
2
3
4
5
if (someNode.nextSibling === null){
alert("Last node in the parent's childNodes list.");
} else if (someNode.previousSibling === null){
alert("First node in the parent's childNodes list.");
}

注意,如果 childNodes中只有一个节点,则它的 previousSibling和 nextSibling属性都是null。

父节点和它的第一个及最后一个子节点也有专门属性:firstChild 和 lastChild 分别指向childNodes 中的第一个和最后一个子节点。someNode.firstChild 的值始终等于 someNode.

childNodes[0],而 someNode.lastChild 的值始终等于 someNode.childNodes[someNode.childNodes.length-1]。如果只有一个子节点,则 firstChild和 lastChild指向同一个节点。如果没有子节点,则 firstChild 和 lastChild 都是 null。

上述这些节点之间的关系为在文档树的节点之间导航提供了方便。图 14-2 形象地展示了这些关系。

20250707163944

有了这些关系,childNodes属性的作用远远不止是必备属性那么简单了。这是因为利用这些关系指针,几乎可以访问到文档树中的任何节点,而这种便利性是 childNodes的最大亮点。还有一个便利的方法是 hasChildNodes(),这个方法如果返回 true 则说明节点有一个或多个子节点。相比查询childNodes 的 length 属性,这个方法无疑更方便。

最后还有一个所有节点都共享的关系。ownerDocument 属性是一个指向代表整个文档的文档节点的指针。所有节点都被创建它们(或自己所在)的文档所拥有,因为一个节点不可能同时存在于两个或者多个文档中。这个属性为迅速访问文档节点提供了便利,因为无需在文档结构中逐层上溯了。

  1. 操纵节点

因为所有关系指针都是只读的,所以 DOM 又提供了一些操纵节点的方法。最常用的方法是appendChild(),用于在 childNodes 列表末尾添加节点。添加新节点会更新相关的关系指针,包括
父节点和之前的最后一个子节点。appendChild()方法返回新添加的节点,如下所示:

1
2
3
let returnedNode = someNode.appendChild(newNode);
alert(returnedNode == newNode); // true
alert(someNode.lastChild == newNode); // true

如果把文档中已经存在的节点传给 appendChild(),则这个节点会从之前的位置被转移到新位置。即使 DOM 树通过各种关系指针维系,一个节点也不会在文档中同时出现在两个或更多个地方。因此,如果调用 appendChild()传入父元素的第一个子节点,则这个节点会成为父元素的最后一个子节点,如下所示:

1
2
3
4
// 假设 someNode 有多个子节点
let returnedNode = someNode.appendChild(someNode.firstChild);
alert(returnedNode == someNode.firstChild); // false
alert(returnedNode == someNode.lastChild); // true

如果想把节点放到 childNodes 中的特定位置而不是末尾,则可以使用 insertBefore()方法。这个方法接收两个参数:要插入的节点和参照节点。调用这个方法后,要插入的节点会变成参照节点的前一个同胞节点,并被返回。如果参照节点是 null,则 insertBefore()appendChild()效果相同,如下面的例子所示:

1
2
3
4
5
6
7
8
9
10
// 作为最后一个子节点插入
returnedNode = someNode.insertBefore(newNode, null);
alert(newNode == someNode.lastChild); // true
// 作为新的第一个子节点插入
returnedNode = someNode.insertBefore(newNode, someNode.firstChild);
alert(returnedNode == newNode); // true
alert(newNode == someNode.firstChild); // true
// 插入最后一个子节点前面
returnedNode = someNode.insertBefore(newNode, someNode.lastChild);
alert(newNode == someNode.childNodes[someNode.childNodes.length - 2]); // true

appendChild()和 insertBefore()在 插 入 节 点 时 不 会 删 除 任 何 已 有 节 点 。 相 对 地 ,replaceChild()方法接收两个参数:要插入的节点和要替换的节点。要替换的节点会被返回并从文档树中完全移除,要插入的节点会取而代之。下面看一个例子:

1
2
3
4
// 替换第一个子节点
let returnedNode = someNode.replaceChild(newNode, someNode.firstChild);
// 替换最后一个子节点
returnedNode = someNode.replaceChild(newNode, someNode.lastChild);

使用 replaceChild()插入一个节点后,所有关系指针都会从被替换的节点复制过来。虽然被替换的节点从技术上说仍然被同一个文档所拥有,但文档中已经没有它的位置。要移除节点而不是替换节点,可以使用 removeChild()方法。这个方法接收一个参数,即要移除的节点。被移除的节点会被返回,如下面的例子所示:

1
2
3
4
// 删除第一个子节点
let formerFirstChild = someNode.removeChild(someNode.firstChild);
// 删除最后一个子节点
let formerLastChild = someNode.removeChild(someNode.lastChild);

与 replaceChild()方法一样,通过 removeChild()被移除的节点从技术上说仍然被同一个文档所拥有,但文档中已经没有它的位置。上面介绍的 4 个方法都用于操纵某个节点的子元素,也就是说使用它们之前必须先取得父节点(使用前面介绍的 parentNode 属性)。并非所有节点类型都有子节点,如果在不支持子节点的节点上调用这些方法,则会导致抛出错误。

  1. 其他方法

所有节点类型还共享了两个方法。第一个是 cloneNode(),会返回与调用它的节点一模一样的节点。cloneNode()方法接收一个布尔值参数,表示是否深复制。在传入 true参数时,会进行深复制,即复制节点及其整个子 DOM 树。如果传入 false,则只会复制调用该方法的节点。复制返回的节点属于文档所有,但尚未指定父节点,所以可称为孤儿节点(orphan) 。可以通过 appendChild()、insertBefore()或 replaceChild()方法把孤儿节点添加到文档中。以下面的 HTML 片段为例

1
2
3
4
5
<ul>
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
</ul>

如果myList保存着对这个<ul>元素的引用,则下列代码展示了使用cloneNode()方法的两种方式:

1
2
3
4
let deepList = myList.cloneNode(true);
alert(deepList.childNodes.length); // 3(IE9之前的版本)或 7(其他浏览器)
let shallowList = myList.cloneNode(false);
alert(shallowList.childNodes.length); // 0

cloneNode()方法不会复制添加到 DOM 节点的 JavaScript 属性,比如事件处理程序。这个方法只复制 HTML 属性,以及可选地复制子节点。除此之外则一概不会复制。
IE 在很长时间内会复制事件处理程序,这是一个 bug,所以推荐在复制前先删除事件处理程序。

本节要介绍的最后一个方法是 normalize()。这个方法唯一的任务就是处理文档子树中的文本节点。由于解析器实现的差异或 DOM 操作等原因,可能会出现并不包含文本的文本节点,或者文本节点之间互为同胞关系。在节点上调用 normalize()方法会检测这个节点的所有后代,从中搜索上述两种情形。如果发现空文本节点,则将其删除;如果两个同胞节点是相邻的,则将其合并为一个文本节点。
这个方法将在本章后面进一步讨论。

14.1.2 Document类型

14.1.3 Element类型

  1. HTML元素

  2. 取得属性

每个元素都有零个或多个属性,通常用于为元素或其内容附加更多信息。与属性相关的 DOM 方法主要有 3 个:

  • getAttribute()
  • setAttribute()
  • removeAttribute()

这些方法主要用于操纵属性,包括在 HTMLElement类型上定义的属性。下面看一个例子:

1
2
3
4
5
6
let div = document.getElementById("myDiv");
alert(div.getAttribute("id")); // "myDiv"
alert(div.getAttribute("class")); // "bd"
alert(div.getAttribute("title")); // "Body text"
alert(div.getAttribute("lang")); // "en"
alert(div.getAttribute("dir")); // "ltr"

注意传给 getAttribute()的属性名与它们实际的属性名是一样的,因此这里要传”class”而非”className” (className是作为对象属性时才那么拼写的) 。 如果给定的属性不存在, 则 getAttribute()返回 null。

getAttribute()方法也能取得不是 HTML 语言正式属性的自定义属性的值。比如下面的元素:
<div id="myDiv" my_special_attribute="hello!"></div>
这个元素有一个自定义属性 my_special_attribute,值为”hello!”。可以像其他属性一样使用getAttribute()取得这个属性的值:
let value = div.getAttribute("my_special_attribute");
注意,属性名不区分大小写,因此”ID”和”id”被认为是同一个属性。另外,根据 HTML5 规范的要求,自定义属性名应该前缀 data-以方便验证。

元素的所有属性也可以通过相应 DOM 元素对象的属性来取得。当然,这包括 HTMLElement 上定义的直接映射对应属性的 5 个属性,还有所有公认(非自定义)的属性也会被添加为 DOM 对象的属性。比如下面的例子:

1
<div id="myDiv" align="left" my_special_attribute="hello"></div>

因为 id 和 align在 HTML 中是

元素公认的属性,所以 DOM 对象上也会有这两个属性。但my_special_attribute 是自定义属性,因此不会成为 DOM 对象的属性。

通过 DOM 对象访问的属性中有两个返回的值跟使用 getAttribute()取得的值不一样。首先是style 属性,这个属性用于为元素设定 CSS 样式。在使用 getAttribute()访问 style 属性时,返回的是 CSS 字符串。而在通过 DOM 对象的属性访问时, style属性返回的是一个(CSSStyleDeclaration)对象。DOM 对象的 style 属性用于以编程方式读写元素样式,因此不会直接映射为元素中 style 属性的字符串值。

第二个属性其实是一类,即事件处理程序(或者事件属性),比如 onclick。在元素上使用事件属性时(比如 onclick),属性的值是一段 JavaScript 代码。如果使用 getAttribute()访问事件属性,则返回的是字符串形式的源代码。而通过 DOM 对象的属性访问事件属性时返回的则是一个 JavaScript函数(未指定该属性则返回 null)。这是因为 onclick及其他事件属性是可以接受函数作为值的。

考虑到以上差异, 开发者在进行 DOM 编程时通常会放弃使用 getAttribute()而只使用对象属性。getAttribute()主要用于取得自定义属性的值。

  1. 设置属性
  1. attributes属性

Element 类型是唯一使用 attributes 属性的 DOM 节点类型。attributes 属性包含一个NamedNodeMap 实例,是一个类似 NodeList的“实时”集合。元素的每个属性都表示为一个 Attr节点,并保存在这个 NamedNodeMap 对象中。NamedNodeMap 对象包含下列方法:

  • getNamedItem(name), 返回nodeName属性等于name的节点
  • removeNamedItem(name), 删除nodeName属性等于name的节点
  • setNamedItem(node), 向列表中添加node节点,以其nodeName为索引
  • item(pos), 返回索引位置pos处的节点
  1. 创建元素

  2. 元素后代

14.1.4 Text类型

  1. 创建文本节点

  2. 规范化文本节点

  3. 拆分文本节点

14.1.5 Comment类型

14.1.6 CDATASection类型

14.1.7 DocumentType类型

14.1.8 DocumentFragment类型

在所有节点类型中,DocumentFragment类型是唯一一个在标记中没有对应表示的类型。DOM 将文档 片 段定 义为 “ 轻量 级” 文 档, 能够 包 含和 操作 节 点, 却没 有 完整 文档 那 样额 外的 消 耗 。DocumentFragment 节点具有以下特征:

  • nodeType等于11
  • nodeName的值为”#document-fragment”;
  • nodeValue的值为null
  • parentNode的值为null
  • 子节点可以是Element,ProcessingInstruction,Comment,Text, CDATASection或EntityReference

不能直接把文档片段添加到文档。相反,文档片段的作用是充当其他要被添加到文档的节点的仓库。可以使用 document.createDocumentFragment()方法像下面这样创建文档片段:

1
let fragment = document.createDocumentFragment()

文档片段从 Node 类型继承了所有文档类型具备的可以执行 DOM 操作的方法。如果文档中的一个节点被添加到一个文档片段,则该节点会从文档树中移除,不会再被浏览器渲染。添加到文档片段的新节点同样不属于文档树,不会被浏览器渲染。可以通过 appendChild()或 insertBefore()方法将文档片段的内容添加到文档。在把文档片段作为参数传给这些方法时,这个文档片段的所有子节点会被添加到文档中相应的位置。文档片段本身永远不会被添加到文档树。以下面的 HTML 为例:

1
<ul id="myList"></ul>

假设想给这个<ul>元素添加 3 个列表项。如果分 3 次给这个元素添加列表项,浏览器就要重新渲染3 次页面,以反映新添加的内容。为避免多次渲染,下面的代码示例使用文档片段创建了所有列表项,然后一次性将它们添加到了<ul>元素:

1
2
3
4
5
6
7
8
let fragment = document.createDocumentFragment();
let ul = document.getElementById("myList");
for (let i = 0; i < 3; ++i) {
let li = document.createElement("li");
li.appendChild(document.createTextNode(`Item ${i + 1}`));
fragment.appendChild(li);
}
ul.appendChild(fragment);

这个例子先创建了一个文档片段,然后取得了<ul>元素的引用。接着通过 for 循环创建了 3 个列表项,每一项都包含表明自己身份的文本。为此先创建<li>元素,再创建文本节点并添加到该元素。然后通过 appendChild()把<li>元素添加到文档片段。循环结束后,通过把文档片段传给 appendChild()将所有列表项添加到了<ul>元素。此时,文档片段的子节点全部被转移到了<ul>元素。

14.1.9 Attr类型

14.2 DOM编程

14.2.1 动态脚本

14.2.2 动态样式

14.2.3 操作表格

14.2.4 使用NodeList

14.3 MotationObserver接口

14.3.1 基本用法

MutationObserver的实例要通过调用 MutationObserver构造函数并传入一个回调函数来创建:

1
let observer = new MutationObserver(() => console.log('DOM was mutated!'));
  1. observe()方法

新创建的MutationObserver实例不会关联DOM的任何部分。要把这个observer与DOM关联起来,需要使用observe()方法。这个方法接收两个必需的参数:要观察变化的DOM节点,以及一个MutationObserverInit对象。

MutationObserverInit对象用于控制观察的是哪些方面的变化,是一个键值对形式配置选项的字典。例如下面的代码会创建一个观察者(observer)并配置它观察<body>元素上的属性变化。

1
2
let observer = new MutationObserver(() => console.log('<body> attributes changed'));
observer.observe(document.body, {attributes: true});

执行以上代码后, 元素上任何属性发生变化都会被这个 MutationObserver实例发现,然后就会异步执行注册的回调函数。元素后代的修改或其他非属性修改都不会触发回调进入任务队列。可以通过以下代码来验证:

1
2
3
4
5
6
let observer = new MutationObserver(() => console.log('<body> attributes changed'));
observer.observe(document.body, { attributes: true });
document.body.className = 'foo';
console.log('Changed body class');
// Changed body class
// <body> attributes changed

注意,回调中的 console.log()是后执行的。这表明回调并非与实际的 DOM 变化同步执行。

  1. 回调与MutationRecord

每个回调都会收到一个 MutationRecord 实例的数组。MutationRecord 实例包含的信息包括发生了什么变化,以及 DOM 的哪一部分受到了影响。因为回调执行之前可能同时发生多个满足观察条件的事件,所以每次执行回调都会传入一个包含按顺序入队的 MutationRecord实例的数组。
下面展示了反映一个属性变化的 MutationRecord 实例的数组:

下面展示了反映一个属性变化的 MutationRecord 实例的数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let observer = new MutationObserver(
(mutationRecords) => console.log(mutationRecords);
)

observer.observe(documents.body, { attributes: true });

document.body.setAttribute('foo', 'bar');
// [
// {
// addedNodes: NodeList [],
// attributeName: "foo",
// attributeNamespace: null,
// nextSibling: null,
// oldValue: null,
// previousSibling: null
// removedNodes: NodeList [],
// target: body
// type: "attributes"
// }
// ]

下面是一次涉及命名空间的类似变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let observer = new MutationObserver(
(mutationRecords) => console.log(mutationRecords));
observer.observe(document.body, { attributes: true });
document.body.setAttributeNS('baz', 'foo', 'bar');
// [
// {
// addedNodes: NodeList [],
// attributeName: "foo",
// attributeNamespace: "baz",
// nextSibling: null,
// oldValue: null,
// previousSibling: null
// removedNodes: NodeList [],
// target: body
// type: "attributes"
// }
// ]

连续修改会生成多个 MutationRecord实例,下次回调执行时就会收到包含所有这些实例的数组,顺序为变化事件发生的顺序:

1
2
3
4
5
6
7
let observer = new MutationObserver(
(mutationRecords) => console.log(mutationRecords));
observer.observe(document.body, { attributes: true });
document.body.className = 'foo';
document.body.className = 'bar';
document.body.className = 'baz';
// [MutationRecord, MutationRecord, MutationRecord]

下表列出了 MutationRecord实例的属性。

20250707184000

20250707184010

传给回调函数的第二个参数是观察变化的 MutationObserver的实例,演示如下:

1
2
3
4
5
6
let observer = new MutationObserver(
(mutationRecords, mutationObserver) => console.log(mutationRecords,
mutationObserver));
observer.observe(document.body, { attributes: true });
document.body.className = 'foo';
// [MutationRecord], MutationObserver
  1. disconnect() 方法

默认情况下,只要被观察的元素不被垃圾回收, MutationObserver的回调就会响应 DOM 变化事件,从而被执行。要提前终止执行回调,可以调用 disconnect()方法。下面的例子演示了同步调用disconnect()之后,不仅会停止此后变化事件的回调,也会抛弃已经加入任务队列要异步执行的回调:

1
2
3
4
5
6
let observer = new MutationObserver(() => console.log('<body> attributes changed'));
observer.observe(document.body, { attributes: true });
document.body.className = 'foo';
observer.disconnect();
document.body.className = 'bar';
//(没有日志输出)

要想让已经加入任务队列的回调执行,可以使用 setTimeout()让已经入列的回调执行完毕再调用disconnect():

1
2
3
4
5
6
7
8
let observer = new MutationObserver(() => console.log('<body> attributes changed'));
observer.observe(document.body, { attributes: true });
document.body.className = 'foo';
setTimeout(() => {
observer.disconnect();
document.body.className = 'bar';
}, 0);
// <body> attributes changed
  1. 复用MutationObserver

多次调用 observe()方法,可以复用一个 MutationObserver 对象观察多个不同的目标节点。此时, MutationRecord的 target 属性可以标识发生变化事件的目标节点。下面的示例演示了这个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let observer = new MutationObserver(
(mutationRecords) => console.log(mutationRecords.map((x) =>
x.target)));
// 向页面主体添加两个子节点
let childA = document.createElement('div'),
childB = document.createElement('span');
document.body.appendChild(childA);
document.body.appendChild(childB);
// 观察两个子节点
observer.observe(childA, { attributes: true });
observer.observe(childB, { attributes: true });
// 修改两个子节点的属性
childA.setAttribute('foo', 'bar');
childB.setAttribute('foo', 'bar');
// [<div>, <span>]

disconnect()方法是一个“一刀切”的方案,调用它会停止观察所有目标:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let observer = new MutationObserver(
(mutationRecords) => console.log(mutationRecords.map((x) =>
x.target)));
// 向页面主体添加两个子节点
let childA = document.createElement('div'),
childB = document.createElement('span');
document.body.appendChild(childA);
document.body.appendChild(childB);
// 观察两个子节点
observer.observe(childA, { attributes: true });
observer.observe(childB, { attributes: true });
observer.disconnect();
// 修改两个子节点的属性
childA.setAttribute('foo', 'bar');
childB.setAttribute('foo', 'bar');
// (没有日志输出)
  1. 重用MutationObserver

调用 disconnect()并不会结束 MutationObserver 的生命。还可以重新使用这个观察者,再将它关联到新的目标节点。下面的示例在两个连续的异步块中先断开然后又恢复了观察者与<body>元素的关联:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let observer = new MutationObserver(() => console.log('<body> attributes
changed'));
observer.observe(document.body, { attributes: true });
// 这行代码会触发变化事件
document.body.setAttribute('foo', 'bar');
setTimeout(() => {
observer.disconnect();
// 这行代码不会触发变化事件
document.body.setAttribute('bar', 'baz');
}, 0);
setTimeout(() => {
// Reattach
observer.observe(document.body, { attributes: true });
// 这行代码会触发变化事件
document.body.setAttribute('baz', 'qux');
}, 0);
// <body> attributes changed
// <body> attributes changed

14.3.2 MutationObserverInit与观察范围

MutationObserverInit 对象用于控制对目标节点的观察范围。粗略地讲,观察者可以观察的事件包括属性变化、文本变化和子节点变化。
下表列出了 MutationObserverInit对象的属性。

20250707184636

20250707184647

注意 在调用 observe()时,MutationObserverInit对象中的 attribute、characterData
和 childList 属性必须至少有一项为 true(无论是直接设置这几个属性,还是通过设置
attributeOldValue 等属性间接导致它们的值转换为 true)。否则会抛出错误,因为没
有任何变化事件可能触发回调。

第15章 DOM扩展

本章内容

  • 理解Selectors API
  • 使用HTML5 DOM扩展

15.1 SelectorsAPI

15.2 querySelectorAll

15.3 HTML5

15.3.1 CSS类扩展

  1. getElementsByClassName()

15.3.2 焦点管理

15.3.3 HTMLDocument扩展

  1. readyState属性

document.readyState属性有两个可能的值

  • loading: 表示文档正在加载
  • complete: 表示文档加载完成

实际开发中,最好是把 document.readyState当成一个指示器,以判断文档是否加载完毕。在这个属性得到广泛支持以前,通常要依赖 onload事件处理程序设置一个标记,表示文档加载完了。这个属性的基本用法如下:

1
2
3
if (document.readyState == "complete"){
// 执行操作
}

15.3.6 插入标记

15.3.7 scrollIntoView()

DOM规范中没有设计的一个问题是如何滚动页面中的某个区域。为填充这方面的缺失,不同浏览器实现了不同的控制滚动方式。HTML5标准化了scrollIntoView()

scrollIntoView()方法存在于所有 HTML 元素上,可以滚动浏览器窗口或容器元素以便包含元素进入视口。这个方法的参数如下:

  • alignToTop 是一个布尔值
    • true:窗口滚动后元素的顶部与视口顶部对齐
    • false:窗口滚动后元素的底部与视口底部对齐
  • scrollIntoViewOptions是一个选项对象
    • behavior: 定义过渡动画,可取的值有”smooth”和”auto”,默认为”auto”
    • block: 定义垂直方向的对齐,可取的值为”start”,”center”,”end”和”nearest”,默认为”start”
    • inline: 定义水平方向的对齐,可取的值为”start”, “center”, “end”和”nearest”,默认为”nearest”

不传参数等同于alignToTop为true.

1
2
3
4
5
6
7
// 确保元素可见
document.forms[0].scrollIntoView();
// 同上
document.forms[0].scrollIntoView(true);
document.forms[0].scrollIntoView({block: 'start'});
// 尝试将元素平滑地滚入视口
document.forms[0].scrollIntoView({behavior: 'smooth', block: 'start'});

第16章 DOM2和DOM3

本章内容

  • DOM2到DOM3的变化
  • 操作样式的DOMAPI
  • DOM遍历与范围

第17章 事件

本章内容

  • 理解事件流
  • 使用事件处理程序
  • 了解不同类型的事件

17.1 事件流

17.1.1 事件冒泡

17.1.2 事件捕获

17.1.3 DOM事件流

17.2 事件处理程序

事件意味着用户或浏览器执行的某种动作。比如,单击(click) 、加载(load) 、鼠标悬停(mouseover) 。为响应事件而调用的函数被称为事件处理程序(或事件监听器) 。事件处理程序的名字以”on”开头,因此 click 事件的处理程序叫作 onclick,而 load 事件的处理程序叫作 onload。有很多方式可以指定事件处理程序。

17.2.1 HTML事件处理程序

17.2.2 DOM0事件处理程序

17.2.3 DOM2事件处理程序

DOM2 Events 为事件处理程序的赋值和移除定义了两个方法:addEventListener()和 remove-EventListener()。这两个方法暴露在所有 DOM 节点上,它们接收 3 个参数:事件名、事件处理函数和一个布尔值,true 表示在捕获阶段调用事件处理程序,false(默认值)表示在冒泡阶段调用事件处理程序。

17.3 事件对象

在 DOM 中发生事件时,所有相关信息都会被收集并存储在一个名为 event的对象中。这个对象包含了一些基本信息,比如导致事件的元素、发生的事件类型,以及可能与特定事件相关的任何其他数据。例如,鼠标操作导致的事件会生成鼠标位置信息,而键盘操作导致的事件会生成与被按下的键有关的信息。所有浏览器都支持这个 event 对象,尽管支持方式不同。

17.3.1 DOM事件对象

在 DOM 合规的浏览器中,event 对象是传给事件处理程序的唯一参数。不管以哪种方式(DOM0或 DOM2)指定事件处理程序,都会传入这个 event 对象。下面的例子展示了在两种方式下都可以使用事件对象:

1
2
3
4
5
6
7
8
let btn = document.getElementById("myBtn");
btn.onclick = function(event) {
console.log(event.type); // "click"
}

btn.addEventListener("click", (event) => {
console.log(event.type); // "click"
}, false)

这个例子中的两个事件处理程序都会在控制台打出 event.type属性包含的事件类型。这个属性中始终包含被触发事件的类型,如”click” (与传给 addEventListener()和 removeEventListener()方法的事件名一致)。在通过 HTML 属性指定的事件处理程序中,同样可以使用变量 event 引用事件对象。下面的例子中演示了如何使用这个变量:

1
<input type="button" value="Click Me" onclick="console.log(event.type)">

以这种方式提供 event 对象,可以让 HTML 属性中的代码实现与 JavaScript 函数同样的功能。如前所述,事件对象包含与特定事件相关的属性和方法。不同的事件生成的事件对象也会包含不同的属性和方法。不过,所有事件对象都会包含下表列出的这些公共属性和方法。

20250707224807

20250707224818

在事件处理程序内部,this 对象始终等于 currentTarget 的值,而 target 只包含事件的实际目标。如果事件处理程序直接添加在了意图的目标,则 this、 currentTarget 和 target的值是一样的。下面的例子展示了这两个属性都等于 this 的情形:

1
2
3
4
5
let btn = document.getElementById("myBtn");
btn.onclick = function(event) {
console.log(event.currentTarget === this); // true
console.log(event.target === this); // true
};

上面的代码检测了 currentTarget 和 target的值是否等于 this。因为 click 事件的目标是按钮,所以这 3 个值是相等的。如果这个事件处理程序是添加到按钮的父节点(如 document.body)上,那么它们的值就不一样了。比如下面的例子在 document.body上添加了单击处理程序:

1
2
3
4
5
document.body.onclick = function(event) {
console.log(event.currentTarget === document.body); // true
console.log(this === document.body); // true
console.log(event.target === document.getElementById("myBtn")); // true
};

这种情况下点击按钮, this 和 currentTarget都等于 document.body,这是因为它是注册事件处理程序的元素。而 target 属性等于按钮本身,这是因为那才是 click 事件真正的目标。由于按钮本身并没有注册事件处理程序,因此 click事件冒泡到 document.body,从而触发了在它上面注册的处理程序。

type 属性在一个处理程序处理多个事件时很有用。比如下面的处理程序中就使用了 event.type:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let btn = document.getElementById("myBtn");
let handler = function(event) {
switch(event.type) {
case "click":
console.log("Clicked");
break;
case "mouseover":
event.target.style.backgroundColor = "red";
break;
case "mouseout":
event.target.style.backgroundColor = "";
break;
}
};
btn.onclick = handler;
btn.onmouseover = handler;
btn.onmouseout = handler;

在这个例子中,函数 handler 被用于处理 3 种不同的事件:click、mouseover 和 mouseout。当按钮被点击时,应该在控制台打印一条消息,如前面的例子所示。而把鼠标放到按钮上,会导致按钮背景变成红色,接着把鼠标从按钮上移开,背景颜色应该又恢复成默认值。这个函数使用 event.type属性确定了事件类型,从而可以做出不同的响应。

preventDefault()方法用于阻止特定事件的默认动作。比如,链接的默认行为就是在被单击时导航到 href 属性指定的 URL。如果想阻止这个导航行为,可以在 onclick 事件处理程序中取消,如下面的例子所示:

1
2
3
4
let link = document.getElementById("myLink");
link.onclick = function(event) {
event.
}

任何可以通过 preventDefault()取消默认行为的事件,其事件对象的 cancelable 属性都会设置为 true.

stopPropagation()方法用于立即阻止事件流在 DOM 结构中传播,取消后续的事件捕获或冒泡。例如,直接添加到按钮的事件处理程序中调用 stopPropagation(),可以阻止 document.body上注册的事件处理程序执行。比如:

1
2
3
4
5
6
7
8
9
let btn = document.getElementById("myBtn");
btn.onclick = function(event) {
console.log("Clicked");
event.stopPropagation();
}

document.body.onclick = function(event) {
console.log("Body clicked");
}

如果这个例子中不调用 stopPropagation(),那么点击按钮就会打印两条消息。但这里由于 click事件不会传播到 document.body,因此 onclick事件处理程序永远不会执行。
eventPhase 属性可用于确定事件流当前所处的阶段。如果事件处理程序在捕获阶段被调用,则eventPhase 等于 1;如果事件处理程序在目标上被调用,则 eventPhase 等于 2;如果事件处理程序在冒泡阶段被调用,则 eventPhase等于 3。不过要注意的是,虽然“到达目标”是在冒泡阶段发生的,但其 eventPhase仍然等于 2。下面的例子展示了 eventPhase在不同阶段的值:

1
2
3
4
5
6
7
8
9
10
11
12
let btn = document.getElementById("myBtn");
btn.onclick = function(event) {
console.log(event.eventPhase); // 2
}

document.body.addEventListener("click", (event) => {
console.log(event.eventPhase); // 1
}, true);

document.body.onclick = (event) => {
console.log(event.eventPhase); // 3
}

在这个例子中,点击按钮首先会触发注册在捕获阶段的 document.body 上的事件处理程序,显示 eventPhase 为 1。接着,会触发按钮本身的事件处理程序(尽管是注册在冒泡阶段),此时显
示 eventPhase 等于 2。最后触发的是注册在冒泡阶段的 document.body 上的事件处理程序,显示eventPhase 为 3。而当 eventPhase等于 2 时,this、target和 currentTarget 三者相等。

event对象只在事件处理程序执行期间存在,一旦执行完毕,就会被销毁。

17.4 事件类型

DOM3 Events定义了如下数据类型

  • 用户界面事件(UIEvent): 设计与BOM交互的通用浏览器事件
  • 焦点事件(FocusEvent): 在元素获得和失去焦点时触发
  • 鼠标事件(MouseEvent): 使用鼠标在页面上执行某些操作时触发
  • 滚轮事件(WheelEvent): 使用鼠标滚轮(或类似设备)时触发。
  • 输入事件(InputEvent): 向文档中输入文本时触发
  • 键盘事件(KeyboardEvent): 使用键盘在页面上执行某些操作时触发。
  • 合成事件(CompositionEvent): 在使用某种IME(Input Method Editor, 输入法编辑器)输入字符时触发。

17.4.7 HTML5事件

  1. contextmenu事件

  2. beforeunload事件

  3. DomContentLoaded事件

window 的 load 事件会在页面完全加载后触发,因为要等待很多外部资源加载完成,所以会花费较长时间。而 DOMContentLoaded 事件会在 DOM 树构建完成后立即触发,而不用等待图片、 JavaScript文件、 CSS 文件或其他资源加载完成。相对于 load事件, DOMContentLoaded可以让开发者在外部资源下载的同时就能指定事件处理程序,从而让用户能够更快地与页面交互。
要处理 DOMContentLoaded 事件,需要给 document 或 window 添加事件处理程序(实际的事件目标是 document,但会冒泡到 window)。下面是一个在 document 上监听 DOMContentLoaded 事件的例子:

1
2
3
document.addEventListener("DOMContentLoaded", (event) => {
console.log("Content loaded");
});

DOMContentLoaded 事件的 event对象中不包含任何额外信息(除了 target 等于 document)。DOMContentLoaded事件通常用于添加事件处理程序或执行其他 DOM 操作。这个事件始终在 load事件之前触发。对于不支持 DOMContentLoaded 事件的浏览器,可以使用超时为 0 的 setTimeout()函数,通过其回调来设置事件处理程序,比如:

  1. readystatechange事件

  2. pageshow与pagehide事件

Firefox 和 Opera 开发了一个名为往返缓存(bfcache,back-forward cache)的功能,此功能旨在使用浏览器“前进”和“后退”按钮时加快页面之间的切换。这个缓存不仅存储页面数据,也存储 DOM 和JavaScript 状态,实际上是把整个页面都保存在内存里。如果页面在缓存中,那么导航到这个页面时就不会触发 load 事件。通常,这不会导致什么问题,因为整个页面状态都被保存起来了。不过,Firefx决定提供一些事件,把往返缓存的行为暴露出来。
第一个事件是 pageshow,其会在页面显示时触发,无论是否来自往返缓存。在新加载的页面上,pageshow 会在 load 事件之后触发;在来自往返缓存的页面上,pageshow 会在页面状态完全恢复后触发。注意,虽然这个事件的目标是 document,但事件处理程序必须添加到 window 上。下面的例子展示了追踪这些事件的代码:

  1. hashchange事件

17.4.8 设备事件

17.5 内存与性能

17.5.1 事件委托

17.5.2 删除事件处理程序

第18章 动画与Canvas

本章内容

  • 使用requestAnimationFrame
  • 理解元素
  • 绘制简单2D图形
  • 使用WebGL绘制3D图形

图形和动画已经日益成为浏览器中现代 Web 应用程序的必备功能,但实现起来仍然比较困难。视觉上复杂的功能要求性能调优和硬件加速,不能拖慢浏览器。目前已经有一套日趋完善的 API 和工具可以用来开发此类功能。

毋庸置疑,是 HTML5 最受欢迎的新特性。这个元素会占据一块页面区域,让 JavaScript可以动态在上面绘制图片。最早是苹果公司提出并准备用在控制面板中的,随着其他浏览器的迅速跟进,HTML5 将其纳入标准。目前所有主流浏览器都在某种程度上支持元素。

与浏览器环境中的其他部分一样,自身提供了一些 API,但并非所有浏览器都支持这些API,其中包括支持基础绘图能力的 2D 上下文和被称为 WebGL 的 3D 上下文。支持的浏览器的最新版本现在都支持 2D 上下文和 WebGL。

18.1 使用requestAnimationFrame

18.1.1 早期定时动画

虽然使用 setInterval()的定时动画比使用多个 setTimeout()实现循环效率更高,但也不是没有问题。无论 setInterval()还是 setTimeout()都是不能保证时间精度的。作为第二个参数的延时只能保证何时会把代码添加到浏览器的任务队列,不能保证添加到队列就会立即运行。如果队列前面还有其他任务,那么就要等这些任务执行完再执行。简单来讲,这里毫秒延时并不是说何时这些代码会执行,而只是说到时候会把回调加到任务队列。如果添加到队列后,主线程还被其他任务占用,比如正在处理用户操作,那么回调就不会马上执行。

18.1.2 时间间隔问题

知道何时绘制下一帧是创造平滑动画的关键。直到几年前,都没有办法确切保证何时能让浏览器把下一帧绘制出来。随着的流行和 HTML5 游戏的兴起,开发者发现 setInterval()和
setTimeout()的不精确是个大问题。浏览器自身计时器的精度让这个问题雪上加霜。浏览器的计时器精度不足毫秒。以下是几个浏览器计时器的精度情况:

18.1.3 requestAnimationFrame

requestAnimationFrame()方法接收一个参数,此参数是一个要在重绘屏幕前调用的函数。这个函数就是修改DOM样式以反映下一次重绘有什么变化的地方。为了实现动画循环,可以把多个requestAnimatitonFrame()调用串联起来,就像以前使用setTimeout()时一样

1
2
3
4
5
6
7
8
function updateProgress() {
const div = document.getElementById("status");
div.style.width = (parseInt(div.style.width, 10) + 5) + "%";
if (div.style.left != "100%") {
requestAnimationFrame(updateProgress);
}
}
requestAnimationFrame(updateProgress);

第19章 表单脚本

本章内容

  • 理解表单基础
  • 文本框验证与交互
  • 使用其他表单控件

19.1 表单基础

Web 表单在 HTML 中以

元素表示,在 JavaScript 中则以 HTMLFormElement 类型表示。HTMLFormElement 类型继承自 HTMLElement类型,因此拥有与其他 HTML 元素一样的默认属性。不过,HTMLFormElement也有自己的属性和方法。

  • acceptCharset: 服务器可以接收的字符集,等价于HTML的accept-charset属性
  • action: 请求的URL,等价于HTML的action属性
  • elements: 表单中所有控件的HTMLCollection
  • enctype: 请求的编码类型,等价于HTML的enctype属性
  • length: 表单中控件的数量
  • method: HTTP请求的方法类型,通常是“get”或“post”,等价于HTML的method属性
  • name: 表单的名字,等价于HTML的name属性
  • reset(): 把表单字段重置为各自的默认值
  • submit(): 提交表单
  • target: 用于发送请求和接收响应的窗口名字,等价于HTML的target属性

19.1.1 提交表单

表单是通过用户点击提交按钮或图片按钮的方式提交的。提交按钮可以使用 type属性为”submit”的<input><button>元素来定义,图片按钮可以使用 type属性为”image”的<input>元素来定义。点击下面例子中定义的所有按钮都可以提交它们所在的表单:

1
2
3
4
5
6
<!-- 通用提交按钮 -->
<input type="submit" value="Submit Form">
<!-- 自定义提交按钮 -->
<button type="submit">Submit Form</button>
<!-- 图片按钮 -->
<input type="image" src="graphic.gif">

如果表单中有上述任何一个按钮,且焦点在表单中某个控件上,则按回车键也可以提交表单。(textarea 控件是个例外,当焦点在它上面时,按回车键会换行。)注意,没有提交按钮的表单在按回车键时不会提交。

以这种方式提交表单会在向服务器发送请求之前触发 submit事件。这样就提供了一个验证表单数据的机会,可以根据验证结果决定是否真的要提交。阻止这个事件的默认行为可以取消提交表单。例如,下面的代码会阻止表单提交:

1
2
3
4
5
let form = document.getElementById("myForm");
form.addEventListener("submit", (event) => {
// 阻止表单提交
event.preventDefault();
});

调用 preventDefault()方法可以阻止表单提交。通常,在表单数据无效以及不应该发送到服务器时可以这样处理。当然,也可以通过编程方式在 JavaScript 中调用 submit()方法来提交表单。可以在任何时候调用这个方法来提交表单,而且表单中不存在提交按钮也不影响表单提交。下面是一个例子:

1
2
3
let form = document.getElementById("myForm");
// 提交表单
form.submit();

通过 submit()提交表单时,submit事件不会触发。因此在调用这个方法前要先做数据验证。通过 submit()提交表单时,submit事件不会触发。因此在调用这个方法前要先做数据验证。

表单提交的一个最大的问题是可能会提交两次表单。如果提交表单之后没有什么反应,那么没有耐心的用户可能会多次点击提交按钮。结果是很烦人的(因为服务器要处理重复的请求),甚至可能造成损失(如果用户正在购物,则可能会多次下单)。解决这个问题主要有两种方式:在表单提交后禁用提交按钮,或者通过 onsubmit事件处理程序取消之后的表单提交。

19.1.2 重置表单

用户单击重置按钮可以重置表单。重置按钮可以使用 type属性为”reset”的<input><button>元素来创建,比如:

1
2
3
4
<!-- 通用重置按钮 -->
<input type="reset" value="Reset Form">
<!-- 自定义重置按钮 -->
<button type="reset">Reset Form</button>

这两种按钮都可以重置表单。表单重置后,所有表单字段都会重置回页面第一次渲染时各自拥有的值。如果字段原来是空的,就会变成空的;如果字段有默认值,则恢复为默认值。
用户单击重置按钮重置表单会触发 reset 事件。这个事件为取消重置提供了机会。例如,以下代码演示了如何阻止重置表单:

1
2
3
4
let form = document.getElementById("myForm");
form.addEventListener("reset", (event) => {
event.preventDefault();
});

与表单提交一样,重置表单也可以通过 JavaScript 调用 reset()方法来完成,如下面的例子所示:

1
2
3
4
let form = document.getElementById("myForm");

// 重置表单
form.resest();

19.1.3 表单字段

  1. 表单字段的公共属性
  • disabled
  • form
  • name
  • readOnly
  • tabIndex
  • type
  • value
  1. 表单字段的公共方法
  • focus()
  • blur()
  1. 表单字段的公共事件
  • blur
  • change
  • focus

第20章 JavaScript API

第21章 错误处理与调试

第22章 处理XML

第23章 JSON

23.1 语法

JSON语法支持表示3种类型的值

  • 简单值: 字符串,数值,布尔值和null,可以在JSON中实现,就像在JavaScript中一样。特殊值undefined不可以
  • 对象:第一种复杂数据类型,对象表示有序键值对。每个值可以简单值,也可以是复杂数据类型。
  • 数组:第二种复杂数据类型,数组表示可以通过数值索引访问的值的有序列表。数组的值可以是任意类型,包括简单值,对象,甚至其他数组。

JSON没有变量,函数或对象实例的概念。JSON的所有记号都只为表示结构化数据,虽然它借用了JavaScript语法,但是千万不要把它和JavaScript混淆。

23.1.1 简单值

JSON字符串必须使用双引号。单引号会导致语法错误。

23.1.2 对象

JSON中对象必须使用双引号把属性名包围起来

没有变量声明(JSON)中没有变量
没有分号,不需要,因为不是JavaScript语句

23.2 解析与序列化

23.2.1 JSON对象

23.2.2 序列化选项

JSON.stringify()方法除了要序列化对象,还可以接受两个参数。这两个参数可以指定其他序列化JavaScript对象的方式。第一个参数是过滤器,可以是数组或函数。第二个参数是用于缩进结果JSON字符串的选项。

  1. 过滤结果

如果第二个参数是一个数组,那么JSON.stringify()返回的结果只会包含该数组中列出的对象属性。比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
let book = {
title: "Professional JavaScript",
authors: [
'Nicholas C. Zakas',
'Matt Frisbie'
],
edition: 4,
year: 2017
};

let jsonText = JSON.stringify(book, ["title", "edition"])

在这个例子中,JSON.stringify()方法的第二个参数是一个包含两个字符串的数组:”title”和”edition”。它们对应着要序列化的对象中的属性,因此结果 JSON 字符串中只会包含这两个属性:{“title”:”Professional JavaScript”,”edition”:4}

如果第二个参数是一个函数,则行为又有不同。提供的函数接收两个参数:属性名(key)属性值(value)。可以根据这个 key 决定要对相应属性执行什么操作。这个 key始终是字符串,只是在值不属于某个键/值对时会是空字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let book = {
title: "Professional JavaScript",
authors: [
"Nicholas C. Zakas",
"Matt Frisbie"
],
edition: 4,
year: 2017
};

let jsonText = JSON.stringify(book, (key, value) => {
switch (key) {
case "authors":
return value.join(",")
case "year":
return 5000;
case "edition":
return undefined; // 值为undefined的属性会被完全忽略
default:
return value;
}
});

console.log(jsonText);

这个函数基于键进行了过滤。如果键是”authors”,则将数组值转换为字符串;如果键是”year”,则将值设置为 5000;如果键是”edition”,则返回 undefined 忽略该属性。最后一定要提供默认返
回值,以便返回其他属性传入的值。第一次调用这个函数实际上会传入空字符串 key,值是 book 对象。最终得到的 JSON 字符串是这样的:

1
2
{"title":"Professional JavaScript","authors":"Nicholas C. Zakas,Matt
Frisbie","year":5000}

注意,函数过滤器会应用到要序列化的对象所包含的所有对象,因此如果数组中包含多个具有这些属性的对象,则序列化之后每个对象都只会剩下上面这些属性。

  1. 字符串缩进

JSON.stringify()方法的第三个参数控制缩进和空格。在这个参数是数值时,表示每一级缩进的空格数。如果缩进参数是一个字符串而非数值,那么JSON字符串中就会使用这个字符串而不是空格来缩进。使用字符串,也可以将缩进字符设置为Tab或任意字符,如两个连字符。

注意,除了缩进,JSON.stringify()方法还为方便阅读插入了换行符。这个行为对于所有有效的缩进参数都会发生。最大缩进值为10,大于10的值会被自动设置为10

  1. toJSON()方法

有时候,对象需要在 JSON.stringify()之上自定义 JSON 序列化。此时,可以在要序列化的对象中添加 toJSON()方法,序列化时会基于这个方法返回适当的 JSON 表示。事实上,原生 Date 对象就
有一个 toJSON()方法,能够自动将 JavaScript 的 Date 对象转换为 ISO 8601 日期字符串(本质上与在Date 对象上调用 toISOString()方法一样)。

下面的对象为自定义序列化而添加了一个 toJSON()方法:

1
2
3
4
5
6
7
8
9
10
11
12
let book = {
title: "Professional JavaScript",
authors: [
"Nicholas C. Zakas",
"Matt Frisbie"
],
edition: 4,
year: 2017,
toJSON: function() {
return this.title;
}
};

这里book对象中定义的toJSON()方法简单地返回了图书的书名(this.title)。与Date对象类型,这个对象会被序列化为简单的字符串而非对象。toJSON()方法可以返回任意序列化值,都可以起到相应的作用。如果对象被嵌入在另一个对象中,返回undefined会导致值变成null.或者如果是顶级对象,则本身就是undefined.注意,**箭头函数不能用来定义toJSON方法。主要原因是箭头函数的词法作用域是全局作用域,在这种情况下不合适。

toJSON()方法可以和过滤函数一起使用,因此理解不同序列化流程的顺序非常重要。在把对象传给JSON.stringify()时会执行如下步骤:

  1. 如果可以获取实际的值,则调用toJSON()方法来获取实际的值,否则使用默认的序列化。
  2. 如果提供了第二个参数,则应用过滤。传入过滤函数的值就是第1步返回的值。
  3. 第2步返回的每个值都会相应地进行序列化。
  4. 如果提供了第三个参数,则相应地进行缩进。

23.2.3 解析选项

JSON.parse()方法也可以接收一个额外的参数,这个函数会针对每个键/值对都调用一次。为区别传给JSON.stringify()的起过滤作用的替代函数,这个函数被成为还原函数

实际上他们的格式完全一样,即还原参数也接收两个参数,key和value,另外也需要返回值。

如果还原函数返回undefined,则结果中就会删除相应的键。如果返回了其他任何值,该值就会成为相应键的值插入到结果中。还原函数还经常用于把日期字符串转换为Date对象。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let book = {
title: "Professional JavaScript",
authors: [
"Nicholas C. Zakas",
"Matt Frisbie"
],
edition: 4,
year: 2017,
releaseDate: new Date(2017, 11, 1)
};
let jsonText = JSON.stringify(book);
let bookCopy = JSON.parse(jsonText,
(key, value) => key == "releaseDate" ? new Date(value) : value);
console.log(bookCopy.releaseDate.getFullYear());

以上代码在 book 对象中增加了 releaseDate 属性,是一个 Date 对象。这个对象在被序列化为JSON 字符串后,又被重新解析为一个对象 bookCopy。这里的还原函数会查找”releaseDate”键,如
果找到就会根据它的日期字符串创建新的 Date 对象。得到的 bookCopy.releaseDate 属性又变回了Date 对象,因此可以调用其 getFullYear()方法。

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

本章内容

  • 使用XMLHttpRequest对象
  • 使用XMLHttpRequest事件
  • 源域Ajax限制
  • Fetch API
  • Streams API

24.1 XMLHttpRequst对象

24.1.1 使用XHR

使用XHR对象首先要调用open()方法,这个方法接收3个参数,请求类型,请求URL,以及表示请求是否异步的布尔值。下面是一个例子:

1
2
let xhr = new XMLHttpRequest();
xhr.open("get", "example.php", false);

这行代码就可以向 example.php 发送一个同步的 GET 请求。关于这行代码需要说明几点。首先,这里的 URL 是相对于代码所在页面的,当然也可以使用绝对 URL。其次,调用 open()不会实际发送请求,只是为发送请求做好准备

只能访问同源URL,也就是域名相同,端口相同,协议相同。如果请求的URL与发送请求的页面在任何方面有所不同,则抛出安全错误。

要发送定义好的数据,必须像下面这样调用send()方法

1
2
xhr.open("get", "example.txt", false);
xhr.send(null);

send()方法接收一个参数,是作为请求体发送的数据。如果不需要发送请求体,则必须传null,因为这个参数在浏览器中是必须的。调用send()方法之后,请求就会传送到服务器。

因为这个请求是同步的,所以JavaScript代码会等待服务器响应后再继续执行。收到响应后,XHR对象的以下属性会被填充上数据

  • responseText: 作为响应体返回的文本。
  • responseXML: 如果响应的内容类型是text/xml或application/xml,那就是包含响应数据的XML DOM文档。
  • status:响应的HTTP状态
  • statusText:响应的HTTP状态描述

收到响应后,第一步要检查status属性确保响应成功返回。一般来说,HTTP状态码为2xx表示成功。此时responseText或responseXML属性中会有内容。如果HTTP状态码是304,则表示资源未修改过,是从浏览器缓存中直接拿取的。当然这也意味着响应有效。

虽然可以像前面的例子一样发送同步请求,但多数情况下最好使用异步请求,这样可以不阻塞JavaScript 代码继续执行。 XHR 对象有一个 readyState属性,表示当前处在请求/响应过程的哪个阶段。
这个属性有如下可能的值。

  • 0: 未初始化(Uninitialized)。尚未调用open()方法
  • 1: 已打开(Open)。已调用open()方法,尚未调用send()方法
  • 2: 已发送(Sent)。已调用send()方法,尚未收到响应。
  • 3: 接受中(Receiving)。已经收到部分响应
  • 4: 完成(Complete)。已经收到所有响应,可以使用了。

每次readyState从一个值变成另一个值,都会触发readystatechange事件。可以借此机会检查readyState的的值。一般来说,我们唯一关心的readyState的值是4,表示数据已就绪。为保证跨浏览器兼容,onreadystatechange事件处理程序应该在调用open之前赋值

1
2
3
4
5
6
7
8
9
10
11
12
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
}
};
xhr.open("get", "example.txt", true);
xhr.send(null);

以上代码使用 DOM Level 0 风格为 XHR 对象添加了事件处理程序,因为并不是所有浏览器都支持DOM Level 2 风格。与其他事件处理程序不同,onreadystatechange 事件处理程序不会收到 event
对象。在事件处理程序中,必须使用 XHR 对象本身来确定接下来该做什么

由于 onreadystatechange 事件处理程序的作用域问题,这个例子在 onready-statechange 事件处理程序中使用了 xhr 对象而不是 this 对象。使用 this 可能导致
功能失败或导致错误,取决于用户使用的是什么浏览器。因此还是使用保存 XHR 对象的变量更保险一些。

如果想在收到响应之前取消异步请求,可以调用abort()方法

调用这个方法之后,XHR对象会停止触发事件,并阻止访问这个对象上任何与响应相关的属性。中断请求后,应该取消对XHR对象的引用。由于内存问题,不推荐重用XHR对象。

24.1.2 HTTP头部

每个HTTP请求和响应都会携带一些头部字段,这些字段可能对开发者会有用。XHR对象会通过一些方法暴露与请求和响应相关的头部字段。

默认情况下XHR会发送这些头部字段

  • Accept: 浏览器可以处理的内容类型
  • Accept-Charset: 浏览器可以显示的字符集
  • Accept-Encodin: 浏览器可以处理的压缩编码类型
  • Acdept-Language: 浏览器使用的语言
  • Connection: 浏览器与服务器的连接类型
  • Cookie: 页面中设置的Cookie
  • Host: 发送请求的页面所在的域
  • Referer: 发送请求的页面的URI。注意,这个字段在HTTP规范中就拼写错误了,所以考虑到兼容性也将错就错。(正确的拼写应该是referrer).
  • User-Agen: 浏览器的用户代理字符串

如果需要发送额外的请求头部,可以使用setRequestHeader()方法。这个方法接收两个参数:头部字段的名称和值。为保证请求头部被发送,必须在open()后,send()前调用setRequestHeader(),如下例子:

1
2
3
4
5
6
7
8
9
10
11
let xhr = new XMLHttpRequest();

xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
console.log(xhr.responseText);
}
};

xhr.open("GET", "/api/test", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(null);

服务器通过读取自定义头部可以确定适当的操作。自定义头部一定要区别于浏览器正常发送的头部,否则可能影响服务器正常响应。有些浏览器允许重写默认头部,有些浏览器则不允许。

可以使用getResponseHeader()方法从XHR对象获取响应头部,只要传入要获取头部的名称即可。如果想要取得所有响应头部,可以使用getAllResponseHeaders()方法,这个方法会返回包含所有响应头部的字符串。下面是调用这两个方法的例子:

1
2
let myHeader = xhr.getResponseHeader("Content-Type");
let allHeaders = xhr.getAllResponseHeaders();

服务器可以使用头部向浏览器传递额外的结构化数据。 getAllResponseHeaders()方法通常返回类似如下的字符串:

1
2
3
4
5
6
Date: Sun, 14 Nov 2004 18:04:03 GMT
Server: Apache/1.3.29 (Unix)
Vary: Accept
X-Powered-By: PHP/4.3.8
Connection: close
Content-Type: text/html; charset=iso-8859-1

24.1.3 GET请求

最常用的请求方法是 GET 请求,用于向服务器查询某些信息。必要时,需要在 GET 请求的 URL后面添加查询字符串参数。对 XHR 而言,查询字符串必须正确编码后添加到 URL 后面,然后再传给open()方法。

24.1.4 POST请求

默认情况下,对服务器而言,POST请求与提交表单是不一样的。服务器逻辑需要读取原始POST数据才能取得浏览器发送的数据。不过,可以使用XHR模拟表单提交。为此,第一步需要把Content-Type头部设置为"application/x-www-formurlencoded"。这是提交表单时使用的内容类型。第二步是创建对应格式的字符串。POST数据此时使用与查询字符串相同的格式。如果网页中确实有一个表单需要序列化并通过XHR发送到服务器,则可以使用第14章的serialize()函数来创建相应的字符串。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function submitData() {
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
}
};
xhr.open("post", "postexample.php", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
let form = document.getElementById("user-info");
xhr.send(serialize(form));
}

24.1.5 XMLHttpRequest Level 2

  1. FormData类型
1
2
let data = new FormData();
data.append("name", "Nicholas");
  1. 超时
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
try {
} else {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
alert("Request was unsuccessful: " + xhr.status);
}
} catch (ex) {
// 假设由 ontimeout 处理
}
}
};
xhr.open("get", "timeout.php", true);
xhr.timeout = 1000; // 设置 1秒超时
xhr.ontimeout = function() {
alert("Request did not return in a second.");
};
xhr.send(null);
  1. overrideMimeType()方法

24.2 进度事件

  • loadstart: 在接收到响应的第一个字节时触发
  • progress: 在接收响应期间反复触发
  • error: 在请求出错时触发
  • abort: 在调用abort()终止连接时触发。
  • load: 在成功接收完响应时触发
  • loadend: 在通信完成时,且在error,abort或load之后触发

每次请求都会首先触发loadstart事件,之后是一个或多个progress事件,接着是error, abort或load中的一个,最后以loadend事件结束。

24.2.1 laod事件

nload 事件处理程序会收到一个 event 对象,其 target 属性设置为 XHR 实例,在这个实例上可以访问所有 XHR 对象属性和方法。不过,并不是所有浏览器都实现了这个事件的 event对象。考虑到跨浏览器兼容,还是需要像下面这样使用 XHR 对象变量:

24.2.2 progress事件

24.3 跨域资源共享

注意,无论请求还是响应都不会包含 cookie 信息。

现代浏览器通过 XMLHttpRequest 对象原生支持 CORS。在尝试访问不同源的资源时,这个行为会被自动触发。要向不同域的源发送请求,可以使用标准 XHR 对象并给 open()方法传入一个绝对 URL,比如:

1
2
3
4
5
6
7
8
9
10
11
12
let xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304) {
alert(xhr.responseText);
} else {
alert("Request was unsuccessful: " + xhr.status);
}
}
};
xhr.open("get", "http://www.somewhere-else.com/page/", true);
xhr.send(null);

跨域 XHR 对象允许访问 status和 statusText 属性,也允许同步请求。出于安全考虑,跨域 XHR对象也施加了一些额外限制

  • 不能使用setRequestHeader()设置自定义头部
  • 不能发送和接收cookie
  • getAllResponseHeaders()方法始终返回空字符串

24.3.1 预检请求

CORS通过一种叫预检请求(preflighted request)的服务器验证机制,允许使用自定义头部,除GET和POST之外的方法。以及不同请求体内容类型。在要发送涉及上述某种高级选项的请求时,会先向服务器发送一个“预检”请求。这个请求使用OPTIONS方法发送并包含以下头部。

  • Origin: 与简单请求相同
  • Access-Control-Request-Method: 请求希望使用的方法
  • Access-Control-Request-Headers(可选): 要使用逗号分隔的自定义头部列表

下面是一个假设的POST请求,包含自定义的NCZ头部:

1
2
3
Origin: http://www.nczonline.net
Access-Control-Request-Method: POST
Access-Control-Request-Headers: NCZ

在这个请求发送之后,服务器可以确定是否允许这种类型的请求,服务器会通过在响应中发送如下头部与浏览器沟通这些信息。

  • Access-Control-Allow-Origin: 与简单请求相同
  • Aceess-Control-Allow-Methods: 允许的方法(逗号分隔的列表)
  • Access-Control-Allow-Headers: 服务器允许的头部(逗号分隔的列表)
  • Access-Control-Max-Age: 缓存预检请求的秒数

预检请求返回后,结果会按响应指定的时间缓存一段时间。换句话说,只有第一次发送这种类型的请求时才会多发送一次额外的HTTP请求。

24.3.2 凭据请求

默认情况下跨源请求不提供凭据(cookie, HTTP认证和客户端SSL证书)。可以通过将withCredentials属性设置为true来表明请求会发送凭据。如果服务器允许带凭据的请求,那么可以在响应中包含如下的HTTP头部。

Access-Control-Allow-Credentials: true

如果发送了凭据请求而服务器返回的响应中没有这个头部,则浏览器不会把响应交给JavaScript。(responseText是空字符串, status是0, onerror()被调用)。注意,服务器也可以在预检请求的响应中发送这个HTTP头部,以表明这个源允许发送凭据请求。

24.4 替代性跨源技术

24.4.1 图片探测

24.4.2 JSONP

JSONP格式包含两部分:回调和数据。回调是页面接收到响应之后调用的函数,通常回调函数的名称是通过请求来动态指定的。而数据就是作为参数传给回调函数的JSON数据。下面是一个典型的JSONP请求

1
http://freeegeoip.net/json/?callback=handleResponse

这个JSONP请求的URL是一个地理位置服务。JSONP服务通常支持以查询字符串形式指定回调函数的名称。比如这个例子就把回调函数的名字指定为handleResponse()

JSONP调用是通过动态创建<script>元素并为其src属性指定跨域URL实现的。此时的<script><img>元素类型,能够不受限制地从其他源加载资源。因为JSONP是有效的JavaScript,所以JSONP响应在被加载完成后会立即执行。

1
2
3
4
5
6
7
8
function handleResponse(response) {
console.log(response);
}

let script = document.createElement("script");
script.src = "https://api.example.com/data?callback=handleResponse";
document.body.appendChild(script);

服务器会返回一段JavaScript脚本,在其中直接调用handleResponse函数,并将响应作为参数传递。

24.5 Fetch API

XMLHttpRequest可以选择异步,但是Fetch API必须是异步。

24.4.1 基本用法

fetch()方法是在暴露在全局作用域中的,包括主页面执行线程,模块和工作线程。调用这个方法,浏览器就会向给定的URL发送请求。

  1. 分派请求

fetch()只有一个必须的参数input.多数情况下,这个参数是要获取资源的URL。这个方法返回一个Promise。

1
2
let r = fetch('/bar');
console.log(r); // Promise<pending>

URL的格式(相对路径,绝对路径等)的解释与XHR对象一样。

请求完成,资源可用时,Proimse会resolve一个Response对象。这个对象是API封装,可以通过它取得相应的资源。获取资源要使用这个对象的属性和方法,掌握响应的情况并将负载转换为有用的形式。

1
2
3
4
5
6
fetch('bar.txt')
.then((res) => {
console.log(response);
});

// Response {type: "basic", url: ...}
  1. 读取响应
  1. 处理状态码和请求失败
  1. 自定义选项

只使用URL时,fetch()会发送GET请求,只包含最低限度的请求头。要进一步配置如何发送请求,需要传入可选的第二个参数init对象。init对象要按照下面的键值对进行填充

  • body: 指定使用请求体时请求体的内容,必须是Blob, BufferSource, FormData, URLSearchParams, ReadableStream或String的实例。
  • cache: 用于控制浏览器与HTTP缓存的交互。要跟踪缓存的重定向,请求的redirect属性必须是follow,而且必须符合同源策略限制。必须是下列值之一。
    • Default
      • fetch()返回命中的有效缓存。不发送请求。
      • 命中无效(stale)缓存会发送条件式请求。如果响应已经改变,则更新缓存的值。然后fetch()返回缓存的值。
      • 未命中缓存会发送请求,并缓存响应。然后fetch()返回响应。
    • no-store
      • 浏览器不检查缓存,直接发送请求
      • 不缓存响应,直接fetch()返回
    • reload
      • 浏览器不检查缓存,直接发送请求
      • 缓存响应,在通过fetch()返回
    • no-cache
      • 无论命中有效缓存还是无效缓存都会发送条件式请求。如果响应已经改变,则更新缓存的值。然后fetch()返回缓存的值。
      • 未命中缓存会发送请求,并缓存响应。然后fetch()返回响应。
    • force-cache
      • 无论命中有效缓存还是无效缓存都通过fetch()返回。不发送请求。
      • 未命中缓存会发送请求,并缓存响应。然后fetch()返回响应
    • only-if-cached
      • 只在请求模式为same-origin时使用缓存
      • 无论命中有效缓存还是无效缓存都通过fetch()返回。不发送请求。
      • 未命中缓存返回状态码为504(网关超时)的响应。
        默认为default
  • credentials: 用于指定在外发请求中如何包含cookie。与XMLHttpRequest的withCredentials标签类似。必须是下列字符串之一:
    • omit: 不发送cookie
    • same-origin: 只在请求URL与发送fetch()请求的页面同源时发送cookie
    • include: 无论同源还是跨源都包含cookie
      默认为same-origin
  • headers: 用于指定请求头部
    • 必须是Headers对象实例或包含字符串格式键值对的常规对象。
    • 默认值为不包含键值对的Headers对象。这不意味着请求不包含任何头部,浏览器仍然会随请求发送一些头部。虽然这些头部对JavaScript不可见,但浏览器的网络检查器可以观察到。
  • integrity: 用于强制子资源的完整性。
    • 必须是包含子资源完整性标识符的字符串
    • 默认为空字符串
  • keepalive: 用于指示浏览器允许请求存在时间超出页面生命周期。适合报告事件或分析,比如页面在fetch()请求后很快卸载。设置keepalive标志的fetch()请求可用于替代Navigator.sendBeacon()
    • 必须是布尔值
    • 默认为false
  • method: 用于指定HTTP请求的方法,通常是如下字符串:
    • GET
    • PSOT
    • PUT
    • PATCH
    • DELETE
    • HEAD
    • OPTIONS
    • CONNECT
    • TARCE
      默认为GET
  • mode: 用于指定请求模式。这个模式决定来自跨源请求的响应是否有效,以及客户端可以读取多少响应。违反这里指定的模式会抛出错误。必须是下列字符串之一
    • cors: 允许遵循CORS协议的跨域请求。响应是CORS过滤的响应。意思是响应中可以访问的浏览器头部是经过浏览器强制白名单过滤的
    • no-cors: 允许不需要发送预检请求的跨源请求(HEAD, GET和只带有满足CORS请求头部的POST),响应类型是opaque,意思是不能读取响应内容。
    • same-origin: 任何跨源请求都不允许发送
    • navigate: 用于支持HTML导航,只在文档间导航时使用。基本用不到。
      在通过构造函数手动创建Request实例时默认为cors,否则,默认为no-cors
  • redirect: 用于指定如何处理重定向响应(状态码为301, 302, 303, 307或308)
    • 必须是下列字符串值之一
    • follow:跟踪重定向请求,以最终非重定向URL的响应作为最终响应。
    • error: 重定向请求会抛出错误
    • manaul: 不跟踪重定向请求,而是返回opaqueredirect类型的响应,同时仍然暴露期望的重定向URL。允许以手动的方式跟踪重定向。
      默认为follow
  • referrer: 用于指定HTTP的Referer头部的内容
    • 必须是下列字符串值之一
    • no-referrer: 以no-referrer作为值
    • client/about:client: 以当前URL或no-referrer(取决于来源策略referrerPolicy)作为值
    • <URL>: 以伪造URL作为值。伪造URL的源必须与执行脚本的源匹配。
      默认为client/about:client
  • referrerPolicy: 用于指定HTTP的Referer头部。
    • 必须是下列字符串值之一
    • no-referrer: 请求中不包含Referer头部
    • no-referrer-when-downgrade
      • 对于从安全HTTPS上下文发送到HTTP URL的请求,不包含Rederer头部
      • 对于所有其他请求,将Referer设置为完整的URl
    • origin: 对于所有请求,将Referer设置为只包含源
    • same-origin:
      • 对于跨源请求,不包含Referer头部
      • 对于同源请求,将Referer设置为完整URL
    • strict-origin

-signal: 用于支持通过AbortController中断进行中的fetch()请求

  • 必须是AbortSignal实例
  • 默认为未关联控制器的AbortSignal实例

24.5.4 Request对象

24.5.5 Response对象

24.5.6 Request, Response及Body混入

24.6 Beacon API

24.7 Web Socket

24.7.1 API

要创建一个新的Web Socket,就要实例化一个WebSocket对象并传入提供连接的URL。

1
let socket = new WebSocket("ws://www.example.com/server.php");

注意,必须给WebSocket构造函数传入一个绝对URL。同源策略不适用于WebSocket,因此可以打开到任意站点的连接。至于是否来自特定源的页面通信,则完全取决于服务器。(在握手阶段就可以确定请求来自哪里。)

浏览器在初始化WebSocket对象之后会立即创建连接。与XHR类似,WebSocket也有一个readyState属性表示当前状态。不过,这个值与XHR中相应的值不一样。

  • WebSocket.OPENING(0): 连接正在建立
  • WebSocket.OPEN(1): 连接已经建立
  • WebSocket.CLOSING(2): 连接正在关闭
  • WebSocket.CLOSE(3): 连接已经关闭

WebSocket对象没有readystatechange事件,而是有与上述不同状态对应的其他事件。readyState的值从0开始。

任何时候都可以调用close()方法关闭Web Socket连接。

1
socket.close()

调用close()后,readyState立即变为2(连接正在关闭),并会在关闭后变为3(连接已经关闭)

24.7.2 发送和接收数据

使用send()方法并传入一个字符串,ArrayBuffer或Blob.

1
2
3
4
5
6
7
let socket = new WebSocket("ws://www.example.com/server.php");
let stringData = "Hello world!";
let arrayBufferData = Uint8Array.from(['f', 'o', 'o']);
let blobData = new Blob(['f', 'o', 'o']);
socket.send(stringData);
socket.send(arrayBufferData.buffer);
socket.send(blobData);

服务器向客户端发送消息时,WebSocket 对象上会触发 message 事件。这个 message 事件与其他消息协议类似,可以通过 event.data属性访问到有效载荷:

1
2
3
4
socket.onmessage = function(event) {
let data = event.data;
// 对数据执行某些操作
};

与通过 send()方法发送的数据类似,event.data返回的数据也可能是 ArrayBuffer 或 Blob。这由 WebSocket 对象的 binaryType属性决定,该属性可能是”blob”或”arraybuffer”。

24.7.3 其他事件

WebSocket对象在连接生命周期中有可能触发3个其他事件

  • open: 在连接成功时触发
  • error: 在发生错误时触发,连接无法存续
  • close: 在连接关闭时触发

WebSocket对象不支持DOM Level2事件监听器,因此需要使用DOM Level0风格的事件处理程序来监听这些事件。

1
2
3
4
5
6
7
8
9
10
let socket = new WebSocket("ws://www.example.com/server.php");
socket.onopen = function() {
alert("Connection established.");
};
socket.onerror = function() {
alert("Connection error.");
};
socket.onclose = function() {
alert("Connection closed.");
};

这些事件中,只有 close 事件的 event 对象上有额外信息。这个对象上有 3 个额外属性:wasClean、code和 reason。其中,wasClean是一个布尔值,表示连接是否干净地关闭;code是一个来自服务器的数值状态码;reason 是一个字符串,包含服务器发来的消息。可以将这些信息显示给用户或记录到日志:

1
2
3
4
socket.onclose = function(event) {
console.log(`as clean? ${event.wasClean} Code=${event.code} Reason=${
event.reason}`);
};

24.8 安全

第25章 客户端存储

本章内容

  • cookie
  • 浏览器存储API
  • IndexedDB

HTTP cookie 也叫做cookie,最初用于在客户端存储会话信息。这个规范要求服务器在响应HTTP请求时,通过发送Set-CookieHTTP头部包含会话信息。例如,下民啊是一个包含这个头部的HTTP响应:

1
2
3
4
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value
Other-header: other-header-value

这个HTTP响应会设置一个名为”name”,值为”value”的cookie。名和值在发送时都会经过URL编码。浏览器会存储这些会话信息,并在之后的每个请求中都会通过HTTP头部cookie再将他们发回服务器,比如:

1
2
3
GET /index.jsl HTTP/1.1
Cookie: name=value
Other-header: other-header-value

这些发送回服务器的额外信息可用于唯一标识发送请求的客户端

25.1.1 限制

cookie是与特定域绑定的。设置cookie后,它会请求一起发送到创建它的域。这个限制能保证cookie中存储的信息只对被认可的接受者开放,不会被其他域访问。

因为cookie存储在客户端机器上,所以为保证它不被恶意利用,浏览器会施加限制。同时,cookie也不会占用太多磁盘空间。

通常,要遵循以下限制

  • 不超过300个cookie
  • 每个cookie不超过4KB
  • 每个域不超过20个cookie
  • 每个域不超过80KB

25.1.2 cookie的构成

cookie在浏览器中由以下参数构成的

  • 名称:唯一标识cookie的名称。cookie名不区分大小写。但是服务器可能区分。cookie名必须经过URL编码
  • 值:存储在cookie里的字符串值。这个值必须经过URL编码
  • 域:cookie有效的域。发送到这个域的所有请求都会包含对应的cookie。这个值可能包含子域,也可以不包含。如果不明确设置,则默认为设置cookie的域。
  • 路径:请求URL中包含这个路径才会把cookie发送到服务器。
  • 过期时间:标识何时删除cookie的时间戳。默认情况下,浏览器回话结束后会删除所有cookie。不过,也可以设置删除cookie的时间。这个值时GMT格式,用于指定删除cookie的具体时间,这样即使关闭浏览器,cookie也会保留在用户机器上。
  • 安全标志:设置之后,只在使用SSL安全连接的情况下才会把cookie发送到服务器。

这些参数在Set-Cookie头部中使用分号加空格隔开。例如:

1
2
3
4
HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; expires=Mon, 22-Jan-07 07:10:24 GMT; domain=.wrox.com
Other-header: other-header-value

安全标志secure是cookie中唯一的非键值对,只需要一个secure就可以了。比如:

1
2
3
4
HTTP/1.1 200 OK
Content-type text/html
Set-Cookie: name=value; domain=.wrox.com; path=/; secure
Other-header: other-header-value

这里创建的cookie对所有wrox.com的子域以及该域中所有的页面都有效。不过,该cookie只能在SSL连接上发送,因为设置了secure标志。

域,路径,过期时间和secure标志用于告诉浏览器什么情况下应该在请求中包含cookie。这些参数并不会随请求发送给服务器,实际发送的只有cookie键值对

25.1.3 JavaScript中的cookie

在 JavaScript 中处理 cookie 比较麻烦,因为接口过于简单,只有 BOM 的 document.cookie属性。根据用法不同,该属性的表现迥异。要使用该属性获取值时,document.cookie返回包含页面中所有有效 cookie 的字符串(根据域、路径、过期时间和安全设置),以分号分隔,如下面的例子所示:

1
name1=value1; name2=value2; name3=value3

所有的键值对都是使用URL编码的,因此必须使用decodeURIComponent()解码。

在设置值时,可以通过document.cookie属性设置新的cookie字符串。这个字符串在被解析后会添加到原有的cookie中。设置document.cookie不会覆盖之前存在的任何cookie,除非设置了已有的cookie。设置cookie的格式如下,域Set-Cookie头部的格式一样。

1
name=value; expires=expiration_time; path=domain_path; domain=domain_name; secure

在所有这些参数中,只有cookie的名称和值是必须的.

1
document.cookie = "name+Nicholas";

25.1.4 子cookie

为了绕过浏览器对每个域cookie数的限制,有些开发者提出了子cookie的概念。子cookie是在单个cookie存储的小块数据,本质上是使用cookie的值在单个cooki中存储多个键值对。
最常见的子cookie的模式如下:

1
name=name1=value1&name2=value2&name3=value3&name4=value4&name5=value5

子cookie的格式类似于查询字符串。这些值可以存储为单个cookie,而不用单独存储为自己的键值对。

25.1.5 使用cookie的注意事项

嗨哟一种叫做HTTP-only的cookie。可以在浏览器设置,也可以在服务器设置。但是只能在服务器上读取。JavaScript无法读取这种cookie值。

因为所有 cookie 都会作为请求头部由浏览器发送给服务器,所以在 cookie 中保存大量信息可能会影响特定域浏览器请求的性能。保存的 cookie 越大,请求完成的时间就越长。即使浏览器对 cookie 大小有限制,最好还是尽可能只通过 cookie 保存必要信息,以避免性能问题。对 cookie 的限制及其特性决定了 cookie 并不是存储大量数据的理想方式。因此,其他客户端存储技术出现了。

25.2 Web Storage

25.2.1 Storage类型

Storage类型用于保存键值对数据,直至存储空间上限(由浏览器决定)。Storage的实例与其他对象一样,但增加了以下方法。

  • clear(): 删除所有值
  • getItem(name): 取得给定name的值
  • key(index): 取得给定数值位置的名称
  • removeItem(name): 删除给定name的键值对
  • setItem(name, value): 设置给定name的值

通过length属性可以确定Storage对象中保存了多少键值对。

Storage类型只能存储字符串。非字符串数据在存储之前会自动转换为字符串。这种转换不能在获取数据是还原

25.2.2 sessionStorage对象

sessionStorage对象值存储会话数据,这意味着数据只会存储到浏览器关闭。这跟浏览器关闭时会消失的会话cookie类似。存储在sessionStorage中的数据不受页面刷新影响,可以在浏览器崩溃并重启后恢复。

因为 sessionStorage 对象与服务器会话紧密相关,所以在运行本地文件时不能使用。存储在sessionStorage 对象中的数据只能由最初存储数据的页面使用,在多页应用程序中的用处有限。

因为 sessionStorage 对象是 Storage 的实例,所以可以通过使用 setItem()方法或直接给属
性赋值给它添加数据。下面是使用这两种方式的例子:

1
2
3
4
// 使用方法存储数据
sessionStorage.setItem("name", "Nicholas");
// 使用属性存储数据
sessionStorage.book = "Professional JavaScript";

存储写入时采用同步阻塞方式。因此数据会被立即提交到存储

25.2.3 localStorage对象

要访问同一个localStorage对象,页面必须来自同一个域(子域不可以),在相同的端口上使用相同的协议。

25.2.4 存储事件

每当Storage对象发生变化时,都会在文档上触发storage事件。使用属性或setItem()设置值,使用delete或removeItem()删除值,以及每次调用clear()时都会触发这个事件。这个事件的事件对象有如下4个属性

  • domain: 存储变化对应的域
  • key: 被设置的键
  • newValue: 键被设置的新值,若键被删除,则为null
  • oldValue: 键变化之前的值
1
2
3
window.addEventListener("storage", (event) => {
console.log("Storage changed for ${event.domain}")
})

对于 sessionStorage 和 localStorage 上的任何更改都会触发 storage事件,但 storage事件不会区分这两者。

25.2.5 限制

大多数限制每个源为5MB的空间。

25.3 IndexedDB

IndexedDB的设计几乎完全是异步的。为此,大多数操作以请求的形式执行,这些请求会异步执行,产生成功的结果或错误。绝大多数的IndexedDB操作要求添加onerror和onsuccess事件处理程序来确定输出。

25.3.1 数据库

INdexedDB使用对象存储而不是表格保存数据。IndexedDB数据库就是在一个公共命名空间下的一组对象存储,类似于NoSQL风格的实现。

使用IndexedDB数据库的第一步是调用indexedDB.open()方法,并给它传入一个要打开的数据库名称.如果给定名称的数据库已存在,则会发送一个打开它的请求;如果它不存在,则会发送创建并打开这个数据库的请求。这个方法会返回IDBRequest的实例,可以在这个实例上添加onerror和onsuccess事件处理程序。

1
2
3
4
5
6
7
8
9
10
let db,
request,
version = 1;
request = indexedDB.open("admin", version);
request.onerror = (event) => {
console.log(`Failed to open: ${event.target.errorCode}`)
}
request.onsuccess = (event) => {
db = event.target.result;
}

所有与数据库有关的操作都要通过db对象本身来进行。

25.3.2 对象存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let user = {
userName: "007",
firstName: "James",
lastName
}

request.onupgradeneeded = (event) => {
const db = event.target.result;
// 如果存在则删除当前 objectStore。测试的时候可以这样做
// 但这样会在每次执行事件处理程序时删除已有数据
if (db.objectStoreNames.contains("users")) {
db.deleteObjectStore("users");
}
db.createObjectStore("users", { keyPath: "username" });

}

这里的第二个参数keyPath属性表示应该用作键的存储对象的属性名。即指定对象中的唯一key

25.3.3 事务

创建了对象存储后,剩下的所有操作都是通过事务完成的。事务要通过调用数据库的transaction()方法创建。任何时候,只要想要读取或修改数据,都要通过事务把所有修改操作组织起来。最简单的情况下,可以像下面这样创建事务。

1
let transaction = db.transaction();

如果不指定参数,则对数据库中所有的对象存储有只读权限。更具体的方式是指定一个或多个要访问的对象存储的名称。

可以给第一个参数传入一个字符串数组来访问多个对象的存储。

1
let transaction = db.transaction(["users", "anotherStore"]);

如前所述,每个事务都以只读的方式访问数据。要修改访问模式,可以传入第二个参数。这个参数应该是下列字符串之一:

  • readonly
  • readWrite
  • versionchagne
1
let transaction = db.transaction("users", "readWrite");

有了事务的引用,就可以使用objectStore()方法并传入对象存储的名称以访问特定的对象存储。然后,可以使用add()put()方法添加和更新对象,使用get()取得对象,使用delete()删除对象,使用clear()清除所有对象。其中,get()delete()方法都接收对象键作为参数,这5个方法都创建新的请求对象

1
2
3
4
5
const transaction = db.transaction("users"),
store = transacttion.objectStore("users"),
request = store.get("007");
request.onerror = (event) => console.log("Did not get the object!");
request.onsuccess = (event) => console.log(event.target.result.firstName);

因为一个事物可以完成任意多个请求,所以事物对象本身也有时间处理程序onerroroncomplete。这两个事件可以用来获取事务级的状态信息。

1
2
3
4
5
6
transaction.onerror = (event) => {
// 整个事务被取消
};
transaction.oncomplete = (event) => {
// 整个事务成功完成
};

注意,不能通过 oncomplete 事件处理程序的 event对象访问 get()请求返回的任何数据。因此,仍然需要通过这些请求的 onsuccess事件处理程序来获取数据。

25.3.4 插入对象

拿到对象存储的引用后,就可以使用add()或put()写入数据了。这两个方法都接收一个参数,即要存储的对象,并把对象保存到对象存储。这两个方法只在对象存储中存在同名的键时有区别。这种情况下,add()会导致错误,而put会简单地重写该对象。

1
2
3
4
// users是一个用户数据的数组
for (let user of users) {
store.add(user);
}

每次调用 add()或 put()都会创建对象存储的新更新请求。如果想验证请求成功与否,可以把请求对象保存到一个变量,然后为它添加 onerror和 onsuccess 事件处理程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
// users 是一个用户数据的数组
let request,
requests = [];
for (let user of users) {
request = store.add(user);
request.onerror = () => {
// 处理错误
};
request.onsuccess = () => {
// 处理成功
};
requests.push(request);
}

25.3.5 通过游标查询

使用事务可以通过一个已知键取得一条记录。如果想要取得多条数据,则需要在事务中创建一个游标。游标是一个指向结果集的指针。与传统的数据库查询不同,游标不会事先收集所有结果。相反,游标指向第一个结果,并在接到指令前不会主动去查找下一条数据

需要在对象存储上调用openCursor()方法创建游标。与其他 IndexedDB 操作一样, openCursor()方法也返回一个请求,因此必须为它添加 onsuccess 和 onerror事件处理程序。例如:

1
2
3
4
5
6
7
8
9
const transaction = db.transaction("users"),
store = transaction.objectStore("users"),
request = store.openCursor();
request.onsuccess = (event) => {
// 处理成功
request.onerror = (event) => {
// 处理错误
}
}

在调用onsuccess事件处理程序时,可以通过event.target.result访问对象存储中的下一条记录,这个属性中保存着IDBCursor的实例(有下一条记录时)或null(没有记录时)。这个IDBCursor实例有几个属性

  • direction: 字符串常量,表示游标的前进方向以及是否应该遍历所有重复的值。可能的值包括
    • NEXT(“next”)
    • NEXTUNIQUE(“nextunique”)
    • PREV(“prev”)
    • PREVEUNIQUE(“prevunique”)
    • key: 对象的键
    • value: 实际的对象
    • primaryKey: 游标使用的键。可能是对象键或索引键
1
2
3
4
5
6
7
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
// 永远要检查
console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`)
}
}

注意,这个例子中的 cursor.value 保存着实际的对象。正因为如此,在显示它之前才需要使用JSON 来编码。

游标可用于更新个别记录。update()方法使用指定的对象更新当前游标对应的值。与其他类似操作一样,调用 update()会创建一个新请求,因此如果想知道结果,需要为 onsuccess和 onerror赋值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
request.onsuccess = (event) => {
const cursor = event.target.result;
let value,
updateRequest;
if (cursor) { // 永远要检查
if (cursor.key == "foo") {
value = cursor.value; // 取得当前对象
value.password = "magic!"; // 更新密码
updateRequest = cursor.update(value); // 请求保存更新后的对象
updateRequest.onsuccess = () => {
// 处理成功
};
updateRequest.onerror = () => {
// 处理错误
};
}
}
};

也可以调用 delelte()来删除游标位置的记录,与 update()一样,这也会创建一个请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
request.onsuccess = (event) => {
const cursor = event.target.result;
let value,
deleteRequest;
if (cursor) { // 永远要检查
if (cursor.key == "foo") {
deleteRequest = cursor.delete(); // 请求删除对象
deleteRequest.onsuccess = () => {
// 处理成功
};
deleteRequest.onerror = () => {
// 处理错误
};
}
}
};

如果事务没有修改对象存储的权限,update()和 delete()都会抛出错误。默认情况下,每个游标只会创建一个请求。要创建另一个请求,必须调用下列中的一个方法。

  • continue(key): 移动到结果集中的下一条记录。参数key是可选的。如果没有指定key,游标就移动到下一条记录;如果指定了,游标就移动到指定的键。
  • advance(count): 游标向前移动指定的count条记录。
1
2
3
4
5
6
7
8
request.onsuccess = (evet) => {
const cursor = event.target.result;
if (cursor) {
console.log(`Kay: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`);
} else {
console.log("Done!");
};
}

调用 cursor.continue()会触发另一个请求并再次调用 onsuccess 事件处理程序。在没有更多记录时,onsuccess事件处理程序最后一次被调用,此时 event.target.result 等于 null。

25.3.6 键范围

25.3.7 设置游标方向

25.3.8 索引

25.3.9 并发问题

IndexDB虽然是网页中的异步API,但仍然存在并发问题。如果两个不同的浏览器标签页同时打开了同一个网页,则有可能出现一个网页尝试升级数据库而另一个尚未就绪的情形。有问题的操作是谁在数据库为新版本,而版本变化只能在浏览器只有一个标签页使用数据库时才能完成。

25.3.10 限制

IndexedDB的很多限制实际上与Web Storage一样,首先,IndexedDB数据库是页面源(协议,域名,端口号)绑定的,因此信息不能跨域共享

其次,每个源都有可以存储的空间限制。Chrome是5MB。

第26章 模块

第27章 工作者线程

本章内容

  • 工作者线程简介
  • 使用专门工作者线程执行后台任务
  • 使用共享的工作者线程
  • 通过服务工作者线程管理请求

工作者线程的共同特点是独立于JavaScript主执行环境

27.1 工作者线程简介

JavaScript环境实际上是运行在托管操作系统中的虚拟环境。在浏览器中每打开一个页面,就会分配一个它自己的环境。这些,每个页面都有自己的内存,事件循环,DOM,等等。一个页面就相当于一个沙盒,不会干扰其他页面。对于浏览器来说,同时管理多个环境是非常简单的,因为所有这些环境都是并行执行的。

使用工作者线程,浏览器可以在原始页面环境之外再分配一个完全独立的二级子环境。这个子环境不能与依赖单线程交互的API(如DOM)进行互操作,但可以与父环境并行执行代码。

27.1.1 工作者线程与线程

  • 工作者线程是以实际线程实现的。例如Blink浏览器引擎实现工作者线程的WorkerThread就对应着底层的线程。
  • 工作者线程并行执行。虽然页面和工作者线程都是单线程JavaScript执行环境,每个环境中的指令则可以并行执行。
  • 工作者线程可以共享某些内存。工作者线程能够使用SharedArrayBuffer在多个环境间共享内容。虽然线程会使用锁实现并发控制,但JavaScript使用Atomics接口实现并发控制。
  • 工作者线程不共享全部内存。在传统线程模型中,多线程有能力读写共享内存空间。除了SharedArrayBuffer以外,从工作者线程进出的数据需要复制或转移。
  • 工作者线程不一定在同一个进程里。通常,一个进行可以在内部产生多个线程。根据浏览器引擎的实现,工作者线程可能与页面属于同一个进程,也可能不属于。例如Chrome的Blink引擎对共享工作者线程和服务工作者线程使用独立的进程。
  • 创建工作者线程的开销更大。工作者线程有自己独立的事件循环,全局对象,事件处理程序和其他JavaScript环境必需的特性。创建这些结构的代码不容忽视。

工作者线程相对比较重,不建议大量使用。通常,工作者线程应该是长期运行的,启动成本比较高,每个实例占用的内存也比较大。

27.1.2 工作者线程的类型

Web工作者线程规范中定义了三种主要的工作者线程:

  • 专用工作者线程
  • 共享工作者线程
  • 服务工作者线程
  1. 专用工作者线程

专用工作者线程,通常简称为工作者线程,Web Worder或Worker,是一种实用的工具,可以让脚本单独创建一个JavaScript线程,以执行委托的任务。专用工作者线程,顾名思义,只能被创建它的页面使用

  1. 共享工作者线程

共享工作者线程与专用工作者线程非常相似。主要区别是共享工作者线程可以被多个不同的上下文使用,包括不同的页面。任何与创建共享工作者线程的脚本同源的脚本,都可以向共享工作者线程发送消息或从中接收消息。

  1. 服务工作者线程

服务工作者线程与专用工作者线程和共享工作者线程截然不同。它的主要用途是拦截,重定向和修改页面发出的请求,充当网络请求仲裁者的角色。

27.1.3 WorderGlobalScope

在网页上,window对象可以向运行在其中的脚本暴露各种全局变量。在工作者线程内部,没有window的概念。这里的全局对象是WorkerGlobalScope实例,通过self关键字暴露出来。

  1. WorkerGlobalScope属性和方法

  2. WorkerGlobalScope的子类

每种类型的工作者线程都使用了自己特定的全局对象,这继承自WorderGlobalScope

  • 专用工作者线程:DedicatedWorkerGlobalScope
  • 共享工作者线程: SharedWorderGlobalScope
  • 服务工作者线程: ServiceWorkerGlobalScope

27.2 专用工作者线程

专用工作者线程是简单的Web工作者线程,网页中的脚本可以创建专用工作者线程来执行在页面线程之外的其他任务。这样的线程可以与父页面交换信息,发送网络请求,执行文件输入/输出,进行密集计算,处理大量数据。以及其他不适合在页面执行线程里做的任务(否则会导致页面响应迟钝)。

注意,在使用工作者线程时,脚本在哪里执行,在哪里加载是非常重要的概念。除非另有说明,否则本章假定main.js是从https://example.com域的根路径加载并执行的顶级脚本。

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

可以把专用工作者线程成为后台脚本(background script)。JavaScript线程的各个方面,包括生命周期管理,代码路径和输入/输出,都由初始化线程时提供的脚本来控制。该脚本也可以再请求其他脚本,但一个线程总是从一个脚本源开始。

  1. 创建专用工作者线程

创建专用工作者线程最常见的方式是加载JavaScript文件。把文件路径提供给Worker()构造函数,然后构造函数再在后台异步加载并实例化工作者线程。传给构造函数的文件路径可以是多种形式。

1
2
// emptyWorker.js
// 空的JS工作者线程文件
1
2
3
4
// main.js
console.log(location.href); // "https://example.com/"
const worker = new Worker(location.href + 'emptyWorker.js');
console.log(worker); // Worder();
  • emptyWorker.js文件是从绝对路径加载的。根据应用程序的结构,使用绝对URL经常是多余的。
  • 这个文件是在后台加载的,工作者线程的初始化完全独立于main.js
  • 工作者线程本身存在于一个独立的JavaScript环境中,因此main.js必须以Worker对象为代理实现与工作者线程通信。
  • 虽然相应的工作者线程可能还不存在,但是该Worker对象已经在原始环境中可用了

前面的例子可修改为使用相对路径。不过,这要求 main.js 必须与 emptyWorker.js 在同一个路径下:

1
2
const worker = new Worker('./emptyWorker.js');
console.log(worker); // Worker {}
  1. 工作者线程安全限制

工作者线程的脚本文件只能从与父页面相同的源加载。从其他源加载工作者线程的脚本文件会导致错误,如下所示:

1
2
3
4
5
6
7
// 尝试基于 https://example.com/worker.js 创建工作者线程
const sameOriginWorker = new Worker('./worker.js');
// 尝试基于 https://untrusted.com/worker.js 创建工作者线程
const remoteOriginWorker = new Worker('https://untrusted.com/worker.js');
// Error: Uncaught DOMException: Failed to construct 'Worker':
// Script at https://untrusted.com/main.js cannot be accessed
// from origin https://example.com

不能使用非同源脚本创建工作者线程,并不影响执行其他源的脚本。在工作者线程内部,使用importScripts()可以加载其他源的脚本。

基于加载脚本创建的工作者线程不受文档的内容安全策略限制,因为工作者线程在与父文档不同的上下文中运行。不过,如果工作者线程加载的脚本带有全局唯一标识符(与加载自一个二进制大文件一
样),就会受父文档内容安全策略的限制。

  1. 使用Worder对象

Worker()构造函数返回的Worker()对象是与刚创建的专用工作者线程通信的连接点。它可以用于在工作者线程和父上下文间传输信息,以及捕获专用工作者线程发出的事件。

要管理好使用Worker()创建的每个Worker对象。在终止工作者线程之前,它不会被垃圾回收,也不能通过编程方式恢复对之前Worker对象的引用。

Worker对象支持下列事件处理程序属性。

  • onerror: 在工作者线程中发生的ErrorEvent类型的错误事件时会调用指定给该属性的处理程序。
    • 该事件也会在工作者线程中跑出错误时发生
    • 该事件也可以通过worker.addEventListener('error', handler)的形式处理。
  • onmessage: 在工作者线程中发生MessageEvent类型的消息事件时

  • onmessageerror

  • psotMessage():用于通过异步消息事件向工作者线程发送消息。

  • terminate(): 用于立即终止工作者线程。没有为工作者线程提供清理的机会,脚本会

  1. DedicatedWorkerGlobalScope
1
2
3
4
5
6
7
8
9
// globalScopeWorker.js
console.log('inside worker:', self);

// main.js
const worker = new Worker('./globalScopeWorker.js');
console.log('created worker:', worker);

// created worker: Worker {}
// inside worker: Dedicated

如此例所示,顶级脚本和工作者线程中的 console 对象都将写入浏览器控制台,这对于调试非常有用。因为工作者线程具有不可忽略的启动延迟,所以即使 Worker对象存在,工作者线程的日志也会
在主线程的日志之后打印出来。

这里两个独立的 JavaScript 线程都在向一个 console对象发消息,该对象随后将消
息序列化并在浏览器控制台打印出来。浏览器从两个不同的 JavaScript 线程收到消息,并
按照自己认为合适的顺序输出这些消息。为此,在多线程应用程序中使用日志确定操作顺
序时必须要当心。

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

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

  • 初始化(initializing)
  • 活动(active)
  • 终止(terminated)

27.2.4 配置Worker选项

27.4 服务工作者线程

  1. ServiceWorkerContainer

服务工作者线程与专用工作者线程或共享工作者线程的一个区别是没有全局构造函数。服务工作者线程时通过ServiceWorkerContainer来管理的,他的实例保存在navigator.serviceWorker属性中。该对象是个顶级接口,通过它可以让浏览器创建,更新,销毁或者与服务器工作者线程交互。

1
2
console.log(navigator.serviceWorker);
// ServiceWorkerContainer {...}
  1. 创建服务工作者线程