JavaScript-高级编程-全-

JavaScript 高级编程(全)

原文:zh.annas-archive.org/md5/C01E768309CC6F31A9A1148399C85D90

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了作者、本书的内容、开始所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件。

关于本书

JavaScript 是 Web 技术的核心编程语言,可用于修改 HTML 和 CSS。它经常被缩写为 JS。JavaScript 经常用于大多数 Web 浏览器的用户界面中进行的处理,如 Internet Explorer,Google Chrome 和 Mozilla Firefox。由于其使浏览器能够完成工作的能力,它是当今最广泛使用的客户端脚本语言。

在本书中,您将深入了解 JavaScript。您将学习如何在 ES6 中使用新的 JavaScript 语法在专业环境中编写 JavaScript,如何利用 JavaScript 的异步特性使用回调和承诺,以及如何设置测试套件并测试您的代码。您将了解 JavaScript 的函数式编程风格,并将所学的一切应用于使用各种 JavaScript 框架和库构建简单应用程序的后端和前端开发。

关于作者

Zachary Shute在 RPI 学习计算机和系统工程。他现在是位于加利福尼亚州旧金山的一家机器学习初创公司的首席全栈工程师。对于他的公司 Simple Emotion,他管理和部署 Node.js 服务器,MongoDB 数据库以及 JavaScript 和 HTML 网站。

目标

  • 检查 ES6 中的主要功能,并实现这些功能来构建应用程序

  • 创建承诺和回调处理程序以处理异步进程

  • 使用 Promise 链和 async/await 语法开发异步流

  • 使用 JavaScript 操作 DOM

  • 处理 JavaScript 浏览器事件

  • 探索测试驱动开发,并使用 JavaScript 代码测试框架构建代码测试。

  • 列出函数式编程与其他风格相比的优缺点

  • 使用 Node.js 后端框架和 React 前端框架构建应用程序

受众

本书旨在针对任何希望在专业环境中编写 JavaScript 的人群。我们期望受众在某种程度上使用过 JavaScript,并熟悉基本语法。本书适合技术爱好者,想知道何时使用生成器或如何有效地使用承诺和回调,或者想加深对 JavaScript 的了解和理解 TDD 的初学开发人员。

方法

这本书以易于理解的方式全面解释了技术,同时完美地平衡了理论和练习。每一章都设计为在前一章所学内容的基础上构建。本书包含多个活动,使用真实的商业场景让您练习并应用新技能,使之具有高度相关性。

最低硬件要求

为了获得最佳的学生体验,我们建议以下硬件配置:

  • 处理器:Intel Core i5 或同等处理器

  • 内存:4 GB RAM

  • 存储:35 GB 可用空间

  • 互联网连接

软件要求

您还需要提前安装以下软件:

安装说明可以单独提供给大型培训中心和组织。所有源代码都可以在 GitHub 上公开获取,并在培训材料中得到完全引用。

安装代码包

将课程的代码包复制到C:/Code文件夹中。

额外资源

本书的代码包也托管在 GitHub 上,网址为github.com/TrainingByPackt/Advanced-JavaScript

我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

约定

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄都显示为以下方式:"JavaScript 中声明变量的三种方式:varletconst。"

代码块设置如下:

var example; // Declare variable
example = 5; // Assign value
console.log( example ); // Expect output: 5

任何命令行输入或输出都以以下方式编写:

npm install babel --save-dev

新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这种方式出现在文本中:"这意味着使用块作用域创建的变量受到时间死区(TDZ)的影响。"

安装 Atom IDE

  1. 要安装 Atom IDE,请在浏览器中转到atom.io/

  2. 点击下载 Windows 安装程序以下载名为AtomSetup-x64.exe的设置文件。

  3. 运行可执行文件。

  4. atomapm命令添加到您的路径中。

  5. 在桌面和开始菜单上创建快捷方式。

Babel 会安装到每个代码项目的本地。要在 NodeJs 项目中安装 Babel,请完成以下步骤:

  1. 打开命令行界面并导航到项目文件夹。

  2. 运行命令npm init

  3. 填写所有必填问题。如果您不确定任何提示的含义,可以按“enter”键跳过问题并使用默认值。

  4. 运行npm install --save-dev babel-cli命令。

  5. 运行命令install --save-dev babel-preset-es2015

  6. 验证package.json中的devDependencies字段是否包含babel-clibabel-presets-es2015

  7. 创建一个名为.babelrc的文件。

  8. 在文本编辑器中打开此文件并添加代码{ "presets": ["es2015"] }

安装 Node.js 和 npm

  1. 要安装 Node.js,请在浏览器中转到nodejs.org/en/

  2. 点击下载 Windows(x64),以下载推荐给大多数用户的 LTS 设置文件,名为node-v10.14.1-x64.msi

  3. 运行可执行文件。

  4. 确保在安装过程中选择 npm 软件包管理器捆绑包。

  5. 接受许可证和默认安装设置。

  6. 重新启动计算机以使更改生效。

第一章:介绍 ECMAScript 6

学习目标

在本章结束时,您将能够:

  • 定义 JavaScript 中的不同作用域并表征变量声明

  • 简化 JavaScript 对象定义

  • 解构对象和数组,并构建类和模块

  • 为了兼容性转译 JavaScript

  • 组合迭代器和生成器

在本章中,您将学习如何使用 ECMAScript 的新语法和概念。

介绍

JavaScript,通常缩写为 JS,是一种旨在允许程序员构建交互式 Web 应用程序的编程语言。JavaScript 是 Web 开发的支柱之一,与 HTML 和 CSS 一起。几乎每个主要的网站,包括 Google、Facebook 和 Netflix,都大量使用 JavaScript。JS 最初是为 Netscape Web 浏览器于 1995 年创建的。JavaScript 的第一个原型是由 Brendan Eich 在短短的 10 天内编写的。自创建以来,JavaScript 已成为当今最常用的编程语言之一。

在本书中,我们将加深您对 JavaScript 核心及其高级功能的理解。我们将涵盖 ECMAScript 标准中引入的新功能,JavaScript 的异步编程特性,DOM 和 HTML 事件与 JavaScript 的交互,JavaScript 的函数式编程范式,测试 JavaScript 代码以及 JavaScript 开发环境。通过本书所获得的知识,您将准备好在专业环境中使用 JavaScript 构建强大的 Web 应用程序。

从 ECMAScript 开始

ECMAScript是由ECMA International标准化的脚本语言规范。它旨在标准化 JavaScript,以允许独立和兼容的实现。ECMAScript 6,或ES6,最初于 2015 年发布,并自那时以来经历了几次次要更新。

注意

您可以参考以下链接了解更多关于 ECMA 规范的信息:developer.mozilla.org/en-US/docs/Web/JavaScript/Language_Resources

理解作用域

在计算机科学中,作用域是计算机程序中名称与实体(如变量或函数)的绑定或关联有效的区域。JavaScript 具有以下两种不同类型的作用域:

  • 函数作用域

  • 块作用域

在 ES6 之前,函数作用域是 JavaScript 中唯一的作用域形式;所有变量和函数声明都遵循函数作用域规则。ES6 引入了块作用域,仅由使用新的变量声明关键字letconst声明的变量使用。这些关键字在声明变量部分中有详细讨论。

函数作用域

JavaScript 中的函数作用域是在函数内部创建的。当声明一个函数时,在该函数的主体内部创建一个新的作用域块。在新函数作用域内声明的变量无法从父作用域访问;但是,函数作用域可以访问父作用域中的变量。

要创建具有函数作用域的变量,必须使用var关键字声明变量。例如:

var example = 5;

以下代码段提供了函数作用域的示例:

var example = 5;
function test() {
  var testVariable = 10;
  console.log( example ); // Expect output: 5
  console.log( testVariable ); // Expect output: 10
}
test();
console.log( testVariable ); // Expect reference error
代码段 1.1:函数作用域

父作用域只是函数定义的代码段的作用域。这通常是全局作用域;但是,在某些情况下,在函数内部定义函数可能很有用。在这种情况下,嵌套函数的父作用域将是其定义的函数。在前面的代码段中,函数作用域是在函数 test 内创建的作用域。父作用域是全局作用域,即函数定义的地方。

注意

父作用域是定义函数的代码块。它不是调用函数的代码块。

函数作用域提升

当使用函数作用域创建变量时,其声明会自动提升到作用域的顶部。提升意味着解释器将实体的实例化移动到其声明的作用域顶部,而不管它在作用域块中的定义位置。在 JavaScript 中,使用var声明的函数和变量会被提升;也就是说,函数或变量可以在其声明之前使用。以下代码演示了这一点:

example = 5; // Assign value
console.log( example ); // Expect output: 5
var example; // Declare variable
片段 1.2:函数作用域提升

注意

由于使用var声明的提升变量可以在声明之前使用,因此我们必须小心在变量被赋值之前不要使用该变量。如果在变量被赋值之前访问变量,它将返回undefined,这可能会导致问题,特别是如果变量在全局作用域中使用。

块作用域

在 JavaScript 中,使用花括号({})创建一个新的块作用域。一对花括号可以放置在代码的任何位置以定义一个新的作用域块。if 语句、循环、函数和任何其他花括号对都将有自己的块作用域。这包括与关键字(if、for 等)无关的浮动花括号对。以下片段中的代码是块作用域规则的示例:

// Top level scope
function scopeExample() {
  // Scope block 1
  for ( let i = 0; i < 10; i++ ){ /* Scope block 2 */ }
  if ( true ) { /* Scope block 3 */ } else {  /* Scope block 4 */ }
  // Braces without keywords create scope blocks
  { /* Scope block 5 */ } 
  // Scope block 1
}
// Top level scope
片段 1.3:块作用域

使用关键字letconst声明的变量具有块作用域。当使用块作用域声明变量时,它不具有与在函数作用域中创建的变量相同的变量提升。块作用域变量不会被提升到作用域的顶部,因此在声明之前无法访问。这意味着使用块作用域创建的变量受到暂时性死区TDZ)的影响。TDZ 是指进入作用域和声明变量之间的时间段。它在变量被声明而不是赋值时结束。以下示例演示了 TDZ:

// console.log( example ); // Would throw ReferenceError
let example;
console.log( example ); // Expected output: undefined
example = 5;
console.log( example ); // Expected output: 5
片段 1.4:暂时性死区

注意

如果在暂时性死区内访问变量,则会抛出运行时错误。这很重要,因为它可以使我们的代码更加健壮,减少由于变量声明而产生的语义错误。

要更好地理解作用域块,请参考以下表格:

图 1.1:函数作用域与块作用域

图 1.1:函数作用域与块作用域

总之,作用域为我们提供了一种在代码块之间分离变量并限制访问的方式。变量标识符名称可以在作用域块之间重复使用。所有创建的新作用域块都可以访问父作用域,或者它们被创建或定义的作用域。JavaScript 有两种作用域。为每个定义的函数创建一个新的函数作用域。变量可以使用var关键字添加到函数作用域,并且这些变量会被提升到作用域的顶部。块作用域是 ES6 的一个新特性。为每组花括号创建一个新的块作用域。使用letconst关键字将变量添加到块作用域。添加的变量不会被提升,并且受到 TDZ 的影响。

练习 1:实现块作用域

要使用变量实现块作用域原则,请执行以下步骤:

  1. 创建一个名为fn1的函数(function fn1())。

  2. 记录字符串为scope 1

  3. 创建一个名为scope的变量,其值为 5。

  4. 记录名为scope的变量的值。

  5. 在函数内部使用花括号({})创建一个新的作用域块。

  6. 在新的作用域块内,记录名为scope 2的字符串。

  7. 在作用域块内创建一个名为scope的新变量,并赋值为different scope

  8. 记录块作用域内变量scope的值(scope 2)。

  9. 在步骤 5 中定义的作用域块之外(scope 2),创建一个新的作用域块(使用花括号)。

  10. 记录名为scope 3的字符串。

  11. 在作用域块(作用域 3)内创建一个同名的变量(称为 scope)并将其赋值为 第三个作用域

  12. 记录新变量的值。

  13. 调用 fn1 并观察其输出

代码

index.js:

function fn1(){
 console.log('Scope 1');
 let scope = 5;
 console.log(scope);
 {
   console.log('Scope 2');
   let scope = 'different scope';
   console.log(scope);
 }
  {
   console.log('Scope 3');
   let scope = 'a third scope';
   console.log(scope);
 }
}
fn1();

bit.ly/2RoOotW

代码片段 1.5:块实现输出

结果

图 1.2:作用域输出

图 1.2:作用域输出

您已成功在 JavaScript 中实现了块作用域。

在本节中,我们介绍了 JavaScript 作用域的两种类型,函数作用域和块作用域,以及它们之间的区别。我们演示了如何在每个函数内部创建一个新的函数作用域实例,以及如何在每组花括号内创建块作用域。我们讨论了每种作用域类型的变量声明关键字,var 用于函数作用域,let/const 用于块作用域。最后,我们介绍了函数作用域和块作用域的变量提升的基础知识。

声明变量

基本 JavaScript 使用关键字 var 进行变量声明。ECMAScript 6 引入了两个新关键字来声明变量;它们是 letconst。在专业 JavaScript 变量声明的世界中,var 现在是最薄弱的环节。在本主题中,我们将介绍新关键字 letconst,并解释它们为什么比 var 更好。

在 JavaScript 中声明变量的三种方式是使用 varletconst。它们的功能略有不同。这三种变量声明关键字之间的关键区别在于它们处理变量重新分配、变量作用域和变量提升的方式。这三个特性可以简要解释如下:

变量重新赋值: 在任何时候改变或重新分配变量的值的能力。

变量作用域: 变量可以被访问的代码范围或区域。

变量提升: 变量实例化和赋值时间与变量声明的关系。有些变量可以在它们被声明之前使用。

var 关键字是在 JavaScript 中用于声明变量的较旧的关键字。所有使用 var 创建的变量都可以重新分配,具有函数作用域,并且具有变量提升。这意味着使用 var 创建的变量被提升到作用域块的顶部,在那里它们被定义并且可以在声明之前访问。以下代码片段演示了这一点,如下所示:

// Referenced before declaration
console.log( example ); // Expect output: undefined
var example = 'example';
代码片段 1.6:使用 var 创建的变量被提升

由关键字 var 创建的变量不是常量,因此可以随意创建、分配和重新分配值。以下代码演示了 var 功能的这一方面:

// Declared and assigned
var example = { prop1: 'test' };
console.log( 'example:', example );
// Expect output: example: {prop1: "test"}
// Value reassigned
example = 5;
console.log( example ); // Expect output: 5
代码片段 1.7:使用 var 创建的变量不是常量

使用 var 创建的变量可以在任何时候重新分配,并且一旦变量被创建,即可在函数中的任何地方访问,甚至是在原始声明点之前。

let 关键字与关键字 var 类似。如预期的那样,关键字 let 允许我们声明一个可以在任何时候重新分配的变量。以下代码中展示了这一点:

// Declared and initialized
let example = { prop1: 'test' };
console.log( 'example:', example );
// Expect output: example: {prop1: 'test"}
// Value reassigned
example = 5;
console.log( example ); // Expect output: 5
代码片段 1.8:使用 let 创建的变量不是常量

letvar 之间有两个重要的区别。letvar 的区别在于它们的作用域和变量提升属性。使用 let 声明的变量的作用域是块级的;也就是说,它们只在匹配的一对花括号({})内的代码块中定义。

使用 let 声明的变量不受变量提升的影响。这意味着在赋值之前访问使用 let 声明的变量将引发运行时错误。正如前面讨论的那样,这就是暂时性死区。以下代码示例说明了这一点:

// Referenced before declaration
console.log( example );
// Expect ReferenceError because example is not defined
let example = 'example';
代码片段 1.9:使用 let 创建的变量不会被提升

最后一个变量声明关键字是constconst关键字具有与let关键字相同的作用域和变量提升规则;使用const声明的变量具有块作用域,并且不会被提升到作用域的顶部。这在以下代码中显示:

// Referenced before declaration
console.log( example );
// Expect ReferenceError because example is not defined
const example = 'example';
片段 1.10:使用 const 创建的变量不会被提升

constlet之间的关键区别在于const表示标识符不会被重新分配。const标识符表示对值的只读引用。换句话说,不能更改const变量中写入的值。如果更改了使用const初始化的变量的值,将抛出TypeError

即使使用const创建的变量不能被重新分配,这并不意味着它们是不可变的。如果数组或对象存储在使用const声明的变量中,则无法覆盖变量的值。但是,数组内容或对象属性可以更改。可以使用push()pop()map()等函数修改数组的内容,并且可以添加、删除或更新对象属性。这在以下代码中显示:

// Declared and initialized
const example = { prop1: 'test' };
// Variable reassigned
example = 5;
// Expect TypeError error because variable was declared with const
// Object property updated
example.prop1 = 5;
// Expect no error because subproperty was modified
片段 1.11:使用 const 创建的变量是常量但不是不可变的

要更详细地了解不同的关键字,请参考以下表格:

图 1.3:var、let 和 const 之间的差异

图 1.3:var、let 和 const 之间的差异

现在我们了解了varletconst之间的细微差别,我们可以决定使用哪一个。在专业世界中,我们应该始终使用letconst,因为它们提供了var的所有功能,并允许程序员对变量的范围和用法进行具体和限制性的定义。

总之,varletconst都有类似的功能。关键区别在于const的性质、作用域和提升。var是函数作用域的,不是常量,并且被提升到作用域块的顶部。letconst都是块作用域的,不会被提升。let不是常量,而const是常量但不可变的。

练习 2:利用变量

为了利用varconstlet变量声明关键字的变量提升和重新分配属性,执行以下步骤:

  1. 记录字符串赋值前提升:hoisted变量的值。

  2. 使用关键字var定义一个名为hoisted的变量,并将其赋值为this got hoisted

  3. 记录字符串赋值后提升:hoisted变量的值。

  4. 创建一个 try-catch 块。

  5. try块内,记录名为notHoisted1的变量的值。

  6. catch块内,给 catch 块err参数,然后记录字符串带错误的未提升 1:err.message的值。

  7. 在 try-catch 块之后,使用关键字let创建notHoisted1变量,并赋值为5

  8. 记录字符串赋值后 notHoisted1notHoisted1的值。

  9. 创建另一个 try-catch 块。

  10. try块内,记录notHoisted2变量的值。

  11. 在 catch 块内,给 catch 块err参数,然后记录字符串带错误的未提升 2:err.message的值。

  12. 在第二个 try-catch 块之后,使用关键字const创建notHoisted2变量,并赋值[1,2,3]。

  13. 记录字符串赋值后 notHoisted2notHoisted2的值。

  14. 定义一个最终的 try catch 块。

  15. try块内,将notHoisted2重新分配为new value字符串。

  16. 在 catch 块内,给 catch 块err参数,然后记录字符串未提升 2 无法更改

  17. 在 try-catch 块之后,将值5推送到notHoisted2中的数组中。

  18. 记录字符串notHoisted2 已更新。现在是:notHoisted2的值。

代码

index.js:
var hoisted = 'this got hoisted';
try{
 console.log(notHoisted1);
} catch(err){}
let notHoisted1 = 5;
try{
 console.log(notHoisted2);
} catch(err){}
const notHoisted2 = [1,2,3];
try{
 notHoisted2 = 'new value';
} catch(err){}
notHoisted2.push(5);
片段 1.12:更新对象的内容

bit.ly/2RDEynv

结果

图 1.4:提升变量

图 1.4:提升变量

您已成功地利用关键字声明变量。

在本节中,我们讨论了 ES6 中的变量声明以及使用letconst变量声明关键字相对于var变量声明关键字的好处。我们讨论了每个关键字的变量重新赋值属性,变量作用域和变量提升属性。关键字letconst都在块作用域中创建变量,而var在函数作用域中创建变量。使用varlet创建的变量可以随意重新赋值。然而,使用const创建的变量不能被重新赋值。最后,使用关键字var创建的变量被提升到它们被定义的作用域块的顶部。使用letconst创建的变量不会被提升。

引入箭头函数

箭头函数,或Fat 箭头函数,是在 ECMAScript 6 中创建函数的新方法。箭头函数简化了函数语法。它们被称为fat 箭头函数,因为它们用字符=>表示,这样放在一起看起来像一个粗箭头。JavaScript 中的箭头函数经常在回调链,承诺链,数组方法中使用,在任何需要未注册函数的情况下都会很有用。

JavaScript 中箭头函数和普通函数之间的关键区别在于箭头函数是匿名的。箭头函数没有名称,也没有绑定到标识符。这意味着箭头函数是动态创建的,不像普通函数那样有名称。然而,箭头函数可以分配给一个变量以便重用。

创建箭头函数时,我们只需要删除函数关键字,并在函数参数和函数体之间放置一个箭头。箭头函数用以下语法表示:

( arg1, arg2, ..., argn ) => { /* Do function stuff here */ }
片段 1.13:箭头函数语法

从前面的语法中可以看出,箭头函数是 JavaScript 中更简洁的编写函数的方式。它们可以使我们的代码更简洁,更易读。

箭头函数语法也可能有所不同,取决于几个因素。语法可能会略有不同,具体取决于传递给函数的参数数量以及函数体中的代码行数。特殊的语法条件在以下列表中简要概述:

  • 单个输入参数

  • 无输入参数

  • 单行函数体

  • 单个表达式跨多行

  • 对象字面量返回值

练习 3:转换箭头函数

为了演示通过将标准函数转换为箭头函数来简化语法,执行以下步骤:

  1. 创建一个接受参数并返回两个参数之和的函数。将函数保存到名为fn1的变量中。

  2. 将刚刚创建的函数转换为箭头函数,并保存到另一个名为fn2的变量中。

要转换函数,删除function关键字。接下来,在函数参数和函数体之间放置一个箭头。

  1. 调用两个函数并比较输出。

代码

index.js:
const fn1 = function( a, b ) { return a + b; };
const fn2 = ( a, b ) => { return a + b; };
console.log( fn1( 3 ,5 ), fn2( 3, 5 ) );
片段 1.14:调用函数

bit.ly/2M6uKwN

结果

图 1.5:比较函数的输出

图 1.5:比较函数的输出

您已成功将普通函数转换为箭头函数。

箭头函数语法

如果有多个参数传递给函数,那么我们使用括号来创建函数,括号包围参数就像平常一样。如果我们只有一个参数要传递给函数,我们就不需要在参数周围加括号。

这个规则有一个例外,那就是参数不是简单的标识符。如果我们在函数参数中包含默认值或执行操作,那么我们必须包含括号。例如,如果我们包含默认参数,那么我们将需要在参数周围加上括号。这两条规则如下面的代码所示:

// Single argument arrow function
arg1 => { /* Do function stuff here */ }
// Non simple identifier function argument
( arg1 = 10 ) => { /* Do function stuff here */ }
片段 1.15:单参数箭头函数

如果我们创建一个没有参数的箭头函数,那么我们需要包括括号,但括号将是空的。如下面的代码所示:

// No arguments passed into the function
( ) => { /* Do function stuff here */ }
片段 1.16:无参数

箭头函数的语法也可以有所不同,取决于函数的主体。如预期的那样,如果函数的主体是多行的,那么我们必须用花括号括起来。但是,如果函数的主体是单行的,那么我们不需要在函数的主体周围包含花括号。这如下面的代码所示:

// Multiple line body arrow function
( arg1, arg2 ) => { 
  console.log( `This is arg1: ${arg1}` );
  console.log( `This is arg2: ${arg2}` );
  /* Many more lines of code can go here */
}
// Single line body arrow function
( arg1, arg2 ) => console.log( `This is arg1: ${arg1}` )
片段 1.17:单行体

在使用箭头函数时,如果函数是单行的,我们也可以省略 return 关键字。箭头函数会自动返回该行表达式的解析值。这种语法如下面的代码所示:

// With return keyword - not necessary
( num1, num2 ) => { return ( num1 + num2 ) }
// If called with arguments num1 = 5 and num2 = 5, expected output is 10
// Without return keyword or braces
( num1, num2 ) => num1 + num2
// If called with arguments num1 = 5 and num2 = 5, expected output is 10
片段 1.18:返回值为单行体

由于单行表达式体的箭头函数可以在没有花括号的情况下定义,我们需要特殊的语法来允许我们将单个表达式分成多行。为此,我们可以将多行表达式放在括号中。JavaScript 解释器会看到括号中的行,并将其视为单行代码。这如下面的代码所示:

// Arrow function with a single line body
// Assume numArray is an array of numbers
( numArray ) => numArray.filter( n => n > 5).map( n => n - 1 ).every( n => n < 10 )
// Arrow function with a single line body broken into multiple lines
// Assume numArray is an array of numbers
( numArray ) => (
  numArray.filter( n => n > 5)
          .map( n => n - 1 )
          .every( n => n < 10 )
) 
片段 1.19:将单行表达式分成多行

如果我们有一个返回对象字面量的单行箭头函数,我们将需要特殊的语法。在 ES6 中,作用域块、函数主体和对象字面量都是用花括号定义的。由于单行箭头函数不需要花括号,我们必须使用特殊的语法来防止对象字面量的花括号被解释为函数主体花括号或作用域块花括号。为此,我们用括号括起返回的对象字面量。这指示 JavaScript 引擎将括号内的花括号解释为表达式,而不是函数主体或作用域块声明。这如下面的代码所示:

// Arrow function with an object literal in the body
( num1, num2 ) => ( { prop1: num1, prop2: num2 } ) // Returns an object
片段 1.20:对象字面量返回值

在使用箭头函数时,我们必须注意这些函数被调用的作用域。箭头函数遵循 JavaScript 中的正常作用域规则,但this作用域除外。回想一下,在基本的 JavaScript 中,每个函数都被分配一个作用域,即this作用域。箭头函数没有被分配一个this作用域。它们继承其父级的this作用域,并且不能将新的this作用域绑定到它们。这意味着,如预期的那样,箭头函数可以访问父函数的作用域,随后访问该作用域中的变量,但this的作用域不能在箭头函数中改变。使用.apply().call().bind()函数修改器都不会改变箭头函数的this属性的作用域。如果你处于必须将this绑定到另一个作用域的情况,那么你必须使用普通的 JavaScript 函数。

总之,箭头函数为我们提供了简化匿名函数语法的方法。要编写箭头函数,只需省略 function 关键字,并在参数和函数体之间添加一个箭头。

然后可以应用特殊语法来简化箭头函数。如果函数有一个输入参数,那么我们可以省略括号。如果函数主体是单行的,我们可以省略return关键字和花括号。然而,返回对象字面量的单行函数必须用括号括起来。

我们还可以在函数体周围使用括号,以便将单行函数体分成多行以提高可读性。

练习 4:升级箭头函数

要利用 ES6 箭头函数语法编写函数,请执行以下步骤:

  1. 参考exercises/exercise4/exercise.js文件并在此文件中执行更新。

  2. 使用基本的 ES6 语法转换fn1

在函数参数之前删除函数关键字。在函数参数和函数体之间添加箭头。

  1. 使用单语句函数体语法转换fn2

在函数参数之前删除函数关键字。在函数参数和函数体之间添加箭头。

删除函数体周围的花括号({})。删除 return 关键字。

  1. 使用单个输入参数语法转换fn3

在函数参数之前删除函数关键字。在函数参数和函数体之间添加箭头。

删除函数输入参数周围的括号。

  1. 使用无输入参数语法转换fn4

在函数参数之前删除函数关键字。在函数参数和函数体之间添加箭头。

  1. 使用对象文字语法转换fn5

在函数参数之前删除函数关键字。在函数参数和函数体之间添加箭头。

删除函数体周围的花括号({})。删除 return 关键字。

用括号括起返回的对象。

代码

index.js:
let fn1 = ( a, b ) => { … };
let fn2 = ( a, b ) => a * b;
let fn3 = a => { … };
let fn4 = () => { … };
let fn5 = ( a ) => ( …  );
代码段 1.21:箭头函数转换

bit.ly/2M6qSfg

结果

图 1.6:转换函数的输出

图 1.6:转换函数的输出

您已成功利用 ES6 箭头函数语法编写函数。

在本节中,我们介绍了箭头函数,并演示了它们如何在 JavaScript 中大大简化函数声明。首先,我们介绍了箭头函数的基本语法:( arg1, arg2, argn ) => { /* function body */ }。然后,我们继续介绍了高级箭头函数的五种特殊语法情况,如下列表所述:

  • 单个输入参数:arg1 => { /* function body */ }

  • 无输入参数:( ) => { /* function body */ }

  • 单行函数体:( arg1, arg2, argn ) => /* single line */

  • 单个表达式分成多行:( arg1, arg2, argn ) => ( /* multi line single expression */ )

  • 对象文字返回值:( arg1, arg2, argn ) => ( { /* object literal */ } )

学习模板文字

模板文字是 ECMAScript 6 中引入的一种新形式的字符串。它们由反引号符号(`),而不是通常的单引号或双引号。模板文字允许您在运行时计算的字符串中嵌入表达式。因此,我们可以很容易地从变量和变量表达式创建动态字符串。这些表达式用美元符号和花括号(${ expression })表示。模板文本语法如以下代码所示:

const example = "pretty";
console.log( `Template literals are ${ example } useful!!!` ); 
// Expected output: Template literals are pretty useful!!!
代码段 1.22:模板字面量基本语法

在 JavaScript 中,模板字面量像其他字符串一样被转义。要转义模板字面量,只需使用反斜杠(\)字符。例如,以下相等性计算结果为真:\ === "",\t === "\t", and ``\n\r` === "\n\r".

模板字面量允许多行字符串。插入源代码的任何换行符都属于模板字面量,并将在输出中导致换行。简单来说,在模板字面量内,我们可以按下键盘上的Enter键并将其拆分成两行。源代码中的换行符将被解析为模板字面量的一部分,并将导致输出中的换行。要使用普通字符串复制这一点,我们必须使用\n字符生成新行。使用模板字面量,我们可以在模板字面量源中换行并实现相同的预期输出。示例代码如下所示:

// Using normal strings
console.log( 'This is line 1\nThis is line 2' );
// Expected output: This is line 1
// This is line 2
// Using template literals
console.log( `This is line 1
This is line 2` );
// Expected output: This is line 1
// This is line 2
代码段 1.23:模板字面量多行语法

练习 5:转换为模板字面量

为了演示模板字面量表达式的强大功能,将标准字符串对象转换为模板字面量,执行以下步骤:

  1. 创建两个变量,ab,并将数字保存其中。

  2. 用普通字符串记录 ab 的总和为 a + b 等于 <result>

  3. 以单个模板字面量的格式记录 ab 的总和为 a + b 等于 <result>

代码

index.js:
let a = 5, b = 10;
console.log( a + ' + ' + b + ' is equal to ' + ( a + b ) );
console.log( `${a} + ${b} is equal to ${a + b}` );
代码段 1.24:模板字面量和字符串比较

bit.ly/2RD5jbC

结果

图 1.7:记录变量输出的总和

图 1.7:记录变量输出的总和

您已成功将标准字符串对象转换为模板字面量。

模板字面量允许表达式嵌套,即,新的模板字面量可以放置在模板字面量的表达式中。由于嵌套的模板字面量是表达式的一部分,它将被解析为新的模板字面量,并且不会干扰外部模板字面量。在某些情况下,嵌套模板字面量是创建字符串的最简单和最可读的方式。模板字面量嵌套的示例代码如下所示:

function javascriptOrCPlusPlus() { return 'JavaScript'; }
const outputLiteral = `We are learning about ${ `Professional ${ javascriptOrCPlusPlus() }` }`
代码段 1.25:模板字面量嵌套

带标记的模板文字是模板文字的更高级形式。带标记的模板文字可以使用称为标记函数的特殊函数进行解析,可以返回一个操作后的字符串或任何其他值。标记函数的第一个输入参数是一个包含字符串值的数组。字符串值表示输入字符串的部分,在每个模板表达式处进行拆分。其余的参数是字符串中模板表达式的值。标记函数不像普通函数那样调用。要调用标记函数,我们忽略模板文字参数周围的括号和空格。以下是此语法的示例:

// Define the tag function
function tagFunction( strings, numExp, fruitExp ) { 
  const str0 = strings[0]; // "We have"
  const str1 = strings[1]; // " of "
  const quantity = numExp < 10 ? 'very few' : 'a lot';
  return str0 + quantity + str1 + fruitExp + str2;
}
const fruit = 'apple', num = 8;
// Note: lack of parenthesis or whitespace when calling tag function
const output = tagFunction`We have ${num} of ${fruit}. Exciting!`
console.log( output )
// Expected output: We have very few of apples. Exciting!!
Snippet 1.26: 带标记的模板文字示例

一个名为raw的特殊属性可用于标记模板的第一个参数。此属性返回一个包含每个拆分模板文字的原始、未转义版本的数组。以下是示例代码:

function tagFunction( strings ){ console.log( strings.raw[0] ); }
tagFunction`This is line 1\. \n This is line 2.`
// Expected output: "This is line 1\. \n This is line 2." The characters //'\' and 'n' are not parsed into a newline character
Snippet 1.27: 带标记的模板原始属性

总而言之,模板文字允许简化复杂的字符串表达式。模板文字允许将变量和复杂表达式嵌入字符串中。模板文字甚至可以嵌套到其他模板文字的表达式字段中。如果模板文字在源代码中分为多行,则解释器将将其解释为字符串中的换行并相应地插入一个换行。模板文字还提供了一种使用带标记模板函数解析和操作字符串的新方式。这些函数为您提供了一种通过特殊函数执行复杂的字符串操作的方法。通过带标记的模板函数,可以访问原始字符串,如其输入一样,忽略任何转义序列。

练习 6:模板文字转换

您正在为一家房地产公司建立网站。您必须构建一个函数,该函数接受包含属性信息的对象,并返回一个格式化的字符串,说明物业所有者、物业所在地(address)以及他们出售的价格。考虑以下对象作为输入:

{
  address: '123 Main St, San Francisco CA, USA',
  floors: 2,
  price: 5000000,
  owner: 'John Doe'
}
Snippet 1.28: 对象输入

要利用模板文本对对象进行漂亮的打印,执行以下步骤:

  1. 创建一个名为parseHouse的函数,该函数接受一个对象。

  2. 从函数返回一个模板文本。使用表达式,将所有者、地址和价格嵌入到格式为<所有者>在<地址>出售价格为<价格>的字符串中。

  3. 创建一个名为house的变量,并将以下对象保存到其中:{ address: "123 Main St, San Francisco CA, USA", floors: 2, price: 5000000, owner: "John Doe" }

  4. 调用parseHouse函数并传入house变量。

  5. 记录输出。

代码

index.js:
function parseHouse( property ) {
 return `${property.owner} is selling the property at ${property.address} for ${property.price} USD`
}
const house = {
 address: "123 Main St, San Francisco CA, USA",
 floors: 2,
 price: 5000000,
 owner: "John Doe"
};
console.log( parseHouse( house ) );
Snippet 1.29: 使用表达式的模板文字

bit.ly/2RklKKH

结果

图 1.8:模板文字输出

图 1.8:模板文字输出

你已成功利用模板字符串来美化打印输出一个对象。

在这一部分,我们介绍了模板字符串。模板字符串通过允许我们在其中嵌入在运行时被解析的表达式来升级字符串。表达式使用以下语法插入:${ expression }。然后,我们向您展示了如何在模板字符串中转义特殊字符,并讨论了编辑器内的模板字符串换行符在输出中作为换行符的解析方式。最后,我们介绍了模板字符串标记和标记函数,这允许我们执行更复杂的模板字符串解析和创建。

增强对象属性

ECMAScript 6 作为ES6 语法糖的一部分,增加了对象字面量的几个增强功能。ES6 添加了三种简化对象字面量创建的方法。这些简化包括更简洁的语法来从变量初始化对象属性,更简洁的语法定义函数方法,以及计算对象属性名称。

注意

语法糖是一种旨在使表达式更易于阅读和表达的语法。它使语法变得"更甜美",因为代码可以被简洁地表达。

对象属性

初始化对象属性的简写允许您创建更简洁的对象。在 ES5 中,我们需要使用键名和值来定义对象属性,如下代码所示:

function getPersionES5( name, age, height ) {
  return {
    name: name,
    age: age,
    height: height
  };
}
getPersionES5( 'Zachary', 23, 195 )
// Expected output: { name: 'Zachary', age: 23, height: 195 }
代码片段 1.30:ES5 对象属性

注意函数返回的对象字面量中的重复。我们在对象中将属性命名为变量名导致了重复(<code>name: name</code>)。在 ES6 中,我们可以简写每个属性并消除重复。在 ES6 中,我们可以简单地在对象字面量中声明变量,它将创建一个键名匹配变量名和值匹配变量值的属性。以下代码示例:

function getPersionES6( name, age, height ) {
  return {
    name,
    age,
    height
  };
}
getPersionES6( 'Zachary', 23, 195 )
// Expected output: { name: 'Zachary', age: 23, height: 195 }
代码片段 1.31:ES6 对象属性

正如你所看到的,无论是 ES5 还是 ES6 的示例,都输出了完全相同的对象。但是,在大型对象字面量声明中,使用这种新的简写可以节省大量空间和重复。

函数声明

ES6 还为在对象内部声明函数方法添加了一个简写。在 ES5 中,我们必须声明属性名称,然后将其定义为函数。以下示例中有所展示:

function getPersonES5( name, age, height ) {
  return {
    name: name,
    height: height,
    getAge: function(){ return age; }
  };
}
getPersonES5( 'Zachary', 23, 195 ).getAge()
// Expected output: 23
代码片段 1.32:ES5 函数属性

在 ES6 中,我们可以定义一个函数,但工作量要少得多。与属性声明一样,我们并不需要键值对来创建函数。函数名称变为键名。以下代码示例中有所展示:

function getPersionES6( name, age, height ) {
  return {
    name,
    height,
    getAge(){ return age; }
  };
}
getPersionES6( 'Zachary', 23, 195 ).getAge()
// Expected output: 23
代码片段 1.33:ES6 函数属性

注意函数声明中的差异。我们省略了函数关键字和属性键名后的冒号。再次,这为我们节省了一些空间并简化了事情。

计算属性

ES6 还增加了一种有效的方式来创建属性名称,即通过计算属性表示法。正如我们已经知道的,在 ES5 中,只有一种方式可以使用变量创建属性名称;这是通过方括号表示法,即,: obj[ expression ] = 'value'。在 ES6 中,我们可以在对象字面量的声明期间使用相同类型的表示法。这在以下示例中显示:

const varName = 'firstName';
const person = {
  [ varName ] = 'John',
  lastName: 'Smith'
};
console.log( person.firstName ); // Expected output: John
代码片段 1.34:ES6 计算属性

如前面代码片段所示,varName 的属性名称计算为 firstName。在访问属性时,我们只需要引用person.firstName。在对象字面量中创建计算属性时,不需要在方括号中计算的值是变量;它几乎可以是任何表达式,甚至是函数。下面的代码示例中提供了一个例子:

const varName = 'first';
function computeNameType( type ) {
  return type + 'Name';
}
const person = {
  [ varName + 'Name' ] = 'John',
  [ computeNameType( 'last' ) ]: 'Smith'
};
console.log( person.firstName ); // Expected output: John
console.log( person.lastName ); // Expected output: Smith
代码片段 1.35:从函数计算属性

在前面代码片段中的示例中,我们创建了两个变量。第一个包含字符串first,第二个包含返回字符串的函数。然后,我们创建了一个对象,并使用计算属性表示法来创建动态对象键名。第一个键名等于firstName。访问person.firstName时,将返回保存的值。第二个键名等于lastName。当访问person.lastName时,也将返回保存的值。

总之,ES6 增加了三种简化对象字面量声明的方法,即属性表示法,函数表示法和计算属性。为了简化对象中的属性创建,在属性是从变量创建时,我们可以省略键名和冒号。被创建的属性的名称设置为变量名称,值设置为变量的值。要将函数作为对象的属性添加,我们可以省略冒号和函数关键字。被创建的属性名称设置为函数名称,属性的值为函数本身。最后,在对象字面量的声明过程中,我们可以使用计算表达式创建属性名称。我们只需用方括号中的表达式替换键名。这三种简化可以节省我们代码中的空间,并使对象字面量的创建更易于阅读。

练习 7:实现增强的对象属性

您正在构建一个简单的 JavaScript 数学包,以发布到Node Package Manager (NPM)。您的模块将导出一个包含多个常量和函数的对象。使用 ES6 语法,创建导出对象,并包含以下函数和值:圆周率的值,将英寸转换为英尺的比率,求两个参数的和的函数,以及求两个参数的差的函数。创建对象后,记录该对象的内容。

要使用 ES6 增强的对象属性创建对象,并演示简化的语法,执行以下步骤:

  1. 创建一个对象并将其保存到exportObject变量中。

  2. 创建一个名为PI的变量,其中包含圆周率的值(3.1415)。

  3. 创建一个名为INCHES_TO_FEET的变量,并将英寸到英尺的转换比值保存到其中(0.083333)。

    使用 ES6 增强的属性表示法,从变量PI添加一个名为PI的属性。从包含英寸到英尺转换比的INCHES_TO_FEET变量中添加一个名为INCHES_TO_FEET的属性。

    添加一个名为sum的函数属性,接受两个输入参数并返回这两个输入参数的和。

    添加一个名为subtract的函数属性,接受两个输入参数并返回这两个输入参数的差值。

  4. 记录对象exportObject

代码

index.js:
const PI = 3.1415;
const INCHES_TO_FEET = 0.083333;
const exportObject = {
 PI,
 INCHES_TO_FEET,
 sum( n1, n2 ) {
   return n1 + n2;
 },
 subtract( n1, n2 ) {
   return n1 - n2;
 }
};
console.log( exportObject );
代码段 1.36:增强的对象属性

bit.ly/2RLdHWk

结果

图 1.9:增强的对象属性输出

图 1.9:增强的对象属性输出

您已成功使用 ES6 增强的对象属性创建对象。

在本节中,我们向您展示了增强的对象属性,这是一种语法糖,可以帮助将对象属性的创建压缩为更少的字符。我们介绍了使用变量和函数初始化对象属性的简写方式,以及计算对象属性的高级特性,即一种在定义对象时内联从计算值创建对象属性名称的方法。

解构赋值

解构赋值是 JavaScript 中的一种语法,允许您从数组中解压值或从对象的属性中保存值到变量中。这是一个非常方便的特性,因为我们可以直接从数组和对象中提取数据保存到变量中,所有这些都可以在一行代码中完成。它非常强大,因为它使我们能够在同一个表达式中提取多个数组元素或对象属性。

数组解构

数组解构允许我们提取多个数组元素并将它们保存到变量中。在 ES5 中,我们通过逐个定义每个变量及其数组值来实现这一点。这使得代码冗长并增加编写所需的时间。

在 ES6 中,为了解构数组,我们简单地创建一个包含要分配数据的变量的数组,并将其设置为被解构的数据数组。数组中的值被解开并从左到右分配给左侧数组中的变量,一个数组值对应一个变量。基本数组解构的示例如下代码所示:

let names = [ 'John', 'Michael' ];
let [ name1, name2 ] = names;
console.log( name1 ); // Expected output: 'John'
console.log( name2 ); // Expected output: 'Michael'
代码段 1.37:基本数组解构

如本例所示,我们有一个姓名数组,并且我们想要将其解构为name1name2两个变量。我们只需用括号括起变量name1name2,并将该表达式设置为数据数组names,然后 JavaScript 将解构names数组,并将数据保存到各个变量中。

数据从输入数组中解构为变量,从左到右,按照数组项的顺序。第一个索引变量将始终被分配第一个索引数组项。这引出了一个问题,如果数组项比变量更多怎么办?如果数组项比变量多,那么剩余的数组项将被丢弃,不会被解构为变量。解构是按照数组顺序进行一对一的映射。

如果变量数多于数组项怎么办?如果我们尝试将一个数组解构为一个包含比数据数组中数组元素总数更多变量的数组,那么其中一些变量将被设置为 undefined。数组从左到右进行解构。在 JavaScript 数组中访问不存在的元素将导致返回 undefined 值。这个 undefined 值将保存在变量数组中剩余的变量中。下面的代码展示了这一点:

let names = [ 'John', 'Michael' ];
let [ name1 ] = names
let [ name2, name3, name4 ] = names;
console.log( name1 ); // Expected output: 'John'
console.log( name2 ); // Expected output: 'John'
console.log( name3 ); // Expected output: 'Michael'
console.log( name4 ); // Expected output: undefined
代码段 1.38:具有不匹配变量和数组项的数组解构

注意

我们在解构数组时必须小心,确保我们不会无意中假设变量将包含一个值。如果数组不够长,变量的值可能被设置为 undefined。

ES6 数组解构允许跳过数组元素。如果我们有一个值的数组,并且只关心第一个和第三个值,我们仍然可以解构数组。要忽略一个值,只需要在表达式的左侧省略该数组索引的变量标识符。这种语法可以用来忽略单个项目、多个项目,甚至是数组中的所有项目。以下代码段中展示了两个示例:

let names = [ 'John', 'Michael', 'Jessica', 'Susan' ];
let [ name1,, name3 ] = names;
// Note the missing variable name for the second array item
let [ ,,, ] = names; // Ignores all items in the array
console.log( name1 ); // Expected output: 'John'
console.log( name3 ); // Expected output: 'Jessica'
代码段 1.39:具有跳过值的数组解构

数组解构的另一个非常有用的特性是为使用解构创建的变量设置默认值的能力。当我们想要添加默认值时,我们只需在解构表达式的左侧将变量设置为所需的默认值。如果我们在解构的内容中没有包含一个可分配给变量的索引,那么默认值将被使用。下面的代码展示了这一点:

let [ a = 1, b = 2, c = 3 ] = [ 'cat', null ]; 
console.log( a ); // Expected output: 'cat'
console.log( b ); // Expected output: null
console.log( c ); // Expected output: 3
代码段 1.40:具有跳过值的数组解构

最后,数组解构也可以用于轻松交换变量的值。如果我们希望交换两个变量的值,我们可以简单地将一个数组解构为反向数组。我们可以创建一个包含要反转的变量的数组,并将其设置为相同的数组,但变量顺序改变。这将导致引用被交换。下面的代码展示了这一点:

let a = 10;
let b = 5;
[ a, b ] = [ b, a ];
console.log( a ); // Expected output: 5
console.log( b ); // Expected output: 10
代码段 1.41:具有跳过值的数组解构

练习 8:数组解构

要使用数组解构赋值从数组中提取值,请执行以下步骤:

  1. 创建一个包含三个值123的数组,并将其保存到名为data的变量中。

  2. 对使用单个表达式创建的数组进行解构。

    将第一个数组值解构为名为a的变量。跳过数组的第二个值。

    将第三个值解构为名为b的变量。尝试将第四个值解构为名为c的变量,如果失败则提供默认值4

  3. 记录所有变量的值。

代码

index.js:
const data = [ 1, 2, 3 ];
const [ a, , b, c = 4 ] = data;
console.log( a, b, c );
代码片段 1.42:数组解构

bit.ly/2D2Hm5g

结果

图 1.10:解构变量的输出

图 1.10:解构变量的输出

您已成功应用了数组解构赋值从数组中提取值并保存到变量中。

总之,数组解构允许我们快速从数组中提取值并将其保存到变量中。变量按从左到右的顺序逐个分配给数组值。如果变量的数量超过数组项的数量,则变量将被设置为未定义,或者如果指定了默认值,则将设置为默认值。我们可以通过在变量数组中留下一个空位来跳过解构中的数组索引。最后,我们可以使用解构赋值来快速交换单行代码中两个或多个变量的值。

Rest 和 Spread 运算符

ES6 还为数组引入了两个新的运算符,称为restspread。rest 和 spread 运算符都用三个省略号或句点表示(...array1)。rest 运算符用于表示作为数组的无限个参数。spread 运算符用于允许可迭代的对象扩展为多个参数。要确定使用的是哪个运算符,我们必须查看应用参数的项。如果该运算符应用于可迭代的对象(数组,对象等),则是 spread 运算符。如果该运算符应用于函数参数,则是 rest 运算符。

注意

在 JavaScript 中,如果可以逐个遍历某些内容(通常是值或键/值对),则将其视为可迭代的。例如,数组是可迭代的,因为可以逐个遍历数组中的项。对象也被认为是可迭代的,因为可以逐个遍历键/值对。

rest 运算符用于表示作为数组的无限个参数。将函数的最后一个参数加上三个省略号时,它将成为一个数组。数组元素由传递到函数中的实际参数提供,其中不包括已在函数的正式声明中分配了单独名称的参数。下面的代码示例展示了 rest 解构的示例:

function fn( num1, num2, ...args ) {
  // Destructures an indefinite number of function parameters into the
//array args, excluding the first two arguments passed in.
  console.log( num1 );
  console.log( num2 );
  console.log( args );
}
fn( 1, 2, 3, 4, 5, 6 );
// Expected output
// 1
// 2
// [ 3, 4, 5, 6 ]
代码片段 1.43:带有跳过值的数组解构

类似于 JavaScript 函数的参数对象,剩余运算符包含函数参数的列表。但是,剩余运算符与参数对象有三个明显的不同之处。正如我们已经知道的那样,参数对象是类似数组的对象,其中包含传递给函数的每个参数。不同之处如下。首先,剩余运算符仅包含在函数表达式中没有单独形式声明的输入参数。

第二,arguments 对象不是Array对象的实例。剩余参数是数组的一个实例,这意味着数组函数如sort()map()forEach()可以直接应用于它们。

最后,参数对象具有特殊功能,而剩余参数没有。例如,调用者属性存在于参数对象上。

剩余参数可以类似于我们解构数组的方式进行解构。在省略号之前放置单个变量名的替代方法是,我们可以用要填充的变量数组替换它。传递给函数的参数将按预期解构为数组。这在下面的代码中显示:

function fn( ...[ n1, n2, n3 ] ) {
  // Destructures an indefinite number of function parameters into the
// array args, which is destructured into 3 variables
  console.log( n1, n2, n3 );
}
fn( 1, 2 ); // Expected output: 1, 2, undefined
代码片段 1.44:解构剩余运算符

展开运算符允许可迭代对象(如数组或字符串)扩展为多个参数(用于函数调用)、数组元素(用于数组文字)或键值对(用于对象表达式)。这基本上意味着我们可以将数组扩展为创建另一个数组、对象或调用函数的参数。展开语法的示例如下代码所示:

function fn( n1, n2, n3 ) {
  console.log( n1, n2, n3 );
}
const values = [ 1, 2, 3 ];
fn( ...values ); // Expected output: 1, 2, 3
代码片段 1.45:展开运算符

在前面的示例中,我们创建了一个简单的函数,它接受三个输入并将它们记录到控制台。我们创建了一个包含三个值的数组,然后使用spread运算符调用函数,将值数组解构为函数的三个输入参数。

剩余运算符可以用于解构对象和数组。在解构数组时,如果数组元素多于变量,我们可以使用剩余运算符在解构过程中捕获所有额外的数组元素。在使用剩余运算符时,它必须是数组解构或函数参数列表中的最后一个参数。下面的代码展示了这一点:

const [ n1, n2, n3, ...remaining ] = [ 1, 2, 3, 4, 5, 6 ];
console.log( n1 ); // Expected output: 1
console.log( n2 ); // Expected output: 2
console.log( n3 ); // Expected output: 3
console.log( remaining ); // Expected output: [ 4, 5, 6 ]
代码片段 1.46:展开运算符

在前面的代码片段中,我们将前三个数组元素解构为n1n2n3三个变量。然后,我们使用剩余运算符捕获了剩余的数组元素,并将它们解构为剩下的变量。

总之,rest 和 spread 操作符允许可迭代实体扩展为多个参数。它们在标识符名称之前用三个省略号表示。这使我们可以在函数中捕获参数数组或在解构实体时捕获未使用的项目。当我们使用 rest 和 spread 操作符时,它们必须是传入它们所使用的表达式的最后的参数。

对象解构

对象解构的用法与数组解构非常相似。对象解构用于从对象中提取数据并将数值赋给新变量。在 ES6 中,我们可以在单个 JavaScript 表达式中实现这一点。要解构对象,我们用大括号({})括起要解构的变量,并将该表达式赋值给要解构的对象。对象解构的基本示例如下所示:

const obj = { firstName: 'Bob', lastName: 'Smith' };
const { firstName, lastName } = obj;
console.log( firstName ); // Expected output: 'Bob'
console.log( lastName ); // Expected output: 'Smith'
代码片段 1.47:对象解构

在上面的例子中,我们创建了一个带有firstNamelastName键的对象。然后将这个对象解构为变量firstNamelastName。注意变量的名称和对象参数的名称匹配。如下例所示:

注意

在进行基本对象解构时,对象中的参数名称和我们要分配的变量名称必须匹配。如果变量我们尝试解构的变量没有匹配的参数,那么该变量将被设置为 undefined。

const obj = { firstName: 'Bob', lastName: 'Smith' };
const { firstName, middleName } = obj;
console.log( firstName ); // Expected output: 'Bob'
console.log( middleName ); // Expected output: undefined
代码片段 1.48:没有定义键的对象解构

如我们所见,middleName键不存在于对象中。当我们尝试解构该键并将其保存到变量中时,它无法找到数值,变量将被设置为 undefined。

通过高级对象解构语法,我们可以将被提取的键保存到另一个名称的变量中。这是通过在解构符号后面添加冒号和新变量名称来实现的。这在以下代码中显示:

const obj = { firstName: 'Bob', lastName: 'Smith' };
const { firstName: first, lastName } = obj;
console.log( first ); // Expected output: 'Bob'
console.log( lastName ); // Expected output: 'Smith'
代码片段 1.49:将对象解构为新变量

在上面的例子中,我们可以清楚地看到,我们正在从对象中解构firstname键,并将其保存到新变量 first 中。lastName键正常解构并保存到一个名为lastName的变量中。

与数组解构一样,我们可以解构一个对象并提供默认值。如果提供了默认值,并且我们尝试解构的键不存在于对象中,那么变量将被设置为默认值,而不是 undefined。如下代码所示:

const obj = { firstName: 'Bob', lastName: 'Smith' };
const { firstName = 'Samantha', middleName = 'Chris' } = obj;
console.log( firstName ); // Expected output: 'Bob'
console.log( middleName ); // Expected output: 'Chris'
代码片段 1.50:带默认值的对象解构

在上面的示例中,我们对尝试从对象解构的变量设置了默认值。指定了 firstName 的默认值,但对象中存在 firstName 键。这意味着解构并忽略了默认值中存储的 firstName 键的值。对象中不存在 middleName 键,并且我们指定了在解构时使用的默认值。解构赋值将解构变量设置为默认值 Chris,而不是使用 firstName 键的未定义值。

当我们提供默认值并将键赋值给新变量名时,我们必须在新变量名后放置默认值赋值。下面的示例展示了这一点:

const obj = { firstName: 'Bob', lastName: 'Smith' };
const { firstName: first = 'Samantha', middleName: middle = 'Chris' } = obj;
console.log( first ); // Expected output: 'Bob'
console.log( middle); // Expected output: 'Chris'
代码片段 1.51:对象解构为具有默认值的新变量

firstName 键存在。obj.firstName 的值保存到名为 first 的新变量中。middleName 键不存在。这意味着新变量 middle 被创建并设置为默认值 Chris

练习 9:对象解构

使用对象解构的概念从对象中提取数据,执行以下步骤:

  1. 创建一个具有字段 f1f2f3 的对象。将值分别设置为 v1v2v3。将对象保存到变量 data 中。

  2. 使用单个语句将此对象解构为变量,如下所示:

    f1 属性解构为名为 f1 的变量。将 f2 属性解构为名为 field2 的变量。将属性 f4 解构为名为 f4 的变量,并提供默认值 v4

  3. 记录创建的变量。

代码

index.js:
const data = { f1: 'v1', f2: '2', f3: 'v3' };
const { f1, f2: field2, f4 = 'v4' } = data;
console.log( f1, field2, f4 );
代码片段 1.52:对象解构

bit.ly/2SJUba9

结果

图 1.11:创建变量的输出

图 1.11:创建变量的输出

您已成功应用了对象解构的概念,从对象中提取数据。

如果我们在对象解构表达式之前声明变量,JavaScript 需要特殊的语法。我们必须用括号括起整个对象解构表达式。数组解构不需要这样的语法。下面的代码展示了这一点:

const obj = { firstName: 'Bob', lastName: 'Smith' };
let firstName, lastName;
( { firstName: first, lastName } = obj );
// Note parentheses around expression
console.log( firstName ); // Expected output: 'Bob'
console.log( lastName ); // Expected output: 'Smith'
代码片段 1.53:对象解构为预定义变量

提示

确保以这种方式完成的对象解构在相同或前一行的分号之前。这可以防止 JavaScript 解释器将括号解释为函数调用。

剩余运算符也可以用于解构对象。由于对象键是可迭代的,我们可以使用剩余运算符来捕获原始解构表达式中未捕获的剩余键。这与数组类似。我们解构要捕获的键,然后我们可以将剩余运算符添加到一个变量中,并捕获未从对象中解构出来的剩余键/值对。这在下面的示例中显示:

const obj = { firstName: 'Bob', middleName: 'Chris', lastName: 'Smith' };
const { firstName, ...otherNames } = obj;
console.log( firstName ); // Expected output: 'Bob'
console.log( otherNames );
// Expected output: { middleName: 'Chris', lastName: 'Smith' }
代码片段 1.54: 带有剩余运算符的对象解构

总之,对象解构允许我们快速从对象中提取值并将其保存到变量中。关键名称必须与简单对象解构中的变量名称匹配,然而,我们可以使用更高级的语法将键的值保存到一个新对象中。如果在对象中未定义键,则变量将设置为false,除非我们为其提供默认值。我们可以将此保存到预定义的变量中,但是我们必须用括号将解构表达式括起来。最后,剩余运算符可以用于捕获剩余的键值对,并将它们保存在一个新对象中。

对象和数组的解构支持嵌套。嵌套解构可能有点令人困惑,但它是一个强大的工具,因为它允许我们将几行解构代码压缩成一行。

练习 10:嵌套解构

要使用嵌套解构概念从嵌套在对象内的数组中解构值,执行以下步骤:

  1. 创建一个带有属性arr的对象,即设置为包含值123的数组。将对象保存到变量data中。

  2. 将数组的第二个值解构为一个变量, 执行以下操作:

    从对象中解构arr属性,并将其保存到一个名为v2的新变量中,该变量为数组。用数组解构替换v2

    在数组解构中,跳过第一个元素。将第二个元素保存到一个名为v2的变量中。

  3. 记录变量。

代码

index.js:
const data = { arr: [ 1, 2, 3 ] };
const { arr: [ , v2 ] } = data;
console.log( v2 ); 
代码片段 1.55: 嵌套数组和对象解构

bit.ly/2SJUba9

结果

图 1.12:嵌套解构输出

图 1.12:嵌套解构输出

您已成功地从对象内的数组中解构了值。

总之,对象和数组的解构是为了缩减代码,允许快速从对象和数组创建变量而引入到 ES6 中的。数组解构通过将一组变量设置为一组项目来表示。对象解构通过将一组变量设置为一组键值对的对象来表示。解构语句可以嵌套以获得更大的效果。

练习 11:实现解构

您已经注册了大学课程,并需要购买课程所需的教材。 您正在构建一个程序,以从书单中抓取数据,并获取每本所需教材的 ISBN 号码。 使用对象和数组嵌套解构来获取课程数组中第一本书的第一本书的 ISBN 值。 课程数组遵循以下格式:

[
 {
   title: 'Linear Algebra II',
   description: 'Advanced linear algebra.',
   texts: [ {
     author: 'James Smith',
     price: 120,
     ISBN: '912-6-44-578441-0'
   } ]
 },
 { ... },
 { ... }
]
Snippet 1.56: 课程数组格式

通过使用嵌套解构来从复杂的数组和对象嵌套中获取数据,执行以下步骤:

  1. 将提供的数据结构保存到courseCatalogMetadata变量中。

  2. 将第一个数组元素解构为名为course的变量:

    [ course ] = [ … ]
    ```

1.  用对象解构替换`course`变量以将文本字段保存到名为`textbooks`的变量中:

[ { texts: textbooks} ] = [ … ]
```
  1. 用数组解构替换textbooks变量以获取文本数组的第一个元素并将其保存到名为textbook的变量中:
    [ { texts: [ textbook ] } ] = [ … ]
    ```

1.  用对象解构替换`textbook`变量以获取`ISBN`字段并将其保存到`ISBN`变量中:

[ { texts: [ { ISBN } ] } ] = [ … ]
```
  1. 记录ISBN的值。

代码

index.js:
const courseCatalogMetadata = [
 {
   title: 'Linear Algebra II',
   description: 'Advanced linear algebra.',
   texts: [ {
     author: 'James Smith',
     price: 120,
     ISBN: '912-6-44-578441-0'
   } ]
 }
];
const [ course ] = courseCatalogMetadata;
const [ { texts: textbooks } ] = courseCatalogMetadata;
const [ { texts: [ textbook ] } ] = courseCatalogMetadata;
const [ { texts: [ { ISBN } ] } ] = courseCatalogMetadata;
console.log( course );
console.log( textbooks );
console.log( textbook );
console.log( ISBN );
Snippet 1.57: 实现解构到代码中

bit.ly/2TMlgtz

结果

图 1.13:数组解构输出

图 1.13:数组解构输出

您已成功使用解构和嵌套解构从数组和对象中获取了数据。

在本节中,我们讨论了数组和对象的解构赋值。 我们演示了如何使用数组和对象的解构赋值简化代码,并允许我们快速从对象和数组中提取值。 解构赋值允许我们从对象和数组中解包值,提供默认值,并在解构时将对象属性重命名为变量。 我们还介绍了两个新操作符——剩余和展开操作符。 剩余运算符用于表示数组的不定数量的参数。 展开运算符用于将可迭代对象分解为多个参数。

类和模块

在 ES6 中添加了类和模块。 类作为一种扩展基于原型的继承的方式,并添加了一些面向对象的概念。 模块作为一种组织 JavaScript 中多个代码文件的方式,并扩展了代码的可重用性和文件之间的作用域。

主要作为语法糖添加到 ECMAScript 6 中,以扩展现有基于原型的继承结构。 类语法不会向 JavaScript 引入面向对象的继承。 JavaScript 中的类继承不像面向对象语言中的类那样工作。

在 JavaScript 中,可以使用关键字 class 来定义一个类。 使用关键字 class,后跟类名和大括号来创建一个类。 在大括号内,我们定义类的所有函数和逻辑。 语法如下:

class name { /* class stuff goes here */ }
Snippet 1.58: 类的语法

一个类可以用可选函数构造函数来创建。构造函数如果对 JavaScript 类不是必需的,但是一个类中只能有一个名为构造函数的方法。当实例化类时,会调用构造函数,并可用于设置所有默认的内部值。以下代码显示了一个类声明的示例:

class House{
  constructor(address, floors = 1, garage = false) {
    this.address = address;
    this.floors = floors;
    this.garage = garage;
  }
}
代码段 1.59:基本类创建

在这个示例中,我们创建了一个名为House的类。我们的House类有一个constructor方法。当我们实例化类时,它调用构造函数。我们的构造函数方法接受三个参数,其中两个具有默认值。构造函数将这些值保存到this作用域中的变量中。

关键字 this 映射到每个类实例化。它是一个全局作用域的类对象。它用于在类内全局作用域中为所有函数和变量划定范围。在类的根部添加的每个函数都将添加到this作用域中。添加到this作用域的所有变量在类内任何函数中都可访问。此外,添加到this作用域的任何内容对于类外部是公开可访问的。

练习 12:创建自己的类

要创建一个简单的类并演示内部类变量,执行以下步骤:

  1. 声明一个名为Vehicle的类。

  2. 向类添加一个构造函数。使构造函数接收两个变量,wheelstopSpeed

  3. 在构造函数中,将输入变量保存到this作用域中的两个变量中,即this.wheelsthis.topSpeed

  4. wheels = 3topSpeed = 20实例化该类,并将其保存到tricycle变量中。

  5. 从保存在tricycle中的类中记录wheelstopSpeed的值。

代码

index.js:
class Vehicle {
  constructor( wheels, topSpeed ) {
    this.wheels = wheels;
    this.topSpeed = topSpeed;
  }
}
const tricycle = new Vehicle( 3, 20 );
console.log( tricycle.wheels, tricycle.topSpeed );
代码段 1.60:创建一个类

bit.ly/2FrpL8X

结果

图 1.14:创建类的输出

图 1.14:创建类的输出

您已成功创建了一个具有数值的简单类。

我们使用 new 关键字实例化了一个新类的实例。要创建一个新的类,只需声明一个变量并将其设置为表达式new className()。当我们实例化一个新类时,传递给类调用的参数将传递到构造函数中,如果存在的话。以下代码显示了一个类实例化的示例:

class House{
  constructor(address, floors = 1) {
    this.address = address;
    this.floors = floors;
  }
}
// Instantiate the class
let myHouse = new House( '1100 Fake St., San Francisco CA, USA', 2, false );
代码段 1.61:类实例化

在此示例中,类的实例化发生在带有新关键字的行上。此行代码会创建House类的新实例并将其保存到myHouse变量中。当我们实例化类时,我们提供了addressfloorsgarage的参数。这些值被传递到构造函数中,然后保存到实例化的类对象中。

要向类中添加函数,我们使用新的 ES6 对象函数声明。快速提醒,当使用新的 ES6 对象函数声明时,可以省略函数关键字和对象键名。当函数添加到对象中时,它会自动附加到this范围内。此外,添加到类的所有函数都可以访问this范围,并能够调用附加到this范围的任何函数和访问任何变量。下面是一个示例:

class House{
  constructor( address, floors = 1) {
    this.address = address;
    this.floors = floors;
  }
  getFloors() {
    return this.floors;
  }
}
let myHouse = new House( '1100 Fake St., San Francisco CA, USA', 2 );
console.log( myHouse.getFloors() ); // Expected output: 2
代码片段 1.62:创建带有函数的类

从这个例子中,我们可以看到两个函数getFloorssetFloors是使用 ES6 增强的对象属性语法添加的。这两个函数都可以访问this范围内的变量。它们可以获取和设置该范围内的变量,以及调用附加到this范围内的函数。

在 ES6 中,我们还可以使用extends关键字创建子类。子类继承自父类的属性和方法。子类的定义方式是在类名后面加上关键字extends和父类的名称。下面是一个子类声明的示例:

class House {}
class Mansion extends House {}
代码片段 1.63:扩展类

类 - 子类

在这个例子中,我们将创建一个名为House的类,然后创建一个名为Mansion的子类,它扩展了类House。当我们创建一个子类时,我们需要注意构造方法的行为。如果我们提供了构造方法,那么我们必须调用super()函数。super是一个调用父对象的构造函数的函数。如果我们试图在不调用super的情况下访问this范围,那么我们将得到一个运行时错误,我们的代码将崩溃。可以将父构造函数所需的任何参数通过super方法传递进去。如果我们没有为子类指定构造函数,则默认的构造函数行为将自动调用 super 构造函数。下面是一个示例:

class House {
  constructor( address = 'somewhere' ) {
    this.address = address;
  }
}
class Mansion extends House {
  constructor( address, floors ) {
    super( address );
    this.floors = floors;
  }
}
let mansion = new Mansion( 'Hollywood CA, USA', 6, 'Brad Pitt' );
console.log( mansion.floors ); // Expected output: 6
代码片段 1.64:带有和不带有构造函数的类的扩展

在这个例子中,我们创建了一个扩展了我们的House类的子类。Mansion子类有一个已定义的构造函数,所以我们必须在访问this范围之前调用 super。当我们调用super时,我们将地址参数传递给父构造函数,父构造函数会将其添加到this范围内。然后Mansion的构造函数继续执行并将楼层变量添加到this范围内。正如我们从此示例末尾的输出日志中看到的那样,子类的this范围还包括父类中创建的所有变量和函数。如果在子类中重新定义变量或函数,它将覆盖父类继承的值或函数。

总之,类使我们能够通过引入一些面向对象的概念来扩展 JavaScript 的基于原型的继承。类使用关键字class定义,并使用关键字new初始化。类定义时,会创建一个特殊的作用域,称为this,用于公开访问类外部的所有项目。我们可以将函数和变量添加到this作用域中,以赋予我们的类功能。当实例化类时,会调用构造函数。我们还可以扩展类以创建子类,使用关键字extends。如果扩展的类有一个构造函数,则必须调用 super 函数来调用其父类构造函数。子类可以访问父类的方法和变量。

模块

几乎每种编程语言都有模块的概念。模块是一种允许程序员将代码分解为更小的独立部分、并能够导入和重用的功能。模块对程序的设计至关重要,用于防止代码重复并减小文件大小。在 ES6 之前,原始 JavaScript 中并不存在模块。而且,并非所有 JavaScript 解释器都支持这一特性。

模块是从当前文件引用其他代码文件的一种方式。代码可以分成多个部分,称为模块。模块可以让我们将不相关的代码分开,这样我们在大型 JavaScript 项目中就可以拥有更小、更简单的文件。

模块还允许包含的代码快速、轻松地共享,而不会出现任何代码重复。ES6 中的模块引入了两个新关键字,exportimport。这些关键字允许我们在加载文件时公开特定的类和变量。

注意

JavaScript 模块在所有平台上都没有完全支持。在编写本书时,并非所有 JavaScript 框架都能支持模块。确保您发布代码的平台能够支持您编写的代码。

导出关键字

模块使用export关键字来公开文件中包含的变量和函数。ES6 模块中的所有内容默认都是私有的。唯一使任何内容公开的方式是使用导出关键字。模块可以通过具名导出默认导出方式导出属性。具名导出允许模块多次导出。如果正在构建一个导出许多函数和常量的数学模块,则多次导出可能会很有用。默认导出则允许每个模型只有一个单一的导出。如果正在构建一个包含一个单一类的模块,则单一的导出可能会很有用。

使用export关键字公开模块的具名内容有两种方式。我们可以通过在变量或函数声明之前加上export关键字来逐个导出每个项目,或者我们可以导出一个包含键值对的对象,引用我们想要导出的每个变量和函数。这两种导出方法在以下示例中显示:

// math-module-1.js
export const PI = 3.1415;
export const DEGREES_IN_CIRCLE = 360;
export function convertDegToRad( degrees ) {
  return degrees * PI / ( DEGREES_IN_CIRCLE /2 );
}
// math-module-2.js
const PI = 3.1415;
const DEGREES_IN_CIRCLE = 360;
function convertDegToRad( degrees ) {
  return degrees * PI / ( DEGREES_IN_CIRCLE /2 );
}
export { PI, DEGREES_IN_CIRCLE, convertDegToRad };
代码片段 1.65:命名导出

在前面的示例中概述的两个模块中,每个模块都导出三个常量变量和一个函数。第一个模块math-module-1.js逐个导出每个项目。第二个模块math-module-2.js通过对象一次性导出所有导出项。

要将模块的内容作为默认导出,我们必须使用default 关键字default关键字在export关键字之后。当我们默认导出一个模块时,我们也可以省略正在导出的类、函数或变量的标识符名称。下面的代码示例中演示了这个例子:

// HouseClass.js
export default class() { /* Class body goes here */ }
// myFunction.js
export default function() { /* Function body goes here */ }
代码片段 1.66:默认导出

在前面的示例中,我们创建了两个模块。一个模块导出一个类,另一个导出一个函数。请注意在export关键字后加入default关键字,以及如何省略类/函数的名称。当我们导出一个默认类时,export是无名的。当我们导入默认导出模块时,我们导入的对象名称是通过模块的名称派生的。下一节将展示这一点,在那里我们将讨论import关键字。

导入关键字

import关键字允许您导入 JavaScript 模块。导入模块允许您将该模块中的任何项导入到当前的代码文件中。当我们导入一个模块时,我们以import关键字开始表达式。然后,我们确定要从模块中导入的部分。然后,我们跟着from关键字,最后完成模块文件的路径。from关键字和文件路径告诉解释器在哪里找到我们要导入的模块。

注意

ES6 模块可能在所有浏览器版本或 Node.js 版本中都不受全面支持。您可能需要使用诸如 Babel 之类的转译器来在某些平台上运行您的代码。

我们可以使用import关键字的四种方式,所有这些方式都在以下代码中展示:

// math-module.js
export const PI = 3.1415;
export const DEGREES_IN_CIRCLE = 360;
// index1.js
import { PI } from 'math-module.js'
// index2.js
import { PI, DEGREES_IN_CIRCLE } from 'math-module.js'
// index3.js
import { PI as pi, DEGREES_IN_CIRCLE as degInCircle } from 'math-module.js'
// index4.js
import * as MathModule from 'math-module.js'
代码片段 1.67:导入模块的不同方式

在上面代码中展示的代码中,我们创建了一个简单的模块,导出了几个常量和四个导入示例文件。在第一个import示例中,我们从模块导出中导入一个单个值,并使其在变量 API 中可以访问。在第二个import示例中,我们从模块中导入多个属性。在第三个示例中,我们导入属性并将它们重命名为新的变量名。然后可以从新变量中访问这些属性。在第四个示例中,我们使用了略有不同的语法。星号表示我们要从模块中导入所有导出的属性。当我们使用星号时,我们还必须使用as关键字给导入的对象赋予一个变量名。

导入和使用模块的过程通过以下代码片段更好地进行解释:

// email-callback-api.js
export function authenticate( … ){ … }
export function sendEmail( … ){ … }
export function listEmails( … ){ … }
// app.js
import * as EmailAPI from 'email-callback-api.js';
const credentials = { password: '****', user: 'Zach' };
EmailAPI.authenticate( credentials, () => {
  EmailAPI.send( { to: 'ceo@google.com', subject: 'promotion', body: 'Please promote me' }, () => {} );'
} );
代码片段 1.68:导入模块

要在浏览器中使用导入,我们必须使用script标记。模块导入可以内联完成,也可以通过源文件完成。要导入一个模块,我们需要创建一个script标记并将 type 属性设置为module。如果我们通过源文件进行导入,我们必须将src属性设置为文件路径。下面的语法展示了这一点:

<script type="module" src="img/module.js"></script>
代码片段 1.69:内联浏览器导入

注意

脚本标记是一个 HTML 标记,允许我们在浏览器中运行 JavaScript 代码。

我们还可以内联导入模块。要做到这一点,我们必须省略src属性,并直接在脚本标记的主体中编写导入。下面的代码展示了这一点:

<script type="module">
  import * as ModuleExample from './path/to/module.js';
</script>
代码片段 1.70:在脚本主体中导入浏览器

注意

在浏览器中导入模块时,不支持 ES6 模块的浏览器版本不会运行 type="module"的脚本。

如果浏览器不支持 ES6 模块,我们可以使用nomodule属性提供一个回退选项。模块兼容的浏览器会忽略带有nomodule属性的脚本标记,因此我们可以使用它来提供回退支持。下面的代码展示了这一点:

<script type="module" src="img/es6-module-supported.js"></script>
<script nomodule src="img/es6-module-NOT-supported.js"></script>
代码片段 1.71:兼容选项的浏览器导入

在前面的例子中,如果浏览器支持模块,那么第一个脚本标记将被运行,第二个则不会。如果浏览器不支持模块,那么第一个脚本标记将被忽略,第二个将被运行。

模块的最后一个考虑: 要小心构建的任何模块不要有循环依赖。由于模块的加载顺序,JavaScript 中的循环依赖可能在 ES6 转译为 ES5 时导致许多逻辑错误。如果你的模块存在循环依赖,你应该重构你的依赖树,以便所有的依赖都是线性的。例如,考虑依赖链: 模块 A 依赖于 B,模块 B 依赖于 C,模块 C 依赖于 A。这是一个循环模块链,因为通过依赖链,A 依赖于 C,C 依赖于 A。代码应该重新构造,以打破循环依赖链。

练习 13:实现类

你被一家汽车销售公司聘用,设计他们的销售网站。你必须创建一个车辆类来存储汽车信息。类必须接受汽车制造商、型号、年份和颜色。汽车应该有一个更改颜色的方法。为了测试这个类,创建一个灰色(颜色)2005(年份)斯巴鲁(制造商)Outback(型号)的实例。记录汽车的变量,更改汽车的颜色,并记录新的颜色。

要构建一个功能类来展示一个类的能力,执行以下步骤:

  1. 创建一个car类。

    添加一个构造函数,它接受makemodelyearcolor。在构造函数中的内部变量(this范围)中保存makemodelyearcolor

    添加一个名为setColor的函数,它接受一个参数 color,并更新内部变量color为提供的颜色。

  2. 用参数SubaruOutback2005Grey来实例化这个类。将这个类保存在Subaru变量中。

  3. 记录在Subaru中存储的类的内部变量,即makemodelyearcolor

  4. Subaru类方法的setColor改变颜色。将颜色设置为Red

  5. 记录新的颜色。

代码

index.js:
class Car {
 constructor( make, model, year, color ) {
   this.make = make;
   this.model = model;
   this.year = year;
   this.color = color;
 }
 setColor( color ) {
   this.color = color;
 }
}
let subaru = new Car( 'Subaru', 'Outback', 2005, 'Grey' );
subaru.setColor( 'Red' );
代码片段 1.72:完整的类实现

bit.ly/2FmaVRS

结果

图 1.15:实现类的输出

图 1.15:实现类的输出

你已经成功构建了一个功能性的类。

在这一部分,我们介绍了 JavaScript 类和 ES6 模块。我们讨论了基于原型的继承结构,并演示了类的基本创建和 JavaScript 类继承的基础知识。在讨论模块时,我们首先展示了如何创建一个模块并导出其中存储的函数和变量。然后,我们展示了如何加载一个模块并导入其中包含的数据。我们以讨论浏览器兼容性并提供支持尚不支持 ES6 模块的浏览器的 HTML 脚本标签选项来结束这个话题。

转译

转译被定义为源到源的编译。已经写了工具来做这件事,它们被称为转译器。转译器接受源代码并将其转换成另一种语言。转译器的重要性有两个原因。首先,不是每个浏览器都支持 ES6 中的每种新语法,其次,许多开发者使用基于 JavaScript 的编程语言,比如 CoffeeScript 或 TypeScript。

注释

ES6 兼容性表可以在kangax.github.io/compat-table/es6/找到。

查看 ES6 浏览器兼容性表清楚地告诉我们在支持上存在一些漏洞。转译器允许我们用 ES6 编写我们的代码并将其转换成普通的 ES5,在每个浏览器中都可以运行。确保我们的代码在尽可能多的 Web 平台上正常工作至关重要。对于确保兼容性,转译器可以是一个非常有用的工具。

转译器还允许我们用其他编程语言开发 Web 或服务器端应用程序。像 TypeScript 和 CoffeeScript 这样的语言可能无法在浏览器中原生运行;然而,通过转译器,我们可以用这些语言构建完整的应用程序,并将它们转换成 JavaScript 以便在服务器端或浏览器中执行。

JavaScript 最流行的转译器之一是Babel。Babel 是一个旨在协助不同版本 JavaScript 之间的转译的工具。Babel 可以通过 node 包管理器(npm)安装。首先,打开你的终端并进入包含 JavaScript 项目的文件夹。

如果在这个目录中没有package.json文件,那么我们必须创建它。可以使用npm init命令完成。命令行界面将询问您输入几个条目,以便您填写package.json文件的默认值。您可以输入这些值,也可以直接按回车键接受默认值。

要安装 Babel 命令行界面,使用以下命令:npm install --save-dev babel-cli。完成后,package.json文件的devDependencies对象中将会添加babel-cli字段:

{
 "devDependencies": {
   "babel-cli": "^6.26.0"
 }
}
片段 1.73:添加第一个依赖

这个命令只安装了基本的 Babel,没有用于在不同版本的 JavaScript 之间进行转译的插件。要安装插件以转译到 ECMAScript 2015,使用命令npm install --save-dev babel-preset-es2015。一旦命令运行完毕,我们的package.json文件将包含另一个依赖:

"devDependencies": {
 "babel-cli": "^6.26.0",
 "babel-preset-es2015": "^6.24.1"
}
片段 1.74:添加第二个依赖

这安装了 ES6 预设。要使用这些预设,我们必须告诉 Babel 使用这些预设进行配置。创建一个名为.babelrc的文件。注意文件名中的前导句号。.babelrc文件是 Babel 的配置文件。这是我们告诉 Babel 我们将使用哪些预设、插件等的地方。创建完成后,在文件中添加以下内容:

{
  "presets": ["es2015"]
}
片段 1.75:安装 ES6 预设

Babel-转译

现在 Babel 已经配置好了,我们必须创建要转译的代码文件。在项目的根目录中,创建一个名为app.js的文件。在这个文件中,粘贴以下 ES6 代码:

const sum5 = inputNumber  => inputNumber + 5;
console.log( `The sum of 5 and 5 is ${sum5(5)}!`);
片段 1.76:粘贴代码

现在 Babel 已经配置好了,我们有了一个要转译的文件,我们需要更新我们的package.json文件,为 npm 添加一个转译脚本。在package.json文件中添加以下行:

"scripts": {
 "transpile": "babel app.js --out-file app.transpiled.js --source-maps"
}
片段 1.77:更新 package.json 文件

脚本对象允许我们从 npm 运行这些命令。我们将命名 npm 脚本为transpile,它将运行命令链babel app.js --out-file app.transpiled.js --source-mapsApp.js是我们的输入文件。--out-file命令指定了编译的输出文件。App.transpiled.js是我们的输出文件。最后,--source-maps创建了一个源映射文件。这个文件告诉浏览器转译代码的哪一行对应原始源代码的哪几行。这让我们能够直接在原始源文件app.js中进行调试。

现在一切都设置好了,我们可以通过在终端窗口输入npm run transpile来运行我们的转译脚本。这将把我们的代码从app.js转译成app.transpiled.js,根据需要创建或更新文件。检查后,我们可以看到app.transpiled.js中的代码已转换为 ES5 格式。您可以在两个文件中运行代码,看到输出是一样的。

Babel 有许多插件和不同模块和 JavaScript 发布的预设。有足够的方法设置和运行 Babel,我可以写一整本关于它的书。这只是将 ES6 代码转换为 ES5 的一个小预览。要获取有关 Babel 的完整文档和每个插件用途的信息,请访问文档。

注意

查看 Babel 的主页 babeljs.io

总之,转译器允许你做源码到源码的编译。这非常有用,因为它让我们在需要部署在尚不支持 ES6 的平台上时将 ES6 代码编译为 ES5。最受欢迎和最强大的 JavaScript 转译器是 Babel。可以在命令行上设置 Babel 来允许我们使用不同版本的 JavaScript 构建整个项目。

练习 14: 转译 ES6 代码

你的办公室团队用 ES6 编写了你的网站代码,但一些用户正在使用的设备不支持 ES6. 这意味着你必须要么用 ES5 重写整个代码库,要么使用转译器将其转换为 ES5. 将升级箭头函数部分中的 ES6 代码转换为 ES5 并通过 Babel 运行原始代码和转译后的代码并比较输出。

为了演示 Babel 将 ES6 代码转换为 ES5 的能力,请执行以下步骤:

在开始之前,请确保 Node.js 已经安装。

  1. 如果尚未安装 Node.js,请安装它。

  2. 使用命令行命令 npm init 设置一个 Node.js 项目。

  3. 升级箭头函数部分的代码放入 app.js 文件。

  4. npm install 安装 Babel 和 Babel ES6 插件。

  5. 通过添加一个带有 es2015 预设的 .babelrc 文件来配置 Babel。

  6. package.json 中添加一个调用 Babel 并从 app.js 转译到 app.transpiled.js 的转译脚本。

  7. 运行转译脚本。

  8. 运行 app.transpiled.js 中的代码。

Code

package.json:
// File 1: package.json
{
 "scripts": {
   "transpile": "babel ./app.js --out-file app.transpiled.js --source-maps"
 },
 "devDependencies": {
   "babel-cli": "^6.26.0",
   "babel-preset-es2015": "^6.24.1"
 }
}
Snippet 1.78: Package.json 配置文件

bit.ly/2FsjzgD

.babelrc:
// File 2: .babelrc
{ "presets": ["es2015"] }
Snippet 1.79: Babel 配置文件

bit.ly/2RMYWSW

app.transpiled.js:
// File 3: app.transpiled.js
var fn1 = function fn1(a, b) { … };
var fn2 = function fn2(a, b) { … };
var fn3 = function fn3(a) { … };
var fn4 = function fn4() { … };
var fn5 = function fn5(a) { … };
Snippet 1.80: 完全转译的代码

bit.ly/2TLhuR7

Outcome

图 1.16: 转译后的脚本输出

图 1.16: 转译后的脚本输出

你已成功实现了 Babel 将代码从 ES6 转换为 ES5 的能力。

在本节中,我们讨论了转译的概念。我们介绍了转译器 Babel,并讨论了如何安装 Babel。我们讨论了设置 Babel 将 ES6 转译为 ES5 兼容代码的基本步骤,并在活动中构建了一个简单的 Node.js 项目,其中包含 ES6 代码来测试 Babel。

迭代器和生成器

迭代器生成器 的最简形式,都是处理集合数据的两种渐进式方式。它们通过跟踪集合的状态而不是集合中的所有项目来提高效率。

迭代器

迭代器是遍历集合中数据的一种方式。遍历数据结构意味着按顺序遍历每个元素。例如,for/in循环是用于遍历 JavaScript 对象中键的方法。当迭代器知道如何从集合中一次访问其项目时,它就是一个迭代器,同时跟踪位置和完成状态。迭代器可用于遍历自定义复杂数据结构或用于遍历可能一次加载不太实际的大数据块。

要创建一个迭代器,我们必须定义一个以集合为参数的函数,并返回一个对象。返回的对象必须具有一个名为next的函数属性。当调用next时,迭代器将跳到集合中的下一个值,并返回一个具有值和迭代状态的对象。以下是示例迭代器的代码:

function createIterator( array ){
  let currentIndex = 0;
  return {
    next(){
      return currentIndex < array.length ?
        { value: array[ currentIndex++ ], done: false} :
        { done: true };
    }
  };
}
代码段 1.81:迭代器声明

此迭代器接受一个数组,并返回一个具有单个函数属性next的对象。在内部,迭代器跟踪数组和我们当前正在查看的索引。要使用迭代器,我们只需调用next函数。调用next将导致迭代器返回一个对象,并将内部索引增加一。迭代器返回的对象必须至少具有valuedone两个属性。value将包含我们当前查看索引处的值。Done将包含一个布尔值。如果布尔值为 true,则我们已经输入集合上完成了遍历。如果为,那么我们可以继续调用next函数:

// Using an iterator 
let it = createIterator( [ 'Hello', 'World' ] );
console.log( it.next() );
// Expected output: { value: 'Hello', done: false }
console.log( it.next() );
// Expected output: { value: 'World' , done: false }
console.log( it.next() );
// Expected output: { value: undefined, done: true }
代码段 1.82:迭代器使用

注意

当迭代器的finality属性为真时,不应返回任何新数据。为了演示iterator.next()的使用,你可以提供前面代码段中的示例。

总之,迭代器为我们提供了一种遍历可能复杂的数据集合的方法。迭代器跟踪其当前状态,每次调用iterator.next()函数时,它都会提供一个具有值和完成状态布尔值的对象。当迭代器到达集合的末尾时,调用iterator.next()将返回一个真值完成参数,并且将不再接收新值。

生成器

生成器提供了一种迭代构建数据集合的方法。生成器可以一次返回一个值,同时暂停执行,直到请求下一个值。生成器跟踪内部状态,每次请求时,它都会返回序列中的新数字。

要创建一个生成器,我们必须在函数名前面加上星号,并在函数体中使用yield关键字。例如,要创建名为testGenerator的生成器,我们可以按如下方式初始化它:

function *testGen( data ) { yield 0; }.

星号表示这是一个生成器函数yield关键字表示正常函数流程的中断,直到生成器函数再次被调用。下面是一个生成器的示例:

function *gen() {
 let i = 0;
 while (true){
   yield i++;
 }
}
代码段 1.83:生成器创建

我们在前面的代码段中创建的这个生成器函数,称为gen,有一个名为i的内部状态变量。当创建生成器时,它会自动初始化一个内部的 next 函数。当第一次调用next函数时,执行开始,循环开始,当执行到yield关键字时,函数的执行被停止,直到再次调用 next 函数。当调用next函数时,程序将返回一个包含值和done的对象。

练习 15:创建一个生成器

创建一个生成器函数,生成 2n 序列的值,以展示生成器如何构建一组连续的数据,执行以下步骤:

  1. 创建一个名为gen生成器

    在标识符名称前面加上一个星号。

  2. 在生成器主体内部,执行以下步骤:

    创建一个名为i的变量,将初始值设为 1。然后,创建一个无限循环。

    在 while 循环体中,使用yield i,并将i设置为i * 2

  3. 初始化gen并将其保存到名为generator的变量中

  4. 多次调用你的生成器并记录输出,以查看值的变化。

代码

index.js:
function *gen() {
 let i = 1;
 while (true){
   yield i;
   i = i * 2;
 }
}
const generator = gen();
console.log( generator.next(), generator.next(), generator.next() );
代码段 1.84:简单生成器

bit.ly/2VK7M3d

结果

图 1.17:调用生成器输出

图 1.17:调用生成器输出

你已成功创建了一个生成器函数。

与迭代器类似,done值包含生成器的完成状态。如果done值设置为true,那么生成器已经执行完毕,不会再返回新的值。值参数包含了yield关键字所在行的表达式的结果。在这种情况下,它将返回i的当前值,然后再递增。下面的代码中展示了这一点:

let sequence = gen();
console.log(sequence.next());
//Expected output: { value: 0, done: false }
console.log(sequence.next());
//Expected output: { value: 1, done: false }
console.log(sequence.next());
//Expected output: { value: 2, done: false }
代码段 1.85:生成器使用

当生成器遇到yield关键字时,执行会暂停。这意味着循环会暂停执行。生成器的另一个强大工具是可以通过 next 函数和yield关键字传入数据。当将一个值传递给 next 函数时,yield表达式的返回值将被设置为传递给 next 的值。下面的代码展示了一个例子:

function *gen() {
 let i = 0;
 while (true){
   let inData = yield i++;
   console.log( inData );
 }
}
let sequence = gen();
sequence.next()
sequence.next( 'test1' )
sequence.next()
sequence.next( 'test2' )
// Expected output:
// 'test1'
// undefined
// 'test2'
代码段 1.86 Yield 关键字

总之,生成器是构建数据集的迭代方式。它们一次返回一个值,同时跟踪内部状态。当达到yield关键字时,内部执行停止并返回一个值。当调用next函数时,执行恢复,直到达到yield。数据可以通过next函数传递给生成器。通过yield表达式返回传入的数据。当生成器发出一个值对象,并将done参数设置为 true 时,对generator.next()的调用不应产生任何新的值。

在最后一个主题 I 中,我们介绍了迭代器和生成器。迭代器遍历数据集合中的数据,并在每一步返回请求的值。一旦它们到达集合的末尾,done标志将设置为 true,并且不会再迭代新的项目。生成器是一种生成数据集合的方法。在每一步中,生成器根据其内部状态产生一个新值。迭代器和生成器都在它们的生命周期中跟踪它们的内部状态。

活动 1:实现生成器

您被要求构建一个简单的应用程序,根据请求生成斐波那契数列中的数字。该应用程序为每个请求生成序列中的下一个数字,并在给定输入时重置序列。使用生成器生成斐波那契数列。如果将一个值传递给生成器,则重置序列。

使用生成器构建复杂的迭代数据集,执行以下步骤:

  1. 查找斐波那契数列。

  2. 创建一个生成器,提供斐波那契数列中的值。

  3. 如果生成器的yield语句返回一个值,则重置序列。

结果

图 1.18:实现生成器输出

图 1.18:实现生成器输出

您已成功创建了一个可以用来基于斐波那契数列构建迭代数据集的生成器。

注意

此活动的解决方案可在第 280 页找到。

总结

在本章中,我们看到 ECMAScript 是现代 JavaScript 的脚本语言规范。ECMAScript 6,或 ES6,于 2015 年发布。通过本章,我们涵盖了 ES6 的一些关键点及其与以前版本 JavaScript 的区别。我们强调了变量作用域的规则,声明变量的关键字,箭头函数语法,模板文字,增强的对象属性表示法,解构赋值,类和模块,转译和迭代器和生成器。您已经准备好将这些知识应用于您的专业 JavaScript 项目。

在下一章中,我们将学习什么是异步编程语言,以及如何编写和理解异步代码。

第二章:异步 JavaScript

学习目标

在本章结束时,您将能够:

  • 定义异步编程

  • 描述 JavaScript 事件循环

  • 利用回调函数和 promises 来编写异步代码

  • 使用 async/await 语法简化异步代码

在本章中,我们将学习异步 JavaScript 及其用途。

介绍

在上一章中,我们涵盖了 ES6 中发布的许多新功能和强大功能。我们讨论了 JavaScript 的发展,并突出了 ES6 中的关键添加。我们讨论了作用域规则、变量声明、箭头函数、模板文字、增强对象属性、解构赋值、类和模块、转译以及迭代器和生成器。

在本章中,我们将学习什么是异步编程语言,以及如何编写和理解异步代码。在第一个主题中,我们将定义异步编程,并展示 JavaScript 是一种异步、事件驱动的编程语言。然后,我们将概述回调函数,并展示如何使用回调函数来编写异步 JavaScript。然后,我们将定义 promises,并演示如何使用 promises 来编写异步 JavaScript。在最后一个主题中,我们将介绍 async/await 语法,并使用 promises 和这种语法简化我们的异步代码。

异步编程

JavaScript 是单线程、事件驱动、异步编程语言。这意味着 JavaScript 在单个线程上运行,并通过事件队列延迟/处理某些事件或函数调用。我们将通过以下主题来分解 JavaScript 如何做到这一点的基础知识。

同步与异步

代码是同步还是异步意味着什么?这两个词在 JavaScript 中经常被提及。同步源自希腊词根syn,意思是"与",chronos,意思是"时间"。同步字面上意味着"与时间",或者说,与时间协调的代码。代码一次运行一行,并且在处理完前一行之前不会开始下一行。异步async源自希腊词根async,意思是"不与",chronos,因此异步字面上意味着"不与时间",或者说,与解释器首次遇到代码行的时间不协调的代码。运行的代码顺序与解释器首次遇到代码行的时间不协调。

同步与异步的时间控制

有两种类型的代码——同步异步。我们将在本节中涵盖它们。

在异步 JavaScript 中,JavaScript 引擎以不同的方式处理慢速和快速代码。我们知道"快"和"慢"这两个词的含义,但这在我们的代码中如何实际应用呢?异步 JavaScript 允许线程在等待来自慢速时间相关操作的响应时执行新的代码行。例如文件系统 I/O。要理解这一点,我们必须了解一些关于计算机操作速度的知识。

CPU 非常非常快,可以处理每秒数百万到数十亿次操作。计算机或网络的其他部分比 CPU 慢得多。例如,硬盘每秒只能执行数百到数千次操作,计算机网络可能每秒只能执行一次操作。对内存的调用比 CPU 周期慢得多个数量级。

硬盘操作比内存操作慢几个数量级。网络调用比硬盘调用慢几个数量级。

同步代码中,我们一次执行一行代码。下一行代码直到前一行代码完成运行后才执行。由于同步代码一次只执行一行代码并等待操作完成后才开始新的一行,如果我们的代码向较慢的介质(如内存、硬盘或网络)发出请求,程序将不会继续执行下一行代码,直到慢介质(HDD、网络等)的请求完成。CPU 将空闲,浪费宝贵的时间,等待操作完成。在网络调用的情况下,这可能需要几秒钟。在编写复杂的同步代码时,程序员通常编写多线程代码。操作系统会在一个线程等待缓慢操作时切换到其他线程。这有助于减少 CPU 的空闲时间。

异步代码中,我们可以按非时间顺序执行代码行。这意味着我们可以在前一行代码完成操作之前开始处理新的代码行。JavaScript 通过事件循环实现这一点,这将在本章后面介绍。

在异步代码中,当 JavaScript 引擎遇到使用缓慢的、非 CPU 依赖操作的代码行时,操作会被启动,而不是等待完成,程序会继续执行下一行代码并继续运行。当缓慢操作完成时,CPU 会跳回到该操作,处理操作的响应,然后继续运行之前的代码。这样可以让 CPU 不浪费宝贵的资源等待可能需要几秒钟的操作。下图显示了同步和异步时间图的示例:

图 2.1:同步与异步时间图

图 2.1:同步与异步时间图

在上图中,我们有四个操作:A、B、C 和 D。操作 C 调用网络并在完成之前有延迟,由网络延迟表示。在同步示例中,我们按顺序运行每个操作。当到达操作 C 时,我们必须等待网络延迟才能完成操作 C。操作 C 完成后,我们运行操作 D。在此等待期间,CPU 处于空闲状态,无法进行其他工作。

在异步示例中,我们按顺序运行前三个操作。当到达操作 C 时,不会等待网络延迟,而是运行操作 D。当网络延迟结束时,我们完成操作 C。在异步示例中,我们可以清楚地看到所有操作的整体完成时间和 CPU 空闲时间都更短。

如果这个概念还有点混乱,我们可以用现实生活中的情况来解释。想象同步代码就像在火车站排队买票的人群。一次只能有一个人使用售票机。在我前面的人都买完票之前,我无法从机器上取票。同样,站在我后面的人在我取票之前也无法开始取票。即使我前面的人决定花五分钟来取票,我也得等到轮到我。就像排队买票一样,同步代码一次只运行一步,按顺序进行。无论一步需要多长时间,都不会运行新的代码行,直到前一步完成。

异步代码更像是在餐厅用餐。每位顾客依次点餐,并且必须等待厨房烹饪订单。订单完成烹饪后会被上菜,但不是按照它们被厨房接收的顺序。烹饪时间较短的订单可能会在烹饪时间较长的订单之前上菜。这与异步代码非常相似。每个异步代码操作,或者我们例子中的食物订单,都是按顺序开始的。当操作等待响应时,可以开始下一个操作。CPU 可以在等待前一个操作的响应时处理其他操作。这显然与同步代码不同。如果厨房以同步方式运行,你将无法在厨房完成前一个订单的烹饪之前点餐。想象一下这会有多低效!

引入事件循环

由于其异步事件循环特性,JavaScript 是一个事件驱动、异步、单线程语言。在 JavaScript 中,异步操作以事件的形式处理。当我们进行异步调用时,一旦调用完成,就会触发一个事件。然后 JavaScript 引擎通过调用回调函数来处理该事件,然后继续执行代码中的下一个操作。

事件循环是我们用来管理 JavaScript 中所有操作的四部分系统的名称。这个系统的部分包括堆栈、堆、事件队列和(主)事件循环。堆栈、堆和事件队列都是 JavaScript 引擎维护的数据结构。主事件循环是在后台运行并管理这三个数据结构的过程。在其最简单的形式中,这个系统很容易理解。堆栈跟踪函数调用。当函数进行异步操作时,它会将事件处理程序放入堆中。当异步操作完成时,事件被推送到事件队列中。事件循环轮询队列以获取事件,然后从堆中获取相关的处理程序,然后调用函数并将其添加到堆栈中。这是事件循环的最基本形式。事件循环数据结构的可视化表示如下:

图 2.2:事件循环数据结构可视化模型

图 2.2:事件循环数据结构可视化模型

这就是事件循环的最简单形式——三个数据结构:一个用于跟踪函数调用,一个用于跟踪事件处理程序,一个用于跟踪事件完成,以及一个循环将它们全部连接在一起。这些各个部分将在接下来的小节中进行更详细的讨论。

堆栈

JavaScript 引擎有一个单一的调用堆栈,事件循环堆栈。事件循环堆栈是一个传统的调用堆栈——它跟踪当前正在执行的函数以及在之后要执行的函数。堆栈中保存的函数被称为帧。事件循环采用先进后出的方式。它本质上是一种类似数组的数据结构,具有特殊的限制。函数帧只能从堆栈顶部添加和移除,就像厨房里的一叠盘子。放在堆栈上的第一项始终在底部,这将是最后一个被取走的。

堆栈跟踪堆栈顶部的当前执行函数以及较低级别的函数调用链。当函数被执行时,会创建一个帧并添加到堆栈顶部。当函数执行完成时,其帧会从堆栈顶部移除。这些帧包含函数、参数和局部变量。

如果一个函数 A 调用另一个函数 B,那么为新执行的函数 B 会创建一个新的帧。函数 B 的新帧会被放在堆栈的顶部,即调用它的函数 A 的帧的顶部。当函数 B 执行完成时,它的帧会从堆栈中移除,函数 A 的帧现在位于顶部。函数 A 继续执行,直到完成,完成后它的帧被移除。以下代码片段和图示例了这一点。

考虑以下代码片段:

function foo( x ) { return 2 * x; }
function bar( y ) { return foo( y + 5 ) - 10; }
console.log( bar( 15 ) ); // Expected output: 30
片段 2.1:调用堆栈示例代码

程序启动时,会创建第一个帧。该帧包含全局状态。然后,当调用console.log时,会创建第二个帧。该帧被放置在全局帧的顶部。当调用bar函数时,会创建第三个帧并添加到堆栈中。该帧包含bar的参数和局部变量。当 bar 调用foo时,会在 bar 帧的顶部添加第四个帧。完整的调用堆栈如下图所示:

图 2.3:调用堆栈

图 2.3:调用堆栈

foo返回时,它的帧从堆栈中移除。堆栈现在只包含一个包含 bar 的参数和变量、console.log调用和全局帧的帧。当bar返回时,它的帧从堆栈中移除,堆栈只包含最后 2 个帧。

堆和事件队列

是一个大的、大部分是无结构的内存块,用于跟踪事件完成时应调用哪些函数。当启动异步操作时,它会被添加到堆中。一旦异步操作完成,项目就会从堆中移除。当异步操作完成时,堆会将必要的数据推送到事件队列中。

队列

队列是用于跟踪异步事件完成的消息队列。它是一个传统的先进先出队列。这意味着它是一个类似数组的数据结构,其中项目被推到队列的末尾并从队列的前端移除。最旧的项目首先被移除和处理。

消息队列中的每条消息都有一个关联的函数,当处理消息时会调用该函数。要处理消息,它会从队列中移除,并以消息的数据作为输入参数调用相应的函数。预期地,当调用函数时会创建一个新的堆栈帧。

让我们考虑一个网页中有两个按钮button1button2,设置为使用clickHandler处理函数处理点击事件。用户快速点击button1button2。事件队列将包含以下简化信息:

Queue: { event: 'click', target: 'button1', handler: clickHandler }, { event: 'click', target: 'button2', handler: clickHandler }
片段 2.2:调用堆栈示例代码

事件循环

事件循环负责处理事件队列中的消息。它通过一个不断的轮询循环来实现这一点。在事件循环的每个“tick”中,事件队列最多执行三件事:检查堆栈,检查队列,等待。

注意

事件队列的“tick”是同步调用与 JavaScript 事件相关的零个或多个回调函数。这是处理事件并运行相关回调的时间。

在每个时刻,事件循环首先检查调用栈是否为空,以及我们是否可以做其他工作。如果调用栈不为空,事件队列将等一会儿,然后再次检查。如果调用栈为空,事件循环将检查事件队列以处理事件。如果事件队列为空,那么我们没有工作要做,事件循环将等待下一个时刻,然后重新开始这个过程。如果有事件要处理,事件循环将从事件队列中取消息并调用与消息相关联的函数。被调用的函数在栈上创建一个帧,JavaScript 引擎开始执行函数指定的工作。事件循环继续其轮询循环。

观察事件循环轮询,我们可以注意到一次只能处理一个事件。如果调用栈中有任何内容,事件循环将不会从事件队列中取出消息。这个功能被称为运行到完成。每条消息在任何其他消息开始处理之前都会被完全处理。

运行到完成在编写应用程序时提供了一些好处。其中一个好处是函数不能被抢占,将在任何其他代码运行之前运行,可能修改函数正在操作的数据。

然而,这种模式的缺点是,如果代码中的事件回调或循环花费很长时间才能完成,应用程序可能会延迟其他待处理的事件。在浏览器中,用户交互事件如点击或滚动可能会因为另一个事件回调花费很长时间而挂起。在服务器端代码中,数据库查询或 HTTP 请求的结果可能会因为另一个事件回调花费很长时间而挂起。

确保由事件调用的回调函数很短是一个良好的实践。长回调函数可以使用setTimeout函数分成几条消息。延迟问题的示例如下所示:

setTimeout( () => { 
  // WARNING: this may take a long time to run on a slow computer
  // Try with smaller numbers first
  for( let i = 0; i < 2000000000; i++ ) {}
  console.log( 'done delaying' );
}, 0 );
setTimeout( () => { console.log( 'done!' ) }, 0 );
代码片段 2.3:阻塞循环示例

在前面的例子中,我们使用setTimeout创建了两个异步调用。第一个计数到 20 亿,然后记录done delaying,第二个只记录done!。当第一个消息从事件队列中取出时,回调被放入调用栈。在大多数计算机上,计数到 20 亿会导致明显的延迟。

注意

如果你的电脑比较旧,那么这种延迟可能会很长。如果你运行这段代码,从较小的数字开始,比如 200 万。

当计算机在计数时,事件循环不会从事件队列中取出下一个消息。对done!的异步调用将在计数完成后才会得到处理。要小心,因为制作回调函数可能需要很长时间。如果被阻塞的console.log('done!')回调是网站中的用户输入事件,网站将阻塞用户输入,可能导致用户不满意,甚至可能失去宝贵的用户。

需要考虑的事情

在处理事件循环时,我们在编写异步代码时有三个重要的事情要考虑。第一件要考虑的事情是事件可能会出现不同步。第二个是同步代码是阻塞的。第三个是零延迟函数不会在 0 毫秒后执行。这三个概念如下所述:

事件可能会出现无序

  • 事件按照它们发生或解决的顺序添加到事件队列中。

  • 这可能不是异步调用启动的顺序。

  • 如果一个异步操作很慢,在它完成之前触发的事件将首先得到处理。

  • 我们必须考虑回调和承诺的程序定时。

  • 我们必须确保在数据可用之前不要访问由异步调用填充的数据。

同步代码是阻塞的

  • 通过使用执行相同或类似任务的同步模块来避免异步代码是非常不好的做法。

  • JavaScript 是单线程的。

  • 如果使用大量同步代码,事件消息可能无法及时处理。

  • 例如鼠标点击或滚动等事件可能会挂起。

零延迟函数实际上不会在 0 毫秒后执行

  • setTimeout在超时后将事件添加到事件队列中。

  • 如果事件队列有很多消息要处理,超时消息可能需要几毫秒才能得到处理。

  • 延迟参数表示的是最小时间,而不是保证时间。

零延迟函数和事件循环状态的概念可以在以下片段中得到展示:

setTimeout( () => { console.log( 'step1' ) }, 0 );
setTimeout( () => { console.log( 'done!' ) }, 0 );
console.log( 'step0' );
//Expected output:
// step0
// step1
// done!
片段 2.4:处理异步代码

在前面的片段中,我们看到主代码文件中有工作要做。运行主程序体,并向调用堆栈添加一个帧。然后解释第一行代码,setTimeout函数将其回调添加到堆中,并安排在 0 毫秒后触发事件。然后事件触发,消息被添加到事件队列。JavaScript 引擎解释下一行代码,即第二个setTimeout调用。回调被添加到堆中,并注册在 0 毫秒后触发事件。第二个超时事件立即触发,并将第二个消息添加到事件队列。JavaScript 引擎处理console.log调用,并将step0记录到控制台。主程序体没有更多同步工作要做,调用堆栈为空。事件循环现在开始处理事件队列中的事件。事件队列包含两条消息,一条是第一个超时事件的消息,另一条是第二个超时事件的消息。然后事件循环获取第一条消息,并将相关的callback函数添加到调用堆栈。JavaScript 引擎处理该调用堆栈帧并记录step1。然后 JavaScript 引擎处理事件队列中的第二条消息。事件队列消息从队列中移除,并向调用堆栈添加一个帧。JS 引擎处理堆栈中的帧并记录done!。没有更多的工作可以做了。所有事件都已触发,堆栈和队列都为空。

结论

与大多数编程语言不同,JavaScript 是一种异步编程语言。更具体地说,它是一种单线程、事件驱动的异步编程语言。这意味着 JavaScript 在等待长时间运行操作的结果时不会空闲。它在等待时运行其他代码块。JavaScript 通过事件循环来管理这一点。事件循环由四个部分组成,即函数堆栈、内存堆、事件队列和事件循环。这四个部分共同处理来自操作完成的事件。

练习 16:使用事件循环处理堆栈

为了更好地理解程序中事件按预期顺序触发和处理的原因,请查看下面提供的程序,并在不运行程序的情况下,写出程序的预期输出。

对于程序的前 10 个步骤,在每个步骤写出预期的堆栈、队列和堆。步骤是指事件触发时,事件循环出列一个事件,或 JS 引擎处理函数调用的任何时间:

step 0
stack: <global>
queue: <empty>
heap: <empty>
片段 2.5:调用堆栈示例代码(起始步骤)

程序显示在以下片段中:

function f1() { console.log( 'f1' ); }
function f2() { console.log( 'f2' ); }
function f3() {
  console.log( 'f3' );
  setTimeout( f5, 90 );
}
function f4() { console.log( 'f4' ); }
function f5() { console.log( 'f5' ); }
setTimeout( f1, 105 );
setTimeout( f2, 15 );
setTimeout( f3, 10 );
setTimeout( f4, 100 );
片段 2.6:调用堆栈示例代码(程序)

为了演示事件循环处理堆栈、队列和堆的简化形式,执行以下步骤:

  1. 如果调用并处理函数,则向堆栈中添加事件循环堆栈帧。

处理函数并将必要的事件和处理程序信息添加到堆中。在下一步中移除事件和处理程序。

  1. 如果事件完成,则将其推送到事件队列中。

  2. 从事件队列中拉取并调用处理程序函数。

  3. 对程序的其余步骤重复此操作(仅限前 10 步)。

代码

https://bit.ly/2R5YGPA

结果

![图 2.4:作用域输出]

](image/Figure_2.4.jpg)

图 2.4:作用域输出

图 2.5:作用域输出

图 2.5:作用域输出

图 2.6:作用域输出

图 2.6:作用域输出

您已成功演示了事件循环如何处理堆栈的简化形式。

回调

回调是 JavaScript 异步编程的最基本形式。简单来说,回调是在另一个函数完成后被调用的函数。回调用于处理异步函数调用的响应。

在 JavaScript 中,函数被视为对象。它们可以作为参数传递,被函数返回,并保存到变量中。回调是作为参数传递到高阶函数中的函数对象。高阶函数简单地是一个数学和计算机科学术语,用于指代接受一个或多个函数作为参数(回调)或返回一个函数的函数。在 JavaScript 中,高阶函数将回调作为参数。一旦高阶函数完成某种形式的工作,比如 HTTP 请求或数据库调用,它将调用回调函数并传递错误或返回值。

如在异步编程中的事件循环部分所述,JavaScript 是一种事件驱动的语言。由于 JavaScript 是单线程的,任何长时间运行的操作都会阻塞。JavaScript 通过使用事件来处理这种阻塞效应。当操作完成并触发事件时,事件会有一个附加的处理程序函数来处理结果。这些函数就是回调。回调是允许 JavaScript 事件在处理异步事件时执行工作的关键。

构建回调

JavaScript 中的回调遵循一个简单的非官方约定。回调函数应至少接受两个参数:errorresult。在构建回调 API 或编写回调函数时,我们建议您遵循这个约定,以便您的代码可以无缝地集成到其他库中。下面是一个回调函数的示例:

TwitterAPI.listFollowers( { user_id: "example_user" }, (err, result) => {   
  console.log( err, result ); 
} );
代码段 2.7:基本回调示例

在前面的示例中,我们使用了一个假的 Twitter API。我们的假 API 有一个高阶函数listFollowers,它接受一个对象和一个回调函数作为参数。一旦listFollowers完成其内部工作,比如在这种情况下是对 Twitter API 的 HTTP 请求,我们的回调函数将被调用。

回调可以接受高阶函数需要的或指定的任意数量的参数,但第一个参数必须是错误对象。几乎每个 API 都遵循这个约定。在编写 API 时违反这个约定将使您的代码更难与任何第三方 API 或应用程序集成。

如果高阶函数在运行时遇到错误,回调的错误参数将被设置。错误参数的内容可以是任何合法的 JavaScript 值。在大多数情况下,它是 Error 类的一个实例;然而,错误对象的内容没有约定。一些 API 可能返回一个对象、字符串或数字,而不是 Error 实例。请确保阅读任何第三方 API 的文档,以确保您的代码可以处理返回的错误格式。

如果高阶函数没有遇到错误,则错误参数应设置为 null。在构建自己的 API 时,建议您也遵循这个惯例。一些第三方 API 可能会返回一个不是 null 的假值,但这是不鼓励的,因为它会使错误处理逻辑变得更加复杂。

注意

Falsy是 JavaScript 类型比较和转换中使用的术语。在 JavaScript 中,Falsy 值在类型比较时转换为布尔值 false。Falsy 值的示例包括 null、undefined、0 和布尔值 false。

回调函数的结果参数包含了高阶函数的评估结果。这可能是一个 HTTP 请求的结果,数据库查询的结果,或者任何其他异步操作的结果。当返回错误时,一些 API 还可能在结果字段中提供更详细的错误信息。重要的是不要假设函数成功完成,如果结果对象存在的话,你必须检查错误字段。

在处理回调函数中的错误时,我们必须检查错误参数。如果错误参数不是 null 或 undefined,那么我们必须以某种方式处理错误。下面的示例中显示了一个错误处理程序:

TwitterAPI.listFollowers( { user_id: "example_user" }, (err, result) => {   
  if ( err ) {
    // HANDLE ERROR
  }
  console.log( err, result ); 
} );
片段 2.8:基本回调错误处理

大多数开发人员会检查错误值是否为真值。如果err是真值,那么将执行错误处理代码。这是一种通用做法;然而,这是编码的懒惰方式。在某些情况下,错误对象可能是布尔值 false,数字 0,空字符串等。这些都会评估为假值,即使值不是 null 或 undefined。如果你正在使用 API,请确保它不会返回一个评估为假值的错误。如果你正在构建一个 API,我们不建议返回一个可能评估为假值的错误。

回调陷阱

回调很容易使用,并且非常有效地实现了它们的目的,但在使用回调时需要考虑一些陷阱。最常见的两个陷阱是回调地狱和回调存在假设。只要有远见地编写代码,这两个陷阱都很容易避免。

最常见的回调陷阱是回调地狱。在异步工作完成并调用回调后,回调函数可以调用另一个异步函数来进行更多的异步工作。当它调用新的异步函数时,将提供另一个回调。新的回调将嵌套在旧的回调内。回调嵌套的示例在下面的片段中显示:

TwitterAPI.listFollowers( { user_id: "example_user" }, (err, result) => { 
  if ( err ) { throw err; }
  TwitterAPI.unfollow( { user_id: result[ 0 ].id }, ( err, result ) => {
    if ( err ) { throw err; }
    console.log( "Unfollowed someone!" );
  } );
 } );
片段 2.9:回调嵌套

在前面的片段中,我们有嵌套的回调。第一个异步操作的回调listFollowers调用了第二个异步操作。取消关注操作也有一个回调,只是处理错误或记录文本。由于回调可以嵌套,经过几层嵌套后,代码可能变得很难阅读。这就是回调地狱。回调地狱的示例在下面的片段中显示:

TwitterAPI.listFollowers( { user_id: "example_user" }, (err, result) => { 
  const [ id1, id2, id3 ] = [ result[ 0 ].id, result[ 1 ].id, result[ 2 ].id ];
  TwitterAPI.unfollow( { user_id: id1 }, ( err, result ) => {
    TwitterAPI.block( { user_id: id1 }, ( err, result ) => {
      TwitterAPI.unfollow( { user_id: id2 }, ( err, result ) => {
        TwitterAPI.block( { user_id: id2 }, ( err, result ) => {
          TwitterAPI.unfollow( { user_id: id3 }, ( err, result ) => {
            TwitterAPI.block( { user_id: id3 }, ( err, result ) => {
              console.log( "Unfollowed and blocked 3 users!" );
片段 2.10:回调地狱

在前面的片段中,我们列出了我们的关注者,然后取消关注并阻止前三个关注者。这是非常简单的代码,但由于回调嵌套,代码变得更加混乱。这就是回调地狱。

注意

回调地狱是关于代码呈现的凌乱,而不是其背后的逻辑。回调嵌套可能导致代码运行无误,但非常难以阅读。非常难以阅读的代码可能非常难以向新开发人员解释,或者在发生错误时进行调试。

修复回调地狱

回调地狱可以通过两种技巧轻松避免:命名函数模块。命名函数非常简单;定义回调并将其分配给标识符(变量)。定义的回调函数可以保存在同一个文件中或放入一个模块并导入。在回调中使用命名函数将有助于防止回调嵌套使代码混乱。这在下面的示例中显示:

function listHandler( err, result ) {
  TwitterAPI.unfollow( { user_id: result[ 0 ].id }, unfollowHandler );
}
function unfollowHandler( err, result) {
  TwitterAPI.block( { user_id: result.id }, blockHandler );
}
function blockHandler( err, result ) {
  console.log( "User unfollowed and blocked!" );
}
TwitterAPI.listFollowers( { user_id: "example_user" }, listHandler);
片段 2.11:修复回调地狱

从前面的片段中可以看出,没有嵌套的代码要清晰得多。如果我们有 30 层的回调嵌套深度,使代码可读的唯一方法就是将回调拆分为命名函数。

另一个潜在的陷阱是回调函数的不存在。如果我们正在编写一个 API,我们必须考虑到 API 的用户可能不会将有效的回调函数传递给 API。如果预期的回调不是一个函数或不存在,那么尝试调用它将导致运行时错误。在尝试调用之前,验证回调存在且是一个函数是一个很好的做法。如果用户传入了无效的回调,那么我们可以优雅地失败。以下是一个示例:

Function apiFunction( args, callback ){
  if ( !callback || !( typeof callback === "function" ) ){
    throw new Error( "Invalid callback. Provide a function." );
  }
  let result = {};
  let err = null;
  // Do work
  // Set err and result
  callback( err, result );
}
代码片段 2.12:检查回调存在

在前面的代码片段中,我们检查了callback参数是否存在且为真,并且它是函数类型。如果回调不存在或不是函数,我们会抛出一个错误,让用户知道出了什么问题。如果callback是一个函数,我们继续。

结论

回调只是作为参数传递给另一个函数的函数,称为高阶函数。JavaScript 使用回调来处理事件。回调使用错误参数和结果参数进行定义。如果在高阶函数中出现错误,回调错误字段将被设置。如果高阶函数完成了结果,结果字段将包含已完成操作的结果。

在使用回调时,我们应该注意两个陷阱。我们必须小心不要嵌套太多的回调并创建回调地狱。我们必须确保验证传递给我们的高阶函数的参数,以确保回调是一个函数。

练习 17:使用回调

您的团队正在构建一个基于回调的 API。为了防止运行时错误,您需要验证传递给回调 API 函数的回调参数是否是有效的可调用函数。为您的 API 创建一个函数。在该函数的主体中,验证回调参数是否是一个函数。如果不是一个函数,抛出一个错误。延迟后,记录传递给 API 函数的数据并调用回调。

要构建一个具有回调函数的回调 API,请执行以下步骤:

  1. 编写一个名为higherOrder的函数,该函数接受两个参数;一个名为data的对象和一个名为cb的回调函数。

  2. 在函数中,检查回调是否是一个函数参数(cb是一个函数)。

如果cb不存在或者不是function类型,则抛出一个错误。

  1. 在函数中,记录data对象。

  2. 在函数中,延迟 10 毫秒后调用callback函数。

  3. 在函数外部,创建一个try-catch块。

  4. 在 try 部分内,使用一个数据对象和没有回调函数调用higherOrder函数。

  5. 在 catch 部分内,捕获错误并记录我们收到的错误消息。

  6. try-catch块之后,使用一个数据对象和一个callback函数调用higherOrder函数。回调函数应记录字符串Callback Called!

代码

Index.js
function higherOrder( data, cb ) {
 if ( !cb || !( typeof cb === 'function' ) ) {
   throw new Error( 'Invalid callback. Please provide a function.' );
 }
 console.log( data );
 setTimeout( cb, 10 );
}
try {
 higherOrder( 1, null );
} catch ( err ) {
 console.log( 'Got error: ${err.message}' );
}
higherOrder( 1, () => {
 console.log( 'Callback Called!' )
} );
代码片段 2.13:实现回调

https://bit.ly/2VTGG9L

结果

图 2.7:回调输出

图 2.7:回调输出

您已成功构建了一个具有回调函数的回调 API。

承诺

在 JavaScript 中,promise是一个包装异步操作并在异步操作完成时通知程序的对象。承诺对象表示包装操作的最终完成或失败。承诺是一个代理值,不一定是已知的。它承诺在将来的某个时刻提供一个值,而不是立即提供值,就像同步程序一样。承诺允许您将成功和错误处理程序与异步操作关联起来。这些处理程序在包装的异步过程完成或失败时被调用。

承诺状态

每个 promise 都有一个状态。一个 promise 只能成功一次,带有一个值,或者失败一次,带有一个错误。promise 的状态定义了 promise 在朝向值的解决过程中的工作状态。

一个 promise 有三种状态:pendingfulfilledrejected。一个 promise 开始于 pending 状态。这意味着 promise 内部进行的异步操作尚未完成。一旦异步操作完成,promise 被视为已解决,并将进入 fulfilled 或 rejected 状态。

当一个 promise 进入完成状态时,意味着异步操作已经完成,没有错误。promise 已经完成,并且有一个值可用。异步操作生成的值已经返回,并且可以使用。

当一个 promise 进入拒绝状态时,意味着异步操作已经以错误完成。当一个 promise 被拒绝时,将不会进行任何未来的工作,也不会提供任何值。异步操作的错误已经返回,并可以从 promise 对象中引用。

解决或拒绝一个 promise

通过实例化Promise类的新对象来创建一个 promise。promise 构造函数接受一个参数,一个函数。这个函数必须有两个参数:resolvereject。下面的片段展示了 promise 的创建示例:

const myPromise = new Promise( ( resolve, reject ) => {
  // Do asynchronous work here and call resolve or reject
} );
片段 2.14:promise 创建语法

promise 的主要异步工作将在传递给构造函数的函数体中完成。resolvereject是可以用来完成 promise 的函数。要完成带有错误的 promise,调用带有错误作为参数的 reject 函数。要标记 promise 为成功,调用resolve函数并将结果作为参数传递给 resolve。下面的两个片段展示了 promise 的拒绝和解决的例子:

// Reject promise with an error
const myPromise = new Promise( ( resolve, reject ) => {
  // Do asynchronous work here
  reject( new Error( 'Oh no! Promise was rejected' ) );
} );
片段 2.15:拒绝一个 promise
// Resolve the promise with a value
const myPromise = new Promise( ( resolve, reject ) => {
  // Do asynchronous work here
  resolve( { key1: 'value1' } );
} );
片段 2.16:解决一个 promise

下面的片段展示了解决执行异步工作的 promise 的示例:

const myPromise = new Promise( ( resolve, reject ) => {
  setTimeout( () => { resolve( 'Done!' ) }, 1000 )
} );
片段 2.17:解决一个 promise

使用 Promises

promise 类有三个成员函数,可以用来处理 promise 的完成和拒绝。这些函数被称为 promise 处理程序。这些函数是then()catch()finally()。当一个 promise 完成时,其中一个处理程序函数被调用。如果 promise 完成,将调用then()函数。如果 promise 被拒绝,要么调用catch()函数,要么调用带有拒绝处理程序的then()函数。

then()成员函数旨在处理并获取 promise 的完成或拒绝结果。then函数接受两个函数参数,一个完成回调和一个拒绝回调。下面的例子展示了这一点:

// Resolve the promise with a value or reject with an error
myPromise.then( 
  ( result ) => { /* handle result */ }, // Promise fulfilled handler
  ( err ) => { /* handle error here */ } // Promise rejected handler
 ) ;
片段 2.18:Promise.then()语法

then()函数中的第一个参数是 promise 完成处理程序。如果 promise 以一个值完成,将调用 promise 完成处理程序回调。promise 完成处理程序接受一个参数。这个参数的值将是传递给 promise 函数体中完成回调的值。下面的片段展示了一个例子:

// Resolve the promise with a value
const myPromise = new Promise( ( resolve, reject ) => {
  // Do asynchronous work here
  resolve( 'Promise was resolved!' );
} );
myPromse.then( value => console.log( value ) );
// Expected output: 'Promise was resolved'
片段 2.19:使用已解决的 promise 的 Promise.then()

then()函数中的第二个参数是 promise 拒绝处理程序。如果 promise 以一个错误被拒绝,将调用 promise 拒绝处理程序回调。promise 拒绝处理程序接受一个参数。这个参数的值是传递给 promise 函数体中 reject 回调的值。下面的片段展示了一个例子:

// Reject the promise with a value
const myPromise = new Promise( ( resolve, reject ) => {
  // Do asynchronous work here
  reject( new Error ( 'Promise was rejected!' ) );
} );
myPromse.then( () => {}, error => console.log( error) );
// Expected output: Error: Promise was rejected! 
// ** output stack trace omitted
片段 2.20:使用 Promise.then()拒绝 promise

练习 18:创建和解决你的第一个 promise

要构建我们的第一个异步 promise,请执行以下步骤:

  1. 创建一个 promise 并将其保存到名为myPromise的变量中。

  2. 在 promise 的主体内,记录开始异步工作!

  3. 在 promise 的主体内,使用超时进行异步工作。

timeout回调在 1000 毫秒后触发。在timeout回调函数内,调用 promise 解决函数并传入值完成!

  1. 将一个 then 处理程序附加到保存在myPromise中的 promise。

  2. 将一个函数传递给 then 处理程序,该函数接受一个参数并记录参数的值。

代码

Index.js
const myPromise = new Promise( ( resolve, reject ) => {
  console.log( 'Starting asynchronous work!' );
  setTimeout( () => { resolve( 'Done!' ); }, 1000 );
} );
myPromise.then( value => console.log( value ) );
片段 2.21:使用 Promise.then()拒绝 Promise

https://bit.ly/2TVQNcz

结果

图 2.8:作用域输出

图 2.8:作用域输出

你已成功利用你刚学到的语法来构建我们的第一个异步 promise。

处理 Promise

当调用Promise.then()时,它会返回一个处于挂起状态的新 promise。在已调用完成或拒绝的 promise 处理程序之后,Promise.then()中的处理程序会异步调用。当从Promise.then()调用的处理程序返回一个值时,该值将用于解决或拒绝promise.then()返回的 promise。以下表格提供了处理程序函数在任何阶段返回值、错误或 promise 时所采取的操作:

图 2.9:返回一个 promise

图 2.9:返回一个 promise

Promise.catch接受一个参数,一个处理程序函数,用于处理 promise 的拒绝值。当调用Promise.catch时,内部会调用Promise.then( undefined, rejectHandler )。这意味着在内部,只调用了Promise.then()处理程序,只有 promise 拒绝回调rejectHandler,没有 promise 完成回调。Promise.catch()返回内部Promise.then()调用的值:

const myPromise = new Promise( ( resolve, reject ) => {
  reject( new Error 'Promise was resolved!' );
} );
myPromise.catch( err => console.log( err ) );
片段 2.22:使用 Promise.then()拒绝 Promise

promise 成员函数Promise.finally()是一个用于捕获所有 promise 完成情况的 promise 处理程序。Promise.finally()处理程序将被用于处理 promise 的拒绝和解决。它接受一个单一函数参数,在 promise 被拒绝或解决时调用。Promise.finally()将捕获被拒绝和解决的 promise,并运行指定的函数。它为我们提供了一个捕获所有情况的处理程序来处理任何完成情况。Promise.finally()应该用于防止在 then 和 catch 处理程序之间重复代码。传递给Promise.finally()的函数不接受任何参数,因此忽略了传递给 promise 的解决或拒绝的任何值。因为在使用Promise.finally()时没有可靠的区分拒绝和解决的方法,所以只有在我们不关心 promise 是否被拒绝或解决时才应该使用Promise.finally()。以下片段中显示了一个示例:

// Resolve the promise with a value
const myPromise = new Promise( ( resolve, reject ) => {
  resolve( 'Promise was resolved!' );
} );
myPromse.finally( value => { 
  console.log( 'Finally!' );
 } );
// Expected output:
// Finally!
片段 2.23:Promise.then()

在使用 promise 时,有时我们可能希望创建一个已经处于完成状态的 promise。Promise 类有两个静态成员函数,允许我们这样做。这些函数是Promise.reject()Promise.resolve()Promise.reject()接受一个参数,并返回一个已经被拒绝的带有传入拒绝函数值的 promise。Promise.resolve()接受一个参数,并返回一个已经被解决的带有传入解决值的 promise。

Promise.resolve( 'Resolve value!' ).then( console.log );
Promise.reject( 'Reject value!' ).catch( console.log );
//Expected output:
// Resolve value!
// Reject value!
片段 2.24:Promise.then()

Promise 链

在使用承诺时,我们可能会遇到承诺地狱。这与回调地狱非常相似。当承诺主体在获得值后需要执行更多的异步工作时,可以嵌套另一个承诺。当嵌套链变得非常深时,嵌套的承诺调用可能变得难以跟踪。为了避免承诺地狱,我们可以将承诺链接在一起。Promise.then()Promise.catch()Promise.finally()都返回承诺,这些承诺将根据处理程序函数的结果被实现或拒绝。这意味着我们可以在这个承诺上附加另一个 then 处理程序,并创建一个承诺链来处理新返回的承诺。这在以下片段中显示:

function apiCall1( result ) { // Function that returns a promise
 return new Promise( ( resolve, reject ) => { 
    resolve( 'value1' );
  } );
}
function apiCall2( result ) {// Function that returns a promise
  return new Promise( ( resolve, reject ) => { 
    resolve( 'value2' );
  } );
}
myPromse.then( apiCall1 ).then( apiCall2 ).then( result =>  console.log( 'done!') ) ;
片段 2.25:承诺链接示例

在前面的示例中,我们创建了两个函数apiCall1()apiCall2()。这些函数返回一个承诺,执行更多的异步工作。出于简洁起见,此示例中省略了异步工作。当原始承诺myPromise完成时,Promise.then()处理程序调用apiCall1(),它返回另一个承诺。第二个Promise.then()处理程序应用于这个新返回的承诺。当apiCall1()返回的承诺被解析时,处理程序函数调用apiCall2(),它也返回一个承诺。当apiCall2()返回的承诺被返回时,将调用最终的Promise.then()处理程序。如果这些具有异步工作的处理程序函数被嵌套,那么跟踪程序将变得非常困难。通过回调链接,跟踪程序流程变得非常容易。

在链接承诺时,承诺处理程序可以返回一个值,而不是一个新的承诺。如果返回一个值,该值将作为输入传递给链中的下一个Promise.then()处理程序。

例如,第一个承诺完成并调用Promise.then()处理程序。此处理程序执行同步工作并返回数字 10。下一个promise.then()处理程序将输入参数设置为 10,并可以继续执行异步工作。这允许您将同步步骤嵌入到承诺链中。

在链接承诺时,我们必须小心处理 catch 处理程序。当承诺被拒绝时,它会跳转到下一个承诺拒绝处理程序。这可以是then处理程序的第二个参数或catch处理程序。在承诺被拒绝的地方和下一个拒绝处理程序之间的所有实现处理程序都将被忽略。当 catch 处理程序完成时,由catch()返回的承诺将以拒绝处理程序的返回值被实现。这意味着下一个承诺实现处理程序将获得一个值来运行。如果catch处理程序不是承诺链中的最后一个处理程序,承诺链将继续以catch处理程序的返回值运行。这可能是一个棘手的错误调试;然而,它允许我们捕获承诺拒绝,以特定方式处理错误,并继续承诺链。它允许承诺链以不同的方式处理拒绝或接受,然后继续进行异步工作。这在以下片段中显示:

// Promise chain handles rejection and continues
// apiCall1 is a function that returns a rejected promise
// apiCall2 is a function that returns a resolved promise
// apiCall3 is a function that returns a resolved promise
// errorHandler1 is a function that returns a resolved promise
myPromse.then( apiCall1 ).then( apiCall2, errorHandler1 ).then( apiCall3 ).catch( errorHandler2 );
片段 2.26:处理错误并继续

在前面的片段中,我们有一个承诺链,其中有三个连续的异步 API 调用,在myPromise解决后。第一个 API 调用将拒绝带有错误的承诺。拒绝的承诺由第二个 then 处理程序处理。由于承诺被拒绝,它忽略了apiCall2()并路由到errorHandler1()函数。errorHandler1()将执行一些工作并返回一个值或承诺。该值或承诺传递给下一个处理程序,该处理程序调用apiCall3(),它返回一个解决的承诺。由于承诺已解决且没有更多的then处理程序,承诺链结束。最终的 catch 被忽略。

要从一个拒绝处理程序跳到下一个拒绝处理程序,我们需要在拒绝处理程序函数内部抛出一个错误。这将导致返回的 promise 被拒绝,并跳到下一个catch处理程序。

如果我们希望在 promise 被拒绝时提前退出 promise 链并且不继续,应该只在链的末尾包含一个 catch 处理程序。当 promise 被拒绝时,拒绝会被找到的第一个处理程序处理。如果这个处理程序是 promise 链中的最后一个处理程序,链就结束了。如下面的片段所示:

// Promise chain handles rejection and continues
// apiCall1 returns a rejected promise
myPromse.then( apiCall1 ).then( apiCall2 ).then( apiCall3 ).catch( errorHandler1 );
片段 2.27:在链的末尾处理错误以中止

在前面片段中显示的 promise 链中,当 myPromise 解析为一个值时,第一个then处理程序被调用。apiCall1()被调用并返回一个被拒绝的 promise。由于接下来的两个then处理程序没有处理 promise 拒绝的参数,拒绝被传递给catch处理程序。catch 处理程序调用errorHandler1,然后 promise 链结束。

链接 promise 用于确保所有 promise 按照链的顺序完成。如果 promise 不需要按顺序完成,我们可以使用Promise.all()静态成员函数。Promise.all()函数不是在 promise 类的实例上创建的。它是一个静态类函数。Promise.all()接受一个 promise 数组,当所有 promise 都解决时,将调用 then 处理程序。then 处理程序函数的参数将是原始Promise.all()调用中每个 promise 的解决值的数组。解决值的数组将与输入到Promise.all()的数组的顺序匹配。如下面的片段所示:

// Create promises
let promise1 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 10 ), 100 ) );
let promise2 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 20 ), 200 ) );
let promise3 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 30 ), 10 ) );
Promise.all( [ promise1, promise2, promise3 ] ).then( results => console.log( results ) );
//Expected output: [ 10, 20, 30 ]
片段 2.28:Promise.all()示例

在上面的例子中,我们创建了三个 promise,分别在 100ms、200ms 和 10ms 后解决。然后将这些 promise 传递给Promise.all()函数。一旦所有 promise 都解决了,附加到Promise.all()函数的 then 处理程序将被调用。此处理程序记录 promise 的结果。请注意,结果数组的顺序与 promise 数组的顺序匹配,而不是 promise 的完成顺序。

如果Promise.all()调用中的一个或多个 promise 被拒绝,reject处理程序将被调用,并且会使用第一个 promise 的拒绝值。所有其他 promise 将继续运行,但是这些 promise 的拒绝或解决不会调用Promise.all() promise 链的任何thencatch处理程序。如下面的片段所示:

// Create promises
let promise1 = new Promise( ( resolve, reject ) => {
  setTimeout( () => { reject( 'Error 1' ); }, 100 );
} );
let promise2 = new Promise( ( resolve, reject ) => {
  setTimeout( () => { reject( 'Error 2' ); }, 200 );
} );
let promise3 = new Promise( ( resolve, reject ) => {
  setTimeout( () => { reject( 'Error 3' ); }, 10 );
} );
Promise.all( [ promise1, promise2, promise3 ] ).then( console.log ).catch( console.log );
// Expected output: 
// Error: Error 3
片段 2.29:Promise.all()拒绝

在这个例子中,我们创建了三个 promise,记录了 promise 编号,然后都被不同的错误拒绝。我们将这些 promise 传递给Promise.all调用。Promise3的超时时间最短,因此是第一个被拒绝的 promise。当Promise3被拒绝时,promise 拒绝被传递给最近的错误处理程序(.catch()),它记录了 promise 的拒绝。之后不久,promise1 和 promise2 都完成运行,并且都被拒绝。对于这些 promise,拒绝处理程序不会再次被调用。

处理多个 promise 的最后一个函数是Promise.race()函数。Promise.race()函数设计用来处理第一个被完成或拒绝的 promise。

注意

如果由于某种原因,您的程序存在有意的竞争条件或多个代码路径,只应该在成功的响应处理程序被调用一次时,Promise.race()是完美的解决方案。

Promise.all()一样,Promise.race()传递一个承诺数组;然而,Promise.race()只调用第一个完成的承诺的承诺完成处理程序。然后它按照正常的承诺链继续。其他承诺的结果被丢弃,无论它们是拒绝还是解决。使用Promise.race()处理承诺拒绝的方式与Promise.all()相同。只处理第一个拒绝的承诺。其他承诺被忽略,无论完成状态如何。Promise.race()的示例如下所示:

// Create promises
let promise1 = new Promise( ( resolve, reject ) => setTimeout( resolve( 10 ), 100 ) );
let promise2 = new Promise( ( resolve, reject ) => setTimeout( resolve( 20 ), 200 ) );
let promise3 = new Promise( ( resolve, reject ) => setTimeout( resolve( 30 ), 10 ) );
Promise.race( [ promise1, promise2, promise3 ] ).then( result => console.log( result ) );
//Expected output: 30
片段 2.30:Promise.race()示例

在上面的示例中,我们创建了三个承诺。这些承诺在各种超时后都会解决。Promise3首先解决,因为它的超时时间最短。当promise3解决时,then 处理程序被调用,并记录了promise3的结果。当promise1promise2解决时,它们的结果被忽略。

承诺和回调

承诺和回调永远不应该混合在一起。编写同时利用回调和承诺进行异步工作的代码可能会变得非常复杂,并导致极其难以调试的错误。为了防止混合回调逻辑和承诺逻辑,我们必须在我们的代码中添加 Shim 来处理回调作为承诺和承诺作为回调。有两种方法可以做到这一点:承诺可以包装在回调中,或者回调可以包装在承诺中。

注意

Shim 是用于向代码库添加缺失功能的代码文件。Shim 通常用于确保 Web 应用程序的跨浏览器兼容性。

将承诺包装在回调中

要将承诺函数包装在回调中,我们只需创建一个包装器函数,该函数接受promise函数、参数和callback。在wrapper函数内部,我们调用promise函数并传入提供的参数。我们附加thencatch处理程序。当这些处理程序解决时,我们调用callback函数并传递承诺返回的结果或错误。这在下面的片段中显示:

// Promise function to be wrapped
function promiseFn( args ){
  return new Promise( ( resolve, reject ) => {
    /* do work */ 
    /* resolve or reject */
  } );
}
// Wrapper function
function wrapper( promiseFn, args,  callback ){
  promiseFn( args ).then( value => callback( null, value )
         .catch( err => callback( err, null );
}
片段 2.31:在回调中包装承诺

在上面的示例中,我们使用承诺的结果调用了回调。如果承诺以一个值解决,我们将该值传递到回调中,错误字段设置为 null。如果承诺被拒绝,我们将错误传递到回调中,结果字段为 null。

要将基于回调的函数包装在承诺中,我们只需创建一个包装器函数,该函数接受要包装的函数和函数参数。在包装器函数内部,我们在一个新的承诺中调用被包装的函数。当回调返回结果或错误时,如果有错误,我们拒绝承诺,如果没有错误,我们解决承诺。这在下面的片段中显示:

// Callback function to be wrapped
function wrappedFn( args, cb ){
  /* do work */ 
  /* call cb with error or result */
}
// Wrapper function
function wrapper( wrappedFn, args ){
  return new Promise( ( resolve, reject ) => {
    wrappedFn( args, ( err, result ) => {
      if( err ) {
        return reject( err );
      }
      resolve( result );
    } );
  } );
}
片段 2.32:在承诺中包装回调

在上面的示例中,我们创建了一个包装器函数,该函数接受一个函数和该函数的参数。我们返回一个调用此函数的承诺,并根据结果拒绝或解决承诺。由于此函数返回一个承诺,因此它可以嵌入在承诺链中,或者可以附加 then 或 catch 处理程序。

结论

承诺是处理 JavaScript 中异步编程的另一种方式。创建时,承诺处于挂起状态,并根据异步工作的结果进入完成或拒绝状态。为了处理承诺的结果,我们使用.then().catch().finally()成员函数。.then()函数接受两个处理程序函数,一个用于承诺完成,一个用于承诺拒绝。.catch()函数只接受一个函数并处理承诺拒绝。Promise.finally()接受一个函数,并在承诺完成或拒绝时调用。

当需要运行多个 promise 但顺序不重要时,我们可以使用Promise.all()Promise.race()静态函数。当所有 promise 都完成运行时,将调用Promise.all()解析处理程序。当第一个 promise 完成运行时,将调用Promise.race()解析处理程序。

Promises 和回调不兼容,不应该在程序主体中混合使用。为了允许使用 promises 或回调函数的函数和模块之间的兼容性,我们可以编写一个包装函数。我们可以将回调包装在 promise 中,或将 promise 包装在回调中。这使我们能够使第三方模块与我们的代码兼容。

练习 19:使用 Promises

您正在构建一个基于 promise 的 API。在您的 API 中,您必须验证用户输入,以确保传递到数据库模型的数据是正确的类型。编写一个返回 promise 的函数。这个 promise 应该验证传递给 API 函数的数据值不是一个数字。如果用户将数字传递给函数,用错误拒绝 promise。如果用户将非数字传递给 API 函数,用单词Success!解析 promise。

构建一个使用实际场景的 promise 的函数,执行以下步骤:

  1. 编写一个名为promiseFunction的函数,它接受一个数据参数并返回一个 promise。

  2. 将一个接受两个参数 resolve 和 reject 的函数传递到 promise 的构造函数中。

  3. 在 promise 中,通过创建一个在 10ms 后运行的超时来开始执行异步工作。

  4. timeout回调函数中,记录提供给promiseFunction的输入数据。

  5. timeout回调中,检查数据的类型是否为数字。如果是,用错误拒绝 promise,否则用字符串Success!解析 promise。

  6. 运行promiseFunction并提供一个数字作为参数。将then()处理程序和catch()处理程序附加到函数返回的 promise 上。

注意

then处理程序应记录 promise 解析值。catch处理程序应记录错误的消息属性。

代码

Index.js
function promiseFunction( data ) {
 return new Promise( ( resolve, reject ) => {
   setTimeout( () => {
     console.log( data );
     if ( typeof data === 'number' ) {
       return reject( new Error( 'Data cannot be of type \'number\'.' ) );
     }
     resolve( 'Success!' );
   }, 10 );
 } );
}
promiseFunction( 1 ).then( console.log ).catch( err => console.log( 'Error: ${err.message}' ) );
promiseFunction( 'test' ).then( console.log ).catch( err => console.log( 'Error: ${err.message}' ) );
片段 2.33:实现 promises

https://bit.ly/2SRZapq

结果

图 2.10:作用域输出

图 2.10:作用域输出

异步/等待

异步/等待是一种新的语法形式,用于简化使用 promises 的代码。异步/等待引入了两个新关键字:asyncawaitasync添加到函数声明中,await用于async函数内部。这是令人惊讶地易于理解和使用。在其最简单的形式中,异步/等待允许我们编写基于 promise 的异步代码,看起来几乎与执行相同任务的同步代码相同。我们将使用异步/等待来简化使用 promises 的代码,并使其更容易阅读和理解。

异步/等待语法

async关键字被添加到函数声明中;它必须在函数关键字之前。async函数声明定义了一个异步函数。以下是async函数声明的示例声明:

async function asyncExample( /* arguments */  ){ /* do work */ }
片段 2.34:实现 promises

async函数隐式返回一个 promise,无论指定的返回值是什么。如果返回值被指定为非 promise 类型,JavaScript 会自动创建一个 promise,并用返回的值解析该 promise。这意味着所有异步函数都可以对返回值应用Promise.then()Promise.catch()处理程序。这允许与现有基于 promise 的代码非常轻松地集成。这在以下片段中显示:

async function example1( ){ return 'Hello'; }
async function example2( ){ return Promise.resolve( 'World' ); }
example1().then( console.log ); // Expected output: Hello
example2().then( console.log ); // Expected output: World
片段 2.35:异步函数输出

await关键字只能在async函数内部使用。Await 告诉 JavaScript 等待相关的承诺解决并返回其结果。这意味着 JavaScript 暂停执行该代码块,等待承诺被解决,同时做其他异步工作,然后在承诺解决后恢复该代码块。这使得等待的代码块像同步函数一样运行,但不会消耗任何资源,因为 JavaScript 引擎仍然可以做其他工作,比如运行脚本或处理事件,而异步代码正在等待。下面的片段中展示了await关键字的示例。

注意

尽管 async/await 功能使 JavaScript 代码看起来和行为上都像是同步的,但 JavaScript 仍然通过事件循环异步运行代码。

async function awaitExample( /* arguments */ ){ 
  let promise = new Promise( ( resolve, reject ) => {
    setTimeout( () => resolve( 'done!'), 100 );
  });
  const result = await promise;
  console.log( result ); // Expected output: done!
}
awaitExample( /* arguments */ );
片段 2.36:等待关键字

在前面的示例中,我们定义了一个async函数awaitExample()。由于它是一个async函数,我们可以使用 await 关键字。在函数内部,我们创建一个进行异步工作的承诺。在这种情况下,它只是等待 100 毫秒,然后用字符串done!解决承诺。然后我们等待创建的承诺。当承诺以一个值解决时,await 获取该值并返回它,该值保存在变量 result 中。然后我们将 result 的值记录在控制台中。我们不是使用 then 处理程序来获取解决值,而是简单地等待该值。这段代码的 await 块看起来类似于同步代码块。

异步/等待承诺拒绝

既然我们知道如何处理异步/等待的承诺兑现,那么我们如何处理承诺的拒绝呢?使用异步/等待处理错误拒绝非常简单,并且与标准的 JavaScript 错误处理非常契合。如果一个承诺被拒绝,等待该承诺解决的 await 语句会抛出一个错误。当在async函数内部抛出错误时,JavaScript 引擎会自动捕获,并且由async函数返回的承诺会被拒绝并携带该错误。这听起来有点复杂,但实际上非常简单。这些关系在下面的片段中展示:

async function errorExample1( /* arguments */ ){ 
  return Promise.reject( 'Rejected!' );
}
async function errorExample2( /* arguments */ ){ 
  throw 'Rejected!';
}
async function errorExample3( /* arguments */ ){ 
  await Promise.reject( 'Rejected!' );
}
errorExample1().catch( console.log ); // Expected output: Rejected!
errorExample2().catch( console.log ); // Expected output: Rejected!
errorExample3().catch( console.log ); // Expected output: Rejected!
片段 2.37:异步/等待承诺拒绝

在前面的片段中,我们创建了三个异步函数。在第一个函数errorExample1()中,我们返回一个被拒绝的承诺,携带字符串Rejected!。在第二个函数errorExample2()中,我们抛出字符串Rejected!。由于这是在async函数内部抛出的错误,async函数会将其包装在一个承诺中并返回一个携带抛出值的被拒绝的承诺。在这种情况下,它返回一个携带字符串Rejected!的被拒绝的承诺。在第三个函数errorExmaple3中,我们等待一个被拒绝的承诺。等待被拒绝的承诺会导致 JavaScript 抛出承诺拒绝值,即Rejected!。然后async函数捕获抛出的错误值,将其包装在一个承诺中,拒绝该承诺,并返回被拒绝的承诺。所有三个示例函数都返回一个携带相同值的被拒绝的承诺。

由于如果等待的承诺被拒绝,await 会抛出一个错误,我们可以简单地使用 JavaScript 中的标准 try/catch 错误处理机制来处理异步错误。这非常有用,因为它允许我们以相同的方式处理所有错误,无论是异步还是同步的。这在下面的示例中展示:

async function tryCatchExample() {
  // Try to do asynchronous work
  try{
    const value1 = await Promise.resolve( 'Success 1' );
    const value2 = await Promise.resolve( 'Success 2' );
    const value3 = await Promise.reject( 'Oh no!' );
  } 

  // Catch errors
  catch( err ){
    console.log( err ); // Expected output: Oh no!
  }
}
tryCatchExample()
片段 2.38:错误处理

在前面的示例中,我们创建了一个尝试进行异步工作的 async 函数。该函数尝试连续等待三个承诺。最后一个被拒绝,导致抛出一个错误。这个错误被catch块捕获和处理。

由于错误被包裹在承诺中,并且被异步函数拒绝,当一个承诺被拒绝时,等待会抛出错误,异步/等待函数错误向最高级别的等待调用传播。这意味着除非需要在各种嵌套级别上以特殊方式处理错误,否则我们可以简单地在最外层错误处使用一个 try catch 块。错误将通过被拒绝的承诺在异步/等待函数堆栈上传播,并且只需要被顶层等待块捕获。这在以下片段中显示:

async function nested1() { return await Promise.reject( 'Error!' ); }
async function nested2() { return await nested1; }
async function nested3() { return await nested2; }
async function nestedErrorExample() {
  try{ const value1 = await nested3; }
  catch( err ){ console.log( err ); } // Expected output: Oh no!
}
nestedErrorExample();
片段 2.39:嵌套错误处理

在前面的例子中,我们创建了几个异步函数,它们等待另一个异步函数的结果。它们按顺序调用nextedErrorExample() -> nested3() -> nested2() -> nested1()nested1()的主体等待一个被拒绝的承诺,这会引发错误。Nested1()捕获此错误并返回一个被拒绝的承诺。nested2()的主体等待nested1()返回的承诺。nested1()返回的承诺被原始错误拒绝,因此nested2()中的等待引发错误,并被nested2()包装在一个承诺中。这一直传播到nestedErrorExample()中的await。嵌套错误示例中的await引发错误,被捕获和处理。由于我们只需要在最高级别处理错误,因此我们将 try/catch 块放在最外层的等待调用处,并允许错误向上传播,直到遇到该 try/catch 块。

使用异步等待

现在我们知道如何使用异步/等待,我们需要将其集成到我们的承诺代码中。要将我们的承诺代码转换为使用异步/等待,我们只需要将承诺链分解为异步函数,并等待每个步骤。承诺处理程序链在每个处理程序函数(then()catch()等)处分开。承诺返回的值用await语句捕获并保存到一个变量中。然后将此值传递给第一个承诺then()承诺处理程序的回调函数,并且函数的结果应该用await语句捕获并保存到一个新变量中。对于承诺链中的每个then()处理程序都是如此。

为了处理错误和承诺拒绝,我们用 try catch 块包围整个块。以下片段中显示了一个例子:

// Promise chain - API functions return a promise
myPromse.then( apiCall1 ).then( apiCall2 ).then( apiCall3 ).catch( errorHandler );
async function asyncAwaitUse( myPromise ) {
  try{
    const value1 = await myPromise;
    const value2 = await apiCall1( value1 );
    const value3 = await apiCall2( value2 );
    const value4 = await apiCall3( value3 );
  } catch( err ){
    errorHandler( err );
  }
}
asyncAwaitUse( myPromise );
片段 2.40:集成异步/等待

正如我们在承诺链中看到的,我们将三个 API 调用和一个错误处理程序链接到myPromise的解决方案上。在每个承诺链步骤中,都会返回一个承诺,并附加一个新的Promise.then()处理程序。如果承诺链的某个步骤被拒绝,将调用 catch 处理程序。

在异步/等待示例中,我们在每个Promise.then()处理程序处中断承诺链。然后,我们将then处理程序转换为返回承诺的函数。在这种情况下,apiCall1()apiCall2()apiCall3()已经返回承诺。然后我们等待每个 API 调用步骤。要处理承诺的拒绝,我们必须用 try catch 语句包围整个块。

就像承诺链中有多个链接的 then 处理程序一样,具有多个等待调用的async函数将依次运行每个等待调用,直到前一个等待调用从相关承诺中接收到一个值为止,才开始下一个等待调用。如果我们试图同时完成几个异步任务,这可能会减慢异步工作的速度。我们必须等待每个步骤完成,然后才能开始下一步。为了避免这种情况,我们可以使用Promise.allawait

正如我们之前学到的,Promise.all 同时运行所有子承诺,并返回一个未完成的承诺,直到所有子承诺都以一个值解决。我们可以像附加 then 处理程序到 Promise.all 一样等待 Promise.all。通过等待 Promise.all 调用返回的值,只有当所有子承诺都完成时才能使用。这在下面的片段中显示:

async function awaitPromiseAll(){
  let promise1 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 10 ), 100 ) );
  let promise2 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 20 ), 200 ) );
  let promise3 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 30 ), 10 ) );
  const result = await Promise.all( [ promise1, promise2, promise3 ] );
  console.log( result ); //Expected output: [ 10, 20, 30 ]
}
awaitPromiseAll();
片段 2.41:并行等待承诺

从前面的示例中可以看出,我们创建了几个承诺,将这些承诺传递给 Promise.all 调用,然后等待 Promise.all 返回的承诺的解决。这遵循了 async/await 的规则,就像我们期望的那样。这个逻辑也可以应用到 Promise.race

在下面的片段中显示了一个 promise 竞赛的示例:

async function awaitPromiseAll(){
  let promise1 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 10 ), 100 ) );
  let promise2 = new Promise( ( resolve, reject ) => setTimeout( () => resolve( 20 ), 200 ) );
  const result = await Promise.race( [ promise1, promise2 ] );
  console.log( result ); //Expected output: 10]
}
awaitPromiseAll();
片段 2.42:Promise 竞赛示例

结论

Async/await 是一个令人惊奇的新语法格式,它帮助我们简化基于承诺的代码。它允许我们编写看起来像同步代码的代码。Async/await 引入了两个关键字,asyncawait。Async 用于表示一个 async 函数。在声明函数时,它在函数关键字之前添加。Async 函数总是返回一个承诺。await 关键字只能在承诺上的 async 函数中使用。它告诉 JavaScript 引擎等待承诺解决,并在拒绝或实现时抛出错误或返回值。Async/await 错误处理通过抛出的错误和拒绝的承诺来完成。async 函数自动捕获抛出的错误并返回一个以该错误拒绝的承诺。等待的承诺在拒绝时抛出错误。这使得错误处理可以轻松地与标准的 JavaScript try/catch 错误处理相结合。Async/await 非常容易集成到基于承诺的代码中,并且可以使其非常易于阅读。

活动 2:使用 Async/Await

你被要求构建一个与数据库交互的服务器。你必须编写代码在数据库中创建和查找基本用户对象。导入 simple_db.js 文件。使用 getinsert 命令,使用 async/await 语法编写以下程序:

  1. 查找 john 键,如果存在,记录结果对象的年龄字段。

  2. 查找 sam 键,如果存在,记录结果对象的年龄。

  3. 查找你的名字。如果不存在,插入你的名字。如果必须添加一个对象,查找新对象并记录年龄。

对于任何失败的 db.get 操作,将键保存到数组中。在程序结束时,打印失败的键。

DB API:

db.get( index ):

这需要一个索引并返回一个承诺。如果索引不存在,查找失败,或者未指定键,则承诺将以错误拒绝。

db.insert( index, insertData ):

这需要一个索引和数据,并返回一个承诺。如果操作完成,承诺将以插入的键实现。如果操作失败,或者没有指定键或插入数据,承诺将以错误拒绝。

利用承诺和 async/await 语法构建程序,执行以下步骤:

  1. 编写一个名为 mainasync 函数。所有操作都将在这里进行。

  2. 创建一个数组来跟踪导致 db 错误的键。

  3. 捕获所有错误并记录它们。

  4. 在所有 try-catch 块之外,在 main 函数的末尾,返回数组。

  5. 调用主函数并附加 then()catch() 处理程序到返回的承诺。

结果

图 2.11:作用域输出

图 2.11:作用域输出

您成功地使用了承诺和 async/await 语法来构建一个访问数据库的程序。

注意

此活动的解决方案可以在第 282 页找到。

总结

JavaScript 是一种异步、事件驱动、单线程的语言。JavaScript 不会在长时间运行的操作中挂起到另一个资源,而是在任何待处理的工作时进行其他操作。JavaScript 通过事件循环实现这一点。事件循环由调用堆栈、堆、事件队列和主事件循环组成。这四个组件共同工作,安排 JavaScript 何时运行代码的不同部分。为了利用 JavaScript 的异步特性,我们使用回调或者 Promise。回调只是作为参数传递给其他函数的简单函数。Promise 是具有事件处理函数的特殊类。当异步操作完成时,JavaScript 引擎运行回调或调用与该操作的完成事件相关联的 Promise 处理程序。这就是 JavaScript 异步的最简单形式。

在下一章中,我们将学习文档对象模型(DOM)、JavaScript 事件对象jQuery 库

第三章:DOM 操作和事件处理

学习目标

在本章结束时,您将能够做到以下几点:

  • 解释 DOM 遍历和操作

  • 创建事件对象和浏览器事件

  • 组织事件传播和冒泡

  • 高效地委托事件

  • 利用 jQuery 处理事件和 DOM 操作

本章将涵盖处理文档节点、事件对象以及链式、导航和处理事件的过程。

介绍

在第一章中,我们涵盖了 ES6 中发布的许多新的强大功能。我们讨论了 JavaScript 的发展,并突出了 ES6 中的关键新增功能。我们讨论了作用域规则、变量声明、箭头函数、模板文字、增强对象属性、解构赋值、类和模块、转译以及迭代器和生成器。

在第二章中,我们涵盖了 JavaScript 的异步编程范式。我们讨论了 JavaScript 事件循环、回调、承诺和 async/await 语法。本章使我们能够应用第一章,介绍 ECMAScript 6中的材料,并编写强大的异步程序。

在本章中,我们将学习文档对象模型(DOM)JavaScript 事件对象。在第一个主题中,我们将定义文档对象模型并解释 DOM 链式、导航和操作。然后,我们将解释 JavaScript 事件对象,并展示如何与处理 DOM 事件进行交互。在本章中,我们将涵盖 jQuery,并使用它来遍历 DOM 和处理事件。

DOM 链式、导航和操作

文档对象模型(DOM)是 HTML 文档的接口。DOM 以一种程序可以更改文档结构、样式和内容的方式来表示网页。DOM 是网页的面向对象表示。

DOM 有两个标准:万维网联盟(W3C)标准和Web 超文本应用技术工作组(WHATWG)标准。WHATWG 是为了应对 W3C 标准的缓慢发展而开发的。这两个标准都将 HTML 元素定义为可以被 JavaScript 代码访问的对象,并为所有 HTML 元素定义了属性、访问器方法和事件。DOM 对象方法是您可以在 HTML 元素上执行的操作,DOM 对象属性是您可以获取或设置的值。DOM 标准提供了一种让 JavaScript 以编程方式添加、获取、更改或删除 HTML 元素的方法。

注意

W3C DOM 标准和 WHATWG DOM 标准由大多数现代浏览器(Chrome、Firefox 和 Edge)实现,并且许多浏览器扩展了这些标准。在与 DOM 进行交互时,我们必须确保我们使用的所有函数与我们的用户可能使用的浏览器兼容。

网页的 DOM 构造为对象树,称为节点。树的顶部对象是文档节点文档是作为网页内容、DOM 树的入口点的接口。页面中的 HTML 元素被添加到文档下的 DOM 树中。它们被称为元素节点

DOM 树中的元素与其周围元素有三种类型的关系:父级同级子级。元素的父元素是包含它的元素。元素的同级节点是同样包含在父元素中的元素。元素的子节点是它包含的元素。以下是一个示例 DOM 树的图示:

图 3.1:DOM 树结构

图 3.1:DOM 树结构

在前面的图表中,我们可以看到全局父级是文档对象。文档对象有一个子节点,即<html>元素。<html>元素的父节点是文档,它有两个子节点,即<head><body>元素。<head><body>元素是彼此的兄弟节点,因为它们都有相同的父节点。

练习 20:从 DOM 树结构构建 HTML 文档

这里的目标是创建一个名为"My title"的网页,显示标题"My header"和链接"My link"。参考前面的图表以获取 DOM 树结构。

要从 DOM 树结构构建 HTML 文档,请执行以下步骤:

  1. 创建一个 HTML 文件。

  2. 在文件中添加一个<html>标签。

  3. <html>标签内添加一个<head>标签。

  4. <head>标签后添加一个<title>标签。

  5. <title>标签中添加文本My title

  6. <head>标签下方添加一个<body>标签。

  7. <body>标签下添加<a><h1>元素。

  8. <a>标签添加href属性,并将其内部文本设置为My link

  9. <h1>标签中添加文本My header

  10. 关闭bodyhtml标签并获取输出。

代码

index.js
<html>
  <head>
    <title>My title</title>
  </head>
  <body>
    <a href>My link</a>
    <h1>My header</h1>
  </body>
</html>

https://bit.ly/2FiLgcE

片段 3.1:演示 DOM 树的简单网站

结果

图 3.2:我的标题链接输出

图 3.2:我的标题链接输出

您已成功从 DOM 树结构构建了 HTML 文档。

DOM 导航

现在我们了解了 DOM 的基本结构,我们准备在我们的应用程序中开始与它进行接口。在我们可以用 JavaScript 修改 DOM 之前,我们必须导航 DOM 树以找到我们想要修改的特定元素节点。我们可以通过两种方式之一找到特定节点:通过标识符找到导航 DOM 树。最快的查找方法是通过标识符查找元素。DOM 元素可以通过以下四种方式之一查找:

  • ID

  • 标签名

  • CSS 查询选择器

查找 DOM 节点

通过document.getElementById( id )方法可以通过 ID 获取元素。该方法接受一个表示要查找的元素 ID 的参数 id,并返回一个元素对象。返回的对象将是描述指定 ID 的 DOM 节点的元素对象。如果没有匹配提供的 ID 的元素,则该函数将返回 null。以下是getElementById函数的示例:

<div id="exampleDiv">Some text here</div>
<script>
  const elem = document.getElementById( 'exampleDiv' );
</script>
片段 3.2:通过 ID 获取元素

通过document.getElementsByTagName( name )方法可以通过标签名获取元素。该函数接受一个表示要搜索的 HTML 标签名的参数。getElementsByTagName返回一个匹配给定标签名的元素的实时HTMLCollection。返回的列表是实时的,这意味着它会自动更新与 DOM 树。不需要多次使用相同的元素和参数调用该函数。以下是getElementsByTagName的示例:

<div id="exampleDiv1">Some text here</div>
<div id="exampleDiv2">Some text here</div>
<div id="exampleDiv3">Some text here</div>
<script>
  const elems = document.getElementsByTagName( 'div' );
</script>
片段 3.3:按标签名获取元素

注意

HTMLCollection是表示元素节点集合(类似数组的对象)的接口。它可以被迭代,并提供用于从列表中选择的方法和属性。

要通过类名获取元素,我们使用document.getElementsByClassName( name )方法。该函数接受一个表示要搜索的 HTML 类名的参数,并返回一个匹配给定类名的元素的实时HTMLCollection。以下是getElementsByClassName的示例:

<div class="example">Some text here</div>
<img class="example"></img>
<style class="example"></style>
<script>
  const elems = document.getElementsByClassName( 'example' );
</script>
片段 3.4:按类名获取元素

querySelector()querySelectorAll()这两个函数用于通过 CSS 查询选择器获取 HTML 元素。它们都接受一个表示 CSS 选择器字符串的单个字符串参数。querySelector将返回一个单个元素。querySelectorAll将返回与查询匹配的元素的静态(非实时)NodeList。可以通过创建包含每个选择器的逗号分隔字符串将多个查询选择器传递给函数。如果将多个选择器传递给查询选择器函数,函数将匹配并返回满足任何选择器要求的元素。querySelectorquerySelectorAll的功能如下片段所示:

<div id="id1">Some text here</div>
<img class="class"></img>
<script>
  const elem = document.querySelector( 'img.class' );
  const elems = document.querySelectorAll( 'img.class, #id1' );
</script>
片段 3.5:使用 CSS 选择器获取元素

注意

NodeList类似于HTMLCollection。它是一个类似数组的 HTML 节点集合,可以进行迭代。

之前介绍的每个方法及其函数语法如下表所示:

图 3.3:方法和语法

图 3.3:方法和语法

getElementsByTagNamegetElementsByClassNamequerySelectorquerySelectorAll函数不仅限于文档对象;它们也可以在元素节点上调用。如果它们在元素节点上调用,函数返回的结果元素集合将仅限于函数调用的元素的子节点。以下示例显示了这一点。

示例:我们获取具有 iddiv1的 div 元素对象,并将其保存在elem变量中。然后我们使用getElementsByTagName来获取其他 div 元素。该函数在保存在elem中的元素对象上调用,因此搜索范围仅限于div1的子节点。getElementsByTagName将返回一个包含 divsdiv2div3HTMLCollection,因为它们是div1的后代:

<div id="div1">
  <div id="div2">
    <div> id="div3"> Some text here </div>
  </div>
</div>
<div> Some other text here </div>
<script>
  const elem = document.getElementById( 'div1' );
  const elems = elem.getElementsByTagName( 'div' );
</script>
片段 3.6:返回 HTMLCollection

查找 DOM 元素的第二种方法是通过导航 DOM 树来查找元素关系。一旦找到要处理的 DOM 元素,我们可以使用多个属性来获取该元素的子节点、父节点和兄弟节点。我们可以通过使用parentNodechildNodesfirstChildlastChildpreviousSiblingnextSibling属性从一个节点到另一个节点遍历 DOM 树。

parentNode属性返回节点的父节点。父节点是 DOM 树中的一个节点,该节点是其后代。父节点始终存在,除非在文档节点上调用parentNode。由于文档节点位于 DOM 树的顶部,它没有父节点,调用parentNode将返回 null。可以使用parentNode属性遍历 DOM 树。以下示例显示了parentNode的用法:

<div id="div1">
  <div id="div2">
    <div id="div3"> Some text here </div>
  </div>
</div>
<script>
  const div3 = document.getElementById( 'div3' );
  const div2 = div3.parentNode;
  const div1 = div2.parentNode;
</script>
片段 3.7:父节点

nextSiblingpreviousSibling属性用于获取 DOM 树中节点的兄弟节点。previousSibling将返回 DOM 树中的前一个兄弟节点(添加到当前节点之前的父节点的兄弟节点),nextSibling将返回 DOM 树中的下一个兄弟节点(添加到当前节点之后的父节点的兄弟节点)。在绘制 DOM 树时,通常将节点的前一个兄弟节点显示在左侧,下一个兄弟节点显示在右侧。可以使用nextSiblingpreviousSibling函数横向遍历 DOM 树。以下示例显示了这些属性:

<div id="div0">
  <div id="div1"> Some text here </div>
  <div id="div2"> Some text here </div>
  <div id="div3"> Some text here </div>
</div>
<script>
  const div2 = document.getElementById( 'div2' );
  const sibling1 = div2.previousSibling; //div1
  const sibling2 = div2.nextSibling; // div3
</script>
片段 3.8:遍历兄弟节点

最后三个属性用于导航到节点的子节点;它们是childNodesfirstChildlastChildchildNodes属性返回元素的子节点的实时NodeListfirstChildlastChild属性分别返回子NodeList中的第一个或最后一个节点。以下片段显示了这些属性的使用:

<div id="div0">
  <div id="div1"> Some text here </div>
  <div id="div2"> Some text here </div>
  <div id="div3"> Some text here </div>
</div>
<script>
  const div0 = document.getElementById( 'div0' );
  const child1 = div0.firstChild; //div1
  const child2 = div0.childNodes[1]; // div2
  const child3 = div0.lastChild; // div3
</script>
片段 3.9:遍历兄弟节点

遍历 DOM

DOM 树导航属性总结如下表:

图 3.4:DOM 树导航属性

图 3.4:DOM 树导航属性

DOM 操作

当您编写应用程序或网页时,您拥有的最强大的工具之一是以某种方式操纵文档结构。这是通过 DOM 操作函数来完成的,用于控制 HTML 并为应用程序或页面设置样式。能够在用户使用应用程序或网站时操纵 HTML 文档,使我们能够动态更改页面的部分而无需完全重新加载内容。例如,当您在手机上使用消息应用时,应用的代码正在操纵您正在查看的文档。每次发送消息时,它都会更新文档以附加构成消息的元素和样式。我们可以操纵 DOM 的三种基本方式。我们可以添加元素或节点,删除元素或节点,以及更新元素或节点。

向 DOM 树添加新元素是交互应用程序的必备功能。在您使用的大多数 Web 应用程序中都有许多示例。谷歌的 Gmail 和微软的 Skype 在您使用应用程序时都会主动向 DOM 添加元素。向 DOM 添加新元素有两个步骤。首先,我们必须为要添加的元素创建一个节点,然后我们必须将新节点添加到 DOM 树中。

要创建新元素或节点,我们可以使用document.createElement()Node.cloneNode()document.createTextNode()函数。CreateElement是在全局文档对象上调用的,并接受两个参数。第一个是tagNametagName是一个字符串,指定要创建的元素类型。如果我们想要创建一个新的 div 元素,我们将通过tagName传递div字符串。第二个参数是一个可选参数,称为 options。Options 是一个包含单个属性的ElementCreationObject,名为'is'。此属性允许我们指定要添加的元素是否是自定义元素。我们将不使用此属性,但知道它的用途很重要。CreateElement返回一个新创建的 Element 对象。document.createElement()的语法和用法如下片段所示:

<script>
  const newElem = document.createElement( 'div' );
</script>
片段 3.10:使用 document.createElement

新的元素节点也可以使用cloneNode函数创建。cloneNode是在 DOM 节点对象上调用的,并复制调用它的节点。它接受一个名为deep的布尔值作为参数,并返回要克隆的节点的副本。如果deep设置为falsecloneNode将进行浅克隆,只克隆调用它的节点。如果deep设置为truecloneNode将进行深度复制,并复制节点及其所有子节点(节点的完整 DOM 树)。克隆节点会复制其所有属性及其值。这包括在 HTML 中内联添加的事件监听器,但不包括通过addEventListener用 JavaScript 添加的监听器,或者通过元素属性分配的监听器。

以下是cloneNode的示例:

<div id="div1">
  <div id="div2"> Text </div>
</div>
<script>
  const div1 = document.getElementById( 'div1' );
  const div1Clone = div1.cloneNode( false );
  const div1Div2Clone = div1.cloneNode( true )
</script>
片段 3.11:克隆节点

在前面的例子中,我们创建了一个包含两个 div 的文档,div1div2div2嵌套在div1中。在前面的代码中,我们通过 id 选择了div1,并通过浅nodeClone将其克隆到div1Clone中。然后我们进行了深度nodeClone,并将div1及其嵌套的子元素div2克隆到div1Div2Clone中。

注意

cloneNode可能会导致文档中出现重复的元素 id。如果复制具有 id 的节点,则应更新该节点的 id 属性为唯一值。

DOM 的规范最近已经更新。在 DOM4 规范中,cloneNodedeep 是一个可选参数。如果省略,该方法将默认将值设置为 true,使用深克隆作为默认行为。要创建浅克隆,必须将 deep 设置为 false。在最新的 DOM 规范中,此行为已更改。deep 仍然是一个可选参数;但是,默认值为 false。我们建议始终提供 deep 参数以实现向后和向前兼容性。

CreateTextNode 用于创建仅包含文本的节点。当用文本填充页面时,会使用仅包含文本的 DOM 节点。我们使用 createTextNode 将新文本放入像 div 这样的元素中。CreateTextNode 接受一个参数,一个名为 data 的字符串,并返回一个文本节点。createTextNode 的示例如下所示:

<script>
  const textNode = document.createTextNode( 'Text goes here' );
</script>
片段 3.12:创建文本节点

现在我们知道如何创建新的 DOM 节点,我们必须将新节点添加到 DOM 树中,以便在应用程序中看到更改。我们可以使用两个函数添加新节点:Node.appendChild()Node.insertBefore()。这两个函数都是在 DOM 节点对象上调用的。

Node.appendChild 将节点添加到其调用的节点的子节点列表的末尾。Node.appendChild 接受一个参数 aChild,并返回附加的子节点。aChild 参数是我们要附加到父节点的子节点列表的节点。如果 appendChild 传入的是已经存在于 DOM 树中的节点,该节点将从当前位置移动到 DOM 中的新位置,作为指定父节点的子节点。如果 appendChild 传入的是 DocumentFragment,则 DocumentFragment 的整个内容将移动到父节点的子节点列表中,并返回一个空的 Document Fragment。appendChild 的语法和用法如下所示:

<div id="div1"></div>
<script>
  const div1 = document.getElementById( 'div1' ); 
  const aChild = document.createElement( 'div' );
  parent.appendChild( aChild );
</script>
片段 3.13:使用 appendChild 插入节点

注意

DocumentFragment 只是一个没有父节点的 DOM 树。

在前面的示例中,我们创建了一个带有 div div1 的 HTML 文档。然后我们创建了一个新的 div div2,然后使用 appendChild 函数将其附加到 div1 的子列表中。

节点还可以使用 Node.insertBefore() 函数插入到 DOM 中。insertBefore 函数将节点插入到其调用的节点的子节点列表中,位于指定的参考节点之前。insertBefore 函数接受两个参数,newNodereferenceNode,并返回插入的节点。newNode 参数表示我们要插入的节点。referenceNode 参数是父节点的子节点列表中的一个节点或值 null。如果 referenceNode 是父节点子列表中的一个节点,newNode 将插入到该节点之前,但如果 referenceNode 是值 nullnewNode 将插入到父节点的子节点列表的末尾。与 Node.appendChild() 类似,如果函数给定要插入的节点已经在 DOM 树中,该节点将从其在 DOM 树中的旧位置中移除,并作为父节点的子节点放置在新位置。InsertBefore 还可以插入整个 DocumentFragment。如果 newNodeDocumentFragment,函数将返回一个空的 DocumentFragment

appendChild 的示例如下所示:

<div id="div1">
  <div id="div2"></div>
</div>
<script>
  const div1 = document.getElementById( 'div1' );
  const div2 = document.getElementById( 'div2' );
  const div3 = document.createElement( 'div' );
  const div4 = document.createElement( 'div' );
  div1.insertBefore( div3, div2 );
  div1.insertBefore( div4, null );
</script>
片段 3.14:使用 insertBefore 插入节点

在前面的示例中,我们创建了一个带有嵌套子 div div2 的 div div1。在脚本中,我们通过元素 id 获取了 div1div2。然后我们创建了两个新的 div,div3div4。我们将 div3 插入到 div1 的子列表中。我们将 div2 作为参考节点传递,因此 div3 被插入到 div1 的子列表中 div2 的前面。然后我们将 div4 插入到 div1 的子列表中。我们将 null 作为参考节点传递。这会导致 div4 被追加到 div1 的子列表的末尾。

注意

referenceNode参数不是可选参数。你必须明确传入一个节点或值 null。不同的浏览器和浏览器版本对无效值的解释不同,应用功能可能会受到影响。

操作 DOM 的另一个关键功能是能够从 DOM 树中删除 DOM 节点。这个功能可以在 Gmail 和 Facebook 中看到。当你在 Gmail 中删除一封邮件或删除 Facebook 的帖子时,与该邮件或帖子相关的 DOM 元素将从 DOM 树中删除。DOM 节点的移除是通过Node.removeChild()函数完成的。RemoveChild从其被调用的父节点中移除指定的子节点。它接受一个参数 child,并返回被移除的子 DOM 节点。child 参数必须是父节点的子节点列表中的一个子节点。如果子元素不是父节点的子节点,将抛出异常。

下面的片段展示了removeChild功能的示例:

<div id="div1">
  <div id="div2"></div>
</div>
<script>
  const div1 = document.getElementById( 'div1' );
  const div2 = document.getElementById( 'div2' );
  div1.removeChild( div2 );
</script>
片段 3.15:从 DOM 中删除节点

在前面的示例中,我们创建了一个 div,div1,带有一个嵌套的子 div,div2。在脚本中,我们通过元素 id 获取了两个 div,然后从div1的子节点列表中移除了div2

现在我们可以向 DOM 添加和删除节点,修改已经存在的节点将非常有用。节点可以通过以下方式进行更新:

  • 替换节点

  • 更改内部 HTML

  • 更改属性

  • 更改类

  • 更改样式

更新 DOM 中的节点

修改 DOM 节点的第一种方法是完全用新的 DOM 节点替换它。DOM 节点可以使用Node.replaceChild()函数替换任何一个子节点。ReplaceChild替换父节点的一个子节点,并用一个新指定的节点调用它。它接受两个参数,newChildoldChild,并返回被替换的节点(oldChild)。oldChild参数是将被替换的父节点子节点列表中的节点,newChild参数是将替换oldChild的节点。

下面的片段展示了这个示例:

<div id="div1">
  <div id="div2"></div>
</div>
<div id="div3"></div>
<script>
  const div1 = document.getElementById( 'div1' );
  const div2 = document.getElementById( 'div2' );
  const div3 = document.getElementById( 'div3' );
  div1.replaceChild( div3, div2 );
</script>
片段 3.16:替换 DOM 中的节点

在前面的示例中,我们创建了两个 div,div1div2Div1创建了一个嵌套的子 div,div2。在脚本中,我们通过元素 id 获取每个 div。然后我们用div3替换了div1的子元素div2

操作 DOM 节点的第二种方法是通过更改节点的内部 HTML。节点的innerHTML属性可用于获取或设置元素中包含的 HTML 或 XML 标记。该属性可用于更改元素子元素中的当前 HTML 代码。它可以用于更新或覆盖 DOM 树中元素下方的任何内容。要将 HTML 插入节点,将innerHTML参数设置为包含要添加的 HTML 元素的字符串。传递到参数中的字符串将被解析为 HTML,并创建新的 DOM 节点;然后将它们作为子节点添加到引用该属性的父节点中。下面的片段展示了innerHTML属性的示例:

<div id="div1"></div>
<script>
  const div1 = document.getElementById( 'div1' );
  div1.innerHTML = '<p>Paragraph1</p><p>Paragraph2</p>';
</script>
片段 3.17:替换节点的 innerHTML

注意

设置innerHTML的值会完全覆盖旧值。DOM 节点将被移除,并用从 HTML 字符串解析出的新节点替换。

出于安全原因,innerHTML不会解析和执行 HTML 字符串中<script>标签内包含的脚本。然而,还有其他方法可以通过innerHTML属性执行 JavaScript。你永远不应该使用innerHTML来追加你无法控制的字符串数据。

操作元素节点的第三种方法是通过更改节点的属性。元素节点属性可以通过三个函数进行交互:Element.getAttribute()Element.setAttribute()Element.removeAttribute()。这三个函数都必须在元素节点上调用。

注意

应用于元素节点的一些属性具有特殊的含义。在添加或移除属性时要小心。HTML 属性列表如下所示:developer.mozilla.org/en-US/docs/Web/HTML/Attributes

getAttribute函数接受一个参数,即属性的名称,并返回指定属性的值。如果属性不存在,函数将返回 null 或空字符串("")。现代 DOM 规范规定该函数应返回 null 的值,大多数浏览器遵循这一规范,但一些浏览器仍遵循旧的 DOM3 规范,该规范规定正确的返回值应为空字符串。重要的是要处理这两种情况。

setAttribute函数用于设置或更新指定属性的值。它接受两个参数,namevalue,并不返回任何值。name参数是要设置的属性的名称。value参数是要设置的属性的字符串值。如果传入的值不是字符串,它将在设置之前转换为字符串。由于值被转换为字符串,将属性设置为对象或 null 将不会得到预期的值。属性将被设置为传入值的字符串化版本。

removeAttribute函数从节点中移除指定的属性。它接受一个参数attrName,并不返回任何值。attrName参数是要移除的属性的名称。您可以使用removeAttribute来代替尝试使用setAttribute将属性的值设置为 null。下面的片段中展示了getAttributesetAttributeremoveAttribute的示例:

<div id="div1"></div>
<script>
  const div1 = document.getElementById( 'div1' );
  div1.setAttribute( 'testName', 'testValue' );
  div1.getAttribute( 'testName' );
  div1.removeAttribute( 'testName' );
</script>
片段 3.18:获取、设置和移除属性

在前面的例子中,我们创建了一个名为div1的 div。然后我们通过其 id 获取该 div,添加testName属性,并将其值设置为testValue。然后我们获取testName的值并将其移除。

操作节点的第四种方式是通过更改其类信息。元素类信息用于关联类似的 HTML 元素以进行样式和分组。可以通过两种方式访问元素的类,即className属性或classList属性。className属性返回一个包含所有元素类信息的字符串。该属性可用于获取或设置类值。classList属性返回一个实时的DOMTokenList对象。这个对象只是当前类信息的实时列表,具有特殊的方法用于获取和更新类信息。

更新 DOM 中的节点

classList对象有六个辅助函数。它们在下表中详细说明:

图 3.5:辅助函数

图 3.5:辅助函数

以下是这些辅助函数在下面的片段中使用:

<div id="div1" class="testClass"></div>
<script>
  const classes = document.getElementById( 'div1' ).classList;
  classes.add( 'class1', 'class2' ); // adds class1 and class2
  classes.remove( 'testClass' ); // removes testClass
  classes.item( 1 ); // gets class at index 1: class2
  classes.toggle( 'class2' ); // removes class2 because it exists
  classes.contains( 'class2' ); // checks for class2: false
  classes.replace( 'class1', 'class0' ) // replaces class1 with class3  
</script>
片段 3.19:使用 classList 对象

我们通常修改节点的第五种和最后一种方式是通过样式对象。样式对象反映了节点的 CSS 样式,每个元素节点都有一个样式对象。样式对象可以通过Element.style获得。样式对象包含了可以分配给对象的每个 CSS 样式的属性。这个对象是只读的,所以不应该直接通过覆盖样式对象来设置元素样式。相反,我们应该改变样式对象的各个属性:

<div id="div1" style="color:blue">Hello World!</div>
<script>
  const style = document.getElementById( 'div1' ).style;
  style[ 'color' ]; // Returns blue
  style[ 'background-color' ] = 'red'; // Sets background-color to red
</script>
片段 3.20:使用 classList 对象

注意

可以在www.w3schools.com/jsref/dom_obj_style.asp上找到所有可用样式属性的列表。

DOM 操作是网页的最重要部分之一。DOM 可以通过查找、添加、删除和更新树中的节点来进行操作。我们可以通过唯一的 id、类或 CSS 查询选择器等多种方式找到 DOM 节点。一旦找到了 DOM 节点,我们可以通过移动到该元素的子节点、同级节点或父节点来遍历 DOM 树。要向 DOM 树中添加新元素,我们必须首先创建一个新的元素节点,然后将该元素附加到 DOM 中的某个位置。要删除一个元素,我们只需获取要删除的元素的节点,然后调用节点的删除函数。要更新一个节点,我们可以更改其属性、属性或直接替换节点。DOM 操作允许我们构建动态网页,这一点非常重要。

结论

从 HTML 代码构建的 Web 文档由文档对象模型(DOM)表示。DOM 是从节点构建的类似树的结构。每个节点对应 HTML 源代码中的一个元素。作为程序员,我们可以与 DOM 交互,动态更新网页。我们可以通过查找、创建、删除和更新元素节点与 DOM 进行交互。结合所有这些概念,我们可以创建可以根据用户交互更新视图的动态网页。几乎每个网站都可以看到这种功能,包括亚马逊、Facebook 和 Google。

练习 21:DOM 操作

您的团队正在构建一个电子邮件网站。该网站需要从 JSON 文件中加载用户的电子邮件数据,并动态填充加载的电子邮件数据的表格。电子邮件在示例代码文件中提供。电子邮件表应显示发件人收件人主题字段,并为每封电子邮件创建一行。使用电子邮件对象通过本章学习的 DOM 操作来构建 DOM 中的电子邮件表。

使用 DOM 操作技术构建 JavaScript 的电子邮件列表,执行以下步骤:

  1. 打开名为exercise的文件,路径为/exercises/exercise21/exercise.html

  2. 在文件底部的script标签中,编写 JavaScript 代码(在Code下,本练习的末尾)。

  3. 创建一个新的表元素(<table>)并将其保存到一个名为table的变量中。

  4. 使用大括号({})创建一个新的作用域块。

创建一个数组来保存表头类型ToFromSubject。将数组保存到变量headers中。

创建一个表行元素(<tr>)并将其保存在变量row中。使用forEach函数循环遍历headers数组。

  1. forEach的回调函数中,执行以下操作:

创建表头元素(<th>)并将其保存到header变量中。使用appendChild(),将一个新的文本节点附加到header。文本节点应包含header名称。

将存储在header中的表头元素作为子元素附加到存储在row中的表行中。

  1. 将存储在row中的表行作为子元素附加到存储在table中的表中。输出如下图所示:图 3.6:步骤 4 输出
图 3.6:步骤 4 输出
  1. 使用大括号({})创建一个新的作用域块。

  2. 使用forEach循环遍历数据数组data,并执行以下操作:

创建一个新的表行元素(<tr>)并将其保存在row变量中。创建另一个新的表数据元素(<td>)并将其保存在to变量中。

接下来,创建另外两个表数据元素(<td><td>),并将它们保存为变量(subject和`from)。

将一个文本节点附加到存储在to中的表数据元素,该文本节点包含forEach循环的数据对象的to值。将另一个文本节点附加到存储在from中的表数据元素,该文本节点包含forEach循环的数据对象的from值。

将一个文本节点附加到存储在subject中的表数据元素,该文本节点包含forEach循环的数据对象的subject值。

将存储在to中的元素附加到存储在row中的表行。将存储在from中的元素附加到存储在row中的表行。

将存储在subject中的元素附加到存储在row中的表行。将存储在row中的行附加到存储在table中的表。

  1. 获取emailTableHolder DOM 节点,并将存储在table变量中的表作为子节点附加。

  2. 在 Web 浏览器中加载 HTML 文件以查看结果。

图 3.7:最终输出

图 3.7:最终输出

代码

solution.html
const table = document.createElement( 'table' );
const row = document.createElement( 'tr' );
[ 'to', 'from', 'subject' ].forEach( h => { 
  const header = document.createElement( 'th' );
  header.appendChild( document.createTextNode( h ) );
  row.appendChild( header );
} );
table.appendChild( row );
data.forEach( email => {
  const row = document.createElement( 'tr' );
  /* code omitted for brevity */
  table.appendChild( row );
} );
document.getElementById( 'emailTableHolder' ).appendChild( table );
Snippet 3.21: 使用 DOM 操作创建电子邮件列表

https://bit.ly/2FmvdK1

结果

您已成功分析了 DOM 操作技术。

DOM 事件和事件对象

DOM 事件是功能性和响应式 Web 应用程序的基础。事件在任何具有任何形式用户交互的网站中使用。Facebook、Google 和 Skype 等网站都大量使用事件。事件是告诉程序员有关 DOM 节点发生了某事的信号。几乎可以出于任何原因触发事件。我们可以使用 JavaScript 来监听事件,并在事件发生时运行函数。

DOM 事件

DOM 事件是由 DOM 节点发送的通知,以通知程序员 DOM 节点发生了某事。这可以是用户单击元素、在键盘上按键或视频播放结束等任何事情。可以触发许多事件。可以为触发的每个事件附加事件侦听器。事件侦听器是等待事件触发然后调用事件处理程序的接口。事件处理程序是响应事件运行的代码。事件处理程序是我们作为程序员分配给事件的 JavaScript 函数。这称为注册事件处理程序。

注意

可以在此处找到完整的事件列表:developer.mozilla.org/en-US/docs/Web/Events

添加事件处理程序的最佳方法是使用addEventListener函数。addEventListener函数设置指定的事件处理程序在指定类型的事件触发时被调用。该函数接受三个参数,typelistener,以及optionsuseCapture。第一个参数 type 是要监听的区分大小写的事件类型。第二个参数 listener 是可以接收通知的对象,通常是 JavaScript 函数。选项和useCapture参数是可选的,您只能提供其中之一。选项参数指定具有captureoncepassive属性的选项对象。在选项参数中,名为'capture'的属性是一个布尔值,指示事件将在推送到 DOM 树之前分派给事件处理程序。名为'once'的属性是一个布尔值,指示事件处理程序在调用一次后是否应该被移除。名为'passive'的属性是一个布尔值,指示事件处理程序永远不会调用preventDefault函数(在处理事件子主题中讨论)。useCapture 参数的功能与options.capture属性相同。

事件侦听器

事件侦听器可以附加到任何 DOM 节点。要附加事件侦听器,我们必须选择需要监听事件的节点,然后我们可以在该节点上调用addEventListener函数。如下面的代码片段所示:

<button id="button1">Click me!</button>
<script>
  const button1 = document.getElementById( 'button1' );
  button1.addEventListener( 'click', () => {
    console.log( 'Clicked' );
  }, false );
</script>
Snippet 3.22: 获取、设置和移除属性

在前面的示例中,我们创建了一个 ID 为button1的按钮。在脚本中,我们选择了该按钮并添加了一个事件侦听器。事件侦听器监听点击事件。当发生点击事件时,它调用处理程序函数,该函数记录到控制台。

注意

您可能会在 HTML 代码中看到内联事件处理程序,例如,<button onclick="alert('Hello!')">Press me</button>。您不应该这样做。最佳做法是保持 JavaScript 和 HTML 分开。当您混合 HTML 和 JavaScript 时,代码很快就会变得难以管理、低效,并且更难解析和解释。

在以后的时间,如果我们决定不再需要事件监听器,我们可以使用removeEventListener函数将其移除。removeEventListener函数从指定的事件类型中移除指定的处理程序函数。它接受与addEventListener相同的参数。要正确地移除事件监听器,removeEventListener必须与添加的监听器匹配。removeEventListener会查找具有相同类型、监听器函数和捕获选项的监听器。如果找到匹配项,则移除事件监听器。以下是removeEventListener的示例:

<button id="button1">Click me!</button>
<script>
  const button1 = document.getElementById( 'button1' );
  function eventHandler() { console.log( 'clicked!' }
  button1.addEventListener( 'click', eventHandler, true );
  button1.removeEventListener( 'click', eventHandler, true );
</script>
片段 3.23:获取、设置和删除属性

在前面的示例中,我们创建了一个带有 id button1的按钮。在脚本中,我们通过添加单击事件的事件监听器来获取该按钮。然后,我们移除相同的监听器,提供与addEventListener函数提供的完全相同的参数,以便removeEventListener可以正确匹配我们要移除的监听器。

事件对象和处理事件

每个事件处理程序函数都接受一个参数。这是事件对象。您经常会看到此参数被定义为eventevt或简单地e。它会自动传递给事件处理程序,以提供有关事件的信息。事件处理程序可以利用事件对象中的信息来操作 DOM,并允许用户与页面交互:

<div id="div1">Click me!</div>
<script>
  const div1 = document.getElementById( 'div1' );
  button1.addEventListener( 'click', ( e ) => {
    e.target.style.backgroundColor = 'red';
  }, false);
</script>
片段 3.24:使用事件处理程序操作 DOM

可以通过调用事件类的新实例(new Event())来创建事件对象的新实例。构造函数接受两个参数:typeoptions。类型是事件的类型,选项是一个可选对象,包含以下字段:bubblescancelablecomposed。这三个字段也都是可选的。bubbles 属性指示事件是否应该冒泡。cancelable属性指示事件是否可以被取消。composed 属性指示事件是否应该触发阴影根之外的监听器。这三个默认值都为 false。

事件对象具有许多有用的属性和函数。这些属性可以被利用来获取有关事件的附加信息。例如,我们可以使用Event.target属性来获取最初触发事件的 DOM 节点,或者我们可以使用Event.type来查看事件的名称。当您希望为多个元素使用相同的处理程序时,Event.target非常有用。我们可以重用处理程序,只需使用Event.target来检查哪个元素触发了事件,而不是为每个事件创建一个新的处理程序函数。

当从 DOM 元素触发事件时,它会通知附加到 DOM 节点的事件监听器。然后,事件会传播或冒泡,直到达到树的顶部为止。这种效果称为事件传播或事件冒泡。它允许我们通过减少页面中所需的事件监听器数量来使我们的代码更加高效。如果我们有一个具有许多子元素的元素,它们都需要相同的用户交互,我们可以将单个事件监听器添加到父元素,并捕获从子节点冒泡上来的任何事件。这称为事件委托。我们委托事件处理给父节点,而不是将监听器附加到每个子节点。

事件传播

事件传播可以通过stopPropagation函数进行控制。这个函数是事件对象中的许多函数之一。StopPropagation不带任何参数。当调用它时,它会阻止当前事件的进一步传播。这意味着它完全捕获了事件,并阻止它向上冒泡到任何其他父节点。停止事件传播在使用委派时或者在子节点和父节点上有监听同一事件但执行不同任务时非常有用。

触发事件

标准 DOM 事件由浏览器自动触发。JavaScript 为我们提供了两个非常强大的工具,允许我们更多地控制页面中事件的触发。第一个工具是通过 JavaScript 触发事件。第二个是自定义事件。

在本章的前面部分,我们学到可以创建事件对象的新实例。如果我们不能触发事件并使 DOM 树知道发生了什么,单独的事件就不是很有用。DOM 节点有一个成员函数dispatchEvent(),允许我们触发或分发事件对象的实例。DispatchEvent()应该在您希望从事件节点触发的 DOM 节点上调用。它接受一个参数并返回一个布尔值。这个参数是将在目标 DOM 节点上触发的事件对象。如果事件是可取消的并且处理事件的一个事件处理程序被调用Event.preventDefault()DispatchEvent()的布尔返回值将为 false。否则,dispatchEvent()将返回 true。以下是dispatchEvent()的示例:

const event = new MouseEvent( 'click' , { 
  bubbles:true,
  cancelable: true
} );
const element = document.getElementById( 'button' );
const canceled = element.dispatchEvent(event);
片段 3.26:触发事件

如果事件的类型没有正确指定,dispatchEvent方法将抛出UNSPECIFIED_EVENT_TYPE_ERR错误。这意味着如果事件的类型为 null 或空字符串,或者在调用dispatchEvent()之前未初始化事件,则会抛出运行时错误。

重要的是要注意,使用dispatchEvent()触发的事件不会通过事件循环异步调用。由 DOM 节点触发的正常事件会通过事件循环异步调用事件处理程序。当使用dispatchEvent()时,事件处理程序会同步调用。所有适用的事件处理程序都会在代码继续执行dispatchEvent调用后执行并返回。如果有许多事件处理程序或者其中一个事件处理程序做了大量同步工作,其他事件可能会被阻塞。

注意

一些浏览器实现了fireEvent()函数,用于在 DOM 节点上触发事件。这个函数是一个非标准函数,在大多数浏览器上不起作用。不要在生产代码中使用这种方法。

练习 22:处理您的第一个事件

要设置事件侦听器并捕获触发的事件,请执行以下步骤:

  1. 创建一个带有body标签的 HTML 文件。

  2. body标签内,创建一个文本为“点击我!”的按钮,并将其 id 设置为button1

  3. 在按钮后添加一个script标签。

  4. script标签中,通过 id 选择按钮并将其保存到button1变量中。

  5. 为存储在button1中的元素添加click事件的事件侦听器。

注意

回调应调用警报函数并用“点击!”字符串警报浏览器。

代码

index.html
<html>
<body>
 <button id="button1">Click me!</button>
 <script>
   const button1 = document.getElementById( 'button1' );
   button1.addEventListener( 'click', ( e ) => {
     alert('clicked!');
   }, false );
 </script>
</body>
</html>
片段 3.25:DOM 事件处理

https://bit.ly/2M0Bcp5

结果

图 3.8:步骤 2 点击我!按钮
图 3.8:步骤 2 点击我!按钮

图 3.9:输出视图

图 3.9:输出视图

您已成功设置了事件侦听器并捕获了触发的事件。

自定义事件

JavaScript 还允许创建自定义事件。自定义事件是一种触发事件和监听具有自定义类型的事件的方式。事件的类型可以是任何非空字符串。创建自定义事件的最基本方式是使用事件类型作为自定义事件名称初始化事件对象的新实例。这是通过以下语法完成的:const event = new Event( 'myCustomEvent' )。像这样创建事件不允许向事件添加任何自定义信息或属性。要创建带有附加信息的自定义事件,我们可以使用CustomEvent类。CustomEvent类构造函数接受两个参数。第一个参数是表示我们要创建的自定义事件的类型名称的字符串。第二个参数是表示自定义事件初始化选项的对象。它接受与传递给事件类初始化器的选项相同的字段,另外还有一个名为detail的字段。详细字段默认为 null,是与事件相关的与事件关联的值。我们想要传递给自定义事件的任何信息都可以通过详细参数传递。此参数中的数据也传递给所有监听自定义事件的处理程序。

注意

事件构造函数适用于所有现代浏览器,除了 Internet Explorer。为了与 IE 完全兼容,必须使用稍后讨论的createEvent()initEvent()方法,或者使用polyfill来模拟CustomEvent类。

为了最大限度地提高代码浏览器兼容性,我们还必须讨论用于创建自定义事件的initEvent()createEvent()方法。这些方法已被弃用并从 Web 标准中删除。但是,一些浏览器仍然支持这些功能。要在旧版浏览器中创建自定义事件,必须首先使用var event = document.createEvent( 'Event' )创建事件(在旧版浏览器中必须使用var而不是const),然后使用event.initEvent()初始化新事件。CreateEvent()接受一个参数,类型。这是将要创建的事件对象的类型。此类型必须是标准 JavaScript 事件类型之一,例如EventMouseEvent等。InitEvent()接受三个参数。第一个参数是表示事件类型名称的字符串。例如,点击事件的类型是click。第二个参数是表示事件冒泡行为的布尔值。第三个参数是表示事件可取消行为的布尔值。这两种行为在本主题的事件对象和处理事件部分中进行了讨论。

为了捕获和处理自定义事件,我们可以使用标准的事件监听器行为。我们所需要做的就是使用addEventListener()附加一个监听自定义事件类型的事件监听器。例如,如果我们创建了一个事件类型为myEventCustomEvent,我们只需要添加一个事件监听器来监听这个类型,使用addEventListener( 'myEvent', e => {} )。每当类型为myEvent的事件被触发时,添加的事件监听器回调函数将被调用。

当调用事件监听器回调时,回调中的事件参数将具有一个额外的字段detail。此字段将包含通过自定义事件选项对象的detail字段传递给自定义事件的信息。与自定义事件相关的任何信息都应通过detail对象传递。详细对象的示例如下所示:

const element = document.getElementById( 'button' );
element.addEventListener( 'myClick', e => {
  console.log( e.detail );
} );
const event = new CustomEvent( 'myClick' , { detail: 'Hello!' } );
const canceled = element.dispatchEvent( event );
片段 3.27:在详细信息中触发自定义事件

练习 23:处理和委托事件

您正在构建一个购物清单页面,以帮助忙碌的购物者管理购物清单,而无需纸和笔。我们的购物清单应用程序将是一个带有表、文本输入和添加行按钮的页面。添加行按钮将在购物清单表中添加新行。添加的行包含购物清单项目(来自文本输入的文本)和一个删除按钮。删除按钮将从购物清单表中删除该行。

以下步骤将构建应用程序:

  1. exercises/exercise23/exercise.html中打开起始文件。

  2. 在 HTML body中的userInteractionHolder div中,添加一个文本输入和一个按钮。

给按钮添加 idaddButton

向具有 idshoppingListdiv添加一个表元素。

向在上一步创建的表(id="shoppingList")中添加一行。

向表添加两个标题项,一个带有文本Item,另一个带有文本Remove

图 3.10:第 2 步后的输出

图 3.10:第 2 步后的输出
  1. script标签中,通过其 id 选择按钮,并添加一个点击监听器,调用_addRow函数。创建_addRow函数,具有以下功能:

接受一个参数e,即事件对象。使用 DOM 遍历,使用事件目标上的previousSibling属性获取文本输入。将文本输入元素节点保存到变量inputBox中。

将文本框中的值保存到value变量中。

通过将其设置为空字符串("")来清除文本区域的值。

创建一个表行元素,并将其保存在row变量中。

使用 DOM 操作和链接将表数据元素附加到表行。将文本节点附加到表数据元素。

注意

文本节点应包含存储在value中的值。

  1. 使用 DOM 操作和链接将表数据附加到表行。

  2. 将按钮附加到表数据元素。

  3. 将文本remove附加到按钮上。

  4. 返回到按钮元素。

  5. 为按钮添加监听器,并让它调用_removeRow函数。

  6. 选择shopingList表,并将行附加到其中。

  7. 创建_removeRow函数,具有以下功能:

接受一个参数e,其中将包含事件对象。

使用 DOM 遍历,获取发生按钮点击的行元素,并使用parentNode属性。记录行元素。

使用 DOM 遍历和链接,获取包含行的表,然后从表中删除行:

图 3.11:最终输出

图 3.11:最终输出

代码

solution.html
<body>
<h1>Shopping List</h1>
<div id="userInteractionHolder">
  <!-- add a text input and an add button -->
</div>
 <div id="shoppingListHolder">
  <!-- add a table with a row for column headers -->
</div>
<script>
  /* add event listener to add button */
  function _addRow( e ) { /* get input data and add row with it */ }
  function _removeRow( e ) { /* get row from event and it */ }
</script>
片段 3.28:使用 DOM 操作和事件处理构建购物清单应用程序

https://bit.ly/2D1c3rC

结果

您已成功应用事件处理概念来构建一个有用的 Web 应用程序。

jQuery

jQuery 是一个轻量级的 JavaScript 库,旨在简化 DOM 交互。它是在 Web 开发中使用最广泛的库之一。jQuery 旨在简化对 DOM 的调用,并使代码更加流畅。在这个主题中,我们将概述 jQuery 是什么,如何在项目中安装 jQuery,jQuery 基础知识,使用 jQuery 进行 DOM 操作以及使用 jQuery 处理事件。

jQuery 是一个旨在使 DOM 遍历、操作、事件处理、动画和 AJAX 请求更简单使用,并使使用这些元素的代码更加流畅的库。jQuery 是一个广泛的 JavaScript 库。对 JavaScript 的深入理解对于发挥 jQuery 的所有功能至关重要。

jQuery 提供了一个易于使用的 API,具有广泛的跨浏览器兼容性。jQuery 实现了他们所谓的“当前”浏览器支持。这只是意味着 JQuery 将在浏览器的当前发布版本和上一个发布版本(v23.x 和 22.x,但不是 v21.x)上运行并得到支持。代码可能会在旧的浏览器版本上成功运行,但对于出现在旧的浏览器版本中的任何错误,JQuery 的错误修复将不会被推送。jQuery 浏览器兼容性还延伸到 Android 和 IOS 设备上的原生移动浏览器。

注意

完整的文档可以在官方 JQuery 网页上找到: jquery.com/

安装 jQuery 的第一种方法是直接下载源 JavaScript 文件。这些文件可以在code.jquery.com找到。JavaScript 文件可以直接添加到项目的文件结构中。由于文件大小较小,应在生产代码中使用缩小版本。

注意

代码缩小是从源代码中删除不必要字符而不改变其功能的过程。缩小是为了减小代码文件的大小。这对 JavaScript、HTML 和 CSS 文件很重要,因为它减少了发送和加载网页所需的资源。

安装 JQuery 的第二种方法是使用包管理器。用于此目的的最流行的命令行包管理器是 NPM、Yarn 和 Bower。本书的最后一章将更详细地讨论 NPM。要使用这些 CLI(命令行界面)包管理器之一安装 jQuery,首先安装和配置相关的包管理器。要使用 NPM 安装,运行以下命令:npm install jquery。这将把 jQuery 文件放在node_modules/jquery/dist/文件夹下的node_modules文件夹中。要使用 Yarn 安装,使用以下命令:yarn add jquery。要使用 Bower 安装,使用以下命令:bower install jquery。使用 Bower 安装将把文件放在bower_components/jquery/dist/文件夹下的bower_components文件夹中。

一旦安装了 JQuery,我们就可以开始将 jQuery 加载到我们的项目中。这只需要在 HTML 文件中添加一个脚本标签即可。在主 HTML 文件中,只需添加一个带有 jQuery 库文件路径的脚本标签()。JQuery 现在已安装并准备好在项目中使用!

jQuery 基础

JQuery 是围绕选择和处理 DOM 节点构建的库。默认情况下,所有 JQuery 操作都可以在库名称jQuery和快捷变量$下使用。我们将通过引用快捷变量来调用所有 JQuery 函数。

在创建或选择 DOM 节点时,jQuery 始终返回一个 JQuery 对象的实例。JQuery 对象是一个类似数组的集合,其中包含零索引序列的 DOM 元素、一些熟悉的数组函数和属性,以及所有内置的 JQuery 方法。关于 JQuery 对象有两点很重要。首先,JQuery 对象不是活动对象。JQuery 对象的内容不会随着 DOM 树的更改而更新。如果 DOM 已更改,可以通过重新运行相同的 JQuery 选择器来更新 JQuery 对象。其次,JQuery 对象也不相等。使用相同查询构建的两个 JQuery 对象之间的相等比较将不会是真值。要比较 JQuery 对象,必须检查集合中包含的元素。

注意

零索引意味着对象具有可以用来引用项目序列中的项目的数字属性(0、1、2、…、n)。

JQuery 对象不是数组。JQuery 对象上可能不存在内置数组属性和函数。

jQuery 选择器

JQuery 的核心功能围绕选择和操作 DOM 元素展开。这是通过 jQuery 核心选择器来实现的。要选择 DOM 元素,我们调用 jQuery 选择器函数jQuery( selector ),或者简写为$( selector )。传递给 jQuery 函数的选择器几乎可以是任何有效的 CSS 选择器、回调函数或 HTML 字符串。如果传递给 JQuery 选择器的是 CSS 选择器,将返回一个匹配元素的集合,这些元素将在一个 JQuery 对象中返回。如果传递给选择器的是 HTML 字符串,将从提供的 HTML 字符串创建一个节点集合。如果传递给选择器函数的是回调函数,当 DOM 加载完成时将运行回调。jQuery 还可以接受一个 DOM 节点,并从中创建一个 JQuery 对象。如果将 DOM 节点传递给 jQuery 选择器函数,该节点将自动被选择并返回到一个 JQuery 集合中。下面的片段展示了 JQuery 选择器函数的一个示例:

const divs = $( "div" ); // JQuery select all divs
const div1 = document.getElementById( 'div1' ); // DOM select a div
const jqueryDiv1 = $( div1 ); // Create a JQuery object from div
片段 3.29:选择 DOM 节点

大多数 jQuery 函数都是在一组 DOM 节点($())上操作的;然而,jQuery 也提供了一组不是这样的函数。这些函数直接通过\(变量引用。这两者之间的区别对于新的 jQuery 用户来说可能会令人困惑。记住这个区别最简单的方法是注意到`\)命名空间中的函数通常是实用方法,不适用于选择。有些情况下,选择器方法和核心实用方法具有相同的名称,例如\(.each()`和`\)().each()`。在阅读 jQuery 文档和学习新函数时,一定要确保你正在探索正确的函数。

在创建基本 DOM 结构之前,HTML 页面的 DOM 不能安全地进行操作。JQuery 提供了一种安全等待 DOM 准备就绪的方法。这是通过ready() JQuery 对象函数来实现的。这个函数应该在包含 HTML 文档的 jQuery 对象上调用($( document ).ready())。ready()函数接受一个参数,一个回调函数。一旦 DOM 准备就绪,这个函数就会运行。操作 DOM 的代码应该放在这个回调函数中。

在 JavaScript 中使用多个库时,命名空间冲突总是一个问题。jQuery 及其所有插件和功能都包含在jQuery命名空间中。因此,jQuery 和任何其他库之间不应该有冲突。然而,有一个例外,jQuery 默认使用$作为 jQuery 命名空间的快捷方式。如果你使用另一个使用$变量的库,可能会与 jQuery 发生冲突。为了避免这种情况,你可以将 jQuery 置于无冲突模式。要做到这一点,调用 jQuery 命名空间上的noConflict()函数(jQuery.noConflict())。这将打开无冲突模式,并允许你为 jQuery 库分配一个新的快捷变量名。变量名可以是任何你喜欢的,从$mySuperAwesomeJQuery。启用无冲突模式并更改 jQuery 快捷变量名的完整示例如下片段所示:

<script src="jquery.js"></script>
<script>
  // Set the jQuery alias to $j instead of $
  const $j = jQuery.noConflict();
</script>
片段 3.30:启用无冲突模式

jQuery DOM 操作

JQuery 是围绕 DOM 操作构建的。在这里,我们将介绍 JQuery DOM 操作的基础知识。我们将从选择元素开始,然后转向遍历和操作 DOM,最后以链式操作结束。

选择元素

DOM 操作的第一步始终是选择要处理的 DOM 节点。JQuery 最基本的概念是“选择一些元素并对其进行操作”。jQuery 通过选择器函数$()非常容易地选择元素。jQuery 支持大多数 CSS3 选择器来选择节点。选择元素的最简单方法是通过 id、类名、属性和 CSS 来选择元素。

通过将 CSS 元素 id 选择器传递给 jQuery 选择器函数来选择元素:$( '#elementId' )。这将返回一个包含匹配该 id 的元素的 JQuery 对象。通过类名选择与通过 id 选择的方式相同。将 CSS 类名选择器传递给 jQuery 选择器函数:$( '.className' )。这将返回一个包含所有匹配该类名的元素的 jQuery 对象。通过属性选择元素是通过将属性 CSS 选择器传递给 jQuery 选择器函数来完成的:$( "div[attribute-name='example']" )。这将返回一个包含所有匹配指定元素类型和属性名称/值的元素的 JQuery 对象。jQuery 还支持更复杂的选择器。您可以传递复合 CSS 选择器、逗号分隔的选择器列表和伪选择器,如:visible。这些选择器都返回包含匹配元素的 JQuery 对象。

注意

如果 jQuery 选择器没有匹配任何节点,它仍然会返回一个 JQuery 对象。JQuery 对象的集合中将没有节点,并且对象的长度属性将等于零。如果要检查选择器是否找到节点,必须检查长度属性,而不是 JQuery 对象的真实性。

一旦您选择了一些节点,就可以使用 JQuery 对象函数来过滤和细化选择。一些非常有用的简单函数包括has()not()filter()first()eq()。所有这些函数都接受一个选择器,并返回一个带有过滤节点集的 JQuery 对象。has()函数将列表过滤为包含其后代与提供给has()的 CSS 选择器匹配的元素。not()函数将 JQuery 对象的节点过滤为仅包含不匹配提供的 CSS 选择器的节点。filter()函数将节点过滤为仅显示与提供的 CSS 选择器匹配的节点。first()返回 JQuery 对象内部节点列表中的第一个节点。eq()函数返回一个包含该索引处节点的 JQuery 对象。这些方法的完整深入文档以及其他过滤方法可以在 JQuery 网站上找到。

遍历 DOM

一旦使用 jQuery 选择器选择了节点,我们可以遍历 DOM 以查找更多元素。DOM 节点可以沿着三个方向遍历:到父节点、到子节点和到兄弟节点。

遍历父节点有很多种方式,但最简单的方式之一是在 JQuery 对象上调用四个函数中的一个。第一种遍历父节点的方式是调用parent()函数。这个函数简单地返回一个包含原始节点的父节点的 JQuery 对象。第二个函数是parents()函数。这个函数接受一个 CSS 选择器,并返回一个包含匹配节点的 JQuery 对象。parents()遍历 DOM 树,选择与提供的查询条件匹配的任何父节点,直到树的顶部。如果没有给出条件,它将选择所有父节点。第三个父遍历函数是parentsUntil()函数。它也接受一个 CSS 选择器,并返回一个 JQuery 对象。这个函数遍历父树,选择元素,直到它达到与提供的选择器匹配的元素。与提供的选择器匹配的节点不包括在新的 JQuery 对象中。最后一个方法是closest()方法。这个函数接受一个 CSS 选择器,并返回一个包含与提供的选择器匹配的第一个父节点的 JQuery 对象。

注意

closest()总是从包含它被调用的 JQuery 对象中的节点开始搜索。如果传递给closest()的选择器与该节点匹配,它将始终返回自身。

遍历子节点可以通过两种简单的方式轻松完成:children()find()children()函数接受一个 CSS 选择器,并返回一个 JQuery 对象,该对象是调用它的节点的直接后代,并且匹配选择器。find()函数接受一个 CSS 选择器,并返回 DOM 树中任何匹配提供的 CSS 选择器的后代节点的 JQuery 对象,包括嵌套的子节点。

遍历兄弟节点可以通过next()prev()siblings()函数以最简单的方式完成。next()获取下一个兄弟节点,prev()获取上一个兄弟节点。这两个函数都返回 JQuery 对象中的新节点。siblings()接受一个 CSS 选择器,并选择匹配提供的选择器的元素的兄弟节点(前一个和后一个)。prev()next()也有类似的函数:prevAll()prevUntil()nextAll()nextUntil()。正如你所期望的那样,All函数选择所有之前或之后的节点。Until函数选择节点,直到但不包括与提供的 CSS 选择器匹配的节点。

修改 DOM

现在我们可以选择 DOM 节点了,我们需要学习如何修改和创建它们。要创建一个节点,我们可以简单地将 HTML 字符串传递给选择器函数。JQuery 将解析 HTML 字符串并创建字符串中的节点。这样做的方式是:$('<div>')。HTML 字符串将被解析为 div 元素,并返回一个包含该元素的 JQuery 对象。

要向 DOM 添加元素,我们可以使用append()before()after()函数。append()函数接受一个 JQuery 对象,并将其附加到调用append()函数的 JQuery 对象的子节点中。然后返回一个包含调用append()函数的节点的 JQuery 对象。before()after()函数以类似的方式工作。它们都接受一个 JQuery 对象,并在调用它们的 JQuery 对象中的节点之前或之后插入它。

要删除 DOM 节点,我们可以使用remove()detach()函数。remove()永久删除与函数传入的 CSS 选择器匹配的节点。remove()返回一个包含已删除节点的 JQuery 对象。所有事件监听器和相关数据都将从节点中删除。如果它们返回到 DOM 中,监听器和数据将需要重新设置。detach()删除节点但保留事件和数据。与remove()一样,它返回一个包含已分离节点的 JQuery 对象。如果打算最终将节点返回到页面上,则应使用detach()

使用 JQuery 修改节点非常简单。一旦我们选择了节点,遍历了树,然后将选择过滤到单个节点,我们就可以调用 JQuery 对象函数来修改属性和 CSS 等内容。要修改属性,我们可以使用attr()函数。attr()接受两个值。第一个是要修改的属性的名称。第二个值设置属性等于什么。要修改元素的 CSS,我们可以使用css()函数。此函数接受两个参数。第一个参数是要修改的 CSS 属性。第二个参数值设置 CSS 属性等于什么。这两个函数也可以用作get函数。如果省略第二个值,attr()css()函数将返回属性或 CSS 属性的值,而不是设置它。

链接

大多数 jQuery 对象函数返回 jQuery 对象。这使我们能够链接调用,并且不需要用分号和换行符分隔每个函数调用。在链接 jQuery 函数时,jQuery 会跟踪对选择器和 JQuery 对象中的节点的更改。我们可以使用end()函数将当前选择恢复到其原始选择。以下是一个示例:

$( "#myList" )
  .find( ".boosted" ) // Finds descendents with the .boosted class
  .eq( 3 ) // Select the third index of the <li> filtered list
    .css( 'background-color', 'red' ) // Set css
    .end() // Restore selection to .boosted items in #myList
  .eq( 0 )
    .attr( 'age', 23 );
片段 3.31:链接和.end()

jQuery 事件

如前面在DOM 事件部分讨论的那样,任何响应灵敏和功能的网页都必须依赖事件。jQuery 还提供了一个简单的接口来添加事件处理程序和处理事件。

注册处理程序

使用 jQuery 注册事件非常简单。jQuery 对象提供了许多注册事件的方法。注册事件的最简单方法是使用on()函数。On()可以使用两组不同的数据进行调用。

设置事件监听器的第一种方法是使用on()调用四个参数:eventsselectordatahandler。Events 是一个以空格分隔的事件类型和可选的命名空间字符串(click hover scroll.myPlugin)。将为提供的每个事件创建一个监听器。第二个参数是选择器。这是可选的。如果提供了 CSS 选择器字符串,则事件监听器也将添加到与选择器匹配的所选元素的所有后代元素。第三个参数是数据。这可以是任何内容,也是可选的。如果提供了数据,那么在触发事件时它将被传递到事件对象的数据字段中。最后一个参数是处理程序函数。这是在事件触发时将被调用的函数。

设置事件监听器的第二种方法是使用三个参数调用on()eventsselectordata。与第一种方法类似,事件指定将创建监听器的事件。但是,在这种情况下,事件是一个对象。键是事件名称,将为其设置监听器的值,而值是事件触发时将被调用的函数。与第一种方法一样,选择器和数据参数是可选的。它们的功能与第一种方法相同。

要删除事件监听器,我们可以使用off()方法。使用 off 删除事件监听器的最简单方法是提供要删除监听器的事件的名称。与on()一样,我们可以通过空格分隔的字符串或对象提供事件类型。

触发事件

jQuery 提供了一种从 JavaScript 中触发事件的简单方法:trigger()函数。trigger()函数应该用于触发事件,并接受事件类型和无限数量的额外参数。事件类型是将被触发的事件类型。额外的参数将传递给事件处理程序函数,并在事件对象之后作为参数传递。

自定义事件

jQuery 中的自定义事件非常简单。与 Vanilla JavaScript 中的自定义事件不同,在 jQuery 中,要为自定义事件设置事件处理程序,我们只需要使用on()函数创建一个监听器,事件类型为自定义事件。要触发事件,我们只需要使用trigger()来触发它,事件类型为自定义事件。

活动 3:实现 jQuery

您想制作一个控制家庭智能 LED 照明系统的网络应用程序。您有三个 LED,可以单独打开或关闭,或者全部一起切换。您必须构建一个简单的 HTML 和 jQuery 界面,显示灯的开启状态,并具有控制灯的按钮。

要使用 JQuery 构建一个功能应用程序,请执行以下步骤:

  1. 使用命令行上的npm run init设置一个 Node.js 项目并安装 jQuery。

  2. 创建一个加载 jQuery 脚本的 HTML 文件。

  3. 在 HTML 文件中,添加三个起始为白色的 div。

  4. 在 div 上方添加一个切换按钮,并在每个 div 后面添加一个按钮。

  5. 为每个按钮设置点击事件的事件监听器。

  6. 切换按钮应更改所有 div 的颜色。其他按钮应更改相关div的颜色。

在颜色变化时,将颜色在黑色和白色之间切换

代码

结果

图 3.12:步骤 4 输出后

图 3.12:步骤 4 输出后

图 3.13:步骤 6 输出后

](image/Figure_3.13.jpg)

图 3.13:步骤 6 输出后

您已成功利用 jQuery 构建了一个功能应用程序。

注意

本活动的解决方案可以在第 285 页找到。

总结

网络开发围绕着文档对象模型和事件对象展开。JavaScript 被设计成能够快速高效地与 DOM 和 DOM 事件进行交互,为我们提供强大而丰富的互动网页。在本章的第一个主题中,我们讨论了 DOM 树,并讨论了导航和操作 DOM 的方法。在本章的第二个主题中,我们讨论了 JavaScript 事件对象,展示了如何与 DOM 事件交互,并演示了如何设置处理程序来捕获事件。在本章的最后一个主题中,我们讨论了 jQuery 模块。我们讨论了 jQuery 对象和 jQuery 选择器,并展示了如何使用 jQuery 进行 DOM 操作和事件处理。通过学习本章的内容,您应该已经准备好开始编写自己的强大而丰富的互动网页。

在下一章中,您将分析测试的好处,并建立代码测试环境。

第四章:测试 JavaScript

学习目标

在本章结束时,您将能够做到以下几点:

  • 分析测试的好处

  • 解释代码测试的各种形式

  • 构建代码测试环境

  • 为您的 JavaScript 代码实施测试

本章将涵盖测试的概念、测试框架以及如何有效地测试代码的不同方式。

介绍

在第一章中,我们介绍了 ES6 中发布的许多新功能和强大功能。我们讨论了 JavaScript 的发展历程,并突出了 ES6 中的关键添加。我们讨论了作用域规则、变量声明、箭头函数、模板文字、增强的对象属性、解构赋值、类和模块、转译以及迭代器和生成器。在第二章中,我们讨论了 JavaScript 的异步编程范式。我们讨论了 JavaScript 事件循环、回调、承诺和 async/await 语法。在第三章中,我们学习了文档对象模型(DOM)、JavaScript 事件对象和 jQuery 库。

在本章中,我们将学习有关 JavaScript 中测试代码和代码测试框架的知识。在第一个主题中,我们将介绍测试并讨论测试驱动开发。然后,我们将讨论应用测试驱动开发以及您可以测试代码和应用程序的几种不同方式。在最后一个主题中,我们将讨论几种 JavaScript 代码测试框架,您可以使用它们来为您的代码构建强大的测试。

测试

测试代码很像去健身房。你知道这对你有好处。所有的论点都说得通,但起身并开始健身之路是困难的。最初的冲动感觉很棒;然而,紧随其后的是酸痛的肌肉,你开始怀疑这是否真的值得。你花了一个小时甚至更多的时间,但你所能展示的只是酸痛的手臂和腿。但是,几周后,情况变得更容易。你开始注意到锻炼的好处。

就像去健身房一样,您可能已经听说过测试代码有多么重要。编写测试是编写良好和可持续代码的一个重要部分。当您开始编写测试时可能会感到困难。编写您的第一个测试并使其成功运行会带来一种兴奋感,但在工作日中花费一个小时来编写测试后的一两天后,您开始怀疑这是否真的值得。但您坚持下去。几周后,这变得不那么乏味,您开始注意到测试代码带来的一些小好处。

在本章中,我们将讨论测试代码的原因,您可能需要实施的测试类型,以及您可能使用的一些 JavaScript 框架来实施和运行测试。

测试代码的原因

测试代码有许多原因。这些原因包括程序正确性、敏捷开发、代码质量、错误捕捉、法律责任、满足感等等。我们将简要讨论列出的每个原因,并解释它们的好处。

  1. 正确性

测试代码的最简单和最重要的原因是测试代码检查代码的正确性。智能编写的测试将针对预定的输入值和相应的输出值测试代码中的所有逻辑。通过将程序的输出与预期输出进行比较,我们可以验证代码是否按预期工作,捕捉语义或语法错误,然后将其集成到代码中。

  1. 敏捷开发

测试代码使开发过程更加敏捷。敏捷开发周期是最受欢迎和最热门的开发风格之一,被包括洛克希德·马丁、Snapchat 和谷歌在内的软件公司采用。敏捷开发依赖于短期目标。更改旧的经过测试的代码是一个非常缓慢的过程。如果需要重构或添加或删除功能的任何旧代码,我们需要重新测试整个过程。有了编写的代码测试,我们可以自动化它们,加快测试过程,并节省大量时间。这可能是实现我们的敏捷冲刺目标和错过截止日期之间的区别。

注意:

敏捷开发周期专注于短期冲刺,设计、实施和发布新功能。这些冲刺通常为两到三周。这种短期和快速的开发策略使您能够将一个大型产品分解成较小的部分,并管理潜在的变化需求。

  1. 捕获错误

测试代码将使您能够在开发周期的早期发现错误。测试应该在集成到产品或模块之前进行。这意味着测试发现的任何错误将在集成到产品之前被发现和修复。调试已完全集成到应用程序中的模块比调试仍在开发中的模块要困难得多。在集成之前编写和运行测试将使您能够在它们与其他代码交互之前找到并修复这些错误,节省大量时间。在集成之前捕获错误并推送正确的工作代码是开发人员可以拥有的最重要的技能之一,代码测试可以极大地提高这一技能。

  1. 代码质量

代码测试提高了编写代码的质量。在编写带有测试的代码时,我们必须明确地考虑这些测试来设计和实施我们的代码。编写良好的测试有助于我们更全面地思考我们试图解决的问题以及我们将要解决问题的方式;我们必须考虑诸如边缘情况之类的事情,并设计一个满足测试要求的良好实现。编写测试将帮助您更好地理解代码的设计和实现,从而产生更高质量、更深思熟虑的代码。

  1. 法律责任

编写测试可以帮助预防和减轻法律责任。在许多司法管辖区和市场领域,供应商被要求确保或证明所提供的软件具有市场质量。有记录的测试过程有可能在某些情况下限制您的法律责任。这可能会防止您因软件漏洞而被起诉。在最糟糕的情况下,充分记录的测试过程也可以用来证明诉讼中涉及的软件漏洞并非出于过失。这可能会减少您的惩罚性赔偿或个人责任。

  1. 满足感

测试代码的最终原因经常被大多数人忽视。测试代码可以非常令人满意。测试可以立即给您关于代码正确性的视觉反馈。看到所有方面都有绿色的勾号是非常令人满意的。发布您知道写得很好、经过充分测试并且将会无故障运行的代码是非常令人满意的。知道您的代码经过了充分测试可以帮助您在截止日期到来时对发布感到自信。

测试驱动开发

测试驱动开发TDD)是一种以编写测试为重点的软件开发形式,先于实现代码。它通常是敏捷开发周期的一部分,也是将测试整合到代码中的最简单方式之一。TDD 是围绕短而简单的开发周期构建的软件开发过程。在其最基本的形式中,该周期包括添加一个定义新功能应如何工作的测试,然后编写代码直到满足测试的要求。这个周期重复进行,直到所有功能都被添加。

测试驱动开发要求开发人员创建自动化测试。这些测试应该清楚地定义代码的要求,并且应该在编写任何代码之前定义。测试应该覆盖所有预期或潜在的用例,特别是边界情况。测试的通过将通知开发人员开发何时完成。

注意:

边界情况是发生在操作参数的极端情况。在代码中,边界情况指的是可能需要特殊处理的有效输入值。例如,斐波那契数列算法(F(n)=F(n-1)+F(n-2))在序列值为 0 或 1 时需要特殊处理。

TDD 允许开发人员在必要时将其代码分解为小而可管理的步骤。这是可能的,因为 TDD 要求每个添加的函数和功能都必须有测试。我们可以编写一个小测试,然后编写使该测试通过的代码。大型功能和函数可以分解为小部分,并逐步构建。这可以极大地帮助理解问题的所有部分。

TDD 还可以促进更模块化和可重用的代码。每一部分代码都必须经过测试,大段的代码可以分解为小部分。这可以导致更小、更专注的类和函数,以及代码文件之间更少的交叉依赖。这些小部分可以被包装在一个带有它们的测试的模块中,并通过程序共享。对模块的更新可以通过运行附加的测试套件来简单地验证其正确性。

TDD 周期

TDD 周期通常是一个六个步骤的序列:

  1. 添加测试:在 TDD 中,每个新功能都应该以编写测试开始。要编写新测试,必须清楚地理解功能的规格和要求。功能的要求必须经过深思熟虑,并分解成可以逐一编写为测试的可测试部分。

  2. 运行所有测试并查看是否有失败:为了检查新测试是否通过,测试显然应该失败,因为我们正在添加的功能尚未实现。如果测试没有失败,那么该功能已经存在,或者测试编写错误。这是对编写的测试进行理智检查。测试应该为预期目的而失败,并有助于检查所测试的逻辑是否正确。

  3. 编写代码修复测试:在这个阶段,代码不需要完美。测试可能以低效的方式修复,但这是可以接受的,因为它可以在后续的过程中进行重构。

  4. 运行测试并确保它们通过:测试应该全部通过,包括之前添加的所有测试。如果新代码破坏了之前通过的测试,可以撤销更改以找出可能的破坏性变化。

  5. 重构/清理代码:如果需要进行任何代码清理,可以在这一步完成。在这里,您可以改进新添加的代码的实现,或者修复在添加新代码时可能已经破坏的任何测试。在任何重构之后,应该再次运行测试以确保所有更改都是正确的。根据需要重复重构和运行测试步骤,直到重构正确为止。

  6. 重复:添加一个新的测试,并重复 TDD 周期,直到功能已经完全实现和测试。

测试驱动开发是确保所有代码都经过测试的强大方法,但如果开发人员不够谨慎,它可能会导致几个陷阱。当需要完整堆栈或功能测试时,TDD 可能很难使用。完整堆栈或功能测试是一次对技术堆栈的多个部分进行测试。需要用户界面元素、数据库调用或网络调用的测试可能非常难编写。通常情况下,代码中测试的外部世界交互可以通过使用模拟数据或网络调用来欺骗。

如果测试不经常运行或维护不当,TDD 也可能会开始崩溃。如果测试被放弃并且从不运行,或者只是偶尔运行,TDD 的整个目的就会崩溃。添加到程序中的功能是根据测试设计的,并且测试用于验证功能是否被正确实现。如果测试从未运行,TDD 的整个目的就被忽视了。维护不当的测试也会阻止 TDD 的有效性。维护不当可能是因为没有更新以满足调整后的功能要求,或者没有添加概述新功能要求的新测试。维护不当的测试将无法正确地告诉您编写的代码是否按照我们想要的方式执行。

TDD 也可能会受到测试编写不当或懒散的影响。如果测试太粗糙,它们将无法找到代码中的错误。测试必须具有足够的特异性,以独立地测试每一点逻辑,而不受其他逻辑的影响。另一方面,如果添加了琐碎的测试,我们会在 TDD 敏捷过程中浪费时间。如果编写了琐碎的测试或重复了以前的测试,我们将降低开发效率。

最后,如果团队中的任何成员不采用开发策略,TDD 可能会崩溃。如果只有部分开发团队在添加新代码之前编写测试,我们只能测试和验证代码库的一小部分。为了使 TDD 取得最佳结果,所有开发团队成员都必须完全采用它。

结论

测试代码是确保代码按预期方式运行的最佳方法。如果您目前不测试代码,要开始实施测试可能会非常困难;然而,这是必须要做的。测试代码可以使您的代码更正确、更容易编写和更高质量。

测试驱动开发是在项目中开始集成测试的最简单方法之一。TDD 围绕着在编写任何实现代码之前添加概述任何功能或函数要求的测试。它迫使开发人员准确了解每个功能将如何实现。TDD 是一个简单的六步过程:添加测试,运行测试,编写代码,运行测试,重构,重复。这个过程确保了每个功能的小部分都得到了测试。

练习 24:应用测试驱动开发

你被要求编写一个斐波那契数生成器。使用测试驱动开发周期编写测试并开发斐波那契算法。您可以参考第一章:介绍 ECMAScript 6中的斐波那契代码,进行修改。您应该为n=0条件编写测试,然后实现n=0条件,然后为n=1条件编写测试并实现,然后为n=2条件编写测试并实现,最后为n=5n=7n=9条件编写测试并实现。如果测试通过,则记录测试通过。否则,抛出错误。

使用 TDD 开发和测试算法,执行以下步骤:

  1. 手工计算斐波那契数列在 n=0,n=1,n=2,n=5,n=7 和 n=9 时的值。

  2. 编写一个名为fibonacci的函数,该函数以变量i作为输入,递归计算斐波那契数列的值,并检查i<=0

如果是,返回1,然后检查if i==1

如果是,则返回1。否则,它会递归获取斐波那契值。

然后返回fibonacci(i-1) + fibonacci(i-2)

  1. 编写一个名为test的通用测试函数,它接受两个参数:计算出的值(value)和预期值(expected)。

  2. 检查两个值是否不同。如果它们不同,则抛出错误。

  3. 如果两个值相同,请打印测试通过消息。

  4. 对于每个要测试的条件(在步骤 1 中计算,n=0,n=1,n=2,n=5,n=7 和 n=9),使用test函数编写测试条件的测试。

  5. 调用test函数,并传入从fibonacci函数返回的值和手动计算的值。

  6. 运行测试。

  7. 如果测试失败,请修复fibonacci函数中的错误。

  8. 修复错误后再次运行测试。

  9. 如果测试通过,请继续下一个测试条件。

  10. 如果测试失败,请修复错误并重新运行测试。

代码

index.js
function fibonacci( i ) {
 if ( i <= 0 ) {
   return 0;
 } else if ( i === 1 ) {
   return 1;
 } else {
   return fibonacci( i - 1 ) + fibonacci( i - 2 );
 }
}
function test( value, expected ) {
 if ( value !== expected ) {
   throw new Error( 'Value did not match expected value' );
 } else {
   console.log( 'Test passed.' );
 }
}
test( fibonacci( 0 ), 0 );
test( fibonacci( 1 ), 1 );
test( fibonacci( 2 ), 1 );
test( fibonacci( 5 ), 5 );
test( fibonacci( 7 ), 13 );
test( fibonacci( 9 ), 34 );

https://bit.ly/2H5CNv0

代码片段 4.1:测试代码

输出

图 4.1:斐波那契测试

图 4.1:斐波那契测试

您已成功应用测试驱动开发来开发和测试算法。

测试类型

软件测试有许多不同的形式。在本节中,我们将讨论测试代码的不同方法,并涵盖最常见的代码测试类型。

黑盒和白盒测试

测试代码有两种方法,黑盒和白盒。术语黑盒表示内部工作原理未知的系统。观察系统的唯一方法是通过其输入和输出。白盒系统是已知内部工作原理的系统。可以通过其输入、输出和确切的内部工作原理来观察。黑盒和白盒系统可以是任何东西,从软件程序到机械设备或任何其他系统。

黑盒测试是指在测试软件时,测试人员不知道代码的内部结构或实现。我们只能观察代码系统的输入和输出。白盒测试是指在测试软件时,测试人员知道代码的内部结构或实现。我们能够观察输入和输出,并确切地了解程序每一步的内部状态如何改变。几乎所有形式的代码测试都基于黑盒或白盒测试原则。以下图示显示了黑盒与白盒的对比:

图 4.2:黑盒和白盒可视化

图 4.2:黑盒和白盒可视化

我们将讨论三种类型的测试:单元测试功能测试集成测试。单元测试旨在验证所有可测试代码的预期目的。它们测试最小的逻辑片段,以确保实现的正确性。功能测试旨在确认功能或组件的功能。集成测试旨在测试集成的组件,以验证它们在集成系统中一起按预期工作。这三种代码测试为您提供了一个良好的基础,可以从中进行代码测试。

单元测试

单元测试是最常见的测试形式之一。单元测试用于确保函数的特定功能部分已满足要求。单元测试通常从白盒测试的角度构建,我们将在本章中讨论单元测试,假设已知代码的内部功能。虽然单元测试可以从黑盒的角度构建,但这更接近功能测试,并将在下一节中更多地讨论。

单元测试只是测试尽可能小的代码单元的测试。代码的“单元”是一个与代码的其他部分逻辑上隔离的小片段。换句话说,它是一段不依赖于代码其他部分的逻辑的代码。代码单元可以更新而不影响其周围代码的功能。例如,考虑以下代码片段中显示的代码:

function adjustValue( value ) {
 if ( value > 5 ) {
   value--;
 } else if ( value < -5 ) {
   value++;
 }
 return value
}
片段 4.2:代码单元示例

函数adjustValue()接受一个数字。如果数字大于 5,则从数字中减去 1,如果值小于-5,则向数字中添加 1。我们可以将这段代码分解为三个逻辑单元,如下所示:

  1. 第一个单元是检查值是否大于 5 的if语句和减量运算符(value--)。

  2. 第二个单元是else if语句,检查值是否小于-5,并且增量运算符(value++)。

  3. 第三个逻辑单元是return语句。更改这三个逻辑单元中的任何一个都不会影响其周围代码的逻辑结构。

我们可以为每个单元创建一个单元测试,以确保它们的功能正确。我们的单元测试应该一次只测试一个代码单元。对于这个例子,我们将需要 3 个单元测试。我们将构建测试来检查返回值、大于 5 的条件和小于-5 的条件。要测试返回条件,我们只需要传入一个小于或等于 5 且大于或等于-5 的值。返回的值应该与传入函数的值相同。要测试大于 5 的条件,我们必须传入一个大于 5 的值。我们知道返回的值必须比输入的值低 1。要测试小于条件,我们必须传入一个小于-5 的值。我们知道返回的值应该比输入的值高 1。这三个单元测试可以放入一个代码文件中,并在对代码进行修改后运行。

单元测试应尽可能频繁地运行。单元测试应该放入文件中,并在任何代码逻辑发生变化时运行。代码片段逻辑的微小变化可能导致结果的重大变化。持续测试将有助于确保没有小错误悄然产生。许多公司都有自动化测试系统,将在 Git 存储库提交或版本发布时自动运行单元测试。这种自动化测试对于帮助追踪破坏代码的提交和更改非常有用。这可以大大减少调试时间和精力。

练习 25:构建单元测试

你被要求为一段代码构建单元测试。要完成这个任务,请按照以下说明进行操作:

  1. 参考exercises/exercise25/exercise.js中提供的文件,并查看名为fakeRounding的函数。我们将为这个函数构建单元测试。

  2. 在文件中,编写一个名为test的通用测试函数,该函数接受两个参数:计算出的值(value)和预期值(expected)。检查这两个值是否不同。如果它们不同,就抛出一个错误。

如果这两个值相同,就打印测试通过的消息。如果愿意,可以使用练习 24中的test函数。

  1. 参考fakeRounding函数,逐行分析函数对输入和输出的影响。

它获取传入数字的绝对值的小数部分。如果小数<=0.5,则返回最接近整数的输入。接下来,如果小数>0.5,则返回最接近整数的输入向下取整。

  1. 使用我们创建的test函数编写测试,检查以下情况。从提供的输入计算预期值。

为多个输入编写测试,0、0.4999、0.5、0.5001、-0.4999、-0.5 和-0.5001:

代码:

solution.js
test( fakeRounding( 0 ), 0 );
test( fakeRounding( 0.4999 ), 1 );
test( fakeRounding( 0.5 ), 1 );
test( fakeRounding( 0.5001 ), 0 );
test( fakeRounding( -0.4999 ), 0 );
test( fakeRounding( -0.5 ), 0 );
test( fakeRounding( -0.5001 ), -1 );
片段 4.3:单元测试

https://bit.ly/2Fjulqw

输出:

图 4.3:单元测试

图 4.3:单元测试

您已经成功为一段代码构建了单元测试。

功能测试

功能测试是一种黑盒测试方法,用于确定应用程序的组件是否按照定义的规范工作。功能测试通常比单元测试更复杂。单元测试测试组件内部函数的逻辑,而功能测试旨在测试组件是否符合规范表或数据表中定义的规范。例如,如果我们在网页上有一个只接受数字的表单,我们可能会使用数字和字符串进行功能测试,以确保正确满足仅接受数字的规范。

功能测试可以分为五个步骤:

  1. 确定功能

  2. 创建输入数据

  3. 确定输出数据

  4. 比较输入和输出

  5. 修复错误

构建功能测试的第一步是确定需要测试的功能。功能测试通常测试主要功能、错误条件、可用性等。通常最容易确定需要构建的测试是通过查看特性/组件规范或数据表来确定的。您可以从数据表中获取组件的所需程序行为和错误处理,并将其分解为一系列测试。

一旦确定了需要测试的功能以及如何测试该功能,您必须创建输入数据进行测试。测试所需的输入数据严重依赖于正在构建的组件或特性,因此很难为教科书的目的进行概括。但是,您应该使用您期望程序接受的值和可能对程序来说意外的值进行测试。例如,如果我们正在创建一个电子邮件输入表单,我们应该使用有效的电子邮件(xxxx@yyy.zzz)和无效的电子邮件(12344312)来测试输入字段。在生成任意测试数据时,通常最好使用数组、字符串或其他数据结构中的非顺序值进行测试。使用随机值可以帮助您发现逻辑错误。

确定测试所需的输入数据后,您必须确定特性的预期输出。这个过程的这一部分可以说是最重要的,不应该草率对待。输出值绝对不能通过将输入通过正在测试的程序来计算。这将导致在运行测试时出现重言,不会发现任何错误。我曾经看到许多测试失败,因为程序员没有正确计算预期的输出值,测试无效。

一旦确定了输出值,我们就可以运行我们的测试了。输入值应该通过特性或组件,并与输出值进行比较。如果组件的输出值与前一步计算的预期输出值相匹配,则测试通过。如果值不匹配,则测试未通过,需要修复错误。

该过程的最后一步是修复错误。如果测试未通过,则组件中存在错误。修复错误后,可以重新运行测试。如果所有功能的所有测试都通过,则该组件可能被认为已准备好进行集成。

构建测试可能是功能测试中最困难的部分之一。我们需要构建两种不同类型的测试:正向测试和负向测试。正向测试测试预期的程序使用流程,而负向测试测试意外的使用流程。

正面测试相对容易生成。任何您希望或期望用户执行的操作都可以转化为正面测试用例。例如,单击应用程序中的按钮或在文本字段中输入信息。这两个用例可以转化为单击按钮的功能测试和输入文本字段的功能测试。由于正面测试旨在测试预期的程序流程,因此应使用有效和预期的数据。在测试不使用数据而是使用其他功能的情况下,例如用户的鼠标点击,我们只需要为预期行为编写正面测试。

负面测试更难创建。它们需要更多的创造力来有效地构建和实施,因为你必须想出奇怪的方法来破坏自己的代码。往往很难预料用户可能如何误用功能。负面测试旨在测试错误路径和失败。例如,如果我们打算让用户在我们的网站上单击一个按钮,可能会明智地为双击条件编写负面测试。双击是意外行为,如果没有妥善考虑,可能会导致表单重新提交。负面测试对于充分测试一个功能是必不可少的。

集成测试

集成测试是从功能测试中退一步。集成测试旨在测试模块和组件在完全集成时的工作方式。单元测试逐个测试功能。功能测试逐个测试完整的组件或模块。集成测试测试组合的组件,以确保它们正确地相互交互。集成测试通常比单元测试或功能测试更复杂。一旦所有组件都建立并集成在一起,集成测试可以为简单的单个网页编写,也可以为包含 API、多个服务器和数据库的完整前端应用程序编写。集成测试通常是最困难和耗时的测试形式。

集成测试可以简化并且可以像制造圆珠笔的过程一样思考。盖子、笔身、墨水、圆珠和带夹的尾盖都是圆珠笔的组成部分。它们都是分别制造和测试,以确保每个组件都符合其设定的规格。当这些部件准备好后,它们被放在一起进行集成测试,以测试这些组件是否能正确地一起运行。例如,我们的集成测试可能测试圆珠是否能放入墨水盒中,墨水和圆珠是否能放入笔身中,或者盖子是否能放在笔身上。如果其中一个测试失败,集成系统(圆珠笔)将无法按规格运行,一个或多个组件必须更新。

进行集成测试有几种方法。它们包括大爆炸测试、自下而上测试、自上而下测试和夹层测试。每种方法都有其优点和缺点。

大爆炸测试包括一次性组合所有组件,然后运行测试。它被称为大爆炸测试,因为你一次性将所有东西放在一起,然后会出现(很可能)失败的集成测试。大爆炸测试对于没有太多组件之间交互的小型系统非常方便。但是,当应用于大型系统时,大爆炸测试通常会出现问题。第一个问题是在非常大型和非常复杂的系统中,故障定位可能会更加困难。如果找到错误源需要很长时间,我们的测试周期将会非常缓慢。第二个问题是由于系统的复杂性,一些组件之间的链接可能会被忽略而未经测试。如果有数百个需要测试的组件链接,一旦它们全部同时链接起来,要跟踪它们可能会很困难。大爆炸测试的第三个问题是,集成测试无法在所有模块或组件被设计和完全构建之前开始。由于必须一次性组合所有模块,一个模块的延迟会推迟整个系统的集成测试。

集成测试的第二种形式是自下而上的测试。在自下而上的测试中,我们必须将系统的层次结构想象成一棵树。我们首先集成底层模块,然后一旦所有测试通过,我们就添加下一层模块或组件,直到整个系统都被测试。为了以这种方式进行测试,我们必须使用驱动程序来模拟上层并调用我们正在测试的底层模块或组件。驱动程序只是模拟高级模块和它们对低级模块的调用的代码片段,用于测试目的。自下而上的测试有两个主要好处。第一个是故障定位非常容易。模块从最低级别开始集成。如果新集成的模块失败,那么我们可以快速找出需要修复的模块。第二个好处是不需要等待所有模块都开发完成。如果模块也是按自下而上的方式开发的,那么一旦准备就绪,我们就可以将它们添加到集成测试中。我们可以在准备就绪时进行集成测试,而不是等到整个系统构建完成。自下而上的测试有两个主要缺点。第一个是很难创建早期的工作原型。由于模块是自下而上构建和集成的,用户界面功能和模块通常是最后实施和测试的。由于原型组件通常最后准备就绪,因此很难拥有早期原型。第二个缺点是控制应用程序流程的顶层关键组件和模块最后进行测试,可能无法像首先测试的模块那样进行充分测试。对于大型集成系统,我一般认为自下而上的测试比大爆炸测试更好。

集成测试的第三种形式是自顶向下测试。在自顶向下测试中,我们必须将系统层次结构想象成一棵树。我们首先集成系统的顶层。这些通常是面向用户的组件和程序流模块。自顶向下测试要求测试人员构建存根来模拟较低级别模块的功能。存根模仿未开发的模块,以便正在测试的模块可以进行所需的调用。自顶向下测试有三个主要优点。与自底向上测试一样,第一个主要优点是故障定位非常容易,我们不需要等待整个系统构建完成才能开始集成测试。组件可以一次添加一个,一旦它们被构建。自顶向下测试的第二个优点是可以非常容易地创建早期原型。首先构建和测试面向用户和最关键的组件,因此很容易将它们集成到早期演示的原型中。最后一个主要优点是对关键模块进行了优先测试。关键模块首先构建,因此更频繁地进行测试,通常更完整。自顶向下测试有两个主要缺点。第一个是需要许多存根。每个较低级别的模块或组件必须构建成一个用于测试的存根。这可能需要编写大量额外的代码。第二个缺点是较低级别的模块通常是最后构建和测试的。通常,它们没有经过如此彻底的测试。

集成测试的最终形式是夹层测试夹层测试是自顶向下和自底向上方法的结合。最重要和最低级别的模块同时构建和集成。这种方法的好处是提供了更一般和大爆炸式的集成测试方法,同时保持了自顶向下和自底向上测试的优点。夹层测试的最大缺点是需要构建存根和驱动程序。如果系统非常复杂,有时很难分清存根和驱动程序。

构建测试

构建测试可能看起来是一个非常艰巨的过程。从头开始构建整个测试套件可能非常困难。然而,测试驱动开发为我们提供了一个非常好的测试起点。如前所述,在测试驱动开发部分,构建测试应始终从编写需求表开始。

需求表是用于构建功能、特性或整个系统的数据表。需求表应将功能的要求细分为非常详细和具体的列表。为软件应用程序编写需求表超出了本书的范围,但我们将通过一个简要的示例进行介绍。假设我们被要求构建一个类似 Facebook 的评论创建组件。该组件必须具有一个带有字符限制的文本字段和一个在点击事件后发表评论的按钮。我们可以从这个场景中轻松构建出两个一般要求:文本字段的字符限制和按钮在点击事件后进行 API 调用。然后,这两个要求可以细化为以下要求列表:

  1. 文本字段必须接受用户输入的字符。

  2. 文本字段包含 250 个或更多字符时,无法向文本字段添加字符。

  3. 在文本字段中,按下退格键可以删除任何字符。

  4. 按钮必须对onclick事件做出响应。

  5. 在点击事件中,组件必须使用文本字段数据调用 API。

这不是功能或功能组件的完整需求列表,但对于这个示例来说,已经足够了。有了这些需求,我们就可以开始编写我们的测试了。

我们可以开始编写测试,逐项通过我们的需求列表。每个需求都应该分解为一个或多个测试。每个测试应该测试一件事,并具有非常具体的成功标准。

第一个要求是文本区域必须接受用户输入的字符。如果我们在键盘上按键,按下的字符应该添加到文本区域,所以我们的第一个测试应该是在键盘上按键,并验证相同的字符是否添加到文本区域。

第二个要求规定,当文本区域包含 250 个或更多字符时,不能添加任何字符到文本字段。这可以分为两个测试:当文本区域有 250 个字符时,不能添加任何按键到文本区域,当文本区域有超过 250 个字符时,不能添加任何按键到文本区域。

第三个要求规定,可以通过按下退格键删除文本字段中的任何字符。这个要求可以很容易地转化为一个测试。我们必须测试,如果按下退格键,一个字符将从文本区域中删除。为了正确测试边缘情况,我们应该运行这个测试四次:一次是空的文本区域,一次是有 0 个但少于 250 个字符的文本区域,一次是 250 个字符,一次是超过 250 个字符。测试文本区域的所有操作条件(甚至是我们从未预期达到的超过 250 个字符的测试用例)将确保不会发生任何故障。

第四个要求规定按钮必须响应点击事件。这个测试非常容易编写。我们只需要添加一个测试,用户点击按钮。最后一个要求规定,按钮上的点击事件必须调用 API。我们可以很容易地将这转化为一个测试,通过模拟点击事件,并确保网站使用正确的数据调用 API。

我们已经在一系列测试中概述了五个要求的列表。现在可以将这些测试编译在一起,并以代码形式编写在一个测试文件中。这个测试文件将用于验证我们需求表中概述的需求是否得到了正确满足。

练习 26:编写测试

你的团队被要求为你的通讯订阅建立一个注册页面。注册页面必须有三个文本字段,用于姓名、电子邮件和年龄,以及一个提交按钮。您的注册页面必须接受 1 到 50 个字符(包括)之间的姓名,1 到 50 个字符(包括,不验证电子邮件格式)之间的电子邮件,以及用户的年龄(必须大于 13 岁)。当按下提交按钮时,用户信息必须经过验证(根据前一节提供的规格)。如果规格的任何部分未满足,就在浏览器控制台中抛出错误。编写一个非常基本的规格表,详细说明每个输入和提交按钮的要求,然后从规格表中构建测试。实现页面(使用exercises/exercise26/exercise.html作为起点),并从 UI 手动执行测试。起始文件包含了您必须编写的测试的提示。在打开起始文件之前编写规格表和测试。

构建一个基本的规格表,并从规格表中运行测试,执行以下步骤:

  1. 通过将包含场景描述中规格信息的每个句子拆分为一个或多个需求来编写规格表。

  2. 将规格表分解为手动 UI 测试,方法是将规格表上的每一项都写成一个或多个测试。

  3. 打开exercises/exercise26/exercise.html中的起始 HTML 文件。

  4. 添加三个带有 ID nameemailage 的输入字段。如下图所示:图 4.4:数据表(第 4 步后)

图 4.4:数据表(第 4 步后)
  1. 提交按钮添加到 HTML 文档中,并在单击时调用validate函数。

  2. 在验证函数中,通过 id 获取name文本字段并将其值保存在name变量中。

通过 id 获取email文本字段并将其值保存在email变量中。

通过 id 获取age文本字段,获取其值,解析数字的值,然后将解析后的值保存在age变量中。

检查与name字段相关的规范表上的条件。还要检查名称是否不存在,或者为 false,如果是,则抛出错误。检查name length <= 0 or > 50,如果是,则抛出错误。

检查与email字段相关的规范表上的条件。还要检查电子邮件是否不存在,或者为假;如果是,则抛出错误。检查email length is <=0 or > 50,如果是,则抛出错误。

检查与age字段相关的规范表上的条件。还要检查年龄是否不存在,或者为假;如果是,则抛出错误。检查age < 13,如果是,则抛出错误。

将用户详细信息(nameemailage)记录到控制台。

  1. 对于规范表中编写的每个测试,手动测试它。填写文本字段中的值,然后单击提交

将记录在控制台的错误与测试的预期结果进行比较。

如果测试失败,则更新验证函数以修复错误并重新运行测试。

代码

solution.html
<body>
 <input type="text" id="name" value="Name">
 <input type="text" id="email" value="Email">
 <input type="text" id="age" value="Age">
 <button onclick="validate()">Submit</button>
 <script>
   function validate() {
     const name = document.getElementById( 'name' ).value;
     const email = document.getElementById( 'email' ).value;
     const age = parseInt( document.getElementById( 'age' ).value, 10 );
     if ( !name ) {
       throw new Error( 'Must provide a name.' );
     } else if ( name.length <= 0 || name.length > 50 ) {
       throw new Error( 'Name must be between 1 and 50 characters.' );
     }
     if ( !email ) {
       throw new Error( 'Must provide an email.' );
     } else if ( email.length <= 0 || email.length > 50 ) {
       throw new Error( 'Email must be between 1 and 50 characters.' );
     }
     if ( !age ) {
       throw new Error( 'Must provide an age that is also a number.' );
     } else if ( age < 13 ) {
       throw new Error( 'Age must be at least 13.' );
     }
     console.log( 'User details:
     Name: ${name}
     Email: ${email}
     Age: ${age}' )
   }
 </script>
</body>
片段 4.4:测试前端输入代码

https://bit.ly/2H5E7OJ

输出

图 4.5:数据表(最终输出)

图 4.5:数据表(最终输出)

您已成功构建了基本的规范表并从规范表中运行了测试。

测试工具和环境

测试工具、框架和环境旨在使测试代码更简单、更快速。JavaScript 有许多可用的测试框架,最受欢迎的将会简要提到。然后我们将深入研究其中一个框架,并演示如何使用该框架编写良好的测试。

测试框架

您需要根据希望进行的测试类型选择测试框架。通常使用三种方式对 JavaScript 进行测试:一般测试代码覆盖测试用户界面测试。在选择框架时,必须决定要测试什么以及希望如何进行测试。

一般测试将包括单元测试、功能测试和集成测试。这是您测试的一种综合方式。最受欢迎的测试框架是MochaJasmineJest。Jest 由 Facebook 使用,是设置最简单的框架之一。Mocha 是 JavaScript 中最受欢迎的测试框架,并且稍后将更详细地介绍它。

代码覆盖测试用于帮助检查测试的完整性。代码覆盖可以定义为您的自动化测试覆盖的代码基数的百分比。代码覆盖可以用作代码测试完整性的一般指导。理论上,应用程序的代码覆盖率越高,测试就越完整和更好。但是,在实践中,拥有 100%的代码覆盖并不意味着代码的测试经过深思熟虑且有效。这只意味着每个代码路径在某种程度上都在测试中引用。编写深思熟虑的测试比随意组合的测试更重要,后者会触及每行代码。最受欢迎且最简单的代码覆盖库是Istanbul。它与许多测试框架兼容,并且可以轻松地融入大多数测试套件中。如果需要第三方库进行代码覆盖测试,我建议使用 Istanbul。

测试的最终形式是用户界面UI)测试。与一般测试一样,我们可以将 UI 测试分为集成测试、功能测试和单元测试。然而,UI 测试通常不包括在一般测试中,因为它们需要特殊和更复杂的框架。要执行 UI 测试,我们必须加载用户视图并模拟用户交互。一些更常见的 UI 测试框架包括 Testcafe、WebdriverIO 和 Casper。

Mocha

Mocha是一个用于在 Node.js 中测试 JavaScript 的框架。它是一个简单的库,旨在简化和自动化测试过程。Mocha 被设计为简单、灵活和可扩展的。我的公司使用 Mocha 进行单元测试、功能测试和集成测试。我们将讨论使用 Mocha 而不是其他框架的一些好处,介绍如何设置和运行 Mocha 的第一个测试,并解释 Mocha 提供的一些高级功能。

注意

Mocha 的完整文档可以在mochajs.org/找到。

Mocha 有许多好处。正如前面所述,Mocha 是 Node.js 最流行的测试框架。这立即给 Mocha 带来了最大的优势:Mocha 拥有最大的开发社区。这对于支持和扩展非常重要。如果您在 Mocha 测试中遇到问题,这个社区可以提供广泛的支持。Stack Overflow 社区很快就会回答关于 Mocha 的问题。Mocha 社区还为独特的测试场景构建了许多插件或扩展。如果您的项目有独特的测试需求,很可能已经构建了适合您需求的插件。

除了庞大的社区支持外,Mocha 还提供了简单的设置、断言和简单的异步测试等优势。通过 npm 可以通过命令行设置 Mocha。对于任何测试框架,我们希望确保设置它不会花费太多时间。Mocha 还允许使用断言模块。虽然不是必需的,但如果您的团队希望从断言标准来进行测试,Mocha 允许您安装和导入许多 JavaScript 断言库。最后,Mocha 专为异步测试而设计。对于任何 JavaScript 测试模块,我们必须依赖异步支持来编写完整的测试。Mocha 被设计为与回调、promises 和 ES6 async/await 语法一起工作。它可以轻松地集成到大多数后端设置中。

设置 Mocha

安装 Mocha 是通过 npm 命令npm install -g mocha完成的。这个命令会在系统上全局安装 Mocha。任何 Node.js 项目现在都可以使用 Mocha 来运行测试。一旦全局安装,我们就可以使用命令行运行测试,使用mocha关键字。

一旦 Mocha 在我们的系统上安装好了,我们必须将其添加到一个项目中。如果您没有 Node.js 项目,请创建一个到所需项目目录的路径,并使用npm init初始化项目。这是在第一章中讨论转译和 Babel 时设置项目时使用的相同命令。npm init命令将创建一个名为package.json的文件。创建 JavaScript 项目后,我们需要创建项目文件。创建一个名为index.js的文件和一个名为test.js的文件。index.js将包含我们的项目代码,test.js将包含我们的测试代码。

package.json文件中,将会有一个名为scripts的字段。要从 npm 运行我们的测试,我们必须向scripts对象添加一个字段。用以下片段中显示的代码替换scripts对象:

"scripts": {
  "test": "mocha ./test.js"
}
片段 4.5:package.json 中的测试脚本

前面片段中的代码向package对象添加了一个名为test的脚本。我们可以使用npm run test命令运行此脚本。运行此命令时,它会调用mocha关键字和./test.js参数。Mocha 测试框架将运行test.js文件中包含的测试。现在我们已经准备好开始向test.js添加测试了。

Mocha 使用describeit关键字组织测试。两者都是以字符串作为第一个参数和函数作为第二个参数的函数。describe函数用于将测试分组在一起。it函数用于定义一个测试。describe()的函数参数包含测试声明(使用it())或更多的描述函数。it()的函数参数包含要运行的测试函数。

您可以将 describe 函数视为描述和组合一组测试的方式。例如,如果我们有一组测试都测试一个名为calculateModifier的函数,我们可以使用 describe 函数将这些测试组合在一起,并使用描述:describe( 'calculateModifier tests', () => { ... } )。这将把包含在函数中的测试分组在calculateModifier测试下。

您可以将it函数视为定义测试的一种方式,形式为“它应该……”。传递给it函数的字符串描述了测试,通常是测试试图实现的内容。函数参数包含实际的测试代码。例如,如果我们想定义一个检查两个值是否相等的测试,我们可以使用it函数来做到这一点:it( 'should have two inputs that are equal', () => { ... } )。描述告诉我们应该发生什么,检查值的代码将放在函数参数中。

Mocha 基础知识

了解测试的基础知识后,我们可以查看 Mocha 入门文档,并查看以下代码片段中显示的代码:

var assert = require('assert');describe('Array', function() {  describe('#indexOf()', function() {    it('should return -1 when the value is not present', function() {      assert.equal([1,2,3].indexOf(4), -1);    });  });});
代码片段 4.6:Mocha 基础知识

您认为这段代码在做什么?首先,我们用描述Array描述了一组测试。在第一个describe块的函数参数内部,我们有另一个describe块。这个新块描述了一个带有描述#indexOf的测试集;因为这些描述块是嵌套的,我们可以假设我们正在测试数组的indexOf功能。在第二个describe块内部,我们使用it函数定义了一个测试。我们定义了一个测试,说当值不存在时应返回-1。根据测试的描述,我们期望indexOf函数在数组中的值不存在时返回值-1。在这个例子中,我们使用 assert 库来断言预期值-1等于实际值。assert 库并不是严格必要的,但使这个例子更容易理解。

练习 27:设置 Mocha 测试环境

目标是设置 Mocha 测试环境并准备一个测试文件。要完成此任务,请按照以下步骤操作:

  1. 运行npm init在练习目录中创建一个package.json文件。

  2. 运行npm install mocha -g来安装测试包。

  3. 创建一个名为test.js的文件,我们的测试将放在其中。

  4. package.json文件中添加一个脚本,以在test.js文件上运行 mocha 测试套件。

  5. test.js文件中,添加一个describe()块,将测试描述为My first test!

  6. describe块的回调内部,添加一个带有it()的测试,通过并具有描述Passing test!

  7. 通过调用package.json中添加的npm脚本来运行测试。

代码:

test.js
describe( 'My first test!', () => {
 it( 'Passing test!', ( done ) => done( false ) );
} );
代码片段 4.7:Mocha 基础知识

https://bit.ly/2RhzNAy

输出:

图 4.6:Mocha 测试

图 4.6:Mocha 测试

您已成功设置了 Mocha 测试环境并准备了一个测试文件。

Mocha 异步

Mocha 支持异步测试和同步测试。在 Snippet 4.6 中显示的示例中,我们执行同步测试。要支持异步测试,我们只需要将一个 done 回调参数传递到it()函数的函数参数中:it( 'description', ( done ) => {} )。这告诉 mocha 在继续进行下一个测试之前等待done回调被调用。done参数是一个函数。如果测试成功,应该使用一个falsy值(没有错误)调用 done。如果使用一个truthy值调用 done,mocha 将解释该值为错误。最佳实践是将错误对象传递给 done 回调,但任何评估为 true 的值都会告诉 Mocha 测试失败。

Mocha 以同步方式按照测试文件中定义的顺序同步执行异步测试。测试可能会异步查询资源,但在上一个测试完全完成(done 已被调用)之前,下一个测试不会开始运行。同步运行测试非常重要。即使同步运行测试可能导致更长的测试时间,它也允许我们测试依赖于一些共享状态的异步系统。例如,我们可以使用 Mocha 测试数据库和数据库接口等系统。如果我们需要执行一个集成测试,测试向数据库添加和删除的过程,我们可以创建一个测试来向数据库添加项目,以及一个测试来从数据库中删除添加的项目。如果这两个测试异步运行,我们可能会遇到时间问题。由于网络延迟或其他意外错误,删除操作可能在添加操作之前被处理,测试将失败。Mocha 通过强制测试同步运行来避免调试此类问题的需要。

Mocha Hooks

对于更复杂的测试,Mocha 允许我们将钩子附加到我们的测试上。Hooks可以用于设置测试的前提条件和后置条件。简单来说,钩子允许我们在测试之前和之后进行设置。Mocha 提供以下钩子:beforeafterbeforeEachafterEach。钩子接受两个参数,一个description和一个callback函数参数。这个函数参数可以接受一个参数 - 一个 done 函数。钩子的语法示例如下所示:

describe( 'Array', () => {

  before( 'description', done => { ... } );
  after( 'description', done => { ... } );
  beforeEach( 'description', done => { ... } );
  afterEach( 'description', done => { ... } );
} );
Snippet 4.8: Mocha hooks

钩子只在它们所包含的描述块中的测试之前或之后运行。before钩子在任何定义的测试开始之前运行一次。它们可以用于在测试之间设置一个共享状态。beforeEach钩子在每个测试开始之前在describe块内运行。它们可以用于设置或重置每个测试所需的共享状态或变量集。after钩子在所有测试完成后运行一次。它们可以用于清理或重置测试之间共享的状态。afterEach钩子在每个测试完成后但下一个测试开始之前运行。它可以用于清理或重置特定于测试的共享状态。

Activity 4: Utilizing Test Environments

您的任务是将斐波那契序列测试代码升级为使用 Mocha 测试框架。取出斐波那契序列代码并测试您为Activity 1: Implementing Generators创建的代码,并升级为使用 Mocha 测试框架来测试代码。您应该为n=0条件编写测试,实现它,然后为n=1条件编写测试并实现。对于n=5n=6以及n=8也重复这个过程。如果it()测试通过,调用没有参数的 done 回调,否则使用错误或其他truthy值调用测试完成回调。

要使用 Mocha 测试框架编写和运行测试,请执行以下步骤:

  1. 设置 NPM 项目并安装 mocha 模块。

  2. package.json中添加一个测试脚本,运行 mocha 和test.js中的测试。

  3. 创建一个index.js文件,其中包含一个斐波那契数列计算器函数。导出这个函数。

  4. 创建test.js,使用 mocha 框架测试斐波那契数列函数。测试fibonacci,n=0,n=1,n=2,n=5,n=7 和 n=9。

输出

图 4.7:使用 Mocha 测试斐波那契数列

图 4.7:使用 Mocha 测试斐波那契数列

你已成功利用 Mocha 测试框架编写和运行测试。

注意:

此活动的解决方案可在第 288 页找到。

总结

代码测试是开发人员可以拥有的最重要的技能之一。测试代码就像去健身房一样。你知道这对你有好处,但往往很难开始。在本章中,我们讨论了测试代码的原因,几种代码测试类型以及几种 JavaScript 代码测试框架。需要进行代码测试以确保程序的正确性。测试驱动开发是将测试整合到项目中的最简单方法之一。TDD 围绕着编写测试来概述任何添加的功能或函数的要求,然后再编写任何实现代码。有许多形式的代码测试。在本章中,我们介绍了单元测试、功能测试和集成测试。这些类型的代码测试是最常见的,通常是从黑盒和白盒两种方法中构建的。功能、单元和集成测试都可以在前面主题中涵盖的许多框架中构建。

在下一章中,我们将介绍函数式编程编码原则,并定义面向对象编程和函数式编程。

第五章:函数式编程

学习目标

在本章结束时,您将能够做到以下几点:

  • 解释函数式编程

  • 实现函数式编程的关键概念

  • 将函数式编程概念应用于您的代码

  • 以函数式编程风格构建新的代码库

本章解释了编程的类型,包括面向对象编程和函数式编程,以及如何使用不同类型的函数。

介绍

在第一章中,我们涵盖了 ES6 中发布的许多新功能和强大功能。我们讨论了 JavaScript 的发展,并突出了 ES6 中的关键增强。我们讨论了作用域规则、变量声明、箭头函数、模板文字、增强对象属性、解构赋值、类和模块、转译以及迭代器和生成器。

在第二章中,我们涵盖了 JavaScript 的异步编程范式。我们讨论了 JavaScript 事件循环、回调、承诺和 async/await 语法。

在第三章中,我们学习了文档对象模型(DOM)、JavaScript 事件对象和 jQuery 库。

在第四章中,我们讨论了测试 JavaScript 代码。我们涵盖了测试的原因以及如何添加测试的方法。然后,我们讨论了不同类型的代码测试以及它们如何应用于您的代码库。最后,我们讨论了各种 JavaScript 代码测试框架以及如何在其中构建测试。

在本章中,我们将介绍函数式编程的编码原则。在第一个主题中,我们将定义面向对象编程和函数式编程,讨论两者之间的区别,并概述我们使用函数式编程的原因。然后,在随后的部分中,我们将讨论函数式编程的每个关键概念。对于每个关键概念,我们将概述定义并展示其在函数式编程中的应用。

引入函数式编程

有许多不同的方法来处理软件设计和构建。最知名的设计哲学或编程范式是面向对象编程(OOP)和函数式编程(FP)。编程范式是一种思考软件设计和构建的方式。编程范式基于几个定义原则,并用于组织和描述软件应用的设计和构建。函数式编程是一种通过表达式和声明构建软件的编程范式。在本节中,我们将讨论面向对象编程和函数式编程的基础知识,并比较这两种编程范式。

面向对象编程

面向对象编程(OOP)是一种基于对象和语句的编程范式。对象是用于组织应用程序部分的编程抽象。在 OOP 中,对象通常包含并存储属性中的数据,具有可以在方法中运行的过程,并且具有thisself的概念,这是对象引用自身的一种方式。一般来说,对象以类的形式存在。可以被视为对象的定义,具有其属性、方法和this范围。对象是类的实例化。在 OOP 中,语句是指令驱动的代码。这将在声明式与命令式主题中更详细地介绍。许多编程语言都适用于面向对象编程软件开发。最流行的面向对象编程语言是 C++、Java 和 Python。

函数式编程

函数式编程(FP)是一种基于表达式和声明而不是对象和语句的编程范式。简而言之,这意味着 FP 依赖于函数而不是对象来组织代码和构建应用程序。函数式编程被认为起源于λ演算,这是在上世纪 30 年代创造的。函数式编程依赖于七个关键概念:声明式函数,纯函数,高阶函数,共享状态,不可变性,副作用和函数组合。这些概念中的每一个将在本章的后续主题中进行讨论。

函数式编程旨在更简洁,可预测和可测试。然而,这些好处可能导致 FP 代码比其他编程范式更密集。一些最常见的函数式编程语言是 JavaScript,PHP 和 Python。

声明式与命令式

有两种一般的编写代码的方式:声明式命令式。在函数式编程范式中编写的代码应该是声明式的。

声明式代码是表达计算逻辑而不描述其控制流的代码。命令式代码是使用语句来改变程序的状态的代码。

如果你以前从未学习过声明式和命令式代码,这些定义很难理解。声明式代码通常与函数式编程一起使用,而命令式代码通常与面向对象编程一起使用。在决定使用哪种编码风格时,没有“正确答案”;它们都有各自的权衡。然而,声明式代码比命令式更适合函数式编程范式。

命令式函数

命令式代码在面向对象编程中最常见。技术上的定义很复杂,但我们可以简化它。编写命令式代码是关于你如何解决问题。考虑在餐厅找到一张桌子。你走向主人/女主人说:“我看到角落的桌子是空的。我和我的妻子要走过去坐下。”这是一种命令式方法,因为你描述了你将如何从主人/女主人那里到你的团队的桌子。

声明式函数

声明式编程在 FP 中最常见。编写声明式代码的方法可以简化为我们需要做什么。考虑前面段落中的餐厅例子。获取桌子的声明式方法是走向主人/女主人说:“请给我们两个人的桌子。”我们描述我们需要什么,而不是我们将采取的每一步来获得桌子。声明式编程是符合开发者的思维模型而不是机器的操作模型。从这些定义和比喻中,我们可以得出结论,声明式编程是对一些命令式实现的抽象。

现在,让我们从比喻转到实际代码。考虑下面片段中显示的代码:

function addImperative( arr ) {
 let result = 0;
 for ( let i = 0; i < arr.length; i++ ) {
   result += arr[ i ];
 }
 return result;
}
function addDeclarative( arr ) {
 return arr.reduce( ( red, val ) => red + val, 0 );
}
片段 5.1:声明式与命令式函数

在上面的片段中,我们创建了两个函数来添加数组中的值。第一个函数addImperative是这个问题的一种命令式方法。代码逐步说明了数组将如何被添加。第二个函数addDeclarative是同一个问题的一种声明式方法。代码说明了数组将如何被添加。它通过使用 JavaScript 数组 reduce 操作来抽象出大部分命令式解决方案(for 循环)。

开始编写声明式代码而不是命令式代码的最简单方法是创建函数。这些函数应该抽象出命令式代码的逐步性质。考虑数组操作,如findmapreduce。这些函数都是声明式的数组成员函数。它们抽象出了对数组进行迭代的逐步性质。使用它们将有助于将声明式概念引入您的代码,并减少您编写的一些命令式代码。

练习 28:构建命令式和声明式函数

您的研究团队已经获得了最新实验的值列表;但是,由于校准错误,只有部分数据可以使用,并且可以使用的任何数据都需要进行缩放。您必须构建一个实用函数,该函数接受一个数组,过滤掉小于或等于 0 的任何值,将剩余的值缩放为乘以2,并返回最终结果。首先,构建一个命令式函数来执行此操作,然后构建一个声明式函数来执行相同的操作。

要使用命令式和声明式编码实践创建函数,请执行以下步骤:

  1. 定义一个名为imperative的函数,采用以下方法:

接受一个名为arr的数组参数。创建一个名为filtered的数组,用于保存过滤后的值。

创建一个for循环来遍历输入数组arr。对于每个项目,检查数组项的值。如果大于0,将该值推送到过滤后的数组中。

创建一个for循环来遍历过滤后的数组。对于每个项目,将其乘以2并保存在相同索引的过滤后的数组中。

返回过滤后的数组。

  1. 定义一个名为declarative的函数,执行以下操作:

使用Array.filter()过滤输入数组。在过滤器的callback函数中,检查值是否大于0。如果是,返回 true;否则,返回 false。

将一个 map 调用链接到filter输出。

使用Array.map()映射过滤后的数组。

在回调中,将每个值乘以2

返回修改后的数组。

  1. 创建一个值从-5+5的测试值数组。

  2. 使用值数组运行imperative并记录输出。

  3. 使用值数组运行declarative并记录输出。

代码

index.js
function imperative( arr ) {
 const filtered = [];
 for ( let i = 0; i < arr.length; i++ ) {
   if ( arr[ i ] > 0 ) {
     filtered.push( arr[ i ] );
   }
 }
 for ( let j = 0; j < filtered.length; j++ ) {
   filtered[ j ] = 2 * filtered[ j ];
 }
 return filtered;
}
function declarative( arr ) {
 return arr.filter( v => v > 0 ).map( v => 2 * v );
}
代码段 5.2:命令式和声明式代码比较

https://bit.ly/2skAnic

结果

图 5.1:测试值输出

图 5.1:测试值输出

图 5.2:修改后的数组输出

图 5.2:修改后的数组输出

您已成功利用了命令式和声明式编码实践来编写函数。

纯函数

纯函数是函数式编程的关键组成部分。纯函数可以定义为不对函数外部的状态产生任何影响或利用任何状态的函数。要被视为纯函数,函数必须满足三个关键标准:

  • 当给定相同的输入时,函数必须始终返回相同的输出。

  • 函数不能有副作用。

  • 函数必须具有引用透明性。

相同的输入给出相同的输出

给定一组输入值,纯函数在提供这些输入值时必须始终返回相同的值。这听起来比实际情况复杂得多。简而言之,纯函数的输出不能改变,除非更改输入值。这意味着函数的内部代码不能依赖于函数外部的任何程序状态。纯函数不能使用函数外部的任何变量来进行计算或代码路径决策。以下代码段显示了这一点的示例:

const state = { prop1: 5 };
function notPure () {
  return state.prop1 > 0 ? 'Valid': 'Invalid';
}
function pure( val ) {
  return val > 0 ? 'Valid': 'Invalid';
}
notPure(); // Expected output: 'Valid'
pure( state.prop ); // Expected output: 'Valid'
代码段 5.3:依赖外部状态

在前面的片段中,我们创建了一个名为 state 的变量,其中的prop1属性设置为5。然后我们定义了两个函数,根据值的比较返回字符串ValidInvalid。在第一个函数notPure中,我们检查 state 的prop1值,并根据此返回一个值。在第二个函数 pure 中,我们检查传入函数的值来决定返回什么。第一个函数不是一个纯函数。它依赖于函数外部的状态来确定函数的返回值。第二个函数是纯的,因为它依赖于函数的输入值,而不是全局状态变量。

无副作用

纯函数不能有副作用。这简单地意味着纯函数不能修改通过引用传递的任何对象或值。副作用将在副作用主题中更详细地讨论。在 JavaScript 中,只有对象和数组可以通过引用传递给函数。纯函数不能以任何方式修改这些对象或数组。如果您的函数需要在内部更新或修改数组或对象,我们必须首先创建数组/对象的副本。重要的是要注意,在 JavaScript 中,复制对象或数组只会复制实体的第一层。这意味着如果一个数组或对象中嵌套了数组或对象,这些嵌套的引用将不会被复制。当复制的对象按引用传递时,嵌套的对象也将被传递。这意味着如果嵌套引用没有被显式复制,可能会导致副作用。要正确地复制一个对象,我们必须创建一个深拷贝。对象的深拷贝是一个复制所有嵌套引用的副本。这可以通过递归或通过 Node.js 的deepcopy模块来完成。副作用的一个示例在下面的片段中显示:

function notPure( input ) {
  input.prop2 = 'test';
}
function pure( input ) {
  input = JSON.parse( JSON.stringify( input ) );
  input.prop2 = 'test';
  return input;
}
片段 5.4:避免副作用

在前面的片段中,我们定义了两个函数notPurepure。这两个函数都向传入函数的input对象添加一个属性。函数的不纯版本(notPure())直接修改了input对象。因为对象是按引用传递的,这个更新将在所有其他使用对象的范围内可见。这是一个副作用。函数的纯版本(pure())通过 JSON 操作创建了对象的深拷贝,然后向新对象添加了一个新属性并返回了新对象。由于对象被克隆了,原始对象没有被修改。没有产生副作用。

引用透明度

引用透明度是纯函数的一个属性,使得弄清楚函数行为更简单。如果一个函数具有引用透明性,那么对该函数的调用可以用函数调用的结果值(函数返回的值)替换,而不改变代码的含义。简而言之,这意味着函数应该返回在其被使用的代码上下文中有意义的值,并且不应该依赖或修改函数外部的状态。

编写纯函数给我们带来了两个关键的好处:

第一个好处是纯函数非常容易进行单元测试。纯函数不依赖外部状态,因此在编写测试时不需要考虑其他上下文。我们只需要考虑输入和输出值。

其次,纯函数使代码更简单、更灵活。纯函数不依赖外部状态,也不产生副作用。这意味着它们可以在任何特殊的上下文中使用。它们可以在更多的地方使用,因此更灵活。

练习 29:构建纯控制器

您已被聘为开发人员,以升级在线商店的购物车实现。构建一个函数来向购物车添加物品。您的函数应该是纯的。您可以假设有一个名为cart的全局数组,其中包含购物车。该函数应该至少接受一个物品(字符串)和一个数量(number)。在提供的文件(exercise-test.js)中创建名为addItem()的函数。该文件将有基本测试来测试纯度。

要使用纯函数概念构建应用程序的一部分,请执行以下步骤:

  1. 打开exercises/exercise29/exercise-test.js中的测试文件。

  2. 创建一个名为addItem的函数,它接受三个参数:cartitemquantity

  3. 复制传入函数的cart,并将复制的值保存到名为newCart的变量中。使用以下方法之一复制cart

对于最简单的复制,请使用 JSON 操作:JSON.parse( JSON.stringify( cart ) )

通过循环遍历原始购物车数组,并将每个项目推送到新数组中。

使用cart.map( () => {} ),因为数组中的所有项目都是简单类型。

使用rest/spread运算符,newCart= [ ...cart ],因为所有项目都是简单类型。

  1. 将传入函数的项目推送到cart数组中,quantity次数。

  2. 返回newCart数组。

  3. 运行exercise-test.js中提供的代码。

如果抛出错误,请修复代码中的错误,然后再次运行测试。

代码

exercise-solution.js
function addItem( cart, item, quantity ) {
 // Duplicate cart
 const newCart = JSON.parse( JSON.stringify( cart ) );
 newCart.push( ...Array( quantity ).fill( item ) );
 return newCart;
}
Snippet 5.5:函数纯度测试

https://bit.ly/2H2TXJG

输出

图 5.3:返回新的购物车数组

图 5.3:返回新的购物车数组

您已成功应用了纯函数的概念来构建应用程序的一部分。

高阶函数

正如我们在第一个主题中学到的,高阶函数是一个要么将另一个函数作为输入参数,要么返回另一个函数作为返回值的函数。几乎所有 JavaScript 中的异步代码都利用高阶函数,通过将回调函数作为输入参数传递。除了它们在 JavaScript 中的广泛使用之外,高阶函数是函数式编程的关键部分,用于三个关键好处:抽象、实用程序和复杂性减少。

高阶函数对于抽象非常重要。抽象是隐藏过程的内部工作或细节的一种方式。例如,考虑根据食谱烹饪一餐的过程。食谱可能要求您切碎食物。什么是切碎?它是一个动作的抽象。完成该动作的动作和步骤是拿起刀,将其放在食物上,向下按压。然后,将刀移动一小段距离沿着食物,并重复该过程,直到没有大块残留。切碎是这个动作的抽象。说“切胡萝卜”比长篇描述更简单更快。与准备食物一样,代码使用抽象来包装复杂的过程,并隐藏代码的内部工作。

高阶函数对于创建功能性实用程序非常有用。作为程序员,我们经常创建旨在对一组值执行操作的实用函数。通常,我们希望最大限度地提高灵活性,并创建可以在各种潜在输入值或格式上工作的函数。创建接受一些参数并返回新函数的高阶实用函数可以是一个很好的方法。这些函数在 JavaScript 中通常称为闭包。考虑以下片段中显示的函数:

function sortObjField1( field ) {
 return function ( v1, v2 ) {
   return v1[ field ] > v2[ field ];
 }
}
function sortObjField2( field, v1, v2 ) {
 return v1[ field ] > v2[ field ];
}
Snippet 5.6:高阶实用程序

在前面的片段中,我们创建了两个用于按指定字段中存储的值对对象数组进行排序的实用函数。这两个实用函数都需要指定字段。它们的区别在于返回值。SortObjField1是一个高阶函数,它接受字段名称并返回一个闭包函数。闭包函数接受我们尝试排序的两个对象,并返回排序值。第二个辅助函数sortObjField2一次接受字段和两个对象,并返回排序值。高阶实用函数更加强大,因为我们不需要同时知道所有的值。我们可以将sortObjField( 'field' )作为参数传递给另一个函数,以在程序的另一个部分中使用。

高阶函数对于减少复杂性也非常有用。代码越长越复杂,就越容易出现错误。高阶函数将复杂部分的内部工作抽象出来,并可以使用实用函数来减少需要编写的代码行数。这两种效果都将减少代码库的大小,从而减少复杂性。简化代码将有助于减少您必须花费在修复错误上的时间。

练习 30:编辑对象数组

目标是将高阶函数的概念应用于编辑对象数组。要使用必要的函数编辑数组,请执行以下步骤:

  1. 创建一个名为data的数组,其中包含以下数据:[ { f1: 6, f2: 3 }, { f1: 12, f2: 0 }, { f1: 9, f2: 1 }, { f1: 6, f2: 7 } ]

  2. 创建一个名为swap的函数,它接受两个参数key1key2

  3. swap函数添加一个return语句。return语句应返回一个函数。这个函数应该接受一个参数obj

  4. 在返回的函数内部,使用数组解构,交换obj中存储的key1key2的值。

提示:使用[a, b] = [b, a]来使用数组解构交换值。

  1. 从函数中返回修改后的对象obj

  2. 通过在data上调用map函数来编辑数据数组。将带有参数f1f2的调用传递给map函数。

提示:data.map( swap( 'f1', 'f2' ) );

  1. 记录对data.map()的调用的输出。

代码

index.js
const data = [ { f1: 6, f2: 3 }, { f1: 12, f2: 0 }, { f1: 9, f2: 1 }, { f1: 6, f2: 7 } ];
function swap( key1, key2 ) {
 return obj => {
   [ obj[ key1 ], obj[ key2 ] ] = [ obj[ key2 ], obj[ key1 ] ];
   return obj;
 }
}
console.log( data.map( swap( 'f1', 'f2' ) ) );

https://bit.ly/2D0t70K

输出

图 5.4:最终输出

图 5.4:最终输出

您已成功将高阶函数的概念应用于编辑对象数组。

共享状态

共享状态是存在于共享范围中的任何变量、对象或内存空间。任何被多个独立范围使用的非常量变量,包括全局范围和闭包范围,都被视为处于共享状态。在函数式编程中,应该避免共享状态。共享状态会阻止函数变得纯粹。当违反共享状态规则并且程序修改变量时,就会产生副作用。在面向对象编程中,共享状态通常作为对象传递。面向对象编程函数可能会修改共享状态。这与函数式编程规则背道而驰。下面的片段中展示了一个共享状态的示例:

const state = { age: 15 }
function doSomething( name ) {
  return state.age > 13 ? '${name} is old enough' : '${name} is not old enough';
}
片段 5.7:共享状态

在前面的例子中,我们有一个全局范围内的变量称为state。在我们的名为doSomething的函数中,我们引用变量 state 来做出逻辑代码决定。由于state变量是在doSomething函数的范围之外定义的,并且不是一个不可变对象(创建后其状态无法修改的对象),它被认为是一个共享状态。这是函数式编程中应该避免的事情,因为它会阻止我们的函数变得纯粹。

共享状态必须避免,原因有几个。首先,共享状态可能会使理解函数变得困难。要真正理解函数的工作原理以及给定输入的输出结果,我们必须理解函数所在的整个状态。如果我们的函数使用共享状态,那么在正确理解函数之前,我们必须理解一个更加复杂的状态。详细理解共享状态非常困难。要正确理解共享状态,必须理解状态如何更新以及它如何在与之共享的每个函数中使用。

虽然起初听起来可能不是一个主要的缺点,但不理解我们的函数如何工作将导致开发速度变慢,出现更多的错误和不充分的测试。共享状态会减慢开发速度,因为我们必须花更多的时间来理解依赖于它们的函数。如果我们不花时间理解共享状态和依赖于它们的函数,那么我们很可能不会编写高效和无错误的代码。这显然会导致更多时间用于调试和重构代码。不完全理解的函数往往更容易出现错误。如果我们不完全理解函数在共享状态中定义的所有可能性和限制下需要如何操作,那么我们很可能会忘记在开发中处理边缘情况。如果这些错误没有被发现,那么有缺陷的代码可能会被发布。最后,不理解函数几乎不可能完全测试一个函数。要完全测试任何函数,我们必须完全理解它在所有条件下的操作方式,换句话说,就是在所有可能被调用的状态下。

练习 31:修复共享状态

目标是重构代码以消除共享状态。要正确地重构代码,请执行以下步骤:

  1. 打开exercises/exercise31/exercise.js文件。您将更新此文件以解决练习。

  2. 运行步骤 1中打开的文件中的代码,并观察输出。

  3. 更新getOlder函数声明,以接受一个名为age的参数。

  4. 更新getOlder的主体,使其返回age+1++age,而不是修改全局变量。

  5. formatName函数声明更新为接受两个参数,firstlast

  6. 更新formatName的主体,使其返回Mrs. ${first} ${last}字符串,其中firstlast是存储在输入参数firstlast中的值。

  7. 更新对getOlder函数的调用,并将person.age作为参数传入。将返回的值保存到person.age中。

  8. 更新对formatName的函数调用,并将person.firstNameperson.lastName作为参数传入。将返回的值保存到person.name中。

  9. 运行代码并将输出与步骤 2的输出进行比较。它们应该是相同的。

代码

solution.js
const person = { age: 10, firstName: 'Sandra', lastName: 'Jeffereys' };
function getOlder( age ) {
 return ++age;
}
function formatName( first, last ) {
 return 'Mrs. ${first} ${last}';
}
console.log( person );
person.age = getOlder( person.age );
person.name = formatName( person.firstName, person.lastName );
console.log( person );

https://bit.ly/2CZwyoC

输出

图 5.5:最终输出

图 5.5:最终输出

您已成功地重构了代码以消除共享状态。

不可变性

不可变性是函数式编程中非常简单但非常重要的概念。不可变性的教科书定义只是“不可改变的”一词。在编程中,我们使用这个词来表示对象和变量在创建后不能改变其状态。

在软件开发中,值可以通过引用传递给函数。当变量通过引用传递时,意味着传递的是指向内存位置(指针)的引用,而不是内存中该位置包含的对象的序列化值。由于所有指向引用传递的变量的指针都指向同一块内存,对通过引用传递的变量值的任何更新都将被指向该内存块的任何指针看到。任何通过引用传递而不是通过值传递的变量都可以被视为共享状态,因为它可以被多个独立作用域修改。编写防止数据突变的函数非常重要,因为对通过引用传递的值的任何更改都将被视为对共享状态的更改。修改通过引用传递的变量将违反函数式编程的原则,并导致副作用。

在 JavaScript 中,不可变性的概念通常适用于传入函数的变量,以及函数返回的变量。在 JavaScript 中,简单类型(字符串、数字、布尔值)是按值传递的,而复杂类型(对象、数组等)是按引用传递的。对这些复杂数据类型的任何更改都会影响所有出现的地方,因为它们本质上只是指向同一块内存的指针。

JavaScript 对不可变性的支持并不完整。JavaScript 没有内置的不可变数组或对象。需要注意的是,变量创建关键字const不会创建不可变对象或数组。正如在第一章中讨论的那样,const 只是锁定名称绑定,使得名称绑定不能被重新分配。它不会阻止被变量引用的对象被修改。在 JavaScript 中,可以通过两种方式创建不可变对象:使用freeze函数和使用第三方库。

不可变对象可以使用 freeze 函数创建。freeze是全局Object prototype ( Object.freeze() )上的一个函数。它接受一个参数,即要冻结的对象,并返回相同的对象。freeze 防止向对象中添加、删除或修改任何内容。如果一个数组被冻结,它将锁定元素的值,并防止向数组中添加或删除元素。需要注意的是,freeze 函数只是浅冻结。作为属性(在对象中)或元素(在数组中)嵌套的对象和数组不会被freeze函数冻结。如果要完全冻结所有嵌套属性,必须编写一个辅助函数来遍历对象或数组树,冻结每个嵌套级别,或者找到一个第三方库。Object.freeze()的使用如下所示:

const data  = {
  prop1: 'value1',
  objectProp: { p1: 'v1', p2: 'v2' },
  arrayProp: [ 1, 'test' , { p1: 'v1' }, [ 1, 2, 3 ] ]
};
Object.freeze( data );
Object.freeze( data.objectProp );
Object.freeze( data.arrayProp );
Object.freeze( data.arrayProp[2] );
Object.freeze( data.arrayProp[3] );
片段 5.8:冻结一个对象

JavaScript 中的不可变性

存在几个第三方库可以为 JavaScript 添加不可变功能。有两个库通常被认为是 JavaScript 中最好的不可变性库。它们是MoriImmutable。Mori 是一个将 ClojurScript 的持久数据结构和不可变性引入 JavaScript 的库。Immutable是 Facebook 的不可变性库的实现,具有 JS API,将许多不可变数据结构引入 JavaScript。这两个库被认为非常高效,并且在许多大型项目中通常被使用。

注意

有关 Mori 和 Immutable 的更多信息,以及完整的文档,请参阅github.com/swannodette/morifacebook.github.io/immutable-js/上的库页面。

在 JavaScript 中有一种最终实现不可变性的方法;然而,这并不是真正的不可变性。为了避免使用第三方库或冻结传递给函数的任何对象或数组,我们可以简单地创建传递引用的任何变量的副本,并修改副本而不是原始值。这将防止通过引用传递数据的共享状态问题,但它会带来内存效率和效率的折衷。简单地将引用分配给一个新变量不会复制数据。我们可以通过三种方式之一复制对象或数组——使用第三方库,通过遍历对象树,或者使用 JSON 操作。

存在用于创建对象的深层副本的第三方库。这通常是复制对象的最简单方法。我们还可以遍历对象的树,并将每个值和属性复制到一个新对象中。这通常需要我们编写和测试自己的函数。最后,我们可以使用 JSON 操作 stringify 和 parse 来复制一个对象。首先将对象字符串化,然后解析字符串(JSON.parse(JSON.stringify(obj)))。JSON 操作通常是复制对象的最简单方法,但它带来了最多的缺点和限制。如果对象具有不兼容 JSON 的属性,例如函数或类,这种方法将无效。将整个对象转换为字符串,然后将整个字符串解析为对象也非常低效。对于小对象,这可能不会影响性能,但如果您必须复制一个大对象,则不建议使用此方法,因为它是一个阻塞操作。

副作用

副作用是我们采取行动后产生的任何次要效果或反应。副作用可以是好的也可以是坏的,但通常是无意的。在函数式编程中,副作用是指除函数返回值之外可以在函数调用之外看到的任何状态更改。根据函数式编程的规则,函数不允许修改函数之外的任何状态。如果函数有意或无意地修改了状态,这被视为副作用,因为它违反了函数式编程的原则。

副作用是不好的,因为它使程序变得更加复杂。正如前面讨论的,共享状态会增加程序的复杂性。函数中的副作用会修改共享状态,因此增加了复杂性。无论有意还是无意,副作用都会使代码更难以测试和调试。以下列表显示了 JavaScript 中副作用最常见的原因的简单分解:

  • 修改任何外部状态(变量)

两种变量类型包括全局变量和父函数作用域中的变量。

这个列表中的第一条应该从 FP 副作用的定义中是不言自明的。对任何外部状态的改变,包括函数范围之外的任何变量,都是副作用。变量的作用域级别并不重要。它可以在全局作用域中,也可以在父函数作用域树中的任何地方;对函数范围之外的变量的任何改变都被视为副作用。

  • 输入/输出

列表包括记录到控制台,写入屏幕或显示器,文件 I/O 操作,网络操作,HTTP 请求,消息队列和数据库请求。

副作用列表中的第二个要点并不那么直观。考虑一下 I/O 操作。它们做什么?它们修改一些外部资源。这可以是控制台的内容,网页上显示的视图或显示,文件系统中的文件,或者仅通过网络访问的外部资源。这些外部资源不直接限定于修改它们的代码块,并且可以被其他完全无关的应用程序修改和查看。根据定义,文件系统和控制台等资源是共享状态。对这些资源的修改算作副作用。

  • 启动或结束外部进程

副作用列表中的第三个要点与第二个类似。启动外部进程,例如辅助线程以卸载一些大量的同步工作,会产生副作用。当我们启动一个新进程时,我们直接改变了系统的状态。创建了一个新线程,它超出了创建它的函数的范围。根据定义,这是一个副作用。

  • 调用任何具有副作用的其他函数

副作用列表中的第四项也不那么直观。调用具有副作用的函数的任何函数都被认为具有副作用。考虑一个程序设置,其中函数 A 调用函数 B,并且函数 B 导致全局状态的更改。对全局状态的更改可以由对函数 B 的直接调用或通过调用函数 A 而引起。由于对函数 A 的调用仍然会导致全局状态的更改,即使函数 A 的代码不直接修改全局状态,函数 A 仍然被认为具有副作用。

在编写 FP 代码时,我们必须考虑以下问题:

如果任何 I/O 操作引起副作用,我们如何将 FP 原则应用于编写没有副作用的有用代码?由于 I/O 操作会引起副作用,那么我们代码中使用的每个网络调用或文件系统操作都会引起副作用吗?是的。 I/O 引起副作用,它们是不可避免的。解决此问题的方法是将具有副作用的代码与软件的其余部分隔离开来。任何具有副作用或依赖具有副作用的模块或操作(数据库操作等)的代码必须与不具有副作用的代码隔离开来。这通常是通过模块完成的。大多数前端和后端框架鼓励我们使用模块将状态管理与代码的其余部分分离。引起副作用的代码被移除并放入自己的模块中,以便代码库的其余部分可以在没有副作用的情况下进行测试和维护。

避免副作用

几乎不可能编写一个没有副作用的完整应用程序。Web 应用程序/服务器必须处理/发出 HTTP 请求-根据定义是副作用。为了实现这一点,您可以执行以下操作:

  • 将具有副作用的代码与代码库的其余部分隔离。

  • 将状态管理代码和具有副作用的代码与应用程序的其余部分分开。

这些方法使测试和调试更容易。

函数组合

函数组合是理解函数式编程的最后关键。函数组合将本章学到的许多概念很好地融入到函数式编程的核心中。函数组合的广泛使用定义是函数组合是一个数学概念,允许您组合多个函数以创建一个新函数。这个定义告诉我们函数组合是什么,但并没有真正告诉我们如何组合函数或者为什么我们需要使用它。

根据定义,函数组合是将函数组合在一起创建新函数的行为。这到底意味着什么?在数学中,我们经常看到像这样组合的函数:f(g(x))。如果这对你来说不熟悉,在表达式 f(g(x))中,我们将变量 x 传递给函数 g,然后将 g(x)的结果传递给函数 f。表达式 f(g(x))从内到外,从右到左,按顺序 x,g,f 进行评估。在函数 g 中使用输入参数的每个实例,我们可以替换为 x 的值。在函数 f 中使用输入参数的每个实例,我们可以替换为 g(x)的值。现在,让我们用代码考虑这种函数组合的方法。考虑以下代码片段:

function multiplyBy2( c ) {
 return 2 * c;
}
function sumNumbers( a, b ) {
 return a + b;
}
const v1 = sumNumbers( 2, 4 ); // 2 + 4 = 6
const v2 = multiplyBy2( v2 ); // 2 * 6 = 12
const v3 = multiplyBy2( sumNumbers( 2, 4 ) ); // 2 * ( 2 + 4 ) = 12
代码段 5.10:函数组合

在上述代码片段中,我们创建了一个将值乘以 2 的函数和一个将两个数字相加的函数。我们可以使用这些函数以两种方式计算一个值。首先,我们独立使用这些函数,依次使用。这需要我们创建一个变量并保存第一个函数的输出,使用该值调用第二个函数,然后将第二个函数的结果保存到一个变量中。这需要两行代码和两个变量。我们计算值的第二个选项是使用函数组合。我们只需要在第二个函数的输入参数中调用一个函数,并保存结果变量。这只需要一行代码和一个变量。从代码片段中可以看出,使用函数组合将有助于简化我们的代码,并减少我们需要编写的代码行数。

函数组合非常有用,可以减少我们需要编写的代码行数,同时减少代码的复杂性。在函数式编程范式中编写代码时,重要的是要认识到我们可以利用函数组合的优势的情况。

活动 5:递归不可变性

您正在使用 JavaScript 构建应用程序,并且已被告知出于安全原因不能使用任何第三方库。现在,您必须为此应用程序使用 FP 原则,并且需要一种算法来创建不可变的对象和数组。创建一个递归函数,使用Object.freeze()来强制对象和数组在所有嵌套级别上的不可变性。为简单起见,您可以假设对象中没有嵌套的空值或类。在'Lesson 5/topic f - immutability/activity-test.js'中编写您的函数。此文件包含测试您实现的代码。

要强制对象的不可变性,请执行以下步骤:

  1. 创建一个名为immutable的函数,它接受一个参数data

  2. 冻结data对象。

  3. 循环遍历对象值,并对每个值递归调用不可变函数。

代码

结果

图 5.6:返回新的购物车数组

图 5.6:返回新的购物车数组

您已成功演示了强制对象的不可变性。

注意

此活动的解决方案可在第 291 页找到。

摘要

函数式编程是一种侧重于表达式和声明来设计应用程序和构建代码库的编程范式。函数式编程是炙手可热的新编程风格之一,被认为是 JavaScript 编程的最佳风格。函数式编程可以帮助我们的 JavaScript 更加简洁,可预测和可测试。函数式编程建立在七个关键概念上:声明式函数,纯函数,高阶函数,共享状态,不可变性,副作用和函数组合。

声明性函数关注的是解决方案或目标,而不是我们如何得到解决方案。声明性函数旨在抽象掉大量的命令式代码。它们帮助开发人员更符合开发者的思维模型,而不是运行代码的机器的操作模型。

纯函数旨在使我们的代码更易于测试、更易于调试,并且更灵活和可重用。我们在 JavaScript 中编写的所有函数都应该努力成为纯函数。纯函数在给定相同的输入值时必须始终返回相同的输出值。它们不能通过修改外部状态来引起任何副作用,并且必须具有引用透明性。

高阶函数是 JavaScript 异步编程中最常用的函数类型之一。高阶函数是任何以函数作为输入并返回函数作为输出的函数。高阶函数非常有用,可以用于抽象代码、减少复杂性以及创建和管理实用函数。它们是闭包的关键,允许我们对代码非常灵活。

共享状态是函数式编程中要避免的最重要的事情之一。共享状态是存在于共享作用域中的任何非常量变量或非不可变对象或内存空间。共享作用域可以是全局作用域或父函数作用域树中的任何作用域。共享状态会阻止函数成为纯函数,并可能导致更多的错误、不充分的测试和开发速度变慢。

不变性是无法改变某物的能力。在 JavaScript 中,所有按引用传递的变量都应该是不可变的。对可变变量的更改可能会导致副作用,并无意中修改不应共享的状态。在 JavaScript 中,可以通过Object.freeze()函数、第三方库和 JSON 操作来实现不可变性。

在 JavaScript 中,副作用是指可以从函数调用外部看到的任何状态更改,不包括函数的返回值。副作用可以由对共享状态变量的任何修改、任何 I/O 操作、任何外部进程执行或调用具有副作用的任何函数引起。要完全消除 JavaScript 应用程序中的副作用可能非常困难。为了最小化副作用的影响,我们必须将具有副作用的代码与代码库的其余部分隔离开来。引起副作用的代码应该移入模块以进行隔离。

函数组合是函数式编程的最后一个关键概念。我们可以通过以新的方式组合更简单的函数来简单地创建复杂而强大的函数。函数组合旨在帮助抽象和减少我们代码的复杂性。

在下一章中,您将介绍服务器端 JavaScript 的基本概念,并构建一个 Node.js 和 Express 服务器。

第六章:JavaScript 生态系统

学习目标

在本章结束时,您将能够做到以下事情:

  • 比较不同的 JavaScript 生态系统

  • 解释服务器端 JavaScript 的基本概念

  • 构建一个 Node.js 和 Express 服务器

  • 构建一个 React 前端网站

  • 将前端框架与后端服务器结合起来

最后一章详细介绍了 JavaScript 生态系统,并教导学生如何使用 Node.js 的不同功能和部分,以及 Node 包管理器(NPM)。

介绍

在第五章“函数式编程”中,我们介绍了“函数式编程范式”。我们讨论了面向对象编程和函数式编程,讨论了两者之间的区别,并概述了为什么我们应该使用函数式编程。在第二部分中,我们讨论了函数式编程的关键概念,并演示了它们如何应用于 JavaScript 代码。

在过去的 10 多年里,JavaScript 生态系统已经大幅增长。JavaScript 不再只是用于在基本的 HTML 网页上添加动画等效果的编程语言。现在 JavaScript 可以用于构建完整的后端 Web 服务器和服务、命令行界面、移动应用程序和前端网站。在本章中,我们将介绍 JavaScript 生态系统,讨论使用 Node.js 在 JavaScript 中构建 Web 服务器,并讨论使用 React 框架在 JavaScript 中构建网站。

JavaScript 生态系统

我们将讨论 JavaScript 生态系统的四个主要类别:前端、命令行界面、移动和后端。

  • 前端 JavaScript 用于用户界面网站。

  • 命令行界面(CLI)JavaScript 用于构建命令行任务,以帮助开发人员。

  • 移动开发 JavaScript 用于构建手机应用程序。

  • 后端 JavaScript 用于构建 Web 服务器和服务。

对于最初是为了在浏览器中嵌入简单应用程序而创建的语言来说,JavaScript 已经走了很长的路。

前端 JavaScript

前端 JavaScript 用于创建复杂和动态的用户界面网站。Facebook、Google Maps、Spotify 和 YouTube 等网站都严重依赖 JavaScript。在前端开发中,JavaScript 用于操作 DOM 和处理事件。许多 JavaScript 库,如 jQuery,已被创建以通过将每个浏览器的 DOM 操作 API 封装成标准化 API 来增加 JavaScript DOM 操作的效率和便利性。最常见的 DOM 操作库是 jQuery,在第三章“DOM 操作和事件处理”中进行了讨论。还创建了 JavaScript 框架,以更无缝地将 DOM 操作和事件与 HTML 设计方面整合在一起。前端开发中最常见的两个 JavaScript 框架是 AngularJS 和 React。AngularJS 由 Google 创建和维护,React 由 Facebook 创建和维护。

Facebook 和 Google 管理其各自框架的错误修复和版本发布。React 将在本章的后面部分进行更详细的讨论。

命令行界面

命令行集成(CLI)JavaScript 通常用于创建实用程序,以帮助开发人员处理重复或耗时的任务。JavaScript 的 CLI 程序通常用于诸如代码检查、启动服务器、构建发布、转译代码、文件最小化以及安装开发依赖和包等任务。JavaScript 的 CLI 程序通常是用 Node.js 编写的。Node.js 是一个跨平台环境,允许开发人员在浏览器之外执行 JavaScript 代码。Node.js 将在本章的后面部分进行更详细的讨论。许多开发人员在日常开发中依赖 CLI 实用程序。

移动开发

使用 JavaScript 进行移动开发正在迅速成为主流。自智能手机兴起以来,移动开发人员已成为炙手可热的商品。尽管 JavaScript 不能在大多数移动操作系统上本地运行,但存在允许将 JavaScript 和 HTML 构建到 Android 和 IOS 手机应用程序中的框架。JavaScript 移动开发最常见的框架是 Ionic、React Native 和 Cordova/PhoneGap。这些框架都允许您编写 JavaScript 来构建应用程序的框架和逻辑,然后将 JavaScript 编译为本机移动操作系统代码。移动开发框架非常强大,因为它们允许我们使用首选的 JavaScript 构建完整的移动应用程序。

后端开发

使用 JavaScript 进行后端开发通常使用 Node.js。Node.js 可用于构建强大的 Web 服务器和服务。正如前面所述,Node.js 及其在后端服务器开发中的应用将在本章的后续部分中进行更详细的讨论。

JavaScript 生态系统非常广泛。几乎可以用 JavaScript 编写任何类型的程序。尽管现代 JavaScript 具有许多框架和功能,但重要的是要记住,框架不能取代对核心 JavaScript 的深入理解。框架很好地封装了核心 JavaScript,使我们能够执行强大的任务,如构建移动和桌面应用程序,但如果不深刻理解 JavaScript 和异步编程的核心原则,应用程序可能会出现缺陷。

Node.js

Node.js(简称 Node),由 Ryan Dahl 于 2009 年开发,是最流行的非浏览器 JavaScript 引擎。Node 是一个基于 Chrome 的 V8 JavaScript 引擎的开源、跨平台 JavaScript 运行时环境。它用于在浏览器之外运行 JavaScript 代码,用于非客户端的应用程序。

与 Chrome 中的 Google V8 JavaScript 引擎一样,Node.js 使用单线程、事件驱动、异步架构。它允许开发人员使用 JavaScript 的事件驱动编程风格来构建 Web 服务器、服务和 CLI 工具。如第二章,异步 JavaScript中所讨论的,JavaScript 是一种非阻塞和事件驱动的编程语言。JavaScript 的异步特性(单线程事件循环),加上 Node 的轻量设计,使我们能够构建非常可扩展的网络应用程序,而无需担心线程。

注意

第二章,异步 JavaScript中所讨论的,JavaScript 是单线程的。在单线程上运行的同步代码是阻塞的。CPU 密集型操作将阻塞事件,如 I/O 文件系统操作和网络操作。

设置 Node.js

Node.js 可以从 Node.js 网站下载,网址为nodejs.org/en/。有两个可供下载的版本:长期支持(LTS)版本和当前版本。我们建议您下载 LTS 版本。当前版本具有最新的功能,但可能不完全没有错误。请务必遂行您操作系统的特定安装说明。可以为所有三种主要操作系统下载安装程序文件,并且可以使用许多软件包管理器安装 Node.js。Node.js 安装调试不在本书的范围之内。但是,可以通过谷歌搜索轻松找到安装提示和调试提示。

注意

Node.js 的下载链接如下:nodejs.org/en/download/

一旦 Node.js 被下载并安装,就可以使用node命令从终端运行它。在执行此命令后不跟任何参数,将运行 Node.js 终端。JavaScript 代码可以直接在终端中输入,就像浏览器的调试控制台一样。重要的是要注意,在终端实例之间没有状态传递。当运行 Node.js 命令行的终端实例关闭时,所有计算将停止,并且 Node.js 命令行进程使用的所有内存将释放回操作系统。要使用 Node.js 运行 JavaScript 代码文件,只需在node命令后直接添加文件路径。例如,以下命令将在./path/to/file位置以文件名my_file.js运行文件:node ./path/to/file/my_file.js

Node 包管理器

Node.js 是一个开源平台。Node 的最大优势之一是可用的开源第三方库,称为模块。Node 使用Node 包管理器(NPM)来处理应用程序使用的第三方模块的安装和管理。NPM 通常与 Node.js 一起安装。要测试 NPM 是否已正确安装到 Node,请打开终端窗口并运行npm -v命令。如果 NPM 已正确安装,终端将打印出当前 NPM 的版本。如果 NPM 未随 Node 一起安装,可能需要重新运行 Node.js 安装程序。

注意

本节未涵盖的所有功能的 NPM 文档可以在docs.npmjs.com/找到。

第一章,介绍 ECMAScript 6中,我们学习了关于 ES6 模块。非常重要的是,我们要区分 ES6 模块和 Node.js 模块。Node.js 模块是在 ES6 和原始 JavaScript 对模块的支持之前创建的。虽然 Node.js 模块和 ES6 模块用于相同的目的,但它们不遵循相同的技术规范。Node.js 模块和 ES6 模块的加载、解析和构建方式不同。Node.js 模块是同步从磁盘加载、同步解析和同步构建的。在模块加载完成之前,没有其他代码可以运行。不幸的是,ES6 模块的加载方式不同。它们是异步从磁盘加载的。这两种不同的模块加载方法不兼容。在撰写本书时,Node.js 对 ES6 模块的支持处于测试阶段,并且默认情况下未启用。可以启用对 ES6 模块的支持,但我们建议您在 ES6 模块的完全支持发布之前使用标准的 Node 模块。

NPM 包是通过命令行使用npm install命令安装的。您可以使用此命令将特定包添加到项目中,或安装所有缺少的依赖项。如果没有向安装命令提供参数,npm将在当前目录中查找package.json文件。在package.json文件中,有一个dependencies字段,其中包含为 Node.js 项目安装的所有依赖项。NPM 将遍历依赖项列表,并验证该列表中指定的每个包是否已安装。packages.json中的依赖项列表将类似于以下代码片段中显示的代码:

"dependencies": {
 "amqplib": "⁰.5.2",
 "body-parser": "¹.18.3",
 "cookie-parser": "¹.4.3",
 "express": "⁴.16.3",
 "uuid": "³.3.2"
}
代码片段 6.1:package.json 中的依赖项列表

package.json中的依赖项字段列出了为项目安装的 NPM 模块,以及版本号。在这个片段中,我们安装了amqplib模块的版本为0.5.2或更高版本,安装了body-parser模块的版本为1.18.3或更高版本,以及其他几个模块。NPM 模块遵循语义化版本。版本号由三个数字组成,用句点分隔。第一个数字是主要版本。主要版本号的增加表示破坏向后兼容的重大更改。第二个数字是次要版本。次要版本号的更改表示发布了不会破坏向后兼容性的新功能。最后一个数字是补丁号。补丁号的增加表示修复错误或对功能进行小更新。补丁号的增加不包括新功能,也不会破坏向后兼容性。

注意

有关语义化版本的更多信息,请访问www.npmjs.com/

安装模块时,可以在npm install命令的install后添加参数(例如,npm install express)。参数可以是包名称、Git 存储库、tarball或文件夹。如果参数是一个包,NPM 将在其注册的包列表中搜索并安装与名称匹配的包。如果参数是 Git 存储库,NPM 将尝试从 Git 存储库下载并安装文件。如果没有提供适当的访问凭据,安装可能会失败。

注意

请参阅 NPM 文档,了解如何从私有 git 存储库安装包。

如果参数是一个 tarball,NPM 将解压 tarball 并安装文件。tarball 可以通过指向 tarball 的 URL 或本地文件进行安装。最后,如果指定的参数是本地机器上的文件夹,NPM 将尝试从指定的文件夹安装 NPM 包。

在使用 NPM 安装包时,重要考虑包的安装方式。默认情况下,包是在本地项目范围内安装的,并且不会保存为项目依赖项。如果要安装一个 NPM 包,并希望将其保存在package.json中作为项目依赖项,必须在安装命令的包名称后包含--save-s参数(例如,npm install express -s)。此参数告诉 NPM 将依赖项保存在package.json中,以便以后的npm install命令会安装它。

NPM 包可以安装在两个范围内:全局范围本地范围。在本地范围内安装的包,或本地包,只能在安装它们的 Node.js 项目中使用。在全局范围内安装的包,或全局包,可以被任何 Node.js 项目使用。默认情况下,包是本地安装的。要强制安装模块为全局安装,可以在包名称后添加-g--global标志到npm install命令(例如,npm install express -g)。

并不总是明显应该在哪里安装包,但如果不确定,可以遵循以下一般规则。如果要在具有require()函数的项目中使用包,请在本地安装包。如果计划在命令行上使用包,请全局安装包。如果仍然无法决定并且需要在项目和命令行中使用包,可以在两个地方都安装它。

加载和创建模块

Node.js 使用CommonJS风格的模块规范作为加载和处理模块的标准。CommonJS 是一个旨在为浏览器外的 JavaScript 指定 JavaScript 生态系统的项目。CommonJS 定义了一个模块规范,被 Node.js 采纳。模块允许开发人员封装功能,并仅向其他 JavaScript 文件公开所需部分的封装功能。

在 Node.js 中,我们使用 require 函数将模块导入到我们的代码中(require('module'))。require函数可以加载任何有效的 JavaScript 文件、NPM 模块或 JSON 文件。我们将使用require函数加载为我们的项目安装的任何 NPM 包。要加载一个模块,只需将模块的名称作为参数传递给require函数,并将返回的对象保存到一个变量中。例如,我们可以使用以下代码加载 NPM 模块body-parserconst bodyParser = require( 'body-parser' )。这将导入导出的函数和变量到bodyParser对象中。require 函数还可以用于加载 JavaScript 文件和 JSON 文件。要加载其中一个文件,只需将文件路径传递给require函数,而不是模块名称。如果未提供文件扩展名,Node.js 将默认查找 JavaScript 文件。

注意

还可以通过 require 函数加载目录。如果提供的是目录而不是 JS 文件,则 require 函数将在指定目录中查找名为index.js的文件并加载该文件。如果找不到该文件,将抛出错误。

要创建一个模块,即 Node.js 模块,我们使用module.exports属性。在 Node.js 中,每个 JavaScript 文件都有一个名为module的全局变量对象。module对象中的exports字段定义了将从模块中导出的项目。当使用require()函数导入模块时,require()的返回值是模块的module.exports字段中设置的值。模块通常导出一个函数或具有每个导出的函数或变量的属性的对象。以下是导出模块的示例:

module.exports = {
  exportedVariable,
  exportedFn
}
const exportedVariable = 10;
function exportedFn( args ){ console.log( 'exportedFn' ) ;}
Snippet 6.2:导出 Node.js 模块

练习 32:导出和导入 NPM 模块

要构建、导出和导入 NPM 模块,请执行以下步骤:

  1. 为我们的模块创建一个名为module.js的 JavaScript 文件。

  2. module.exports属性设置为一个对象。

  3. exportedConstant字段添加到对象中,并将其值设置为An exported constant!

  4. exportedFunction字段添加到对象中,并将其值设置为记录到控制台的函数,文本为An exported function!

  5. 为我们的主要代码创建一个index.js文件。

  6. 使用require函数从module.js导入模块,并将其保存到ourModule变量中。

  7. 从 ourModule 中记录exportedString的值。

  8. ourModule调用exportedFunction函数。

代码

module.js
module.js
module.exports = {
 exportedString: 'An exported string!',
 exportedFunction(){ console.log( 'An exported function!' ) }
};
Snippet 6.3:将代码导出为模块

https://bit.ly/2M3SIsT

index.js
const ourModule = require('./module.js');
console.log( ourModule.exportedString );
ourModule.exportedFunction();
Snippet 6.4:将代码导出为模块

https://bit.ly/2RwOIXP

结果

图 6.1:测试值输出

图 6.1:测试值输出

您已成功构建、导出和导入 NPM 模块。

基本 Node.js 服务器

Node.js 最常见的应用是 Web 服务器。Node.js 使构建高效和可扩展的 Web 服务器变得非常容易,因为开发人员不需要担心线程。在本节中,我们将演示在 Node.js 中创建基本 Web 服务器所需的代码。

Node.js 服务器可以设置为 HTTP、HTTPS 或 HTTP2 服务器。在本例中,我们将创建一个基本的 HTTP 服务器。Node.js 通过 HTTP 模块提供了 HTTP 服务器的基本功能。使用 require 语句导入 HTTP 模块。如下所示:

const http = require( 'http' );
Snippet 6.5:加载 HTTP 模块

这行代码将导入模块'HTTP'中包含的功能,并将其保存在变量http中以供以后使用。现在我们已经加载了 HTTP 模块,我们需要选择一个主机名和一个端口来运行我们的服务器。由于这个服务器只会在我们的计算机本地运行,我们可以使用机器内部本地网络的 IP 地址,即 localhost('127.0.0.1')作为我们的主机名地址。我们可以在任何尚未被其他应用程序使用的网络端口上运行我们的本地服务器。

您可以选择任何有效的端口号,但通常情况下程序不会默认使用端口8000,所以在这个演示中使用了这个端口号。在您的代码中添加一个变量来包含端口号和主机名。到目前为止的完整代码如下所示:

const http = require('http');
const hostname = '127.0.0.1';
const port = 8000;
代码段 6.6:简单服务器的常量

现在我们已经为我们的服务器设置了所有基本参数,我们可以编写代码来创建和启动服务器。HTTP 模块包含一个名为createServer()的函数,它返回一个服务器对象。这个函数可以接受一个可选的回调函数,作为 HTTP 请求监听器。当任何 HTTP 请求进入服务器时,提供的回调方法会被调用。我们需要使用带有请求监听器回调的createServer函数,这样我们的服务器才能正确地响应传入的 HTTP 请求。这是在以下代码段中显示的代码行:

const server = http.createServer((req, res) => {  res.statusCode = 200;  res.setHeader('Content-Type', 'text/plain');  res.end('Welcome to my server!\n');});
代码段 6.7:创建一个简单的服务器

在前面的代码段中,我们调用create server函数并将返回的服务器保存到server变量中。我们将一个回调传递给createServer()。这个回调接受两个参数:reqresreq参数表示传入的 HTTP 请求,res参数表示服务器的 HTTP 响应。在回调的第一行代码中,我们将响应状态码设置为200。响应中的200状态码表示服务器对 HTTP 请求成功。在状态码之后的一行中,我们在响应中设置了Content-Type头为text/plain。这一步告诉响应传入的数据将是纯文本。在回调的最后一行中,我们调用了res.end()函数。这个函数将传入的数据附加到响应中,然后关闭响应并将其发送回请求者。在这个代码段中,我们将Welcome to my server!字符串传递给end()函数。响应中附加了这个字符串,并将文本发送回请求者。我们的服务器现在使用这个处理程序处理所有对它的 HTTP 调用。

将我们的迷你服务器启动并运行的最后一步是在服务器对象上调用.listen()函数。listen函数在指定的porthostname上启动 HTTP 服务器。一旦服务器开始监听,它就可以接受 HTTP 请求。以下代码段显示了如何使服务器在指定的port和指定的hostname上监听:

server.listen( port, hostname, () => {  console.log('Server running at http://${hostname}:${port}/');});
代码段 6.8:服务器开始在主机名和端口上监听

前面的代码段显示了如何调用server.listen()函数。传递给函数的第一个参数是我们的服务器将暴露在的端口号。第二个参数是我们的服务器将从中访问的主机名。在这个例子中,端口评估为8000,主机名评估为127.0.0.1(您的计算机的本地网络)。在这个例子中,我们的服务器将在127.0.0.1:8000上监听。传递给.listen()的最后一个参数是一个回调函数。一旦服务器开始在指定的端口和主机名上监听 HTTP 请求,提供的回调函数就会被调用。在前面的代码段中,回调函数只是打印出我们的服务器可以在本地访问的 URL。您可以将此 URL 输入到浏览器中,然后一个网页将加载。

练习 33:创建基本的 HTTP 服务器

要构建一个基本的 HTTP 服务器,请执行以下步骤:

  1. 导入http模块。

  2. 为主机名和端口设置变量,并分别给它们赋值127.0.0.18000

  3. 使用http.createServer创建服务器。

  4. createServer函数提供一个回调,该回调接受参数reqres

  5. 将响应状态码设置为200

  6. 将响应内容类型设置为text/plain

  7. 使用My first server!响应请求

  8. 使用server.listen函数使服务器监听指定的端口和主机。

  9. listen函数提供一个回调,记录Server running at ${server uri}

  10. 启动服务器并加载已记录的网页。

代码

index.js
const http = require( 'http' );
const hostname = '127.0.0.1';
const port = 8000;
const server = http.createServer( ( req, res ) => {
 res.statusCode = 200;
 res.setHeader( 'Content-Type', 'text/plain' );
 res.end( 'My first server!\n' );
} );
server.listen( port, hostname, () => console.log( 'Server running at http://${hostname}:${port}/' ) );
代码片段 6.9:简单的 HTTP 服务器

https://bit.ly/2sihcFw

结果

图 6.2:返回新的购物车数组

图 6.2:返回新的购物车数组

图 6.3:返回新的购物车数组

图 6.3:返回新的购物车数组

您已成功构建了一个基本的 HTTP 服务器。

流和管道

流数据可能是 Node.js 中最复杂和最被误解的方面之一。流也可以说是 Node.js 提供的最强大的功能之一。流只是数据的集合,就像标准数组或字符串一样。主要区别在于,使用流时,所有数据可能不会同时可用。你可以把它想象成从 YouTube 或 Netflix 上流视频。你不需要在开始观看视频之前下载整个视频。视频提供者(YouTube 或 Netflix)以小块的方式向你的计算机发送视频。你可以开始观看视频的一部分,而不需要等待其他部分被加载。流非常强大,因为它们允许服务器和客户端不需要一次性将整个大量数据集加载到内存中。在 JavaScript 服务器中,流对于内存管理至关重要。

Node.js 中的许多内置模块依赖于流。这些模块包括 HTTP 模块(http)中的请求和响应对象,文件系统模块(fs)中的文件,加密模块(crypto)和子进程模块(child_process)。在 Node.js 中,流有四种类型——可读可写双工转换。理解它们的作用非常简单。

流的类型

数据从可读流中消耗。它们抽象了源的加载和分块。数据以一次一个数据块的方式呈现给可读流进行消耗(使用)。在数据块被消耗后,它被流释放,并呈现下一个数据块。可读流不能由消费者推送数据进入其中。可读流的一个例子是 HTTP 请求体。

可读流有两种模式——流动暂停。这些模式决定了流的数据流动。当流处于流动模式时,数据会自动从底层流系统中读取,并提供给消费者。当流处于暂停模式时,数据不会自动从底层系统中读取。消费者必须使用stream.read()函数显式请求流中的数据。所有可读流都以暂停模式开始,并可以通过附加data事件处理程序、调用stream.resume()调用 stream.pipe()来切换到流动模式。事件处理程序和流管道将在本节后面介绍。可读流可以使用stream.pause()方法或stream.unpipe()方法从流动切换到暂停。

可写流是可以写入或推送数据的流。可写流将源的组合和处理抽象化。数据被呈现给流以供提供者消耗。流将一次消耗一个数据块,直到被告知停止。在流消耗了一个数据块并适当处理后,它将消耗或请求下一个可用的数据块。一个可写流的例子是文件系统模块的createWriteStream函数,它允许我们将数据流到磁盘上的文件中。

双工流是既可读又可写的流。数据可以由提供者以块的形式推送到流中,也可以由消费者以块的形式从流中消耗。双工流的一个例子是网络套接字,比如 TCP 套接字。

转换流是允许数据块在流中移动时进行变异的双工流。一个转换流的例子是 Node.js 的ZLib模块中的gzip方法,它使用gzip压缩方法压缩数据。

流以块的形式加载数据,而不是一次性加载,因此为了有效地使用流,我们需要一种方法来确定流是否已加载数据。在 Node.js 中,流是EventEmitter原型的实例。当关键事件发生时,流会发出事件,比如错误或数据可用性。事件监听器可以使用.on().once()方法附加到流上。可读流和可写流都有用于数据处理、错误处理和流管理的事件。

以下表格显示了可用的事件及其目的:

可写流事件:

图 6.4:可写流事件

图 6.4:可写流事件

可读流事件:

图 6.5:可读流事件

图 6.5:可读流事件

注意

这些事件监听器可以附加到流上,以处理数据流和管理流的状态。完整的文档可以在 Node.js 网站的流 API 下找到。

现在你了解了流的基础知识,我们必须实现它们。可读流遵循一个基本的工作流程。通常会调用一个返回可读流的方法。一个例子是文件系统 API 的createReadStream()函数,它创建一个从磁盘上流出文件的可读流。在返回可读流之后,我们可以通过附加data事件处理程序来开始从流中拉取数据。以下片段展示了一个例子:

const fs = require( 'fs' );
fs.createReadStream( './path/to/files.ext' ).on( 'data', data => { 
  console.log( data );  
} );
片段 6.10:使用可读流

在上面的例子中,我们导入了fs模块并调用了createReadStream函数。这个函数返回一个可读流。然后我们给data事件附加了一个事件监听器。这将把流放入流动模式,每当数据块准备就绪时,提供的回调函数将被调用。在这个例子中,我们的回调函数简单地记录了可读流放弃的数据。

就像可读流一样,可写流也遵循一个相当标准的工作流程。可写流的最基本工作流程是首先调用一个返回可写流的方法。一个例子是fs模块的createWriteStream函数。创建了可写流之后,我们可以使用stream.write()函数向其写入数据。这个函数将传入的数据写入流中。以下片段展示了一个例子:

const fs = require( 'fs' );
const writeable = fs.createWriteStream( './path/to/files.ext' );
writeable.write( 'some data' );
writeable.write( 'more data!' );
片段 6.11:使用可写流

在上面的片段中,我们加载了fs模块并调用了createWriteStream函数。这返回一个将数据写入文件系统的可写流。然后我们多次调用stream.write()函数。每次调用write函数时,我们传入的数据都被推送到可写流并写入磁盘。

Node.js 中最强大的功能之一是流的管道功能。管道流简单地将源流“管道”到目标流。您将一个流的数据输出管道到另一个流的输入。这非常强大,因为它允许我们简化连接流的过程。

考虑一个问题,我们必须从磁盘加载文件并将其作为 HTTP 响应发送给客户端。我们可以用两种方式来做这件事。我们可以构建的第一种实现是将整个文件加载到内存中,然后一次性将其推送给客户端。这对我们的服务器来说非常低效。第二种方法是利用流。我们从磁盘流式传输文件,并将数据块推送到请求流中。要做到这一点,我们必须在读取流上附加监听器,并捕获每个数据块,然后将数据块推送到 HTTP 响应。此伪代码如下所示:

const fileSystemStream = load( 'filePath' );
fileSystemStream.on( 'data', data => HTTP_Response.push( data ) );
fileSystemStream.on( 'end', HTTP_Response.end() );
片段 6.12:使用流将数据发送到 HTTP 响应

在前面片段的伪代码中,我们创建了一个从指定文件路径加载的流。然后为data事件和end事件添加了事件处理程序。每当数据事件有数据时,我们将该数据推送到HTTP_Response流。一旦没有更多数据并且触发了 end 事件,我们关闭HTTP_Response流,数据被发送到客户端。这需要几行代码,并要求开发人员管理数据和数据流。我们可以使用单行代码构建完全相同的功能,使用流管道。

使用Stream.pipe()函数进行流的管道传输。管道是在源流上调用的,并将目标流作为参数传递(例如,readableStream.pipe( writeableStream ))。管道返回目标流,允许它用于链接管道命令。使用与前面示例相同的场景,我们可以使用管道命令将伪代码简化为一行。如下所示:

load( 'filePath' ).pipe( HTTP_Response );
片段 6.13:管道数据伪代码

在前面的片段中,我们加载了文件数据并将其传输到HTTP_response。可读流加载的每个数据块都会自动传递给可写流HTTP_Response。当可读流完成加载数据时,它会自动关闭并告诉写流也关闭。

文件系统操作

Node 的文件系统模块,名为'fs',提供了一个 API,我们可以与文件系统交互。文件系统 API 是围绕 POSIX 标准建模的。POSIX(可移植操作系统接口)标准是由 IEEE 计算机学会指定的标准,旨在帮助不同操作系统文件系统之间保持一般兼容性。您不需要学习标准的细节,但要了解 fs 模块遵循它以保持跨平台兼容性。要导入文件系统模块,我们可以使用以下命令:const fs = require( 'fs' );

Node.js 中的大多数文件系统函数都要求您指定要使用的文件路径。在为 fs 模块指定文件路径时,路径可以以三种方式之一指定:作为字符串,作为缓冲区,或者使用file:协议的URL对象。当路径是字符串时,文件系统模块将尝试解析字符串以获得有效的文件路径。如果文件路径是缓冲区,文件系统模块将尝试解析缓冲区的内容以获得有效的文件路径。如果路径是 URL 对象,则文件系统将将对象转换为有效的 URL 字符串,然后尝试解析字符串以获得有效的文件路径。三种显示文件路径的示例如下所示:

fs.existsSync( '/some/path/to/file.txt' );
fs.existsSync( Buffer.from( '/some/path/to/file.txt' ) );
fs.existsSync( new URL( 'file://some/path/to/file.txt' ) );
片段 6.14:文件系统路径格式

正如您在前面的示例中看到的,我们使用了fs模块的existsSync函数。在第一行中,我们将文件路径作为字符串传递。在第二行中,我们从文件路径字符串创建了一个缓冲区,并将缓冲区传递给existsSync函数。在最后一个示例中,我们从文件路径的file:协议 URL 创建了一个 URL 对象,并将 URL 对象传递给existsSync函数。

文件路径可以解析为相对路径绝对路径。绝对路径是从操作系统的根文件夹解析的。相对路径是从当前工作目录解析的。当前工作目录可以通过process.cwd()函数获得。通过字符串或缓冲区指定的路径可以是相对的或绝对的。使用 URL 对象指定的路径必须是对象的绝对路径。

文件系统模块引入了许多函数,允许我们与硬盘交互。对于这些函数的大部分,都有同步和异步实现。同步的 fs 函数是阻塞的!当您编写使用 fs 模块的任何代码时,记住这一点非常重要。

注意

还记得第二章,异步 JavaScript中对阻塞操作的定义吗?阻塞操作将阻止事件循环处理任何事件。

如果您使用同步的fs函数加载大文件,它将阻塞事件循环。在同步的fs函数完成工作之前,不会处理任何事件。Node.js 线程不会执行任何其他操作,包括响应 HTTP 请求,处理事件或任何其他异步工作。您几乎总是应该使用fs函数的异步版本。唯一需要使用同步版本的情况是在必须在任何其他操作之前执行文件系统操作时。这可能是加载整个系统或服务器依赖的文件的一个例子。

Express 服务器

我们在本主题的早期部分讨论了基本的 Node.js HTTP 服务器。我们创建的服务器非常基本,缺乏我们从真正的 Web 服务器中期望的许多功能。在 Node.js 中,用于创建最小和灵活的 Web 服务器的最常见模块之一是Express。Express 将 Node.js 服务器对象包装在一个简化功能的 API 中。Express 可以通过 NPM(npm install express --save)安装。

注意

Express 的完整文档可以在expressjs.com找到。

基本的 Express 服务器非常容易创建。让我们回顾一下本章前面创建的基本 Node.js HTTP 服务器。在基本的 HTTP 服务器示例中,我们首先使用HTTP.createServer()函数创建了一个服务器,并传递了一个基本的请求处理程序。然后使用server.listen()函数启动了服务器。Express 服务器的创建方式类似。就像 HTTP 服务器一样,我们首先需要引入我们的模块。为Express模块添加一个require语句,并创建变量来保存我们的主机名和端口号。接下来,我们必须创建我们的 Express 服务器。这只需调用默认从require('express')语句导入的函数。调用导入的函数并将结果保存在一个变量中。如下面的片段所示:

注意

简单 HTTP 服务器的代码可以在练习 33 的代码下找到。

const express = require( 'express' );
const hostname = '127.0.0.1';
const port = 8000;
const app = express();
片段 6.15:设置 Express 服务器

在前面的片段中,我们导入了Express模块并将其保存到变量Express中。然后创建了两个常量变量——一个用于保存主机名,一个用于保存端口号。在代码的最后一行,我们调用了通过 require 语句导入的函数。这将创建一个带有所有默认参数的基本Express服务器。

我们必须做的下一步是复制我们的基本 HTTP 服务器,添加一个基本的 HTTP 请求处理程序。这可以通过app.get()函数完成。App.get为其提供的路径设置一个 HTTP GET 请求处理程序。它接受两个参数——路径和回调。路径指定处理程序将捕获请求的 URL 路径。callback是处理 HTTP 请求时调用的函数。我们应该为服务器的根路径('/')添加一个路由处理程序。如下片段所示:

app.get( '/', ( req, res ) => res.end( 'Working express server!' ) )
片段 6.16:设置路由处理程序

在前面的代码片段中,我们使用app.get()添加了一个路由处理程序。我们传入根路径('/'),这样当基本路径('localhost/')被 HTTP 请求命中时,指定的回调将被调用。在我们的回调中,我们传入一个具有两个参数的函数:reqres。就像简单的 HTTP 服务器一样,req代表传入的 HTTP 请求,res代表传出的 HTTP 响应。在函数的主体中,我们使用字符串Working express server!关闭 HTTP 响应。这告诉Express使用基本的 200 HTTP 响应代码,并将文本作为响应的主体发送。

最后一步,我们必须采取的步骤来使我们的基本Express服务器工作是让它监听 HTTP 请求。为此,我们可以使用app.listen()函数。此函数告诉服务器开始在指定端口监听 HTTP 请求。我们将三个参数传递给app.listen()。第一个参数是端口号。第二个参数是主机名。第三个参数是一个回调函数,一旦服务器开始监听,就会被调用。使用正确的端口、主机名和一个打印我们可以访问服务器的 URL 的回调来调用listen函数。以下是一个示例:

app.listen( port, hostname, () => console.log( 'Server running at http://${hostname}:${port}/' ) );
片段 6.17:使 Express 服务器监听传入请求

在前面的片段中,我们调用了listen函数。我们传入端口号,解析为8000;主机名,解析为127.0.0.1;和一个callback函数,记录服务器 URL。一旦服务器开始在本地网络上的端口8000监听 HTTP 请求,就会调用callback函数。转到控制台上记录的 URL,看看你的基本服务器是如何工作的!

练习 34:创建一个基本的 Express 服务器

要构建一个基本的 Express 服务器,请执行以下步骤:

  1. 导入express模块。

  2. 设置主机名和端口的变量,并分别给它们赋值127.0.0.18000

  3. 通过调用express()创建服务器应用程序,并将其保存到app变量中。

  4. 在基本路由/上添加一个 get 请求处理程序。

  5. 提供一个接受reqrescallback函数,并使用文本Working express server!关闭响应。

  6. 使服务器在指定的端口和主机上侦听app.listen()

  7. 提供一个回调函数给app.listen(),记录Server running at ${server uri}

  8. 启动服务器并在浏览器中加载指定的 URL。

代码

index.js
const express = require( 'express' );
const hostname = '127.0.0.1';
const port = 8000;
const app = express();
app.get( '/', ( req, res ) => res.end( 'Working express server!' ) );
app.listen( port, hostname, () => console.log( 'Server running at http://${hostname}:${port}/' ) );
片段 6.18:简单的 Express 服务器

https://bit.ly/2Qz4Z93

结果

图 6.6:返回新的购物车数组

图 6.6:返回新的购物车数组

图 6.7:返回新的购物车数组

图 6.7:返回新的购物车数组

您已成功构建了一个基本的 Express 服务器。

路由

Express 最强大的功能之一是其灵活的路由。路由指的是 Web 服务器的端点 URI 如何响应客户端请求。当客户端向 Web 服务器发出请求时,它请求指定的端点(URI 或路径)以及指定的 HTTP 方法(GETPOST等)。Web 服务器必须明确处理它将接受的路径和方法,以及说明如何处理请求的回调函数。在 Express 中,可以使用以下代码行来实现:app.METHOD( PATH, HANDLER );app变量是 Express 服务器的实例。Method 是要为其设置处理程序的 HTTP 方法。方法应为小写。路径是服务器上的 URI 路径,处理程序将对其进行响应。处理程序是如果路径和方法匹配请求将执行的回调函数。以下是此功能的示例:

app.get( '/', ( req, res ) => res.end('GET request at /') );
app.post( '/user', ( req, res ) => res.end( 'POST request at /user') );
app.delete( '/cart/item', ( req, res ) => res.end('DELETE request at /cart/item') );
片段 6.19:Express 路由示例

在上述片段中,我们为 Express 服务器设置了三个路由处理程序。第一个是使用.get()函数设置的。这意味着服务器将寻找对指定路由的GET请求。我们传入了服务器的基本路由(/)。当基本路由收到GET请求时,将调用提供的回调函数。在我们的回调函数中,我们用字符串GET request at /进行响应。在第二行代码中,我们设置服务器响应路径/userPOST请求。当POST请求到达 Express 服务器时,我们调用提供的回调函数,关闭响应并返回字符串POST request at /user.在最后一行代码中,我们为DELETE请求设置了处理程序。当DELETE请求进入 URI/cart/item时,我们用提供的回调进行响应。

Express 还支持特殊函数app.all()。如果您经常使用 HTTP 请求,您会意识到ALL不是有效的 HTTP 方法。app.all()是一个特殊的处理程序函数,告诉 Express 响应指定 URI 的所有有效 HTTP 请求方法,并使用指定的回调。它被添加到 Express 中,以帮助减少重复的代码,如果一个路由打算接受任何请求方法。

Express 支持为请求 URI 和 HTTP 方法设置多个回调函数。为了实现这一点,我们必须向回调函数添加第三个参数:nextnext是一个函数,当调用next时,Express 将移动到匹配方法和 URI 的下一个回调处理程序。以下是一个示例:

app.get( '/', ( req, res, next ) => next() );
app.get( '/', ( req, res ) => res.end( 'Second handler!' ) );
片段 6.20:相同路由的多个请求处理程序

在上述片段中,我们为基本 URI 设置了两个不同的路由处理程序和GET请求。当捕获到对基本路由的GET请求时,将调用第一个处理程序。此处理程序仅调用next()函数,告诉 Express 寻找下一个匹配的处理程序。Express 看到有第二个匹配的处理程序,并调用第二个处理程序函数,关闭 HTTP 响应。重要的是要注意,HTTP 响应只能关闭并一次返回给客户端。如果为 URI 服务器和 HTTP 方法设置了多个处理程序,必须确保只有一个处理程序关闭 HTTP 请求,否则将会出现错误。多个处理程序提供的功能对于 Express 中的中间件和错误处理非常重要。这些应用程序将在本节后面更详细地讨论。

高级路由

如前所述,在 Express 中,路由路径是它匹配的路径 URI,以及 HTTP 方法,在检查要调用哪个处理程序回调时。路由路径作为第一个参数传递给函数,例如app.get()。Express 的强大之处在于能够创建极其动态的路由路径,以匹配多个 URI。在 Express 中,路由路径可以是字符串、字符串模式或正则表达式。Express 将解析基于字符串的路由,以查找特殊字符?+*()$[]。在字符串路径中使用时,特殊字符?+*()是正则表达式对应字符的子集。[]字符用于转义 URL 的部分,在字符串中不会被字面解释。$字符是 Express 路径解析模块中的保留字符。如果必须在路径字符串中使用$字符,则必须使用[]进行转义。例如,/user/$22515应该在 Express 路由处理程序中写成/data/[\$]22515

*字符的功能类似于+字符,但匹配零个或多个字符的重复。Express 将匹配与字符串完全匹配但不包含额外字符的路由。一个或多个连续字符可以用来代替星号。示例如下:

app.get( '/abc?de', ( req, res ) => {
  console.log( 'Matched /abde or /abcde' );
} );
片段 6.23:使用零个或多个字符进行路由

在前面的片段中,我们为 URL 路径/abc?de设置了一个GET处理程序。当命中此 URL 时,将调用回调函数,该函数记录两个可能的 URI 匹配选项。由于?字符跟在c字符后面,因此c字符被视为可选的。Express 将匹配包含或不包含可选字符的 URI 的GET请求。对/abde/abcde的请求都将匹配。

+符号用于指示字符或字符组的零个或多个重复。Express 将匹配与重复字符的字符串完全匹配的路由,以及包含一个或多个标记字符的连续重复的任何字符串。示例如下:

app.get( '/fo+d', ( req, res ) => {
  console.log( 'Matched /fd, /fod, /food, /fooooooooood' );
} );
片段 6.22:路由路径中零个或多个重复字符

在前面的片段中,我们为 URL 路径fo+d设置了一个GET处理程序。当命中此 URI 时,将调用回调函数,该函数记录一些匹配选项。由于 o 字符后面跟着+字符,Express 将解析任何包含零个或多个o的路由。Express 将匹配fdfodfoodfoooooooooooood和任何其他具有连续o的字符串 URI。

片段 6.21:路由路径中的可选字符

app.get( '/fo*d', ( req, res ) => {
  console.log( 'Matched /fd, /fod, /fad, /faeioud' );
} );
如果我们希望在路由中使用特殊字符以增加灵活性,我们可以使用字符?+*()。这些字符的操作方式与它们的正则表达式对应字符相同。这意味着?字符用于表示可选字符。跟在?符号后面的任何字符或字符组都将被视为可选的,Express 将匹配要么与可选字符的完整字符串匹配,要么与不包含可选字符的完整字符串匹配。示例如下:

在前面的片段中,我们为 URL 路径fo*d设置了一个GET处理程序。当命中此 URI 时,将调用回调函数,该函数记录一些匹配选项。由于o字符后面跟着*字符,Express 将解析任何包含零个或多个额外字符的路由。Express 将匹配fdfodfadfooodfaeioud和任何其他具有连续字符的字符串 URI。在比较+*字符的匹配字符串时,请注意匹配字符串之间的差异。*字符将匹配+字符匹配的所有字符串,还会匹配任何有效字符代替星号的字符串。

最后一组字符是()。括号将一组字符分组在一起。当与其他特殊字符(?+*)一起使用时,分组字符将被视为单个单位。例如,URI/ab(cd)?ef将匹配 URI/abef/abcdef。字符cd被分组在一起,并且整个组受到?字符的影响。示例显示了这种与每个特殊字符的交互在以下片段中:

app.get( '/b(es)?t', ( req, res ) => {
  console.log( 'Matched /bt and /best' );
} );
app.get( '/b(es)+t', ( req, res ) => {
  console.log( 'Matched /bt, /best, /besest, /besesest' );
} );
app.get( '/b(es)*t', ( req, res ) => {
  console.log( 'Matched /bt, /best, /besest, /besesest' );
} );
片段 6.24:使用字符组进行路由

在前面的片段中,我们为路径b(es)?tb(es)+tb(es)*t设置了GET请求处理程序。每个处理程序调用一个回调函数,记录一些匹配选项。在所有处理程序中,字符es被分组在一起。在第一个处理程序中,分组字符受到?字符的影响,并被视为可选的。处理程序将匹配包含完整字符串并且只包含非可选字符的 URI。两个选项是btbest。在第二个处理程序中,字符组受到+字符的影响。具有零个或多个连续重复字符组的 URI 将匹配。匹配选项是btbestbesestbesesest,以及任何其他具有更多连续重复的字符串。

Express 还允许我们在路由字符串中设置路由参数。路由参数是命名路由部分,允许我们指定要捕获并保存到变量中的路由 URL 的部分。URL 的捕获部分保存在req.params对象中,对象的键名与捕获的名称匹配。URL 参数使用:字符指定,后跟捕获名称。任何落在路由的那部分字符串都将被捕获并保存。示例显示在以下片段中:

app.get( '/amazon/audible/:userId/books/:bookId', ( req, res ) => {
  console.log( req.params );
} );
片段 6.25:使用 URL 参数进行路由

在前面的片段中,我们为路由/amazon/audible/:userId/books/:bookId设置了一个 get 参数。这个路由有两个命名参数捕获:一个是userId,另一个是bookId。这两个命名捕获可以包含任何一组有效的 URL 字符。在audible//books之间包含的任何字符都将保存在req.params.userId变量中,而在books/之后的任何字符都将保存在req.params.bookId中。重要的是要注意,/字符是用来分割 URL 的。保存的捕获组将不包含/字符,因为 Express 将其解析为 URL 分隔符。

Express 路由还可以在路径字符串的位置使用正则表达式。如果将正则表达式传递给请求处理程序的第一个参数而不是字符串,Express 将解析正则表达式,并且与正则表达式匹配的任何字符串都将触发提供的回调处理程序。如果您对正则表达式不熟悉,可以在网上找到许多教授基础知识的教程。正则表达式路径的示例显示在以下片段中:

app.get( /^web.*/, ( req, res ) => {
  console.log( 'Matched strings like web, website, and webmail' );
} );
片段 6.26:使用正则表达式进行路由

在前面的片段中,我们为正则表达式路由/^web.*/设置了一个GET处理程序。如果匹配此处理程序,服务器将记录两个匹配的字符串示例。我们提供给GET处理程序的正则表达式指定了 URI 必须以字符串web开头,可以跟随任意数量的字符。这将匹配诸如/web/website/webmail等 URI。

中间件

Express 还通过一个名为中间件的功能扩展了服务器的灵活性。Express 是一个路由和中间件框架,本身功能有限。中间件是具有对 HTTP 请求请求和响应对象的访问权限并在处理序列的中间某处运行的函数。中间件可以执行四项任务中的一项:执行代码,对请求和响应对象进行更改,结束 HTTP 请求-响应序列,并调用适用于请求的下一个中间件。

注意

中间件函数可以手动编写,也可以通过第三方 NPM 模块下载。在编写中间件之前,请检查中间件是否已经存在。官方中间件模块和一些热门模块可以在expressjs.com/en/resources/middleware.html找到。

中间件函数有三个输入变量:reqresnextReq表示请求对象,res表示响应对象,next是一个告诉 Express 继续到下一个中间件处理程序的函数。我们在本节的前面看到了next函数,当将多个路由处理程序注册到一个 URI 时。next函数告诉中间件处理程序将控制权传递给处理程序堆栈中的下一个中间件。简单来说,它告诉next中间件运行。如果中间件没有结束请求-响应序列,它必须调用next函数。如果不这样做,请求将挂起并最终超时。中间件可以使用app.use()app.METHOD()函数附加。使用app.use()设置的中间件将对匹配指定可选路径的所有 HTTP 方法触发。使用 HTTP 方法函数附加的中间件将对匹配方法和指定路径的所有请求触发。下面的片段显示了中间件的示例:

app.use( ( req, res, next ) => {
  req.currentTime = new Date();
  next();
} );
app.get( '/', ( req, res ) => {
  console.log( req.currentTime );
} );
片段 6.27:设置中间件

在前面的片段中,我们使用app.use()设置了一个中间件函数。我们没有为app.use()提供路径,因此所有请求都将触发中间件。中间件通过在请求中设置currentTime字段为一个新的日期对象来更新请求对象。然后中间件调用下一个函数,该函数将控制权传递给下一个中间件或路由处理程序。假设请求到基本 URI,下一个被触发的处理程序是注册的处理程序,它打印req.currentTime中保存的值。

错误处理

Express 的最后一个重要方面是错误处理。错误处理是 Express 处理和管理错误的过程。Express 可以处理同步错误和异步错误。Express 具有内置的错误处理,因此您不需要编写自己的错误处理程序。Express 的内置错误处理程序将在响应正文中将错误返回给客户端。这可能包括错误详细信息,如堆栈跟踪。如果您希望用户看到自定义错误消息或页面,则必须附加自己的错误处理程序。

内置的 Express 错误处理程序将捕获路由处理程序或中间件函数中同步代码中抛出的任何错误。这包括运行时错误和使用 throw 关键字抛出的错误。但是,Express 不会捕获在异步函数中抛出的错误。要在异步函数中调用错误,必须将next函数添加到回调处理程序中。如果发生错误,必须使用要处理的错误调用 next。下面的片段显示了使用默认错误处理程序进行同步和异步错误处理的示例:

app.get( '/synchronousError', ( req, res ) => {
  throw new Error( 'Synchronous error' );
} );
app.get( '/asynchronousError', ( req, res, next ) => {
  setTimeout( () => next( new Error( 'Asynchronous error' ) ), 0 );
} );
片段 6.28:同步和异步错误处理

在前面的片段中,我们首先创建了一个GET请求处理程序,路径为/synchronousError。如果触发了这个处理程序,我们调用回调函数,在同步代码块中抛出一个错误。由于错误是在同步代码块中抛出的,Express 会自动捕获错误并将其传递给客户端。在第二个示例中,我们为路径/asynchronousError创建了一个GET请求处理程序。当触发了这个处理程序时,我们调用一个回调函数,开始一个超时,并使用错误调用next函数。错误发生在一个异步代码块中,因此必须通过 next 函数传递给 Express。当 Express 捕获到错误时,无论是通过抛出事件同步地还是通过 next 函数异步地,它会立即跳过所有适用的中间件和路由处理程序,并跳转到第一个适用的错误处理程序。

定义我们自己的错误处理中间件函数,我们以与其他中间件函数相同的方式添加它,只是有一个关键的区别。错误处理中间件回调函数在回调中有四个参数,而不是三个。参数依次是errreqresnext。它们的解释如下:

  • err代表正在处理的错误。

  • req代表请求对象。

  • res代表响应对象。

  • next是一个告诉 Express 继续下一个错误处理程序的函数。

自定义错误处理程序应该是最后定义的中间件。下面是自定义错误处理的示例:

app.get( '/', ( req, res ) => {
  throw new Error( 'OH NO AN ERROR!' );
} );
app.use( ( err, req, res, next ) => {
  req.json( 'Got an error!' + err.message );
} );
片段 6.29:添加自定义错误处理程序

在前面的片段中,我们为基本路由添加了一个GET请求处理程序。当处理程序被触发时,它调用一个回调函数,该函数抛出一个错误。这个错误会被 Express 自动捕获并传递给下一个错误处理程序。下一个错误处理程序是我们用app.use()函数定义的。这个错误处理程序捕获错误,然后用错误消息响应客户端。

练习 35:使用 Node.js 构建后端

你被要求为一个笔记应用构建一个 Node.js Express 服务器。服务器应该为基本路由(/)提供一个基本的 HTML 页面(在活动文件夹下的index.html中提供)。服务器将需要一个 API 路由,从服务器的本地文件系统中的文本文件加载保存的笔记,并且一个 API 路由,将对笔记的更改保存到服务器的本地文件系统中的文本文件。服务器应该在加载笔记时接受一个GET请求到 URI/load,在保存笔记时接受一个POST请求到 URI/save。提供的 HTML 文件将假定这些是服务器上使用的 API 路径。在构建服务器时,您可能希望使用body-parser中间件,并将 strict 选项设置为 false,以简化请求的处理。

要构建一个工作的 Node.js 服务器,提供一个 HTML 文件并接受 API 调用,执行以下步骤:

  1. 使用npm init设置项目。

  2. 使用 npm 安装expressbody-parser

  3. 导入模块expresshttpbody-parser保存为bodyParser,以及fs,并将它们保存在变量中。

  4. 创建一个名为notePath的变量,其中包含文本文件的路径(./note.txt)。

  5. 记录我们正在创建一个服务器。

  6. 使用express()创建服务器应用,并将其保存在app变量中。

  7. 使用http.createServer(app)从 Express 应用创建一个 HTTP 服务器,并将其保存在 server 变量中。

  8. 记录我们正在配置服务器。

  9. 使用body-parser中间件来解析 JSON 请求体。

告诉 Express 应用使用中间件app.use()

bodyParser.json()传递给 use 函数。

将一个选项对象传递给bodyParser.json(),使用key/value对。strict:false。

  1. 使用express.Router()创建一个路由来处理路由,并将其保存在变量 router 中。

  2. 为基本路由添加一个GET路由处理程序,使用router.route('/').get

添加一个接受reqres的回调函数。

在回调中,使用res.sendFile()发送index.html文件。

index.html作为第一个参数传递,并将选项对象{root: __dirname}作为第二个参数传递。

  1. /save路由添加一个路由,接受POST请求router.route( '/save' ).post

路由处理程序回调应该接受参数reqres

在回调中,使用fs函数writeFile()notePath以及req.body参数和回调函数。

在回调函数中,接受参数errdata

如果提供了err,则使用状态码500和 JSON 形式的错误关闭响应。

如果没有提供错误,则使用数据对象的 JSON 关闭响应,并使用200状态码。

  1. /load路由添加一个路由,接受GET请求router.route( '/load ).get

路由处理程序回调应该接受参数reqres

在回调中,使用fs函数readFilenotePath以及utf8参数和回调函数。

在回调函数中,接受参数errdata

如果提供了err,则使用状态码500和 JSON 形式的错误关闭响应。

如果没有提供错误,则使用数据对象的 JSON 关闭响应,并使用200状态码。

  1. 使express应用程序使用路由器处理基本路由的请求app.use('/', router)

  2. 设置服务器以侦听正确的端口和主机名,并使用server.listen( port, hostname, callback )传入回调。

回调函数应该接受一个错误参数。如果找到错误,抛出该错误。

否则,记录服务器正在侦听的端口。

  1. 启动服务器并加载运行在(localhost:PORT)的 URL。

  2. 通过保存一个笔记,刷新网页,加载保存的笔记(应该与之前保存的内容匹配),然后更新笔记来测试服务器的路由和功能。

代码

index.js
router.route( '/' ).get( ( req, res ) => res.sendFile( 'index.html', { root: __dirname } ) );
router.route( '/save' ).post( ( req, res ) => {
 fs.writeFile( notePath, req.body, 'utf8', err => {
   if ( err ) {
     res.status( 500 );
   }
   res.end();
 } );
} );
router.route( '/load' ).get( ( req, res ) => {
 fs.readFile( notePath, 'utf8', ( err, data ) => {
   if ( err ) {
     res.status( 500 ).end();
   }
   res.json( data );
 } );
} );
片段 6.30:复杂应用的 Express 服务器路由

https://bit.ly/2C4FR64

结果

图 6.8:监听端口 8000

图 6.8:监听端口 8000

图 6.9:加载测试笔记

图 6.9:加载测试笔记

您已成功构建了一个工作的 Node.js 服务器,可以提供 HTML 文件并接受 API 调用。

React

React是一个用于构建用户界面的 JavaScript 库。React 主要由 FaceBook 维护。React 最初是由 Facebook 软件工程师 Jordal Walke 创建的,并于 2013 年开源。React 旨在简化 Web 开发,使开发人员能够轻松构建单页网站和移动应用程序。

注意

React 的完整文档以及扩展教程可以在它们的主页找到:reactjs.org/

React 使用声明性方法来设计视图,以提高页面的可预测性和调试性。开发人员可以为应用程序中的每个状态声明和设计简单的视图。React 将处理视图的更新和渲染,随着状态的改变。React 依赖于基于组件的模型。开发人员构建封装的组件,跟踪和处理它们自己的内部状态。我们可以组合我们的组件以创建复杂的用户界面,类似于我们如何使用函数组合从简单函数构建复杂函数。通过组件,我们可以通过应用程序在组件之间传递丰富的数据类型。这是允许的,因为组件逻辑纯粹是用 JavaScript 编写的。最后,React 允许我们在框架中非常灵活。不会对应用程序背后的技术栈做出任何假设。React 可以在浏览器中加载时编译,在 Node.js 服务器上编译,或者使用 React Native 编译成移动应用程序。这使得可以在不需要重构现有代码的情况下逐步将 React 纳入新功能。您可以在技术栈的任何点开始纳入 React。

安装 React

React 可以通过 NPM 安装,并在服务器上编译,或通过脚本标签集成到应用程序中。有几种安装 React 并将其添加到项目中的方法。

将 React 添加到应用程序的最快方法是通过<script>标签包含内置库。如果您有现有项目并希望逐步开始将 React 纳入其中,这种方法通常是最简单的。以这种方式添加 React 不到一分钟,可以让您立即开始添加组件。首先,我们需要在 HTML 页面中添加一个 DOM 容器,我们希望我们的 React 组件附加到其中。通常这是一个带有唯一 ID 的 div。然后使用脚本标签添加ReactReactDOM模块。添加了脚本标签后,可以使用脚本标签加载 React 组件。以下是一个示例。

<div id="react-attach-point"></div>
<script src="https://unpkg.com/react@16/umd/react.development.js" crossorigin></script><script src="https://unpkg.com/react-dom@16/umd/react-dom.development.js" crossorigin></script>
<script src="react_components.js"></script>
代码段 6.31:将 React 添加到网页

设置 React 应用程序并将 React 安装到新项目中的下一个最简单的方法是使用 React 应用程序创建者。这个模块是一个 Node.js 命令行界面,可以自动设置一个具有简单预定义文件夹结构和基本依赖项安装的 React 项目。可以使用命令行命令npm install create-react-app -g安装 CLI 工具。这个命令告诉 NPM 在全局范围内安装 CLI 模块,以便可以从命令行运行它。安装了 CLI 后,可以通过运行create-react-app my-app-name命令创建一个新的 React 项目。CLI 工具将在工作目录中创建一个文件夹,名称为提供的名称(例如命令中的my-app-name),安装 React 依赖项,并为应用程序资源创建两个文件夹。CLI 工具将使用示例应用程序填充源代码文件夹,命名为src。可以使用npm start命令启动应用程序。从这一点开始,可以开始修改文件,看看 React 是如何工作的,或者可以删除src中的所有文件,开始编写自己的应用程序。

安装 React 最困难的方法是逐个安装各个依赖项。这种方法提供了最大的灵活性,允许您将 React 集成到现有的工具链中。要安装 React,必须使用 NPM 安装reactreact-dom模块。这两个模块可以在本地项目范围内安装,并应该使用--save-s标志保存到package.json依赖项列表中。安装了模块后,可以使用现有的工具链创建和构建 React 组件。

在本主题中,我们将使用带有 JSX 的 React。JSX是 JavaScript 的一种语法糖,默认情况下不受浏览器支持。JSX 必须通过 Babel 转译为有效的 JavaScript 代码。要完成 React 的设置,您需要设置 Babel 来将您的 React 和 JSX 代码转译为 JavaScript。如果您的项目尚未安装 Babel,可以使用npm install babel -s命令进行安装。

这将保存 Babel 作为项目的依赖项。要将 React JSX 插件添加到 Babel 中,请运行npm install babel-preset-react-app -s命令。此命令添加了 Babel 的 JSX 转换库。设置好 Babel 后,我们必须创建一个构建脚本,可以运行以转换我们所有的代码。在 package.json 中,添加以下行:build": "npx babel src -d lib --presets react-app/prod。请注意,npx不是拼写错误。它是一个随 NPM 一起提供的包运行工具。这行告诉 Babel 将代码从src目录编译到lib目录,并使用react-app/prod预设。每次我们对 React 代码进行更改并希望在前端反映这些更改时,都应该运行此命令。现在,您已经准备好开始构建 React 应用程序了。

注意

您可以提供上一段中所述的 Babel 设置命令,以演示如何为转译设置项目。

React 基础

React 是围绕称为组件的小型封装代码构建的。在 React 中,组件是通过对React.ComponentReact.PureComponent进行子类化来定义的。最常见的方法是使用React.Component。在最简单的形式中,React 组件接受属性(通常称为props)并通过调用render()返回要显示的视图。在初始化组件时定义属性。每个创建的组件必须在子类中定义一个名为render()的方法。render 函数以 JSX 形式返回屏幕上将呈现的内容的描述。以下是一个示例组件声明:

class HelloWorld extends React.Component {
  render() {
    return (
      <div>
        Hello World!!! Made by {this.props.by}!!!
      </div>
    );
  }
}
ReactDOM.render(
  <HelloWorld by="Zach"/>,
  document.getElementById('root')
);
片段 6.32:基本的 React 元素

在前面的片段中,我们定义了一个名为HelloWorld的新的 React 组件class。这个新类扩展了基本的React.Component。在声明内部,我们定义了render()函数。render()函数返回一个 JSX 块,定义了将在屏幕上呈现的内容。在这个 JSX 块中,我们创建了一个带有文本Hello World!!! Made by *!!!div,其中*字符被通过属性传递的值替换。在最后几行中,我们调用了ReactDom.render()函数。这告诉ReactDom模块呈现我们传递给render()函数的所有组件和视图。在前面的片段中,我们将我们的HelloWorld组件与属性by设置为Zach,并告诉渲染函数将呈现的 DOM 附加到根元素上。传递到属性中的数据被传递到我们的组件内部的this.props中,并填充到Hello World!!! div中。

注意

如果您的代码库不使用 ES6 或 ES6 类,您可以使用 create-react-class 模块,但是,此模块的具体细节超出了本书的范围。

恭喜!您已经了解了 React 的最基本形式。通过扩展这个例子,您现在可以构建基本的静态网页。这可能看起来并不是很有用,但它是所有网页开发的最基本构建块。

React 特定

在我们之前的片段中的基本示例中,我们可以看到 React 使用了一种奇怪的语法糖叫做 JSX。JSX 既不是 HTML 也不是 JavaScript。它是 JavaScript 的语法扩展,结合了 HTML 和 XML 的一些概念,帮助描述用户界面应该是什么样子。JSX 对于 React 应用并非必需,但建议在构建 React UI 时使用它。它看起来像一个模板语言,但具有 JavaScript 的全部功能。它可以通过 Babel React 插件编译成标准的 JavaScript。以下片段显示了 JSX 和等效的 JavaScript 示例:

const elementJSX = <div>Hello, world!</div>;
const elementJS = React.createElement( "div", null, "Hello, world!" );
片段 6.33:JSX vs JS

在前面的片段中,我们定义了一个名为elementJSX的变量,并将一个 JSX 元素保存到其中。在第二行,我们创建了一个名为elementJS的变量,并用纯 JavaScript 保存了等效的元素。在这个示例中,你可以清楚地看到 JSX 的标记风格如何简化了在 JavaScript 中定义元素的方法。

JSX

JSX可以像标准 JavaScript 中的模板文字一样嵌入表达式。然而,主要区别在于 JSX 只使用花括号({})来定义表达式。与模板文字一样,在 JSX 中使用的表达式可以是变量、对象引用或函数调用。这使我们能够在 React 中使用 JSX 创建动态元素。以下片段显示了 JSX 表达式的示例:

const name = "David";
function multiplyBy2( num ) { return num * 2; }
const element1 = <div>Hello {name}!</div>;
const element2 = <div>6 * 2 = {multiplyBy2(6)}</div>;
片段 6.34:JSX 表达式

在前面的片段中,我们首先创建了一个名为 name 的变量,其中包含字符串David,以及一个名为multiplyBy2的函数,该函数接受一个数字并返回乘以2的数字。然后我们创建了一个名为element1的变量,并将一个 JSX 元素保存到其中。这个 JSX 元素包含一个包含对name变量的引用的表达式的div。当构建这个 JSX 元素时,表达式将name变量评估为字符串David并将其插入到最终的标记中。在代码的最后一行,我们创建了一个名为element2的变量,并将另一个 JSX 元素保存到其中。这个 JSX 元素包含一个包含对multiplyBy2函数的表达式的 div。当创建 JSX 元素时,表达式将评估其中的代码并调用函数。函数的返回值被放入最终的标记中。正如你所看到的,JSX 中的表达式与模板文字中的表达式非常相似。

ReactDOM

当我们创建 React 元素时,我们必须有一种方法将它们渲染到 DOM 中。这在 React 介绍示例中被非常简要地提及了。在那个示例中,我们使用了ReactDOM库来渲染我们创建的组件。从react-dom模块导入的ReactDOM对象提供了可以在整个应用程序中使用的特定于 DOM 的方法;然而,大多数组件并不需要这些方法。你将最常使用的函数是render()函数。这个函数接受三个参数。

第一个参数是我们将要渲染或附加到 DOM 的 React 元素。第二个参数是 React 组件将被渲染到的容器或 DOM 节点。最后一个参数是一个可选的回调方法。回调函数将在组件渲染后执行。对于完整的 React 应用程序,ReactDOM.render()通常只需要在应用程序的顶层使用,并用于在视图中渲染整个应用程序。在将 React 逐渐纳入现有代码库的应用程序中,ReactDOM.render()可能在每个新的 React 组件被纳入非 React 代码的地方使用。以下片段显示了ReactDOM.render()的示例:

import ReactDOM from 'react-dom';
const element = <div>HELLO WORLD!!!</div>;
ReactDOM.render( element, document.getElementById('root'), () => {
  console.log( 'Done rendering' );
});
片段 6.35:将元素渲染到 DOM 中

在上面的示例中,我们首先导入了ReactDOM模块。然后我们使用 JSX 创建了一个新的 React 元素。这个简单的元素只包含一个带有文本HELLO WORLD!!!div。然后我们调用了ReactDOM.render()函数并传入了所有三个参数。这个函数调用告诉浏览器选择根 DOM 节点并附加我们的 React 元素渲染的标记。当渲染完成时,将调用提供的回调,并将Done rendering字符串记录到控制台中。

React.Component

React 围绕组件展开。正如我们之前学到的,创建新组件的最简单方法是创建一个扩展React.Component类的新子类。React.Component类可以通过从 React NPM 模块导入的 React 对象访问。当我们定义一个 React 组件时,我们必须至少定义一个render()函数。render函数返回组件将包含的 JSX 描述。如果我们希望创建更复杂的组件,例如具有状态的组件,我们可以向组件添加构造函数。构造函数必须接受props变量,并且必须使用props变量调用super()函数。props变量将包含在创建 React 组件时分配的属性的对象。以下是一个具有构造函数的 React 组件的示例:

class ConstructorExample extends React.Component{
  constructor( props ){
    super( props );
    this.variable = 'test';
  }
  render() { return <div>Constructor Example</div>; }
}
片段 6.36:React 类构造函数

在上面的片段中,我们创建了一个名为ConstructorExample的新组件。在同一个片段中,我们调用了constructor函数。constructor函数接受一个参数,即包含属性的对象。在构造函数中,我们调用了super()函数并传入props变量。然后我们创建了一个名为variableclass变量,并赋予了值test。在类的末尾,作为所有 React 组件所需的,我们添加了一个返回组件的 JSX 标记的render()函数。

状态

要向 React 组件添加本地状态,我们只需在构造函数内部(this.state = {})初始化状态变量。状态变量是 React 中的一个特殊变量关键字名称。对this.state的任何更改都将导致调用render()函数。这使我们能够根据组件的当前状态动态更改视图。

关于状态变量,有三个关键要知道的事情。首先,您不应该直接修改状态,比如this.state.value = 'value'。以这种方式修改状态不会导致调用render()和视图更新。相反,您必须使用setState()函数。这将使用传递给函数的数据更新状态。例如,我们必须这样设置状态:this.setState( { name: 'Zach' } )。第二个关键细节是状态更新可能是异步的。React 可能会将多个setState调用合并为单个更新以提高性能。因此,我们不能依赖它们的值来计算状态。如果我们必须使用当前状态或属性的当前值来计算下一个状态,我们可以使用setState的第二种形式,它接受一个函数而不是一个对象。该函数将接收前一个状态作为第一个参数,并在应用状态更新时接收属性对象。这可靠地允许我们使用先前的状态和属性信息来计算下一个状态。最后,状态更新是合并而不是覆盖。与Object.assign函数类似,setState对状态对象和新状态进行浅合并。在设置状态时,新对象将与旧状态对象合并。只有新状态对象中指定的属性将更改。旧状态对象中的所有属性,如果不在新状态对象中,将保持不变。

在 React 组件中,属性对象从组件内部是只读的。这意味着从组件内部对属性对象的更改不会反映到父组件或 DOM 结构中的任何变量。数据只能向下流动。因此,对子组件属性的父组件 JSX 标记的任何更改都将导致子组件使用新的属性值重新渲染。要使数据向上流动,我们必须以属性的形式将函数从父组件传递到子组件。以下片段显示了一个示例。

class ChildElement extends React.Component {
 render() {
   return (
     <button onClick={this.props.onClick}>
       Click me!
     </button>
   );
 }
}
class ParentElement extends React.Component {
 clicked() { console.log( 'clicked' ); }
 render() {
   return <ChildElement onClick={this.clicked.bind(this)}/>;
 }
}
片段 6.37:渲染子组件

在这个片段中,我们创建了两个组件。第一个称为ChildElement,第二个称为ParentElementChildElement简单地包含了一个按钮的 JSX,当点击时,通过onClick属性传递的函数被调用。ParentElement包含一个名为clicked的函数,用于记录到控制台,并在渲染时返回带有ChildElement实例的 JSX。在ParentElement的 JSX 中创建的ChildElementonClick属性设置为ParentElementclicked()函数。当点击ChildElement中的按钮时,将调用clicked()函数。在这个例子中,当我们将它传递给子元素时,将父级范围绑定到this.clickedthis.clicked.bind(this))。如果this.clicked需要访问父组件中的任何内容,我们必须将其范围绑定到父组件的范围。在您的 React 应用程序中,您可以使用此功能创建向上的数据流。

在 React 中处理 DOM 事件与 HTML DOM 元素事件处理非常相似,但有一些主要区别。首先,在 React 中,事件名称使用驼峰命名法而不是小写。这意味着在名称的每个“新单词”中,该单词的第一个字母是大写的。例如,在 React 中,DOM 事件onclick变成了onClick。其次,在 JSX 中,函数事件处理程序直接作为函数传递到处理程序定义中,而不是作为包含处理程序函数名称的字符串。以下代码显示了标准 HTML 和 React 之间的差异:

<button onclick="doSomething()">HTML</button>
<button onClick={doSomething}>JSX and React</button>
片段 6.38:JSX 与 HTML 事件

在前面的片段中,我们创建了两个按钮。第一个是 HTML 格式的,它附加了一个onclick侦听器,调用doSomething函数。第二个按钮是 JSX 格式的,也有一个onclick侦听器,调用doSomething函数。请注意侦听器定义的不同之处。JSX 事件名称是camelcase,HTML 事件名称是小写。在 JSX 中,我们通过表达式设置处理程序函数,该表达式求值为函数。在 HTML 中,我们将事件处理程序设置为调用函数的字符串。

注意

我们在第三章,DOM 操作和事件处理中学到,直接在 DOM 中附加事件是一种不好的做法。JSX 不是 HTML,这种做法是可以接受的,因为 JSX 通过转义 JSX 中嵌入的任何值来防止注入攻击。

React 事件处理和标准 DOM 事件处理之间的另一个重要区别是,在 React 中,事件处理程序函数不能返回 false 以阻止默认行为。您必须在事件对象上明确调用preventDefault()函数。

在 React 中附加事件侦听器时,我们必须小心处理this作用域。在 JavaScript 中,类方法默认情况下不绑定到this作用域。如果函数被传递到其他地方并从其他地方调用,则this作用域可能无法正确设置。在将它们附加到侦听器或作为属性传递方法时,应确保正确绑定this作用域。

条件渲染

在 React 中,我们创建不同的组件来封装我们需要的视图或行为。我们需要一种方式,根据应用程序的状态,只渲染我们创建的一些组件。在 React 中,这称为条件渲染。在 React 中,条件渲染的工作方式与 JavaScript 条件语句相同。我们可以使用 JavaScript 的 if 或条件运算符来决定渲染哪些元素。这可以通过几种方式来实现。

在两种简单的方式中,一种是根据当前状态返回一个 React 元素(JSX)的函数,而另一种是在 JSX 中有一个条件语句,根据当前状态返回一个 React 元素。这些条件渲染形式的示例显示在以下片段中:

class AccountControl extends React.Component {
  constructor( props ) {
    super( props );
    this.state = { account: this.props.account };
  }
  isLoggedIn() {
    if ( this.state.account ) { return <LogoutButton/>; }
    else { return <LoginButton/>; }
  }
  render() {
    return (
      <div>
        {this.isLoggedIn()}
        {this.state.account ? <LogoutButton/> : <LoginButton/>}
      </div>
    );
  }
}
片段 6.39:条件渲染

在前面的片段中,我们创建了一个名为AccountControl的元素。在构造函数中,我们将本地状态设置为包含从属性变量传入的帐户信息的对象。渲染函数简单地返回一个带有两个表达式的div。这两个表达式都利用条件渲染来根据当前状态显示信息。第一个表达式调用isLoggedIn函数,该函数检查this.state.account并根据当前状态返回LogoutButtonLoginButton。第二个表达式使用条件运算符来内联检查this.state.account,并根据本地状态返回LogoutButtonLoginButton

项目列表

在 React 中渲染项目列表非常简单。它基于 JSX 和表达式的概念。正如我们之前学到的,JSX 使用表达式来创建动态代码。如果表达式求值为组件数组,则所有组件将被呈现为如果它们被内联添加到 JSX 中。我们可以构建一个组件的集合或数组,将集合保存在一个变量中,并将变量包含在 JSX 表达式中。这种示例显示在以下片段中:

class ListElement extends React.Component {
  render() {
    return (
      <ul> {this.props.items.map( i => <li>{i}</li> )} </ul>
    );
  }
}
ReactDOM.render(
  <ListElement items={[ 1, 4, 5, 5, 7, 9 ]}/>,
  document.getElementById( 'root' )
);
片段 6.40:渲染列表

在前面的片段中,我们创建了一个名为ListElement的元素。这个元素简单地接受一个项目数组,并将数组映射到包含<li>标签中的数组项值的 JSX 元素数组中。然后将结果列表项数组返回到<ul>标签中。当 JSX 将其编译成 HTML 时,数组中的每个项目按顺序插入到<ul>元素中。

HTML 表单

React 的最后一个关键概念是 HTML 表单。HTML 表单在 React 中的工作方式与其他 DOM 元素不同,因为 HTML 表单跟踪其自己的内部状态。如果我们只需要处理表单的默认行为,那么我们可以在 React 中直接使用它们,并且不会出现任何问题。然而,当我们希望让 JavaScript 处理表单提交并访问表单中的所有数据时,我们会遇到一个复杂的问题。这个问题是因为元素和 React 组件同时尝试跟踪表单的状态。

实现这一点的方法是使用受控组件。受控组件的目标是从表单元素中删除状态控制,并使 React 成为控制组件。这是通过为字段的值更改事件(onChange)添加一个 React 事件监听器,并让 React 将其内部state变量值设置为表单的值来实现的。然后,React 将字段的值设置为保存在state变量中的值。React 读取input字段中的任何更改,并强制input字段采用发生在 React 组件中存储的数据的任何更改。以下片段中显示了这一点的示例:

class ControlledInput extends React.Component {
  constructor(props) {
    super(props);
    this.state = {value: ''};
  }
  handleChange(event) { this.setState({value: event.target.value}); }
  render() {
    return (
      <div>
        <input type="text" value={this.state.value} onChange={this.handleChange.bind(this)} />
        <div>Value: {this.state.value} </div>
      </div>
    );
  }
}
片段 6.41:React 组件状态

在前面的片段中,我们创建了一个名为ControlledInput的组件。该组件有一个名为 value 的状态变量,用于存储文本输入的值。我们创建了一个名为handleChange的函数,简单地通过将值设置为从事件中读取的值来更新组件的状态。在渲染函数中,我们创建一个包含一个input字段和另一个div的 div。这个输入字段的值映射到this.state.value,并且有一个调用handleChange函数的事件监听器。第二个div简单地镜像了this.state.value的值。当我们在文本输入框内进行更改时,onChange监听器被调用,组件的state.value被设置为输入字段的当前值。每当this.state.value被更改时,这种变化都会反映到input字段上。组件的this.state.value的值是绝对的,input字段被强制镜像它。

活动 6:使用 React 构建前端

练习 32中负责笔记应用的前端团队意外辞职了。您必须使用 React 构建此应用的前端。您的前端应该有两个视图,一个是主页视图,一个是编辑视图。为每个视图创建一个React组件。主页视图应该有一个按钮,可以切换到编辑视图。编辑视图应该有一个按钮,可以切换回主页视图,一个包含笔记文本的文本输入,一个调用 API 加载路由的加载按钮,以及一个调用 API 保存路由的保存按钮。已经为您提供了一个 Node.js 服务器。在activities/activity6/activity/src/index.js中编写您的 React 代码。当您准备测试您的代码时,在启动服务器之前运行构建脚本(在package.json中定义)。您可以参考练习 35中的index.html文件,了解如何调用 API 路由的提示。

要构建一个可工作的 React 前端并将其与 Node.js Express 服务器集成,执行以下步骤:

  1. 打开activity/activity6/activity中的起始活动。运行npm install以安装所需的依赖项。

  2. src/index.js文件中创建HomeEditor组件。

  3. 主页视图应该显示应用程序名称,并有一个按钮,可以将应用状态更改为编辑视图。

  4. 编辑视图应该有一个返回主页的按钮,可以将应用状态更改为编辑视图,一个由编辑视图状态控制的文本输入,一个向服务器请求保存的笔记文本加载按钮,以及一个向服务器请求保存笔记文本的保存按钮。

  5. App组件中,使用app状态来决定显示哪个视图(主页编辑)。

代码

结果

图 6.10:主页视图

图 6.10:主页视图

图 6.11:编辑视图

图 6.11:编辑视图

图 6.12:服务器视图

图 6.12:服务器视图

您已成功构建了一个可工作的 React 前端,并将其与 Node.js Express 服务器集成。

注意

此活动的解决方案可在第 293 页找到。

总结

在过去 10 多年中,JavaScript 生态系统已经大幅增长。在本章中,我们首先讨论了 JavaScript 生态系统。JavaScript 可用于构建完整的后端 Web 服务器和服务、命令行界面、移动应用程序和前端网站。在第二部分中,我们介绍了 Node.js。我们讨论了如何为浏览器外的 JavaScript 开发设置 Node.js,Node 包管理器,加载和创建模块,基本的 HTTP 服务器,流和管道,文件系统操作以及 Express 服务器。在最后一个主题中,我们介绍了用于前端 Web 开发的 React 框架。我们讨论了安装 React 以及 React 的基础知识和特定内容。

这本书到此结束。在本书中,您学习了 ES6 中的主要特性,并实现了这些特性来构建应用程序。然后,您处理了 JavaScript 浏览器事件,并创建了遵循 TDD 模式的程序。最后,您构建了后端框架 Node.js 和前端框架 React。现在,您应该具备将所学知识应用于实际工作中的工具。感谢您选择本高级 JavaScript 书籍。

附录

关于

本节旨在帮助学生执行书中的活动。它包括详细的步骤,学生需要执行这些步骤以实现活动的目标。

第一章:介绍 ECMAScript 6

活动 1 - 实现生成器

您被要求构建一个简单的应用程序,根据请求生成斐波那契数列中的数字。该应用程序为每个请求生成序列中的下一个数字,并在给定输入时重置序列。使用生成器生成斐波那契数列。如果将一个值传递给生成器,则重置序列。为了简单起见,您可以从 n=1 开始斐波那契数列。

为了突出生成器如何用于构建迭代数据集,请按照以下步骤进行:

  1. 查找斐波那契数列并了解下一个值是如何计算的。

  2. 为斐波那契数列创建一个生成器。

  3. 在生成器内,使用变量n2n1设置currentnext(0, 1)的默认值。

  4. 创建一个无限的while循环。

  5. while循环内,使用yield关键字提供序列中的当前值,并将 yield 语句的返回值保存到名为input的变量中。

  6. 如果输入包含一个值,则将变量n2n1重置为它们的起始值。

  7. while循环内,从current + next计算出新的下一个值,并将其保存到变量 next 中。

  8. 否则,将n2更新为包含n1next值)的值,并将n1设置为我们在while循环顶部计算的next值。

代码:

index.js
function* fibonacci () {
 let n2 = 0;x
 let n1 = 1;
 while ( true ) {
   let input = yield n2;
   if ( input ) {
     n1 = 1;
     n2 = 0;
   } else {
     let next = n1 + n2;
     [ n1, n2 ] = [ next, n1 ];
   }
 }
}
let gen = fibonacci();
片段 1.87:实现生成器

https://bit.ly/2CV4KAi

结果:

图 1.19:带有生成器的斐波那契数列

图 1.19:带有生成器的斐波那契数列

您已成功演示了如何使用生成器基于斐波那契数列构建迭代数据集。

第二章:异步 JavaScript

活动 2 - 使用 Async/Await

您被要求构建一个与数据库交互的服务器。您必须编写一些代码来查找数据库中的集合和基本用户对象。导入simple_db.js文件。使用async/await语法编写以下程序,使用getinsert命令:

  • 查找名为john的键,名为sam的键,以及您的名字作为数据库键。

  • 如果数据库条目存在,则记录结果对象的age字段。

  • 如果您的名字在数据库中不存在,请插入您的名字并关联一个包含您的名字、姓氏和年龄的对象。查找新的数据关联并记录年龄。

对于任何失败的db.get操作,将键保存到数组中。在程序结束时,打印失败的键。

DB API:

db.get(index):

这需要一个索引并返回一个 promise。如果索引不存在,数据库查找失败,或者未指定键参数,则 promise 将被拒绝并返回错误。

db.insert(index,insertData):

这需要一个索引和一些数据,并返回一个 promise。如果操作完成,promise 将被插入的键满足。如果操作失败,或者没有指定键或插入的数据,则 promise 将被拒绝并返回错误。

要利用 promise 和 async/await 语法构建程序,请按照以下步骤进行:

  1. 使用require('./simple_db')导入数据库 API,并将其保存到变量db中。

  2. 编写一个异步主函数。所有操作都将在这里进行。

  3. 创建一个数组来跟踪导致db错误的键。将其保存到变量missingKeys中。

  4. 创建一个 try-catch 块。

在 try 部分内,使用 async/await 和db.get()函数从数据库中查找键john

将值保存到变量user中。

记录我们查找的用户的年龄。

在 catch 部分,将键john推送到missingKeys数组中。

  1. 创建第二个 try-catch 块。

在 try 部分中,使用 async/await 和db.get()函数查找数据库中的键sam

将值保存到变量user中。

记录查找到的用户的年龄。

在 catch 部分,将键sam推送到missingKeys数组中。

  1. 创建第三个 try-catch 块。

在 try 部分中,使用 async/await 和db.get()函数查找数据库中的您的名字的键。

将值保存到变量user中。

记录查找到的用户的年龄。

在 catch 部分,将键推送到missingKeys数组中。

在 catch 部分,使用 await 和db.insert()将您的user对象插入db中。

在 catch 部分,在catch块内创建一个新的 try-catch 块。在新的 try 部分中,使用 async/await 查找刚刚添加到 db 中的用户。将找到的用户保存到variable user中。记录查找到的用户的年龄。在 catch 部分,将键推送到missingKeys数组中。

  1. 在所有的 try-catch 块之外,在主函数的末尾,返回missingKeys数组。

  2. 调用main函数,并为返回的承诺附加一个then()catch()处理程序。

  3. then()处理程序应传递一个记录承诺解析值的函数。

  4. catch()处理程序应传递一个记录错误消息字段的函数。

代码

index.js
const db = require( './simple_db' );
async function main() {
 const missingKeys = [];
 try { const user = await db.get( 'john' ); } 
 catch ( err ) { missingKeys.push( 'john' ); }
 try { const user = await db.get( 'sam' ); }
 catch ( err ) { missingKeys.push( 'sam' ); }
 try { const user = await db.get( 'zach' ); }
 catch ( err ) {
   missingKeys.push( 'zach' );
   await db.insert('zach', { first: 'zach', last: 'smith', age: 25 });
   try { const user = await db.get( 'zach' ); }
   catch ( err ) { missingKeys.push( 'zach' ); }
 }
 return missingKeys;
}
main().then( console.log ).catch( console.log );
片段 2.43:使用 async/await

https://bit.ly/2FvhPo2

结果

图 2.12:显示的姓名和年龄

图 2.12:显示的姓名和年龄

您已成功实现了文件跟踪命令并浏览了存储库的历史记录。

第三章:DOM 操作和事件处理

活动 3 - 实现 jQuery

您想要制作一个控制家中智能 LED 灯系统的 Web 应用程序。您有三个 LED 灯,可以单独打开或关闭,或者一起切换。您必须构建一个简单的 HTML 和 jQuery 界面,显示灯的开启状态。它还必须有控制灯的按钮。

要利用 jQuery 构建一个功能应用程序,请按照以下步骤进行:

  1. 为该活动创建一个目录,并在该目录中,在命令提示符中运行npm run init以设置package.json

  2. 运行npm install jquery -s以安装 jQuery。

  3. 为该活动创建一个 HTML 文件,并给 HTML 块添加一个 body。

  4. 添加一个样式块。

  5. 添加一个 div 来容纳所有的按钮和灯。

  6. 添加一个带有jQuery文件源的script标签。

<script src="./node_modules/jquery/dist/jquery.js"></script>
  1. 添加一个script标签来保存主 JavaScript 代码。

  2. 在 CSS 样式表中为light类添加以下设置:

宽度和高度:100px

背景颜色:白色

  1. 在 div 中添加一个切换按钮,使用id=toggle

  2. 添加一个 div 来容纳带有 idlights的灯。

  3. 在这个 div 内添加三个 div。

注意

每个 div 应该有一个带有light类的 div 和一个带有lightButton类的按钮。

  1. 在代码脚本中,设置一个函数在 DOM 加载时运行:

$(() => { ... })

  1. 选择所有lightButton类按钮,并添加一个点击处理程序,执行以下操作:

停止事件传播并选择元素目标,并通过遍历 DOM 获取light div。

检查lit属性。如果已点亮,则取消lit属性。否则,使用jQuery.attr()设置它

background-color css样式更改为反映lit属性的jQuery.css()

  1. 通过 ID 选择toggle按钮,并添加一个点击处理程序,执行以下操作:

停止事件传播并通过 CSS 类选择所有的灯按钮,并在它们上触发点击事件:

代码

activity.html

以下是精简的代码。完整的解决方案可以在activities/activity3/index.html中找到。

$( '.lightButton' ).on( 'click', e => {
  e.stopPropagation();
  const element = $( e.target ).prev();
  if ( element.attr( 'lit' ) !== 'true' ) {
    element.attr( 'lit', 'true' );
    element.css( 'background-color', 'black' );
  } else {
    element.attr( 'lit', 'false' );
    element.css( 'background-color', 'white' );
  }
} );
$( '#toggle' ).on( 'click', e => {
  e.stopPropagation();
  $( '.lightButton' ).trigger( 'click' );
} );
片段 3.32:jQuery 函数应用

https://bit.ly/2VV9DlB

结果

图 3.14:在每个 div 后添加按钮

图 3.14:在每个 div 后添加按钮

图 3.15:添加切换按钮

图 3.15:添加切换按钮

您已成功利用 jQuery 构建了一个功能应用程序。

第四章:测试 JavaScript

Activity 4 – 利用测试环境

您的任务是将斐波那契数列测试代码升级为使用 Mocha 测试框架。将您为 Activity 1 创建的斐波那契数列代码和测试代码升级为使用 Mocha 测试框架来测试代码。您应该为 n=0 条件编写测试,然后实现 n=0 条件,然后为 n=1 条件编写测试并实现,然后为 n=2 条件编写测试并实现,最后为 n=5、n=7 和 n=9 条件做同样的操作。如果 it() 测试通过,调用 done 回调而不带参数。否则,使用错误或其他真值调用 test done 回调。

要利用 Mocha 测试框架编写和运行测试,请按照以下步骤进行:

  1. 使用 npm run init 设置项目目录。使用 npm install mocha -s -g 全局安装 mocha。

  2. 创建 index.js 来保存代码,test.js 保存测试。

  3. 将测试脚本添加到 package.jsonscripts 字段中。测试应该调用 mocha 模块并传入 test.js 文件。

  4. 将递归斐波那契数列代码添加到 index.js。您可以使用 Exercise 24 中构建的代码。

  5. 导出函数与 module.exports = { fibonacci }.

  6. 使用以下命令将斐波那契函数导入测试文件:const { fibonacci } = require( './index.js' );

  7. 为测试编写一个 describe 块。传入字符串 fibonacci 和一个回调函数

  8. 通过手工计算每个斐波那契数列中的预期值(您也可以在 Google 上查找该数列,以避免手工计算太多数学)。

  9. 对于每个测试条件(n=0、n=1、n=2、n=5、n=7 和 n=9),执行以下操作:

使用 it() 函数创建一个 mocha 测试,并将测试描述作为第一个参数传入。

将回调作为第二个参数。回调函数应该接受一个参数 done

在回调函数中调用斐波那契数列,并使用不等于比较将其结果与预期值进行比较。

调用 done() 函数并传入测试比较结果。

如果测试失败,返回 done( error )。否则,返回 done(null)done(false)

  1. 使用 npm run test 从命令行运行测试。

代码:

test.js
'use strict';
const { fibonacci } = require( './index.js' );
describe( 'fibonacci', () => {
 it( 'n=0 should equal 0', ( done ) => {
   done( fibonacci( 0 ) !== 0 );
 } );
 it( 'n=1 should equal 1', ( done ) => {
   done( fibonacci( 1 ) !== 1 );
 } );
 it( 'n=2 should equal 1', ( done ) => {
   done( fibonacci( 2 ) !== 1 );
 } );
 it( 'n=5 should equal 5', ( done ) => {
   done( fibonacci( 5 ) !== 5 );
 } );
 it( 'n=7 should equal 13, ( done ) => {
   done( fibonacci( 7 ) !== 13 );
 } );
 it( 'n=9 should equal 34, ( done ) => {
   done( fibonacci( 9 ) !== 34 );
 } );
} );
Snippet 4.9:利用测试环境

https://bit.ly/2CcDpJE

查看下面的输出截图:

图 4.8:显示斐波那契数列

图 4.8:显示斐波那契数列

结果

您已成功利用 Mocha 测试框架编写和运行测试。

第五章:函数式编程

Activity 1 – 递归不可变性

您正在使用 JavaScript 构建一个应用程序,并且您的团队被告知出于安全原因不能使用任何第三方库。现在,您必须为该应用程序使用函数式编程(FP)原则,并且您需要一个算法来创建不可变的对象和数组。创建一个递归函数,使用 Object.freeze() 强制对象和数组在所有嵌套级别上的不可变性。为简单起见,您可以假设对象中没有嵌套的 null 或类。在 activities/activity5/activity-test.js 中编写您的函数。该文件包含测试您的实现的代码。

为了演示在对象中强制不可变性,请按照以下步骤进行:

  1. 打开 activities/activity5/activity-test.js 中的活动测试文件。

  2. 创建一个名为 immutable 的函数,它接受一个参数 data

  3. 检查 data 是否不是 object 类型。如果不是,则返回。

  4. 冻结 data 对象。您不需要冻结非对象。

  5. 使用 object.valuesforEach() 循环遍历对象值。对每个值递归调用不可变函数。

  6. 运行测试文件中包含的代码。如果有任何测试失败,修复错误并重新运行测试。

代码:

activity-solution.js
function immutable( data ) {
 if ( typeof data !== 'object' ) {
   return;
 }
 Object.freeze( data );
 Object.values( data ).forEach( immutable );
}
片段 5.11:递归不可变性

https://bit.ly/2H56ah1

查看下面的输出截图:

图 5.7:通过测试的输出显示

图 5.7:通过测试的输出显示

结果:

您已成功地展示了在对象中强制使用不可变性。

第六章:JavaScript 生态系统

活动 6 - 使用 React 构建前端

从练习 35 中负责笔记应用的前端团队意外退出。您必须使用 React 构建此应用程序的前端。您的前端应该有两个视图:一个 Home 视图和一个Edit视图。为每个视图创建一个 React 组件。Home视图应该有一个按钮,可以将视图切换到 Edit 视图。Edit 视图应该有一个按钮,可以切换回 Home 视图,一个包含笔记文本的文本输入,一个调用 API 加载路由的加载按钮,以及一个调用 API 保存路由的保存按钮。已提供一个 Node.js 服务器。在activities/activity6/activity/src/index.js中编写您的 React 代码。当您准备测试您的代码时,在启动服务器之前运行构建脚本(在package.json中定义)。您可以参考练习 35 中的index.html文件,了解如何调用 API 路由的提示。

要构建一个可工作的 React 前端并将其与 Node.js express 服务器集成,按照以下步骤进行:

  1. 开始在"activity/activity6/activity"中工作。运行npm install来安装所需的依赖项。

  2. 在 src/index.js 中,创建名为HomeEditor的 React 组件。

  3. 向 App react 组件添加一个构造函数。在构造函数中:

接受props变量。调用super并将props传递给super

this作用域中设置state对象。它必须具有一个名为view且值为home的属性。

  1. 向应用程序添加一个changeView方法。

changeView方法应该接受一个名为view的参数。

使用setState更新状态,并将stateview属性设置为提供的参数view

在构造函数中,添加一行代码,将this.changeView设置为this.changeView.bind(this)

  1. 在 App 的render函数中,根据this.state.view的值创建一个条件渲染,执行以下操作:

如果state.view等于home,则显示 Home 视图。否则,显示编辑器视图。

changeView函数作为参数传递给两个视图,即<Editor changeView={this.changeView}/><Home changeView={this.changeView}/>.

  1. Home组件中,添加一个goEdit()函数,调用通过props传入的changeView函数(this.props.changeView),并将字符串editor传递给changeView函数。

  2. Home组件中创建render函数:

返回一个 JSX 表达式。

在返回的 JSX 表达式中,添加一个包含应用程序Note Editor App标题的div,并添加一个按钮,将视图更改为Edit视图。

按钮应在单击时调用goEdit函数。确保将this状态正确绑定到goEdit函数。

  1. Editor组件添加一个构造函数:

接受props参数

调用super并将props变量传递给它。

this作用域中将state变量设置为{value: ‘’}对象。

  1. Editor中添加一个名为handleChage的函数:

接受一个名为e的参数,表示事件对象。

使用setState更新状态,将状态属性value设置为事件目标的值:

this.setState( { value: e.target.value } );
  1. Editor中创建一个名为save的函数,向 API 的 save 路由发出 HTTP 请求。

创建一个新的 XHR 请求,并将其保存到xhttp变量中。

xhttp属性onreadystatechange设置为一个函数,检查this.readyState是否等于4。如果不是,则返回。还要检查this.status是否等于200。如果不是,则抛出错误。

通过在xhttp上调用open函数打开xhr请求。传入参数POST/savetrue

通过在xhttp对象上调用setRequestHeader将请求头Content-Type设置为application/json;charset=UTF-8。传入指定的值。

通过xhttp.send发送文本输入的 JSON 数据。

必须发送的值存储在this.state中。在发送之前对值进行字符串化。

  1. Editor中创建一个load函数,向 API 加载路由发出 HTTP 请求。

创建一个新的 XHR 请求并将其保存到xhttp变量中。

this范围保存到名为that的变量中,以便在xhr请求内部使用。

xhttp属性onreadystatechange设置为一个函数,检查this.readyState是否等于 4。如果不是,则返回。然后检查this.status是否等于 200。如果不是,则抛出错误。它在 React 组件的范围上调用setState函数,该函数保存在that变量中。

传入一个对象,其中键value等于请求的解析响应。使用JSON.parse函数从this.response变量中解析 HTTP 响应值。

通过在xhttp变量上调用open函数打开 HTTP 请求。传入参数GET/loadtrue

通过在xhttp对象上调用send()方法发送 XHR 请求。

  1. Editor中创建一个goHome函数。

调用通过 React 元素属性对象传入的changeView函数(this.props.changeView())。

传入字符串“主页”。

  1. Editor中创建render函数。

添加一个按钮,点击后调用包含文本“返回主页”的goHome函数。在点击事件上调用goHome函数。确保将this范围正确绑定到函数。

添加一个包含笔记文本的“文本”输入。文本输入从state.value字段加载其值。“文本”字段在更改事件上调用handleChange函数。确保将this范围正确绑定到handleChange函数。

添加一个按钮,从包含文本“加载”的服务器中加载笔记数据。在点击事件上调用load函数。确保将this范围正确绑定到 load 函数调用。

添加一个按钮,将包含文本“保存”的笔记数据保存到服务器。在点击事件上调用save函数。确保将this范围正确绑定到 save 函数调用。

确保将this状态正确绑定到所有监听器。

  1. 准备好后通过以下方式测试代码:

在根项目文件夹中运行npm run build以将代码从 JSX 转换。

运行npm start以启动服务器。

在服务器启动时加载指定的 URL(127.0.0.1:PORT)。

通过单击“编辑”和“返回主页”按钮测试视图更改。

通过在“文本”输入框中输入文本,保存并检查创建的文件夹中的文本文件,测试“保存”功能。

通过在文本文件中输入文本,加载它,并确保“文本”输入中的值与文本文件中的值匹配,测试“加载”功能。

以下提供了一个简化的片段。有关完整解决方案代码,请参阅activities/activity6/solution/src/index.js

Index.js
class Editor extends React.Component {
 constructor( props ) { ... }
 handleChange( e ) { ... }
 save() { ... }
 load() { ... }
 goHome() { ... }
 render() {
   return (
     <div>
       <button type="button" onClick={this.goHome.bind( this )}>Back to home</button>
       <input type="text" name="Note Text" value={this.state.value} onChange={this.handleChange.bind( this )}/>
       <button type="button" onClick={this.load.bind( this )}>Load</button>
       <button type="button" onClick={this.save.bind( this )}>Save</button>
     </div>
   );
 }
}
class Home extends React.Component {
 goEdit() { ... }
 render() {
   return (
     <div>
       <h1>Note Editor App</h1>
       <button type="button" onClick={this.goEdit.bind( this )}>Edit Note</button>
     </div>
   );
 }
}
//{…}
片段 6.42:React 组件

https://bit.ly/2RzxKI2

结果:

查看这里的输出截图:

图 6.13:编辑视图

图 6.13:编辑视图

图 6.14:服务器视图

图 6.14:服务器视图

图 6.15:运行服务器以测试代码

图 6.15:运行服务器以测试代码

您已成功构建了一个可工作的 React 前端,并将其与 Node.js express 服务器集成。

posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报