精通-JavaScript-全-

精通 JavaScript(全)

原文:zh.annas-archive.org/md5/866633107896D180D34D9AC33F923CF3

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

似乎已经写下了所有需要关于 JavaScript 的东西。坦白说,要找到一个关于 JavaScript 还没有被详尽讨论的话题是困难的。然而,JavaScript 正在迅速变化。ECMAScript 6 有潜力改变这门语言以及我们用它编写的代码方式。Node.js 已经改变了我们用 JavaScript 编写服务器的方式。像 React 和 Flux 这样的新想法将推动语言的下一轮迭代。虽然我们花时间学习新特性,但不可否认的是,必须掌握 JavaScript 的基础理念。这些理念是基础且需要关注。如果你已经是一个有经验的 JavaScript 开发者,你会意识到现代 JavaScript 与大多数人所知的那门语言大相径庭。现代 JavaScript 要求特定的风格纪律和思维的严谨性。工具变得更加强大,并逐渐成为开发工作流程的一个重要组成部分。尽管语言似乎在变化,但它建立在一些非常坚实且恒定的理念之上。这本书强调的就是这些基本理念。

在撰写这本书的过程中,JavaScript 领域的很多事情都在不断变化。幸运的是,我们成功地在这本书中包括了所有重要的相关更新。

《精通 JavaScript》为你提供了对语言基础和一些现代工具和库(如 jQuery、Underscore.js 和 Jasmine)的详细概述。

我们希望你能像我们享受写作一样享受这本书。

本书内容概览

第一章,JavaScript 入门,专注于语言构造,而不花太多时间在基本细节上。我们将涵盖变量作用域和循环的更复杂部分以及使用类型和数据结构的最佳实践。我们还将涵盖大量的代码风格和推荐的代码组织模式。

第二章,函数、闭包和模块,涵盖了语言复杂性的核心。我们将讨论使用函数方面以及在 JavaScript 中对待闭包的不同处理方法的复杂性。这是一个谨慎且详尽的讨论,将为你进一步探索更高级的设计模式做好准备。

第三章,数据结构及其操作,详细介绍了正则表达式和数组。数组是 JavaScript 中的一个基本数据类型,本章将帮助你有效地使用数组。正则表达式可以使你的代码简洁—我们将详细介绍如何在你的代码中有效地使用正则表达式。

第四章《面向对象的 JavaScript》,讨论了 JavaScript 中的面向对象。我们将讨论继承和原型链,并专注于理解 JavaScript 提供的原型继承模型。我们还将讨论这个模型与其他面向对象模型的不同之处,以帮助 Java 或 C++ 程序员熟悉这种变化。

第五章《JavaScript 模式》,讨论了常见的设计模式以及如何在 JavaScript 中实现它们。一旦你掌握了 JavaScript 的面向对象模型,理解设计和编程模式就会更容易,写出模块化且易于维护的代码。

第六章《测试与调试》,涵盖了各种现代方法来测试和调试 JavaScript 代码中的问题。我们还将探讨 JavaScript 的持续测试和测试驱动方法。我们将使用 Jasmine 作为测试框架。

第七章《ECMAScript 6》,专注于由 ECMAScript 6 (ES6) 引入的新语言特性。它使 JavaScript 更加强大,本章将帮助你理解新特性以及如何在代码中使用它们。

第八章《DOM 操作与事件》,详细探讨了 JavaScript 作为浏览器语言的部分。本章讨论了 DOM 操作和浏览器事件。

第九章《服务器端 JavaScript》,解释了如何使用 Node.js 在 JavaScript 中编写可扩展的服务器系统。我们将讨论 Node.js 的架构和一些有用的技术。

本书你需要什么

本书中的所有示例都可以在任何现代浏览器上运行。对于最后一章,你需要 Node.js。为了运行本书中的示例和样本,你需要以下先决条件:

  • 安装有 Windows 7 或更高版本、Linux 或 Mac OS X 的计算机。

  • 最新版本的 Google Chrome 或 Mozilla Firefox 浏览器。

  • 你选择的文本编辑器。Sublime Text、vi、Atom 或 Notepad++ 都是理想的选择。完全由你决定。

本书适合谁

本书旨在为你提供掌握 JavaScript 的必要细节。本书将对以下读者群体有用:

  • 有经验的开发者,熟悉其他面向对象语言。本书的信息将使他们能够利用现有的经验转向 JavaScript。

  • 有一定经验的 Web 开发者。这本书将帮助他们学习 JavaScript 的高级概念并完善他们的编程风格。

  • 初学者想要理解并最终掌握 JavaScript。这本书为他们提供了开始所需的信息。

约定

在这本书中,您会发现有一些文本样式用于区分不同类型的信息。以下是一些这些样式的示例及其含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理方式如下所示:"首先,<head>中的<script>标签导入了 JavaScript,而第二个<script>标签用于嵌入内联 JavaScript。"

代码块如下所示:

function sayHello(what) {
  return "Hello " + what;
}
console.log(sayHello("world"));

当我们要引您注意代码块中的某个特定部分时,相关的行或项目会被加粗:

<head>
 <script type="text/javascript" src="img/script.js"></script>
 <script type="text/javascript">
 var x = "Hello World";
 console.log(x);
 </script>
</head>

任何命令行输入或输出都如下所示:

EN-VedA:~$ node
> 0.1+0.2
0.30000000000000004
> (0.1+0.2)===0.3
false

新术语重要词汇 以粗体显示。例如,在菜单或对话框中看到的屏幕上的词,会在文本中这样显示:"You can run the page and inspect using Chrome's Developer Tool"

注意

警告或重要说明以这样的盒子出现。

提示

技巧和建议以这样的形式出现。

读者反馈

读者对我们书籍的反馈总是受欢迎的。让我们知道您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们很重要,因为它帮助我们开发出您会真正从中受益的标题。

要发送给我们一般性反馈,只需电子邮件 <feedback@packtpub.com>,并在您消息的主题中提到书的标题。

如果您在某个主题上有专业知识,并且有兴趣撰写或贡献一本书,请查看我们的作者指南:www.packtpub.com/authors

客户支持

既然您已经成为 Packt 图书的自豪拥有者,我们有很多事情可以帮助您充分利用您的购买。

下载示例代码

您可以从您在 www.packtpub.com 的账户上下载本书中的示例代码文件,您购买的 Packt Publishing 所有的书籍都可以。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 注册,以便将文件直接通过电子邮件发送给您。

下载本书彩色图片

我们还为您提供了一个 PDF 文件,其中包含本书中使用的屏幕快照/图表的彩色图片。这些彩色图片将帮助您更好地理解输出中的变化。您可以从 www.packtpub.com/sites/default/files/downloads/MasteringJavaScript_ColorImages.pdf 下载这个文件。

勘误

虽然我们已经尽一切努力确保我们内容的准确性,但错误确实会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——我们将非常感激如果您能向我们报告。通过这样做,您可以节省其他读者不必要的挫折,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您错误的详细信息。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站,或添加到该标题的错误部分已有的错误列表中。

要查看之前提交的错误,请前往 www.packtpub.com/books/content/support 并在搜索框中输入书籍名称。所需信息将在错误部分出现。

盗版

互联网上的版权材料盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常严肃地对待我们版权和许可的保护。如果您在互联网上以任何形式发现我们作品的非法副本,请立即提供给我们位置地址或网站名称,这样我们可以寻求一个补救措施。

如果您发现可疑的盗版材料,请联系我们 <copyright@packtpub.com>并提供链接。

我们感激您在保护我们的作者和我们提供有价值内容的能力方面所提供的帮助。

问题

如果您在这本书的任何方面遇到问题,您可以通过 <questions@packtpub.com> 联系我们,我们会尽最大努力解决问题。

第一章:JavaScript 简介

编写文章的起初几句话总是困难的,尤其是在谈论像 JavaScript 这样的主题时。这种困难主要源于人们对这门语言已经有了太多的说法。自从 Netscape Navigator 的早期阶段以来,JavaScript 就一直是网络的语言——如果你愿意,可以说是互联网的通用语。JavaScript 从业余爱好者的工具迅速转变为鉴赏家的武器。

JavaScript 是网络和开源生态系统中最受欢迎的语言。githut.info/ 图表记录了过去几年中 GitHub 上活跃仓库的数量以及该语言的整体受欢迎程度。JavaScript 的流行和重要性可以归因于它与浏览器的关联。Google 的 V8 和 Mozilla 的 SpiderMonkey 是分别驱动 Google Chrome 和 Mozilla Firefox 浏览器的极度优化的 JavaScript 引擎。

尽管网络浏览器是 JavaScript 最广泛使用的平台,但现代数据库如 MongoDB 和 CouchDB 使用 JavaScript 作为它们的脚本和查询语言。JavaScript 也在浏览器之外成为了重要的平台。例如,Node.jsio.js 项目提供了强大的平台,用于使用 JavaScript 开发可扩展的服务器环境。一些有趣的项目正在将语言能力推向极限,例如,Emscripten (kripken.github.io/emscripten-site/) 是一个基于低级虚拟机 (LLVM) 的项目,它将 C 和 C++编译成高度优化的 JavaScript,格式为asm.js。这允许你在网上以接近本地速度运行 C 和 C++。

JavaScript 围绕坚实的基础构建,例如,函数、动态对象、松散类型、原型继承以及强大的对象字面量表示法。

虽然 JavaScript 建立在坚实的设计原则上,但不幸的是,这门语言不得不随着浏览器一起发展。网络浏览器以支持各种特性和标准的方式而闻名。JavaScript 试图适应浏览器的所有奇思妙想,结果做出了一些非常糟糕的设计决策。这些糟糕的部分(这个术语由 Douglas Crockford 闻名)使这门语言的优点对大多数人来说都显得黯淡。程序员编写了糟糕的代码,其他程序员试图调试这些糟糕代码时噩梦般地努力,这门语言最终获得了坏名声。不幸的是,JavaScript 是最被误解的编程语言之一(javascript.crockford.com/javascript.html)。

对 JavaScript 的另一种批评是,它让你在没有成为该语言专家的情况下完成事情。我见过程序员因为想快速完成事情而写出极其糟糕的 JavaScript 代码,而 JavaScript 正好允许他们这样做。我花了很多时间调试一个显然不是程序员的人写的非常糟糕的 JavaScript。然而,语言是一种工具,不能因为草率的编程而受到责备。像所有工艺一样,编程需要极大的奉献和纪律。

一段简短的历史

1993 年,国家超级计算应用中心NCSA)的Mosaic浏览器是第一个流行的网页浏览器之一。一年后,网景通讯公司创建了专有的网页浏览器Netscape Navigator。几名原始 Mosaic 作者参与了 Navigator 的开发。

1995 年,网景通讯公司聘请了布兰登·艾奇,承诺让他实现Scheme(一种 Lisp 方言)在浏览器中。在这一切发生之前,网景与太阳微系统公司(现在称为甲骨文)联系,希望在导航者浏览器中包含 Java。

由于 Java 的流行和易编程,网景决定脚本语言的语法必须与 Java 相似。这排除了采用现有的如 Python、工具命令语言TCL)或 Scheme 等语言。艾奇仅用 10 天就编写了最初的原型(www.computer.org/csdl/mags/co/2012/02/mco2012020007.pdf),1995 年 5 月。JavaScript 的第一个代号是Mocha,由马克·安德森提出。网景后来将其改为LiveScript,出于商标原因。1995 年 12 月初,太阳公司将 Java 商标授权给网景。该语言最终被更名为 JavaScript。

如何使用这本书

如果你希望快速完成事情,这本书不会对你有所帮助。这本书将专注于用 JavaScript 正确编码的方法。我们将花很多时间了解如何避免该语言的缺点,并在 JavaScript 中构建可靠且可读的代码。我们将避免该语言的草率特性,以确保你不会习惯它们——如果你已经学会了使用这些习惯来编程,这本书将试图让你改掉这个习惯。我们将重点关注正确的风格和工具,以使你的代码变得更好。

本书中的大多数概念都将是来自现实世界问题的例子和模式。我会坚持让你为每个片段编写代码,以确保你对概念的理解被编程到你的肌肉记忆中。相信我,没有比写大量代码更好的学习编程的方法了。

通常,你需要创建一个 HTML 页面来运行嵌入式 JavaScript 代码,如下所示:

<!DOCTYPE html>
<html>
<head>
 <script type="text/javascript" src="img/script.js"></script>
 <script type="text/javascript">
 var x = "Hello World";
 console.log(x);
 </script>
</head>
<body>
</body>
</html>

这个示例代码展示了 JavaScript 嵌入 HTML 页面的两种方式。首先,<head>中的<script>标签导入了 JavaScript,而第二个<script>标签则用于嵌入内联 JavaScript。

提示

下载示例代码

你可以从www.packtpub.com下载你购买的所有 Packt Publishing 书籍的示例代码文件。如果你在其他地方购买了此书,你可以访问www.packtpub.com/support并注册,以便让文件直接通过电子邮件发送给你。

你可以将这个 HTML 页面保存到本地并在浏览器中打开。在 Firefox 中,你可以打开开发者控制台(Firefox 菜单 | 开发者 | 网络控制台),你可以在控制台标签上看到"Hello World"文本。根据你的操作系统和浏览器版本,屏幕可能看起来会有所不同:

如何使用本书

你可以使用 Chrome 的开发者工具运行并检查页面:

如何使用本书

这里一个非常有趣的事情是,在控制台上显示了一个关于我们尝试使用以下代码行导入的缺失.js文件的错误:

<script type="text/javascript" src="img/script.js"></script>

使用浏览器开发者控制台或像Firebug这样的扩展在调试代码错误条件时非常有用。我们将在后面的章节中详细讨论调试技术。

创建这样的 HTML 骨架对本书中的每一个练习来说可能会很繁琐。相反,我们想为 JavaScript 使用一个读-评估-打印循环REPL)。与 Python 不同,JavaScript 没有内置的 REPL。我们可以使用 Node.js 作为 REPL。如果你已经在你的电脑上安装了 Node.js,你只需在命令行中输入node就可以开始与之实验。你会观察到 Node REPL 错误并不是非常优雅地显示。

让我们看看以下示例:

EN-VedA:~$ node
>function greeter(){
  x="World"l
SyntaxError: Unexpected identifier
 at Object.exports.createScript (vm.js:44:10)
 at REPLServer.defaultEval (repl.js:117:23)
 at bound (domain.js:254:14)
 …

在出现此错误之后,你必须重新启动。尽管如此,它还是能让你更快地尝试小段代码。

我个人经常使用的一个工具是JS Bin(jsbin.com/). JS Bin 为你提供了一套很好的工具来测试 JavaScript,比如语法高亮和运行时错误检测。以下是 JS Bin 的屏幕截图:

如何使用本书

根据你的喜好,你可以选择一个让你更容易尝试代码示例的工具。无论你使用哪个工具,确保你在这本书中输出了每一个练习。

Hello World

没有一种编程语言应该没有传统的 Hello World 程序就被发布——这本书为什么应该有任何不同?

请(不要复制和粘贴)在 JS Bin 中输入以下代码:

function sayHello(what) {
  return "Hello " + what;
}
console.log(sayHello("world"));

你的屏幕应该看起来像以下样子:

Hello World

JavaScript 概览

简而言之,JavaScript 是一种基于原型的脚本语言,具有动态类型和一流的函数支持。JavaScript 大部分语法借鉴了 Java,但也受到了 Awk、Perl 和 Python 的影响。JavaScript 是大小写敏感的,且对空格不敏感。

注释

JavaScript 允许单行或多行注释。其语法与 C 或 Java 类似:

// a one line comment

/* this is a longer, 
   multi-line comment
 */

/* You can't /* nest comments */ SyntaxError */

变量

变量是值的符号名称。变量的名称,或标识符,必须遵循某些规则。

JavaScript 变量名必须以字母、下划线 (_) 或美元符号 ($) 开头;后续字符还可以是数字 (0-9)。由于 JavaScript 是大小写敏感的,所以字母包括 AZ (大写)和 az (小写)的字符。

你可以在变量名中使用 ISO 8859-1 或 Unicode 字母。

在 JavaScript 中,新变量应该使用 var 关键字定义。如果你声明了一个变量但没有给它赋值,那么它默认的类型是未定义。一个糟糕的事情是,如果你不使用 var 关键字声明变量,它们会变成隐式的全局变量。让我重申一下,隐式的全局变量是一件糟糕的事情——我们将在书中讨论变量作用域和闭包时详细讨论这个问题,但重要的是要记住,除非你知道你在做什么,否则你应该总是用 var 关键字声明变量:

var a;      //declares a variable but its undefined
var b = 0;
console.log(b);    //0
console.log(a);    //undefined
console.log(a+b);  //NaN

NaN 值是一个特殊值,用来表示实体不是数字

常量

你可以使用 const 关键字创建一个只读的命名常量。常量名必须以字母、下划线或美元符号开头,并可以包含字母、数字或下划线字符:

const area_code = '515';

常量不能通过赋值改变其值,也不能重新声明,并且必须初始化为一个值。

JavaScript 支持标准类型变体:

  • 数字

  • 字符串

  • 布尔值

  • 符号(ECMAScript 6 新增)

  • 对象:

    • 函数

    • 数组

    • 日期

    • 正则表达式

  • 空值

  • 未定义

数字

Number 类型可以表示 32 位整数和 64 位浮点值。例如,以下代码行声明了一个变量来保存整数值,该值由字面量 555 定义:

var aNumber = 555;

要定义一个浮点值,你需要包含一个小数点和一个小数点后的一位数字:

var aFloat = 555.0;

本质上,在 JavaScript 中并没有所谓的整数。JavaScript 使用 64 位浮点表示,这与 Java 的 double 相同。

因此,你会看到如下内容:

EN-VedA:~$ node
> 0.1+0.2
0.30000000000000004
> (0.1+0.2)===0.3
false

我建议你阅读 Stack Overflow 上的详尽回答(stackoverflow.com/questions/588004/is-floating-point-math-broken)和(floating-point-gui.de/),它解释了为什么会这样。然而,重要的是要理解浮点数运算应该小心处理。在大多数情况下,你可能不需要依赖小数的极端精确度,但如果需要,你可以尝试使用诸如big.js(github.com/MikeMcl/big.js)之类的库来解决这个问题。

如果你打算编写极其精确的财务系统,你应该将$值表示为分,以避免舍入错误。我曾经参与过的其中一个系统过去将增值税VAT)金额四舍五入到两位小数。每天有成千上万的订单,这个每订单的舍入金额变成了一个巨大的会计难题。我们需要彻底重构整个 Java Web 服务堆栈和 JavaScript 前端。

还有一些特殊值也被定义为 Number 类型的部分。前两个是Number.MAX_VALUENumber.MIN_VALUE,它们定义了 Number 值集的外部界限。所有 ECMAScript 数字必须在这两个值之间,没有例外。然而,一个计算可能会产生不在这两个值之间的数字。当计算结果大于Number.MAX_VALUE时,它被赋予Number.POSITIVE_INFINITY的值,意味着它不再有数值。同样,计算结果小于Number.MIN_VALUE时,被赋予Number.NEGATIVE_INFINITY的值,也没有数值。如果计算返回一个无限值,则结果不能用于任何进一步的计算。你可以使用isInfinite()方法来验证计算结果是否为无限值。

JavaScript 的另一个特性是一个特殊的值,称为 NaN(Not a Number的缩写)。通常,这发生在从其他类型(字符串、布尔值等)转换失败时。观察 NaN 的以下特性:

EN-VedA:~ $ node
> isNaN(NaN);
true
> NaN==NaN;
false
> isNaN("elephant");
true
> NaN+5;
NaN

第二行很奇怪——NaN 不等于 NaN。如果 NaN 是任何数学运算的一部分,结果也变成 NaN。一般来说,避免在任何表达式中使用 NaN。对于任何高级数学运算,你可以使用Math全局对象及其方法:

> Math.E
2.718281828459045
> Math.SQRT2
1.4142135623730951
> Math.abs(-900)
900
> Math.pow(2,3)
8

你可以使用parseInt()parseFloat()方法将字符串表达式转换为整数或浮点数:

> parseInt("230",10);
230
> parseInt("010",10);
10
> parseInt("010",8); //octal base
8
> parseInt("010",2); //binary
2
> + "4"
4

使用parseInt()时,你应该提供一个明确的基数,以防止在旧浏览器上出现糟糕的惊喜。最后一个技巧就是使用+号自动将"42"字符串转换为数字42。谨慎地处理parseInt()的结果与isNaN()。让我们看看以下示例:

var underterminedValue = "elephant";
if (isNaN(parseInt(underterminedValue,2))) 
{
   console.log("handle not a number case");
}
else
{
   console.log("handle number case");
}

在这个例子中,你无法确定underterminedValue变量如果从外部接口设置值可能持有的类型。如果isNaN()没有处理,parseInt()将引发异常,程序可能会崩溃。

字符串

在 JavaScript 中,字符串是 Unicode 字符的序列(每个字符占用 16 位)。字符串中的每个字符可以通过它的索引来访问。第一个字符的索引是零。字符串被"'括起来——两者都是表示字符串的有效方式。让我们看以下:

> console.log("Hippopotamus chewing gum");
Hippopotamus chewing gum
> console.log('Single quoted hippopotamus');
Single quoted hippopotamus
> console.log("Broken \n lines");
Broken
 lines

最后一行展示了当你用反斜杠\转义某些字符字面量时,它们可以作为特殊字符使用。以下是这样一些特殊字符的列表:

  • \n: 换行

  • \t: 制表符

  • \b: 退格

  • \r: 回车

  • \\: 反斜杠

  • \': 单引号

  • \": 双引号

你可以在 JavaScript 字符串中获得对特殊字符和 Unicode 字面量的默认支持:

> '\xA9'
'©'
> '\u00A9'
'©'

关于 JavaScript 字符串、数字和布尔值的一个重要事情是,它们实际上有包装对象围绕它们的原始等价物。以下示例展示了包装对象的使用:

var s = new String("dummy"); //Creates a String object
console.log(s); //"dummy"
console.log(typeof s); //"object"
var nonObject = "1" + "2"; //Create a String primitive 
console.log(typeof nonObject); //"string"
var objString = new String("1" + "2"); //Creates a String object
console.log(typeof objString); //"object"
//Helper functions
console.log("Hello".length); //5
console.log("Hello".charAt(0)); //"H"
console.log("Hello".charAt(1)); //"e"
console.log("Hello".indexOf("e")); //1
console.log("Hello".lastIndexOf("l")); //3
console.log("Hello".startsWith("H")); //true
console.log("Hello".endsWith("o")); //true
console.log("Hello".includes("X")); //false
var splitStringByWords = "Hello World".split(" ");
console.log(splitStringByWords); //["Hello", "World"]
var splitStringByChars = "Hello World".split("");
console.log(splitStringByChars); //["H", "e", "l", "l", "o", " ", "W", "o", "r", "l", "d"]
console.log("lowercasestring".toUpperCase()); //"LOWERCASESTRING"
console.log("UPPPERCASESTRING".toLowerCase()); //"upppercasestring"
console.log("There are no spaces in the end     ".trim()); //"There are no spaces in the end"

JavaScript 也支持多行字符串。用`(重音符号—en.wikipedia.org/wiki/Grave_accent)括起来的字符串被认为是多行。让我们看以下示例:

> console.log(`string text on first line
string text on second line `);
"string text on first line
string text on second line "

这种字符串也被称为模板字符串,可以用于字符串插值。JavaScript 允许使用这种语法进行 Python 式的字符串插值。

通常,你会做类似以下的事情:

var a=1, b=2;
console.log("Sum of values is :" + (a+b) + " and multiplication is :" + (a*b));

然而,在字符串插值中,事情变得更加清晰:

console.log(`Sum of values is :${a+b} and multiplication is : ${a*b}`);

未定义值

JavaScript 用两个特殊值来表示没有意义值——null,当非值是故意的,和 undefined,当值还没有分配给变量。让我们看以下示例:

> var xl;
> console.log(typeof xl);
undefined
> console.log(null==undefined);
true

布尔值

JavaScript 布尔原语由truefalse关键字表示。以下规则决定什么变成假,什么变成真:

  • 假,0,空字符串(""),NaN,null,和未定义被表示为假

  • 其他一切都是真

JavaScript 布尔值之所以棘手,主要是因为创建它们的方式行为差异很大。

在 JavaScript 中有两种创建布尔值的方法:

  • 你可以通过将一个真或假的字面量赋给一个变量来创建原始的布尔值。考虑以下示例:

    var pBooleanTrue = true;
    var pBooleanFalse = false;
    
    
  • 使用Boolean()函数;这是一个普通函数,返回一个原始的布尔值:

    var fBooleanTrue = Boolean(true);
    var fBooleanFalse = Boolean(false);
    
    

这两种方法都返回预期的真值假值。然而,如果你使用new操作符创建一个布尔对象,事情可能会出得很糟糕。

本质上,当你使用new操作符和Boolean(value)构造函数时,你不会得到一个原始的truefalse,你得到的是一个对象——不幸的是,JavaScript 认为一个对象是真值

var oBooleanTrue = new Boolean(true);
var oBooleanFalse = new Boolean(false);
console.log(oBooleanTrue); //true
console.log(typeof oBooleanTrue); //object
if(oBooleanFalse){
 console.log("I am seriously truthy, don't believe me");
}
>"I am seriously truthy, don't believe me"

if(oBooleanTrue){
 console.log("I am also truthy, see ?");
}
>"I am also truthy, see ?"

//Use valueOf() to extract real value within the Boolean object
if(oBooleanFalse.valueOf()){
 console.log("With valueOf, I am false"); 
}else{
 console.log("Without valueOf, I am still truthy");
}
>"Without valueOf, I am still truthy"

因此,明智的做法是始终避免使用 Boolean 构造函数来创建一个新的 Boolean 对象。这违反了布尔逻辑的基本合同,你应该远离这种难以调试的错误代码。

instanceof 操作符

使用引用类型存储值的一个问题一直是使用typeof操作符,它无论引用的是什么类型的对象,都会返回object。为了解决这个问题,你可以使用instanceof操作符。让我们看一些例子:

var aStringObject = new String("string");
console.log(typeof aStringObject);        //"object"
console.log(aStringObject instanceof String);    //true
var aString = "This is a string";
console.log(aString instanceof String);     //false

第三行返回false。当我们讨论原型链时,我们将讨论为什么会这样。

Date 对象

JavaScript 没有日期数据类型。相反,你可以使用Date对象及其方法来处理应用程序中的日期和时间。Date 对象相当全面,包含了许多处理大多数与日期和时间相关用例的方法。

JavaScript 将日期处理方式与 Java 相似。JavaScript 将日期存储为自 1970 年 1 月 1 日 00:00:00 以来的毫秒数。

你可以使用以下声明创建一个 Date 对象:

var dataObject = new Date([parameters]);

Date 对象构造函数的参数可以是以下形式:

  • 不带参数创建今天的日期和时间。例如,var today = new Date();

  • 一个表示日期为Month day, year hours:minutes:seconds的字符串。例如,var twoThousandFifteen = new Date("December 31, 2015 23:59:59");。如果你省略小时、分钟或秒,值将被设置为0

  • 一组表示年份、月份和日期的整数值。例如,var christmas = new Date(2015, 11, 25);

  • 一组表示年份、月份、日、时、分和秒的整数值。例如,var christmas = new Date(2015, 11, 25, 21, 00, 0);

以下是一些关于如何在 JavaScript 中创建和操作日期的示例:

var today = new Date();
console.log(today.getDate()); //27
console.log(today.getMonth()); //4
console.log(today.getFullYear()); //2015
console.log(today.getHours()); //23
console.log(today.getMinutes()); //13
console.log(today.getSeconds()); //10
//number of milliseconds since January 1, 1970, 00:00:00 UTC
console.log(today.getTime()); //1432748611392
console.log(today.getTimezoneOffset()); //-330 Minutes

//Calculating elapsed time
var start = Date.now();
// loop for a long time
for (var i=0;i<100000;i++);
var end = Date.now();
var elapsed = end - start; // elapsed time in milliseconds
console.log(elapsed); //71

对于任何需要对日期和时间对象进行细粒度控制的严肃应用程序,我们推荐使用诸如Moment.js(github.com/moment/moment), Timezone.js(github.com/mde/timezone-js), 或date.js(github.com/MatthewMueller/date))这样的库。这些库为你简化了很多重复任务,帮助你专注于其他更重要的事情。

+操作符

+操作符作为一元操作符使用时,对一个数字没有任何影响。然而,当应用于字符串时,+操作符将其转换为数字,如下所示:

var a=25;
a=+a;            //No impact on a's value  
console.log(a);  //25

var b="70";
console.log(typeof b); //string
b=+b;           //converts string to number
console.log(b); //70
console.log(typeof b); //number

程序员经常使用+操作符快速将字符串的数值表示转换为数字。然而,如果字符串字面量不能转换为数字,你会得到稍微不可预测的结果,如下所示:

var c="foo";
c=+c;            //Converts foo to number
console.log(c);  //NaN
console.log(typeof c);  //number

var zero="";
zero=+zero; //empty strings are converted to 0
console.log(zero);
console.log(typeof zero);

我们将在文本后面讨论+操作符对其他几种数据类型的影响。

++和--操作符

++运算符是将值增加 1 的简写,--运算符是将值减少 1 的简写。Java 和 C 有等效的运算符,大多数人熟悉它们。这个怎么样?

var a= 1;
var b= a++;
console.log(a); //2
console.log(b); //1

呃,这里发生了什么?b变量不应该有值2吗?++和--运算符是可以作为前缀或后缀使用的单目运算符。它们的使用顺序很重要。当++用作前缀形式如++a时,它在值从表达式返回之前增加值,而不是像a++那样在值返回之后增加。让我们看看以下代码:

var a= 1;
var b= ++a;
console.log(a);  //2
console.log(b);  //2

许多程序员使用链式赋值来为多个变量分配单个值,如下所示:

var a, b, c;
a = b = c = 0;

这是可以的,因为赋值运算符(=)导致值被赋值。在这个例子中,c=0被评估为0;这将导致b=0也被评估为0,因此,a=0也被评估。

然而,对前面例子的微小修改将产生非凡的结果。考虑这个:

var a = b = 0;

在这个例子中,只有变量a是用var声明的,而变量b被创建成了一个意外的全局变量。(如果你处于严格模式,这将产生一个错误。)在 JavaScript 中,小心你所希望的,你可能会得到它。

布尔运算符

JavaScript 中有三个布尔运算符——与(&), 或(|), 非(!)。

在讨论逻辑与和或运算符之前,我们需要了解它们是如何产生布尔结果的。逻辑运算符从左到右求值,并且它们是按照以下短路规则进行测试的:

  • 逻辑与:如果第一个操作数确定了结果,第二个操作数就不会被评估。

    在下面的示例中,我突出了如果它作为短路评估规则的一部分被执行时的右表达式:

    console.log(true  && true); // true AND true returns true
    console.log(true  && false);// true AND false returns false
    console.log(false && true);// false AND true returns false
    console.log("Foo" && "Bar");// Foo(true) AND Bar(true) returns Bar
    console.log(false && "Foo");// false && Foo(true) returns false
    console.log("Foo" && false);// Foo(true) && false returns false
    console.log(false && (1 == 2));// false && false(1==2) returns false
    
  • 逻辑或:如果第一个操作数是真,第二个操作数就不会被评估:

    console.log(true  || true); // true AND true returns true
    console.log(true  || false);// true AND false returns true
    console.log(false || true);// false AND true returns true
    console.log("Foo" || "Bar");// Foo(true) AND Bar(true) returns Foo
    console.log(false || "Foo");// false && Foo(true) returns Foo
    console.log("Foo" || false);// Foo(true) && false returns Foo
    console.log(false || (1 == 2));// false && false(1==2) returns false
    

    然而,逻辑与和逻辑或也可以用于非布尔操作数。当左操作数或右操作数不是原始布尔值时,与和或运算符不返回布尔值。

现在我们将解释三个逻辑布尔运算符:

  • 逻辑与(&&):如果第一个操作数对象是假值,它返回那个对象。如果它是真值,第二个操作数对象将被返回:

    console.log (0 && "Foo");  //First operand is falsy - return it
    console.log ("Foo" && "Bar"); //First operand is truthy, return the second operand
    
  • 逻辑或(||):如果第一个操作数是真值,它将被返回。否则,第二个操作数将被返回:

    console.log (0 || "Foo");  //First operand is falsy - return second operand
    console.log ("Foo" || "Bar"); //First operand is truthy, return it
    console.log (0 || false); //First operand is falsy, return second operand
    

    逻辑或的典型用途是为变量分配默认值:

    function greeting(name){
        name = name || "John";
        console.log("Hello " + name);
    }
    
    greeting("Johnson"); // alerts "Hi Johnson";
    greeting(); //alerts "Hello John"
    

    你将在大多数专业的 JavaScript 库中频繁看到这个模式。你应该理解如何使用逻辑或运算符来实现默认值。

  • 逻辑非:这总是返回一个布尔值。返回的值取决于以下情况:

    //If the operand is an object, false is returned.
    var s = new String("string");
    console.log(!s);              //false
    
    //If the operand is the number 0, true is returned.
    var t = 0;
    console.log(!t);              //true
    
    //If the operand is any number other than 0, false is returned.
    var x = 11;
    console.log(!x);              //false
    
    //If operand is null or NaN, true is returned
    var y =null;
    var z = NaN;
    console.log(!y);              //true
    console.log(!z);              //true
    //If operand is undefined, you get true
    var foo;
    console.log(!foo);            //true
    

此外,JavaScript 支持类似于 C 的三元运算符,如下所示:

var allowedToDrive = (age > 21) ? "yes" : "no";

如果(age>21)?后面的表达式将被赋值给allowedToDrive变量,否则:后面的表达式将被赋值。这相当于一个 if-else 条件语句。让我们看另一个例子:

function isAllowedToDrive(age){
  if(age>21){
    return true;
  }else{
    return false;
  }
}
console.log(isAllowedToDrive(22));

在这个例子中,isAllowedToDrive()函数接受一个整数参数age。根据这个变量的值,我们返回真或假给调用函数。这是一个众所周知且最熟悉的 if-else 条件逻辑。大多数时候,if-else 使代码更容易阅读。对于单一条件的简单情况,使用三元运算符也可以,但如果你看到你正在为更复杂的表达式使用三元运算符,尝试坚持使用 if-else,因为解析 if-else 条件比解析一个非常复杂的三元表达式要容易。

条件语句可以如下嵌套:

if (condition1) {
  statement1
} else if (condition2) {
  statement2
} else if (condition3) {
  statement3
}
..
} else {
  statementN
}

纯粹出于审美原因,你可以像下面这样缩进嵌套的else if

if (condition1) {
  statement1
} else
    if (condition2) {

不要在条件语句的地方使用赋值语句。大多数时候,它们被使用是因为下面的错误:

if(a=b) {
  //do something
}

大多数时候,这是由于错误造成的;意图中的代码是if(a==b),或者更好,if(a===b)。当你犯这个错误,并用赋值语句替换条件语句时,你最终会犯一个非常难以发现的错误。然而,如果你真的想在一个 if 语句中使用赋值语句,请确保你的意图非常明确。

一种方法是在你的赋值语句周围加上额外的括号:

if((a=b)){
  //this is really something you want to do
}

处理条件执行的另一种方法是使用 switch-case 语句。JavaScript 中的 switch-case 结构与 C 或 Java 中的类似。让我们看以下例子:

function sayDay(day){
  switch(day){
    case 1: console.log("Sunday");
      break;
    case 2: console.log("Monday");
      break;
    default:
      console.log("We live in a binary world. Go to Pluto");
  }
}

sayDay(1); //Sunday
sayDay(3); //We live in a binary world. Go to Pluto

这种结构的一个问题是,你有break语句在每一个case后面;否则,执行将会递归到下一级。如果我们从第一个case语句中移除break语句,输出将会如下:

>sayDay(1);
Sunday
Monday

正如您所看到的,如果我们省略break语句,在条件满足后立即中断执行,执行顺序会继续递归到下一级。这可能会导致代码中难以检测到的问题。然而,如果你打算一直递归到下一级,这种写条件逻辑的方式也很流行:

function debug(level,msg){
  switch(level){
    case "INFO": //intentional fall-through
    case "WARN" :  
    case "DEBUG": console.log(level+ ": " + msg);  
      break;
    case "ERROR": console.error(msg);  
  }
}

debug("INFO","Info Message");
debug("DEBUG","Debug Message");
debug("ERROR","Fatal Exception");

在这个例子中,我们故意让执行递归,以编写简洁的 switch-case。如果级别是 INFO、WARN 或 DEBUG,我们使用 switch-case 递归到单一点执行。我们省略这个break语句。如果你想遵循这种写 switch 语句的模式,请确保你记录你的使用方式,以提高可读性。

switch 语句可以有一个default案例,用来处理任何不能被其他案例评估的值。

JavaScript 有一个 while 和 do-while 循环。while 循环让你迭代一系列表达式,直到满足某个条件。以下第一个例子迭代了{}内的语句,直到i<10表达式为真。记住,如果i计数器的值已经大于10,循环根本不会执行:

var i=0;
while(i<10){
  i=i+1;
  console.log(i);
}

下面的循环会一直执行到无穷大,因为条件总是为真——这可能导致灾难性的后果。你的程序可能会耗尽你所有的内存,或者更糟糕的事情:

//infinite loop
while(true){
  //keep doing this
}

如果你想要确保至少执行一次循环,你可以使用 do-while 循环(有时被称为后置条件循环):

var choice;
do {
  choice=getChoiceFromUserInput();
} while(!isInputValid(input));

在这个例子中,我们要求用户输入,直到我们找到有效的用户输入。当用户输入无效时,我们继续要求用户输入。人们总是认为,从逻辑上讲,每个 do-while 循环都可以转换为 while 循环。然而,do-while 循环有一个非常有效的用例,就像我们刚才看到的那样,你希望在循环块执行一次之后才检查条件。

JavaScript 有一个非常强大的循环,类似于 C 或 Java——for 循环。for 循环之所以流行,是因为它允许你在一句话中定义循环的控制条件。

下面的例子会打印五次Hello

for (var i=0;i<5;i++){
  console.log("Hello");
}

在循环的定义中,你定义了循环计数器i的初始值为0,定义了i<5的退出条件,最后定义了增量因子。

前面例子中的所有三个表达式都是可选的。如果需要,你可以省略它们。例如,下面的变体都将产生与之前循环相同的结果:

var x=0;
//Omit initialitzation
for (;x<5;x++){
  console.log("Hello");
}

//Omit exit condition
for (var j=0;;j++){
  //exit condition
  if(j>=5){
    break;  
  }else{
    console.log("Hello");
  }
}
//Omit increment
for (var k=0; k<5;){
  console.log("Hello");
  k++;
}

你也可以省略这三个表达式,写 for 循环。一个经常使用的有趣习惯是用 for 循环与空语句。下面的循环用于将数组的所有元素设置为100。注意循环体内没有主体:

var arr = [10, 20, 30];
// Assign all array values to 100
for (i = 0; i < arr.length; arr[i++] = 100);
console.log(arr);

这里的空语句只是我们在 for 循环语句之后看到的那个单一的语句。增量因子也修改了数组内容。我们将在书的后面讨论数组,但在这里,只要看到循环定义本身将数组元素设置为100值就足够了。

等价

JavaScript 提供了两种等价模式——严格和宽松。本质上,宽松等价在比较两个值时会执行类型转换,而严格等价则不进行任何类型转换的检查。严格等价检查由=完成,而宽松等价检查由完成。

ECMAScript 6 还提供了Object.is方法来进行与=相同的严格等价检查。然而,Object.is对 NaN 有特殊的处理:-0 和+0。当*NaN=NaNNaN==NaN*评估为假时,Object.is(NaN,NaN)将返回真。

严格等价使用===

严格等价比较两个值而不进行任何隐式的类型转换。以下规则适用:

  • 如果值属于不同的类型,它们是不相等的。

  • 对于相同类型的非数字值,如果它们的值相同,它们是相等的。

  • 对于原始数字,严格等价对于值来说是有效的。如果值相同,===结果为true。然而,NaN 不等于任何数字,所以NaN===<一个数字>将会是false

严格等价始终是正确的等价检查。始终使用=而不是作为等价检查的规则:

条件 输出
"" === "0"; 错误
0 === ""; 错误
0 === "0"; 错误
false === "false"; 错误
false === "0"; 错误
false === undefined; 错误
false === null; 错误
null === undefined; 错误

在比较对象时,结果如下:

条件 输出
{} === {}; 错误
new String('bah') === 'bah'; 错误
new Number(1) === 1; 错误
var bar = {};``bar === bar; 正确

以下是在 JS Bin 或 Node REPL 上尝试的进一步示例:

var n = 0;
var o = new String("0");
var s = "0";
var b = false;

console.log(n === n); // true - same values for numbers
console.log(o === o); // true - non numbers are compared for their values
console.log(s === s); // true - ditto

console.log(n === o); // false - no implicit type conversion, types are different
console.log(n === s); // false - types are different
console.log(o === s); // false - types are different
console.log(null === undefined); // false
console.log(o === null); // false
console.log(o === undefined); // false

当进行严格等价检查时,你可以使用!==来处理不等于的情况。

使用==的弱等价

绝对不要诱惑你使用这种等价形式。严肃地说,离这种形式远一点。这种等价形式主要是由于 JavaScript 的弱类型而有很多问题。等价操作符==,首先尝试强制类型然后再进行比较。以下示例展示了它是如何工作的:

条件 输出
"" == "0"; 错误
0 == ""; 正确
0 == "0"; 正确
false == "false"; 错误
false == "0"; 正确
false == undefined; 错误
false == null; 错误
null == undefined; 正确

从这些示例中,可以看出弱等价可能会导致意外的结果。此外,隐式强制类型转换在性能上是有代价的。所以,一般来说,在 JavaScript 中应避免使用弱等价。

javascript 类型

我们简要讨论了 JavaScript 是一种动态语言。如果你有使用强类型语言如 Java 的先前经验,你可能会对完全缺乏你所熟悉的类型检查感到有些不舒服。纯粹主义者认为 JavaScript 应该声称有标签或者也许是子类型,但不是类型。尽管 JavaScript 没有传统意义上的类型定义,但深入理解 JavaScript 如何处理数据类型和强制类型转换内部是绝对必要的。每个非平凡的 JavaScript 程序都需要以某种形式处理值强制类型转换,所以理解这个概念很重要。

显式强制类型转换发生在当你自己修改类型时。在以下示例中,你将使用toString()方法将一个数字转换为字符串并从中提取第二个字符:

var fortyTwo = 42;
console.log(fortyTwo.toString()[1]); //prints "2"

这是一个显式类型转换的例子。再次强调,我们在这里使用“类型”这个词是比较宽泛的,因为在声明fortyTwo变量时,并没有任何地方强制类型。

然而,强制转换发生的许多不同方式。显式强制转换可能容易理解且 mostly 可靠;但如果你不小心,强制转换可能会以非常奇怪和惊讶的方式发生。

围绕强制转换的混淆可能是 JavaScript 开发者谈论最多的挫折之一。为了确保你心中永远不会有这种混淆,让我们重新回顾一下 JavaScript 中的类型。我们之前谈过一些概念:

typeof 1             === "number";    // true
typeof "1"           === "string";    // true
typeof { age: 39 }   === "object";    // true
typeof Symbol()      === "symbol";    // true
typeof undefined     === "undefined"; // true
typeof true          === "boolean";   // true

到目前为止,还不错。我们已经知道了这些,我们刚才看到的一些例子加强了我们对于类型的想法。

从一个类型转换到另一个类型的值的转换称为类型转换或显式强制转换。JavaScript 也通过根据某些猜测改变值的类型来进行隐式强制转换。这些猜测使 JavaScript 在几种情况下发挥作用,不幸的是,它默默地、意外地失败了。以下代码段显示了显式和隐式强制转换的情况:

var t=1;
var u=""+t; //implicit coercion
console.log(typeof t);  //"number"
console.log(typeof u);  //"string"
var v=String(t);  //Explicit coercion
console.log(typeof v);  //"string"
var x=null
console.log(""+x); //"null"

很容易看出这里发生了什么。当你用""+t对数字值t(在这个例子中是1)进行操作时,JavaScript 意识到你试图将某种东西与一个""字符串连接起来。因为只有字符串才能与其他字符串连接,所以 JavaScript 前进并把一个数字1转换为一个"1"字符串,然后将两者连接成一个字符串值。这就是当 JavaScript 被要求隐式转换值时会发生的事情。然而,String(t)是一个明确调用数字转换为字符串的。这是一个类型的显式转换。最后的部分令人惊讶。我们正在将null""连接——这不应该失败吗?

那么 JavaScript 是如何进行类型转换的呢?一个抽象值如何变成字符串或数字或布尔值?JavaScript 依赖于toString()toNumber()toBoolean()方法来进行这些内部转换。

当一个非字符串值被强制转换为字符串时,JavaScript 内部使用toString()方法来完成这个转换。所有原始值都有自然的字符串形式——null的自然字符串形式是"null"undefined的自然字符串形式是"undefined",依此类推。对于 Java 开发者来说,这类似于一个类有一个toString()方法,返回该类的字符串表示。我们将看到对象的情况是如何工作的。

所以本质上,你可以做类似以下的事情:

var a="abc";
console.log(a.length);
console.log(a.toUpperCase());
As we discussed earlier, JavaScript kindly wraps these primitives in their wrappers by default thus making it possible for us to directly access the wrapper's methods and properties as if they were of the primitives themselves.

当任何非数字值需要被转换为数字时,JavaScript 内部使用toNumber()方法:true变成1undefined变成NaNfalse变成0null变成0。字符串上的toNumber()方法与字面转换一起工作,如果这个失败了,方法返回NaN

其他一些情况呢?

typeof null ==="object" //true

好吧,null是一个对象?是的,一个特别持久的错误使得这成为可能。由于这个错误,你在测试一个值是否为null时需要小心:

var x = null;
if (!x && typeof x === "object"){
  console.log("100% null");
}

那么可能还有其他具有类型的东西,比如函数呢?

f = function test() {
  return 12;
}
console.log(typeof f === "function");  //prints "true"

那么数组呢?

console.log (typeof [1,2,3,4]); //"object"

确实如此,它们也是对象。我们将在书的后面详细介绍函数和数组。

在 JavaScript 中,值有类型,变量没有。由于语言的动态特性,变量可以随时持有任何值。

JavaScript 不强制类型,这意味着该语言不坚持变量始终持有与初始类型相同的值。变量可以持有字符串,然后在下一个赋值中持有数字,依此类推:

var a = 1; 
typeof a; // "number"  
a = false; 
typeof a; // "boolean"

typeof 操作符总是返回一个字符串:

typeof typeof 1; // "string"

自动分号插入

尽管 JavaScript 基于 C 风格语法,但它不强制在源代码中使用分号。

然而,JavaScript 并不是一个无分号的语言。JavaScript 语言解析器需要分号来理解源代码。因此,当解析器遇到由于缺少分号而导致的解析错误时,它会自动插入分号。需要注意的是,自动分号插入ASI)只有在存在换行符(也称为行 break)时才会生效。分号不会在一行中间插入。

基本上,如果 JavaScript 解析器解析一行,在该行中会发生解析错误(缺少预期的分号)并且它可以插入一个,它会这样做。插入分号的条件是什么?只有当某些语句的末尾和该行的换行符/行 break 之间只有空白字符和/或注释时。

关于 ASI 一直有激烈的争论——一个合理地被认为是设计选择非常糟糕的功能。网络上进行了史诗般的讨论,例如github.com/twbs/bootstrap/issues/3057brendaneich.com/2012/04/the-infernal-semicolon/

在判断这些论点的有效性之前,你需要了解 ASI 影响了什么。以下受 ASI 影响的声明:

  • 一个空声明

  • 一个 var 声明

  • 一个表达式声明

  • 一个 do-while 声明

  • 一个 continue 声明

  • 一个 break 声明

  • 一个 return 声明

  • 一个 throw 声明

ASI 背后的想法是使分号在行的末尾成为可选。这样,ASI 帮助解析器确定语句何时结束。通常,它在分号处结束。ASI 规定,在以下情况下语句也以如下情况结束:

  • 换行符(例如,换行符)后面跟着一个非法令牌

  • 遇到一个闭合括号

  • 文件已达到末尾

让我们看看以下示例:

if (a < 1) a = 1 console.log(a)

在 1 之后 console 令牌是非法的,并按照以下方式触发 ASI:

if (a < 1) a = 1; console.log(a);

在下面的代码中,大括号内的语句没有用分号终止:

function add(a,b) { return a+b }

ASI 为前面的代码创建了一个语法上正确的版本:

function add(a,b) { return a+b; }

JavaScript 风格指南

每种编程语言都会发展出自己的风格和结构。不幸的是,新开发者并没有付出太多努力去学习一门语言的风格细微差别。一旦养成了坏习惯,后来要发展这项技能就非常困难了。为了生成美观、可读且易于维护的代码,学习正确的风格是非常重要的。有很多样式建议。我们将选择最实用的那些。在适用的情况下,我们将讨论合适的样式。让我们设定一些风格基础规则。

空白符

虽然空白符在 JavaScript 中并不重要,但正确使用空白符可以使代码更易读。以下指南将帮助你在代码中管理空白符:

  • 不要混合空格和制表符。

  • 在你编写任何代码之前,选择使用软缩进(空格)或真正的制表符。为了提高可读性,我总是建议你将编辑器的缩进大小设置为两个字符——这意味着两个空格或两个空格表示一个真正的制表符。

  • 始终开启显示不可见字符设置。这种做法的好处如下:

    • 强制一致性。

    • 消除行尾的空白符。

    • 消除行尾的空白符。

    • 提交和差异更容易阅读。

    • 在可能的情况下使用EditorConfig (editorconfig.org/)。

括号、换行符和括号

如果、否则、for、while 和 try 总是有空格和括号,并且跨越多行。这种风格有助于提高可读性。让我们看看以下的代码:

//Cramped style (Bad)
if(condition) doSomeTask();

while(condition) i++;

for(var i=0;i<10;i++) iterate();

//Use whitespace for better readability (Good)
//Place 1 space before the leading brace.
if (condition) {
  // statements
}

while ( condition ) {
  // statements
}

for ( var i = 0; i < 100; i++ ) {
  // statements
}

// Better:

var i,
    length = 100;

for ( i = 0; i < length; i++ ) {
  // statements
}

// Or...

var i = 0,
    length = 100;

for ( ; i < length; i++ ) {
  // statements
}

var value;

for ( value in object ) {
  // statements
}

if ( true ) {
  // statements
} else {
  // statements
}

//Set off operators with spaces.
// bad
var x=y+5;

// good
var x = y + 5;

//End files with a single newline character.
// bad
(function(global) {
  // ...stuff...
})(this);

// bad
(function(global) {
  // ...stuff...
})(this);↵
↵

// good
(function(global) {
  // ...stuff...
})(this);↵

引号

无论你更喜欢单引号还是双引号,都不应该有区别;JavaScript 解析它们的方式没有区别。然而,为了保持一致性,同一个项目中不要混合引号。选择一种风格并坚持使用。

行尾和空行

空白符可能会使代码差异和更改列表无法辨认。许多编辑器允许你自动删除额外的空行和行尾空格——你应该使用这些功能。

类型检查

检查一个变量的类型可以按照如下方式进行:

//String:
typeof variable === "string"
//Number:
typeof variable === "number"
//Boolean:
typeof variable === "boolean"
//Object:
typeof variable === "object"
//null:
variable === null
//null or undefined:
variable == null

类型转换

如下在语句开头执行类型强制:

// bad
const totalScore = this.reviewScore + '';
// good
const totalScore = String(this.reviewScore);

对数字使用parseInt(),并且总是需要一个基数来进行类型转换:

const inputValue = '4';
// bad
const val = new Number(inputValue);
// bad
const val = +inputValue;
// bad
const val = inputValue >> 0;
// bad
const val = parseInt(inputValue);
// good
const val = Number(inputValue);
// good
const val = parseInt(inputValue, 10);

以下示例向你展示了如何使用布尔值进行类型转换:

const age = 0;  // bad 
const hasAge = new Boolean(age);  // good 
const hasAge = Boolean(age); // good 
const hasAge = !!age;

条件评估

有关条件语句的样式指南有很多。让我们研究一下以下的代码:

// When evaluating that array has length,
// WRONG:
if ( array.length > 0 ) ...

// evaluate truthiness(GOOD):
if ( array.length ) ...

// When evaluating that an array is empty,
// (BAD):
if ( array.length === 0 ) ...

// evaluate truthiness(GOOD):
if ( !array.length ) ...

// When checking if string is not empty,
// (BAD):
if ( string !== "" ) ...

// evaluate truthiness (GOOD):
if ( string ) ...

// When checking if a string is empty,
// BAD:
if ( string === "" ) ...

// evaluate falsy-ness (GOOD):
if ( !string ) ...

// When checking if a reference is true,
// BAD:
if ( foo === true ) ...

// GOOD
if ( foo ) ...

// When checking if a reference is false,
// BAD:
if ( foo === false ) ...

// GOOD
if ( !foo ) ...

// this will also match: 0, "", null, undefined, NaN
// If you MUST test for a boolean false, then use
if ( foo === false ) ...

// a reference that might be null or undefined, but NOT false, "" or 0,
// BAD:
if ( foo === null || foo === undefined ) ...

// GOOD
if ( foo == null ) ...

// Don't complicate matters
return x === 0 ? 'sunday' : x === 1 ? 'Monday' : 'Tuesday';

// Better:
if (x === 0) {
    return 'Sunday';
} else if (x === 1) {
    return 'Monday';
} else {
    return 'Tuesday';
}

// Even Better:
switch (x) {
    case 0:
        return 'Sunday';
    case 1:
        return 'Monday';
    default:
        return 'Tuesday';
}

命名

命名非常重要。我敢肯定你遇到过命名简短且难以辨认的代码。让我们研究一下以下代码行:

//Avoid single letter names. Be descriptive with your naming.
// bad
function q() {

}

// good
function query() {
}

//Use camelCase when naming objects, functions, and instances.
// bad
const OBJEcT = {};
const this_is_object = {};
function c() {}

// good
const thisIsObject = {};
function thisIsFunction() {}

//Use PascalCase when naming constructors or classes.
// bad
function user(options) {
  this.name = options.name;
}

const bad = new user({
  name: 'nope',
});

// good
class User {
  constructor(options) {
    this.name = options.name;
  }
}

const good = new User({
  name: 'yup',
});

// Use a leading underscore _ when naming private properties.
// bad
this.__firstName__ = 'Panda';
this.firstName_ = 'Panda';

// good
this._firstName = 'Panda';

eval()方法是邪恶的

eval():
console.log(typeof eval(new String("1+1"))); // "object"
console.log(eval(new String("1+1")));        //1+1
console.log(eval("1+1"));                    // 2
console.log(typeof eval("1+1"));             // returns "number"
var expression = new String("1+1");
console.log(eval(expression.toString()));    //2

我将避免展示eval()的其他用途,并确保你被劝阻得足够,从而远离它。

严格模式

ECMAScript 5 有一个严格模式,结果是更干净的 JavaScript,具有更少的危险功能、更多的警告和更逻辑的行为。正常(非严格)模式也称为松散模式。严格模式可以帮助你避免一些松散编程实践。如果你正在启动一个新的 JavaScript 项目,我强烈建议你默认使用严格模式。

要开启严格模式,你需要在你的 JavaScript 文件或你的<script>元素中首先输入以下行:

'use strict';

请注意,不支持 ECMAScript 5 的 JavaScript 引擎将简单地忽略前述语句,并以非严格模式继续执行。

如果你想要为每个函数开启严格模式,你可以这样做:

function foo() {
    'use strict';

}

当你与遗留代码库合作时,这很方便,因为在大范围内开启严格模式可能会破坏事物。

如果你正在处理现有的遗留代码,要小心,因为使用严格模式可能会破坏事物。这一点有告诫:

为现有代码启用严格模式可能会破坏它

代码可能依赖于不再可用的功能或与松散模式和严格模式不同的行为。不要忘记你有选项可以向处于松散模式的文件中添加单个严格模式函数。

小心地封装

当你连接和/或压缩文件时,你必须小心,确保严格模式在应该开启的地方没有关闭或相反。两者都可能破坏代码。

以下部分详细解释了严格模式的功能。你通常不需要了解它们,因为你大部分时候会因为不应该做的事情而收到警告。

在严格模式下,变量必须声明

在严格模式下,所有变量都必须显式声明。这有助于防止打字错误。在松散模式下,对未声明变量的赋值将创建一个全局变量:

function sloppyFunc() {
  sloppyVar = 123; 
} sloppyFunc();  // creates global variable `sloppyVar`
console.log(sloppyVar);  // 123

在严格模式下,对未声明变量的赋值会抛出异常:

function strictFunc() {
  'use strict';
  strictVar = 123;
}
strictFunc();  // ReferenceError: strictVar is not defined
在严格模式下,eval()函数更简洁

在严格模式下,eval()函数变得不那么怪异:在评估的字符串中声明的变量不再添加到围绕eval()的作用域中。

在严格模式下被阻止的功能

不允许使用 with 语句。(我们将在书中稍后讨论这个问题。)在编译时间(加载代码时)你会得到一个语法错误。

在松散模式下,带前导零的整数被解释为八进制(基数 8)如下:

> 010 === 8 true

在严格模式下,如果你使用这种字面量,你会得到一个语法错误:

function f() { 
'use strict'; 
return 010 
} 
//SyntaxError: Octal literals are not allowed in 

运行 JSHint

JSHint 是一个程序,用于标记使用 JavaScript 编写的程序中的可疑用法。该项目核心包括本身作为一个库以及作为 Node 模块分发的命令行界面CLI)程序。

如果你安装了 Node.js,你可以使用npm如下安装 JSHint:

npm install jshint –g

test.js file:
function f(condition) {
  switch (condition) {
  case 1:
    console.log(1);
  case 2:
    console.log(1);
  }
}

当我们使用 JSHint 运行文件时,它将警告我们在 switch case 中缺少break语句,如下所示:

>jshint test.js
test.js: line 4, col 19, Expected a 'break' statement before 'case'.
1 error

JSHint 可以根据您的需求进行配置。查看jshint.com/docs/的文档,了解如何根据您的项目需求自定义 JSHint。我广泛使用 JSHint,并建议您开始使用它。您会惊讶地发现,使用这样一个简单的工具,您能够在代码中修正多少隐藏的错误和风格问题。

您可以在项目的根目录下运行 JSHint,并对整个项目进行 lint 检查。您可以在.jshintrc文件中放置 JSHint 指令。这个文件可能如下所示:

{
     "asi": false,
     "expr": true,
     "loopfunc": true,
     "curly": false,
     "evil": true,
     "white": true,
     "undef": true,
     "indent": 4
}

总结

在本章中,我们围绕 JavaScript 语法、类型和风格考虑方面设定了一些基础。我们故意没有讨论其他重要方面,如函数、变量作用域和闭包,主要是因为它们应该在这本书中有自己的独立章节。我相信这一章节帮助你理解了 JavaScript 的一些基本概念。有了这些基础,我们将看看如何编写专业质量的 JavaScript 代码。

第二章:函数、闭包和模块

在上一章中,我们故意没有讨论 JavaScript 的某些方面。这些是赋予 JavaScript 其力量和优雅的一些语言特性。如果你是一个中级或高级的 JavaScript 程序员,你可能正在积极使用对象和函数。然而,在许多情况下,开发者在这些基本层面上绊倒,对 JavaScript 核心构造产生了半生不熟或有时错误的理解。由于对 JavaScript 中闭包概念的普遍理解不足,许多程序员无法很好地使用 JavaScript 的功能方面。在 JavaScript 中,对象、函数和闭包之间有很强的相互联系。理解这三个概念之间强烈的关系可以大大提高我们的 JavaScript 编程能力,为我们提供任何类型应用程序开发坚实的基础。

函数是 JavaScript 的基础。理解 JavaScript 中的函数是你武器库中最重要的武器。关于函数最重要的事实是,在 JavaScript 中,函数是第一类对象。它们像任何其他 JavaScript 对象一样被对待。与其他 JavaScript 数据类型一样,它们可以被变量引用,通过字面量声明,甚至可以作为函数参数传递。

就像 JavaScript 中的任何其他对象一样,函数具有以下能力:

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

  • 它们可以分配给变量、数组元素和其他对象的属性

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

  • 它们可以从函数中作为值返回

  • 它们可以拥有动态创建和赋值的属性

在本章以及本书的剩余部分,我们将讨论 JavaScript 函数的这些独特能力。

函数字面量

JavaScript 中最重要的概念之一是函数是执行的主要单位。函数是你会包裹所有代码的地方,因此它们会给你的程序带来结构。

JavaScript 函数是通过函数字面量声明的。

函数字面量由以下四个部分组成:

  • 函数关键字。

  • 可选的名称,如果指定,必须是一个有效的 JavaScript 标识符。

  • 用括号括起来的参数名称列表。如果函数没有参数,你需要提供空括号。

  • 函数体,作为一系列用花括号括起来的 JavaScript 语句。

函数声明

下面是一个非常简单的例子,用于展示函数声明的所有组成部分:

function add(a,b){
  return a+b;
}
c = add(1,2);
console.log(c);  //prints 3

这种声明以function关键词开头,后接函数名。函数名是可选的。如果一个函数没有指定名称,则称其为匿名函数。我们将看到匿名函数是如何使用的。第三部分是一组函数参数,被括号括起来。括号内是一组零个或多个由逗号分隔的参数名称。这些名称将在函数中被定义为变量,并且它们不会初始化为 undefined,而是初始化为函数调用时提供的参数。第四部分是一组用大括号括起来的语句。这些语句是函数的主体。当函数被调用时,它们将被执行。

这种函数声明方法也被称为函数语句。当你这样声明函数时,函数的内容将被编译,并且会创建一个与函数同名的对象。

另一种函数声明方式是通过函数表达式

var add = function(a,b){
  return a+b;
}
c = add(1,2);
console.log(c);  //prints 3

在这里,我们创建了一个匿名函数并将其赋值给一个add变量;这个变量像之前的例子一样用来调用函数。这种函数声明方式的一个问题是,我们无法进行这种函数的递归调用。递归是一种优雅的编程方式,函数调用自己。你可以使用命名的函数表达式来解决这个限制。作为一个例子,参考以下计算给定数字n的阶乘的函数:

var facto = function factorial(n) {
  if (n <= 1)
    return 1;
  return n * factorial(n - 1);
};
console.log(facto(3));  //prints 6

在这里,你没有创建一个匿名函数,而是创建了一个有名字的函数。现在,因为函数有一个名字,所以它可以递归地调用自己。

最后,你可以创建自调用函数表达式(我们稍后讨论它们):

(function sayHello() {
  console.log("hello!");
})();

一旦定义,一个函数可以在其他 JavaScript 函数中被调用。函数体执行完毕后,调用者代码(执行函数的代码)将继续执行。你还可以将一个函数作为参数传递给另一个函数:

function changeCase(val) {
  return val.toUpperCase();
}
function demofunc(a, passfunction) {
  console.log(passfunction(a));
}
demofunc("smallcase", changeCase);

在前面的示例中,我们用两个参数调用demofunc()函数。第一个参数是我们想要转换为大写的字符串,第二个参数是changeCase()函数的函数引用。在demofunc()中,我们通过传递给passfunction参数的引用调用changeCase()函数。在这里,我们通过将函数引用作为参数传递给另一个函数来传递一个函数引用。这个强大的概念将在书中讨论回调的部分详细讨论。

一个函数可能返回一个值,也可能不返回值。在前面的例子中,我们看到add函数向调用代码返回了一个值。除了在函数末尾返回一个值外,显式调用return还可以让你有条件地从函数中返回:

var looper = function(x){
  if (x%5===0) {
    return;
  }
  console.log(x)
}
for(var i=1;i<10;i++){
  looper(i);
}
1, 2, 3, 4, 6, 7, 8, and 9, and not 5\. When the if (x%5===0) condition is evaluated to true, the code simply returns from the function and the rest of the code is not executed.

函数作为数据

在 JavaScript 中,函数可以赋值给变量,而变量是数据。你很快就会看到这是一个强大的概念。让我们看以下示例:

var say = console.log;
say("I can also say things");

在前面的例子中,我们将熟悉的console.log()函数赋值给 say 变量。任何函数都可以赋值给一个变量,正如前面例子所示。给变量添加括号将调用它。此外,你还可以将函数作为参数传递给其他函数。仔细研究下面的例子并在 JS Bin 中键入它:

var validateDataForAge = function(data) {
 person = data();
  console.log(person);
  if (person.age <1 || person.age > 99){
    return true;
  }else{
    return false;
  }
};

var errorHandlerForAge = function(error) {
  console.log("Error while processing age");
};

function parseRequest(data,validateData,errorHandler) {
  var error = validateData(data);
  if (!error) {
    console.log("no errors");
  } else {
    errorHandler();
  }
}

var generateDataForScientist = function() {
  return {
    name: "Albert Einstein",
    age : Math.floor(Math.random() * (100 - 1)) + 1,
  };
};
var generateDataForComposer = function() {
  return {
    name: "J S Bach",
    age : Math.floor(Math.random() * (100 - 1)) + 1,
  };
};

//parse request
parseRequest(generateDataForScientist, validateDataForAge, errorHandlerForAge);
parseRequest(generateDataForComposer, validateDataForAge, errorHandlerForAge);

在这个例子中,我们正在将函数作为参数传递给parseRequest()函数。我们为两个不同的调用传递了不同的函数,generateDataForScientistgenerateDataForComposers,而其他两个函数保持不变。你可以观察到我们定义了一个通用的parseRequest()。它接受三个函数作为参数,这些函数负责拼接具体内容:数据、验证器、和错误处理程序。parseRequest()函数是完全可扩展和可定制的,并且因为它将被每个请求调用,所以有一个单一、干净的调试点。我敢肯定你已经开始欣赏 JavaScript 函数所提供的强大功能。

作用域

对于初学者来说,JavaScript 的作用域稍微有些令人困惑。这些概念可能看起来很简单;然而,并非如此。存在一些重要的细微差别,必须理解才能掌握这个概念。那么作用域是什么?在 JavaScript 中,作用域指的是代码的当前上下文。

变量的作用域是变量存在的上下文。作用域指定你可以从哪里访问变量,以及在该上下文中你是否可以访问变量。作用域可以是全局定义的或局部定义的。

全局作用域

任何你声明的变量默认都在全局作用域中定义。这是 JavaScript 中采取的最令人烦恼的语言设计决策之一。由于全局变量在其他所有作用域中都是可见的,所以任何作用域都可以修改全局变量。全局变量使得在同一个程序/模块中运行松耦合的子程序变得更加困难。如果子程序碰巧有全局变量并且共享相同的名称,那么它们会相互干扰,并且很可能失败,通常以难以诊断的方式失败。这种情况有时被称为命名空间冲突。我们在前一章中讨论了全局作用域,但现在让我们简要地重新访问它,以了解如何最好地避免这种情况。

你可以用两种方法创建全局变量:

  • 第一种方法是将 var 声明放在任何函数外部。本质上,任何在函数外部声明的变量都被定义在全局作用域中。

  • 第二种方法是在声明变量时省略 var 声明(也称为隐式全局变量)。我认为这是为了方便新程序员而设计的,但结果却成了一个噩梦。即使在函数作用域内,如果你在声明变量时省略了 var 声明,它默认也是在全局作用域中创建的。这很糟糕。你总是应该让你程序运行于ESLintJSHint,让他们标记出这样的违规行为。下面的示例展示了全局作用域的行为:

    //Global Scope
    var a = 1;
    function scopeTest() {
      console.log(a);
    }
    scopeTest();  //prints 1
    

在这里,我们在函数外部声明了一个变量,并在全局作用域中。这个变量在scopeTest()函数中可用。如果你在函数作用域(局部)内给全局作用域变量赋新值,全局作用域中的原始值将被覆盖:

//Global Scope
var a = 1;
function scopeTest() {
  a = 2; //Overwrites global variable 2, you omit 'var'
  console.log(a);
}
console.log(a); //prints 1
scopeTest();  //prints 2
console.log(a); //prints 2 (global value is overwritten)

局部作用域

与大多数编程语言不同,JavaScript 没有块级作用域(作用域限定在周围的括号内);相反,JavaScript 有函数级作用域。函数内部声明的变量是局部变量,只能在函数内部或该函数内部的函数中访问:

var scope_name = "Global";
function showScopeName () {
  // local variable; only accessible in this function
  var scope_name = "Local";
  console.log (scope_name); // Local
}
console.log (scope_name);     //prints - Global
showScopeName();             //prints – Local

函数作用域与块作用域

JavaScript 变量的作用域在函数级别。你可以将这看作是一个小气泡被创建出来,防止变量从这个气泡外部被看到。函数为在其内部声明的变量创建这样一个气泡。你可以这样想象气泡:

-GLOBAL SCOPE---------------------------------------------|
var g =0;                                                 |
function foo(a) { -----------------------|                |
    var b = 1;                           |                |
    //code                               |                |
    function bar() { ------|             |                |
        // ...             |ScopeBar     | ScopeFoo       |
    }                ------|             |                |
    // code                              |                |
    var c = 2;                           |                |
}----------------------------------------|                |
foo();   //WORKS                                          |
bar();   //FAILS                                          |
----------------------------------------------------------|

JavaScript 使用作用域链来为给定函数建立作用域。通常有一个全局作用域,每个定义的函数都有自己的嵌套作用域。在另一个函数内部定义的任何函数都有一个局部作用域,它与外部函数链接。源代码中的位置始终定义作用域。在解析变量时,JavaScript 从最内层的作用域开始向外搜索。有了这个,让我们来看看 JavaScript 中的各种作用域规则。

在前面的粗略绘图视觉中,你可以看到foo()函数定义在全局作用域中。foo()函数在其局部作用域内有访问g变量的权限,因为它在全局作用域中。abc变量在局部作用域内可用,因为它们是在函数作用域内定义的。bar()函数也在函数作用域内声明,并在foo()函数内可用。然而,一旦函数作用域结束,bar()函数就不可用了。你不能从foo()函数外部看到或调用bar()函数——一个作用域气泡。

现在bar()函数也有了自己的函数作用域(气泡),这里有什么可用?bar()函数可以访问foo()函数以及foo()函数的父作用域内创建的所有变量——abcbar()函数还可以访问全局作用域变量g

这是一个强大的想法。花点时间思考一下。我们刚刚讨论了 JavaScript 中全局作用域可以变得多么泛滥和不受控制。那我们为什么不定性地将一段任意代码包裹在一个函数中呢?我们可以将这个作用域气泡隐藏起来,并围绕这段代码创建一个作用域气泡。使用函数包装来创建正确的作用域将有助于我们编写正确的代码,并防止难以检测的错误。

函数作用域和在此作用域内隐藏变量及函数的另一个优点是,你可以避免两个标识符之间的冲突。以下示例展示了这样一个糟糕的情况:

function foo() {
  function bar(a) {
    i = 2; // changing the 'i' in the enclosing scope's for-loop
    console.log(a+i);
  }
  for (var i=0; i<10; i++) {
    bar(i); // infinite loop
  }
}
foo();

bar()函数中,我们不知不觉地修改了i=2的值。当我们从for循环内部调用bar()时,i变量的值被设置为2,我们陷入了无限循环。这是一个命名空间冲突的坏例子。

到目前为止,使用函数作为作用域听起来是实现 JavaScript 模块化和正确性的好方法。嗯,虽然这种技术有效,但实际上并不理想。第一个问题是我们必须创建一个命名函数。如果我们只是为了引入函数作用域而不断创建这样的函数,我们就会污染全局作用域或父作用域。此外,我们必须不断调用这些函数。这引入了大量样板代码,使代码随时间变得不可读:

var a = 1;
//Lets introduce a function -scope
//1\. Add a named function foo() into the global scope
function foo() { 
 var a = 2;
 console.log( a ); // 2
} 
//2\. Now call the named function foo()
foo();
console.log( a ); // 1

我们在全局作用域中创建了一个新的函数foo(),并通过调用这个函数后来执行代码。

在 JavaScript 中,你可以通过创建立即执行的函数来解决这两个问题。仔细研究和输入以下示例:

var a = 1;
//Lets introduce a function -scope
//1\. Add a named function foo() into the global scope
(function foo() { 
 var a = 2;
 console.log( a ); // 2
})(); //<---this function executes immediately
console.log( a ); // 1

请注意,包装函数声明以function开头。这意味着,而不是将函数视为标准声明,而是将函数视为函数表达式。

(function foo(){ })表达式作为语句意味着foo标识符只存在于foo()函数的作用域中,而不是在外部作用域。隐藏foo名称本身意味着它不会不必要的污染外部作用域。这是非常有用且更好。我们在函数表达式后添加()以立即执行它。所以完整的模式如下所示:

(function foo(){ /* code */ })();

这种模式如此常见,以至于它有一个名字:IIFE,代表立即调用 函数表达式。许多程序员在使用 IIFE 时省略函数名称。由于 IIFE 的主要用途是引入函数作用域,因此实际上并不需要命名函数。我们可以像下面这样写先前的例子:

var a = 1;
(function() { 
 var a = 2;
 console.log( a ); // 2
})(); 
console.log( a ); // 1

在这里,我们创建了一个匿名函数作为立即执行的函数表达式(IIFE)。虽然这与先前的命名 IIFE 相同,但使用匿名 IIFE 有几个缺点:

  • 由于在堆栈跟踪中看不到函数名称,因此调试此类代码非常困难。

  • 你不能对匿名函数使用递归(如我们之前讨论的)

  • 过度使用匿名 IIFE 有时会导致代码不可读。

迪奥格斯·克劳福德(Douglas Crockford)和其他一些专家推荐 IIFE 的一小部分变化:

(function(){ /* code */ }());

这两种 IIFE 形式都很流行,你将看到大量使用这两种变体的代码。

你可以向 IIFE 传递参数。以下示例展示了如何向 IIFE 传递参数:

(function foo(b) { 
    var a = 2;
    console.log( a + b ); 
})(3); //prints 5

内联函数表达式

还有一种匿名函数表达式的流行用法,即把函数作为参数传递给其他函数:

function setActiveTab(activeTabHandler, tab){
  //set active tab
  //call handler
  activeTabHandler();
}
setActiveTab( function (){ 
 console.log( "Setting active tab" );
}, 1 );
//prints "Setting active tab"

再次,你可以给这个内联函数表达式命名,以确保在调试代码时获得正确的堆栈跟踪。

块级作用域

正如我们之前讨论的,JavaScript 没有块作用域的概念。熟悉其他语言(如 Java 或 C)的程序员会觉得这非常不舒服。ECMAScript 6ES6)引入了let 关键字来引入传统的块作用域。这非常方便,如果你确定你的环境将支持 ES6,你应该总是使用 let 关键字。以下代码所示:

var foo = true;
if (foo) {
  let bar = 42; //variable bar is local in this block { }
  console.log( bar );
}
console.log( bar ); // ReferenceError

然而,截至目前,ES6 并不被大多数流行浏览器默认支持。

到目前为止,本章应该已经向你充分解释了 JavaScript 中作用域是如何工作的。如果你仍然不清楚,我建议你停在这里,重新阅读本章的早期部分。上网查找你的疑惑,或者在 Stack Overflow 上提出你的问题。总之,一定要确保你对作用域规则没有任何疑惑。

我们很容易认为代码执行是自上而下,逐行进行的。这是大多数 JavaScript 代码执行的方式,但有一些例外。

考虑以下代码:

console.log( a );
var a = 1;

如果你说这是无效的代码,当我们调用 console.log() 时会得到 undefined,你完全正确。然而,这个呢?

a = 1;
var a;
console.log( a );

preceding 代码的输出应该是什么?自然会期望 undefined 作为 var a 语句在 a = 1 之后,似乎自然地假设变量被重新定义并分配了默认的 undefined。然而,输出将是 1

当你看到 var a = 1 时,JavaScript 将其拆分为两个语句:var aa = 1。第一个语句,声明,在编译阶段处理。第二个语句,赋值,在执行阶段保持原位。

所以,前面的片段实际上将按以下方式执行:

var a;   //----Compilation phase

a = 1;    //------execution phase
console.log( a );

第一个片段实际上按以下方式执行:

var a;     //-----Compilation phase

console.log( a );   
a = 1;     //------execution phase  

所以,如我们所见,变量和函数声明在编译阶段被移动到代码的顶部——这也被称为提升。非常重要记住的是,只有声明本身被提升,而任何赋值或其他可执行逻辑都保持原位。以下片段展示了函数声明是如何被提升的:

foo();
function foo() {
  console.log(a); // undefined
  var a = 1;
}

foo() 函数的声明被提升,以至于我们能够在定义它之前执行该函数。提升的一个重要方面是它按作用域工作。在 foo() 函数内部,变量的声明将被提升到 foo() 函数的顶部,而不是程序的顶部。利用提升执行 foo() 函数的实际代码如下:

function foo() {
  var a;
  console.log(a); // undefined
  a = 1;
}

我们看到了函数声明被提升,但函数表达式不会。下一节解释了这个案例。

函数声明与函数表达式

我们看到了定义函数的两种方式。虽然它们都服务于相同的目的,但这些两种声明之间存在差异。查看下面的例子:

//Function expression
functionOne();
//Error
//"TypeError: functionOne is not a function

var functionOne = function() {
  console.log("functionOne");
};
//Function declaration
functionTwo();
//No error
//Prints - functionTwo

function functionTwo() {
  console.log("functionTwo");
}
sayMoo() but such a conditional code is not guaranteed to work across all browsers and can result in unpredictable results:
// Never do this - different browsers will behave differently
if (true) {
  function sayMoo() {
    return 'trueMoo';
  }
}
else {
  function sayMoo() {
    return 'falseMoo';
  }
}
foo();

然而,用函数表达式这样做是完全安全且明智的:

var sayMoo;
if (true) {
  sayMoo = function() {
    return 'trueMoo';
  };
}
else {
  sayMoo = function() {
    return 'falseMoo';
  };
}
foo();

如果你好奇想知道为什么不应该在条件块中使用函数声明,请继续阅读;否则,你可以跳过下面的段落。

函数声明只能出现在程序或函数体中。它们不能出现在块({ ... })中。块只能包含语句,不能包含函数声明。由于这个原因,几乎所有 JavaScript 的实现都有与这个不同的行为。建议永远不要在条件块中使用函数声明。

另一方面,函数表达式非常流行。在 JavaScript 程序员中,基于某种条件对函数定义进行分叉是一个非常常见的模式。由于这样的分叉通常发生在同一作用域中,几乎总是需要使用函数表达式。

arguments参数

arguments参数包含了所有传递给函数的参数。这个集合有一个名为length的属性,包含了参数的数量,单个参数的值可以使用数组索引表示法来获取。好吧,我们有点撒谎。arguments参数不是一个 JavaScript 数组,如果你尝试在arguments上使用数组方法,你会失败得很惨。你可以把arguments看作是一个类似数组结构。这使得能够编写接受不确定数量参数的函数成为可能。下面的片段展示了如何向函数传递可变数量的参数,并使用arguments数组遍历它们:

var sum = function () { 
  var i, total = 0;
  for (i = 0; i < arguments.length; i += 1) {
    total += arguments[i];
  }
  return total;
};
console.log(sum(1,2,3,4,5,6,7,8,9)); // prints 45
console.log(sum(1,2,3,4,5)); // prints 15

正如我们讨论的,arguments参数并不是一个真正的数组;可以像下面这样将其转换为数组:

var args = Array.prototype.slice.call(arguments);

一旦转换为数组,你可以随意操作列表。

这个参数

每当函数被调用时,除了代表在函数调用中提供的显式参数之外,还会隐式地传递一个名为this的参数给函数。它指的是与函数调用隐式相关联的对象,称为函数上下文。如果你编过 Java 代码,this关键字对你来说会很熟悉;就像 Java 一样,this指向定义方法类实例。

有了这些知识,让我们来谈谈各种调用方法。

作为函数的调用

如果一个函数不是作为方法、构造函数,或者通过apply()call()调用,它就简单地以函数的形式调用:

function add() {}
add();
var substract = function() {

};
substract();

当一个函数以这种模式调用时,this绑定到全局对象。许多专家认为这是一个糟糕的设计选择。自然地,我们可能会认为this会被绑定到父上下文。当你处于这种情况时,你可以将this的值捕获到另一个变量中。我们稍后重点关注这种模式。

作为方法调用

方法是与对象上的属性绑定的函数。对于方法来说,在调用时this绑定到调用对象上:

var person = {
  name: 'Albert Einstein',
  age: 66,
  greet: function () {
    console.log(this.name);
  }
};
person.greet();

在这个例子中,当调用greetthis绑定到person对象上,因为greetperson的一个方法。让我们看看这两种调用模式下这种行为是如何表现的。

让我们准备这个 HTML 和 JavaScript harness:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>This test</title>
  <script type="text/javascript">
 function testF(){ return this; }
 console.log(testF()); 
 var testFCopy = testF;
 console.log(testFCopy()); 
 var testObj = {
 testObjFunc: testF
 };
 console.log(testObj.testObjFunc ());
  </script>
</head>
<body>
</body>
</html>

Firebug控制台中,你可以看到以下输出:

作为方法的调用

前两个方法调用都是作为函数调用;因此,this参数指向全局上下文(在这个例子中是Window)。

接下来,我们定义了一个名为testObj的变量,它有一个名为testObjFunc的属性,该属性接收对testF()的引用——如果你现在还不清楚对象是如何创建的,也不要担心。这样做,我们创建了一个testObjMethod()方法。现在,当我们调用这个方法时,我们期望当显示this的值时显示函数上下文。

作为构造函数的调用

构造函数的声明与其他任何函数一样,即将作为构造函数的函数也没有什么特别之处。然而,它们的调用方式却大不相同。

要作为构造函数调用函数,我们在函数调用前加上new关键字。当这样做时,this绑定到新对象上。

在我们讨论更多之前,让我们先快速介绍一下 JavaScript 中的面向对象。当然,我们将在下一章中详细讨论这个话题。JavaScript 是一种基于原型继承的语言。这意味着对象可以直接从其他对象继承属性。这种语言是无类的。设计为用new前缀调用的函数称为构造函数。通常,为了更容易区分,它们使用帕斯卡命名法而不是驼峰命名法。在下面的例子中,注意greet函数使用this来访问name属性。this参数绑定到Person上:

var Person = function (name) {
  this.name = name;
};
Person.prototype.greet = function () {
  return this.name;
};
var albert = new Person('Albert Einstein');
console.log(albert.greet());

我们将在下一章学习对象时讨论这种特定的调用方法。

使用apply()call()方法调用

我们之前说过,JavaScript 函数是对象。与其他对象一样,它们也有一些特定的方法。要使用apply()方法调用函数,我们向apply()传递两个参数:作为函数上下文的对象和一个作为调用参数的数组。call()方法的用法类似,不同之处在于参数是直接在参数列表中传递,而不是作为数组。

匿名函数

我们在这章的早些时候向你介绍了匿名函数,因为它们是一个关键概念,所以我们将详细介绍它们。对于受 Scheme 启发的语言来说,匿名函数是一个重要的逻辑和结构构建。

匿名函数通常用于函数不需要在稍后引用的情况下。让我们看看匿名函数的一些最流行的使用情况。

在创建对象时使用匿名函数

匿名函数可以赋值给对象属性。这样做时,我们可以使用点(.)运算符调用该函数。如果你来自 Java 或其他面向对象语言的背景,你会发现这非常熟悉。在这样 languages, a function, which is part of a class is generally called with a notation—Class.function(). Let's consider the following example:

var santa = {
  say :function(){ 
    console.log("ho ho ho"); 
  }
}
santa.say();

在这个例子中,我们创建了一个具有say属性的对象,该属性是一个匿名函数。在这个特定情况下,这个属性被称为方法而不是函数。我们不需要给这个函数命名,因为我们打算将其作为对象属性调用。这是一个流行的模式,应该会派上用场。

在创建列表时使用匿名函数

在这里,我们创建了两个匿名函数并将它们添加到一个数组中。(我们稍后会对数组进行详细介绍。)然后,你遍历这个数组并在循环中执行这些函数:

<script type="text/javascript">
var things = [
  function() { alert("ThingOne") },
  function() { alert("ThingTwo") },
];
for(var x=0; x<things.length; x++) {
  things[x]();
}
</script>

将匿名函数作为另一个函数的参数

这是最流行的模式之一,你会在大多数专业库中找到这样的代码:

// function statement
function eventHandler(event){
  event();
}

eventHandler(function(){
  //do a lot of event related things
  console.log("Event fired");
});

你将匿名函数传递给另一个函数。在接收函数中,你执行作为参数传递的函数。如果你正在创建一次性函数,例如对象方法或事件处理程序,这会非常方便。与先声明一个函数然后将其作为两个单独的步骤进行处理相比,匿名函数语法更为简洁。

在条件逻辑中使用匿名函数

你可以使用匿名函数表达式来条件性地改变行为。以下示例展示了这种模式:

var shape;
if(shape_name === "SQUARE") {
  shape = function() {
    return "drawing square";
  }
}
else {
  shape = function() {
    return "drawing square";
  }
}
alert(shape());

在这里,根据条件,我们将不同的实现分配给shape变量。如果使用得当,这种模式非常有用。过度使用可能导致代码难以阅读和调试。

在这本书的后面部分,我们将探讨几种函数式技巧,例如记忆化和缓存函数调用。如果你是快速浏览了整个章节后到达这里的,我建议你停一下,思考一下我们迄今为止讨论的内容。最后几页包含了大量信息,所有这些信息需要一段时间才能吸收。我建议你在继续之前重新阅读这一章。下一节将重点介绍闭包和模块模式。

闭包

传统上,闭包一直是纯函数式编程语言的一个特性。JavaScript 通过将闭包视为核心语言结构的一部分,显示了它与这类函数式编程语言的亲和力。闭包在主流 JavaScript 库和高级生产代码中越来越受欢迎,因为它们可以帮助你简化复杂操作。你会在经验丰富的 JavaScript 程序员那里听到他们对闭包几乎带有敬畏的谈论——仿佛闭包是超出了普通人智力范围的一些神奇构造。然而,事实并非如此。当你研究这个概念时,你会发现闭包其实非常明显,几乎是不言自明。在你达到闭包的顿悟之前,我建议你多次阅读这一章节,上网查找资料,编写代码,阅读 JavaScript 库,以了解闭包的行为——但不要放弃。

你首先必须认识到的是,闭包在 JavaScript 中无处不在。它并不是语言中一个隐藏的特殊部分。

在我们深入细节之前,让我们快速回顾一下 JavaScript 中的词法作用域。我们详细讨论了在 JavaScript 中如何根据函数级别确定词法作用域。词法作用域基本上决定了所有标识符在哪里以及如何声明,并预测在执行期间它们如何被查找。

简而言之,闭包是当一个函数被声明时创建的上下文,它允许函数访问和操作位于该函数之外的变量。换句话说,闭包允许函数访问在自己声明时处于作用域内的所有变量及其他函数。

让我们通过一些示例代码来理解这个定义:

var outer = 'I am outer'; //Define a value in global scope
function outerFn() { //Declare a a function in global scope
  console.log(outer);
}
outerFn(); //prints - I am outer

你期待一些闪亮的东西吗?不,这真的是闭包最普通的情况。我们在全局作用域中声明一个变量,并在全局作用域中声明一个函数。在函数中,我们能够访问在全局作用域中声明的变量——outer。所以,本质上,outerFn()函数的外部作用域就是一个闭包,并且始终对outerFn()可用。这是一个不错的开始,但也许你还不确定为什么这是一件多么伟大的事情。

让我们让事情变得复杂一些:

var outer = 'Outer'; //Variable declared in global scope
var copy;
function outerFn(){  //Function declared in global scope

  var inner = 'Inner'; //Variable has function scope only, can not be
  //accessed from outside 

  function innerFn(){     //Inner function within Outer function, 
    //both global context and outer
    //context are available hence can access 
    //'outer' and 'inner'
    console.log(outer);
    console.log(inner);
  }
  copy=innerFn;          //Store reference to inner function, 
  //because 'copy' itself is declared
  //in global context, it will be available 
  //outside also
}
outerFn();
copy();  //Cant invoke innerFn() directly but can invoke via a 
//variable declared in global scope

是什么现象使得在innerFn()内部函数执行时,即使它创建的作用域已经消失很久,inner变量仍然可用?当我们在outerFn()中声明innerFn()时,不仅函数声明被定义,而且还创建了一个闭包,它不仅包含函数声明,还包括声明时处于作用域内的所有变量。当innerFn()执行时,即使它是在自己声明的作用域消失后执行,它仍然可以通过闭包访问到自己声明时的原始作用域。

让我们继续扩展这个示例,以了解你可以使用闭包做到何种程度:

var outer='outer';
var copy;
function outerFn() {
  var inner='inner';
  function innerFn(param){
    console.log(outer);
    console.log(inner);
 console.log(param);
 console.log(magic);
  }
  copy=innerFn;
}
console.log(magic); //ERROR: magic not defined
var magic="Magic";
outerFn();
copy("copy");

在前面的示例中,我们添加了一些东西。首先,我们在innerFn()中添加了一个参数——只是为了说明参数也是闭包的一部分。我们有两个重要的点想要强调。

即使在外层作用域中声明变量是在函数声明之后,外层作用域中的所有变量也会被包含在内。这使得innerFn()中的行console.log(magic)可以正常工作。

然而,在全局作用域中相同的行console.log(magic)将失败,因为即使在相同的作用域中,尚未定义的变量也不能引用。

所有这些示例都是为了传达一些关于闭包如何工作的概念。闭包是 JavaScript 语言中的一个突出特性,您可以在大多数库中看到它们。

让我们看看一些关于闭包的流行模式。

定时器和回调

在实现定时器或回调时,您需要异步调用处理程序,通常在稍后的时间点。由于异步调用,我们需要从这些函数外部访问变量。考虑以下示例:

function delay(message) {
  setTimeout( function timerFn(){
    console.log( message );
  }, 1000 );
}
delay( "Hello World" );

我们将内部timerFn()函数传递给内置库函数setTimeout()。然而,timerFn()对外层delay()作用域有闭包,因此它可以引用变量 message。

私有变量

闭包经常用来封装一些作为私有变量的信息。JavaScript 不允许像 Java 或 C++这样的编程语言中的封装,但通过使用闭包,我们可以实现类似的封装:

function privateTest(){
 var points=0;
  this.getPoints=function(){
    return points;
  };
  this.score=function(){
    points++;
  };
}

var private = new privateTest();
private.score();
console.log(private.points); // undefined
console.log(private.getPoints());

在前面的示例中,我们创建了一个打算作为构造函数调用的函数。在这个privateTest()函数中,我们创建了一个名为var points=0的变量作为函数作用域变量。这个变量仅在privateTest()中可用。此外,我们创建了一个访问器函数(也称为获取器)——getPoints()——这个方法允许我们从privateTest()外部只读取点变量的一个值,使得这个变量成为函数的私有变量。然而,另一个方法score()允许我们不直接从外部访问的情况下修改私有点变量值。这使得我们可以编写代码,以受控的方式更新私有变量。当您编写基于合同和预定义接口控制变量访问的库时,这种模式非常有用。

循环和闭包

考虑以下在循环中使用函数的示例:

for (var i=1; i<=5; i++) {
  setTimeout( function delay(){
    console.log( i );
  }, i*100);
}
print 1, 2, 3, 4, and 5 on the console at an interval of 100 ms, right? Instead, it prints 6, 6, 6, 6, and 6 at an interval of 100 ms. Why is this happening? Here, we encounter a common issue with closures and looping. The i variable is being updated after the function is bound. This means that every bound function handler will always print the last value stored in i. In fact, the timeout function callbacks are running after the completion of the loop. This is such a common problem that JSLint will warn you if you try to use functions this way inside a loop.

我们如何修复这种行为?我们可以在作用域中引入一个函数作用域和局部复制的i变量。以下代码片段向您展示了我们如何这样做:

for (var i=1; i<=5; i++) {
  (function(j){
    setTimeout( function delay(){
      console.log( j );
    }, j*100);
  })( i );
}

我们在 IIFE 中传递了i变量,并将其复制到局部变量j中。在每次迭代中引入 IIFE 可以为新迭代创建一个新的作用域,从而更新具有正确值的局部副本。

模块

模块用于模仿类,并专注于变量和函数的公共和私有访问。模块有助于减少全局作用域的污染。有效使用模块可以减少大型代码库中的名称冲突。这种模式采取的典型格式如下:

Var moduleName=function() {
  //private state
  //private functions
  return {
     //public state
     //public variables
  }
}

要在此前格式中实现此模式,有两个要求:

  • 必须有一个外部闭合函数至少执行一次。

  • 这个闭合函数必须至少返回一个内部函数。这是创建对私有状态的闭包所必需的——没有它,你根本无法访问私有状态。

检查以下模块示例:

var superModule = (function (){
  var secret = 'supersecretkey';
  var passcode = 'nuke';

  function getSecret() {
    console.log( secret );
  }

  function getPassCode() {
    console.log( passcode );
  }

  return {
    getSecret: getSecret,
    getPassCode: getPassCode
  };
})();
superModule.getSecret();
superModule.getPassCode();

这个示例满足两个条件。首先,我们创建一个 IIFE 或命名函数作为外部闭合。定义的变量将保持私有,因为它们在函数作用域内。我们返回公共函数,以确保我们对私有作用域有闭包。在模块模式中使用 IIFE 将实际上导致这个函数的单例实例。如果你想要创建多个实例,你也可以创建作为模块一部分的命名函数表达式。

我们将继续探索 JavaScript 函数方面的各种方面,特别是闭包。这种优雅结构可以有大量的创新用途。理解各种模式的有效方式是研究流行库的代码并在你的代码中实践这些模式。

风格上的考虑

正如前章所述,我们将以某些风格上的考虑来结束这次讨论。再次说明,这些通常是公认的指导原则,而非规则——如果你有理由相信其他情况,请随意偏离它们:

  • 使用函数声明,而不是函数表达式:

    // bad
    const foo = function () {
    };
    
    // good
    function foo() {
    }
    
  • 永远不要在非函数块中声明一个函数(if,while 等)。相反,将函数赋值给一个变量。浏览器允许这样做,但它们的解释各不相同。

  • 永远不要将参数命名为arguments。这将优先于给予每个函数作用域的arguments对象。

总结

在本章中,我们学习了 JavaScript 函数。在 JavaScript 中,函数扮演着至关重要的角色。我们讨论了函数是如何创建和使用的。我们还讨论了闭包和函数作用域中变量的 scope 的重要概念。我们讨论了函数作为创建可见类和封装的方法。

在下一章中,我们将查看 JavaScript 中的各种数据结构和数据操作技术。

第三章.数据结构与操作

在编程中你花费大部分时间做的事情是操作数据。你处理数据的属性,根据数据得出结论,改变数据的本性。在本章中,我们将详细介绍 JavaScript 中的各种数据结构和数据操作技术。正确使用这些表达式结构,你的程序将会是正确的、简洁的、易于阅读的,并且很有可能是更快的。这将在以下主题帮助下解释:

  • 正则表达式

  • 精确匹配

  • 从字符类中匹配

  • 重复出现

  • 开始和结束

  • 反向引用

  • 贪婪与懒惰量词

  • 数组

  • 映射

  • 集合

  • 风格问题

正则表达式

如果你不熟悉正则表达式,我建议你花时间去学习它们。有效地学习和使用正则表达式是你会获得的最有价值的技能之一。在大多数代码审查会议中,我首先评论的是如何将一段代码转换成单个正则表达式(或 RegEx)的行。如果你研究流行的 JavaScript 库,你会惊讶地看到正则表达式的普遍性。大多数经验丰富的工程师主要依赖正则表达式,因为一旦你知道如何使用它们,它们就是简洁且易于测试的。然而,学习正则表达式将需要大量的精力和时间。正则表达式是表达匹配文本字符串的模式的方法。表达式本身由术语和操作符组成,使我们能够定义这些模式。我们很快就会看到这些术语和操作符由什么组成。

在 JavaScript 中,创建正则表达式有两种方法:通过正则表达式字面量和使用RegExp对象实例化。

例如,如果我们想要创建一个正好匹配字符串 test 的正则表达式,我们可以使用以下正则表达式字面量:

var pattern = /test/;

正则表达式字面量使用斜杠分隔。或者,我们可以构造一个RegExp实例,将正则表达式作为字符串传递:

var pattern = new RegExp("test");

这两种格式都会在变量 pattern 中创建相同的正则表达式。除了表达式本身,还有三个标志可以与正则表达式关联:

  • i:这使正则表达式不区分大小写,所以/test/i不仅匹配test,还匹配TestTESTtEsT等。

  • g:这与默认的局部匹配相反,后者只匹配第一个出现。稍后会有更多介绍。

  • m:这允许跨多行匹配,这可能来自textarea元素的值。

这些标志在字面量末尾附加(例如,/test/ig)或作为字符串传递给RegExp构造器的第二个参数(new RegExp("test", "ig"))。

以下示例说明了各种标志以及它们如何影响模式匹配:

var pattern = /orange/;
console.log(pattern.test("orange")); // true

var patternIgnoreCase = /orange/i;
console.log(patternIgnoreCase.test("Orange")); // true

var patternGlobal = /orange/ig;
console.log(patternGlobal.test("Orange Juice")); // true

如果我们只能测试模式是否与一个字符串匹配,那就没什么意思了。让我们看看如何表达更复杂的模式。

精确匹配

任何不是特殊正则字符或运算符的连续字符都代表一个字符字面量:

var pattern = /orange/;

我们的意思是o后面跟着r,后面跟着a,后面跟着n,后面跟着……—你应该明白了。当我们使用正则表达式时,我们很少使用精确匹配,因为那就像是比较两个字符串。精确匹配模式有时被称为简单模式。

从一类字符中匹配

如果你想匹配一组字符,你可以在[]里放置这一组字符。例如,[abc]就意味着任何字符abc

var pattern = /[abc]/;
console.log(pattern.test('a')); //true
console.log(pattern.test('d')); //false

你可以指定想匹配除模式以外的任何内容,通过在模式的开头添加一个^(感叹号)来实现:

var pattern = /[^abc]/;
console.log(pattern.test('a')); //false
console.log(pattern.test('d')); //true

这个模式的一个关键变体是值的范围。如果我们想匹配一系列连续的字符或数字,我们可以使用以下的模式:

var pattern = /[0-5]/;
console.log(pattern.test(3)); //true
console.log(pattern.test(12345)); //true
console.log(pattern.test(9)); //false
console.log(pattern.test(6789)); //false
console.log(/[0123456789]/.test("This is year 2015")); //true

特殊字符,比如$.,要么代表与自身以外的匹配,要么是修饰前面项的运算符。实际上,我们已经看到了[, ], -, 和^字符如何用来表示它们字面值以外的含义。

那么我们如何指定想匹配一个字面[$^或其他特殊字符呢?在正则表达式中,反斜杠字符转义它后面的任何字符,使其成为一个字面匹配项。所以\[指定了一个对[字符的精确匹配,而不是字符类表达式的开始。双反斜杠(\\)匹配一个单反斜杠。

在前面的例子中,我们看到了test()方法,它基于匹配到的模式返回truefalse。有时你想访问特定模式的各个出现。在这种情况下,exec()方法就派上用场了。

exec()方法接收一个字符串作为参数,返回一个包含所有匹配项的数组。考虑以下例子:

var strToMatch = 'A Toyota! Race fast, safe car! A Toyota!'; 
var regExAt = /Toy/;
var arrMatches = regExAt.exec(strToMatch); 
console.log(arrMatches);
['Toy']; if you want all the instances of the pattern Toy, you can use the g (global) flag as follows:
var strToMatch = 'A Toyota! Race fast, safe car! A Toyota!'; 
var regExAt = /Toy/g;
var arrMatches = regExAt.exec(strToMatch); 
console.log(arrMatches);

这将返回原文中所有单词oyo的出现。String 对象包含match()方法,其功能与exec()方法类似。在 String 对象上调用match()方法,把正则表达式作为参数传给它。考虑以下例子:

var strToMatch = 'A Toyota! Race fast, safe car! A Toyota!'; 
var regExAt = /Toy/;
var arrMatches = strToMatch.match(regExAt);
console.log(arrMatches);

在这个例子中,我们在 String 对象上调用match()方法。我们把正则表达式作为参数传给match()方法。这两种情况的结果是一样的。

另一个 String 对象的方法是replace()。它用一个不同的字符串替换所有子字符串的出现:

var strToMatch = 'Blue is your favorite color ?'; 
var regExAt = /Blue/;
console.log(strToMatch.replace(regExAt, "Red"));
//Output- "Red is your favorite color ?"

你可以把一个函数作为replace()方法的第二个参数。replace()函数把匹配到的文本作为参数,并返回用作替换的文本:

var strToMatch = 'Blue is your favorite color ?'; 
var regExAt = /Blue/;
console.log(strToMatch.replace(regExAt, function(matchingText){
  return 'Red';
}));
//Output- "Red is your favorite color ?"

字符串对象的split()方法也接受一个正则表达式参数,并返回一个包含在原字符串分割后生成的所有子字符串的数组:

var sColor = 'sun,moon,stars';
var reComma = /\,/;
console.log(sColor.split(reComma));
//Output - ["sun", "moon", "stars"]

我们需要在逗号之前加上反斜杠,因为正则表达式中逗号有特殊含义,如果我们想直接使用它,就需要转义它。

使用简单的字符类,你可以匹配多个模式。例如,如果你想匹配catbatfat,以下片段展示了如何使用简单的字符类:

var strToMatch = 'wooden bat, smelly Cat,a fat cat';
var re = /[bcf]at/gi;
var arrMatches = strToMatch.match(re);
console.log(arrMatches);
//["bat", "Cat", "fat", "cat"]

正如你所看到的,这种变化打开了编写简洁正则表达式模式的可能性。看下面的例子:

var strToMatch = 'i1,i2,i3,i4,i5,i6,i7,i8,i9';
var re = /i[0-5]/gi;
var arrMatches = strToMatch.match(re);
console.log(arrMatches);
//["i1", "i2", "i3", "i4", "i5"]

在这个例子中,我们匹配匹配字符的数字部分,范围为[0-5],因此我们从i0得到匹配到i5。您还可以使用否定类^过滤其余的匹配:

var strToMatch = 'i1,i2,i3,i4,i5,i6,i7,i8,i9';
var re = /i[⁰-5]/gi;
var arrMatches = strToMatch.match(re);
console.log(arrMatches);
//["i6", "i7", "i8", "i9"]

注意我们是如何只否定范围子句而不是整个表达式的。

几个字符组有快捷方式。例如,快捷方式\d[0-9]相同:

表示法 意义
\d 任何数字字符
\w 字母数字字符(单词字符)
\s 任何空白字符(空格、制表符、换行符等)
\D 非数字字符
\W 非字母数字字符
\S 非空白字符
. 除换行符外的任何字符

这些快捷方式在编写简洁的正则表达式中很有价值。考虑这个例子:

var strToMatch = '123-456-7890';
var re = /[0-9][0-9][0-9]-[0-9][0-9][0-9]/;
var arrMatches = strToMatch.match(re);
console.log(arrMatches);
//["123-456"]

这个表达式看起来确实有点奇怪。我们可以用\d替换[0-9],使这变得更易读:

var strToMatch = '123-456-7890';
var re = /\d\d\d-\d\d\d/;
var arrMatches = strToMatch.match(re);
console.log(arrMatches);
//["123-456"]

然而,你很快就会看到还有更好的方法来这样做。

重复出现

到目前为止,我们看到了如何匹配固定字符或数字模式。大多数时候,你希望处理模式的某些重复特性。例如,如果我想要匹配 4 个a,我可以写/aaaa/,但如果我想指定一个可以匹配任意数量a的模式呢?

正则表达式为您提供了各种重复量词。重复量词让我们指定特定模式可以出现的次数。我们可以指定固定值(字符应出现 n 次)和变量值(字符可以出现至少 n 次,直到它们出现 m 次)。以下表格列出了各种重复量词:

  • ?: 要么出现 0 次要么出现 1 次(将出现标记为可选)

  • *: 0 或多个出现

  • +: 1 或多个出现

  • {n}: 正好 n 次出现

  • {n,m}: 在 nm 之间的出现

  • {n,}: 至少出现 n

  • {,n}: 0 到 n 次出现

在以下示例中,我们创建一个字符u可选(出现 0 或 1 次)的模式:

var str = /behaviou?r/;
console.log(str.test("behaviour"));
// true
console.log(str.test("behavior"));
// true

/behaviou?r/表达式看作是 0 或 1 次字符u的出现有助于阅读。重复量词 succeeds 了我们想要重复的字符。让我们尝试一些更多例子:

console.log(/'\d+'/.test("'123'")); // true

你应该读取并解释\d+表达式,就像'是字面字符匹配,\d匹配字符[0-9]+量词将允许一个或多个出现,而'是字面字符匹配。

您还可以使用()对字符表达式进行分组。观察以下示例:

var heartyLaugh = /Ha+(Ha+)+/i;
console.log(heartyLaugh.test("HaHaHaHaHaHaHaaaaaaaaaaa"));
//true

让我们把前面的表达式分解成更小的块,以了解这里发生了什么:

  • H:字面字符匹配

  • a+:字符a的一个或多个出现

  • (:表达式组的开始

  • H:字面字符匹配

  • a+:字符a的一个或多个出现

  • ):表达式组的结束

  • +:表达式组(Ha+)的一个或多个出现

现在更容易看出分组是如何进行的。如果我们必须解释表达式,有时读出表达式是有帮助的,如前例所示。

通常,你想匹配一组字母或数字本身,而不仅仅是作为子字符串。当你匹配的词不是其他任何词的一部分时,这是一个相当常见的用例。我们可以通过使用\b模式来指定单词边界。\b的单词边界匹配一侧是单词字符(字母、数字或下划线)而另一侧不是的位置。考虑以下示例。

以下是一个简单的字面匹配。如果cat是子字符串的一部分,这个匹配也会成功:

console.log(/cat/.test('a black cat')); //true

然而,在下面的示例中,我们通过在单词cat前标示\b来定义一个单词边界——这意味着我们只想匹配cat作为一个单词而不是一个子字符串。边界是在cat之前建立的,因此在文本a black cat中找到了匹配项:

console.log(/\bcat/.test('a black cat')); //true

当我们对单词tomcat使用相同的边界时,我们得到一个失败的匹配,因为在单词tomcatcat之前没有单词边界:

console.log(/\bcat/.test('tomcat')); //false

在单词tomcat中,cat之后有一个单词边界,因此以下是一个成功的匹配:

console.log(/cat\b/.test('tomcat')); //true

在以下示例中,我们在单词cat的前后都定义了单词边界,以表示我们想要cat作为一个有前后边界的独立单词:

console.log(/\bcat\b/.test('a black cat')); //true

基于相同逻辑,以下匹配失败,因为在单词concatenatecat前后的边界不存在:

console.log(/\bcat\b/.test("concatenate")); //false

exec()方法在获取关于找到匹配的信息方面很有用,因为它返回一个包含关于匹配的信息的对象。exec()返回的对象有一个index属性,告诉我们成功匹配在字符串中的开始位置。这在许多方面都是有用的:

var match = /\d+/.exec("There are 100 ways to do this");
console.log(match);
// ["100"]
console.log(match.index);
// 10

替代方案——或

使用|(管道)字符可以表示替代方案。例如,/a|b/匹配ab字符,而/(ab)+|(cd)+/匹配abcd的一个或多个出现。

开始和结束

经常,我们可能希望确保模式在字符串的开始处或 perhaps 在字符串的结束处匹配。当正则表达式的第一个字符是井号时(^),它将匹配固定在字符串的开始处,例如/^test/仅当test子字符串出现在要匹配的字符串的开头时才匹配。同样,美元符号($)表示模式必须出现在字符串的末尾:/test$/

使用^$指示指定的模式必须包含整个候选字符串:/^test$/

反向引用

在表达式计算之后,每个组都存储起来以供以后使用。这些值称为反向引用。反向引用通过从左到右遇到左括号字符的顺序创建并编号。你可以将反向引用视为与正则表达式中的项成功匹配的字符串的部分。

引用后缀的表示方法是一个反斜杠,后面跟着要引用的捕获组的编号,从 1 开始,例如\1\2等等。

一个例子可能是/^([XYZ])a\1/,它匹配一个以XYZ中的任何一个字符开头,后面跟着一个a,再后面跟着与第一个捕获组匹配的任何字符的字符串。这与/[XYZ] a[XYZ]/非常不同。a后面的字符不能是XYZ中的任何一个,而必须是触发第一个字符匹配的那个。反向引用用于字符串的replace()方法,使用特殊字符序列$1$2等等。假设你想把1234 5678字符串改为5678 1234。以下代码实现此功能:

var orig = "1234 5678";
var re = /(\d{4}) (\d{4})/;
var modifiedStr = orig.replace(re, "$2 $1"); 
console.log(modifiedStr); //outputs "5678 1234" 

在这个例子中,正则表达式有两个组,每个组都有四个数字。在replace()方法的第二个参数中,$2等于5678$1等于1234,对应于它们在表达式中出现的顺序。

贪婪与懒惰量词

我们迄今为止讨论的所有量词都是贪婪的。一个贪婪的量词从整个字符串开始寻找匹配。如果没有找到匹配,它会删除字符串中的最后一个字符并重新尝试匹配。如果没有再次找到匹配,它将再次删除最后一个字符,并重复这个过程,直到找到匹配或者字符串剩下没有字符。

例如,\d+模式将匹配一个或多个数字。例如,如果你的字符串是123,贪婪匹配将匹配112123。贪婪模式h.+l将在字符串hello中匹配hell—这是可能的最长字符串匹配。由于\d+是贪婪的,它会尽可能多地匹配数字,因此匹配将是123

与贪婪量词相比,懒惰量词尽可能少地匹配量词化的令牌。你可以在正则表达式中添加一个问号(?)使其变得懒惰。一个懒惰的模式h.?l将在字符串hello中匹配hel—这是可能的最短字符串。

\w*?X模式将匹配零个或多个单词,然后匹配一个X。然而,在*后面的问号表示应该尽可能少地匹配字符。对于字符串abcXXX,匹配可以是abcXabcXXabcXXX。哪一个应该被匹配?由于*?是懒惰的,尽可能少地匹配字符,因此匹配是abcX

有了这些必要的信息,让我们尝试使用正则表达式解决一些常见问题。

从字符串的开始和结束去除多余的空格是一个非常常见的用例。由于字符串对象直到最近才有一个trim()方法,因此一些 JavaScript 库为没有String.trim()方法的旧浏览器提供并使用了字符串截取的实现。最常用的方法看起来像下面的代码:

function trim(str) {
  return (str || "").replace(/^\s+|\s+$/g, "");
}
console.log("--"+trim("   test    ")+"--");
//"--test--"

如果我们想用一个空格替换重复的空格怎么办?

re=/\s+/g;
console.log('There are    a lot      of spaces'.replace(re,' '));
//"There are a lot of spaces"
As you can see, regular expressions can prove to be a Swiss army knife in your JavaScript arsenal. Careful study and practice will be extremely rewarding for you in the long run.

数组

数组是一个有序的值集合。你可以用一个名字和索引来引用数组元素。以下是 JavaScript 中创建数组的三个方法:

var arr = new Array(1,2,3);
var arr = Array(1,2,3);
var arr = [1,2,3];

当这些值被指定时,数组初始化为这些值作为数组的元素。数组的length属性等于参数的数量。方括号语法称为数组字面量。这是一种更简短且更推荐的方式来初始化数组。

如果你想初始化一个只有一个元素且该元素碰巧是数字的数组,你必须使用数组字面量语法。如果你将一个单一的数字值传递给Array()构造函数或函数,JavaScript 将这个参数视为数组的长度,而不是单个元素:

var arr = [10];
var arr = Array(10); // Creates an array with no element, but with arr.length set to 10
// The above code is equivalent to
var arr = [];
arr.length = 10;

JavaScript 没有显式的数组数据类型。然而,你可以使用预定义的Array对象及其方法来处理应用程序中的数组。Array对象有各种方式操作数组的方法,如连接、反转和排序它们。它有一个属性来确定数组长度和其他用于正则表达式的属性。

你可以通过给它的元素赋值来填充一个数组:

var days = [];
days[0] = "Sunday";
days[1] = "Monday";

你也可以在创建数组时填充它:

var arr_generic = new Array("A String", myCustomValue, 3.14);
var fruits = ["Mango", "Apple", "Orange"]

在大多数语言中,数组的元素都必须是同一类型。JavaScript 允许数组包含任何类型的值:

var arr = [
  'string', 42.0, true, false, null, undefined,
  ['sub', 'array'], {object: true}, NaN
]; 

你可以使用元素的索引号码来引用Array的一个元素。例如,假设你定义了以下数组:

var days = ["Sunday", "Monday", "Tuesday"]

然后你将数组的第一个元素称为colors[0],第二个元素称为colors[1]。元素的索引从0开始。

JavaScript 内部将数组元素作为标准对象属性存储,使用数组索引作为属性名。length属性是不同的。length属性总是返回最后一个元素索引加一。正如我们讨论的,JavaScript 数组索引是基于 0 的:它们从0开始,而不是1。这意味着length属性将是数组中存储的最高索引加一:

var colors = [];
colors[30] = ['Green'];
console.log(colors.length); // 31

你还可以赋值给length属性。如果写入的值比存储的项目数少,数组就会被截断;写入0则会清空它:

var colors = ['Red', 'Blue', 'Yellow'];
console.log(colors.length); // 3
colors.length = 2;
console.log(colors); // ["Red","Blue"] - Yellow has been removed
colors.length = 0;
console.log(colors); // [] the colors array is empty
colors.length = 3;
console.log(colors); // [undefined, undefined, undefined]

如果你查询一个不存在的数组索引,你会得到undefined

一个常见的操作是遍历数组的值,以某种方式处理每一个值。这样做最简单的方式如下:

var colors = ['red', 'green', 'blue']; 
for (var i = 0; i < colors.length; i++) { 
  console.log(colors[i]); 
}

forEach() 方法提供了另一种遍历数组的方式:

var colors = ['red', 'green', 'blue'];
colors.forEach(function(color) {
  console.log(color);
});

传递给 forEach() 的函数对数组中的每个项目执行一次,将数组项目作为函数的参数传递。在 forEach() 循环中不会遍历未赋值的值。

Array 对象有一组实用的方法。这些方法允许操作数组中存储的数据。

concat() 方法将两个数组合并成一个新数组:

var myArray = new Array("33", "44", "55");
myArray = myArray.concat("3", "2", "1"); 
console.log(myArray);
// ["33", "44", "55", "3", "2", "1"]

join() 方法将数组的所有元素合并成一个字符串。这在处理列表时可能很有用。默认的分隔符是逗号 (,):

var myArray = new Array('Red','Blue','Yellow');
var list = myArray.join(" ~ "); 
console.log(list);
//"Red ~ Blue ~ Yellow"

pop() 方法从数组中移除最后一个元素,并返回该元素。这与栈的 pop() 方法类似:

var myArray = new Array("1", "2", "3");
var last = myArray.pop(); 
// myArray = ["1", "2"], last = "3"

push() 方法向数组的末尾添加一个或多个元素,并返回数组的结果长度:

var myArray = new Array("1", "2");
myArray.push("3"); 
// myArray = ["1", "2", "3"]

shift() 方法从数组中移除第一个元素,并返回该元素:

var myArray = new Array ("1", "2", "3");
var first = myArray.shift(); 
// myArray = ["2", "3"], first = "1"

unshift() 方法向数组的开头添加一个或多个元素,并返回数组的新长度:

var myArray = new Array ("1", "2", "3");
myArray.unshift("4", "5"); 
// myArray = ["4", "5", "1", "2", "3"]

reverse() 方法反转或转置数组的元素——第一个数组元素变为最后一个,最后一个变为第一个:

var myArray = new Array ("1", "2", "3");
myArray.reverse(); 
// transposes the array so that myArray = [ "3", "2", "1" ]

sort() 方法对数组的元素进行排序:

var myArray = new Array("A", "C", "B");
myArray.sort(); 
// sorts the array so that myArray = [ "A","B","c" ]

sort() 方法可以接受一个回调函数作为可选参数,以定义元素如何进行比较。该函数比较两个值并返回三个值之一。让我们研究以下函数:

  • indexOf(searchElement[, fromIndex]):此方法在数组中搜索 searchElement 并返回第一个匹配项的索引:

    var a = ['a', 'b', 'a', 'b', 'a','c','a'];
    console.log(a.indexOf('b')); // 1
    // Now try again, starting from after the last match
    console.log(a.indexOf('b', 2)); // 3
    console.log(a.indexOf('1')); // -1, 'q' is not found
    
  • lastIndexOf(searchElement[, fromIndex]):此方法类似于 indexOf(),但只从后向前搜索:

    var a = ['a', 'b', 'c', 'd', 'a', 'b'];
    console.log(a.lastIndexOf('b')); //  5
    // Now try again, starting from before the last match
    console.log(a.lastIndexOf('b', 4)); //  1
    console.log(a.lastIndexOf('z')); //  -1
    

既然我们已经深入讲解了 JavaScript 数组,那么让我向您介绍一个名为 Underscore.js 的绝佳库(underscorejs.org/)。Underscore.js 提供了一系列极其有用的函数编程助手,使您的代码更加清晰和功能化。

我们假设您熟悉Node.js;在这种情况下,通过 npm 安装 Underscore.js:

npm install underscore

由于我们正在将 Underscore 作为 Node 模块进行安装,因此我们将通过在 Node.js 上运行 .js 文件来输入所有示例。您也可以使用 Bower 安装 Underscore。

类似于 jQuery 的 $ 模块,Underscore 带有一个 _ 模块的定义。您将使用这个模块引用调用所有函数。

将以下代码输入文本文件并命名为 test_.js

var _ = require('underscore');
function print(n){
  console.log(n);
}
_.each([1, 2, 3], print);
//prints 1 2 3

以下是不使用 underscore 库中的 each() 函数的写法:

var myArray = [1,2,3];
var arrayLength = myArray.length;
for (var i = 0; i < arrayLength; i++) {
  console.log(myArray[i]);
}

这里所展示的是一个强大的功能性结构,使代码更加优雅和简洁。你可以明显看出传统方法是冗长的。像 Java 这样的许多语言都受到这种冗长的影响。它们正在逐渐接受函数式编程范式。作为 JavaScript 程序员,我们尽可能地将这些思想融入到我们的代码中是非常重要的。

前面例子中看到的each()函数遍历元素列表,依次将每个元素传递给迭代函数。每次迭代函数调用时,都会传入三个参数(元素、索引和列表)。在前面的例子中,each()函数遍历数组[1,2,3],对于数组中的每个元素,print函数都会被调用,并传入数组元素作为参数。这是访问数组中所有元素的方便方法,代替传统的循环机制。

range()函数创建整数列表。如果省略起始值,默认为0,步长默认为1。如果你想要一个负范围,使用负步长:

var _ = require('underscore');
console.log(_.range(10));
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
console.log(_.range(1, 11));
//[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ]
console.log(_.range(0, 30, 5));
//[ 0, 5, 10, 15, 20, 25 ]
console.log(_.range(0, -10, -1));
//[ 0, -1, -2, -3, -4, -5, -6, -7, -8, -9 ]
console.log(_.range(0));
//[]

默认情况下,range()用整数填充数组,但用一点小技巧,你也可以用其他数据类型填充:

console.log(_.range(3).map(function () { return 'a' }) );
[ 'a', 'a', 'a' ]

这是一种快速方便的方法来创建和初始化一个带有值的数组。我们经常通过传统循环来做这件事。

map()函数通过映射每个列表中的值到一个转换函数,生成一个新的值数组。考虑以下示例:

var _ = require('underscore');
console.log(_.map([1, 2, 3], function(num){ return num * 3; }));
//[3,6,9]

reduce()函数将一个值列表减少到一个单一的值。初始状态由迭代函数传递,每个连续步骤由迭代函数返回。以下示例展示了使用方法:

var _ = require('underscore');
var sum = _.reduce([1, 2, 3], function(memo, num){console.log(memo,num);return memo + num; }, 0);
console.log(sum);

在这个例子中,console.log(memo,num);这行代码只是为了更清楚地说明想法。输出结果如下:

0 1
1 2
3 3
6

最终输出是*1+2+3=6*的和。正如你所见,两个值被传递到迭代函数中。在第一次迭代中,我们调用迭代函数并传入两个值(0,1)——memo在调用reduce()函数时的默认值是01是列表的第一个元素。在函数中,我们计算memonum的和并返回中间的sum,这个sum将被iterate()函数作为memo参数使用——最终,memo将累积sum。理解这个概念对于了解如何使用中间状态来计算最终结果很重要。

filter()函数遍历整个列表,返回满足条件的所有元素的数组。看看下面的例子:

var _ = require('underscore');
var evens = _.filter([1, 2, 3, 4, 5, 6], function(num){ return num % 2 == 0; });
console.log(evens);

filter()函数的迭代函数应该返回一个真值。结果的evens数组包含所有满足真值测试的元素。

filter()函数的反义词是reject()。正如名字 suggest,它遍历列表并忽略满足真值测试的元素:

var _ = require('underscore');
var odds = _.reject([1, 2, 3, 4, 5, 6], function(num){ return num % 2 == 0; });
console.log(odds);
//[ 1, 3, 5 ]

我们使用了与上一个例子相同的代码,但这次用reject()方法而不是filter()——结果正好相反。

contains()函数是一个有用的小函数,如果值在列表中,就返回true;否则,返回false

var _ = require('underscore');
console.log(_.contains([1, 2, 3], 3));
//true

一个非常实用的函数,我已经喜欢上了,就是 invoke()。它在列表中的每个元素上调用一个特定的函数。我无法告诉你自从偶然发现它以来我使用了多少次。让我们研究以下示例:

var _ = require('underscore');
console.log(_.invoke([[5, 1, 7], [3, 2, 1]], 'sort'));
//[ [ 1, 5, 7 ], [ 1, 2, 3 ] ]

在这个例子中,Array 对象的 sort() 方法被应用于数组中的每个元素。注意这将失败:

var _ = require('underscore');
console.log(_.invoke(["new","old","cat"], 'sort'));
//[ undefined, undefined, undefined ]

这是因为 sort 方法不是字符串对象的一部分。然而,这完全有效:

var _ = require('underscore');
console.log(_.invoke(["new","old","cat"], 'toUpperCase'));
//[ 'NEW', 'OLD', 'CAT' ]

这是因为 toUpperCase() 是字符串对象的方法,列表中的所有元素都是字符串类型。

uniq() 函数返回去除原始数组所有重复项后的数组:

var _ = require('underscore');
var uniqArray = _.uniq([1,1,2,2,3]);
console.log(uniqArray);
//[1,2,3]

partition() 函数将数组分成两部分;一部分是满足谓词的元素,另一部分是不满足谓词的元素:

var _ = require('underscore');
function isOdd(n){
  return n%2==0;
}
console.log(_.partition([0, 1, 2, 3, 4, 5], isOdd));
//[ [ 0, 2, 4 ], [ 1, 3, 5 ] ]
[1,2,3]—this is a helpful method to eliminate any value from a list that can cause runtime exceptions.

without() 函数返回一个删除特定值所有实例的数组副本:

var _ = require('underscore');
console.log(_.without([1,2,3,4,5,6,7,8,9,0,1,2,0,0,1,1],0,1,2));
//[ 3, 4, 5, 6, 7, 8, 9 ]

映射(Maps)

Map type and their usage:
var founders = new Map();
founders.set("facebook", "mark");
founders.set("google", "larry");
founders.size; // 2
founders.get("twitter"); // undefined
founders.has("yahoo"); // false

for (var [key, value] of founders) {
  console.log(key + " founded by " + value);
}
// "facebook founded by mark"
// "google founded by larry"

集合

ECMAScript 6 引入了集合。集合是值的集合,并且可以按照它们的元素插入顺序进行迭代。关于集合的一个重要特征是,集合中的值只能出现一次。

以下代码片段展示了集合的一些基本操作:

var mySet = new Set();
mySet.add(1);
mySet.add("Howdy");
mySet.add("foo");

mySet.has(1); // true
mySet.delete("foo");
mySet.size; // 2

for (let item of mySet) console.log(item);
// 1
// "Howdy"

我们简要讨论过,JavaScript 数组并不是真正意义上的数组。在 JavaScript 中,数组是具有以下特征的对象:

  • length 属性

  • 继承自 Array.prototype 的函数(我们将在下一章讨论这个)

  • 对数字键的特殊处理

当我们写数组索引作为数字时,它们会被转换为字符串——arr[0] 内部变成了 arr["0"]。由于这一点,当我们使用 JavaScript 数组时,我们需要注意一些事情:

  • 通过索引访问数组元素并不是一个常数时间操作,比如在 C 语言中。因为数组实际上是键值映射,访问将取决于映射的布局和其他因素(冲突等)。

  • JavaScript 数组是稀疏的(大多数元素都有默认值),这意味着数组中可能会有间隙。为了理解这一点,看看以下代码片段:

    var testArr=new Array(3);
    console.log(testArr); 
    

    你会看到输出是 [undefined, undefined, undefined]——undefined 是数组元素存储的默认值。

考虑以下示例:

var testArr=[];
testArr[3] = 10;
testArr[10] = 3;
console.log(testArr);
// [undefined, undefined, undefined, 10, undefined, undefined, undefined, undefined, undefined, undefined, 3]

你可以看到这个数组中有间隙。只有两个元素有值,其余的都是使用默认值填充的间隙。了解这一点可以帮助你避免一些问题。使用 for...in 循环迭代数组可能会导致意外的结果。考虑以下示例:

var a = [];
a[5] = 5;
for (var i=0; i<a.length; i++) {
  console.log(a[i]);
}
// Iterates over numeric indexes from 0 to 5
// [undefined,undefined,undefined,undefined,undefined,5]

for (var x in a) {
  console.log(x);
}
// Shows only the explicitly set index of "5", and ignores 0-4

风格问题

和前面章节一样,我们将花些时间讨论创建数组时的风格考虑。

  • 使用字面量语法创建数组:

    // bad
    const items = new Array();
    // good
    const items = [];
    
  • 使用 Array#push 而不是直接赋值来向数组中添加项目:

    const stack = [];
    // bad
    stack[stack.length] = 'pushme';
    // good
    stack.push('pushme');
    

总结

随着 JavaScript 作为一种语言的成熟,其工具链也变得更加健壮和有效。经验丰富的程序员很少会避开像 Underscore.js 这样的库。随着我们看到更多高级主题,我们将继续探索更多这样的多功能库,这些库可以使你的代码更加紧凑、易读且性能更优。我们研究了正则表达式——它们在 JavaScript 中是第一类对象。一旦你开始理解RegExp,你很快就会发现自己更多地使用它们来使你的代码更加简洁。在下一章,我们将探讨 JavaScript 对象表示法以及 JavaScript 原型继承是如何为面向对象编程提供一种新的视角。

第四章:面向对象的 JavaScript

JavaScript 最基本的数据类型是对象数据类型。JavaScript 对象可以被视为可变的基于键值对的集合。在 JavaScript 中,数组、函数和 RegExp 都是对象,而数字、字符串和布尔值是类似对象的构造,是不可变的,但具有方法。在本章中,你将学习以下主题:

  • 理解对象

  • 实例属性与原型属性

  • 继承

  • 获取器和设置器

理解对象

在我们开始研究 JavaScript 如何处理对象之前,我们应该先花些时间来了解一下面向对象范式。像大多数编程范式一样,面向对象编程OOP)也是为了解决复杂性而产生的。主要思想是将整个系统划分为更小的、相互隔离的部分。如果这些小部分能隐藏尽可能多的实现细节,它们就变得容易使用了。一个经典的汽车类比将帮助你理解 OOP 的非常重要的一点。

当你驾驶汽车时,你在操作界面——转向、离合器、刹车和油门。你使用汽车的视角被这个界面所限制,这使得我们能够驾驶汽车。这个界面本质上隐藏了所有真正驱动汽车复杂的系统,比如它的发动机内部运作、电子系统等等。作为一名驾驶员,你不需要关心这些复杂性。这是面向对象编程(OOP)的主要驱动力。一个对象隐藏了实现特定功能的所有复杂性,并向外界暴露了一个有限的接口。所有其他系统都可以使用这个接口,而无需真正关心被隐藏的内部复杂性。此外,一个对象通常会隐藏其内部状态,不让其他对象直接修改。这是 OOP 的一个重要方面。

在一个大型系统中,如果许多对象调用其他对象的接口,而允许它们修改这些对象的内部状态,事情可能会变得非常糟糕。OOP 的基本理念是,对象的内部状态 inherently hidden from the outside world,并且只能通过受控的接口操作来更改。

面向对象编程(OOP)是一个重要的想法,也是从传统的结构化编程向前迈出的明确一步。然而,许多人认为 OOP 做得过头了。大多数 OOP 系统定义了复杂且不必要的类和类型层次结构。另一个大的缺点是,在追求隐藏状态的过程中,OOP 几乎将对象状态视为不重要。尽管 OOP 非常流行,但在许多方面显然是有缺陷的。然而,OOP 确实有一些非常好的想法,尤其是隐藏复杂性并只向外部世界暴露接口。JavaScript 采纳了一些好想法,并围绕它们构建了其对象模型。幸运的是,这使得 JavaScript 对象非常多功能。在他们开创性的作品中,《设计模式:可重用面向对象软件的元素》,四人帮给出了更好的面向对象设计两个基本原则:

  • 面向接口编程,而不是面向实现

  • 对象组合优于类继承

这两个想法实际上是与经典 OOP 的运作方式相反的。经典继承的运作方式是基于继承,将父类暴露给所有子类。经典继承紧密耦合了子类和其父类。经典继承中有机制可以在一定程度上解决这个问题。如果你在像 Java 这样的语言中使用经典继承,通常建议面向接口编程,而不是面向实现。在 Java 中,你可以使用接口编写松耦合的代码:

//programming to an interface 'List' and not implementation 'ArrayList'
List theList = new ArrayList();

而不是编程到实现,你可以执行以下操作:

ArrayList theList = new ArrayList();

编程到一个接口有什么帮助?当你编程到List接口时,你只能调用List接口独有的方法,不能调用ArrayList特定的方法。编程到一个接口给你自由改变你的代码并使用List接口的任何其他特定子类。例如,我可以改变我的实现并使用LinkedList而不是ArrayList。你可以将你的变量更改为使用LinkedList

List theList = new LinkedList();

这种方法的优点是,如果你在你的程序中 100 次使用List,你根本不需要担心在所有这些地方改变实现。因为你是面向接口编程,而不是面向实现,所以你能够编写松耦合的代码。当你使用经典继承时,这是一个重要的原则。

经典继承也有一个限制,即你只能在父类范围内增强子类。你不能根本区别于从祖先那里得到的东西。这阻碍了重用。经典继承还有其他几个问题,如下:

  • 继承引入了紧密耦合。子类对其祖先有所了解。这种紧密耦合了一个子类与其父类之间的关系。

  • 当你从父类继承时,你无法选择继承什么和不继承什么。Joe ArmstrongErlang的发明者)很好地解释了这种情况——他那如今著名的名言:

    "面向对象语言的问题在于,它们携带的所有这些隐式环境。你想要一根香蕉,但你所得到的是一个拿着香蕉和整个丛林的大猩猩。"

JavaScript 对象的行为

有了这些背景知识,让我们来探讨一下 JavaScript 对象的行为。从广义上讲,一个对象包含属性,这些属性定义为键值对。属性键(名称)可以是字符串,值可以是任何有效的 JavaScript 值。你可以使用对象字面量来创建对象。以下片段展示了对象字面量是如何创建的:

var nothing = {};
var author = {
  "firstname": "Douglas",
  "lastname": "Crockford"
}

属性的名称可以是任何字符串或空字符串。如果属性名是合法的 JavaScript 名称,你可以省略属性名周围的引号。所以first-name周围需要引号,但firstname周围可以省略引号。逗号用于分隔这些对。你可以像下面这样嵌套对象:

var author = {
  firstname : "Douglas",
  lastname : "Crockford",
  book : {
    title:"JavaScript- The Good Parts",
    pages:"172"
  }
};

可以通过使用两种表示法来访问对象的属性:数组表示法和点表示法。根据数组表示法,你可以通过将字符串表达式包裹在[]中来从对象中检索值。如果表达式是一个有效的 JavaScript 名称,你可以使用点表示法使用.代替。使用.是从对象中检索值的首选方法:

console.log(author['firstname']); //Douglas
console.log(author.lastname);     //Crockford
console.log(author.book.title);   // JavaScript- The Good Parts

如果你尝试获取一个不存在的值,你会得到一个undefined错误。以下将返回undefined

console.log(author.age);

一个有用的技巧是使用||运算符在这种情况下填充默认值:

console.log(author.age || "No Age Found");

你可以通过将新值赋给属性来更新对象的值:

author.book.pages = 190;
console.log(author.book.pages); //190

如果你仔细观察,你会意识到你看到的对象字面量语法与 JSON 格式非常相似。

对象的方法是对象的属性,可以持有函数值,如下所示:

var meetingRoom = {};
meetingRoom.book = function(roomId){
  console.log("booked meeting room -"+roomId);
}
meetingRoom.book("VL");

原型

除了我们添加到对象上的属性外,几乎所有对象都有一个默认属性,称为原型。当一个对象没有请求的属性时,JavaScript 会去它的原型中查找。Object.getPrototypeOf()函数返回一个对象的 prototype。

许多程序员认为原型与对象的继承密切相关——它们确实是一种定义对象类型的方式——但从根本上说,它们与函数紧密相关。

原型是用来定义将应用于对象实例的属性和函数的一种方式。原型的属性最终成为实例化对象的属性。原型可以被视为创建对象的蓝图。它们可以被视为面向对象语言中类的类似物。JavaScript 中的原型用于编写经典风格的面向对象代码并模仿经典继承。让我们重新回顾一下我们之前的例子:

var author = {};
author.firstname = 'Douglas';
author.lastname = 'Crockford';
new operator to instantiate an object via constructors. However, there is no concept of a class in JavaScript, and it is important to note that the new operator is applied to the constructor function. To clearly understand this, let's look at the following example:
//A function that returns nothing and creates nothing
function Player() {}

//Add a function to the prototype property of the function
Player.prototype.usesBat = function() {
  return true;
}

//We call player() as a function and prove that nothing happens
var crazyBob = Player();
if(crazyBob === undefined){
  console.log("CrazyBob is not defined");
}

//Now we call player() as a constructor along with 'new' 
//1\. The instance is created
//2\. method usesBat() is derived from the prototype of the function
var swingJay = new Player();
if(swingJay && swingJay.usesBat && swingJay.usesBat()){
  console.log("SwingJay exists and can use bat");
}

在前一个例子中,我们有一个player()函数,它什么也不做。我们以两种不同的方式调用它。第一个调用是作为普通函数,第二个调用作为构造函数——注意这个调用中使用了new()操作符。一旦函数被定义,我们向它添加了一个usesBat()方法。当这个函数作为普通函数调用时,对象没有被实例化,我们看到undefined被赋值给crazyBob。然而,当我们使用new操作符调用这个函数时,我们得到了一个完全实例化的对象,swingJay

实例属性与原型属性对比

实例属性是对象实例本身的一部分属性,如下例所示:

function Player() {
  this.isAvailable = function() {
    return "Instance method says - he is hired";
  };
}
Player.prototype.isAvailable = function() {
  return "Prototype method says - he is Not hired";
};
var crazyBob = new Player();
console.log(crazyBob.isAvailable());

当你运行这个例子时,你会看到实例方法说 - 他被雇佣了被打印出来。在Player()函数中定义的isAvailable()函数被称为Player的实例。这意味着除了通过原型附加属性外,你还可以使用this关键字在构造函数中初始化属性。当我们实例属性和原型中都有相同的函数定义时,实例属性优先。决定初始化优先级的规则如下:

  • 属性从原型绑定到对象实例。

  • 属性在构造函数中绑定到对象实例。

这个例子让我们了解了this关键字的用法。this关键字很容易让人混淆,因为它在 JavaScript 中的行为不同。在其他面向对象的编程语言(如 Java)中,this关键字指的是类当前的实例。在 JavaScript 中,this的值由函数的调用上下文和调用位置决定。让我们看看这种行为需要如何仔细理解:

  • 在全局上下文中使用this:当在全局上下文中调用this时,它绑定到全局上下文。例如,在浏览器中,全局上下文通常是window。这也适用于函数。如果你在全局上下文中定义的函数中使用this,它仍然绑定到全局上下文,因为函数是全局上下文的一部分:

    function globalAlias(){
      return this;
    }
    console.log(globalAlias()); //[object Window]
    
  • 在对象方法中使用this:在这种情况下,this被赋值或绑定到包含它的对象。注意,如果你们嵌套对象,包含对象是立即的父级:

    var f = {
      name: "f",
      func: function () {
        return this; 
      }
    };
    console.log(f.func());  
    //prints - 
    //[object Object] {
    //  func: function () {
    //    return this; 
    //  },
    //  name: "f"
    //}
    
  • 在没有上下文的情况下:如果一个函数没有被任何对象调用,它不会获得任何上下文。默认情况下,它绑定到全局上下文。当你在这样一个函数中使用this时,它也绑定到全局上下文。

  • 当在构造函数中使用this时:正如我们之前所看到的,当一个函数通过new关键字调用时,它充当构造函数。在构造函数的情况下,this指向正在构造的对象。在下面的例子中,f()被用作构造函数(因为它通过new关键字调用),因此,this指向正在创建的新对象。所以当我们说this.member = "f"时,新成员被添加到正在创建的对象中,在这个例子中,这个对象碰巧是o

    var member = "global";
    function f()
    {
      this.member = "f";
    }
    var o= new f(); 
    console.log(o.member); // f
    

我们发现,当实例属性和原型属性同时定义同一个属性时,实例属性具有优先权。很容易想象,当创建新对象时,构造函数的原型属性会被复制过来。然而,这并不是一个正确的假设。实际发生的情况是,原型被附加到对象上,并在引用该对象的任何属性时引用它。本质上,当引用对象的属性时,以下情况之一会发生:

  • 检查对象是否具有该属性。如果找到,则返回该属性。

  • 检查相关原型。如果找到属性,则返回该属性;否则,返回一个undefined错误。

这是一个重要的理解,因为在 JavaScript 中,以下代码实际上完全有效:

function Player() {
  isAvailable=false;
}
var crazyBob = new Player();
Player.prototype.isAvailable = function() {
  return isAvailable;
};
console.log(crazyBob.isAvailable()); //false

这段代码是之前示例的稍微变体。我们首先创建一个对象,然后将其函数附加到原型上。当你最终在对象上调用isAvailable()方法时,如果在该对象中找不到它(在这个例子中是crazyBob),JavaScript 会到其原型中寻找。你可以将其视为热代码加载——如果使用得当,这种能力可以在对象创建后为你提供巨大的扩展基本对象框架的权力。

如果你已经熟悉面向对象编程(OOP),你可能想知道我们是否能控制对象成员的可见性和访问权限。正如我们之前讨论的,JavaScript 没有类。在像 Java 这样的编程语言中,你有访问修饰符,如privatepublic,可以让你控制类成员的可见性。在 JavaScript 中,我们可以使用函数作用域实现类似的功能:

  • 你可以在函数中使用var关键字声明私有变量。它们可以通过私有函数或特权方法访问。

  • 私有函数可以在对象的构造函数中声明,并且可以通过特权方法调用。

  • 特权方法可以通过this.method=function() {}声明。

  • 公共方法通过Class.prototype.method=function(){}声明。

  • 公共属性可以用this.property声明,并从对象外部访问。

以下示例展示了几种实现方式:

function Player(name,sport,age,country){ 

  this.constructor.noOfPlayers++;

  // Private Properties and Functions
  // Can only be viewed, edited or invoked by privileged members
  var retirementAge = 40;
  var available=true;
  var playerAge = age?age:18;
  function isAvailable(){ return available && (playerAge<retirementAge); } 
  var playerName=name ? name :"Unknown";
  var playerSport = sport ? sport : "Unknown";

  // Privileged Methods
  // Can be invoked from outside and can access private members
  // Can be replaced with public counterparts
  this.book=function(){ 
    if (!isAvailable()){ 
      this.available=false;
    } else {
      console.log("Player is unavailable");
    } 
  };
  this.getSport=function(){ return playerSport; }; 
  // Public properties, modifiable from anywhere
  this.batPreference="Lefty";
  this.hasCelebGirlfriend=false;
  this.endorses="Super Brand";
} 

// Public methods - can be read or written by anyone
// Can only access public and prototype properties
Player.prototype.switchHands = function(){ this.batPreference="righty"; }; 
Player.prototype.dateCeleb = function(){ this.hasCelebGirlfriend=true; } ;
Player.prototype.fixEyes = function(){ this.wearGlasses=false; };

// Prototype Properties - can be read or written by anyone (or overridden)
Player.prototype.wearsGlasses=true;

// Static Properties - anyone can read or write
Player.noOfPlayers = 0;

(function PlayerTest(){ 
  //New instance of the Player object created.
  var cricketer=new Player("Vivian","Cricket",23,"England"); 
  var golfer =new Player("Pete","Golf",32,"USA"); 
  console.log("So far there are " + Player.noOfPlayers + " in the guild");

  //Both these functions share the common 'Player.prototype.wearsGlasses' variable
  cricketer.fixEyes(); 
  golfer.fixEyes(); 

  cricketer.endorses="Other Brand";//public variable can be updated 

  //Both Player's public method is now changed via their prototype 
  Player.prototype.fixEyes=function(){ 
    this.wearGlasses=true;
  };
  //Only Cricketer's function is changed
  cricketer.switchHands=function(){
    this.batPreference="undecided";
  };

})();

让我们从这个例子中理解一些重要的概念:

  • retirementAge变量是一个私有变量,没有特权方法来获取或设置其值。

  • country变量是一个通过构造函数参数创建的私有变量。构造函数参数作为私有变量对对象可用。

  • 当我们调用cricketer.switchHands()时,这个函数只应用于cricketer本身,而没有同时应用于两名球员,尽管它本身是Player对象的一个原型函数。

  • 私有函数和特权方法随着每个新对象的创建而实例化。在我们的例子中,每次我们创建一个新的球员实例时,都会创建isAvailable()book()的新副本。另一方面,只有公共方法的一个副本被创建,并在所有实例之间共享。这可能会带来一些性能提升。如果你真的不需要将某事设为私有,考虑将其设为公共。

继承

继承是面向对象编程(OOP)的一个重要概念。通常会有许多实现相同方法的对象,也很常见几乎相似的对象定义,差异仅在于几个方法。继承在促进代码重用方面非常有用。我们可以看看以下继承关系的经典示例:

继承

在这里,你可以看到从通用的Animal类中,我们派生出更具体的一些类,如MammalBird,这些都是基于特定的特性。哺乳动物和鸟类班级都有动物类的同一个模板;然而,它们还定义了特定于它们自己的行为和属性。最后,我们派生出一个非常具体的哺乳动物,Dog。狗从动物类和哺乳动物类中继承了共同的属性和行为,同时它还增加了狗特有的属性和行为。这可以继续添加复杂的继承关系。

传统上,继承被用来建立或描述IS-A关系。例如,狗是哺乳动物。这就是我们所说的经典继承。你可能会在面向对象的语言如 C++和 Java 中看到这样的关系。JavaScript 有一个完全不同的机制来处理继承。JavaScript 是一种无类语言,使用原型进行继承。原型继承在本质上非常不同,需要深入理解。经典继承和原型继承在本质上非常不同,需要仔细研究。

在经典继承中,实例从类蓝图中继承,并创建子类关系。你不能在类定义本身上调用实例方法。你需要创建一个实例,然后在这个实例上调用方法。另一方面,在原型继承中,实例从其他实例中继承。

至于继承,JavaScript 只使用对象。如我们之前讨论的,每个对象都有一个链接到另一个对象的原型。这个原型对象,反过来,也有自己的原型,依此类推,直到找到一个其原型为null的对象;null,按定义,没有原型,作为原型链中的最后一个链接。

为了更好地理解原型链,让我们考虑以下示例:

function Person() {}
Person.prototype.cry = function() { 
  console.log("Crying");
}
function Child() {}
Child.prototype = {cry: Person.prototype.cry};
var aChild = new Child();
console.log(aChild instanceof Child);  //true
console.log(aChild instanceof Person); //false
console.log(aChild instanceof Object); //true

在这里,我们定义了一个Person,然后是Child——一个孩子 IS-A 人。我们还把Personcry属性复制给了Childcry属性。当我们尝试使用instanceof来看这种关系时,我们很快意识到,仅仅通过复制行为,我们并不能真正使Child成为Person的实例;aChild instanceof Person失败。这只是复制或伪装,并不是继承。即使我们把Person的所有属性复制给Child,我们也不会从Person继承。这通常是一个糟糕的主意,这里只是为了说明目的。我们希望导出一个原型链——一个 IS-A 关系,一个真正的继承,我们可以说是 child IS-A person。我们希望创建一个链:child IS-A person IS-A mammal IS-A animal IS-A object。在 JavaScript 中,这是通过使用一个对象的实例作为原型来完成的:

SubClass.prototype = new SuperClass();
Child.prototype = new Person();

让我们修改之前的示例:

function Person() {}
Person.prototype.cry = function() { 
  console.log("Crying");
}
function Child() {}
Child.prototype = new Person();
var aChild = new Child();
console.log(aChild instanceof Child);  //true
console.log(aChild instanceof Person); //true
console.log(aChild instanceof Object); //true

修改后的行使用了Person实例作为Child的原型。这与之前的方法有重要的区别。这里我们声明 child IS-A person。

我们讨论了 JavaScript 如何在一个属性直到它达到Object.prototype的原型链中寻找属性。让我们详细讨论原型链的概念,并尝试设计以下员工层次结构:

继承

这是继承的典型模式。经理 IS-A(n) 员工。经理员工继承了共同的属性。它可以拥有一个报告人员的数组。一个个人贡献者也是基于一个员工,但他没有任何报告人员。一个团队领导从经理派生出来,有几个与经理不同的功能。我们本质上是在做每个孩子从它的父母那里导出属性(经理是父母,团队领导是孩子)。

让我们看看我们如何在 JavaScript 中创建这个层次结构。让我们定义我们的Employee类型:

function Employee() {
  this.name = '';
  this.dept = 'None';
  this.salary = 0.00;
}

这些定义没有什么特别之处。Employee对象包含三个属性—姓名、薪水、部门。接下来,我们定义Manager。这个定义展示了如何指定继承链中的下一个对象:

function Manager() {
 Employee.call(this);
  this.reports = [];
}
Manager.prototype = Object.create(Employee.prototype);

在 JavaScript 中,你可以在定义构造函数后任何时候将原型实例添加到构造函数的 prototype 属性中。在这个例子中,我们还没有探索到两个想法。首先,我们调用Employee.call(this)。如果你来自 Java 背景,这与构造函数中的super()方法调用类似。call()方法用一个特定的对象作为其上下文(在这个例子中,是给定的this值)调用一个函数,换句话说,call()允许指定在函数执行时哪个对象将被this关键字引用。与 Java 中的super()类似,调用parentObject.call(this)是初始化正在创建的对象所必需的。

我们看到的另一点是使用Object.create()而不是调用newObject.create()创建了一个具有指定原型的对象。当我们调用new Parent()时,会调用父类的构造逻辑。在大多数情况下,我们想要的是Child.prototype是一个通过原型链接到Parent.prototype的对象。如果父类构造函数包含特定于父类的额外逻辑,我们在创建子对象时不想运行这个逻辑。这可能会导致非常难以发现的错误。Object.create()创建了与new运算符相同的父子原型链接,而不会调用父类构造函数。

为了有一个无副作用且准确的继承机制,我们必须确保我们执行以下操作:

  • 将原型设置为父类的实例来初始化原型链(继承);这只需要做一次(因为原型对象是共享的)

  • 调用父类的构造函数初始化对象本身;这在每次实例化时都会进行(你可以在构造它时传递不同的参数)

在理解了这一点的基础上,我们来定义其余的对象:

function IndividualContributor() {
  Employee.call(this);
  this.active_projects = [];
}
IndividualContributor.prototype = Object.create(Employee.prototype);

function TeamLead() {
  Manager.call(this);
  this.dept = "Software";
  this.salary = 100000;
}
TeamLead.prototype = Object.create(Manager.prototype);

function Engineer() {
  TeamLead.call(this);
  this.dept = "JavaScript";
  this.desktop_id = "8822" ;
  this.salary = 80000;
}
Engineer.prototype = Object.create(TeamLead.prototype);

基于这个层次结构,我们可以实例化这些对象:

var genericEmployee = new Employee();
console.log(genericEmployee);

你可以看到以下代码片段的输出:

[object Object] {
  dept: "None",
  name: "",
  salary: 0
}

一个通用的Employee对象分配给None的部门(如默认值中所指定),其余属性也分配为默认值。

接下来,我们实例化一个经理;我们可以像下面这样提供具体的值:

var karen = new Manager();
karen.name = "Karen";
karen.reports = [1,2,3];
console.log(karen);

你会看到以下输出:

[object Object] {
  dept: "None",
  name: "Karen",
  reports: [1, 2, 3],
  salary: 0
}

对于TeamLead,其reports属性是从基类(在这个例子中是 Manager)派生出来的:

var jason = new TeamLead();
jason.name = "Json";
console.log(jason);

你会看到以下的输出:

[object Object] {
  dept: "Software",
  name: "Json",
  reports: [],
  salary: 100000
}

当 JavaScript 处理新的操作符时,它创建一个新对象,并将这个对象作为this的值传递给父对象——即TeamLead构造函数。构造函数设置projects属性的值,并隐式地将内部__proto__属性的值设置为TeamLead.prototype的值。__proto__属性决定了用于返回属性值的原型链。这个过程不会在jason对象中设置从原型链继承的属性值。当读取属性的值时,JavaScript 首先检查该对象中是否存在这个值。如果值存在,这个值就被返回。如果值不存在,JavaScript 使用__proto__属性检查原型链。说到这里,当你做以下操作时会发生什么:

Employee.prototype.name = "Undefined";

它不会传播到Employee的所有实例中。这是因为当你创建一个Employee对象的实例时,这个实例获得了名字的局部值。当你通过创建一个新的Employee对象来设置TeamLead原型时,TeamLead.prototype拥有name属性的局部值。因此,当 JavaScript 查找jason对象(TeamLead的一个实例)的name属性时,它找到了TeamLead.prototype中的这个属性的局部值。它不会尝试进一步查找链中的Employee.prototype

如果你想在运行时改变属性的值,并且希望新值被对象的的所有后代继承,你不能在对象的构造函数中定义属性。要实现这一点,你需要将其添加到构造函数的原型中。例如,让我们稍稍修改一下先前的例子:

function Employee() {
  this.dept = 'None';
  this.salary = 0.00;
}
Employee.prototype.name = '';
function Manager() {
  this.reports = [];
}
Manager.prototype = new Employee();
var sandy = new Manager();
var karen = new Manager();

Employee.prototype.name = "Junk";

console.log(sandy.name);
console.log(karen.name);
String object to add a reverse() method to reverse a string. This method does not exist in the native String object but by manipulating String's prototype, we add this method to String:
String.prototype.reverse = function() {
  return Array.prototype.reverse.apply(this.split('')).join('');
};
var str = 'JavaScript';
console.log(str.reverse()); //"tpircSavaJ"

虽然这是一个非常强大的技术,但使用时应该小心,不要过度使用。参阅perfectionkills.com/extending-native-builtins/以了解扩展原生内置对象的陷阱以及如果你打算这样做应该注意什么。

访问器和方法

访问器方法是获取特定属性值方便的方法;正如其名,设置器方法是设置属性值的方法。通常,你可能希望基于其他值派生一个值。传统上,访问器和方法通常是像下面的函数:

var person = {
  firstname: "Albert",
  lastname: "Einstein",
  setLastName: function(_lastname){
    this.lastname= _lastname;
  },
  setFirstName: function (_firstname){
    this.firstname= _firstname;
  },
  getFullName: function (){
    return this.firstname + ' '+ this.lastname;
  }  
};
person.setLastName('Newton');
person.setFirstName('Issac');
console.log(person.getFullName());

如你所见,setLastName()setFirstName()getFullName()是用于属性获取设置的函数。Fullname是通过连接firstnamelastname属性派生出的属性。这是一个非常常见的用例,ECMAScript 5 现在为您提供了访问器和方法的默认语法。

以下示例展示了如何在 ECMAScript 5 中使用对象字面量语法创建访问器和方法:

var person = {
  firstname: "Albert",
  lastname: "Einstein",
  get fullname() {
    return this.firstname +" "+this.lastname;
  },
  set fullname(_name){
    var words = _name.toString().split(' ');
    this.firstname = words[0];
    this.lastname = words[1];
  }
};
person.fullname = "Issac Newton";
console.log(person.firstname); //"Issac"
console.log(person.lastname);  //"Newton"
console.log(person.fullname);  //"Issac Newton"

声明访问器和方法的另一种方式是使用Object.defineProperty()方法:

var person = {
  firstname: "Albert",
  lastname: "Einstein",
};
Object.defineProperty(person, 'fullname', {
  get: function() {
    return this.firstname + ' ' + this.lastname;
  },
  set: function(name) {
    var words = name.split(' ');
    this.firstname = words[0];
    this.lastname = words[1];
  }
});
person.fullname = "Issac Newton";
console.log(person.firstname); //"Issac"
console.log(person.lastname);  //"Newton"
console.log(person.fullname);  //"Issac Newton"

在这个方法中,即使对象已经被创建,你也可以调用Object.defineProperty()

既然你已经尝到了 JavaScript 对象导向的味道,接下来我们将介绍由Underscore.js提供的一组非常有用的工具方法。我们在上一章讨论了 Underscore.js 的安装和基本使用。这些方法将使对对象的基本操作变得非常容易:

  • keys():这个方法检索对象自身可枚举属性的名称。请注意,这个函数不会遍历原型链:

    var _ = require('underscore');
    var testobj = {
      name: 'Albert',
      age : 90,
      profession: 'Physicist'
    };
    console.log(_.keys(testobj));
    //[ 'name', 'age', 'profession' ]
    
  • allKeys(): 这个方法会检索对象自身和继承的属性的名称:

    var _ = require('underscore');
    function Scientist() {
      this.name = 'Albert';
    }
    Scientist.prototype.married = true;
    aScientist = new Scientist();
    console.log(_.keys(aScientist)); //[ 'name' ]
    console.log(_.allKeys(aScientist));//[ 'name', 'married' ]
    
    
  • values():这个方法检索对象自身属性的值:

    var _ = require('underscore');
    function Scientist() {
      this.name = 'Albert';
    }
    Scientist.prototype.married = true;
    aScientist = new Scientist();
    console.log(_.values(aScientist)); //[ 'Albert' ]
    
  • mapObject(): 这个方法会将对象中每个属性的值进行转换:

    var _ = require('underscore');
    function Scientist() {
      this.name = 'Albert';
      this.age = 90;
    }
    aScientist = new Scientist();
    var lst = _.mapObject(aScientist, function(val,key){
      if(key==="age"){
        return val + 10;
      } else {
        return val;
      }
    });
    console.log(lst); //{ name: 'Albert', age: 100 }
    
  • functions():这会返回一个排序好的列表,包含对象中每个方法的名称——对象每个函数属性的名称。

  • pick():这个函数返回一个对象的副本,过滤出提供的键的值:

    var _ = require('underscore');
    var testobj = {
      name: 'Albert',
      age : 90,
      profession: 'Physicist'
    };
    console.log(_.pick(testobj, 'name','age')); //{ name: 'Albert', age: 90 }
    console.log(_.pick(testobj, function(val,key,object){
      return _.isNumber(val);
    })); //{ age: 90 }
    
  • omit(): 这个函数是pick()的逆操作——它返回一个对象的副本,过滤掉指定键的值。

总结

允许 JavaScript 应用程序通过使用对象导向带来的更大控制和结构,从而提高代码的清晰度和质量。JavaScript 的对象导向基于函数原型和原型继承。这两个概念可以为开发者提供大量的财富。

在本章中,我们看到了基本的对象创建和操作。我们探讨了构造函数如何用于创建对象。我们深入研究了原型链以及如何在原型链上操作继承。这些基础将用于构建我们在下一章中探索的 JavaScript 模式的知识。

第五章:JavaScript 模式

到目前为止,我们已经查看了几个编写 JavaScript 代码所必需的基本构建块。一旦你开始使用这些基本构建块来构建更大的系统,你很快就会意识到有些事情可能有一种标准的方法。在开发大型系统时,你会遇到重复的问题;模式旨在为这些已知和识别的问题提供标准化的解决方案。模式可以被视为最佳实践、有用的抽象或模板来解决常见问题。编写可维护的代码是困难的。编写模块化、正确和可维护的代码的关键是理解重复的主题并使用通用模板来编写这些优化的解决方案。关于设计模式的最重要文本是一本于 1995 年出版的书籍,名为《设计模式:可重用面向对象软件的元素》,作者是埃里希·伽玛(Erich Gamma)、理查德·赫尔姆(Richard Helm)、拉尔夫·约翰逊(Ralph Johnson)和约翰·维利斯 ides(John Vlissides)——一个被称为四人帮(简称 GOF)的团队。这本开创性的作品给出了各种模式的正式定义,并解释了今天我们使用的大多数流行模式的实现细节。理解模式的重要性是非常重要的:

  • 模式提供了解决常见问题的经过验证的解决方案:模式提供了优化解决特定问题的模板。这些模式得到了坚实的工程经验支持,并经过验证。

  • 模式旨在被重用:它们足够通用,可以适应问题的变体。

  • 模式定义了词汇:模式是定义良好的结构,因此为解决方案提供了一个通用的词汇。这在跨大型团队沟通时非常有表现力。

设计模式

在本章中,我们将探讨一些适用于 JavaScript 的设计模式。然而,编码模式对于 JavaScript 来说非常具体,对我们来说也非常重要。虽然我们花费了大量时间和精力来理解和掌握设计模式,但理解反模式以及如何避免陷阱也同样重要。在通常的软件开发周期中,有几种地方可能会引入糟糕的代码,主要是在代码接近发布的时候,或者当代码交给另一个团队进行维护时。如果将这些糟糕的设计结构记录为反模式,它们可以指导开发者知道该避免哪些陷阱,以及如何不采用糟糕的设计模式。大多数语言都有它们自己的反模式。根据它们解决的问题类型,设计模式被 GOF 归类为几个大类:

  • 创建型设计模式:这些模式处理各种对象创建机制。尽管大多数语言提供了基本对象创建方法,但这些模式关注对象创建的优化或更受控的机制。

  • 结构设计模式:这些模式都是关于对象及其之间关系的组合。想法是在系统中的某处发生变化时,对整体对象关系的影响最小。

  • 行为设计模式:这些模式专注于对象之间的相互依赖和通信。

下面的表格是一个有用的工具,用于识别模式的类别:

  • 创建型模式:

    • 工厂方法

    • 抽象工厂

    • 建造者

    • 原型

    • 单例

  • 结构模式:

    • 适配器

    • 桥接

    • 组合

    • 装饰器

    • 外观

    • 享元

    • 代理

  • 行为模式

    • 解释器

    • 模板方法

    • 责任链

    • 命令

    • 迭代器

    • 中介者

    • 备忘录

    • 观察者

    • 状态

    • 策略

    • 访问者

本章中我们将讨论的一些模式可能不包括在此列表中,因为它们更特定于 JavaScript 或这些经典模式的一种变体。同样,我们也不会讨论不适合 JavaScript 或不常用的模式。

命名空间模式

在 JavaScript 中过度使用全局作用域几乎是一种禁忌。当你构建更大的程序时,有时很难控制全局作用域被污染的程度。命名空间可以减少程序创建的全局变量数量,并帮助避免命名冲突或过度的前缀命名。使用命名空间的想法是创建一个全局对象,为您的应用程序或库添加所有这些对象和函数,而不是用对象污染全局作用域。JavaScript 没有显式的语法来定义命名空间,但命名空间可以很容易地创建。考虑以下示例:

function Car() {}
function BMW() {}
var engines = 1;
var features = {
  seats: 6,
  airbags:6
};

我们正在全局作用域中创建所有这些内容。这是一个反模式,这从来不是一个好主意。然而,我们可以重构这个代码,创建一个全局对象,并让所有的函数和对象成为这个全局对象的一部分,如下所示:

// Single global object
var CARFACTORY = CARFACTORY || {};
CARFACTORY.Car = function () {};
CARFACTORY.BMW = function () {};
CARFACTORY.engines = 1;
CARFACTORY.features = {
  seats: 6,
  airbags:6
};

按惯例,全局命名空间对象名称通常全部用大写书写。这种模式为应用程序添加了命名空间,防止了您的代码以及您的代码与使用的第三方库之间的命名冲突。许多项目在其公司或项目名后使用独特名称来为他们的命名空间创建唯一名称。

尽管这似乎是一种理想的方式来限制你的全局变量并为你的代码添加一个命名空间,但它有点冗长;你需要为每个变量和函数加上命名空间前缀。你需要输入更多内容,代码变得不必要地冗长。此外,单一的全局实例意味着代码的任何部分都可以修改全局实例,其余的功能得到更新状态—这可能会导致非常糟糕的副作用。在之前的例子中,一个有趣的现象是这一行—var CARFACTORY = CARFACTORY || {};. 当你在一个大型代码库上工作时,你不能假设你正在为这个命名空间(或者给它分配一个属性)创建第一次。有可能命名空间已经存在。为了确保只有当命名空间尚未创建时才创建命名空间,始终依赖通过短路||操作符的快速默认是安全的。

模块模式

随着你构建大型应用程序,你很快会意识到保持代码库的组织和模块化变得越来越困难。模块模式有助于保持代码清晰地分离和组织。

模块将更大的程序分成更小的部分,并赋予它们一个命名空间。这非常重要,因为一旦你将代码分成模块,这些模块可以在多个地方重复使用。仔细设计模块的接口将使您的代码非常易于重用和扩展。

JavaScript 提供了灵活的函数和对象,这使得创建健壮的模块系统变得容易。函数作用域有助于创建模块内部的命名空间,而对象可用于存储一系列导出的值。

在我们开始探索模式本身之前,让我们快速回顾一下我们之前讨论的一些概念。

我们详细讨论了对象字面量。对象字面量允许你按照如下方式创建名称-值对:

var basicServerConfig = {
  environment: "production",
  startupParams: {
    cacheTimeout: 30,
    locale: "en_US"
  },
  init: function () {
    console.log( "Initializing the server" );
  },
  updateStartup: function( params ) {
      this.startupParams = params;
      console.log( this.startupParams.cacheTimeout );
      console.log( this.startupParams.locale );
  }
};
basicServerConfig.init(); //"Initializing the server"
basicServerConfig.updateStartup({cacheTimeout:60, locale:"en_UK"}); //60, en_UK

在这个例子中,我们创建了一个对象字面量,并定义了键值对来创建属性和函数。

在 JavaScript 中,模块模式被广泛使用。模块有助于模仿类的概念。模块允许我们包含一个对象的公共/私有方法和变量,但最重要的是,模块将这些部分限制在全局作用域之外。由于变量和函数被包含在模块作用域内,我们自动防止了与其他使用相同名称的脚本发生命名冲突。

模块模式的另一个美丽方面是,我们只暴露公共 API。与内部实现相关的所有其他内容都在模块的闭包内保持私有。

与其他面向对象的编程语言不同,JavaScript 没有显式的访问修饰符,因此,没有隐私的概念。你不能有公共变量或私有变量。如我们之前讨论的,在 JavaScript 中,函数作用域可以用来强制这个概念。模块模式使用闭包来限制变量和函数的访问仅限于模块内部;然而,变量和函数是在被返回的对象中定义的,这对外部是可用的。

让我们考虑之前的例子,将其转换为模块。我们实际上是在使用一个立即执行的函数表达式(IIFE),并返回模块的接口,即initupdateStartup函数:

var basicServerConfig = (function () {
  var environment= "production";
  startupParams= {
    cacheTimeout: 30,
    locale: "en_US"
  };
  return {
    init: function () {
      console.log( "Initializing the server" );
    },
    updateStartup: function( params ) {
      this.startupParams = params;
      console.log( this.startupParams.cacheTimeout );
      console.log( this.startupParams.locale );
    }
  };
})();
basicServerConfig.init(); //"Initializing the server"
basicServerConfig.updateStartup({cacheTimeout:60, locale:"en_UK"}); //60, en_UK

在这个例子中,basicServerConfig作为全局上下文中的一个模块创建。为了确保我们不会污染全局上下文,创建模块时命名空间很重要。此外,由于模块本质上是可以重用的,确保我们使用命名空间避免命名冲突也很重要。对于basicServerConfig模块,以下代码片段展示了创建命名空间的方法:

// Single global object
var SERVER = SERVER||{};
SERVER.basicServerConfig = (function () {
  Var environment= "production";
  startupParams= {
    cacheTimeout: 30,
    locale: "en_US"
  };
  return {
    init: function () {
      console.log( "Initializing the server" );
    },
    updateStartup: function( params ) {
      this.startupParams = params;
      console.log( this.startupParams.cacheTimeout );
      console.log( this.startupParams.locale );
    }
  };
})();
SERVER.basicServerConfig.init(); //"Initializing the server"
SERVER.basicServerConfig.updateStartup({cacheTimeout:60, locale:"en_UK"}); //60, en_UK

使用命名空间与模块通常是好主意;然而,并不是说模块必须与命名空间相关联。

模块模式的一种变体试图克服原始模块模式的一些问题。这种改进的模块模式也被称为揭示模块模式(RMP)。RMP 最初由Christian Heilmann普及。他不喜欢在从另一个函数调用公共函数或访问公共变量时必须使用模块名。另一个小问题是,你必须在返回公共接口时使用对象字面量表示法。考虑以下示例:

var modulePattern = function(){
  var privateOne = 1;
  function privateFn(){
    console.log('privateFn called');
  }
  return {
    publicTwo: 2,
    publicFn:function(){
      modulePattern.publicFnTwo();   
    },
    publicFnTwo:function(){
      privateFn();
    }
  }
}();
modulePattern.publicFn(); "privateFn called"

你可以看到,在publicFn()中我们需要通过modulePattern调用publicFnTwo()。此外,公共接口是以对象字面量返回的。改进经典的模块模式的就是所谓的 RMP。这个模式背后的主要思想是在私有作用域中定义所有成员,并返回一个匿名对象,该对象指向需要作为公共接口公开的私有功能。

让我们看看如何将我们之前的示例转换为 RMP。这个示例深受 Christian 博客的启发:

var revealingExample = function(){
  var privateOne = 1;
  function privateFn(){
    console.log('privateFn called');
  }
  var publicTwo = 2;
  function publicFn(){
    publicFnTwo();    
  }
  function publicFnTwo(){
    privateFn();
  }
  function getCurrentState(){
    return 2;
  }
  // reveal private variables by assigning public pointers
  return {
    setup:publicFn,
    count:publicTwo,
    increaseCount:publicFnTwo,
    current:getCurrentState()
  };
}();
console.log(revealingExample.current); // 2
revealingExample.setup(); //privateFn called

在这里的一个重要区别是,你在私有作用域中定义函数和变量,并返回一个匿名对象,该对象指向你想作为公共接口公开的私有变量和函数。这是一个更干净的变体,应优先于经典模块模式。

然而,在生产代码中,你希望使用一种更标准的模块创建方法。目前,创建模块主要有两种方法。第一种被称为CommonJS 模块。CommonJS 模块通常更适合服务器端 JavaScript 环境,如Node.js。一个 CommonJS 模块包含一个require()函数,该函数接收模块的名称并返回模块的接口。该格式是由 CommonJS 的志愿者小组提出的;他们的目标是设计、原型化和标准化 JavaScript API。CommonJS 模块由两部分组成。首先,模块需要暴露的变量和函数列表;当你将一个变量或函数赋值给module.exports变量时,它就从模块中暴露出来。其次,一个require函数,模块可以使用它来导入其他模块的导出:

//Add a dependency module 
var crypto = require('crypto');
function randomString(length, chars) {
  var randomBytes = crypto.randomBytes(length);
  ...
  ...
}
//Export this module to be available for other modules
module.exports=randomString;

CommonJS 模块在服务器端的 Node.js 和浏览器端的curl.js中得到支持。

JavaScript 模块的另一种形式被称为异步模块定义AMD)。它们是以浏览器为首要目标的模块,并选择异步行为。AMD 使用一个define函数来定义模块。这个函数接受一个模块名称数组和一个函数。一旦模块被加载,define函数就带着它们的接口作为参数执行这个函数。AMD 提案旨在异步加载模块及其依赖项。define函数用于根据以下签名定义命名或未命名模块:

define(
  module_id /*optional*/,
  [dependencies] /*optional*/,
  definition function /*function for instantiating the module or object*/
);

你可以如下添加一个无依赖的模块:

define(
{ 
  add: function(x, y){ 
    return x + y; 
  } 
});

require模块的使用如下:

require(["math","draw"], function ( math,draw ) {
  draw.2DRender(math.pi);
});

RequireJS(requirejs.org/docs/whyamd.html)是实现 AMD 的模块加载器之一。

ES6 模块

两种不同的模块系统和不同的模块加载器可能会让人感到有些害怕。ES6 试图解决这个问题。ES6 有一个拟定的模块规范,试图保留 CommonJS 和 AMD 模块模式的优点。ES6 模块的语法类似于 CommonJS,并且 ES6 模块支持异步加载和可配置的模块加载:

//json_processor.js
function processJSON(url) {
  ...
}
export function getSiteContent(url) {
  return processJSON(url);
}
//main.js
import { getSiteContent } from "json_processor.js";
content=getSiteContent("http://google.com/");

ES6 导出允许你以类似于 CommonJS 的方式导出一个函数或变量。在需要使用这个导入的函数的代码中,你使用import关键字来指定你想从哪里导入这个依赖。一旦依赖被导入,它就可以作为程序的一个成员使用。我们将在后面的章节中讨论如何在不支持 ES6 的环境中使用 ES6。

工厂模式

工厂模式是另一种流行的对象创建模式。它不需要使用构造函数。这个模式提供了一个接口来创建对象。基于传递给工厂的类型,该特定类型的对象由工厂创建。这个模式的一个常见实现通常是使用类的构造函数或静态方法。这样的类或方法的目的如下:

  • 它抽象了创建类似对象时的重复操作

  • 它允许消费者不了解对象创建的内部细节就能创建对象

让我们举一个常见的例子来了解工厂的使用。假设我们有以下内容:

  • 构造函数,CarFactory()

  • CarFactory中有一个名为make()的静态方法,它知道如何创建car类型的对象

  • 具体的car类型,如CarFactory.SUVCarFactory.Sedan

我们希望如下使用CarFactory

var golf = CarFactory.make('Compact');
var vento = CarFactory.make('Sedan');
var touareg = CarFactory.make('SUV');

以下是实现这样一个工厂的方法。以下实现相当标准。我们通过编程调用构造函数来创建指定类型的对象——CarFactory[const].prototype = new CarFactory();

我们在映射对象类型到构造函数。实现这个模式可能有以下几种变化:

// Factory Constructor
function CarFactory() {}
CarFactory.prototype.info = function() {
  console.log("This car has "+this.doors+" doors and a "+this.engine_capacity+" liter engine");
};
// the static factory method
CarFactory.make = function (type) {
  var constr 0= type;
  var car;
  CarFactory[constr].prototype = new CarFactory();
  // create a new instance
  car = new CarFactory[constr]();
  return car;
};

CarFactory.Compact = function () {
  this.doors = 4;
  this.engine_capacity = 2; 
};
CarFactory.Sedan = function () {
  this.doors = 2;
  this.engine_capacity = 2;
};
CarFactory.SUV = function () {
  this.doors = 4;
  this.engine_capacity = 6;
}; 
  var golf = CarFactory.make('Compact');
  var vento = CarFactory.make('Sedan');
  var touareg = CarFactory.make('SUV');
  golf.info(); //"This car has 4 doors and a 2 liter engine"

我们建议您在 JS Bin 中尝试这个例子,并通过实际编写代码来理解这个概念。

混入模式

混入有助于显著减少我们代码中的功能重复,并有助于功能重用。我们可以将共享功能移动到混入中,减少共享行为的重复。这样,您就可以专注于构建实际功能,而不必重复共享行为。让我们考虑以下示例。我们想要创建一个可以被任何对象实例使用的自定义日志记录器。日志记录器将成为需要在使用/扩展混入的对象之间共享的功能:

var _ = require('underscore');
//Shared functionality encapsulated into a CustomLogger
var logger = (function () {
  var CustomLogger = {
    log: function (message) {
      console.log(message);
    }
  };
  return CustomLogger;
}());

//An object that will need the custom logger to log system specific logs
var Server = (function (Logger) {
  var CustomServer = function () {
    this.init = function () {
      this.log("Initializing Server...");
    };
  };

  // This copies/extends the members of the 'CustomLogger' into 'CustomServer'
  _.extend(CustomServer.prototype, Logger);
  return CustomServer;
}(logger));

(new Server()).init(); //Initializing Server...

在这个例子中,我们使用了来自Underscore.js_.extend——我们在上一章讨论了这个函数。这个函数用于将源(Logger)的所有属性复制到目标(CustomServer.prototype)。正如您在这个例子中观察到的,我们创建了一个共享的CustomLogger对象,旨在被任何需要其功能的对象实例使用。这样一个对象是CustomServer——在其init()方法中,我们调用这个自定义日志记录器的log()方法。这个方法对CustomServer是可用的,因为我们通过 Underscore 的extend()CustomLogger扩展到CustomServer。我们动态地将混入的功能添加到消费者对象中。理解混入和继承之间的区别很重要。当您在多个对象和类层次结构中有共享功能时,您可以使用混入。如果您在单一的类层次结构中有共享功能,您可以使用继承。在原型继承中,当你从原型继承时,对原型的任何更改都会影响继承原型的一切。如果您不想这样,可以使用混入。

装饰器模式

装饰器模式背后的主要思想是,你应以一个具有某些基本功能的普通对象开始你的设计。随着设计的演变,你可以使用现有的装饰器来增强你的普通对象。这是一种在面向对象世界中非常流行的模式,尤其是在 Java 中。让我们以BasicServer为例,这是一个具有非常基本功能的服务器。这些基本功能可以通过装饰来服务于特定目的。我们可以有两个不同的情况,这个服务器可以同时服务于 PHP 和 Node.js,并在不同的端口上提供服务。这些不同的功能是通过装饰基本服务器实现的:

var phpServer = new BasicServer();
phpServer = phpServer.decorate('reverseProxy');
phpServer = phpServer.decorate('servePHP');
phpServer = phpServer.decorate('80');
phpServer = phpServer.decorate('serveStaticAssets');
phpServer.init();

节点服务器将具有以下内容:

var nodeServer = new BasicServer();
nodeServer = nodeServer.decorate('serveNode');
nodeServer = nodeServer.decorate('3000');
nodeServer.init();

在 JavaScript 中实现装饰器模式有几种方法。我们将讨论一种方法,其中模式通过列表实现,不依赖于继承或方法调用链:

//Implement BasicServer that does the bare minimum
function BasicServer() {
  this.pid = 1;
  console.log("Initializing basic Server");
  this.decorators_list = []; //Empty list of decorators
}
//List of all decorators
BasicServer.decorators = {};

//Add each decorator to the list of BasicServer's decorators
//Each decorator in this list will be applied on the BasicServer instance
BasicServer.decorators.reverseProxy = {
  init: function(pid) {
    console.log("Started Reverse Proxy");
    return pid + 1;
  }
};
BasicServer.decorators.servePHP = {
  init: function(pid) {
    console.log("Started serving PHP");
    return pid + 1;
  }
};
BasicServer.decorators.serveNode = {
  init: function(pid) {
    console.log("Started serving Node");
    return pid + 1;
  }
};

//Push the decorator to this list everytime decorate() is called
BasicServer.prototype.decorate = function(decorator) {
  this.decorators_list.push(decorator);
};
//init() method looks through all the applied decorators on BasicServer
//and executes init() method on all of them
BasicServer.prototype.init = function () {
  var running_processes = 0;
  var pid = this.pid;
  for (i = 0; i < this.decorators_list.length; i += 1) {
    decorator_name = this.decorators_list[i];
    running_processes = BasicServer.decorators[decorator_name].init(pid);
  }
  return running_processes;
};

//Create server to serve PHP
var phpServer = new BasicServer();
phpServer.decorate('reverseProxy');
phpServer.decorate('servePHP');
total_processes = phpServer.init();
console.log(total_processes);

//Create server to serve Node
var nodeServer = new BasicServer();
nodeServer.decorate('serveNode');
nodeServer.init();
total_processes = phpServer.init();
console.log(total_processes);

BasicServer.decorate()BasicServer.init()是两个真正发生事情的方法。我们将所有要应用到BasicServer上的装饰器推送到BasicServer的装饰器列表中。在init()方法中,我们从这些装饰器列表中执行或应用每个装饰器的init()方法。这是一种不使用继承的更清洁的装饰器模式方法。这种方法在 Stoyan Stefanov 的书中《JavaScript 模式,O'Reilly 媒体》中有描述,因其简单性而在 JavaScript 开发者中得到了重视。

观察者模式

首先,让我们看看观察者模式的语言无关定义。在 GOF 的书中,《设计模式:可重用面向对象软件的元素》,定义观察者模式如下:

一个或多个观察者对主题的状态感兴趣,并通过附着自身向主题注册他们的兴趣。当主题中发生观察者可能感兴趣的变化时,会发送一个通知消息,调用每个观察者的更新方法。当观察者不再对主题的状态感兴趣时,他们可以简单地将自己分离。

在观察者设计模式中,主题保持一个依赖于它的对象列表(称为观察者),并在状态变化时通知它们。主题使用广播向观察者通知变化。观察者可以在不再希望收到通知时从列表中删除自己。基于这种理解,我们可以定义此模式中的参与者:

  • 主题:它保持观察者的列表,并具有添加、删除和更新观察者的方法

  • 观察者:为需要在主题状态变化时通知的对象提供接口

让我们创建一个可以添加、删除和通知观察者的主题:

var Subject = ( function(  ) {
  function Subject() {
    this.observer_list = [];
  }
  // this method will handle adding observers to the internal list
  Subject.prototype.add_observer = function ( obj ) {
    console.log( 'Added observer' );
    this.observer_list.push( obj );
  };
  Subject.prototype.remove_observer = function ( obj ) {
    for( var i = 0; i < this.observer_list.length; i++ ) {
      if( this.observer_list[ i ] === obj ) {
        this.observer_list.splice( i, 1 );
        console.log( 'Removed Observer' );
      }
    }
  };
  Subject.prototype.notify = function () {
    var args = Array.prototype.slice.call( arguments, 0 );
    for( var i = 0; i<this.observer_list.length; i++ ) {
 this.observer_list[i].update(args);
    }
  };
  return Subject;
})();

这是一个相当直接实现的Subject。关于notify()方法的重要事实是,所有观察者对象update()方法的调用方式,以广播方式更新。

现在让我们定义一个创建随机推文的简单对象。这个对象提供了一个接口,通过 addObserver()removeObserver() 方法向 Subject 添加和删除观察者。它还调用 Subjectnotify() 方法,并传递新获取的推文。当这种情况发生时,所有观察者都会传播新推文已更新,新推文作为参数传递:

function Tweeter() {
  var subject = new Subject();
  this.addObserver = function ( observer ) {
    subject.add_observer( observer );
  };
  this.removeObserver = function (observer) {
    subject.remove_observer(observer);
  };
  this.fetchTweets = function fetchTweets() {
    // tweet
    var tweet = {
      tweet: "This is one nice observer"
    };
    // notify our observers of the stock change
    subject.notify( tweet );
  };
}

现在让我们添加两个观察者:

var TweetUpdater = {
  update : function() {
    console.log( 'Updated Tweet -  ', arguments );
  }
};
var TweetFollower = {
  update : function() {
    console.log( '"Following this tweet -  ', arguments );
  }
};

这两个观察者都只有一个 update() 方法,该方法将由 Subject.notify() 方法调用。现在我们实际上可以通过推特的界面将这些观察者添加到 Subject 中:

var tweetApp = new Tweeter();
tweetApp.addObserver( TweetUpdater );
tweetApp.addObserver( TweetFollower );
tweetApp.fetchTweets();
tweetApp.removeObserver(TweetUpdater);
tweetApp.removeObserver(TweetFollower);

这将导致以下输出:

Added observer
Added observer
Updated Tweet -   { '0': [ { tweet: 'This is one nice observer' } ] }
"Following this tweet -   { '0': [ { tweet: 'This is one nice observer' } ] }
Removed Observer
Removed Observer

这是一个基本的实现,用于说明观察者模式的思想。

JavaScript 模型-视图*模式

模型-视图-控制器MVC)、模型-视图-呈现器MVP)和 模型-视图-视图模型MVVM)在服务器应用程序中一直很受欢迎,但在最近几年,JavaScript 应用程序也开始使用这些模式来结构和管理工作量大的项目。许多 JavaScript 框架已经出现,支持 MV* 模式。我们将讨论使用 Backbone.js 的几个示例。

模型-视图-控制器

模型-视图-控制器(MVC)是一种流行的结构模式,其核心思想是将应用程序分为三个部分,以将信息的内部表示与表示层分离。MVC 包含组件。模型是应用程序对象,视图是底层模型对象的表示,控制器处理用户界面根据用户交互的行为。

模型

模型是代表应用程序中数据的构造。它们与用户界面或路由逻辑无关。模型更改通常通过遵循观察者设计模式来通知视图层。模型也可能包含用于验证、创建或删除数据的代码。当数据更改时自动通知视图层做出反应的能力使得像 Backbone.js、Amber.js 等框架在构建 MV* 应用程序时非常有用。以下示例向您展示了一个典型的 Backbone 模型:

var EmployeeModel = Backbone.Model.extend({
  url: '/employee/1',
  defaults: {
    id: 1,
    name: 'John Doe',
    occupation: null
  }
  initialize: function() {
 }
}); var JohnDoe = new EmployeeModel();

这个模型结构可能在不同框架之间有所不同,但它们通常有一些共同点。在大多数现实世界中应用程序中,您希望您的模型被持久化到内存存储或数据库中。

视图

视图是您模型的视觉表示。通常,模型的状态在呈现给视图层之前进行处理、筛选或按摩。在 JavaScript 中,视图负责渲染和操作 DOM 元素。视图观察模型,并在模型发生变化时收到通知。当用户与视图交互时,通过视图层(通常通过控制器)更改模型的某些属性。在诸如 Backbone 的 JavaScript 框架中,视图是使用模板引擎(如Handlebar.js(handlebarsjs.com/)或mustache.js(mustache.github.io/))创建的。这些模板本身并不是视图。它们观察模型,并根据这些变化保持视图状态更新。让我们来看一个用 Handlebar 定义的视图示例:

<li class="employee_photo">
  <h2>{{title}}</h2>
  <img class="emp_headshot_small" src="img/{{src}}"/>
  <div class="employee_details">
    {{employee_details}}
  </div>
</li>

像前一个示例这样的视图包含包含模板变量的标记。这些变量通过自定义语法进行分隔。例如,在 Handlebar.js 中,模板变量使用{{ }}进行分隔。框架通常以 JSON 格式传输数据。视图如何从模型中填充由框架透明处理。

控制器

控制器作为模型和视图之间的层,负责当用户改变视图属性时更新模型。大多数 JavaScript 框架与经典定义的控制器有所偏离。例如,Backbone 没有一个叫做控制器的概念;他们有一个叫做路由器的东西,负责处理路由逻辑。你可以把视图和路由器的组合看作是一个控制器,因为很多同步模型和视图的逻辑都在视图本身内完成。一个典型的 Backbone 路由器如下所示:

var EmployeeRouter = Backbone.Router.extend({
  routes: { "employee/:id": "route" },
  route: function( id ) {
    ...view render logic...
  }
});

模型-视图-呈现器模式

模型-视图-呈现器是我们之前讨论的原始 MVC 模式的一种变体。MVC 和 MVP 都旨在分离关注点,但在很多基本方面它们是不同的。MVP 中的呈现器具有视图所需的必要逻辑。视图的任何调用都会委派给呈现器。呈现器还观察模型,并在模型更新时更新视图。许多作者认为,因为呈现器将模型与视图绑定在一起,所以它也执行了传统控制器的角色。有各种 MVP 的实现方式,而且没有框架提供开箱即用的经典 MVP。在 MVP 的实现中,以下是一些将 MVP 与 MVC 分开的主要区别:

  • 视图没有参考模型

  • 呈现器有一个模型参考,并在模型变化时负责更新视图

MVP 通常有两种实现方式:

  • 被动视图:视图尽可能天真,所有的业务逻辑都在呈现器中。例如,一个简单的 Handlebars 模板可以被视为一个被动视图。

  • 监控控制器:视图中大多包含声明性逻辑。当视图中的简单声明性逻辑不足时,由呈现器接管。

下面的图表描述了 MVP 架构:

模型-视图-呈现器模式

模型-视图-视图模型

MVVM 最初是由微软为与Windows Presentation Foundation (WPF) 和 Silverlight 使用而提出的。MVVM 是 MVC 和 MVP 的一个变种,并进一步试图将用户界面(视图)与业务模型和应用程序行为分离。MVVM 在 MVC 和 MVP 中讨论的领域模型之上创建了一个新的模型层。这个模型层将属性作为视图的接口。假设我们 UI 上有复选框。复选框的状态被捕捉到一个IsChecked属性中。在 MVP 中,视图会有这个属性,呈现器会设置它。然而,在 MVVM 中,呈现器会有IsChecked属性,视图负责与它同步。既然呈现器实际上并没有做传统呈现器的工作,它被重新命名为视图模型:

模型-视图-视图模型

这些方法的实现细节取决于我们试图解决的问题和所使用的框架。

摘要

在构建大型应用程序时,我们会看到某些问题模式一次又一次地重复。这些问题模式有定义良好的解决方案,可以复用以构建健壮的解决方案。在本章中,我们讨论了一些关于这些模式的重要模式和思想。大多数现代 JavaScript 应用程序使用这些模式。在一个大型系统中不实现模块、装饰器、工厂或 MV*模式的情况很少见。这些是我们本章讨论的基础思想。下一章我们将讨论各种测试和调试技术。

第六章:测试与调试

随着你编写 JavaScript 应用程序,你很快就会意识到拥有一个健全的测试策略是不可或缺的。事实上,编写足够的测试用例几乎总是一个坏主意。确保以下几点非常重要:

  • 现有的代码按照规范运行。

  • 任何新代码都不会破坏规格定义的行为。

这两个点都非常重要。许多工程师认为只有第一个点是覆盖代码足够测试的唯一原因。测试覆盖的最明显优势是确保推送到生产系统的代码基本上是错误免费的。编写测试用例以智能地覆盖代码的最大功能区域通常会给你关于代码整体质量的一个很好的指示。在这个问题上不应该有任何争论或妥协。不幸的是,许多生产系统仍然缺乏足够的代码覆盖。建立一个工程师文化,让开发者在编写代码时思考编写测试用例,这一点非常重要。

第二个点甚至更重要。遗留系统通常非常难以管理。当你在别人写的代码或大型分布式团队写的代码上工作时,很容易引入错误和破坏事物。即使是最优秀的工程师也会犯错误。当你在一个你不太熟悉的大的代码库上工作时,如果没有健全的测试覆盖来帮助你,你会引入错误。由于没有测试用例来确认你的更改,你对所做的更改没有信心,你的代码发布将会是颤抖的、缓慢的,显然充满了隐藏的错误。

你将避免重构或优化你的代码,因为你其实不确定代码库的哪些更改可能会潜在地破坏某些功能(再次,因为没有测试用例来确认你的更改)——所有这些都是一个恶性的循环。这就像一个土木工程师说,“虽然我已经建造了这座桥,但我对自己建造的质量没有信心。它可能会立即倒塌或永远不会倒塌。”尽管这听起来像是一种夸张,但我见过很多高影响的生产代码在没有测试覆盖的情况下被推送到生产环境中。这是危险的,应该避免。当你编写足够的测试用例来覆盖大部分功能性代码,并对这些代码进行更改时,你会立即意识到是否有新更改的问题。如果你的更改导致测试用例失败,你就会意识到问题。如果你的重构破坏了测试场景,你就会意识到问题——所有这些都发生在代码推送到生产环境之前。

近年来,像测试驱动开发和自测试代码这样的想法越来越流行,尤其是在敏捷方法论中。这些从根本上来说是正确的想法,将帮助你编写健壮的代码——你自信的代码。我们将在本章讨论所有这些想法。你将了解如何在现代 JavaScript 中编写好的测试用例。我们还将查看几种调试代码的工具和方法。JavaScript 传统上在测试和调试方面一直有点困难,主要是因为缺乏工具,但现代工具使这两者变得容易和自然。

单元测试

当我们谈论测试用例时,我们大部分时候是指单元测试。假设我们要测试的单元始终是一个函数是不正确的。单元(或工作单元)是一个构成单一行为的逻辑单位。这个单元应该能够通过公共接口调用,并且应该能够独立测试。

因此,单元测试执行以下功能:

  • 它测试一个单一的逻辑函数

  • 它可以不按照特定的执行顺序运行

  • 它处理自己的依赖项和模拟数据

  • 它总是对相同的输入返回相同的结果

  • 它应该是自解释的,可维护的,可读的

注意

马丁·福勒提倡使用测试金字塔(martinfowler.com/bliki/TestPyramid.html)策略,以确保我们有大量的单元测试,从而确保最大的代码覆盖率。测试金字塔指出,你应该编写比高级集成和 UI 测试更多的底层单元测试。

有两种重要的测试策略我们将在此章节讨论。

测试驱动开发

测试驱动 开发TDD)在过去几年中得到了很多重视。这个概念最初是在极限编程方法论中提出的。这个想法是有一个短暂重复的开发周期,重点是先编写测试用例。这个周期如下所示:

  1. 根据特定代码单元的规格添加测试用例。

  2. 运行现有的测试用例套,看看你写的新的测试用例是否会失败——它应该会(因为没有为此单元编写代码)。这一步确保当前的测试框架运行良好。

  3. 编写主要用来确认测试用例的代码。这段代码没有优化或重构,甚至可能不完全正确。然而,此刻这是可以接受的。

  4. 重新运行测试,看看所有测试用例是否通过。在这个步骤之后,你会自信新代码没有破坏任何东西。

  5. 重构代码,确保你在优化单元并处理所有边缘情况。

这些步骤会为新添加的所有代码重复执行。这是一种非常优雅的策略,非常适合敏捷方法论。TDD 只有在可测试的代码单元小且只符合测试用例时才会成功。编写小型的、模块化的、精确的代码单元非常重要,这些单元的输入和输出符合测试用例。

行为驱动开发

在尝试遵循 TDD 时一个非常常见的问题就是词汇和正确性的定义。BDD 试图在遵循 TDD 时引入一个普遍的语言。这种语言确保业务和工程团队都在谈论同一件事。

我们将使用Jasmine作为主要的 BDD 框架,并探索各种测试策略。

注意

您可以从github.com/jasmine/jasmine/releases/download/v2.3.4/jasmine-standalone-2.3.4.zip下载独立包来安装 Jasmine。

解压此包后,您将拥有以下目录结构:

行为驱动开发

lib目录包含了你在项目中开始编写 Jasmine 测试用例所需的 JavaScript 文件。如果你打开SpecRunner.html,你会发现以下 JavaScript 文件包含在其中:

<script src="img/jasmine.js"></script>
<script src="img/jasmine-html.js"></script>
<script src="img/boot.js"></script>    

<!-- include source files here... -->   
<script src="img/Player.js"></script>   
<script src="img/Song.js"></script>    
<!-- include spec files here... -->   
<script src="img/SpecHelper.js"></script>   
<script src="img/PlayerSpec.js"></script>

前三项是 Jasmine 自己的框架文件。下一部分包括我们要测试的源文件和实际的测试规格。

让我们用一个非常普通的例子来实验 Jasmine。创建一个bigfatjavascriptcode.js文件,并将其放在src/目录中。我们将测试以下函数:

function capitalizeName(name){
  return name.toUpperCase();
}

这是一个只做一件事情的简单函数。它接收一个字符串并返回一个首字母大写的字符串。我们将围绕这个函数测试各种场景。这是我们之前讨论过的代码单元。

接下来,创建测试规格。创建一个 JavaScript 文件,test.spec.js,并将其放在spec/目录中。该文件应包含以下内容。您需要向SpecRunner.html中添加以下两行:

<script src="img/bigfatjavascriptcode.js"></script> 
<script src="img/test.spec.js"></script> 

这个包含的顺序不影响。当我们运行SpecRunner.html时,你会看到如下内容:

行为驱动开发

这是显示执行测试次数和失败和成功计数的 Jasmine 报告。现在,让我们让测试用例失败。我们想测试一个将未定义变量传递给函数的用例。再添加一个测试用例如下:

it("can handle undefined", function() {
  var str= undefined;
  expect(capitalizeName(str)).toEqual(undefined);
});

现在,当你运行SpecRunner.html时,你会看到以下结果:

行为驱动开发

正如你所见,这个测试用例的失败以详细的错误堆栈显示出来。现在,我们来解决这个问题。在你原始的 JavaScript 代码中,我们可以这样处理一个未定义的条件:

function capitalizeName(name){
  if(name){
    return name.toUpperCase();
  }
}

有了这个改变,你的测试用例将通过,你将在 Jasmine 报告中看到以下内容:

行为驱动开发

这和测试驱动开发非常相似。你编写测试用例,然后填充必要的代码以符合规格,然后重新运行测试套件。让我们了解 Jasmine 测试的结构。

我们的测试规格如下:

describe("TestStringUtilities", function() {
  it("converts to capital", function() {
    var str = "albert";
    expect(capitalizeName(str)).toEqual("ALBERT");
  });
  it("can handle undefined", function() {
    var str= undefined;
    expect(capitalizeName(str)).toEqual(undefined);
  });
});

describe("TestStringUtilities"是一个测试套件。测试套件的名称应该描述我们正在测试的代码单元——这可以是一个函数或一组相关功能。在规格说明中,你调用全局 Jasmine it函数,并向其传递规格的标题和测试函数,该函数用于测试用例。这个函数是实际的测试用例。你可以使用expect函数捕获一个或多个断言或一般期望。当所有期望都是true时,你的规格说明通过。你可以在describeit函数中编写任何有效的 JavaScript 代码。作为期望值的一部分,我们使用匹配器进行匹配。在我们示例中,toEqual()是匹配两个值相等的匹配器。Jasmine 包含一组丰富的匹配器,以适应大多数常见用例。Jasmine 支持的一些常见匹配器如下:

  • toBe():这个匹配器检查两个比较对象是否相等。这和===比较一样,如下面的代码所示:

    var a = { value: 1};
    var b = { value: 1 };
    
    expect(a).toEqual(b);  // success, same as == comparison
    expect(b).toBe(b);     // failure, same as === comparison
    expect(a).toBe(a);     // success, same as === comparison
    
  • not:你可以用not前缀来否定一个匹配器。例如,expect(1).not.toEqual(2);将否定toEqual()所建立的匹配。

  • toContain():这检查一个元素是否是数组的一部分。这不同于toBe()的精确对象匹配。例如,看看以下代码:

    expect([1, 2, 3]).toContain(3);
    expect("astronomy is a science").toContain("science");
    
  • toBeDefined()toBeUndefined():这两个匹配器很方便,用于检查变量是否未定义(或不是)。

  • toBeNull():这检查变量的值是否为null

  • toBeGreaterThan()toBeLessThan():这些匹配器执行数值比较(它们也可以用于字符串):

    expect(2).toBeGreaterThan(1);
    expect(1).toBeLessThan(2);
    expect("a").toBeLessThan("b");
    

Jasmine 的一个有趣特性是间谍功能。当你编写一个大型系统时,不可能确保所有系统始终可用且正确。同时,你不想因为一个可能已损坏或不可用的依赖而使单元测试失败。为了模拟一个所有依赖项对我们要测试的代码单元都可用的情况,我们模拟这些依赖项以总是给出我们期望的响应。模拟是测试的一个重要方面,大多数测试框架都提供对模拟的支持。Jasmine 通过一个称为间谍的特征允许模拟。Jasmine 间谍本质上是我们可能没有准备好的函数的桩;在编写测试用例时,我们需要跟踪我们正在执行这些依赖项,而不是忽略它们。请考虑以下示例:

describe("mocking configurator", function() {
  var configurator = null;
  var responseJSON = {};

  beforeEach(function() {
    configurator = {
      submitPOSTRequest: function(payload) {
        //This is a mock service that will eventually be replaced 
        //by a real service
        console.log(payload);
        return {"status": "200"};
      }
    };
 spyOn(configurator, 'submitPOSTRequest').and.returnValue({"status": "200"});
    configurator.submitPOSTRequest({
      "port":"8000",
      "client-encoding":"UTF-8"
    });
  });

  it("the spy was called", function() {
    expect(configurator.submitPOSTRequest).toHaveBeenCalled();
  });

  it("the arguments of the spy's call are tracked", function() {
    expect(configurator.submitPOSTRequest).toHaveBeenCalledWith({"port":"8000","client-encoding":"UTF-8"});
  });
});

在这个例子中,当我们编写这个测试用例时,要么我们没有configurator.submitPOSTRequest()依赖的实际实现,要么有人正在修复这个特定的依赖。无论如何,我们目前没有可用。为了让我们的测试工作,我们需要模拟它。Jasmine 间谍允许我们用模拟函数替换一个函数并追踪其执行。

在这种情况下,我们需要确保我们调用了依赖。当实际的依赖准备就绪时,我们将重新审视这个测试用例,以确保它符合规格,但此时,我们只需要确保依赖被调用即可。Jasmine 的toHaveBeenCalled()函数让我们能够追踪函数的执行,该函数可能是一个模拟函数。我们可以使用toHaveBeenCalledWith()来确定 stub 函数是否用正确的参数被调用。使用 Jasmine 间谍,你可以创建几个其他有趣的场景。本章节的范围不允许我们涵盖它们所有,但我鼓励你自己去发现这些领域。

注意

你可以参考 Jasmine 用户手册,了解关于 Jasmine 间谍的更多信息,链接为:jasmine.github.io/2.0/introduction.html

提示

Mocha,Chai 和 Sinon

尽管 Jasmine 是最著名的 JavaScript 测试框架,但在 Node.js 环境中,MochaChai越来越受到重视。Mocha 是用于描述和运行测试用例的测试框架。Chai 是支持 Mocha 的断言库。Sinon.JS在创建测试的模拟和 stub 时非常有用。本书不会讨论这些框架,但如果你想尝试这些框架,对 Jasmine 的了解将会有帮助。

JavaScript 调试

如果你不是一个完全的新程序员,我相信你一定花了一些时间来调试自己的代码或别人的代码。调试几乎像一种艺术形式。每种语言都有不同的调试方法和挑战。JavaScript 传统上是一个难以调试的语言。我曾经为了使用alert()函数调试糟糕的 JavaScript 代码而痛苦不堪。幸运的是,现代浏览器如 Mozilla Firefox 和 Google Chrome 都有出色的开发者工具来帮助调试浏览器中的 JavaScript。还有像IntelliJ WebStorm这样的 IDE,为 JavaScript 和 Node.js 提供了出色的调试支持。在本章中,我们将重点介绍 Google Chrome 内置的开发者工具。Firefox 也支持 Firebug 扩展,并具有出色的内置开发者工具,但它们的行为与 Google Chrome 的开发者工具DevTools)大致相同,因此我们将讨论这两种工具都适用的常见调试方法。

在我们讨论具体的调试技术之前,让我们先了解在尝试调试我们的代码时我们可能感兴趣的错误类型。

语法错误

当你的代码有不符合 JavaScript 语言语法的内容时,解释器会拒绝这部分代码。如果你的 IDE 支持语法检查,这些错误很容易被捕捉到。大多数现代 IDE 都能帮助检测这些错误。之前,我们讨论了像JSLintJSHint这样的工具有助于捕捉代码中的语法问题。它们分析代码并在语法上标出错误。JSHint 的输出可能非常有启发性。例如,以下输出显示了代码中我们可以更改许多内容。以下片段来自我现有项目中的一个:

temp git:(dev_branch) ✗ jshint test.js
test.js: line 1, col 1, Use the function form of "use strict".
test.js: line 4, col 1, 'destructuring expression' is available in ES6 (use esnext option) or Mozilla JS extensions (use moz).
test.js: line 44, col 70, 'arrow function syntax (=>)' is only available in ES6 (use esnext option).
test.js: line 61, col 33, 'arrow function syntax (=>)' is only available in ES6 (use esnext option).
test.js: line 200, col 29, Expected ')' to match '(' from line 200 and instead saw ':'.
test.js: line 200, col 29, 'function closure expressions' is only available in Mozilla JavaScript extensions (use moz option).
test.js: line 200, col 37, Expected '}' to match '{' from line 36 and instead saw ')'.
test.js: line 200, col 39, Expected ')' and instead saw '{'.
test.js: line 200, col 40, Missing semicolon.

使用严格模式

在早前的章节中,我们简要讨论了严格模式。JavaScript 中的严格模式可以标出或消除一些 JavaScript 的隐式错误。严格模式不会默默失败,而是让这些错误抛出异常。严格模式还能帮助将错误转化为实际的错误。强制严格模式有两种方法。如果你想让整个脚本都使用严格模式,你只需在 JavaScript 程序的第一行添加use strict声明。如果你想让某个特定函数遵循严格模式,你可以在函数的第一行添加指令:

function strictFn(){ 
// This line makes EVERYTHING under this strict mode
'use strict'; 
…
function nestedStrictFn() { 
//Everything in this function is also nested
…
} 
}

运行时异常

这些错误出现在执行代码时,尝试引用一个未定义的变量或处理一个 null。当运行时异常发生时,导致异常的那一行之后的任何代码都不会被执行。在代码中正确处理这种异常情况至关重要。虽然异常处理可以帮助防止程序崩溃,但它也助于调试。你可以将可能遇到运行时异常的代码包裹在一个try{ }块中。当这个块中的任何代码引发运行时异常时,相应的处理程序会捕获它。这个处理程序由一个catch(exception){}块定义。让我们通过一个例子来澄清这一点:

try {
  var a = doesnotexist; // throws a runtime exception
} catch(e) { 
  console.log(e.message);  //handle the exception
  //prints - "doesnotexist is not defined"
}

在这个例子中,var a = doesnotexist;行试图将一个未定义的变量doesnotexist赋值给另一个变量a。这会导致运行时异常。当我们把这段有问题的代码包裹在try{} catch(){}块中,当异常发生(或被抛出)时,执行会在try{}块中停止,并直接跳到catch() {}处理程序。catch处理程序负责处理异常情况。在这个例子中,我们在控制台上显示错误消息以供调试。你可以显式地抛出一个异常来触发代码中的一个未处理场景。考虑以下例子:

function engageGear(gear){
  if(gear==="R"){ console.log ("Reversing");}
  if(gear==="D"){ console.log ("Driving");}
  if(gear==="N"){ console.log ("Neutral/Parking");}
 throw new Error("Invalid Gear State");
}
try
{
  engageGear("R");  //Reversing
  engageGear("P");  //Invalid Gear State
}
catch(e){
  console.log(e.message);
}

在这个例子中,我们处理了齿轮换挡的有效状态(RND),但当我们收到一个无效状态时,我们明确地抛出一个异常,清楚地说明原因。当我们调用我们认为是可能抛出异常的函数时,我们将代码包裹在try{}块中,并附上一个catch(){}处理程序。当异常被catch()块捕获时,我们适当地处理异常条件。

控制台打印和断言

在控制台上显示执行状态在调试时非常有用。然而,现代开发者工具允许你在运行时设置断点并暂停执行以检查特定值。你可以在控制台上记录一些变量的状态,快速检测小问题。

有了这些概念,让我们看看如何使用 Chrome 开发者工具来调试 JavaScript 代码。

Chrome DevTools

你可以通过导航到菜单 | 更多工具 | 开发者工具来启动 Chrome DevTools:

Chrome DevTools

Chrome DevTools 在浏览器的下部面板中打开,并有一组非常有用的部分:

Chrome DevTools

元素面板帮助你检查和监视每个组件的 DOM 树和相关样式表。

网络面板有助于了解网络活动。例如,你可以实时监视网络上下载的资源。

对我们来说最重要的面板是源代码面板。这个面板是显示 JavaScript 源代码和调试器的部分。让我们创建一个带有以下内容的示例 HTML:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>This test</title>
  <script type="text/javascript">
  function engageGear(gear){
    if(gear==="R"){ console.log ("Reversing");}
    if(gear==="D"){ console.log ("Driving");}
    if(gear==="N"){ console.log ("Neutral/Parking");}
    throw new Error("Invalid Gear State");
  }
  try
  {
    engageGear("R");  //Reversing
    engageGear("P");  //Invalid Gear State
  }
  catch(e){
    console.log(e.message);
  }
  </script>
</head>
<body>
</body>
</html>

保存这个 HTML 文件并在 Google Chrome 中打开它。在浏览器中打开 DevTools,你会看到以下屏幕:

Chrome DevTools

这是源代码面板的视图。你可以在这个面板中看到 HTML 和嵌入的 JavaScript 源代码。你也可以看到控制台窗口。你可以看到文件被执行并在控制台中显示输出。

在右侧,你会看到调试器窗口:

Chrome DevTools

源代码面板中,点击行号815来添加断点。断点允许你在指定的点停止脚本的执行:

Chrome DevTools

在调试面板中,你可以看到所有现有的断点:

Chrome DevTools

现在,当你再次运行同一页面时,你会看到执行停留在调试点。在调试阶段注入代码是一个非常实用的技术。当调试器正在运行时,你可以添加代码以帮助你更好地理解代码的状态:

Chrome DevTools

这个窗口现在有所有的动作。你可以看到执行停在15行。在调试窗口中,你可以看到哪个断点被触发。你也可以看到调用栈。你有几种方法可以继续执行。调试命令窗口有一组动作:

Chrome DevTools

你可以通过点击Chrome DevTools按钮来继续执行(这将执行到下一个断点),当你这样做时,执行会继续直到遇到下一个断点。在我们的案例中,我们在第8行暂停:

Chrome DevTools

你可以观察到调用栈窗口显示了我们如何到达第8行。作用域面板显示了局部作用域,你可以看到在到达断点时的作用域中的变量。你还可以步入或跳过下一个函数。

使用 Chrome DevTools 还有其他非常实用的机制来调试和分析你的代码。我建议你去尝试这个工具,并使其成为你常规开发流程的一部分。

摘要

测试和调试阶段对于开发健壮的 JavaScript 代码都至关重要。TDD 和 BDD 是与敏捷方法论紧密相关的方法,并被 JavaScript 开发者社区广泛采用。在本章中,我们回顾了围绕 TDD 的最佳实践以及使用 Jasmine 作为测试框架的方法。我们看到了使用 Chrome DevTools 进行各种 JavaScript 调试的方法。在下一章中,我们将探索 ES6、DOM 操作和跨浏览器策略这个新奇的世界。

第七章:ECMAScript 6

到目前为止,我们已经对 JavaScript 编程语言进行了详细的了解。我相信您一定对语言的核心有了深刻的了解。到目前为止,我们所了解的都是按照ECMAScript 5ES5)标准进行的。ECMAScript 6ES6)或ECMAScript 2015ES2015)是 ECMAScript 标准的最新版本。这个标准在不断发展,最后一次修改是在 2015 年 6 月。ES2015 在其范围和推荐方面都具有重要意义,并且 ES2015 的推荐正在大多数 JavaScript 引擎中得到实施。这对我们来说是个好消息。ES6 引入了大量的新特性和帮助器,这些新特性和帮助器极大地丰富了语言。ECMAScript 标准的快速发展使得浏览器和 JavaScript 引擎支持新特性变得有些困难。同时,大多数程序员实际上需要编写可以在旧浏览器上运行的代码。臭名昭著的 Internet Explorer 6 曾经是世界上使用最广泛的浏览器。确保您的代码与尽可能多的浏览器兼容是一项艰巨的任务。因此,虽然您想使用 ES6 下一组酷炫的特性,但您必须考虑这样一个事实:许多 ES6 特性可能不被最流行的浏览器或 JavaScript 框架支持。

这看起来可能是一个糟糕的情况,但事情并没有那么糟糕。Node.js使用支持大多数 ES6 特性的最新版 V8 引擎。Facebook 的React也支持它们。Mozilla Firefox 和 Google Chrome 是目前使用最广泛的两种浏览器,它们支持大多数 ES6 特性。

为了避免这些陷阱和不可预测性,提出了一些解决方案。这些解决方案中最有用的是 polyfills/shims 和转译器。

Shims 或 polyfills

Polyfills(也称为 shims)是一种定义新版本环境中兼容旧版本环境的行为的模式。有一个很棒的 ES6 shims 集合叫做ES6 shimgithub.com/paulmillr/es6-shim/);我强烈建议您研究这些 shims。从 ES6 shim 集合中,考虑以下 shim 的示例。

根据 ECMAScript 2015(ES6)标准,Number.isFinite()方法用于确定传递的值是否是一个有限数字。它的等效 shim 可能如下所示:

var numberIsFinite = Number.isFinite || function isFinite(value) {
  return typeof value === 'number' && globalIsFinite(value);
};

这个 shim 首先检查Number.isFinite()方法是否可用;如果不可用,则用实现来填充它。这是一种非常巧妙的技巧,用于填补规范中的空白。shims 不断升级,加入新特性,因此,在项目中保留最新版本的 shims 是一个明智的策略。

注意

endsWith() polyfill 的详细说明可以在 developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/endsWith 找到。String.endsWith() 是 ES6 的一部分,但可以很容易地为 pre-ES6 环境进行 polyfill。

然而,shims 不能 polyfill 语法变化。为此,我们可以考虑转译器作为一个选项。

转译器

转译是一种结合了编译和转换的技术。想法是写 ES6 兼容的代码,并使用一个将这种代码转译成有效且等效的 ES5 代码的工具。我们将探讨最完整且流行的 ES6 转译器,名为 Babel (babeljs.io/)。

Babel 可以用多种方式使用。你可以把它安装为 node 模块,从命令行调用它,或者在你的网页中导入它作为一个脚本。Babel 的设置非常全面且文档齐全,详情请查看 babeljs.io/docs/setup/。Babel 还有一个很棒的 Read-Eval-Print-Loop (REPL)。在本章中,我们将使用 Babel REPL 来进行大多数示例。深入理解 Babel 可以用的各种方式超出了本书的范围。然而,我建议你开始将 Babel 作为你开发工作流程的一部分来使用。

我们将在本章覆盖 ES6 规范的最重要部分。如果可能的话,你应该探索 ES6 的所有特性,并让它们成为你开发工作流程的一部分。

ES6 语法变化

ES6 为 JavaScript 带来了重大的语法变化。这些变化需要仔细学习和适应。在本节中,我们将学习一些最重要的语法变化,并了解如何使用 Babel 立即在你的代码中使用这些新的构造。

块级作用域

我们之前讨论过,JavaScript 中的变量是函数作用域的。在嵌套作用域中创建的变量对整个函数都是可用的。几种编程语言为你提供了一个默认的块作用域,其中在任何代码块(通常由 {} 限定)中声明的变量(可用)仅限于这个块。为了在 JavaScript 中实现类似的块作用域,一个普遍的方法是使用立即调用函数表达式(IIFE)。考虑以下示例:

var a = 1;
(function blockscope(){
    var a = 2;
    console.log(a);   // 2
})();
console.log(a);       // 1

使用 IIFE,我们为 a 变量创建了一个块作用域。当在 IIFE 中声明一个变量时,它的作用域被限制在函数内部。这是模拟块作用域的传统方式。ES6 支持不使用 IIFE 的块作用域。在 ES6 中,你可以用 {} 定义的块来包含任何语句(或语句)。用 var 声明变量,你可以使用 let 来定义块作用域。前一个示例可以用 ES6 块作用域重写如下:

"use strict";
var a = 1;
{
  let a = 2;
  console.log( a ); // 2
}
console.log( a ); // 1

在 JavaScript 中使用独立的方括号{}可能看起来很奇怪,但这种约定在许多语言中用来创建块级作用域是非常普遍的。块级作用域同样适用于其他构造,比如if { }for (){ }

当你以这种方式使用块级作用域时,通常最好将变量声明放在块的最顶部。varlet声明的变量之间的一个区别是,用var声明的变量附着在整个函数作用域上,而用let声明的变量附着在块级作用域上,并且在块中出现之前它们不会被初始化。因此,你不能在声明之前访问用let声明的变量,而对于用var声明的变量,顺序并不重要:

function fooey() {
  console.log(foo); // ReferenceError
  let foo = 5000;
}

let的一个特定用途是在 for 循环中。当我们使用var声明一个变量在 for 循环中时,它是在全局或父作用域中创建的。我们可以在 for 循环作用域中通过使用let声明一个变量来创建一个块级作用域的变量。考虑以下示例:

for (let i = 0; i<5; i++) {
  console.log(i);
}
console.log(i); // i is not defined

由于i是通过let创建的,它在for循环中是有作用域的。你可以看到,这个变量在作用域之外是不可用的。

在 ES6 中,块级作用域的另一个用途是创建常量。使用const关键字,你可以在块级作用域中创建常量。一旦值被设置,你就无法改变这样一个常量的值:

if(true){
  const a=1;
  console.log(a);
  a=100;  ///"a" is read-only, you will get a TypeError
}

常量必须在声明时初始化。同样的块级作用域规则也适用于函数。当一个函数在块内部声明时,它只能在那个作用域内使用。

默认参数

默认值是非常常见的。你总是为传递给函数的参数或你初始化的变量设置一些默认值。你可能见过类似下面的代码:

function sum(a,b){
  a = a || 0;
  b = b || 0;
  return (a+b);
}
console.log(sum(9,9)); //18
console.log(sum(9));   //9

在这里,我们使用||(或运算符)来默认变量ab如果没有在调用函数时提供值,则默认为0。在 ES6 中,你有了一种标准的默认函数参数的方法。之前的示例可以重写如下:

function sum(a=0, b=0){
  return (a+b);
}
console.log(sum(9,9)); //18
console.log(sum(9));   //9

你可以将任何有效的表达式或函数调用作为默认参数列表的一部分传递。

展开和剩余

ES6 有一个新的操作符,。根据它的使用方式,它被称为展开剩余。让我们看一个简单的例子:

function print(a, b){
  console.log(a,b);
}
print(...[1,2]);  //1,2

这里发生的事情是,当你在数组(或可迭代对象)前加上时,它展开了数组中的元素,将其分别赋值给函数参数中的独立变量。当数组被展开时,ab这两个函数参数被赋予了数组中的两个值。在展开数组时,会忽略多余的参数:

print(...[1,2,3 ]);  //1,2

这仍然会打印12,因为这里只有两个功能参数可用。展开也可以用在其他地方,比如数组赋值:

var a = [1,2];
var b = [ 0, ...a, 3 ];
console.log( b ); //[0,1,2,3]

操作符还有一个与我们刚才看到完全相反的用途。不是展开值,而是用同一个操作符将它们聚集到一起:

function print (a,...b){
  console.log(a,b);
}
console.log(print(1,2,3,4,5,6,7));  //1 [2,3,4,5,6,7]

在这种情况下,变量b取剩余的值。变量a取第一个值作为1,变量b取剩余的值作为一个数组。

解构

如果你在函数式语言如Erlang上工作过,你会理解模式匹配的概念。JavaScript 中的解构与之一致。解构允许你使用模式匹配将值绑定到变量。考虑以下示例:

var [start, end] = [0,5];
for (let i=start; i<end; i++){
  console.log(i);
}
//prints - 0,1,2,3,4

我们使用数组解构来分配两个变量:

var [start, end] = [0,5];

如前所示的例子,我们希望模式在第一个值分配给第一个变量(start)和第二个值分配给第二个变量(end)时匹配。考虑以下片段,看看数组元素解构是如何工作的:

function fn() {
  return [1,2,3];
}
var [a,b,c]=fn();
console.log(a,b,c); //1 2 3
//We can skip one of them
var [d,,f]=fn();
console.log(d,f);   //1 3
//Rest of the values are not used
var [e,] = fn();
console.log(e);     //1

让我们讨论一下对象解构是如何工作的。假设你有一个返回对象的函数f,它按照如下方式返回:

function f() {
  return {
    a: 'a',
    b: 'b',
    c: 'c'
  };
}

当我们解构这个函数返回的对象时,我们可以使用我们之前看到的类似语法;不同的是,我们使用{}而不是[]

var { a: a, b: b, c: c } = f();
console.log(a,b,c); //a b c

与数组类似,我们使用模式匹配将变量分配给函数返回的相应值。如果你使用与匹配的变量相同的变量,这种写法会更短。下面的例子恰到好处:

var { a,b,c } = f();

然而,你通常会使用与函数返回的变量不同的变量名。重要的是要记住,语法是源:目标,而不是通常的目标:源。仔细观察下面的例子:

//this is target: source - which is incorrect
var { x: a, x: b, x: c } = f();
console.log(x,y,z); //x is undefined, y is undefined z is undefined
//this is source: target - correct
var { a: x, b: y, c: z } = f();
console.log(x,y,z); // a b c

这是目标 = 源赋值方式的相反,因此需要一些时间来适应。

对象字面量

对象字面量在 JavaScript 中无处不在。你会认为没有改进的余地。然而,ES6 也想改进这一点。ES6 引入了几种快捷方式,以围绕对象字面量创建紧凑的语法:

var firstname = "Albert", lastname = "Einstein",
  person = {
    firstname: firstname,
    lastname: lastname
  };

如果你打算使用与分配变量相同的属性名,你可以使用 ES6 的紧凑属性表示法:

var firstname = "Albert", lastname = "Einstein",
  person = {
    firstname,
    lastname
  };

同样地,你是这样给属性分配函数的:

var person = {
  getName: function(){
    // ..
  },
  getAge: function(){
    //..
  }
}

与其前的行相比,你可以这样说:

var person = {
  getName(){
    // ..
  },
  getAge(){
    //..
  }
}

模板字面量

我相信你肯定做过如下的事情:

function SuperLogger(level, clazz, msg){
  console.log(level+": Exception happened in class:"+clazz+" - Exception :"+ msg);
}

这是一种非常常见的替换变量值以形成字符串字面量的方法。ES6 为您提供了一种新的字符串字面量类型,使用反引号(`):

函数 SuperLogger(level, clazz, msg){

console.log(${level} : 在类: ${clazz} 中发生异常 - 异常 : {$msg});

}

around 一个字符串字面量。在这个字面量内部,任何\({..}`形式的表达式都会立即解析。这种解析称为插值。在解析时,变量的值替换了`\){}`内的占位符。结果字符串只是普通字符串,占位符被实际变量值替换。

使用字符串插值,你也可以将字符串拆分为多行,如下面的代码所示(与 Python 非常相似):

var quote =
`Good night, good night! 
Parting is such sweet sorrow, 
that I shall say good night 
till it be morrow.`;
console.log( quote );

你可以使用函数调用或有效的 JavaScript 表达式作为字符串插值的一部分:

function sum(a,b){
  console.log(`The sum seems to be ${a + b}`);
}
sum(1,2); //The sum seems to be 3

模板字符串的最后一种变体称为带标签的模板字符串。想法是用一个函数来修改模板字符串。考虑以下示例:

function emmy(key, ...values){
  console.log(key);
  console.log(values);
}
let category="Best Movie";
let movie="Adventures in ES6";
emmy`And the award for ${category} goes to ${movie}`;

//["And the award for "," goes to ",""]
//["Best Movie","Adventures in ES6"]

当我们用模板字面量调用emmy函数时,最奇怪的是这并不是传统函数调用的语法。我们不是写emmy();我们只是在标记字面量。当这个函数被调用时,第一个参数是所有普通字符串(插值表达式之间的字符串)的数组。第二个参数是所有插值表达式被求值和存储的数组。

这意味着标签函数实际上可以改变结果模板标签:

function priceFilter(s, ...v){
  //Bump up discount
  return s[0]+ (v[0] + 5);
}
let default_discount = 20;
let greeting = priceFilter `Your purchase has a discount of ${default_discount} percent`;
console.log(greeting);  //Your purchase has a discount of 25

正如你所看到的,我们在标签函数中修改了折扣的值并返回了修改后的值。

映射和集合

ES6 引入了四种新的数据结构:MapWeakMapSetWeakSet。我们之前讨论过,对象是 JavaScript 中创建键值对的常用方式。对象的缺点是你不能使用非字符串值作为键。以下片段演示了如何在 ES6 中创建映射:

let m = new Map();
let s = { 'seq' : 101 };

m.set('1','Albert');
m.set('MAX', 99);
m.set(s,'Einstein');

console.log(m.has('1')); //true
console.log(m.get(s));   //Einstein
console.log(m.size);     //3
m.delete(s);
m.clear();

你可以在声明它时初始化映射:

let m = new Map([
  [ 1, 'Albert' ],
  [ 2, 'Douglas' ],
  [ 3, 'Clive' ],
]);

如果你想遍历映射中的条目,你可以使用entries()函数,它将返回一个迭代器。你可以使用keys()函数遍历所有键,使用values()函数遍历映射的值:

let m2 = new Map([
    [ 1, 'Albert' ],
    [ 2, 'Douglas' ],
    [ 3, 'Clive' ],
]);
for (let a of m2.entries()){
  console.log(a);
}
//[1,"Albert"] [2,"Douglas"][3,"Clive"] 
for (let a of m2.keys()){
  console.log(a);
} //1 2 3
for (let a of m2.values()){
  console.log(a);
}
//Albert Douglas Clive

JavaScript 映射的一种变体是 WeakMap——WeakMap 不阻止其键被垃圾回收。WeakMap 的键必须是对象,而值可以是任意值。虽然 WeakMap 的行为与普通映射相同,但你不能遍历它,也不能清空它。这些限制背后有原因。由于映射的状态不能保证保持静态(键可能被垃圾回收),你不能确保正确的遍历。

使用 WeakMap 的情况并不多。大多数映射的使用可以用普通映射来实现。

虽然映射允许你存储任意值,但集合是唯一值的集合。映射和集合有类似的方法;然而,set()被替换为add(),而get()方法不存在。get()方法不存在的原因是因为集合有唯一值,所以你只关心集合是否包含一个值。考虑以下示例:

let x = {'first': 'Albert'};
let s = new Set([1,2,'Sunday',x]);
//console.log(s.has(x));  //true
s.add(300);
//console.log(s);  //[1,2,"Sunday",{"first":"Albert"},300]

for (let a of s.entries()){
  console.log(a);
}
//[1,1]
//[2,2]
//["Sunday","Sunday"]
//[{"first":"Albert"},{"first":"Albert"}]
//[300,300]
for (let a of s.keys()){
  console.log(a);
}
//1
//2
//Sunday
//{"first":"Albert"}
//300
for (let a of s.values()){
  console.log(a);
}
//1
//2
//Sunday
//{"first":"Albert"}
//300

keys()values()迭代器都返回集合中唯一值的列表。entries()迭代器生成一个条目数组列表,数组中的两个项目都是集合中的唯一值。集合的默认迭代器是其values()迭代器。

符号

ES6 引入了一种新数据类型叫做 Symbol。Symbol 是保证唯一且不可变的。Symbol 通常用作对象属性的标识符。它们可以被认为是唯一生成的 ID。你可以使用Symbol()工厂方法创建 Symbols——记住这不是一个构造函数,因此你不应该使用new操作符:

let s = Symbol();
console.log(typeof s); //symbol

与字符串不同,Symbols 保证是唯一的,因此有助于防止名称冲突。有了 Symbols,我们有一个对每个人都有效的扩展机制。ES6 带有一些预定义的内置 Symbols,它们揭示了 JavaScript 对象值的各种元行为。

迭代器

迭代器在其他编程语言中已经存在很长时间了。它们提供了方便的方法来处理数据集合。ES6 引入了迭代器来处理同样的用例。ES6 的迭代器是一个具有特定接口的对象。迭代器有一个next()方法,它返回一个对象。返回的对象有两个属性——value(下一个值)和done(表示是否已经达到最后一个结果)。ES6 还定义了一个Iterable接口,描述了必须能够产生迭代器的对象。让我们看看一个数组,它是一个可迭代的,以及它能够产生的迭代器来消费其值:

var a = [1,2];
var i = a[Symbol.iterator]();
console.log(i.next());      // { value: 1, done: false }
console.log(i.next());      // { value: 2, done: false }
console.log(i.next());      // { value: undefined, done: true }

正如你所见,我们是通过Symbol.iterator()访问数组的迭代器,并在其上调用next()方法来获取每个连续元素。next()方法调用返回valuedone两者。当你在数组的最后一个元素之后调用next()时,你会得到一个未定义的值和done: true,这表明你已经遍历了整个数组。

对于..of 循环

ES6 添加了一种新的迭代机制,形式为for..of循环,它遍历由迭代器产生的值集合。

我们遍历的for..of值是一个可迭代的。

让我们比较一下for..offor..in

var list = ['Sunday','Monday','Tuesday'];
for (let i in list){
  console.log(i);  //0 1 2
}
for (let i of list){
  console.log(i);  //Sunday Monday Tuesday
}

正如你所见,使用for..in循环,你可以遍历list数组的索引,而for..of循环让你遍历存储在list数组中的值。

箭头函数

ECMAScript 6 最有趣的新特性之一是箭头函数。箭头函数,正如其名称所暗示的,是使用一种新语法定义的函数,该语法使用箭头=>)作为语法的一部分。让我们首先看看箭头函数看起来如何:

//Traditional Function
function multiply(a,b) {
  return a*b;
}
//Arrow
var multiply = (a,b) => a*b;
console.log(multiply(1,2)); //2

箭头函数定义包括参数列表(零个或多个参数,如果恰好有一个参数则周围是( .. )),后面跟着=>标记,后面跟着函数体。

如果函数体中有多个表达式,可以用{ .. }括起来。如果只有一个表达式,并且省略了周围的{ .. },则在表达式前面有一个隐式的返回。你可以以几种不同的方式编写箭头函数。以下是最常用的几种:

// single argument, single statement
//arg => expression;
var f1 = x => console.log("Just X");
f1(); //Just X

// multiple arguments, single statement
//(arg1 [, arg2]) => expression;
var f2 = (x,y) => x*y;
console.log(f2(2,2)); //4

// single argument, multiple statements
// arg => {
//     statements;
// }
var f3 = x => {
  if(x>5){
    console.log(x);
  }
  else {
    console.log(x+5);
  }
}
f3(6); //6

// multiple arguments, multiple statements
// ([arg] [, arg]) => {
//   statements
// }
var f4 = (x,y) => {
  if(x!=0 && y!=0){
    return x*y;
  }
}
console.log(f4(2,2));//4

// with no arguments, single statement
//() => expression;
var f5 = () => 2*2;
console.log(f5()); //4

//IIFE
console.log(( x => x * 3 )( 3 )); // 9

重要的是要记住,所有正常函数参数的特征都适用于箭头函数,包括默认值、解构和剩余参数。

箭头函数提供了一种方便且简洁的语法,给你的代码带来了非常函数式编程的风格。箭头函数之所以受欢迎,是因为它们通过从代码中删除 function、return 和{ .. },提供了编写简洁函数的吸引力。然而,箭头函数是为了根本解决与 this 相关的编程中的一个特定且常见痛点而设计的。在正常的 ES5 函数中,每个新定义的函数都定义了自己的this值(在构造函数中是一个新对象,在严格模式函数调用中是undefined,如果函数作为对象方法调用,则是上下文对象等)。JavaScript 函数总是有自己的this,这阻止了你从回调内部访问例如周围方法中的this。为了理解这个问题,请考虑以下示例:

function CustomStr(str){
  this.str = str;
}
CustomStr.prototype.add = function(s){   // --> 1
  'use strict';
  return s.map(function (a){             // --> 2
    return this.str + a;                 // --> 3
  });
};

var customStr = new CustomStr("Hello");
console.log(customStr.add(["World"])); 
//Cannot read property 'str' of undefined

在标记为3的行上,我们试图获取this.str,但匿名函数也有自己的this,它遮蔽了从行1来的方法中的this。为了在 ES5 中修复这个问题,我们可以将this赋值给一个变量,然后使用这个变量:

function CustomStr(str){
  this.str = str;
}
CustomStr.prototype.add = function(s){   
  'use strict';
 var that = this;                       // --> 1
  return s.map(function (a){             // --> 2
    return that.str + a;                 // --> 3
  });
};

var customStr = new CustomStr("Hello");
console.log(customStr.add(["World"])); 
//["HelloWorld]

在标记为1的行上,我们将this赋值给一个变量that,在匿名函数中我们使用that变量,它将引用正确上下文中的this

ES6 箭头函数具有词法this,这意味着箭头函数捕获了外层上下文的this值。我们可以如下将前面的函数转换为等效的箭头函数:

function CustomStr(str){
  this.str = str;
}
CustomStr.prototype.add = function(s){ 
 return s.map((a)=> {
 return this.str + a;
 });
};
var customStr = new CustomStr("Hello");
console.log(customStr.add(["World"])); 
//["HelloWorld]

摘要

在本章中,我们讨论了几种重要特性,这些特性被添加到 ES6 语言中。这是一组令人兴奋的新语言特性和范式,并且,通过使用 polyfills 和 transpilers,你可以立即开始使用它们。JavaScript 是一种不断发展的语言,了解未来趋势非常重要。ES6 特性使 JavaScript 成为一个更加有趣和成熟的语言。在下一章中,我们将深入研究使用 jQuery 和 JavaScript 操纵浏览器的文档对象模型DOM)和事件。

第八章:DOM 操作与事件

javascript 存在最重要的原因就是网络。JavaScript 是网络的语言,浏览器就是 JavaScript 存在的理由。JavaScript 为原本静态的网页赋予了动态性。在本章中,我们将深入探讨浏览器与语言之间的关系。我们将了解 JavaScript 与网页组件进行交互的方式。我们将查看文档对象模型DOM)和 JavaScript 事件模型。

DOM

在本章中,我们将探讨 JavaScript 与浏览器和 HTML 的各种方面。HTML,我相信您已经知道,是用于定义网页的标记语言。存在各种形式的标记用于不同的用途。流行的标记有可扩展标记语言XML)和标准通用标记语言SGML)。除了这些通用的标记语言之外,还有针对特定目的非常具体的标记语言,例如文本处理和图像元信息。超文本标记语言HTML)是定义网页表示语义的标准标记语言。网页本质上是一个文档。DOM 为您提供了这个文档的表示。DOM 还为您提供了存储和操纵这个文档的手段。DOM 是 HTML 的编程接口,并允许使用脚本语言(如 JavaScript)进行结构操作。DOM 为文档提供了结构表示。该结构由节点和对象组成。节点有属性和方法,您可以对这些属性和方法进行操作以操纵节点本身。DOM 只是一个表示,并不是一个编程结构。DOM 作为 DOM 处理语言(如 JavaScript)的模型。

访问 DOM 元素

大多数时候,你将会想要访问 DOM 元素以检查它们的值,或者处理这些值以进行某些业务逻辑。我们将详细查看这个特定的用例。让我们创建一个带有以下内容的示例 HTML 文件:

<html>
<head>
  <title>DOM</title> 
</head>
<body>
  <p>Hello World!</p>
</body>
</html>

您可以将此文件保存为sample_dom.html;当您在 Google Chrome 浏览器中打开此文件时,您将看到显示Hello World文本的网页。现在,打开 Google Chrome 开发者工具,通过转到选项 | 更多工具 | 开发者工具(此路径可能因您的操作系统和浏览器版本而异)。在开发者工具窗口中,您将看到 DOM 结构:

访问 DOM 元素

接下来,我们将向这个 HTML 页面中插入一些 JavaScript。当网页加载时,我们将调用 JavaScript 函数。为此,我们将调用window.onload上的一个函数。您可以将您的脚本放在<script>标签下,该标签位于<head>标签下。您的页面应如下所示:

<html>
  <head>
    <title>DOM</title>
    <script>
      // run this function when the document is loaded
      window.onload = function() {
        var doc = document.documentElement;
        var body = doc.body;
        var _head = doc.firstChild;
        var _body = doc.lastChild;
        var _head_ = doc.childNodes[0];
        var title = _head.firstChild;
        alert(_head.parentNode === doc); //true
      }
    </script>
  </head>
  <body>
    <p>Hello World!</p>
  </body>
</html>

匿名函数在浏览器加载页面时执行。在函数中,我们获取 DOM 节点的程序化方式。整个 HTML 文档可以通过document.documentElement函数访问。我们将文档存储在一个变量中。一旦访问了文档,我们就可以使用文档的几个辅助属性来遍历节点。我们使用doc.body访问<body>元素。可以通过childNodes数组遍历元素的子节点。节点的第一个和最后一个子节点可以通过额外的属性——firstChildlastChild来访问。

注意

不建议在<head>标签中使用阻塞渲染的 JavaScript。这会显著减慢页面渲染速度。现代浏览器支持asyncdefer属性,以指示浏览器在下载脚本的同时可以继续渲染。你可以在<head>标签中使用这些标签,而不用担心性能下降。你可以在stackoverflow.com/questions/436411/where-is-the-best-place-to-put-script-tags-in-html-markup获取更多信息。

访问特定节点

核心 DOM 定义了getElementsByTagName()方法,返回所有tagName属性等于特定值的元素对象NodeList。以下代码行返回一个文档中所有<p>元素的列表:

var paragraphs = document.getElementsByTagName('p');

HTML DOM 定义了getElementsByName()方法来获取所有名称属性设置为特定值的元素。考虑以下片段:

<html>
  <head>
    <title>DOM</title>
    <script>
 showFeelings = function() {
 var feelings = document.getElementsByName("feeling");
 alert(feelings[0].getAttribute("value"));
 alert(feelings[1].getAttribute("value"));
 }
    </script>
  </head>
  <body>
    <p>Hello World!</p>
    <form method="post" action="/post">
      <fieldset>
        <p>How are you feeling today?</p>
        <input type="radio" name="feeling" value="Happy" /> Happy<br />
        <input type="radio" name="feeling" value="Sad" />Sad<br />
      </fieldset>
      <input type="button" value="Submit" onClick="showFeelings()"/>
    </form>
  </body>
</html>

在这个例子中,我们创建了一组单选按钮,其name属性定义为feeling。在showFeelings函数中,我们获取所有name属性设置为feeling的元素,并对这些元素进行遍历。

HTML DOM 还定义了getElementById()方法。这是一个非常实用的方法,用于访问特定元素。该方法基于与元素相关联的id属性进行查找。id属性对每个元素都是唯一的,因此这种查找非常快速,应优先于getElementsByName()方法。然而,你应该注意到浏览器不保证id属性的唯一性。在以下示例中,我们使用 ID 访问一个特定元素。元素 ID 相对于标签或名称属性来说是唯一的:

<html>
  <head>
    <title>DOM</title>
    <script>
      window.onload= function() {
 var greeting = document.getElementById("greeting");
 alert(greeting.innerHTML); //shows "Hello World" alert
      }
    </script>
  </head>
  <body>
    <p id="greeting">Hello World!</p>
    <p id="identify">Earthlings</p>
  </body>
</html>

迄今为止,我们讨论的是 DOM 遍历的基本知识。当 DOM 变得复杂且需要在 DOM 上进行复杂操作时,这些遍历和访问函数似乎有限。有了这些基本知识,是时候介绍一个用于 DOM 遍历(以及其他功能)的出色库——jQuery。

jQuery 是一个轻量级库,旨在使常见的浏览器操作更加容易。纯 JavaScript 中进行诸如 DOM 遍历和操作、事件处理、动画和 Ajax 等常见操作可能会很繁琐。jQuery 提供了易于使用且更短的助手机制,帮助你轻松快速地开发这些常见操作。jQuery 是一个功能丰富的库,但就本章而言,我们将主要关注 DOM 操作和事件。

你可以通过从内容分发网络CDN)直接添加脚本来将 jQuery 添加到你的 HTML 中,或者手动下载文件并将其添加到脚本标签中。以下示例将指导你如何从谷歌的 CDN 下载 jQuery:

<html>
  <head>
    <script src="img/jquery.min.js"></script>
  </head>
  <body>
  </body>
</html>

使用 CDN 下载的优势在于,谷歌的 CDN 会自动为你找到最近的下载服务器,并保持对 jQuery 库的更新稳定副本。如果你希望下载并手动托管 jQuery 以及你的网站,你可以按照以下方式添加脚本:

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

在这个例子中,jQuery 库是在lib目录中手动下载的。在 HTML 页面中设置 jQuery 后,让我们探索操纵 DOM 元素的方法。考虑以下示例:

<html>
  <head>
    <script src="img/jquery.min.js"></script>
    <script>
 $(document).ready(function() {
 $('#greeting').html('Hello World Martian');
 });
  </script>
  </head>
  <body>
    <p id="greeting">Hello World Earthling ! </p>
  </body>
</html>

在将 jQuery 添加到 HTML 页面后,我们编写自定义 JavaScript,选择具有greeting ID 的元素并更改其值。$()内的奇怪代码是 jQuery 在起作用。如果你阅读 jQuery 源代码(并且你应该阅读,它非常出色)你会看到最后一行:

// Expose jQuery to the global object
window.jQuery = window.$ = jQuery;

$只是一个函数。它是调用名为 jQuery 的函数的别名。$是一种语法糖,使代码更加简洁。实际上,你可以交替使用$jQuery。例如,$('#greeting').html('Hello World Martian');jQuery('#greeting').html('Hello World Martian');是相同的。

在页面完全加载之前不能使用 jQuery。因为 jQuery 需要知道 DOM 结构的的所有节点,整个 DOM 必须保存在内存中。为了确保页面完全加载并处于可以被操纵的状态,我们可以使用$(document).ready()函数。在这里,IIFE 仅在整个文档准备就绪后执行:

$(document).ready(function() {
  $('#greeting').html('Hello World Martian');
});
.ready() function. This function will be executed once the document is ready. We are using $(document) to create a jQuery object from our page's document. We are calling the .ready() function on the jQuery object and passing it the function that we want to execute.

在使用 jQuery 时,这是一个非常常见的行为——以至于它有自己的快捷方式。你可以用一个短的$()调用替换整个ready()调用:

$(function() {
  $('#greeting').html('Hello World Martian');
});

jQuery 中最重要的函数是$()。这个函数通常接受一个 CSS 选择器作为其唯一参数,并返回一个指向页面相应元素的新 jQuery 对象。三种主要的选择器是标签名、ID 和类。它们可以单独使用,也可以与其他元素组合使用。以下简单示例展示了这三种选择器在代码中的表现形式:

选择器 CSS 选择器 jQuery 选择器 选择器的输出
标签 p{} $('p') 这选择了文档中的所有p标签。
ID #div_1 $('#div_1') 这选择具有div_1 ID 的单个元素。用来标识 ID 的符号是#
.bold_fonts $('.bold_fonts') 这选择文档中具有bold_fonts CSS 类的所有元素。用来标识类匹配的符号是"."。

jQuery 工作在 CSS 选择器上。

注意

由于 CSS 选择器超出了本书的范围,我建议你前往www.w3.org/TR/CSS2/selector.html以了解这个概念。

我们假设你对 HTML 标签和语法也很熟悉。以下示例涵盖了 jQuery 选择器的基本工作原理:

<html>
  <head>
    <script src="img/jquery.min.js"></script>
    <script>
 $(function() {
 $('h1').html(function(index, oldHTML){
 return oldHTML + "Finally?";
 });
 $('h1').addClass('highlight-blue');
 $('#header > h1 ').css('background-color', 'cyan');
 $('ul li:not(.highlight-blue)').addClass('highlight-green');
 $('tr:nth-child(odd)').addClass('zebra');
 });
    </script>
    <style>
      .highlight-blue {
        color: blue;
      }
      .highlight-green{
        color: green;
      }
      .zebra{
        background-color: #666666;
        color: white;
      }
    </style>
  </head>
  <body>
    <div id=header>
      <h1>Are we there yet ? </h1>
      <span class="highlight">
        <p>Journey to Mars</p>
        <ul>
          <li>First</li>
          <li>Second</li>
          <li class="highlight-blue">Third</li>
        </ul>
      </span>
      <table>
        <tr><th>Id</th><th>First name</th><th>Last Name</th></tr>
        <tr><td>1</td><td>Albert</td><td>Einstein</td></tr>
        <tr><td>2</td><td>Issac</td><td>Newton</td></tr>
        <tr><td>3</td><td>Enrico</td><td>Fermi</td></tr>
        <tr><td>4</td><td>Richard</td><td>Feynman</td></tr>
      </table>
    </div>
  </body>
</html>

在这个例子中,我们使用选择器在 HTML 页面上选择几个 DOM 元素。我们有一个文本为Are we there yet ?的 H1 头部;当页面加载时,我们的 jQuery 脚本访问所有的 H1 头部并将文本Finally?附加到它们:

$('h1').html(function(index, oldHTML){
  return oldHTML + "Finally ?";
});

$.html()函数设置目标元素的 HTML——在这个例子中是一个 H1 头部。此外,我们选择所有的 H1 头部并为它们应用一个特定的 CSS 样式类,highlight-blue$('h1').addClass('highlight-blue')语句选择所有的 H1 头部,并使用$.addClass(<CSS 类>)方法为使用选择器选择的所有的元素应用一个 CSS 类。

我们使用子组合符(>)和$.css()函数自定义 CSS 样式。实际上,$()函数中的选择器是在说:“找到每个h1头部元素(#header的子元素)。” 对每个这样的元素,我们应用一个自定义的 CSS。下一个用法是有趣的。考虑以下行:

$('ul li:not(.highlight-blue)').addClass('highlight-green');

我们选择“对所有未应用highlight-blue类的li列表元素,应用highlight-green CSS 类。最后一行—$('tr:nth-child(odd)').addClass('zebra')—可以解释为:从所有表格行(tr)中,对每一行,应用zebra CSS 样式。第n个孩子选择器是 jQuery 提供的自定义选择器。最终输出类似于以下内容(虽然它展示了几个 jQuery 选择器类型,但非常清晰地表明了,了解 jQuery 并不是设计糟糕的替代品。):

访问特定节点

一旦你做出了一个选择,你可以在选定的元素上调用两种广泛的方法。这些方法是获取器设置器。获取器从选择集中检索信息,设置器以某种方式更改选择集。

获取器通常只对选择集中的第一个元素进行操作,而设置器则对选择集中的所有元素进行操作。设置器通过隐式迭代来自动遍历选择集中的所有元素。

例如,我们想要给页面上的所有列表项应用一个 CSS 类。当我们对选择器调用 addClass 方法时,它自动应用于这个特定选择的所有元素。这就是隐式迭代在行动:

$( 'li' ).addClass( highlighted' );

然而,有时你只是不想通过隐式迭代来遍历所有元素。你可能只想选择性地修改几个元素。你可以使用 .each() 方法显式地遍历元素。在以下代码中,我们选择性地处理元素并使用元素的 index 属性:

$( 'li' ).each(function( index, element ) {
  if(index % 2 == 0)
    $(elem).prepend( '<b>' + STATUS + '</b>' );
});

链式操作

链式 jQuery 方法允许你在选择上调用一系列方法,而无需临时存储中间值。这是可能的,因为我们所调用的每个设置器方法都会返回它被调用的选择。这是一个非常强大的特性,你将会看到许多专业库在使用它。考虑以下示例:

$( '#button_submit' )
  .click(function() {
    $( this ).addClass( 'submit_clicked' );
  })
  .find( '#notification' )
    .attr( 'title', 'Message Sent' );x
click(), find(), and attr() methods on a selector. Here, the click() method is executed, and once the execution finishes, the find() method locates the element with the notification ID and changes its title attribute to a string.

遍历和操作

我们讨论了使用 jQuery 进行元素选择的各种方法。我们在本节中将讨论使用 jQuery 进行 DOM 遍历和操作的几个方法。这些任务如果使用原生的 DOM 操作来实现将会相当繁琐。jQuery 使它们变得直观和优雅。

在我们深入这些方法之前,让我们先熟悉一些我们接下来会使用的 HTML 术语。考虑以下 HTML:

<ul> <-This is the parent of both 'li' and ancestor of everything in 
  <li> <-The first (li) is a child of the (ul)
    <span>  <-this is the descendent of the 'ul'
      <i>Hello</i>
    </span>
  </li>
  <li>World</li> <-both 'li' are siblings
</ul>

使用 jQuery 遍历方法,我们选择第一个元素并相对于这个元素遍历 DOM。在遍历 DOM 的过程中,我们改变了原始选择,我们或者是用新的选择替换原始选择,或者是修改原始选择。

例如,你可以过滤现有的选择,只包括符合某些标准的元素。考虑这个例子:

var list = $( 'li' ); //select all list elements
// filter items that has a class 'highlight' associated
var highlighted = list.filter( '.highlight );
// filter items that doesn't have class 'highlight' associated 
var not_highlighted = list.not( '.highlight );

jQuery 允许你给元素添加和移除类。如果你想要切换元素的类值,你可以使用 toggleClass() 方法:

$( '#usename' ).addClass( 'hidden' );
$( '#usename' ).removeClass( 'hidden' );
$( '#usename' ).toggleClass( 'hidden' );

大多数时候,你可能想更改元素的值。你可以使用 val() 方法来更改元素值的形式。例如,以下行更改了表单中所有 text 类型输入的值:

$( 'input[type="text"]' ).val( 'Enter usename:' );

要修改元素属性,你可以如下使用 attr() 方法:

$('a').attr( 'title', 'Click' );

jQuery 在 DOM 操作方面具有 incredible 的功能深度——本书的范围限制了对所有可能性的详细讨论。

处理浏览器事件

当你为浏览器开发时,你将不得不处理与它们相关的用户交互和事件,例如文本框中输入的文本、页面的滚动、鼠标按键按下等。当用户在页面上做些什么时,一个事件就会发生。有些事件不是由用户交互触发的,例如,load 事件不需要用户输入。

当你在浏览器中处理鼠标或键盘事件时,你无法预测这些事件何时以及以何种顺序发生。你必须不断寻找按键或鼠标移动事件的发生。这就像运行一个无尽的后台循环,监听某个键或鼠标事件的发生。在传统编程中,这被称为轮询。有许多变体,其中等待线程通过队列进行优化;然而,轮询通常仍然不是一个好主意。

浏览器提供了一种比轮询更好的替代方案。浏览器为您提供了在事件发生时做出反应的程序化手段。这些钩子通常称为监听器。您可以注册一个监听器,用于在特定事件发生时执行关联的回调函数。请参考这个例子:

<script> 
  addEventListener("click", function() { 
    ... 
  }); 
</script>

addEventListener 函数将其第二个参数注册为回调函数。当第一个参数指定的事件触发时,执行此回调。

刚才我们看到的是一个通用的 click 事件监听器。同样,每个 DOM 元素都有自己的 addEventListener 方法,允许你在这个元素上特别监听:

<button>Submit</button> 
<p>No handler here.</p> 
<script> 
  var button = document.getElementById("#Bigbutton");
  button.addEventListener("click", function() {
    console.log("Button clicked."); 
  }); 
</script>

在这个示例中,我们通过调用 getElementById() 使用特定元素的引用——一个具有 Bigbutton ID 的按钮。在按钮元素的引用上,我们调用 addEventListener() 为点击事件分配一个处理函数。在 Mozilla Firefox 或 Google Chrome 等现代浏览器中,这段代码完全合法且运行良好。然而,在 IE9 之前的 Internet Explorer 中,这段代码是无效的。这是因为微软在 Internet Explorer 9 之前实现了自己的自定义 attachEvent() 方法,而不是 W3C 标准的 addEventListener()。这非常不幸,因为你将不得不编写非常糟糕的快捷方式来处理浏览器特定的怪癖。

传播

在这个时候,我们应该问一个重要的问题——如果一个元素和它的一个祖先元素都有同一个事件处理程序,哪个处理程序将首先被触发?请参考以下图形:

传播

例如,我们有一个 Element2 作为 Element1 的子元素,两者都具有 onClick 处理程序。当用户点击 Element2 时,Element2 和 Element1 的 onClick 都会被触发,但问题是哪个先被触发。事件顺序应该是怎样的?嗯,不幸的是,答案完全取决于浏览器。当浏览器首次出现时,自然会从 Netscape 和 Microsoft 出现两种观点。

Netscape 决定首先触发的是 Element1 的 onClick 事件。这种事件排序被称为事件捕获。

Microsoft 决定首先触发的是 Element2 的 onClick 事件。这种事件排序被称为事件冒泡。

这两种方法完全代表了浏览器处理事件的两种相反观点和实现。为了结束这种疯狂,万维网联盟W3C)决定采取明智的中庸之道。在这个模型中,事件首先被捕获,直到它到达目标元素,然后再次冒泡。在这个标准行为中,你可以选择在哪个阶段注册你的事件处理程序——捕获阶段或冒泡阶段。如果在addEventListener()中的最后一个参数为 true,则事件处理程序设置为捕获阶段,如果为 false,则事件处理程序设置为冒泡阶段。

有时,如果你已经通过子元素触发了事件,你不想让父元素也触发事件。你可以在事件对象上调用stopPropagation()方法,以防止更高层次的处理程序接收事件。一些事件与它们关联的默认动作。例如,如果你点击一个 URL 链接,你会被带到链接的目标。在默认行为执行之前调用 JavaScript 事件处理程序。你可以在事件对象上调用preventDefault()方法,以阻止默认行为的触发。

当你在浏览器上使用纯 JavaScript 时,这些都是事件基础。这里有一个问题。浏览器在定义事件处理行为方面臭名昭著。我们将看看 jQuery 的事件处理。为了使管理更加容易,jQuery 总是为模型的冒泡阶段注册事件处理程序。这意味着最具体的元素将首先有机会对任何事件做出响应。

jQuery 事件处理和传播

jQuery 事件处理可以解决浏览器许多怪癖。你可以专注于编写在大多数受支持的浏览器上运行的代码。jQuery 对浏览器事件的支持简单直观。例如,这段代码监听用户点击页面上的任何按钮元素:

$('button').click(function(event) {
  console.log('Mouse button clicked');
});

就像click()方法一样,还有几个其他助手方法来涵盖几乎所有类型的浏览器事件。以下助手方法存在:

  • blur

  • change

  • click

  • dblclick

  • error

  • focus

  • keydown

  • keypress

  • keyup

  • load

  • mousedown

  • mousemove

  • mouseout

  • mouseover

  • mouseup

  • resize

  • scroll

  • select

  • submit

  • unload

另外,你可以使用.on()方法。使用.on()方法有几个优点,因为它为你提供了更多的灵活性。.on()方法允许你将处理程序绑定到多个事件。使用.on()方法,你也可以处理自定义事件。

事件名称作为on()方法的第一个参数传递,就像我们看到的其它方法一样:

$('button').on( 'click', function( event ) {
  console.log(' Mouse button clicked');
});

一旦你向元素注册了一个事件处理程序,你可以按照以下方式触发这个事件:

$('button').trigger( 'click' );

这个事件也可以按照以下方式触发:

$('button').click();

你可以使用 jQuery 的.off()方法解除事件绑定。这将移除绑定到指定事件的任何事件处理程序:

$('button').off( 'click' );

你可以向元素添加多个处理程序:

$("#element")   
.on("click", firstHandler) 
.on("click", secondHandler);

当事件被触发时,两个处理器都会被调用。如果你只想删除第一个处理器,你可以使用带有第二个参数的off()方法,该参数指明你想删除的处理器:

$("#element).off("click",firstHandler);

如果你有处理器的引用,这是可能的。如果你使用匿名函数作为处理器,你不能获取对它们的引用。在这种情况下,你可以使用命名空间事件。考虑以下示例:

$("#element").on("click.firstclick",function() { 
  console.log("first click");
});

现在你已经为元素注册了一个命名空间事件处理器,你可以按照以下方式删除它:

$("#element).off("click.firstclick");

使用.on()的一个主要优点是,你可以一次绑定多个事件。.on()方法允许你通过空格分隔的字符串传递多个事件。考虑以下示例:

$('#inputBoxUserName').on('focus blur', function() {
  console.log( Handling Focus or blur event' );
});

你可以为多个事件添加多个事件处理器如下:

$( "#heading" ).on({
  mouseenter: function() {
    console.log( "mouse entered on heading" );
  },
  mouseleave: function() {
    console.log( "mouse left heading" );
  },
  click: function() {
    console.log( "clicked on heading" );
  }
});

截至 jQuery 1.7,所有事件都是通过on()方法绑定的,即使你调用如click()的帮助方法。内部地,jQuery 将这些调用映射到on()方法。因此,通常建议使用on()方法以保持一致性和更快的执行。

事件委托

事件委托允许我们将一个事件监听器附加到父元素上。这个事件将会为所有匹配选择器的后代元素触发,即使这些后代元素是在监听器绑定后创建的(将来创建)。

我们之前讨论了事件冒泡。jQuery 中的事件委托主要归功于事件冒泡。每当页面上的事件发生时,事件会从它起源的元素开始冒泡,一直冒泡到它的父元素,然后冒泡到父元素的父元素,依此类推,直到它达到根元素(window)。考虑以下示例:

<html>
  <body>
    <div id="container">
      <ul id="list">
        <li><a href="http://google.com">Google</a></li>
        <li><a href="http://myntra.com">Myntra</a></li>
        <li><a href="http://bing.com">Bing</a></li>
      </ul>
    </div>
  </body>
</html>

现在假设我们想要对任何 URL 的点击执行一些常见操作。我们可以如下向列表中的所有a元素添加事件处理器:

$( "#list a" ).on( "click", function( event ) {
  console.log( $( this ).text() );
});

这完全没问题,但这段代码有一个小错误。如果由于某些动态操作在列表中添加了一个额外的 URL 会发生什么?比如说,我们有一个添加按钮,它将新的 URL 添加到这个列表中。所以,如果新列表项是通过一个新的 URL 添加的,那么早先的事件处理器将不会附加到它。例如,如果以下链接动态地添加到列表中,点击它将不会触发我们刚刚添加的处理器:

<li><a href="http://yahoo.com">Yahoo</a></li>

这是因为这样的事件只有在调用on()方法时才注册。在这种情况下,由于这个新元素在调用.on()时不存在,所以它不会获得事件处理器。根据我们对事件冒泡的理解,我们可以想象事件将如何在 DOM 树中向上传播。当点击任何一个 URL 时,传播将如下进行:

a(click)->li->ul#list->div#container->body->html->root

我们可以如下创建一个委托事件:

$( "#list" ).on( "click", "a", function( event ) {
  console.log( $( this ).text() );
});

我们把a从原来的选择器移动到了on()方法的第二个参数。on()方法的第二个参数使得处理程序监听这个特定的事件,并检查触发元素是否为第二个参数(在我们这个案例中的a)。由于第二个参数匹配,处理函数将被执行。通过这种委派事件,我们为整个ul#list添加了一个处理程序。这个处理程序将监听ul元素的任何后代元素触发的点击事件。

事件对象

到目前为止,我们为匿名函数添加了事件处理程序。为了使我们的事件处理程序更具通用性和可用性,我们可以创建命名函数并将它们分配给事件。考虑以下几行:

function handlesClicks(event){
  //Handle click event
}
$("#bigButton").on('click', handlesClicks);

这里,我们传递了一个命名函数而不是一个匿名函数给on()方法。现在让我们将注意力转移到我们传递给函数的event参数。jQuery 为所有事件回调传递了一个事件对象。事件对象包含了有关触发的事件的非常有用的信息。在不想让元素的默认行为发生的情况下,我们可以使用事件对象上的preventDefault()方法。例如,我们希望在提交完整表单之前发起一个 AJAX 请求,或者在点击 URL 锚点时阻止默认位置的打开。在这些情况下,您可能还希望阻止事件在 DOM 上冒泡。您可以通过调用事件对象的stopPropagation()方法来停止事件传播。考虑以下示例:

$( "#loginform" ).on( "submit", function( event ) { 
  // Prevent the form's default submission.
  event.preventDefault();
  // Prevent event from bubbling up DOM tree, also stops any delegation
  event.stopPropagation();
});

除了事件对象,您还可以获得一个对触发事件的 DOM 对象的引用。这个元素可以通过$(this)来引用。考虑以下示例:

$( "a" ).click(function( event ) {
  var anchor = $( this );
  if ( anchor.attr( "href" ).match( "google" ) ) {
    event.preventDefault();
  }
});

摘要

本章主要讲解的是 JavaScript 在其最重要的角色——浏览器语言中的使用。JavaScript 通过在浏览器上实现 DOM 操作和事件管理,引入了网页的动态性。我们讨论了有无 jQuery 的情况下这两种概念。随着现代网页需求的增加,使用如 jQuery 的库变得至关重要。这些库能显著提高代码质量和效率,同时让你有更多的自由去关注重要的事情。

我们将关注 JavaScript 的另一种化身——主要是服务器端。Node.js 已经成为一个流行的 JavaScript 框架,用于编写可扩展的服务器端应用程序。我们将详细探讨如何最佳地利用 Node.js 进行服务器应用程序的开发。

第九章.服务器端 JavaScript

到目前为止,我们一直在关注 JavaScript 作为浏览器语言的多样性。考虑到 JavaScript 已经作为一种可编程可扩展服务器系统的语言获得了显著的流行,这充分说明了这种语言的辉煌。在本章中,我们将介绍 Node.js。Node.js 是最受欢迎的 JavaScript 框架之一,用于服务器端编程。Node.js 也是 GitHub 上最受关注的项目之一,并且拥有非常出色的社区支持。

Node.js 使用 V8,这是为 Google Chrome 提供动力的虚拟机,来进行服务器端编程。V8 给 Node.js 带来了巨大的性能提升,因为它直接将 JavaScript 编译成本地机器代码,而不是执行字节码或使用解释器作为中间件。

V8 和 JavaScript 的多样性是一种美好的组合——性能、覆盖面以及 JavaScript 的整体流行度使得 Node.js 一夜之间取得了成功。在本章中,我们将涵盖以下主题:

  • 浏览器和服务器端 Node.js 中的异步事件模型

  • 回调

  • 定时器

  • 事件发射器

  • 模块和 npm

浏览器中的异步事件模型

在我们尝试理解 Node.js 之前,让我们先来理解一下浏览器中的 JavaScript。

Node.js 依赖于事件驱动和异步的平台来进行服务器端 JavaScript 的编程。这与浏览器处理 JavaScript 的方式非常相似。当浏览器和 Node.js 在进行 I/O 操作时,都是事件驱动和非阻塞的。

为了更深入地了解 Node.js 的事件驱动和异步特性,让我们首先比较一下各种操作及其相关的成本:

从 L1 缓存读取 0.5 纳秒
从 L2 缓存读取 7 纳秒
读取 RAM 100 纳秒
从 SSD 随机读取 4 KB 150,000 纳秒
从 SSD 顺序读取 1 MB 1,000,000 纳秒
从磁盘顺序读取 1 MB 20,000,000 纳秒

这些数字来自gist.github.com/jboner/2841832,展示了输入/输出I/O)可能有多么昂贵。计算机程序中最耗时的操作就是 I/O 操作,如果程序一直在等待这些 I/O 操作完成,这些操作就会降低整个程序的执行效率。让我们来看一个这样的操作示例:

console.log("1");
var log = fileSystemReader.read("./verybigfile.txt");
console.log("2");

当你调用fileSystemReader.read()时,你正在从文件系统中读取文件。正如我们刚才看到的,I/O 是这里的瓶颈,而且可能需要相当长的时间才能完成读取操作。根据硬件、文件系统、操作系统等不同,这个操作会很大程度上阻塞整个程序的执行。前面的代码执行了一些 I/O 操作,这是一个阻塞操作——进程将会一直阻塞,直到 I/O 操作完成并返回数据。这是传统的 I/O 模型,我们大多数人都很熟悉。然而,这种方法代价高昂,可能会导致可怕的延迟。每个进程都关联着内存和状态——在这两个方面,都会一直阻塞,直到 I/O 操作完成。

如果一个程序阻塞了 I/O,Node 服务器将拒绝新的请求。解决这个问题有几种方法。最传统的流行方法是使用多个线程来处理请求——这种技术被称为多线程。如果你熟悉像 Java 这样的语言,那么你很可能写过多线程代码。多种语言支持线程的不同形式——线程本质上保持自己的内存和状态。在大规模上编写多线程应用程序是困难的。当多个线程访问公共共享内存或值时,在这些线程之间维护正确的状态是非常困难的工作。线程在内存和 CPU 利用率方面也是昂贵的。用于同步资源的线程可能会最终被阻塞。

浏览器处理方式不同。浏览器中的 I/O 发生在主线程之外,当 I/O 完成时会发出一个事件。这个事件由与该事件关联的回调函数处理。这种 I/O 是非阻塞和异步的。因为 I/O 不阻塞主线程,所以浏览器可以继续处理其他事件,而无需等待任何 I/O。这是一个强大的想法。异步 I/O 允许浏览器响应多个事件,并实现高度的交互性。

Node 为异步处理使用了类似的想法。Node 的事件循环作为一个单线程运行。这意味着你编写的应用程序本质上是单线程的。这并不意味着 Node 本身是单线程的。Node 使用了libuv并且是多线程的——幸运的是,这些细节被隐藏在 Node 内部,你在开发应用程序时不需要了解它们。

每个涉及 I/O 调用的调用都需要你注册一个回调函数。注册回调函数也是异步的,并且会立即返回。一旦 I/O 操作完成,其回调函数就会被推送到事件循环中。当所有在其他事件循环中被推送到的事件回调执行完毕后,它才会被执行。所有的操作本质上都是线程安全的,这主要是因为事件循环中没有需要同步的并行执行路径。

本质上,只有一个线程在运行你的代码,并且没有并行执行;然而,除了你的代码之外的所有其他操作都是并行运行的。

Node.js 依赖于libev(software.schmorp.de/pkg/libev.html)来提供事件循环,并通过libeio(software.schmorp.de/pkg/libeio.html)使用池化线程提供异步 I/O。要了解更多,请查看 libev 文档:pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod

考虑以下 Node.js 中异步代码执行的示例:

var fs = require('fs');
console.log('1');
fs.readFile('./response.json', function (error, data) {
  if(!error){
    console.log(data);
  });
console.log('2');

在这个程序中,我们从磁盘上读取response.json文件。当磁盘 I/O 完成后,回调函数会以包含任何错误发生的参数和文件数据的参数执行。你将在控制台看到的是console.log('1')console.log('2')的输出连续出现:

浏览器中的异步事件模型

Node.js 不需要任何额外的服务器组件,因为它创建了自己的服务器进程。Node 应用程序本质上是在指定端口上运行的服务器。在 Node 中,服务器和应用程序是相同的。

以下是一个 Node.js 服务器示例,当通过浏览器运行http://localhost:3000/ URL 时,会返回Hello Node字符串:

var http = require('http');
var server = http.createServer();
server.on('request', function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello Node\n');
});
server.listen(3000); 

在这个例子中,我们使用了一个http模块。如果你回想我们之前关于 JavaScript 模块的讨论,你就会意识到这是 CommonJS 模块的实现。Node 将几个模块编译到二进制文件中。核心模块在 Node 的源代码中定义。它们可以在lib/文件夹中找到。

如果传递了它们的标识符给require(),它们会首先被加载。例如,require('http')总是会返回内置的 HTTP 模块,即使存在同名的文件也是如此。

加载处理 HTTP 请求的模块后,我们创建一个server对象,并使用server.on()函数为request事件添加一个监听器。无论何时有请求到达端口3000上的此服务器,回调都会被调用。回调接收requestresponse参数。我们还在发送响应之前设置Content-Type头和 HTTP 响应代码。你可以复制上面的代码,将其保存为一个纯文本文件,并命名为app.js。你可以使用以下命令行节点 js 运行服务器:

$ » node app.js

一旦服务器启动,你可以打开http://localhost:3000 URL 在浏览器中,你会看到令人兴奋的文本:

浏览器中的异步事件模型

如果你想要检查内部正在发生的事情,你可以发出如下curl命令:

~ » curl -v http://localhost:3000 
* Rebuilt URL to: http://localhost:3000/
*   Trying ::1...
* Connected to localhost (::1) port 3000 (#0)
> GET / HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Type: text/plain
< Date: Thu, 12 Nov 2015 05:31:44 GMT
< Connection: keep-alive
< Transfer-Encoding: chunked
<
Hello Node
* Connection #0 to host localhost left intact

Curl 显示了一个漂亮的请求(>)和响应(<)对话,包括请求和响应头。

回调

在 JavaScript 中的回调通常需要一段时间来适应。如果你来自其他一些非异步编程背景,你需要仔细理解回调是如何工作的;你可能会觉得你正在第一次学习编程。因为 Node 中一切都是异步的,所以你将不尝试仔细地结构它们而使用回调。Node.js 项目最重要的部分有时是代码组织和模块管理。

回调函数是在稍后异步执行的函数。而不是代码从上到下按程序顺序阅读,异步程序可能会根据早期函数(如 HTTP 请求或文件系统读取)的顺序和速度在不同时间执行不同的函数。

函数执行是顺序还是异步取决于它执行的上下文:

var i=0;
function add(num){
  console.log(i);
  i=i+num;
}
add(100);
console.log(i);

如果你使用 Node 运行这个程序,你会看到以下输出(假设你的文件名为app.js):

~/Chapter9 » node app.js
0
100

我们都习惯了这种情况。这是传统的同步代码执行,每一行按顺序执行。这里的代码定义了一个函数,然后在下一行调用这个函数,而不等待任何东西。这是顺序控制流。

如果我们在这个序列中引入 I/O,情况将会不同。如果我们试图从文件中读取一些内容或调用远程端点,Node 将以异步方式执行这些操作。在下一个例子中,我们将使用一个名为request的 Node.js 模块。我们将使用这个模块来执行 HTTP 调用。你可以按照以下方式安装这个模块:

npm install request

我们将在本章后面讨论 npm 的使用。考虑以下例子:

var request = require('request');
var status = undefined;
request('http://google.com', function (error, response, body) {
  if (!error && response.statusCode == 200) {
    status_code = response.statusCode;
  }
});
console.log(status); 

当你执行这段代码时,你会看到status变量的值仍然是undefined。在这个例子中,我们正在执行一个 HTTP 调用——这是一个 I/O 操作。当我们进行 I/O 操作时,执行变得异步。在之前的例子中,我们在内存中完成所有事情,并且没有涉及 I/O,因此,执行是同步的。当我们运行这个程序时,所有函数都被立即定义,但它们并不都立即执行。request()函数被调用,执行继续到下一行。如果没有东西要执行,Node 将等待 I/O 完成,或者退出。当request()函数完成其工作时,它将执行回调函数(作为request()函数第二个参数的匿名函数)。我们在前面例子中得到undefined的原因是,在我们的代码中没有任何逻辑告诉console.log()语句等待request()函数从 HTTP 调用中获取响应。

回调函数是在稍后的时间执行的函数。这改变了你组织代码的方式。重新组织代码的想法如下:

  • 将异步代码包裹在函数中

  • 将回调函数传递给包装函数

我们将在考虑这两个想法的基础上组织我们之前的例子。考虑这个修改后的例子:

var request = require('request');
var status = undefined;
function getSiteStatus(callback){
  request('http://google.com', function (error, response, body) {
    if (!error && response.statusCode == 200) {
      status_code = response.statusCode;
    }
    callback(status_code);
  });
}
function showStatusCode(status){
  console.log(status);
}
getSiteStatus(showStatusCode);

当你运行这个程序时,你会得到以下(正确)输出:

$node app.js
200

我们所改变的是将异步代码包裹在getSiteStatus()函数中,将一个名为callback()的函数作为参数传递给这个函数,在getSiteStatus()的最后一行执行这个函数。showStatusCode()回调函数仅仅是围绕我们之前调用的console.log()。然而,异步执行的工作方式有所不同。在学习如何使用回调编程时,理解函数是一等对象,可以存储在变量中并以不同的名称传递是非常重要的。给你的变量取简单且描述性的名称,这对于让你的代码更容易被他人阅读很重要。现在,一旦 HTTP 调用完成,回调函数就会被调用,status_code变量的值将会有一个正确的值。在某些真实情况下,你可能希望一个异步任务在另一个异步任务完成后执行。考虑这个场景:

http.createServer(function (req, res) {
  getURL(url, function (err, res) {
    getURLContent(res.data, function(err,res) {
      ...
    });
  });
});

正如你所看到的,我们在一个异步函数中嵌套另一个异步函数。这种嵌套可能导致代码难以阅读和管理。这种回调风格有时被称为回调地狱。为了避免这种情况,如果你有代码必须等待其他异步代码完成,那么你通过将你的代码放在作为回调传递的函数中来表达这种依赖关系。另一个重要的想法是给你的函数命名,而不是依赖匿名函数作为回调。我们可以将前面的示例重构为更易读的一个,如下所示:

var urlContentProcessor = function(data){
  ...
}
var urlResponseProcessor = function(data){
  getURLContent(data,urlContentProcessor);
}
var createServer = function(req,res){
  getURL(url,urlResponseProcessor);
};
http.createServer(createServer);

这个片段使用了两个重要的概念。首先,我们使用了命名函数并将它们作为回调使用。其次,我们并没有嵌套这些异步函数。如果你在内部函数中访问闭包变量,之前的实现会有所不同。在这种情况下,使用内联匿名函数更是可取的。

回调在 Node 中最为常用。它们通常用于定义一次性响应的逻辑。当你需要对重复事件做出响应时,Node 提供了另一种机制。在进一步讲解之前,我们需要了解 Node 中的定时器和事件函数。

定时器

定时器用于在特定延迟后安排特定回调的执行。设置这种延迟执行有两种主要方法:setTimeoutsetIntervalsetTimeout()函数用于在延迟后安排特定回调的执行,而setInterval用于安排回调的重复执行。setTimeout函数适用于需要计划执行的任务,例如家务。考虑以下示例:

setTimeout(function() {
  console.log("This is just one time delay");
},1000);
var count=0;
var t = setInterval(function() {
  count++;
  console.log(count);
  if (count> 5){
    clearInteval(t);
  }
}, 2000 );

首先,我们使用setTimeout()在 1,000 毫秒后执行回调(匿名函数)。这只是对这个回调的一次性计划。我们使用setInterval()来安排回调的重复执行。注意我们将setInterval()返回的值赋给变量t——我们可以在clearInterval()中使用这个引用来清除这个计划。

事件发射器

我们之前讨论过,回调对于执行一次性逻辑非常出色。EventEmitter在响应重复事件方面很有用。EventEmitter 触发事件,并在事件触发时处理这些事件。一些重要的 Node API 是基于 EventEmitter 构建的。

由 EventEmitter 引发的事件通过监听器处理。监听器是与事件关联的回调函数——当事件触发时,其关联的监听器也会被触发。event.EventEmitter是一个类,用于提供一致的接口来触发(触发)和绑定回调到事件。

作为一个常见的样式约定,事件名用驼峰命名法表示;然而,任何有效的字符串都可以作为事件名。

使用require('events')来访问EventEmitter类:

var EventEmitter = require('events');

当 EventEmitter 实例遇到错误时,它会触发一个error事件。在 Node.js 中,错误事件被视为一个特殊案例。如果你不处理这些错误,程序将以异常堆栈退出。

所有 EventEmitter 在添加新监听器时都会触发newListener事件,并在移除监听器时触发removeListener

为了理解 EventEmitter 的使用方法,我们将构建一个简化的 telnet 服务器,不同的客户端可以登录并输入某些命令。根据这些命令,我们的服务器将做出相应的响应:

var _net = require('net');
var _events = require ('events');
var _emitter = new events.EventEmitter();
_emitter.on('join', function(id,caller){
  console.log(id+" - joined");
});
_emitter.on('quit', function(id,caller){
  console.log(id+" - left");
});

var _server = _net.createServer(function(caller) {
  var process_id = caller.remoteAddress + ':' + caller.remotePort;
  _emitter.emit('join',id,caller);
  caller.on('end', function() {
    console.log("disconnected");
    _emitter.emit('quit',id,caller);
  });
});
_server.listen(8124);
net module from Node. The idea here is to create a server and let the client connect to it via a standard telnet command. When a client connects, the server displays the client address and port, and when the client quits, the server logs this too.

当一个客户端连接时,我们触发一个join事件,当客户端断开连接时,我们触发一个quit事件。我们对这两个事件都有监听器,它们在服务器上记录适当的消息。

你启动这个程序,并通过 telnet 连接到我们的服务器,如下所示:

telnet 127.0.0.1 8124

在服务器控制台上,你会看到服务器记录哪个客户端加入了服务器:

» node app.js
::ffff:127.0.0.1:51000 - joined
::ffff:127.0.0.1:51001 – joined

如果任何客户端退出会话,会出现一个适当的消息。

模块

当你写很多代码时,你很快就会达到一个需要开始思考如何组织代码的点。Node 模块是我们在讨论模块模式时提到的 CommonJS 模块。Node 模块可以发布到Node 包管理器npm)仓库。npm 仓库是 Node 模块的在线集合。

创建模块

Node 模块可以是单个文件或包含一个或多个文件的目录。通常创建一个单独的模块目录是个好主意。模块目录中的文件通常命名为index.js。模块目录可能如下所示:

node_project/src/nav
                --- >index.js

在你的项目目录中,nav模块目录包含了模块代码。通常,你的模块代码需要放在index.js文件中——如果你想要,你可以将其改放到另一个文件中。考虑这个叫做geo.js的简单模块:

exports.area = function (r) {
  return 3.14 * r * r;
};
exports.circumference = function (r) {
  return 3.14 * 3.14 * r;
};

你通过exports导出了两个函数。你可以使用require函数来使用这个模块。这个函数接收模块的名称或者模块代码的系统路径。你可以像下面这样使用我们创建的模块:

var geo = require('./geo.js');
console.log(geo.area(2));

因为我们只向外部导出两个函数,所以其他所有内容都保持私有。如果你还记得,我们详细讨论了模块模式——Node 使用 CommonJS 模块。创建模块还有一种替代语法。你可以使用modules.exports来导出你的模块。实际上,exports是为modules.exports创建的一个助手。当你使用exports时,它将一个模块导出的属性附加到modules.exports上。然而,如果modules.exports已经有一些属性附加到它上面,exports附加的属性将被忽略。

本节开头创建的geo模块可以改写,以返回一个Geo构造函数,而不是包含函数的对象。我们可以重写geo模块及其使用方式,如下:

var Geo = function(PI) {
  this.PI = PI;
}
Geo.prototype.area = function (r) {
  return this.PI * r * r;
};
Geo.prototype.circumference = function (r) {
  return this.PI * this.PI * r;
};
module.exports = Geo;

考虑一个config.js模块:

var db_config = {
  server: "0.0.0.0",
  port: "3306",
  user: "mysql",
  password: "mysql"
};
module.exports = db_config;

如果你想要从模块外部访问db_config,你可以使用require()来包含这个模块,并像下面这样引用这个对象:

var config = require('./config.js');
console.log(config.user);

组织模块有三种方式:

  • 使用相对路径,例如,config = require('./lib/config.js')

  • 使用绝对路径,例如,config = require('/nodeproject/lib/config.js')

  • 使用模块搜索,例如,config = require('config')

前两个选项是很容易理解的——它们允许 Node 在文件系统中特定位置查找模块。

当你使用第三种选项时,你是在要求 Node 使用标准的查找方法来定位模块。为了定位模块,Node 从当前目录开始,并附上./node_modules/。Node 然后尝试从这个位置加载模块。如果找不到模块,那么搜索从父目录开始,直到达到文件系统的根目录。

例如,如果require('config')/projects/node/中被调用,Node 将会搜索以下位置,直到找到匹配项:

  • /projects/node /node_modules/config.js

  • /projects/node_modules/config.js

  • /node_modules/config.js

对于从 npm 下载的模块,使用这种方法相对简单。正如我们之前讨论的,只要为 Node 提供一个入口点,你就可以将你的模块组织在目录中。

实现这一点最简单的方法是创建一个./node_modules/supermodule/目录,并在该目录中插入一个index.js文件。这个index.js文件将会被默认加载。另外,你也可以在mymodulename文件夹中放一个package.json文件,指明模块的名称和主文件:

{
  "name": "supermodule",
  "main": "./lib/config.js"
}

你必须明白 Node 将模块缓存为对象。如果你有两个(或更多)文件需要某个特定模块,第一个require将在内存中缓存该模块,这样第二个require就无需重新加载模块源代码。然而,第二个require可以更改模块的功能,如果它愿意的话。这通常被称为猴子补丁,用于修改模块的行为,而不真正修改或版本化原始模块。

npm

npm 是 Node 用来分发模块的包管理器。npm 可以用来安装、更新和管理模块。包管理器在其他语言中也很流行,如 Python。npm 会自动为包解决和更新依赖,因此使你的生活变得轻松。

安装包

安装 npm 包有两种方法:本地安装或全局安装。如果你只想为特定的 Node 项目使用模块的功能,可以在项目相对路径下本地安装,这是npm install的默认行为。另外,有许多模块可以用作命令行工具;在这种情况下,你可以全局安装它们:

npm install request

使用npminstall指令将安装一个特定的模块——request在这个例子中。为了确认npm install是否正确工作,检查是否存在一个node_modules目录,并验证它包含你安装的包的目录。

随着你向项目中添加模块,管理每个模块的版本/依赖变得困难。管理本地安装包的最佳方式是在你的项目中创建一个package.json文件。

package.json文件可以通过以下方式帮助你:

  • 定义你想安装的每个模块的版本。有时你的项目依赖于模块的特定版本。在这种情况下,你的package.json帮助你下载和维护正确的版本依赖。

  • 作为项目所需所有模块的文档。

  • 部署和打包你的应用程序,而不用担心每次部署代码时都要管理依赖。

你可以通过以下命令创建package.json

npm init

在回答了关于你的项目的基本问题后,会创建一个空白的package.json,其内容与以下类似:

{
  "name": "chapter9",
  "version": "1.0.0",
  "description": "chapter9 sample project",
  "main": "app.js",
  "dependencies": {
    "request": "².65.0"
  },
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [
    "Chapter9",
    "sample",
    "project"
  ],
  "author": "Ved Antani",
  "license": "MIT"
}

您可以在文本编辑器中手动编辑此文件。这个文件的一个重要部分是dependencies标签。为了指定你的项目依赖的包,你需要在你的package.json文件中列出你想要使用的包。你可以列出两种类型的包:

  • dependencies:这些包是应用程序在生产中所需的

  • devDependencies:这些包仅用于开发和测试(例如,使用Jasmine node 包

在前面的示例中,你可以看到以下依赖关系:

"dependencies": {
  "request": "².65.0"
},

这意味着项目依赖于request模块。

注意

模块的版本依赖于语义版本规则——docs.npmjs.com/getting-started/semantic-versioning

一旦你的 package.json 文件准备好了,你只需使用 npm install 命令就可以自动为你的项目安装所有模块。

有一个我很喜欢的酷炫技巧。在从命令行安装模块时,我们可以添加 --save 标志以自动将该模块的依赖项添加到 package.json 文件中:

npm install async --save
npm WARN package.json chapter9@1.0.0 No repository field.
npm WARN package.json chapter9@1.0.0 No README data
async@1.5.0 node_modules/async

在前面的命令中,我们使用带有 --save 标志的正常 npm 命令安装了 async 模块。在 package.json 中自动创建了相应的条目:

"dependencies": {
  "async": "¹.5.0",
  "request": "².65.0"
},

JavaScript 性能

像任何其他语言一样,编写大规模正确的 JavaScript 代码是一项涉及的任务。随着语言的成熟,许多内在问题正在得到解决。有许多优秀的库可以帮助编写高质量的代码。对于大多数严肃的系统来说,好的代码 = 正确的代码 + 高性能的代码。新一代软件系统对性能的要求很高。在本节中,我们将讨论一些你可以使用来分析你的 JavaScript 代码并了解其性能指标的工具。

在本节中,我们将讨论以下两个想法:

  • 剖析:在脚本剖析过程中计时各种函数和操作有助于识别你可以优化代码的区域。

  • 网络性能:检查网络资源的加载,如图片、样式表和脚本。

JavaScript 剖析

JavaScript 剖析对于理解代码各个部分的性能方面至关重要。你可以观察函数和操作的时间来了解哪个操作花费的时间更多。有了这些信息,你可以优化耗时函数的性能并调整代码的整体性能。我们将重点关注 Chrome 开发者工具提供的剖析选项。还有全面的分析工具,你可以使用它们来了解代码的性能指标。

CPU 剖析

CPU 剖析显示了你的代码各个部分执行花费的时间。我们必须通知 DevTools 记录 CPU 剖析数据。让我们来试试剖析器。

你可以按照以下方式在 DevTools 中启用 CPU 剖析器:

  1. 打开 Chrome DevTools 的性能面板。

  2. 确认收集 JavaScript CPU 剖析已选中:CPU 剖析

为此章节,我们将使用谷歌自己的基准页面,octane-benchmark.googlecode.com/svn/latest/index.html。我们将使用这个页面,因为它包含示例函数,我们可以看到各种性能瓶颈和基准测试。要开始记录 CPU 配置文件,请在 Chrome 中打开开发者工具,在配置文件标签中,点击开始按钮或按Cmd/Ctrl + E。刷新V8 基准套件页面。当页面完成重新加载后,将显示基准测试的得分。返回配置文件面板,通过点击停止按钮或再次按Cmd/Ctrl + E来停止记录。

记录的 CPU 配置文件为您提供了函数及其执行时间的详细视图,以下图所示:

CPU 配置文件

时间线视图

谷歌开发者工具时间线工具是您可以开始查看代码整体性能的第一站。它允许您记录并分析应用程序运行过程中的所有活动。

时间线为您提供了加载和使用您网站时时间花费的完整概述。时间线记录包括每个发生事件的记录,并以瀑布图的形式显示:

时间线视图

前一个屏幕展示了我们在浏览器中尝试渲染twitter.com/时的时间线视图。时间线视图为您提供了执行中各个操作花费了多少时间的总体视图:

时间线视图

在前一个屏幕截图中,我们可以看到各种 JavaScript 函数、网络调用、资源下载和其他渲染 Twitter 主页的操作逐步执行。这个视图让我们对哪些操作可能需要更长时间有了很好的了解。一旦我们识别出这样的操作,我们就可以对其进行性能优化。内存视图是一个很好的工具,可以帮助您了解在浏览器中您的应用程序生命周期内内存的使用情况。内存视图向您展示了您的应用程序随时间使用的内存的图表,并维护了一个计数器,用于统计保存在内存中的文档数量、DOM 节点和事件监听器。内存视图可以帮助检测内存泄漏,并给出足够好的提示,让您了解需要进行哪些优化:

时间线视图

JavaScript 性能是一个迷人的主题,完全值得一本专著。我强烈建议您探索 Chrome 的开发者工具,了解如何最佳地使用这些工具来检测和诊断您代码中的性能问题。

概要

在本章中,我们查看了 JavaScript 的另一个化身——以 Node.js 形式的 server-side 框架。

Node 提供了一个异步事件模型,用 JavaScript 编写可扩展和高性能的服务器应用程序。我们深入探讨了 Node 的一些核心概念,例如事件循环、回调、模块和定时器。理解它们对于编写好的 Node 代码至关重要。我们还讨论了几种更好地组织 Node 代码和回调的技术。

至此,我们已经探索了一种出色的编程语言。JavaScript 之所以在万维网的演变中发挥了重要作用,是因为它的多样性。该语言继续扩大其视野,并在每次新迭代中得到改进。

我们的旅程始于理解语言的语法和语法的构建块。我们掌握了闭包和 JavaScript 的功能行为的基本思想。这些概念是如此基本,以至于大多数 JavaScript 模式都是基于它们的。我们探讨了如何利用这些模式用 JavaScript 写出更好的代码。我们研究了 JavaScript 如何操作 DOM 以及如何有效地使用 jQuery 操纵 DOM。最后,我们查看了 JavaScript 的服务器端化身 Node.js。

这本书应该已经让你在开始用 JavaScript 编程时思维方式有所不同。你不仅会在编码时考虑常见的模式,而且会欣赏并使用 ES6 带来的新语言特性。

posted @ 2025-10-26 08:57  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报