JavaScript-忍者秘籍第二版-全-

JavaScript 忍者秘籍第二版(全)

原文:Secrets of the JavaScript Ninja2E

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分. 热身

这本书的这一部分将为您的 JavaScript 忍者训练做好准备。在第一章中,我们将探讨 JavaScript 的现状,并探索 JavaScript 代码可以执行的一些环境。我们将特别关注一切开始的地方——浏览器——并讨论开发 JavaScript 应用程序的一些最佳实践。

由于我们的 JavaScript 探索将在浏览器环境中进行,在第二章中,我们将向您介绍客户端 Web 应用程序的生命周期以及执行 JavaScript 代码如何融入这个生命周期。

当你完成这本书的这一部分后,你将准备好开始你的 JavaScript 忍者训练!

第一章. JavaScript 无处不在

本章涵盖

  • JavaScript 的核心语言特性

  • JavaScript 引擎的核心项目

  • JavaScript 开发中的三个最佳实践

让我们谈谈鲍勃。在花了几年的时间学习如何用 C++创建桌面应用程序后,他在 2000 年代初以软件工程师的身份毕业,然后进入了广阔的世界。那时,网络刚刚步入正轨,每个人都想成为下一个亚马逊。所以他做的第一件事就是学习 Web 开发。

他学习了一些 PHP,以便能够动态生成网页,他通常会在网页上添加 JavaScript 以实现复杂的功能,如表单验证甚至动态页面时钟!快进几年,智能手机已经成为一种潮流,因此预见一个巨大的新市场即将开放,鲍勃提前学习了 Objective-C 和 Java 来开发在 iOS 和 Android 上运行的移动应用。

这些年来,鲍勃创建了许多成功的应用程序,所有这些都需要维护和扩展。不幸的是,每天在所有这些不同的编程语言和应用框架之间跳转,真的开始让可怜的鲍勃感到疲惫。

现在,让我们谈谈安。两年前,安以软件开发学位毕业,专攻基于 Web 和云的应用程序。她基于现代模型-视图-控制器(MVC)框架创建了一些中型 Web 应用程序,以及相应的在 iOS 和 Android 上运行的移动应用程序。她创建了一个在 Linux、Windows 和 OS X 上运行的桌面应用程序,甚至还开始构建一个完全基于云的无服务器版本的应用程序。而且她所做的一切都是用 JavaScript 编写的

那太棒了!鲍勃用 10 年和 5 种语言才做到的事情,安在 2 年内只用一种语言就实现了。在计算机历史的整个过程中,某个特定的知识集能够在如此多的不同领域轻松转移和有用是罕见的。

1995 年开始的一个简短的 10 天项目,现在已经成为世界上最广泛使用的编程语言之一。JavaScript 实际上 无处不在,这得益于更强大的 JavaScript 引擎以及 Node、Apache Cordova、Ionic 和 Electron 等框架的引入,这些框架将语言的应用范围扩展到了简单的网页之外。而且,就像 HTML 一样,这门语言本身现在也正在进行长期期待已久的升级,旨在使 JavaScript 更适合现代应用程序开发。

在这本书中,我们将确保你了解所有关于 JavaScript 的知识,无论你是像 Ann 还是像 Bob,你都可以在绿色或棕色土地上开发各种应用程序。

你知道吗?

Q1:

Babel 和 Traceur 是什么,为什么它们对今天的 JavaScript 开发者来说很重要?

Q2:

任何网络浏览器中用于网络应用程序的 JavaScript API 的核心部分是什么?

1.1. 理解 JavaScript 语言

随着他们职业生涯的推进,许多像 Bob 和 Ann 这样的 JavaScript 开发者会达到一个积极使用构成语言的大量元素的阶段。然而,在许多情况下,这些技能可能并没有被提升到基本水平以上。我们的猜测是,这通常是因为 JavaScript 使用类似于 C 的语法,与广泛使用的类似 C 语言(如 C# 和 Java)在外观上有一定的相似性,因此给人留下了熟悉的感觉。

人们常常觉得,如果他们了解 C# 或 Java,他们已经对 JavaScript 的工作原理有了相当牢固的理解。但这是一个陷阱!与其他主流语言相比,JavaScript 要更加 函数式。一些 JavaScript 概念与大多数其他语言的基本概念有根本性的区别。

这些差异包括以下内容:

  • 函数是一等公民—— 在 JavaScript 中,函数与其他 JavaScript 对象共存,并且可以像其他 JavaScript 对象一样被对待。它们可以通过字面量创建,通过变量引用,作为函数参数传递,甚至作为函数返回值返回。我们在第三章 chapter 3 中投入了大量篇幅来探讨函数作为一等公民为我们的 JavaScript 代码带来的许多美妙好处。

  • 函数闭包—— 函数闭包的概念通常理解得不好,但与此同时,它从根本上和不可逆转地证明了函数对 JavaScript 的重要性。目前,只需知道函数是 当它积极维护(“封闭”)其主体中使用的外部变量时,就是一个闭包。现在如果你没有看到闭包的许多好处,请不要担心;我们将在第五章 chapter 5 中确保一切都很清晰。除了闭包之外,我们还将彻底探讨第三章 chapters 3 和第四章 chapters 4 中函数本身的许多方面,以及第五章 chapters 5 中的标识符作用域。

  • 作用域—— 直到最近,JavaScript 没有块级变量(如其他 C 语言类似的语言);相反,我们只能依赖全局变量和函数级变量。

  • 基于原型的面向对象—— 与其他主流编程语言(如 C#、Java 和 Ruby)使用基于类的面向对象不同,JavaScript 使用原型。通常,当开发者从基于类的语言(如 Java)转向 JavaScript 时,他们试图将 JavaScript 当作 Java 来使用,本质上是用 JavaScript 的语法编写 Java 的基于类的代码。然后,由于某种原因,当结果与预期不符时,他们会感到惊讶。这就是为什么我们将深入探讨原型,了解基于原型的面向对象是如何工作的,以及它在 JavaScript 中的实现方式。

JavaScript 由对象和原型、函数和闭包之间的紧密关系组成。理解这些概念之间的强大关系可以极大地提高你的 JavaScript 编程能力,为你提供任何类型的应用程序开发的基础,无论你的 JavaScript 代码将在网页、桌面应用程序、移动应用程序还是服务器上执行。

除了这些基本概念之外,其他 JavaScript 特性可以帮助你编写更优雅、更高效的代码。其中一些是经验丰富的开发者如 Bob 从其他语言(如 Java 和 C++)中认识到的特性。特别是,我们关注以下内容:

  • 生成器,它们是可以在每次请求的基础上生成多个值并可以在请求之间暂停执行的函数

  • 承诺,它让我们更好地控制异步代码

  • 代理,它允许我们控制对某些对象的访问

  • 高级数组方法,使数组处理代码更加优雅

  • 映射,我们可以用它来创建字典集合;以及集合,它允许我们处理唯一项的集合

  • 正则表达式,它让我们可以简化原本可能很复杂的代码片段

  • 模块,我们可以用它将代码分解成更小、相对自包含的部分,使项目更易于管理

深入理解基础知识并学习如何使用高级语言特性以最佳方式提升你的代码水平。将这些概念和特性结合起来,将使你达到一个层次,使创建任何类型的 JavaScript 应用程序成为可能。

1.1.1. JavaScript 将如何演变?

负责标准化语言的 ECMAScript 委员会刚刚完成了 JavaScript 的 ES7/ES2016 版本。ES7 版本是对 JavaScript 的相对较小的升级(至少与 ES6 相比),因为委员会未来的目标是专注于语言的小型、年度增量变化。

在这本书中,我们全面探讨了 ES6,同时也关注 ES7 的新特性,例如新的异步函数,这将帮助你处理异步代码(在第六章中讨论)。

注意

当我们介绍 ES6/ES2015 或 ES7/ES2016 中定义的 JavaScript 特性时,你会在链接旁边看到这些图标,以了解它们是否被你的浏览器支持。

语言规范的年度增量更新是好消息,但这并不意味着在规范发布后,Web 开发者会立即获得新特性的访问权限。JavaScript 代码必须由 JavaScript 引擎执行,所以我们经常不得不焦急地等待我们最喜欢的 JavaScript 引擎更新,以包含这些新而令人兴奋的特性。

不幸的是,尽管 JavaScript 引擎开发者正在努力跟上,并且一直在做得更好,但总有可能遇到你迫切想要使用但尚未得到支持的特性。

幸运的是,你可以通过 kangax.github.io/compat-table/es6/kangax.github.io/compat-table/es2016plus/kangax.github.io/compat-table/esnext/ 上的列表来跟踪各种浏览器中特性支持的状态。

1.1.2. 转译器让我们今天就能使用明天的 JavaScript

由于浏览器快速发布周期,我们通常不需要等待很长时间就能看到 JavaScript 特性的支持。但如果我们想利用最新 JavaScript 特性的所有好处,却受到残酷现实的束缚:我们的 Web 应用的用户可能仍在使用较旧的浏览器?

解决这个问题的方法之一是使用 转译器(“转换+编译”),这些工具可以将前沿的 JavaScript 代码转换成在大多数当前浏览器中正确运行的等效(或,如果不可能,相似)代码。

今天最受欢迎的转译器是 Traceur (github.com/google/traceur-compiler) 和 Babel (babeljs.io/)。设置它们非常简单;只需遵循其中一个教程,例如 github.com/google/traceur-compiler/wiki/Getting-Startedbabeljs.io/docs/setup/

在这本书中,我们特别关注在浏览器中运行 JavaScript 代码。为了有效地使用浏览器平台,你必须亲自动手研究浏览器的内部工作原理。让我们开始吧!

1.2. 理解浏览器

现在,JavaScript 应用程序可以在许多环境中执行。但所有这一切开始的环境,所有其他环境从中汲取灵感的来源,以及我们将要关注的环境,是 浏览器。浏览器提供了各种概念和 API 以供彻底探索;参见 图 1.1。

图 1.1. 客户端 Web 应用程序依赖于浏览器提供的架构。我们将特别关注 DOM、事件、计时器和浏览器 API。

我们将重点关注以下内容:

  • 文档对象模型 (DOM) — DOM 是客户端 Web 应用程序 UI 的结构化表示,至少最初是由 Web 应用的 HTML 代码构建的。要开发出色的应用程序,你不仅需要深入理解核心 JavaScript 机制,还需要研究 DOM 的构建方式 (第二章) 以及如何编写有效代码来操作 DOM (第十二章)。这将使你能够轻松创建高级、高度动态的 UI。

  • 事件 — 大多数 JavaScript 应用程序都是 事件驱动 应用程序,这意味着大部分代码都是在响应特定事件的上下文中执行的。事件示例包括网络事件、计时器和用户生成的事件,如点击、鼠标移动、键盘按键等。因此,我们将彻底探讨第十三章 chapter 13 中事件背后的机制。我们将特别关注 计时器,它们通常是谜团,但让我们处理复杂编码任务,如长时间计算和流畅动画。

  • 浏览器 API — 为了帮助我们与世界互动,浏览器提供了一个 API,允许我们访问有关设备的信息、本地存储数据或与远程服务器通信。本书中我们将探讨一些这些 API。

精通 JavaScript 编程技能并深入理解浏览器提供的 API 将带你走得很远。但 sooner, rather than later, you’ll run face first into the browsers and their various issues and inconsistencies. 在一个完美的世界里,所有浏览器都不会有错误,并且会以一致的方式支持 Web 标准;不幸的是,我们并不生活在这个世界里。

浏览器的质量最近有了很大的提升,但它们仍然存在一些错误、缺失的 API 和浏览器特有的怪癖,我们需要处理这些问题。制定一个全面策略来解决这些浏览器问题,并深入了解它们之间的差异和怪癖,这几乎和掌握 JavaScript 本身一样重要。

当我们编写浏览器应用或用于其中的 JavaScript 库时,选择支持哪些浏览器是一个重要的考虑因素。我们希望支持所有浏览器,但开发和测试资源的限制决定了否则。因此,我们将彻底探讨第十四章中跨浏览器开发的策略。第十四章。

开发有效的、跨浏览器的代码在很大程度上取决于开发者的技能和经验。本书旨在提高这一技能水平,因此让我们通过查看当前的最佳实践来着手进行。

1.3. 使用当前最佳实践

掌握 JavaScript 语言和了解跨浏览器编码问题,是成为一名专家 Web 应用开发者的重要部分,但它们并不是全部。要进入高级别,你还需要展现出许多前开发者证明对编写高质量代码有益的特质。这些特质被称为最佳实践,除了语言掌握之外,还包括如下元素:

  • 调试技能

  • 测试

  • 性能分析

在编码时坚持这些实践至关重要,我们将在整本书中使用它们。接下来,让我们考察其中的一些。

1.3.1. 调试

调试 JavaScript 过去意味着使用alert来验证变量的值。幸运的是,调试 JavaScript 代码的能力已经大幅提高,这在很大程度上得益于 Firefox 的 Firebug 开发者扩展的流行。为所有主要浏览器都开发了类似的工具:

  • Firebug— Firefox 中流行的开发者扩展,它推动了这一进程(getfirebug.com/)

  • Chrome DevTools— Chrome 团队开发,用于 Chrome 和 Opera

  • Firefox 开发者工具— Firefox 团队开发的一个工具

  • F12 开发者工具— 包含在 Internet Explorer 和 Microsoft Edge 中

  • WebKit Inspector— Safari 使用的工具

正如你所见,每个主要浏览器都提供了我们可以用来调试我们的 Web 应用的开发者工具。使用 JavaScript 弹窗进行调试的日子已经一去不复返了!

所有这些工具都基于类似的思想,这些思想大多由 Firebug 引入,因此它们提供了类似的功能:探索 DOM、调试 JavaScript、编辑 CSS 样式、跟踪网络事件等。任何一种都能很好地完成任务;使用您浏览器提供的工具,或者在使用中调查 bug 的浏览器中。

此外,您还可以使用其中的一些,例如 Chrome Dev Tools,来调试其他类型的应用程序,如 Node.js 应用。(我们将在附录 B 中介绍一些调试技术。附录 B)

1.3.2. 测试

在整本书中,我们将应用测试技术来确保示例代码按预期运行,并作为如何测试代码的一般示例。我们将使用的主要测试工具是一个 assert 函数,其目的是断言一个前提是正确还是错误。通过指定断言,我们可以检查代码是否按预期运行。

该函数的一般形式如下:

assert(condition, message);

第一个参数是一个应该为真的条件,第二个参数是在它不为真时将显示的消息。

例如,考虑以下内容:

assert(a === 1, "Disaster! a is not 1!");

如果变量 a 的值不等于 1,则断言失败,并显示一个有些过于夸张的消息。

注意

assert 函数不是语言的标准功能,因此我们将自己在 附录 B 中实现它。

1.3.3. 性能分析

另一个重要的实践是性能分析。JavaScript 引擎在 JavaScript 性能方面取得了惊人的进步,但这并不是编写马虎和低效代码的借口。

我们将在本书后面使用如下代码来收集性能信息:

在这里,我们使用内置的 console 对象的 timetimeEnd 方法的两次调用将待测代码的执行括起来。

在操作开始执行之前,对 console.time 的调用以一个名称(在这种情况下,My operation)启动计时器。然后我们运行 for 循环中的代码一定次数(在这种情况下,maxCount 次)。由于代码的单个操作发生得太快而无法可靠地测量,我们需要多次执行代码以获得可测量的值。通常,这个计数可以是成千上万,甚至数百万,这取决于被测量的代码的性质。一点尝试和错误让我们选择一个合理的值。

当操作结束时,我们使用相同名称的 console.timeEnd 方法。这会导致浏览器输出自计时器开始以来经过的时间。

这些最佳实践技术,以及你将在学习过程中了解的其他技术,将极大地提高你的 JavaScript 开发技能。使用浏览器提供的受限资源开发应用程序,结合浏览器功能和兼容性的日益复杂,需要一套强大而完整的技能。

1.4. 提高技能迁移性

当鲍勃刚开始学习网页开发时,每个浏览器都有自己解释脚本和 UI 样式的独特方式,宣扬自己的方法是最佳方法,这让每个开发者都感到沮丧。幸运的是,随着 HTML、CSS、DOM API 和 JavaScript 的标准化,浏览器大战终于结束,开发者的关注点转向了有效的跨浏览器 JavaScript 应用。确实,这种将网站视为应用的关注导致了众多想法、工具和技术从桌面应用跨越到 Web 应用。现在,这种知识和工具的迁移再次发生,因为起源于客户端 Web 开发的想法、工具和技术也已经渗透到其他应用领域。

因此,通过核心 API 的知识来深入理解基本的 JavaScript 原则可以使你成为一个更灵活的开发者。通过使用浏览器和 Node.js(一个从浏览器衍生出来的环境),你可以开发几乎任何类型的应用程序:

  • 桌面应用程序,例如使用 NW.js (nwjs.io/) 或 Electron (electron.atom.io/)。这些技术通常封装浏览器,这样我们就可以使用标准的 HTML、CSS 和 JavaScript(这样我们就可以依赖我们的核心 JavaScript 和浏览器知识)来构建桌面 UI,并提供了额外的支持,使得我们可以与文件系统交互。我们可以构建真正平台独立的桌面应用程序,在 Windows、Mac 和 Linux 上具有相同的视觉和感觉。

  • 使用框架的移动应用,例如 Apache Cordova (cordova.apache.org/)。与使用 Web 技术构建的桌面应用类似,移动应用框架使用封装的浏览器,但增加了特定平台的 API,使我们能够与移动平台交互。

  • 使用 Node.js 的 Server-side 应用程序和嵌入式设备应用程序,Node.js 是一个从浏览器衍生出来的环境,它使用了许多与浏览器相同的底层原则。例如,Node.js 执行 JavaScript 代码并依赖于事件。

安妮不知道她有多幸运(尽管鲍勃有一个相当好的想法)。无论她是否需要构建标准桌面应用程序、移动应用程序、服务器端应用程序,甚至嵌入式应用程序——所有这些类型的应用程序都共享一些标准客户端 Web 应用程序的相同的基本原则。通过理解 JavaScript 的核心机制如何工作,以及理解浏览器提供的核心 API(例如事件,这些事件也与 Node.js 提供的机制有很多共同之处),她可以全面提升她的开发技能。你也是如此。在这个过程中,你将成为一个更全能的开发者,并获得解决各种问题的知识和理解。你甚至能够使用基于云的 AWS Lambda 等服务提供的 JavaScript API 来构建自己的无服务器应用程序,以部署、维护和控制应用程序的云组件。

1.5. 概述

  • 客户端 Web 应用程序是目前最受欢迎的,曾经仅用于其开发的理念、工具和技术已经渗透到其他应用领域。理解客户端 Web 应用程序的基础将帮助你在广泛的领域开发应用程序。

  • 提高你的开发技能,你必须深入理解 JavaScript 的核心机制以及浏览器提供的基础设施。

  • 本书重点关注核心 JavaScript 机制,如函数、函数闭包和原型,以及新的 JavaScript 特性,如生成器、承诺、代理、映射、集合和模块。

  • JavaScript 可以在大量环境中执行,但所有这一切开始的地方,以及我们将集中关注的环境,是浏览器。

  • 除了 JavaScript 之外,我们还将探索浏览器内部结构,如 DOM(网页 UI 的结构化表示)和事件,因为客户端 Web 应用程序是事件驱动应用程序。

  • 我们将带着最佳实践进行这次探索:调试、测试和性能分析。

第二章. 在运行时构建页面

本章涵盖

  • Web 应用程序生命周期中的步骤

  • 处理 HTML 代码以生成网页

  • 执行 JavaScript 代码的顺序

  • 通过事件实现交互性

  • 事件循环

我们的 JavaScript 探索是在客户端 Web 应用程序和浏览器(作为执行 JavaScript 代码的引擎)的背景下进行的。为了有一个强大的基础,继续探索 JavaScript 作为一门语言和浏览器作为平台,我们首先必须理解完整的 Web 应用程序生命周期,特别是我们的 JavaScript 代码如何适应这个生命周期。

在本章中,我们将彻底探讨客户端网络应用程序的生命周期,从页面请求开始,到用户执行的各种交互,直到页面关闭。首先,我们将探索如何通过处理 HTML 代码来构建页面。然后,我们将关注 JavaScript 代码的执行,它为我们的页面增添了必要的动态性。最后,我们将研究如何处理事件,以开发对用户操作做出响应的交互式应用程序。

在这个过程中,我们将探讨一些基本的网络应用程序概念,如 DOM(网页的结构化表示)和事件循环(确定应用程序如何处理事件)。让我们深入探讨!

你知道吗?

Q1:

浏览器是否总是按照给定的 HTML 准确构建页面?

Q2:

网络应用程序一次可以处理多少个事件?

Q3:

为什么浏览器必须使用事件队列来处理事件?

2.1. 生命周期概述

典型的客户端网络应用程序的生命周期始于用户在浏览器的地址栏中输入 URL 或点击链接。假设我们想查找一个术语并访问谷歌的主页。我们输入 URL www.google.com,如图 2.1 上方所示。

图 2.1. 客户端网络应用程序的生命周期始于用户指定网站地址(或点击链接),并在用户离开网页时结束。它由两个步骤组成:页面构建事件处理

生命周期概述

代表用户,浏览器制定一个请求发送到服务器 服务器,服务器处理请求 处理请求 并制定一个通常由 HTML、CSS 和 JavaScript 代码组成的响应。当浏览器接收到这个响应 响应 时,我们的客户端网络应用程序真正开始活跃起来。

由于客户端网络应用程序是图形用户界面 (GUI) 应用程序,它们的生命周期遵循与其他 GUI 应用程序(如标准桌面应用程序或移动应用程序)相似的阶段,并在以下两个步骤中执行:

  1. 页面构建— 设置用户界面。

  2. 事件处理— 进入循环 事件处理 等待事件发生 事件发生,并开始调用事件处理器。

应用程序的生命周期在用户关闭或离开网页 关闭网页 时结束。

现在我们来看一个具有简单 UI 并对用户操作做出响应的示例网络应用程序:每次用户移动鼠标或点击页面时,都会显示一条消息。我们将在这个章节中使用这个应用程序。

列表 2.1. 带有 GUI 并对事件做出响应的小型网络应用程序

步骤 1

示例 1

代码列表 2.1 首先定义了两个 CSS 规则,#first#second,它们指定了具有 ID firstsecond 的元素的文本颜色(这样我们可以轻松地区分它们)。然后我们定义一个具有 id first 的列表元素:

<ul id="first"></ul>

然后我们定义了一个 addMessage 函数,当被调用时,创建一个新的列表项元素,设置其文本内容,并将其追加到一个现有元素中:

function addMessage(element, message){
  var messageElement = document.createElement("li");
  messageElement.textContent = message;
  element.appendChild(messageElement);
}

接着,我们使用内置的 getElementById 方法从文档中获取 ID 为 first 的元素,并向其添加一条消息,通知我们页面正在加载:

var first = document.getElementById("first");
addMessage(first, "Page loading");

接下来我们定义另一个列表元素,现在具有属性 ID second

<ul id="second"></ul>

最后,我们将两个事件处理器附加到网页的主体上。我们首先从 mousemove 事件处理器开始,它在用户移动鼠标时执行,并通过调用 addMessage 函数将一条消息 "Event: mousemove" 添加到 second 列表元素中:

document.body.addEventListener("mousemove", function() {
  var second = document.getElementById("second");
  addMessage(second, "Event: mousemove");
});

我们还注册了一个 click 事件处理器,每当用户点击页面时,都会记录一条消息 "Event: click",也记录到 second 列表元素中:

document.body.addEventListener("click", function(){
  var second = document.getElementById("second");
  addMessage(second, "Event: click");
});

运行和与该应用程序交互的结果显示在图 2.2 中。

图 2.2. 当代码列表 2.1 中的代码运行时,根据用户操作记录消息。

图 2.2

我们将使用这个示例应用程序来探索和说明不同阶段之间的差异。让我们从页面构建阶段开始。

2.2. 页面构建阶段

在一个 Web 应用程序可以交互或甚至显示之前,页面必须从从服务器收到的信息(通常是 HTML、CSS 和 JavaScript 代码)构建。页面构建阶段的目标是设置 Web 应用程序的 UI,这是通过两个不同的步骤完成的:

  1. 解析 HTML 和构建文档对象模型(DOM)

  2. 执行 JavaScript 代码

第一步是在浏览器处理 HTML 节点时执行,第二步是在遇到一种特殊的 HTML 元素——script 元素(包含或引用 JavaScript 代码)时执行。在页面构建阶段,浏览器可以根据需要在这两个步骤之间切换,如图 2.3 所示。

图 2.3. 页面构建阶段开始于浏览器接收到页面代码时。它分为两个步骤:解析 HTML 和构建 DOM,以及执行 JavaScript 代码。

图 2.3_ 替代

2.2.1. 解析 HTML 和构建 DOM

页面构建阶段始于浏览器接收到 HTML 代码,该代码作为浏览器构建页面 UI 的基础。浏览器通过逐个解析 HTML 代码,并构建一个 DOM,即 HTML 页面的结构化表示,其中每个 HTML 元素都表示为一个节点来完成这一操作。例如,图 2.4 显示了构建到第一个script元素时的示例页面 DOM。

图 2.4. 当浏览器遇到第一个script元素时,它已经创建了一个包含多个 HTML 元素的 DOM(右侧的节点)。

![02fig04_alt.jpg]

注意图 2.4 中的节点是如何组织的,使得除了第一个节点(根html节点 ![num-01.jpg])之外,每个节点恰好有一个父节点。例如,head节点 ![num-02.jpg] 的父节点是html节点 ![num-01.jpg]。同时,一个节点可以有任意数量的子节点。例如,html节点 ![num-01.jpg] 有两个子节点:head节点 ![num-02.jpg] 和body节点 ![num-07.jpg]。同一元素的孩子被称为兄弟节点。(head节点 ![num-02.jpg] 和body节点 ![num-07.jpg] 是兄弟节点。)

需要强调的是,尽管 HTML 和 DOM 紧密相关,DOM 是由 HTML 构建的,但它们并不相同。您应该将 HTML 代码视为浏览器在构建页面初始 DOM(即 UI)时遵循的蓝图。浏览器甚至可以修复它在这个蓝图中发现的问题,以创建一个有效的 DOM。让我们考虑图 2.5 中显示的示例。

图 2.5. 浏览器修复的无效 HTML 示例

![02fig05_alt.jpg]

图 2.5 展示了一个简单的错误 HTML 代码示例,其中段落元素被放置在head元素中。head元素的本意是用于提供页面的一般信息:例如,页面标题、字符编码和外部样式和脚本。它并不用于定义页面内容,正如这个例子所示。因为这个错误,浏览器通过构建正确的 DOM(如图 2.5 中的右侧所示)来静默地修复它,其中段落元素被放置在body元素中,这是页面内容应该放置的地方。

HTML 规范和 DOM 规范

当前版本的 HTML 是 HTML5,其规范可在html.spec.whatwg.org/找到。如果您需要更易读的内容,我们推荐 Mozilla 的 HTML5 指南,可在developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5找到。

另一方面,DOM 的演变要慢一些。当前版本是 DOM3,其规范可在 dom.spec.whatwg.org/ 找到。同样,Mozilla 准备了一份报告,可在 developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model 找到。

在页面构建过程中,浏览器可能会遇到一种特殊的 HTML 元素,即 script 元素,它用于包含 JavaScript 代码。当这种情况发生时,浏览器会暂停从 HTML 代码构建 DOM,并开始执行 JavaScript 代码。

2.2.2. 执行 JavaScript 代码

包含在 script 元素中的所有 JavaScript 代码都由浏览器的 JavaScript 引擎执行;例如,Firefox 的 Spidermonkey、Chrome 和 Opera 的 V8,或者 Edge 的 (IE 的) Chakra。由于 JavaScript 代码的主要目的是为页面提供动态性,浏览器通过一个全局对象提供了一个 API,该 API 可以由 JavaScript 引擎用来与页面交互和修改页面。

JavaScript 中的全局对象

浏览器向 JavaScript 引擎暴露的主要全局对象是 window 对象,它代表了包含网页的窗口。window 对象是 唯一的 全局对象,通过它所有其他全局对象、全局变量(甚至用户定义的变量)和浏览器 API 都可以访问。全局 window 对象的一个重要属性是 document,它代表了当前页面的 DOM。通过使用这个对象,JavaScript 代码可以修改页面的 DOM 到任何程度,通过修改或删除现有元素,甚至创建和插入新的元素。

让我们看看 列表 2.1 中的代码片段:

var first = document.getElementById("first");

这个例子使用全局 document 对象从 DOM 中选择具有 ID first 的元素并将其分配给变量 first。然后我们可以使用 JavaScript 代码对该元素进行各种修改,例如更改其文本内容、修改其属性、动态创建并添加新的子元素,甚至从 DOM 中删除该元素。

浏览器 API

在整本书中,我们使用了许多浏览器内置对象和函数(例如,windowdocument)。不幸的是,涵盖浏览器支持的所有内容超出了 JavaScript 书籍的范围。幸运的是,Mozilla 再次为我们提供了支持,在 developer.mozilla.org/en-US/docs/Web/API 上,你可以找到 Web API 接口的当前状态。

在了解了浏览器提供的全局对象的基本知识之后,让我们看看两种不同类型的 JavaScript 代码,它们定义了代码的确切执行时间。

不同类型的 JavaScript 代码

我们广泛区分两种不同的 JavaScript 代码类型:全局代码函数代码。以下列表将帮助您理解这两种类型代码之间的差异。

列表 2.2. 全局和函数 JavaScript 代码

这两种类型代码之间的主要区别在于它们的放置位置:函数中包含的代码称为 函数代码,而放置在所有函数之外的代码称为 全局代码

这两种代码类型在执行上也有所不同(你将在后面看到一些额外的差异,尤其是在 第五章 中)。全局代码由 JavaScript 引擎自动按顺序逐行执行,就像遇到它时那样。例如,在 列表 2.2 中,定义 addMessage 函数的全局代码使用内置的 getElementById 方法获取 ID 为 first 的元素并调用 addMessage 函数;它们按顺序遇到时执行,如图 2.6 所示。

图 2.6. 执行 JavaScript 代码时的程序执行流程

另一方面,函数代码为了执行,必须被其他东西调用:要么是全局代码(例如,全局代码中的 addMessage 函数调用导致 addMessage 函数代码的执行),要么是其他函数,或者由浏览器(很快就会详细介绍)。

在页面构建阶段执行 JavaScript 代码

当浏览器在页面构建阶段到达 script 节点时,它会暂停基于 HTML 代码的 DOM 构建并开始执行 JavaScript 代码。这意味着执行 script 元素中包含的全球 JavaScript 代码(以及由全球代码调用的函数也会执行)。让我们回到 列表 2.1 的例子。

图 2.7 展示了全局 JavaScript 代码执行后的 DOM 状态。让我们慢慢分析其执行过程。首先定义一个函数 addMessage

function addMessage(element, message){
  var messageElement = document.createElement("li");
  messageElement.textContent = message;
  element.appendChild(messageElement);
 }
图 2.7. 执行 script 元素中包含的 JavaScript 代码后的页面 DOM

然后使用全局 document 对象及其 getElementById 方法从 DOM 中获取现有元素:

var first = document.getElementById("first");

这之后是对 addMessage 函数的调用

addMessage(first, "Page loading");

这会导致创建一个新的 li 元素,修改其文本内容,并将其最终插入到 DOM 中。

在这个例子中,JavaScript 代码通过创建一个新元素并将其插入到 DOM 中来修改当前 DOM。但一般来说,JavaScript 代码可以修改 DOM 到任何程度:它可以创建新节点,修改或删除现有的 DOM 节点。但也有一些它做不到的事情,例如选择和修改尚未创建的元素。例如,我们无法选择和修改具有 ID secondul 元素,因为该元素是在当前 script 节点之后找到的,尚未到达并创建。这就是人们倾向于将 script 元素放在页面底部的原因之一。这样,我们就不必担心特定的 HTML 元素是否已经被到达。

当 JavaScript 引擎执行 script 元素中最后一行 JavaScript 代码(在 图 2.5 中,这意味着从 addMessage 函数返回),浏览器退出 JavaScript 执行模式,并继续通过处理剩余的 HTML 代码来构建 DOM 节点。如果在处理过程中,浏览器再次遇到 script 元素,则从 HTML 代码创建 DOM 再次暂停,JavaScript 运行时开始执行包含的 JavaScript 代码。重要的是要注意,在此期间 JavaScript 应用程序的全球状态持续存在。在单个 script 元素中执行 JavaScript 代码期间创建的所有用户定义的全局变量通常都可以在其他 script 元素中的 JavaScript 代码中访问。这是因为存储所有全局 JavaScript 变量的全局 window 对象在整个页面生命周期中都是活跃且可访问的。

这两个步骤

  1. 从 HTML 构建 DOM

  2. 执行 JavaScript 代码

只要还有要处理的 HTML 元素和要执行的 JavaScript 代码,这两个步骤就会重复。

最后,当浏览器处理完所有 HTML 元素后,页面构建阶段就完成了。然后,浏览器继续进入 Web 应用程序生命周期的第二部分:事件处理

2.3. 事件处理

客户端 Web 应用程序是 GUI 应用程序,这意味着它们会对不同类型的事件做出反应:鼠标移动、点击、键盘按键等。因此,在页面构建阶段执行的 JavaScript 代码,除了影响全局应用程序状态和修改 DOM 之外,还可以注册事件监听器(或处理程序):当事件发生时由浏览器执行的功能。通过这些事件处理程序,我们为我们的应用程序提供交互性。但在深入了解注册事件处理程序之前,让我们先了解一下事件处理背后的基本思想。

2.3.1. 事件处理概述

浏览器执行环境的核心思想是:一次只能执行一段代码:所谓的单线程执行模型。想象一下银行排队。每个人都排成单行,必须等待轮到自己被柜员“处理”。但使用 JavaScript 时,只有一个柜员窗口是开放的!客户(事件)在轮到他们时才会被处理,一次只处理一个。只需一个人认为在柜员窗口时做整个财年的财务规划是合适的(我们都有遇到过这种情况!)就会让整个流程变得混乱。

每当发生事件时,浏览器都应该执行相关的事件处理函数。但我们无法保证用户有极大的耐心,总是在触发另一个事件之前等待适当的时间。因此,浏览器需要一种方式来跟踪已经发生但尚未处理的事件。为此,浏览器使用一个事件队列,如图 2.8 所示。

图 2.8。在事件处理阶段,所有事件(无论是来自用户,如鼠标点击和按键,还是来自服务器,如 Ajax 事件)都会在发生时排队,并按照执行的单线程顺序进行处理。

图 2.8

所有的生成事件(无论是用户生成的,如鼠标移动或按键,还是服务器生成的,如 Ajax 事件)都会按照浏览器检测到的顺序放入同一个事件队列中。如图 2.8 的中间部分所示,事件处理过程可以用一个简单的流程图来描述:

  • 浏览器检查事件队列的头部。

  • 如果没有事件,浏览器会持续检查。

  • 如果事件队列的头部有事件,浏览器会取走它并执行相关处理程序(如果有的话)。在执行过程中,其余的事件会耐心地等待在事件队列中,等待轮到自己被处理。

由于一次只能处理一个事件,我们必须格外注意处理事件所需的时间;编写执行时间很长的处理程序会导致 Web 应用无响应!(如果这听起来有点模糊,不要担心;我们将在第十三章 chapter 13 中回到事件循环,并确切地了解它如何影响 Web 应用的感知性能)。

重要的是要注意,将事件放入队列的浏览器机制是独立于页面构建和事件处理阶段的。确定事件何时发生并将它们推入事件队列所需的处理不参与处理事件的线程。

事件是异步的

当事件发生时,它们可能在不可预测的时间和顺序中发生(强制用户以特定顺序按键或点击是有点棘手的)。我们说事件的处理,以及因此调用其处理函数,是异步的

以下类型的事件可能发生,等等:

  • 浏览器事件,例如页面加载完成或即将卸载时

  • 网络事件,例如来自服务器的响应(Ajax 事件,服务器端事件)

  • 用户事件,例如鼠标点击、鼠标移动和按键

  • 定时器事件,例如超时到期或间隔触发

大多数代码都是由于此类事件而执行的!

事件处理的概念是网络应用的核心,你将在本书的示例中反复看到:代码是在事先设置好以便在稍后执行。除了全局代码外,我们放置在页面上的绝大多数代码都将作为某些事件的结果执行。

在事件可以被处理之前,我们的代码必须通知浏览器我们感兴趣处理特定事件。让我们看看如何注册事件处理器。

2.3.2. 注册事件处理器

正如我们已经提到的,事件处理器是我们希望在特定事件发生时执行的函数。为了实现这一点,我们必须通知浏览器我们对事件感兴趣。这被称为事件处理器注册。在客户端网络应用中,有两种方式可以注册事件:

  • 通过将函数分配给特殊属性

  • 通过使用内置的addEventListener方法

例如,编写以下代码将函数分配给window对象的特殊onload属性:

window.onload = function(){};

load事件(当 DOM 准备就绪且完全构建时)注册了事件处理器。(如果赋值操作符右侧的符号看起来有点奇怪,请不要担心;我们将在后面的章节中详细讨论函数。)同样,如果我们想在文档的body上注册click事件的处理器,我们可以写类似以下的内容:

document.body.onclick = function(){};

将函数分配给特殊属性是注册事件处理器的一种简单直接的方法,你可能已经遇到过。但我们不建议你以这种方式注册事件处理器,因为这样做有一个缺点:只能为特定事件注册一个函数处理器。这意味着很容易覆盖先前的事件处理器函数,这可能会有些令人沮丧。幸运的是,有一个替代方案:addEventListener方法使我们能够注册所需数量的事件处理器函数。为了给你一个例子,以下列表回到列表 2.1 的示例摘录。

列表 2.3. 注册事件处理器

这个例子使用 HTML 元素上的内置addEventListener方法来指定事件类型(mousemoveclick)和事件处理器函数。这意味着每当鼠标移动到页面上时,浏览器都会调用一个函数,将消息"Event: mousemove"添加到 ID 为second的列表元素中(当点击主体时,向同一元素添加类似的消息"Event: click")。

现在你已经知道了如何设置事件处理器,让我们回顾一下你之前看到的简单流程图,并更仔细地看看事件是如何被处理的。

2.3.3. 处理事件

事件处理背后的主要思想是,当事件发生时,浏览器调用相关的事件处理器。正如我们之前提到的,由于单线程执行模型,一次只能执行一个事件处理器。任何后续的事件都只有在当前事件处理器完全执行完毕后才会被处理!

让我们回到列表 2.1 的应用。图 2.9展示了用户快速移动并点击鼠标的一个示例执行过程。

![图 2.9. 事件处理阶段的示例,其中处理了两个事件—mousemoveclick]

02fig09_alt.jpg

让我们来看看这里发生了什么。作为对这些用户行为的响应,浏览器按照它们发生的顺序将mousemoveclick事件放入事件队列中:首先是mousemove事件,然后是click事件num-01.jpg

在事件处理阶段,事件循环随后检查队列,看到队列前面有一个mousemove事件,并执行相关的事件处理器num-02.jpg。当mousemove处理器正在处理时,click事件在队列中等待它的轮次。当mousemove处理器函数的最后一行执行完毕并且 JavaScript 引擎退出处理器函数后,mousemove事件就被完全处理num-03.jpg,事件循环再次检查队列。这次,在队列前面,事件循环找到了click事件并处理它。一旦click处理器执行完成,队列中没有新的事件,事件循环继续循环,等待处理新事件。这个循环将一直执行,直到用户关闭 Web 应用。

现在我们对事件处理阶段发生的整体步骤有了感觉,让我们看看这个执行是如何影响 DOM 的(图 2.10)。mousemove处理器的执行选择了 ID 为second的第二个列表元素,并通过使用addMessage函数,添加了一个新的列表项元素num-01.jpg,其文本为"Event: mousemove"。一旦mousemove处理器的执行完成,事件循环执行click处理器,这导致创建另一个列表项元素num-02.jpg,该元素也被添加到 ID 为second的第二个列表元素中。

图 2.10. 处理了mousemoveclick事件后的示例应用的 DOM

带着对客户端 Web 应用生命周期的扎实理解,在本书的下一部分,我们将开始关注 JavaScript 语言,通过学习函数的细节来深入了解。

2.4. 概述

  • 浏览器接收到的 HTML 代码用作创建 DOM 的蓝图,DOM 是客户端 Web 应用结构的内部表示。

  • 我们使用 JavaScript 代码动态修改 DOM,为 Web 应用带来动态行为。

  • 客户端 Web 应用的执行分为两个阶段:

    • 页面构建— 处理 HTML 代码以创建 DOM,并在遇到脚本节点时执行全局 JavaScript 代码。在执行过程中,JavaScript 代码可以修改当前 DOM 到任何程度,甚至可以注册事件处理程序—在特定事件发生时执行(例如,鼠标点击或键盘按键)。注册事件处理程序很简单:使用内置的addEventListener方法。

    • 事件处理— 各种事件按它们生成的顺序逐个处理。事件处理阶段高度依赖于事件队列,其中所有事件都按它们发生的顺序存储。事件循环始终检查队列的顶部以查找事件,如果找到事件,则调用匹配的事件处理函数。

2.5. 练习

1

客户端 Web 应用的生命周期中有哪两个阶段?

2

使用addEventListener方法注册事件处理程序与将处理程序分配给特定元素属性相比,主要优势是什么?

3

一次可以处理多少个事件?

4

事件队列中的事件是按什么顺序处理的?

第二部分. 理解函数

现在你已经做好了心理准备,并且理解了 JavaScript 代码执行的 环境,你就可以学习 JavaScript 为你提供的功能基础了。

在第三章中,你将了解 JavaScript 最重要的基本概念:不是对象,而是函数。本章将教你为什么理解 JavaScript 函数是解锁语言秘密的关键。

第四章通过研究函数的调用以及执行函数代码时可以访问的所有隐式参数的来龙去脉,继续深入探索函数。

第五章 通过闭包将函数提升到新的层次——这可能是 JavaScript 语言中最被误解(甚至未知)的方面之一。正如你很快就会看到的,闭包与作用域紧密相关。在本章中,除了闭包之外,我们还特别关注 JavaScript 中的作用域机制。

我们对函数的探索将在第六章中完成,我们将讨论一种全新的函数类型——生成器函数,它具有一些特殊属性,在处理异步代码时特别有用。

第三章. 新手的优先级函数:定义和参数

本章涵盖

  • 理解函数为什么如此关键

  • 函数是如何成为一等对象的

  • 定义函数的方法

  • 参数分配的秘密

当你翻到这本书关于 JavaScript 基础的部分时,可能会感到惊讶,讨论的第一个主题是函数而不是对象。我们当然会在书的第三部分第三部分中详细讨论对象,但归根结底,编写 JavaScript 代码与编写 JavaScript 忍者代码之间的主要区别在于理解 JavaScript 作为一种函数式语言。你将在 JavaScript 中编写的所有代码的复杂程度都取决于这一认识。

如果你正在阅读这本书,你不是一个初学者。我们假设你足够了解对象基础来应对(我们将在第七章中查看更高级的对象概念),但真正理解 JavaScript 中的函数是你能使用的最重要的武器。事实上,如此重要,以至于本章以及接下来的三个章节都致力于彻底理解 JavaScript 中的函数。

最重要的是,在 JavaScript 中,函数是一等对象,或者如人们常说的一等公民。它们与其他 JavaScript 对象共存,并且可以像其他 JavaScript 对象一样被对待。就像更平凡的 JavaScript 数据类型一样,它们可以通过变量引用,用字面量声明,甚至作为函数参数传递。在本章中,我们将首先探讨这种对函数的定位带来的差异,你将看到这如何帮助我们编写更紧凑、更易于理解的代码,通过允许我们在需要的地方定义函数。我们还将探讨如何利用函数作为一等对象来编写性能更好的函数。你将看到定义函数的各种方法,甚至包括一些新类型,如箭头函数,这将帮助你编写更优雅的代码。最后,我们将探讨函数参数和函数参数之间的区别,特别关注 ES6 新增的功能,如剩余参数和默认参数。

让我们从探讨函数编程的一些好处开始。

你知道吗?

Q1:

在什么情况下回调函数可能会同步使用?异步使用?

Q2:

箭头函数和函数表达式之间的区别是什么?

Q3:

为什么你可能需要在函数中使用默认参数值?

3.1. 功能差异是什么?

函数和函数概念在 JavaScript 中如此重要的一个原因是函数是执行的主要模块单元。除了在页面构建阶段执行的全球 JavaScript 代码外,我们将为我们的页面编写的所有脚本代码都将在一个函数内部。

由于我们的大部分代码都将作为函数调用的结果运行,你会发现拥有灵活且强大的函数构造给我们编写代码带来了极大的灵活性和影响力。本书的很大一部分内容解释了如何利用函数作为一等对象的本质,以获得巨大的好处。但首先,让我们看看我们可以用对象执行的一些操作。在 JavaScript 中,对象享有某些能力:

  • 它们可以通过字面量创建:{}

  • 它们可以被分配给变量、数组条目和其他对象的属性:

  • 它们可以作为参数传递给函数:

  • 它们可以作为函数的返回值:

  • 它们可以拥有可以动态创建和分配的属性:

结果表明,与许多其他编程语言不同,在 JavaScript 中,我们可以用函数做几乎完全相同的事情。

3.1.1. 函数作为一等对象

JavaScript 中的函数具有所有对象的能力,因此它们在语言中像任何其他对象一样被对待。我们说函数是一等对象,它们也可以被

  • 通过字面量创建

    function ninjaFunction() {}
    
  • 分配给变量、数组条目和其他对象的属性

  • 作为参数传递给其他函数

  • 函数返回的值

  • 它们可以拥有可以动态创建和分配的属性:

我们可以用函数做任何可以用对象做的事情。函数就是对象,只是具有额外的、特殊的能力,即可调用性:函数可以被调用或调用以执行某个操作。

JavaScript 中的函数式编程

将函数作为一等对象是迈向函数式编程的第一步,这是一种专注于通过组合函数(而不是像主流的命令式编程那样指定一系列步骤)来解决问题的编程风格。函数式编程可以帮助我们编写更容易测试、扩展和模块化的代码。但这是一个很大的主题,在这本书中我们只是简要地提到了它(例如,在第九章[kindle_split_021.html#ch09])。如果你对学习如何利用函数式编程的概念并将其应用于 JavaScript 程序感兴趣,我们推荐 Luis Atencio 的《JavaScript 函数式编程》(Manning,2016),可在www.manning.com/books/functional-programming-in-javascript找到。

一等对象的一个特点是它们可以作为参数传递给函数。在函数的情况下,这意味着我们传递一个函数作为参数给另一个函数,该函数可能在应用程序执行过程中的某个时刻调用传入的函数。这是一个更一般概念——回调函数——的例子。让我们探讨这个重要概念。

3.1.2. 回调函数

每当我们设置一个函数以便在以后的时间被调用,无论是浏览器在事件处理阶段还是其他代码,我们都是在设置一个回调。这个术语来源于我们正在建立一个函数,其他代码将在适当的执行点“回调”这个函数。

回调是有效使用 JavaScript 的一个基本部分,我们敢打赌你已经在你的代码中大量使用了它们——无论是执行按钮点击时的代码、从服务器接收数据,还是动画化 UI 的某些部分。

在本节中,我们将探讨如何使用回调来处理事件或轻松地对集合进行排序——这是回调在现实世界中使用的典型例子。但这有点复杂,所以在深入之前,让我们将回调概念完全剥离开来,以最简单的形式来审视它。我们将从一个接受另一个函数引用作为参数并作为回调调用该函数的无用函数的例子开始:

function useless(ninjaCallback) {
  return ninjaCallback();
}

尽管这个函数毫无用处,但它展示了将函数作为参数传递给另一个函数,并随后通过传递的参数调用该函数的能力。

我们可以使用以下列表中的代码来测试这个无用的函数。

列表 3.1. 一个简单的回调示例

图片

在这个列表中,我们使用自定义的 report 函数在代码执行过程中输出几条消息,这样我们就可以跟踪程序的执行。我们还使用了我们在第一章中提到的 assert 测试函数。assert 函数通常接受两个参数。第一个参数是一个断言前提的表达式。在这种情况下,我们想要确定调用我们的 useless 函数并传递参数 getText 返回的值是否等于变量 text 的值(useless(getText) === text)。如果第一个参数评估为 true,则断言通过;否则,被视为失败。第二个参数是相关的消息,通常与适当的通过/失败指示器一起记录。(附录 C 讨论了测试的一般情况,以及我们自己的 assertreport 函数的小型实现)。

当我们运行这段代码时,最终得到图 3.1 中所示的结果。如图所示,使用我们的 getText 回调函数作为参数调用 useless 函数返回了预期的值。

图 3.1. 运行列表 3.1 中代码的结果

图片

我们还可以查看这个简单的回调示例是如何具体执行的。如图 3.2(#ch03fig02)所示,我们将 getText 函数作为参数传递给 useless 函数。这意味着在 useless 函数的体内,可以通过 callback 参数引用 getText 函数。然后,通过调用 callback(),我们导致 getText 函数的执行;我们作为参数传递的 getText 函数被 useless 函数回调。

图 3.2. 执行 useless(getText) 调用时的执行流程。useless 函数以 getText 作为参数被调用。在 useless 函数的体内有一个对传入函数的调用,这在本例中触发了 getText 函数的执行(我们“回调”到 getText 函数)。

图片

这很简单,因为 JavaScript 的函数式特性让我们可以像处理一等对象一样处理函数。我们甚至可以更进一步,通过以下方式重写我们的代码:

图片

JavaScript 最重要的特性之一是能够在代码的任何位置创建函数,只要表达式可以出现的地方。除了使代码更加紧凑和易于理解(通过将函数定义放置在它们被使用的地方附近),这个特性还可以在函数不会在代码的多个地方被引用时,消除污染全局命名空间中不必要的名称的需求。

在先前的回调示例中,我们调用了自己的回调。但回调也可以由浏览器调用。回想一下第二章,其中有一个包含以下片段的示例:

document.body.addEventListener("mousemove", function() {
  var second = document.getElementById("second");
  addMessage(second, "Event: mousemove");
});

这也是一个回调函数,它被定义为mousemove事件的处理器,并且当该事件发生时,浏览器会调用它。

注意

本节介绍了回调函数作为其他代码将在适当的执行点“回调”的函数。您已经看到了一个例子,其中我们的代码立即调用了提供的回调(useless函数示例),以及一个例子,其中浏览器在特定事件发生时进行调用(mousemove示例)。重要的是要注意,与我们的不同,有些人认为回调必须异步调用,因此第一个例子并不是真正的回调。我们只是提到这一点,以防您遇到一些热烈的讨论。

现在让我们考虑一个回调的使用,这将极大地简化我们排序集合的方式。

使用比较器进行排序

几乎在我们拥有数据集合的同时,我们很可能会需要对其进行排序。假设我们有一个随机排序的数字数组:0, 3, 2, 5, 7, 4, 8, 1。这种顺序可能很好,但很可能会在某个时候想要重新排列它。

通常,实现排序算法并不是编程任务中最简单的;我们必须选择最适合当前任务的算法,实现它,适应我们的当前需求(以便项目按特定顺序排序),并且要小心不要引入错误。在这些任务中,唯一与特定应用相关的就是排序顺序。幸运的是,所有 JavaScript 数组都可以访问sort方法,它只需要我们定义一个比较算法,告诉排序算法如何对值进行排序。

这就是回调介入的地方!我们不会让排序算法决定哪些值应该排在其他值之前,我们将提供一个执行比较的函数。我们将给排序算法提供访问这个函数作为回调的权限,算法将在需要比较时调用回调。回调预期返回一个正数,如果传递的值的顺序应该被反转,返回一个负数,如果不应该反转,返回零,如果值相等;从比较的值中减去产生所需的返回值以对数组进行排序:

var values = [0, 3, 2, 5, 7, 4, 8, 1];

values.sort(function(value1, value2){
  return value1 - value2;
});

没有必要考虑排序算法的低级细节(甚至不需要选择哪种排序算法)。我们提供了一个回调函数,JavaScript 引擎将在需要比较两个项目时调用它。

功能性方法允许我们创建一个作为独立实体的函数,就像我们可以创建任何其他对象类型一样,并且可以将它作为参数传递给一个方法,就像任何其他对象类型一样,该方法可以将其作为参数接受,就像任何其他对象类型一样。这就是一等公民地位发挥作用的地方。

3.2. 函数作为对象的乐趣

在本节中,我们将探讨利用函数与其他对象类型共享的相似性的方法。一个可能令人惊讶的能力是,没有任何阻止我们将属性附加到函数上:

图片描述

让我们看看使用这种能力可以做的几件更有趣的事情:

  • 将函数存储在集合中使我们能够轻松管理相关的函数——例如,当发生某些感兴趣的事情时必须调用的回调函数。

  • 记忆化允许函数记住之前计算过的值,从而提高后续调用的性能。

让我们开始吧。

3.2.1. 存储函数

在某些情况下(例如,当我们需要管理在发生特定事件时应该调用的回调函数集合时),我们希望存储一组独特的函数。向此类集合添加函数时,我们可能面临的一个挑战是确定哪些函数是集合中的新函数,应该添加,哪些函数已经存在,不应该添加。通常,在管理回调函数集合时,我们不希望有任何重复,因为单个事件会导致对同一回调函数的多次调用。

一种明显但天真技术是将所有函数存储在数组中,然后遍历数组,检查重复的函数。不幸的是,这表现不佳,作为一个忍者,我们希望事情能够良好地工作,而不仅仅是工作。我们可以使用函数属性以适当的复杂度实现这一点,如下一列表所示。

列表 3.2. 存储一组独特的函数

图片描述

在这个列表中,我们创建了一个对象,分配给变量store,我们将在这个对象中存储一组独特的函数。这个对象有两个数据属性:一个用于存储下一个可用的id值,另一个用于在其中缓存存储的函数。函数通过add()方法添加到这个缓存中:

add: function(fn) {
  if (!fn.id) {
    fn.id = this.nextId++;
    this.cache[fn.id] = fn;
    return true;
  }
 ...

add函数内部,我们首先检查函数是否已经被添加到集合中,通过查找id属性的存在来确认。如果当前函数有一个id属性,我们假设该函数已经被处理,并忽略它。否则,我们将一个id属性分配给函数(同时递增nextId属性),并将函数作为cache的属性添加,使用id值作为属性名。然后我们返回值true,这样我们就可以在调用add()之后知道函数何时被添加。

在浏览器中运行页面显示,当我们的测试尝试两次添加ninja()函数时,函数只被添加了一次,如图 3.3 所示。第九章展示了利用集合和 ES6 中可用的新类型对象集的更佳技术来处理唯一项集合。

图 3.3。通过将一个属性附加到一个函数上,我们可以跟踪它。这样,我们可以确保我们的函数只被添加了一次。

当使用函数属性时,我们可以从袖子里拿出另一个有用的技巧,那就是给函数赋予修改自己的能力。这项技术可以用来记住之前计算过的值,在未来的计算中节省时间。

3.2.2. 自记忆化函数

如前所述,记忆化(不,这不是一个打字错误)是构建一个能够记住之前计算值的函数的过程。简而言之,每当函数计算其结果时,我们都会将结果存储在函数参数旁边。这样,当另一个调用发生,并且参数相同,我们可以返回之前存储的结果,而不是重新计算。这可以通过避免已经执行的无用复杂计算来显著提高性能。记忆化在执行动画计算、搜索不经常变化的数据或任何耗时数学计算时特别有用。

例如,让我们看看一个简单(当然,也绝不是特别高效)的算法,用于计算素数。虽然这是一个复杂计算的简单示例,但这种技术可以轻松应用于其他复杂的计算(例如,计算字符串的 MD5 散列),而这些计算过于复杂,无法在此展示。

从外部看,这个函数看起来就像任何正常函数一样,但我们将秘密地构建一个答案缓存,其中函数将保存它执行的计算的答案。请查看以下代码。

列表 3.3. 记忆化之前计算过的值

isPrime函数内部,我们首先检查我们将用作缓存的answers属性是否已经创建,如果没有,我们创建它:

if (!isPrime.answers) {
    isPrime.answers = {};
}

这个最初为空的对象的创建只会发生在对函数的第一次调用中;之后,缓存将存在。

然后我们检查传递的值的计算结果是否已经缓存在了answers中:

if (isPrime.answers[value] !== undefined) {
  return isPrime.answers[value];
}

在这个缓存中,我们将使用value作为属性键来存储计算出的答案(truefalse)。如果我们找到缓存的答案,我们就返回它。

如果找不到缓存的值,我们将继续执行所需的计算以确定该值是否为质数(对于较大的值,这可能是一项昂贵的操作),并在返回时将其结果存储在缓存中:

return isPrime.answers[value] = prime;

我们的缓存是函数本身的属性,因此只要函数本身存在,它就会保持活跃。

最后,我们测试一下缓存是否正常工作!

assert(isPrime(5), "5 is prime!" );
assert(isPrime.answers[5], "The answer was cached!" );

这种方法有两个主要优点:

  • 最终用户在请求之前已计算过的函数值时,会享受到性能上的好处。

  • 这一切都在幕后无缝进行;既不需要最终用户也不需要页面作者执行任何特殊请求或进行任何额外的初始化,以便使其正常工作。

但并非一切都如此美好;它的缺点可能需要权衡其优点:

  • 任何形式的缓存都会牺牲内存以换取性能。

  • 纯粹主义者可能会认为缓存是一个不应该与业务逻辑混合的问题;一个函数或方法应该只做一件事,并且做好。但别担心;在第八章中,你将看到如何处理这种抱怨。

  • 对于这种类型的算法,很难进行负载测试或测量其性能,因为我们的结果取决于函数的先前输入。

现在你已经看到了一些一等函数的实际用例,让我们来探讨定义函数的各种方式。

3.3. 定义函数

JavaScript 函数通常通过使用函数字面量来定义,它以与例如,数字字面量创建数字值相同的方式创建函数值。记住,作为一等对象,函数是可以在语言中使用,就像其他值一样,例如字符串和数字。而且无论你是否意识到,你一直在这样做。

JavaScript 提供了几种定义函数的方法,可以分为四组:

  • 函数声明函数表达式——定义函数最常见且细微不同的两种方式。人们通常甚至不把它们视为不同的,但正如你将看到的,了解它们之间的差异可以帮助我们理解何时我们的函数可以调用:

    function myFun(){ return 1;}
    
  • 箭头函数(通常称为lambda 函数)——JavaScript 标准中最近添加的 ES6 功能,使我们能够用更少的语法冗余来定义函数。它们甚至解决了回调函数的一个常见问题,但关于这一点稍后会更详细地讨论:

    myArg => myArg*2
    
  • 函数构造函数— 一种不太常用的定义函数的方式,使我们能够从字符串动态构造一个新函数,该字符串也可以动态生成。以下示例动态创建了一个具有两个参数ab的函数,该函数返回这两个参数的和:

    new Function('a', 'b', 'return a + b')
    
  • 生成器函数— 这是 JavaScript 中 ES6 的添加,使我们能够创建函数,与普通函数不同,可以在应用程序执行过程中退出并在稍后重新进入,同时保持这些变量在这些重新进入中的值。我们可以定义函数声明函数表达式函数构造函数的生成器版本:

    function* myGen(){ yield 1; }
    

理解这些差异非常重要,因为函数的定义方式显著影响函数何时可以被调用以及它的行为,以及可以在哪个对象上调用函数。

在本章中,我们将探讨函数声明、函数表达式和箭头函数。你将学习它们的语法以及它们是如何工作的,并且我们将在整本书中多次回到它们,以探讨它们的细节。另一方面,生成器函数相当独特,与标准函数有显著的不同。我们将在第六章中详细回顾它们。第六章。

这就留下了函数构造函数,这是一个 JavaScript 特性,我们将完全跳过。尽管它有一些有趣的应用,尤其是在动态创建和评估代码时,但我们认为它是 JavaScript 语言的边缘特性。如果你想了解更多关于函数构造函数的信息,请访问mng.bz/ZN8e

让我们从最简单、最传统的方式来定义函数开始:函数声明函数表达式

3.3.1. 函数声明和函数表达式

在 JavaScript 中定义函数的两种最常见的方式是使用函数声明和函数表达式。这两种技术非常相似,以至于我们经常甚至不区分它们,但正如你将在以下章节中看到的,存在细微的差异。

函数声明

在 JavaScript 中定义函数最基本的方式是使用函数声明(参见图 3.4)。正如你所看到的,每个函数声明都以一个强制性的function关键字开始,后面跟着一个强制性的函数名和一个可选的、用强制性的括号括起来的逗号分隔的参数名列表。函数体,一个可能为空的状态列表,必须被一个开括号和一个闭括号包围。除了这个每个函数声明都必须满足的形式之外,还有一个额外的条件:函数声明必须单独放置,作为一个独立的 JavaScript 语句(但可以包含在其他函数或代码块中;你将在下一节中看到我们确切的意思)。

图 3.4. 函数声明独立地站立,作为一个单独的 JavaScript 代码块!(它可以包含在其他函数中。)

图片

下面的列表展示了几个函数声明的例子。

列表 3.4. 函数声明的例子

图片

如果你仔细观察,你会发现一些你可能不习惯的东西,如果你没有太多接触过函数式语言:在另一个函数内部定义的函数!

function ninja() {
  function hiddenNinja() {
    return "ninja here";
  }
  return hiddenNinja();
}

在 JavaScript 中,这是完全正常的,我们在这里再次强调函数在 JavaScript 中的重要性。

注意

在其他函数中包含函数可能会引起一些关于作用域和标识符解析的棘手问题,但先暂时放下,因为我们将在第五章中详细回顾这个案例。

函数表达式

正如我们多次提到的,JavaScript 中的函数是一等对象,这意味着它们可以通过字面量创建,分配给变量和属性,并用作其他函数的参数和返回值。因为函数是如此基本的构造,JavaScript 允许我们将它们视为任何其他表达式。所以,就像我们可以使用数字字面量一样

var a = 3;
myFunction(4);

同样,我们也可以在相同的位置使用函数字面量。

var a = function() {};
myFunction(function(){});

这种始终是另一个语句一部分的函数(例如,作为赋值表达式的右侧,或作为另一个函数的参数)被称为函数表达式。函数表达式很棒,因为它们允许我们在需要的地方精确地定义函数,从而使得我们的代码更容易理解。

下面的列表显示了函数声明和函数表达式的区别。

列表 3.5. 函数声明和函数表达式

图片

这个示例代码从一个标准的函数声明开始,其中包含另一个内部函数声明:

function myFunctionDeclaration(){
  function innerFunction() {}
}

这里你可以看到函数声明是如何作为 JavaScript 代码的独立语句,但可以包含在其他函数的体内的。

相比之下,函数表达式始终是另一个语句的一部分。它们放置在表达式级别,作为变量声明(或赋值)的右侧:

var myFunc = function(){};

或者作为另一个函数调用的参数,或者作为函数的返回值:

myFunc(function() {
  return function(){};
});

除了它们在代码中的位置,函数声明和函数表达式之间还有一个区别:对于函数声明,函数名称是必需的,而对于函数表达式,它是完全可选的

函数声明必须有一个已定义的名称,因为它们是独立的。因为函数的一个基本要求是它必须可调用,我们必须有一种方式来引用它,而唯一的方法是通过它的名称。

另一方面,函数表达式是其他 JavaScript 表达式的部分,因此我们有其他方法来调用它们。例如,如果一个函数表达式被分配给一个变量,我们可以使用该变量来调用函数:

var doNothing = function(){};
doNothing();

或者,如果它是另一个函数的参数,我们可以在该函数内部通过匹配的参数名来调用它:

function doSomething(action) {
  action();
}
立即函数

函数表达式甚至可以被放置在最初看起来有点奇怪的位置,例如在我们通常期望函数标识符的位置。让我们停下来仔细看看这个结构(见图 3.5)。

图 3.5. 标准函数调用与对函数表达式的立即调用的比较

当我们想要进行函数调用时,我们使用一个求值结果为函数的表达式,后面跟着一对函数调用括号,其中可能包含参数。在最基本的函数调用中,我们放置一个求值结果为函数的标识符,就像图 3.5 的左侧所示。但是调用括号左侧的表达式不一定是简单的标识符;它可以是任何求值结果为函数的表达式。例如,指定一个求值结果为函数的表达式的一个简单方法就是使用函数表达式。所以图 3.5 的右侧,我们首先创建一个函数,然后立即调用这个新创建的函数。顺便说一句,这被称为立即调用的函数表达式(IIFE),或简称为立即函数,它是 JavaScript 开发中的一个重要概念,因为它允许我们在 JavaScript 中模拟模块。我们将在第十一章中关注 IIFE 的这种应用。

函数表达式周围的括号

还有一件事可能让你对我们在函数表达式周围立即调用函数的方式感到困扰:函数表达式本身的括号。我们为什么需要这些括号呢?原因纯粹是语法上的。JavaScript 解析器必须能够轻松区分函数声明和函数表达式。如果我们省略函数表达式周围的括号,并将立即调用作为一个单独的语句function(){}(3),JavaScript 解析器将开始处理它,并得出结论,因为它是一个以关键字function开始的单独语句,所以它正在处理一个函数声明。因为每个函数声明都必须有一个名称(而在这里我们没有指定一个),将会抛出一个错误。为了避免这种情况,我们将函数表达式放在括号内,向 JavaScript 解析器发出信号,表明它正在处理一个表达式,而不是一个语句。

此外,还有一种更简单的方法(虽然奇怪,但使用频率较低)可以达到相同的目的:(function(){}(3))。通过将立即执行函数定义和调用包裹在括号内,你也可以通知 JavaScript 解析器它正在处理一个表达式。

列表 3.5 中的最后四个表达式是立即调用函数表达式(IIFE)的变体,这种表达式在许多 JavaScript 库中经常可以看到:

+function(){}();
-function(){}();
!function(){}();
~function(){}();

这次,我们不再使用括号来区分函数表达式和函数声明,而是可以使用一元运算符:+-!~。我们这样做是为了向 JavaScript 引擎发出信号,表明它正在处理表达式而不是语句。注意应用这些一元运算符的结果并没有被存储在任何地方;从计算的角度来看,它们并不重要;只有对 IIFE 的调用才是重要的。

现在我们已经研究了 JavaScript 中定义函数的两种最基本的方法(函数声明和函数表达式)的细节,让我们探索 JavaScript 标准中的一个新特性:箭头函数

3.3.2. 箭头函数

注意

箭头函数是 JavaScript 标准中 ES6 的一个新增特性(关于浏览器兼容性,请参阅 mng.bz/8bnH)。

因为在我们的 JavaScript 中使用了 很多 函数,所以添加一些语法糖,使我们能够以更短、更简洁的方式创建函数,从而让我们的开发生活更加愉快,这是有意义的。

在很多方面,箭头函数是函数表达式的一种简化。让我们回顾一下本章第一部分中的排序示例:

var values = [0, 3, 2, 5, 7, 4, 8, 1];
values.sort(function(value1,value2){
  return value1 – value2;
});

这个例子使用了一个回调函数表达式,它被发送到数组对象的 sort 方法;这个回调将由 JavaScript 引擎调用,以按降序对数组的值进行排序。

现在,让我们看看如何使用箭头函数做完全相同的事情:

var values = [0, 3, 2, 5, 7, 4, 8, 1];
values.sort((value1,value2) => value1 – value2);

看看这有多简洁?

没有由 function 关键字、花括号或 return 语句引起的杂乱。与函数表达式相比,箭头函数以一种更简单的方式声明:这是一个接受两个参数并返回它们差值的函数。注意新操作符 => 的引入,即所谓的 粗箭头 操作符(一个等于号紧跟着一个大于号),这是定义箭头函数的核心。

现在让我们分解箭头函数的语法,从最简单的方式开始:

param => expression

这个箭头函数接受一个参数并返回一个表达式的值。我们可以使用以下示例中的语法来使用这种语法。

列表 3.6. 比较箭头函数和函数表达式

仔细欣赏箭头函数如何使代码更加简洁,同时不失清晰。这是箭头函数语法的最简单版本,但通常,箭头函数可以用两种方式定义,如图 3.6 所示。

图 3.6。箭头函数的语法

如您所见,箭头函数的定义从可选的逗号分隔的参数名列表开始。如果没有参数,或者参数超过一个,这个列表必须用括号括起来。但是,如果我们只有一个参数,括号是可选的。这个参数列表后面跟着一个强制性的粗箭头操作符,它告诉我们和 JavaScript 引擎我们正在处理一个箭头函数。

在粗箭头操作符之后,我们有两种选择。如果是一个简单函数,我们就在那里放一个表达式(一个数学运算,另一个函数调用,等等),函数调用的结果将是该表达式的值。例如,我们的第一个箭头函数示例具有以下箭头函数:

var greet = name => "Greetings " + name;

函数的返回值是字符串“Greetings”与name参数值的连接。

在其他情况下,当我们的箭头函数不那么简单且需要更多代码时,我们可以在箭头操作符之后包含一段代码块。例如:

var greet = name => {
  var helloString = 'Greetings ';
  return helloString + name;
};

在这种情况下,箭头函数的返回值行为与标准函数相同。如果没有返回语句,函数调用的结果将是undefined,如果有,结果将是返回表达式的值。

我们将在整本书中多次回顾箭头函数。在众多其他事情中,我们将展示箭头函数的附加功能,这将帮助我们避免与更标准函数相关的微妙错误。

箭头函数,像所有其他函数一样,可以通过传递参数来接收它们以执行其任务。让我们看看我们传递给函数的值会发生什么。

3.4。参数和函数参数

讨论函数时,我们经常几乎互换使用术语参数参数,好像它们是或多或少相同的东西。但现在,让我们更加正式:

  • 参数是我们将作为函数定义一部分列出的变量。

  • 参数是我们调用函数时传递给函数的值。

图 3.7 说明了这种区别。

图 3.7。函数参数和函数参数之间的区别

如您所见,函数参数是在函数定义中指定的,所有类型的函数都可以有参数:

  • 函数声明(skulk函数的ninja参数)

  • 函数表达式(perform-Action函数的personaction参数)

  • 箭头函数(daimyo参数)

另一方面,参数与函数的调用相关联;它们是在函数调用时传递给函数的值:

  • 字符串Hattori被传递为skulk函数的参数。

  • 字符串Oda Nobunaga被传递为rule函数的参数。

  • skulk函数的ninja参数被传递为performAction函数的参数。

当将参数列表作为函数调用的一部分提供时,这些参数将按照指定的顺序分配给函数定义中的参数。第一个参数被分配给第一个参数,第二个参数被分配给第二个参数,依此类推。

如果我们的参数数量与参数数量不同,不会引发错误。JavaScript 对这种情况处理得很好,并按以下方式处理。如果提供的参数多于参数,则“多余的”参数不会被分配给参数名称。例如,请参阅图 3.8。

图 3.8. 参数按照指定的顺序分配给函数参数。多余的参数不会被分配给任何参数。

图 3.8 显示,如果我们调用practice函数并使用practice("Yoshi", "sword", "shadow sword", "katana"),参数Yoshiswordshadow sword将分别分配给参数ninjaweapontechnique。参数katana是一个多余的参数,不会被分配给任何参数。在下一章中,您将看到即使某些参数没有被分配给参数名称,我们仍然有方法访问它们。

另一方面,如果我们有比参数更多的参数,没有对应参数的参数将被设置为undefined。例如,如果我们调用practice("Yoshi"),参数ninja将被分配值Yoshi,而参数weapontechnique将被设置为undefined

处理函数参数与 JavaScript 本身一样古老,但现在让我们来探索 ES6 赋予 JavaScript 的两个新特性:剩余参数和默认参数。

3.4.1. 剩余参数

注意

ES6 标准(为了浏览器兼容性,请参阅mng.bz/3go1)增加了剩余参数。

在我们的下一个例子中,我们将构建一个函数,该函数将第一个参数与剩余参数中的最大值相乘。这可能不是我们应用中特别适用的东西,但它是对函数内处理参数的更多技术的示例。

这可能看起来很简单:我们将获取第一个参数并将其与剩余参数值中的最大值相乘。在 JavaScript 的旧版本中,这需要一些工作(我们将在下一章中探讨)。幸运的是,在 ES6 中,我们不需要跳过任何障碍。我们可以使用剩余参数,如下面的列表所示。

列表 3.7. 使用剩余参数

通过在函数的最后一个命名参数前加上省略号(...),我们将其转换成一个名为剩余参数的数组,它包含所有传入的剩余参数。

function multiMax(first, ...remainingNumbers){
  ...
}

例如,在这种情况下,multiMax 函数使用四个参数被调用:multiMax(3, 1, 2, 3)。在 multiMax 函数体内,第一个参数的值,3,被分配给第一个 multiMax 函数参数,first。因为函数的第二个参数是剩余参数,所以所有剩余的参数(1, 2, 3)都被放置在一个新的数组中:remainingNumbers。然后我们通过按降序排序数组(注意如何简单地更改排序顺序)并选择排序数组中的第一个数字来获取最大的数字,这个数字就是我们的排序数组中的第一个位置。(这远非确定最大数字的最有效方法,但为什么不利用我们在本章早期获得的知识呢?)

注意

只有最后一个函数参数可以是剩余参数。尝试在最后一个参数之前的任何参数前放置省略号将只会带来悲伤,以SyntaxError: parameter after rest parameter的形式。

在下一节中,我们将继续丰富我们的 JavaScript 工具箱,添加额外的 ES6 功能:默认参数。

3.4.2. 默认参数

注意

默认参数由 ES6 标准(为了浏览器兼容性,请参阅mng.bz/wI8w)添加。

许多 Web UI 组件(尤其是 jQuery 插件)可以进行配置。例如,如果我们正在开发一个滑动组件,我们可能希望给我们的用户提供一个选项,指定一个计时器间隔,在此之后一个项目会被另一个项目替换,以及一个在变化发生时使用的动画。同时,也许有些用户并不关心,并且乐于使用我们提供的任何设置。默认参数是这种情况的理想选择!

我们关于滑动组件设置的简单示例只是以下情况的一个具体案例:在这种情况下,几乎所有函数调用都使用特定参数的相同值(注意对几乎的强调)。考虑一个更简单的情况,我们的大多数忍者都习惯于潜行,但柳生却只关心简单的潜行:

function  performAction(ninja, action) {
  return ninja + " " + action;
}
performAction("Fuma", "skulking");
performAction("Yoshi", "skulking");
performAction("Hattori", "skulking");
performAction("Yagyu", "sneaking");

总是重复相同的论点,潜行,仅仅因为柳生固执且拒绝像一名真正的忍者那样行动,这难道不觉得繁琐吗?

在其他编程语言中,这个问题通常通过函数重载(指定具有相同名称但参数集不同的额外函数)来解决。不幸的是,JavaScript 不支持函数重载,所以当过去遇到这种情况时,开发者通常会求助于以下列表。

列表 3.8. 在 ES6 之前处理默认参数

在这里,我们定义了一个 performAction 函数,该函数检查 action 参数的值是否为 undefined(通过使用 typeof 运算符),如果是,则函数将 action 变量的值设置为 skulking。如果 action 参数通过函数调用传递(它不是 undefined),我们保持其值。

注意

typeof 运算符返回一个字符串,指示操作数的类型。如果操作数未定义(例如,如果我们没有为函数参数提供匹配的参数),则返回值是字符串 undefined

这是一个常见的模式,编写起来很繁琐,因此 ES6 标准已经添加了对 默认参数 的支持,如下所示。

列表 3.9. 在 ES6 中处理默认参数

图片

在这里,你可以看到 JavaScript 中默认函数参数的语法。要创建默认参数,我们给函数参数赋值:

function performAction(ninja, action = "skulking"){
   return ninja + " " + action;
}

然后,当我们进行函数调用并且省略了匹配的参数值,就像 FumaYoshiHattori 一样,将使用默认值(在这种情况下,skulking):

assert(performAction("Fuma") === "Fuma skulking",
      "The default value is used for Fuma");

assert(performAction("Yoshi") === "Yoshi skulking",
      "The default value is used for Yoshi");

assert(performAction("Hattori") === "Hattori skulking",
      "The default value is used for Hattori");

如果我们指定了值,则默认值将被覆盖:

assert(performAction("Yagyu", "sneaking") === "Yagyu sneaking",
       "Yagyu can do whatever he pleases, even sneak!");

我们可以将任何值分配给默认参数:简单的原始值,如数字或字符串,也可以是复杂类型,如对象、数组,甚至是函数。这些值在每次函数调用时按从左到右的顺序进行评估,并且在将值分配给后续的默认参数时,我们可以引用前面的参数,如下所示。

列表 3.10. 引用之前的默认参数

图片

尽管 JavaScript 允许你这样做,但我们强烈建议谨慎行事。在我们看来,这并不增强代码的可读性,并且应该尽可能避免。但是,适度使用默认参数——作为避免 null 值的手段,或者作为相对简单的标志来配置我们函数的行为——可以导致更简单、更优雅的代码。

3.5. 摘要

  • 编写复杂的代码取决于将 JavaScript 作为函数式语言来学习。

  • 函数是第一类对象,在 JavaScript 中被当作任何其他对象一样对待。类似于任何其他对象类型,它们可以被

    • 通过字面量创建

    • 分配给变量或属性

    • 作为参数传递

    • 作为函数结果返回

    • 分配属性和方法

  • 回调函数是其他代码将后来“回调”的函数,并且通常在事件处理中使用。

  • 我们可以利用函数可以具有属性,并且这些属性可以用来存储任何信息的事实;例如

    • 我们可以将函数存储在函数属性中以供以后引用和调用。

    • 我们可以使用函数属性来创建缓存(记忆化),从而避免不必要的计算。

  • 有不同类型的函数:函数声明、函数表达式、箭头函数和函数生成器。

  • 函数声明和函数表达式是两种最常见的函数类型。函数声明必须有一个名称,并且必须作为单独的语句放置在我们的代码中。函数表达式不需要命名,但必须是另一个代码语句的一部分。

  • 箭头函数是 JavaScript 的新增功能,它使我们能够以比标准函数更简洁的方式定义函数。

  • 参数是我们将作为函数定义一部分列出的变量,而参数是我们调用函数时传递给函数的值。

  • 函数的参数列表和其参数列表可以有不同的长度:

    • 未分配的参数评估为undefined

    • 额外的参数不会绑定到参数名称。

  • 休息参数和默认参数是 JavaScript 的新增功能:

    • 休息参数使我们能够引用没有匹配参数名称的剩余参数。

    • 默认参数使我们能够指定默认参数值,当在函数调用期间未提供值时将使用这些值。

3.6. 练习

1

在以下代码片段中,哪些是回调函数?

numbers.sort(function sortAsc(a,b){
  return a – b;
});

function ninja(){}
ninja();

var myButton = document.getElementById("myButton");
myButton.addEventListener("click", function handleClick(){
  alert("Clicked");
});

2

在以下代码片段中,根据其类型(函数声明、函数表达式或箭头函数)对函数进行分类。

numbers.sort(function sortAsc(a,b){
  return a – b;
});

numbers.sort((a,b) => b – a);

(function(){})();

function outer(){
  function inner(){}
  return inner;
}

(function(){}());

(()=>"Yoshi")();

3

执行以下代码片段后,变量samuraininja的值是什么?

var samurai = (() => "Tomoe")();
var ninja = (() => {"Yoshi"})();

4

test函数体内部,对于两次函数调用,参数abc的值是什么?

function test(a, b, ...c){ /*a, b, c*/}

test(1, 2, 3, 4, 5);
test();

5

执行以下代码片段后,message1message2变量的值是什么?

function getNinjaWieldingWeapon(ninja, weapon = "katana"){
  return ninja + " " + katana;
}

var message1 = getNinjaWieldingWeapon("Yoshi");
var message2 = getNinjaWieldingWeapon("Yoshi", "wakizashi");

第四章. 适合工匠的函数:理解函数调用

本章涵盖

  • 两个隐式函数参数:arguments 和 this

  • 调用函数的方法

  • 处理函数上下文的问题

在上一章中,您了解到 JavaScript 是一种具有显著功能导向特性的编程语言。我们探讨了函数调用参数和函数参数之间的区别,以及值是如何从调用参数传递到函数参数的。

本章继续以类似的方式展开,首先讨论我们在上一章中保留的内容:隐式函数参数thisarguments。这些参数被静默传递给函数,并且可以在函数体内部像任何其他显式命名的函数参数一样访问。

this 参数代表函数上下文,即我们的函数被调用的对象,而 arguments 参数代表通过函数调用传入的所有参数。这两个参数在 JavaScript 代码中都非常重要。this 参数是面向对象 JavaScript 的基本成分之一,而 arguments 参数使我们能够对函数接受的参数进行创造性使用。因此,我们将探讨一些与这些隐含参数相关的常见陷阱。

然后,我们将继续探讨在 JavaScript 中调用函数的方法。我们调用函数的方式对隐含函数参数的确定有很大影响。

最后,我们将通过学习与函数上下文、this 参数相关的常见问题来结束本章。无需多言,让我们开始探索吧!

你知道吗?

Q1:

为什么 this 参数被称为函数 上下文

Q2:

函数和方法之间的区别是什么?

Q3:

如果构造函数明确返回一个对象会发生什么?

4.1. 使用隐含函数参数

在上一章中,我们探讨了函数 参数(作为函数定义一部分列出的变量)和函数 参数(当我们调用函数时传递给函数的值)之间的区别。但我们没有提到,除了我们在函数定义中明确声明的参数之外,函数调用通常还传递两个隐含参数:argumentsthis

通过 隐含的,我们是指这些参数没有在函数签名中明确列出,但它们被默默地传递给函数,并在函数内部可访问。它们可以在函数内部像任何其他明确命名的参数一样被引用。让我们依次查看这些隐含参数。

4.1.1. 参数

arguments 参数是传递给函数的所有参数的集合。它很有用,因为它允许我们访问所有函数参数,无论匹配的参数是否明确定义。这使我们能够实现函数重载,这是 JavaScript 本身不支持的功能,以及接受可变数量参数的变长函数。说实话,由于上一章引入的 rest 参数,arguments 参数的需要已经大大减少。然而,了解 arguments 参数的工作方式仍然很重要,因为当你处理遗留代码时,你很可能会遇到它。

arguments 对象有一个名为 length 的属性,表示参数的确切数量。可以通过数组索引表示法获取单个参数值;例如,arguments[2] 将获取第三个参数。请看下面的列表。

列表 4.1. 使用参数

这里有一个 whatever 函数,它被调用了五个参数,whatever (1,2,3,4,5),尽管它只声明了三个参数,a, b, c

function whatever(a, b, c){
  ...
}

我们可以通过各自的功能参数 abc 访问前三个参数:

assert(a === 1, 'The value of a is 1');
assert(b === 2, 'The value of b is 2');
assert(c === 3, 'The value of c is 3');

我们还可以通过使用 arguments.length 属性来检查传递给函数的总参数数。

arguments 参数也可以用来通过数组表示法访问每个单独的参数。重要的是要注意,这也包括与任何函数参数都不相关的多余参数:

assert(arguments[0] === a, 'The first argument is assigned to a');
assert(arguments[1] === b, 'The second argument is assigned to b');
assert(arguments[2] === c, 'The third argument is assigned to c');
assert(arguments[3] === 4, 'We can access the fourth argument');
assert(arguments[4] === 5, 'We can access the fifth argument');

在本节中,我们特意避免将 arguments 参数称为 数组。你可能会被误导,认为它是一个数组;毕竟,它有一个 length 参数,并且可以使用数组表示法获取其条目。但它 不是 一个 JavaScript 数组,如果你尝试在 arguments 上使用数组方法(例如,在上一章中使用的 sort 方法),你会发现只有失望和心碎。只需将 arguments 视为一个 类似数组 的结构,并在使用时保持克制。

正如我们之前提到的,arguments 对象的主要目的是允许我们访问传递给函数的所有参数,无论特定参数是否与函数参数相关联。让我们通过实现一个可以计算任意数量参数的总和的函数来了解如何做到这一点。

列表 4.2. 使用参数对象对所有函数参数进行操作

在这里,我们首先定义了一个 sum 函数,它没有明确列出任何参数。尽管如此,我们仍然可以通过 arguments 对象访问所有函数参数。我们遍历所有参数并计算它们的总和。

现在是回报的时候了。我们可以用任意数量的参数调用函数,所以我们对几个案例进行了测试,看看是否一切正常。这是 arguments 对象的真正力量。它允许我们编写更灵活、更通用的函数,这些函数可以轻松处理不同的情况。

注意

我们之前提到,在许多情况下,我们可以使用 rest 参数而不是 arguments 参数。rest 参数是一个真正的数组,这意味着我们可以使用所有我们喜欢的数组方法来操作它。这使它在某种程度上优于 arguments 对象。作为一个练习,将 列表 4.2 重写为使用 rest 参数而不是 arguments 参数。

现在我们已经了解了 arguments 对象的工作原理,让我们来探讨一些它的陷阱。

参数对象作为函数参数的别名

arguments 参数有一个奇特的功能:它将函数参数作为别名。如果我们为例如 arguments[0] 设置一个新值,第一个参数的值也会改变。请看以下列表。

列表 4.3. 参数对象别名函数参数

你可以看到 arguments 对象是如何作为函数参数的代理的。我们定义了一个函数 infiltrate,它接受一个参数 person,并用参数 gardener 调用它。我们可以通过函数参数 personarguments 对象访问值 gardener

assert(person === 'gardener', 'The person is a gardener');
assert(arguments[0] === 'gardener', 'The first argument is a gardener');

因为 arguments 对象是函数参数的代理,如果我们改变 arguments 对象,这种改变也会反映在匹配的函数参数上:

arguments[0] = 'ninja';

assert(person === 'ninja', 'The person is a ninja now');
assert(arguments[0] === 'ninja', 'The first argument is a ninja');

同样的情况也适用于另一个方向。如果我们改变一个参数,这种改变可以在参数和 arguments 对象中观察到:

person = 'gardener';

assert(person === 'gardener',
    'The person is a gardener once more');
assert(arguments[0] === 'gardener',
    'The first argument is a gardener again');
避免代理

通过 arguments 对象代理函数参数的概念可能会令人困惑,因此 JavaScript 提供了一种通过使用 严格模式 来退出该模式的方法。

严格模式

严格模式是 JavaScript ES5 的一个新增功能,它改变了 JavaScript 引擎的行为,使得错误被抛出而不是静默地捕获。一些语言特性的行为发生了变化,甚至一些不安全的语言特性被完全禁止(关于这一点稍后还会提到)。严格模式改变的事情之一是它禁用了 arguments 代理。

像往常一样,让我们看看一个简单的例子。

列表 4.4. 使用严格模式避免参数代理

我们首先将简单的字符串 use strict 作为代码的第一行。这告诉 JavaScript 引擎我们想要以严格模式执行以下代码。在这个例子中,严格模式改变了我们程序的含义,使得 person 参数和第一个 argument 以相同的值开始:

assert(person === 'gardener', 'The person is a gardener');
assert(arguments[0] === 'gardener', 'The first argument is a gardener');

但是,与非严格模式不同,这次 arguments 对象并没有代理参数。如果我们改变第一个参数的值,arguments[0] = 'ninja',第一个参数的值会改变,但 person 参数不会:

assert(arguments[0] === 'ninja', 'The first argument is now a ninja');
assert(person === 'gardener', 'The person is still a gardener');

我们将在本书的后面部分重新访问 arguments 对象,但到目前为止,让我们专注于另一个隐式参数:this,它在某些方面甚至更有趣。

4.1.2. this 参数:介绍函数上下文

当一个函数被调用时,除了在函数调用中提供的显式参数之外,还会传递一个名为 this 的隐式参数给函数。this 参数是面向对象 JavaScript 中的一个重要组成部分,它指向与函数调用相关联的对象。因此,它通常被称为 函数上下文

函数上下文是一个概念,那些来自像 Java 这样的面向对象语言的人可能会认为他们理解。在这些语言中,this 通常指向定义方法所在的类的实例。

但要小心!正如我们很快就会看到的,在 JavaScript 中,将函数作为方法调用只是函数被调用的方式之一。而且,实际上,this参数指向的内容并不是(如在 Java 或 C#中)仅由函数的定义方式和位置决定的;它还可以受到函数调用方式的影响。因为理解this参数的确切性质是面向对象 JavaScript 最重要的支柱之一,我们将探讨调用函数的各种方式。你会发现它们之间的一个主要区别在于this值的确定方式。然后,在接下来的几个章节中,我们将详细研究函数上下文,所以如果事情一开始没有完全弄清楚,请不要担心。

现在我们将详细地看看函数是如何被调用的。

4.2. 调用函数

我们都调用过 JavaScript 函数,但你有没有停下来想过当函数被调用时实际上会发生什么?实际上,函数被调用的方式对函数内部代码的操作方式有巨大的影响,主要是在如何建立this参数、函数上下文方面。这种差异比最初看起来要重要得多。我们将在本节中对其进行研究,并在本书的其余部分利用它来帮助我们提升代码到忍者级别。

我们可以通过四种方式调用一个函数,每种方式都有其独特的细微差别:

  • 作为函数skulk(),其中函数以直接的方式被调用

  • 作为方法ninja.skulk(),将调用与一个对象关联起来,从而实现面向对象编程

  • 作为构造函数new Ninja(),其中创建了一个新的对象

  • 通过函数的 apply call 方法skulk.call(ninja)skulk.apply(ninja)

这里有一些例子:

对于除了callapply方法之外的所有方法,函数调用运算符是一组跟在评估为函数引用的任何表达式后面的括号。

让我们从最简单的形式开始,将函数作为函数调用。

4.2.1. 作为函数的调用

作为函数调用?当然,函数当然是以函数的方式被调用的。认为其他方式是愚蠢的。但在现实中,我们说函数是以“作为函数”的方式被调用的,以区别于其他调用机制:方法、构造函数和apply/call。如果一个函数不是作为方法、构造函数或通过applycall调用,那么它就是作为函数被调用的。

当使用()运算符调用函数,并且该运算符应用的表达式不引用函数作为对象的属性时,会发生这种类型的调用。(在这种情况下,我们将有一个方法调用,但我们将在下一节讨论。)以下是一些简单的例子:

以这种方式调用时,函数上下文(this关键字的值)可以是两件事:在非严格模式中,它将是全局上下文(window对象),而在严格模式中,它将是undefined

以下列表说明了严格模式和非常严格模式之间的行为差异。

列表 4.5. 作为函数的调用

注意

如您所见,严格模式在大多数情况下比非严格模式更直接。例如,当列表 4.5 将函数作为函数调用(而不是作为方法调用)时,它没有指定应该在该对象上调用该函数的对象。因此,我们认为将this关键字设置为undefined(如在严格模式中)比在非严格模式中的全局window对象更有意义。一般来说,严格模式解决了许多这些小的 JavaScript 怪异之处。(还记得章节开头提到的参数别名吗?)

你可能已经多次编写了这样的代码,但并没有过多思考。现在让我们提高一个档次,看看函数是如何作为方法被调用的。

4.2.2. 作为方法的调用

当一个函数被分配给对象的属性,并且通过引用该属性来调用该函数时,那么该函数就是作为该对象的方法被调用的。以下是一个例子:

var ninja = {};
ninja.skulk = function(){};
ninja.skulk();

好吧;那么这又意味着什么呢?在这种情况下,函数被称为方法,但那又有什么有趣或有用之处呢?嗯,如果你有面向对象背景,你会记得一个方法所属的对象可以在方法的主体中作为this使用。这里也是同样的情况。当我们把一个函数作为对象的方法调用时,该对象成为函数上下文,并且可以通过this参数在函数内部使用。这是 JavaScript 允许编写面向对象代码的主要方法之一。(构造函数是另一个,我们很快就会讨论到。)

让我们在下一个列表中考虑一些测试代码,以说明作为函数调用和作为方法调用之间的差异和相似之处。

列表 4.6. 函数调用和方法调用的差异

这个测试设置了一个名为whatsMyContext的函数,我们将在列表的其余部分中使用它。这个函数唯一做的事情就是返回其函数上下文,这样我们就可以从函数外部看到调用时的函数上下文是什么。(否则,我们将很难知道。)

function whatsMyContext() {
  return this;
}

当我们直接通过名称调用函数时,这是一个将函数作为函数调用的例子,因此我们期望函数上下文将是全局上下文(window),因为我们处于非严格模式。我们断言这是正确的:

assert(whatsMyContext() === window, ...)

然后,我们在一个名为 getMyThis 的变量中创建对函数 whatsMyContext 的引用:var getMyThis = whatsMyContext。这并没有创建函数的第二个实例;它仅仅创建了对同一个函数的引用(你知道,第一类对象和所有)。

当我们通过变量调用函数时——我们可以这样做,因为函数调用操作符可以应用于任何求值结果为函数的表达式——我们再次将函数作为函数调用。因此,我们再次期望函数上下文是 window,并且确实是这样的:

assert(getMyThis() === window,
       "Another function call in window");

现在,事情变得稍微复杂一些,我们在变量 ninja1 中定义了一个名为 getMyThis 的属性,它接收对 whatsMyContext 函数的引用。通过这样做,我们说我们在对象上创建了一个名为 getMyThis方法。我们并没有说 whatsMyContext 已经 成为 ninja1 的一个方法;它并没有。你已经看到 whatsMyContext 是一个独立的函数,可以通过多种方式调用:

var ninja1 = {
  getMyThis: whatsMyContext
};

根据我们之前所说的,当我们通过方法引用调用函数时,我们期望函数上下文是该方法的对象(在这种情况下,ninja1),我们这样断言:

assert(ninja1.getMyThis() === ninja1,
      "Working with 1st ninja");
注意

将函数作为方法调用对于编写面向对象的 JavaScript 至关重要。这样做使得你可以在任何方法中使用 this 来引用该方法的所有权对象——这是面向对象编程中的一个基本概念。

为了强调这一点,我们继续通过创建另一个对象 ninja2 来进行测试,它也具有一个名为 getMyThis 的属性,该属性引用 whatsMyContext 函数。通过 ninja2 对象调用此函数时,我们正确地断言其函数上下文是 ninja2

var ninja2 = {
  getMyThis: whatsMyContext
};

assert(ninja2.getMyThis() === ninja2,
  "Working with 2nd ninja");

即使在整个示例中使用了 相同的 函数——whatsMyContext,但 this 返回的函数上下文会根据 whatsMyContext 的调用方式而改变。例如,完全相同的函数由 ninja1ninja2 共享,但执行时,该函数可以通过调用方法的对象访问并操作对象。我们不需要为不同的对象创建函数的单独副本来执行完全相同的处理。这是面向对象编程的一个原则。

尽管这是一种强大的功能,但在本例中它的使用方式存在局限性。首先,当我们创建两个忍者对象时,我们可以共享同一个函数作为每个对象的方法,但我们必须使用一些重复的代码来设置单独的对象及其方法。

但这并不是绝望的理由——JavaScript 提供了机制,使得从单个模式创建对象比本例中要容易得多。我们将在第七章(kindle_split_019.html#ch07)中深入探讨这些功能。但就目前而言,让我们考虑与函数调用相关的那部分机制:构造函数

4.2.3. 作为构造函数的调用

将要作为构造函数使用的函数没有什么特别之处。构造函数的声明方式与任何其他函数一样,我们可以轻松地使用函数声明和函数表达式来构造新对象。唯一的例外是箭头函数,你将在本章后面看到,它的工作方式略有不同。但无论如何,主要区别在于函数的调用方式。

要将函数作为构造函数调用,我们在函数调用之前加上关键字new。例如,回想一下上一节中的whatsMyContext函数:

function whatsMyContext(){ return this; }

如果我们想将whatsMyContext函数作为构造函数调用,我们写这个:

new whatsMyContext();

尽管我们可以将whatsMyContext作为构造函数调用,但这个函数并不是一个特别有用的构造函数。让我们通过讨论使构造函数特殊的原因来找出原因。

注意

记得在第三章中,我们讨论了定义函数的方法吗?在函数声明、函数表达式、箭头函数和生成器函数中,我们还提到了函数构造器,它使我们能够从字符串中构造新的函数。例如:new Function('a', 'b', 'return a + b')创建了一个具有两个参数ab的新函数,它返回它们的和。请注意不要将这些函数构造器构造函数混淆!区别虽然微妙,但很重要。函数构造器使我们能够从动态创建的字符串中创建函数。另一方面,构造函数,本节的主题,是我们用来创建和初始化对象实例的函数。

构造函数的超级能力

将函数作为构造函数调用是 JavaScript 的一个强大功能,我们将在下面的列表中探讨。

列表 4.7. 使用构造函数设置常见对象

在这个例子中,我们创建了一个名为Ninja的函数,我们将用它来构造,嗯,忍者。当使用关键字new调用时,会创建一个空的对象实例,并将其作为函数上下文(this参数)传递给函数。构造函数在这个对象上创建了一个名为skulk的属性,并将其分配了一个函数,使该函数成为新创建的对象的方法。

通常,当调用构造函数时,会发生一些特殊操作,如图 4.1 所示。使用关键字new调用函数会触发以下步骤:

  1. 一个新的空对象被创建。

  2. 此对象作为this参数传递给构造函数,因此成为构造函数的函数上下文。

  3. 新构造的对象作为new运算符的值返回(但有一个例外,我们很快就会讨论到)。

图 4.1. 当使用关键字new调用函数时,会创建一个新的空对象,并将其设置为构造函数的上下文,即this参数。

最后两点涉及到为什么在new whatsMyContext()中的whatsMyContext使得构造函数变得糟糕。构造函数的目的是创建一个新的对象,对其进行设置,并将其作为构造函数的值返回。任何干扰这一意图的东西都不适合作为构造函数。

让我们考虑一个更合适的构造函数,Ninja,它用于设置潜行的忍者,如列表 4.7 所示:

function Ninja() {
  this.skulk = function() {
    return this;
  };
}

skulk方法执行与前面章节中whatsMyContext相同的操作,返回函数上下文,以便我们可以外部测试它。

构造函数定义后,我们通过两次调用构造函数创建了两个新的Ninja对象。请注意,从调用中返回的值被存储在变量中,这些变量成为新创建的Ninja对象的引用:

var ninja1 = new Ninja();
var ninja2 = new Ninja();

然后我们运行确保每个方法调用都操作预期对象的测试:

assert(ninja1.skulk() === ninja1,
  "The 1st ninja is skulking");
assert(ninja2.skulk() === ninja2,
  "The 2nd ninja is skulking");

那就是全部!现在你知道如何使用构造函数函数创建和初始化新对象了。用关键字new调用函数返回新创建的对象。但让我们检查这是否总是完全正确。

构造函数返回值

我们之前提到,构造函数的目的是初始化新创建的对象,并且新构造的对象是构造函数调用的结果(通过new运算符)。但是当构造函数返回自己的值时会发生什么?让我们在下面的列表中探索这种情况。

列表 4.8. 返回原始值的构造函数

图片

如果我们运行这个列表,我们会看到一切正常。这个Ninja函数返回一个简单的数字1对代码的行为没有显著影响。如果我们像预期的那样将Ninja函数作为函数调用,它返回1;如果我们用关键字new将其作为构造函数调用,就会构建并返回一个新的ninja对象。到目前为止,一切顺利。

但现在让我们尝试做一些不同的事情,一个构造函数函数返回另一个对象,如下面的列表所示。

列表 4.9. 显式返回对象值的构造函数

图片

这个列表采取了一种稍微不同的方法。我们首先创建一个puppet对象,其rules属性设置为false

var puppet = {
  rules: false
};

然后我们定义了一个Emperor函数,它向新构造的对象添加一个rules属性并将其设置为true。此外,Emperor函数有一个怪癖;它返回全局的puppet对象:

function Emperor() {
  this.rules = true;
  return puppet;
}

后来,我们用关键字newEmperor函数作为构造函数调用:

var emperor = new Emperor();

通过这种方式,我们设置了一个模糊的情况:我们得到一个对象传递给构造函数作为函数上下文中的this,我们初始化它,但随后我们返回一个完全不同的puppet对象。哪个对象将统治?

让我们测试它:

assert(emperor === puppet, "The emperor is merely a puppet!");
assert(emperor.rules === false,
      "The puppet does not know how to rule!");

结果表明,我们的测试表明构造函数调用的值返回了 puppet 对象,而我们构造函数中在函数上下文中执行的所有初始化都是徒劳的。puppet 已经暴露了!

现在我们已经进行了一些测试,让我们总结一下我们的发现:

  • 如果构造函数返回一个对象,则该对象作为整个 new 表达式的值返回,而新创建的对象作为 this 传递给构造函数被丢弃。

  • 如果构造函数返回的不是对象,则返回值将被忽略,并返回新创建的对象。

由于这些特殊性,打算用作构造函数的函数通常以与其他函数不同的方式编写。让我们更详细地探讨这一点。

构造函数的编码考虑因素

构造函数的目的是初始化由函数调用创建的新对象到初始条件。尽管这样的函数 可以 作为“正常”函数调用,或者甚至分配给对象属性以便作为方法调用,但它们通常作为这样的用途并不太有用。例如

function Ninja() {
    this.skulk = function() {
      return this;
    };
}
var whatever = Ninja();

我们可以将 Ninja 作为简单的函数调用,但在非严格模式下,skulk 属性将在 window 上创建——这不是一个特别有用的操作。在严格模式下,事情会变得更加糟糕,因为 this 将是未定义的,我们的 JavaScript 应用程序将会崩溃。但这是好事;如果我们非严格模式下犯了这个错误,我们可能没有注意到(除非我们有很好的测试),但在严格模式下,错误是显而易见的。这是一个为什么引入严格模式的好例子。

由于构造函数通常以与其他函数不同的方式编写和使用,并且除非作为构造函数调用,否则并不那么有用,因此出现了一种命名约定来区分构造函数和普通函数和方法。如果你一直很注意,你可能已经注意到了这一点。

函数和方法通常以描述它们所执行动作的动词开头(例如 skulkcreepsneakdoSomethingWonderful 等),并且以小写字母开头。另一方面,构造函数通常以描述正在构建的对象的名词命名,并且以大写字母开头:NinjaSamuraiEmperorRonin 等。

很容易看出,构造函数如何使创建符合相同模式的多个对象变得更加优雅,而无需一遍又一遍地重复相同的代码。公共代码只编写一次,作为构造函数的主体。在 第七章 中,你将了解更多关于使用构造函数以及 JavaScript 提供的其他面向对象机制,这些机制使设置对象模式变得更加容易。

但我们还没有完成函数调用的讨论。JavaScript 还有一种让我们调用函数的方法,它提供了对调用细节的大量控制。

4.2.4. 使用 apply 和 call 方法进行调用

到目前为止,你已经看到函数调用类型之间一个主要的不同点在于最终成为执行函数中隐式 this 参数上下文的对象是什么。对于方法,它是拥有该方法的对象;对于顶层函数,它要么是 window,要么是 undefined(取决于当前的严格性);对于构造函数,它是一个新创建的对象实例。

但如果我们想使函数上下文成为我们想要的任何东西呢?如果我们想显式地设置它呢?如果我们想……好吧,我们为什么要这样做呢?

为了了解我们为什么会对这种能力感兴趣,我们将查看一个实际例子,它说明了与事件处理相关的一个令人惊讶的常见错误。现在,考虑一下当事件处理器被调用时,函数上下文被设置为事件绑定到的对象。(如果你觉得这很模糊,不用担心;你将在第十三章中详细了解事件处理。)看看下面的列表。

列表 4.10. 将特定上下文绑定到函数

077fig01_alt.jpg

在这个例子中,我们有一个按钮,<button id="test">点击我!</button>,我们想知道它是否曾被点击过。为了保留这种状态信息,我们使用构造函数创建一个名为 button 的后端对象,我们将在此对象中存储点击状态:

function Button(){
  this.clicked = false;
  this.click = function(){
    this.clicked = true;
    assert(button.clicked, "The button has been clicked");
  };
}
var button = new Button();

在那个对象中,我们还定义了一个 click 方法,它将在按钮被点击时作为事件处理器触发。该方法将点击属性设置为 true,然后测试状态是否已正确记录在后端对象中(我们有意使用了 button 标识符而不是 this 关键字——毕竟,它们应该指向同一件事,对吧?)。最后,我们将 button.click 方法作为按钮的点击处理器建立起来:

var elem = document.getElementById("test");
elem.addEventListener("click", button.click);

当我们将示例加载到浏览器中并点击按钮时,通过 图 4.2 的显示,我们可以看到有些不对劲;被划掉的文字表明测试失败了。在 列表 4.10 中的代码失败了,因为 click 函数的上下文并没有按照我们的意图指向 button 对象。

图 4.2. 为什么我们的测试失败了?状态变化去哪里了?通常,事件回调的上下文是引发事件的那个对象(在这种情况下,是 HTML 按钮,而不是按钮对象)。

04fig02.jpg

回想一下本章前面的课程,如果我们通过button.click()调用函数,上下文将会是按钮,因为函数将作为button对象上的方法被调用。但在这个例子中,浏览器的事件处理系统定义了调用的上下文为目标元素,这导致上下文是<button>元素,而不是button对象。因此,我们在错误的对象上设置了我们的click状态!

这是一个非常常见的问题,在本章的后面部分,你将看到完全避免它的技术。现在,让我们通过检查如何使用applycall方法显式设置函数上下文来探讨如何解决这个问题。

使用 apply 和 call 方法

JavaScript 为我们提供了一种调用函数并显式指定我们想要作为函数上下文的对象的方法。我们通过使用每个函数都存在的两种方法之一来实现这一点:applycall

是的,我们说的是函数的方法。作为一等对象(顺便提一下,是由内置的Function构造函数创建的),函数可以像任何其他对象类型一样拥有属性,包括方法。

要使用apply方法调用函数,我们向apply传递两个参数:用作函数上下文的对象,以及用作调用参数的值数组。call方法以类似的方式使用,除了参数直接传递到参数列表中,而不是作为数组。

以下列表显示了这两种方法的作用。

列表 4.11。使用 apply 和 call 方法提供函数上下文

在这个例子中,我们设置了一个名为juggle的函数,在其中我们将抛接定义为将所有参数相加并将它们存储在函数上下文(通过this关键字引用)上的一个名为result的属性中。这可能是一个相当蹩脚的抛接定义,但它允许我们确定是否正确地将参数传递给了函数,以及哪个对象最终成为了函数上下文。

然后,我们设置了两个对象ninja1ninja2,我们将它们用作函数上下文,将第一个传递给函数的apply方法,并附带一个数组参数,将第二个传递给函数的call方法,并附带一个列表参数:

juggle.apply(ninja1,[1,2,3,4]);
juggle.call(ninja2, 5,6,7,8);

注意,applycall之间的唯一区别在于参数的提供方式。在apply的情况下,我们使用一个参数数组,而在call的情况下,我们将其作为调用参数列出,在函数上下文之后。参见图 4.3。

图 4.3。callapply方法都接受一个作为函数上下文使用的对象作为第一个参数。区别在于后面的参数。apply只接受一个额外的参数,即参数值的数组;call接受任意数量的参数,这些参数将用作函数参数。

在我们提供了函数上下文和参数之后,我们继续测试!首先,我们检查通过 apply 调用的 ninja1 是否收到了一个 result 属性,该属性是传递数组中所有参数值(1234)的总和。然后我们对通过 call 调用的 ninja2 做同样的检查,检查参数 5678 的结果:

assert(ninja1.result === 10, "juggled via apply");
assert(ninja2.result === 26, "juggled via call");

图 4.4 更详细地展示了 列表 4.11 中的内容。

图 4.4. 通过使用内置的 callapply 从 列表 4.11 手动设置函数上下文,产生了这些函数上下文(this 参数)和 arguments 的组合。

这两种方法,callapply,在需要用我们自己的对象来替代通常的函数上下文时非常有用——这在调用回调函数时尤其有用。

在回调函数中强制函数上下文

让我们考虑一个具体的例子,强制函数上下文成为我们自己的对象。我们将使用一个简单的函数来对数组的每个条目执行操作。

在命令式编程中,将数组传递给一个方法并使用 for 循环遍历每个条目,对每个条目执行操作是很常见的:

function(collection) {
  for (var n = 0; n < collection.length; n++) {
    /* do something to collection[n] */
  }
}

相比之下,函数式方法是为单个元素创建一个函数,并将每个条目传递给该函数:

function(item){
  /* do something to item */
}

差别在于思考的层面,在这个层面上,函数是程序的主要构建块。你可能认为这无关紧要,你只是在将 for 循环移动了一个层级,但我们还没有完全梳理这个例子。

为了便于更函数式风格的编程,所有数组对象都可以访问一个 forEach 函数,该函数会对数组中的每个元素调用回调函数。这种方法通常更简洁,熟悉函数式编程的人更倾向于使用这种方法而不是传统的 for 循环语句。在第五章(kindle_split_016.html#ch05)介绍了闭包之后,这种组织上的好处将变得更加明显(咳嗽,代码重用,咳嗽)。这样的迭代函数 可以 将当前元素作为参数传递给回调函数,但大多数情况下,它们将当前元素作为回调函数的函数上下文。

尽管现在所有现代的 JavaScript 引擎都支持数组上的 forEach 方法,但我们在下一个列表中仍将构建这样一个函数的(简化)版本。

列表 4.12. 构建一个 forEach 函数以演示设置函数上下文

迭代函数具有简单的签名,它期望第一个参数是要迭代的对象数组,第二个参数是回调函数。该函数遍历数组条目,为每个条目调用回调函数:

function forEach(list,callback) {
  for (var n = 0; n < list.length; n++) {
    callback.call(list[n], n);
  }
}

我们使用回调函数的call方法,将当前迭代条目作为第一个参数,将循环索引作为第二个参数。这应该导致当前条目成为函数上下文,并将索引作为单个参数传递给回调。

现在来测试一下!我们设置了一个简单的武器数组。然后我们调用forEach函数,传递测试数组和回调函数,在回调函数内部我们检查预期的条目是否被设置为每次回调调用的函数上下文:

forEach(weapons, function(index){
  assert(this === weapons[index],
        "Got the expected value of " + weapons[index].type);
});

图 4.5 显示我们的函数工作得非常好。

图 4.5. 测试结果显示,我们有能力将任何对象设置为回调调用上下文。

图 4.5

在这样一个函数的生产就绪实现中,还有很多工作要做。例如,如果第一个参数不是一个数组怎么办?如果第二个参数不是一个函数怎么办?你将如何允许页面作者在任何时候终止循环?作为一个练习,你可以增强这个函数以处理这些情况。另一个你可以自己尝试的练习是增强这个函数,使得页面作者可以向回调传递任意数量的参数,除了迭代索引。

既然applycall几乎做同样的事情,那么你现在可能会问自己一个问题:我们如何决定使用哪一个?高级答案与许多此类问题相同:我们使用任何一个可以提高代码清晰度的。一个更实际的答案是使用最适合我们手头参数的。如果我们有一堆无关的值在变量中或作为字面量指定,call允许我们直接在其参数列表中列出它们。但如果我们已经有了一组参数值在数组中,或者如果方便以这种方式收集它们,apply可能是更好的选择。

4.3. 解决函数上下文问题

在前面的章节中,你看到了在处理 JavaScript 中的函数上下文时可能会遇到的一些问题。在回调函数(如事件处理器)中,函数上下文可能并不完全符合我们的预期,但我们可以使用callapply方法来解决这个问题。在本节中,你将看到两种其他选项:箭头函数和bind方法,在某些情况下,它们可以达到相同的效果,但方式更加优雅。

4.3.1. 使用箭头函数绕过函数上下文

除了允许我们以比标准函数声明和函数表达式更优雅的方式创建函数之外,上一章中引入的箭头函数还有一个特性使它们特别适合作为回调函数:箭头函数没有自己的this值。相反,它们会记住定义时this参数的值。让我们回顾一下以下列表中的按钮点击回调问题。

列表 4.13。使用箭头函数绕过回调函数上下文

图片

与列表 4.10 相比,唯一的改变是列表 4.13 使用了一个箭头函数:

this.click = () = > {
  this.clicked = true;
  assert(button.clicked, "The button has been clicked");
};

现在,如果我们运行代码,我们将得到图 4.6 中显示的输出。

图 4.6。箭头函数没有它们自己的上下文。相反,上下文是从它们定义的函数中继承的。我们箭头函数回调中的this参数指向按钮对象。

图片

如您所见,现在一切正常。按钮对象跟踪clicked状态。发生的事情是我们的点击处理程序是在Button构造函数内部作为一个箭头函数创建的:

function Button(){
    this.clicked = false;
    this.click = () => {
      this.clicked = true;
      assert(button.clicked, "The button has been clicked");
    };
}

正如我们之前提到的,当我们调用箭头函数时,箭头函数不会获得它们自己的隐式this参数;相反,它们会记住它们创建时的this参数的值。在我们的例子中,click箭头函数是在构造函数内部创建的,其中this参数是新构造的对象,所以无论我们(或浏览器)何时调用click函数,this参数的值都将始终绑定到新构造的按钮对象。

注意:箭头函数和对象字面量

由于this参数的值是在箭头函数创建时获取的,因此可能会出现一些看似奇怪的行为。让我们回到我们的按钮点击处理程序示例。假设我们得出结论,我们不需要构造函数,因为我们只有一个按钮。我们用以下方式替换它为一个简单的对象字面量。

列表 4.14。箭头函数和对象字面量

图片

如果我们运行列表 4.14,我们又会失望,因为button对象再次未能跟踪clicked状态。参见图 4.7。

图 4.7。如果一个箭头函数是在全局代码中定义的对象字面量内部定义的,那么与箭头函数关联的this参数的值是全局的window对象。

图片

幸运的是,我们在代码中散布了一些断言,这将有助于我们。例如,我们将以下内容直接放置在全局代码中,以便检查this参数的值:

assert(this === window, "this === window");

因为断言通过了,我们可以确信在全局代码中this指的是全局的window对象。

我们接着指定button对象字面量有一个click箭头函数属性:

var button = {
  clicked: false,
  click: () => {
    this.clicked = true;
    assert(button.clicked,"The button has been clicked");
    assert(this === window, "In arrow function this === window");
    assert(window.clicked, "Clicked is stored in window");
  };
}

现在,我们将再次回顾我们的小规则:箭头函数在创建时获取 this 参数的值。因为click箭头函数作为一个属性值在对象字面量上创建,而对象字面量是在全局代码中创建的,所以箭头函数的this值将是全局代码的this值。而且,正如我们从我们在全局代码中放置的第一个断言中看到的

assert(this === window, "this === window");

全局代码中 this 参数的值是全局的 window 对象。因此,我们的 clicked 属性将在全局 window 对象上定义,而不是在我们的 button 对象上。为了确保这一点,最后我们检查 window 对象是否被分配了一个 clicked 属性:

assert(window.clicked, "Clicked is stored in window");

正如你所见,未能记住所有箭头函数的后果可能会导致一些微妙的错误,所以请小心!

现在我们已经探讨了如何使用箭头函数来绕过函数上下文的问题,让我们继续使用另一种方法来解决这个问题。

4.3.2. 使用 bind 方法

在本章中,你已经遇到了每个函数都可以访问的两个方法,callapply,你也看到了如何使用它们来对我们的函数调用有更大的控制权。

除了这些方法外,每个函数都可以访问 bind 方法,简而言之,它创建一个新的函数。这个函数有相同的主体,但它的上下文始终绑定到某个对象,无论 我们如何调用它。

让我们再次回顾一下我们的按钮点击处理器的小问题。

列表 4.15. 将特定上下文绑定到事件处理器

这里添加的秘密成分是 bind() 方法:

elem.addEventListener("click", button.click.bind(button));

bind 方法对所有函数都可用,并且旨在 创建 并返回一个 的函数,该函数绑定到传入的对象(在这种情况下,是 button 对象)。this 参数的值始终设置为该对象,无论绑定函数是如何调用的。除此之外,绑定函数的行为与原始函数一样,因为它在其主体中有相同的代码。

每次按钮被点击时,那个绑定函数都会以 button 对象作为其上下文被调用,因为我们已经将那个 button 对象作为 bind 的参数。

注意,调用 bind 方法不会修改原始函数。它创建了一个全新的函数,这在示例的末尾得到了证实:

var boundFunction = button.click.bind(button);
assert(boundFunction != button.click,
           "Calling bind creates a completly new function");

有了这个,我们将结束对函数上下文的探索。现在休息一下,因为下一章,我们将处理 JavaScript 中最重要概念之一:闭包。

4.4. 总结

  • 当调用一个函数时,除了在函数定义中明确声明的参数外,函数调用还传递了两个隐含参数:argumentsthis

    • arguments 参数是传递给函数的参数集合。它有一个 length 属性,表示传递了多少个参数,并且它使我们能够访问没有匹配参数的参数值。在非严格模式下,arguments 对象是函数参数的别名(改变参数会改变参数的值,反之亦然)。这可以通过使用严格模式来避免。

    • this参数表示函数上下文,一个与函数调用关联的对象。this是如何确定的,可以取决于函数的定义方式以及它的调用方式。

  • 函数可以通过四种方式被调用:

    • 作为函数:skulk()

    • 作为方法:ninja.skulk()

    • 作为构造函数:new Ninja()

    • 通过其applycall方法:skulk.call(ninja)skulk.apply(ninja)

  • 函数的调用方式会影响this参数的值:

    • 如果一个函数作为函数被调用,非严格模式下this参数的值通常是全局window对象,而在严格模式下是undefined

    • 如果一个函数作为方法被调用,this参数的值通常是调用该函数的对象。

    • 如果一个函数作为构造函数被调用,this参数的值是新构造的对象。

    • 如果一个函数通过callapply被调用,this参数的值是callapply提供的第一个参数。

  • 箭头函数没有自己的this参数值。相反,它们在创建时获取它。

  • 使用所有函数都有的bind方法创建一个新的函数,该函数始终绑定到bind方法的参数。在其他所有方面,绑定函数的行为与原始函数相同。

4.5. 练习

1

以下函数通过使用arguments对象计算传入参数的总和:

function sum(){
  var sum = 0;
  for(var i = 0; i < arguments.length; i++){
     sum += arguments[i];
  }
  return sum;
}

assert(sum(1, 2, 3) === 6, 'Sum of first three numbers is 6');
assert(sum(1, 2, 3, 4) === 10, 'Sum of first four numbers is 10');

通过使用上一章中引入的剩余参数,重写sum函数,使其不使用arguments对象。

2

在运行以下代码后,变量ninjasamurai的值是什么?

function getSamurai(samurai){
  "use strict"

  arguments[0] = "Ishida";

  return samurai;
}

function getNinja(ninja){
  arguments[0] = "Fuma";
  return ninja;
}

var samurai = getSamurai("Toyotomi");
var ninja = getNinja("Yoshi");

3

在运行以下代码时,哪个断言会通过?

function whoAmI1(){
  "use strict";
  return this;
}

function whoAmI2(){
  return this;
}

assert(whoAmI1() === window, "Window?");
assert(whoAmI2() === window, "Window?");

4

在运行以下代码时,哪个断言会通过?

var ninja1 = {
   whoAmI: function(){
     return this;
   }
};

var ninja2 = {
  whoAmI: ninja1.whoAmI
};
var identify = ninja2.whoAmI;

assert(ninja1.whoAmI() === ninja1, "ninja1?");
assert(ninja2.whoAmI() === ninja1, " ninja1 again?");

assert(identify() === ninja1, "ninja1 again?");

assert(ninja1.whoAmI.call(ninja2) === ninja2, "ninja2 here?");

5

在运行以下代码时,哪个断言会通过?

function Ninja(){
  this.whoAmI = () => this;
}

var ninja1 = new Ninja();
var ninja2 = {
  whoAmI: ninja1.whoAmI
};

assert(ninja1.whoAmI() === ninja1, "ninja1 here?");
assert(ninja2.whoAmI() === ninja2, "ninja2 here?");

6

以下哪个断言会通过?

function Ninja(){
  this.whoAmI = function(){
    return this;
  }.bind(this);
}

var ninja1 = new Ninja();
var ninja2 = {
  whoAmI: ninja1.whoAmI
};

assert(ninja1.whoAmI() === ninja1, "ninja1 here?");
assert(ninja2.whoAmI() === ninja2, "ninja2 here?");

第五章. 为大师准备的函数:闭包和作用域

本章涵盖

  • 使用闭包简化开发

  • 使用执行上下文跟踪 JavaScript 程序的执行

  • 使用词法环境跟踪变量作用域

  • 理解变量类型

  • 探索闭包的工作原理

与我们在前几章中学到的函数紧密相关,闭包是 JavaScript 的一个定义性特征。尽管许多 JavaScript 开发者可以编写代码而不理解闭包的好处,但闭包的使用不仅可以帮助我们减少添加高级功能所需的代码量和复杂性,而且还能使我们能够完成其他情况下不可能完成或过于复杂而无法实现的事情。例如,任何涉及回调的任务,如事件处理或动画,如果没有闭包,将会变得非常复杂。其他任务,如提供对私有对象变量的支持,将完全不可能。语言本身以及我们编写代码的方式,都因闭包的引入而永远改变。

传统上,闭包是纯函数式编程语言的一个特性。看到它们跨越到主流开发中是令人鼓舞的。在 JavaScript 库和其他高级代码库中,闭包的普遍存在是因为它们能够极大地简化复杂的操作。

闭包是 JavaScript 中作用域工作方式的副作用。因此,我们将探讨 JavaScript 的作用域规则,特别关注最近的新增功能。这将帮助你理解闭包在幕后是如何工作的。让我们直接进入正题!

你知道吗?

Q1:

变量或方法可以有多少个不同的作用域,它们是什么?

Q2:

标识符及其值是如何追踪的?

Q3:

什么是可变变量,如何在 JavaScript 中定义一个?

5.1. 理解闭包

闭包 允许一个函数访问和操作该函数外部定义的变量。闭包允许一个函数访问在函数定义时作用域内的所有变量,以及其他函数。

注意

你可能熟悉作用域的概念,但以防万一,作用域 指的是标识符在程序某些部分的可视性。作用域是程序的一部分,在这个部分中,某个名称绑定到某个变量上。

这可能看起来很直观,直到你记得一个声明的函数可以在任何后来的时间被调用,甚至是在它声明的范围消失之后。这个概念最好通过代码来解释。但在我们具体讨论有助于你编写更优雅的动画代码或定义私有对象属性的示例之前,让我们从小处着手,从以下列表开始。

列表 5.1. 一个简单的闭包

在这个代码示例中,我们在同一个作用域中声明了一个变量 outerValue 和一个函数 outerFunction——在这个例子中,是全局作用域。之后,我们调用了 outerFunction

正如你在 图 5.1 中看到的,函数能够“看到”并访问 outerValue 变量。你可能已经编写了数百次这样的代码,而没有意识到你正在创建闭包!

图 5.1. 我们的功能已经找到了隐藏在明处的忍者。

图片

不太满意?猜这并不奇怪。因为outerValueouterFunction都是在全局范围内声明的,这个范围(它是一个闭包)永远不会消失(只要我们的应用程序正在运行)。函数能够访问变量并不奇怪,因为它仍然在范围内且有效。

尽管闭包存在,但其好处还不明显。让我们在下一个列表中添加一些内容。

列表 5.2. 另一个闭包示例

图片

让我们过度分析innerFunction中的代码,看看我们是否能预测可能会发生什么:

  • 第一个assert肯定能通过;outerValue在全局范围内,对一切可见。但第二个assert呢?

  • 我们是通过将函数的引用复制到全局变量later中,在outerFunction执行后执行innerFunction的。

  • innerFunction执行时,外函数内部的范围已经消失且在通过later调用函数的点上不可见。

  • 因此,我们完全可以预期assert会失败,因为innerValue肯定会被赋予undefined。对吗?

但当我们运行测试时,我们看到的是图 5.2 中的显示。

图 5.2. 尽管试图隐藏在函数内部,忍者已经被检测到!

图片

怎么会这样?什么魔法允许我们在执行内部函数时,即使在其创建的作用域已经消失很久之后,innerValue变量仍然“存活”?答案是闭包。

当我们在外函数内部声明innerFunction时,不仅定义了函数声明,还创建了一个闭包,它包括函数定义以及函数定义点的作用域内的所有变量。当innerFunction最终执行时,即使它是在声明它的作用域消失之后执行的,它也可以通过其闭包访问其声明的原始作用域,如图 5.3 所示。

图 5.3. 就像保护性的气泡一样,innerFunction的闭包使函数作用域内的变量在函数存在期间保持活跃。

图片

这就是闭包的全部内容。它们创建了一个“安全气泡”,包含函数及其在函数定义点的作用域内的变量,这样函数就有它执行所需的一切。这个包含函数及其变量的气泡,只要函数存在就会一直存在。

尽管所有这些结构并不一目了然(没有“闭包”对象持有所有这些信息供你检查),以这种方式存储和引用信息是有直接成本的。重要的是要记住,每个通过闭包访问信息的函数都附有一个“球和链”,携带这些信息。因此,尽管闭包非常有用,但它们并非没有开销。所有这些信息都需要保留在内存中,直到 JavaScript 引擎明确知道它不再需要(并且可以安全地进行垃圾回收),或者直到页面卸载。

别担心;我们并非只有关于闭包工作原理的这些话要说。但在探索使闭包得以实现的机制之前,让我们先看看它们的实际用途。

5.2. 利用闭包

现在我们对闭包有了高层次的理解,让我们看看如何在 JavaScript 应用程序中利用它们。现在,我们将关注它们的实际方面和好处。在本章的后面部分,我们将重新审视相同的示例,以了解幕后到底发生了什么。

5.2.1. 模拟私有变量

许多编程语言使用私有变量——对象属性,对外部方隐藏。这是一个有用的特性,因为我们不希望在使用对象时,让用户承受不必要的实现细节的负担。不幸的是,JavaScript 没有对私有变量的原生支持。但通过使用闭包,我们可以实现一个可接受的近似,如下面的代码所示。

列表 5.3. 使用闭包近似私有变量

在这里,我们创建一个函数,Ninja,作为构造函数。我们在第三章中介绍了使用函数作为构造函数(我们将在第七章中深入探讨)。现在,请回忆一下,当在函数上使用new关键字时,会创建一个新的对象实例,并且该函数会以这个新对象作为上下文被调用,作为该对象的构造函数。因此,函数内的this指向一个新实例化的对象。

在构造函数中,我们定义一个变量来保存状态,feints。这个变量的 JavaScript 作用域规则限制了其可访问性仅限于构造函数内部。为了从代码外部的作用域访问变量的值,我们定义了一个访问器方法:getFeints,它可以用来读取私有变量。(访问器方法通常被称为获取器。)

function Ninja() {
  var feints = 0;
  this.getFeints = function(){
    return feints;
  };
  this.feint = function(){
    feints++;
  };
 }

然后创建了一个实现方法,feint,以让我们控制变量的值。在实际应用中,这可能是业务方法,但在这个例子中,它只是增加feints的值。

构造函数完成其任务后,我们可以在新创建的ninja1对象上调用feint方法:

var ninja1 = new Ninja();
ninja1.feint();

我们的测试表明,我们可以使用访问器方法来获取私有变量的值,但我们不能直接访问它。这防止了我们能够无控制地更改变量的值,就像它是一个真正的私有变量一样。这种情况在图 5.4 中有所描述。

图 5.4. 将变量隐藏在构造函数中使其对外部作用域不可见,但在它起作用的地方,变量仍然活跃,并受到闭包的保护。

图片

使用闭包可以让忍者的状态在方法内部保持,而不会让方法的使用者直接访问它——因为变量通过闭包对内部方法可用,但对外部构造函数之外的代码不可用。

这是对面向对象 JavaScript 世界的窥视,我们将在第七章中更深入地探讨。现在,让我们专注于闭包的另一种常见用途。

5.2.2. 使用闭包与回调

闭包的另一种常见用途发生在处理回调函数时——当函数在不确定的将来某个时间被调用时。通常,在这样的函数中,我们经常需要访问外部数据。以下列表显示了一个使用回调计时器创建简单动画的示例。

列表 5.4. 在计时器间隔回调中使用闭包

图片

图片

这段代码特别重要的是它使用了一个匿名函数,作为setInterval的参数,来完成目标div元素的动画。该函数通过闭包访问三个变量:elemticktimer,以控制动画过程。这三个变量(DOM 元素的引用elem;计数器tick;计时器引用timer)都必须在动画的各个步骤中保持。而且我们需要将它们保持在全局作用域之外。

但如果我们把变量从animateIt函数移出并放入全局作用域,示例仍然可以正常工作。那么为什么还要大惊小怪,不去污染全局作用域呢?

尝试将变量移入全局作用域并验证示例是否仍然有效。现在修改示例以动画两个元素:添加另一个具有唯一 ID 的元素,并在原始调用之后立即使用该 ID 调用animateIt函数。

问题立即变得明显。如果我们保持变量在全局作用域中,我们需要为每个动画设置一组三个变量。否则,它们会相互覆盖,试图使用同一组变量来跟踪多个状态。

通过在函数内部定义变量,并依靠闭包使它们对计时器回调调用可用,每个动画都获得自己的私有“气泡”变量,如图 5.5 所示。

图 5.5. 通过保持函数多个实例的变量分离,我们可以同时做很多事情。

图片

没有闭包,同时做很多事情,无论是事件处理、动画,甚至是服务器请求,都会非常困难。如果你一直在等待一个关心闭包的理由,那就是它了!

这个例子特别适合演示闭包如何产生一些令人惊讶直观和简洁的代码。通过在animateIt函数中包含变量,我们创建了一个隐式的闭包,而无需任何复杂的语法。

这个例子还清楚地说明了另一个重要概念。我们不仅可以看到闭包创建时这些变量的值,我们还可以在闭包内部执行函数时更新它们。闭包不仅仅是创建时作用域状态的快照,而是一个我们可以修改的活跃封装状态,只要闭包存在。

闭包与作用域密切相关,因此我们将在本章中花费大量时间探讨 JavaScript 中的作用域规则。但首先,我们将从 JavaScript 中代码执行跟踪的细节开始。

5.3. 使用执行上下文跟踪代码执行

在 JavaScript 中,执行的基本单位是函数。我们经常使用它们,用于计算某些东西,执行副作用,如更改 UI,实现代码重用,或使我们的代码更容易理解。为了实现其目的,一个函数可以调用另一个函数,然后另一个函数可以调用另一个函数,依此类推。当一个函数执行其操作时,我们的程序执行必须返回到函数被调用的位置。但你有没有想过 JavaScript 引擎是如何跟踪所有这些正在执行的函数和返回位置的?

如我们在第二章中提到的,JavaScript 代码主要有两种类型:全局代码,放置在所有函数之外,和函数代码,包含在函数中。当我们的代码被 JavaScript 引擎执行时,每个语句都是在一定的执行上下文中执行的。

正如我们有两种类型的代码,我们也有两种类型的执行上下文:一个全局执行上下文和一个函数执行上下文。这里有一个显著的区别:只有一个全局执行上下文,在我们 JavaScript 程序开始执行时创建,而每次函数调用都会创建一个新的函数执行上下文

注意

你可能还记得第四章中提到的,函数上下文是我们函数被调用的对象,可以通过this关键字访问。执行上下文,尽管名称相似,但完全是另一回事。它是一个 JavaScript 引擎用来跟踪我们函数执行的内部概念。

如我们在第二章中提到的,JavaScript 基于单线程执行模型:一次只能执行一段代码。每次调用函数时,当前执行上下必须停止,并创建一个新的函数执行上下文,其中将评估函数代码。函数执行完其任务后,其函数执行上下文通常会被丢弃,并恢复调用者的执行上下文。因此,需要跟踪所有这些执行上下文——正在执行的以及耐心等待的。最简单的方法是使用一个,称为执行上下文栈(或通常称为调用栈)。

注意

栈是一种基本的数据结构,你只能将新项目放入顶部,只能从顶部取出现有项目。想象一下自助餐厅里的一摞托盘。当你想要取一个时,你从顶部取一个。而且,有一个新干净的托盘的自助餐厅工作人员也会把它放在顶部。

这可能看起来有些模糊,那么让我们看看以下代码,它报告了两个潜行忍者的活动。

列表 5.5. 执行上下文的创建

图片

这段代码很简单;我们定义了一个skulk函数,它调用report函数,该函数输出一条消息。然后,从全局代码中,我们分别调用两次skulk函数:skulk("Kuma")skulk("Yoshi")。以这段代码为基础,我们将探讨执行上下文的创建,如图 5.6 所示。

图 5.6. 执行上下文栈的行为

图片

当执行示例代码时,执行上下文的行为如下:

  1. 执行上下文栈以全局执行上下文开始,全局执行上下文在每个 JavaScript 程序中只创建一次(在网页的情况下,每页只创建一次)。全局执行上下文是执行全局代码时的活动执行上下文。

  2. 在全局代码中,程序首先定义了两个函数:skulkreport,然后调用skulk函数,并传递参数skulk("Kuma")。因为一次只能执行一段代码,JavaScript 引擎暂停全局代码的执行,并转到执行带有参数Kumaskulk函数代码。这是通过创建一个新的函数执行上下文并将其推到栈顶来完成的。

  3. skulk函数反过来调用report函数,并传递参数Kuma skulking。同样,因为一次只能执行一段代码,skulk执行上下文被暂停,并为report函数创建一个新的函数执行上下文,参数为Kuma skulking,并将其推入栈中。

  4. report函数通过内置的console.log函数(见附录 C)记录消息并完成其执行后,我们必须回到skulk函数。这是通过从栈中弹出report函数的执行上下文来完成的。然后重新激活skulk函数的执行上下文,并继续执行skulk函数。

  5. skulk函数完成其执行时,也会发生类似的事情:skulk函数的函数执行上下文从栈中移除,整个过程中一直耐心等待的全局执行上下文被恢复为活动执行上下文。全局 JavaScript 代码的执行也被恢复。

这个整个过程以类似的方式重复进行,对于skulk函数的第二次调用,现在带有参数Yoshi。当相应的函数被调用时,创建了两个新的函数执行上下文并推入栈中,分别是skulk("Yoshi")report("Yoshi skulking")。当程序从匹配的函数返回时,这些执行上下文也会从栈中弹出。

尽管执行上下文栈是 JavaScript 的一个内部概念,你可以在任何 JavaScript 调试器中探索它,在那里它被称为调用栈。图 5.7 展示了 Chrome DevTools 中的调用栈。

图 5.7. Chrome DevTools 中执行上下文栈的当前状态

注意

附录 C 提供了关于各种浏览器中可用调试工具的更详细说明。

除了跟踪应用程序执行中的位置,执行上下文在标识符解析中也非常重要,这是确定某个标识符引用哪个变量的过程。执行上下文通过词法环境来完成这项工作。

5.4. 使用词法环境跟踪标识符

词法环境是 JavaScript 引擎内部的一个结构,用于跟踪标识符到特定变量的映射。例如,考虑以下代码:

var ninja = "Hattori";
console.log(ninja);

当在console.log语句中访问ninja变量时,会咨询词法环境。

注意

词法环境是 JavaScript 作用域机制的内部实现,人们通常口语化地称它们为作用域

通常,词法环境与 JavaScript 代码的特定结构相关联。它可以与一个函数、一段代码或try-catch语句的catch部分相关联。这些结构(函数、块和catch部分)都可以有自己的独立的标识符映射。

注意

在 ES6 之前的 JavaScript 版本中,词法环境只能与一个函数关联。变量只能是函数作用域的。这造成了很多混淆。因为 JavaScript 是一种类似 C 的语言,来自其他类似 C 的语言(如 C++、C# 或 Java)的人自然期望一些底层概念,如块作用域的存在,是相同的。随着 ES6 的推出,这个问题终于得到了解决。

5.4.1. 代码嵌套

词法环境在很大程度上基于 代码嵌套,这使得一个代码结构可以被包含在另一个代码结构中。图 5.8 展示了各种类型的代码嵌套。

图 5.8. 代码嵌套的类型

在这个例子中,我们可以看到以下内容:

  • for 循环嵌套在 report 函数中。

  • report 函数嵌套在 skulk 函数中。

  • skulk 函数嵌套在全局代码中。

在作用域方面,每次代码被评估时,每个代码结构都会获得一个相关的词法环境。例如,每次调用 skulk 函数时,都会创建一个新的函数词法环境。

此外,重要的是要强调内部代码结构可以访问外部代码结构中定义的变量;例如,for 循环可以访问 report 函数、skulk 函数和全局代码中的变量;report 函数可以访问 skulk 函数和全局代码中的变量;而 skulk 函数只能访问全局代码中的额外变量。

这种访问变量的方式并没有什么特别之处;我们中的大多数人可能已经做过很多次了。但是,JavaScript 引擎是如何跟踪所有这些变量的,以及从哪里可以访问它们呢?这就是词法环境介入的地方。

5.4.2. 代码嵌套和词法环境

除了跟踪局部变量、函数声明和函数参数之外,每个词法环境还必须跟踪其 外部(父级)词法环境。这是必要的,因为我们必须能够访问外部代码结构中定义的变量;如果在当前环境中找不到标识符,则会搜索外部环境。这会在找到匹配的变量时停止,或者如果已经达到全局环境且没有找到搜索的标识符,则会引发引用错误。图 5.9 展示了一个示例;你可以看到在执行 report 函数时,标识符 introactionninja 是如何被解析的。

图 5.9. JavaScript 引擎如何解析变量的值

在这个例子中,report 函数是由 skulk 函数调用的,而 skulk 函数又是由全局代码调用的。每个执行上下文都与一个词法环境相关联,该环境包含在该上下文中直接定义的所有标识符的映射。例如,全局环境包含标识符 ninjaskulk 的映射,skulk 环境包含标识符 actionreport 的映射,而 report 环境包含标识符 intro 的映射(即图 5.9 的右侧)。

在特定的执行上下文中,除了访问匹配词法环境中直接定义的标识符外,我们的程序通常还会访问外部环境中定义的其他变量。例如,在 report 函数的主体中,我们访问外部 skulk 函数的 action 变量,以及全局的 ninja 变量。为了做到这一点,我们必须以某种方式跟踪这些外部环境。JavaScript 通过利用函数作为一等对象来实现这一点。

每当创建一个函数时,都会将函数创建时的词法环境引用存储在一个内部(这意味着你不能直接访问或操作它)属性中,该属性名为 [[Environment]];双中括号是我们用来标记这些内部属性的符号。在我们的例子中,skulk 函数将保留对全局环境的引用,而 report 函数将保留对 skulk 环境的引用,因为这些是函数创建的环境。

注意

这一开始可能看起来有些奇怪。我们为什么不只是遍历整个执行上下文栈并搜索它们的匹配环境以查找标识符映射呢?技术上,在我们的例子中这会起作用。但记住,JavaScript 函数可以被像任何其他对象一样传递,因此函数定义的位置和函数被调用的位置通常是不相关的(记住闭包)。

每当调用一个函数时,都会创建一个新的函数执行上下文并将其推入执行上下文栈。此外,还会创建一个新的相关词法环境。现在到了关键部分:对于新创建的词法环境的外部环境,JavaScript 引擎将调用函数内部 [[Environment]] 属性引用的环境,即现在被调用的函数创建时的环境!

在我们的例子中,当调用 skulk 函数时,新创建的 skulk 环境的外部环境变为全局环境(因为它是 skulk 函数创建的环境)。同样,当调用 report 函数时,新创建的 report 环境的外部环境被设置为 skulk 环境。

现在让我们来看看 report 函数:

function report() {
  var intro = "Aha!";
  assert(intro === "Aha!", "Local");
  assert(action === "Skulking", "Outer");
  assert(action === "Muneyoshi", "Global");
}

当第一个 assert 语句正在评估时,我们必须解析 intro 标识符。为此,JavaScript 引擎首先检查当前运行执行上下文的环境,即 report 环境。因为 report 环境包含对 intro 的引用,所以标识符被解析。

接下来,第二个 assert 语句必须解析 action 标识符。同样,检查当前运行执行上下文的环境。但是 report 环境不包含对 action 标识符的引用,所以 JavaScript 引擎必须检查 report 环境的外部环境:skulk 环境。幸运的是,skulk 环境包含对 action 标识符的引用,并且标识符被解析。尝试解析 ninja 标识符时遵循类似的流程(一个小提示:标识符可以在全局环境中找到)。

现在您已经了解了标识符解析的基础知识,让我们来看看变量可以声明的各种方式。

5.5. 理解 JavaScript 变量的类型

在 JavaScript 中,我们可以使用三个关键字来定义变量:varletconst。它们在两个方面有所不同:可变性和它们与词法环境的关系。

注意

关键字 var 自 JavaScript 诞生以来就是其一部分,而 letconst 是 ES6 的新增功能。您可以在以下链接中检查您的浏览器是否支持 letconstmng.bz/CGJ6mng.bz/uUIT

5.5.1. 变量可变性

如果我们要根据可变性来划分变量声明关键字,我们会把 const 放在一侧,而 varlet 放在另一侧。所有使用 const 定义的变量都是不可变的,这意味着它们的值只能设置一次。另一方面,使用关键字 varlet 定义的变量是典型的常规变量,我们可以根据需要多次更改它们的值。

现在,让我们深入了解 const 变量的工作方式和行为。

const 变量

一个 const “变量”与普通变量类似,除了在声明时我们必须提供一个初始化值,并且之后不能为其分配一个全新的值。嗯,这不太像“变量”,对吧?

Const 变量通常用于两个略有不同的目的:

  • 指定不应重新分配的变量(在本书的其余部分,我们主要用这种方式使用它们)。

  • 通过名称引用一个固定值,例如,小队中浪人的最大数量 MAX_RONIN_COUNT,而不是使用一个字面量数字,如 234。这使得我们的程序更容易理解和维护。我们的代码不是充满了看似随机的字面量(234),而是充满了有意义的名称(MAX_RONIN_COUNT),其值只在一个地方指定。

在任何情况下,因为const变量在程序执行期间都不应该被重新赋值,所以我们已经保护了我们的代码免受未预期或意外的修改,甚至让 JavaScript 引擎能够进行一些性能优化。

下面的列表说明了const变量的行为。

列表 5.6. const变量的行为

在这里,我们首先定义了一个名为firstConstconst变量,其值为samurai,并测试该变量是否已按预期初始化:

const firstConst = "samurai";
assert(firstConst === "samurai", "firstConst is a samurai");

我们继续尝试将一个全新的值ninja赋给我们的firstConst变量:

try{
  firstConst = "ninja";
  fail("Shouldn't be here");
} catch(e){
  pass("An exception has occurred");
}

因为firstConst变量是一个常量,所以我们不能给它分配新的值,所以 JavaScript 引擎会抛出一个异常而不修改变量的值。注意,我们使用了之前没有使用过的两个函数:failpass。这两个方法的行为与assert方法类似,但fail总是失败,而pass总是通过。在这里,我们使用它们来检查是否发生了异常:如果发生异常,catch语句会被激活,并执行pass方法。如果没有异常,fail方法会被执行,我们会被告知某些事情并不像它应该的那样。我们可以检查以验证异常是否发生,如图 5.10 所示。

图 5.10. 检查const变量的行为。当我们尝试给const变量分配一个全新的值时,会发生异常。

接下来,我们定义另一个const变量,这次将其初始化为一个空对象:

const secondConst = {};

现在我们将讨论const变量的重要特性。正如您已经看到的,我们无法给const变量分配一个全新的值。但没有任何阻止我们修改当前值。例如,我们可以向当前对象添加新的属性:

secondConst.weapon = "wakizashi";
assert(secondConst.weapon === "wakizashi",
       "We can add new properties");

或者,如果我们的const变量引用的是一个数组,我们可以修改这个数组到任何程度:

const thirdConst = [];
assert(thirdConst.length === 0, "No items in our array");

thirdConst.push("Yoshi");

assert(thirdConst.length === 1, "The array has changed");

就这样。const变量本身并不复杂。您只需要记住,const变量的值只能在初始化时设置,并且我们以后不能分配一个全新的值。我们仍然可以修改现有值;我们只是不能完全覆盖它。

现在我们已经探讨了变量的可变性,让我们考虑不同类型变量和词法环境之间关系的细节。

5.5.2. 变量定义关键字和词法环境

三种变量定义类型——varletconst——也可以根据它们与词法环境的关系(换句话说,它们的范围)进行分类。在这种情况下,我们可以将var放在一边,而将letconst放在另一边。

使用var关键字

当我们使用var关键字时,变量定义在最近的函数或全局词法环境中。(注意,块被忽略!)这是 JavaScript 的一个长期细节,让很多来自其他语言的开发者感到困惑。

考虑以下代码示例。

列表 5.7. 使用var关键字

我们首先定义一个全局变量globalNinja,然后定义一个reportActivity函数,该函数循环两次并通知我们globalNinja的跳跃活动。正如你所见,在for循环体中,我们可以正常访问块变量(iforMessage),函数变量(functionActivity)和全局变量(globalNinja)。

但 JavaScript 中有些奇怪的地方,让很多来自其他语言的开发者感到困惑,那就是我们可以在代码块外部访问使用代码块定义的变量:

assert(i === 3 && forMessage === "Yoshi jumping",
       "Loop variables accessible outside of the loop");

这源于使用var关键字声明的变量总是注册在最近的函数或全局词法环境中,而不考虑块。图 5.11通过显示reportActivity函数中for循环第二次迭代后的词法环境状态来描述这种情况。

图 5.11. 使用var关键字定义变量时,变量定义在最近的函数或全局环境中(同时忽略块环境)。在我们的例子中,变量forMessagei注册在reportActivity环境中(最近的函数环境),尽管它们包含在for循环中。

这里我们有三个词法环境:

  • 注册globalNinja变量的全局环境(因为这是最近的函数或全局词法环境)

  • reportActivity函数调用时创建的reportActivity环境,其中包含functionActivityiforMessage变量,因为它们是用var关键字定义的,这是它们最近的函数环境

  • for块环境为空,因为用var定义的变量忽略了块(即使它们包含在其中)

由于这种行为有点奇怪,ES6 版本的 JavaScript 提供了两个新的变量声明关键字:letconst

使用letconst指定块作用域变量

与定义变量在最近的函数或全局词法环境中的var不同,letconst关键字更为直接。它们在最近的词法环境中定义变量(这可以是块环境、循环环境、函数环境,甚至是全局环境)。我们可以使用letconst来定义块作用域、函数作用域和全局作用域变量。

让我们重写之前的例子,使用constlet

列表 5.8. 使用constlet关键字

图 5.12 展示了当前情况,当reportActivity函数中for循环的第二次迭代执行完毕时。我们再次有三个词法环境:全局环境(用于所有函数和块之外的全局代码),与reportActivity函数绑定的reportActivity环境,以及for循环体块的块环境。但是因为我们使用了letconst关键字,变量定义在其最近的词法环境中;GLOBAL_NINJA变量定义在全局环境中,functionActivity变量在reportActivity环境中,而iforMessage变量在for块环境中。

图 5.12。当使用letconst关键字定义变量时,变量定义在其最近的词法环境中。在我们的例子中,变量forMessagei注册在for块环境中,变量functionActivityreportActivity环境中,而GLOBAL_NINJA变量在全局环境中(在所有情况下,都是各自变量的最近词法环境)。

现在,随着constlet的引入,大量最近从其他编程语言转来的新 JavaScript 开发者可以安心了。JavaScript 终于支持与其他 C-like 语言相同的作用域规则。因此,从本书的这个点开始,我们几乎总是使用constlet而不是var

现在我们已经了解了标识符映射如何在词法环境中保持,以及词法环境如何与程序执行相关联,让我们讨论标识符在词法环境中定义的确切过程。这将帮助我们更好地理解一些常见的错误。

5.5.3。在词法环境中注册标识符

设计 JavaScript 作为编程语言背后的一个驱动原则是其易用性。这也是没有指定函数返回类型、函数参数类型、变量类型等原因之一。而且你已经知道 JavaScript 代码是逐行、直接执行。考虑以下内容:

firstRonin = "Kiyokawa";
secondRonin = "Kondo";

将值Kiyokawa分配给标识符firstRonin,然后值Kondo分配给标识符secondRonin。这没什么奇怪的,对吧?但是看看另一个例子:

const firstRonin = "Kiyokawa";
check(firstRonin);
function check(ronin) {
  assert(ronin === "Kiyokawa", "The ronin was checked! ");
}

在这种情况下,我们将值Kiyokawa分配给标识符firstRonin,然后我们用标识符firstRonin作为参数调用check函数。但是等等——如果代码逐行执行,我们是否应该能够调用check函数?我们的程序执行还没有达到其声明,所以 JavaScript 引擎甚至不应该知道它。

但如果我们检查,如图 5.13 所示,你会看到一切正常。JavaScript 对我们定义函数的位置并不太挑剔。我们可以选择在函数调用之前或之后放置函数声明。这不是开发者需要烦恼的事情。

图 5.13. 函数确实在执行达到其定义之前就已经可见了。

注册标识符的过程

但除了易用性之外,如果代码是逐行执行的,JavaScript 引擎是如何知道存在一个名为 check 的函数的呢?实际上,JavaScript 引擎“作弊”了一点点,JavaScript 代码的执行发生在两个阶段。

每当创建一个新的词法环境时,都会激活第一阶段。在这个阶段,代码不会执行,但 JavaScript 引擎会访问并注册当前词法环境内声明的所有变量和函数。第二阶段,JavaScript 执行,在完成这一阶段后开始;具体行为取决于变量的类型(letvarconst、函数声明)和环境的类型(全局、函数或块)。

该过程如下:

  1. 如果我们正在创建一个函数环境,就会创建隐式的 arguments 标识符,以及所有形式参数及其参数值。如果我们处理的是一个非函数环境,这一步就会被跳过。

  2. 如果我们正在创建一个全局或函数环境,当前代码会被扫描(但不进入其他函数的主体)以查找函数声明(但不包括函数表达式或箭头函数!)。对于每个发现的函数声明,都会创建一个新的函数并将其绑定到环境中具有该函数名称的标识符。如果该标识符名称已经存在,其值将被覆盖。如果我们处理的是块环境,这一步会被跳过。

  3. 当前代码会被扫描以查找变量声明。在函数和全局环境中,所有使用 var 关键字声明且在其他函数外部定义的变量(但它们可以放在块中!)都会被发现,以及所有使用 letconst 关键字声明且在其他函数和块外部定义的变量都会被发现。在块环境中,代码只会扫描使用 letconst 关键字直接在当前块中声明的变量。对于每个发现的变量,如果标识符在环境中不存在,则注册该标识符并将其值初始化为 undefined。但如果标识符存在,则保留其值。

这些步骤总结在图 5.14 中。

图 5.14. 根据环境类型注册标识符的过程

现在,我们将探讨这些规则的含义。您将看到一些常见的 JavaScript 难题,这些问题可能导致奇怪的错误,这些错误容易创建但难以理解。让我们从为什么我们能够在函数声明之前调用函数的原因开始。

在函数声明之前调用函数

使 JavaScript 易于使用的一个特性是函数定义的顺序并不重要。那些使用过 Pascal 的人可能不会对它的严格结构要求有很好的回忆。在 JavaScript 中,我们甚至可以在正式声明函数之前就调用它。查看以下列表。

列表 5.9. 在函数声明之前访问函数

我们甚至可以在定义函数fun之前就访问它。我们可以这样做,因为fun被定义为函数声明,而本节之前列出的第二步表明,使用函数声明创建的函数在当前词法环境创建时就已经创建并注册了标识符,任何 JavaScript 代码执行之前。所以,在我们开始执行我们的assert调用之前,fun函数已经存在了。

JavaScript 引擎这样做是为了让我们作为开发者更容易,允许我们向前引用函数,而不必为我们放置函数的顺序施加精确的要求。函数在我们代码开始执行时就已经存在了。

注意,这仅适用于函数声明。函数表达式和箭头函数不是这个过程的一部分,而是在程序执行到达它们的定义时创建的。这就是为什么我们无法访问myFunExpmyArrow函数。

覆盖函数

接下来要解决的问题是如何覆盖函数标识符的问题。让我们看看另一个例子。

列表 5.10. 覆盖函数标识符

在这个例子中,一个变量声明和一个函数声明有相同的名字:fun。如果您运行这段代码,您会看到两个assert都通过了。在第一个assert中,标识符fun指的是一个函数;而在第二个和第三个中,fun指的是一个数字。

这种行为是直接后果于注册标识符时采取的步骤。在概述的过程的第二步中,使用函数声明定义的函数在评估任何代码之前就已经创建并与其标识符关联;在第三步中,处理变量声明,并将值undefined与当前环境中尚未遇到的标识符关联。

在这种情况下,因为标识符 fun 在函数声明注册的第二步中被遇到,所以没有将值 undefined 分配给变量 fun。这就是为什么第一个断言,测试 fun 是否是函数,通过的原因。之后,我们有一个赋值语句,var fun = 3,它将数字 3 分配给标识符 fun。通过这样做,我们失去了对函数的引用,从那时起,标识符 fun 指的是一个数字。

在实际程序执行过程中,函数声明会被跳过,所以 fun 函数的定义对 fun 标识符的值没有任何影响。

变量提升

如果你阅读了许多解释标识符解析的 JavaScript 博客或书籍,你可能已经遇到过术语 提升——例如,变量和函数声明会被提升,或者 提升 到函数或全局作用域的顶部。

然而,如你所见,这是一个过于简化的观点。变量和函数声明在技术上并不是“移动”到任何地方。在执行任何代码之前,它们在词法环境中被访问和注册。尽管 提升,正如它通常定义的那样,足以提供对 JavaScript 作用域工作方式的基本理解,但我们通过查看词法环境,在成为真正的 JavaScript 大师的路上又迈出了新的一步。

在下一节中,我们将探讨本章到目前为止所探讨的所有概念,这将帮助你更好地理解闭包。

5.6. 探索闭包的工作方式

我们从闭包开始本章,这是一种机制,允许函数在函数本身创建时访问所有作用域内的变量。你也看到了闭包如何帮助你的一些方式——例如,通过允许我们模拟私有对象变量或在我们处理回调时使我们的代码更加优雅。

闭包与作用域不可逆转地紧密耦合。闭包是 JavaScript 中作用域规则工作方式的直接副作用。因此,在本节中,我们将重新审视本章开头的闭包示例。但这次,你将利用执行上下文和词法环境,这将使你能够理解闭包在底层是如何工作的。

5.6.1. 重新探讨使用闭包模拟私有变量

正如你之前已经看到的,闭包可以帮助我们模拟私有变量。现在,我们已经对 JavaScript 中作用域规则的工作方式有了坚实的理解,让我们重新审视私有变量示例。这次,我们将专注于执行上下文和词法环境。为了使事情更容易理解,让我们重复一下列表。

列表 5.11. 使用闭包近似私有变量

现在我们将分析在创建第一个 Ninja 对象之后应用程序的状态,如图 5.15 所示。我们可以利用我们对标识符解析复杂性的了解,更好地理解在这种情况下闭包是如何起作用的。JavaScript 构造函数是使用 new 关键字调用的函数。因此,每次我们调用构造函数时,我们都会创建一个新的词法环境,该环境跟踪构造函数的局部变量。在这个例子中,创建了一个新的 Ninja 环境来跟踪 feints 变量。

图 5.15. 私有变量作为由构造函数中定义的对象方法创建的闭包来实现。

图 5.15 代替

此外,每当创建一个函数时,它会保留对其创建时的词法环境的引用(通过内部的 [[Environment]] 属性)。在这种情况下,在 Ninja 构造函数函数内部,我们创建了两个新的函数:getFeintsfeint,它们获取对 Ninja 环境的引用,因为这是它们被创建的环境。

getFeintsfeint 函数被分配为新创建的 ninja 对象的方法(如果你还记得上一章,可以通过 this 关键字访问)。因此,getFeintsfeint 将可以从 Ninja 构造函数函数外部访问,这又导致你实际上创建了一个围绕 feints 变量的闭包。

当我们创建另一个 Ninja 对象,即 ninja2 对象时,整个过程会重复。图 5.16 展示了创建第二个 Ninja 对象后应用程序的状态。

图 5.16. 每个实例的方法围绕“私有”实例变量创建闭包。

图 5.16 代替

使用 Ninja 构造函数创建的每个对象都获得自己的方法(ninja1.getFeints 方法与 ninja2.getFeints 方法不同),这些方法围绕在构造函数调用时定义的变量周围。这些“私有”变量只能通过在构造函数内创建的对象方法访问,而不能直接访问!

现在我们来看一下在调用 ninja2.getFeints() 时会发生什么。图 5.17 展示了详细信息。

图 5.17. 执行上下文和词法环境在执行 ninja2.getFeints() 调用时的状态。创建了一个新的 getFeints 环境作为其外部环境,该环境是 ninja2 被创建的构造函数函数的环境。getFeints 可以访问“私有”的 feints 变量。

图 5.17 代替

在调用 ninja2.getFeints() 之前,我们的 JavaScript 引擎正在执行全局代码。我们的程序执行处于全局执行上下文中,这也是执行栈中的唯一上下文。同时,唯一的活跃词法环境是全局环境,与全局执行上下文相关联的环境。

当调用 ninja2.getFeints() 时,我们正在调用 ninja2 对象的 getFeints 方法。因为每次函数调用都会导致创建一个新的执行上下文,所以会创建一个新的 getFeints 执行上下文并将其推入执行栈。这也导致了新的 getFeints 词法环境的创建,这通常用于跟踪在此函数中定义的变量。此外,getFeints 词法环境,作为其外部环境,获取了 getFeints 函数被创建时的环境,即当 ninja2 对象被构建时活动的 Ninja 环境。

现在我们来看看当我们尝试获取 feints 变量的值时会发生什么。首先,查询当前活动的 getFeints 词法环境。因为我们没有在 getFeints 函数中定义任何变量,所以这个词法环境是空的,我们的目标 feints 变量不会在那里找到。接下来,搜索继续在当前词法环境的 外部 环境——在我们的例子中,当构建 ninja2 对象时,Ninja 环境是活动的。这一次,Ninja 环境有一个对 feints 变量的引用,搜索完成。就这么简单。

现在我们已经了解了在处理闭包时执行上下文和词法环境所起的作用,我们想将注意力转向“私有”变量以及为什么我们总是将引号放在它们周围。正如你可能现在已经猜到的,这些“私有”变量不是对象的私有属性,而是由构造函数中创建的对象方法保持活跃的变量。让我们看看这个的一个有趣的副作用。

5.6.2. 私有变量注意事项

在 JavaScript 中,没有任何阻止我们将一个对象上创建的属性赋值给另一个对象。例如,我们可以轻松地将 列表 5.11 中的代码重写为以下类似的形式。

列表 5.12. 通过函数访问私有变量,而不是通过对象!

这个列表以修改源代码的方式,将 ninja1.getFeints 方法赋值给一个全新的 imposter 对象。然后,当我们对 imposter 对象调用 getFeints 函数时,我们测试我们是否可以访问在 ninja1 实例化时创建的变量 feints 的值,从而证明我们是在伪造整个“私有”变量的事情。参见 图 5.18。

图 5.18. 我们可以通过函数访问“私有”变量,即使该函数附加到另一个对象上!

这个例子说明在 JavaScript 中没有私有对象变量,但我们可以使用由对象方法创建的闭包来有一个“足够好”的替代方案。尽管这不是真正的解决方案,但许多开发者发现这种隐藏信息的方式很有用。

5.6.3. 重新审视闭包和回调示例

让我们回到我们的简单动画示例,这次我们将使用回调计时器来动画化两个对象,如下列所示。

列表 5.13. 在计时器间隔回调中使用闭包
<div id="box1">First Box</div>
<div id="box2">Second Box</div>
<script>
  function animateIt(elementId) {
    var elem = document.getElementById(elementId);
    var tick = 0;
    var timer = setInterval(function(){
      if (tick < 100) {
        elem.style.left = elem.style.top = tick + "px";
        tick++;
      }
      else {
        clearInterval(timer);
        assert(tick === 100,
               "Tick accessed via a closure.");
        assert(elem,
               "Element also accessed via a closure.");
        assert(timer,
               "Timer reference also obtained via a closure." );
      }
    }, 10);
  }
  animateIt("box1");
  animateIt("box2");
</script>

如您在本章前面所见,我们使用闭包来简化在页面上对多个对象的动画处理。但现在我们将考虑词法环境,如图 5.19 所示。

图 5.19. 通过创建多个闭包,我们可以同时做很多事情。每当间隔到期时,回调函数会重新激活回调创建时活跃的环境。每个回调的闭包会自动跟踪其自己的变量集。

每次我们调用 animateIt 函数时,都会创建一个新的函数词法环境 ,该环境会跟踪对动画重要的变量集(elementIdelem,正在被动画化的元素;tick,当前的 tick 数;以及 timer,执行动画的计时器 ID)。只要至少有一个函数通过闭包与这些变量一起工作,这个环境就会保持活跃。在这种情况下,浏览器会保持 setInterval 回调的活跃状态,直到我们调用 clearInterval 函数。稍后,当间隔到期时,浏览器会调用相应的回调——通过闭包,回调创建时定义的变量也随之而来。这使我们能够避免手动映射回调和活动变量 的麻烦,从而显著简化我们的代码。

关于闭包和作用域,我们就说这么多。现在回顾本章内容,我们将在下一章中探讨两个全新的 ES6 概念:生成器和承诺,这些概念可以帮助我们编写异步代码。

5.7. 摘要

  • 闭包允许函数访问在函数定义时处于作用域内的所有变量。它们为函数及其定义点处的作用域内的变量创建一个“安全气泡”。这样,即使函数创建的作用域已经消失,函数仍然拥有执行所需的一切。

  • 我们可以使用函数闭包来实现这些高级用法:

    • 通过方法闭包封装构造函数变量,模拟私有对象变量

    • 处理回调,从而显著简化我们的代码

  • JavaScript 引擎通过执行上下文栈(或俗称调用栈)来跟踪函数执行。每次函数被调用时,都会创建一个新的函数执行上下文并将其放置在栈上。当函数执行完毕时,对应的执行上下文将从栈中弹出。

  • JavaScript 引擎使用词法环境(或俗称作用域)来跟踪标识符。

  • 在 JavaScript 中,我们可以定义全局作用域、函数作用域,甚至块作用域的变量。

  • 定义变量时,我们使用 varletconst 关键字:

    • var 关键字在最近的函数或全局作用域中定义变量(同时忽略块)。

    • letconst 关键字定义了最接近的作用域(包括块)中的变量,使我们能够创建块级作用域变量,这在 ES6 之前的 JavaScript 中是不可能的。此外,关键字 const 允许我们定义“变量”,其值只能被赋值一次。

  • 闭包仅仅是 JavaScript 作用域规则的副作用。一个函数可以在它被创建的作用域已经消失很久之后被调用。

5.8. 练习

1

闭包允许函数

  1. 在函数定义时访问作用域内的外部变量
  2. 在函数被调用时访问作用域内的外部变量

2

闭包附带

  1. 代码大小成本
  2. 内存成本
  3. 处理成本

3

在以下代码示例中,标记通过闭包访问的标识符:

function Samurai(name) {
  var weapon = "katana";
  this.getWeapon = function(){
    return weapon;
  };

  this.getName = function(){
    return name;
  }

  this.message = name + " wielding a " + weapon;

  this.getMessage = function(){
    return this.message;
  }
}

var samurai = new Samurai("Hattori");

samurai.getWeapon();
samurai.getName();
samurai.getMessage();

4

在以下代码中,创建了多少执行上下文,以及执行上下文栈的最大大小是多少?

function perform(ninja) {
  sneak(ninja);
  infiltrate(ninja);
}

function sneak(ninja) {
  return ninja + " skulking";
}

function infiltrate(ninja) {
  return ninja + " infiltrating";
}

perfom("Kuma");

5

在 JavaScript 中,哪个关键字允许我们定义不能重新赋值为完全新值的变量?

6

varlet 之间的区别是什么?

7

以下代码将在哪里和为什么抛出异常?

getNinja();
getSamurai();

function getNinja() {
  return "Yoshi";
}

var getSamurai = () => "Hattori";

第六章. 未来函数:生成器和承诺

本章涵盖

  • 使用生成器继续函数执行

  • 使用承诺处理异步任务

  • 通过结合生成器和承诺实现优雅的异步代码

在前三个章节中,我们专注于函数,特别是如何定义函数以及如何有效地使用它们。尽管我们介绍了一些 ES6 特性,如箭头函数和块级作用域,但我们主要是在探索 JavaScript 已经存在了一段时间的特性。本章通过介绍 生成器承诺,即两个全新的 JavaScript 特性,来处理 ES6 的前沿技术。

注意

生成器和承诺都是由 ES6 引入的。你可以查看当前浏览器的支持情况,请访问 mng.bz/sOs4mng.bz/Du38

生成器 是一种特殊类型的函数。与标准函数在从头到尾运行其代码时最多产生一个值不同,生成器在每次请求的基础上产生多个值,在请求之间暂停其执行。尽管在 JavaScript 中是新的,但生成器在 Python、PHP 和 C# 中已经存在了一段时间。

生成器通常被认为是一种几乎怪异或边缘语言特性,普通程序员很少使用。尽管本章的大部分示例都是为了教你如何使用生成器函数,我们也会探讨一些生成器的实际应用。你会看到如何使用生成器简化复杂的循环,以及如何利用生成器暂停和恢复执行的能力,这可以帮助你编写更简单、更优雅的异步代码。

另一方面,承诺是一种新的内置对象类型,可以帮助你处理异步代码。承诺是一个占位符,代表我们目前还没有但将在某个未来的点拥有的值。它们特别适合处理多个异步步骤。

在本章中,你将了解生成器和承诺是如何工作的,然后我们将通过探索如何将它们结合起来,极大地简化我们对异步代码的处理。但在深入具体细节之前,让我们先窥视一下我们的异步代码可以多么优雅。

你知道吗?

Q1:

生成器函数有哪些常见的用途?

Q2:

为什么承诺对于异步代码来说比简单的回调更好?

Q3:

你使用 Promise.race 开始一系列长时间运行的任务。承诺何时解决?它何时会失败而无法解决?

6.1. 使用生成器和承诺使我们的异步代码更优雅

想象一下,你是一名在 freelanceninja.com 工作的开发商,这是一个流行的自由职业者忍者招聘网站,它使客户能够雇佣忍者执行秘密任务。你的任务是实现一个功能,让用户能够获取由最受欢迎的忍者完成的最高评分任务的详细信息。代表忍者的数据、他们任务的摘要以及任务详情都存储在一个远程服务器上,编码为 JSON。你可能编写如下代码:

try {
   var ninjas = syncGetJSON("ninjas.json");
   var missions = syncGetJSON(ninjas[0].missionsUrl);
   var missionDetails = syncGetJSON(missions[0].detailsUrl);
   //Study the mission description
}
catch(e){
  //Oh no, we weren't able to get the mission details
}

这段代码相对容易理解,如果在任何步骤中发生错误,我们可以在 catch 块中轻松捕获它。但不幸的是,这段代码有一个大问题。从服务器获取数据是一个长时间运行的操作,由于 JavaScript 依赖于单线程执行模型,我们刚刚阻塞了我们的 UI,直到长时间运行的操作完成。这导致了无响应的应用程序和失望的用户。为了解决这个问题,我们可以用回调重写它,当任务完成时将调用回调,而不会阻塞 UI:

getJSON("ninjas.json", function(err, ninjas){
  if(err) {
    console.log("Error fetching list of ninjas", err);
    return;
  }
  getJSON(ninjas[0].missionsUrl, function(err, missions) {
    if(err) {
      console.log("Error locating ninja missions", err);
      return;
    }
  getJSON(missions[0].detailsUrl, function(err, missionDetails){
    if(err) {
      console.log("Error locating mission details", err);
      return;
    }
    //Study the intel plan
    });
  });
});

虽然这段代码可能会受到我们用户的更好欢迎,但你可能会同意,它很混乱,添加了大量的样板错误处理代码,而且看起来很丑陋。这就是生成器和承诺介入的地方。通过将它们结合起来,我们可以将非阻塞但笨拙的回调代码转变为更加优雅的代码:

如果这个例子让你感到困惑,或者你发现某些语法(例如 function*yield)不熟悉,请不要担心。到本章结束时,你将遇到所有必要的元素。现在,只要你能比较非阻塞回调代码和非阻塞生成器和承诺代码的优雅性(或缺乏优雅性)就足够了。

让我们从探索生成器函数开始,这是通向优雅异步代码的第一块垫脚石。

6.2. 使用生成器函数

生成器是一种全新的函数类型,与标准、普通的函数有显著的不同。一个 生成器 是一个生成一系列值的函数,但不是一次性生成,就像标准函数那样,而是在每次请求的基础上生成。我们必须明确请求生成器提供一个新值,生成器将返回一个值或通知我们它没有更多的值可以生成。更令人好奇的是,在生成一个值之后,生成器函数不会像普通函数那样结束执行。相反,生成器只是被挂起。然后,当有另一个值的请求时,生成器从上次停止的地方继续执行。

下面的列表提供了一个使用生成器生成武器序列的简单示例。

列表 6.1. 使用生成器函数生成一系列值

我们首先定义一个生成器,它将生成一系列武器。创建生成器函数很简单:我们在 function 关键字后添加一个星号 (*)。这使得我们可以在生成器的主体中使用新的 yield 关键字来生成单个值。图 6.1 说明了语法。

图 6.1. 在函数关键字后添加一个星号 (*) 来定义生成器。

在这个例子中,我们创建了一个名为 WeaponGenerator 的生成器,它生成一系列武器:KatanaWakizashiKusarigama。消费这些武器序列的一种方法是通过使用一种新的循环类型,即 for-of 循环:

for(let weapon of WeaponGenerator()) {
  assert(weapon, weapon);
}

调用此 for-of 循环的结果显示在图 6.2 中。(目前,不必过分担心 for-of 循环,因为我们将在稍后探讨它。)

图 6.2. 遍历我们的 WeaponGenerator() 的结果

for-of 循环的右侧,我们放置了调用我们的生成器的结果。但是,如果您仔细查看 WeaponGenerator 函数的主体,您会看到没有 return 语句。这是怎么回事?在这种情况下,for-of 循环的右侧不应该评估为 undefined 吗?就像我们处理标准函数那样?

事实上,生成器与标准函数相当不同。首先,调用生成器不会执行生成器函数;相反,它创建了一个名为 迭代器 的对象。让我们来探索这个对象。

6.2.1. 通过迭代器对象控制生成器

调用生成器并不意味着生成器函数的主体将被执行。相反,创建了一个迭代器对象,通过这个对象我们可以与生成器进行通信。例如,我们可以使用迭代器请求额外的值。让我们调整之前的示例来探索迭代器对象的工作方式。

列表 6.2. 通过迭代器对象控制生成器

如您所见,当我们调用生成器时,会创建一个新的 迭代器

const weaponsIterator = WeaponGenerator();

迭代器用于控制生成器的执行。迭代器对象公开的一个基本功能是next方法,我们可以用它通过请求值来控制生成器:

const result1 = weaponsIterator.next();

作为对那个调用的响应,生成器执行其代码直到它达到一个yield关键字,该关键字产生一个中间结果(生成项序列中的一个项),并返回一个对象,该对象封装了该结果并告诉我们其工作是否完成。

一旦产生当前值,生成器会暂停其执行而不会阻塞,并耐心地等待另一个值请求。这是一个非常强大的特性,标准函数没有这个特性,我们将在后面用它产生很好的效果。

在这种情况下,迭代器next方法的第一次调用会执行生成器的代码,直到第一个yield表达式yield "Katana",然后返回一个具有value属性设置为Katanadone属性设置为false的对象,表示还有更多值要生成。

后来,我们通过再次调用weaponIteratornext方法从生成器请求另一个值:

const result2 = weaponsIterator.next();

这将唤醒生成器从暂停状态,生成器继续从上次停止的地方开始执行其代码,直到达到另一个中间值:yield "Wakizashi"。这会暂停生成器并产生一个携带Wakizashi的对象。

最后,当我们第三次调用next方法时,生成器继续执行。但这次没有更多的代码可以执行,所以生成器返回一个value设置为undefineddone设置为true的对象,表示它已经完成了其工作。

现在你已经看到了如何通过迭代器控制生成器,你就可以学习如何迭代产生的值了。

迭代迭代器

通过调用生成器创建的迭代器通过next方法公开,我们可以从中请求新的值。next方法返回一个包含生成器产生的值的对象,以及存储在done属性中的信息,该属性告诉我们生成器是否有更多的值要产生。

现在,我们将利用这些事实来使用普通的while循环迭代生成器产生的值。请看下面的列表。

列表 6.3. 使用while循环迭代生成器结果

在这里,我们再次通过调用生成器函数来创建一个迭代器对象:

const weaponsIterator = WeaponGenerator();

我们还创建了一个item变量,我们将用它来存储生成器产生的单个值。我们继续通过指定一个稍微复杂的循环条件来执行while循环,我们将稍作分解:

while(!(item = weaponsIterator.next()).done) {
  assert(item !== null, item.value)
}

在每次循环迭代中,我们通过调用 weaponsIteratornext 方法从生成器中获取一个值,并将其存储在 item 变量中。像所有这样的对象一样,item 变量引用的对象有一个 value 属性,用于存储生成器返回的值,以及一个 done 属性,用于指示生成器是否已完成值的生成。如果生成器还没有完成其工作,我们将进入循环的另一个迭代;如果它已经完成,我们将停止循环。

这就是我们的第一个生成器示例中的 for-of 循环是如何工作的。for-of 循环是迭代器迭代的语法糖:

for(var item of WeaponGenerator ()){
  assert(item !== null, item);
}

我们可以使用 for-of 循环来完成手动调用匹配迭代器的 next 方法以及始终检查我们是否完成的工作,只是在幕后进行。

委托给另一个生成器

正如我们经常从一个标准函数调用另一个标准函数一样,在某些情况下,我们希望能够将一个生成器的执行委托给另一个生成器。让我们看看一个同时生成武士和忍者的例子。

列表 6.4. 使用 yield* 将任务委托给另一个生成器

图片描述

如果你运行这段代码,你会看到输出是 Sun TzuHattoriYoshiGenghis Khan。生成 Sun Tzu 可能不会让你感到意外;它是 WarriorGenerator 生成的第一个值。但第二个输出 Hattori 值得解释。

通过在迭代器上使用 yield* 操作符,我们可以委托给另一个生成器。在这个例子中,我们从 WarriorGenerator 委托给一个新的 NinjaGenerator;当前 WarriorGenerator 迭代器的 next 方法的所有调用都被重定向到 NinjaGenerator。这会持续到 NinjaGenerator 没有剩余的工作要做。所以,在我们的例子中,在 Sun Tzu 之后,程序生成 HattoriYoshi。只有当 NinjaGenerator 完成其工作后,原始迭代器的执行才会继续,输出 Genghis Khan。请注意,这对调用原始生成器的代码来说是透明的。for-of 循环并不关心 WarriorGenerator 是否委托给另一个生成器;它继续调用 next 直到完成。

现在你已经了解了生成器的一般工作原理以及委托给另一个生成器的工作原理,让我们看看一些实际例子。

6.2.2. 使用生成器

生成项目序列听起来很美好,但让我们更实际一些,从一个简单的生成唯一 ID 的例子开始。

使用生成器生成 ID

在创建某些对象时,我们通常需要为每个对象分配一个唯一的 ID。最简单的方法是通过全局计数器变量,但这有点丑陋,因为变量可能会在我们的代码的任何地方被意外破坏。另一种选择是使用生成器,如下面的列表所示。

列表 6.5. 使用生成器生成 ID

图片描述

这个例子从一个具有一个局部变量 id 的生成器开始,id 变量代表我们的 ID 计数器。id 变量是生成器本地的;不用担心有人会意外地从代码的其他地方修改它。这后面跟着一个无限的 while 循环,在每次迭代中产生一个新的 id 值,并暂停其执行,直到有另一个 ID 请求到来:

function *IdGenerator(){
  let id = 0;
  while(true){
    yield ++id;
  }
}
注意

在标准函数中编写无限循环并不是我们通常想要做的事情。但有了生成器,一切都很正常!每当生成器遇到 yield 语句时,生成器执行就会暂停,直到再次调用 next 方法。所以每个 next 调用只执行无限 while 循环的一次迭代,并返回下一个 ID 值。

在定义生成器之后,我们创建一个迭代器对象:

const idIterator = IdGenerator();

这允许我们通过调用 idIterator.next() 方法来控制生成器。这执行生成器直到遇到 yield,返回我们可以用于我们的对象的新 ID 值:

const ninja1 = { id: idIterator.next().value };

看看这是多么简单?没有可以意外更改值的混乱的全局变量。相反,我们使用迭代器从生成器请求值。此外,如果我们以后需要另一个迭代器来跟踪例如 samurai 的 ID,我们可以为那个初始化一个新的生成器。

使用生成器遍历 DOM

正如你在第二章中看到的,网页布局基于 DOM,这是一个 HTML 节点的树状结构,其中每个节点(除了根节点)恰好有一个父节点,可以有零个或多个子节点。因为 DOM 是网络开发中的基本结构,所以我们的很多代码都是围绕遍历它来编写的。一个相对简单的方法是实现一个递归函数,该函数将为每个访问的节点执行。请看下面的代码。

列表 6.6. 递归 DOM 遍历

在这个例子中,我们使用递归函数遍历具有 id subtree 的元素的子代,在这个过程中记录我们访问的每种节点类型。在这种情况下,代码输出 DIVFORMINPUTPSPAN

我们已经编写了这样的 DOM 遍历代码一段时间了,它一直为我们服务得很好。但现在我们有了生成器,我们可以用不同的方式来做这件事;请看下面的代码。

列表 6.7. 使用生成器遍历 DOM 树

这个示例表明,我们可以像使用标准递归一样轻松地使用生成器进行 DOM 遍历,但还有一个额外的优点,就是不必使用稍微有些尴尬的回调语法。我们不是通过递归到另一个级别来处理每个访问节点的子树,而是为每个访问节点创建一个生成器函数并向它发送yield。这使得我们可以以可迭代的方式编写概念上递归的代码。好处是,我们可以使用简单的for-of循环来消费生成的节点序列,而不必求助于讨厌的回调。

这个例子特别出色,因为它还展示了如何使用生成器来分离产生值的代码(在这种情况下,HTML 节点)和消费生成值序列的代码(在这种情况下,记录访问节点的for-of循环),而不必求助于回调。此外,在某些情况下,使用迭代比递归更自然,所以总是好的,保持我们的选择开放。

现在我们已经探索了一些生成器的实际应用方面,让我们回到一个稍微更理论性的话题,看看如何与正在运行的生成器交换数据。

6.2.3. 与生成器通信

在前面提供的示例中,你已经看到了如何使用yield表达式从生成器返回多个值。但生成器比这更强大!我们还可以将数据发送到生成器,从而实现双向通信!使用生成器,我们可以产生一个中间结果,使用该结果从生成器外部计算其他东西,然后,当我们准备好时,将完全新的数据发送回生成器并继续其执行。我们将在本章末尾使用这个特性来处理异步代码,但现在让我们保持简单。

将值作为生成器函数参数发送

向生成器发送数据最简单的方法是将它当作任何其他函数一样对待,并使用函数调用参数。请看下面的示例。

列表 6.8. 向生成器发送数据并从生成器接收数据

接收数据的函数没有什么特别之处;普通的函数一直在做这件事。但请记住,生成器有这种惊人的能力;它们可以被暂停和恢复。而且,事实证明,与标准函数不同,生成器甚至可以在它们的执行开始后接收数据,只要我们通过请求下一个值来恢复它们。

使用next方法向生成器发送值

除了在首次调用生成器时提供数据外,我们还可以通过向next方法传递参数将数据发送到生成器中。在这个过程中,我们唤醒了暂停的生成器并继续其执行。这个传入的值被生成器用作当前暂停的整个yield表达式的值,如图 6.3 所示。

图 6.3. 第一次调用 ninjaIterator.next() 从生成器请求一个新值,生成器返回 Hattori skulk 并在 yield 表达式处暂停生成器的执行。第二次调用 ninjaIterator.next("Hanzo") 请求一个新值,同时也将参数 Hanzo 传递给生成器。这个值将用作整个 yield 表达式的值,而变量 imposter 现在将携带 Hanzo 的值。

图片

在这个例子中,我们对 ninjaIteratornext 方法进行了两次调用。第一次调用 ninjaIterator.next(),请求生成器的第一个值。因为我们的生成器还没有开始执行,这次调用启动了生成器,计算表达式 "Hattori " + action 的值,产生 Hattori skulk 值,并暂停生成器的执行。这没有什么特别的;我们在本章中已经多次做过类似的事情。

有趣的事情发生在对 ninjaIteratornext 方法的第二次调用中:ninjaIterator.next("Hanzo")。这次,我们使用 next 方法将数据传递回生成器。我们的生成器函数正耐心地等待,暂停在 yield ("Hattori " + action) 表达式处,因此传递给 next() 的值 Hanzo 被用作整个 yield 表达式的值。在我们的例子中,这意味着 imposter = yield ("Hattori " + action) 中的变量 imposter 最终将获得 Hanzo 的值。

这就是我们与生成器实现双向通信的方法。我们使用 yield 从生成器返回数据,并使用迭代器的 next() 方法将数据传递回生成器。

注意

next 方法为等待的 yield 表达式提供值,所以如果没有等待的 yield 表达式,就没有值可以提供。因此,我们 不能 在第一次调用 next 方法时提供值。但记住,如果你需要向生成器提供一个初始值,你可以在调用生成器本身时这样做,就像我们用 NinjaGenerator("skulk") 所做的那样。

抛出异常

另一种,稍微不那么正统的方法,向生成器提供一个值:通过抛出异常。每个迭代器除了有一个 next 方法外,还有一个 throw 方法,我们可以用它来向生成器抛出异常。再次,让我们看看一个简单的例子。

列表 6.9. 向生成器抛出异常

图片

列表 6.9 与 列表 6.8 类似,通过指定一个名为 NinjaGenerator 的生成器开始。但这次,生成器的主体略有不同。我们用 try-catch 块包围了整个函数主体代码:

function* NinjaGenerator() {
  try{
    yield "Hattori";
    fail("The expected exception didn't occur");
  }
  catch(e){
    assert(e === "Catch this!", "Aha! We caught an exception");
  }
}

然后我们继续创建一个迭代器,并从生成器中获取一个值:

const ninjaIterator = NinjaGenerator();
const result1 = ninjaIterator.next();

最后,我们使用所有迭代器都有的 throw 方法将异常抛回生成器:

ninjaIterator.throw("Catch this!");

通过运行这个列表,我们可以看到我们的异常抛出工作如预期所示,如图 6.4。

图 6.4。我们可以从生成器外部向生成器抛出异常。

06fig04.jpg

这个特性使我们能够将异常抛回到生成器,一开始可能会觉得有点奇怪。我们为什么要这样做呢?别担心,我们不会让你长时间处于黑暗中。在本章末尾,我们将使用这个特性来改进异步服务器端通信。只需再耐心一点。

现在你已经看到了生成器的几个方面,我们准备深入底层看看生成器是如何工作的。

6.2.4. 探索底层的生成器

到目前为止,我们知道调用生成器并不会执行它。相反,它创建了一个新的迭代器,我们可以使用它来请求生成器的值。在生成器产生(或产生)一个值之后,它会暂停其执行并等待下一个请求。所以从某种意义上说,生成器几乎像一个小程序,一个在状态之间移动的状态机:

  • 暂停开始—— 当生成器被创建时,它从这个状态开始。生成器的任何代码都没有被执行。

  • 执行中—— 生成器代码被执行的状态。执行要么从开始处继续,要么从生成器上次暂停的地方继续。当调用匹配迭代器的next方法且存在要执行的代码时,生成器移动到这个状态。

  • 暂停产生—— 在执行过程中,当生成器达到一个yield表达式时,它创建一个新的携带返回值的对象,产生它,并暂停其执行。这是生成器暂停并等待继续执行的状态。

  • 完成—— 如果在执行过程中生成器遇到了return语句或者没有更多的代码可以执行,生成器就会移动到这个状态。

图 6.5 说明了这些状态。

图 6.5。在执行过程中,生成器在调用匹配迭代器的next方法时在状态之间移动。

06fig05_alt.jpg

现在,让我们从更深层次补充这一点,通过查看生成器的执行是如何与执行上下文一起跟踪的。

使用执行上下文跟踪生成器

在上一章中,我们介绍了执行上下文,这是一个内部 JavaScript 机制,用于跟踪函数的执行。尽管有些特殊,但生成器仍然是函数,让我们通过探索它们与执行上下文之间的关系来更深入地了解它们。我们将从一个简单的代码片段开始:

function* NinjaGenerator(action) {
  yield "Hattori " + action;
  return "Yoshi " + action;
}

const ninjaIterator = NinjaGenerator("skulk");
const result1 = ninjaIterator.next();
const result2 = ninjaIterator.next();

在这里,我们重用我们的生成器,它产生两个值:Hattori skulkYoshi skulk

现在,我们将探索应用程序的状态,以及在应用程序执行过程中的各个点的执行上下文堆栈状态。图 6.6 给出了应用程序执行两个位置的快照。第一个快照显示了调用NinjaGenerator函数之前的应用程序执行状态 num-01.jpg。因为我们正在执行全局代码,所以执行上下文堆栈中只包含全局执行上下文,它引用了我们的标识符所保持的全局环境。只有NinjaGenerator标识符引用一个函数,而所有其他标识符的值都是undefined

图 6.6. 在调用NinjaGenerator函数之前的状态 num-01.jpg,以及调用NinjaGenerator函数时 num-02.jpg

06fig06_alt.jpg

当我们调用NinjaGenerator函数时 num-02.jpg

const ninjaIterator = NinjaGenerator("skulk");

控制流进入生成器,就像我们进入任何其他函数时发生的情况一样,创建一个新的NinjaGenerator执行上下文项(以及匹配的词法环境)并将其推入堆栈。但由于生成器是特殊的,没有任何函数代码被执行。相反,创建了一个新的迭代器,我们在代码中将其称为ninjaIterator,并将其返回。因为迭代器用于控制生成器的执行,所以迭代器获得了对其创建时的执行上下文的引用。

当程序执行离开生成器时,会发生有趣的事情,如图 6.7 所示。图 6.7。通常,当程序执行从一个标准函数返回时,匹配的执行上下文会从堆栈中弹出并完全丢弃。但生成器的情况并非如此。

图 6.7. 从NinjaGenerator调用返回时的应用程序状态

06fig07_alt.jpg

匹配的NinjaGenerator堆栈项确实从堆栈中弹出,但它并没有被丢弃,因为ninjaIterator保持对其的引用。你可以将其视为闭包的类似物。在闭包中,我们需要保持函数创建时活跃的变量的活跃状态,因此我们的函数会保持对其创建环境的引用。这样,我们确保环境及其变量在函数本身存在期间保持活跃。另一方面,生成器必须能够恢复其执行。因为所有函数的执行都由执行上下文处理,迭代器保持对其创建时的执行上下文的引用,以便在迭代器需要时保持活跃。

当我们在迭代器上调用next方法时,发生另一件有趣的事情:

const result1 = ninjaIterator.next();

如果这是一个标准的直接函数调用,这将导致创建一个新的 next() 执行上下文项,并将其放置在栈上。但正如你可能已经注意到的,生成器根本不是标准的,对迭代器的 next 方法的调用行为大不相同。它重新激活匹配的执行上下文,在这种情况下,是 NinjaGenerator 上下文,并将其放置在栈顶,继续上次停止的地方执行,如图 6.8 所示。

图 6.8。调用迭代器的 next 方法重新激活匹配生成器的执行上下文栈项,将其推入栈中,并从上次停止的地方继续执行。

图 6.9

图 6.8 展示了标准函数和生成器之间的重要区别。标准函数只能重新调用,并且每次调用都会创建一个新的执行上下文。相比之下,生成器的执行上下文可以被临时挂起并随意恢复。

在我们的例子中,因为这是对 next 方法的第一次调用,生成器还没有开始执行,所以生成器开始执行并移动到执行状态。当我们的生成器函数到达这个点时,接下来有趣的事情发生了:

yield "Hattori " + action

生成器确定表达式等于 Hattori skulk,评估到达了 yield 关键字。这意味着 Hattori skulk 是我们生成器的第一个中间结果,并且我们想要挂起生成器的执行并返回该值。从应用程序状态的角度来看,发生的事情与之前类似:NinjaGenerator 上下文从栈中移除,但它并未完全丢弃,因为 ninjaIterator 保留了对它的引用。现在生成器被挂起,并移动到挂起产生状态,没有阻塞。程序执行通过将产生的值存储到 result1 中在全局代码中继续。应用程序的当前状态如图 6.9 所示。

图 6.9。在产生一个值后,生成器的执行上下文从栈中弹出(但并未丢弃,因为 ninjaIterator 保留了对它的引用),生成器的执行被挂起(生成器移动到挂起产生状态)。

图 6.9

代码继续执行,到达另一个迭代器调用:

const result2 = ninjaIterator.next();

在这个阶段,我们再次走一遍整个流程:我们重新激活由 ninjaIterator 引用的 NinjaGenerator 上下文,将其推入栈中,并从上次停止的地方继续执行。在这种情况下,生成器评估表达式 "Yoshi " + action。但这次没有 yield 表达式,而是程序遇到了一个 return 语句。这返回了值 Yoshi skulk 并通过将生成器移动到完成状态来完成生成器的执行。

哎,这真是个大发现!我们深入探讨了生成器在底层是如何工作的,以向您展示生成器的所有美妙好处都是由于如果我们从生成器中 yield,生成器的执行上下文会保持活跃,而不是像返回值和标准函数那样被销毁。

现在我们建议您在继续学习编写优雅的异步代码的第二个关键要素——Promise 之前,先稍作休息。

6.3. 使用 Promise

在 JavaScript 中,我们大量依赖于异步计算,这些计算的结果我们目前还没有,但将在某个未来的时刻获得。因此,ES6 引入了一个新的概念,使得处理异步任务变得更加容易:Promise。

Promise 是一个占位符,用于表示我们现在没有但将来会有的值;它是对我们最终将知道异步计算结果的保证。如果我们履行我们的承诺,我们的结果将是一个值。如果发生问题,我们的结果将是一个错误,解释了我们为什么无法交付的理由。使用 Promise 的一个很好的例子是从服务器获取数据;我们承诺我们最终会得到数据,但总有可能出现问题。

如您在以下示例中看到的那样,创建一个新的 Promise 很简单。

列表 6.10. 创建一个简单的 Promise

要创建一个 Promise,我们使用新的内置 Promise 构造函数,我们向它传递一个函数,在这种情况下是一个箭头函数(但我们也可以简单地使用函数表达式)。这个函数被称为 执行器 函数,它有两个参数:resolvereject。当使用两个内置函数作为参数构造 Promise 对象时,执行器会被立即调用:resolve,如果我们想成功解析 Promise,则手动调用它;reject,如果发生错误,则调用它。

此代码通过在 Promise 对象上调用内置的 then 方法来使用 Promise,我们向该方法传递两个回调函数:一个 成功 回调和一个 失败 回调。如果 Promise 成功解析(如果在 Promise 上调用了 resolve 函数),则调用前者;如果出现问题(要么发生未处理的异常,要么在 Promise 上调用了 reject 函数),则调用后者。

在我们的示例代码中,我们创建了一个 Promise 并立即通过调用带有参数 Hattoriresolve 函数来解析它。因此,当我们调用 then 方法时,第一个成功回调会被执行,并且输出 We were promised Hattori! 的测试通过。

既然我们已经对 Promise 是什么以及它是如何工作的有了大致的了解,让我们退一步来看看 Promise 解决的一些问题。

6.3.1. 理解简单回调的问题

我们使用异步代码,因为我们不希望在长时间运行的任务执行时阻塞我们应用程序的执行(从而让我们的用户失望)。目前,我们通过以下方式解决这个问题:向长时间运行的任务提供一个函数,一个回调,当任务最终完成时将被调用。

例如,从服务器获取 JSON 文件是一个长时间运行的任务,在这个过程中,我们不希望让我们的应用程序对用户无响应。因此,我们提供了一个回调,当任务完成时将被调用:

getJSON("data/ninjas.json", function() {
  /*Handle results*/
});

自然地,在长时间运行的任务期间,可能会发生错误。回调的问题是你不能以这种方式使用内置的语言结构,例如try-catch语句:

try {
  getJSON("data/ninjas.json", function() {
    //Handle results
  });
} catch(e) {/*Handle errors*/}

这是因为调用回调的代码通常不会在事件循环的同一步骤中执行,与启动长时间运行任务的代码(当你学习到第十三章中关于事件循环的更多内容时,你会确切地了解这是什么意思)。因此,错误通常会被丢失。因此,许多库定义了自己的约定来报告错误。例如,在 Node.js 的世界里,回调通常接受两个参数,errdata,其中如果过程中发生错误,err将不会是空值。这导致了回调的第一个问题:错误处理困难

在我们执行了长时间运行的任务之后,我们通常想要对获得的数据做些处理。这可能导致启动另一个长时间运行的任务,这最终可能触发另一个长时间运行的任务,以此类推——导致一系列相互依赖的、异步的、回调处理的步骤。例如,如果我们想要执行一个秘密的计划来找到我们所有的忍者,获取第一个忍者的位置,并发送一些命令,我们最终会得到如下代码:

getJSON("data/ninjas.json", function(err, ninjas){
  getJSON(ninjas[0].location, function(err, locationInfo){
    sendOrder(locationInfo, function(err, status){
     /*Process status*/
    })
  })
});

你可能至少一次或两次遇到过类似结构的代码——一系列嵌套的回调,代表了一系列必须执行的步骤。你可能注意到,这段代码很难理解,插入新步骤很痛苦,错误处理显著复杂化了你的代码。你得到了一个不断增长且难以管理的“灾难金字塔”。这导致我们面临回调的第二个问题:执行一系列步骤很棘手

有时候,我们得到最终结果所需的步骤之间没有相互依赖,所以我们不需要按顺序执行它们。相反,为了节省宝贵的时间,我们可以并行执行它们。例如,如果我们想要启动一个需要我们知道我们有哪些忍者的计划,计划本身,以及我们的计划将实施的位置,我们可以利用 jQuery 的get方法并编写如下代码:

var ninjas, mapInfo, plan;

$.get("data/ninjas.json", function(err, data){
  if(err) { processError(err); return; }
  ninjas = data;
  actionItemArrived();
});

$.get("data/mapInfo.json", function(err, data){
  if(err) { processError(err); return; }
  mapInfo = data;
  actionItemArrived();
});

$.get("plan.json", function(err, data) {
  if(err) { processError(err); return; }

  plan = data;
  actionItemArrived ();
});

function actionItemArrived(){
  if(ninjas != null && mapInfo != null && plan != null){
    console.log("The plan is ready to be set in motion!");
  }
}

function processError(err){
  alert("Error", err)
}

在此代码中,我们并行执行获取忍者、获取地图信息和获取计划的操作,因为这些操作之间没有依赖关系。我们只关心最终我们是否拥有所有可用的数据。因为我们不知道数据接收的顺序,所以每次我们获取一些数据时,我们必须检查它是否是我们缺少的最后一块拼图。最后,当所有部件都到位时,我们可以开始执行我们的计划。请注意,我们不得不编写大量的样板代码,仅仅是为了执行像并行执行多个操作这样常见的事情。这导致我们面临回调的第三个问题:并行执行多个步骤也很棘手

当我们展示回调的第一个问题——处理错误时,我们展示了我们无法使用一些基本语言结构,例如try-catch语句。与循环类似:如果你想要对集合中的每个项目执行异步操作,你必须跳过一些额外的步骤来完成它。

事实确实如此,你可以创建一个库来简化处理所有这些问题(许多人已经这样做了)。但这样做往往会导致处理相同问题的多种略微不同的方式,因此 JavaScript 背后的开发者赐予了我们承诺,这是一种处理异步计算的标准方法。

现在你已经了解了引入承诺背后的大多数原因,以及对其有基本的了解,让我们更进一步。

6.3.2. 深入理解承诺

承诺是一个对象,它作为异步任务结果的占位符。它代表一个我们目前没有但希望未来能拥有的值。因此,在其生命周期内,承诺可以经历几个状态,如图 6.10 所示。

图 6.10. 承诺的状态

图片

承诺从挂起状态开始,我们对我们承诺的值一无所知。这就是为什么挂起状态的承诺也被称为未解决承诺。在程序执行过程中,如果调用承诺的resolve函数,承诺将进入已解决状态,这时我们已经成功获得了承诺的值。另一方面,如果调用承诺的reject函数,或者在处理承诺时发生未处理的异常,承诺将进入拒绝状态,这时我们无法获得承诺的值,但至少我们知道原因。一旦承诺达到已解决拒绝状态之一,它就不能切换(承诺不能从已解决状态变为拒绝状态或反之亦然),并且它始终保持在那个状态。我们说承诺是已解决的(无论是成功还是不成功)。

下面的列表提供了当我们使用承诺时发生情况的更详细说明。

列表 6.11. 深入了解承诺的执行顺序

图片

图片

代码清单 6.11 中的代码输出了图 6.11 中显示的结果。正如你所见,代码首先通过我们定制的report函数(附录 C)记录了“代码开始”的消息,该函数将消息输出到屏幕上。这使得我们能够轻松跟踪执行顺序。

图 6.11. 执行代码清单 6.11 的结果

接下来,我们通过调用Promise构造函数创建一个新的承诺。这立即调用了设置超时的执行器函数:

setTimeout(() => {
  report("Resolving ninjaDelayedPromise");
  resolve("Hattori");
}, 500);

超时将在 500 毫秒后解析承诺。这可以是任何其他异步任务,但我们选择简单超时是因为它的简单性。

在创建ninjaDelayedPromise之后,它仍然不知道它最终将具有的值,或者它是否甚至会被成功解析。(记住,它仍在等待解析它的超时。)因此,在构建之后,ninjaDelayedPromise处于第一个承诺状态,pending

接下来,我们在ninjaDelayedPromise上使用then方法来安排一个回调,当承诺成功解析时执行:

ninjaDelayedPromise.then(ninja => {
  assert(ninja === "Hattori",
        "ninjaDelayedPromise resolve handled with Hattori");
});

这个回调总是会异步调用,无论承诺的当前状态如何。

我们继续通过创建另一个承诺,ninjaImmediatePromise,在构建过程中立即通过调用resolve函数来解析它。与在构建后处于pending状态的ninjaDelayedPromise不同,ninja-ImmediatePromiseresolved状态下完成构建,并且承诺已经具有值Yoshi

之后,我们使用ninjaImmediatePromisethen方法注册一个回调,当承诺成功解析时执行。但我们的承诺已经确定;这意味着成功回调会被立即调用还是会被忽略?答案是都不是

承诺被设计用来处理异步操作,因此 JavaScript 引擎总是求助于异步处理,以使承诺行为可预测。引擎通过在事件循环当前步骤的所有代码执行之后执行then回调来实现这一点(我们将在第十三章中再次探讨这究竟意味着什么)。因此,如果我们研究图 6.11 中的输出,我们会看到我们首先记录“代码结束”,然后记录ninjaImmediatePromise已被解析。最后,在 500 毫秒超时到期后,ninjaDelayedPromise被解析,这导致匹配的then回调执行。

在这个例子中,为了简单起见,我们只处理了一切顺利的情景。但现实世界并非总是阳光明媚,所以让我们看看如何处理可能出现的各种疯狂问题。

6.3.3. 拒绝承诺

有两种拒绝承诺的方式:显式地,通过在承诺执行函数中调用传入的 reject 方法,以及隐式地,如果在处理承诺期间发生未处理的异常。让我们从以下列表开始我们的探索。

列表 6.12. 显式拒绝承诺

我们可以通过调用传入的 reject 方法显式地拒绝一个承诺:reject("显式拒绝一个承诺!")。如果一个承诺被拒绝,当通过 then 方法注册回调时,第二个,错误,回调总是会调用。

此外,我们可以使用一种替代语法来处理承诺拒绝,通过使用内置的 catch 方法,如下所示。

列表 6.13. 链式调用 catch 方法

如 列表 6.13 所示,我们可以在 then 方法之后链式调用 catch 方法,以提供当承诺被拒绝时将被调用的错误回调。在这个例子中,这是一个个人风格的问题。两种选项都同样有效,但稍后,当处理承诺链时,我们将看到一个其中链式调用 catch 方法是有用的例子。

除了显式拒绝(通过 reject 调用)之外,如果在其处理过程中发生异常,承诺也可以被隐式拒绝。看看以下示例。

列表 6.14. 异常隐式拒绝一个承诺

在承诺执行函数的主体中,我们尝试增加 undeclaredVariable,这是一个在我们的程序中没有定义的变量。不出所料,这导致了一个异常。因为执行函数的主体中没有 try-catch 语句,这导致当前承诺的隐式拒绝,最终调用 catch 回调。在这种情况下,我们也可以同样容易地向 then 方法提供第二个回调,最终效果将是相同的。

以统一的方式处理在处理承诺时发生的所有问题的方式非常方便。无论承诺是如何被拒绝的,无论是通过显式地调用 reject 方法,甚至是隐式地,如果发生异常,所有错误和拒绝原因都会被导向我们的拒绝回调。这使得我们的开发生活变得稍微容易一些。

现在我们已经了解了承诺是如何工作的,以及如何安排成功和失败回调,让我们来看一个现实场景,从服务器获取 JSON 格式的数据,并将其“承诺化”。

6.3.4. 创建我们的第一个现实世界的承诺

在客户端,最常见的异步操作之一是从服务器获取数据。因此,这是一个关于承诺使用的极好的小案例研究。对于底层实现,我们将使用内置的 XMLHttpRequest 对象。

列表 6.15. 创建一个 getJSON 承诺

注意

执行此示例以及所有重用此函数的后续示例需要一个正在运行的服务器。例如,你可以使用www.npmjs.com/package/http-server

我们的目的是创建一个getJSON函数,该函数返回一个承诺,使我们能够为从服务器异步获取 JSON 格式数据注册成功和失败回调。对于底层实现,我们使用内置的XMLHttpRequest对象,它提供了两个事件:onloadonerroronload事件在浏览器从服务器收到响应时触发,而onerror在通信发生错误时触发。这些事件处理程序将按异步方式由浏览器调用,就像它们发生时一样。

如果发生通信错误,我们肯定无法从服务器获取数据,所以诚实的做法是拒绝我们的承诺:

request.onerror = function(){
  reject(this.status + " " + this.statusText);
};

如果我们收到服务器的响应,我们必须分析该响应并考虑具体情况。不深入细节,服务器可以响应各种内容,但在这个案例中,我们只关心响应是否成功(状态 200)。如果不是,我们再次拒绝承诺。

即使服务器已经成功响应了数据,这并不意味着我们已经安全。因为我们的目标是获取来自服务器的 JSON 格式对象,JSON 代码可能总是存在语法错误。这就是为什么,在调用JSON.parse方法时,我们用try-catch语句包围代码。如果在解析服务器响应时发生异常,我们也会拒绝承诺。这样,我们就处理了所有可能发生的糟糕情况。

如果一切按计划进行,并且我们成功获取了我们的对象,我们可以安全地解析承诺。最后,我们可以使用我们的getJSON函数从服务器获取忍者:

getJSON("data/ninjas.json").then(ninjas => {
  assert(ninjas !== null, "Ninjas obtained!");
}).catch(e => fail("Shouldn't be here:" + e));

在这种情况下,我们有三个潜在的错误来源:服务器和客户端之间建立通信时的错误、服务器响应了未预料到的数据(无效的响应状态),以及无效的 JSON 代码。但从使用getJSON函数的代码的角度来看,我们并不关心错误的具体来源。我们只提供一个回调,当一切顺利且数据正确接收时触发,以及一个回调,当发生任何错误时触发。这使得我们的开发生活变得更加容易。

现在,我们将更进一步,探索承诺的另一个重大优势:它们的优雅组合。我们将从一系列不同的步骤中链式连接几个承诺开始。

6.3.5. 链式承诺

你已经看到了如何处理一系列相互依赖的步骤会导致“末日金字塔”,这是一个深度嵌套且难以维护的回调序列。承诺(Promises)是解决该问题的第一步,因为它们具有链式调用的能力。

在本章前面,您看到通过在承诺上使用 then 方法,我们可以注册一个回调,如果承诺成功解决,则执行该回调。我们没有告诉您的是,调用 then 方法也会返回一个新的承诺。因此,我们没有任何阻止我们链入任意数量的 then 方法;请参见以下代码。

列表 6.16. 使用 then 链接承诺

图片

这将创建一系列承诺,如果一切按计划进行,将依次解决。首先,我们使用 getJSON("data/ninjas.json") 方法从服务器上的文件获取忍者列表。收到该列表后,我们获取第一个忍者的信息,并请求该忍者分配的任务列表:getJSON(ninjas[0].missionsUrl)。稍后,当这些任务到来时,我们再次请求第一个任务的详细信息:getJSON(missions[0].details-Url)。最后,我们记录任务的详细信息。

使用标准回调编写此类代码会导致深度嵌套的回调序列。确定确切的步骤顺序可能不容易,而且我们绝对不希望在中途添加额外的步骤。

链接承诺中的错误捕获

在处理一系列异步步骤时,任何步骤都可能发生错误。我们已经知道,我们可以在 then 调用中提供一个第二个错误回调,或者可以链入一个带有错误回调的 catch 调用。当我们只关心整个步骤序列的成功/失败时,为每个步骤提供特殊错误处理可能很繁琐。所以,如 列表 6.16 所示,我们可以利用之前看到的 catch 方法:

...catch(error => fail("An error has occurred:" + err));

如果之前的任何承诺发生失败,catch 方法将捕获它。如果没有发生错误,程序流程将继续通过它,不受阻碍。

与常规回调相比,使用承诺处理一系列步骤要方便得多,不是吗?但它仍然不如它本可以的那样优雅。我们很快就会看到这一点,但首先让我们看看如何使用承诺来处理并行异步步骤。

6.3.6. 等待多个承诺

除了帮助我们处理一系列相互依赖的异步步骤外,承诺还显著减轻了等待多个独立异步任务的负担。让我们回顾一下我们的例子,其中我们想要并行收集关于我们可用的忍者的信息、计划的复杂性以及计划将启动的位置的地图。使用承诺,这就像以下列表中所示的那样简单。

列表 6.17. 使用 Promise.all 等待多个承诺

图片

如您所见,我们不必关心任务执行的顺序,也不必关心其中一些任务是否已完成,而另一些任务尚未完成。我们通过使用内置的 Promise.all 方法来声明我们想要等待一定数量的承诺。此方法接受一个承诺数组,并创建一个的承诺,当所有传入的承诺都成功解决时,该承诺成功解决,如果任何一个承诺失败,则拒绝。成功回调接收一个成功值数组,每个传入的承诺一个,按顺序排列。花一分钟来欣赏使用承诺处理多个并行异步任务的代码的优雅性。

Promise.all 方法等待列表中的所有承诺。但有时我们有许多承诺,但我们只关心第一个成功(或失败)的承诺。认识一下 Promise.race 方法。

竞赛承诺

假设我们有一组可供我们使用的忍者,并且我们想要将任务分配给第一个回应我们召唤的忍者。在处理承诺时,我们可以编写如下所示的内容。

列表 6.18. 使用 Promise.race 竞赛承诺
Promise.race([getJSON("data/yoshi.json"),
              getJSON("data/hattori.json"),
              getJSON("data/hanzo.json")])
       .then(ninja => {
         assert(ninja !== null, ninja.name + " responded first");
        }).catch(error => fail("Failure!"));

如此简单。无需手动跟踪一切。我们使用 Promise.race 方法接受一个承诺数组,并返回一个完全的承诺,该承诺在第一个承诺解决或拒绝时立即解决或拒绝。

到目前为止,你已经看到了承诺是如何工作的,以及我们如何使用它们来极大地简化处理一系列异步步骤,无论是顺序还是并行。虽然与普通的回调相比,在错误处理和代码优雅性方面有所改进,但承诺化的代码仍然没有达到简单同步代码的优雅水平。在下一节中,我们将介绍本章中引入的两个主要概念,生成器承诺,它们结合在一起提供了同步代码的简单性和异步代码的非阻塞特性。

6.4. 结合生成器和承诺

在本节中,我们将结合生成器(及其暂停和恢复执行的能力)与承诺,以实现更优雅的异步代码。我们将使用一个功能示例,该功能允许用户获取最受欢迎的忍者完成的最高评级任务的详细信息。代表忍者的数据、他们任务的摘要以及任务详情都存储在远程服务器上,编码为 JSON 格式。

所有这些子任务都是长时间运行且相互依赖的。如果我们以同步方式实现它们,我们会得到以下简单的代码:

try {
   const ninjas = syncGetJSON("data/ninjas.json");
   const missions = syncGetJSON(ninjas[0].missionsUrl);
   const missionDetails = syncGetJSON(missions[0].detailsUrl);
   //Study the mission description
} catch(e){
  //Oh no, we weren't able to get the mission details
}

尽管这段代码在简单性和错误处理方面很出色,但它会阻塞用户界面,导致用户不满。理想情况下,我们希望修改此代码,以便在长时间运行的任务期间不发生阻塞。实现这一目标的一种方法是将生成器和承诺结合起来。

如我们所知,从生成器中产生会暂停生成器的执行,但不会阻塞。为了唤醒生成器并继续其执行,我们必须在生成器的迭代器上调用 next 方法。另一方面,承诺允许我们指定一个回调,当能够获取承诺的值时将被触发,以及一个回调,当发生错误时将被触发。

因此,我们的想法是将生成器和承诺以以下方式结合:我们将使用异步任务的代码放在生成器中,并执行该生成器函数。当我们到达生成器执行中调用异步任务的位置时,我们创建一个代表该异步任务值的承诺。因为我们不知道这个承诺何时会解决(甚至是否会被解决),在这个生成器执行点,我们从生成器中产生,这样我们就不会造成阻塞。过了一段时间,当承诺得到解决后,我们通过调用迭代器的 next 方法继续生成器的执行。我们根据需要这样做多次。请参见以下列表以获取实际示例。

列表 6.19. 结合生成器和承诺

async 函数接受一个生成器,调用它,并创建一个迭代器,该迭代器将用于恢复生成器的执行。在 async 函数内部,我们声明一个 handle 函数,该函数处理生成器的一个返回值——迭代器的一次“迭代”。如果生成器结果是一个成功解决的承诺,我们使用迭代器的 next 方法将承诺的值发送回生成器并恢复生成器的执行。如果发生错误并且承诺被拒绝,我们通过迭代器的 throw 方法将错误抛给生成器(告诉过你会派上用场)。我们一直这样做,直到生成器表示它已完成。

注意

这是一个粗略的草图,结合生成器和承诺所需的最小代码量。我们不推荐在生产环境中使用此代码。

现在让我们更仔细地看看生成器。在迭代器的next方法第一次被调用时,生成器会执行到第一个getJSON("data/ninjas.json")调用。这个调用创建了一个最终将包含我们ninjas信息列表的承诺。因为这个值是异步获取的,所以我们不知道浏览器获取它需要多少时间。但我们知道一件事:我们不希望在等待时阻塞应用程序的执行。正因为如此,在执行的这个时刻,生成器交出控制权,暂停生成器,并将控制流返回到handle函数的调用。因为产生的值是一个getJSON承诺,在handle函数中,通过使用承诺的thencatch方法,我们注册了一个成功和错误回调,并继续执行。有了这个,控制流离开了handle函数的执行和async函数的主体,并在调用async函数之后继续(在我们的例子中,之后没有更多的代码,所以它处于空闲状态)。在这段时间里,我们的生成器函数耐心地等待挂起,不会阻塞程序执行。

在很久以后,当浏览器收到响应(无论是积极的还是消极的)时,其中一个承诺回调会被调用。如果承诺成功解决,则调用成功回调,这反过来又会导致迭代器的next方法的执行,该方法要求生成器提供另一个值。这意味着我们在第一次yield表达式之后重新进入生成器的主体,其值成为从服务器异步获取的ninjas列表。生成器函数的执行继续,并将值分配给plan变量。

在生成器的下一行中,我们使用一些获取到的数据,ninjas[0].missionUrl,来发起另一个getJSON调用,该调用创建另一个承诺,该承诺最终应包含最受欢迎的忍者完成的任务列表。同样,由于这是一个异步任务,我们不知道它将花费多长时间,所以我们再次交出执行并重复整个过程。

这个过程会一直重复,直到生成器中还有异步任务。

这有点复杂,但我们喜欢这个例子,因为它结合了你迄今为止学到的大部分内容:

  • 函数作为一等对象— 我们将一个函数作为参数传递给async函数。

  • 生成器函数— 我们使用它们暂停和恢复执行的能力。

  • 承诺— 它们帮助我们处理异步代码。

  • 回调— 我们在我们的承诺上注册成功和失败回调。

  • 箭头函数— 由于它们的简单性,对于回调我们使用箭头函数。

  • 闭包—— 通过async函数创建的迭代器,用于控制生成器,我们通过闭包在承诺回调中访问它。

现在我们已经完成了整个过程,让我们花一分钟来欣赏一下实现我们业务逻辑的代码是多么的优雅。考虑以下内容:

getJSON("data/ninjas.json", (err, ninjas) => {
  if(err) { console.log("Error fetching ninjas", err); return; }

  getJSON(ninjas[0].missionsUrl, (err, missions) => {
     if(err) { console.log("Error locating ninja missions", err); return; }
     console.log(misssions);
  })
});

与混合控制流和错误处理以及稍微有些混乱的代码不同,我们最终得到如下内容:

async(function*() {
  try {
    const ninjas = yield getJSON("data/ninjas.json");
    const missions = yield getJSON(ninjas[0].missionsUrl);

    //All information recieved
  }
  catch(e) {
    //An error has occurred
  }
});

这种最终结果结合了同步和异步代码的优点。从同步代码中,我们有易于理解,以及使用所有标准控制流和异常处理机制的能力,如循环和try-catch语句。从异步代码中,我们得到非阻塞的特性;在等待长时间运行的异步任务时,我们的应用程序的执行不会被阻塞。

6.4.1. 展望——异步函数

注意,我们仍然不得不编写一些样板代码;我们必须开发一个async函数来处理承诺并从生成器请求值。虽然我们只能编写这个函数一次,然后在我们的代码中重用它,但如果我们可以不必考虑它,那就更好了。JavaScript 的负责人非常清楚生成器和承诺结合的有用性,他们希望通过内置直接语言支持来混合生成器和承诺,从而使我们的生活更加轻松。

对于这些情况,目前的计划是包含两个新的关键字asyncawait,将处理这些样板代码。不久,我们将能够写出如下内容:

(async function (){
  try {
    const ninjas = await getJSON("data/ninjas.json");
    const missions = await getJSON(missions[0].missionsUrl);

    console.log(missions);
  }
  catch(e) {
    console.log("Error: ", e);
  }
})()

我们在函数关键字前使用async关键字来指定该函数依赖于异步值,在调用每个异步任务的地方,我们放置await关键字,告诉 JavaScript 引擎请等待此结果而不阻塞。在后台,一切正如我们在本章前面讨论的那样发生,但现在我们不需要担心它。

注意

异步函数将在 JavaScript 的下一部分中介绍。目前没有浏览器支持它,但如果你想在代码中使用异步,可以使用 Babel 或 Traceur 等转换器。

6.5. 摘要

  • 生成器是生成值序列的函数——不是一次性生成,而是在每次请求的基础上生成。

  • 与标准函数不同,生成器可以暂停和恢复它们的执行。生成器生成一个值后,它会暂停执行而不阻塞主线程,并耐心地等待下一个请求。

  • 生成器通过在函数关键字后放置一个星号(*)来声明。在生成器的主体中,我们可以使用新的yield关键字,它产生一个值并暂停生成器的执行。如果我们想向另一个生成器产生值,我们使用yield*运算符。

  • 调用一个生成器会通过一个迭代器对象创建一个迭代器,我们可以通过这个迭代器控制生成器的执行。我们通过使用迭代器的 next 方法从生成器请求新值,甚至可以通过调用迭代器的 throw 方法向生成器抛出异常。此外,next 方法还可以用来向生成器发送值。

  • 承诺是计算结果的占位符;它保证我们最终会知道计算的结果,通常是异步计算的结果。承诺可以成功或失败,一旦完成,就不会再有变化。

  • 承诺显著简化了我们处理异步任务的方式。我们可以通过使用 then 方法链承诺来轻松地处理一系列相互依赖的异步步骤。并行处理多个异步步骤也大大简化了;我们使用 Promise.all 方法。

  • 我们可以将生成器和承诺结合使用,以同步代码的简单性处理异步任务。

6.6. 练习

1

运行以下代码后,变量 a1a4 的值是什么?

function *EvenGenerator(){
  let num = 2;
  while(true){
    yield num;
    num = num + 2;
  }
}

let generator = EvenGenerator();

let a1 = generator.next().value;
let a2 = generator.next().value;
let a3 = EvenGenerator().next().value;
let a4 = generator.next().value;

2

运行以下代码后,ninjas 数组的内文是什么?(提示:考虑如何使用 while 循环实现 for-of 循环。)

function* NinjaGenerator(){
  yield "Yoshi";
  return "Hattori";
  yield "Hanzo";
}

var ninjas = [];
for(let ninja of NinjaGenerator()){
  ninjas.push(ninja);
}

ninjas;

3

运行以下代码后,变量 a1a2 的值是什么?

function *Gen(val){
  val = yield val * 2;
  yield val;
}

let generator = Gen(2);
let a1 = generator.next(3).value;
let a2 = generator.next(4).value;

4

以下代码的输出是什么?

const promise = new Promise((resolve, reject) => {
  reject("Hattori");
});

promise.then(val => alert("Success: " + val))
       .catch(e => alert("Error: " + e));

5

以下代码的输出是什么?

const promise = new Promise((resolve, reject) => {
  resolve("Hattori");
  setTimeout(()=> reject("Yoshi"), 500);
});

promise.then(val => alert("Success: " + val))
       .catch(e => alert("Error: " + e));

第三部分. 深入对象并加强你的代码

现在你已经了解了函数的方方面面,我们将通过更仔细地研究第七章第七章中的对象基础来继续我们的 JavaScript 探索。

在第八章中,我们将学习如何使用获取器和设置器以及代理来控制对对象的访问并监控我们的对象,代理是 JavaScript 中一种全新的对象类型。

我们将在第九章中查看集合——传统的如数组,以及全新的类型如映射和集合。

从那里,我们将继续到第十章第十章中的正则表达式。你将了解到,许多以前需要大量代码才能完成的任务,通过正确使用 JavaScript 正则表达式,可以简化为仅仅几个语句。

最后,在第十一章第十一章中,我们将向您展示如何将 JavaScript 应用程序结构化为更小、更组织化的功能单元,称为模块。

第七章. 使用原型的面向对象

本章涵盖

  • 探索原型

  • 使用函数作为构造函数

  • 使用原型扩展对象

  • 避免常见陷阱

  • 使用继承构建类

你已经了解到函数是 JavaScript 中的第一类对象,闭包使它们变得极其灵活和有用,并且你可以将生成器函数与承诺结合使用来解决异步代码的问题。现在我们准备解决 JavaScript 的另一个重要方面:对象原型。

一个原型是一个对象,可以将其特定属性的搜索委托给它。原型是定义将自动对其他对象可访问的属性和功能的一种方便方式。原型在经典面向对象语言中类似于类的作用。确实,原型在 JavaScript 中的主要用途是产生类似但又不完全像 Java 或 C#等更传统、基于类的语言中的代码的面向对象代码。

在本章中,我们将深入研究原型的工作原理,研究它们与构造函数的联系,并了解如何模仿在其他更传统的面向对象语言中经常使用的某些面向对象特性。我们还将探索 JavaScript 的一个新特性,即class关键字,它并不完全将功能齐全的类引入 JavaScript,但确实使我们能够轻松地模仿类和继承。让我们开始探索。

你知道吗?

Q1:

你如何测试一个对象是否可以访问特定的属性?

Q2:

为什么原型链在 JavaScript 中处理对象时很重要?

Q3:

ES6 类是否会改变 JavaScript 处理对象的方式?

7.1. 理解原型

在 JavaScript 中,对象是由具有值的命名属性组成的集合。例如,我们可以轻松地使用对象字面量语法创建新的对象:

168fig01.jpg

如我们所见,对象属性可以是简单的值(如数字或字符串)、函数,甚至是其他对象。此外,JavaScript 是一种高度动态的语言,我们可以通过修改和删除现有属性来轻松地更改分配给对象的属性:

168fig02_alt.jpg

我们甚至可以添加完全新的属性:

168fig03_alt.jpg

最后,所有这些修改都没有改变我们简单对象的状态:

{
  prop1: [],
  prop3: {},
  prop4: "Hello"
};

在开发软件时,我们努力不重复造轮子,因此我们希望尽可能多地重用代码。一种代码重用形式也有助于组织我们的程序,那就是继承,将一个对象的特性扩展到另一个对象。在 JavaScript 中,继承是通过原型实现的。

原型的概念很简单。每个对象都可以有一个对其prototype的引用,这是一个对象,如果对象本身没有该属性,则可以将对该属性的搜索委托给该对象。想象一下,你在一群人中参加一个游戏问答,游戏主持人问你一个问题。如果你知道答案,你会立即给出,如果你不知道,你会问旁边的人。就这么简单。

让我们看一下下面的代码示例。

列表 7.1. 使用原型,对象可以访问其他对象的属性

169fig01_alt.jpg

在这个例子中,我们首先创建了三个对象:yoshihattorikuma。每个对象都有一个特定的属性,只有该对象可以访问:只有yoshi可以skulk,只有hattori可以sneak,只有kuma可以creep。参见图 7.1。

图 7.1. 初始时,每个对象只能访问它自己的属性。

07fig01_alt.jpg

要测试一个对象是否可以访问特定的属性,我们可以使用in运算符。例如,执行skulk in yoshi返回true,因为yoshi可以访问skulk属性;而执行sneak in yoshi返回false

在 JavaScript 中,对象的prototype属性是一个内部属性,不能直接访问(所以我们用[[prototype]]标记它)。相反,内置方法Object.setPrototypeOf接受两个对象参数,并将第二个对象设置为第一个对象的原型。例如,调用Object.setPrototypeOf(yoshi, hattori);会将hattori设置为yoshi的原型。

因此,每当我们要询问yoshi一个它没有的属性时,yoshi会将该搜索委托给hattori。我们可以通过yoshi访问hattorisneak属性。参见图 7.2。

图 7.2. 当我们访问对象没有的属性时,会搜索对象的原型以查找该属性。在这里,我们可以通过yoshi访问hattorisneak属性,因为yoshihattori的原型。

07fig02_alt.jpg

我们可以用类似的方法处理 hattorikuma。通过使用 Object.setPrototypeOf 方法,我们可以将 kuma 设置为 hattori 的原型。如果我们然后询问 hattori 他没有的属性,搜索将会委托给 kuma。在这种情况下,hattori 现在可以访问 kumacreep 属性。参见 图 7.3。

图 7.3. 搜索特定属性会在没有更多原型可以探索时停止。访问 yoshi.creep 首先在 yoshi 中搜索,然后是 hattori,最后是 kuma

需要强调的是,每个对象都可以有一个原型,一个对象的原型也可以有一个原型,以此类推,形成一个 原型链。特定属性的搜索会在整个链上进行,直到没有更多的原型可以探索为止。例如,如图 7.3 所示,询问 yoshicreep 属性值会首先在 yoshi 中搜索该属性。因为属性未找到,所以会搜索 yoshi 的原型 hattori。再次,hattori 没有名为 creep 的属性,所以会搜索 hattori 的原型 kuma,最终找到该属性。

现在我们已经基本了解了通过原型链搜索特定属性的过程,接下来让我们看看在构造新对象时构造函数是如何使用原型的。

7.2. 对象构造和原型

创建新对象的最简单方法是使用如下语句:

const warrior = {};

这将创建一个新的空对象,然后我们可以通过赋值语句向其中添加属性:

const warrior = {};
warrior.name = 'Saito';
warrior.occupation = 'marksman';

但那些来自面向对象背景的人可能会错过类构造函数带来的封装和结构化,类构造函数是一个用于初始化对象到已知初始状态的功能。毕竟,如果我们打算创建同一类型对象的多个实例,逐个分配属性不仅麻烦,而且容易出错。我们希望能够在同一个地方合并一个对象类的一组属性和方法。

JavaScript 提供了这样的机制,尽管它的形式与其他大多数语言不同。像 Java 和 C++ 这样的面向对象语言,JavaScript 使用 new 操作符通过构造函数实例化新对象,但在 JavaScript 中没有真正的类定义。相反,将 new 操作符应用于构造函数(正如你在第三章中看到的),会触发一个新分配的对象的创建。

在前面的章节中,我们没有学习到的是每个函数都有一个原型对象,这个对象会自动设置为使用该函数创建的对象的原型。让我们看看以下列表中它是如何工作的。

列表 7.2. 使用原型方法创建新实例

在这段代码中,我们定义了一个看似什么也不做的函数Ninja,我们将以两种方式调用它:作为“正常”函数,const ninja1 = Ninja();以及作为构造函数,const ninja2 = new Ninja()

当函数被创建时,它立即为其原型对象分配一个新的对象,这个对象我们可以像任何其他对象一样扩展。在这种情况下,我们向其中添加一个swingSword方法:

Ninja.prototype.swingSword = function(){
  return true;
};

然后我们对这个函数进行测试。首先我们正常调用该函数并将结果存储在变量ninja1中。查看函数体,我们看到它没有返回任何值,所以我们预计ninja1将测试为undefined,我们断言这是true。作为一个简单的函数,Ninja看起来并不那么有用。

然后我们通过new运算符调用该函数,将其作为构造函数调用,这时会发生完全不同的事情。函数再次被调用,但这次创建了一个新的对象,并将其设置为函数的上下文(并且可以通过this关键字访问)。new运算符返回的是对这个新对象的引用。然后我们测试ninja2是否引用了这个新创建的对象,并且这个对象有一个我们可以调用的swingSword方法。参见图 7.4 以了解当前应用程序的状态。

图 7.4. 每个函数在创建时都会得到一个新的原型对象。当我们使用函数作为构造函数时,构造的对象的原型被设置为函数的原型。

图片

如您所见,函数在创建时得到一个新的对象,该对象被分配给其原型属性。原型对象最初只有一个属性,即constructor,它引用回函数(我们稍后会重新访问constructor属性)。

当我们使用函数作为构造函数(例如,通过调用new Ninja())时,新构造的对象的原型被设置为构造函数引用的对象。

在这个例子中,我们通过swingSword方法扩展了Ninja.prototype,当创建ninja2对象时,其prototype属性被设置为Ninja的原型。因此,当我们尝试在ninja2上访问swingSword属性时,对该属性的搜索被委托给Ninja原型对象。请注意,所有使用Ninja构造函数创建的对象都将能够访问swingSword方法。现在这就是代码复用!

swingSword方法是Ninja原型的属性,而不是ninja实例的属性。让我们来探讨实例属性和原型属性之间的区别。

7.2.1. 实例属性

当函数通过new运算符作为构造函数被调用时,其上下文被定义为新的对象实例。除了通过原型公开属性外,我们还可以通过this参数在构造函数内初始化值。让我们在下一列表中查看这种实例属性的创建。

列表 7.3. 观察初始化活动的优先级

图片

列表 7.3 与上一个示例类似,我们通过将其添加到构造函数的prototype属性中来定义swingSword方法:

Ninja.prototype.swingSword = function(){
   return this.swung;
};

但我们还在构造函数本身中添加了一个同名的方法:

function Ninja(){
  this.swung = false;
  this.swingSword = function(){
    return !this.swung;
  };
}

这两个方法被定义为返回相反的结果,这样我们就可以知道哪个将被调用。

注意

这不是我们在实际代码中建议做的事情;恰恰相反。我们在这里这样做只是为了演示属性的优先级。

当你运行测试时,你会看到它通过了!这表明实例成员将隐藏在原型中定义的同名属性。参见图 7.5。

图 7.5. 如果实例本身可以找到属性,甚至不会咨询原型!

图片

在构造函数内部,this关键字指向新创建的对象,因此构造函数内添加的属性是直接创建在新的ninja实例上的。稍后,当我们访问ninja上的swingSword属性时,不需要遍历原型链(如图 7.4 所示);在构造函数内创建的属性立即被找到并返回(参见图 7.5)。

这有一个有趣的副作用。看看图 7.6,它显示了如果我们创建三个ninja实例时应用程序的状态。

图 7.6. 每个实例都拥有在构造函数内创建的属性的独特版本,但它们都可以访问相同的原型属性。

图片

如您所见,每个ninja实例都拥有在构造函数内创建的属性的独特版本,同时它们都可以访问相同的原型属性。这对于特定于每个对象实例的值属性(例如,swung)来说是可行的。但在某些情况下,对于方法可能会出现问题。

在这个例子中,我们将有三个版本的swingSword方法,它们都执行相同的逻辑。如果我们创建几个对象,这并不是问题,但如果我们计划创建大量对象,那就需要注意了。因为每个方法副本的行为都是相同的,创建多个副本通常没有意义,因为它只会消耗更多的内存。当然,一般来说,JavaScript 引擎可能会执行一些优化,但这不是我们可以依赖的。从这个角度来看,将对象方法仅放在函数的原型上是有意义的,因为这样我们就有了一个所有对象实例共享的单个方法。

注意

记住 第五章 中的闭包:在构造函数中定义的方法允许我们模拟私有对象变量。如果我们需要这样做,在构造函数中指定方法是唯一的方法。

7.2.2. JavaScript 动态特性的副作用

您已经看到 JavaScript 是一种动态语言,其中的属性可以随意添加、删除和修改。同样,这也适用于原型,无论是函数原型还是对象原型。请参见以下列表。

列表 7.4. 使用原型,一切都可以在运行时更改

图片

图片

在这里我们再次定义一个 Ninja 构造函数,并继续使用它来创建一个对象实例。此时应用程序的状态如图 7.7 所示。

图 7.7. 构造之后,ninja1swung 属性,其原型是只有一个 constructor 属性的 Ninja 原型。

图片

实例创建之后,我们在原型中添加了一个 swingSword 方法。然后我们运行一个测试来显示在对象构造之后对原型所做的更改生效。应用程序的当前状态如图 7.8 所示。

图 7.8. 因为 ninja1 实例引用了 Ninja 原型,所以即使在实例创建之后所做的更改也可以访问。

图片

之后,我们通过将其赋值给一个具有 pierce 方法的全新对象来覆盖 Ninja 函数的原型。这导致应用程序的状态如图 7.9 所示。

图 7.9. 函数的原型可以随意替换。已经构造的实例引用的是旧的原型!

图片

如您所见,尽管 Ninja 函数没有引用旧的 Ninja 原型,但旧的原型仍然被 ninja1 实例所保持活跃,它仍然可以通过原型链访问 swingSword 方法。但如果我们在这个原型切换之后创建新的对象,应用程序的状态将如图 7.10 所示。

图 7.10. 所有新创建的实例都引用新的原型。

图片

对象与函数原型的引用是在对象实例化时建立的。新创建的对象将引用新的原型,并将能够访问 pierce 方法,而旧的、在原型更改之前创建的对象则保持其原始原型,快乐地挥舞着他们的剑。

我们已经探讨了原型的工作原理以及它们与对象实例化的关系。做得好!现在深吸一口气,我们可以继续学习更多关于这些对象特性的知识。

7.2.3. 通过构造函数进行对象类型化

虽然了解 JavaScript 如何使用原型来找到正确的属性引用是很好的,但知道哪个函数构造了对象实例也很方便。正如你之前看到的,对象的构造函数可以通过构造函数原型的constructor属性来访问。例如,图 7.11 显示了我们使用Ninja构造函数实例化对象时的应用程序状态。

图 7.11. 每个函数的原型对象都有一个指向函数的constructor属性。

通过使用constructor属性,我们可以访问用于创建对象的函数。这些信息可以用作类型检查的一种形式,如下一列表所示。

列表 7.5. 检查实例的类型及其构造函数

我们定义了一个构造函数并使用它来创建一个对象实例。然后我们通过使用typeof运算符来检查实例的类型。这并没有揭示太多,因为所有实例都将返回object,因此总是返回object作为结果。更有趣的是instanceof运算符,它为我们提供了一种确定实例是否由特定函数构造函数创建的方法。你将在本章后面了解更多关于instanceof运算符的工作原理。

此外,我们可以使用现在我们知道对所有实例都可访问的constructor属性,作为创建它的原始函数的引用。我们可以使用这一点来验证实例的来源(就像我们可以使用instanceof运算符一样)。

此外,因为这只是对原始构造函数的引用,我们可以使用它来实例化一个新的Ninja对象,如下一列表所示。

列表 7.6. 使用构造函数的引用来实例化新对象

在这里,我们定义了一个构造函数并使用该构造函数创建了一个实例。然后我们使用创建的实例的constructor属性来构造第二个实例。测试显示,已经构造了第二个Ninja实例,并且变量不仅仅指向同一个实例。

尤其有趣的是,我们甚至不需要访问原始函数就能做到这一点;我们可以在幕后完全使用引用,即使原始构造函数已经不在作用域内。

备注

虽然对象的constructor属性可以被更改,但这样做并没有任何立即或明显的建设性目的(尽管我们可能能够想到一些恶意用途)。这个属性存在的理由是指出对象是从哪里构造的。如果constructor属性被覆盖,原始值就会丢失。

这都是很有用的,但我们只是刚刚触及了原型赋予我们的超级能力的表面。现在事情变得有趣起来。

7.3. 实现继承

继承是一种重用形式,其中新对象可以访问现有对象的属性。这有助于我们避免在代码库中重复代码和数据。在 JavaScript 中,继承的工作方式与其他流行的面向对象语言略有不同。考虑以下列表,我们试图实现继承。

列表 7.7. 尝试使用原型实现继承

图片

因为函数的原型是一个对象,所以有多种方式可以复制功能(如属性或方法)以实现继承。在这段代码中,我们定义了一个“人”,然后定义了一个“忍者”。因为“忍者”显然是一个人,我们希望“忍者”继承“人”的属性。我们尝试通过将“人”原型方法中的dance属性复制到“忍者”原型中同名属性中来实现这一点。

运行我们的测试显示,尽管我们可能已经教会了忍者跳舞,但我们未能使“忍者”成为“人”,如图 7.12 所示。我们教会了“忍者”模仿人的舞蹈,但这并没有使“忍者”成为“人”。这不是继承——这只是复制。

图 7.12. 我们的“忍者”实际上不是一个“人”。没有快乐的舞蹈!

图片

除了这种方法并不完全奏效之外,我们还需要将“人”的每个属性单独复制到“忍者”的原型中。这不是继承的正确方法。让我们继续探索。

我们真正想要实现的是一个原型链,使得一个“忍者”可以成为一个“人”,一个“人”可以成为“哺乳动物”,一个“哺乳动物”可以成为“动物”,以此类推,一直到“对象”。创建这种原型链的最佳技术是使用一个对象的实例作为另一个对象的原型:

*SubClass*.prototype = new *SuperClass*();

例如:

Ninja.prototype = new Person();

这保留了原型链,因为“子类”实例的原型将是一个“超类”的实例,该实例具有“超类”的所有属性,并且它将反过来有一个指向其超类实例的原型,以此类推。在下一个列表中,我们稍微修改了列表 7.7 以使用这种技术。

列表 7.8. 使用原型实现继承

图片

代码的唯一更改是将“人”的一个实例用作“忍者”的原型。运行测试显示我们已经成功,如图 7.13 所示。现在我们将通过查看创建新“忍者”对象后的应用程序状态来更详细地了解其内部工作原理,如图 7.14 所示。

图 7.13. 我们的“忍者”是一个“人”!胜利的舞蹈开始吧。

图片

图 7.14. 我们通过将“忍者”构造函数的原型设置为“人”对象的新实例来实现继承。

图片

图 7.14 显示,当我们定义一个Person函数时,也会创建一个Person原型,它通过其constructor属性引用Person函数。通常,我们可以通过扩展Person原型来添加额外的属性,在这种情况下,我们指定每个使用Person构造函数创建的人都可以访问dance方法:

function Person(){}
Person.prototype.dance = function(){};

我们还定义了一个Ninja函数,它具有自己的原型对象,该对象具有一个引用Ninja函数的constructor属性:function Ninja(){}

接下来,为了实现继承,我们将Ninja函数的原型替换为一个新的Person实例。现在,当我们创建一个新的Ninja对象时,新创建的ninja对象的内部原型属性将被设置为当前Ninja原型属性指向的对象,即之前构建的Person实例:

function Ninja(){}
Ninja.prototype = new Person();
var ninja = new Ninja();

当我们尝试通过ninja对象访问dance方法时,JavaScript 运行时会首先检查ninja对象本身。因为它没有dance属性,所以会搜索其原型,即person对象。person对象也没有dance属性,所以会继续搜索其原型,最终找到该属性。这就是 JavaScript 中实现继承的方法!

这里有一个重要的含义:当我们执行instanceof操作时,我们可以确定函数是否继承了其原型链中任何对象的函数功能。

注意

另一种可能已经想到的技术,但我们强烈建议不要使用,是将Person原型对象直接用作Ninja原型,如下所示:Ninja.prototype = Person.prototype。然后,对Ninja原型的任何更改也将更改Person原型(因为它们是同一个对象),这很可能会产生不良的副作用。

以这种方式进行原型继承的另一个额外的好处是,所有继承的函数原型都将继续实时更新。从原型继承的对象始终可以访问当前的原型属性。

7.3.1. 覆盖构造函数属性的问题

如果我们仔细观察图 7.14,我们会看到,通过将新的Person对象设置为Ninja构造函数的原型,我们已经失去了与原始Ninja原型保持的联系的Ninja构造函数。这是一个问题,因为constructor属性可以用来确定创建对象时所使用的函数。有人使用我们的代码可能会做出一个完全合理的假设,即以下测试将会通过:

assert(ninja.constructor === Ninja,
      "The ninja object was created by the Ninja constructor");

但在当前的应用状态中,这个测试失败了。如图 7.14 所示,如果我们搜索ninja对象中的constructor属性,我们将找不到它。因此,我们转向其原型,它也没有constructor属性,然后我们再次跟随原型,最终到达Person的原型对象,它有一个指向Person函数的constructor属性。实际上,我们得到了错误的答案:如果我们询问ninja对象哪个函数构建了它,我们将得到Person作为答案。这可能是某些严重错误的来源。

这取决于我们如何解决这个问题!但在我们能够这样做之前,我们必须绕道而行,看看 JavaScript 是如何使我们能够配置属性的。

配置对象属性

在 JavaScript 中,每个对象属性都是通过一个属性描述符来描述的,通过它可以配置以下键:

  • configurable——如果设置为true,属性的描述符可以被更改,属性可以被删除。如果设置为false,我们无法执行这两件事。

  • enumerable——如果设置为true,属性将在遍历对象属性的for-in循环中显示(我们很快就会了解到for-in循环)。

  • value——指定属性的值。默认为undefined

  • writable——如果设置为true,属性值可以通过赋值来更改。

  • get——定义获取器函数,当访问属性时将被调用。不能与valuewritable一起定义。

  • set——定义设置器函数,当对属性进行赋值时将被调用。也不能与valuewritable一起定义。

假设我们通过简单的赋值创建一个属性,例如:

ninja.name = "Yoshi";

这个属性将是可配置的、可枚举的、可写的,其值将被设置为Yoshi,而getset函数将是undefined

当我们想要微调我们的属性配置时,我们可以使用内置的Object.defineProperty方法,它接受一个对象,该对象将定义属性,属性的名字,以及一个属性描述符对象。以下是一个例子。

列表 7.9. 配置属性

我们从一个空对象开始,向其中添加两个属性:nameweapon,按照传统的方式,通过使用赋值。接下来,我们使用内置的Object.defineProperty方法来定义属性sneaky,它不是configurable的,不是enumerable的,并且其value被设置为true。因为这个value可以被更改,所以它是writable的。

最后,我们测试我们是否可以访问新创建的sneaky属性,我们使用for-in循环遍历对象的所有可枚举属性。图 7.15 显示了结果。

图 7.15. 在for-in循环中将被访问的属性是nameweapon,而我们的特殊添加的sneaky属性则不会(即使我们通常可以访问它)。

图片 3

通过将 enumerable 设置为 false,我们可以确保属性在使用 for-in 循环时不会出现。为了理解我们为什么要这样做,让我们回到原始问题。

最终解决了覆盖构造函数属性的问题

当尝试用 Ninja 扩展 Person(或者使 Ninja 成为 Person 的子类)时,我们遇到了以下问题:当我们把一个新的 Person 对象设置为 Ninja 构造函数的原型时,我们失去了原本的 Ninja 原型,它保留了我们的 constructor 属性。我们不希望失去 constructor 属性,因为它对于确定创建我们的对象实例所用的函数很有用,并且其他正在我们代码库上工作的开发者可能会期望它。

我们可以通过使用我们刚刚获得的知识来解决这个问题。我们将使用 Object.defineProperty 方法在新的 Ninja.prototype 上定义一个新的 constructor 属性。请参见以下列表。

列表 7.10. 修复构造函数属性问题

图片 4

现在如果我们运行代码,我们会看到一切都很顺利。我们已经重新建立了 ninja 实例和 Ninja 函数之间的联系,因此我们可以知道它们是由 Ninja 函数构建的。此外,如果有人尝试遍历 Ninja.prototype 对象的属性,我们已经确保我们的修补属性 constructor 不会被访问。现在这就是真正的忍者的标志;我们进去,完成了我们的工作,然后离开,没有人注意到外面有任何变化!

7.3.2. instanceof 操作符

在大多数编程语言中,检查一个对象是否属于类层次结构的一种直接方法就是使用 instanceof 操作符。例如,在 Java 中,instanceof 操作符通过检查左侧的对象是否与右侧的类类型相同或是一个子类来工作。

尽管可以与 JavaScript 中的 instanceof 操作符的工作方式建立某些相似之处,但也有一些细微差别。在 JavaScript 中,instanceof 操作符在对象的原型链上工作。例如,假设我们有以下表达式:

ninja instanceof Ninja

instanceof 操作符通过检查 Ninja 函数的 当前 原型是否在 ninja 实例的原型链中来工作。让我们回到我们的人物和忍者,以一个更具体的例子。

列表 7.11. 研究 instanceof 操作符

图片 1

如预期,忍者同时是 NinjaPerson。但是,为了确定这一点,图 7.16 展示了幕后整个工作的过程。

图 7.16. ninja 实例的原型链由一个 new Person() 对象和 Person prototype 组成。

图片 2

ninja实例的原型链由一个new Person()对象组成,通过它我们实现了继承,以及Person prototype。当评估表达式ninja instanceof Ninja时,JavaScript 引擎会取Ninja函数的原型,new Person()对象,并检查它是否在ninja实例的原型链中。因为new Person()对象是ninja实例的直接原型,所以结果是true

在第二种情况下,当我们检查ninja instanceof Person时,JavaScript 引擎会取Person函数的原型,Person prototype,并检查它是否可以在ninja实例的原型链中找到。同样,它可以找到,因为它是我们new Person()对象的原型,正如我们之前看到的,它是ninja实例的原型。

关于instanceof运算符的所有知识就这些了。尽管它最常见的使用是在提供一个明确的方式来确定一个实例是否是由特定的函数构造器创建的,但它并不完全是这样工作的。相反,它检查右侧函数的原型是否在左侧对象的原型链中。因此,有一个需要注意的警告。

instanceof 的注意事项

正如你在本章中多次看到的,JavaScript 是一种动态语言,在程序执行期间我们可以修改很多东西。例如,没有阻止我们改变构造函数的原型,如下面的列表所示。

列表 7.12. 注意构造函数原型的变化

图片

在这个例子中,我们再次重复了创建ninja实例的所有基本步骤,我们的第一次测试进行得很顺利。但如果我们在创建ninja实例之后改变Ninja构造函数的原型,然后再测试ninja是否是Ninja的实例,我们会看到情况已经改变。这只会让我们感到惊讶,如果我们坚持认为instanceof运算符告诉我们实例是否是由特定的函数构造器创建的。另一方面,如果我们接受instanceof运算符的真实语义——它只检查右侧函数的原型是否在左侧对象的原型链中——我们就不会感到惊讶。这种情况在图 7.17 中显示。

![图 7.17]. instanceof运算符检查右侧函数的原型是否在左侧对象的原型链中。请注意;函数的原型可以随时更改!

图片

现在我们已经了解了 JavaScript 中原型的工作方式,以及如何结合构造函数使用原型来实现继承,让我们继续探讨 JavaScript ES6 版本中的新特性:类。

7.4. 在 ES6 中使用 JavaScript“类”

很好,JavaScript 允许我们通过原型的方式使用一种继承形式。但许多开发者,尤其是那些有经典面向对象背景的开发者,更希望将 JavaScript 的继承系统简化或抽象成一个他们更熟悉的系统。

这不可避免地引导我们进入类的领域,尽管 JavaScript 本身不支持原生的经典继承。作为对这个需求的回应,已经出现了几个模拟经典继承的 JavaScript 库。因为每个库都以自己的方式实现类,ECMAScript 委员会已经标准化了模拟基于类继承的语法。注意我们是如何说“模拟”的。尽管现在我们可以在 JavaScript 中使用class关键字,但底层实现仍然是基于原型继承的!

注意

class关键字已被添加到 JavaScript 的 ES6 版本中,并且并非所有浏览器都实现了它(有关当前支持情况,请参阅mng.bz/3ykA)。

让我们从学习新的语法开始。

7.4.1. 使用类关键字

ES6 引入了一个新的class关键字,它提供了一个比手动使用原型实现对象和继承更优雅的方法。使用class关键字很简单,如下面的列表所示。

列表 7.13. 在 ES6 中创建类

列表 7.13 展示了我们可以通过使用class关键字来创建一个Ninja类。在创建 ES6 类时,我们可以显式定义一个在实例化Ninja实例时将被调用的constructor函数。在构造函数体内,我们可以使用this关键字访问新创建的实例,并且可以轻松添加新的属性,例如name属性。在类体内,我们还可以定义所有Ninja实例都可以访问的方法。在这种情况下,我们定义了一个返回trueswingSword方法:

class Ninja{
  constructor(name){
    this.name = name;
  }

  swingSword(){
    return true;
  }
}

接下来,我们可以通过使用关键字new调用Ninja类来创建一个Ninja实例,就像我们之前在章节中用Ninja作为简单的构造函数一样:

var ninja = new Ninja("Yoshi");

最后,我们可以测试ninja实例是否按预期行为,即它是一个instance-of Ninja,有一个name属性,并且可以访问swingSword方法:

assert(ninja instanceof Ninja, "Our ninja is a Ninja");
assert(ninja.name === "Yoshi", "named Yoshi");
assert(ninja.swingSword(), "and he can swing a sword");
类是语法糖

如前所述,尽管 ES6 引入了class关键字,但底层我们仍然处理的是古老的、好的原型;类是语法糖,旨在使我们在模拟 JavaScript 中的类时生活更加轻松。

我们从列表 7.13 中的类代码可以转换为功能上相同的 ES5 代码:

function Ninja(name) {
  this.name = name;
}
Ninja.prototype.swingSword = function() {
  return true;
};

如您所见,ES6 类并没有什么特别新的地方。代码更加优雅,但应用的概念是相同的。

静态方法

在前面的例子中,你看到了如何定义对象方法(原型方法),这些方法对所有对象实例都是可访问的。除了这些方法之外,像 Java 这样的经典面向对象语言还使用静态方法,即在类级别上定义的方法。查看以下示例。

列表 7.14. ES6 中的静态方法

我们再次创建一个具有 swingSword 方法的 Ninja 类,这个方法可以从所有 ninja 实例中访问。我们还通过在方法名前加关键字 static 定义了一个静态方法 compare

static compare(ninja1, ninja2){
    return ninja1.level - ninja2.level;
}

compare 方法,它比较两个忍者的技能水平,是在类级别上定义的,而不是实例级别!稍后我们将测试这实际上意味着 compare 方法不能从 ninja 实例中访问,但可以从 Ninja 类中访问:

assert(!("compare" in ninja1) && !("compare" in ninja2),
       "The ninja instance doesn't know how to compare");
assert(Ninja.compare(ninja1, ninja2) > 0,
      "The Ninja class can do the comparison!");

我们还可以看看如何在 ES6 之前的代码中实现“静态”方法。为此,我们只需要记住,类是通过函数实现的。因为静态方法是类级别的,我们可以通过利用函数作为一等对象,并给我们的构造函数添加一个方法属性来实现它们,如下例所示:

现在让我们继续讨论继承。

7.4.2. 实现继承

老实说,在 ES6 之前的代码中执行继承可能会很痛苦。让我们回到我们信任的忍者和人例子:

function Person(){}
Person.prototype.dance = function(){};

function Ninja(){}
Ninja.prototype = new Person();

Object.defineProperty(Ninja.prototype, "constructor", {
  enumerable: false,
  value: Ninja,
  writable: true
});

这里有很多需要注意的地方:应该直接将可访问所有实例的方法添加到构造函数的原型中,就像我们用 dance 方法和 Person 构造函数所做的那样。如果我们想实现继承,我们必须将派生“类”的原型设置为基“类”的实例。在这种情况下,我们将 Person 的新实例分配给了 Ninja.prototype。不幸的是,这破坏了 constructor 属性,因此我们必须使用 Object.defineProperty 方法手动恢复它。在尝试实现一个相对简单且常用功能(继承)时,这些都是需要记住的。幸运的是,随着 ES6 的出现,所有这些都显著简化了。

让我们看看以下列表中是如何实现的。

列表 7.15. ES6 中的继承

列表 7.15 展示了如何在 ES6 中实现继承;我们使用 extends 关键字从另一个类继承:

class Ninja extends Person

在这个例子中,我们创建了一个 Person 类,其构造函数将 name 分配给每个 Person 实例。我们还定义了一个 dance 方法,它将可访问所有 Person 实例:

class Person {
  constructor(name){
    this.name = name;
  }
  dance(){
    return true;
  }
}

接下来我们定义一个扩展 Person 类的 Ninja 类。它有一个额外的 weapon 属性,以及一个 wieldWeapon 方法:

class Ninja extends Person {
  constructor(name, weapon){
    super(name);
    this.weapon = weapon;
  }

  wieldWeapon(){
    return true;
  }
}

在派生类 Ninja 的构造函数中,通过关键字 super 调用了基类 Person 的构造函数。如果你使用过任何基于类的语言,这应该很熟悉。

我们继续创建一个person实例,并检查它是否是具有name和可以dancePerson类的实例。为了确保这一点,我们还检查一个不是Ninja的人不能使用武器:

var person = new Person("Bob");

assert(person instanceof Person, "A person's a person");
assert(person.dance(), "A person can dance.");
assert(person.name === "Bob", "We can call it by name.");
assert(!(person instanceof Ninja), "But it's not a Ninja");
assert(!("wieldWeapon" in person), "And it cannot wield a weapon");

我们还创建了一个ninja实例,并检查它是否是Ninja的实例,并且能够使用武器。因为每个ninja也是一个Person,所以我们检查一个ninja是否是Person的实例,它有一个name,并且在战斗间隙,它也喜欢跳舞:

var ninja = new Ninja("Yoshi", "Wakizashi");
assert(ninja instanceof Ninja, "A ninja's a ninja");
assert(ninja.wieldWeapon(), "That can wield a weapon");
assert(ninja instanceof Person, "But it's also a person");
assert(ninja.name === "Yoshi" , "That has a name");
assert(ninja.dance(), "And enjoys dancing");

看起来有多简单?没有必要考虑原型或某些重写属性的副作用。我们定义类,并使用extends关键字指定它们之间的关系。最后,使用 ES6,来自 Java 或 C#等语言的大量开发者可以安心了。

就这样。使用 ES6,我们几乎可以像在其他任何更传统的面向对象语言中一样轻松地构建类层次结构。

7.5. 摘要

  • JavaScript 对象是具有值的命名属性集合。

  • JavaScript 使用原型。

  • 每个对象都可以有一个指向一个原型的引用,一个当对象本身没有要搜索的属性时,我们将搜索该属性的搜索委托给的对象。一个对象的原型可以有自己的原型,以此类推,形成一个原型链

  • 我们可以使用Object.setPrototypeOf方法定义一个对象的原型。

  • 原型与构造函数紧密相关。每个函数都有一个prototype属性,该属性被设置为它实例化的对象的原型。

  • 函数的prototype对象有一个指向该函数本身的constructor属性。这个属性对所有使用该函数实例化的对象都是可访问的,并且,在一定限制下,可以用来确定一个对象是否是由特定函数创建的。

  • 在 JavaScript 中,几乎可以在运行时更改任何东西,包括对象的原型和函数的原型!

  • 如果我们想让由Ninja构造函数创建的实例“继承”(更准确地说,能够访问)由Person构造函数创建的实例可访问的属性,请将Ninja构造函数的原型设置为Person类的新实例。

  • 在 JavaScript 中,属性有属性(configurable、enumerable、writable)。这些属性可以使用内置的Object.defineProperty方法定义。

  • JavaScript ES6 增加了对class关键字的支持,这使得我们能够更容易地模拟类。幕后,原型仍然在发挥作用!

  • extends关键字使得优雅的继承成为可能。

7.6. 练习

1

以下哪个属性指向一个对象,如果目标对象没有要搜索的属性,则会搜索该对象?

  1. class
  2. instance
  3. prototype
  4. pointTo

2

执行以下代码后,变量a1的值是多少?

function Ninja(){}
Ninja.prototype.talk = function (){
  return "Hello";
};

const ninja = new Ninja();
const a1 = ninja.talk();

3

运行以下代码后a1的值是多少?

function Ninja(){}
Ninja.message = "Hello";

const ninja = new Ninja();

const a1 = ninja.message;

4

解释以下两个代码片段中getFullName方法的区别:

//First fragment
function Person(firstName, lastName){
  this.firstName = firstName;
  this.lastName = lastName;

  this.getFullName = function () {
    return this.firstName + " " + this.lastName;
  }
}

//Second fragment
function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

Person.prototype.getFullName = function () {
  return this.firstName + " " + this.lastName;
}

5

在运行以下代码后,ninja.constructor将指向什么?

function Person() { }
function Ninja() { }

const ninja = new Ninja();

6

在运行以下代码后,ninja.constructor将指向什么?

function Person() { }
function Ninja() { }
Ninja.prototype = new Person();
const ninja = new Ninja();

7

解释以下示例中instanceof运算符的工作原理。

function Warrior() { }

function Samurai() { }
Samurai.prototype = new Warrior();

var samurai = new Samurai();

samurai instanceof Warrior; //Explain

8

将以下 ES6 代码转换为 ES5 代码。

class Warrior {
  constructor(weapon){
    this.weapon = weapon;
  }

  wield() {
    return "Wielding " + this.weapon;
  }

  static duel(warrior1, warrior2){
    return warrior1.wield() + " " + warrior2.wield();
  }
}

第八章。控制对对象的访问

本章涵盖

  • 使用获取器和设置器控制对对象属性的访问

  • 通过代理控制对对象的访问

  • 使用代理处理横切关注点

在上一章中,您了解到 JavaScript 对象是动态的属性集合。我们可以轻松地添加新属性、更改属性值,甚至完全删除现有属性。在许多情况下(例如,在验证属性值、日志记录或在 UI 中显示数据时),我们需要能够监控我们的对象所发生的一切。因此,在本章中,您将学习控制访问和监控您对象中发生的所有更改的技术。

我们将从获取器和设置器开始,这些方法用于控制对特定对象属性的访问。您第一次在第五章和第七章中看到这些方法的使用。在本章中,您将了解它们的一些内置语言支持以及如何使用它们进行日志记录、执行数据验证和定义计算属性。

我们将继续介绍代理,这是 ES6 中引入的一种全新的对象类型。这些对象控制对其他对象的访问。您将了解它们的工作原理以及如何有效地使用它们来轻松扩展代码,包括性能测量或日志记录等横切关注点,以及如何通过自动填充对象属性来避免空异常。让我们从我们已知的某个程度开始:获取器和设置器。

你知道吗?

Q1:

通过获取器和设置器访问属性值有哪些好处?

Q2:

代理与获取器和设置器的主要区别是什么?

Q3:

代理陷阱是什么?命名三种类型的陷阱。

8.1. 使用获取器和设置器控制属性访问

在 JavaScript 中,对象是相对简单的属性集合。跟踪我们程序状态的主要方式是通过修改这些属性。例如,考虑以下代码:

function Ninja (level) {
  this.skillLevel = level;
}
const ninja = new Ninja(100);

在这里,我们定义了一个Ninja构造函数,它创建具有skillLevel属性的ninja对象。稍后,如果我们想更改该属性的值,我们可以编写以下代码:ninja.skillLevel = 20

这听起来很方便,但在以下情况下会发生什么?

  • 我们希望防止意外错误,例如分配未预料到的数据。例如,我们希望阻止自己执行如下操作:ninja.skillLevel = "high"

  • 我们希望记录对 skillLevel 属性的所有更改。

  • 我们需要在我们的网页 UI 中某处显示我们的 skillLevel 属性的值。自然地,我们希望展示属性的最后一个、最新的值,但我们如何轻松地做到这一点呢?

我们可以使用获取器和设置器方法优雅地处理所有这些情况。

在 第五章 中,你看到了使用获取器和设置器作为在 JavaScript 中通过闭包模拟私有对象属性的方法。让我们通过使用只有通过获取器和设置器才能访问的私有 skillLevel 属性的忍者来回顾你迄今为止学到的内容,如下所示。

列表 8.1. 使用获取器和设置器保护私有属性

图片

我们定义了一个 Ninja 构造函数,它创建只有通过我们的 getSkillLevelsetSkillLevel 方法才能访问的“私有”skillLevel 变量的忍者:属性值只能通过 getSkillLevel 方法获取,而新的属性值只能通过 setSkillLevel 方法设置(还记得 第五章 中的闭包吗?)。

现在,如果我们想记录对 skillLevel 属性的所有读取尝试,我们扩展 getSkillLevel 方法;如果我们想对所有的写入尝试做出反应,我们扩展 setSkillLevel 方法,如下面的代码片段所示:

图片

这很好。我们可以通过插入,例如,记录、数据验证或其他副作用(如 UI 修改)来轻松地对我们属性的交互做出反应。

但一个令人烦恼的疑问可能正在你的脑海中浮现。skillLevel 属性是一个值属性;它引用数据(数字 100),而不是一个函数。不幸的是,为了充分利用受控访问的所有好处,我们与该属性的交互必须通过显式调用相关方法来完成,这,坦白说,有点尴尬。

幸运的是,JavaScript 有内置的对真正的获取器和设置器的支持:作为正常数据属性访问的属性(例如,ninja.skillLevel),但它们是计算请求属性值、验证传入的值或我们需要的任何其他操作的方法。让我们看看这个内置支持。

8.1.1. 定义获取器和设置器

在 JavaScript 中,获取器和设置器方法可以以两种方式定义:

  • 在对象字面量或 ES6 类定义中指定它们

  • 通过使用内置的 Object.defineProperty 方法

对获取器和设置器的显式支持已经存在了一段时间,自从 ES5 时代起。一如既往,让我们通过一个示例来探索语法。在这种情况下,我们有一个存储忍者列表的对象,我们希望能够获取和设置列表中的第一个忍者。

列表 8.2. 在对象字面量中定义获取器和设置器

图片

此示例定义了一个ninjaCollection对象,它有一个标准属性ninjas,该属性引用了一个忍者数组,以及firstNinja属性的获取器和设置器。获取器和设置器的一般语法在图 8.1 中显示。

图 8.1. 定义获取器和设置器的语法。在属性名前加上getset关键字。

图片

如您所见,我们通过在名称前加上get关键字来定义获取器属性,并通过set关键字定义设置器属性。

在列表 8.2 中,获取器和设置器都记录了一条消息。此外,获取器返回索引0处的忍者的值,而设置器将新值赋给同一索引处的忍者:

get firstNinja(){
  report("Getting firstNinja");
  return this.ninjas[0];
},
set firstNinja(value){
  report("Setting firstNinja");
  this.ninjas[0] = value;
}

接下来,我们测试访问获取器属性返回第一个忍者,Yoshi:

assert(ninjaCollection.firstNinja === "Yoshi",
       "Yoshi is the first ninja");

注意,获取器属性被访问的方式就像它是标准对象属性(而不是作为方法)一样。

在我们访问获取器属性后,相关的获取器方法被隐式调用,记录了消息Getting firstNinja,并返回索引0处的忍者的值。

我们继续利用我们的设置器方法,写入firstNinja属性,就像我们分配新值给普通对象属性一样:

ninjaCollection.firstNinja = "Hachi";

与前一个情况类似,因为firstNinja属性有一个设置器方法,所以每次我们给该属性赋值时,设置器方法都会被隐式调用。这记录了消息Setting firstNinja并修改了索引0处的忍者的值。

最后,我们可以测试我们的修改是否已经完成工作,以及索引0处的忍者的新值可以通过ninjas集合和我们的获取器方法访问:

assert(ninjaCollection.firstNinja === "Hachi"
    && ninjaCollection.ninjas[0] ===  "Hachi",
       "Now Hachi is the first ninja");

图 8.2 显示了列表 8.2 生成的输出。当我们通过ninjaCollection.firstNinja访问具有获取器的属性时,获取器方法立即被调用,在这种情况下,记录了消息Getting firstNinja。稍后,我们测试输出是Yoshi,并记录了消息Yoshi is the first ninja。我们通过给firstNinja属性赋新值的方式继续进行,正如我们可以在输出中看到的那样,这隐式触发了设置器方法的执行,该方法输出了消息Setting firstNinja

图 8.2. 列表 8.2 的输出:如果一个属性有获取器和设置器方法,那么每次我们读取属性值时,都会隐式调用获取器方法,而每次我们给属性赋新值时,都会调用设置器方法。

图片

从所有这些中可以得出的一个重要观点是,原生的获取器和设置器允许我们指定作为标准属性访问的属性,但这些属性的方法在属性被访问时立即触发执行。这在图 8.3 中得到了进一步的强调。

图 8.3. 使用 getter 方法访问属性会隐式调用匹配的 getter。这个过程与这是一个标准方法调用时相同,getter 方法会被执行。当我们通过 setter 方法将值赋给属性时,也会发生类似的事情。

图片

定义 getter 和 setter 的这种语法很简单,所以我们可以用完全相同的语法在其他情况下定义 getter 和 setter 也就不足为奇了。以下示例使用了 ES6 类。

列表 8.3. 使用 ES6 类中的 getter 和 setter

图片

这将列表 8.2 中的代码修改为包括 ES6 类。我们保留所有测试以验证示例仍然按预期工作。

注意

对于给定的属性,我们并不总是需要定义一个 getter 和一个 setter。例如,我们通常会只想提供一个 getter。如果在这种情况下我们仍然尝试向该属性写入值,确切的行为取决于代码是在严格模式还是非严格模式下。如果代码是在非严格模式下,向只有一个 getter 的属性赋值将没有任何效果;JavaScript 引擎会静默地忽略我们的请求。另一方面,如果代码是在严格模式下,JavaScript 引擎将抛出一个类型错误,表明我们正在尝试向一个有 getter 但没有 setter 的属性赋值。

虽然通过 ES6 类和对象字面量指定 getter 和 setter 很简单,但你可能已经注意到了一些缺失的东西。传统上,getter 和 setter 用于控制对私有对象属性的访问,如列表 8.1 所示。不幸的是,正如我们已经在第五章中知道的那样,JavaScript 没有私有对象属性。相反,我们可以通过定义变量和指定将覆盖这些变量的对象方法来模拟它们。因为在我们使用对象字面量和类时,我们的 getter 和 setter 方法并不是在用于私有对象属性的变量相同的函数作用域内创建的,所以我们不能这样做。幸运的是,有一种替代方法,即通过Object.defineProperty方法。

在第七章中,你看到了可以使用Object.defineProperty方法通过传递一个属性描述符对象来定义新属性。属性描述符可以包括一个get和一个set属性,它们定义了属性的 getter 和 setter 方法。

我们将使用这个特性来修改列表 8.1 以实现内置的 getter 和 setter,这些 getter 和 setter 可以控制对“私有”对象属性的访问,如下面的列表所示。

列表 8.4. 使用 Object.defineProperty 定义 getter 和 setter

图片

图片

在这个例子中,我们首先定义了一个Ninja构造函数,其中包含一个_skillLevel变量,我们将使用它作为私有变量,就像在列表 8.1 中一样。

接下来,在由 this 关键字引用的新创建的对象上,我们使用内置的 Object.defineProperty 方法定义一个 skillLevel 属性:

Object.defineProperty(this, 'skillLevel', {
  get: () => {
    report("The get method is called");
    return _skillLevel;
  },
  set: value => {
    report("The set method is called");
    _skillLevel = value;
  }
});

因为我们希望 skillLevel 属性控制对私有变量的访问,所以我们指定了一个在访问属性时将被调用的 getset 方法。

注意,与在对象字面量和类上指定的获取器和设置器不同,通过 Object.defineProperty 定义的 getset 方法是在与“私有”skillLevel 变量相同的范围内创建的。这两个方法都在私有变量周围创建了一个闭包,我们只能通过这两个方法来访问那个私有变量。

其余的代码与前面的示例完全一样。我们创建一个新的 Ninja 实例,并检查我们是否可以直接访问私有变量。相反,所有交互都必须通过获取器和设置器进行,我们现在就像它们是标准对象属性一样使用它们:

如您所见,使用 Object.defineProperty 的方法比对象字面量和类中的获取器和设置器更冗长和复杂。但在某些情况下,当我们需要私有对象属性时,这是值得的。

无论我们如何定义它们,获取器和设置器都允许我们定义像标准对象属性一样使用的对象属性,但它们是方法,每当我们在特定属性上读取或写入时,都可以执行额外的代码。这是一个非常实用的功能,使我们能够执行日志记录、验证赋值值,甚至在某些更改发生时通知代码的其他部分。让我们探索一些这些应用。

8.1.2. 使用获取器和设置器验证属性值

如我们所知,设置器是一个方法,每当我们将值写入匹配的属性时都会执行。我们可以利用设置器在尝试更新属性值时执行某些操作。例如,我们可以执行的操作之一是验证传入的值。请看以下代码,它确保我们的 skillLevel 属性只能被分配整数值。

列表 8.5. 使用设置器验证属性值赋值

这个例子是 列表 8.4 的直接扩展。唯一的重大区别是,现在每当将新值分配给 skillLevel 属性时,我们检查传入的值是否为整数。如果不是,将抛出异常,私有 _skillLevel 变量不会被修改。如果一切顺利并且接收到了整数值,我们将得到私有 _skillLevel 变量的新值:

set: value => {
  if(!Number.isInteger(value)){
    throw new TypeError("Skill level should be a number");
  }
  _skillLevel = value;
}

在测试此代码时,我们首先检查如果我们分配一个整数,一切是否顺利:

ninja.skillLevel = 10;
assert(ninja.skillLevel === 10, "The value was updated");

然后我们测试了错误地分配其他类型值的情况,例如字符串。在这种情况下,我们应该得到一个异常。

try {
  ninja.skillLevel = "Great";
  fail("Should not be here");
} catch(e){
  pass("Setting a non-integer value throws an exception");
}

这就是如何避免当错误类型的值最终出现在某个属性中时发生的所有那些愚蠢的小错误。当然,这会增加开销,但这是我们有时不得不为了安全地使用像 JavaScript 这样高度动态的语言而付出的代价。

这只是 setter 方法有用性的一个例子;还有更多我们没有明确探讨的例子。例如,你可以使用同样的原则来跟踪值历史、执行日志记录、提供变更通知等等。

8.1.3. 使用 getter 和 setter 定义计算属性

除了能够控制对某些对象属性的访问之外,getter 和 setter 还可以用来定义 计算属性,这些属性的值是按请求计算的。计算属性不存储值;它们提供 get 和/或 set 方法来间接检索和设置其他属性。在下面的示例中,该对象有两个属性,nameclan,我们将使用它们来计算属性 fullTitle

列表 8.6. 定义计算属性

在这里,我们定义了一个 shogun 对象,具有两个标准属性,nameclan。此外,我们还指定了一个计算属性 fullTitle 的 getter 和 setter 方法:

const shogun = {
  name: "Yoshiaki",
  clan: "Ashikaga",
  get fullTitle(){
    return this.name + " " + this.clan;
  },
  set fullTitle(value) {
    const segments = value.split(" ");
    this.name = segments[0];
    this.clan = segments[1];
  }
};

get 方法在请求时通过连接 nameclan 属性来计算 fullTitle 属性的值。另一方面,set 方法使用所有字符串都有的内置 split 方法,通过空格字符将分配的字符串分割成段。第一个段代表名字,并分配给 name 属性,而第二个段代表部落,并分配给 clan 属性。

这处理了两种路由:读取 fullTitle 属性会计算其值,向 fullTitle 属性写入会修改构成属性值的属性。

说实话,我们不必使用计算属性。一个名为 getFullTitle 的方法可能同样有用,但计算属性可以提高我们代码的概念清晰度。如果某个值(在这个例子中,是 fullTitle 值)依赖于对象的内部状态(在这个例子中,依赖于 nameclan 属性),那么将其表示为数据字段、属性而不是函数是完美的。

这就结束了我们对 getter 和 setter 的探索。你已经看到,它们是语言的有用补充,可以帮助我们处理日志记录、数据验证和检测属性值的变化。不幸的是,有时这还不够。在某些情况下,我们需要控制与我们的对象的所有类型的交互,为此,我们可以使用一种全新的对象类型:一个 代理

8.2. 使用代理来控制访问

代理 是一个代理,通过它我们可以控制对另一个对象的访问。它使我们能够定义在对象交互时(例如,当读取或设置属性值,或调用方法时)将执行的定制操作。你可以将代理视为几乎是一般化的获取器和设置器;但是,每个获取器和设置器只控制对单个对象属性的访问,而代理使我们能够通用地处理与对象的交互,包括甚至方法调用。

当我们传统上使用获取器和设置器(例如用于日志记录、数据验证和计算属性)时,我们可以使用代理。但代理的功能更强大。它们允许我们轻松地为代码添加性能分析和性能测量,自动填充对象属性以避免讨厌的空异常,以及将宿主对象(如 DOM)包装起来以减少跨浏览器不兼容性。

注意

代理是由 ES6 引入的。有关当前浏览器的支持情况,请参阅mng.bz/9uEM

在 JavaScript 中,我们可以使用内置的 Proxy 构造函数创建代理。让我们从一个拦截所有尝试读取和写入对象属性值的代理开始。

列表 8.7. 使用 Proxy 构造函数创建代理

图片

图片

我们首先创建我们的基础 emperor 对象,它只有一个 name 属性。然后,通过使用内置的 Proxy 构造函数,我们将 emperor 对象(或通常称为 target 对象)包装成一个名为 representative 的代理对象。在代理构造过程中,我们还将一个对象作为第二个参数发送,该对象指定了 traps,即当对对象执行某些操作时将被调用的函数:

const representative = new Proxy(emperor, {
  get: (target, key) => {
    report("Reading " + key + " through a proxy");
    return key in target ? target[key]
                             : "Don't bother the emperor!"
  },
  set: (target, key, value) => {
    report("Writing " + key + " through a proxy");
    target[key] = value;
  }
});

在这种情况下,我们指定了两个捕获器:一个 get 捕获器,它将在我们尝试通过代理读取属性值时被调用,以及一个 set 捕获器,它将在我们通过代理设置属性值时被调用。get 捕获器执行以下功能:如果目标对象具有属性,则返回该属性;如果对象没有属性,我们返回一条消息警告用户不要因为琐碎的细节而打扰 emperor

get: (target, key) => {
  report("Reading " + key + " through a proxy");
  return key in target ? target[key]
                       : "Don't bother the emperor!"
}

接下来,我们测试我们是否可以通过目标 emperor 对象以及我们的代理对象直接访问 name 属性:

assert(emperor.name === "Komei", "The emperor's name is Komei");
assert(representative.name === "Komei",
      "We can get the name property through a proxy");

如果我们直接通过 emperor 对象访问 name 属性,将返回值 Komei。但如果通过 proxy 对象访问 name 属性,将隐式调用 get 捕获器。因为 name 属性在目标 emperor 对象中找到,所以也返回值 Komei。参见图 8.4。

图 8.4. 直接访问 name 属性(在左侧)和通过代理间接访问(在右侧)

图片

注意

重要的是要强调,代理陷阱的激活方式与获取器和设置器相同。一旦我们执行一个动作(例如,在代理上访问属性值),匹配的陷阱就会隐式调用,JavaScript 引擎会经历一个类似于我们显式调用函数的过程。

另一方面,如果我们直接在目标 emperor 对象上访问一个不存在的 nickname 属性,我们会得到一个 undefined 值。但如果我们尝试通过我们的 proxy 对象来访问它,get 处理程序将被激活。因为目标 emperor 对象没有 nickname 属性,代理的 get 陷阱将返回 Don't bother the emperor! 消息。

我们将通过代理对象分配一个新属性来继续这个例子:representative.nickname = "Tenno"。因为分配是通过代理完成的,而不是直接完成,所以 set 陷阱,它记录一条消息并将属性分配给我们的目标 emperor 对象,被激活:

set: (target, key, value) => {
    report("Writing " + key + " through a proxy");
    target[key] = value;
}

自然地,新创建的属性可以通过代理对象和目标对象来访问:

assert(emperor.nickname === "Tenno",
      "The emperor now has a nickname");
assert(representative.nickname === "Tenno",
      "The nickname is also accessible through the proxy");

这就是使用代理的精髓:通过 Proxy 构造函数,我们创建一个代理对象,该对象通过激活某些陷阱来控制对目标对象的访问,每当直接在代理上执行操作时。

在这个例子中,我们使用了 getset 陷阱,但许多其他内置陷阱允许我们为各种对象操作定义处理程序(见 mng.bz/ba55)。例如:

  • 当调用函数时,将激活 apply 陷阱,当使用 new 操作符时,将激活 construct 陷阱。

  • 当读取/写入属性时,将激活 getset 陷阱。

  • 对于 for-in 语句,将激活 enumerate 陷阱。

  • getPrototypeOfsetPrototypeOf 将在获取和设置原型值时被激活。

我们可以拦截许多操作,但详细讨论所有这些操作超出了本书的范围。现在,我们将注意力转向一些我们无法覆盖的操作:相等性(== 或 ===)、instanceoftypeof 操作符。

例如,表达式 x == y(或更严格的 x === y)用于检查 xy 是否引用相同的对象(或具有相同的值)。这个相等操作符有一些假设。例如,比较两个对象应该总是为相同的两个对象返回相同的值,如果我们不能保证这个值是由用户指定的函数确定的,那么我们无法保证这一点。此外,比较两个对象的行为不应该提供对其中一个对象的访问,如果相等性可以被捕获,那么就会发生这种情况。出于类似的原因,instanceoftypeof 操作符也不能被捕获。

现在我们已经了解了代理的工作原理以及如何创建它们,让我们来探讨一些它们的实际应用方面,例如如何使用代理进行日志记录、性能测量、自动填充属性以及实现可以通过负索引访问的数组。我们将从日志记录开始。

8.2.1. 使用代理进行日志记录

当试图了解代码的工作原理或试图找到讨厌的 bug 的根本原因时,最强大的工具之一是日志记录,即在特定时刻输出我们认为有用的信息的行为。例如,我们可能想知道哪些函数被调用,它们执行了多长时间,哪些属性被读取或写入,等等。

不幸的是,在实现日志记录时,我们通常会在代码中分散地放置日志语句。看看本章前面提到的“忍者”示例。

列表 8.8. 不使用代理的日志记录

图片

我们定义了一个 Ninja 构造函数,它向 skillLevel 属性添加了一个获取器和设置器,记录对该属性的所有读取和写入尝试。

注意,这不是一个理想的解决方案。我们使处理对象属性读写操作的领域代码变得杂乱无章,加入了日志代码。此外,如果将来在“忍者”对象上需要更多的属性,我们必须小心不要忘记为每个新属性添加额外的日志语句。

幸运的是,代理的一个直接用途是在我们读取或写入属性时启用日志记录,但以一种更加优雅和干净的方式。考虑以下示例。

列表 8.9. 使用代理使向对象添加日志记录更加容易

图片

图片

在这里,我们定义了一个 makeLoggable 函数,它接受一个 target 对象并返回一个新的 Proxy,该 Proxy 具有带有 getset 陷阱的处理程序。这些陷阱除了读取和写入属性外,还会记录读取或写入的属性信息。

接下来,我们创建一个具有 name 属性的 ninja 对象,并将其传递给 make-Loggable 函数,其中它将作为新创建的代理的目标。然后我们将代理赋值回 ninja 标识符,以覆盖它。(别担心,我们的原始 ninja 对象作为代理的目标对象仍然保持活跃。)

每当我们尝试读取一个属性(例如,使用 ninja.name)时,get 陷阱将被调用,并记录已读取的属性信息。当向属性写入时也会发生类似的事情:ninja.weapon = "sword"

注意,与使用获取器和设置器的标准方式相比,这要容易得多,也更加透明。我们不需要将领域代码与日志代码混合,也不需要为每个对象属性添加单独的日志。相反,所有属性读取和写入都通过我们的代理对象陷阱方法进行。日志记录只在唯一的位置指定,并且根据需要重复使用,在尽可能多的对象上使用。

8.2.2. 使用代理来测量性能

除了用于记录属性访问外,代理还可以用于测量函数调用的性能,甚至不需要修改函数的源代码。比如说,我们想要测量一个计算一个数是否为质数的函数的性能,如下面的列表所示。

列表 8.10. 使用代理测量性能

图片 216fig01_alt

在这个例子中,我们有一个简单的 isPrime 函数。(具体的函数不重要;我们只是用它作为函数执行可能持续一段时间的示例。)

现在想象一下,我们需要测量 isPrime 函数的性能,但又不想修改其代码。我们可以将函数包装到一个代理中,该代理有一个陷阱,每当函数被调用时都会被调用:

isPrime = new Proxy(isPrime, {
  apply: (target, thisArg, args) => {
...
  }
});

我们使用 isPrime 函数作为新构造的代理的目标对象。此外,我们还提供了一个带有 apply 陷阱的处理程序,该陷阱将在函数调用时执行。

类似于前一个示例,我们将新创建的代理分配给了 isPrime 标识符。这样,我们就不必更改任何调用我们想要测量执行时间的函数的代码;程序代码的其余部分对我们的更改一无所知。(这难道不是一些忍者隐形行动吗?)

每当调用 isPrime 函数时,该调用会被重定向到我们的代理的 apply 陷阱,该陷阱将使用内置的 console.time 方法启动计时器(记住 第一章),调用原始的 isPrime 函数,记录经过的时间,并最终返回 isPrime 调用的结果。

8.2.3. 使用代理来自动填充属性

除了简化日志记录外,代理还可以用于自动填充属性。例如,假设您必须模拟计算机的文件夹结构,其中文件夹对象可以具有也可以是文件夹的属性。现在想象一下,您必须模拟一个位于长路径末尾的文件,如下所示:

rootFolder.ninjasDir.firstNinjaDir.ninjaFile = "yoshi.txt";

为了创建这个,你可能需要写一些类似以下的内容:

const rootFolder = new Folder();
rootFolder.ninjasDir = new Folder();
rootFolder.ninjasDir.firstNinjaDir = new Folder();
rootFolder.ninjasDir.firstNinjaDir.ninjaFile = "yoshi.txt";

这似乎比必要的繁琐多了,不是吗?这就是自动填充属性发挥作用的地方;只需看看下面的例子。

列表 8.11. 使用代理来自动填充属性

图片 217fig01_alt

通常情况下,如果我们只考虑以下代码,我们预期会抛出一个异常:

const rootFolder = new Folder();
rootFolder.ninjasDir.firstNinjaDir.ninjaFile = "yoshi.txt";

我们正在访问 rootFolder 对象中未定义的属性 ninjasDir 的属性 firstNinjaDir。但如果我们运行代码,你会看到一切正常,如图 图 8.5 所示。

图 8.5. 运行 列表 8.11 代码的输出

图片 08fig05

这是因为我们在使用代理。每次我们访问一个属性时,代理的 get 陷阱就会被激活。如果我们的文件夹对象已经包含请求的属性,它的值就会被返回,如果没有,就会创建一个新的文件夹并将其分配给属性。这就是我们的两个属性 ninjasDirfirstNinjaDir 被创建的方式。请求未初始化属性的值会触发其创建。

最后,我们终于有一个工具可以摆脱一些讨厌的空指针异常了!

8.2.4. 使用代理实现负数组索引

在我们的日常编程中,我们通常会处理 很多 数组。让我们探索如何利用代理使我们对数组的处理更加愉快。

如果你的编程背景来自像 Python、Ruby 或 Perl 这样的语言,你可能已经习惯了负数组索引,这允许你使用负索引从后端访问数组项,如下面的代码片段所示:

现在比较我们通常用来访问数组最后一个元素的代码,ninjas [ninjas.length-1],以及如果我们选择的语言支持负数组索引,我们可以使用的代码,ninjas[-1]。看看这有多优雅?

不幸的是,JavaScript 并没有提供对负数组索引的原生支持,但我们可以通过代理来模拟这种能力。为了探索这个概念,我们将查看由 Sindre Sorhus 编写的代码的略微简化版本(github.com/sindresorhus/negative-array),如下所示列表。

列表 8.12. 使用代理实现负数组索引

在这个例子中,我们定义了一个函数,该函数将为传入的数组创建一个代理。因为我们不希望我们的代理与其他类型的对象一起工作,所以如果参数不是数组,我们会抛出一个异常:

if (!Array.isArray(array)) {
    throw new TypeError('Expected an array');
}

我们继续创建并返回一个新的代理,它有两个陷阱:一个 get 陷阱,它会在我们尝试读取数组项时激活,以及一个 set 陷阱,它会在我们写入数组项时激活:

return new Proxy(array, {
  get: (target, index) => {
    index = +index;
    return target[index < 0 ? target.length + index : index];
  },
  set: (target, index, val) => {
    index = +index;
    return target[index < 0 ? target.length + index : index] = val;
  }
});

陷阱体是相似的。首先,我们使用一元加运算符(index = +index)将属性转换为数字。然后,如果请求的索引小于 0,我们通过锚定到数组的长度从后端访问数组项,如果它大于或等于 0,我们以标准方式访问数组项。

最后,我们执行各种测试来检查在正常数组中我们只能通过正数组索引访问数组项,而且如果我们使用代理,我们可以通过负索引访问和修改数组项。

现在你已经看到了如何使用代理来实现一些有趣的功能,比如自动填充对象属性和访问负数组索引(没有代理这是不可能的),让我们探索代理的最大缺点:性能问题。

8.2.5. 代理的性能成本

如我们所知,代理是一个代理对象,通过它我们可以控制对另一个对象的访问。代理可以定义陷阱,即当在代理上执行特定操作时将执行的函数。而且,正如你所看到的,我们可以使用这些陷阱来实现有用的功能,如记录、性能测量、自动填充属性、负数组索引等。不幸的是,也存在一个缺点。由于所有操作都必须通过代理进行,这增加了一个间接层,使我们能够实现所有这些酷炫功能,但同时它也引入了大量的额外处理,这影响了性能。

为了测试这些性能问题,我们可以在列表 8.12 中的负数组索引示例的基础上构建,并比较在正常数组中访问项目与通过代理访问项目时的执行时间,如下面的列表所示。

列表 8.13. 检查代理的性能限制

图片描述

由于代码的单个操作发生得太快,无法可靠地测量,因此必须多次执行代码才能得到可测量的值。通常,这个计数可以是数万次,甚至数百万次,具体取决于被测量的代码的性质。一点尝试和错误让我们选择一个合理的值:在这种情况下是 500,000。

我们还需要使用两个new Date().getTime()时间戳来括号化代码的执行:一个在我们开始执行目标代码之前,另一个在之后。它们的差值告诉我们代码执行所需的时间。最后,我们可以通过在代理数组和对标准数组上调用measure函数来比较结果。

在我们的机器上,代理的结果并不理想。结果是,在 Chrome 中,代理大约慢 50 倍;在 Firefox 中,它们大约慢 20 倍。

目前,我们建议你在使用代理时要小心。虽然它们允许你在控制对象访问方面具有创造性,但这种控制程度伴随着性能问题。你可以使用对性能不敏感的代码与代理一起使用,但在大量执行的代码中使用它们时要小心。一如既往,我们建议你彻底测试你代码的性能。

8.3. 摘要

  • 我们可以使用获取器(getters)、设置器(setters)和代理(proxies)来监控我们的对象。

  • 通过使用访问器方法(获取器和设置器),我们可以控制对对象属性的访问。

    • 访问器属性可以通过使用内置的Object.defineProperty方法或使用特殊的getset语法作为对象字面量或 ES6 类的部分来定义。

    • 每当我们尝试读取时,都会隐式调用get方法,而当我们为匹配对象的属性赋值时,则会调用set方法。

    • 获取器方法可以用来定义计算属性,这些属性的价值是基于每个请求计算的,而设置器方法可以用来实现数据验证和日志记录。

  • 代理是 JavaScript 的 ES6 新增功能,用于控制其他对象。

    • 代理使我们能够定义在对象交互时(例如,当读取属性或调用函数时)将执行的定制操作。

    • 所有交互都必须通过代理进行,当发生特定操作时,代理会触发陷阱。

  • 使用代理实现优雅

    • 记录

    • 性能测量

    • 数据验证

    • 自动填充对象属性(从而避免讨厌的 null 异常)

    • 负数组索引

  • 代理并不快,所以在大量执行的代码中使用它们时要小心。我们建议您进行性能测试。

8.4. 练习

1

在运行以下代码后,以下哪个表达式会抛出异常,以及为什么?

const ninja = {
   get name() {
     return "Akiyama";
   }
}
  1. ninja.name();
  2. const name = ninja.name;

2

在以下代码中,哪种机制允许获取器访问私有对象变量?

function Samurai() {
  const _weapon = "katana";
  Object.defineProperty(this, "weapon", {
    get: () => _weapon
  });
}
const samurai = new Samurai();
assert(samurai.weapon === "katana", "A katana wielding samurai");

3

以下哪个断言会通过?

const daimyo = { name: "Matsu", clan: "Takasu"};
const proxy = new Proxy(daimyo, {
  get: (target, key) => {
    if(key === "clan"){
      return "Tokugawa";
    }
  }
});

assert(daimyo.clan === "Takasu", "Matsu of clan Takasu");
assert(proxy.clan === "Tokugawa", "Matsu of clan Tokugawa?");

proxy.clan = "Tokugawa";

assert(daimyo.clan === "Takasu", "Matsu of clan Takasu");
assert(proxy.clan === "Tokugawa", "Matsu of clan Tokugawa?");

4

以下哪个断言会通过?

const daimyo = { name: "Matsu", clan: "Takasu", armySize: 10000};
const proxy = new Proxy(daimyo, {
  set: (target, key, value) => {
    if(key === "armySize") {
      const number = Number.parseInt(value);
      if(!Number.isNaN(number)){
        target[key] = number;
      }
    } else {
       target[key] = value;
    }
  },
});

assert(daimyo.armySize === 10000, "Matsu has 10 000 men at arms");
assert(proxy.armySize === 10000, "Matsu has 10 000 men at arms");

proxy.armySize = "large";
assert(daimyo.armySize === "large", "Matsu has a large army");

daimyo.armySize = "large";
assert(daimyo.armySize === "large", "Matsu has a large army");

第九章. 处理集合

本章涵盖

  • 创建和修改数组

  • 使用和重用数组函数

  • 使用映射创建字典

  • 使用集合创建唯一对象的集合

现在我们已经花了一些时间处理 JavaScript 中面向对象的特定性,我们将继续探讨一个与之密切相关的话题:项目集合。我们将从数组开始,这是 JavaScript 中最基本的集合类型,并探讨一些如果你在另一种编程语言中编程背景,可能不会预料到的数组特性。我们将继续探讨一些内置的数组方法,这些方法将帮助你编写更优雅的数组处理代码。

接下来,我们将讨论两个新的 ES6 集合:映射和集合。使用映射,你可以创建一种类型的字典,它携带键和值之间的映射——这种集合在特定的编程任务中非常有用。另一方面,集合是唯一项目的集合,其中每个项目只能出现一次。让我们从最简单、最常见的一切集合开始:数组。

你知道吗?

Q1:

使用对象作为字典或映射时,常见的陷阱有哪些?

Q2:

Map中,键/值对可以有什么值类型?

Q3:

Set中的项目必须具有相同的类型吗?

9.1. 数组

数组是最常见的数据类型之一。使用它们,你可以处理项目集合。如果你的编程背景是在 C 这样的强类型语言中,你可能认为数组是存储相同类型项的连续内存块,每个内存块具有固定的大小,并通过一个关联的索引可以轻松访问它。

但就像 JavaScript 中的许多事物一样,数组也有一些转折:它们只是对象。尽管这导致了一些不幸的副作用,主要是性能方面,但它也有一些好处。例如,数组可以访问方法,就像其他对象一样——这些方法将使我们的生活变得更加容易。

在本节中,我们将首先探讨创建数组的方法。然后我们将探讨如何向数组的不同位置添加项和从数组中移除项。最后,我们将检查内置的数组方法,这些方法将使我们的数组处理代码更加优雅。

9.1.1. 创建数组

创建新数组有两种基本方法:

  • 使用内置的Array构造函数

  • 使用数组字面量[]

让我们从创建一个忍者数组和武士数组的简单例子开始。

列表 9.1. 创建数组

在列表 9.1 中,我们首先创建两个数组。ninjas数组是通过简单的数组字面量创建的:

const ninjas = ["Kuma", "Hattori", "Yagyu"];

它立即填充了三个忍者:Kuma、Hattori 和 Yagyu。samurai数组是通过内置的Array构造函数创建的:

const samurai = new Array("Oda", "Tomoe");

数组字面量与数组构造函数

使用数组字面量创建数组比使用Array构造函数创建数组更受欢迎。主要原因是简单性:[]new Array()(2 个字符与 11 个字符——几乎不是一个公平的竞争)。此外,由于 JavaScript 高度动态,没有人可以阻止某人覆盖内置的Array构造函数,这意味着调用new Array()不一定必须创建一个数组。因此,我们建议你通常坚持使用数组字面量。

无论我们如何创建它,每个数组都有一个length属性,该属性指定了数组的大小。例如,ninjas数组的长度是3,它包含 3 个忍者。我们可以通过以下断言来测试这一点:

assert(ninjas.length === 3, "There are three ninjas");
assert(samurai.length === 2, "And only two samurai");

如你所知,你通过使用索引符号来访问数组项,其中第一个项目位于索引0,最后一个项目位于索引array.length - 1。但是如果我们尝试访问这些范围之外的索引——例如,使用ninjas[4](记住,我们只有三个忍者!),我们不会得到在大多数其他编程语言中接收到的可怕的“数组索引越界”异常。相反,返回undefined,表示那里没有内容:

assert(ninjas[4] === undefined,
      "We get undefined if we try to access an out of bounds index");

这种行为是 JavaScript 数组是对象的事实的结果。正如如果我们尝试访问一个不存在的对象属性会得到undefined一样,当我们尝试访问一个不存在的数组索引时,也会得到undefined

另一方面,如果我们尝试写入数组范围之外的某个位置,就像

ninjas[4] = "Ishi";

数组将扩展以适应新的情况。例如,参见 图 9.1:我们在数组中实际上创建了一个空位,索引 3 的项目是 undefined。这也改变了 length 属性的值,现在报告的值是 5,尽管数组中有一个项目是 undefined

图 9.1. 在数组边界之外写入数组索引会扩展数组。

与大多数其他语言不同,在 JavaScript 中,数组还表现出与 length 属性相关的奇特特性:没有任何东西阻止我们手动更改它的值。将值设置得高于当前长度将会使用 undefined 项目扩展数组,而将值设置得低于当前长度将会裁剪数组,例如 ninjas.length = 2;

既然我们已经了解了数组创建的基础知识,那么让我们来看看一些最常见的数组方法。

9.1.2. 在数组的两端添加和移除项目

让我们从以下简单的方法开始,这些方法可以帮助我们在数组中添加和移除项目:

  • push 方法向数组的末尾添加一个项目。

  • unshift 方法向数组的开头添加一个项目。

  • pop 方法从数组的末尾移除一个项目。

  • shift 方法从数组的开头移除一个项目。

你可能已经使用过这些方法,但为了以防万一,让我们通过探索以下列表来确保我们处于同一页面上。

列表 9.2. 添加和移除数组项目

在这个例子中,我们首先创建一个新的空 ninjas 数组:

ninjas = [] // ninjas: []

在每个数组中,我们可以使用内置的 push 方法将项目追加到数组的末尾,在这个过程中改变其长度:

ninjas.push("Kuma"); // ninjas: ["Kuma"];
ninjas.push("Hattori"); // ninjas: ["Kuma", "Hattori"];

我们还可以通过使用内置的 unshift 方法在数组的开头添加新项目:

ninjas.unshift("Yagyu");// ninjas: ["Yagyu", "Kuma", "Hattori"];

注意现有数组项目是如何调整的。例如,在调用 unshift 方法之前,"Kuma" 在索引 0,之后它位于索引 1

我们还可以从数组的末尾或开头移除元素。调用内置的 pop 方法会从数组的末尾移除一个元素,在这个过程中减少数组的长度:

var lastNinja = ninjas.pop(); // ninjas:["Yagyu", "Kuma"]
                              // lastNinja: "Hattori"

我们还可以通过使用内置的 shift 方法从数组的开头移除一个项目:

var firstNinja = ninjas.shift(); //ninjas: ["Kuma"]
                                 //firstNinja: "Yagyu"

图 9.2 展示了 pushpopshiftunshift 如何修改数组。

图 9.2. pushpop 方法修改数组的末尾,而 shiftunshift 修改数组的开头。

性能考虑:pop 和 push 与 shift 和 unshift 的比较

poppush 方法只影响数组中的最后一个项目:pop 通过删除最后一个项目,而 push 通过在数组末尾插入一个项目。另一方面,shiftunshift 方法改变数组中的第一个项目。这意味着任何后续数组项的索引都需要调整。因此,pushpop 的操作比 shiftunshift 快得多,我们建议除非有充分的理由,否则使用它们。

9.1.3. 在任何数组位置添加和删除项

之前的例子是从数组的开始和结束删除项。但这太受限制了——一般来说,我们应该能够从数组的任何位置删除项。以下列表展示了实现这一点的直接方法。

列表 9.3. 删除数组项的朴素方法

从数组中删除项的方法不起作用。我们实际上只是在数组中创建了一个空位。数组仍然报告它有四个项目,但其中之一——我们想要删除的那个——是 undefined(见图 9.3)。

图 9.3. 从数组中删除项会在数组中创建一个空位。

类似地,如果我们想在任意位置插入一个项目,我们甚至从哪里开始呢?作为对这些问题的回答,所有 JavaScript 数组都可以访问 splice 方法:从给定索引开始,这个方法会删除和插入项目。查看以下示例。

列表 9.4. 在任意位置添加和删除项

我们首先创建一个包含四个项目的新数组:

var ninjas = ["Yagyu", "Kuma", "Hattori", "Fuma"];

然后我们调用内置的 splice 方法:

var removedItems = ninjas.splice(1,1);//ninjas:["Yagyu","Hattori", "Fuma"];
                                    //removedItems: ["Kuma"]

在这种情况下,splice 接受两个参数:开始剪切操作的索引,以及要删除的元素数量(如果我们省略这个参数,则删除数组末尾的所有元素)。在这种情况下,索引为 1 的元素从数组中删除,并且所有后续元素相应地移动。

此外,splice 方法返回一个包含已删除项的数组。在这种情况下,结果是包含单个项目的数组:"Kuma"

使用 splice 方法,我们还可以在数组的任意位置插入项目。例如,考虑以下代码:

removedItems = ninjas.splice(1, 2, "Mochizuki", "Yoshi", "Momochi");
//ninjas: ["Yagyu", "Mochizuki", "Yoshi", "Momochi"]
//removedItems: ["Hattori", "Fuma"]

从索引 1 开始,它首先删除两个项目,然后添加三个项目:"Mochizuki""Yoshi""Momochi"

既然我们已经向您介绍了数组的工作原理,让我们继续研究一些在数组上经常执行的一些常见操作。这将帮助您编写更优雅的数组处理代码。

9.1.4. 数组上的常见操作

在本节中,我们将探讨数组上的一些最常见操作:

  • 迭代(或遍历)数组

  • 映射现有的数组项以创建基于它们的新数组

  • 测试数组项以检查它们是否满足某些条件

  • 查找特定的数组项

  • 聚合 数组并基于数组项计算单个值(例如,计算数组的总和)

我们将从基础知识开始:数组迭代。

遍历数组

最常见的操作之一是遍历数组。回到计算机科学 101,迭代通常以以下方式执行:

这个例子看起来很简单。它使用 for 循环检查数组中的每个项;结果在 图 9.4 中显示。

图 9.4. 使用 for 循环检查忍者的输出

你可能已经写了无数次类似的东西,以至于你甚至不再需要思考。但以防万一,让我们更仔细地看看 for 循环。

要遍历一个数组,我们必须设置一个计数器变量 i,指定我们想要计数的数字(ninjas.length),并定义计数器如何被修改(i++)。这是一件非常繁琐的事情,只是执行这样一个常见的操作,而且它可能是令人烦恼的小错误的来源。此外,它使我们的代码更难以阅读。读者必须仔细查看 for 循环的每个部分,以确保它遍历了所有项且没有跳过任何项。

为了使生活更简单,所有 JavaScript 数组都有一个内置的 forEach 方法,我们可以在这种情况下使用。看看以下示例。

列表 9.5. 使用 forEach 方法

我们提供了一个回调(在这种情况下,一个箭头函数),它对数组中的每个项都会立即被调用。就是这样——不再需要担心起始索引、结束条件或精确的增量。JavaScript 引擎会为我们处理所有这些,幕后操作。注意,这段代码更容易理解,并且错误产生的点更少。

我们将继续提高难度,看看我们如何将数组映射到其他数组。

映射数组

假设你有一个 ninja 对象的数组。每个忍者和他最喜欢的武器,你想要从 ninjas 数组中提取武器数组。有了 forEach 方法的知识,你可能会写出以下列表。

列表 9.6. 原始提取武器数组

这并不算太糟糕:我们创建了一个新的、空的数组,并使用 forEach 方法遍历 ninjas 数组。然后,对于每个 ninja 对象,我们将当前武器添加到 weapons 数组中。

如你所想,根据现有数组中的项创建新数组是出奇地常见——常见到它有一个特殊的名称:映射 数组。其想法是将一个数组中的每个项映射到新数组的新项。方便的是,JavaScript 有一个 map 函数可以做到这一点,如下面的列表所示。

列表 9.7. 映射数组

内置的 map 方法构建一个全新的数组,然后遍历输入数组。对于输入数组中的每个项目,map 根据提供给 map 的回调函数的结果,在新建的数组中放置一个项目。map 函数的内部工作原理在 图 9.5 中展示。

图 9.5. map 函数对每个数组项目调用提供的回调函数(fc),并创建一个包含回调返回值的新数组。

图片 5

现在我们知道了如何映射数组,让我们看看如何测试数组项目是否满足特定条件。

测试数组项目

当处理项目集合时,我们经常会遇到需要知道是否所有或至少某些数组项目满足特定条件的情况。为了尽可能高效地编写此代码,所有 JavaScript 数组都可以访问内置的 everysome 方法,如下所示。

列表 9.8. 使用 everysome 方法测试数组

图片 1

列表 9.8 展示了一个例子,其中我们有一个 ninja 对象集合,但我们不确定它们的名称以及是否所有对象都配备了武器。为了找到这个问题的根源,我们首先利用 every

var allNinjasAreNamed = ninjas.every(ninja => "name" in ninja);

every 方法接受一个回调函数,对于集合中的每个 ninja,检查我们是否知道 ninja 的名字。只有当传入的回调函数对数组中的每个项目都返回 true 时,every 才返回 true。图 9.6 展示了 every 的工作原理。

图 9.6. every 方法测试数组中的所有项目是否满足由回调函数表示的某个条件。

图片 3

在其他情况下,我们只关心是否某些数组项目满足特定条件。对于这些情况,我们可以使用内置方法 some

const someNinjasAreArmed = ninjas.some(ninja => "weapon" in ninja);

从第一个数组项目开始,some 对每个数组项目调用回调函数,直到找到一个回调返回 true 值的项目。如果找到了这样的项目,返回值是 true;如果没有,返回值是 false

图 9.7 展示了 some 方法在底层是如何工作的:我们按顺序搜索数组以确定其项目是否满足某个条件。接下来,我们将探讨如何搜索数组以找到特定项目。

图 9.7. some 方法检查至少有一个数组项目是否满足传入回调函数表示的条件。

图片 4

搜索数组

你迟早会使用的一个常见操作是查找数组中的项目。同样,这个任务可以通过另一个内置数组方法 find 得到极大的简化。让我们研究以下列表。

注意

内置的 find 方法是 ES6 标准的一部分。关于当前浏览器的兼容性,请参阅 mng.bz/U532

列表 9.9. 查找数组项目

图片 2

图片 6

找到一个满足特定条件的数组项很容易:我们使用内置的 find 方法,传递一个回调函数,该函数对集合中的每个项进行调用,直到找到目标项。这由回调返回 true 来指示。例如,以下表达式

ninjas.find(ninja => ninja.weapon === "wakizashi");

找到 ninjas 数组中第一个携带短刀的忍者 Kuma。

如果我们遍历了整个数组而没有单个项返回 true,则搜索的最终结果是 undefined。例如,以下代码

ninjaWithKatana = ninjas.find(ninja => ninja.weapon === "katana");

返回 undefined,因为没有携带武士刀的忍者。图 9.8 展示了 find 函数的内部工作原理。

图 9.8. find 函数在一个数组中查找一个项:第一个使 find 回调返回 true 的项。

如果我们需要找到满足特定标准的多项,我们可以使用 filter 方法,它创建一个包含所有满足该标准的项的新数组。例如,以下表达式

const armedNinjas = ninjas.filter(ninja => "weapon" in ninja);

创建一个只包含 ninjas 武器的 armedNinjas 数组。在这种情况下,可怜的无武器 Yoshi 被排除在外。图 9.9 展示了 filter 函数的工作方式。

图 9.9. filter 函数创建一个新数组,该数组包含所有使回调返回 true 的项。

在这个例子中,你已经看到了如何在数组中查找特定项,但在许多情况下,也可能需要找到项的索引。让我们通过以下示例进行更详细的了解。

列表 9.10. 查找数组索引
const ninjas = ["Yagyu", "Yoshi", "Kuma", "Yoshi"];

assert(ninjas.indexOf("Yoshi") === 1, "Yoshi is at index 1");
assert(ninjas.lastIndexOf("Yoshi") === 3, "and at index 3");

const yoshiIndex = ninjas.findIndex(ninja => ninja === "Yoshi");

assert(yoshiIndex === 1, "Yoshi is still at index 1");

要找到特定项的索引,我们使用内置的 indexOf 方法,传递我们想要找到索引的项:

ninjas.indexOf("Yoshi")

在特定项可以在数组中多次出现的情况下(例如 "Yoshi"ninjas 数组),我们可能还感兴趣找到 Yoshi 出现的最后一个索引。为此,我们可以使用 lastIndexOf 方法:

ninjas.lastIndexOf("Yoshi")

最后,在大多数情况下,当我们没有要搜索的项的确切索引的引用时,我们可以使用 findIndex 方法:

const yoshiIndex = ninjas.findIndex(ninja => ninja === "Yoshi");

findIndex 方法接受一个回调函数并返回第一个使回调返回 true 的项的索引。本质上,它的工作方式与 find 方法非常相似,唯一的区别在于 find 返回一个特定的项,而 findIndex 返回该项的索引。

排序数组

最常见的数组操作之一是 排序——有系统地按某种顺序排列项。不幸的是,正确实现排序算法并不是一项容易的编程任务:我们必须为任务选择最佳的排序算法,实现它,并根据我们的需求进行调整,同时始终小心不要引入微妙的错误。为了减轻这个负担,正如你在第三章中看到的,所有 JavaScript 数组都可以访问内置的 sort 方法,其用法如下:

array.sort((a, b) => a – b);

JavaScript 引擎实现了排序算法。我们唯一需要提供的是回调函数,它告知排序算法两个数组项之间的关系。可能的结果如下:

  • 如果回调返回的值小于 0,则项目 a 应该排在项目 b 之前。

  • 如果回调返回的值等于 0,则项目 ab 处于平等地位(就排序算法而言,它们是相等的)。

  • 如果回调返回的值大于 0,则项目 a 应该排在项目 b 之后。

图 9.10 展示了排序算法根据回调返回值所做的决策。

图 9.10. 如果回调返回的值小于 0,则第一个项目应排在第二个项目之前。如果回调返回 0,则两个项目应保持不变。如果返回值大于 0,则第一个项目应排在第二个项目之后。

关于排序算法,你只需要知道这么多。实际的排序是在幕后进行的,我们无需手动移动数组项。让我们看看一个简单的例子。

列表 9.11. 排序数组

在 列表 9.11 中,我们有一个包含 ninja 对象的数组,其中每个忍者和一个名字。我们的目标是按忍者名字的字典顺序(字母顺序)对数组进行排序。为此,我们自然使用 sort 函数:

ninjas.sort(function(ninja1, ninja2){
  if(ninja1.name < ninja2.name) { return -1; }
  if(ninja1.name > ninja2.name) { return 1; }

  return 0;
});

我们只需要传递给 sort 函数一个用于比较两个数组项的回调函数。因为我们想进行字典比较,所以我们声明如果 ninja1 的名字“小于” ninja2 的名字,回调返回 -1(记住,这意味着 ninja1 应该在最终排序顺序中排在 ninja2 之前);如果它更大,回调返回 1ninja1 应该排在 ninja2 之后);如果它们相等,回调返回 0。注意,我们可以使用简单的小于 (<) 和大于 (>) 运算符来比较两个忍者的名字。

就这些了!排序的其余细节留给 JavaScript 引擎处理,我们无需担心。

聚合数组项

你有多少次编写过如下代码?

const numbers = [1, 2, 3, 4];
const sum = 0;

numbers.forEach(number => {
   sum += number;
});

assert(sum === 10, "The sum of first four numbers is 10");

这段代码必须访问集合中的每个项目并聚合一些值,本质上是将整个数组缩减为一个值。别担心——JavaScript 也有助于这种情况,那就是 reduce 方法,如下例所示。

列表 9.12. 使用 reduce 聚合项目

reduce 方法通过获取初始值(在本例中为 0)并在每个数组项上调用回调函数,将前一个回调调用的结果(或初始值)和当前数组项作为参数。reduce 调用的结果是最后一个回调的结果,该回调在最后一个数组项上调用。图 9.11 更详细地说明了这个过程。

图 9.11。reduce函数将回调函数应用于聚合值和数组中的每个元素,以将数组缩减为单个值。

09fig11_alt.jpg

我们希望我们已经说服您,JavaScript 包含一些有用的数组方法,这些方法可以使我们的生活变得更加轻松,代码更加优雅,而无需求助于讨厌的for循环。如果您想了解更多关于这些和其他数组方法的信息,我们建议您查看 Mozilla 开发者网络上的解释,网址为mng.bz/cS21

现在我们将进一步深入,向您展示如何在自己的自定义对象上重用这些数组方法。

9.1.5。重用内置数组函数

有时候,我们可能想要创建一个包含数据集合的对象。如果集合是我们唯一关心的事情,我们可以使用数组。但在某些情况下,可能需要存储比集合本身更多的状态——也许我们需要存储有关收集项的一些元数据。

有一种选择是每次需要创建此类对象的新版本时,就创建一个新的数组,并将元数据属性和方法添加到其中。记住,我们可以随意向对象添加属性和方法,包括数组。然而,通常情况下,这可能会很慢,而且很繁琐。

让我们考察一下使用普通对象并“赋予”它我们所需功能的可能性。Array对象上已经存在一些知道如何处理集合的方法;我们能否欺骗它们在我们的对象上工作?事实证明我们可以,如下面的列表所示。

列表 9.13。模拟数组类似方法

ch09ex13-0.jpg

ch09ex13-1.jpg

在这个例子中,我们创建了一个“普通”对象,并对其进行了配置以模拟数组的一些行为。首先,我们定义一个length属性来记录存储的元素数量,就像数组一样。然后,我们定义一个方法来向模拟数组的末尾添加一个元素,这个方法叫做add

add: function(elem){
  Array.prototype.push.call(this, elem);
}

我们可以不编写自己的代码,而是使用 JavaScript 数组的原生方法:Array.prototype.push

通常,Array.prototype.push方法会通过其函数上下文操作它自己的数组。但在这里,我们通过使用call方法(记得第四章)并强制我们的对象成为push方法的上下文,来欺骗这个方法使用我们的对象作为上下文。(注意我们也可以同样容易地使用apply方法。)push方法增加length属性(认为它是数组的length属性),向对象添加一个引用传递元素的编号属性。从某种意义上说,这种行为几乎是颠覆性的(对于忍者来说多么合适!),但它展示了我们可以用可变对象上下文做什么。

add方法期望传递一个元素引用以进行存储。虽然有时我们可能周围有这样的引用,但更常见的情况是我们没有,所以我们还定义了一个便利方法gather,它通过其id值查找元素并将其添加到存储中:

gather: function(id){
  this.add(document.getElementById(id));
}

最后,我们还定义了一个find方法,它允许我们通过利用内置数组find方法的优势,在我们的自定义对象中找到任意项:

find: function(callback){
  return Array.prototype.find.call(this, callback);
}

在本节中展示的边缘恶性行为不仅揭示了可塑函数上下文赋予我们的力量,还展示了我们如何巧妙地重用已经编写的代码,而不是不断地重新发明轮子。

现在我们已经花了一些时间在数组上,让我们继续学习 ES6 引入的两种新类型的集合:映射和集合。

9.2。映射

假设你是freelanceninja.com的开发商,这个网站希望迎合更国际化的受众。对于网站上的每一篇文本——例如,“招聘忍者”——你希望为每种目标语言创建一个映射,例如“图片”(日语),“图片”(中文)或“图片”(韩语)(希望谷歌翻译已经做得足够好)。这些将键映射到特定值的集合,在不同的编程语言中被称为不同的名称,但最常见的是被称为字典映射

但你如何在 JavaScript 中有效地管理这种本地化?一种传统的方法是利用对象是命名属性和值的集合的事实,并创建类似以下字典的东西:

图片

乍一看,这似乎是解决这个问题的完美方法,在这个例子中,它并不算太差。但不幸的是,在一般情况下,你不能依赖它。

9.2.1。不要将对象用作映射

假设在我们网站的某个地方我们需要访问单词constructor的翻译,所以我们把字典示例扩展到以下代码。

代码清单 9.14。对象可以访问未明确定义的属性

图片

我们尝试访问单词constructor的翻译——一个我们愚蠢地忘记在字典中定义的单词。通常情况下,在这种情况下,我们期望字典返回undefined。但结果并非如此,正如你在图 9.12 中看到的那样。

图 9.12 显示,运行代码清单 9.14 表明对象不是好的映射,因为它们可以访问未明确定义(通过它们的原型)的属性。

图片

如你所见,通过访问constructor属性,我们获得了以下字符串:

"function Object() { [native code] }"

这是怎么回事?正如你在第七章中学到的,所有对象都有原型;即使我们定义新的、空的对象作为我们的映射,它们仍然可以访问原型对象中的属性。这些属性之一是constructor(回想一下,constructor是原型对象指向构造函数的属性),它是我们现在手头混乱的罪魁祸首。

此外,在对象中,键只能为字符串值;如果你想要为任何其他值创建映射,该值将被静默转换为字符串,而不会有人询问你任何问题!例如,想象一下我们想要跟踪一些关于 HTML 节点的信息,如下所示。

列表 9.15. 使用对象将值映射到 HTML 节点

在列表 9.15 中,我们创建了两个 HTML 元素,firstElementsecondElement,然后我们使用document.getElementById方法从 DOM 中获取它们。为了创建一个将存储每个元素额外信息的映射,我们定义了一个普通的 JavaScript 对象:

const map = {};

然后,我们使用 HTML 元素作为映射对象的键,并与之关联一些数据:

map[firstElement] = { data: "firstElement"}

然后,我们检查我们是否可以检索这些数据。因为这是按预期工作的,所以我们为第二个元素重复整个过程:

map[secondElement] = { data: "secondElement"};

再次,一切看起来都很正常;我们已经成功地与我们的 HTML 元素关联了一些数据。但如果我们决定重新访问第一个元素,就会出现问题:

map[firstElement].data

本来可能会认为我们会再次获得第一个元素的信息,但事实并非如此。相反,如图图 9.13 所示,返回的是第二个元素的信息。

图 9.13. 运行列表 9.15 中的代码显示,如果我们尝试将对象用作对象属性,对象会被转换为字符串。

这是因为在对象中,键被存储为字符串。这意味着当我们尝试将任何非字符串值,例如一个 HTML 元素,作为对象的属性时,该值会通过调用其toString方法被静默转换为字符串。在这里,这返回字符串"[object HTMLDivElement]",并且关于第一个元素的信息被存储为[object HTMLDivElement]属性的值。

接下来,当我们尝试为第二个元素创建映射时,发生类似的事情。第二个元素也是一个 HTML div 元素,它也被转换为字符串,并且它的额外数据也被分配给[object HTMLDivElement]属性,覆盖了我们为第一个元素设置的值。

由于这两个原因——通过原型继承的属性和仅支持字符串键的支持——普通对象通常不适用于映射。由于这种限制,ECMAScript 委员会指定了一个全新的类型:Map

注意

Map 是 ES6 标准的一部分。关于当前浏览器的兼容性,请参阅:mng.bz/JYYM

9.2.2. 创建我们的第一个映射

创建映射很容易:我们使用一个新的内置Map构造函数。看看以下示例。

列表 9.16. 创建我们的第一个映射

在这个例子中,我们通过调用内置的Map构造函数来创建一个新的映射:

const ninjaIslandMap = new Map();

接下来,我们创建了三个忍者对象,巧妙地命名为ninja1ninja2ninja3。然后我们使用内置的映射set方法:

ninjaIslandMap.set(ninja1, { homeIsland: "Honshu"});

这在键(在这种情况下,是ninja1对象)和值(在这种情况下,是一个携带忍者家乡岛屿信息的对象)之间创建了一个映射。我们为前两个忍者ninja1ninja2这样做。

在下一步中,我们通过使用另一个内置的映射方法get来获取前两个忍者的映射:

assert(ninjaIslandMap.get(ninja1).homeIsland === "Honshu",
      "The first mapping works");

课程映射对于前两个忍者是存在的,但对于第三个忍者则不存在,因为我们没有将第三个忍者作为set方法的参数使用。当前映射的状态显示在图 9.14 中。

图 9.14. 映射是一组键值对,其中键可以是任何东西——甚至是另一个对象。

除了getset方法之外,每个映射还有一个内置的size属性和has以及delete方法。size属性告诉我们我们创建了多少映射。在这种情况下,我们只创建了两个映射。

另一方面,has方法会通知我们特定键的映射是否已经存在:

ninjaIslandMap.has(ninja1); //true
ninjaIslandMap.has(ninja3); //false

delete方法使我们能够从我们的映射中删除项:

ninjaIslandMap.delete(ninja1);

处理映射时的一个基本概念是确定两个映射键何时相等。让我们探讨这个概念。

键相等

如果你来自一个稍微传统一点的背景,比如 C#、Java 或 Python,你可能对下一个例子感到惊讶。

列表 9.17. 映射中的键相等

在列表 9.17 中,我们使用内置的location.href属性来获取当前页面的 URL。接下来,通过使用内置的 URL 构造函数,我们创建了两个指向当前页面的新 URL 对象。然后我们将描述对象与每个链接关联起来。最后,我们检查是否创建了正确的映射,如图图 9.15 所示。

图 9.15. 如果我们运行列表 9.17 中的代码,我们可以看到映射中的键相等是基于对象相等的。

对于主要在 JavaScript 中工作的人来说,这个结果可能并不意外:我们创建了两个不同的对象,并为它们创建了两个不同的映射。但请注意,尽管这两个 URL 对象是独立的对象,它们仍然指向相同的 URL 位置:当前页面的位置。我们可以争论,在创建映射时,这两个对象应该被视为相等。但在 JavaScript 中,我们不能重载等号运算符,并且这两个对象,尽管它们具有相同的内容,总是被视为不同的。在其他语言中,例如 Java 和 C#,情况并非如此,所以请小心!

9.2.3. 遍历映射

到目前为止,你已经看到了一些映射的优势:你可以确信它们只包含你放入其中的项目,并且你可以使用任何东西作为键。但还有更多!

因为映射是集合,所以我们没有理由不能使用 for...of 循环遍历它们。(记住,我们在第六章中使用了 for...of 循环来遍历由生成器创建的值。)你还可以保证这些值将以它们被插入的顺序被访问(当我们使用 for...in 循环遍历对象时,我们无法依赖这一点)。让我们看看以下示例。

列表 9.18. 遍历映射

图片

如前一个列表所示,一旦我们创建了一个映射,我们就可以很容易地使用 for...of 循环遍历它:

for(var item of directory){
  assert(item[0] !== null, "Key:" + item[0]);
  assert(item[1] !== null, "Value:" + item[1]);
}

在每次迭代中,这会给出一个包含两个项目的数组,其中第一个项目是一个键,第二个项目是我们目录映射中一个项目的值。我们还可以使用 keysvalues 方法来遍历映射中包含的键和值。

现在我们已经了解了映射,让我们来看看 JavaScript 的另一个新成员:集合,它是一组唯一的项。

9.3. 集合

在许多现实世界的问题中,我们必须处理称为 集合 的唯一项的集合(意味着每个项目不能出现多次)。在 ES6 之前,你必须通过使用标准对象模拟集合来实现这一点。以下是一个粗略的示例。

列表 9.19. 使用对象模拟集合

图片

图片

列表 9.19 展示了如何使用对象模拟集合的简单示例。我们使用数据存储对象 data 来跟踪我们的集合项,并公开了三个方法:has,它检查一个项目是否已经包含在集合中;add,它仅在相同的项目尚未包含在集合中时添加项目;以及 remove,它从集合中删除已包含的项目。

但这是一个糟糕的复制品。因为与映射不同,你实际上不能存储对象——只有字符串和数字,并且始终存在访问原型对象的危险。出于这些原因,ECMAScript 委员会决定引入一种全新的集合类型:集合

注意

集合是 ES6 标准的一部分。关于当前浏览器的兼容性,请参阅 mng.bz/QRTS

9.3.1. 创建我们的第一个集合

创建集合的基石是新引入的构造函数,方便地命名为 Set。让我们看看一个示例。

列表 9.20. 创建一个集合

图片

在这里,我们使用内置的 Set 构造函数创建一个新的 ninjas 集合,它将包含不同的忍者。如果我们不传递任何参数,将创建一个空集合。我们也可以传递一个数组,例如这个,它预先填充集合:

new Set(["Kuma", "Hattori", "Yagyu", "Hattori"]);

如我们之前提到的,集合是唯一项目的集合,它们的主要目的是阻止我们存储相同对象的多个实例。在这种情况下,这意味着 "Hattori",我们试图添加两次,只添加了一次。

每个集合都可以访问多个方法。例如,has 方法检查一个项目是否包含在集合中:

ninjas.has("Hattori")

add 方法用于向集合中添加唯一的元素:

ninjas.add("Yoshi");

如果你好奇一个集合中有多少个元素,你总是可以使用 size 属性。

与映射和数组类似,集合是集合,因此我们可以使用 for...of 循环遍历它们。正如你在图 9.16 中可以看到的,元素总是按照它们被插入的顺序遍历。

图 9.16. 运行列表 9.20 中的代码显示,集合中的元素是按照它们被插入的顺序遍历的。

既然我们已经了解了集合的基础知识,让我们来看看集合的一些常见操作:并集、交集和差集。

9.3.2. 集合的并集

两个集合 AB 的并集创建了一个新的集合,该集合包含 AB 中的所有元素。自然地,每个元素在新集合中不会出现超过一次。

列表 9.21. 使用集合执行集合的并集操作

我们首先创建一个 ninjas 数组和一个 samurai 数组。注意,服部的生活很忙碌:白天是武士,晚上是忍者。现在想象一下,如果我们需要创建一个集合,我们可以召集这些人,如果邻近的大名决定他的领地有点拥挤。我们创建一个新的集合 warriors,包括所有忍者和所有武士。服部在两个集合中,但我们只想包含他一次——不像有两个服部会回应我们的召唤。

在这种情况下,集合是完美的!我们不需要手动跟踪一个项目是否已经被包含:集合会自动处理这一点。在创建这个新集合时,我们使用扩展运算符 [...ninjas, ...samurai](记住第三章)来创建一个新的数组,该数组包含所有忍者和所有武士。如果你想知道,服部在这个新数组中出现了两次。但当我们最终将这个数组传递给 Set 构造函数时,服部只被包含一次,如图 9.17 所示。

图 9.17. 两个集合的并集保留了两个集合中的元素(没有重复)。

9.3.3. 集合的交集

两个集合 AB交集创建了一个包含 A 中也在 B 中的元素的集合。例如,我们可以找到既是忍者又是武士的人,如下所示。

列表 9.22. 集合的交集

清单 9.22 的想法是创建一个只包含也是武士的忍者的新集合。我们通过利用数组的 filter 方法来实现这一点,正如你可能记得的,filter 方法创建一个只包含符合特定标准的项目的新数组。在这种情况下,标准是忍者也是武士(包含在武士集合中)。因为 filter 方法只能用于数组,我们必须通过使用扩展运算符将 ninjas 集合转换为数组:

[...ninjas]

最后,我们检查是否只找到了一个既是忍者又是武士的忍者:万事通,服部。

9.3.4. 集合的差集

两个集合 AB 的差集包含所有在集合 A 中但不在集合 B 中的元素。正如你可能猜到的,这与集合的交集类似,但有一个微小但重要的区别。在下一个列表中,我们只想找到真正的忍者(不是那些也兼职做武士的人)。

列表 9.23. 集合的差集

图片 255fig02_alt.jpg

唯一的改变是,我们只关心那些不是武士的忍者,通过在 samurai.has(ninja) 表达式前放置一个感叹号(!)来实现。

9.4. 概述

  • 数组是一种特殊的对象,具有 length 属性,其原型为 Array.prototype

  • 我们可以使用数组字面量表示法([])或通过调用内置的 Array 构造函数来创建新数组。

  • 我们可以使用从数组对象可访问的几种方法来修改数组的内 容:

    • 内置的 pushpop 方法可以在数组的末尾添加和移除项目。

    • 内置的 shiftunshift 方法可以在数组的开始处添加和移除项目。

    • 内置的 splice 方法可以用来从任意数组位置移除项目并添加项目。

  • 所有数组都可以访问许多有用的方法:

    • map 方法通过在每一个元素上调用回调函数来创建一个包含结果的新数组。

    • everysome 方法确定所有或某些数组项目是否满足某个特定标准。

    • findfilter 方法可以找到满足特定条件的数组项目。

    • sort 方法可以对数组进行排序。

    • reduce 方法将数组中的所有项目聚合到一个单一值。

  • 当通过显式设置 callapply 方法的方法调用上下文来实现自己的对象时,你可以重用内置的数组方法。

  • 映射和字典是包含键和值之间映射的对象。

  • JavaScript 中的对象作为映射很糟糕,因为你只能使用字符串值作为键,并且始终存在访问原型属性的风险。相反,使用新的内置 Map 集合。

  • 映射是集合,可以使用 for...of 循环迭代。

  • 集合是一系列独特的项目。

9.5. 练习

1

在运行以下代码后,samurai 数组的内容将是什么?

const samurai = ["Oda", "Tomoe"];
samurai[3] = "Hattori";

2

运行以下代码后,ninjas数组的内容将会是什么?

const ninjas = [];

ninjas.push("Yoshi");
ninjas.unshift("Hattori");

ninjas.length = 3;

ninjas.pop();

3

运行以下代码后,samurai数组的内容将会是什么?

const samurai = [];

samurai.push("Oda");
samurai.unshift("Tomoe");
samurai.splice(1, 0, "Hattori", "Takeda");
samurai.pop();

4

运行以下代码后,变量firstsecondthird将会存储什么?

const ninjas = [{name:"Yoshi", age: 18},
             {name:"Hattori", age: 19},
             {name:"Yagyu", age: 20}];

const first = persons.map(ninja => ninja.age);
const second = first.filter(age => age % 2 == 0);
const third = first.reduce((aggregate, item) =>  aggregate + item, 0);

5

运行以下代码后,变量firstsecond将会存储什么?

const ninjas = [{ name: "Yoshi", age: 18 },
               { name: "Hattor", age: 19 },
               { name: "Yagyu", age: 20 }];

const first = ninjas.some(ninja => ninja.age % 2 == 0);
const second = ninjas.every(ninja => ninja.age % 2 == 0);

6

以下哪个断言将会通过?

const samuraiClanMap = new Map();

const samurai1 = { name: "Toyotomi"};
const samurai2 = { name: "Takeda"};
const samurai3 = { name: "Akiyama"};

const oda = { clan: "Oda"};
const tokugawa = { clan: "Tokugawa"};
const takeda ={clan: "Takeda"};

samuraiClanMap.set(samurai1, oda);
samuraiClanMap.set(samurai2, tokugawa);
samuraiClanMap.set(samurai2, takeda);
assert(samuraiClanMap.size === 3, "There are three mappings");
assert(samuraiClanMap.has(samurai1), "The first samurai has a mapping");
assert(samuraiClanMap.has(samurai3), "The third samurai has a mapping");

7

以下哪个断言将会通过?

const samurai = new Set("Toyotomi", "Takeda", "Akiyama", "Akiyama");
assert(samurai.size === 4, "There are four samurai in the set");

samurai.add("Akiyama");
assert(samurai.size === 5, "There are five samurai in the set");

assert(samurai.has("Toyotomi", "Toyotomi is in!");
assert(samurai.has("Hattori", "Hattori is in!");

第十章. 处理正则表达式

本章涵盖

  • 正则表达式复习

  • 编译正则表达式

  • 使用正则表达式捕获

  • 处理常见习语

正则表达式是现代开发的必需品。我们就是这样说的。尽管许多网页开发者可能一生中都可以快乐地忽略正则表达式,但在 JavaScript 代码中需要解决的问题中,没有正则表达式就无法优雅地解决。

当然,可能还有其他解决同样问题的方法。但通常,使用适当的正则表达式,可能需要半屏代码的任务可以简化为一个语句。所有 JavaScript 忍者都需要正则表达式作为他们工具箱的一个基本部分。

正则表达式简化了拆分字符串并查找信息的过程。在主流 JavaScript 库的任何地方,你都会看到正则表达式被广泛用于各种定位任务:

  • 操作 HTML 节点字符串

  • 在 CSS 选择器表达式中定位部分选择器

  • 确定元素是否具有特定的类名

  • 输入验证

  • 以及更多

让我们从查看一个例子开始。

提示

要熟练掌握正则表达式需要大量的练习。你可能会发现像 JS Bin(jsbin.com)这样的网站在尝试示例时很有用。有几个网站专门用于正则表达式测试,例如 JavaScript 的正则表达式测试页面(www.regexplanet.com/advanced/javascript/index.html)和 regex101(www.regex101.com/#javascript)。regex101 对于初学者来说尤其有用,因为它还会自动生成目标正则表达式的解释。

你知道吗?

Q1:

在什么情况下你更愿意使用正则表达式字面量而不是正则表达式对象?

Q2:

什么是粘性匹配,如何启用它?

Q3:

当使用全局正则表达式与非全局正则表达式匹配时,匹配有何不同?

10.1. 为什么正则表达式很棒

假设我们想要验证一个字符串,可能是网站用户输入到表单中的字符串,是否符合九位美国邮政编码的格式。我们都知道美国邮政服务几乎没有幽默感,并坚持认为邮政编码(也称为 ZIP 代码)必须遵循以下特定格式:

99999-9999

每个9代表一个十进制数字,格式是 5 个十进制数字,后面跟着一个连字符,然后是 4 个十进制数字。如果你使用任何其他格式,你的包裹或信件就会被送入手工分拣部门的黑洞,好运预测它再次出现需要多长时间。

让我们创建一个函数,给定一个字符串,验证美国邮政服务是否会保持满意。我们可以通过逐个字符进行比较,但我们是忍者,这种解决方案太不优雅了,会导致很多不必要的重复。相反,考虑以下解决方案。

列表 10.1. 在字符串中测试特定模式

图片

这段代码利用了这样一个事实,即我们只需要进行两次检查,这取决于字符在字符串中的位置。我们仍然需要在运行时进行多达九次比较,但我们必须只写一次每个比较。

即使如此,有人会认为这个解决方案是优雅的吗?它比蛮力、非迭代方法更优雅,但它仍然看起来为这样一个简单的检查编写了太多的代码。现在考虑这个方法:

function isThisAZipCode(candidate) {
  return /^\d{5}-\d{4}$/.test(candidate);
}

除了函数体中的一些晦涩语法之外,这要简洁和优雅得多,不是吗?这就是正则表达式的力量,这只是冰山一角。如果那种语法看起来像是某个人的宠物鬣蜥在键盘上走过,请不要担心;在我们教你如何以忍者般的方式在你的页面上使用正则表达式之前,我们将回顾正则表达式。

10.2. 正则表达式复习

尽管我们很想在这里提供一个关于正则表达式的详尽教程,但我们不能。毕竟,已经有整本书是关于正则表达式的。但我们将尽力涵盖所有重要点。

对于本章中我们无法提供的更多细节,杰弗里·E·F·弗里德尔的《精通正则表达式》、迈克尔·菲茨杰拉德的《介绍正则表达式》以及简·戈伊瓦雷茨和史蒂文·莱维森的《正则表达式食谱》(所有来自 O'Reilly 出版社)都是流行的选择。

让我们深入探讨。

10.2.1. 正则表达式解释

术语正则表达式起源于中世纪的数学,当时一位名叫斯蒂芬·克莱尼的数学家将计算自动机的模型描述为“正则集”。但这不会帮助我们理解正则表达式,所以让我们简化一下,说正则表达式是表达文本字符串匹配模式的一种方式。表达式本身由术语和运算符组成,允许我们定义这些模式。我们很快就会看到这些术语和运算符由什么组成。

在 JavaScript 中,就像大多数其他对象类型一样,我们有两种方式来创建正则表达式:

  • 通过正则表达式字面量

  • 通过构造一个RegExp对象的实例

例如,如果我们想创建一个普通的正则表达式(或简称为 regex),以精确匹配字符串 test,我们可以使用正则表达式字面量来完成:

const pattern = /test/;

这可能看起来有些奇怪,但正则表达式字面量与字符串字面量一样,都是用正斜杠分隔的。

或者,我们可以构造一个 RegExp 实例,传递正则表达式作为字符串:

const pattern = new RegExp("test");

这两种格式都会在变量 pattern 中创建相同的正则表达式。

提示

当正则表达式在开发时已知时,首选字面量语法,而当正则表达式在运行时通过动态构建字符串来构建时,则使用构造函数方法。

之所以首选字面量语法而不是在字符串中表达正则表达式,其中一个原因是(您很快就会看到)反斜杠字符在正则表达式中起着重要作用。但反斜杠字符也是字符串字面量的转义字符,因此要在字符串字面量中表达反斜杠,我们需要使用双反斜杠(\\)。这可能会使正则表达式,其本身就已经具有神秘的语法,在字符串中表达时看起来更加奇特。

除了表达式本身之外,还可以将五个标志与正则表达式相关联:

  • i—使正则表达式不区分大小写,因此 /test/i 不仅匹配 test,还匹配 TestTESTtEsT 等等。

  • g—匹配所有模式的实例,而不是默认的 局部 匹配,后者只匹配第一个出现。关于这一点稍后会有更多介绍。

  • m—允许跨多行匹配,就像从 textarea 元素的值中获取的那样。

  • y—启用粘性匹配。正则表达式通过尝试从最后一个匹配位置开始匹配,在目标字符串中执行粘性匹配。

  • u—启用使用 Unicode 点转义(\u{...})。

这些标志附加到字面量的末尾(例如,/test/ig)或作为 RegExp 构造函数的第二个参数传递(new RegExp("test", "ig"))。

精确匹配字符串 test(即使在不区分大小写的情况下)并不有趣——毕竟,我们可以用简单的字符串比较来完成这个特定的检查。所以,让我们来看看那些赋予正则表达式强大匹配能力的关键词和运算符。

10.2.2. 术语和运算符

正则表达式,就像我们熟悉的许多其他表达式一样,由术语和运算符组成,这些术语和运算符对术语进行限定。在接下来的章节中,您将看到这些术语和运算符如何用来表达模式。

精确匹配

任何不是特殊字符或运算符(我们将随着介绍而介绍)的字符都必须在表达式中以字面形式出现。例如,在我们的 /test/ 正则表达式中,四个术语代表在字符串中必须以字面形式出现的字符,以便匹配所表达的图案。

将此类字符依次放置,隐含地表示一个操作,意味着 跟随。所以 /test/ 表示 t 跟随 e 跟随 s 跟随 t

从字符类中匹配

许多时候,我们可能不想匹配特定的字面字符,而是想匹配有限字符集中的字符。我们可以通过将我们想要匹配的字符集放在方括号中来指定这一点:[abc]

上述示例表示我们想匹配字符 abc 中的任意一个。请注意,尽管这个表达式跨越了五个字符(三个字母和两个括号),但它只匹配候选字符串中的单个字符。

其他时候,我们可能想匹配除了有限字符集之外的任何字符。我们可以通过在集合操作符的开括号后放置一个 caret 字符(^)来指定这一点:

[^abc]

这将改变意义,变为除了 abc 之外的任意字符。

集合操作中还有一个非常有价值的变体:指定值范围的能力。例如,如果我们想匹配介于 am 之间的任意一个小写字母,我们可以写成 [abcdefghijklm]。但我们可以更简洁地表达如下:

[a-m]

破折号表示从 am(包括)的所有字符(按字典顺序)都包含在集合中。

转义

并非所有字符都代表它们的字面等价物。当然,所有的字母和十进制数字字符都代表自己,但正如你将看到的,特殊字符如 $ 和点(.)代表的是除了它们自身之外的其他匹配项,或者是对前面项进行限定运算符。事实上,你已经在 []-^ 字符如何用来表示除了它们字面意义之外的内容中看到了。

我们如何指定我们想要匹配字面 ``、$^ 或其他特殊字符?在正则表达式中,反斜杠字符会转义其后的任何字符,使其成为一个字面匹配项。所以 \[ 指定对 [ 字符的字面匹配,而不是字符类表达式的开始。双反斜杠(\\)匹配单个反斜杠。

开始和结束

经常情况下,我们可能想确保一个模式与字符串的开头匹配,或者可能在字符串的末尾匹配。当正则表达式的第一个字符是 caret 字符时,它将匹配锚定在字符串的开头,例如 /^test/ 只在匹配的子字符串 test 出现在被匹配的字符串开头时才匹配。(注意,这是 ^ 字符的重载,因为它也用于否定字符类集。)

类似地,美元符号($)表示模式必须出现在字符串的末尾:

/test$/

使用 ^$ 两个符号表示指定的模式必须涵盖整个候选字符串:

/^test$/
重复出现

如果我们要匹配一系列四个 a 字符,我们可以用 /aaaa/ 来表达,但如果我们想匹配任何数量的相同字符呢?正则表达式使我们能够指定几个重复选项:

  • 要指定一个字符是可选的(它可以出现一次或不出现),在其后跟 ?。例如,/t?est/ 匹配 testest

  • 要指定一个字符出现一次或多次,使用 +,例如 /t+est/,它可以匹配 testttesttttest,但不能匹配 est

  • 要指定字符出现 零、一次多次,使用 *,例如 /t*est/,它可以匹配 testttesttttestest

  • 要指定固定数量的重复,在花括号中指定允许重复的次数。例如,/a{4}/ 表示匹配四个连续的 a 字符。

  • 要指定重复计数的范围,用逗号分隔符表示范围。例如,/a{4,10}/ 匹配任何由 4 到 10 个连续 a 字符组成的字符串。

  • 要指定一个开放的范围,省略范围中的第二个值(但保留逗号)。正则表达式 /a{4,}/ 匹配任何由四个或更多连续 a 字符组成的字符串。

这些重复运算符可以是 贪婪的非贪婪的。默认情况下,它们是贪婪的:它们将消耗构成匹配的所有可能的字符。通过在运算符上使用 ? 字符(? 运算符的重载),例如 a+?,可以使操作非贪婪的:它将只消耗 足够 的字符以进行匹配。

例如,如果我们正在匹配字符串 aaa,正则表达式 /a+/ 会匹配所有三个 a 字符,而非贪婪表达式 /a+?/ 只会匹配一个 a 字符,因为单个 a 字符就足以满足 a+ 项。

预定义字符类

我们可能想要匹配的一些字符无法用字面字符指定(例如,回车等控制字符)。此外,我们可能经常想要匹配字符类,例如一组十进制数字或一组空白字符。正则表达式语法提供了预定义的术语来表示这些字符或常用类,这样我们就可以在我们的正则表达式中使用控制字符匹配,而无需求助于字符类运算符来处理常用字符集。

[表 10.1 列出了这些术语以及它们所代表的字符或集合。这些预定义集合有助于使我们的正则表达式看起来不那么晦涩。

表 10.1. 预定义字符类和字符项
预定义术语 匹配
\t 水平制表符
\b 退格
\v 垂直制表符
\f 分页符
\r 回车
\n 换行
\cA : \cZ 控制字符
\u0000 : \uFFFF Unicode 十六进制
\x00 : \xFF ASCII 十六进制
. 任何字符,除了空白字符 (\s)
\d 任何十进制数字;等同于 [0-9]
\D 任何非十进制数字字符;等同于 [⁰-9]
\w 包括下划线的任何字母数字字符;等同于 [A-Za-z0-9_]
\W 任何非字母数字和下划线字符;等同于 [^A-Za-z0-9_]
\s 任何空白字符(空格、制表符、换页符等)
\S 任何非空白字符
\b 单词边界
\B 非单词边界(在单词内部)
分组

到目前为止,你已经看到运算符(如 +*)只影响前面的术语。如果我们想将运算符应用于一组术语,我们可以使用括号进行分组,就像在数学表达式中一样。例如,/(ab)+/ 匹配 ab 子串的一个或多个连续出现。

当正则表达式的一部分用括号分组时,它具有双重作用,同时也创建了一个称为 捕获 的内容。关于捕获有很多内容,我们将在 第 10.4 节 中更深入地讨论。

选择(或)

可以使用管道符(|)来表示替代项。例如:/a|b/ 匹配字符 ab,而 /(ab)+|(cd)+/ 匹配 abcd 的一个或多个连续出现。

回溯引用

我们可以在正则表达式中表达的最复杂术语是回溯到正则表达式中定义的 捕获。我们在 第 10.4 节 中详细讨论了捕获,但在此处,你可以将它们视为与正则表达式中的术语成功匹配的候选字符串的部分。此类术语的表示法是反斜杠后跟要引用的捕获编号,从 1 开始,例如 \1\2 等。

例如 /^([dtn])a\1/ 匹配以 dtn 中的任何一个字符开始的字符串,后面跟着一个 a,然后跟着与第一个捕获匹配的任何字符。这一点很重要!这不同于 /[dtn] a[dtn]/a 后面的字符不能是 dtn 中的任何一个,而必须是触发第一个字符匹配的那个字符。因此,\1 将匹配哪个字符只能在评估时才能知道。

这种方法在匹配 XML 类型的标记元素时可能非常有用。考虑以下正则表达式:

/<(\w+)>(.+)<\/\1>/

这允许我们匹配简单的元素,例如 <strong> whatever </strong>。如果没有指定回溯引用的能力,这是不可能的,因为我们无法提前知道哪个结束标签会匹配前面的开始标签。

小贴士

这就是关于正则表达式的一个快速入门课程。如果它们仍然让你感到困扰,并且你发现自己陷入了后续材料的困境,我们强烈建议你使用本章前面提到的资源之一。

现在你已经掌握了正则表达式的基础,你就可以开始学习如何在代码中明智地使用它们了。

10.3. 编译正则表达式

正则表达式会经过多个处理阶段,了解每个阶段发生的事情可以帮助我们优化使用正则表达式的 JavaScript 代码。两个主要阶段是编译和执行。

编译发生在正则表达式首次创建时。执行发生在我们使用编译后的正则表达式来匹配字符串中的模式时。

在编译过程中,表达式被 JavaScript 引擎解析并转换为它的内部表示(无论是什么)。这个解析和转换阶段必须每次创建正则表达式时发生(不考虑浏览器执行的任何内部优化)。

通常,浏览器足够智能,能够确定何时使用相同的正则表达式,并缓存该特定表达式的编译结果。但我们不能依赖所有浏览器都这样做。对于复杂表达式,特别是,我们可以通过预先定义(因此预先编译)我们的正则表达式以供以后使用,开始获得一些明显的速度提升。

正如我们在上一节中的正则表达式概述中学到的,在 JavaScript 中创建编译后的正则表达式有两种方式:通过字面量和通过构造函数。让我们在下面的列表中看看一个例子。

列表 10.2. 创建编译后的正则表达式的两种方式

在这个例子中,两个正则表达式在创建后都处于编译状态。如果我们把对re1的每个引用都替换为字面量/test/i,那么同一个正则表达式可能会被一次又一次地编译,所以编译正则表达式一次并将其存储在变量中以供以后引用可能是一个重要的优化。

注意,每个正则表达式都有一个独特的对象表示:每次创建正则表达式(因此编译)时,都会创建一个新的正则表达式对象。这与其他原始类型(如字符串、数字等)不同,因为结果总是唯一的。

特别重要的是使用构造函数(`new RegExp(...)》)来创建正则表达式。这种技术允许我们从可以在运行时动态创建的字符串构建和编译表达式。这对于构建将被大量重用的复杂表达式非常有用。

例如,假设我们想要确定文档中哪些元素具有特定的类名,其值我们将在运行时才知道。因为元素可以与多个类名相关联(不便地存储在空格分隔的字符串中),这构成了一个有趣的运行时正则表达式编译的例子(见以下列表)。

列表 10.3. 编译运行时正则表达式以供以后使用

我们可以从列表 10.3 中了解几个有趣的事情。首先,我们设置了一些带有各种类名组合的测试主题<div><span>元素。然后我们定义了我们的类名检查函数,该函数接受我们将要检查的类名和要检查的元素类型作为参数。

然后,我们通过使用getElementsByTagName内置方法收集指定类型的所有元素,并设置我们的正则表达式:

const regex = new RegExp("(^|\\s)" + className + "(\\s|$)");

注意使用new RegExp()构造函数根据传递给函数的类名编译正则表达式。这是一个我们不能使用正则表达式文本的情况,因为我们将要搜索的类名事先是未知的。

我们只构建(因此,编译)这个表达式一次,以避免频繁且不必要的重新编译。因为表达式的内容是动态的(基于传入的className参数),我们可以通过这种方式处理表达式,从而实现重大的性能提升。

正则表达式本身匹配字符串的开始或一个空白字符,后跟目标类名,后跟一个空白字符或字符串的结尾。注意新正则表达式中的双重转义(\\)的使用:\\s。当创建包含反斜杠的文本正则表达式时,我们必须只提供一次反斜杠。但由于我们是在字符串中写入这些反斜杠,我们必须对它们进行双重转义。这确实是一个麻烦,但当我们构建字符串中的正则表达式而不是文本时,我们必须意识到这一点。

正则表达式编译后,通过test方法收集匹配元素变得非常简单:

regex.test(elems[i].className)

预先构建和预编译正则表达式,以便它们可以一次又一次地重复使用(执行),这是一种推荐的技术,它提供的性能提升不容忽视。几乎所有的复杂正则表达式情况都可以从使用这种技术中受益。

在本章前面,我们提到正则表达式中的括号不仅用于对术语进行分组以应用运算符,而且还会创建捕获。让我们了解更多关于这一点的内容。

10.4. 捕获匹配段

关于正则表达式的实用性,当我们捕获找到的结果以便我们可以对它们进行操作时,这种实用性达到了顶峰。确定一个字符串是否与模式匹配是一个明显的第一步,通常也是我们所需要的,但在许多情况下,确定匹配了什么也是有用的。

10.4.1. 执行简单捕获

假设我们想要提取嵌入在复杂字符串中的值。这样的字符串的一个很好的例子是 CSS 转换属性的值,通过它可以修改 HTML 元素的视觉位置。

列表 10.4. 捕获嵌入值的简单函数

图片

我们定义一个元素,指定将位置平移 15 px 的样式:

"transform:translateY(15px);"

不幸的是,浏览器没有提供易于获取元素平移量的 API。因此,我们创建了自己的函数:

function getTranslateY(elem){
    const transformValue = elem.style.transform;
    if(transformValue){
      const match = transformValue.match(/translateY\(([^\)]+)\)/);
      return match ? match[1] : "";
    }
     return "";
   }

转换解析代码一开始可能看起来有些复杂:

const match = transformValue.match(/translateY\(([^\)]+)\)/);
return match ? match[1] : "";

但当我们将其分解时,情况并不太糟糕。首先,我们需要确定是否存在一个transform属性,以便我们进行解析。如果不存在,我们将返回一个空字符串。如果transform属性存在,我们就可以开始提取不透明度值。正则表达式的match方法在找到匹配项时返回一个捕获值的数组,如果没有找到匹配项,则返回null

match返回的数组包括第一个索引中的整个匹配,然后是每个后续的捕获。因此,零索引将包含translateY(15px)的整个匹配字符串,下一个位置的条目将是15px

记住,捕获是由正则表达式中的括号定义的。因此,当我们匹配转换值时,该值位于数组的[1]位置,因为我们指定的唯一捕获是在正则表达式的translateY部分之后嵌入的括号创建的。

此示例使用本地正则表达式和match方法。当我们使用全局表达式时,情况会有所变化。让我们看看。

10.4.2. 使用全局表达式进行匹配

如前节所述,使用不带全局标志的本地正则表达式(String对象的match方法)返回一个包含整个匹配字符串的数组,以及任何在操作中匹配的捕获。

但当我们提供一个全局正则表达式(包含g标志的)时,match返回的结果就不同了。它仍然是一个结果数组,但在全局正则表达式的案例中,它匹配候选字符串中的所有可能性,而不仅仅是第一个匹配项,返回的数组包含全局匹配;在这种情况下,不返回每个匹配项内的捕获。

我们可以在以下代码和测试中看到这一行为。

列表 10.5. match方法中全局和本地搜索的差异

我们可以看到,当我们进行本地匹配时,html.match(/<(\/?)(\w+)([^>]*?)>/),匹配单个实例,并且该匹配项内的捕获也被返回。但当我们使用全局匹配时,html.match(/<(\/?)(\w+)([^>]*?)>/g),返回的是匹配列表。

如果捕获对我们很重要,我们可以在执行全局搜索的同时恢复此功能,通过使用正则表达式的exec方法。此方法可以反复调用正则表达式,每次调用时都返回下一个匹配的信息集。以下是一个典型的使用模式。

列表 10.6. 使用exec方法进行捕获和全局搜索

在这个例子中,我们反复调用exec方法:

while ((match = tag.exec(html)) !== null) {...}

这保留了前一次调用的状态,以便后续的每次调用都进展到下一个全局匹配。每次调用都返回下一个匹配项及其捕获。

通过使用matchexec,我们总能找到我们想要的精确匹配(和捕获)。但如果我们想在正则表达式中引用捕获本身,我们还需要进一步挖掘。

10.4.3. 引用捕获

我们可以通过两种方式引用已捕获的匹配部分:一种是在匹配本身内部,另一种是在替换字符串(如果适用)内部。例如,让我们回顾列表 10.6 中的匹配(其中我们匹配一个开标签或闭标签)并修改它,在以下列表中使其也匹配标签本身的内部内容。

列表 10.7. 使用后向引用匹配 HTML 标签的内容

图片描述

我们使用\1来引用表达式中的第一个捕获,在这个例子中是标签的名称。利用这些信息,我们可以匹配适当的闭合标签,引用捕获匹配的内容。(当然,这假设当前标签内没有嵌套的同名标签,所以这几乎不是一个详尽的标签匹配示例。)

此外,我们还可以在replace方法的调用中的替换字符串中获取捕获引用。与列表 10.7 中的后向引用代码不同,我们使用$1$2$3等语法,直到每个捕获编号。以下是一个示例:

assert("fontFamily".replace(/([A-Z])/g, "-$1").toLowerCase() ===
       "font-family", "Convert the camelCase into dashed notation.");

在此代码中,第一个捕获的值(在这个例子中是首字母大写的F)在替换字符串(通过$1)中被引用。这允许我们在匹配时间之前甚至不知道其值的情况下指定替换字符串。这是一件强大的类似忍者般的武器。

能够引用正则表达式捕获的能力使得许多原本难以编写的代码变得非常简单。它提供的表达性最终允许我们编写一些简洁的语句,否则这些语句可能会相当晦涩、复杂且冗长。

由于捕获和表达式分组都是通过括号指定的,正则表达式处理器无法知道我们在正则表达式中添加的哪些括号是为了分组,哪些是为了表示捕获。它将所有括号集都视为组和捕获,这可能导致捕获比我们真正想要的信息更多,因为我们需要在正则表达式中指定一些分组。在这种情况下我们该怎么办?

10.4.4. 非捕获组

正如我们之前提到的,括号具有双重作用:它们不仅用于对操作项进行分组,还用于指定捕获。这通常不会成为问题,但在进行大量分组操作的正则表达式中,可能会导致不必要的捕获,这可能会使得对结果捕获的排序变得繁琐。

考虑以下正则表达式:

const pattern = /((ninja-)+)sword/;

这里,我们的意图是创建一个正则表达式,允许前缀 ninja-sword 单词之前出现一次或多次,并且我们想要捕获整个前缀。这个正则表达式需要两组括号:

  • 定义捕获的括号(字符串 sword 之前的所有内容)

  • ninja- 文本分组以供 + 操作符使用的括号

这一切都没有问题,但它由于内部分组括号的存在,导致捕获的结果多于一个预期的结果。

为了表示一组括号不应该导致捕获,正则表达式语法允许我们在开括号后立即放置 ?: 符号。这被称为被动子表达式

将这个正则表达式改为

const pattern = /((?:ninja-)+)sword/;

只导致外部的括号集创建捕获。内部的括号已被转换为被动子表达式。

为了测试这一点,请看以下代码。

列表 10.8. 无捕获的分组

图片

运行这些测试,我们可以看到被动子表达式 /((?:ninja-)+)sword/ 阻止了不必要的捕获。

在我们的正则表达式中,尽可能使用非捕获(被动)组来代替捕获,当捕获不是必需的时候,这样表达式引擎在记住和返回捕获时会有更少的工作要做。如果我们不需要捕获的结果,就没有必要去请求它们!我们付出的代价是,已经复杂的正则表达式可能会变得更加晦涩难懂。

现在,让我们将注意力转向正则表达式赋予我们忍者力量的另一种方式:使用String对象的replace方法与函数一起使用。

10.5. 使用函数进行替换

String对象的replace方法是一个强大且多功能的函数,我们在讨论捕获时简要地使用了它。当正则表达式作为replace的第一个参数提供时,它将对模式进行替换(如果正则表达式是全局的,则替换所有匹配项)而不是对固定字符串进行替换。

例如,假设我们想要将字符串中的所有大写字母替换为 X。我们可以写出以下代码:

"ABCDEfg".replace(/[A-Z]/g,"X")

这将得到 XXXXXfg 的值。不错。

replace方法最强大的特性可能是提供函数作为替换值而不是固定字符串的能力。

当替换值(第二个参数)是一个函数时,它会对每个找到的匹配项调用(记住全局搜索会匹配源字符串中模式的全部实例)并带有可变参数列表:

  • 比赛全文

  • 比赛捕捉,每个参数一个

  • 匹配项在原始字符串中的索引

  • 源字符串

函数返回的值作为替换值。

这为我们提供了巨大的灵活性来确定运行时替换字符串应该是什么,我们手头有大量关于匹配性质的信息。例如,在下面的列表中,我们使用该函数提供动态替换值,将用破折号分隔单词的字符串转换为它的驼峰式等效形式。

列表 10.9. 将破折号字符串转换为驼峰式

在这里,我们提供了一个匹配任何由破折号字符之前字符的正则表达式。全局正则表达式中的捕获识别了被匹配的字符(不包括破折号)。每次函数被调用时(在这个例子中是两次),它都会传递完整的匹配字符串作为第一个参数,以及捕获(对于这个正则表达式只有一个)作为第二个参数。我们对于其余的参数不感兴趣,因此我们没有指定它们。

第一次函数被调用时,传递 -bb;,第二次调用时传递 -ww。在每种情况下,捕获的字母都会被转换为大写并作为替换字符串返回。我们最终将 -b 替换为 B,将 -w 替换为 W

由于全局正则表达式会导致替换函数在源字符串中的每个匹配项上执行,因此这种技术甚至可以扩展到执行机械的替换之外。我们可以将这种技术用作字符串遍历的手段,而不是像在本章前面看到的 exec()-in-a-while-loop 技术那样。

例如,假设我们正在寻找将查询字符串转换为适合我们目的的替代格式的操作。我们会将一个查询字符串,例如

foo=1&foo=2&blah=a&blah=b&foo=3

转换为如下所示

foo=1,2,3&blah=a,b"

使用正则表达式和 replace 的解决方案可能会导致一些特别简洁的代码,如下一列表所示。

列表 10.10. 压缩查询字符串的技术

这个例子最有趣的地方在于它将字符串 replace 方法用作遍历字符串以获取值的一种手段,而不是用作搜索和替换机制。这个技巧有两个方面:将一个函数作为替换值参数传递,并且不返回值,而是用它作为搜索的手段。

示例代码首先声明一个哈希 key,在其中我们存储在源查询字符串中找到的键和值。然后我们在源字符串上调用 replace 方法,传递一个将匹配键值对并捕获键和值的正则表达式。我们还传递一个函数,该函数将传递完整的匹配、键捕获和值捕获。这些捕获的值将被存储在哈希中以供以后参考。请注意,我们返回空字符串,因为我们不关心源字符串中发生了什么替换——我们只是使用副作用而不是结果。

replace返回后,我们声明一个数组,我们将在这里聚合结果并遍历我们找到的键,将每个添加到数组中。最后,我们使用&作为分隔符将存储在数组中的每个结果连接起来,并返回结果:

const result = [];
for (let key in keys) {
  result.push(key + "=" + keys[key]);
}
return result.join("&");

使用这种技术,我们可以利用String对象的replace方法作为我们自己的字符串搜索机制。结果不仅快速,而且简单有效。这种技术提供的强大功能,尤其是在代码量很少的情况下,不应被低估。

所有这些正则表达式技术都可能对我们编写页面上的脚本产生巨大影响。让我们看看如何应用你所学的知识来解决我们可能遇到的一些常见问题。

10.6. 使用正则表达式解决常见问题

在 JavaScript 中,一些惯用表达式可能会反复出现,但它们的解决方案并不总是显而易见。了解正则表达式无疑可以提供帮助,在本节中,我们将探讨一些我们可以用正则表达式解决的一些常见问题。

10.6.1. 匹配换行符

在进行搜索时,有时希望点号(.)术语,它匹配除换行符之外的任何字符,也能包括换行符。其他语言的正则表达式实现通常包括一个标志来实现这一点,但 JavaScript 的实现没有。

让我们看看在 JavaScript 中如何解决这种遗漏的几种方法,如下一列表所示。

列表 10.11. 匹配所有字符,包括换行符

图片

此示例定义了一个测试主题字符串:"<b>Hello</b>\n<i>world!</i>",其中包含换行符。然后我们尝试各种方法来匹配字符串中的所有字符。

在第一个测试中,/.*/.exec(html)[0] === "<b>Hello</b>",我们验证了换行符不会被.运算符匹配。忍者不会拒绝,所以在下一个测试中,我们使用一个替代正则表达式/[\S\s]*/,在其中我们定义一个字符类,它匹配任何不是空白字符的字符,以及任何是空白字符的字符。这个并集是所有字符的集合。

下一个测试采用了另一种方法:

/[\S\s]*/.exec(html)[0] === "<b>Hello</b>\n<i>world!</i>"

在这里,我们使用一个交替正则表达式/(?:.|\s)*/,在其中我们匹配由.匹配的所有内容,即除了换行符之外的所有内容,以及被认为是空白字符的所有内容,包括换行符。结果并集是包括换行符在内的所有字符的集合。注意使用被动子表达式以防止任何意外的捕获。由于其简单性(以及隐含的速度优势),/[\S\s]*/提供的解决方案通常被认为是最佳的。

接下来,让我们进一步拓宽视野,将其扩展到全球范围。

10.6.2. 匹配 Unicode

在使用正则表达式的过程中,我们经常想要匹配字母数字字符,例如在 CSS 选择器引擎实现中的一个 ID 选择器。但假设字母字符仅来自英语 ASCII 字符集是短视的。

将集合扩展到包括 Unicode 字符有时是可取的,明确支持传统字母数字字符集未涵盖的多种语言(见以下列表)。

列表 10.12. 匹配 Unicode 字符

图片

此列表通过创建一个包含 \w 项的字符类来匹配整个匹配范围内的 Unicode 字符,以匹配所有“正常”的单词字符,以及一个跨越 U+0080 以上整个 Unicode 字符集的范围。从 128 开始,我们得到了一些高 ASCII 字符以及基本多语言平面中的所有 Unicode 字符。

聪明的你们可能会注意到,通过添加上述 \u0080 的整个 Unicode 字符范围,我们不仅匹配了字母字符,还匹配了所有 Unicode 标点符号和其他特殊字符(例如箭头)。但这没关系,因为本例的目的是展示如何一般性地匹配 Unicode 字符。如果您想要匹配特定范围的字符,您可以使用本例的教训将您想要的任何范围添加到字符类中。

在我们结束对正则表达式的考察之前,让我们解决一个更常见的问题。

10.6.3. 匹配转义字符

页面作者在为页面元素分配 id 值时通常使用符合程序标识符的名称,但这只是一个约定;id 值可以包含除“单词”字符以外的字符,包括标点符号。例如,一个网络开发者可能会为元素使用 idform:update

当一个库开发者为 CSS 选择器引擎编写实现时,他希望支持转义字符。这允许用户指定不符合典型命名约定的复杂名称。因此,让我们开发一个正则表达式,以便匹配转义字符。考虑以下代码。

列表 10.13. 匹配 CSS 选择器中的转义字符

图片

这个特定的表达式通过允许匹配单词字符序列或一个反斜杠后跟任何字符的序列来工作。

注意,要完全支持所有转义字符需要做更多的工作。更多详情,请访问 mathiasbynens.be/notes/css-escapes

10.7. 摘要

  • 正则表达式是现代 JavaScript 开发中无处不在的强大工具;几乎任何类型的匹配的各个方面都依赖于它们的使用。通过对本章中涵盖的高级正则表达式概念的深入了解,你应该能够轻松应对任何可能从正则表达式中受益的具有挑战性的代码片段。

  • 我们可以使用正则表达式字面量(/test/)和 RegExp 构造函数(new RegExp("test"))来创建正则表达式。当正则表达式在开发时已知时,首选字面量,当正则表达式在运行时构建时,首选构造函数。

  • 每个正则表达式都可以关联五个标志:i 使正则表达式变为不区分大小写,g 匹配模式的全部实例,m 允许跨多行匹配,y 启用粘性匹配,而 u 启用 Unicode 转义。标志添加在正则表达式字面量的末尾:/test/ig,或者作为 RegExp 构造函数的第二个参数:new RegExp("test", "i")

  • 使用 [](如 [abc])来指定我们希望匹配的字符集。

  • 使用 ^ 来表示模式必须出现在字符串的开头,而使用 $ 来表示模式必须出现在字符串的末尾。

  • 使用 ? 来指定一个术语是可选的,+ 来指定一个术语应该出现一次或多次,而 * 来指定一个术语出现零次、一次或多次。

  • 使用 . 来匹配任何字符。

  • 我们可以使用反斜杠(\)来转义特殊正则表达式字符(例如:. [ $ ^)。

  • 使用括号 () 将多个术语组合在一起,并使用管道符(|)来指定交替。

  • 成功匹配术语的字符串部分可以使用反斜杠后跟捕获编号(\1\2 等)进行反向引用。

  • 每个字符串都可以访问 match 函数,该函数接受一个正则表达式并返回一个包含整个匹配字符串以及任何匹配捕获的数组。我们还可以使用 replace 函数,该函数在模式匹配上执行替换,而不是在固定字符串上。

10.8. 练习

1

在 JavaScript 中,可以使用以下哪种方式创建正则表达式?

  1. 正则表达式字面量
  2. 内置的 RegExp 构造函数
  3. 内置的 RegularExpression 构造函数

2

以下哪个是正则表达式字面量?

  1. /test/
  2. \test\
  3. new RegExp("test");

3

选择正确的正则表达式标志:

  1. /test/g
  2. g/test/
  3. new RegExp("test", "gi");

4

正则表达式 /def/ 匹配以下哪个字符串?

  1. 字符串 def 之一
  2. def
  3. de

5

正则表达式 /[^abc]/ 匹配以下哪个?

  1. 字符串 abc 之一
  2. 字符串 def 之一
  3. 匹配字符串 ab

6

以下哪个正则表达式匹配字符串 hello

  1. /hello/
  2. /hell?o/
  3. /hel*o/
  4. /[hello]/

7

正则表达式 /(cd)+(de)*/ 匹配以下哪个字符串?

  1. cd
  2. de
  3. cdde
  4. cdcd
  5. ce
  6. cdcddedede

8

在正则表达式中,我们可以用以下哪种方式表示选择?

  1. #
  2. &
  3. |

9

在正则表达式 /([0-9])2/ 中,我们可以用以下哪种方式引用第一个匹配的数字?

  1. /0
  2. /1
  3. \0
  4. \1

10

正则表达式 /([0-5])6\1/ 将匹配以下哪个?

  1. 060
  2. 16
  3. 261
  4. 565

11

正则表达式/(?:ninja)-(trick)?-\1/将匹配以下哪个?

  1. ninja-
  2. ninja-trick-ninja
  3. ninja-trick-trick

12

执行"012675".replace(/0-5/g, "a")的结果是什么?

  1. aaa67a
  2. a12675
  3. a1267a

第十一章. 代码模块化技术

本章涵盖

  • 使用模块模式

  • 使用当前编写模块化代码的标准:AMD 和 CommonJS

  • 与 ES6 模块一起工作

到目前为止,我们已经探讨了 JavaScript 的基本原语,如函数、对象、集合和正则表达式。我们拥有解决 JavaScript 代码中特定问题的工具。但随着我们的应用程序开始增长,与如何构建和管理我们的代码相关的一系列问题开始出现。一次又一次地证明,大型、单体代码库比小型、组织良好的代码库更难理解和维护。因此,改善我们程序的结构和组织的一种方法自然是将它们分解成更小、相对松散耦合的段,称为模块

模块是比对象和函数更大的代码组织单位;它们允许我们将程序划分为属于一起的集群。在创建模块时,我们应该努力形成一致的抽象并封装实现细节。这使得推理我们的应用程序变得更加容易,因为我们使用模块功能时不会被各种琐碎的细节所困扰。此外,拥有模块意味着我们可以轻松地在应用程序的不同部分以及不同的应用程序中重用模块功能,从而显著加快我们的开发过程。

如您在本书前面所见,JavaScript 非常重视全局变量:只要我们在主代码中定义一个变量,该变量就会自动被视为全局变量,并且可以从代码的任何其他部分访问。对于小型程序来说,这可能不是问题,但随着我们的应用程序开始增长,并且我们包含了第三方代码,命名冲突发生的可能性开始显著增加。在大多数其他编程语言中,这个问题通过命名空间(C++和 C#)或包(Java)来解决,这些命名空间或包将所有封装的名称包裹在另一个名称中,从而显著减少了潜在的冲突。

到 ES6 为止,JavaScript 没有提供更高层次的内置功能,允许我们在模块、命名空间或包中分组相关的变量。因此,为了解决这个问题,JavaScript 程序员已经开发了一些高级代码模块化技术,这些技术利用了现有的 JavaScript 结构,如对象、立即函数和闭包。在本章中,我们将探讨这些技术中的一些。

幸运的是,我们很快就能完全放弃这些权宜之计,因为 ES6 最终引入了原生模块。不幸的是,浏览器还没有跟上,所以我们将探讨在 ES6 中模块应该如何工作,尽管我们不会有一个特定的原生浏览器实现来测试它们。

让我们从我们可以使用的模块化技术开始。

你知道吗?

Q1:

你使用什么现有机制在预 ES6 的 JavaScript 中近似模块?

Q2:

AMD 和 CommonJS 模块规范有什么区别?

Q3:

使用 ES6,你需要使用哪两个语句从名为 test 的模块中在另一个名为 guineaPig 的模块内调用 tryThisOut() 函数?

11.1. 在预 ES6 的 JavaScript 中模块化代码

预 ES6 的 JavaScript 只有两种作用域:全局作用域和函数作用域。它没有介于两者之间的事物,如命名空间或模块,这会允许我们将某些功能组合在一起。为了编写模块化代码,JavaScript 开发者被迫利用现有的 JavaScript 语言特性进行创新。

在决定使用哪些特性时,我们必须记住,在最基本的情况下,每个模块系统应该能够做到以下事情:

  • 定义一个接口,通过该接口我们可以访问模块提供的功能。

  • 隐藏模块内部细节,这样我们的模块用户就不会被一大堆不重要的实现细节所负担。此外,通过隐藏模块内部细节,我们保护这些内部细节免受外部世界的影响,从而防止可能导致各种副作用和错误的未希望修改。

在本节中,我们将首先看到如何通过使用我们在本书中已经探讨过的标准 JavaScript 特性来创建模块,例如对象、闭包和立即函数。我们将继续探索异步模块定义(AMD)和 CommonJS,这两种最流行的模块规范标准,它们建立在略微不同的基础上。您将学习如何使用这些标准来定义模块,以及它们的优缺点。

但让我们从在前几章中已经设定了基础的内容开始。

11.1.1. 使用对象、闭包和立即函数来指定模块

让我们回到我们最小的模块系统要求,隐藏实现细节定义模块接口。现在考虑一下我们可以利用哪些 JavaScript 语言特性来实现这些最小要求:

  • 隐藏模块内部—— 如我们所知,调用 JavaScript 函数会在其中创建一个新的作用域,我们可以在其中定义仅在当前函数中可见的变量。因此,隐藏模块内部的一个选项是使用函数作为模块。这样,所有函数变量都成为内部模块变量,对外部世界隐藏。

  • 定义模块接口— 通过函数变量实现模块内部意味着这些变量只能从模块内部访问。但如果我们希望模块能被其他代码使用,我们必须能够定义一个干净的接口,通过这个接口我们可以暴露模块提供的功能。实现这一目标的一种方法是通过利用对象和闭包。想法是,从我们的函数模块中,我们返回一个代表我们模块公共接口的对象。这个对象应该包含模块提供的方法,这些方法将通过闭包保持我们的内部模块变量活跃,即使我们的模块函数执行完毕后也是如此。

现在我们已经给出了如何在 JavaScript 中实现模块的高级描述,让我们一步一步地慢慢来,从使用函数来隐藏模块内部开始。

函数作为模块

调用一个函数会创建一个新的作用域,我们可以在这个作用域中定义变量,这些变量在当前函数外部是不可见的。让我们看一下以下代码片段,它计算网页上的点击次数:

在这个例子中,我们创建了一个名为 countClicks 的函数,它创建了一个变量 numClicks 并在整个文档上注册了一个点击事件处理程序。每当点击发生时,numClicks 变量会增加,并通过一个警告框将结果显示给用户。这里有两个重要的事情需要注意:

  • numClicks 变量,是 countClicks 函数内部的,通过点击处理函数的闭包保持活跃。这个变量只能在处理程序中引用,其他任何地方都不能引用!我们已经将 numClicks 变量从 countClicks 函数外部的代码中屏蔽。同时,我们也没有将一个可能对我们代码的其他部分不是那么重要的变量污染到我们的程序的全局命名空间中。

  • 我们的 countClicks 函数只在这个地方被调用,因此我们不是定义一个函数然后在单独的语句中调用它,而是使用立即执行函数,或者称为 IIFE(在第三章中介绍),来定义并立即调用 countClicks 函数。

我们还可以查看当前应用程序的状态,特别是我们的内部函数(或模块)变量如何通过闭包保持活跃,如图 11.1 所示。

图 11.1. 点击事件处理程序,通过闭包,使局部 numClicks 变量保持活跃。

现在我们已经了解了如何隐藏内部模块细节,以及闭包如何使这些内部细节在必要时保持活跃,让我们继续到模块的第二个最小要求:定义模块接口。

模块模式:将函数作为模块,以对象作为接口增强

模块接口通常由一组变量和函数组成,我们的模块将这些变量和函数提供给外部世界。创建此类接口的最简单方法就是使用谦逊的 JavaScript 对象。

例如,让我们为我们的模块创建一个接口,该接口用于计算网页上的点击次数,如下面的列表所示。

列表 11.1. 模块模式

![286fig01_alt.jpg]

在这里,我们使用立即函数来实现一个模块。在立即函数内部,我们定义我们的内部模块实现细节:一个局部变量numClicks和一个局部函数handleClick,这些变量和函数只能在模块内部访问。接下来,我们创建并立即返回一个对象,该对象将作为模块的“公共接口”。这个接口包含一个countClicks方法,我们可以从模块外部使用这个方法来访问模块功能。

同时,因为我们已经公开了模块接口,我们的内部模块细节通过接口创建的闭包保持活跃。例如,在这种情况下,接口的countClicks方法保持了内部模块变量numClickshandleClick的活跃状态,如图 11.2 所示。

图 11.2. 通过返回的对象公开模块的公共接口。内部模块实现(“私有”变量和函数)通过公共接口方法创建的闭包保持活跃。

![11fig02_alt.jpg]

最后,我们将立即函数返回的表示模块接口的对象存储在一个名为MouseCounterModule的变量中,通过这个变量我们可以轻松地使用模块功能,如下面的代码所示:

MouseCounterModule.countClicks()

这基本上就是全部内容。

通过利用立即函数,我们可以隐藏某些模块实现细节。然后通过添加对象和闭包,我们可以指定一个模块接口,将我们的模块提供的功能暴露给外部世界。

使用立即函数、对象和闭包在 JavaScript 中创建模块的模式称为模块模式。它由 Douglas Crockford 推广,是第一种广泛流行的 JavaScript 代码模块化方式。

一旦我们有了定义模块的能力,总是很乐意能够将它们拆分到多个文件中(以便更容易管理),或者能够在不修改其源代码的情况下,在现有模块上定义额外的功能。

让我们看看这是如何实现的。

增强模块

让我们增强前一个例子中的MouseCounterModule,添加一个额外的功能,即计算鼠标滚动的次数,而不修改原始的MouseCounterModule代码。请参见以下列表。

列表 11.2. 增强模块

![ch11ex02-0.jpg]

图 11.2

当增强一个模块时,我们通常遵循一个类似于创建新模块的程序。我们立即调用一个函数,但这次,我们将要扩展的模块作为参数传递给它:

(function(module){
  ...
  return module;
})(MouseCounterModule);

在函数内部,我们继续进行我们的工作并创建所有必要的私有变量和函数。在这种情况下,我们定义了一个用于计数和报告滚动次数的私有变量和私有函数:

let numScrolls = 0;
const handleScroll = () => {
  alert(++numScrolls);
}

最后,我们通过立即函数的 module 参数扩展我们的模块,就像我们扩展任何其他对象一样:

module.countScrolls = ()=> {
  document.addEventListener("wheel", handleScroll);
};

在执行这个简单的操作之后,我们的 MouseCounterModule 也可以 countScrolls

我们公开的模块接口现在有两个方法,我们可以以下方式使用模块:

正如我们已经提到的,我们通过立即调用的函数扩展模块,这种方式类似于创建一个新的模块。这在闭包方面有一些有趣的副作用,所以让我们在增强模块后更仔细地看看应用状态,如图 11.3 所示。

图 11.3。当我们增强一个模块时,我们通过将模块传递给另一个立即函数来扩展其外部接口以添加新功能。在这个例子中,我们向我们的 MouseCounterModule 添加了 countScrolls 的能力。请注意,两个不同的函数在不同的环境中定义,它们无法访问彼此的内部变量。

如果你仔细观察,图 11.3 还显示了模块模式的一个缺点:无法在模块扩展之间共享私有模块变量。例如,countClicks 函数围绕 numClickshandleClick 变量保持闭包,我们可以通过 countClicks 方法访问这些私有模块内部。

不幸的是,我们的扩展,即 countScrolls 函数,是在一个完全独立的范围内创建的,具有一组全新的私有变量:numScrollshandleScrollcountScrolls 函数只围绕 numScrollshandleScroll 变量创建闭包,因此无法访问 numClickshandleClick 变量。

注意

当通过单独的立即函数执行模块扩展时,模块扩展无法共享私有模块内部,因为每次函数调用都会创建一个新的作用域。尽管这是一个缺点,但它并不是一个致命缺陷,我们仍然可以使用模块模式来保持我们的 JavaScript 应用程序模块化。

注意,在模块模式中,模块就像任何其他对象一样是对象,我们可以以任何我们认为合适的方式扩展它们。例如,我们可以通过向模块对象添加新属性来添加新功能:

MouseCounterModule.newMethod = ()=> {...}

我们也可以使用相同的原理轻松创建子模块:

MouseCounterModule.newSubmodule = () => {
  return {...};
}();

注意,所有这些方法都受到模块模式相同的基本缺陷的影响:模块的后续扩展无法访问先前定义的内部模块细节。

不幸的是,模块模式还存在更多问题。当我们开始构建模块化应用程序时,模块本身通常会依赖于其他模块来实现其功能。不幸的是,模块模式并没有涵盖这些依赖的管理。作为开发者,我们必须注意正确的依赖顺序,以确保我们的模块代码拥有执行所需的一切。尽管这在小型和中型应用程序中不是问题,但它可能会在大量使用相互依赖模块的大型应用程序中引入严重问题。

为了处理这些问题,出现了一些相互竞争的标准,即异步模块定义(AMD)和 CommonJS。

11.1.2. 使用 AMD 和 CommonJS 模块化 JavaScript 应用程序

AMD 和 CommonJS 是相互竞争的模块规范标准,允许我们指定 JavaScript 模块。除了语法和哲学上的某些差异外,主要区别在于 AMD 是专门针对浏览器设计的,而 CommonJS 是为通用 JavaScript 环境设计的(例如服务器,使用 Node.js),而不受浏览器限制。本节提供了一个相对简短的模块规范概述;设置它们并将它们包含在项目中超出了本书的范围。有关更多信息,我们推荐 Nicolas G. Bevacqua 的《JavaScript 应用程序设计》(Manning,2015 年)。

AMD

AMD 诞生于 Dojo 工具包(dojotoolkit.org/),这是构建客户端 Web 应用程序中流行的 JavaScript 工具包之一。AMD 允许我们轻松指定模块及其依赖项。同时,它是从零开始为浏览器构建的。目前,最受欢迎的 AMD 实现是 RequireJS(requirejs.org/)。

让我们看看一个定义小型模块的例子,该模块依赖于 jQuery。

列表 11.3. 使用 AMD 指定依赖于 jQuery 的模块

AMD 提供了一个名为 define 的函数,它接受以下内容:

  • 新创建模块的 ID。这个 ID 可以在以后从我们系统的其他部分调用该模块时使用。

  • 我们当前模块所依赖的模块 ID 列表(所需的模块)。

  • 一个初始化模块的工厂函数,它接受所需的模块作为参数。

在这个例子中,我们使用 AMD 的define函数创建一个 ID 为MouseCounterModule的模块,该模块依赖于 jQuery。由于这个依赖关系,AMD 首先请求 jQuery 模块,如果文件需要从服务器请求,这可能需要一些时间。这个操作是异步执行的,以避免阻塞。在所有依赖项都下载并评估完毕后,模块工厂函数被调用,每个请求的模块都有一个参数。在这种情况下,将有一个参数,因为我们的新模块只需要 jQuery。在工厂函数内部,我们创建我们的模块,就像使用标准模块模式一样:通过返回一个对象来公开模块的公共接口。

正如你所见,AMD 提供了几个有趣的好处,例如这些:

  • 自动解析依赖关系,这样我们就不必考虑我们包含模块的顺序。

  • 模块可以异步加载,从而避免阻塞。

  • 一个文件中可以定义多个模块。

现在你已经了解了 AMD 的基本工作原理,让我们来看看另一个广泛流行的模块定义标准。

CommonJS

与 AMD 专为浏览器构建不同,CommonJS 是一个为通用 JavaScript 环境设计的模块规范。目前它在 Node.js 社区中拥有最多的追随者。

CommonJS 使用基于文件的模块,因此我们可以为每个文件指定一个模块。对于每个模块,CommonJS 公开一个变量module,它有一个属性exports,我们可以通过添加额外的属性轻松扩展它。最终,module.exports的内容被公开为模块的公共接口。

如果我们想在应用程序的其他部分使用一个模块,我们可以使用require来引用它。文件将以同步方式加载,我们将能够访问其公共接口。这也是为什么 CommonJS 在服务器上比客户端更受欢迎的原因,在服务器上模块检索相对较快,因为它只需要文件系统读取,而在客户端,模块需要从远程服务器下载,并且同步加载通常意味着阻塞。

让我们来看一个例子,这个例子定义了我们的重复使用的MouseCounterModule,这次是在 CommonJS 中。

列表 11.4. 使用 CommonJS 定义一个模块

要在另一个文件中包含我们的模块,我们可以这样写:

const MouseCounterModule = require("MouseCounterModule.js");
MouseCounterModule.countClicks();

看看这是多么简单?

由于 CommonJS 的哲学是每个文件一个模块,因此我们放入文件模块中的任何代码都将成为该模块的一部分。因此,没有必要将变量包裹在立即执行函数中。模块内部定义的所有变量都安全地包含在当前模块的作用域内,不会泄漏到全局作用域。例如,我们所有的模块变量($numClickshandleClick)都是模块作用域的,即使它们是在顶层代码中定义的(在所有函数和块之外),这在技术上会使它们成为标准 JavaScript 文件中的全局变量。

再次强调,需要注意的是,只有通过 module.exports 对象暴露的变量和函数才能从模块外部访问。这个过程与模块模式类似,只是我们不是返回一个全新的对象,而是环境已经提供了一个,我们可以通过我们的接口方法和属性来扩展它。

CommonJS 有几个优点:

  • 它具有简单的语法。我们只需要指定 module.exports 属性,而模块代码的其余部分基本上与如果我们编写标准 JavaScript 一样。引入模块也很简单;我们只需使用 require 函数。

  • CommonJS 是 Node.js 的默认模块格式,因此我们可以通过 npm(node 的包管理器)访问成千上万的可用包。

CommonJS 的最大缺点是它没有明确地考虑到浏览器。在浏览器中的 JavaScript,没有对 module 变量和 export 属性的支持;我们必须将我们的 CommonJS 模块打包成浏览器可读的格式。我们可以通过 Browserify (browserify.org/) 或 RequireJS (requirejs.org/docs/commonjs.html) 来实现这一点。

由于存在两个用于指定模块的竞争标准,AMD 和 CommonJS,这导致了人们倾向于分成两个,有时甚至是对立的阵营。如果你在相对封闭的项目上工作,这可能不是问题;你可以选择更适合你的标准。然而,当我们需要从对立阵营重用代码并被迫跳过各种障碍时,可能会出现问题。一种解决方案是使用通用模块定义(UMD),github.com/umdjs/umd,这是一种语法有些复杂的模式,允许同一文件同时被 AMD 和 CommonJS 使用。这超出了本书的范围,但如果你对它感兴趣,网上有许多优质资源。

幸运的是,ECMAScript 委员会已经认识到在所有 JavaScript 环境中支持统一模块语法的必要性,因此 ES6 定义了一个新的模块标准,这应该最终结束这些差异。

11.2. ES6 模块

ES6 模块旨在结合 CommonJS 和 AMD 的优点:

  • 与 CommonJS 类似,ES6 模块具有相对简单的语法,ES6 模块是基于文件的(每个文件一个模块)。

  • 与 AMD 类似,ES6 模块提供了对异步模块加载的支持。

注意

内置模块是 ES6 标准的一部分。正如你很快就会看到的,ES6 模块语法包括一些额外的语义和关键字(如exportimport关键字),这些关键字当前浏览器不支持。如果我们想今天使用模块,我们必须使用 Traceur (github.com/google/traceur-compiler)、Babel (babeljs.io/)或 TypeScript (www.typescriptlang.org/)将我们的模块代码进行转译。我们还可以使用 SystemJS 库 (github.com/systemjs/systemjs),它提供了对所有当前可用的模块标准的支持:AMD、CommonJS,甚至是 ES6 模块。你可以在项目的存储库中找到如何使用 SystemJS 的说明 (github.com/systemjs/systemjs)。

ES6 模块背后的主要思想是,只有从模块中显式导出的标识符才能从模块外部访问。所有其他标识符,即使在顶级作用域(在标准 JavaScript 中将是全局作用域)中定义的,也只可以在模块内部访问。这受到了 CommonJS 的启发。

为了提供这种功能,ES6 引入了两个新的关键字:

  • export——用于使某些标识符从模块外部可用

  • import——用于导入导出的模块标识符

导出和导入模块功能的语法很简单,但它有很多细微的差别,我们将逐步探索。

11.2.1. 导出和导入功能

让我们从简单的例子开始,展示如何从一个模块导出功能并将其导入到另一个模块中。

列表 11.5. 从 Ninja.js 模块导出

我们首先定义一个变量,ninja,这是一个模块变量,它只在本模块内部可访问,即使它放在顶级代码中(在 ES6 之前的代码中这将使其成为全局变量)。

接下来,我们定义另一个顶级变量,message,通过使用新的export关键字,使其可以从模块外部访问。最后,我们还创建并导出了sayHiToNinja函数。

就这样!这是我们定义自己的模块所需了解的最基本的语法。我们不需要使用立即执行函数或记住任何晦涩的语法,以便从模块中导出功能。我们编写代码的方式就像编写标准的 JavaScript 代码一样,唯一的区别是我们使用export关键字作为一些标识符(如变量、函数或类)的前缀。

在学习如何导入这种导出功能之前,我们将看看另一种导出标识符的方法:我们在模块的末尾列出我们想要导出的所有内容,如下面的列表所示。

列表 11.6. 在模块末尾导出

这种导出模块标识符的方式与模块模式有些相似,因为立即函数返回一个表示我们模块公共接口的对象,特别是与 CommonJS 相似,因为我们通过公共模块接口扩展了 module.exports 对象。

无论我们如何导出某个模块的标识符,如果我们需要将它们导入到另一个模块中,我们必须使用 import 关键字,如下面的示例所示。

列表 11.7. 从 Ninja.js 模块导入

我们使用新的 import 关键字从 ninja 模块中导入一个变量 message 和一个函数 sayHiToNinja

import { message, sayHiToNinja} from "Ninja.js";

通过这样做,我们获得了访问在 ninja 模块中定义的这两个标识符的权限。最后,我们可以测试我们是否可以访问 message 变量和调用 sayHiToNinja 函数:

assert(message === "Hello",
      "We can access the imported variable");
assert(sayHiToNinja() === "Hello Yoshi",
      "We can say hi to Yoshi from outside the module");

我们无法访问未导出和未导入的变量。例如,我们无法访问 ninja 变量,因为它没有标记为 export

assert(typeof ninja === "undefined",
      "But we cannot access Yoshi directly");

使用模块,我们最终可以更安全地避免全局变量的误用。我们没有明确标记为导出的所有内容都很好地隔离在模块内部。

在这个例子中,我们使用了 命名导出,这使得我们可以从一个模块中导出多个标识符(就像我们使用 messagesayHiToNinja 一样)。由于我们可以导出大量标识符,将它们全部列在导入语句中可能会很繁琐。因此,一种简写符号使我们能够将模块中所有导出的标识符导入进来,如下面的列表所示。

列表 11.8. 从 Ninja.js 模块导入所有命名导出

如 列表 11.8 所示,要从一个模块中导入所有导出的标识符,我们使用 import * 符号,并结合一个我们将用来引用整个模块的标识符(在这种情况下,是 ninjaModule 标识符)。完成此操作后,我们可以通过属性符号访问导出的标识符;例如,ninjaModule.messageninjaModule.sayHiToNinja。请注意,我们仍然无法访问未导出的顶层变量,例如 ninja 变量。

默认导出

通常我们不想从一个模块中导出一组相关的标识符,而是想通过单个导出来表示整个模块。这种情况相当常见的一个例子是,当我们的模块包含一个单独的类时,如下面的列表所示。

列表 11.9. 从 Ninja.js 模块导出的默认值

在这里,我们在 export 关键字之后添加了 default 关键字,这指定了此模块的默认绑定。在这种情况下,此模块的默认绑定是名为 Ninja 的类。尽管我们指定了默认绑定,我们仍然可以使用命名导出导出额外的标识符,就像我们使用 compare-Ninjas 函数那样。

现在,我们可以使用简化的语法从 Ninja.js 导入功能,如下所示。

列表 11.10. 导入默认导出

我们从这个例子开始导入默认导出。为此,我们使用一个更简洁的导入语法,通过省略导入命名导出时必须的括号。注意,我们可以选择任意名称来引用默认导出;我们不必使用我们在导出时使用的那个名称。在这个例子中,ImportedNinja 指的是在文件 Ninja.js 中定义的 Ninja 类。

我们继续这个例子,通过导入一个命名导出,就像之前的例子一样,只是为了说明我们可以在单个模块中既有默认导出也有多个命名导出。最后,我们实例化了一些 ninja 对象并调用了 compareNinjas 函数,以确认所有导入都按预期工作。

在这种情况下,两个导入都来自同一个文件。ES6 提供了一种简写语法:

import ImportedNinja, {compareNinjas} from "Ninja.js";

我们在这里使用逗号运算符从 Ninja.js 文件中导入默认导出和命名导出,在单个语句中完成。

重命名导出和导入

如果需要,我们也可以重命名导出和导入。让我们从重命名导出开始,如下面的代码所示(注释表明代码位于哪个文件中):

在前面的例子中,我们定义了一个名为 sayHi 的函数,并测试我们只能通过 sayHi 标识符访问该函数,而不能通过我们在模块末尾通过 as 关键字提供的 sayHello 别名:

export { sayHi as sayHello }

我们只能在这种导出形式中执行导出重命名,而不能通过在变量或函数声明前加上 export 关键字来实现。

然后,当我们导入重命名的导出时,我们通过给定的别名引用导入:

import { sayHello } from "Greetings.js";

最后,我们测试我们是否有权访问别名标识符,但不能访问原始标识符:

assert(typeof sayHi === "undefined"
    && typeof sayHello === "function",
      "When importing, we can only access the alias");

当重命名导入时,情况类似,如下面的代码段所示:

类似于导出标识符,我们也可以使用 as 关键字在从其他模块导入标识符时创建别名。当我们需要提供一个更适合当前上下文的名字,或者当我们想要避免命名冲突时,这很有用,就像在这个小例子中一样。

通过这种方式,我们完成了对 ES6 模块语法的探索,这在 表 11.1 中进行了总结。

表 11.1. ES6 模块语法概述
代码 含义

|

export const ninja = "Yoshi";
export function compare(){}
export class Ninja{}
导出一个命名变量。导出一个命名函数。导出一个命名类。

|

export default class Ninja{}
export default function Ninja(){}
导出默认类导出。导出默认函数导出。

|

const ninja = "Yoshi";
function compare(){};
export {ninja, compare};
export {ninja as samurai, compare};
导出现有变量。通过新名称导出变量。

|

import Ninja from "Ninja.js";
import {ninja, Ninja} from "Ninja.js";
导入默认导出。导入命名导出。

|

import * as Ninja from "Ninja.js";
从模块中导入所有命名的导出。

|

import {ninja as iNinja} from "Ninja.js";
通过新名称导入命名导出。

11.3. 概述

  • 大型、单体代码库比小型、组织良好的代码库更难理解和维护。改善我们程序的结构和组织的一种方法是将它们分解成更小、相对松散耦合的段或模块。

  • 模块是比对象和函数更大的代码组织单元,它们允许我们将程序划分为属于一起的集群。

  • 通常,模块促进了可理解性、易于维护和提高了代码的可重用性。

  • 在 ES6 之前的 JavaScript 没有内置的模块,开发者必须利用现有语言特性进行创新,以实现代码模块化。创建模块最流行的方式之一是将立即调用的函数与闭包结合起来。

    • 立即函数被使用,因为它们为定义模块变量创建了一个新的作用域,这些变量在该作用域之外是不可见的。

    • 使用闭包是因为它们使我们能够保持模块变量存活。

    • 最流行的模式是模块模式,它通常将立即函数与表示模块公共接口的新对象返回值结合起来。

  • 除了模块模式之外,还存在两个流行的模块标准:异步模块定义(AMD),旨在使浏览器中的模块化成为可能;以及 CommonJS,它在服务器端 JavaScript 中更为流行。

    • AMD 可以自动解析依赖关系,模块异步加载,从而避免阻塞。

    • CommonJS 具有简单的语法,同步加载模块(因此更适合服务器端),并且通过 node 的包管理器(npm)提供了许多可用的包。

  • ES6 模块旨在考虑 AMD 和 CommonJS 的特性。这些模块具有受 CommonJS 影响的简单语法,并提供与 AMD 相似的异步模块加载。

    • ES6 模块基于文件,每个文件一个模块。

    • 我们导出标识符,以便其他模块可以使用新的 export 关键字来引用它们。

    • 我们使用 import 关键字导入来自其他模块导出的标识符。

    • 模块可以有一个单一的 default 导出,如果我们想通过单个导出来表示整个模块,我们就使用它。

    • 导入和导出都可以使用 as 关键字重命名。

11.4. 练习

1

哪种机制使得模块模式中存在私有模块变量?

  1. 原型
  2. 闭包
  3. Promises

2

在以下使用 ES6 模块的代码中,如果导入模块,哪些标识符可以被访问?

const spy = "Yagyu";
function command(){
  return general + " commands you to wage war!";
}
export const general = "Minamoto";
  1. spy
  2. command
  3. general

3

在以下使用 ES6 模块的代码中,当导入模块时,哪些标识符可以被访问?

const ninja = "Yagyu";
function command(){
  return general + " commands you to wage war!";
}
const general  = "Minamoto";

export {ninja as spy};
  1. spy
  2. command
  3. general
  4. ninja

4

以下哪些导入是允许的?

//File: personnel.js
const ninja = "Yagyu";
function command(){
  return general + " commands you to wage war!";
}
const general  = "Minamoto";

export {ninja as spy};
  1. 从 "personnel.js" 中导入 {ninja, spy, general}
  2. 从 "personnel.js" 中导入所有内容为 Personnel
  3. 从 "personnel.js" 中导入 {spy}

5

如果我们有以下模块代码,以下哪个语句将导入 Ninja 类?

//Ninja.js
export default class Ninja {
  skulk(){ return "skulking"; }
}
  1. 从 “Ninja.js” 中导入 Ninja
  2. 从 “Ninja.js” 中导入 * as Ninja
  3. 从 “Ninja.js” 中导入 *

第四部分. 浏览器侦察

现在我们已经探讨了 JavaScript 语言的基础,我们将转向浏览器,这是大多数 JavaScript 应用执行的环境。

在第十二章中,我们将通过探索修改 DOM 的有效技术来更深入地了解 DOM,以实现快速、高度动态的网络应用。

在第十三章中,你将了解事件,特别关注事件循环及其对感知网络应用性能的影响。

最后,这本书以一个不太愉快但必要的主题结束:跨浏览器开发。尽管近年来情况有了很大改善,但我们仍然不能假设我们的代码在所有可用的浏览器中将以相同的方式工作。因此,第十四章介绍了开发跨浏览器网络应用的战略。

第十二章. 操作 DOM

本章涵盖

  • 将 HTML 插入 DOM

  • 理解 DOM 属性和 DOM 属性

  • 发现计算样式

  • 处理布局抖动

到目前为止,你主要学习的是 JavaScript 语言,尽管纯 JavaScript 有很多细微差别,但当我们把浏览器的文档对象模型(DOM)加入其中时,开发网络应用肯定不会变得更容易。实现高度动态且对用户动作做出响应的网络应用的主要方法之一是修改 DOM。但如果打开一个 JavaScript 库,你会注意到简单 DOM 操作背后的代码的长度和复杂性。即使是像cloneNoderemoveChild这样的看似简单的操作也有相对复杂的实现。

这提出了两个问题:

  • 为什么这段代码如此复杂?

  • 如果库会为你处理它,为什么你需要了解它是如何工作的?

最有力的理由是性能。了解库中 DOM 修改的工作原理可以让你编写更好、更快的代码,使用库,或者,你也可以将这些技术应用到自己的代码中。

因此,我们将从探讨如何通过注入任意 HTML 按需创建我们页面的新部分开始本章。然后,我们将检查浏览器在元素属性和属性方面对我们提出的所有难题,并发现为什么结果并不总是我们可能期望的那样。

这同样适用于层叠样式表(CSS)和元素的样式。我们在构建动态网络应用时遇到的许多困难都源于设置和获取元素样式的复杂性。这本书无法涵盖关于处理元素样式的所有已知信息(这足以填满另一整本书),但核心要点已讨论。

我们将通过查看一些可能出现的性能困难来结束本章,这些困难可能出现在你没有注意你修改和从 DOM 中读取信息的方式时。让我们首先看看如何将任意 HTML 注入我们的页面。

你知道吗?

Q1:

为什么在将 HTML 注入页面之前需要预先解析页面中的自闭合元素?

Q2:

在插入 HTML 时,与 DOM 片段一起工作的好处是什么?

Q3:

你如何确定页面中隐藏元素的尺寸?

12.1. 将 HTML 注入 DOM

在本节中,我们将探讨一种在给定 HTML 字符串的情况下,在任何位置高效插入 HTML 的方法。我们提出这种特定的技术,因为它经常用于创建高度动态的网页,其中用户界面作为对用户操作或从服务器接收的数据的响应而修改。这在以下场景中尤其有用:

  • 将任意 HTML 注入页面,并操作和插入客户端模板

  • 获取和注入从服务器发送的 HTML

正确实现此功能可能具有技术挑战性(尤其是与构建面向对象的 DOM 构建 API 相比,后者当然更容易实现,但需要比注入 HTML 额外的抽象层)。考虑以下示例,从 HTML 字符串创建 HTML 元素,我们可以使用 jQuery:

$(document.body).append("<div><h1>Greetings</h1><p>Yoshi here</p></div>")

将其与仅使用 DOM API 的方法进行比较:

const h1 = document.createElement("h1");
h1.textContent = "Greetings";

const p = document.createElement("p");
p.textContent = "Yoshi here";

const div = document.createElement("div");

div.appendChild(h1);
div.appendChild(p);

document.body.appendChild(div);

你更愿意使用哪一个?

由于这些原因,我们将从头开始实现我们自己的清洁 DOM 操作方法。实现需要以下步骤:

  1. 将任意但有效的 HTML 字符串转换为 DOM 结构。

  2. 尽可能高效地将该 DOM 结构注入 DOM 中的任何位置。

这些步骤为页面作者提供了一种智能的方法来将 HTML 注入文档。让我们开始吧。

12.1.1. 将 HTML 转换为 DOM

将 HTML 字符串转换为 DOM 结构并不涉及太多的魔法。实际上,它使用了一个你可能已经很熟悉的工具:DOM 元素的innerHTML属性。

使用它是一个多步骤的过程:

  1. 确保 HTML 字符串包含有效的 HTML 代码。

  2. 将字符串包裹在任何浏览器规则要求的封装标记中。

  3. 使用innerHTML将 HTML 字符串插入到虚拟 DOM 元素中。

  4. 将 DOM 节点提取出来。

步骤并不过于复杂,但实际的插入有一些需要注意的陷阱。让我们详细看看每个步骤。

预处理 HTML 源字符串

首先,我们需要清理源 HTML 以满足我们的需求。例如,让我们看看一个骨架 HTML,它允许我们通过option元素选择一个忍者,并在表格中显示所选忍者的详细信息,这些详细信息打算稍后添加:

<option>Yoshi</option>
<option>Kuma</option>
<table/>

这个 HTML 字符串有两个问题。首先,选项元素不应该独立存在。如果你遵循正确的 HTML 语义,它们应该包含在一个 select 元素内。其次,尽管标记语言通常允许我们自闭合无子元素的元素,如 <table/>,但在 HTML 中,自闭合只适用于一小部分元素(table 不是其中之一)。在其他情况下尝试使用该语法可能会在某些浏览器中引起问题。

让我们从解决自闭合元素的问题开始。为了支持这个特性,我们可以在 HTML 字符串上进行快速预解析,将如 <table/> 这样的元素转换为 <table></table>(这将在所有浏览器中统一处理),如下面的列表所示。

列表 12.1. 确保自闭合元素被正确解释

当我们将 convert 函数应用于这个示例 HTML 字符串时,我们最终得到以下 HTML 字符串:

完成这些后,我们仍然需要解决我们的 option 元素不包含在 select 元素中的问题。让我们看看如何确定一个元素是否需要被包裹。

HTML 包裹

根据 HTML 的语义,一些 HTML 元素在注入之前必须位于某些容器元素内。例如,一个 <option> 元素必须包含在一个 <select> 元素内。

我们可以通过两种方式解决这个问题,这两种方式都需要在问题元素和它们的容器之间构建映射:

  • 可以通过使用 innerHTML 将字符串直接注入到特定的父元素中,其中父元素之前已经使用内置的 document.createElement 构建过。尽管在某些情况下和某些浏览器中这可能有效,但它并不保证在所有情况下都有效。

  • 可以将字符串包裹在适当的所需标记中,然后直接注入到任何容器元素中(例如一个 <div>)。这更可靠,但工作量也更大。

第二种技术更受欢迎;与第一种方法相比,它涉及很少的特定浏览器代码,而第一种方法需要相当多的主要是特定浏览器代码。

需要包裹在特定容器元素中的问题元素集幸运地只有七个是可管理的。在表 12.1 中,省略号(...)表示元素需要注入的位置。

表 12.1. 需要包含在其他元素中的元素
, , , ,
元素名称 祖先元素
...
...

| |

...
...
|

...
|
, ...

| |

|

...
|

几乎所有这些都是直接的,除了以下需要一些解释的点:

  • 使用具有multiple属性的<select>元素(而不是非多选选择),因为它不会自动检查放置在其内的任何选项(而单选将自动检查第一个选项)。

  • <col>修复包括一个额外的<tbody>,没有它,<colgroup>将无法正确生成。

在元素正确映射到其包装要求之后,让我们开始生成。

根据表 12.1 中的信息,我们可以生成需要插入到 DOM 元素中的 HTML,如下所示。

列表 12.2. 从一些标记生成 DOM 节点列表

我们创建一个映射,其中包含需要放置在特殊父容器内的所有元素类型,该映射包含节点的深度以及封装的 HTML。接下来,我们使用正则表达式来匹配要插入的元素的打开括号和标签名:

const tagName = htmlString.match(/<\w+/);

然后我们选择一个映射条目,如果没有,我们创建一个带有空父元素标记的虚拟条目:

let mapEntry = tagName ? map[tagName[0]] : null;
if  (!mapEntry) { mapEntry = [0, " ", " "]; }

我们随后创建一个新的div元素,将其与映射的 HTML 包装在一起,并将新创建的 HTML 插入到之前创建的div元素中:

let div = (doc || document).createElement("div");
div.innerHTML = mapEntry[1] + htmlString + mapEntry[2]

最后,我们找到从我们的 HTML 字符串创建的所需节点的父节点,并返回新创建的节点:

while (mapEntry[0]--) { div = div.lastChild;}
return div.childNodes;

在所有这些之后,我们有一组 DOM 节点可以开始插入到文档中。

如果我们回到我们的动机示例,并应用getNodes函数,我们最终会得到以下类似的结果:

12.1.2. 在文档中插入元素

在我们获得 DOM 节点后,是时候将它们插入到文档中了。需要几个步骤,我们将在本节中逐一介绍。

由于我们需要插入的元素数组——可能插入到文档中的任何位置——我们希望尽量减少执行的操作数量。我们可以通过使用DOM 片段来实现这一点。DOM 片段是 W3C DOM 规范的一部分,并且所有浏览器都支持。这个有用的功能为我们提供了一个容器来保存一组 DOM 节点。

这本身很有用,但它还有一个优点,即片段可以在单个操作中注入和克隆,而不是每次都要重复注入和克隆每个单独的节点。这有可能大大减少页面所需的操作数量。

在我们将在代码中使用此机制之前,让我们回顾一下列表 12.2 中的getNodes()代码,并稍作调整以使用 DOM 片段。这些更改很小,包括向函数参数列表中添加一个fragment参数,如下所示。

列表 12.3. 使用片段扩展 getNodes 函数

在这个例子中,我们进行了一些修改。首先,我们通过添加另一个参数fragment修改了函数签名:

function getNodes(htmlString, doc, fragment) {...}

如果传递了此参数,则预期它是一个 DOM 片段,我们希望将节点注入其中以供以后使用。

要做到这一点,我们在函数的return语句之前添加以下片段,以便将节点添加到传递的片段中:

if (fragment) {
  while (div.firstChild) {
    fragment.appendChild(div.firstChild);
  }
}

现在,让我们看看它是如何使用的。在下面的列表中,假设更新的getNodes函数在作用域内,创建了一个片段并将其传递给该函数(你可能还记得,该函数将传入的 HTML 字符串转换为 DOM 元素)。现在,这个 DOM 被附加到片段中。

列表 12.4. 在 DOM 的多个位置插入 DOM 片段

这里还有一个重要的点:如果我们将此元素插入文档的多个位置,我们将需要反复克隆此片段。如果我们没有使用片段,我们就必须每次都克隆每个单独的节点,而不是整个片段。

通过这种方式,我们已经开发出了一种以直观方式生成和插入任意 DOM 元素的方法。让我们继续探索 DOM,看看 DOM 属性和属性之间的区别。

12.2. 使用 DOM 属性和属性

当访问元素属性值时,我们有两种选择:使用传统的 DOM 方法getAttributesetAttribute,或者使用与属性对应的 DOM 对象属性。

例如,要获取存储在变量e中的元素的id,我们可以使用以下两种方法中的任何一种:

e.getAttribute('id')
e.id

任何一种方法都会给我们id的值。

让我们检查以下代码,以更好地理解属性值及其对应属性的行为。

列表 12.5. 通过 DOM 方法和属性访问属性值

这个例子展示了元素属性和元素属性之间有趣的行为。它首先定义了一个简单的<div>元素,我们将用它作为测试对象。在文档的DOMContentLoaded处理程序(以确保 DOM 完全构建)中,我们获取对唯一的<div>元素的引用,const div = document.querySelector("div"),然后运行一些测试。

在第一个测试中,我们通过setAttribute()方法将id属性设置为值ninja-1。然后我们断言getAttribute()返回该属性的相同值。当页面加载时,这个测试工作得很好,这并不令人惊讶:

div.setAttribute("id", "ninja-1");
assert(div.getAttribute('id') === "ninja-1",
       "Attribute successfully changed");

同样,在下一个测试中,我们将id属性设置为值ninja-2,然后验证属性值确实已更改。没问题。

div.id = "ninja-2";
assert(div.id === "ninja-2",
      "Property successfully changed");

下一个测试是当事情变得有趣的时候。我们再次将id属性设置为新的值,在这种情况下是ninja-3,并再次验证属性值已经改变。但然后我们断言,不仅属性值应该改变,而且id 属性的值也应该改变。两个断言都通过了。从这个例子中我们了解到,id属性和id属性是某种方式联系在一起的。改变id属性值也会改变id属性值:

div.id = "ninja-3";
assert(div.id === "ninja-3",
      "Property successfully changed");
assert(div.getAttribute('id') === "ninja-3",
       "Attribute successfully changed via property");

下一个测试证明它也可以反过来工作:设置属性值也会改变相应的属性值。

div.setAttribute("id","ninja-4");
assert(div.id === "ninja-4",
       "Property successfully changed via attribute");
assert(div.getAttribute('id') === "ninja-4","Attribute changed");

但不要被这个想法误导,以为属性和属性值是共享相同的值——它们不是。我们将在本章后面看到,尽管属性和相应的属性是链接的,但它们并不总是相同的。

重要的是要注意,并非所有属性都由元素属性表示。尽管对于由 HTML DOM 原生指定的属性来说通常是正确的,但我们在页面元素上放置的自定义属性并不会自动成为元素属性的表示。要访问自定义属性值,我们需要使用 DOM 方法getAttribute()setAttribute()

如果你不确定一个属性的属性值是否存在,你总是可以测试它,如果不存在,就回退到 DOM 方法。以下是一个例子:

const value = element.someValue ? element.someValue
                                : element.getAttribute('someValue');
小贴士

在 HTML5 中,使用前缀data-为所有自定义属性,以保持它们在 HTML5 规范眼中的有效性。这是一个良好的约定,它清楚地区分了自定义属性和原生属性。

12.3. 样式属性头痛问题

与一般的属性一样,获取和设置样式属性可能会让人头疼。与上一节中的属性和属性值一样,我们再次有两种处理style值的方法:属性值,以及由此创建的元素属性。

这些中最常用的是style元素属性,它不是一个字符串,而是一个包含与元素标记中指定的样式值相对应的属性的对象。此外,你还会看到有一个方法可以访问元素的计算样式信息,其中计算样式是指评估所有继承和应用的样式信息后应用于元素的样式。

本节概述了在浏览器中处理样式时需要了解的内容。让我们先看看样式信息是如何记录的。

12.3.1. 我的样式在哪里?

DOM 元素的style属性中定位的样式信息最初是从元素标记中指定的style属性值设置的。例如,style="color:red;"会导致该样式信息被放入样式对象中。在页面执行期间,脚本可以设置或修改样式对象中的值,这些更改将直接影响元素的显示。

许多脚本作者失望地发现,页面上的<style>元素或外部样式表中的任何值都不在元素的style对象中可用。但我们的失望不会持续太久——你很快就会看到一种获取这些信息的方法。

现在,让我们看看style属性是如何获取其值的。检查以下代码。

列表 12.6. 检查样式属性

在这个例子中,我们设置了一个<style>元素来建立一个内部样式表,其值将应用于页面上的元素。样式表指定所有<div>元素将以默认字体大小的 1.8 倍显示,并带有宽度为0的实心金色边框。任何应用了这个样式的元素都将具有边框,但由于宽度为0,它将不可见。

<style>
  div { font-size: 1.8em; border: 0 solid gold; }
</style>

然后我们创建了一个带有内联样式属性的<div>元素,该属性将元素的文本颜色设置为黑色:

我们随后开始测试。在获取到<div>元素引用后,我们测试style属性是否接收到了一个表示分配给该元素的颜色的color属性。请注意,尽管color在行内样式中被指定为#000,但在大多数浏览器中将它设置为style属性时,它会被规范化为 RGB 表示法(因此我们检查了两种格式)。

assert(div.style.color === 'rgb(0, 0, 0)' ||
       div.style.color === '#000',
       'color was recorded');

向前看,在图 12.1 中,我们看到这个测试通过了。

图 12.1. 通过运行这个测试,我们可以看到内联和指定的样式被记录,但继承的样式没有被记录。

然后我们天真地测试了内联样式表中指定的字体大小样式和边框宽度是否已记录在样式对象中。但尽管我们在图 12.1 中看到字体大小样式已被应用到元素上,测试却失败了。这是因为样式对象没有反映从 CSS 样式表中继承的任何样式信息:

assert(div.style.fontSize === '1.8em',
       'fontSize was recorded');
assert(div.style.borderWidth === '0',
       'borderWidth was recorded');

接下来,我们使用赋值来改变样式对象中borderWidth属性的值,将其设置为 4 像素宽,并测试这个更改是否被应用。我们可以在图 12.1 中看到测试通过了,并且之前不可见的边框被应用到元素上。这个赋值导致在元素的style属性中出现了borderWidth属性,正如测试所证明的那样。

div.style.borderWidth = "4px";
assert(div.style.borderWidth === '4px',
       'borderWidth was replaced');

应该注意的是,元素style属性中的任何值都优先于从样式表继承的任何内容(即使样式表规则使用了!important注释)。

你可能在列表 12.6 中注意到了一点,CSS 将字体大小属性指定为font-size,但在脚本中我们引用它为fontSize。为什么是这样?

12.3.2. 样式属性命名

当涉及到访问浏览器提供的值时,CSS 属性在跨浏览器中引起的问题相对较少。但 CSS 命名样式和我们在脚本中访问它们的方式之间存在差异,并且一些样式名称在不同的浏览器中是不同的。

包含多个单词的 CSS 属性使用连字符分隔单词;例如font-weightfont-sizebackground-color。你可能记得 JavaScript 中的属性名称可以包含连字符,但包含连字符会阻止通过点运算符访问属性。

考虑以下示例:

const fontSize = element.style['font-size'];

前面的代码是有效的。但以下代码不是:

const fontSize = element.style.font-size;

JavaScript 解析器会将连字符视为减法运算符,没有人会对结果感到满意。与其强迫页面开发者始终使用属性访问的一般形式,不如将多词 CSS 样式名称在用作属性名称时转换为驼峰式。因此,font-size变为fontSizebackgroundcolor变为backgroundColor

我们可以记住这样做,或者编写一个简单的 API 来设置或获取样式,该 API 会自动处理驼峰式,如下面的列表所示。

列表 12.7. 访问样式的简单方法

样式函数有两个重要特性:

  • 它使用正则表达式将name参数转换为驼峰式表示法。(如果你对正则表达式驱动的转换操作感到困惑,你可能想回顾第十章的内容。)

  • 它可以通过检查其自己的参数列表同时作为设置器和获取器使用。例如,我们可以使用style(div, 'font-size')获取字体大小属性的值,并且我们可以使用style(div, 'font-size', '5px')设置新的值。

考虑以下代码:

function style(element,name,value){
    ...
  if (typeof value !== 'undefined') {
    element.style[name] = value;
  }

  return element.style[name];
}

如果向函数传递一个value参数,则函数作为设置器,将传递的值设置为属性的值。如果省略value参数,并且只传递前两个参数,则函数作为获取器,检索指定属性的值。在任一情况下,都会返回属性的值,这使得在函数调用链中以任一模式使用函数变得容易。

元素的style属性不包括元素从作用域内的样式表中继承的任何样式信息。很多时候,知道应用于元素的完整计算样式会很有用,所以让我们看看是否有方法可以获得它。

12.3.3. 获取计算样式

在任何时刻,元素的计算样式是浏览器提供的所有内置样式、通过样式表应用的所有样式、元素的style属性以及通过脚本对style属性的任何操作的组合。图 12.2 显示了浏览器开发者工具如何区分样式。

图 12.2. 与元素关联的最终样式可以来自许多方面:浏览器的内置样式(用户代理样式表)、通过style属性分配的样式,以及 CSS 代码中定义的 CSS 规则中的样式。

标准方法,由所有现代浏览器实现,是 getComputedStyle 方法。这个方法接受一个要计算样式的元素,并返回一个接口,通过这个接口可以进行属性查询。返回的接口提供了一个名为 getPropertyValue 的方法,用于检索特定样式属性的计算后样式。

与元素的 style 对象的属性不同,getPropertyValue 方法接受 CSS 属性名(如 font-sizebackground-color),而不是这些名称的驼峰式版本。

下面的列表展示了一个简单的例子。

列表 12.8. 获取计算后的样式值

图片

图片

为了测试我们将要创建的函数,我们设置了一个在标记中指定样式信息的元素和一个提供要应用于元素的样式规则的样式表。我们预计计算后的样式将是将直接和应用的样式应用于元素的结果。

然后我们定义一个新的函数,它接受一个元素和我们要查找计算值的样式属性。为了特别友好(毕竟,我们是忍者——让使用我们代码的人更容易是我们的工作的一部分),我们将允许以两种格式指定多词属性名:破折号或驼峰式。换句话说,我们将接受 backgroundColorbackground-color。我们将在稍后展示如何实现这一点。

我们首先想要做的是获取计算后的样式接口,我们将它存储在一个变量 computedStyles 中,以便稍后引用。我们这样做的理由是因为我们不知道调用这个接口可能需要多少成本,而且最好避免无谓地重复调用。

const computedStyles = getComputedStyle(element);
if (computedStyles) {
  property = property.replace(/([A-Z])/g,'-$1').toLowerCase();
  return computedStyles.getPropertyValue(property);
}

如果这成功了(我们想不出任何它不会成功的理由,但通常谨慎一些是有好处的),我们就调用接口的 getPropertyValue() 方法来获取计算后的样式值。但首先我们需要调整属性的名称,以适应属性名的驼峰式或破折号版本。getPropertyValue 方法期望的是破折号版本,所以我们使用 Stringreplace() 方法,配合一个简单但巧妙的正则表达式,在每一个大写字母前插入一个破折号,然后将整个字符串转换为小写。(这比你想的要容易吧。)

为了测试这个函数,我们调用函数,传递各种格式和样式名称,并显示结果,如图 12.3 所示。

图 12.3. 计算后的样式包括元素指定的所有样式以及从样式表中继承的样式。

图片

注意,无论这些样式是否在元素上显式声明或从样式表中继承,都会检索到这些样式。另外,注意在样式表和元素上直接指定的color属性返回的是显式值。通过元素的style属性指定的样式始终优先于继承的样式,即使标记为!important

在处理样式属性时,我们还需要注意一个额外的主题:混合属性。CSS 允许我们使用简写符号来表示如border-属性这样的混合属性。我们不必分别指定颜色、宽度和所有四个边框的边框样式,可以使用如下规则:

border: 1px solid crimson;

我们在列表 12.8 中使用了这个精确的规则。这可以节省很多输入,但我们需要意识到,当我们检索属性时,我们需要获取低级别的单个属性。我们不能获取border,但我们可以获取如border-top-colorborder-top-width这样的样式,就像我们在示例中所做的那样。

这可能有点麻烦,尤其是当所有四个样式都赋予相同的值时,但我们只能接受这个现实。

12.3.4. 转换像素值

在设置样式值时,需要考虑的是分配代表像素的数值。当为一个样式属性设置数值时,我们必须指定单位,以便它在所有浏览器中可靠地工作。例如,假设我们想要将一个元素的height样式值设置为 10 像素。以下两种方法都可以安全地在浏览器之间进行操作:

element.style.height = "10px";
element.style.height = 10 + "px";

以下方法在浏览器之间不安全:

element.style.height = 10;

你可能会认为向列表 12.7 的style()函数添加一点逻辑,将px添加到传入函数的数值的末尾很容易。但别急!并非所有数值都代表像素!一些样式属性接受的数值不代表像素维度。以下是一些例子:

  • z-index

  • font-weight

  • opacity

  • zoom

  • line-height

对于这些(以及你可以想到的任何其他情况),请扩展列表 12.6 的功能,以自动处理非像素值。此外,当尝试从一个样式属性中读取像素值时,应使用parseFloat方法,以确保在所有情况下都能得到预期的值。

现在让我们看看一组重要的样式属性,这些属性可能很难处理。

12.3.5. 测量高度和宽度

样式属性如heightwidth存在特殊问题,因为当未指定时,它们的值默认为auto,这样元素就会根据其内容的大小自动调整。因此,除非在属性字符串中提供了显式值,否则我们不能使用heightwidth样式属性来获取准确值。

幸运的是,offsetHeightoffsetWidth属性正好提供了这样的功能:一种相当可靠的方式来访问元素的高度和宽度。但请注意,分配给这两个属性的价值包括元素的填充。如果我们试图将一个元素定位在另一个元素之上,通常这正是我们想要的信息。但有时我们可能想要获取有关元素尺寸的信息,包括和不包括边框和填充。

然而,需要注意的是,在高度交互的网站上,元素可能会花费一些时间处于非显示状态(display样式设置为none),当一个元素不是显示的一部分时,它没有尺寸。尝试获取非显示元素的offsetWidthoffsetHeight属性将导致值为0

对于这些隐藏元素,如果我们想获取非隐藏维度,我们可以使用一个技巧来暂时显示元素,获取值,然后再隐藏它。当然,我们希望这样做的方式不会留下任何明显的线索,表明幕后正在进行这种情况。我们如何使一个隐藏的元素看起来不隐藏,同时又不会让它变得可见?

利用我们的忍者技巧,我们可以做到!下面是如何操作的:

  1. display属性更改为block

  2. visibility设置为hidden

  3. position设置为absolute

  4. 捕获维度值。

  5. 恢复更改的属性。

display属性更改为block允许我们获取offsetHeightoffsetWidth的值,但这会使元素成为显示的一部分,因此是可见的。为了使元素不可见,我们将visibility属性设置为hidden。但(总会有另一个“但是”)这将留下一个很大的洞,元素就定位在那里,所以我们还将position属性设置为absolute,将元素从正常的显示流程中移除。

所有这些听起来比实现要复杂,下面的列表显示了实现。

列表 12.9。获取隐藏元素的尺寸

图片

图片

这是一段很长的列表,但其中大部分是测试代码;新维度获取函数的实现只有十几行代码。

让我们逐个分析它。首先,我们设置了用于测试的元素:一个包含大量文本的<div>元素,其中包含两张图片,通过外部样式表中的样式左对齐。这些图片元素将成为我们测试的主题;一个是可见的,一个是隐藏的。

在运行任何脚本之前,元素看起来如图 12.4 所示。如果第二张图片没有被隐藏,它将作为一个第二忍者出现在可见忍者右侧。

图 12.4。我们将使用两张图片——一张可见,一张隐藏——来测试获取隐藏元素的尺寸。

图片

接着,我们着手定义我们的新函数。我们将使用一个哈希表来存储一些重要信息,但我们不想用这个哈希表污染全局命名空间;我们希望它在函数的局部作用域内可用,但仅限于此。

我们通过将哈希定义和函数声明放在立即函数中来实现这一点,这创建了一个局部作用域。哈希表在立即函数外部不可访问,但我们也在立即函数中定义的 getDimensions 函数可以通过闭包访问这个哈希表。不错吧?

(function(){
    const PROPERTIES = {
      position: "absolute",
      visibility: "hidden",
      display: "block"
    };
    window.getDimensions = element => {
      const previous = {};
      for (let key in PROPERTIES) {
        previous[key] = element.style[key];
        element.style[key] = PROPERTIES[key];
      }
      const result = {
        width: element.offsetWidth,
        height: element.offsetHeight
      };
      for (let key in PROPERTIES) {
        element.style[key] = previous[key];
      }
      return result;
    };
  })();

我们首先声明了新的维度获取函数,该函数接受要测量的元素。在这个函数内部,我们首先创建一个名为 previous 的哈希表,我们将记录将要修改的样式属性的前一个值,以便稍后恢复它们。在遍历替换属性时,我们记录每个属性的前一个值,并用新值替换它们。

完成这些后,我们准备测量元素,该元素现在已成为显示布局的一部分,不可见,并且绝对定位。尺寸记录在分配给局部变量 result 的哈希表中。

现在我们已经得到了我们想要的东西,我们通过恢复我们修改的样式属性的原值来擦除我们的痕迹,并将包含 widthheight 属性的哈希表作为结果返回。

所有这些都很好,但它真的起作用吗?让我们来看看。

在加载处理程序中,我们在 3 秒计时器的回调中执行测试。为什么问这个?加载处理程序确保我们在知道 DOM 已经构建后才执行测试,计时器使我们能够在测试运行时观察显示,以确保我们在调整隐藏元素的属性时不会出现显示故障。毕竟,如果我们运行我们的函数时显示以任何方式受到干扰,那将是一个失败。

在计时器回调中,我们首先获取我们的测试对象(两个图像)的引用,并断言我们可以通过使用偏移属性来获取可见图像的尺寸。这个测试通过了,如果我们提前看到图 12.5。

图 12.5。通过临时调整隐藏元素的样式属性,我们可以成功获取它们的尺寸。

12fig05.jpg

然后我们对隐藏元素进行了相同的测试,错误地假设偏移属性将适用于隐藏图像。不出所料,因为我们已经承认这不会工作,测试失败了。

接下来,我们在隐藏图像上调用我们的新函数,然后使用这些结果重新进行测试。成功了!我们的测试通过了,如图 12.5 所示。

当测试运行时,如果我们观察页面的显示——记住,我们延迟运行测试直到 DOM 加载后的 3 秒——我们可以看到,我们的隐藏元素属性背后的调整并没有以任何方式影响显示。

提示

检查offsetWidthoffsetHeight样式属性是否为零可以作为一个非常高效的确定元素可见性的方法。

12.4. 最小化布局抖动

到目前为止,在本章中,你已经学会了如何相对容易地修改 DOM:通过创建和插入新元素、删除现有元素或修改它们的属性。修改 DOM 是实现高度动态 Web 应用程序的基本工具之一。

但这个工具也附带了一些使用注意事项,其中最重要的一点是,要注意布局抖动。布局抖动发生在我们连续对 DOM 进行一系列的读取和写入操作时,在这个过程中不允许浏览器执行布局优化。

在我们深入探讨之前,请考虑这样一个事实:改变一个元素的属性(或修改其内容)并不一定只会影响该元素;相反,它可能引起一系列的变化。例如,设置一个元素的宽度可能会导致该元素的孩子、兄弟和父元素发生变化。因此,每当进行更改时,浏览器都必须计算这些更改的影响。在某些情况下,我们对此无能为力;我们需要这些更改发生。但与此同时,我们也没有必要给我们的可怜的浏览器增加额外的负担,导致我们的 Web 应用程序性能下降。

由于重新计算布局代价高昂,浏览器尽可能地变得懒惰,通过尽可能推迟处理布局;它们试图将尽可能多的 DOM 写入操作排队,以便一次性执行这些操作。然后,当出现需要最新布局的操作时,浏览器不情愿地服从,执行所有排队的操作,并最终更新布局。但有时,我们编写代码的方式并没有给浏览器足够的空间来执行这些优化,这迫使浏览器进行大量的(可能是多余的)重新计算。这就是布局抖动的问题;它发生在我们的代码执行一系列(通常是多余的)连续的 DOM 读取和写入操作时,不允许浏览器优化布局操作。问题是,每当修改 DOM 时,浏览器都必须在读取任何布局信息之前重新计算布局。这个动作在性能方面代价高昂。让我们看看一个例子。

列表 12.10. 连续的读取和写入序列导致布局抖动

读取元素的clientWidth属性值是那些需要浏览器保持布局最新状态的操作之一。通过连续读取和写入不同元素的宽度属性,我们不允许浏览器以智能的方式偷懒。相反,因为我们每次布局修改后都会读取布局信息,所以浏览器必须每次都重新计算布局,以确保我们仍然得到正确的信息。

减小布局抖动的一种方法是以不会引起不必要的布局重新计算的方式编写代码。例如,我们可以将 列表 12.10 重写为以下内容。

列表 12.11. 批量 DOM 读取和写入以避免布局抖动

图片

在这里,我们批量处理所有读取和写入操作,因为我们知道我们元素的尺寸之间不存在依赖关系;设置ninja元素的宽度不会影响samurai元素的宽度。这允许浏览器以懒加载的方式批量处理修改 DOM 的操作。

布局抖动不是你在较小、较简单的页面上会注意到的事情,但在开发复杂网络应用程序时,尤其是在移动设备上,这是需要记住的事情。因此,始终记住需要最新布局的方法和属性总是好的,如下表所示(来自 ricostacruz.com/cheatsheets/layout-thrashing.html)。

表 12.2. 导致布局无效化的 API 和属性
接口 属性名
Element clientHeight, clientLeft, clientTop, clientWidth, focus, getBoundingClientRect, getClientRects, innerText, offsetHeight, offsetLeft, offsetParent, offsetTop, offsetWidth, outerText, scrollByLines, scrollByPages, scrollHeight, scrollIntoView, scrollIntoViewIfNeeded, scrollLeft, scrollTop, scrollWidth
MouseEvent layerX, layerY, offsetX, offsetY
Window getComputedStyle, scrollBy, scrollTo, scroll, scrollY
Frame, Document, Image height, width

已经开发出几个试图最小化布局抖动的库。其中较受欢迎的一个是 FastDom (github.com/wilsonpage/fastdom)。库的存储库包括示例,清楚地展示了通过批量 DOM 读写操作可以获得性能提升 (wilsonpage.github.io/fastdom/examples/aspect-ratio.html)。

React 的虚拟 DOM

最受欢迎的客户端库之一是 Facebook 的 React (facebook.github.io/react/)。React 通过使用虚拟 DOM(一组模仿实际 DOM 的 JavaScript 对象)实现了出色的性能。当我们使用 React 开发应用程序时,我们会对虚拟 DOM 进行所有修改,而不考虑布局抖动。然后,在适当的时候,React 使用虚拟 DOM 来确定需要对实际 DOM 进行哪些更改,以保持 UI 保持同步。这种更新批处理提高了应用程序的性能。

12.5. 摘要

  • 将 HTML 字符串转换为 DOM 元素包括以下步骤:

    • 确保 HTML 字符串是有效的 HTML 代码

    • 将其包装到浏览器规则要求的封装标记中

    • 通过 DOM 元素的 innerHTML 属性将 HTML 插入到虚拟 DOM 元素中

    • 提取创建的 DOM 节点

  • 为了快速插入 DOM 节点,请使用 DOM 片段,因为片段可以在单个操作中注入,从而大大减少操作次数。

  • DOM 元素属性和属性,尽管相关联,但并不总是相同!我们可以通过使用 getAttributesetAttribute 方法来读取和写入 DOM 属性,而通过使用对象属性表示法来写入 DOM 属性。

  • 当处理属性和属性时,我们必须注意 自定义属性。我们决定放置在 HTML 元素上的属性,以便携带对我们应用程序有用的信息,并不会自动作为元素属性呈现。

  • style 元素属性是一个对象,它包含与元素标记中指定的样式值相对应的属性。要获取计算后的样式,这也会考虑在样式表中设置的样式,请使用内置的 getComputedStyle 方法。

  • 要获取 HTML 元素的尺寸,请使用 offsetWidthoffsetHeight 属性。

  • 当代码执行一系列连续的 DOM 读取和写入操作时,会发生布局抖动,每次都会迫使浏览器重新计算布局信息。这导致 Web 应用程序运行缓慢,响应性降低。

  • 批量更新 DOM!

12.6. 练习

1.

在以下代码中,以下哪个断言将会通过?

<div id="samurai"></div>
<script>
  const element = document.querySelector("#samurai");

  assert(element.id === "samurai", "property id is samurai");
  assert(element.getAttribute("id") === "samurai",
         "attribute id is samurai");

  element.id = "newSamurai";
  assert(element.id === "newSamurai", "property id is newSamurai");
  assert(element.getAttribute("id") === "newSamurai",
         "attribute id is newSamurai");
</script>

2

给定以下代码,我们如何访问元素的 border-width 样式属性?

<div id="element" style="border-width: 1px;
                         border-style:solid; border-color: red">
</div>
<script>
  const element = document.querySelector("#element");
</script>
  1. element.border-width
  2. element.getAttribute("border-width");
  3. element.style["border-width"];
  4. element.style.borderWidth;

3

哪个内置方法可以获取应用到特定元素上的所有样式(浏览器提供的样式、通过样式表应用的样式以及通过样式属性设置的属性)?

  1. getStyle
  2. getAllStyles
  3. getComputedStyle

4

布局抖动何时发生?

第十三章:应对事件

本章涵盖

  • 理解事件循环

  • 使用计时器处理复杂任务

  • 使用计时器管理动画

  • 使用事件冒泡和委托

  • 使用自定义事件

第二章中包含了对 JavaScript 单线程执行模型的简要讨论,并介绍了事件循环和事件队列,其中事件等待轮到它们被处理的机会。当展示网页的生命周期步骤时,特别是讨论某些 JavaScript 代码执行的顺序时,这次讨论特别有用。同时,这也是一种简化,因此为了更全面地了解浏览器的工作原理,我们将在本章中花相当大的篇幅来探索事件循环的各个角落。这将帮助我们更好地理解 JavaScript 和浏览器中固有的某些性能限制。反过来,我们将利用这些知识来开发运行更流畅的应用程序。

在这次探索中,我们将特别关注定时器,这是一个 JavaScript 特性,它允许我们通过一定的时间延迟异步执行一段代码。乍一看,这可能看起来并不重要,但我们将向您展示如何使用定时器将长时间运行的任务分解成不会阻塞浏览器的小任务。这有助于开发性能更好的应用程序。

我们将继续探索事件,展示事件是如何通过 DOM 树传播的,以及如何利用这些知识来编写更简单、内存消耗更少的代码。最后,我们将以创建自定义事件结束本章,这有助于减少应用程序不同部分之间的耦合。现在,让我们不拖延,开始遍历事件循环。

你知道吗?

Q1:

为什么定时器回调的时机没有保证?

Q2:

如果一个setInterval定时器在另一个事件处理程序运行了 16 毫秒的同时每 3 毫秒触发一次,定时器的回调函数将被添加到微任务队列多少次?

Q3:

为什么事件处理函数的函数上下文有时与事件的目标不同?

13.1. 深入事件循环

如您可能已经猜到的,事件循环比其在第二章中的描述要复杂。首先,事件循环不仅仅有一个只包含事件的单一事件队列,它至少包含两个队列,除了事件外,这些队列还持有浏览器执行的其他操作。这些操作被称为任务,并被分为两类:宏任务(或通常简称为任务)和微任务

宏任务的例子包括创建主文档对象、解析 HTML、执行主线(或全局)JavaScript 代码、更改当前 URL,以及各种事件,如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,一个宏任务代表一个离散的、自包含的工作单元。执行完一个任务后,浏览器可以继续其他任务,例如重新渲染页面 UI 或执行垃圾回收。

另一方面,微任务是较小的任务,用于更新应用程序状态,应该在浏览器继续其他任务(如重新渲染 UI)之前执行。例如,包括 promise 回调和 DOM 变更。微任务应该尽快以异步方式执行,但不需要执行整个新的宏任务。微任务使我们能够在 UI 重新渲染之前执行某些操作,从而避免显示不一致的应用程序状态的无效 UI 渲染。

注意

ECMAScript 规范没有提到事件循环。相反,事件循环在 HTML 规范中详细说明(html.spec.whatwg.org/#event-loops),同时也讨论了宏任务和微任务的概念。ECMAScript 规范在处理 promise 回调时提到了作业(与微任务类似)。尽管事件循环在 HTML 规范中定义,但其他环境,如 Node.js,也使用它。

事件循环的实现应该至少使用一个队列来处理宏任务,以及至少一个队列来处理微任务。事件循环的实现通常不止于此,并为不同类型的宏任务和微任务提供多个队列。这使得事件循环能够优先处理不同类型的任务;例如,优先处理性能敏感的任务,如用户输入。另一方面,由于野外有众多浏览器和 JavaScript 执行环境,如果你遇到只有单个队列同时处理这两种类型任务的事件循环,也不必感到惊讶。

事件循环基于两个基本原理:

  • 任务是逐个处理的。

  • 一个任务运行到完成,不能被另一个任务中断。

让我们看看图 13.1,它描绘了这两个原则。

图 13.1。事件循环通常至少可以访问两个任务队列:一个微任务队列和一个宏任务队列。这两种类型的任务都是逐个处理的。

在高层次上,图 13.1 显示,在单次迭代中,事件循环首先检查宏任务队列,如果有等待执行的任务,则开始执行。只有在任务完全处理完毕(或者队列中没有任务)之后,事件循环才会继续处理微任务队列。如果队列中有等待的任务,事件循环会取走并执行它直到完成。这会针对队列中的所有微任务执行。注意处理宏任务和微任务队列之间的区别:在单次循环迭代中,最多处理一个宏任务(其他任务则留在队列中等待),而所有微任务都会被处理。

当微任务队列最终为空时,事件循环检查是否需要 UI 渲染更新,如果是的话,UI 就会被重新渲染。这标志着事件循环当前迭代的结束,然后它回到开始处再次检查宏任务队列。

现在我们对事件循环有了高层次的理解,让我们检查图 13.1 中显示的一些有趣细节:

  • 两个任务队列都放置在事件循环之外,以表明将任务添加到其匹配队列的行为发生在事件循环之外。如果不是这样,在 JavaScript 代码执行期间发生的任何事件都将被忽略。因为我们绝对不希望这样做,所以检测和添加任务的行为与事件循环分开进行。

  • 两种类型的任务一次执行一个,因为 JavaScript 基于单线程执行模型。当一个任务开始执行时,它会执行到完成,不会被另一个任务中断。只有浏览器可以停止任务的执行;例如,如果任务变得过于自私,占用太多时间或内存。

  • 所有微任务都应该在下一轮渲染之前执行,因为它们的目的是在渲染发生之前更新应用程序状态。

  • 浏览器通常每秒尝试渲染页面 60 次,以达到每秒 60 帧(60 fps),这是一种常被认为对平滑运动,如动画等理想的帧率——也就是说,浏览器尝试每 16 毫秒渲染一帧。 注意到“更新渲染”操作,如图 13.1 所示,发生在事件循环内部,因为当页面正在渲染时,页面的内容不应该被另一个任务修改。所有这些都意味着,如果我们想实现运行流畅的应用程序,我们在单个事件循环迭代中处理任务的时间并不多。一个任务及其产生的所有微任务理想上应该在 16 毫秒内完成

现在,让我们考虑在浏览器完成页面渲染后的下一个事件循环迭代中可能出现的三种情况:

  • 事件循环在另一个 16 毫秒过去之前就达到了“是否需要渲染?”的决策点。因为更新 UI 是一个复杂的操作,如果没有明确的渲染页面需求,浏览器可能会选择不在这次循环迭代中执行 UI 渲染。

  • 事件循环在大约最后一次渲染后的 16 毫秒左右达到“是否需要渲染?”的决策点。在这种情况下,浏览器更新 UI,用户将体验到运行流畅的应用程序。

  • 执行下一个任务(及其所有相关微任务)需要超过 16 毫秒。在这种情况下,浏览器将无法以目标帧率重新渲染页面,UI 将不会更新。如果运行任务代码的时间不太长(不超过几百毫秒),这种延迟可能甚至感觉不到,尤其是如果页面上没有太多运动。另一方面,如果我们花费太多时间,或者页面上有动画运行,用户可能会觉得网页运行缓慢且无响应。在最坏的情况下,如果一个任务执行超过几秒钟,用户的浏览器会显示可怕的“无响应脚本”消息。(别担心,在本章的后面部分,我们将向你展示一种将复杂任务分解成更小任务的技术,这样就不会阻塞事件循环。)

注意

仔细考虑你决定处理哪些事件,它们发生的频率,以及事件处理器消耗的处理时间。例如,在处理鼠标移动事件时,你应该格外小心。鼠标的移动会导致大量事件被排队,因此在鼠标移动处理器中执行任何复杂操作都可能导致构建一个缓慢且不流畅的 Web 应用程序。

现在我们已经描述了事件循环的工作原理,你就可以详细探索几个示例了。

13.1.1. 只包含宏任务的示例

JavaScript 的单线程执行模型不可避免的结果是,一次只能执行一个任务。这反过来意味着所有创建的任务都必须在队列中等待,直到轮到它们执行。

让我们关注一个简单的网页,它包含以下内容:

  • 非平凡的常规(全局)JavaScript 代码

  • 两个按钮和两个非平凡的点击处理器,每个按钮一个

下面的列表显示了示例代码。

列表 13.1. 事件循环演示的一个任务队列的伪代码

这个例子需要一些想象力,所以我们不把不必要的代码添加到代码片段中,而是要求你想象以下内容:

  • 我们的主线 JavaScript 代码执行需要 15 毫秒。

  • 第一次点击事件处理器运行了 8 毫秒。

  • 第二次点击事件处理器运行了 5 毫秒。

现在让我们继续发挥想象力,假设我们有一个超级快的用户,在脚本开始执行后的 5 毫秒点击了第一个按钮,在 12 毫秒后点击了第二个按钮。图 13.2 描述了这种情况。

图 13.2. 这个时间图显示了事件如何随着它们的发生被添加到任务队列中。当一个任务执行完毕时,事件循环将其从队列中移除,并继续执行下一个任务。

这里有很多信息需要消化,但完全理解它将更好地帮助你了解事件循环是如何工作的。在图的上部,时间(以毫秒为单位)沿着 x 轴从左到右运行。时间线之下的矩形代表正在执行的 JavaScript 代码的部分,它们的持续时间与它们运行的时间相同。例如,第一块主线 JavaScript 代码执行了大约 15 毫秒,第一个点击处理程序大约 8 毫秒,第二个点击处理程序大约 5 毫秒。时序图还显示了某些事件发生的时间;例如,第一个按钮点击发生在应用程序执行 5 毫秒时,第二个按钮点击发生在 12 毫秒时。图的底部显示了应用程序执行过程中各个时间点的宏任务队列的状态。

程序开始执行主线 JavaScript 代码。立即,两个元素 firstButtonsecondButton 被从 DOM 中获取,两个函数 firstHandlersecondHandler 被注册为点击事件处理程序:

firstButton.addEventListener("click", function firstHandler(){...});
secondButton.addEventListener("click", function secondHandler(){...});

接着是执行了另外 15 毫秒的代码。在这段执行过程中,我们的快速用户在程序开始执行后 5 毫秒点击了 firstButton,并在 12 毫秒后点击了 secondButton

由于 JavaScript 基于单线程执行模型,点击 firstButton 并不意味着点击处理程序会立即执行。(记住,如果一个任务正在执行,它不能被另一个任务中断。)相反,与 firstButton 相关的点击事件被放置在任务队列中,它耐心地等待轮到它执行。当 secondButton 被点击时,也会发生同样的事情:一个匹配的事件被放置在任务队列中,等待执行。请注意,事件检测和添加到任务队列发生在事件循环之外;即使在主线 JavaScript 代码执行的同时,任务也会被添加到任务队列中。

如果我们在脚本执行 12 毫秒时对任务队列进行快照,我们将看到以下三个任务:

  1. 评估主线 JavaScript 代码——当前正在执行的任务。

  2. 点击 firstButton——当点击 firstButton 时创建的事件。

  3. 点击 secondButton——当点击 secondButton 时创建的事件。

这些任务也显示在图 13.3 中。

图 13.3. 在应用程序执行 12 毫秒时,任务队列中有三个任务:一个用于评估主线 JavaScript 代码(当前正在执行的任务),以及每个按钮点击事件一个。

图片描述

应用程序执行中的下一个有趣点发生在 15 毫秒,此时主线 JavaScript 代码完成执行。如图 13.1 所示,在任务完成执行后,事件循环继续处理微任务队列。因为在这种情况下我们没有微任务(我们甚至在图中没有显示微任务队列,因为它总是空的),所以我们跳过这一步,继续更新 UI。在这个例子中,尽管更新发生了并且花费了一些时间,但为了简单起见,我们将其排除在我们的讨论之外。这样,事件循环完成第一次迭代,并开始第二次迭代,移动到队列中的下一个任务。

接下来,firstButton点击任务开始执行。图 13.4 说明了应用程序执行 15 毫秒时的任务队列。与firstButton点击相关的firstHandler执行大约需要 8 毫秒,处理程序被执行到完成,没有中断,而与secondButton相关的点击事件正在队列中等待。

图 13.4.应用程序执行 15 毫秒时的任务队列包含两个点击事件的任务。第一个任务目前正在执行。

13fig04.jpg

接下来,在 23 毫秒时,firstButton点击事件被完全处理,匹配的任务从任务队列中移除。同样,浏览器检查微任务队列,它仍然是空的,并在必要时重新渲染页面。

最后,在第三次循环迭代中,正在处理secondButton点击事件,如图 13.5 所示。secondHandler大约需要 5 毫秒来执行,在此之后,任务队列最终在 28 毫秒时为空。

图 13.5.应用程序开始执行后 23 毫秒,只有一个任务,即处理secondButton点击事件,需要执行。

13fig05_alt.jpg

这个例子强调,如果其他任务正在处理中,事件必须等待其轮次才能被处理。例如,尽管secondButton点击发生在应用程序执行的 12 毫秒处,但匹配的处理程序在大约 23 毫秒处被调用。

现在让我们将这段代码扩展到包括微任务。

13.1.2. 包含宏任务和微任务的例子

现在你已经看到了事件循环如何针对一个任务队列工作,我们将扩展我们的例子,也包括一个微任务队列。最干净的方法是在第一个按钮点击处理程序中包含一个 promise,以及在其解析后处理 promise 的代码。如你从第六章中回忆的那样,promise 是我们尚未拥有但将来会拥有的值的占位符;它是对我们最终将知道异步计算结果的保证。因此,promise 处理程序,我们通过 promise 的then方法附加的回调,总是异步调用的,即使我们将其附加到已解析的 promise 上。

下面的列表显示了此双队列示例的修改后的代码。

列表 13.2. 我们带有两个队列的事件循环演示的伪代码

340fig01_alt.jpg

在这个例子中,我们假设发生的行为与第一个例子相同:

  • firstButton 在 5 毫秒后被点击。

  • secondButton 在 12 毫秒后被点击。

  • firstHandler 处理 firstButton 的点击事件,并运行 8 毫秒。

  • secondHandler 处理 secondButton 的点击事件,并运行 5 毫秒。

唯一的不同之处在于,这次,在 firstHandler 代码中,我们还创建了一个立即解决的承诺,并将一个运行时间为 4 毫秒的回调传递给它。因为承诺代表一个我们通常不知道的未来的值,所以承诺处理程序总是异步处理的。

说实话,在这种情况下,我们已经创建了一个立即解决的承诺,JavaScript 引擎可以立即调用回调,因为我们已经知道承诺已经成功解决。但是,为了保持一致性,JavaScript 引擎并不这样做,而是将所有承诺回调异步地调用,在执行完其余的 firstHandler 代码(运行时间为 8 毫秒)之后。它是通过创建一个新的微任务并将其推入微任务队列来做到这一点的。让我们来探索这个执行过程的时序图,如图 13.6 所示。

图 13.6. 如果微任务被排入微任务队列,它将获得优先级,即使队列中已经有一个更早的任务在等待,它也会被处理。在这种情况下,承诺成功微任务比 secondButton 点击事件任务具有优先级。

13fig06_alt.jpg

这个时序图与上一个例子的图相似。如果我们对应用程序执行 12 毫秒时的任务队列进行快照,我们会看到队列中确切的相同任务:主线 JavaScript 代码正在处理,而处理 firstButton 点击和 secondButton 点击的任务正在等待它们的轮次(就像在图 13.3 中一样)。但在任务队列之外,在这个例子中我们还将关注微任务队列,在应用程序执行 12 毫秒时,微任务队列仍然是空的。

应用程序执行中的下一个有趣点发生在 15 毫秒,此时主线 JavaScript 执行结束。因为一个任务已经完成执行,事件循环检查微任务队列,该队列是空的,如果需要,则继续页面渲染。为了简单起见,我们不包括渲染片段在我们的时序图中。

在事件循环的下一个迭代中,正在处理与 firstButton 点击相关联的任务:

firstButton.addEventListener("click", function firstHandler(){
    Promise.resolve().then(() => {
      /*Some promise handling code that runs for 4ms*/
    });
    /*Some click handle code that runs for 8ms*/
  });

firstHandler 函数通过调用 Promise.resolve() 并传入一个一定会被调用的回调函数来创建一个已解决的承诺,因为承诺已经解决。这会创建一个新的微任务来运行回调代码。微任务被放入微任务队列中,而点击处理程序继续执行另外 8 毫秒。当前任务队列的状态显示在 图 13.7 中。

图 13.7. 在第一个点击处理程序执行期间,创建了一个已解决的承诺。这将在微任务队列中排一个承诺成功微任务,该任务将尽可能快地执行,但不会打断当前正在运行的任务。

图片 13.7

firstButton 点击完全处理并从任务队列中移除后,应用执行 23 毫秒时,我们再次回顾任务队列。

在这一点上,事件循环必须选择下一个要处理的任务。我们有一个处理 secondButton 点击的宏任务,该任务在应用执行 12 毫秒时放入任务队列,还有一个处理承诺成功的微任务,该任务在应用执行大约 15 毫秒时放入微任务队列。

如果考虑这些因素,secondButton 点击任务首先被处理似乎是公平的,但正如我们之前提到的,微任务是应该尽快执行的小任务。微任务有优先级,如果你回顾 图 13.1,你会看到每次处理任务时,事件循环首先检查微任务队列,目标是处理所有微任务,然后再继续渲染或其他任务。

因此,承诺成功任务在 firstButton 点击后立即执行,即使“较老”的 secondButton 点击任务仍然在任务队列中等待,如 图 13.8 所示。

图 13.8. 任务执行后,事件循环处理微任务队列中的所有任务。在这种情况下,在移动到 secondButton 点击任务之前,处理了承诺成功任务。

图片 13.8

有一个重要的观点我们需要强调。在宏任务执行完毕后,事件循环立即转向处理微任务队列,直到微任务队列为空之前不允许渲染。只需看看 图 13.9 中的时间图即可。

图 13.9. 在两个宏任务(主线 JavaScript 执行和第一个点击处理程序)之间可以重新渲染页面,而不能在执行微任务之前(在承诺处理程序之前)进行渲染。

图片 13.9

图 13.9 显示,在两个宏任务之间可以发生重新渲染,前提是它们之间没有微任务。在我们的情况下,页面可以在主线 JavaScript 执行和第一个点击处理程序之间渲染,但不能在第一个点击处理程序之后立即渲染,因为微任务(如承诺处理程序)的优先级高于渲染。

在微任务之后也可以发生渲染,但前提是微任务队列中没有其他微任务等待。在我们的例子中,在承诺处理程序发生之后,但在事件循环移动到第二个点击处理程序之前,浏览器可以重新渲染页面。

注意,没有任何东西可以阻止成功承诺的微任务将其他微任务排队,并且所有这些微任务都将优先于 secondButton 点击任务。事件循环只有在微任务队列为空时才会重新渲染页面并移动到 secondButton 任务,所以请小心!

现在你已经了解了事件循环的工作原理,让我们来看看一组特殊的事件:定时器。

13.2. 驯服定时器:超时和间隔

定时器,JavaScript 中一个常被误用且理解不深的特性,如果使用得当,可以增强复杂应用程序的开发。定时器使我们能够通过至少一定的毫秒数延迟执行一段代码。我们将利用这一功能将长时间运行的任务分解成更小的任务,这样就不会阻塞事件循环,从而停止浏览器渲染,在这个过程中使我们的网络应用变得缓慢且无响应。

但首先,我们将从检查我们可以用来构建和操作定时器的函数开始。浏览器提供了两种创建定时器的方法:setTimeoutsetInterval。浏览器还提供了两种相应的方法来清除(或移除)它们:clearTimeoutclearInterval。所有这些都是 window(全局上下文)对象的方法。与事件循环类似,定时器不是在 JavaScript 本身中定义的;相反,它们由宿主环境(例如客户端的浏览器或服务器的 Node.js)提供。表 13.1 列出了创建和清除定时器的方法。

表 13.1. JavaScript 的定时器操作方法(全局 window 对象的方法)
方法 格式 描述
setTimeout id = setTimeout(fn,delay) 启动一个定时器,该定时器将在指定的延迟时间过后执行一次传入的回调函数。返回一个唯一标识定时器的值。
clearTimeout clearTimeout(id) 如果定时器尚未触发,则取消(清除)由传入值标识的定时器。
setInterval id = setInterval(fn,delay) 启动一个定时器,该定时器将在指定的延迟间隔内不断尝试执行传入的回调函数,直到取消。返回一个唯一标识定时器的值。
clearInterval clearInterval(id) 取消(清除)由传递的值标识的间隔计时器。

这些方法允许我们设置和清除计时器,这些计时器要么一次性触发,要么在指定的时间间隔内周期性触发。在实践中,大多数浏览器允许你使用clearTimeoutclearInterval来取消这两种类型的计时器,但如果只是为了清晰,建议使用匹配的成对方法。

注意

重要的是要理解计时器的延迟是不保证的。这与事件循环有很大关系,我们将在下一节中看到。

13.2.1. 事件循环中的计时器执行

你已经详细地检查了事件发生时会发生什么。但是计时器与标准事件不同,所以让我们探索一个与之前看到的类似的例子。下面的列表显示了用于此例的代码。

列表 13.3.我们的超时和间隔演示的伪代码

这次我们只有一个按钮,但我们还注册了两个计时器。首先,我们注册了一个在 10 毫秒后到期的超时:

setTimeout(function timeoutHandler(){
  /*Some timeout handler code that runs for 6ms*/
}, 10);

作为处理者,那个超时有一个执行时间为 6 毫秒的功能。接下来,我们还注册了一个每 10 毫秒就到期的间隔:

setInterval(function intervalHandler(){
  /*Some interval handler code that runs for 8ms*/
}, 10);

间隔有一个处理程序,它需要 8 毫秒来执行。我们继续注册一个按钮点击事件处理程序,它需要 10 毫秒来执行:

const myButton = document.getElementById("myButton");
myButton.addEventListener("click", function clickHandler(){
   /*Some click handler code that runs for 10ms*/
});

这个例子以一个运行约 18 毫秒(再次,请稍等片刻,想象一些复杂的代码)的代码块结束。

现在,假设我们有一个快速且不耐烦的用户在应用程序执行 6 毫秒时点击了按钮。图 13.10 显示了执行前 18 毫秒的时序图。

图 13.10。一个显示示例程序中 18 毫秒执行时间的时序图。第一个,当前正在运行的任务是评估主线 JavaScript 代码。它需要 18 毫秒来执行。在执行过程中,发生了三个事件:鼠标点击、计时器到期和间隔触发。

如前所述,队列中的第一个任务是执行主线 JavaScript 代码。在这个过程中,大约需要 18 毫秒来完成,发生了三件重要的事情:

  1. 在 0 毫秒时,启动了一个 10 毫秒延迟的超时计时器,并启动了一个 10 毫秒延迟的间隔计时器。它们的引用被浏览器保留。

  2. 在 6 毫秒时,鼠标被点击。

  3. 在 10 毫秒时,超时计时器到期,第一个间隔触发。

正如我们从事件循环探索中已经知道的,一个任务总是运行到完成,不能被另一个任务中断。相反,所有新创建的任务都被放置在一个队列中,它们耐心地等待轮到它们被处理。当用户在应用程序执行 6 毫秒时点击按钮,该任务被添加到任务队列中。在大约 10 毫秒时,当计时器到期并触发间隔时,也会发生类似的事情。计时器事件,就像输入事件(如鼠标事件)一样,被放置在任务队列中。请注意,计时器和间隔都是以 10 毫秒的延迟启动的,并且在此期间,它们的匹配任务被放置在任务队列中。我们稍后会回到这一点,但现在你只需要注意到任务是以处理程序注册的顺序添加到队列中的:首先是超时处理程序,然后是间隔处理程序。

初始代码块在 18 毫秒后完成执行,因为这次执行中没有微任务,浏览器可以重新渲染页面(再次省略了我们的时间讨论,因为简单),并进入事件循环的第二次迭代。此时任务队列的状态在图 13.11 中显示。

图 13.11。计时器事件在到期时被放置到任务队列中。

当初始代码块在 18 毫秒时结束执行,有三个代码块被排队等待执行:点击处理程序、超时处理程序和间隔处理程序的第一次调用。这意味着等待的点击处理程序(我们假设它需要 10 毫秒来执行)开始执行。图 13.12 显示了另一个时间图。

图 13.12。如果一个间隔事件被触发,并且一个任务已经与该间隔关联并等待在队列中,则不会添加新的任务。相反,就像在 20 毫秒和 30 毫秒的队列中所示,什么都不会发生。

与只触发一次的setTimeout函数不同,setInterval函数会一直触发,直到我们明确清除它。所以,在大约 20 毫秒时,另一个间隔被触发。通常,这会创建一个新的任务并将其添加到任务队列中。但这次,因为一个间隔任务的实例已经排队并等待执行,这次调用被丢弃。浏览器不会为特定的间隔处理程序排队超过一个实例

点击处理程序在 28 毫秒时完成,在事件循环进入下一次迭代之前,浏览器再次被允许重新渲染页面。在事件循环的下一个迭代中,在 28 毫秒时,处理超时任务。但回想一下这个示例的开始。我们使用以下函数调用来设置一个应该在 10 毫秒后到期的超时:

setTimeout(function timeoutHandler(){
  /*Some timeout handle code that runs for 6ms*/
}, 10);

因为这是我们应用程序中的第一个任务,所以期待超时处理程序在 10 毫秒后正好执行并不奇怪。但正如你在图 13.11 中看到的,超时是在 28 毫秒标记开始的!

正因如此,我们在说定时器提供的能力可以异步延迟代码执行至少一定数量的毫秒时格外小心。由于 JavaScript 的单线程特性,我们只能控制定时器任务何时被添加到队列,而不能控制它何时最终执行!现在我们已经澄清了这个小难题,让我们继续处理应用程序执行的其余部分。

超时任务需要 6 毫秒来执行,所以它应该在应用程序执行的 34 毫秒时完成。在这个时间段内,在 30 毫秒时另一个间隔触发,因为我们已经安排它每 10 毫秒执行一次。再次,没有额外的任务被排队,因为间隔处理程序执行的匹配任务已经在队列中等待。在 34 毫秒时,超时处理程序完成,浏览器再次有机会重新渲染页面并进入事件循环的另一个迭代。

最后,间隔处理程序在 34 毫秒开始执行,比它被添加到事件队列的 10 毫秒标记晚了 24 毫秒。这再次强调了我们将作为参数传递给setTimeout(fn, delay)setInterval(fn, delay)函数的延迟,只指定了匹配任务添加到队列后的延迟时间,而不是执行的确切时间。

间隔处理程序需要 8 毫秒来执行,所以在它执行的过程中,另一个间隔在 40 毫秒标记处到期。这次,因为间隔处理程序正在执行(而不是在队列中等待),一个新的间隔任务最终被添加到任务队列中,并且应用程序的执行继续,如图 13.13 所示。将setInterval延迟设置为 10 毫秒并不意味着我们的处理程序会每 10 毫秒执行一次。例如,因为任务被排队,单个任务执行的时间可能有所不同,所以间隔可以一个接一个地执行,就像 42 毫秒和 50 毫秒标记处的间隔一样。

图 13.13。由于鼠标点击和超时处理程序造成的延误,间隔处理程序每 10 毫秒开始执行需要一些时间。

图片

最后,经过 50 毫秒后,我们的间隔稳定下来,并且每 10 毫秒执行一次。需要记住的重要概念是事件循环一次只能处理一个任务,而且我们永远不能确定定时器处理程序会正好在我们期望的时候执行。这一点在间隔处理程序中尤其如此。在这个例子中,尽管我们安排了在 10、20、30、40、50、60 和 70 毫秒标记处触发的间隔,但回调却在 34、42、50、60 和 70 毫秒标记处执行。在这种情况下,我们完全丢失了其中两个,而且有些并没有在预期的时间执行。

如我们所见,间隔有一些特殊的考虑因素,这些因素不适用于超时。让我们更仔细地看看这些。

超时和间隔之间的差异

初看之下,一个间隔可能看起来像是一个周期性重复的时间暂停。但差异要深得多。让我们通过一个例子来更好地说明setTimeoutsetInterval之间的区别:

图片描述

这两段代码可能看起来在功能上是等效的,但它们并不是。值得注意的是,代码的setTimeout变体在之前的回调执行后始终至少有 10 毫秒的延迟(取决于事件队列的状态,它可能最终会更多,但绝不会更少),而setInterval将尝试在每次回调执行后每 10 毫秒执行一次。而且,正如你在上一节中的示例中所看到的,间隔可以立即连续触发,而不管延迟是多少。

我们知道,超时回调并不保证在触发时正好执行。它不会像间隔那样每 10 毫秒触发一次,而是在执行完毕后重新安排 10 毫秒后执行。

所有这些知识都极其重要。了解 JavaScript 引擎如何处理异步代码,尤其是在平均页面中通常发生的众多异步事件,为构建高级应用程序代码奠定了坚实的基础。

在掌握了所有这些知识之后,让我们看看我们对计时器和事件循环的理解如何帮助我们避免一些性能陷阱。

13.2.2. 处理计算密集型处理

JavaScript 的单线程特性可能是复杂 JavaScript 应用程序开发中最大的陷阱。当 JavaScript 正在执行时,浏览器中的用户交互最多只能变得缓慢,最坏的情况是变得无响应。浏览器可能会出现卡顿或似乎挂起的情况,因为当 JavaScript 执行时,页面的所有更新都会暂停。

如果我们希望保持界面响应,将所有超过几百毫秒的复杂操作简化为可管理的部分变得必要。此外,大多数浏览器会在脚本连续运行至少 5 秒后,弹出一个对话框警告用户脚本已变得“无响应”,而一些其他浏览器甚至会静默地终止运行超过 5 秒的任何脚本。

你可能参加过一次家庭聚会,其中一位健谈的叔叔不停地说个不停,坚持一遍又一遍地讲述同样的故事。如果没有人有机会打断并说上几句,对话对任何人来说都不会愉快(除了布鲁斯叔叔)。同样,占用所有处理时间的代码会导致结果不尽如人意;产生无响应的用户界面永远不是好事。但几乎肯定会出现需要我们处理大量数据的情况,例如操作几千个 DOM 元素。

在这些场合,计时器可以救命,并且变得特别有用。因为计时器能够有效地挂起 JavaScript 代码的执行,直到稍后时间,它们也可以将单个代码片段分解成不会使浏览器挂起的不够长的时间段。考虑到这一点,我们可以将密集的循环和操作转换为非阻塞操作。

让我们看看以下可能需要花费很长时间的任务示例。

列表 13.4。一个长时间运行的任务

在这个例子中,我们总共创建了 240,000 个 DOM 节点,用包含 6 个单元格的 20,000 行填充表格,每个单元格都包含一个文本节点。这非常昂贵,并且在执行过程中很可能会使浏览器挂起一段时间,从而阻止用户进行正常交互(就像布鲁斯叔叔在家庭聚会上主导谈话一样)。

我们需要定期让布鲁斯叔叔闭嘴,这样其他人就有机会加入谈话。在代码中,我们可以引入计时器来创建这样的“谈话中断”,如下一个列表所示。

列表 13.5。使用计时器分解长时间运行的任务

在这个对示例的修改中,我们将长时间操作分解为四个更小的操作,每个操作都创建自己的 DOM 节点。这些较小的操作不太可能中断浏览器的流程,如图 13.14 所示。请注意,我们已经将其设置为将控制操作的数据值收集到易于调整的变量中(rowCountdivideIntochunkSize),如果我们需要将操作分解为,比如说,四个部分而不是十个部分。

图 13.14。使用计时器将长时间运行的任务分解为不会阻塞事件循环的小任务。

还需要注意的是,我们需要一点数学知识来跟踪我们在前一次迭代中留下的位置,base = chunkSize * iteration,以及我们如何自动安排下一次迭代,直到我们确定已经完成:

if (iteration < divideInto)
    setTimeout(generateRows, 0);

令人印象深刻的是,为了适应这种新的异步方法,代码需要做的改动非常小。我们必须做一点额外的工作来跟踪正在发生的事情,确保操作正确执行,并安排执行部分。但除此之外,代码的核心看起来与我们开始时相似。

注意

在这种情况下,我们为超时延迟使用了0。如果你密切关注事件循环的工作方式,你就会知道这并不意味着回调将在 0 毫秒后执行。相反,这是一种告诉浏览器的方式,请尽快执行此回调;但与微任务不同,你可以在其中进行页面渲染。这允许浏览器更新 UI,并使我们的 Web 应用更加响应。

从用户的角度来看,这种技术最明显的变化是,长时间的浏览器挂起被替换为四(或我们选择的任何数量)页面的视觉更新。尽管浏览器会尽可能快地执行代码段,但它也会在每个定时器步骤之后渲染 DOM 更改。在代码的原始版本中,它需要等待一次大的批量更新。

大多数时候,这些类型的更新对用户来说是不可察觉的,但重要的是要记住它们确实发生了。我们应该努力确保我们引入页面的任何代码都不会明显中断浏览器的正常操作。

这种技术有多么有用往往令人惊讶。通过理解事件循环的工作原理,我们可以绕过单线程浏览器环境的限制,同时仍然为用户提供愉悦的体验。

现在我们已经理解了事件循环以及定时器在处理复杂操作中的作用,让我们更仔细地看看事件本身是如何工作的。

13.3. 处理事件

当某个事件发生时,我们可以在我们的代码中处理它。正如你在本书中多次看到的,注册事件处理器的一种常见方式是使用内置的 addEventListener 方法,如下面的列表所示。

列表 13.6. 注册事件处理器

图片

在这个片段中,我们定义了一个名为 myButton 的按钮,并通过从所有元素可访问的内置 addEventListener 方法注册了一个点击事件处理器。

在点击事件发生后,浏览器会调用相关处理器,在本例中是 myHandler 函数。浏览器会将一个包含我们可用于获取更多事件信息的属性的事件对象传递给此处理器,例如鼠标的位置或被点击的鼠标按钮,如果我们处理的是鼠标点击事件,或者按下的键,如果我们处理的是键盘事件。

传入事件对象的属性之一是 target 属性,它引用了事件发生的元素。

注意

就像在大多数其他函数中一样,在事件处理器中,我们可以使用 this 关键字。人们常常口语化地说,在事件处理器中,this 关键字指的是事件发生的对象,但正如我们很快就会发现的,这并不完全正确。相反,this 关键字指的是事件处理器已被注册的元素。坦白说,在大多数情况下,事件处理器已被注册的元素确实是事件发生的元素,但也有一些例外。我们很快就会探讨这些情况。

在进一步探索这个概念之前,让我们设定场景,以便你可以看到事件是如何通过 DOM 传播的。

13.3.1. 通过 DOM 传播事件

如我们已在第二章中了解到的,在 HTML 文档中,元素以树状结构组织。一个元素可以有零个或多个子元素,每个元素(除了根html元素)恰好有一个父元素。现在,假设我们正在处理一个页面,其中包含另一个元素内的元素,并且这两个元素都有点击处理器,如下所示。

列表 13.7. 嵌套元素和点击处理器

图片

图片

这里我们有两个 HTML 元素,outerContainerinnerContainer,它们,就像所有其他 HTML 元素一样,包含在我们的全局document中。并且在这三个对象上,我们注册了一个点击处理器。

现在假设用户点击了innerContainer元素。因为innerContainer包含在outerContainer元素中,而这两个元素都包含在document中,所以很明显,这将触发所有三个事件处理器的执行,输出三条消息。不明显的是事件处理器的执行顺序。

我们应该遵循事件注册的顺序吗?应该从事件发生元素开始向上移动?还是应该从顶部开始向下移动到目标元素?在浏览器最初做出这些决定的时候,两大主要竞争对手,网景和微软,做出了相反的选择。

在网景的事件模型中,事件处理从顶层元素开始,逐级向下传递到事件目标元素。在我们的例子中,事件处理器的执行顺序如下:document点击处理器,outerContainer点击处理器,最后是innerContainer点击处理器。这被称为事件捕获

微软选择了另一种方式:从目标元素开始,向上冒泡到 DOM 树。在我们的例子中,事件的执行顺序如下:innerContainer点击处理器,outerContainer点击处理器,以及document点击处理器。这被称为事件冒泡

由万维网联盟([www.w3.org/TR/DOM-Level-3-Events/](http://www.w3.org/TR/DOM-Level-3-Events/))制定的标准,被所有现代浏览器实现,它包含了两种方法。事件处理分为两个阶段:

  1. 捕获阶段— 事件首先在顶层元素被捕获,然后逐级向下传递到目标元素。

  2. 冒泡阶段— 在捕获阶段到达目标元素后,事件处理切换到冒泡,事件再次从目标元素向上冒泡到顶层元素。

这两个阶段在图 13.15 中展示。

图 13.15. 在捕获中,事件逐级向下传递到目标元素。在冒泡中,事件从目标元素向上冒泡。

图片

我们可以通过向 addEventListener 方法添加另一个布尔参数轻松地决定我们想要使用的事件处理顺序。如果我们使用 true 作为第三个参数,事件将被捕获,而如果我们使用 false(或省略该值),事件将冒泡。因此,从某种意义上说,W3C 标准略微更倾向于事件冒泡而不是事件捕获,因为冒泡已经成为默认选项。

现在,让我们回到 代码列表 13.7 并仔细看看我们注册事件的方式:

outerContainer.addEventListener("click", () => {
  report("Outer container click");
});

innerContainer.addEventListener("click", () => {
  report("Inner container click");
});

document.addEventListener("click", () => {
  report("Document click");
});

如你所见,在这三种情况下,我们只使用两个参数调用了 addEventListener 方法,这意味着选择了默认方法,即 冒泡。因此,在这种情况下,如果我们点击 innerContainer 元素,事件处理程序将按以下顺序执行:innerContainer 点击处理程序,outerContainer 点击处理程序,document 点击处理程序。

让我们按照以下方式修改 代码列表 13.7 中的代码。

列表 13.8. 捕获与冒泡

这次,我们将 outerContainer 的事件处理程序设置为捕获模式(通过传递 true 作为第三个参数),而将 innerContainer(通过传递 false 作为第三个参数)和 document 的事件处理程序设置为冒泡模式(省略第三个参数选择默认的冒泡模式)。

如你所知,单个事件可以触发多个事件处理程序的执行,其中每个处理程序可以是捕获模式或冒泡模式。因此,事件首先从顶层元素开始,逐级向下到事件目标元素。当达到目标元素时,冒泡模式被激活,事件从目标元素冒泡回顶部。

在我们这个例子中,捕获从顶部开始,从 window 对象开始,逐级向下到 innerContainer 元素,目的是找到所有在捕获模式下具有此点击事件事件处理程序的所有元素。只找到一个元素,即 outerContainer,其匹配的点击事件处理程序作为第一个事件处理程序执行。

事件继续沿着捕获路径向下移动,但没有找到更多具有捕获的事件处理程序。当事件达到事件目标元素,即 innerContainer 元素时,事件进入冒泡阶段,从目标元素一路冒泡回顶部,执行该路径上所有冒泡事件处理程序。

在我们这个例子中,innerContainer 的点击事件处理程序将作为第二个事件处理程序执行,而 document 的点击事件处理程序将作为第三个。点击 innerContainer 元素生成的输出以及所采取的路径显示在 图 13.16 中。

图 13.16. 首先,事件从顶部逐级向下,执行所有捕获模式的事件处理程序。当达到目标元素时,事件向上冒泡到顶部,执行所有冒泡模式的事件处理程序。

这个例子展示的一个事情是,处理事件的元素不必是事件发生的元素。例如,在我们的情况下,事件发生在innerContainer元素上,但我们可以处理它在上层 DOM 层次结构中的元素,比如outerContainerdocument元素。

这带我们回到了事件处理程序中的this关键字,以及为什么我们明确指出this关键字指的是事件处理程序注册的元素,而不一定是事件发生的元素。

再次,让我们修改我们的运行示例,如下所示。

列表 13.9. 事件处理程序中thisevent.target之间的区别

再次,让我们看看当点击innerContainer时应用程序的执行情况。因为两个事件处理程序都使用事件冒泡(在addEventListener方法中没有设置第三个参数为true),首先调用innerContainer点击处理程序。在处理程序的主体中,我们检查this关键字和event.target属性是否都指向innerContainer元素:

assert(this === innerContainer,
       "This refers to the innerContainer");
assert(event.target === innerContainer,
       "event.target refers to the innerContainer");

this关键字指向innerContainer元素,因为这是当前处理程序已注册的元素,而event.target属性指向innerContainer元素,因为这是事件发生的元素。

接下来,事件冒泡到outerContainer处理程序。这次,this关键字和event.target指向不同的元素:

assert(this === outerContainer,
       "This refers to the outerContainer");
assert(event.target === innerContainer,
       "event.target refers to the innerContainer");

如预期,this关键字指向outerContainer元素,因为这是当前处理程序已注册的元素。另一方面,event.target属性指向innerContainer元素,因为这是事件发生的元素。

现在我们已经了解了事件是如何通过 DOM 树传播的,以及如何访问事件最初发生的元素,让我们看看如何应用这些知识来编写更节省内存的代码。

将事件委托给祖先

假设我们想要通过最初为每个单元格显示白色背景,然后在单元格被点击后改变背景颜色为黄色,来视觉上指示用户是否点击了表格内的单元格。听起来足够简单。我们可以遍历所有单元格,并在每个单元格上建立处理程序来改变背景颜色属性:

const cells = document.querySelectorAll('td');
for (let n = 0; n < cells.length; n++) {
  cells[n].addEventListener('click', function(){
    this.style.backgroundColor = 'yellow';
  });
}

虽然这可行,但这是否优雅?不。我们在可能成百上千个元素上建立了完全相同的事件处理程序,它们都做完全相同的事情

一个更加优雅的方法是在比单元格更高的级别上建立一个单一的处理程序,使用事件冒泡来处理所有事件。

我们知道所有单元格都将是其包含表格的后代,我们也知道我们可以通过 event.target 获取被点击元素的引用。将事件处理委托给表格要优雅得多,如下所示:

在这里,我们创建了一个处理程序,它可以轻松地处理更改表格中被点击的所有单元格的背景颜色的任务。这要高效得多,也更优雅。

使用事件委托,我们必须确保它只应用于事件目标元素的祖先元素。这样,我们才能确保事件最终会冒泡到已委托处理器的元素。

到目前为止,我们一直在处理浏览器提供的事件,但你有没有热烈地渴望过能够触发你自己的 自定义 事件?

13.3.2. 自定义事件

想象一个场景,你想要执行一个操作,但你希望在不同的代码片段中,在多种条件下触发它,也许甚至是从共享脚本文件中的代码触发。新手会在需要的地方重复代码。熟练工会创建一个全局函数并在需要的地方调用它。忍者会使用自定义事件。但为什么?

松耦合

假设我们在共享代码中执行操作,并且我们想在特定条件需要响应时让页面代码知道。如果我们使用熟练工的全局函数方法,我们会引入一个缺点,即我们的共享代码需要为函数定义一个固定的名称,并且所有使用共享代码的页面都需要使用这样的函数。

此外,如果在触发条件发生时需要执行多项操作呢?为多个通知留出空间将会是艰巨且必然混乱的。这些缺点是 紧密耦合 的结果,在这种耦合中,检测条件的代码必须知道将对此条件做出反应的代码的详细信息。

另一方面,松耦合 发生在触发条件的代码对将对此条件做出反应的代码一无所知的情况下,甚至不知道是否有什么东西会做出反应。事件处理程序的一个优点是我们可以建立尽可能多的处理程序,并且这些处理程序之间完全独立。因此,事件处理是松耦合的一个很好的例子。当按钮点击事件被触发时,触发事件的代码对我们已经在页面上建立的处理器一无所知,甚至不知道是否存在任何处理器。相反,点击事件由浏览器推送到任务队列,而触发事件的任何东西都不会关心之后会发生什么。如果已经为点击事件建立了处理器,它们最终将以完全独立的方式逐个调用。

紧耦合有很多优点。在我们的场景中,当共享代码检测到有趣的条件时,它会触发某种类型的信号,表示“发生了这个有趣的事情;任何感兴趣的人都可以处理它”,而且它根本不在乎是否有人感兴趣。让我们考察一个具体的例子。

一个 Ajax 示例

让我们假设我们已经编写了一些共享代码,这些代码将执行 Ajax 请求。使用这些代码的页面希望在 Ajax 请求开始和结束时得到通知;每个页面在发生这些“事件”时都有它需要做的事情。

例如,在一个使用此包的页面上,我们希望在 Ajax 请求开始时显示一个旋转的风扇,当请求完成时隐藏它,以便给用户提供一些视觉反馈,表明请求正在处理。如果我们把开始条件想象成一个名为 ajax-start 的事件,把停止条件想象成 ajax-complete,如果我们能在页面上为这些事件建立显示和隐藏图像的处理程序,那岂不是很好?

考虑以下内容:

document.addEventListener('ajax-start', e => {
  document.getElementById('whirlyThing').style.display = 'inline-block';
});
document.addEventListener('ajax-complete', e => {
  document.getElementById('whirlyThing').style.display = 'none';
});

很遗憾,这些事件并不存在,但没有任何阻止我们使它们成为现实。

创建自定义事件

自定义事件是模拟(对于我们的共享代码的用户)真实事件体验的一种方式,但这个事件在我们应用程序的上下文中具有业务意义。下面的列表显示了一个触发自定义事件的示例。

列表 13.10. 使用自定义事件

图片

图片

在这个例子中,我们通过建立前一个章节中描述的场景来探索自定义事件:在 Ajax 操作进行时显示一个动画风扇图像。操作是由按钮的点击触发的。

以完全解耦的方式,为名为 ajax-start 的自定义事件创建了一个处理程序,同样为 ajax-complete 自定义事件也创建了一个处理程序。这些事件的处理程序分别显示和隐藏风扇图像:

button.addEventListener('click', () => {
  performAjaxOperation();
});

document.addEventListener('ajax-start', e => {
  document.getElementById('whirlyThing').style.display = 'inline-block';
  assert(e.detail.url === 'my-url', 'We can pass in event data');
});

document.addEventListener('ajax-complete', e => {
    document.getElementById('whirlyThing').style.display = 'none';
});

注意,这三个处理程序彼此之间一无所知。特别是,按钮点击处理程序在显示和隐藏图像方面没有责任。

Ajax 操作本身是通过以下代码模拟的:

function performAjaxOperation() {
  triggerEvent(document, 'ajax-start', { url: 'my-url'});
  setTimeout(() => {
    triggerEvent(document, 'ajax-complete');
  }, 5000);
}

该函数触发 ajax-start 事件并发送关于事件的数据(url 属性),假装即将发起一个 Ajax 请求。然后该函数发出一个 5 秒的超时,模拟一个跨越 5 秒的 Ajax 请求。当计时器到期时,我们假装响应已经返回,并触发一个 ajax-complete 事件,表示 Ajax 操作已完成。

注意这个例子中高度的解耦。共享的 Ajax 操作代码不知道当事件被触发时页面代码会做什么,甚至不知道是否真的有页面代码来触发。页面代码被模块化成小的处理程序,它们之间互不了解。此外,页面代码对共享代码如何执行一无所知;它只是对可能或可能不被触发的事件做出反应。

这种程度的解耦有助于保持代码模块化,更容易编写,当出现问题时也更容易调试。它还使得共享代码片段变得容易,并且可以在不违反代码片段之间耦合依赖的情况下移动它们。解耦是使用自定义事件时的基本优势,它允许我们以更表达性和灵活的方式开发应用程序。

13.4. 摘要

  • 事件循环任务代表浏览器执行的动作。任务分为两类:

    • 宏任务是一些离散的、自包含的浏览器操作,例如创建主文档对象、处理各种事件以及进行 URL 更改。

    • 微任务是一些应该尽快执行的小任务。例如,包括承诺回调和 DOM 变更。

  • 由于单线程执行模型,任务一次处理一个,一旦任务开始执行,就不能被另一个任务中断。事件循环通常至少有两个事件队列:宏任务队列和微任务队列。

  • 定时器提供了一种异步延迟代码执行至少一些毫秒数的能力。

    • 使用 setTimeout 函数在指定的延迟时间过后执行回调。

    • 使用 setInterval 函数启动一个定时器,该定时器将在指定的延迟间隔内尝试执行回调,直到被取消。

    • 两个函数都返回一个定时器的 ID,我们可以通过 clearTimeoutclearInterval 函数来取消定时器。

    • 使用定时器将计算密集型代码分解成可管理的块,这样就不会阻塞浏览器。

  • DOM 是一个元素分层树,一个元素(目标)上发生的事件通常通过 DOM 传播。有两种传播机制:

    • 在事件捕获中,事件从顶层元素逐级向下传递到目标元素。

    • 在事件冒泡中,事件从目标元素逐级向上冒泡到顶层元素。

  • 当调用事件处理器时,浏览器也会传递一个事件对象。通过事件对象的 target 属性访问事件发生的元素。在处理器内部,使用 this 关键字来引用已注册处理器的元素。

  • 使用通过内置的 CustomEvent 构造函数创建的自定义事件,并通过 dispatchEvent 方法分发,以减少应用程序不同部分之间的耦合。

13.5. 练习

1

为什么将任务添加到任务队列中发生在事件循环之外很重要?

2

为什么事件循环的每一迭代不需要超过大约 16 毫秒很重要?

3

运行以下代码 2 秒后,输出结果是什么?

setTimeout(function(){
  console.log("Timeout ");
}, 1000);

setInterval(function(){
  console.log("Interval ");
}, 500);
  1. Timeout Interval Interval Interval Interval
  2. Interval Timeout Interval Interval Interval
  3. Interval Timeout Timeout

4

运行以下代码 2 秒后的输出结果是什么?

const timeoutId = setTimeout(function(){
  console.log("Timeout ");
}, 1000);

setInterval(function(){
  console.log("Interval ");
}, 500);

clearTimeout(timeoutId);
  1. Interval Timeout Interval Interval Interval
  2. Interval
  3. Interval Interval Interval Interval

5

运行以下代码并点击 ID 为inner的元素后,输出结果是什么?

<body>
  <div id="outer">
    <div id="inner"></div>
  </div>
  <script>
    const innerElement = document.querySelector("#inner");
    const outerElement = document.querySelector("#outer");
    const bodyElement = document.querySelector("body");

    innerElement.addEventListener("click", function(){
       console.log("Inner");
    });

    outerElement.addEventListener("click", function(){
       console.log("Outer");
    }, true);

    bodyElement.addEventListener("click", function(){
       console.log("Body");
    })
  </script>
</body>
  1. Inner Outer Body
  2. Body Outer Inner
  3. Outer Inner Body

第十四章. 开发跨浏览器策略

本章涵盖

  • 开发可重用的跨浏览器 JavaScript 代码

  • 分析需要解决的跨浏览器问题

  • 以智能方式解决这些问题

任何在页面上开发 JavaScript 代码的人都知道,在确保代码在一系列支持的浏览器中完美运行时,存在广泛的痛点。这些考虑因素从满足基本开发需求,到为未来的浏览器发布做准备,再到在尚未创建的网页上重用代码,涵盖了所有这些。

为多个浏览器编写代码是一个非平凡的任务,必须根据你现有的开发方法和项目可用的资源进行平衡。尽管我们希望我们的页面能够在所有曾经存在或将来存在的浏览器中完美运行,但现实总会露出其丑陋的一面,我们必须认识到我们的开发资源是有限的。我们必须计划适当地、谨慎地应用这些资源,以获得最大的效益。

因此,我们从这个章节开始,提供关于选择支持哪些浏览器的建议。接下来是关于跨浏览器开发的主要问题的讨论,以及处理这些问题的有效策略。让我们直接进入如何谨慎选择支持浏览器的途径。

你知道吗?

Q1:

处理不同浏览器之间行为不一致的常见方法有哪些?

Q2:

最好的方法是什么,可以使你的代码在别人的页面上可用?

Q3:

为什么 polyfills 在跨浏览器脚本中很有用?

14.1. 跨浏览器考虑因素

完善我们的 JavaScript 编程技能将使我们受益匪浅,尤其是现在 JavaScript 已经摆脱了浏览器的限制,并在 Node.js 服务器上使用。但当我们开发基于浏览器的 JavaScript 应用程序(本书的重点)时,迟早会直面浏览器及其各种问题和不一致性。

在一个完美的世界里,所有浏览器都不会有错误,并且会一致地支持网络标准,但正如我们所知,我们并不生活在这个世界里。尽管近年来浏览器的质量有了很大提高,但所有浏览器仍然存在一些错误、缺失的 API 和浏览器特有的怪癖,我们需要处理这些问题。制定一个全面策略来应对这些浏览器问题,并深入了解它们之间的差异和怪癖,并不亚于掌握 JavaScript 本身的重要性。

在编写浏览器应用程序时,选择支持哪些浏览器很重要。我们可能希望支持所有浏览器,但开发和测试资源的限制决定了否则。那么我们如何决定支持哪些,以及支持到什么程度?

我们可以采用的一种方法是借鉴了较老的 Yahoo!方法,即分级浏览器支持。在这种技术中,我们创建一个浏览器支持矩阵,作为浏览器及其平台对我们需求重要性的快照。在这个表格中,我们列出目标平台在一轴上,浏览器在另一轴上。然后,在表格单元格中,我们对每个浏览器/平台组合给出一个“等级”(A 到 F,或任何满足我们需求的评分系统)。表 14.1 显示了假设的例子。

表 14.1. 一个假设的浏览器支持矩阵
Windows OS X Linux iOS Android
IE 9 N/A N/A N/A N/A
IE10 N/A N/A N/A N/A
IE11 N/A N/A N/A N/A
Edge N/A N/A N/A N/A
Firefox N/A
Chrome
Opera
Safari N/A N/A

注意,我们还没有填写任何等级。你分配给特定平台和浏览器组合的等级完全取决于你项目的需求和需求,以及其他重要因素,如目标受众的构成。我们可以使用这种方法来制定衡量该平台/浏览器支持重要性的等级,并将这些信息与支持的成本结合起来,试图找出最佳的支持浏览器组合。

当我们选择支持一个浏览器时,我们通常做出以下承诺:

  • 我们将使用我们的测试套件积极测试该浏览器。

  • 我们将修复与该浏览器相关的错误和回归。

  • 浏览器将以合理的性能执行我们的代码。

由于针对众多平台/浏览器组合进行开发不切实际,我们必须权衡支持各种浏览器所带来的成本与收益。这种分析必须考虑多个因素,其中主要因素如下:

  • 目标受众的期望和需求

  • 浏览器的市场份额

  • 支持浏览器所需的工作量

第一点是一个主观的观点,只有你的项目可以确定。另一方面,市场份额通常可以使用可用信息来衡量。通过考虑浏览器的功能和它们对现代标准的遵守程度,可以确定支持每个浏览器所需的大致工作量。

图 14.1 展示了一个代表浏览器使用信息(2016 年 4 月从 gs.statcounter.com 获取)的样本图表。任何可重用的 JavaScript 代码,无论是大众消费的 JavaScript 库还是我们自己的页面代码,都应该开发成在尽可能多的环境中工作,专注于对最终用户重要的浏览器和平台。对于大众消费的库,这是一个很大的集合;对于更针对性的应用,所需的集合可能更窄。

图 14.1. 观察桌面和移动设备上的浏览器使用统计,我们可以了解哪些浏览器需要我们关注。

14fig01_alt.jpg

但是,不要贪多嚼不烂,质量永远不应该为了覆盖面而牺牲。这一点很重要,值得重复;实际上,我们敦促你大声朗读:

质量永远不应该为了覆盖面而牺牲

在本章中,我们将探讨 JavaScript 代码在跨浏览器支持方面可能遇到的情况。然后,我们将探讨编写该代码的一些最佳方法,目的是减轻这些情况可能带来的任何潜在问题。这应该有助于你决定哪些技术值得你投入时间采用,并帮助你完善自己的浏览器支持图表。

14.2. 五大主要开发关注点

任何非平凡代码都包含无数的开发关注点。但五个主要点对我们可重用 JavaScript 代码构成了最大的挑战,如图 14.2 所示。

图 14.2. 可重用 JavaScript 开发关注的五大要点

14fig02.jpg

这些是五个要点:

  • 浏览器错误

  • 浏览器错误修复

  • 外部代码

  • 浏览器回归

  • 浏览器中缺失的功能

我们将想要平衡我们在每个点上花费的时间与由此产生的收益。最终,这些问题是你必须回答的,将它们应用于你自己的情况。分析你的目标受众、开发资源和时间表都是影响你决策的因素。

当努力开发可重用 JavaScript 代码时,我们必须考虑所有这些要点,但最密切的关注点是目前最流行的浏览器,因为这些最有可能被我们的目标受众使用。对于其他不太受欢迎的浏览器,我们至少要确保我们的代码能够优雅地降级。例如,如果一个浏览器不支持某个 API,至少我们应该小心,确保我们的代码不会抛出任何异常,这样其余的代码就可以执行。

在接下来的几节中,我们将分解这些各种关注点,以更好地理解我们所面临的挑战以及如何应对它们。

14.2.1. 浏览器错误和差异

在开发可重用 JavaScript 代码时,我们需要处理与我们所决定支持的浏览器集合相关的各种错误和 API 差异。尽管如今浏览器更加统一,但我们在代码中提供的任何功能都应该在所有我们选择的浏览器中完全且可验证地正确。

实现这一点的方法很简单:我们需要一套全面的测试来覆盖代码的常见和边缘用例。有了良好的测试覆盖率,我们可以放心地知道我们开发的代码将在支持的浏览器集合中工作。并且假设后续的浏览器更改不会破坏向后兼容性,我们将有一种温暖而舒适的感觉,认为我们的代码甚至可以在那些浏览器的未来版本中工作。我们将在第 14.3 节中查看处理浏览器错误和差异的具体策略。

在所有这些中,一个棘手的问题是,以这种方式实施对当前浏览器错误的修复,使得它们能够抵抗未来浏览器版本中实施的任何针对这些错误的修复。

14.2.2. 浏览器错误修复

假设浏览器将永远存在某个特定错误是愚蠢的——大多数浏览器错误最终都会得到修复,依赖于错误的持续存在是一种危险的开发策略。最好使用第 14.3 节中的技术,以确保任何错误绕过策略尽可能具有未来性。

当编写一段可重用 JavaScript 代码时,我们希望确保它能够长期存在。就像编写网站的任何方面(CSS、HTML 等)一样,我们不希望因为新浏览器的发布而不得不回过头来修复损坏的代码。

对浏览器错误的假设会导致一种常见的网站故障形式:为了绕过浏览器提出的错误而实施的特定黑客手段,当浏览器在未来版本中修复这些错误时,这些手段会失效。

处理浏览器错误的问题有两个方面:

  • 当最终实施错误修复时,我们的代码可能会出现故障。

  • 我们可能会训练浏览器供应商修复错误,以免造成网站故障。

第二种情况的一个有趣的例子最近刚刚发生,就是scrollTop错误(dev.opera.com/articles/fixing-the-scrolltop-bug/)。

当处理 HTML DOM 中的元素时,我们可以使用scrollTopscrollLeft属性来访问和修改元素的当前滚动位置。但如果我们对根元素html使用这些属性,根据规范,这些属性应该报告(并影响)视口的滚动位置。IE 11 和 Firefox 严格遵循这个规范。不幸的是,Safari、Chrome 和 Opera 不遵循。相反,如果你尝试修改根元素html的这些属性,什么都不会发生。为了在这些浏览器中达到相同的效果,我们必须使用body元素的scrollTopscrollLeft属性。

面对这种不一致性,网页开发者们常常求助于检测当前浏览器的名称(通过用户代理字符串,关于这一点稍后详述),然后如果我们的 JavaScript 代码在 IE 或 Firefox 中执行,就修改html元素的scrollTopscrollLeft,如果代码在 Safari、Chrome 或 Opera 中执行,就修改body元素。不幸的是,这种绕过这个错误的方法已经被证明是灾难性的。因为现在许多页面明确编码“如果是 Safari、Chrome 或 Opera”,就修改body元素,这些浏览器实际上无法修复这个错误,因为修复错误反而会导致许多网页失败。

这又提出了关于错误的一个重要观点:在确定某个功能是否可能是错误时,始终要使用规范来验证它!

浏览器错误也与未指定的 API 不同。参考浏览器规范很重要,因为它们提供了浏览器开发和完善代码的确切标准。相比之下,未指定 API 的实现可能会在任何时候发生变化(尤其是如果实现试图成为标准化的情况下)。在未指定 API 的不一致情况下,你应该始终测试你期望的输出。始终意识到,随着它们变得稳固,这些 API 可能会发生未来的变化。

此外,错误修复和 API 更改之间还有一个区别。虽然错误修复很容易预见——浏览器最终会修复其实施中的错误,即使这需要很长时间——但 API 更改很难发现。标准 API 不太可能更改(尽管并非完全不可能);更改更有可能发生在未指定的 API 上。

幸运的是,这种情况很少发生到足以严重破坏大多数 Web 应用的程度。但如果确实发生了,那么在事先是难以检测到的(除非,当然,我们测试了我们接触过的每一个 API——但这样的过程所造成的开销将是荒谬的)。这种类型的 API 更改应像处理任何其他回归一样处理。

对于我们下一个关注点,我们知道没有人是一座孤岛,我们的代码也不例外。让我们探讨其影响。

14.2.3. 外部代码和标记

任何可重用代码都必须与周围的代码共存。无论我们期望我们的代码在我们自己编写的页面上工作,还是在他人开发的网站上工作,我们都需要确保它可以在页面上与任何其他随机代码共存。

这是一把双刃剑:我们的代码不仅必须能够承受与可能编写不良的外部代码共存,而且必须注意不要对其共存的代码产生不利影响。

我们需要对此关注点保持多高的警惕性,很大程度上取决于我们期望代码被使用的环境。例如,如果我们正在编写适用于单个或有限数量网站的可重用代码,并且我们对这些网站有一定程度的控制,那么可能不需要过于担心外部代码的影响,因为我们知道代码将在哪里运行,并且我们可以在一定程度上自行修复任何问题。

小贴士

这是一个足够重要的问题,足以写成一整本书。如果您想深入了解,我们强烈推荐本·文尼加和安东·科瓦洛夫的《第三方 JavaScript》(Manning,2013 年,www.manning.com/books/third-party-javascript)。

如果我们正在开发将在未知(且不可控)环境中广泛应用的代码,我们需要确保我们的代码是健壮的。让我们讨论一些实现这一目标的策略。

封装我们的代码

为了防止我们的代码影响加载页面上的其他代码片段,最好的做法是实践封装。一般来说,这指的是将某物放入或仿佛放入胶囊中的行为。一个更专注于特定领域的定义是“一种语言机制,用于限制对某些对象组件的访问。”您的阿姨玛蒂尔达可能会更简洁地总结为“管好自己的事!”

当我们将代码引入页面时,保持极小的全局影响范围可以极大地让阿姨玛蒂尔达感到满意。事实上,将我们的全局影响范围限制在几个全局变量,或者更好,一个全局变量,相当容易。

正如你在第十二章中看到的,jQuery,最受欢迎的客户端 JavaScript 库,是这种做法的一个很好的例子。它引入了一个名为jQuery的全局变量(一个函数),以及该全局变量的一个别名$。它甚至有支持将$别名返回给页面上其他代码或其他库的途径。

几乎 jQuery 中的所有操作都是通过jQuery函数完成的。它提供的任何其他函数(称为实用函数)都被定义为jQuery的属性(记得从第三章中了解到,定义其他函数的属性是多么容易),因此使用jQuery作为所有定义的命名空间

我们可以使用相同的策略。假设我们正在定义一组函数,供我们自己使用或供他人使用,我们将它们分组在我们自己选择的命名空间下——比如说,ninja

我们可以像 jQuery 一样,定义一个名为ninja()的全局函数,该函数根据我们传递给函数的内容执行各种操作。例如:

var ninja = function(){ /* implementation code goes here */ }

定义我们自己的实用函数,这些函数使用此函数作为它们的命名空间,是很容易的:

ninja.hitsuke = function(){ /* code to distract guards with fire here */ }

如果我们不希望或需要ninja是一个函数,而只想将其作为命名空间使用,我们可以如下定义它:

var ninja = {};

这将创建一个空对象,我们可以在其中定义属性和函数,以避免将这些名称添加到全局命名空间中。

为了保持我们的代码封装,我们还想避免的其他做法是修改任何现有的变量、函数原型,甚至 DOM 元素。我们代码修改的页面任何方面,除了自身之外,都可能是潜在的冲突和混淆区域。

双向街道的另一边是,即使我们遵循最佳实践并仔细封装我们的代码,我们也不能保证我们没有编写的代码会表现得很好。

处理不尽如人意的代码

有一个古老的笑话,自从 Grace Hopper 在白垩纪时期从继电器中移除那只蛾子以来一直在流传:“唯一不糟糕的代码就是你自己写的代码。”这听起来可能有些愤世嫉俗,但当我们无法控制的代码与我们的代码共存时,为了安全起见,我们应该假设最坏的情况。

一些代码,即使写得很好,也可能故意进行修改函数原型、对象属性和 DOM 元素方法等操作。这种做法,无论初衷如何,都可能为我们设置陷阱。

在这种情况下,我们的代码可能在进行一些无害的操作,例如使用 JavaScript 数组,而且没有人会因为我们简单地假设 JavaScript 数组会像 JavaScript 数组那样表现而责怪我们。但如果页面上其他代码修改了数组的工作方式,我们的代码可能最终无法按预期工作,而这绝对不是我们的错。

不幸的是,在处理这类情况时,并没有很多坚定不移的规则,但我们可以采取一些缓解措施。接下来的几节将介绍这些防御性步骤。

应对贪婪的 ID

大多数浏览器都表现出一种反功能(我们不能称之为bug,因为这种行为是完全有意的),这可能导致我们的代码意外地出错。这个功能通过使用原始元素的idname属性将元素引用添加到其他元素中。当那个idname与元素已包含的属性冲突时,就会发生不好的事情。

看一下以下 HTML 片段,以观察这些贪婪的 ID可能导致的恶劣后果:

<form id="form" action="/conceal">
  <input type="text" id="action"/>
  <input type="submit" id="submit"/>
</form>

现在,在浏览器中,我们将其称为:

var what = document.getElementById('form').action;

正确地,我们期望这是表单的action属性的值。在大多数情况下,它确实是。但如果我们检查变量what的值,我们会发现它实际上是对input#action元素的引用! huh?

让我们试试别的:

document.getElementById('form').submit();

这个语句应该会导致表单提交,但相反,我们得到了一个脚本错误:

Uncaught TypeError: Property 'submit' of object #<HTMLFormElement> is not a function

发生了什么?

浏览器为表单中的每个输入元素添加了属性,这些属性引用了该元素。一开始这看起来很方便,直到我们意识到添加的属性名称是从输入元素的idname值中取来的。如果这个值恰好是表单元素的已使用属性,比如actionsubmit,那么这些原始属性就会被新属性所取代。这通常被称为DOM 覆盖

因此,在创建input#submit元素之前,引用form.action指向的是<form>action属性的值。之后,它指向input#submit元素。form.submit也会发生同样的事情。唉!

这是从很久以前遗留下来的,那时浏览器还没有丰富的 API 方法来从 DOM 中获取元素。浏览器供应商添加了这个功能,以便轻松访问表单元素。如今,我们可以轻松访问 DOM 中的任何元素,所以我们只剩下这个功能的遗憾副作用。

无论如何,这个浏览器特有的“功能”可能会在我们的代码中引起众多神秘的问题,我们在调试时应该牢记这一点。当我们遇到看似无法解释地变成了我们预期之外的其他属性时,DOM 覆盖可能是罪魁祸首。

幸运的是,我们可以在自己的标记中避免这个问题,通过避免可能与其他标准属性名冲突的简单idname值,并鼓励他人也这样做。submit值尤其要避免,因为它是一个常见的导致令人沮丧和困惑的故障行为的来源。

样式表和脚本的加载顺序

我们通常期望在代码执行时 CSS 规则已经可用。确保当我们的 JavaScript 代码执行时,由样式表提供的 CSS 规则已定义的最好方法之一是在包含外部脚本文件之前包含外部样式表。

不这样做可能会导致意外结果,因为脚本尝试访问尚未定义的样式信息。不幸的是,这个问题不能轻易地用纯 JavaScript 修复,而应该通过用户文档来处理。

这些最后几个部分介绍了一些基本示例,说明了外部因素如何影响我们的代码工作,通常是以无意和令人困惑的方式。当其他用户尝试将其集成到“他们”的网站时,我们代码的问题通常会浮出水面,此时我们应该能够诊断这些问题并构建适当的测试来处理它们。在其他时候,当我们将他人的代码集成到我们的页面中时,希望这些部分中的提示有助于识别原因。

很不幸,除了采取一些明智的初步步骤并编写防御性代码之外,没有更好的和确定性的解决方案来处理这些集成问题。我们现在将转向下一个关注点。

14.2.4. 回归

“回归”是我们将在创建可重用和可维护的 JavaScript 代码过程中遇到的最困难的问题之一。这些是错误,或者非向后兼容的 API 更改(主要是对未指定的 API),浏览器已经引入,并以不可预测的方式导致代码中断。

注意

在这里,我们使用“回归”这个术语的经典定义:曾经起作用但现在不再按预期工作的功能。这通常是无意的,但有时是由故意改变现有代码而导致的。

预测变化

有一些 API 更改,通过一些预见性,我们可以主动检测和处理,如列表 14.1 所示。例如,在 Internet Explorer 9 中,微软引入了对 DOM 2 级事件处理程序的支持(使用addEventListener方法绑定),而之前的 IE 版本则使用 IE 特定的内置attachEvent方法。对于在 IE 9 之前编写的代码,简单的功能检测能够处理这种变化。

列表 14.1. 预测即将到来的 API 更改

图片

在这个例子中,我们使代码具有未来兼容性,知道(或抱着一线希望)有一天微软会将 Internet Explorer 纳入 DOM 标准。如果浏览器支持符合标准的 API,我们使用功能检测来推断这一点,并使用标准 API,即addEventListener方法。如果不支持,我们检查 IE 专有的方法attachEvent是否可用,并使用它。如果所有其他方法都失败,我们就不做任何事情。

很遗憾,大多数未来的 API 更改并不容易预测,而且无法预测即将出现的错误。这正是我们在本书中强调测试的一个重要原因。面对将影响我们代码的不可预测的变化,我们所能期望的最好的事情就是勤勉地监控每个浏览器版本的测试,并迅速解决回归可能引入的问题。

拥有一个好的测试套件并密切关注即将发布的浏览器版本是处理此类未来回归的绝对最佳方式。这不需要对您的正常开发周期造成负担,您的开发周期应该已经包括常规测试。在新的浏览器版本上运行这些测试应该始终纳入任何开发周期的规划中。

您可以从以下位置获取有关即将发布的浏览器版本的信息:

勤勉很重要。因为我们永远无法完全预测浏览器将引入的错误,所以最好确保我们始终关注我们的代码,并迅速避免可能出现的任何危机。

幸运的是,浏览器供应商正在做很多事情以确保这种类型的回归不会发生,浏览器通常将来自各种 JavaScript 库的测试套件集成到它们的主浏览器测试套件中。这确保了不会引入任何未来的回归,这些回归会直接影响到这些库。尽管这不会捕获所有回归(当然,在所有浏览器中都不会),但这是一个很好的开始,显示了浏览器供应商在尽可能防止尽可能多的问题方面取得的良好进展。

在本节中,我们已经讨论了开发可重用 JavaScript 的四个主要关注点:浏览器错误、浏览器错误修复、外部代码和浏览器回归。第五个关注点——浏览器中缺失的功能——值得特别提及,因此我们在下一节中将其与其他适用于跨浏览器 Web 应用的实现策略一起介绍。

14.3. 实施策略

了解需要关注的问题只是战斗的一半。找出有效的解决方案并使用它们来实现健壮的跨浏览器代码是另一回事。

一系列策略可供选择,尽管并非每个策略都适用于每种情况,但本节中提供的策略涵盖了我们需要在健壮的代码库中解决的大多数问题。让我们从一件既简单又几乎无麻烦的事情开始。

14.3.1. 安全的跨浏览器修复

最简单(也是最安全)的跨浏览器修复类型是那些具有两个重要特性的类型:

  • 它们对其他浏览器没有负面影响或副作用。

  • 他们不使用任何形式的浏览器或功能检测。

应用这些修复的实例可能很少,但它们是我们应该在应用程序中始终追求的策略。

让我们来看一个例子。以下代码片段代表了一个更改(从 jQuery 中摘取),这是在处理 Internet Explorer 时出现的:

// ignore negative width and height values
if ((key == 'width' || key == 'height') && parseFloat(value) < 0)
  value = undefined;

一些版本的 IE 在将负值设置为 heightwidth 样式属性时抛出异常。所有其他浏览器都忽略负输入。这个解决方案忽略了所有浏览器中的所有负值。这个更改防止了在 Internet Explorer 中抛出异常,并且对任何其他浏览器都没有影响。这是一个无痛苦的更改,为用户提供了一个统一的 API(因为抛出意外的异常是不希望的)。

这种类型修复的另一个例子(也来自 jQuery)出现在属性操作代码中。考虑以下内容:

if (name == "type" &&
    elem.nodeName.toLowerCase()== "input" &&
    elem.parentNode)
  throw "type attribute can't be changed";

Internet Explorer 不允许我们操作已包含在 DOM 中的输入元素的 type 属性;尝试更改此属性会导致抛出专有异常。jQuery 达成了一个折中方案:它禁止在所有浏览器中注入输入元素上的 type 属性的所有尝试,并抛出一个统一的信息性异常。

对 jQuery 代码库的这种更改不需要浏览器或功能检测;它在所有浏览器中统一了 API。操作仍然会导致异常,但这个异常在所有浏览器类型中都是统一的。

这种特定的方法可能会被认为是具有争议性的。它故意限制了在所有浏览器中库的功能,因为只有一个浏览器存在一个错误。jQuery 团队仔细权衡了这个决定,并认为拥有一个始终如一工作的统一 API 比在开发跨浏览器代码时意外中断的 API 更好。在开发自己的可重用代码库时,你可能会遇到这种情况,你需要仔细考虑这种限制方法是否适合你的受众。

对于这些类型的代码更改,重要的是要记住,它们提供了一个在浏览器之间无缝工作的解决方案,无需进行浏览器或功能检测,实际上使它们免受未来更改的影响。你应该始终努力寻求以这种方式工作的解决方案,即使适用实例很少。

14.3.2. 功能检测和填充

正如我们之前讨论的,特征检测是在编写跨浏览器代码时常用的一种方法。这种方法不仅简单,而且通常有效。它通过确定是否存在某个对象或对象属性,并假设它提供了隐含的功能来实现。 (在下一节中,我们将看到当这个假设失败时应该怎么做。)

最常见的是,特征检测用于在提供重复功能的多重 API 之间进行选择。例如,第十章(kindle_split_022.html#ch10)探讨了所有数组都可以访问的find方法,我们可以使用它来找到满足特定条件的第一个数组项。不幸的是,这个方法只能在完全支持 ES6 的浏览器中使用。那么当我们遇到仍然不支持这个特性的浏览器时,我们该怎么办?一般来说,我们如何处理浏览器中缺失的功能?

答案是 polyfill!polyfill 是一种浏览器回退。如果一个浏览器不支持特定的功能,我们就提供自己的实现。例如,Mozilla 开发者网络(MDN)为广泛的 ES6 功能提供了 polyfill。其中之一就是Array.prototype.find方法的 JavaScript 实现(mng.bz/d9lU),如下面的列表所示。

列表 14.2. Array.prototype.find方法的 polyfill

图片

在这个例子中,我们首先使用特征检测来检查当前浏览器是否内置了对find方法的支持:

if (!Array.prototype.find) {
 ...
}

在可能的情况下,我们应该默认使用执行任何操作的标准方式。如前所述,这将有助于使我们的代码尽可能具有未来性。因此,如果浏览器已经支持该方法,我们就不做任何事情。如果我们处理的是尚未赶上 ES6 的浏览器,我们提供自己的实现。

结果表明,该方法的核心非常简单。我们遍历数组,调用传入的谓词函数,该函数检查数组项是否满足我们的标准。如果满足,我们就返回它。

在这个列表中,介绍了一种有趣的技巧:

var length = list.length >>> 0;

>>>运算符是零填充右移运算符,它将第一个操作数向右移动指定的位数,同时丢弃多余的位。在这种情况下,这个运算符用于将length属性转换为非负整数。这样做是因为 JavaScript 中的数组索引应该是无符号整数。

特征检测的一个重要用途是发现代码执行的环境提供的浏览器环境设施。这允许我们在代码中使用这些设施提供的功能,或者确定我们是否需要提供回退。

以下代码片段展示了使用功能检测检测浏览器功能是否存在的基本示例,以确定我们是否应该提供完整的应用程序功能或简化体验的回退:

if (typeof document !== "undefined" &&
    document.addEventListener &&
    document.querySelector &&
    document.querySelectorAll) {
  // We have enough of an API to work with to build our application
}
else {
  // Provide Fallback
}

这里,我们测试是否

  • 浏览器已加载文档

  • 浏览器提供了一种绑定事件处理器的方法

  • 浏览器可以根据选择器找到元素

如果任何这些测试失败,我们将不得不求助于回退位置。在回退中做什么取决于代码的消费者期望以及代码的要求。可以考虑以下几种选项:

  • 我们可以执行进一步的功能检测,以确定如何提供一种使用一些 JavaScript 的简化体验。

  • 我们可以选择不执行任何 JavaScript,转而使用页面上的未脚本化 HTML。

  • 我们可以将用户重定向到网站的简化版本。例如,谷歌在 Gmail 中就是这样做的。

由于功能检测几乎没有开销(它只是属性/对象查找)并且实现相对简单,它是在 API 和应用程序级别提供基本回退水平的好方法。它是您可重用代码编写的第一道防线的好选择。

14.3.3. 无法测试的浏览器问题

不幸的是,JavaScript 和 DOM 有几个可能的问题区域,这些区域要么无法测试,要么测试成本过高。这些情况很幸运地很少发生,但当我们遇到它们时,花时间调查看看是否有什么可以做的总是值得的。

以下几节讨论了一些使用任何传统 JavaScript 交互都无法测试的已知问题。

事件处理器绑定

浏览器中令人恼火的一个缺陷是无法通过编程方式确定事件处理器是否已被绑定。浏览器没有提供任何方法来确定是否有任何函数被绑定到元素的事件监听器上。除非我们在创建时维护了对所有绑定处理器的引用,否则无法从元素中移除所有绑定的事件处理器。

事件触发

另一个令人烦恼的问题是确定事件是否会触发。虽然可以确定浏览器是否支持绑定事件的方法,但无法知道浏览器是否会触发事件。这会在几个地方造成问题。

首先,如果脚本在页面已经加载后动态加载,脚本可能会尝试绑定一个监听器等待窗口加载,但实际上那个事件已经发生了。由于无法确定事件是否已经发生,代码可能会无限期地等待执行。

第二种情况发生在脚本想要使用浏览器提供的自定义事件作为替代时。例如,Internet Explorer 提供了mouseentermouseleave事件,这些事件简化了确定用户鼠标进入或离开元素边界的过程。这些事件经常被用作mouseovermouseout事件的替代,因为它们比标准事件更直观。但由于没有方法在绑定事件并等待用户交互之前确定这些事件是否会触发,所以在可重用代码中使用它们很困难。

CSS 属性效果

另一个痛点是确定更改某些 CSS 属性是否会影响显示效果。一些 CSS 属性仅影响显示的视觉表示,而不影响其他任何内容;它们不会改变周围的元素或影响元素上的其他属性。例如,colorbackgroundColoropacity

没有方法可以通过编程方式确定更改这些样式属性是否会生成期望的效果。验证影响的唯一方法是通过页面的视觉检查。

浏览器崩溃

测试导致浏览器崩溃的脚本又是一个烦恼。导致浏览器崩溃的代码特别有问题,因为与可以轻松捕获和处理异常不同,这些代码将始终导致浏览器崩溃。

例如,在 Safari 的旧版本中(见bugs.jquery.com/ticket/1331),创建一个使用 Unicode 字符范围的正则表达式将始终导致浏览器崩溃,如下面的示例所示:

new RegExp("[\\w\u0128-\uFFFF*_-]+");

这个问题的麻烦在于,无法测试这个问题是否存在,因为测试本身将始终在旧浏览器中产生崩溃。

此外,那些导致浏览器永久崩溃的 bug 变得非常棘手,因为尽管对于使用你浏览器的某些用户群体来说禁用 JavaScript 可能是可以接受的,但直接让这些用户的浏览器崩溃是绝对不可接受的。

不一致的 API

一段时间前,我们看到了 jQuery 决定禁止在所有浏览器中更改type属性的能力,这是由于 Internet Explorer 中的一个 bug。我们可以测试这个功能,并仅在 IE 中禁用它,但这会设置一个不一致性,因为 API 在各个浏览器中的工作方式将不同。在这些情况下,当 bug 如此严重以至于导致 API 损坏时,唯一的选择是绕过受影响的区域并提供不同的解决方案。

除了无法测试的问题之外,还有一些问题是可以测试的,但测试起来非常困难。让我们看看其中的一些。

API 性能

有时,特定的 API 在不同的浏览器中可能更快或更慢。在编写可重用和健壮的代码时,尝试使用提供良好性能的 API 很重要。但并不总是明显知道哪个 API 是正确的。

对一个功能进行有效的性能分析通常意味着向其投入大量数据,这通常需要相对较长的时间。因此,这不是我们可以在页面加载时随时进行的事情。

无法测试的特性是编写可重用 JavaScript 的一个重大麻烦,但通常我们可以通过一些努力和巧妙的方法来绕过它们。通过使用替代技术,或者构建我们的 API 以消除这些问题,我们可能会在不利的情况下构建出有效的代码。

14.4. 减少假设

编写跨浏览器、可重用的代码是一场假设的战斗,但通过使用巧妙的检测和编写,我们可以减少我们在代码中做出的假设数量。当我们对我们编写的代码做出假设时,我们可能会在未来的道路上遇到问题。

例如,假设一个问题或错误总是存在于特定浏览器中是一个巨大且危险的做法。相反,测试问题(正如我们在本章中一直所做的那样)证明要有效得多。在我们的编码中,我们应该始终努力减少假设的数量,从而有效地减少错误的空间和事情可能反过来咬我们屁股的概率。

在 JavaScript 中,最常见的假设区域是在用户代理检测方面——具体来说,是分析浏览器提供的用户代理(navigator.userAgent)并据此做出关于浏览器行为的假设(换句话说,浏览器检测)。不幸的是,大多数用户代理字符串分析都证明是未来错误的一个极好来源。假设一个错误、问题或专有特性总是与特定浏览器相关联,这无疑是一个灾难性的做法。

但在减少假设方面,现实总是介入:几乎不可能完全去除所有假设。在某个时刻,我们不得不假设浏览器会做它应该做的事情。确定如何达到这种平衡完全取决于开发者,这也是他们所说的“区分男人和男孩”的地方(向我们的女性读者致歉)。

例如,让我们重新审视本章中已经看到的事件绑定代码:

function bindEvent(element, type, handle) {
  if (element.addEventListener) {
    element.addEventListener(type, handle, false);
  }
  else if (element.attachEvent) {
    element.attachEvent("on" + type, handle);
  }
}

不看后续内容,看看你是否能找出这段代码中做出的三个假设。继续吧,我们会等待的。(*《危险边缘》主题音乐播放中...)

你做得怎么样?前面的代码至少有这三个假设:

  • 我们正在检查的是可调用的函数。

  • 这些是正确的函数,执行我们期望的动作。

  • 这两种方法是绑定事件的唯一可能方式。

我们可以通过添加检查来轻松去除第一个假设,看看这些属性实际上是否是函数。解决剩余的两个点要困难得多。

在这段代码中,我们总是需要决定对我们需求、目标受众和我们自己来说最优化的是多少假设。通常,减少假设的数量也会增加代码库的大小和复杂性。试图将假设减少到完全疯狂的程度是完全可能的,而且相当容易,但到了某个时候,我们必须停下来,评估我们所拥有的,说“足够好”,然后从这里开始工作。记住,即使是假设最少的代码也仍然容易受到浏览器引入的回归的影响。

14.5. 摘要

  • 尽管情况已经大大改善,但不幸的是,浏览器仍然存在错误,并且通常不会一致地支持 Web 标准。

  • 在编写 JavaScript 应用程序时,选择支持哪些浏览器和平台是一个重要的考虑因素。

  • 由于无法支持所有组合,质量永远不应该为了覆盖率而牺牲!

  • 能够在各种浏览器中执行的 JavaScript 代码的最大挑战是错误修复、回归、浏览器错误、缺少功能和外部代码。

  • 可重用跨浏览器开发涉及权衡几个因素:

    • 代码大小— 保持文件大小小

    • 性能开销— 保持性能水平在可接受的最低限度以上

    • API 质量— 确保 API 在所有浏览器中都能统一工作

  • 没有确定这些因素正确平衡的魔法公式。

  • 开发因素是每个开发者在其个人开发工作中必须权衡的东西。

  • 通过使用智能技术,如功能检测,我们可以防御来自多个方向的攻击,而无需做出任何不必要的牺牲。

14.6. 练习

1

决定支持哪些浏览器时,我们应该考虑哪些因素?

2

解释贪婪 ID 的问题。

3

功能检测是什么?

4

浏览器 polyfill 是什么?

附录 A. ES6 新增特性

本附录涵盖

  • 模板字符串

  • 解构

  • 对象字面量增强

本附录涵盖了一些“较小”的 ES6 特性,这些特性不适合放入前面的章节。模板字符串使字符串插值和多行字符串成为可能,解构使我们能够轻松地从对象和数组中提取数据,而增强的对象字面量则改进了与对象字面量的处理。

模板字符串

模板字符串是 ES6 的一个新特性,它使字符串操作比以前更加愉快。回想一下;你有多少次被迫写出像这样丑陋的东西?

const ninja = {
  name: "Yoshi",
  action: "subterfuge"
};

const concatMessage = "Name: " + ninja.name + " "
                    + "Action: " + ninja.action;

在这个例子中,我们必须构建一个包含动态插入数据的字符串。为了实现这一点,我们必须求助于一些混乱的连接。但不再是这样了!在 ES6 中,我们可以使用模板字符串达到相同的结果;只需看看下面的列表。

列表 A.1. 模板字符串

图片

如您所见,ES6 提供了一种新的字符串类型,它使用反引号(`),这种字符串可以包含占位符,用${}语法表示。在这些占位符内,我们可以放置任何 JavaScript 表达式:一个简单的变量、一个对象属性访问(就像我们用ninja.action做的那样),甚至函数调用。

当模板字符串被评估时,占位符会被替换为那些占位符内包含的 JavaScript 表达式的评估结果。

此外,模板字符串不仅限于单行(就像标准的双引号和单引号字符串那样),我们也没有什么阻止我们使它们成为多行的,如下面的列表所示。

列表 A.2. 多行模板字符串

图片

现在我们已经为您简要介绍了模板字符串,让我们看看另一个 ES6 特性:解构。

解构

解构允许我们通过使用模式轻松地从对象和数组中提取数据。例如,假设你有一个你想要将其属性分配给几个变量的对象,如下面的列表所示。

列表 A.3. 解构对象

图片

如列表 A.3 所示,使用对象解构,我们可以轻松地从对象字面量中提取多个变量,一次提取所有。考虑以下语句:

const {name, action, weapon} = ninja;

这创建了三个新变量(nameactionweapon),它们的值分别是语句右侧对象匹配属性的值(ninja.nameninja.actionninja.weapon,分别)。

当我们不想使用对象属性的名称时,我们可以对它们进行微调,如下面的语句所示:

const {name: myName, action: myAction, weapon: myWeapon} = ninja;

在这里,我们创建了三个变量(myNamemyActionmyWeapon)并将指定的对象属性值分配给它们。

之前,我们提到过我们也可以解构数组,因为数组只是对象的一种特殊形式。看看下面的列表。

列表 A.4. 解构数组

图片

解构数组与解构对象略有不同,主要在于语法,因为变量被括号包围(与用于对象解构的大括号相反),如下面的片段所示:

const [firstNinja, secondNinja, thirdNinja] = ninjas;

在这种情况下,第一个忍者Yoshi被分配给变量firstNinjaKuma被分配给变量secondNinjaHattori被分配给变量thirdNinja

数组解构也有一些高级用法。例如,如果我们想跳过某些项,我们可以省略变量名,同时保留逗号,如下面的语句所示:

const [, , third] = ninjas;

在这种情况下,前两个忍者将被忽略,而第三个忍者Hattori的值将被分配给变量third

此外,我们还可以只提取某些项,同时将剩余项分配给一个新数组:

const [first, ...remaining] = ninjas;

第一个项目,Yoshi,被分配给变量first,其余的忍者,KumaHattori,被分配给新的数组remaining。注意,在这种情况下,剩余的项目与剩余参数(...运算符)的标记方式相同。

增强的对象字面量

关于 JavaScript 的伟大之处之一是其使用对象字面量创建对象的简便性:我们定义几个属性,并将它们放在大括号内,然后 voilà,我们就创建了一个新的对象。在 ES6 中,对象字面量语法增加了一些新扩展。让我们看一个例子。假设我们想创建一个ninja对象,并基于作用域内的变量值给它赋一个属性,一个动态计算名称的属性,以及一个方法,如下面的列表所示。

列表 A.5:增强的对象字面量

图片

图片

此示例首先使用旧的预 ES6 对象字面量语法创建一个oldNinja对象:

const name = "Yoshi";
const oldNinja = {
  name: name,
  getName: function(){
    return this.name;
  }
};
oldNinja["old" + name] = true;

我们将此与增强的对象字面量进行对比,后者可以达到完全相同的效果,但语法更简洁:

const newNinja = {
  name,
  getName(){
    return this.name;
  },
  ["new" + name]: true
};

这完成了我们对 ES6 引入的重要新概念的探索。

附录 B. 带上测试和调试的武装

本附录涵盖

  • 调试 JavaScript 代码的工具

  • 生成测试的技术

  • 构建测试套件

  • 概述一些流行的测试框架

本附录介绍了开发客户端网络应用的一些基本技术:调试和测试。构建有效的测试套件对于我们的代码始终很重要。毕竟,如果我们不测试我们的代码,我们怎么知道它是否按我们的意图运行呢?测试为我们提供了一种确保我们的代码不仅能够运行,而且能够正确运行的手段。

此外,尽管一个坚实的测试策略对于所有代码来说都很重要,但在外部因素有可能影响我们代码操作的情况下,它可能是至关重要的,这正是我们在跨浏览器 JavaScript 开发中所面临的情况。我们不仅面临着确保代码质量(尤其是在处理多个开发者共同维护的单一代码库时)的典型问题,还要防范可能导致 API 部分失效的回归(所有程序员都需要处理的通用问题),而且我们还有确定我们的代码在我们选择支持的浏览器中是否都能正常工作的问题。

在本章中,我们将探讨调试 JavaScript 代码的工具和技术,基于这些结果生成测试,并构建一个可靠的测试套件来运行这些测试。让我们开始吧。

网络开发者工具

很长一段时间,JavaScript 应用程序的开发受到了缺乏基本调试基础设施的限制。调试 JavaScript 代码的唯一方法是在代码中散布alert语句,这些语句会通知我们有关被警告表达式的值,这些语句散布在表现异常的代码周围。正如你可能想象的那样,这使得调试(几乎从来不是一项有趣的活动)变得更加困难。

幸运的是,Firefox 的一个扩展——Firebug,在 2007 年被开发出来。Firebug 在许多网络开发者心中占有特殊的位置,因为它是最早提供与最先进的集成开发环境(IDE)中的调试体验相匹配的工具之一,如 Visual Studio 或 Eclipse。此外,Firebug 还激发了为所有主要浏览器开发类似开发者工具的进程:包含在 Internet Explorer 和 Microsoft Edge 中的 F12 开发者工具;包含在 Safari 中的 WebKit Inspector;包含在 Firefox 中的 Firefox 开发者工具;以及包含在 Chrome 和 Opera 中的 Chrome DevTools。让我们稍微探索一下它们。

Firebug

Firebug,第一个高级网络应用调试工具,仅适用于 Firefox,可以通过按 F12 键(或在页面上任何位置右键单击并选择带有 Firebug 的“检查元素”)访问。您可以通过在 Firefox 中打开页面(getfirebug.com/)并按照说明来安装 Firebug。图 B.1 展示了 Firebug。

图 B.1. 仅在 Firefox 中可用的 Firebug 是第一个针对网络应用的高级调试工具。

Firebug 提供了高级调试功能,其中一些功能甚至是由它首创的。例如,我们可以通过使用 HTML 选项卡(如图 B.1 所示的选项卡)轻松地探索 DOM 的当前状态,通过控制台(如图 B.1 底部所示)在当前页面的上下文中运行自定义 JavaScript 代码,通过脚本选项卡探索我们的 JavaScript 代码的状态,甚至可以通过网络选项卡探索网络通信。

Firefox 开发者工具

除了 Firebug 之外,如果你是 Firefox 用户,你可以使用内置的 Firefox DevTools,如图 B.2 所示。正如你所看到的,Firefox 开发者工具的外观和感觉与 Firebug 类似(除了某些细微的布局和标签差异;例如,Firebug 中的 HTML 选项卡在 Firefox 开发者工具中被称为检查器)。

图 B.2. 内置于 Firefox 的 Firefox 开发者工具提供了所有 Firebug 功能以及更多。

Firefox 开发者工具是由 Mozilla 团队构建的,他们通过引入一些额外的有用功能,利用了与 Firefox 的紧密集成。例如,性能选项卡提供了关于我们网络应用性能的详细洞察。此外,Firefox 开发者工具是以现代网络为设计理念的。例如,它们提供了响应式设计模式,这有助于我们探索不同屏幕尺寸下网络应用的外观和感觉——这是我们必须小心注意的,因为如今用户不仅从他们的电脑,还从移动设备、平板电脑甚至电视访问网络应用。

F12 开发者工具

如果你属于 Internet Explorer (IE) 阵营,你可能会很高兴地知道 IE 和 Microsoft Edge(IE 的继任者)提供了他们自己的开发者工具,即 F12 开发者工具。(快速猜测一下哪个键可以切换它们的开和关。)这些工具如图 B.3 所示。

图 B.3. 通过按 F12 键切换的 F12 开发者工具(在 Internet Explorer 和 Edge 中可用)。

再次注意 F12 开发者工具和 Firefox 开发者工具之间的相似性(标签只有细微的差异)。F12 工具还使我们能够探索 DOM 的当前状态(DOM 探索器选项卡,图 B.3),通过控制台运行自定义 JavaScript 代码,调试我们的 JavaScript 代码(调试器选项卡),分析网络流量(网络),处理响应式设计(UI 响应性),以及分析性能和内存消耗(性能分析器和内存)。

WebKit 检查器

如果你是一名 OS X 用户,你可以使用 Safari 提供的 WebKit 检查器,如图 B.4 所示。尽管 Safari 的 WebKit 检查器的用户界面略有不同

图 B.4. 仅在 Safari 中可用的 WebKit 检查器

与 F12 开发者工具或 Firefox 的开发者工具相比,请放心,WebKit 检查器也支持所有重要的调试功能。

Chrome DevTools

我们将用 Chrome DevTools 完成我们对开发者工具的小调查——在我们看来,这是目前推动了许多创新的网络应用开发者工具的旗舰产品。如图 B.5(#app02fig05)所示,其基本用户界面和功能与其他开发者工具类似。

图 B.5. 可在 Chrome 和 Opera 中使用的 Chrome DevTools

在整本书中,我们为了保持一致性,使用了 Chrome DevTools。但如您在本节中看到的,大多数开发者工具都提供了类似的功能(如果其中之一提供了新的功能,其他工具也会很快跟进)。您同样可以轻松地使用您选择的浏览器的开发者工具。

现在您已经了解了可用于调试代码的工具,让我们来探索一些调试技术。

调试代码

在软件开发过程中,很大一部分时间都花在移除烦人的错误上。虽然这有时可能很有趣,几乎像解决一个悬疑故事一样,但通常我们希望代码尽快正确无误且无错误。

调试 JavaScript 有两个重要的方面:

  • 记录,它打印出代码运行时的状态

  • 断点,它允许我们暂时暂停代码的执行并探索应用程序的当前状态

它们都用于回答重要的问题,“我的代码中发生了什么?”但每个都从不同的角度着手。让我们先从记录开始看。

记录

记录语句用于在程序执行期间输出消息,而不会干扰程序的正常流程。当我们向代码中添加记录语句时(例如,使用 console.log 方法),我们可以在浏览器的控制台中看到消息。例如,如果我们想了解在程序执行的某些点上变量 x 的值,我们可能会编写如下列表。

列表 B.1. 在程序执行的各个点上记录变量 x 的值
  <!DOCTYPE html>
1: <html>
2:   <head>
3:    <title>Logging</title>
4:    <script>
5:      var x = 213;
6:      console.log("The value of x is: ", x);
7:
8:      x = "Hello " + "World";
9:      console.log("The value of x is now:", x);
10:   </script>
11:  </head>
12:  <body></body>
13:</html>

图 B.6 展示了在启用 JavaScript 控制台的情况下,在 Chrome 浏览器中执行此代码的结果。

图 B.6. 记录使我们能够看到代码运行时的状态。在这种情况下,我们可以看到从列表 B.1 的第 6 行记录了 213 的值,以及从第 9 行记录了“Hello World”。所有开发者工具,包括此处展示的 Chrome DevTools,都有一个用于记录的“控制台”标签页。

如您所见,浏览器将消息直接记录到 JavaScript 控制台,显示了记录的消息及其所在的行。

这是一个在程序执行的不同点记录变量值的简单示例。但通常,你可以使用日志记录来探索运行中的应用程序的各个方面,例如重要函数的执行、重要对象属性的更改或特定事件的发生。

当代码运行时,日志记录对于查看事物的状态是很好,但有时我们可能想要停止动作并四处看看。这就是断点的作用。

断点

使用 断点 可能比日志记录更复杂,但它们有一个显著的优势:它们会在特定的代码行处暂停脚本的执行,使浏览器暂停。这允许我们在断点处悠闲地调查各种事物的状态。

假设我们有一个页面,它记录了对一位著名忍者的问候,如下所示。

列表 B.2. 一个简单的“问候忍者”页面

假设我们通过在列表 B.2 中调用 logGreeting 函数的注释行上使用 Chrome DevTools 设置断点(通过在调试器面板中点击行号空白处)并刷新页面来执行代码。然后调试器会在该行停止执行并显示 图 B.7 中的显示。

图 B.7. 当我们通过点击行空白处设置代码行的断点并加载页面时,浏览器会在执行该行之前停止执行 JavaScript 代码。然后你可以在右侧的面板中悠闲地探索应用程序的当前状态。

右侧的面板显示了我们的代码正在运行的应用程序的状态,包括 ninja 变量的值(Hattori Hanzo)。调试器在断点之前的行上中断;在这个例子中,调用 logGreeting 函数尚未执行。

进入函数

如果我们正在尝试调试 logGreeting 函数的问题,我们可能想要 进入 该函数以查看其内部的情况。当我们的执行在 logGreeting 调用处暂停(我们之前设置的断点)时,我们点击 Step Into 按钮(在大多数调试器中显示为指向点的箭头)或按 F11,这将导致调试器执行到我们的 logGreeting 函数的第一行。图 B.8 显示了结果。

图 B.8. 进入函数让我们看到函数执行的新状态。我们可以通过研究调用堆栈和局部变量的当前值来探索当前位置。

注意,Chrome DevTools 的外观略有变化(与图 B.7 相比)以允许我们探索 logGreeting 函数执行的应用程序状态。例如,现在我们可以轻松地探索 logGreeting 函数的局部变量,并看到我们有一个值为 Hattori Hanzoname 变量(变量值甚至以内联方式显示,源代码在左侧)。此外,请注意右上角有一个调用堆栈面板,显示我们目前处于由全局代码调用的 logGreeting 函数中。

单步执行和跳出

除了“进入”命令外,我们还可以使用“单步执行”和“跳出”。

“单步执行”命令逐行执行我们的代码。如果执行行中的代码包含函数调用,调试器将跳过该函数(函数将被执行,但调试器不会跳入其代码)。

如果我们暂停了函数的执行,点击“跳出”按钮将执行函数中的代码直到结束,然后调试器将在执行离开该函数后再次暂停。

条件断点

标准断点会在调试器每次达到程序执行中的特定点时停止应用程序执行。在某些情况下,这可能会很累。考虑以下列表。

列表 B.3. 计数忍者与条件断点

假设我们想要探索在计数第 50 个忍者的应用程序状态。在最终到达我们想要的忍者之前,访问所有 49 个忍者会有多累?

欢迎使用条件断点!与传统断点不同,每次断点所在的行被执行时,断点都会停止,而条件断点仅在关联的条件表达式满足时才会导致调试器中断。您可以通过在行号凹槽中右键单击并选择添加来设置条件断点(有关如何在 Chrome 中执行的操作,请参阅图 B.9)。

图 B.9. 在行号边缘右键单击以设置条件断点;注意这些断点以不同的颜色显示,通常是橙色。

通过将表达式:i == 49 与条件断点关联,调试器只有在满足该条件时才会停止。这样,我们可以立即跳转到我们感兴趣的应用程序执行点,而忽略那些不太有趣的点。

到目前为止,你已经看到了如何使用不同浏览器的各种开发者工具来通过日志和断点调试我们的代码。这些都是伟大的工具,帮助我们定位特定的错误,并更好地理解特定应用程序的执行。但除此之外,我们还想建立一个基础设施,以便我们能够尽快检测到错误。这可以通过测试来实现。

创建测试

罗伯特·弗罗斯特写道:“好的篱笆能成为好的邻居”,但在网络应用的世界里,实际上在任何编程领域,好的测试能产生好的代码。注意对“好”这个词的强调。如果测试构建得不好,即使有一个庞大的测试套件,也可能对我们的代码质量毫无帮助。

好的测试表现出三个重要的特征:

  • 可重复性— 我们测试的结果应该高度可重复。重复运行的测试应该始终产生完全相同的结果。如果测试结果是不确定的,我们如何知道哪些结果是有效的,哪些是无效的?此外,可重复性确保我们的测试不依赖于外部因素,例如网络或 CPU 负载。

  • 简洁性— 我们的测试应该专注于测试一个事物。我们应该努力去除尽可能多的 HTML 标记、CSS 或 JavaScript,而不破坏测试用例的意图。我们去除的越多,测试用例受我们测试的具体代码影响的可能性就越大。

  • 独立性— 我们的测试应该独立执行。我们必须避免使一个测试的结果依赖于另一个测试。将测试分解成尽可能小的单元将有助于我们在发生错误时确定错误的准确来源。

我们可以使用各种方法来构建测试。两种主要的方法是分解法和构建法:

  • 分解测试用例— 现有代码被削减(分解)以隔离问题,消除与问题无关的任何内容。这有助于实现之前列出的三个特征。我们可能从一个完整的网站开始,但在去除额外的标记、CSS 和 JavaScript 之后,我们将到达一个较小的案例,可以重现问题。

  • 构建测试用例— 我们从一个已知的好、简化的案例开始,直到我们能够重现所讨论的 bug。要使用这种测试风格,我们需要几个简单的测试文件来构建测试,以及一种使用我们代码的干净副本生成这些新测试的方法。

让我们看看构建测试的一个例子。

在创建简化测试用例时,我们可以从包含最小功能的一些 HTML 文件开始。我们甚至可能为不同的功能区域有不同的起始文件;例如,一个用于 DOM 操作,一个用于 Ajax 测试,一个用于动画,等等。

例如,以下列表显示了一个简单的 DOM 测试用例,用于测试 jQuery。

列表 B.4. jQuery 的简化 DOM 测试用例
<style>
  #test { width: 100px; height: 100px; background: red; }
</style>
<div id="test"></div>
<script src="dist/jquery.js"></script>
<script>
  $(document).ready(function() {
    $("#test").append("test");
  });
</script>

另一个选择是使用为创建简单测试用例而设计的预构建服务,例如 JSFiddle (jsfiddle.net/)、CodePen (codepen.io/) 或 JS Bin (jsbin.com/)。它们都具有类似的功能;它们允许我们构建测试用例,这些测试用例在唯一的 URL 上可用。(你甚至可以包含流行库的副本。)JSFiddle 中的一个示例在 图 B.10 中显示。

图 B.10. JSFiddle 允许我们在沙盒中测试 HTML、CSS 和 JavaScript 片段的组合,以查看是否按预期工作。

b0fig10_alt.jpg

当我们需要快速测试某个概念时,使用 JSFiddle(或类似工具)既方便又实用,尤其是因为你可以轻松地与他人分享,甚至可能得到一些有用的反馈。不幸的是,运行此类测试需要你手动打开测试并检查其结果,如果你只有几个测试,这可能还不错,但通常我们应该有很多很多测试,检查我们代码的每一个角落和缝隙。因此,我们希望尽可能自动化我们的测试。让我们看看如何实现这一点。

测试框架的基本原理

测试框架的主要目的是让我们能够指定可以封装成单个单元的单独测试,以便它们可以批量运行,提供一个可以轻松且重复运行的单一资源。

为了更好地理解测试框架是如何工作的,查看它是如何构建的很有意义。也许令人惊讶的是,JavaScript 测试框架很容易构建。

然而,你可能会问,“我为什么要构建一个新的测试框架?” 对于大多数情况,编写自己的 JavaScript 测试框架并不是必要的,因为已经有很多高质量的框架可用(你很快就会看到)。但构建自己的测试框架可以作为一个很好的学习经历。

断言

单元测试框架的核心是其断言方法,通常命名为 assert。此方法通常接受一个 ——一个前提被 断言 的表达式——以及断言目的的描述。如果该值评估为 true,则断言通过;否则,被视为失败。相关的消息通常与适当的通过/失败指示器一起记录。

这个概念的一个简单实现可以在以下列表中看到。

列表 B.5. 一个简单的 JavaScript 断言实现

404fig01_alt.jpg

命名为 assert 的函数几乎出奇地简单。它创建一个新的 <li> 元素,包含描述,根据断言参数(value)的值分配一个名为 passfail 的类,并将新元素附加到文档体中的列表元素。

测试套件由两个简单的测试组成:一个总是成功,另一个总是失败:

assert(true, "The test suite is running."); //Will always pass
assert(false, "Fail!"); //Will always fail

对于 passfail 类的样式规则使用颜色直观地表示成功或失败。

在 Chrome 中运行我们的测试套件的结果显示在图 B.11 中。

图 B.11. 我们第一个测试套件运行的结果

图片

提示

如果你需要快速的方法,可以使用内置的 console.assert() 方法(见图 B.12)。

图 B.12. 你可以使用内置的 console.assert 作为测试代码的快捷方式。只有当断言失败时,失败信息才会记录到控制台。

图片

现在我们已经构建了自己的基础测试框架,让我们来认识一些广泛可用且更受欢迎的测试框架。

流行的测试框架

一个测试框架应该是你开发工作流程的基本部分,因此你应该选择一个特别适合你的编码风格和代码库的框架。一个 JavaScript 测试框架应该满足单一需求:显示测试结果,并使确定哪些测试通过或失败变得容易。测试框架可以帮助我们达到这个目标,而无需担心除了创建测试并将它们组织成称为 test suites 的集合之外的其他任何事情。

根据测试的需求,我们可能在 JavaScript 单元测试框架中寻找几个功能。以下是一些可能的功能:

  • 能够模拟浏览器行为(点击、按键等)

  • 测试的交互式控制(暂停和恢复测试)

  • 处理异步测试超时

  • 能够过滤要执行哪些测试

让我们来认识目前最流行的两个测试框架:QUnit 和 Jasmine。

QUnit

QUnit 是最初为测试 jQuery 而构建的单元测试框架。它已经超越了最初的目标,现在是一个独立的单元测试框架。QUnit 主要设计为一个简单的单元测试解决方案,提供最小但易于使用的 API。QUnit 的独特特性如下:

  • 简单的 API

  • 支持异步测试

  • 不仅限于 jQuery 或使用 jQuery 的代码

  • 特别适合回归测试

让我们看看以下列表中的 QUnit 测试示例,该示例测试我们是否开发了一个能够准确地对忍者说“Hi”的函数。

列表 B.6. QUnit 测试示例

图片

当你在浏览器中打开此示例时,你应该得到图 B.13 中显示的结果,其中有一个通过断言来自执行 sayHiToNinja("Hatori") 行,还有一个失败的断言来自 assert.ok(false, "Failed")

图 B.13. QUnit 测试运行的一个示例。作为我们测试的一部分,我们有一个通过和一个失败的断言(两个断言中有一个通过,一个失败。)显示的结果对失败的测试给予了更大的关注,以确保我们尽快修复它。

图片

关于 QUnit 的更多信息可以在 qunitjs.com/ 找到。

Jasmine

Jasmine 是另一个流行的测试框架,其构建基础与 QUnit 略有不同。框架的主要部分如下:

  • describe 函数,用于描述测试套件

  • it 函数,用于指定单个测试

  • expect 函数,用于检查单个断言

这些函数的组合和命名旨在使测试套件几乎具有对话性质。例如,以下列表展示了如何使用 Jasmine 测试 sayHiToNinja 函数。

列表 B.7. Jasmine 测试示例

在浏览器中运行此 Jasmine 测试套件的结果如图 B.14 所示。

图 B.14. 在浏览器中运行 Jasmine 测试套件的结果。我们有两个测试:一个通过和一个失败(两个规范,一个失败)。

关于 Jasmine 的更多信息可以在 jasmine.github.io/ 找到。

测量代码覆盖率

要说清楚什么使一个特定的测试套件良好是很困难的。理想情况下,我们应该测试我们程序的所有可能的执行路径。不幸的是,除了最简单的情况外,这是不可能的。朝着正确方向迈出的一步是尝试测试尽可能多的代码,而衡量测试套件覆盖我们代码程度的指标被称为代码覆盖率

例如,如果一个测试套件的代码覆盖率是 80%,这意味着我们的程序代码中有 80% 被测试套件执行,而 20% 的代码没有被执行。尽管我们无法完全确定这 80% 的代码中没有错误(我们可能遗漏了导致错误的执行路径),但我们对于那 20% 没有被执行的代码一无所知。这就是为什么我们应该衡量测试套件的代码覆盖率。

在 JavaScript 开发中,我们可以使用几个库来测量测试套件的覆盖率,最著名的是 Blanket.js (github.com/alex-seville/blanket) 和 Istanbul (github.com/gotwarlost/istanbul)。设置这些库超出了本书的范围,但它们的相应网页提供了我们可能需要的所有关于正确设置它们的信息。

附录 C. 练习答案

第二章. 运行时构建页面

1

客户端 Web 应用程序的生命周期中有两个阶段是什么?

A:

客户端 Web 应用程序的生命周期中的两个阶段是页面构建和事件处理。在页面构建阶段,通过处理 HTML 代码和执行主线 JavaScript 代码来构建我们页面的用户界面。在处理最后一个 HTML 节点后,页面进入事件处理阶段,在该阶段中处理各种事件。

2

使用 addEventListener 方法注册事件处理程序与将处理程序分配给特定元素属性相比,主要优势是什么?

A:

当将事件处理程序分配给特定元素属性时,我们只能注册一个事件处理程序;另一方面,addEventListener 允许我们注册所需的所有事件处理程序。

3

一次可以处理多少个事件?

A:

JavaScript 基于单线程执行模型,其中事件逐个处理。

4

事件队列中的事件按什么顺序处理?

A:

事件按照它们生成的顺序进行处理:先进先出。

第三章. 为新手准备的顶级函数:定义和参数

1

在以下代码片段中,哪些是回调函数?

//sortAsc is a callback because the JavaScript engine
//calls it to compare array items
numbers.sort(function sortAsc(a,b){
  return a – b;
});

//Not a callback; ninja is called like a standard function
function ninja(){}
ninja();

var myButton = document.getElementById("myButton");
//handleClick is a callback, the function is called
//whenever myButton is clicked
myButton.addEventListener("click", function handleClick(){
  alert("Clicked");
});

2

在以下代码片段中,根据函数类型(函数声明、函数表达式或箭头函数)对函数进行分类。

//function expression as argument to another function
numbers.sort(function sortAsc(a,b){
  return a – b;
});

//arrow function as argument to another function
numbers.sort((a,b) => b – a);

//function expression as the callee in a call expression
function(){})();

//function declaration
function outer(){
  //function declaration
  function inner(){}
  return inner;
}

//function expression call wrapped in an expression
(function(){}());

//arrow function as a callee
(()=>"Yoshi")();

3

执行以下代码片段后,变量 samuraininja 的值是什么?

//"Tomoe", the value of the expression body of the arrow function
var samurai = (() => "Tomoe")();
//undefined, in case an arrow function's body is a block statement
//the value is the value of the return statement.
//Because there's no return statement, the value is undefined.
var ninja = (() => {"Yoshi"})();

4

test 函数体内,对于两次函数调用,参数 abc 的值是什么?

function test(a, b, ...c){ /*a, b, c*/}

// a = 1; b = 2; c = [3, 4, 5]
test(1, 2, 3, 4, 5);
// a = undefined; b = undefined; c = []
test();

5

执行以下代码片段后,message1message2 变量的值是什么?

function getNinjaWieldingWeapon(ninja, weapon = "katana"){
  return ninja + " " + katana;
}

//"Yoshi katana" – there's only one argument in the call
//so weapon defaults to "katana"
var message1 = getNinjaWieldingWeapon("Yoshi");

//"Yoshi wakizashi" – we've sent in two arguments, the default
//value is not taken into account
var message2 = getNinjaWieldingWeapon("Yoshi", "wakizashi");

第四章. 对于熟练工:理解函数调用

1

以下函数使用 arguments 对象计算传入参数的总和。通过使用上一章中引入的剩余参数,重写 sum 函数,使其不使用 arguments 对象。

function sum(){
  var sum = 0;
  for(var i = 0; i < arguments.length; i++){
     sum += arguments[i];
  }
  return sum;
}

assert(sum(1, 2, 3) === 6, 'Sum of first three numbers is 6');
assert(sum(1, 2, 3, 4) === 10, 'Sum of first four numbers is 10');

A:

在函数定义中添加一个剩余参数,并稍微调整函数体:

function sum(... numbers){
  var sum = 0;
  for(var i = 0; i < numbers.length; i++){
     sum += numbers[i];
  }
  return sum;
}

assert(sum(1, 2, 3) === 6, 'Sum of first three numbers is 6');
assert(sum(1, 2, 3, 4) === 10, 'Sum of first four numbers is 10');

2

在浏览器中运行以下代码后,变量 ninjasamurai 的值是什么?

function getSamurai(samurai){
  "use strict"

  arguments[0] = "Ishida";

  return samurai;
}

function getNinja(ninja){
  arguments[0] = "Fuma";
  return ninja;
}

var samurai = getSamurai("Toyotomi");
var ninja = getNinja("Yoshi");

A:

samurai 将具有值 Toyotomi,而 ninja 将具有值 Fuma。因为 getSamurai 函数处于严格模式,所以 arguments 参数不会别名函数参数,因此更改第一个参数的值不会更改 samurai 参数的值。因为 getNinja 函数处于非严格模式,对 arguments 参数所做的任何更改都将反映在函数参数中。

3

当运行以下代码时,哪个断言会通过?

function whoAmI1(){
  "use strict";
  return this;
}

function whoAmI2(){
  return this;
}

assert(whoAmI1() === window, "Window?"); //fail
assert(whoAmI2() === window, "Window?"); //pass

A:

whoAmI1函数处于严格模式;当它作为函数被调用时,this参数的值将是undefined(而不是window)。第二个断言会通过:如果非严格模式下的函数作为函数被调用,this将指向全局对象(在浏览器中运行代码时是window对象)。

4

当运行以下代码时,哪个断言会通过?

var ninja1 = {
   whoAmI: function(){
     return this;
   }
};

var ninja2 = {
  whoAmI: ninja1.whoAmI
};

var identify = ninja2.whoAmI;

//pass: whoAmI called as a method of ninja1
assert(ninja1.whoAmI() === ninja1, "ninja1?");

//fail: whoAmI called as a method of ninja2
assert(ninja2.whoAmI() === ninja1, " ninja1 again?");

//fail: identify calls the function as a function
//because we are in non-strict mode, this refers to the window
assert(identify() === ninja1, "ninja1 again?");

//pass: Using call to supply the function context
//this refers to ninja2
assert(ninja1.whoAmI.call(ninja2) === ninja2, "ninja2 here?");

5

当运行以下代码时,哪个断言会通过?

function Ninja(){
  this.whoAmI = () => this;
}

var ninja1 = new Ninja();
var ninja2 = {
  whoAmI: ninja1.whoAmI
};

//pass: whoAmI is an arrow function inherits the function context
//from the context in which it was created.
//Because it was created during the construction of ninja1
//this will always point to ninja1
assert(ninja1.whoAmI() === ninja1, "ninja1 here?");

//false: this always refers to ninja1
assert(ninja2.whoAmI() === ninja2, "ninja2 here?");

6

以下哪个断言会通过?

function Ninja(){
  this.whoAmI = function(){
    return this;
  }.bind(this);
}

var ninja1 = new Ninja();
var ninja2 = {
  whoAmI: ninja1.whoAmI
};
//pass: the function assigned to whoAmI is a function bound
//to ninja1 (the value of this when the constructor was invoked)
//this will always refer to ninja1
assert(ninja1.whoAmI() === ninja1, "ninja1 here?");
//fail: this in whoAmI always refers to ninja1
//because whoAmI is a bound function.
assert(ninja2.whoAmI() === ninja2, "ninja2 here?");

第五章. 精通者的函数:闭包和作用域

1

闭包允许函数

A:

访问在函数定义时作用域内的外部变量(选项 a)

2

闭包带有

A:

内存成本(闭包在函数定义时保持作用域内的变量)(选项 b)

3

在以下代码示例中,标记通过闭包访问的标识符:

function Samurai(name) {
  var weapon = "katana";

  this.getWeapon = function(){
    //accesses the local variable: weapon
    return weapon;
  };

  this.getName = function(){
    //accesses the function parameter: name
    return name;
  }

  this.message = name + " wielding a " + weapon;

  this.getMessage = function(){
    //this.message is not accessed through a closure
   //it is an object property (and not a variable)
    return this.message;
  }
}

var samurai = new Samurai("Hattori");

samurai.getWeapon();
samurai.getName();
samurai.getMessage();

4

在以下代码中,创建了多少执行上下文,以及执行上下文栈的最大大小?

function perfom(ninja) {
  sneak(ninja);
  infiltrate(ninja);
}

function sneak(ninja) {
  return ninja + " skulking";
}

function infiltrate(ninja) {
  return ninja + " infiltrating";
}

perfom("Kuma");

A:

最大的栈大小是 3,在以下情况下:

  • 全局代码 -> perform -> sneak
  • 全局代码 -> perform -> infiltrate

5

JavaScript 中哪个关键字允许我们定义不能重新分配为完全新值的变量?

A:

const变量不能重新分配为新值。

6

varlet之间的区别是什么?

A:

关键字var用于定义仅函数或全局作用域的变量,而let使我们能够定义块作用域、函数作用域和全局作用域的变量。

7

以下代码将在哪里以及为什么抛出异常?

getNinja();
getSamurai();  //throws an exception

function getNinja() {
  return "Yoshi";
}

var getSamurai = () => "Hattori";

A:

当尝试调用getSamurai函数时,将会抛出异常。getNinja函数使用函数声明定义,将在任何代码执行之前创建;我们可以称它为“在代码中达到其声明之前”的“之前”。另一方面,getSamurai函数是一个箭头函数,它在执行到达它时创建,所以当我们尝试调用它时,它将是未定义的。

第六章. 未来的函数:生成器和承诺

1

在运行以下代码后,变量a1a4的值是什么?

function *EvenGenerator(){
  let num = 2;
  while(true){
    yield num;
    num = num + 2;
  }
}

let generator = EvenGenerator();

//2 the first value yielded
let a1 = generator.next().value;

//4 the second value yielded
let a2 = generator.next().value;
//2, because we have started a new generator
let a3 = EvenGenerator().next().value;
//6, we go back to the first generator
let a4 = generator.next().value;

2

运行以下代码后,ninjas数组的内容是什么?(提示:考虑如何使用while循环实现for-of循环。)

function* NinjaGenerator(){
  yield "Yoshi";
  return "Hattori";
  yield "Hanzo";
}

var ninjas = [];
for(let ninja of NinjaGenerator()){
  ninjas.push(ninja);
}

ninjas;

A:

ninjas 数组将只包含 Yoshi。这是因为 for-of 循环会遍历生成器直到生成器表示已完成(不包括与完成一起传递的值)。这发生在生成器中没有更多代码可执行时,或者遇到 return 语句时。

3

执行以下代码后,变量 a1a2 的值是什么?

function *Gen(val){
  val = yield val * 2;
  yield val;
}

let generator = Gen(2);
//4\. The value of the first value passed in through next: 3 is ignored
//because the generator hasn't yet started its execution, and there
//is no waiting yield expression.
//Because the generator is created with val being 2
//the first yield occurs for val * 2, i.e. 2*2 == 4
let a1 = generator.next(3).value;
//5: passing in 5 as a argument to next
//means that the waiting yielded expression will get the value 5
//(yield val * 2) == 5
//because that value is then assigned to val, the next yield expression
//yield val;
//will return 5
let a2 = generator.next(5).value;

4

以下代码的输出是什么?

const promise = new Promise((resolve, reject) => {
  reject("Hattori"); //the promise was explicitly rejected
});

//the error handler will be invoked
promise.then(val => alert("Success: " + val))
       .catch(e => alert("Error: " + e));

5

以下代码的输出是什么?

const promise = new Promise((resolve, reject) => {
  //the promise was explicitly resolved
  resolve("Hattori");
  //once a promise has settled, it can't be changed
  //rejecting it after 500ms will have no effect
  setTimeout(()=> reject("Yoshi"), 500);
});

//the success handler will be invoked
promise.then(val => alert("Success: " + val))
       .catch(e => alert("Error: " + e));

第七章. 使用原型的面向对象

1

以下哪个属性指向一个对象,如果目标对象没有要搜索的属性,则会搜索该对象?

A:

prototype(选项 c)

2

执行以下代码后,变量 a1 的值是什么?

function Ninja(){}
Ninja.prototype.talk = function (){
  return "Hello";
};

const ninja = new Ninja();
const a1 = ninja.talk(); //"Hello"

A:

变量 a1 的值将是 Hello。即使对象 ninja 没有拥有 talk 方法,但其原型有。

3

执行以下代码后,a1 的值是什么?

function Ninja(){}
Ninja.message = "Hello";

const ninja = new Ninja();

const a1 = ninja.message;

A:

变量 a1 的值将是未定义的。message 属性在构造函数 Ninja 中定义,并且不能通过 ninja 对象访问。

4

解释这两个代码片段中 getFullName 方法的区别:

//First fragment
function Person(firstName, lastName){
  this.firstName = firstName;
  this.lastName = lastName;

  this.getFullName = function () {
    return this.firstName + " " + this.lastName;
  }
}

//Second fragment
function Person(firstName, lastName) {
  this.firstName = firstName;
  this.lastName = lastName;
}

Person.prototype.getFullName = function () {
  return this.firstName + " " + this.lastName;
}

A:

在第一个片段中,getFullName 方法是在使用 Person 构造函数创建的实例上直接定义的。使用 Person 构造函数创建的每个对象都会获得自己的 getFullName 方法。在第二个片段中,getFullName 方法是在 Person 函数的原型上定义的。使用 Person 函数创建的所有实例都将能够访问这个单一的方法。

5

执行以下代码后,ninja.constructor 将指向什么?

function Person() { }
function Ninja() { }

const ninja = new Ninja();

A:

当访问 ninja.constructor 时,构造函数属性位于 ninja 的原型上。因为 ninja 是用 Ninja 构造函数创建的,所以构造函数属性指向 Ninja 函数。

6

执行以下代码后,ninja.constructor 将指向什么?

function Person() { }
function Ninja() { }
Ninja.prototype = new Person();
const ninja = new Ninja();

A:

constructor 属性是由构造函数创建的原型对象的属性。在这个例子中,我们用一个新的 Person 对象覆盖了 Ninja 函数的内置原型。因此,当使用 Ninja 构造函数创建 ninja 对象时,其原型被设置为新的 person 对象。最后,当我们访问 ninja 对象上的 constructor 属性时,因为 ninja 对象没有自己的 constructor 属性,所以会咨询其原型,即新的 person 对象。person 对象也没有 constructor 属性,所以会咨询其原型,即 Person.prototype 对象。该对象有一个 constructor 属性,引用了 Person 函数。这个例子完美地说明了为什么在使用 constructor 属性时要小心:尽管我们的 ninja 对象是用 Ninja 函数创建的,但由于覆盖了默认的 Ninja.prototypeconstructor 属性指向了 Person 函数。

7

解释以下例子中 instanceof 操作符的工作原理。

function Warrior() { }

function Samurai() { }
Samurai.prototype = new Warrior();

var samurai = new Samurai();

samurai instanceof Warrior; //Explain

A:

instanceof 操作符检查右侧函数的原型是否在左侧对象的原型链中。左侧的对象是用 Samurai 函数创建的,其原型有一个新的 warrior 对象,其原型是 Warrior 函数的原型 (Warrior.prototype)。在右侧我们有 Warrior 函数。所以在这个例子中,instanceof 操作符将返回 true,因为右侧函数的原型 Warrior.prototype 可以在左侧对象的原型链中找到。

8

将以下 ES6 代码翻译成 ES5 代码。

class Warrior {
  constructor(weapon){
    this.weapon = weapon;
  }

  wield() {
    return "Wielding " + this.weapon;
  }

  static duel(warrior1, warrior2){
    return warrior1.wield() + " " + warrior2.wield();
  }
}

A:

我们可以这样翻译以下代码:

function Warrior(weapon) {
  this.weapon = weapon;
}

Warrior.prototype.wield = function () {
  return "Wielding " + this.weapon;
};

Warrior.duel = function (warrior1, warrior2) {
  return warrior1.wield() + " " + warrior2.wield();
};

第八章. 控制对象访问

1

在运行以下代码后,以下哪个表达式会抛出异常,原因是什么?

const ninja = {
   get name() {
     return "Akiyama";
   }
}

A:

调用 ninja.name() 会抛出异常,因为 ninja 没有名字方法(选项 a)。在 const name = ninja.name 中访问 ninja.name 的工作方式很完美;获取器被激活,变量 name 获得了值 Akiyama

2

在以下代码中,哪种机制允许获取器访问私有对象变量?

function Samurai() {
  const _weapon = "katana";
  Object.defineProperty(this, "weapon", {
    get: () => _weapon
  });
}
const samurai = new Samurai();
assert(samurai.weapon === "katana", "A katana wielding samurai");

A:

闭包允许获取器访问私有对象变量。在这种情况下,get 方法在构造函数中定义的 _weapon 私有变量周围创建了一个闭包,这使 _weapon 变量保持活跃。

3

以下哪个断言会通过?

const daimyo = { name: "Matsu", clan: "Takasu"};
const proxy = new Proxy(daimyo, {
  get: (target, key) => {
    if(key === "clan"){
      return "Tokugawa";
    }
  }
});

assert(daimyo.clan === "Takasu", "Matsu of clan Takasu");     //pass
assert(proxy.clan === "Tokugawa", "Matsu of clan Tokugawa?"); //pass

proxy.clan = "Tokugawa";

assert(daimyo.clan === "Takasu", "Matsu of clan Takasu");     //fail
assert(proxy.clan === "Tokugawa", "Matsu of clan Tokugawa?"); //pass

A:

第一个断言通过,因为 daimyo 有一个值为 Takasuclan 属性。第二个断言通过,因为我们通过一个具有总是返回 Tokugawa 作为 clan 属性值的获取陷阱的代理访问 clan 属性。

当表达式 proxy.clan = "Tokugawa" 被评估时,值 Tokugawa 被存储在 daimyoclan 属性中,因为代理没有设置陷阱,所以设置属性的默认操作是在目标对象 daimyo 上执行。

第三个断言失败,因为 daimyo 的族属性值为 Tokugawa 而不是 Takasu

第四个断言通过,因为代理总是返回 Tokugawa,无论目标对象 clan 属性中存储的值是什么。

4

以下哪个断言会通过?

const daimyo = { name: "Matsu", clan: "Takasu", armySize: 10000};
const proxy = new Proxy(daimyo, {
  set: (target, key, value) => {
    if(key === "armySize") {
      const number = Number.parseInt(value);
      if(!Number.isNaN(number)){
        target[key] = number;
      }
    } else {
        target[key] = value;
    }
  },
});
//pass
assert(daimyo.armySize === 10000, "Matsu has 10 000 men at arms");
//pass
assert(proxy.armySize === 10000, "Matsu has 10 000 men at arms");

proxy.armySize = "large";
assert(daimyo.armySize === "large", "Matsu has a large army"); //fail

daimyo.armySize = "large";
assert(daimyo.armySize === "large", "Matsu has a large army");//pass

A:

第一个断言通过;daimyoarmySize 属性值为 10000。第二个断言也通过;代理没有定义获取陷阱,所以返回目标 daimyoarmySize 属性的值。

当表达式 proxy.armySize = "large"; 被评估时,代理的设置陷阱被激活。设置器检查传入的值是否为数字,并且只有当它是数字时,值才被分配给目标属性。在这种情况下,传入的值不是数字,所以没有对 armySize 属性进行更改。因此,假设更改的第三个断言失败。

表达式 daimyo.armySize = "large"; 直接写入 armySize 属性,绕过了代理。因此,最后一个断言通过。

第九章。处理集合

1

运行以下代码后,samurai 数组的内容是什么?

const samurai = ["Oda", "Tomoe"];
samurai[3] = "Hattori";

A:

samurai 的值是 ["Oda", "Tomoe", undefined, "Hattori"]。数组以 OdaTomoe 在索引 01 处开始。然后我们在索引 3 添加一个新的武士 Hattori,这“扩展”了数组,并在索引 2 留下 undefined

2

运行以下代码后,ninjas 数组的内容是什么?

const ninjas = [];

ninjas.push("Yoshi");
ninjas.unshift("Hattori");

ninjas.length = 3;

ninjas.pop();

A:

ninjas 的值是 ["Hattori", "Yoshi"]。我们从一个空数组开始,pushYoshi 添加到末尾,而 unshiftHattori 添加到开头。显式地将 length 设置为 3 通过在索引 2 处添加 undefined 来扩展数组。调用 pop 从数组中删除那个 undefined,留下只有 ["Hattori", "Yoshi"]

3

运行以下代码后,samurai 数组的内容是什么?

const samurai = [];

samurai.push("Oda");
samurai.unshift("Tomoe");
samurai.splice(1, 0, "Hattori", "Takeda");
samurai.pop();

A:

samurai 的值是 ["Tomoe", "Hattori", "Takeda"]。数组最初为空;pushOda 添加到末尾,而 unshiftTomoe 添加到开头;splice 删除索引 1 的项目(Oda)并添加 HattoriTakeda

4

运行以下代码后,变量 firstsecondthird 中存储了什么?

const ninjas = [{name:"Yoshi", age: 18},
    {name:"Hattori", age: 19},
    {name:"Yagyu", age: 20}];

const first = persons.map(ninja => ninja.age);
const second = first.filter(age => age % 2 == 0);
const third = first.reduce((aggregate, item) =>  aggregate + item, 0);

A:

第一:[18, 19, 20]; 第二:[18, 20]; 第三:57

5

执行以下代码后,变量 firstsecond 中存储了什么?

const ninjas = [{ name: "Yoshi", age: 18 },
               { name: "Hattor", age: 19 },
               { name: "Yagyu", age: 20 }];

const first = ninjas.some(ninja => ninja.age % 2 == 0);
const second = ninjas.every(ninja => ninja.age % 2 == 0);

A:

第一:true;第二:false

6

以下哪个断言会通过?

const samuraiClanMap = new Map();

const samurai1 = { name: "Toyotomi"};
const samurai2 = { name: "Takeda"};
const samurai3 = { name: "Akiyama"};

const oda = { clan: "Oda"};
const tokugawa = { clan: "Tokugawa"};
const takeda ={clan: "Takeda"};
samuraiClanMap.set(samurai1, oda);
samuraiClanMap.set(samurai2, tokugawa);
samuraiClanMap.set(samurai2, takeda);

assert(samuraiClanMap.size === 3, "There are three mappings");
assert(samuraiClanMap.has(samurai1), "The first samurai has a mapping");
assert(samuraiClanMap.has(samurai3), "The third samurai has a mapping");

A:

第一个断言失败,因为为 samurai2 创建了两次映射。第二个断言通过,因为添加了 samurai1 的映射。第三个断言失败,因为从未创建 samurai3 的映射。

7

以下哪个断言会通过?

const samurai = new Set("Toyotomi", "Takeda", "Akiyama", "Akiyama");
assert(samurai.size === 4, "There are four samurai in the set");

samurai.add("Akiyama");
assert(samurai.size === 5, "There are five samurai in the set");

assert(samurai.has("Toyotomi", "Toyotomi is in!");
assert(samurai.has("Hattori", "Hattori is in!");

A:

第一个断言失败,因为 Akiyama 只添加了一次到集合中。第二个断言也失败,因为再次尝试添加 Akiyama 不会改变集合(及其长度)。最后两个断言会通过。

第十章。正则表达式的处理

1

在 JavaScript 中,可以使用以下哪种方式创建正则表达式?

A:

正则表达式字面量(选项 a)和通过使用内置的 RegExp 构造函数(选项 B)。答案 c 是错误的;没有内置的 RegularExpression 构造函数。

2

以下哪个是正则表达式字面量?

A:

在 JavaScript 中,正则表达式字面量用两个正斜杠括起来:/test/(选项 a)。

3

选择正确的正则表达式标志。

A:

使用正则表达式字面量时,表达式标志放在关闭的正斜杠之后:/test/g(选项 a)。使用 RegExp 构造函数时,它们作为第二个参数传递:new RegExp ("test", "gi");(选项 c)。

4

正则表达式 /def/ 匹配以下哪个字符串?

A:

正则表达式 /def/ 仅匹配 def:由 d 后跟 e,再跟 f(选项 b)。

5

正则表达式 /[^abc]/ 匹配以下哪个?

A:

正则表达式 /[^abc]/ 匹配以下字符串之一:def——一个不是 abc 的字符(选项 b)。

6

以下哪个正则表达式匹配字符串 hello

A:

选项 a、b 和 c 匹配。/hello/ 仅匹配确切的字符串 hello/hell?o/ 匹配 hellohelo(第二个 l 是可选的)。/hel*o/,在第一个 l 之后,匹配任意数量的字母 l

7

正则表达式 /(cd)+(de)*/ 匹配以下哪个字符串?

A:

选项 a、c、d 和 f 是正确的。/(cd)+(de)*/ 匹配一个或多个 cd 的出现,后跟任意数量的 de 的出现。

8

在正则表达式中,我们可以用以下哪种方式表达选择?

A:

我们使用管道字符|在正则表达式中表示替代(选项 c)。

9

在正则表达式/([0-9])2/中,我们可以用以下哪个来引用第一个匹配的数字?

A:

\1(选项 d)

10

正则表达式/([0-5])6\1/将匹配以下哪个?

A:

在正则表达式/([0-5])6\1/中,第一个字符是 0 到 5 的数字,第二个字符是数字 6,第三个字符是第一个匹配的数字,所以060565都匹配(选项 a 和 d)。

11

正则表达式/(?:ninja)-(trick)?-\1/将匹配以下哪个?

A:

在正则表达式/(?:ninja)-(trick)?-\1/中,第一个组(?:ninja)是一个非捕获组,而第二个组是一个可选的捕获组(trick)?。但如果找到了第二个组,最后我们将有一个对它的反向引用。因此,ninja-ninja-trick-trick匹配(选项 a 和 c)。

12

执行"012675".replace(/0-5/g, "a")的结果是什么?

A:

代码将所有从 0 到 5 的数字替换为字母 a,因此结果是aaa67a(选项 a)。

第十一章。代码模块化技术

1

哪种机制使得模块模式中可以存在私有模块变量?

A:

在模块模式中,闭包允许我们隐藏模块内部:模块公共 API 的方法保持了模块内部的生命(选项 b)。

2

在以下使用 ES6 模块的代码中,如果导入模块,哪些标识符可以被访问?

const spy = "Yagyu";
function command(){
  return general + " commands you to wage war!";
}
export const general = "Minamoto";

A:

从模块外部,我们只能访问general标识符,因为它是唯一一个被显式导出的标识符(选项 c)。

3

在以下使用 ES6 模块的代码中,当导入模块时,哪些标识符可以被访问?

const ninja = "Yagyu";
function command(){
  return general + " commands you to wage war!";
}
const general  = "Minamoto";

export {ninja as spy};

A:

从模块外部,我们只能访问spy标识符:这是唯一一个被导出为ninja变量别名的标识符(选项 a)。

4

以下哪些导入是被允许的?

//File: personnel.js
const ninja = "Yagyu";
function command(){
  return general + " commands you to wage war!";
}
const general  = "Minamoto";

export {ninja as spy};

A:

第一个导入不被允许,因为personnel模块没有导出ninjageneral标识符(选项 a)。第二个导入是允许的,因为我们导入了整个模块,可以通过Personnel对象访问(选项 b)。第三个导入也是允许的,因为我们导入了导出的spy标识符(选项 c)。

5

如果我们有以下模块代码,哪个语句将导入Ninja类?

//Ninja.js
export default class Ninja {
  skulk(){ return "skulking"; }
}

A:

第一个导入是允许的:我们导入默认导出(选项 a)。第二个导入是允许的:我们导入整个模块(选项 b)。第三个导入是不允许的,因为它在语法上不正确(在*之后应该跟“as Name”部分)(选项 c)。

第十二章. 操作 DOM

1

在以下代码中,以下哪个断言会通过?

<div id="samurai"></div>
<script>
  const element = document.querySelector("#samurai");
  assert(element.id === "samurai", "property id is samurai");
  assert(element.getAttribute("id") === "samurai",
         "attribute id is samurai");

  element.id = "newSamurai";

  assert(element.id === "newSamurai", "property id is newSamurai");
  assert(element.getAttribute("id") === "newSamurai",
         "attribute id is newSamurai");
</script>

A:

在此代码中,所有断言都通过。id属性和id属性是链接的;对其中一个的更改会反映在另一个上。

2

给定以下代码,我们如何访问元素的border-width样式属性?

<div id="element" style="border-width: 1px;
                         border-style:solid; border-color: red">
</div>
<script>
  const element = document.querySelector("#element");
</script>

A:

element.border-width 表达式没有太多意义。它计算element.border和变量width之间的差异,这绝对不是我们想要的。下一个选项element.getAttribute ("border-width");获取 HTML 元素的属性,而不是样式属性。最后,最后两个选项给出1px的值(选项 c 和 d)。

3

哪个内置方法可以获取应用到某个元素上的所有样式(包括浏览器提供的样式、通过样式表应用的样式以及通过样式属性设置的属性)?

A:

只有最后一个选项,getComputedStyle,是一个内置方法,可以用来获取某个 HTML 元素的计算样式(选项 c)。其他三个方法不包括在标准 API 中。

4

布局销毁何时发生?

A:

当我们的代码对 DOM 执行一系列连续的读取和写入操作时,就会发生布局销毁。这会导致较慢、响应性较差的 Web 应用程序。

第十三章. 生存事件

1

为什么将任务添加到任务队列发生在事件循环之外很重要?

A:

如果将任务添加到任务队列的过程是事件循环的一部分,那么在 JavaScript 代码执行期间发生的任何事件都将被忽略。这绝对是一个糟糕的想法。

2

为什么事件循环的每次迭代不超过大约 16 毫秒很重要?

A:

为了实现运行流畅的应用程序,浏览器试图每秒渲染大约 60 次。因为渲染是在事件循环的末尾执行的,所以每次迭代不应该超过 16 毫秒,除非我们想要创建缓慢且参差不齐的应用程序。

3

运行以下代码 2 秒后的输出是什么?

setTimeout(function(){
  console.log("Timeout ");
}, 1000);

setInterval(function(){
  console.log("Interval ");
}, 500);

A:

Interval Timeout Interval Interval Interval (选项 b)。setInterval方法以至少每次调用之间的固定延迟调用处理程序,直到显式清除间隔。另一方面,setTimeout方法仅在指定的延迟过去后调用一次回调。在这个例子中,首先在 500 毫秒后触发一次setInterval回调。然后,在 1000 毫秒后调用setTimeout回调,紧接着又立即调用另一个setInterval。我们的检查在另外两个setInterval回调调用结束时停止,一个在 1500 毫秒,另一个在 2000 毫秒。

4

运行以下代码 2 秒后的输出是什么?

const timeoutId = setTimeout(function(){
  console.log("Timeout ");
}, 1000);

setInterval(function(){
  console.log("Interval ");
}, 500);

clearTimeout(timeoutId);

A:

Interval Interval Interval Interval (选项 c)。在setTimeout回调有机会触发之前,它被清除,所以在这种情况下,我们只有四次setInterval回调的执行。

5

运行以下代码并点击具有 ID inner的元素会产生什么输出?

<body>
  <div id="outer">
    <div id="inner"></div>
  </div>
  <script>
    const innerElement = document.querySelector("#inner");
    const outerElement = document.querySelector("#outer");
    const bodyElement = document.querySelector("body");
    innerElement.addEventListener("click", function(){
      console.log("Inner");
    });

    outerElement.addEventListener("click", function(){
      console.log("Outer");
    }, true);

    bodyElement.addEventListener("click", function(){
      console.log("Body");
    });
  <script>
</body>

A:

Outer Inner Body (选项 c)。在innerElementbodyElement上的点击处理程序以冒泡模式注册,而outerElement上的点击处理程序以捕获模式注册。在处理事件时,事件首先从顶部开始,并调用所有捕获模式的事件处理程序。第一条消息将是Outer。在事件目标被达到后,在我们的例子中是具有 ID inner的元素,事件冒泡发生,事件向上传播。因此,第二条消息将是Inner,第三条将是Body

第十四章。开发跨浏览器策略

1

决定支持哪些浏览器时,我们应该考虑哪些因素?

A:

在决定支持哪些浏览器时,我们至少应该考虑以下因素:

  • 目标受众的期望和需求
  • 浏览器的市场份额
  • 支持浏览器所需的努力量

2

解释贪婪 ID 的问题。

A:

当与表单元素一起工作时,浏览器为每个具有 ID 的子元素添加属性到表单元素,这样我们就可以通过表单元素轻松访问这些元素。不幸的是,这可能会覆盖一些内置的表单属性,如actionsubmit

3

特征检测是什么?

A:

特征检测通过确定某个对象或对象属性是否存在,如果存在,则假定它提供了隐含的功能。我们不是测试用户是否使用特定的浏览器,然后根据该信息实现解决方案,而是测试某个功能是否按预期工作。

4

浏览器 polyfill 是什么?

A:

如果我们想要使用某些所有目标浏览器都不支持的功能,我们可以使用功能检测。如果一个当前浏览器不支持某个功能,我们提供自己的实现,这被称为 polyfilling。

posted @ 2025-11-15 13:05  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报