JavaScript-研讨会-全-

JavaScript 研讨会(全)

原文:zh.annas-archive.org/md5/8e13e4ab3ea046f5123a7fb02ed2833f

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

关于

本节简要介绍了本书的涵盖范围,你开始所需的技术技能,以及完成所有包含的活动和练习所需的硬件和软件要求。

关于本书

你已经知道你想要学习 JavaScript,而更智能的学习 JavaScript 的方式是通过实践学习。《JavaScript 工作坊》 专注于提升你的实践技能,以便你可以为网络、移动和桌面用户开发前沿的应用程序。除了 HTML 和 CSS 的知识外,JavaScript 是那些希望进入专业网络开发的人的关键技能。你将从真实示例中学习,这些示例可以带来真实的结果。

《JavaScript 工作坊》 中,你将采取引人入胜的逐步方法来理解 JavaScript 代码。你不必忍受任何不必要的理论。如果你时间紧迫,你可以每天跳入一个单独的练习,或者花一个周末来学习函数式编程。由你选择。按照你的方式学习,你将以一种感觉有成就感的方式建立和加强关键技能。

每一份 《JavaScript 工作坊》 的物理副本都能解锁访问互动版。视频详细介绍了所有练习和活动,你将始终有一个指导性的解决方案。你还可以通过评估来衡量自己的水平,跟踪你的进度,并接收内容更新。完成学习后,你甚至可以赚取一个可以在线分享和验证的安全凭证。这是与印刷副本一起提供的优质学习体验。要兑换它,请遵循 JavaScript 书籍开头的说明。

《JavaScript 工作坊》 快速直接,是 JavaScript 初学者的理想伴侣。你将像真正的软件开发者一样构建和迭代你的 JavaScript 编码技能,并在学习过程中不断进步。这个过程意味着你会发现你的新技能会持续存在,作为最佳实践的嵌入 – 为未来的几年打下坚实的基础。

关于章节

第一章了解 JavaScript,介绍了 JavaScript 的基础知识,以便向前迈进并达到熟练水平。本章通过其历史,从现代实现到各种语言用途的附加信息,向你介绍 JavaScript,为接下来要学习的内容提供适当的背景。

第二章使用 JavaScript,涵盖了与该语言的一些实际操作。我们将概述一些流行的 JavaScript 编写工具和执行其代码的各种可用运行时。我们将特别关注 JavaScript 的主要目标,以及本身就是一个优秀工具的现代网络浏览器。

第三章编程基础,作为在 JavaScript 中工作以及一般编程时涉及的基本概念和结构的介绍。我们将涵盖所有基础知识,从对象类型到条件语句和循环结构,如何编写和调用函数,甚至注释和调试它们的代码。

第四章JavaScript 库和框架,专注于纯 JavaScript,以及今天存在的各种框架和库。本章的主要重点是提供一种理解,即虽然对核心语言的扩展可能很好,但有时,核心 JavaScript 就足够了。

第五章超越基础,解释了在不同的语言和运行时中数据表示方式的不同。JavaScript 基于 ECMAScript 规范,并具有关于数据表示的明确规则。本章讨论了 JavaScript 中的数据,如何在不同类型之间进行转换,以及类型如何在脚本中传递。

第六章理解核心概念,利用本书中的 HTML 页面以及 JavaScript,作为第一个解释事件消息系统的抽象性质。在 JavaScript 中构建有用的 Web 应用程序时,理解这些概念非常有价值。在本章中,你将探索事件消息冒泡和捕获的各种细微差别,以及如何最好地用于控制应用程序中的信息流。你还将看到如何阻止这些事件,以及如何创建自己的自定义事件。本章将为你提供基础工具库,以应对任何大小或复杂性的应用程序。

第七章揭开盖子,阐明了很多人认为的“仅仅是 JavaScript”实际上可以被分解成独立的组件:JavaScript 引擎,包括调用栈、内存堆和垃圾回收器;以及 JavaScript 运行时环境,例如浏览器或 Node.js,它包含 JavaScript 引擎,并使引擎能够访问额外的函数和接口,如setTimeout()或文件系统接口。我们还将探讨 JavaScript 如何管理内存分配和释放,尽管它是自动管理的,但对于开发者来说,了解涉及的过程对于编写能够使垃圾回收器正确工作的代码非常重要。

第八章浏览器 API,介绍了几个最有用和有趣的浏览器 API,这些 API 为我们提供了广泛的功能,我们可以在 JavaScript 应用程序中使用。我们将看到,虽然这些 API 通常通过 JavaScript 访问,但它们不是 JavaScript 引擎编程的 ECMAScript 规范的一部分,也不是 JavaScript 核心功能的一部分。

第九章使用 Node.js 工作,指导我们围绕单一编程语言统一整个 Web 应用程序开发,而不是学习不同的语言并为服务器端和客户端构建不同的项目。在本章中,你将了解节点在后台是如何工作的,以及它是如何异步处理请求的。此外,你还将学习不同类型的模块以及如何使用它们。你还将进行许多重要的练习,以获得实际的经验。

第十章访问外部资源,探讨了在没有新鲜数据的情况下,网页是静态的且用途有限的这一事实。本章涵盖了使用 Ajax 获取数据的不同方法,主要是从 RESTful 服务中获取。

第十一章编写整洁且易于维护的代码,介绍了编写整洁且易于维护代码的最佳实践。你会了解到使用整洁编码技术的重构代码比之前更长。但你会发现,与原始代码相比,代码更加整洁,更容易理解和测试。这种编程风格的价值在复杂现实世界的应用中体现得更为明显,因此以这种方式工作是一种良好的实践。开发人员和技术负责人需要决定哪些标准和整洁编码实践适合他们特定的项目。

第十二章使用下一代 JavaScript,探讨了市场上用于 JavaScript 高级开发的多种工具。我们将学习如何在旧浏览器中使用最新的 JavaScript 语法,以及识别在其他语言中开发 JavaScript 应用程序的不同选项。我们还将探索与 JavaScript 兼容的各种包管理器,如 npm 和 Yarn,以及几个不同的框架,如 AngularJS、React 和 Vue.js。最后,我们将探讨一些服务器端库,如 Express、Request 和 Socket.IO。

第十三章JavaScript 编程范式,教你 JavaScript 是一种多范式编程语言。我们可以用它以过程式、面向对象和函数式设计模式编写代码。在任何编程语言的学习阶段,人们通常以过程式的方式编码,而不是规划,他们大部分的注意力都放在执行上,并理解该特定编程语言的概念。但是,当涉及到现实生活中的实际执行时,面向对象编程范式,或OOP,是一个可扩展的选项。

第十四章理解函数式编程,讨论了函数式编程与其他编程范式(如命令式和面向对象方法)相当不同,并且需要一些习惯。但是,如果正确应用,它是一种非常强大的方式,可以使程序结构更加声明性、正确,并且具有更少的错误。即使您在项目中不使用纯函数式编程,也有很多有用的技术可以单独使用。这对于 mapreducefilter 数组方法尤其如此,它们有广泛的应用。本章涵盖的主题将帮助您增强在函数式风格中追求编程项目所需的技能。

第十五章异步任务,讨论了异步任务如何允许程序的主线程在等待数据、事件或另一个进程的结果时继续执行,从而实现更快的用户界面和一些形式的并行处理。语言最近的增强,如承诺和 async/await 关键字,简化了这种开发,并使编写干净和可维护的异步代码变得更加容易。

注意

您可以访问courses.packtpub.com/上的奖励章节

习惯用法

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:

ifelse ifelse 语句为您提供了四种选择或跳过代码块的结构。”

您在屏幕上看到的单词,例如在菜单或对话框中,也以这种方式出现在文本中:“按 F12 键启动调试器或从菜单中选择 更多工具 | 开发者工具。”

代码块设置如下:

function logAndReturn( value ) {
  console.log("logAndReturn:" +value );
  return value;
}
if ( logAndReturn (true) || logAndReturn (false)) {
  console.log("|| operator returned truthy.");
}

新术语和重要词汇如下所示:计时器事件在您的应用程序中提供强制性的异步功能。它们允许您在一段时间后调用一个函数;一次或重复多次。

长代码片段将被截断,GitHub 上相应的代码文件名将放置在截断代码的顶部。整个代码的永久链接放置在代码片段下方。它应该如下所示:

activity.html
1 <!doctype html>
2 <html lang="en">
3 <head>
4     <meta charset="utf-8">
5     <title>To-Do List</title>
6     <link href=https://fonts.googleapis.com/css?family=Architects+Daughter|Bowlby+One+SC       rel="stylesheet">
7     <style>
8         body {
9             background-color:rgb(37, 37, 37);
10             color:aliceblue;
11             padding:2rem;
12             margin:0;
13             font-family:'Architects Daughter', cursive;
14             font-size: 12px;
15         }
The full code is available at: https://packt.live/2Xc9Y4o

在开始之前

每次伟大的旅程都是从谦逊的一步开始的。我们即将开始的 JavaScript 编程之旅也不例外。在我们能够使用 JavaScript 做出令人惊叹的事情之前,我们需要准备好一个高效的环境。在这篇简短的笔记中,我们将看到如何做到这一点。如果您在安装过程中有任何问题或疑问,请通过 workshops@packt.com 发送电子邮件给我们。

安装 Visual Studio Code

安装 Visual Studio Code(VSCode)的步骤如下:

  1. packt.live/2BIlniA下载最新的 VSCode:图 0.1:下载 VSCode

    图 0.1:下载 VSCode

  2. 打开下载的文件,按照安装步骤进行,完成安装过程。

安装“在默认浏览器中打开”扩展程序

  1. 打开你的 VSCode,点击扩展图标,并在搜索栏中输入“在默认浏览器中打开”,如下截图所示:图 0.2:在默认浏览器扩展程序搜索

    图片 C14377_0_02.jpg

    图 0.2:在默认浏览器扩展程序搜索

  2. 点击“安装”以完成安装过程,如下截图所示:

图 0.3:安装扩展程序

图片 C14377_0_03.jpg

图 0.3:安装扩展程序

下载 Node.js

Node.js 是开源的,你可以从其官方网站nodejs.org/en/download/下载适用于所有平台。它支持所有三个主要平台:Windows、Linux 和 macOS。

Windows

访问他们的官方网站并下载最新的稳定版.MSI 安装程序。过程非常简单。只需执行.MSI文件,并按照说明在系统上安装它。会有一些关于接受许可协议的提示。你必须接受这些协议,然后点击“完成”。就这样了。

Mac

你必须从官方网站下载.pkg 文件并执行它。然后,按照说明进行操作。你可能需要接受许可协议。之后,按照提示完成安装过程。

Linux

为了在 Linux 上安装 Node.js,请以 root 身份按以下顺序执行以下命令:

  • $ cd /tmp

  • $ wget http://nodejs.org/dist/v8.11.2/node-v8.11.2-linux-x64.tar.gz

  • $ tar xvfz node-v8.11.2-linux-x64.tar.gz

  • $ mkdir -p /usr/local/nodejs

  • $ mv node-v8.11.2-linux-x64./* /usr/local/nodejs

在这里,你首先将当前活动目录更改为系统的临时目录(tmp)。其次,从官方发行目录下载nodetar包。第三,将tar包提取到tmp目录。此目录包含所有编译和可执行文件。第四,在系统中为Node.js创建一个目录。在最后一个命令中,你将所有编译和可执行文件移动到该目录。

验证安装

安装过程完成后,你可以在 Node.js 目录中执行以下命令来验证是否正确安装:

$ node -v && npm -v

它将输出当前安装的 Node.js 和 npm 版本:

图 0.3:已安装的 Node.js 和 npm 版本

图片 C14377_0_04.jpg

图 0.4:已安装的 Node.js 和 npm 版本

这里显示的是系统上安装了 Node.js 的 8.11.2 版本,以及 npm 的 5.6.0 版本。

安装代码包

从 GitHub 在github.com/PacktWorkshops/The-JavaScript-Workshop下载代码和相关文件,并将它们放置在您本地系统上名为C:\Code的新文件夹中。请参考这些代码文件以获取完整的代码包。

第一章:1. 了解 JavaScript

概述

到本章结束时,你将能够在一个网络浏览器中定位 JavaScript 元素以及其他代码元素;识别各种网络浏览器支持的 JavaScript 的不同版本;构建简单的 JavaScript 命令;讨论现代 JavaScript 的各种方法;描述 JavaScript 的功能,并在网络浏览器中创建一个警告框弹出窗口。

本章描述了 JavaScript 的基础知识,以便我们能够继续前进,并掌握这种广泛使用的编程语言。

简介

JavaScript 是一种有着有趣起源的语言。在其早期,它并没有受到太多的重视——该语言被广泛接受、功能正确的唯一用途是执行客户端表单数据的验证。许多开发者只是复制粘贴简单的代码片段,在构建的网站上执行单一的操作。所有这些代码片段所做的只是非常简单的动作,例如向用户显示一个警告或提供日期倒计时——简单的逻辑。

现在,JavaScript 已经完全不同了——它具有 literally 构建整个 HTML 文档、实时修改 CSS 样式以及从各种远程来源安全传输和解释数据的能力。在过去,HTML 是网络上的主要技术,而在当今时代,JavaScript 才是王者。

任何对 JavaScript 的介绍都需要对语言的历史和起源有一个基础的了解,以便继续前进并掌握该语言。本章从历史到现代实现介绍了 JavaScript,还提供了关于语言各种用途的额外信息,以便我们为接下来要讨论的内容提供一个适当的背景。

什么是 JavaScript 以及它是如何被使用的?

JavaScript 是一种弱类型、多范式、事件驱动、面向对象的编程语言。它包括处理字符串、日期、数组、对象等的能力。它通常用于浏览器环境中的客户端,但也可以用于其他环境,如服务器和桌面应用程序。运行时环境对 JavaScript 非常重要——特别是因为它本身并不包括任何网络、文件、图形或存储能力。

JavaScript 与其他语言的比较

如果你以 Java 或 Python 等其他语言的经验来接近 JavaScript,可能会觉得有点奇怪。虽然许多语言(如 Java)必须编译才能运行,但 JavaScript 是直接运行的,不需要额外的步骤。

尽管 JavaScript 在许多环境和许多用途中被使用,但它在本质上仍然是三种原生于网络的编程语言之一。其他两种语言是 HTML 语义标记语言和 CSS 样式布局语言。这三种语言在目的和功能上彼此非常不同,但它们都旨在在单一环境中协同工作。让我们来了解一下:

  • 超文本标记语言 (HTML):这是这三种语言中最基础的,因为它定义了组成 HTML 页面的元素,并定义了向用户呈现的基本信息流。

  • 层叠样式表 (CSS):这是用来定义一组样式布局规则,它为定义的 HTML 元素添加视觉装饰和高级布局。

  • JavaScript(JS):这是用来使网页具有交互性的,也是本书的重点内容。

对于这三种语言,有一个基本的理解是关注点的分离,即 HTML 提供内容结构,CSS 提供样式和布局,JavaScript 提供交互性。虽然这种理解仍然占主导地位,但许多框架并不完全遵守这种分离,并以某种形式将这些各种语言混合在一起。一些开发者对此表示可以接受,而另一些则不行。当进入这个领域时,这确实是一个需要注意的问题,但最终取决于你根据自己的特定需求选择哪种立场。在我看来,对于这样的问题没有绝对的答案。

练习 1.01:语言发现

让我们继续检查一个网站,看看我们是否可以发现 HTML、JavaScript 和 CSS 是如何表示的。你可以选择任何你喜欢的网站来做这个练习。

注意

本书中的所有示例和截图都将使用 Google Chrome 作为首选的网页浏览器。你可以使用你喜欢的浏览器,尽管一些步骤在不同浏览器之间可能会有所不同。

让我们开始吧:

  1. 在你的网页浏览器中,在地址栏输入一个 URL,然后按Enter/Return键来加载所选资源。例如,让我们使用angular.io/——Angular 网站。当然,你可以选择任何你想要探索的网站。

  2. 现在,在浏览器视图中任何地方右键单击以召唤上下文菜单。选择允许你查看页面源代码的选项。在 Chrome 中,这个选项被标记为“查看页面源代码”。

  3. 页面的源代码将随后出现在一个新标签页中。你可以检查页面的结构,并从原始源代码中挑选出各种 HTML、CSS 和 JavaScript 元素:![图 1.1:通过检查原始源代码可以学到很多东西 img/C14377_01_01.jpg

    图 1.1:通过检查原始源代码可以学到很多东西

  4. 源代码暴露后,向下滚动并识别页面结构中的各种 HTML 元素。你可能会找到一个<head>标签和一个<body>标签(这是必需的),以及页面中的各种<p><h1><h6>标签。

    这里是一些基本 HTML 内容的示例(实际上并非来自 Angular 网站):

    <body>
    <h1>Welcome!</h1>
    <p>Angular is a framework used to build web applications.</p>
    <p>Create high-performing and accessible applications using Angular.</p>
    </body>
    
  5. 现在,尝试定位嵌入到<style>元素中的 CSS 规则,或者甚至是一个链接的 CSS 文件。这里是一些嵌入的 CSS 示例:

    <style>
    color: red; 
    margin-top: 40px; 
    position: relative; 
    text-align center;
    </style>
    

    这里还有一个链接的 CSS 文件:

    <link rel="stylesheet"href="styles.css">
    
  6. 最后,我们将定位一些 JavaScript。与 CSS 类似,JavaScript 可以通过<script>标签嵌入到页面中,或者通过类似机制链接整个 JavaScript 文件。在这里,我们正在定位一些嵌入的 JavaScript:

    <script>
    function writeMessage() {
    document.getElementById("message").innerHTML = "Hello From JavaScript!";
    }
    </script>
    

    这里是一个链接的 JavaScript 文件:

    <script src="img/main.js"></script>
    

    选择查看像这样的公共网页的源代码曾经是了解网络技术的一种常见方式。

    注意

    在各种网站和示例中,你可能会看到在<script>标签中包含一个type属性,指定type="text/javascript"。在 HTML5 中,这不再是必需的,而是默认属性。如果你必须针对 HTML 的早期版本,你需要指定它。

到目前为止,我们已经介绍了 JavaScript 编程语言,并检查了它的主要运行环境(网络浏览器)。我们还简要地看了看 JavaScript 与 HTML 和 CSS 的关系,作为三种原生网络技术之一。

在下一节中,我们将探讨 JavaScript 的历史以及它是如何随着时间演变的。

JavaScript 语言简史

我们已经看到 JavaScript 通常是如何在 Web 环境中集成的,但这个语言是如何产生的呢?我们需要回到 20 世纪 90 年代初,了解在那个时代网络是什么样子,然后我们才开始谈论 JavaScript 本身。

这个故事实际上始于 Netscape 和他们创建的名为Netscape Navigator的网络浏览器。这个新浏览器基于成功的 Mosaic 网络浏览器,目的是将其商业化。在那个时期,根据你与谁交谈,Netscape Navigator 是开发人员选择的网络浏览器。在十年中期的某个时候,微软发布了其Internet Explorer浏览器,这引发了第一次浏览器大战。

Netscape Mocha 和 LiveScript

大约在同一时间,Netscape 聘请 Brendan Eich 为 Netscape 的网络浏览器开发一种编程语言。Eich 从 Scheme(Lisp)、Self 以及最重要的是 Java 中获得了灵感。这项语言的工作,当时称为Mocha,最初(并且臭名昭著地)在仅 10 天的时间内完成。随着初始版本的完成,Netscape 更改了他们的名称,并开始将其称为LiveScript

结果表明,LiveScript 作为一个语言名称,就像 Mocha 一样,只是一个临时的名称,直到 Netscape 与另一家大型公司合作,推进了网络开发双管齐下的理念。

Sun Microsystems 和 Java

流行的 Java 语言通过与 Netscape 和 Sun Microsystems 的合作而发挥作用。Sun 将网络视为 Java 的下一步,而 Netscape 正在寻找在即将到来的与 Microsoft 的战争中寻求盟友,因此形成了联盟。Eich 正在开发的语言从那时起被命名为JavaScript,因为它旨在在网页浏览器中与 Java 协同工作,作为一种更易于添加交互性的方法。

这意味着 Java 语言将是严肃的开发者用来编写网页交互内容的选择,而 JavaScript 将提供类似的交互功能,但更多地面向业余爱好者和那些想要摆弄的人。

注意

当然,实际情况与这截然不同。JavaScript 在 Java 之前就被很好地整合到了浏览器中,而一旦 Java 加入,它只能通过 applet 来实现。随着 Java applet 在很久以前就失去了流行,我们现在只剩下 JavaScript,而不是最初设想的两种语言,尽管JavaScript这个名字已经固定下来。

Ecma International 和 ECMAScript

Ecma 组织采用了并标准化了该规范,并将该语言本身更名为 ECMAScript,JavaScript 成为了该规范的商业实现。Ecma International 仍然是开发和发布 ECMAScript 规范及其所有新版本的机构,这些规范最终影响了 JavaScript 语言。

在本节中,我们了解了 JavaScript 是如何产生的,并进行了快速练习,展示了如何在网页浏览器中实时检查它。此时,你应该对 JavaScript 的确切含义、来源以及它是如何工作的有一个相当好的了解。

在下一节中,我们将通过查看 JavaScript 在 ECMA 标准化之后的版本历史,来了解这个语言背后的更多历史。

ECMAScript(和 JavaScript)的版本

现在 JavaScript 获得了 Ecma International 的标准化和 ECMAScript 规范,它也需要遵循标准的版本化实践。对于语言的前几个迭代,这对开发者来说意义不大。然而,正如你将看到的,随着需求增长和语言的发展,ECMAScript 将会有重大变化。在某些情况下,这些变化会影响到 JavaScript,在其他情况下则完全消失。

ECMAScript 1 (1997)

第一个进行标准化的版本基本上是从 LiveScript 中编码其特性的。这个版本有时被称为ECMAScript 第一版

它通常对应于 JavaScript 版本 1.3。

ECMAScript 2 (1998)

这个版本除了对现有标准的编辑以更好地符合之外,几乎没有变化。它可能应该被标记为版本 1.1。

它也通常对应于 JavaScript 版本 1.3。

ECMAScript 3 (1999)

这个版本的 ECMAScript 添加了一些基本但预期的(必要的)语言增强。其中最重要的之一是引入了 try…catch 条件结构,它为更基本的 if…else 语句提供了一个替代方案,允许更复杂的错误处理。还引入了 in 操作符。这通常对应于 JavaScript 版本 1.5。

ECMAScript 4(未发布)

这个版本包括了真正的类、模块、生成器、静态类型以及许多后来添加到规范中的语言特性。

注意

最终,由于委员会和公司内部的斗争,ECMAScript 4 完全被废弃。取而代之的是,它被 ECMAScript 3 的增量改进所取代,也称为 ECMAScript 3.1。

在这个时候,Adobe 决定基于新的 ECMAScript 版本(ActionScript 3.0)对 ActionScript 语言进行一次全面的修订。这是一个尝试将 Flash Player 背后的语言与通常托管它的浏览器紧密对齐的尝试。以下是一个基本的 ActionScript 3.0 类的示例——请注意,它与之前的 ECMAScript 版本相当不同:

package com.josephlabrecque {
import flash.display.MovieClip;
public class Main extends MovieClip {
public function Main() {
// constructor code
}
}
}

ECMAScript 5 (2009)

实际上,这个版本是 ECMAScript 3.1,版本 4 完全被废弃。这与其说是实质性的发布,不如说是更多政治动机的发布,尽管它包括了来自 ECMAScript 4 的某些错误修复,以及严格模式、JSON 支持,以及用于处理数组的一些额外方法。

这通常对应于 JavaScript 版本 1.8.5,并且是今天大多数浏览器遵循的 JavaScript 版本。

ECMAScript 6(2015)

包括箭头函数、映射、类型化数组、承诺等在内的许多功能都是随着这个版本的 ECMAScript 一起引入的,其中许多是现代 JavaScript 开发的基石。这个规范还允许编写类和模块——终于。以下表格解释了 ES6 的浏览器支持情况:

图 1.2:通过 w3schools.com 的 ECMAScript 2015 浏览器支持表

图 1.2:通过 w3schools.com 的 ECMAScript 2015 浏览器支持表

图 1.2:通过 w3schools.com 的 ECMAScript 2015 浏览器支持表

这个版本的 JavaScript 通常由现代网络浏览器支持,并且是一个主要的功能性发布。

ECMAScript 7(2016),ECMAScript 8(2017)和 ECMAScript 9(2018)

2015 年之后的所有版本都是增量式的,每年对 ECMAScript 6 中确立的内容进行修改。这发生的原因有很多:

  • 它确立了这个语言是稳定、成熟的,无需进行重大破坏。

  • 它允许开发者和浏览器供应商轻松跟上所采用的变化和增强。

  • 它为新规范版本提供了一个稳定的发布周期:

图 1.3:通过 w3schools.com 的 ECMAScript 2016 浏览器支持表

图 1.3:通过 w3schools.com 的 ECMAScript 2016 浏览器支持表

图 1.3:通过 w3schools.com 的 ECMAScript 2016 浏览器支持表

当使用 ECMAScript 2015(或“ES6”)及更高版本编写时,您可能需要将 JavaScript 转译为以前的版本,以便它可以在当前网络浏览器的 JavaScript 引擎中被理解。虽然这是一个额外的步骤,但近年来处理此类任务的工具已经变得更加易于使用。

练习 1.02:我能使用这个功能吗?

没有一种简单的方法可以判断哪些版本的 JavaScript 被哪些浏览器支持——一个更可靠的方法是测试您希望使用的功能是否被当前运行代码的引擎支持。让我们看看Can I Use表格:

图 1.4: “Can I Use” 表格显示 ECMAScript 2015 在不同浏览器中的支持情况

图 1.4: "Can I Use" 表格显示 ECMAScript 2015 在不同浏览器中的支持情况

为了帮助我们做到这一点,互联网上有许多资源和服务跟踪 JavaScript 功能以及每个浏览器中的支持水平。其中最流行的大概是 SVG、HTML5 等。

让我们继续检查 Promise.prototype.finally 的支持情况,该功能首次在 ECMAScript 2018 中实现:

  1. 打开一个网络浏览器,并指示它加载 caniuse.com/。注意,您可以直接从主页访问最新的和最常搜索的功能,而无需搜索:图 1.5: Can I Use 网站

    图 1.5: Can I Use 网站

  2. 我们正在寻找特定的事物。在顶部找到搜索区域,它写着 finally,因为我们想查看哪些浏览器支持 Promise.prototype.finally。我们的搜索结果将自动显示在一个彩色网格中:图 1.6: Promise.prototype.finally 的浏览器支持网格

    图 1.6: Promise.prototype.finally 的浏览器支持网格

    注意,某些块是红色的,而其他块是绿色的。红色表示该功能不受支持,绿色表示该功能受支持。您还可能看到黄色,表示部分支持。

  3. 如果您想查看与特定浏览器版本相关的具体信息,将光标悬停在指示的版本或版本范围内,将出现一个小覆盖层,其中包含额外的信息,例如版本发布日期甚至该版本的统计数据:图 1.7: Chrome 73 的具体支持信息

图 1.7: Chrome 73 的具体支持信息

在界面中继续搜索其他选项——有很多可以探索。

在本节中,我们回顾了 ECMAScript 的不同版本,并探讨了 JavaScript 的功能,这些功能源于那些特定的规范,以及这些功能如何在今天的网络浏览器中得到支持。

在下一节中,我们将探讨如何访问网页浏览器开发者工具,以便更好地了解 JavaScript 的执行情况——甚至可以在浏览器中实时编写 JavaScript。

访问网页浏览器开发者工具

随着我们讨论的每个主题,我们对 JavaScript 与网页浏览器之间关系的理解变得越来越清晰。在前一个练习中,我们看到了如何深入挖掘并发现不同网页浏览器对 JavaScript 功能支持的各个级别。这直接引导我们查看各种浏览器本身,以及每个浏览器内用于检查甚至编写 JavaScript 代码的工具。

浏览器是按照 HTML 和 CSS 等标准构建的。然而,在解释这些标准以及每个主要网页浏览器提供的工具方面存在许多差异。当为网页浏览器编写 JavaScript 时,了解如何访问和使用浏览器开发者工具——尤其是 JavaScript 控制台选项卡——是很重要的。

Google Chrome

在撰写本文时,Chrome 是最受欢迎的网页浏览器——这一事实对普通用户和开发者都适用。Chrome 首次于 2008 年 9 月发布,现在可在多个桌面和移动操作系统上使用。

注意

您可以从 www.google.com/chrome/browser/ 下载 Google Chrome。

要在 Chrome 中访问开发者工具和 JavaScript 控制台,您可以在视口中任何位置右键单击,然后从出现的菜单中选择 Inspect。或者,按 F12。一旦开发者工具打开,点击 Console 选项卡,就可以在 Chrome 本身内检查和编写 JavaScript 代码:

图 1.8:Google Chrome 开发者工具

图 1.8:Google Chrome 开发者工具

使用 Chrome 开发者工具,您可以过滤显示错误、警告或甚至只是像 console.log() 返回的信息这类内容。您甚至可以在浏览器中使用 Console 选项卡视图编写 JavaScript,正如您很快就会看到的。还有一个 Sources 选项卡,允许修改和调试代码。

Microsoft Edge

曾经作为世界上使用最广泛的网页浏览器而统治一时的 Internet Explorer,其最终版本为 IE11。但这并不意味着微软已经放弃网页浏览器,因为随着 Windows 10 的发布,新创建的 Edge 浏览器于 2015 年 7 月作为替代品提供给用户。

注意

Microsoft Edge 随 Windows 10 系统一起安装(www.microsoft.com/windows)。

要在 Edge 中访问开发者工具和 JavaScript 控制台,您可以在视口中任何位置右键单击,然后从出现的菜单中选择 Inspect Element。或者,按 F12

图 1.9:Microsoft Edge 开发者工具

图 1.9:Microsoft Edge 开发者工具

Microsoft Edge 的开发者工具相当丑陋,不是吗?与其他浏览器一样,Edge 的开发者工具包括一个控制台和一个 JavaScript调试器视图。类似于 Chrome,你还可以在代码执行时过滤控制台中出现的输出类型。

注意

到目前为止,Microsoft Edge 正在基于 Chromium 基础进行重写。这意味着 Chrome、Opera、Safari 和 Edge 最终将使用底下的完全相同的浏览器技术。

Apple Safari

在 Apple macOS 和 iOS 操作系统上,Safari 是默认的网页浏览器,并且与这些机器的用户体验紧密集成。类似于 Windows 和 Internet Explorer/Edge,许多用户永远不会偏离他们机器上预装的浏览器。

Safari 曾经甚至可在 Windows 上使用,但随着 2012 年 Windows 最终版本的发布,开发已停止。

注意

Apple Safari 随 Apple macOS(www.apple.com/macos/)安装。

要访问 Safari 中的开发者工具和 JavaScript 控制台,你必须首先调整浏览器本身的一些偏好设置。让我们开始吧:

  1. 首先,通过选择应用程序菜单中的Safari | 偏好设置来访问偏好设置对话框。

  2. 偏好设置对话框中,点击名为高级的标签页。

  3. 一旦高级标签页的内容出现,查看底部并启用在菜单栏中显示开发菜单选项:图 1.10:Apple Safari 高级偏好设置

    图 1.10:Apple Safari 高级偏好设置

  4. 启用该选项后,关闭偏好设置对话框。

  5. 现在,从应用程序菜单中选择新启用的开发选项,然后选择显示 JavaScript 控制台以显示开发者工具。你还可以右键单击并选择检查元素

好消息是,一旦启用开发菜单,它将在会话之间保持启用状态。你只需打开开发者工具即可访问这些功能:

图 1.11:Apple Safari 开发者工具

图 1.11:Apple Safari 开发者工具

今天,Safari 似乎在采用某些功能方面似乎落后于大多数其他浏览器,但你会发现 Apple 在开发者工具中的控制台调试器视图版本是一样的。

变量的介绍

在几乎任何语言中,包括 JavaScript,编程的第一步是理解常见的变量。变量可以被视为某个数据片段的标识符。要在 JavaScript 中声明变量,我们使用保留字var

var name; 

在前面的例子中,我们使用name标识符声明了一个变量。我们的变量还没有任何与之关联的数据。为此,我们必须使用赋值运算符:

name = "Joseph";

由于变量名已经声明,我们在此第二步中不再需要使用var来声明它。我们只需通过其name来引用变量,然后跟一个赋值运算符=,然后是一个值,在这个例子中是"Joseph"。当然,你在这里可能想使用你自己的名字。

我们为了约定和可读性,在每个代码行末尾使用;。请注意,我们也可以在单行代码中执行变量声明和赋值:

var name = "Joseph";

现在,你已经了解了如何声明和分配数据值给变量的基础知识。

练习 1.03:编程第一步

让我们继续在开发者工具控制台中逐步执行一些 JavaScript 代码,然后再继续。如果你在上一个部分中仍然打开了浏览器开发者工具。如果没有,请参阅本章的“访问网络浏览器开发者工具”部分以访问控制台。

由于控制台现在可在网络浏览器中使用,我们将逐步执行一些基本的 JavaScript 声明:

  1. 在控制台中,输入以下代码并按Enter键:

    var myCity= "London";
    

    这声明了一个具有标识性名称myCity的变量。这将允许你在以后调用此变量。

  2. 由于这个变量现在已在内存中定义,我们可以随时访问它。在控制台中输入以下内容并按Enter键:

    alert("Welcome to " + myCity + "!");
    

    一个带有"Welcome to London!"信息的提示框将出现在浏览器视口中。为了实现完整的问候,我们还将使用+运算符将额外的字符串信息添加到变量中。这允许我们在输出中混合变量值和纯文本数据。

现在,你知道了如何将值写入命名变量,以及如何通过变量名读取这些值。

活动一.01:在网页浏览器中创建一个弹窗提示框

在本活动中,你将调用 JavaScript 并见证其与网络浏览器的紧密关系。你将学习如何在浏览器环境中使用浏览器开发者工具执行一个提示框。

注意

在以下说明和输出图像中,我们将使用 Google Chrome。其他浏览器可能会有所不同。

步骤

  1. F12键打开开发者工具。或者,右键单击可能显示一个菜单,你可以从中选择“检查”。

  2. 激活“控制台”标签页。开发者工具可能默认显示此视图。如果不显示,则可能有一个可以点击以激活的“控制台”标签页。

  3. 在控制台中,输入 JavaScript 命令。

  4. Return/Enter键执行代码。

预期输出

输出应类似于以下内容:

图 1.12:出现带有我们信息的提示框

图 1.12:出现带有我们信息的提示框

注意

本活动的解决方案可在第 710 页找到。

在本节中,我们探讨了如何访问各种流行浏览器中的网络浏览器开发者工具,并查看了一些可以访问的不同视图。

在下一节中,我们将概述 JavaScript 的具体用途,并大致了解该语言的能力。

JavaScript 功能概述

没有 JavaScript,网络将是一个相当平淡且非交互式的体验。作为与 HTML 和 CSS 一起构建网络的核心理技术之一,JavaScript 对于今天使用这些技术的任何人来说都至关重要。JavaScript 允许我们执行复杂交互,将数据传输到您的应用程序中,并在网页视图中显示重构的值。它甚至有能力构建、销毁以及修改整个 HTML 文档。

客户端表单验证

表单在网络上无处不在——HTML 规范包括各种输入、复选框、单选组、文本区域等等。通常,在数据到达服务器之前,你可能会想要一些逻辑来查找用户输入的某些格式特性或其他异常。你可以在点击“提交”按钮后触发初始客户端验证,或者甚至在每个输入失去焦点时:

图 1.13:登录表单验证

图 1.13:登录表单验证

这可能是网络中 JavaScript 最常见的用途之一:你可以提供基本的反馈,让用户知道他们犯了错误——在这种情况下,无效的登录凭证。

JavaScript 小部件或组件

不论是使用由组件库如 BootstrapjQuery UI 提供的 JavaScript 片段,还是由特定供应商和服务提供的代码,人们已经使用 JavaScript 包括功能小部件和组件超过二十年了。这确实是网络中 JavaScript 最常见的用途之一。

通常,你会得到一些代码,这些代码通常由 HTML 和 JavaScript 组成。当它在页面上运行时,通常有一个嵌入的 JavaScript 库,它可以调用函数,或者是一个远程库,它将空白片段转换成特定目的的完整功能内容:

图 1.14:可嵌入的 Twitter 小部件

图 1.14:可嵌入的 Twitter 小部件

JavaScript 基于组件或小部件的最佳例子之一是 Twitter 时间线嵌入。你还会发现类似的可嵌入类型用于 Instagram 和其他社交网络。几乎所有这些都使用 JavaScript 将动态内容插入文档中。

注意

这与 <iframe> 嵌入不同,因为使用 <iframe> 元素时,你只是在从远程资源中拉入内容,而不是动态构建它。

XML HTTP 请求 (XHR)

这种技术源于 富互联网应用RIA)的概念,这种概念在世纪初由 Adobe Flash Player 和 Microsoft Silverlight 等技术主导。RIA 的优点在于,你不再需要刷新整个浏览器视图来查看浏览器 DOM 中呈现的数据的变化。使用像 Flash Player 这样的视觉交互层,可以在应用程序中使用 ActionScript 执行所有与在后台检索数据相关的任务,用户界面随后根据检索到的数据进行更改。这样,用户得到了更好的体验,因为整个文档不需要在每次与服务器交互时都加载和重新加载。

随着开发者开始寻找不使用额外技术完成同样事情的方法,XMLHttpRequestXHR)作为 Microsoft Internet Explorer 1999 年的一部分被引入,称为 XMLHTTP。其他浏览器制造商认识到这种实现的明显好处,继续在其解释中将其标准化为 XMLHttpRequest

注意

在这个更现代的命名之前,XHR 通常被称为异步 JavaScript 和 XML,简称 AJAX。当人们提到 AJAX 时,他们指的是 XHR API。

F12 并导航到 网络 | 预览 以在浏览器中查看 XHR 网络预览:

图 1.15:Chrome 中的 XHR 网络预览

图 1.15:Chrome 中的 XHR 网络预览

浏览器开发者工具都有一种检查与当前网站相关的文件和数据传输到浏览器的方法。对于 XHR,你可以查看原始头信息、格式化预览以及更多内容。

存储本地数据

网络浏览器已经能够以 data:value 对的形式存储本地数据一段时间了,这允许在应用程序的客户端实现某种会话记忆。随着应用程序复杂性的增加,最终出现了在浏览器中存储更复杂本地数据的需求。

现在我们有了 LocalStorage,它是比 cookie 更好的版本,但它仍然缺乏真正数据库的能力。

如果你确实需要访问用于你的网络应用程序的真正客户端数据库,你将想要探索 Indexed DatabaseIndexedDB)API。IndexedDB 是一个真正的客户端数据库,允许复杂的数据结构、关系以及你从数据库中期望的一切。

注意

一些网络浏览器也有访问 Web SQL 数据库的能力——但这是不被网络标准机构认为合适的,通常应该避免。

你可以通过深入开发者工具来检查你访问的任何网站的本地存储。在 Google Chrome 中,你将在 应用程序 视图中找到本地存储:

图 1.16:Chrome 中的本地存储检查

图 1.16:Chrome 中的本地存储检查

DOM 操作

JavaScript 可以修改、创建和销毁 文档对象模型DOM)内的元素和属性。这是 JavaScript 的一个非常强大的方面,几乎所有现代开发框架都以某种方式利用了这一功能。类似于 XHR,使用 JavaScript 在客户端执行这些修改时,浏览器页面不需要刷新。

我们将在下一章中看到一个基于此的特定项目,你将有机会亲身体验这个任务。

动画和效果

回顾网络在婴儿期,一切都是一个非常静态的体验。页面在浏览器中提供,由文本和超链接组成。根据年份,我们通常看到黑色衬线字体与白色背景,偶尔会有蓝色/紫色的超链接。

最终,图像和不同的视觉风格属性也变得可用,但真正改变的是各种扩展的出现,如 Macromedia ShockwaveFlash Player。突然之间,丰富的体验,如交互式视频、动画、游戏、音频播放、特殊效果等,都变得触手可及。

网络标准机构正确地认识到,所有这些功能不应被锁定在不同的浏览器插件之后,而应该是使用核心网络技术作为原生网络体验的一部分。当然,其中最重要的是 JavaScript,尽管 JavaScript 通常依赖于与 HTML 和 CSS 的紧密关系来实现功能。以下截图显示了使用 CreateJS 库创建的交互式动画:

图 1.17:使用 CreateJS JavaScript 库的交互式动画

图 1.17:使用 CreateJS JavaScript 库的交互式动画

现在,我们有了丰富的内容创建类型实现,这些类型之前仅通过第三方插件可用。例如 CreateJS 这样的库允许实现大量的效果、游戏应用、交互、动画等,并使用原生 JavaScript。

注意

在这个开发领域的可能性方面,有许多示例,你可以查看 Google Doodles 存档,网址为 www.google.com/doodles

在本节中,我们探讨了 JavaScript 在当今网络中的一些常用功能。在接下来的章节中,我们将更深入地探讨这些功能。

摘要

在本章中,我们花了一些时间来构建一个知识基础,这将构成本书其余部分的结构。我们从历史概述开始,然后考察了 ECMAScript 与 JavaScript 之间的关系。接着,我们通过访问各种浏览器的开发者工具来探索 JavaScript。在完成了一次动手实践并使用浏览器开发者工具编写了一些 JavaScript 代码之后,我们以对 JavaScript 功能的概述结束了本章。这包括 DOM 操作、本地数据存储、表单验证以及其他示例,以帮助我们正确思考在 JavaScript 中工作的可能性。

在下一章中,我们将通过使用集成开发环境IDE)和检查 JavaScript 的语法规则来更详细地探讨如何使用 JavaScript。我们将更深入地研究网络浏览器,将其视为多种 JavaScript 运行时之一。我们还将更详细地探讨浏览器开发者工具的使用,并亲自动手操作浏览器元素及其相关属性。

第二章:2. 使用 JavaScript

概述

到本章结束时,你将能够操作现代集成开发环境IDE);识别和描述基本的 JavaScript 结构;描述不同的 JavaScript 环境;识别流行网络浏览器开发者工具中的主要视图及其用途,并构建 HTML 元素以及修改它们的各种属性。

在本章中,你将了解一些流行的 JavaScript 编写工具,以及使用现代网络浏览器执行代码的各种可用运行时。

简介

在前一章中,我们探讨了 JavaScript 的历史概述,并考察了该语言与标准 ECMAScript 的关系。然后,我们通过访问各种浏览器的开发者工具来探索 JavaScript。

现在我们已经以更深入的方式熟悉了 JavaScript,是时候通过实际操作来学习这门语言了。首先,我们将概述一些流行的 JavaScript 编写工具和可用于执行代码的各种运行时。安装这些工具后,我们可以开始编写一些 JavaScript 代码,以便熟悉语言的语法和结构——如何以有意义的方式编写代码以及如何在网络环境中包含它。

在本章中,我们将特别关注大多数 JavaScript 环境的主要目标,以及本身就是一个伟大工具的现代网络浏览器。在前一章中,我们探讨了如何在不同网络浏览器中访问开发者工具的概述。在本章中,我们将进一步探讨这些工具如何在 JavaScript 开发中得以应用。

最后,我们将更深入地研究网络浏览器交互,并探讨如何使用常见的 JavaScript 函数来控制网络浏览器中元素的样式和内容。本章中的所有代码都将使用我们即将介绍的编辑器编写。

集成开发环境(IDE)

JavaScript 是一种在运行时解释的语言,因为我们不需要事先编译它。还有其他非直接编写 JavaScript 的方法,例如通过转译或编译,但我们将稍后回顾这些内容。在我们深入到与 JavaScript 一起工作和编写代码之前,我们应该检查使用专用开发环境来编写和管理我们的 JavaScript 代码的好处。

使用与您所使用的平台和语言相匹配的 IDE,与简单的文本编辑器相比,可以提供许多好处。例如,IDE 通常包括以下功能:

  • 检查、格式化和其他清理工具

  • 集成终端和命令行访问

  • 编程语言调试工具

  • 输入时提供强大的代码补全和提示

  • 代码片段和预定义的代码内容

  • 内置编译器(取决于语言和平台)

  • 潜在的 模拟功能——尤其是在处理移动开发时

GitHub 的 Atom

Atom 是一个免费且开源的编辑器,由 GitHub 维护,适用于 Microsoft Windows、Apple macOS 以及各种 Linux 发行版。由于该编辑器是由 GitHub 创建的,因此它的一项主要功能是与他们在编辑器内提供的其他服务的紧密集成:

图 2.1:GitHub for Atom

图 2.1:GitHub for Atom

该编辑器具有插件系统,允许用户添加对各种语言和主题的支持。Atom 可以从 https://atom.io/ 免费下载和安装。

Sublime Text

几年前虽然是一个非常受欢迎的编辑器,但这里仍值得提及,因为许多开发者使用这个 IDE 进行 JavaScript 以及更多开发。Sublime Text 支持 Microsoft Windows、Apple macOS 以及各种 Linux 发行版。最新版本于 2019 年发布:

图 2.2:Sublime Text

图 2.2:Sublime Text

Sublime Text 可以从 www.sublimetext.com/ 下载并安装作为免费评估工具;然而,如果您想长期使用,则需要购买它。

Adobe Dreamweaver

之前是 Macromedia 的财产,Adobe 收购了该公司,并停止了他们现有的网页编辑产品 Adobe GoLive 的开发,转而加强了 Dreamweaver 的支持。自那时以来,该应用程序已经经历了多次重写和调整,但重点始终在视觉编辑视图和针对开发者的裸代码形式之间分裂。Dreamweaver 内置的代码编辑器基于 Adobe 的开源 Brackets (packt.live/2WWMUH6) 项目:

图 2.3:Adobe Dreamweaver

图 2.3:Adobe Dreamweaver

您可以从 www.adobe.com/products/dreamweaver.html 下载并安装 Dreamweaver 进行试用,但为了继续使用,必须购买。

JetBrains WebStorm

从 JetBrains 可获得各种编辑器和工具。其中许多在复杂性和功能方面相互构建。当主要寻找用于编写 JavaScript、HTML 和 CSS 的网页编辑器时,JetBrains WebStorm 是一个好的选择,但它处理项目和关联文件管理的方式确实有一定的学习曲线:

图 2.4:JetBrains WebStorm

图 2.4:JetBrains WebStorm

WebStorm 可以从 www.jetbrains.com/webstorm/ 下载并安装为免费试用,之后有多种购买选项可供选择。与教育机构有关联的人可以申请免费许可证,并每年更新。

Microsoft Visual Studio Code

对于本模块,我们将使用 Visual Studio Code 作为我们的 IDE。这个软件应用是来自微软的免费、跨平台 IDE,每月都会更新。它允许你以非常有效的方式使用原生 Web 技术——同时也有通过扩展支持其他语言和功能的能力。Visual Studio Code 在所有类型的开发者中都非常受欢迎:

图 2.5:Microsoft Visual Studio Code

图 2.5:Microsoft Visual Studio Code

Visual Studio Code 可以从code.visualstudio.com/免费下载和安装。

注意

下载和安装 IDE 的过程可以在本书的前言中找到。

JavaScript 项目和文件管理

现在我们已经安装了开发环境,我们需要考虑一些最佳实践,关于我们在本地机器上存储项目的地方,以及如何在每个项目中组织文件夹和文件。我们还将通过一个小练习来演示如何将项目(包括所有相关文件)加载到 Visual Studio Code 中。

项目文件夹和文件

建议在你的本地机器上留出一个目录,用于放置你可能正在工作的所有项目。例如,你可以在本地磁盘上创建一个名为Projects的目录,然后在其中创建特定的项目文件夹——每个项目一个。这样,你可以确保所有项目都位于一个地方。你甚至可以创建一个到Projects文件夹的快捷方式,以便随时轻松访问:

图 2.6:macOS 上的项目目录示例

图 2.6:macOS 上的项目目录示例

个体项目将存在于主Projects目录中的特定文件夹中。根据你工作的项目数量,你可能需要创建许多子文件夹。每个项目都应该有一个清晰的名字,以便于识别。

练习 2.01:创建工作项目目录

让我们看看如何创建一个目录,以便我们可以包含我们的工作项目以及所有相关的文件和子文件夹:

  1. 在你的文件系统中,找到一个易于访问并且你的账户有完全读写权限的地方,因为你将在这个位置写入文件。如果你已经有一个现有的Projects目录,这很可能是一个理想的位置。

  2. 在这个文件夹中,创建一个新的文件夹,并将其命名为JavaScript,或者你选择的任何其他名字,如下所示:图 2.7:在 macOS 上创建新文件夹

    图 2.7:在 macOS 上创建新文件夹

  3. 在创建好新的项目文件夹后,导航到其父目录。你可能已经在那里,或者可能需要在上一个级别在文件系统中向上移动。这完全取决于你的操作系统以及你如何创建文件夹。

  4. 使用你的鼠标、触控笔或触摸板,将工作项目文件夹从文件资源管理器拖动到 Visual Studio Code 应用程序窗口中:图 2.8:将项目加载到 Visual Studio Code 中

    图 2.8:将项目加载到 Visual Studio Code 中

  5. Visual Studio Code 中当前标签页的内容将被遮挡,这表明你可以将文件夹释放到上面。请继续这样做。

  6. 这将有效地将你拖放到 Visual Studio Code 界面的文件夹设置为你的工作项目文件夹。查看左侧窗格并导航其中的文件和文件夹:图 2.9:声明的当前工作项目文件夹

图 2.9:声明的当前工作项目文件夹

新创建的文件夹现在是 Visual Studio Code 中的工作项目文件夹。通常,你会在最左侧的侧边栏中看到文件和文件夹的列表。我们还没有创建任何文件或文件夹,所以那里什么也没有。

注意

你也可以通过从应用程序菜单中选择文件 | 打开来声明工作项目文件夹,并浏览到该文件夹。

Visual Studio Code,就像许多其他编辑器一样,会记住你在其中打开的项目,并在你下次打开应用程序时显示它们的列表。

在本节中,你了解了在你的本地机器上保持有组织的文件夹结构对于管理所有项目的重要性。我们还看到了如何为项目创建一个新的工作文件夹,然后在我们的代码编辑器中打开它。

在下一节中,我们将使用我们刚刚创建的项目来开始编写和检查 JavaScript 语法和常见结构元素。

JavaScript 语法和结构

现在我们已经安装了 IDE 并确定了如何在代码编辑器中管理工作项目文件夹,是时候看看如何在这样的环境中编写和排序 JavaScript 代码了。我们首先需要做的是在Project文件夹中创建一组文件,因为我们将在这里编写我们的 JavaScript 指令。我们将在工作项目文件夹中创建一组文件,并将它们相互绑定。

练习 2.02:创建项目模板

JavaScript 最常运行的环境是在网页浏览器中。为了在这个环境中运行 JavaScript,它必须以某种方式包含在宿主 HTML 文件中。让我们创建一个基本的 HTML 文件和一个 JavaScript 文件,并指示浏览器在运行时在 HTML 中加载我们的 JavaScript 文件。让我们开始吧:

  1. 在你的 IDE(Visual Studio Code)中打开你之前创建的工作项目文件夹。由于我们还没有创建任何文件,所以最左侧的窗格中不会列出任何文件:图 2.10:当前项目不包含文件

    图 2.10:当前项目不包含文件

  2. 如果你将鼠标悬停在这个最左侧的区域,你会注意到项目名称右侧出现了一些图标。这些图标中最左边的一个允许创建新的文件。旁边的那个允许创建新的文件夹。点击index.html以查看新创建文件的扩展名。名称index告诉我们这个文件是我们项目的索引HTML 文件。.html扩展名通知我们和电脑这个文件的性质:图 2.12:命名文件 index.html

    ![图 2.12:命名文件 index.html1. 完成后,按键盘上的Enter/Return键提交更改。文件将立即在编辑器的编辑面板中打开。正如你所看到的,它是完全空的:图 2.13:文件存在且已打开,但它是空的

    ![图 2.13:文件存在且已打开,但它是空的 1. 输入以下样板 HTML 代码以设置文件的结构: js <!DOCTYPE html> <html lang="en">   <head>     <meta charset="utf-8">     <title>JavaScript Project</title>   </head>   <body>     <h1>Just an HTML page...</h1>   </body> </html> 1. 我们将文件声明为 HTML 并建立<head>标签和<body>标签。HTML 文档的头部包含不可见的数据,例如声明字符集和标题。HTML 文档的主体包含可见元素,如文本和图像。请注意,你必须使用文件 | 保存或通过使用命令/Ctrl + S来保存文件:图 2.14:我们的初始 HTML 标记结构

    ![图 2.14:我们的初始 HTML 标记结构 1. 再次创建一个新文件,这次命名为app.js。这将表明这是应用程序的主要 JavaScript 文件。.js文件扩展名表明这是一个外部 JavaScript 文件。1. 新的 JavaScript 文件将像 HTML 文件一样打开。正如你所看到的,它最初也是空的。输入以下 JavaScript 代码: js console.log("app.js JavaScript Included!"); 1. 确保再次保存 JavaScript 文件,通过从应用程序菜单导航到文件 | 保存或使用键盘快捷键命令/Ctrl + S图 2.15:你现在应该既有 JavaScript 文件也有 HTML 文件

    ![图 2.15:你现在应该既有 JavaScript 文件也有 HTML 文件 1. 为了将新创建的 JavaScript 文件绑定到我们的 HTML 中,以便它可以在网页浏览器中运行,我们必须在 HTML 文件本身中引用它。切换到标题为<title>的 HTML 文件,并在<head>元素的关闭标签之前: js <script src="img/app.js"></script> 1. <script>标签用于包含外部.js文件,就像我们在这里做的那样,或者它可以用来在 HTML 中直接表示一段 JavaScript 代码。完整的 HTML 文件代码现在应该如下所示: js <!DOCTYPE html> <html lang="en">   <head>     <meta charset="utf-8">     <title>JavaScript Project</title>     <script src="img/app.js"></script>   </head>   <body>     <h1>Just an HTML page...</h1>   </body> </html> 1. 在 HTML 中添加了新的一行代码后,再次保存文件。在 Visual Studio Code 中,项目资源管理器中的文件标签页中有一个小而满的圆盘表示有未保存的更改:图 2.16:有未保存更改的文件

    ![图 2.16:有未保存更改的文件 1. 在网络浏览器中运行 index.html 文件。打开开发者工具并激活控制台视图。如果一切顺利,你将看到我们指示 JavaScript 输出的消息:图 2.17:生成的控制台输出

图 2.17:生成的控制台输出

我们现在知道我们的项目模板已经配置正确,因为 HTML 正在运行并且有效地调用了 JavaScript 文件中的代码。我们现在有了网络应用程序的起点——包括结构(HTML)和逻辑(JavaScript)组件。

基本 JavaScript 语法

了解任何编程语言的基本语法对于正确编写它来说非常重要。要开始编写 JavaScript,你需要知道如何声明变量、将数据赋给变量以及正确地终止命令。

JavaScript 中的变量是一个 var 关键字。以下是一个变量声明的例子:

var myName;

要实际给这个变量赋值并让它做一些有用的事情,我们必须使用赋值运算符,=。以下是给变量赋值的相同语句:

var myName = "Joseph";

注意

在这个例子中,我们正在将一个字符串值赋给变量。我们可以将许多不同类型的值或数据赋给变量,我们将在下一章中了解更多关于这些内容。

你会注意到,我们在每个变量声明后也放置了一个分号,无论我们是否给它赋值。虽然这样做并不是绝对必要的,但以这种方式使用分号可以终止一个命令。然而,多个命令应该放在多行中,如下所示:

var firstName = "Joseph";
var lastName = "Labrecque";
console.log("Hello, " + firstName + " " + lastName);

我们将字符串值赋给 firstNamelastName 变量,然后使用 console.log() 方法以及一些使用 + 运算符的字符串连接来形成一个消息并将其输出到浏览器控制台。在浏览器中执行时,它看起来像这样:

图 2.18:生成的输出信息

图 2.18:生成的输出信息

注意

术语连接简单地指的是将普通字符串和字符串变量值连接在一起,就像我们在这里所做的那样。

关于语法,你需要知道的就是这些,以开始学习。不用担心——当你到达第五章,即“超越基础”时,会有更多关于语法的详细信息。

JavaScript 执行顺序

这一节在某种程度上回顾了上一章中的例子,说明了 JavaScript 可以在网页文档中的各种包含方式。这里有两种选择:要么在 HTML 文档的 <head> 标签或 <body> 标签中包含一个外部 JavaScript 文件,要么直接在文档本身中嵌入代码,同样在上述位置之一。

无论你如何在 HTML 文档中包含 JavaScript,网页浏览器都会从上到下执行。文档的<head>标签内的任何 JavaScript 都会在<body>标签内的任何内容之前执行。当然,你可以在函数中封装 JavaScript 代码块,以便在调用时执行,这实际上在某些方面绕过了这个规则。

练习 2.03:验证执行顺序

让我们进行一个小练习,看看对于任何 JavaScript,文档的<head>部分是否会先于<body>标签内的任何内容执行。让我们开始吧:

  1. 在本章的练习文件中,你会找到一个名为order.html的文档。在 Visual Studio Code 中打开它,你会看到以下 HTML 代码:

    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>JavaScript Execution Order</title>
    </head>
    <body>
        <h1>JavaScript Execution Order</h1>
        <p>View the browser console to see the effective order of execution.</p>
    </body>
    </html>
    
  2. 你会注意到目前还没有 JavaScript,所以让我们在这个演示中插入一些代码片段。在文档的<head>标签内的<title>元素下方添加以下代码:

    <script>console.log('Within the HEAD');</script>
    
  3. 现在,将此代码片段直接添加到文档的<body>标签内的<h1>元素上方:

    <script>console.log('Within the BODY');</script>
    
  4. 最后,我们在<body>标签关闭之前添加另一行代码:

    <script>console.log('At the very END');</script>
    

    文档现在应该看起来像这样:

    <!doctype html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>JavaScript Execution Order</title>
        <script>console.log('Within the HEAD');</script>
    </head>
    <body>
        <script>console.log('Within the BODY');</script>
        <h1>JavaScript Execution Order</h1>
        <p>View the browser console to see the effective order of execution.</p>
        <script>console.log('At the very END');</script>
    </body>
    </html>
    
  5. 在你的网页浏览器中运行此文档,同时打开开发者工具的控制台视图。你将能够验证,是的——代码确实是从上到下处理的,正如我们解释的那样:![图 2.19:执行顺序验证

    ![图 C14377_02_19.jpg]

图 2.19:执行顺序验证

JavaScript 中的console.log()命令会将括号内的任何数据写入控制台。这是调试 JavaScript 的最简单方法,尽管这将在下一章中进一步探讨。

在本节中,我们了解了几种关于 JavaScript 结构和语法的 重要基础,尤其是当涉及到网页浏览器环境时。

在下一节中,我们将探讨 JavaScript 可以运行的其它环境。

JavaScript 环境概述

到目前为止,在我们的旅程中,我们只接触到了现代网页浏览器作为 JavaScript 运行时的角色,但即使在浏览器中,也有各种 JavaScript 引擎作为不同的 JavaScript 运行时。例如,Chrome 有V8 引擎,而 Firefox 通过SpiderMonkey运行 JavaScript。几乎每个浏览器都有自己的独特引擎。

除了基于浏览器的运行时之外,还有其他运行时。我们将查看我们现在可用的各种运行时。

基于浏览器的 JavaScript

在 JavaScript 作为语言的历史中,最常用的环境无疑是网页浏览器。我们已经对此特定的运行时谈了很多,所以我们将不会再次花时间讲解所有这些内容:

![图 2.20:V8——网页浏览器中的 JavaScript

![图 C14377_02_20.jpg]

图 2.20:V8——网页浏览器中的 JavaScript

使用网络浏览器作为 JavaScript 运行时的好处如下:

  • 网络浏览器是世界上分布最广泛的软件平台之一。

  • 浏览器包含一组内置的开发者工具,用于调试和监控 JavaScript。

  • 浏览器是 JavaScript 以及所有其他语言的绝对主要运行时,其他所有运行时都跟随它。

您可以在各自网站了解每个浏览器运行时的更多信息。要了解更多关于 Chrome 背后的运行时引擎 V8 的信息,请访问v8.dev/

基于服务器的 JavaScript

随着网络浏览器中 JavaScript 引擎性能的提高,人们开始怀疑是否可能有其他应用场景和特定的运行时——特别是在基于服务器的环境中。2009 年,随着Node.js的创建,这一想法成为现实。在这之前的一年,谷歌开源了其强大的 V8 JavaScript 引擎。这使得开发者能够实现 V8,以及特定的操作系统绑定,从而产生 Node.js 的第一个版本:

图 2.21:Node.js——服务器上的 JavaScript

图 2.21:Node.js——服务器上的 JavaScript

使用基于服务器的 JavaScript 运行时的好处包括以下:

  • 代码的无线程执行。

  • 事件是完全非阻塞的。

  • 效率和性能与客户端分离。

您可以在nodejs.org/了解更多关于 Node.js 的信息。

桌面 JavaScript

虽然不是 JavaScript 运行的新环境,但桌面应用程序是一个随着额外运行时和库成熟而成熟的增长领域。构建桌面应用程序最流行的解决方案是 Electron。类似于其他框架,当为 Electron 开发应用程序时,您将使用原生网络技术,这些技术最终会被封装在任何目标桌面操作系统上的原生关注容器中。

Electron 应用程序可以针对 Apple macOS、Microsoft Windows 或 Linux,并且都是内置 JavaScript:

图 2.22:Electron——桌面上的 JavaScript

图 2.22:Electron——桌面上的 JavaScript

使用以桌面为中心的 JavaScript 运行时的好处包括以下:

  • 能够使用原生网络技术编写桌面应用程序的能力。

  • 大多数功能都是平台无关的,因此,通常不需要特定的操作系统命令。

您可以在electronjs.org/了解更多关于 Electron 的信息。

移动 JavaScript

自从 iPhone 和 Android 几年前首次亮相以来,移动设备已经变得非常庞大。当然,开发者们希望进入这个市场份额,幸运的是,多年来已经出现了很多相当不错的解决方案,这些解决方案利用了 JavaScript。在相当长的一段时间里,Apache Cordova 和 Adobe PhoneGap 是将网络技术转化为功能型移动应用的主要框架。最近,像 Ionic 这样的技术对于使用 Angular、React 和 Vue 等基于 JavaScript 的常见框架的人来说变得极其流行。使用许多这些工具,你还可以使用纯 JavaScript:

![图 2.23:Ionic——移动设备上的 JavaScript图 C14377_02_23.jpg

![图 2.23:Ionic——移动设备上的 JavaScript

使用以移动为中心的 JavaScript 运行时的好处包括以下内容:

  • 使用原生网络技术编写 iOS 和 Android 应用的能力。

  • 大多数功能都是平台无关的,所以,通常不需要特定的移动操作系统命令。

你可以在 ionicframework.com/ 上了解更多关于 Ionic 的信息。

到目前为止,我们已经简要概述了现代 JavaScript 的各种主要运行环境。

在下一节中,我们将再次关注我们在第一章中介绍的网络浏览器,同时更加关注它们工具中一些更有用视图的特定功能。

深入了解网页浏览器开发者工具

在上一章中,我们简要介绍了使用浏览器开发者工具的工作方式。我们将扩展我们对浏览器开发者工具的探索,并检查在网页浏览器中处理 JavaScript 时最常用的视图样本。

当然,还有许多其他视图和选项,这里没有提及,而且当我们从 Google Chrome 内部检查这些时,它们在整体外观和功能使用上会因浏览器而异。不过,这应该能给你一个很好的想法,无论你首选的浏览器是什么。

注意

要访问 Google Chrome 中的开发者工具,请按 F12 键。

元素视图

当你第一次探索浏览器开发者工具时,你可能会看到的主要视图很可能是元素视图。这个视图非常有用,因为它以非常结构化的方式展示了网页文档的所有元素以及相关的内容和属性。你还会注意到,在这个视图中,你可以探索各种样式和事件监听器。选择一个元素将显示 CSS 规则和任何相关的事件监听器:

![图 2.24:网页文档元素视图图 C14377_02_24.jpg

图 2.24:网页文档元素视图

虽然从 JavaScript 的角度来看,你可能认为在这个视图中没有显示任何有用的内容,但实际上你可以访问整个 DOM 结构,并可以监控这个结构和相关的属性,以验证和探索你通过代码所做的更改。

控制台视图

这是迄今为止我们互动最多的开发者工具视图,可能是编写和测试 JavaScript 代码时最重要的视图。任何错误和警告都将在此视图中显示,你还可以在代码在文档中执行时获取你想要的数据的输出。使用 JavaScript 方法 console.log() 将在控制台视图中显示各种有用的数据,供你探索,你甚至可以通过与视图本身相关的各种选项来定制显示的数据类型:

图 2.25:浏览器控制台输出

图 2.25:浏览器控制台输出

每个网络浏览器都有一个控制台视图,尽管不同浏览器中此视图的具体使用可能不同,但基本用法保持不变。

源视图

在任何类型的编程中,设置断点以有效地暂停代码执行并在特定状态下调试程序的能力是至关重要的。使用源视图,我们可以在网页浏览器本身内有效地做到这一点。

这个视图为我们提供了一种选择,可以查看当前运行的任何 HTML 或 JavaScript 文件的源代码,并在特定行设置断点,以便在遇到断点时使运行时暂停。一旦暂停,我们就可以使用源视图中的其他工具以某种方式检查我们的代码:

图 2.26:在网页浏览器中调试 JavaScript

图 2.26:在网页浏览器中调试 JavaScript

在前面的屏幕截图中,我们在 HTML 文件的第 39 行设置了断点,该行包含嵌入的 JavaScript 代码。当代码执行暂停在此特定行时,我们可以非常详细地检查程序的状态。

网络视图

在我们继续之前,我们将查看的最后一个开发者工具视图是网络视图。这个视图允许你跟踪应用程序中传输的所有内容。HTML 文档、JavaScript 文件、CSS 文件,甚至是不可见的内容,如 XMLHttpRequests (XHR) 和其他幕后数据传输,都会在这里为你记录和测量,以便你检查。如果你想查看特定类型的网络活动并隐藏其他所有内容,顶部甚至有一个方便的过滤器:

图 2.27:在网页浏览器中查看网络活动

图 2.27:在网页浏览器中查看网络活动

你需要注意网络视图的一个重要方面是禁用缓存是一个工具选项。在测试程序时,如果你正在对外部加载的.js文件进行许多更改,禁用浏览器缓存是一个特别好的主意,因为它将防止这些文件在测试时被浏览器缓存。

在本节中,我们花了更多的时间熟悉一些网络浏览器开发者工具中的有用视图。

在下一节中,我们将查看一个实际操作活动,它允许通过 JavaScript 代码直接操作 HTML 元素及其属性。

使用 JavaScript 进行 HTML 元素操作

我们多次提到了 JavaScript 直接操作 HTML 文档中元素的能力。现在,我们将亲自看看这种语言是如何被利用来执行这种操作的。

在进行活动之前,有几个概念需要理解,这样你才知道 HTML 元素是如何工作的。正如你通过示例所看到的,HTML 中的元素通常由一个开始标签和一个结束标签组成,其中通常包含文本数据。如果你想到 HTML 中的<p>标签或段落元素,该元素内的文本,即在开始和结束标签之间,是显示给用户的文本。

如果我们需要定位一个特定的 HTML 元素来操作,使用getElementById() JavaScript 方法是最佳选择。当然,相关的元素必须包含一个 ID 属性,这样它才能按预期工作。

例如,我们可能有一个简单的 HTML 无序列表,其中包含一组三个列表项:

<ul id="frameworks">
    <li>Angular</li>
    <li>Vue</li>
    <li>React</li>
</ul>

如果我们想通过代码来操作这个列表,我们可以用一些简单的 JavaScript 来实现。首先,我们必须存储对列表本身的引用。由于列表元素有一个值为frameworksid属性,我们可以使用以下代码来存储对这个元素的引用:

var frameworksList = document.getElementById('frameworks');

创建了引用后,我们现在可以轻松地创建和添加一个新的子元素:

var newFramework = document.createElement('li');
newFramework.innerText = "Apache Royale";
frameworksList.appendChild(newFramework);

我们甚至可以调整新添加元素的样式属性:

frameworksList.lastChild.style.color = "crimson";

getElementById()是一个 JavaScript 函数,它返回一个具有特定 ID 属性且与特定字符串完全匹配的元素。HTML 元素的 ID 属性必须是唯一的。

一旦我们通过使用getElementById()在 JavaScript 中获得了对任何元素的引用,我们就可以通过children.length子属性来获取其子元素的引用,并最终通过调用长度属性来获取存在的子元素数量。

此外,任何作为这些元素一部分定义的属性也可以用 JavaScript 代码来操作。在这个活动中,我们将调整style属性——实际上修改了特定元素内容的视觉外观。

在本节中,我们看到了如何使用纯 JavaScript 代码直接操作 HTML 元素及其相关属性。现在您应该对这种语言的能力有了一个很好的了解。

活动 2.01:向/在待办事项列表中添加和修改项目

在此活动中,我们将检查一个小型基于网页视图的起始部分,该视图旨在列出一系列待办事项。您需要为以下代码创建一些模板,这些代码目前是静态的 HTML 和 CSS,并带有占位符 <script> 标签。然后您需要向待办事项列表中添加一个项目“研究葡萄酒”,并将新添加的项目字体颜色改为猩红色。最后,您需要验证此代码的执行顺序。

此活动将有助于巩固 HTML、CSS 和 JavaScript 之间的联系。以下代码展示了 JavaScript 如何影响 HTML 节点的视觉显示样式,甚至影响其元素内容:

activity.html
1 <!doctype html>
2 <html lang="en">
3 <head>
4     <meta charset="utf-8">
5     <title>To-Do List</title>
6     <link href=https://fonts.googleapis.com/css?family=Architects+Daughter|Bowlby+One+SC  rel="stylesheet">
7     <style>
8         body {
9             background-color:rgb(37, 37, 37);
10             color:aliceblue;
11             padding:2rem;
12             margin:0;
13             font-family: 'Architects Daughter', cursive;
14             font-size: 12px;
15         } 
The full code is available at: https://packt.live/2Xc9Y4o

此活动的步骤如下:

  1. 您将自行创建 HTML 文件,并将前面的模板 HTML 代码粘贴进去以开始。此文件符合针对 HTML5 规范的标准 HTML 文档。它由 <head> 标签和 <body> 标签组成,并且 <body> 标签内部嵌套着用户在网页浏览器中可以看到的视觉元素和数据。此外,在此文件中,您将看到一组在 <style> 标签内的 CSS 样式规则,这些规则为视觉 HTML 元素提供了额外的特定颜色、字体和大小。CSS 引用了从远程 Google Fonts (fonts.google.com/) 服务加载的额外样式字体信息。

  2. 为我们的列表分配一个 ID,以便通过代码识别它。

  3. 创建一个新的变量,并使用 ID 通过 getElementById() 方法直接引用此元素。

  4. 创建一个新的 HTML 列表项元素。

  5. 在列表项中填充一个数据值。

  6. 通过将其附加到已存在的选择父容器中,将其添加到视觉文档中。

  7. 改变现有元素的颜色。

  8. 通过计算列表项的初始数量来验证我们命令的执行顺序。

  9. 然后,在代码执行后,计算列表项的最终数量。

  10. 刷新浏览器视图并观察控制台。

    注意

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

摘要

在本章中,我们探讨了不同的流行 IDE 用于编写和维护 JavaScript 代码,并选择了 Visual Studio Code 作为本书此部分的默认编辑器。然后我们使用此编辑器来检查 JavaScript 文件结构、语法和项目管理任务。随后,我们对不同的 JavaScript 运行时环境进行了简要概述——从浏览器到桌面、远程服务器和移动设备。最后,我们更详细地了解了可用的各种网络浏览器开发者工具,并进行了允许操作 HTML 元素及其各种属性的活动的操作。

在下一章中,我们将探讨在 JavaScript 中工作时涉及的基本概念和结构,从对象类型到条件语句和循环结构的基础知识,如何编写和调用函数,甚至如何注释和调试代码。

第三章:3. 编程基础

概述

到本章结束时,你将能够展示 JavaScript 的语法和结构;编写注释和调试代码;实现条件逻辑和循环;编写函数并在代码中调用它们;以及构建对用户输入做出反应并更新 DOM 的事件。

本章旨在介绍在 JavaScript(实际上,是编程的一般过程)中工作时涉及的基本概念和结构。

简介

在上一章中,你被介绍了一些流行的 JavaScript 工具和现代网络浏览器可用的各种执行代码的运行时。我们还探讨了网络浏览器的交互,并看到了如何使用集成开发环境IDE)中的常见 JavaScript 函数来控制网络浏览器中元素的样式和内容。

你已经看到了大量的 JavaScript 代码;然而,理解每个函数的作用是任何优秀开发人员的关键技能。本章作为 JavaScript 编程中涉及的基本概念和结构的介绍,我们将涵盖所有基础知识,从对象类型到条件语句和循环结构,如何编写和调用函数,甚至注释和调试它们的代码。

从使用变量存储和计算数据到使用if/else语句对不同变量应用条件,本章将是你在 JavaScript 学习路径上最重要的垫脚石之一。对布尔值、字符串、对象、数组、函数、参数等的深入了解将提高你的开发技能。

数据类型

编程全部关于操作数据。数据可以代表诸如人名、温度、图像尺寸、磁盘存储量以及讨论组帖子上的总点赞数等值。

程序中的所有数据都有数据类型。在 JavaScript 中,你通常首先学习的使用的数据类型是数字、字符串、布尔值、对象、数组和函数。数字、字符串和布尔值数据类型代表单个值。对象代表更复杂的数据。函数用于编写程序。

一些常见的 JavaScript 数据类型及其用途和描述如下:

  • 数字:任何正数或负数的整数,通常称为整数和浮点数,可以在数学运算中使用。它们用于产品价格、结账总额、帖子上的点赞数、圆周率几何值,也可以用作随机数。

  • 字符串:任何一组有效的字符,这些字符不能或不应用于计算操作。它们用于对讨论帖子进行注释,可以是公司名称、街道地址、地点名称、账户号码、电话号码或邮政编码。

  • 布尔值: 表示真和假的任何值。它用于检查表单是否可以提交,密码是否符合其所需的字符,订单余额是否符合免费运输条件,以及按钮是否可以点击。

  • 对象: 一个无序的值集合,称为属性,以及代码,称为方法,它们旨在协同工作。它用于现实世界的对象,例如订单、秒表、时钟、日期或微波炉。它们也可以用于软件对象,例如网页文档、网页上的 HTML 元素、CSS 样式规则或 HTTP 请求。

  • 函数: 一种特殊的数据类型对象,表示一段代码。代码可以使用可选的输入数据,并可选择返回数据。它们可以用于数据转换,如温度转换,在列表中查找值,更新 HTML 元素的样式,向网络服务器发送数据,在屏幕上显示消息,或检查有效数据输入格式,如电子邮件地址。

数据表示

程序中使用表达式来表示数据。如果你曾经使用过电子表格程序,那么表达式与单元格公式类似。表达式可以解析为表示特定数据类型的值。

表达式可以分解为更小的部分,如下所示:

  • 文字值

  • 运算符

  • 变量

  • 返回数据的函数

  • 对象属性

  • 返回数据的对象方法

学习表达式的良好起点是文字值、运算符和变量。函数和对象将在本章后面单独介绍,并且我们将通过它们在表达式中的使用来重新审视它们。

文字值

文字值写入到编程代码中。文字值是静态的。这意味着每次执行代码行时它们都有相同的值,并且不能更改。

文字数据需要根据其数据类型的规则进行格式化。数字、字符串和布尔值是一个良好的起点,这样我们就可以理解文字值的格式化。函数和对象文字将在后面的主题中介绍。以下是一些它们的类型及其规则以及每个有效和无效案例的示例:

  • 1000000, 101, 9.876, 和 -0.1234。无效的例子包括 1,000,000, $1000000, 和 1 000 000

  • 'ABC Company', "Earth's Moon", "She yelled \"duck\"!", 'She yelled "duck"!' 等。无效的例子包括 ABC Company"She yelled "duck"!"

  • truefalse 是有效示例,而无效的例子包括 True, TRUE, FALSE, 和 False

在表达式中使用运算符

运算符用于执行算术、组合文本、进行逻辑比较以及将值赋给变量和属性。

我们所讨论的运算符可以按以下方式分组:

  • 算术

  • 字符串

  • 分组

  • 比较

  • 逻辑

  • typeof

对于数学计算,我们使用算术运算符。字符串运算符允许我们将表达式的部分组合成一个字符串值。以下表格描述了某些算术运算符及其示例:

图 3.1:算术运算符

图 3.1:算术运算符

假设我们可以使用 +,这是连接运算符。它将非字符串数据类型转换为字符串。以下代码显示了三个单独的示例:

"Blue" + "Moon"
"Blue" + " " + "Moon"
"$" + 100 * .10 + " discount"

每个示例的输出如下:

"BlueMoon"
"Blue Moon"
"$10 discount"

表达式不是按从左到右的顺序进行评估的。相反,它们是根据预设的运算符顺序进行评估的,这被称为运算符优先级。例如,乘法运算符的优先级高于加法运算符。您可以使用分组运算符来覆盖运算符的优先级。它强制在评估表达式中的其余部分之前先评估它所包含的表达式。

例如,() 运算符控制表达式评估的优先级:

1 + 2 * 3
(1 + 2) * 3
10 + 10 * 5 + 5
(10 + 10) * (5 + 5)

前述每个示例的输出如下:

7 
9
65
100

比较数据是编程的重要组成部分。比较数据的结果要么是 true(真),要么是 false(假)。可以使用比较运算符(有时称为关系运算符)将表达式的一部分与另一部分进行比较。以下表格描述了某些比较运算符及其示例:

图 3.2:比较运算符

图 3.2:比较运算符

可以使用逻辑运算符比较表达式的多个部分。这些有时被称为布尔运算符。以下是一些布尔运算符及其描述和示例:

图 3.3:逻辑运算符

图 3.3:逻辑运算符

并非所有运算符都是符号。一个非常有用的运算符是 typeof。它以字符串形式显示数据类型。该运算符全部为小写字母。使用分组运算符来获取使用其他运算符的表达式的类型。

例如,typeof 运算符控制表达式评估的优先级:

typeof 100
TypeOf 100
typeof "100"
typeof true
typeof (1 > 2)
typeof (2 + " dozen eggs")

前述每个示例的输出如下:

number
Uncaught SyntaxError: …
boolean
boolean
string
string

注意

周围使用空格包围运算符是一个好的做法。例外情况是不在分组运算符 ( ) 和逻辑非运算符 ! 前后使用空格。

练习 3.01:实现表达式和运算符

在这个练习中,您将交互式地将数字、字符串和比较表达式输入到网络浏览器控制台窗口中,并查看结果。让我们开始吧。在您的网络浏览器中打开 data-expressions.html 文档:

  1. 使用您的网络浏览器打开开发者控制台窗口。

    在接下来的几个步骤中,我们将使用数字数据和算术运算符实现几个表达式。在以 > 符号开始的行上键入项目。控制台窗口将在以 < 符号开始的行上显示响应。

  2. 编写以下代码来加两个字面整数值:

    > 200 + 200
    \\output
    < 400
    
  3. 编写以下代码来除以字面整数值:

    > 1000 / 4
    \\Output
    < 250
    
  4. 现在,编写以下代码来除以一个整数:

    > 150.75 / 3
    \\Output
    < 50.25
    
  5. 乘法有更高的运算符优先级,以下代码展示了这一点:

    > 100 + 100 * 2
    \\Output
    < 300
    
  6. 我们可以使用括号来改变运算符的优先级顺序,如下所示:

    > (100 + 100) * 2
    \\Output
    < 400
    
  7. 要显示实数的类型,我们可以使用typeof,如下所示:

    >typeof 987.123
    \\Output
    < "number"
    
  8. 让我们尝试以下命令:

    > 123 456 789
    \\Output
    < Uncaught SyntaxError: Unexpected number
    

    输出是语法错误,因为您不能以这种格式(例如123 456 789不被识别为数字,但123456789是)包含数字。

  9. 我们可以使用>运算符来比较两个整数,如下所示:

    > 100 > 200
    \\Output
    < false
    
  10. 同样,我们可以使用<运算符来比较两个整数:

    > 100 < 200
    \\Output
    < true
    
  11. 现在,我们可以切换到处理字符串数据。让我们看看当我们使用双引号作为分隔符输入字面字符串时的输出:

    > "Albert Einstein"
    \\Output
    < "Albert Einstein"
    

    下面的几个代码片段将展示使用字面字符串的不同示例。

  12. 如果不使用分隔符使用字面字符串会导致错误,因为 JavaScript 无法识别此类输入:

    > Albert Einstein
    \\Output
    < Uncaught SyntaxError: Unexpected identifier
    
  13. 您可以使用双引号字面字符串。假设您想返回双引号内的语句。您可以将双引号放在单引号之间:

    > 'The quote "The only source of knowledge is experience" is attributed to Albert Einstein'
    \\Output
    < The quote "The only source of knowledge is experience" is attributed to Albert Einstein
    
  14. 使用\转义字符来使用分隔符。这将特殊字符转换为字符串字符:

    > "The quote \"The only source of knowledge is experience\" is attributed to Albert Einstein"
    \\Output. Notice the escape character is removed.
    < The quote "The only source of knowledge is experience" is attributed to Albert Einstein
    
  15. 没有分隔符的非数学数字,例如电话号码,将被转换为数字:

    > 123-456-7890
    \\Output. Expression converted to number
    < -8223
    
  16. 由于我们使用的是"",非数学数字,例如电话号码,将如下所示:

    > "123-456-7890"
    \\Output
    < "123-456-7890"
    
  17. 我们还可以将数字和字面字符串组合起来,如下所示:

    > 100 - 10 + " Main Street"
    \\Output. 
    < "90 Main Street" 
    When string is in expression JavaScript attempts to convert all other elements to a string.
    
  18. 我们可以使用==运算符来比较具有相同大小写的两个字符串:

    > "Albert Einstein" == "Albert Einstein"
    \\Output
    < true
    
  19. 现在,让我们尝试比较两个不同大小写的字符串:

    > "Albert Einstein" == "ALBERT EINSTEIN"
    \\Output
    < false
    
  20. 当我们使用==运算符比较具有相同数值的数字和字符串时,会发生数据类型转换。以下代码片段展示了这一点:

    > 100 == "100"
    \\Output. Data type conversion takes place
    < true
    
  21. 如果我们希望在比较之前不进行数据类型转换,我们需要使用===运算符,如下所示:

    > 100 === "100"
    \\Output. No data type conversion
    < false
    

在前面的练习中,我们使用了几个运算符和表达式。这些运算符和表达式的实际应用案例会根据正在开发的应用类型而有所不同。然而,前面的练习是使用这些运算符在实际程序中的良好起点。请注意,到目前为止,我们使用的示例都是使用字面值。然而,在现实世界的应用中,情况可能并非总是如此。通常,在程序执行过程中,值会动态变化,在这种情况下,在表达式中使用变量变得不可避免。下一节将向您展示您如何在表达式中使用变量和常量。

在表达式中使用变量和常量

变量和常量是分配给值的符号名称。变量的值可以在分配后更改。分配给常量的值不能更改。变量和常量涉及以下项目:

  • 声明关键字

  • 名称

  • 赋值运算符

  • 表达式

  • 数据类型

变量和常量需要使用varlet。对于常量,声明关键字是const

变量和常量需要=。变量的数据类型是动态的,与表达式相同。

声明变量时不需要赋值。常量必须在声明时赋值。

看看以下声明变量但不赋值的示例:

var firstName
var totalLikes
var errorMessage
var isSold

没有赋值的变量仍然有数据类型。这种数据类型被称为typeof运算符检测未定义的数据类型。

下面是一些声明变量并赋值的示例:

var firstName = "Albert"
var totalLikes = 50
var errorMessage = "Something terrible happened"
var isSold = false

以下是一些将值赋给先前声明的变量的示例:

firstName = "Marie"                         
totalLikes = 50
errorMessage = "Something terrible happened"
isSold = false

练习 3.02:使用网络浏览器控制台处理变量

在这个练习中,您将使用网络浏览器控制台窗口来处理变量。您将练习声明变量、赋值和检查它们的类型。让我们开始吧:

  1. 在您的网络浏览器中使用packt.live/370myse中的variables.html文件。

  2. 使用您的网络浏览器打开开发者控制台窗口。

  3. 将项目输入到以>符号开始的行上。控制台窗口将在以<符号开始的行上显示响应。

  4. 声明一个名为firstName的变量:

    > var firstName
    \\Value is expressed as undefined
    < undefined
    
  5. 写出变量的数据类型:

    >typeoffirstName
    \\Output
    < "undefined"
    This is expected as we have not defined our variable with any value.
    
  6. 将字符串值Albert赋给firstName变量:

    >firstName = "Albert"
    \\Output
    < "Albert"
    
  7. 要找出输入的数据类型,使用typeof关键字,如下所示:

    >typeoffirstName
    \\Output
    < "string"
    As expected, our input is correctly identified as beginning a string.
    
  8. 要找出firstName变量所持有的值,我们只需编写以下代码:

    >firstName
    \\Output
    < "Albert"
    Until now, we have used strings. In the next step, we will define a new variable and store a number value in it.
    
  9. 声明一个变量并将其赋给一个数值表达式:

    > var totalLikes = 50
    \\Output. Console may express value when declared but before assigned
    < undefined
    
  10. 写出totalLikes的值:

    >totalLikes
    \\Output
    < 50
    
  11. 为了确定数据类型,我们再次使用typeof,如下所示:

    >typeoftotalLikes
    < "number"
    

    到目前为止,我们还没有改变变量所持有的值。我们将在下一步中这样做。

  12. 这是更改totalLikes所持值的代码:

    >totalLikes = totalLikes + 1
    \\Output. New value is expressed
    < 51
    

    我们可以使用比较运算符>来比较变量所持有的值与参考值。这将在下一步中完成。

  13. 使用以下代码比较totalLikes的值:

    >totalLikes> 100
    < false
    

    结果显然是假的,因为totalLikes当前值为51

    现在,让我们定义一个新的变量并使用布尔表达式。

  14. 声明一个变量并将其赋给一个布尔表达式:

    > var isSold = false
    \\Output. Console may express undefined data type when declared but before assigned.
    < undefined
    
  15. 写出数据类型,如下所示:

    >typeofisSold
    < "boolean"
    

现在,您已经交互式地与声明变量、为它们赋值以及在表达式中使用它们进行了工作。我们使用不同的输入定义了变量,如字符串、数字和布尔值。您还使用了typeof运算符来揭示变量的数据类型。现在,我们将继续到另一个重要主题——函数。

返回值的函数

函数可以被编写为返回一个值。在这种情况下,我们可以在表达式中使用它们。当我们使用一个函数时,它也被称作调用函数。

要在表达式中使用函数,您需要包含函数名,后跟括号。如果函数需要输入,它被放置在括号内作为有效的表达式。这些被称为参数。如果需要多个参数,它们用逗号分隔。

这些示例假设函数将返回一个值。

看看这个示例,它是关于表达不需要参数的函数的:

getTotal() 
isLoggedIn()

这个示例展示了如何表达一个参数被表示为数字字面值的函数:

getCelsiusFromFahrenheit(32)

这个示例展示了如何使用字面值表达具有多个参数的函数:

getSearchResults("Pet Names", 25)

最后,这个示例展示了如何使用变量表达具有多个参数的函数:

var amount = 100000
var decimals = 2
var decimalSeparator = "."
var thousandsSeparator = ","
formatCurrency(amount, decimals, decimalSeparator, thousandsSeparator)

当你在表达式中看到一个函数时,把它想象成代表一个值。

练习 3.03:在表达式中使用函数

在这个练习中,我们将使用一个预定义的函数,然后在表达式中使用它。这个练习将展示您如何调用、检查和返回数据类型,以及在表达式中使用函数。为了这个练习的目的,我们将使用定义为getDiceRoll的函数。让我们开始吧:

  1. 在您的网络浏览器中打开use-functions.html文档。

  2. 使用您的网络浏览器打开网页开发者工具中的控制台窗口

    网页中有一个名为getDiceRoll的函数。它返回一个掷出的骰子的值。它有一个参数。这个参数允许你提供要掷的骰子数量。在以>符号开始的行上输入项目。控制台窗口将在以符号开始的行上显示响应。

  3. 表达数据类型。请注意,没有括号的函数名被使用:

    >typeofgetDiceRoll
    \\Expressed as a function type. It also assures us that there is a function.
    <·function
    
  4. 表达返回值的数据类型。请注意,带有括号的函数名被使用:

    >typeofgetDiceRoll()
    \\Function return value is a number. We do not see the actual value.
    <·"number"
    
  5. 使用以下代码调用函数:

    >getDiceRoll()
    \\Your value will be 1 to 6\. Repeat a few times.
    <·3
    

    我们也可以在数学表达式中调用函数。

  6. 在数学表达式中调用该函数:

    > 100 * getDiceRoll()
    \\Your value will be 100 to 600 Repeat a few times.
    <·300
    

    我们也可以在比较表达式中调用函数。

  7. 在比较表达式中调用函数:

    >getDiceRoll() == 4
    \\You may need to repeat a few times to get a true result.
    <·true
    

    到目前为止,我们还没有为我们的函数传递任何参数。然而,请记住,我们确实有这个选项,因为我们的函数被定义为接受单个参数。这个参数定义了将要掷的骰子数量。让我们在下一步尝试传递一个参数。

  8. 调用并传递掷骰子数量的参数为 2:

    >getDiceRoll(2)
    \\You will receive values from 2 to 12.
    <·11
    

函数对于 JavaScript 编程至关重要。为了让您入门,我们只展示了如何使用预定义的函数。您将在本章的后面学习如何编写自己的函数。然而,您可能会遇到需要使用已经创建的函数的场景。这个练习是一个很好的起点,向您展示如何做到这一点。

对象数据类型

JavaScript 是围绕对象数据设计的,因此理解它很重要。有一些 JavaScript 对象是为我们预先准备好的,您作为程序员将创建对象。在两种情况下,JavaScript 对象都是由 属性方法 组成的:

属性:一个具有指定名称的值。它们一起通常被称为名称/值对。值可以是任何类型,即数据、数字、字符串、布尔值或对象。属性值可以动态更改。

方法:执行动作的函数。

预制对象

JavaScript 提供了一些预制对象,我们可以使用它们来帮助我们开始学习如何编程。JavaScript 中内置了许多有用的对象。网络浏览器提供了一组称为文档对象模型(DOM)的对象集合。

预制对象的例子如下:

  • window 是 DOM 中的一个对象。它能够访问网络浏览器的打开窗口。通常被认为是一个顶层 DOM 对象,包含其他由网络浏览器创建的对象作为其属性,它有设置计时器事件和打印的方法。

  • console 是 DOM 中的一个对象。它提供了向网络浏览器控制台窗口输出的能力。它也是窗口对象的一个属性。

  • document 是 DOM 中的一个对象。它能够访问网页的 HTML 元素、样式和内容。它也是窗口对象的一个属性。

  • location 是 DOM 中的一个对象。它包含有关当前 URL 的信息。它是窗口对象的一个属性。

  • Math 是一个内置对象。它包含数学常数,如 Pi,以及如舍入等函数。

  • Date 是一个内置对象。它提供日历日期和时间操作。

练习 3.04:使用预制对象

在这个练习中,我们将实验 JavaScript 在网络浏览器中可用的预制对象的属性和方法。我们将使用随机、舍入、向上取整和向下取整方法从预定义对象中调用数学对象。让我们开始吧:

  1. 在您的网络浏览器中打开 objects-ready-made.html 文档。

  2. 使用您的网络浏览器打开网页开发者控制台窗口。

  3. 首先,我们将从网络浏览器文档对象开始。在以 > 符号开始的行上输入项目。控制台窗口将在以 符号开始的行上显示响应。

  4. 显示文档对象的标题属性:

    >document.title
    \\Output
    << "JavaScript Data and Expression Practice | Packt Publishing"
    
  5. 现在,显示文档对象的 doctype 属性:

    >document.doctype
    \\Output
    <<!doctype html>
    
  6. 显示文档对象的 lastModified 属性:

    >document.lastModified
    \\Your output may have a different time and date value.
    < "09/09/2019 21:58:25"
    
  7. 声明一个变量,并使用文档对象的 getElementById 方法将其赋值给 HTMLElement 对象变量:

    > var pageHeadEle = document.getElementById('page-heading')
    \\Console may express undefined data type when declared but before assigned. 
    <·undefined
    
  8. 显示 pageHeadEleHTMLElement 对象:

    >pageHeadEle
    \\Output
    <<div id="page-heading" class="heading-section">
    <h1 class="center-text">JavaScript Data and Expression Practice</h1>
    </div>
    
  9. 写入 pageHeadEle object innerHTML 属性:

    >pageHeadEle.innerHTML
    \\Output
    <·"
    <h1 class="center-text">JavaScript Data and Expression Practice</h1>
      "
    
  10. 现在,让我们看看 JavaScript 内置的 Math 对象。写入 Math 对象的 PI 属性:

    >Math.PI
    \\Output
    < 3.141592653589793
    
  11. 调用 Math 对象的 random 方法:

    >Math.random()
    < 0.9857480203668554
    

    Math.random() 方法返回一个介于 01 之间的随机数,包括这两个数。每次调用都会返回不同的值。

  12. 调用 Math 对象的 random 方法:

    >Math.random()
    <·0.3588305599787365
    
  13. 调用 Math 对象的 random 方法:

    >Math.random()
    <·0.45663802022566413
    
  14. 使用 Math 对象的 round 方法:

    >Math.round(10.5)
    <·11
    
  15. 使用 Math 对象的 round 方法:

    >Math.round(10.4)
    <·10
    

    Math.round() 方法返回四舍五入到最接近整数的数字。

  16. 使用 Math 对象的 ceil 方法:

    >Math.ceil(10.5)
    <·11
    

    Match.ceil() 方法返回大于或等于给定参数的下一个最小整数值。

  17. 使用 Math 对象的 ceil 方法:

    >Math.ceil(10.4)
    <·11
    
  18. 使用 Math 对象的 floor 方法:

    >Math.floor(10.4)
    <·10
    

    Math.floor() 方法返回小于或等于给定参数的前一个最大整数值。

  19. 使用 Math 对象的 floor 方法:

    >Math.floor(10.6)
    <·10
    
  20. 这是获取随机骰子值的表达式。floor 方法的参数是一个表达式,即 Math.random() * 6。其结果加 1:

    >Math.floor(Math.random() * 6) + 1
    \\Output
    < 1
    

JavaScript 中有许多现成的对象可供使用。它们的使用方式与其他函数和变量类似,只是我们调用函数时使用方法,调用变量时使用属性。

自定义对象

在开发现实世界应用时,你经常需要创建对象。它们帮助你组织一组相互协作的数据和函数。想想你可能为计时器对象使用的属性和方法。

你可以看我们如何命名属性和方法如下:

  • elapsedTime 是一个数字类型的数据属性。它显示自计时开始以来经过的秒数。

  • resultsHistory 是一个对象类型的数据属性。它显示之前的计时列表。

  • isTiming 是一个布尔类型的数据属性。它显示其计时状态。

  • isPaused 是一个布尔类型的数据属性。它显示是否暂停的状态。

  • start 是一个函数类型的数据方法。它开始计时并将 elapsedTime 设置为 0

  • pause 是一个函数类型的数据方法。它暂停计时。

  • resume 是一个函数类型的数据方法。它恢复计时。

  • stop 是一个函数类型的数据方法。它停止计时并将结果添加到 resultsHistory

对象点表示法

要引用对象属性和方法,你使用点表示法。这是对象名称,后面跟着一个点,然后是属性或方法的名称。让我们以 stopWatch 对象为例:

stopWatch.elapsedTime
stopWatch.start()
stopWatch.start()
stopWatch.stop()

方法名称后需要跟括号。如果方法需要数据输入,则数据放在括号内。

数组对象

数组是表示值列表的对象。列表中的每个项称为元素。要初始化数组,你可以将其设置为数组字面量。数组字面量是一组用方括号括起来的逗号分隔的表达式,如下所示:

["Saab", "Ford", "BMW", "GM"]

数组中的元素可以是不同的数据类型。通常,所有元素都是相同的数据类型:

["Milk", false, 123, document, "Gold", -.9876]

文字数组中的元素可以是表达式,但它们将被评估,并且只存储表达式的值:

[price - cost, Math.random(), document.title, someVariable / 2]

变量和对象属性可以包含数组:

let todoList = [
 "Wash Laundry",
 "Clean Silver",
 "Write Letters",
 "Purchase Groceries",
 "Retrieve Mail",
 "Prepare Dinner"
]
game.scores = [120, 175, 145, 200]

数组元素可以是数组:

notes = [
 [
  "Wash Laundry",
  "Clean Silver",
  "Write Letters"
 ], 123, "999-999-9999"
]

具有有用属性和方法的数组对象如下:

  • length 是一个数字数据类型的属性,它显示数组中的项目数量。

  • push 是一个数字数据类型的方法,它将一个元素添加到数组的末尾并返回新的长度。

  • unshift 是一个数字数据类型的方法,它将一个元素添加到数组的开头并返回新的长度。

  • shift 是一个混合数据类型的方法,它移除第一个元素并返回被移除元素的值。

  • pop 是一个混合数据类型的方法,它移除最后一个元素并返回被移除元素的值。

  • concat 是一个函数数据类型的方法,它将两个或多个数组合并为一个新数组。

使用控制台对象

console 对象有一个名为 log 的方法,我们可以使用它来测试 JavaScript 程序中的表达式。它接受由逗号分隔的不限数量的表达式。我们输入到控制台窗口的所有表达式都将与 console.log 方法一起工作。它评估这些表达式,并在控制台中返回它们的结果。多个表达式由空格分隔。

在接下来的练习中,我们将使用 console.log 方法。让我们看看它的语法:

console.log(expression 1[, expression 2][, expression n])

这里有一些 console.log 方法的示例:

console.log("Odd number count started!");
console.log("Iteration:", i);
console.log("Number:", number);
console.log(oddsCount + " odd numbers found!");
console.log(document);

语法

程序遵循一组规则,这些规则定义了关键字、符号和结构。这被称为语法。我们已经学习了 JavaScript 中许多用于表示数据、变量和表达式的语法规则。你将需要命名对象、属性、方法、变量和函数。

以下是一组基本的规则和约定。约定是最佳实践的另一种说法。尽管不遵循约定不会导致你的代码出现问题,但它们可以使你的代码更难以理解,并且可能不被其他程序员接受,例如,他们可能在面试你时要求查看你的代码示例。

函数和变量的命名规则和约定如下:

  • 26 个大小写字母(A-Z, a-z)。

  • 任何字符但第一个字符可以是 10 个数字(0-9)之一。

  • 没有空格、破折号或逗号。下划线(_)字符是可以接受的。

  • 大小写遵循驼峰式命名法。这意味着所有字符都是小写,除了单词的首字母以及复合词名中的第一个单词的首字母。

  • 没有 JavaScript 保留字;例如,你不能使用 typeofvarletconst

代码语句末尾的分号

一些编程语言要求每个可执行代码语句的末尾都要有一个分号 ;。JavaScript 没有这个要求,除非你在 JavaScript 文件的同一行上有多个可执行代码语句。

在每个可执行代码语句的末尾要求分号 ; 更多的是个人或开发团队的选择。由于分号字符 ; 在其他语言中经常使用,程序员通常更喜欢在 JavaScript 中使用它们,这样他们就能养成使用它们的习惯,并且可以节省更多的时间来处理语法错误。如果你选择使用分号字符 ;,那么请保持一致性。

代码行与语句

JavaScript 源文件中的每一行都不需要是单独的可执行代码行。你可以将一行可执行代码拆分成多个源文件行,或者在一个源文件行上放置多行可执行代码。这种灵活性允许你格式化代码,使其更容易阅读和编辑。

以下是一行使用单个源文件行的可执行代码:

let todoList = ["Laundry", "Letters", "Groceries", "Mail", "Dinner"]

然而,使用多行源文件行可能更可取,如下所示:

let todoList = [
 "Laundry",
 "Letters",
 "Groceries",
 "Mail",
 "Dinner"
]

如果你使用 ; 在前一行代码之后,你可以在同一行上有超过一行代码:

var bid = 10; checkBid(bid)l

你将了解到,当 JavaScript 文件准备发布时,你可以选择性地使用优化程序将源文件中的所有行压缩成一行。这样,行尾的不可见字符就被移除,使文件更小。

注释

你可以在代码中添加注释,因为它们在程序执行时会被忽略。注释可以帮助我们在未来记住代码的功能,并告知可能需要使用或与你的代码一起工作的其他程序员。

注释是防止代码在测试中执行的有用工具。例如,假设你有一行或多行代码没有按预期工作,你想尝试替代代码。当你尝试替代方案时,你可以对有问题的代码进行注释。

JavaScript 有内联注释,也称为单行注释。这使用双斜杠 //。当程序执行时,双斜杠之后直到行尾的所有文本都被忽略。

让我们看看一些内联注释的例子。以下注释解释了下一行代码:

// Hide all result message elements
matchedMsgEle.style.display = 'none';

行尾的注释正在解释代码:

let numberGuessed = parseInt(guessInputEle.value); // NaN or integer

JavaScript 有块级注释,也称为多行注释。这使用组合的斜杠星号字符来标记注释的开始,以及组合星号斜杠的反向来标记注释的结束。当程序执行时,/**/ 之间的所有文本都被忽略。让我们看看各种块级注释。

以下是一个包含代码的多行块级注释。这段代码片段将不会被执行:

/* This is a block comment.
It can span multiple lines in the file.
Code in a comment is ignored such as the next line.
var profit = revenue - cost;
 */

块级注释可以是文件中的一行:

/* This is a block comment. */

以下是一个使用JDoc块注释为函数的示例:

/**
 * Shuffles array elements
 * @param {array} sourceArray - Array to be shuffled.
 * @returns {array} - New array with shuffled items 
*/
function getNewShuffledArray(sourceArray){
   // Statements for function
}

也有一些工具使用语法从注释中生成你的代码文档(例如,JDoc)。这些工具读取你的源代码并生成你的代码文档指南。注释增加了网页的带宽,因此当你检查网页时,通常看不到源代码中的注释。这是因为,通常,原始的 JavaScript 文件并没有发布,而是发布了一个压缩版本。压缩 JavaScript 文件的工具会默认删除注释。注释对学习很有帮助。鼓励你在代码中添加注释,解释代码的功能。

条件和循环流程

JavaScript 中的语句按加载顺序顺序处理。这个顺序可以通过条件或循环代码语句来改变。控制语句的不同部分如下:

  • 代码块 {…}

  • 条件流程语句,如if...elseswitchtry catch finally

  • 循环语句,如fordo...whilewhilefor...infor...of

  • 其他控制语句,如labeledbreakcontinue

我们将在下一节中详细描述这些内容。

代码块

代码块是放置在开闭花括号之间的语句。其语法如下:

//Code block     
{                
   //Statement   
   //Statement   
   //Statement   
}

代码块本身并不提供任何语句流程优势,直到你将它们与条件或循环语句结合使用。

条件流程语句

条件语句使用逻辑表达式从一组语句中选择要处理的语句。

if...else 语句

ifelse...ifelse语句为你提供了四种选择或跳过代码块的结构。

if 语句

if语句中的代码在表达式评估为true时处理,如果表达式评估为false则跳过。其语法如下:

if(boolean expression){
   //Statement
   //Statement
   //Statement
}
if(boolean expression)
   //Single statement

这显示了if语句的流程。如果布尔表达式为true,则处理代码。如果为false,则跳过代码:

图 3.4:if 流程图

图 3.4:if 流程图

练习 3.05:编写 if 语句

在这个练习中,你将使用if语句测试 1 到 6 之间的偶数,并在你的网络浏览器控制台窗口中测试结果。让我们开始吧:

  1. 在你的网络浏览器中打开if-statement.html文档。

  2. 使用你的网络浏览器打开网页开发者控制台窗口。

  3. 在你的代码编辑器中打开if-statement.js文档,将其所有内容替换为以下代码,然后保存:

    var diceValue = Math.floor(Math.random() * 6) + 1;
    console.log("Dice value:", diceValue);
    if(diceValue % 2 != 0){
    console.log("Is an odd number.");
    }
    
  4. Math.random()函数随机生成一个从16的整数,并在控制台显示。在这里,if语句表明,如果数字除以二的余数不为零,即diceValue % 2 != 0,则if表达式为真,并在控制台显示console.log()消息。

  5. 在你的网页浏览器中重新加载 if-statement.html 网页,同时打开控制台窗口。重复操作,直到你看到两个示例的版本:

    // Example of output if the number is odd.
      Dice value: 3
      Is an odd number.
    // Example of output if the number is even.
      Dice value: 4
    
  6. 使用加粗的行编辑 if-statement.js 文档,然后保存它:

    var diceValue = Math.floor(Math.random() * 10) + 1;
    console.log("Dice value:", diceValue);
    console.log("Is an odd number.");
    }
    

    因为 if 语句中只有一行代码,所以不需要代码块括号。

  7. 在你的网页浏览器中重新加载 if-statement.html 网页,同时打开控制台窗口。你应该期望得到相同的结果。

  8. 编辑 if-statement.js 文档,将高亮行添加到 console.log() 并保存:

    var diceValue = Math.floor(Math.random() * 6) + 1;
    console.log("Dice value:", diceValue);
    if(diceValue % 2 != 0)
    console.log("Is an odd number.");
    console.log('"You have to be odd to be number one", Dr. Seuss');
    
  9. 在你的网页浏览器中重新加载 if-statement.html 网页,同时打开控制台窗口:

    // Example of output if the number is odd.
      Dice value: 3
      Is an odd number.
      "You have to be odd to be number one", Dr. Seuss
    // Example of output if the number is even.
      Dice value: 2
    "You have to be odd to be number one", Dr. Seuss
    

    无论数字是偶数还是奇数,都会显示苏斯博士的引言。

  10. 使用加粗的行编辑 if-statement.js 文档中的行并保存。我们在这里添加了代码块定界符:

    console.log("Is an odd number.");
    console.log('"You have to be odd to be number one", Dr. Seuss');
    }
    
  11. 在你的网页浏览器中重新加载 if-statement.html 网页,同时打开控制台窗口:

    // Example of output if the number is odd. The Dr. Seuss quote is included when the value is an odd number.
      Dice value: 3
    Is an odd number.
    "You have to be odd to be number one", Dr. Seuss
    // Example of output if the number is even. The Dr. Seuss quote is skipped when the value is an even number.
      Dice value: 2
    

你可以看到根据 if 语句的逻辑表达式得到不同的结果。

if 语句和 else 语句

你可以将一个 if 语句与一个 else 语句结合使用。如果表达式评估为 true,则处理 if 语句中的代码,并跳过 else 语句中的代码。如果表达式为 false,则发生相反的情况;也就是说,跳过 if 语句中的代码,并处理 else 语句中的代码。语法如下:

if(boolean expression){
   //Statement
   //Statement
   //Statement
}else{
   //Statement
   //Statement
   //Statement
}
if(boolean expression)
   //Single statement
else
   //Single statement

if...else 的工作流程可以从以下流程图中看出:

图 3.5:if else 流程图

图 3.5:if else 流程图

练习 3.06:编写 if...else 语句

在这个练习中,我们使用随机数来模拟抛硬币。随机值等于或大于 .5 表示正面,小于 .5 表示反面。我们将假设每个情况都需要多行语句。让我们开始吧:

  1. 在你的网页浏览器中打开 if-else-statements.html 文档。

  2. 使用你的网页浏览器打开开发者控制台窗口。

  3. 在你的代码编辑器中打开 if-else-statements.js 文档,将其全部内容替换为以下代码,然后保存:

      var tossValue = Math.random();
    console.log("Random toss value:", tossValue);
    if(tossValue>= .5){
      console.log("Heads");
    }
    

    tossValue 变量是一个从 0 到 1 的值,不包括 1。目前,仅使用一个 else 语句来表示正面投掷。

  4. 在你的网页浏览器中重新加载 if-else-statements.html 网页,同时打开控制台窗口。重复操作,直到你看到两个示例的版本:

    // Example of output if the number is .5 or greater.
    Random toss value: 0.8210720135035767
    Heads
    // Example of output if the number is less than .5\. 
    Random toss value: 0.4565522878478414
    //random()gives out a different value each time
    

    注意

    你得到的结果可能与这里展示的不同。

  5. 编辑 if-else-statements.js 文档,添加以下加粗的代码,然后保存它:

    if(tossValue>= .5){
      console.log("Heads");
    }else{
      console.log("Tails");
    }
    

    如果 if 语句表达式为 true,则处理其块中的语句,并跳过 else 块中的语句。如果 if 块表达式为 false,则只处理 else 块中的语句。

  6. 在你的网页浏览器中重新加载 if-else-statements.html 网页,同时打开控制台窗口:

    // Example of output if the number is .5 or greater.
    Random toss value: 0.9519471939452648
    Heads
    // Example of output if the number is less than .5\. 
    Random toss value: 0.07600044264786021
    Tails
    

再次强调,你将看到根据 if 语句的逻辑表达式产生不同的结果。考虑一下 if 语句如何处理屏幕上切换点赞图标的情况。

带有多个 else...if 语句的 if 语句

除了 if 语句外,你还可以有一个或多个else...if语句。每个 if 语句和每个 else...if 语句都有自己的表达式。如果第一个语句中的代码表达式评估为true,则进行处理,并跳过所有其他语句中的代码。如果没有表达式评估为true,则跳过所有代码语句。语法如下:

if(boolean expression){
   //Statement
   //Statement
   //Statement
}else if(boolean expression){
   //Statement
   //Statement
   //Statement
}else if(boolean expression){
   //Statement
   //Statement
   //Statement
}
if(boolean expression)
   //Single statement
else if(boolean expression)
   //Single statement 
else if(boolean expression)
   //Single statement

以下流程图说明了除了 if 语句外,还包含一个或多个else...if语句。每个布尔表达式按遇到的顺序评估。如果第一个表达式为true,则处理代码,并跳转到最后一个 else...if 语句之后的代码:

图 3.6:if 和多个 elseif 流程图

图 3.6:if 和多个 else...if 流程图

if 语句、多个 else...if 语句和 else 语句

可以在最后一个 else...if 语句之后有一个 else 语句。如果第一个语句中的代码表达式评估为true,则进行处理,并跳过所有其他语句中的代码。如果没有表达式评估为true,则处理 else 语句中的代码。语法如下:

if(boolean expression){      
   //Statement
   //Statement
   //Statement
}else if(boolean expression){
   //Statement
   //Statement
   //Statement
}else if(boolean expression){
   //Statement
   //Statement
   //Statement
}else{
   //Statement
}
if(boolean expression)
   //Single statement
else if(boolean expression)
   //Single statement 
else if(boolean expression)
   //Single statement
else
   //Single statement

以下流程图说明了包含 else 语句、else if 语句和 if 语句的情况。如果所有布尔表达式都为假,则处理 else 块中的代码:

图 3.7:else 语句,以及 else...if 语句和 if 语句

图 3.7:else 语句,以及 else...if 语句和 if 语句

练习 3.07:编写带有多个 if else 语句和 else 语句的 if 语句

在这个练习中,我们将构建一个简单的游戏,该游戏从 1 到 21(包括 21)生成四个随机游戏数字。其中一个是玩家的得分,一个是目标得分,一个是幸运得分,最后一个是不幸得分。玩家获得 20 倍于玩家得分的钱包。有五种可能的结果,每种结果都会给玩家的钱包分配不同的赢或输:

  • 玩家的得分与幸运得分相同,而幸运得分和不幸得分不同。钱包增加的金额是幸运值加上玩家得分乘以10

  • 玩家的得分等于不幸得分,而幸运得分和不幸得分不同。钱包减少到零。

  • 玩家的得分等于目标得分。钱包增加的金额是21与目标得分之差乘以10

  • 玩家的得分超过目标得分。钱包增加的金额是玩家得分与目标得分之差乘以10

  • 目标分数击败了玩家的分数。钱包减少目标分数与玩家分数之间的差值乘以10

    完成步骤如下:

  1. 在您的网络浏览器中打开if-else-if-else-statements.html文档。

  2. 使用您的网络浏览器打开网页开发者控制台窗口

  3. 在您的代码编辑器中打开if-else-if-else-statements.js文档,将其全部内容替换为以下代码,然后保存:

    var target = Math.floor(Math.random() * 21) + 1;
    var player = Math.floor(Math.random() * 21) + 1;
    console.log("Target score:", target);
    console.log("Player score:", player);
    if (player >= target){
     console.log("Player wins: beats target by " + (player - target));
    }else{
     console.log("Player loses: misses target by " + (target - player));
    }
    

    我们将首先使用if语句块匹配目标或超过它,即if (player >= target)。声明“玩家输:低于目标”的else语句块涵盖了低于目标的情况。

  4. 在您的网络浏览器中打开控制台窗口,重新加载if-else-if-else-statements.html网页。重复此操作,直到您看到这三个示例的版本。

    玩家分数超过目标的示例如下:

    Target score: 5
    Player score: 13
    Player wins: beats target by 8
    

    以下是玩家分数匹配目标的示例。在这种情况下,消息不支持该逻辑:

    Target score: 14
    Player score: 14
    Player wins: beats target by 0
    

    目标分数超过玩家分数的示例如下:

    Target score: 19
    Player score: 1
    Player loses: misses target by 18
    

    现在,我们可以添加一些代码来处理玩家匹配目标的情况。

  5. 编辑if-else-if-else-statements.js文档,添加以下加粗代码,然后删除删除线代码并保存:

    console.log("Player score:", player);
    if (player == target){
     console.log("Player wins: ties target " + target);
    }else if (player > target){
     console.log("Player wins: beats target by " + (player - target);
    }else{
    

    添加一个新的if语句块来处理玩家与目标平局的条件。原始的if语句块被替换为else...if语句块,该语句块仅测试玩家值超过目标的情况。

  6. 在您的网络浏览器中打开控制台窗口,重新加载if-else-if-else-statements.html网页。重复此操作,直到您看到这三个示例的版本。

    玩家分数超过目标的示例如下:

    Target score: 7
    Player score: 14
    Player wins: beats target by 7
    

    以下是一个玩家匹配目标的示例。在这种情况下,消息不支持该逻辑:

    Target score: 3
    Player score: 3
    Player wins: ties target 3
    

    目标分数超过玩家分数的示例如下:

    Target score: 10
    Player score: 5
    Player loses: misses target by 5
    
  7. 编辑if-else-if-else-statements.js文档,使用以下加粗代码更新它,然后保存。

    为幸运数和不幸数添加了一个变量,并将它们输出到控制台,以便我们可以观察它们:

    var target = Math.floor(Math.random() * 21) + 1;
    var player = Math.floor(Math.random() * 21) + 1;
    var lucky = Math.floor(Math.random() * 21) + 1;
    var unlucky = Math.floor(Math.random() * 21) + 1;
    console.log("Target score:", target);
    console.log("Player score:", player);
    console.log("Lucky score:", lucky);
    console.log("Unlucky score:", unlucky);
    
  8. 接下来,当幸运值不等于不幸值且玩家值等于幸运值时,我们添加一个if语句块。使用逻辑&&运算符处理这两个必需的测试,两者都必须为真。

    此条件优先于其他获胜和失败条件的if语句,因此它需要在这些语句之前。添加以下加粗代码并删除删除线代码:

    if (lucky != unlucky && player == lucky){
     console.log("Player wins: matches lucky score.");
    }else if (player == target){
     console.log("Player wins: ties target " + target);
    }
    
  9. 我们还希望有一个条件,当幸运值不等于不幸值,且玩家值等于不幸值时。再次,使用逻辑&&运算符处理这两个必需的测试,两者都必须为真。

    此条件优先于其他获胜和失败条件的if语句,因此它需要在这些语句之前。插入以下加粗代码:

    if (lucky != unlucky && player == lucky){
     console.log("Player wins: matches lucky score.");
    }else if (lucky != unlucky && player == unlucky){
     console.log("Player loses: matches unlucky score.");
    }else if (player == target){
    
  10. 在你的网页浏览器中重新加载if-else-if-else-statements.html网页,同时打开控制台窗口。重复此操作,直到你看到这两个示例的版本。

    以下是一个玩家匹配幸运数字但未匹配不幸数字的示例:

    Target score: 7
    Player score: 14
    Lucky score: 16
    Unlucky score: 20
    Player wins: matches lucky score
    

    以下是一个玩家匹配不幸数字但未匹配幸运数字的示例:

    Target score: 4
    Player score: 9
    Lucky score: 3
    Unlucky score: 9
    Player loses: matches unlucky score.
    
  11. 编辑if-else-if-else-statements.js文档,用以下加粗的代码更新它,然后保存。

    初始钱包值是玩家得分的10倍。它与其他游戏数据一起显示:

    var unlucky = Math.floor(Math.random() * 21) + 1;
    var wallet = player * 20;
    console.log("Target score:", target);
    console.log("Unlucky score:", unlucky);
    console.log("Player initial wallet:", wallet);
    

    如果匹配到幸运数字,钱包将增加玩家的得分和幸运数字得分乘以10

    if (lucky != unlucky && player == lucky){
     console.log("Player wins: matches lucky score.");
     wallet += (lucky + player) * 10;
    

    如果匹配到不幸数字,钱包将减少到零:

    }else if (lucky != unlucky && player == unlucky){
     console.log("Player loses: matches unlucky score.");
     wallet = 0;
    

    如果玩家的得分与目标得分相同,钱包将增加21与目标得分之间的差值:

    }else if (player == target){
     console.log("Player wins: ties target " + target);
     wallet += (21 - target) * 10;
    

    如果玩家的得分超过目标得分,钱包将增加差值乘以10

    }else if (player > target){
     console.log("Player wins: beats target by " + (player - target));
     wallet += (player - target) * 10;
    

    else语句块将钱包减少到目标和玩家之间的差值。它绑定到10,但不低于零。

    ifif elseelse块语句之后,显示玩家的最终钱包:

    }else{
     console.log("Player loses: misses target by " + (target - player));
     wallet = Math.max(0, wallet - (target - player) * 10);
    }
    console.log("Player final wallet:", wallet);
    
  12. 在你的网页浏览器中重新加载if-else-if-else-statements.html网页,同时打开控制台窗口。重复此操作,直到你看到每个示例的版本。

    以下是一个目标得分超过玩家得分,并且从钱包扣除的金额超过钱包余额的示例。在这种情况下,钱包减少到零:

    Target score: 4
    8 Player score: 1
    Lucky score: 6
    Unlucky score: 4
    Player initial wallet: 20
    Player loses: misses target by 3
    Players final wallet: 0
    

    以下是一个玩家得分超过目标得分的示例。钱包增加了超过目标得分的差值的10倍:

    Target score: 10
    Player score: 18
    Lucky score: 21
    Unlucky score: 10
    Player initial wallet: 360
    Player wins: beats target by 8
    Players final wallet: 440
    

    以下是一个玩家得分匹配目标得分的示例。钱包增加了21与目标得分之间的差值的10倍:

    Target score: 19
    Player score: 19
    Lucky score: 4
    Unlucky score: 7
    Player initial wallet: 380
    Player wins: ties target 19
    Players final wallet: 400
    

    以下是一个玩家匹配幸运数字但未匹配不幸数字的示例。钱包增加了玩家和目标乘以10

    Target score: 19
    Player score: 1
    Lucky score: 1
    Unlucky score: 7
    Player initial wallet: 20
    Player wins: matches lucky score.
    Players final wallet: 40
    

    以下是一个玩家匹配不幸数字但未匹配幸运数字的示例。钱包减少到0

    Target score: 8
    Player score: 13
    Lucky score: 10
    Unlucky score: 13
    Player initial wallet: 260
    Player loses: matches unlucky score.
    Players final wallet: 0
    

这是一个相当长的练习。它展示了多个具有不同逻辑表达式的if语句如何协同工作以产生一个结果。你可能会注意到逻辑表达式的顺序可以产生影响,因为在这种情况下,幸运和不幸的值需要在目标值表达式之前解决。改变顺序会产生一系列不同的结果。

break语句

break语句用于循环语句和switch语句的块中。当在循环语句和switch语句的块中遇到break语句时,程序流程将继续在块的下一行继续。语法如下:

break
break label

当它在一个带标签的语句块中使用时,需要使用第二种语法形式。你将在本章后面了解更多关于带标签语句的信息。接下来的练习将使用break语句。

switch 语句

switch语句定义了一个由case语句和可选的default语句分隔的代码块。case语句后面跟着switch语句表达式的可能值,然后是一个冒号:。可选地,代码可以跟在case语句后面。default语句后面只跟一个冒号:

它是如何工作的?switch语句的表达式被评估,然后处理第一个与switch语句表达式值匹配的case语句后面的代码,直到遇到break语句。然后,跳过任何剩余的代码。如果没有case语句的值匹配,并且存在default语句,则处理default语句后面的代码。否则,不处理任何代码。语法如下:

switch(expression){
   case expression_value:      
      //Optional statement(s)   
      break; //optional        
   case expression_value:      
     //Optional statement(s)   
      break; //optional        
   default:      
      //Statement(s)            
}

以下是一个说明switch语句的流程图:

图 3.8:switch 语句流程图

图 3.8:switch 语句流程图

图 3.8:switch 语句流程图

练习 3.08:编写并测试 switch 语句

我们将通过模拟一个游戏来使用switch语句,玩家可以使用键盘移动他们的游戏棋子。他们可以用A键向左移动,用S键向右移动,用W键向上移动,用Z键向下移动。为了模拟从keyNames字符串中随机选择键(大写或小写),将使用一个变量。让我们开始吧:

  1. 在您的网络浏览器中打开switch-statement.html文档。

  2. 使用您的网络浏览器打开开发者控制台窗口。

  3. 在您的代码编辑器中打开switch-statement.js文档,将其全部内容替换为以下代码,然后保存:

    var keyNames = "WASDwasd";
    var keyName = keyNames.charAt(Math.floor(Math.random() * keyNames.length));
    console.log("keyName:", keyName);
    

    Math.floor(Math.random() * keys.length)表达式从07选择一个数字,然后由charAt使用该数字从keyNames字符串变量中选择字符。

  4. 通过在您的网络浏览器中打开控制台窗口并重新加载switch-statement.html网页进行几次测试。您的结果将显示从ADWSadws字符中的选择。以下是一些控制台输出的示例:

    keyName: a
    keyName: S
    
  5. 编辑switch-statement.js文档,使其包含以下加粗的行,然后保存它。

    switch语句表达式如下:

    console.log("keyName:", keyName); 
    switch (keyName.toLowerCase()){
    

    switch语句表达式将字符转换为小写,以便每个 case 语句可以检查一个值。在这里,我们正在检查 case 值是否等于 switch 项:

     case "a":
      console.log("move left"); //This block will execute when break; // keyName is a
     case "d":
      console.log("move right");//This block will execute when break; // keyName is d
     case "w":
      console.log("move up");//This block will execute when break; // keyName is w
     case "s":
      console.log("move down");//This block will execute when break; // keyName is s
    
    }
    

    switch语句使用一个表达式,然后根据与 case 语句匹配的结果来确定要处理的代码行。需要注意的是,如果没有break语句,一旦一个 case 语句与表达式值匹配,就会处理switch语句末尾的所有代码。这当多个 case 语句使用相同的代码时可能是一个优点。default语句允许在没有任何 case 语句与表达式值匹配时处理的代码。然而,请记住,default语句不是必需的。在这个例子中,如果用户按错了键,什么也不会发生,这在游戏控制台中很常见。

  6. 在您的网络浏览器中重新加载switch-statement.html网页,同时打开控制台窗口。以下是一些示例结果:

    keyName: S
    move down
    keyName: d
    move right
    

    让我们使用IJKL键执行相同的任务。我们将使用I键向上,J键向左,K键向右,M键向下。

    编辑switch-statement.js文档,包括以下加粗的行并保存。

    首先,添加新的键字母:

    var keyNames = "WASDwasdIJKMijkm"; 
    

    接下来,为每个添加 case 语句:

     case "a":
     case "j":
      console.log("move left");
      break;
     case "d":
     case "k":
      console.log("move right");
      break;
     case "w":
     case "i":
      console.log("move up");
      break;
     case "s":
     case "m":
      console.log("move down");
      break;
    }
    

    如果 case 语句后面没有跟break,则也会处理下一个 case 语句的代码。

  7. 在您的网络浏览器中重新加载switch-statement.html网页,同时打开控制台窗口。以下是一些示例结果:

    keyName: J
    move left
    keyName: w
    move up
    

    模拟代码不会生成与case语句不匹配的任何键。如果有,整个switch语句将被跳过。switch语句可以通过使用default语句来处理其他情况。

  8. 编辑switch-statement.js文档,包括以下加粗的行,然后保存。首先,让我们添加一些测试字符:

    var keyNames = "WASDwasdIJKMijkmRTXPrtxp";
    

    接下来,让我们添加default语句:

     case "m":
      console.log("move down");
      break;
     default:
      console.log("invalid key");
      break;
    }
    
  9. 在您的网络浏览器中重新加载switch-statement.html网页,同时打开控制台窗口。重复此操作,直到您看到表示无效键的结果:

    keyName: R
    invalid key
    

在这个练习中,如果用户按错了键,什么也不会发生,这在游戏控制台中很常见。

循环语句

循环代码块也被称为迭代块。它们被设计为在循环语句表达式变为 false 之前继续处理其块中的代码。迭代是一个术语,用来表示通过循环的一次。

注意

一个不会终止的循环被称为无限循环。网络浏览器可能会显示一个对话框,提供终止长时间运行的循环的选项。

for 语句

for语句会重复执行代码,直到重复表达式变为false。其语法如下:

for(initialize statement; repeat expression; post expression){
   //Statement
   //Statement
}

以下流程图描述了for语句的工作方式:

图 3.9:for 语句流程图

图 3.9:for 语句流程图

当第一次到达 for 语句时,会处理 initialize 语句。它通常设置一个在重复表达式中使用的变量。后表达式会改变重复表达式中的值。在循环中的最后一行代码处理完毕后,会处理后表达式,然后处理重复表达式。如果重复表达式仍然为真,则再次处理循环块中的第一个语句。后表达式通常使用称为增量(increment)和减量(decrement)的算术运算符以及赋值加法和减法运算符。以下是一些更多算术运算符的示例:

图 3.10:更多算术运算符

图 3.10:更多算术运算符

练习 3.09:编写并测试 for 循环

这个练习演示了使用 for 语句创建递增计数器和递减计数器。让我们开始吧:

  1. 在你的网页浏览器中打开 for-statement.html 文档。

  2. 使用你的网页浏览器打开开发者控制台窗口。

  3. 在你的代码编辑器中打开 for-statement.js 文档,将其全部内容替换为以下代码,然后保存:

    for(var i = 1; i<= 5; i++){
     console.log(i);
    }
    

    这个示例是一个递增计数器的循环。initialize 语句声明了 i 变量并将其赋值为 1。这是循环第一次迭代时的值。在循环结束时,会评估重复表达式,如果为真,则会处理循环之后的行。后表达式使用增量运算符在每次循环结束时增加 i 1 变量的值。

  4. 在你的网页浏览器中打开 for-statement.html 网页,并确保 控制台窗口 是开启的。以下是一些结果:

    1
    2
    3
    4
    5
    
  5. 使用以下加粗的行编辑 for-statement.js 文档,然后保存它:

    for(var i = 5; i>= 1; i--){
     console.log(i);
    }
    

    这个示例说明了递减计数器的循环。在这个例子中,后表达式使用减量运算符。重复表达式被修改为为真,直到 i 变量的值是 1 或更小。initialize 语句声明了 i 变量并将其设置为 5

  6. 在你的网页浏览器中打开 for-statement.html 网页,并确保控制台窗口是开启的。以下是一些结果:

    5
    4
    3
    2
    1
    
  7. 使用以下加粗的行编辑 for-statement.js 文档,然后保存它:

    for(var i = 2; i<= 10; i+=2){
     console.log(i);
    }
    

    这个示例展示了使用加法赋值运算符创建一个递增计数器循环,每次增加 2,从 2 开始,到 10 结束。

  8. 在你的网页浏览器中打开 for-statement.html 网页,并确保控制台窗口是开启的。以下是一些结果:

    2
    4
    6
    8
    10
    

for 循环是用于重复执行代码的计数迭代的工作马。通过遍历数组,你会发现它的用途更加广泛。

do...while 语句

do...while语句是一个循环,它会执行代码直到重复表达式的值变为假。重复表达式在所有语句处理完毕后评估,从而确保它们至少被处理一次。语法如下:

do{
   //Statement
   //Statement
} while(repeat expression
do
   //Single statement
while(repeat expression)

如果你在其他地方使用分号(;),则需要在while行的末尾放置一个分号。

下面是do…while语句的流程图:

图 3.11:do...while 语句流程图

图 3.11:do...while 语句流程图

练习 3.10:编写 do...while 循环并测试它

在这个练习中,你将使用do…while循环来模拟两个骰子直到它们具有相同值时的迭代。让我们开始吧:

  1. 在你的网络浏览器中打开do-while-statements.html文档。

  2. 使用你的网络浏览器打开开发者控制台窗口。

  3. 在你的代码编辑器中打开do-while-statements.js文档,将其所有内容替换为以下代码,然后保存:

    do{
     var die1 = Math.floor(Math.random() * 6) + 1;
     var die2 = Math.floor(Math.random() * 6) + 1;
     console.log("Die 1:", die1, "Die 2:", die2);
    }while(die1 != die2);
    

    第二行和第三行各自计算一个16之间的随机数并将其存储在一个变量中。这些变量显示在第三行。这些行总是执行一次。如果die1die2变量的值不相等,则while条件为真。如果值相等,表达式为假,循环重复。如果不相等,则处理do…while循环之后的任何语句。

  4. 在你的网络浏览器中打开do-while-statements.html网页,并打开控制台窗口进行几次测试。由于随机值的不同,你的结果可能会有所不同。

    以下是一个多次迭代的示例结果:

    Die 1: 1 Die 2: 3
    Die 1: 2 Die 2: 3
    Die 1: 3 Die 2: 4
    Die 1: 4 Die 2: 5
    Die 1: 3 Die 2: 3
    

    以下示例显示了单次迭代的输出。do…while循环语句总是逐个处理:

    Die 1: 5 Die 2: 5
    
  5. 编辑do-while-statements.js文档,使其包含以下加粗的行,然后保存:

    let iterations = 0;
    do{
     iterations++;
     var die1 = Math.floor(Math.random() * 6) + 1;
     var die2 = Math.floor(Math.random() * 6) + 1;
     console.log("Die 1:", die1, "Die 2:", die2);
    }while(die1 != die2);
    console.log("The matched value is: ", die1);
    console.log("Number of iterations: ", iterations);
    

    第一行let iterations声明了一个名为 iterations 的变量并将其赋值为0。然后,在do…while循环中,iterations 变量iterations++增加1。循环结束后,匹配的值和 iterations 将被显示。

  6. 在控制台窗口打开的情况下,通过重新加载do-while-statements.html网页进行几次测试。由于随机值的不同,你的结果可能会有所不同。

    以下是一个多次迭代的示例结果:

    Die 1: 1 Die 2: 3
    Die 1: 2 Die 2: 3
    Die 1: 5 Die 2: 4
    Die 1: 3 Die 2: 1
    Die 1: 4 Die 2: 4 
    The matched value is:  4
    Number of iterations:  5
    

    以下是一个单次迭代的示例结果:

    Die 1: 4 Die 2: 4
    The matched value is:  4
    Number of iterations:  1
    

while 语句

while语句是一个循环,如果重复表达式是true/false,则会执行代码。重复表达式在执行任何代码之前评估,因此如果第一次评估为false,则可能不会处理任何代码。语法如下:

while(repeat expression){
   //Statement
   //Statement
}
while (repeat expression)
   //Single statement

while语句的流程如下所示:

图 3.12:while 块中的代码语句

图 3.12:while 块中的代码语句

练习 3.11:编写 while 循环并测试它

在这个练习中,我们将使用 while 循环来模拟需要掷出偶数所需的骰子滚动次数。让我们开始吧:

  1. 在您的网络浏览器中打开 while-statement.html 文档。

  2. 使用您的网络浏览器打开开发者控制台窗口。

  3. 在您的代码编辑器中打开 while-statement.js 文档,将其全部内容替换为以下代码,然后保存:

    let iterations = 0;
    while (iterations <10){
     console.log("iterations:", iterations);
     iterations ++;
    }
    

    这只是一个重复 10 次的 while 循环的初始外壳。while 循环的重复表达式在 iterations 变量低于 10 的值时为真。第一次评估表达式时,iterations 变量为 0。在 while 循环内部,iterations 变量在第一行增加 1,并在每次循环迭代中从 0 增加到 9

  4. 在您的网络浏览器中打开 while-statement.html 网页,并打开控制台窗口。

    结果显示 iterations 变量从 0 增加到 9,共进行了 10 次迭代:

    iterations: 0
    iterations: 1
    iterations: 2
    iterations: 3
    iterations: 4
    iterations: 5
    iterations: 6
    iterations: 7
    iterations: 8
    iterations: 9
    
  5. 使用以下加粗的行编辑 while-statement.js 文档,然后保存。这将添加一行以显示每次迭代的骰子滚动:

     die = Math.floor(Math.random() * 6) + 1;
     console.log("die:", die);
     iterations ++;
    
  6. 在您的网络浏览器中打开控制台窗口,并重新加载 while-statement.html 网页。

  7. 您将看到一个包含 10 个骰子值的列表。您的值可能会有所不同:

    die: 2
    die: 5
    die: 2
    die: 4
    die: 2
    die: 3
    die: 4
    die: 2
    die: 6
    die: 1
    
  8. 使用以下加粗的行编辑 while-statement.js 文档,然后保存。

    这将添加一个 if 块来测试骰子滚动的偶数。如果为 true,则 break 语句终止 while 循环,并处理其后的行。while 循环之后的两个行显示发生了多少次迭代以及那次迭代的骰子滚动值:

    let die;
    while (iterations <10){
     die = Math.floor(Math.random() * 6) + 1;
     if (die % 2 == 0){
      break;
     }
     iterations ++;
    }
    console.log("Number of iterations: ", iterations + 1);
    console.log("Die value: ", die);
    
  9. 通过在网络浏览器中打开控制台窗口并重新加载 while-statement.html 网页进行几次测试:

    Number of iterations:  1
    Die value:  2
    

while-loop 使用布尔表达式来确定它包含的代码是否发生了任何迭代。在这种情况下,如果迭代变量大于 10,则不会发生任何迭代。

for...in 语句

for...in 语句允许我们遍历对象数据类型。for 表达式中的变量持有对象名称值对中的一个名称,即对象的属性和方法名称。其语法如下:

for (variable in object){
   //Statement
   //Statement
}
for (variable in object)
   //Single statement

您可以使用 constvarlet 声明变量。

练习 3.12:编写并测试 for...in 循环

这个练习将 for...in 循环应用于现成的 location 对象和程序员创建的对象。您可以通过使用它们来访问对象名称和值。让我们开始吧:

  1. 在您的网络浏览器中打开 for-in-statement.html 文档。

  2. 使用您的网络浏览器打开开发者控制台窗口。

  3. 在您的代码编辑器中打开 for-in-statement.js 文档,将其全部内容替换为以下代码,然后保存:

    for (let name in location) {
     console.log(name);
    };
    

    这会迭代网络浏览器创建的 location 对象。

  4. 在您的网络浏览器中打开控制台窗口,并重新加载 for-in-statement.html 网页。

    以下输出显示了 location 对象的所有属性和方法名称:

    replace
    href
    ancestorOrigins
    origin
    protocol
    host
    hostname
    port
    pathname
    search
    hash
    assign
    reload
    toString
    
  5. 编辑 for-in-statement.js 文档,添加以下加粗文本,然后保存:

    for (let name in location) {
     console.log(name, ":", location[name]);
    };
    

    这将添加属性或方法的值。

  6. 在你的网页浏览器中打开控制台窗口,重新加载 for-in-statement.html 网页。假设网页是从本地文件文件夹打开的,而不是使用 http 或 https,则值可能会有所不同:

    replace : ƒ () { [native code] }
    href : file://PATH_TO/for-in-statement.html
    ancestorOrigins :DOMStringList {length: 0}
    origin : file://
    protocol :
    host :
    hostname :
    port :
    

    pathname : /PATH_TO/for-in-statement.html

    search :
    hash :
    assign : ƒ assign() { [native code] }
    reload : ƒ reload() { [native code] }
    toString : ƒ toString() { [native code] }
    
  7. 编辑 for-in-statement.js 文档,将其替换为以下代码,然后保存:

    var stopWatch = {
     elapsedTime: 0,
     resultsHistory: [],
     isTiming: true,
     isPaused: true,
     start: function(){console.log("start");},
     pause: function(){console.log("pause");},
     resume: function(){console.log("resume");},
     stop: function(){console.log("stop");}
    };
    
  8. 在下面添加以下代码,以便我们可以遍历对象:

    for (const name in stopWatch) {
     console.log(name, ":", stopWatch[name]);
    };
    
  9. 在你的网页浏览器中打开控制台窗口,重新加载 for-in-statement.html 网页。

    以下是在控制台窗口中的输出示例:

    elapsedTime : 0
    resultsHistory : []
    isTiming : true
    isPaused : true
    start : ƒ (){console.log("start");}
    pause : ƒ (){console.log("pause");}
    resume : ƒ (){console.log("resume");}
    stop : ƒ (){console.log("stop");}
    

当代码依赖于需要存在的特定属性或名称时,遍历对象的方法和属性可能会有所帮助。

for...of 语句

for...of 语句关注可迭代的对象。并非所有对象都是可迭代的。虽然我们不会介绍如何创建自己的可迭代对象,但有一些现成的可迭代对象,你可能发现 for∙∙∙of 块很有用。其语法如下:

for (variable of object){
   //Statement
   //Statement
}
for (variable of object)
   //Single statement

你可以使用 constvarlet 声明变量。

练习 3.13:编写一个 for...of 循环并测试它

本练习使用 for...of 语句,该语句是为可迭代对象设计的。你将学习一些对象可能不是可迭代对象,并生成错误。对于可迭代对象,我们使用数组和字符串。让我们开始吧:

  1. 在你的网页浏览器中打开 for-of-statement.html 文档。

  2. 使用你的网页浏览器打开开发者控制台窗口。

  3. 在你的代码编辑器中打开 for-of-statement.js 文档,将其全部内容替换为以下代码,然后保存:

    var stopWatch = {
     elapsedTime: 0,
     resultsHistory: [],
     isTiming: true,
     isPaused: true,
     start: function(){console.log("start");},
     pause: function(){console.log("pause");},
     resume: function(){console.log("resume");},
     stop: function(){console.log("stop");}
    };
    
  4. 在下面添加以下代码,以便我们可以遍历对象:

    for (let name of stopWatch) {
     console.log(name, ":", stopWatch[name]);
    };
    
  5. 在你的网页浏览器中打开控制台窗口,重新加载 for-of-statement.html 网页。

    将会发生错误。我们需要对对象进行编码,使其可迭代才能使其工作;然而,我们目前并没有学习如何做到这一点:

    Uncaught TypeError: stopWatch is not iterable
    
  6. 在你的代码编辑器中编辑 for-of-statement.js 文档,将其全部内容替换为以下代码,然后保存。

    字符串最终被证明是可迭代的:

    let anyString = 'abcxyz123';
    for (const value of anyString) {
     console.log(value);
    }
    
  7. 在你的网页浏览器中打开控制台窗口,重新加载 for-of-statement.html 网页:

    a
    b
    c
    x
    y
    z
    1
    2
    3
    
  8. 在你的代码编辑器中编辑 for-of-statement.js 文档,按照以下代码中的加粗部分进行更改,然后保存。

    字符串最终被证明是可迭代的:

    let anyString = 'abcxyz123';
    /*
    for (let value of anyString) {
     console.log(value);
    }
    */
    for (var i = 0; i<anyString.length; i++) {
     console.log(anyString.charAt(i));
    }
    
  9. 在你的网页浏览器中打开控制台窗口,重新加载 for-of-statement.html 网页。你将得到相同的结果。for of 循环的优势在于它更加简洁:

    a
    b
    c
    x
    y
    z
    1
    2
    3
    
  10. 在你的代码编辑器中编辑 for-of-statement.js 文档,将其全部内容替换为以下代码,然后保存。

    数组是可迭代的:

    let bowlingScores = [150, 160, 144, 190, 210, 185];
    for (const value of bowlingScores) {
     console.log(value);
    }
    
  11. 在你的网络浏览器中重新加载for-of-statement.html网页,同时打开console窗口:

    150
    160
    144
    190
    210
    185
    
  12. 在你的代码编辑器中编辑for-of-statement.js文档,按照以下代码中加粗的部分进行更改,然后保存它。

    一个数组是可迭代的:

    let bowlingScores = [150, 160, 144, 190, 210, 185];
    /*
    for (const value of bowlingScores) {
     console.log(value);
    }
    */
    for (var i = 0; i<bowlingScores.length; i++) {
     console.log(bowlingScores[i]);
    }
    
  13. 在你的网络浏览器中重新加载for-of-statement.html网页,同时打开控制台窗口。你将得到相同的结果:

    150
    160
    144
    190
    210
    185
    

当代码依赖于需要存在的特定属性或名称以使其工作的时候,遍历对象的方法和属性可能会有所帮助。

continue语句

continue语句用于在循环或标记循环的当前迭代中停止执行,并开始执行下一个循环迭代。然后loop语句确定是否应该发生另一个迭代。语法如下:

continue
continue label

第二种语法用于在标记语句块内使用。我们将在本章后面了解更多关于标记语句的内容。

标记语句

Labeled语句用于创建循环流程和条件流程。它命名block语句或loop语句。语法如下:

label : {
   //Statement
   //Statement
}
label : loop statement

loop语句被命名时,语句将在引用该标签的块内处理,直到遇到break语句或continue语句。

当遇到break语句时,程序流程将继续在由break语句引用的标记语句块之后的行上继续。如果遇到continue语句,程序流程将继续在由continue语句引用的块的第一行上继续。continue语句要求标记语句是一个循环。break语句和continue语句都必须出现在它们引用的标记语句块内。它们不能出现在它们引用的标记语句块之外。它们可以出现在嵌套的标记块中,并引用外部标记块。标记语句较少使用,因为它们容易造成混乱或难以理解的程序流程。

注意

避免或找到方法从代码中消除所有标记语句是一种良好的实践。条件语句和将代码划分为函数或对象方法是标记语句的替代方案。

让我们看看使用标记循环语句的一个示例。循环标记了for语句,它运行 10 次迭代。每次迭代生成一个从1 到 12的随机数。如果数字是偶数,则continue语句从for语句的开始处开始:

console.log("Odd number count started!");
let oddsCount = 0;
odd_number:
 for (let i = 1; i<= 10; i++){
  console.log("Iteration:", i);
  var number = Math.floor(Math.random() * 12) + 1;
  if (number % 2 == 0){
   continue odd_number;
  }
  oddsCount ++;
  console.log("Number:", number);
 }
console.log(oddsCount + " odd numbers found!");

上述代码片段的输出如下:

Odd number count started!
Iteration: 1
Iteration: 2
Iteration: 3
Iteration: 4
Number: 7
Iteration: 5
Iteration: 6
Number: 5
Iteration: 7
Iteration: 8
Iteration: 9
Number: 3
3 odd numbers found!

可以通过更好地使用if语句来消除标签,以实现相同的结果:

console.log("Odd number count started!");
let oddsCount = 0;
for (let i = 1; i<= 10; i++){
 console.log("Iteration:", i);
 var number = Math.floor(Math.random() * 12) + 1;
 if (number % 2 != 0){
  oddsCount ++;
  console.log("Number:", number);
 }
}
console.log(oddsCount + " odd numbers found!");

输出相同,但值不同:

Odd number count started!
Iteration: 1
Iteration: 2
Number: 9
Iteration: 3
Number: 5
Iteration: 4
Iteration: 5
Number: 5
Iteration: 6
Iteration: 7
Number: 9
Iteration: 8
Iteration: 9
Number: 1
Iteration: 10
Number: 7
6 odd numbers found!

编写和调用函数

函数是编写 JavaScript 程序的基本构建块。函数是一组可以可选接收数据输入并提供数据输出的语句。函数中的语句在函数被调用之前不会使用。调用函数也称为调用函数。

定义函数

JavaScript 提供了几种定义函数的方法。我们将探讨函数声明、函数表达式和箭头函数表达式作为定义函数的方式。

函数声明

你可以将函数定义为语句。这被称为函数定义或声明。其语法如下:

function name(optional parameter list) {
   //Statements
   //Optional return statement
}

它以函数关键字开始一行代码。它后面跟着一个名称。名称是函数在其他代码中如何出现的方式。逗号分隔的参数列表是函数输入值的名称。参数是函数内部的变量。它们被括号包围。然后是包含代码的代码块。一旦声明了函数,它就可以通过单独的一行代码或表达式来调用。在其用作表达式的场合,函数通常返回数据。

当一个 JavaScript 函数被声明为一个语句时,它可以通过出现在它之前的语句来调用。这被称为提升。

让我们看看定义和调用函数声明的例子。这个例子没有参数,返回单次掷骰子的值:

function getDiceRollValue(){
  return Math.floor(Math.random() * 6) + 1;
}

由于它返回一个值,我们可以将其用作表达式。以下是一个例子,其中它在加法表达式中使用,以获取两个骰子的滚动值:

var rollValue = getDiceRollValue() + getDiceRollValue();

我们可以通过创建一个参数来改进函数,使其返回一组指定数量的骰子的值。在这里,参数是掷骰子的数量:

function getDiceRoll(numberOfDice){
 var rollValue = 0;
 for (let i = 1; i<= numberOfDice; i++){
  rollValue += Math.floor(Math.random() * 6) + 1;
 }
 return rollValue;
}

调用模拟两次掷骰子的改进表达式只需要我们传递参数值。在这个例子中,参数被表示为一个数字字面量:

var rollValue = getDiceRollValue(2);

在这个例子中,参数被表示为一个变量:

var numberOfDice = 2;
var rollValue = getDiceRollValue(numberOfDice);

参数可以是可选的,否则可能会传递错误的数据。因此,函数通常有代码来验证参数或提供默认值。在这个例子中,JavaScript 内置函数 parseInt 的参数被用来将参数转换为整数。其结果使用内置函数 isNaN 进行测试,该函数在数字不是数字或数字小于 1 时返回 truefalse。如果这些中的任何一个是真的,则参数值被设置为 1。如果不是,则提供的数字被传递:

function getDiceRoll(numberOfDice){
 numberOfDice = (isNaN(parseInt(numberOfDice)) || numberOfDice< 1) ?1 :numberOfDice;
 var rollValue = 0;
 for (let i = 1; i<= numberOfDice; i++){
  rollValue += Math.floor(Math.random() * 6) + 1;
 }
 return rollValue;
}

现在,函数总是返回一个骰子的滚动值,无论参数是否错误或没有参数。在这两个例子中,都返回了一个骰子的滚动值:

var rollValue = getDiceRoll();
var rollValue = getDiceRoll("BOGUS");

练习 3.14:将函数作为语句编写并调用它

本练习将定义一个函数作为语句,然后使用它。我们将创建的函数将接受一个参数并返回一个数组。如果参数可以验证为数字,则数组中的每个项目都有一个从 0 索引项开始的数字,该数字代表个位值,1 索引项代表十位值,依此类推。

我们将在本练习中使用 console 对象的 log 方法。记住,方法是一个属于对象的函数,因此它可以有参数。log 方法可以接受无限数量的参数。让我们开始吧:

  1. 在你的网页浏览器中打开 function-declare.html 文档。

  2. 使用你的网页浏览器打开网页开发者控制台窗口。

  3. 在你的代码编辑器中打开 function-declare.js 文档,将其全部内容替换为以下代码,然后保存。

    这声明了函数。它的名字是 getDigitsOfNumber。它有一个名为 num 的参数。它返回 digits 数组:

    function getDigitsOfNumber(num){
     var digits = [];
     console.log("num:", num);
     return digits;
    }
    
  4. 在你的网页浏览器中打开 function-declare.html 网页,并确保控制台窗口已打开。

    没有输出,因为函数没有被调用。

  5. 编辑 function-declare.js 文档,将以下加粗文本添加到文件末尾,然后保存。

    在这个例子中,函数被表达出来而没有被调用。调用一个函数需要在函数名周围添加括号:

     return digits;
    } 
    console.log("getDigitsOfNumber:", getDigitsOfNumber);
    
  6. 在你的网页浏览器中打开 function-declare.html 网页,并确保控制台窗口已打开。

    函数被当作数据来处理并显示:

    getDigitsOfNumber: ƒ getDigitsOfNumber(num){
     var digits = [];
     console.log("num:", num);
     return digits;
    }
    
  7. 编辑 function-declare.js 文档,使用以下加粗文本更新文件末尾,然后保存。

    这在赋值语句中调用函数以创建 test456 变量:

     return digits;
    }
    var test456 = getDigitsOfNumber(456);
    console.log("test456:", test456);
    

    这将添加属性或方法的值。

  8. 在你的网页浏览器中打开 function-declare.html 网页,并确保控制台窗口已打开。输出第一行显示了函数被调用时显示的 num 参数。第二行输出是 test456 变量被设置为函数返回的空数组:

    num: 456
    test456: =>[]
    
  9. 编辑 function-declare.js 文档,在文件开头添加以下加粗文本,然后保存。这显示了在声明之前调用函数。这演示了提升(hoisting)。

    var test123 = getDigitsOfNumber(123);
    console.log("test123:", test123);
    function getDigitsOfNumber_1(num){
    
  10. 在你的网页浏览器中打开 function-declare.html 网页,并确保控制台窗口已打开。这些是在函数声明前后调用的结果:

    num: 123
    test123: =>[]
    num: 456
    test456: =>[]
    
  11. 编辑 function-declare.js 文档,使用以下加粗文本更新它,然后保存。

    函数的第二行将任何负数转换为正数,并截断任何小数。if语句检查以确保num参数包含一个数字。while循环会一直重复,直到num参数变为零。在循环内部,通过除以 10 并使用余数将个位值添加到digits数组中。然后,从num参数中移除个位值:

    function getDigitsOfNumber(num){
     var digits = [];
     num = Math.floor(Math.abs(num));
     if(!isNaN(num)){
      while(num != 0) {
       digits.push(num % 10);
       num = Math.floor(num / 10);
      }
     }
     return digits;
    }
    
  12. 在控制台窗口打开的情况下,使用你的网络浏览器重新加载function-declare.html网页。

    每个数组显示了测试值数字拆分为数组。零数组索引有个位值,一数组索引有十位值,二索引位置有百位值:

    test123: =>(3) [3, 2, 1]
    test456: =>(3) [6, 5, 4]
    
  13. 编辑function-declare.js文档,将以下加粗文本添加到文件末尾,然后保存。你可以根据可能的输入和预期输出对函数进行各种测试。这里有一些供你尝试。

    没有使用中间变量来处理这些新行。函数可以在可以使用表达式的任何地方使用。在这些添加中,它被用作log方法参数的表达式:

    var test456 = getDigitsOfNumber(456);
    console.log("test456:", test456);
    console.log('5:', getDigitsOfNumber(5));
    console.log('4563:', getDigitsOfNumber(4563));
    console.log('123.654:', getDigitsOfNumber(123.654));
    console.log('-123.654:', getDigitsOfNumber(-123.654));
    console.log('"1000"', getDigitsOfNumber("1000"));
    console.log('"1,000"', getDigitsOfNumber("1,000"));
    console.log('"B37"', getDigitsOfNumber("B37"));
    console.log('"37B"', getDigitsOfNumber("37B"));
    
  14. 在控制台窗口打开的情况下,使用你的网络浏览器重新加载function-declare.html网页。

    这里是输出结果。预期结果应该是一个包含所有数字或空数组的数组。评估每个输出并验证这是否是结果。如果输出是空数组,确定原因:

    test123: =>(3) [3, 2, 1]
    test456: =>(3) [6, 5, 4]
    5: =>[5]
    4563: =>(4) [3, 6, 5, 4]
    123.654: =>(3) [3, 2, 1]
    -123.654: =>(3) [3, 2, 1]
    "1000" =>(4) [0, 0, 0, 1]
    "1,000" =>[]
    "B37" =>[]
    "37B" =>[]
    

在这个练习中,我们定义了一个接受一个参数并返回数组的函数。在这个练习中,我们使用了console对象的log方法。

函数表达式

在这个变体中,你可以将函数作为表达式的一部分来定义。函数没有名称。然而,由于函数是一种数据类型,它可以被分配给变量。然后,可以使用变量来调用函数。语法如下:

function(optional parameter list) {
   //Statements
   //Optional return statement
}

语法相同,只是不需要函数名称。作为表达式的 JavaScript 函数不能通过出现在它之前的语句来调用。

这里是一个定义和调用函数表达式的例子。

这个例子显示了函数作为赋值表达式的一部分。赋值表达式的右侧是没有名称的函数。左侧是变量:

var getDiceValue = function(){
 return Math.floor(Math.random() * 6) + 1;
}

在这种情况下,变量可以用来调用函数:

var rollValue = getDiceValue() + getDiceValue();

练习 3.15:将函数作为表达式编写并调用它

这个练习定义了一个作为表达式的函数,然后使用它。

函数从字符串中返回一个随机字符。字符串是函数的唯一参数。如果参数不是字符串或为空字符串,则返回空字符串。让我们开始吧:

  1. 在你的网络浏览器中打开function-expression.html文档。

  2. 使用你的网络浏览器打开开发者控制台窗口。

  3. 在你的代码编辑器中打开function-expression.js文档,将其全部内容替换为以下代码,然后保存。

    函数作为一个字面量值,被分配给getRandomStringCharacter变量。

    然后,变量将在控制台中显示。记住,除非你包含括号,否则函数不会被调用:

    var getRandomStringCharacter = function(source){
     var returnCharacter = '';
     console.log("source:", source);
     return returnCharacter;
    }
    console.log('getRandomStringCharacter', getRandomStringCharacter);
    
  4. 在你的网页浏览器中打开控制台窗口后,重新加载function-expression.html网页。实际函数显示出来了,但并没有按照预期调用:

    getRandomStringCharacter ƒ (source){
     var returnCharacter = '';
     console.log("source:", source);
     return returnCharacter;
    }
    
  5. 编辑function-expression.js文档,更新以下加粗文本的行,然后保存。

    现在,我们将调用该函数:

     return returnCharacter;
    }
    getRandomStringCharacter();
    getRandomStringCharacter("AEIOU");
    
  6. 在你的网页浏览器中打开控制台窗口后,重新加载function-expression.html网页。

    函数中的console.log语句显示每次调用时的source参数值。第一次调用没有传递任何参数。source参数的数据类型是未定义的:

    source: undefined
    source: AEIOU
    
  7. 编辑function-expression.js文档,在函数上方添加以下加粗文本,然后保存。

    现在,我们将在函数定义之前调用该函数:

    getRandomStringCharacter();
    var getRandomStringCharacter = function(source){
     var returnCharacter = '';
    
  8. 在你的网页浏览器中打开控制台窗口后,重新加载function-expression.html网页。

    你应该在控制台中看到一个错误。定义为表达式的函数不能在定义之前调用:

    Uncaught TypeError: getRandomStringCharacter is not a function
        at function-expression.js:1
    
  9. 编辑function-expression.js文档,更新以下加粗文本,然后保存。

    我们可以移除在定义之前调用函数的行,完成函数的编码。if块测试源参数是否未定义且包含字符。在if块中,Math.floor(Math.random() * source.length)表达式找到一个随机字符位置,其值从 0 到source参数的长度减 1。charAt字符串方法提取该位置的字符,它将被返回:

    var getRandomStringCharacter = function(source){
     if (source !=undefined &&source.length> 0){
      returnCharacter = source.charAt(Math.floor(Math.random() * source.length));
     }
     return returnCharacter;
    }
    

    这些行是一组对各种源值的测试。第一个没有传递参数。第二个、第三个和第四个传递了一个字符串。第五个传递了一个数字,最后一个传递了一个布尔值:

    console.log('():', getRandomStringCharacter());
    console.log('("AEIOU"):', getRandomStringCharacter('AEIOU'));
    console.log('("JavaScript"):', getRandomStringCharacter('JavaScript'));
    console.log('("124678"):', getRandomStringCharacter('124678'));
    console.log('(124678):', getRandomStringCharacter(124678));
    console.log('(true):', getRandomStringCharacter(true));
    
  10. 在你的网页浏览器中打开控制台窗口后,重新加载function-expression.html网页。

    函数的输出期望在源参数中有一个随机字符。如果它不为空,它将返回一个字符串,对于所有其他值将返回一个空字符串。重新加载网页几次以获取不同的测试结果:

    (): 
    ("AEIOU"): U
    ("124678"): 6
    ("JavaScript"): a
    (124678): 
    (true):
    

箭头函数表达式

箭头函数是在 ES6 中引入的。它们在表达式中定义的方式类似,例如在定义函数表达式时。它们提供了比定义函数表达式更简洁的语法选择。它们的调用方式没有区别。=> 符号是定义箭头函数的明显标志。此外,不使用 function 关键字。简洁的变体,没有函数体,可以返回带有或不带有 return 语句的表达式。这与函数表达式不同,函数表达式需要 return 语句来返回值。语法如下:

(optional parameter list) => {
   //Statements
   //Optional return statement
}
(optional parameter list) => //Expression or return statement

当只有一个参数被命名时,参数括号是可选的:

parameter => {
   //Statements
   //Optional return statement
}
parameter => //Expression or return statement

如果没有参数,则需要括号:

() => {
   //Statements
   //Optional return statement
}
() =>//Expression or return statement

JavaScript 箭头函数不能通过出现在其之前的语句来调用。

定义和调用箭头函数表达式

此示例展示了没有参数的单语句箭头函数。赋值右侧是未命名的函数。左侧是变量:

var getDiceValue = ()=> Math.floor(Math.random() * 6) + 1;

在这种情况下,可以使用变量来调用函数:

var rollValue = getDiceValue() + getDiceValue();

此示例展示了具有一个参数的多语句箭头函数。赋值右侧是未命名的函数。左侧是变量:

var getDiceRoll = (numberOfDice) => {
 numberOfDice = (isNaN(parseInt(numberOfDice)) || numberOfDice< 1) ?1 :numberOfDice;
 var rollValue = 0;
 for (let i = 1; i<= numberOfDice; i++){
  rollValue += Math.floor(Math.random() * 6) + 1;
 }
 return rollValue;
}

以下是对函数进行调用并传递参数的输出:

var rollValue = getDiceRoll(2);

练习 3.16:编写并调用箭头函数

此练习将向您展示如何将函数表达式转换为箭头函数。我们将使用的 JavaScript 文件已经包含该函数。让我们开始吧:

  1. 在您的网页浏览器中打开 function-arrow.html 文档。

  2. 使用您的网页浏览器打开开发者 console window

  3. 在您的网页浏览器中打开 function-arrow.html 网页,并确保 console window 已打开。

    第一个、倒数第二个和最后一个结果是空字符串。第二个、第三个和第四个结果显示了字符串中的一个随机字符:

    (): 
    ("AEIOU"): U
    ("124678"): 6
    ("JavaScript"): a
    (124678): 
    (true):
    
  4. 在您的代码编辑器中打开 function-arrow.js 文档,对加粗的行进行修改,然后保存:

    var getRandomStringCharacter = (source) => {
     var returnCharacter = '';
     if (source !=undefined &&source.length> 0){
      returnCharacter = source.charAt(Math.floor(Math.random() * source.length);
     }
     return returnCharacter;
    }
    
  5. 在您的网页浏览器中打开 function-arrow.html 网页,并确保 console window 已打开。

    结果是相同的。第一个、倒数第二个和最后一个结果是空字符串。第二个、第三个和第四个结果显示了字符串中的一个随机字符:

    (): 
    ("AEIOU"): I
    ("124678"): 2
    ("JavaScript"): J
    (124678): 
    (true):
    

响应用户输入事件和更新 DOM

JavaScript 用于与 DOM 交互。这包括响应用户点击按钮等 DOM 生成的事件。它还包括更新内容和 HTML 元素,例如显示通知消息。

DOM 中的元素是对象。JavaScript 提供的 document 对象包含元素对象。它还包含访问和更新元素的方法。

DOM HTML 元素对象

HTML 元素表示为对象。由于它们是对象,我们可以使用方法和属性来操作它们。这些属性和方法是从由网络浏览器提供的 DOM 对象层次结构中继承的,从名为Node的对象开始。例如,ol元素从以下 DOM 对象层次结构中共享方法和属性:

Node⇒Element⇒HTMLElement⇒HTMLOListElement

不必了解所有涉及的对象,但了解它们是有好处的。最好了解一些从所有这些对象中派生出来的属性和方法。以下是从 DOM 元素的上层继承的一些属性和方法:

  • innerHTML: 使用源元素,这是元素中包含的 HTML 和内容。

  • innerText: 使用源HTMLElement,这是元素的渲染文本。

  • addEventListener(): 使用源元素事件目标,这用于注册一个函数以响应事件,例如用户点击元素。

  • appendChild(): 使用源节点,这向父节点添加一个节点;例如,将li元素添加到ol元素的末尾,或将p元素添加到div元素的末尾。

在 DOM 中获取元素访问权限

以下是一些document对象,它们包含我们可以用来从 DOM 中获取一个或多个HTMLElement 对象的方法:

  • getElementById(element-id): 元素 ID 是元素的 ID 属性。返回一个HTMLElement对象。

  • getElementsByTagName(element-name): 元素名称是 HTML 元素的静态名称,例如body, div, p, footer, ol, 和 ul。这返回一个NodeList对象。NodeList对象类似于对象数组。

  • getElementsByClassName(css-class-name): CSS 类名称是元素的类属性。这返回一个NodeList对象。NodeList对象类似于对象数组。

  • querySelector(selectors): 选择器类似于在 CSS 中使用的选择器。这返回一个匹配的第一个元素的HTMLElement对象。

  • querySelectorAll(selectors): 选择器类似于在 CSS 中使用的选择器。这返回一个NodeList对象。NodeList对象类似于匹配的每个元素的数组。

  • createElement(tag name): 这为提供的 HTML 标签名称创建一个HTMLElement对象。

  • createTextNode(data): 这创建一个可以放置在 HTML 元素内部的Text对象,例如,在h1p元素内部。数据参数是一个字符串。

以下是一个使用document对象的getElementById方法来访问 DOM 元素的示例。这从一个具有id属性为user-id的元素 DOM 创建了一个对象:

let userIdEle = getElementById("user-id");

这是一个使用 document 对象的 getElementByTagName 方法来访问 DOM 元素的例子。这创建了一个代表文档中所有 div 元素的集合。需要进一步步骤来访问每个元素,例如使用循环:

let divEles = getElementByTagName("div");

这创建了一个代表文档中所有使用 notice 类的元素的集合。需要进一步步骤来访问每个元素,例如使用循环:

let noticeEles = getElementByClassName("notice");

这是一个使用 document 对象的 getElementByClassName 方法来访问 DOM 元素的例子。这创建了一个代表文档中所有使用 notice 类的元素的集合。需要进一步步骤来访问每个元素,例如使用循环:

let noticeEles = getElementByClassName("notice");

在 DOM 中创建元素和内容

您可能希望 JavaScript 向网页添加 HTML 元素和内容。这是通过更新 DOM 来完成的。document 对象有两个用于此的有用方法:

  • createElement(tag name): 为提供的 HTML 标签名创建一个 HTMLElement 对象。

  • createTextNode(data): 创建一个可以放置在 HTML 元素内部的文本对象,例如,在 h1p 元素内部。数据参数是一个字符串。

以下是一个使用 document 对象的 createElement 方法创建 li 元素的例子:

let liEle = document.createElement("li");

以下是一个使用 document 对象的 createTextNode 方法创建 Milk Moon 元素的例子:

let elementTextNode = document.createTextNode("Milk Moon");

将所有这些放在一起,我们可以将元素和文本节点添加到 DOM 中。考虑以下 HTML 列表,列出 11 月的满月名称:

<ul>
 <li>Flower Moon</li>
 <li>Planting Moon</li>
/ul>

假设我们想要将另一个 li 元素添加到牛奶月。为此,我们使用 document 对象的 createElement 方法创建一个 li 元素:

let liEle = document.createElement("li");

createElement 方法返回一个 HTMLElement 对象。它提供了 appendChild 方法,我们可以在这种情况下使用它。对于 appendChild 方法的参数,document 对象的 createTextNode 方法可以提供所需的文本节点:

liEle.appendChild(document.createTextNode("Milk Moon"));

最终生成的 DOM 如下所示:

<ul>
 <li>Flower Moon</li>
 <li>Planting Moon</li>
 <li>Milk Moon</li>
</ul>

让我们更进一步,并假设我们有一个包含满月名称的数组:

let mayMoons = [
 "Flower Moon",
 "Planting Moon",
 "Milk Moon"
];

现在,我们想要使用数组来填充具有 id 属性为 full-moonsul 元素:

<ul id ="full-moons">
 <li>Grass Moon</li>
 <li>Egg Moon</li>
 <li>Pink Moon</li>
</ul>

首先,你可能想要从 ul 元素中移除现有的 li 元素。你可以通过使用 document.getElementById 方法以及元素的 innerHTML 属性来完成这个操作:

let moonsEle = document.getElementById("full-moons");
moonsEle.innerHTML = "";

接下来,我们可以遍历数组,将 li 元素添加到月球名称中:

for (let i= 0; i<= mayMoons.length - 1; i++){
 let liEle = document.createElement("li");
 liEle.appendChild(document.createTextNode(mayMoons.length[i]));
 listEle.appendChild(liEle);
}

最终生成的 DOM 如下所示:

<ul id ="full-moons">
 li>Flower Moon</li>
 <li>Planting Moon</li>
 <li>Milk Moon</li>
</ul>

DOM 事件

事件是您可以提供给代码的消息,以便它可以处理它;例如,用户在 HTML 页面上点击按钮。文档模型对象使用 addEventListener 方法添加您的代码,以便在事件发生时进行处理。语法如下:

target.addEventListener(event-type, listener)

目标是一个具有 addEventListener 方法的对象。代表 DOM 中元素的对象具有此方法。

事件类型参数是事件的预定义名称。例如,click 是鼠标点击事件的名称。监听器是一个具有“监听”事件能力的对象。函数是具有“监听”事件能力的对象。用作事件监听器的函数有一个参数,即 Event 对象。

例如,使用函数字面量的点击事件 addEventListener 方法可以写成如下:

helpButtonEle.addEventListener("click", function(e){
 console.log("Something was clicked");
}

练习 3.17:使用 DOM 操作和事件

这个练习将接受来自网页的输入值,目的是猜测一个从 1 到 10 的数字。使用一个按钮来检查输入值是否与从 1 到 10 生成的随机数匹配。根据是否有匹配,网页上其他元素的 display 属性将切换以隐藏或显示该元素。同时,生成的数字也会显示在页面上。让我们开始吧:

  1. 在您的网页浏览器中打开 number-guess.html 文档。

  2. 使用您的网页浏览器打开开发者控制台窗口。

  3. 首先,我们可以从网页的 document 对象开始。

  4. 输入以 > 符号开头的行。控制台窗口将在以 符号开头的行上显示响应。

  5. 在您的代码编辑器中打开 number-guess.html 文档。

    让我们回顾一下在 JavaScript 中将要访问的一些元素。首先是 input 元素,用于输入猜测值。请注意,它的 id 属性值为 number-guessed。我们将使用 id 属性来获取我们在 JavaScript 中使用的所有元素:

    <input id="number-guessed" type="text" maxlength="2">
    

    接下来是 button 元素。其 id 属性值为 test-button

    <button id="test-button">Test Your Guess</button>
    

    接下来是 p 元素。其 id 属性值为 results-msg。这是所有结果消息的容器。它有一个 class 值为 hiddennumber-guess.css 文件将 hidden 类的 display 属性设置为 none

    .hidden{
     display:none;
    }
    

    当网页加载时,这个 p 元素不会显示。JavaScript 将隐藏或取消隐藏这个元素:

    <p id="results-msg"  class="hidden" …</p>
    

    p 元素内部有两个 span 元素,包含匹配或不匹配的猜测消息。它们也使用了 hidden 类。这是因为,如果它们的父元素被取消隐藏,这些元素将保持隐藏状态,直到代码确定要取消隐藏哪个。每个 span 元素都有一个 id 属性。JavaScript 将隐藏或取消隐藏这些 span 元素中的每一个:

    <span id="match-msg" class="hidden">Congratulations!</span><span id="no-match-msg" class="hidden">Sorry!</span>
    

    p 元素内部还有一个 span 元素,用于显示猜测的数字。JavaScript 将更新这个:

    <span id="number-to-guess"></span>
    
  6. 在您的代码编辑器中打开 number-guess.js 文档,将其所有内容替换为以下代码,然后保存。

    第一行使用 document 对象的 getElementByID 方法创建了一个具有 idtest-button 的元素对象。

    第二行添加了一个名为 testMatch 的函数,作为按钮点击事件的监听器。

    以下是 testMatch 函数和一条发送到控制台的消息,以便我们可以测试它:

    let testButtonEle = document.getElementById('test-button');
    testButtonEle.addEventListener('click', testMatch);
    function testMatch(e){
     console.log("Clicked!");
    }
    
  7. 在你的网络浏览器中重新加载 number-guess.html 网页,同时打开控制台窗口,并点击 测试你的猜测 按钮。

    你应该在控制台窗口中看到以下消息:

    Clicked!
    
  8. 编辑 number-guess.js 文档,使用粗体文本更新它,然后保存。

    在文件顶部,我们需要访问的 HTML 中的所有元素都已分配给一个变量:

    let resultsMsgEle = document.getElementById('results-msg');
    let matchedMsgEle = document.getElementById('match-msg');
    let noMatchMsgEle = document.getElementById('no-match-msg');
    let numberToGuessEle = document.getElementById('number-to-guess');
    let guessInputEle = document.getElementById('number-guessed');
    let testButtonEle = document.getElementById('test-button');
    

    接下来,添加 DOM 接口以从输入元素的 guessInputEle 对象中获取 value 属性。如果用户没有输入整数,parseInt JavaScript 内置函数将标记它不是数字。然后,只有当数字在 110(包括 110)之间时,if 语句表达式才是真的:

    function testMatch(e){
     let numberGuessed = parseInt(guessInputEle.value);
     if(!isNaN(numberGuessed) &&numberGuessed> 0 &&numberGuessed<= 10){
     }
    }
    

    if 语句块中,第一步是从 110 获取一个随机整数。然后,如果输入数字与生成的数字匹配,我们使用 if...else 语句块。

    目前,我们可以通过控制台输出进行测试:

     if(!isNaN(numberGuessed) &&numberGuessed> 0 &&numberGuessed<= 10){
      let numberToGuess = Math.floor(Math.random() * 10 + 1);
      if (numberGuessed == numberToGuess){
       console.log("MATCHED!");
      }else{
       console.log("NOT MATCHED!");
      }
      console.log("Number guessed:", numberGuessed);
      console.log("Number to match:", numberToGuess);
     }
    
  9. 在你的网络浏览器中重新加载 number-guess.html 网页,同时打开控制台窗口,输入一个从 110 的整数,然后多次点击 测试你的猜测 按钮。

    这里是两个测试结果:

    NOT MATCHED!
    Number guessed: 1
    Number to match: 9
    MATCHED!
    Number guessed: 1
    Number to match: 1
    

    尝试无效值,如字母。不应该有输出到控制台。

  10. 编辑 number-guess.js 文档,使用粗体文本更新它,然后保存。

    现在,我们可以添加更新 DOM 元素的步骤。首先,当按钮被点击时,所有结果元素都是隐藏的:

    function testMatch(e){
     matchedMsgEle.style.display = 'none';
     noMatchMsgEle.style.display = 'none';
     resultsMsgEle.style.display = 'none';
     let numberGuessed = parseInt(guessInputEle.value);
    

    首先,显示用于消息元素的隐藏容器。然后,根据是否有匹配项,显示显示该结果的元素。最后,在为该数字创建的元素中更新要猜测的数字:

     if(!isNaN(numberGuessed) &&numberGuessed> 0 &&numberGuessed<= 10){
      resultsMsgEle.style.display = 'block';
      let numberToGuess = Math.floor(Math.random() * 10 + 1);
      if (numberGuessed == numberToGuess){
       matchedMsgEle.style.display = 'inline';
      }else{
       noMatchMsgEle.style.display = 'inline';
      }
      numberToGuessEle.innerText = numberToGuess;
     }
    
  11. 在你的网络浏览器中重新加载 number-guess.html 网页,同时打开控制台窗口,并多次点击 测试你的猜测 按钮输入值。

    匹配输出的结果如下:

![图 3.13:匹配值图片 C14377_03_13.jpg

图 3.13:匹配值

不匹配输出的结果如下:

![图 3.14:不匹配值图片 C14377_03_14.jpg

图 3.14:不匹配值

无效输入输出的结果如下:

![图 3.15:无效输入图片 C14377_03_15.jpg

图 3.15:无效输入

调试

JavaScript 程序可能无法按预期工作。当这种情况发生时,通常被称为错误。

静默失败

观看你的网页的人不会看到任何错误消息,除非他们了解网络开发者控制台。这被称为静默失败方法。静默失败使网页免于向访客显示可能难以理解的错误消息。然而,当访客尝试与网页交互但没有发生任何操作且没有消息时,他们可能会感到困惑。

错误通常分为两大类:语法和逻辑:

  • 语法:语法错误是格式不正确的 JavaScript 代码。

  • 逻辑:当语法正确的代码没有按预期执行时,会发生逻辑错误。

语法错误

你的控制台窗口会显示语法错误,以便它们容易找到和纠正。以下是一个示例,展示了名为convert-celsius-fahrenheit.js的 JavaScript 文件第 25 行的错误:

图 3.16:控制台窗口中的语法错误

图 3.16:控制台窗口中的语法错误

错误代码包含描述和指向文件行号的链接。当你点击那个链接时,源代码文件将在窗口中打开,并显示相关的行,如下所示:

图 3.17:语法错误的源代码

图 3.17:语法错误的源代码

在这种情况下,错误报告了一个“意外的 else”标记。现在,你需要查看代码以找出语法错误的位置。在这种情况下,是在第 21 行的 if 语句后面缺少了一个{

现在,你可以修复源文件中的语法错误,然后重新加载页面:

图 3.18:加载时的语法错误

图 3.18:加载时的语法错误

语法错误出现在加载时。这意味着当 JavaScript 文件被浏览器加载时,语法错误被揭示出来。

然而,语法错误可以在运行时出现。这发生在代码执行时,而它不需要在加载时发生,例如在按钮点击时,如下面的截图所示:

图 3.19:网页上执行的代码

图 3.19:网页上执行的代码

这里是一个示例,展示了用户在网页上点击“转换”按钮后代码的执行情况,用户看不到任何错误。点击按钮时没有任何反应。如果浏览器控制台窗口是打开的,我们将看到那个引起问题的语法错误。

错误消息还包括一个调用堆栈。调用堆栈是一系列被调用的函数和方法,它们被用来到达错误消息中报告的行。这个调用堆栈显示了包含失败行的getFahrenheit函数。然后,它显示该函数是在分配给HTMLButtonElement对象的convertButtonClickEventHandler方法内部被调用的。注意,调用堆栈中的每个项目都会将你带到文件中的一个行。

我们从错误消息中的一部分链接开始,它打开源视图窗口并带你到第 38 行。错误行后面跟着一个注释,显示了正确的行。你可以看到这是一个简单的省略了赋值操作符的错误。现在必须修复源文件中的代码行,然后重新加载。然后再次点击“转换”按钮,以查看语法错误是否已修复。

逻辑错误

当代码在语法上正确但未按预期执行时,会发生逻辑错误。逻辑错误通常是由于数据和使用或计算不正确的值而引起的。

当 JavaScript 程序遇到逻辑错误时,它会停止执行剩余的代码语句。通常没有错误消息可以追踪。

这使得逻辑错误更难以解决,你希望使用调试工具来帮助解决它们。

调试

修复错误被称为调试。调试需要工具、技能和技术。它通常涉及纠正源代码。

使用console.log方法和在控制台窗口中显示值是我们可以使用的一种工具。这允许你在程序的某些点上查看值,以查看它们是否是预期的值。这种方法的一个缺点是,这要求你在源代码中放置console.log方法,最终需要作为最佳实践将其删除。另一个问题是,console.log方法的参数可能是潜在的错误。

另一个选择是使用调试器。顶部的桌面网络浏览器都有 JavaScript 调试器。

调试器

为了帮助解决逻辑错误,你通常需要一个调试器。调试器是一种允许你暂停程序、跟踪每个步骤并检查这些步骤的数据值的工具。大多数桌面网络浏览器都将其调试器内置在其网络开发者视图中。以下是一个 Chrome 网络浏览器开发者工具的调试器示例:

![图 3.20:为 Chrome 网络浏览器设置断点图片

图 3.20:为 Chrome 网络浏览器设置断点

它最重要的功能之一是设置断点。断点会暂停代码的执行。在这个例子中,第 34 行有一个断点。它不仅显示在“断点”面板中,还显示在源窗口中,行号上有符号。行号上的符号实际上是一个切换,用于设置或清除断点。当你有多个断点分散在代码中,并且需要启用或禁用它们而不必在源窗口中找到代码行时,“断点”面板非常有用。

一旦代码执行到达断点,你就可以通过将鼠标指针悬停在代码上检查表达式。还有一个窗口,它将所有数据值组织起来,以便检查。例如,在下面的屏幕截图中,guessedNumber变量显示为5

![图 3.21:调试器工具中组织的数据值图片

图 3.21:调试器工具中组织的数据值

一旦执行被暂停,你可以使用调试器菜单控制代码的执行:

![图 3.22:调试器菜单图片

图 3.22:调试器菜单

前四个选项是一个好的开始地方:

  • 摘要:第一个选项重新启动 JavaScript 代码的执行,直到结束或达到另一个断点。

  • 覆盖步骤:第二个选项不会进入函数,而是进入它调用的所有代码。这很有用,因为可能不仅有您编写的许多函数,还有编写的第三方函数,这些函数不需要逐个检查。

  • 进入步骤:第三个选项会进入一个函数,您可以在其中继续。您可以将其视为逐行执行。

  • 退出步骤:第四个选项是退出函数到调用它的行。

活动三.01:待办事项列表打乱程序

本活动将帮助您构建一个待办事项列表网页。我们将使用 JavaScript 来加载列表,并在加载后打乱列表。HTML 文件中已添加一个标签为“打乱”的按钮,并将ol元素分配了 ID,todo-list

本活动的概要步骤如下:

  1. 使用activity.js文件编写您的代码。如果您愿意,它包含您可能使用的编码提示注释。它还包括一个名为getNewShuffledArray的函数。

  2. 您需要从 JavaScript 中加载li元素todo项,然后允许用户随机化列表。您可以将活动分为两部分来处理。

    todo列表项中创建一个函数,使用ol元素和数组作为参数来更新 HTML DOM 列表项。该函数将删除之前的li元素,并遍历数组以添加具有数组参数中值的新的li元素。在继续之前进行测试。您可以在packt.live/2XcP1GU找到 HTML 文件。

  3. todo项中,并使用您之前编写的函数来更新ol元素的列表项。它还将使用getNewShuffledArray函数随机打乱数组并返回打乱后的数组。

本活动的输出如下:

图 3.23:待办事项列表打乱程序

图 3.23:待办事项列表打乱程序

注意

本活动的解决方案可在第 715 页找到。

摘要

JavaScript 编程是一种解决问题的努力。它严重依赖于数据和数据表达式。在本章开头,我们提到数据可以是人们的名字、温度、图像尺寸、磁盘存储量以及讨论组帖子上的总点赞数。数据可以是用户界面的值,例如屏幕坐标、大小、滚动值、颜色和字体。

一个 JavaScript 程序是一系列使用数据的步骤。程序从一个事件开始。事件可能是当网络浏览器完成网页加载时,鼠标事件,例如点击或鼠标悬停在屏幕上的某个位置,例如按钮或图像,或者当从请求 JavaScript 的 Web 服务器接收到一些数据时。

一旦程序开始,它将按顺序执行代码语句,并由ifswitchforwhile等流程控制语句指导。

代码被组织成称为函数的单元。函数包含可能需要在程序的不同部分重复使用但具有不同数据和不同结果的代码。函数可以接受数据作为输入值并返回一个结果;例如,以华氏度作为输入,以摄氏度作为输出。

网页的 JavaScript 程序通常处理 DOM。DOM 只是由网络浏览器创建的一个大对象。它由所有数据和函数组成

在尝试解决每一个编码问题之前,你可能发现其他程序员已经解决了许多常见问题,并将他们的代码以库和框架的形式提供给你使用。例如,你可以使用 JavaScript 和 DOM 来编写代码,通过滑动或淡入淡出用户界面元素来动画化它们。然而,如果有人已经解决了那个编码问题,你可能想使用他们的代码。在下一章中,我们将探讨一些流行的库和框架,它们为网页解决了广泛的问题。

第四章:4. JavaScript 库和框架

概述

到本章结束时,你将能够使用 JavaScript 框架和库执行不同的任务;使用 jQuery 演示事件处理;使用流行的 JavaScript 框架;列出使用框架的注意事项和禁忌;并构建一个库

在本章中,你将学习如何以及何时将你的源代码与外部软件结合。

简介

在前面的章节中,你学习了如何利用条件逻辑、循环和最常见的数据结构。这些构成了编写程序和构建复杂 JavaScript 应用程序的基础和要素。然而,构建实际的软件是一个固有的挑战性任务;仅仅关注业务逻辑更是如此。因此,作为开发者,我们经常依赖外部软件,使我们能够专注于与我们产品或业务最相关的源代码。这些软件通过简化特定任务和为我们抽象复杂性来实现这一点。这些外部软件组件就是我们所说的框架

以下是一些现代 JavaScript 框架可以为我们提供的任务:

  • 复杂或动态单页应用程序(SPA)的性能渲染

  • 管理客户端应用程序控制器和视图之间的持续数据流

  • 创建复杂的动画

  • 使用快速直观的服务器 API 进行创建

在我们深入探讨使用外部代码的原因和目的之前,我们需要明确“框架”和“库”这两个术语之间的区别。这将是下一节的主题。

框架与库的比较

库描述了一个执行特定任务的函数的外部集合。这些函数通过 API 使我们这些库的用户能够访问。一个有用的库是lodash,例如,它可以从数组中移除所有重复的值:

const duplicatedArray = [1,2,1,2,3];
const uniqueArray = lodash.uniq(duplicatedArray)
// => [1,2,3]

相反,框架是库的一种特定形式。它们是可重用的代码框架,为 JavaScript 应用程序构建基础。与扩展你代码的功能的库不同,框架可以独立存在,并通过你的源代码增强来创建你喜欢的应用程序。

一个流行的框架是Vue.js,我们可以如下使用它:

library-vue.js
1 // example.html
2 <div id="example">
3 <input :value="text" @input="update"/>
4 <div v-html="myOwnText"></div>
5 </div>
6 //————————————————————————————————
7 // example.js
8 new Vue({
9 el: '#example',
10 data: {
11 text: 'My first framework'
12 },
13  computed: {
14 myOwnText: function () {
15 return this.text
16 }
The full code is available at: https://packt.live/32MD4IN

如你所见,一般来说,框架比库更复杂。尽管如此,两者对软件开发同等重要。

尽管库和框架之间存在技术差异,我们将会将这些术语互换使用。在 JavaScript 世界中,描述外部源代码的另一个同义词是“包”。在 JS 资源中,你可能遇到的一个这样的包是Vanilla.js。我们将在下一节中对其进行探讨。

Vanilla.js

这个特定的框架遵循非正式惯例,在名称中包含 JavaScript 文件扩展名nameOfFramework.js。然而,vanilla.js 不是一个框架;它甚至不是一个库。提到vanilla.js的人正在谈论没有外部代码或工具的纯 JavaScript。这个名字在 JavaScript 社区中是一个流行的笑话,因为一些开发者和非开发者认为我们为构建的每一件事都需要使用框架。我们将在稍后讨论这不是原因。

流行 JavaScript 框架

我们刚刚了解了 lodash.js,这是一个帮助开发者处理数据结构的库;(例如,用于创建唯一的数组)以及 Vue.js,这是一个用于构建模块化和动态用户界面的框架。这些只是两个非常流行且广泛使用的 JS 框架/库的例子。除了这些,还有大量不断增长的第三方包可供选择。每个包都有助于解决一组特定的专业问题。

一些现代且常用的替代方案,支持创建浏览器应用程序,例如 React.js、Vue.js 和 Angular.js。其他帮助你在应用程序中存储和管理数据的库包括 MobX、VueX 和 Redux。

再次强调,其他人可以将源代码转换,使其支持旧版浏览器引擎,例如Babel,或者为你处理和操作时间,例如 moment.js。

然后,还有像 Express.js 或 Hapi 这样的框架,让你能够为 Node.js 创建简单、易于维护和性能良好的 REST API。

一些包使构建命令行界面CLIs)或桌面应用程序变得容易。

大多数 JavaScript 生态系统的构建和生产工具都作为库提供给社区。Webpack、Parcel 和 Gulp 就是这些工具中的几个。

可用的库并不都是同样流行或有用。它们的流行程度取决于几个关键事实:

  • 是否解决了许多开发者烦恼的问题

  • 它们的 API 定义和结构有多好

  • 他们的文档质量

  • 性能优化的水平

当你创建一个希望广为人知的包时,请记住这些。

永恒的 jQuery

一个已经存在超过十年的常青库是 jQuery。它几乎以某种方式触及了每个 Web 应用程序,并且属于每个构建浏览器应用程序的人的工具箱:

图 4.1:jQuery 文档

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_04_01.jpg)

图 4.1:jQuery 文档

jQuery不是第一个,但无疑是世界上第一个被开发者用来简化工作的 JavaScript 库之一。自从它首次发布以来,许多维护者和工程师都为使 jQuery 成为今天的样子做出了贡献——即现代互联网的一个强大和基本组成部分,提供了许多不同的功能。

jQuery 提供,但不仅限于提供以下功能:

  • DOM 操作

  • 事件处理

  • 动画效果和过渡

当我们在本章后面更详细地查看 jQuery 时,我们将看到如何做这些事情。

哪里可以找到外部代码以及如何使用

在将库包含到您的程序中时,有几种不同的方法。根据这些方法,我们从不同的地方获取包。

一种方法是复制库的源代码,并按我们的意愿处理它。从安全性的角度来看,这种方法是最安全的,因为我们完全控制软件,可以将其定制以适应我们的需求。然而,这样做,我们就放弃了兼容性和自动更新和补丁。大多数开源项目将它们的代码托管在 GitHub 或其他版本控制平台上。因此,访问和分叉包代码相当容易。一旦我们下载了源代码,我们就可以做任何我们想做的事情,使其与我们的软件一起工作。可能的解决方案包括将其托管在我们的云分发网络CDN)上,并从那里访问它,或者将其与我们的源代码捆绑在一起。

另一种方法是在运行时从客户端下载 CDN 上的包。专门托管 JavaScript 库的最受欢迎的 CDN 是courseds.com。它托管了数千个库,您可以在标记中包含它们,而无需担心存储位置或如何升级:

图 4.2:从 cdnjs.com 下载包

图 4.2:从 cdnjs.com 下载包

以下是如何在您的标记中包含 Vue.js 的示例:

// myApplicationMarkup.html
<html>
<script src="img/vue.js"></script>
<script type="text/javascript">
console.log("Vue was loaded: ", !!Vue)
// => Vue was loaded: true
</script>
</html>

注意

如果您在运行时从浏览器中加载包来包含它们,您必须注意脚本标签的顺序。它们是从上到下加载的。因此,如果您在先前的示例中交换了两个脚本标签,console.log 将打印出没有加载Vue.js,尽管最终它会加载。

之前的方法获得了巨大的流行度,并且由于近年来 JavaScript 生态系统的开发,现在已经成为最常见的方法。它涉及到Node.js 包管理器npm)。npm 是一个工具,正如其名称所暗示的,负责 Node.js 生态系统中的 JavaScript 包。npm 本身由三个部分组成:

  • npmjs.com网站,用于托管所有文档和包搜索

  • Node.js一起安装的 CLI

  • 存储并使所有模块可安装的注册表:

  • 图 4.3:NPM 网站

图 4.3:NPM 网站

使用 npm 需要在您的机器上安装一个Node.js版本,以及任何工具来打包您所有的 JavaScript 代码,使其在浏览器中可执行。

然后,您只需安装您在 npm 上找到的任何模块:

// in your terminal
$ npm install <package>

此命令将包存储在特定文件夹中,称为node_modules。此文件夹包含您安装的所有库的源代码,在构建时间,打包器将它们与您的应用程序连接起来。

所述的所有方法都是有效的,并且各有其首选的使用场景。然而,你很可能在 Node.js 生态系统中设置新项目时最常使用后者,因为模块和 npm 就是从那里自然产生的。尽管如此,了解如何在没有 npm 的情况下使用外部资源仍然很有用,当你想要比整个项目设置更舒适、更快捷时。因此,让我们进行一个练习,我们将把第三方库加载到我们的代码中。

练习 4.01:在您的代码中使用第三方库

如我们所发现的,使用外部软件,即库和框架,是一项极其有用的技能,因为它可以节省大量资源并帮助您构建高度功能的应用程序。在这个练习中,我们将自己查找并使用一个库。我们将使用 lodash 库来创建一个包含唯一值的数组。让我们开始吧:

  1. 创建一个新的 HTML 文件:

    <html>
    <head></head>
    </html>
    
  2. 查找最新版本的 lodash 的 CDN URL。为此,请导航到 cdnjs.com 并搜索 lodash,然后复制图中突出显示的 URL:![图 4.4:cdnjs.com 上的 lodash 搜索结果 img/C14377_04_04.jpg

    图 4.4:cdnjs.com 上的 lodash 搜索结果

  3. 要查看 lodash 文档,请导航到 lodash.com。在那里,你可以使用搜索栏来查找 "uniq" 函数:![图 4.5:lodash.com 的 uniq 函数文档 img/C14377_04_05.jpg

    图 4.5:lodash.com 的 uniq 函数文档

  4. 在脚本标签的 src 属性中加载 CDN URL。为此,粘贴你在 步骤 2 中复制的 URL:

    <html>
      <head>
        <script src="img/lodash.min.    js"></script>
      </head>
    </html>
    
  5. 创建另一个 script 标签,并使用 lodash 编写 JS 代码来创建一个包含 unique 值的数组 [1,5,5,2,6,7,2,1]

    <html>
      <head>
        <script src="img/lodash.min.    js"></script><script type=„text/javascript">
          // create an array with duplicated values
          const exampleArray = [1,5,5,2,6,7,2,1];
          // use lodash.uniq to make the array contain unique values
          const uniqueArray = _.uniq(exampleArray);
          // print the unique array to the console
          console.log(uniqueArray);
          // => [1,5,2,6,7]
        </script>
      </head>
    </html>
    
  6. 在浏览器中打开你的 HTML,包括 JavaScript,并验证你是否在浏览器开发工具控制台中创建了一个包含唯一值的数组:

  7. ![图 4.6:浏览器开发工具控制台中的唯一数组值 img/C14377_04_06.jpg

图 4.6:浏览器开发工具控制台中的唯一数组值

在这个练习中,我们使用了 lodash 库来创建一个包含唯一值的数组。

jQuery 与 Vanilla.js

在本章的 永恒的 jQuery 部分中,我们之前查看过 jQuery 以及它在 JavaScript 社区中的卓越地位。为了展示为什么库和框架,尤其是 jQuery,变得流行,我们将将其与 Vanilla.js(纯 JS)进行比较。

操作 DOM

如果我们想在纯 JavaScript 中淡出并删除一个元素,我们将编写冗长且不够全面的代码:

// Vanilla.js
const styles = document.querySelector('#example').style;
styles.opacity = 1;(function fade() {
styles.opacity -= .1;
styles.opacity< 0
? styles.display = "none"
: setTimeout(fade, 40)
})();

另一方面,使用 jQuery,我们可以在一行可理解的代码中完成所有这些操作:

// jQuery
$('#example').fadeOut();

发送 XHR 请求

现代网页和应用程序的一个基本功能是从远程服务器请求额外的资源或数据。每个浏览器都提供了执行这些所谓的 XHR 请求的接口。这些接口可以从 JavaScript 中使用。正如我们可以在以下代码示例中看到,与 vanilla.js 相比,jQuery 让我们能够编写干净且易于理解的代码:

// Vanilla.js
const request = new XMLHttpRequest();
request.open("POST", "/example/api", true);
request.onreadystatechange = function() {
if (request.readyState != 4 || request.status != 200) return;
console.log("Successful XHR!");
};
request.send("example=payload");

与前面的代码片段相比,在 jQuery 中调用服务器的代码更加清晰易读。它更易读,因为它非常清晰易懂,关于函数需要什么参数以及它将要做什么非常明确。让我们看看一个向/example/api URL 发送的POST Ajax 请求,带有指定的有效负载数据和当请求成功时被触发的函数:

// jQuery
$.ajax({
type: "POST",
url: "/example/api",
data: "example=payload",
success: function() {
console.log("Successful XHR!");
}
});

注意

jQuery 将自己分配给$变量。因此,在代码示例中,$.functionName可以替换为jquery.functionName

我们可以继续展示更多 jQuery 比原生 JS 更快达到目标的使用场景。相反,在接下来的练习中,我们将使用这个库,并从中获得一些第一手经验。具体来说,我们将编写代码来处理按钮点击事件,一次使用 jQuery,一次使用纯 JavaScript。

注意

所有现代主流浏览器中的开发者工具都已适应了$符号,但仅作为document.querySelector的包装器。

练习 4.02:使用 jQuery 处理点击事件

在这个练习中,我们将了解 jQuery 如何帮助我们响应当目标(在我们的例子中,是一个按钮)被点击时传播的事件。让我们开始吧:

  1. 创建一个新的 HTML 文件,包括一个具有 ID exampleButton的按钮标签。这个按钮将是我们的目标:

    <html>
      <body>
        <button id="exampleButton">Click me.</button>
      </body>
    </html>
    
  2. cdnjs.com上找到最新的 jQuery CDN URL。

  3. 阅读 jQuery 文档中的.on()函数(api.jquery.com/on/)![图 4.7:JQuery.com 上的.on()函数文档]

    img/C14377_04_07.jpg

    图 4.7:JQuery.com 上的.on()函数文档

  4. 加载 CDN URL:

    <html>
      <head>
        <script src="img/jquery.min.    js"></script>
      </head>
      <body>
        <button id="exampleButton">Click me.</button>
      </body>
    </html>
    
  5. 创建一个包含代码的脚本标签,当点击按钮时,将Hello World消息记录到控制台:

    <html>
      <head>
        <script src="img/jquery.min.    js"></script>
      </head>
      <body>
        <button id="exampleButton">Click me.</button>
        <script type="text/javascript">
          $('#exampleButton').on('click', function() {
            console.log('Hello World')
          })
        </script>
      </body>
    </html>
    
  6. 确保将script标签放置在按钮标签之后。

  7. 在你的浏览器中打开 HTML 文件,并打开开发者工具控制台。

  8. 点击点击我按钮,并验证它是否将Hello World打印到控制台:![图 4.8:使用 jQuery 点击事件输出 Hello World]

    img/C14377_04_08.jpg

图 4.8:使用 jQuery 点击事件输出 Hello World

在这个练习中,你使用 jQuery 处理了一个由浏览器在按钮点击时触发的事件。你实现的处理程序会在按下点击我按钮后立即将Hello World打印到浏览器的控制台。

你也看到了如何轻松地使用像 jQuery 这样的库来完成那些否则需要手动完成的工作。

然而,在 vanilla.js 中处理点击事件也不是特别困难,你将在下一个练习中看到。

练习 4.03:使用 Vanilla.js 处理相同的事件

与前一个练习相比,这个练习演示了如何使用纯 JavaScript 创建一个在点击事件上被触发的处理程序。让我们开始吧:

  1. 创建一个新的 HTML 文件,其中包含一个 ID 为exampleButton的按钮标签:

    <html>
      <body>
        <button id="exampleButton">Click me.</button>
        </body>
    </html>
    
  2. 创建一个包含 vanilla.js 代码的脚本标签,当点击按钮时,将Hello World消息记录到开发者工具控制台。addEventListener是浏览器为我们提供的原生 API。它接受eventTypehandlerFunction作为参数:

    <html>
      <body>
        <button id="exampleButton">Click me.</button>
        <script type="text/javascript">
          const button = document.querySelector('#exampleButton')
          button.addEventListener('click', function() {
            console.log('Hello World')
          })
        </script>
      </body>
    </html>
    

    再次确保将script标签放在按钮标签之后。

  3. 在你的浏览器中打开 HTML 文件,并打开开发者工具控制台。点击“Click me.”按钮,并验证它是否将“Hello World”打印到控制台:![图 4.9:使用 Vanilla.js 的“Hello World”输出

    ![img/C14377_04_09.jpg]

图 4.9:使用 Vanilla.js 的“Hello World”输出

在前两个练习中,我们向一个按钮添加了事件监听器。我们使用 jQuery 做了一次,其他时候没有使用外部代码,而是使用了浏览器为我们提供的原生 API。

使用 jQuery 进行 UI 动画

除了我们在“操作 DOM 和发送 XHR 请求”部分的代码示例中看到的 jQuery 用法,以及在练习 4.02:使用 jQuery 处理点击事件中,还有一个重要的功能是jQuery为我们提供的:用户界面(UI)动画。

动画有助于使网站更具吸引力,并且可能意味着用户在使用你的应用程序时享受的体验更佳。通常,对用户输入的反应会被动画化,以突出显示交互已被认可或发生了变化。例如,出现的元素可以动画化,或者输入字段内的占位符可以动画化。继续以下练习,自己实现前面的 UI 动画示例。

练习 4.04:在按钮点击时动画化“Peek-a-boo”

在这个练习中,你将基于你使用 jQuery 处理事件的知识来构建。然而,相关部分将是动画化页面上的一个元素。

每当点击“Peek…”按钮时,…a-boo`标题就会显示出来。让我们开始吧:

  1. 创建一个新的 HTML 文件,其中包含一个 ID 为Peek...的按钮标签、一个 ID 为…a-boo的标题,以及display: none样式属性:

    <html>
      <head></head>
      <body>
        <button id="peek">Peek...</button>
        <h1 id="aboo" style="display: none;">...a-boo</h1>
      </body>
    </html>
    
  2. 在脚本标签内加载最新的jQuery CDN URL,从cdnjs.com(见练习 2,使用 jQuery 处理点击事件,步骤 2):

    <html>
    <html>
      <head>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquer
        y.min.js"></script>
      </head>
      <body>
        <button id="peek">Peek...</button>
        <h1 id="aboo" style="display: none;">...a-boo</h1>
      </body>
    </html><head>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquer
        y.min.js"></script>
      </head>
      <body>
        <button id="peek">Peek...</button>
        <h1 id="aboo" style="display: none;">...a-boo</h1>
      </body>
    </html>
    
  3. 创建一个包含选择 peek 按钮并添加onClick事件监听器代码的脚本标签:

    <html>
      <head>
        <script src="img/jquery.min.    js"></script>
      </head>
      <body>
        <button id="peek">Peek...</button>
        <h1 id="aboo" style="display: none;">...a-boo</h1>
        <script type="text/javascript">
          const peekButton = $(‚#peek');
          peekButton.on('click', function() {});
        </script>
      </body>
    </html>
    
  4. 在新的脚本标签内,编写额外的代码来选择aboo标题,并使用jQuery.fadeToggle函数来动画化标题,使其淡入淡出:

    <html>
      <head>
        <script src="img/jquery.min.    js"></script>
      </head>
      <body>
        <button id="peek">Peek...</button>
        <h1 id="aboo" style="display: none;">...a-boo</h1>
        <script type="text/javascript">
          const peekButton = $('#peek');
          const abooHeadline = $('#aboo');
          peekButton.on('click', function() {
            abooHeadline.fadeToggle();
          });
        </script>
      </body>
    </html>
    
  5. 在你的浏览器中打开 HTML 页面,并点击peek按钮。

  6. 当你点击peek按钮时,你应该看到aboo标题淡入和淡出:图 4.10:使用 Click 按钮的动画输出

图 4.10:使用 Click 按钮的动画输出

在这个练习中,你使用了jQuery在浏览器中执行另一种类型的任务。UI 中的动画可以像我们的淡入淡出示例一样简单,但在构建游戏或创建 3D 动画时,它们也可以非常复杂。

到现在为止,你已经对 jQuery 以及其他库或框架能帮助你做什么有了概念。在下一节中,我们将探讨为什么以及何时放弃外部源代码可能更明智。

框架与核心 JavaScript

到目前为止,我们已经讨论了很多关于为什么、如何以及在什么情况下使用库的原因。但我们还没有讨论何时以及为什么不依赖它们可能是一个更好的主意。

首先,框架和库所能做的所有事情我们自己都可以做到。在商业环境中,或者为了提高开发速度,我们通常在面临“自制或购买”决策时选择购买。但有时,我们应该记住,将外部源代码添加到我们的程序中,甚至基于这些源代码构建,会增加我们拥有的源代码量。对于构建面向客户端的应用程序的 JavaScript 开发者来说,增加所需资源的整体大小尤其令人不快,因为我们应该优化性能(应用程序在客户端加载的速度)。一般来说,更多的 JavaScript 代码会导致以下情况:

  • 更长的下载时间

  • 更长的解析时间

  • 更晚的执行延迟

  • 可能阻止应用程序的渲染或可用性

尽管我们有复杂的优化算法,如摇树或死代码消除,这些算法有助于我们应对这些情况下的巨大包大小,但通常,最好的选择是亲自完成手头的任务。

另一个需要考虑的方面是我们应用程序的安全性。一些库或框架可能会打开我们无法控制的攻击向量,因为我们没有完全拥有或理解涉及的代码。然而,最受欢迎的库非常关注它们包的安全性,并且对于已知漏洞的补丁发布也非常迅速。

为了提供一个实际案例,说明库或框架可能对我们应用程序产生的负面影响,在接下来的两个练习中,我们将创建一个列表并将其渲染到屏幕上。第一个将使用外部库,而第二个将使用原生 JavaScript 编写。

练习 4.05:使用 React 渲染待办事项列表

在这个练习中,我们将显示一些列表标签作为假想todo列表的列表项。为此,我们将使用一个非常流行的库react.js及其互补的react-dom.js。让我们开始吧:

  1. 在 HTML body 内部创建一个带有 head 和具有root ID 的 div 标签的新空 HTML 文件:

    <html>
      <head></head>
      <body>
        <div id="root"></div>
      </body>
    </html>
    
  2. 前往 cdnjs.com 获取最新的 react.jsreact-dom.js CDN 网址,并将这些网址加载到 HTML 头部内的脚本标签中:

    <script src="img/react.production.min.js" charset="utf-8"></script>
    <script src="img/react-dom.production.min.js" charset="utf-8"></script>
    
  3. 使用 react.jsreact-dom 创建三个列表项并将它们渲染到 root div 元素中:

      <script type="text/javascript">
        const todoListItems = [
          'buy flowers',
          'tidy up',
          'go to the gym'
        ];
        const todoListElements = todoListItems.map(item =>
          React.createElement('li', null, item)
        );
        ReactDOM.render(todoListElements, document.getElementById('root'));
      </script>
    
  4. 打开浏览器中的 HTML 页面,确保你的 todo 列表项正确显示。

  5. 打开浏览器开发者工具的网络标签页,查看加载了多少千字节的 JavaScript:图 4.11:加载到网络标签页中的 JavaScript 的大小

图 4.11:加载到网络标签页中的 JavaScript 的大小

在这个练习中,你学习了在哪里找到以及如何使用 React.js。尽管在这个练习中你只是创建了一个小型的静态待办事项列表,React.js 允许你创建复杂、动态的用户界面,而无需担心通常需要创建此类用户界面的原生浏览器 API。然而,正如我们之前提到的,使用框架也有代价,以千字节来衡量。

接下来,你将学习如何在不使用 React 的情况下完成相同的任务。之后,你将能够理解在构建应用程序时千字节与复杂性的权衡。

练习 4.06:不使用库渲染待办事项列表

在上一个练习中,我们使用了流行的库 React.js,加载了超过 37 KB(ZIP 格式)以及几百字节的 HTML,包括脚本标签,以创建和渲染三个项目的列表。在这个练习中,我们将做同样的事情,但我们将利用浏览器已经提供的所有功能。让我们开始吧:

  1. 创建一个新的空 HTML 文件,并在 HTML 体的内部包含一个具有 root ID 的 div 标签:

    <html>
      <body>
        <div id="root"></div>
      </body>
    </html>
    
  2. 创建一个脚本标签,并编写一些 JS 代码来创建三个列表项并将它们渲染到 root div 元素中:

    index.html
    4 <script type="text/javascript">
    5  const todoListItems = [
    6    'buy flowers',
    7    'tidy up',
    8    'go to the gym'
    9   ];
    10  const rootElement = document.getElementById('root');
    11  const listFragment = document.createDocumentFragment();
    12  todoListItems.forEach(item => {
    13   const currentItemElement = document.createElement('li');
    14   currentItemElement.innerText = item;
    15   listFragment.appendChild(currentItemElement)
    16   });
    The full code is available at: https://packt.live/2QYfUxb
    
  3. 打开浏览器中的 HTML 页面,确保你的 todoListItems 正确显示。

  4. 打开浏览器开发者工具的网络标签页,查看加载了多少千字节的 JavaScript:图 4.12:不使用库加载 JavaScript 的下载大小

图 4.12:不使用库加载 JavaScript 的下载大小

完全没有,也就是说,0 KB 的额外 JavaScript。这使我们比使用 react.js 的方法在下载、解析和执行方面有 37 KB 的优势,同时实现了相同的功能。

当然,这是一个简化的例子。一般来说,现实世界中的应用程序比我们的练习更复杂。尽管如此,你可能会发现自己经常处于类似的情况,其中性能是一个需要考虑的因素,并且可以使用 vanilla.js 合理地完成这项任务。

我们已经详细讨论了库和框架是什么以及它们可以帮助我们做什么。为了真正理解库可能看起来是什么样的以及构建一个库实际上有多容易,我们将在接下来的练习中自己创建一个。

练习 4.07:自己创建一个库

我们库最初可能功能有限,但你可以投入一些精力,并根据需要扩展它。

HeadlineCreator.js 是我们库的名称。这是一个好名字,因为它已经暗示了它所做的事情;即创建标题。从技术角度讲,我们的库将执行以下操作:

  • 在当前浏览器标签的全局窗口对象上可访问,就像我们之前使用 jQuery 时看到的那样:window.headlineCreator

  • 提供一个名为 createHeadline 的单一方法。

  • 允许我们(通过 createHeadline 方法)使用给定的文本创建一个标题,并将其附加到给定的父元素。

  • 为我们处理错误处理,例如,如果我们忘记定义要显示的文本或为父元素传递了无效的选择器

  • 为了验证我们的库是否正常工作,并展示其用法的一个示例,我们将创建一个 HTML 页面,包括使用我们的 HeadlineCreator.js 库的 script 标签,除了创建库本身之外。

    让我们开始吧:

  1. 创建一个空的 HTML 文件,包含一个 head 标签、一个 body 标签,以及一个 ID 为 root 的 div 标签:

    <html>
      <head></head>
      <body>
        <div id="root"></div>
      </body>
    </html>
    
  2. script 标签内加载一个名为 headlineCreator.js 的本地 JS 文件:

    <html>
      <head>
        <script src="img/headlineCreator.js"></script>
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>
    
  3. 在同一目录中创建 headlineCreator.js 文件,该目录中创建了空的 HTML 文件。

  4. 在 JavaScript 文件内部,创建一个 IIFE 并将其分配给 headlineCreator 变量:

    // headlineCreator.js
    const headlineCreator = (function(){})();
    

    注意

    IIFE 代表“立即执行函数表达式”。这个名字听起来比实际要复杂。IIFE 是在定义的同时立即执行的功能。在框架或库中,它们的用例之一是防止与源代码中使用的变量发生名称冲突。这包括库。例如,在库中使用创建函数可能会导致奇怪的副作用,因为这是一个非常常见且模糊的名字。因此,库可能是一个与预期不同的不同函数。

  5. 在立即执行函数表达式(IIFE)内部,创建另一个函数并将其命名为 createHeadline。这个函数接受两个参数,textparentElementSelector

    // headlineCreator.js
    const headlineCreator = (() => {
     function createHeadline(text, parentElementSelector = '#root') {}
    })();
    
  6. 在 IIFE headlineCreator 内部添加一个 return 语句。这个 return 语句将返回一个对象,该对象有一个名为 createHeadline 的唯一键,其值与函数名相同(就像我们在上一步中创建的函数名一样):

    {}return {
    createHeadline: createHeadline
      };;
    
  7. 使用 DOM 查询扩展新函数,以查找作为 createHeadline 函数参数传递的 parentElementSelector 指定的元素。

  8. 将 DOM 查询的结果分配给一个名为 parentElement 的变量:

    {{const parentElement = document.querySelector(parentElementSelector);{e;;
    
  9. 接下来,创建一个 h1 标签元素,并将此元素的 innerText 属性设置为传递给函数的 text 参数:

    {{; const headlineToInsert = document.createElement('h1');
    headlineToInsert.innerText = text;}{e;;
    
  10. 最后,将创建的标题附加到我们存储在 parentElement 中的节点:

    {{;;;parentElement.appendChild(headlineToInsert);}{e;;
    
  11. 刷新你的 HTML 页面,并在开发者工具的控制台中调用window.headlineCreator.createHeadline函数,使用你想要的任何参数。然后,查看结果:图 4.13:控制台中的 window.headlineCreator.createHeadline 函数及其输出

    图 4.13:控制台中的 window.headlineCreator.createHeadline 函数及其输出

  12. 如果你愿意,你可以添加一些错误处理并返回新创建的标题元素,因为在这种情况下这被认为是良好的实践:

    headlineCreator.js
    1 // headlineCreator.js
    2 window.headlineCreator = (function() {
    3   function createHeadline(text, parentElementSelector = '#root') {
    4     const parentElement = 5 document.querySelector(parentElementSelector);
    5    if (!text) {
    6       throw new Error('You forgot to pass the "text" parameter');
    7     }
    8     if (!parentElement) {
    9       throw new Error(
    10         `There was no node found for the Selector: "${parentElementSelector}"`
    11       );
    12     }
    The full code is available at: https://packt.live/2OIR6q0
    
  13. 为了测试错误处理,只需调用headlineCreator函数而不传递文本参数:图 4.14:控制台中的错误信息

    图 4.14:控制台中的错误信息

  14. 最后但同样重要的是,在 HTML 文件中添加一个 script 标签,并从那里调用headlineCreator库,这样每次加载 HTML 时都会创建一个标题:

    <html>
      <head>
        <script src="img/headlineCreator.js" charset="utf-8"></script>
      </head>
      <body>
        <div id="root"></div>
        <script type="text/javascript">
          headlineCreator.createHeadline('This is the HeadlineCreator');
        </script>
      </body>
    </html>
    

    这将产生以下输出:

图 4.15:创建的 HeadlineCreator.js 库

图 4.15:创建的 HeadlineCreator.js 库

通过这个练习,你已经了解到库的内部工作原理不必复杂且难以理解。headlineCreator库允许用户创建并将标题添加到指定的父元素中。尽管这是一个简化和几乎微不足道的用例,但它展示了构建和使用库的基本过程——即存在一个程序性问题,找到解决它的方法,抽象复杂性,并通过库提供给他人。

活动 4.01:将动画添加到待办事项应用中

在这个活动中,你被要求使用前面章节中的活动之一来动画化你一直在构建的todo列表应用。你可以使用以下三个库中的任何一个来完成此操作:

  • jQuery

  • Velocity.js

  • Anime.js

当你点击 Shuffle 按钮时,生成的todo列表应该会动画化待办事项。你可以使用任何你想要的精确动画,但我建议你从一个简单的动画开始,比如淡入淡出待办事项。

活动的步骤如下:

选择一个框架。为了更好地理解如何使用它们,在网上搜索它们并查看它们的文档(jquery.com, velocityjs.org, animejs.com):

  1. 前往cdnjs.com获取 jQuery CDN URL。

  2. 使用 script 标签将库加载到现有 Todo-List-HTML 的 head 标签中。这将使你能够在代码中使用 jQuery。

  3. activity.js中,你需要更改todoEle变量。将其更改为 jQuery 元素。

  4. replaceListElements函数中,你现在可以使用 jQuery 为你提供的todoEle元素上的函数。

  5. 使用 jQuery 函数隐藏并清除元素内的内容。

  6. 在 for 循环内部,创建 liEle 列表项元素,设置文本内容,并将其追加到 listEle 列表元素中。

  7. 最后,缓慢地将新的排序好的 todo 列表,即 listEle,淡入。

  8. 现在,在浏览器中打开 HTML 并点击 Shuffle 按钮。待办事项列表应该淡出、打乱顺序,然后再次淡入。你应该看到预期的输出。

  9. 现在,我们将使用 Velocity.js 方法。访问 cdnjs.com 并获取 velocity.js CDN URL。

  10. 使用脚本标签将库加载到现有的 Todo-List-HTML 的 head 标签中。这将允许你在代码中使用 velocity.js

  11. replaceListElements 函数内部,你现在可以使用 Velocity.js 将列表元素 listEle 隐藏(通过将不透明度设置为 0),然后清空其内部元素。

  12. 要将列表元素淡入,使用 Velocity.js 动画 listEle 并将不透明度设置为 1。在 for 循环之后设置代码。

  13. 现在,在浏览器中打开 HTML 并点击 Shuffle 按钮。待办事项列表应该淡出、打乱顺序,然后再次淡入。

  14. 最后,使用 Animae 方法,访问 cdnjs.com 并获取 Anime.js CDN URL。

  15. 使用脚本标签将库加载到现有的 Todo-List-HTML 的 head 标签中。这将允许你在代码中使用 Anime.js

  16. replaceListElements 函数内部,你现在可以使用 Anime.js 将列表元素 listEle 移出视图(使用 translateX = -1000),然后清空其内部元素。

  17. 要显示重新打乱的待办事项列表,使用 Anime.js 动画 listEle 列表元素回到视图(translateX = 0)。在超时内这样做,以确保打乱操作已经完成。

    现在,在浏览器中打开 HTML 并点击 Shuffle 按钮。待办事项列表应该淡出、打乱顺序,然后再次淡入。它应该看起来如下:

    图 4.16:点击时动画待办事项列表

图 4.16:点击时动画待办事项列表

注意

本活动的解决方案可以在第 719 页找到。

摘要

在本章中,我们深入探索了 JavaScript 库的广阔世界。我们首先解释了库和框架这两个术语。从那里,我们查看了一些流行的框架以及它们能帮助我们做什么。本章中的练习展示了我们可以在哪里找到外部包以及如何使用它们。我们使用这些库所做的事情包括创建淡入淡出效果、从列表中删除重复条目以及将 DOM 元素渲染到文档中。我们还讨论了使用外部源代码的缺点。然而,本章最大的成就是创建了我们自己的库,这个库帮助我们创建标题。我们通过利用各种库来增强待办事项列表的 UI 效果来结束本章。

下一章将向我们展示如何在 JavaScript 中处理数据。我们将了解数据是如何表示的,它是如何被传递的,以及如何将特定类型转换为不同的类型。

第五章:5. 超越基础

概述

到本章结束时,您将能够识别 JavaScript 的可变和不可变类型之间的区别;自信地操作每个内置数据类型;将数据从一种类型转换为另一种类型;格式化数据类型以供展示;以及区分表达式和语句。

简介

在上一章中,您被带上了对 JavaScript、其运行时和历史的游览。通过高级概览,那一章应该已经让您对 JavaScript 是什么、它能做什么以及它在互联网软件开发行业中的普遍性有了大致的了解。

对于初学者来说,理解代码可能很困难。JavaScript 也不例外。它的灵活性、广泛的语言语法和不同的编码模式可能会让初学者感到难以应对。

本章将带您更接近于用 JavaScript 编写自己的软件应用程序。通过解释基础知识,您将能够不仅理解脚本做什么,而且如何使用 JavaScript 语法进行问题推理。

在本章中,您将仔细研究 JavaScript 的类型系统。所有编程语言都有类型系统。类型实际上决定了变量或函数参数中存储的数据类型。类型通常分为两类:原始类型和复杂数据类型

在 JavaScript 中,所有原始数据类型都是不可变的。这意味着值在内存中不能被改变。可以给变量分配新值,但存储在内存中的底层数据不能直接修改。这与 C++等语言的情况不同,在这些语言中,可以使用指针和辅助函数直接在内存中更改值。在 JavaScript 中,当从一个变量传递原始值到另一个变量时,数据会在内存中复制到新变量。因此,更新一个变量不会影响另一个变量。

复杂数据类型的工作方式不同。它们也被称为引用类型。引用类型包括Object类型及其所有派生类型,例如ArrayDateFunction。所有引用类型都是通过引用传递的,因此得名。因此,如果一个对象通过一个引用被修改,所有共享相同对象的其它引用也会看到它被更新,因为所有的引用都指向内存中的相同数据。

复杂数据类型简单来说就是比原始类型具有更多功能的类型。例如,Date值提供了额外的表示方式,而对象可以包含许多嵌套值,例如原始类型和其他复杂数据类型。

注意

函数类型在本章中不会详细解释,而是在介绍原型时将在后面的章节中介绍。

所有原始类型和现有的引用类型都被称为内置数据类型。每种类型都有一个相应的对象,该对象提供用于操作该类型的函数。这些函数可以通过将值作为参数传递给函数来应用于外部数据,或者可以通过将函数作为该类型的成员方法来调用。后者也适用于几个原始类型,尽管它们在 JavaScript 类型系统中不是作为字面对象存在的。然而,这是通过数据的语法上下文实现的。关于这一特性将在本章中进一步解释。

创建变量

变量创建是将值赋给符号的手段。在这种情况下,符号是数据的文本表示,类似于一个容器,可以用来在程序中移动数据。它也提高了代码的可读性。创建变量的方式有多种,包括对全局作用域的赋值或使用varletconst关键字。

关于引用的说明

即使在这个早期阶段,也必须强调 JavaScript 的引用功能可能会相当令人困惑。存在闭包原型全局局部内存栈变量赋值变体以及函数调用选项,这些都可能让经验丰富的程序员感到困惑。上述所有功能都支持 JavaScript 成为一个强大且灵活的编程语言,几乎可以挑战其他大多数平台用于任何目的。虽然这确实加深了 JavaScript 的学习曲线,但掌握这些概念可以带来极大的回报。

本章强调了数据引用的非常基础的知识,并试图尽可能不使问题复杂化。只讨论与全局级别数据相关的引用。

全局赋值

不使用varletconst来赋值变量将变量放入全局作用域。此值将在应用程序的任何地方都可以访问,除非该作用域中存在同名的变量。不使用前缀关键字重新声明相同的变量名将覆盖全局引用,即使它是在不同的作用域中赋值的。

在浏览器环境中全局声明相当于在全局 window 对象上声明一个字段。

使用var声明

在变量赋值前加上var关键字将变量放入函数作用域。这意味着变量只存在于赋值所在的函数中,而不在该函数之外。在全局作用域中使用var声明等同于不使用var关键字声明。

使用var重新声明变量,但在嵌套作用域中,不会覆盖外部作用域中同名的变量。

使用 var 关键字,变量可以在它们在同一作用域内使用之后进行作用域(声明)。这是由于变量提升。提升在 第四章,JavaScript 库和框架中进行了解释。

使用 let 声明

let 关键字的作用域更窄。虽然 var 被认为是函数作用域,但 let 关键字是块作用域。这意味着使用 var 创建的变量在整个函数作用域级别存在,而使用 let 声明的变量在块级别创建和使用,例如在 if 条件块或 for 循环中。

例如,使用 let,可以在 for 循环内临时覆盖变量,而不会改变外部函数中具有相同名称的变量。然而,如果使用 var,则外部变量将被更改:

var a=0;
for(var a in [0, 1]);
console.log( a ); // ==> a is now 1 (as modified by the loop)

在前面的示例中,for 循环中声明的变量与外部声明的符号匹配。因此,相同的变量引用被修改。然而,在下面的示例中,结果不同,因为 let 声明的变量仅存在于 for 循环的上下文中,这意味着相同名称的外部变量保持不变:

var a=0;
for(let a in [0, 1]);
console.log( a ); // ==> a is still 0 (not modified by the loop)

var 不同,let 声明的变量不会被提升。如果一个作用域使用 let 声明了一个变量,那么在 let 声明语句之前(在同一作用域或任何内部作用域)访问该变量将引发错误(这无论外部作用域中是否已创建了具有相同名称的变量):

glob=1; {glob=2; let glob=3;}  // ==> can't access lexical declaration `glob' before 
initialization
glob=1; {glob=2; var glob=3;} // ==> accepted syntax

使用 const 声明

const 关键字与 let 关键字具有相同的作用域和提升规则。与 const 的区别在于,它假定变量在其整个生命周期内不会改变。使用 const 允许 JavaScript 引擎在编译时进行某些优化,因为它期望在运行时数据保持不变。

在嵌套函数作用域中,可以创建具有相同名称的新变量赋值,但使用全局作用域规则将无法修改具有相同名称的变量。

注意

使用 varlet 声明变量,但未分配值,将导致变量包含 undefined。将在本章稍后介绍 undefined 值。

练习 5.01:变量和作用域

在这个练习中,我们将使用浏览器的 JavaScript 读取-评估-打印循环REPL)来实验变量赋值和作用域。让我们开始吧:

  1. 启动您的浏览器并打开开发者工具控制台。在 Chrome 中,您可以通过按 F12 键来完成此操作。

  2. 确保已选择 Console 选项卡:![图 5.1:控制台选项卡 图 C14377_05_01.jpg

    图 5.1:控制台选项卡

  3. prompt 中输入以下命令,每行输入后按 Enter 键:

    const i = 10;
    console.log(i);
    // ->    10
    

    console.log 命令将 i 的值写入控制台。

  4. 接下来,创建一个函数,该函数也初始化具有相同名称的变量,如下所示:

    const f = function() {
        var i = 20;
        console.log(i);
    };
    
  5. 调用函数以打印函数作用域内存在的变量。如果您然后打印全局变量,您将看到它没有被修改:

    f();
    // ->    20
    console.log(i);
    // ->    10
    
  6. 接下来,尝试使用 let 关键字:

    if (true) {
        let i = 15;
        console.log(i);
    }
    // ->   15
     console.log(i);
    // ->   10
    

    如您所见,let 赋值仅存在于 if 语句之后的代码块的生命周期内。

  7. 关闭浏览器标签页。打开一个新的标签页并再次打开控制台(否则,您将无法重新分配 i 作为变量)。现在,尝试使用 var。您将看到变量声明引发了错误,因为它与条件块外的 i 变量冲突:

    i = 10;
    if (true) {
        var i = 15;
        console.log(i);
    }
    // ->    15
    console.log(i);
    // ->    15
    

图 5.2:练习 5.01 输出

图 5.2:练习 5.01 输出

理解变量周围的范围对于正确执行您的应用程序以及最小化错误非常重要。在工作过程中,尽量在心中记住每个变量的位置和使用情况。利用第十三章中讨论的函数范式,JavaScript 编程范式,也将有助于缓解变量作用域中的任何差异。

识别变量的类型

到目前为止,您已经创建了变量并将它们的值输出到浏览器的控制台。然而,为了充分利用本章的内容,能够识别变量的内容将非常有帮助。JavaScript 被称为弱类型语言,因为一个变量可以在某一刻持有 string,然后又变成 integer

下一个。通过能够识别变量中存储的值的类型,您可以防止尝试处理预期为不同类型的值时发生错误。

typeof 关键字就是为了做这件事。通过在变量前加上 typeof 关键字,返回的值是表示为 string 的变量类型。

typeof 关键字使用以下类型映射进行评估:

图 5.3:类型和响应

图 5.3:类型和响应

null 类型评估为 "对象"。这种异常源于 JavaScript 的早期版本,其中数据类型被内部标记为整数值。对象类型被标记为 0,而 null 值存在为一个 null 指针(或 0x00 作为值)。由于这两个表达式相同,确定 null 的类型导致了与对象相同的类型。这种相同的异常至今仍然存在于 JavaScript 中。因此,在确定一个类型是否为对象时,我们还需要将其与 null 进行比较:

var value = [1, 2, 3]; // an array - which is also an object
if (typeof value === "object" && value != null) {
    console.log("value is an object");
}

练习 5.02:从变量中评估类型

在这个练习中,我们将创建一个函数,该函数输出传递给它的任何变量的类型。让我们开始吧:

  1. 在命令提示符中,输入以下行以声明函数签名:

    var printType = function(val) {
    

    此函数接受一个变量,该变量将被用作分析变量。

  2. 由于存在 Null 值的注意事项,你必须首先检查这一点。在这里,比较 valNull 并输出相应的信息。如果值确实是 Null,那么函数必须返回,以便不再进行比较:

      if (val === null) {
        console.log("Value is null");
        return;
      }
    

    在这里,你正在比较 valNull 并输出相应的信息。如果值确实是 Null,那么函数必须返回,以便不再进行比较。

  3. 如果值不是 Null,那么你可以安全地返回值的类型本身:

      console.log("Value is", typeof val);
    }
    

    console.log(...) 将输出传递给它的任何值,并将它们连接到同一行。在这里,你输出一个通用信息,但随后将其与变量的类型连接起来。由于不需要从这个函数传递任何值,并且没有更多的逻辑要执行,因此不需要返回语句来关闭函数。

  4. 为了测试这个函数,请在控制台中用不同的值执行它:

    printType(12);
    printType("I am a string");
    printType({});
    printType(null);
    

    上述代码将产生以下输出:

    Value is number
    Value is string
    Value is object
    Value is null
    

![图 5.4:练习 5.02 输出图 5.4:练习 5.02 输出

图 5.4:练习 5.02 输出

你刚刚创建的函数在自我检查方面相当轻量。它本质上使你能够确定传入值的通用类型,但它不足以区分对象类型,包括 JavaScript 的内置对象。无论你传入一个 Date 或一个 Array,你都会得到相同的输出。

你将在本模块的后面部分发现如何更彻底地确定数据类型。

固定类型

固定类型是没有值变化的类型。与数字不同,数字可以有任何数字组合,可选的负号(用于负数),小数点或科学记数法,固定类型始终是一个简单的值或值组。

在 JavaScript 中,可用的固定类型包括 nullundefinedBooleanstruefalse)。这些值是静态的,不能改变。如果一个变量包含这些值之一,那么它就严格等于该值本身。固定类型更多的是一种情况的表示,而不是实际的数据。例如,true 是真实性的固定表示,而 false 是虚假性的固定表示。这些值在现实世界中是不可量化的,但它们代表了软件直接处理的逻辑。

null 的值

在数学术语中,null 表示一个不存在的值。在 JavaScript 中,null 是一个静态值,用来表示没有值。在其他语言中,这相当于 nil 或 void。

null 是一个有用的值,用于取消引用变量或在没有值可以返回时从函数返回值。例如,一个函数可能从数组中返回一个对象,如果项目存在,但如果没有,则返回 null

未定义的值

undefined在许多方面与null相似,因此这两个值经常被误用。undefined是任何未分配值的变量的值。它也是函数返回的值,该函数没有使用return关键字显式返回值,以及它是从没有结果值的语句(一个没有结果的动作)返回的值。

当处理undefined时,你应该始终预期它,但绝不要将其分配给变量或从函数中显式返回。在这种情况下,你应该使用null

布尔值

布尔(Boolean)这个术语是以 19 世纪英国数学家和哲学家乔治·布尔(George Boole)的名字命名的。它用来表示truefalse这两个值。这些值可以分配给变量,并且与它们的值严格等价,就像null一样。

布尔值在 JavaScript 支持的所有类型中是独特的,因为它们可以与其他类型和表达式间接比较。例如,本书第三章“编程基础”中描述的逻辑运算符都会返回一个Boolean值。

布尔运算符

布尔运算符是当组合成表达式时返回Boolean值的运算符。大多数布尔运算符是“二元”运算符,接受两个值,每个值位于运算符的两侧。像其他运算符一样,每个值都可以是一个表达式,可以是任何值类型。由于布尔运算符本身形成表达式,因此可以用作其他布尔运算符的输入。

布尔运算符可以分为两类;即比较运算符和逻辑运算符。

比较运算符

比较运算符用于比较一个值或表达式的结果与另一个值。在这种情况下,运算符可以被视为一条规则。如果规则成功,则组合表达式的响应返回true。否则,它返回false

比较运算符包括以下符号:

![图 5.5:比较运算符及其描述]

![图片 C14377_05_05.jpg]

![图 5.5:比较运算符及其描述]

比较运算符通常用作 if 条件语句和 while 循环语句的条件参数。如果或当条件表达式返回true时,表达式的主体块将执行。

以下示例表达式都将返回值true

21 == 9+12;
false != true;
6 > 1;
5 >= 5;
"1" == 1;

如果你查看列表中的最后一个示例,你可能会有点惊讶。==运算符是一个“值比较运算符”。在显示的示例中,数值 1 和字符串值“1”被认为是相同的值。因此,作为“值比较”运算符的等价运算符将它们视为相等。

为了确定值是否属于同一类型以及是否具有相同的值,应使用“严格比较运算符”:

![图 5.6:等价运算符及其描述]

![图片 C14377_05_06.jpg]

图 5.6:相等运算符及其描述

逻辑运算符

逻辑运算符通常用于将 Boolean 表达式连接起来。例如,当比较一个 string 值的质量时,你可能希望如果 string 的长度大于一个值但小于另一个值时执行代码。为了做到这一点,你需要使用 && 运算符将两个比较表达式连接起来。在另一个条件下,你可能希望只有一个表达式为 true 时执行代码,在这种情况下,你会使用 || 运算符。

以下表格列出了每个逻辑运算符及其功能:

图 5.7:逻辑运算符及其描述

图 5.7:逻辑运算符及其描述

练习 5.03:奇数和偶数

在这个练习中,我们将处理一系列数字,并输出描述数字是奇数还是偶数的消息。

我们将使用函数来完成这个练习,这样你就可以尝试不同的起始值。让我们开始吧:

  1. 在命令提示符下,创建一个带有几个参数的 odd_or_even 函数:

    function odd_or_even(counter, last) {
    

    last 参数将是数值序列的上限值,而 counter 参数既是每个循环的起始值,也是当前索引变量。

  2. 接下来,使用 while 关键字创建你的循环。while 将在条件表达式为真时处理代码块。在这个练习中,你将简单地比较 counterlast 参数:

      while (counter <= last) {
    

    如果 counter 变量的值大于 last 参数,则 while 循环将退出,这也会退出函数。

  3. while 条件成立后,你现在可以开始描述每次迭代的计数器值。为此,你只需检查 counter 的值,并根据其内容给出适当的消息:

        if (counter % 2 == 0) { // is true if the remainder of 'counter / 2' is 
    equal to zero
          console.log(counter, "is an even number");
        } else {
          console.log(counter, "is an odd number");
        }
    
  4. 在关闭 while 循环块之前,现在将 counter 变量增加 1。如果你没有增加,while 循环的条件将始终为 true,循环将永远不会退出。此外,循环的每次迭代都会处理相同的内容,这不是你想要的结果:

      counter = counter + 1;
    
  5. 关闭 while 块和函数。由于我们对该函数的任何最终值不感兴趣,因此不需要从这个函数中返回任何内容:

      }
    }
    
  6. 现在,按照要求传递 counter 值和 last 值来执行函数。输出应该准确地描述从 counterlast 的所有数字,包括 last 本身。

    这是输出:

    odd_or_even(1, 5);
    //   1 "is an odd number"
    //   2 "is an even number"
    //   3 "is an odd number"
    //   4 "is an even number"
    //   5 "is an odd number"
    

图 5.8:练习 5.03 输出

图 5.8:练习 5.03 输出

尝试在调用函数时更改传递的参数。但是,请确保将 counter 的值保持在小于或等于 last 参数的范围内,否则 while 循环将不会执行。

测试值的真伪

当用 JavaScript 编写程序时,你将经常需要比较值,通常是在处理条件时。通常,值会与其他值进行比较,但你也可能需要检查值的真值。

测试真值可以意味着许多事情:

  • 是否存在值?

  • 数组中是否有任何项?

  • 字符串的长度是否大于 0?

  • 传递的表达式返回true吗?

JavaScript 提供了一种方法,可以将单个值传递给条件语句以测试其真值。然而,这有时可能会引起混淆。例如,检查以下示例:

if (0) console.log("reached");  // doesn't succeed
console.log( 0 == false ); // prints true
console.log( 0 === false ); // prints false

如果条件是真值,则执行if语句的主体。在前面的代码的第一个例子中,数值零被视为假值。正如第二个和第三个例子所示,false等于数值零,但只是非严格相等。然而,在第三个例子中,数值零并不严格等同于false。这是因为false值和假值之间有一个区别。false值始终是false,但假值可能是几个值之一,包括以下内容:

  • false

  • undefined

  • null

  • -0+0NaN

  • 一个空字符串

如果值不在前面的列表中,则被认为是真值。

NOT 运算符

!NOT运算符相当独特。它被认为是一个“一元”运算符,因为它只接受它右侧的一个值。通过使用NOT运算符,你实际上是否定了它前面的值。以下是一个例子:

var falseValue = !true;

在前面的例子中,falseValue变量将包含一个false的值。

NOT运算符的一个非常有用的特性是“双重 NOT”。这是指两个NOT运算符组合起来双重否定一个表达式;一个真表达式被否定为false,然后又回到true,而一个false表达式被否定为true,然后又回到false

当处理真值或假值表达式时,使用双重 NOT运算符会改变这些表达式的结果值,使其成为实际的布尔值。以下是一个例子:

if (!!1 === true) {
  console.log("this code will execute");
}

布尔运算符优先级

所有运算符都有一个执行顺序,称为“优先级”。这种优先级在数学中也很明显,它是一种确保表达式以可预测的方式执行的手段。

考虑以下代码:

if (true || false && false)

前面的例子可以有两种不同的解读方式。这是第一种解读方式:

if ((true || false) && false)

这是第二种解读方式:

if (true || (false && false))

如果你从左到右遵循代码,就像前面的第一个例子一样,它将返回false,因为&&运算符是最后执行的。在那里,代码将简化为以下内容:

   true || false && false
= true && false
= false

然而,第二种解释会产生不同的结果:

   true || false && false
= true || false
= true

为了防止这种歧义,存在运算符优先级规则。优先级适用于 JavaScript 语言中的所有运算符,但在这里我们只列出适用于Boolean表达式的那些运算符:

![图 5.9:布尔运算符及其结合性图片

图 5.9:布尔运算符及其关联性

在前面的表中,最上面的行具有最高的优先级,因此首先评估,而最下面的行具有最低的优先级,最后评估。

布尔运算符关联性

在前面的表中,每个运算符都被赋予了关联性描述。关联性与表达式的执行方向有关。大多数运算符具有“从左到右”的关联性,这意味着左侧表达式会在右侧表达式之前执行。然而,NOT 运算符首先执行其右侧表达式。

关联性可能非常重要,尤其是在表达式中发生副作用时。在以下示例中,位于 || 运算符两边的表达式记录参数并返回它:

function logAndReturn( value ) {
  console.log( "logAndReturn: " +value );
  return value;
}
if ( logAndReturn (true) || logAndReturn (false)) {
  console.log("|| operator returned truthy.");
}

当执行时,如果 log_and_return 函数返回一个真值,那么只有第一次执行会发生,因此只有那个调用使用 log_and_return 记录一条消息:与传入的值连接。由于 || 运算符是左到右关联的,如果左侧返回 true,则整个表达式被认为是真值。因此,右侧永远不会执行。对于这个特定的运算符,右侧只有在左侧为假时才会执行。这种行为也称为短路。

由于 logAndReturn 的副作用仅仅是记录值,这为调试提供了一个有用的工具。然而,考虑一个接收对象作为参数、修改它然后返回值的函数:

// Following two variables are set to "anonymous" (simple) objects,
// each with two fields, 'name' and 'happy', set to initial values (both sad)
var john= {name: "John", happy: false};
var lucy= {name: "Lucy", happy: false};
function make_happy( person ) {
  console.log("Making " +person.name+ " happy.");
  person.happy= true;
  return true;
}
if (make_happy(john) || make_happy(lucy)) {
  console.log("John is happy: " +john.happy+ ", Lucy is happy: " +lucy.happy);
}

这两个对象遵循相同的结构,make_happy 函数可能可以与任一对象一起工作。然而,当调用条件时,只有 john 会被更新,因为条件表达式中的 || 条件在其左侧满足。

右侧永远不会执行。因此,如果代码依赖于稍后修改两个对象,它将失败。

对于 && 运算符,同样的警告也是适用的。由于 && 运算符表达式只有在两边都为真时才被认为是 true,因此只有当左侧执行返回 true 时,两边才会执行。

对于 || 运算符的关联执行规则在处理变量时特别有用。在某些情况下,如果变量尚未包含值,则最好为其分配一个默认值。在这种情况下,使用 || 运算符可以轻松完成这项任务:

distanceLimit = distanceLimit || 5;

如果变量已经包含值,则它将保持该值。但是,如果其值为 nullundefined 或其他假值,则它将被分配值为 5。

类似地,如果你希望在一个先前的变量为真时执行一个函数,使用 && 运算符是很好的选择:

items.length && processItems(items);

练习 5.04:免费送货资格验证

在这个练习中,我们将创建一个函数,用于确定杂货店的客户是否有资格享受免费送货上门服务。商店只为位于商店 5 英里范围内的客户送货。为了让这个练习更有趣,商店最近决定为位于商店 10 英里范围内的客户提供免费送货服务,但前提是这些客户必须拥有其忠诚度计划的活跃会员资格。此外,如果客户位于商店 1 英里以内,无论其会员状态如何,都不符合免费送货上门的资格。让我们开始吧:

  1. 定义你的函数签名。该函数应接受客户家与商店的距离以及他们的会员状态:

    function isEligible(distance, membershipstatus) {
    

    根据商店的标准,如果客户有资格享受免费送货,则函数返回 true;如果他们不符合,则返回 false。描述某种内容的 Boolean 形式的函数通常被标记为 is,例如 isValidisEnabledisGoingToReturnABoolean

  2. 构建这个函数的主体有两种方式;要么将问题分解成小块并逐步测试参数,要么创建一个检测所有适当结果的单一条件。我们将使用后者来适当展示本章迄今为止的内容。以下 if 语句是一个否定检查——它检查客户是否有资格享受免费送货上门服务:

    if (distance < 1 || membershipstatus === "active" && distance > 10 || membershipstatus === "inactive" && distance > 5 ) {
    

    这是练习的核心。布尔运算符按以下顺序执行,但只有那些确定整体结果所必需的运算符。首先是检查房屋是否位于商店 1 英里范围内的相对检查。如果房屋位于商店 1 英里以内,则整体结果为 true,并且不会评估整个表达式的其余部分。只有当距离为 1 英里或更多时,整体结果尚未确定,然后才会继续进行。只有当会员状态为活跃时,才会检查是否超过 10 英里。否则,如果会员状态不活跃,则会检查是否超过 5 英里。然后,这些结果将与小于 1 英里的检查相结合。由于运算符优先级,不需要使用括号进行分组。

  3. 如果条件评估为真值,则我们希望报告该人员不符合免费送货的资格:

        return false;
    
  4. 由于函数将在这里简单地停止,如果条件块被执行,只需为任何通过的内容返回 true

      }
      return true;
    }
    
  5. 函数完成后,尝试不同的参数变体来测试它:

    console.log( isEligible(.5, "active") );
    // =>   false
    console.log( isEligible(7, "inactive") );
    // =>   false
    console.log( isEligible(7, "active") );
    // =>    true
    

![图 5.10:练习 5.04 输出

![img/C14377_05_10.jpg]

图 5.10:练习 5.04 输出

为什么你不应该比较布尔值和非布尔表达式

虽然许多非布尔值和对象被认为是真值,但它们可能不等于 Boolean true

console.log( 1 == true ); // => true, but:
console.log( 2 == true ); // => false, because true first converts to 1
console.log( 2 == false ); // => also false, because false converts to 0

一个好的经验法则是使用 !!(双重否定)将非布尔表达式转换为 Boolean 类型:

console.log( !!2 == true ); // => true
console.log( !!2 == false ); // => false

你为什么不应该链式比较表达式

对同一运算符在两个以上表达式上的重复应用称为链式操作。通常,这既实用又清晰:

console.log( 1 + 2 + 3 ); // => 6
console.log( true && true && false ); // => false

使用比较运算符进行此过程可能也很诱人,但这样做会得到一个令人惊讶且不正确的结果。在这种情况下,第一次布尔比较的中间结果将提供一个布尔结果。因此,当它与链中的下一个数字进行比较时,JavaScript 引擎会将其转换为1(如果是true)或0(如果是false):

console.log( 1 < 3 < 2 ); // 1<3 => true, but then: true<2 => 1<2 => true!

当使用比较运算符时,也会出现类似的混淆:

console.log( 2==2==2 ); // 2==2 => true, but then: true==2 => 1==2 => false!
// Similarly with 0:
console.log( 0==0==0 ); // 0==0 => true, but then: true==0 => 1==0 => false!
// However, not the same with 1:
console.log( 1==1==1 ); // 1==1 => true, then: true==1 => 1==1 => true

因此,除非你明确地处理布尔值,否则请避免链式任何比较运算符。

三元运算符

到目前为止,我们已经讨论了一元和二元运算符,但 JavaScript 还支持另一个运算符。这个运算符简单地称为三元运算符,它执行与if...else类似的功能,但以更紧凑的方式。三元运算符由一个问号(?)和一个冒号(:)组成,用于表示条件表达式?,一个if条件下的真表达式和一个false条件下的表达式。例如:

var action = (score < 40) ? "Fail" : "Pass";

当然,这与以下内容相同:

var action;
if (score < 40) {
  action = "Fail";
} else {
  action = "Pass";
}

这里的主要区别在于三元运算符本身就是一个表达式。这与if不同,if是一个语句(它不返回值)。

三元运算符的条件部分不需要括号包围,但通常被视为如此,以便它紧密地类似于if表达式。每个三个表达式的规则很简单,即它们必须是表达式;你不能使用ifwhile或另一个此类语句,否则会抛出错误。

由于三元运算符是表达式,因此它们可以嵌套。运算符的每个问号部分都期望跟随一个冒号部分,就像嵌套括号组一样。因此,执行以下操作是可能的,也是可接受的:

var status = (score < 40) ? "Fail" : (score > 90) ? "Outstanding Score" : "Pass";

这等同于以下内容:

var status;
if (score < 40) {
  status = "Fail";
} else if (score > 90) {
  status = "Outstanding Score";
} else {
  status = "Pass";
}

三元运算符对于保持代码简洁非常有用。有时,使用完整的if...else语句会削弱代码的目的,并使其更难以理解。请随意在需要的地方使用三元运算符。

处理数字

JavaScript 中的所有数字都是 64 位浮点值。与其他语言不同,JavaScript 中没有内部区分浮点值和整数的差异。JavaScript 提供了几个包含特定于浮点值和整数的函数的对象。然而,这些对象是语义上的。因此,将整数特定的函数应用于一个数字仍然会得到一个浮点值。

数值是 JavaScript 引擎表示的数据的最简单形式。数值是不可变的,这意味着它们的值在内存中不能被修改。如果您将新数值赋给变量,您只是在用新值覆盖旧值。现有值不会被修改。

由于数字是通过值传递给变量的,因此两个变量不可能指向相同的数字地址空间。因此,处理数字值被认为是纯的,前提是你不重新分配变量的值。

算术限制

浮点值在 JavaScript 中可能会引起一些问题。由于它们的二进制编码,这是 JavaScript 引擎在位中表示数字的方式,简单地相加两个浮点数可能不会产生你期望的结果。考虑以下:

0.1 + 0.2;  // outputs 0.30000000000000004

在这里,响应应该是0.3,但它不是。底层运行时简单地没有以允许它们准确的方式处理值,即使只有一位小数。

如果精度对您的应用程序是必要的,有一些技巧可以提供正确的输出。关于前面的例子,简单地在加法之前将值转换为十进制将提高精度。然后您可以像这样将结果值转换回浮点数:

((0.1 * 10) + (0.2 * 10)) / 10;  // outputs 0.3

同样,乘法和除法也存在相同的问题:

0.0032 * 13;  // outputs 0.041600000000000005

然而,如果您首先将其转换为整数,则结果更准确:

0.0032 * 1000 * 13 / 1000; // outputs 0.0416

这种限制不仅限于 JavaScript。实际上,任何使用 64 位 IEEE 754 浮点数的语言都会有相同的限制。互联网上有许多库可以帮助解决这些问题,如果您不愿意自己解决。

注意

JavaScript 可以表示的最大整数值是9,007,199,254,740,991-9,007,199,254,740,991

Number 对象

正如我们之前提到的,JavaScript 中的数值是原始类型。因此,它们没有属性或方法。然而,与此相反的是,JavaScript 引擎维护了对您的应用程序中数值字面量和变量使用的了解,并通过number对象提供语法支持。甚至可以通过原型扩展此对象,这将在第四部分中详细解释。对Number对象施加的任何扩展都将可用于您的应用程序中的数值:

5.123.toPrecision(3);
  // returns "5.12"

注意,虽然数值可能看起来像是对象,但实际上并非如此。在内存中,数值是非常简单的值。Number对象及其由 JavaScript 运行时实现的实现,仅仅提供了与这些值相关的许多对象的好处。

数值函数

Number 对象包含一系列用于处理数值的函数。像所有对象一样,Number 对象提供了一个构造函数,如果使用 new 关键字调用,则创建一个 Number 对象实例。使用 Number 构造函数创建的数字实际上是对象,这与前面的陈述相反,即数字不是对象,这也是造成很多困惑的原因。更有趣的是,结果对象实例可以像任何其他数字一样处理。

除了构造函数之外,还有 Number 函数。这个函数的使用方式与 Number 构造函数相同,但不需要 new 关键字。调用此函数返回一个 number,而不是 object

var num1 = 99;
var num2 = Number(99);
var num3 = new Number(99);
console.log(num1 == num2); // outputs 'true'
console.log(num1 == num3); // outputs 'true'
console.log(num2 == num3); // outputs 'true'
console.log(num1, num2, num3); // outputs '99 99 Number {99}'

在前面代码中详细说明的所有情况下,结果值可以以相同的方式和规则进行处理,除了处理真值条件。通常,条件将值 0(零)视为假值,但从 new Number(0) 返回的值是真值,尽管它也是零。

console.log( false==new Number(0) ); // => true, meaning that Number(0) equals to false, but:
if( new Number(0) ) { // => truthy
  console.log("truthy");
}
else {
  console.log("falsey");
}

同样,当按类型比较时,从 new Number(0) 返回的值是一个对象,而不是数字,因此与数字字面量的严格比较将失败。

Number 函数和构造函数都接受任何值类型。如果值类型不能转换为数字,则返回 NaN(不是一个数字):

console.log( Number(true) ); // 1
console.log( Number(false) ); // 0
console.log( Number("5") ); // 5
console.log( Number([]) ); // 0
console.log( Number([1, 2, 3]) ); // NaN

当使用 JavaScript 时,建议根本不要使用 Number 构造函数,以便代码更易于阅读。

除了 Number 函数和构造函数之外,全局的 Number 对象还提供了一系列函数,帮助我们识别或解析数值:

![图 5.11:数字函数和它们的描述]

![图片 C14377_05_11.jpg]

图 5.11:数字函数和它们的描述

这些函数都是 static 的,因此必须用全局的 Number 对象(在许多语言中充当类)作为前缀,除非使用 parseFloatparseInt。这些函数也是全局的,因此可以像这样调用,而不需要前面的 Number

console.log( Number.parseFloat("1.235e+2") ); // outputs 123.5
console.log( parseFloat("1.235e+2") ); // outputs 123.5 again

数字方法

由于 JavaScript 解析器在语义上识别数值,因此可以像对实际对象一样调用 Number 对象的实例方法。这些方法中的大多数用于将 numeric 值格式化为 string 表示形式,这对于在网页中的展示非常有用:

![图 5.12:数字方法和它们的描述]

![图片 C14377_05_12.jpg]

![图 5.12:数字方法和它们的描述]

通过组合使用 Number 函数和方法,可以在必要时将数值转换为其他数值,尽管可能会丢失一些精度:

console.log( 123.456.toLocaleString() ); // outputs "123.456"
console.log( 123.456.toFixed(1) ); // outputs "123.5"
console.log( 123.456.toExponential(3) ); // outputs "1.235e+2"

然而,在整数字面量(而不是浮点数)上调用这些函数会失败:

console.log( 123.toString() ); // => Uncaught SyntaxError: Invalid or unexpected token

当 JavaScript 在一个或多个数字之后看到第一个点时,它假设你想要写一个浮点字面量。有一些解决方案可以解决这个问题:

console.log( 123.0.toString() ); // Append .0\. It will still be represented as an integer (as far as it fits in the integer range)
console.log( (123).toExponential(2) ); // Wrap within parentheses (..)

数字属性

全局Number对象提供了各种常量属性,这在比较你的数值时很有用。其中最重要的是NaN。能够在 JavaScript 运行时之外识别数值差异,为你提供了减少代码中错误的方法。例如,观察以下示例:

var num = 999 / 0;

当执行时,num的结果是被称为无穷大的常量值。由于无法从无穷大添加、减去、乘以或除以其他值,因此对该值进行的任何进一步数学运算也将是无穷大。因此,能够在代码中推断出这种限制将提供早期警告,表明你的逻辑可能存在问题。

数字的其他属性包括以下内容:

图 5.13:数字属性及其描述

图 5.13:数字属性及其描述

MAX_SAFE_INTEGERMIN_SAFE_INTEGER都是有趣的价值。考虑以下代码:

Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2;

意想不到的是,前面表达式的结果是true。这仅仅是因为数字超出了安全边界,因此不再被准确表示。前面表达式的两边使用的精度相同,因此被认为是相等的。

练习 5.05:货币格式化器

在这个练习中,我们将创建一个函数,该函数可以将数值参数作为numberstring格式化为具有两位小数的价格值。为了在前面添加货币符号,该函数将接受它作为参数。让我们开始吧:

  1. 定义你的函数签名。这个函数将接受两个参数。其中第一个将是小数值,第二个将是货币符号:

    function formatPrice(value, currency) {
    
  2. 在执行时,函数执行的第一项任务应该是验证传入参数的质量。value参数必须能够转换为数值,而currency参数应该是一个字符string。如果currency是假的,例如没有传递参数,那么我们可以将其默认值设置为美元符号:

      value = Number(value);
      currency = currency || "$";
    
  3. 在响应错误时,我们可以用多种方式通知调用者出了问题。在这个例子中,我们简单地返回null。这样,调用者就会知道除了string响应之外,任何其他响应都意味着有些地方不太对劲:

      if (Number.isNaN(value) || typeof currency != "string") {
        return null;
      }
    
  4. 现在我们知道参数是可用的,将它们组合成正确的格式并返回值:

      return currency + value.toFixed(2);
    }
    
  5. 如果你继续执行这个函数,你会看到相应的响应:

    console.log( formatPrice(1.99, 32) ); // => null
    console.log( formatPrice(5, "£") ); // => £5.00
    console.log( formatPrice(9.9) ); // => $9.90
    console.log( formatPrice("Ted") ); // => null
    

图 5.14:练习 5.05 输出

图 5.14:练习 5.05 输出

我们可以在前面的图中看到运行所有四个函数后的输出。在这个练习中,我们创建了一个函数,该函数接受数值参数作为numberstring,并将其格式化为具有两位小数的价格值。

处理字符串

数字一样,字符串是简单的不可变数据类型,在 JavaScript 运行时作为二进制值的列表进行管理,这些值可以表示为字符。由于它们是不可变的,因此不能被更改。如果你使用提供的方法之一修改字符串,实际上是在创建一个新的字符串,并应用了更改。

字符串的文本表示是由引号包围的字符列表。这些引号可以是双引号、单引号(撇号)或反引号:

console.log( "I am a string" );
console.log( 'I am also a string' );
console.log( `I am a special string` );

可以将字符串视为一个由单个字符组成的很长列表,就像数组一样,这将在后面讨论。因此,可以查询单个字符或字符组:

["H", "e", "l", "l", "o", ",", " ", "W", "o", "r", "l", "d", "!"]

上述代码不是文本的文本表示,而只是字符串可能被感知的类比。由于字符串是列表,因此可以使用字符索引从它们中检索字符。这是通过用方括号符号包围索引来完成的。字符字符串的索引从0(零)开始:

"Hello, World!"[7];
// =>   "W"

由于字符串是不可变的,因此你不能像这样将替换字符分配给索引:

var msg = "Hello, World!";
console.log( msg[7] = "Z" ); // => "Z"
msg; // =>  "Hello, World!"

无法修改字符串。相反,你只能根据原始内容创建一个新的。你可以使用运算符重新构造它,或者使用String对象提供的许多字符串函数之一,这将在稍后描述。

特殊字符

由于字符串包含在引号中,因此在字符串内放置相同的引号可能会出现问题。简单地在字符串中键入引号就等同于终止该字符串。这意味着后续的字符可能被视为格式不正确的 JavaScript 代码,或者更糟,可能是可执行的 JavaScript 代码。

避免终止字符串的一种方法是在包含字符串时使用不同类型的引号。例如,如果字符串包含在双引号中,则可以自由使用单引号。同样,在单引号中包含字符串将允许自由使用双引号:

console.log( "I can contain 'single' quotes" );
console.log( 'I can contain "double" quotes' );

这对于简单的文本是有效的,但对于需要包含两种引号类型的字符串来说并不那么保险。

JavaScript 语言提供了一种方法来转义字符串中包含的字符,以便它们被以不同的方式处理。这是通过在要转义的字符之前加上反斜杠(\)字符来完成的。在引号的情况下,转义确保引号字符不被视为字符串终止字符:

"It's useful to be able to \"escape\" quotes"

转义字符可以与几个其他字符一起使用,以产生不同的效果。它甚至被用来转义转义字符,在这种情况下,反斜杠必须存在于字符串中:

"This \\ will create a single backslash"

其他支持的转义字符包括以下内容:

图 5.15:转义字符

图 5.15:转义字符

连接

连接是将元素首尾相连以形成新元素的一种方法。就字符串而言,这仅仅是把字符串连接起来形成更长的字符串。

字符串连接使用加号(+)符号进行。这被称为方法重载:

"This string " + "and " + "this string " + "are concatenated";
// =>  "This string and this string are concatenated"

在连接字符串时,需要注意空格字符的存在。在连接点不会添加任何额外的字符。因此,如果你需要在组合的字符串之间添加空格,你必须自己添加。

由于字符串是不可变的,连接字符串会创建一个新的字符串,你可以将其分配给变量或作为参数传递给函数。原始字符串不受影响。

模板字符串

模板字符串是 JavaScript 语言中较新的一个特性。使用反引号(`)包围字符串可以改变 JavaScript 引擎对字符串字面量的翻译,并提供了额外的功能。

第一个有趣的功能,也是最常用的功能,是能够在字符串中插入表达式。这是通过在字符串中嵌入以美元($)字符开头的块来实现的。以这种方式组合字符串的好处是使字符串字面量更容易阅读,但也可以极大地简化更复杂的字符串连接需求:

var str = `2 + 2 = ${2 + 2}`;
console.log( str ); // =>  "2 + 2 = 4"
var name = "Jonny";
welcomeStr = `Hello, ${name}!`;
console.log( welcomeStr );  // => "Hello, Jonny!"

模板字符串的另一个有用特性是能够使用物理换行符。通过在 JavaScript 代码中应用换行符,这些相同的换行符将出现在解析后的字符串中:

var str = `This is line one
and this is line two`;
console.log( str );
// => "This is line one
// =>  and this is line two"

最后,模板表达式可以包含嵌套的模板字符串,从而提供嵌套模板字符串的实现。你将在下一个示例中看到它们。这适用于字符串本身中的更复杂表达式,例如条件或循环。

模板字符串的结果是 JavaScript 中的一个特性,非常适合以更可管理的方式处理页面和其他字符串数据内容。在 JavaScript 开发者曾经寻求第三方库来执行此类任务时,这些库现在不再需要。

练习 5.06:电子邮件模板

在这个练习中,你将创建一个函数,该函数接受电子邮件发送服务的组件并将它们组合成电子邮件正文模板。为了使事情更有趣,只有成年人有资格在网站上发表评论。相应地,消息文本将改变。让我们开始吧:

  1. 首先,创建一个函数签名。函数的参数决定了可以输入到电子邮件正文的值。如描述中所述,我们需要一个age参数:

    function sendEmail(name, age, comments) {
    
  2. 在此基础上,现在检查传递的参数。如果有任何无效的参数,函数将简单地返回null

      var age = Number(age);
      if (Number.isNaN(age)
      || typeof name != "string"
      || typeof comments != "string") {
        return null;
      }
    

    在这里,我们检查数字是否可以用作有效的数值,以及namecomments是否是字符串。我们也可以选择检查字符串的长度以确保它们有内容,但在这个练习中这不是必要的。

  3. 现在我们有了有效的参数,我们需要创建包含提到的嵌套模板表达式的body文本:

      var body = `A user has posted a comment from the website:
      name: ${name}
      age: ${age}
      status: ${(age < 18) ? `${name} is not a valid user` : `${name} is a valid  user`}
      comments: ${comments}`;
    

    如你所见,一个三元运算符表达式用于填充 status 行的插值内容。在这里调用一个函数也是可能的,但使用实际的 if 条件是不被接受的。这样做的原因是 if 条件由一个或多个块组成,这在模板插值语法中是不被接受的。

  4. 最后,我们可以通过返回变量来关闭函数。如果你现在执行该函数并传入一些任意的参数,你应该能看到完整的插值字符串:

      return body;
    }
    sendEmail("Jane", 27, "Your website is fantastic!");
    // =>  "A user has posted a comment from the website:
    // =>  name: Jane
    // =>  age: 27
    // =>  status: Jane is a valid user
    // =>  comments Your website is fantastic!"
    

预期输出如下:

![图 5.16 – 练习 5.06 输出图片 5.16

图 5.16:练习 5.06 输出

你选择如何和在哪里连接或插值字符串取决于你。在解决问题时,考虑使用一种引号形式而不是另一种形式是否会使代码更易于阅读,特别是如果有多个开发者将使用该代码。

字符串对象

与数字值一样,字符字符串也附带一个有用的对象,即 String 对象。同样,String 对象提供了围绕字符串的许多函数、方法和属性。

同样类似于 Number 对象的是 String 函数,它将任何传入的值转换为字符串。String 函数通过调用值的 toString 函数来实现,我们稍后会讨论这一点。

长度属性

String 对象,以及字符串本身,只有一个属性:length 属性。正如其名所示,length 属性返回字符串的长度作为一个整数。由于字符串的索引从零开始,长度总是比最后一个字符索引多一个:

"Hello, World!".length;
// =>  13

length 属性在迭代字符串或处理其长度尚未已知的大多数字符串方法时特别有用。

字符串方法

String 对象没有任何 static 方法,但它支持许多可用的字符串方法——比 Number 对象中可用的方法多得多。本章不会尝试涵盖所有这些方法,但会查看更有用的方法。

在 JavaScript 中处理和操作字符串是一个常见的需求;不仅用于显示文本,而且用于处理数据。通常需要根据不同的标准剪切、排序、修改、添加和删除字符串的片段。因此,JavaScript 提供了你可能想到的几乎所有方法来简化这些任务。

以下表格列出了由 String 对象提供的最常用的方法:

![图 5.17:字符串方法图片 5.17

图 5.17:字符串方法

练习 5.07:句子反转

在这个练习中,你将创建一个函数,该函数接受任何大小的字符串,删除任何空白字符,反转其内容,然后将字符串中或跟在句号字符之后的第一个字符转换为大写。结果应该看起来像来自反转维度的正常句子。这个练习的目的是理解数据转换。在软件开发中,数据转换无处不在。JavaScript 运行时在读取你的代码并将其转换为运行中的应用程序时转换数据。能够以简单的方式转换数据将证明是一项宝贵的技能。让我们开始吧:

  1. 从函数签名开始。我们只想接受一个参数,我们将称之为str;这是“字符串”的缩写:

    function reverse(str) {
    
  2. 接下来,先执行最简单的任务,即从字符串的开始和结束处删除任何尾随的空白字符。在执行此操作时,你还应确保你实际上正在处理一个字符串值:

      str = String(str).trim();
    
  3. 在参数准备就绪后,你现在应该开始一个循环。这个循环将通过反向遍历参数字符串来构建一个新的字符串。因此,你还需要声明四个额外的变量,即一个临时变量来保存结果值,一个变量来跟踪当前字符串索引,一个变量来存储当前字符,以及一个变量来跟踪下一个非空白字符是否在句子的开始处:

      var result = "", index = str.length - 1, chr, isStart = true;
      while (index >= 0) {
    

    在前面的代码中,我们确保计数器从字符串的length减一的位置开始(字符串的最后一个索引)并且循环只要计数器大于或等于0(零)就继续迭代。

  4. 在循环进行时,将索引处的字符存储到chr变量中:

        chr = str[index];
    
  5. 在存储字符之后,检查前一个迭代是否出现在句子的末尾。如果是,那么你需要将下一个非空白字符转换为大写。否则,你需要将其小写

        if (isStart && chr != " ") {
          chr = chr.toUpperCase();
          isStart = false;
        } else {
          chr = chr.toLowerCase();
        }
    

    如果字符是句子的开始,那么isStart标志需要被重置为false,这样下一个迭代就不会重复大写转换。

  6. 由于上一次迭代发生了大写转换,检查是否应该在这个迭代中标记一个新的句子:

        if (chr == ".") {
          isStart = true;
          if (index == str.length - 1) {
            index--;
            continue;
    

    你通过检查句号字符来检测新句子的开始。如果是新句子的开始,那么你设置isStart标志,但你还需要确定这是原始字符串的非常末尾。这样做的原因是,你不想将原始字符串的最后一个句号复制到新字符串的开始处,否则结果将以句号开头,这没有意义。如果是这样,你只需通过递减index并继续循环来完全跳过该字符。

  7. 如果句点不在原始字符串的末尾,做一些调整。你不会想让新的句子以空格和句点结尾——你需要反转这一点。由于空格已经应用于结果,你需要回退一个字符并重新添加所需的输出。由于结果字符串现在已经修改,你需要再次继续到下一次迭代:

          } else {
            result = result.substr(0, result.length-1) + ". ";
            index--;
            continue;
          }
        }
    
  8. 如果循环没有继续,则当前迭代的结果是一个普通字符,它应该简单地附加到结果上。为下一次迭代递减索引并关闭循环。通过将结果返回给调用者来结束函数:

         result = result + chr;
         index--;
      }
      return result + ".";
    }
    
  9. 现在,执行该函数。尝试在单个字符串中传递多个句子以查看完整结果:

    reverse("This is the first sentence. This is the second.");
    // =>  "Dnoces eht si siht. Ecnetnes tsrif eht si siht."
    

![图 5.18 – 练习 5.07 输出]

![img/C14377_05_18.jpg]

图 5.18:练习 5.07 输出

就这样。你刚刚使用String全局对象的方法创建了一个字符串操作函数。当然,这个函数可能不会赢得任何奖项,并且如果提供了包含连续空白字符的字符串,可能工作得不是很好,但它确实可以工作。

toString方法

在 JavaScript 中,每个原始类型都可以使用String函数转换为字符串。然而,当处理更复杂类型时,情况并不总是如此。例如,对于一个典型的对象,将其转换为string将产生一个固定的结果,无论对象的内容如何:

var obj = {name: "Bob"};
String(obj);
// =>  "[object Object]"

原因在于 JavaScript 引擎不理解你希望如何解析数据。这可能仅仅是因为你需要输出形成一个键值表,或者你可能只想得到值列表并丢弃键。可能性是无限的。

因此,JavaScript 提供了toString值方法的概念。

toString是一个存在于所有数据类型上的方法,但在某些复杂类型上需要被覆盖,前提是你有自己的解析逻辑。当你调用String函数并传递一个值时,实际上是在调用该值的toString方法:

console.log( String(99) );
// =>   "99"
console.log(  (99).toString() ); // See above about invoking methods on integer literals
// =>  "99"
console.log( [1, 2, 3].toString() );
// =>  "[1, 2, 3]"

覆盖toString方法只是提供一个同名的替代函数给数据值。例如,要覆盖自定义对象中的toString方法,你可以简单地这样做:

var obj = {ted: "bob", toString: function() { return "I am Bob!" }};
obj.toString();
// =>  "I am Bob!"
String(obj);
   "I am Bob!"

在本章后面将详细描述处理复杂对象和对象函数。

字符串中的数字

数字字符串都是原始数据类型,并且都重载了+(加法)运算符。然而,两者之间还有更多的联系,这可能会在你不小心的时候对你有所帮助或造成困扰。

有趣的是,JavaScript 中的数字和字符串通常可以互换,这要归功于一个智能的基于上下文的系统。根据情况,JavaScript 将尝试根据可用的参数推断表达式的结果类型。

你已经看到,可以使用 + 运算符连接字符串,但数字也可以连接成字符串:

"I am " + 21 + " years old";
// =>  "I am 21 years old"

当 JavaScript 解析器识别出传递给 + 运算符的数值和字符串值时,它将数字转换为字符串,以便结果是一个简单的字符串连接。这通常被称为基于上下文的类型转换。

当字符串表达式也是数值的表示,并且与其他数值相关运算符(如 /*)一起使用时,将发生相反的操作。在这种情况下,JavaScript 的基于上下文的类型转换将字符串数字转换为实际数字。以下是一个示例:

"42.7" * 2;
// =>  85.4

当我们使用 + 运算符以产生预期结果时,JavaScript 总是将非字符串值转换为字符串。如果 + 运算符根据字符串表达式的内容以不同的方式工作,将会非常混乱。

当两个表达式都是字符串时,数字也可以进行数学计算,但再次强调,这只会发生在你没有使用 + 运算符的情况下:

console.log( "10" * "10" );
// =>  100
console.log( "10" + "10" );
// =>  "1010"

如果预期需要将表示为字符串的两个数字相加,例如从文本输入字段读取的值,始终先将它们转换为数字:

 Number("10") + Number("10");
// =>  20

与函数一起工作

正如你所看到的,JavaScript 函数是带有签名的代码块,命名了在调用时传递给它们的变量。与任何块一样,函数有自己的栈,封装并保护了它们内部声明的数据。

在 JavaScript 中,函数被视为一等类型。这意味着,就像任何其他类型一样,它们可以被分配给变量,作为参数传递给其他函数,并从函数中返回。它们还可以自我调用,这被称为递归,并且是帮助使 JavaScript 成为一个函数式语言的特征。

JavaScript 中有许多函数形式:

  • 匿名函数

  • 命名函数

  • 箭头函数

  • 生成器函数

它们之间的区别主要是一些细微的语法变化,这些变化影响了它们的使用方式。在本章中,我们将简要介绍每种函数类型。

匿名函数

由于 JavaScript 中的函数是一等类型,它们就像原始类型和对象一样,是一种可转移的资源。到目前为止,函数已经被声明和分配,这意味着它们有一个可调用的名称。然而,函数也是一个具有两种状态的表达式:它的 声明格式 和它的 调用

函数可以在签名中不提供名称而存在,这样它就有以下格式:

function (...parameters) {
   ...body
}

在 JavaScript 中,可以在声明时不提供函数名称来编写函数。以这种方式创建函数允许它们在调用其他函数时就地创建:

otherFunction( function(a, b) { /* do something */ } );

函数也可以分配给变量,当然,在函数调用中也可以作为参数接收。一旦匿名函数被分配给变量,它就变成了一个命名的函数,因为变量构成了它的名称:

var echo = function(subject) { console.log(subject); };
echo("Hello, World!");
// =>  "Hello, World!"

事实上,到目前为止,您一直在编写具有全局作用域的函数。声明一个命名函数仅仅意味着在当前作用域中声明一个同名的变量,并将它指向该函数。例如,以下两种语法是等价的:

var myFunc = function(i) { return i + 1; };
function myFunc(i) { return i + 1; };

声明一个没有分配名称的函数可以被认为是函数字面量。由于函数存在于定义点,它也可以就地执行。这有什么用呢?它可以封装整个程序并避免污染全局命名空间:

( function(a, b) { console.log(a + b); }
)(2, 4);
// =>  6

命名空间污染是一个用来描述全局声明的函数和变量的术语。虽然这样做并不被禁止,但可能会出现问题。如果同一网页内的两个库创建了同名全局变量,可能会出现意外的结果。将在后面的章节中讨论创建尊重干净全局环境的应用程序。

回调

匿名函数的一个重要用例通常是异步执行的回调。当调用不会立即返回值,但也不会停止立即后续代码的执行时,该代码被认为是“异步”的。

包含异步代码的应用程序需要一种方法来在异步代码运行完成后通知应用程序的其他部分,并且必须返回一个值。在 JavaScript 中,回调长期以来一直被用于这个目的:

function doSomethingAsync(data, callback) {
  async_task(data).then(    // do async request
    function(result) {    // then on return
      callback(result);    // execute callback, passing result data!
    }
   );
   //.. continue with other code ..
}

回调的问题在于,如果需要发生多个顺序执行的异步调用,生成的代码文件往往会过度缩进。这个问题有时被亲切地称为“末日金字塔”或“回调地狱”:

asyncOne(data, function(res1) {
  asyncTwo(res1, function(res2) {
    asyncThree(res2, function(res3) {
      //... ad infinitum ..
    });
  });
});

如您所见,每次新的请求都会缩进两个字符。在编码过程中,一个应用程序拥有几十个请求的回调链并不罕见,这会导致代码在屏幕的另一侧。开发者可以选择不缩进,因为缩进不是必需的,但这样做会导致代码难以阅读。为了解决这个问题,引入了生成器函数。您将在本章后面了解更多关于生成器函数的内容。

练习 5.08:函数参数

在这个练习中,您将创建一个接受两个参数的函数:一个原始数据类型和一个函数。然后,该函数将结合这些参数并返回一个函数作为结果。返回的函数将与其作为参数传递的函数工作方式相同,唯一的区别是它总是将原始原始参数作为其参数。让我们开始吧:

  1. 首先,创建函数签名。你知道它将接受两个参数,并且由于它将作为某种柯里化过程(一个在函数式编程中使用的术语),所以这里将使用这个名字:

    function curry(prim, fun) {
    

    这里没有什么特别的。curry 函数就像任何命名函数一样。

    在这种情况下,第一个参数包含的值并不重要。即使它包含 null,在这个例子中仍然有效,所以你可以接受任何传入的值。

  2. 现在,检查第二个参数是否是函数。如果不是,当它被调用时,如果它是其他值类型,可能会发生错误:

      if (typeof fun != "function") return;
    
  3. 现在是时候来点乐趣了。目的是始终用这个函数的第一个参数填充传入函数的参数列表,无论它被调用多少次。为此,使用局部函数定义:

      var ret = function() {
        return fun(prim);
      };
    

    如您所见,这里的结果是一个函数,每次调用它时,都会简单地调用 fun 函数。prim 参数在这里始终不变,所以调用将始终产生相同的结果。

  4. 现在,返回新的函数:

      return ret;
    }
    
  5. 让我们试一试。尝试调用函数,同时尝试不同的参数值:

    var fun = function(val) { return val + 50 };
    var curry1 = curry(99, fun);
    console.log( curry1() );
    // =>  149
    console.log( curry1() );
    // =>  149
    // calling curry1 will produce the same output however many times 
    // it is called, because it is a fixed, pure function.
    var curry2 = curry("Bob", fun);
    console.log( curry2() );
    // =>  "Bob50"
    

    预期的输出将如下所示:

![图 5.19 – 练习 5.08 输出

![img/C14377_05_19.jpg]

图 5.19:练习 5.08 输出

箭头函数

箭头函数,有时称为 fat arrow 函数,是函数声明的简化语法:

var myFun = (param) => param + 1;

如前例所示,箭头函数不需要提供代码块,可以用表达式代替。如果使用表达式,则不需要 return 关键字,因为表达式已经返回了一个值。然而,如果使用代码块,则需要 return 关键字,因为代码块不是表达式:

var myFun = (param) => {
  return param + 1;
};

除了可以不使用代码块外,箭头函数还可以不使用参数列表周围的括号来声明:

var myFun = param => param + 1;

然而,前面的代码只有在参数是一个列表的情况下才能工作。这是因为两个或更多参数的列表会形成一个相当模糊的声明。例如,考虑以下内容:

var myFun = a, b, c => a + b + c;

在阅读前面的声明时,编译器将不知道你试图实现以下哪个声明字符串:

var myFun = a, b = undefined, (c) => { return a + b + c };
var myFun = a, (b, c) => { return a + b + c };
var myFun = (a, b, c) => { return a + b + c };

注意

前两个例子将产生错误,因为它们试图在 var 语句中定义箭头函数,但没有将其分配给变量。

箭头函数注意事项

虽然箭头函数看起来比常规函数声明更干净、更灵活,但它们的使用也有缺点。第一个缺点是箭头函数不能用作对象构造函数,并且不建议将它们用作对象方法。原因与第二个限制有关;箭头函数没有访问它们自己的 this、arguments 或 super 对象(将在本章后面讨论)。

箭头函数的目的是简单地在工作于匿名函数时提供一个更简洁的语法。箭头函数是我们之前描述的回调地狱的第一个语法武器。因此,箭头函数应该被明智地使用。

生成器函数

生成器是 JavaScript 语言中最近且相当复杂的一个新增功能。一旦你开始理解它们,它们将是非常有用的函数,尽管这可能需要一些努力。生成器不提供任何其他方式无法在 JavaScript 语言中执行的手段。因此,本节将仅简要介绍生成器函数的主题,以提醒你它们的有用性。

生成器为序列迭代提供了额外的功能。以下是一个例子:

for (let i = 0; i < 3; i++) {
  callback(i);
};

上述代码是一个迭代器。循环迭代三次,从02。每次迭代发生时,都会调用callback函数,并将迭代结果传递给它。

现在,循环的问题在于它们是一个封闭的栈。对于任何需要在循环内执行的定制代码,循环需要知道如何处理迭代数据。这是生成器试图克服的限制。

生成器函数的声明与命名函数和无名函数类似,但有一点不同;在function关键字之后必须放置一个星号:

var myFun = function*(params) { /*body*/ };

注意

箭头函数格式不能用于生成器函数。

在创建函数体时,命名函数和无名函数遵循相同的规则。然而,也有一些不同之处。看看以下基于前面循环的例子:

var myFun = function*() {
  for (let i = 0; i < 3; i++) {
    yield i;
  }
};

特别注意yield关键字。yield是一个从诸如 C++这样的多线程语言中借用的关键字。在这些语言中的用法与这里的用法相似。本质上,通过调用yield,你是在请求运行时引擎将控制权交还给调用者。在 JavaScript 中,“控制权交还”包括向调用者发送一个值。在上面的例子中,每次函数yield时都会发送一个值,总共会发送三次。

要使用该函数,你必须通过调用函数来创建生成器的一个实例:

var myGen = myFun();

一旦你有了生成器实例,你可以获取一个值:

var firstValue = myGen.next().value;
console.log( firstValue );  // firstValue will equal 0;

你可以多次调用next函数,直到流耗尽。一旦耗尽,返回的值将是undefined

console.log(myGen.next().value);
// =>   1
console.log(myGen.next().value);
// =>  2
console.log(myGen.next().value);
// =>  undefined

next()函数的返回值是一个包含两个字段的对象:

{value: <value>, done: <boolean>}

在前面的例子中,object是隐藏的,我们只是返回值以保持事情简单。只要生成器还有更多的yield可以返回,done值就会返回true。一旦生成器耗尽,它将在所有后续对next()的调用中返回以下内容:

{value: undefined, done: true}

需要注意的是,yield 关键字可以在 generator 函数中调用任意多次。在前面的例子中,yield 关键字是在循环中使用的,但它也可以在其他地方调用:

var myFun = function*() {
  var count = 0
  for (let i = 0; i < 3; i++) {
    yield i;
    count += i;
  }
  yield count;
};

生成器函数也可以使用 return 关键字。如果使用 return,那么返回的值将通过调用 next() 来检索,就像产生的值一样。然而,调用 return 将结束生成器,这意味着即使函数中存在更多的 yield 关键字,也不会从对 next() 的调用中返回更多值。

this 关键字

所有函数(除了箭头函数)都可以访问与函数调用栈相关的额外对象。正如我们之前提到的,函数提供了一个栈,它将声明在其中的变量的内存圈起来,同时允许访问在调用函数的函数或块中声明的变量。这通常被称为封装,它保护外部栈免受函数体相关过程的意外破坏,同时也保护函数内部的数据免受外部过程的侵害。

this 关键字存在是为了能够在函数执行过程中直接指向当前上下文。虽然函数内部声明的变量是该函数调用栈的直接成员,但函数体的上下文可能具体是另一个块或对象,甚至可能在调用时改变为函数定义之外的特定上下文:

图 5.20:全局、对象和函数图

图 5.20:全局、对象和函数图

arguments 关键字

另一个对函数可用的关键字是 arguments 关键字。在定义函数签名时,签名括号内列出的参数被称为“命名参数”,而在调用函数时传递给函数的值被认为是“函数参数”。命名参数在函数执行期间尽可能映射到参数。

在调用函数时,你可以传递任意多或少的参数,但它们不需要与函数签名中列出的参数总数相等。如果你指定的参数少于函数签名中命名的参数,那么未提供的参数将简单地具有 undefined 的值:

function myFun(param1, param2) {
  console.log(param1, param2);
};
myFun(99);
// =>  99, undefined

如果,另一方面,你指定的参数多于该函数列出的参数,那么这些参数仍然对函数可用,尽管没有足够的命名参数;它们只是没有被命名。在这种情况下,你可以使用 arguments 关键字访问额外的参数。

注意

arguments关键字非常类似于一个数组。您可以像访问数组一样访问它,并且您可以在期望数组的功能中使用它。它甚至有内置的类似数组的函数。然而,arguments关键字不是一个数组。

要访问额外的函数参数,您可以通过索引针对arguments关键字进行特定目标。例如,如果向函数传递了四个参数,您可以使用以下代码访问第四个参数:

var someValue = arguments[3];

正如您稍后将在数组中看到的那样,您可以通过调用length属性来找出传递给函数调用的参数数量:

var numParams = arguments.length;

当与更动态的函数一起工作时,arguments对象可以非常有用。

调用和 Apply

正如我们之前提到的,JavaScript 是一种非常灵活的语言。由于函数在 JavaScript 中是一等公民,因此语言提供了操作函数的设施。

在这项工作中,最常用的两个工具是callapply

callapply函数非常相似:通过启用函数的调用并改变函数调用栈的上下文。

callapply之间的区别仅仅是call仅用于操作被调用函数的上下文,而apply用于相同的目的,并且还可以提供任意数量的参数:

var fun = function () { return arguments.length; };
fun.call(this, 1, 2, 3);
// =>  3
fun.apply(this, [1, 2, 3]);
// =>  3

如您所见,要使用call,您需要在开发时知道参数的数量。在apply中使用的参数可以是任何长度,并且不必知道。

练习 5.09:动态 Currying

这项练习将是之前练习的延续。由于您现在对 JavaScript 中函数的工作方式了解得更多,我们将通过支持任意数量的参数将curry概念提升到更高的水平。让我们开始吧:

  1. 从您的函数签名开始。然而,由于您希望支持任意数量的参数,函数参数需要放在第一位。此外,由于剩余的参数是任意的,因此没有必要定义它们:

    var curry = function(fun) { 
    

    在继续执行其余逻辑之前,请检查第一个参数是否是一个函数

      if (typeof fun != "function") return;
    
  2. 如您所猜测的,您将使用参数对象来获取参数。然而,您需要操作参数列表,因为您不希望将函数参数传递给自己。正如我们之前提到的,arguments对象不是一个数组,所以您需要通过使用array函数来操作它,首先将其转换为数组:

      var args = Array.prototype.slice.call(arguments);
      args.shift();
    

    为了将 arguments 转换为 array,你需要调用 array 实例的原生函数来复制数组。slice 函数创建了一个数组的浅拷贝。在这种情况下,它不知道 arguments 对象不是一个数组,但仍然可以正常工作,这对于这个用例来说非常完美。args.shift() 代码使用新创建的数组的 shift 函数移除数组中的第一个元素。由于数组是可变的,就像对象一样,args 数组值被永久修改。

  3. 现在你有了参数列表,创建你的函数包装器,就像你之前做的那样。然而,这次,fun 参数将使用 apply 来调用:

      var ret = function() {
        var nested_args = Array.prototype.slice.call(arguments);
        return fun.apply(this, args.concat(nested_args));
      }
    

    由于参数将被提供给 curry 函数和返回的函数,因此每个函数的参数必须合并成一个单独的数组。这正是 concat 函数的作用。然后,得到的数组被用作 fun 函数的参数。

  4. 最后,返回新的函数并关闭 curry 函数:

      return ret;
    }
    
  5. 现在,让我们试一试:

    var fun = function() { return arguments.length; };
    var cur1 = curry(fun, 1, 2, 3);
    console.log( cur1(4, 5, 6) );
    // =>  6
    var cur2 = curry(fun, 1, 2, 3, 4, 5, 6);
    console.log( cur2(9, 8, 7, 6, 5) );
    // =>  11
    

    预期的输出如下:

图 5.21:练习 5.09 输出

图 5.21:练习 5.09 输出

你刚刚取得的成就并不小。Currying 是函数式编程中的一个强大工具,而你用非常少的代码就完成了这个任务。

活动 5.01:简单的数字比较

到目前为止已经涵盖了大量的内容,因此是时候进行一个活动了。在这个活动中,你被要求编写一个函数,该函数将接收一个学生整个学年的课程成绩作为百分比。该函数必须计算每个成绩的平均值,以确定学生是否通过了整个学年的课程。计算将假设以下情况:

  • 平均分低于 35% 为 F 级。

  • 平均分在 35% – 44% 之间为 D 级。

  • 平均分在 45% – 59% 之间为 C 级。

  • 平均分在 60% – 74% 之间为 B 级。

  • 平均分在 75% 及以上为 A 级。

每个课程作业的成绩可以作为一个 NumberString 传递。不需要其他数据类型,因此不需要错误处理。

活动的概要步骤如下:

  1. 创建一个函数。由于我们不知道会有多少个参数,因此不需要参数标签。

  2. 提取函数的参数。

  3. 获取传递的参数数量并将其存储为一个变量。

  4. 将所有参数相加并计算平均值。将这个值存储在一个变量中。

    备注

    如果其他条件都失败了,最终条件始终为真,因此可以跳过该条件。如果函数已经从上一个条件返回,则不会评估每个条件。

  5. 根据学生的 average 确定成绩并返回。

    备注

    该活动的解决方案可以在第 724 页找到。

这个活动应该强调使用 JavaScript 函数和数据类型解决常见问题的灵活性和简单性。实际上,解决这个问题的方法有很多,但以逻辑、易于阅读的方式尝试总是更可取的。

与对象一起工作

在 JavaScript 中,对象是主要的可配置数据结构,所有其他复杂的数据类型都从中扩展,包括ArrayDate。对象像哈希表一样工作;它们包含/属性,可以包含任何数据类型,包括函数和其他对象。

对象使用花括号定义,类似于一个块:

var myObject = {};

添加到对象中的值是该对象的“成员”。这些成员可以通过点符号访问:

var myObject = {foo: "bar"};
console.log(myObject.foo);
// =>  "bar"

属性的键可以带引号或不带引号,但结果完全相同:

var myObject = {param1: 1, "param2": 2};

JavaScript 被称为原型语言,这意味着它的面向对象能力是通过在实例化之前将原型值赋给对象来提供的。因此,JavaScript 对象支持prototype关键字。原型在本章中过于高级,将在后续章节中详细讨论。

对象作为哈希表

对象非常类似于键/值哈希表:你可以使用给定的名称为对象分配一个值。这些值是任意的,可以是原始数据类型、函数、对象、数组等等。一旦定义了一个对象,你可以使用点符号进一步为它们分配属性:

var myObject = {};
myObject.age = 21;
console.log(myObject.age);
// =>  21

除了通过点符号分配值之外,它们还可以通过命名索引分配,就像一个数组

myObject["age"] = 32;
console.log(myObject.age);
// =>  32

结果完全相同,但这些方法之间有一些差异。

当使用点符号时,对象的参数必须使用标准的变量命名规则。这些包括以下内容:

  • 只能使用字母、数字、下划线和美元符号。

  • 必须以字母、美元符号或下划线符号开头。

  • 名称区分大小写(a 和 A 是不同的变量)。

  • 不能匹配保留字,如"while"或"if"。

然而,对象的键不受此约定的限制。通过使用方括号并传递名称作为字符串,命名键的范围变得更为广泛。实际上,你可以似乎使用任何选择的ASCII字符,包括空白字符,长度最多可达227个字符。那就是134,217,728个字符!

var obj = {};
obj["   "] = 99;
console.log(obj["   "]);
// =>   99

除了字符串之外,数字也可以用作键。这导致对象看起来很像数组。实际上,大部分数组本身也是对象,尽管它们有一些自己的超级能力。

注意

就像字符串可以使用方括号一样,值可以通过使用方括号之间的变量(或表达式)动态地写入和读取对象。

对象键和内存

当将对象作为数据存储使用时,可能会诱使您添加和检索各种数据。对象是极其灵活的容器,它们的使用是许多应用程序的基础。然而,与任何语言平台一样,数据会消耗内存。每次向对象添加新的键时,主机计算机都会使用更多的内存。

JavaScript 使用一个相当智能的垃圾回收器;其任务是清理废弃的数据。然而,问题在于,如果对象中存在对该数据的引用,则数据可能不会被考虑为废弃。如果处理不当,那么随着你添加更多数据,内存将继续被消耗,最终可能导致浏览器崩溃。这被称为内存泄漏!

从对象中删除数据引用的一种方法是将它替换为其他内容。例如,在 JavaScript 应用程序中,当对象参数不再需要时,通常会将null赋值给它们。然而,这种方法的问题在于,虽然原始值已经从对象中分离出来,但新的null值已经占据了它的位置。毕竟,null 是一个值。这可能不会造成太大的问题,因为所有的null值都指向相同的数据空间,但包含的值并不是占用内存的唯一部分;key也是一个开销:

var obj = {key: 99};
obj.key = null;
console.log(obj);
// =>  {key: null}

为了完全删除对象中的引用,包括keyvalue,应该使用delete关键字:

var obj = {key: "data"};
delete obj.key;
console.log(obj);
// =>   {}

对象和按引用传递

正如我们在本章开头提到的,原始值是不可变的,并且按值存在。当将它们传递给函数或修改它们时,数据会创建一个新的副本,这占据了内存中的不同位置。

在这方面,对象与原始值不同。

对象是可变数据。这意味着在您的应用程序中,传递给函数或变量赋值的不是对象数据的副本,而是一直传递原始对象数据的引用。当修改对象时,实际上是修改了原始对象。不会创建新的对象:

var myObj = {key: 99};
function update(obj) {
  obj.key = 22;
  console.log(obj === myObj);  // check they are the same object
}
update(myObj);
// =>   true
console.log(myObj.key);
// =>   22

对象之所以如此不同,是因为复制对象数据既慢又占用 CPU 资源。由于对象可以是嵌套的,尝试复制一个具有链接到其上的后代树的对象可能会使主机机器感到痛苦,因此完全不切实际。

由于对象的表现形式不同,在使用它们时必须小心。通过将对象传递给函数来修改对象数据可能是难以发现的错误的原因。

对象迭代

由于对象像hash arrays一样工作,因此存在函数来处理对象作为iterable是有意义的。JavaScript 语言提供了一些函数用于在迭代对象时使用,但它也提供了运算符来实现这一点,如in运算符所示。

in运算符通过遍历其键将对象转换为iterable

var myObj= {key: "value"};
for (const key in myObj) {
  console.log(myObj[key]);
}

同样的功能也可以通过 Object.keys(myObj) 函数实现。这里的区别在于它返回一个包含对象键的数组,因此在处理对象时也有其他用途:

var keys = Object.keys(myObj);
for (let i=0; i<keys.length; i++) {
  var key = keys[i];
  console.log(myObj[key]);
}

它也可以这样使用:

var keys = Object.keys(myObj);
for (const key of keys) {
  console.log(myObj[key]);
}

除了获取对象键作为数组之外,还有一个函数可以用来获取对象的值作为数组:

var values = Object.values(myObj);
for (const value of values) {
  console.log(value);
}

最后,如果你需要作为关联对同时获取键和值,JavaScript 提供了 entries 函数来实现这一点。key/value 对作为数组提供,第一个元素是键,第二个元素是值:

var keyValues = Object.entries(myObj);
for (const kv of keyValues) {
  console.log(kv[0], kv[1]);
}

对象访问器

正如你可能看到的,对对象的读写是允许的,但这可能不是你想要的。例如,假设你希望创建一个 gameState 对象,用来跟踪玩家的得分和剩余的 enemies。通过允许随机读写数据,你为错误潜入你的应用程序提供了途径。

让我们来看一个例子:

var gameState = {
  score: 0,
  enemies: 99,
  lives: 3
}

现在,限制对这些属性访问的一个解决方案是结合使用函数。以下是一个例子:

var gameState = {
  _score: 0,
  _enemies: 99,
  _lives: 3,
  addToScore: function(value) {
    this._score += value;
  },
  killEnemies: function(num) {
    this._enemies -= num;
  },
  killPlayer: function() {
    this._lives -= 1;
  }
}

在这里,成员变量已经被重命名为以下划线开头。这是一种常见的做法,因为任何以下划线开头的值都被认为是不应该直接访问的值。在 第四章JavaScript 库和框架 中,你将了解到如何绝对保护对象变量,使得直接访问变得不可能。

现在,虽然前面的实现是合理的,但对象的属性不再可赋值,而是被调用。如果你需要读取这些属性的值,你需要另一组函数,并且这些函数也需要被调用。简而言之,这并不干净。

JavaScript 通过使用访问器(也称为获取器和设置器)来解决这个问题。访问器是一种添加可以像变量一样使用的函数的方式,其中获取器允许检索数据,而设置器允许设置数据。

访问器的语法如下:

<accessor_type> <accessor_name>() {
  .. body..
}

让我们重新整理之前的例子,以利用获取器和设置器:

 var gameState = {
   _score: 0,
   _enemies: 99,
   _lives: 3,
   get score() {
     return this._score;
   },
   set score(value) {
     this._score += value;
   },

在这里,我们可以看到 get.score() 允许获取得分,而 set.score(value) 允许设置数据值。

   get enemies() {
     return this._enemies;
   },
   get killEnemies() {
     this._enemies--;
   },
   set killEnemies(num) {
     this._enemies -= num;
   },
   get lives() {
     return this._lives;
   },
   get killPlayer() {
     if (this.enemies <= 0) {
       this._lives = 3;
     } else {
       this._lives--;
     }
   }
 }

在这里,我们使用了一些创造性的许可。score 可以像任何其他值一样读取和写入,但在写入时,不是替换值,而是将值添加到原始值上,如下所示:

console.log(gameState.score);
// =>   0
gameState.score = 100;
gameState.score = 99;
console.log(gameState.score);
// =>   199

enemies 的值可以正常读取,但通过调用 killEnemies 并传递一个值,可以从当前值中减去它,如果没有传递总量,则从值中减去 1

console.log(gameState.enemies);
// =>   99
gameState.killEnemies = 3;
console.log(gameState.enemies);
// =>   96
gameState.killEnemies;
console.log(gameState.enemies);
// =>   95

最后,读取玩家的生命属性将返回当前的生命数,但读取killPlayer将减去一个生命,或者如果没有剩余敌人,它将重置生命回到3。这可能很有用,例如,如果你想在玩家完成游戏后重置玩家的生命:

console.log(gameState.lives);
// =>   3
gameState.killPlayer;
console.log(gameState.lives);
// =>   2
gameState.killEnemies = 99;
gameState.killPlayer;
console.log(gameState.lives);
// =>   3

注意,如果你将设置器的值赋给另一个变量,那么另一个变量将包含传递给设置器的任何内容,而不是设置器逻辑中确定的值。如果没有传递值,那么访问器不是获取器,因此返回undefined

练习 5.10:将对象转换为 toString

在这个练习中,你将在对象内部创建一个函数,当在需要string值的情况下使用该对象时,该函数提供“格式化打印”功能。该函数将利用我们在本章前面详细介绍的toString功能:

  1. 首先,创建一个包含许多不同值的对象。添加一些嵌套对象以使事情更有趣:

    var obj = {meaningOfLife: 42, foo: "bar", child: {me: "you", other: {him: "her"}}, toString: Object.prototype.toString};
    
  2. 现在,如果你使用console.log输出这个结果,它应该会正常显示,因为控制台是为了调试目的而设计来解析复杂对象的。然而,如果你将对象数据连接成一个字符串,你会得到不期望的结果:

    var str = obj + "";
    console.log(str);
    // =>   [object Object]
    
  3. 为了纠正这个问题,创建一个函数,将对象解析成字符串表示:

    var objToString = function(obj, indent) {
      obj = obj || this;
      indent = indent || "";
      var res = "";
    

    在这里做的第一件事是接受对象的传递并利用当前对象上下文。这样,函数就可以在它存在的对象上调用,也可以作为传递的参数调用。你需要这样做,以便可以递归地stringify父对象中存在的任何子对象。indent参数用于跟踪子对象的缩进。对于遇到的每个子级,你都会想要进一步缩进它。这有助于你在打印时可视化对象结构。最后,你还需要res变量来存储正在构建的结果字符串表示。

  4. 遍历对象的所有键并构建字符串表示:

      for (var k in obj) {
    
  5. 现在,事情变得有点棘手。如果k键的值也是一个对象,你也会想要格式化打印它。因此,只需将其传递给同一个函数:

        if (typeof obj[k] == "object") {
          res += indent + k + " = {\n";
          res += objToString(obj[k], indent + "- ");
          res += indent + "}";
    

    为了嵌套子对象,该子对象的键会加上当前缩进参数的值作为前缀。在返回的字符串中使用开大括号来表示对象。然后增加缩进长度,并将其传递给objToString函数的递归调用以进行嵌套迭代。子对象打印后,使用闭合大括号关闭,该大括号也进行了缩进。

  6. 如果键的值不是一个对象,那么它可能是一个函数。你将想要跳过这些函数,因为你不能干净地打印它们。其他所有内容都可以像字符串一样附加,但还需要缩进,以防它是子对象字段中的值:

        } else if (typeof obj[k] != "function") {
          res += indent + k + " = " + obj[k];
        } else {
          continue;
        }
    
  7. 接下来,应用一个分隔符,以便每个键都分开。换行符就足够了:

        res += "\n";
    
  8. 最后,关闭循环并返回值:

      }
      return res;
    }
    
  9. 要使这起作用,将 objToString 函数附加到起始对象作为 toString 函数:

    obj.toString = objToString;
    
  10. 最后,为了测试这一点,只需将对象连接成一个字符串,强制将对象转换为字符串值:

    obj + "";
    

    输出应该是这样的:

    "meaningOfLife = 4
    foo = bar
    child = {
      - me = you
      - other = {
      - - him = her
      - }
    }"
    

    预期的输出将如下所示:

图 5.22:练习 5.10 输出

图 5.22:练习 5.10 输出

您已创建了一个可用的 stringifier 函数,它可以与任何深度的复杂对象一起使用。

数组操作

数组是建立在对象之上的另一种复杂对象类型。与对象不同,数组旨在与数据列表一起工作。数组可以通过几种方式创建。第一种称为数组字面量,类似于对象字面量,它只是将定义的数组值传递给变量的简单方法:

var myArray = [1, 2, 3];
var myEmptyArray = [];

数组的值没有键,而是使用整数索引以方括号形式访问:

myValue = myArray[3];

与其他类型一样,数组类型也有一个构造函数,用于创建数组实例。数组构造函数可以传递值来预填充 Array。因此,以下示例是等效的:

var arr1 = [1, 2, 3];
var arr2 = new Array(1, 2, 3);

然而,当使用构造函数形式时,传递单个整数值将创建一个具有固定数量的值设置为 undefined 的数组:

var arr = new Array(3);
console.log( arr );
// =>   (3) [empty x3]

注意,如果开发者打算创建一个只有一个整数值的 Array,则 Array 构造函数可能会导致意外的结果。因此,使用字面量形式初始化所有数组被视为一种良好的实践。

数组和可变性

与对象一样,数组是可变对象。如果您更新了一个传递给函数的数组,原始的 Array 也会被修改。

Array 对象有几个内置函数,在维护数组使用时的不可变性时很有用。它们不会使数组不可变,但提供了一种在确保它们被复制而不是修改的情况下与数组一起工作的方法。

数组的不可变函数将在 第四章JavaScript 库和框架 中详细讨论,当讨论函数式编程方法时。

数组迭代

数组可以像对象一样迭代。如果您使用的是 for...in 语法,则 Array 的元素是索引,这与处理具有数值键的对象时的结果相同:

for (var i in myArray) {
  console.log(myArray[i]);
}

如果您需要从开始到结束迭代一个数组,这种格式效果很好,但这并不灵活。为了帮助遍历数组,JavaScript 提供了多个附加功能。

由于数组是一个线性列表,它有一个 length 属性。数组对象提供了一个长度属性,它返回数组中的元素数量:

for (let i = 0; i < myArray.length; i++) {
  console.log(myArray [i]);
}

数组的起始索引始终是 0,而数组的 length 属性始终比数组的最后一个索引多 1

除了通过索引从数组中检索值之外,还可以使用indexOf函数在数组中搜索值并返回其索引。indexOf接受一个参数,即要数组中查找的值:

var arr = [1, "b", true];
arr.indexOf("b");
// =>   1

如果找到值,indexOf将返回值的索引,如果没有找到,则返回-1。匹配可以位于index 0或以上,直到但不包括arr.length。要在条件语句中使用indexOf(...)的结果,比较它并检查它是否大于-1

var searchedValue= "b";
 if ( arr.indexOf(searchedValue)>-1 ) {
   console.log( "match found" );
}

注意,indexOf寻找元素的匹配值。因此,只有当复杂类型在数组中通过引用存在时,它们才能在数组中找到。以下是一个示例:

var obj = {name: "bob"};
var arr = ["a", 99, obj];
console.log( arr.indexOf(obj) );
// =>   2
console.log( arr.indexOf({name: "bob"}) );
// =>   -1

由于结构相同但复杂的类型在值上不被认为是相同的,因此不可能以这种方式在数组中找到复杂对象的实例。

如果数组中存在多个该值的实例,indexOf将返回第一个找到的项目索引。该值的所有其他实例都将被忽略。

indexOf函数的伴随函数是lastIndexOf函数。这个函数与indexOf函数工作方式相同,唯一的区别是索引搜索从数组的末尾开始。

内置数组函数

数组类型提供了许多在遍历、复制、连接和呈现数组结构时非常有用的函数。以下表格列出了作为数组类型成员的一些重要且有用的函数:

图 5.23:内置数组函数及其描述

图 5.23:内置数组函数及其描述

处理日期

Date对象在 JavaScript 中是一个重要的类型,但在任何语言中都是一个复杂的类型。就像Array类型一样,Date类型是建立在 JavaScript 对象之上的。

日期没有文字格式。因此,它们必须使用Date构造函数创建。有四种方法可以做到这一点:

  • 空构造函数创建一个包含当前日期和时间的日期。

  • 构造函数可以传递一个整数,表示自1970 年 1 月 1 日开始经过的毫秒数。

  • 提供多个整数参数将指定日期段,例如:

    (年,月,日,小时,分钟,秒,毫秒)

    (年,月,日,小时,分钟,秒)

    (年,月,日,小时,分钟)

    (年,月,日,小时)

    (年,月,日)

    (年,月)

    注意,月份由数字011指定。

  • 提供日期的字符串表示:

    ISO 8601 日期格式(例如"2019-04-25""2019-04-25T12:00:00Z",其中月份、日期和时间填充为两位数字长度)

    美国短日期格式(例如"04/25/2019",日期和月份填充)

    美国长日期格式(例如"Apr 25 2019"等)

    注意

    你不能仅仅通过传递年值作为整数来创建一个Date实例,因为 JavaScript 引擎将不知道你是指年还是毫秒。然而,你可以通过简单地传递一个年字符串来创建一个Date实例。

一旦构建了Date对象,就可以对其进行查询。Date对象提供了许多用于提取date元素的函数。以下表格列出了可用的函数:

图 5.24:Date 对象方法和它们的描述

图 5.24:Date 对象方法和它们的描述

图 5.24:Date 对象方法和它们的描述

注意

Date提供的每个函数返回一个从 0 开始的值,除了getDate方法。这通常会导致混淆和错误,所以请务必记住这一点。

在前表中详细说明的每个函数都有一个等效的集合,除了getDay。因此,要更新Date实例的小时,你只需调用setHour并传递一个整数:

 var d = new Date();
 d.setHours(12);

解析日期字符串

正如我们之前提到的,Date构造函数可以接受一个日期字符串并将其转换为Date对象的一个实例。日期在Date类型内部以整数的形式表示。因此,getDate方法返回日期值的真正解释。

如果你有一个有效的日期字符串,如前所述,你可以通过调用parse方法将其转换为日期:

var greatDate = Date.parse("November 3, 1976");

然而,Date.parse方法的返回值并不返回一个Date实例。相反,它返回自1970 年 1 月 1 日到该日期的毫秒数。因此,为了创建一个Date实例,你必须将这个结果值传递给Date构造函数:

var millis = Date.parse("November 3, 1976");
var greatDate = new Date(millis);

将日期格式化为字符串

Date对象提供了自己的toString函数。如果你尝试将Date实例用作string,你将收到一个格式化的字符串:

var d = new Date();
console.log(d);
//  => current time in local timezone, for example:
// Thu Apr 25 2019 12:00:00 GMT+0100 (British Summer Time)

然而,这通常不是你需要的格式。如果你希望提供自己的日期字符串格式,你可以覆盖对象的toString函数,就像在本章的与对象一起工作部分中一样。以下是一个示例:

var toString = function(date) {
  date = date || this;
  var months = [
   "Jan", "Feb", "Mar",
   "Apr", "May", "Jun",
   "Jul", "Aug", "Sep",
   "Oct", "Nov", "Dec"
  ];
  var day = date.getDate();
  var mnth = date.getMonth();
  var year = date.getFullYear();
  return day + ' ' + months[mnth] + ' ' + year;
}
var d = new Date();
d.toString = toString;
console.log(d);

此代码的输出将如下所示:

current date in format 25 Apr 2019

日期数学

JavaScript 没有提供比较、添加或减去日期的函数。然而,在 JavaScript 中计算日期差异或组合日期并不困难。

通常,在比较日期时需要考虑两个任务:

  • 两个日期之间的差异是什么

  • 向/从日期添加或减去时间

第一个任务相对简单。由于日期可以转换为表示自1970 年 1 月 1 日以来的毫秒数的简单整数,因此你希望差异的日期可以简单地表示为毫秒,并且可以比较这个值。以下是一个示例:

var date1 = new Date("Dec 25 2001").getTime();
var date2 = new Date("Dec 25 2019").getTime();
var diff = date2 - date1;
diff
// =>   567993600000

现在,有了每个时间单位的毫秒数,你可以将其转换为时间单位。例如,如果你想找出这个差异代表的天数,你只需做以下操作:

var day = 1000 * 60 * 60 * 24;
var numDays = diff / day;
numDays
// =>   6574

要获取单位,你只需从毫秒开始,逐步向上。因此,一天是1,000 毫秒 * 60 秒 * 60 分钟 * 24 小时

从日期中添加或减去时间也非常简单。Date对象提供的set方法为我们提供了一种方法,可以滚动超过下一个最大单位值的值。例如,如果当前日期是2019 年 4 月 25 日,添加10天将日期更改为2019 年 5 月 5 日。此功能适用于所有set函数。因此,要添加时间,只需获取你想要添加的时间单位并添加即可:

var d = new Date("Apr 25 2019");
d.setMonth(d.getMonth() + 60);
d
// =>   Thu Apr 25 2024 00:00:00 GMT+0100 (British Summer Time)
// The above result will use your local timezone.

活动第 5.02 节:创建待办模型

让我们利用本章所学到的所有信息,看看我们记住了什么。为此活动,想象你正在与一组开发者合作,你的项目是创建一个存储待办应用条目的状态模型。该模型将是一个主函数,尽管可以创建并使用其他函数。该函数需要存储一个或多个条目,并将接收“动作”来告诉状态改变。

这些动作将包括以下内容:

  • 创建一个新的待办事项

  • 删除一个待办事项

  • 修改一个待办事项

动作将以给定的动作关键字CREATEREMOVEMODIFY传递给状态。

状态中的每个待办事项都将有以下字段:

![图 5.25:活动字段

![img/C14377_05_25.jpg]

图 5.25:活动字段

数据将以动作类型传递给状态函数。如果动作是CREATE动作,则将传递所有前面的字段,除了id字段。如果是REMOVE动作,则只传递id。它将以字符串值传递。最后,如果传递了MODIFY动作,则将传递所有数据,除了created_at值。这是因为created_at值不应该改变。

此任务的一个重要部分是确保状态数据被视为不可变,因为项目经理是函数式编程的粉丝,并希望确保尽可能少地将错误添加到应用程序中。在此活动中,不应更改任何对象,包括数组日期。相反,必须创建新的对象、数组日期,以替换旧值。这同样适用于包含TODO条目的数组。

注意

Arrayconcat函数返回一个新的Array,但其中任何对象仍然是原始值的引用。

最后,当接收到每个动作时,状态函数需要将数据以美观的格式打印到控制台。对于CREATE动作,这将是对传入数据的打印,对于REMOVE动作,这将是对删除数据的打印。对于MODIFY动作,应打印删除和创建的数据。

如果一切按计划进行,你将能够在不担心数据损坏的情况下向你的状态函数添加、删除和修改条目。你可以通过修改发送到状态函数的值来证明这一点。如果状态函数中的条目也被修改,则你的模型不是不可变的。

此活动突出了处理应用程序数据的一种常见方法,这种方法既有效,又不会向应用程序引入错误。通过以纯方式管理数据,你将确保你的开发实践在短时间内产生可靠的结果。

该活动的概述步骤如下:

  1. 创建一个函数签名以接受以下内容:

    • 当前状态,即当前待办事项列表

    • 动作,它只是一个字符串值

    • 应用到状态变化的数据

      函数签名将如下所示:

      function modelStateChange(state, action, data)
      

      在这里,state 是模型中当前 ToDo 项的数组,actionCREATEMODIFYREMOVE 之一,而 data 是新的 ToDo 数据或匹配要删除的 ToDo 项的参数。

  2. 为每种动作类型创建一个条件。然后,在条件的主体中,根据需要操作状态。请记住,在条件主体中返回新的状态值。你可能需要创建一个辅助函数来在状态中查找 ToDo,因为你需要在 MODIFYREMOVE 动作中使用此功能。

    记住,这个函数应该始终返回一个新的状态值。这样,就可以干净利落地测试函数,并确保它按预期工作。

    该函数可能被调用的示例如下:

    todoState = modelStateChange(_todoState, "MODIFY", {id: curTodo.id, completed: true});
    

    注意

    该活动的解决方案可以在第 726 页找到。

摘要

本章涵盖了大量的内容。你现在应该对 JavaScript 提供的类型有更深入的了解,但也要了解每种类型之间微妙的关系。了解数据在语言中的表示方式为快速构建应用程序并减少错误提供了坚实的基础。

除了了解数据类型外,你还看到了如何使用方法以及 JavaScript 引擎提供的辅助函数来操作它们。你还看到了如何将数据转换为不同类型,以便实现数据互操作性。

最后,你看到了如何使用控制台和语言提供的基于字符串的数据格式化功能来调试你的数据。

在下一章中,你将开始学习用户交互的道路,并了解如何触发事件来强制你的代码执行某些操作。你还将了解到在浏览器环境中,JavaScript 语言与 HTML DOM 之间的关系。

第六章:6. 理解核心概念

概述

到本章结束时,你将能够将超时和间隔应用于应用中,以包含异步功能;识别不同的浏览器事件类型;捕获和处理用户交互;拦截和防止事件;模拟事件以改善应用用户体验;总结不同浏览器支持的输入控件;以及处理表单、表单提交和表单事件处理。

简介

在上一章中,你被介绍到了 JavaScript 语言和运行时支持的许多数据类型,包括函数,这是 JavaScript 最令人印象深刻的顶级数据类型。理解数据类型之间的差异是构建实用、高效且无错误的程序的重要第一步。软件应用有多种形式,可能有多种用途。在其最简单形式中,应用可能在执行时读取参数,处理数据,并返回响应。它甚至可能不与其他应用或外部服务交互。终端命令是这一点的良好例子。例如,在 Windows 命令窗口中执行dir或在Linux 终端中执行ls,将简单地读取硬盘上的目录内容,并在终端窗口中显示这些文件和目录的详细信息。Linux 操作系统建立在非常小且简单的应用协同工作以创建更大生态系统的前提之上。这一点的对立面可能是现代多人视频游戏,它们通常对用户交互做出反应,并从远程位置接收流式数据。这些概念中的前者可以被认为类似于一个函数:输入从顶部进入,在函数体内输出到某个地方,通常是函数的末尾。

JavaScript 应用可以促进这一光谱的两端,以及实际上介于两者之间的任何事物。现代浏览器现在完全能够提供构建巨大和处理器密集型 3D 多人游戏的基础,响应来自多个来源的数据,但 JavaScript 也经常用于最简单的任务,例如格式化字符串或四舍五入数字

所有这些应用的核心都是事件。从概念上讲,事件是执行代码的触发器。例如,这可能是页面加载完成时的就绪状态,或者用户在页面内点击元素时的鼠标事件。通常,没有事件,函数不知道何时执行,因此,什么都不会发生。

在本章中,我们将检查 JavaScript 为在浏览器环境中监听和处理不同类型的事件提供的选项。

事件类型

事件简单来说就是 JavaScript 运行时内的一个通知或一个"触发"警报。这些通知可以代表几乎所有事物,但它们是在这种事件发生时调用一个或多个你自己的函数的手段。

当网页加载时,浏览器通常会在内容可用时立即显示内容。这意味着在整页下载完成之前,一些内容将呈现给用户。浏览器这样做是为了防止长时间加载的资产阻止其他内容对用户可用。

现在,假设您想在网页中立即调用一个函数来旋转一个图片。嵌入到网页中的 JavaScript 代码在由 JavaScript 引擎解析后能够立即运行,这可能在相关图片可用之前。为了克服这个难题,JavaScript 提供了一个onload事件,该事件在所有页面内容下载完成后立即触发。通过在事件触发之前不调用您的函数,您可以确保您的图片可以被旋转。

当然,还有许多其他此类事件。可以说,在 JavaScript 生态系统中存在四种特定类型的事件:

  • 计时器事件在您的应用程序中提供强制性的异步功能。它们允许您在一段时间后调用一个函数,一次或重复多次。

  • 回调是在某个操作执行完毕但与您的应用程序中任何其他函数执行并行发生时触发的。这意味着该过程并没有阻止应用程序执行其他任务。

  • DOM或键盘事件,是由于用户与您的应用程序交互而触发的事件。

  • 自定义事件是您自己创建的事件。这些事件可以是几乎任何东西,但通常是在对之前列出的 JavaScript 事件类型之一做出响应时创建的。

您需要的事件类型非常具体,取决于特定的用例。在本章中,我们将检查基于计时器的事件和交互事件。

计时器事件

在某些语言中,例如 C,基本应用程序可能使用一个连续的循环来运行。具体来说,应用程序在其main函数的生命周期内运行;当此函数返回时,应用程序退出。通常,需要运行一段时间并响应事件的应用程序会使用一个简单的循环。事实上,看到应用程序以类似以下内容开始并不罕见:

int main(int ac, char** av) {
    while (true) {
        // .. do stuff ..
    }
    return 1;
}

在这里,应用程序简单地进入一个不确定的循环。如果应用程序需要退出,它将调用一个类似于 JavaScript 的break关键字的命令。否则,应用程序将非常乐意运行并按需调用函数。

用 C 语言编写的应用程序能够这样做有几个原因。首先,C 语言是一个多线程平台。这意味着可以在 C 应用程序中创建多个进程,称为线程,并且可以并发运行,前提是底层硬件支持。在最坏的情况下,这些线程会循环,使每个线程都能在 CPU 中利用一段执行时间。另一个原因是 C 应用程序运行得非常接近硬件,并且与 JavaScript 不同,它们不受底层引擎的限制,该引擎决定了执行流程。

由于一个程序可以有一个循环作为其核心,因此可以合理地认为函数将在每次迭代中被调用,或者可以调用。然而,如果函数在每次迭代中都无限期地被调用,这样的过程可能会过于资源密集,或者简单地运行得太快。一个替代方案是使函数条件化,要求它仅在自上次执行以来经过足够的时间后才执行。这就是计时器事件的基本原理。

与 C 语言不同,JavaScript 是一个单线程的平台,这意味着它只能在整个应用程序中执行单个线程。线程是 CPU 中的执行空间。如果你只有一个线程,那么一次只能发生一个函数执行序列。在 JavaScript 中,这并不意味着底层引擎不使用或没有访问多个线程;只是你的应用程序只能程序化地访问一个线程。

由于 JavaScript 在虚拟机(也称为引擎)中运行,它遵循一系列规则,这些规则决定了你的代码是如何运行的。JavaScript 虚拟机遵循一个称为事件循环的架构。这意味着前面 C 语言示例中的循环已经在你的应用程序运行的 JavaScript 引擎中发生了。然而,在这个循环中,JavaScript 引擎管理着应用程序中每个函数调用的代码执行,包括调用你自己的函数或 JavaScript 的本地函数。

练习 6.01:自定义计时器

正如我们之前提到的,许多来自底层语言的开发者会将循环视为创建定时函数调用的手段。如果一个循环可以无限发生,那么我们只需要检查当前系统时间,并在足够的时间过去后调用函数。例如,在动画处理中,为了控制动画的速度,你可能希望确保你的帧更新函数在每次调用之间有足够的时间间隔。如果没有这种控制,你的动画帧将随着 JavaScript 运行时的每个可能的周期更新,这在某些机器上可能非常快,而在较弱的机器上则可能不那么快。在这个练习中,你将实现这样一个循环。让我们开始吧:

  1. 首先,你需要三个变量。第一个将存储每次迭代的当前时间(以毫秒为单位),第二个变量将包含自定义计时器函数上次执行的时间(以毫秒为单位),第三个变量将是计时器函数调用之间所需的最低间隔(以毫秒为单位):

    var curTime, lastTime,
      interval = 500;
    
  2. 接下来,我们打开 main 函数和无限循环。类似于前面的例子,我们将简单地使用 while 循环并传递一个递减的值:

    function main() {  // primary function
      let running = true;  // loop running flag
      while (running) {  // enter loop
    
  3. 现在,每个迭代需要发生的第一件事是获取当前时间(以毫秒为单位)并将其与上次存储的时间进行比较:

        curTime = new Date().getTime();
        lastTime = lastTime || curTime;
        if (curTime - lastTime > interval) {
    

    如果 lastTime 变量是 null,它将被赋予 curTime 的值。这样,它将从第一次迭代开始正确执行,因为 null 不能从 integer 中减去。

  4. 如果值足够不同(大于 interval 数量),你可以调用你的定时函数。然后你需要更新 lastTime 变量,使其等于当前时间,这样函数就不会连续执行,而是等待下一个持续时间发生:

          console.log(curTime);
          lastTime = curTime;
          running = false;
    
  5. 最后,你需要关闭条件语句、循环和函数:

        }
      }
    }
    
  6. 就这样。如果你通过调用 main() 来执行函数,你将看到每 500 毫秒在控制台输出当前时间:

    main(); // ==> 1558632112316
    

你刚刚创建的是类似于应用程序循环。许多编程语言支持应用程序循环的概念。事实上,像 C++ 这样的语言要求这样的循环以防止应用程序退出。在这种情况下,循环是一个简单的 "保持活动状态" 机制,其中在循环中手动检查潜在的事件。在 JavaScript 中,这样的循环是不必要的。这是因为 JavaScript 引擎已经在幕后提供了这样的循环的帮助,称为事件循环。

事件计时器

前一个练习展示了完全合法的代码,并且可以方便地实现一个工作函数调用计时器。然而,以这种方式创建计时器有许多缺点。首先的问题是,由于 JavaScript 是单线程的,整个应用程序将包含在循环中。没有方法可以在不跳出循环的情况下继续处理循环外的数据。

前一个练习的第二个问题是,由于 JavaScript 引擎已经运行自己的事件循环,示例代码实际上执行了两个无限循环,一个嵌套在另一个内部:

图 6.1:嵌套无限事件循环

图 6.1:嵌套无限事件循环

由于延迟和重复函数调用是常见的编程需求,JavaScript 语言提供了两个函数,使得计时器简单,无需我们构建自己的循环。这些是 setIntervalsetTimeout

setInterval 函数

setInterval是我们之前无限循环的原生实现。前提是,给定一个函数和一个以毫秒为单位的间隔值,JavaScript 将在间隔时间通过时重复执行该函数:

intervalReference = setInterval(timerFun, milliseconds);

注意

JavaScript 中的基于时间的执行尽可能接近间隔值。由于底层硬件、操作系统和资源可用性的各种限制,JavaScript 无法保证在执行触发器时的绝对准确性。

我们可以用以下代码重现先前的例子:

var timerFunction = function() {
  var time = new Date().getTime();
  console.log(time);}
setInterval(timerFunction, 500);
// ==> 1558632112316

在调用setInterval500毫秒以及之后的每个500毫秒,这个示例中的函数表达式将被执行。它是异步执行的,所以setInterval调用之后的代码将无延迟地执行:

function main() {
  setInterval(() => console.log("executed"), 500);
  console.log("after execution");
  console.log("another message");
}();// ==>   after execution
// ==>   another message
// ==>   executed
// ==>   executed

当调用setInterval函数时,它本身会返回一个指向结果间隔处理程序的引用,即调用传递的callback函数的执行栈。这个引用可以用来在任何时候使用clearInterval函数终止间隔循环:

var ref = setInterval(someFunc, 100);
clearInterval(ref);

setTimeout函数

setTimeout函数与setInterval函数工作方式相同,区别在于其传递的callback函数只被调用一次。setTimeout函数在动画 HTML 页面中的元素或当你希望延迟一段时间的过程时(例如清除一个可见的错误消息或对话框)非常有用:

setTimeout(someFunc, 500);

setInterval一样,setTimeout函数也返回一个指向其执行处理程序的引用,以便可以使用clearTimeout清除定时器。由于setTimeout回调只执行一次,在callback执行后调用clearTimeout没有效果。然而,在它执行之前取消setTimeout是完全合理的:

var ref = setTimeout(() => console.log("fire!"), 200);
setInterval(function() {
  console.log("waiting...");
  clearTimeout(ref);
}, 100);
// ==> waiting...
// ==> waiting...
// ==> waiting...

通过在第一次定时器迭代后清除其引用,setTimeout函数可以被setInterval函数模拟,如下所示:

var ref = setInterval(function() {
  console.log("Boo!");
  clearInterval(ref);
}, 500);
// ==> Boo!

定时器参数

在现代浏览器中(不包括IE9及以下版本),setIntervalsetTimeout函数可以接收额外的参数。如果提供了间隔参数之前的任何参数,这些额外的参数将在调用时作为参数传递给callback函数。这为自定义定时器函数提供了一种有用的方法:

var handler = function(p1, p2) {
  console.log(p1, p2);
};
setTimeout(handler, 100, "Hello,", "World!");
// ==> Hello, World!

如果你预计较老的浏览器会运行你的脚本,可以通过将传递的callback包裹在一个匿名函数调用中达到相同的效果:

setTimeout(function() {
  handler("Hello,", "World!");
}, 100);
// ==> Hello, World!

练习 6.02:实时时钟

现在是时候将你对定时器的知识付诸实践了。在这个练习中,你将创建一个实时页面时钟显示,它会以秒为单位递增,并使用 24 小时数字时钟格式显示完整的时间。这个练习将使用一个 HTML 文件,尽管它很简单。让我们开始吧:

  1. 创建一个名为script的新文件:

    <html>
      <script>
    

    script标签将包含要在页面中执行的 JavaScript 代码。

  2. 接下来,你需要一些占位符变量用于 secondsminuteshours 和当前的 Date 对象实例:

        var secs, mins, hrs, date,
    
  3. 你将用于计时器的 handler 函数也将分配给一个名为 setTime 的变量。在其中,你将简单地用当前的时间组件填充前面的变量:

        setTime = function() {
          date = new Date();
          hrs = date.getHours();
          mins = date.getMinutes();
          secs = date.getSeconds();
    
  4. 要将时间输出到页面,你只需更新 body 内容。这个模块后面会解释更好的解决方案:

        document.body.innerHTML = `${hrs}:${mins}:${secs}`;
    
  5. 最后,关闭函数并将其分配给一个间隔。将间隔设置为每 500 毫秒运行一次,以确保更好的准确性:

        }
        setInterval(setTime, 500);
    
  6. script 完成,你应该关闭 script 标签块:

      </script>
    
  7. 页面应该以一个包含时钟和关闭 html 标签的 body 标签块结束:

      <body>
      </body>
    </html>
    
  8. 现在,保存页面并加载到你的浏览器中(或拖动它)。你应该在页面的左上角看到时间显示,并且每秒更新一次。你已经成功构建了第一个基于计时器的 JavaScript 应用程序。

在网页上显示实时时钟是一个实际的应用,尤其是在可能展示全球几个时区时间的公司网站和内部网络中。然而,使用计时器更新页面内容并不仅限于时钟。这个过程同样可以用于更新股票市场价格、实时聊天论坛或几乎任何类型的实时展示。

JavaScript 事件模型

正如我们之前提到的,JavaScript 引擎使用事件循环。实际上,事件是 JavaScript 引擎及其语言的核心。在最简单的定义中,事件是某种事情发生的通知。这可能是由用户与网页的交互,或者是在浏览器内完成的某个过程的完成。

要使用事件,必须将一个 callback 函数分配给事件类型,就像计时器函数一样。然而,通常,事件比简单的计时器更复杂,功能更丰富。

在编程中,事件通知被称为已派发的事件。当 JavaScript 事件被派发时,它们会传递一个事件对象。这对于所有 JavaScript 事件都是成立的。事件对象包含有关派发事件的详细信息,包括事件名称、一个指向包含事件上下文的对象的引用,以及一个指向触发事件的对象的引用。

下表列出了事件对象的属性:

![图 6.2:事件对象属性图片 C14377_06_02.jpg

图 6.2:事件对象属性

事件冒泡

为了理解事件冒泡,如果我们了解基于浏览器的 HTML 会很有帮助。HTML 是超文本标记语言的缩写,它本身是可扩展标记语言(XML)的衍生。HTML 实际上根本不是一种语言,而是一种声明性信息标记,用于结构化数据,在网站的情况下,是页面内容。

HTML 是一种层次结构,最好将其想象成一个数据树。想象以下页面内容:

![图 6.3:HTML 模拟图片 C14377_06_03.jpg

![图 6.3:HTML 模拟页面的结构由两列组成。左侧是一张图片,右侧是一个包含两行的容器;第一行包含三个按钮,第二行包含一段文本。就像一棵树,这种布局可能看起来像这样:![图 6.4:HTML 数据树图片 C14377_06_04.jpg

图 6.4:HTML 数据树

前面的树概述了页面的可见内容,但 body 节点并不是实际的 HTML 树的顶部。相反,页面内容树从名为 document 的节点开始。然后它有一个名为 html 的子节点,该节点包含 body 节点。

HTML 树中的每个节点在与交互时都会引发事件,即使该节点不是立即可见的。例如,当用鼠标点击页面时,最接近树底部的鼠标箭头下的可见节点将引发一个 click 事件。如果已经为该节点分配了一个或多个事件处理器,那么这些处理器将被调用,并将传递一个 event 对象。

当事件处理器未分配给该事件的节点或事件被处理但允许事件继续传播时,就会发生事件冒泡。此时,将调用父节点对该特定事件的处理器,并且发生相同的过程。如果事件继续没有被明确阻止,它将 冒泡 通过每个父节点,直到达到 document 节点。

如果一个节点上存在针对特定事件的多个处理器,任何一个处理器都可以阻止事件,防止其冒泡。不需要所有处理器都阻止事件。

遍历节点树

为了处理一个事件,你首先需要向一个节点添加一个事件类型处理器。然而,为了做到这一点,你需要某种方式来获取你希望监听的节点的引用。JavaScript 提供了多种函数,可以根据许多不同的因素选择和获取节点,包括直接命名访问、通过节点树遍历获取以及通过属性值获取。

在你的 HTML 页面中的所有节点中,最容易获取的是 bodydocument 节点。这两个节点都在全局 document 对象上具有简单的属性访问器:

var document = document.documentElement;
var bodyNode = document.body;
console.log(bodyNode);
// ==> <body></body>

一旦你有了树的顶部的引用,获取树中其他地方的节点就简单了,只需遍历它。JavaScript 提供了几个属性来获取节点的前一个、兄弟或子节点,每个属性都使用了 熟悉的关系 比喻:

![图 6.5:节点属性及其描述图片 C14377_06_05.jpg

![图 6.5:节点属性及其描述

这些属性都可以从给定的节点中读取。如果找不到相应的节点,则该属性将返回 null(或者在 childNodes 的情况下返回空数组):

var image = document.body.firstChild.firstChild;
image
// ==> <img src="img/packt.png" >
var btn = image.parentNode.nextSibling.firstChild.childNodes[1];
btn
// ==> <button>button 2</button>

直接节点获取

除了节点树遍历之外,JavaScript 还提供了一种通过提供节点属性过滤器来指定所需节点引用的方法,该过滤器是一个字符串值,它使用特定格式描述所需的节点。

所有 HTML 节点都遵循一定的模式:

  • 它们可以打开和关闭,有时在一个标签内完成。

  • 它们有一个节点名称或类型。

  • 它们可能有一个可选的 id 属性,该属性应对于页面是唯一的。

  • 它们可能有一个或多个可选的 class 名称。

  • 它们可能还有其他可选的已知属性,例如 namestylestypevaluesrc

  • 它们可能有可选的自定义属性,由页面创建者命名。

节点的签名称为其 tag,它定义在尖括号内,节点开始处有一个左向尖括号,节点结束处有一个右向括号。左向括号之后是节点的名称。属性作为 attribute="value" 追加到节点上:

![图 6.6:HTML 节点结构图片

图 6.6:HTML 节点结构

节点的结束标签包含节点的名称,并且也被左向和右向的尖括号包围。然而,为了将其与新的开始标签区分开来,其左向尖括号之前有一个正斜杠(/)字符。

如果一个节点没有子节点,则可以立即关闭节点而不提供特定的结束标签。这是通过在右向尖括号之前提供正斜杠字符来实现的:

<img src="img/flower.png" />

节点的属性是其描述。稍后,这些值允许您设置节点的外观和感觉,但它们特别便于节点的数据和身份。因此,向节点添加属性以使其易于获取是完全合法的。id 属性就是这样一种值,它仅用于区分标签,并且所有在页面内使用的标签都应该有一个唯一的 id 属性,如果有的话。如果存在 id 属性,则可以使用 document 对象的 getElementById 方法获取相关的节点:

var node = document.getElementById("myTagId");

由于节点 ID 被认为是唯一的,getElementById 方法返回单个节点,或者在找不到匹配节点时返回 null。如果由于某种原因,页面包含具有相同 ID 属性值的多节点,则使用该值调用 getElementById 将返回页面中找到的第一个元素。

存在其他类似的功能,用于使用其他标签描述符进行查询,例如 nameclass。其中大多数返回一个数组,因为预期许多标签可能共享匹配的描述符。以下表格列出了获取标签引用的一些常见函数:

图 6.7:标签引用的常用函数

图 6.7:标签引用的常用函数

分配和移除事件处理程序

一旦您有了节点引用,您就可以为特定的事件类型分配监听器(或处理程序)。您可以使用 addEventListener 函数将监听器分配给节点,该函数接受两个参数,即事件类型作为字符串值和事件处理程序作为函数:

document.body.addEventListener("click", () => alert("I was clicked"));

当事件被分发时,事件处理程序会传递一个单一值,称为事件对象。事件对象可能根据处理的事件类型略有不同。实际上,这个对象是特定事件对象类型的实例。例如,基于鼠标的事件,如 clickmousedown,会生成 MouseEvent 对象。这些对象与许多其他事件的不同之处在于,它们包含 xy 值,这些值详细说明了在事件分发时鼠标在网页文档中的坐标:

document.body.addEventListener("click", function(evt) { console.log(evt); });
// ==> MouseEvent {isTrusted: true, screenX: 230, screenY: 499, clientX: 163, clientY: 400, …}

在附加事件处理程序时,即使处理程序被分配给相同的事件类型,它也不会覆盖该节点上附加的现有处理程序。实际上,一个节点在任何时候都可能附加任意数量的事件监听器。这样,您的应用程序的多个方面可以独立访问同一对象的相同事件通知,如需。然而,不可能将相同的 函数引用 分配给同一事件类型,如下所示:

var display = () => console.log("Clicked");
document.body.addEventHandler("click", () => console.log("I was clicked");
document.body.addEventHandler("click", () => console.log("I was clicked");
document.body.addEventHandler("click", display);
document.body.addEventHandler("click", display);  // this one will not be output
// ==> I was clicked
// ==> I was clicked
// ==> Clicked

在前面的示例中,由于 display 函数是单一引用,因此第二个监听器分配被简单地忽略。JavaScript 不会重复调用函数超过一次。然而,之前的分配都调用了,因为尽管功能相同,但函数本身具有不同的引用。

如果您想移除事件处理程序,可以使用 removeEventListener 方法,该方法接受与其对应方法相同的参数:

document.body.removeEventListener("click", display);

removeEventListener 方法通过引用查找处理程序关联。这意味着您使用匿名函数所做的任何事件监听器分配都无法使用 removeEventListener 方法移除。

注意

如果您销毁了一个附加有事件处理程序的节点,JavaScript 引擎的垃圾回收器不会对其进行清理。在不清理其事件处理程序的情况下移除节点是 JavaScript 中内存泄漏的常见原因,这会导致应用程序性能下降。如果您知道一个节点可能从 DOM 中移除,请不要使用匿名函数向它或其子节点添加事件监听器。

练习 6.03:标签内容

在这个练习中,您将使用到目前为止所学到的知识来创建一个标签显示。显示将使用页面顶部的三个按钮,底部有一个容器 div 标签。当按下按钮时,与按钮处理程序相关的内容将在容器 div 标签内显示。让我们开始吧:

  1. 首先,创建一个名为tabs.html的新文档,并添加起始 HTML:

    <html>
      <head>
        <title>Tabbed Display</title>
      </head>
      <body>
    
  2. 三个按钮将并排放置在一个单独的容器div中。每个按钮都将有一个唯一的 ID,这样我们就可以轻松引用它们:

        <div>
          <button id="btn1">Tab One</button>
          <button id="btn2">Tab Two</button>
          <button id="btn3">Tab Three</button>
        </div>
    
  3. 接下来,添加容器div。我们将添加一个描述性主体来告知用户页面内容。然而,一旦按钮被按下,该内容将永久消失,并被动态内容所取代:

        <div id="container">Click a button!</div>
    
  4. 当页面的结构就绪后,你现在可以关闭body标签并开始script块:

      </body>  <script>    var btn1 = document.getElementById("btn1"),
          btn2 = document.getElementById("btn2"),
          btn3 = document.getElementById("btn3"),
          container = document.getElementById("container");
    

    在这里,我们为需要与之交互的页面中的每个元素创建了一个变量。这使得代码更加整洁和清晰。

  5. 接下来,你需要一些内容在按钮被按下时添加,每个按钮一个:

        var content1 = "Button 1 was pressed",
          content2 = "Button 2 was pressed",
          content3 = "Button 3 was pressed";
    
  6. 现在,我们需要连接内容。为此,只需为每个按钮添加一个事件监听器,更新每个容器div的内容:

        btn1.addEventListener("click", () => container.innerHTML = content1);
        btn2.addEventListener("click", () => container.innerHTML = content2);
        btn3.addEventListener("click", () => container.innerHTML = content3);
    
  7. 现在,只需关闭打开的标签并保存页面:

      </script>
    </html>
    
  8. 就这样。如果你现在在浏览器中运行页面并点击每个按钮,你应该会看到内容已被更新。

    交互式页面如下:

图 6.8:标签控制练习

图 6.8:标签控制练习

这是你对交互式内容的第一次探索。根据用户交互操作页面内容是 JavaScript 的常见需求,也是它擅长的领域。通过仔细规划和良好的编码实践,可以创建出可以模仿几乎所有原生软件应用的 JavaScript 应用程序。

冒泡与捕获

到目前为止,你已经看到事件冒泡是从发出事件的节点向上冒泡到树的顶部,但 JavaScript 还提供了一个名为捕获的冒泡的替代方案:

事件捕获是指事件处理顺序与冒泡相反,从发出事件的节点到树的底部的捕获通知。这意味着当一个节点被交互时,其附加的事件处理器可能不是第一个拦截事件的。相反,一个父节点(或祖先节点)可能首先接收到事件。如果这些祖先处理器之一阻止了事件,那么引发事件的节点上的处理器可能根本不会被调用:

图 6.9:冒泡与捕获

图 6.9:冒泡与捕获

要将事件处理器附加到捕获事件,你只需将一个第三个参数传递给addEventListener方法。这个第三个参数被称为useCapture参数,是一个布尔值。如果设置为true,附加的事件将以捕获模式分配。因此,不向addEventListener方法提供useCapture参数与提供该参数的false相同:

var clickHandler = () => console.log("clicked");
document.body.addEventListener("click", clickHandler);
// ==> clicked

附加到捕获事件的处理器位于与冒泡事件监听器不同的空间。在分配捕获事件时,它们不会与冒泡事件冲突。因此,在同一个节点上使用相同的事件类型将函数引用作为冒泡和捕获的事件处理程序,意味着当该事件被派发时,该函数将被调用两次:

var clickHandler = () => console.log("clicked");
document.body.addEventListener("click", clickHandler);
document.body.addEventListener("click", clickHandler, true);
// ==> clicked
// ==> clicked

要移除使用useCapture设置为true添加的事件,你只需将相同的useCapture值传递给removeEventListener方法:

document.body.removeEventListener("click", clickHandler, true);

JavaScript 事件生命周期

不论哪个节点派发了事件,所有事件通知都从document节点开始。然后它们以捕获模式通过树分支向派发事件的节点(即target节点)移动。一旦沿该路径的所有适当的捕获处理程序都被调用,事件随后返回到document节点,调用所有适当的冒泡处理程序。

当事件沿着树向下传递时,我们称事件处于捕获阶段;当事件返回到document节点时,我们称其处于冒泡阶段。当目标节点的处理程序被调用时,无论它是捕获处理程序还是冒泡处理程序,事件都处于目标阶段。

在事件往返旅行的任何一点,它都可能被处理程序阻止,从而防止所有其他事件处理程序被调用。

停止事件传播

有时,在处理事件时,你可能需要阻止事件。知道事件可能在页面的其他地方被处理,如果你的应用程序的条件不允许事件继续,那么停止事件可能是有意义的。

例如,如果当用户在textfield控件中输入新密码时派发事件,如果密码不符合某些要求,例如字符数太少或包含不允许的字符,那么该事件可能就毫无用处。

JavaScript 提供了两个类似的功能来停止事件:stopPropagationstopImmediatePropagation。这两个函数都是事件对象的方法,作为它们的唯一参数传递给事件处理程序。

stopPropagation方法将阻止其旅程中进一步节点上的事件处理程序被调用,无论事件是在捕获阶段还是冒泡阶段。然而,如果它们与调用stopPropagation的事件处理程序位于同一节点上,它仍然允许所有剩余尚未调用的当前事件类型的事件处理程序执行。stopImmediatePropagation方法将停止所有进一步的处理程序,包括当前节点上尚未调用的处理程序:

var handler = function(ev) {
  if (ev.target.value.length < 6) {
    ev.stopImmediatePropagation();
  }
};

任何方法都可以在处理程序中的任何位置调用,并且可以在多个处理程序中调用,尽管只有第一个实例会执行。

停止事件动作

JavaScript 引擎内部的一些事件会导致动作发生。动作是浏览器对事件的特定响应,它位于你自己的自定义事件处理程序之外。这包括在点击链接时提交form或页面重定向。

动作发生在事件冒泡阶段之后,一旦事件完成了其在节点树中的旅程。如果由于调用stopPropagationstopImmediatePropagation而停止了事件,动作仍然会发生。

要阻止一个动作,必须调用事件对象的preventDefault方法:

var handler = function(ev) {
  ev.preventDefault();
}

调用preventDefault不会阻止事件通过捕获或冒泡阶段。因此,如果你想阻止事件并防止其动作,你必须调用这两种类型的方法:

var handler = function(ev) {
  ev.stopPropagation();
  ev.preventDefault();
}

注意,并非所有事件都可以阻止其触发动作。每个派发的事件都包含一个名为cancelable的属性。如果这个属性是true,那么可以通过调用它的preventDefault方法来取消它。然而,如果属性是false,那么调用preventDefault将不会对其行为产生任何影响:

var handler = function(ev) {
  if (ev.cancelable) {
    ev.preventDefault();
  }
};

onload事件就是这样一种无法取消的事件,这是有充分理由的,因为它对于确保浏览器的正常功能至关重要。相反,表单的onsubmit事件是可取消的,因为其成功必须由页面的业务逻辑来决定。

练习 6.04:条件事件阻止

在这个练习中,你将创建一组链接。每个链接在被点击时都会引发一个click事件。在这些链接上方,包含它们的div将监听每个链接引发的每个事件,并确定是否应该停止事件传播、停止其动作或两者都停止。让我们开始吧:

  1. 让我们从创建一个名为ev-prev.html的文档并添加一些 HTML 代码开始:

    <html>
      <body>
        <div id="container">
          <a href="https://google.com">Google</a>
          <a href="https://bing.com">Bing</a>
          <a href="https://yahoo.com">Yahoo</a>
        </div>
        <div id="message"></div>
      </body>
    

    在这里,我们有一个包含三个链接和一个用于输出消息的次级容器的容器。

  2. 接下来,打开一个script标签用于 JavaScript,并创建变量来引用link容器、链接本身和message容器:

      <script>
        var container = document.getElementById("container"),
            links = container.children,
            msg = document.getElementById("message");
    
  3. 当每个链接被点击时,我们希望显示一条消息,显示哪个链接被点击了。因此,我们将向link容器附加一个event listener,以便当事件冒泡时,处理程序将在那里捕获事件对象并确定哪个链接派发了事件:

        container.addEventListener("click", function(ev) {
          msg.innerHTML = `${ev.target.text} clicked`;
          console.log(`${ev.target.text} clicked`);
        });
    
  4. 然后,我们将为每个链接添加一个行为。第一个将阻止动作,第二个将停止propagation,第三个将两者都做:

        links[0].addEventListener("click", function(ev) {
          ev.preventDefault();
        });
        links[1].addEventListener("click", function(ev) {
          ev.stopPropagation();
        });
        links[2].addEventListener("click", function(ev) {
          ev.preventDefault();
          ev.stopPropagation();
        });
    
  5. 最后,关闭script标签和html标签:

      </script>
    </html>
    
  6. 通过在浏览器中运行此页面,你应该会看到第一个链接将在消息容器中打印Google点击,第二个链接将重定向用户到Bing网站,而第三个则什么都不做。当点击第二个链接时,消息永远不会显示,因为事件传播在处理程序中被停止。

    现在,你已经成功地将事件序列化并捕获了它们在节点树中冒泡的过程:

图 6.10:事件阻止

图 6.10:事件阻止

事件触发器

你已经看到,在与网页交互时,事件是动态分发的,但事件也可以通过你的代码手动触发。

正如我们之前提到的,JavaScript 中的事件是有类型的对象。当一个动态事件被引发时,JavaScript 引擎创建这样一个对象并将其分发。该对象包含一个类型值,它将事件类型作为字符串存储,例如 click

你可以实例化自己的事件对象并分发它们,而不仅仅是依赖于动态创建的事件。你可能有很多原因想要这样做,比如模拟用户交互或轻松调用已分配为事件处理器的代码,而不重复代码。要做到这一点,你只需使用 new 关键字创建一个 Event 实例,并传递你希望引发的事件类型:

var ev = new Event("click");

创建后,你只需使用节点的 dispatchEvent 方法来分发它:

someNode.dispatchEvent(ev);

调用 dispatchEvent 方法的节点成为事件对象的 target 属性。一旦分发,事件就会进入捕获阶段,通过 DOM 传递到该节点,然后以正常的方式从它向上冒泡,在移动过程中触发事件监听器。

自定义事件

Event 对象是 JavaScript 框架提供的最简单的事件类型。实际上,JavaScript 提供的所有其他事件都是简单 Event 类型的扩展。然而,Event 对象本身并不灵活,并且不提供为事件轻松附加额外数据的方法。为了解决这个问题,JavaScript 语言提供了 CustomEvent 类型,即专门为自定义开发者事件设计的事件。

当实例化时,CustomEvent 对象接受一个额外的命名参数,称为 detail。通过将具有 detail 属性的对象作为第二个构造函数参数提供,该属性的值随后对所有拦截该事件的处理器都可用,如下所示:

var event = new CustomEvent("click", {detail: 123});

现在,任何可能拦截此事件的处理器都可以通过简单地引用它来检索 detail 值:

var handler = function(ev) {
  var value = ev.detail;  // value is now 123
};

CustomEvent 不仅用于手动触发原生事件类型;还可以创建自己的事件类型。在创建事件对象时传递给它的名称可以是任何你选择的字符串。通过监听该事件,你能够像处理内置在 JavaScript 引擎中的事件一样处理它:

var event = new CustomEvent("myEvent", {detail: 42});
someContainer.addEventListener("myEvent", someEventHandler);
someNode.dispatchEvent(event);

原生事件触发器

JavaScript 语言中有许多原生事件类型,一些有动作,一些没有。到目前为止,你已经看到了 click 事件的使用,但远不止这些。鼠标事件组就包括 15 种不同的事件类型,还有超过 40 个不同的事件组,包括以下内容:

  • 网络

  • 元素聚焦

  • Web sockets

  • CSS 动画和过渡

  • 表单

  • 打印

  • 键盘交互性

  • 元素拖放

  • 窗口和文档事件

甚至还有通过触摸屏显示交互、虚拟现实头盔、设备电池变化、智能卡事件等发生的事件,还有更多。

事件处理器属性

当构建 HTML 页面时,HTML 规范还提供了中缀事件处理表示法。这就是事件处理器在 HTML 节点内部分配的地方。在 HTML 中,中缀事件表示法被称为事件属性。有众多可用的事件属性类型,尽管不如 JavaScript 提供的事件类型多。

事件属性通常具有与它们的原生 JavaScript 事件类型相对应的名称,但前面加上单词 on。例如,click 事件会被分配给节点作为 onclick 属性:

<div id="someNode" onclick="someFunction();"></div>

事件属性的价值是一个可执行的 JavaScript 语句,例如函数调用。

DOM 节点只能支持每种类型的一个事件属性;例如,不可能在单个节点中提供两个 onclick 属性。然而,通过确保正确使用分号字符来区分不同的语句,事件属性可以在单个属性内执行多个语句:

<div id="someNode" onclick="someFun1(); someFun2();"></div>

作为事件属性处理程序调用的函数不会传递事件对象。然而,与典型的事件处理器不同,它们可以用额外的参数调用。例如,要传递包含事件属性节点的一个引用,事件属性函数声明可以传递 this 上下文:

<div id="someNode" onclick="someFunction(this);"></div>

在这种情况下,this 解析为第一个参数,它是对 div 节点的引用。

许多开发者认为事件属性是最后的手段,因为它们的包含将视图(HTML)与逻辑(JavaScript)混合在一起。推荐的做法是始终使用 addEventListener 动态分配事件处理器。

事件和内存

在上一章中,介绍了 delete 关键字,并简要提到了内存管理章节。当与事件处理器一起工作时,内存管理变得非常重要。如果一个事件处理器被附加到一个节点上,但该节点随后从页面 DOM 中移除,那么它可能不会被 JavaScript 引擎的垃圾回收器(从内存中清理)清理,直到处理器从节点中移除。这尤其适用于事件处理器不是匿名函数的情况。

当使用可能从 DOM 中删除的节点的事件时,确保您正确清理您的节点和处理程序。这可能意味着在删除之前移除事件处理器并正确删除变量的内容。

与表单一起工作

表单是 HTML 规范的一部分,它独立于 JavaScript 引擎存在,尽管它们也与 JavaScript 完全交互。HTML 表单是使用form标签定义的声明性结构。此标签概述了一个可以提交的上下文,其数据被发送到远程服务器位置。

要理解form标签的工作原理,了解 HTTP 请求、它们的类型区分以及数据如何在请求中发送是有帮助的。

HTTP

HTTP 是一个规范,其完整形式为HyperText Transfer Protocol。它最初于 1990 年以HTTP 1.0的形式发布,并在RFC 1945规范中详细说明(其中 1945 是规范编号,而不是年份)。这种传输格式旨在在互联网上传输超文本文档,如HyperText Markup Language(HTML)文档。

在 HTTP 规范中,识别了许多可以与HTTP 请求一起发送的元数据。这些元数据被称为头部,因为它们位于请求包的开始字节中,用于标识如何读取请求。

HTTP 协议提供了一个选项来识别请求包的目的,称为Method。有众多Method选项可用,尽管它们被接收它们的 HTTP 服务器以不同的方式解析和读取,但它们的使用也可能是简单的上下文相关。最常用的两个 HTTPMethodsGETPOST

GET是一种方法,其简单含义是“在这个地址获取信息”,其中地址是请求发送的 URL。当进行GET请求时,参数可以作为查询变量或作为路径本身附加到地址上,服务器可以据此以某种方式使用。然而,在HTTP 协议的早期版本中,统一资源定位符URL)地址仅提供最多255个字符,包括协议和域名地址,这对于大量数据,包括文件上传来说很棘手。为了解决这个问题,规范还提供了一个名为POST的方法。

POSTGET的扩展,允许我们包含一个请求body。在 HTTP 请求中,body是包含在头部之后的包内容;因此,它是实际的包主体:

![图 6.11:HTTP 包图片

图 6.11:HTTP 包

由于请求包中没有跟随body的内容,因此body可以比GET限制的255个字符大得多。

在发送请求的body时,发送者可以提供一个Content-Type头,该头描述了body的格式(或 MIME 类型)。例如,如果body是一个JavaScript Object NotationJSON)字符串,那么请求的Content-Type可能是application/jpeg

表单标签

form标签自 HTML 公开以来就存在,并在HTML RFC 1866规范中有详细说明。form标签通常包围了实际表单的元素,对 HTML 页面的用户可见。与任何 HTML 节点一样,form标签可以根据需要视觉化或保持不可见:

<form method="POST" action="/data/form-handler.php" enctype="text/plain">
   ...
</form>

表单标签的属性包括以下内容:

图 6.12:表单标签属性及其描述

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_06_12.jpg)

图 6.12:表单标签属性及其描述

与 HTTP 包规范相比,method属性等同于 HTTP 包本身的methodaction与 URL 参数相同,enctypeContent-Type头值相同。

form标签为输入控件组提供了一个逻辑上的请求块。网页中的每个form标签都需要提交,以便形成并发送请求。提交可以通过 JavaScript 动态发生,或者通过使用submit按钮控件通过用户交互发生:

<form method="POST" action="/data/form-handler.php" target="_top">
  <input type="submit" value="submit" />
</form>

当您更喜欢直接的用户交互时,可以使用submitimage输入控件提交表单,其中后者提供了一种显示样式图形作为提交按钮的方法:

<form method="POST" action="/data/form-handler.php" target="_top">
  <input type="image" src="img/send-btn.png" />
</form>

使用 JavaScript 提交表单

有时,通过用户交互发送form请求可能不合适。相反,表单可能在动画完成后发送数据,或者一旦发现浏览器的功能,就重定向用户。在这些时候,能够使用 JavaScript 动态触发表单的提交非常有帮助。

与所有基于 DOM 的交互一样,首先需要获取表单节点才能提交表单。一旦获取,只需调用其submit函数即可提交表单:

var form = document.getElementsByTagName("form")[0];
form.submit();

注意,一个页面中可能存在多个表单标签。表单标签可以包括标识和基于样式的属性,就像任何其他标签一样,例如nameidclassstyle

表单提交事件

form标签支持围绕控件和表单处理的一系列有用的事件。其中最有用的事件是submit事件。

当表单提交时,会触发submit事件,但在实际将请求发送到指定的端点之前。此事件通常用于验证用户提供的表单值,以确保没有明显的错误或确保已填写所有必填字段。

注意

由于submit事件在请求发送之前触发,请确保不要使用此事件将用户重定向到另一个页面或执行任何会阻止表单提交完成的事情。如果您希望阻止表单提交,请参阅“防止表单提交”部分。

与许多事件一样,可以使用显式的 HTML 属性语法将submit事件处理器分配给表单节点:

<form method="GET" action="/endpoint" onsubmit="myFormHandler()">

submit事件的功能就像任何其他事件一样,意味着它从document节点捕获并冒泡。

阻止表单提交

在 HTML 和 JavaScript 中,阻止表单提交是一个常见的需求。通常,如果表单验证失败,最好取消提交并向用户显示适当的消息。要取消表单提交,我们可以调用事件的preventDefault方法,这将阻止事件的最终操作,就像对其他事件类型所做的那样。另一种方法是函数返回false值。

从事件处理器返回false与调用事件的preventDefault函数具有相同的效果,但它不会停止捕获/冒泡。

如果你的函数正在处理 jQuery 事件,返回false与执行preventDefaultstopPropagation方法调用相同:

function formHandler(ev) {
  if (document.getElementById("password").value().length < 3) {
    alert("Password is too short");
    return false;
  }
};

重置表单

重置表单意味着将表单恢复到其初始状态。此功能提供了一种将表单恢复到干净状态的方法,或者如果表单已加载默认或原始值,则将这些值返回到每个控制项。

网站用户有时可能需要重置功能,如果他们正在处理复杂值。能够重置表单可以节省用户记住表单中存在的初始值,或者至少快速返回到重新填充表单之前的原始状态。

与提交类似,重置表单可以通过使用重置输入控制项通过用户交互来实现:

<form method="GET" action="/endpoint">
  <input type="reset" value="Reset Form" />
</form>

一旦控制项被点击,表单将恢复到其初始状态。

重置表单的另一种方法是使用form节点的reset方法:

document.getElementByTagName("form")[0].reset();

注意

表单重置操作不能自动撤销。如果需要此功能,首先需要保存表单的所有值,然后逐个重新应用到控制项上。

表单重置事件

当重置表单时,浏览器将引发reset事件。重置事件的处理器可以显式地应用于 HTML 节点的声明中:

<form method="GET" action="/endpoint" onreset="myResetHandler()">

同样,可以通过简单地分配事件处理器来处理表单重置事件:

document.getElementByTagName("form")[0].addEventListener("reset", myResetHandler);

练习 6.05:简单表单处理

在这个练习中,你将创建一个带有submitreset按钮的简单表单。在提交时,表单操作将被取消,但提交的值仍然会被处理并在屏幕上显示。这是创建表单验证系统的第一步。让我们开始吧:

  1. 让我们从标准的 HTML 模板开始:

    <html>
      <body>
    
  2. 接下来,创建开头的表单标签。这将赋予一个 ID,以便于获取,并提供一个随机的操作 URL,因为在这个练习中它不会被使用:

        <form id="myForm" method="GET" action="http://google.com">
    
  3. 为了使这更有趣,让我们引入一个简单的文本字段控制项。虽然它本身不会被使用,但它将有助于演示重置功能:

          <input type="text" value="original text" />
    

    输入控制项将在本章下一节中讨论。

  4. 现在,你需要两个按钮:一个用于submit,一个用于reset

          <input type="submit" value="Submit" />
          <input type="reset" value="Reset" />
    
  5. 最后,让我们关闭 form 标签并打开 script 标签,准备我们的 JavaScript:

        </form>    <script>
    
  6. reset 按钮处理器将很简单。一旦点击,将在控制台显示一条消息。但是,你不会将处理器附加到按钮的 click 事件,因为这样做将无法提供停止 reset 的能力,如果你希望这样做的话。相反,事件将被分配给表单的 reset 事件:

          function resetHandler(ev) {
            console.log("form has been reset");
          };
          document.getElementById("myForm").addEventListener("reset", resetHandler);
    
  7. 同样,使用 submit 处理器,将监听表单的 submit 事件。然而,在这种情况下,你将调用 ev.preventDefault() 从处理器中,以防止表单实际提交:

          function submitHandler(ev) {
            console.log("form has been submitted");
            ev.preventDefault();
          };
          document.getElementById("myForm").addEventListener("submit", submitHandler);
    
  8. 最后,关闭 script 标签和页面:

        </script>
      </body>
    </html>
    

图 6.13:表单按钮

图 6.13:表单按钮

就这样。现在,如果你在浏览器中运行页面,你会看到点击 submit 将在控制台显示一条消息,而 reset 按钮将显示另一条消息。此外,点击 reset 总是将文本字段中的文本恢复到原始文本。

表单控制

没有数据发送的表单标签几乎毫无用处。这些数据通常使用 HTML 规范中提供的各种表单控件或小部件提供。在可能的情况下,并且当控件没有子节点时,表单控件通常使用 input 节点标签类型。其他控件包括 selecttextareabuttondatalist。我们将在本章的其余部分查看这些控件类型。

输入控制

大多数可用于 HTML 表单的控件都是通过 input 标签提供的。input 标签需要一个 type 参数,它显示 HTML 页面中的相对控件:

<input type="text" />

在使用 input 控制时,现代浏览器中可用的类型如下:

按钮控制

button 控制看起来非常像 submitreset 表单按钮。然而,与 submitreset 不同,button 控制没有默认操作:

<input type="button" onclick="buttonHandler();" value="Clickable Button" />

button 控制还有一个替代的标签格式,我们可以通过使用 button 标签来使用它:

<button onclick="buttonHandler();">Clickable Button</button>

注意,input 格式需要在 value 属性中传递一个标签,而 button 标签格式需要将按钮文本作为内容传递,使用一个关闭的 button 标签,如下所示:

图 6.14:按钮输入控制

图 6.14:按钮输入控制

按钮控制支持 click 事件,或 onclick 属性,正如我们在本章前面所解释的。

复选框控制

checkbox 控制代表“是或否”、“开启或关闭”或“是或否”控制:

<input type="checkbox" checked />

在前面的例子中,checked 参数是一个无值的属性。当提供时,checkbox 默认被选中,并将设置为检查是否重置包含的 form

此实现的另一种方法是提供 checked 的值:

<input type="checkbox" checked="checked" />

任何无值的属性提供的值都被忽略,因此可以提供任何值:

![图 6.15:复选框输入控件

![图片 C14377_06_15.jpg]

图 6.15:复选框输入控件

checkbox 控件支持 changeinput 事件。当控制器的 value 参数发生变化时,将触发 input 事件,而 change 事件仅在控制器的 value 被提交时触发,例如在失去焦点或按下 Enter 键时。通常,当与 checkbox 控件一起使用时,这两个事件之间几乎没有区别,尽管被认为始终使用 change 事件是更好的选择。

checkbox 出现在表单中时,只有当它被选中时才会提交其值。如果没有选中,则完全不将任何值传递给处理脚本。

要使用 JavaScript 检查复选框的选中状态,只需查询其 checked 参数:

<input type="checkbox" id="check" value="1" checked />
<script>
  var chk = document.getElementById("check");
  console.log(chk.checked);  // ==> true
</script>

如果控件被选中,则 checked 的值将是 true,如果没有选中,则将是 false

要设置控件的 checked 状态,只需将一个 Boolean 值传递给 checked 参数。

无线电控制

radio 控件类似于 checkbox 控件,但除了处理多选选项。与 checkbox 控件不同,radio 控件不能通过点击来取消选择。相反,必须选择不同的选项,从而在提交 form 时至少提供一个结果:

<input type="radio" name="color" value="red" checked />
<input type="radio" name="color" value="blue" />
<input type="radio" name="color" value="green" />

radio 控件的 name 属性提供了分组功能。如果从之前的 radio 控件提供了不同的 name,则新控件属于不同的组。只有通过点击同一组内的 radio 控件,才能取消选择之前选中的 radio 控件:

![图 6.16:单选按钮输入控件

![图片 C14377_06_16.jpg]

图 6.16:单选按钮输入控件

checkbox 控件一样,radio 控件是通过 checked 属性来选择的。如果页面中存在 radio 控件组,但没有设置任何控件为 checked,则不会选择任何这些 radio 控件。在这种情况下,提交父表单意味着 radio 组不会出现在发送的数据中。

要动态检查特定的 radio 按钮(从而取消选中当前选中的按钮),需要有一种方法来引用希望检查的特定 radio 控件。有几种方法可以实现这一点,例如为组中的每个 radio 控件提供唯一的 id,但最有效的方法是简单地引用其在组中的索引。例如,要选择组中的第二个 radio 按钮,我们可以这样做:

<input type="radio" name="color" value="red" checked /> red
<input type="radio" name="color" value="blue" /> green
<input type="radio" name="color" value="green" /> blue
<script>
  var chk = document.getElementsByName("color")[1];  // select index 1
  chk.checked = true;
</script>

getElementsByName 返回与传入标准匹配的所有元素的数组。因此,提供索引可以实现对给定元素索引的直接访问。

radio 控件支持 inputchange 事件,就像 checkbox 控件一样。

文本控件

text 控件是所有 input 控件类型中最基本的,用于创建自由文本字段。这些字段允许使用键盘输入单行文本字符串,尽管也可以使用浏览器上下文菜单将其粘贴到其中。通过将 input 控件的 type 属性设置为 text 来创建文本字段:

<input type="text" name="color" value="red" />

可以通过提供正则表达式形式的 pattern 属性值来限制 text 控件的允许内容。例如,可以使用以下代码将文本字段限制为仅接受数值:

<input type="text" name="num" pattern="[0-9]" title="Enter a number" />

当使用前面的文本字段时,提交除数字以外的值将导致表单提交终止,并在 text 字段旁边显示一个工具提示,提示“输入一个数字”。在这种情况下,表单数据将不会提交:

图 6.17:具有模式属性的文本输入控件

图 6.17:具有模式属性的文本输入控件

text 字段的另一个特性是 placeholder 属性。占位符允许在字段本身中存在临时文本:

<input type="text" name="num" placeholder="Enter a number" />

此文本不是控件的值,因此提交容器表单不会产生 placeholder 值。同样,如果 placeholder 可见,查询文本控件的 value 属性也不会返回 placeholder 值:

图 6.18:具有占位符属性的文本输入控件

图 6.18:具有占位符属性的文本输入控件

密码控件

password 控件与 text 字段控件非常相似,但有一些区别:

<input type="password" name="pass" />

password 控件的主要区别是,控件中存在的任何值都向用户呈现为一系列点,称为散列,而不是作为值文本本身。散列的目的是使值对用户不可读,从而提供一定程度的保护,防止不受欢迎的注意。因此,用户预计已经知道字段中包含的值。在提交表单时,值会以明文形式正确地随表单数据传递:

图 6.19:密码输入控件

图 6.19:密码输入控件

text 控件相比,password 控件的另一个区别是,无法突出显示其内容并复制它。尝试复制 password 控件的内容将被忽略。这防止恶意用户将 password 控件的值复制并粘贴到文本编辑器或其他此类软件中,从而使值文本清晰可读。然而,尽管如此,仍然可以通过 JavaScript 引用控件并以此方式输出其值,如下所示:

<input type="password" id="secureValue" value="secret" />
<script>
var pass = document.getElementById("secureValue");
console.log(pass.value);  // ==> "secret"
</script>

email、tel 和 url 控件

emailtelurl 控件是文本字段控件的现代变体。它们可以通过简单地将 email、tel 或 url 作为输入控件的类型属性传递来创建:

<input type="email" />
<input type="tel" />
<input type="url" />

单独来看,这些控件在功能上并没有比标准的 text 控件提供额外的功能。这些控件的属性、事件和视觉外观都与 text 控件以及彼此相同。然而,当与提供屏幕键盘的设备(如手机和平板电脑)一起使用时,这些类型的好处就显现出来了。通过使用这些 input 类型之一,而不是 text 控件,向其中输入文本时显示的可见键盘通常面向控件的内容类型:

图 6.20:文本、电子邮件、电话和 URL 控件的移动键盘

图 6.20:文本、电子邮件、电话和 URL 控件的移动键盘

注意,需要做额外的工作来确保字段的内容适合字段格式。这可以通过使用 pattern 属性和表单验证逻辑来实现。

隐藏控件

hidden 控件是一个非常有用的字段,用于存储要传递给表单处理程序(如远程服务器端点)的数据,而网页的用户不会意识到其存在。正如其名称所暗示的,hidden 字段对用户来说是隐藏的,并且没有可见的存在:

<input type="hidden" />

当使用 hidden 控件时,输入标签提供的许多属性都是无关紧要的,因为它不是用户会与之交互的控件。然而,它的 valueidname 属性将非常有用,并且可以像 text 控件一样使用和操作。

数字控件

number 字段看起来与 text 字段相似,但会自然地将所有文本输入约束为数值、加减符号和点符号:

<input type="number" />

在某些浏览器中,数字控件还会显示小的上下箭头按钮,可以用来增加或减少包含的值:

图 6.21:数字控件

图 6.21:数字控件

number 控件的内容可以通过使用其 minmaxstep 属性进一步约束。minmax 属性是自解释的,它们将可能的数字输入值约束到这些属性。例如,提供一个 min 值为 0 将确保不能输入负数,而 max 值为 100 将确保值永远不会超过 100:

<input type="number" min="0" max="100" />

使用 minmax 并不意味着超出这些约束的值不能从键盘物理输入到控件中,而只是说在点击提供的箭头按钮时不能违反这些约束,或者当提交表单时,任何超出这些参数的提供的值都将被接受:

图 6.22:数字控件约束

图 6.22:数字控件约束

步进属性提供了一种方法来增加箭头按钮增加或减少值的速率。例如,如果控件中允许大数字,每次点击增加 1 可能不太实用。因此,设置更大的步进值将允许通过更少的点击来改变值:

<input type="number" min="-100000" max="100000" max="100" />

当使用步进时,点击箭头按钮时值的改变将以步进值的速率从控件中当前存在的任何值开始改变。因此,设置步进值为 5 不会将包含的值限制为 5 的倍数。

图像控制

image控件作为一个img标签和submit输入控件的混合体工作。这里的想法是你可以使用图形图像作为提交按钮。你使用src属性指定图像源,就像我们使用img标签一样:

<input type="image" src="img/img.png" />

使用image输入控件的一个额外好处是,一旦点击,鼠标相对于图像的 x 和 y 坐标也会作为xy值与表单数据一起发送。如果,例如,你希望在表单提交中将地图上的某个位置注册为一部分,或者如果你希望确定用户点击的图像区域,这可能会非常有用:

<form method="GET" action="/handler.php">
  <input type="image" src="img/img.png" />
</form>
// will submit to a URL like "/handler.php?x=14&y=27

image控件的可用性意味着表单可以超越 HTML 按钮控件提供的限制进行样式化,并且可能非常受网页设计师的欢迎。

文件控件

当上传文件到远程服务器时,file控件是必需的。实际上,如果不以某种方式使用file控件,就无法动态上传文件。这是由于安全性的原因,在没有用户知情的情况下动态上传文件是不道德的:

<input type="file" name="file" />

file控件通常以text字段和label对的形式呈现给用户。可以样式化控件,使得这些项目中的一个或两个对用户不可见。按钮和标签中提供的文本由浏览器固定,并且需要一些极端的样式才能更改。

![图 6.23:文件控制

![img/C14377_06_23.jpg]

图 6.23:文件控制

当使用file控件将文件上传到服务器时,设置周围表单的enctype参数为"multipart/form-data"非常重要。此值告知表单提交在发送到服务器时如何编码数据。如果不这样做,将阻止文件上传,因为文件通常需要多个数据包才能成功传输所有文件的数据字节。

文件控件支持accept属性值,该值可以启用接受文件格式类型的过滤。此属性可以支持多个值,但它们必须作为 MIME 类型(文件类型的预定义字符串表示)提供:

<input type="file" name="file" accept="image/png, image/jpeg" />

在前面的示例中,只有具有.png、.jpg 或.jpeg 扩展名的文件在通过控件选择文件时才会可见。

文本区域控件

虽然 text 控件非常适合接受单行文本,但 textarea 控件是捕获多行文本值所必需的:

<textarea name="description">
    Some default text.
</textarea>

如其名所示,textarea 控件支持文本区域,因此它比许多 input 控件都要大。与 button 标签一样,textarea 控件由一个开标签和一个闭标签组成。在标签之间提供的任何文本都构成了其文本内容。

尽管文本区域不使用 value 属性,但其内容仍然可以通过 JavaScript 中的 value 属性进行读取和设置,如下所示:

var desc = document.getElementsByName("description")[0];
console.log(desc.value) // ==> outputs "Some default text"
desc.value = "Some other text"; // ==> updates the value of the textarea to "Some other text"

图 6.24:文本区域控件

图 6.24:文本区域控件

选取控件

select 控件提供了下拉列表控件的功能。与 textarea 类似,select 控件使用一个开标签和一个闭标签,这两个标签共同构成了控件的主体。然而,控件内的项目是通过一个额外的标签提供的,称为 option

<select name="colors">
  <option value="">--Please choose an option--</option>
  <option value="red">Red</option>
  <option value="blue">Blue</option>
  <option value="green">Green</option>
</select>

select 控件的 option 元素通常包含两个值:value 属性和 text 主体。这些简单地被称为 valuetext。在开 option 标签和闭 option 标签之间的文本是用户在控件中可见的字符串,而 value 属性是在选择该特定 option 元素时提交表单时要发送的字符串。在添加 option 元素时,可以省略 value 属性,但这将意味着将 text 值与表单数据一起发送。

与复选框和单选按钮类似,可以通过将 selected 属性传递给选项值之一来预先选择一个选项值:

<select name="colors">
  <option value="">--Please choose an option--</option>
  <option value="red" selected>Red</option>
  <option value="blue">Blue</option>
  <option value="green">Green</option>
</select>

选取控件可以以两种格式显示。标准格式是一个下拉(或组合)列表控件:

图 6.25:选取控件

图 6.25:选取控件

如果控件需要支持同时选择多个选项,则会显示第二种格式。因此,控件以永久打开的列表控件形式显示,具有可滚动的选项。select 控件可以通过提供 multiple 属性来支持多个选定的选项:

<select name="colors" multiple>
  <option value="">--Please choose an option--</option>
  <option value="red">Red</option>
  <option value="blue">Blue</option>
  <option value="green">Green</option>
</select>

当显示时,用户在选择项目时必须按住键盘上的 Ctrl 键。如果没有按 Ctrl 键,则选择一个项目将取消选择之前选定的任何项目:

图 6.26:具有多个属性的选取控件

图 6.26:具有多个属性的选取控件

当使用下拉 select 控件时,可以通过查询 select 控件的 value 属性来获取所选 option 的值:

var select = document.getElementsByName("colors")[0];
console.log(select.value); // ==> outputs selected color

还可以通过查询 selectedIndex 属性来输出所选项目的索引。索引值从 0(零)开始,对应第一个元素:

var select = document.getElementsByName("colors")[0];
console.log(select.selectedIndex); // ==> outputs numerical index of selected option

然而,当查询多选列表时,事情并不那么简单。如果多个项目被选中,查询value属性将简单地返回列表中第一个选中的项目,从而忽略所有其他选中的项目。相反,开发者需要利用select控件的options属性和option项的selected属性来识别哪些项目被选中。

select控件中的options属性返回所有包含在其内的option元素,无论它们的选中状态如何。option项的selected属性只是其选中状态的简单条件;如果选中则为真,如果没有选中则为假。因此,通过结合这两个值,可以通过简单的循环识别选中的option元素,如下所示:

var selectedItems = [];
var select = document.getElementsByName("colors")[0];
for (let opt of select.options) {
  if (opt.selected) {
    selectedItems.push(opt.value);
  }
}
console.log(selectedItems); // ==> outputs array of selected values

活动六.01:对模型进行更改

本章涵盖了大量内容,但你现在应该具备制作可视内容所需的知识。在这个活动中,你的角色是创建一个简单的表单,该表单请求输入新待办事项条目的标题和描述。当提交时,这些数据将被发送到我们在上一章中创建的动作处理器。

一旦你的表单就位并且其数据正在被处理,模型功能必须更新以接收这些数据。创建一个能够接收CREATE动作事件并将它们适当传递给模型的处理器。

由于模型中没有视觉提示表示数据已成功存储,因此对模型的任何更新都应导致发送通知事件。这样,应用程序的其他区域可以根据数据变化做出相应的响应。你的项目经理要求从模型中发送一个名为CHANGED的自定义事件。这将通知任何感兴趣的方,数据已被添加、更新或从模型中删除。

为了验证CHANGED事件是否工作,在页面顶部创建一个消息横幅,该横幅将短暂显示消息“待办事项模型已更新”。此消息应存在三秒钟,然后被移除。

你的项目经理要求将这些信息作为自定义事件发送,以便由动作处理器捕获。此事件应与模型已识别的动作类型相匹配。因此,请确保以CREATE事件的形式发送对象。

将以下 HTML 保存到名为index.html的文件中:

<html>
  <head>
    <title>Create TODO</title>
    <script src="img/model.js"></script>
    <script src="img/create_todo.js"></script>
  </head>
  <body onload="loadHandler();">
    <div id="notifications"></div>
    <form id="todo_form" />
      <label>Title:
        <input type="text" id="title" />
      </label>
      <label>Description:
        <textarea id="description"></textarea>
      </label>
      <input type="submit" value="Create TODO" />
    </form>
  </body>
</html>

此 HTML 应包括上一章中的模型,但还应包含一个包含此活动逻辑的新 JavaScript 文件。

以下是这个活动的预期输出:

![图 6.27:待办事项提交表单图 6.27:待办事项提交表单

图 6.27:待办事项提交表单

按照以下步骤完成此活动:

  1. 向模型添加一个自定义事件处理器。此处理器应接收CREATE状态变化,并使用事件主体的新TODO详细信息更新模型。

  2. loadHandler 函数添加到 create_todo.js 文件中。这个处理函数应该监听提交按钮的 click 事件,但也监听模型发出的自定义 CHANGED 事件。

  3. create_todo.js 文件中添加一个处理函数来处理 CHANGED 事件本身。这个处理函数应该暂时在 notifications 标签内显示一条 The TODO model has been updated 消息。

  4. TODO 添加到 create_todo.js 文件中。这将执行当 submit 按钮点击事件被触发时。这个处理函数应该解析表单控件中的值,并在它们有效的情况下,通过一个新的 CREATE 自定义事件将它们分发出去。如果任何数据无效,那么应该在 notifications 标签中暂时显示足够的错误消息。

  5. 当模型更新时,添加一个 CHANGED 事件分发。事件体应包含一个添加的类型和一个包含新 TODO 详细信息的值。

如果你运行 HTML 页面,你将期望在提交表单时看到消息被写入屏幕。记住,模型将接收和发送事件。它不会被直接联系。处理函数将确保事件被正确附加,并且数据已经被正确解析,以便可以发送到模型。

注意

这个活动的解决方案可以在第 728 页找到。

摘要

这本书中第一个使用 JavaScript 在 HTML 页面上,以及第一个解释事件消息系统抽象性质的章节。理解这些概念对于在 JavaScript 中构建有用的网络应用程序非常有价值。

在本章中,你已经探索了事件消息冒泡和捕获的各种细微差别,以及如何使用它们来控制应用程序内部的信息流。你还看到了如何阻止这些事件,以及如何创建你自己的自定义事件。

通过采用本章中你学到的工具和技能,你将拥有一个基础的工具库,这样你就可以处理任何大小或复杂性的应用程序。这些技能将在本书的其余部分得到磨练,同时拓宽你对这个强大语言可能性的认识。

在下一章中,你将更深入地研究 JavaScript 事件循环,并更深入地了解底层技术。

第七章:7. 拆卸引擎盖

概述

到本章结束时,你将能够区分单线程和多线程执行;描述 JavaScript 的执行过程;展示调用栈和内存堆如何与其他运行时元素交互;编写与 JavaScript 的垃圾回收过程协同工作的代码;并在浏览器中调试与内存相关的问题。

在本章中,我们将探讨 JavaScript 在浏览器中的执行方式以及它如何管理重要的系统资源,如内存。

简介

在前两章中,你学习了 JavaScript 的一些核心概念,了解了事件循环的概念,并探讨了 JavaScript 如何处理内存管理的过程。在本章中,我们将更详细地探讨这些语言方面,并学习如何编写与 JavaScript 的一些底层功能协同工作的代码。

对于开发者来说,在职业生涯中取得很大进展而没有对 JavaScript 的一些核心概念及其周围的概念有坚实的理解是很常见的。确实,完全有可能成为一名成功的开发者,编写出坚实、具有商业价值的应用程序,而从未完全掌握本章涵盖的主题。

那么,为什么我们应该了解 JavaScript 的内部工作原理呢?我们难道不能只是编写我们的代码,让 JavaScript 处理细节吗?嗯,这种方法的缺点是,有时事情并不完全按照计划进行,我们需要能够理解底层发生了什么,以便重写使应用程序出现错误或性能下降的代码部分。想象一下,你是一名拉力赛车手。你只需要对如何驾驶汽车有基本的了解,就可以沿着赛道驾驶你的汽车。这种技能水平可能让你到达终点线,但绝对不能让你赢得任何比赛。

为了与其他驾驶员区分开来,你需要提高你的技能和经验水平。当然,仅仅练习技能会随着时间的推移而帮助你提高,并让你能够直观地了解幕后发生的事情——这适用于驾驶汽车和用 JavaScript 编写计算机程序——但更深入地了解实际涉及的过程将使你能够自信地制定计划和做出决策,以便你知道具体想要控制系统的哪些部分。

为了提供一个更具体的编程示例,想象一下你正在测试同事的代码,你无法理解为什么页面似乎加载得如此缓慢,为什么在某个操作期间变得无响应,或者为什么应用程序消耗了过多的系统资源。这类性能问题并不一定会破坏应用程序,甚至可能对每个用户都不可见。但它们确实会影响软件的可使用性和用户体验,这对 SEO 页面排名和网站在最终用户中的受欢迎程度有连锁反应。了解底层发生的事情可以帮助你编写更好的代码,更快地调试代码,使你的开发者生活更轻松,并为用户提供更流畅的体验和更成功的应用程序。

通过掌握本章所涵盖的知识,你编写的代码将最大限度地利用 JavaScript 编程语言及其运行时环境。

JavaScript 执行和事件循环

JavaScript 是一种单线程语言,这意味着它将所有的操作都排成一条线,并逐个执行。许多其他语言是多线程的,也就是说,它们能够同时执行多个操作线程。每种执行方法都有其优缺点,主要围绕效率与复杂度展开,但在这里我们不会深入探讨这些。正如我们马上要看到的,JavaScript 的调用栈会逐个处理操作,按照后进先出(LIFO)的原则。

LIFO 描述的是从数据结构中添加和删除元素的过程——在本例中,是从栈中。正如其名所示,最后添加的元素是第一个被移除的——就像在桌子上堆叠书籍一样。

JavaScript 运行时

运行时环境是一个允许软件在系统上运行的应用程序。它是正在运行的软件与其运行系统之间的桥梁,并提供对系统资源(如内存和文件系统)以及运行时和环境变量的访问。在 JavaScript 的情况下,运行时通常是——但并不总是——浏览器。

JavaScript 运行时有不同的实现方式,它们处理执行的具体方式各不相同。每个实现都使用了优化,这可能导致实际过程与这里描述的不同。

然而,从理论角度来看,JavaScript 运行时可以有效地分解为几个关键组件和过程:

  • JavaScript 引擎

  • 环境/浏览器 API

  • 消息队列/回调队列

  • 事件循环

将 JavaScript 运行时环境和执行分解为以下关键组件,可以表示如下:

图 7.1:JavaScript 运行时概述

图 7.1:JavaScript 运行时概述

图 7.1:JavaScript 运行时概述

让我们逐一查看这些组件,以更好地理解它们的功能。

JavaScript 引擎

JavaScript 引擎是一段运行 JavaScript 代码的软件,通过Node.js(用于服务器端 JavaScript 执行)等过程,将高级 JavaScript 源代码转换为低级机器代码。V8,像大多数现代浏览器一样,使用即时编译过程来执行 JavaScript 代码,这意味着代码是在运行时编译的,而不是像旧编译过程那样提前编译。

我们在这里关注的 JavaScript 引擎的两个元素是内存堆栈调用栈,这两者都将在稍后进行解释。请注意,前面的图示显示事件循环是独立于 JavaScript 引擎的,因为通常预期 JavaScript 运行时会实现并管理事件循环。然而,许多 JavaScript 引擎实现了它们自己的事件循环过程,这可以作为运行时环境的后备。

注意

默认情况下,Node 在 V8 JavaScript 引擎上运行。然而,正在开发使用不同引擎的 Node 的替代实现。Node-ChakraCore,它使用微软的 ChakraCore JavaScript 引擎,是这样一个项目,并且正在积极开发中。另一个是SpiderNode,尽管在撰写本文时,这个项目的发展已经停滞。

环境 API

在浏览器的上下文中通常被称为 Web API 或浏览器 API,这些是运行 JavaScript 的环境提供给 JavaScript 的接口。例如,浏览器提供了对LocalStorage的访问,以及我们已在前面章节中介绍过的setTimeout()setInterval()等方法。在浏览器之外运行的 JavaScript 将有不同的需求,因此运行时会为它提供不同的接口。Node.js 是一个流行的服务器端 JavaScript 运行环境,我们将在本书的后面部分进行介绍。对于 Node.js 来说,拥有文档对象模型(DOM)通常没有太多意义,因此,Node 提供了更适用于服务器端代码的 API,例如用于执行filesystemFileSystem API。

如我们很快就会看到的,环境 API 是 JavaScript 执行异步操作能力的一个关键组成部分。

消息队列

消息队列(也称为setTimeout函数的延迟时间已过期,或者当发生事件并且有相应的事件监听器时,例如当用户点击按钮并且按钮附加了点击事件监听器时。这些操作按照先进先出FIFO)的顺序发生,就像你在超市结账处找到的队列一样。消息队列本身并不执行函数——它只是一个在调用栈完成其操作之前暂时存放它们的地方。操作执行的时间由事件循环决定。

事件循环

事件循环是通过将消息从消息队列添加到调用栈的过程。事件循环监视调用栈和消息队列,如果调用栈为空,则消息队列中最老的消息(第一个)将被推送到栈上执行。只有当栈上的所有函数调用都返回后,后续的消息(函数调用)才会被推送到栈上。

调用栈

调用栈跟踪 JavaScript 的函数调用。当一个函数被调用时,一个栈帧(由函数的名称(或“匿名”,对于匿名函数)和函数调用者的地址引用组成)被推送到栈顶。如果这个函数调用另一个函数,那么第二个函数的新帧会被推送到栈顶,位于上一个帧之上。当函数返回——无论是显式还是隐式——相应的栈帧就会被从调用栈中弹出,代码执行从函数调用之前的位置继续。与消息队列不同,调用栈以 LIFO(后进先出)的顺序处理帧:栈帧被添加到和从栈顶移除,就像在桌子上堆叠书籍一样。当调用栈为空时,事件循环将决定是否将消息队列中的消息推送到调用栈,或者允许新的栈帧被添加到调用栈以供后续函数调用。

内存堆

内存堆是 JavaScript 引擎在运行时用于动态读取和写入对象的未排序内存区域。我们将在本章的后面部分详细讨论 JavaScript 中的内存管理。

事件循环实战

本节解释我们将用来演示事件循环实战的代码,并提供关于异步代码执行的一个简要先导。

在你选择的浏览器 JavaScript 控制台或代码编辑器中,三个简单的函数声明如下:

function firstCall() {
  console.log('I'm logged first!!");
  secondCall();
}
function secondCall() {
  console.log('I'm second...");
  thirdCall();
}
function thirdCall() {
  console.log('I'm last.");
}
firstCall();

在这个简单的代码片段中,我们声明了三个函数,然后调用这三个函数中的第一个。firstCall函数在控制台记录了'I'm logged first'字符串,然后调用下一个函数secondCall()。这个函数记录了'I'm second...'字符串,随后调用我们的第三个函数thirdCall()thirdCall()简单地记录了'I'm last.'字符串

当我们运行此代码时,你期望在控制台看到什么?希望它非常明显,我们将看到以下日志:

>I'm logged first!!
>I'm second...
>I'm last.
>undefined

在这种情况下,每个函数调用都被添加到调用栈中,依次执行,然后从栈顶弹出。因此,我们以正确的顺序看到了来自三个函数的字符串。你可以在以下屏幕截图中看到每个函数调用的栈帧:

图 7.2:演示调用栈中的执行顺序

图 7.2:演示调用栈中的执行顺序

现在,让我们通过在第二个函数块中添加一个setTimeout()函数来对我们的代码进行一个小改动。所以,让我们看看如果你编写并运行代码,这次三个字符串将如何被记录:

function firstCall() {
  console.log('I'm logged first!!");
  secondCall();
}
function secondCall() {
  setTimeout(function() {
    console.log('I'm second...");
  }, 0);
  thirdCall();
}
function thirdCall() {
  console.log('I'm last.");
}
firstCall();

逻辑上讲,我们控制台日志的顺序应该保持不变——毕竟,setTimeout的延迟是0毫秒,所以它应该会立即在控制台日志中执行,对吧?

然而,我们发现顺序已经改变:

>I'm logged first!!
>I'm last.
>undefined
>I'm second...

那么,你认为这里发生了什么?我们将逐行分析源代码,看看 JavaScript 运行时是如何处理每个函数的。

首先,调用firstCall()函数,并将一个新的栈帧推入该函数调用的调用栈顶部。该函数包含对控制台对象的log()方法的调用,参数是一个字符串类型的值'I'm logged first!!'。这在上面的图中被突出显示:

![图 7.3:调用 firstCall() 后的调用栈演示

![图片 C14377_07_03.jpg]

图 7.3:调用 firstCall() 后的调用栈演示

将字符串'I'm logged first!!'记录到控制台,调用secondCall()函数,并在栈顶为secondCall()推入一个新的栈帧。这里与原始代码片段不同之处在于,这个函数包含对setTimeout()函数的调用,它是浏览器 API 的一部分,因此这个函数目前被移出主 JavaScript 执行线程。setTimeout()函数以0毫秒的延迟被调用,之后将一个包含secondCall()函数中console.log()调用的引用的消息传递到消息队列。现在,这个消息坐在消息队列中,耐心地等待由event循环处理:

![图 7.4:演示 setTimeout() 方法被分配到消息队列

![图片 C14377_07_04.jpg]

图 7.4:演示 setTimeout() 方法被分配到消息队列

现在,让我们看看secondCall()函数是如何调用thirdCall()的。为这次函数调用在调用栈上推入另一个新的栈帧。它使用字符串'I'm last.'调用console.log(),该字符串被打印到控制台:

![图 7.5:最终函数调用的结束

![图片 C14377_07_05.jpg]

图 7.5:最终函数调用的结束

在这一点上,thirdCall()没有其他操作要执行,因此它隐式地将值undefined返回给secondCall(),并且thirdCall()的调用栈帧从调用栈中弹出。然后secondCall()undefined返回给firstCall(),并且secondCall()的调用栈帧从栈中弹出。接下来,firstCall()的调用栈帧从栈中弹出,并将undefined返回给主进程:

![图 7.6:显示函数返回后的空调用栈

![图片 C14377_07_06.jpg]

图 7.6:显示函数返回后的空调用栈

现在,我们可以看到调用栈上唯一剩下的帧是匿名的主进程,并且主进程没有调用其他函数。事件循环看到调用栈可用,并且消息队列中有一个等待的消息,因此它为待处理消息中引用的回调函数推入一个新的栈帧。这个回调函数是我们的剩余 console.log() 调用,带有字符串 'I'm second…'

![图 7.7:显示 console.log 调用栈帧img/C14377_07_07.jpg

图 7.7:显示 console.log 调用栈帧

最后的 console.log 调用被执行,字符串被打印到控制台,然后调用栈帧从调用栈中弹出,再次只留下匿名的主进程留在栈上。

这展示了尽管 JavaScript 是单线程的,但在之前调用的函数等待完成的同时继续执行代码是可能的。除了 setTimeout 之外,浏览器还内置了许多其他函数和 API,我们将在下一章中探讨更多这些内容。

练习 7.01:与事件循环一起工作

让我们看看如何将这一知识应用到更现实(尽管简单)的应用中。本练习的目的是说明事件循环如何在我们的应用中产生一些意外的行为,并了解我们如何与事件循环一起工作,为我们的应用提供有用的功能。让我们开始吧:

  1. 我们将有一个包含两个 <div> 元素的 HTML 文件,其 body 中的 ID 分别为 statusresult。应用的目的将是运行一个函数并在 result div 中显示结果。我们知道我们的函数将需要相当长的时间才能运行,因此我们还将结合状态功能,以便让用户了解应用中正在发生的事情。在这个例子中,我们的主函数将是一个任意计算,需要几秒钟才能完成。在现实生活中,这可能是一类复杂的计算,或者是从外部源(如数据库或 API)获取数据的函数。加载 index.html 文件:

    <!-- index.html -->
    <!DOCTYPE html>
    <html>
      <head>
        <script src='event-loop.js'></script>
      </head>
      <body>
        <span id='status'></span>
        <span id='result'></span>
      </body>
    </html>
    
  2. 在一个名为 event-loop.js 的单独的 JavaScript 文件中,我们将编写一组函数来构建我们的应用。首先,我们向窗口对象添加一个事件监听器,这样在 DOM 的内容加载完成之前(DOMContentLoaded 在浏览器完成 DOM 树结构时触发,不包括任何样式表或图像)其余的代码不会运行:

    // event-loop.js
    document.addEventListener('DOMContentLoaded', () => {
    
  3. 在此之后,我们将两个具有 statusresult ID 的 <span> 元素分配给两个恰如其分的变量:

      let statusSpan = document.getElementById('status');
      let resultSpan = document.getElementById('result');
    
  4. 接下来,我们定义了两个函数,showStatus()doCalculation()showStatus()函数接受一个名为statusText的参数,该参数将被设置为statusSpaninnerText属性,从而在页面上显示我们传递给showStatus()的任何文本:

      let showStatus = (statusText) => {
        statusSpan.innerText = statusText;
      };
    
  5. 另一个函数doCalculation()执行我们的计算,然后将结果设置到resultSpan变量的innerText属性中:

      let doCalculation = () => {
        let result = 0;
        for(var i = 0; i< 10000000000; i++) {
          result = result + i;
        };
        resultSpan.innerText = `The result is ${result}`;
      };
    });
    
  6. 因此,为了将这些结合起来,我们可以在DOMContentLoaded回调函数的末尾调用函数,如下所示:

    showStatus('Calculation running, please wait... Maybe for quite a while...');
    doCalculation();
    showStatus('Calculation finished, here is the result:');
    
  7. 尝试运行此代码,看看它是否按预期工作。不是很好,对吧?当我们第一次打开页面时,它会加载一段时间,然后显示Calculation finished状态和结果。但我们从未看到Calculation running状态:![图 7.8:应用程序第一版的结果 图片

图 7.8:应用程序第一版的结果

那么,为什么我们看不到第一个状态呢?当我们通过设置 DOM 节点的innerText属性等方式更新 DOM 时,DOM 树本身会更新,然后浏览器将渲染树重新绘制到浏览器窗口中。这是两个独立的步骤,重绘步骤发生在当前调用栈完成之后。所以,根据我们对事件循环的新理解,我们应该能够看到正在发生的事情。当我们第一次调用showStatus()时,DOM 被更新,但浏览器还没有重新绘制页面。然后,调用doCalculation(),执行线程被阻塞,直到计算完成。showStatus()函数第二次被调用,并带有Calculation finished字符串,此时,浏览器使用我们传递给showStatus()第二次调用的Calculation finished字符串重绘渲染树。

为了使我们的应用程序更符合我们的规范,在查看解决方案之前,先自己尝试一下:

showStatus('Calculation running, please wait... Maybe for quite a while...');
setTimeout(() => {
  doCalculation();
  showStatus('Calculation finished, here is the result:');
},0);

通过将doCalculation()的调用和showStatus()的第二次调用添加到setTimout()函数中,在第一次showStatus()函数执行后,调用栈被清空,此时浏览器重绘页面,显示预期的Calculation running字符串。这是一个更好的实现,因为它让用户了解应用程序正在做什么:

![图 7.9:在计算运行时显示状态图片

图 7.9:在计算运行时显示状态

Stack Overflow

调用栈是称为——你猜对了——栈的数据类型的一个例子。你可以简单地将栈视为对象的容器(在调用栈的情况下,栈帧代表函数和参数)。调用栈可以容纳的帧数是有限的。现在,我们将看看调用栈满了会发生什么,这是开发者面临的一个常见问题,也称为栈溢出。(在 V8 JavaScript 引擎中,它有不同的名称,但理论是相同的。)

例如,当开发者尝试编写一个递归函数但未能编写基例和/或终止条件时,这种情况就会发生。此时,递归应该停止。对于新开发者来说,考虑到可能绕过这些点的每个边缘情况可能很棘手。

首先,让我们看看一个简单的栈溢出例子,之后我们将探讨一个更贴近实际的例子。

给定以下代码,让我们看看在调用callMe()函数时,JavaScript 引擎会发生什么:

function callMe() {
  nowCallMe();
}
function nowCallMe() {
  callMe();
}

这段代码会发生什么应该是相当明显的,但仍然,让我们看看调用栈中这个过程的一些步骤。

当我们调用callMe()时,一个新的栈帧被添加到该调用的调用栈中:

图 7.10:第一个栈帧被添加到调用栈中

图 7.10:第一个栈帧被添加到调用栈中

callMe()内部,另一个函数nowCallMe()被调用,为该函数调用添加一个栈帧到栈中。nowCallMe()反过来调用callMe(),向调用栈中添加一个新的栈帧,以此类推,两个函数依次调用对方,每次调用都会在栈中添加一个新的栈帧。在这种情况下,JavaScript 执行线程没有其他地方可去——代码中没有条件会导致线程从这个循环中移动:

图 7.11:调用栈正在填充栈帧

图 7.11:调用栈正在填充栈帧

这个循环会继续向调用栈中添加栈帧,直到栈的极限。在 V8 JavaScript 引擎的实现中,栈中帧数的限制通常在 16,000 左右,尽管它可以更高或更低,这取决于每个帧的内容、使用的变量和其他因素。(在本章末尾,我们将编写一个函数来计算不同 JavaScript 引擎和环境的栈限制。)如果超过这个限制,引擎会抛出一个栈溢出错误,V8 将其称为Maximum call stack size exceeded

图 7.12:栈溢出错误

图 7.12:栈溢出错误

注意

可以在 V8 JavaScript 引擎中更改调用栈大小限制。要做到这一点,只需使用带有 stack-size=[value] 标志启动环境——无论是 Node、Chrome 还是其他实现——但请注意,这仅应用于调试或实验——你当然不希望编写期望以除默认调用栈大小之外的任何大小运行的代码。

现在,让我们看看一个更贴近现实生活的例子,稍后我们将看看如何修复它:

function countdownByTwo(num) {
  if (num === 0) return console.log(num);
  console.log(num);
  countdownByTwo(num - 2);
}

这个函数是 递归 的一个例子,我们将在本书的后面部分更详细地探讨。这个函数的作用可能一开始并不明显,所以让我们将其分解成几个步骤。

函数接收一个数字并递归地调用自身,每次调用从 num 参数中减去 2。假设我们用 10 作为参数调用它,如下所示:

countdownByTwo(10);

函数首先检查 num 是否等于 0,如果是,它将返回一个包含 0 值的控制台日志,并且此函数的执行将结束(这是我们的终止条件)。目前,num 等于 10,所以这个 if 语句是 false,执行继续到下一行。

下一条记录将 num 的值记录到控制台,然后继续到下一行。

现在,函数再次调用自身,这次 num 的值减去 2——在我们的例子中是 8。这个过程会继续。

通过使用 10 作为参数调用前面的函数,我们将看到 1086420 这些数字按预期记录到控制台。

但如果我们用奇数调用这个函数会发生什么?尝试用 11 作为输入调用相同的函数。你会看到我们的终止条件 num=== 0 永远不会发生,因为 num1 变到 -1

练习 7.02:Stack Overflow

本练习的目标是重写栈溢出函数,使其能够处理尽可能多的其他输入。考虑所有可能被调用此函数的参数,以及函数将如何处理每一个。让我们开始:

  1. 让我们通过一些可能的输入来确保我们考虑到所有可能的情况,从而最大限度地减少错误发生的风险。以下是我们需要小心的一些边缘情况:

    • num 输入是奇数。

    • 输入为 0。

    • 此输入小于 0。

    • 输入为空或不是 number 类型。

  2. 目前,让我们假设我们希望函数在输入不是数字或为负数时返回,如果输入为 0,则希望函数将 0 记录到控制台并返回。请按以下方式编写函数:

    function countdownByTwo(num) {
      if (typeof num !== 'number' || num< 0) return;
      console.log(num);
      countdownByTwo(num - 2);
    }
    

    在这里,我们使用typeof运算符添加了一个终止条件,以确定输入num是否为预期的数字。如果不是数字类型或是一个小于0的数字,我们将返回undefined。如果num是一个数字,并且它大于或等于0,那么函数将记录num的值,并再次以num –2调用自身,然后这个循环会重复。

  3. 通过对函数进行这些更改,我们考虑了初始输入为奇数的情况,这可能会绕过原始函数中的终止条件。我们还在考虑num不是number类型的情况;比如说,一个字符串或一个对象。但还有一些不那么明显的边缘情况我们需要注意。让我们看看当我们调用这样的函数时会发生什么:

    countdownByTwo('bananas' * 2)
    

    结果表明,将bananas乘以2没有任何意义:JavaScript 无法将结果强制转换为数值,因此它导致了一个NaN值。

    这对我们函数意味着什么?为了回答这个问题,我们需要确定NaN属性的数据类型。你可能会认为运行typeof NaN会返回除了number之外的其他任何值,但你会错的。正如我们在上一章中看到的,NaNNumber对象的一个属性,并且确实属于number类型。这导致另一个堆栈溢出,函数会重复记录NaN,直到达到最大调用堆栈大小。

  4. 当调用函数时使用Infinity也会出现类似的问题,因此很明显,我们需要在isFinite()函数中添加另一个检查,以返回其输入是否为有限且合法的数字,以处理这些边缘情况:

    function countdownByTwo(num) {
      if (typeof num !== 'number' || num< 0 || !isFinite(num)) return;
      console.log(num);
      countdownByTwo(num - 2);
    }
    
  5. 这个函数被用来移除typeof运算符检查。现在,我们为这个函数提供了一套相当健壮的终止情况。可能还有其他检查我们想要实现,例如限制函数将计数从哪个数字开始的大小。例如,如果我们想确保输入小于10,000,我们可以修改我们的if语句如下:

    if (num< 0 || num> 10000 || !isFinite(num)) return;
    

这会导致以下输出:

图 7.13:函数的多种输入输出

图 7.13:countdownByTwo()函数的多种输入输出

在这个练习中,我们看到了一个基本示例,说明了我们可能无意中编写了与 JavaScript 引擎工作不佳的代码,我们应该尽可能处理代码可能遇到的所有不同边缘情况。

到目前为止,在本章中,我们探讨了 JavaScript 运行时,它在概念上是什么,以及它包含的过程和组件。我们详细探讨了 JavaScript 引擎——特别是 V8 实现——以及它的调用堆栈和内存堆如何与其他运行时元素交互。

我们还查看了一种常见的调用栈错误,以及确保我们的代码不会达到最大调用栈大小的办法。

内存管理

现在,我们将注意力转向计算机硬件的另一个核心方面——它的内存。内存管理是 JavaScript 中软件开发的一个重要但常常被忽视的方面。内存管理简单来说就是为构成我们程序的各个数据结构分配、使用和释放系统内存。

不同的编程语言在处理内存管理时采用两种主要方法:显式分配和释放以及自动分配和释放。当用像 C 语言这样的显式内存管理语言编写软件时,软件开发者的任务是告诉编译器何时分配内存以及在任何给定阶段为软件分配多少内存。开发者还必须决定何时该内存不再需要,并明确告诉编译器释放它。这增加了开发者的工作量,并可能导致令人沮丧的错误被引入。

自动内存管理另一方面消除了开发者显式分配和释放内存的需求,这在很大程度上使得开发者的工作变得更简单。编译器在两个主要阶段从操作系统请求内存:编译时的静态分配和运行时的动态分配。现代 JavaScript 引擎使用即时编译,它利用了不止一个编译器——一个基线编译器——和一个或多个优化编译器,这些编译器重新编译并缓存代码的部分,使其更高效。这形成了一个编译、优化和反编译/重新编译的连续循环。结果是 JavaScript 代码在运行时持续编译和重新编译,这在一定程度上模糊了静态和动态内存分配阶段。

内存分配步骤基本上是直接的:JavaScript 引擎确定它需要的内存量,并从操作系统请求它。然后根据程序的需求读取和写入内存。内存管理的最后阶段,即释放,是我们需要在此处关注的。

垃圾回收器

JavaScript 引擎有一个额外的应用称为垃圾收集器,它处理运行时的内存自动释放。垃圾收集器使用一个称为标记-清除的过程来识别不再需要的对象并将它们从内存中移除。它是通过从一个根对象开始——例如,全局的 window 对象——遍历由根引用的每个对象来完成的。然后它检查那些对象引用的所有子对象和孙对象,从而绘制出从根可访问的所有对象。任何从这个图中断开连接的,因此无法由根对象访问的,都会被标记为删除,并随后从内存中移除:

let cat = {
  name: 'Professor Meow"
}

让我们看看以下图表:

图 7.14:对象之间的引用

图 7.14:对象之间的引用

在这个简单的例子中,根对象有一个对cat对象的引用,而cat对象有一个对其name属性的引用,该属性是一个值为Professor Meow的字符串。垃圾收集器将看到这些引用,并将cat对象及其name属性标记为可访问的,它们不会被收集。

如果我们现在将cat重新赋值为null,我们将移除根对象和catname属性之间的引用链:

cat = null; 

让我们看看以下图表:

图 7.15:引用丢失,内存释放

图 7.15:引用丢失,内存释放

cat对象仍然是全局对象的属性,值为null,但任何不是由另一个链接到根对象的cat属性引用的cat属性都将被垃圾收集器从内存中移除。

我们可以扩展这个例子来展示从多个其他对象引用一个对象如何保留其在内存中的位置:

let mammal = {
  hasTeeth: true,
  furry: true
}
let cat = {
  name: 'Professor Meow",
  class: mammal
}
let dog = {
  name: 'Captain Woof",
  class: mammal
}

让我们看看以下图表:

图 7.16:同一对象的多个引用

图 7.16:同一对象的多个引用

catdogmammal对象都是全局对象(在垃圾收集器中称为root)的属性,catdog对象通过它们的属性引用mammal对象。

如果我们现在将mammal对象重新赋值为null,然后再将cat对象重新赋值为null,那么通过cat对象从global对象到mammal对象的引用以及它的直接引用都将被断开。然而,由于我们通过dog对象对mammal对象还有另一个引用,mammal对象仍然可以从global对象访问,并且不会被垃圾收集器收集。

你可以看到,通过无意中保持对象引用,程序可能会占用比所需更多的内存。这对于大数据集来说尤其令人烦恼。

内存泄漏

垃圾回收对我们开发者来说是一个方便的过程,因为它减少了手动管理内存的工作量。但它是一把双刃剑;垃圾回收是自动发生的——我们无法触发垃圾回收过程,也不知道 JavaScript 引擎何时会决定进行垃圾回收运行——因此我们很容易忘记内存管理的潜在陷阱。但是垃圾回收并不是一个完美的过程,或者至少它并不总是按我们可能期望的方式运行。它通常会失败,无法释放实际上不再需要的内存。这不是垃圾回收过程中的错误或错误,这只是问题的一个症状:在执行过程中,一个对象是否会被再次需要的确定性只能由开发者来回答。

当一块内存被分配但保持对根对象的链接,即使在程序中不再需要时,它也永远不会被释放,并且将保持分配状态,从而占用系统内存,直到软件执行结束。这是一个内存泄漏,不难想象它们可以成为大问题,或者频繁发生。当我们在功能强大的机器上开发应用程序时,我们可能不会明显地发现我们的应用程序正在遭受内存泄漏,因此避免常见的错误并密切关注应用程序的内存使用情况是很重要的。

让我们看看一些常见的内存泄漏场景,并看看我们如何避免它们。

事件监听器

内存泄漏最常见的方式之一来自事件监听器。考虑以下代码:

document.getElementById('scrollable').addEventListener('scroll', function() {
  console.log('I've been scrolled!")
})

在事件监听器中使用匿名回调函数是很常见的,并且它会按预期工作(只要有一个 ID 为scrollable的元素)。然而,通过使用匿名函数,我们无法在稍后需要时移除事件监听器,这意味着一旦添加了这个事件监听器,它将在整个程序执行期间保持原位,并且根据函数的不同,每次调用都可能向内存中添加对象。为了解决这个问题,让我们声明一个命名函数,然后将该函数传递给事件监听器:

let scrollHandler = function () {
  console.log('I've been scrolled!")
}
document.getElementById('scrollable').addEventListener('click', scrollHandler)

现在我们有了处理函数而不是匿名函数,如果我们想在程序中稍后移除事件监听器,我们可以使用removeEventListener方法:

document.getElementById('scrollable').removeEventListener('scroll', scrollHandler)

分离的 DOM 节点

JavaScript 对 DOM 节点所做的任何引用都将阻止该节点的内存分配被释放,即使该节点随后被从 DOM 中移除。

例如,假设我们有一个想要添加到 DOM 中的图像源数组。将父 DOM 节点存储在一个变量中并通过这个变量引用添加图像是有意义的:

let imageParent = document.getElementById('image-wrapper');
imageSources.forEach(imgSrc => {
  let tempImg = document.createElement('img');
  tempImg.src = imgSrc;
  imageParent.appendChild(tempImg);
});

因此,现在,我们已经将所有附加到 DOM 上的图像都处理完毕,但我们还创建了对图像父元素的额外引用;在 DOM 树中有一个引用,通过 JavaScript 变量,即imageParent。假设在程序稍后我们需要删除图像父元素:

document.body.removeChild(imageParent);

这将删除具有 ID 为image-wrapper的 DOM 节点,但变量及其所有附加的子img元素仍然被imageParent变量引用,并且仍然会占用内存,永远不会被垃圾回收器收集。

在这种情况下,简单的解决方案是在从 DOM 中删除imageParent之后,将imageParent变量重新赋值为undefined

全局变量

由于标记-清除算法会寻找与内存对象图根节点相关联的所有引用,因此全局对象的任何变量(在基于浏览器的 JavaScript 中是window对象,在 Node.js 中是global对象),都将始终被引用,因此永远不会被垃圾回收器收集。

尽可能避免在全局对象上声明变量是一种良好的实践,换句话说,不要污染全局命名空间。避免这样做有几个很好的理由,其中之一是避免由此产生的内存泄漏。

当变量在全局对象上显式声明时,这通常是相当明显的,但有一些情况可能导致意外的全局变量:

function makeGlobalCat() {
  cat = 'I'm a cat"
}
makeGlobalCat();

这个函数创建了一个未声明的变量cat,它将隐式地成为global对象的一个属性,即使它是在函数内部创建的:

function makeGlobalDog() {
  this.dog = 'I'm a dog"
}
makeGlobalDog();

同样,使用this关键字将创建一个全局变量。你可以在函数内部使用varletconst声明一个变量,它将具有该函数的函数级作用域,而不是具有全局作用域,就像未声明的变量那样。在 JavaScript 文件顶部使用use strict语句也是一个好主意,这将导致在尝试创建未声明的变量时抛出错误。

识别内存泄漏

尽管我们尽了最大努力,内存泄漏仍然可能悄悄地进入我们的代码。它们通常并不明显,因为它们消耗的内存量可能很小,它们可能增长相对较慢,这意味着除非它们在许多小时或几天内持续运行,否则它们可能不会影响应用程序的性能。即使当你发现你有内存泄漏时,找到根本原因也可能很棘手。

练习 7.03:识别内存泄漏

让我们用一个简单的例子来看看 Chrome 的开发者工具如何帮助我们识别我们是否有内存泄漏。让我们开始吧。

  1. 在 Chrome 中打开一个新标签页,打开菜单 > 更多工具 > 开发者工具,然后转到标签页:图 7.17:一个空的代码片段

    图 7.17:一个空的代码片段

  2. 在窗口的左上角点击 '+ New snippet' 并添加以下代码以设置我们的内存泄漏:首先,我们创建一个新的 div 类型的 DOM 元素并将其分配给名为 imageWrapper 的变量:

    // Create a div element
    let imageWrapper = document.createElement('div');
    

    注意

    每个现代浏览器都有自己的开发者工具集。然而,对于处理内存泄漏,Chrome 的开发者工具最为有用。

  3. 接下来,我们声明三个函数来模拟一些用户与我们的页面交互。第一个函数 loadImages() 创建 50 个新的图像元素,并给它们添加一个包含 1,024 b 字符串的数据属性。这类似于加载图像并将它们添加到 imageWrapper

    function loadImages() {
      for (let i = 0; I < 50; +i) {
        let img = document.createElement('img');
        img.data = new Array(1024).join('b');
        imageWrapper.appendChild(img);
      } // Add 50 child images to the 'imageWrapper'
    }
    
  4. 下一个函数 add() 简单地将 imageWrapper 元素添加到文档体的末尾,我们的第三个函数 remove() 将用于移除该图像包装器。

    function add() {
      document.body.appendChild(imageWrapper);
    } // Add the 'imageWrapper' div to the end of body
    function remove() {
      document.body.removeChild(imageWrapper);
    } // Remove the 'imageWrapper' div from the body
    
  5. 现在,让我们编写最后一个函数来将这些三个函数结合起来:

    function process() {
      for (let i=0;i<1000;i++) {
        loadImages();
        add();
        remove();
      }
    }
    
  6. 这个最后的函数模拟多次添加和移除图像包装器,因此每次添加 50 张新图像。一个现实世界的例子可能是有图像画廊,用户点击“'next'”按钮来加载下一组图像(在我们的场景中,他们会点击它 1,000 次!)!显然,这将是这样一个功能的相当糟糕的实现,但我们的目的是以简单的方式演示内存泄漏是如何发生的。你的最终代码片段应该看起来像这样:![图 7.18:包含所有 process() 代码的代码片段 图片

    图 7.18:包含所有 process() 代码的代码片段

  7. 现在,点击 run snippet 按钮执行代码。

  8. 接下来,我们将转到开发者工具的性能标签页,查看我们在添加和移除图像时内存堆中发生的情况。从性能标签页,点击记录按钮开始记录性能配置文件。在记录过程中,在控制台中调用 process() 函数,比如三次,然后点击停止按钮。你现在应该会看到一个类似这样的屏幕:![图 7.19:开发者工具性能标签页的内存堆 图片

图 7.19:开发者工具性能标签页的内存堆

这是我们刚刚记录的性能配置文件。它可以告诉我们关于我们的应用程序在记录期间使用的系统资源的许多信息。这里蓝色的线条显示了 JavaScript 内存堆随时间使用的情况。在一个没有内存泄漏的应用程序中,我们期望内存使用量随着内存分配而反复上升,并在内存释放后回到一个基本水平,给我们一个 images,我们在每次调用 process() 后不再需要的图像,因此它们留在内存堆中。如果你注意到一个实际的应用程序随着时间的推移性能下降,或者使用高于预期的系统资源,那么这是一个检查内存泄漏的好地方。

练习 7.04:修复内存泄漏

既然我们已经确定存在内存泄漏——如前一个屏幕截图中的 JavaScript 内存堆图所示——我们的下一个任务是修复我们的代码,使其不再包含泄漏。根据你对垃圾回收器和泄漏原因的了解,尝试对之前的代码进行修复,以便垃圾回收器可以看到我们的对象何时不再需要。在每次函数调用后,记录一个性能分析,以查看垃圾回收器是否能够释放内存。你所寻找的是蓝色堆内存分配线随着内存分配而上升,但随后在规律的时间间隔内再次下降,表明内存正在被释放。这是垃圾回收器能够在每次process()函数执行后释放内存的迹象。让我们开始吧:

  1. 编写一个函数并将其添加到在process中调用的现有三个函数:

    function resetImageWrapper() {
      imageWrapper = document.createElement('div');
    }
    

    在这里,我们添加了一个名为resetImageWrapper()的函数,该函数将imageWrapper对象重置为空的div元素,并将其添加到process()函数的for…loop中。现在,每次从 DOM 中移除一组图像时,其 JavaScript 中的引用也会被移除,并且可以被垃圾回收器标记为删除。

  2. 下一步是在每次处理我们的图像时调用这个新函数,因此我们将它添加到我们的主process()函数中:

    function process() {
      for (let i = 0; i< 1000; i++) {
        loadImages();
        add();
        remove();
        resetImageWrapper();
      }
    }
    
  3. 再次,我们将运行性能分析器。前往开发者工具,然后点击“性能”标签页,并点击记录按钮开始性能分析记录。然后,多次调用process()函数,并查看内存堆的使用情况:图 7.20:内存堆使用情况

图 7.20:内存堆使用情况

注意

如你或许注意到的,我们代码的问题是我们将每个图像元素的引用及其数据属性的值存储在imageWrapper变量中。解决这个问题的一个简单方法是在每次从 DOM 中移除它时重新分配imageWrapper变量。

这是一个更健康的内存堆分析图。在每次三个process()函数调用之后,垃圾回收器可以看到图像不再被 DOM 或 JavaScript 引用,它们在内存中分配的空间被释放并归还给内存池。

在本节中,我们介绍了处理不同编程语言中内存管理的两种主要技术——手动和自动——并探讨了浏览器的垃圾回收器过程。然后,我们更详细地讨论了自动内存管理的重大缺点和现代浏览器的垃圾收集算法,即内存泄漏。最后,我们概述了我们可以用来识别我们的应用程序何时出现此类泄漏的技术之一。

活动 7.01:找出堆栈帧的数量

在本章的早期,我们学习了 JavaScript 的调用栈,并了解到如果向栈中添加过多的栈帧,则可能会生成错误。JavaScript 引擎的每个实现都可以有不同的栈大小限制。在这个活动中,我们将编写一个函数,它将告诉我们何时在触发栈溢出错误之前栈中达到最大数量的栈帧。

活动的概述步骤如下:

  1. 添加一个函数,该函数会反复调用自身,导致栈溢出。

  2. 计数函数自我调用的次数。(这就像计算推送到调用栈的栈帧数量。)

  3. 在栈溢出错误发生后显示最终数字。记住,在栈溢出错误之后不能调用任何新函数!

    该活动的输出将如下所示:

    图 7.21:显示在触发栈溢出之前推送到栈中的栈帧数量

图 7.21:显示在触发栈溢出之前推送到栈中的栈帧数量

注意

该活动的解决方案可以在第 731 页找到。

摘要

在本章中,我们看到了许多人认为只是“JavaScript”的代码块实际上可以被分解成单独的组件:JavaScript 引擎,包括调用栈、内存堆和垃圾回收器(以及本章未涵盖的其他重要组件);以及 JavaScript 运行时环境,如浏览器或 Node.js,它包含 JavaScript 引擎,并为引擎提供访问额外的函数和接口,例如 setTimeout() 或文件系统接口。

我们还探讨了 JavaScript 如何管理内存分配和释放,尽管它是自动管理的,但开发者仍然需要记住涉及的过程,以便编写能够使垃圾回收器正确工作的代码。

在下一章中,我们将更详细地探讨环境 API 的不同方面,以便我们可以了解在浏览器和 Node.js 中可以找到的一些不太常用的功能。

第八章:8. 浏览器 API

概述

到本章结束时,你将能够解释什么是 Web/浏览器 API;使用 JavaScript 在 HTML 中绘制;在浏览器中创建和控制音频;在浏览器中存储数据;在不同情况下决定使用哪种存储类型;测量和跟踪网站的性能;以及创建和管理浏览器与服务器之间的持续、双向连接。

在本章中,你将了解有趣的浏览器 API,并查看 JavaScript 的扩展功能。

简介

在上一章中,我们探讨了不同的 JavaScript 运行时环境的不同示例,并对它们的组件进行了概述。现在,我们将更深入地研究这些组件之一,即浏览器对象模型BOM),以及它向 JavaScript 公开的 API。

BOM 是一组属性和方法,浏览器通过这些属性和方法将它们提供给 JavaScript。现在,你已经遇到了 BOM 的许多部分,例如setTimeOut()方法和文档属性,它具有许多方法,如addEventListener()。这是一个微妙但重要的观点,我们将在本章中讨论的方法和属性不是 JavaScript 编程语言的一部分;也就是说,它们不是 ECMAScript 规范的一部分——JavaScript 引擎是按照这个规范构建的——但它们是浏览器的方法和属性,并且构成了 JavaScript 和浏览器(以及通过扩展,与它运行的其他系统)之间的接口。

正如每个浏览器都有自己的 JavaScript 引擎实现一样,每个浏览器都以略微不同的方式实现 BOM。因此,作为开发者,检查你想要使用的功能的跨浏览器兼容性非常重要,并为不支持某些功能的浏览器实现回退polyfills。通常情况下,特定浏览器的当前版本支持某些功能,但旧版本则不支持。

如我们之前提到的,你已经看到了 BOM 中一些更常用的方法。在这里,我们将更详细地探讨一些最常用且实用的浏览器 API,以及一些不太常用但功能强大的 BOM 方面,这将大大增加你可用工具的数量和功能,并允许你将一些超级酷甚至可能很有用的功能构建到你的网站和应用程序中。

Canvas

图片和图表是创建引人入胜的网站和应用程序的基本组成部分。我们已经知道如何在我们的页面中包含图片和视频,但我们可以通过使用 JavaScript 和<canvas>元素来绘制自己的图片、图表,甚至复杂的视觉元素,如图表或游戏元素。有了它,我们可以绘制路径和矩形,并控制诸如描边和填充颜色、线段虚线、圆弧半径(或半径,如果你喜欢这样称呼)等。

使用 JavaScript 在 HTML 画布内绘制图形的过程可以分解为几个不同的步骤:

  • 获取 HTML 的画布元素的引用。

  • 获取一个新的画布渲染上下文,图形将绘制在其上。

  • 根据需要设置各种绘图样式和选项(例如,线宽和填充颜色)。

  • 定义构成图形的路径。

  • “描边”或填充定义的路径和形状——这是实际绘图发生的步骤。

在下面的练习中,我们将首先使用fillRect()方法。这个方法是我们可以在画布上绘制的方法之一,正如其名称所暗示的,它绘制一个矩形并用颜色填充它。为了描述矩形,我们需要四个信息:左上角xy坐标,矩形的宽度和高度。因此,我们将传递给fillRect()的参数是一个x坐标,一个y坐标,宽度,和高度。

练习 8.01:使用 Canvas 元素绘制形状

让我们从一项练习开始,我们将学习如何与 Canvas 元素一起工作,API 的一些组成部分,以及我们如何使用它来绘制简单的形状。Canvas API 有许多方法和接口。在这个练习中,我们将查看一些最常用的方法。让我们开始吧:

  1. 创建一个名为index.html的 HTML 文件,其中包含一个<canvas>元素,并在 HTML 主体的DevTools控制台中引用一个 JavaScript 文件。我们将把这个 JavaScript 文件命名为canvas.js

    <!-- index.html -->
    <!DOCTYPE html>
    <html>
      <head>
      </head>
      <body>
        <canvas id='canvas' style="border: 1px solid"></canvas>
        <script src='canvas.js'></script>
      </body>
    </html>
    

    我们已经给画布元素分配了一个 ID 为'canvas',这样我们就可以在 JavaScript 中轻松选择它,并且有一个内联样式"border: 1px solid",这样我们就可以看到画布在 HTML 页面中占据的区域。

  2. 接下来,我们将在与index.html相同的目录下创建一个名为canvas.js的文件,并声明一个变量来保存对 HTML 画布元素的引用:

    // canvas.js
    let canvas = document.getElementById('canvas');
    
  3. 现在,我们将通过使用带有'2d'参数的getContext()方法创建一个渲染上下文,因为我们将会绘制 2D 图形。此方法接收一个字符串来表示上下文类型,并返回一个用于绘制和修改我们想要显示的图形的绘图上下文。有几种类型的上下文,但在这个 Canvas 的介绍中,我们只会查看'2d'上下文:

    let context = canvas.getContext('2d');
    

    现在我们有了上下文,我们可以开始绘图了。画布对象使用网格系统,其原点位于左上角,因此0,0坐标位于画布的左上角。我们从这个原点开始绘制我们的图形。

  4. 最后,我们将使用fillRect()方法在画布上绘制一个100100像素的矩形:

    context.fillRect(10,10, 100, 100);
    

    现在,在浏览器中打开 HTML 文件。你应该会看到如下内容:

    ![图 8.1:带有内部正方形的简单画布 图片

    图 8.1:带有内部正方形的简单画布

  5. 你可能已经注意到了画布元素相当小。默认情况下,画布是 300 x 150 像素,所以让我们在我们的 JavaScript 中添加几行代码,以便在加载 HTML 页面时画布的尺寸与窗口的大小相匹配。我们将使用窗口对象的 innerWidthinnerHeight 属性——它们告诉我们视口的宽度和高度——来设置画布的宽度和高度:

    // canvas.js
    let canvas = document.getElementById('canvas');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    let context = canvas.getContext('2d');
    context.fillRect(10,10, 100, 100);
    
  6. 现在我们有一个更大的画布(假设你的浏览器窗口比 300 x 150 像素大),我们可以开始尝试使用这些和其他绘图方法。让我们添加几个更多的矩形,但稍微改变一下:

    canvas.js
    let canvas = document.getElementById('canvas');
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;
    let context = canvas.getContext('2d');
    context.fillStyle = 'yellow';
    context.fillRect(10,10,200,200);
    context.fillStyle = 'black';
    context.strokeRect(230, 10, 200, 200);
    context.setLineDash([10]);
    context.strokeRect(450, 10, 200, 200);
    context.setLineDash([0]);
    context.strokeStyle = 'red';
    context.strokeRect(10, 230, 200, 200);
    context.fillRect(450, 230, 200, 200);
    context.clearRect(500, 280, 100, 100);
    
  7. 我们现在应该有五个新的矩形,看起来像这样(如果包括填充黑色矩形的末尾的 clearRect(),实际上有六个):![图 8.2:六个更多矩形 图片

图 8.2:六个更多矩形

在这个练习中,我们学习了如何使用 canvas 元素绘制所有格式的矩形。

练习中额外的代码相当直观,但仍有一些事情需要指出:

  • fillStylestrokeStyle 属性可以接受任何有效的 CSS 颜色值(十六进制、RGB、RGBA、HSL、HSLA 或命名颜色)。

  • setLineDash 属性接受一个数字数组,用于确定绘制线条和空白的距离。列表会重复,所以如果你传入 [5],线条和空白将重复,每个长度为 5 像素。如果你传入 [5, 15],则所有线条都将为 5 像素,所有空白都将为 15 像素。

  • 一旦设置,fillStylestrokeStylesetLineDashvalues 将持续应用于同一上下文中绘制的任何内容,所以如果你需要,请确保重置值,就像我们用 setLineDash 属性(否则红色矩形将是虚线)和 fillStyle 所做的那样。

  • clearRect 方法可以用来从画布的另一部分移除绘制的区域。

使用路径操纵形状

现在我们已经掌握了绘制矩形的技巧,我们将开始使用上下文对象上可用的其他一些方法绘制更有趣的形状。路径是一系列由线条连接的点。我们可以操纵这些路径的属性,如它们的曲率、颜色和粗细。这次,我们将首先介绍方法,然后看看它们是如何工作的:

  • beginPath(): 开始一个新的路径列表

  • moveTo(x,y): 设置下一个路径绘制的起点

  • lineTo(x,y): 从当前点创建一条到方法传入坐标的线

  • closePath(): 从最近的一个点创建一条到第一个点的线,从而封闭形状

  • stroke(): 绘制已描述的形状

  • fill(): 用纯色填充所描述的形状

这些方法可以用来画一个占据大部分画布宽度和高度的三角形。你可以用以下代码替换 canvas.js 中的上一段代码,或者创建一个新的 JavaScript 文件,并在 HTML 文件中的<script>标签的源属性中更改以反映新的 JavaScript 文件:

canvas-1.js
let canvas = document.getElementById('canvas');
const width = window.innerWidth;
const height = window.innerHeight;
canvas.width = width
canvas.height = height
let context = canvas.getContext('2d');
context.beginPath();
context.moveTo(50, 50);
context.lineTo(width - 50, 50);
context.lineTo(width / 2, height - 50);
context.closePath();
context.stroke();

首先,我们将窗口的内部宽度和高度值赋给一个变量,因为我们将会多次使用它们。在获取到画布对象的引用后,我们开始一个新的路径,并将起始点移动到两个轴上的50像素处。然后,我们从一个当前点画一条线到内宽度的50像素处,顶部50像素处的一个点。接着,我们画一条线到一个内宽度的半数,底部50像素处的一个点。最后两个方法用于关闭路径,并使用stroke()方法绘制整个形状。

图 8.3:一个三角形

图 8.3:一个三角形

创建分形图案的步骤相当直接,但以下是一些需要记住的要点:

  • 起始点应该是canvas元素的中部。

  • 线条在四个方向之一绘制。

  • 当点在画布边界内时,函数的线条绘制部分应重复执行。

  • 在前面的例子中,线条在每画两条线后长度增加。然而,你也可以通过在每条线上增加线条长度来得到类似的结果。

活动 8.01:创建一个简单的分形

现在,我们将把关于 HTML Canvas 所学的知识应用到实践中。这次,我们将使用 JavaScript 重复绘制步骤以创建一个非常简单的分形。尝试创建一个像这样的图案:

图 8.4:一个基本模式

图 8.4:一个基本模式

按照以下步骤创建分形:

  1. 在画布中部初始化一个坐标变量作为起始点。

  2. 创建一个循环。对于循环的每一次迭代,交替增加和减少坐标并绘制线条:

    • 通过增加或减少坐标值来使点向外螺旋移动

    • 从上一个点到新点画一条线

  3. 当点达到画布的任何边缘时,结束循环。

在查看解决方案之前,先花些时间自己尝试做这个。

注意

本活动的解决方案可以在第 732 页找到。

目前,我们将继续学习另一个 Web API:Web Audio API。

Web Audio API

此 API 提供了一套方法和对象,我们可以使用它们从各种来源向 HTML 添加音频,甚至允许开发者从头创建新声音。该 API 功能丰富,支持诸如声像、低通滤波器等多种效果,可以组合起来创建不同类型的音频应用程序。

与 Canvas API 类似,Audio API从音频上下文开始,然后在上下文中创建多个音频节点,形成一个音频处理图:

图 8.5. 音频上下文及其音频处理图

图 8.5. 音频上下文及其音频处理图

一个 音频节点 可以是一个源、一个目的地或一个音频处理器,例如一个滤波器或增益节点,并且它们可以组合起来创建所需的音频输出,然后可以传递给用户的扬声器或耳机。

练习 8.02:创建振荡器波形

在这个练习中,我们将看到如何在 JavaScript 中创建 一个简单的振荡器波形 并将其输出到系统的音频输出设备。让我们开始吧:

  1. 让我们先创建一个音频上下文并添加一个音量和振荡器节点。将以下代码输入到 Google Chrome 开发者工具的控制台窗口中(可以通过按 F12 键访问):

    // create the audio context
    let context = new AudioContext();
    // create a gain node
    let gain = context.createGain(); 
    // connect the gain node to the context destination
    gain.connect(context.destination);
    // create an oscillator node
    let osci = context.createOscillator(); 
    
  2. 现在,我们将设置振荡器类型为 'sawtooth' 并将振荡频率设置为 100。除了 'sawtooth',你还可以将振荡器类型设置为 'sine''square''triangle'。请随意尝试不同的频率:

    // set the oscillation type
    osci.type = 'sawtooth';
    // set the oscillation frequency
    osci.frequency.value = 100;
    

    注意

    波形的频率指的是波形完成一个周期或周期的频率,1 赫兹(1 Hz)表示每秒一个周期。我们感知频率更高的声波为音调更高。

  3. 最后,我们将振荡器连接到增益节点并调用振荡器的 start() 方法:

    // connect the oscillator node to the gain node
    osci.connect(gain);
    // start the oscillation node playing
    osci.start(); 
    

如果你将音量调大运行此代码,你应该会听到连续的振荡声音(声音类似于你在收音机上找不到想要频道时听到的静电噪声)。一些浏览器在用户以某种方式与屏幕交互之前不会播放使用 Audio API 的声音。这是为了阻止开发者制作出令人讨厌的播放不需要声音的页面。如果你遇到错误,只需在运行代码之前在屏幕上的某个位置点击一下。

我们可以添加多个源节点,可以是相同类型(在我们的例子中是一个振荡器)或不同类型的,并且它们可以分别控制,或者共享其他音频节点,如增益或平衡节点。当然,我们也可以让我们的音频上下文对某些外部输入做出响应,例如用户输入或时间事件。

活动 8.02:使用两个振荡器播放声音和控制频率

让我们通过添加一些交互性来更好地利用音频 API。在这个活动中,我们将有两个振荡器播放声音,用户可以通过在 HTML 页面上移动光标来控制它们的频率。一个振荡器的频率将由光标的 x 位置控制,随着光标向页面右侧移动,频率增加,另一个振荡器的频率由 y 位置控制,随着光标向页面底部移动,频率增加。

在检查解决方案之前,看看你是否能实现这个目标;这将是对本章末尾活动的良好练习。

一些帮助你开始的建议:

  • 两个振荡器应该在同一个上下文中,并且连接到同一个音量节点。

  • 振荡器节点接口提供了四种预设的振荡器类型:'sine'(默认)、'square''sawtooth''triangle'。我们的两个振荡器可以有不同的类型,所以可以尝试不同的组合。

该活动的概要步骤如下:

  1. 初始化一个音频上下文和一个音量节点。

  2. 创建一个增益节点并将其连接到上下文的目的地。

  3. 初始化两个振荡器(一个用于光标的每个坐标)。

  4. 设置振荡器类型,将它们连接到增益节点,并调用它们的start()方法。

  5. 创建一个document

  6. 根据光标的位置设置振荡器的频率。

    注意

    该活动的解决方案可以在第 733 页找到。

在我们继续到下一个 Web API 之前,这里有一些关于如何从当前播放的声音中提取数据并将其用于在我们的应用程序中可视化该声音的信息。

音频可视化

音频可视化是声音的图形表示。在音频程序中常见这种表示,它可以产生非常有趣的图案和形状。Web 音频有许多种类的音频节点。其中一种为音频可视化打开了许多可能性的是分析节点,它允许你访问其音频输入的波形和频率数据。除非你是声音技术人员,否则节点的内部工作原理相当晦涩,所以我们直接进入如何访问数据的方法。我们将使用一个额外的属性和一个方法来获取一些对可视化有用的数据:

  • frequencyBinCount:这实际上告诉我们我们有多少数据点可用于我们的数据可视化。

  • getFloatTimeDomainData():此方法接受一个Float32Array作为参数,并将当前波形数据复制到它。(Float32Array 是一种特殊的数组,它接受 32 位浮点数。当数组被分成数组中的项目数量时,它表示波形。每个项目代表波形那一部分的振幅,从-1 到 1)。

如果我们有一个振荡器节点,我们可以创建一个分析节点,将其连接到振荡器,并使用前面的两个属性来获取在那个精确时刻播放的声音的波形数据:

let oscillator = audioContect.createOscillator(); // create the oscillator
let analyser = audioContect.createAnalyser(); // create the analyser node
oscillator.connect(analyser); // connect the oscillator and the analyser
oscillator.start(); // start the oscillator playing
let waveform = new Float32Array(analyser.frequencyBinCount); 
// create a Float32Array which will hold the waveform data in the next step
analyser.getFloatTimeDomainData(waveform); // get the waveform data for the  sounds at this precise moment.

在创建音频可视化时,getFloatTimeDomainData函数会在每一帧被调用。本小节中的信息将在本章末尾的活动中有用,所以届时请参考它。

Web 存储 API

在浏览器中存储数据可以是一种提高用户体验的绝佳方式。它可以避免用户等待从服务器获取相同数据,并且可以用来立即将之前访问过的页面恢复到离开时的状态,这意味着,例如,用户不需要重新填写表单中的相同部分。《Web Storage API》用于以键/值对的形式在浏览器中存储数据。它可以用来存储用户在表单中输入的数据,以便他们可以轻松返回并稍后完成它,或者它可能是用户在 Web 应用程序中选择的偏好,您想要在相同源内的页面之间传递的数据,或者您认为有保存价值的其他任何数据。《Web Storage API》是同步的,因此设置和检索数据将阻塞其他 JavaScript 代码,直到 Web 存储方法完成。Web 存储旨在存储相对较小的数据量,在这种情况下,同步不会对性能产生明显影响。

你可能听说过 cookie,它是浏览器内数据存储的一个例子。Web 存储与 cookie 有些相似,尽管它们各自的使用场景不同:cookie 是用来向服务器发送数据的,而 Web 存储仅设计用于客户端存储。此外,Web 存储允许存储更多的数据——通常,Web 存储的极限是10 MB(尽管,就像在 Web 开发的世界中许多事情一样,这完全取决于浏览器),而 cookie 的限制是4 KB。另一个关键的区别是,cookie 必须设置过期日期,否则它们将在会话结束时过期,而另一方面,一种 Web 存储只能通过 JavaScript 或清除浏览器缓存来删除。

Web Storage API 非常简单,但在我们深入探讨之前,让我们看看 Web 存储的两种变体,并介绍一些关于该接口的其他关键点。

通过 API 提供的两种 Web 存储方式是sessionStoragelocalStorage。这两种存储方式的主要区别在于sessionStorage仅在当前会话活跃期间持续存在;也就是说,直到浏览器窗口关闭。另一方面,localStorage没有过期日期,将在客户端机器上持续存在,直到通过 JavaScript 或清除浏览器缓存来清除。sessionStoragelocalStorage都遵循相同的源原则,这意味着由特定域名存储的数据只能由该域名访问。

可用于sessionStoragelocalStorage的方法是相同的,API 的使用非常简单。我们有五种方法可供使用,但只有三到四种是常用的:

  • setItem() 方法是我们将键/值对存储在 Web 存储中的方式。它接受两个参数,都是字符串类型。第一个是项目的键,而第二个是其值:

    // Sets key 'dog' with value 'woof'
    sessionStorage.setItem('dog', 'woof');
    
  • getItem() 方法允许我们获取存储中设置的任何项目。它接受一个参数,即我们想要检索的项目的键。如果提供的键在存储中不存在,则它将返回 null:

    sessionStorge.getItem('dog');
    // gets the value of key 'dog'
    
  • removeItem() 方法接受一个参数,即您希望删除的项目的键:

    sessionStorage.removeItem('dog');
    // removes the key 'dog' and its value
    
  • clear() 方法清除当前页面的整个存储,并且不接受任何参数:

    sessionStorage.clear();
    // clears all sessionStorage for the current origin
    
  • key() 方法接受一个索引作为其参数,并返回该索引处的项目的键,如果该索引没有项目存在,则返回 null

    sessionStorage.key(0); 
    // returns the key of item at index 0 (if any)
    

此外,还有 sessionStorage.lengthlocalStorage.length 属性,它们返回浏览器存储对象中存储的项目数量。

Web 存储对象的行为与 JavaScript 对象非常相似,我们可以通过点符号以及使用 setItemgetItem 方法来访问它们的属性:

sessionStorage.planet = 'Mars'; 
// sets an item with the key 'planet'
sessionStorage.planet;
// returns the string 'Mars'

一个需要注意的重要点是,项目的值必须是基本数据类型,但这并不意味着我们不能使用 Web 存储存储更复杂的对象。如果我们想在 Web 存储中存储一个对象,我们可以在设置时使用 JSON 对象 obj 将其序列化,然后在再次检索时解析它:

let obj = {
  name: 'Japan',
  continent: 'Asia'
};
sessionStorage.setItem('country', JSON.stringify(obj));

然后,我们可以将 sessionStorage.getItem()JSON.parse() 结合起来检索对象:

JSON.parse(sessionStorage.getItem('country'));
// Outputs the country object 'obj' defined above.

练习 8.03:使用 localStorage API 存储和填充姓名

让我们创建一个简单的网页,它接受一些用户信息,并使用 localStorageAPI 存储这些信息,以便用户下次访问页面时可以显示。如今,浏览器对 Web 存储的支持非常强大。尽管如此,考虑到 Web 存储可能不被支持的可能性,确保在用户的浏览器不支持 Web 存储时提醒用户。在这个练习中,我们将要求用户提供他们的名字和姓氏。让我们开始吧:

  1. 首先,让我们创建一个带有标准样板 HTML 的 HTML 文件,并为用户的名字和姓氏添加几个输入框,如果浏览器不支持 Web 存储,则添加一个警告消息。我们将默认设置 <p> 标签的显示样式为 none:

    <!-- index.html -->
    <!DOCTYPE html>
    <html>
      <head>
      </head>
      <body>
        <input type="text" id='first-name' placeholder='First name'>
        <input type="text" id='last-name' placeholder='Last name'>
        <p style='display: none;' id='warning'>Your browser doesn't support local storage</p>
        <script src='storage.js'></script>
      </body>
    </html>
    

    如果您在浏览器中打开这个 HTML 文件,它看起来会是这样:

    ![图 8.6. 带有两个输入框的 HTML 页面 图片

    图 8.6. 带有两个输入框的 HTML 页面

  2. 接下来,我们将创建一个 JavaScript 文件,首先检查 localStorage 方法是否在窗口对象上可用。如果不可用,我们简单地返回并将警告消息的显示样式设置为 block,从而提醒用户页面将会有减少的功能:

    // storage.js
    if (!window.localStorage) {
      // if localStorage is not supported then display the warning and return out to stop the rest of the code from being run.
      document.getElementById('warning').style.display = 'block';
    } else {
    
  3. 如果浏览器支持localStorage,我们将继续将当前保存在localStorage中的firstNamelastName键的任何值分配给同名变量:

      let firstName = localStorage.getItem('firstName');
      let lastName = localStorage.getItem('lastName');
    
  4. 然后,我们将获取两个输入元素,如果firstNamelastName有值,那么这个值将被设置为相应的文本输入的值,从而将保存在localStorage中的任何字符串重新填充到相关的文本输入中:

      let inputFName = document.getElementById('first-name');
      let inputLName = document.getElementById('last-name');
      if (firstName) {
        inputFName.value = firstName;
      }
      if (lastName) {
        inputLName.value = lastName;
      }
    
  5. 我们需要做的最后一件事是为两个文本输入添加事件监听器,并在每次输入事件触发时将它们的当前值存储在localStorage中:

      inputFName.addEventListener('input', event => {
        localStorage.setItem('firstName', event.target.value);
      });
      inputLName.addEventListener('input', event => {
        localStorage.setItem('lastName', event.target.value);
      });
    }
    

错误输出将如下所示:

图 8.7:输出错误

图 8.7:输出错误

输出如下所示,显示了存储的两个名称:

图 8.8:存储并填充了两个输入变量的 HTML 页面

图 8.8:存储并填充了两个输入变量的 HTML 页面

这就完成了我们的简单应用程序。假设localStorage被支持,任何输入到文本输入中的字符串都将被保存并重新填充,即使页面刷新或浏览器或标签关闭后也是如此。

注意

在这里,我们的特性检测方法不够稳健,它不会检测,例如,当特性在浏览器中被禁用时。对于生产代码来说,更好的方法是在localStorage中尝试设置和获取一个项。如果获取的值符合预期,那么我们知道本地存储正在工作。

虽然 Web 存储 API 对于存储相对较小的数据量非常有用,但它并不适合存储较大的文件或数据结构。首先,我们只能在 Web 存储中存储字符串值,更重要的是,由于 API 是同步的,如果应用程序存储和检索大量数据,将会影响性能。

在我们想要客户端存储大量数据集、文件或 blob 的情况下,我们可以利用另一个浏览器 API:IndexedDB API。

IndexedDB

IndexedDB是另一种客户端数据存储形式,它在某些重要方面与 Web 存储不同:

  • 与 Web 存储不同,它非常适合存储大量数据,并且可以存储许多不同的数据类型。

  • 该 API 比 Web 存储 API 具有更强大的功能,允许我们执行对索引数据的查询等操作。

  • 它是一个异步 API,因此使用存储在 indexedDB 中的数据不会阻塞其他代码的执行。

最后两点暗示了使用 indexedDB 而不是 Web 存储的最大缺点:它的 API 和工作流程比 Web 存储的简单获取和设置方法更复杂。IndexedDB 经常被批评 API 过于复杂,但这是确保数据完整性的必要条件(关于这一点稍后讨论),而且无论如何,如果我们花时间理解一些核心概念,那么我们会发现它实际上并不那么复杂。

与 web 存储一样,indexedDB 遵循相同的源规则,这意味着只有位于同一域名、协议和端口的页面才能访问 indexedDB 的特定实例。在我们开始使用 indexedDB 之前,让我们检查一些其组件和核心概念。

一个 indexedDB 数据库包含一个或多个 对象存储。正如其名称所暗示的,对象存储是我们存储在数据库中的对象的容器。与 web 存储一样,indexedDB 中的对象以键/值对的形式存储,但与 web 存储 不同,值不需要是字符串类型。值可以是任何 JavaScript 数据类型,甚至是 blob 或文件。

这些对象通常是同一类型,但它们不需要具有完全相同的结构,正如您可能期望的传统数据库那样。例如,假设我们正在存储员工数据。对象存储中的两个对象可能都有薪资属性,但一个的值可能是 30,000,而另一个可能是三十万。

对象存储可以链接到索引(这些索引实际上是不同类型的对象存储)。这些索引用于高效查询我们存储在数据库中的数据。索引是自动维护的。我们将在稍后更详细地了解如何使用它们:

![图 8.9:indexedDB 的布局img/C14377_08_09.jpg

图 8.9:indexedDB 的布局

在 indexedDB 中,我们所有的 创建、读取、更新和删除CRUD)操作都是在 事务 中执行的,我们将在稍后详细讨论。在事务内部工作可能看起来是一种复杂的方式,但它是一种有效的方法,可以防止同时在对同一记录进行写操作。考虑在同一页面上打开的两个页面,它们都在尝试更新同一记录。当一个页面打开事务时,另一个页面无法对同一记录执行操作。

与 indexedDB 一起工作的过程可以分为四个步骤:

  1. 打开数据库。

  2. 如果所需的存储尚不存在,则创建对象存储。

  3. 处理事务:创建、读取、更新或删除记录或记录。

  4. 关闭事务。

练习 8.04:创建对象存储并添加数据

让我们创建一个将保存动物记录的数据库。我们将更详细地执行前面的步骤来创建数据库、创建对象存储、开始事务并向数据库添加一些数据。将以下代码添加到 Google Chrome 开发者工具的控制台:

  1. 我们将使用 indexedDB.open() 方法初始化一个名为 request 的变量,并将数据库名称 animals 和数据库版本号 1 作为参数传递。它返回一个请求对象,该对象将接收三个事件之一:successerrorupgradeneeded

    let request = window.indexedDB.open('animals', 1);
    

    当我们第一次调用 open 时,将触发 upgradeneeded 事件,我们可以附加一个 onupgradeneeded 事件处理函数,在其中我们将定义我们的对象存储。

  2. 然后,我们将定义一个函数来处理onupgradeneeded事件,将event.target.results中的数据库分配给db变量,并创建一个'mammals'对象存储:

    request.onupgradeneeded = event => { // handle the upgradeneeded event
      let db = event.target.result;
      db.createObjectStore('mammals', {
        keyPath: 'species'
      });
    };
    

    注意,我们在open方法中传递了第二个参数1。这是数据库的版本号,我们可以更改它以允许对对象存储进行更改,或者添加新的对象存储。我们稍后会看到它是如何工作的。

    数据库本身可通过请求对象的result属性访问。我们可以通过事件对象的event.target或通过请求对象(事件目标是请求对象)来访问它。

    然后,我们使用数据库的createObjectStore()方法创建一个新的存储。我们向此方法传递一个名称,这个名称可以是任何字符串,但通常应该描述正在存储的数据类型。我们还传递一个对象,其中包含键keypath和值为我们想要用于访问存储的对象的键,以及访问存储的对象。

  3. 现在我们已经创建了数据库,我们可以继续插入一些对象。这次,当我们调用indexedDB对象的open方法时——假设没有错误——成功事件将被触发,我们访问数据库并继续事务。让我们回顾一下我们在onsuccess处理程序中做了什么。再次将数据库分配给db变量,并处理可能发生的错误(现在,我们只是将它们记录到控制台):

    request.onsuccess = event => {
      let db = event.target.result;
      db.onerror = error => {
        console.log(error);
      }
    
  4. 使用storeName属性为'mammals'和类型为'readwrite'创建一个事务。这限制了事务只能对'mammals'对象存储执行读写操作:

      let transaction= db.transaction('mammals', 'readwrite');
    
  5. 接下来,我们将对象存储分配给store变量,并向存储中添加两个记录:

      let store = transaction.objectStore('mammals');
      store.put({
        species: "Canis lupus",
        commonName: "Wolf",
        traits: ["Furry", "Likes to howl at moon"]
      });
      store.put({
        species: "Nycticebuscoucang",
        commonName: "Slow Loris",
        traits: ["Furry", "Every day is Sunday"]
      });
    
  6. 然后,我们定义当事务接收到'complete'事件时应执行的操作,即关闭数据库,从而完成我们的事务:

      transaction.oncomplete = () => {
        db.close();
      };
    };
    
  7. 运行此代码后,假设没有错误,你可以打开 Chrome 的开发者工具,导航到应用程序标签页,并展开左侧的 IndexedDB 存储项。在这里,你会看到你刚刚创建的animals数据库,其中包含其哺乳动物对象存储,以及我们之前添加的两个条目:图 8.10:在开发者工具中查看 IndexedDB

    图 8.10:在开发者工具中查看 IndexedDB

  8. 现在你已经在数据库中保存了一些数据,让我们学习如何再次检索它。检索数据的过程与最初存储它的过程类似。当我们创建对象存储时,我们将keyPath设置为物种,因为我们知道这将是一个唯一的属性。我们可以使用这个属性来访问对象存储中的特定条目:

    indexedDB-v2.js
    1 let request = window.indexedDB.open('animals', 1);
    2 
    3 request.onsuccess = event => {
    4   let db = event.target.result;
    5   db.onerror = error => {
    6     // handle an error
    7     console.log(error); 
    8   }
    9   let trx = db.transaction('mammals', 'readonly');
    10   let store = trx.objectStore('mammals');
    11   let animalReq = store.get('Nycticebuscoucang');
    12   animalReq.onsuccess = (event) => {
    13     console.log(event.target.result);
    14   };
    The full code is available at: https://packt.live/2q8v5bX
    
  9. 就像我们之前做的那样,我们必须发起一个请求来打开数据库,并将一个onsuccess处理程序附加到该请求上。当成功事件被触发时,我们可以通过请求的结果或通过事件对象来访问数据库,即event.target.result。我们现在可以通过调用数据库的transaction()方法来创建一个事务,并指定我们想要的存储对象和事务类型,即哺乳动物和读写。

  10. 接下来,我们通过调用事务的objectStore()方法来访问存储。我们现在可以调用get()方法,并传入我们想要访问的条目的keyPath值。这个get()方法返回另一个请求对象,它也会接收到成功和错误的事件。我们将一个最终的成功处理程序附加到onsuccess属性,它将访问event.target.result属性。这包含我们要找的条目。

    当我们第一次创建数据库时,以及每次我们随后的请求打开它时,我们都将数据库版本号作为indexedDB.open()方法的第二个参数传递。只要我们保持版本号不变,数据库将以一致的对象存储打开,但我们不允许对存储的结构进行任何更改,也无法将新对象存储添加到数据库中。如果我们想修改对象存储或添加新的,我们需要升级我们的数据库。我们通过简单地创建一个打开请求并将新版本号传递给第二个参数来完成此操作。

    这将触发请求的onupgradeneeded事件,并允许我们创建一个版本更改事务,这是唯一可以修改或添加对象存储的事务类型。版本号必须是整数,并且任何新版本都必须比数据库的当前版本号高。

    假设我们想要添加另一个对象存储,这次是为了upgradeneeded事件中的动物。当我们第一次创建数据库时,这个过程是相同的。当添加一个新的对象存储时,请求对象上的成功事件将被触发。这意味着我们可以在创建它之后立即向我们的新对象存储添加条目:

indexedDB-v3.js
2 let request = window.indexedDB.open('animals', 2);
3 
4 // handle the upgradeneeded event
5 request.onupgradeneeded = event => {
6   let db = event.target.result;
7   // Our new cephalopods store
8   db.createObjectStore('cephalopods', {
9     keyPath: 'species'
10   });
11 };
12 
13 request.onsuccess = event => {
14   let db = event.target.result;
15   db.onerror = error => {
16     console.log(error) 
The full code is available at: https://packt.live/2pdYCAr

再次查看 Chrome 开发者工具的应用程序标签页,我们将看到我们新创建的头足类存储及其两个新条目:

图 8.11:新的对象存储和 indexedDB 中的条目

图 8.11:新的对象存储和 indexedDB 中的条目

在这个练习中,我们创建了一个包含动物记录的数据库。你可以进一步尝试添加不同的对象存储并向其中添加数据。

查询 IndexedDB

除了通过其键(在我们的例子中是物种)访问数据外,我们还可以对对象存储运行简单查询,以返回与我们的查询词匹配的多个条目。在 indexedDB 中的数据需要通过我们想要用于查询的任何键进行索引;与其他数据库不同,indexedDB 没有内置的搜索功能。如果我们决定想要使用与我们创建对象存储时设置的键路径不同的键,我们需要创建一个新的索引。

练习 8.05:查询数据库

在这个练习中,我们将看到如何使用与我们在创建 objectStore 时使用的 keyPath 不同的键。为此,我们将使用 createIndex 方法,该方法接受两个参数和一个作为第三个参数的 options 对象。第一个是我们想要与新的索引关联的名称,而第二个是我们想要链接到索引的数据键。这样做需要在创建数据库打开请求时再次更新数据库版本。让我们通过练习来看看我们如何实现这一点。就像我们之前做的那样,在 Google Chrome 的开发者工具中的代码片段中跟随:

  1. 向打开动物数据库发出新的请求,并将一个函数分配给 onupgradeneeded 事件:

    let request = window.indexedDB.open('animals', 3); // version 3 of the DB
    request.onupgradeneeded = event => {
    
  2. 通过 event.target.transaction.objectStore 访问哺乳动物存储,并在其上调用 createIndex() 方法:

      let store = event.target.transaction.objectStore('mammals');
      store.createIndex('traits', 'traits', {multiEntry: true, unique: false});
    };
    

    如我们之前提到的,createIndex 方法接受两个参数。在我们的例子中,我们使用特征作为这两个参数。第三个参数是一个 options 对象。在这里,你可以将唯一属性设置为 true 以确保数据库不允许存储此键的重复项,或者设置为 false 以允许具有此键相同值的多个记录。你还可以设置一个 multiEntry 参数。如果设置为 true,则数据库将为数组中的每个项目添加一个条目;如果设置为 false,则整个数组将作为一个条目进行索引。将此设置为 true 将允许我们通过单个特征查询条目,正如我们现在将看到的。

  3. 接下来,我们为数据库的第三个版本创建一个数据库打开请求对象并创建另一个 onsuccess 事件处理函数:

    let request = window.indexedDB.open('animals', 3);
    request.onsuccess = event => {
    
  4. 然后,我们获取结果数据库,创建一个事务,访问存储,并使用我们想要查询的索引名称调用存储的 index() 方法:

      let db = event.target.result;
      let trx = db.transaction('mammals', 'readonly');
      let store = trx.objectStore('mammals');
      let index = store.index('traits')
    
  5. 然后,我们调用 index.getAll() 并将 Furry 的值作为参数,将返回的值分配给 animalReq 变量。像往常一样,此对象通过成功事件接收,我们可以通过该事件访问与我们的查询匹配的所有记录的数组:

      let animalReq = index.getAll('Furry');
      animalReq.onsuccess = (event) => {
        console.log(event.target.result);
      };
    
  6. 最后,我们创建一个错误事件处理函数来处理可能出现的任何错误:

      animalReq.onerror = (error) => {
        console.log(error); // handle any error
      };
    };
    
  7. 如果我们运行此代码,我们应该得到所有与我们的查询匹配的数据库条目:

![图 8.12:访问数据库中所有毛茸茸哺乳动物的结果

![img/C14377_08_12.jpg]

图 8.12:访问数据库中所有毛茸茸哺乳动物的结果

在这个练习中,我们学会了使用与 keyPathcreateIndex 方法不同的键,该方法接受两个参数和一个作为第三个参数的 options 对象。

IndexedDB 光标

如我们之前提到的,indexedDB 没有对未索引记录键的本地记录搜索功能。如果我们想在我们的数据库中实现这个功能,我们就得自己动手。然而,indexedDB 确实为我们提供了一个光标,它是一个表示对象存储中位置的对象,我们可以使用它来遍历数据库中的对象。与其他 indexedDB API 的部分一样,光标对象是基于事件的,因此我们必须等待成功事件触发后才能继续我们的操作:

let request = window.indexedDB.open('animals', 3);
request.onsuccess = event => {
  let db = event.target.result;
  let trx = db.transaction('mammals', 'readonly');
  let store = trx.objectStore('mammals'); 
  let cursorReq = store.openCursor();
  cursorReq.onsuccess = e => {
    let cursor = e.target.result;
    if (cursor) {
      console.log(cursor.value); // do something with this entry.
      cursor.continue();
    } else {
      console.log('end of entries');
    };
  };
};

再次强调,我们将回顾获取数据库访问权限、打开事务和访问我们感兴趣的存储的过程。现在,我们可以使用对象存储的 openCursor() 方法来创建我们的光标。此方法可以接受两个可选参数:光标可以遍历的键的范围,以及当调用其 continue() 方法或 advance() 方法时告诉光标在记录中移动方向的参数。方向参数的可能值是 nextnextuniqueprevprevunique,默认为 next

在我们的情况下,我们没有向 openCursor() 方法提供任何参数,所以它将遍历所有键,并逐个记录地向前移动。

我们首先定义了一个 cursor.delete() 方法。在我们的例子中,我们只是将记录记录到控制台,然后调用 continue() 方法。调用 continue() 会将光标移动到下一个记录,然后触发 cursorReq 对象的成功事件,再次启动这个过程。如果光标已经到达记录的末尾,光标对象将是 null,我们可以终止这个过程。

在 indexedDB 中有很多内容需要介绍——这实际上并不令人惊讶,因为它是比我们之前查看的 Web Storage API 更全面、功能更多、复杂性也更高的客户端数据库。

在我们继续进行练习以巩固对 indexedDB 的理解之前,这里是对我们所学内容的快速回顾:

  • IndexedDB 适合存储大量数据。

  • 它可以存储比 Web Storage(任何 JavaScript 数据类型、文件或 blob)更多的数据类型。

  • 它是基于事件的——几乎所有的操作都是从数据库请求的,并接收各种事件。

  • 它是异步的。

  • 它包括数据库、一个或多个对象存储、数据对象和索引(一种对象存储)。

  • 所有操作都在事务内部发生,这确保了所有操作都成功完成,或者对象存储被回滚到事务前的状态。

  • 我们可以针对指定的索引查询记录。

  • 我们可以使用游标遍历对象存储中的记录,并使用它来创建我们自己的搜索功能,这符合我们应用程序的需求。

练习 8.06:获取、存储和查询数据

在这个练习中,我们将从远程 API 获取一些数据,将其存储在 indexedDB 数据库中,然后编写我们自己的函数来查询数据库以获取特定子集的数据。我们将通过向数据库添加 200 个 todo 项目并检索未完成的任务来实现这一点。

我们将要调用的 API 可以在 jsonplaceholder.typicode.com 找到。如果我们向它的 todos 路径发起 GET 请求,我们将得到一个 todo 项目的列表作为响应。

然后,我们将创建一个 indexedDB 数据库和一个对象存储,并将所有这些数据存储在存储中。在这个例子中,我们将使用 fetch API,这是另一个用于在 JavaScript 中发起 HTTP 请求的浏览器 API。让我们开始吧:

  1. 在 Google Chrome 开发者工具的新片段中,我们将从 API 获取数据:

    const http = new XMLHttpRequest();
    http.open('GET', 'https://jsonplaceholder.typicode.com/todos');
    http.send();
    http.onload = event => {
      let todos = JSON.parse(event.target.responseText);
    

    在这里,我们使用 XMLHttpRequest() 构造函数向我们的 API 端点发起一个新的 HTTP GET 请求。

  2. 然后,我们将一个函数设置到 HTTP 请求对象的 load 事件监听器上。这个事件处理器是我们从 API 接收 todos 数据的地方,也是我们将编写其余代码的地方。如果我们要在控制台输出 todos 变量,我们会看到以下格式的对象数组:

    {
      userId: 1,
      id: 1,
      completed: false,
      title: "delectusautautem"
    }
    
  3. 一旦我们将数据放入 todos 变量中,我们将创建一个名为 tasks 的新数据库和一个名为 todos 的新对象存储,并将对象存储的 keyPath 设置为 todo 项目的 id 属性(再次强调,所有这些都在 http 对象的 onload 处理器内部发生):

      let dbRequest = window.indexedDB.open('tasks', 1);
      dbRequest.onupgradeneeded = event => {
        // handle the upgradeneeded event
        let db = event.target.result;
        db.createObjectStore('todos', {
          keyPath: 'id'
        });
      };
    
  4. 现在,我们可以继续将 todo 项目添加到数据库中。就像我们之前做的那样,我们将在 http.onload 事件处理器中添加一些代码。这次,我们将在 dbRequest 对象中添加一个 onsuccess 函数,在这个函数中,我们将从成功事件对象中获取数据库,并开始一个针对 todos 存储的 readwrite 事务。我们将使用 forEach 循环从事务中访问存储,并遍历 todos 数组中的每个项目,将其推入数据库:

     dbRequest.onsuccess = event => {
        let db = event.target.result;
        let trx = db.transaction('todos', 'readwrite');
        let store = trx.objectStore('todos');
        todos.forEach(item => {
          store.put(item);
        });
        trx.oncomplete = () => {
          console.log('close');
          db.close();
        };
      };
    };
    
  5. 选择开发者工具的 Application 选项卡,并在左侧展开 IndexedDB 列表。在这里,你应该找到包含 todos 对象存储的任务数据库,它现在应该包含 200 个 todo 项目:图 8.13:添加数据后的 indexedDB

    图 8.13:添加数据后的 indexedDB

  6. 在我们的数据安全地存储在数据库中后,我们将编写一个查询函数来获取所有未完成的todo项。首先,我们将实例化一个空数组来存储我们的未完成todos。然后,我们将使用 indexedDB 游标接口遍历记录。对于每条记录,我们将检查完成属性是否为false。如果是,我们将该记录推入数组。由于我们已经在数据库中有了我们的数据,最好注释掉最后一块代码,否则,我们将再次进行 HTTP 请求并保存所有todos的副本:

exercise-8_06_1.js
1 let dbRequest = window.indexedDB.open('tasks', 1);
2 let outstandingTodos = [];
3 dbRequest.onsuccess = event => {
4   let db = event.target.result;
5   let trx = db.transaction('todos', 'readonly');
6   let store = trx.objectStore('todos');
7   let cursorReq = store.openCursor();
8   cursorReq.onsuccess = e => {
9     let cursor = e.target.result;
10     if (cursor) {
11       console.log(cursor.value)
12       if (!cursor.value.completed) outstandingTodos.push(cursor.value);
The full code is available at: https://packt.live/2qRT6Ek

这导致了以下输出:

Figure 8.14: The console output from our query function

img/C14377_08_14.jpg

图 8.14:我们的查询函数的控制台输出

我们可以从前面的图中看到,完成属性是false,未完成的是true。在这个练习中,我们学习了如何从远程 API 获取一些数据,将其存储在indexedDB数据库中,然后编写我们自己的函数来查询数据库以获取特定子集的数据。

本节介绍了一个更复杂的 Web API。以下是 IndexedDB API 的核心原则的快速回顾:

  • IndexedDB 数据库包含数据库,其中包含一个或多个对象存储,这些对象存储包含实际的数据对象

  • (几乎)所有事情都是通过事件发生的,所以你经常使用事件处理器。

  • 事务是业务发生的地方。事务仅适用于一个对象存储,可以是只读、读写或版本更改。

  • 如果项目已经被该键索引,你可以通过其键名获取项目,或者你可以使用游标遍历一组记录。

现在,我们将查看一个浏览器 API,我们可以使用它来获取有关网站或应用程序性能的信息。这个 API 出人意料地被称为性能 API。

性能 API

当我们构建网站和 Web 应用程序时,能够衡量我们应用程序的性能对于确保良好的用户体验非常重要。我们在开发、测试阶段和在生产环境中这样做。随着我们的应用程序增长并添加新功能,确保我们正在进行的更改不会对性能产生负面影响同样重要。有几种方法可以衡量这一点,还有一些有用的工具可以帮助我们。其中一套工具是浏览器的性能 API和其他密切相关 API。

性能 API 允许我们以极高的精度计时事件:我们可以访问的时间测量以毫秒为单位表示,但准确到大约 5 微秒。使用这些 API,我们可以准确测量完成特定动作所需的时间,例如以下动作:

  • 渲染我们页面第一个像素所需的时间

  • 用户点击一个元素与下一个动作(例如,动画的开始或向服务器发送请求)之间的时间

  • 各种页面资源加载所需的时间

  • 信息从浏览器发送到服务器,然后获取回复所需的时间

该 API 还使我们能够访问在网站加载前浏览器收集的特定数据,例如以下内容:

  • 导致页面加载的导航类型(从历史记录、导航事件或页面刷新)

  • DNS 响应 IP 地址所需的时间

  • 建立 TCP 连接所需的时间

你还可以创建自定义测量来查看特定过程在应用程序中花费的时间。所有这些信息都可以用来创建一个网站的详细性能报告,帮助识别需要优化的应用程序区域,并跟踪你在对网站进行更改时的性能改进(或打击)。

假设你想知道你的页面加载需要多长时间。这是一个合理的问题,但在准确和有用地回答这个问题之前,你必须更具体地说明你的意思。首先,你需要问自己你实际上想要什么信息:从开发者的角度来看,这个问题可以解释为“我的 web 服务器发送所有请求的资源到浏览器,以及浏览器处理和渲染这些资源需要多长时间?”,但从用户的角度来看,这个问题更像是,“从我点击链接到页面完全加载需要多长时间?”。这两个问题都很重要,但用户的问题需要更多的信息来回答,而不仅仅是开发者的问题。因此,我们可以开始看到,我们需要分解所有发生的事件,以便能够回答这些问题,以及其他问题。这正是性能 API 发挥作用的地方:它为我们提供了许多我们可以使用的指标,包括在我们请求页面之前发生的过程。

首先,让我们分解一下当用户点击新域名网站链接时发生的一些关键步骤。实际上,涉及的步骤比这里显示的要多,但在这个例子中,真正没有必要剖析整个流程:

图 8.15:用户点击链接后的过程概述

图 8.15:用户点击链接后的过程概述

图 8.15:用户点击链接后的过程概述

让我们按以下步骤进行:

  1. 当用户点击链接——比如一个 Google 搜索结果——浏览器向域名服务器(DNS)发送请求,并接收该域的 web 服务器 IP 地址。

  2. 浏览器随后与 IP 地址处的服务器建立 TCP 连接。

  3. 当这个连接过程完成后,浏览器请求页面数据。

  4. 服务器响应这些数据,浏览器处理并显示页面给用户。这是一个非常高级、简化、概括的描述,当浏览器想要加载一个页面时会发生什么,并且假设没有出错。这里的要点是事情很多,并且有许多潜在的导航和页面加载被减慢的区域。使用性能 API 给我们提供了许多关键事件的计时。

打开浏览器到任意页面。在控制台,你可以查看该页面的性能数据。我们可以从浏览器中获取一个导航时间对象,它将给我们提供我们寻找的大部分信息。首先,我们将性能 API 的导航条目分配给一个变量:

let navTiming = performance.getEntriesByType("navigation")[0]; // this returns an array, but we're only interested in one object.

getEntriesByType 方法返回浏览器存储的指定类型的所有性能计时条目。在这里,我们说我们想要所有导航类型的条目(只有一个条目,所以我们将得到一个包含一个对象的数组)。

在将返回数组中的 0th 对象的引用分配后,我们可以在控制台中通过输入变量的名称(即 navTiming)来查看该对象:

![Figure 8.16:Expanded navigation timing object]

![img/C14377_08_16.jpg]

图 8.16:展开的导航时间对象

展开导航条目对象,我们可以看到许多属性,我们可以使用这些属性来计算在导航和加载当前页面期间各种操作所花费的时间。让我们通过几个例子来了解一下,这样你就能抓住这个概念:

let dnsLookupTime = navTiming.domainLookupEnd - navTiming.domainLookupStart;

这将给我们提供域名服务响应请求域的 IP 地址所需的总时间。浏览器通常会缓存特定域的 IP 地址,所以如果你之前访问过你正在测试的页面,这可能会得到零。让我们看看以下代码:

let tcpConnectTime = navTiming.connectEnd - navTiming.connectStart

connectStartconnectEnd 属性是客户端与服务器建立 TCP 连接的时间和连接过程完成的时间。从其中一个减去另一个,我们得到总连接时间。让我们看看以下代码:

navTiming.domComplete;

domComplete 属性是浏览器完成加载文档及其所有资源(如 CSS 和图片)的时间,而 document.readyState 属性被设置为 complete。这将回答我们用户的问题:“从点击链接的那一刻到页面完全加载需要多长时间?”。

如你所见,在这个导航时间条目中,你可以使用许多其他指标来计时导航和加载页面。但是,一旦我们的页面加载完成,用户开始与之交互,怎么办?显然,我们希望能够在网站使用期间测量其性能,性能 API 给我们提供了一些非常有用的方法来实现这一点。

我们可以使用性能 API 通过使用接口的mark()measure()方法来测量我们网站或应用程序任何部分的性能。例如,假设你的应用程序涉及一些需要优化的 CPU 密集型处理,你可以使用性能标记来以高精度测量所需时间并测量不同优化方法的成功程度:

function complicatedFunction() {
  let n = 0;
  for (let i = 0; i< 1e9;) {
    n = n + i++;
  }
  return n;
};

在这里,我们定义了一个执行一些任意计算的for循环的函数,我们可以在循环的开始和结束处使用performance.mark()方法,然后使用performance.measure()方法来测量这两个标记并返回结果:

functioncomplicatedFunction() {
  let n = 0;
  performance.mark('compStart');
  for (let i = 0; i< 1e9;) {
    n = n + i++;
  }; 
  performance.mark('compEnd');
  console.log(n);
  performance.measure('compMeasure', 'compStart', 'compEnd');
  console.log(performance.getEntriesByName('compMeasure')[0].duration);
};

调用标记方法会创建一个带有提供名称的性能时间线条目(我们称之为compStartcompEnd)。然后我们可以使用performance.measure()来创建一个performance.measure条目,这将给我们提供开始和结束标记之间的精确时间。运行complicatedFunction()将给出以下输出:

![图 8.17:函数运行输出图 8.17:函数运行输出

图 8.17:函数运行输出

练习 8.07:评估性能

假设我们想要向我们的应用程序添加一个新功能,该功能涉及与上一个示例中类似的 CPU 密集型过程,因此我们想要确保以尽可能高效的方式编写函数。我们可以使用性能 API 的mark()measure()方法来找到运行特定代码段的确切时间,然后我们可以比较相同逻辑的两种不同实现。在这个练习中,我们将使用mark()方法标记我们想要比较的代码块的开始和结束点,并使用measure()方法来测量标记之间的确切时间。我们的输出将是时间差。

让我们拿上一个示例来比较 JavaScript 中不同循环函数的性能。让我们开始吧:

  1. 这个第一个函数将测量for循环的性能。首先,声明一个函数并初始化一个变量,该变量将保存循环中使用的值:

    function complicatedForLoop() {
      let n = 0;
    
  2. 现在,我们将使用performance.mark()方法标记循环函数的开始,并给标记一个名为forLoopStart的名字:

      performance.mark('forLoopStart');
    
  3. 接下来,我们将运行for循环,它执行的计算与上一个示例中的相同:

      for (let i = 0; i< 1e9;) {
        n = n + i++;
      }
      performance.mark('forLoopEnd');
      console.log(n);
      performance.measure('forLoopMeasure', 'forLoopStart', 'forLoopEnd');
      console.log(`for loop: ${performance.getEntriesByName('forLoopMeasure')[0].duration}`);
    };
    
  4. 第二个函数将测量while循环的性能:

    function complicatedWhileLoop() {
      let n = 0;
      let i = 0;
      performance.mark('whileLoopStart');
      while(i<1e9) {
        n = n + i++;
      }
      performance.mark('whileLoopEnd');
      console.log(n);
      performance.measure('whileLoopMeasure', 'whileLoopStart', 'whileLoopEnd');
      console.log(`while loop: ${performance.getEntriesByName('whileLoopMeasure')[0].duration}`)
    }
    
  5. 现在,让我们运行这两个函数并比较性能:

    complicatedForLoop();
    complicatedWhileLoop();
    

在这里,我们声明了两个函数,它们都产生相同的结果,但使用不同的 JavaScript 循环函数:一个for循环和一个while循环。我们在每个循环开始之前标记,并在循环结束时再次标记。然后我们测量标记,并将测量的持续时间记录到控制台。你得到了什么结果?

![图 8.18:对for循环和while循环的性能测试结果图 8.17:函数运行输出

图 8.18:for 循环和 while 循环的性能测试结果

您的结果可能会有很大差异,这取决于您运行的系统和 JavaScript 引擎,但您应该仍然能在两个循环语句之间看到明显的差异。本节深入探讨了相当高级的主题,可能不如绘制三角形那样令人兴奋。然而,应用程序的性能是需要牢记在心的,因为未能做到这一点可能会导致应用程序运行缓慢,人们会感到沮丧,并最终放弃使用。

WebSocket API

通常,当浏览器在正常浏览过程中连接到服务器时,它使用 HTTP 或 HTTPS。就本主题而言,我们真正需要了解的 HTTP 是,每次浏览器想要从服务器发送或接收信息时,它都必须打开到该服务器的新连接,发出请求,然后关闭连接。这在大多数情况下是可以的,但它是一条单行道;服务器无法与浏览器建立连接。这意味着如果服务器收到一些新数据,它无法通知浏览器,而必须依赖于浏览器在某个时刻查询服务器并请求数据。很多时候,这是可以接受的,因为作为开发者,我们知道何时可以期待新数据可用,或者我们知道在应用程序中何时需要请求任何新数据。

当然,仅仅依赖开发者的敏锐度是不够的,因为在某些情况下,我们无法完全控制新数据何时以及以何种频率提供给服务器。这类情况的经典例子是实时聊天应用,例如 Facebook 的即时消息或微信。我们可能都熟悉这些应用的基本功能:两个人或更多人可以互相发送消息,并且消息会立即出现在接收者的设备上(除去网络延迟和处理时间)。

但这种功能是如何实现的呢?如果我们从 HTTP 的角度来考虑这个问题,就没有优雅的解决方案:客户端 A 想要通过服务器向客户端 B 发送消息。从客户端 A 发送消息到服务器没有问题——客户端可以打开一个 HTTP 连接,发送消息,服务器就会收到消息。但当服务器需要将这条消息转发给客户端 B 时,服务器无法在其端打开连接。在这种情况下,解决方案是所有已连接的客户端定期询问服务器是否有新消息,比如每 30 秒一次。这不是一个好的解决方案;这意味着会有很多不必要的连接打开和关闭,每个连接都携带相对大量的数据,以 HTTP 头部形式存在。此外,如果客户端在第一秒发送消息,那么接收客户端至少需要 29 秒才能知道这条消息——如果这是一条重要消息怎么办?

WebSocket是浏览器和客户端之间通信的另一种方式,允许双向通信;也就是说,服务器可以在任何时候向客户端发送消息。从高层次来看,连接过程相当简单:客户端通过 HTTP 连接到服务器,并包含一个包含 Upgrade 头部的 WebSocket 握手请求(这基本上告诉服务器客户端想要将协议升级到 WebSocket),服务器发送握手响应,然后将 HTTP 连接升级为 WebSocket 连接。然后就开始了。

这个 WebSocket 连接会无限期地保持活跃,服务器会保持一个可以随时与之通信的已连接客户端列表。如果连接中断,则客户端可以尝试重新打开连接。只要连接保持活跃,任何一方都可以在任何时候向另一方发送消息,如果需要,另一方也可以回复这些消息。这是一个开放的、双向的通信通道,由我们开发者决定如何使用它。

WebSocket 消息可以包含多种类型的数据,包括字符串ArrayBuffersBlobs。为了发送和接收 JavaScript 对象,我们可以在发送之前使用 JSON 对象将它们转换为字符串,然后在接收端解析它们。

设置 WebSocket 服务器相当复杂,本章节中包含太多细节。然而,我们可以轻松设置 WebSocket 客户端并连接到多个在线 WebSocket 测试服务器之一。

有几个 WebSocket 测试服务器我们可以使用。在这个例子中,我们将使用位于wss://echo.websocket.org的服务器。如果它不起作用,请随意寻找另一个在线服务器。需要注意的是,客户端和服务器必须在同一 HTTP 协议上启动,所以如果您在控制台打开的页面是 HTTPS,那么 WebSocket 服务器必须在 WSS 协议上(而不是 WS)。

打开您浏览器中的任意页面,打开开发者工具,然后打开控制台。

WebSocket 连接是事件驱动的,因此当我们创建连接时,我们必须为要处理的事件分配函数。

首先,让我们使用浏览器的 WebSocket 构造函数创建一个新的 WebSocket 连接。它需要一个服务器地址作为参数:

let socket = new WebSocket('wss://echo.websocket.org');

如果您运行此代码,然后在控制台中访问 socket 对象,您将看到我们创建的新连接对象:

图 8.19:WebSocket 连接对象

图片

图 8.19:WebSocket 连接对象

在这里,我们可以看到我们连接到的服务器 URL,以及onmessage属性的事件监听器:

socket.onmessage = event => console.log(event);

现在,我们已准备好向 WebSocket 服务器发送消息:

socket.send("Hello websocket server");

我选择的 - 以及当然任何其他 WebSocket 测试服务器 - 将简单地输出你发送给它的任何消息作为响应。由于我们有一个附加到消息事件的处理器,该处理器将事件记录到控制台,我们应该在发送消息后不久在我们的控制台中看到这个事件对象:

图 8.20:WebSocket 服务器的响应

图 8.20:WebSocket 服务器的响应

我们现在有一个工作的 WebSocket 连接。如果服务器被编程为这样做,它可以在任何时候发送消息,只要连接保持打开。由于 WebSockets 对许多不同类型的应用程序都很有用,它们中没有内置特定的功能,尽管有一些非常常见的用例。我们需要开发系统来处理不同类型的消息,例如,发送一个带有“加入聊天组”操作的消息与一个常规的“向用户发送消息”操作。

练习 8.08:使用 WebSocket 创建聊天室

让我们创建一个小型应用,以便更充分地使用这个 WebSocket 服务器。我们将创建一个包含两个聊天室的应用程序:一个是群聊,另一个是只有一个用户的直接消息聊天室。由于 WebSocket 服务器功能有限,因为它所做的只是将接收到的消息发送回客户端。由于我们只有一个客户端,服务器将只响应我们发送的消息,所以这将是一个有点孤独的聊天。

对于这个应用,我们需要一个包含两个聊天消息列表的 HTML 页面:一个用于群聊,另一个用于直接消息聊天。我们还需要为这两个聊天线程添加一个输入框,以便我们可以输入我们的消息,以及沿途的一些其他元素。我们将为大多数元素分配相关的 ID,这样我们就可以在 JavaScript 中轻松获取它们。让我们开始吧:

  1. 让我们从创建一个 HTML 页面开始,添加我们的 HTML 开头标签,在 DevTools 控制台中添加一个引用 JavaScript 文件的 head 标签,并添加我们的 body 开头标签:

    <!-- index.html -->
    <!DOCTYPE html>
    <html>
      <head>
        <script src='scripts.js'></script>
      </head>
      <body>
    
  2. 现在,在主体内部,我们将添加一个 <h1> 元素作为我们页面的标题:

        <h1>The Echo Chamber</h1>
    
  3. 让我们添加一个 <h4> 元素,这将让我们知道套接字是打开还是关闭(默认是关闭):

        <h4 id='socket-status'>Socket is closed</h4>
    
  4. 让我们添加一个 <h6> 元素作为我们的群聊消息列表标题:

        <h6>Group Chat</h6>
    
  5. 让我们添加一个 <ul> 元素,我们将向其中追加新的群组消息:

        <ul id='group-list'></ul>
    
  6. 让我们在群聊中添加一个 <input> 元素,我们将在这里输入消息:

        <input type="text" id='group-input'>
    
  7. 让我们添加另一个 <h6> 元素用于私人聊天室:

        <h6>Private Chat</h6>
    
  8. 让我们添加一个 <ul> 元素用于私人聊天消息列表:

        <ul id='dm-list'></ul>
    
  9. 以下是我们编写私人消息所需的输入:

        <input type="text" id='dm-input'>
    
  10. 最后,我们需要添加我们的关闭 <body><html> 标签:

      </body>
    </html>
    

    这将产生以下输出:

    图 8.21:我们新的聊天应用的 HTML

    图 8.21:我们新的聊天应用的 HTML

    现在让我们更详细地了解一下 JavaScript 的功能。我们需要获取一些 HTML 元素,以便在 JavaScript 中处理它们。我们需要打开一个新的 WebSocket 连接到我们的服务器,即wss://echo.websocket.org。我们希望在套接字打开或关闭时通知用户,因此我们将添加onopenonclose套接字事件处理程序,并相应地设置我们的<h4>元素的文本。我们将监听用户在任一输入框中按下Enter键时的情况,然后向套接字服务器发送消息。服务器将回显我们的消息,因此我们将监听传入的消息,解码它们,并将它们附加到正确的消息列表的末尾。

    这是对我们的 JavaScript 将执行的操作的高级概述,让我们逐步通过代码。

  11. 我们将使用一个监听DOMContentLoaded事件的监听器来开始 JavaScript 文件,并将我们的代码放在事件监听器的回调函数中:

    // scripts.js
    // wait for page load
    document.addEventListener('DOMContentLoaded', () => { 
    
  12. 接下来,我们将创建一个新的套接字连接到我们选择的服务器:

      let socket = new WebSocket("wss://echo.websocket.org"); // create new  socket connection
    
  13. 让我们获取我们需要的各种 HTML 元素的引用:

      let dmInput = document.getElementById('dm-id'); // get the DM text input
      let groupInput = document.getElementById('group-input'); // get the group text input
      let dmList = document.getElementById('dm-list'); // get the dm messages list
      let groupList = document.getElementById('group-list'); // get the group  messages list
    
  14. 现在,我们将设置套接字的onopen事件处理函数,该函数将socket-status元素的内部文本设置为 Socket is open:

      socket.onopen = event => {
        document.getElementById('socket-status').innerText = "Socket is open"; 
        // set the status on open
      };
    
  15. 我们还将为套接字的onclose事件设置一个函数,该函数将状态重置为 Socket is closed:

      socket.onclose = event => {
        document.getElementById('socket-status').innerText = "Socket is closed";
        // set the status on close
      };
    
  16. 接下来,我们将设置套接字的onmessage函数。此事件在从 WebSocket 服务器接收到消息时触发:

      // prepare to receive socket messages
      socket.onmessage = event => { 
    
  17. 我们将使用 JSON 对象的parse()方法将传入的数据从字符串解析回 JavaScript 对象,并将结果分配给一个变量:

        // parse the data
        let messageData = JSON.parse(event.data); 
    
  18. 我们将创建一个新的<li>元素,并将其分配给一个名为newMessage的变量:

        // create a new HTML <li> element
        let newMessage = document.createElement('li'); 
    
  19. 接下来,我们将newMessage <li>的内部文本值设置为消息数据的消息属性值:

        // set the <li> element's innerText to the message text
        newMessage.innerText = messageData.message; 
    
  20. 现在,我们将检查消息是否是针对群聊的,如果是,我们将将其追加到groupList中:

        // if it's a group message
        if (messageData.action === 'group') { 
          // append to the group list
          groupList.append(newMessage); 
    
  21. 如果它不是针对群聊的,那么我们将将其追加到 DM 列表中,然后关闭此事件处理函数:

        } else {
          // append to the dm list
          dmList.append(newMessage); 
        };
      };
    
  22. 接下来,我们将迭代 HTML 的两个输入元素:

      // For each input element
      Array.from(document.getElementsByTagName('input')).forEach(input => { 
    
  23. 我们将在输入元素上添加一个keydown事件监听器,并将处理函数分配给该事件:

        // add a keydown event listener
        input.addEventListener('keydown', event => { 
    
  24. 如果keydown事件是由具有messageData的键触发的:

          // if it's keyCode 13 (the enter key)
          if (event.keyCode === 13) {
            // declare the message data object
            let messageData = {
              message: event.target.value,
            };
    
  25. 现在,我们将检查目标输入是否是具有group-input ID 的那个,如果是的话,我们将在messageData变量上设置一个值为 group 的动作属性:

            // check the message type by looking at the input element's ID
            if (event.target.id === 'group-input') {
              messageData.action = 'group';
    
  26. 否则,我们将分配相同的属性,但值为 dm:

            } else {
              messageData.action = 'dm';
            };
    
  27. 然后,我们将使用JSON.stringify()方法将messageData对象转换为字符串,并使用我们最初创建的套接字连接对象的send()方法将其发送到 WebSocket 服务器:

            // stringify the message and send it through the socket connection
            socket.send(JSON.stringify(messageData));
    
  28. 最后,我们将清除目标输入框并关闭函数:

            // clear the input element
            event.target.value = ''; 
          };
        });
      });
    });
    

在浏览器中打开 HTML 文件,如果你在任一输入框中输入消息,你应该在聊天列表中看到它被回显:

![图 8.22:Echo Chamber 聊天应用的消息img/C14377_08_22.jpg

图 8.22:Echo Chamber 聊天应用的消息

这是对我们如何向 WebSocket API 添加自己的功能的一个快速了解。在任何需要实时数据在浏览器中显示的时候,WebSockets 都很有用,比如股票市场价格的更新,或者当与服务器保持持续开放连接有意义时,例如在聊天应用中。

活动 8.03:音频可视化

我们将把本章一开始就探讨的一些接口结合起来,即画布 API 和 Web Audio API。这个活动的目的是创建一个显示图形的页面,并且这个图形将根据我们在 音频 API 部分查看的 Audio API 的 getFloatTimeDomainData 方法进行动画处理。音频 API 的声音应由用户控制,图形应以某种方式表示音频(例如,动画可以根据声音的音量或频率变化)。

这个活动规格相当广泛,但你可以为两个 API 构建练习来提出一些想法,或者你可以利用本章前面 Web Audio API 部分的 音频可视化 子部分的中的信息。在查看解决方案之前,看看你能想出什么。

活动的高级步骤如下:

  1. 创建一个简单的 HTML 文件,其中包含一个指向 JavaScript 文件的链接。

  2. 在文档上添加一个事件监听器,监听点击事件。

  3. 设置一个 HTML 画布元素和画布渲染上下文。

  4. 设置一个包含一个或多个振荡器或其他音频源的音频上下文。

  5. 将音频分析仪连接到音频上下文。

  6. 开始音频源。

  7. 在一个连续的循环中,使用音频 API 的 getFloatTimeDomainData() 方法的输出在画布上下文中绘制,以修改循环每次迭代中图形的一个或多个参数。

预期的输出应如下所示:

![图 8.23:音频可视化输出图像的一帧img/C14377_08_23.jpg

图 8.23:音频可视化输出图像的一帧

注意

这个活动的解决方案可以在第 734 页找到。

摘要

在本章中,我们探讨了几个最有用和有趣的浏览器 API,这些 API 为我们打开了广泛的功能,我们可以在 JavaScript 应用程序中使用。我们了解到,尽管这些 API 通常通过 JavaScript 访问,但它们并不是 JavaScript 引擎编程的 ECMAScript 规范的一部分,也不是 JavaScript 的核心功能的一部分。尽管我们在本章中涵盖了相当多的信息,但还有许多其他 API 可供我们使用。当与浏览器 API 一起工作时,检查特定功能在浏览器中的支持程度非常重要,因为一些 API 是实验性的或非标准的,而其他 API 则已过时或废弃。通常,某些浏览器将完全支持一个功能,其他浏览器将支持同一接口的某些方面,而其他浏览器则完全不支持。这有点像雷区,但请利用caniuse.com,这是你在本书早期看到的,来引导你自己和你的项目走向正确的方向。

要查看可用的 Web API 列表,请查看 Mozilla 开发者网络页面:developer.mozilla.org/en-US/docs/Web/API

到目前为止,你主要学习的是基于浏览器的传统 JavaScript。然而,JavaScript 还可以在浏览器之外的其他许多环境中运行。在下一章中,我们将探讨这些其他环境,特别是Node.js,它通常用于服务器端 JavaScript 执行。

第九章:9. 使用 Node.js

概述

到本章结束时,你将能够描述 Node.js 的基础知识,并使用 Node.js 构建基本的 Web 应用程序;区分同步和异步处理;使用 Node Package Manager (npm) 通过命令行界面添加、删除和更新包;使用内置和第三方节点模块;运行 MySQL 和 MongoDB 数据库;并使用 WebSocket 构建实时 Web 应用程序,等等。

简介

到目前为止,你已经了解了 JavaScript 的基础和核心基础知识。这包括理解使用 JavaScript 代码构建交互式 Web 程序的核心语法。对这种编程语言基础的良好理解将使我们能够了解 Node.js,它超越了浏览器。它是 JavaScript 流行的基础。

在本章中,你将了解 Node.js。在 Node.js 之前,JavaScript 主要用于浏览器中的客户端脚本。2009 年,Ryan Dahl 开发了 Node.js,这是一个跨平台的开源 JavaScript 运行时环境,可以在浏览器之外执行 JavaScript。它允许开发者使用命令行工具并执行服务器端脚本。基本上,它通过单一编程语言统一了整个 Web 应用程序开发过程,而不是开发者需要学习不同的语言并为服务器端和客户端构建不同的项目。

Node.js 不仅被视为一种编程语言,而且是一个可以执行 JavaScript 的环境。它是一种流行的编程语言,在 GitHub 上拥有庞大的仓库,由世界各地的数千名开发者的贡献所维持。在本章中,你将从所有平台上的 Node.js 安装开始,然后了解它在后台的工作方式以及如何异步处理请求。此外,你将学习不同类型的模块以及如何使用它们。你还将进行许多重要的练习,以获得 Node.js 的实际应用经验。有很多东西要学习,所以让我们开始吧。

Node.js 环境

Node.js 拥有事件驱动的架构,能够异步处理请求。Node.js 采用单线程架构。传统的服务器采用多线程架构,每当有新的请求到来时,就会创建一个新的线程,但 Node.js 在单个线程上处理所有事情。你可能想知道单线程的 Node.js 如何处理数百万个请求。答案是事件循环。JavaScript 在单线程上运行,并通过事件循环架构处理异步操作。任何耗时较长的请求都会被发送到后台,然后处理下一个请求。在继续之前,让我们了解同步处理和异步处理之间的区别。

同步与异步

如果程序的执行是按线性序列进行的,那么这就是同步处理。例如,在以下代码块中,将读取并执行整行,然后进程才会移动到下一行:

var fs = require('fs');
var contents = fs.readFileSync('fake.js', 'utf8');
console.log(contents);

这个过程在只有一个请求的情况下效果最好。在多个请求的情况下,你必须等待前一个请求完成。这可能会像看草生长一样令人兴奋。为了克服这个问题,你可以异步处理请求。这样,你将任何耗时太长的过程从执行栈中推送到后台,以便其他代码可以执行。一旦后台工作完成,程序将再次推回到执行栈并进一步处理:

var fs = require('fs');
fs.readFile('DATA', 'utf8', function(err, contents) {   
    console.log(contents);
});
console.log('after calling readFile');

请求处理背景

Node.js 使用一个名为llibuv的库。它处理异步 I/O 非常出色。它不是为每个请求启动多个线程,而是在操作系统的内核帮助下非常高效地管理线程池。一旦新的请求落在 Node.js 服务器上,它就会将大部分工作委托给其他系统工作者。一旦后台工作者完成他们的工作,他们就会向注册在该事件上的 Node.js 回调函数发出事件。这个过程在以下图中得到了可视化:

![图 9.1:Node.js 事件循环架构

![img/C14377_09_01.jpg]

图 9.1:Node.js 事件循环架构

Node.js 比多线程系统快得多,即使只有一个线程。因此,Node.js 使用具有线程池管理的事件循环架构,这使得它比竞争对手更强大和更快。

什么是回调?

当涉及到异步编程时,回调是一个非常重要的概念。回调是一个可以在其主函数完成时立即执行的功能。回调在 Node.js 中被广泛使用。

回调可能有用的一个典型例子是从文件中读取文本。在读取文件时,你不想服务器等待它完成。文件读取可以由后台工作者处理,一旦完成,它将执行一个事件,该事件将被事件循环处理。然后,该事件将执行回调。

Node.js Shell

Node.js 自带一个虚拟终端 shell。它提供了一种快速使用 Node.js 的方法。你可以在 shell 中执行表达式。你还可以在 shell 中执行循环并定义函数。要进入 shell,打开你的终端并输入 node。

注意

REPL也是一个内置模块。你还可以将其导入到你的模块中。

练习 9.01:你的第一个程序

现在你已经了解了 Node.js 环境和它的运作方式,你准备好编写你的第一个脚本,并用 Node.js 执行它。让我们编写我们的第一个非常简单的 Node.js 脚本,我们将只执行两个数字的和,并将输出显示在屏幕上:

  1. 创建一个名为first.js的文件。在同一目录下打开终端,并添加以下行:

    // 1\. define the function
    let add = (a, b) => {  
        return a + b;
    }
    // 2\. Call the defined function
    console.log("Sum of 12 and 34 is", add(12, 34));
    
  2. 运行函数以获取输出:

![图 9.2:你的第一个程序输出

![img/C14377_09_02.jpg]

图 9.2:你的第一个程序的输出

在这里,你已经编写了一个简单的函数来添加两个数字,你将在调用函数时传递这些数字。然后,你将使用 Node.js 执行此脚本,输出将打印在控制台。

如何在 Node.js 应用程序中导入/引入模块

在 Node.js 中导入/引入程序中的其他模块非常简单。你可以使用 require 将其他模块导入到你的 Node.js 应用程序中。假设我们需要 Node.js 的一个内置模块在我们的脚本中。我们会使用以下语法:

const path = require('path');

这将在 Node.js 模块内以及项目全局或本地安装的任何包中查找包。如果找到,它将导入它;否则,它将抛出异常。在编写模块化代码时,你可以创建自己的自定义包,并使用 require 和相对路径导入它们,如下所示:

const myModule = require('./modules/myModule');

节点包管理器(npm)

与 Node.js 一起工作的好处之一是你可以编写高度模块化的代码。互联网上有数百万个包可供你的项目使用。但随着你在项目中使用的包的数量增加,处理它们的难度也会增加。Node.js 自带自己的包管理器,称为 npm。

npm 有数千个包,所有这些都可以通过其网络门户和命令行界面轻松访问。它用于管理你的应用程序需要的包。你可以通过命令行界面添加、删除和更新包。

注意

npm 在所有主要平台上在安装 Node.js 时预先配置。在 Linux 的情况下,如果你在成功安装 Node.js 后访问 npm 时遇到问题,那么你必须将 npm 的路径添加到 $PATH 变量中。在书的 前言 部分检查 Linux 安装部分以获取更多详细信息。在 Windows 和 Mac 的情况下,你很可能不会遇到任何问题。

标志

一些有用的命令行标志是:

-g = 在系统中全局安装包。

-S = 将包保存为项目依赖项。类似于 –save

-D = 将包保存为 dev 依赖项。类似于 --save-dev

-v = 检查当前安装的版本。

命令

npm 的一些非常有用的命令如下所示:

  • npmi 参数。例如,假设你想将 express.js 添加到你的程序中——你会这样做:

    $ npm install express //i is the shortcut to install. (npm i express)
    
  • npm。例如,如果你想在程序中更新 express.js,你会使用这个:

    $ npm update express
    
  • rm 参数的 npm。例如,假设你想从你的程序中删除之前安装的包(express.js)——你可以这样做:

    $ npm remove express // rm is the shortcut to remove. (npm rm express)
    
  • npm 注册表。使用 publish 参数将包推送到 npm 注册表:

    $ npm publish
    
  • search 参数:

    $ npm search express
    

package.json

package.json是一个始终位于项目根目录的文件。它是一个清单文件,几乎所有的Node.js项目都有。这是npm用来管理依赖项的文件。在开始Node.js开发之前,每个人都应该了解package.json是什么以及它做什么。它基本上有两个主要用途:

  • 管理您项目的依赖项

  • 提供帮助生成构建、运行测试以及与您的项目相关的其他内容的脚本

您可以在该文件中定义启动脚本,这将帮助您将环境变量注入到您的项目中。您甚至可以使用此文件来配置生产环境和开发环境。

要在项目根目录中创建此文件,请在您的终端中执行以下操作:

$ npminit

您将被提示回答一个问题。您可以简单地按Enter键跳过它,并在当前目录中创建一个名为package.json的文件:

图 9.3:package.json 的示例输出

图 9.3:package.json 的示例输出

发布一个包

npm注册表完全对外开放新包。您可以在npm注册表中构建和上传自己的包,为此,您只需要一个包含package.json文件的目录。您可以直接编写您的模块并更新package.json参数。然后,您使用以下命令将其推送到注册表:

$ npm publish

现在,您可以在www.npmjs.com上搜索您的包,任何人都可以将其作为依赖项安装到他们的项目中。

在本节中,您了解了 Node.js 及其工作原理,并编写并执行了您的第一个 Node.js 程序。您学习了如何高效地处理 Node.js 包。您理解了package.json的目的和重要性。这只是一个介绍。现在,既然我们已经向您介绍了 Node.js,让我们更深入地探讨如何管理 Node.js 包并在项目中使用它们。

Node 模块

首先,我们可以这样说,Node.js 模块可以生动地理解应用程序的依赖项。假设您已经创建了一个易于使用的支付应用程序,比如为餐厅设计的。您已经开发了一个支付应用程序。现在,您有了在应用程序中实现 QR 扫描器的想法,以使账单支付更加简单。好吧,您有两个选择。要么自己从头开始创建整个功能,花费时间开发,要么可以使用npm的大量模块库存将相同的功能安装到您的应用程序中。

您只需遵循以下步骤即可:

  1. 您需要在谷歌上搜索模块名称,例如QR scanner,以便在您的 Node 或 Angular 应用程序中使用。

  2. 您需要的第一链接是www.npmjs.com/。在这个库存中,您可以看到许多高效的 Node.js 模块。您可以在那里找到各种各样的模块,用于美化您的终端或修正您的代码。当您找到所需的模块时,您将需要安装并将其与您的应用程序合并。

  3. 现在您已经有了模块,您只需在您的应用程序中实现它。最后一步是在您的终端中输入以下命令来安装该模块:

    $ npm install <module_name> --save
    
  4. 这将在您的 package.json 文件中将您的模块添加为依赖项。您只需按照技术语法导入或引入它。只需按照已安装模块的指南复制并粘贴函数。现在是运行您应用程序的时候了:

    $ npm start
    

您现在在几分钟内就为您的应用程序添加了一些奇妙的新功能。

总结一下,Node.js 模块是一个由开发者持续维护的、包含一个或多个 JavaScript 文件的、旨在以最有效和可持续的方式在您的应用程序中执行特定操作的、正确打包的神奇盒子。

您甚至可以创建自己的 Node.js 模块并发布它们。这使您成为开源贡献者。在继续之前,让我们跳到一个关于内置 node 模块的有趣讨论。

Node.js 模块

Node.js 包含了许多不需要安装的模块。其中之一是基本的 URL 模块。

URL

URL 是 Node.js 提供的一个模块,用于将复杂的 URL 字符串拆分为更易读的格式。它可以如下使用:

const url = require('url');

此模块提供了一些实用工具,您可以使用它们来解析和解析 URL。如果您仔细查看任何 URL,您会发现它包含一些以复杂格式编写的特定组件:

![图 9.4:将 URL 拆分为不同的术语img/C14377_09_04.jpg

图 9.4:将 URL 拆分为不同的术语

您可以使用 URL 模块来解决您遇到的任何困难。该模块将 URL 视为一个对象,URL 内部的每个组件都被视为该对象的属性,这意味着您可以轻松访问 URL 的每个部分。

本表中显示了 URL 的一些有用属性:

![图 9.5:URL 属性img/C14377_09_05.jpg

图 9.5:URL 属性

练习 9.02:使用 URL 模块更新 URL 信息

为了理解 URL 的不同属性,让我们做一个练习,我们将尝试更新 URL 的信息,例如路径名和主机。这将帮助我们了解如何操作 URL 对象的属性以更改 URL,当我们需要时:

  1. 创建一个空文件,并保存为 .js 扩展名。对于这个练习,让我们创建 url.js

  2. 首先要做的事情是导入 URL 模块:

    const url = require('url');
    
  3. 现在,让我们使用 URL 模块的 parse 函数尝试处理一个 URL:

    const url = url.parse('https://www.google.com/maps#horizontal');
    
  4. 调用 parse 函数后,您将得到一个可处理的对象。该对象将包含该 URL 的所有元数据。然后我们可以使用此对象来操作 URL。让我们更改 URL 的主机、路径名和哈希值:

    url.host ='maps.google.com'; // https://maps.google.com/maps#horizontal
    url.pathname = '/q'; // https://maps.google.com/q
    url.hash = 'vertical'; // https://maps.google.com/q#vertical
    
  5. 现在,让我们使用可处理的 URL 对象的 format 函数将其格式化为字符串,并使用 console.log 函数打印它:

    console.log(URL.format(url));
    
  6. 最后,只需使用 Node.js 执行脚本。它将打印出你使用 URL 对象的属性更新后的新 URL:

图 9.6:URL 程序的输出

图 9.6:URL 程序的输出

在这个练习中,我们学习了如何操作 URL 的属性。我们通过使用 URL 模块提供的不同函数修改了 URL 的不同组件。

文件系统

Node.js 部分你可以用来处理文件系统。你可以使用此模块执行各种文件和目录操作,例如创建、更新、读取和删除操作。try...catch 语句是一块用于处理使用同步操作发生的异常的语句。这些异常也可以被允许向上冒泡。

注意

在继续前进之前,请了解你将在一个名为 intro.txt 的文件上执行所有这些操作,该文件包含 Node.js 的介绍。所以,请确保你已经在项目的根目录中有了包含一些内容的 intro.txt 文件,你将在那里编写这些脚本。你可以通过在终端窗口中简单地输入 node NameofYourFile.js 来运行和测试脚本,确保你有适当的权限。

你可以使用以下方式使用此模块:

  • read 用于使用 fs.open() 方法在文件系统中读取文件:

    var fs = require('fs');
    fs.readFile('sample.txt', 'utf-8', (err, data) => {
            if (err) { console.log(err) }
            console.log('Data read from file: ', data);
    });
    

    这将打印出文件的所有数据到控制台。

  • append 通过使用 fs.appendFile() 向文件添加特定内容:

    var fs = require('fs');
    var data = "\nLearn Node.js with the help of a well built Node.js tutorial.";
    fs.appendFile('sample.txt', data, 'utf8',
    // using the callback function
    function (err) {
    if (err) throw err;
    // if there is no error
        console.log("New data was appended to file successfully.")
    });
    

    它会将作为 appendFile 函数第二个参数传递的行追加到文件中。

  • 在文件系统中重命名文件是通过使用 fs.rename() 方法完成的:

    var fs = require('fs');
    fs.rename('sample.txt', 'introduction.txt', (err) => {
            if (err) { console.log(err) }
            console.log('Done');
    })
    

    此代码将 intro.txt 文件重命名为 introduction.txt 文件。

  • 可以使用 fs.unlink() 方法删除文件:

    var fs = require('fs');
    fs.unlink('introduction.txt', (err) => {
            if (err) { console.log(err) }
            console.log('Done');
    })
    

你使用 unlink 从文件系统中删除任何文件。只需传递你想要删除的相对路径或文件名,它将从文件系统中解除链接该文件/路径。

操作系统

本节提供了一些与操作系统相关的实用方法。可以使用以下方式导入:

const os = require('os');

此模块的一些重要功能如下:

  • os.arch()

    此方法将返回 Node.js 二进制文件编译的操作系统 CPU 架构,即 armarm64x32x64 等。如果你正在设计任何架构相关的模块,此模块非常有用。

  • os.cpus()

    此方法将返回一个对象数组,其中包含每个 CPU 核心的所有信息。

  • os.hostname()

    此方法将返回操作系统的主机名。

  • os.platform()

    此方法将返回 Node.js 编译的操作系统平台。这将在 Node.js 的编译时间设置。一些著名的平台包括 Darwin、freebsd、linux、openbsd 和 win32。

  • os.networkInterfaces()

    此方法将为我们提供有关已分配网络地址的网络接口的所有信息。它将返回一个对象,每个键将标识一个网络接口。

练习 9.03:获取操作系统详细信息

让我们通过一个简单的练习来探索这个模块。在这个练习中,您将使用 Node.js 的 OS 模块来获取有关操作系统的详细信息:

  1. 您必须在您的 Node.js 脚本中使用 require 导入 os 模块:

    const os = require('os');
    
  2. 然后,您可以访问此 os 对象来调用其属性并获取必要的信息:

    console.log(os.arch()); //x32, x64
    console.log(os.platform()); //win32, Win64, Darwin, Linux
    
  3. 您可以根据脚本运行的架构更改代码执行的流程。

    输出将如下所示:

    ![图 9.7:node 的 REPL 模式内内置的 os 模块的一些有用方法]

    ![img/C14377_09_07.jpg]

图 9.7:node 的 REPL 模式内内置的 os 模块的一些有用方法

在这个练习中,我们学习了一些有用的方法来根据脚本的架构更改代码执行的流程,并获取操作系统的详细信息。

路径模块

path 模块提供了用于处理文件和目录路径的实用工具。它可以按以下方式导入:

const path = require('path');

此模块的一些重要函数包括:

path.dirname(pathString)

此方法将返回路径的目录名。它与 Unix 的 dirname 命令类似。

path.extname(pathString)

此方法将返回路径的扩展名。它将从输入路径中最后一个 .(点)字符开始,到路径的末尾。

path.format(pathObject)

此方法将从具有特定键的对象中返回一个路径字符串。这是 path.parse() 的相反操作。

path.join([...pathStrings, pathString... ])

此方法使用平台特定的分隔符作为分隔符将所有给定的路径段连接在一起,然后规范化结果路径。当您需要程序化地切换目录时,它非常有用。

练习 9.04:如何提取和连接目录

让我们通过一个练习来详细探索 path 模块。在这个练习中,您将从一个绝对路径和相对路径中提取目录,连接目录,并从一个路径中提取文件扩展名:

  1. 创建一个空文件,并以 .js 扩展名保存它。对于这个练习,让我们称它为 path.js

  2. 现在,让我们提供一些示例目录和文件名供您使用,以观察 path 模块如何处理路径。请注意,这些路径与 Windows 相关:

    let dir = 'C:/Packt';
    let otherDir = '/assets/images/';
    let file = path.js';
    
  3. 首先,让我们从一个路径中获取目录。假设您有一个文件的路径(/Users/YourUserName/Documents/node/modules/myFile.js),您想提取该文件所在的目录:

    // On Windows: "C:/Packt"
    path.dirname(dir+file);
    
  4. 现在,让我们从路径字符串中提取文件扩展名。为此,您必须使用 path 模块的 extname 函数:

    path.extname(file); // ".js"
    
  5. 最后,让我们尝试将多个目录和文件连接起来以创建路径。您可以使用 path 模块的 join 方法来完成此操作:

    path.join(dir, otherDir + file)
    
  6. 现在您有了完整的代码片段,请在终端中运行 node path.js

    const path = require('path');
    let dir = "C:/Packt";
    let otherDir = "/assets/images/";
    let file = "path.js";
    console.log(path.dirname(dir + file));
    console.log(path.extname(file));
    console.log(path.join(dir, otherDir + file));
    

    上述代码的截图将显示如下:

    图 9.8:路径程序的输出

图 9.8:路径程序的输出

我们可以在前面的图中看到目录的路径。我们还学习了如何从路径中提取文件扩展名。

HTTP

http 是 Node.js 中最重要的模块。它帮助您启动一个将监听特定端口的服务器。它将允许您通过 超文本传输协议HTTP)传输信息。

createServer 函数接受一个函数作为参数,当您向服务器发出任何请求时,该函数将被调用。该函数有两个参数:reqres。第一个参数,请求参数,是一个包含请求所有详细信息的流。例如,如果您使用 POST 请求提交表单,那么这个对象将包含其中的所有值。响应参数也是一个流,您可以使用它来更新响应头、状态等:

res.writeHead(200, {'Content-Type': 'application/json'});

在这里,您正在更新头部的键,并将状态码写入 200,即“OK”。参数与查询的区别在以下图中突出显示:

图 9.9:参数与查询的区别

图 9.9:参数与查询的区别

您也可以从同一个 req 对象中获取参数和查询,这将有助于您处理请求。

练习 9.05:使用 Node.js 服务器

让我们开始我们的第一个 Node.js 服务器。这将是一个非常基础的服务器,它将仅对所有的请求返回一个“Hello World!”响应。我们将学习如何在特定端口上启动服务器以及如何编写对请求的响应。让我们直接进入编码:

  1. 创建一个名为 http_server.js 的文件,并将以下内容复制到该文件中,然后保存:

    var http = require('http');
    var port = 3000;
    // Start the server instance
    let server = http.createServer( function (req, res) {
            res.write( 'Hello World!' ); // Response content
            res.end(); // End response
    });
    server.listen( port ); // the server object listens on port 3000
    

    它应该显示如下:

    图 9.10:Node.js 服务器

    图 9.10:Node.js 服务器

  2. 在您的终端中使用以下命令运行 http_server.js 文件:

    $ npm i express
    
  3. 然后打开浏览器并访问 http://localhost:3000

图 9.11:Chrome 中 Node.js 服务器的输出

图 9.11:Chrome 中 Node.js 服务器的输出

通过启动服务器,我们可以在浏览器中看到 Hello World 响应。我们学习了如何使用 Node.js 服务器以及如何编写对请求的响应。

第三方模块

Node.js 拥有一个庞大的包库,其中许多开发者已经编写并发布了供您使用的有用模块。您可以使用 npm 简单地下载这些模块并在您的项目中使用它们。npm 存储库中有数千个包可用。让我们看看一些可用于 Node.js 的有用第三方包。

Express.js

Express 是 node.js 最受欢迎的框架之一。这也是 Node.js 非常受欢迎的原因之一。它是一个最小化、开源且灵活的 Web 应用程序框架,为 Web 和移动应用程序提供了一套强大的功能。

您可以使用以下命令安装它:

$ npm i express

Express 中启动 Web 服务器非常简单:

const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(3000, () => console.log('Example app listening on port 3000)));}!'))

服务器只需四行代码即可启动并运行。

Express 中的路由

Express 对路由处理得非常好。您可以在 Express 中编写模块化的路由。以下代码可以用来设置基本路由

const express = require('express')
const app = express()
// GET
app.get('/', function (req, res) {
  res.send('Hello World!')
})
// POST
app.post('/', function (req, res) {
  res.send('Got a POST request')
})
// PUT
app.put('/user', function (req, res) {
  res.send('Got a PUT request at /user')
})
// DELETE
app.delete('/user', function (req, res) {
  res.send('Got a DELETE request at /user')
})

bodyParser 模块

JSON 是一个非常常见的用于互联网上超过 90% 的网络应用程序的数据共享格式。在 JavaScript 中管理 JSON 非常容易,但当涉及到在线共享 JSON 时,它就变得稍微困难一些。为此,我们使用 bodyParser 模块。数据在互联网上以缓冲区形式共享。此模块作为请求接收和您的应用程序之间的中间件。它将缓冲区转换为纯 JSON 并将其绑定到请求:

var express = require('express')
var bodyParser = require('body-parser')

var app = express()

// parse application/x-www-form-urlencoded
app.use(bodyParser.urlencoded({ extended: false }))

// parse application/json
app.use(bodyParser.json())

app.use(function (req, res) {
  res.setHeader('Content-Type', 'text/plain')
  res.write('you posted:\n')
  res.end(JSON.stringify(req.body, null, 2))
})

Morgan 日志记录器

morgan 是一个日志模块。每次请求击中服务器时,您的应用程序都会记录请求以揭示服务器真实的状态。服务器可以处理多种类型的请求。因此,应用程序必须记录所有请求以检查服务器的健康状况。在服务器上使用日志记录器有很多好处。其中一些列在这里:

  • 您可以跟踪服务器每天、每周、每月等处理了多少请求。

  • 您可以看到每个请求处理所需的时间。

  • 您可以看到被击中的请求类型,例如 GETPOSTPUT

  • 您可以看到哪些端点被频繁使用。

  • 该模块将维护所有错误日志。

morgan 非常容易使用,并且是一个为 Node.js 应用程序配置的中间件 npm 模块。您可以通过在终端中输入以下命令来安装它:

$ npm install morgan --save

然后在您的应用程序中如下使用它:

var morgan = require('morgan')

最后,您只需添加这一行即可在 morgan 和您的 Node.js 应用程序之间创建一个中间件:

app.use(morgan(':method :status :url - :response-time ms'));

这将打印以下日志:

图 9.12:终端中 Node.js 服务器的输出

图 9.12:终端中 Node.js 服务器的输出

图 9.12:终端中 Node.js 服务器的输出

在本节中,我们学习了如何在项目中使用内置和第三方 Node.js 包。您学习了如何使用路由以及如何在服务器上记录请求。但这并不是结束;您还可以编写自己的自定义模块。您需要做的只是导出一个入口函数。以下是一个示例:

图 9.13:使用 Node.js 的导出和导入示例

图 9.13:终端中 Node.js 服务器的输出

图 9.13:使用 Node.js 的导出和导入示例

将模块导出视为一个变量。模块导出是一个变量,您可以在其中放置一些值,并且您可以在应用程序需要此文件的地方获取相同的数据。您可以从任何 JavaScript 文件导出函数、JSON、字符串或任何类型的数据到应用程序中的任何其他 JavaScript 文件。

与数据库一起工作

当谈到服务器端时,数据库非常重要。所有需要存储以供将来参考的应用程序数据都必须存储在某个地方。在本主题中,您将学习如何使用两种最受欢迎的数据库:MySQL 和 MongoDB。

设置数据库

在本节中,我们将处理今天存在的两种主要数据库类型。在继续之前,让我们看看我们可以用哪些方式与数据库建立连接。与数据库建立连接有两种方式:

  • 本地:当数据库服务器在您的机器上运行时。

  • 远程:当数据库服务器在另一台机器上运行,并且您通过互联网访问它时。

您可以将数据库服务器运行在云中的某个地方,并使用带有凭证的 URL 来访问它。但在这个部分,让我们在本机上设置两个数据库,并使用 Node.js 来连接它们。

两个数据库的安装都非常简单。您可以从它们的官方网站下载最新的捆绑包,并像安装其他应用程序一样安装它们。对于安装指南,您可以遵循它们的官方文档,它非常易于理解。它们还涵盖了在所有不同类型的平台上的安装,例如 Windows 和 Linux。

MySQL: packt.live/32ypsRH MongoDB: packt.live/2PY7SDV

与数据库连接

安装后,您必须启动两个数据库服务器并配置一个用户。这个用户就是您将使用其凭证来访问数据库的用户。出于学习目的,请给这个用户管理员权限,这样您将拥有执行各种类型操作的所有权限。

为了与数据库建立连接,我们需要一些关于服务器机器的信息:

主机:主机将是数据库运行的服务器的域名或 IP 地址。

端口:这将是在数据库服务器上监听的端口号。默认情况下,MySQL 数据库运行在端口 3306,MongoDB 运行在端口 27017。

用户:在这里,我们必须指定数据库任何活跃用户的用户名。我们总是在安装完成后创建一个管理员用户。不建议在生产环境中使用管理员账户,但出于学习目的,我们可以使用它。在生产环境中,我们必须创建一个数据库用户,该用户只有有限的和必要的访问权限。

密码:用户的密码将在这里。

数据库:在这里,我们必须提到我们想要初始化连接的数据库名称。

我们必须将此配置指定给我们将在与数据库建立连接时使用的数据库驱动程序。例如,在连接到 MySQL 时,我们必须指定此配置:

var connection = MySQL.createConnection({
        host: 'localhost', // 127.0.0.1
        user: 'me',
        password: 'secret',
        database: 'my_db'
});

注意

如果没有指定端口号,它将选择端口号的默认值。

在下一节中,我们将学习如何建立连接以及如何使用此连接从 MySQL 和 MongoDB 中获取和保存数据。

MySQL

MySQL 是一个关系型 SQL 数据库管理系统。它是世界上历史最悠久、最成功、最受欢迎的开源数据库之一。它被广泛用于开发各种基于 Web 的软件应用程序。

MySQL 库是最广泛使用的 npm 库之一。数百万的开发者在全球范围内使用这个库。了解配置数据库的最佳方式是通过实践。让我们通过一个非常实用的练习来学习在任何 Node.js 项目中设置数据库的方法。

练习 9.06:使用 MySQL 服务器安装、连接和处理响应

让我们进行一个练习,我们将安装一个 MySQL 驱动程序并将其连接到 MySQL 服务器。我们还将查看如何向数据库发送 MySQL 查询以及如何处理响应:

  1. 为了使用此模块与 node 一起,您可以将它作为依赖项安装到您的项目中:

    $ npm install mysql
    
  2. 安装完成后,您可以在项目中使用以下方式引入它:

    var MySQL = require('mysql');
    
  3. 在将其导入到项目中后,您必须与数据库建立连接。为此,您可以使用 createConnection 方法。

  4. 如果一切顺利,连接将就绪。您可以使用以下方式发送 MySQL 查询:

    var connection = MySQL.createConnection({
            host: 'localhost',
            user: 'me',
            password: 'secret',
            database: 'my_db'
    });
    connection.connect(function (err) {
            if (err) {
                    console.error('error connecting: ' + err.stack);
                    return;
            }
            console.log('connected as id ' + connection.threadId);
    }
    );
    Code runs connection and logs thread ID to console. From here on, the reader should be able to run queries like,connection.query('SELECT * FROM table, function(err, result, fields)  
    { 
      if (err) throw err;
      console.log(result); 
    });
    

    注意

    在关闭连接之前,请注意端口号 3307 是作者的本地端口。标准的 MySQL 端口号是 3306。

    ![图 9.14:在 MySQL 服务器中传递凭证

    ![img/C14377_09_14.jpg]

    图 9.14:在 MySQL 服务器中传递凭证

  5. 确保在浏览上一节(设置数据库)时传递您之前创建的用户凭证:

    connection.query('SELECT 1 + 1 AS solution', function (error, results, fields) {
            if (error) throw error;
            console.log('The solution is: ', results[0].solution);
          });  
    
  6. 在查询数据库完成后,您可以使用以下方式关闭连接:

    connection.end();
    
  7. 要检查连接,请使用以下查询到数据库:

    if(connection.state === 'disconnected'){
         return respond(null, { status: 'fail', message: 'server down'});
       } else ("continue with app code")
    

在这个练习中,我们连接到 MySQL 服务器并向数据库发送 MySQL 查询以处理响应。

MongoDB

MongoDB 是领先的开源 NoSQL 数据库。它是一个用 C++ 编写的面向文档的数据库程序。它使用类似 JSON 的结构来存储数据,这也是它最常用于与 node 应用程序一起使用的原因。它也是 MEAN 栈的一部分,MEAN 栈是目前世界上最受欢迎的技术栈之一。MEAN 栈是 MongoDB、Express.js、AngularJS 和 Node.js 四大主要技术的组合。在 MongoDB 中,表被称为集合,数据行被称为文档。文档以 JSON 格式进行格式化,并且默认情况下是模式无关的。

注意

确保首先运行本地 MongoDB 服务器,然后获取连接的 URI。一个 URI 包含协议、认证、端口和数据库名称,它是一个单一的字符串。你很快就会了解 URI 的格式。

练习 9.07:在 MongoDB 中安装和配置连接

让我们在 Node.js 中编写一些代码来帮助我们连接我们的应用程序与 MongoDB。完成这个练习后,你将能够使用 Node.js 安装 MongoDB 并配置与 MongoDB 的连接:

  1. 为了安装驱动程序,请在你的终端中使用以下命令:

    $ npm install mongodb --save
    
  2. 然后,在你的应用程序中,使其成为一个要求:

    const MongoClient = require('mongodb').MongoClient;
    
  3. 然后,你必须准备一个连接 URL。它必须采用以下形式:

    mongodb://[username:password@]host1[:port1][,...hostN[:portN]]][/[database][?options]]
    
  4. 你可以使用此 URI 连接到 MongoDB:

    const url = 'mongodb://localhost:27017';
    MongoClient.connect(url, function(err, client) {
      assert.equal(null, err);
      console.log("Connected successfully to server");
    
      const db = client.db(dbName);
    
      client.close();
    });
    

    输出将显示如下:

图 9.15:与 MongoDB 成功连接

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_09_15.jpg)

图 9.15:与 MongoDB 成功连接

确保在通过“设置数据库”部分时传递你之前创建的用户凭据。

在这个练习中,你了解了我们今天在行业中拥有的主要数据库之一。到目前为止,你已经了解了 MySQL 和 MongoDB 是什么以及如何使用它们与 Node.js 应用一起使用。

制作实时 Web 应用

我们的世界非常动态,我们生活在一个实时通信至关重要的时代。无论是与现实生活中的人交谈还是关注板球比分,实时通信和数据都非常重要。Node.js 最好的地方就是其对流和 WebSocket 的支持。Node.js 是创建实时 Web 应用的完美工具。

WebSocket

WebSocket 提供了一个连续的全双工通信通道。这意味着服务器和客户端可以在单个 TCP 连接上同时进行通信和交换数据。使用 WebSocket,客户端不需要刷新页面就能看到变化。服务器会将数据推送到客户端。WebSocket 有助于促进连接的动态流动,使得两端通信都能以相当高的速度进行。这意味着你现在只需一个连接就可以接收和发送数据。

由于通信中没有延迟,服务器可以实时配置客户端,并且客户端可以持续将其数据与服务器共享,这将允许它分析和优化项目:

图 9.16:使用 WebSocket 连接的客户端和服务器之间的全双工隧道

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_09_16.jpg)

图 9.16:使用 WebSocket 连接的客户端和服务器之间的全双工隧道

WebSocket 连接是通过一个称为 WebSocket 握手的过程来建立的。这个过程从客户端向服务器发起一个常规 HTTP 请求开始。任何附加信息都将包含在这个请求的头部中,告知服务器客户端希望建立 WebSocket 连接。如果服务器已配置 WebSocket,它将接受请求。当握手完成时,初始的 HTTP 连接将被 WebSocket 连接所取代。这个连接使用与 HTTP 相同的底层 TCP/IP 连接。现在,无论是前端还是后端都可以开始发送数据。

Socket.IO

Socket.IO 是一个库,它使得浏览器和服务器之间实现实时、双向和基于事件的通信变得容易。它被构建来简化 WebSocket 的使用。它只需要两件事:

  • 一个 Node.js 服务器

  • 一个用于浏览器的 JavaScript 库

它支持自动连接,这意味着如果任何客户端由于任何原因断开连接,它将不断尝试与服务器重新连接。

让我们看看如何在你的项目中安装它。在你的终端中使用以下命令:

$ npm install socket.io --save  

接下来,使用你的应用服务器配置它:

const express = require('express')
const app = express();
server.listen(3000);
const io = require('socket.io')(server)

socket.io现在已与应用服务器配置。你可以使用io变量绑定事件和监听器。现在,每当新的客户端与服务器连接时,它将执行一个连接事件,你可以获取有关套接字的所有信息:

io.on('connection', client => {
        client.on('event', data => { /* … */ });
        client.on('disconnect', () => { /* … */ });
      });      

就这样。配置 socket.io 与你的应用服务器真的非常简单。

练习 9.08:构建聊天应用程序

让我们使用 node 和 socket.io 制作一个实时聊天应用程序。在这个练习中,我们将创建一个应用程序,允许我们为不同的用户启动多个会话,并在用户之间开发实时聊天体验。这将是一个非常基础和简单的聊天应用程序,你将能够创建一个群组,其中:

  • 你可以进行实时聊天。

  • 多个人可以加入这个群组。

  • 每个成员默认将分配一个假名字。

  • 你可以更改你的名字。

  • 你可以看到任何时间谁正在输入。

在进一步操作之前,让我们来看看我们项目的文件结构:

图 9.17:本练习的文件结构

图 9.17:本练习的文件结构

有两个主要文件:

  • App.js:此文件包含所有服务器端配置。在这个文件中,我们将配置socket.io,编写所有事件和监听器,并执行请求的路由。

  • Chat.js:此文件包含客户端所需的所有代码。

  1. 启动服务器,并在app.js中配置它以使用socket.io,添加以下代码:

    const express = require('express')
    const app = express()
    // Listen on port 3000
    server = app.listen(3000)
    // Configuring Socket
    const io = require('socket.io')(server)
    
  2. 现在,WebSocket 已与服务器配置。让我们创建一些事件和监听器,这将帮助我们与客户端(前端)通信并监听每个连接:

    io.on('connection', (socket) => {
            //listen on change_username
            socket.on('change_username', (data) => {
                socket.username = data.username
            })
    
            //listen on typing
            socket.on('typing', (data) => {
            socket.broadcast.emit('typing',{username : socket.username
    })
    })
    })
    

    应用程序正在监听两个事件,typingchange_username。每当套接字发出这些事件时,它们将被执行。

  3. 现在,你已经完成了你的服务器端代码。让我们来处理前端(客户端)。首先,将 socket.io 库导入到客户端。在服务器成功配置后,你可以在浏览器中打开 http://localhost:3000/socket.io/socket.io.js,这将下载一个脚本文件。这就是你需要在客户端导入的文件。

  4. index.html 中添加以下 script 标签:

    <script src='/socket.io/socket.io.js'></script>
    
  5. 完整的文件将看起来像这样:

    index.html
    2 <html>
    3 <head>
    4     <meta http-equiv="Content-Type" const="text/html;charset=UTF-8" />
    5     <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/boot       strap.min.css"
    6         integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"           crossorigin="anonymous">
    7     <link rel="stylesheet" type="text/css" href="style.css">
    8     <script src="img/socket.io.js"></script>
    9     <title>Packt - Chat App Exercise</title>
    10 </head>
    11 
    12 <body>
    13     <header>
    14         <h1>Avengers Chatroom</h1>
    15     </header>
    The full code is available at: https://packt.live/2NIGAjn
    
  6. 让我们通过将客户端连接到服务器来配置客户端。创建一个名为 chat.js 的文件,并确保 index.htmlchat.js 都在同一目录或文件夹中。如果你想要将文件移动到不同的位置,那么你必须在 index.html 中的 import 链接中反映这一点。

  7. 你必须在 chat.js 中声明请求将被转发到的服务器链接:

    var socket = io.connect('http://localhost:3000');
    
  8. 我们使用 localhost 是因为服务器运行在我们的本地机器上。它监听由服务器执行的事件。在这种情况下,我们需要监听 键盘输入。在 chat.js 文件中声明的连接下方输入以下代码片段:

    socket.on('typing', (data) => {
    feedback.html("<p><i><b>" + data.username + "</b> is typing a message..." + "</i></p>")
    })
    
    //Listen on typing
    socket.on('stop_typing', (data) => {
    feedback.html("")
    })
    
  9. 在你的终端中执行以下命令以启动服务器并监听所有请求:

    § npm start
    
  10. 现在,打开两个 Chrome 实例并访问 http://localhost:3000。这两个 Chrome 实例将创建两个会话,这将模拟两个不同的用户。只需开始输入并发送消息,就可以进行实时愉快的对话:

    注意

    代码将在本书的 GitHub 仓库中提供,网址为 packt.live/36KWlh0

图 9.18:两个进行实时聊天的 Chrome 会话

图 9.18:两个进行实时聊天的 Chrome 会话

如图中所示,有两个 Chrome 会话正在运行。这两个会话代表两个用户。他们都可以进行实时聊天。他们甚至可以看到对方是否在输入。

在本节中,你学习了如何使用 socket.io 在 node 中构建实时 Web 应用程序,并构建了一个酷炫的聊天应用程序。

活动 9.01:创建一个上传、存储和保存图像详情的 Web 应用程序

网络开发的一个主要部分是上传媒体并在将来引用它。在这个活动中,你将创建一个 Web 应用程序,允许你将图像上传到服务器并在目录中存储它们。为了使其更具挑战性,你还需要将图像详情保存到数据库中以便进一步使用和分析。

注意

此活动需要系统上运行 MySQL 数据库服务器。请在执行此脚本之前确保已安装并运行 MySQL 服务器。

当请求到达服务器时,它将首先重命名文件,然后将图像上传到目录中,在将图像路径作为响应发送回客户端之前:

图 9.19:node 服务器的输出

图 9.19:node 服务器的输出

您应该从服务器端获得以下日志:

![图 9.20:文件已上传到服务器上的图像目录

![img/C14377_09_20.jpg]

图 9.20:文件已上传到服务器上的图像目录

一旦文件成功上传到目录,您应该看到以下 MySQL 输出:

![图 9.21:MySQL 输出

![img/C14377_09_21.jpg]

图 9.21:MySQL 输出

注意

图像文件的详细信息也成功存储在数据库中。

这里有一些步骤将帮助您完成活动:

  1. 定义目录结构。

  2. 导入所有依赖项。

  3. 配置 Node.js 的morgan模块,以在控制台记录每个请求的详细信息。

  4. 配置您的应用程序以使用 MySQL 数据库。

  5. 建立数据库连接以配置Multer

  6. 向应用程序添加路由。

  7. 启动服务器。

  8. 向服务器发送请求上传图像。

    注意

    本活动的解决方案可在第 738 页找到。

摘要

到目前为止,您已经几乎涵盖了使用 Node.js 进行 Web 开发的全部基础知识。

您从 Node.js 的介绍开始,编写了您的第一个程序,并运行了它。您了解了 Node 的包管理器和 Node.js 环境。您被引导了解一些有用的内置和第三方 Node.js 模块,并发现了如何在您的应用程序中导入这些模块以及如何使用常见的模块,如body parseauth。您使用 Node.js 与数据库建立连接,并学习了如何在数据库中执行查询。

最后,您学习了如何制作实时网络应用程序,并学习了如何构建聊天应用程序。由于 Node.js 内容庞大,还有很多概念需要覆盖,但是您已经拥有了进一步轻松探索 Node.js 所需的知识。在下一章中,您将学习如何使用请求与其他服务进行通信。您将了解不同类型的请求以及如何处理和显示数据。您还将了解RESTful API

让我们继续下一章。

第十章:10. 访问外部资源

概述

在本章结束时,您将能够描述 AJAX、REST、JSON 和 HTTP 到 API 的内容;使用 jQuery 或原生 XMLHttpRequest 等库执行服务调用,并熟悉每种方法的优缺点;使用 JavaScript 通过外部 API 获取数据;使用一些 jQuery 功能进行 UI 和事件处理;并识别支持跨域的 API 并使用跨域请求。

在本章中,我们将介绍使用 AJAX 获取数据的不同方法,主要是从 RESTful 服务中获取。

简介

在上一章中,您学习了 Node.js,它运行在服务器端。本章将涵盖服务的另一面——您将学习如何从客户端调用它们。被访问的服务实际上可能是用 Node.js 实现的,但它们通常也运行在其他平台上,如 Java、C# 和 Python。

没有新鲜数据的网页是静态的,用途有限。网络服务是一套技术,为您的网页提供与其他服务器和站点通信的标准,以交换数据。

为了使用不同语言实现的服务能够相互通信,它们需要有一套共同的规则,关于交换的请求和响应应该如何看起来以及如何结构化。因此,存在许多不同的标准和网络服务方法,它们定义了交换数据格式。目前用于网站的最流行组合是 RESTJSON,它代表 Representational State TransferJavaScript Object Notation。本章将详细描述您需要了解的所有内容,以便调用 REST 服务为您的网页提供数据。

在网络服务出现之前,网络服务器需要在服务器端收集所有必要的数据,然后渲染最终要提供的 HTML。为了获取新鲜更新,整个页面必须重新绘制。页面用户体验始终受到影响,特别是如果涉及复杂计算或查询,因为用户需要等待重新绘制完成,在此期间无法使用页面上的任何其他功能。

例如,想象一个显示股票报价或电子邮件信息的页面。在以前,您必须在浏览器中重新加载或刷新整个页面,才能查看股票报价是否有更新,或者是否有新邮件。网络服务和动态 HTML 改变了这一点,因为现在页面通常只更新部分内容,而不需要重新加载和重新绘制整个页面。

注意,许多网络服务使用一种称为SOAP的不同技术,它代表简单对象访问协议,具有XML(可扩展标记语言)格式。还有一些新兴的标准也在获得关注,例如谷歌的协议缓冲区。虽然每种技术都有其优点和缺点以及适用的用例,但本书中我们将只关注 REST 和 JSON,因为它们是目前使用最广泛的方法。

JSON

由于您在本书中一直使用 JavaScript,因此您应该对 JSON 的语法感到非常熟悉,因为它是从 JavaScript 派生出来的,并且与您在前面章节中学到的对象结构非常相似。JSON,通常发音像名字 Jason,轻量级,易于人类阅读和编写,也易于机器解析和处理。

JSON 中使用了两种主要的数据结构:

  • 用花括号{ }括起来的键值对集合

  • 用方括号[ ]括起来的值列表

下面是一个示例 JSON 对象,展示了不同的结构和值类型:

sample_json.json
1 {
2     "twitter_username": "@LeoDiCaprio",
3     "first_name": "Leonardo",
4     "last_name": "DiCaprio",
5     "famous_movies": [{
6         "title": "Titanic",
7         "director": "James Cameron",
8         "costars": [
9             "Kate Winslet",
10              "Billy Zane"
11         ],
12         "year": 1997
13     }, {
14         "title": "The Great Gatsby",
15         "director": "Baz Luhrmann",
The full code is available at: https://packt.live/2CDXhWC

每个键值对中,键被双引号包围,例如"key",键和值之间放置一个冒号。例如,以下是不正确的,因为键没有双引号:

first_name: "Leonardo"

键必须在每个对象内是唯一的,可以是任何有效的字符串,并允许空格字符(尽管在键中使用空格并不总是最好的主意,因为如果使用其他字符,如下划线,编程可能更容易)。每个键值对通过逗号分隔。

值可以是以下类型之一:

  • 在前面的示例中"twitter_username": "@LeoDiCaprio"。字符串值用双引号括起来。

  • 在前面的示例中"year": 1997。符号(+或-)和小数也是允许的。数字不需要用双引号括起来。

  • 在前面的示例中"active": true(布尔值不需要用双引号括起来)。

  • 嵌套数组:这是一个自身是字符串数组的值。每个字符串都用双引号括起来,并通过逗号分隔。(在这里使用数组是为了允许指定任意数量的联袂演员)。看看下面的示例块:

    "costars": [
        "Kate Winslet",
        "Billy Zane"
    ],
    
  • famous_movies列表。数组中的每个元素都是一个包含与电影相关的字段(包括title, director, costarsyear)的嵌套对象。数组中的嵌套对象通过逗号分隔。

  • 在前面的代码片段中"eye_color": nullnull可以用来表示未知或不适用的值。null表示时不加引号。

    注意

    关于如何最好地表示此类场景存在一些争议,许多人认为如果某个值未知,则字段根本不应出现,而不是分配一个null值。为了本书的目的,我们可以暂时将这个争议放在一边。

空白(空格、制表符和换行符)在 JSON 中通常被忽略(除了字符串之外),并且可以自由使用以提高可读性。

JSON 语法也有一些重要的限制:

  • 值必须是之前指定的类型之一,并且不能有更复杂的表达式、计算或函数调用。

  • 不允许注释。

REST

应用程序编程接口API)定义了程序之间交流的格式和规则。REST 是一种软件架构风格,已成为今天大多数网站使用的网络服务的既定标准。在这本书中,我们不会陷入 REST 背后的学术理论,而是将重点放在客户端如何调用和使用 RESTful 服务的实际方面。

什么是 HTTP?

为了理解 REST,了解万维网的基础技术以及资源是如何被标识和通信的非常重要。简要来说,超文本传输协议HTTP)定义了浏览器和服务器在响应各种命令时应采取的操作。

使用统一资源定位符URL)通过使用 HTML 页面、图像、文档或视频文件等资源进行标识。URL 具有以下部分:

![图 10.1:URL 的组成部分图片 C14377_10_01.jpg

图 10.1:URL 的组成部分

  1. http(不安全)或https(用于安全通信)。

  2. 主机名:指定 IP 地址或域名的位置。

  3. 默认情况下,http假设为80https443)。

  4. 路径:指向网络服务器上的文件或位置。

  5. 参数名称与值之间由等号(=)分隔。也可以存在多个参数,通常由和号(&)分隔。

大多数 REST API 倾向于使用结合标识符和其他 URL 元素的 URL,而不是在查询字符串中使用参数,例如,一个如http://myserver.com/user/1234的 URL。

之前的 URL 代码通常比http://myserver.com/user?user_id=1234更受欢迎。

但这种后端风格仍然完全有效,并且绝对被认为是 RESTful 的!

在基本层面上,REST 通过使用 HTTP 动词来对数据进行操作或资源:GET用于检索资源,PUT用于更新资源,POST用于创建新资源,而DELETE用于删除数据。

HTTP 请求通常还需要通过头部传递不同的值。HTTP 头部格式包含由冒号分隔的名称-值对。例如,为了表明你希望你的 HTTP 响应中的数据以 JSON 格式提供,通常需要提供以下头部:

Accept: application/json

TheSportsDB

有许多网站和服务通过 API 提供各种类型的数据。TheSportsDB(www.thesportsdb.com)就是这样一个网站,它通过其简单的 JSON REST API 提供体育相关数据。

TheSportsDB 是一个涵盖数百种专业和非专业体育(如足球、篮球、棒球、网球、板球和赛车)的体育数据和艺术作品的社区数据库。它提供实时比分、标志、阵容、统计数据、赛程等更多信息:

图 10.2:TheSportsDB 的首页和标志

图 10.2:TheSportsDB 的首页和标志

许多 API 要求用户获取 API 密钥才能使用其服务。好消息是,通过测试 API 密钥 1,TheSportsDB API 对教育目的或较小应用是免费的。(如果 API 将被频繁使用,鼓励商业应用和大型用户注册。通过 Patreon 的捐赠也是接受的,尽管不是必需的。)

![图 10.3:示例页面,展示如何将来自各种 API 调用的数据组合起来展示球队信息]

结合起来展示球队信息

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_10_03.jpg)

图 10.3:示例页面,展示如何将来自各种 API 调用的数据组合起来展示球队信息

注意

完整的 API 文档位于 https://packt.live/2NuwMtd。

练习 10.01:使用 REST API 进行调用

在学习如何以编程方式调用它之前,为了更好地了解这个 REST API 及其工作原理,我们将在我们的网页浏览器中直接进行一些调用。

TheSportsDB API 的调用形式为 https://www.thesportsdb.com/api/v1/json/{APIKEY}/resource_path。由于我们使用的是免费测试 API 密钥,将 {APIKEY} 替换为 1 的值。

以下是我们将在练习中使用的 API 调用的摘要。一串 XXXX 表示应替换为适当的标识符或值的适当位置:

  • https://www.thesportsdb.com/api/v1/json/1/all_leagues.php

  • https://www.thesportsdb.com/api/v1/json/1/lookup_all_teams.php?id=XXXX,其中 XXXX 是来自联赛列表的 idLeague 的值

  • https://www.thesportsdb.com/api/v1/json/1/eventslast.php?id=XXXX,其中 XXXX 是来自球队列表的 idTeam 的值

  • https://www.thesportsdb.com/api/v1/json/1/lookupteam.php?id=XXXX,其中,再次,XXXX 是来自球队列表的 idTeam 的值

以下是一些 API 提供的其他有用方法(但在此处不会具体介绍):

  • https://www.thesportsdb.com/api/v1/json/1/searchteams.php?t=XXXX,其中 XXXX 是搜索字符串

  • https://www.thesportsdb.com/api/v1/json/1/lookup_all_players.php?id=XXXX,其中 XXXX 是来自球队列表的 idTeam 的值

  • https://www.thesportsdb.com/api/v1/json/1/lookupplayer.php?id=XXXX,其中 XXXX 是来自球员列表的 idPlayer 的值

  • https://www.thesportsdb.com/api/v1/json/1/lookuphonors.php?id= XXXX,其中 XXXX 是来自球员列表的 idPlayer 的值

  • https://www.thesportsdb.com/api/v1/json/1/eventsnext.php?id= XXXX,其中 XXXX 是来自球队列表的 idTeam 的值

  • https://www.thesportsdb.com/api/v1/json/1/lookupevent.php?id=XXXX,其中 XXXX 是来自活动列表的 idEvent 的值

  • https://www.thesportsdb.com/api/v1/json/1/eventsnextleague.php?id=XXXX,其中XXXX是从联赛列表中获取的idLeague的值

  • https://www.thesportsdb.com/api/v1/json/1/eventsday.php?d=2019-10-10

为了获取有关我们最喜欢的运动队的有用数据,首先需要查找该队的 ID。最简单的方法是获取所有可用联赛的列表,找到我们感兴趣的 ID,然后使用此 ID 进行另一个调用。

TheSportsDB提供了以下服务调用以获取所有可用联赛的列表。以下说明适用于 Google Chrome,但任何主要浏览器都支持类似的功能:

  1. 启动浏览器的新实例。

  2. 按下F12键以启动调试器(或从菜单中选择更多工具 | 开发者工具)。

  3. 选择网络选项卡,并确保圆圈图标为红色(表示正在记录网络流量)。此时你的屏幕应该看起来类似于以下这样:图 10.4:Chrome 开发者工具中的网络选项卡

    图 10.4:Chrome 开发者工具中的网络选项卡

  4. https://www.thesportsdb.com/api/v1/json/1/all_leagues.php作为 URL 输入地址栏并按Enter。此时你的屏幕应该看起来类似于以下这样:图 10.5:包含联赛数据的原始 JSON 响应

    图 10.5:包含联赛数据的原始 JSON 响应

    注意主窗口中的 JSON 响应列出了所有联赛数据,但它未格式化且不太友好。幸运的是,调试器提供了一个更友好的方式来以可折叠树的形式查看数据。在网络选项卡中,选择带有all_leagues.php的行,并选择预览选项卡。

    你应该在预览窗口中看到第一行 JSON 数据。将鼠标悬停在此行上,然后右键单击鼠标按钮以打开上下文菜单。从上下文菜单中选择递归展开。现在你会看到更多数据,如以下截图所示。请注意,你可能需要选择递归展开两次或三次才能使数据完全展开。

  5. 让我们找到我们感兴趣的联赛的数据。按Ctrl + F并输入NBA以找到国家篮球协会的条目。此时你的屏幕应该看起来类似于以下这样:图 10.6:展开并格式化的 JSON

    图 10.6:展开并格式化的 JSON

  6. 专注于我们联赛的条目,该条目的完整 JSON 格式如下(为了清晰起见进行了格式化):

    {
        idLeague: "4387"
        strLeague: "NBA"
        strLeagueAlternate: "National Basketball Association"
        strSport: "Basketball"
    }
    

    此条目只有几个键值对,但有了idLeague键,我们现在知道 API 中的联赛 ID 4387代表 NBA。现在可以使用此 ID 进行另一个服务调用,以获取有关 NBA 的更多详细信息。

  7. 按照之前描述的类似过程,将以下内容作为 URL 输入:

    https://www.thesportsdb.com/api/v1/json/1/lookup_all_teams.php?id=4387

    注意 URL 末尾放置的id参数,我们在这里插入了之前步骤中找到的4387 ID。

  8. 一旦你扩展了结果集,搜索单词Knicks。这次,你需要搜索多个匹配项,直到找到正确的一个,因为单词Knicks作为长描述的一部分出现在多个无关条目中。此外,与上次相比,结果条目中将有更多的键值对,但我们将只关注其中的一些。

相关的 JSON 看起来像这样,其中许多字段已被删除:

{
  "idTeam": "134862",
  "strTeam": "New York Knicks",
  "strTeamShort": "NYK",
  "intFormedYear": "1946",
  "strSport": "Basketball",
  "strLeague": "NBA",
  "idLeague": "4387",
  "strStadium": "Madison Square Garden",
  "strStadiumLocation": "New York City, New York",
  "intStadiumCapacity": "19812",
  "strWebsite": "www.nba.com/knicks/?tmd=1",
  "strFacebook": "www.facebook.com/NYKnicks",
  "strTwitter": "twitter.com/nyknicks",
  "strInstagram": "instagram.com/nyknicks",
  "strCountry": "USA",

在这里,我们可以看到idTeamstrTeam键中的团队 ID 和名称,后面跟着与团队历史、球场信息和网站及社交媒体 URL 相关的其他字段。

  "strTeamBadge": "https://www.thesportsdb.com/images/media/team/badge/wyhpuf1511810435.png",
  "strTeamJersey": "https://www.thesportsdb.com/images/media/team/jersey/jfktrl1507048454.png",
  "strTeamLogo": "https://www.thesportsdb.com/images/media/team/logo/yqtrrt1421884766.png",
  "strTeamBanner": "https://www.thesportsdb.com/images/media/team/banner/wvrwup1421885325.jpg",
}

最后,有一些链接到各种可用的团队徽章、球衣、标志和横幅图片,我们可以根据需要将这些图片显示在我们的网站上。我们将在接下来的练习中使用这些值。

HTTP 头部

当我们打开网络监视器时,这是一个记录一些其他有用信息的好时机。点击头部标签,你会看到所有 HTTP 请求头部、响应头部以及一些涉及交互的其他细节,类似于以下内容:

图 10.7:HTTP 头部

图 10.7:HTTP 头部

以下内容特别值得关注:

  • 请求方法:GET:输入到浏览器地址栏的 URL 默认为GET

  • 状态码:200:这表示交互成功。有几个状态码,以下是一些最常见的。后续状态码,编号为400或更大,表示发生了各种错误条件:

图 10.8:状态码及其含义

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_10_08.jpg)

图 10.8:状态码及其含义

  • content-type: application/json; charset=utf-8:这表示响应以 UTF-8 编码的 JSON 格式。

  • 远程地址:104.18.46.13:443:这表示处理请求的远程服务器的 IP 地址和端口号。

  • access-control-allow-origin: *:这是一个允许跨站请求的头部。关于这一点,将在后面的章节中详细介绍。

  • accept: text/html,application/xhtml+xml,application/xml;…:此头部通知服务器调用者希望接受哪些格式的响应。在我们的情况下,需要一些解释。TheSportsDB 被设计为仅支持 JSON 格式的 API,因此即使浏览器发送的接受格式列表中没有指示 JSON,响应仍然是 JSON 格式。(对于可能支持多种格式(如 HTML、XML 或 JSON)的其他 API 来说,情况并非如此。因此,此头部将用于调用者表明他们希望使用哪种格式。)

其他可用的 HTTP 头部虽然不是本次请求的一部分,但值得提及。简要总结一些最常见的头部:

  • Authorization:许多资源受到保护,需要凭证才能访问,例如使用用户/密码组合或令牌。此头信息用于以各种格式发送凭证。

  • Cache-Control:静态资源或预计在不久的将来不会改变的数据可以被浏览器或客户端缓存,因此如果再次请求,则不需要不必要地获取资源。此响应头指示是否允许缓存。一些最常见的值是no-cachemax-age=<seconds>,分别表示对象是否可以缓存以及可以缓存多长时间。

  • Last-Modified:此响应头指示对象最后修改的时间。

  • Keep-Alive:控制持久连接应保持多长时间开放。持久连接是一种特殊类型,用于在同一个连接上发出多个请求时使用,这使得无需关闭和重新建立连接成为可能。

  • Cookie/Set-Cookie:Cookie 是由网站发送的小数据块,用于保存状态或跟踪用户身份验证、设备和活动。当浏览器或客户端下次向服务器发出请求时,之前发送的 cookie 值将被包含在内。

在本节中,我们介绍了 HTTP、JSON 和 REST Web 服务的基础知识。我们学习了 URL 的组成部分、HTTP 状态码、HTTP 头信息,以及如何使用浏览器调试器调用 REST 服务并获取所需的数据。

在下一个主题中,我们将介绍 AJAX 以及如何使用 jQuery 库通过 JavaScript 代码调用服务。

AJAX

AJAX 代表异步 JavaScript 和 XML,是一个总称,用于描述一系列用于以各种格式(包括 JSON 和 XML)与服务器和其他站点通信的技术。

大多数 JavaScript 是同步的,这意味着只有一个执行线程,一次只能执行一个操作。如果浏览器真正是同步的,那么网站将难以使用,因为一次只能加载一个资源。想象一下,如果网站上的图片在加载后只绘制一个!有时,请求可能需要很长时间,例如进行计算或复杂的数据库查询。你肯定不希望你的网站在请求处理时变得无响应!

幸运的是,服务调用被设计为允许异步发生多个调用。此外,在发出请求并收到响应之间,主执行线程可以继续执行,并可能发出更多的服务调用。这是 AJAX 中的“A”。

JavaScript 已经开发出多种技术和方法来实现同步,其中最直接和最广泛使用的是回调函数。本章将使用回调函数,但其他技术,如 Promise,将在第十五章“异步任务”中探讨。

JavaScript 通过一个名为 XMLHttpRequest 的对象提供了一种原生的执行服务调用的方式。通常,人们倾向于使用 JavaScript 内置的本地功能,但在我看来,XMLHttpRequest 太底层且难以使用。这是一个利用库会更有意义且真正简化代码的场景。(然而,我们将在本章后面也介绍 XMLHttpRequest。)

有几个库可用于执行 REST 调用,我们将从探索 jQuery 的 ajax()getJSON() 调用开始。

jQuery

jQuery 是目前最受欢迎的 JavaScript 库之一。它轻量级,极大地简化了与网站相关的许多 JavaScript 编程任务。这些包括:

  • HTML/DOM 操作

  • CSS 样式辅助工具

  • HTML 事件处理

  • AJAX 服务调用辅助工具以及更多

![图 10.9:jQuery 项目的首页]

图片

图 10.9:jQuery 项目的首页

对于 HTML/DOM 和 CSS 操作,jQuery 语法通常遵循选择 HTML 元素并调用方法在元素上执行操作的模式。

基本语法是 $(selector).action(),其中:

  • $ 符号定义/访问 jQuery。

  • (selector) 用于定位 HTML 元素。

  • action() 表示要在元素上执行的 jQuery 操作。

选择器以美元符号和括号开始:$()。它们的语法与在 CSS 代码中选择元素非常相似。使用选择器的最基本案例是通过 HTML ID 选择元素,其中 ID 前缀为井号(#)。例如,如果你从以下 HTML 开始:

<div id="test_div">
     <button class="testbutton">Click Me</button>
</div>

$("#test_div"").hide() 这段代码会使 ID 为 test_div<div> 元素从视图中隐藏。也可以在同一个元素上以链式方式执行多个操作。例如,$("#test_div"").html(""Hello World"").show() 会将文本 Hello World 赋予 <div> 的主体,并使其重新出现。

以下是由 jQuery 提供的常见 UI 方法:

![图 10.10:用于 UI 目的的常见 jQuery 方法]

图片

图 10.10:用于 UI 目的的常见 jQuery 方法

jQuery 还可以用来定义处理 HTML 元素事件的函数;例如:

$(".testbutton").click(function() {
    alert("Button Clicked!");
}

之前的代码会在按钮被点击时显示一个包含消息 Button Clicked 的警告。此外,请注意,在这个例子中使用了类选择器,而不是之前使用的 ID 选择器。类选择器以点(.)开头,并选择文档中具有该类名的所有元素(在这个例子中,只有一个元素)。

也可以通过类型、属性、值等多种方式选择多个元素。

以下是由 jQuery 提供的常见事件处理方法:

![图 10.11:用于事件处理的常见 jQuery 方法]

图片

图 10.11:用于事件处理的常见 jQuery 方法

最后,jQuery 提供了一套完整的辅助工具来执行 AJAX 服务调用。这些将在下一个练习中介绍。

前面的段落只是对 jQuery 的简要介绍,但有许多优秀的教程和资源可以帮助你更深入地了解它。

练习 10.02:使用 AJAX 和 jQuery 的 ajax() 方法调用服务

现在我们已经了解了 HTTP 和 REST 的机制,我们可以开始编写一些代码了。

jQuery 提供了非常方便的方法,可以极大地简化 AJAX 交互。在这个练习中,我们将首先使用 $.ajax() 方法调用 TheSportsDB 服务以找出我们最喜欢的球队在上一场比赛中的得分。我们将使用上一节中获得的团队 ID 作为参数。

  1. 首先,我们输入 HTML 代码。打开文本编辑器或 IDE 并创建一个新文件(exercise2.html)。

  2. 添加 <html> 标签和 <head> 元素,这些元素简单地包含了 jQuery 库的 JavaScript 文件以及我们即将展示的 JavaScript 源文件,该文件将使用 exercise2.html 文件展示:

    <html>
      <head>
        <meta charset="utf-8"/>
        <script src="img/jquery.min.    js"></scr ipt>
        <script src="img/exercise2.js"></script>
      </head>
    
  3. 接下来,添加 <body> 标签。<table> 包含用于显示最后一场比赛数据的 HTML 标签:

    <body>
      <table id="game_table" style="display: none">
    

    我们使用表格来辅助布局。表格最初指定为 display: none 样式,因为页面加载时数据不完整,我们不想立即显示它。我们将在服务调用返回并提供数据后使其可见。

  4. 填充表格的行和列,直到 HTML 的结尾:

    exercise2.html
    11   <tr>
    12       <td>Last Game Score:</td>
    13       <td></td>
    14       <td id="game_date"></td>
    15   </tr>
    16   <tr>
    17       <td id="away_team"></td>
    18       <td>vs</td>
    19       <td id="home_team"></td>
    20   </tr>
    21   <tr>
    22       <td id="away_score"></td>
    23       <td></td>
    24       <td id="home_score"></td>
    25   </tr>
    The full code is available at: https://packt.live/37aWtH4
    

    表的大部分列都赋予了 ID,例如 away_teamaway_score,这样就可以在数据可用时引用它们来填充数据。这也允许我们分别处理每个值,并在需要时为每个值定义不同的样式。

  5. 最后,我们到达实际的 JavaScript 代码。将第一个文件保存为 exercise2.html 并在文本编辑器或 IDE 中创建一个新文件。

  6. 输入 exercise2.js 文件的第一行:

    $(document).ready(function () {
    

    所有 jQuery 函数都以美元符号 $ 开头,作为快捷方式,后面跟着一个点和函数名。$(document).ready() 函数在页面完全加载后调用。在这里使用这个函数很重要,因为此 JavaScript 文件是通过 HTML <head> 部分中的 <script> 标签在 <body> 标签之前包含到文档中的。如果我们的逻辑没有包含在 $(document).ready() 函数中,如果引用了尚未加载的 HTML 元素,则可能会出现错误。

  7. 按如下方式开始 $.ajax() 调用:

        $.ajax({
            method: 'GET',
    

    $.ajax() 调用包含了一系列设置。method 设置指示我们想要的调用类型是 GET。实际上,如果不指定 method 设置,GET 是默认值,因此通常没有必要指定。但有时更明确地指定可能更有益。

  8. 接下来,我们输入 dataType 设置:

            dataType: 'json',
    

    这指定了我们期望调用响应以 JSON 格式返回,并将 JSON 解析为 JavaScript 对象。这也导致发送了一个类似于Accept: application/json的请求头。

  9. 接下来输入url设置,它指示我们调用的 URL:

            url: 'https://www.thesportsdb.com/api/v1/json/1/eventslast.php',
    
  10. 接下来是data设置:

            data: {id: 134862},
    

    data设置指示请求中应发送的参数。由于这是一个GET请求,在底层,jQuery 将参数键值对添加到 URL 查询字符串中,在本例中导致?id=134862。如果我们想自己添加这个值到 URL 中,我们可以这样做,但在data设置中指定参数允许参数与 URL 本身分开。另一个原因是让库处理任何复杂值所需的 URL 编码。

  11. 接下来,开始success函数,这是一个由 jQuery 在服务调用返回响应时调用的回调函数。到这时,jQuery 将已处理响应并将 JSON 解析为 JavaScript 对象:

            success: function (data) {
    
  12. 这个eventslast.php服务调用返回该队的最后五个事件,按最新日期排序,位于data.results[]数组中。然而,就我们的目的而言,我们只需要最新的比赛。API 中有一个我们需要注意的怪癖:如果比赛正在进行中,事件将存在于数组中,但得分可能还没有值。对于我们的页面,我们更愿意只显示实际有得分的比赛。输入以下代码:

                // find the most recent game that had a score reported
                const lastGame = data.results.find(g => g.intAwayScore != null && 
                                                       g.intHomeScore != null);
    

    我们使用data.results.find()函数遍历数组并定位具有得分的第一个元素。具体来说,我们寻找intAwayScoreintHomeScore值非空的第一个事件。接下来,我们将使用以下代码设置各种表格单元格数据:

                $("#game_date").html(lastGame.dateEvent);
                $("#away_team").html(lastGame.strAwayTeam);
                $("#home_team").html(lastGame.strHomeTeam);
                $("#away_score").html(lastGame.intAwayScore);
                $("#home_score").html(lastGame.intHomeScore);
    

    这些行将对应于 ID 的表格单元格设置为指定的值。在 jQuery 中,可以通过在 ID 前放置一个井号(#)字符来通过 ID 选择 HTML 元素。例如,$("#game_date")将选择具有game_date ID 的<td>元素,随后调用的方法将应用于该元素。最后,html()调用将导致值被分配给该表格单元格。

  13. 一旦所有数据都分配到表格中,我们最终准备好向用户显示表格。输入以下最终代码:

                $("#game_table").show();
            }
        });
    });
    

    jQuery 有一个show()函数,它将display: none样式变为可见。因此,$("#game_table").show()将选择由game_table ID 表示的表格并使其显示。

  14. 将文件保存为exercise2.js

  15. exercise2.html文件加载到浏览器中。输出应类似于以下截图:

图 10.12:练习 10.02 的结果

图 10.12:练习 10.02 的结果

注意目前还没有样式(但将在下一个练习中添加)。我们可以看到使用$.ajax()方法得分的最喜欢的球队的结果。

在本节中,我们介绍了 AJAX 和 jQuery 的ajax()方法来调用服务。我们还介绍了基本的 jQuery 函数,用于向 HTML 元素添加动态内容,并在需要时显示/隐藏它们。

在下一个主题中,我们将进一步使用一个服务的输出作为另一个服务调用的输入。我们还将异步调用多个服务。

练习 10.03:更多 AJAX 和 CSS 样式

之前的练习是一个很好的起点,但现在我们想在此基础上做以下事情:

  • 使用第一个服务调用获得的结果来显示与比赛和球队相关的附加项目,例如球队标志。

  • 通过添加 CSS 样式使网站更具视觉吸引力。

我们将在以下步骤中增强代码:

  1. 打开一个文本编辑器或 IDE 并创建一个新的文件。

  2. 首先,添加<html>标签和<head>元素。这几乎与练习 10.02:使用 AJAX 和 jQuery 的 ajax()方法调用服务中的代码相同,但增加了使用文件exercise3.html的样式表:

    <html>
    <head>
      <meta charset="utf-8"/>
      <script src="img/jquery.min.js">  </script>
      <script src="img/exercise3.js"></script>
      <link rel="stylesheet" href="exercise3.css" />
    </head>
    
  3. 接下来,添加<body>。在<table>中,第一个<tr>行与练习 10.02:使用 AJAX 和 jQuery 的 ajax()方法调用服务相同:

    <body>
      <table id="game_table" style="display: none">
      <tr>
          <td>Last Game Score:</td>
          <td></td>
          <td id="game_date"></td>
      </tr>
    
  4. 添加下一个表格行。这是新代码,包含两个包含球队横幅图片的<img>元素。注意,这些图片也已标记为class="team_banner",因为我们将在稍后定义它们的 CSS 样式:

      <tr>
          <td><img id="away_img" class="team_banner" /></td>
          <td>vs</td>
          <td><img id="home_img" class="team_banner" /></td>
      </tr>
    
  5. 最后的行和剩余的 HTML 代码与上一个练习相同,同样也包括显示主队和客队得分的槽位:

      <tr>
          <td id="away_score"></td>
          <td></td>
          <td id="home_score"></td>
      </tr>
      </table>
    </body>
    </html>
    
  6. 保存文件并为 JavaScript 代码创建一个新的文件。复制上一个练习中的初始代码:

    $(document).ready(function () {
        $.ajax({
            method: 'GET',
            dataType: 'json',
            url: 'https://www.thesportsdb.com/api/v1/json/1/eventslast.php',
            data: {id: 134862},
            success: function (data) {
    
                // find the most recent game that had a score reported
                const lastGame = data.results.find(g => g.intAwayScore != null && 
                                                       g.intHomeScore != null);
    
                $("#game_date").html(lastGame.dateEvent);
    
  7. 一个酷炫的功能是对获胜得分应用特殊样式,或者在平局的情况下对两个得分都应用样式。我们通过以下代码将动态样式分配给适当的得分元素。首先,添加布尔表达式以确定主队是否有获胜得分或是否有平局:

                const homeScore = parseInt(lastGame.intHomeScore);
                const awayScore = parseInt(lastGame.intAwayScore);
                const homeWinner = homeScore > awayScore;
                const tie = homeScore == awayScore;
    

    如果主队得分高于客队得分,则homewinner将等于true。注意,在执行比较之前,你需要调用parseInt()将数据值转换为整数,因为服务以字符串类型返回得分。

                $("#home_score").html(homeScore)
                    .addClass( (homeWinner || tie) ? "winning_score" : "")	
                $("#away_score").html(awayScore)
                    .addClass( (!homeWinner || tie) ? "winning_score" : "");
    

    注意使用条件三元运算符的表达式。这个快捷方式与以下代码具有相同的效果:

            if (homeWinner == true || tie == true) {
               $("#home_score").addClass("winning_score");
            }
    

    注意

    如果我们愿意,这实际上可以进一步改进。jQuery 有一个名为toggleClass()的函数,用于此特定用例,其中应条件性地添加一个类。此代码可以重写如下:

    $("#home_score").html(homeScore)

    .toggleClass("winning_score", homeWinner || tie);

    toggleClass()方法接受两个参数。第一个参数是要条件添加(或删除)的类的名称。第二个参数是布尔值或表达式本身。

  8. 接下来,我们添加获取球队横幅图片的代码:

                getTeamImage(lastGame.idHomeTeam, "#home_img");
                getTeamImage(lastGame.idAwayTeam, "#away_img");
    

    lastGame.idHomeTeamlastGame.idAwayTeam 字段包含最近一场比赛的本地和客场球队的 ID。使用这些 ID,我们将调用 getTeamImage() 函数,该函数将为每个球队加载横幅图像,同时也会传入图像的 HTML ID。(此函数将很快定义。)

  9. 输入函数的其余部分,与上一个练习中的相同,以实际显示表格:

                $("#game_table").show();
            }
        });
    });
    
  10. 最后,我们来到了 getTeamImage() 函数:

    function getTeamImage(teamId, imageId) {
        $.getJSON('https://www.thesportsdb.com/api/v1/json/1/lookupteam.php', 
            {id: teamId}, 
             function(data) { 
                 const teamData = data.teams[0];	  
                 $(imageId).attr("src", teamData.strTeamBanner);
            }
        );
    }
    

一旦练习完成,最终结果应该类似于以下截图:

![图 10.13:练习的最终结果图片链接

图 10.13:练习的最终结果

这里有几个需要注意的点:

  • 我们这次使用了 $.getJSON() 而不是 $.ajax()$.getJSON() 函数是一个用于 JSON GET 请求的快捷方式,默认情况下假定 dataType="json"。不需要单独指定 URL、数据参数和 success 函数作为设置,因为函数的签名如下,方括号表示最后两个参数是可选的:$.getJSON( url [, data ] [, success ] )

  • imageId 被传递进来,以表示将要设置的 HTML 图像元素的 ID。我们将使用 jQuery 选择此图像元素并调用 attr("src", value),这是 jQuery 的实用方法,用于设置 <img> HTML 元素的 src 字段(类似于 <img src="img/…."/>)。我们将使用服务调用返回的 teamData.strTeamBanner 元素中的值来设置此 src。设置 src 字段将触发浏览器动态加载相应的图像。

  • $.getJSON() 是一个异步调用,这意味着在请求发出后,浏览器不会等待响应,即使在服务调用进行中,执行也会继续(并在收到响应时执行 success 回调)。这意味着对 getTeamImage() 的调用将实际上发出两个同时的服务调用,并且在请求进行时不会锁定浏览器。

CSS

我们现在将简要介绍在前一个练习中添加到页面中的 CSS 和样式,以使其更具视觉吸引力。由于这并不是一本关于 CSS 的书,我们只会提供一个简要概述。(有许多优秀的书籍和资源可以深入介绍 CSS。)

样式表通过以下标签包含在 head 元素中:

    <link rel="stylesheet" href="exercise3.css" />

CSS 文件的內容如下:

exercise3.css
1 #game_table { 
2     border: 1px solid;
3     display: none; 
4 }
5 
6 #game_table td { 
7     text-align: right;
8 }
9 
10 #away_score, #home_score { 
11     font-size: 24px;
12 }
13 
14  team_banner { 
15     width: 275px; 
The full code is available at: https://packt.live/33K3gVY
  • #game_table 选择器表示此块中的所有样式都将应用于具有 game_table ID 的 HTML 元素。

  • 在表格周围放置边框,使其看起来更紧凑。

  • 我们将 display:none 样式(用于防止在数据加载前显示)移动到样式表中本身,以使 HTML 代码更简洁,避免过多的样式代码。

  • #game_table td 选择器表示该块中的样式将应用于具有 ID 为 game_table 的表格中出现的所有 <td> 元素。在这里,我们表示表格中的所有文本都应该右对齐。

  • 选择器中的逗号允许您选择多个元素。在这里,我们表示分数应该有更大的、24 像素的字体。

  • 以点开头的选择器(如这里的 .team_banner)是另一种选择器。这是一个 class 选择器,它将应用于所有指定 class="team_banner" 的 HTML 元素。

  • 服务器上的横幅图像相当大,但我们只想得到一个缩小到 275 x 50 像素的图像。

  • 一种独特的样式,用绿色和粗体表示获胜的分数。

在最后一节中,我们探讨了服务(JSON 和图像)的不同类型响应以及如何使用 jQuery 通过动态 DOM 操作助手来显示它们。我们还看到了如何使用 CSS 添加颜色、字体和其他样式,使我们的屏幕更具视觉吸引力。

其他库和 XMLHttpRequest

本节探讨了其他使用 JavaScript 的 AJAX 方法。使用 jQuery 和回调进行 REST 调用的方法只是许多不同选项之一,并且最初作为对新手用户最直接、最容易理解的选择而提出。但这并不意味着这种方法不强大。实际上,在许多情况下,这已经足够了。记住,jQuery 还提供了您可能在应用程序中想要使用的其他功能。

Axios 和 Fetch API

在本书的这一部分,我们想提到两个可能适合在您的项目中使用的其他流行选择,Axios 和 Fetch API。它们使用诸如承诺等高级概念,因此这里不会涉及,但将在高级模块的 第十五章,异步任务 中介绍。(请注意,即使是 jQuery 本身也提供了返回承诺而不是使用回调的 AJAX 方法的变体,但这种用法也不在本章的范围内。)

但现在,我们将在下一节中将 jQuery 与原生的 XMLHttpRequest JavaScript 对象进行比较。

对比:XMLHttpRequest 和 jQuery

XMLHttpRequest 是 JavaScript 中内置的低级类,用于处理服务调用。尽管其名称中有 XML,但 XMLHttpRequest 实际上也可以用于其他协议,包括 JSON 和 HTML。

以下是与练习 10.02:使用 AJAX 和 jQuery ajax() 方法调用服务等效的代码,以便您可以比较使用 XMLHttpRequest 的代码与 jQuery (xml_http_request_example.js) 的代码之间的差异:

注意

HTML 部分与文件 exercise2.html 中的 练习 10.02 相同,但省略 <head> 中的 jQuery 库,并将 js 脚本 src 文件更改为 xml_http_request_example.js 而不是 exercise2.js

xml_http_request_example.js
1 const url = "https://www.thesportsdb.com/api/v1/json/1/eventslast.php?id=134862"";
2 var xhttp = new XMLHttpRequest();
3 xhttp.open('GET', url);
4 xhttp.setRequestHeader('Accept', 'application/json');
5 xhttp.onreadystatechange = function() {
6     if (this.readyState == 4 && this.status == 200) {
7         const data = JSON.parse(this.response);
8   
9         // find the most recent game that had a score reported
10         const lastGame = data.results.find(g => g.intAwayScore != null && 
11                                                 g.intHomeScore != null);
12   
13         document.getElementById("game_date").innerHTML = lastGame.dateEvent;
14         document.getElementById("away_team").innerHTML = lastGame.strAwayTeam;
15         document.getElementById("home_team").innerHTML = lastGame.strHomeTeam;
The full code is available at: https://packt.live/2q9dErM

让我们详细检查前面的示例:

const url = "https://www.thesportsdb.com/api/v1/json/1/eventslast.php?id=134862";

当使用XMLHttpRequest时,我们需要自己将查询参数附加到 URL 的末尾。在这里,这样做很简单,因为值只是一个简单的数字,但更复杂的值将需要通过调用encodeURI()进行 HTTP 编码:

var xhttp = new XMLHttpRequest();
xhttp.open('GET', url);

我们实例化XMLHttpRequest对象并调用open方法,指定方法(GETPOST等)和 URL:

xhttp.setRequestHeader('Accept', 'application/json');

这个低级 API 不像 jQuery 那样处理设置头部:

xhttp.onreadystatechange = function() {...}

这是当就绪状态发生变化时将被调用的回调函数。请求可以有多个状态,因此我们需要检查当前状态,如下面的行所示:

if (this.readyState == 4 && this.status == 200) {...}

与 jQuery 实现不同,没有success()函数被调用。我们必须显式地检查readyStatestatus代码。

readyState4表示请求已完成且响应就绪。可能的状态有:

  • 0: 请求未初始化

  • 1: 建立服务器连接

  • 2: 接收请求

  • 3: 处理请求

  • 4: 请求完成,响应就绪

状态200表示OK。请参阅HTTP 头部部分,了解最常见的 HTTP 状态代码列表:

const data = JSON.parse(this.response);

与 jQuery 不同,我们需要通过从响应字段调用JSON.parse()来自己解析 JSON 文本:

document.getElementById("game_date").innerHTML = lastGame.dateEvent;

document.getElementById()函数是 JavaScript 中通过 ID 选择 DOM 元素的本地方式(相当于 jQuery 的$("#game_date")函数)。你会通过直接设置innerHTML字段来设置元素的文本(而不是像 jQuery 那样调用html()函数):

document.getElementById("game_table").style.display = 'block';

设置style.display是使隐藏元素再次可见的本地方式,相当于 jQuery 的show()方法(尽管显然不那么简单):

xhttp.send();

请求实际上只有在send()方法被实际调用时才会发送,因此我们很重要的一点是不要忘记调用它。(与 jQuery 不同,jQuery 会自动发送请求。)

使用 jQuery 和 XMLHttpRequest 进行 POST 请求

使用$.ajax()进行带有常规参数的POST请求与GET请求非常相似,除了指定method="POST"。然而,POST请求有一个额外的功能——以JSON格式发送请求数据,而不是默认的'application/x-www-form-urlencoded'内容类型。如果你希望这样做,应将contentType设置设置为'application/json',并将数据对象包装在JSON.stringify()中。以下是一个示例:

$.ajax({
    url: 'http://example.do',
    dataType: 'json',
    type: 'post',
    contentType: 'application/json',
    data: JSON.stringify( {"firstname": "George", "lastname": "Cloony"} ),
    success: function(data){
...

$.getJSON()类似,jQuery 还提供了一个用于POST请求的快捷方式,称为$.post()。不过,它有一个额外的参数,用于指示响应的数据类型。以下是一个示例:

$.post("http://example.do", {
        "firstname": "George",
        "lastname": "Cloony"
    }, function (data) {
        // process the response
    }, 'json'
);

对于XMLHttpRequest

  • 如果你只是发送标准参数,使用setRequestHeader("Content-Type", "application/x-www-form-urlencoded")并在调用send()时发送编码后的参数。(如果你的数据复杂,你可能还需要调用encodeURI()。)

  • 如果你以 JSON 格式发送输入,请在调用send()时使用setRequestHeader("Content-type", "application/json"),并将数据包裹在JSON.stringify()中发送。

  • 对于任何一种方法,一个需要注意的问题是,服务返回的代码通常会频繁地是201 (Created)而不是200 (OK),所以你想要确保你的代码检查到正确的返回代码。

这里有一个例子:

xhttp.setRequestHeader("Content-type", "application/json");
var data = JSON.stringify({
    "firstname": "George",
    "lastname": "Clooney"
});
xhttp.send(data);

跨域请求

在选择或实现 REST API 时,有一个重要的规则需要注意,这可能会影响 API 是否适用于你的网站。浏览器有一个称为同源策略的安全功能。此策略仅允许在站点上运行的脚本访问同一站点上的 URL 数据,没有具体限制,但阻止脚本访问托管在不同域上的数据。此策略的原因是为了防止不道德的人通过利用浏览器安全漏洞来窃取你的数据或调用恶意服务调用而进行的各种攻击。

例如,如果你的页面托管在www.mygreatsite.com/foo.html,你将能够通过类似www.mygreatsite.com/my_data.json的 URL 访问数据或调用服务。然而,如果域名不同,即使是不同的子域名,如www.foobar.mygreatsite.com/my_data.json,浏览器将返回如下错误:

XMLHttpRequest cannot load https://www.foobar.mygreatsite.com/my_data.json. Origin null is not allowed by Access-Control-Allow-Origin.

幸运的是,有几种方法可以绕过这种限制,API 可以实现。这些解决方案的具体实现细节在服务器端,并不在本章的讨论范围内。但重要的是要知道,API 可以设计成跨站点的数据可用。因此,在选择用于项目的 API 时,你需要调查它是否支持跨域。(我们在前面的练习中使用的 TheSportsDB 就是一个这样的网站。)

CORS 头信息

如果你真的好奇,允许跨站请求的主要技术是通过服务发送一个称为CORS的头信息,它代表跨源资源共享。如果 API 希望使其服务对任何域名都可用,服务器在响应时可以发送以下响应头:

Access-Control-Allow-Origin: *

*值表示“任何来源”,但服务器也可以根据其意愿对可以访问的网站进行更严格的限制。这只是一个对更大主题的简要描述。还有一些其他技术可以实现跨域请求,例如使用 JSONP、帧之间的postMessage()或本地服务器端代理,但这里不会详细说明。

活动十点零一:使用各种技术实现 REST 服务调用

到目前为止,我们只为 HTTP GET请求编写了代码。让我们练习使用POST。我们在之前的练习中使用的 API,TheSportsDB,只包含 GET 请求,这使得它不适合测试其他 HTTP 方法。幸运的是,有几个专门为测试和原型设计、用于我们目的的虚拟 REST API。我们将使用一个名为REQ | RES(在reqres.in/)的免费 API:

图 10.14:REQ|RES 首页

图 10.14:REQ|RES 首页

该 API 在reqres.in/api/users提供了一个 POST 方法,你可以提供任何字段的 JSON 对象。你将对象作为服务调用的数据,服务响应时会返回相同的对象,但会额外包含idcreatedAt字段。

例如,如果你有以下数据:

图 10.15:请求和响应示例

图 10.15:请求和响应示例

你的任务是使用$.ajax()方法,然后是$.post()方法实现这个服务调用,并最终使用XMLHttpRequest方法执行它。然后,将返回的idcreatedAt字段打印到控制台。

请记住,REQ | RES API 实际上只是一个用于测试的虚拟服务,它不会实际持久化任何数据,所以不要期望能够检索你稍后发送的数据。

活动的整体步骤如下:

  1. 使用$.ajax(),设置method: 'post'dataType: 'json'

  2. 将你的数据字段放在一个对象{}中。

  3. 创建一个success函数以输出预期的值。

  4. 现在,使用$.post()

  5. 将你的数据字段放在一个对象{}中。

  6. 创建一个success函数以输出预期的值。

  7. 作为$.post()的最后一个参数,使用'json'值来指示预期的 JSON 返回类型。

  8. 最后,创建一个新的XMLHttpRequest对象。

  9. 调用open('POST')

  10. Content-typeAccept请求头设置为适当的值。

  11. 创建一个用于onreadystatechange的函数,该函数检查状态码为201 (Created),并使用JSON.parse()解析 JSON 数据。

  12. 对输入数据调用JSON.stringify()以将其转换为 JSON 格式。

  13. 在调用send()时发送 JSON 数据。

代码应该在 Google Chrome 的 JavaScript 控制台中产生如下结果:

图 10.16:JavaScript 控制台输出

图 10.16:JavaScript 控制台输出

注意

这个活动的解决方案可以在第 742 页找到。

摘要

在最后一节中,我们探讨了原生的XMLHttpRequest对象以及它与制作服务调用其他库的不同之处。总的来说,正如你所看到的,与 jQuery 相比,原生方法在大多数情况下更为冗长、难以操作且级别较低。除非你有特定的需要更多控制的需求,否则我不建议使用它。

在下一章中,我们将描述正则表达式,它们通常被用来以简洁、灵活和高效的方式匹配模式。我们还将探讨编写清晰易懂代码的最佳实践,这对于你希望代码易于维护并长期存活至关重要。

第十一章:11. 创建清洁且可维护的代码

概述

到本章结束时,你将能够识别和实现基本正则表达式(regex);使用最佳实践来生成清洁且可维护的代码;利用代码质量工具,如 ESLintJSLintJSHint,并实施代码重构策略。

简介

在本章中,你将学习到可用于模式匹配和清洁编码的技术,这些技术在许多方面都有应用,甚至可能有助于你的测试。

正则表达式(简称 regex)是一种简洁而强大的方法,用于搜索和匹配模式。它们一开始可能看起来很陌生且令人畏惧,但一旦你掌握了基础知识,它们就会迅速变得不那么困难,因此你可能会认识到它们的有用性。正则表达式在许多涉及文本和数据的语言和工具中都很常见。因此,花时间学习它们是值得的。用正则表达式表达的模式通常比使用传统技术解析和匹配相同模式的等效代码要短得多。

利用正则表达式还可以导致清洁且可维护的编码实践。为了使编程项目成功,代码必须易于他人理解,并且必须是有序的、专注的和灵活的。本章介绍了编码技术和最佳实践,从为你的变量和方法选择最清晰、最易懂的名称开始。

什么是正则表达式?

正则表达式是一个字符序列,它形成一个用于搜索的模式。模式中的每个字符要么具有特殊含义(元字符),要么是为了匹配字符本身(字面量)。这一点可以通过以下示例最好地理解。

为了展示传统编码与正则表达式技术进行模式匹配之间的差异,考虑以下用于以传统方式匹配电话号码格式模式的代码。然后我们将使用正则表达式重写匹配逻辑以进行比较。为了使事情简单,我们只将寻找符合以下模式的电话号码,这对于电话号码来说很常见,尤其是在美国:

	[2-9]XX-XXX-XXXX

在这里,X 可以是 0-9 之间的任何数字,第一个数字不能是零或一(只允许 2-9)。例如,234-567-8901 是这种格式下的有效电话号码。

你可以使用以下代码使用传统方法进行匹配:

conventional.html
1 <html>
2 <head>
3   <meta charset="utf-8"/>
4 </head>
5 <body>
6 Enter a phone number in format XXX-XXX-XXXX:
7 <input type="text" id="phone">
8 <div id="msg"></div>
9 <script>
10 var phone = document.getElementById("phone");
11 phone.addEventListener("input", validatePhoneNumber);
12 function isDigit(character) {
13     return character >= '0' && character <= '9';
14 }
The full code is available at: https://packt.live/2CFKQtK

以下截图显示了在输入无效格式的数字时,前面代码的输出:

![图 11.1:输入数字格式不正确时的示例输出

![图片 C14377_11_01.jpg]

图 11.1:输入数字格式不正确时的示例输出

当输入有效格式的数字时,这是输出结果:

![图 11.2:正确输入数字时的示例输出

![图片 C14377_11_02.jpg]

图 11.2:输入数字格式正确时的示例输出

让我们关注 JavaScript 代码中的重要部分:

var phone = document.getElementById("phone");
phone.addEventListener("input", validatePhoneNumber);

这些行设置了电话号码验证,该验证将在用户输入值时进行。它找到输入文本的 DOM 元素并为input事件添加事件监听器。此事件发生在<input>的值改变时,例如按键或粘贴值。当触发时,调用validatePhoneNumber()函数。

注意检查一个字符是否为数字的代码:

function isDigit(character) {
    return character >= '0' && character <= '9';
}

这个函数有一个参数,它必须是一个字符。可能不太直观的是,JavaScript 允许在字符类型上使用大于或小于运算符。这允许我们检查字符是否在09字符之间的任何数字。

下面的代码是validatePhoneNumber()方法的代码:

    var phoneNum = phone.value.trim();
    // check if phone number matches format [2-9]XX-XXX-XXXX
    var valid =
        phoneNum.length == 12 &&
        phoneNum.charAt(0) != '0' &&
        isDigit(phoneNum.charAt(0)) &&
        isDigit(phoneNum.charAt(1)) &&
        isDigit(phoneNum.charAt(2)) &&
        phoneNum.charAt(3) == '-' &&

这段代码执行以下操作:

  • 检查电话号码的预期长度,以确保没有多余的字符,例如额外的数字、字母字符或符号。请注意,由于我们在最初读取电话号码值时调用了trim()函数,因此这个检查对首尾空白字符是宽容的。

  • 检查第一个字符不是0

  • 检查前三个字符是否为数字(索引0-2)。

    注意

    我们可以不必多次调用isDigit()函数,而是可以向函数添加参数,以便在一次调用中检查多个字符。然而,为了这个练习的目的,我们选择保持代码更简单、更容易理解。

  • 检查随后的-字符。

验证的其余部分重复,对于其余字符也是类似的。

最后,验证信息被形成并设置在<div>中:

    var validMsg = "phone number entered is " +
        (valid ? "valid." : "INVALID!!!");
    document.getElementById("msg").innerHTML = validMsg;

我们现在将比较使用正则表达式的等效实现。我们将在下一节更系统、更深入地描述正则表达式,但就目前而言,我们将看看如何使用正则表达式技术进行编码,即使这可能对你来说现在还没有太多意义。我们将为之前提供的电话号码验证函数编写一个使用正则表达式的替代方案。所以,如果你现在还没有完全理解所有概念,不要担心,因为它们将在稍后详细解释。

考虑前面的代码,这是使用传统方法进行模式匹配的代码。让我们修改它,使其使用正则表达式。

要做到这一点,找到validatePhoneNumber()函数和valid变量的声明。然后,删除从该注释之上的整个表达式直到分号(;)字符。用以下内容替换你删除的区域:

// check if phone number matches format [2-9]XX-XXX-XXXX
var valid = phoneNum.match(//);

到目前为止,你只在两个正斜杠之间的区域有一个正则表达式的框架。

在第一个正斜杠之后,添加一个^字符(称为撇号)。你的代码现在应该看起来像这样:

var valid = phoneNum.match(/^/);

^符号是一个锚点,表示匹配应从字符串的开始处开始。

现在,添加字符[2-9],使得代码现在看起来像这样:

var valid = phoneNum.match(/^[2-9]/);

[2-9]字符指定了29

到现在为止,你应该能够看到你如何逐步添加更多字符。以类似的方式,逐个添加剩余的字符,如下表左侧所示:

图 11.3:字符表

图 11.3:字符表

完整的代码现在应该看起来像这样:

    // check if phone number matches format [2-9]XX-XXX-XXXX
    var valid = phoneNum.match(/^[2-9]\d{2}-\d{3}-\d{4}$/);

在这里,你已经看到了正则表达式的强大功能,以及所有这些都可以用一行代码表达。以下图中展示了正则表达式的不同概念:

图 11.4:展示正则表达式中的不同概念

图 11.4:展示正则表达式中的不同概念

正则表达式详解

在接下来的章节中,以下样本短语被用来进行说明:

"The ships were loaded with all these belongings of the mother"

这个短语将被用来演示各种正则表达式概念,包括文本字符、单词边界、字符类等。

文本字符

最简单的正则表达式之一是the。这表示一个模式,如果t字符紧跟着h字符,最后是一个e字符,则匹配。这个表达式在样本短语中有四个匹配项:开头的the,倒数第二个单词,thethese单词中的the,以及作为单词mother一部分的the序列。

特殊字符、锚点和转义

如果一个正则表达式只有文本字符,它的用途将是有限的。在大多数情况下,你不想只匹配文本字符;因此,正则表达式有一系列具有特殊意义的字符。这些也被称为元字符。其中两个特殊字符是之前看到的^$ 锚点,分别表示字符串的开始和结束。

因此,如果你将正则表达式更改为^the,那么现在短语开头的the将是唯一匹配的,因为这是短语中唯一一个位于字符串开头的the实例。锚点在需要避免匹配非预期匹配内容的情况下通常很重要。

以下是在正则表达式中具有特殊意义的最常见的字符:

图 11.5:特殊字符

图 11.5:特殊字符

注意

如果你需要在你的正则表达式中使用这些字符作为文本字符,在大多数情况下,字符将需要被转义。你通过在字符前放置反斜杠\来实现这一点。

这里有两个例子:

  • 如果你需要将美元符号作为模式的一部分进行匹配,你会使用\$

  • 一些不太明显的转义包括反斜杠字符本身(例如,你只需要使用两个连续的反斜杠,例如\\),以及点,其中\.是正确的转义序列。

单词边界

另一种类型的锚点是 \b(一个反斜杠后跟一个 b)。单词边界被定义为以下两种情况之一:

  • 字符串的开始,后面跟着一个单词字符

  • 空白字符和单词字符之间的字符,或者单词字符后面跟着空白字符或字符串的末尾

注意,单词字符被定义为以下内容。

  • 如果将先前的正则表达式更改为 \bthe,样本短语中只有三个匹配项:之前的四个匹配项减去 mother 中的 the。 (模式的初始 \b 是特殊字符,其余的是字面字符。)

  • 如果将正则表达式更改为 \bthe\b,只有两个独立的 the 单词会匹配,而不是 thesemother 中的那些。这是指定搜索整个单词的一种方法,但给定单词中没有其他前导或尾随字符。

简写字符类和单词字符

有多个 \d 序列,表示任何数字 [0-9]

另一个是 \w,定义为 [A-Za-z0-9_]。 (字符类和范围将在下一节中更详细地定义。现在,这被读作大写字母 A-Z 的范围内的字符,小写字母 a-z 的范围内的字符,数字 0-9 的范围内的字符,或者下划线 _ 字符。) 这里有一些例子:

  • 如果在先前的示例短语上使用 the\w 正则表达式,只有单词 thesemother 中的 the 序列会匹配,因为其他两个 the 单词后面没有单词字符。

  • 如果使用 \wthe\w 正则表达式,只有单词 mother 中的 the 序列会匹配,因为它是字符串中唯一一个 the 序列既被单词字符前缀又后缀的地方。这是指定如何搜索仅包含在单词中的字符的一种方法。

  • 结合单词边界和单词字符简写,如果使用 \bthe\w 正则表达式,只有单词 these 中的 the 序列会匹配,因为它是字符串中唯一一个 the 序列从单词边界开始并且后面跟着一个单词字符的地方。

另一个常见的简写字符类是 \s,它是空白字符的简写,包括空格字符、制表符和换行符。

反向类

每个简写字符类都有一个相反的类,通过将字母转换为大写来表示:

  • \B:单词边界的相反,例如在单词中间

  • \D:表示任何非数字字符

  • \W:任何不在 [A-Za-z0-9_] 中的字符

  • \S:任何非空白字符

例如,作为上一节第一个示例的反面,如果使用 the\W 正则表达式在先前的示例短语上,只有两个 the 字符会匹配,包括尾随的空格字符。单词 thesemother 会匹配,因为在两种情况下 the 后面都有一个单词字符。

点字符

点号,.,匹配任何字符(除了换行符)。例如,.h. 正则表达式会寻找任何 h 并将其与它之前和之后的字符匹配,只要它不是换行符。以下高亮显示的字符是应用正则表达式到早期示例短语后的结果:

"The ships were loaded with all these belongings of the mother"

特别注意单词 with 的最后两个字母的第三次匹配,以及随后的空格字符。很容易忘记点号匹配任何字符,包括空格。如果你打算在模式中允许空格,这可能会是一个常见的错误。

集合

包含在方括号 [...] 中的一个或多个字符或字符类表示我们应该匹配给定的任何字符。这被称为集合。以下有两个例子:

  • [AEIOUaeiou] 正则表达式可以用来匹配元音字符。

  • 当将集合与两个字面字符结合时,[oi]ng 正则表达式会匹配单词 belongings(来自示例短语)中的 onging

范围

一个 [2-9],表示在 29 之间的字符。类似于集合,范围是在方括号 [...] 中指定的,并使用破折号字符来分隔表示范围的字符。

你之前看到的另一个范围是 [A-Za-z0-9_],它指定了单词字符。这也显示了如何在单个表达式中指示多个范围,甚至是一个不属于范围的字符,因为这允许 A-Za-z0-9 的字符,以及下划线字符。

排除集合和范围

正则集合和范围的相反,排除范围表示“匹配除以下字符之外的字符”。排除集合或范围是通过在表达式中第一个方括号后立即放置一个撇号字符 ^ 来表示的。

例如,[^AEIOUaeiou] 会匹配任何不是元音的字符。

破折号也可以包含在排除中,以表示要排除的字符范围。例如,[⁰-9] 会指示匹配任何不是数字的字符(类似于 \d)。

量词

量词指定了匹配给定字符、字符类或标记所需的数量。

最直接的可能是一个 {4}。例如,a{4} 正则表达式要求字母 a 重复四次,而 \d{3} 要求连续三个数字。

它的近亲,范围量词,可以使用类似于 {min,max} 的格式进行指定。这个量词会匹配在指定的最小和最大出现次数之间的任何内容。例如,a{2,5} 正则表达式会匹配 aaaaaaaaaaaaaa 中的任何一个。

如果省略了逗号后的数字,则特殊类型的范围允许无界上限。例如,a{3,} 会匹配至少出现三次的连续 a 字符的任何数量。

简写量词

有三个量词使用得非常频繁,以至于为它们指定了特殊的快捷字符来表示它们。它们如下所示:

图 11.6:量词

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_11_06.jpg)

图 11.6:量词

将这些简写量词与我们之前介绍的概念(文字、字符类、集合、范围等)结合起来,以下是一些示例正则表达式以及它们会匹配的内容:

图 11.7:正则表达式及其匹配项

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_11_07.jpg)

图 11.7:正则表达式及其匹配项

选择

假设您希望您的模式允许两个(或更多)单词中的任意一个。您可以通过使用管道 | 字符分隔单词来实现这一点,这相当于 OR 操作符,表示 expression1 OR expression2

在大多数情况下,将备选表达式括在括号中也是一个好主意,例如 (expression1|expression2),以将其与模式的其他部分分开。例如,要扫描一个单词 the 后跟一个或多个空白字符的情况,这将匹配 motherfather,正则表达式如下:

    the\s+(mother|father)

许多其他正则表达式概念

本章仅对基本正则表达式概念进行了简要概述,并提供了良好的背景知识,以便您可以开始使用它们。鼓励您使用许多可用资源探索更多高级功能。以下是一些概念:

模式规范和标志

到目前为止,我们已经涵盖了构成正则表达式模式的元素,但现在,我们将介绍这些模式如何在 JavaScript 代码中传达。最常见的方法是将模式括在成对的斜杠字符中,例如 /pattern/。也可以在第二个斜杠之后添加标志,但这不是必需的。

标志可以改变匹配和搜索的行为。最常见的标志如下:

  • g:如果指定,将返回所有匹配项(如果没有指定,默认情况下将返回第一个匹配项)。

  • i:使匹配不区分大小写;大写字母和小写字母之间没有区别。

其他包括 m(多行搜索)、s(点 . 匹配换行符)、u(Unicode 支持)和 y(粘性模式)。

使用 String.match() 查找匹配项

JavaScript String 类有几个内置方法,可以接受正则表达式参数。本节概述了其中一些。

您最常用的方法是 String.match()。此方法的行为不同,其返回值取决于是否包含 g 标志:

  • 如果没有 g 标志:找到第一个匹配项后停止搜索。结果是包含匹配项作为数组元素的数组,额外的属性 index 指示匹配项找到的位置,以及一些额外的属性。(注意,如果正则表达式包含分组,它还有其他功能,但分组在本章中未涉及)

  • 如果有 g 标志:搜索所有可能的匹配项,并返回找到的匹配项数组。返回值中没有其他属性。

练习 11.01:g 标志的影响

以下代码说明了是否存在 g 标志的差异。它匹配以字母 t 开头的单词。(i 标志也用于演示以使匹配不区分大小写。)让我们开始吧:

  1. 在 Google Chrome 浏览器中,进入 开发者工具(菜单(屏幕右上角的三个点),更多工具 | 开发者工具,或者直接按 F12 键)。

  2. 将以下内容输入控制台以设置测试字符串:

        const str = "Here's the food for Tommy today";
        let match;
    
  3. g 标志不存在。输入以下内容:

        let match = str.match(/\bt\w+/i);
    

    在接下来的步骤中,以 > 开头的代码片段的行表示你应该输入的内容。以 <- 开头的行是你预期的输出。

  4. 让我们看看匹配了什么。为此,请输入以下内容:

        > match[0]
       <- "the"
    
  5. 检查匹配发生的字符索引:

        > match.index
       <- 7
    
  6. 由于没有 g,我们预期不会有更多的匹配项。为了验证这一点,请使用以下代码:

        > match[1]
       <- undefined
    

    第一个表达式的匹配项将显示如下:

    图 11.8:第一个匹配表达式的完整输出

    图 11.8:第一个匹配表达式的完整输出

  7. g 标志存在。输入以下内容以重新分配匹配变量以包含 g(和 i):

        match = str.match(/\bt\w+/gi);
    
  8. 按顺序输入剩余的行,检查每行的预期输出:

        > match[0]
       <- "the"
        > match[1]
       <- "Tommy"
        > match[2]
       <- "today"
        > match[3]
       <- undefined
    

    带有 g 标志的表达式匹配将显示如下:

图 11.9:带有 g 标志的匹配表达式的完整输出

图 11.9:带有 g 标志的匹配表达式的完整输出

在这个练习中,你看到了如何使用 String.match() 方法的 g 标志来获取多个匹配项。接下来,你将学习其他涉及正则表达式的相关方法。

其他用于正则表达式的字符串方法

以下表格简要描述了与正则表达式相关的 String 对象中的其他一些方法:

图 11.10:字符串方法

图 11.10:字符串方法

在使用 JavaScript 中的正则表达式时,还有一个有用的方法需要注意。test() 方法返回一个简单的 truefalse,指示是否找到了匹配项。你可以在如下代码中使用它来测试字符串是否以字符 hello 开头:

   if (/^hello/.test(str)) {

   }

注意

String 对象还有其他一些更高级的方法,这里没有涵盖。JavaScript 还有一个专门的内置 RegExp 对象,支持高级用例(test() 方法实际上属于 RegExp)。

练习 11.02:修改正则表达式以匹配模式

回想一下我们在本章开头提出的正则表达式模式,我们用它来匹配格式为 XXX-XXX-XXXX 的电话号码:

    ^[2-9]\d{2}-\d{3}-\d{4}$

使用你到目前为止所学到的知识,在这个练习中,你将修改正则表达式以匹配稍有不同的电话号码格式:

    (XXX) XXX-XXXX

有许多网站可以帮助你构建和测试正则表达式。我们将使用 regex101.com/ 来处理我们的正则表达式。让我们开始吧:

  1. 首先,比较这两种格式,看看它们有多相似。注意,新的格式 (XXX) XXX-XXXX 在最后七位数字上与原始格式 XXX-XXX-XXXX 完全相同,只是在模式的开头有所不同。我们只需要将原始模式的前四个字符(三个数字和一个连字符,XXX-)替换为新字符(一个开括号,三个数字,一个闭括号和一个空格)。

  2. 我们需要想出一个与 (XXX) 对应的正则表达式,包括一个不显示的尾随空格字符:

        \([2-9]\d{2}\)
    

    让我们分析这段代码:

    • \(:你需要匹配的第一个字符是一个括号。记住,括号在正则表达式中是特殊字符(元字符),所以它们需要用反斜杠转义,以表示你确实想要匹配括号字符。

    • [2-9]:一个字符范围,用于匹配介于 2 和 9 之间的字符。

    • \d:这是一个字符类,指定匹配一个数字字符。

    • {2}:一个固定量词,表示前面的数字字符(\d)需要重复两次才能被认为是匹配。

    • \):闭括号,也进行了转义。

  3. 通过与原始正则表达式的其余部分结合,你得到最终的正则表达式:

        ^\([2-9]\d{2}\) \d{3}-\d{4}$
    

    为了测试这个解决方案,有许多网站可以帮助你构建和测试正则表达式。你可以访问 regex101.com/ 并在输入框中输入正则表达式。此外,在 TEST STRING 区域输入 (234) 567-8910,如图所示:

图 11.11:https://regex101.com/ 的截图

img/C14377_11_11.jpg

图 11.11:https://regex101.com/ 的截图

如您所见,MATCH INFORMATION 部分表明我们的测试字符串是一个完整的匹配。

在这个屏幕上发生了很多事情,有很多信息可以帮助你理解和处理正则表达式。如果你查看 REGULAR EXPRESSION 输入框,你会看到正则表达式的各个元素已经被着色,以帮助将其分解为其组成部分。EXPLANATION 区域更进一步,为每个字符或标记提供详细的解释。由于前面的截图中没有显示全部文本,以下是提供的解释:

图 11.12:解释区域

img/C14377_11_12.jpg

图 11.12:解释区域

在正则表达式中添加更多字符串

除了有一个通过匹配的测试字符串外,还应该放入其他类似的测试字符串,这些字符串不应该通过匹配。以下截图显示了 TEST STRING 区域中的一些其他此类模式,但请注意,只有这些模式中的第一个模式显示为匹配(正如预期的那样):

图 11.13:更多不期望匹配的测试字符串

图 11.13:更多不期望匹配的测试字符串

在上一个练习中匹配电话号码的模式是可行的,但它寻找一个非常具体的模式。以下活动将挑战你使正则表达式更加灵活,以便它接受多种电话号码格式。

如果有人要求你提出一个正则表达式,使其能够匹配XXX-XXX-XXXX(XXX) XXX-XXXX中的任意一种模式,你会怎么做?

挑战在于只提出一个正则表达式,使其能够匹配这两种电话号码格式。在提出一个好的解决方案之前,让我们先考虑一个不正确且简单的方法,它乍一看似乎是采取的最明显的方法,但实际上是有缺陷的,充满了陷阱。

注意到第二种格式与原始格式大部分相似,只是第一组三位数字以括号字符(())开始和结束,并且后面是空格而不是破折号字符。你可能开始考虑通过在正则表达式中添加括号来表示这些差异,并简单地使用问号量词使它们成为可选的,并将破折号和空格放在一个集合中(使用[]语法)。这样的正则表达式看起来会像这样(强调的是新增部分):

    ^\(?[2-9]\d{2}\)?[- ]\d{3}-\d{4}$

问题在于这个正则表达式是不完整的,它也会允许一些不正确且不希望出现的格式匹配,如下所示:

  • (234 567-8901 (没有结束括号)

  • 234) 567-8901 (没有起始括号)

  • 234 567-8901 (不恰当地使用空格字符)

  • (234)-567-8901 (不恰当地使用破折号)

如果你将这些测试字符串输入到工具中,你也会看到这种情况:

图 11.14:所有测试字符串都匹配,因为正则表达式有缺陷

图 11.14:所有测试字符串都匹配,因为正则表达式有缺陷

只有前两个测试字符串应该匹配,但它们都匹配了(通过它们都被突出显示为蓝色来表示)。

活动内容 11.01:扩展电话号码匹配模式以接受多种格式

在上一个练习中,我们修改了一个正则表达式来匹配具有以下格式的电话号码:(XXX) XXX-XXXX。在这个活动中,我们将创建一个正则表达式,可以用来匹配XXX-XXX-XXXX(XXX) XXX-XXXX中的任意一种模式。一旦完成这个活动,你应该会有一个接受(XXX) XXX-XXXXXXX-XXX-XXXX号码格式的正则表达式。

活动的总体步骤如下:

  1. 指出与每种格式对应的正则表达式片段是交替表达式的不同形式。

  2. 将它们与上一练习中的原始正则表达式结合,以获得完整的正则表达式。

  3. 现在,使用以下数字测试正则表达式:(234) 567-8901;234-567-8907;234) 567-8901;234 567-8901;234 567-8901;以及(234)-567-8901。

这个活动的预期输出应该是以下内容:

图 11.15:活动 11.01 的输出

图 11.15:活动 11.01 的输出

注意

活动解决方案可以在第 745 页找到。

活动作业 11.02:扩展电话号码匹配模式以接受特定格式

在这个活动中,我们将+XXX(例如以下之一):

+97 (234) 567-8910
+97 234-567-8910

一旦你完成了这个活动,你应该有一个能够成功测试+xxx格式的正则表达式。

步骤:

  1. +XXX模式(其中 1-3 位数字是可接受的)构造正则表达式。

  2. 将此与原始正则表达式结合,以获得完整的正则表达式。

  3. 修改正则表达式,允许使用空格或点字符作为数字分隔符,而不是仅使用破折号。

  4. 使用以下数字测试模式:

图 11.16:许多与正则表达式匹配的模式

图 11.16:许多与正则表达式匹配的模式

注意

这个活动的解决方案可以在第 747 页找到。

有用的正则表达式

以下表格展示了一些用于不同目的的正则表达式。你应该能够使用我们介绍的概念来理解它们,但在大多数情况下,除了正则表达式本身外,不会提供进一步的解释:

图 11.17:正则表达式及其用途

图 11.17:正则表达式及其用途

在这个主题中,你学习了正则表达式的基础及其用途。不过,有一个注意事项:虽然正则表达式非常强大,但它们也可能非常难以实现,正确使用它们可能需要一些练习。

现在,有一点要坦白。虽然我们的练习展示了用于验证电话号码的正则表达式,但它们是为了学习和教育正则表达式而设计的。在实际应用中,你可能希望考虑使用专门的库来进行此类验证。有几种这样的库可用,使用这些库的优点是它们经过深思熟虑、经过测试,并支持来自世界各地的各种选项和电话号码格式。其中一个这样的库是 Google 的libphonenumber

在下一个主题中,你将学习到整洁的编码。

整洁编码的最佳实践

代码往往比任何人想象的都要长寿。只需看看今天仍在使用的所有大型机系统。有时,即使是经验丰富的开发者也会对它感到困惑,在几周或几个月后再次查看自己编写的代码时,很难理解。软件开发者有责任在编码时采取良好的实践和习惯的心态。

代码几乎永远不会只写一次就不再使用。通常,你或其他人会在以后需要修改代码。如果你编写了干净的代码,你将帮助未来的自己和同事在那时更高效地工作。你也在使系统维护和修复错误变得更加容易。

本节中的许多想法和实践都基于罗伯特·C·马丁(Robert C. Martin)的书籍和博客,他也被称作“Uncle Bob”,是清洁编码领域的公认专家,并创作了广受欢迎的书籍、博客和培训视频。我们将只提供一个简要的概述和亮点,但关于这个主题已经写出了整本书。我们鼓励你深入研究这个主题。

以下是一段代码示例:

    function circ(r) {
      return r * 2 * Math.PI;
    }

这段代码的目的是计算圆的周长,但根据它的编写方式,这对你来说可能并不明显。也许添加一些解释性注释会有所帮助,但将前面的代码与以下代码进行比较:

    function circumference(radius) {
        return radius * 2 * Math.PI;
    }

你会同意这段代码更容易理解,并且几乎不会对其功能、参数和返回值产生任何疑问。将函数从 circ 重命名为 circumference,将参数从 r 重命名为 radius 就足以实现理解。这一切都无需添加任何解释性注释。

我们通过使用更好的命名改进了代码,但这里还有一个值得改进的地方。周长的常用公式是 C = πd,其中 d 是直径。如果你将我们的计算分成两个步骤,代码将更清晰。将前面的代码与以下代码进行比较:

    function circumference(radius) {
        let diameter = radius * 2;
        return diameter * Math.PI;
    }

这个简单的更改使计算需要将半径乘以 2 的原因更加清晰,即先计算直径,然后再乘以π以得到周长。

这可能看起来不是很大的改进,并且可以说额外的代码行并没有增加多少价值或清晰度。在这个简单的例子中,你可能会正确地认为。但考虑一下以这种方式编码如何可能简化需要更复杂计算或逻辑的场景。

好的命名习惯

在命名变量、函数或其他对象时,请遵循以下指南:

  • timeElapsedInDays, daysSinceCreation, 和 ageInDays。一般来说,代码应该尽可能地自我文档化,注释应尽量保持最少。随着代码重构和逻辑随时间变化,注释有变得过时的倾向,程序员也往往会忘记更新它们。

  • 如果它实际上是一个书籍数组,则使用 bookList。直接称为 books 更为可取。

  • BookInfoBookData,你虽然使名称不同,但并没有使它们代表不同的含义。使用更具体的名称来区分每个类的用途。

  • const yyyymmdstr = moment().format("YYYY/MM/DD");

    现在,让我们与一个好的例子进行比较:

        const currentDate = moment().format("YYYY/MM/DD");
    
  • SECONDS_IN_DAY 比起 86400 更有意义,并且更好地解释了这个数字代表什么。它还使得在搜索文本主体时更容易定位。

  • CustomerAccountAddressParser。避免使用像 ManagerProcessorDataInfo 这样的词,这些词要么是动词,要么过于通用。

  • addFundsdeleteUsersave。访问器和修改器应该以 getset 前缀。

  • fetchretrieveget 都做同样的事情。最好选择一个单词并在代码中一致地使用它。

  • eatMyShorts() 用于表示 abort()

  • 不要添加不必要的上下文:如果你的类或对象已经具有描述性的名称,就没有必要在变量中重复该名称。看看以下示例。

    这是错误的:

        const employee = {
            employeeFirstName: "Daniel",
            employeenLastName: "Rosenbaum",
            employeeActive: true
        };
        function fireEmployee(employee) {
            employee.employeeActive = false;
        }
    

    而这是好的:

        const employee = {
            firstName: "John",
            lastName: "Smith",
            active: true
        };
        function fireEmployee(employee) {
            employee.active = false;
        }
    

以下是函数的最佳实践:

  • 函数应该只做一件事情并且保持简洁:当函数简洁且功能有限时,它们更容易理解、测试和操作。它们也会读起来更清晰,并且更容易重构。结合良好的函数名,它们也是自我文档化的。

    这是一个糟糕的函数:

        function phoneSubscribers(subscribers) {
            subscribers.forEach(subscriber => {
                const subscriberRecord = database.lookup(subscriber);
                if (subscriberRecord.isActive()) {
                    phoneSubscriber(subscriber);
                }
            });
        }
    

    这是一个好的函数:

        function phoneActiveSubscribers(subscribers) {
            clients.filter(isActiveSubscriber).forEach(phone);
        }
        function isActiveSubscriber(subscriber) {
            const subscriberRecord = database.lookup(subscriber);
            return subscriberRecord.isActive();
        }
    
  • 限制函数参数的数量:理想情况下,参数不应超过两个或三个。这使得测试更容易。如果有更多参数,考虑你的函数可能试图处理太多事情,应该拆分成多个函数。

  • 函数名应该说明其功能:以下是一个糟糕的函数名示例:

        function addToDate(date, month) {
          // ...
        }
        const date = new Date();
        // It's hard to tell from the function name what is added
        addToDate(date, 1);
    

    这是一个好的函数名:

        function addMonthToDate(month, date) {
          // ...
        }
        const date = new Date();
        addMonthToDate(1, date);
    
  • 如果 doSomething()doSomethingOrSomethingDifferentIfAFlagIsSet(),那么是时候拆分函数了。

    看看以下代码:

        function createFile(name, temp) {
          if (temp) {
            fs.create(`./temp/${name}`);
          } else {
            fs.create(name);
          }
        }
    

    而不是使用前面的代码,可以使用以下代码代替:

        function createFile(name) {
          fs.create(name);
        }
        function createTempFile(name) {
          createFile(`./temp/${name}`);
        }
    
  • playerXplayerY 变量表示角色的当前坐标:

        let playerX = 45, playerY = 100;
        const moveRight = (numSlots) => {
          playerX += numSlots;
        }
        moveRight(5);
    

    最好将 x 作为参数传递,并将结果重新赋值给函数外部的全局变量,这样函数保持纯净并保持可预测的返回值(并且使测试更容易):

        let playerX = 45, playerY=100;
        const moveRight = (playerX, numSlots) => playerX + numSlots;
        playerX = moveRight(playerX, 5);
    

    注意

    副作用和纯净函数在 第十四章,理解函数式编程 中有更详细的介绍。另一个重要例子是在进行修改时克隆对象、数组或列表,而不是直接在输入上进行修改。

  • 创建函数来捕获条件语句:如果你有复杂的条件,你的代码可能会变得杂乱无章、缺乏焦点且难以理解。为你的条件创建专用函数并给它们起描述性的名称,可以使代码自我文档化并更容易理解。

    一个糟糕的函数示例:

    if (serviceCall.state === "loading" && isEmpty(result)) {
        // ...
    }
    

    一个好的函数示例:

    function shouldShowSpinner(serviceCall, result) {
        return serviceCall.state === "loading" && isEmpty(result);
    }
    if (shouldShowSpinner(serviceCall, result)) {
        // ...
    }
    

JavaScript Linters

代码检查工具是一种分析源代码的工具,可以帮助您调试代码,查找潜在的问题和错误,并检查编码风格(这通常是主观的)。在项目中使用代码检查工具可以帮助您和您的团队提高代码质量,并提供一致的样式,这有助于平滑不同团队成员编写的代码之间的差异。

代码检查工具通常可以以三种不同的方式使用:

  • 通过浏览器页面将您的代码键入或粘贴到工具的在线版本中。这是最简单的方法,但不适用于任何除了小范围检查之外的事情。

  • 使用您 IDE 或文本编辑器的插件,在您键入时显示错误和警告,或者单独显示。

  • 在构建源代码时运行扫描并生成报告,或者定期进行。 (如果需要,您甚至可以设置构建失败,如果扫描结果显示出足够严重的错误。)

对于 JavaScript,有几种可用的代码检查工具,包括以下几种:

  • ESLint:这是一个非常可配置和可定制的工具。这也许使得它成为最复杂且最难上手使用的代码检查工具。

  • JSLint:这是可以配置的,但在一个流行的但特定的编码风格上非常主观,如工具文档中所述。

  • JSHint:在可定制性方面,它介于其他两种之间。

练习 11.03:JSLint

这个练习将更详细地描述 JSLint,因为它最容易设置和开始使用,使用良好的编码风格,并且适合许多项目。让我们开始吧:

  1. 打开一个网络浏览器,例如 Google Chrome,并访问 www.jslint.com

  2. 在屏幕下方的“选项”部分,选择“Assume → a browser”(这将设置扫描以定义通常在浏览器中可用的某些对象,例如 document 对象):图 11.18:JSLint 的在线版本

    图 11.18:JSLint 的在线版本

  3. conventional.html 文件中的代码粘贴到 Source 窗口中。

  4. 点击 JSLint 按钮,这将产生一个输出结果,类似于以下截图所示:图 11.19:扫描结果

    图 11.19:扫描结果

  5. 扫描结果有多个以下警告:使用双引号,而不是单引号。这可能是一个主观偏好的例子,因为在许多语言中,单字符通常使用单引号,而不是双引号。幸运的是,这是一个可配置的选项,如果您在“选项”部分选择“Tolerate → single quote strings”,如以下截图所示。再次点击 JSLint 按钮后,这将导致所有警告被移除:图 11.20:Tolerate → single quote strings 选项

    图 11.20:Tolerate → single quote strings 选项

  6. 通过一种特殊的注释语法指定允许单引号字符串(以及其他一些选项)的选项,这种语法以/*jslint开始。取消选择Tolerate → single quote strings选项,并将以下内容添加到代码顶部:

        /*jslint
            single
        */
    

    点击 JSLint 按钮后,警告将继续为空。

这个练习解释了如何使用Jslint工具编译你的 JavaScript 代码。它还通过提供问题修复报告来帮助减少调试时间。

活动 11.03:重构为干净代码

养成使用干净编码最佳实践的习惯是一项基本技能。我们现在准备将我们新获得的干净编码技能付诸实践。

查看以下文件<script>部分的 JavaScript 代码,并找出你可以如何重构它以使其更干净、更容易维护和测试。

activity_original_code.html
1 <html>
2 <head>
3 <meta charset="utf-8"/>
4 </head>
5 <body>
5 <span id="error" style="color: red"></span>
6 <table>
7   <tr>
8     <td># of hours:</td>
9     <td><input id="numHours" /></td>
10   </tr>
11   <tr>
12     <td>Pay rate per hour:</td>
13     <td><input id="payRate" /></td>
14     <td>(in ####.## format)</td>
15   </tr>

简而言之,这是一个简单的网页,根据小时数、每小时的工资率和工人类型计算工资。有三种类型的工人,他们的工资确定规则不同:标准工人,在无加班费时获得加班费,不获得任何加班费,以及双倍加班费工人,在 50 小时后支付2 倍工资。

对于两个数字字段也有格式检查(使用正则表达式实现)和显示验证错误消息的功能。以下屏幕截图显示了带有有效输入的输出(在这个实现中只有最小的颜色和样式):

图 11.21:带有有效输入的示例输出

图 11.21:带有有效输入的示例输出

以下屏幕截图显示了带有验证错误的输出:

图 11.22:带有验证错误的示例输出

图 11.22:带有验证错误的示例输出

注意

这个活动的解决方案可以在第 748 页找到。

活动的总体步骤如下:

  1. 要重构代码,创建一个名为processForm()的函数。

  2. 创建一个名为resetErrorsAndResults()的函数。

  3. 创建一个类来保存表单字段值并执行验证。

  4. 接下来,创建一个名为getFormFields()的函数。此函数限于从表单获取值并创建FormFields类的实例。

  5. 创建displayError()displayResult()函数以显示错误和结果。

  6. 最后,创建calculateStandardWorkerPay()calculateNoOvertimeWorkerPay()calculateDoubleOvertimeWorkerPay()函数,这些函数具有相同的两个参数和相同的返回值定义,以便可以在doCalculation()中抽象地调用。

摘要

在本章中,你学习了编写干净和可维护代码的最佳实践。正如你在本章的活动中所看到的,使用干净编码技术的重构代码比之前更长。然而,你可以看到,当前的代码比原始代码更干净,更容易理解和测试。

对于我们简单应用所展示的这种程度的重构,可以说是过度了,许多开发者都有这样的感觉。但真正体现这种编程风格价值的,其实是在复杂现实世界的应用中,以这种方式工作是一种良好的实践。开发者和技术负责人需要决定对于他们特定的项目,哪些标准和清洁编码实践是有意义的。

在下一章中,你将探索 JavaScript 所能提供的当前趋势和前沿特性。

第十二章:12. 使用下一代 JavaScript

概述

到本章结束时,你将能够识别和选择可用于高级 JavaScript 开发的工具;在旧浏览器中使用最新的 JavaScript 语法;从客户端和服务器端应用程序开发的实用框架中选择;在项目中使用 npmYarn;使用 Parcel 进行无配置的资产管理;以及使用 webpack 实现可配置的资产管理。

简介

在上一章中,你学习了通过利用编码最佳实践、确保纯函数实现以及保持代码简洁来创建干净、可维护的代码。现在,尽管你的代码可能简洁且正确,但在部署应用程序时,仍有许多因素可能导致问题。

编写 JavaScript 应用程序并非原生的“编写和部署”实践。有许多需要注意的事项需要克服;例如,管理集成第三方模块、确保项目的正确目录结构以及确保你的代码在所有必要的环境中运行无误。

重要的是要记住,JavaScript 是一个不断发展的平台。自从其创建以来,JavaScript 总是在可用的运行时之间以及,最值得注意的是,在浏览器类型和版本之间存在差异。在其存在的早期,JavaScript 非常难以驾驭,浏览器之间存在明显的差异。在那个时期,开发者需要频繁地在每个浏览器中重复测试他们的应用程序,以确保其成功运行且无错误。即使现在,每个可用的现代浏览器都有不同的支持功能列表,并且尴尬的是,相似功能实现的微小差异可能会让一个没有防备的开发者陷入困境。试图编写适用于所有现代浏览器的代码可能会耗费时间,并且可能需要一些耐心。

幸运的是,有方法可以通过工具和库来克服这些差异。以下是一些方法:

  • Polyfill 库,它们向运行时添加缺失的功能,确保环境之间的更好匹配

  • 支持编码方法的库,确保实用的跨运行时开发体验

  • Transpilers,它们将单一语言实现转换为支持多个不同运行时环境的代码

通过将这些工具包含在你的项目工作区中,你不仅可以节省部署代码时的数小时挫败感和不必要的头痛,还可以确保从开始就有一个愉快的开发体验。这个想法是支持你的创造力,而不是与工具作斗争。

本章将突出那些使使用 JavaScript 编码变得轻松的库和工具,并告知你如何查找信息,以便你可以定制你的开发体验。

浏览器差异

当用户查看网站或网络应用程序时,它按预期工作并且最好在所有浏览器中工作方式相似是很重要的。然而,对于许多开发者来说,确保这一点是一项艰巨的任务。不仅浏览器提供了略有不同的 JavaScript 实现,还有 HTML5 功能、层叠样式表 (CSS) 支持,以及更多。克服这些差异一直是每个 JavaScript 开发者职业生涯中的重要任务,经验在确保应用程序在所有环境中工作方式相似中起着关键作用。

要查看不同浏览器类型和版本上哪些功能可用,你可以使用 Can I Use 网站,该网站列出了每个浏览器的功能和它们的兼容性:caniuse.com/.

近年来,微软致力于消除这些差异。例如,在 iOS 设备上,微软的 Edge 浏览器使用 WebKit,这是谷歌的 Chrome 和苹果的 Safari 浏览器所使用的相同技术。因此,网络应用程序开发的未来看起来更加光明,浏览器之间的限制更少。然而,问题在于,并非所有用户都更新了他们互联网浏览器的最新版本,这意味着向后兼容性仍然是必要的。

polyfills

解决浏览器兼容性问题的一个方法是使用 polyfills。这些是由第三方创建的库,用于弥合浏览器之间的差距,确保它们提供的功能与预期匹配。有许多 polyfill 库,每个库都声称在一系列浏览器和浏览器版本中提供特定的功能列表。其中最受欢迎的 polyfills 是 Polyfill.io (polyfill.io/v3).

Polyfill.io 是由 Financial Times 创建的,用于覆盖浏览器之间广泛的不一致。它是一个开源库,可以配置为仅包含你需要的功能,或者你可以简单地导入所有内容。通过将其包含在你的项目中,你可以平滑处理浏览器差异。

在你的应用程序中包含 Polyfill.io 与通过 script 标签添加它一样简单:

<script crossorigin="anonymous" src="img/polyfill.min.js"></script>

然而,为了充分利用 Polyfill.io,最好通过 Polyfill.io 网站的“创建 Polyfill 包”页面适当地配置它以满足你的需求。

转译器

转译器是转换一种语言到另一种语言的工具。它们与编译器类似,但编译器通常将一种语言转换为机器代码或中间字节码。有许多转译器可以将不同的语言转换为 JavaScript,但其中在网页开发中最受欢迎的是 Babel 转译器,它将 JavaScript 转换为 JavaScript。这听起来可能有些奇怪,但想象一下能够利用 JavaScript 的所有最新功能,然后将其转译为在过去 5 年的所有主要浏览器上工作,而无需使用 polyfill 库。这正是 Babel 所做的。

Babel 转译器以及本章后面将要讨论的几个其他转译器。

开发方法库

作为覆盖浏览器差异裂缝的替代方案,可以利用提供统一开发方法且在旧浏览器中受支持的库。有许多库提供了一系列功能,从而使得以预定义、有见地的编程方式更容易。以下是一些这样的库:

  • UnderscoreLodashRxJSRamda 函数式编程库

  • ReactPolymerRiot 组件化 UI 库

  • BackboneKnockout MVC 框架

  • jQueryMooTools DOM 操作库

前述每个库都可以与其他库和转译器一起使用,因此选择你的库可能是获得所需结果的重要步骤。我们将在本章后面查看一些有用的库。

包管理器

包管理器是提供对包含库的管理的支持的工具。这些包管理器可能提供可以在生产代码中使用的功能,提供对应用程序外部帮助测试代码的功能,或者甚至提供可以用来使开发更容易但最终从生产代码中丢弃的工具。

在实现包管理器时,会保留一个清单,以跟踪项目中导入的每个库及其版本号。这样,如果你要从项目中移除所有库,你可以轻松地重新安装曾经使用的库。这在将应用程序源代码存储在源代码库(如 GitHub)中非常有用,因为你只需要存储自己的代码。其他开发者可以通过包清单轻松安装你过去使用的任何库,从而确保源代码库的简洁。

许多年来,JavaScript 开发者最常用的包管理器是节点包管理器(npm)。然而,还有其他管理器可用,其中一些正在获得人气,包括 Yarn。

节点包管理器

当你安装 Node.js 时,节点包管理器(npm)会自动安装。因此,如果你跟随了第九章“使用 Node.js”的内容,那么你应该已经安装了它。要测试是否已安装,只需在你的终端中运行以下命令:

npm -v

如果你确实在你的系统上安装了它,这将输出版本信息:

图 12.1:npm 版本

图 12.1:npm 版本

图 12.1:npm 版本

当使用 npm 与项目一起使用时,它会创建一个名为package.json的清单文件,该文件维护了一个用于与项目一起使用的生产环境和开发库的列表。任何已安装的包都存储在一个名为node_modules的目录中,npm 也会创建这个目录,并且它位于项目的根目录。出于简化目的,建议确保node_modules目录永远不会存储在你的项目源代码仓库中。

要开始使用 npm,你只需使用终端导航到你的项目目录,并执行以下命令:

npm init

这将提示你回答一系列问题。如果你不知道要填什么,可以简单地按 Enter 键跳过它们,并使用提供的默认值。如果你愿意的话:

![Figure 12.2: npm initializationimg/C14377_12_02.jpg

图 12.2:npm 初始化

完成后,它将创建一个包含前述截图详细 JSON 代码的裸骨package.json文件。现在,在这个时候,这个 JSON 将不包含任何模块,但这没关系。它所做和可以包含的是关于你的项目的元数据,例如其版本、初始索引或启动脚本,以及其他此类重要信息。

预定义的元数据属性之一是scripts对象。这是一个重要的条目,你很快就会熟悉。该对象中的每个条目都是一个可执行的命令快捷方式,你可以使用 npm 终端命令调用它。最初提供的那个实际上并没有做什么,除了输出一个错误,但如果你愿意,你可以运行它以查看其效果。通过在终端中执行以下命令来实现:

npm run test

使用当前的 JSON,这将简单地输出以下错误:

![Figure 12.3: Initial package.json outputimg/C14377_12_03.jpg

图 12.3:初始 package.json 输出

package.json文件的scripts部分对于实现自己的快捷命令非常有用,这样你就可以运行常见任务。你将在本章的后面大量使用它。

安装模块

包管理器的关键任务是安装模块。正如我们之前所述,将模块安装到项目中有两种主要方式。第一种是将它作为生产级模块安装。这意味着它可以在构建阶段被整合到你的部署脚本中:

npm install --save-prod <module>

同样,要将其作为开发模块安装,你只需执行以下代码:

npm install --save-dev <module>

每个已安装的模块都将添加到package.json文件中的一个列表中;对于生产模块是dependencies,对于开发模块是devDependencies

在任何时间,一旦你的模块列表存在于package.json文件中,你只需在终端中调用以下命令即可安装所有模块:

npm install

如果你的项目中不存在node_modules文件夹,这个命令将会创建它,并继续下载dependenciesdevDependencies列表中包含的所有模块。

Yarn 包管理器

Yarn 是 JavaScript 包管理器中较新的补充,但正在变得越来越受欢迎。它由 Facebook 联合 Google、Tilde Inc. 和 Exponent Inc. 共同构思,作为 npm 存储库的不同前端。它主要是为了提供一个比 npm 命令行工具更安全的替代品,因为 npm 命令行工具可以在安装模块时运行代码,这可能是一个安全风险。

您是否应该使用 Yarn 只是一个偏好问题。Yarn 项目暴露的问题可能会随着时间的推移由 npm 修复,并且可能已经不再相关。由于 Yarn 并没有在 npm 生态系统中取代太多东西,因此没有很大必要在 npm CLI 之上使用它。

Babel 转译器

Babel 是一套将 JavaScript 转译为 JavaScript 的工具和库。它的好处是您今天可以使用最前沿的 JavaScript 特性,同时确保它们在广泛的浏览器和浏览器版本上运行,同时确保生成的代码量最小。

注意事项

当处理特别小的项目时,仍然可以创建比大多数浏览器上运行的脚本更短的脚本。使用 Babel 主要对中等至大型项目有益,许多现代 JavaScript 应用程序往往属于这一类。因此,如果您只是从字段或两个字段中读取一些值,并使用几行 JavaScript 验证它们,那么您可能最好完全跳过模块和转译器。

一个简单的 Babel 安装提供了三个开发时工具和一个需要编译到您的应用程序中的 polyfill 库。具体如下:

  • Babel 引擎核心

  • Babel 命令行界面

  • Babel 环境预设引擎

  • Babel/polyfill 库

每个 Babel 工具都可以使用 npm CLI 安装,并使用以 @babel/ 前缀的包名。这被称为包作用域或命名空间,确保 npm 存储库中的其他包在名称相同的情况下不会发生冲突。

@babel/core

@babel/core 工具提供了 Babel 转译器的引擎。它包含将 JavaScript 代码从一种形式转换为另一种形式的函数,尽管各种转换的逻辑可能不在 @babel/core 包本身中。所有 Babel 安装都必须包含 @babel/core 包才能正常工作。

@babel/cli

@babel/cli 为 Babel 项目提供了命令行功能。这可以用来调用源代码的转译,以及包含额外的插件和配置。然而,您通常不会直接使用此 CLI。相反,您将创建一个新的 scripts 条目,并在单独的文件中提供配置。

@babel/preset-env

@babel/preset-env工具为针对特定环境(如浏览器类型和版本)转换您的代码提供了智能指令。通常,您希望支持的环境范围越广,您生成的可部署转换代码就越大。此工具的配置将存在于配置文件中,这样您就只需指定一次。

@babel/polyfill

@babel/polyfill是一个至少部分编译到您的可部署代码中的库。通过使用@babel/preset-env,Babel 从@babel/polyfill中选择元素包含在转换代码中。

.babelrc 配置文件

.babelrc文件(注意文件名开头的点字符)是一个 JSON 配置文件,位于项目目录的根目录。此文件可以存储有关预设、插件以及各种其他对 Babel 安装有用的信息。

您将应用到此文件的最常见的配置是您希望支持的环境:

{
"presets": [
    [
"@babel/preset-env", {
"targets": "> 0.25%, not dead"
      }
    ]
  ]
}

之前的例子要求 Babel 将代码转换为支持全球使用率超过 0.25%的浏览器。否则,这些浏览器不被认为是已死亡的,这意味着仍然得到积极支持的浏览器。

可以在packt.live/2NVxWwP找到.babelrc配置文件支持的完整环境查询说明。

练习 12.01:一个基本的 Babel 项目

在这个练习中,您将使用 npm 创建一个可用的 Babel 转换器安装。这将是一个简单的设置,将产生一个能够将源模块化 JavaScript 应用转换为可部署文件库的工作环境。让我们开始吧:

  1. 首先,在操作系统的Documents文件夹中使用终端创建一个名为babel_app的新目录。然后,导航到该目录:

    cd ~/Documents/
    mkdir babel_app
    cd babel_app/
    
  2. 然后,将文件夹初始化为 npm 项目。您可以为此使用所有默认设置:

    npm init
    

    您应该看到以下类似的输出:

    图 12.4:npm 初始化

    图 12.4:npm 初始化

  3. 现在 npm 已经设置好了,您可以安装 Babel 的开发库。这些库需要保存在devDependencies列表中:

    npm install --save-dev @babel/core @babel/cli @babel/preset-env
    

    您将看到一些与将模块下载到node_modules目录有关的信息:

    图 12.5:已安装的开发模块

    图 12.5:已安装的开发模块

    这些文件不会被编译到您的结果转换应用中,因为它们用于执行转换本身。

  4. 现在,您需要安装 Babel polyfill 库。此库将根据转换器的需求转换为您的结果应用:

    npm install --save @babel/polyfill
    

    再次,这个模块将被保存在node_modules目录中:

    图 12.6:Polyfill 模块安装

    图 12.6:Polyfill 模块安装

  5. 安装了模块后,你现在需要配置 Babel。这需要一个名为.babelrc的新文件,它将包含适当的 JSON。Babel 有许多可能的可配置功能,但在这个练习中,你只需要指定环境预设。在项目目录的根目录中创建一个名为.babelrc的新文件,并填充以下内容:

    {
      "presets": [
        "@babel/preset-env", {
          "targets": "> 0.25%, not dead"
        }
      ]
    }
    
  6. 一切准备就绪后,你现在需要创建你的项目工作文件和目录。你可以按照你喜欢的任何方式布局你的项目,但在这个例子中,你将使用两个目录:一个src目录用于源文件,一个dist目录用于转换后的文件。请继续在项目目录的根目录中创建这些目录:

    mkdirsrcdist
    
  7. 接下来,你需要一个项目文件来进行转换。在src目录中,添加一个名为index.js的文件,并添加以下 JavaScript 代码:

    [1, 2, 3].map((value) => console.log("Mapping value ", value));
    

    这个 JavaScript 使用了一点点 ES2015 规范,以胖箭头函数的形式。这意味着在转换时,你应该在源文件和转换后的文件中看到差异。

  8. 现在,为了使一切正常工作,你需要修改package.json文件以包含一个执行 Babel 转换器的脚本。请打开该文件,然后在scripts数组中添加以下行:

    "build": "npx babel src --out-dir dist"
    

    这行代码的意思是,当你运行它时,它将使用 npm 包运行器调用 Babel CLI 工具,并将src目录作为输入,将dist目录作为输出。别忘了在上一个行末添加逗号,否则你的 JSON 将是无效的。你的package.json文件现在应该看起来如下所示:

    package.json
    1  {
    2  "name": "babel_app",
    3  "version": "1.0.0",
    4  "description": "",
    5  "main": "index.js",
    6  "scripts": {
    7  "test": "echo \"Error: no test specified\"&& exit 1",
    8  "build": "npx babel src --out-dir dist"
    9    },
    10 "author": "",
    11 "license": "ISC",
    12 "devDependencies": {
    13 "@babel/cli": "⁷.5.5",
    14 "@babel/core": "⁷.5.5",
    15 "@babel/preset-env": "⁷.5.5"
    The full code is available at: https://packt.live/32DKdv6
    

    注意每个模块条目旁边的版本号。如果你的版本号不同,不要慌张。安装这些模块所使用的步骤确保了下载了最新版本。你自己的文件与前面代码之间的任何差异仅仅意味着这些模块自从本章编写以来已经更新了。然而,一切应该仍然运行良好。

  9. 最后,是时候运行转换器了。为此,只需通过 npm CLI 工具调用脚本即可:

    npm run build
    

    执行后,查看dist目录。它现在也应该包含一个index.js文件。然而,这个文件的內容将与你在src目录中的index.js文件中输入的代码略有不同:

    "use strict";
    [1, 2, 3].map(function (value) {
      return console.log("Mapping value ", value);
    });
    

    注意到 Babel 转换器移除了胖箭头函数语法,并用标准的函数定义替换了它。这样做是为了确保转换后的代码能在你在.babelrc配置文件中指定的环境中运行。Babel 允许你使用任何你想要使用的尖端特性,同时安心地知道你的转换后的应用程序应该正好在你需要的地方运行。

我们在这里描述的基本设置为即使是最大的应用程序也提供了一个强大的起点。许多专业的 JavaScript 开发公司使用 Babel 来确保代码的正确性、可移植性和灵活的工作环境,这样开发团队可以管理,而不需要每个开发者互相干扰。

虽然你所取得的成果是一个健壮、团队驱动的应用的绝佳起点,但还有更多可以做到的来进一步提升这一点。我们将在本章的剩余部分探讨这些额外的步骤。

使用 Parcel 的 Babel 应用程序

npm 作为包管理器工作得非常好,但其项目管理功能有限。在之前的练习中,你在 npm 的 package.json 文件中设置了一个脚本,该脚本执行了 Babel 编译器。编译器可以一次翻译一个 JavaScript 文件,并将生成的翻译文件放置在 dist 目录中,但这只是其中的一部分。通常,你的项目会有其他要求,例如以下内容:

  • 确保你的文件合并成更少的 JavaScript 结果文件

  • 定义热模块(将编译输出拆分成更小的块,以动态加载)

  • 处理 CSS 文件

  • 压缩图片

  • 确保文件在构建之间不被缓存

你可以编写自己的脚本来管理你的项目,并从父脚本中通过 npm 执行它们,但这是一项大量重复性的工作,已经被其他人解决了。Parcel 是一个解决方案。

什么是 Parcel?

Parcel 被视为一个网络应用程序打包器。本质上,它是一个执行上一节中列出的任务的打包模块。使 Parcel 独特的是,它是一个零配置打包器。你只需从你的项目目录中调用它,它就会确定你的应用程序应该如何为分发做好准备。那么,唯一的要求就是调用 Parcel CLI 命令来启动它。

Parcel 通常作为全局模块安装在你的项目中。这意味着它不会存储在你的项目文件夹中的 node_modules 目录中,而是在你的操作系统环境路径上存在的 node_modules 目录中。为此,你不需要提供 --save--save-dev 标志,而是提供一个 -g 标志,用于全局:

npm install -g parcel

由于 Parcel 是全局安装的,安装不会修改 package.json 文件,因为它不需要被添加到 dependenciesdevDependencies 列表中。

使用 Parcel

要使用 Parcel,你需要向 package.json scripts 对象中添加新的脚本。有多种方式可以调用 Parcel。第一种是简单地将主 JavaScript 文件作为唯一参数传递给 parcel CLI 工具:

parcel src/index.js

这将请求 Parcel 处理 JavaScript 文件,并输出其开发构建版本。Parcel 遍历此文件,并确定是否有其他文件也需要处理。在处理阶段,Parcel 还会分析项目目录中存在的任何配置文件。.babelrc配置文件就是 Parcel 理解的一种文件。因此,通过其存在,Parcel 将确保任何 JavaScript 文件都通过 Babel 进行转换。如果存在其他工具的通用配置文件,或者源树中给定类型的文件(如 HTML 和 CSS 文件)也是如此,情况将相同:

图 12.7:Parcel 开发构建

图 12.7:Parcel 开发构建

当作为开发构建处理时,Parcel 会在转换后的 JavaScript 中包含额外的代码。此外,调用 Parcel 以开发模式处理你的文件将不会返回。正如你可以在前面的屏幕截图中所见,以开发模式运行 Parcel 会启动一个特殊的发展服务器,你可以通过在浏览器中访问localhost:1234来导航到该服务器。这仅仅是将你的转换后的应用程序展示得好像它正在远程服务器上运行。

运行的服务还利用文件系统监听器,以便当你更新项目中的文件时,该文件会自动重新处理,并且产生的更改会立即可用。如果你在文件更改时在浏览器中查看你的应用程序,浏览器会自动刷新以包含这些更改。这个特性大大加快了开发时间。

另一种调用 parcel CLI 工具的方法是提供build标志:

parcel build src/index.js

build标志告诉 Parcel 处理源文件,以便它们为生产发布做好准备。这个版本将不会包含额外的浏览器更新代码,并且不会有开发服务器在运行:

图 12.8:Parcel 生产构建

图 12.8:Parcel 生产构建

当将你的完成的应用程序部署到生产服务器时,开发功能是不必要的,并且只是增加了代码的冗余,因此能够不使用它们编译应用程序是一个必要的步骤。

Parcel 中的模块化应用程序

使用 Parcel 提供了许多免费功能。在第九章,使用 Node.js中,你看到了如何在 Node.js 应用程序中获取模块。使用 Babel 和 Parcel 进行转换时,使用import关键字也能获取模块。

在构建应用程序时,最好将应用程序源代码拆分成更小的文件,这样管理代码会更简单。import关键字遵循 ES2015 规范,其中每个源目录中的模块exports一个或多个函数,然后可以使用import关键字将它们导入到其他模块中。如果一个模块exports只有一个函数,那么它可以简单地作为default函数导出:

// myModule.js
const myFun = () => console.log("Hello, World!");
export default myFun;

在另一个模块中,此函数可以像这样通过命名引用导入:

// index.js
import fun from "./myModule";
fun();  // ==> Hello, World!

注意,在命名导入模块时不需要.js扩展名,这与 Node.js 导入模块的方式非常相似。转换器会自动理解如何使用此格式引用外部文件。当处理index.js文件时,Parcel 会自动遍历所有导入的文件,并将它们一起转换。每个模块都会被跟踪,包括它们的链接模块等。生成的代码存储在dist目录下的单个 JavaScript 文件中。

如果一个模块包含多个函数,那么它可以使用对象格式导出它们。这省略了default关键字,因为现在必须使用特定的名称来挑选函数:

// multiModule.js
const fun1 = () => console.log("I am function one");
const fun2 = () => console.log("I am function two");
export {fun1, fun2};

现在,在调用模块中,每个必需的函数都必须明确命名:

// index.js
import {fun1, fun2} from "./multiModule";
fun1();
fun2();

使用这种方法,只需要导入您打算使用的函数。转换器将确保任何未调用的函数(从未被调用的函数)不会无谓地转换到生成的dist代码中。

练习 12.02:一个基本的 Parcel 项目

在这个练习中,您将更新在练习 12.01:一个基本的 Babel 项目中创建的应用程序,以包含一个 Parcel 构建系统。您还将包含一个额外的模块,以便您可以体验模块化应用程序开发是多么简单。让我们开始吧:

  1. 首先,您需要安装 Parcel 工具。如果您还没有这样做,请全局安装它们:

    npm install -g parcel
    

    如果您在 Mac 或 Linux 设备上收到一个错误,表明您权限不足,您需要在上一行之前添加sudo命令:

    sudo npm install -g parcel
    
  2. 接下来,在src目录中创建一个新文件,并将其命名为index.html。然后,添加以下标记:

    <html>
    <head>
    <title>Babel App</title>
    <script src="img/index.js"></script>
    </head>
    <body>
    </body>
    </html>
    

    Parcel 通过处理文件树来工作。通过传递一个 HTML 文件作为主文件,不仅会处理 JavaScript 文件,还会处理应用程序中链接的 HTML、CSS 和其他此类资产。

  3. 现在,更新package.json文件以包含新的build命令脚本:

    "dev": "parcel src/index.html",
    "build": "parcel build src/index.html"
    

    "build"条目应替换我们之前使用的,它直接调用 Babel CLI。您的package.json文件现在应如下所示:

    package.json
    1  {
    2  "name": "babel_app",
    3  "version": "1.0.0",
    4  "description": "",
    5  "main": "index.js",
    6  "scripts": {
    7  "test": "echo \"Error: no test specified\"&& exit 1",
    8  "dev": "parcel src/index.html",
    9  "build": "parcel build src/index.html"
    10   },
    11 "author": "",
    12 "license": "ISC",
    13 "devDependencies": {
    14 "@babel/cli": "⁷.5.5",
    15 "@babel/core": "⁷.5.5",
    The full code is available at: https://packt.live/32LScpY
    
  4. 接下来,在src目录中添加一个名为module.js的新模块。此文件将演示 Parcel 中的模块加载。在此文件中,添加以下代码:

    export default () => {
      [1, 2, 3].map((value) => console.log("Mapping value ", value));
    };
    
  5. 现在,将此模块导入到index.js文件中,方法是替换其内容为以下内容:

    import mapper from "./module";
    mapper();
    
  6. 您现在可以通过简单地调用以下命令来以开发模式运行您的应用程序:

    npm run dev
    

    您应该然后在终端中看到预期的开发服务器启动:

    图 12.9:运行开发服务器

    图 12.9:运行开发服务器

    如果您在打开控制台的情况下启动浏览器,您将看到预期的内容:

    图 12.10:控制台输出

    图 12.10:控制台输出

  7. 最后,通过在数组中添加额外的值来更新 module.js。当您保存文件时,浏览器应该立即刷新以显示最新的更改:图 12.11:更新后的控制台输出

    图 12.11:更新后的控制台输出

  8. 如果您查看 HTML 页面的源代码,您会发现 HTML 并没有像在 src 目录中那样引用 index.js。相反,JavaScript 文件将有一个随机名称,以防止浏览器缓存该文件:

    <html>
    <head>
    <title>Babel App</title>
    <script src="img/src.e31bb0bc.js"></script>
    </head>
    <body>
    </body>
    </html>
    

在这个练习中我们创建的设置现在是一个许多 JavaScript 项目的完美起点。这个简单的构建提供了许多开发功能,这些功能能够增强您的编码并确保生产文件的最佳可靠性。

使用 Webpack 的 Babel 应用程序

Webpack 是一个用于 JavaScript 应用程序的应用程序打包器,它提供了一个更加可配置的体验。在版本 4 中,Webpack 团队引入了零配置支持,以特别与 Parcel 竞争。虽然 Webpack 是一个打包您的 JavaScript 和其他资源的优秀工具,但它确实需要相当多的配置,并且需要同样的耐心。

要使用 Webpack,您只需像安装任何 JavaScript 模块一样安装它。Webpack 主要由两部分组成:Webpack 引擎和 Webpack CLI。这两个可以同时安装,如下所示:

npm install --save-dev webpack webpack-cli

如果您使用终端安装它,您应该看到以下输出:

图 12.12:Webpack 安装

图 12.12:Webpack 安装

下载这些文件后,您可以在 package.json 文件中添加一个脚本来执行它们,如下所示:

"wp": "webpack"

然后,通过在终端中运行脚本,它应该编译您的 JavaScript:

图 12.13:执行 Webpack 零配置

图 12.13:执行 Webpack 零配置

如您所见,在前面的屏幕截图中,index.jsmodule.js 文件都被编译成了一个名为 main.js 的文件。Webpack 默认将其入口点路径设置为 src/index.js,输出路径设置为 dist/main.js

让我们明确指定源文件和输出文件,以尝试模拟 Parcel 构建。将您的 wp 脚本更新如下:

"wp": "webpack src/index.html -o dist/index.html"

这将设置输入源为 index.html 文件,并请求输出放置在 dist 目录中,但使用相同的文件名。如果您运行此命令,您应该看到以下内容:

图 12.14:Webpack HTML 解析错误

图 12.14:Webpack HTML 解析错误

如您所见,Webpack 对找到 HTML 文件并不太高兴。

现在,关于 webpack 的一个注意事项是它并不试图变得智能。前一次执行的编译输出不会使用 Babel 对您的代码进行转换,因此源文件中的箭头函数将进入输出文件夹。此外,零配置模式的 webpack 只会编译您的 JavaScript,这意味着任何包含在您的应用程序中的其他资产都需要一些配置才能被处理并发送到 dist 目录,包括任何 HTML 文件。

Webpack 架构

Webpack 为您的应用程序提供了一个线性处理管道。使用源输入文件,它将构建一个依赖树,称为依赖图,并将处理配置了该文件类型的每个文件。该管道利用以下内容:

  • 源输入文件或入口点

  • 输出文件

  • 加载器模块

  • 插件模块

Webpack 需要对您的应用程序中的每种资产类型进行特定的配置,以便它知道如何处理它们。

Webpack 加载器和插件

加载器插件都是您在 webpack 管道中包含的代码模块。它们可以被认为是附加功能,因为 webpack 是一个管道,它们就像是它的“配件和附件。”加载器和插件都会影响 webpack 如何处理您的应用程序,但它们之间也相当不同。

加载器是一个在 webpack 管道开始时工作的模块;有时甚至在它开始之前。这些模块单独处理资产。例如,如果您想在 webpack 配置中使用 Babel,您需要包含 babel-webpack 加载器,该加载器会逐个转换正在处理的每个文件。

相反,插件通常在 webpack 管道的末端工作。这些模块影响整个输出包,并允许您对应用程序输出有更大的控制权。插件比加载器复杂得多,它们的配置通常反映了这一点。

Webpack 配置

webpack 管道的配置被添加到一个名为 webpack.config.js 的文件中,该文件位于您的项目目录的根目录中。作为一个 JavaScript 文件,它由 Node.js 运行时执行,因此可以包含兼容 Node.js 的 JavaScript。

webpack 配置是从 webpack.config.js 文件导出的,就像任何 JavaScript 模块一样。导出的数据可以包括有关入口点路径、目标路径、加载器和插件的信息,如所需。

例如,一个简单的 webpack.config.js 文件,它指定了入口点和输出路径,可能看起来像这样:

var path = require('path');
module.exports = {
  entry: path.join(__dirname, 'src', 'index.js'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'index.js'
  }
}

如果您创建了一个 webpack.config.js 文件,请确保重置您的 package.json 脚本,使其如下所示:

"wp": "webpack"

如果不这样做,可能会导致结果混乱。

练习 12.03:一个基本的 WebPack 项目

在这个练习中,你将设置你的 webpack 安装,使其实现 Babel 转译,并且包含并处理你的index.html源文件。正如我们之前所述,你可以为 webpack 安装应用许多配置功能,但通过完成这个练习,你将更好地理解如何实现所需的任何加载器。让我们开始吧:

  1. 虽然你的应用程序在package.json文件中包含了 Babel 转译器,但这对于 webpack 理解如何以及何时使用它来说是不够的。要利用 webpack 与 Babel,你需要包含并配置Babel loader module。在你的终端中运行以下命令以下载和安装 Babel 加载器:

    npm install --save-dev babel-loader
    
  2. 接下来,如果你有webpack.config.js文件,请打开它,并在exports对象中添加一个新的module部分。如果你还没有webpack.config.js文件,现在创建它并添加上一节中的代码:

    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          use: {
            loader: "babel-loader"
          }
        }
      ]
    }
    

    这个新块提供了我们可以用来处理文件的 Babel 规则。本质上,它是在说“test:对于以.js结尾的文件名,exclude:排除node_modules文件夹中的任何文件,use:对找到的这些文件使用babel-loader。”

  3. 在支持 Babel 转译后,你现在需要包含对 HTML 文件的支持。HTML 处理既是 JavaScript 处理的前置任务也是后置任务,因此它使用加载器和插件来完成。要安装这两个,运行以下代码:

    npm install --save-dev html-webpack-plugin html-loader
    

    如果一切顺利,你应该会看到以下输出:

    ![图 12.15:Webpack HTML 加载器和插件安装 图片 C14377_12_15.jpg

    图 12.15:Webpack HTML 加载器和插件安装

  4. 安装了这些模块后,现在需要在webpack.config.js文件中进行配置。与 Babel 加载器一样,HTML 加载器应该被添加到exports对象的modules数组中。在babel-loader配置之后添加它:

    {
      test: /\.html$/,
      use: [
        {
          loader: "html-loader",
          options: { minimize: true }
        }
      ]
    }
    
  5. 实现 HTML 插件需要几个步骤。首先,你需要在页面顶部引入它:

    const HtmlWebPackPlugin = require("html-webpack-plugin");
    
  6. 在需要模块后,你可以通过将其添加到exports对象的plugins部分来配置它:

    plugins: [
      new HtmlWebPackPlugin({
        template: "./src/index.html",
        filename: "./index.html"
      })
    ]
    

    注意,输出文件的目录不是必需的。HTML 插件将把它放在dist文件夹中,不管怎样。

    你的webpack.config.js文件现在应该如下所示:

    webpack.config.js
    10   module: {
    11     rules: 
    12       {
    13         test: /\.js$/,
    14         exclude: /node_modules/,
    15         use: {
    16           loader: "babel-loader"
    17         }
    18       },
    19       {
    20         test: /\.html$/,
    21         use: [
    22           {
    23             loader: "html-loader",
    24             options: { minimize: true }
    25           }
    The full code is available at: https://packt.live/2XckybQ
    
  7. 最后,执行你的 webpack 脚本。你现在应该会发现 JavaScript 输出已经被 Babel 正确转译,并且index.html文件也将出现在dist目录中。你的终端窗口应该如下所示:![图 12.16:成功的 webpack 转译

    ![图片 C14377_12_16.jpg

图 12.16:成功的 webpack 转译

配置 webpack 安装可能是一个相当长的试错过程。即使进行这个练习,你也可能注意到 index.html 文件仍然没有实现一个非缓存过程,即 JavaScript 输出被随机重命名以避免浏览器缓存。虽然 webpack 对于需要额外配置自由度的中到大项目是必需的,但对于那些需要简单设置且没有配置头痛的小项目,建议使用 Parcel。

其他流行打包器

对于你的 JavaScript 项目,有许多打包器工具可供选择,每个都有自己的优点和缺点。显然,选择打包器可能源于个人选择,也可能是项目经理、开发团队或投标项目的组织的要求。然而,在这些打包器中,尚未在本章中介绍的两个最受欢迎的工具是 Gulp 和 Grunt。

GulpGrunt 与你之前看到的示例略有不同,因为它们不使用配置文件。相反,它们使用你编写的 JavaScript 代码来完成类似于 webpack 和 Parcel 可以完成的任务。

Gulp 和 Grunt 被称为任务运行器。这意味着,你不需要为你的项目定义一个编码环境,它们充当一个你编写的应用程序来管理你正在编写的应用程序;如果你愿意,可以将其视为一种应用程序包装器。这两个打包器的工具集提供了一个框架来简化这个过程,它运行在 Node.js 运行时上。你只需编写你想要实现的功能并执行它。

在 Node.js 运行时运行的 gulp 打包器如下:

var gulp = require('gulp');
gulp.task('build', () => { /* Compile production application */ });
gulp.task('build.dev', () => { /* Compile development application */ });
gulp.task('test.unit', () => { /* Run all unit tests */});
gulp.task('test.e2e', () => { /* Run all end-to-end tests */});
gulp.task('test', ['test.unit', 'test.e2e']);

在 Node.js 运行时运行的 grunt 打包器如下:

module.exports = function(grunt) {
  grunt.initConfig(gruntConfig);
  grunt.loadNpmTasks('grunt-contrib-jshint');
  grunt.loadNpmTasks('grunt-contrib-uglify');
  grunt.loadNpmTasks('grunt-contrib-watch');
  grunt.registerTask('default', ['jshint', 'uglify']);
};

其他语言转译

JavaScript 平台是一个非常流行的平台。毕竟,它是大多数浏览器的事实上的脚本运行时。然而,JavaScript 语言并不一定受到每个人的喜爱。一些开发者更喜欢静态类型系统,而另一些则更喜欢与他们的首选服务器端语言有更好的互操作性。无论原因如何,这种爱恨交加的关系,加上 JavaScript 对浏览器本身的垄断,创造了一长串将转换为 JavaScript 的替代语言。其中一些更流行的语言包括以下内容:

  • TypeScript

  • Dart

  • CoffeeScript

  • Elm

  • ClojureScript

  • Haxe

  • Nim

  • PureScript

当然,这个列表比这要长得多,但前面的列表确实突出了其中一些更受欢迎的替代品,每个都有其自身的优点和缺点。

TypeScript 语言

TypeScript 是 JavaScript 语言的有趣且重要的替代品。作为 JavaScript 语言的超集,TypeScript 由微软开发,并于 2014 年左右发布。它与 JavaScript 非常相似,但有一个对某些人来说非常重要的特性:严格的类型推断。

严格类型是变量具有固定类型的情况。这可能是一个数字、一个字符串或一个布尔值。通过严格类型,变量无法包含任何其他类型的值。这可以防止系统中出现许多错误。

让我们看看一个 JavaScript 问题。

开发者构建了一个接受两个数字并将它们相加的函数:

function add(a, b) {
if (a && b && a + b) {
    return a + b;
  } else {
    throw "invalid parameters passed to 'add' function";
  }
}

现在,开发者已经想到了检查参数,首先检查 ab 是否都提供了,然后检查这两个值是否可以相加。然而,正如你在 第五章,超越基础 中看到的,加法操作符是重载的,所以如果传递了一个字符串,它将被连接成一个新的字符串:

add(1, 2);  // ==> 3
add(true, false);  // ==> "invalid parameters passed to 'add' function"
add(1, "2");  // ==> "12"

虽然如果开发者预期这样的结果,这是可以的,但它确实提出了可能被甚至经验丰富的开发者忽视的潜在风险。这样的错误是有问题的,因为它们不会在源代码中产生错误,而是在应用程序的更深处。

TypeScript 的静态类型可以通过确保传递给函数的值是特定类型来轻松解决这个问题:

function add(a: number, b: number): number {
  return a + b;
}

所有类型都在编译时进行检查。在上面的例子中,检查类型内容不是必要的,因为编译器将确保函数以正确的参数数量被调用。如果某个参数是可选的,则可以使用 ? 操作符将其标记为可选:

function fun(a: number, b?: boolean) {}

这样的可选参数必须始终出现在参数列表的末尾。

当然,指定变量的类型不是必需的——编译器会推断它们。这意味着根据变量包含的第一个值,编译器会期望它始终包含该类型的值。如果你希望函数参数包含任何类型,它们仍然可以包含任何类型。

练习 12.04:一个基本的 TypeScript 项目

TypeScript 是专业 JavaScript 世界中的主要参与者,因此了解如何设置 TypeScript 项目是一个重要的技能。在这个练习中,你将通过使用 TypeScript 编译器创建一个最小的 webpack 项目。让我们开始吧:

  1. 创建一个新的目录,并按照 练习 12.03:一个基本的 Webpack 项目 的说明初始化它。

  2. 接下来,从 npm 安装 TypeScript 库。这些库将被保存为 devDependencies,因为 TypeScript 在编译后不是你的项目所必需的:

    npm install --save-dev typescript ts-loader
    

    ts-loader 是一个 webpack 加载模块,因为 webpack 默认不知道或理解 *.ts 文件。

  3. TypeScript 编译器利用项目根目录中一个名为 tsconfig.json 的独特文档中的配置。这个文档的可能值非常广泛,但对于一个简单的项目,只需输入以下内容:

    {
    "compilerOptions": {
    "outDir": "./dist/",
    "noImplicitAny": true,
    "module": "es6",
    "target": "es5",
    "allowJs": true
      }
    }
    
  4. 打开 webpack.config.js 文件,并在 rules 列表中添加以下规则:

    {
      test: /\.tsx?$/,
      use: 'ts-loader',
      exclude: /node_modules/,
    },
    
  5. 将入口文件名从 index.js 更改为 index.ts。现在,你的整个 webpack.config.js 文件应该如下所示:

    webpack.config.js
    28  plugins: [
    29    new HtmlWebPackPlugin({
    30      template: "./src/index.html",
    31      filename: "./index.html"
    32    })
    33  ],
    34  resolve: {
    35    extensions: [ '.tsx', '.ts', '.js' ],
    36  },
    37  output: {
    38    filename: 'bundle.js',
    39    path: path.resolve(__dirname, 'dist'),
    40  },
    41}
    The full code is available at: https://packt.live/2KhPK3Y
    
  6. 现在,在 src 目录中提供一个 index.ts 文件,并添加以下内容:

    export function hello(name: string): string {
      return 'Hello ' + name;
    }
    
  7. 您现在可以使用以下代码编译应用程序:

    npm run wp
    

您现在应该看到一个成功的编译输出,如下所示:

![图 12.17:使用 webpack 的 TypeScript]

图片链接

图 12.17:使用 webpack 的 TypeScript

您刚刚创建的是任何类型 TypeScript 应用程序的样板代码。创建 webpack 和 TypeScript 项目可能是您在许多未来的项目中反复进行的事情。

Elm 和 ClojureScript

nullundefined值完全支持。

这些语言的目的在于通过输出更好的代码,同时提高开发者的编码能力。通过声明性和函数性思考,可以更快地解决问题并获得更好的结果。

Haxe

Haxe 从不同的角度接近 JavaScript。它被创建为一个可以在许多平台(包括以下)上编译的统一语言:

  • Flash ActionScript 3

  • C++

  • C# (.NET)

  • Java

  • JavaScript

  • Neko(一个小型、本地的跨平台虚拟机)

  • HashLink(一个更快、更便携的跨平台虚拟机)

  • PHP

  • Python

  • Lua

由于热情的社区,支持的平台不断增多。

通过在许多平台上编译,Haxe 应用程序的源代码具有在这些平台之间可能可移植的好处。这意味着您可以为 JavaScript 客户端编写 Haxe 应用程序,同时也可以在 C#服务器应用程序中使用大量相同的代码。

代码支持库

在其相对较长的生命周期中,JavaScript 获得了一些流行的和有用的库,以帮助工程师进行应用程序开发。其中一些库仅提供有用的和可重用的函数,以减少代码复杂性,而其他则提供广泛的具有意见的框架。从用户界面到数据库管理,涵盖了所有内容,许多重叠的库提供了与竞争库略有不同的东西。

jQuery

jQuery 是可用的最古老的运行实用库之一。作为一个通用工具,jQuery 通过提供一种简单的方式来操纵浏览器的文档对象模型(DOM)、执行动画、发送异步 JavaScript 和 XML(AJAX)请求、管理事件等,赋予了开发者权力。

在 jQuery 首次发布之前,在网页中查找和获取节点是一项费力的任务,同样处理来自 UI 控件的数据和事件也是如此。jQuery 的诞生缓解了早期浏览器中的许多问题,特别是在跨浏览器差异方面。

现在,jQuery 提供的许多功能现在在现代浏览器中都有,但该库本身仍然是许多寻求更统一方式来管理客户端应用程序开发的流行选择。

jQuery 提供了一个单一的全球引用对象,可以通过两种方式访问:

jquery(<context>)
$(<context>)

jQuery 的普及意味着你很可能会在互联网上看到单独的$符号被使用,实际上,很少有库采用这个简单的符号,因为它是一个有效的变量符号。

jQuery 库在前面的章节中已有一定程度的介绍,因此这里不再提供更多信息。

Underscore、Lodash 和 Ramda

函数式编程正变得越来越重要,其好处在许多不同的语言和平台上得到体现。虽然我们可以在 JavaScript 中以函数式编程,但它仍然缺乏更成熟函数式语言的一些特性。

为了克服这一缺点,JavaScript 社区提供了许多以函数式为导向的库,其中最受欢迎的是 Underscore、Lodash 和 Ramda。

Underscore 和 Lodash 之所以被命名为这样,是因为它们使用了_符号。就像 jQuery 的$符号一样,_符号为这两个库提供了一个单一的访问点。这两个库都提供了一种方法,如果应用程序中存在库命名空间冲突的可能性,可以将主要对象分配给不同名称的变量,但由于 Underscore 和 Lodash 提供了许多相同的功能,因此同时使用这两个库并不常见。Ramda 库使用大写字母R作为其库访问器:

let toString = (v) => `${v}`;
// Lodash
_.map([1, 2], toString);  // ==> ["1", "2"]
// Underscore
_.map([1, 2], toString);  // ==> ["1", "2"]
// Ramda
R.map(toString, [1, 2]);  // ==> ["1", "2"]

虽然这三个库都提供了一些重叠的功能,但其中一些功能是这三个库中独有的。例如,Ramda 库提供了 lensing 功能,这是一种在满足给定标准的数据集合子集上操作的手段。

客户端框架概述

近年来,强大的 JavaScript 应用程序框架数量激增。许多框架提供了简化单页应用程序(SPA)开发以及模块化、动态加载应用程序的有趣方法。虽然框架长期以来一直是降低开发复杂性、支持工程团队和简化常见用例的重要要求,但现代框架通常将事情提升到了全新的水平。

多亏了移动互联网浏览,JavaScript 应用程序通常需要在浏览器中提供更多功能,而在服务器上则较少。这大大增加了复杂性,尽管许多大型应用程序需要许多工程师共同参与这样的 JavaScript 项目,但从头开始构建这些项目并不利于高效的项目。因此,框架现在变得越来越重要,以减少上市时间、提高标准和最佳实践,以及提升创造力。

模型、视图和控制

大多数应用程序框架实现了一种将应用程序逻辑划分为常见功能单元的架构。这些单元的主要抽象通常由模型(Model)、视图(View)和控制器(Controller)组成,其中模型代表应用程序的数据,视图代表信息如何呈现给用户,控制器是促进数据与事件之间处理的功能:

![图 12.18:模型-视图-控制器图 12.18:模型-视图-控制器

图 12.18:模型-视图-控制器

MVC(模型-视图-控制器)的概念在 JavaScript 语言出现之前就已经被很好地推导出来了,实际上,框架通常是从 MVC 派生出来,以更适应浏览器的某种模式,例如模型-视图-视图模型(MVVM)模式。然而,仅仅理解存在一个抽象,并知道其中角色的作用,有助于提高你对任何给定框架的采用。

虚拟 DOM

许多较新框架的另一个共同点是包含虚拟 DOM。这些是管理 DOM 内节点变化的渲染引擎。这意味着,开发者将不再直接操作网页中的 HTML,而是利用位于应用程序和 DOM 之间的 API。这样,当需要更新时,虚拟 DOM 将仅修改那些已更改的 DOM 元素,通过提供已知低延迟、高效率的方法。

当在模型和视图之间实现双向数据绑定时,虚拟 DOM 的一个巨大好处就显现出来了。如果用户在文本字段中输入一个值,相关的模型可以立即自动更新。同样,当模型更新时,这也可能触发另一个视图显示这个新值。

这被称为双向数据绑定,即当某个位置发生变化时,模型和其视图能够相互更新。通过使用虚拟 DOM,这些对浏览器的操作得到了极大的增强,这可能会以前导致用户体验下降。

响应式编程

现代框架中常见的另一个特性是响应式功能。当使用虚拟 DOM 实现双向数据绑定时,这通常是一种促进响应性、弹性和可伸缩性的响应式代码。其理念是,而不是轮询和拉取数据,当数据发生变化或新数据可用时,消息会被推送到。这些更新可能发生在应用程序内部或应用程序之间,例如浏览器和服务器之间。

响应式编程是一个如此重要的新范式,以至于整个库和框架都以它的名字命名,例如 RxJS(Rx 代表响应式)和 ReactJS。

流行框架

在可用的众多框架中,有三个在 JavaScript 社区中可能是最受欢迎的。它们如下:

  • AngularJS

  • ReactJS

  • Vue.js

AngularJS

AngularJS 是由 Google 创建的,于 2010 年 10 月发布,并在 2014 年完全重写为 Angular2,改变了其许多独特的特性,并采用 TypeScript 作为其首选语言;尽管通过一些工作也可以使用 vanilla JavaScript。重写之后,Angular2 现在定期通过新版本进行增量更新。目前,Angular2 处于第 8 版,但仍被称为 Angular2,这可能会非常令人困惑。

AngularJS(及其继任者 Angular2)的学习曲线相对较高,繁荣了许多仅属于 Angular 社区的独特范例。然而,它是一个非常有意见的库,有助于确保开发团队以相同的方式使用它,从而消除任何潜在的功能模糊性。此外,AngularJS 是一个完全包容的框架,在构建复杂的基于浏览器的应用程序时,几乎提供了你可能需要的所有工具。

ReactJS

ReactJS 是由 Facebook 的一位工程师构思和开发的。与 AngularJS 相比,ReactJS 并不试图为你做所有事情,而是声称仅仅是 UI 管理层,提供虚拟 DOM 功能和其他类似的好东西。围绕这个框架形成的社区已经创建了额外的库,这些库可以结合起来形成一个具有与 AngularJS 相似功能的生态系统。然而,与 AngularJS 不同,ReactJS 并不那么有意见,这意味着应用程序可以选择实现什么,以及在某种程度上如何实现。开发者也有更大的自由度,可以从竞争提供类似功能的众多库中选择。

Vue.js

Vue.js 是由 Evan You 在 2013 年开发的,他是一位在 Google 工作的开发者,当时一直在使用 AngularJS,但他决定他只想保留他喜欢的 AngularJS 元素,并创建自己的轻量级框架。结果是,一个模块化框架,它提供了几乎与 AngularJS 相同的功能,但以可以按需选择的片段形式提供,例如 ReactJS,并且总文件大小远小于其他两个框架。

Vue.js 的学习曲线非常平缓,作为一个提供优秀应用开发结构但又不拘泥于团队环境中工作得很好的严格意见化范例的框架,它的受欢迎程度正在稳步上升。然而,这些往往阻碍了那些不那么常规项目的进展。

服务器端库

就像浏览器一样,服务器端 JavaScript 生态系统也有访问库和框架的权限,这些库和框架有助于应用程序的开发。由于 Node.js 也是 JavaScript,因此可以在浏览器和 Node.js 中利用许多库。例如,Lodash、Underscore 和 Ramda 没有浏览器特定的特性,在 Node.js 应用程序中也能正常工作。这是 Node.js 平台在最初发布后迅速起飞的关键原因之一;由于平台上有许多库已经可用,这些库是为浏览器应用程序创建的,因此开发人员可以在他们熟悉的方式下继续进行服务器端应用程序开发。

除了所有可能的以浏览器为中心的库之外,Node.js 还有一些自己的关键库,这些库提供了在浏览器中无法实现的功能,例如 REST 服务器功能或数据库对象关系映射(ORM)。

Express

可能是 Node.js 最受欢迎的库之一,名为 Express。这个库使得构建 web 服务器变得极其简单,因为它提供了在请求发送到这些路由时建立路由和提供内容的功能。

Express 可以通过以下命令安装:

npm install --save express

安装完成后,你可以编写一个简单的服务器应用程序,例如以下内容:

const express = require('express')
const app = express()
const port = 4000; 
app.get("/say_hello", (request, response) => response.send("Hello, World!"))
app.listen(4000port, () => console.log(`Web server now listening on port: ${port}`))

一旦构建完成,web 服务器就可以像任何其他 Node.js 应用程序一样启动;例如:

node index.js

Express 对象支持所有可能的 HTTP 调用类型,包括 GETPUTPOSTDELETE,从而使得完全具备 REST 功能的服务器成为可能,但它也具备使用特殊 static 函数提供静态 HTML、JavaScript、CSS 和其他此类资产文件的能力。

Express 库非常完整,因此建议阅读其网站上的文档和指南,以便你能够真正感受到其功能。

请求

虽然 Express 充当 web 服务器,但 Request 库充当 web 客户端。在创建网络应用程序时,有时可能需要从另一个远程 web 服务器代理内容、数据或功能。Request 库允许为此目的与这些服务器进行通信。

请求可以通过以下命令安装:

npm install --save request

一旦安装,你可以按以下方式使用 Request:

const request = require("request")
request("https://www.google.com", function (error, response, data) {
  // do something with data
});

Socket.IO

使用 HTTP 进行工作可能会很慢。客户端创建一个请求包并将其发送到接收服务器。然后,该服务器创建一个响应包并将其发送回调用者。每个请求/响应事务都是原子的,这意味着它独立于任何其他请求/响应事务发生,通常需要设置和断开一个独特的连接。

现代应用程序越来越倾向于利用 WebSocket。这些是“始终连接”的套接字,它们使用更快速、更敏捷的连接协议来发送数据,非常适合构建在线聊天室、多人游戏等应用程序,也适用于快速数据存储和检索。

虽然 WebSocket 使用自己的协议,但该协议仅处理数据传输的安全性和可靠性,而不是应用程序逻辑的具体要求。

为了解决这个问题,Socket.IO 提供了一个额外的层,使得使用 WebSocket 变得更加容易,从而为许多可以利用它的应用程序提供了通用的功能。

可以使用以下命令安装 Socket.IO:

npm install --save socket.io

一个简单的 Socket.IO 服务器应用程序看起来如下所示:

const app = require("express")()
const server = require("http").createServer(app)
const sio = require("socket.io")(server)
sio.on("connection", function(socket){
  console.log("Connection established");
});

活动 12.01:创建一个识别和编译 TypeScript 文件的项目

本章提供了大量关于调查更大 JavaScript 世界的信息。显然,你可以在语言或平台的基础知识之外学到很多东西,但知道在哪里寻找以及如何将新的库或框架集成到你的应用程序中,为你提供了必要的基石,从那里你可以进行实验并提高你的编码技能。

在这个活动中,你被要求为一个新的项目设置 TypeScript。你现在已经知道 TypeScript 是什么了。对于当前的项目,你的项目经理和开发同事都希望利用 TypeScript 的静态类型能力,以及其他功能,这将是一个大型项目。

本活动的任务是创建初始项目设置,确保 TypeScript 文件被正确识别并编译到输出文件夹。不需要提供任何代码,只需确保一切编译成功。项目经理很高兴在这个项目中使用 Parcel 以保持事情简单。

本活动的概要步骤如下:

  1. 创建一个新的项目,并初始化 npm

  2. 将 Parcel 作为全局库安装。

  3. 在应用程序中安装 TypeScript 库。

  4. 创建必要的 TypeScript 配置,但要保持简单。

  5. src 目录中创建一个临时的 .ts 文件。

  6. 将必要的脚本添加到 package.json 文件中。

  7. 运行编译器并确保生成输出。你不应该看到任何错误。如果一切顺利,TypeScript 编译器应该显示 Built in <x>ms 的响应消息。

本活动的预期输出如下:

图 12.19:Parcel TypeScript 输出

img/C14377_12_19.jpg

图 12.19:Parcel TypeScript 输出

注意

本活动的解决方案可以在第 752 页找到。

仅了解如何使用包管理器就能打开大量的能力,让你可以快速创建功能性的应用程序。在专业环境中工作,正确使用必要的工具至关重要,这将确保你的应用程序有一个良好的开端。

摘要

在本章中,我们回顾了市场上用于 JavaScript 高级开发的多种工具。我们学习了如何在旧浏览器中使用最新的 JavaScript 语法,并确定了可用于在其他语言中开发 JavaScript 应用程序的不同选项。我们还探讨了与 JavaScript 兼容的各种包管理器,例如 npm 和 Yarn,以及几个不同的框架,如 AngularJS、ReactJS 和 Vue.js。最后,我们查看了一些服务器端库,例如 Express、Request 和 Socket.IO。

在下一章中,我们将探讨一些其他高级 JavaScript 领域。

第十三章:13. JavaScript 编程范式

概述

到本章结束时,你将能够应用不同的 JavaScript 编程范式;使用原型、继承和匿名函数;列出不同类型的数据作用域和闭包;使用提升声明变量;并解释 JavaScript 内存管理。

在本章中,我们将深入探讨使 JavaScript 成为一个非常多样化和多范式编程语言的核心特性。

简介

到目前为止,我们已经了解了 JavaScript 在浏览器级别的重要性以及其在服务器级别的能力。我们学习了如何在系统上安装 Node.js 以及如何编写和执行代码。此外,我们还获得了大量关于 Node.js 内部和外部模块的知识。此外,上一章还涵盖了 Web Sockets 和与数据库的工作。我们都是通过有趣的活动和练习来学习这些内容的。现在,是时候加强这些知识,并学习 JavaScript 的根本概念了。

在本章中,我们将探讨不同类型的 JavaScript 编程范式。在任何编程语言的学习阶段,人们通常以过程式的方式编码;而不是规划,他们把大部分精力放在执行和理解该特定编程语言的概念上。但是,当涉及到解决现实生活中的问题时,过程式的方法并不是一个可扩展的选项。幸运的是,我们有大量的不同类型的代码实现技术,我们可以使用这些技术用编程语言来模拟现实生活中的实体,例如面向对象编程范式,或 OOP

让我们用一个现实世界的例子来说明,我们需要为我们的大学构建一个项目。这里会有教师、职员、学生、系主任等等。实现这个项目的一种方式是为这些实体中的每一个分别编写逻辑,这并不是一个可扩展的选项,而且它也不会是一个灵活的解决方案。另一种方式是使用面向对象的方法,其中我们将创建一个 Person 模型,该模型将使用对象中的键来存储人的职位。这样,我们就将人与他们的职位分离开了。我们可以轻松地在 Person 或实体(即系主任、教师、学生等)中实现更改。对 Person 的所有更改将自动应用于实体,因为它们属于 Person 类。我们可以使用很多其他方法来解决相同类型的问题。在本章中,我们将探讨其中的一些。

之后,你将学习原型是什么以及如何使用它们来实现继承。还有很多其他基础知识。让我们开始吧。

JavaScript 编程范式

编程范式是我们编写代码以解决不同类型问题的方法或方法。由于有大量的编码方式,因此开发者使用了许多编程范式来编写代码。

JavaScript 是一种多范式脚本语言,这意味着它在本质上非常动态,支持各种类型的编程风格,如面向对象、命令式和函数式编程。在这本书中,我们将讨论在开发者中流行的三种主要编程范式。

我们可以将编程范式分为两类:

  • 命令式,包括程序化编程面向对象

  • 声明式,包括函数式编程

在本章中,我们将讨论程序化和面向对象编程。函数式编程相当流行,有很多概念需要学习,所以我们专门用一章,名为函数式编程,来涵盖这个主题。

程序化范式

正如其名所示,这个范式遵循程序化模式。在这个范式中,我们将整个程序划分为例程和子例程。在这个编码模式中,流程非常线性且同步。它遵循自顶向下的编程方法。它仅仅涉及将期望的结果分解为一些例程和更小的子例程。这些子例程将进一步划分为过程,然后执行以实现期望的结果。

程序化编程使用自顶向下的方法来编写应用程序,而面向对象编程则遵循数据流的之字形方法。在开发大型应用程序时,可重用性是最重要的因素之一。与面向对象编程相比,程序化编程的可重用性较低,这也是面向对象方法在可扩展应用程序中更受欢迎的原因。在用程序化编程开发程序时,我们可能会在没有考虑代码重用的前提下规划程序。

在程序化编程中,数据流是顺序的,但这并不意味着我们不需要规划数据流。在程序化编程中仍然涉及到规划。它采取了一种更加字面的方法。程序化编程应用程序的结构更像是故事格式。程序化方法使开发过程变得更加简单,但消耗的时间更多。

采用程序化范式的优点如下:

  • 在网上可以找到大量的学习资源。

  • 它有更简单的方式来跟踪流程。

  • 程序的实现非常简单。

采用程序化范式的缺点如下:

  • 与现实世界难以相关联。

  • 数据安全性较低。

  • 使用这种方法解决复杂问题比较困难。

程序化方法是在人们开始学习代码时使用的基本方法之一。因此,为了加强我们的概念,始终练习我们刚刚学到的内容是更好的。让我们做一个练习,我们将使用直接的程序化编程来实现一个非常基本和简单的函数。

练习 13.01:实现程序化编程

在这个练习中,我们有一个字符串,我们必须将其中的每个单词都转换为大写。按照程序性方法,我们必须以自顶向下的方式实现它。这意味着我们将从第一个语句开始,到最后的语句,我们将得到我们的结果。为了做到这一点,我们必须执行一些操作。让我们逐一查看它们:

  1. 首先,让我们创建一个空文件,并将其命名为procedural.js。如果您想的话,可以更改名称。

  2. 让我们编写一个名为toCapitalize()的函数,它将接受一个参数作为输入。输入参数只能是字符串类型:

    functiontoCapitalize(input){
       }
    
  3. 在这个函数内部,让我们首先使用空格将输入字符串分割开。这将把输入字符串分割成单词,并返回所有这些单词的数组:

    let arrayOfString = input.split(' ');
    

    在这个语句之后,arrayOfString将包含一个单词数组,即['Once', 'upon', 'a', 'time', 'in', 'new', 'york']

  4. 让我们遍历这个数组:

    for(let i=0; i<arrayOfString.length; i++) {
       }
    
  5. 在这个for循环内部,提取所有单词的首字母,并将结果保存到一个变量中:

    letfirstChar = arrayOfString[i].charAt(0);
    
  6. 让我们使用toUpperCase()方法将这个字母转换为大写:

    firstChar.toUpperCase();
    
  7. 现在,从输入字符串中移除每个单词的首字母,并用我们刚刚制作的大写字母替换它:

    arrayOfString[i] = firstChar.toUpperCase() + arrayOfString[i].slice(1);
    
  8. 最后,让我们将数组中的所有单词连接起来,包括空格,再次形成一个句子,并将其作为函数的输出返回:

    arrayOfString.join(' ');
    
  9. 现在,我们可以将任何字符串传递给这个函数,它将返回所有单词首字母大写的字符串:

    let string = "Once upon a time in new york.";
        console.log(toCapitalize(string));
    
  10. 让我们使用 Node.js 执行这个脚本并查看输出:

![图 13.1:程序性练习的输出img/C14377_13_01.jpg

图 13.1:程序性练习的输出

如您所见,我们现在得到了期望的输出:每个单词的首字母都大写。在代码中,我们调用了许多方法,例如splittoUpperCaseslicejoin。所有这些函数都是自顶向下调用的。我们从这个函数的第一个语句开始,其中包含我们的输入字符串,然后处理输入。在执行这个函数的最后一个语句之后,我们得到了期望的结果。这是一个程序性编程实现的简单例子。接下来,我们将学习如何实现编程中的面向对象方法。

面向对象范式

面向对象范式是开发者使用最广泛的范式之一。许多编程语言都倾向于这种范式。其受欢迎的原因在于它能够用代码模拟现实生活中的事物。在这个范式中,我们可以创建代表现实世界实体的对象。

我们使用类来模仿现实世界的类别,然后我们可以从这些类中创建对象,这些对象将充当实体。JavaScript 中的所有类都有一个构造函数,每次我们初始化该对象的新的实例时,它都会执行。我们使用 Class 关键字来创建一个类,constructor 是每个类内部的一个默认函数:

class Animal{
constructor(category){
}
}

constructor 函数中初始化的任何内容都将具有整个类的范围级别。我们可以使用 new 关键字来创建这个对象的实例:

new Animal('Lion');

在这里,创建新实例时传递的参数将直接传递给 constructor 函数。

练习 13.02:实现面向对象编程

面向对象范式是最好的实现方式,因为我们可以将代码与现实生活联系起来。我们创建现实世界实体的类,如汽车、家具和电子产品,并创建这些类的实例来表示现实世界的对象。例如,想象你有一辆来自奥迪制造商的汽车。这辆汽车是一个实体,我们可以将其表示为一个面向对象的类。我们可以创建这个 car 类的实例,它将代表你的汽车,并且这个实例将包含关于你的信息,例如汽车的所有者是你,制造商是奥迪。每个实例都将作为该车辆的登记。这样,我们可以在代码和现实世界之间建立关系。

要理解这一点,最好的方式是从现实世界中的例子入手并实现它。让我们考虑一个简单的程序,该程序显示地球上生物的信息。为此,我们必须创建一个生物类,并且我们可以创建该类的多个实例,例如 HumansAnimalsPlants

要开始这个话题,我们首先需要创建一个名为 Humans 的类。人类有很多共同的特征,比如年龄、体重、身高,以及肤色和发色,但在这个例子中,我们只对展示他们的年龄、姓名和性别感兴趣:

  1. 让我们创建一个空文件,并将其命名为 humans.js

  2. 现在,让我们创建一个名为 Humans 的类和一个 constructor 函数,这个函数将接受 nameagegender 作为参数:

    class Humans {
    constructor(name, age, gender) {
            this.name = name;
    this.age = age;
    this.gender = gender;
        }
     }
    
  3. 让我们在类中创建一个额外的函数,该函数将打印 nameagegender 的值,因为只有这个类的函数才能访问类变量:

    info() {
    return console.log(this);
        }
    
  4. 从我们的 Humans 类中,我们现在可以创建一些实例,代表真实的人类:

    let Gaurav = new Humans('Gaurav', 24, 'Male');
    let Nishi = new Humans('Nishi', 23, 'Female');
    
  5. 现在,我们将打印这两个对象。它们将包含我们在创建它们时传递给构造函数的所有信息:图 13.2:面向对象练习的输出

图 13.2:面向对象练习的输出

如您所见,我们现在有两个Humans类的实例,每个实例都包含有关一个人的信息。我们可以将这些两个实例视为真实的人类。他们有自己的nameagegender属性。这在编写代码时将非常有帮助,因为你知道你正在为哪个对象编写逻辑。同样,我们可以将任何现实世界的实体实现为一个类,并可以创建该类的多个对象,这些对象可以代表现实世界的对象。

到目前为止,我们知道使用面向对象编程有很多好处,其中一些非常有用。接下来,我们将探讨这个范式提供的两个非常重要的特性——封装和继承。让我们逐一了解这些概念,并看看我们如何使用 JavaScript 来实现这些概念。

封装

使用面向对象编程(OOP)的一个好处是它保护了类中的数据。我们可以向类中添加只有类的方法才能访问的数据。其他类将无法访问这些数据。它就像一个保护盾牌,保护着类。

正如我们在上一个例子中所见,nameagegender变量的作用域仅限于类。在类中初始化的所有方法都可以访问这些变量。这就是我们如何在类内部保护数据的方式。

继承

继承是一种方式,其中一个类可以访问另一个类的属性。在上一个例子中,人类可以有各种各样的职业。因此,我们也可以通过职业来继承Humans类。让我们通过一个练习来检验这个概念。在上一个练习中,我们创建了一个包含姓名、性别和年龄信息的Humans类。我们将通过创建一个新的类Teacher来进一步扩展这个类。这个类将包含Humans类的所有属性,以及一些额外的属性,描述他们教授的科目、成绩等。这将给你一个关于类如何扩展以及父类属性如何被其他类继承的直观印象。现在让我们进入代码。

练习 13.03:使用extends关键字实现继承

让我们创建一个名为Teacher的类,它将继承Humans类的属性。我们将使用extends关键字来继承本练习中的类:

  1. 使用extends关键字来继承类:

    class Teacher extends Humans {
      }
    
  2. 在这个类中,我们必须将所有必要的参数传递给constructor函数。在constructor函数内部,我们将调用super函数并传递父类所需的参数:

    constructor( name, age, gender, subject, grade ) {
            super(name, age, gender);
    this.subject = subject;
    this.grade = grade;
        }
    
  3. 在这里,nameagegenderHumans类的参数。使用super函数,我们将这些参数传递给了Humans类,其余的变量,即subjectgrade,是Teacher类的一部分。

  4. 让我们在Teacher类中有一个info方法,它会打印出该类的所有变量:

    info(){
             return console.log(this);
        }
    
  5. 在我们完成Teacher类的创建后,让我们创建一个它的实例:

    let teacher = new Teacher('GauravMehla', 24, 'Male','Science', 'A');
    
  6. 最后,让我们用 Node.js 运行这个脚本:

图 13.3:继承后的 Teacher 实例

img/C14377_13_03.jpg

图 13.3:继承后的 Teacher 实例

正如输出所示,有一些属性,如 nameagegender,在 Teacher 类中不存在。这些属性是从我们实现的 Humans 类继承而来的,该类在 练习 13.02:实现 OOP 中实现。

现在我们知道了如何使用 JavaScript 实现继承,我们现在可以创建多个 Teacher 类的实例,这些实例将继承其父类 Humans 的所有属性。

让我们看看采用面向对象范式的优点:

  • 可重用性:我们可以反复使用已经创建的类,而无需创建新类。

  • 现实生活建模:我们可以使用面向对象编程(OOP)来模拟现实世界的概念,例如椅子、人或汽车。这使得理解实现变得容易。

  • 并行开发:类可以独立,这意味着我们可以同时开发多个类。这导致项目开发更快。

  • 团队独立性:由于面向对象编程支持并行开发,团队可以相互独立工作。

  • 安全开发:面向对象编程的特性,如继承和封装,隐藏了数据,从而提高了安全性。类的内部数据不能被外部函数访问。

使用面向对象范式的可能缺点:

  • 不必要的代码:如果没有适当的规划,它可能会创建大量不必要的冗余代码。

  • 代码重复:由于面向对象编程可以为每个单独的类实现,因此可能导致代码重复。

  • 早期规划:程序员在设计程序之前应该有一个适当的计划。

  • 项目规模:使用面向对象编程开发的项目通常比使用其他方法(如过程式范式)开发的项目更大。

在本节中,我们学习了可以使用 JavaScript 的不同编程范式。我们深入探讨了两种最流行的范式——过程式和面向对象。我们讨论了两种范式的不同优缺点。我们还学习了面向对象编程的两个最重要的特性,即封装和继承。

基本 JavaScript 概念

编程范式很重要,但要详细了解它们,我们需要对不同的 JavaScript 概念有一个基本的了解。因此,让我们回顾一些 JavaScript 的核心概念,这将帮助您掌握 JavaScript,并更好地理解我们如何使用编程范式来构建可扩展的解决方案。

原型和原型继承

对象非常重要,因为它们帮助我们操作 JavaScript 以实现我们想要的功能。在 JavaScript 中有很多创建对象的方法。其中一种方法是通过使用 constructor 函数,每次我们创建一个函数时,JavaScript 引擎都会向该函数添加一个 prototype 属性。这个 prototype 属性是一个默认包含 constructor 属性的对象。这个构造函数指向父函数。你可以通过调用 functionName.prototype 来看到这个函数。

让我们首先创建一个函数:

functionPersonName(first_name, last_name) {
this.first_name = first_name;
this.last_name = last_name;
this.fullName = function(){
 return [ this.first_name, this.last_name].join(" ");
     }
 }

现在,让我们通过输入 PersonName.prototype 来检查它的 prototype 属性。输出将如下所示:

![图 13.4:对象的原型属性图片

图 13.4:对象的原型属性

如你所见,我们创建了一个名为 Person 的函数,JavaScript 自动将其绑定到一个 prototype 属性上。你可以打印 prototype 并看到有一个 constructor 属性,它包含了父函数的所有元数据。

什么是原型继承?

如我们所知,JavaScript 中的所有内容都是一个对象。每一个字符串、整数、数组、对象和定义的函数都是其相应父类的一个对象。JavaScript 中的每个对象都包含一个 proto 属性(子对象内部的 __proto__ 键通常被称为 proto 属性),它包含了其父类的所有属性。我们可以使用这些 proto 属性来实现继承。这些 prototype 对象作为模板对象,所有子对象都将从中继承方法和属性。我们也可以使用这个 prototype 属性来覆盖父类的属性。这种原型链接被称为原型链。

练习 13.04:原型继承实现

在这个练习中,让我们实现一个非常简单的函数,它将接受一个名字和一个姓氏作为参数,并返回全名。完成这个练习后,你应该完全清楚原型继承是如何工作的,以及实现这种输出两种方法的区别:

  1. 我们可以在 PersonName 父函数内部实现 firstNamelastName 子函数,这样它们就能使用父函数的值。我们可以将这些函数绑定到它们的父原型上,因为父函数总能访问子函数的作用域。让我们实现并观察这两种方法。使用 F12 键打开 Google Chrome 开发者工具控制台。

  2. 使用这个构造函数创建一些对象,并将代码粘贴到控制台中:

    functionPersonName(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.fullName = function(){
     return [ this.firstName, this.lastName].join(" ");
         }
     }
    let pName1 = new PersonName("Gaurav", "Mehla");
    

    每当我们使用 constructor 函数创建对象时,JavaScript 都会向其对象添加一个属性,即 _proto_。这个 _proto_ 属性包含了指向主函数原型的链接,这在下面的输出中可以看到:

    ![图 13.5:使用 new 关键字创建的实例的原型属性 图片

    图 13.5:使用new关键字创建的实例的原型属性

    如你所见,pName1对象有一个__proto__属性,它直接指向其父PersonName.prototype的原型。在下一步中,我们可以使用这个prototype属性通过 JavaScript 实现继承。

  3. 让我们将fullName函数绑定到Personprototype属性上,而不是在函数内部定义它:

    PersonName.prototype.fullName = function(){
        return [this.firstName, this.lastName].join(" ");
    }
    
  4. 现在,使用new关键字创建两个PersonName方法的对象:

    let pName1 = new PersonName("Gaurav", "Mehla");
     let pName2 = new PersonName("Sarthak", "Sharma");
    
  5. 在这里使用new关键字创建的所有对象现在都将包含一个proto属性,该属性将指向其父原型,并且父原型中定义的所有函数和属性都将对所有子对象可访问。换句话说,如果我们创建一个函数并将其绑定到父函数的原型上,那么这个函数将通过原型链对所有对象可访问。现在,使用其对象调用定义在Person方法prototype属性中的fullName函数:

    pName1.fullName();
    pName2.fullName();
    

    输出如下:

图 13.6:原型链示例

图 13.6:原型链示例

在这个练习中,我们声明了一个构造函数(PersonName),然后创建了一个函数(fullName)并将其绑定到其原型上。接着,我们使用该函数的构造函数创建了两个子对象。你可以看到这两个对象都可以访问到fullName函数,该函数位于它们的父原型中。这样,我们就通过原型链实现了继承,如图13.7所示:

图 13.7:__proto__ 属性的引用

图 13.7:proto 属性的引用

现在,我们可以创建任意多的PersonName实例,并且所有这些实例都将始终可以访问PersonName.fullName

我们甚至可以修改原型,并且这些更改将立即反映在其所有子对象中。

匿名函数

JavaScript 语言中的对象非常复杂。JavaScript 中的所有东西都是对象。因此,JavaScript 中的函数被视为功能性对象,可以像使用对象一样使用。JavaScript 中的函数也可以作为参数传递给其他函数。任何返回函数的函数都称为函数工厂。让我们来看一个例子:

functioncalculateSum(a, b) {
    return () => {
        return a + b();
    }
}
let sum = calculateSum(10, () =>20);

在这个例子中,我们调用了一个函数calculateSum,并向它传递了两个参数。一个是数字,另一个是函数。这个函数将返回一个新的函数,该函数将显示传递的函数和结果,如下面的输出所示:

图 13.8:匿名函数示例

图 13.8:匿名函数的示例

在示例中,您可以看到我们使用了大量未命名的函数。这些函数在 JavaScript 中非常重要。没有特定名称声明的函数称为匿名函数。我们在用 JavaScript 编程时经常使用匿名函数。这类函数在运行时动态声明,并可以作为参数传递给其他函数。例如,Function () {} 是匿名函数的典型示例。您可以将此函数的返回值赋给任何变量。以这种方式创建函数允许我们在运行时创建函数。匿名函数主要用于回调。

命名函数和无名函数之间的区别

命名函数和无名函数之间的主要区别在于,当您声明一个命名函数时,编译器会为该函数分配一个存储内存块。因此,如果您必须调用它,您可以使用名称来调用它。但是,对于匿名函数,内存块被分配给它们,并返回地址,然后我们可以将其存储在变量中。这有助于我们在不能声明命名函数的地方初始化函数。我们甚至可以通过将函数赋给另一个变量来更改调用此函数的名称。这可以在以下图中表示:

![图 13.9:命名函数与匿名函数的比较img/C14377_13_09.jpg

图 13.9:命名函数与匿名函数的比较

数据作用域

数据作用域确定在运行时您的代码中变量、函数和对象的访问性。这意味着变量的作用域由变量声明的位置控制。

在 JavaScript 中,存在两种主要的范围类型:

  • 全局作用域

  • 局部作用域,包括函数级块级

全局作用域

每个 JavaScript 应用程序都有一个全局作用域,我们可以定义所有函数都可以访问的内容。所有在函数、块和模块外部定义的变量都具有全局作用域。全局变量在整个应用程序的生命周期内都是可用的。

创建全局变量的另一种方式是使用预定义的全局变量,例如 process(在 Node.js 中)和 window(在浏览器中)。您可以将任何值绑定到这些已定义的全局变量上,并且您可以从应用程序的任何地方访问它们。例如,让我们将 NODE_VERSION 值添加到 processenv 属性中:

$ process.env.NODE_VERSION=10.8

在程序执行过程中,process 变量具有全局作用域。现在,我们可以在程序的任何地方访问我们设置的值(NODE_VERSION = 10.08):

$ console.log(process.env.NODE_VERSION); // "10.08"

局部作用域

在函数或块内定义的变量处于局部作用域。只有定义在该函数或块内部的函数才能访问这些变量。

函数级作用域

在 JavaScript 中,每个函数都有自己的作用域。在该函数内部定义的所有变量和函数将只能访问彼此:

// Function A
function parent(arg1, arg2) {
let name = "gaurav";
let age = 24;
function print(){
console.log(name, age);
}
}
// Function B
function print(){
console.log(name, age); // Error : name, age variable not defined.
}

在这里,两个函数有不同的作用域级别。函数 B无法访问函数 A中定义的变量。以下图中突出了两个函数的作用域:

图 13.10:函数级作用域的示例

![图 13.10:函数级作用域的示例### 块级作用域块级作用域类似于函数级作用域,但在这类作用域中,我们不初始化任何函数。我们可以在 JavaScript 中创建块来分离变量的作用域:js// 1st block{let name = "gaurav";let age = 24;console.log(name, age);}// 2nd block{console.log(name, age);}注意我们在 JavaScript 中使用花括号来创建块。第二个块无法访问第一个块中创建的任何变量。## 提升机制我们现在知道变量和函数的作用域取决于它们声明的位置,但 JavaScript 中有一个有趣的概念,称为提升。提升是一个特性,其中解释器将函数和变量的声明移动到它们作用域的顶部。这意味着变量声明在执行任何代码之前被处理。当处理任何作用域时,首先搜索整个作用域中的变量和函数声明。然后,为每个变量和函数分配内存空间。之后,按行执行函数或块的主体。注意提升只移动变量和函数的声明,而不移动赋值。赋值保持在相同的位置。函数首先提升,然后是变量。因此,始终首先声明函数然后处理实现部分是一个好习惯:js// 1st block{var name;console.log(name);name="gaurav"}// 2nd block{console.log(name);var name = "gaurav";}在这里,两个块都将返回undefined。两个块输出相同。在块内部,声明的位置并不重要。第一个块不会抛出任何与未定义变量相关的错误。## varlet之间的差异在var的情况下,在创建变量定义之后,每个变量都被初始化为未定义值,但在let/const的情况下,直到声明行才会发生初始化为未定义的操作。在以下代码中,变量处于临时死区,访问它会导致引用错误:js// 1st block{let name;console.log(name);name="gaurav";}// 2nd block{console.log(name);let name="gaurav"}让我们执行这个,通过观察varlet的不同输出来看差异:图 13.11:关键字的使用

图 13.11:let关键字的使用

在前面的图中,你可以看到如果我们使用let,它会抛出一个引用错误。让我们看看使用var关键字时的输出:

图 13.12:关键字的使用

图 13.12:var关键字的使用

我们可以看到使用var关键字为两个块都提供了相同的未定义输出。

闭包

闭包是 JavaScript 中的一个特性,其中一个函数定义在另一个函数内部,可以访问父函数的变量。闭包有三个作用域链:

  • 自作用域:在其花括号内定义的变量

  • 父函数:在父函数中定义的属性

  • 全局变量:在全局作用域中定义的属性

让我们来看一个例子:

function outer(arg) {
let count = 1;
function inner() {
console.log(arg, '=', count++);
    }
return inner;
}
varfunA = outer('A');  // outer() invoked the first time
varfunB = outer('B');  // outer() invoked the second time
funA(); // function funA called for first time
funA(); // function funA called for second time
funA(); // function funA called for third time
funB(); // function funB called for first time

我们有一个主要函数,即outer。然后,我们声明了一个值为1count变量。我们还有一个inner函数,它正在使用并增加count的值。然后,我们返回inner函数。前面代码的输出如下:

图 13.13:闭包示例

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_13_13.jpg)

图 13.13:闭包示例

当我们第一次调用outer('A')函数时,我们为count变量和inner函数创建了一个作用域。然后,我们返回inner函数并将其地址保存在funA()中。我们对funB()也做了同样的事情。

当我们首先调用funA()函数时,它能够访问count变量,因为它在其父函数中。所以,它打印了count的值,并通过添加1来更新它。当我们再次调用它时,它再次访问父作用域并获取count的更新值。JavaScript 能够实现这一点,归功于闭包。

在本节中,我们学习了 JavaScript 的许多基本功能。我们从原型开始,并使用它们来实现继承。然后,我们学习了匿名和命名函数以及如何使用它们。我们还学习了 JavaScript 中不同类型的数据作用域。最后,我们学习了提升和闭包,这些是 JavaScript 提供的最复杂和最重要的功能之一。

JavaScript 内存管理

在其他技术语言中,如 C 和 C++,内存分配和释放是一个额外的任务。我们必须在 C 中使用malloc()free()函数为我们的变量分配和释放内存。幸运的是,我们不再需要在 JavaScript 中关心内存分配了。JavaScript 内置了垃圾回收器。JavaScript 在对象创建和销毁时自动分配和释放内存。

内存生命周期

大多数编程语言的内存生命周期是相同的。无论你使用的是 JavaScript、Python 还是 Java,内存的分配和释放几乎都是相同的。它们都遵循三个步骤:

  1. 分配内存。

  2. 使用分配的内存。

  3. 释放分配的内存。

在低级语言中,第一和最后部分是显式的,这意味着开发者必须编写代码来分配和释放内存,但在像 JavaScript 这样的高级语言中,这通常是隐式的。在编译你的代码时,编译器会检查代码中使用的所有原始数据类型,并计算程序将占用多少内存。然后,它将所需的内存分配到程序的调用栈空间中。这个过程称为静态内存分配。在运行时,内存分配以 LIFO(后进先出)的方式进行,这意味着随着函数的调用和终止,它们的内存被添加到现有内存的顶部,并以 LIFO 顺序移除。

静态分配与动态分配

存在两种内存分配类型,静态和动态。静态分配仅在编译时执行。当我们编译代码时,编译器会确定所有静态变量并在那时分配内存。以下是一个示例:

let num = 786;    // allocates memory for a number
let str = 'Hello World';  // allocates memory for a string

如其名所示,动态分配仅在运行时执行,因为有时我们不知道数据的确切大小,例如,当将 API 的响应分配给变量时。在这种情况下,我们不知道 API 会发送给我们什么,因此内存将在运行时分配:

let res = response.json();  // allocates memory for json

在这个示例中,我们无法提前预测 JSON 对象的大小,因此这个变量将动态分配。静态和动态内存分配之间的主要区别将在下一节中详细说明。

以下为静态内存分配的特点:

  • 在编译时分配

  • 使用堆存储

  • 当已知所需内存量时使用较好

  • 使用 LIFO(后进先出)

  • 比动态分配执行更快

  • 更高效

  • 性能更高

以下为动态内存分配的特点:

  • 在运行时分配

  • 使用堆存储

  • 当所需内存量未知时使用较好

  • 没有分配顺序

  • 比静态分配执行更慢

  • 比静态分配效率低

  • 由于运行时内存分配,性能较慢

释放内存

最困难的任务是确定何时分配的内存不再需要。当需要找到和清除未使用的内存时,JavaScript 的垃圾回收器非常有用。

引用计数垃圾回收

找到可用的变量的一种方法是通过找到它们的引用。如果任何变量有多个引用,这意味着可以使用这个变量。但如果我们移除任何变量的所有引用,它就变得无用,并且 JavaScript 将在下一个周期中回收它。

假设我们有一个嵌套对象obj。它有一个名为a的属性,还有一个名为b的属性。现在,obj引用a,而a引用b。访问b的唯一方法是通过a

let obj = {
a : {
 b : 2
    }
}

如果我们将a的引用更改为b,则b将被垃圾回收:

obj.a = null;

现在,由于没有对b的引用,垃圾回收器将删除它并释放内存。

在本节中,我们学习了 JavaScript 如何自动管理内存,以及它如何为我们进行内存管理。我们了解了静态和堆存储设备,并对 JavaScript 中的垃圾回收器的工作原理进行了概述。我们探讨了引用垃圾回收作为垃圾回收器在 JavaScript 程序中查找和清除未使用内存的许多方法之一。

活动练习 13.01:创建计算器应用程序

在本章中,我们学习了在用 JavaScript 编程时可以使用的不同类型的范式。现在,是时候加深我们对这些范式的了解,并确保我们在现实世界中实现范式时知道它们之间的区别。

让我们使用过程式和面向对象两种方法构建一个简单的计算器应用程序,这将为我们提供一个清晰的示例,说明如何以不同的范式实现问题的解决方案。

活动的高级步骤如下:

  1. 创建一个空文件并命名为procedural.js

  2. 初始化一个数组,该数组将维护函数调用的历史记录。

  3. 创建简单的additionsubtractionmultiplicationdivisionpower函数。

  4. 创建一个history函数,该函数将维护函数调用的历史记录。

  5. 逐个调用所有函数,并使用一些随机数字作为参数。

  6. 现在打印历史记录以检查输出。

  7. 现在,使用面向对象编程(OOP)构建应用程序。创建一个类并命名为calculator

  8. 初始化一个historyList数组,该数组将维护所有函数调用的历史记录。

  9. 创建简单的addsubtractmultiplydividepow方法。

  10. 添加一个额外的函数,该函数将显示操作的历史记录。

  11. 创建此类的实例,并使用简单的数字调用其方法以执行数学运算。

  12. 调用calculator类的history方法以检查历史记录。

使用过程式和面向对象两种方法,此代码的输出结果如下:

图 13.14:使用过程式和面向对象方法得到相同输出

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_13_14.jpg)

图 13.14:使用过程式和面向对象方法得到相同输出

注意

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

你将看到,两个脚本的输出完全相同,但我们的实现方式完全不同。这种实现方式告诉我们,解决问题有多种方法。最佳方法取决于许多因素,例如团队规模、项目计划、项目期望等。因此,最终,我们实现了相同问题的相同解决方案。

摘要

到目前为止,在这本书中,我们已经涵盖了客户端和服务器端 JavaScript 的执行。你学习了 JavaScript 中作用域的重要性,以及 JavaScript 坚实基础所需的基本知识。

在本章中,我们学习了不同的编程范式。我们讨论了开发者常用的两种流行方法,即过程式和面向对象。然后,我们探讨了原型、数据作用域、提升和闭包的用法。

现在,让我们深入探讨最后一个也是最关键的编程范式,许多开发者都在使用:函数式编程。在下一章中,我们将学习很多关于函数式编程的知识,包括何时以及如何使用它。

第十四章:14. 理解函数式编程

概述

到本章结束时,你将能够使用纯函数、不可变性、组合和柯里化等函数式编程概念;使用如 filter、map 和 reduce 的高级函数;应用如克隆对象等技术以减少代码中的副作用;并展示减少代码中命令式逻辑和 for 循环的策略。

简介

在上一章中,我们讨论了 JavaScript 是一种多范式编程语言。可以编写具有过程式、面向对象和函数式设计模式的代码。在本章中,我们将仔细研究函数式编程设计模式。

函数式编程是一种在最近几年变得流行的编程范式,尽管在此之前,大多数 JavaScript 开发者对它并不熟悉。

JavaScript 并不像 Haskell、Scheme 和 Clojure 那样是一种纯粹的函数式语言。然而,如果你选择使用,JavaScript 支持函数式结构和技巧。熟悉其概念并掌握如何使用它们是值得的。

函数式编程有一系列特性。其中,以下是一些重要的特性:

  • 纯函数

  • 不可变性和避免共享状态、可变数据和副作用

  • 声明式而非命令式

  • 高阶函数

  • 函数组合和管道

  • 柯里化函数

  • 减少了使用传统的流程控制结构,如 forwhile,甚至 if

这些概念将在本章中介绍。如果正确实现,函数式编程可以产生比其他编程方法更可预测、更少错误、更容易测试的代码。

纯函数

纯函数是函数式编程的支柱之一。一个函数是纯的,如果它总是对相同的参数返回相同的结果。它也不能依赖于或修改函数作用域之外的变量或状态。

一个不纯函数的简单例子如下:

var positionX = 10;
function moveRight(numSlots) {
    positionX += numSlots;
}
moveRight(5);

你可以清楚地看到函数如何在其作用域之外操作一个值,即 positionX 全局变量。一个纯函数应该只使用传递给它的参数进行逻辑,而不应该直接修改它们。另一个问题是该函数实际上没有返回值。

考虑以下代码。你能看出为什么它不被认为是纯函数吗?

var positionX = 10;
function moveRight(numSlots) {
    return positionX + numSlots;
}
positionX = moveRight(5);

虽然该函数只读取全局变量的值,并没有直接操作该变量,但它仍然不是纯的。为了理解原因,考虑如果你多次以 numSlots 参数的值 5 调用该函数会发生什么:

  • 第一次,结果是 15(因为 positionX1010 + 5 = 15

  • 第二次,结果将是 20

  • 第三次,结果将是 25

换句话说,每次调用都会得到不同的结果。为了使函数纯净,结果必须对于给定的参数值解析为完全相同的值,即5。同时,考虑一下编写此函数测试的难度,因为结果是不可预测的。

使此函数纯净的正确方法如下:

var positionX = 10;
function moveRight(x, numSlots) {
    return x + numSlots;
}
positionX = moveRight(positionX, 5);

在这个版本中,函数在逻辑中使用的所有数据都作为参数传递,它不会引用函数作用域之外的数据。它也将对于给定参数集始终产生相同的结果:

  • 如果x=10numSlots=5,结果将始终是15

  • 如果x=15numSlots=5,结果将始终是20

  • 如果x=20numSlots=5,结果将始终是25

结果的可预测性使得代码质量更高,更容易对函数进行推理,也更容易编写测试。它还使得代码易于维护且风险较低,如果函数需要重构。

副作用

函数式编程中的一个重要概念是与纯净函数密切相关的是减少副作用。副作用是指函数执行某些操作,无论是直接还是间接的,这些操作并非严格为了函数或其返回值的目的。

副作用的例子包括显示警告框、写入文件、在网络上触发服务调用或更改 DOM 的操作。(实际上,当我们在上一个章节的纯函数示例中操作全局变量时,我们也在创建一种称为共享状态的副作用。)

注意

完全没有副作用的程序是不可能的,也是不希望的。毕竟,如果你不能以某种方式看到输出,这个程序有什么用呢?然而,函数式程序员大多数时候的目标是创建纯净函数,并隔离需要输出或副作用的函数和代码部分。保持此类代码的分离有助于你更好地理解软件,以便进行调试、创建更好的测试,以及简化未来的维护和扩展。

不可变性

函数式编程中的另一个概念是尽可能优先选择不可变值和对象而不是可变对象。简而言之,不可变对象是指一旦创建后其值就不能改变的值,即使这些对象被使用。接下来,我们将进行一些练习,以展示某些对象,如字符串和数字是不可变的,而数组不是。我们将从以下练习开始字符串的不可变性。

练习 14.01:不可变值和对象 – 字符串

在这个练习中,我们将展示字符串是如何不可变的。让我们开始吧:

  1. 在 Google Chrome 浏览器中,转到“开发者工具”(在屏幕右上角带有三个点的菜单中选择“更多工具”|“开发者工具”,或者直接按F12键)。

  2. JavaScript 有几个内置的不可变对象,例如字符串。创建两个常量 string1string2,并将变量赋值,使得 string2string1 的子字符串:

    const string1 = "Hello, World!";
    const string2 = string1.substring(7, 12);
    
  3. 显示两个字符串。在控制台中输入以下内容:

    console.log(`string1: ${string1}`);
    console.log(`string2: ${string2}`);
    
  4. 此代码产生以下输出:

图 14.1:字符串的输出

图 14.1:字符串的输出

从这个例子中,你可以看到对 string1 执行 substring() 操作并没有以任何方式改变 string1 的值,这证明了字符串是不可变的。实际上,它产生了一个由给定索引之间的部分字符串字符组成的新字符串。然后,这个结果被设置为 string2 变量的值。

练习 14.02:不可变值和对象 – 数字

原始类型,如数字,也是不可变的。在这个练习中,我们将对数字执行操作以演示数字的可变性。

  1. 创建两个常量 number1number2,并赋予它们数值,使得 number2number1 值的一半:

    const number1 = 500;
    const number2 = number1 / 2;
    
  2. 在控制台中显示两个数字对象。输入以下内容:

    console.log(`number1: ${number1}`);
    console.log(`number2: ${number2}`);
    
  3. 此代码产生以下输出:

图 14.2:数字的输出

图 14.2:数字的输出

我们可以看到,对 number1 进行计算并将结果设置到新变量中不会影响原始变量。

练习 14.03:可变性 – 数组

到目前为止,我们已经探讨了不可变对象。从现在开始,我们将查看不具有这种不可变性的对象示例。在这个练习中,我们将创建一个数组并将其值赋给另一个数组,然后我们将修改其值以演示数组是如何可变的。

  1. 创建并定义 array1,使其具有三个值元素,即 'one''two''three'

    const array1 = ['one', 'two', 'three'];
    
  2. 创建另一个数组 array2,其值等于 array1

    const array2 = array1;
    
  3. 现在,向 array2 添加另一个元素 'four'

    array2.push('four');
    
  4. 在控制台中显示两个输出,如下所示:

    console.log(`array1: ${array1}`);
    console.log(`array2: ${array2}`);
    

此代码产生以下输出:

图 14.3:数组的输出

图 14.3:数组的输出

在这里,我们将 array2 变量赋值给与 array1 相同的数组,然后向 array2 添加另一个元素(值 'four')。你可能感到惊讶,array1 也会受到影响,并添加了相同的元素,这与之前的例子不同。这是因为当对 array2 进行赋值时,它并没有创建一个新的数组。相反,它只分配了一个指向原始数组的引用,即 array1。操纵任一数组都会影响这两个变量,因为它们实际上是同一个数组。

练习 14.04:可变性 – 对象

在这个练习中,我们将向对象属性赋值以演示对象的可变性。

  1. 创建一个具有 nameshow 属性的对象 actor1。将这些属性赋值为 SheldonBB Theory

    const actor1 = {
        name: 'Sheldon',
        show: 'BB Theory'
    };
    
  2. 现在,创建另一个变量,actor2,并将其赋值给与actor1相同的对象。然后,也为actor2添加一个名为name的新属性

    const actor2 = actor1;
    actor2.name = 'Leonard';
    
  3. 在控制台中输入以下内容:

    console.log("actor1:", actor1);
    console.log("actor2:", actor2);
    
  4. 这段代码的结果如下:

![图 14.4:对象的输出

![图 14.4:对象的输出

图 14.4:对象的输出

如你所见,actor1actor2变量中的对象最终都是完全相同的。name属性不仅存在于actor2中,正如你可能预期的。这又是由于actor2只是对actor1的引用,而不是它自己的对象。

另一个值得注意的点。在所有这些例子中,变量都是使用const关键字定义为常量的。然而,正如我们在最后两个例子中看到的,我们能够修改对象,并且编译器没有报错。这表明const关键字并不等同于说值是不可变的!

const实际上意味着编译器阻止你将变量重新赋值给一个新的对象。但它并不限制你改变分配对象的属性或添加数组元素。

下一个部分将展示一些关于如何有效处理可变对象的方法。

克隆对象和数组

在上一个练习中,你看到了数组和对象是可变的。那么,如果你需要修改它们怎么办?你如何以安全的方式修改,避免副作用?

首先,有一个简单的数组技术。如果你只是向数组中添加一个元素,你可以使用Array.prototype.concat而不是Array.prototype.push。区别在于concat返回一个包含新元素的新数组副本,而push则修改原始数组。

我们可以在以下代码中看到这一点。在这里,array1array2现在实际上是不同的对象:

const array1 = ['one', 'two', 'three'];
const array2 = array1.concat('four');
console.log(`array1: ${array1}`);   // output: array1: one,two,three
console.log(`array2: ${array2}`);   // output: array2: one,two,three,four

上述代码的输出如下:

array1: one,two,three
and
array2: one,two,three,four

对于其他数组的修改或操作对象,你通常需要克隆数组或对象,并在克隆上进行操作。你可能会问,如何制作克隆?这里有一个小技巧:在较新的 JavaScript 版本中(自 ECMAScript 2018 以来),展开语法对数组和对象都有效。使用展开语法,你可以做以下操作:

// Arrays
const array1 = ['one', 'two', 'three'];
const array2 = [...array1];
array2[0] = 'four';
console.log(`array1: ${array1}`);   // output: array1: one,two,three
console.log(`array2: ${array2}`);   // output: array2: four,two,three
// Objects
const actor1 = {
    name: 'Sheldon',
    show: 'BB Theory'
};

const actor2 = {...actor1};
actor2.name = 'Leonard';
//the output for variable actor1 will be displayed.       
console.log("actor1:", actor1);   

const actor1的输出如下:

    // output: actor1: { name: "Sheldon", show: "BB Theory" }
//the output for variable actor2 will be displayed.
console.log("actor2:", actor2);

const actor2的输出如下:

    // output: actor2: { name: "Leonard", show: "BB Theory" }

注意到在[...array1]{...actor1}中有三个连续的点。这些点被称为展开运算符。以这种方式使用展开语法实际上会克隆数组,或者对象中的键值对。

虽然如此,有一个需要注意的地方。这种方法只进行浅拷贝,这意味着只有顶层元素或属性被复制。在顶层之上,只创建引用。这意味着,例如,多维数组或嵌套对象不会被复制。

如果需要深拷贝,一个流行的方法是将对象转换为 JSON 字符串,然后再将其解析回来,类似于以下代码。这对对象和数组都有效:

let object2 = JSON.parse(JSON.stringify(object1));

深拷贝方法还有一个额外的优点,即它可以在较旧的 JavaScript 版本上工作。

示例数据和练习样本

在我们继续之前,我们需要介绍一个带有样本数据的场景。在接下来的章节中,以下数据将在示例和练习中使用:

const runners = [
    {name: "Courtney", gender: "F", age: 21, timeSeconds: 1505},
    {name: "Lelisa",   gender: "M", age: 24, timeSeconds: 1370},
    {name: "Anthony",  gender: "M", age: 32, timeSeconds: 1538},
    {name: "Halina",   gender: "F", age: 33, timeSeconds: 1576},
    {name: "Nilani ",  gender: "F", age: 27, timeSeconds: 1601},
    {name: "Laferne",  gender: "F", age: 35, timeSeconds: 1572},
    {name: "Jerome",   gender: "M", age: 22, timeSeconds: 1384},
    {name: "Yipeng",   gender: "M", age: 29, timeSeconds: 1347},
    {name: "Jyothi",   gender: "F", age: 39, timeSeconds: 1462},
    {name: "Chetan",   gender: "M", age: 36, timeSeconds: 1597},
    {name: "Giuseppe", gender: "M", age: 38, timeSeconds: 1570},
    {name: "Oksana",   gender: "F", age: 23, timeSeconds: 1617}
];

这是一个表示 5 公里赛跑跑步者结果的数组对象。每个跑步者的姓名、性别、年龄和时间都在对象字段中指示。时间以秒为单位记录,便于进行分钟/秒和配速计算。

我们还将定义三个辅助函数来显示数据。它们将使用一些你可能还不熟悉的概念,特别是箭头函数表示法和 Array.prototype.map 方法。但别担心——这些概念将在接下来的章节中介绍,并且很快就会变得清晰。

我们第一个辅助函数的目的是将秒数格式化为 MM:SS

   const minsSecs = timeSeconds =>
       Math.floor(timeSeconds / 60) + ":" +
       Math.round(timeSeconds % 60).toString().padStart(2, '0');

让我们详细理解一下代码:

  • minsSecs 变量定义了一个带有 timeSeconds 输入参数的箭头函数。

  • 对于分钟部分,Math.floor() 方法在将秒数除以 60 时移除了小数部分,从而得到一个整数。

  • 对于秒数部分,Math.round() 方法返回四舍五入到最接近整数的数字。(注意,我们只想四舍五入小数秒。对于分钟部分,四舍五入是不正确的。)

  • String.prototype.padStart 方法在值小于 10 时,会在秒值前面填充一个前导 0。秒数本身是通过使用取余运算符 % 计算的,它返回除法中的任何余数值。

我们的第二个辅助函数创建一个字符串,以自定义格式打印 runner 对象的字段:

        const printRunner = runner =>
            [`Name: ${runner.name}`,
             `gender: ${runner.gender}`,
             `age: ${runner.age}`,
             `time: ${minsSecs(runner.timeSeconds)}`
            ].join('\t');

让我们详细理解一下代码:

  • 再次使用箭头函数语法。该函数名为 printRunner 并有一个 runner 输入参数。

  • 创建了一个格式化字符串数组,每个字段对应一个 runner 对象。

  • 最后,通过调用 Array.prototype.join('\t') 将所有字符串元素使用制表符分隔符连接在一起,当打印时将形成整齐的列。

最后一个辅助函数打印所有跑步者:

        const printRunners = (runners, listType) =>
            `List of ${listType} (total ${runners.length}):\n` +
                runners.map(printRunner).join('\n');

让我们详细地分析上述代码的不同部分:

  • 该函数名为 printRunners 并接受两个参数:一个 runners 数组和一个 listType,它描述了正在打印的列表类型。它返回一个字符串。

  • Array.prototype.map 用于形成打印的跑步者详细信息。

  • 简而言之,Array.prototype.map 方法遍历数组的每个元素,对它们执行回调函数,并生成一个包含每个元素转换值的新数组。我们将在稍后详细解释这是如何工作的。

  • 但目前,这里的Array.prototype.map调用会在每个数组元素上调用之前指定的printRunner函数,以获取格式化的字符串。由于printRunner函数只接受一个参数,在这种情况下,没有必要显式指定参数,因为它已经隐含了。

  • 然后通过调用Array.prototype.join('\n')将字符串与换行符连接起来。

要将所有跑者打印到控制台,可以这样调用:

    console.log(printRunners(runners, "all runners"));

输出将如下所示:

图 14.5:控制台所有跑者的示例输出

图 14.5:控制台所有跑者的示例输出

高阶函数

JavaScript 中的函数是一等公民。这意味着它们可以作为参数值传递给其他函数,甚至可以分配给一个变量。这是使 JavaScript 非常适合函数式编程风格的主要特征之一。

高阶函数是操作其他函数的函数。它们可以通过以下三种方式之一来实现:

  • 如果函数接受另一个函数作为参数

  • 如果函数返回另一个函数作为其结果

  • 在这两种方式中

在前面的章节中,我们已经看到了几个高阶函数,可能你甚至没有意识到。记得响应 DOM 事件的回调函数,或者在第十章,访问外部资源中的回调函数,一旦 AJAX 响应就绪就会被调用?这些都是高阶函数的例子,因为这些函数是传递给其他函数的参数。

以下部分将介绍在函数式编程中常用到的三个高阶函数:Array.prototype.filterArray.prototype.mapArray.prototype.reduce

Array.prototype.filter 方法

我们将要查看的第一个函数是Array.prototype.filter方法,它很简单。给定一个现有数组,filter()会创建一个新数组,其中包含符合指定标准的元素。

语法如下:

var newArray = array.filter(function(item) {
  return condition;
});

回调函数会依次对数组的每个元素进行调用。如果条件通过且函数返回true,则该元素将被添加到新数组中。如果函数返回false,则该元素将被跳过,不会添加到数组中。

注意,返回值是一个新数组。原始数组完全不受此操作的影响。换句话说,如果项目不通过条件,则不会从原始数组中过滤并删除项目。相反,会创建一个新数组,其中包含通过测试的元素。

创建新数组而不是修改现有数组的原因是由于你之前学到的函数式编程的基本原则:不可变性和避免副作用。

我们将在下一节中查看Array.prototype.filter的使用示例。

复习

然而,在我们查看这些示例之前,我们审慎地退一步回顾基本的 JavaScript 函数语法和箭头函数表示法是明智的。这将确保你对即将到来的内容有一个良好的基础。我们将通过向您展示不同的方式来指定Array.prototype.filter的过滤函数来完成这项回顾。

假设我们想要过滤本章前面提到的跑步者数组,只保留女性跑步者。最直接的过滤函数看起来像这样:

function femaleFilter(runner) {
    if (runner.gender === "F") {
        return true;
    }
    return false;
}

这个过滤函数将从另一个函数中调用,该函数实际上使用以下代码调用filter()

const getFemaleRunners = runners => runners.filter(femaleFilter);

为了使函数独立,它将runners数组作为参数。要求runners为全局变量并不是一个好的实践。

注意,我们只传递过滤函数的名称femaleFilter作为参数,而不是像femaleFilter()那样带有括号。我们不希望函数立即执行,如果有括号就会发生这种情况。相反,当不带括号通过名称传递函数时,你实际上是在传递函数对象本身。filter方法是一个高阶函数,它接受一个回调函数作为输入,这需要实际的函数对象。

可以使用以下代码显示此过滤的结果:

console.log(
    printRunners(getFemaleRunners(runners), "female runners"));
// output:
// → List of female runners (total 6):
// → Name: Courtney  gender: F     age: 21   time: 25:05
// → Name: Halina    gender: F     age: 33   time: 26:16
// → Name: Nilani    gender: F     age: 27   time: 26:41
// → Name: Laferne   gender: F     age: 35   time: 26:12
// → Name: Jyothi    gender: F     age: 38   time: 24:22
// → Name: Oksana    gender: F     age: 23   time: 26:57

注意

应使用此代码显示以下示例的结果。每个示例都应期望得到相同的结果。

我们做得相当不错,但我们可以做得更好。作为替代方案,过滤函数可以直接内联指定:

const getFemaleRunners = runners => runners.filter(
    function(runner) {
        if (runner.gender === "F") {
            return true;
        }
        return false;
    }
);

如果我们将过滤测试改为布尔表达式,而不是在if语句中显式返回truefalse,我们可以进一步简化这一点:

const getFemaleRunners = runners => runners.filter(
    function(runner) {
        return runner.gender === "F";
    }
);

在 JavaScript 的新版本中,自 ES6 以来,这个函数也可以使用箭头函数表达式更简洁地表示:

const getFemaleRunners = runners => runners.filter(runner => {
    return runner.gender === "F";
});

最后,请注意,这个函数只有一个参数,并且其主体中只有一个return语句。这使得我们可以用以下单行代码使代码更加简洁,该代码省略了开/闭括号和return关键字:

const getFemaleRunners = runners =>
    runners.filter(runner => runner.gender === "F");

如果需要,过滤函数也可以拆分成它自己的函数并存储在一个变量中,因为函数在 JavaScript 中是一等对象:

const femaleFilter = runner => runner.gender === "F";
const getFemaleRunners = runners => runners.filter(femaleFilter);

消除 for 循环

Array.prototype.filter函数是强大函数式编程技术的绝佳演示,这些技术用于消除循环代码,特别是for循环。为了感受传统for循环的潜在陷阱,考虑过滤女性跑步者的等效命令式代码:

var femaleRunners = [];
for (var i = 0; i < runners.length; i++) {
    if (runners[i].gender == "F") {
        femaleRunners.push(runners[i]);
    }
}

将其与我们在上一节中看到的单行代码进行比较,它做的是同样的事情:

const femaleRunners = runners.filter(runner => runner.gender === "F");

强制性循环代码需要使用循环变量i。这会将状态突变引入我们的代码中,并可能成为错误的一个来源。尽管在这种情况下,它是一个局部状态,但在所有可能的情况下最好避免使用状态。在未来的某个时刻,存在一个风险,即变量会因未知原因而改变,从而产生难以调试的问题。

使用函数式等价物,可以更直观地看到代码做了什么,更容易测试,并且有更多潜在的复用机会。它没有缩进,没有循环,代码更加简洁和表达性强。

这也展示了函数式代码通常是声明性的,而不是命令性的。它指定了“做什么”(声明性),而不是“如何做”的步骤和流程(命令性)。在这个例子中,函数式代码只是简单地说明了,“过滤掉runners参数中性别为女性的数组元素”。与此相比,命令性代码需要多个变量、语句、循环等,它描述的是“如何做”而不是“做什么”。

在接下来的章节中,我们将探讨其他消除循环的数组方法,例如Array.prototype.mapArray.prototype.reduce

Array.prototype.map方法

当你想转换数组元素时,会使用map()数组方法。它将一个函数应用于调用数组的每个元素,并构建一个由返回值组成的新数组。新数组将与输入数组具有相同的长度,但每个元素的内部内容将被转换(映射)成其他内容。

假设你想计算 5 公里比赛中每位跑步者的平均配速。我们的数据集提供了一个timeSeconds字段,这是跑步者完成全程所需的总秒数。5 公里中也有 3.1 英里。因此,要得到每英里的配速,你需要将秒数除以 3.1。

我们可以使用以下代码计算所有跑步者的配速:

const getPaces = runners => runners.map(runner => runner.timeSeconds / 3.1);
const paces = getPaces(runners);

这段代码生成一个新数组,其元素具有与输入数组中相同索引的对应跑步者的pace值。换句话说,paces[0]的值对应于runner[0]中的跑步者,paces[1]的值对应于runner[1]中的跑步者,依此类推。

可以按照以下方式将配速结果打印到控制台:

paces.forEach(pace => console.log(minsSecs(pace)));
// output:
// → 8:05
// → 7:22
// → 8:16
// → 8:27
// ...

练习 14.05:另一种使用 Array.prototype.map 的方法

关于映射到单值元素的数组的结果在前一节中是有用的,对于某些上下文来说,例如如果你打算随后计算值的总和或平均值。当你只需要原始数字且上下文不重要时,这是可以的。但如果你需要每个元素更多的值或上下文,比如达到特定配速的跑者的名字呢?这个练习展示了另一种我们可以使用 Array.prototype.map 来使用原始数据集实现不同结果的方法;例如,获取每个跑者的计算配速。

  1. 在 Google Chrome 浏览器中,转到 开发者工具(点击屏幕右上角的三个点菜单 | 更多工具 | 开发者工具,或者直接按 F12 键):![图 14.6:Google Chrome 浏览器中的开发者工具 图片

    图 14.6:Google Chrome 浏览器中的开发者工具

  2. 在控制台粘贴本章 示例数据 部分的样本跑者数据(以 const runners = [...] 开头):

    const runners = [
        {name: "Courtney", gender: "F", age: 21, timeSeconds: 1505},
        {name: "Lelisa",   gender: "M", age: 24, timeSeconds: 1370},
        {name: "Anthony",  gender: "M", age: 32, timeSeconds: 1538},
        {name: "Halina",   gender: "F", age: 33, timeSeconds: 1576},
        {name: "Nilani ",  gender: "F", age: 27, timeSeconds: 1601},
        {name: "Laferne",  gender: "F", age: 35, timeSeconds: 1572},
        {name: "Jerome",   gender: "M", age: 22, timeSeconds: 1384},
        {name: "Yipeng",   gender: "M", age: 29, timeSeconds: 1347},
        {name: "Jyothi",   gender: "F", age: 39, timeSeconds: 1462},
        {name: "Chetan",   gender: "M", age: 36, timeSeconds: 1597},
        {name: "Giuseppe", gender: "M", age: 38, timeSeconds: 1570},
        {name: "Oksana",   gender: "F", age: 23, timeSeconds: 1617}
    ];
    
  3. 在控制台粘贴 minsSecs() 辅助函数的代码,也来自本章的 示例数据 部分:

    const minsSecs = timeSeconds =>
                Math.floor(timeSeconds / 60) + ":" + 
                Math.round(timeSeconds % 60).toString().padStart(2, '0');
    
  4. 将以下代码输入到控制台:

    const getPacesWithNames = runners => runners.map(runner =>
        ({name: runner.name, pace: runner.timeSeconds / 3.1}));
    const pacesWithNames = getPacesWithNames(runners);
    

    此代码展示了向数组元素添加上下文的简单方法:而不是从映射函数中返回单个值,可以返回一个包含所需字段的对象。在这种情况下,对象为每个数组元素包含 namepace 字段。

  5. 我们可以通过以下代码查看输出:

    // print each value
    pacesWithNames.forEach(paceObj =>
        console.log(`name: ${paceObj.name}\tpace: ${minsSecs(paceObj.pace)}`));
    

    执行前面的命令后,你的控制台日志应该看起来像下面的截图所示。注意底部的姓名和配速列表:

    ![图 14.7:姓名和配速字段的输出 图片

    图 14.7:姓名和配速字段的输出

    你会注意到我们有了原始数据中的所有相同的跑者,但没有性别、年龄或秒数。我们还添加了一个新的值 pace,这是通过 getPacesWithNames 函数创建的。如果你想让你的数组包含所有原始字段并附加一个额外的 pace 字段怎么办?

  6. 我们可以使用你之前学到的扩展运算符。在控制台输入以下内容:

    const addPacesToRunners = runners => runners.map(runner =>
        ({...runner, pace: runner.timeSeconds / 3.1}));
    
  7. ...runner 扩展运算符有效地克隆了对象中的所有键值对,将它们添加到新的映射值中,并显示输出。将 addPacesToRunners 函数添加并运行到你的控制台。

    注意

    将会复制字段。和之前一样,我们不想仅仅修改原始对象以便添加新字段,因为这可能会产生副作用。

  8. 以下代码运行函数并在控制台显示结果:

    const pacesWithAllFields = addPacesToRunners(runners);
    pacesWithAllFields.forEach(paceObj => console.log(paceObj));
    

一旦运行 forEach() 函数来遍历 pacesWithAllFields 的元素,您应该得到一个包含所有原始数据的跑者列表,但除此之外,还将有一个新的平均配速字段:

图 14.8:添加配速字段后的 addPacesToRunners 结果

图 14.8:添加配速字段后的 addPacesToRunners 结果

注意

如果您预计代码将在较旧的浏览器中运行,请不要使用展开技术。使用 Object.assign() 等替代方案来克隆您的字段。以下是 addPacesToRunners 在较旧环境中编码的方式:

const addPacesToRunners = runners => runners.map(runner =>

    Object.assign({}, runner, {pace: runner.timeSeconds / 3.1}));

或者,像 Babel 这样的转译器支持在较旧的浏览器中扩展语法。

在这个练习中,我们探讨了使用 Array.prototype.map 方法以及如何使用函数式编程设计模式来组合函数以创建复杂的结果。我们结合了 addPacesToRunnersminsSecspacesWithNames 来打印每个跑者的配速,以及原始数据集中的数据。重要的是,我们在不修改原始数据集的情况下添加了额外的配速数据值。因此,使用本练习中的技术可以在映射值时保留上下文。

在下一节中,我们将学习另一个数组方法 reduce,它允许我们从数组中取出一组值并将它们计算成一个单一值。

Array.prototype.reduce 方法

map() 类似,数组 reduce() 方法作用于数组的每个元素。当您需要从它们计算出一个单一值时使用它。

一个简单的例子是如果您需要一组数字的总和:

const sum = [2, 4, 6, 8, 10].reduce((total, current) => total + current, 0);
console.log(sum);

前一个函数的输出将如下所示:

// output:
// → 10

在这里,reduce() 方法接受两个参数:一个组合函数和一个起始值(在这种情况下为 0)。它会导致组合函数依次与数组的每个元素一起调用,就像在 for 循环中做的那样。对于每次调用,当前元素作为 current 值传递,同时传递到目前为止的 total 值(有时称为累加器)。

第一次调用组合函数时,total 是起始值(0),而 current 是数组中的第一个数字(2)。加法运算,即 total + current,结果是 2

第二次调用组合函数时,total 是上一次调用的结果(2),而 current 是数组中的第二个数字(4)。加法运算,即 total + current,结果是 6

这个过程会重复进行,直到数组中剩余的元素都被处理。以下是一个简单的表格,显示了每次调用的值:

图 14.9:调用值及其结果

图 14.9:调用值及其结果

这里是另一个可视化这个减少过程,可能有助于你更清楚地看到它:

图 14.10:减少过程的描述

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_14_10.jpg)

图 14.10:减少过程的描述

回到使用我们的runners数据集,以下是如何使用reduce()计算所有跑者的平均配速。但首先,回忆一下上一节中使用的map()来计算每个跑者的配速,并将结果返回到一个新数组中的代码:

const getPaces = runners => runners.map(runner => runner.timeSeconds / 3.1);
const paces = getPaces(runners);

我们可以使用这些配速来使用reduce()计算平均值:

const getAvgPace = paces => paces.reduce(
    (total, currentPace) => total + currentPace, 0) / paces.length;
console.log(minsSecs(getAvgPace(paces)));

reduce()函数的输出如下:

// output:
// → 8:08

首先,在reduce()中,我们使用与之前求和数组数字类似的技术计算所有pace值的总和。但有一个额外的步骤。我们不是返回总和,而是在返回结果之前将其除以数组的长度。

练习 14.06:使用 Array.prototype.reduce 进行分组

如果你想计算按性别分组的所有跑者的平均配速呢?我们可以使用reduce()来实现,但这比之前的例子要复杂一些。在这个练习中,我们将实现一种分组方法。

与我们计算直线数字的平均值不同,对于分组平均值,我们需要分两步进行:首先,收集每个性别的总和和计数,然后在第二步中计算平均值。

以下概述了求和和计数步骤的方法:

  • 使用一个空对象({})作为我们的起始值。

  • 当遍历数组元素时,获取当前元素性别的到目前为止的组sumcount统计数据。(如果还没有性别统计数据,则创建一个空的组,并将sumcount设置为0。)

  • 将当前元素的配速加到组总和中。

  • 将组的计数增加1

这里是完成此操作的步骤:

  1. 在 Google Chrome 浏览器中,前往开发者工具(点击屏幕右上角的三个点菜单 | 更多工具 | 开发者工具,或者直接按F12键)。

  2. 在控制台中粘贴来自本章“示例数据”部分的样本跑者数据(以const runners = [...]开始)。

  3. 在控制台中粘贴来自本章“示例数据”部分的minsSecs()辅助函数的代码。

  4. 我们将使用来自“Array.prototype.map()”部分的pacesWithAllFields值,该值创建了一个新数组,并为每个元素添加了一个计算出的pace字段。在控制台中输入以下内容:

    const addPacesToRunners = runners => runners.map(runner =>
        ({...runner, pace: runner.timeSeconds / 3.1}));
    const pacesWithAllFields = addPacesToRunners(runners);
    
  5. 以下是我们之前概述的第一个求和和计数步骤的代码:

    const groupSumPaceByGender = runners => runners.reduce((groups, runner) => {
        const gender = runner.gender;
        groups[gender] = groups[gender] || {pace: 0, count: 0};
        groups[gender].pace += runner.pace;
        groups[gender].count += 1;
        return groups;
    }, {});
    const sumPacesByGender = groupSumPaceByGender(pacesWithAllFields);
    
  6. 到目前为止,sumPacesByGender函数返回的对象将有两个键,代表性别值,即"M"和"F"。每个键的值也是一个对象,其中包含pacecount字段,它们包含与键对应的性别的计算统计数据。

  7. 在 JavaScript 控制台中显示这样的对象有点笨拙且难以操作。我们需要一个技巧:我们将对象转换为格式化的 JSON 文本并显示它。在控制台中输入以下代码:

    console.log(JSON.stringify(sumPacesByGender,null,4));
    

    这将输出带有 4 个空格缩进的 JSON:

    // output:
    // → { 
    // →     "F": { 
    // →         "pace": 3010.645161290322, 
    // →         "count": 6 
    // →     },
    // →     "M": { 
    // →         "pace": 2840.6451612903224, 
    // →         "count": 6 
    // →     }
    // → }
    
  8. 现在我们已经确定了每个组的总和和计数,我们可以进行第二步,计算每个组的平均值。我们可以通过使用 Object.keys() 获取一个包含对象键的数组(这些键具有值"M"和"F"),然后使用一个函数调用 Array.prototype.map() 来计算每个性别的平均值。在控制台中输入以下内容:

    const calcAvgPaceByGender = sumPacesByGender =>
        Object.keys(sumPacesByGender).map(gender => {
            const group = sumPacesByGender[gender];
            return {gender: gender, avgPace: group.pace / group.count};
        }
    );
    const avgPaceByGender = calcAvgPaceByGender(sumPacesByGender);
    
  9. 让我们编写代码来显示输出:

    console.log("Average pace by gender:");
    avgPaceByGender.forEach(entry => console.log(
        `gender: ${entry.gender}  average pace: ${minsSecs(entry.avgPace)}`));
    
  10. 输出应显示如下:

图 14.11:使用 Array.prototype.reduce 对性别配速进行分组

图 14.11:使用 Array.prototype.reduce 对性别配速进行分组

图 14.11:使用 Array.prototype.reduce 对性别配速的结果进行分组

这个输出使我们能够以有效的方式将大量的数据点“减少”为更少的结果。

在这个练习中,我们探讨了使用 Array.prototype.reduce 方法进行分组。与之前的练习一样,我们通过组合几个函数来创建一个更复杂的结果,而没有修改原始数据集。首先,我们使用 addPacesToRunners 为集合中的每个条目添加了 pace 值,然后我们使用 groupSumPaceByGender 为每个性别创建了一个分组总和,最后,我们使用 calcAvgPaceByGender 来获取比赛中男性和女性平均配速的值。

在下一节中,我们将讨论组合的概念。在本章中,我们已经多次使用了组合,即每次我们通过组合较小的函数来创建一个更大的过程。然而,我们还没有具体探讨这个概念,也没有讨论它在函数式范式中的重要性。我们还将查看 pipe()compose() 函数,这些函数使得以这种方式组合函数变得更加容易和可读。

使用 compose()pipe() 进行组合

在之前的练习中,我们看到从跑步者数组开始,我们需要三个不同的函数来计算每个性别的平均配速:

  • addPacesToRunners: 这用于计算每英里的配速。

  • groupSumPaceByGender: 这用于计算每个性别的配速总和。

  • calcAvgPaceByGender: 这用于计算每个性别的平均配速。

每个函数都需要前一个函数的结果作为输入来完成其工作。基本上,它做了以下事情,尽管这一点可能直到现在还不明显:

const result1 = addPacesToRunners(runners);
const result2 = groupSumPaceByGender(result1);
const avg = calcAvgPaceByGender(result2);

这等价于以下,即使用嵌套函数并移除中间变量:

const avg =
    calcAvgPaceByGender(groupSumPaceByGender(addPacesToRunners(runners)));

这是组合的概念:多个简单的函数组合起来构建一个更复杂的函数。每个函数的结果都传递给下一个函数。

我们可以创建称为composepipe的高阶函数,以更通用的方式实现函数组合。暂时不考虑实际的实现,让我们看看函数将如何被使用。使用compose,前面的嵌套函数可以这样编写:

const avgWithComposition =
    compose(calcAvgPaceByGender, groupSumPaceByGender, addPacesToRunners);

这个函数可以这样使用:

const avgResult = avgWithComposition(runners);
avgResult.forEach(entry => console.log(
    `gender: ${entry.gender}  average pace: ${minsSecs(entry.avgPace)}`));

函数的输出如下:

// output:
// → gender: F average pace: 8:22
// → gender: M average pace: 7:53

注意,可能有些反直觉,compose中的函数实际上是按照与参数列表中给出的相反的顺序调用的,即从右到左。因此,addPacesToRunners方法首先使用runners参数被调用(即使它是给定列表中的最后一个函数),然后结果传递给groupSumPaceByGender,最后这些结果传递给calcAvgPaceByGender

许多人发现这种函数调用顺序不自然,尽管它与我们在上面嵌套函数中调用的顺序一致。pipe函数与compose类似,但函数的组合方向相反,是从左到右而不是从右到左。pipe方法更符合线性思维:首先做 A,然后做 B,然后做 C,而做 A、B 和 C 的函数将按此顺序给出。

使用pipe,等效的代码如下:

const avgWithPipe = 
    pipe(addPacesToRunners, groupSumPaceByGender, calcAvgPaceByGender);
const resultPipe = avgWithPipe(runners);
resultPipe.forEach(entry => console.log(
    `gender: ${entry.gender}  average pace: ${minsSecs(entry.avgPace)}`));
// output:
// → gender: F average pace: 8:22
// → gender: M average pace: 7:53

实现compose()pipe()

现在,让我们看看一种实际实现这些函数的方法。实现方式相似,但我们将首先从pipe开始,因为它更容易理解。

当使用Array.prototype.reduce时,实现变得相当直接:

function pipe(...fns) {
    return input => fns.reduce((prev, fn) => fn(prev), input);
}

pipe函数接受一个或多个作为参数传递的函数,这些函数通过扩展运算符转换为函数数组,即...fns。然后,我们对函数数组应用reduce,首先使用input参数作为prev调用第一个函数fn。在下一次调用中,第一个函数的结果(作为prev)被传递并用作调用数组中下一个函数的参数。数组中的其余函数以类似的方式处理,最终函数的结果值被返回。

注意,这个函数可以通过使用full fat-arrow符号稍微简化一下:

const pipe = (...fns) => input => fns.reduce((prev, fn) => fn(prev), input);

关于compose函数,回想一下,它几乎与pipe相同,只是函数的处理顺序是从右到左而不是从左到右。因此,compose的实现基本上也是相同的,但不是使用Array.prototype.reduce这个姐妹函数,而是使用Array.prototype.reduceRightreduceRight函数从数组的末尾开始处理数组,首先操作数组的最后一个元素,然后操作倒数第二个元素,依此类推。

下面是compose函数的实现:

const compose = (...fns) => input =>
    fns.reduceRight((prev, fn) => fn(prev), input);

函数柯里化

柯里化是将一个接受多个参数的函数分解为一个或多个接受单个参数的额外函数,这些函数最终会解析为一个值。初始函数调用并不接受所有参数,而是返回一个函数,该函数的输入是剩余的参数,其输出是所有参数的预期结果。

这段话有点长,让我们来看一个例子。假设你有一个简单的sum函数:

function sum(a, b) {
    return a + b;
}

让我们将这个表达式写成箭头符号中的柯里化函数:

const sum = a => b => a + b;

注意,这里有两层函数,每个函数都接受一个参数。第一个函数接受一个参数,a,然后返回另一个函数,该函数接受第二个参数,b

注意

如果你发现难以理解两个函数层级,这里有一个等效的表达可能有助于理解:

function sum(a) {

return function(b) {

return a + b;

};

};

你也可以用箭头符号来写:

const sum = a => function(b) {
    return a + b;
};

要用多个参数调用这个柯里化的sum函数,你需要使用以下相当笨拙的语法:

let result = sum(3)(5);    // 8

这意味着首先用参数值3调用sum,然后调用返回的函数,用参数5调用。

但通常情况下,你不会这样调用柯里化函数,这正是柯里化的真正用途所在。通常,函数会逐个调用,这允许我们创建中间函数,这些函数“记住”传递给它们的参数。

例如,我们可以创建以下中间函数:

const incrementByOne = sum(1);
const addThree = sum(3);
let result1 = incrementByOne(3); // result1 = 4, equivalent to calling sum(1)(3)
let result2 = addThree(5);       // result2 = 8, equivalent to calling sum(3)(5)

这两个中间函数都记住了它们的参数:incrementByOne保留参数值1(如在sum(1)中),而addThree记住3。这些函数也被称为参数被应用到的函数,但实际结果只有在返回的函数用b参数调用时才知道。(注意,尽管部分应用与柯里化函数并不完全相同,因为部分应用可以保留多个参数,而柯里化函数始终只接受一个参数。)

这些本质上是可以被多次重用的新函数。它们也是composepipe的良好候选者,因为这些函数只有一个参数。

练习 14.07:组合和柯里化函数的更多用途

在这个练习中,你将进一步探索柯里化和组合。最值得注意的是,你将看到如何创建常见函数的柯里化版本,如Array.prototype.mapArray.prototype.filter,以组合其他函数。在函数式编程中,常见函数通常需要重构,以便它们可以作为函数链中处理数据的构建块。

这个练习将再次使用runners数据集。你需要创建一个函数来扫描数据并返回最年长女跑者的年龄。挑战在于使用composepipe的组合来完成这个任务,从而将一个函数的结果传递给下一个函数。

我们需要做的基本轮廓如下:

  • 创建一个仅针对女性跑者过滤数据的函数

  • 创建一个函数来映射这些数据,以获取每个跑者的年龄

  • 创建一个使用 Math.max() 获取最高年龄值的函数

  • 将我们迄今为止创建的函数组合起来,并按顺序调用它们以获得最终结果

以下步骤展示了我们如何详细地进行这项操作:

  1. 在浏览器窗口的右上角打开 Chrome 菜单,然后选择 工具 | 开发者工具

  2. 前往控制台,粘贴本章 示例数据 部分的样本运行数据(以 const runners = [...] 开头)。

  3. 首先,创建一个 Array.prototype.filter 的柯里化版本。在控制台中输入以下内容:

    const filter = fx => arr => arr.filter(fx);
    
  4. 在这里,fx 是过滤函数,而 arr 是要过滤的数组。注意参数的顺序,过滤函数将在数组之前传递。这允许我们将数据处理本身作为最后一步。

  5. filter 类似,您需要创建一个 Array.prototype.map 的柯里化版本。在控制台中输入以下内容:

    const map = fx => arr => arr.map(fx);
    

    在这里,fx 是要调用的函数,用于映射每个数组元素,而 arr 是要映射到其他内容的数组本身。

  6. 下一个需要重构的函数是 Math.max(),它返回传入参数中的最大值。在控制台中输入以下内容:

    const max = arr => Math.max(...arr);
    

    在这里,arr 是要找到最大值的数字数组。默认情况下,Math.max() 不接受数组作为参数。然而,通过使用扩展运算符,即 ...arr,单个数组元素将被作为一系列参数传递给 Math.max(),而不是作为数组。

  7. 输入 compose 函数的实现:

    const compose = (...fns) => input => 
        fns.reduceRight((prev, fn) => fn(prev), input);
    
  8. 您现在可以尝试将这些函数组合在一起了。在控制台中输入以下内容:

    const oldestFemaleRunner1 = compose(
        max,
        map(runner => runner.age),
        filter(runner => runner.gender === "F")
    );
    

    记住,使用 compose 时,操作顺序是从下到上。首先,我们有一个选择女性跑者的过滤函数,使用 runner.gender === "F" 表达式。接下来,我们有一个 map 函数,从之前 filter 函数中解析的女性跑者中 提取 age 属性,并创建一个只包含年龄值的新数组。最后,调用 max 以从这些值中获得最大年龄。

  9. 我们现在已经组合了所有函数,但还没有实际运行数组数据通过它们来获得结果。为此,在控制台中输入以下内容:

    const result1 = oldestFemaleRunner1(runners);
    

    现在打印结果:

    console.log("Result of oldestFemaleRunner1 is ", result1);
    

    您将得到一个输出,说明最年长的女性跑者年龄为 39:

    // → output: Result of oldestFemaleRunner1 is 39
    
  10. 这方法是可行的,但可以对 femaleFilter 部分进行一些轻微的改进。为什么不将其制作成一个可重用的函数呢?我们可以这样做:

    const femaleFilter = filter(runner => runner.gender === "F");
    

    回想一下,filter是一个具有两层参数(fxarr)的柯里化函数。在这里,我们使用filter的第一个参数,fx,这会产生一个部分应用函数。这个femaleFilter函数现在可以在任何上下文中使用,而不仅仅是这里。

    通过将femaleFilter应用于以下组合来测试该函数:

    const oldestFemaleRunner2 = compose(
        max,
        map(runner => runner.age),
        femaleFilter
    );
    const result2 = oldestFemaleRunner2(runners);
    console.log("Result of oldestFemaleRunner2 is ", result2);
    

    使用filter函数时,你会得到一个输出,表明最年长的女性跑者是 39 岁,如下所示:

    // → output: Result of oldestFemaleRunner2 is 39
    
  11. 有些人发现从下到上的处理顺序令人困惑且不直观。幸运的是,我们有pipe函数,它的功能与compose相同,但顺序是从上到下。首先,输入pipe函数本身的实现:

    const pipe = (...fns) => input => fns.reduce((prev, fn) => fn(prev), input);
    
  12. 下面是使用pipe的等效实现:

    const oldestFemaleRunner3 = pipe(
        femaleFilter,
        map(runner => runner.age),
        max
    );
    const result3 = oldestFemaleRunner3(runners);
    console.log("Result of oldestFemaleRunner3 is ", result3);
    
  13. 使用管道函数时,你会得到相同的输出,即表明最年长的女性跑者是 39 岁,如下所示:

    // → output: Result of oldestFemaleRunner3 is 39
    

在这个练习中,我们更详细地研究了组合和柯里化,以及如何将这些结合起来相互补充。我们使用了filter的柯里化版本来传递一个针对跑者性别的过滤器,然后将结果传递给map函数以获取age值,最后使用Math.maxage值的数组中找到最高值。虽然前面的练习涉及了一些将简单函数组合成更复杂过程的部分,但在这次练习中,我们实际上使用了compose来创建一个新的函数,该函数结合了子函数。这使得新函数oldestFemaleRunner1可以被其他人使用,而无需考虑底层子函数。

在下一节中,我们将学习递归函数——这是函数式编程的另一个重要方面,但由于 JavaScript 编程语言缺乏尾调用优化(这在其他函数式编程语言中是存在的),因此在 JavaScript 编程语言中有所限制。

函数递归

函数式编程的另一种技术涉及函数的自我递归调用。这通常意味着你从一个大问题开始,将其分解成多个相同问题的多个实例,但每次函数调用时,问题都变得更小。

递归的一个常见例子是反转字符串字符的函数,reverse(str)。思考一下如何用自身来表述这个问题。假设你有一个字符串,"abcd",并想将其反转成"dcba"。认识到"dcba"可以重新表述如下:

reverse("bcd") + "a"

换句话说,你正在通过移除第一个字符并将剩余字符串的递归调用作为更小的问题来分解输入字符串。以下代码可能更容易理解:

function reverse(str) {
    if (str.length == 1) return str;
    return reverse(str.slice(1)) + str[0];
}
reverse("abcd");   // => output: "dcba"

让我们分解一下:

  • str.length == 1if条件是基本案例。当输入恰好有一个字符时,就没有东西可以反转了,所以解决方案就是字符本身。

  • 否则,使用 String.slice() 并以索引 1 为参数来获取一个新字符串,该字符串不包含输入的第一个字符。将这个字符串用作 reverse() 的递归调用的输入。

  • 返回递归调用的结果,加上字符串的第一个字符(str[0])。

这里是调用步骤的逐步进展:

reverse("abcd")  =>  reverse("bcd") + "a"
reverse("bcd")   =>  reverse("cd") + "b"
reverse("cd")    =>  reverse("d") + "c"
reverse("d")     =>  "d"

重要的是要意识到这些函数调用在内部执行栈上是嵌套的。一旦达到一个字符的基例,递归最终有一个实际的返回值,这导致栈“展开”。当发生这种情况时,最内层的函数返回一个值,然后是它之前的函数,以此类推,直到执行回传到第一个调用。这导致最内层函数的返回值是 "d",然后是 "dc""dcb",最后是我们预期的结果:"dcba"

递归可以作为避免需要状态突变和循环的代码的另一种技术。事实上,几乎任何循环都可以用递归实现,一些纯函数式编程语言更喜欢递归。然而,当前的 JavaScript 引擎并没有针对递归进行优化,这限制了它的用途。编写会导致性能缓慢和内存消耗过度的代码太容易了。(已经提出了可以缓解这些问题的未来增强功能,但在此之前,如果你考虑在程序中使用递归,需要非常小心。)

练习 14.08:使用 reduce() 创建一副扑克牌

我们已经探讨了 JavaScript 中函数式编程的基本元素和一些使用 runner 数据的数据处理示例。但处理数据不必全是数字计算——实际上可以很有趣。以一副扑克牌为例,从某种意义上说,它只是以某种方式有序排列的数据值集合。在这个练习中,我们将通过组合四个函数:suitsrankNamescreateOrderedDeck 来创建一副扑克牌。

  1. 创建一个名为 suits 的函数和另一个名为 rankNames 的函数来描述一副扑克牌的花色和数值。它们不是数组,而是返回数组的函数:

    const suits =
        () => [
            { suit: "hearts", symbol: '&#9829;' },    // symbol: '♥'
            { suit: 'diamonds', symbol: '&#9830;' },  // symbol: '♦'
            { suit: 'spades', symbol: '&#9824;' },    // symbol: '♠'
            { suit: 'clubs', symbol: '&#9827;' }      // symbol: '♣'
        ];
    const rankNames =
        () => ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q','K'];
    
  2. 创建一个名为 ranks 的函数,该函数接受 rankNames 数组作为输入,并返回每个点数映射为键值对。

    const ranks =
        rankNames => rankNames.map(rankName => ({ rank: rankName }));
    
  3. 创建一个名为 createOrderedDeck 的函数,该函数接受 suitsrank 作为输入,并返回所有可能的组合(例如,牌组中的每张牌):

    const createOrderedDeck =
        (suits, ranks) => suits.reduce(
            (deck, suit) => {
                const cards = ranks.map(rank => ({ ...rank, ...suit }));
                return deck.concat(cards);
            }, []);
    

    我们使用 Array.prototype.reduce 并以空数组 [] 作为初始值。然后我们“迭代”suits,并使用 Array.prototype.mapranks 进行操作,通过使用扩展运算符 (...) 来组合花色和点数。然后 Array.prototype.concat() 方法将新牌添加到结果数组中。一旦“嵌套循环”完成,我们就得到了 52 张独特的牌,包含了所有花色和点数的组合。

  4. 接下来,我们将通过从createOrderedDeck的结果以及我们的suitsranks函数创建一个变量来创建一副牌的实例:

    const orderedDeck = createOrderedDeck(suits(), ranks(rankNames()));
    
  5. 为了演示到目前为止所做的工作,打开 Google Chrome 浏览器,转到开发者工具,然后粘贴前面的步骤。完成之后,输入orderedDeck。你应该得到一个类似于以下截图所示的数组。尝试点击一些项目来查看包含的牌:

![图 14.12:使用reduce函数创建的牌堆列表图片

图 14.12:使用reduce函数创建的牌堆列表

在这个练习中,我们回顾了本章 earlier 学习到的reduce函数,并将其应用于创建牌堆的情况。我们将在下一个练习中在此基础上创建一个函数,该函数可以随机洗牌,这对于游戏来说非常有用。

练习 14.09:使用管道方法创建洗牌函数

现在我们有一副有序的牌,我们将看看我们如何可以洗牌。当然,就像所有功能性代码一样,我们将不修改任何现有变量来完成这个操作。

  1. 在与上一个练习相同的控制台中,定义我们之前讨论的pipemap函数。这里我们不会使用compose,但你应该养成在编写功能性代码时为每个程序定义这三个函数的习惯,因为你将大量使用它们:

    const compose =
        (...fns) => input => fns.reduceRight((prev, fn) => fn(prev), input);
    const pipe =
        (...fns) => input => fns.reduce((prev, fn) => fn(prev), input);
    const map = fx => arr => arr.map(fx);
    

    addRandom函数为每个元素添加一个名为random的字段。注意随机数本身是从一个单独的randomizer方法中获得的。这是为了尽可能保持addRandom函数的纯度,并隔离具有副作用代码。

  2. 创建一个randomizer变量,然后创建一个addRandom curry 函数:

    const randomizer =
        Math.random;
    const addRandom =
        randomizer => deck => deck.map(card => ({
            random: randomizer(),
            card
        }));
    
  3. 创建一个sortByRandom函数,该函数可以随机排序输入的牌堆:

    const sortByRandom =
        deck => [...deck].sort((a, b) => a.random - b.random);
    

    此函数根据添加的random字段对牌进行排序。使用spread操作符(...)在排序之前克隆数组,而不是对原始数组进行排序。

  4. 创建一个shuffle函数,它接受一副牌和一个随机化值(如果需要更随机的值,可以在需要时更改随机化值,就像在真正的赌场游戏中那样)。然后我们使用pipe来创建一个函数,该函数结合了addRandom(用于指定我们的随机化器),sortByRandom和一个map函数。最后,我们将执行我们刚刚创建的doShuffle函数,并使用我们的牌堆作为输入:

    const shuffle =
        (deck, randomizer) => {
            const doShuffle = pipe(
                addRandom(randomizer),
                sortByRandom,
                map(card => card.card)
            );
            return doShuffle(deck);
        };
    

    curried map函数的目的是移除之前添加的random字段,仅保留与牌本身相关的原始字段。

  5. 打开之前练习中的 Google Chrome 开发者工具 会话。如果您没有保存,您需要输入之前练习中的代码。到那时,输入本练习中的前四个代码片段。有了这些,执行 shuffle 函数,使用 shuffle(orderedDeck, randomizer),然后通过点击并观察以下截图来探索返回的对象,以查看牌已经被洗好:

图 14.13:使用 reduce 函数的牌组列表

图 14.13:使用 reduce 函数的牌组列表

我们可以看到洗好的牌组是使用 管道和映射 函数。现在我们可以继续使用这些函数来处理黑杰克牌局。

黑杰克

在本章的剩余部分,我们将使用关于函数式编程的知识来实现一个简单的纸牌游戏黑杰克。

然而,与常规黑杰克不同,我们的游戏只有一个玩家。玩家可以抽取任意数量的牌(抽牌),只要总价值不超过 21。

总计是玩家手中牌的价值总和。牌有以下价值:

  • 数字牌有它们的面值(例如,红桃 6 的价值为 6)

  • J、Q 或 K 的价值为 10

  • 为了简化,A 牌的价值为 1(与常规黑杰克不同,在那里它可以是 1 或 11)

如果总价值超过 21,则手牌爆牌,游戏结束。

将牌面值映射到牌上

前两个练习在最终作业中将会非常有用,在最终作业中,你将实现一个黑杰克游戏。你可以直接使用那些代码片段。当然,仅仅知道牌的名字是不够的——你还需要知道每张牌的价值。我们之前探索的 map 函数将在这个任务中非常有用。增强 练习 8:使用 reduce 创建牌组 中的 ranks 柔性函数,将 rankNames 转换为 rankvalue 字段:

const ranks =
    rankNames => rankNames.map(
        (rank, index) => ({ rank, value: Math.min(10, index + 1) }));

此函数利用映射函数中作为可选第二个参数传递的索引。牌面值 "A" 在索引 0,所以值解析为 1(因为公式是 索引 + 1)。牌面值 "2" 在索引 1,所以值解析为 2(因为 索引 + 1 = 2)。同样的规则适用于其他数字,值解析为与数字相同的值。一旦我们到达 "J" 及以上,由于 Math.min(),值解析为 10

现在,输入 orderedDeck 并探索返回的对象。你会注意到现在所有项目都有一个值,面值牌(JQK)的值都是 10:

图 14.14:使用排序函数的牌组有序列表

图 14.14:使用排序函数的牌组有序列表

通过我们现在所涵盖的与卡片相关的函数,即函数式编程的基本原理,例如mapreducecomposepipe,你将为构建自己的卡片游戏打下坚实的基础。

活动 14.01:黑杰克卡片函数

本活动的目的是让你使用所学的函数式编程知识创建一些编写黑杰克游戏所需的函数。你将不会编写整个游戏,只是与卡片逻辑相关的核心函数。

在 GitHub 项目中,你会找到一个预构建的 HTML 文件start.html,其中包含一些 CSS,你应该将其作为起点使用。

本活动的概要步骤如下:

  1. 打开名为blackjack/start.html的起始 HTML/CSS 文件。

  2. 添加或实现使用花色、牌名和值创建一副牌的函数。

  3. 为核心的函数式编程方法编写实现,即pipecomposemap

  4. 添加抽取一张牌、计算玩家牌的总和、检查手牌是否超过 21 点以及检查游戏是否结束(玩家选择停留或爆牌)的函数。

  5. 添加一个更新卡片显示和卡片图像的函数。

  6. 添加一个更新状态显示的函数,告诉用户他们手牌的总和。

  7. 添加用户可以采取的不同操作的playhitstay处理函数。

  8. 最后,添加你可能需要的任何不纯函数,例如通过 ID 或类获取元素的辅助函数。

  9. 添加设置状态和触发游戏的函数。

完成这些步骤后,你现在应该能够打开 HTML 文件并在浏览器中运行游戏,如下面的截图所示:

![图 14.15:黑杰克游戏的截图图片

图 14.15:黑杰克游戏的截图

注意

本活动的解决方案可以在第 758 页找到。

老实说,这个黑杰克的实现并不太好玩,也不会因为视觉设计而获奖。然而,它是一个很好的函数式编程演示。看看你是否可以用这段代码作为基础来实现你自己的完整两人版本的游戏。

管理黑杰克游戏状态

这个游戏只需要少量状态:即玩家的手牌、游戏牌组和玩家是否选择停留(而不是再抽一张牌)。这种状态管理被隔离到以下代码中:

const createState = (dom) => {
    let _state;
    const getState = () => [..._state];
    const setState =
        (hand, gameDeck, stay = false) => {
            _state = [hand, gameDeck];
            updateCardDisplay(dom, hand);
            updateStatusDisplay(dom, hand, stay);
        };
    return { getState, setState };
}

注意最后的return语句。只有两个方法getStatesetState最终暴露给调用者,但_state变量仍然在闭包中保持安全,并作为面向对象编程中“私有”字段的等价物。此外:

  • 为了尽可能隔离产生副作用(side-effects)的代码,有一个单独的参数dom,它引用了实际进行 DOM 操作的其它函数。

  • getState 函数返回状态字段的克隆(使用扩展运算符 ...),而不是字段中的实际值

  • 当调用 setState 时,会调用另外两个函数 updateCardDisplayupdateStatusDisplay(即将介绍)来更新显示的相应部分以对应新的状态。这些函数被设计为每次状态改变时动态重新生成所有与状态值相关的 HTML。这样,在显示逻辑本身中就不需要额外的状态。(流行的 Web 框架如 Angular 和 React 以类似的方式更新显示,尽管为了性能进行了一些优化)。

状态是在游戏开始时创建的:

startGame(createState(dom));

黑杰克游戏逻辑流程

startGame 函数本身注册了三个事件处理函数来响应用户可能点击的三个按钮:“新游戏”、“击中”或“停留”:

const startGame = (state) => {
    byId("playBtn").addEventListener("click", playHandler(randomizer, state));
    byId("hitBtn").addEventListener("click", hitHandler(state));
    byId("stayBtn").addEventListener("click", stayHandler(state));
}

playHandler 函数看起来如下:

const playHandler = (randomizer, { getState, setState }) => () => {
    const orderedDeck = createOrderedDeck(suits(), ranks(rankNames()));
    let gameDeck = shuffle(orderedDeck, randomizer);
    [hand, gameDeck] = draw(gameDeck, 2);
    setState(hand, gameDeck);
};

首先创建并洗牌以创建完整的游戏牌组。然后从游戏牌组中抽取两张牌作为手牌。通过调用 setState 保存手牌和剩余的游戏牌组(减去抽取的两张牌)(这间接也触发了屏幕显示牌)。

hitHandler 函数遵循类似的模式:

const hitHandler = ({ getState, setState }) => () => {
    [hand, gameDeck] = getState();
    [card, gameDeck] = draw(gameDeck, 1);
    setState(hand.concat(card), gameDeck);
};

通过调用 getState 获取当前手牌和游戏牌组。然后从游戏牌组中抽取一张牌。这张牌被添加到手牌中并通过调用 setState 保存(这再次间接触发屏幕显示牌)。

stayHandler 较简单。除了在最后一个参数中调用 setState 并传入 true 以指示玩家已停留外,不进行任何状态修改:

const stayHandler = ({ getState, setState }) => () => {
    [hand, gameDeck] = getState();
    setState(hand, gameDeck, true);
};

黑杰克游戏显示函数

updateCardDisplay 函数如下:

const updateCardDisplay =
    ({ updateHTML }, hand) => {
        const cardHtml = hand.map((card, index) =>
            `<div class="card ${card.suit}"
                style="top: -${index * 120}px;
                       left: ${index * 100}px;">
                <div class="top rank">${card.rank}</div>
                <div class="bigsuit">${card.symbol}</div>
                <div class="bottom rank">${card.rank}</div>
             </div>`);
        updateHTML("cards", cardHtml.join(""));
    };

在这个函数中使用 Array.prototype.map 确定手牌中每张牌的 HTML,并在最后将它们连接成一个字符串。对样式 topleft 的计算利用了映射函数的可选 index 参数,以允许牌有错落的效果。不同的 CSS 类 toprankbigsuitbottom 用于定位和调整牌的不同部分的大小。花色名称本身也是一个 CSS 类,用于应用正确的花色颜色(黑色或红色)。

与显示相关的另一个函数 updateStatusDisplay 的实现如下:

const updateStatusDisplay =
    ({ updateStyle, updateHTML }, hand, stay) => {
        const total = sumCards(hand);
        updateHTML("totalSpan", total);
        const bust = isBust(total);
        const gameover = isGameOver(bust, stay);
        showOrHide(updateStyle, "playBtn", !gameover);
        showOrHide(updateStyle, "hitBtn", gameover);
        showOrHide(updateStyle, "stayBtn", gameover);
        let statusMsg = gameover ?
            "Game over.  Press New Game button to start again." :
            "Select Hit or Stay";
        statusMsg = bust ? "You went bust!!! " + statusMsg : statusMsg;
        updateHTML("statusMsg", statusMsg);
    };

这个函数做了几件事情:

  • 计算牌的总值并显示

  • 通过调用 isBustisGameOver 来确定游戏是否结束。(如果手牌正在游戏中,则“新游戏”按钮不应可见。如果游戏结束或未激活,则“击中”和“停留”按钮不应可见。见图 14.16。)

  • 根据游戏是否结束显示或隐藏不同的按钮

  • 根据游戏是否结束来更改状态信息

![图 14.16:当游戏活跃时,击中和停留按钮可见]

图片

图 14.16:当游戏处于活动状态时,击中和停留按钮是可见的

实际上,这个函数实际上驱动了游戏流程的大部分,因为用户可用的 UI 元素都设置在其中。

黑杰克代码列表

前几节涵盖了代码最重要的部分。游戏的完整代码列表如下所示:

packt.live/370zgaq

为了简单起见,所有代码都包含在一个文件中,包括所有 CSS 样式和 JavaScript 支持函数。然而,在实际应用中,你应该考虑将文件拆分。

摘要

在本章中,你体验了函数式编程。它与命令式和面向对象等其他编程范式相当不同,需要一段时间才能习惯。但一旦正确应用,它是一种非常强大的程序结构方式,使程序更加声明性、正确、可测试,并且错误更少。

即使你不在项目中使用纯函数式编程,也有许多有用的技术可以单独使用。这尤其适用于mapreducefilter数组方法,它们有广泛的应用。

本章也仅使用了原生 JavaScript 中可用的功能。但请注意,还有许多流行的库可用于辅助函数式编程。这些库简化了函数式编程的实际问题,如不可变性、无副作用函数、组合和自动柯里化。

本章中涉及的主题将帮助你增强在函数式风格中进行编程项目所需的技能。

在下一章中,你将更深入地了解异步编程,包括异步回调的历史、生成器、承诺和 async/await。这将完成你对现代 JavaScript 开发的旅程,为你创建出色的软件做好准备。

第十五章:15. 异步任务

概述

到本章结束时,你将能够实现异步编程及其不同的技术;探索回调地狱和灾难金字塔的陷阱;展示如何使用 promises 在操作完成后执行代码;使用新的async/await语法使异步代码看起来和感觉上几乎像是顺序代码;并应用 Fetch API 来执行远程服务调用。

简介

异步任务允许程序的主线程在等待数据、事件或另一个进程的结果时继续执行,从而实现更快的 UI 响应,并允许某些类型的并行处理。

与其他可以有许多并发线程执行的语言不同,JavaScript 通常在单个线程上运行。到目前为止,你已经详细学习了 JavaScript 的单线程模型是如何通过事件循环和相关的事件队列来实现的。在底层,浏览器或 Node.js 运行时都有后台线程,它们监听事件或发出服务调用。当捕获到新事件或服务调用响应时,它会被推入事件队列。JavaScript 不断地扫描事件队列,并在可用时触发这些事件的处理器。事件处理器最常见的是回调方法,但还有其他类型,例如Promises,你将在本章中学习到。

一些线程的执行时间比其他线程长。在餐厅里,准备牛排比点一杯酒需要更多的时间。然而,由于这些项目之间没有依赖关系,它们可以并行执行。即使酒是在牛排点单后几分钟内点的,也有很大的可能性酒会比牛排先送到,甚至可能是由同一个服务员送来的。这本质上就是异步处理的概念。(为了进一步说明这个类比,当每个项目准备好提供给顾客时,厨房工作人员会将它们放入服务员的队列中。服务员会不断检查他们的队列,以寻找更多需要带给餐厅顾客的东西。)

JavaScript 的早期版本主要使用回调函数来实现异步,但创建回调地狱的负面影响很快就会显现,正如你将看到的。然后,在 ECMAScript 2015 中,引入了一种替代方案,称为 Promises,这非常有帮助,但仍然还有一些不足。最近,在 ECMAScript 2017 中,添加了新的关键字和语法,称为async/await,这进一步简化了异步代码,并在许多方面使其看起来更像是常规顺序代码。你将在接下来的章节中探索这些内容。

在本章中,你还将回顾在第十章 访问外部资源 中引入的 TheSportsAPI,你曾用它查询和检索有关球队、比赛得分、球员和即将发生的事件的体育相关数据。重新阅读那一章以刷新记忆可能是个好主意,因为我们将在此基础上扩展内容。

回调

如你在第十章 访问外部资源 中所探索的,回调是 JavaScript 中执行异步功能最古老和最简单的方法。回调是在操作结果准备好后要调用的指定函数。你可以在 jQuery 的 $.ajax()$.getJSON() 方法中看到这一点,在这些方法中,一旦成功的服务调用响应可用,就会调用一个函数;例如:

$.getJSON('https:/www.somesite.com/someservice',
      function(data) {
      // this function is a callback and is called once
             // the response to the service call is received
      }
    );

另一个回调被大量使用的领域是事件处理器。事件可以被视为异步的,因为它们可以在不可预测的时间以任何顺序发生。处理事件的回调通常在调用 addEventListener() 时注册并添加到事件队列中。

setTimeout()

setTimeout() 函数是传统的方式,用于在未来的某个时间点异步调度代码执行。它通常使用一个参数来指定在执行之前要等待的毫秒数。

练习 15.01:使用 setTimeout() 进行异步执行

这个练习演示了当 setTimeout() 参数指定为 0 或省略时,执行流程是如何的:

  1. 在 Google Chrome 浏览器中,进入“开发者工具”(屏幕右上角带有三个点的菜单)| “更多工具”| “开发者工具”,或者直接按 F12 键)。

  2. 在“控制台”标签页中,粘贴以下文件的代码,但不要按 Enter 键。你可以在文件 exercise1.js 中找到代码。

    console.log("start");
    setTimeout(function() {
            console.log("in setTimeout");
        }, 0);
    console.log("at end of code");
    

    考虑你粘贴的代码。你可能认为 setTimeout() 块中的函数会立即执行,因为它被指定在零毫秒后执行。但实际上,情况并非如此。所以,让我们看看输出结果。

  3. 在控制台中按 Enter 键执行代码。输出将如下所示:

    start
    at end of code
    in setTimeout
    

    由于异步处理的方式,setTimeout() 中的回调被放置在事件队列中以安排稍后处理,而主代码的执行继续进行。回调将在主代码完成后才会执行。

    过度使用 setTimeout() 也会导致不良的编码实践,我们将在下一节中看到。

回调地狱与死亡金字塔

回调可能是处理异步请求最简单、最直接的方法,但如果你不小心,你的代码可能会很快变得混乱且难以管理。这尤其适用于你需要进行一系列嵌套的异步服务调用,而这些调用依赖于前一个调用的返回数据。

回想一下第十章中的 TheSportsDB,在 访问外部资源 中。假设你有一个需求,需要获取你最喜欢的球队所获得的荣誉列表。

在大多数情况下,你事先并不知道 API 所需的 id 参数的标识符。因此,你首先需要使用 API 服务调用查看团队 ID,以便获取球员列表。但还有一个进一步的注意事项,结果是,为了做到这一点,你现在还需要知道该团队所属联赛的标识符。由于你不知道联赛 ID,你需要使用另一个服务来找到联赛 ID 本身。

对于这样的需求,你可能会得到如下代码片段(如果你现在还不理解这段代码,不要担心,因为稍后会有深入的解释)。你可以在以下位置找到文件 pyramid_of_doom_example.html 的代码:

// Pyramid of DOOM!!!
$.getJSON(ALL_LEAGUES_URL, function(leagueData) {
    const leagueId = findLeagueId(leagueData, LEAGUE_NAME);
    $.getJSON(ALL_TEAMS_URL, {id: leagueId}, function(teamData) {
        const teamId = findTeamId(teamData, TEAM_NAME);
        $.getJSON(ALL_PLAYERS_URL, {id: teamId}, function(playerData) {
            playerData.player.forEach(player => {
                $.getJSON(PLAYER_HONORS_URL, {id: player.idPlayer},
                    function(honorData) {
                        printHonors(honorData);
                    }
                );
            });
        });
    });
});

换句话说,这是一个案例,为了在一次调用中获取一块数据,依赖于其他调用的结果。每个回调都使用前一个调用的结果来调用进一步的调用。

注意所有由使用回调产生的嵌套块。它从一个函数开始,然后包含另一个函数,然后是函数内的多个级别的更多函数,这导致了一系列无序的结束括号和结束括号字符。这段代码的形状类似于侧向旋转的金字塔,因此有“末日金字塔”这个俚语:

在本节中,你回顾了 JavaScript 中异步逻辑的传统实现方式,以及使用回调可能会让你陷入困境并导致难以管理的乱糟糟的代码。你还熟悉了 TheSportsDB API,并实现了对其的一些新功能。

近年来,已经开发出几种用于异步处理的回调替代方案,包括 promises 和新的 async/await 语法。下一节将探讨 promises,它是对回调的重大改进,你将会看到。

Promises 和 Fetch API

简而言之,promise 是一个封装异步逻辑的对象,它提供方法在操作完成后访问结果或错误。它是对结果值的代理,直到它被知晓,并允许你关联处理函数而不是使用回调。它是对提供已知和可用值的 承诺

为了更好地理解如何使用 promises,你首先将介绍 Fetch API,它大量使用了 promises。然后,我们将回溯并深入探讨 promises 本身的详细描述。

Fetch 是另一个允许你进行网络请求和 REST 服务调用的 API,类似于 jQuery 的 AJAX 方法或原生的 XMLHttpRequest。主要区别在于 Fetch API 使用了承诺(这有助于你避免回调地狱)。

典型的 Fetch API 用于 JSON 请求的使用方法看起来像这样:

fetch(someURL)
      .then(response =>response.json())
      .then(jsonData =>parseSomeDataFromResponse(jsonData))
      .then(someData =>doSomethingWithDataObtained(someData))
      .catch(error => console.log(error));

fetch() 调用会触发 URL 中的服务调用。一旦有有效的响应可用,第一个 then() 块中的函数就会被执行。该函数接收响应作为参数,在这种情况下,对它运行 json() 方法将文本转换为对象。然后,这个方法调用的结果会被传递给链中的后续 then() 方法。错误也可以通过 catch() 方法来处理。

使用 Fetch API 获取球员荣誉

在本节中,我们将放弃之前用于获取球员荣誉数据的 jQuery 回调方法,转而采用利用承诺的方法(这使我们摆脱了回调地狱的金字塔)。

Fetch API 相对较低级,并不像 jQuery 的 $.ajax()$.getJSON() 函数那样提供许多免费功能,因此我们将创建一个名为 myFetch() 的包装函数,以便在我们的用例中使使用更加方便;具体来说:

  • Fetch 只接受完整的 URL,并且不会为你编码参数。myFetch() 函数将包括一个可选的第二个参数 params,作为键值对,如果指定,将编码参数值并将结果查询字符串附加到 URL 上。

  • Fetch 不会自动解析 JSON 响应,所以你将包括这个在 myFetch() 中。

  • Fetch 不会将 HTTP 状态码视为错误条件,除非代码是 500 或更高。但就我们的目的而言,任何不是 200 (OK) 的响应都应被视为错误。你将添加一个检查。

    注意

    此包装器并不适用于所有用例。你应该根据你的特定需求进行定制。

练习 15.02:重构荣誉名单以使用 Fetch API

在这个练习中,我们将重构代码以获取你最喜欢的球队的球员所获得的荣誉名单。我们将重构它以使用 Fetch API:

  1. 首先,我们将创建一个包含将在本章中使用的常见代码片段的文件。在文本编辑器或 IDE 中输入以下初始代码块。你还可以在 GitHub 上找到文件 players.js 的代码,文件位置为:packt.live/2KUdBY4

    // hard coded data for purposes of illustration
    const LEAGUE_NAME = "English Premier League";
    const TEAM_NAME = "Arsenal";
    const BASE_URL = "https://www.thesportsdb.com/api/v1/json/1/";
    const ALL_LEAGUES_URL = BASE_URL + "all_leagues.php";
    const ALL_TEAMS_URL = BASE_URL + "lookup_all_teams.php";
    const ALL_PLAYERS_URL = BASE_URL + "lookup_all_players.php";
    const PLAYER_HONORS_URL = BASE_URL + "lookuphonors.php";
    

    此代码包含了我们将要调用的 TheSportsDB API 的远程服务的 URL 和数据值。

  2. 输入以下 myFetch() 方法:

    Function myFetch(url, params) {
        if (params) {
            url += "?" + encodeParams(params);
        }
        return fetch(url)
            .then(response => {
                if (!response.ok) {
                    throw new Error(response.status);
                }
                Return response.json()
            }
        );
    }
    

    这是之前提到的 fetch() 包装函数的实现。首先,如果指定了一个或多个参数键值对,它们将被编码为查询字符串并附加到 URL 上。然后,调用 fetch() 函数,当响应可用时执行 then()。如果 HTTP 状态码不是 200 (OK),则会抛出错误。这会导致它被 catch() 函数(如果已在承诺调用链中定义)捕获。最后,如果一切顺利,它调用 response.json() 将 JSON 响应解析为对象,并将其作为另一个承诺返回,以便在后续的 then() 函数中传递和解析。

  3. 使用以下辅助函数,将键值对参数编码为附加到 URL 查询字符串的参数:

    Function encodeParams(params) {
        return Object.keys(params)
            .map(k => encodeURIComponent(k) + '=' +
                      encodeURIComponent(params[k]))
            .join('&');
    }
    
  4. 现在,编写 findLeagueId() 函数:

    Function findLeagueId(leagueData, leagueName) {
        const league = leagueData.leagues.find(l => l.strLeague === leagueName);
        return league ? league.idLeague : null;
    }
    

    此代码使用 ALL_LEAGUES_URL 服务调用的结果,并利用 find() 来定位与所需联赛名称匹配的结果。一旦找到,就返回该联赛的 ID(如果没有找到匹配项,则返回 null)。

  5. 按如下方式编写 findTeamId() 函数:

    Function findTeamId(teamData, teamName) {
        const team = teamData.teams.find(t => t.strTeam === teamName);
        return team ? team.idTeam : null;
    } 
    

    与上一个函数类似,此代码使用 ALL_TEAMS_URL 服务调用的结果,并使用 find() 来定位所需的球队。

  6. 输入 printHonors() 函数:

    Function printHonors(honorData) {
        if (honorData.honors != null) {
            var playerLI = document.createElement("li");
            document.getElementById("honorsList").append(playerLI);
            var playerName =
                document.createTextNode(honorData.honors[0].strPlayer);
            playerLI.appendChild(playerName);
            var honorsUL= document.createElement("ul");
            playerLI.appendChild(honorsUL);
            honorData.honors.forEach(honor => {
                var honorLI = document.createElement("li");
                honorsUL.appendChild(honorLI);
                var honorText = document.createTextNode(
                    `${honor.strHonour} - ${honor.strSeason}`);
                honorLI.appendChild(honorText);
            });
        }
    }
    

    此函数使用 PLAYER_HONORS_URL 服务调用的结果创建一个包含 <ul><li> HTML 标签的球员荣誉列表。

  7. 我们现在已经完成了常用功能。将此文件保存为文件名 players.js

  8. 在您的编辑器或 IDE 中创建一个新文件。输入以下文件中的初始代码块。您可以在 GitHub 上找到代码,文件位置为:packt.live/2XRGLMO

    <html>
    <head>
        <meta charset="utf-8"/>
        <script src="img/players.js"></script>
    </head>
    <body>
    Arsenal Player Honors:
    <ul id="honorsList"></ul>
    <script>
    
  9. 输入以下内容,开始用 Fetch API 的调用替换 jQuery 的 $.getJSON 代码:

    myFetch(ALL_LEAGUES_URL)
      .then(leagueData => {
          const leagueId = findLeagueId(leagueData, LEAGUE_NAME);
          return myFetch(ALL_TEAMS_URL, {id: leagueId});
      })
    

    处理开始于调用 myFetch() 包装函数来调用检索所有联赛列表的服务调用。一旦响应可用,就调用 then() 方法中指定的函数。

    注意

    没有必要检查 HTTP 错误,并且可以假设响应是有效的,因为在上面的 myFetch() 函数调用实现中已经进行了错误检查。您也不需要将 JSON 解析为对象。

    然后调用 findLeagueId() 函数来查找您感兴趣的联赛的 ID,这是获取联赛中球队的下一次服务调用所需的。一旦找到,就再次调用 myFetch()myFetch() 函数调用返回的承诺随后被返回,以便在后续的 then() 块中传递和处理。

  10. 输入下一个 then() 子句以获取球队 ID:

      .then(teamData => {
          const teamId = findTeamId(teamData, TEAM_NAME);
          return myFetch(ALL_PLAYERS_URL, {id: teamId});
      })
    

    以类似的方式,一旦第二个服务调用的响应可用,then() 块中的函数就会被调用。响应被搜索以找到下一个调用所需的团队 ID,然后再次调用 myFetch() 来获取球队中的所有球员。

  11. 输入下一个 then() 块以获取球队球员名单,这是查询每位球员荣誉所需的信息:

        .then(playerData => {
    

    浏览器(以及 JavaScript 运行时)完全能够同时处理多个服务调用的调用。

    注意

    一种简单的方法是按顺序或同步方式依次调用所有服务调用,但这样做会导致浏览器(或 JavaScript 运行时)锁定,直到所有服务调用完成,因为 JavaScript 具有单线程模型。

  12. playerData.player 列表上调用 map() 函数,这会导致列表被迭代,并为列表中的每位球员调用 myFetch(),因此会向 TheSportsDB API 发起多个新的 REST 调用。每个服务调用的结果承诺被收集在 honorRequests 变量中:

          const honorReqests = playerData.player.map(player =>
              myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}));
    
  13. Promise.all() 方法在将关联的承诺返回以在下一个 then() 块中处理之前,会等待所有服务调用完成。一旦可用,承诺将按服务调用调用的顺序以数组的形式返回。这个数组通过 forEach() 进行迭代,为每个响应调用 printHonors()

          return Promise.all(honorReqests);
      })
      .then(honorResponses => honorResponses.forEach(printHonors))
    
  14. 最后,有一个 catch() 方法,以防在处理承诺期间发生错误:

    .catch(error => console.log(error));
    

    这只是将错误记录到控制台(在实际应用中,你应该考虑以某种方式向用户指示发生了错误,例如在 UI 中显示错误消息)。

  15. 使用以下内容关闭文件:

    </script>
    </body>
    </html>
    

练习中的代码导致浏览器出现如下情况:

![图 15.1:玩家荣誉的示例输出

![img/C14377_15_01.jpg]

图 15.1:玩家荣誉的示例输出

在这个练习中,我们重构了代码以使用 Fetch API。这被处理得不同。TheSportsDB API 一次只能提供一个服务调用以检索一个玩家的荣誉。因此,要获取球队所有球员的荣誉,你需要对球队中的每位球员调用多个服务调用。这就是异步变得有用的地方。因此,浏览器(以及 JavaScript 运行时)完全能够同时处理多个服务调用的调用。我们将在下一节中改进这一点。

性能改进

之前的代码是有效的,但仍有另一个值得改进的地方。由于使用了 Promise.all(),直到获取玩家荣誉的所有请求都返回结果之前,都不会显示任何结果。这导致在加载列表时产生了比必要的更长时间的暂停。

如果您在第一个玩家的荣誉数据可用时开始显示列表条目,然后是第二个玩家的荣誉,依此类推,您就可以提高感知性能。即使其他玩家的数据尚未到达,您也可以这样做,只要在显示条目列表时保持正确的玩家顺序即可。

要实现这一点,基本方法是为一个承诺创建一个承诺,将一系列事件附加到该承诺上。您将使用 myFetch() 返回的相应玩家的承诺,并将它们逐个附加到序列中,如下面的伪代码所示:

promise
  .then(createPromiseForPlayer1())
  .then(printDataForPlayer1())     // print once data for player 1 is loaded
  .then(createPromiseForPlayer2()) // etc
  .then(printDataForPlayer2())
  .then(createPromiseForPlayer3())
  .then(printDataForPlayer3())
  .then(createPromiseForPlayer4())
  .then(printDataForPlayer4())

我们的实现将使用 forEach() 来遍历玩家并将他们添加到序列中。序列本身的承诺是通过 Promise.resolve() 创建的,这导致一个立即解决且没有返回值的承诺。但这没关系,因为这个承诺只是作为一个占位符,以便通过一系列 then() 调用来链接其他项目。

之前看起来是这样的代码:

 .then(playerData => {
      const honorReqests = playerData.player.map(player =>
          myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}));
      return Promise.all(honorReqests);
  })
  .then(honorResponses => honorResponses.forEach(printHonors))
  .catch(error => console.log(error));

现在已被以下内容替换。你可以在 GitHub 上的文件 other/fetch_example_improved.html 中找到代码。

  .then(playerData => {
      const sequence = Promise.resolve();

      playerData.player.forEach(player =>
          sequence
            .then(() => myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}))
            .then(printHonors));
      return sequence;
  })
  .catch(error => console.log(error));

结果序列将最终以一系列 then() 子句结束,用于获取并打印每位玩家的荣誉数据,正如前面伪代码中解释的那样。

对于那些更倾向于函数式代码的人来说,这里有一个替代实现。我将让您决定这两个中哪一个更直接、更清晰。

  .then(playerData =>playerData.player.reduce((sequence, player) =>
          sequence
            .then(() =>myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}))
            .then(printHonors)
    , Promise.resolve())
  )

整理 Fetch 代码

前面展示的代码使用 then()catch() 方法处理承诺,执行正确,但确实相当冗长且难以操作。我们能做得更好吗?让我们尝试通过重构每个块的处理为每个 then()catch() 创建单行代码。

下面的代码替换了上面包含的以 myFetch() 开始的承诺代码。你可以在 GitHub 上的文件 other/fetch_tidied.html 中找到代码。

myFetch(ALL_LEAGUES_URL)
  .then(leagueData =>getTeamsInLeague(leagueData, LEAGUE_NAME))
  .then(teamData =>getPlayersOnTeam(teamData, TEAM_NAME))
  .then(playerData =>getPlayerHonors(playerData))
  .catch(console.log);

注意现在代码的整洁程度提高了,你可以更清楚地看到代码执行的过程,仅从函数的命名(即,首先获取所有联赛,然后是正确的联赛,然后是正确的球队,依此类推)就可以看出。

您需要添加的支持函数如下。这些基本上是以前在每个相应的 then() 块中的代码,现在被重构为它们自己的函数:

function getTeamsInLeague(leagueData, leagueName) {
    const leagueId = findLeagueId(leagueData, leagueName);
    return myFetch(ALL_TEAMS_URL, {id: leagueId});
}
Function getPlayersOnTeam(teamData, teamName) {
    const teamId = findTeamId(teamData, teamName);
    return myFetch(ALL_PLAYERS_URL, {id: teamId});
}
function getPlayerHonors(playerData) {
    const sequence = Promise.resolve();
    playerData.player.forEach(player =>
        sequence
          .then(() => myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}))
          .then(printHonors));
    return sequence;
}

注意

请关注章节末尾的活动,届时将使用其他高级技术,如 currying,进一步清理和简化此代码。

一些 Fetch API 使用细节

本节简要总结了之前介绍过的 Fetch API 的某些细节。

注意

一些设置值得关注,但它们的全部细节并不在本章的范围内。这些设置将会被指出,因为如果您的用例需要它们,它们可能对您很重要。

fetch() 方法的完整方法签名如下:

fetchResponsePromise = fetch(resource, init);

init参数允许你为请求分配某些自定义设置。一些可用的选项包括:

  • method: 请求方法,例如GETPOST

  • headers: 应随请求一起发送的任何头部信息,包含在一个Headers对象中(如下面的代码片段所示)。

  • body: 你想要添加到请求中的任何内容,例如一个stringBlobBufferSource。通常用于POST请求。

  • credentials: 如果你访问的资源需要用于身份验证/授权的凭据,你会指定此设置。可能的值包括omitsame-originincludecredentials的完整细节不在本章范围内)。

  • cache: 请求要使用的缓存模式。有效值包括defaultno-cacheno-storereloadforce-cacheonly-if-cached(缓存的完整细节不在本章范围内)。

    一个POST请求的示例用法如下:

    const url = "http://mysite.com/myservice";
    const data = {param1: 1234};
    let responsePromise = fetch(url, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(data)
    })
    .then(response => response.json());
    

    fetch()方法返回一个解析为response对象的 promise,该对象表示请求返回的响应的详细信息。以下是最重要的response对象属性;所有属性都是只读的:

  • Response.headers: 包含与响应相关的头部信息,作为一个键值对的对象。headers对象包含访问它们的方法,例如使用Headers.get()获取给定键的值,或使用Headers.forEach()遍历键/值条目并对每个调用一个函数;例如:

    var headerVal = response.get("Content-Type");
          // application/json
    response.headers.forEach((val, key) => { 
        console.log(key, val); 
    });
    

    注意

    对于跨域请求,对可见的头部信息有所限制。

  • Response.ok: 一个表示响应是否成功的布尔值。如果状态码在200-299范围内,则认为响应是成功的。

  • Response.status: 响应的状态码,例如404表示未找到错误。

  • Response.statusText: 与状态码相对应的状态消息文本,例如200对应OK

在本节中,你了解了 Promise 及其在 Fetch API 中的应用。你看到了如何检索远程数据以及如何处理错误。

一些开发者觉得 Fetch 比较底层,因此更喜欢其他远程请求的替代方案。一个流行的库是 Axios 库。例如,他们认为 Fetch 不理想的地方,Axios 会自动将 JSON 响应转换为对象,而 Fetch 则需要显式地进行转换。在catch()块中处理错误的状态方面也存在差异(因为 Fetch 只认为状态码为500或以上的情况为错误,但对于许多用例,任何不是200的状态码都应被视为错误条件)。

在大多数情况下,我们不需要在我们的代码中引入另一个依赖项。提到的缺点可以通过创建针对您的特定用例的简单包装器来克服,例如您如何实现了 myFetch() 包装器函数。通过包装器访问 API 提供了 Axios 大多数相同的功能,但是您有更多的控制权。

在下一节中,您将详细了解 Promise。

关于 Promise 的详细信息

现在,您将深入了解承诺的细节以及它们通常是如何被使用的,不一定是在服务调用的上下文中。

Promise 的构造函数看起来像这样:

new Promise(function(resolve, reject) {
});

您将传递一个执行器函数,该函数接受两个参数:resolve 和(可选的)reject。当承诺被实例化时,此函数会立即执行。您对执行器函数的实现通常会启动一些异步操作。一旦返回值可用,它应该调用传入的 resolve 函数或 reject(如果有错误或其他无效条件)。如果在执行器函数中抛出错误,它也会导致承诺被拒绝(即使没有显式调用 reject)。

用伪代码表示,这类似于以下内容:

const promise = new Promise((resolve, reject) => {
    // do something asynchronous, which eventually calls either:
    //   resolve(someValue);  // fulfilled
    // or
  //   reject("failure reason");  // rejected
});

Promise 可以处于三种可能的状态之一:已解决已拒绝挂起(尚未解决或拒绝)。一旦承诺不再处于挂起状态(无论是已解决还是已拒绝),就称承诺已解决。

作为简单的例子,考虑一个承诺,其目的是在处理过程中引入故意的 3 秒延迟。您可以使用以下方式使用 setTimeout() 实现:

const timeoutPromise = new Promise((resolve, reject) => { 
    setTimeout(() => {
        // call resolve() to signal that async operation is complete
        resolve("Called after three seconds!");
    }, 3000);
});
timeoutPromise.then(console.log);

这将导致消息 Called after three seconds 打印到控制台。请注意,在此实例中未显式调用 reject()(并且如果您愿意,reject 参数甚至可以省略)。

现在让我们详细了解一下根据执行器函数的返回值会发生什么。如果函数:

  • then 使用返回的值解决。

  • then 使用 undefined 值解决。

  • then 使用抛出的错误作为其值拒绝。

练习 15.03:创建一个用于延迟执行的实用函数

在这个练习中,您将生成一个用于在另一个承诺完成后添加延迟的承诺创建实用函数。如果您想要执行异步操作,如服务调用,但又不想立即处理结果,这很有用。然后,通过进行服务调用并在延迟后打印结果来测试此函数:

  1. 在 Google Chrome 浏览器中,进入 开发者工具(屏幕右上角有三个点的菜单)| 更多工具 | 开发者工具,或者直接按 F12 键)。

  2. 控制台 选项卡中粘贴以下内容并按 Enter:您可以在 GitHub 上的文件位置找到代码:packt.live/2XM98vE

    function addDelay(ms, promise) {
        return promise.then(returnVal =>
            new Promise(resolve =>
                setTimeout(() => resolve(returnVal), ms)
            )
        );
    }
    

    这是我们对这个简单案例的第一个解决方案尝试,其实现与前面的 timeoutPromise 代码相似。

  3. 你将通过调用 TheSportsDB 中的服务来测试它,该服务获取一个联赛的下一场比赛,并将结果打印到控制台(为了测试目的,联赛 ID 已硬编码在 URL 中)。将以下代码粘贴到控制台并按 Enter

    const BASE_URL = "https://www.thesportsdb.com/api/v1/json/1/";
    constnextEventUrl = BASE_URL + "eventsnextleague.php?id=4328";
    addDelay(3000, fetch(nextEventUrl))
      .then(response =>response.json())
      .then(nextEvents => console.log(nextEvents.events[0].strEvent));
    

    前面的代码在 3 秒后在控制台中显示消息 Bournemouth vs Norwich,尽管你的事件可能不同。

    注意

    你也可以使用前面章节中更健壮的 myFetch() 包装器而不是 fetch()

![图 15.2:结果截图

![img/C14377_15_02.jpg]

图 15.2:结果截图

在这个练习中,我们学习了如何使用 addDelay() 函数添加处理延迟。如果你想要执行一个 async 操作,比如服务调用,但又不想立即处理结果,这可以派上用场。在下一节中,我们将进一步改进这个函数。

对 addDelay() 的进一步改进

现在,作为额外的奖励,让我们看看你是否可以想到前面练习中 addDelay() 实用函数的不同用例,以及你如何指定不同的参数选项来支持这些用例。

前面的练习中的代码运行良好,但如果你想要使其更加无缝,并且只想将延迟指令作为 then() 子句之一引入,会怎样?例如:

fetch(nextEventUrl)
  .then(addDelay(1000))
  .then(response =>response.json())
  .then(nextEvents => console.log(nextEvents.events[1].strEvent));

这种形式更简洁,更容易看到流程(即获取响应,添加 1 秒的延迟,然后处理)。

为了支持这一点,你现在有两种方式可以指定参数:

  • 如果有两个参数存在,这是一个简单的情况,返回的承诺将在延迟结束后完成。

  • 如果只有一个参数存在,实际上并没有传递任何承诺。在这里,你将返回一个函数,该函数接受承诺作为参数,预期在调用函数时 then() 调用将提供承诺。然后这个函数将对相同的 addDelay() 函数进行递归调用,带有两个参数。

我们现在的代码如下:

function addDelay(ms, promise) {
    if (promise === undefined) {
        // In this case, only one param was specified.  Since you don't have
        // the promise yet, return a function with the promise as a param and
        // call addDelay() recursively with two params
        return promise =>addDelay(ms, promise);
    }
    // if you reached this far, there were two parameters
    return promise.then(returnVal =>
        new Promise(resolve =>
setTimeout(() => resolve(returnVal), ms)
        )
    );
}

你还应该考虑另一个用例,这将使这个实用函数更加灵活。假设你一开始根本不使用承诺,只想在延迟后返回一个值。

你可以通过使用 Promise.resolve() 并传递要转换的值来支持这一点,这本质上将这个值视为一个立即满足的承诺。如果该值已经是承诺,这个调用将没有效果。

注意

在承诺规范指南中提到,在承诺参数上调用 Promise.resolve() 是一种最佳实践。

通常,当期望一个参数是 promise 时,也应该允许 thenables 和非 promise 值,在使用之前将参数解析为 promise。你不应该对传入的值进行类型检测,在 promise 和其他值之间重载,或者将 promise 放入联合类型中。

最终的代码如下。你可以在 GitHub 上的文件other/addDelay.js中找到这段代码。

function addDelay(ms, promise) {
    if (promise === undefined) {

在这种情况下,只指定了一个参数。由于你还没有 promise,返回一个带有 promise 作为参数的函数,并使用两个参数递归地调用addDelay()

        return promise =>addDelay(ms, promise);
    }

如果你已经读到这儿,有两个参数:

    return Promise.resolve(promise).then(returnVal =>
        new Promise(resolve =>
setTimeout(() => resolve(returnVal), ms)
        )
    );
}

下面是测试三个场景的代码:

const BASE_URL = "https://www.thesportsdb.com/api/v1/json/1/";
const nextEventUrl = BASE_URL + "eventsnextleague.php?id=4328";

用例一是指定了两个参数,因此它在延迟后执行 promise:

let p1 = addDelay(3000, fetch(nextEventUrl))
  .then(response => response.json())
  .then(nextEvents => console.log("Use 1: " + nextEvents.events[0].strEvent));

用例二是只指定了一个参数,因此它返回一个函数,该函数接受 promise 作为参数,并期望在调用函数时then()调用将提供 promise:

let p2 = fetch(nextEventUrl)
  .then(addDelay(1000))
  .then(response =>response.json())
  .then(nextEvents => console.log("Use 2: " + nextEvents.events[1].strEvent));

用例三是我们只想在延迟后返回一个值的情况:

let p3 = addDelay(2000, "This is a passed in value")
  .then(result => console.log("Use 3: " + result));

输出All done!应该写成以下形式:

Promise.all([p1, p2, p3])
  .then(() => console.log("All done!"));

前面代码的输出顺序如下:

    Use 2    (after 1 second)
    Use 3    (after 2 seconds)
    Use 1    (after 3 seconds)

预期的输出将如下所示:

图 15.3:输出截图

图 15.3:输出截图

记住,这不是一个顺序代码,尽管它看起来是这样。在处理异步逻辑时,理解这一点很重要:

  • 当设置Use 1的代码执行时,它安排了函数在 3 秒后回调和执行。但主执行线程立即继续设置Use 2,并不会等待 3 秒完成。

  • Use 2被安排在未来的 1 秒后执行,并且最终会在Use 1之前触发,因此它首先输出。然而,在此发生之前,主执行线程再次立即继续到Use 3

  • Use 3被安排在未来的 2 秒后执行。这是第二个触发并产生输出的用例,因为Use 1不会在 3 秒后触发。

  • 最后,当达到第三个秒时,Use 1触发并输出:

图 15.4:用例在图中的展示

图 15.4:用例在图中的展示

在本节中,你学习了如何创建和使用 promise 的细节。Promise 已经成为 JavaScript 的一个重要部分,许多库和 API 都使用它们。Promise 也成为了进一步扩展语言和直接通过新关键字支持它们的基础,正如你很快就会看到的。

下一个部分将探讨async/await,它通过新的语法扩展了 promise 的使用。

Async/Await

JavaScript 最近版本(自 ES2017-ES8 以来)的新增功能使处理异步逻辑变得更加容易、更加透明,并且使你的代码看起来几乎像是同步的。这是 async/await 语法,这是近年来语言中最令人兴奋和有用的新增功能之一。我们将直接深入探讨并通过示例了解 asyncawait 关键字的使用。

现在,我们将展示你将如何修改承诺代码,以便将其从 Further Refinements to addDelay() 部分中留下的样子重构为使用 async/await。首先,回忆一下看起来像这样的主要处理代码:

myFetch(ALL_LEAGUES_URL)
  .then(leagueData => getTeamsInLeague(leagueData, LEAGUE_NAME))
  .then(teamData => getPlayersOnTeam(teamData, TEAM_NAME))
  .then(playerData => getPlayerHonors(playerData))
  .catch(console.log)

当重构为使用 await 语法时,它将看起来像以下这样。你可以在 GitHub 上的文件 other/async_await.html 中找到代码。

    try {
        let leagueData = await myFetch(ALL_LEAGUES_URL);
        let teamData = await getTeamsInLeague(leagueData, LEAGUE_NAME);
        let playerData = await getPlayersOnTeam(teamData, TEAM_NAME);
        await getPlayerHonors(playerData);
    } catch (err) {
        console.log("caught error: " + err);
    }

await 关键字表示随后的函数返回一个承诺,并指示浏览器或 JavaScript 运行时等待承诺解决并返回结果。

使用 await 实际上只是作为调用 promise.then() 的替代的语法糖,结果与如果调用 promise.then() 会传递的值相同。但使用 await 允许你在变量中捕获结果,看起来就像你正在编写同步代码。

此外,请注意错误处理是如何使用典型的 try...catch 块而不是 catch() 函数来完成的。这是 await 使异步代码更加无缝的另一种方式。

我们还将重构另一个方法:myFetch()。之前,它看起来像这样:

function myFetch(url, params) {
    if (params) {
        url += "?" + encodeParams(params);
    }
    return fetch(url)
        .then(response => {
            if (!response.ok) {
                throw new Error(response.status);
            }
            return response.json()
        }
    );
}

重新重构后,它将看起来像这样:

async function myFetch(url, params) {
    if (params) {
        url += "?" + encodeParams(params);
    } 
    let response = await fetch(url);
    if (!response.ok) {
        throw new Error(response.status);
    }
    return response.json()
}

函数定义前的 async 关键字表示该函数始终返回一个承诺。即使函数实际返回的值不是一个承诺,JavaScript 也会自动将其包装在一个承诺中。在这种情况下,返回值是 response.json() 调用的结果对象,但实际上返回的是一个包装了这个对象的承诺。(调用端的 await 关键字通常用于再次解包值,但也有一些用例需要直接与承诺一起工作。)

还请注意,fetch() 函数调用现在前面有一个 await 关键字,而不是使用 then() 函数的典型承诺 API 来处理它。

你还可以从前面的部分重构另一个函数:getPlayerHonors()。这是它之前的样子:

function getPlayerHonors(playerData) {
   const sequence = Promise.resolve();
    playerData.player.forEach(player =>
        sequence
          .then(() => myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}))
          .then(printHonors));
    return sequence;
}

记住,这段代码的目的是对多个玩家的玩家荣誉数据进行 REST 服务调用。使用 async/await 重构代码可以稍微简化它并删除序列。以下是新代码:

async function getPlayerHonors(playerData) {
    const playerPromises = playerData.player.map(async player =>
        myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}));
    for (constplayerPromise of playerPromises) {
        printHonors(await playerPromise);
    }
}

array.map() 函数影响所有玩家的迭代,并为每个玩家调用 myFetch() 来获取荣誉数据,从而产生一个承诺数组。注意,你在箭头函数的左侧使用了 async 关键字。这是完全有效的,并且只是向 array.map() 信号该函数返回一个承诺。在处理过程中,array.map() 的执行不会等待第一个函数完成后再调用下一个函数。这使得利用 array.map()async 的技术非常适合并发请求的启动。

之后,使用标准的 for...loop 进行第二次迭代,这次是对之前产生的承诺进行迭代。在调用 printHonors 时使用 await 关键字会导致执行等待承诺解决后再打印可用的结果。此外,由于你在一个循环中,你确保输出以正确的顺序打印。

注意

在使用 await 关键字时,还有一个重要的注意事项:它只能在前面带有 async 关键字的函数中使用。尝试在普通函数或顶层代码中使用它会导致语法错误。(因此,在接下来的代码中,请注意你将主要处理代码放在一个匿名的 async 函数中。)

异步生成器和迭代器

对于前面提到的 getPlayerHonors() 函数,还有一种实现技术可以考虑使用。这种方法利用了生成器函数,这在 第五章超越基础 中有描述。一般来说,生成器是 JavaScript 语言中较新的且相对复杂的功能扩展,而迭代器则更为新颖,因此并非所有浏览器和运行时环境都支持它们。因此,我们不会花费太多时间来解释它们。但我们只是想简要地提及它们,并解释一下如何使用 async 与生成器和迭代器一起使用。

下面是具体的实现。你可以在 GitHub 上的文件 other/async_generator_impl.html 中找到代码。

async function* getPlayerHonorsGenerator(playerData) {

    const playerPromises = playerData.player.map(async player =>
        myFetch(PLAYER_HONORS_URL, {id: player.idPlayer}));

    for (const playerPromise of playerPromises) {
        yield playerPromise;
    }
}
async function getPlayerHonors(playerData) {
    for await(const player of getPlayerHonorsGenerator(playerData)) {
        printHonors(player);
    }
}

第一个 getPlayerHonorsGenerator() 函数看起来应该很熟悉,因为它与之前的实现类似,但有一些重要的区别。在 function 关键字后面的星号 (*) 表示它是一个生成器函数,这意味着它通过后续调用返回多个值。

注意循环中的 yield 关键字。当遇到 yield 时,执行权会返回给调用者(实际上就是第二个函数)。当再次调用生成器函数时,执行会从循环中间的断点处继续,并返回下一个值。一旦循环结束,所有值都已返回,生成器会发出完成信号。

第二个函数使用 for-await...of 迭代器语法调用生成器函数。紧跟在 for 之后的 await 关键字使其成为一个 async 迭代器。在执行迭代时,执行将等待生成器(通过 yield)返回的每个 Promise 依次解析,然后再执行循环体。

生成器是一个复杂的话题。然而,通过采用这种技术,你能够以干净的循环语法访问多个异步调用的结果。

活动 15.01:将 Promise 代码重构为 await/async 语法

在本章中,你已经探讨了如何将同步代码重构为使用回调、Promise 和 async/await 语法。本活动将解决一些悬而未决的问题,并挑战你通过使用之前章节中学到的技能使代码的一些方面变得更好。

完成步骤如下:

  1. 首先,回想一下本章 练习 15.03创建一个用于延迟执行的实用函数 中的以下代码,该代码使用 Promise 测试了我们的 addDelay 函数的三个不同用途。

  2. 将其重写为使用 async/await 语法。

  3. 在本活动的目的下,你不得使用 Promise.all()(尽管在常规编程中,这可能是等待多个 Promise 完成的好方法)。

    提示

    请注意你放置 await 关键字的位置,因为这三个案例的解析顺序并不一致。

预期输出是:

图 15.5:玩家荣誉的示例输出

图 15.5:玩家荣誉的示例输出

注意

本活动的解决方案可以在第 763 页找到。

在我们进入下一个活动之前,我们将简要回顾一下柯里化是什么。柯里化是将一个带有多个参数的函数分解为一个或多个额外的函数,这些函数只接受一个参数,并最终解析为一个值。初始函数调用不接收所有参数,而是返回一个函数,该函数的输入是剩余的参数,其输出是所有参数的预期结果。

活动 15.02:进一步简化 Promise 代码以移除函数参数

回到 Promise,我们在整理 Promise 代码以使 then() 子句成为单行语句后,结束了 async/await 部分。以下是代码再次刷新你的记忆:

myFetch(ALL_LEAGUES_URL)
  .then(leagueData =>getTeamsInLeague(leagueData, LEAGUE_NAME))
  .then(teamData =>getPlayersOnTeam(teamData, TEAM_NAME))
  .then(playerData =>getPlayerHonors(playerData))
  .catch(console.log)

这已经相当不错了,但你还能做得更好吗?

现在,我们需要考虑一种简化代码并完全移除函数参数的方法,使其看起来像这样:

myFetch(ALL_LEAGUES_URL)
  .then(getTeamsInLeague(LEAGUE_NAME))
  .then(getPlayersOnTeam(TEAM_NAME))
  .then(getPlayerHonors)
  .catch(console.log)

提示

思考一下你如何可能推迟 getTeamsInLeague()getPlayersOnTeam() 的第一个参数的处理。重构这些函数以返回另一个函数,该函数最终处理此参数,而不是使用你在 第十四章理解函数式编程 中学到的柯里化技术。

为了方便起见,原始代码在此重复(getPlayerHonors() 函数已经只接受一个参数,因此无需进一步简化以实现此目的):

function getTeamsInLeague(leagueData, leagueName) {
constleagueId = findLeagueId(leagueData, leagueName);
    return myFetch(ALL_TEAMS_URL, {id: leagueId});
}
function getPlayersOnTeam(teamData, teamName) {
constteamId = findTeamId(teamData, teamName);
    return myFetch(ALL_PLAYERS_URL, {id: teamId});
}

完成步骤如下:

  1. 在技术 #1 中,重构 getTeamsInLeague 使其现在只接受一个参数 (leagueName),而不是两个参数,这两个参数实际上需要来确定完整的结果 (leagueData, leagueName)。另一个参数将延迟到以后。

  2. 在技术 #1 中,你不再直接从 myFetch 返回承诺,而是返回另一个以 leagueData 作为参数的柯里化函数。此时它只是一个部分应用函数。

  3. 技术 #2 实际上与第一个想法相同,但使用函数变量和多层箭头函数而不是常规函数。

  4. 最后,当在 then() 子句中调用 getTeamsInLeague(LEAGUE_NAME) 时,上面返回的函数将被完全应用,前一个承诺解析的值作为隐含的 leagueData 参数传入。

  5. 在调用 getTeamsInLeague(LEAGUE_NAME) 时,该过程在那时是不完整的,并返回另一个函数来完成它。因此,调用一个部分应用函数。

    该活动的预期输出与 练习 15.02使用 Fetch API 重构荣誉列表 相同,它提供了一个玩家荣誉的示例输出。

    注意

    该活动的解决方案可以在第 765 页找到。

摘要

就像承诺一样,async/await 在 JavaScript 中变得非常重要。你看到了这种语法如何帮助你的代码看起来几乎像是同步代码,并且可以使你的代码更清晰地表达你的意图。它甚至可以通过 try/catch 以更标准的方式处理错误。

但这有时具有欺骗性,如果不小心可能会给你带来麻烦。了解异步代码与顺序代码的不同,特别是异步代码是如何由事件循环触发的以及它不会阻塞主执行线程,这一点非常重要。对于承诺本身也是如此,但由于 async/await 看起来与同步代码如此相似,很容易忘记这个事实。

话虽如此,async/await 仍然非常强大且值得使用。我们已经到达了这本书的结尾。到现在为止,你已经全面了解了 JavaScript 的基础和基础知识。你也已经完全理解了 JavaScript 的语法和结构,无论是用于网页还是其他方面。现在,你准备好构建具有挑战性的智力开发问题,并将其应用于日常工作。

附录

关于

本节包含的内容旨在帮助学生执行书中提到的活动。它包括学生为完成和实现本书目标而需要执行的详细步骤。

第一章:了解 JavaScript

活动 1.01:在网页浏览器中创建一个警告框弹出

解决方案

  1. F12 打开集成在其内的开发者工具。如果不起作用,右键点击可能会弹出一个提示,让你也能这样做:图 1.18:在 Google Chrome 中选择“检查”

    图 1.18:在 Google Chrome 中选择“检查”

  2. 开发者工具默认可能是控制台。如果不是,可能有一个可以点击的 Console 选项卡来激活它。控制台允许你在网页浏览器中直接编写 JavaScript 代码:图 1.19:Google Chrome 中的开发者工具控制台选项卡

    图 1.19:Google Chrome 中的开发者工具控制台选项卡

  3. 在控制台中,输入以下命令:

    var greeting = 'Hello from JavaScript!';
    alert(greeting);
    
  4. Return/Enter 执行代码。代码将在浏览器环境中执行。

    你应该在浏览器视口中看到一个弹出的警告框显示你的消息,如图所示:

图 1.20:出现带有我们消息的警告

图 1.20:出现带有我们消息的警告

这段代码做了什么?它在第一行使用 var 关键字声明了一个名为 greeting 的变量。作为同一行代码的一部分,我们使用 = 赋值运算符将一个 'Hello from JavaScript!' 文本字符串值赋给我们的变量。

在第二行,我们使用 alert() 函数并将我们的 greeting 标识符作为参数传递。结果是,网页浏览器显示了一个带有我们传递给标识符的文本值的警告叠加层。

第二章:使用 JavaScript

活动 2.01:向待办事项列表中添加和修改项

解决方案

  1. 创建 HTML 文件并粘贴 HTML 代码以开始:图 2.28:待办事项列表的初始外观

    图 2.28:待办事项列表的初始外观

  2. 我们首先需要为我们的列表分配一个 ID,以便通过代码来识别它。为此,将一个 id 属性添加到 ol 元素中,并给它赋值为 todo-list。完成这些后,我们就可以直接使用 JavaScript 来引用这个元素:

    <ol id="todo-list">
    
  3. 使用一些 JavaScript 代码,我们现在可以创建一个名为 parentContainer 的新变量。这将引用包含所有列表项的有序列表容器元素。我们将使用之前步骤中分配的 ID,通过 getElementById() 方法直接引用这个元素:

    var parentContainer = document.getElementById('todo-list');
    
  4. 通过 JavaScript 创建一个新的 HTML <li> 列表项元素。目前,这个元素只存在于内存中,因为我们必须将其添加到一个可视容器中。它也没有与它关联的文本内容:

    var newItem = document.createElement('li');
    
  5. 现在,让我们用数据值填充列表项。将newItem节点的innerText设置为字符串。该字符串的值可以是任何您喜欢的,但它应该符合待办事项列表的概念:

    newItem.innerText = "Research Wines";
    
  6. 现在新的 HTML 元素已经创建并填充了文本,我们可以通过将其附加到已存在的父容器中将其添加到视觉文档中:

    parentContainer.appendChild(newItem);
    
  7. 我们还需要在所有其他现有的<script>标签下方添加一行额外的 JavaScript 代码来修改元素的外观。我们将引用有序列表中的最后一个子元素,并将style属性更改为添加 CSS 颜色规则。您可以将文本的实际颜色设置为任何您喜欢的颜色——我将使用crimson

    parentContainer.lastChild.style.color = "crimson";
    
  8. 现在,为了通过代码验证执行顺序,直接在parentContainer的初始声明下方添加以下 JavaScript 行:

    console.log('Beginning List Count: ' + parentContainer.children.length);
    
  9. 然后,在关闭<script>标签之前添加以下行:

    console.log('End List Count: ' + parentContainer.children.length);
    
  10. 完成后,刷新您的浏览器视图,您的列表中应该会出现第七个项目。请务必关注开发者工具控制台以验证代码的执行顺序!不仅新项目将被添加到现有的有序列表中,而且它还将以深红色文本而不是通常的白色显示:图 2.29:已将一个列表项添加到我们的待办事项列表中并使用深红色进行了样式设置

图 2.29:已将一个列表项添加到我们的待办事项列表中,并使用深红色进行了样式设置

完整的代码如下:

activity-complete.html
30    <ol id="todo-list">
31        <li>Wash Laundry</li>
32        <li>Clean Silver</li>
33        <li>Write Letters</li>
34        <li>Purchase Groceries</li>
35        <li>Retrieve Mail</li>
36        <li>Prepare Dinner</li>
37    </ol>
38    <script>
39        var parentContainer = document.getElementById('todo-list');
The full code is available at: https://packt.live/2q8CGY1

这只是一个简单的例子,说明了 JavaScript 如何直接影响其运行环境的其他方面。

第三章:编程基础

活动 3.01:待办事项列表洗牌程序

解决方案

  1. 所有编码都在activity.js文件中完成。在每个步骤中添加显示的注释后的代码。创建待办事项的数组:

    // Declare and initialize the todo list array
    let todoList = [
     "Wash Laundry",
     "Clean Silver",
     "Write Letters",
     "Purchase Groceries",
     "Retrieve Mail",
     "Prepare Dinner"
    ];
    
  2. 检查名为activity.html的 HTML 文档文件中的ol元素,注意它有一个值为todo-listid属性:

     <ol id="todo-list">
     </ol>
    
  3. 您可以创建一个对象变量,使用document.getElementById方法引用ol元素:

    // The todo list element
    let todoEle = document.getElementById('todo-list');
    
  4. 接下来,编写一个函数,该函数接受一个列表元素对象和一个值数组。该函数从列表元素中删除li元素,然后遍历数组,为数组中的每个值创建新的li元素:

    // Function to replace an HTML DOM list li elements with array items.
    function replaceListElements(listEle, listItems){
     listEle.innerHTML = "";
     for (let i= 0; i<= listItems.length - 1; i++){
      let liEle = document.createElement("li");
      liEle.appendChild(document.createTextNode(listItems[i]));
      listEle.appendChild(liEle);
     }
    }
    

    第一行使用innerHTML属性删除列表元素对象的全部内容。for循环遍历数组。for循环中的第一行创建了一个名为liEleli元素对象。下一行使用liEle对象的addChild方法将文本节点附加到liEle对象上。document.createTextNode方法提供了数组中的项作为适当的节点对象。最后一行将liEle对象附加到listEle对象上。

  5. 最后一步是调用该函数:

    // Update the todo list view with initial list of items
    replaceListElements(todoEle, todoList);
    

    此函数将在activity.js文件加载时执行。

  6. 保存 activity.js 文件并在网络浏览器中重新加载 activity.html 文件。它应该如下所示:图 3.24:activity.js 和 activity.html 文件输出

    图 3.24:activity.jsactivity.html 文件输出

  7. 检查名为 activity.html 的 HTML 文档文件中的 button 元素,注意它有一个值为 shuffle-buttonid 属性:

     <button id="shuffle-button">Shuffle</button>
    

    你可以使用 document.getElementById 方法创建一个对象变量来引用 button 元素:

    // The shuffle button element.
    let shuffleButtonEle = document.getElementById('shuffle-button');
    
  8. 接下来,你需要监听点击事件并调用一个处理该事件的函数。shuffleButtonEle 对象的 addEventListener 方法可以为你完成这个任务。第一个参数是事件名称,第二个参数是函数名称:

    // Add event listener function for the shuffle button element.
    shuffleButtonEle.addEventListener('click', shuffleButtonClicked);
    
  9. 现在,我们需要编写 shuffleButtonClicked 函数:

    // Function to handle click events for the Shuffle button
    function shuffleButtonClicked(e){
     replaceListElements(todoEle, getNewShuffledArray(todoList));
    }
    

    代码的唯一一行用于调用 replaceListElements 函数。第一个参数是 ol 元素的 todoEle 对象。第二个参数是 getNewShuffledArray 函数,它使用 todoList 数组作为参数。getNewShuffledArray 函数返回一个已打乱顺序的数组。

  10. 保存 activity.js 文件并在网络浏览器中重新加载 activity.html 文件。然后,点击 Shuffle 按钮,以查看列表的变化。

  11. 花点时间检查 getNewShuffledArray 函数中的注释,看看它是如何工作的。它打乱数组元素,看起来如下:

     function getNewShuffledArray(sourceArray){
    
  12. 现在,复制 sourceArray 并设置索引以进行交换,从最后一个开始:

     var newArray = [].concat(sourceArray);
     let swapIndex = newArray.length;
    
  13. 创建一个索引与 swapIndex 交换,并交换 swapIndex 的值:

     let swapWithIndex;
     let swapIndexValue;
    
  14. 创建一个名为 swapIndexwhile 循环,其值不等于 0,并从 0 到当前的 swapIndex 选择一个索引进行交换:

     while (0 !== swapIndex) {
      swapWithIndex = Math.floor(Math.random() * swapIndex);
    
  15. 现在,将 swapIndex 减少到 1 并复制 swapIndex 的值。然后,将 swapIndex 的值替换为 swapWithIndex 的值,并将 swapWithIndex 的值替换为 temporaryValue 的值:

      swapIndex -= 1;
      swapIndexValue = newArray[swapIndex];
      newArray[swapIndex] = newArray[swapWithIndex];
      newArray[swapWithIndex] = swapIndexValue;
     }
     return newArray;
    

打乱的 To-Do 列表看起来可能如下所示:

图 3.25:打乱列表 1

图 3.25:打乱列表 1

另一个打乱 To-Do 列表的例子如下:

图 3.26:打乱列表 2

图 3.26:打乱列表 2

第四章:JavaScript 库和框架

活动 4.01:为 Todo 列表应用程序添加动画

解决方案:

  1. 前往 cdnjs.com 获取 jQuery CDN URL。

  2. 将库通过脚本标签加载到现有的 Todo-List-HTML 的 head 标签中。这将允许你在代码中使用 jQuery:

    <head>
        // ... links and meta tags from the previous activity
    <script src="img/jquery.min.js"></script>
    </head>
    

    activity.js 中,你需要更改 todoEle 变量。将其更改为 jQuery 元素:

    // The todo list element
    // let todoEle = document.getElementById('todo-list'); // old
    let todoEle = $('#todo-list'); // new
    

    replaceListElements 函数内部,你现在可以使用 jQuery 提供的函数在 todoEle 元素上使用。

  3. 使用必要的 jQuery 函数隐藏并清除元素内部的内容:

    function replaceListElements(listEle, listItems){
     // listEle.innerHTML = „"; // old
     listEle.hide();
     listEle.empty();
     for (let i= 0; i<= listItems.length - 1; i++){
      let liEle = document.createElement("li");
      liEle.appendChild(document.createTextNode(listItems[i]));
      listEle.appendChild(liEle);
     }
    }
    
  4. 在 for 循环内部,创建liEle列表项元素,设置文本内容,并将其附加到listEle列表元素上:

    function replaceListElements(listEle, listItems){
     listEle.hide();
     listEle.empty();
     for (let i= 0; i<= listItems.length - 1; i++){
      // let liEle = document.createElement("li");
      // liEle.appendChild(document.createTextNode(listItems[i]));
      // listEle.appendChild(liEle);
      let liEle = $(document.createElement("li"));
      liEle.append(document.createTextNode(listItems[i]));
      liEle.appendTo(listEle);
     }
    }
    
  5. 最后,缓慢淡入新的排序后的待办事项列表,即listEle

    function replaceListElements(listEle, listItems){
     listEle.hide();
     listEle.empty();
     for (let i= 0; i<= listItems.length - 1; i++){
      let liEle = $(document.createElement("li"));
      liEle.append(document.createTextNode(listItems[i]));
      liEle.appendTo(listEle)
     }
     listEle.fadeIn('slow');
    }
    
  6. 现在,在您的浏览器中打开 HTML 文件,并点击“Shuffle”按钮。待办事项列表应该淡出、打乱顺序,然后再次淡入:图 4.17:输出图像

    <head>
        // ... previous links and meta tags
    <script src="img/velocity.min.js"></script>
    </head>
    
  7. replaceListElements函数内部,现在可以使用 Velocity 将列表元素listEle隐藏(通过设置不透明度为 0),然后清空其内部元素:

    function replaceListElements(listEle, listItems){
     Velocity(listEle, { opacity: 0 }, { duration: 0 })
     listEle.innerHTML = "";
     for (let i= 0; i<= listItems.length - 1; i++){
      let liEle = document.createElement("li");
      liEle.appendChild(document.createTextNode(listItems[i]));
      listEle.appendChild(liEle)
     }
    }
    
  8. 要将列表元素淡入,使用 Velocity 对listEle进行动画处理,并将不透明度设置为1。在 for 循环之后设置代码:

    function replaceListElements(listEle, listItems){
     Velocity(listEle, { opacity: 0 }, { duration: 0 })
     listEle.innerHTML = "";
     for (let i= 0; i<= listItems.length - 1; i++){
      let liEle = document.createElement("li");
      liEle.appendChild(document.createTextNode(listItems[i]));
      listEle.appendChild(liEle)
     }
     Velocity(listEle, { opacity: 1 }, { duration: 500 })
    }
    
  9. 现在,在您的浏览器中打开 HTML 文件,并点击Shuffle按钮。todo列表应该淡出、打乱顺序,然后再次淡入。

  10. 最后,使用 Anime 方法,前往courseds.com并获取Anime.js的 CDN URL。它将看起来与之前的输出相同。

  11. 使用 script 标签将库加载到现有的 Todo-List-HTML 的 head 标签中。这将允许您在代码中使用Anime.js

    <head>
        // ... previous links and meta tags
    <script src="img/anime.min.js"></script>
    </head>
    
  12. replaceListElements函数内部,现在可以使用Anime.js通过设置translateX = -1000将列表元素listEle移出视图,然后清空其内部元素:

    function replaceListElements(listEle, listItems){
     anime({
      targets: listEle,
      translateX: -1000
     });
     listEle.innerHTML = "";
     for (let i= 0; i<= listItems.length - 1; i++){
      let liEle = document.createElement("li");
      liEle.appendChild(document.createTextNode(listItems[i]));
      listEle.appendChild(liEle)
     }
    }
    
  13. 要显示新打乱的待办事项列表,使用Anime.jslistEle列表元素进行动画处理,使其回到视图(通过使用translateX = 0)。在超时内这样做,以确保打乱操作已经完成:

    activity.anime.js
    21 function replaceListElements(listEle, listItems){
    22  anime({
    23   // ANIME SOLUTION
    24   targets: listEle,
    25   translateX: -1000
    26  });
    27  listEle.innerHTML = "";
    28 
    29 
    30  for (let i= 0; i<= listItems.length - 1; i++){
    31   let liEle = document.createElement("li");
    32   liEle.appendChild(document.createTextNode(listItems[i]));
    33   listEle.appendChild(liEle)
    34  }
    The full code is available at: https://packt.live/2Kd08dx
    
  14. 现在,在您的浏览器中打开 HTML 文件,并点击Shuffle按钮。todo列表应该淡出、打乱顺序,然后再次淡入。它将看起来与之前的输出相同。

第五章:超越基础

活动 5.01:简单的数字比较

解答

  1. 创建函数签名:

    function average_grade() {
    
  2. 将函数参数复制到一个变量中。这应该是一个新的 Array 实例:

        var args = Array.prototype.slice.call(arguments);
    
  3. 将所有参数的值相加,并将它们存储在一个变量中:

        var sum = 0;    for (let i=0; i<args.length; i++) {
            sum += Number(args[i]);
        }
    

    记得将成绩值转换为 Number 实例,以便它们可以正确相加。

  4. 计算总和的平均值并将其存储在一个变量中:

        var average = sum / args.length;
    
  5. 使用平均值计算学生的成绩并返回。这可以简单地是一个条件列表:

        if (average < 35) {
          return "F";
        }
        if (average >= 35 && average < 45) {
          return "D";
        }
        if (average >= 45 && average < 60) {
          return "C";
        }
        if (average >= 60 && average < 75) {
          return "B";
        }
        return "A";
    }
    

输出将如下所示:

图 5.26:活动 5.01 输出

图 5.26:活动 5.01 输出

注意

如果其他条件失败,最终条件始终为真,因此可以跳过条件本身。如果上一个条件已经从函数返回,则不会评估每个条件。

活动 5.02:创建 TODO 模型

解答

  1. 第一步是声明状态。它必须在任何函数之外声明,以便在函数调用之间存在:

    let todos = [];
    
  2. 接下来,创建一个辅助函数,该函数将用于从 state 数组中通过 id 查找 TODO。为此,只需遍历数组,一旦找到就返回索引。如果没有找到并且到达数组的末尾,则返回 -1 以表示不存在具有特定 id 的 TODO:

    function modelFindIndex(state, id) {
      for (let i=0; i<state.length; i++) {
        if (state[i].id == id) {
          return i;
        }
      }
      return -1;
    }
    
  3. 现在,创建描述中所述的函数:

    function modelStateChange(state, action, data) {
    
  4. 该函数的行为将根据 action 的值而有所不同。在成功修改数据时,它应该返回新的状态。首先,处理 CREATE 动作向状态中添加新的 TODO:

      if (action == "CREATE") {
        console.log("created:", data);
        return state.concat(data);
      }
    

    记住,需要使用 console.log 将数据写入控制台。

  5. 接下来,处理 REMOVE 功能。这将利用 modelFindIndex 函数定位要删除的 TODO

      if (action == "REMOVE") {
       let data = modelFindIndex(todos, ev.detail);
        if (i > -1) {
          state = state.splice(i, 1);
          console.log("removed", data);
          return state
        }
      }
    
  6. 最后,处理 MODIFY 功能。这一步稍微复杂一些,因为需要在不更改对象引用的情况下更改原始数据:

      if (action == "MODIFY") {
        let data = modelFindIndex(todos, ev.detail);
        if (i > -1) {
          state = state.splice(i, 1);
          console.log("removed", data);
          return state
        }
      }
    }
    

第六章:理解核心概念

活动第 6.01:对模型进行更改

解决方案:

  1. 表单所在的页面有一个区域用于显示消息。表单本身包含标题 textfieldtextarea 字段的描述。

    页面加载了上一章中的模型代码,但也包含了一个新脚本,该脚本将很快被创建。body 标签被分配了一个页面事件处理程序,该处理程序将包含在 create_todos.js 文件中。

    为了使用具有事件的模型模块,您需要通过为每个动作类型提供事件处理程序来扩展它。将以下代码添加到 model.js 文件的底部:

    function modelInit() {
      document.addEventListener("CREATE", modelCreateHandler);
    }
    function modelCreateHandler(ev) {
      todos = modelStateChange(todos, "CREATE", ev.detail);
      document.dispatchEvent(new Event("CHANGED", {detail: {type: "added", value: ev.detail}}));
    }
    
  2. create_todos.js 文件中,添加 loadHandler 函数:

    function loadHandler() {
      model_init();
      let form = document.getElementById("todo_form");
      form.addEventListener("submit", createHandler);
      document.addEventListener("CHANGED", changedHandler);
    }
    

    此函数将初始化模型并设置任何必要的处理程序。如您所见,它将 CHANGED 事件分配给 changedHandler 函数。让我们接下来创建它。

  3. changedHandler 是一件简单的事情。它只是等待 CHANGED 事件被触发,然后在它发生时更新通知区域:

    function changedHandler() {
      let msg = document.getElementById("notifications");
      msg.innerHTML = "The TODO model has been updated";
      setTimeout(() => {
        msg.innerHTML = "";
      }, 3000);
    }
    

    changedHandler 在三秒后将通知区域清除,以保持整洁。

  4. 最后,您需要添加 createHandler 函数,该函数处理表单的提交:

    create_todos.js
    16 function createHandler(ev) {
    17   ev.preventDefault();
    18   let title = document.getElementById("title").value,
    19       description = document.getElementById("description").value,
    20       msg = document.getElementById("notifications");
    21   let errors = [];
    22   if (title.trim() == "") {
    23     errors = errors.concat(["Title is not valid"]);
    24   }
    25   if (description.trim() == "") {
    26     errors = errors.concat(["Description is not valid"]);
    27   }
    28   if (errors.length > 0) {
    29     msg.innerHTML = errors.join("/n");
    30     setTimeout(() => {
    The full code is available at: https://packt.live/2Xbd34R
    

    这段代码的主要作用是在提交表单之前确保提供了值,如果没有提供,则会提醒用户。

  5. 现在,运行应用程序。如果一切顺利,在提交表单时应该会短暂显示通知,具体消息取决于字段是否已填充。例如,打开浏览器控制台并简单地输入以下内容:

    todos;
    

    您应该会看到提交的 TODO 对象在这里展示,如下所示:

![图 6.28:待办事项提交表单img/C14377_06_27.jpg

图 6.28:待办事项提交表单

使用事件提供了一种强大的方法来保持你的应用程序简单。更重要的是,它还赋予了抽象能力,允许脚本发送数据,而无需知道应用程序的其他哪些区域对这些事件感兴趣。这促进了代码的整洁设计和易于维护。

第七章:揭开盖子

活动 7.01:找出栈帧的数量

解决方案

  • 建立调用栈限制的函数如下:

    var frameCount = 0;

    function stackOverflow() {
      frameCount++;
      stackOverflow();
    }
    

    解决方案从将frameCount变量初始化为0开始。声明了stackOverflow()函数,该函数将frameCount变量的值增加 1,然后调用自身,从而引发栈溢出。

  • 现在,启动setTimout()函数,该函数将在至少 500 毫秒后将frameCount的值记录到控制台。现在,调用stackOverflow()函数。

    setTimeout(() => console.log(frameCount), 500);
    stackOverflow();
    

    这将console.log函数从主执行线程中移除,允许它在抛出栈溢出错误后调用:

    图 7.22:显示解决方案和触发栈溢出之前推入的栈帧数量

图 7.22:显示解决方案和触发栈溢出之前推入的栈帧数量

第八章:浏览器 API

活动 8.01:创建一个简单的分形

解决方案

  1. 我们初始化画布和上下文,就像之前做的那样,但这次我们添加了一个点变量,并用画布中心的坐标初始化它:

    let canvas = document.getElementById('canvas');
    const width = window.innerWidth;
    const height = window.innerHeight;
    canvas.width = width;
    canvas.height = height;
    let context = canvas.getContext('2d');
    // Set the starting point to the center of the canvas
    let point = [width / 2, height / 2];
    
  2. 然后,我们开始一个新的路径,并将点移动到分配给点变量的坐标:

    context.beginPath();
    context.moveTo(point[0], point[1]);
    
  3. 我们声明i,我们将使用它作为乘数来告诉函数线条应该有多长。我们还声明了两个常量来保存分形与画布边缘之间的边距值以及一个乘数,该乘数用于增加绘制的线条长度。然后,我们开始一个 while 循环,该循环将在点保持在画布边界内(加上每边的边距)时继续进行:

    let i = 1;
    const OFFSET = 10;
    const MARGIN = 5;
    while (
      point[0] > MARGIN &&
      point[0] < width - MARGIN &&
      point[1] > MARGIN &&
      point[1] < height - MARGIN
    ) {
    
  4. 在 while 循环内部,根据线条的绘制方向和i的值,增加或减少点数组中的值:

    point[1] = point[1] - OFFSET * i;
    
  5. 然后,使用点变量中的值调用 lineTo 函数。每次绘制线条时,i都会增加。这意味着线条的长度在每次绘制线条后都会加倍。你也可以在每次绘制线条时增加i,这样线条之间的间隔就会更大:

    context.lineTo(point[0], point[1]);
      point[0] = point[0] + OFFSET * i;
      i++;
      context.lineTo(point[0], point[1]);
      point[1] = point[1] + OFFSET * i;
      context.lineTo(point[0], point[1]);
      point[0] = point[0] - OFFSET * i;
      i++;
      context.lineTo(point[0], point[1]);
    }
    
  6. 最后,当 while 循环的终止条件满足时(当点达到画布边缘的 5 像素以内时),上下文的 stroke()方法被调用,为描述的线条添加描边:

    context.stroke();
    

    Canvas API 中有更多可用方法,以及许多使用它们的可能性。你可以绘制复杂的图案、图片和图表,并使你绘制的任何东西都动起来。你应该更深入地探索 Canvas API,以了解它能做什么。

活动 8.02:使用两个振荡器播放声音和控制频率

解决方案

  1. 初始化一个音频上下文和一个音量节点:

    let context = new AudioContext();
    let volume = context.createGain();
    
  2. 创建一个增益节点并将其连接到上下文的输出:

    volume.connect(context.destination);
    
  3. 初始化两个振荡器(一个用于光标的每个坐标):

    let osciA = context.createOscillator();
    let osciB = context.createOscillator();
    
  4. 设置振荡器类型(你可以自由使用你喜欢的任何类型),将它们连接到音量节点,并调用它们的 start() 方法:

    osciA.type = 'sawtooth';
    osciB.type = 'square';
    osciA.connect(volume);
    osciB.connect(volume);
    osciA.start();
    osciB.start();
    
  5. 创建一个事件监听器,监听 document 上的 mousemove 事件,并根据光标的位置设置振荡器的频率:

    document.addEventListener('mousemove', event => {
      osciA.frequency.value = event.clientY;
      osciB.frequency.value = event.clientX;
    });
    

传递给事件监听器的回调函数在每次 'mousemove' 事件触发时将光标的 x 和 y 值分配给两个振荡器节点的频率值。这是一个简单的解决方案:它本质上限制了每个振荡器的最高频率值,这取决于浏览器窗口的宽度和高度。这对于演示来说是可以的,但更好的实现方式是在文档的最右侧和最下侧分配相同的任意最高频率值,无论其尺寸如何。

活动 8.03:音频可视化

解决方案

  1. 创建一个简单的 HTML 文件,其中包含对名为 scripts.js(或你想要命名的任何名称)的 JavaScript 文件的链接,在 body 中包含一个 元素,并在 DevTools 控制台中具有 ID 为 canvas:

    <!-- index.html -->
    <!DOCTYPE html>
    <html>
      <head>
        <script src='scripts.js'></script>
      </head>
      <body>
        <canvas id='canvas'></canvas>
      </body>
    </html>
    
  2. 在 scripts.js 文件中,我们将在文档上添加一个事件监听器,监听点击事件。正如我们在本章的 音频 API 部分所看到的,在许多现代浏览器中,音频在用户与页面交互之前是禁用的,所以等待点击事件是一个简单的方法来确保我们在这方面不会遇到任何错误:

    // scripts.js
    document.addEventListener('click', () => {
    
  3. 然后,我们获取 canvas 元素,创建一个 canvas 上下文,设置 canvas 的宽度和高度,并获取其中心 x/y 坐标。我们还实例化一个将保存鼠标位置 Y 值的变量:

      // initialise canvas and related variables
      let canvas = document.getElementById('canvas');
      let canvasContext = canvas.getContext('2d');
      let width = window.innerWidth;
      let height = window.innerHeight;
      canvas.width = width;
      canvas.height = height;
      let centerX = width / 2;
      let centerY = height / 2;
      let mouseY; // this will be set in the 'mousemove' event handler
    
  4. 然后,我们将创建一个音频上下文、一个增益节点、一个振荡器节点和一个分析器节点。我们将振荡器连接到音量节点和分析器节点,然后将音量节点连接到音频上下文的输出:

      // initialise Audio context, nodes and related variables
      let audioContect = new AudioContext();
      let volume = audioContect.createGain();
      let osciA = audioContect.createOscillator();
      let analyser = audioContect.createAnalyser();
      let waveform = new Float32Array(analyser.frequencyBinCount);
      osciA.type = 'sine';
      osciA.connect(volume);
      osciA.connect(analyser);
      volume.connect(audioContect.destination);
      volume.gain.value = 1;
      osciA.start();
    
  5. 接下来,我们监听鼠标移动事件,并在事件回调函数中,将光标的 X 位置分配给振荡器的频率值,将光标的 Y 位置分配给 mouseY 变量。这意味着光标的 X 位置将控制振荡的频率。我们很快就会看到 mouseY 变量是用来做什么的:

      // set oscillator frequency from mouse's x-position
      document.addEventListener('mousemove', event => {
        osciA.frequency.value = event.clientX;
        mouseY = event.clientY;
      });
    
  6. 现在,我们来到了应用程序的核心部分。我们调用 draw() 函数。这个函数告诉浏览器我们想要绘制动画的一帧,并通过将 draw() 函数作为回调,它在每次页面渲染时重复这个函数一次:

    // start drawing
      draw();
    
  7. 接下来,我们从分析仪获取波形数据,并将其复制到波形数组中。然后,我们清除画布上的任何先前 stroke()方法,并开始一个新的路径:

      // the draw function
      function draw() {
        let drawing = window.requestAnimationFrame(draw); // Repeat the drawing function on every animation frame
        analyser.getFloatTimeDomainData(waveform);
        canvasContext.clearRect(0,0,canvas.width,canvas.height); // empty the canvas, so we don't get arcs drawn on top of each other
        canvasContext.beginPath();
    
  8. 现在,我们来遍历波形数组。对于数组中的每个项目(代表波形点),我们将绘制圆的小部分。圆将被分成与数组中项目数量一样多的部分。每个部分的半径是画布宽度的一半减去鼠标的 Y 位置,加上当前波形块振幅的当前块(乘以一个任意数,即 15)。如果鼠标在屏幕的一半以上,结果可能是一个负数,所以我们用 Math.abs()将整个包裹起来,它返回一个数字的绝对值(没有负数):

        // plot a section of the circle for each part of the waveform
        for(let i = 0; i<waveform.length; i++) {
          let radius = Math.abs(((width / 2) - mouseY) + (waveform[i] * 15));
    
  9. 接下来,我们需要设置圆弧的起始角度和结束角度(以弧度为单位)。整个圆是 2*π弧度,但我们将圆分成与波形数组中项目数量一样多的弧。因此,我们可以计算出起始角度为((2 / waveform.length) * i) *π,其中 waveforms.length 是项目数量,i 是当前波形/我们的圆的部分。我们只需将 i 加 1,即为结束角度,因为每个块的最后角度与下一个块的起始角度相同:

          let startAngle = ((2 / waveform.length) * i) * Math.PI;
    

    let endAngle = ((2 / waveform.length) * i) * Math.PI;

  10. 在完成所有这些,并调用画布上下文的 arc()方法之后,我们可以调用 stroke()方法来为刚刚绘制的所有弧添加描边:

          canvasContext.arc(centerX, centerY, radius, startAngle, endAngle);
        }
    
  11. 将所有这些放在一起,运行它,然后点击页面:我们应该得到一个非常漂亮的振荡圆,其直径随着光标的 y 位置增加,其振荡与扬声器发出的正弦波声音相匹配。顺便说一下,这可以通过光标的 x 位置来控制。非常有趣:

        canvasContext.stroke();
      };
    })
    

第九章:使用 Node.js

活动九.01:创建一个用于上传、存储和保存图像详情的 Web 应用程序

解决方案

  1. 让我们先通过目录结构,并定义上传图像的文件夹:![图 9.22:此活动的目录结构 图片

    图 9.22:此活动的目录结构

    如您所见,在这个项目中,您将尝试在public/images目录中上传图像。此目录必须位于项目的根目录,并且当前用户应具有对此目录的读写权限。默认情况下,您将获得对此目录的权限。

    注意

    源代码将只包含两个文件(app.jspackage.json)。在前面的图中显示的images目录中的所有其他文件将不会存在。一旦您开始上传文件,您将看到所有上传的图像在那里。

    在继续之前,请确保在与此项目的app.jspackage.json相关的项目根目录中创建一个public/images目录。您的机器当前登录用户必须具有在此目录中添加文件的权限。

  2. 第一步是导入所有依赖项:

    const express   = require('express');
    const multer            = require('multer');
    const MySQL             = require('MySQL');
    const morgan    = require('morgan')
    const app               = express();
    const port              = 3000;
    
  3. 然后让我们配置 morgan 以在控制台中记录每个请求的详细信息。您可以将 morgan 作为中间件使用:

    // Middleware for logging requests
    app.use(morgan(':method :status :url - :response-time ms'));
    
  4. 现在,让我们配置数据库。在这个项目中,您将使用 MySQL 数据库:

    /*
    * Database
    */
    // Creating MySQL Connection
    let connection = MySQL.createConnection({
       host         : 'localhost',
       user         : 'root',
       password     : '12345678',
       database     : 'packt_JavaScript'
    });
    
    // Connection to db
    connection.connect();
    

    确保在通过 设置数据库 部分时传递您之前创建的用户凭据。

  5. 当您的应用程序建立数据库连接时,是时候通过项目根目录中的终端执行以下命令来安装 multer 了:

    npm i multer --save
    
  6. 现在,让我们通过添加 app.js 文件中的代码来配置 multer

    /*
    * File Upload Settings
    */
    let storage = multer.diskStorage({
            destination: (req, file, cb) => {
                      cb(null, 'public/images')
            },
            filename: (req, file, cb) => {
                    let ext = file.originalname.split('.').pop();
                      cb(null, 'img_' + Date.now() + '.' + ext);
            }
    });
    let upload = multer({storage: storage});    
    

    此代码将设置所有文件上传的目的地,即 public/images,并且在上传后更改 filename 以避免重复文件名的问题。

  7. 现在让我们配置您应用程序中的某些路由:

    app.js
    39 /*
    40 * Routing
    41 */
    42 // Landing route
    43 app.get('/', (req, res) => res.send('Hello World!'))
    44 
    45 // Upload image route
    46 app.post('/upload/image', upload.single('image'), function (req, re s) {
    47        // Column name: values
    48        let payload = {
    49                filename: req.file.filename,
    50                type: req.file.mimetype,
    51                original_name: req.file.originalname,
    52                path: req.file.path,
    53                size: req.file.size
    54 }
    The full code is available at: https://packt.live/2NIE6RR
    
  8. 最后一步是启动服务器:

    // Start listening to requests
    app.listen(port, () => console.log(`App listening on port http://localhost:${port}!`))
    
  9. 就这样。服务器现在已启动并运行。最后一件事是向服务器发送上传图片的请求。为此,我们将使用 Postman(Chrome 扩展)作为我们的客户端:

图 9.23:Postman 中的 API 响应

图 9.23:Postman 中的 API 响应

第十章:访问外部资源

活动 10.01:使用各种技术实现 REST 服务调用

解答:

  1. 使用 $.ajax() 并设置 method: 'post'dataType: 'json'

    solution_using_jquery_ajax.html
        $.ajax({
            method: 'post',
            dataType: 'json',
            url: 'https://reqres.in/api/users',
    
  2. 在对象 {} 中封装您的数据字段:

            data: {
                "name": "Beyonce Knowles",
                "occupation": "artist"
            },
    
  3. 创建一个 success 函数以输出预期的值:

            success: function (data) {
                console.log("Returned id " + data.id + 
                            ", createdAt " + data.createdAt);
                console.log(data);
                }
            });
    

    省略了样板 HTML,并将其留作您的练习。前面的代码在 Google Chrome 的 JavaScript 控制台中会产生类似以下的内容。(所有提供的解决方案都有类似的输出):

    图 10.17:JavaScript 控制台输出

    图 10.17:JavaScript 控制台输出

  4. 现在,让我们使用 $.post() 方法以及文件 solution_using_jquery_post.html 来获取相同的输出。

    $.post('https://reqres.in/api/users',
    
  5. 在对象 {} 中封装您的数据字段:

    {
                "name": "Beyonce Knowles",
                "occupation": "artist"
            },
    
  6. 创建一个 success 函数以输出预期的值:

    function (data) {
                console.log("Returned id " + data.id + 
                            ", createdAt " + data.createdAt);
                console.log(data);
            },
    
  7. 作为 $.post() 的最后一个参数,使用 'json' 值来指示预期的 JSON 返回类型:

    'json'
        );
    

    输出将与 图 10.17 中显示的相同。

  8. 最后,创建一个新的 XMLHttpRequest 对象:

    const url = "https://reqres.in/api/users";
    var xhttp = new XMLHttpRequest();
    
  9. 调用 open('POST')

    xhttp.open('POST', url);
    
  10. Content-typeAccept 请求头设置为适当的值:

    xhttp.setRequestHeader("Content-type", "application/json");
    xhttp.setRequestHeader('Accept', 'application/json');
    
  11. onreadystatechange 创建一个函数,检查状态码为 201 (已创建) 并使用 JSON.parse() 解析 JSON 数据:

    xhttp.onreadystatechange = function() {
        if (this.readyState == 4 && this.status == 201) {
            const data = JSON.parse(this.response);
            console.log("Returned id " + data.id + 
                        ", createdAt " + data.createdAt);
            console.log(data);
        }
    }
    
  12. 在输入数据上调用 JSON.stringify() 以将其转换为 JSON 格式:

    var data = JSON.stringify({
        "name": "Beyonce Knowles",
        "occupation": "artist"
    });
    
  13. 在调用 send() 时发送 JSON 数据:

    xhttp.send(data);
    

    输出将与 图 10.17 中显示的相同。

第十一章:创建干净且易于维护的代码

活动 11.01:扩展电话号码匹配模式以接受多种格式

解答:

  1. 注意到每个模式的开头字符不同,但两种模式中的最后字符 XXX-XXXX 是相同的。

  2. 对于不同的字符,为了使我们的正则表达式正确匹配两种格式,你可以指定与每种格式对应的正则表达式片段,作为交替表达式的替代表达式。回想一下,交替表达式具有 (expression1|expression2) 的形式:

        ([2-9]\d{2}-|\([2-9]\d{2}\) )
    where Regex for XXX-\Regex for (XXX)
    
  3. 将其与原始正则表达式的其余部分结合,以获得完整的正则表达式:

        ^([2-9]\d{2}-|\([2-9]\d{2}\) )\d{3}-\d{4}$
    
  4. 记住,原始正则表达式 \d{3}-\d{4} 匹配 XXX-XXXX,这在两种模式中都是相同的。我们只需要交替每个模式的不同部分。^$ 字符还强制执行没有其他字符在可接受字符之前或之后。

    现在,当你将这个正确的正则表达式替换到我们的工具中时,只有前两个测试字符串匹配(正如预期的那样)

![图 11.23:使用正确的正则表达式后,只有前两个测试字符串匹配(正如预期的那样)]

](https://github.com/OpenDocCN/freelearn-html-css-js-zh/raw/master/docs/js-ws/img/C14377_11_23.jpg)

图 11.23:使用正确的正则表达式后,只有前两个测试字符串匹配(正如预期的那样)

练习 11.01,g 标志的影响 和此活动中,我们创建了接受美国号码格式的正则表达式。现在,我们将修改正则表达式以接受非美国号码格式。

活动 11.02:扩展电话号码匹配模式以接受特定格式

解决方案:

  1. 构建用于 +XXX 模式的正则表达式(其中 1-3 位数字是可接受的):

        (\+\d{1,3} )?
    

    这里我们需要两件事:

    第一个 + 需要转义,因为它是一个特殊字符。

    注意后面的 ? 后面的括号。这指定了括号内的整个表达式可以发生 0 次或 1 次。换句话说,表达式中的字符是可选的。\d{1,3} 表达式是一个范围量词,需要 1 到 3 位数字。

  2. 通过将其添加到前面的活动中的正则表达式之前,整个正则表达式现在如下所示:

        ^(\+\d{1,3} )?([2-9]\d{2}-|\([2-9]\d{2}\) )\d{3}-\d{4}$
    

    注意

    请参阅前面的活动,以了解正则表达式后部分的完整描述。

  3. 为了再提高一个层次,以下是一些我们可以对正则表达式进行的更改,允许我们使用空格或点字符作为数字分隔符,而不是仅使用破折号:

        ^(\+\d{1,3} )?([2-9]\d{2}|\([2-9]\d{2}\))[-. ] set allows a dash, dot, or space character to be used as the separator.This modification now allows all the formats shown in the following screenshot to match:NoteOne flaw with this regex is that it allows you to mix and match what characters are used as separators. For example, you can use a dash and dot, like so: 234-567.8910\. It is possible to make the regex stronger, but the result will end up being very long and convoluted.
    

活动 11.03:重构为简洁代码

解决方案:

注意

代码可以通过多种方式进行重构。以下解决方案代表了一种方式,并附有解释性注释。

  1. 使用 activity_solution.html 文件。HTML 代码与原始代码相同。只有 <script> 部分的内文被替换。

  2. 我们通过创建一个名为 processForm() 的函数开始重构代码:

    document.getElementById("calc_button").addEventListener("click ", processForm);
    function processForm() {
        resetErrorsAndResults();
        const formFields = getFormFields();
        const validationError = formFields.validate();
        if (validationError) {
            displayError(validationError);
            return;
        }
        const result = doCalculation(formFields);
        displayResult(result);
    }
    

    原始代码有一个非常长的函数,它处理了所有表单字段处理、验证和计算。新代码为不同的关注点创建了新的方法。这些方法简短,主要专注于单一任务。

    代码读起来像是一个叙事故事,段落和句子主要是用普通的英语写的。仅通过阅读它,就可以很容易地看到逻辑和流程,而且没有太多杂乱:重置错误和结果,获取表单字段,验证字段并显示错误(如果有),进行计算,并显示结果。每个这些的详细信息都包含在其他方法中。

  3. 我们还将创建一个名为 resetErrorsAndResults() 的函数。根据名称和实现,很容易判断这个函数的目的是什么,并且这个函数仅限于这个特定目的:

    function resetErrorsAndResults() {
        document.getElementById("error").innerHTML = "";
        document.getElementById("result").innerHTML = "";
    }
    
  4. 对于下一个重构,我们决定创建一个类来保存表单字段值并执行验证:

    activity_solution.html
    63 class FormFields {
    64     constructor(numHours, payRate, workerType) {
    65         this.numHours = numHours;
    66         this.payRate = payRate;
    67         this.workerType = workerType;
    68     }
    69
    70     validate() {
    71         let validationError = this.validateNumHours();
    72         if (validationError) {
    73             return validationError;
    74         }
    The full code is available at: https://packt.live/372KXxp
    

    验证方法简单,如果正则表达式测试失败,则仅返回一个包含错误信息的字符串。显示错误信息是一个独立的问题(与原始代码不同,原始代码将这些问题合并在一起)。它包含一个构造函数,其中表单字段的值被传递进来,而不是我们需要直接从表单中获取这些值,从而允许类和验证独立于表单本身进行测试。

  5. 接下来,我们创建一个名为 getFormFields() 的函数。这个函数仅限于从表单中获取值并创建 FormFields 类的实例:

    function getFormFields() {
        const numHours = document.getElementById("numHours").value;
        const payRate = document.getElementById("payRate").value;
        const workerType =
            document.querySelector('input[name="workerType"]:checked').value;
        return new FormFields(numHours, payRate, workerType);
    }
    

    这些方法没有副作用,因为它们便于测试。这是因为对于给定的一组输入参数,返回值总是可预测的。

  6. displayError()displayResult() 函数简单,只做一件事:

    function displayError(error) {
        document.getElementById("error").innerHTML = error;
    }
    function displayResult(result) {
        document.getElementById("result").innerHTML =
            `Total pay check: ${result.toFixed(2)}`;
    }
    

    原始代码中的 switch 语句正大声疾呼“重构我”,以便为每个情况分离出函数!我们将重构它,使其使用一个名为 calculateFunctions 的关联数组。数组中每个条目的键是 workerType,以及从单选按钮中选定的用户,以及包含相应计算逻辑的函数的引用。这些注释是最小的,因为方法、对象和变量名都是自文档化的。

  7. 此外,calculateStandardWorkerPay()calculateNoOvertimeWorkerPay()calculateDoubleOvertimeWorkerPay() 函数接受相同的两个参数,并且具有相同的返回值定义,因此它们可以在 doCalculation() 中抽象地调用:

activity_solution.html
108 const calculateFunctions = {
109     "standard": calculateStandardWorkerPay,
110     "no_overtime": calculateNoOvertimeWorkerPay,
111     "double_overtime": calculateDoubleOvertimeWorkerPay
112 };
113 
114 function doCalculation(formFields) {
115     // determine function to use for calculation based on worker type
116     const calculateFunction = calculateFunctions[formFields.workerType];
117     return calculateFunction(formFields.numHours, formFields.payRate);
118 }
119 
120 function calculateStandardWorkerPay(numHours, payRate) {
121     if (numHours < 40) {
122         return numHours * payRate;
123     }
The full code is available at: https://packt.live/373ThN5

在实际应用中,你可能考虑使用许多可用的验证框架之一,而不是自己编写。为了计算目的,这个实现选择使用关联数组中的函数。可以考虑的一个替代方案是创建一个类层次结构,其中子类在基类中实现或覆盖一个计算方法。

第十二章:使用下一代 JavaScript

活动 12.01:创建一个用于识别和编译 TypeScript 文件的项目

解决方案:

  1. 第一步是创建一个新的项目目录,然后使用 cd 命令进入该目录,并使用 npm 初始化它:

    mkdir my_app
    cd my_app
    npm init
    
  2. 接下来,全局安装 Parcel 库:

    npm install -g parcel
    
  3. 现在,你需要安装 TypeScript,你也可以将其保存为全局库:

    npm install -g typescript
    
  4. 要生成 TypeScript 的配置,你只需调用 TypeScript CLI 工具tsc,并传递--init标志:

    tsc --init
    

    如果一切顺利,你应该会看到一个类似以下的消息:

    message TS6071: Successfully created a tsconfig.json file.
    

    你还应该在项目文件夹的根目录中找到一个名为tsconfig.json的新文件。

  5. 接下来,创建一个名为src的目录,并在其中放置一个index.ts文件。将以下代码作为文件的内容添加进去:

    const message:string = "Hello, World!";
    console.log(message);
    
  6. 一切准备就绪后,更新package.json文件以包含以下脚本:

    "build": "parcel build src/index.ts"
    
  7. 最后,通过调用npm可执行文件来运行脚本:

    npm run build
    

    你应该在屏幕上看到一个成功的Built消息输出,以及包含在项目根目录中的预期dist文件夹,其中包含转换后的js文件。

第十三章:JavaScript 编程范式

活动 13.01:创建计算器应用程序

解决方案

  1. 创建一个空文件,并将其命名为procedural.js

  2. 初始化一个数组,它将维护函数调用的历史记录:

    lethistoryList = [];
    
  3. 现在,创建简单的加法、减法、乘法、除法和幂函数:

    procedural.js
    3 function add(m, n){
    4 historyList.push(['ADD', m, n]);
    5 return m+n;
    6 }
    7 
    8 function subtract(m, n){
    9 historyList.push(['SUB', m, n]);
    10 return m-n;
    11 }
    12 
    13 function multiply(m, n){
    14 historyList.push(['MUL', m, n]);
    15 return m*n;
    16 }
    The full code is available at: https://packt.live/2Xf6kHk
    
  4. 创建一个history函数,它将维护函数调用的历史记录:

    function history(){
     historyList.map((command, index)=>{
      console.log(index+1+'.', command.join(' '));
     })
    }Call all the functions one by one with some random numbers as parameters:
    console.log('ADD 2 3 :', add(2, 3));
    console.log('SUB 2 3 :', subtract(2, 3));
    console.log('MUL 2 3 :', multiply(2, 3));
    console.log('DIV 2 3 :', divide(2, 3));
    console.log('POW 2 3 :', pow(2, 3));
    
  5. 现在,打印history

    console.log('----------------HISTORY---------------');
    history();
    

    这段代码的输出结果如下:

    ![图 13.15:过程式方法的输出

    ![img/C14377_14_151.jpg]

    图 13.15:过程式方法的输出

  6. 现在,创建一个类,并将其命名为calculator

    class Calculator {}
    
  7. 然后,初始化一个historyList数组,它将维护所有函数调用的历史记录:

     constructor() {
      this.historyList = []
     }
    
  8. 现在,创建简单的addsubtractmultiplydividepow方法:

    oop.js
    6  add(m, n) {
    7   this.historyList.push(['ADD', m, n]);
    8   return m+n;
    9  }
    10 
    11  subtract(m, n) {
    12   this.historyList.push(['SUB', m, n]);
    13   return m-n;
    14  }
    15 
    16  multiply(m, n) {
    17   this.historyList.push(['MUL', m, n]);
    18   return m*n;
    19  }
    The full code is available at: https://packt.live/2O70oMi
    
  9. 添加一个额外的函数,它将显示操作的历史记录:

     history() {
      this.historyList.map((command, index)=>{
       console.log(index+1+'.', command.join(' '));
      })
     }
    
  10. 最后,创建这个类的实例,并用简单的数字调用其方法以执行数学运算:

    let calc = new Calculator();
    console.log('ADD 2 3 :', calc.add(2, 3));
    console.log('SUB 2 3 :', calc.subtract(2, 3));
    console.log('MUL 2 3 :', calc.multiply(2, 3));
    console.log('DIV 2 3 :', calc.divide(2, 3));
    console.log('POW 2 3 :', calc.pow(2, 3));
    
  11. 要检查其历史记录,调用calculator类的history方法:

    calc.history();
    

    这段代码的输出结果如下:

![图 13.16:面向对象方法的输出

![img/C14377_14_161.jpg]

图 13.16:面向对象方法的输出

第十四章:理解函数式编程

活动 14.01:黑杰克牌函数

解决方案:

  1. blackjack/start.html文件中,找到打开的脚本标签,并添加一些定义牌元素和创建有序牌组的函数:

    solution.html
    88 const suits =
    89     () => [
    90        { suit: "hearts", symbol: '&#9829;' },    // symbol: '♥'
    91        { suit: 'diamonds', symbol: '&#9830;' },  // symbol: '♦'
    92        { suit: 'spades', symbol: '&#9824;' },    // symbol: '♠'
    93        { suit: 'clubs', symbol: '&#9827;' }      // symbol: '♣'
    94     ];
    95 const rankNames =
    96     () => ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
    97 
    98 const ranks =
    99     rankNames => rankNames.map(
    100         (rank, index) => ({ rank, value: Math.min(10, index + 1) 101 }));
    The full code is available at: https://packt.live/2QgJ0Y2
    
  2. 在此代码下方,定义如何通过添加核心函数式编程方法来创建一副牌:

    const compose =
        (...fns) => input => fns.reduceRight((prev, fn) => fn(prev), input);
    const pipe = (...fns) => input => fns.reduce((prev, fn) => fn(prev), input);
    const map = fx => arr => arr.map(fx);
    
  3. 接下来,添加洗牌函数,就像我们在练习 14.09:使用管道方法创建洗牌函数中做的那样:

    solution.html
    117 const addRandom =
    118     randomizer => deck => deck.map(card => ({
    119         random: randomizer(),
    120         card
    121     }));
    122 
    123 const sortByRandom =
    124     deck => [...deck].sort((a, b) => a.random - b.random);
    125 
    126 const shuffle =
    127     (deck, randomizer) => {
    128         const doShuffle = pipe(
    129             addRandom(randomizer),
    130             sortByRandom,
    131             map(card => card.card)
    132         );
    The full code is available at: https://packt.live/2QlDGCZ
    
  4. 现在,我们将添加抽牌、计算玩家牌的总和、检查手牌是否超过 21 点以及检查游戏是否结束的函数:

    const draw =
        (deck, n = 1) => [deck.slice(0, n), deck.slice(n)];
    const sumCards =
        cards => cards.reduce((total, card) => total + card.value, 0);
    const isBust =
        total => total > 21;
    const isGameOver =
        (bust, stay) => bust || stay;
    
  5. 接下来,我们需要添加一种更新视觉显示和向用户显示的牌的方法。以下是一种实现方式:

    const updateCardDisplay =
        ({ updateHTML }, hand) => {
            const cardHtml = hand.map((card, index) => 
                `<div class="card ${card.suit}"
                    style="top: -${index * 120}px; 
                           left: ${index * 100}px;">
                    <div class="top rank">${card.rank}</div>
                    <div class="bigsuit">${card.symbol}</div>
                    <div class="bottom rank">${card.rank}</div>
                 </div>`);
            updateHTML("cards", cardHtml.join(""));
        };
    
  6. 下一个视觉显示部分是状态显示,它告诉用户他们的手牌总和以及游戏是否结束。在这个解决方案中将要使用的实现包括以下代码中显示的两个函数:

    const showOrHide =
        (updateStyle, element, hide) =>
            updateStyle(element, "display", hide ? "none" : "");
    const updateStatusDisplay =
        ({ updateStyle, updateHTML }, hand, stay) => {
            const total = sumCards(hand);
            updateHTML("totalSpan", total);
            const bust = isBust(total);
            const gameover = isGameOver(bust, stay);
            showOrHide(updateStyle, "playBtn", !gameover);
            showOrHide(updateStyle, "hitBtn", gameover);
            showOrHide(updateStyle, "stayBtn", gameover);
            let statusMsg = gameover ?
                "Game over.  Press New Game button to start again." :
                "Select Hit or Stay";
            statusMsg = bust ? "You went bust!!! " + statusMsg : statusMsg;
            updateHTML("statusMsg", statusMsg);
        };
    
  7. 现在,我们将为用户可以采取的每个动作添加函数,例如 playstayhit。我们将调用这些处理程序:

    const playHandler = (randomizer, { getState, setState }) => () => {
         const orderedDeck = createOrderedDeck(suits(), ranks(rankNames()));
         let gameDeck = shuffle(orderedDeck, randomizer);
         [hand, gameDeck] = draw(gameDeck, 2);
         setState(hand, gameDeck);
     };
    
    const hitHandler = ({ getState, setState }) => () => {
         [hand, gameDeck] = getState();
         [card, gameDeck] = draw(gameDeck, 1);
         setState(hand.concat(card), gameDeck);
     };
    
    const stayHandler = ({ getState, setState }) => () => {
         [hand, gameDeck] = getState();
    setState(hand, gameDeck, true);
    };
    
  8. 你可能已经注意到,在解决方案步骤中,有一些变量尚未定义。我们将这些保存到了一个包含所有非纯函数代码的末尾部分:

    Solution.html
    206 // impure functions
    207 
    208 const byId =
    209     elementId => document.getElementById(elementId);
    210 
    211 const updateHTML =
    212     (elementId, html) => byId(elementId).innerHTML = html;
    213 
    214 const updateStyle =
    215     (elementId, style, value) => byId(elementId).style[style] = value;
    216 
    217 const randomizer =
    218     Math.random;
    The full code is available at: https://packt.live/2FnuFCH
    
  9. 我们现在几乎有一个完全工作的游戏。我们只需要设置状态并调用它来告诉游戏开始:

    const createState = (dom) => {
        let _state;
        const getState = () => [..._state];
        const setState =
            (hand, gameDeck, stay = false) => {
                _state = [hand, gameDeck];
                updateCardDisplay(dom, hand);
                updateStatusDisplay(dom, hand, stay);
            };
        return { getState, setState };
    }
    startGame(createState(dom));
    

完成这些步骤后,你现在应该能够在一个浏览器中打开 HTML 文件,并运行游戏的版本,如下面的截图所示:

![图 14.17:Blackjack 游戏截图]

图 14.17:Blackjack 游戏的截图

![图 14.17:Blackjack 游戏截图]

如果你不确定是否正确地遵循了解决方案步骤,请随意查看 blackjack/solution.html 文件,并将其与你的实现进行比较。

第十五章:异步任务

活动 15.01:将 Promise 代码重构为 await/async 语法

解决方案

这里是使用 async/await 的一种等效代码实现:

  1. 按照以下方式定义 promise 变量:

    (async () => {  
        let p1 = use1(); 
        let p2 = use2(); 
        let p3 = use3(); 
    
  2. 在调用每个函数时,将 await 关键字放置在初始块中是不正确的:

        let p1 = await use1();
        let p2 = await use2();
        let p3 = await use3();
    

    这是因为每个用例都有一个不同的超时定义。如果你在调用 use1() 时使用了 await,它会在 use2() 甚至开始之前造成 3 秒的延迟,这并不是你想要的。相反,我们的愿望是所有三个用例都一个接一个地触发,没有任何延迟,以便它们并发执行。

  3. 你有三个 await 关键字:

        await p1;
        await p2;
        await p3;
    
  4. 将每个情况重构为其自己的异步函数:use1()use2()use3()async 关键字表示这些函数是异步的(你也可以使用内联 async 函数,但这会使语法变得相当混乱和尴尬):

activity1_solution.js
31     console.log("All done!");
32 })();
33 
34 async function use1() {
35     let response = await addDelay(3000, fetch(nextEventUrl));
36     let nextEvents = await response.json();
37     console.log("Use case 1: " + nextEvents.events[0].strEvent);
38 }
39 
40 async function use2() {
41     let response = await fetch(nextEventUrl);
42     await addDelay(1000);
43     let nextEvents = await response.json();
44     console.log("Use case 2: " + nextEvents.events[1].strEvent);
The full code is available at: https://packt.live/32JulHw

在这个活动中,你等待 promises 的顺序并不重要。尽管 await use1 最终会等待 3 秒,但在调用 await use2await use3 的时候,这两个 promises 已经在前一两秒内完成了,所以它们会立即继续执行。

活动 15.02:进一步简化 Promise 代码以删除函数参数

解决方案

这里是可能的一种实现。这两个函数非常相似,为了演示目的,使用了两种不同的风格进行重构。然而,使用其中一种技术来解决这两个函数的挑战是完全可接受的:

  1. 在技术 #1 中,重构 getTeamsInLeague,使其现在只接受一个参数 (leagueName),而不是两个参数来确定完整的结果 (leagueData, leagueName)。另一个参数被延迟到以后:

    function getTeamsInLeague(leagueName) {
    
  2. 在技术 #1 中,你并不是直接从 myFetch 返回承诺,而是返回另一个以 leagueData 作为其参数的柯里化函数(你在最后一步中延迟的参数)。在这个阶段,它只是一个部分应用过的函数:

    return leagueData => {
        const leagueId = findLeagueId(leagueData, leagueName);
            return myFetch(ALL_TEAMS_URL, {id: leagueId});
        }
    }
    
  3. 技术编号 #2 实际上是一个相同的概念,但它使用函数变量和多层箭头函数,而不是常规函数:

    // technique #2 – use a function variable and multiple arrow functions
    const getPlayersOnTeam = teamName => teamData => {
        const teamId = findTeamId(teamData, teamName);
        return myFetch(ALL_PLAYERS_URL, {id: teamId});
    }
    
  4. 最后,当在 then() 子句中调用 getTeamsInLeague(LEAGUE_NAME) 时,上面返回的函数会被完全应用,上一个承诺解析出的值作为隐含的 leagueData 参数传入:

    myFetch(ALL_LEAGUES_URL)
      .then(getTeamsInLeague(LEAGUE_NAME))
      .then(getPlayersOnTeam(TEAM_NAME))
      .then(getPlayerHonors)
      .catch(console.log)
    
  5. 为了可能使这更清晰,让我们假设这是用 async/await 语法编写的。参考以下代码。在第二行,调用 getTeamsInLeague(LEAGUE_NAME) 的过程在那个点是不完整的,并返回另一个函数来完成它。只有当你在第三行调用这个部分应用过的函数时,你才会最终得到期望的结果:

    const allLeagueData = await myFetch(ALL_LEAGUES_URL);
    const partiallyAppliedFunction = getTeamsInLeague(LEAGUE_NAME);
    const league = await partiallyAppliedFunction(allLeagueData);
    
posted @ 2025-10-08 11:34  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报