ECMAScript-学习指南第二版-全-
ECMAScript 学习指南第二版(全)
原文:
zh.annas-archive.org/md5/d56f3fe5e12a1472a4b38effcd464009译者:飞龙
前言
JavaScript 是网络开发和服务器端编程的一个组成部分。理解 JavaScript 的基础知识不仅能帮助人们创建交互式网络应用程序,还能帮助设置网络服务器,通过 React Native 等框架创建移动应用程序,甚至使用 electronJS 等框架创建桌面应用程序。
本书以 ECMAScript 2017(ES8)的形式介绍了 JavaScript 的新鲜和核心概念,其中包括您开始使用 JavaScript 并具备从基础到高级理解所需的一切,以便您能够实现第一段中提到的所有内容。
本书面向对象
本书适合对 JavaScript 一无所知且愿意学习这项技术的人。本书也可供熟悉旧版 JavaScript 并希望将知识提升到最新标准和使用技术的人使用。对于更高级的用户,本书可用于复习模块化、Web Workers 和共享内存等概念。
本书涵盖内容
第一章,开始使用 ECMAScript,讨论了 ECMAScript 到底是什么以及为什么我们称之为 ECMAScript 而不是 JavaScript。它还讨论了如何创建变量、执行基本操作,并在 ES8 中提供执行这些操作的新方法。
第二章,了解您的库,展示了作为初学者和中级 JavaScript 开发者需要了解的所有函数,以便在各种项目中顺利工作。本章教授的函数是通用和泛型函数,您将能够在需要的地方理解和应用它们。
第三章,使用迭代器,介绍了如何在 JavaScript 中以正确的方式遍历可迭代事物。我们讨论了 Symbol,这是一种新的原生 JavaScript 类型,它是什么,以及为什么我们需要它。我们还讨论了尾调用优化技术,这是浏览器为了加快代码执行而实现的。
第四章,异步编程,探讨了实现异步编程的现代方法,并将其与不那么美好的过去方法进行了比较,包括回调地狱。它将教会您以同步方式实现异步编程。
第五章,模块化编程,讨论了如何将您的 JavaScript 代码模块化到不同的文件中,以便于重用和调试单个模块。我们首先介绍早期可用的基本和第三方解决方案,然后介绍浏览器为世界带来的原生支持。
第六章,实现 Reflect API,展示了 JavaScript 中提供的 Reflect API 的信息,它基本上有助于操作对象的属性和方法。
第七章,代理,介绍了 JavaScript 中的一种新实现,即对象上的代理。它具有许多优点,例如隐藏私有属性、为对象属性和方法设置默认值,以及创建令人惊叹的自定义功能。例如,JavaScript 的类似 Python 的数组切片。
第八章,类,探讨了类、它们的实现、类中的继承以及类最终只是函数实现上的语法糖。这很重要,因为类使得代码对有面向对象编程背景的人来说更易于阅读和理解。
第九章,网页上的 JavaScript,探讨了在网站上使用 JavaScript 的基础知识,浏览器在网页上向开发者公开的一些流行 API,以及 JavaScript 如何用于与 DOM 交互以操纵网页上的内容。
第十章,JavaScript 中的存储 API,探讨了浏览器中可用的存储 API,并展示了如何利用它们在用户的计算机上本地存储数据。
第十一章,Web 和 Service Workers,讨论了 HTML5 中可用的 Web Workers,渐进式 Web 应用的服务 Workers,并展示了如何有效地使用这些 Workers 来分配任务负载。
第十二章,共享内存和原子操作,教我们如何利用 Web Workers 提供的多线程环境,通过SharedArrayBuffer允许 Web Workers 通过共享内存实现快速访问内存。它涵盖了与线程共享相同数据的一些常见问题,并提供了这些问题的解决方案。
要充分利用这本书
虽然这不是严格要求的,但如果你对 HTML/CSS 有些了解,并且对如何使用它创建基本的网页有些了解,那就太好了。
您应该熟悉现代浏览器(首选 Chrome 或 Firefox)在桌面/笔记本电脑上。
要充分利用这本书,不要仅仅阅读书籍;保持其他学习资源开放,并尽可能多地实现演示和示例代码。
下载示例代码文件
您可以从www.packtpub.com的账户下载这本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载与勘误。
-
在搜索框中输入书名,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR/7-Zip for Windows
-
适用于 Mac 的 Zipeg/iZip/UnRarX
-
适用于 Linux 的 7-Zip/PeaZip
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-ECMAScript-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/上找到。查看它们吧!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个例子:“仔细看看,我们并没有反复执行counter()。”
代码块设置如下:
let myCounter = counter(); // returns a function (with count = 1)
myCounter(); // now returns 2
myCounter(); // now returns 3
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
var ob1 = {
prop1 : 1,
prop2 : {
prop2_1 : 2
}
};
Object.freeze( ob1 );
粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字会像这样显示。以下是一个例子:“然而,您可以指定移动的距离,也就是说history.go(5);与用户在浏览器中点击前进按钮五次是等效的。”
警告或重要提示看起来是这样的。
小贴士和技巧看起来是这样的。
联系我们
我们欢迎读者的反馈。
一般反馈:请通过电子邮件feedback@packtpub.com发送反馈,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请通过questions@packtpub.com发送电子邮件给我们。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权邮箱 copyright@packtpub.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问packtpub.com。
第一章:ECMAScript 入门
ECMAScript 2017(ES8)于 2017 年 6 月底由技术委员会第 39 号(TC39)发布。它是 ECMA 的一部分,该机构根据 ECMAScript 规范标准化 JavaScript 语言。目前,该标准旨在每年发布一个新的 ES 规范版本。ES6 于 2015 年发布,ES7 于 2016 年发布。ES6 发布时发生了许多变化(箭头函数、类、生成器、模块加载器、异步编程等),随着时间的推移,还有更多有趣的事情发生。
在本章中,我们将从 JavaScript 的基础知识开始,从 ES6 基础知识开始,逐步过渡到 ES8 的内容。此外,我们还将探讨传统 JS 的一些有趣方面,如闭包,以及一些新特性,如箭头函数。
作为一名自学者,我强烈推荐不仅阅读这本书,还尝试将在这里学到的知识应用到一些小而有趣的项目中。这将帮助你轻松地保留大量知识。
在本章中,我们将介绍以下内容:
-
使用
let关键字创建块作用域变量 -
使用
const关键字创建常量变量 -
扩展运算符和剩余参数
-
变量提升
-
使用解构赋值从可迭代对象和对象中提取数据
-
箭头函数
-
闭包及其处理方法
-
在 JavaScript 中使用分号
-
比较
let、var和const的性能基准 -
创建对象属性的新的语法
let关键字
使用let关键字可以声明一个块作用域变量(关于这一点稍后会有更多介绍),并可选择性地将其初始化为某个值。来自不同编程语言背景但新接触 JavaScript 的程序员,常常会编写出容易出错的 JavaScript 程序,认为使用传统var关键字创建的 JavaScript 变量是块作用域的。几乎每种流行的编程语言在变量作用域方面都有相同的规则,但 JavaScript 由于缺乏块作用域变量而表现得略有不同。由于 JavaScript 变量不是块作用域的,存在内存泄漏的风险,并且 JavaScript 程序更难以阅读和调试。
声明函数作用域变量
使用var关键字声明的 JavaScript 变量被称为函数作用域变量。函数作用域变量对整个脚本全局可用,即如果在外部函数中声明,则在整个脚本中可用。同样,如果函数作用域变量在函数内部声明,则它们在整个函数中可用,但不在函数外部。让我们看一个例子:
var a = 12; // accessible everywhere
function myFunction() {
console.log(a); // alerts 12
var b = 13;
if(true) {
var c = 14; // this is also accessible throughout the function!
alert(b); // alerts 13
}
alert(c); // alerts 14
}
myFunction();
alert(b); // alerts undefined
显然,在函数内部初始化的变量仅限于该函数内部。然而,在块作用域(即不在函数内的花括号{},例如if语句)中声明的变量也可以在那些块外部使用。
声明块级作用域变量
使用let关键字声明的变量被称为块级作用域变量。当在函数外部声明时,块级作用域变量的行为与函数级作用域变量相同,即它们是全局可访问的。但是,当块级作用域变量在块内部声明时,它们只在其定义的块内部(以及任何子块)可访问,而块外部则不可访问:
let a = 12; // accessible everywhere
function myFunction() {
console.log(a); // alerts 12
let b = 13;
if(true) {
let c = 14; // this is NOT accessible throughout the function!
alert(b); // alerts 13
}
alert(c); // alerts undefined
}
myFunction();
alert(b); // alerts undefined
仔细研究代码。这与前面的示例相同,但是将var替换为let。观察现在 C 如何提示undefined(let使其在if {}之外不可访问)。
重新声明变量
当你使用var关键字声明一个变量,而这个变量已经在同一作用域内使用var关键字声明过时,那么它会被覆盖。考虑以下示例:
var a = 0;
var a = 1;
alert(a); // alerts 1
function myFunction() {
var b = 2;
var b = 3;
alert(b); // alerts 3
}
myFunction();
结果正如预期。但是,使用let关键字创建的变量并不以相同的方式表现。
当你使用let关键字声明一个变量,而这个变量已经在同一作用域内使用let关键字声明过时,那么它会抛出一个SyntaxError 异常。考虑以下示例:
let a = 0;
let a = 1; // SyntaxError
function myFunction() {
let b = 2;
let b = 3; // SyntaxError
if(true) {
let c = 4;
let c = 5; // SyntaxError
}
}
myFunction();
当你使用一个在函数(或内部函数)中已经可访问的名称声明一个变量,或者是一个使用var或let关键字分别创建的子块时,那么它就是一个不同的变量。这里有一个示例,展示了这种行为:
var a = 1;
let b = 2;
function myFunction() {
var a = 3; // different variable
let b = 4; // different variable
if(true) {
var a = 5; // overwritten
let b = 6; // different variable
console.log(a); // 5
console.log(b); // 6
}
console.log(a); // 5
console.log(b); // 4
}
myFunction();
console.log(a);
console.log(b);
闭包和let关键字
恭喜你到达这里!让我们面对现实,JavaScript 有一些奇怪(以及一些不好)的一面。闭包是 JavaScript 奇怪的一面之一。让我们看看“闭包”这个术语实际上是什么意思。
当你声明一个局部变量时,该变量有一个受限的作用域,也就是说,它不能在声明它的特定作用域之外使用(这取决于var和let)。如前所述,局部变量在块(如let的情况)或函数作用域(如var或let的情况)之外是不可用的。
让我们通过以下示例来了解前一段落所阐述的内容:
function() {
var a = 1;
console.log(a); // 1
}
console.log(a); // Error
当一个函数完全执行完毕,即返回了它的值,它的局部变量就不再需要,并且从内存中清理掉。然而,闭包是一个持久的局部变量作用域。
考虑以下示例:
function counter () {
var count = 0;
return function () {
count += 1;
return count;
}
}
显然,返回的函数使用了counter()函数的局部变量。当你调用counter()时会发生什么?
let myCounter = counter(); // returns a function (with count = 1)
myCounter(); // now returns 2
myCounter(); // now returns 3
仔细观察,我们并没有反复执行counter()。我们将counter返回的值存储在myCounter变量中,然后不断调用返回的函数。
返回的myCounter函数每次被调用时都会增加一。当你调用myCounter()时,你正在执行一个包含对变量(count)的引用的函数,该变量存在于父函数中,并且在技术上应该在完全执行后销毁。然而,JavaScript 以一种不同的堆保留了返回函数中的使用过的变量。这种属性被称为闭包。
闭包已经存在很长时间了,那么有什么不同?使用let关键字。看看这个例子:
for(var i=0;i<5;i++){
setTimeout(function() {
console.log(i);
}, 1000);
}
输出将是:
5 5 5 5 5
为什么呢?因为在setTimeout触发之前,循环已经结束,i变量已经是5了。但这种情况不会发生在let上:
for(let i=0;i<5;i++){
setTimeout(function() {
console.log(i);
}, 1000);
}
输出:
0 1 2 3 4
let将变量绑定到块(因此,在这种情况下,是for循环)的事实意味着它将变量绑定到每个迭代。所以,当循环结束时,你将拥有五个setTimeout函数(i分别为0、1、2、3、4),它们将依次触发。
let通过在每次迭代中创建自己的闭包来实现这一点。在let中,这发生在幕后,所以你不需要编写这方面的代码。
要在不使用let的情况下修复此代码,我们需要创建一个立即执行的函数表达式(IIFE),其外观如下:
for(var i=0;i<5;i++){
(function(arg) {
setTimeout(function() {
console.log(arg);
}, 1000);
}(i));
}
这就是let在幕后所做的大致事情。那么这里发生了什么?
我们创建了一个匿名函数,它在每个循环周期立即执行,并带有与其关联的正确i值。现在,这个函数将正确的i值作为arg传递给函数参数。最后,我们使用console.log在两秒后得到正确的输出0 1 2 3 4。
所以你可以观察到,一个简单的let语句可以在这种情况下大大简化代码。
const关键字
使用const关键字,你可以创建一旦初始化后就不能改变其值的变量(因此它们被称为常量),即你无法在代码的后续部分用另一个值重新初始化它们。
如果你尝试重新初始化一个const变量,将抛出一个只读异常。此外,你不能只声明而不初始化一个const变量。它也会抛出异常。
例如,你可能希望当有人试图更改计算器中的特定常数,比如pi时,你的 JavaScript 崩溃。下面是如何实现这一点:
const pi = 3.141;
pi = 4; // not possible in this universe, or in other terms,
// throws Read-only error
常量变量的作用域
const变量是块级作用域变量,即它们遵循与使用let关键字声明的变量相同的范围规则。以下示例演示了常量变量的作用域:
const a = 12; // accessible globally
function myFunction() {
console.log(a);
const b = 13; // accessible throughout function
if(true) {
const c = 14; // accessible throughout the "if" statement
console.log(b);
}
console.log(c);
}
myFunction();
上述代码的输出是:
12
13
ReferenceError Exception
这里,我们可以看到,常量变量在作用域规则方面与块级作用域变量表现相同。
使用常量变量引用对象
当我们将一个对象赋值给变量时,变量持有的是对象的引用,而不是对象本身。因此,当将对象赋值给常量变量时,对象的引用变为对该变量的常量引用,而不是对对象的引用。因此,对象是可变的。
考虑以下示例:
const a = {
name: "Mehul"
};
console.log(a.name);
a.name = "Mohan";
console.log(a.name);
a = {}; //throws read-only exception
上述代码的输出是:
Mehul
Mohan
<Error thrown>
在这个例子中,a 变量存储的是对象的地址(即引用)。因此,对象的地址是 a 变量的值,并且不能被更改。但是对象是可变的。因此,当我们尝试将另一个对象赋值给 a 变量时,我们得到了异常,因为我们试图更改 a 变量的值。
何时使用 var/let/const
const 和 let 之间的区别在于 const 确保不会发生重新绑定。这意味着您不能重新初始化一个 const 变量,但 let 变量可以被重新初始化(但不能重新声明)。
在特定的作用域内,const 变量始终引用同一个对象。因为 let 可以在运行时更改其值,所以不能保证 let 变量始终引用同一个值。因此,作为一般规则,您可以(不是严格地)遵循以下规则:
-
如果您知道您不会更改值(最大性能提升),请默认使用
const。 -
只有当您认为在您的代码中需要/可能发生重新赋值时才使用
let(现代语法) -
避免使用
var(let在块作用域中定义时不会创建全局变量;如果您来自 C、C++、Java 或任何类似背景,这会使您更不容易混淆)
let 与 var 与 const 的性能基准
目前,在我的笔记本电脑上(MacBook Air,Google Chrome 版本 61.0.3163.100(官方构建)(64 位))运行基准测试产生以下结果:

很明显,在 Chrome 上,全局作用域中的 let 性能最慢,而块内的 let 和 const 性能最快。
首先,上述基准测试是通过运行循环 1000 x 30 次来执行的,循环中执行的操作是将一个值追加到数组中。也就是说,数组从 [1] 开始,然后在下一次迭代中变为 [1,2],然后是 [1,2,3],依此类推。
这些结果意味着什么?我们可以从这些结果中得出的一个推论是,当在声明中使用 let 时,let 在 for 循环中较慢:for(let i=0;i<1000;i++)。
这是因为 let 在每次迭代时都会重新声明(将此与您之前阅读的闭包部分联系起来),而 for(var i=0;i<1000;i++) 则为整个代码块声明了 i 变量。这使得 let 在循环定义中使用时稍微慢一些。
然而,当 let 不在循环体内部使用而是在循环外部声明时,它的性能相当不错。例如:
let myArr = [];
for(var i = 0;i<1000;i++) {
myArr.append(i);
}
这将为您带来最佳结果。然而,如果您不是进行成百上千次的迭代,这应该不会有什么影响。
JavaScript 中的不可变性
不可变性,用一行定义,意味着一旦该值被赋值,那么它就永远不能改变:
var string1 = "I am an immutable";
var string2 = string1.slice(4, 8);
string1.slice 不会改变 string1 的值。事实上,没有字符串方法会改变它们操作的字符串,它们都返回新的字符串。原因是字符串是不可变的——它们不能改变。
字符串不是 JavaScript 中唯一的不可变实体。数字也是不可变的。
Object.freeze 与 const 的比较
之前,我们看到即使你在对象前使用 const,程序员仍然能够修改其属性。这是因为 const 创建了一个不可变的绑定,也就是说,你不能将新值赋给这个绑定。
因此,为了真正使对象成为常量(即,不可修改的属性),我们必须使用一个叫做 Object.freeze 的东西。然而,Object.freeze 又是一个浅层方法,也就是说,你需要递归地应用于嵌套对象以保护它们。让我们用一个简单的例子来澄清这一点。
考虑这个例子:
var ob1 = {
prop1 : 1,
prop2 : {
prop2_1 : 2
}
};
Object.freeze( ob1 );
const ob2 = {
prop1 : 1,
prop2 : {
prop2_1 : 2
}
}
ob1.prop1 = 4; // (frozen) ob1.prop1 is not modified
ob2.prop1 = 4; // (const) ob2.foo gets modified
ob1.prop2.prop2_1 = 4; // (frozen) modified, because ob1.prop2.prop2_1 is nested
ob2.bar.value = 4; // (const) modified
ob1.prop2 = 4; // (frozen) not modified, bar is a key of obj1
ob2.prop2 = 4; // (const) modified
ob1 = {}; // (frozen) ob1 redeclared (ob1's declaration is not frozen)
ob2 = {}; // (const) ob2 not redeclared (used const)
我们冻结了 ob1,因此它的所有第一级层次属性都被冻结了(即,不能被修改)。一个冻结的对象在尝试修改时不会抛出错误,而是简单地忽略所做的修改。
然而,当我们深入研究时,你会注意到 ob1.bar.value 被修改了,因为它有 2 个层级并且没有被冻结。所以,你需要递归地冻结嵌套对象以使它们常量。
最后,如果我们看看最后两行,你会意识到何时使用 Object.freeze 和何时使用 const。const 声明不再声明,而 ob1 被重新声明,因为它不是常量(它是 var)。Object.freeze 不会冻结原始变量绑定,因此不是 const 的替代品。同样,const 不会冻结属性,也不是 Object.freeze 的替代品。
此外,一旦一个对象被冻结,你不能再向它添加属性。然而,你可以向嵌套对象添加属性(如果存在)。
默认参数值
在 JavaScript 中,没有定义的方法可以为未传递的函数参数分配默认值。因此,程序员通常会检查具有 undefined 值的参数(因为它是不传递参数的默认值)并将默认值分配给它们。以下示例演示了如何做到这一点:
function myFunction(x, y, z) {
x = x === undefined ? 1 : x;
y = y === undefined ? 2 : y;
z = z === undefined ? 3 : z;
console.log(x, y, z); //Output "6 7 3"
}
myFunction(6, 7);
这可以通过为函数参数提供默认值来更容易地完成。以下代码演示了如何做到这一点:
function myFunction(x = 1, y = 2, z = 3) {
console.log(x, y, z);
}
myFunction(6,7); // Outputs 6 7 3
在前面的代码块中,由于我们在函数调用语句中传递了前两个参数,默认值(即 x = 1 和 y = 2)将被我们传递的值(即 x = 6 和 y = 7)覆盖。第三个参数没有传递,因此使用其默认值(即 z = 3)。
此外,传递 undefined 被认为是缺少一个参数。以下示例演示了这一点:
function myFunction(x = 1, y = 2, z = 3) {
console.log(x, y, z); // Outputs "1 7 9"
}
myFunction(undefined,7,9);
这里发生类似的事情。如果你想省略第一个参数,只需在那个位置传递undefined即可。
默认值也可以是表达式。以下示例演示了这一点:
function myFunction(x = 1, y = 2, z = x + y) {
console.log(x, y, z); // Output "6 7 13"
}
myFunction(6,7);
这里,我们正在使用默认参数值内部的参数变量!也就是说,无论你传递什么作为前两个参数,如果第三个参数没有传递,它将取前两个参数之和的值。由于我们向第一个和第二个参数传递了6和7,所以z变为6 + 7 = 13。
扩展运算符
扩展运算符由...标记表示。扩展运算符将可迭代对象拆分为其单个值。
可迭代是一个包含一组值并实现了 ES6 可迭代协议的对象,使我们能够遍历其值。数组是内置可迭代对象的一个例子。
扩展运算符可以放置在代码中需要多个函数参数或多个元素(对于数组字面量)的任何地方。
扩展运算符通常用于将可迭代对象的值扩展到函数的参数中。让我们以数组为例,看看如何将其拆分为函数的参数。
要将数组的值作为函数参数提供,你可以使用Function的apply()方法。此方法对每个函数都可用。以下示例演示了:
function myFunction(a, b) {
return a + b;
}
var data = [1, 4];
var result = myFunction.apply(null, data);
console.log(result); //Output "5"
这里,apply方法接受一个数组,提取其值,将它们作为单独的参数传递给函数,然后调用它。
这里有一个使用现代方法(即使用扩展运算符)的示例:
function myFunction(a, b) {
return a + b;
}
let data = [1, 4];
let result = myFunction(...data);
console.log(result); //Output "5"
在运行时,在 JavaScript 解释器调用myFunction函数之前,它将...data替换为1,4表达式:
let result = myFunction(...data);
之前的代码被替换为:
let result = myFunction(1,4);
在此之后,函数被调用。
扩展运算符的其他用法
扩展运算符不仅限于将可迭代对象扩展到函数参数,它还可以用于代码中需要多个元素(例如,数组字面量)的任何地方。因此,它有很多用途。让我们看看扩展运算符在数组中的其他一些用例。
将数组值作为另一个数组的一部分
扩展运算符也可以用来将数组值作为另一个数组的一部分。以下示例代码演示了如何在创建数组的同时将现有数组的值作为另一个数组的一部分:
let array1 = [2,3,4];
let array2 = [1, ...array1, 5, 6, 7];
console.log(array2); //Output "1, 2, 3, 4, 5, 6, 7"
考虑以下代码:
let array2 = [1, ...array1, 5, 6, 7];
这段代码等同于:
let array2 = [1, 2, 3, 4, 5, 6, 7];
将数组值推送到另一个数组
有时,我们可能需要将现有数组的值推送到另一个现有数组的末尾。
这就是程序员过去通常的做法:
var array1 = [2,3,4];
var array2 = [1];
Array.prototype.push.apply(array2, array1);
console.log(array2); //Output "1, 2, 3, 4"
但从 ES6 开始,我们有一个更简洁的方式来完成它,如下所示:
let array1 = [2,3,4];
let array2 = [1];
array2.push(...array1);
console.log(array2); //Output "1, 2, 3, 4"
这里,push方法接受一系列变量,并将它们添加到调用它的数组的末尾。
看以下行:
array2.push(...array1);
这将被替换为以下行:
array2.push(2, 3, 4);
扩展多个数组
可以在单行表达式中展开多个数组。例如,以下代码:
let array1 = [1];
let array2 = [2];
let array3 = [...array1, ...array2, ...[3, 4]];//multi arrayspread
let array4 = [5];
function myFunction(a, b, c, d, e) {
return a+b+c+d+e;
}
let result = myFunction(...array3, ...array4); //multi array spread
console.log(result); //Output "15"
剩余参数
剩余参数也用...标记表示。带有...的函数的最后一个参数被称为剩余参数。剩余参数是一个数组类型,当参数的数量超过命名参数的数量时,它包含函数的其余参数。
剩余参数用于从函数内部捕获一个可变数量的函数参数。
arguments对象也可以用来访问所有传递的参数。arguments对象不是严格意义上的数组,但它提供了一些类似于数组的接口。
以下示例代码展示了如何使用arguments对象来检索额外的参数:
function myFunction(a, b) {
const args = Array.prototype.slice.call(arguments, myFunction.length);
console.log(args);
}
myFunction(1, 2, 3, 4, 5); //Output "3, 4, 5"
这可以通过使用剩余参数以更简单、更干净的方式完成。以下示例演示了如何使用剩余参数:
function myFunction(a, b, ...args) {
console.log(args); //Output "3, 4, 5"
}
myFunction(1, 2, 3, 4, 5);
arguments对象不是一个数组对象。因此,要对arguments对象执行数组操作,你需要将其转换为数组。剩余参数易于处理。
这个...标记叫什么?
...标记被称为扩展操作符或剩余参数,具体取决于其使用方式和位置。
提升机制
提升机制是 JavaScript 的默认行为:将声明移动到顶部。这意味着以下代码在 JavaScript 中将会工作:
bookName("ES8 Concepts");
function bookName(name) {
console.log("I'm reading " + name);
}
如果你来自 C/C++的背景,一开始这可能会觉得有点奇怪,因为那些语言不允许你在至少声明其原型之前调用函数。但 JavaScript 在幕后会提升函数,也就是说,所有的函数声明都会移动到上下文的顶部。所以,本质上,前面的代码等同于以下代码:
function bookName(name) {
console.log("I'm reading " + name);
}
bookName("ES8 Concepts");
提升机制只移动声明,而不是初始化。因此,尽管前面的代码可以工作,但以下代码将不会工作:
bookName("ES8 Concepts"); // bookName is not a function
var bookName = function(name) {
console.log("I'm reading " + name);
}
这是因为,正如我们之前所说的,只有声明会被提升。因此,浏览器看到的是类似以下的内容:
var bookName; // hoisted above
bookName("ES8 Concepts"); // bookName is not function
// because bookName is undefined
bookName = function(name) { // initalization is not hoisted
console.log("I'm reading " + name);
}
猜猜以下代码的输出:
function foo(a) {
a();
function a() {
console.log("Mehul");
}
}
foo(); // ??
foo( undefined ); // ??
foo( function(){ console.log("Not Mehul"); } ); // ??
准备好揭晓答案了吗?你的可能答案是:
-
Mehul undefined Not Mehul -
Program throws error -
Mehul Mehul Mehul
输出将会是:
Mehul
Mehul
Mehul
为什么?因为这就是浏览器看到这段代码的方式(在应用提升机制之后):
function foo(a) {
// the moment below function is declared,
//the argument 'a' passed is overwritten.
function a() {
console.log("Mehul");
}
a();
}
foo();
foo( undefined );
foo( function(){ console.log("Not Mehul"); } );
一旦函数被提升,你传递给该函数的内容就不再重要。它总是会被foo函数内部定义的函数覆盖。
因此,输出结果仅仅是Mehul这个词重复三次。
解构赋值
解构赋值是一种表达式,它允许你使用类似于数组或对象构造字面量的语法,将可迭代或对象中的值或属性赋给变量。
解构赋值使得从可迭代或对象中提取数据变得简单,因为它提供了一个更短的语法。解构赋值已经存在于像 Perl 和 Python 这样的编程语言中,并且它们的工作方式是一样的。
有两种解构赋值表达式:数组和对象。让我们详细看看每一种。
数组解构赋值
数组解构赋值用于从可迭代对象中提取值并将它们赋给变量。它被称为数组解构赋值,因为表达式类似于数组构造字面量。
程序员过去是这样做的,将数组的值赋给变量:
var myArray = [1, 2, 3];
var a = myArray[0];
var b = myArray[1];
var c = myArray[2];
在这里,我们正在提取数组的值并将它们分别赋值给a、b、c变量。
使用数组解构赋值,我们可以用一行语句完成:
let myArray = [1, 2, 3];
let a, b, c;
[a, b, c] = myArray; //array destructuring assignment syntax
如你所见,[a, b, c]是一个数组解构表达式。
在数组解构语句的左侧,我们需要放置我们想要将数组值赋给变量的变量,使用与数组字面量类似的语法。在右侧,我们需要放置一个数组(实际上可以是任何可迭代对象),我们想要从中提取值。
以这种方式,前面的示例代码可以变得更短:
let [a, b, c] = [1, 2, 3];
在这里,我们在同一语句中创建变量,而不是提供数组变量,我们提供带有构造字面量的数组。
如果变量比数组中的项目少,则只考虑前几个项目。
如果你将非可迭代对象放在数组解构赋值语法的右侧,则会抛出 TypeError 异常。
忽略值
我们也可以忽略可迭代对象的一些值。以下示例代码展示了如何做到这一点:
let [a, , b] = [1, 2, 3]; // notice -->, ,<-- (2 commas)
console.log(a);
console.log(b);
输出如下:
1 3
在数组解构赋值中使用剩余操作符
我们可以使用...符号来给数组解构表达式的最后一个变量加前缀。在这种情况下,如果其他变量的数量少于可迭代对象的值,该变量总是被转换为一个数组对象,该对象包含可迭代对象的所有剩余值。
考虑这个例子来理解它:
let [a, ...b] = [1, 2, 3, 4, 5, 6];
console.log(a);
console.log(Array.isArray(b));
console.log(b);
输出如下:
1
true
2,3,4,5,6
在前面的示例代码中,你可以看到b变量被转换为一个数组,并且它包含了右侧数组中的所有其他值。
这里...符号被称为剩余操作符。
我们也可以在使用剩余操作符时忽略值。以下示例演示了这一点:
let [a, , ,...b] = [1, 2, 3, 4, 5, 6];
console.log(a);
console.log(b);
输出如下:
1 4,5,6
这里,我们忽略了2、3值。
变量的默认值
在解构时,如果数组索引是undefined,你也可以为变量提供默认值。以下示例演示了这一点:
let [a, b, c = 3] = [1, 2];
console.log(c); //Output "3"
嵌套数组解构
我们也可以从多维数组中提取值并将它们赋给变量。以下示例演示了这一点:
let [a, b, [c, d]] = [1, 2, [3, 4]];
使用解构赋值作为参数
我们还可以使用数组解构表达式作为函数参数,以提取作为函数参数传递的可迭代对象的值。以下示例演示了这一点:
function myFunction([a, b, c = 3]) {
console.log(a, b, c); //Output "1 2 3"
}
myFunction([1, 2]);
在本章前面,我们了解到如果我们将 undefined 作为参数传递给函数调用,那么 JavaScript 会检查默认参数值。因此,我们也可以在这里提供一个默认数组,如果参数是 undefined,它将被使用。以下示例演示了这一点:
function myFunction([a, b, c = 3] = [1, 2, 3]) {
console.log(a, b, c); //Output "1 2 3"
}
myFunction(undefined);
这里,我们传递了 undefined 作为参数,因此使用了默认数组 [1, 2, 3] 来提取值。
对象解构赋值
对象解构赋值用于提取对象的属性值并将它们分配给变量。
这是一个传统(并且仍然有用)的方法,用于将属性值赋给对象:
var object = {"name" : "John", "age" : 23};
var name = object.name;
var age = object.age;
我们可以用一行语句完成这个操作,使用对象解构赋值:
let object = {"name" : "John", "age" : 23};
let name, age;
({name, age} = object); //object destructuring assignment syntax
在对象解构语句的左侧,我们需要放置我们想要将对象属性值分配到的变量,使用类似于对象字面量的语法。在右侧,我们需要放置我们想要提取属性值的对象。最后,使用 ( ) 符号关闭语句。
在这里,变量名必须与对象属性名相同。如果你想使用不同的变量名,可以这样操作:
let object = {"name" : "John", "age" : 23};
let x, y;
({name: x, age: y} = object);
之前的代码可以这样进一步缩短:
let {name: x, age: y} = {"name" : "John", "age" : 23};
这里我们在同一行创建了变量和对象。我们不需要使用 ( ) 符号关闭语句,因为我们是在同一语句中创建变量的。
变量的默认值
如果在解构时对象属性是 undefined,你也可以为变量提供默认值。以下示例演示了这一点:
let {a, b, c = 3} = {a: "1", b: "2"};
console.log(c); //Output "3"
一些属性名是使用表达式动态构建的。在这种情况下,为了提取属性值,我们可以使用 [ ] 符号提供一个带有表达式的属性名。以下示例演示了这一点:
let {["first"+"Name"]: x} = { firstName: "Eden" };
console.log(x); //Output "Eden"
解构嵌套对象
我们还可以从嵌套对象中提取属性值,即对象中的对象。以下示例演示了这一点:
var {name, otherInfo: {age}} = {name: "Eden", otherInfo: {age:
23}};
console.log(name, age); //Eden 23
使用对象解构赋值作为参数
就像数组解构赋值一样,我们也可以将对象解构赋值用作函数参数。以下示例演示了这一点:
function myFunction({name = 'Eden', age = 23, profession =
"Designer"} = {}) {
console.log(name, age, profession); // Outputs "John 23 Designer"
}
myFunction({name: "John", age: 23});
这里,我们传递了一个空对象作为默认参数值,如果将 undefined 作为函数参数传递,它将用作默认对象。
箭头函数
一眼看上去,箭头函数只是创建常规 JavaScript 函数的一种花哨方式(然而,也有一些惊喜)。使用箭头函数,你可以创建简洁的一行代码函数,而且它们确实可以工作!
以下示例演示了如何创建一个箭头函数:
let circumference = (pi, r) => {
let ans = 2 * pi * r;
return ans;
}
let result = circumference(3.141592, 3);
console.log(result); // Outputs 18.849552
在这里,周长是一个变量,引用了匿名箭头函数。
之前的代码与以下 ES5 代码类似:
var circumference = function(pi, r) {
var area = 2 * pi * r;
return area;
}
var result = circumference(3.141592, 3);
console.log(result); //Output 18.849552
如果你的函数只包含一个语句(并且你想返回该语句的结果),那么你不需要使用{}括号来包裹代码。这使得它成为一行代码。以下示例演示了这一点:
let circumference = (pi, r) => 2 * pi * r;
let result = circumference(3.141592, 3);
console.log(result); //Output 18.849552
当不使用{}括号时,则语句体的值将自动返回。前面的代码等同于以下代码:
let circumference = function(pi, r) { return 2 * pi * r; }
let result = circumference(3.14, 3);
console.log(result); //Output 18.84
此外,如果只有一个参数,你可以省略括号以使代码更短。考虑以下示例:
let areaOfSquare = side => side * side;
let result = areaOfSquare(10);
console.log(result); //Output 100
由于只有一个参数,side,因此我们可以省略圆括号。
箭头函数中的"this"值
在箭头函数中,this关键字的值与封闭作用域(全局或函数作用域,无论箭头函数定义在何处)的this关键字的值相同。这意味着,与传统的函数中this的值(即函数作为属性所在的对象的上下文)不同,this实际上指的是全局或函数作用域,其中函数被调用。
考虑以下示例以了解传统函数和箭头函数之间的区别,这个值:
var car = {
name: 'Bugatti',
fuel: 0,
// site A
addFuel: function() {
// site B
setInterval(function() {
// site C
this.fuel++;
console.log("The fuel is now " + this.fuel);
}, 1000)
}
}
你认为当你调用car.addFuel()方法时会发生什么?如果你猜到燃料现在未定义将永远出现,那么你是正确的!但为什么呢?!
当你在function() {}(上述网站 B)内部定义addFuel方法时,你的this关键字指向当前对象。然而,一旦你进入函数的更深层次(网站 C),你的this现在指向那个特定的函数及其原型。因此,你不能使用this关键字访问父对象的属性。
我们如何解决这个问题?看看这些箭头函数!
var car = {
name: 'Bugatti',
fuel: 0,
// site A
addFuel: function() {
// site B
setInterval(() => { // notice!
// site C
this.fuel++;
console.log("The fuel is now " + this.fuel);
}, 1000)
}
}
现在,在网站 C 内部,this关键字指向父对象。因此,我们只能使用this关键字来访问fuel属性。
箭头函数与传统函数之间的其他区别
箭头函数不能用作对象构造函数,也就是说,不能将new运算符应用于它们。
除了语法、值和new运算符之外,箭头函数和传统函数之间没有其他区别,即它们都是Function构造函数的实例。
增强的对象字面量
曾经,JavaScript 要求开发者写出完整的函数名、属性名,即使函数名/属性名值相同(例如:var a = { obj: obj })。然而,ES6/ES7/ES8 以及之后的版本放宽了这一限制,并以多种方式允许代码的压缩和可读性。让我们看看如何。
定义属性
ES6 引入了为具有与属性同名的变量赋值对象属性的更短语法。
传统上,你会这样做:
var x = 1, y = 2;
var object = {
x: x,
y: y
};
console.log(object.x); //output "1"
但现在,你可以这样做:
let x = 1, y = 2;
let object = { x, y };
console.log(object.x); //output "1"
定义方法
从 ES6 开始,提供了定义对象上方法的新语法。以下示例演示了新语法:
let object = {
myFunction(){
console.log("Hello World!!!"); //Output "Hello World!!!"
}
}
object.myFunction();
这个简洁的函数允许在其中使用super,而传统的对象方法不允许使用super。我们将在本书的后面了解更多关于这个内容。
计算属性名
在运行时评估的属性名称为计算属性名。表达式通常被解析以动态找到属性名。
计算属性曾经是这样定义的:
var object = {};
object["first"+"Name"] = "Eden";//"firstName" is the property name
//extract
console.log(object["first"+"Name"]); //Output "Eden"
在这里,在创建对象之后,我们将属性附加到对象上。但在 ES6 中,我们可以在创建对象时添加具有计算名称的属性。以下示例演示了这一点:
let object = {
["first" + "Name"]: "Eden",
};
//extract
console.log(object["first" + "Name"]); //Output "Eden"
尾随逗号和 JavaScript
尾随逗号是指在数组列表、对象或函数参数末尾找到的逗号。当向 JavaScript 代码添加新元素、参数或属性时,它们可能很有用。它只是让开发者可以选择将数组写成[1,2,3]或[1,2,3,](注意第二个示例中的逗号)
JavaScript 长期以来允许在数组和对象中使用尾随逗号。最终,在 ECMAScript 2017(ES8)中,标准现在允许你将尾随逗号添加到函数参数中。
这意味着以下所有示例都是有效的 JavaScript 代码:
数组:
var arr = [1, 2, 3,,,];
arr.length; // 5
arr[3]; // undefined
var arr2 = [1, 2, 3,];
arr2.length; // 3
上述示例显然是有效的 JavaScript 代码,arr被创建为[1, 2, 3, undefined, undefined]
让我们现在来探索带有尾随逗号的物体行为。
对象:
var book = {
name: "Learning ES8",
chapter: "1",
reader: "awesome", // trailing comma allowed here
};
可以看到,即使在最后一个属性名后放置逗号,代码也不会抛出任何错误。现在让我们转向函数。
函数:
function myFunc(arg) {
console.log(arg);
}
function myFunc2(arg,) {
console.log(arg)
}
let myFunc3 = (arg) => {
console.log(arg);
};
let myFunc4 = (arg,) => {
console.log(arg);
}
所有上述函数定义从 ES2017(ES8)规范起都是有效的。
分号困境
你一定见过很多带有分号的 JavaScript 代码,也见过很多不带分号的代码。而且令人惊讶的是,两者都工作得很好!虽然像 C、C++、Java 等语言对分号的使用非常严格,而另一方面像 Python 这样的语言对不使用分号(只有缩进)非常严格,但 JavaScript 没有这样的固定规则。
所以让我们看看在 JavaScript 中何时需要分号。
JavaScript 中的自动分号插入
ECMAScript 语言规范(www.ecma-international.org/ecma-262/5.1/#sec-7.9)指出:
"某些 ECMAScript 语句必须以分号结束。这样的分号可以始终显式出现在源文本中"
但规范还说:
"为了方便起见,然而,在某些情况下,可以从源文本中省略这些分号。"
因此,规范指出 JavaScript 能够根据其自身判断处理自动分号插入。然而,在某些情况下,它极其容易出错,而且一点也不直观。
考虑这个例子:
var a = 1
var b = 2
var c = 3
JavaScript 会自动插入分号,使代码看起来像:
var a = 1;
var b = 2;
var c = 3;
到目前为止一切顺利。
在 JavaScript 中在哪里插入分号?
有时,你可能会发现自己跳过了某些分号,但你发现代码仍然可以正常工作!这与你在 C 或 C++等语言中遇到的情况正好相反。让我们看看一个可能会因为不正确使用分号而陷入困境的场景。
考虑以下代码:
var fn = function (arg) {
console.log(arg);
} // Semicolon missing
// self invoking function
(function () {
alert(5);
})() // semicolon missing
fn(7)
仔细观察并猜测可能出现的警告及其顺序。当你准备好答案后,看看以下内容,这是 JavaScript 编译后的代码(实际上并不是,只是插入自动分号后的代码):
var fn = function (arg) {
alert(arg);
}(function () { // <-- semicolon was missing here,
// this made it an argument for the function
alert(5);
})();
fn(7);
因此,与其调用那个自调用的函数,你显然是,将整个函数作为参数传递给第一个函数。因此,尽量使用分号来避免代码中的歧义。你总是可以在之后使用 JavaScript 压缩器,它会处理保留分号必要的地方。从这个例子中我们可以得到的启示是使用分号。
摘要
在本章中,我们学习了变量作用域、只读变量、将数组拆分为单个值、向函数传递不定参数、从对象和数组中提取数据、箭头函数以及创建对象属性的新语法、提升、IIFE、分号的使用以及更多。
在下一章中,我们将学习内置对象和符号,并会发现 JavaScript 为我们提供的许多基本工具,这些工具是开箱即用的。
第二章:了解你的库
ES6/ES7/ES8 向内置 JavaScript 对象添加了许多新的属性和方法。这些新功能旨在帮助开发者避免使用黑客和易出错的技巧来完成与数字、字符串和数组相关的各种操作。
从上一章,你现在对 JavaScript 有了相当多的背景知识,包括它是如何工作的、其基础以及诸如提升、变量作用域和不可变性等基本内容。现在让我们继续前进,看看一些你将在代码中实际使用的话题。
在本章中,我们将涵盖:
-
Number、Object、Math和Array对象的新属性和方法 -
将数字常量表示为二进制或八进制
-
创建多行字符串和
String对象的新方法 -
映射和集合
-
使用数组缓冲区和类型化数组
-
如何使用一些内置方法正确地遍历数组
-
字符串填充,等等!
处理数字
ES6、ES2016(ES7)和 ES2017(ES8)带来了创建数字的新方法以及Number对象的新属性,使得处理数字更加容易。Number对象在 ES6 中得到了极大的增强,使其更容易创建数学丰富的应用程序并防止导致错误的常见误解。
二进制表示法
以前,没有原生的方法来表示数字常量作为二进制。但现在,你可以使用0b前缀来前缀数字常量,使 JavaScript 将它们解释为二进制。
这里有一个示例:
let a = 0b00001111;
let b = 15;
console.log(a === b);
console.log(a);
输出如下:
true
15
这里,0b00001111是十进制 15 的二进制表示。
八进制表示法
八进制表示法是一种仅使用八个数字的数制,即从 0 到 7。如果你喜欢,你可以使用 JavaScript 以八进制格式表示一个数字。
以前,要表示数字常量作为八进制,我们需要使用0前缀。例如,看看以下内容:
const a = 017;
const b = 15;
console.log(a === b);
console.log(a);
输出如下:
true
15
但是,对于 JavaScript 的新程序员来说,八进制表示法和以0开头的十进制数字常常会让他们感到困惑。例如,他们认为017和17是相同的。因此,为了消除这种困惑,JavaScript 现在允许我们使用00前缀来前缀数字常量,使 JavaScript 将它们解释为八进制。
这里有一个示例来演示这一点:
const a = 0017;
const b = 15;
console.log(a === b);
console.log(a);
输出如下:
true
15
Number.isInteger(number)方法
JavaScript 中的数字以 64 位浮点数的形式存储。因此,JavaScript 中的整数是没有小数部分的浮点数,或者小数部分全是 0 的浮点数。
在 ES5 中,没有内置的方法来检查一个数字是否为整数。Number对象中存在一个新的方法isInteger(),它接受一个数字并返回true或false,这取决于该数字是否为整数。
这里有一个示例:
let a = 17.0;
let b = 1.2;
console.log(Number.isInteger(a));
console.log(Number.isInteger(b));
输出如下:
true
false
Number.isNaN(value)方法
Number.isNaN 函数仅在值等于 NaN 时返回 true。在其他所有情况下,它都返回 false。这意味着它不会尝试将不是数字的东西类型转换为数字(这通常会导致返回 NaN)。
检查以下示例:
let a = "NaN";
let b = NaN;
let c = "hello";
let d = 12;
console.log(Number.isNaN(a)); // false
console.log(Number.isNaN(b)); // true
console.log(Number.isNaN(c)); // false
console.log(Number.isNaN(d)); // false
这里你可以看到,Number.isNaN() 方法仅在传入的值正好是 NaN 时返回 true。
你可能会问,为什么不使用 == 或 === 运算符而不是 Number.isNaN(value) 方法?NaN 值是唯一一个不等于自身的值,即表达式 NaN==NaN 或 NaN===NaN 将返回 false。
如果你声明 x = NaN,那么 x 就不等于自身!
isNaN 与 Number.isNaN
对我来说,一个名为 isNaN 的方法应该直观地只在数字上返回 false,在其他所有情况下返回 true。这正是 isNaN() 全局方法所做的事情。然而,如果你想要将一个值与 NaN(你不能使用 === 或 ==)进行比较,那么 Number.isNaN 就是你的选择。
例如:
isNaN(' '); // false => because Number(' ') is equal to 0 (a number)
isNaN(true); // false => because Number(true) is equal to 1 (a number)
简而言之,isNaN 也试图执行类型转换。这就是为什么一些开发者认为它是损坏的。
Number.isFinite(number) 方法
全局 isFinite() 函数接受一个值并检查它是否是有限数。但不幸的是,它也会对转换为 Number 类型的值返回 true。
Number.isFinite() 方法解决了 window.isFinite() 函数的问题。以下是一个示例来演示这一点:
console.log(isFinite(10)); // true
console.log(isFinite(NaN)); // false
console.log(isFinite(null)); // true
console.log(isFinite([])); // true
console.log(Number.isFinite(10)); // true
console.log(Number.isFinite(NaN)); // false
console.log(Number.isFinite(null)); // false
console.log(Number.isFinite([])); // false
Number.isSafeInteger(number) 方法
JavaScript 中的数字以 64 位浮点数的形式存储,遵循国际 IEEE 754 标准。这种格式使用 64 位存储数字,其中数字(分数)存储在 0 到 51 位,指数在 52 到 62 位,符号在最后一位。
因此,在 JavaScript 中,安全的整数是指那些不需要四舍五入到其他整数以适应 IEEE 754 表示的数字。从数学上讲,从 -(2⁵³-1) 到 (2⁵³-1) 的数字被认为是安全的整数。
以下是一个示例来演示这一点:
console.log(Number.isSafeInteger(156));
console.log(Number.isSafeInteger('1212'));
console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER));
console.log(Number.isSafeInteger(Number.MAX_SAFE_INTEGER + 1));
console.log(Number.isSafeInteger(Number.MIN_SAFE_INTEGER));
console.log(Number.isSafeInteger(Number.MIN_SAFE_INTEGER - 1));
输出如下:
true
false
true
false
true
false
在这里,Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER 是在 ES6 中引入的常量值,分别代表 (2⁵³-1) 和 -(2⁵³-1)。
Number.EPSILON 属性
JavaScript 使用二进制浮点数表示,结果导致计算机无法准确表示像 0.1、0.2、0.3 等这样的数字。当你的代码执行时,像 0.1 这样的数字会被四舍五入到该格式中最接近的数字,这会导致小的舍入误差。
考虑以下示例:
console.log(0.1 + 0.2 == 0.3);
console.log(0.9 - 0.8 == 0.1);
console.log(0.1 + 0.2);
console.log(0.9 - 0.8);
输出如下:
false
false
0.30000000000000004
0.09999999999999998
Number.EPSILON 属性是在 ES6 中引入的,其值约为 2^(-52)。这个值表示在比较浮点数时合理的误差范围。使用这个数字,我们可以创建一个自定义函数来比较浮点数,同时忽略最小的舍入误差。以下示例代码:
function epsilonEqual(a, b) {
return Math.abs(a - b) < Number.EPSILON;
}
console.log(epsilonEqual(0.1 + 0.2, 0.3));
console.log(epsilonEqual(0.9 - 0.8, 0.1));
输出如下:
true
true
在这里,epsilonEqual() 是我们构建的用于比较两个值是否相等的自定义函数。现在,输出符合预期。
进行数学运算
ES6 及以上版本向 Math 对象添加了许多新方法,涉及三角学、算术和杂项。这使得开发者可以使用原生方法而不是外部数学库。原生方法针对性能进行了优化,并且具有更好的十进制精度。
与三角学相关的操作
经常需要使用与三角学、指数、对数等相关联的数学函数。JavaScript 提供了原生方法来简化我们的工作。
以下示例代码,展示了添加到 Math 对象的所有与三角学相关的方法:
console.log(Math.sinh(0)); //hyberbolic sine of a value
console.log(Math.cosh(0)); //hyberbolic cosine of a value
console.log(Math.tanh(0)); //hyberbolic tangent of a value
console.log(Math.asinh(0)); //inverse hyperbolic sine of a value
console.log(Math.acosh(1)); //inverse hyperbolic cosine of a value
console.log(Math.atanh(0)); //inverse hyperbolic tangent of a value
console.log(Math.hypot(2, 2, 1));//Pythagoras theorem
输出如下:
0
1
0
0
0
0
3
与算术相关的操作
正如我们之前讨论的,JavaScript 也公开了一些用于执行对数和指数计算的函数,这在很多情况下都非常有用(尤其是在你创建游戏时)。
以下示例代码,展示了添加到 Math 对象的所有与算术相关的方法:
console.log(Math.log2(16)); //log base 2
console.log(Math.log10(1000)); //log base 10
console.log(Math.log1p(0)); //same as log(1 + value)
console.log(Math.expm1(0)); //inverse of Math.log1p()
console.log(Math.cbrt(8)); //cube root of a value
输出如下:
4
3
0
0
2
指数操作符
ES7 引入了一种使用 JavaScript 执行指数计算的新方法,即使用新的 ** 操作符。如果你来自 Python 背景,你应该能够立即联想到这一点。单个星号表示乘法;然而,两个一起表示指数。a**b 表示 a 的 b 次幂。看看以下例子:
const a = 5**5;
const b = Math.pow(5, 5);
console.log(a);
console.log(a == b);
输出如下:
3125
true
Math.pow 之前用于执行指数计算。现在,a**b 表示将 a 乘以自身 b 次。
杂项数学方法
除了我们之前讨论的日常数学方法和运算符之外,还有一些 无聊 的方法,并不总是被使用。然而,如果你正在尝试构建下一个在线科学计算器,这里有一份你应该了解的函数列表。
Math.imul(number1, number2) 函数
Math.imul() 函数将两个数字作为 32 位整数相乘,并返回结果的下 32 位。这是 JavaScript 中执行 32 位整数乘法的唯一原生方法。
这里有一个例子来演示这一点:
console.log(Math.imul(590, 5000000)); //32-bit integer multiplication
console.log(590 * 5000000); //64-bit floating-point multiplication
输出如下:
-1344967296
2950000000
在这里,当进行乘法运算时,产生了一个太大以至于无法存储在 32 位中的数字;因此,低位的数字丢失了。
Math.clz32(number) 函数
Math.clz32() 函数返回一个数字在 32 位表示中的前导零位数。
这里有一个例子来演示这一点:
console.log(Math.clz32(7));
console.log(Math.clz32(1000));
console.log(Math.clz32(295000000));
输出如下:
29
22
3
Math.clz32() 函数通常用于 DSP 算法中,用于在声音和视频处理中归一化样本。
Math.sign(number) 函数
Math.sign() 函数返回一个数字的符号,指示数字是负数、正数还是零。
这里有一个例子来演示这一点:
console.log(Math.sign(11));
console.log(Math.sign(-11));
console.log(Math.sign(0));
输出如下:
1
-1
0
从前面的代码中,我们可以看到 Math.sign() 函数在数字为正时返回 1,在数字为负时返回 -1,在数字为零时返回 0。
Math.trunc(number) 函数
Math.trunc() 函数通过删除任何小数位来返回数字的整数部分。以下是一个示例来演示这一点:
console.log(Math.trunc(11.17));
console.log(Math.trunc(-1.112));
输出如下:
11
-1
Math.fround(number) 函数
Math.fround() 函数将一个数字四舍五入到 32 位浮点值。
以下是一个示例来演示这一点:
console.log(Math.fround(0));
console.log(Math.fround(1));
console.log(Math.fround(1.137));
console.log(Math.fround(1.5));
输出如下:
0
1
1.1369999647140503
1.5
处理字符串
ES6/ES7/ES8 提供了创建字符串的新方法,并为全局 String 对象及其实例添加了新属性,以使处理字符串更容易。与 Python 和 Ruby 等编程语言相比,JavaScript 中的字符串缺乏功能和能力;因此,ES6 增强了字符串以改变这一点。
在我们深入研究新的字符串功能之前,让我们复习一下 JavaScript 的内部字符编码和转义序列。在 Unicode 字符集中,每个字符都由一个称为代码点的十进制基数表示。代码单元是内存中存储代码点的固定位数。编码方案决定了代码单元的长度。如果使用 UTF-8 编码方案,则代码单元是 8 位,如果使用 UTF-16 编码方案,则代码单元是 16 位。如果一个代码点不适合代码单元,它将被分成多个代码单元,即表示单个字符的序列中的多个字符。
默认情况下,JavaScript 解释器将 JavaScript 源代码解释为 UTF-16 代码单元的序列。如果源代码是用 UTF-8 编码方案编写的,那么有各种方法可以告诉 JavaScript 解释器将其解释为 UTF-8 代码单元的序列。JavaScript 字符串始终是 UTF-16 代码点的序列。
任何具有小于 65,536 的代码点的 Unicode 字符都可以使用其代码点的十六进制值在 JavaScript 字符串或源代码中进行转义,前面加上 \u。转义序列是六个字符长。它们需要紧跟 \u 的正好四个字符。如果十六进制字符代码只有一位、两位或三位,则需要用前导零填充它。以下是一个示例来演示这一点:
const \u0061 = "\u0061\u0062\u0063";
console.log(a); //Output is "abc"
repeat(count) 方法
字符串的 repeat() 方法构建并返回一个新的字符串,该字符串包含在它被调用的指定数量的副本,并将它们连接在一起。以下是一个示例来演示这一点:
console.log("a".repeat(6)); //Output "aaaaaa"
includes(string, index) 方法
includes() 方法用于检查一个字符串是否可以在另一个字符串中找到,根据适当的情况返回 true 或 false。以下是一个示例来演示这一点:
const str = "Hi, I am a JS Developer";
console.log(str.includes("JS")); //Output "true"
它接受一个可选的第二个参数,表示在字符串中开始搜索的位置。以下是一个示例来演示这一点:
const str = "Hi, I am a JS Developer";
console.log(str.includes("JS", 13)); // Output "false"
startsWith(string, index) 方法
startsWith() 方法用于检查一个字符串是否以另一个字符串的字符开头,根据情况返回 true 或 false。以下是一个示例来演示这一点:
const str = "Hi, I am a JS Developer";
console.log(str.startsWith('Hi, I am')); //Output "true"
它接受一个可选的第二个参数,表示在字符串中开始搜索的位置。以下是一个示例来演示这一点:
const str = "Hi, I am a JS Developer";
console.log(str.startsWith('JS Developer', 11)); //Output "true"
endsWith(string, index) 函数
endsWith() 方法用于检查一个字符串是否以另一个字符串的字符结尾,根据情况返回 true 或 false。它还接受一个可选的第二个参数,表示假设为字符串末尾的位置。以下是一个示例来演示这一点:
const str = "Hi, I am a JS Developer";
console.log(str.endsWith("JS Developer")); //Output "true"
console.log(str.endsWith("JS", 13)); //Output "true"
indexOf(string) 函数
个人来说,99% 的时间,我使用 indexOf 而不是 startsWith 或 endsWith、includes,主要是因为我非常习惯于它,而且它非常直观。此方法将返回您在给定字符串中传递的子字符串的第一次出现的位置。如果不存在,则返回 -1。例如:
const string = "this is an interesting book and this book is quite good as well.";
console.log(string.indexOf("this"))
The output for the preceding code is:
0
这是因为子字符串在较大字符串的 0^(th) 位置找到。如果子字符串不在字符串中,indexOf 返回 -1。
你能否想出一个用 indexOf 替换 startsWith 方法的方案?以下就是答案!
const string = "this is a sentence.";
console.log(string.startsWith("this")); // true => starts with "this"
console.log(string.indexOf("this") == 0); // true => starts with "this"
lastIndexOf(string)
lastIndexOf 方法基本上与 indexOf 做的事情相同,但它将从字符串的末尾开始搜索子字符串。因此,indexOf 返回子字符串第一次出现的位置,而 lastIndexOf 返回子字符串最后一次出现的位置:
const string = "this is an interesting book and
this book is quite good as well.";
console.log(string.lastIndexOf("this"))
从这个输出结果是:
32
虽然用 lastIndexOf 替换 endsWith 字符串方法可能有点麻烦,但我仍然强烈建议你尝试自己实现它。一旦你准备好了你的解决方案,请检查以下答案:
const string = "this is an interesting book and
this book is quite good as well";
console.log(string.endsWith("well")); // true
console.log(string.lastIndexOf("well") + "well".length == string.length); // true
padStart(length [, padString])
ES2017 (ES8) 提供了 padStart() 方法,该方法使用另一个给定的字符串填充给定的字符串,以使原始字符串达到所需的长度。填充是从字符串的开始进行的。
如果没有传递 padString,则默认使用空格。请看以下示例:
'normal'.padStart(7);
'1'.padStart(3, '0');
'My Awesome String'.padStart(20, '*');
''.padStart(10, '*');
'Hey!'.padStart(13, 'But this is long');
每一行的输出结果将是:
" normal"
"001"
"****My Awesome String"
"**********"
"But this Hey!"
注意,padStart 函数中提供的长度将是整个字符串的最大长度。如果原始字符串已经大于 padStart 提供的长度,则根本不应用填充。
类似于上一个示例,如果 padString 的长度超过了所需的填充长度,padString 将从最左侧部分开始被截断,直到达到所需的长度。
这种用法的一个可能案例是:
for(let i=1;i<=100;i++) {
console.log(`Test case ${(i+"").padStart(3, "0")}`);
}
你能猜出以下输出的结果吗?这里是答案:
Test case 001
Test case 002
Test case 003
....
...
Test case 010
Test case 011
...
..
Test case 100
没有使用 padStart 函数,要实现这个解决方案是相当棘手的。您必须以某种方式手动跟踪数字,并意识到何时需要附加多少个零。尝试在没有 padStart 的情况下想出一个替代方案。
padEnd(length [, padString])
padEnd与padStart类似。区别,正如函数名所说,是它会在字符串的末尾附加提供的填充字符串。
再次考虑以下示例:
'normal'.padEnd(7);
'1'.padEnd(3, '0');
'My Awesome String'.padEnd(20, '*');
''.padEnd(10, '*');
'Hey!'.padEnd(13, 'But this is long');
这个输出的结果是:
"normal "
"100"
"My Awesome String***"
"**********"
"Hey!But this "
你也可以一起使用padStart和padEnd,例如:"1".padStart(5, "*").padEnd(10, "*"),以生成****1****。
模板字符串
模板字符串只是创建字符串的新字面量,这使得许多事情变得容易。它们提供了嵌入表达式、多行字符串、字符串插值、字符串格式化、字符串标记等功能。它们总是在运行时被处理和转换为普通 JavaScript 字符串;因此,它们可以在我们使用普通字符串的任何地方使用。
模板字符串使用反引号而不是单引号或双引号来编写。以下是一个简单模板字符串的示例:
let str1 = `hello!!!`; //template string
let str2 = "hello!!!";
console.log(str1 === str2); //output "true"
表达式
模板字符串还把所谓的“表达式”带到了 JavaScript 中。之前,除了简单地连接字符串外别无选择。例如,要在普通字符串中嵌入表达式,你会这样做:
var a = 20;
var b = 10;
var c = "JavaScript";
var str = "My age is " + (a + b) + " and I love " + c;
console.log(str);
输出如下:
My age is 30 and I love JavaScript
然而,现在模板字符串使得在字符串中嵌入表达式变得容易得多。模板字符串可以包含表达式。这些表达式放置在由美元符号和大括号指示的占位符中,即${expressions}。占位符中表达式的解析值以及它们之间的文本被传递给一个函数,以解析模板字符串为普通字符串。默认函数只是将部分连接成一个单一的字符串。如果我们使用自定义函数来处理字符串部分,那么模板字符串被称为标记模板字符串,而自定义函数被称为标记函数。
这里有一个示例,展示了如何在模板字符串中嵌入表达式:
const a = 20;
const b = 10;
const c = "JavaScript";
const str = `My age is ${a+b} and I love ${c}`;
console.log(str);
输出如下:
My age is 30 and I love JavaScript
标记模板字面量
让我们创建一个标记模板字符串,即使用一个函数来处理模板字符串字面量。让我们实现标记函数来执行与默认函数相同的事情。以下是一个演示此功能的示例:
const tag = function(strings, aPLUSb, aSTARb) {
// strings is: ['a+b equals', 'and a*b equals']
// aPLUSb is: 30
// aSTARb is: 200
return 'SURPRISE!';
};
const a = 20;
const b = 10;
let str = tag `a+b equals ${a+b} and a*b equals ${a*b}`;
console.log(str);
输出如下:
SURPRISE!
刚才发生了什么?使用标记函数,你返回的任何内容都是分配给变量的最终值。第一个参数,strings,包含你模板字面量中的所有静态字符串,作为一个数组。元素在找到表达式时被分隔。后续参数是在解析模板字面量中的表达式后收到的动态值。
所以,如果你在tag函数中修改aPLUSb变量,那么在最终结果中值将被更新。我的意思是:
const tag = function(strings, aPLUSb, aSTARb) {
// strings is: ['a+b equals', 'and a*b equals']
// aPLUSb is: 30
// aSTARb is: 200
aPLUSb = 200;
aSTARb = 30;
return `a+b equals ${aPLUSb} and a*b equals ${aSTARb}`;
};
const a = 20;
const b = 10;
>
let str = tag `a+b equals ${a+b} and a*b equals ${a*b}`;
console.log(str);
现在的输出是:
a+b equals 200 and a*b equals 30
多行字符串
模板字符串提供了一种创建包含多行文本的字符串的新方法。
在 ES5 中,我们需要使用\n换行符来添加新行。以下是一个演示此功能的示例:
console.log("1\n2\n3");
输出如下:
1
2
3
在 ES6 中,使用多行字符串,我们可以简单地写:
console.log(`1
2
3`);
输出如下:
1
2
3
在前面的代码中,我们只是在需要放置 \n 的地方添加了新行。在将模板字符串转换为普通字符串时,新行会被转换为 \n。
原始字符串
原始字符串 是一种普通字符串,其中转义字符不会被解释。我们可以使用模板字符串创建原始字符串。我们可以使用 String.raw 标签函数获取模板字符串的原始版本。以下是一个示例来演示这一点:
let s = String.raw `xy\n${ 1 + 1 }z`;
console.log(s);
输出如下:
xy\n2z
在这里 \n 不会被解释为换行符。相反,它是一个由两个字符组成的原始字符串,即 \ 和 n。变量 s 的长度将是 6。如果你创建了一个标签函数并希望返回原始字符串,那么请使用第一个参数的原始属性。
原始属性是一个数组,它包含第一个参数的字符串的原始版本。以下是一个示例来演示这一点:
let tag = function(strings, ...values) {
return strings.raw[0]
};
let str = tag `Hello \n World!!!`;
console.log(str);
输出如下:
Hello \n World!!!
模板字面量中的转义序列问题
标签模板很棒!然而,在模板字面量内部(如果使用)的转义序列有一些规则:
-
以
\u开头的任何内容都将被视为 Unicode 转义序列 -
以
\x开头的任何内容都将被视为十六进制转义序列 -
以
\开头然后跟一个数字的任何内容都将被视为八进制转义序列
因此,截至目前,即使有标签模板,由于这些语言的语法,也无法在模板字符串中使用如 LaTeX 这样的语言。
LaTeX 是一种文档准备系统,通常用于编写复杂的方程、数学公式等。使用如 E &= \frac{mc²}{\sqrt{1-\frac{v²}{c²}}} 这样的转义序列将生成一个花哨的公式:
.
ES2018(即 ES9 规范)旨在解决这个问题。
数组
向全局 Array 对象及其实例添加了一些新属性,以便更容易地处理数组。与 Python 和 Ruby 等编程语言相比,JavaScript 中的数组在功能和能力方面缺乏。让我们看看一些与数组相关且用途广泛的流行方法。
Array.from(iterable, mapFunc, this) 方法
Array.from() 方法从一个可迭代对象创建一个新的数组实例。第一个参数是可迭代对象的引用。第二个参数是可选的,是一个回调函数(称为 Map 函数),它会对可迭代对象的每个元素进行调用。第三个参数也是可选的,是 Map 函数内部的值。
以下是一个示例来演示这一点:
let str = "0123";
let arr = Array.from(str, value => parseInt(value) * 5);
console.log(arr);
输出如下:
[0, 5, 10, 15].
Array.from 在将“类似数组”结构转换为实际数组时非常有用。例如,当处理文档对象模型(DOM)时(如第十章所述,JavaScript 中的存储 API),当你从 DOM 树中获取大量元素时,你可能会希望使用诸如forEach之类的函数。然而,由于forEach之类的函数仅存在于实际数组中,因此你不能使用它们。但是,一旦你使用Array.from方法将其转换为实际数组,你就可以顺利使用了。一个简单的例子如下:
const arr = document.querySelectorAll('div');
/* arr.forEach( item => console.log(item.tagName) ) */ // => wrong
Array.from(arr).forEach( item => console.log(item.tagName) );
// correct
arr.forEach是错误的,因为arr实际上不是一个数组。它在结构上是“类似数组”的(关于这一点稍后还会讨论)。
Array.of(values…)方法
Array.of()方法是为创建数组而提供的Array构造函数的替代方案。当使用Array构造函数时,如果我们只传递一个参数,而且这个参数也是一个数字,那么Array构造函数将创建一个空数组,其数组长度属性等于传递的数字,而不是创建一个包含该数字的单元素数组。因此,引入了Array.of()方法来解决这个问题。
这里有一个例子来演示这一点:
let arr1 = Array(2);
let arr2 = Array.of(2);
console.log(arr1);
console.log(arr2);
输出如下:
[undefined, undefined]
[2]
当你动态构建一个新的数组实例时,应该使用Array.of()而不是Array构造函数,即当你不知道值的类型和元素数量时。
而不是浏览器显示[undefined, undefined],你的浏览器可能会显示[undefined x 2]或[empty x 2]作为输出。
fill(value, startIndex, endIndex)方法
数组的fill()方法从startIndex到endIndex(不包括endIndex)用给定的值填充数组中的所有元素。请记住,startIndex和endIndex参数是可选的;因此,如果它们没有提供,则整个数组将用给定的值填充。
如果只提供了startIndex,则endIndex默认为数组长度减 1。如果startIndex为负数,则视为数组长度加上startIndex。如果endIndex为负数,则视为数组长度加上endIndex。
这里有一个例子来演示这一点:
let arr1 = [1, 2, 3, 4];
let arr2 = [1, 2, 3, 4];
let arr3 = [1, 2, 3, 4];
let arr4 = [1, 2, 3, 4];
let arr5 = [1, 2, 3, 4];
arr1.fill(5);
arr2.fill(5, 1, 2);
arr3.fill(5, 1, 3);
arr4.fill(5, -3, 2);
arr5.fill(5, 0, -2);
console.log(arr1);
console.log(arr2);
console.log(arr3);
console.log(arr4);
console.log(arr5);
输出如下:
[5,5,5,5]
[1,5,3,4]
[1,5,5,4]
[1,5,3,4]
[5,5,3,4]
includes()方法
includes()方法如果某个指定的元素存在于数组中,则返回true;如果不存在,则返回false。
这一点仅用一个例子就足够简单易懂了:
const arr = [0, 1, 1, 2, 3, 5, 8, 13];
arr.includes(0); // true
arr.includes(13); // true
arr.includes(21); // false
includes()与indexOf()方法的比较
就像字符串一样,indexOf也存在于数组中,正如你所期望的,它将返回元素在数组中的位置。看看这个例子:
const arr = ['apple', 'mango', 'banana'];
console.log(arr.indexOf('apple')); // 0
console.log(arr.indexOf('mango')); // 1
console.log(arr.indexOf('apple') >= 0); // true => apple exists
console.log(arr.includes('apple')); // true => apple exists
console.log(arr.indexOf('pineapple') >= 0); // false => pineapple
// doesn't exists
console.log(arr.includes('pineapple')); // false => pineapple doesn't
//exists
那么,它们之间有什么区别呢?除非我们谈论NaN和所有那些奇怪的东西,否则实际上并没有区别。例如:
const arr = ['Some elements I like', NaN, 1337, true, false, 0017];
console.log(arr.includes(NaN)); // true
console.log(arr.indexOf(NaN) >= 0); // false => indexOf says there is
//no NaN element in array
这是因为,在底层,indexOf使用的是等价检查(===),正如之前讨论的那样,这显然在NaN上失败。因此,在数组的情况下,includes是一个更好的选择。
find(testingFunc)方法
数组的+方法如果满足提供的测试函数,则返回一个数组元素。否则,返回undefined。
find()方法接受两个参数;也就是说,第一个参数是测试函数,第二个参数是测试函数中的这个值的值。第二个参数是可选的。
测试函数有三个参数:第一个参数是正在处理的数组元素,第二个参数是正在处理的当前元素的索引,第三个参数是调用find()的数组。
测试函数需要返回true以满足一个值。find()方法返回满足提供的测试函数的第一个元素。
这里有一个示例来演示find()方法:
const x = 12;
const arr = [11, 12, 13];
const result = arr.find( (value, index, array) => value == x )
console.log(result); //Output "12"
findIndex(testingFunc)方法
findIndex()方法与find()方法类似。findIndex()方法返回满足条件的数组元素的索引,而不是元素本身。看看这个例子:
const x = 12;
const arr = [11, 12, 13];
const result = arr.findIndex( (value, index, array) => value == x );
console.log(result);
输出是1。
copyWithin(targetIndex, startIndex, endIndex)函数
数组的copyWithin()方法用于将数组的值序列复制到数组中的不同位置。
copyWithin()方法接受三个参数:第一个参数表示要复制元素的目标索引,第二个参数表示开始复制的索引位置,第三个参数表示索引,即复制元素应该结束的位置。
第三个参数是可选的,如果没有提供,则默认为length-1,其中length是数组的长度。如果startIndex是负数,则计算为length+startIndex。同样,如果endIndex是负数,则计算为length+endIndex。
这里有一个示例来演示这一点:
const arr1 = [1, 2, 3, 4, 5];
const arr2 = [1, 2, 3, 4, 5];
const arr3 = [1, 2, 3, 4, 5];
const arr4 = [1, 2, 3, 4, 5];
arr1.copyWithin(1, 2, 4);
arr2.copyWithin(0, 1);
arr3.copyWithin(1, -2);
arr4.copyWithin(1, -2, -1);
console.log(arr1);
console.log(arr2);
console.log(arr3);
console.log(arr4);
输出如下:
[1,3,4,4,5]
[2,3,4,5,5]
[1,4,5,4,5]
[1,4,3,4,5]
entries()、keys()和values()方法
数组的entries()方法返回一个可迭代对象,它包含数组的每个索引的键/值对。同样,数组的keys()方法返回一个可迭代对象,它包含数组中每个索引的键。
同样,数组的values()方法返回一个可迭代对象,它包含数组的值。entries()方法返回的可迭代对象以数组的形式存储键/值对。
这些函数返回的可迭代对象不是一个数组。
这里有一个示例来演示这一点:
const arr = ['a', 'b', 'c'];
const entries = arr.entries();
const keys = arr.keys();
const values = arr.values();
console.log(...entries);
console.log(...keys);
console.log(...values);
输出如下:
0,a 1,b 2,c
0 1 2
a b c
arr.values()在写作时(2017 年 11 月)仍然非常实验性,并且大多数浏览器中尚未实现。
数组迭代
在开发过程中,你大部分时间都会在迭代数组:来自 REST API 的数组、来自用户输入的数组、来自这里的数组、来自那里的数组。因此,掌握一些你可以用来迭代数组的重要工具是至关重要的。
map()方法
map()方法创建并返回一个新数组,并将该数组的每个元素传递给提供的函数。看看这个例子:
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const squaredNums = arr.map( num => num**2 );
console.log(squaredNums);
输出如下:
[1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
在前面的函数中,当你对arr运行map时,每个值都会逐个传递给提供的函数。值包含为num。由于我们使用 ES6 箭头函数表示法,一切看起来都非常简洁和整洁。
filter()方法
filter()方法创建一个新数组,只包含通过程序员定义的测试的给定数组中的元素。
例如:
const arr = ['Mike', 'John', 'Mehul', 'Rahul', 'Akshay', 'Deep','Om', 'Ryan'];
const namesWithOnly4Letters = arr.filter( name => name.length == 4 );
console.log(namesWithOnly4Letters);
输出如下:
["Mike", "John", "Deep", "Ryan"]
如您所见,在filter中,我们提供的函数始终返回一个布尔值。每当内部函数返回true时,该特定元素就会包含在namesWithOnly4Letters中。每当它返回false时,它就不会包含。
forEach()方法
forEach()方法会对数组中的每个元素调用给定的函数。它与map函数不同,因为map函数基于从map函数返回的内容创建原始数组的副本。但forEach只是对每个元素运行一个函数。它不关心你从函数返回什么。
看看这个:
const arr = [1, 2, 3, 4];
arr.forEach( (value, index) => console.log(`arr[${index}] = ${value}`) );
输出如下:
arr[0] = 1
arr[1] = 2
arr[2] = 3
arr[3] = 4
显然,当你只想对数组的元素执行一系列操作时,使用forEach。
some()方法
some()方法将检查给定数组中的任何元素是否通过提供的测试(使用函数)。如果它找到一个通过测试的元素,它就会停止,不会运行进一步(并返回true)。否则,它将返回false。
这里有一个例子:
const arr = [1, 3, 5, 7, 9, 10, 11];
const isAnyElementEven = arr.some( elem => {
console.log('Checking '+elem);
return elem % 2 == 0
});
console.log(isAnyElementEven); // true
输出如下:
Checking 1
Checking 3
Checking 5
Checking 7
Checking 9
Checking 10
true
注意,一旦测试通过,它就会停止在10。
集合
集合是一个将多个元素存储为单一单元的对象。ES6 引入了各种新的集合对象,以提供更好的存储和组织数据的方法。
在 ES5 中,数组是唯一的集合对象。现在我们有 ArrayBuffers、SharedArrayBuffers、Typed Arrays、Sets 和 Maps,这些都是内置的集合对象。
让我们探索 JavaScript 中提供的不同集合对象。
ArrayBuffer
数组的元素可以是任何类型,例如字符串、数字、对象等。数组可以动态增长。数组的问题在于它们在执行时间上较慢,并且占用更多内存。这导致在开发需要大量计算和处理大量数字的应用程序时出现问题。因此,引入了数组缓冲区来解决这个问题。
数组缓冲区是内存中 8 位块的集合。每个块都是一个数组缓冲区元素。数组缓冲区的大小在创建时需要确定;因此,它不能动态增长。数组缓冲区只能存储数字。所有块在创建数组缓冲区时都初始化为数字 0。
使用ArrayBuffer构造函数创建数组缓冲区对象:
const buffer = new ArrayBuffer(80); //80 bytes size
使用DateView对象可以从ArrayBuffer对象中读取值并写入值。不强制使用 8 位来表示一个数字。我们可以使用 8 位、16 位、32 位和 64 位来表示一个数字。以下是一个示例,展示了如何创建DateView对象并读取/写入ArrayBuffer对象:
const buffer = new ArrayBuffer(80);
const view = new DataView(buffer);
view.setInt32(8,22,false);
const number = view.getInt32(8,false);
console.log(number); //Output "22"
在这里,我们使用DataView构造函数创建了一个DataView对象。DataView对象提供了几种方法来将数字读入和写入ArrayBuffer对象。这里我们使用了setInt32()方法,它使用 32 位来存储提供的数字。所有用于将数据写入ArrayBuffer对象的DataView对象的方法都接受三个参数。第一个参数表示偏移量,即我们想要写入数字的字节。第二个参数是要存储的数字。第三个参数是一个布尔类型,表示数字的端序,例如false表示大端序。
类似地,所有用于从ArrayBuffer对象读取数据的DataView对象的方法都接受两个参数。第一个参数是偏移量,第二个参数表示使用的端序。
这里是DataView对象提供的其他存储数字的函数:
-
setInt8: 使用 8 位来存储一个数字。它接受一个有符号整数(负数或正数)。 -
setUint8: 使用 8 位来存储一个数字。它接受一个无符号整数(正数)。 -
setInt16: 使用 16 位来存储一个数字。它接受一个有符号整数。 -
setUint16: 使用 16 位来存储一个数字。它接受一个无符号整数。 -
setInt32: 使用 32 位来存储一个数字。它接受一个有符号整数。 -
setUint32: 使用 32 位来存储一个数字。它接受一个无符号整数。 -
setFloat32: 使用 32 位来存储一个数字。它接受一个有符号的十进制数。 -
setFloat64: 使用 64 位来存储一个数字。它接受一个有符号的十进制数。
这里是其他通过DataView对象检索存储数字的函数:
-
getInt8: 读取 8 位。返回一个有符号整数。 -
getUint8: 读取 8 位。返回一个无符号整数。 -
getInt16: 读取 16 位。返回一个有符号整数。 -
getUint16: 读取 16 位。返回一个无符号整数。 -
getInt32: 读取 32 位。返回一个有符号整数。 -
getUint32: 读取 32 位。返回一个无符号整数。 -
getFloat32: 读取 32 位。返回一个有符号的十进制数。 -
getFloat64: 读取 64 位。返回一个有符号的十进制数。
类型化数组
我们看到了如何在数组缓冲区中读取和写入数字。但是方法非常繁琐,因为我们必须每次都调用一个函数。类型化数组允许我们像对普通数组那样读取和写入ArrayBuffer对象。
类型化数组作为ArrayBuffer对象的包装器,并将ArrayBuffer对象中的数据视为 n 位数字的序列。n值取决于我们如何创建类型化数组。
接下来是一个代码示例,演示了如何创建ArrayBuffer对象并使用类型数组对其进行读写:
const buffer = new ArrayBuffer(80);
const typed_array = new Float64Array(buffer);
typed_array[4] = 11;
console.log(typed_array.length);
console.log(typed_array[4]);
输出如下:
10
11
我们使用Float64Array构造函数创建了一个类型数组。因此,它将ArrayBuffer中的数据视为 64 位有符号十进制数的序列。这里ArrayBuffer对象的大小为 640 位;因此,只能存储 10 个 64 位数字。
类似地,还有其他类型数组构造函数,用于将ArrayBuffer中的数据表示为不同位数的序列。以下是列表:
-
Int8Array:表示 8 位有符号整数 -
Uint8Array:表示 8 位无符号整数 -
Int16Array:表示 16 位有符号整数 -
Uint16Array:表示 16 位无符号整数 -
Int32Array:表示 32 位有符号整数 -
Uint32Array:表示 32 位无符号整数 -
Float32Array:表示 32 位有符号十进制数 -
Float64Array:表示 64 位有符号十进制数
类型数组提供了正常 JavaScript 数组提供的所有方法。它们还实现了可迭代协议;因此,它们可以用作可迭代对象。
我们将在第十二章“共享内存和原子操作”中使用类型数组
Set
Set是一个包含任何数据类型唯一值的集合。Set 中的值按插入顺序排列。Set 是通过Set构造函数创建的。以下是一个示例:
const set1 = new Set();
const set2 = new Set("Hello!!!");
在这里,set1是一个空集,而set2是使用可迭代对象(即字符串的字符和字符串)创建的,因此不为空;因此,set2不为空。以下示例代码演示了可以在 Set 上执行的各种操作:
let set = new Set("Hello!!!");
set.add(12); //add 12
console.log(set.has("!")); //check if value exists
console.log(set.size);
set.delete(12); //delete 12
console.log(...set);
set.clear(); //delete all values
输出如下:
true
6
H e l o !
在这里,我们向Set对象中添加了九个项目,但大小仅为六个,因为 Set 会自动删除重复的值。字符l和!被重复多次。Set对象还实现了可迭代协议,因此可以用作可迭代对象。
当你想维护一组值并检查值是否存在而不是检索值时,使用集合。例如,如果你在代码中只使用数组的indexOf()方法来检查值是否存在,则可以将集合用作数组的替代品。
WeakSet
下面是Set和WeakSet对象之间的区别:
-
Set可以存储原始类型和对象引用,而WeakSet对象只能存储对象引用 -
WeakSet对象的一个重要特性是,如果没有其他引用指向存储在WeakSet对象中的对象,则它们会被垃圾回收 -
最后,
WeakSet对象是不可枚举的:也就是说,你不能找到它的大小;它也没有实现可迭代协议
除了这三个区别之外,WeakSet的行为与Set完全相同。除了这三个区别之外,Set和WeakSet对象之间的一切都是相同的。
一个 WeakSet 对象是通过 WeakSet 构造函数创建的。你不能将可迭代对象作为参数传递给 WeakSet 对象。
这里有一个示例来演示 WeakSet:
let weakset = new WeakSet();
(function(){
let a = {};
weakset.add(a);
})(); //here 'a' is garbage collected from weakset
console.log(weakset.size); //output "undefined"
console.log(...weakset); //Exception is thrown
weakset.clear(); //Exception, no such function
Map
一个 Map 是键/值对的集合。Map 的键和值可以是任何数据类型。键/值对按插入顺序排列。Map 对象是通过 Map 构造函数创建的。
这里有一个示例,展示了如何创建一个 Map 对象并在其上执行各种操作:
let map = new Map();
let o = {n: 1};
map.set(o, "A"); //add
map.set("2", 9);
console.log(map.has("2")); //check if key exists
console.log(map.get(o)); //retrieve value associated with key
console.log(...map);
map.delete("2"); //delete key and associated value
map.clear(); //delete everything
//create a map from iterable object
let map_1 = new Map([[1, 2], [4, 5]]);
console.log(map_1.size); //number of keys
输出如下:
true
A
[object Object],A 2,9
2
当从一个可迭代对象创建 Map 对象时,我们需要确保可迭代对象返回的值是长度为 2 的数组;也就是说,索引 0 是键,索引 1 是值。
如果我们尝试添加一个已经存在的键,那么它会被覆盖。Map 对象也实现了可迭代协议,因此也可以用作可迭代对象。在通过可迭代协议迭代 Map 时,它们返回键/值对数组,正如前一个示例所示。
WeakMap
WeakMap,正如其名所示,是一个对象,其中的键是弱引用到键/值对的。这意味着值可以是任何东西。键是弱引用的,因为键是对象。
Map 和 WeakMap 对象之间的区别如下:
-
Map对象的键可以是原始类型或对象引用,但WeakMap对象中的键只能是对象引用。 -
WeakMap对象的一个重要特性是,如果一个对象没有其他引用,那么该对象被键引用时,该键会被垃圾回收。 -
最后,一个
WeakMap对象是不可枚举的,也就是说,你不能找到它的大小,它也没有实现可迭代协议。
在其他所有方面,除了这三个区别之外,Map 和 WeakMap 对象是相似的。
WeakMap 是通过 WeakMap 构造函数创建的。以下是一个演示其用法的示例:
let weakmap = new WeakMap();
(function(){
let o = {n: 1};
weakmap.set(o, "A");
})(); // here 'o' key is garbage collected
let s = {m: 1};
weakmap.set(s, "B");
console.log(weakmap.get(s));
console.log(...weakmap); // exception thrown
weakmap.delete(s);
weakmap.clear(); // Exception, no such function
let weakmap_1 = new WeakMap([[{}, 2], [{}, 5]]); //this works
console.log(weakmap_1.size); //undefined
Objects
对象在 JavaScript 中已经存在很长时间了。它们是 JavaScript 的骨架,因为几乎每种数据类型都可以与 对象 关联(new String()、new Number()、new Boolean() 等等)。当你在处理网络应用程序或 JavaScript 时,你经常会发现自己一直在处理和操作对象。
ES6、ES2016(ES7)和 ES2017(ES8)引入了许多与对象相关的新属性和方法。让我们来看看它们。
Object.values()
ES8 引入了 Object.values() 方法,以便程序员可以以数组的形式检索对象的所有值。这之前可以通过手动遍历对象的每个属性并将它的值存储在数组中来实现。
这里有一个示例:
const obj = {
book: "Learning ES2017 (ES8)",
author: "Mehul Mohan",
publisher: "Packt",
useful: true
};
console.log(Object.values(obj));
输出将如下:
["Learning ES2017 (ES8)", "Mehul Mohan", "Packt", true]
Object.entries()
Object.entries() 可以用来将一个对象转换成数组形式的键/值对。这意味着你的对象将被转换成一个二维数组(在最简单的情况下),每个元素都是一个包含键和值的数组。看看这个例子:
const obj = {
book: "Learning ES2017 (ES8)",
author: "Mehul Mohan",
publisher: "Packt",
useful: true
};
console.log(Object.entries(obj));
输出将如下:
[["book","Learning ES2017 (ES8)"],["author","Mehul Mohan"],["publisher","Packt"],["useful",true]]
__proto__ 属性
JavaScript 对象有一个内部 [[prototype]] 属性,它引用对象的原型,即它继承的对象:这是 JavaScript 使用的 原型继承模型。为了读取属性,我们必须使用 Object.getPrototypeOf(),为了创建具有给定原型的新的对象,我们必须使用 Object.create() 方法。[[prototype]] 属性不能直接读取或修改。
由于 [[prototype]] 属性的性质,继承变得很繁琐;因此,一些浏览器在对象中添加了一个特殊的 __proto__ 属性,它是一个访问器属性,暴露了内部的 [[prototype]] 属性,使得与原型的工作更加容易。__proto__ 属性在 ES5 中没有标准化,但由于其流行,它在后续版本中得到了标准化。
以下是一个演示这个的示例:
//In ES5
var x = {prop1: 12};
var y = Object.create(x, {prop2: {value: 13}});
console.log(y.prop1); //Output "12"
console.log(y.prop2); //Output "13"
console.log(x); // Output: {prop1: 12}
console.log(y); // Output: {prop2: 13}
//In ES6 onwards
let a = {prop1: 12, __proto__: {prop2: 13}};
console.log(a.prop1); //Output "12"
console.log(a.prop2); //Output "13"
console.log(a); // Output: {prop1: 12}
console.log(a.__proto__); // Output: {prop2: 13}
仔细观察:
-
在 ES5 的例子中,对象
y从对象x继承;因此,当你简单地对对象y使用console.log时,它从对象x继承的属性不会直接可见(或者更确切地说,它们是隐藏的)。然而,当你尝试访问y.prop2时,JavaScript 不会在对象y上找到它,所以它会查看__proto__链(这是 JavaScript 的工作方式),并发现实际上在原型链上有一个对prop2的引用。然而,在 ES5 中无法直接编辑它。 -
在 ES6/ES7/ES8/ES.next 及以后的版本中,你可以直接向对象的原型链中添加值。
Object.is(value1, value2) 方法
Object.is() 方法确定两个值是否相等。它与 === 运算符类似,但 Object.is() 方法有一些特殊情况。以下是一个演示这些特殊情况的示例:
console.log(Object.is(0, -0));
console.log(0 === -0);
console.log(Object.is(NaN, 0/0));
console.log(NaN === 0/0);
console.log(Object.is(NaN, NaN));
console.log(NaN ===NaN);
输出如下:
false
true
true
false
true
false
这里有一个你可能想要查看的方便的表格,用于比较 0、、= 和 Object.is 之间的区别:**

虽然看起来直观,Object.is 可以比较两个给定的对象是否相同,但这并不是事实。x = {foo: 1} 和 y = {foo: 1} 在所有三个运算符(==、=== 和 Object.is)中都不是相同的。
Object.setPrototypeOf(object, prototype) 方法
Object.setPrototypeOf() 方法只是另一种方式来分配对象的 [[prototype]] 属性,这是我们刚刚讨论过的。你可以使用这个方法或者直接操作 __proto__ 属性。然而,使用方法是一种更干净、更容易阅读的方法。以下是一个示例来演示这一点:
let x = {x: 12};
let y = {y: 13};
Object.setPrototypeOf(y, x);
console.log(y.x); //Output "12"
console.log(y.y); //Output "13"
Object.assign(targetObj, sourceObjs...) 方法
Object.assign() 方法用于从一个或多个源对象复制所有可枚举的自有属性到目标对象。此方法将返回 targetObj。以下是一个演示此功能的示例:
let x = {x: 12};
let y = {y: 13, __proto__: x};
let z = {z: 14, get b() {return 2;}, q: {}};
Object.defineProperty(z, "z", {enumerable: false});
let m = {};
Object.assign(m, y, z);
console.log(m.y);
console.log(m.z);
console.log(m.b);
console.log(m.x);
console.log(m.q == z.q);
输出如下:
13
undefined
2
undefined
true
在使用 Object.assign() 方法时,以下是一些重要事项需要记住:
-
它在源上调用 getter,在目标上调用 setter。
-
它只是将源属性的值赋给目标的新或现有属性。
-
它不会复制源的
[[prototype]]属性。 -
JavaScript 属性名可以是字符串或符号。
Object.assign()会复制两者。 -
属性定义不是从源中复制的;因此,你需要使用
Object.getOwnPropertyDescriptor()。 -
它会忽略带有 null 和 undefined 值的键的复制。
Object.getOwnPropertyDescriptors()
在 ES8 中引入的 Object.getOwnPropertyDescriptors() 方法将返回给定对象的全部属性描述符。这究竟意味着什么呢?让我们来看一看:
const details = {
get food1() { return 'tasty'; },
get food2() { return 'bad'; }
};
Object.getOwnPropertyDescriptors(details);
生成的输出是:
{
food1: {
configurable: true,
enumerable: true,
get: function food1(){}, //the getter function
set: undefined
},
food2: {
configurable: true,
enumerable: true,
get: function food2(){}, //the getter function
set: undefined
}
}
当你尝试访问属性时(但同时也想先做一堆事情),get() 函数会被触发。所以,当你执行 details.food1 时,会返回 tasty。
这种实际用法主要应用于 装饰器(这是一个全新的主题)以及创建浅拷贝,如下所示:
const x = { foo: 1, __proto__: { bar: 2 } };
const y = Object.create(
Object.getPrototypeOf(x),
Object.getOwnPropertyDescriptors(x)
);
console.log(y.__proto__); // { bar: 2 }
摘要
在本章中,我们学习了 ES8、ES7 和 ES6 中新增的用于处理数字、字符串、数组和对象的特性。我们看到了数组在数学密集型应用中的影响以及如何使用数组缓冲区来替代。我们还探讨了 ES8 提供的新集合对象。
在下一章中,我们将探讨符号和迭代协议,同时也会探索 yield 关键字和生成器。许多激动人心和前沿的内容即将呈现在你面前!请保持耐心!
第三章:使用迭代器
ES8 及更早版本引入了新的对象接口和循环迭代。新迭代协议的添加为 JavaScript 打开了算法和能力的新世界。我们将从介绍符号和 Symbol 对象的各种属性开始本章。我们还将学习嵌套函数调用如何创建执行栈,它们的影响,以及如何优化它们的性能和内存使用。
虽然符号是迭代器的一个独立主题,但我们仍将在本章中涵盖符号,因为要实现迭代协议,你需要使用符号。
本章我们将涵盖:
-
使用符号作为对象属性键
-
在对象中实现迭代协议
-
创建和使用
generator对象 -
使用
for…of循环进行迭代 -
尾调用优化
符号 – 原始数据类型
符号 是一种在 ES6 中首次引入的原始类型。符号是一个唯一且不可变的值。以下是一个示例,展示了如何创建一个符号:
const s = Symbol();
符号没有字面形式;因此,我们需要使用 Symbol() 函数来创建一个符号。每次调用 Symbol() 函数时,它都会返回一个唯一的符号。
Symbol() 函数接受一个可选的字符串参数,表示符号的描述。符号的描述可用于调试,但不能用来访问符号本身。具有相同描述的两个符号完全不等于彼此。以下是一个示例来演示这一点:
let s1 = Symbol("My Symbol");
let s2 = Symbol("My Symbol");
console.log(s1 === s2); // Outputs false
从前面的示例中,我们也可以说符号是一个类似于字符串的值,它不会与其他任何值冲突。
typeof 操作符
typeof 操作符用于确定特定变量/常量持有的值的类型。对于 Symbol,typeof 输出 symbol。以下是一个示例来演示相同的内容:
const s = Symbol();
console.log(typeof s); //Outputs "symbol"
使用 typeof 操作符是唯一识别变量是否持有符号的方法。
新操作符
你不能将 new 操作符应用于 Symbol() 函数。Symbol() 函数会检测它是否被用作构造函数,如果是,则抛出异常。
下面是一个示例来演示这一点:
try {
let s = new Symbol(); //"TypeError" exception
}
catch(e) {
console.log(e.message); //Output "Symbol is not a constructor"
}
但 JavaScript 引擎可以使用 Symbol() 函数作为构造函数在内部包装一个符号。因此,s 将等于对象(s)。
从 ES6 开始引入的所有原始类型都不允许手动调用它们的构造函数。
使用符号作为对象属性键
直到 ES5,JavaScript 对象属性键必须是字符串类型。但自从 ES6 以来,JavaScript 对象属性键可以是字符串或符号。以下是一个示例,演示了如何使用符号作为对象属性键:
let obj = null;
let s1 = null;
(function(){
let s2 = Symbol();
s1 = s2;
obj = {[s2]: "mySymbol"}
console.log(obj[s2]);
console.log(obj[s2] == obj[s1]);
})();
console.log(obj[s1]);
输出是:
mySymbol
true
mySymbol
从前面的代码中,你可以看到,为了使用符号创建或检索属性键,你需要使用 [] 符号。我们在讨论第二章中的计算属性名时看到了 [] 符号,第二章,了解你的库。
要访问一个符号属性键,我们需要符号。在先前的例子中,s1 和 s2 都持有相同的符号值。
Object.getOwnPropertySymbols() 方法
Object.getOwnPropertyNames() 方法无法检索符号属性。因此,ES6 引入了 Object.getOwnPropertySymbols() 来检索对象的一组符号属性。以下是一个示例来演示这一点:
let obj = {a: 12};
let s1 = Symbol("mySymbol");
let s2 = Symbol("mySymbol");
Object.defineProperty(obj, s1, {
enumerable: false
});
obj[s2] = "";
console.log(Object.getOwnPropertySymbols(obj));
输出如下:
Symbol(mySymbol),Symbol(mySymbol)
从先前的例子中,你可以看到 Object.getOwnPropertySymbols() 方法也可以检索不可枚举的符号属性。
in 操作符可以在对象中找到符号属性,而 for…in 循环和 Object.getOwnPropertyNames() 由于向后兼容性的原因,不能在对象中找到符号属性。
Symbol.for(string) 方法
Symbol 对象维护了一个键/值对的注册表,其中键是符号描述,值是符号。每次我们使用 Symbol.for() 方法创建符号时,它都会被添加到注册表中,并且该方法返回符号。如果我们尝试使用已存在的描述来创建符号,那么将检索现有的符号。
使用 Symbol.for() 方法而不是 Symbol() 方法创建符号的优势在于,在使用 Symbol.for() 方法时,你不必担心使符号在全局范围内可用,因为它始终在全局范围内可用。以下是一个示例来演示这一点:
let obj = {};
(function(){
let s1 = Symbol("name");
obj[s1] = "Eden";
})();
//obj[s1] cannot be accessed here
(function(){
let s2 = Symbol.for("age");
obj[s2] = 27;
})();
console.log(obj[Symbol.for("age")]); //Output "27"
已知符号
除了你自己的符号外,ES6 还提供了一套内置的符号集合,称为已知符号。以下是一个属性列表,引用了一些重要的内置符号:
-
Symbol.iterator -
Symbol.match -
Symbol.search -
Symbol.replace -
Symbol.split -
Symbol.hasInstanceSymbol.species -
Symbol.unscopables -
Symbol.isContcatSpreadable -
Symbol.toPrimitive
你将在本书的各个章节中遇到这些符号的使用。
当在文本中引用已知的符号时,我们通常使用 @@ 符号来作为前缀。例如,Symbol.iterator 符号被称为 @@iterator 方法。这样做是为了使在文本中引用这些符号更加容易。
迭代协议
迭代协议是一组规则,对象需要遵循这些规则来实现接口。当使用此协议时,循环或构造可以遍历对象的一组值。
JavaScript 有两个名为 iterator 和 iterable 的迭代协议。
迭代协议
任何实现了迭代器协议的对象都被称为迭代器。根据迭代器协议,一个对象需要提供一个next()方法,该方法返回一组项目序列中的下一个项目。
这里有一个例子来演示这一点:
let obj = {
array: [1, 2, 3, 4, 5],
nextIndex: 0,
next: function() {
return this.nextIndex < this.array.length ? {value: this.array[this.nextIndex++], done: false} : {done: true}
}
};
console.log(obj.next().value);
console.log(obj.next().value);
console.log(obj.next().value);
console.log(obj.next().value);
console.log(obj.next().value);
console.log(obj.next().done);
输出如下:
1
2
3
4
5
true
如果你仔细观察,你会发现obj对象内部的next方法如下:
return this.nextIndex < this.array.length ? {value: this.array[this.nextIndex++], done: false} : {done: true}
这可以写成以下形式:
if(this.nextIndex < this.array.length) {
this.nextIndex++;
return { value: this.array[this.nextIndex], done: false }
} else {
return { done: true }
}
这清楚地告诉我们,如果对象obj中存在新元素,我们将增加nextIndex并从对象obj中发送array的下一个元素。当没有元素剩下时,我们返回{ done: true }。
可迭代协议
任何实现了可迭代协议的对象都被称为可迭代对象。根据可迭代协议,一个对象需要提供一个@@iterator方法;也就是说,它必须有一个Symbol.iterator符号作为属性键。@@iterator方法必须返回一个迭代器对象。
这里有一个例子来演示这一点:
let obj = {
array: [1, 2, 3, 4, 5],
nextIndex: 0,
[Symbol.iterator]: function(){
return {
array: this.array,
nextIndex: this.nextIndex,
next: function(){
return this.nextIndex < this.array.length ?
{value: this.array[this.nextIndex++], done: false} :
{done: true};
}
}
}
};
let iterable = obj[Symbol.iterator]()
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().done);
输出如下:
1
2
3
4
5
true
这一切看起来都很不错,但这样做有什么用呢?
前两个代码块展示了如何在自身上实现可迭代协议。然而,像数组这样的东西自带可迭代协议(即,它们的__proto__链实现了Symbol.iterator方法),这是默认实现的,从而节省了开发者的时间。让我们来看一个例子:
const arr = [1, 2]
const iterator = arr[Symbol.iterator](); // returns you an iterator
console.log(iterator.next())
console.log(iterator.next())
console.log(iterator.next())
根据我们迄今为止所学到的,你认为输出应该是什么?
输出如下:
{ value: 1, done: false }
{ value: 2, done: false }
{ value: undefined, done: true }
现在我们来看看生成器,它们或多或少与迭代器相似。
生成器函数
一个generator是一个普通的函数,但它不是返回一个单一值,而是逐个返回多个值。调用generator函数不会立即执行其主体,而是返回一个新的generator对象实例(即,一个实现了可迭代和迭代器协议的对象)。
每个generator对象都持有generator函数的新执行上下文。当我们执行generator对象的next()方法时,它会在遇到yield关键字之前执行generator函数的主体。它返回产生的值并暂停函数。当再次调用next()方法时,它继续执行并返回下一个产生的值。当generator函数没有产生任何值时,done属性为true。
generator函数是用function*表达式编写的。这里有一个例子来演示这一点:
function* generator_function(){
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
let generator = generator_function();
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().done);
generator = generator_function();
let iterable = generator[Symbol.iterator]();
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().value);
console.log(iterable.next().done);
输出如下:
1
2
3
4
5
true
1
2
3
4
5
true
在yield关键字后面有一个表达式。该表达式的值是通过可迭代协议由generator函数返回的。如果我们省略这个表达式,那么返回undefined。这个表达式的值就是我们所说的,产生的值。
我们也可以向 next() 方法传递一个可选参数。这个参数成为 yield 语句返回的值,其中 generator 函数当前处于暂停状态。以下是一个示例来演示这一点:
function* generator_function(){
const a = yield 12;
const b = yield a + 1;
const c = yield b + 2;
yield c + 3; // Final Line
}
const generator = generator_function();
console.log(generator.next().value);
console.log(generator.next(5).value);
console.log(generator.next(11).value);
console.log(generator.next(78).value);
console.log(generator.next().done);
输出如下:
12
6
13
81
true
这里是对这个输出的解释:
-
在第一次调用
generator.next()时,调用yield 12并返回值12。 -
在第二次调用
generator.next(5)时,之前的yield(存储在const a中)获取传递的值(即5),然后是第二个yield(a + 1)。然后,调用yield 5 + 1并返回值6(注意:这里的a不是12)。 -
在第三次调用
generator.next(11)时,const b变为11,然后因为它是11 + 2的和,所以返回13。 -
这一直持续到最后一个过程,即直到示例中提到的
Final Line行。 -
由于
yield最终返回一个值和其done状态,在执行yield c + 3之后,显然没有值可以yield。因此,返回的值是undefined,done是true。
返回(value)方法
你可以在 generator 函数完成所有值 yield 之前,使用 generator 对象的 return() 方法随时结束它。return() 方法接受一个可选参数,表示要返回的最终值。
这里有一个示例来演示这一点:
function* generator_function(){
yield 1;
yield 2;
yield 3;
}
const generator = generator_function();
console.log(generator.next().value);
console.log(generator.return(22).value);
console.log(generator.next().done);
输出如下:
1
22
true
throw(exception) 方法
你可以使用 generator 对象的 throw() 方法在 generator 函数内部手动触发异常。你必须向 throw() 方法传递你想要抛出的异常。以下是一个示例来演示这一点:
function* generator_function(){
try {
yield 1;
} catch(e) {
console.log("1st Exception");
}
try {
yield 2;
} catch(e) {
console.log("2nd Exception");
}
}
const generator = generator_function();
console.log(generator.next().value);
console.log(generator.throw("exception string").value);
console.log(generator.throw("exception string").done);
输出如下:
1
1st Exception
2
2nd Exception
true
在前面的示例中,你可以看到异常是在函数最后暂停的地方抛出的。在异常被处理后,throw() 方法继续执行,并返回下一个 yield 的值。
yield* 关键字
在 generator 函数内部,yield* 关键字将可迭代对象作为表达式并迭代它以 yield 其值。以下是一个示例来演示这一点:
function* generator_function_1(){
yield 2;
yield 3;
}
function* generator_function_2(){
yield 1;
yield* generator_function_1();
yield* [4, 5];
}
const generator = generator_function_2();
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().done);
输出如下:
1
2
3
4
5
true
for...of 循环
到目前为止,我们一直使用 next() 方法迭代可迭代对象,这是一个繁琐的任务。ES6 引入了 for...of 循环来简化这个过程。
for...of 循环被引入来迭代可迭代对象的值。以下是一个示例来演示这一点:
function* generator_function(){
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
let arr = [1, 2, 3];
for(let value of generator_function()){
console.log(value);
}
for(let value of arr){
console.log(value);
}
输出如下:
1
2
3
4
5
1
2
3
尾调用优化
每当进行函数调用时,在堆栈内存中创建一个执行栈来存储函数的变量。尾调用优化基本上意味着如果没有信息在代码执行序列的后续部分中需要该堆栈分配的信息,你可以重用分配的堆栈。
为什么需要尾调用优化?
当在另一个函数调用内部进行函数调用时,会为内部函数调用创建一个新的执行栈。然而,问题是内部函数执行栈会占用一些额外的内存——也就是说,它存储了一个额外的地址,表示当这个函数执行完毕后如何恢复执行。切换和创建执行栈也会消耗一些额外的 CPU 时间。当调用嵌套层级只有几百层时,这个问题并不明显,但当有数千层或更多嵌套层级时,这个问题就很明显了——即 JavaScript 引擎抛出 RangeError: Maximum call stack size exceeded 异常。你可能在创建递归函数时遇到过 RangeError 异常。
尾调用是在函数的末尾,通过 return 语句可选执行的函数调用。如果一个尾调用反复调用同一个函数,那么它被称为尾递归,这是递归的一个特殊情况。尾调用特殊之处在于,实际上有一种方法可以防止在尾调用时额外的 CPU 时间和内存使用,那就是重用外函数的栈,而不是创建新的执行栈。在尾调用时重用执行栈称为尾调用优化。
如果脚本以 "use strict" 模式编写,JavaScript 支持在特定浏览器中进行尾调用优化。让我们看看一个尾调用的例子:
"use strict";
function _add(x, y){
return x + y;
}
function add1(x, y){
x = parseInt(x);
y = parseInt(y); //tail call
return _add(x, y);
}
function add2(x, y) {
x = parseInt(x);
y = parseInt(y);
//not tail call
return 0 + _add(x, y);
}
console.log(add1(1, '1')); //2
console.log(add2(1, '2')); //3
在这里,add1() 函数中的 _add() 调用是一个尾调用,因为它是 add1() 函数的最终操作。然而,add2() 函数中的 _add() 调用不是一个尾调用,因为它不是最终的操作;将 0 添加到 _add() 的结果是最终的操作。
在 add1() 函数中的 _add() 调用不会创建新的执行栈。相反,它重用了 add1() 函数的执行栈;换句话说,发生了尾调用优化。
尾调用优化规范目前没有积极开发,目前仅在 Safari 中实现。因此,你只能在 Safari 中使用 TCO。
将非尾调用转换为尾调用
由于尾调用得到了优化,因此你应尽可能使用尾调用,而不是非尾调用。你可以通过将非尾调用转换为尾调用来优化你的代码。
让我们看看一个类似的例子:
"use strict";
function _add(x, y) {
return x + y;
}
function add(x, y) {
x = parseInt(x);
y = parseInt(y);
const result = _add(x, y);
return result;
}
console.log(add(1, '1'));
在之前的代码中,_add() 调用不是一个尾调用,因此创建了两个执行栈。我们可以这样将其转换为尾调用:
function add(x, y){
x = parseInt(x);
y = parseInt(y);
return _add(x, y);
}
在这里,我们省略了 result 变量的使用,而是将函数调用与 return 语句对齐。还有许多其他类似的策略可以将非尾调用转换为尾调用。
摘要
在本章中,我们学习了一种使用符号创建对象属性键的新方法。我们了解了迭代器和可迭代协议,并学习了如何在自定义对象中实现这些协议。然后,我们学习了如何使用for…of循环遍历可迭代对象。最后,我们通过学习尾调用及其优化来结束本章。
在下一章中,我们将学习如何使用 Promises 以及 ES8 中最近推出的 async/await 特性进行异步编程,这使得异步代码看起来更像是同步代码。让我们开始吧!
第四章:异步编程
在本书的所有章节中,这是我最喜欢的章节,因为我过去遇到过不良异步编程的后果,包括事件监听器上的回调、HTTP 请求以及基本上所有需要延迟的操作。
JavaScript 已经从所有这些杂乱无章、难以阅读、难以维护的编程实践中发展而来,这就是我们将在本章学习的内容。
无论如何,让我们学习异步程序是什么。你可以将异步程序想象为包含两行代码的程序,比如说 L1 和 L2。我们都知道,在给定的文件中,代码总是从上到下执行。此外,这是直观的,代码会在执行下一行之前等待当前行完成。
在异步编程的情况下,代码将执行 L1,但不会在 L1 完成之前阻塞 L2。你可以将其视为一种非阻塞编程。
在本章中,我们将涵盖:
-
JavaScript 执行模型
-
事件循环
-
编写异步代码时遇到的困难
-
什么是 promises?
-
创建和使用 promises
-
async/await 与 promises 的不同之处
-
使用 async/await 进行高级异步编程
让我们开始吧!
JavaScript 执行模型
JavaScript 代码是在单线程中执行的,也就是说,脚本的两部分不能同时运行。浏览器中打开的每个网站都获得一个用于下载、解析和执行网站的单独线程,称为主线程。
主线程也维护一个队列,其中包含排队等待依次执行的任务。这些排队任务可以是事件处理器、回调函数或任何其他类型的任务。当发生 AJAX 请求/响应、事件发生、注册计时器等情况时,新任务会被添加到队列中。一个长时间运行的队列任务可能会停止所有其他队列任务和主脚本的执行。主线程尽可能执行队列中的任务。
HTML5 引入了 Web Workers,这是与主线程并行运行的真正线程。当 Web Worker 完成执行或需要通知主线程时,它只需将一个新的事件项添加到队列中。我们将在第九章“Web 上的 JavaScript”中单独讨论 Web Workers。
事件循环
JavaScript 在运行机制上遵循基于事件循环的模型。这与 Java 等语言非常不同。尽管现代 JavaScript 编译器实现了非常复杂且高度优化的事件循环模型,我们仍然可以基本理解事件循环是如何工作的。
调用栈
JavaScript 是一种单线程语言。这意味着它可以在给定时间只有一个调用栈(一个线程等于一个调用栈)。此外,这也意味着 JavaScript 一次不能做超过两件事。或者它可以吗?
当你调用一个函数时,你进入该函数内部。这个函数被添加到调用栈中。当函数返回一个值时,该函数从调用栈中弹出。
让我们看看这个例子:
const page1 = $.syncHTTP('http://example.com/page1');
const page2 = $.syncHTTP('http://example.com/page2');
const page3 = $.syncHTTP('http://example.com/page3');
const page4 = $.syncHTTP('http://example.com/page4');
console.log(page1, page2, page3, page4);
为了简化,假设$.syncHTTP是一个预定义的方法,它执行同步HTTP 请求,也就是说,它会在完成之前阻塞代码。让我们假设每个请求需要大约 500 毫秒来完成。因此,如果所有这些请求在点击按钮时触发,JavaScript 会立即阻止浏览器在两秒钟内做任何事情!这会以 100 倍的比例杀死用户体验!
显然,调用栈将包含第一个请求,然后在 500 毫秒后从调用栈中移除它,然后转到第二个请求,将其添加到调用栈中,等待 500 毫秒以接收响应,然后从调用栈中移除,依此类推。
然而,当我们使用像setTimeout()这样的异步函数时,会发生一些奇怪的事情。看看这个例子:
console.log('Start');
setTimeout( () => {
console.log('Middle');
}, 1000 )
console.log('End');
这里,正如你所期望的,我们首先会打印出Start,因为调用栈将console.log添加到栈中,执行它,然后从栈中移除。然后 JavaScript 来到setTimeout(),将其添加到调用栈中,神奇地不做任何事情(关于这一点稍后还会详细说明),来到最后的console.log,将其添加到调用栈中,执行它以显示End,然后从调用栈中移除。
最后,神奇的是,1 秒后,另一个console.log出现在调用栈中,被执行以打印Middle,然后从调用栈中移除。
让我们理解这个魔法。
栈、队列和 Web API
那么,当我们在前面的代码中调用setTimeout()时发生了什么?它是如何神奇地从调用栈中消失,为下一个函数执行腾出空间的?
好吧,setTimeout()是每个浏览器单独提供的 Web API。当你调用setTimeout时,调用栈会将setTimeout()函数调用发送到 Web API,然后它会跟踪计时器(在我们的例子中)直到它完成。
一旦 Web API 意识到计时器已完成,它不会立即将内容推回栈中。它会将setTimeout()函数的回调推送到一个称为队列的东西。正如其名所示,这可以是一个等待执行的功能队列。
这时,事件循环就派上用场了。事件循环是一个简单的工具,它始终检查栈和队列,看看栈是否为空;如果队列中有内容,它会从队列中取出并将其推入栈中。
所以本质上,一旦你离开了调用栈(异步函数),你的函数必须等待调用栈被清空后才能执行。
根据上一行,猜测这段代码的输出:
console.log('Hello');
setTimeout( () => {
console.log('World')
}, 0 ) // 0 second timeout (executes immediately)
console.log('???')
想想这个问题。当你准备好了,看看下面的答案:
Hello
???
World
现在的原因是,当你调用 setTimeout() 时,它会被从调用栈中清除,然后调用下一个函数。Web API 发现 setTimeout() 的计时器已超时,并将其推入队列。事件循环等待最后的 console.log 语句执行完毕,然后将 setTimeout() 的回调函数推入栈中。因此,我们得到了之前显示的输出。
下图说明了之前的代码:

编写异步代码
尽管现代 JavaScript 引入了 promises 和 ES8 引入了 async/await(我们很快就会看到),但仍然会有时候你遇到使用回调机制/基于事件的机制进行异步操作的老旧 API。
理解老旧的异步编程实践的工作原理非常重要。这是因为,如果不真正理解其工作原理,你无法将基于回调的异步代码片段转换为基于 promises/async-await 的闪亮代码!
JavaScript 早期原生支持两种编写异步代码的模式,即事件模式和回调模式。在编写异步代码时,我们通常启动一个异步操作,并注册事件处理器或传递回调函数,这些函数将在操作完成后执行。
事件处理器或回调的使用取决于特定异步 API 的设计。为事件模式设计的 API 可以通过一些自定义代码包装成回调模式,反之亦然。例如,AJAX 是为事件模式设计的,但 jQuery AJAX 以回调模式暴露它。让我们考虑一些涉及事件和回调的异步代码编写示例及其困难。
涉及事件的异步代码
对于涉及事件的异步 JavaScript API,你需要注册根据操作是否成功执行的成功和错误事件处理器。
例如,在发起 AJAX 请求时,我们会注册根据 AJAX 请求是否成功执行的事件处理器。考虑以下代码片段,它发起一个 AJAX 请求并记录检索到的信息:
function displayName(json) {
try {
//we usally display it using DOM
console.log(json.Name);
} catch(e) {
console.log("Exception: " + e.message);
}
}
function displayProfession(json) {
try {
console.log(json.Profession);
} catch(e) {
console.log("Exception: " + e.message);
}
}
function displayAge(json) {
try {
console.log(json.Age);
} catch(e) {
console.log("Exception: " + e.message);
}
}
function displayData(data) {
try {
const json = JSON.parse(data);
displayName(json);
displayProfession(json);
displayAge(json);
} catch(e) {
console.log("Exception: " + e.message);
}
}
const request = new XMLHttpRequest();
const url = "data.json";
request.open("GET", url);
request.addEventListener("load", function(){
if(request.status === 200) {
displayData(request.responseText);
} else {
console.log("Server Error: " + request.status);
}
}, false);
request.addEventListener("error", function(){
console.log("Cannot Make AJAX Request");
}, false);
request.send();
这里,我们假设 data.json 文件包含以下内容:
{
"Name": "Eden",
"Profession": "Developer",
"Age": "25"
}
XMLHttpRequest() 对象的 send() 方法是异步执行的,它检索 data.json 文件,并根据请求是否成功调用加载或错误事件处理器。
这个 AJAX 的工作方式完全没有问题,但问题在于我们如何编写涉及事件处理的代码。以下是我们在编写前一段代码时遇到的问题:
-
我们不得不为每个将要异步执行的代码块添加异常处理器。我们不能只用一个
try和catch语句包裹整个代码。这使得捕获异常变得困难。 -
代码难以阅读,因为嵌套函数调用使得代码流程难以追踪。
如果程序的另一部分想要知道异步操作是否已完成、挂起或正在执行,那么我们必须为该目的维护自定义变量。因此,我们可以说找到异步操作的状态是困难的。如果你嵌套了多个 AJAX 或其他异步操作,这段代码可能会变得更加复杂和难以阅读。例如,在显示数据后,你可能希望让用户验证数据是否正确,然后将布尔值发送回服务器。以下是一个代码示例,演示了这一点:
function verify() {
try {
const result = confirm("Is the data correct?");
if (result) {
//make AJAX request to send data to server
} else {
//make AJAX request to send data to server
}
} catch(e) {
console.log("Exception: " + e.message);
}
}
function displayData(data) {
try {
const json = JSON.parse(data);
displayName(json);
displayProfession(json);
displayAge(json);
verify();
} catch(e) {
console.log("Exception: " + e.message);
}
}
异步代码涉及回调
对于涉及回调的异步 JavaScript API,你需要传递成功和错误回调,这些回调将根据操作是成功还是失败而分别被调用。例如,在用 jQuery 发起 AJAX 请求时,我们需要传递回调,这些回调将根据 AJAX 请求是否成功执行。考虑以下使用 jQuery 发起 AJAX 请求并记录检索信息的代码片段:
function displayName(json) {
try {
console.log(json.Name);
} catch(e) {
console.log("Exception: " + e.message);
}
}
function displayProfession(json) {
try {
console.log(json.Profession);
} catch(e) {
console.log("Exception: " + e.message);
}
}
function displayAge(json) {
try {
console.log(json.Age);
} catch(e) {
console.log("Exception: " + e.message);
}
}
function displayData(data) {
try {
const json = JSON.parse(data);
displayName(json);
displayProfession(json);
displayAge(json);
} catch(e) {
console.log("Exception: " + e.message);
}
}
$.ajax({
url: "data.json",
success: function(result, status, responseObject) {
displayData(responseObject.responseText);
},
error: function(xhr,status,error) {
console.log("Cannot Make AJAX Request. Error is: " + error);
}
});
即使在这里,jQuery AJAX 的工作方式完全没有问题,但问题在于我们如何编写涉及回调的代码。以下是我们在编写前面代码时遇到的问题:
-
捕获异常很困难,因为我们必须使用多个
try和catch语句。 -
代码难以阅读,因为嵌套函数调用使得代码流程难以追踪。
-
维护异步操作的状态很困难。如果我们嵌套多个 jQuery AJAX 或其他异步操作,这段代码将变得更加复杂。
承诺和异步编程
JavaScript 现在有一个新的原生模式来编写异步代码,称为Promise模式。这个新模式消除了事件和回调模式中常见的代码问题。它还使代码看起来更像同步代码。承诺(或Promise对象)代表一个异步操作。现有的异步 JavaScript API 通常用承诺包装,而新的 JavaScript API 则完全使用承诺实现。承诺在 JavaScript 中是新的,但已经在许多其他编程语言中存在。支持承诺的编程语言,如 C# 5、C++ 11、Swift、Scala 等,是一些例子。
让我们看看如何使用承诺。
承诺状态
承诺始终处于以下状态之一:
-
已履行:如果解析回调以非承诺对象作为参数或没有参数被调用,那么我们说承诺已履行
-
拒绝:如果拒绝回调被调用或在执行器作用域中发生异常,那么我们说承诺被拒绝
-
挂起:如果解析或拒绝回调尚未被调用,那么我们说承诺是挂起的
-
已解决:如果
Promise被实现或拒绝,但不是挂起状态,则称其为已解决
一旦Promise被实现或拒绝,它就不能再转换。尝试转换将没有任何效果。
Promise与回调的比较
假设你想要依次执行三个 AJAX 请求。以下是在回调风格中的示例实现:
ajaxCall('http://example.com/page1', response1 => {
ajaxCall('http://example.com/page2'+response1, response2 => {
ajaxCall('http://example.com/page3'+response2, response3 => {
console.log(response3)
}
})
})
你可以很快地看到如何进入所谓的回调地狱。多层嵌套不仅使代码难以阅读,而且难以维护。此外,如果你在每次调用后开始处理数据,并且下一个调用基于前一个调用的响应数据,代码的复杂性将无法匹敌。
回调地狱指的是多个异步函数嵌套在每个回调函数内部。这使得代码更难阅读和维护。
Promise可以简化这段代码。让我们看看:
ajaxCallPromise('http://example.com/page1')
.then( response1 => ajaxCallPromise('http://example.com/page2'+response1) )
.then( response2 => ajaxCallPromise('http://example.com/page3'+response2) )
.then( response3 => console.log(response3) )
你可以看到代码复杂性突然降低,代码看起来更干净、更易读。让我们首先看看ajaxCallPromise将如何实现。
请阅读以下解释,以更清晰地了解前面的代码片段。
Promise 构造函数和(resolve,reject)方法
要将现有的回调类型函数转换为Promise,我们必须使用Promise构造函数。在前面的例子中,ajaxCallPromise返回一个Promise,开发人员可以将其实现或拒绝。让我们看看如何实现ajaxCallPromise:
const ajaxCallPromise = url => {
return new Promise((resolve, reject) => {
// DO YOUR ASYNC STUFF HERE
$.ajaxAsyncWithNativeAPI(url, function(data) {
if(data.resCode === 200) {
resolve(data.message)
} else {
reject(data.error)
}
})
})
}
等等!刚才发生了什么?
-
首先,我们从
ajaxCallPromise函数中返回Promise。这意味着我们现在所做的任何操作都将是一个Promise。 -
Promise接受一个函数参数,该函数本身接受两个非常特殊的参数,即 resolve 和 reject。 -
resolve和reject本身是函数。 -
当在
Promise构造函数函数体内调用resolve或reject时,Promise将获得一个已解决或已拒绝的值,该值在之后无法更改。 -
我们随后使用原生的基于回调的 API 并检查一切是否正常。如果一切确实正常,我们使用由服务器发送的消息(假设是 JSON 响应)来
resolvePromise。 -
如果响应中存在错误,我们拒绝
Promise。
你可以在then调用中返回一个Promise。当你这样做时,你可以简化代码而不是再次链式调用Promise。
例如,如果foo()和bar()都返回Promise,则then而不是:
foo().then( res => {
bar().then( res2 => {
console.log('Both done')
})
})
我们可以这样写:
foo()
.then( res => bar() ) // bar() returns a Promise
.then( res => {
console.log('Both done')
})
这简化了代码。
then(onFulfilled,onRejected)方法
Promise对象的then方法允许我们在Promise实现或拒绝后执行任务。该任务也可以是另一个事件驱动或基于回调的异步操作。
Promise 对象的 then() 方法接受两个参数,即 onFulfilled 和 onRejected 回调。如果 Promise 对象得到实现,则执行 onFulfilled 回调;如果承诺被拒绝,则执行 onRejected 回调。
如果在执行器的作用域中抛出异常,则也会执行 onRejected 回调。因此,它表现得像异常处理程序,即它捕获异常。
onFulfilled 回调接受一个参数,即承诺的实现值。同样,onRejected 回调接受一个参数,即拒绝的原因:
ajaxCallPromise('http://example.com/page1').then(
successData => { console.log('Request was successful') },
failData => { console.log('Request failed' + failData) }
)
当我们在 ajaxCallPromise 定义内部拒绝承诺时,第二个函数将执行(failData 一个),而不是第一个函数。
让我们通过将 setTimeout() 从回调转换为承诺来举一个例子。这是 setTimeout() 的样子:
setTimeout( () => {
// code here executes after TIME_DURATION milliseconds
}, TIME_DURATION)
承诺版本看起来可能如下所示:
const PsetTimeout = duration => {
return new Promise((resolve, reject) => {
setTimeout( () => {
resolve()
}, duration);
})
}
// usage:
PsetTimeout(1000)
.then(() => {
console.log('Executes after a second')
})
在这里,我们以无值的方式解决了这个承诺。如果你这样做,它将以等于 undefined 的值解决。
catch(onRejected) 方法
当我们只使用 then() 方法来处理错误和异常时,使用 Promise 对象的 catch() 方法代替 then() 方法。catch() 方法的工作方式没有特别之处。只是它使代码更容易阅读,因为单词 catch 使其更有意义。
catch() 方法只接受一个参数,即 onRejected 回调。catch() 方法的 onRejected 回调以与 then() 方法的 onRejected 回调相同的方式被调用。
catch() 方法总是返回一个承诺。以下是 catch() 方法返回新 Promise 对象的方式:
-
如果
onRejected回调中没有返回语句,那么将内部创建一个新的实现Promise并返回。 -
如果我们在
onRejected回调中返回一个自定义的Promise,那么它将内部创建并返回一个新的Promise对象。新承诺对象解决了自定义承诺对象。 -
如果在
onRejected回调中返回的不是自定义的Promise,那么将内部创建一个新的Promise对象并返回。新Promise对象解决了返回的值。 -
如果我们传递
null而不是onRejected回调,或者省略它,那么将内部创建一个回调并使用它。内部创建的onRejected回调返回一个被拒绝的Promise对象。新Promise对象被拒绝的原因与父Promise对象被拒绝的原因相同。 -
如果调用
catch()的Promise对象得到实现,那么catch()方法简单地返回一个新的实现承诺对象并忽略onRejected回调。新Promise对象的实现值与父Promise的实现值相同。
要理解 catch() 方法,考虑以下代码:
ajaxPromiseCall('http://invalidURL.com')
.then(success => { console.log(success) },
failed => { console.log(failed) });
这段代码可以使用 catch() 方法重写如下:
ajaxPromiseCall('http://invalidURL.com')
.then(success => console.log(success))
.catch(failed => console.log(failed));
这两个代码片段的工作方式大致相同。
Promise.resolve(value) 方法
Promise 对象的 resolve() 方法接受一个值,并返回一个 Promise 对象,该对象解析传递的值。resolve() 方法基本上用于将值转换为 Promise 对象。当你发现自己有一个可能是也可能不是 Promise 的值,但你想将其用作 Promise 时,它非常有用。例如,jQuery 承诺与 ES6 承诺有不同的接口。因此,你可以使用 resolve() 方法将 jQuery 承诺转换为 ES6 承诺。
以下是一个演示如何使用 resolve() 方法的示例:
const p1 = Promise.resolve(4);
p1.then(function(value){
console.log(value);
}); //passed a promise object
Promise.resolve(p1).then(function(value){
console.log(value);
});
Promise.resolve({name: "Eden"})
.then(function(value){
console.log(value.name);
});
输出如下:
4
4
Eden
Promise.reject(value) 方法
Promise 对象的 reject() 方法接受一个值,并返回一个带有传递值作为原因的拒绝的 Promise 对象。与 Promise.resolve() 方法不同,reject() 方法用于调试目的,而不是将值转换为承诺。
以下是一个演示如何使用 reject() 方法的示例:
const p1 = Promise.reject(4);
p1.then(null, function(value){
console.log(value);
});
Promise.reject({name: "Eden"})
.then(null, function(value){
console.log(value.name);
});
输出如下:
4
Eden
Promise.all(iterable) 方法
Promise 对象的 all() 方法接受一个可迭代对象作为参数,并在可迭代对象中的所有承诺都得到满足时返回一个承诺。
这在我们在一些异步操作完成后想要执行任务时非常有用。以下是一个演示如何使用 Promise.all() 方法的代码示例:
const p1 = new Promise(function(resolve, reject){
setTimeout(function(){
resolve();
}, 1000);
});
const p2 = new Promise(function(resolve, reject){
setTimeout(function(){
resolve();
}, 2000);
});
const arr = [p1, p2];
Promise.all(arr).then(function(){
console.log("Done"); //"Done" is logged after 2 seconds
});
如果可迭代对象包含一个不是 Promise 对象的值,则它将使用 Promise.resolve() 方法转换为 Promise 对象。
如果传递的任何承诺被拒绝,那么 Promise.all() 方法会立即返回一个新的拒绝的 Promise,原因与被拒绝的传递 Promise 相同。以下是一个演示此功能的示例:
const p1 = new Promise(function(resolve, reject){
setTimeout(function(){
reject("Error");
}, 1000);
});
const p2 = new Promise(function(resolve, reject){
setTimeout(function(){
resolve();
}, 2000);
});
const arr = [p1, p2];
Promise.all(arr).then(null, function(reason){
console.log(reason); //"Error" is logged after 1 second
});
Promise.race(iterable) 方法
Promise 对象的 race() 方法接受一个可迭代对象作为参数,并在可迭代对象中的任何一个承诺满足或拒绝时立即满足或拒绝,使用该 Promise 的满足值或原因。
如其名所示,race() 方法用于在承诺之间进行竞争,以查看哪个先完成。以下是一个展示如何使用 race() 方法的代码示例:
var p1 = new Promise(function(resolve, reject){
setTimeout(function(){
resolve("Fulfillment Value 1");
}, 1000);
});
var p2 = new Promise(function(resolve, reject){
setTimeout(function(){
resolve("fulfillment Value 2");
}, 2000);
});
var arr = [p1, p2];
Promise.race(arr).then(function(value){
console.log(value); //Output "Fulfillment value 1"
}, function(reason){
console.log(reason);
});
现在,我假设你已经对承诺的工作方式、它们是什么以及如何将回调式 API 转换为承诺式 API 有了一个基本的了解。让我们来看看 async/await,异步编程的未来。
async/await – 异步编程的未来
说实话,async/await 完全超越了之前关于 Promise 的任何阅读。但是!显然,你需要了解 Promise 是如何工作的,才能知道如何使用 async/await。async/await 是基于 Promise 构建的;然而,一旦你习惯了它们,你就不会回到 Promise(除非,再次,你需要将回调类型 API 转换为 async/await(你需要使用 Promise 来完成这个转换))。
关于 async/await:
-
它用于异步编程
-
它让代码看起来与同步代码极其相似,因此非常强大且易于阅读
-
它是基于 Promise 构建的
-
它让错误处理变得轻而易举。你终于可以使用
try和catch进行异步编程了! -
ES8 引入了 async/await,到你看这篇文档的时候,它将已经在所有浏览器中原生支持(在写作时,只有 IE 和 Opera 不支持 async/await)
async/await 与 Promise 的比较
虽然 async/await 实际上在底层是 Promise,但它们通过极大地提高代码的可读性而非常有帮助。在表面层面上,我认为开发者应该意识到 async/await 与 Promise 使用上的细微差别。这里是一些这些差别的概述:
| async/await | Promise |
|---|---|
| 极其干净的代码库 | 嵌套 Promise 的更丑陋的代码库 |
使用原生的try-catch块进行错误处理 |
分离的catch()方法进行错误处理 |
| Promise 的语法糖(基于 Promise 构建) | 标准中的原生实现 |
| 在 ES8 中引入 | 在 ES6 中引入 |
异步函数和 await 关键字
为了使用await关键字,我们需要有一个async函数。函数和async函数之间的区别在于async函数后面跟着一个*async*关键字。让我们看看一个例子:
async function ES8isCool() {
// asynchronous work
const information = await getES8Information() // Here getES8Information itself is an async function
}
这是问题的关键。你只能在async函数中使用await。这是因为当你调用一个async函数时,它返回一个Promise。然而,我们并不是使用then与它结合,这最终会形成一个 Promise 链,而是在它前面使用await关键字,在async函数的上下文中暂停执行(实际上并不是)。
让我们看看一个真实的例子:
function sendAsyncHTTP(url) {
return new Promise((resolve, reject) => {
const xhttp = new XMLHttpRequest()
xhttp.onreadystatechange = function() {
if (this.readyState == 4) { // success
if(this.status == 200) {
resolve(xhttp.responseText)
} else {
console.log(this.readyState, this.status)
reject(xhttp.statusText) // failed
}
}
};
xhttp.open("GET", url, true);
xhttp.send();
})
}
async function doSomeTasks() {
const documentFile1 = await sendAsyncHTTP('http://example.com')
console.log('Got first document')
const documentFile2 = await sendAsyncHTTP('http://example.com/?somevar=true')
console.log('Got second document')
return documentFile2
}
doSomeTasks() // returns a Promise
.then( res => console.log("res is a HTML file") )
好的!首先,记住异步函数返回一个Promise吗?为什么我们没有在sendAsyncHTTP中使用async关键字?为什么我们从sendAsyncHTTP返回一个Promise?下面的代码为什么不会工作?
async function sendAsyncHTTP(url) {
const xhttp = new XMLHttpRequest()
xhttp.onreadystatechange = function() { // <-- hint
if(this.status == 200) {
resolve(xhttp.responseText)
} else {
console.log(this.readyState, this.status)
reject(xhttp.statusText) // failed
}
};
xhttp.open("GET", url, true);
xhttp.send();
}
仔细看看提示注释行。由于我们在onreadystatechange中使用了一个函数,在该函数内部返回并不会返回父函数。所以本质上,你从sendAsyncHTTP函数中返回的是undefined而不是有效的响应。如果有其他使用await关键字的异步函数,我们就可以返回一个值而不需要使用new Promise()声明。
感到困惑?请继续跟我。如果你之前没有真正理解发生了什么,请继续阅读。你会的。看看下一个函数:
async function doSomeTasks() {
const documentFile1 = await sendAsyncHTTP('http://example.com')
console.log('Got first document')
const documentFile2 = await sendAsyncHTTP('http://example.com/?somevar=true')
console.log('Got second document')
return documentFile2
}
仔细看看!我们在这里没有使用 return new Promise 返回一个 Promise。那么这是为什么它能工作呢?这是因为这个函数实际上使用 await 关键字等待一个 async 函数。看,当代码到达第 1 行时,它会在执行下一行 (console.log('Got first document')) 之前暂停。
当 JavaScript 遇到 await 后跟一个返回 Promise 的函数时,它会等待那个 Promise 解决或拒绝。在我们的例子中,sendAsyncHTTP 使用网站源代码解决,所以我们通过 documentfile2 变量获取它。
我们再次做类似的事情,但这次使用了一个稍微不同的 URL。一旦我们完成这两个步骤,我们就返回 documentFile2。在这里等一下。再次记住,async function 返回一个 Promise。这意味着,无论你从 async 函数中返回什么值,它实际上是那个返回的 Promise 的解决值。而且,无论你在 async 函数内部 throw 的什么值,它都会作为那个返回的 Promise 的拒绝值。这很重要!
最后,我们调用了 doSomeTasks(),正如之前提到的,它返回了一个 Promise。因此,你可以使用一个 then 链来简单地将其输出到控制台,表明一切已完成。res 变量包含你返回的任何值。catch() 方法将捕获 async 函数内部抛出的任何错误。
让异步代码看起来是同步的
尝试将以下 Promise 代码转换为 async/await:
const doSomething = () => {
return p1().then(res1 => {
return p2().then(res2 => {
// finally we need both res1 and res2
return p3(res1, res2)
})
})
}
你看?即使有 Promise,如果你需要在链的某个地方使用第一个 Promise 的值,你也不能避免嵌套。这是因为,如果你展开 Promise 链,你最终会失去之前 Promise 返回的值。
准备好答案了吗?看这里:
const doSomething = async () => {
const res1 = await p1()
const res2 = await p2()
return p3(res1, res2)
}
你能看出后者的代码清晰度吗?这是非常显著的!尽可能使用 async/await,而不是回调或 Promise。
摘要
在本章中,我们学习了 JavaScript 如何执行异步代码。我们学习了事件循环,以及 JavaScript 如何在不使用任何额外线程的情况下管理所有异步和多个任务。我们学习了编写异步代码的不同模式。
我们看到了 Promise 如何使异步代码的读写更容易,以及 async/await 如何在实践中取代 Promise。在下一章中,我们将探讨如何使用模块化编程方法组织你的 JavaScript 代码。
第五章:模块化编程
模块化编程是软件设计中最重要的和最常用的技术之一。模块化编程基本上意味着将你的代码拆分成多个文件,这些文件通常彼此独立。这使得管理和维护程序的不同模块变得轻而易举。它有助于轻松调试讨厌的 bug,向特定模块推送更新等等。
不幸的是,长期以来,JavaScript 没有原生支持模块;这导致程序员使用替代技术来实现 JavaScript 中的模块化编程。然而,ES6 首次在 JavaScript 中引入了原生的模块技术。
本章全部关于如何创建和导入 JavaScript 模块。在本章中,我们将首先学习模块是如何早期创建的,然后我们将跳转到新的内置 JavaScript 模块系统。
在本章中,我们将涵盖:
-
什么是模块化编程?
-
模块化编程的好处
-
IIFE 模块、AMD、UMD 和 CommonJS 模块的基本原理
-
创建和导入 ES6 模块
-
在浏览器中实现模块
JavaScript 模块 101
将程序和库分解为模块的实践称为模块化编程。
在 JavaScript 中,模块是一组相关的对象、函数和程序或库的其他组件的集合,这些组件被封装在一起,并从程序或库的其余部分的作用域中隔离出来。
模块导出一些变量到外部程序,以便它能够访问模块封装的组件。要使用模块,程序需要导入模块及其导出的变量。
一个模块也可以进一步拆分为称为子模块的模块,从而创建模块层次结构。
模块化编程有许多好处。以下是一些好处:
-
通过将其拆分为多个模块,它既保持了代码的清晰分离,又保持了组织性
-
模块化编程导致全局变量更少,也就是说,它消除了全局变量的问题,因为模块不通过全局作用域进行接口交互,每个模块都有自己的作用域
-
它使得代码的可重用性更容易,因为在不同项目中导入和使用相同的模块更容易
-
它允许许多程序员在同一程序或库上协作,通过让每个程序员专注于具有特定功能的特定模块
-
应用程序中的 bug 很容易被识别,因为它们被局部化到特定的模块中
实现模块 - 旧方法
在 ES6 之前,JavaScript 从未原生支持模块。开发者使用其他技术和第三方库在 JavaScript 中实现模块。使用立即执行的函数表达式(IIFE)、异步模块定义(AMD)、CommonJS 和通用模块定义(UMD)是 ES5 中实现模块的几种流行方式。由于这些方式不是 JavaScript 的本地特性,它们存在一些问题。让我们概述一下这些旧的模块实现方式。
立即执行的函数表达式(IIFE)
我们在前面章节中简要讨论了 IIFE 函数。它基本上是一个自动执行的匿名函数。让我们来看一个例子。这是一个典型的使用 IIFE 的老式 JS 模块的例子:
//Module Starts
(function(window){
const sum =(x, y) => x + y;
const sub = (x,y) => x - y;
const math = {
findSum(a, b) { return sum(a, b) },
findSub(a,b) { return sub(a, b) }
}
window.math = math;
})(window)
//Module Ends
console.log(math.findSum(1, 2)); //Output "3"
console.log(math.findSub(1, 2)); //Output "-1"
在这里,我们使用 IIFE 创建了一个模块。sum和sub变量在模块内部是全局的,但在模块外部不可见。math变量由模块导出到主程序,以暴露它提供的功能。
此模块完全独立于程序,可以通过将其复制到源代码中或作为单独的文件导入(通过包括脚本 src 或使用外部库)来由任何其他程序导入。
这之所以能工作,是因为最终你将math对象附加到了全局的 window 对象上(即全局作用域)。
使用立即执行函数表达式(IIFE)的库,例如 jQuery,会将所有的 API 封装在一个单独的 IIFE 模块中。当程序使用 jQuery 库时,它会自动导入该模块。
异步模块定义(AMD)
AMD 是为在浏览器中实现模块而设计的规范。AMD 在设计时考虑了浏览器的限制,即异步导入模块以防止阻塞网页的加载。由于 AMD 不是原生的浏览器规范,我们需要使用 AMD 库。
RequireJS 是最流行的 AMD 库。让我们看看如何使用 RequireJS 创建和导入模块的例子。根据 AMD 规范,每个模块都需要由一个单独的文件表示。所以首先,创建一个名为math.js的文件来表示模块。以下是模块中的示例代码:
define(function(){
const sum = (x, y) => x + y
const sub = (x, y) => x - y
const math = {
findSum(a, b) { return sum(a,b) },
findSub(a, b){ return sub(a, b); }
}
return math;
});
在这里,模块导出math变量以暴露其功能。
现在,让我们创建一个名为index.js的文件,作为导入模块和导出变量的主程序。以下是index.js文件中的代码:
require(["math"], function(math){
console.log(math.findSum(1, 2)); //Output "3"
console.log(math.findSub(1, 2)); //Output "-1"
})
在这里,第一个参数中的math变量是作为 AMD 模块处理的文件名。文件名添加的.js扩展名是由 RequireJS 自动添加的。
第二个参数中的math变量引用了导出的变量。在这里,模块是异步导入的,回调也是异步执行的。
你可以在这里了解更多关于 RequireJS 及其使用的信息:bit.ly/requirejs-tutorials。
CommonJS
CommonJS 是目前最广泛使用的非官方规范。CommonJS 是在 Node.js 中实现模块的规范。根据 CommonJS 规范,每个模块都需要由一个单独的文件表示。CommonJS 模块是 同步 导入的。这也是为什么浏览器不使用 CommonJS 作为模块加载器的原因!
让我们看看如何使用 CommonJS 创建和导入模块的示例。首先,我们将创建一个名为 math.js 的文件,它代表一个模块。以下是模块内部的示例代码:
const sum =(x, y) => x + y;
const sub = (x, y) => x - y;
const math = {
findSum(a, b) {
return sum(a,b);
},
findSub(a, b){
return sub(a, b);
}
}
exports.math = math; // or module.exports.math = math
在这里,模块导出 math 变量以公开其功能。现在,让我们创建一个名为 index.js 的文件,作为导入模块的主程序。
这里是 index.js 文件内部的代码:
const math = require("./math").math;
console.log(math.findSum(1, 2)); //Output "3"
console.log(math.findSub(1, 2)); //Output "-1"
在这里,math 变量是作为模块处理的文件的名称。CommonJS 会自动为文件名添加 .js 扩展名。
exports 与 module.exports 的区别
此前,我们说过,你可以使用 exports.math 或 module.exports.math 来从模块导出一个变量。两者之间有什么区别?
技术上讲,exports 和 module.exports 指向同一个对象。你可以这样考虑:
exports = module.exports = { }
这就是开始的方式。现在,无论你是否将属性值分配给 module.exports 或 exports,因为它们都指向同一个对象。然而,你必须记住,实际上是 module.exports 被实际导出!
例如,考虑 string1.js、string2.js 和 index.js。
string1.js 如下所示:
// string1.js
module.exports = () => "Amazing string" // correct export of function
string2.js 如下所示:
// string2.js
exports = () => "Amazing string" // this fails to export this function
index.js 如下所示:
// index.js
console.log(require('string1.js')()); // <-- we're executing the imported function
console.log(require('string2.js')());
你认为输出是什么?
很明显,正如我们之前所说的,exports 并没有被导出。因此,第二次调用 (require('string2.js')()) 抛出错误,因为 require('string2.js') 返回一个空对象(因此你不能将其作为函数执行)。
另一方面,当 module.exports 从对象更改为函数时,该函数被导出,并由开发者在一行调用 (string1.js) 中调用。
通用模块定义 (UMD)
我们看到了实现模块的三个不同规范。这三个规范都有它们各自创建和导入模块的方式。如果我们能够创建可以被作为 IIFE、AMD 或 CommonJS 模块导入的模块,那岂不是很好?
UMD 是一组用于创建可以作为 IIFE、CommonJS 或 AMD 模块导入的模块的技术。因此,现在一个程序可以导入第三方模块,无论它使用什么模块规范。
最流行的 UMD 技术是 returnExports。根据 returnExports 技术,每个模块都需要由一个单独的文件表示。所以,让我们创建一个名为 math.js 的文件来表示一个模块。以下是模块内部的示例代码:
(function (root, factory) {
//Environment Detection
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof exports === 'object') {
module.exports = factory();
} else {
root.returnExports = factory();
}
}(this, function () {
//Module Definition
const sum = (x, y) => x + y;
const sub = (x, y) => x - y;
const math = {
findSum(a, b) {
return sum(a,b);
},
findSub(a, b) {
return sub(a, b);
}
}
return math;
})
);
现在,你可以以你希望的方式成功导入 math.js 模块,例如,使用 CommonJS、RequireJS 或 IIFE。
实现模块 – 新方法
在 JavaScript 中导入和导出模块的新方法就是官方模块系统。由于它被语言原生支持,因此可以称为标准 JavaScript 模块系统。你应该在实际应用中考虑使用官方模块系统,因为它原生且因此针对速度和性能进行了优化。
导入/导出模块
假设你正在编写一个模块文件,现在你准备好将其导入到主文件中。你将如何使用官方模块系统导出它?以下是方法:
// module.js
const takeSquareAndAdd2 = num => {
return num*num + 2;
}
export { takeSquareAndAdd2 }; // #1
export const someVariable = 100; // #2
export function yourName(name) {
return `Your name ${name} is a nice name`
}; // #3
export default "Holy moly this is interesting!" // #4
-
#1: 我们首先编写了一个函数,然后使用export关键字使其对导入此特定模块的其他模块可用。 -
#2: 你可以在一行中直接声明、初始化和导出变量/函数。 -
#3: 如#2所述,你可以在创建函数的同时直接导出它们。 -
#4: 前三个被称为命名导出,而这是默认导出。我们很快就会看到两者之间的区别。
让我们看看如何在一个单独的文件中导入这个先前的模块:
// index.js
import myString, { takeSquareAndAdd2 } from './module.js'
console.log(myString) // "Holy moly this is interesting"
console.log(takeSquareAndAdd2(2)) // 6
等等。这里发生了什么?让我们来研究一下。
命名导出与默认导出
之前,我们看到了我们使用了 export default:Holy moly this is interesting。它所做的,当我们使用 import <varname> from './module' 时,将 <varname> 赋值为默认导出的值。因此,请看以下内容:
// index.js
import string from './module.js'
console.log(string)
这将在控制台输出 Holy moly this is interesting。
这被称为默认导出。
另一方面,命名导出与一个名称相关联(变量的名称或函数的名称)。你必须使用解构语法导入命名导出变量。这是因为你可以将 export 关键字视为导出一个默认值 加上 包含所有其他导出(命名导出)的对象。
因此,请看以下内容:
import { takeSquareAndAdd2 } from './module.js';
console.log(takeSquareAndAdd2(3))
这将输出 11。
当进行默认导出时,你不能使用 var/let/const;只需执行 export default <YOUR VALUE HERE>。然而,你可以执行 export default 后跟一个函数。
命名导入
你也可以更改你导入的模块中命名导出的名称。这是通过使用 as 关键字实现的:
import { takeSquareAndAdd2 as myFunc } from './module.js';
console.log(myFunc(3))
这仍然会产生输出 11。这在你有长模块名或可能与你基础代码/其他导入模块冲突的名称时是必要的。
类似地,你也可以重命名一些命名导入,其余的保持不变:
import { takeSquareAndAdd2 as myFunc, yourName } from './module.js';
console.log(myFunc(3)) // 11
console.log(yourName("Mehul")) // Your name Mehul is a nice name
通配符导入
如果你想导入整个模块中导出的所有实体呢?自己编写每个实体的名称会很麻烦;此外,如果你这样做,你将污染全局作用域。让我们看看我们如何解决这两个问题。
让我们假设我们的 module.js 看起来像这样:
// module.js
export const PI = 3.14
export const sqrt3 = 1.73
export function returnWhatYouSay(text) { return text; }
让我们一次性导入所有内容:
// index.js
import * as myModule from './module.js'
console.log(myModule.PI) // 3.14
console.log(myModule.returnWhatYouSay("This is cool!"))
星号(*)将导入myModule对象作用域下导出的所有内容。这使得访问所有导出的变量/方法更加简洁。
让我们在以下部分中快速收集有关导入/导出语法的所有信息。
关于导出的附加信息
我们需要在模块中使用导出语句来导出变量。导出语句有多种不同的格式。以下是这些格式:
-
export {variableName}- 这种格式导出一个变量 -
export {variableName1, variableName2, variableName3}- 这种格式用于导出多个变量 -
export {variableName as myVariableName}- 这种格式用于导出一个具有另一个名称的变量,即别名 -
export {variableName1 as myVariableName1, variableName2 as myVariableName2}- 这种格式用于导出具有不同名称的多个变量 -
export {variableName as default}- 这种格式使用默认作为别名 -
export {variableName as default, variableName1 as myVariableName1, variableName2}- 这种格式与第四种格式类似,但它还有一个默认别名 -
export default function(){}- 这种格式与第五种格式类似,但在这里你可以放置一个表达式而不是变量名 -
export {variableName1, variableName2} from "myAnotherModule"- 这种格式用于导出子模块的导出变量 -
export * from "myAnotherModule"- 这种格式用于导出子模块的所有导出变量
关于导出语句,以下是一些你需要知道的重要事项:
-
导出语句可以在模块的任何位置使用。不需要在模块的末尾使用它。
-
模块中可以有任意数量的导出语句。
-
你不能按需导出变量。例如,将导出语句放在
if...else条件中会抛出错误。因此,我们可以说模块结构需要是静态的,即导出可以在编译时确定。 -
你不能多次导出相同的变量名或别名。但你可以使用不同的别名多次导出同一个变量。
-
默认情况下,模块内的所有代码都在严格模式下执行。
-
导出变量的值可以在导出它们的模块内部更改。
关于导入的附加信息
要导入一个模块,我们需要使用import语句。import语句有多种不同的格式。以下是这些格式:
import x from "module-relative-path";
import {x} from "module-relative-path";
import {x1 as x2} from "module-relative-path";
import {x1, x2} from "module-relative-path";
import {x1, x2 as x3} from "module-relative-path";
import x, {x1, x2} from "module-relative-path";
import "module-relative-path";
import * as x from "module-relative-path";
import x1, * as x2 from "module-relative-path";
导出语句由两部分组成:我们想要导入的变量名和模块的相对路径。
这些格式之间的差异如下:
-
import x from "module-relative-path"- 在这种格式中,默认别名被导入。x是默认别名的别名。 -
import {x} from "module-relative-path"- 在这种格式中,导入x变量。 -
import {x1 as x2} from "module-relative-path"- 这种格式与第二种格式相同。只是x2是x1的别名。 -
import {x1, x2} from "module-relative-path"- 在这种格式中,我们导入x1和x2变量。 -
import {x1, x2 as x3} from "module-relative-path"- 在这种格式中,我们导入x1和x2变量。x3是x2变量的别名。 -
import x, {x1, x2} from "module-relative-path"- 在这种格式中,我们导入x1、x2变量和默认别名。x是默认别名的别名。 -
import "module-relative-path"- 在这种格式中,我们只是导入模块。我们没有导入模块导出的任何变量。 -
import * as x from "module-relative-path"- 在这种格式中,我们导入所有变量并将它们包装在一个名为x的对象中。甚至默认别名也被导入。 -
import x1, * as x2 from "module-relative-path"- 第九种格式与第八种格式相同。在这里,我们给默认别名提供了另一个别名。
这里有一些关于导入语句的重要事项,你需要了解:
-
当导入一个变量时,如果我们用别名导入它,那么要引用该变量,我们必须使用别名而不是实际的变量名;也就是说,实际的变量名将不可见,只有别名是可见的。
-
导入语句不会导入导出变量的副本;相反,它使变量在导入它的程序的作用域中可用。因此,如果你在模块内部更改导出变量,那么更改对导入它的程序是可见的。
-
导入的变量是只读的,也就是说,你不能在导出它们的模块作用域之外将它们重新分配给其他东西。
-
一个模块在单个 JavaScript 引擎实例中只能导入一次。如果我们再次尝试导入它,那么已经导入的模块实例将被使用。(换句话说,模块在 JavaScript 中是单例的。)
-
我们不能按需导入模块。例如,将导入语句放在
if…else条件中会抛出错误。因此,我们可以这样说,导入应该在编译时确定。
树摇动
树摇动是模块打包器如 Webpack 和 Rollup 用来传达可以使用导入-导出模块语法进行死代码消除的术语。
从本质上讲,新的模块加载系统使得这些模块打包器能够执行一种称为树摇动的操作,即摇动树以去除枯叶。
你的 JavaScript 就像一棵树。你导入的模块代表树上的活叶。而那些死(未使用)的代码则由树上的棕色枯叶来表示。为了移除枯叶,打包器必须摇动这棵树,让它们落下。
树摇动的执行方式
模块打包器使用的树摇动以以下方式消除未使用的代码:
-
首先,打包器会将所有导入的模块文件合并在一起(就像一个好的打包器一样)。在这里,任何文件都没有导入的导出项都不会被导出。
-
之后,打包器会压缩包并同时移除无效代码。因此,那些未导出或在其各自的模块文件内部未使用的变量/函数不会出现在压缩后的包文件中。这样,就执行了摇树优化。
在网络上使用模块
现代浏览器最近才开始原生实现模块加载器。要原生使用导入模块的脚本,您需要将其type属性设置为module。
下面是一个在 Chrome 63 中的非常基础的示例:
// index.html
<!doctype HTML>
<html>
<head>
<script src="img/index.js" type="module"></script>
</head>
<body>
<div id="text"></div>
</body>
</html>
这就是index.js(主脚本文件)的外观:
// index.js
import { writeText2Div as write2Div } from './module.js';
write2Div('Hello world!')
这是index.js(在同一目录下)导入的模块:
// module.js
const writeText2Div = text => document.getElementByID('text').innerText = text;
export { writeText2Div };
当进行测试时,应该在屏幕上显示Hello World。
摘要
在本章中,我们了解了模块化编程是什么,并学习了不同的模块化编程规范。我们了解了模块化编程的未来以及如何在真实项目中使用它在网络浏览器中。随着 ECMAScript 的发展,我们期待看到更多功能和性能的提升,这将最终惠及最终用户和开发者。
在下一章中,我们将探讨一个被称为Reflect API的东西,这是一个用于可拦截 JavaScript 操作的新颖方法集合。
第六章:实现 Reflect API
Reflect API 用于对象反射(即检查和操作对象的属性)。尽管 ES5 已经有了对象反射的 API,但这些 API 组织得不好,并且在失败时通常会抛出异常。Reflect API 组织得很好,使得代码的阅读和编写更加容易,因为它在失败时不会抛出异常。相反,它返回表示操作是否为真的布尔值。由于开发人员正在适应 Reflect API 进行对象反射,因此深入了解此 API 非常重要。在本章中,我们将涵盖:
-
使用给定的
this值调用函数 -
使用另一个构造函数的原型属性调用构造函数
-
定义或修改对象属性的属性
-
使用迭代器对象枚举对象的属性
-
获取和设置对象的内部 [[原型]] 属性
-
许多与检查和操作对象方法和属性相关的其他操作
Reflect 对象
全局 Reflect 对象公开了所有新的对象反射方法。Reflect 不是一个函数对象;因此,你不能调用 Reflect 对象。此外,你不能使用它与 new 操作符一起使用。Reflect API 的所有方法都被封装在 Reflect 对象中,使其看起来组织良好。
Reflect 对象提供了许多方法,在功能上与全局对象的方法重叠。让我们看看 Reflect 对象为对象反射提供的各种方法。
Reflect.apply(function, this, args) 方法
使用 Reflect.apply() 方法来调用具有给定 this 值的函数。由 Reflect.apply() 调用的函数称为目标函数。它与函数对象的 apply() 方法相同。Reflect.apply() 方法接受三个参数:
-
第一个参数代表目标函数。
-
第二个参数表示目标函数内部的
this值。此参数是可选的。 -
第三个参数是一个数组对象,指定目标函数的参数。此参数是可选的。
Reflect.apply() 方法返回目标函数返回的任何内容。以下是一个代码示例,演示如何使用 Reflect.apply() 方法:
function function_name(a, b, c) {
return this.value + a + b + c;
}
var returned_value = Reflect.apply(function_name, {value: 100}, [10, 20, 30]);
console.log(returned_value); //Output "160"
Reflect.construct(constructor, args, prototype) 方法
Reflect.construct() 方法用于将函数作为构造函数调用。它与 new 操作符类似。将要调用的函数称为目标构造函数。
你可能想要使用 Reflect.construct() 方法而不是 new 操作符的一个特殊原因是,你可以将构造函数的原型指向另一个构造函数的原型。
Reflect.construct() 方法接受三个参数:
-
第一个参数是目标构造函数。
-
第二个参数是一个数组,指定目标构造函数的参数。此参数是可选的。
-
第三个参数是另一个构造函数,其原型将被用作目标构造函数的原型。此参数是可选的。
Reflect.construct()方法返回由目标构造函数创建的新实例。
这里是代码示例,以演示如何使用 Reflect.constructor() 方法:
function constructor1(a, b) {
this.a = a;
this.b = b;
this.f = function(){
return this.a + this.b + this.c;
}
}
function constructor2(){
}
constructor2.prototype.c = 100;
var myObject = Reflect.construct(constructor1, [1,2], constructor2);
console.log(myObject.f()); //Output "103"
在前面的示例中,我们在调用 constructor1 时使用了 constructor2 的原型链作为 constructor1 的原型。
Reflect.defineProperty(object, property, descriptor) 方法
Reflect.defineProperty() 方法直接在对象上定义新属性或修改对象上的现有属性。它返回一个布尔值,表示操作是否成功。
它与 Object.defineProperty() 方法类似。区别在于 Reflect.defineProperty() 方法返回一个布尔值,而 Object.defineProperty() 返回修改后的对象。如果 Object.defineProperty() 方法无法修改或定义对象属性,则抛出异常,而 Reflect.defineProperty() 方法返回一个 false 结果。Reflect.defineProperty() 方法接受三个参数:
-
第一个参数是要定义或修改属性的使用的对象
-
第二个参数是要定义或修改的属性的符号或名称
-
第三个参数是要定义或修改的属性的描述符
理解数据属性和访问器属性
自从 ES5 以来,每个对象属性要么是数据属性,要么是访问器属性。数据属性有一个值,这个值可能是可写的,也可能不可写,而访问器属性有一对 getter-setter 函数来设置和检索属性值。
数据属性的特性有 value、writable、enumerable 和 configurable。另一方面,访问器属性的特性有 set、get、enumerable 和 configurable。
描述符是一个对象,用于描述属性的属性。当使用 Reflect.defineProperty() 方法、Object.defineProperty() 方法、Object.defineProperties() 方法或 Object.create() 方法创建属性时,我们需要传递一个属性描述符。
数据属性的描述符对象具有以下属性:
-
Value: 这是与属性关联的值。默认值是
undefined。 -
Writable: 如果这是
true,则可以使用赋值运算符更改属性值。默认值是false。 -
Configurable: 如果这是
true,则可以更改属性属性,并且可以删除属性。默认值是false。记住,当可配置属性为false且可写为true时,可以更改值和可写属性。 -
Enumerable: 如果这是
true,则该属性会在for…in循环和Object.keys()方法中出现。默认值是false。
访问器属性的描述符具有以下属性:
-
获取: 这是一个返回属性值的函数。该函数没有参数,默认值是
undefined。 -
设置: 这是一个设置属性值的函数。该函数将接收分配给属性的新的值。
-
可配置: 如果这是
true,则可以更改属性描述符,并且属性可以被删除。默认值是false。 -
可枚举: 如果这是
true,则该属性会在for…in循环和Object.keys()方法中出现。默认值是false。
根据描述符对象的属性,JavaScript 决定属性是数据属性还是访问器属性。
如果你没有使用 Reflect.defineProperty() 方法、Object.defineProperty() 方法、Object.defineProperties() 方法或 Object.create() 方法添加属性,那么该属性是一个数据属性,其可写性、可枚举性和可配置性属性都被设置为 true。属性添加后,你可以修改其属性。
如果在调用 Reflect.defineProperty() 方法、Object.defineProperty() 方法或 Object.defineProperties() 方法时,对象已经具有指定的属性,则属性将被修改。描述符中未指定的属性保持不变。
你可以将数据属性更改为访问器属性,反之亦然。如果你这样做,描述符中未指定的可配置性和可枚举性属性将保留在属性中。描述符中未指定的其他属性将被设置为它们的默认值。
这里有一些示例代码,展示了如何使用 Reflect.defineProperty() 方法创建数据属性:
var obj = {}
Reflect.defineProperty(obj, "name", {
value: "Eden",
writable: true,
configurable: true,
enumerable: true
});
console.log(obj.name); //Output "Eden"
这里还有一些示例代码,展示了如何使用 Reflect.defineProperty() 方法创建访问器属性:
var obj = { __name__: "Eden" }
Reflect.defineProperty(obj, "name", {
get: function(){
return this.__name__;
},
set: function(newName){
this.__name__ = newName;
},
configurable: true,
enumerable: true
});
obj.name = "John";
console.log(obj.name); //Output "John"
Reflect.deleteProperty(object, property) 方法
Reflect.deleteProperty() 方法用于删除对象的属性。它与 delete 操作符相同。
此方法接受两个参数——即第一个参数是对象的引用,第二个参数是要删除的属性的名称。如果 Reflect.deleteProperty() 方法成功删除了属性,则返回 true。否则,返回 false。
这里有一个代码示例,展示了如何使用 Reflect.deleteProperty() 方法删除属性:
var obj = { name: "Eden" }
console.log(obj.name); //Output "Eden"
Reflect.deleteProperty(obj, "name");
console.log(obj.name); //Output "undefined"
Reflect.get(object, property, this) 方法
Reflect.get() 方法用于检索对象的属性值。第一个参数是对象,第二个参数是属性名。如果属性是访问器属性,则我们可以提供一个第三个参数,它将是 get 函数内部的 this 的值。
这里有一个代码示例,展示了如何使用 Reflect.get() 方法:
var obj = { __name__: "Eden" };
Reflect.defineProperty(obj, "name", {
get: function(){
return this.__name__;
}
});
console.log(obj.name); //Output "Eden"
var name = Reflect.get(obj, "name", {__name__: "John"});
console.log(name); //Output "John"
Reflect.set(object, property, value, this) 方法
Reflect.set() 方法用于设置对象的属性值。第一个参数是对象,第二个参数是属性名,第三个参数是属性值。如果属性是访问器属性,则我们可以提供一个第四个参数,它将是 set 函数内部 this 的值。
如果属性值设置成功,Reflect.set() 方法返回 true。否则,它返回 false。
下面是一个代码示例,演示了如何使用 Reflect.set() 方法:
var obj1 = { __name__: "Eden" };
Reflect.defineProperty(obj1, "name", {
set: function(newName){
this.__name__ = newName;
},
get: function(){
return this.__name__;
}
});
var obj2 = { __name__: "John" };
Reflect.set(obj1, "name", "Eden", obj2);
console.log(obj1.name); //Output "Eden"
console.log(obj2.__name__); //Output "Eden"
Reflect.getOwnPropertyDescriptor(object, property) 方法
Reflect.getOwnPropertyDescriptor() 方法用于检索对象的属性描述符。
Reflect.getOwnPropertyDescriptor() 方法与 Object.getOwnPropertyDescriptor() 方法相同。Reflect.getOwnPropertyDescriptor() 方法接受两个参数。第一个参数是对象,第二个参数是属性名。
下面是一个示例,演示了如何使用 Reflect.getOwnPropertyDescriptor() 方法:
var obj = { name: "Eden" };
var descriptor = Reflect.getOwnPropertyDescriptor(obj, "name");
console.log(descriptor.value);
console.log(descriptor.writable);
console.log(descriptor.enumerable);
console.log(descriptor.configurable);
输出如下:
Eden
true
true
true
Reflect.getPrototypeOf(object) 方法
Reflect.getPrototypeOf() 方法用于检索对象的原型,即对象的内部 [[prototype]] 属性的值。
Reflect.getPrototypeOf() 方法与 Object.getPrototypeOf() 方法相同。
下面是一个代码示例,演示了如何使用 Reflect.getPrototypeOf() 方法:
var obj1 = {
__proto__: { name: "Eden" }
};
var obj2 = Reflect.getPrototypeOf(obj1);
console.log(obj2.name); //Output "Eden"
Reflect.setPrototypeOf(object, prototype) 方法
Reflect.setPrototypeOf() 方法用于设置对象的内部 [[prototype]] 属性的值。如果成功设置内部 [[prototype]] 属性的值,Reflect.setPrototypeOf() 方法将返回 true。否则,它将返回 false。
下面是一个代码示例,演示了如何使用它:
var obj = {};
Reflect.setPrototypeOf(obj, { name: "Eden" });
console.log(obj.name); //Output "Eden"
Reflect.has(object, property) 方法
Reflect.has() 方法用于检查一个属性是否存在于对象中。它也会检查继承属性。如果属性存在,则返回 true。否则,返回 false。
它与 JavaScript 中的 in 操作符相同。
下面是一个代码示例,演示了如何使用 Reflect.has() 方法:
var obj = {
__proto__: { name: "Eden" },
age: 12
};
console.log(Reflect.has(obj, "name")); //Output "true"
console.log(Reflect.has(obj, "age")); //Output "true"
Reflect.isExtensible(object) 方法
Reflect.isExtensible() 方法用于检查一个对象是否可扩展,即我们是否可以向对象添加新属性。
可以使用 Object.preventExtensions()、Object.freeze() 和 Object.seal() 方法将对象标记为不可扩展。
Reflect.isExtensible() 方法与 Object.isExtensible() 方法相同。
下面是一个代码示例,演示了如何使用 Reflect.isExtensible() 方法:
var obj = { name: "Eden" };
console.log(Reflect.isExtensible(obj)); //Output "true"
Object.preventExtensions(obj);
console.log(Reflect.isExtensible(obj)); //Output "false"
Reflect.preventExtensions(object) 方法
Reflect.preventExtensions() 方法用于将对象标记为不可扩展。它返回一个布尔值,指示操作是否成功。
它与 Object.preventExtensions() 方法相同:
var obj = { name: "Eden" };
console.log(Reflect.isExtensible(obj)); //Output "true"
console.log(Reflect.preventExtensions(obj)); //Output "true"
console.log(Reflect.isExtensible(obj)); //Output "false"
Reflect.ownKeys(object) 方法
Reflect.ownKeys() 方法返回一个数组,其值表示提供对象的属性键。它忽略继承的属性。
下面是演示此方法的示例代码:
var obj = { a: 1, b: 2, __proto__: { c: 3 } };
var keys = Reflect.ownKeys(obj);
console.log(keys.length); //Output "2"
console.log(keys[0]); //Output "a"
console.log(keys[1]); //Output "b"
摘要
在本章中,我们学习了对象反射的概念以及如何使用 Reflect API 进行对象反射。我们通过示例看到了 Reflect 对象的各种方法。总的来说,本章介绍了 Reflect API 用于检查和操作对象属性。在下一章中,我们将学习代理及其用途。
第七章:代理
代理用于定义对象基本操作的自定义行为。代理在诸如 C#、C++和 Java 等编程语言中已经可用,但 JavaScript 从未有过代理。ES6 引入了 Proxy API,允许我们创建代理。在本章中,我们将探讨代理、它们的用法和代理陷阱。由于代理的好处,开发者越来越多地使用它们,因此深入了解代理,并通过示例进行学习变得非常重要,我们将在本章中这样做。
在本章中,我们将涵盖:
-
使用 Proxy API 创建代理
-
理解代理是什么以及如何使用它们
-
使用陷阱拦截对象上的各种操作
-
可用的不同陷阱类型
-
代理的一些用例
代理概述
代理就像是一个对象的包装器,并定义了对象基本操作的自定义行为。对象的一些基本操作包括属性查找、属性赋值、构造函数调用、枚举等。
想象这是一种拦截你用对象及其相关属性执行的操作的基本方式。例如,通过编写<objectname>.propertyName来调用属性值,从技术上讲,应该只是回显属性值,对吧?
如果你想在回声部分之前,但在调用部分之后,退一步并注入你的控制,那么代理就派上用场了。
一旦使用代理包装了一个对象,那么应该在代理对象上执行所有应该在对象上执行的操作,以便执行自定义行为。
代理的术语
在学习代理时,以下是一些重要的术语:
-
目标对象:这是被代理包装的对象。
-
陷阱:这些是拦截目标对象上各种操作并定义这些操作自定义行为的函数。
-
处理器:这是一个包含陷阱的对象。处理器附加到代理对象上。
与 Proxy API 一起工作
ES6 Proxy API 提供了代理构造函数来创建代理。代理构造函数接受两个参数,它们是:
-
目标对象:这是将被代理包装的对象
-
处理器:这是一个包含
target对象陷阱的对象
可以为目标对象上的每个可能操作定义陷阱。如果没有定义陷阱,则默认在目标上执行操作。以下是一个代码示例,展示了如何创建代理,并在目标对象上执行各种操作。在这个例子中,我们没有定义任何陷阱:
const target = { age: 12 };
const handler = {};
const proxy = new Proxy(target, handler);
proxy.name = "Eden";
console.log(target.name);
console.log(proxy.name);
console.log(target.age);
console.log(proxy.age);
这会输出以下内容:
Eden
Eden
12
12
在这里,我们可以看到可以通过proxy对象访问target对象的age属性。当我们向proxy对象添加name属性时,实际上它是添加到target对象上的。
由于没有将陷阱附加到属性赋值上,proxy.name赋值导致了默认行为——即简单地赋值给属性。
因此,我们可以这样说,代理只是一个target对象的包装器,并且可以定义陷阱来改变操作的默认行为。
许多开发者没有保留target对象的引用变量,因此访问对象时使用代理不是强制性的。只有在你需要为多个代理重用处理器时,才保留处理器的引用。以下是重写之前代码的方法:
var proxy = new Proxy({ age: 12 }, {});
proxy.name = "Eden";
代理陷阱
对于可以在对象上执行的不同操作,有不同的陷阱。一些陷阱需要返回值。在返回值时,你需要遵循一些规则。返回的值被代理拦截以过滤和/或检查返回的值是否遵守规则。如果一个陷阱在返回值时违反了规则,那么代理会抛出TypeError异常。
陷阱内部的this值始终是处理器的引用。
让我们来看看各种陷阱。
get(target, property, receiver)方法
当我们使用点或方括号表示法检索属性值,或使用Reflect.get()方法时,会执行获取陷阱。它接受三个参数——即target对象、属性名和代理。
它必须返回一个表示属性值的值。以下是一个代码示例,展示了如何使用获取陷阱:
const proxy = new Proxy({
age: 12
}, {
get(target, property, receiver) {
if(property in target) {
return target[property];
}
return "Property not found!";
}
});
console.log(proxy.age);
console.log(proxy.name);
输出,正如你可能已经猜到的,如下所示:
12
Property not found!
而不是输出,没有代理时你会得到以下内容:
12
undefined
在这里,我们可以看到获取陷阱在target对象中查找属性,如果找到了,则返回property值。否则,它返回一个字符串,表示未找到。
接收器参数是我们打算访问其属性的对象的引用。考虑以下示例,以更好地理解接收器参数的值:
const proxy = new Proxy({
age: 13
}, {
get(target, property, receiver) {
if(property in target) {
return target[property];
} else if(property == "name") {
console.log("Receiver here is ", receiver);
return "backup property value for name.";
} else {
return console.log("Property Not Found ", receiver);
}
}
});
let temp = proxy.name;
let obj = {
age: 12,
__proto__: proxy
};
temp = obj.name;
const justARandomVariablePassingBy = obj.age;
console.log(justARandomVariablePassingBy);
输出:
Receiver here is <ProxyObject> {age: 13}
Receiver here is {age: 12}
12
注意,这里的{age: 13}是ProxyObject,{ age: 12 }是普通对象。
在这里,obj继承了proxy对象。因此,当名称属性在obj对象中未找到时,它会在proxy对象中查找。由于proxy对象有一个获取陷阱,它提供了一个值。
因此,当我们通过obj.name表达式访问名称属性时,接收器参数的值是obj,当我们通过proxy.name访问名称属性时,表达式是proxy。
对于所有其他陷阱,接收器参数的值决定方式相同。
使用获取陷阱的规则
在使用获取陷阱时,不应违反以下规则:
-
如果
target对象属性是一个不可写、不可配置的数据属性,则返回的属性值必须与target对象属性的值相同。 -
如果
target对象的属性是一个不可配置的访问器属性,并且其[[Get]]属性为undefined,则返回值必须为undefined。
set(target, property, value, receiver) 方法
当我们使用赋值运算符或 Reflect.set() 方法设置属性值时,将调用 set 陷阱。它接受四个参数--即,target 对象、属性名、新的属性值和接收者。
如果赋值成功,set 陷阱必须返回 true。否则,它将返回 false。
下面是一个代码示例,演示了如何使用 set 陷阱:
const proxy = new Proxy({}, {
set(target, property, value, receiver) {
target[property] = value;
return true;
}
});
proxy.name = "Eden";
console.log(proxy.name); //Output "Eden"
使用 set 陷阱的规则
在使用 set 陷阱时,不应违反这些规则:
-
如果
target对象的属性是一个不可写、不可配置的数据属性,则它将返回false--也就是说,您不能更改属性值 -
如果
target对象的属性是一个不可配置的访问器属性,并且其[[Set]]属性为undefined,则它将返回false--也就是说,您不能更改属性值
has(target, property) 方法
当我们使用 in 运算符检查属性是否存在时,将执行 has 陷阱。它接受两个参数--即,target 对象和属性名。它必须返回一个布尔值,指示属性是否存在。
下面是一个代码示例,演示了如何使用 has 陷阱:
const proxy = new Proxy({age: 12}, {
has(target, property) {
return property in target;
}
});
console.log(Reflect.has(proxy, "name"));
console.log(Reflect.has(proxy, "age"));
输出如下:
false
true
使用 has 陷阱的规则
在使用 has 陷阱时,不应违反这些规则:
-
如果属性作为非配置属性存在于
target对象中,并且是其自身属性,则不能返回false -
如果属性作为
target对象的自身属性存在,并且target对象是不可扩展的,则不能返回false
isExtensible(target) 方法
当我们使用 Object.isExtensible() 方法检查对象是否可扩展时,将执行 isExtensible 陷阱。它只接受一个参数--即,target 对象。它必须返回一个布尔值,指示对象是否可扩展。
下面是一个代码示例,演示了如何使用 isExtensible 陷阱:
const proxy = new Proxy({age: 12}, {
isExtensible(target) {
return Object.isExtensible(target);
}
});
console.log(Reflect.isExtensible(proxy)); //Output "true"
使用 isExtensible 陷阱的规则
在使用 isExtensible 陷阱时,不应违反此规则:
- 如果目标对象是可扩展的,则不能返回
false。同样,如果目标对象是不可扩展的,则不能返回true
getPrototypeOf(target) 方法
当我们使用 Object.getPrototypeOf() 方法或 __proto__ 属性检索内部 [[prototype]] 属性的值时,将执行 getPrototypeOf 陷阱。它只接受一个参数--即,target 对象。
它必须返回一个对象或 null 值。null 值表示该对象没有继承其他任何内容,并且是继承链的末端。
下面是一个代码示例,演示了如何使用 getPrototypeOf 陷阱:
const proxy = new Proxy({
age: 12,
__proto__: {name: "Eden"}
},
{
getPrototypeOf(target) {
return Object.getPrototypeOf(target);
}
});
console.log(Reflect.getPrototypeOf(proxy).name); //Output "Eden"
使用 getPrototypeOf 陷阱的规则
在使用 getPrototypeOf 陷阱时,不应违反这些规则:
-
它必须返回一个对象或返回一个空值
-
如果目标是不可扩展的,那么这个陷阱必须返回实际的原型
setPrototypeOf(target, prototype) 方法
当我们使用 Object.setPrototypeOf() 方法或 __proto__ 属性设置内部 [[prototype]] 属性的值时,将执行 setPrototypeOf 陷阱。它接受两个参数——即 target 对象和要分配的属性的值。
这个陷阱将返回一个布尔值,表示是否成功设置了原型。
这里有一个代码示例,演示了如何使用 setPrototypeOf 陷阱:
const proxy = new Proxy({}, {
setPrototypeOf(target, value) {
Reflect.setPrototypeOf(target, value);
return true;
}
});
Reflect.setPrototypeOf(proxy, {name: "Eden"}); console.log(Reflect.getPrototypeOf(proxy).name); //Output "Eden"
使用 setPrototypeOf 陷阱的规则
在使用 setPrototypeOf 陷阱时,不应违反以下规则:
- 如果
target不是不可扩展的,你必须返回false
preventExtensions(target) 方法
当我们使用 Object.preventExtensions() 方法防止添加新属性时,将执行 preventExtensions 陷阱。它只接受一个参数——即 target 对象。
它必须返回一个布尔值,表示是否成功阻止了对象的扩展。
这里有一个代码示例,演示了如何使用 preventExtensions 陷阱:
const proxy = new Proxy({}, {
preventExtensions(target) {
Object.preventExtensions(target);
return true;
}
});
Reflect.preventExtensions(proxy);
proxy.a = 12;
console.log(proxy.a); //Output "undefined"
使用 preventExtensions 陷阱的规则
在使用 preventExtensions 陷阱时,不应违反以下规则:
- 只有当
target是不可扩展的或已使target不可扩展时,这个陷阱才能返回true
getOwnPropertyDescriptor(target, property) 方法
当我们使用 Object.getOwnPropertyDescriptor() 方法检索属性的描述符时,将执行 getOwnPropertyDescriptor 陷阱。它接受两个参数——即 target 对象和属性的名称。
这个陷阱必须返回一个 descriptor 对象或 undefined。如果属性不存在,则返回 undefined 值。
这里有一个代码示例,演示了如何使用 getOwnPropertyDescriptor 陷阱:
const proxy = new Proxy({age: 12}, {
getOwnPropertyDescriptor(target, property) {
return Object.getOwnPropertyDescriptor(target, property);
}
});
const descriptor = Reflect.getOwnPropertyDescriptor(proxy, "age"); console.log("Enumerable: " + descriptor.enumerable);
console.log("Writable: " + descriptor.writable);
console.log("Configurable: " + descriptor.configurable);
console.log("Value: " + descriptor.value);
输出如下:
Enumerable: true
Writable: true
Configurable: true
Value: 12
使用 getOwnPropertyDescriptor 陷阱的规则
在使用 getOwnPropertyDescriptor 陷阱时,不应违反以下规则:
-
这个陷阱必须返回一个对象或返回一个
undefined属性 -
如果属性作为
target对象的非配置性自有属性存在,则不能返回undefined值 -
如果属性作为
target对象的自有属性存在,并且target对象是不可扩展的,则不能返回undefined值 -
如果属性不是
target对象的自有属性,并且target对象是不可扩展的,那么你必须返回undefined -
如果属性作为
target对象的自有属性存在,或者如果它作为target对象的可配置自有属性存在,则不能将返回的描述符对象的配置性设置为false
defineProperty(target, property, descriptor) 方法
当我们使用Object.defineProperty()方法定义属性时,会执行defineProperty陷阱。它接受三个参数——即target对象、属性名和descriptor对象。
这个陷阱应该返回一个布尔值,表示是否成功定义了属性。
下面是一个代码示例,演示了如何使用defineProperty陷阱:
const proxy = new Proxy({}, {
defineProperty(target, property, descriptor) {
Object.defineProperty(target, property, descriptor);
return true;
}
});
Reflect.defineProperty(proxy, "name", {value: "Eden"});
console.log(proxy.name); //Output "Eden"
使用defineProperty的规则
在使用defineProperty陷阱时,不应违反此规则:
- 如果
target对象是不可扩展的,并且属性尚未存在,则必须返回false。
deleteProperty(target, property)方法
当我们使用删除运算符或Reflect.deleteProperty()方法删除属性时,会执行deleteProperty陷阱。它接受两个参数——即target对象和属性名。
这个陷阱必须返回一个布尔值,表示属性是否成功删除。下面是一个代码示例,演示了如何使用deleteProperty陷阱:
const proxy = new Proxy({age: 12}, {
deleteProperty(target, property) {
return delete target[property];
}
});
Reflect.deleteProperty(proxy, "age");
console.log(proxy.age); //Output "undefined"
使用deleteProperty陷阱的规则
在使用deleteProperty陷阱时,不应违反此规则:
- 这个陷阱必须返回
false,如果属性作为target对象的非可配置自有属性存在
ownKeys(target)方法
当我们使用Reflect.ownKeys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和Object.keys()方法检索自有属性键时,会执行ownKeys陷阱。它只接受一个参数——即target对象。
Reflect.ownKeys()方法类似于Object.getOwnPropertyNames()方法——即它们都返回对象的可枚举和非可枚举属性键。
它们也都会忽略继承的属性。唯一的区别是Reflect.ownKeys()方法返回符号键和字符串键,而Object.getOwnPropertyNames()方法只返回字符串键。
Object.getOwnPropertySymbols()方法返回具有符号键的可枚举和非可枚举属性。它忽略了继承的属性。
Object.keys()方法类似于Object.getOwnPropertyNames()方法,但唯一的区别是Object.keys()方法只返回可枚举属性。
ownKeys陷阱必须返回一个数组,表示自有属性键。
下面是一个代码示例,演示了如何使用ownKeys陷阱:
const s = Symbol();
const object = {age: 12, __proto__: {name: "Eden"}, [s]: "Symbol"}; Object.defineProperty(object, "profession",
{
enumerable: false,
configurable: false,
writable: false,
value: "Developer"
})
const proxy = new Proxy(object, {
ownKeys(target) {
return Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target));
}
});
console.log(Reflect.ownKeys(proxy));
console.log(Object.getOwnPropertyNames(proxy));
console.log(Object.keys(proxy));
console.log(Object.getOwnPropertySymbols(proxy));
输出如下:
["age", "profession", Symbol()]
["age", "profession"]
["age"]
[Symbol()]
在这里,我们可以看到ownKeys陷阱返回的数组值被代理过滤,基于调用者期望的内容。例如,Object.getOwnPropertySymbols()调用者期望一个包含符号的数组。因此,代理从返回的数组中移除了字符串。
使用ownKeys陷阱的规则
这些规则在使用ownKeys陷阱时不应被违反:
-
返回数组的元素必须是字符串或符号
-
返回的数组必须包含
target对象的所有非配置自有属性的键 -
如果
target对象是不可扩展的,则返回的数组必须包含自有属性和target对象的所有键,并且不包含其他值
apply(target, thisValue, arguments)方法
如果目标是函数,则调用代理将执行apply陷阱。apply陷阱也会为函数的apply()和call()方法以及Reflect.apply()方法执行。
apply陷阱接受三个参数。第一个参数是target对象,第三个参数是一个数组,表示函数调用的参数。
第二个参数与target函数的this值相同--即,如果目标函数没有使用代理被调用,则与目标函数的this值相同。
以下是一个代码示例,演示如何使用 apply 陷阱:
const proxy = new Proxy(function(){}, {
apply(target, thisValue, arguments) {
console.log(thisValue.name);
return arguments[0] + arguments[1] + arguments[2];
}
});
const obj = { name: "Eden", f: proxy }
const sum = obj.f(1,2,3);
console.log(sum);
输出如下:
Eden
6
construct(target, arguments)方法
如果目标是函数,则使用new运算符或Reflect.construct()方法将目标作为构造函数调用将执行构造陷阱。
construct陷阱接受两个参数。第一个参数是target对象,第二个参数是一个数组,表示构造函数调用的参数。
construct陷阱必须返回一个对象,表示新创建的实例。以下是一个代码示例,演示如何使用construct陷阱:
const proxy = new Proxy(function(){}, {
construct(target, arguments) {
return {name: arguments[0]};
}
});
const obj = new proxy("Eden");
console.log(obj.name); //Output "Eden"
Proxy.revocable(target, handler)方法
可撤销代理是一种可以被撤销(即,关闭)的代理。
要创建可撤销代理,我们必须使用Proxy.revocable()方法。Proxy.revocable()方法不是一个构造函数。此方法也接受与Proxy构造函数相同的参数,但它不是直接返回一个可撤销代理实例,而是返回一个具有两个属性的对象,如下所示:
-
proxy:这是可撤销的proxy对象 -
revoke:当此函数被调用时,它将撤销代理
一旦撤销了可撤销的proxy,任何尝试使用它的操作都将抛出TypeError异常。以下是一个示例,演示如何创建可撤销的proxy并撤销它:
const revocableProxy = Proxy.revocable({ age: 12 }, {
get(target, property, receiver) {
if(property in target) {
return target[property];
}
return "Not Found";
}
});
console.log(revocableProxy.proxy.age);
revocableProxy.revoke();
console.log(revocableProxy.proxy.name);
输出如下:
12
TypeError: proxy is revoked
可撤销代理的使用场景
您可以使用可撤销代理而不是常规代理。当您将代理传递给一个异步或并行运行的功能时,您可以撤销它,这样您就不希望该功能再使用该代理了。
代理的使用
代理有几种用途。以下是主要的使用场景:
-
创建虚拟化对象,例如远程对象、持久化对象等
-
对象的懒加载创建
-
透明的日志记录、跟踪、分析等
-
内嵌领域特定语言
-
通过强制访问控制来泛化抽象
概述
在本章中,我们学习了什么是代理以及如何使用它们。我们通过例子看到了可用的各种陷阱。我们还看到了不同陷阱需要遵循的不同规则。本章深入解释了 JavaScript 中 Proxy API 的各个方面。最后,我们看到了一些代理的使用案例。在下一章中,我们将探讨面向对象编程和 ES6 类。
第八章:类
JavaScript 有类,它们提供了创建构造函数和处理继承的更简单、更清晰的语法。直到现在,JavaScript 从来没有类的概念,尽管它是一种面向对象的编程语言。来自其他编程语言背景的程序员常常因为缺乏类而发现理解 JavaScript 的面向对象模型和继承很困难。
在本章中,我们将通过类学习面向对象的 JavaScript:
-
JavaScript 数据类型
-
以传统方式创建对象
-
原始类型的构造函数
-
什么是类?
-
使用类创建对象
-
类中的继承
-
类的特性
理解面向对象的 JavaScript
在我们继续学习 ES6 类之前,让我们回顾一下 JavaScript 数据类型、构造函数和继承的知识。在学习类的同时,我们将比较基于构造函数和原型继承的语法与类的语法。因此,对这些主题有良好的掌握是很重要的。
JavaScript 数据类型
JavaScript 变量持有(或存储)数据(或值)。变量持有的数据类型称为数据类型。在 JavaScript 中,有七种不同的数据类型:number、string、Boolean、null、undefined、symbol 和 object。
当涉及到存储对象时,变量持有对象引用(即内存地址)而不是对象本身。如果你来自 C/C++ 背景,你可以将它们与指针联系起来,但并不完全一样。
除了对象之外的所有数据类型都称为原始数据类型。
数组和函数实际上是 JavaScript 对象。很多事物在底层都是对象。
创建对象
在 JavaScript 中创建对象有两种方式:使用 object 字面量,或使用 constructor。当我们需要创建固定对象时使用 object 字面量,而当我们想要在运行时动态创建对象时使用 constructor。
让我们考虑一个可能需要使用 constructor 而不是 object 字面量的情况。以下是一个代码示例:
const student = {
name: "Eden",
printName() {
console.log(this.name);
}
}
student.printName(); //Output "Eden"
在这里,我们使用 object 字面量创建了一个 student 对象,即 {} 符号。当你只想创建单个 student 对象时,这很有效。
但问题出现在你想要创建多个 student 对象时。显然,你不想多次编写之前的代码来创建多个 student 对象。这就是 constructor 发挥作用的地方。
当使用 new 关键字调用时,function 作为一个 constructor。constructor 创建并返回一个对象。在作为 constructor 调用时的 function 内部的 this 关键字指向新的对象实例,一旦 constructor 执行完成,新的对象就会自动返回。考虑以下示例:
function Student(name) {
this.name = name;
}
Student.prototype.printName = function(){
console.log(this.name);
}
const student1 = new Student("Eden");
const student2 = new Student("John");
student1.printName(); //Output "Eden"
student2.printName(); //Output "John"
在这里,为了创建多个student对象,我们多次调用了constructor,而不是使用object字面量创建多个student对象。
要向constructor的实例添加方法,我们没有使用this关键字;相反,我们使用了constructor的prototype属性。我们将在下一节中了解更多关于为什么这样做,以及prototype属性是什么。
实际上,每个对象都必须属于一个constructor。每个对象都有一个继承属性名为constructor,指向对象的constructor。当我们使用object字面量创建对象时,constructor属性指向全局的Object的constructor。考虑以下示例来理解这种行为:
var student = {}
console.log(student.constructor == Object); //Output "true"
理解原型继承模型
每个 JavaScript 对象都有一个指向另一个对象的内嵌[[prototype]]属性,这个对象被称为其原型。这个原型对象有自己的原型,以此类推,直到遇到原型为 null 的对象。null 没有原型,并且作为原型链中的最后一个链接。
当尝试访问一个对象属性时,如果该属性在对象中找不到,那么就会在对象的原型中搜索该属性。如果仍然找不到,那么就会在原型对象的原型中搜索。这个过程会一直持续到原型链中遇到 null。这就是 JavaScript 中继承的工作方式。
由于 JavaScript 对象只能有一个原型,JavaScript 只支持单继承。
在使用object字面量创建对象时,我们可以使用特殊的__proto__属性或Object.setPrototypeOf()方法来为对象分配一个原型。JavaScript 还提供了一个Object.create()方法,我们可以使用它来创建一个具有指定原型的新的对象,因为__proto__在浏览器中不受支持,而Object.setPrototypeOf()方法看起来有点奇怪。
下面是一个代码示例,演示了在创建特定对象时使用Object字面量设置对象原型的不同方法:
const object1 = { name: "Eden", __proto__: {age: 24} }
const object2 = {name: "Eden" }
Object.setPrototypeOf(object2, {age: 24});
const object3 = Object.create({age: 24}, {
name: {value: "Eden"}
});
console.log(object1.name + " " + object1.age);
console.log(object2.name + " " + object2.age);
console.log(object3.name + " " + object3.age);
输出如下:
Eden 24
Eden 24
Eden 24
在这里,{age:24}对象被称为基对象、超对象或父对象,因为它正在被继承。而{name:"Eden"}对象被称为派生对象、子对象或子对象,因为它继承另一个对象。
如果你在使用object字面量创建对象时没有为其分配原型,那么原型将指向Object.prototype属性。Object.prototype的原型是 null,因此导致原型链的结束。以下是一个示例来演示这一点:
const obj = { name: "Eden" }
console.log(obj.__proto__ == Object.prototype); //Output "true"
当使用constructor创建对象时,新对象的原型始终指向一个名为prototype的属性,该属性属于function对象。默认情况下,prototype属性是一个具有一个名为constructor的属性的对象。constructor属性指向该函数本身。考虑以下示例以理解此模型:
function Student() {
this.name = "Eden";
}
const obj = new Student();
console.log(obj.__proto__.constructor == Student); //Output "true"
console.log(obj.__proto__ == Student.prototype); //Output "true"
要向constructor的实例添加新方法,我们应该像之前做的那样,将它们添加到constructor的prototype属性中。
我们之前没有使用this将方法添加到constructor中的原因是因为每个constructor的实例都将有一个方法副本,这并不非常节省内存。通过将方法附加到constructor的prototype属性,每个函数只有一个副本,所有实例都共享。为了理解这一点,请考虑以下示例:
function Student(name) {
this.name = name;
}
Student.prototype.printName = function() {
console.log(this.name);
}
const s1 = new Student("Eden");
const s2 = new Student("John");
function School(name) {
this.name = name;
this.printName = function() {
console.log(this.name);
}
}
const s3 = new School("ABC");
const s4 = new School("XYZ");
console.log(s1.printName == s2.printName);
console.log(s3.printName == s4.printName);
输出如下:
true
false
在这里,s1和s2共享相同的printName函数,这减少了内存的使用,而s3和s4包含两个不同的printName函数,使得程序使用更多的内存。这是不必要的,因为这两个函数执行的是相同的事情。因此,我们将实例的方法添加到constructor的prototype属性中。
在constructor中实现继承层次结构并不像在object字面量中那样直接。这是因为子constructor需要调用父constructor以执行父构造函数的初始化逻辑,并且我们需要将父constructor的prototype属性的函数添加到子constructor的prototype属性中,以便我们可以使用它们与子constructor的对象一起使用。没有预定义的方法来完成所有这些。开发人员和 JavaScript 库有他们自己的方法来做这件事。我将向您展示最常见的方法。
下面是一个示例,演示如何在创建对象时实现继承:
function School(schoolName) {
this.schoolName = schoolName;
}
School.prototype.printSchoolName = function(){
console.log(this.schoolName);
}
function Student(studentName, schoolName) {
this.studentName = studentName;
School.call(this, schoolName);
}
Student.prototype = new School();
Student.prototype.printStudentName = function() {
console.log(this.studentName);
}
const s = new Student("Eden", "ABC School");
s.printStudentName();
s.printSchoolName();
输出如下:
Eden
ABC School
在这里,我们使用函数对象的call方法调用了父constructor。为了继承方法,我们创建了一个父constructor的实例并将其赋值给子构造函数的prototype属性。
这并不是在构造函数中实现继承的万无一失的方法,因为这里存在许多潜在的问题。例如,如果父constructor执行的操作不仅仅是初始化属性,比如 DOM 操作,那么将父constructor的新实例赋值给子constructor的prototype属性可能会导致问题。
因此,类提供了更好的、更简单的方式来继承现有的构造函数和类。我们将在本章后面看到更多关于这一点的内容。
原始数据类型的构造函数
原始数据类型,如布尔值、字符串和数字,都有它们的构造函数对应物。这些对应构造函数的行为就像这些原始类型的包装器。例如,String 构造函数用于创建一个包含内部 [[PrimitiveValue]] 属性的字符串对象,该属性持有实际的原始值。
在运行时, wherever necessary,原始值会被它们的 constructor 对应物所包裹,而对应对象则被当作原始值来处理,这样代码才能按预期工作。考虑以下示例代码来理解它是如何工作的:
const s1 = "String";
const s2 = new String("String");
console.log(typeof s1);
console.log(typeof s2);
console.log(s1 == s2);
console.log(s1.length);
输出如下:
string
object
true
6
在这里,s1 是一个原始类型,而 s2 是一个对象,尽管对它们应用 == 操作符会得到一个真值。s1 是一个原始类型,但我们仍然能够访问长度属性,尽管原始类型不应该有任何属性。
所有这些都是在运行时将之前的代码转换为以下内容发生的:
const s1 = "String";
const s2 = new String("String");
console.log(typeof s1);
console.log(typeof s2);
console.log(s1 == s2.valueOf());
console.log((new String(s1)).length);
在这里,我们可以看到原始值是如何被包裹在其 constructor 对应物中的,以及对象对应物在必要时是如何被当作原始值处理的。因此,代码按预期工作。
从 ES6 开始引入的原始类型不会允许它们的对应函数作为构造函数被调用,也就是说,我们不能使用它们的对象对应物显式地包裹它们。我们在学习符号时看到了这种行为。
null 和 undefined 原始类型没有对应的构造函数。
使用类
我们看到,JavaScript 的面向对象模型是基于构造函数和基于原型的继承。嗯,ES6 类只是现有模型的一种新语法。类并没有为 JavaScript 引入一个新的面向对象模型。
ES6 类旨在提供一种更简单、更清晰的语法来处理构造函数和继承。
实际上,类是函数。类只是创建用作构造函数的函数的一种新语法。使用类创建不作为构造函数使用的函数没有任何意义,也不提供任何好处。
相反,这会让你的代码难以阅读,因为它变得混乱。因此,只有当你想使用类来构建对象时才使用类。让我们详细看看类。
定义一个类
正如定义函数有两种方式,函数声明和函数表达式一样,定义类也有两种方式:使用类声明和类表达式。
类声明
要使用 class 声明来定义一个类,你需要使用 class 关键字并为 class 提供一个名称。
这里有一个代码示例,演示如何使用 class 声明来定义一个类:
class Student {
constructor(name) {
this.name = name;
}
}
const s1 = new Student("Eden");
console.log(s1.name); //Output "Eden"
在这里,我们创建了一个名为 Student 的 class。然后,我们在其中定义了一个 constructor 方法。最后,我们创建了该类的一个新实例——一个对象,并记录了该对象的名字属性。
类的主体在花括号 {} 中。这是我们定义方法的地方。方法定义时不使用 function 关键字,方法之间不用逗号分隔。
类被视为函数;内部上,类名被视为函数名,constructor 方法的主体被视为函数的主体。
一个 class 中只能有一个 constructor 方法。定义多个 constructor 将会抛出 SyntaxError 异常。
默认情况下,类主体内的所有代码都在严格模式下执行。
当使用 function 编写时,前面的代码与以下代码相同:
function Student(name) {
this.name = name;
}
const s1 = new Student("Eden");
console.log(s1.name); //Output "Eden"
要证明一个 class 是一个 function,考虑以下代码:
class Student {
constructor(name) {
this.name = name;
}
}
function School(name) {
this.name = name;
}
console.log(typeof Student);
console.log(typeof School == typeof Student);
输出如下:
function
true
这里,我们可以看到 class 是一个 function。它只是创建函数的新语法。
类表达式
类表达式具有与类声明类似的语法。然而,使用类表达式时,您可以省略类名。两种方式下的主体和行为都保持不变。
下面是一个代码示例,演示如何使用类表达式定义一个 class:
const Student = class {
constructor(name) {
this.name = name;
}
}
const s1 = new Student("Eden");
console.log(s1.name); //Output "Eden"
这里,我们将 class 的引用存储在一个变量中,并使用它来构造对象。
当使用 function 编写时,前面的代码与以下代码相同:
const Student = function(name) {
this.name = name;
};
const s1 = new Student("Eden");
console.log(s1.name); //Output "Eden"
原型方法
类主体中的所有方法都被添加到类的 prototype 属性中。prototype 属性是使用 class 创建的对象的原型。
下面是一个示例,展示如何向 class 的 prototype 属性添加方法:
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
printProfile() {
console.log("Name is: " + this.name + " and Age is: " + this.age);
}
}
const p = new Person("Eden", 12);
p.printProfile();
console.log("printProfile" in p.__proto__);
console.log("printProfile" in Person.prototype);
输出如下:
Name is: Eden and Age is: 12
true
true
这里,我们可以看到 printProfile 方法被添加到了 class 的 prototype 属性中。
当使用 function 编写时,前面的代码与以下代码相同:
function Person(name, age) {
this.name = name;
this.age = age;
}
Person.prototype.printProfile = function() {
console.log("Name is: " + this.name + " and Age is: " + this.age);
}
const p = new Person("Eden", 12);
p.printProfile();
console.log("printProfile" in p.__proto__);
console.log("printProfile" in Person.prototype);
输出如下:
Name is: Eden and Age is: 12
true
true
获取器和设置器
之前,为了向对象添加访问器属性,我们必须使用 Object.defineProperty() 方法。从 ES6 开始,有 get 和 set 前缀用于方法。这些方法可以被添加到对象字面量和类中,以定义访问器属性的获取和设置属性。
当在类主体中使用 get 和 set 方法时,它们会被添加到类的 prototype 属性中。
下面是一个示例,演示如何在 class 中定义 get 和 set 方法:
class Person {
constructor(name) {
this._name_ = name;
}
get name() {
return this._name_;
}
set name(name) {
this.someOtherCustomProp = true;
this._name_ = name;
}
}
const p = new Person("Eden");
console.log(p.name); // Outputs: "Eden"
p.name = "John";
console.log(p.name); // Outputs: "John"
console.log(p.someOtherCustomProp); // Outputs: "true"
在这里,我们创建了一个访问器属性来封装 _name_ 属性。我们还记录了一些其他信息来证明 name 是一个添加到 class 的 prototype 属性的访问器属性。
generator 方法
要将一个对象的简洁方法视为 generator 方法,或者将一个类的方法视为 generator 方法,我们只需在它前面加上 * 字符。
类的 generator 方法被添加到类的 prototype 属性中。
下面是一个示例,演示如何在 class 中定义 generator 方法:
class myClass {
* generator_function() {
yield 1;
yield 2;
yield 3;
yield 4;
yield 5;
}
}
const obj = new myClass();
let generator = obj.generator_function();
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().value);
console.log(generator.next().done);
console.log("generator_function" in myClass.prototype);
输出如下:
1
2
3
4
5
true
true
静态方法
使用 static 前缀添加到 class 体内的方法称为 static 方法。static 方法是类的自身方法;也就是说,它们是添加到类本身而不是类的 prototype 属性。例如,String.fromCharCode() 方法是字符串构造函数的 static 方法,即 fromCharCode 是 String 函数本身的属性。
static 方法通常用于创建应用程序的实用函数。
下面是一个示例,演示如何在 class 中定义和使用 static 方法:
class Student {
constructor(name) {
this.name = name;
}
static findName(student) {
return student.name;
}
}
const s = new Student("Eden");
const name = Student.findName(s);
console.log(name); //Output "Eden"
在类中实现继承
在本章的早期部分,我们看到了在函数中实现继承层次结构的难度。因此,ES6 通过引入 extends 子句和 class 的 super 关键字来简化这一点。
通过使用 extends 子句,一个 class 可以从另一个 constructor(可能使用类定义,也可能不是)继承静态和非静态属性。
super 关键字有两种用法:
-
它用于类
constructor方法中调用父constructor -
当在
class的方法中使用时,它引用父constructor的静态和非静态方法
下面是一个示例,演示如何使用 extends 子句和 super 关键字在构造函数中实现继承层次结构:
function A(a) {
this.a = a;
}
A.prototype.printA = function(){
console.log(this.a);
}
class B extends A {
constructor(a, b) {
super(a);
this.b = b;
}
printB() {
console.log(this.b);
}
static sayHello() {
console.log("Hello");
}
}
class C extends B {
constructor(a, b, c) {
super(a, b);
this.c = c;
}
printC() {
console.log(this.c);
}
printAll() {
this.printC();
super.printB();
super.printA();
}
}
const obj = new C(1, 2, 3);
obj.printAll();
C.sayHello();
输出如下:
3
2
1
Hello
这里,A 是一个函数构造函数;B 是继承 A 的 class;C 是继承 B 的 class;由于 B 继承了 A,因此 C 也继承了 A。
由于类可以继承函数构造函数,我们也可以通过类继承预构建的函数构造函数,例如字符串和数组,以及使用类而不是我们以前使用的替代性笨拙方法来创建自定义函数构造函数。
之前的示例还展示了如何以及在哪里使用 super 关键字。记住,在 constructor 方法内部,在使用 this 关键字之前需要使用 super 关键字。否则,会抛出异常。
如果子类没有 constructor 方法,则默认行为将调用父类的 constructor 方法。
计算方法名称
你也可以在运行时决定 class 的静态和非静态方法以及对象字面量的简洁方法名称;也就是说,你可以通过表达式定义方法名称。下面是一个示例来演示这一点:
class myClass {
static ["my" + "Method"]() {
console.log("Hello");
}
}
myClass["my" + "Method"](); //Output "Hello"
计算属性名称还允许你使用符号作为方法的键。下面是一个示例来演示这一点:
var s = Symbol("Sample");
class myClass {
static [s]() {
console.log("Hello");
}
}
myClass[s](); //Output "Hello"
属性的属性特征
当使用 class 时,静态和非静态属性的属性与使用函数声明的属性不同:
-
static方法是可写的和可配置的,但不可枚举 -
class的prototype属性和prototype.constructor属性是不可写的、不可枚举的或不可配置的 -
prototype属性的属性是可写和可配置的,但不是可枚举的
类不会被提升!
你可以在定义函数之前调用它;也就是说,可以在函数定义之前调用函数调用。但是,你无法在定义之前使用 class。在类中尝试这样做将抛出 ReferenceError 异常。
以下是一个示例来演示这一点:
myFunc(); // fine
function myFunc(){}
var obj = new myClass(); // throws error
class myClass {}
覆盖构造函数方法的返回结果
默认情况下,constructor 方法如果没有返回语句,则返回新实例。如果有返回语句,则返回语句中的任何值。如果你来自像 C++ 这样的语言,这可能会显得有些奇怪,因为在那里通常不能从 constructor 返回任何值。
以下是一个示例来演示这一点:
class myClass {
constructor() {
return Object.create(null);
}
}
console.log(new myClass() instanceof myClass); //Output "false"
Symbol.species 静态访问器属性
@@species 静态访问器属性可选地添加到子 constructor 中,以便通知父 constructor 的方法,如果父构造函数的方法返回新的实例,则构造函数应该使用什么。如果子 constructor 上未定义 @@species 静态访问器属性,则父 constructor 的方法可以使用默认的 constructor。
考虑以下示例以了解 @@species 的使用——数组对象的 map() 方法返回一个新的数组实例。如果我们调用继承自 Array 对象的对象的 map() 方法,那么 map() 方法将返回子 constructor 的新实例而不是 Array 构造函数,这通常不是我们想要的。提供此类函数信号方式的 @@species 属性使用不同的 constructor 而不是默认的 constructor。
以下是一个代码示例来演示如何使用 @@species 静态访问器属性:
class myCustomArray1 extends Array {
static get [Symbol.species]() {
return Array;
}
}
class myCustomArray2 extends Array{}
var arr1 = new myCustomArray1(0, 1, 2, 3, 4);
var arr2 = new myCustomArray2(0, 1, 2, 3, 4);
console.log(arr1 instanceof myCustomArray1); // Outputs "true"
console.log(arr2 instanceof myCustomArray2); // Outputs "true"
arr1 = arr1.map(value => value + 1);
arr2 = arr2.map(value => value + 1);
console.log(arr1 instanceof myCustomArray1); // Outputs "false"
console.log(arr2 instanceof myCustomArray2); // Outputs "true"
console.log(arr1 instanceof Array); // Outputs "true"
console.log(arr2 instanceof Array); // Outputs "true"
如果你在创建 JavaScript 库,建议你的库中的构造函数方法在返回新实例时始终查找 @@species 属性。以下是一个示例来演示这一点:
//Assume myArray1 is part of library
class myArray1 {
//default @@species. Child class will inherit this property
static get [Symbol.species]() {
//default constructor
return this;
}
mapping() {
return new this.constructor[Symbol.species]();
}
}
class myArray2 extends myArray1 {
static get [Symbol.species]() {
return myArray1;
}
}
let arr = new myArray2();
console.log(arr instanceof myArray2); //Output "true"
arr = arr.mapping();
console.log(arr instanceof myArray1); //Output "true"
如果你不想在父构造函数中定义默认的 @@species 属性,则可以使用 if…else 条件语句检查 @@species 属性是否已定义,但首选的模式是之前的模式。内置的 map() 方法也使用之前的模式。
从 ES6 开始,JavaScript 构造函数的所有内置方法在返回新实例时都会查找 @@species 属性。例如,Array、Map、ArrayBuffer、Promise 和其他此类构造函数的方法在返回新实例时会查找 @@species 属性。
new.target 隐式参数
new.target 的默认值是 undefined,但当一个函数作为构造函数被调用时,new.target 参数的值取决于以下条件:
-
如果使用
new运算符调用构造函数,则new.target指向此构造函数 -
如果通过 super 关键字调用构造函数,那么其中的 new.target 的值与被调用 super 的构造函数的 new.target 的值相同。
在箭头函数内部,new.target 的值与周围非箭头函数的new.target的值相同。
下面是演示此功能的示例代码:
function myConstructor() {
console.log(new.target.name);
}
class myClass extends myConstructor {
constructor() {
super();
}
}
const obj1 = new myClass();
const obj2 = new myConstructor();
输出如下:
myClass
myConstructor
在对象字面量中使用 super
super 关键字也可以在对象字面量的简洁方法中使用。对象字面量简洁方法中的 super 关键字具有与该对象字面量定义的对象的[[prototype]]属性相同的值。
在对象字面量中,super 用于通过子对象访问重写的属性。
这里有一个示例来展示如何在对象字面量中使用 super:
const obj1 = {
print() {
console.log("Hello");
}
}
const obj2 = {
print() {
super.print();
}
}
Object.setPrototypeOf(obj2, obj1);
obj2.print(); //Output "Hello"
ES.next 提案包括通过使用 hash(#)符号在类中添加对真正私有属性的支持。类内部的#myProp 将是该类的私有属性。
概述
在本章中,我们首先使用传统函数方法学习了面向对象编程的基础。然后,我们转向类,学习了它们如何使我们更容易阅读和编写面向对象的 JavaScript 代码。我们还了解了一些其他特性,如new.target和存取器方法。现在,让我们继续前进到网络,一个我们可以实现所学内容的地方!
第九章:网页上的 JavaScript
你好!到目前为止,我们已经学习和创建了对 JavaScript 的坚实基础理解,包括它在底层是如何工作的以及它包含的内容。但我们实际上是如何使用它的呢?我们如何开始构建一些东西?这就是本章要处理的内容。
在本章中,我们将学习以下内容:
-
HTML5 和现代 JavaScript 的兴起
-
文档对象模型 (DOM) 是什么?
-
DOM 方法/属性
-
现代 JavaScript 浏览器 API
-
页面可见性 API
-
导航器 API
-
剪贴板 API
-
Canvas API - 网络的画板
-
Fetch API
HTML5 和现代 JavaScript 的兴起
HTML5 规范于 2008 年发布。然而,HTML5 在 2008 年的技术如此先进,以至于预测它至少要到 2022 年才准备好!然而,结果证明这是不正确的,现在我们有了完全支持的 HTML5 和支持 ES6/ES7/ES8 的浏览器。
许多 HTML5 使用的 API 都与 JavaScript 密不可分。在查看这些 API 之前,让我们了解一下 JavaScript 如何看待网络。这将最终使我们处于强大的位置,以理解各种有趣的 JavaScript 相关事物,例如 Web Workers API,它值得拥有自己的章节(剧透警告:它包含在这本书中!)
HTML DOM
HTML DOM 是文档外观的树形版本。以下是一个非常简单的 HTML 文档示例:
<!doctype HTML>
<html>
<head>
<title>Cool Stuff!</title>
</head>
<body>
<p>Awesome!</p>
</body>
</html>
它的树形版本将看起来如下:

之前的图只是一个 DOM 树的大致表示。HTML 标签由 head 和 body 组成;此外,<body> 标签包含一个 <p> 标签,而 <head> 标签包含 <title> 标签。简单!
JavaScript 可以直接访问 DOM,并可以修改这些节点之间的连接,添加节点,删除节点,更改内容,附加事件监听器等。
文档对象模型 (DOM) 是什么?
简而言之,DOM 是一种将 HTML 或 XML 文档表示为节点的方式。这使得其他编程语言更容易连接到遵循 DOM 的页面并相应地修改它。
为了清楚起见,DOM 不是一个编程语言。DOM 为 JavaScript 提供了一种与网页交互的方式。你可以将其视为一个标准。每个元素都是 DOM 树的一部分,可以通过暴露给 JavaScript 的 API 进行访问和修改。
DOM 不限于只能由 JavaScript 访问。它是语言无关的,并且有各种语言模块可用于解析 DOM(就像 JavaScript 一样),包括 PHP、Python、Java 等。
如前所述,DOM 为 JavaScript 提供了一种与之交互的方式。如何?嗯,访问 DOM 就像访问 JavaScript 中的预定义对象一样简单:document。DOM API 指定了你将在 document 对象中找到的内容。document 对象实际上为 JavaScript 提供了对由你的 HTML 文档形成的 DOM 树的访问。如果你注意到,不首先访问 document 对象,你根本无法访问任何元素。
DOM 方法/属性
所有 HTML 元素在 JavaScript 中都是对象。最常用的对象是 document 对象。它将整个 DOM 树附加到它上面。你可以在那里查询元素。让我们看看这些方法的几个非常常见的例子:
-
getElementById方法 -
getElementsByTagName方法 -
getElementsByClassName方法 -
querySelector方法 -
querySelectorAll方法
这绝不是所有可用方法的详尽列表。然而,这个列表至少应该能让你开始 DOM 操作。使用 MDN 作为你参考各种其他方法的依据。以下是链接:developer.mozilla.org/en-US/docs/Web/API/Document#Methods。
使用 getElementById 方法
在 HTML 中,你可以给一个元素分配一个 ID,然后在 JavaScript 中检索它以进行操作。下面是如何做的:
<div id="myID">My Content Here</div>
<script>
const myID = document.getElementById('myID'); // myID now contains reference to the div above
</script>
一旦你有了这个,你就可以访问这个对象的属性,这实际上会根据需要修改屏幕上的元素。
使用 getElementsByTagName 方法
与 ID 方法类似,getElementsByTagName(<标签名>) 获取元素有一些区别:
-
它给你一组元素而不是单个元素(数组)
-
它基于元素的标签名查询元素,而不是
ID值
下面是一个例子:
<div>My Content Here</div>
<script>
const div = document.getElementsByTagName('div')[0];
div.innerHTML = "Cool"; // above div's text is replaced with "Cool"
</script>
注意到单词 getElements。它返回给我们一堆元素。因此,我们从 NodeList 中选择第一个元素并将其内容设置为 Cool。
innerHTML 用于更改你正在工作的元素内部的 HTML 内容。
使用 getElementsByClassName 方法
getElementsByClassName 方法将返回具有相同类的元素作为一个 NodeList,而不是一个 Array!NodeList 并不完全是一个 Array;然而,它是可迭代的,并且很容易转换成 Array:
<span class="tag">Hello</span>
<span class="tag">Hi</span>
<span class="tag">Wohoo!</span>
<script>
const tags = document.getElementsByClassName('tag'); // This is a NodeList (not Array)
try {
tags.map(tag => console.log(tag)); // ERROR! map is not a function
} catch(e) {
console.log('Error ', e);
}
[...tags].map(tag => console.log(tag)); // No error
</script>
如前所述,tags 实际上是一个 NodeList。首先,我们使用一个解构操作符并用方括号包围它,实际上将其转换为一个 Array。然后我们使用 map(我们可以在 Array 上使用它,但不能在 NodeList 上使用)来遍历每个标签,即 <span> 元素,我们只是控制台输出这个元素。
从这段代码中我们可以学到的是我们如何在其中使用解构操作符。你经常会发现自己经常使用我们之前学过的重要概念。
使用 querySelector 方法
querySelector方法以与通过 CSS 选择器选择元素相同的方式返回一个元素。然而,document.querySelector只返回一个元素。因此,一旦我们查询到该元素,我们就可以直接操作它:
<div data-attr="coolDIV">Make me red!</div>
<script>
document.querySelector('div[data-attr]').style.color = 'red'; // div becomes red
</script>
如果在文档中只有一个<div>标签,这相当于执行document.querySelector('div').style.color = 'red';。
使用 querySelectorAll 方法
就像您可以使用querySelector获取任何元素一样,您也可以使用querySelectorAll获取符合特定标准的多个元素。您已经在getElementsByClass方法中看到了如何使用NodeList。试着理解以下代码:
<div data-attr="red">Make me red!</div>
<div data-attr="blue">Make me blue!</div>
<script>
[...document.querySelectorAll('div[data-attr]')].map(div => {
div.style.color = div.attributes['data-attr'].value;
});
</script>
首先,我们使用解构将NodeList转换为Array。然后我们映射数组,并根据每个<div>的 data-attr 值更改其样式。
现代 JavaScript 浏览器 API
HTML5 从一开始就为 JavaScript 中一些很棒和酷的 API 提供了大量支持。尽管一些 API 是与 HTML5 本身一起发布的(例如 Canvas API),但也有一些是在之后添加的(例如 Fetch API)。
让我们看看一些这些 API 以及如何使用它们的代码示例。
页面可见性 API - 用户是否仍然在页面上?
Page Visibility API允许开发者在其页面的用户获得或失去焦点时运行特定的代码。想象一下,如果你运营一个游戏托管网站,并且希望在用户失去对您的标签的焦点时暂停游戏。这就是你该采取的方法!
function pageChanged() {
if (document.hidden) {
console.log('User is on some other tab/out of focus') // line #1
} else {
console.log('Hurray! User returned') // line #2
}
}
document.addEventListener("visibilitychange", pageChanged);
我们向文档添加了一个事件监听器;它会在页面更改时触发。当然,pageChanged函数也会在参数中接收到一个事件对象,但我们可以简单地使用document.hidden属性,该属性返回一个布尔值,取决于代码调用时的页面可见性。
你将在line #1处添加你的*pause game*代码,并在line #2处添加你的*resume game*代码。
navigator.onLine API – 用户网络状态
navigator.onLine API 会告诉你用户是否在线。想象一下,如果你正在开发一个多人游戏,并且希望当用户断开互联网连接时游戏自动暂停。这就是你该采取的方法!
function state(e) {
if(navigator.onLine) {
console.log('Cool we\'re up');
} else {
console.log('Uh! we\'re down!');
}
}
window.addEventListener('offline', state);
window.addEventListener('online', state);
在这里,我们向 window 全局对象附加了两个事件监听器。我们希望在用户离线或在线时调用state函数。
浏览器会在用户离线或在线时调用state函数。我们可以通过navigator.onLine访问它,如果存在互联网连接,则返回一个布尔值true,如果没有,则返回false。
Clipboard API - 以编程方式操作剪贴板
Clipboard API最终允许开发者在不使用那些令人讨厌的 Adobe Flash 插件黑客手段的情况下将内容复制到用户的剪贴板。以下是您将如何将选择复制到用户剪贴板的方法:
<script>
function copy2Clipboard(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.focus();
textarea.setSelectionRange(0, text.length);
document.execCommand('copy');
document.body.removeChild(textarea);
}
</script>
<button onclick="copy2Clipboard('Something good!')">Click me!</button>
首先,我们需要用户实际点击按钮。一旦用户点击按钮,我们就调用一个函数,在后台使用document.createElement方法创建一个textarea。然后脚本将textarea的值设置为传入的文本(这很好!)我们接着聚焦到那个textarea并选择其中的所有内容。
一旦内容被选中,我们就使用document.execCommand('copy')执行复制;这会将文档中的当前选择复制到剪贴板。由于现在textarea中的值被选中,它就被复制到剪贴板。最后,我们从文档中移除textarea,以免它破坏文档布局。
你不能在没有用户交互的情况下触发copy2Clipboard。我的意思是,显然你可以,但如果事件不是来自用户(点击、双击等),document.execCommand('copy')将不会工作。这是一个安全实现,以确保用户的剪贴板不会被他们访问的每个网站所干扰。
Canvas API - 网页的绘图板
HTML5 终于引入了对<canvas>的支持,这是一种在网页上绘制图形的标准方式!Canvas 几乎可以用于你所能想到的与图形相关的任何事情;从用笔数字化签名,到在网页上创建 3D 游戏(3D 游戏需要 WebGL 知识,感兴趣吗?- 访问bit.ly/webgl-101)。
让我们通过一个简单的例子来看看 Canvas API 的基础:
<canvas id="canvas" width="100" height="100"></canvas>
<script>
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
ctx.moveTo(0,0);
ctx.lineTo(100, 100);
ctx.stroke();
</script>
这将产生以下结果:

它是如何做到这一点的?
-
首先,
document.getElementById('canvas')给我们提供了文档中画布的引用。 -
然后我们获取画布的上下文。这是指明我想对画布做什么的一种方式。当然,你可以在那里放一个 3D 值!当你使用 WebGL 和 canvas 进行 3D 渲染时,情况确实如此。
-
一旦我们有了对上下文的引用,我们就可以做很多事情,并直接添加 API 提供的方法。在这里,我们将光标移动到了(0, 0)坐标。
-
然后我们画了一条线到(100,100)(这在正方形画布上基本上是斜线)。
-
然后我们调用 stroke 来实际上在我们的画布上绘制。很简单!
Canvas 是一个广泛的话题,值得有一本自己的书!如果你对使用 Canvas 开发酷炫的游戏和应用程序感兴趣,我建议你从 MDN 文档开始:bit.ly/canvas-html5。
Fetch API - 基于 Promise 的 HTTP 请求
浏览器中引入的最酷的异步 API 之一是 Fetch API,它是XMLHttpRequest API 的现代替代品。你有没有发现自己只是为了简化 AJAX 请求而使用 jQuery 的$.ajax?如果你有,那么这个 API 对你来说肯定是一块金子,因为它本地更容易编写和阅读!
如果你还记得,我们在第四章“异步编程”中自己创建了一个XMLHttpRequest的承诺版本。然而,fetch是原生的,因此,它有性能优势。让我们看看它是如何工作的:
fetch(link)
.then(data => {
// do something with data
})
.catch(err => {
// do something with error
});
太棒了!所以fetch使用承诺!如果是这样,我们可以将其与 async/await 结合使用,使其看起来完全同步且易于阅读!
<img id="img1" alt="Mozilla logo" />
<img id="img2" alt="Google logo" />
const get2Images = async () => {
const image1 = await fetch('https://cdn.mdn.mozilla.net/static/img/web-docs-sprite.22a6a085cf14.svg');
const image2 = await fetch('https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png');
console.log(image1); // gives us response as an object
const blob1 = await image1.blob();
const blob2 = await image2.blob();
const url1 = URL.createObjectURL(blob1);
const url2 = URL.createObjectURL(blob2);
document.getElementById('img1').src = url1;
document.getElementById('img2').src = url2;
return 'complete';
}
get2Images().then(status => console.log(status));
console.log(image1)这一行将打印以下内容:

您可以看到image1响应提供了关于请求的大量信息。它有一个有趣的字段 body,实际上是一个ReadableStream,以及可以转换为我们的二进制大对象(BLOB)的数据字节流。
一个blob对象代表一个不可变和原始数据的文件样对象。
在获取到Response之后,我们将其转换为blob对象,这样我们就可以将其用作图像。在这里,fetch实际上直接获取图像,因此我们可以将其作为blob(而不是将其链接到主网站)提供给用户。
因此,这可以在服务器端完成,blob数据可以通过 WebSocket 或其他类似方式传递下来。
Fetch API 自定义
Fetch API 非常可定制。您甚至可以在请求中包含自己的头信息。假设您有一个网站,只有拥有有效令牌的认证用户才能访问图像。以下是您如何向请求添加自定义头信息的方法:
const headers = new Headers();
headers.append("Allow-Secret-Access", "yeah-because-my-token-is-1337");
const config = { method: 'POST', headers };
const req = new Request('http://myawesomewebsite.awesometld/secretimage.jpg', config);
fetch(req)
.then(img => img.blob())
.then(blob => myImageTag.src = URL.createObjectURL(blob));
在这里,我们向我们的Request添加了一个自定义头,然后创建了一个名为Request的对象(一个包含我们Request信息的对象)。第一个参数,即http://myawesomewebsite.awesometld/secretimage.jpg,是 URL,第二个是配置。以下是一些其他配置选项:
-
Credentials:用于在启用跨源资源共享(CORS)的服务器上跨域请求中传递 cookie。
-
Method:指定请求方法(GET、POST、HEAD 等)。
-
Headers:与请求相关的头信息。
-
完整性:一个安全特性,由你请求的文件的(可能)SHA-256 表示组成,用于验证请求是否被篡改(数据被修改)或未被篡改。除非你在非常大规模且不在 HTTPS 上构建东西,否则可能无需过多担心。
-
Redirect:重定向可以有三个值:
-
Follow:将跟随 URL 重定向
-
错误:如果 URL 重定向,将抛出错误
-
手动:不跟随重定向,但返回一个包装重定向响应的过滤响应
-
-
Referrer:HTTP 请求中作为引用头出现的 URL。
使用 history API 访问和修改历史记录
您可以通过history API 在一定程度上访问用户的历史记录,并根据您的需求进行修改。它包括长度和状态属性:
console.log(history, history.length, history.state);
输出如下:
{length: 4, scrollRestoration: "auto", state: null}
4
null
在你的情况下,length显然会根据你从该特定标签页访问的页面数量而有所不同。
history.state可以包含任何你想要的内容(我们很快就会看到其用例)。在查看一些有用的历史方法之前,让我们看看window.onpopstate事件。
处理 window.onpopstate 事件
当用户在开发者设置的历史状态之间导航时,浏览器会自动触发window.onpopstate事件。当你向历史对象推送信息,并在稍后用户按下浏览器的后退/前进按钮时检索信息时,处理此事件非常重要。
这是我们将如何编程一个简单的popstate事件:
window.addEventListener('popstate', e => {
console.log(e.state); // state data of history (remember history.state ?)
})
现在我们将讨论一些与history对象相关的方法。
修改历史记录 - history.go(distance)方法
history.go(x)等同于用户在浏览器中点击前进按钮x次。然而,你可以指定移动的距离,即history.go(5);。这相当于用户在浏览器中点击前进按钮五次。
同样,你也可以指定负值来使其向后移动。指定 0 或无值将简单地刷新页面:
history.go(5); // forwards the browser 5 times
history.go(-1); // similar effect of clicking back button
history.go(0); // refreshes page
history.go(); // refreshes page
向前跳跃 - history.forward()方法
此方法简单地等同于history.go(1)。
当你想将用户推回到他们之前所在的页面时,这很有用。一个用例是当你可以创建一个全屏沉浸式 Web 应用,并且在你的屏幕上有一些在幕后操作历史的最小控件:
if(awesomeButtonClicked && userWantsToMoveForward()) {
history.forward()
}
向后移动 - history.back()方法
此方法简单地等同于history.go(-1)。
负数会使历史记录向后移动。再次强调,这仅仅是一种简单(且无数字)的方式回到用户之前访问的页面。其应用可能类似于前进按钮,即创建一个全屏 Web 应用,并为用户提供一个界面来导航。
向历史记录中推送 - history.pushState()
这真的很有趣。你可以不通过 HTTP 请求击中服务器来更改浏览器 URL。如果你在浏览器中运行以下 JS,你的浏览器将从当前路径(domain.com/abc/egh)更改为/i_am_awesome(domain.com/i_am_awesome),而实际上并没有导航到任何页面:
history.pushState({myName: "Mehul"}, "This is title of page", "/i_am_awesome");
history.pushState({page2: "Packt"}, "This is page2", "/page2_packt"); // <-- state is currently here
历史 API 并不关心页面是否真的存在于服务器上。它只会按照指示替换 URL。
当使用浏览器的后退/前进按钮触发popstate事件时,将触发下面的函数,我们可以这样编程:
window.onpopstate = e => { // when this is called, state is already updated.
// e.state is the new state. It is null if it is the root state.
if(e.state !== null) {
console.log(e.state);
} else {
console.log("Root state");
}
}
要运行此代码,首先运行onpopstate事件,然后运行之前的两行history.pushState。然后按下浏览器的后退按钮。你应该会看到类似以下的内容:
{myName: "Mehul"}
这与父状态相关的信息。再按一次后退按钮,你会看到消息根状态。
pushState不会触发onpopstate事件。只有浏览器的后退/前进按钮会。
在历史记录栈上推进 - history.replaceState()
history.replaceState() 方法与 history.pushState() 方法完全相同,唯一的区别是它会用另一个页面替换当前页面,也就是说,如果您使用 history.pushState() 并按下后退按钮,您将被导向您之前所在的页面。
然而,当您使用 history.replaceState() 并按下后退按钮时,您不会被导向您之前所在的页面,因为它是用栈上的新页面替换的。以下是一个使用 replaceState 方法的示例:
history.replaceState({myName: "Mehul"}, "This is title of page", "/i_am_awesome");
这将(而不是推送)当前状态替换为新状态。
虽然直接在您的代码中使用 History API 可能目前对您没有好处,但许多框架和库,如 React,在底层使用 History API 来为最终用户提供无缝、无需重新加载、流畅的体验。
摘要
在本章中,我们介绍了一些由 HTML5 和现代 JavaScript 一起引入的最佳 API,以及它们如何塑造人们浏览和与网站交互的方式。
在下一章中,我们将简要概述 HTTP 协议以及 JavaScript 中可用的某些存储 API,这些 API 可以用于本地存储数据和与服务器通信。让我们开始吧!
第十章:JavaScript 中的存储 API
想象一下,你正在使用 Facebook 并登录到了你的账户。你看到了你的新闻动态;一切看起来都很正常。接下来,你点击了一个帖子,然后被要求再次登录。这很奇怪。你继续操作并再次登录,帖子才打开。你点击了一条评论中的链接,然后又被要求再次登录。这是怎么回事?
如果我们生活在一个前端没有存储 API 的世界里,会发生这样的事情。
在本章中,我们将探讨以下主题:
-
网络是如何因为 cookies 而工作的
-
JavaScript 中可用的不同数据存储区域形式
-
与
localStorage和sessionStorage对象相关的方法 -
indexedDB简介 -
如何使用
indexedDB执行基本的添加、删除和读取操作
超文本传输协议(HTTP)
HTTP 是一个无状态协议。无状态协议意味着服务器上没有存储任何状态,这反过来意味着服务器在向客户端发送响应后就会忘记一切。考虑以下情况:
你在你的浏览器中输入了http://example.com。当你的请求到达服务器时,服务器知道你的 IP 地址、你请求的页面以及与你的 HTTP 请求相关的任何其他头信息。它从文件系统或数据库中获取内容,将响应发送给你,然后忘记这一切。
在每个新的 HTTP 请求中,客户端和服务器都会像第一次见面一样互动。那么,这难道不是意味着我们之前的 Facebook 例子在现实世界中也是正确的吗?
实质上,情况就是这样。所有网站都使用cookies进行身份验证,这是一种模拟协议状态性的方法。从每个请求中移除 cookies,你将能够看到你面前的原始、无状态的 HTTP 协议。
什么是 TLS/SSL 握手?
在深入了解握手是什么之前,让我们花一分钟来理解一下传输层安全性(TLS)/安全套接字层(SSL)是什么。
首先,我们应该注意,TLS 只是 SSL 的一个升级版,一个更现代的版本。那么,SSL 是什么呢?
SSL 是安全协议中的一个标准,用于在您的计算机和远程服务器之间建立一个加密和安全的隧道。它阻止了正在监听您互联网连接的人,比如您的互联网服务提供商(ISP),窃取通过网络传输的数据。
在当今的每个主要网站上,你都会在浏览器中 URL 的左侧看到一个绿色的锁。这是安全的象征,这意味着你的浏览器正在使用 TLS/SSL 加密与服务器进行通信。
现在,什么是握手?正如字面意义,握手是浏览器和服务器交换它们在每次通信中使用的加密密钥的地方,用于加密或解密彼此发送的消息。
为什么我们要讨论 TLS/SSL?这是因为 TLS/SSL 握手在性能上很昂贵。当只有一次握手时,它们并不真正昂贵,但如果我们引入无状态的概念,它们就会开始成为一个问题。这意味着你的浏览器和服务器会在每次请求时忘记他们已经知道彼此的加密密钥。这意味着你的浏览器和服务器需要为每次请求执行 TLS 握手,这将使一切变得相当缓慢。为了避免这种情况,TLS/SSL 协议实际上是一个有状态的协议。
HTTP 之所以如此可扩展,是因为它是无状态的。有状态的协议,如 TLS 和 SSL,在逻辑实现上较为复杂。如果你想了解更多关于 TLS/SSL 的工作原理,请阅读以下内容:https://security.stackexchange.com/a/20833/44281
模仿 HTTP 状态
使用 cookie 是一种存储与访问你网站的用户相关的小量数据的方式。你将在下一节中了解更多关于 cookie 的内容。你在特定网站上存储在 cookie 中的任何内容都会附加到对该网站的每个 HTTP 请求上。所以,基本上,你的 HTTP 协议在每次请求中都会传输一个 cookie 字符串,这允许服务器存储与每个连接到它的客户端相关的一些信息。
当我们向我们的XMLHttpRequest添加自定义头(记得上一章中的“The *Fetch API 自定义”部分?),这使得在 HTTP 协议上伪造我们的状态变得容易。授权头是浏览器在每次请求中发送的另一个头,如果设置了的话。
现在我们实际来看看这些存储区域,比如 cookie、localStorage、sessionStorage和indexedDB。
使用 cookie 存储数据
Cookies 是一些小字符串,一旦为某个域和路径设置,就会在每次请求中反复发送到服务器。这对于身份验证来说很完美,但如果你使用它们来存储你只需要一次或需要在前端访问的数据,比如你不在服务器上存储结果的某个游戏的玩家得分,那么就不是很理想。
人们通常使用 cookie 来存储大量数据,以便在域的某些其他路径上可用。这是一种不良做法,因为你会不断地将数据传输到服务器,如果数据量很大,这会使你的通信变慢。
设置 cookie
让我们看看如何使用 JavaScript 访问和设置 cookie:
存储在 cookie 中的信息以key=value;格式存在。让我们使用下面的代码片段在浏览器中创建一些 cookie:
document.cookie = "myFirstCookie=good;"
document.cookie = "mySecondCookie=great;"
console.log(document.cookie);
警告:前方有奇怪的行为!
你期望在这里记录什么?答案是如下:

为什么没有覆盖document.cookie对象?所有这些内容将在下一节中解释。
document.cookie 是一个奇怪的对象
如您之前所见,document.cookie显示了一种特殊的行为。它不是替换所有 cookie,而是将变量更新为新 cookie。这种行为是通过一个实际上被称为宿主对象的文档实现的,而不是原生对象。宿主对象具有做任何事的能力,因为它们不需要遵循常规对象的语义。
宿主对象实际上是在特定环境中提供的对象--在我们的案例中,是浏览器。当 JavaScript 在服务器(Node.js)上运行时,您无法访问 document 或 window。这意味着它们是宿主对象--也就是说,依赖于宿主并由宿主实现(浏览器)。
在这种情况下,document.cookie覆盖了赋值运算符,实际上是将值附加到变量而不是修改它。
关于[[PutValue]]的更多技术信息可以在es5.github.io/#x8.7.2找到。
现在的问题是,我们如何删除我们设置的 cookie?我们将在下一节中查看。
删除 cookie
要删除 cookie,您需要回到 cookie 并为其指定一个过期日期。之后,浏览器将删除 cookie,并且不再在每次请求时将其发送到服务器。
下面是如何在代码中实现它的示例:
document.cookie = "myFirstCookie=good;"
document.cookie = "mySecondCookie=great;"
console.log(document.cookie);
document.cookie = "mySecondCookie=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
console.log(document.cookie);
1970 年 1 月 1 日 00:00:00是我们可以追溯的时间点,因为 JavaScript 遵循 Unix 的时间戳。前面代码的输出如下:

获取 cookie 值
JavaScript 没有提供任何方便的方法来直接获取 cookie 值。我们只有一串随机 cookie,可以通过document.cookie访问。我们需要做一些工作,如下面的代码片段所示:
document.cookie = "awesomecookie=yes;";
document.cookie = "ilovecookies=sure;";
document.cookie = "great=yes";
function getCookie(name) {
const cookies = document.cookie.split(';');
for(let i=0;i < cookies.length;i++) {
if(cookies[i].trim().indexOf(name) === 0) {
return cookies[i].split('=')[1];
}
}
return null;
}
console.log(getCookie("ilovecookies"));
console.log(getCookie("doesnotexist"));
因此,输出将如下所示:
sure
null
如您在下面图像的高亮部分中可以看到,当我们重新加载页面时,设置的 cookie 会在每次请求时发送到服务器:

这些 cookie 将随后由服务器访问,具体取决于您使用的后端语言。
与 localStorage 一起工作
localStorage对象在所有主要浏览器中都是可用的。它是在 HTML5 中引入的。本地存储允许您在用户的计算机上持久存储数据。除非您的脚本或用户明确想要清除数据,否则数据将保持不变。
本地存储遵循相同的源策略。我们将在下一章中详细讨论源策略,但就目前而言,只需了解相同的源策略可以限制不同网站及其对特定网站本地存储的访问。
此外,请注意,localStorage 中的键值对只能是字符串值。要存储对象,您必须首先使用JSON.stringify对其进行序列化。
创建本地存储条目
我们可以以比 cookie 更直观和方便的方式向本地存储添加条目。以下是使用localStorage.setItem(key, value)的语法:
localStorage.setItem('myKey', 'awesome value');
console.log('entry added');
localStorage是一个同步 API。它会在完成之前阻塞线程执行。
现在我们快速、粗略地确定localStorage.setItem平均需要多少时间,如下所示:
const now = performance.now();
for(let i=0;i<1000;i++) {
localStorage.setItem(`myKey${i}`, `myValue${i}`);
}
const then = performance.now();
console.log('Done')
console.log(`Time taken: ${(then - now)/1000} milliseconds per operation`);
如你所见,结果并不那么糟糕:

因此,一个操作大约需要0.02毫秒。这对于一个常规应用来说已经很不错了。
获取存储的项目
你可以使用localStorage.getItem、localStorage.key或localStorage['key']方法访问 session 存储对象中的存储项目。我们将在稍后的localStorage.getItem('key')与localStorage.key部分更详细地探讨这个问题,我们将看到哪种方法是最好的,以及为什么不要使用其他方法;现在,尽管如此,让我们坚持使用localStorage.getItem方法。
从本地存储中获取存储的项目很容易,如下面的代码片段所示:
const item = localStorage.getItem('myKey');
console.log(item); // my awesome value
删除存储的项目
你可以从localStorage对象中删除单个项目。为此,你需要有你想删除的键值对的键。这可能是一切你不再需要的东西。
在你的代码中进一步访问它将导致null,如下所示:
localStorage.removeItem('myKey');
console.log(localStorage.getItem('myKey')); // null
清除所有项目
有时候,在实验过程中,你可能会发现你在存储中放入了很多无用的键值对。你可以使用本地存储中的clear方法一次性清除它们。你可以用以下命令做到这一点:
localStorage.clear();
console.log(localStorage); // blank object {}
localStorage.getItem('key')与localStorage.key与localStorage['key']
这三个方法,localStorage.getItem('key')、localStorage.key和localStorage['key'],都做同样的事情。然而,出于以下原因,建议使用提供的方法:
-
localStorage.getItem('key-does-not-exist')返回null,而localStorage['key-does-not-exist']将返回undefined。在 JavaScript 中,null不等于undefined。例如,假设你想要设置一个实际上是对象属性或函数名称的键,比如getItem和setItem。在这种情况下,你最好使用getItem方法,如下所示:localStorage.setItem('getItem', 'whohoo we are not overwriting getItem'); // #1 localStorage.getItem('getItem'); // whohoo we are not overwriting getItem localStorage.getItem = 'oh no I'm screwed'; // #2 localStorage.getItem('getItem'); // Error! getItem is not a function. -
如果你意外地使用了
#2而不是#1来存储一个数字,localStorage将覆盖getItem函数,你将无法再访问它,如下面的代码片段所示:localStorage.setItem('length', 100); // Stores "1" as string in localStorage localStorage.length = 100; // Ignored by localStorage
这里的经验是使用localStorage上的getItem、setItem和其他方法。
与 SessionStorage 一起工作
Session 存储就像本地存储一样,只是 session 存储不是持久的。这意味着每次你关闭设置 session 存储的标签页时,你的数据都会丢失。
会话存储可能有用的情况之一是当你有一个基于 Ajax 的网站,该网站动态加载一切。你想要创建一个类似状态的对象,你可以使用它来存储界面的状态,这样当用户返回他们已经访问过的页面时,你可以轻松地恢复该页面的状态。
现在我们快速浏览一下会话存储的所有方法。
创建会话存储条目
要在sessionStorage对象中创建键值对,你可以使用setItem方法,类似于localStorage对象。就像localStorage一样,sessionStorage也是一个同步 API,所以你可以确信你将立即能够访问你存储的任何值。
向会话存储中添加项目就像处理本地存储一样,如下面的代码片段所示:
sessionStorage.setItem('my key', 'awesome value');
console.log('Added to session storage');
获取存储项
可以使用sessionStorage.getItem、sessionStorage.key或sessionStorage['key']方法访问sessionStorage对象中的存储项。然而,与localStorage一样,建议使用getItem来安全地获取正确的存储值,而不是sessionStorage对象的属性。
以下代码片段演示了如何从会话存储中获取存储项:
const item = sessionStorage.getItem('myKey');
console.log(item); // my awesome value
移除存储的项目
你可以从sessionStorage对象中移除单个项目。为此,你需要拥有你想要移除的键值对的键。这可以是任何你不再需要的东西。
在你的代码中进一步访问它将导致null,如下面的代码片段所示:
sessionStorage.removeItem('myKey');
console.log(sessionStorage.getItem('myKey')); // null
清除所有项目
有时候,在实验过程中,你可能会发现你在存储中放入了大量的无用键值对。你可以使用会话存储中的clear方法一次性清除它们,如下面的代码片段所示:
sessionStorage.clear();
console.log(sessionStorage); // blank object {}
处理多个标签页之间的存储更改
当存储发生变化时,会发出某些事件,其他打开的标签页可以捕获这些事件。你可以为它们设置事件监听器来监听并执行任何适当的修改。
例如,假设你在网站的一个标签页中添加了localStorage。一个用户也打开了你的网站的另一个标签页。如果你想反映该标签页中localStorage的变化,你可以监听存储事件并相应地更新内容。
注意,更新事件将在除了更改的那个标签页之外的所有其他标签页上触发:
window.addEventListener('storage', e => {
console.log(e);
});
localStorage.setItem('myKey', 'myValue'); // note that this line was run in another tab
以下代码产生以下输出:

你可以注意到它包含有关存储事件的大量有用信息。
网络工作者(在第十一章中讨论)无法访问本地存储或会话存储。
与本地存储相比的 Cookies
到目前为止,你可能已经观察到 cookies 和本地存储几乎完全服务于不同的目的。它们唯一共同的地方是它们存储数据。以下是对 cookies 和本地存储的简要比较:
| Cookies | Local storage |
|---|---|
| 浏览器会在每次请求时自动将 cookies 传输到服务器 | 要将本地存储数据传输到服务器,你需要手动发送 Ajax 请求或通过隐藏表单字段发送 |
| 如果数据需要由客户端和服务器同时访问和读取,请使用 cookies | 如果数据只需要由客户端访问和读取,请使用本地存储 |
| Cookies 可以设置过期日期,过期后它们将被自动删除 | 本地存储不提供此类过期日期功能;它只能通过 JavaScript 清除 |
| 一个 cookie 的最大大小是 4 KB | 本地存储的最大大小取决于浏览器和平台,但通常每个域大约是 5 MB |
indexedDB - 存储大量数据
与我们已讨论的其他存储介质相比,indexedDB是一个相对较新且底层的 API。它用于存储比本地存储更大的数据量。然而,这个缺点是它难以使用和设置。
你可以用几行代码在本地存储中完成的事情,在indexedDB中可能需要很多行代码和回调。因此,在使用它时要小心。如果你在应用程序中使用它,我们建议你使用流行的包装器而不是直接编写端点,这样会使事情更容易。
indexedDB如此庞大,可以说它值得拥有自己的一整章。我们无法在本章中涵盖每个方面,但我们会尽力传达所需的关键信息。
打开indexedDB数据库
indexedDB对象在window对象上可用。你需要实际打开一个数据库才能在indexedDB中存储数据,如下所示:
const open = window.indexedDB.open("myDB", 1);
你必须首先从indexedDB请求打开数据库。这里的第一个参数是数据库的名称。如果它不存在,它将被自动创建。
第二个参数是数据库的版本号。这意味着你可以为每个数据库模式分配一个版本号,这在以下示例中很有用。
考虑到你已发布了使用indexedDB的应用程序。现在,indexedDB由一个数据库模式组成,它规定了数据在数据库中应如何呈现,其数据类型等规则。然而,你很快就会意识到你需要更新你的数据库设计。现在,你可以通过indexedDB.open将生产代码与更高版本一起发布,这进一步使你能够在代码中知道你的旧数据库模式可能与新版本不兼容。
如果数据库已经存在,并且你用更高的版本号(比如我们案例中的 2)打开它,那么它将触发upgradeneeded事件,你可以在代码中处理该事件。
版本号仅支持整数。任何传递的浮点数都将四舍五入到最接近的较低整数。例如,将 2.3 作为版本号与传递 2 相同。
处理upgradeneeded事件
如前所述,我们现在可以处理upgradeneeded事件。由于我们刚刚第一次创建了数据库,下面的upgradeneeded事件将会被触发:
const open = window.indexedDB.open("types", 1);
// Let us create a schema for the database
open.onupgradeneeded = () => {
const dbHandler = open.result;
const storeHandler = dbHandler.createObjectStore("frontend");
};
好的,在上面的代码中,我们通过调用open.result得到了IDBDatabase对象处理器,我们称之为dbHandler。
然后,我们在indexedDB中创建了一个名为对象存储的东西。对象存储类似于indexedDB中的表,其中数据以键值对的形式存储。
向对象存储中添加数据
我们可以使用storeHandler通过以下代码将数据实际放入一个表中:
const open = window.indexedDB.open("types", 1);
open.onupgradeneeded = () => {
const dbHandler = open.result;
const storeHandler = dbHandler.createObjectStore("frontend");
storeHandler.add({
latestVersion: 5,
cool: "yes",
easy2use: "yes"
}, "HTML5");
};
让我们花点时间来理解刚刚发生了什么。通过调用storeHandler.add(),我们能够将数据添加到我们的types数据库(版本 1)中的frontend表中。第一个参数——即我们传递的对象——是值,它可以是indexedDB中的对象。值只能是localStorage中的字符串。第二个参数——即HTML5——是我们键的名称。
结果应该看起来像以下屏幕截图:

在前面的屏幕截图中,你应该能够看到indexedDB、types数据库,然后是一个名为frontend的表,该表将键存储为HTML5,将值存储为我们提供的对象。
从对象存储中读取数据
每当建立连接时,onsuccess事件就会被触发。只有onupgradeneeded中的读写操作才会工作,因为如果数据库的版本号没有增加,它就不会被触发。
实际上,我们建议从onupgradeneeded事件中更改数据库模式。当你处于onsuccess时,只执行 CRUD 操作(即创建、更新、检索和删除)。
我们可以在success事件内部使用以下代码执行我们的操作:
const open = window.indexedDB.open("types", 1); // same database as above
open.onsuccess = () => {
const dbHandler = open.result;
const transaction = dbHandler.transaction(['frontend'], 'readonly');
const storeHandler = transaction.objectStore('frontend');
const req = storeHandler.get("HTML5");
req.onsuccess = e => {
console.log(e.target.result);
}
};
程序的输出如下:
{latestVersion: 5, cool: "yes", easy2use: "yes"}
结果与之前存储的数据完全相同,但事务是什么呢?它们如下所示:
-
indexedDB使用事务来在数据库上执行读取和写入操作。 -
我们首先以
readonly模式打开数据库frontend的事务;另一种模式是readwrite模式,当你想向数据库写入时使用。 -
从那个事务中,我们得到了
storeHandler,这与我们在早期部分创建存储时拥有的存储处理器相同。 -
现在,我们使用
get方法获取与我们存储的键关联的值。 -
然后,我们等待
req调用success事件,在事件中我们在控制台日志中记录目标结果值,这实际上就是我们的存储对象。
从对象存储中删除数据
与写入和读取类似,我们也可以从对象存储中删除数据,如下所示:
const open = window.indexedDB.open("types", 1); // same database as above
open.onsuccess = () => {
const dbHandler = open.result;
const transaction = dbHandler.transaction(['frontend'], 'readwrite');
const storeHandler = transaction.objectStore('frontend');
storeHandler.delete("HTML5");
};
在这里我们唯一做的事情是使用delete方法。请注意,我们必须给事务提供readwrite访问权限才能删除记录。
建议您阅读 MDN 文档以深入了解如何在大型项目中使 indexedDB 正常工作;您可以在developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB找到它们。
摘要
太好了!这又是一个我们已掌握的概念。在本章中,您学习了如何有效地在客户端存储数据,以及浏览器如何自动将 cookies 发送到服务器。在接下来的两个章节中,我们将深入探讨 web workers 和共享内存,当它们结合在一起时可以创建一些非常强大的功能。让我们开始吧!
第十一章:网络和服务工作者
假设你正在构建一个酷炫的 Web 应用,比如说,将一个数分解为两个质数。现在,这涉及到大量的 CPU 密集型工作,这将阻塞主 UI 线程。主 UI 线程是最终用户直接观察和感知的交通车道。如果它看起来拥堵(卡顿)或阻塞,即使只有几秒钟,也会破坏用户体验。
这就是 web 工作者发挥作用的地方。可以将 web 工作者想象为道路上的那些侧车道,你可以将重型和缓慢(CPU 密集型)的卡车转移到那里,这样你就不至于阻塞主路上的用户闪亮的兰博基尼(主 UI 线程)。
另一方面,服务工作者也非常酷。服务工作者是你自己的可编程网络代理,它位于用户互联网连接和你的网站之间。在与服务工作者一起工作部分将有更多关于这个话题的介绍。
在本章中,我们将涵盖:
-
线程的介绍
-
网络工作者的介绍
-
专用工作者的介绍
-
设置专用工作者
-
共享工作者的介绍
-
设置共享工作者
-
设置内联 web 工作者
-
与主线程的通信
-
服务工作者的介绍
-
设置服务工作者
线程概念的介绍
简而言之,线程是一个简单且独立的运行代码片段。它是你的任务被执行的容器。在 web 工作者之前,JavaScript 只提供了一个线程;即开发者的主线程,用于做所有事情。
这在技术进步方面造成了一些问题。假设你正在运行一个平滑的 CSS3 动画,突然你需要因为某种原因在 JavaScript 端进行一些重量级的计算。如果你在主线程上这样做,这会使动画变得迟缓。然而,如果你将其卸载到在其自己的线程中运行的 web worker,它将不会对用户体验产生影响。
由于 web workers 在其自己的线程中运行,它们无法访问以下内容:
-
DOM:从 web 工作者和主 UI 脚本访问它不是线程安全的 -
parent对象:基本上,这提供了访问一些 DOM API 的权限,正如上述原因,访问这些 API 也是线程不安全的 -
window对象:浏览器对象模型(BOM);访问此对象也是线程不安全的 -
document对象:DOM对象;因此,线程不安全
Web 工作者无法访问上述所有内容,因为给予工作者访问它们不是线程安全的。让我们更深入地了解我所说的意思。
什么是线程安全?
当两个或更多线程访问一个公共数据源时,必须非常小心,因为数据损坏和线程安全条件(如死锁、先决条件、竞争条件等)的可能性很高。
JavaScript 从一开始就没有添加线程支持。随着网页工作者在 JavaScript 中引入了一种类似的线程环境,这将有助于理解与线程相关的一些条件。
什么是死锁?
死锁是一种情况,其中两个线程因为各种原因而相互等待,两个线程的原因相互依赖。以下图将解释什么是死锁:

显然,两个线程(持有枪支的人)都需要对方的资源才能继续进行,所以没有人能继续进行。
什么是竞争条件?
如果允许网页工作者访问 DOM,可能会发生竞争条件问题。竞争条件是指两个线程竞争或竞争读取/修改单个数据源的情况。这是危险的,因为当两个线程同时尝试修改数据时,无法确定哪个线程会先修改数据。考虑以下示例。
假设有两个线程正在内存中处理相同的变量:
// thread 1 - pseudo program code
if variable == 5:
asyncOperationWhichTakes200MS()
// just here thread 2 modifies variable to 10
res = variable * 5
// res is now 50 instead of 25
// unpredicatable behavior ahead
通过使用信号量可以避免竞争条件,这实际上就是锁定共享数据资源,直到一个线程完成并释放它。
有趣的事实:如果你在 Ubuntu 或任何支持apt-get作为包管理器的 Linux 发行版上使用sudo apt-get update,然后在另一个终端中尝试运行另一个apt-get update命令,你会得到这个错误:
E: 无法锁定管理目录 (/var/lib/dpkg/),另一个进程正在使用它?
Linux 锁定目录是为了避免两个命令相互覆盖结果的潜在竞争条件。
大多数语言只有一个线程与 UI 交互并更新 UI,其他线程只能向主线程发送消息以更新 UI。
网页工作者的简介
网页工作者本质上是一段不在你的主应用程序线程中运行的 JS 代码。而且,我说的线程字面意思是不同的线程。网页工作者真正使 JavaScript 能够在多线程模式下工作。这里可能会出现的一个问题是,异步操作和网页工作者之间有什么区别?
如果你仔细想想,它们基本上是同一件事。网页工作者会暂时从主线程中卸载一些负载,然后带着结果回来。然而,要理解的是,async函数在 UI 线程上运行,而网页工作者则不是。此外,网页工作者是长期存在的,它们存在于一个单独的线程中,而异步操作符,如我们在第四章,异步编程中讨论的,遵循事件循环。
在性能方面,网页工作者也比传统的异步操作快得多。这里有一个测试,它将随机生成的长度为10K和1M的数组作为异步操作和网页工作者进行排序:

注意,2,493 ops/sec 意味着 JS 能够在 1 秒内对2,493个长度为10K的数组进行排序!另一方面,异步 JS 在 1 秒内能够对大约 67 个长度为10K的数组进行排序,这仍然非常快,但比其竞争对手慢得多。
检查工作支持是否可用
尽管 Web 工作器已经存在很长时间,并且支持非常强大,但你仍然可能想要检查客户端浏览器中是否支持 Web 工作器(例如,Opera Mini 不支持它)。如果不支持,那么只需在主脚本中也加载 Web 工作器文件,让你的用户感受到这种热感。
Web 工作器作为 window 对象可用,因此你只需检查这一点就可以开始:
if(typeof window.Worker !== "function") {
// worker not available
} else {
// good to go
}
使用专用 Web 工作器
专用工作器是专门为单个主脚本服务的工作器。这意味着工作器不能与任何其他脚本交互,除了页面上或任何其他域的主脚本。
让我们通过设置一个专用工作器来尝试理解专用工作器。
设置专用工作器
使用构造函数中的文件名调用 new Worker() 是创建专用工作器所需做的全部事情:
// script.js loaded on index.html
const awesomeWorker = new Worker('myworker.js');
使用 new Worker 构造函数,我们创建了一个 Worker 实例。这将使浏览器下载 myworker.js 文件并为它启动一个新的操作系统线程。
我们可以在 myworker.js 文件中放置以下内容:
// myworker.js
console.log('Hello world!');
这将在控制台内打印 Hello world。
工作器可以自己创建子工作器,以下所有内容也适用于该子工作器。
使用专用工作器
专用工作器可以与其启动脚本通信,监听某些事件,这些事件在任一脚本发送/接收消息时触发。
这些事件可以通过某些事件处理器在两个脚本(工作器和主脚本)中处理。让我们学习如何实现这一点。
在主脚本上监听消息
我们可以通过 onmessage 事件监听一个工作器发送到主脚本的任何内容。这将是它的样子:
// script.js
const awesomeworker = new Worker('myworker.js');
awesomeworker.addEventListener('message', e => {
console.log(e.data); // data sent by worker
});
在这里,我们的脚本正在监听工作器发送的消息。每次工作器发送消息(我们将在下一节“从主脚本发送消息”中看到如何做),都会触发前一个事件,并且我们在控制台中打印数据。
在工作脚本上监听消息
工作者本身可以访问 self 对象,你可以附加类似的事件监听器,如之前讨论的那样。让我们看看结果:
// myworker.js
self.addEventListener('message', e => {
console.log(e.data); // data sent by main script
});
在这里,每当主脚本向这个特定的 Web 工作器发送消息时,消息事件监听器就会被触发。我们简单地使用 console.log(e.data) 打印主脚本发送的内容。
如果你愿意,可以省略 self 关键字。默认情况下,在工作者中,事件将附加到 self。
从主脚本发送消息
好的!一旦你正确设置了监听事件,你将想要向工作器发送一些任务以便它执行。这是如何实现这一点的:
// script.js
const awesomeworker = new Worker('myworker.js');
awesomeworker.addEventListener('message', e => {
console.log(e.data); // data sent by worker
});
const data = {task: "add", nums: [5, 10, 15, 20]};
// lets send this data
awesomeworker.postMessage(data);
好的。在这里,我们给工作器分配了一个加两个数字的任务。请注意,我们能够通过 postMessage 方法传递对象/数组,这实际上用于向生成的工作器发送/传递消息。
通过 postMessage 发送的对象是复制的而不是引用。这意味着,如果工作器脚本以任何方式修改此对象,它将不会反映在主 script 对象中。这对于消息传递的一致性很重要。
现在,我们可以在另一端(即工作器)接收此对象,并按以下方式处理它:
// myworker.js
self.addEventListener('message', e => {
if(e.data.task == "add") {
const res = e.data.nums.reduce((sum, num) => sum+num, 0);
// do something with res
}
});
在这里,在接收到消息后,我们检查主脚本是否希望工作器添加数字。如果是这种情况,我们使用内置的 reduce 方法将传递的数组中的所有数字减少到一个单一值。
从工作器脚本发送消息
与主脚本类似,postMessage 在工作器脚本中也用于与主脚本通信。让我们看看如何将前面的结果发送到主脚本:
// myworker.js
addEventListener('message', e => {
if(e.data.task == "add") {
const res = e.data.nums.reduce((sum, num) => sum+num, 0);
postMessage({task: "add", result: res}); // self.postMessage will also work
}
});
在这里,就像在先前的代码中一样,我们将数组值减少到 sum,然后实际上通过 postMessage 函数将我们执行的操作发送回主 UI 脚本。传递的对象可以通过调用其自身的监听方法来接收。
script.js 将如下所示:
// script.js
const awesomeworker = new Worker('myworker.js');
awesomeworker.addEventListener('message', e => {
if(e.data.task == "add") {
// task completed. do something with result
document.write(e.data.result);
}
});
const data = {task: "add", nums: [5, 10, 15, 20, 25, 30, 35, 40, 45, 50]};
awesomeworker.postMessage(data);
在这里,你可以看到我们将 task 以对象的形式发送到工作器,工作器很好地执行了计算并将结果发送回主脚本,这进一步由附加到 awesomeworker 的消息事件监听器处理,该监听器简单地将结果写入文档。
工作器中的错误处理
有可能你的工作器可能会因为主脚本发送的格式不正确的数据而抛出错误。在这种情况下,主脚本中的工作器 onerror 方法被调用:
// script.js
const awesomeworker = new Worker('myworker.js');
awesomeworker.postMessage({task: "divide", num1: 5, num2: 0})
awesomeworker.addEventListener('error', e => {
console.log(e); // information of ErrorEvent
});
在这里,我们附加了一个错误事件监听器,目前我们只是将其记录到控制台。你可能希望将其发送到服务器以在生产应用程序中进行进一步分析。
工作器如下所示:
// myworker.js
self.addEventListener('message', e => {
if(e.data.num2 == 0) {
throw "Cannot divide by 0";
} else {
postMessage({task: "divide", result: e.data.num1/e.data.num2 });
}
});
在前一个例子中,工作器抛出一个错误,这在主脚本中表现为一个 ErrorEvent 对象。从那里,你可以处理这个错误。
从 Web Worker 抛出的错误不会永久停止其工作。除非终止,否则它仍然可用。
终止工作器
当你认为工作器不再需要时,你可以终止工作器。你可以从工作器本身或父脚本中终止工作器。让我们在下一节中看看如何操作。
从工作器脚本终止
有时候,当工作器在进行某种可能持续不同时间段的异步任务时,可能需要在工作器内部终止工作器。为此,工作器内部提供了一个名为 close() 的方法:
// myworker.js
addEventListener('message', e => {
if(e.data.message == "doAjaxAndDie") {
fetch(...).then(data => {
postMessage(data);
close(); // or self.close();
});
}
});
从主脚本终止
同样,如果你愿意,你也可以从主脚本中终止一个工作线程。终止后,你的工作线程实例将无法再用于发送消息。它还会终止你工作线程中正在执行的所有进程:
// script.js
const awesomeworker = new Worker('myworker.js');
awesomeworker.addEventListener('message', e => {
if(e.data.message == "killme") {
awesomeworker.terminate(); // bye bye
console.log("Worker terminated");
}
});
为此,相应的 myworker.js 文件将是:
// myworker.js
// .. some work
postMessage({message: "killme"});
在工作线程内部终止涉及调用 close(),而从父脚本中终止则涉及调用 terminate() 方法。
通过 postMessage 传输(而非复制)数据
实际上,你可以使用 postMessage 函数传输大量数据。这意味着什么,它与到目前为止我们使用 postMessage 所做的事情有什么不同?
好吧,postMessage 的实际语法是:postMessage(aMessage, transferList)。
这意味着,你传递给 transferList 的任何内容在发送它的那个工作线程中似乎都丢失了。实际上,你赋予了其他脚本拥有那些数据的权限。你将那些数据的所有权转让给了那个其他脚本。记住,这与通常发生的情况不同(即,你仍然可以访问发送到 Web 工作线程/主脚本的同一脚本中的对象),因为在这种情况下,数据并没有被复制。其所有权已被转让。
这使得在 Web 工作线程之间传输大量数据变得非常快。可传输的对象包括 ArrayBuffer 等内容。以下是如何处理它的一个示例:
const ab = new ArrayBuffer(100);
// add data to this arraybuffer
console.log(ab.byteLength); // 100
worker.postMessage(ab, [ab]);
console.log(ab.byteLength); // 0 - ownership lost
你可以看到,我们的 ArrayBuffer 的大小从 100 变为 0。这是因为你不再可以访问 ArrayBuffer 内存,因为你已经将其传输给了其他脚本。
与共享工作线程一起工作
如前所述,共享工作线程是多个脚本可以访问的工作线程,前提是它们遵循相同的源策略(更多内容将在名为 Same origin policy 的后续部分中介绍)。
与专用工作线程相比,API 略有不同,因为这些工作线程可以被任何脚本访问,因此需要通过 SharedWorker 对象中嵌入的不同端口来管理所有连接。
设置共享工作线程
可以通过调用 SharedWorker 构造函数并传入文件名作为参数来创建一个共享工作线程:
const awesomeworker = new SharedWorker('myworker.js');
在这里,我们使用了 SharedWorker 构造函数来创建一个 sharedworker 对象的实例。与专用工作线程不同,你将无法在浏览器中看到对 myworker.js 文件进行的 HTTP 网络请求。这很重要,因为浏览器只需要维护多个脚本调用此 Web 工作线程时该文件的一个实例:
// myworker.js
console.log('Hello world!');
与专用工作线程不同,共享工作线程不会在主网站的控制台中输出 Hello World!。这是因为共享工作线程不会只加载到那个页面。共享工作线程为每个访问它的文件加载一次。因此,它有自己的控制台。
在 Google Chrome 中,要调试共享工作线程,请在打开负责启动共享工作线程的页面后,打开 chrome://inspect/#workers。在那里,你可以选择调试它:(“检查”链接)

完成这些后,让我们继续设置共享工作者监听器的指南。
与共享工作者一起工作
共享工作者可以与其启动脚本通信,监听某些事件,这些事件在任一脚本发送/接收消息时触发。然而,与专用工作者不同,在这里我们必须在每个连接上显式注册 onmessage 事件。
在主脚本上监听消息
在这里,与专用工作者不同,我们必须在共享工作者对象上可用的端口属性上添加 onmessage 事件:
// script.js
const awesomeworker = new SharedWorker('myworker.js');
awesomeworker.port.start(); // important
awesomeworker.port.addEventListener('message', e => { // notice the .port
console.log('Shared worker says .. ', e.data);
});
当我们的 SharedWorker 对这个特定的脚本做出回应时,会触发此事件。
注意到 awesomeworker.port.start(); 这一行,它指示共享工作者与这个脚本进行交互。当使用 addEventListener 时,从两个文件(工作者和脚本)中都必须使用 port.start() 行来启动双向通信。
在工作者脚本上监听消息
类似地,self 在这里被定义;然而,window 并没有被定义。所以,你可以使用 self.addEventListener 或 addEventListener(或者直接 onconnect = function()):
// myworker.js
addEventListener('connect', e => {
console.log(e.ports);
const port = e.ports[0];
port.start();
port.addEventListener('message', event => {
console.log('Some calling script says.. ', event.data);
});
});
在这里,事件包含了关于我们的脚本连接到的端口的详细信息。我们获取连接端口并与之建立连接。
与我们的主脚本类似,我们必须在这里指定 port.start() 以确保两个文件之间通信成功。
从父脚本发送消息
注意,任何同源的脚本(基本上,同源意味着你从属于同一域的多个 URL 访问它;例如,www.google.co.in/ 访问 google.com/script.js 符合同源,但 facebook.com 访问 google.com/script.js 则不符合。关于同源的内容将在稍后的名为 同源策略 的部分中详细介绍)可以以与我们之前 script.js 访问它相同的方式访问共享工作者,工作者对于每个访问它的文件都处于相同的状态。
这就是从脚本向工作者发送消息的方式:
// script.js
const awesomeworker = new SharedWorker('myworker.js');
awesomeworker.port.start();
awesomeworker.port.postMessage("Hello from the other side!");
这与专用工作者类似,但我们必须在这里明确提到端口对象。
myworker.js 文件看起来像:
// myworker.js
addEventListener('connect', e => {
console.log(e.ports);
const port = e.ports[0];
port.start();
port.addEventListener('message', event => {
console.log('Some calling script says.. ', event.data); // logs
"Hello from the other side!"
});
});
如前所述,如果我们使用 addEventListener 添加回调,则需要执行 port.start() 来建立工作者和主脚本之间的通信。然后我们为这个特定的端口分配一个 onmessage 事件监听器。
最后,我们只需将调用脚本要告诉工作者的内容记录到控制台。
从工作者脚本发送消息
如果您已经认识到我们在专用工作者和共享工作者中调用方法的方式之间的区别,做得好!我们不是在self上调用方法,而是在端口对象上调用所有专用 Web 工作者方法,这是工作者区分许多(可能)与之通信的脚本的方式:
// myworker.js
addEventListener('connect', e => {
console.log(e.ports);
const port = e.ports[0];
port.start();
port.addEventListener('message', event => {
console.log('Some calling script says.. ', event.data);
// some work
port.postMessage("Hello ;)");
});
});
它与上面的代码完全一样,但这次我们的共享工作者回复了发送消息的人,并向它说“你好”。
如果您有两个实例的 HTML 页面正在运行,加载script.js(即新的SharedWorker),它们都与共享工作者有独立的端口连接。
错误处理
在这里,错误处理有点棘手。由于错误可能由任何端口(任何父文件)在任何脚本中发生,您必须手动将错误发送到每个端口。但为此,您还必须存储端口(当它们连接时)。以下是它应该看起来像什么:
// myworker.js
const ports = [];
addEventListener('connect', e => {
const port = e.ports[0];
ports.push(port); // assemble all connections
port.start();
// .. other info
});
addEventListener('error', e => {
console.log(e); // Info about error
ports.forEach(port => port.postMessage({type: 'error', res: e}));
});
在这里,您可以看到我们正在手动将错误信息发送到每个父文件。之后,您可以在父文件本身中处理错误。
作为旁注,在您的共享工作者内部有一个数组访问所有连接是一个好习惯。在某些情况下可能会有所帮助,例如,当您想要不同的页面相互通信时!
断开共享工作者连接
您可以从共享工作者那里断开父进程的连接,或者完全关闭共享工作者。然而,后者只能由工作者的 JS 完成。以下章节将讨论您如何断开单个父进程与工作者的连接。
断开单个父-工作者连接
当调用此代码时,父进程与工作者之间的连接被关闭,您将无法再使用该工作者对象来发送消息:
// script.js
const awesomeworker = new SharedWorker('myworker.js');
awesomeworker.port.start();
// some processing and some work
awesomeworker.port.close();
awesomeworker.port.postMessage("Are you still alive?"); // does not work | no effect
尽管工作者仍然存在,但它失去了调用.port.close()的脚本的连接。
连接关闭后,工作者将无法从主脚本发送/接收消息。然而,主脚本始终可以通过使用new SharedWorker构造函数创建一个新的实例来再次调用共享 Web 工作者。
完全断开共享工作者
您可以通过在其 JS 内部调用self.close()来永久终止共享工作者。您也可以从父脚本发送消息来杀死工作者:
// script.js
const awesomeworker = new SharedWorker('myworker.js');
awesomeworker.port.start();
awesomeworker.port.postMessage({type: 'cmd', action: 'die'});
我们只是从我们的主脚本向我们的共享工作者发送了一条消息,并传递了共享工作者应该被永久终止的消息。
工作者文件看起来像:
// myworker.js
addEventListener('connect', e => {
const port = e.ports[0];
port.start();
port.addEventListener('message', event => {
if(event.data.type == 'cmd' && event.data.action == 'die') {
self.close(); // terminates worker
}
});
});
在验证主脚本确实想要终止所有实例的工作者之后,工作者在其自身上调用close方法,从而终止它。
内联 Web 工作者的介绍
从单个文件创建 Web Worker 而不实际为你的 Web Worker 创建一个单独的 JS 文件是可能的。然而,我仍然建议你为你的 Web Worker 创建一个不同的文件,为了代码的清晰性和模块化。在编程中,模块化总是首选。
我们可以利用blob URL 来实际上将内存中的数据指向一个 URL,然后加载blob URL 而不是实际的文件 URL。由于这个 URL 仅在用户的计算机上动态生成,因此你不需要为特定的 Web Worker 创建一个单独的文件。以下是我们将如何做到这一点:
const blob = new Blob(['(',
function() {
// web worker code here
}.toString(),
')()'], { type: 'application/javascript' }));
const url = URL.createObjectURL(blob); // gives a url of kind blob:http://....
const awesomeworker = new Worker(url);
有时快速启动一个小型 Web Worker 很容易。然而,这种方法不适用于共享 Web Worker。你需要为它们创建一个单独的文件。这是因为SharedWorker依赖于所有实例都从一个单独的文件加载的事实。然而,创建 blob 数据的 URL 每次都会创建不同的 URL。所以两个页面,即使它们有相同的 JS 代码,也会有不同的 URL,因此会有不同的SharedWorker实例。
同源策略
之前我说了几次,共享工作者将仅对与它们共享相同源的父母文件可用。这意味着什么?
让我们考虑 URL www.packtpub.com/all.
下面是一个表格,展示了哪些 URL 将与该域名具有相同的源,哪些不会:
| 新 URL | 同源 | 原因 |
|---|---|---|
www.packtpub.com/support |
是 | - |
http://www.packtpub.com/account/abc/xyz |
是 | - |
www.packtpub.com/all |
否 | 不同的协议 |
username:password@www.packtpub.com/all |
是 | - |
http://www.packtpub.com:8000/somepage |
否 | 不同的端口号 |
http://packtpub.com/somepage |
否 | 不同的主机 |
http://dev.packtpub.com/somepage |
否 | 不同的主机 |
到目前为止,我相信你能够猜出什么使得某些内容具有相同的源,什么不具有。是的,你是对的!相同的主机、端口和协议使得两个 URL 处于相同的源。对于列表中的 URL,其答案为“是”的,只有那些 URL 才能访问由www.packtpub.com/all启动的共享工作者。
与服务工作者一起工作
服务工作者!它们最终通过在 JavaScript 中创建网络代理,为开发者提供了对网络层的精确控制。使用服务工作者,你可以拦截和修改网络资源请求,处理缓存方式,并在用户网络断开时做出适当的响应。
让我们一步一步地展示如何设置服务工作者及其相关方法。
服务工作者的先决条件
服务工作者的先决条件是:
-
由于 service worker 功能强大(几乎像网络代理),为了避免某些攻击,它们仅适用于运行在 HTTPS 上的域名。然而,它们在
localhost上也能正常运行。 -
它们大量依赖于我们已经在第四章,异步编程中深入讨论过的 promises。
检查浏览器支持
检查客户端的浏览器是否支持 service workers 很容易:
if('serviceWorker' in navigator) {
// service worker available
// lets code
}
这里,我将假设用户的浏览器中已经可用了一个 service worker,以避免每次都进行不必要的代码缩进。
service worker 的生命周期
下图展示了 service worker 的生命周期:

从图中可以看出,首先需要安装 service worker。然后它触发某些事件,我们可以在代码中捕获这些事件来处理不同的事情。现在让我们详细讨论如何实现所有这些步骤。
注册 service worker
首先,你的主脚本必须将 service worker 注册到浏览器中。以下是操作方法:
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log(reg))
.catch(err => console.log(err));
reg对象与你的 service worker 注册信息相关联。
service worker 注册代码可以安全地多次运行。如果已经注册,浏览器将不会重新注册它。
你可以在这里调试 service workers:Chrome-->检查-->Service Workers。
注意,service worker 脚本的作用域是它所在的作用域。例如,前面的文件位于根目录(localhost/sw.js),因此它可以拦截所有localhost/*请求。如果它位于,比如说,localhost/directory/sw.js,那么它只能拦截localhost/directory/*请求。
安装 service workers
一旦你的 worker 注册成功,你的 service worker 文件内部会触发一个安装事件。在这里,我们将设置资源的缓存。接下来会有很多新术语出现;请耐心等待:
// sw.js
self.addEventListener('install', e => {
e.waitUntil(async function() {
const cache = await caches.open('cacheArea');
await cache.addAll(['/', '/styles/main.css', '/styles/about.css']);
}());
});
好的!发生了什么?
-
我们在我们的
sw.js文件中添加了一个安装事件监听器,当我们的 service worker 被注册时,它会触发。 -
e.waitUntil接受一个 promise(我们确实提供了一个 promise;记得async function返回一个 promise,我们也调用了那个函数)。 -
然后在浏览器中有一个名为CacheStorage的东西。我们通过命名缓存并使用
addAll方法添加我们想要缓存的资源来将资源添加到缓存中。 -
我们传递一个包含所有想要添加到缓存存储的文件/路径的数组。
-
安装事件已经结束。
在安装事件本身中设置缓存是完全可选的。我们可以在安装事件之后,稍后进行设置。例如,在获取新资源时,可以边走边设置缓存。
使用 service workers 进行获取
一切准备就绪后,你应该能够通过fetch事件以以下方式拦截请求:
self.addEventListener('fetch', e => {
e.respondWith(async function() {
const response = await caches.match(e.request);
if(response) {
return response;
}
return fetch(e.request);
}());
});
等一下!让我们看看这里发生了什么:
-
这个监听器将在浏览器在其注册范围内进行 fetch 请求时被触发(我们之前讨论过这一点)。
-
respondWith也接受一个承诺,这是我们给它提供的。 -
然后,我们检查请求的文件是否已经存在于我们的缓存中(使用
catches.match(e.request))。如果是,我们直接返回缓存的文件。如果不是,我们使用 fetch API(我们之前章节讨论过)来获取响应,然后继续执行。
你也可以在控制台中打印出e.request并稍作修改以修改请求。这给了网站开发者对其自己网站巨大的控制权,因此不应由其他人处理。这也是为什么 service workers 仅在 HTTPS 协议上可用,以避免中间人攻击。
Service workers 是一项相对较新的技术,它们的标准正在经历许多工作。查看bit.ly/serviceworkers获取任何更新。
摘要
因此,最终,我们有机会查看 web workers 和 service workers 以及 JavaScript 提供的多线程环境中的精彩部分!虽然 service workers 是渐进式 Web 应用的未来,但 web workers 将支持它们处理任何高负载任务。
正确地结合这些技术,似乎一切皆有可能!在下一章中,我们将探讨 JavaScript 首次引入的非常有趣的概念:共享内存和原子操作。
第十二章:共享内存和原子操作
让我们来看看低级内存内容!这一章将会有些高级,但很有趣。我会尽量让它尽可能简单易懂。
在这些准备工作完成之后,让我们来看看 JavaScript 中最终能实现的功能!低级内存访问、多线程、原子操作、共享内存以及所有那些酷炫强大的功能。但正如有人所说,权力越大,责任越大。让我们开始吧!
本章我们将涵盖以下内容:
-
计算机内存管理基础
-
什么是共享内存?
-
使用
SharedArrayBuffer -
介绍并行编程
-
多线程访问同一内存位置时可能出现的问题
-
什么是原子操作?
-
执行原子操作
-
JavaScript 中的原子 API
-
正确使用并行编程
内存基础
我们必须稍微了解内存是如何工作的,才能理解SharedArrayBuffer在 JavaScript 中的重要性。
将内存想象成一个大衣柜里的一堆抽屉,你可以打开一个抽屉并放些东西进去。每个抽屉都有自己的最大容量。
每个抽屉还贴有一个标签,上面有一个唯一的数字,这有助于你记录哪个抽屉有数据,哪个没有。当需要访问这些数据时,你会得到一个编号的抽屉,然后可以相应地取出数据。
现在,让我们先了解内存存储的基础。假设我想在内存中存储一个数字,比如 100。首先,我们需要将这个数字转换为二进制,因为这是计算机理解的方式,对它们来说存储起来也很容易:

上述图示是数字 100 的二进制表示,也是它在内存中的存储方式。
简单!以类似的方式,我们可以存储更复杂的数据,例如字母,通过将它们转换为数字(称为 ASCII 值),然后直接存储这些数字,而不是字母。同样,一个图像(假设是黑白图像)可以通过存储每个像素的亮度级别浮点数来存储。
内存管理的抽象
内存管理意味着你实际上是在直接与硬件交互,从你的代码中自己存储/更新/释放内存块。大多数高级编程语言都从开发者那里拿走了内存管理。
这是因为管理内存很困难。真的!在复杂的程序中,人类难免会犯错误,造成大量问题,不仅限于内存泄漏(这是人们最容易犯的错误)。
当然,这种抽象化会带来性能上的代价。但与安全性、可读性和便利性的优势相比,这是一个公平的交易。
JavaScript 也会自动管理内存。JavaScript 引擎负责在创建新变量时注册内存,在不再需要时释放内存,等等。想象一下自己管理一个闭包程序的内存!即使程序稍微复杂一些,也很容易在函数执行结束后弄不清楚哪些变量需要保留在内存中,哪些可以丢弃。JavaScript 来拯救!
垃圾回收
JavaScript 是一种垃圾回收语言。这意味着 JavaScript 引擎会偶尔触发一个叫做垃圾回收器的东西,它会查找程序中内存中的未使用和不可访问的引用,并将它们清除,使内存可用于存储其他数据。
垃圾回收器让生活变得容易多了,但在性能关键的应用程序中会稍微增加一些开销。比如说你正在编写一个 3D 游戏,你希望在不太好的硬件上实现非常高的每秒帧数(FPS)。
你可能会发现,与垃圾回收的语言如 Java 相比,用 C/C++编写的游戏效果非常好。这是因为当你玩游戏时,垃圾回收器可能会在不必要的时候启动,这浪费了一些本可以由渲染线程使用的资源。
手动管理内存
在内存管理方面,像 C/C++这样的语言是独立的。在这些语言中,你必须自己分配内存和释放内存。这也是为什么 C/C++如此快的原因——因为它们非常接近硬件,几乎没有抽象。但这也使得编写复杂的应用程序变得痛苦,因为事情可能会迅速失控。
有一种叫做WebAssembly的东西,它是网络中 JavaScript 替代方案的编译形式。C/C++代码可以编译成 WebAssembly,在某些情况下比原生 JavaScript 快 100-200%!
WebAssembly 由于其速度和多种语言支持,将成为网络的未来。然而,它又要求你自行管理内存,因为最终,你需要用 C/C++来编写代码。
手动管理内存很困难。在大型程序中,很难知道何时清除不再需要的内存部分。过早清除,应用程序会崩溃。过晚清除,你会耗尽内存。这就是为什么在很多情况下抽象是好的。
什么是共享内存?
假设我们正在开发一个实时性能关键的应用程序,这就是我们为什么如此关注这个有趣话题的原因。假设我在后台运行了两个 Web Workers,并且我想从一个 Worker 共享一些数据到另一个 Worker。Web Workers 在单独的操作系统级别线程上独立运行,彼此之间没有任何了解。
一种方法是通过postMessage在 Web Workers 之间传输消息,正如我们在上一章中看到的。然而,这很慢。
另一种方式是将对象完全传输到另一个工作者;然而,如果你记得,那样会使传输的对象对发送它的工作者不可访问。
这个问题的解决方案是 SharedArrayBuffer。
共享 ArrayBuffer 介绍
SharedArrayBuffer 是创建一个所有工作者可以同时访问的内存存储的方式。现在,如果你一直在仔细阅读,你将已经理解到一旦允许存在类似共享内存存储的东西,可能会发生一些淘气的事情。
如果你记得,工作者没有直接访问 DOM 的唯一原因是因为 DOM API 不是线程安全的,可能会导致死锁和竞态条件等问题。如果你能判断出这里可能也会发生同样的事情,你是对的!但这将是后续章节(竞态条件)的主题。
让我们回到 SharedArrayBuffer。那么它与 ArrayBuffer 有什么不同?
好吧,SharedArrayBuffer 几乎就是许多脚本可用的 ArrayBuffer。你只需要在一个地方创建 SharedArrayBuffer,然后使用 postMessage 将其发送到其他工作者(而不是传输!)
你不应该传输它,因为那样你就会失去对 SharedArrayBuffer 的所有权。当你发送它时,只有缓冲区的引用会自动传递并可供所有其他脚本使用:
const sab = new SharedArrayBuffer(1024);
worker.postMessage(sab); // DO NOT TRANSFER: worker.postMessage(sab, [sab]);
一旦你这样做,所有的工作者都将能够访问、读取和写入 SharedArrayBuffer。请看以下表示:

这是一种粗略的表示,你可以想象 SharedArrayBuffer 如何与底下的内存连接。现在,让我们假设每个线程都在不同的 CPU 上生成。
理解并行编程
并行编程,正如其名所示,就是以这种方式运行的一个程序,该程序的实例会同时多次运行。
另一方面,并发编程与并行编程非常相似,但不同之处在于任务永远不会同时发生。
并行与并发编程的区别
为了理解并行编程和并发编程之间的区别,让我们考虑一个例子。
假设有一个比赛,比赛内容是吃放在两个盘子上的糖果。盘子之间相距五米。现在,假设你是唯一的参赛者,约束条件是你必须保持两个盘子上的糖果数量差异小于两个。
你在这里会做什么?你必须从第一个盘子开始吃,跑到五米外的第二个盘子,从第二个盘子吃,然后再跑五米回到第一个盘子,以此类推。
现在,假设你有一个朋友。现在,你们两个人都可以选择一个盘子并开始吃自己的糖果。
尝试将其与并发编程和并行编程分别联系起来。在第一个例子中,你是 CPU 的核心,在这里到处运行,一次又一次,在两个线程(盘子)之间。你跑得很快,但由于你的物理限制,无论你多么努力,你都无法同时从两个盘子中取食。同样,在并发编程中,CPU 执行这两个任务,但它不是同时执行,而是分块执行。
在下一个例子中,对于并行编程,你的朋友就像另一个 CPU,完全处理另一个线程。这样,你们每个人只需要执行自己的线程。这就是并行性。
如果这说得通,那么让我们进入并行编程,这是 Web Workers 给我们带来的东西,以及如何利用并行编程中的共享内存来真正加快速度而不是减慢速度(因为当你做错的时候,这种情况经常发生)。
破除迷思——并行计算总是更快
似乎很直观地说,并行计算应该总是比单线程计算更快。就像 spawning two threads 应该,直观上,几乎可以减半计算时间。这不仅从数字上是错误的,而且如果做得不正确,并行计算会产生垃圾结果。
要理解这一点,可以考虑一个被分配任务将一堆积木从一个地方转移到另一个地方的人:

他以某种速度完成这项工作。带上另一个人可能听起来像是加倍工作的速度,但这两个人实际上可能在路上撞到一起,反而使事情变得更慢而不是更快。
当并行性实现不正确时,这种情况实际上经常发生,我们现在就会看到。
让我们数到十亿!
为了验证如果设置不当,并行计算实际上就是垃圾,让我们用 JavaScript 的单线程和多线程环境数到十亿。
让我们首先尝试单线程计数:
// Main thread
const sharedMem = new SharedArrayBuffer(4);
function countSingleThread(limit) {
const arr = new Uint32Array(sharedMem);
for(let i=0; i<limit; i++) {
arr[0] = arr[0] + 1;
}
}
const now = performance.now();
countSingleThread(1000000000);
console.log(`Time Taken: ${performance.now() - now}`);
在我的 MacBook Air 上,这个程序运行需要 ∼2606 毫秒。这大约是 2.6 秒:

现在我们尝试将代码分配给两个工人,看看会发生什么:
// Main thread
const sharedMem = new SharedArrayBuffer(4);
const workers = [new Worker('worker.js'), new Worker('worker.js')];
let oneWorkerDone = false;
const now = performance.now();
for(let i=0;i<2;i++) {
workers[i].postMessage({message: 'sab', memory: sharedMem});
workers[i].addEventListener('message', data => {
if(!oneWorkerDone) {
oneWorkerDone = true;
} else {
console.log("Both workers done. The memory is: ", new
Uint32Array(sharedMem))
console.log(`Time taken: ${performance.now()-now}`)
}
});
workers[i].postMessage({cmd: 'start', iterations: 500000000});
}
好吧!那么这里到底发生了什么?以下是一个解释:
-
我们创建了一个
SharedArrayBuffer,以便创建一个可以被两个生成的 Web 工人同时访问的内存存储区域。 -
SharedArrayBuffer的大小是4,因为,为了将数字添加到整数数组中,我们将它转换为Uint32Array,其大小是4的倍数。 -
我们从同一个文件启动了两个 Web 工人。
-
我们给了他们访问
SharedArrayBuffer的权限。 -
我们在主脚本中监听,当两个工人都说他们完成了。
-
我们向每个工人发送了 5 亿次迭代,从而将这些工作分配给这两个线程。
现在我们来看看 worker.js 的样子:
// worker.js
let sharedMem;
addEventListener('message', ({data}) => {
//console.log(data);
if(data.message == 'sab') {
sharedMem = data.memory;
console.log('Memory ready');
}
if(data.cmd == 'start') {
console.log('Iterations ready');
startCounting(data.iterations);
}
});
function startCounting(limit) {
const arr = new Uint32Array(sharedMem);
for(let i=0;i<limit;i++) {
arr[0] += 1;
}
postMessage('done')
}
在 worker.js 中,我们执行以下操作:
-
监听来自主脚本的消息。
-
检查消息是否指示存储
SharedArrayBuffer;如果是,我们就存储它。 -
如果消息指示开始迭代,我们首先将其转换为
Uint32Array。 -
在迭代之后,我们向主脚本发送一个友好的 'done' 消息,通知它我们已经完成。
预期结果:程序的速度将提高约 2 倍,因为每个线程都必须完成一半的工作。我们还期望最终值为一亿。
现实情况:测试 #1 如下所示。
首次运行前面的代码会产生以下结果:

测试 #2 如下所示。
第二次运行前面的代码会产生以下结果:

测试 #3 如下所示。
第三次运行前面的代码会产生以下结果:

我得到了垃圾值!每次我运行程序,我都会得到不同的值,接近 5 亿。为什么会这样?
竞态条件
在前两个截图中的垃圾值代表了一个经典的竞态条件示例。你还记得我在 SharedArrayBuffer 简介 部分中展示的第一个图像吗?记得链接到 CPU 1 和 CPU 2 的 SharedArrayBuffer,它分别链接到 Worker 1 和 Worker 2 吗?好吧,结果证明这并不完全正确。
这是你在机器上的实际设置:

问题就出现在这里。竞态条件意味着 CPU 1 从共享内存中获取数据并发送给 Worker 1。与此同时,CPU 2 也获取了它,但它不知道 CPU 1 已经在处理它。所以,当 Worker 1 将值从 0 更改为 1 时,CPU 2,即 Worker 2,仍然在获取值 0。
Worker 1 然后将共享内存更新为 1 的值,然后 Worker 2 将其自己的副本更新为 1(因为它不知道 CPU 1 已经将其更新为 1),然后再次将其写入共享内存。
这里,我们成功地浪费了两个计算,而只需要一个。这是一个如何不进行并行处理的快速示例:

我们该如何解决这个问题?原子操作(我们将在本章后面的部分,即 使用原子操作修复十亿计数 部分中回到这个问题)。
原子操作是什么?
原子操作是什么?原子操作,或者更准确地说,一个原子操作,是一个一次性发生而不是分步骤发生的操作。它就像一个原子——不可分割的(尽管在技术上原子是可以分割的,但让我们不要破坏这个类比)。
原子操作是一个对所有其他工作线程可见的单个操作。它立即发生。它就像一条机器代码的执行,要么尚未完成,要么已经完成。中间没有其他状态。
简而言之,某物是原子的意味着一次只能对其执行一个操作。例如,更新一个变量可以使其成为原子的。这可以用来避免竞争条件。
锁和互斥锁的信息
当我说更新一个变量可以使其成为原子的,我的意思是,在某个线程访问该内存的期间,不应该允许其他线程访问它。这只有在你在访问的变量上引入锁或互斥锁(互斥排他)时才可能。这样,其他线程就知道该变量正在使用中,它应该等待锁被释放。
这样就可以进行原子操作。但这种安全感也伴随着代价。原子锁定不是一个可以忽略不计时间的操作,所以它肯定涉及一些开销。
做一亿次,你可能就完蛋了(我们很快将在 使用原子操作修复一亿次计数 中看到这一点)。
JavaScript 中的 Atomics
JavaScript 有一个 Atomics 对象,它为我们提供了之前讨论过的确切功能。然而,它在某种程度上相当有限,因为你只能进行加法、减法、按位与、按位或、按位异或和存储。
其他功能可以建立在这些功能之上,未来将会有库提供这些功能。现在,让我们了解一下原生可用的方法。
使用 Atomics.load(typedArray, index) 方法
Atomics.load 方法返回一个特定索引值内类型数组的值。以下是使用方法:
const sab = new SharedArrayBuffer(1);
const arr = new Uint8Array(sab);
arr[0] = 5;
console.log(Atomics.load(arr, 0));
上述代码只是线程安全地访问 arr[0] 的方式。
这会输出:
5
使用 Atomics.add(typedArray, index, value) 方法
Atomics.add 是向类型数组中的特定索引添加特定值的方法。它很容易理解和编写:
const sab = new SharedArrayBuffer(1);
const arr = new Uint8Array(sab);
arr[0] = 5;
console.log(Atomics.add(arr, 0, 10));
console.log(Atomics.load(arr, 0));
Atomics.add 又是执行 arr[0] += 10 的线程安全方式。
这会输出:
5
15
Atomics.add 返回该索引处的旧值。在命令执行后,该索引处的值将被更新。
使用 Atomics.sub(typedArray, index, value) 方法
Atomics.sub 是从类型数组中特定索引减去特定值的方法。它也很容易使用:
const sab = new SharedArrayBuffer(1);
const arr = new Uint8Array(sab);
arr[0] = 5;
console.log(Atomics.sub(arr, 0, 2));
console.log(Atomics.load(arr, 0));
Atomics.sub 又是执行 arr[0] -= 2 的线程安全方式。
这会输出:
5
3
Atomics.sub 返回该索引处的旧值。在命令执行后,该索引处的值将被更新。
使用 Atomics.and(typedArray, index, value) 方法
Atomics.and 在类型数组中特定索引的值和您提供的值之间执行按位与运算:
const sab = new SharedArrayBuffer(1);
const arr = new Uint8Array(sab);
arr[0] = 5; // 5 is 0101 in binary.
Atomics.and(arr, 0, 12); // 12 is 1100 in binary
console.log(Atomics.load(arr, 0));
Atomics.and 在这里执行 arr[0] 和数字 12 之间的按位与运算。
这会输出:
4
按位与运算的工作原理
假设我们想要对 5 和 12 进行按位与运算:
-
将两个数字转换为二进制;5 是 0101,12 是 1100。
-
按位与运算从第一个位开始逐位执行
AND操作:5 & 12
0 && 1 = 0
1 && 1 = 1
0 && 0 = 0
1 && 0 = 0
-
因此,5 && 12 = 0100,这是 4。
使用 Atomics.or(typedArray, index, value) 方法
与按位与类似,Atomics.or 方法执行按位或操作:
const sab = new SharedArrayBuffer(1);
const arr = new Uint8Array(sab);
arr[0] = 5; // 5 is 0101 in binary.
Atomics.or(arr, 0, 12); // 12 is 1100 in binary
console.log(Atomics.load(arr, 0));
在这里,Atomics.or 方法在 arr[0] 和数字 12 之间执行了按位或操作。
输出如下:
13
按位或的工作原理
假设我们想要对 5 和 12 进行按位或操作:
-
将两个数字都转换为二进制;5 是 0101,12 是 1100
-
按位或操作是逐位进行或操作的,从第一个位开始:
5 | 12
0 || 1 = 1
1 || 1 = 1
0 || 0 = 0
1 || 0 = 1
-
因此,5 | 12 = 1101,即 13。
使用 Atomics.xor(typedArray, index, value) 方法
再次,Atomics.xor 方法执行按位异或操作,这是一种排他或操作(即,它是一个或门,当两个输入都是 1 时输出 0)
const sab = new SharedArrayBuffer(1);
const arr = new Uint8Array(sab);
arr[0] = 5; // 5 is 0101 in binary.
Atomics.xor(arr, 0, 12); // 10 is 1100 in binary
console.log(Atomics.load(arr, 0));
Atomics.xor 这里在 arr[0] 和数字 10 之间执行了异或操作。
这会输出:
9
按位异或的工作原理
假设我们想要对 5 和 12 进行按位异或操作:
-
将两个数字都转换为二进制;5 是 0101,12 是 1100。
-
按位异或操作是逐位进行的,从第一个位开始:
5 ^ 12
0 ^ 1 = 1
1 ^ 1 = 0
0 ^ 0 = 0
1 ^ 0 = 1
-
因此,5 ^ 12 = 1001,即 9。
在我们所需的关于原子操作的所有知识的基础上,让我们回到我们的一千亿计数问题,看看一个可能的解决方案。
使用原子操作修复一亿计数问题
现在我们知道了垃圾值的原因,并且,使用原子操作,应该很容易修复这个问题。对吗?不对。
使用原子锁定一亿次会有巨大的性能损失。现在让我们看看我们的更新后的 worker.js 代码:
// worker.js
let sharedMem;
addEventListener('message', ({data}) => {
//console.log(data);
if(data.message == 'sab') {
sharedMem = data.memory;
console.log('Memory ready');
}
if(data.cmd == 'start') {
console.log('Iterations ready');
startCounting(data.iterations);
}
});
function startCounting(limit) {
const arr = new Uint32Array(sharedMem);
for(let i=0;i<limit;i++) {
Atomics.add(arr, 0, 1);
}
postMessage('done')
}
这与我们的先前实现类似,只是变化在于我们不是直接将其添加到数组中,而是在执行原子操作,这样在另一个线程添加值时,值不会被其他线程更改。
当然,这是一个漂亮的解决方案。它也有效:

但看看那个时间:80 秒!这就是当你在一亿次锁定和解锁内存时得到的惩罚。
单线程更快,因为它可以从寄存器中快速访问局部变量值并使用它们。我们的性能较慢,因为我们正在获取共享内存的引用,锁定它,增加它,然后释放它。
让我们再读一遍。单线程更快,因为它可以从寄存器中快速访问局部变量值并使用它们。我们能做些什么吗?让我们看看!
优化的解决方案
为什么不结合原子操作和 CPU 寄存器中局部变量的速度优势呢?这里就是:
// worker.js
let sharedMem;
addEventListener('message', ({data}) => {
//console.log(data);
if(data.message == 'sab') {
sharedMem = data.memory;
console.log('Memory ready');
}
if(data.cmd == 'start') {
console.log('Iterations ready');
startCounting(data.iterations);
}
});
function startCounting(limit) {
const arr = new Uint32Array(sharedMem);
let count = 0;
for(let i=0;i<limit;i++) {
count += 1;
}
Atomics.add(arr, 0, count);
postMessage('done')
}
在这里,从我们最后的实现中,我们将 Atomics.add 从循环中移除,以避免调用它一亿次。相反,我们在这个 web worker 内部的局部变量中执行分配给它的任务,并在完成后仅使用原子操作更新内存。这确保了在两个线程同时完成时不会发生覆盖。现在是时候查看输出结果了。
看看这些令人惊叹的结果:

不到一秒!仅仅使用两个工作者就实现了大约 2.5 倍的提升!当我们正确实现了并行编程,我们能够超越预期的 2 倍加速,达到大约 2.5 倍!
等一下。故事还没有结束。让我们增加 4 个工作者,看看会发生什么:
// Main Script
const sharedMem = new SharedArrayBuffer(4);
const workers = [new Worker('worker.js'), new Worker('worker.js'), new Worker('worker.js'), new Worker('worker.js')];
let workersDone = 0;
const now = performance.now();
for(let i=0;i<2;i++) {
workers[i].postMessage({message: 'sab', memory: sharedMem});
workers[i].addEventListener('message', data => {
if(++workersDone == 4) { // don't worry. this is thread-safe ;)
console.log("All workers done. The memory is: ", new Uint32Array(sharedMem))
console.log(`Time taken: ${performance.now()-now}`)
}
});
workers[i].postMessage({cmd: 'start', iterations: 1000000000/workers.length});
}
对看到结果感到兴奋吗?我也是!让我们看看:

哎呀。嗯,这看起来并不是一个非常令人印象深刻的性能提升。从单线程到双线程是一个巨大的提升。为什么从双线程到四线程,提升不是一倍呢?
让我们看看“网络”标签页:

哈哈,我们好像找到了一个突破!看起来我们在总共 911 毫秒的程序执行时间中,有 462 毫秒是在仅仅下载worker.js文件上!这甚至不包括 JavaScript 引擎在下载后对每个单独脚本编译成机器代码的时间。
不幸的是,这就是我们能从我们这边做到的尽头。现在浏览器需要优化的是,如果单个文件在 web worker 中被反复调用,它应该从缓存中提取编译后的文件,这样它实际上可以使用一个已经编译的文件实例,而不是再次下载三次并重新编译。
在未来,如果 Chrome 根据前面的建议进行优化,我们可以说它将花费大约~120毫秒而不是 462 毫秒来下载和编译。
因此,我们的脚本在不久的将来,将花费大约~570 毫秒来计算到十亿。这是相对于单线程的 500%的性能提升。这就是 JavaScript 中的多线程,朋友们。
一瞥 Spectre
2018 年 1 月 3 日,我们发现了一个与我们过去 20 年使用的 CPU 架构相关的根本性缺陷。这动摇了现代安全的根基。虽然 Spectre 和 Meltdown 的工作原理非常复杂(如果你对安全领域感兴趣,这非常有趣),你现在需要知道的是,由于 Spectre,所有主要的浏览器供应商都默认禁用了SharedArrayBuffer。
你可以通过访问chrome://flags并搜索SharedArrayBuffer来启用SharedArrayBuffer。
禁用SharedArrayBuffer的原因是为了减轻 Spectre 的影响,这是一个危险但精心设计的漏洞,需要非常精确的时间测量来攻击。SharedArrayBuffer为多个线程提供了一种方式,使得每个线程都可以访问到,原子操作则提供了对数据的更多精确控制。这可以通过SharedArrayBuffer创建高度精确的时钟,可以用来执行 Spectre 攻击。
Spectre 主要利用了现代 CPU 预计算大量事情并将它们放入缓存的事实。所以,如果你发现你的程序不应该访问的内存部分被拒绝访问的速度比预期快得多,那么很可能,那个特定的内存块就在缓存中。使用精心制作的脚本,甚至可以知道缓存中存储了哪个值,因为你的程序是将其放入那里的!
哎!要真正地玩转 Spectre 和 Meltdown,需要很长的一章。但这将是另一天,另一本书的内容。这里的要点是,在撰写本章时,SharedArrayBuffer 在浏览器中默认未启用。当所有浏览器供应商都实施适当的补丁时,它将在未来启用。
这里有一些文章,适合那些觉得这类东西很酷的人:
-
如何 Spectre 工作:
www.i-programmer.info/news/149-security/11449-how-spectre-works.html -
解释 Spectre 和 Meltdown:
www.csoonline.com/article/3247868/vulnerabilities/spectre-and-meltdown-explained-what-they-are-how-they-work-whats-at-risk.html
到那时,请保持安全!
摘要
不得不说,这现在是我的最爱章节,而第四章,异步编程则降至第二位。这项技术是原始的、新鲜的,等待被探索。
在本章中,我们关于 ES2017 学到了很多新知识,这些知识在不久的将来将成为用 JavaScript 编写的多线程程序的基础。好吧,就是这样!你现在已经成为一个了解 ES2017(即 ES8)以及更多未来技术的优秀开发者了。运用你的力量,让这个世界变得更美好!


浙公网安备 33010602011771号