JavaScript-权威指南第七版-GPT-重译--全-
JavaScript 权威指南第七版(GPT 重译)(全)
前言
本书涵盖了 JavaScript 语言以及 Web 浏览器和 Node 实现的 JavaScript API。我为一些具有先前编程经验的读者编写了这本书,他们想要学习 JavaScript,也为已经使用 JavaScript 的程序员编写了这本书,但希望将他们的理解提升到一个新的水平,并真正掌握这门语言。我写这本书的目标是全面和权威地记录 JavaScript 语言,并深入介绍 JavaScript 程序可用的最重要的客户端和服务器端 API。因此,这是一本长篇详细的书。然而,我希望它会奖励仔细学习,并且您花在阅读上的时间将很容易以更高的编程生产力形式收回。
本书的早期版本包括了一个全面的参考部分。我不再认为在印刷形式中包含这些材料是有意义的,因为在网上很容易找到最新的参考材料。如果您需要查找与核心或客户端 JavaScript 相关的任何内容,我建议您访问MDN 网站。对于服务器端 Node API,我建议您直接访问源并查阅Node.js 参考文档。
本书中使用的约定
我在本书中使用以下排版约定:
斜体
用于强调和指示术语的首次使用。斜体也用于电子邮件地址,URL 和文件名。
固定宽度
用于所有 JavaScript 代码和 CSS 和 HTML 列表,通常用于编程时需要字面输入的任何内容。
固定宽度斜体
有时用于解释 JavaScript 语法。
固定宽度粗体
显示用户应该按照字面意义输入的命令或其他文本
注意
此元素表示一般说明。
重要
此元素表示警告或注意事项。
示例代码
本书的补充材料(代码示例,练习等)可在以下网址下载:
本书旨在帮助您完成工作。一般情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则无需联系我们请求许可。例如,编写一个使用本书多个代码块的程序不需要许可。销售或分发 O'Reilly 图书中的示例需要许可。引用本书并引用示例代码回答问题不需要许可。将本书中大量示例代码合并到产品文档中需要许可。
我们感谢,但通常不要求署名。署名通常包括标题,作者,出版商和 ISBN。例如:“JavaScript: The Definitive Guide,第七版,作者 David Flanagan(O'Reilly)。版权所有 2020 年 David Flanagan,978-1-491-95202-3。”
如果您认为您使用的代码示例超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。
致谢
许多人在创作本书时提供了帮助。我要感谢我的编辑 Angela Rufino,她让我保持在正确的轨道上,对我错过的截止日期的耐心。也感谢我的技术审阅者:Brian Sletten,Elisabeth Robson,Ethan Flanagan,Maximiliano Firtman,Sarah Wachs 和 Schalk Neethling。他们的评论和建议使这本书变得更好。
O’Reilly 的制作团队一如既往地出色:Kristen Brown 管理了制作过程,Deborah Baker 担任制作编辑,Rebecca Demarest 绘制了图表,Judy McConville 创建了索引。
本书的编辑、审阅者和贡献者包括:Andrew Schulman,Angelo Sirigos,Aristotle Pagaltzis,Brendan Eich,Christian Heilmann,Dan Shafer,Dave C. Mitchell,Deb Cameron,Douglas Crockford,Dr. Tankred Hirschmann,Dylan Schiemann,Frank Willison,Geoff Stearns,Herman Venter,Jay Hodges,Jeff Yates,Joseph Kesselman,Ken Cooper,Larry Sullivan,Lynn Rollins,Neil Berkman,Mike Loukides,Nick Thompson,Norris Boyd,Paula Ferguson,Peter-Paul Koch,Philippe Le Hegaret,Raffaele Cecco,Richard Yaker,Sanders Kleinfeld,Scott Furman,Scott Isaacs,Shon Katzenberger,Terry Allen,Todd Ditchendorf,Vidur Apparao,Waldemar Horwat 和 Zachary Kessin。
撰写第七版使我在许多深夜远离了家人。我爱他们,感谢他们忍受我的缺席。
David Flanagan,2020 年 3 月
第一章:介绍 JavaScript
JavaScript 是 Web 的编程语言。绝大多数网站使用 JavaScript,并且所有现代 Web 浏览器——无论是桌面、平板还是手机——都包含 JavaScript 解释器,使 JavaScript 成为历史上部署最广泛的编程语言。在过去的十年中,Node.js 使 JavaScript 编程超越了 Web 浏览器,Node 的巨大成功意味着 JavaScript 现在也是软件开发人员中使用最广泛的编程语言。无论您是从零开始还是已经专业使用 JavaScript,本书都将帮助您掌握这门语言。
如果您已经熟悉其他编程语言,了解 JavaScript 是一种高级、动态、解释性编程语言,非常适合面向对象和函数式编程风格,可能会对您有所帮助。JavaScript 的变量是无类型的。其语法在很大程度上基于 Java,但这两种语言在其他方面没有关联。JavaScript 从 Scheme 语言中继承了头等函数,从鲜为人知的 Self 语言中继承了基于原型的继承。但您不需要了解这些语言,或熟悉这些术语,就可以使用本书学习 JavaScript。
名称“JavaScript”非常具有误导性。除了表面上的语法相似性外,JavaScript 与 Java 编程语言完全不同。JavaScript 早已超越了其脚本语言的起源,成为一种强大而高效的通用语言,适用于严肃的软件工程和具有庞大代码库的项目。
要有用,每种语言都必须有一个平台或标准库,用于执行诸如基本输入和输出之类的操作。核心 JavaScript 语言定义了一个最小的 API,用于处理数字、文本、数组、集合、映射等,但不包括任何输入或输出功能。输入和输出(以及更复杂的功能,如网络、存储和图形)是嵌入 JavaScript 的“主机环境”的责任。
JavaScript 的原始主机环境是 Web 浏览器,这仍然是 JavaScript 代码最常见的执行环境。Web 浏览器环境允许 JavaScript 代码通过用户的鼠标和键盘输入以及通过进行 HTTP 请求来获取输入。它还允许 JavaScript 代码使用 HTML 和 CSS 向用户显示输出。
自 2010 年以来,JavaScript 代码还有另一个主机环境可供选择。与将 JavaScript 限制在与 Web 浏览器提供的 API 一起使用不同,Node 使 JavaScript 可以访问整个操作系统,允许 JavaScript 程序读写文件,通过网络发送和接收数据,并进行和提供 HTTP 请求。Node 是实现 Web 服务器的热门选择,也是编写简单实用程序脚本的便捷工具,可作为 shell 脚本的替代品。
本书大部分内容都集中在 JavaScript 语言本身上。第十一章记录了 JavaScript 标准库,第十五章介绍了 Web 浏览器主机环境,第十六章介绍了 Node 主机环境。
本书首先涵盖低级基础知识,然后构建在此基础上,向更高级和更高级的抽象发展。这些章节应该按照更多或更少的顺序阅读。但是学习新的编程语言从来不是一个线性过程,描述一种语言也不是线性的:每个语言特性都与其他特性相关联,本书充满了交叉引用——有时是向后,有时是向前——到相关材料。本介绍性章节快速地介绍了语言的关键特性,这将使您更容易理解后续章节中的深入讨论。如果您已经是一名实践的 JavaScript 程序员,您可能可以跳过本章节。(尽管在继续之前,您可能会喜欢阅读示例 1-1)
1.1 探索 JavaScript
学习新的编程语言时,重要的是尝试书中的示例,然后修改它们并再次尝试以测试您对语言的理解。为此,您需要一个 JavaScript 解释器。
尝试几行 JavaScript 代码的最简单方法是在您的网络浏览器中打开 Web 开发者工具(使用 F12、Ctrl-Shift-I 或 Command-Option-I),然后选择控制台选项卡。然后,您可以在提示符处输入代码并在输入时查看结果。浏览器开发者工具通常显示为浏览器窗口底部或右侧的窗格,但通常可以将它们分离为单独的窗口(如图 1-1 所示),这通常非常方便。

图 1-1. Firefox 开发者工具中的 JavaScript 控制台
尝试 JavaScript 代码的另一种方法是从https://nodejs.org下载并安装 Node。安装 Node 后,您只需打开一个终端窗口并输入node即可开始像这样进行交互式 JavaScript 会话:
$ node
Welcome to Node.js v12.13.0.
Type ".help" for more information.
> .help
.break Sometimes you get stuck, this gets you out
.clear Alias for .break
.editor Enter editor mode
.exit Exit the repl
.help Print this help message
.load Load JS from a file into the REPL session
.save Save all evaluated commands in this REPL session to a file
Press ^C to abort current expression, ^D to exit the repl
> let x = 2, y = 3;
undefined
> x + y
5
> (x === 2) && (y === 3)
true
> (x > 3) || (y < 3)
false
1.2 你好,世界
当您准备开始尝试更长的代码块时,这些逐行交互式环境可能不再适用,您可能更喜欢在文本编辑器中编写代码。从那里,您可以将代码复制粘贴到 JavaScript 控制台或 Node 会话中。或者您可以将代码保存到文件中(JavaScript 代码的传统文件扩展名为.js),然后使用 Node 运行该 JavaScript 代码文件:
$ node snippet.js
如果您像这样以非交互方式使用 Node,它不会自动打印出您运行的所有代码的值,因此您需要自己执行。您可以使用函数console.log()在终端窗口或浏览器的开发者工具控制台中显示文本和其他 JavaScript 值。例如,如果您创建一个包含以下代码行的hello.js文件:
console.log("Hello World!");
并使用node hello.js执行文件,您将看到打印出“Hello World!”的消息。
如果您想在网络浏览器的 JavaScript 控制台中看到相同的消息打印出来,请创建一个名为hello.html的新文件,并将以下文本放入其中:
<script src="hello.js"></script>
然后使用file:// URL 将hello.html加载到您的网络浏览器中,就像这样:
file:///Users/username/javascript/hello.html
打开开发者工具窗口以在控制台中查看问候语。
1.3 JavaScript 之旅
本节通过代码示例快速介绍了 JavaScript 语言。在这个介绍性章节之后,我们将从最低级别深入 JavaScript:第二章解释了 JavaScript 注释、分号和 Unicode 字符集等内容。第三章开始变得更有趣:它解释了 JavaScript 变量以及您可以分配给这些变量的值。
这里有一些示例代码来说明这两章的亮点:
// Anything following double slashes is an English-language comment.
// Read the comments carefully: they explain the JavaScript code.
// A variable is a symbolic name for a value.
// Variables are declared with the let keyword:
let x; // Declare a variable named x.
// Values can be assigned to variables with an = sign
x = 0; // Now the variable x has the value 0
x // => 0: A variable evaluates to its value.
// JavaScript supports several types of values
x = 1; // Numbers.
x = 0.01; // Numbers can be integers or reals.
x = "hello world"; // Strings of text in quotation marks.
x = 'JavaScript'; // Single quote marks also delimit strings.
x = true; // A Boolean value.
x = false; // The other Boolean value.
x = null; // Null is a special value that means "no value."
x = undefined; // Undefined is another special value like null.
JavaScript 程序可以操作的另外两个非常重要的类型 是对象和数组。这是第六章和第七章的主题,但它们非常重要,以至于在到达这些章节之前你会看到它们很多次:
// JavaScript's most important datatype is the object.
// An object is a collection of name/value pairs, or a string to value map.
let book = { // Objects are enclosed in curly braces.
topic: "JavaScript", // The property "topic" has value "JavaScript."
edition: 7 // The property "edition" has value 7
}; // The curly brace marks the end of the object.
// Access the properties of an object with . or []:
book.topic // => "JavaScript"
book["edition"] // => 7: another way to access property values.
book.author = "Flanagan"; // Create new properties by assignment.
book.contents = {}; // {} is an empty object with no properties.
// Conditionally access properties with ?. (ES2020):
book.contents?.ch01?.sect1 // => undefined: book.contents has no ch01 property.
// JavaScript also supports arrays (numerically indexed lists) of values:
let primes = [2, 3, 5, 7]; // An array of 4 values, delimited with [ and ].
primes[0] // => 2: the first element (index 0) of the array.
primes.length // => 4: how many elements in the array.
primes[primes.length-1] // => 7: the last element of the array.
primes[4] = 9; // Add a new element by assignment.
primes[4] = 11; // Or alter an existing element by assignment.
let empty = []; // [] is an empty array with no elements.
empty.length // => 0
// Arrays and objects can hold other arrays and objects:
let points = [ // An array with 2 elements.
{x: 0, y: 0}, // Each element is an object.
{x: 1, y: 1}
];
let data = { // An object with 2 properties
trial1: [[1,2], [3,4]], // The value of each property is an array.
trial2: [[2,3], [4,5]] // The elements of the arrays are arrays.
};
这里展示的用方括号列出数组元素或在花括号内将对象属性名映射到属性值的语法被称为初始化表达式,这只是第四章的一个主题。表达式 是 JavaScript 的短语,可以评估以产生一个值。例如,使用.和[]来引用对象属性或数组元素的值就是一个表达式。
在 JavaScript 中形成表达式的最常见方式之一是使用运算符:
// Operators act on values (the operands) to produce a new value.
// Arithmetic operators are some of the simplest:
3 + 2 // => 5: addition
3 - 2 // => 1: subtraction
3 * 2 // => 6: multiplication
3 / 2 // => 1.5: division
points[1].x - points[0].x // => 1: more complicated operands also work
"3" + "2" // => "32": + adds numbers, concatenates strings
// JavaScript defines some shorthand arithmetic operators
let count = 0; // Define a variable
count++; // Increment the variable
count--; // Decrement the variable
count += 2; // Add 2: same as count = count + 2;
count *= 3; // Multiply by 3: same as count = count * 3;
count // => 6: variable names are expressions, too.
// Equality and relational operators test whether two values are equal,
// unequal, less than, greater than, and so on. They evaluate to true or false.
let x = 2, y = 3; // These = signs are assignment, not equality tests
x === y // => false: equality
x !== y // => true: inequality
x < y // => true: less-than
x <= y // => true: less-than or equal
x > y // => false: greater-than
x >= y // => false: greater-than or equal
"two" === "three" // => false: the two strings are different
"two" > "three" // => true: "tw" is alphabetically greater than "th"
false === (x > y) // => true: false is equal to false
// Logical operators combine or invert boolean values
(x === 2) && (y === 3) // => true: both comparisons are true. && is AND
(x > 3) || (y < 3) // => false: neither comparison is true. || is OR
!(x === y) // => true: ! inverts a boolean value
如果 JavaScript 表达式就像短语,那么 JavaScript 语句 就像完整的句子。语句是第五章的主题。粗略地说,表达式是计算值但不执行任何操作的东西:它不以任何方式改变程序状态。另一方面,语句没有值,但它们会改变状态。你已经在上面看到了变量声明和赋值语句。另一个广泛的语句类别是控制结构,如条件语句和循环。在我们讨论完函数之后,你将在下面看到示例。
函数 是一段命名和带参数的 JavaScript 代码块,你定义一次,然后可以反复调用。函数直到第八章才会正式介绍,但与对象和数组一样,你在到达该章之前会看到它们很多次。这里有一些简单的示例:
// Functions are parameterized blocks of JavaScript code that we can invoke.
function plus1(x) { // Define a function named "plus1" with parameter "x"
return x + 1; // Return a value one larger than the value passed in
} // Functions are enclosed in curly braces
plus1(y) // => 4: y is 3, so this invocation returns 3+1
let square = function(x) { // Functions are values and can be assigned to vars
return x * x; // Compute the function's value
}; // Semicolon marks the end of the assignment.
square(plus1(y)) // => 16: invoke two functions in one expression
在 ES6 及更高版本中,有一种用于定义函数的简洁语法。这种简洁语法使用=>将参数列表与函数体分开,因此用这种方式定义的函数被称为箭头函数。箭头函数在想要将匿名函数作为另一个函数的参数传递时最常用。前面的代码重写为使用箭头函数时如下所示:
const plus1 = x => x + 1; // The input x maps to the output x + 1
const square = x => x * x; // The input x maps to the output x * x
plus1(y) // => 4: function invocation is the same
square(plus1(y)) // => 16
当我们将函数与对象一起使用时,我们得到方法:
// When functions are assigned to the properties of an object, we call
// them "methods." All JavaScript objects (including arrays) have methods:
let a = []; // Create an empty array
a.push(1,2,3); // The push() method adds elements to an array
a.reverse(); // Another method: reverse the order of elements
// We can define our own methods, too. The "this" keyword refers to the object
// on which the method is defined: in this case, the points array from earlier.
points.dist = function() { // Define a method to compute distance between points
let p1 = this[0]; // First element of array we're invoked on
let p2 = this[1]; // Second element of the "this" object
let a = p2.x-p1.x; // Difference in x coordinates
let b = p2.y-p1.y; // Difference in y coordinates
return Math.sqrt(a*a + // The Pythagorean theorem
b*b); // Math.sqrt() computes the square root
};
points.dist() // => Math.sqrt(2): distance between our 2 points
现在,正如承诺的那样,这里有一些函数,它们的主体演示了常见的 JavaScript 控制结构语句:
// JavaScript statements include conditionals and loops using the syntax
// of C, C++, Java, and other languages.
function abs(x) { // A function to compute the absolute value.
if (x >= 0) { // The if statement...
return x; // executes this code if the comparison is true.
} // This is the end of the if clause.
else { // The optional else clause executes its code if
return -x; // the comparison is false.
} // Curly braces optional when 1 statement per clause.
} // Note return statements nested inside if/else.
abs(-10) === abs(10) // => true
function sum(array) { // Compute the sum of the elements of an array
let sum = 0; // Start with an initial sum of 0.
for(let x of array) { // Loop over array, assigning each element to x.
sum += x; // Add the element value to the sum.
} // This is the end of the loop.
return sum; // Return the sum.
}
sum(primes) // => 28: sum of the first 5 primes 2+3+5+7+11
function factorial(n) { // A function to compute factorials
let product = 1; // Start with a product of 1
while(n > 1) { // Repeat statements in {} while expr in () is true
product *= n; // Shortcut for product = product * n;
n--; // Shortcut for n = n - 1
} // End of loop
return product; // Return the product
}
factorial(4) // => 24: 1*4*3*2
function factorial2(n) { // Another version using a different loop
let i, product = 1; // Start with 1
for(i=2; i <= n; i++) // Automatically increment i from 2 up to n
product *= i; // Do this each time. {} not needed for 1-line loops
return product; // Return the factorial
}
factorial2(5) // => 120: 1*2*3*4*5
JavaScript 支持面向对象的编程风格,但与“经典”面向对象编程语言有很大不同。第九章详细介绍了 JavaScript 中的面向对象编程,提供了大量示例。下面是一个非常简单的示例,演示了如何定义一个 JavaScript 类来表示 2D 几何点。这个类的实例对象具有一个名为distance()的方法,用于计算点到原点的距离:
class Point { // By convention, class names are capitalized.
constructor(x, y) { // Constructor function to initialize new instances.
this.x = x; // This keyword is the new object being initialized.
this.y = y; // Store function arguments as object properties.
} // No return is necessary in constructor functions.
distance() { // Method to compute distance from origin to point.
return Math.sqrt( // Return the square root of x² + y².
this.x * this.x + // this refers to the Point object on which
this.y * this.y // the distance method is invoked.
);
}
}
// Use the Point() constructor function with "new" to create Point objects
let p = new Point(1, 1); // The geometric point (1,1).
// Now use a method of the Point object p
p.distance() // => Math.SQRT2
这里介绍了 JavaScript 基本语法和功能的入门之旅到此结束,但本书将继续涵盖语言的其他特性的独立章节:
第十章,模块
展示了一个文件或脚本中的 JavaScript 代码如何使用其他文件或脚本中定义的 JavaScript 函数和类。
第十一章,JavaScript 标准库
涵盖了所有 JavaScript 程序都可以使用的内置函数和类。这包括重要的数据结构如映射和集合,用于文本模式匹配的正则表达式类,用于序列化 JavaScript 数据结构的函数等等。
第十二章,迭代器和生成器
解释了for/of循环的工作原理以及如何使自己的类可迭代使用for/of。还涵盖了生成器函数和yield语句。
第十三章,异步 JavaScript
本章深入探讨了 JavaScript 中的异步编程,涵盖了回调和事件、基于 Promise 的 API,以及async和await关键字。尽管核心 JavaScript 语言不是异步的,但异步 API 在 Web 浏览器和 Node 中是默认的,本章解释了处理这些 API 的技术。
第十四章,元编程
介绍了一些对编写供其他 JavaScript 程序员使用的代码库感兴趣的 JavaScript 的高级特性。
第十五章,Web 浏览器中的 JavaScript
介绍了 Web 浏览器主机环境,解释了 Web 浏览器如何执行 JavaScript 代码,并涵盖了 Web 浏览器定义的许多重要 API 中最重要的部分。这是本书中迄今为止最长的一章。
第十六章,使用 Node 进行服务器端 JavaScript
介绍了 Node 主机环境,涵盖了最重要的编程模型、数据结构和 API,这些内容是最重要的理解。
第十七章,JavaScript 工具和扩展
涵盖了一些值得了解的工具和语言扩展,因为它们被广泛使用,可能会使您成为更高效的程序员。
1.4 示例:字符频率直方图
这一章以一个简短但非平凡的 JavaScript 程序结尾。示例 1-1 是一个 Node 程序,从标准输入读取文本,计算该文本的字符频率直方图,然后打印出直方图。您可以像这样调用程序来分析其自身源代码的字符频率:
$ node charfreq.js < charfreq.js
T: ########### 11.22%
E: ########## 10.15%
R: ####### 6.68%
S: ###### 6.44%
A: ###### 6.16%
N: ###### 5.81%
O: ##### 5.45%
I: ##### 4.54%
H: #### 4.07%
C: ### 3.36%
L: ### 3.20%
U: ### 3.08%
/: ### 2.88%
本示例使用了许多高级 JavaScript 特性,旨在演示真实世界的 JavaScript 程序可能是什么样子。您不应该期望立即理解所有代码,但请放心,所有内容将在接下来的章节中解释。
示例 1-1. 使用 JavaScript 计算字符频率直方图
/**
* This Node program reads text from standard input, computes the frequency
* of each letter in that text, and displays a histogram of the most
* frequently used characters. It requires Node 12 or higher to run.
*
* In a Unix-type environment you can invoke the program like this:
* node charfreq.js < corpus.txt
*/
// This class extends Map so that the get() method returns the specified
// value instead of null when the key is not in the map
class DefaultMap extends Map {
constructor(defaultValue) {
super(); // Invoke superclass constructor
this.defaultValue = defaultValue; // Remember the default value
}
get(key) {
if (this.has(key)) { // If the key is already in the map
return super.get(key); // return its value from superclass.
}
else {
return this.defaultValue; // Otherwise return the default value
}
}
}
// This class computes and displays letter frequency histograms
class Histogram {
constructor() {
this.letterCounts = new DefaultMap(0); // Map from letters to counts
this.totalLetters = 0; // How many letters in all
}
// This function updates the histogram with the letters of text.
add(text) {
// Remove whitespace from the text, and convert to upper case
text = text.replace(/\s/g, "").toUpperCase();
// Now loop through the characters of the text
for(let character of text) {
let count = this.letterCounts.get(character); // Get old count
this.letterCounts.set(character, count+1); // Increment it
this.totalLetters++;
}
}
// Convert the histogram to a string that displays an ASCII graphic
toString() {
// Convert the Map to an array of [key,value] arrays
let entries = [...this.letterCounts];
// Sort the array by count, then alphabetically
entries.sort((a,b) => { // A function to define sort order.
if (a[1] === b[1]) { // If the counts are the same
return a[0] < b[0] ? -1 : 1; // sort alphabetically.
} else { // If the counts differ
return b[1] - a[1]; // sort by largest count.
}
});
// Convert the counts to percentages
for(let entry of entries) {
entry[1] = entry[1] / this.totalLetters*100;
}
// Drop any entries less than 1%
entries = entries.filter(entry => entry[1] >= 1);
// Now convert each entry to a line of text
let lines = entries.map(
([l,n]) => `${l}: ${"#".repeat(Math.round(n))} ${n.toFixed(2)}%`
);
// And return the concatenated lines, separated by newline characters.
return lines.join("\n");
}
}
// This async (Promise-returning) function creates a Histogram object,
// asynchronously reads chunks of text from standard input, and adds those chunks to
// the histogram. When it reaches the end of the stream, it returns this histogram
async function histogramFromStdin() {
process.stdin.setEncoding("utf-8"); // Read Unicode strings, not bytes
let histogram = new Histogram();
for await (let chunk of process.stdin) {
histogram.add(chunk);
}
return histogram;
}
// This one final line of code is the main body of the program.
// It makes a Histogram object from standard input, then prints the histogram.
histogramFromStdin().then(histogram => { console.log(histogram.toString()); });
1.5 总结
本书从底层向上解释 JavaScript。这意味着我们从注释、标识符、变量和类型等低级细节开始;然后构建表达式、语句、对象和函数;然后涵盖类和模块等高级语言抽象。我认真对待本书标题中的“权威”一词,接下来的章节将以可能一开始感觉令人望而却步的细节水平解释语言。然而,真正掌握 JavaScript 需要理解这些细节,我希望您能抽出时间从头到尾阅读本书。但请不要觉得您需要在第一次阅读时就这样做。如果发现自己在某一部分感到困惑,请直接跳到下一部分。一旦对整个语言有了工作知识,您可以回来掌握细节。
第二章:词法结构
编程语言的词法结构是指定如何在该语言中编写程序的基本规则集。它是语言的最低级语法:它指定变量名的外观,注释的分隔符字符,以及如何将一个程序语句与下一个分隔开,例如。本短章记录了 JavaScript 的词法结构。它涵盖了:
-
区分大小写、空格和换行
-
注释
-
文字
-
标识符和保留字
-
Unicode
-
可选分号
2.1 JavaScript 程序的文本
JavaScript 是区分大小写的语言。这意味着语言关键字、变量、函数名和其他标识符必须始终以一致的字母大小写输入。例如,while关键字必须输入为while,而不是“While”或“WHILE”。同样,online、Online、OnLine和ONLINE是四个不同的变量名。
JavaScript 会忽略程序中标记之间出现的空格。在大多数情况下,JavaScript 也会忽略换行(但请参见§2.6 中的一个例外)。由于您可以在程序中自由使用空格和换行,因此可以以整洁一致的方式格式化和缩进程序,使代码易于阅读和理解。
除了常规空格字符(\u0020)外,JavaScript 还识别制表符、各种 ASCII 控制字符和各种 Unicode 空格字符作为空白。JavaScript 将换行符、回车符和回车符/换行符序列识别为行终止符。
2.2 注释
JavaScript 支持两种注释风格。任何位于//和行尾之间的文本都被视为注释,JavaScript 会忽略它。位于/*和*/之间的文本也被视为注释;这些注释可以跨越多行,但不能嵌套。以下代码行都是合法的 JavaScript 注释:
// This is a single-line comment.
/* This is also a comment */ // and here is another comment.
/*
* This is a multi-line comment. The extra * characters at the start of
* each line are not a required part of the syntax; they just look cool!
*/
2.3 文字
文字 是直接出现在程序中的数据值。以下都是文字:
12 // The number twelve
1.2 // The number one point two
"hello world" // A string of text
'Hi' // Another string
true // A Boolean value
false // The other Boolean value
null // Absence of an object
数字和字符串文字的完整详细信息请参见第三章。
2.4 标识符和保留字
标识符 就是一个名字。在 JavaScript 中,标识符用于命名常量、变量、属性、函数和类,并为 JavaScript 代码中某些循环提供标签。JavaScript 标识符必须以字母、下划线(_)或美元符号($)开头。后续字符可以是字母、数字、下划线或美元符号。(不允许数字作为第一个字符,以便 JavaScript 可以轻松区分标识符和数字。)以下都是合法的标识符:
i
my_variable_name
v13
_dummy
$str
与任何语言一样,JavaScript 为语言本身保留了某些标识符。这些“保留字”不能用作常规标识符。它们在下一节中列出。
2.4.1 保留字
以下单词是 JavaScript 语言的一部分。其中许多(如if、while和for)是必须避免用作常量、变量、函数或类名称的保留关键字(尽管它们都可以用作对象内的属性名称)。其他一些单词(如from、of、get和set)在有限的上下文中使用时没有语法歧义,作为标识符是完全合法的。还有其他关键字(如let)为了保持与旧程序的向后兼容性而不能完全保留,因此有复杂的规则规定何时可以将其用作标识符,何时不行。(例如,如果在类外部用var声明,let可以用作变量名,但如果在类内部或用const声明,则不行。)最简单的方法是避免将这些单词用作标识符,除了from、set和target,它们是安全的并且已经被广泛使用。
as const export get null target void
async continue extends if of this while
await debugger false import return throw with
break default finally in set true yield
case delete for instanceof static try
catch do from let super typeof
class else function new switch var
JavaScript 还保留或限制了某些关键字的使用,这些关键字目前尚未被语言使用,但可能在未来版本中使用:
enum implements interface package private protected public
由于历史原因,在某些情况下不允许将arguments和eval用作标识符,并且最好完全避免使用它们。
2.5 Unicode
JavaScript 程序使用 Unicode 字符集编写,您可以在字符串和注释中使用任何 Unicode 字符。为了便于移植和编辑,通常在标识符中仅使用 ASCII 字母和数字。但这只是一种编程约定,语言允许在标识符中使用 Unicode 字母、数字和表意文字(但不允许使用表情符号)。这意味着程序员可以使用数学符号和非英语语言中的单词作为常量和变量:
const π = 3.14;
const sí = true;
2.5.1 Unicode 转义序列
一些计算机硬件和软件无法显示、输入或正确处理完整的 Unicode 字符集。为了支持使用较旧技术的程序员和系统,JavaScript 定义了转义序列,允许我们仅使用 ASCII 字符编写 Unicode 字符。这些 Unicode 转义以字符\u开头,后面要么跟着恰好四个十六进制数字(使用大写或小写字母 A-F),要么是由一个到六个十六进制数字括在花括号内。这些 Unicode 转义可能出现在 JavaScript 字符串文字、正则表达式文字和标识符中(但不出现在语言关键字中)。例如,字符“é”的 Unicode 转义是\u00E9;以下是三种包含此字符的变量名的不同写法:
let café = 1; // Define a variable using a Unicode character
caf\u00e9 // => 1; access the variable using an escape sequence
caf\u{E9} // => 1; another form of the same escape sequence
早期版本的 JavaScript 仅支持四位数转义序列。带有花括号的版本是在 ES6 中引入的,以更好地支持需要超过 16 位的 Unicode 代码点,例如表情符号:
console.log("\u{1F600}"); // Prints a smiley face emoji
Unicode 转义也可能出现在注释中,但由于注释被忽略,因此在该上下文中它们仅被视为 ASCII 字符,而不被解释为 Unicode。
2.5.2 Unicode 规范化
如果您在 JavaScript 程序中使用非 ASCII 字符,您必须意识到 Unicode 允许以多种方式对相同字符进行编码。例如,字符串“é”可以编码为单个 Unicode 字符\u00E9,也可以编码为常规 ASCII 的“e”后跟重音符组合标记\u0301。这两种编码在文本编辑器中显示时通常看起来完全相同,但它们具有不同的二进制编码,这意味着 JavaScript 认为它们是不同的,这可能导致非常令人困惑的程序:
const café = 1; // This constant is named "caf\u{e9}"
const café = 2; // This constant is different: "cafe\u{301}"
café // => 1: this constant has one value
café // => 2: this indistinguishable constant has a different value
Unicode 标准定义了所有字符的首选编码,并指定了一种规范化过程,将文本转换为适合比较的规范形式。JavaScript 假定它正在解释的源代码已经被规范化,并且不会自行进行任何规范化。如果您计划在 JavaScript 程序中使用 Unicode 字符,您应确保您的编辑器或其他工具对源代码执行 Unicode 规范化,以防止您最终得到不同但在视觉上无法区分的标识符。
2.6 可选分号
像许多编程语言一样,JavaScript 使用分号(;)来分隔语句(参见第五章)。这对于使代码的含义清晰很重要:没有分隔符,一个语句的结尾可能看起来是下一个语句的开头,反之亦然。在 JavaScript 中,如果两个语句写在不同行上,通常可以省略这两个语句之间的分号。(如果程序的下一个标记是闭合大括号},也可以省略分号。)许多 JavaScript 程序员(以及本书中的代码)使用分号明确标记语句的结尾,即使不需要也是如此。另一种风格是尽可能省略分号,只在需要时使用。无论你选择哪种风格,都应该了解 JavaScript 中可选分号的一些细节。
考虑以下代码。由于两个语句出现在不同行上,第一个分号可以省略:
a = 3;
b = 4;
然而,按照以下方式书写,第一个分号是必需的:
a = 3; b = 4;
请注意,JavaScript 并不会将每个换行符都视为分号:通常只有在无法解析代码而需要添加隐式分号时,才会将换行符视为分号。更正式地说(稍后描述的三个例外情况),如果下一个非空格字符无法被解释为当前语句的延续,JavaScript 将换行符视为分号。考虑以下代码:
let a
a
=
3
console.log(a)
JavaScript 解释这段代码如下:
let a; a = 3; console.log(a);
JavaScript 将第一个换行符视为分号,因为它无法解析不带分号的代码let a a。第二个a可以作为语句a;独立存在,但 JavaScript 不会将第二个换行符视为分号,因为它可以继续解析较长的语句a = 3;。
这些语句终止规则会导致一些令人惊讶的情况。这段代码看起来像是两个用换行符分隔的独立语句:
let y = x + f
(a+b).toString()
但是代码的第二行括号可以被解释为从第一行调用f的函数调用,JavaScript 会这样解释代码:
let y = x + f(a+b).toString();
很可能这并不是代码作者打算的解释。为了作为两个独立语句工作,这种情况下需要一个显式分号。
一般来说,如果语句以(、[、/、+或-开头,那么它可能被解释为前一个语句的延续。以/、+和-开头的语句在实践中相当罕见,但以(和[开头的语句在某些 JavaScript 编程风格中并不罕见。一些程序员喜欢在这类语句的开头放置一个防御性分号,以便即使修改了其前面的语句并删除了先前的分号,它仍将正确工作:
let x = 0 // Semicolon omitted here
;[x,x+1,x+2].forEach(console.log) // Defensive ; keeps this statement separate
有三个例外情况不符合 JavaScript 将换行符解释为分号的一般规则,即当它无法将第二行解析为第一行语句的延续时。第一个例外涉及return、throw、yield、break和continue语句(参见第五章)。这些语句通常是独立的,但有时会跟随标识符或表达式。如果这些单词之后(在任何其他标记之前)出现换行符,JavaScript 将始终将该换行符解释为分号。例如,如果你写:
return
true;
JavaScript 假设你的意思是:
return; true;
然而,你可能的意思是:
return true;
这意味着你不能在return、break或continue与后面的表达式之间插入换行符。如果插入换行符,你的代码很可能会以难以调试的非明显方式失败。
第二个例外涉及++和−−运算符(§4.8)。这些运算符可以是前缀运算符,出现在表达式之前,也可以是后缀运算符,出现在表达式之后。如果要将这些运算符之一用作后缀运算符,它们必须出现在应用于的表达式的同一行上。第三个例外涉及使用简洁的“箭头”语法定义的函数:=>箭头本身必须出现在参数列表的同一行上。
2.7 总结
本章展示了 JavaScript 程序是如何在最低级别编写的。下一章将带我们迈向更高一级,并介绍作为 JavaScript 程序计算的基本单位的原始类型和值(数字、字符串等)。
第三章:类型、值和变量
计算机程序通过操作值来工作,例如数字 3.14 或文本“Hello World”。在编程语言中可以表示和操作的值的种类称为类型,编程语言的最基本特征之一是它支持的类型集合。当程序需要保留一个值以供将来使用时,它将该值分配给(或“存储”在)一个变量中。变量有名称,并且允许在我们的程序中使用这些名称来引用值。变量的工作方式是任何编程语言的另一个基本特征。本章解释了 JavaScript 中的类型、值和变量。它从概述和一些定义开始。
3.1 概述和定义
JavaScript 类型可以分为两类:原始类型 和 对象类型。JavaScript 的原始类型包括数字、文本字符串(称为字符串)和布尔真值(称为布尔值)。本章的重要部分详细解释了 JavaScript 中的数字(§3.2)和字符串(§3.3)类型。布尔值在§3.4 中介绍。
特殊的 JavaScript 值 null 和 undefined 是原始值,但它们不是数字、字符串或布尔值。每个值通常被认为是其自己特殊类型的唯一成员。关于 null 和 undefined 的更多内容请参见§3.5。ES6 添加了一种新的特殊类型,称为 Symbol,它可以在不影响向后兼容性的情况下定义语言扩展。Symbols 在§3.6 中简要介绍。
任何不是数字、字符串、布尔值、符号、null 或 undefined 的 JavaScript 值都是对象。对象(即类型 object 的成员)是一个属性集合,其中每个属性都有一个名称和一个值(可以是原始值或另一个对象)。一个非常特殊的对象,全局对象,在§3.7 中介绍,但是一般和更详细的对象覆盖在第六章中。
一个普通的 JavaScript 对象是一个无序的命名值集合。该语言还定义了一种特殊类型的对象,称为数组,表示一个有序的编号值集合。JavaScript 语言包括特殊的语法用于处理数组,并且数组具有一些特殊的行为,使它们与普通对象有所区别。数组是第七章的主题。
除了基本对象和数组之外,JavaScript 还定义了许多其他有用的对象类型。Set 对象表示一组值。Map 对象表示从键到值的映射。各种“类型化数组”类型便于对字节数组和其他二进制数据进行操作。RegExp 类型表示文本模式,并支持对字符串进行复杂的匹配、搜索和替换操作。Date 类型表示日期和时间,并支持基本的日期算术。Error 及其子类型表示执行 JavaScript 代码时可能出现的错误。所有这些类型在第十一章中介绍。
JavaScript 与更静态的语言不同之处在于函数和类不仅仅是语言语法的一部分:它们本身是 JavaScript 程序可以操作的值。与任何不是原始值的 JavaScript 值一样,函数和类是一种特殊类型的对象。它们在第八章和第九章中详细介绍。
JavaScript 解释器执行自动垃圾回收以进行内存管理。这意味着 JavaScript 程序员通常不需要担心对象或其他值的销毁或释放。当一个值不再可达时——当程序不再有任何方式引用它时——解释器知道它永远不会再被使用,并自动回收它占用的内存。(JavaScript 程序员有时需要小心确保值不会意外地保持可达——因此不可回收——时间比必要长。)
JavaScript 支持面向对象的编程风格。宽松地说,这意味着与其在全局定义函数来操作各种类型的值,类型本身定义了用于处理值的方法。例如,要对数组a的元素进行排序,我们不会将a传递给sort()函数。相反,我们调用a的sort()方法:
a.sort(); // The object-oriented version of sort(a).
方法定义在第九章中介绍。技术上,只有 JavaScript 对象有方法。但是数字、字符串、布尔值和符号值的行为就好像它们有方法一样。在 JavaScript 中,只有null和undefined是不能调用方法的值。
JavaScript 的对象类型是可变的,而其原始类型是不可变的。可变类型的值可以改变:JavaScript 程序可以更改对象属性和数组元素的值。数字、布尔值、符号、null和undefined是不可变的——例如,谈论更改数字的值甚至没有意义。字符串可以被视为字符数组,你可能期望它们是可变的。然而,在 JavaScript 中,字符串是不可变的:你可以访问字符串的任何索引处的文本,但 JavaScript 没有提供一种方法来更改现有字符串的文本。可变和不可变值之间的差异在§3.8 中进一步探讨。
JavaScript 自由地将一个类型的值转换为另一个类型。例如,如果一个程序期望一个字符串,而你给了它一个数字,它会自动为你将数字转换为字符串。如果你在期望布尔值的地方使用了非布尔值,JavaScript 会相应地进行转换。值转换的规则在§3.9 中解释。JavaScript 自由的值转换规则影响了它对相等性的定义,==相等运算符执行如§3.9.1 中描述的类型转换。(然而,在实践中,==相等运算符已被弃用,而是使用严格相等运算符===,它不进行类型转换。有关这两个运算符的更多信息,请参见§4.9.1。)
常量和变量允许您在程序中使用名称引用值。常量使用const声明,变量使用let声明(或在旧的 JavaScript 代码中使用var)。JavaScript 的常量和变量是无类型的:声明不指定将分配什么类型的值。变量声明和赋值在§3.10 中介绍。
从这个长篇介绍中可以看出,这是一个涵盖广泛的章节,解释了 JavaScript 中数据如何表示和操作的许多基本细节。我们将从直接深入讨论 JavaScript 数字和文本的细节开始。
3.2 数字
JavaScript 的主要数值类型 Number 用于表示整数和近似实数。JavaScript 使用 IEEE 754 标准定义的 64 位浮点格式表示数字,¹这意味着它可以表示大约±1.7976931348623157 × 10³⁰⁸和小约±5 × 10^(−324)的数字。
JavaScript 数字格式允许您精确表示介于−9,007,199,254,740,992(−2⁵³)和 9,007,199,254,740,992(2⁵³)之间的所有整数,包括这两个数。如果使用大于此值的整数值,可能会失去尾数的精度。但请注意,JavaScript 中的某些操作(如数组索引和第四章中描述的位运算符)是使用 32 位整数执行的。如果需要精确表示更大的整数,请参阅§3.2.5。
当一个数字直接出现在 JavaScript 程序中时,它被称为数字文字。JavaScript 支持几种格式的数字文字,如下面的部分所述。请注意,任何数字文字都可以在前面加上减号(-)以使数字为负数。
3.2.1 整数文字
在 JavaScript 程序中,十进制整数被写为数字序列。例如:
0
3
10000000
除了十进制整数文字,JavaScript 还识别十六进制(基数 16)值。十六进制文字以0x或0X开头,后跟一串十六进制数字。十六进制数字是数字 0 到 9 或字母 a(或 A)到 f(或 F)中的一个,表示值 10 到 15。以下是十六进制整数文字的示例:
0xff // => 255: (15*16 + 15)
0xBADCAFE // => 195939070
在 ES6 及更高版本中,你还可以使用前缀0b和0o(或0B和0O)来表示二进制(基数 2)或八进制(基数 8)中的整数,而不是0x:
0b10101 // => 21: (1*16 + 0*8 + 1*4 + 0*2 + 1*1)
0o377 // => 255: (3*64 + 7*8 + 7*1)
3.2.2 浮点数文字
浮点文字可以有小数点;它们使用实数的传统语法。一个实数由数字的整数部分表示,后跟一个小数点和数字的小数部分。
浮点文字也可以使用指数表示法表示:一个实数后跟字母 e(或 E),后跟一个可选的加号或减号,后跟一个整数指数。这种表示法表示实数乘以 10 的指数次幂。
更简洁地说,语法是:
[*`digits`*][.*`digits`*][(E|e)[(+|-)]*`digits`*]
例如:
3.14
2345.6789
.333333333333333333
6.02e23 // 6.02 × 10²³
1.4738223E-32 // 1.4738223 × 10⁻³²
3.2.3 JavaScript 中的算术
JavaScript 程序使用语言提供的算术运算符与数字一起工作。这些包括+用于加法,-用于减法,*用于乘法,/用于除法,%用于取模(除法后的余数)。ES2016 添加了**用于指数运算。关于这些和其他运算符的详细信息可以在第四章中找到。
除了这些基本算术运算符外,JavaScript 通过一组函数和常量定义为Math对象的属性支持更复杂的数学运算:
Math.pow(2,53) // => 9007199254740992: 2 to the power 53
Math.round(.6) // => 1.0: round to the nearest integer
Math.ceil(.6) // => 1.0: round up to an integer
Math.floor(.6) // => 0.0: round down to an integer
Math.abs(-5) // => 5: absolute value
Math.max(x,y,z) // Return the largest argument
Math.min(x,y,z) // Return the smallest argument
Math.random() // Pseudo-random number x where 0 <= x < 1.0
Math.PI // π: circumference of a circle / diameter
Math.E // e: The base of the natural logarithm
Math.sqrt(3) // => 3**0.5: the square root of 3
Math.pow(3, 1/3) // => 3**(1/3): the cube root of 3
Math.sin(0) // Trigonometry: also Math.cos, Math.atan, etc.
Math.log(10) // Natural logarithm of 10
Math.log(100)/Math.LN10 // Base 10 logarithm of 100
Math.log(512)/Math.LN2 // Base 2 logarithm of 512
Math.exp(3) // Math.E cubed
ES6 在Math对象上定义了更多函数:
Math.cbrt(27) // => 3: cube root
Math.hypot(3, 4) // => 5: square root of sum of squares of all arguments
Math.log10(100) // => 2: Base-10 logarithm
Math.log2(1024) // => 10: Base-2 logarithm
Math.log1p(x) // Natural log of (1+x); accurate for very small x
Math.expm1(x) // Math.exp(x)-1; the inverse of Math.log1p()
Math.sign(x) // -1, 0, or 1 for arguments <, ==, or > 0
Math.imul(2,3) // => 6: optimized multiplication of 32-bit integers
Math.clz32(0xf) // => 28: number of leading zero bits in a 32-bit integer
Math.trunc(3.9) // => 3: convert to an integer by truncating fractional part
Math.fround(x) // Round to nearest 32-bit float number
Math.sinh(x) // Hyperbolic sine. Also Math.cosh(), Math.tanh()
Math.asinh(x) // Hyperbolic arcsine. Also Math.acosh(), Math.atanh()
JavaScript 中的算术运算不会在溢出、下溢或除以零的情况下引发错误。当数值运算的结果大于最大可表示的数(溢出)时,结果是一个特殊的无穷大值,Infinity。同样,当负值的绝对值变得大于最大可表示的负数的绝对值时,结果是负无穷大,-Infinity。无穷大值的行为如你所期望的那样:将它们相加、相减、相乘或相除的结果是一个无穷大值(可能带有相反的符号)。
下溢发生在数值运算的结果接近零而不是最小可表示数时。在这种情况下,JavaScript 返回 0。如果下溢发生在负数中,JavaScript 返回一个称为“负零”的特殊值。这个值几乎与普通零完全无法区分,JavaScript 程序员很少需要检测它。
在 JavaScript 中,除以零不会导致错误:它只是返回正无穷大或负无穷大。然而,有一个例外:零除以零没有明确定义的值,这个操作的结果是特殊的非数字值 NaN。如果尝试将无穷大除以无穷大、对负数取平方根或使用无法转换为数字的非数字操作数进行算术运算,也会产生 NaN。
JavaScript 预定义全局常量 Infinity 和 NaN 分别表示正无穷大和非数字值,并且这些值也作为 Number 对象的属性可用:
Infinity // A positive number too big to represent
Number.POSITIVE_INFINITY // Same value
1/0 // => Infinity
Number.MAX_VALUE * 2 // => Infinity; overflow
-Infinity // A negative number too big to represent
Number.NEGATIVE_INFINITY // The same value
-1/0 // => -Infinity
-Number.MAX_VALUE * 2 // => -Infinity
NaN // The not-a-number value
Number.NaN // The same value, written another way
0/0 // => NaN
Infinity/Infinity // => NaN
Number.MIN_VALUE/2 // => 0: underflow
-Number.MIN_VALUE/2 // => -0: negative zero
-1/Infinity // -> -0: also negative 0
-0
// The following Number properties are defined in ES6
Number.parseInt() // Same as the global parseInt() function
Number.parseFloat() // Same as the global parseFloat() function
Number.isNaN(x) // Is x the NaN value?
Number.isFinite(x) // Is x a number and finite?
Number.isInteger(x) // Is x an integer?
Number.isSafeInteger(x) // Is x an integer -(2**53) < x < 2**53?
Number.MIN_SAFE_INTEGER // => -(2**53 - 1)
Number.MAX_SAFE_INTEGER // => 2**53 - 1
Number.EPSILON // => 2**-52: smallest difference between numbers
在 JavaScript 中,非数字值具有一个不寻常的特征:它与任何其他值(包括自身)都不相等。这意味着您不能写 x === NaN 来确定变量 x 的值是否为 NaN。相反,您必须写 x != x 或 Number.isNaN(x)。只有当 x 的值与全局常量 NaN 相同时,这些表达式才为真。
全局函数 isNaN() 类似于 Number.isNaN()。如果其参数是 NaN,或者该参数是无法转换为数字的非数字值,则返回 true。相关函数 Number.isFinite() 如果其参数是除 NaN、Infinity 或 -Infinity 之外的数字,则返回 true。全局函数 isFinite() 如果其参数是有限数字或可以转换为有限数字,则返回 true。
负零值也有些不寻常。它与正零相等(即使使用 JavaScript 的严格相等测试),这意味着这两个值几乎无法区分,除非用作除数:
let zero = 0; // Regular zero
let negz = -0; // Negative zero
zero === negz // => true: zero and negative zero are equal
1/zero === 1/negz // => false: Infinity and -Infinity are not equal
3.2.4 二进制浮点数和舍入误差
实数有无限多个,但只有有限数量的实数(准确地说是 18,437,736,874,454,810,627)可以被 JavaScript 浮点格式精确表示。这意味着当您在 JavaScript 中使用实数时,该数字的表示通常是实际数字的近似值。
JavaScript 使用的 IEEE-754 浮点表示法(几乎所有现代编程语言都使用)是二进制表示法,可以精确表示分数如 1/2、1/8 和 1/1024。不幸的是,我们最常使用的分数(尤其是在进行财务计算时)是十进制分数:1/10、1/100 等。二进制浮点表示法无法精确表示像 0.1 这样简单的数字。
JavaScript 数字具有足够的精度,可以非常接近地近似 0.1。但是,这个数字无法精确表示可能会导致问题。考虑以下代码:
let x = .3 - .2; // thirty cents minus 20 cents
let y = .2 - .1; // twenty cents minus 10 cents
x === y // => false: the two values are not the same!
x === .1 // => false: .3-.2 is not equal to .1
y === .1 // => true: .2-.1 is equal to .1
由于四舍五入误差,.3 和 .2 的近似值之间的差异并不完全等同于 .2 和 .1 的近似值之间的差异。重要的是要理解这个问题并不特定于 JavaScript:它影响任何使用二进制浮点数的编程语言。此外,请注意代码中的值 x 和 y 非常接近彼此和正确值。计算出的值对于几乎任何目的都是足够的;问题只在我们尝试比较相等值时才会出现。
如果这些浮点数近似值对您的程序有问题,请考虑使用缩放整数。例如,您可以将货币值作为整数分而不是小数美元进行操作。
3.2.5 使用 BigInt 进行任意精度整数运算
JavaScript 的最新特性之一,定义在 ES2020 中,是一种称为 BigInt 的新数值类型。截至 2020 年初,它已经在 Chrome、Firefox、Edge 和 Node 中实现,并且 Safari 中正在进行实现。顾名思义,BigInt 是一个数值类型,其值为整数。JavaScript 主要添加了这种类型,以允许表示 64 位整数,这对于与许多其他编程语言和 API 兼容是必需的。但是 BigInt 值可以有数千甚至数百万位数字,如果你需要处理如此大的数字的话。(但是请注意,BigInt 实现不适用于加密,因为它们不会尝试防止时间攻击。)
BigInt 字面量写为一个由数字组成的字符串,后面跟着一个小写字母 n。默认情况下,它们是以 10 进制表示的,但你可以使用 0b、0o 和 0x 前缀来表示二进制、八进制和十六进制的 BigInt:
1234n // A not-so-big BigInt literal
0b111111n // A binary BigInt
0o7777n // An octal BigInt
0x8000000000000000n // => 2n**63n: A 64-bit integer
你可以将 BigInt() 作为一个函数,用于将常规的 JavaScript 数字或字符串转换为 BigInt 值:
BigInt(Number.MAX_SAFE_INTEGER) // => 9007199254740991n
let string = "1" + "0".repeat(100); // 1 followed by 100 zeros.
BigInt(string) // => 10n**100n: one googol
与 BigInt 值进行算术运算的方式与常规 JavaScript 数字的算术运算类似,只是除法会舍弃任何余数并向下取整(朝着零的方向):
1000n + 2000n // => 3000n
3000n - 2000n // => 1000n
2000n * 3000n // => 6000000n
3000n / 997n // => 3n: the quotient is 3
3000n % 997n // => 9n: and the remainder is 9
(2n ** 131071n) - 1n // A Mersenne prime with 39457 decimal digits
尽管标准的 +、-、*、/、% 和 ** 运算符可以与 BigInt 一起使用,但重要的是要理解,你不能将 BigInt 类型的操作数与常规数字操作数混合使用。这一开始可能看起来令人困惑,但这是有充分理由的。如果一个数值类型比另一个更通用,那么可以很容易地定义混合操作数的算术运算,只需返回更通用类型的值。但是没有一个类型比另一个更通用:BigInt 可以表示非常大的值,使其比常规数字更通用。但 BigInt 只能表示整数,使得常规的 JavaScript 数字类型更通用。这个问题没有解决的方法,所以 JavaScript 通过简单地不允许混合操作数来绕过它。
相比之下,比较运算符可以处理混合数值类型(但请参阅 §3.9.1 了解有关 == 和 === 之间差异的更多信息):
1 < 2n // => true
2 > 1n // => true
0 == 0n // => true
0 === 0n // => false: the === checks for type equality as well
位运算符(在 §4.8.3 中描述)通常与 BigInt 操作数一起使用。然而,Math 对象的函数都不接受 BigInt 操作数。
3.2.6 日期和时间
JavaScript 定义了一个简单的 Date 类来表示和操作表示日期和时间的数字。JavaScript 的日期是对象,但它们也有一个数值表示作为 时间戳,指定自 1970 年 1 月 1 日以来经过的毫秒数:
let timestamp = Date.now(); // The current time as a timestamp (a number).
let now = new Date(); // The current time as a Date object.
let ms = now.getTime(); // Convert to a millisecond timestamp.
let iso = now.toISOString(); // Convert to a string in standard format.
Date 类及其方法在 §11.4 中有详细介绍。但是我们将在 §3.9.3 中再次看到 Date 对象,当我们检查 JavaScript 类型转换的细节时。
3.3 文本
用于表示文本的 JavaScript 类型是 字符串。字符串是一个不可变的有序 16 位值序列,其中每个值通常表示一个 Unicode 字符。字符串的 长度 是它包含的 16 位值的数量。JavaScript 的字符串(以及其数组)使用从零开始的索引:第一个 16 位值位于位置 0,第二个位于位置 1,依此类推。空字符串 是长度为 0 的字符串。JavaScript 没有一个特殊的类型来表示字符串的单个元素。要表示一个单个的 16 位值,只需使用长度为 1 的字符串。
3.3.1 字符串字面量
要在 JavaScript 程序中包含一个字符串,只需将字符串的字符置于匹配的一对单引号、双引号或反引号中(' 或 " 或 `)。双引号字符和反斜线可能包含在由单引号字符分隔的字符串中,由双引号和反斜线分隔的字符串也是如此。以下是字符串文字的示例:
"" // 空字符串:它没有任何字符
'testing'
"3.14"
'name="myform"'
"Wouldn't you prefer O'Reilly's book?"
"τ is the ratio of a circle's circumference to its radius"
`"She said ''hi''", he said.`
使用反引号界定的字符串是 ES6 的一个特性,允许将 JavaScript 表达式嵌入到字符串字面量中(或 插入 到其中)。这种表达式插值语法在 §3.3.4 中有介绍。
JavaScript 的原始版本要求字符串字面量写在单行上,通常会看到 JavaScript 代码通过使用 + 运算符连接单行字符串来创建长字符串。然而,从 ES5 开始,你可以通过在每行的末尾(除了最后一行)加上反斜杠(\)来跨多行书写字符串字面量。反斜杠和其后的换行符不属于字符串字面量的一部分。如果需要在单引号或双引号字符串字面量中包含换行符,可以使用字符序列 \n(在下一节中有介绍)。ES6 的反引号语法允许字符串跨多行书写,此时换行符属于字符串字面量的一部分:
// 一个表示在一行上写的 2 行的字符串:
'two\nlines'
"one\
long\
line"
// 两行字符串分别写在两行上:
`the newline character at the end of this line
is included literally in this string`
请注意,当使用单引号界定字符串时,必须小心处理英语缩写和所有格,例如 can’t 和 O’Reilly’s。由于撇号与单引号字符相同,必须使用反斜杠字符(\)来“转义”出现在单引号字符串中的任何撇号(转义在下一节中有解释)。
在客户端 JavaScript 编程中,JavaScript 代码可能包含 HTML 代码的字符串,而 HTML 代码可能包含 JavaScript 代码的字符串。与 JavaScript 一样,HTML 使用单引号或双引号来界定其字符串。因此,在结合 JavaScript 和 HTML 时,最好使用一种引号风格用于 JavaScript,另一种引号风格用于 HTML。在下面的示例中,“Thank you” 字符串在 JavaScript 表达式中使用单引号引起,然后在 HTML 事件处理程序属性中使用双引号引起:
<button onclick="alert('Thank you')">Click Me</button>
3.3.2 字符串字面量中的转义序列
反斜杠字符(\)在 JavaScript 字符串中有特殊用途。与其后的字符结合,它表示字符串中无法用其他方式表示的字符。例如,\n 是表示换行字符的 转义序列。
另一个之前提到的例子是 \' 转义,表示单引号(或撇号)字符。当需要在包含在单引号中的字符串字面量中包含撇号时,这个转义序列很有用。你可以看到为什么这些被称为转义序列:反斜杠允许你从单引号字符的通常解释中逃脱。你不再使用它来标记字符串的结束,而是将其用作撇号:
'You\'re right, it can\'t be a quote'
表 3-1 列出了 JavaScript 转义序列及其表示的字符。三个转义序列是通用的,可以通过指定其 Unicode 字符代码作为十六进制数来表示任何字符。例如,序列 \xA9 表示版权符号,其 Unicode 编码由十六进制数 A9 给出。类似地,\u 转义表示由四个十六进制数字或在大括号中括起的一到五个数字指定的任意 Unicode 字符:例如,\u03c0 表示字符 π,而 \u{1f600} 表示“笑脸”表情符号。
表 3-1. JavaScript 转义序列
| 序列 | 表示的字符 |
|---|---|
\0 |
NUL 字符 (\u0000) |
\b |
退格符 (\u0008) |
\t |
水平制表符 (\u0009) |
\n |
换行符 (\u000A) |
\v |
垂直制表符 (\u000B) |
\f |
换页符 (\u000C) |
\r |
回车符 (\u000D) |
\" |
双引号 (\u0022) |
\' |
撇号或单引号 (\u0027) |
\\ |
反斜杠 (\u005C) |
\xnn |
由两个十六进制数字 nn 指定的 Unicode 字符 |
\unnnn |
由四个十六进制数字 nnnn 指定的 Unicode 字符 |
\u{n} |
由代码点 n 指定的 Unicode 字符,其中 n 是 0 到 10FFFF 之间的一到六个十六进制数字(ES6) |
如果 \ 字符位于除表 3-1 中显示的字符之外的任何字符之前,则反斜杠将被简单地忽略(尽管语言的未来版本当然可以定义新的转义序列)。例如,\# 与 # 相同。最后,正如前面提到的,ES5 允许在换行符之前放置反斜杠,以便跨多行断开字符串文字。
3.3.3 处理字符串
JavaScript 的内置功能之一是能够连接字符串。如果您使用 + 运算符与数字一起使用,它们会相加。但是如果您在字符串上使用此运算符,则会通过将第二个字符串附加到第一个字符串来连接它们。例如:
let msg = "Hello, " + "world"; // 生成字符串 "Hello, world"
let greeting = "Welcome to my blog," + " " + name;
字符串可以使用标准的 === 相等和 !== 不等运算符进行比较:只有当它们由完全相同的 16 位值序列组成时,两个字符串才相等。字符串也可以使用 <、<=、> 和 >= 运算符进行比较。字符串比较只是简单地比较 16 位值。(有关更健壮的区域感知字符串比较和排序,请参见 §11.7.3。)
要确定字符串的长度——它包含的 16 位值的数量——请使用字符串的 length 属性:
s.length
除了 length 属性之外,JavaScript 还提供了丰富的 API 用于处理字符串:
let s = "Hello, world"; // 以一些文本开头。
// 获取字符串的部分
s.substring(1,4) // => "ell": 第 2、3、4 个字符。
s.slice(1,4) // => "ell": 同上
s.slice(-3) // => "rld": 最后 3 个字符
s.split(", ") // => ["Hello", "world"]: 在分隔符字符串处分割
// 搜索字符串
s.indexOf("l") // => 2: 第一个字母 l 的位置
s.indexOf("l", 3) // => 3: 第一个 "l" 在或之后 3 的位置
s.indexOf("zz") // => -1: s 不包含子字符串 "zz"
s.lastIndexOf("l") // => 10: 最后一个字母 l 的位置
// ES6 及更高版本中的布尔搜索函数
s.startsWith("Hell") // => true: 字符串以这些开头
s.endsWith("!") // => false: s 不以此结尾
s.includes("or") // => true: s 包含子字符串 "or"
// 创建字符串的修改版本
s.replace("llo", "ya") // => "Heya, world"
s.toLowerCase() // => "hello, world"
s.toUpperCase() // => "HELLO, WORLD"
s.normalize() // Unicode NFC 标准化:ES6
s.normalize("NFD") // NFD 标准化。也可用 "NFKC", "NFKD"
// 检查字符串的各个(16 位)字符
s.charAt(0) // => "H": 第一个字符
s.charAt(s.length-1) // => "d": 最后一个字符
s.charCodeAt(0) // => 72: 指定位置的 16 位数字
s.codePointAt(0) // => 72: ES6,适用于大于 16 位的码点
// ES2017 中的字符串填充函数
"x".padStart(3) // => " x": 在左侧添加空格,使长度为 3
"x".padEnd(3) // => "x ": 在右侧添加空格,使长度为 3
"x".padStart(3, "*") // => "**x": 在左侧添加星号,使长度为 3
"x".padEnd(3, "-") // => "x--": 在右侧添加破折号,使长度为 3
// 修剪空格函数。trim() 是 ES5;其他是 ES2019
" test ".trim() // => "test": 删除开头和结尾的空格
" test ".trimStart() // => "test ": 删除左侧的空格。也可用 trimLeft
" test ".trimEnd() // => " test": 删除右侧的空格。也可用 trimRight
// 其他字符串方法
s.concat("!") // => "Hello, world!": 只需使用 + 运算符
"<>".repeat(5) // => "<><><><><>": 连接 n 个副本。ES6
请记住,在 JavaScript 中字符串是不可变的。像 replace() 和 toUpperCase() 这样的方法会返回新的字符串:它们不会修改调用它们的字符串。
字符串也可以像只读数组一样处理,您可以使用方括号而不是 charAt() 方法从字符串中访问单个字符(16 位值):
let s = "hello, world";
s[0] // => "h"
s[s.length-1] // => "d"
3.3.4 模板字面量
在 ES6 及更高版本中,字符串字面量可以用反引号括起来:
let s = `hello world`;
然而,这不仅仅是另一种字符串字面量语法,因为这些模板字面量可以包含任意的 JavaScript 表达式。反引号中的字符串字面量的最终值是通过评估包含的任何表达式,将这些表达式的值转换为字符串,并将这些计算出的字符串与反引号中的文字字符组合而成的:
let name = "Bill";
let greeting = `Hello ${ name }.`; // greeting == "Hello Bill."
${ 和匹配的 } 之间的所有内容都被解释为 JavaScript 表达式。花括号外的所有内容都是普通的字符串文字。花括号内的表达式被评估,然后转换为字符串并插入到模板中,替换美元符号、花括号和它们之间的所有内容。
模板字面量可以包含任意数量的表达式。它可以使用任何普通字符串可以使用的转义字符,并且可以跨越任意数量的行,不需要特殊的转义。以下模板字面量包括四个 JavaScript 表达式,一个 Unicode 转义序列,以及至少四个换行符(表达式的值也可能包含换行符):
let errorMessage = `\
# \u2718 Test failure at ${filename}:${linenumber}:
${exception.message}
Stack trace:
${exception.stack}
`;
这里第一行末尾的反斜杠转义了初始换行符,使得生成的字符串以 Unicode ✘ 字符 (# \u2718) 开头,而不是一个换行符。
标记模板字面量
模板字面量的一个强大但不常用的特性是,如果一个函数名(或“标签”)紧跟在反引号之前,那么模板字面量中的文本和表达式的值将传递给该函数。标记模板字面量的值是函数的返回值。例如,这可以用来在将值替换到文本之前应用 HTML 或 SQL 转义。
ES6 中有一个内置的标签函数:String.raw()。它返回反引号内的文本,不处理反斜杠转义:
`\n`.length // => 1: 字符串有一个换行符
String.raw`\n`.length // => 2: 一个反斜杠字符和字母 n
请注意,即使标记模板字面量的标签部分是一个函数,也不需要在其调用中使用括号。在这种非常特殊的情况下,反引号字符替换了开放和关闭括号。
定义自己的模板标签函数的能力是 JavaScript 的一个强大特性。这些函数不需要返回字符串,并且可以像构造函数一样使用,就好像为语言定义了一种新的文字语法。我们将在§14.5 中看到一个例子。
3.3.5 模式匹配
JavaScript 定义了一种称为正则表达式(或 RegExp)的数据类型,用于描述和匹配文本字符串中的模式。RegExps 不是 JavaScript 中的基本数据类型之一,但它们具有类似数字和字符串的文字语法,因此有时似乎是基本的。正则表达式文字的语法复杂,它们定义的 API 也不简单。它们在§11.3 中有详细说明。然而,由于 RegExps 功能强大且常用于文本处理,因此本节提供了简要概述。
一对斜杠之间的文本构成正则表达式文字。在一对斜杠中的第二个斜杠后面也可以跟随一个或多个字母,这些字母修改模式的含义。例如:
/^HTML/; // 匹配字符串开头的字母 H T M L
/[1-9][0-9]*/; // 匹配非零数字,后跟任意数量的数字
/\bjavascript\b/i; // 匹配 "javascript" 作为一个单词,不区分大小写
RegExp 对象定义了许多有用的方法,字符串也有接受 RegExp 参数的方法。例如:
let text = "testing: 1, 2, 3"; // 示例文本
let pattern = /\d+/g; // 匹配所有一个或多个数字的实例
pattern.test(text) // => true: 存在匹配项
text.search(pattern) // => 9: 第一个匹配项的位置
text.match(pattern) // => ["1", "2", "3"]: 所有匹配项的数组
text.replace(pattern, "#") // => "testing: #, #, #"
text.split(/\D+/) // => ["","1","2","3"]: 以非数字为分隔符进行分割
3.4 布尔值
布尔值表示真或假,开或关,是或否。此类型仅有两个可能的值。保留字true和false评估为这两个值。
布尔值通常是您在 JavaScript 程序中进行比较的结果。例如:
a === 4
此代码测试变量a的值是否等于数字4。如果是,则此比较的结果是布尔值true。如果a不等于4,则比较的结果是false。
布尔值通常在 JavaScript 控制结构中使用。例如,JavaScript 中的if/else语句在布尔值为true时执行一个操作,在值为false时执行另一个操作。通常将直接创建布尔值的比较与使用它的语句结合在一起。结果如下:
if (a === 4) {
b = b + 1;
} else {
a = a + 1;
}
此代码检查a是否等于4。如果是,则将1添加到b;否则,将1添加到a。
正如我们将在§3.9 中讨论的那样,任何 JavaScript 值都可以转换为布尔值。以下值转换为,并因此像false一样工作:
undefined
null
0
-0
NaN
"" // 空字符串
所有其他值,包括所有对象(和数组)转换为,并像true一样工作。false和转换为它的六个值有时被称为假值,所有其他值被称为真值。每当 JavaScript 期望布尔值时,假值像false一样工作,真值像true一样工作。
例如,假设变量o可以保存对象或值null。您可以使用如下if语句明确测试o是否非空:
if (o !== null) ...
不等运算符!==比较o和null,并评估为true或false。但您可以省略比较,而是依赖于null为假值,对象为真值的事实:
if (o) ...
在第一种情况下,只有当o不是null时,if的主体才会被执行。第二种情况不那么严格:只有当o不是false或任何假值(如null或undefined)时,if的主体才会被执行。哪种if语句适合你的程序实际上取决于你期望为o分配什么值。如果你需要区分null和0以及"",那么你应该使用显式比较。
布尔值有一个toString()方法,你可以用它将它们转换为字符串“true”或“false”,但它们没有其他有用的方法。尽管 API 很简单,但有三个重要的布尔运算符。
&&运算符执行布尔 AND 操作。只有当它的两个操作数都为真时,它才会评估为真;否则它会评估为假。||运算符是布尔 OR 操作:如果它的一个(或两个)操作数为真,则它评估为真,如果两个操作数都为假,则它评估为假。最后,一元!运算符执行布尔 NOT 操作:如果它的操作数为假,则评估为true,如果它的操作数为真,则评估为false。例如:
if ((x === 0 && y === 0) || !(z === 0)) {
// x 和 y 都为零或 z 非零
}
这些运算符的详细信息在§4.10 中。
3.5 null 和 undefined
null是一个语言关键字,其值通常用于指示值的缺失。对null使用typeof运算符会返回字符串“object”,表明null可以被视为指示“没有对象”的特殊对象值。然而,在实践中,null通常被视为其自身类型的唯一成员,并且它可以用于表示数字、字符串以及对象的“无值”。大多数编程语言都有类似 JavaScript 的null的等价物:你可能熟悉它作为NULL、nil或None。
JavaScript 还有第二个表示值缺失的值。undefined值代表一种更深层次的缺失。它是未初始化变量的值,以及查询不存在的对象属性或数组元素的值时得到的值。undefined值也是那些没有显式返回值的函数的返回值,以及没有传递参数的函数参数的值。undefined是一个预定义的全局常量(不像null那样是一个语言关键字,尽管在实践中这并不是一个重要的区别),它被初始化为undefined值。如果你对undefined值应用typeof运算符,它会返回undefined,表明这个值是一个特殊类型的唯一成员。
尽管存在这些差异,null和undefined都表示值的缺失,并且通常可以互换使用。相等运算符==认为它们相等。(使用严格相等运算符===来区分它们。)它们都是假值:当需要布尔值时,它们的行为类似于false。null和undefined都没有任何属性或方法。实际上,使用.或[]来访问这些值的属性或方法会导致 TypeError。
我认为undefined表示系统级别的、意外的或类似错误的值缺失,而null表示程序级别的、正常的或预期的值缺失。我尽量避免使用null和undefined,但如果需要将这些值分配给变量或属性,或者将这些值传递给函数或从函数中返回这些值,我通常使用null。一些程序员努力避免使用null,并在可能的情况下使用undefined代替。
3.6 符号
在 ES6 中引入了符号作为非字符串属性名称。要理解符号,您需要知道 JavaScript 的基本 Object 类型是一个无序的属性集合,其中每个属性都有一个名称和一个值。属性名称通常(直到 ES6 之前一直)是字符串。但在 ES6 及以后的版本中,符号也可以用于此目的:
let strname = "string name"; // 用作属性名称的字符串
let symname = Symbol("propname"); // 用作属性名称的符号
typeof strname // => "string": strname 是一个字符串
typeof symname // => "symbol": symname 是一个符号
let o = {}; // 创建一个新对象
o[strname] = 1; // 使用字符串名称定义属性
o[symname] = 2; // 使用符号名称定义属性
o[strname] // => 1: 访问以字符串命名的属性
o[symname] // => 2: 访问以符号命名的属性
符号类型没有文字语法。要获得符号值,您需要调用Symbol()函数。这个函数永远不会两次返回相同的值,即使使用相同的参数调用。这意味着如果您调用Symbol()来获取一个符号值,您可以安全地将该值用作属性名称,以向对象添加新属性,而不必担心可能会覆盖同名的现有属性。同样,如果使用符号属性名称并且不共享这些符号,您可以确信程序中的其他代码模块不会意外地覆盖您的属性。
在实践中,符号作为一种语言扩展机制。当 ES6 引入了for/of循环(§5.4.4)和可迭代对象(第十二章)时,需要定义标准方法,使类能够实现自身的可迭代性。但是,标准化任何特定的字符串名称作为此迭代器方法会破坏现有代码,因此使用了一个符号名称。正如我们将在第十二章中看到的,Symbol.iterator是一个符号值,可以用作方法名称,使对象可迭代。
Symbol()函数接受一个可选的字符串参数,并返回一个唯一的符号值。如果提供一个字符串参数,那么该字符串将包含在符号的toString()方法的输出中。但请注意,使用相同的字符串两次调用Symbol()会产生两个完全不同的符号值。
let s = Symbol("sym_x");
s.toString() // => "Symbol(sym_x)"
toString()是 Symbol 实例唯一有趣的方法。但是,还有另外两个与 Symbol 相关的函数您应该了解。有时在使用 Symbols 时,您希望将它们私有化,以确保您的属性永远不会与其他代码使用的属性发生冲突。但是,有时您可能希望定义一个 Symbol 值并与其他代码广泛共享。例如,如果您正在定义某种扩展,希望其他代码能够参与其中,那么就会出现这种情况,就像之前描述的Symbol.iterator机制一样。
为了满足后一种用例,JavaScript 定义了一个全局 Symbol 注册表。Symbol.for()函数接受一个字符串参数,并返回与您传递的字符串关联的 Symbol 值。如果该字符串尚未关联任何 Symbol,则会创建并返回一个新的 Symbol;否则,将返回已存在的 Symbol。也就是说,Symbol.for()函数与Symbol()函数完全不同:Symbol()永远不会两次返回相同的值,但Symbol.for()在使用相同字符串调用时总是返回相同的值。传递给Symbol.for()的字符串将出现在返回的 Symbol 的toString()输出中,并且还可以通过在返回的 Symbol 上调用Symbol.keyFor()来检索。
let s = Symbol.for("shared");
let t = Symbol.for("shared");
s === t // => true
s.toString() // => "Symbol(shared)"
Symbol.keyFor(t) // => "shared"
3.7 全局对象
前面的章节已经解释了 JavaScript 的原始类型和值。对象类型——对象、数组和函数——将在本书的后面章节中单独讨论。但是现在我们必须介绍一个非常重要的对象值。全局对象是一个常规的 JavaScript 对象,具有非常重要的作用:该对象的属性是 JavaScript 程序可用的全局定义标识符。当 JavaScript 解释器启动(或者每当 Web 浏览器加载新页面时),它会创建一个新的全局对象,并赋予它一组初始属性,用于定义:
-
像
undefined、Infinity和NaN这样的全局常量 -
像
isNaN()、parseInt()(§3.9.2)和eval()(§4.12)这样的全局函数 -
像
Date()、RegExp()、String()、Object()和Array()(§3.9.2)这样的构造函数 -
像 Math 和 JSON(§6.8)这样的全局对象
全局对象的初始属性不是保留字,但应当视为保留字。本章已经描述了一些这些全局属性。其他大部分属性将在本书的其他地方介绍。
在 Node 中,全局对象有一个名为global的属性,其值是全局对象本身,因此在 Node 程序中始终可以通过名称global引用全局对象。
在 Web 浏览器中,Window 对象作为代表浏览器窗口中包含的所有 JavaScript 代码的全局对象。这个全局 Window 对象有一个自引用的window属性,可以用来引用全局对象。Window 对象定义了核心全局属性,但它还定义了许多其他特定于 Web 浏览器和客户端 JavaScript 的全局对象。Web worker 线程(§15.13)具有与其关联的不同全局对象。工作线程中的代码可以将其全局对象称为self。
ES2020 最终将globalThis定义为在任何上下文中引用全局对象的标准方式。截至 2020 年初,这个功能已被所有现代浏览器和 Node 实现。
3.8 不可变的原始值和可变的对象引用
JavaScript 中原始值(undefined、null、布尔值、数字和字符串)和对象(包括数组和函数)之间有一个根本的区别。原始值是不可变的:没有办法改变(或“突变”)原始值。对于数字和布尔值来说,这是显而易见的——改变一个数字的值甚至没有意义。然而,对于字符串来说,情况并不那么明显。由于字符串类似于字符数组,您可能希望能够更改任何指定索引处的字符。实际上,JavaScript 不允许这样做,所有看起来返回修改后字符串的字符串方法实际上都是返回一个新的字符串值。例如:
let s = "hello"; // 从一些小写文本开始
s.toUpperCase(); // 返回"HELLO",但不改变 s
s // => "hello": 原始字符串没有改变
原始值也是按值比较的:只有当它们的值相同时,两个值才相同。对于数字、布尔值、null和undefined来说,这听起来很循环:它们没有其他比较方式。然而,对于字符串来说,情况并不那么明显。如果比较两个不同的字符串值,JavaScript 会将它们视为相等,当且仅当它们的长度相同,并且每个索引处的字符相同。
对象与原始值不同。首先,它们是可变的——它们的值可以改变:
let o = { x: 1 }; // 从一个对象开始
o.x = 2; // 通过更改属性的值来改变它
o.y = 3; // 通过添加新属性再次改变它
let a = [1,2,3]; // 数组也是可变的
a[0] = 0; // 改变数组元素的值
a[3] = 4; // 添加一个新的数组元素
对象不是按值比较的:即使它们具有相同的属性和值,两个不同的对象也不相等。即使它们具有相同顺序的相同元素,两个不同的数组也不相等:
let o = {x: 1}, p = {x: 1}; // 具有相同属性的两个对象
o === p // => false: 不同的对象永远不相等
let a = [], b = []; // 两个不同的空数组
a === b // => false: 不同的数组永远不相等
对象有时被称为引用类型,以区别于 JavaScript 的原始类型。使用这个术语,对象值是引用,我们说对象是按引用比较的:只有当两个对象值引用同一个基础对象时,它们才相同。
let a = []; // 变量 a 指向一个空数组。
let b = a; // 现在 b 指向同一个数组。
b[0] = 1; // 改变变量 b 引用的数组。
a[0] // => 1: 更改也通过变量 a 可见。
a === b // => true: a 和 b 指向同一个对象,所以它们相等。
从这段代码中可以看出,将对象(或数组)赋给变量只是赋予了引用:它并不创建对象的新副本。如果要创建对象或数组的新副本,必须显式复制对象的属性或数组的元素。这个示例演示了使用for循环(§5.4.3):
let a = ["a","b","c"]; // 我们想要复制的数组
let b = []; // 我们将复制到的不同数组
for(let i = 0; i < a.length; i++) { // 对于 a[]的每个索引
b[i] = a[i]; // 将 a 的一个元素复制到 b
}
let c = Array.from(b); // 在 ES6 中,使用 Array.from()复制数组
同样,如果我们想比较两个不同的对象或数组,我们必须比较它们的属性或元素。以下代码定义了一个比较两个数组的函数:
function equalArrays(a, b) {
if (a === b) return true; // 相同的数组是相等的
if (a.length !== b.length) return false; // 不同大小的数组不相等
for(let i = 0; i < a.length; i++) { // 遍历所有元素
if (a[i] !== b[i]) return false; // 如果有任何不同,数组不相等
}
return true; // 否则它们是相等的
}
3.9 类型转换
JavaScript 对所需值的类型非常灵活。我们已经看到了布尔值的情况:当 JavaScript 需要一个布尔值时,您可以提供任何类型的值,JavaScript 将根据需要进行转换。一些值(“真值”)转换为 true,而其他值(“假值”)转换为 false。其他类型也是如此:如果 JavaScript 需要一个字符串,它将把您提供的任何值转换为字符串。如果 JavaScript 需要一个数字,它将尝试将您提供的值转换为数字(或者如果无法执行有意义的转换,则转换为 NaN)。
一些例子:
10 + " objects" // => "10 objects": 数字 10 转换为字符串
"7" * "4" // => 28: 两个字符串都转换为数字
let n = 1 - "x"; // n == NaN; 字符串"x"无法转换为数字
n + " objects" // => "NaN objects": NaN 转换为字符串"NaN"
表 3-2 总结了 JavaScript 中值从一种类型转换为另一种类型的方式。表中的粗体条目突出显示了您可能会感到惊讶的转换。空单元格表示不需要转换,也不执行任何转换。
表 3-2. JavaScript 类型转换
| 值 | 转为字符串 | 转为数字 | 转为布尔值 |
|---|---|---|---|
undefined |
"undefined" |
NaN |
false |
null |
"null" |
0 |
false |
true |
"true" |
1 |
|
false |
"false" |
0 |
|
""(空字符串) |
0 |
false |
|
"1.2"(非空,数值) |
1.2 |
true |
|
"one"(非空,非数字) |
NaN |
true |
|
0 |
"0" |
false |
|
-0 |
"0" |
false |
|
1(有限的,非零) |
"1" |
true |
|
Infinity |
"Infinity" |
true |
|
-Infinity |
"-Infinity" |
true |
|
NaN |
"NaN" |
false |
|
{}(任何对象) |
见 §3.9.3 | 见 §3.9.3 | true |
[](空数组) |
"" |
0 |
true |
[9](一个数值元素) |
"9" |
9 |
true |
['a'](任何其他数组) |
使用 join() 方法 | NaN |
true |
function(){}(任何函数) |
见 §3.9.3 | NaN |
true |
表中显示的原始到原始的转换相对简单。布尔值转换已在第 3.4 节中讨论过。对于所有原始值,字符串转换是明确定义的。转换为数字稍微棘手一点。可以解析为数字的字符串将转换为这些数字。允许前导和尾随空格,但任何不是数字文字的前导或尾随非空格字符会导致字符串到数字的转换产生 NaN。一些数字转换可能看起来令人惊讶:true 转换为 1,false 和空字符串转换为 0。
对象到原始值的转换有点复杂,这是第 3.9.3 节的主题。
3.9.1 转换和相等性
JavaScript 有两个操作符用于测试两个值是否相等。“严格相等操作符”===在不同类型的操作数时不认为它们相等,这几乎总是编码时应该使用的正确操作符。但是因为 JavaScript 在类型转换方面非常灵活,它还定义了==操作符,具有灵活的相等定义。例如,以下所有比较都是真的:
null == undefined // => true: 这两个值被视为相等。
"0" == 0 // => true: 在比较之前,字符串转换为数字。
0 == false // => true: 在比较之前,布尔值转换为数字。
"0" == false // => true: 在比较之前,两个操作数都转换为 0!
§4.9.1 解释了==操作符执行的转换,以确定两个值是否应被视为相等。
请记住,一个值转换为另一个值并不意味着这两个值相等。例如,如果在期望布尔值的地方使用undefined,它会转换为false。但这并不意味着undefined == false。JavaScript 操作符和语句期望各种类型的值,并对这些类型进行转换。if语句将undefined转换为false,但==操作符从不尝试将其操作数转换为布尔值。
3.9.2 显式转换
尽管 JavaScript 会自动执行许多类型转换,但有时你可能需要执行显式转换,或者你可能更喜欢使转换明确以保持代码更清晰。
执行显式类型转换的最简单方法是使用Boolean()、Number()和String()函数:
Number("3") // => 3
String(false) // => "false": 或者使用 false.toString()
Boolean([]) // => true
除了null或undefined之外的任何值都有一个toString()方法,而这个方法的结果通常与String()函数返回的结果相同。
顺便提一下,注意Boolean()、Number()和String()函数也可以被调用——带有new——作为构造函数。如果以这种方式使用它们,你将得到一个行为就像原始布尔值、数字或字符串值的“包装”对象。这些包装对象是 JavaScript 最早期的历史遗留物,实际上从来没有任何好理由使用它们。
某些 JavaScript 操作符执行隐式类型转换,有时会明确用于类型转换的目的。如果+操作符的一个操作数是字符串,则它会将另一个操作数转换为字符串。一元+操作符将其操作数转换为数字。一元!操作符将其操作数转换为布尔值并对其取反。这些事实导致以下类型转换习语,你可能在一些代码中看到:
x + "" // => String(x)
+x // => Number(x)
x-0 // => Number(x)
!!x // => Boolean(x): 注意双重!
在计算机程序中,格式化和解析数字是常见的任务,JavaScript 有专门的函数和方法,可以更精确地控制数字到字符串和字符串到数字的转换。
Number 类定义的toString()方法接受一个可选参数,指定转换的基数或进制。如果不指定参数,转换将以十进制进行。但是,你也可以将数字转换为其他进制(介于 2 和 36 之间)。例如:
let n = 17;
let binary = "0b" + n.toString(2); // 二进制 == "0b10001"
let octal = "0o" + n.toString(8); // 八进制 == "0o21"
let hex = "0x" + n.toString(16); // hex == "0x11"
在处理财务或科学数据时,您可能希望以控制输出中小数位数或有效数字位数的方式将数字转换为字符串,或者您可能希望控制是否使用指数表示法。Number 类定义了三种用于这种数字到字符串转换的方法。toFixed()将数字转换为一个字符串,小数点后有指定数量的数字。它永远不使用指数表示法。toExponential()将数字转换为一个使用指数表示法的字符串,小数点前有一个数字,小数点后有指定数量的数字(这意味着有效数字的数量比您指定的值大一个)。toPrecision()将数字转换为一个具有您指定的有效数字数量的字符串。如果有效数字的数量不足以显示整数部分的全部内容,则使用指数表示法。请注意,这三种方法都会四舍五入尾随数字或根据需要填充零。考虑以下示例:
let n = 123456.789;
n.toFixed(0) // => "123457"
n.toFixed(2) // => "123456.79"
n.toFixed(5) // => "123456.78900"
n.toExponential(1) // => "1.2e+5"
n.toExponential(3) // => "1.235e+5"
n.toPrecision(4) // => "1.235e+5"
n.toPrecision(7) // => "123456.8"
n.toPrecision(10) // => "123456.7890"
除了这里展示的数字格式化方法外,Intl.NumberFormat 类定义了一种更通用的、国际化的数字格式化方法。详细信息请参见§11.7.1。
如果将字符串传递给Number()转换函数,它会尝试将该字符串解析为整数或浮点文字。该函数仅适用于十进制整数,并且不允许包含在文字中的尾随字符。parseInt()和parseFloat()函数(这些是全局函数,不是任何类的方法)更加灵活。parseInt()仅解析整数,而parseFloat()解析整数和浮点数。如果字符串以“0x”或“0X”开头,parseInt()会将其解释为十六进制数。parseInt()和parseFloat()都会跳过前导空格,解析尽可能多的数字字符,并忽略其后的任何内容。如果第一个非空格字符不是有效的数字文字的一部分,它们会返回NaN:
parseInt("3 blind mice") // => 3
parseFloat(" 3.14 meters") // => 3.14
parseInt("-12.34") // => -12
parseInt("0xFF") // => 255
parseInt("0xff") // => 255
parseInt("-0XFF") // => -255
parseFloat(".1") // => 0.1
parseInt("0.1") // => 0
parseInt(".1") // => NaN:整数不能以 "." 开头
parseFloat("$72.47") // => NaN:数字不能以 "$" 开头
parseInt()接受一个可选的第二个参数,指定要解析的数字的基数(进制)。合法值介于 2 和 36 之间。例如:
parseInt("11", 2) // => 3:(1*2 + 1)
parseInt("ff", 16) // => 255:(15*16 + 15)
parseInt("zz", 36) // => 1295:(35*36 + 35)
parseInt("077", 8) // => 63:(7*8 + 7)
parseInt("077", 10) // => 77:(7*10 + 7)
3.9.3 对象到原始值的转换
前面的部分已经解释了如何显式将一种类型的值转换为另一种类型,并解释了 JavaScript 将值从一种原始类型转换为另一种原始类型的隐式转换。本节涵盖了 JavaScript 用于将对象转换为原始值的复杂规则。这部分内容很长,很晦涩,如果这是您第一次阅读本章,可以放心地跳到§3.10。
JavaScript 对象到原始值的转换复杂的一个原因是,某些类型的对象有多个原始表示。例如,日期对象可以被表示为字符串或数值时间戳。JavaScript 规范定义了三种基本算法来将对象转换为原始值:
优先选择字符串
这个算法返回一个原始值,如果可能的话,优先选择一个字符串值。
优先选择数字
这个算法返回一个原始值,如果可能的话,优先选择一个数字。
无偏好
这个算法不表达对所需原始值类型的偏好,类可以定义自己的转换。在内置的 JavaScript 类型中,除了日期类以优先选择字符串算法实现外,其他所有类都以优先选择数字算法实现。
这些对象到原始值的转换算法的实现在本节末尾有解释。然而,首先我们解释一下这些算法在 JavaScript 中是如何使用的。
对象到布尔值的转换
对象到布尔值的转换是微不足道的:所有对象都转换为true。请注意,这种转换不需要使用前述的对象到原始值的算法,并且它确实适用于所有对象,包括空数组甚至包装对象new Boolean(false)。
对象到字符串的转换
当一个对象需要被转换为字符串时,JavaScript 首先使用优先选择字符串算法将其转换为一个原始值,然后根据表 3-2 中的规则将得到的原始值转换为字符串,如果需要的话。
这种转换会发生在例如,如果你将一个对象传递给一个内置函数,该函数期望一个字符串参数,如果你调用String()作为一个转换函数,以及当你将对象插入到模板字面量中时。
对象到数字的转换
当一个对象需要被转换为数字时,JavaScript 首先使用优先选择数字算法将其转换为一个原始值,然后根据表 3-2 中的规则将得到的原始值转换为数字,如果需要的话。
内置的 JavaScript 函数和方法期望数字参数时,将对象参数转换为数字的方式,大多数(参见下面的例外情况)期望数字操作数的 JavaScript 操作符也以这种方式将对象转换为数字。
特殊情况的操作符转换
操作符在第四章中有详细介绍。在这里,我们解释一下那些不使用前述基本对象到字符串和对象到数字转换的特殊情况操作符。
JavaScript 中的+运算符执行数字加法和字符串连接。如果其操作数中有一个是对象,则 JavaScript 会使用no-preference算法将它们转换为原始值。一旦有了两个原始值,它会检查它们的类型。如果任一参数是字符串,则将另一个转换为字符串并连接字符串。否则,将两个参数转换为数字并相加。
==和!=运算符以一种允许类型转换的宽松方式执行相等性和不相等性测试。如果一个操作数是对象,另一个是原始值,这些运算符会使用no-preference算法将对象转换为原始值,然后比较两个原始值。
最后,关系运算符<、<=、>和>=比较它们的操作数的顺序,可用于比较数字和字符串。如果任一操作数是对象,则会使用prefer-number算法将其转换为原始值。但请注意,与对象到数字的转换不同,prefer-number转换返回的原始值不会再转换为数字。
请注意,Date 对象的数字表示可以有意义地使用<和>进行比较,但字符串表示则不行。对于 Date 对象,no-preference算法会转换为字符串,因此 JavaScript 对这些运算符使用prefer-number算法意味着我们可以使用它们来比较两个 Date 对象的顺序。
toString()和 valueOf()方法
所有对象都继承了两个用于对象到原始值转换的转换方法,在我们解释prefer-string、prefer-number和no-preference转换算法之前,我们必须解释这两个方法。
第一个方法是toString(),它的作用是返回对象的字符串表示。默认的toString()方法并不返回一个非常有趣的值(尽管我们会在§14.4.3 中发现它很有用):
({x: 1, y: 2}).toString() // => "[object Object]"
许多类定义了更具体版本的toString()方法。例如,Array 类的toString()方法将每个数组元素转换为字符串,并用逗号将结果字符串连接在一起。Function 类的toString()方法将用户定义的函数转换为 JavaScript 源代码的字符串。Date 类定义了一个toString()方法,返回一个可读的(且可被 JavaScript 解析)日期和时间字符串。RegExp 类定义了一个toString()方法,将 RegExp 对象转换为类似 RegExp 字面量的字符串:
[1,2,3].toString() // => "1,2,3"
(function(x) { f(x); }).toString() // => "function(x) { f(x); }"
/\d+/g.toString() // => "/\\d+/g"
let d = new Date(2020,0,1);
d.toString() // => "Wed Jan 01 2020 00:00:00 GMT-0800 (Pacific Standard Time)"
另一个对象转换函数称为valueOf()。这个方法的作用定义较少:它应该将对象转换为表示该对象的原始值,如果存在这样的原始值。对象是复合值,大多数对象实际上不能用单个原始值表示,因此默认的valueOf()方法只返回对象本身,而不是返回原始值。包装类如 String、Number 和 Boolean 定义了简单返回包装的原始值的valueOf()方法。数组、函数和正则表达式只是继承了默认方法。对于这些类型的实例调用valueOf()只会返回对象本身。Date 类定义了一个valueOf()方法,返回其内部表示的日期:自 1970 年 1 月 1 日以来的毫秒数:
let d = new Date(2010, 0, 1); // 2010 年 1 月 1 日(太平洋时间)
d.valueOf() // => 1262332800000
对象到原始值转换算法
通过解释toString()和valueOf()方法,我们现在可以大致解释三种对象到原始值的算法是如何工作的(完整细节将延迟到§14.4.7):
-
prefer-string算法首先尝试
toString()方法。如果该方法被定义并返回一个原始值,那么 JavaScript 使用该原始值(即使它不是字符串!)。如果toString()不存在或者返回一个对象,那么 JavaScript 尝试valueOf()方法。如果该方法存在并返回一个原始值,那么 JavaScript 使用该值。否则,转换将失败并抛出 TypeError。 -
prefer-number算法类似于prefer-string算法,只是它首先尝试
valueOf(),然后尝试toString()。 -
no-preference算法取决于要转换的对象的类。如果对象是一个 Date 对象,那么 JavaScript 使用prefer-string算法。对于任何其他对象,JavaScript 使用prefer-number算法。
这里描述的规则适用于所有内置的 JavaScript 类型,并且是您自己定义的任何类的默认规则。§14.4.7 解释了如何为您定义的类定义自己的对象到原始值转换算法。
在我们离开这个主题之前,值得注意的是prefer-number转换的细节解释了为什么空数组转换为数字 0,而单元素数组也可以转换为数字:
Number([]) // => 0:这是意外的!
Number([99]) // => 99:真的吗?
对象到数字的转换首先使用prefer-number算法将对象转换为原始值,然后将得到的原始值转换为数字。prefer-number算法首先尝试valueOf(),然后退而求其次使用toString()。但是 Array 类继承了默认的valueOf()方法,它不会返回原始值。因此,当我们尝试将数组转换为数字时,实际上调用了数组的toString()方法。空数组转换为空字符串。空字符串转换为数字 0。包含单个元素的数组转换为该元素的字符串。如果数组包含单个数字,则该数字被转换为字符串,然后再转换为数字。
3.10 变量声明和赋值
计算机编程中最基本的技术之一是使用名称或标识符来表示值。将名称绑定到值可以让我们引用该值并在我们编写的程序中使用它。当我们这样做时,通常说我们正在为变量赋值。术语“变量”意味着可以分配新值:与变量关联的值可能会随着程序运行而变化。如果我们永久地为一个名称分配一个值,那么我们称该名称为常量而不是变量。
在 JavaScript 程序中使用变量或常量之前,必须声明它。在 ES6 及更高版本中,可以使用let和const关键字来声明,我们将在下面解释。在 ES6 之前,变量使用var声明,这更具特殊性,稍后在本节中解释。
3.10.1 使用 let 和 const 进行声明
在现代 JavaScript(ES6 及更高版本)中,变量使用let关键字声明,如下所示:
let i;
let sum;
也可以在单个let语句中声明多个变量:
let i, sum;
在声明变量时给变量赋予初始值是一个良好的编程实践,如果可能的话:
let message = "hello";
let i = 0, j = 0, k = 0;
let x = 2, y = x*x; // 初始化器可以使用先前声明的变量
如果使用let语句时没有指定变量的初始值,那么变量会被声明,但其值为undefined,直到你的代码为其赋值。
若要声明常量而不是变量,请使用const代替let。const的工作方式与let相同,只是在声明时必须初始化常量:
const H0 = 74; // 哈勃常数(km/s/Mpc)
const C = 299792.458; // 真空中的光速(km/s)
const AU = 1.496E8; // 天文单位:到太阳的距离(km)
如其名称所示,常量的值不能被更改,任何尝试这样做都会导致抛出 TypeError。
通常(但不是普遍)约定使用全大写字母的名称来声明常量,例如H0或HTTP_NOT_FOUND,以区分它们与变量。
何时使用 const
关于使用const关键字有两种思路。一种方法是仅将const用于基本上不变的值,比如所示的物理常数,或程序版本号,或用于识别文件类型的字节序列等。另一种方法认识到我们程序中许多所谓的变量实际上在程序运行时根本不会改变。在这种方法中,我们用const声明所有内容,然后如果发现我们实际上想要允许值变化,我们将声明切换为let。这可能有助于通过排除我们不打算的变量的意外更改来防止错误。
在一种方法中,我们仅将const用于绝对不改变的值。在另一种方法中,我们将const用于任何偶然不改变的值。在我的代码中,我更喜欢前一种方法。
在第五章,我们将学习 JavaScript 中的for、for/in和for/of循环语句。每个循环都包括一个循环变量,在循环的每次迭代中都会被分配一个新值。JavaScript 允许我们将循环变量声明为循环语法的一部分,这是另一种常见的使用let的方式:
for(let i = 0, len = data.length; i < len; i++) console.log(data[i]);
for(let datum of data) console.log(datum);
for(let property in object) console.log(property);
也许令人惊讶的是,你也可以使用const来声明for/in和for/of循环的循环“变量”,只要循环体不重新分配新值。在这种情况下,const声明只是表示该值在一个循环迭代期间是常量:
for(const datum of data) console.log(datum);
for(const property in object) console.log(property);
变量和常量作用域
变量的作用域是定义它的程序源代码区域。使用let和const声明的变量和常量是块作用域。这意味着它们仅在let或const语句出现的代码块内定义。JavaScript 类和函数定义是块,if/else语句的主体,while循环,for循环等也是块。粗略地说,如果一个变量或常量在一对花括号内声明,那么这些花括号限定了变量或常量定义的代码区域(尽管在声明变量的let或const语句之前执行的代码行中引用变量或常量是不合法的)。作为for、for/in或for/of循环的一部分声明的变量和常量具有循环体作为它们的作用域,尽管它们在技术上出现在花括号外部。
当一个声明出现在顶层,不在任何代码块内时,我们称之为全局变量或常量,并具有全局作用域。在 Node 和客户端 JavaScript 模块(见第十章)中,全局变量的作用域是定义它的文件。然而,在传统的客户端 JavaScript 中,全局变量的作用域是定义它的 HTML 文档。也就是说:如果一个 <script> 声明了一个全局变量或常量,那么该变量或常量将在该文档中的所有 <script> 元素中定义(或至少在 let 或 const 语句执行后执行的所有脚本中定义)。
重复声明
在同一作用域内使用多个 let 或 const 声明相同名称是语法错误。在嵌套作用域中声明具有相同名称的新变量是合法的(尽管最好避免这种做法):
const x = 1; // 将 x 声明为全局常量
if (x === 1) {
let x = 2; // 在一个块内,x 可能指向不同的值
console.log(x); // 打印 2
}
console.log(x); // 打印 1:我们现在回到了全局范围
let x = 3; // 错误!尝试重新声明 x 的语法错误
声明和类型
如果你习惯于像 C 或 Java 这样的静态类型语言,你可能会认为变量声明的主要目的是指定可以分配给变量的值的类型。但是,正如你所见,JavaScript 的变量声明没有与之关联的类型。² JavaScript 变量可以保存任何类型的值。例如,在 JavaScript 中将一个数字赋给一个变量,然后稍后将一个字符串赋给该变量是完全合法的(但通常是不良的编程风格):
let i = 10;
i = "ten";
3.10.2 使用 var 声明变量
在 ES6 之前的 JavaScript 版本中,声明变量的唯一方式是使用 var 关键字,没有办法声明常量。var 的语法与 let 的语法完全相同:
var x;
var data = [], count = data.length;
for(var i = 0; i < count; i++) console.log(data[i]);
尽管 var 和 let 具有相同的语法,但它们的工作方式有重要的区别:
-
使用
var声明的变量没有块级作用域。相反,它们的作用域是包含函数的主体,无论它们在该函数内嵌套多深。 -
如果在函数体外部使用
var,它会声明一个全局变量。但是用var声明的全局变量与用let声明的全局变量有一个重要的区别。用var声明的全局变量被实现为全局对象的属性(§3.7)。全局对象可以被引用为globalThis。因此,如果你在函数外部写var x = 2;,就像你写了globalThis.x = 2;。但请注意,这个类比并不完美:用全局var声明创建的属性不能被delete运算符删除(§4.13.4)。用let和const声明的全局变量和常量不是全局对象的属性。 -
与使用
let声明的变量不同,使用var可以多次声明同一个变量是合法的。由于var变量具有函数作用域而不是块作用域,这种重新声明实际上是很常见的。变量i经常用于整数值,尤其是作为for循环的索引变量。在具有多个for循环的函数中,每个循环通常以for(var i = 0; ...开始。因为var不将这些变量限定在循环体内,所以每个循环都会(无害地)重新声明和重新初始化相同的变量。 -
var声明中最不寻常的特性之一被称为提升。当使用var声明变量时,声明会被提升(或“提升”)到封闭函数的顶部。变量的初始化仍然在你编写的位置,但变量的定义移动到函数的顶部。因此,使用var声明的变量可以在封闭函数的任何地方使用,而不会出错。如果初始化代码尚未运行,则变量的值可能是undefined,但在变量初始化之前使用变量不会出错。(这可能是一个错误的来源,也是let纠正的重要缺陷之一:如果使用let声明变量但在let语句运行之前尝试使用它,你将收到一个实际的错误,而不仅仅是看到一个undefined值。)
使用未声明的变量
在严格模式(§5.6.3)中,如果尝试使用未声明的变量,在运行代码时会收到一个引用错误。然而,在非严格模式下,如果给一个未用let、const或var声明的名称赋值,你将创建一个新的全局变量。无论你的代码嵌套多深,它都将是一个全局变量,这几乎肯定不是你想要的,容易出错,这也是使用严格模式的最好理由之一!
以这种意外方式创建的全局变量类似于用var声明的全局变量:它们定义了全局对象的属性。但与由正确的var声明定义的属性不同,这些属性可以使用delete运算符(§4.13.4)删除。
3.10.3 解构赋值
ES6 实现了一种称为解构赋值的复合声明和赋值语法。在解构赋值中,等号右侧的值是一个数组或对象(一个“结构化”值),而左侧指定一个或多个变量名,使用一种模仿数组和对象字面量语法的语法。当发生解构赋值时,一个或多个值从右侧的值中被提取(“解构”)并存储到左侧命名的变量中。解构赋值可能最常用于作为const、let或var声明语句的一部分初始化变量,但也可以在常规赋值表达式中进行(使用已经声明的变量)。正如我们将在§8.3.5 中看到的,解构也可以在定义函数参数时使用。
这里是使用值数组的简单解构赋值:
let [x,y] = [1,2]; // 同 let x=1, y=2
[x,y] = [x+1,y+1]; // 同 x = x + 1, y = y + 1
[x,y] = [y,x]; // 交换两个变量的值
[x,y] // => [3,2]:递增和交换的值
注意解构赋值如何使处理返回值数组的函数变得简单:
// 将[x,y]坐标转换为[r,theta]极坐标
function toPolar(x, y) {
return [Math.sqrt(x*x+y*y), Math.atan2(y,x)];
}
// 将极坐标转换为直角坐标
function toCartesian(r, theta) {
return [r*Math.cos(theta), r*Math.sin(theta)];
}
let [r,theta] = toPolar(1.0, 1.0); // r == Math.sqrt(2); theta == Math.PI/4
let [x,y] = toCartesian(r,theta); // [x, y] == [1.0, 1,0]
我们看到变量和常量可以作为 JavaScript 的各种for循环的一部分声明。在这种情况下,也可以在此上下文中使用变量解构。以下是一个代码,循环遍历对象的所有属性的名称/值对,并使用解构赋值将这些对从两个元素数组转换为单独的变量:
let o = { x: 1, y: 2 }; // 我们将循环的对象
for(const [name, value] of Object.entries(o)) {
console.log(name, value); // 打印 "x 1" 和 "y 2"
}
解构赋值的左侧变量数量不必与右侧数组元素数量匹配。左侧的额外变量将被设置为undefined,右侧的额外值将被忽略。左侧变量列表可以包含额外的逗号以跳过右侧的某些值:
let [x,y] = [1]; // x == 1; y == undefined
[x,y] = [1,2,3]; // x == 1; y == 2
[,x,,y] = [1,2,3,4]; // x == 2; y == 4
如果要在解构数组时将所有未使用或剩余的值收集到一个变量中,请在左侧最后一个变量名之前使用三个点(...):
let [x, ...y] = [1,2,3,4]; // y == [2,3,4]
我们将在§8.3.2 中再次看到这种方式使用三个点,用于指示所有剩余的函数参数应该被收集到一个单独的数组中。
解构赋值可以与嵌套数组一起使用。在这种情况下,赋值的左侧应该看起来像一个嵌套数组字面量:
let [a, [b, c]] = [1, [2,2.5], 3]; // a == 1; b == 2; c == 2.5
数组解构的一个强大特性是它实际上并不需要一个数组!您可以在赋值的右侧使用任何可迭代对象(第十二章);任何可以与for/of循环(§5.4.4)一起使用的对象也可以被解构:
let [first, ...rest] = "Hello"; // first == "H"; rest == ["e","l","l","o"]
当右侧是对象值时,也可以执行解构赋值。在这种情况下,赋值的左侧看起来像一个对象字面量:在花括号内用逗号分隔的变量名列表:
let transparent = {r: 0.0, g: 0.0, b: 0.0, a: 1.0}; // 一个 RGBA 颜色
let {r, g, b} = transparent; // r == 0.0; g == 0.0; b == 0.0
下一个示例将全局函数Math对象的函数复制到变量中,这可能简化了大量三角函数的代码:
// 同 const sin=Math.sin, cos=Math.cos, tan=Math.tan
const {sin, cos, tan} = Math;
在这里的代码中请注意,Math对象除了被解构为单独变量的三个属性外,还有许多其他属性。那些未命名的属性将被简单地忽略。如果这个赋值的左侧包含一个不是Math属性的变量,那么该变量将被简单地赋值为undefined。
在这些对象解构示例中,我们选择了与要解构的对象的属性名匹配的变量名。这保持了语法的简单和易于理解,但并非必须。在对象解构赋值的左侧,每个标识符也可以是一个以冒号分隔的标识符对,第一个是要赋值的属性名,第二个是要赋给它的变量名:
// 同 const cosine = Math.cos, tangent = Math.tan;
const { cos: cosine, tan: tangent } = Math;
我发现当变量名和属性名不同时,对象解构语法变得过于复杂,不太实用,我倾向于在这种情况下避免使用简写。如果你选择使用它,请记住属性名始终位于冒号的左侧,无论是在对象字面量中还是在对象解构赋值的左侧。
当与嵌套对象、对象数组或数组对象一起使用时,解构赋值变得更加复杂,但是是合法的:
let points = [{x: 1, y: 2}, {x: 3, y: 4}]; // 一个包含两个点对象的数组
let [{x: x1, y: y1}, {x: x2, y: y2}] = points; // 解构成 4 个变量
(x1 === 1 && y1 === 2 && x2 === 3 && y2 === 4) // => true
或者,我们可以对一个包含数组的对象进行解构:
let points = { p1: [1,2], p2: [3,4] }; // 一个具有 2 个数组属性的对象
let { p1: [x1, y1], p2: [x2, y2] } = points; // 解构成 4 个变量
(x1 === 1 && y1 === 2 && x2 === 3 && y2 === 4) // => true
像这样复杂的解构语法可能很难编写和阅读,你可能最好还是用传统的代码明确地写出你的赋值,比如let x1 = points.p1[0];。
3.11 总结
本章需要记住的一些关键点:
-
如何在 JavaScript 中编写和操作数字和文本字符串。
-
如何处理 JavaScript 的其他基本类型:布尔值、符号、
null和undefined。 -
不可变的基本类型和可变的引用类型之间的区别。
-
JavaScript 如何隐式地将值从一种类型转换为另一种类型,以及你如何在程序中显式地进行转换。
-
如何声明和初始化常量和变量(包括解构赋值),以及你声明的变量和常量的词法作用域。
¹ 这是 Java、C++和大多数现代编程语言中double类型的数字的格式。
² 有一些 JavaScript 的扩展,比如 TypeScript 和 Flow (§17.8),允许在变量声明中指定类型,语法类似于let x: number = 0;。
第四章:表达式和运算符
本章记录了 JavaScript 表达式以及构建许多这些表达式的运算符。表达式 是 JavaScript 的短语,可以 评估 以产生一个值。在程序中直接嵌入的常量是一种非常简单的表达式。变量名也是一个简单表达式,它评估为分配给该变量的任何值。复杂表达式是由简单表达式构建的。例如,一个数组访问表达式由一个评估为数组的表达式、一个开放方括号、一个评估为整数的表达式和一个闭合方括号组成。这个新的、更复杂的表达式评估为存储在指定数组索引处的值。类似地,函数调用表达式由一个评估为函数对象的表达式和零个或多个额外表达式组成,这些额外表达式用作函数的参数。
从简单表达式中构建复杂表达式的最常见方法是使用 运算符。运算符以某种方式结合其操作数的值(通常是两个操作数中的一个)并评估为一个新值。乘法运算符 * 是一个简单的例子。表达式 x * y 评估为表达式 x 和 y 的值的乘积。为简单起见,我们有时说一个运算符 返回 一个值,而不是“评估为”一个值。
本章记录了 JavaScript 的所有运算符,并解释了不使用运算符的表达式(如数组索引和函数调用)。如果您已经了解使用 C 风格语法的其他编程语言,您会发现大多数 JavaScript 表达式和运算符的语法已经很熟悉了。
4.1 主要表达式
最简单的表达式,称为 主要表达式,是那些独立存在的表达式——它们不包括任何更简单的表达式。JavaScript 中的主要表达式是常量或 字面值、某些语言关键字和变量引用。
字面量是直接嵌入到程序中的常量值。它们看起来像这样:
1.23 // A number literal
"hello" // A string literal
/pattern/ // A regular expression literal
JavaScript 中关于数字字面量的语法已在 §3.2 中介绍过。字符串字面量在 §3.3 中有文档记录。正则表达式字面量语法在 §3.3.5 中介绍过,并将在 §11.3 中详细记录。
JavaScript 的一些保留字是主要表达式:
true // Evalutes to the boolean true value
false // Evaluates to the boolean false value
null // Evaluates to the null value
this // Evaluates to the "current" object
我们在 §3.4 和 §3.5 中学习了 true、false 和 null。与其他关键字不同,this 不是一个常量——它在程序中的不同位置评估为不同的值。this 关键字用于面向对象编程。在方法体内,this 评估为调用该方法的对象。查看 §4.5、第八章(特别是 §8.2.2)和 第九章 了解更多关于 this 的内容。
最后,第三种主要表达式是对变量、常量或全局对象属性的引用:
i // Evaluates to the value of the variable i.
sum // Evaluates to the value of the variable sum.
undefined // The value of the "undefined" property of the global object
当程序中出现任何标识符时,JavaScript 假定它是一个变量、常量或全局对象的属性,并查找其值。如果不存在具有该名称的变量,则尝试评估不存在的变量会抛出 ReferenceError。
4.2 对象和数组初始化器
对象 和 数组初始化器 是值为新创建的对象或数组的表达式。这些初始化器表达式有时被称为 对象字面量 和 数组字面量。然而,与真正的字面量不同,它们不是主要表达式,因为它们包括一些指定属性和元素值的子表达式。数组初始化器具有稍微简单的语法,我们将从这些开始。
数组初始化器是方括号内包含的逗号分隔的表达式列表。数组初始化器的值是一个新创建的数组。这个新数组的元素被初始化为逗号分隔表达式的值:
[] // An empty array: no expressions inside brackets means no elements
[1+2,3+4] // A 2-element array. First element is 3, second is 7
数组初始化器中的元素表达式本身可以是数组初始化器,这意味着这些表达式可以创建嵌套数组:
let matrix = [[1,2,3], [4,5,6], [7,8,9]];
数组初始化器中的元素表达式在每次评估数组初始化器时都会被评估。这意味着数组初始化器表达式的值在每次评估时可能会有所不同。
可以通过简单地在逗号之间省略值来在数组文字中包含未定义的元素。例如,以下数组包含五个元素,包括三个未定义的元素:
let sparseArray = [1,,,,5];
在数组初始化器中,最后一个表达式后允许有一个逗号,并且不会创建未定义的元素。然而,对于最后一个表达式之后的索引的任何数组访问表达式都将必然评估为未定义。
对象初始化器表达式类似于数组初始化器表达式,但方括号被花括号替换,每个子表达式前缀都带有属性名和冒号:
let p = { x: 2.3, y: -1.2 }; // An object with 2 properties
let q = {}; // An empty object with no properties
q.x = 2.3; q.y = -1.2; // Now q has the same properties as p
在 ES6 中,对象文字具有更丰富的语法(详细信息请参见§6.10)。对象文字可以嵌套。例如:
let rectangle = {
upperLeft: { x: 2, y: 2 },
lowerRight: { x: 4, y: 5 }
};
我们将在第六章和第七章再次看到对象和数组初始化器。
4.3 函数定义表达式
函数定义表达式 定义了一个 JavaScript 函数,这种表达式的值是新定义的函数。在某种意义上,函数定义表达式是“函数文字”的一种方式,就像对象初始化器是“对象文字”一样。函数定义表达式通常由关键字function后跟一个逗号分隔的零个或多个标识符(参数名称)的列表(在括号中)和一个 JavaScript 代码块(函数体)在花括号中组成。例如:
// This function returns the square of the value passed to it.
let square = function(x) { return x * x; };
函数定义表达式也可以包括函数的名称。函数也可以使用函数语句而不是函数表达式来定义。在 ES6 及更高版本中,函数表达式可以使用紧凑的新“箭头函数”语法。有关函数定义的完整详细信息请参见第八章。
4.4 属性访问表达式
属性访问表达式 评估为对象属性或数组元素的值。JavaScript 为属性访问定义了两种语法:
*`expression`* . *identifier*
*expression* [ *expression* ]
属性访问的第一种风格是一个表达式后跟一个句点和一个标识符。表达式指定对象,标识符指定所需属性的名称。属性访问的第二种风格在第一个表达式(对象或数组)后跟另一个方括号中的表达式。这第二个表达式指定所需属性的名称或所需数组元素的索引。以下是一些具体示例:
let o = {x: 1, y: {z: 3}}; // An example object
let a = [o, 4, [5, 6]]; // An example array that contains the object
o.x // => 1: property x of expression o
o.y.z // => 3: property z of expression o.y
o["x"] // => 1: property x of object o
a[1] // => 4: element at index 1 of expression a
a[2]["1"] // => 6: element at index 1 of expression a[2]
a[0].x // => 1: property x of expression a[0]
使用任一类型的属性访问表达式时,首先评估.或``之前的表达式。如果值为null或undefined,则该表达式会抛出 TypeError,因为这是两个 JavaScript 值,不能具有属性。如果对象表达式后跟一个句点和一个标识符,则查找该标识符命名的属性的值,并成为表达式的整体值。如果对象表达式后跟另一个方括号中的表达式,则评估并转换为字符串。然后,表达式的整体值是由该字符串命名的属性的值。在任一情况下,如果命名属性不存在,则属性访问表达式的值为undefined。
.identifier语法是两种属性访问选项中更简单的一种,但请注意,只有当要访问的属性具有合法标识符名称,并且在编写程序时知道名称时才能使用。如果属性名称包含空格或标点符号,或者是数字(对于数组),则必须使用方括号表示法。当属性名称不是静态的,而是计算结果时,也使用方括号(参见[§6.3.1 中的示例)。
对象及其属性在第六章中有详细介绍,数组及其元素在第七章中有介绍。
4.4.1 条件属性访问
ES2020 添加了两种新的属性访问表达式:
*`expression`* ?. *identifier*
*expression* ?.[ *expression* ]
在 JavaScript 中,值null和undefined是唯一没有属性的两个值。在使用.或[]的常规属性访问表达式中,如果左侧的表达式评估为null或undefined,则会收到 TypeError。您可以使用?.和?.[]语法来防止此类错误。
考虑表达式a?.b。如果a是null或undefined,那么该表达式将评估为undefined,而不会尝试访问属性b。如果a是其他值,则a?.b将评估为a.b的评估结果(如果a没有名为b的属性,则该值将再次为undefined)。
这种形式的属性访问表达式有时被称为“可选链”,因为它也适用于像这样的更长的“链式”属性访问表达式:
let a = { b: null };
a.b?.c.d // => undefined
a是一个对象,因此a.b是一个有效的属性访问表达式。但是a.b的值是null,所以a.b.c会抛出 TypeError。通过使用?.而不是.,我们避免了 TypeError,a.b?.c评估为undefined。这意味着(a.b?.c).d将抛出 TypeError,因为该表达式尝试访问值undefined的属性。但是——这是“可选链”非常重要的一部分——a.b?.c.d(不带括号)简单地评估为undefined,不会抛出错误。这是因为使用?.的属性访问是“短路”的:如果?.左侧的子表达式评估为null或undefined,则整个表达式立即评估为undefined,而不会进一步尝试访问属性。
当然,如果a.b是一个对象,并且该对象没有名为c的属性,则a.b?.c.d将再次抛出 TypeError,我们将需要使用另一种条件属性访问:
let a = { b: {} };
a.b?.c?.d // => undefined
使用?.[]而不是[]也可以进行条件属性访问。在表达式a?.[b][c]中,如果a的值为null或undefined,则整个表达式立即评估为undefined,并且子表达式b和c甚至不会被评估。如果其中任何一个表达式具有副作用,则如果a未定义,则副作用不会发生:
let a; // Oops, we forgot to initialize this variable!
let index = 0;
try {
a[index++]; // Throws TypeError
} catch(e) {
index // => 1: increment occurs before TypeError is thrown
}
a?.[index++] // => undefined: because a is undefined
index // => 1: not incremented because ?.[] short-circuits
a[index++] // !TypeError: can't index undefined.
使用?.和?.[]进行条件属性访问是 JavaScript 的最新功能之一。截至 2020 年初,这种新语法在大多数主要浏览器的当前或测试版本中得到支持。
4.5 调用表达式
调用表达式是 JavaScript 用于调用(或执行)函数或方法的语法。它以标识要调用的函数的函数表达式开头。函数表达式后跟一个开括号,一个逗号分隔的零个或多个参数表达式列表,以及一个闭括号。一些示例:
f(0) // f is the function expression; 0 is the argument expression.
Math.max(x,y,z) // Math.max is the function; x, y, and z are the arguments.
a.sort() // a.sort is the function; there are no arguments.
当调用表达式被评估时,首先评估函数表达式,然后评估参数表达式以生成参数值列表。如果函数表达式的值不是函数,则会抛出 TypeError。接下来,按顺序将参数值分配给函数定义时指定的参数名,然后执行函数体。如果函数使用return语句返回一个值,则该值成为调用表达式的值。否则,调用表达式的值为undefined。有关函数调用的完整详细信息,包括当参数表达式的数量与函数定义中的参数数量不匹配时会发生什么的解释,请参阅第八章。
每个调用表达式都包括一对括号和开括号前的表达式。如果该表达式是一个属性访问表达式,则调用被称为方法调用。在方法调用中,作为属性访问主题的对象或数组在执行函数体时成为this关键字的值。这使得面向对象编程范式成为可能,其中函数(当以这种方式使用时我们称之为“方法”)在其所属对象上操作。详细信息请参阅第九章。
4.5.1 条件调用
在 ES2020 中,你也可以使用?.()而不是()来调用函数。通常当你调用一个函数时,如果括号左侧的表达式为null或undefined或任何其他非函数值,将抛出 TypeError。使用新的?.()调用语法,如果?.左侧的表达式评估为null或undefined,那么整个调用表达式将评估为undefined,不会抛出异常。
数组对象有一个sort()方法,可以选择性地传递一个函数参数,该函数定义了数组元素的期望排序顺序。在 ES2020 之前,如果你想编写一个像sort()这样的方法,它接受一个可选的函数参数,你通常会使用一个if语句来检查函数参数在if体中调用之前是否已定义:
function square(x, log) { // The second argument is an optional function
if (log) { // If the optional function is passed
log(x); // Invoke it
}
return x * x; // Return the square of the argument
}
然而,使用 ES2020 的这种条件调用语法,你可以简单地使用?.()编写函数调用,只有在实际有值可调用时才会发生调用:
function square(x, log) { // The second argument is an optional function
log?.(x); // Call the function if there is one
return x * x; // Return the square of the argument
}
但请注意,?.()仅检查左侧是否为null或undefined。它不验证该值实际上是否为函数。因此,在这个例子中,如果你向square()函数传递两个数字,它仍会抛出异常。
类似于条件属性访问表达式(§4.4.1),带有?.()的函数调用是短路的:如果?.左侧的值为null或undefined,则括号内的参数表达式都不会被评估:
let f = null, x = 0;
try {
f(x++); // Throws TypeError because f is null
} catch(e) {
x // => 1: x gets incremented before the exception is thrown
}
f?.(x++) // => undefined: f is null, but no exception thrown
x // => 1: increment is skipped because of short-circuiting
带有?.()的条件调用表达式对方法和函数同样有效。但是因为方法调用还涉及属性访问,所以值得花点时间确保你理解以下表达式之间的区别:
o.m() // Regular property access, regular invocation
o?.m() // Conditional property access, regular invocation
o.m?.() // Regular property access, conditional invocation
在第一个表达式中,o必须是一个具有属性m且该属性的值必须是一个函数的对象。在第二个表达式中,如果o为null或undefined,则表达式评估为undefined。但如果o有任何其他值,则它必须具有一个值为函数的属性m。在第三个表达式中,o不能为null或undefined。如果它没有属性m,或者该属性的值为null,则整个表达式评估为undefined。
使用?.()进行条件调用是 JavaScript 的最新功能之一。截至 2020 年初,这种新语法在大多数主要浏览器的当前或测试版本中得到支持。
4.6 对象创建表达式
对象创建表达式创建一个新对象,并调用一个函数(称为构造函数)来初始化该对象的属性。对象创建表达式类似于调用表达式,只是它们以关键字new为前缀:
new Object()
new Point(2,3)
如果在对象创建表达式中未传递参数给构造函数,则可以省略空括号对:
new Object
new Date
对象创建表达式的值是新创建的对象。构造函数在第九章中有更详细的解释。
4.7 运算符概述
运算符用于 JavaScript 的算术表达式,比较表达式,逻辑表达式,赋值表达式等。表 4-1 总结了这些运算符,并作为一个方便的参考。
请注意,大多数运算符由标点字符表示,如+和=。但是,有些运算符由关键字表示,如delete和instanceof。关键字运算符是常规运算符,就像用标点符号表示的那些一样;它们只是具有不太简洁的语法。
表 4-1 按运算符优先级进行组织。列出的运算符比最后列出的运算符具有更高的优先级。由水平线分隔的运算符具有不同的优先级级别。标记为 A 的列给出了运算符的结合性,可以是 L(从左到右)或 R(从右到左),列 N 指定了操作数的数量。标记为 Types 的列列出了操作数的预期类型和(在→符号之后)运算符的结果类型。表后面的子章节解释了优先级,结合性和操作数类型的概念。这些运算符本身在讨论之后分别进行了文档化。
表 4-1. JavaScript 运算符
| 运算符 | 操作 | A | N | 类型 |
|---|---|---|---|---|
++ |
前置或后置递增 | R | 1 | lval→num |
-- |
前置或后置递减 | R | 1 | lval→num |
- |
取反数 | R | 1 | num→num |
+ |
转换为数字 | R | 1 | any→num |
~ |
反转位 | R | 1 | int→int |
! |
反转布尔值 | R | 1 | bool→bool |
delete |
删除属性 | R | 1 | lval→bool |
typeof |
确定操作数的类型 | R | 1 | any→str |
void |
返回未定义的值 | R | 1 | any→undef |
** |
指数 | R | 2 | num,num→num |
*, /, % |
乘法,除法,取余 | L | 2 | num,num→num |
+, - |
加法,减法 | L | 2 | num,num→num |
+ |
连接字符串 | L | 2 | str,str→str |
<< |
左移 | L | 2 | int,int→int |
>> |
右移并用符号扩展 | L | 2 | int,int→int |
>>> |
右移并用零扩展 | L | 2 | int,int→int |
<, <=,>, >= |
按数字顺序比较 | L | 2 | num,num→bool |
<, <=,>, >= |
按字母顺序比较 | L | 2 | str,str→bool |
instanceof |
测试对象类 | L | 2 | obj,func→bool |
in |
测试属性是否存在 | L | 2 | any,obj→bool |
== |
测试非严格相等性 | L | 2 | any,any→bool |
!= |
测试非严格不等式 | L | 2 | any,any→bool |
=== |
测试严格相等性 | L | 2 | any,any→bool |
!== |
测试严格不等式 | L | 2 | any,any→bool |
& |
计算按位与 | L | 2 | int,int→int |
^ |
计算按位异或 | L | 2 | int,int→int |
| |
计算按位或 | L | 2 | int,int→int |
&& |
计算逻辑与 | L | 2 | any,any→any |
|| |
计算逻辑或 | L | 2 | any,any→any |
?? |
选择第一个定义的操作数 | L | 2 | any,any→any |
?: |
选择第二或第三个操作数 | R | 3 | bool,any,any→any |
= |
分配给变量或属性 | R | 2 | lval,any→any |
**=, *=, /=, %=, |
运算并赋值 | R | 2 | lval,any→any |
+=, -=, &=, ^=, |=, |
||||
<<=, >>=, >>>= |
||||
, |
丢弃第一个操作数,返回第二个 | L | 2 | any,any→any |
4.7.1 操作数的数量
运算符可以根据它们期望的操作数数量(它们的arity)进行分类。大多数 JavaScript 运算符,如 * 乘法运算符,都是将两个表达式组合成单个更复杂表达式的二元运算符。也就是说,它们期望两个操作数。JavaScript 还支持许多一元运算符,它们将单个表达式转换为单个更复杂表达式。表达式 −x 中的 − 运算符是一个一元运算符,它对操作数 x 执行否定操作。最后,JavaScript 支持一个三元运算符,条件运算符 ?:,它将三个表达式组合成单个表达式。
4.7.2 操作数和结果类型
一些运算符适用于任何类型的值,但大多数期望它们的操作数是特定类型的,并且大多数运算符返回(或计算为)特定类型的值。表 4-1 中的类型列指定了运算符的操作数类型(箭头前)和结果类型(箭头后)。
JavaScript 运算符通常根据需要转换操作数的类型(参见 §3.9)。乘法运算符 * 需要数字操作数,但表达式 "3" * "5" 是合法的,因为 JavaScript 可以将操作数转换为数字。这个表达式的值是数字 15,而不是字符串“15”,当然。还要记住,每个 JavaScript 值都是“真值”或“假值”,因此期望布尔操作数的运算符将使用任何类型的操作数。
一些运算符的行为取决于与它们一起使用的操作数的类型。最值得注意的是,+ 运算符添加数字操作数,但连接字符串操作数。类似地,诸如 < 的比较运算符根据操作数的类型以数字或字母顺序执行比较。各个运算符的描述解释了它们的类型依赖性,并指定它们执行的类型转换。
注意,赋值运算符和 表 4-1 中列出的其他一些运算符期望类型为 lval 的操作数。lvalue 是一个历史术语,意思是“一个可以合法出现在赋值表达式左侧的表达式”。在 JavaScript 中,变量、对象的属性和数组的元素都是 lvalues。
4.7.3 运算符副作用
评估简单表达式如 2 * 3 不会影响程序的状态,程序执行的任何未来计算也不会受到该评估的影响。然而,一些表达式具有副作用,它们的评估可能会影响未来评估的结果。赋值运算符是最明显的例子:如果将一个值赋给变量或属性,那么使用该变量或属性的任何表达式的值都会发生变化。++ 和 -- 递增和递减运算符也类似,因为它们执行隐式赋值。delete 运算符也具有副作用:删除属性就像(但不完全相同于)将 undefined 赋给属性。
没有其他 JavaScript 运算符会产生副作用,但是如果函数调用和对象创建表达式中使用的任何运算符具有副作用,则会产生副作用。
4.7.4 运算符优先级
表 4-1 中列出的运算符按照从高优先级到低优先级的顺序排列,水平线将同一优先级的运算符分组。运算符优先级控制操作执行的顺序。优先级较高的运算符(在表的顶部附近)在优先级较低的运算符(在表的底部附近)之前执行。
考虑以下表达式:
w = x + y*z;
乘法运算符*的优先级高于加法运算符+,因此先执行乘法。此外,赋值运算符=的优先级最低,因此在右侧所有操作完成后执行赋值。
可以通过显式使用括号来覆盖运算符的优先级。要求在上一个示例中首先执行加法,写成:
w = (x + y)*z;
注意,属性访问和调用表达式的优先级高于表 4-1 中列出的任何运算符。考虑以下表达式:
// my is an object with a property named functions whose value is an
// array of functions. We invoke function number x, passing it argument
// y, and then we ask for the type of the value returned.
typeof my.functionsx
尽管typeof是优先级最高的运算符之一,但typeof操作是在属性访问、数组索引和函数调用的结果上执行的,所有这些操作的优先级都高于运算符。
实际上,如果您对运算符的优先级有任何疑问,最简单的方法是使用括号使评估顺序明确。重要的规则是:乘法和除法在加法和减法之前执行。赋值的优先级非常低,几乎总是最后执行。
当新的运算符添加到 JavaScript 时,它们并不总是自然地适应这个优先级方案。??运算符(§4.13.2)在表中显示为比||和&&低优先级,但实际上,它相对于这些运算符的优先级没有定义,并且 ES2020 要求您在混合??与||或&&时明确使用括号。同样,新的**乘幂运算符相对于一元否定运算符没有明确定义的优先级,当将否定与乘幂结合时,必须使用括号。
4.7.5 运算符结合性
在表 4-1 中,标记为 A 的列指定了运算符的结合性。L 值指定左到右的结合性,R 值指定右到左的结合性。运算符的结合性指定了相同优先级操作的执行顺序。左到右的结合性意味着操作从左到右执行。例如,减法运算符具有左到右的结合性,因此:
w = x - y - z;
等同于:
w = ((x - y) - z);
另一方面,以下表达式:
y = a ** b ** c;
x = ~-y;
w = x = y = z;
q = a?b:c?d:e?f:g;
等同于:
y = (a ** (b ** c));
x = ~(-y);
w = (x = (y = z));
q = a?b:(c?d:(e?f:g));
因为乘幂、一元、赋值和三元条件运算符具有从右到左的结合性。
4.7.6 评估顺序
运算符的优先级和结合性指定复杂表达式中操作的执行顺序,但它们不指定子表达式的评估顺序。JavaScript 总是严格按照从左到右的顺序评估表达式。例如,在表达式w = x + y * z中,首先评估子表达式w,然后是x、y和z。然后将y和z的值相乘,加上x的值,并将结果赋给表达式w指定的变量或属性。添加括号可以改变乘法、加法和赋值的相对顺序,但不能改变从左到右的评估顺序。
评估顺序只有在正在评估的任何表达式具有影响另一个表达式值的副作用时才会有所不同。如果表达式x增加了一个被表达式z使用的变量,那么评估x在z之前的事实就很重要。
4.8 算术表达式
本节涵盖对操作数执行算术或其他数值操作的运算符。乘幂、乘法、除法和减法运算符是直接的,并且首先进行讨论。加法运算符有自己的子节,因为它还可以执行字符串连接,并且具有一些不寻常的类型转换规则。一元运算符和位运算符也有自己的子节。
这些算术运算符中的大多数(除非另有说明如下)可以与 BigInt(参见 §3.2.5)操作数或常规数字一起使用,只要不混合这两种类型。
基本算术运算符包括 **(指数运算),*(乘法),/(除法),%(取模:除法后的余数),+(加法)和 -(减法)。正如前面所述,我们将在单独的章节讨论 + 运算符。其他五个基本运算符只是评估它们的操作数,必要时将值转换为数字,然后计算幂、乘积、商、余数或差。无法转换为数字的非数字操作数将转换为 NaN 值。如果任一操作数为(或转换为)NaN,则操作的结果(几乎总是)为 NaN。
** 运算符的优先级高于 *,/ 和 %(这些运算符的优先级又高于 + 和 -)。与其他运算符不同,** 从右到左工作,因此 2**2**3 等同于 2**8,而不是 4**3。表达式 -3**2 存在自然的歧义。根据一元减号和指数运算符的相对优先级,该表达式可能表示 (-3)**2 或 -(3**2)。不同的语言处理方式不同,而 JavaScript 简单地使得在这种情况下省略括号成为语法错误,强制您编写一个明确的表达式。** 是 JavaScript 最新的算术运算符:它是在 ES2016 版本中添加到语言中的。然而,Math.pow() 函数自最早版本的 JavaScript 就已经可用,并且执行的操作与 ** 运算符完全相同。
/ 运算符将其第一个操作数除以第二个操作数。如果您习惯于区分整数和浮点数的编程语言,当您将一个整数除以另一个整数时,您可能期望得到一个整数结果。然而,在 JavaScript 中,所有数字都是浮点数,因此所有除法操作都具有浮点结果:5/2 的结果为 2.5,而不是 2。除以零会产生正无穷大或负无穷大,而 0/0 的结果为 NaN:这两种情况都不会引发错误。
% 运算符计算第一个操作数对第二个操作数的模。换句话说,它返回第一个操作数除以第二个操作数的整数除法后的余数。结果的符号与第一个操作数的符号相同。例如,5 % 2 的结果为 1,-5 % 2 的结果为 -1。
尽管取模运算符通常用于整数操作数,但它也适用于浮点值。例如,6.5 % 2.1 的结果为 0.2。
4.8.1 + 运算符
二元 + 运算符添加数字操作数或连接字符串操作数:
1 + 2 // => 3
"hello" + " " + "there" // => "hello there"
"1" + "2" // => "12"
当两个操作数的值都是数字,或者都是字符串时,+ 运算符的作用是显而易见的。然而,在任何其他情况下,都需要进行类型转换,并且要执行的操作取决于所执行的转换。+ 的转换规则优先考虑字符串连接:如果其中一个操作数是字符串或可转换为字符串的对象,则另一个操作数将被转换为字符串并执行连接。只有当两个操作数都不像字符串时才执行加法。
技术上,+ 运算符的行为如下:
-
如果其操作数值中的任一值为对象,则它将使用 §3.9.3 中描述的对象转换为原始值算法将其转换为原始值。日期对象通过其
toString()方法转换,而所有其他对象通过valueOf()转换,如果该方法返回原始值。然而,大多数对象没有有用的valueOf()方法,因此它们也通过toString()转换。 -
在对象转换为原始值之后,如果其中一个操作数是字符串,则另一个操作数将被转换为字符串并执行连接。
-
否则,两个操作数将被转换为数字(或
NaN),然后执行加法。
以下是一些示例:
1 + 2 // => 3: addition
"1" + "2" // => "12": concatenation
"1" + 2 // => "12": concatenation after number-to-string
1 + {} // => "1[object Object]": concatenation after object-to-string
true + true // => 2: addition after boolean-to-number
2 + null // => 2: addition after null converts to 0
2 + undefined // => NaN: addition after undefined converts to NaN
最后,重要的是要注意,当 + 运算符与字符串和数字一起使用时,它可能不是结合的。也就是说,结果可能取决于操作执行的顺序。
例如:
1 + 2 + " blind mice" // => "3 blind mice"
1 + (2 + " blind mice") // => "12 blind mice"
第一行没有括号,+ 运算符具有从左到右的结合性,因此先将两个数字相加,然后将它们的和与字符串连接起来。在第二行中,括号改变了操作顺序:数字 2 与字符串连接以产生一个新字符串。然后数字 1 与新字符串连接以产生最终结果。
4.8.2 一元算术运算符
一元运算符修改单个操作数的值以产生一个新值。在 JavaScript 中,所有一元运算符都具有高优先级,并且都是右结合的。本节描述的算术一元运算符(+、-、++ 和 --)都将其单个操作数转换为数字(如果需要的话)。请注意,标点字符 + 和 - 既用作一元运算符又用作二元运算符。
以下是一元算术运算符:
一元加(+)
一元加运算符将其操作数转换为数字(或 NaN)并返回该转换后的值。当与已经是数字的操作数一起使用时,它不会执行任何操作。由于 BigInt 值无法转换为常规数字,因此不能使用此运算符。
一元减(-)
当 - 作为一元运算符使用时,它将其操作数转换为数字(如果需要的话),然后改变结果的符号。
递增(++)
++ 运算符递增(即加 1)其单个操作数,该操作数必须是左值(变量、数组元素或对象的属性)。该运算符将其操作数转换为数字,将 1 添加到该数字,并将递增后的值重新赋给变量、元素或属性。
++ 运算符的返回值取决于其相对于操作数的位置。当在操作数之前使用时,称为前增量运算符,它递增操作数并计算该操作数的递增值。当在操作数之后使用时,称为后增量运算符,它递增其操作数但计算该操作数的未递增值。考虑以下两行代码之间的区别:
let i = 1, j = ++i; // i and j are both 2
let n = 1, m = n++; // n is 2, m is 1
注意表达式 x++ 不总是等同于 x=x+1。++ 运算符永远不会执行字符串连接:它总是将其操作数转换为数字并递增。如果 x 是字符串“1”,++x 是数字 2,但 x+1 是字符串“11”。
还要注意,由于 JavaScript 的自动分号插入,您不能在后增量运算符和其前面的操作数之间插入换行符。如果这样做,JavaScript 将把操作数视为一个独立的完整语句,并在其前插入一个分号。
这个运算符,在其前增量和后增量形式中,最常用于递增控制 for 循环的计数器(§5.4.3)。
递减(--)
-- 运算符期望一个左值操作数。它将操作数的值转换为数字,减去 1,并将减少后的值重新赋给操作数。与 ++ 运算符一样,-- 的返回值取决于其相对于操作数的位置。当在操作数之前使用时,它减少并返回减少后的值。当在操作数之后使用时,它减少操作数但返回未减少的值。在操作数之后使用时,不允许换行符。
4.8.3 位运算符
位运算符对数字的二进制表示中的位进行低级别操作。虽然它们不执行传统的算术运算,但在这里被归类为算术运算符,因为它们对数字操作并返回一个数字值。这四个运算符对操作数的各个位执行布尔代数运算,表现得好像每个操作数中的每个位都是一个布尔值(1=true,0=false)。另外三个位运算符用于左移和右移位。这些运算符在 JavaScript 编程中并不常用,如果你不熟悉整数的二进制表示,包括负整数的二进制补码表示,那么你可能可以跳过这一部分。
位运算符期望整数操作数,并表现得好像这些值被表示为 32 位整数而不是 64 位浮点值。这些运算符将它们的操作数转换为数字,如果需要的话,然后通过丢弃任何小数部分和超过第 32 位的任何位来将数值值强制转换为 32 位整数。移位运算符需要一个右侧操作数,介于 0 和 31 之间。在将此操作数转换为无符号 32 位整数后,它们会丢弃超过第 5 位的任何位,从而得到适当范围内的数字。令人惊讶的是,当这些位运算符的操作数时,NaN、Infinity 和 -Infinity 都会转换为 0。
所有这些位运算符除了 >>> 都可以与常规数字操作数或 BigInt(参见 §3.2.5)操作数一起使用。
位与 (&)
& 运算符对其整数参数的每个位执行布尔与操作。只有在两个操作数中相应的位都设置时,结果中才设置一个位。例如,0x1234 & 0x00FF 的计算结果为 0x0034。
位或 (|)
| 运算符对其整数参数的每个位执行布尔或操作。如果相应的位在一个或两个操作数中的一个或两个中设置,则结果中设置一个位。例如,0x1234 | 0x00FF 的计算结果为 0x12FF。
位异或 (^)
^ 运算符对其整数参数的每个位执行布尔异或操作。异或意味着操作数一为 true 或操作数二为 true,但不是两者都为 true。如果在这个操作的结果中设置了一个相应的位,则表示两个操作数中的一个(但不是两个)中设置了一个位。例如,0xFF00 ^ 0xF0F0 的计算结果为 0x0FF0。
位非 (~)
~ 运算符是一个一元运算符,出现在其单个整数操作数之前。它通过反转操作数中的所有位来运行。由于 JavaScript 中有符号整数的表示方式,将 ~ 运算符应用于一个值等同于改变其符号并减去 1。例如,~0x0F 的计算结果为 0xFFFFFFF0,或者 −16。
左移 (<<)
<< 运算符将其第一个操作数中的所有位向左移动指定的位数,该位数应为介于 0 和 31 之间的整数。例如,在操作 a << 1 中,a 的第一位(个位)变为第二位(十位),a 的第二位变为第三位,依此类推。新的第一位使用零,第 32 位的值丢失。将一个值左移一位等同于乘以 2,将两个位置左移等同于乘以 4,依此类推。例如,7 << 2 的计算结果为 28。
带符号右移 (>>)
>> 运算符将其第一个操作数中的所有位向右移动指定的位数(一个介于 0 和 31 之间的整数)。向右移动的位将丢失。左侧填充的位取决于原始操作数的符号位,以保留结果的符号。如果第一个操作数是正数,则结果的高位为零;如果第一个操作数是负数,则结果的高位为一。向右移动一个正值相当于除以 2(舍弃余数),向右移动两个位置相当于整数除以 4,依此类推。例如,7 >> 1 的结果为 3,但请注意−7 >> 1 的结果为−4。
零填充右移 (>>>)
>>> 运算符与 >> 运算符类似,只是左侧移入的位始终为零,不管第一个操作数的符号如何。当您希望将有符号的 32 位值视为无符号整数时,这很有用。例如,−1 >> 4 的结果为−1,但−1 >>> 4 的结果为0x0FFFFFFF。这是 JavaScript 按位运算符中唯一不能与 BigInt 值一起使用的运算符。BigInt 不通过设置高位来表示负数,而是通过特定的二进制补码表示。
4.9 关系表达式
本节描述了 JavaScript 的关系运算符。这些运算符测试两个值之间的关系(如“相等”,“小于”或“属性”),并根据该关系是否存在返回true或false。关系表达式始终评估为布尔值,并且该值通常用于控制程序执行在if,while和for语句中的流程(参见第五章)。接下来的小节记录了相等和不等运算符,比较运算符以及 JavaScript 的另外两个关系运算符in和instanceof。
4.9.1 相等和不等运算符
== 和 === 运算符检查两个值是否相同,使用两种不同的相同定义。这两个运算符接受任何类型的操作数,并且如果它们的操作数相同则返回true,如果它们不同则返回false。=== 运算符被称为严格相等运算符(有时称为身份运算符),它使用严格的相同定义来检查其两个操作数是否“相同”。== 运算符被称为相等运算符;它使用更宽松的相同定义来检查其两个操作数是否“相等”,允许类型转换。
!= 和 !== 运算符测试== 和 === 运算符的确刚好相反。!= 不等运算符如果两个值根据==相等则返回false,否则返回true。!== 运算符如果两个值严格相等则返回false,否则返回true。正如您将在§4.10 中看到的,! 运算符计算布尔非操作。这使得很容易记住!= 和 !== 代表“不等于”和“不严格相等于”。
如§3.8 中所述,JavaScript 对象通过引用而不是值进行比较。对象等于自身,但不等于任何其他对象。如果两个不同的对象具有相同数量的属性,具有相同名称和值,则它们仍然不相等。同样,具有相同顺序的相同元素的两个数组也不相等。
严格相等
严格相等运算符===评估其操作数,然后按照以下方式比较两个值,不执行任何类型转换:
-
如果两个值具有不同的类型,则它们不相等。
-
如果两个值都是
null或两个值都是undefined,它们是相等的。 -
如果两个值都是布尔值
true或都是布尔值false,它们是相等的。 -
如果一个或两个值是
NaN,它们不相等。(这很令人惊讶,但NaN值永远不等于任何其他值,包括它自己!要检查值x是否为NaN,请使用x !== x或全局的isNaN()函数。) -
如果两个值都是数字且具有相同的值,则它们是相等的。如果一个值是
0,另一个是-0,它们也是相等的。 -
如果两个值都是字符串且包含完全相同的 16 位值(参见§3.3 中的侧边栏)且位置相同,则它们是相等的。如果字符串在长度或内容上有所不同,则它们不相等。两个字符串可能具有相同的含义和相同的视觉外观,但仍然使用不同的 16 位值序列进行编码。JavaScript 不执行 Unicode 规范化,因此这样的一对字符串不被认为等于
===或==运算符。 -
如果两个值引用相同的对象、数组或函数,则它们是相等的。如果它们引用不同的对象,则它们不相等,即使两个对象具有相同的属性。
带类型转换的相等性
相等运算符==类似于严格相等运算符,但它不那么严格。如果两个操作数的值不是相同类型,则它尝试一些类型转换并再次尝试比较:
-
如果两个值具有相同的类型,请按照前面描述的严格相等性进行测试。如果它们严格相等,则它们是相等的。如果它们不严格相等,则它们不相等。
-
如果两个值的类型不同,
==运算符可能仍然认为它们相等。它使用以下规则和类型转换来检查相等性:-
如果一个值是
null,另一个是undefined,它们是相等的。 -
如果一个值是数字,另一个是字符串,则将字符串转换为数字,然后使用转换后的值再次尝试比较。
-
如果任一值为
true,则将其转换为 1,然后再次尝试比较。如果任一值为false,则将其转换为 0,然后再次尝试比较。 -
如果一个值是对象,另一个是数字或字符串,则使用§3.9.3 中描述的算法将对象转换为原始值,然后再次尝试比较。对象通过其
toString()方法或valueOf()方法转换为原始值。核心 JavaScript 的内置类在执行toString()转换之前尝试valueOf()转换,但 Date 类除外,它执行toString()转换。 -
任何其他值的组合都不相等。
-
作为相等性测试的一个例子,考虑比较:
"1" == true // => true
此表达式求值为true,表示这些外观非常不同的值实际上是相等的。布尔值true首先转换为数字 1,然后再次进行比较。接下来,字符串"1"转换为数字 1。由于现在两个值相同,比较返回true。
4.9.2 比较运算符
这些比较运算符测试它们的两个操作数的相对顺序(数字或字母):
小于 (<)
<运算符在其第一个操作数小于第二个操作数时求值为true;否则,求值为false。
大于 (>)
>运算符在其第一个操作数大于第二个操作数时求值为true;否则,求值为false。
小于或等于 (<=)
<=运算符在其第一个操作数小于或等于第二个操作数时求值为true;否则,求值为false。
大于或等于 (>=)
>=运算符在其第一个操作数大于或等于第二个操作数时求值为true;否则,求值为false。
这些比较运算符的操作数可以是任何类型。但是,比较只能在数字和字符串上执行,因此不是数字或字符串的操作数将被转换。
比较和转换如下进行:
-
如果任一操作数评估为对象,则将该对象转换为原始值,如§3.9.3 末尾所述;如果其
valueOf()方法返回原始值,则使用该值。否则,使用其toString()方法的返回值。 -
如果在任何必要的对象到原始值转换后,两个操作数都是字符串,则比较这两个字符串,使用字母顺序,其中“字母顺序”由组成字符串的 16 位 Unicode 值的数值顺序定义。
-
如果在对象到原始值转换后,至少有一个操作数不是字符串,则两个操作数都将转换为数字并进行数值比较。
0和-0被视为相等。Infinity大于除自身以外的任何数字,而-Infinity小于除自身以外的任何数字。如果任一操作数是(或转换为)NaN,则比较运算符始终返回false。尽管算术运算符不允许 BigInt 值与常规数字混合使用,但比较运算符允许数字和 BigInt 之间的比较。
请记住,JavaScript 字符串是 16 位整数值的序列,并且字符串比较只是对两个字符串中的值进行数值比较。Unicode 定义的数值编码顺序可能与任何特定语言或区域设置中使用的传统排序顺序不匹配。特别注意,字符串比较区分大小写,所有大写 ASCII 字母都“小于”所有小写 ASCII 字母。如果您没有预期,此规则可能导致令人困惑的结果。例如,根据<运算符,字符串“Zoo”在字符串“aardvark”之前。
对于更强大的字符串比较算法,请尝试String.localeCompare()方法,该方法还考虑了特定区域设置的字母顺序定义。对于不区分大小写的比较,您可以使用String.toLowerCase()或String.toUpperCase()将字符串转换为全小写或全大写。而且,为了使用更通用且更好本地化的字符串比较工具,请使用§11.7.3 中描述的 Intl.Collator 类。
+运算符和比较运算符对数字和字符串操作数的行为不同。+偏向于字符串:如果任一操作数是字符串,则执行连接操作。比较运算符偏向于数字,只有在两个操作数都是字符串时才执行字符串比较:
1 + 2 // => 3: addition.
"1" + "2" // => "12": concatenation.
"1" + 2 // => "12": 2 is converted to "2".
11 < 3 // => false: numeric comparison.
"11" < "3" // => true: string comparison.
"11" < 3 // => false: numeric comparison, "11" converted to 11.
"one" < 3 // => false: numeric comparison, "one" converted to NaN.
最后,请注意<=(小于或等于)和>=(大于或等于)运算符不依赖于相等或严格相等运算符来确定两个值是否“相等”。相反,小于或等于运算符简单地定义为“不大于”,大于或等于运算符定义为“不小于”。唯一的例外是当任一操作数是(或转换为)NaN时,此时所有四个比较运算符都返回false。
4.9.3 in 运算符
in运算符期望左侧操作数是一个字符串、符号或可转换为字符串的值。它期望右侧操作数是一个对象。如果左侧值是右侧对象的属性名称,则评估为true。例如:
let point = {x: 1, y: 1}; // Define an object
"x" in point // => true: object has property named "x"
"z" in point // => false: object has no "z" property.
"toString" in point // => true: object inherits toString method
let data = [7,8,9]; // An array with elements (indices) 0, 1, and 2
"0" in data // => true: array has an element "0"
1 in data // => true: numbers are converted to strings
3 in data // => false: no element 3
4.9.4 instanceof 运算符
instanceof运算符期望左侧操作数是一个对象,右侧操作数标识对象类。如果左侧对象是右侧类的实例,则运算符评估为true,否则评估为false。第九章解释了在 JavaScript 中,对象类由初始化它们的构造函数定义。因此,instanceof的右侧操作数应该是一个函数。以下是示例:
let d = new Date(); // Create a new object with the Date() constructor
d instanceof Date // => true: d was created with Date()
d instanceof Object // => true: all objects are instances of Object
d instanceof Number // => false: d is not a Number object
let a = [1, 2, 3]; // Create an array with array literal syntax
a instanceof Array // => true: a is an array
a instanceof Object // => true: all arrays are objects
a instanceof RegExp // => false: arrays are not regular expressions
注意所有对象都是Object的实例。instanceof在判断一个对象是否是某个类的实例时会考虑“超类”。如果instanceof的左操作数不是对象,则返回false。如果右操作数不是对象类,则抛出TypeError。
要理解instanceof运算符的工作原理,您必须了解“原型链”。这是 JavaScript 的继承机制,描述在§6.3.2 中。要评估表达式o instanceof f,JavaScript 会评估f.prototype,然后在o的原型链中查找该值。如果找到,则o是f的实例(或f的子类),运算符返回true。如果f.prototype不是o的原型链中的值之一,则o不是f的实例,instanceof返回false。
4.10 逻辑表达式
逻辑运算符&&、||和!执行布尔代数,通常与关系运算符结合使用,将两个关系表达式组合成一个更复杂的表达式。这些运算符在接下来的小节中描述。为了完全理解它们,您可能需要回顾§3.4 中介绍的“真值”和“假值”概念。
4.10.1 逻辑 AND(&&)
&&运算符可以在三个不同级别理解。在最简单的级别上,当与布尔操作数一起使用时,&&对这两个值执行布尔 AND 操作:仅当其第一个操作数和第二个操作数都为true时才返回true。如果其中一个或两个操作数为false,则返回false。
&&经常用作连接两个关系表达式的连接词:
x === 0 && y === 0 // true if, and only if, x and y are both 0
关系表达式始终评估为true或false,因此在这种情况下,&&运算符本身返回true或false。关系运算符的优先级高于&&(和||),因此可以安全地写出不带括号的表达式。
但是&&不要求其操作数是布尔值。回想一下,所有 JavaScript 值都是“真值”或“假值”。(有关详细信息,请参阅§3.4。假值包括false、null、undefined、0、-0、NaN和""。所有其他值,包括所有对象,都是真值。)&&的第二个级别可以理解为真值和假值的布尔 AND 运算符。如果两个操作数都是真值,则运算符返回真值。否则,一个或两个操作数必须是假值,运算符返回假值。在 JavaScript 中,任何期望布尔值的表达式或语句都可以使用真值或假值,因此&&并不总是返回true或false不会造成实际问题。
请注意,此描述指出该运算符返回“真值”或“假值”,但没有指定该值是什么。为此,我们需要在第三个最终级别描述&&。该运算符首先评估其第一个操作数,即左侧的表达式。如果左侧的值为假,整个表达式的值也必须为假,因此&&只返回左侧的值,甚至不评估右侧的表达式。
另一方面,如果左侧的值为真值,则表达式的整体值取决于右侧的值。如果右侧的值为真值,则整体值必须为真值,如果右侧的值为假值,则整体值必须为假值。因此,当左侧的值为真值时,&&运算符评估并返回右侧的值:
let o = {x: 1};
let p = null;
o && o.x // => 1: o is truthy, so return value of o.x
p && p.x // => null: p is falsy, so return it and don't evaluate p.x
重要的是要理解 && 可能会或可能不会评估其右侧操作数。在这个代码示例中,变量 p 被设置为 null,并且表达式 p.x 如果被评估,将导致 TypeError。但是代码以一种惯用的方式使用 &&,以便仅在 p 为真值时才评估 p.x,而不是 null 或 undefined。
&& 的行为有时被称为短路,你可能会看到故意利用这种行为有条件地执行代码的代码。例如,下面两行 JavaScript 代码具有等效的效果:
if (a === b) stop(); // Invoke stop() only if a === b
(a === b) && stop(); // This does the same thing
一般来说,当你在 && 的右侧写一个具有副作用(赋值、递增、递减或函数调用)的表达式时,你必须小心。这些副作用是否发生取决于左侧的值。
尽管这个运算符实际上的工作方式有些复杂,但它最常用作一个简单的布尔代数运算符,适用于真值和假值。
4.10.2 逻辑 OR (||)
|| 运算符对其两个操作数执行布尔 OR 操作。如果一个或两个操作数为真值,则返回真值。如果两个操作数都为假值,则返回假值。
尽管 || 运算符通常被简单地用作布尔 OR 运算符,但它和 && 运算符一样,具有更复杂的行为。它首先评估其第一个操作数,即左侧的表达式。如果这个第一个操作数的值为真值,它会短路并返回该真值,而不会评估右侧的表达式。另一方面,如果第一个操作数的值为假值,则 || 评估其第二个操作数并返回该表达式的值。
与 && 运算符一样,你应该避免包含副作用的右侧操作数,除非你故意想要利用右侧表达式可能不会被评估的事实。
这个运算符的一个惯用用法是在一组备选项中选择第一个真值:
// If maxWidth is truthy, use that. Otherwise, look for a value in
// the preferences object. If that is not truthy, use a hardcoded constant.
let max = maxWidth || preferences.maxWidth || 500;
请注意,如果 0 是 maxWidth 的合法值,则此代码将无法正常工作,因为 0 是一个假值。参见 ?? 运算符(§4.13.2)以获取替代方案。
在 ES6 之前,这种习惯通常用于函数中为参数提供默认值:
// Copy the properties of o to p, and return p
function copy(o, p) {
p = p || {}; // If no object passed for p, use a newly created object.
// function body goes here
}
然而,在 ES6 及以后,这个技巧不再需要,因为默认参数值可以直接写在函数定义中:function copy(o, p={}) { ... }。
4.10.3 逻辑 NOT (!)
! 运算符是一个一元运算符;它放在单个操作数之前。它的目的是反转其操作数的布尔值。例如,如果 x 是真值,!x 评估为 false。如果 x 是假值,则 !x 是 true。
与 && 和 || 运算符不同,! 运算符在反转转换其操作数为布尔值(使用 第三章 中描述的规则)之前。这意味着 ! 总是返回 true 或 false,你可以通过两次应用这个运算符将任何值 x 转换为其等效的布尔值:!!x(参见 §3.9.2)。
作为一元运算符,! 具有高优先级并且紧密绑定。如果你想反转类似 p && q 的表达式的值,你需要使用括号:!(p && q)。值得注意的是,我们可以使用 JavaScript 语法表达布尔代数的两个定律:
// DeMorgan's Laws
!(p && q) === (!p || !q) // => true: for all values of p and q
!(p || q) === (!p && !q) // => true: for all values of p and q
4.11 赋值表达式
JavaScript 使用 = 运算符将一个值分配给一个变量或属性。例如:
i = 0; // Set the variable i to 0.
o.x = 1; // Set the property x of object o to 1.
= 运算符期望其左侧操作数是一个 lvalue:一个变量或对象属性(或数组元素)。它期望其右侧操作数是任何类型的任意值。赋值表达式的值是右侧操作数的值。作为副作用,= 运算符将右侧的值分配给左侧的变量或属性,以便将来对变量或属性的引用评估为该值。
虽然赋值表达式通常相当简单,但有时您可能会看到赋值表达式的值作为更大表达式的一部分使用。例如,您可以使用以下代码在同一表达式中赋值和测试一个值:
(a = b) === 0
如果这样做,请确保您清楚=和===运算符之间的区别!请注意,=的优先级非常低,当赋值的值要在更大的表达式中使用时,通常需要括号。
赋值运算符具有从右到左的结合性,这意味着当表达式中出现多个赋值运算符时,它们将从右到左进行评估。因此,您可以编写如下代码将单个值分配给多个变量:
i = j = k = 0; // Initialize 3 variables to 0
4.11.1 带操作符的赋值
除了正常的=赋值运算符外,JavaScript 还支持许多其他赋值运算符,通过将赋值与其他操作结合起来提供快捷方式。例如,+=运算符执行加法和赋值。以下表达式:
total += salesTax;
等同于这个:
total = total + salesTax;
正如您所期望的那样,+=运算符适用于数字或字符串。对于数字操作数,它执行加法和赋值;对于字符串操作数,它执行连接和赋值。
类似的运算符包括-=、*=、&=等。表 4-2 列出了它们全部。
表 4-2. 赋值运算符
| 运算符 | 示例 | 等价 |
|---|---|---|
+= |
a += b |
a = a + b |
-= |
a -= b |
a = a - b |
*= |
a *= b |
a = a * b |
/= |
a /= b |
a = a / b |
%= |
a %= b |
a = a % b |
**= |
a **= b |
a = a ** b |
<<= |
a <<= b |
a = a << b |
>>= |
a >>= b |
a = a >> b |
>>>= |
a >>>= b |
a = a >>> b |
&= |
a &= b |
a = a & b |
|= |
a |= b |
a = a | b |
^= |
a ^= b |
a = a ^ b |
在大多数情况下,表达式:
a op= b
其中op是一个运算符,等价于表达式:
a = a op b
在第一行中,表达式a被评估一次。在第二行中,它被评估两次。这两种情况只有在a包含函数调用或增量运算符等副作用时才会有所不同。例如,以下两个赋值是不同的:
data[i++] *= 2;
data[i++] = data[i++] * 2;
4.12 评估表达式
与许多解释性语言一样,JavaScript 有解释 JavaScript 源代码字符串并对其进行评估以生成值的能力。JavaScript 使用全局函数eval()来实现这一点:
eval("3+2") // => 5
动态评估源代码字符串是一种强大的语言特性,在实践中几乎从不需要。如果您发现自己使用eval(),您应该仔细考虑是否真的需要使用它。特别是,eval()可能存在安全漏洞,您绝不应将任何源自用户输入的字符串传递给eval()。由于 JavaScript 这样复杂的语言,没有办法对用户输入进行清理以使其安全用于eval()。由于这些安全问题,一些 Web 服务器使用 HTTP 的“内容安全策略”头部来禁用整个网站的eval()。
接下来的小节将解释eval()的基本用法,并解释两个对优化器影响较小的受限版本。
4.12.1 eval()
eval()期望一个参数。如果传递的值不是字符串,则它只是返回该值。如果传递一个字符串,则它尝试将字符串解析为 JavaScript 代码,如果失败则抛出 SyntaxError。如果成功解析字符串,则评估代码并返回字符串中最后一个表达式或语句的值,如果最后一个表达式或语句没有值,则返回undefined。如果评估的字符串引发异常,则该异常从调用eval()传播出来。
eval()的关键之处(在这种情况下调用)是它使用调用它的代码的变量环境。也就是说,它查找变量的值,并以与局部代码相同的方式定义新变量和函数。如果一个函数定义了一个局部变量x,然后调用eval("x"),它将获得局部变量的值。如果它调用eval("x=1"),它会改变局部变量的值。如果函数调用eval("var y = 3;"),它会声明一个新的局部变量y。另一方面,如果被评估的字符串使用let或const,则声明的变量或常量将局部于评估,并不会在调用环境中定义。
类似地,函数可以使用以下代码声明一个局部函数:
eval("function f() { return x+1; }");
如果你从顶层代码调用eval(),它当然会操作全局变量和全局函数。
请注意,传递给eval()的代码字符串必须在语法上是合理的:你不能使用它来将代码片段粘贴到函数中。例如,写eval("return;")是没有意义的,因为return只在函数内部合法,而被评估的字符串使用与调用函数相同的变量环境并不使其成为该函数的一部分。如果你的字符串作为独立脚本是合理的(即使是非常简短的像x=0),那么它是可以传递给eval()的。否则,eval()会抛出 SyntaxError。
4.12.2 全局 eval()
正是eval()改变局部变量的能力让 JavaScript 优化器感到困扰。然而,作为一种解决方法,解释器只是对调用eval()的任何函数进行较少的优化。但是,如果一个脚本定义了eval()的别名,然后通过另一个名称调用该函数,JavaScript 规范声明,当eval()被任何名称调用时,除了“eval”之外,它应该评估字符串,就像它是顶层全局代码一样。被评估的代码可以定义新的全局变量或全局函数,并且可以设置全局变量,但不会使用或修改调用函数的局部变量,因此不会干扰局部优化。
“直接 eval”是使用确切的、未限定名称“eval”调用eval()函数的表达式(开始感觉像是一个保留字)。直接调用eval()使用调用上下文的变量环境。任何其他调用——间接调用——使用全局对象作为其变量环境,不能读取、写入或定义局部变量或函数。(直接和间接调用只能使用var定义新变量。在评估的字符串中使用let和const会创建仅在评估中局部的变量和常量,不会改变调用或全局环境。)
以下代码演示:
const geval = eval; // Using another name does a global eval
let x = "global", y = "global"; // Two global variables
function f() { // This function does a local eval
let x = "local"; // Define a local variable
eval("x += 'changed';"); // Direct eval sets local variable
return x; // Return changed local variable
}
function g() { // This function does a global eval
let y = "local"; // A local variable
geval("y += 'changed';"); // Indirect eval sets global variable
return y; // Return unchanged local variable
}
console.log(f(), x); // Local variable changed: prints "localchanged global":
console.log(g(), y); // Global variable changed: prints "local globalchanged":
请注意,进行全局 eval 的能力不仅仅是为了优化器的需要;实际上,这是一个非常有用的功能,允许你执行字符串代码,就像它们是独立的顶层脚本一样。正如本节开头所述,真正需要评估代码字符串是罕见的。但是如果你确实发现有必要,你更可能想要进行全局 eval 而不是局部 eval。
4.12.3 严格 eval()
严格模式(参见§5.6.3)对eval()函数的行为甚至对标识符“eval”的使用施加了进一步的限制。当从严格模式代码中调用eval(),或者当要评估的代码字符串本身以“use strict”指令开头时,eval()会使用私有变量环境进行局部评估。这意味着在严格模式下,被评估的代码可以查询和设置局部变量,但不能在局部范围内定义新变量或函数。
此外,严格模式使 eval() 更像是一个运算符,有效地将“eval”变成了一个保留字。你不能用新值覆盖 eval() 函数。你也不能声明一个名为“eval”的变量、函数、函数参数或 catch 块参数。
4.13 其他运算符
JavaScript 支持许多其他杂项运算符,详细描述在以下章节。
4.13.1 条件运算符 (?😃
条件运算符是 JavaScript 中唯一的三元运算符,有时实际上被称为三元运算符。这个运算符有时被写为 ?:,尽管在代码中看起来并不完全是这样。因为这个运算符有三个操作数,第一个在 ? 前面,第二个在 ? 和 : 之间,第三个在 : 后面。使用方法如下:
x > 0 ? x : -x // The absolute value of x
条件运算符的操作数可以是任何类型。第一个操作数被评估并解释为布尔值。如果第一个操作数的值为真值,则评估第二个操作数,并返回其值。否则,如果第一个操作数为假值,则评估第三个操作数,并返回其值。第二个和第三个操作数中只有一个被评估;永远不会同时评估两个。
虽然可以使用 if 语句 (§5.3.1) 实现类似的结果,但 ?: 运算符通常提供了一个便捷的快捷方式。以下是一个典型的用法,检查变量是否已定义(并具有有意义的真值),如果是,则使用它,否则提供默认值:
greeting = "hello " + (username ? username : "there");
这等同于以下 if 语句,但更简洁:
greeting = "hello ";
if (username) {
greeting += username;
} else {
greeting += "there";
}
4.13.2 第一个定义的 (??)
第一个定义运算符 ?? 的值为其第一个定义的操作数:如果其左操作数不是 null 且不是 undefined,则返回该值。否则,返回右操作数的值。与 && 和 || 运算符一样,?? 是短路运算:只有在第一个操作数评估为 null 或 undefined 时才评估第二个操作数。如果表达式 a 没有副作用,那么表达式 a ?? b 等效于:
(a !== null && a !== undefined) ? a : b
当你想选择第一个定义的操作数而不是第一个真值操作数时,?? 是 || (§4.10.2) 的一个有用替代。虽然 || 名义上是一个逻辑 OR 运算符,但它也被习惯性地用来选择第一个非假值操作数,例如以下代码:
// If maxWidth is truthy, use that. Otherwise, look for a value in
// the preferences object. If that is not truthy, use a hardcoded constant.
let max = maxWidth || preferences.maxWidth || 500;
这种习惯用法的问题在于零、空字符串和 false 都是假值,在某些情况下可能是完全有效的值。在这个代码示例中,如果 maxWidth 是零,则该值将被忽略。但如果我们将 || 运算符改为 ??,我们最终得到一个零是有效值的表达式:
// If maxWidth is defined, use that. Otherwise, look for a value in
// the preferences object. If that is not defined, use a hardcoded constant.
let max = maxWidth ?? preferences.maxWidth ?? 500;
以下是更多示例,展示了当第一个操作数为假值时 ?? 的工作原理。如果该操作数为假值但已定义,则 ?? 返回它。只有当第一个操作数为“nullish”(即 null 或 undefined)时,该运算符才会评估并返回第二个操作数:
let options = { timeout: 0, title: "", verbose: false, n: null };
options.timeout ?? 1000 // => 0: as defined in the object
options.title ?? "Untitled" // => "": as defined in the object
options.verbose ?? true // => false: as defined in the object
options.quiet ?? false // => false: property is not defined
options.n ?? 10 // => 10: property is null
请注意,如果我们使用 || 而不是 ??,这里的 timeout、title 和 verbose 表达式将具有不同的值。
?? 运算符类似于 && 和 || 运算符,但它的优先级既不高于它们,也不低于它们。如果你在一个表达式中使用它与这些运算符之一,你必须使用显式括号来指定你想要先执行哪个操作:
(a ?? b) || c // ?? first, then ||
a ?? (b || c) // || first, then ??
a ?? b || c // SyntaxError: parentheses are required
?? 运算符由 ES2020 定义,在 2020 年初,所有主要浏览器的当前版本或 beta 版本都新支持该运算符。这个运算符正式称为“nullish coalescing”运算符,但我避免使用这个术语,因为这个运算符选择其操作数之一,但在我看来并没有以任何方式“合并”它们。
4.13.3 typeof 运算符
typeof 是一个一元运算符,放置在其单个操作数之前,该操作数可以是任何类型。它的值是一个指定操作数类型的字符串。Table 4-3 指定了typeof 运算符对任何 JavaScript 值的值。
表 4-3。typeof 运算符返回的值
x |
typeof x |
|---|---|
undefined |
"undefined" |
null |
"object" |
true 或 false |
"boolean" |
任何数字或 NaN |
"number" |
| 任何 BigInt | "bigint" |
| 任何字符串 | "string" |
| 任何符号 | "symbol" |
| 任何函数 | "function" |
| 任何非函数对象 | "object" |
您可能会在表达式中使用typeof 运算符,如下所示:
// If the value is a string, wrap it in quotes, otherwise, convert
(typeof value === "string") ? "'" + value + "'" : value.toString()
注意,如果操作数值为null,typeof 返回“object”。如果要区分null 和对象,您必须明确测试这种特殊情况的值。
尽管 JavaScript 函数是一种对象,但typeof 运算符认为函数与其他对象有足够大的不同,因此它们有自己的返回值。
因为对于除函数之外的所有对象和数组值,typeof 都会评估为“object”,所以它只有在区分对象和其他原始类型时才有用。为了区分一个类的对象与另一个类的对象,您必须使用其他技术,如instanceof 运算符(参见§4.9.4)、class 属性(参见§14.4.3)或constructor 属性(参见§9.2.2 和§14.3)。
4.13.4 delete 运算符
delete 是一个一元运算符,试图删除指定为其操作数的对象属性或数组元素。与赋值、递增和递减运算符一样,delete 通常用于其属性删除副作用,而不是用于其返回的值。一些例子:
let o = { x: 1, y: 2}; // Start with an object
delete o.x; // Delete one of its properties
"x" in o // => false: the property does not exist anymore
let a = [1,2,3]; // Start with an array
delete a[2]; // Delete the last element of the array
2 in a // => false: array element 2 doesn't exist anymore
a.length // => 3: note that array length doesn't change, though
请注意,删除的属性或数组元素不仅仅被设置为undefined 值。当删除属性时,该属性将不再存在。尝试读取不存在的属性会返回undefined,但您可以使用in 运算符(§4.9.3)测试属性的实际存在性。删除数组元素会在数组中留下一个“空洞”,并且不会更改数组的长度。结果数组是稀疏的(§7.3)。
delete 期望其操作数为左值。如果它不是左值,则运算符不起作用并返回true。否则,delete 会尝试删除指定的左值。如果成功删除指定的左值,则delete 返回true。然而,并非所有属性都可以被删除:不可配置的属性(§14.1)不受删除的影响。
在严格模式下,如果其操作数是未经限定的标识符,如变量、函数或函数参数,则delete 会引发 SyntaxError:它仅在操作数为属性访问表达式时起作用(§4.4)。严格模式还指定,如果要删除任何不可配置的(即不可删除的)属性,则delete 会引发 TypeError。在严格模式之外,这些情况不会发生异常,delete 简单地返回false,表示无法删除操作数。
以下是delete 运算符的一些示例用法:
let o = {x: 1, y: 2};
delete o.x; // Delete one of the object properties; returns true.
typeof o.x; // Property does not exist; returns "undefined".
delete o.x; // Delete a nonexistent property; returns true.
delete 1; // This makes no sense, but it just returns true.
// Can't delete a variable; returns false, or SyntaxError in strict mode.
delete o;
// Undeletable property: returns false, or TypeError in strict mode.
delete Object.prototype;
我们将在§6.4 中再次看到delete 运算符。
4.13.5 await 运算符
await在 ES2017 中引入,作为使 JavaScript 中的异步编程更自然的一种方式。您需要阅读第十三章以了解此运算符。简而言之,await期望一个 Promise 对象(表示异步计算)作为其唯一操作数,并使您的程序表现得好像正在等待异步计算完成(但实际上不会阻塞,并且不会阻止其他异步操作同时进行)。await运算符的值是 Promise 对象的完成值。重要的是,await只在使用async关键字声明的函数内部合法。再次查看第十三章获取完整详情。
4.13.6 void 运算符
void是一个一元运算符,出现在其单个操作数之前,该操作数可以是任何类型。这个运算符是不寻常且很少使用的;它评估其操作数,然后丢弃值并返回undefined。由于操作数值被丢弃,只有在操作数具有副作用时使用void运算符才有意义。
void运算符如此隐晦,以至于很难想出其使用的实际示例。一个情况是当您想要定义一个什么都不返回但也使用箭头函数快捷语法的函数时(参见§8.1.3),其中函数体是一个被评估并返回的单个表达式。如果您仅仅为了其副作用而评估表达式,并且不想返回其值,那么最简单的方法是在函数体周围使用大括号。但是,作为替代方案,在这种情况下您也可以使用void运算符:
let counter = 0;
const increment = () => void counter++;
increment() // => undefined
counter // => 1
4.13.7 逗号运算符(,)
逗号运算符是一个二元运算符,其操作数可以是任何类型。它评估其左操作数,评估其右操作数,然后返回右操作数的值。因此,以下行:
i=0, j=1, k=2;
评估为 2,基本上等同于:
i = 0; j = 1; k = 2;
左侧表达式始终被评估,但其值被丢弃,这意味着只有在左侧表达式具有副作用时才有意义使用逗号运算符。逗号运算符通常使用的唯一情况是在具有多个循环变量的for循环(§5.4.3)中:
// The first comma below is part of the syntax of the let statement
// The second comma is the comma operator: it lets us squeeze 2
// expressions (i++ and j--) into a statement (the for loop) that expects 1.
for(let i=0,j=10; i < j; i++,j--) {
console.log(i+j);
}
4.14 总结
本章涵盖了各种主题,并且这里有很多参考资料,您可能希望在未来继续学习 JavaScript 时重新阅读。然而,需要记住的一些关键点是:
-
表达式是 JavaScript 程序的短语。
-
任何表达式都可以评估为 JavaScript 值。
-
表达式除了产生一个值外,还可能具有副作用(如变量赋值)。
-
简单表达式,如文字,变量引用和属性访问,可以与运算符结合以产生更大的表达式。
-
JavaScript 定义了用于算术,比较,布尔逻辑,赋值和位操作的运算符,以及一些其他运算符,包括三元条件运算符。
-
JavaScript
+运算符用于添加数字和连接字符串。 -
逻辑运算符
&&和||具有特殊的“短路”行为,有时只评估它们的一个参数。常见的 JavaScript 习语要求您了解这些运算符的特殊行为。
第五章:语句
第四章将表达式描述为 JavaScript 短语。按照这个类比,语句是 JavaScript 句子或命令。就像英语句子用句号终止并用句号分隔开一样,JavaScript 语句用分号终止(§2.6)。表达式被评估以产生一个值,但语句被执行以使某事发生。
使某事发生的一种方法是评估具有副作用的表达式。具有副作用的表达式,如赋值和函数调用,可以独立作为语句存在,当以这种方式使用时被称为表达式语句。另一类语句是声明语句,它声明新变量并定义新函数。
JavaScript 程序只不过是一系列要执行的语句。默认情况下,JavaScript 解释器按照它们编写的顺序一个接一个地执行这些语句。改变这种默认执行顺序的另一种方法是使用 JavaScript 中的一些语句或控制结构:
条件语句
诸如if和switch这样的语句根据表达式的值使 JavaScript 解释器执行或跳过其他语句
循环
诸如while和for这样重复执行其他语句的语句
跳转
诸如break、return和throw这样的语句会导致解释器跳转到程序的另一个部分
接下来的章节描述了 JavaScript 中的各种语句并解释了它们的语法。表 5-1 在本章末尾总结了语法。JavaScript 程序只不过是一系列语句,用分号分隔开,因此一旦熟悉了 JavaScript 的语句,就可以开始编写 JavaScript 程序。
5.1 表达式语句
JavaScript 中最简单的语句是具有副作用的表达式。这种语句在第四章中有所展示。赋值语句是表达式语句的一个主要类别。例如:
greeting = "Hello " + name;
i *= 3;
递增和递减运算符++和--与赋值语句相关。它们具有改变变量值的副作用,就像执行了一个赋值一样:
counter++;
delete 运算符的重要副作用是删除对象属性。因此,它几乎总是作为语句使用,而不是作为更大表达式的一部分:
delete o.x;
函数调用是另一种重要的表达式语句。例如:
console.log(debugMessage);
displaySpinner(); // A hypothetical function to display a spinner in a web app.
这些函数调用是表达式,但它们具有影响主机环境或程序状态的副作用,并且在这里被用作语句。如果一个函数没有任何副作用,那么调用它就没有意义,除非它是更大表达式或赋值语句的一部分。例如,你不会仅仅计算余弦值然后丢弃结果:
Math.cos(x);
但你可能会计算值并将其赋给一个变量以备将来使用:
cx = Math.cos(x);
请注意,这些示例中的每行代码都以分号结束。
5.2 复合语句和空语句
就像逗号运算符(§4.13.7)将多个表达式组合成一个单一表达式一样,语句块将多个语句组合成一个复合语句。语句块只是一系列语句被花括号包围起来。因此,以下行作为单个语句,并可以在 JavaScript 需要单个语句的任何地方使用:
{
x = Math.PI;
cx = Math.cos(x);
console.log("cos(π) = " + cx);
}
关于这个语句块有几点需要注意。首先,它不以分号结束。块内的原始语句以分号结束,但块本身不以分号结束。其次,块内的行相对于包围它们的花括号缩进。这是可选的,但它使代码更易于阅读和理解。
就像表达式经常包含子表达式一样,许多 JavaScript 语句包含子语句。形式上,JavaScript 语法通常允许单个子语句。例如,while循环语法包括一个作为循环体的单个语句。使用语句块,您可以在这个单个允许的子语句中放置任意数量的语句。
复合语句允许您在 JavaScript 语法期望单个语句的地方使用多个语句。空语句则相反:它允许您在期望一个语句的地方不包含任何语句。空语句如下所示:
;
当执行空语句时,JavaScript 解释器不会采取任何操作。空语句偶尔在您想要创建一个空循环体的循环时很有用。考虑以下for循环(for循环将在§5.4.3 中介绍):
// Initialize an array a
for(let i = 0; i < a.length; a[i++] = 0) ;
在这个循环中,所有工作都由表达式a[i++] = 0完成,不需要循环体。然而,JavaScript 语法要求循环体作为一个语句,因此使用了一个空语句——只是一个裸分号。
请注意,在for循环、while循环或if语句的右括号后意外包含分号可能导致难以检测的令人沮丧的错误。例如,以下代码可能不会按照作者的意图执行:
if ((a === 0) || (b === 0)); // Oops! This line does nothing...
o = null; // and this line is always executed.
当您有意使用空语句时,最好以一种清晰表明您是有意这样做的方式对代码进行注释。例如:
for(let i = 0; i < a.length; a[i++] = 0) /* empty */ ;
5.3 条件语句
条件语句根据指定表达式的值执行或跳过其他语句。这些语句是您代码的决策点,有时也被称为“分支”。如果想象一个 JavaScript 解释器沿着代码路径执行,条件语句是代码分支成两个或多个路径的地方,解释器必须选择要遵循的路径。
以下小节解释了 JavaScript 的基本条件语句if/else,并介绍了更复杂的多路分支语句switch。
5.3.1 if
if语句是允许 JavaScript 做出决策的基本控制语句,更准确地说,是有条件地执行语句。该语句有两种形式。第一种是:
if (*`expression`*)
*`statement`*
在这种形式中,expression被评估。如果结果值为真值,将执行statement。如果expression为假值,则不执行statement。(有关真值和假值的定义,请参见§3.4。)例如:
if (username == null) // If username is null or undefined,
username = "John Doe"; // define it
或者类似地:
// If username is null, undefined, false, 0, "", or NaN, give it a new value
if (!username) username = "John Doe";
请注意,围绕expression的括号是if语句语法的必需部分。
JavaScript 语法要求在if关键字和括号表达式之后有一个语句,但您可以使用语句块将多个语句组合成一个。因此,if语句也可能如下所示:
if (!address) {
address = "";
message = "Please specify a mailing address.";
}
第二种形式的if语句引入了一个else子句,当expression为false时执行。其语法如下:
if (*`expression`*)
*`statement1`*
else
*`statement2`*
该语句形式在expression为真值时执行statement1,在expression为假值时执行statement2。例如:
if (n === 1)
console.log("You have 1 new message.");
else
console.log(`You have ${n} new messages.`);
当您有嵌套的带有else子句的if语句时,需要谨慎确保else子句与适当的if语句配对。考虑以下行:
i = j = 1;
k = 2;
if (i === j)
if (j === k)
console.log("i equals k");
else
console.log("i doesn't equal j"); // WRONG!!
在这个例子中,内部的if语句形成了外部if语句语法允许的单个语句。不幸的是,不清楚(除了缩进给出的提示外)else与哪个if配对。而且在这个例子中,缩进是错误的,因为 JavaScript 解释器实际上将前一个例子解释为:
if (i === j) {
if (j === k)
console.log("i equals k");
else
console.log("i doesn't equal j"); // OOPS!
}
JavaScript(与大多数编程语言一样)的规则是,默认情况下else子句是最近的if语句的一部分。为了使这个例子不那么模棱两可,更容易阅读、理解、维护和调试,您应该使用花括号:
if (i === j) {
if (j === k) {
console.log("i equals k");
}
} else { // What a difference the location of a curly brace makes!
console.log("i doesn't equal j");
}
许多程序员习惯将 if 和 else 语句的主体(以及其他复合语句,如 while 循环)放在花括号中,即使主体只包含一个语句。始终如此可以防止刚才显示的问题,我建议你采用这种做法。在这本印刷书中,我非常重视保持示例代码的垂直紧凑性,并且并不总是遵循自己在这个问题上的建议。
5.3.2 else if
if/else 语句评估一个表达式并根据结果执行两个代码块中的一个。但是当你需要执行多个代码块中的一个时怎么办?一种方法是使用 else if 语句。else if 实际上不是一个 JavaScript 语句,而只是一个经常使用的编程习惯,当使用重复的 if/else 语句时会出现:
if (n === 1) {
// Execute code block #1
} else if (n === 2) {
// Execute code block #2
} else if (n === 3) {
// Execute code block #3
} else {
// If all else fails, execute block #4
}
这段代码没有什么特别之处。它只是一系列 if 语句,每个后续的 if 都是前一个语句的 else 子句的一部分。使用 else if 习惯比在其语法上等效的完全嵌套形式中编写这些语句更可取,也更易读:
if (n === 1) {
// Execute code block #1
}
else {
if (n === 2) {
// Execute code block #2
}
else {
if (n === 3) {
// Execute code block #3
}
else {
// If all else fails, execute block #4
}
}
}
5.3.3 switch
if 语句会导致程序执行流程的分支,你可以使用 else if 习惯来执行多路分支。然而,当所有分支都依赖于相同表达式的值时,这并不是最佳解决方案。在这种情况下,多次在多个 if 语句中评估该表达式是浪费的。
switch 语句正好处理这种情况。switch 关键字后跟着括号中的表达式和花括号中的代码块:
switch(*`expression`*) {
*`statements`*
}
然而,switch 语句的完整语法比这更复杂。代码块中的各个位置都用 case 关键字标记,后跟一个表达式和一个冒号。当 switch 执行时,它计算表达式的值,然后寻找一个 case 标签,其表达式的值与之相同(相同性由 === 运算符确定)。如果找到一个匹配值的 case,它会从标记为 case 的语句开始执行代码块。如果找不到具有匹配值的 case,它会寻找一个标记为 default: 的语句。如果没有 default: 标签,switch 语句会跳过整个代码块。
switch 是一个很难解释的语句;通过一个例子,它的操作会变得更加清晰。下面的 switch 语句等同于前一节中展示的重复的 if/else 语句:
switch(n) {
case 1: // Start here if n === 1
// Execute code block #1.
break; // Stop here
case 2: // Start here if n === 2
// Execute code block #2.
break; // Stop here
case 3: // Start here if n === 3
// Execute code block #3.
break; // Stop here
default: // If all else fails...
// Execute code block #4.
break; // Stop here
}
注意这段代码中每个 case 结尾使用的 break 关键字。break 语句会在本章后面描述,它会导致解释器跳出(或“中断”)switch 语句并继续执行后面的语句。switch 语句中的 case 子句只指定所需代码的起始点;它们不指定任何结束点。在没有 break 语句的情况下,switch 语句会从与其表达式值匹配的 case 标签开始执行其代码块,并继续执行语句直到达到代码块的末尾。在极少数情况下,编写“穿透”从一个 case 标签到下一个的代码是有用的,但 99% 的情况下,你应该小心地用 break 语句结束每个 case。(然而,在函数内部使用 switch 时,你可以使用 return 语句代替 break 语句。两者都用于终止 switch 语句并防止执行穿透到下一个 case。)
这里是 switch 语句的一个更加现实的例子;它根据值的类型将值转换为字符串:
function convert(x) {
switch(typeof x) {
case "number": // Convert the number to a hexadecimal integer
return x.toString(16);
case "string": // Return the string enclosed in quotes
return '"' + x + '"';
default: // Convert any other type in the usual way
return String(x);
}
}
请注意,在前两个示例中,case关键字分别后跟数字和字符串字面量。这是switch语句在实践中最常用的方式,但请注意,ECMAScript 标准允许每个case后跟任意表达式。
switch语句首先评估跟在switch关键字后面的表达式,然后按照它们出现的顺序评估case表达式,直到找到匹配的值。匹配的情况是使用===身份运算符确定的,而不是==相等运算符,因此表达式必须在没有任何类型转换的情况下匹配。
因为并非每次执行switch语句时都会评估所有case表达式,所以应避免使用包含函数调用或赋值等副作用的case表达式。最安全的做法是将case表达式限制为常量表达式。
如前所述,如果没有case表达式与switch表达式匹配,switch语句将从标记为default:的语句处开始执行其主体。如果没有default:标签,则switch语句将完全跳过其主体。请注意,在所示示例中,default:标签出现在switch主体的末尾,跟在所有case标签后面。这是一个逻辑和常见的位置,但实际上它可以出现在语句主体的任何位置。
5.4 循环
要理解条件语句,我们可以想象 JavaScript 解释器通过源代码的分支路径。循环语句是将该路径弯回自身以重复代码部分的语句。JavaScript 有五个循环语句:while、do/while、for、for/of(及其for/await变体)和for/in。以下各小节依次解释每个循环语句。循环的一个常见用途是遍历数组元素。§7.6 详细讨论了这种循环,并涵盖了 Array 类定义的特殊循环方法。
5.4.1 while
就像if语句是 JavaScript 的基本条件语句一样,while语句是 JavaScript 的基本循环语句。它的语法如下:
while (*`expression`*)
*`statement`*
要执行while语句,解释器首先评估expression。如果表达式的值为假值,则解释器跳过作为循环体的statement并继续执行程序中的下一条语句。另一方面,如果expression为真值,则解释器执行statement并重复,跳回循环的顶部并再次评估expression。另一种说法是,解释器在expression为真值时重复执行statement。请注意,您可以使用while(true)语法创建一个无限循环。
通常,您不希望 JavaScript 一遍又一遍地执行完全相同的操作。在几乎每个循环中,一个或多个变量会随着循环的每次迭代而改变。由于变量会改变,执行statement的操作可能每次循环时都不同。此外,如果涉及到expression中的变化变量,那么表达式的值可能每次循环时都不同。这很重要;否则,一开始为真值的表达式永远不会改变,循环永远不会结束!以下是一个打印从 0 到 9 的数字的while循环示例:
let count = 0;
while(count < 10) {
console.log(count);
count++;
}
正如你所看到的,变量count从 0 开始,并且在循环体运行每次后递增。一旦循环执行了 10 次,表达式变为false(即变量count不再小于 10),while语句结束,解释器可以继续执行程序中的下一条语句。许多循环都有像count这样的计数变量。变量名i、j和k通常用作循环计数器,但如果使用更具描述性的名称可以使代码更易于理解。
5.4.2 do/while
do/while循环类似于while循环,不同之处在于循环表达式在循环底部测试而不是在顶部测试。这意味着循环体始终至少执行一次。语法是:
do
*`statement`*
while (*`expression`*);
do/while循环比其while表亲更少使用——实际上,很少有确定要执行至少一次循环的情况。以下是do/while循环的示例:
function printArray(a) {
let len = a.length, i = 0;
if (len === 0) {
console.log("Empty Array");
} else {
do {
console.log(a[i]);
} while(++i < len);
}
}
do/while循环和普通的while循环之间有一些语法上的差异。首先,do循环需要do关键字(标记循环开始)和while关键字(标记结束并引入循环条件)。此外,do循环必须始终以分号结尾。如果循环体用大括号括起来,则while循环不需要分号。
5.4.3 for
for语句提供了一个循环结构,通常比while语句更方便。for语句简化了遵循常见模式的循环。大多数循环都有某种计数变量。该变量在循环开始之前初始化,并在每次循环迭代之前进行测试。最后,在循环体结束之前,计数变量会递增或以其他方式更新,然后再次测试该变量。在这种循环中,初始化、测试和更新是循环变量的三个关键操作。for语句将这三个操作编码为表达式,并将这些表达式作为循环语法的显式部分:
for(*`initialize`* ; *`test`* ; *`increment`*)
*`statement`*
initialize、test和increment是三个(用分号分隔的)表达式,负责初始化、测试和递增循环变量。将它们都放在循环的第一行中可以轻松理解for循环正在做什么,并防止遗漏初始化或递增循环变量等错误。
解释for循环如何工作的最简单方法是展示等效的while循环:²
*`initialize`*;
while(*`test`*) {
*`statement`*
*`increment`*;
}
换句话说,initialize表达式在循环开始之前只计算一次。为了有用,此表达式必须具有副作用(通常是赋值)。JavaScript 还允许initialize是一个变量声明语句,这样您可以同时声明和初始化循环计数器。test表达式在每次迭代之前进行评估,并控制循环体是否执行。如果test评估为真值,则执行循环体的statement。最后,评估increment表达式。同样,这必须是具有副作用的表达式才能有效。通常,它是一个赋值表达式,或者使用++或--运算符。
我们可以使用以下for循环打印从 0 到 9 的数字。将其与前一节中显示的等效while循环进行对比:
for(let count = 0; count < 10; count++) {
console.log(count);
}
当然,循环可能比这个简单示例复杂得多,有时多个变量在循环的每次迭代中都会发生变化。这种情况是 JavaScript 中唯一常用逗号运算符的地方;它提供了一种将多个初始化和递增表达式组合成适合在for循环中使用的单个表达式的方法:
let i, j, sum = 0;
for(i = 0, j = 10 ; i < 10 ; i++, j--) {
sum += i * j;
}
到目前为止,我们所有的循环示例中,循环变量都是数字。这是很常见的,但并非必须的。以下代码使用for循环遍历一个链表数据结构并返回列表中的最后一个对象(即,第一个没有next属性的对象):
function tail(o) { // Return the tail of linked list o
for(; o.next; o = o.next) /* empty */ ; // Traverse while o.next is truthy
return o;
}
注意,这段代码没有初始化表达式。for循环中的三个表达式中的任何一个都可以省略,但两个分号是必需的。如果省略测试表达式,则循环将永远重复,for(;;)就像while(true)一样是写无限循环的另一种方式。
5.4.4 for/of
ES6 定义了一种新的循环语句:for/of。这种新类型的循环使用for关键字,但是与常规的for循环完全不同。(它也与我们将在§5.4.5 中描述的旧的for/in循环完全不同。)
for/of循环适用于可迭代对象。我们将在第十二章中详细解释对象何时被视为可迭代,但在本章中,只需知道数组、字符串、集合和映射是可迭代的:它们代表一个序列或一组元素,您可以使用for/of循环进行循环或迭代。
例如,这里是我们如何使用for/of循环遍历一个数字数组的元素并计算它们的总和:
let data = [1, 2, 3, 4, 5, 6, 7, 8, 9], sum = 0;
for(let element of data) {
sum += element;
}
sum // => 45
表面上,语法看起来像是常规的for循环:for关键字后面跟着包含有关循环应该执行的详细信息的括号。在这种情况下,括号包含一个变量声明(或者对于已经声明的变量,只是变量的名称),后面跟着of关键字和一个求值为可迭代对象的表达式,就像这种情况下的data数组一样。与所有循环一样,for/of循环的主体跟在括号后面,通常在花括号内。
在刚才显示的代码中,循环体会针对data数组的每个元素运行一次。在执行循环体之前,数组的下一个元素会被分配给元素变量。数组元素按顺序从第一个到最后一个进行迭代。
数组是“实时”迭代的——在迭代过程中进行的更改可能会影响迭代的结果。如果我们在循环体内添加data.push(sum);这行代码,那么我们将创建一个无限循环,因为迭代永远无法到达数组的最后一个元素。
使用对象进行for/of循环
对象默认情况下不可迭代。尝试在常规对象上使用for/of会在运行时引发 TypeError:
let o = { x: 1, y: 2, z: 3 };
for(let element of o) { // Throws TypeError because o is not iterable
console.log(element);
}
如果要遍历对象的属性,可以使用for/in循环(在§5.4.5 中介绍),或者使用for/of与Object.keys()方法:
let o = { x: 1, y: 2, z: 3 };
let keys = "";
for(let k of Object.keys(o)) {
keys += k;
}
keys // => "xyz"
这是因为Object.keys()返回一个对象的属性名称数组,数组可以使用for/of进行迭代。还要注意,与上面的数组示例不同,对象的键的这种迭代不是实时的——在循环体中对对象o进行的更改不会影响迭代。如果您不关心对象的键,也可以像这样迭代它们对应的值:
let sum = 0;
for(let v of Object.values(o)) {
sum += v;
}
sum // => 6
如果您对对象属性的键和值都感兴趣,可以使用for/of与Object.entries()和解构赋值:
let pairs = "";
for(let [k, v] of Object.entries(o)) {
pairs += k + v;
}
pairs // => "x1y2z3"
Object.entries()返回一个数组,其中每个内部数组表示对象的一个属性的键/值对。在这个代码示例中,我们使用解构赋值来将这些内部数组解包成两个单独的变量。
使用字符串进行for/of循环
在 ES6 中,字符串是逐个字符可迭代的:
let frequency = {};
for(let letter of "mississippi") {
if (frequency[letter]) {
frequency[letter]++;
} else {
frequency[letter] = 1;
}
}
frequency // => {m: 1, i: 4, s: 4, p: 2}
请注意,字符串是按 Unicode 代码点迭代的,而不是按 UTF-16 字符。字符串“I ❤
”的.length为 5(因为两个表情符号字符分别需要两个 UTF-16 字符来表示)。但如果您使用for/of迭代该字符串,循环体将运行三次,分别为每个代码点“I”、“❤”和“
”。
使用 Set 和 Map 进行 for/of
内置的 ES6 Set 和 Map 类是可迭代的。当您使用 for/of 迭代 Set 时,循环体会为集合的每个元素运行一次。您可以使用以下代码打印文本字符串中的唯一单词:
let text = "Na na na na na na na na Batman!";
let wordSet = new Set(text.split(" "));
let unique = [];
for(let word of wordSet) {
unique.push(word);
}
unique // => ["Na", "na", "Batman!"]
Map 是一个有趣的情况,因为 Map 对象的迭代器不会迭代 Map 键或 Map 值,而是键/值对。在每次迭代中,迭代器返回一个数组,其第一个元素是键,第二个元素是相应的值。给定一个 Map m,您可以像这样迭代并解构其键/值对:
let m = new Map([[1, "one"]]);
for(let [key, value] of m) {
key // => 1
value // => "one"
}
使用 for/await 进行异步迭代
ES2018 引入了一种新类型的迭代器,称为异步迭代器,以及与之配套的 for/of 循环的变体,称为 for/await 循环,可与异步迭代器一起使用。
您需要阅读第十二章和第十三章才能理解 for/await 循环,但以下是代码示例:
// Read chunks from an asynchronously iterable stream and print them out
async function printStream(stream) {
for await (let chunk of stream) {
console.log(chunk);
}
}
5.4.5 for/in
for/in 循环看起来很像 for/of 循环,只是将 of 关键字更改为 in。在 of 之后,for/of 循环需要一个可迭代对象,而 for/in 循环在 in 之后可以使用任何对象。for/of 循环是 ES6 中的新功能,但 for/in 从 JavaScript 最初就存在(这就是为什么它具有更自然的语法)。
for/in 语句循环遍历指定对象的属性名称。语法如下:
for (*`variable`* in *`object`*)
*`statement`*
variable 通常命名一个变量,但它也可以是一个变量声明或任何适合作为赋值表达式左侧的内容。object 是一个求值为对象的表达式。通常情况下,statement 是作为循环主体的语句或语句块。
您可能会像这样使用 for/in 循环:
for(let p in o) { // Assign property names of o to variable p
console.log(o[p]); // Print the value of each property
}
要执行 for/in 语句,JavaScript 解释器首先评估 object 表达式。如果它评估为 null 或 undefined,解释器将跳过循环并继续执行下一条语句。解释器现在会为对象的每个可枚举属性执行循环体。然而,在每次迭代之前,解释器会评估 variable 表达式并将属性的名称(一个字符串值)赋给它。
请注意,在 for/in 循环中的 variable 可以是任意表达式,只要它评估为适合赋值左侧的内容。这个表达式在每次循环时都会被评估,这意味着它可能每次评估的结果都不同。例如,您可以使用以下代码将所有对象属性的名称复制到数组中:
let o = { x: 1, y: 2, z: 3 };
let a = [], i = 0;
for(a[i++] in o) /* empty */;
JavaScript 数组只是一种特殊类型的对象,数组索引是可以用 for/in 循环枚举的对象属性。例如,以下代码后面加上这行代码,将枚举数组索引 0、1 和 2:
for(let i in a) console.log(i);
我发现在我的代码中常见的错误来源是意外使用数组时使用 for/in 而不是 for/of。在处理数组时,您几乎总是希望使用 for/of 而不是 for/in。
for/in 循环实际上并不枚举对象的所有属性。它不会枚举名称为符号的属性。对于名称为字符串的属性,它只循环遍历可枚举属性(参见§14.1)。核心 JavaScript 定义的各种内置方法都不可枚举。例如,所有对象都有一个 toString() 方法,但 for/in 循环不会枚举这个 toString 属性。除了内置方法,许多内置对象的其他属性也是不可枚举的。默认情况下,您代码定义的所有属性和方法都是可枚举的(您可以使用§14.1 中解释的技术使它们变为不可枚举)。
可枚举的继承属性(参见§6.3.2)也会被for/in循环枚举。这意味着如果您使用for/in循环,并且还使用定义了所有对象都继承的属性的代码,那么您的循环可能不会按您的预期方式运行。因此,许多程序员更喜欢使用Object.keys()的for/of循环而不是for/in循环。
如果for/in循环的主体删除尚未枚举的属性,则该属性将不会被枚举。如果循环的主体在对象上定义了新属性,则这些属性可能会被枚举,也可能不会被枚举。有关for/in枚举对象属性的顺序的更多信息,请参见§6.6.1。
5.5 跳转
另一类 JavaScript 语句是跳转语句。顾名思义,这些语句会导致 JavaScript 解释器跳转到源代码中的新位置。break语句使解释器跳转到循环或其他语句的末尾。continue使解释器跳过循环体的其余部分,并跳回到循环的顶部开始新的迭代。JavaScript 允许对语句进行命名,或标记,break和continue可以标识目标循环或其他语句标签。
return语句使解释器从函数调用跳回到调用它的代码,并提供调用的值。throw语句是一种临时从生成器函数返回的方式。throw语句引发异常,并设计用于与try/catch/finally语句一起工作,后者建立了一个异常处理代码块。这是一种复杂的跳转语句:当抛出异常时,解释器会跳转到最近的封闭异常处理程序,该处理程序可能在同一函数中或在调用函数的调用堆栈中。
关于这些跳转语句的详细信息在接下来的章节中。
5.5.1 标记语句
任何语句都可以通过在其前面加上标识符和冒号来标记:
*`identifier`*: *`statement`*
通过给语句加上标签,您为其赋予一个名称,以便在程序的其他地方引用它。您可以为任何语句加上标签,尽管只有为具有主体的语句加上标签才有用,例如循环和条件语句。通过给循环命名,您可以在循环体内使用break和continue语句来退出循环或直接跳转到循环的顶部开始下一次迭代。break和continue是唯一使用语句标签的 JavaScript 语句;它们在以下子节中介绍。这里是一个带有标签的while循环和使用标签的continue语句的示例。
mainloop: while(token !== null) {
// Code omitted...
continue mainloop; // Jump to the next iteration of the named loop
// More code omitted...
}
用于标记语句的标识符可以是任何合法的 JavaScript 标识符,不能是保留字。标签的命名空间与变量和函数的命名空间不同,因此您可以将相同的标识符用作语句标签和变量或函数名称。语句标签仅在其适用的语句内部定义(当然也包括其子语句)。语句不能具有包含它的语句相同的标签,但是只要一个语句不嵌套在另一个语句内,两个语句可以具有相同的标签。标记的语句本身也可以被标记。实际上,这意味着任何语句可以具有多个标签。
5.5.2 break
单独使用的break语句会导致最内层的循环或switch语句立即退出。其语法很简单:
break;
因为它导致循环或switch退出,所以这种形式的break语句只有在出现在这些语句内部时才合法。
您已经看到了switch语句中break语句的示例。在循环中,当不再需要完成循环时,通常会提前退出。当循环具有复杂的终止条件时,通常更容易使用break语句实现其中一些条件,而不是尝试在单个循环表达式中表达所有条件。以下代码搜索数组元素以找到特定值。当它在数组中找到所需的内容时,循环以正常方式终止;如果在数组中找到所需的内容,则使用break语句终止:
for(let i = 0; i < a.length; i++) {
if (a[i] === target) break;
}
JavaScript 还允许在break关键字后面跟着一个语句标签(只是标识符,没有冒号):
break *`labelname`*;
当break与标签一起使用时,它会跳转到具有指定标签的结束语句,或终止该结束语句。如果没有具有指定标签的结束语句,则以这种形式使用break语句是语法错误。使用这种形式的break语句时,命名的语句不必是循环或switch:break可以“跳出”任何包含语句。这个语句甚至可以是一个仅用于使用标签命名块的大括号组成的语句块。
在break关键字和labelname之间不允许换行。这是由于 JavaScript 自动插入省略的分号:如果在break关键字和后面的标签之间放置换行符,JavaScript 会认为您想使用简单的、无标签的语句形式,并将换行符视为分号。(参见§2.6。)
当您想要跳出不是最近的循环或switch的语句时,您需要带标签的break语句。以下代码演示了:
let matrix = getData(); // Get a 2D array of numbers from somewhere
// Now sum all the numbers in the matrix.
let sum = 0, success = false;
// Start with a labeled statement that we can break out of if errors occur
computeSum: if (matrix) {
for(let x = 0; x < matrix.length; x++) {
let row = matrix[x];
if (!row) break computeSum;
for(let y = 0; y < row.length; y++) {
let cell = row[y];
if (isNaN(cell)) break computeSum;
sum += cell;
}
}
success = true;
}
// The break statements jump here. If we arrive here with success == false
// then there was something wrong with the matrix we were given.
// Otherwise, sum contains the sum of all cells of the matrix.
最后,请注意,break语句,无论是否带有标签,都不能跨越函数边界转移控制。例如,您不能给函数定义语句加上标签,然后在函数内部使用该标签。
5.5.3 continue
continue语句类似于break语句。但是,continue不是退出循环,而是在下一次迭代时重新开始循环。continue语句的语法与break语句一样简单:
continue;
continue语句也可以与标签一起使用:
continue *`labelname`*;
continue语句,无论是带标签还是不带标签,只能在循环体内使用。在其他任何地方使用它都会导致语法错误。
当执行continue语句时,将终止当前循环的迭代,并开始下一次迭代。对于不同类型的循环,这意味着不同的事情:
-
在
while循环中,循环开始时测试循环开头的指定表达式,如果为true,则从顶部执行循环体。 -
在
do/while循环中,执行跳转到循环底部,然后再次测试循环条件,然后重新开始循环。 -
在
for循环中,将评估增量表达式,并再次测试测试表达式以确定是否应进行另一次迭代。 -
在
for/of或for/in循环中,循环将重新开始,下一个迭代值或下一个属性名将被赋给指定的变量。
请注意while和for循环中continue语句的行为差异:while循环直接返回到其条件,但for循环首先评估其增量表达式,然后返回到其条件。之前,我们考虑了for循环的行为,以等效的while循环来描述。然而,由于continue语句对这两种循环的行为不同,因此仅使用while循环无法完全模拟for循环。
以下示例显示了在发生错误时使用未标记的continue语句跳过当前迭代的其余部分的情况:
for(let i = 0; i < data.length; i++) {
if (!data[i]) continue; // Can't proceed with undefined data
total += data[i];
}
与break语句类似,continue语句可以在嵌套循环中的标记形式中使用,当要重新启动的循环不是直接包围的循环时。同样,与break语句一样,continue语句和其labelname之间不允许换行。
5.5.4 return
请记住函数调用是表达式,所有表达式都有值。函数内部的return语句指定了该函数调用的值。下面是return语句的语法:
return *`expression`*;
return语句只能出现在函数体内部。在其他任何地方出现都会导致语法错误。当执行return语句时,包含它的函数将expression的值返回给调用者。例如:
function square(x) { return x*x; } // A function that has a return statement
square(2) // => 4
没有return语句时,函数调用会依次执行函数体中的每个语句,直到到达函数末尾然后返回给调用者。在这种情况下,调用表达式评估为undefined。return语句通常出现在函数中的最后一个语句,但不一定非得是最后一个:当执行return语句时,函数返回给调用者,即使函数体中还有其他语句。
return语句也可以在没有expression的情况下使用,使函数返回undefined给调用者。例如:
function displayObject(o) {
// Return immediately if the argument is null or undefined.
if (!o) return;
// Rest of function goes here...
}
由于 JavaScript 的自动分号插入(§2.6),你不能在return关键字和其后的表达式之间插入换行符。
5.5.5 yield
yield语句与return语句非常相似,但仅在 ES6 生成器函数(参见§12.3)中使用,用于生成值序列中的下一个值而不实际返回:
// A generator function that yields a range of integers
function* range(from, to) {
for(let i = from; i <= to; i++) {
yield i;
}
}
要理解yield,你必须理解迭代器和生成器,这将在第十二章中介绍。然而,为了完整起见,这里包括了yield。(严格来说,yield是一个运算符而不是语句,如§12.4.2 中所解释的。)
5.5.6 throw
异常是指示发生了某种异常情况或错误的信号。抛出异常是指示发生了这样的错误或异常情况。捕获异常是处理它 - 采取必要或适当的措施来从异常中恢复。在 JavaScript 中,每当发生运行时错误或程序明确使用throw语句抛出异常时,都会抛出异常。异常可以通过try/catch/finally语句捕获,下一节将对此进行描述。
throw语句的语法如下:
throw *`expression`*;
expression可能会评估为任何类型的值。你可以抛出一个代表错误代码的数字,或者包含人类可读错误消息的字符串。当 JavaScript 解释器本身抛出错误时,会使用 Error 类及其子类,你也可以使用它们。一个 Error 对象有一个name属性指定错误类型,一个message属性保存传递给构造函数的字符串。下面是一个示例函数,当使用无效参数调用时会抛出一个 Error 对象:
function factorial(x) {
// If the input argument is invalid, throw an exception!
if (x < 0) throw new Error("x must not be negative");
// Otherwise, compute a value and return normally
let f;
for(f = 1; x > 1; f *= x, x--) /* empty */ ;
return f;
}
factorial(4) // => 24
当抛出异常时,JavaScript 解释器立即停止正常程序执行,并跳转到最近的异常处理程序。异常处理程序使用try/catch/finally语句的catch子句编写,下一节将对其进行描述。如果抛出异常的代码块没有关联的catch子句,解释器将检查下一个最高级别的封闭代码块,看看它是否有与之关联的异常处理程序。这将一直持续下去,直到找到处理程序。如果在一个不包含try/catch/finally语句来处理异常的函数中抛出异常,异常将传播到调用该函数的代码。通过这种方式,异常通过 JavaScript 方法的词法结构向上传播,并沿着调用堆栈向上传播。如果从未找到异常处理程序,异常将被视为错误并报告给用户。
5.5.7 try/catch/finally
try/catch/finally语句是 JavaScript 的异常处理机制。该语句的try子句简单地定义了要处理异常的代码块。try块后面是一个catch子句,当try块内部发生异常时,将调用一组语句。catch子句后面是一个finally块,其中包含清理代码,无论try块中发生了什么,都保证会执行。catch和finally块都是可选的,但try块必须至少伴随其中一个。try、catch和finally块都以大括号开始和结束。这些大括号是语法的必要部分,即使一个子句只包含一个语句也不能省略。
以下代码示例说明了try/catch/finally语句的语法和目的:
try {
// Normally, this code runs from the top of the block to the bottom
// without problems. But it can sometimes throw an exception,
// either directly, with a throw statement, or indirectly, by calling
// a method that throws an exception.
}
catch(e) {
// The statements in this block are executed if, and only if, the try
// block throws an exception. These statements can use the local variable
// e to refer to the Error object or other value that was thrown.
// This block may handle the exception somehow, may ignore the
// exception by doing nothing, or may rethrow the exception with throw.
}
finally {
// This block contains statements that are always executed, regardless of
// what happens in the try block. They are executed whether the try
// block terminates:
// 1) normally, after reaching the bottom of the block
// 2) because of a break, continue, or return statement
// 3) with an exception that is handled by a catch clause above
// 4) with an uncaught exception that is still propagating
}
请注意,catch关键字通常后面跟着一个括号中的标识符。这个标识符类似于函数参数。当捕获到异常时,与异常相关联的值(例如一个 Error 对象)将被分配给这个参数。与catch子句关联的标识符具有块作用域——它只在catch块内定义。
这里是try/catch语句的一个实际例子。它使用了前一节中定义的factorial()方法以及客户端 JavaScript 方法prompt()和alert()来进行输入和输出:
try {
// Ask the user to enter a number
let n = Number(prompt("Please enter a positive integer", ""));
// Compute the factorial of the number, assuming the input is valid
let f = factorial(n);
// Display the result
alert(n + "! = " + f);
}
catch(ex) { // If the user's input was not valid, we end up here
alert(ex); // Tell the user what the error is
}
这个例子是一个没有finally子句的try/catch语句。虽然finally不像catch那样经常使用,但它也是有用的。然而,它的行为需要额外的解释。如果try块的任何部分被执行,finally子句将被执行。它通常用于在try子句中的代码执行完毕后进行清理。
在正常情况下,JavaScript 解释器执行完try块后,然后继续执行finally块,执行任何必要的清理工作。如果解释器因为return、continue或break语句而离开try块,那么在解释器跳转到新目的地之前,将执行finally块。
如果在try块中发生异常,并且有一个关联的catch块来处理异常,解释器首先执行catch块,然后执行finally块。如果没有本地catch块来处理异常,解释器首先执行finally块,然后跳转到最近的包含catch子句。
如果finally块本身导致使用return、continue、break或throw语句跳转,或通过调用抛出异常的方法,解释器会放弃任何待处理的跳转并执行新的跳转。例如,如果finally子句抛出异常,那个异常会替换正在被抛出的任何异常。如果finally子句发出return语句,方法会正常返回,即使已经抛出异常但尚未处理。
try和finally可以在没有catch子句的情况下一起使用。在这种情况下,finally块只是保证会被执行的清理代码,无论try块中发生了什么。请记住,我们无法完全用while循环模拟for循环,因为continue语句对这两种循环的行为是不同的。如果我们添加一个try/finally语句,我们可以编写一个像for循环一样工作并正确处理continue语句的while循环:
// Simulate for(*`initialize`* ; *`test`* ;*`increment`* ) body;
*`initialize`* ;
while( *`test`* ) {
try { *`body`* ; }
finally { *`increment`* ; }
}
但是请注意,包含break语句的body在while循环中的行为略有不同(导致在退出之前额外增加一次递增)与在for循环中的行为不同,因此即使有finally子句,也无法完全用while模拟for循环。
5.6 其他语句
本节描述了剩余的三个 JavaScript 语句——with、debugger和"use strict"。
5.6.1 with
with语句会将指定对象的属性作为作用域内的变量运行一段代码块。它的语法如下:
with (*`object`*)
*`statement`*
这个语句创建一个临时作用域,将object的属性作为变量,然后在该作用域内执行statement。
with语句在严格模式下是被禁止的(参见§5.6.3),在非严格模式下应被视为已弃用:尽量避免使用。使用with的 JavaScript 代码很难优化,并且可能比不使用with语句编写的等效代码运行得慢得多。
with语句的常见用法是使得在深度嵌套的对象层次结构中更容易工作。例如,在客户端 JavaScript 中,你可能需要输入这样的表达式来访问 HTML 表单的元素:
document.forms[0].address.value
如果你需要多次编写这样的表达式,你可以使用with语句将表单对象的属性视为变量处理:
with(document.forms[0]) {
// Access form elements directly here. For example:
name.value = "";
address.value = "";
email.value = "";
}
这样可以减少你需要输入的内容:你不再需要在每个表单属性名称前加上document.forms[0]。当然,避免使用with语句并像这样编写前面的代码同样简单:
let f = document.forms[0];
f.name.value = "";
f.address.value = "";
f.email.value = "";
请注意,如果在with语句的主体中使用const、let或var声明变量或常量,它会创建一个普通变量,而不会在指定对象中定义一个新属性。
5.6.2 debugger
debugger语句通常不会执行任何操作。然而,如果一个调试器程序可用且正在运行,那么实现可能(但不是必须)执行某种调试操作。实际上,这个语句就像一个断点:JavaScript 代码的执行会停止,你可以使用调试器打印变量的值,检查调用堆栈等。例如,假设你在函数f()中遇到异常,因为它被使用未定义的参数调用,而你无法弄清楚这个调用是从哪里来的。为了帮助你调试这个问题,你可以修改f(),使其如下所示开始:
function f(o) {
if (o === undefined) debugger; // Temporary line for debugging purposes
... // The rest of the function goes here.
}
现在,当没有参数调用f()时,执行会停止,你可以使用调试器检查调用堆栈,并找出这个错误调用是从哪里来的。
请注意,仅仅拥有一个调试器是不够的:debugger语句不会为你启动调试器。然而,如果你正在使用一个网页浏览器并且打开了开发者工具控制台,这个语句会导致断点。
5.6.3 “use strict”
"use strict"是 ES5 中引入的指令。 指令不是语句(但足够接近,以至于在此处记录了"use strict")。 "use strict"指令和常规语句之间有两个重要区别:
-
它不包括任何语言关键字:该指令只是一个表达式语句,由一个特殊的字符串文字(单引号或双引号)组成。
-
它只能出现在脚本的开头或函数体的开头,在任何真实语句出现之前。
"use strict"指令的目的是指示随后的代码(在脚本或函数中)是严格代码。 如果脚本有"use strict"指令,则脚本的顶级(非函数)代码是严格代码。 如果函数体在严格代码中定义或具有"use strict"指令,则函数体是严格代码。 如果从严格代码调用eval()方法,则传递给eval()的代码是严格代码,或者如果代码字符串包含"use strict"指令。 除了明确声明为严格的代码外,class体(第九章)中的任何代码或 ES6 模块(§10.3)中的任何代码都自动成为严格代码。 这意味着如果所有 JavaScript 代码都编写为模块,则所有代码都自动成为严格代码,您将永远不需要使用显式的"use strict"指令。
严格模式下执行严格模式。 严格模式是语言的受限子集,修复了重要的语言缺陷,并提供了更强的错误检查和增强的安全性。 由于严格模式不是默认设置,仍然使用语言的不足遗留功能的旧 JavaScript 代码将继续正确运行。 严格模式和非严格模式之间的区别如下(前三个特别重要):
-
在严格模式下,不允许使用
with语句。 -
在严格模式下,所有变量必须声明:如果将值分配给未声明的变量、函数、函数参数、
catch子句参数或全局对象的属性,则会抛出 ReferenceError。(在非严格模式下,这将通过向全局对象添加新属性来隐式声明全局变量。) -
在严格模式下,作为函数调用的函数(而不是作为方法)的
this值为undefined。(在非严格模式下,作为函数调用的函数始终将全局对象作为其this值传递。)此外,在严格模式下,当使用call()或apply()(§8.7.4)调用函数时,this值正好是传递给call()或apply()的第一个参数的值。(在非严格模式下,null和undefined值将替换为全局对象,非对象值将转换为对象。) -
在严格模式下,对不可写属性的赋值和尝试在不可扩展对象上创建新属性会抛出 TypeError。(在非严格模式下,这些尝试会静默失败。)
-
在严格模式下,传递给
eval()的代码不能在调用者的范围内声明变量或定义函数,就像在非严格模式下那样。 相反,变量和函数定义存在于为eval()创建的新作用域中。 当eval()返回时,此作用域将被丢弃。 -
在严格模式下,函数中的 Arguments 对象(§8.3.3)保存传递给函数的值的静态副本。 在非严格模式下,Arguments 对象具有“神奇”的行为,其中数组的元素和命名函数参数都指向相同的值。
-
在严格模式下,如果
delete运算符后跟未经限定的标识符(如变量、函数或函数参数),则会抛出 SyntaxError。(在非严格模式下,这样的delete表达式不起作用并计算为false。) -
在严格模式下,尝试删除不可配置属性会抛出 TypeError。 (在非严格模式下,尝试失败,
delete表达式的值为false。) -
在严格模式下,对象字面量定义具有相同名称的两个或更多属性是语法错误。(在非严格模式下,不会发生错误。)
-
在严格模式下,函数声明具有两个或更多具有相同名称的参数是语法错误。(在非严格模式下,不会发生错误。)
-
在严格模式下,不允许使用八进制整数字面量(以 0 开头且后面不跟 x)。(在非严格模式下,一些实现允许八进制字面量。)
-
在严格模式下,标识符
eval和arguments被视为关键字,不允许更改它们的值。不能为这些标识符分配值,将它们声明为变量,将它们用作函数名称,将它们用作函数参数名称,或将它们用作catch块的标识符。 -
在严格模式下,限制了检查调用堆栈的能力。在严格模式函数内,
arguments.caller和arguments.callee都会抛出 TypeError。严格模式函数还具有caller和arguments属性,当读取时会抛出 TypeError。(一些实现在非严格函数上定义这些非标准属性。)
5.7 声明
关键字const、let、var、function、class、import和export在技术上不是语句,但它们看起来很像语句,因此本书非正式地将它们称为语句,因此它们在本章中值得一提。
这些关键字更准确地描述为声明而不是语句。我们在本章开头说过语句“让某事发生”。声明用于定义新值并为其赋予我们可以用来引用这些值的名称。它们本身并没有做太多事情,但通过为值提供名称,它们在重要意义上定义了程序中其他语句的含义。
当程序运行时,程序的表达式正在被评估,程序的语句正在被执行。程序中的声明不会像语句一样“运行”:相反,它们定义了程序本身的结构。可以粗略地将声明视为在代码开始运行之前处理的程序部分。
JavaScript 声明用于定义常量、变量、函数和类,并用于在模块之间导入和导出值。下一小节将给出所有这些声明的示例。它们在本书的其他地方都有更详细的介绍。
5.7.1 const、let 和 var
const、let和var声明在§3.10 中有介绍。在 ES6 及更高版本中,const声明常量,let声明变量。在 ES6 之前,var关键字是声明变量的唯一方式,没有办法声明常量。使用var声明的变量的作用域是包含函数而不是包含块。这可能导致错误,并且在现代 JavaScript 中,没有理由使用var而不是let。
const TAU = 2*Math.PI;
let radius = 3;
var circumference = TAU * radius;
5.7.2 function
function声明用于定义函数,在第八章中有详细介绍。(我们还在§4.3 中看到function,那里它被用作函数表达式的一部分而不是函数声明。)函数声明如下所示:
function area(radius) {
return Math.PI * radius * radius;
}
函数声明创建一个函数对象并将其分配给指定的名称—在这个例子中是area。 在程序的其他地方,我们可以通过使用这个名称引用函数—并运行其中的代码。 JavaScript 代码块中的函数声明在代码运行之前被处理,并且函数名称在整个代码块中绑定到函数对象。 我们说函数声明被“提升”,因为它就好像它们都被移动到它们所在的作用域的顶部一样。 结果是调用函数的代码可以存在于程序中,在声明函数的代码之前。
§12.3 描述了一种特殊类型的函数,称为生成器。 生成器声明使用function关键字,但后面跟着一个星号。 §13.3 描述了异步函数,也是使用function关键字声明的,但前面加上async关键字。
5.7.3 类
在 ES6 及更高版本中,class声明创建一个新的类,并为其赋予一个我们可以用来引用它的名称。 类在第九章中有详细描述。 一个简单的类声明可能如下所示:
class Circle {
constructor(radius) { this.r = radius; }
area() { return Math.PI * this.r * this.r; }
circumference() { return 2 * Math.PI * this.r; }
}
与函数不同,类声明不会被提升,你不能在类声明之前的代码中使用以这种方式声明的类。
5.7.4 导入和导出
import和export声明一起使用,使得在 JavaScript 代码的一个模块中定义的值可以在另一个模块中使用。 模块是具有自己全局命名空间的 JavaScript 代码文件,完全独立于所有其他模块。 一个值(如函数或类)在一个模块中定义后,只有通过export导出并在另一个模块中使用import导入,才能在另一个模块中使用。 模块是第十章的主题,import和export在§10.3 中有详细介绍。
import指令用于从另一个 JavaScript 代码文件中导入一个或多个值,并在当前模块中为它们命名。 import指令有几种不同的形式。 以下是一些示例:
import Circle from './geometry/circle.js';
import { PI, TAU } from './geometry/constants.js';
import { magnitude as hypotenuse } from './vectors/utils.js';
JavaScript 模块中的值是私有的,除非它们已经被明确导出,否则不能被导入到其他模块中。 export指令可以实现这一点:它声明当前模块中定义的一个或多个值被导出,因此可以被其他模块导入。 export指令比import指令有更多的变体。 这是其中之一:
// geometry/constants.js
const PI = Math.PI;
const TAU = 2 * PI;
export { PI, TAU };
export关键字有时用作其他声明的修饰符,从而形成一种复合声明,同时定义一个常量、变量、函数或类并将其导出。 当一个模块只导出一个值时,通常使用特殊形式export default:
export const TAU = 2 * Math.PI;
export function magnitude(x,y) { return Math.sqrt(x*x + y*y); }
export default class Circle { /* class definition omitted here */ }
5.8 JavaScript 语句总结
本章介绍了 JavaScript 语言的每个语句,总结在表 5-1 中。
表 5-1. JavaScript 语句语法
| 语句 | 目的 |
|---|---|
| break | 退出最内层循环或switch或从命名封闭语句中退出 |
| case | 在switch语句中标记一个语句 |
| class | 声明一个类 |
| const | 声明和初始化一个或多个常量 |
| continue | 开始最内层循环或命名循环的下一次迭代 |
| debugger | 调试器断点 |
| default | 标记switch语句中的默认语句 |
| do/while | while循环的替代方案 |
| export | 声明可以被其他模块导入的值 |
| for | 一个易于使用的循环 |
| for/await | 异步迭代异步迭代器的值 |
| for/in | 枚举对象的属性名称 |
| for/of | 枚举可迭代对象(如数组)的值 |
| function | 声明一个函数 |
| if/else | 根据条件执行一个语句或另一个 |
| import | 声明在其他模块中定义的值的名称 |
| label | 为break和continue给语句命名 |
| let | 声明并初始化一个或多个块作用域变量(新语法) |
| return | 从函数中返回一个值 |
| switch | 多路分支到case或default:标签 |
| throw | 抛出异常 |
| try/catch/finally | 处理异常和代码清理 |
| “use strict” | 将严格模式限制应用于脚本或函数 |
| var | 声明并初始化一个或多个变量(旧语法) |
| while | 基本的循环结构 |
| with | 扩展作用域链(已弃用且在严格模式下禁止使用) |
| yield | 提供一个要迭代的值;仅在生成器函数中使用 |
¹ case表达式在运行时评估的事实使得 JavaScript 的switch语句与 C、C++和 Java 的switch语句有很大不同(且效率较低)。在那些语言中,case表达式必须是相同类型的编译时常量,并且switch语句通常可以编译为高效的跳转表。
² 当我们考虑在§5.5.3 中的continue语句时,我们会发现这个while循环并不是for循环的精确等价。
第六章:对象
对象是 JavaScript 中最基本的数据类型,您在本章之前的章节中已经多次看到它们。因为对象对于 JavaScript 语言非常重要,所以您需要详细了解它们的工作原理,而本章提供了这些细节。它从对象的正式概述开始,然后深入到关于创建对象和查询、设置、删除、测试和枚举对象属性的实用部分。这些以属性为重点的部分之后是关于如何扩展、序列化和定义对象重要方法的部分。最后,本章以关于 ES6 和更高版本语言中新对象字面量语法的长篇部分结束。
6.1 对象简介
对象是一个复合值:它聚合了多个值(原始值或其他对象),并允许您通过名称存储和检索这些值。对象是一个无序的属性集合,每个属性都有一个名称和一个值。属性名称通常是字符串(尽管,正如我们将在§6.10.3 中看到的,属性名称也可以是符号),因此我们可以说对象将字符串映射到值。这种字符串到值的映射有各种名称——您可能已经熟悉了以“哈希”、“哈希表”、“字典”或“关联数组”命名的基本数据结构。然而,对象不仅仅是一个简单的字符串到值的映射。除了维护自己的一组属性外,JavaScript 对象还继承另一个对象的属性,称为其“原型”。对象的方法通常是继承的属性,这种“原型继承”是 JavaScript 的一个关键特性。
JavaScript 对象是动态的——属性通常可以添加和删除——但它们可以用来模拟静态类型语言的静态对象和“结构”。它们也可以被用来(通过忽略字符串到值映射的值部分)表示字符串集合。
任何在 JavaScript 中不是字符串、数字、符号、true、false、null 或 undefined 的值都是对象。即使字符串、数字和布尔值不是对象,它们也可以像不可变对象一样行事。
从§3.8 中回想起,对象是可变的,通过引用而不是值来操作。如果变量 x 引用一个对象,并且执行代码 let y = x;,那么变量 y 持有对同一对象的引用,而不是该对象的副本。通过变量 y 对对象进行的任何修改也会通过变量 x 可见。
对象最常见的操作是创建它们并设置、查询、删除、测试和枚举它们的属性。这些基本操作在本章的开头部分进行了描述。之后的部分涵盖了更高级的主题。
属性具有名称和值。属性名称可以是任何字符串,包括空字符串(或任何符号),但没有对象可以具有两个具有相同名称的属性。该值可以是任何 JavaScript 值,或者它可以是一个 getter 或 setter 函数(或两者)。我们将在§6.10.6 中学习有关 getter 和 setter 函数的内容。
有时重要的是能够区分直接在对象上定义的属性和从原型对象继承的属性。JavaScript 使用术语自有属性来指代非继承的属性。
除了名称和值之外,每个属性还有三个属性属性:
-
writable 属性指定属性的值是否可以被设置。
-
enumerable 属性指定属性名称是否由
for/in循环返回。 -
configurable 属性指定属性是否可以被删除以及其属性是否可以被更改。
JavaScript 的许多内置对象具有只读、不可枚举或不可配置的属性。但是,默认情况下,您创建的对象的所有属性都是可写的、可枚举的和可配置的。§14.1 解释了指定对象的非默认属性属性值的技术。
6.2 创建对象
使用对象字面量、new关键字和Object.create()函数可以创建对象。下面的小节描述了每种技术。
6.2.1 对象字面量
创建对象的最简单方法是在 JavaScript 代码中包含一个对象字面量。在其最简单的形式中,对象字面量是一个逗号分隔的冒号分隔的名称:值对列表,包含在花括号中。属性名是 JavaScript 标识符或字符串字面量(允许空字符串)。属性值是任何 JavaScript 表达式;表达式的值(可以是原始值或对象值)成为属性的值。以下是一些示例:
let empty = {}; // An object with no properties
let point = { x: 0, y: 0 }; // Two numeric properties
let p2 = { x: point.x, y: point.y+1 }; // More complex values
let book = {
"main title": "JavaScript", // These property names include spaces,
"sub-title": "The Definitive Guide", // and hyphens, so use string literals.
for: "all audiences", // for is reserved, but no quotes.
author: { // The value of this property is
firstname: "David", // itself an object.
surname: "Flanagan"
}
};
在对象字面量中最后一个属性后面加上逗号是合法的,一些编程风格鼓励使用这些尾随逗号,这样如果以后在对象字面量的末尾添加新属性,就不太可能导致语法错误。
对象字面量是一个表达式,每次评估时都会创建和初始化一个新的独立对象。每个属性的值在每次评估字面量时都会被评估。这意味着如果对象字面量出现在循环体内或重复调用的函数中,一个对象字面量可以创建许多新对象,并且这些对象的属性值可能彼此不同。
这里显示的对象字面量使用自 JavaScript 最早版本以来就合法的简单语法。语言的最新版本引入了许多新的对象字面量特性,这些特性在§6.10 中有介绍。
6.2.2 使用 new 创建对象
new运算符创建并初始化一个新对象。new关键字必须跟随一个函数调用。以这种方式使用的函数称为构造函数,用于初始化新创建的对象。JavaScript 包括其内置类型的构造函数。例如:
let o = new Object(); // Create an empty object: same as {}.
let a = new Array(); // Create an empty array: same as [].
let d = new Date(); // Create a Date object representing the current time
let r = new Map(); // Create a Map object for key/value mapping
除了这些内置构造函数,通常会定义自己的构造函数来初始化新创建的对象。这在第九章中有介绍。
6.2.3 原型
在我们讨论第三种对象创建技术之前,我们必须停顿一下来解释原型。几乎每个 JavaScript 对象都有一个与之关联的第二个 JavaScript 对象。这第二个对象称为原型,第一个对象从原型继承属性。
所有通过对象字面量创建的对象都有相同的原型对象,在 JavaScript 代码中我们可以将这个原型对象称为Object.prototype。使用new关键字和构造函数调用创建的对象使用构造函数的prototype属性的值作为它们的原型。因此,通过new Object()创建的对象继承自Object.prototype,就像通过{}创建的对象一样。类似地,通过new Array()创建的对象使用Array.prototype作为它们的原型,通过new Date()创建的对象使用Date.prototype作为它们的原型。初学 JavaScript 时可能会感到困惑。记住:几乎所有对象都有一个原型,但只有相对较少的对象有一个prototype属性。具有prototype属性的这些对象为所有其他对象定义了原型。
Object.prototype是少数没有原型的对象之一:它不继承任何属性。其他原型对象是具有原型的普通对象。大多数内置构造函数(以及大多数用户定义的构造函数)具有从Object.prototype继承的原型。例如,Date.prototype从Object.prototype继承属性,因此通过new Date()创建的 Date 对象从Date.prototype和Object.prototype继承属性。这个链接的原型对象系列被称为原型链。
如何工作属性继承的解释在§6.3.2 中。第九章更详细地解释了原型和构造函数之间的关系:它展示了如何通过编写构造函数并将其prototype属性设置为由该构造函数创建的“实例”使用的原型对象来定义新的对象“类”。我们将学习如何在§14.3 中查询(甚至更改)对象的原型。
6.2.4 Object.create()
Object.create()创建一个新对象,使用其第一个参数作为该对象的原型:
let o1 = Object.create({x: 1, y: 2}); // o1 inherits properties x and y.
o1.x + o1.y // => 3
您可以传递null来创建一个没有原型的新对象,但如果这样做,新创建的对象将不会继承任何东西,甚至不会继承像toString()这样的基本方法(这意味着它也无法与+运算符一起使用):
let o2 = Object.create(null); // o2 inherits no props or methods.
如果要创建一个普通的空对象(类似于{}或new Object()返回的对象),请传递Object.prototype:
let o3 = Object.create(Object.prototype); // o3 is like {} or new Object().
使用具有任意原型的新对象的能力是强大的,我们将在本章的许多地方使用Object.create()。(Object.create()还接受一个可选的第二个参数,描述新对象的属性。这个第二个参数是一个高级功能,涵盖在§14.1 中。)
使用Object.create()的一个用途是当您想要防止通过您无法控制的库函数意外(但非恶意)修改对象时。您可以传递一个从中继承的对象而不是直接将对象传递给函数。如果函数读取该对象的属性,它将看到继承的值。但是,如果它设置属性,这些写入将不会影响原始对象。
let o = { x: "don't change this value" };
library.function(Object.create(o)); // Guard against accidental modifications
要理解为什么这样做有效,您需要了解在 JavaScript 中如何查询和设置属性。这些是下一节的主题。
6.3 查询和设置属性
要获取属性的值,请使用§4.4 中描述的点号(.)或方括号([])运算符。左侧应该是一个值为对象的表达式。如果使用点运算符,则右侧必须是一个简单的标识符,用于命名属性。如果使用方括号,则括号内的值必须是一个求值为包含所需属性名称的字符串的表达式:
let author = book.author; // Get the "author" property of the book.
let name = author.surname; // Get the "surname" property of the author.
let title = book["main title"]; // Get the "main title" property of the book.
要创建或设置属性,请像查询属性一样使用点号或方括号,但将它们放在赋值表达式的左侧:
book.edition = 7; // Create an "edition" property of book.
book["main title"] = "ECMAScript"; // Change the "main title" property.
在使用方括号表示法时,我们已经说过方括号内的表达式必须求值为字符串。更精确的说法是,表达式必须求值为字符串或可以转换为字符串或符号的值(§6.10.3)。例如,在第七章中,我们将看到在方括号内使用数字是常见的。
6.3.1 对象作为关联数组
如前一节所述,以下两个 JavaScript 表达式具有相同的值:
object.property
object["property"]
第一种语法,使用点和标识符,类似于在 C 或 Java 中访问结构体或对象的静态字段的语法。第二种语法,使用方括号和字符串,看起来像数组访问,但是是通过字符串而不是数字索引的数组。这种类型的数组被称为关联数组(或哈希或映射或字典)。JavaScript 对象就是关联数组,本节解释了为什么这很重要。
在 C、C++、Java 等强类型语言中,一个对象只能拥有固定数量的属性,并且这些属性的名称必须事先定义。由于 JavaScript 是一种弱类型语言,这个规则不适用:程序可以在任何对象中创建任意数量的属性。然而,当你使用.运算符访问对象的属性时,属性的名称必须表示为标识符。标识符必须直接输入到你的 JavaScript 程序中;它们不是一种数据类型,因此不能被程序操作。
另一方面,当你使用[]数组表示法访问对象的属性时,属性的名称表示为字符串。字符串是 JavaScript 数据类型,因此它们可以在程序运行时被操作和创建。因此,例如,你可以在 JavaScript 中编写以下代码:
let addr = "";
for(let i = 0; i < 4; i++) {
addr += customer[`address${i}`] + "\n";
}
这段代码读取并连接customer对象的address0、address1、address2和address3属性。
这个简短的示例展示了使用数组表示法访问对象属性时的灵活性。这段代码可以使用点表示法重写,但有些情况下只有数组表示法才能胜任。例如,假设你正在编写一个程序,该程序使用网络资源计算用户股票市场投资的当前价值。该程序允许用户输入他们拥有的每支股票的名称以及每支股票的股数。你可以使用一个名为portfolio的对象来保存这些信息。对象的每个属性都代表一支股票。属性的名称是股票的名称,属性值是该股票的股数。因此,例如,如果用户持有 IBM 的 50 股,portfolio.ibm属性的值为50。
这个程序的一部分可能是一个用于向投资组合添加新股票的函数:
function addstock(portfolio, stockname, shares) {
portfolio[stockname] = shares;
}
由于用户在运行时输入股票名称,所以你无法提前知道属性名称。因为在编写程序时你无法知道属性名称,所以无法使用.运算符访问portfolio对象的属性。然而,你可以使用[]运算符,因为它使用字符串值(动态的,可以在运行时更改)而不是标识符(静态的,必须在程序中硬编码)来命名属性。
在第五章中,我们介绍了for/in循环(我们很快会再次看到它,在§6.6 中)。当你考虑它与关联数组一起使用时,这个 JavaScript 语句的强大之处就显而易见了。下面是计算投资组合总价值时如何使用它的示例:
function computeValue(portfolio) {
let total = 0.0;
for(let stock in portfolio) { // For each stock in the portfolio:
let shares = portfolio[stock]; // get the number of shares
let price = getQuote(stock); // look up share price
total += shares * price; // add stock value to total value
}
return total; // Return total value.
}
JavaScript 对象通常被用作关联数组,如下所示,了解这是如何工作的很重要。然而,在 ES6 及以后的版本中,描述在§11.1.2 中的 Map 类通常比使用普通对象更好。
6.3.2 继承
JavaScript 对象有一组“自有属性”,它们还从它们的原型对象继承了一组属性。要理解这一点,我们必须更详细地考虑属性访问。本节中的示例使用Object.create()函数创建具有指定原型的对象。然而,我们将在第九章中看到,每次使用new创建类的实例时,都会创建一个从原型对象继承属性的对象。
假设您查询对象o中的属性x。如果o没有具有该名称的自有属性,则将查询o的原型对象¹的属性x。如果原型对象没有具有该名称的自有属性,但具有自己的原型,则将在原型的原型上执行查询。这将继续,直到找到属性x或直到搜索具有null原型的对象。正如您所看到的,对象的prototype属性创建了一个链或链接列表,从中继承属性:
let o = {}; // o inherits object methods from Object.prototype
o.x = 1; // and it now has an own property x.
let p = Object.create(o); // p inherits properties from o and Object.prototype
p.y = 2; // and has an own property y.
let q = Object.create(p); // q inherits properties from p, o, and...
q.z = 3; // ...Object.prototype and has an own property z.
let f = q.toString(); // toString is inherited from Object.prototype
q.x + q.y // => 3; x and y are inherited from o and p
现在假设您对对象o的属性x进行赋值。如果o已经具有自己的(非继承的)名为x的属性,则赋值将简单地更改此现有属性的值。否则,赋值将在对象o上创建一个名为x的新属性。如果o先前继承了属性x,那么新创建的同名自有属性将隐藏该继承的属性。
属性赋值仅检查原型链以确定是否允许赋值。例如,如果o继承了一个名为x的只读属性,则不允许赋值。(有关何时可以设置属性的详细信息,请参见§6.3.3。)然而,如果允许赋值,它总是在原始对象中创建或设置属性,而不会修改原型链中的对象。查询属性时发生继承,但在设置属性时不会发生继承是 JavaScript 的一个关键特性,因为它允许我们有选择地覆盖继承的属性:
let unitcircle = { r: 1 }; // An object to inherit from
let c = Object.create(unitcircle); // c inherits the property r
c.x = 1; c.y = 1; // c defines two properties of its own
c.r = 2; // c overrides its inherited property
unitcircle.r // => 1: the prototype is not affected
有一个例外情况,即属性赋值要么失败,要么在原始对象中创建或设置属性。如果o继承了属性x,并且该属性是一个具有 setter 方法的访问器属性(参见§6.10.6),那么将调用该 setter 方法,而不是在o中创建新属性x。然而,请注意,setter 方法是在对象o上调用的,而不是在定义属性的原型对象上调用的,因此如果 setter 方法定义了任何属性,它将在o上进行,而且它将再次不修改原型链。
6.3.3 属性访问错误
属性访问表达式并不总是返回或设置一个值。本节解释了在查询或设置属性时可能出现的问题。
查询不存在的属性并不是错误的。如果在o的自有属性或继承属性中找不到属性x,则属性访问表达式o.x将求值为undefined。请记住,我们的书对象具有“子标题”属性,但没有“subtitle”属性:
book.subtitle // => undefined: property doesn't exist
然而,尝试查询不存在的对象的属性是错误的。null和undefined值没有属性,查询这些值的属性是错误的。继续前面的例子:
let len = book.subtitle.length; // !TypeError: undefined doesn't have length
如果.的左侧是null或undefined,则属性访问表达式将失败。因此,在编写诸如book.author.surname的表达式时,如果不确定book和book.author是否已定义,应谨慎。以下是防止此类问题的两种方法:
// A verbose and explicit technique
let surname = undefined;
if (book) {
if (book.author) {
surname = book.author.surname;
}
}
// A concise and idiomatic alternative to get surname or null or undefined
surname = book && book.author && book.author.surname;
要理解为什么这种成语表达式可以防止 TypeError 异常,您可能需要回顾一下&&运算符的短路行为,详情请参见§4.10.1。
如§4.4.1 中所述,ES2020 支持使用?.进行条件属性访问,这使我们可以将先前的赋值表达式重写为:
let surname = book?.author?.surname;
尝试在 null 或 undefined 上设置属性也会导致 TypeError。在其他值上尝试设置属性也不总是成功:某些属性是只读的,无法设置,某些对象不允许添加新属性。在严格模式下(§5.6.3),每当尝试设置属性失败时都会抛出 TypeError。在非严格模式下,这些失败通常是静默的。
指定属性赋值何时成功何时失败的规则是直观的,但难以简洁表达。在以下情况下,尝试设置对象 o 的属性 p 失败:
-
o有一个自己的只读属性p:无法设置只读属性。 -
o具有一个继承的只读属性p:无法通过具有相同名称的自有属性隐藏继承的只读属性。 -
o没有自己的属性p;o没有继承具有 setter 方法的属性p,且o的 可扩展 属性(见 §14.2)为false。由于o中p不存在,并且没有 setter 方法可调用,因此必须将p添加到o中。但如果o不可扩展,则无法在其上定义新属性。
6.4 删除属性
delete 运算符(§4.13.4)从对象中删除属性。其单个操作数应为属性访问表达式。令人惊讶的是,delete 不是作用于属性的值,而是作用于属性本身:
delete book.author; // The book object now has no author property.
delete book["main title"]; // Now it doesn't have "main title", either.
delete 运算符仅删除自有属性,而不删除继承的属性。(要删除继承的属性,必须从定义该属性的原型对象中删除它。这会影响从该原型继承的每个对象。)
delete 表达式在删除成功删除或删除无效(例如删除不存在的属性)时求值为 true。当与非属性访问表达式一起使用时,delete 也会求值为 true(毫无意义地):
let o = {x: 1}; // o has own property x and inherits property toString
delete o.x // => true: deletes property x
delete o.x // => true: does nothing (x doesn't exist) but true anyway
delete o.toString // => true: does nothing (toString isn't an own property)
delete 1 // => true: nonsense, but true anyway
delete 不会删除具有 可配置 属性为 false 的属性。某些内置对象的属性是不可配置的,变量声明和函数声明创建的全局对象的属性也是如此。在严格模式下,尝试删除不可配置属性会导致 TypeError。在非严格模式下,此情况下 delete 简单地求值为 false:
// In strict mode, all these deletions throw TypeError instead of returning false
delete Object.prototype // => false: property is non-configurable
var x = 1; // Declare a global variable
delete globalThis.x // => false: can't delete this property
function f() {} // Declare a global function
delete globalThis.f // => false: can't delete this property either
在非严格模式下删除全局对象的可配置属性时,可以省略对全局对象的引用,只需跟随 delete 运算符后面的属性名:
globalThis.x = 1; // Create a configurable global property (no let or var)
delete x // => true: this property can be deleted
然而,在严格模式下,如果其操作数是像 x 这样的未限定标识符,delete 会引发 SyntaxError,并且您必须明确指定属性访问:
delete x; // SyntaxError in strict mode
delete globalThis.x; // This works
6.5 测试属性
JavaScript 对象可以被视为属性集合,通常有必要能够测试是否属于该集合——检查对象是否具有给定名称的属性。您可以使用 in 运算符、hasOwnProperty() 和 propertyIsEnumerable() 方法,或者简单地查询属性来实现此目的。这里显示的示例都使用字符串作为属性名称,但它们也适用于符号(§6.10.3)。
in 运算符在其左侧期望一个属性名,在其右侧期望一个对象。如果对象具有该名称的自有属性或继承属性,则返回 true:
let o = { x: 1 };
"x" in o // => true: o has an own property "x"
"y" in o // => false: o doesn't have a property "y"
"toString" in o // => true: o inherits a toString property
对象的 hasOwnProperty() 方法测试该对象是否具有给定名称的自有属性。对于继承属性,它返回 false:
let o = { x: 1 };
o.hasOwnProperty("x") // => true: o has an own property x
o.hasOwnProperty("y") // => false: o doesn't have a property y
o.hasOwnProperty("toString") // => false: toString is an inherited property
propertyIsEnumerable() 优化了 hasOwnProperty() 测试。只有在命名属性是自有属性且其可枚举属性为 true 时才返回 true。某些内置属性是不可枚举的。通过正常的 JavaScript 代码创建的属性是可枚举的,除非你使用了 §14.1 中展示的技术之一使它们变为不可枚举。
let o = { x: 1 };
o.propertyIsEnumerable("x") // => true: o has an own enumerable property x
o.propertyIsEnumerable("toString") // => false: not an own property
Object.prototype.propertyIsEnumerable("toString") // => false: not enumerable
不必使用 in 运算符,通常只需查询属性并使用 !== 来确保它不是未定义的:
let o = { x: 1 };
o.x !== undefined // => true: o has a property x
o.y !== undefined // => false: o doesn't have a property y
o.toString !== undefined // => true: o inherits a toString property
in 运算符可以做到这里展示的简单属性访问技术无法做到的一件事。in 可以区分不存在的属性和已设置为 undefined 的属性。考虑以下代码:
let o = { x: undefined }; // Property is explicitly set to undefined
o.x !== undefined // => false: property exists but is undefined
o.y !== undefined // => false: property doesn't even exist
"x" in o // => true: the property exists
"y" in o // => false: the property doesn't exist
delete o.x; // Delete the property x
"x" in o // => false: it doesn't exist anymore
6.6 枚举属性
有时我们不想测试单个属性的存在,而是想遍历或获取对象的所有属性列表。有几种不同的方法可以做到这一点。
for/in 循环在 §5.4.5 中有介绍。它会为指定对象的每个可枚举属性(自有或继承的)执行一次循环体,将属性的名称赋给循环变量。对象继承的内置方法是不可枚举的,但你的代码添加到对象的属性默认是可枚举的。例如:
let o = {x: 1, y: 2, z: 3}; // Three enumerable own properties
o.propertyIsEnumerable("toString") // => false: not enumerable
for(let p in o) { // Loop through the properties
console.log(p); // Prints x, y, and z, but not toString
}
为了防止使用 for/in 枚举继承属性,你可以在循环体内添加一个显式检查:
for(let p in o) {
if (!o.hasOwnProperty(p)) continue; // Skip inherited properties
}
for(let p in o) {
if (typeof o[p] === "function") continue; // Skip all methods
}
作为使用 for/in 循环的替代方案,通常更容易获得对象的属性名称数组,然后使用 for/of 循环遍历该数组。有四个函数可以用来获取属性名称数组:
-
Object.keys()返回一个对象的可枚举自有属性名称的数组。它不包括不可枚举属性、继承属性或名称为 Symbol 的属性(参见 §6.10.3)。 -
Object.getOwnPropertyNames()的工作方式类似于Object.keys(),但会返回一个非枚举自有属性名称的数组,只要它们的名称是字符串。 -
Object.getOwnPropertySymbols()返回那些名称为 Symbol 的自有属性,无论它们是否可枚举。 -
Reflect.ownKeys()返回所有自有属性名称,包括可枚举和不可枚举的,以及字符串和 Symbol。 (参见 §14.6.)
在 §6.7 中有关于使用 Object.keys() 与 for/of 循环的示例。
6.6.1 属性枚举顺序
ES6 正式定义了对象自有属性枚举的顺序。Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()、Reflect.ownKeys() 和相关方法如 JSON.stringify() 都按照以下顺序列出属性,受其自身关于是否列出非枚举属性或属性名称为字符串或 Symbol 的额外约束:
-
名称为非负整数的字符串属性首先按数字顺序从小到大列出。这个规则意味着数组和类数组对象的属性将按顺序枚举。
-
列出所有看起来像数组索引的属性后,所有剩余的具有字符串名称的属性也会被列出(包括看起来像负数或浮点数的属性)。这些属性按照它们添加到对象的顺序列出。对于对象字面量中定义的属性,这个顺序与它们在字面量中出现的顺序相同。
-
最后,那些名称为 Symbol 对象的属性按照它们添加到对象的顺序列出。
for/in 循环的枚举顺序并没有像这些枚举函数那样严格规定,但通常的实现会按照刚才描述的顺序枚举自有属性,然后沿着原型链向上遍历,对每个原型对象按照相同的顺序枚举属性。然而,请注意,如果同名属性已经被枚举过,或者即使同名的不可枚举属性已经被考虑过,该属性将不会被枚举。
6.7 扩展对象
JavaScript 程序中的一个常见操作是需要将一个对象的属性复制到另一个对象中。可以使用以下代码轻松实现这一操作:
let target = {x: 1}, source = {y: 2, z: 3};
for(let key of Object.keys(source)) {
target[key] = source[key];
}
target // => {x: 1, y: 2, z: 3}
但由于这是一个常见的操作,各种 JavaScript 框架已经定义了实用函数,通常命名为 extend(),来执行这种复制操作。最后,在 ES6 中,这种能力以 Object.assign() 的形式进入了核心 JavaScript 语言。
Object.assign() 期望两个或更多对象作为其参数。它修改并返回第一个参数,即目标对象,但不会改变第二个或任何后续参数,即源对象。对于每个源对象,它将该对象的可枚举自有属性(包括那些名称为 Symbols 的属性)复制到目标对象中。它按照参数列表顺序处理源对象,因此第一个源对象中的属性将覆盖目标对象中同名的属性,第二个源对象中的属性(如果有的话)将覆盖第一个源对象中同名的属性。
Object.assign() 使用普通的属性获取和设置操作来复制属性,因此如果源对象具有 getter 方法或目标对象具有 setter 方法,则它们将在复制过程中被调用,但它们本身不会被复制。
将一个对象的属性分配到另一个对象中的一个原因是,当你有一个对象定义了许多属性的默认值,并且希望将这些默认属性复制到另一个对象中,如果该对象中不存在同名属性。简单地使用 Object.assign() 不会达到你想要的效果:
Object.assign(o, defaults); // overwrites everything in o with defaults
相反,您可以创建一个新对象,将默认值复制到其中,然后用 o 中的属性覆盖这些默认值:
o = Object.assign({}, defaults, o);
我们将在 §6.10.4 中看到,您还可以使用 ... 展开运算符来表达这种对象复制和覆盖操作,就像这样:
o = {...defaults, ...o};
我们也可以通过编写一个只在属性缺失时才复制属性的版本的 Object.assign() 来避免额外的对象创建和复制开销:
// Like Object.assign() but doesn't override existing properties
// (and also doesn't handle Symbol properties)
function merge(target, ...sources) {
for(let source of sources) {
for(let key of Object.keys(source)) {
if (!(key in target)) { // This is different than Object.assign()
target[key] = source[key];
}
}
}
return target;
}
Object.assign({x: 1}, {x: 2, y: 2}, {y: 3, z: 4}) // => {x: 2, y: 3, z: 4}
merge({x: 1}, {x: 2, y: 2}, {y: 3, z: 4}) // => {x: 1, y: 2, z: 4}
编写其他类似这个 merge() 函数的属性操作实用程序是很简单的。例如,restrict() 函数可以删除对象的属性,如果这些属性在另一个模板对象中不存在。或者 subtract() 函数可以从另一个对象中删除所有属性。
6.8 序列化对象
对象序列化是将对象状态转换为一个字符串的过程,以便以后可以恢复该对象。函数 JSON.stringify() 和 JSON.parse() 可以序列化和恢复 JavaScript 对象。这些函数使用 JSON 数据交换格式。JSON 代表“JavaScript 对象表示法”,其语法与 JavaScript 对象和数组文字非常相似:
let o = {x: 1, y: {z: [false, null, ""]}}; // Define a test object
let s = JSON.stringify(o); // s == '{"x":1,"y":{"z":[false,null,""]}}'
let p = JSON.parse(s); // p == {x: 1, y: {z: [false, null, ""]}}
JSON 语法是 JavaScript 语法的子集,它不能表示所有 JavaScript 值。支持并可以序列化和还原的有对象、数组、字符串、有限数字、true、false和null。NaN、Infinity和-Infinity被序列化为null。Date 对象被序列化为 ISO 格式的日期字符串(参见Date.toJSON()函数),但JSON.parse()将它们保留为字符串形式,不会还原原始的 Date 对象。Function、RegExp 和 Error 对象以及undefined值不能被序列化或还原。JSON.stringify()只序列化对象的可枚举自有属性。如果属性值无法序列化,则该属性将简单地从字符串化输出中省略。JSON.stringify()和JSON.parse()都接受可选的第二个参数,用于通过指定要序列化的属性列表来自定义序列化和/或还原过程,例如,在序列化或字符串化过程中转换某些值。这些函数的完整文档在§11.6 中。
6.9 对象方法
正如前面讨论的,所有 JavaScript 对象(除了明确创建时没有原型的对象)都从Object.prototype继承属性。这些继承的属性主要是方法,因为它们是普遍可用的,所以它们对 JavaScript 程序员特别感兴趣。例如,我们已经看到了hasOwnProperty()和propertyIsEnumerable()方法。(我们也已经涵盖了Object构造函数上定义的许多静态函数,比如Object.create()和Object.keys()。)本节解释了一些定义在Object.prototype上的通用对象方法,但是这些方法旨在被其他更专门的实现所取代。在接下来的章节中,我们将展示在单个对象上定义这些方法的示例。在第九章中,您将学习如何为整个对象类更普遍地定义这些方法。
6.9.1 toString() 方法
toString() 方法不接受任何参数;它返回一个表示调用它的对象的值的字符串。JavaScript 在需要将对象转换为字符串时会调用这个方法。例如,当你使用+运算符将字符串与对象连接在一起,或者当你将对象传递给期望字符串的方法时,就会发生这种情况。
默认的toString()方法并不是很有信息量(尽管它对于确定对象的类很有用,正如我们将在§14.4.3 中看到的)。例如,以下代码行简单地评估为字符串“[object Object]”:
let s = { x: 1, y: 1 }.toString(); // s == "[object Object]"
因为这个默认方法并不显示太多有用信息,许多类定义了它们自己的toString()版本。例如,当数组转换为字符串时,你会得到一个数组元素列表,它们各自被转换为字符串,当函数转换为字符串时,你会得到函数的源代码。你可以像这样定义自己的toString()方法:
let point = {
x: 1,
y: 2,
toString: function() { return `(${this.x}, ${this.y})`; }
};
String(point) // => "(1, 2)": toString() is used for string conversions
6.9.2 toLocaleString() 方法
除了基本的toString()方法外,所有对象都有一个toLocaleString()方法。这个方法的目的是返回对象的本地化字符串表示。Object 定义的默认toLocaleString()方法不进行任何本地化:它只是调用toString()并返回该值。Date 和 Number 类定义了定制版本的toLocaleString(),试图根据本地惯例格式化数字、日期和时间。Array 定义了一个toLocaleString()方法,工作方式类似于toString(),只是通过调用它们的toLocaleString()方法而不是toString()方法来格式化数组元素。你可以像这样处理point对象:
let point = {
x: 1000,
y: 2000,
toString: function() { return `(${this.x}, ${this.y})`; },
toLocaleString: function() {
return `(${this.x.toLocaleString()}, ${this.y.toLocaleString()})`;
}
};
point.toString() // => "(1000, 2000)"
point.toLocaleString() // => "(1,000, 2,000)": note thousands separators
在实现 toLocaleString() 方法时,§11.7 中记录的国际化类可能会很有用。
6.9.3 valueOf() 方法
valueOf() 方法类似于 toString() 方法,但当 JavaScript 需要将对象转换为除字符串以外的某种原始类型时(通常是数字),就会调用它。如果对象在需要原始值的上下文中使用,JavaScript 会自动调用这个方法。默认的 valueOf() 方法没有什么有趣的功能,但一些内置类定义了自己的 valueOf() 方法。Date 类定义了 valueOf() 方法来将日期转换为数字,这允许使用 < 和 > 来对日期对象进行比较。你可以通过定义一个 valueOf() 方法来实现类似的功能,返回从原点到点的距离:
let point = {
x: 3,
y: 4,
valueOf: function() { return Math.hypot(this.x, this.y); }
};
Number(point) // => 5: valueOf() is used for conversions to numbers
point > 4 // => true
point > 5 // => false
point < 6 // => true
6.9.4 toJSON() 方法
Object.prototype 实际上并没有定义 toJSON() 方法,但 JSON.stringify() 方法(参见 §6.8)会在要序列化的任何对象上查找 toJSON() 方法。如果这个方法存在于要序列化的对象上,它就会被调用,返回值会被序列化,而不是原始对象。Date 类(§11.4)定义了一个 toJSON() 方法,返回日期的可序列化字符串表示。我们可以为我们的 Point 对象做同样的事情:
let point = {
x: 1,
y: 2,
toString: function() { return `(${this.x}, ${this.y})`; },
toJSON: function() { return this.toString(); }
};
JSON.stringify([point]) // => '["(1, 2)"]'
6.10 扩展对象字面量语法
JavaScript 的最新版本在对象字面量的语法上以多种有用的方式进行了扩展。以下小节解释了这些扩展。
6.10.1 简写属性
假设你有存储在变量 x 和 y 中的值,并且想要创建一个具有名为 x 和 y 的属性的对象,其中包含这些值。使用基本对象字面量语法,你将重复每个标识符两次:
let x = 1, y = 2;
let o = {
x: x,
y: y
};
在 ES6 及更高版本中,你可以省略冒号和一个标识符的副本,从而得到更简洁的代码:
let x = 1, y = 2;
let o = { x, y };
o.x + o.y // => 3
6.10.2 计算属性名
有时候你需要创建一个具有特定属性的对象,但该属性的名称不是你可以在源代码中直接输入的编译时常量。相反,你需要的属性名称存储在一个变量中,或者是一个你调用的函数的返回值。你不能使用基本对象字面量来定义这种属性。相反,你必须先创建一个对象,然后作为额外步骤添加所需的属性:
const PROPERTY_NAME = "p1";
function computePropertyName() { return "p" + 2; }
let o = {};
o[PROPERTY_NAME] = 1;
o[computePropertyName()] = 2;
使用 ES6 功能中称为计算属性的功能,可以更简单地设置一个对象,直接将前面代码中的方括号移到对象字面量中:
const PROPERTY_NAME = "p1";
function computePropertyName() { return "p" + 2; }
let p = {
[PROPERTY_NAME]: 1,
[computePropertyName()]: 2
};
p.p1 + p.p2 // => 3
使用这种新的语法,方括号限定了任意的 JavaScript 表达式。该表达式被评估,结果值(如有必要,转换为字符串)被用作属性名。
一个情况下你可能想使用计算属性的地方是当你有一个 JavaScript 代码库,该库期望传递具有特定属性集的对象,并且这些属性的名称在该库中被定义为常量。如果你正在编写代码来创建将传递给该库的对象,你可以硬编码属性名称,但如果在任何地方输入属性名称错误,就会出现错误,如果库的新版本更改了所需的属性名称,就会出现版本不匹配的问题。相反,你可能会发现使用由库定义的属性名常量与计算属性语法使你的代码更加健壮。
6.10.3 符号作为属性名
计算属性语法还启用了另一个非常重要的对象字面量特性。在 ES6 及更高版本中,属性名称可以是字符串或符号。如果将符号分配给变量或常量,那么可以使用计算属性语法将该符号作为属性名:
const extension = Symbol("my extension symbol");
let o = {
[extension]: { /* extension data stored in this object */ }
};
o[extension].x = 0; // This won't conflict with other properties of o
如§3.6 中所解释的,符号是不透明的值。你不能对它们做任何操作,只能将它们用作属性名称。然而,每个符号都与其他任何符号都不同,这意味着符号非常适合创建唯一的属性名称。通过调用Symbol()工厂函数创建一个新符号。(符号是原始值,不是对象,因此Symbol()不是一个你使用new调用的构造函数。)Symbol()返回的值不等于任何其他符号或其他值。你可以向Symbol()传递一个字符串,当你的符号转换为字符串时,将使用该字符串。但这仅用于调试:使用相同字符串参数创建的两个符号仍然彼此不同。
符号的作用不是安全性,而是为 JavaScript 对象定义一个安全的扩展机制。如果你从你无法控制的第三方代码中获取一个对象,并且需要向该对象添加一些你自己的属性,但又希望确保你的属性不会与对象上可能已经存在的任何属性发生冲突,那么你可以安全地使用符号作为你的属性名称。如果你这样做,你还可以确信第三方代码不会意外地更改你的以符号命名的属性。(当然,该第三方代码可以使用Object.getOwnPropertySymbols()来发现你正在使用的符号,并可能更改或删除你的属性。这就是为什么符号不是一种安全机制。)
6.10.4 展开运算符
在 ES2018 及更高版本中,你可以使用“展开运算符”...将现有对象的属性复制到一个新对象中,写在对象字面量内部:
let position = { x: 0, y: 0 };
let dimensions = { width: 100, height: 75 };
let rect = { ...position, ...dimensions };
rect.x + rect.y + rect.width + rect.height // => 175
在这段代码中,position和dimensions对象的属性被“展开”到rect对象字面量中,就好像它们被直接写在那些花括号内一样。请注意,这种...语法通常被称为展开运算符,但在任何情况下都不是真正的 JavaScript 运算符。相反,它是仅在对象字面量内部可用的特殊语法。 (在其他 JavaScript 上下文中,三个点用于其他目的,但对象字面量是唯一的上下文,其中这三个点会导致一个对象插入到另一个对象中。)
如果被展开的对象和被展开到的对象都有同名属性,则该属性的值将是最后一个出现的值:
let o = { x: 1 };
let p = { x: 0, ...o };
p.x // => 1: the value from object o overrides the initial value
let q = { ...o, x: 2 };
q.x // => 2: the value 2 overrides the previous value from o.
还要注意,展开运算符只展开对象的自有属性,而不包括任何继承的属性:
let o = Object.create({x: 1}); // o inherits the property x
let p = { ...o };
p.x // => undefined
最后,值得注意的是,尽管展开运算符在你的代码中只是三个小点,但它可能代表 JavaScript 解释器大量的工作。如果一个对象有n个属性,将这些属性展开到另一个对象中的过程可能是一个O(n)的操作。这意味着如果你发现自己在循环或递归函数中使用...来将数据累积到一个大对象中,你可能正在编写一个效率低下的O(n²)算法,随着n的增大,它的性能将不会很好。
6.10.5 简写方法
当一个函数被定义为对象的属性时,我们称该函数为方法(我们将在第八章和第九章中详细讨论方法)。在 ES6 之前,你可以使用函数定义表达式在对象字面量中定义一个方法,就像你定义对象的任何其他属性一样:
let square = {
area: function() { return this.side * this.side; },
side: 10
};
square.area() // => 100
然而,在 ES6 中,对象字面量语法(以及我们将在第九章中看到的类定义语法)已经扩展,允许一种快捷方式,其中省略了function关键字和冒号,导致代码如下:
let square = {
area() { return this.side * this.side; },
side: 10
};
square.area() // => 100
两种形式的代码是等价的:都向对象字面量添加了一个名为area的属性,并将该属性的值设置为指定的函数。简写语法使得area()是一个方法,而不是像side那样的数据属性。
当使用这种简写语法编写方法时,属性名称可以采用对象字面量中合法的任何形式:除了像上面的area名称一样的常规 JavaScript 标识符外,还可以使用字符串文字和计算属性名称,其中可以包括 Symbol 属性名称:
const METHOD_NAME = "m";
const symbol = Symbol();
let weirdMethods = {
"method With Spaces"(x) { return x + 1; },
METHOD_NAME { return x + 2; },
symbol { return x + 3; }
};
weirdMethods"method With Spaces" // => 2
weirdMethodsMETHOD_NAME // => 3
weirdMethodssymbol // => 4
使用符号作为方法名并不像看起来那么奇怪。为了使对象可迭代(以便与for/of循环一起使用),必须定义一个具有符号名称Symbol.iterator的方法,第十二章中有这样做的示例。
6.10.6 属性的 getter 和 setter
到目前为止,在本章中讨论的所有对象属性都是具有名称和普通值的数据属性。JavaScript 还支持访问器属性,它们没有单个值,而是具有一个或两个访问器方法:一个getter和/或一个setter。
当程序查询访问器属性的值时,JavaScript 会调用 getter 方法(不传递任何参数)。此方法的返回值成为属性访问表达式的值。当程序设置访问器属性的值时,JavaScript 会调用 setter 方法,传递赋值右侧的值。该方法负责在某种意义上“设置”属性值。setter 方法的返回值将被忽略。
如果一个属性同时具有 getter 和 setter 方法,则它是一个读/写属性。如果它只有 getter 方法,则它是一个只读属性。如果它只有 setter 方法,则它是一个只写属性(这是使用数据属性不可能实现的),并且尝试读取它的值总是评估为undefined。
访问器属性可以使用对象字面量语法的扩展来定义(与我们在这里看到的其他 ES6 扩展不同,getter 和 setter 是在 ES5 中引入的):
let o = {
// An ordinary data property
dataProp: value,
// An accessor property defined as a pair of functions.
get accessorProp() { return this.dataProp; },
set accessorProp(value) { this.dataProp = value; }
};
访问器属性被定义为一个或两个方法,其名称与属性名称相同。它们看起来像使用 ES6 简写定义的普通方法,只是 getter 和 setter 定义前缀为get或set。(在 ES6 中,当定义 getter 和 setter 时,也可以使用计算属性名称。只需在get或set后用方括号中的表达式替换属性名称。)
上面定义的访问器方法只是获取和设置数据属性的值,并没有理由优先使用访问器属性而不是数据属性。但作为一个更有趣的例子,考虑以下表示 2D 笛卡尔点的对象。它具有普通数据属性来表示点的x和y坐标,并且具有访问器属性来给出点的等效极坐标:
let p = {
// x and y are regular read-write data properties.
x: 1.0,
y: 1.0,
// r is a read-write accessor property with getter and setter.
// Don't forget to put a comma after accessor methods.
get r() { return Math.hypot(this.x, this.y); },
set r(newvalue) {
let oldvalue = Math.hypot(this.x, this.y);
let ratio = newvalue/oldvalue;
this.x *= ratio;
this.y *= ratio;
},
// theta is a read-only accessor property with getter only.
get theta() { return Math.atan2(this.y, this.x); }
};
p.r // => Math.SQRT2
p.theta // => Math.PI / 4
注意在这个例子中,关键字this在 getter 和 setter 中的使用。JavaScript 将这些函数作为定义它们的对象的方法调用,这意味着在函数体内,this指的是点对象p。因此,r属性的 getter 方法可以将x和y属性称为this.x和this.y。更详细地讨论方法和this关键字在§8.2.2 中有介绍。
访问器属性是继承的,就像数据属性一样,因此可以将上面定义的对象p用作其他点的原型。您可以为新对象提供它们自己的x和y属性,并且它们将继承r和theta属性:
let q = Object.create(p); // A new object that inherits getters and setters
q.x = 3; q.y = 4; // Create q's own data properties
q.r // => 5: the inherited accessor properties work
q.theta // => Math.atan2(4, 3)
上面的代码使用访问器属性来定义一个 API,提供单组数据的两种表示(笛卡尔坐标和极坐标)。使用访问器属性的其他原因包括对属性写入进行检查和在每次属性读取时返回不同的值:
// This object generates strictly increasing serial numbers
const serialnum = {
// This data property holds the next serial number.
// The _ in the property name hints that it is for internal use only.
_n: 0,
// Return the current value and increment it
get next() { return this._n++; },
// Set a new value of n, but only if it is larger than current
set next(n) {
if (n > this._n) this._n = n;
else throw new Error("serial number can only be set to a larger value");
}
};
serialnum.next = 10; // Set the starting serial number
serialnum.next // => 10
serialnum.next // => 11: different value each time we get next
最后,这里是另一个示例,使用 getter 方法实现具有“神奇”行为的属性:
// This object has accessor properties that return random numbers.
// The expression "random.octet", for example, yields a random number
// between 0 and 255 each time it is evaluated.
const random = {
get octet() { return Math.floor(Math.random()*256); },
get uint16() { return Math.floor(Math.random()*65536); },
get int16() { return Math.floor(Math.random()*65536)-32768; }
};
6.11 总结
本章详细记录了 JavaScript 对象,涵盖的主题包括:
-
基本对象术语,包括诸如可枚举和自有属性等术语的含义。
-
对象字面量语法,包括 ES6 及以后版本中的许多新特性。
-
如何读取、写入、删除、枚举和检查对象的属性是否存在。
-
JavaScript 中基于原型的继承是如何工作的,以及如何使用
Object.create()创建一个继承自另一个对象的对象。 -
如何使用
Object.assign()将一个对象的属性复制到另一个对象中。
所有非原始值的 JavaScript 值都是对象。这包括数组和函数,它们是接下来两章的主题。
¹ 记住;几乎所有对象都有一个原型,但大多数对象没有名为prototype的属性。即使无法直接访问原型对象,JavaScript 继承仍然有效。但如果想学习如何做到这一点,请参见§14.3。
第七章:数组
本章介绍了数组,这是 JavaScript 和大多数其他编程语言中的一种基本数据类型。数组是一个有序的值集合。每个值称为一个元素,每个元素在数组中有一个数值位置,称为其索引。JavaScript 数组是无类型的:数组元素可以是任何类型,同一数组的不同元素可以是不同类型。数组元素甚至可以是对象或其他数组,这使您可以创建复杂的数据结构,例如对象数组和数组数组。JavaScript 数组是基于零的,并使用 32 位索引:第一个元素的索引为 0,最大可能的索引为 4294967294(2³²−2),最大数组大小为 4,294,967,295 个元素。JavaScript 数组是动态的:它们根据需要增长或缩小,并且在创建数组时无需声明固定大小,也无需在大小更改时重新分配。JavaScript 数组可能是稀疏的:元素不必具有连续的索引,可能存在间隙。每个 JavaScript 数组都有一个length属性。对于非稀疏数组,此属性指定数组中的元素数量。对于稀疏数组,length大于任何元素的最高索引。
JavaScript 数组是 JavaScript 对象的一种特殊形式,数组索引实际上只是整数属性名。我们将在本章的其他地方更详细地讨论数组的特殊性。实现通常会优化数组,使得对数值索引的数组元素的访问通常比对常规对象属性的访问要快得多。
数组从Array.prototype继承属性,该属性定义了一组丰富的数组操作方法,涵盖在§7.8 中。这些方法大多是通用的,这意味着它们不仅适用于真实数组,还适用于任何“类似数组的对象”。我们将在§7.9 中讨论类似数组的对象。最后,JavaScript 字符串的行为类似于字符数组,我们将在§7.10 中讨论这一点。
ES6 引入了一组被统称为“类型化数组”的新数组类。与常规的 JavaScript 数组不同,类型化数组具有固定的长度和固定的数值元素类型。它们提供高性能和对二进制数据的字节级访问,并在§11.2 中有所涉及。
7.1 创建数组
有几种创建数组的方法。接下来的小节将解释如何使用以下方式创建数组:
-
数组字面量
-
可迭代对象上的
...展开运算符 -
Array()构造函数 -
Array.of()和Array.from()工厂方法
7.1.1 数组字面量
创造数组最简单的方法是使用数组字面量,它只是方括号内以逗号分隔的数组元素列表。例如:
let empty = []; // An array with no elements
let primes = [2, 3, 5, 7, 11]; // An array with 5 numeric elements
let misc = [ 1.1, true, "a", ]; // 3 elements of various types + trailing comma
数组字面量中的值不必是常量;它们可以是任意表达式:
let base = 1024;
let table = [base, base+1, base+2, base+3];
数组字面量可以包含对象字面量或其他数组字面量:
let b = [[1, {x: 1, y: 2}], [2, {x: 3, y: 4}]];
如果数组字面量中包含多个连续的逗号,且之间没有值,那么该数组是稀疏的(参见§7.3)。省略值的数组元素并不存在,但如果查询它们,则看起来像是undefined:
let count = [1,,3]; // Elements at indexes 0 and 2\. No element at index 1
let undefs = [,,]; // An array with no elements but a length of 2
数组字面量语法允许有可选的尾随逗号,因此[,,]的长度为 2,而不是 3。
7.1.2 展开运算符
在 ES6 及更高版本中,您可以使用“展开运算符”...将一个数组的元素包含在一个数组字面量中:
let a = [1, 2, 3];
let b = [0, ...a, 4]; // b == [0, 1, 2, 3, 4]
这三个点“展开”数组a,使得它的元素成为正在创建的数组字面量中的元素。就好像...a被数组a的元素替换,字面上列为封闭数组字面量的一部分。 (请注意,尽管我们称这三个点为展开运算符,但这不是一个真正的运算符,因为它只能在数组字面量中使用,并且正如我们将在本书后面看到的,函数调用。)
展开运算符是创建(浅层)数组副本的便捷方式:
let original = [1,2,3];
let copy = [...original];
copy[0] = 0; // Modifying the copy does not change the original
original[0] // => 1
展开运算符适用于任何可迭代对象。(可迭代对象是for/of循环迭代的对象;我们首次在§5.4.4 中看到它们,并且我们将在第十二章中看到更多关于它们的内容。) 字符串是可迭代的,因此您可以使用展开运算符将任何字符串转换为由单个字符字符串组成的数组:
let digits = [..."0123456789ABCDEF"];
digits // => ["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"]
集合对象(§11.1.1)是可迭代的,因此从数组中删除重复元素的简单方法是将数组转换为集合,然后立即使用展开运算符将集合转换回数组:
let letters = [..."hello world"];
[...new Set(letters)] // => ["h","e","l","o"," ","w","r","d"]
7.1.3 Array() 构造函数
另一种创建数组的方法是使用Array()构造函数。您可以以三种不同的方式调用此构造函数:
-
不带参数调用它:
let a = new Array();此方法创建一个没有元素的空数组,等同于数组字面量
[]。 -
使用单个数字参数调用它,指定长度:
let a = new Array(10);这种技术创建具有指定长度的数组。当您事先知道将需要多少元素时,可以使用
Array()构造函数的这种形式来预先分配数组。请注意,数组中不存储任何值,并且数组索引属性“0”、“1”等甚至未为数组定义。 -
明确指定两个或更多数组元素或单个非数值元素:
let a = new Array(5, 4, 3, 2, 1, "testing, testing");在这种形式中,构造函数参数成为新数组的元素。几乎总是比使用
Array()构造函数更简单的是使用数组字面量。
7.1.4 Array.of()
当使用一个数值参数调用Array()构造函数时,它将该参数用作数组长度。但是,当使用多个数值参数调用时,它将这些参数视为要创建的数组的元素。这意味着Array()构造函数不能用于创建具有单个数值元素的数组。
在 ES6 中,Array.of()函数解决了这个问题:它是一个工厂方法,使用其参数值(无论有多少个)作为数组元素创建并返回一个新数组:
Array.of() // => []; returns empty array with no arguments
Array.of(10) // => [10]; can create arrays with a single numeric argument
Array.of(1,2,3) // => [1, 2, 3]
7.1.5 Array.from()
Array.from是 ES6 中引入的另一个数组工厂方法。它期望一个可迭代或类似数组的对象作为其第一个参数,并返回一个包含该对象元素的新数组。对于可迭代参数,Array.from(iterable)的工作方式类似于展开运算符[...iterable]。这也是制作数组副本的简单方法:
let copy = Array.from(original);
Array.from()也很重要,因为它定义了一种使类似数组对象的真数组副本的方法。类似数组的对象是具有数值长度属性并且具有存储值的属性的非数组对象,这些属性的名称恰好是整数。在使用客户端 JavaScript 时,某些 Web 浏览器方法的返回值是类似数组的,如果您首先将它们转换为真数组,那么使用它们可能会更容易:
let truearray = Array.from(arraylike);
Array.from()还接受一个可选的第二个参数。如果将一个函数作为第二个参数传递,那么在构建新数组时,源对象的每个元素都将传递给您指定的函数,并且函数的返回值将存储在数组中,而不是原始值。(这非常类似于稍后将在本章介绍的数组map()方法,但在构建数组时执行映射比构建数组然后将其映射到另一个新数组更有效。)
7.2 读取和写入数组元素
使用[]运算符访问数组元素。方括号左侧应该是数组的引用。方括号内应该是一个非负整数值的任意表达式。你可以使用这种语法来读取和写入数组元素的值。因此,以下都是合法的 JavaScript 语句:
let a = ["world"]; // Start with a one-element array
let value = a[0]; // Read element 0
a[1] = 3.14; // Write element 1
let i = 2;
a[i] = 3; // Write element 2
a[i + 1] = "hello"; // Write element 3
a[a[i]] = a[0]; // Read elements 0 and 2, write element 3
数组的特殊之处在于,当你使用非负整数且小于 2³²–1 的属性名时,数组会自动为你维护length属性的值。例如,在前面的例子中,我们创建了一个只有一个元素的数组a。然后我们在索引 1、2 和 3 处分配了值。随着我们的操作,数组的length属性也发生了变化,因此:
a.length // => 4
请记住,数组是一种特殊类型的对象。用于访问数组元素的方括号与用于访问对象属性的方括号工作方式相同。JavaScript 将你指定的数值数组索引转换为字符串——索引1变为字符串"1"——然后将该字符串用作属性名。将索引从数字转换为字符串没有什么特殊之处:你也可以对常规对象这样做:
let o = {}; // Create a plain object
o[1] = "one"; // Index it with an integer
o["1"] // => "one"; numeric and string property names are the same
清楚地区分数组索引和对象属性名是有帮助的。所有索引都是属性名,但只有介于 0 和 2³²–2 之间的整数属性名才是索引。所有数组都是对象,你可以在它们上面创建任何名称的属性。然而,如果你使用的是数组索引的属性,数组会根据需要更新它们的length属性。
请注意,你可以使用负数或非整数的数字对数组进行索引。当你这样做时,数字会转换为字符串,并且该字符串将用作属性名。由于名称不是非负整数,因此它被视为常规对象属性,而不是数组索引。此外,如果你使用恰好是非负整数的字符串对数组进行索引,它将表现为数组索引,而不是对象属性。如果你使用与整数相同的浮点数,情况也是如此:
a[-1.23] = true; // This creates a property named "-1.23"
a["1000"] = 0; // This the 1001st element of the array
a[1.000] = 1; // Array index 1\. Same as a[1] = 1;
数组索引只是对象属性名的一种特殊类型,这意味着 JavaScript 数组没有“越界”错误的概念。当你尝试查询任何对象的不存在属性时,你不会收到错误;你只会得到undefined。对于数组和对象来说,这一点同样适用:
let a = [true, false]; // This array has elements at indexes 0 and 1
a[2] // => undefined; no element at this index.
a[-1] // => undefined; no property with this name.
7.3 稀疏数组
稀疏数组是指元素的索引不是从 0 开始的连续索引。通常,数组的length属性指定数组中元素的数量。如果数组是稀疏的,length属性的值将大于元素的数量。可以使用Array()构造函数创建稀疏数组,或者简单地通过分配给大于当前数组length的数组索引来创建稀疏数组。
let a = new Array(5); // No elements, but a.length is 5.
a = []; // Create an array with no elements and length = 0.
a[1000] = 0; // Assignment adds one element but sets length to 1001.
我们稍后会看到,你也可以使用delete运算符使数组变得稀疏。
具有足够稀疏性的数组通常以比密集数组更慢、更节省内存的方式实现,查找这种数组中的元素将花费与常规对象属性查找相同的时间。
注意,当你在数组字面量中省略一个值(使用重复逗号,如[1,,3]),结果得到的数组是稀疏的,省略的元素简单地不存在:
let a1 = [,]; // This array has no elements and length 1
let a2 = [undefined]; // This array has one undefined element
0 in a1 // => false: a1 has no element with index 0
0 in a2 // => true: a2 has the undefined value at index 0
理解稀疏数组是理解 JavaScript 数组真正本质的重要部分。然而,在实践中,你将使用的大多数 JavaScript 数组都不会是稀疏的。而且,如果你确实需要使用稀疏数组,你的代码可能会像对待具有undefined元素的非稀疏数组一样对待它。
7.4 数组长度
每个数组都有一个length属性,正是这个属性使数组与常规 JavaScript 对象不同。对于密集数组(即非稀疏数组),length属性指定数组中元素的数量。其值比数组中最高索引多一:
[].length // => 0: the array has no elements
["a","b","c"].length // => 3: highest index is 2, length is 3
当数组是稀疏的时,length属性大于元素数量,我们只能说length保证大于数组中每个元素的索引。换句话说,数组(稀疏或非稀疏)永远不会有索引大于或等于其length的元素。为了保持这个不变量,数组有两个特殊行为。我们上面描述的第一个:如果您为索引i大于或等于数组当前length的数组元素分配一个值,length属性的值将设置为i+1。
数组为了保持长度不变的第二个特殊行为是,如果您将length属性设置为小于当前值的非负整数n,则任何索引大于或等于n的数组元素将从数组中删除:
a = [1,2,3,4,5]; // Start with a 5-element array.
a.length = 3; // a is now [1,2,3].
a.length = 0; // Delete all elements. a is [].
a.length = 5; // Length is 5, but no elements, like new Array(5)
您还可以将数组的length属性设置为大于当前值的值。这样做实际上并不向数组添加任何新元素;它只是在数组末尾创建了一个稀疏区域。
7.5 添加和删除数组元素
我们已经看到向数组添加元素的最简单方法:只需为新索引分配值:
let a = []; // Start with an empty array.
a[0] = "zero"; // And add elements to it.
a[1] = "one";
您还可以使用push()方法将一个或多个值添加到数组的末尾:
let a = []; // Start with an empty array
a.push("zero"); // Add a value at the end. a = ["zero"]
a.push("one", "two"); // Add two more values. a = ["zero", "one", "two"]
将值推送到数组a上与将值分配给a[a.length]相同。您可以使用unshift()方法(在§7.8 中描述)在数组的开头插入一个值,将现有数组元素移动到更高的索引。pop()方法是push()的相反操作:它删除数组的最后一个元素并返回它,将数组的长度减少 1。类似地,shift()方法删除并返回数组的第一个元素,将长度减 1 并将所有元素向下移动到比当前索引低一个索引。有关这些方法的更多信息,请参阅§7.8。
您可以使用delete运算符删除数组元素,就像您可以删除对象属性一样:
let a = [1,2,3];
delete a[2]; // a now has no element at index 2
2 in a // => false: no array index 2 is defined
a.length // => 3: delete does not affect array length
删除数组元素与将undefined分配给该元素类似(但略有不同)。请注意,使用delete删除数组元素不会改变length属性,并且不会将具有更高索引的元素向下移动以填补被删除属性留下的空白。如果从数组中删除一个元素,数组将变得稀疏。
正如我们上面看到的,您也可以通过将length属性设置为新的所需长度来从数组末尾删除元素。
最后,splice()是用于插入、删除或替换数组元素的通用方法。它改变length属性并根据需要将数组元素移动到更高或更低的索引。有关详细信息,请参阅§7.8。
7.6 遍历数组
从 ES6 开始,遍历数组(或任何可迭代对象)的最简单方法是使用for/of循环,这在§5.4.4 中有详细介绍:
let letters = [..."Hello world"]; // An array of letters
let string = "";
for(let letter of letters) {
string += letter;
}
string // => "Hello world"; we reassembled the original text
for/of循环使用的内置数组迭代器按升序返回数组的元素。对于稀疏数组,它没有特殊行为,只是对于不存在的数组元素返回undefined。
如果您想要使用for/of循环遍历数组并需要知道每个数组元素的索引,请使用数组的entries()方法,以及解构赋值,如下所示:
let everyother = "";
for(let [index, letter] of letters.entries()) {
if (index % 2 === 0) everyother += letter; // letters at even indexes
}
everyother // => "Hlowrd"
另一种遍历数组的好方法是使用forEach()。这不是for循环的新形式,而是一种提供数组迭代功能的数组方法。您将一个函数传递给数组的forEach()方法,forEach()在数组的每个元素上调用您的函数一次:
let uppercase = "";
letters.forEach(letter => { // Note arrow function syntax here
uppercase += letter.toUpperCase();
});
uppercase // => "HELLO WORLD"
正如你所期望的那样,forEach()按顺序迭代数组,并将数组索引作为第二个参数传递给你的函数,这有时很有用。与for/of循环不同,forEach()知道稀疏数组,并且不会为不存在的元素调用你的函数。
§7.8.1 详细介绍了forEach()方法。该部分还涵盖了类似map()和filter()的相关方法,执行特定类型的数组迭代。
您还可以使用传统的for循环遍历数组的元素(§5.4.3):
let vowels = "";
for(let i = 0; i < letters.length; i++) { // For each index in the array
let letter = letters[i]; // Get the element at that index
if (/[aeiou]/.test(letter)) { // Use a regular expression test
vowels += letter; // If it is a vowel, remember it
}
}
vowels // => "eoo"
在嵌套循环或其他性能关键的情况下,有时会看到基本的数组迭代循环被写成只查找一次数组长度而不是在每次迭代中查找。以下两种for循环形式都是惯用的,尽管不是特别常见,并且在现代 JavaScript 解释器中,它们是否会对性能产生影响并不清楚:
// Save the array length into a local variable
for(let i = 0, len = letters.length; i < len; i++) {
// loop body remains the same
}
// Iterate backwards from the end of the array to the start
for(let i = letters.length-1; i >= 0; i--) {
// loop body remains the same
}
这些示例假设数组是密集的,并且所有元素都包含有效数据。如果不是这种情况,您应该在使用数组元素之前对其进行测试。如果要跳过未定义和不存在的元素,您可以这样写:
for(let i = 0; i < a.length; i++) {
if (a[i] === undefined) continue; // Skip undefined + nonexistent elements
// loop body here
}
7.7 多维数组
JavaScript 不支持真正的多维数组,但可以用数组的数组来近似实现。要访问数组中的值,只需简单地两次使用[]运算符。例如,假设变量matrix是一个包含数字数组的数组。matrix[x]中的每个元素都是一个数字数组。要访问这个数组中的特定数字,你可以写成matrix[x][y]。以下是一个使用二维数组作为乘法表的具体示例:
// Create a multidimensional array
let table = new Array(10); // 10 rows of the table
for(let i = 0; i < table.length; i++) {
table[i] = new Array(10); // Each row has 10 columns
}
// Initialize the array
for(let row = 0; row < table.length; row++) {
for(let col = 0; col < table[row].length; col++) {
table[row][col] = row*col;
}
}
// Use the multidimensional array to compute 5*7
table[5][7] // => 35
7.8 数组方法
前面的部分重点介绍了处理数组的基本 JavaScript 语法。然而,一般来说,Array 类定义的方法是最强大的。接下来的部分记录了这些方法。在阅读这些方法时,请记住其中一些方法会修改调用它们的数组,而另一些方法则会保持数组不变。其中一些方法会返回一个数组:有时这是一个新数组,原始数组保持不变。其他时候,一个方法会就地修改数组,并同时返回修改后的数组的引用。
接下来的各小节涵盖了一组相关的数组方法:
-
迭代方法循环遍历数组的元素,通常在每个元素上调用您指定的函数。
-
栈和队列方法向数组的开头和结尾添加和移除数组元素。
-
子数组方法用于提取、删除、插入、填充和复制较大数组的连续区域。
-
搜索和排序方法用于在数组中定位元素并对数组元素进行排序。
以下小节还涵盖了 Array 类的静态方法以及一些用于连接数组和将数组转换为字符串的杂项方法。
7.8.1 数组迭代方法
本节描述的方法通过将数组元素按顺序传递给您提供的函数来迭代数组,并提供了方便的方法来迭代、映射、过滤、测试和减少数组。
然而,在详细解释这些方法之前,值得对它们做一些概括。首先,所有这些方法都接受一个函数作为它们的第一个参数,并为数组的每个元素(或某些元素)调用该函数。如果数组是稀疏的,您传递的函数不会为不存在的元素调用。在大多数情况下,您提供的函数会被调用三个参数:数组元素的值、数组元素的索引和数组本身。通常,您只需要第一个参数值,可以忽略第二和第三个值。
在下面的小节中描述的大多数迭代器方法都接受一个可选的第二个参数。如果指定了,函数将被调用,就好像它是第二个参数的方法一样。也就是说,您传递的第二个参数将成为您作为第一个参数传递的函数内部的 this 关键字的值。您传递的函数的返回值通常很重要,但不同的方法以不同的方式处理返回值。这里描述的方法都不会修改调用它们的数组(尽管您传递的函数可以修改数组,当然)。
每个这些函数都是以一个函数作为其第一个参数调用的,很常见的是在方法调用表达式中定义该函数内联,而不是使用在其他地方定义的现有函数。箭头函数语法(参见§8.1.3)与这些方法特别配合,我们将在接下来的示例中使用它。
forEach()
forEach() 方法遍历数组,为每个元素调用您指定的函数。正如我们所描述的,您将函数作为第一个参数传递给 forEach()。然后,forEach() 使用三个参数调用您的函数:数组元素的值,数组元素的索引和数组本身。如果您只关心数组元素的值,您可以编写一个只有一个参数的函数——额外的参数将被忽略:
let data = [1,2,3,4,5], sum = 0;
// Compute the sum of the elements of the array
data.forEach(value => { sum += value; }); // sum == 15
// Now increment each array element
data.forEach(function(v, i, a) { a[i] = v + 1; }); // data == [2,3,4,5,6]
请注意,forEach() 不提供在所有元素被传递给函数之前终止迭代的方法。也就是说,您无法像在常规 for 循环中使用 break 语句那样使用。
map()
map() 方法将调用它的数组的每个元素传递给您指定的函数,并返回一个包含您函数返回的值的数组。例如:
let a = [1, 2, 3];
a.map(x => x*x) // => [1, 4, 9]: the function takes input x and returns x*x
传递给 map() 的函数的调用方式与传递给 forEach() 的函数相同。然而,对于 map() 方法,您传递的函数应该返回一个值。请注意,map() 返回一个新数组:它不会修改调用它的数组。如果该数组是稀疏的,您的函数将不会为缺失的元素调用,但返回的数组将与原始数组一样稀疏:它将具有相同的长度和相同的缺失元素。
filter()
filter() 方法返回一个包含调用它的数组的元素子集的数组。传递给它的函数应该是谓词:一个返回 true 或 false 的函数。谓词的调用方式与 forEach() 和 map() 相同。如果返回值为 true,或者可以转换为 true 的值,则传递给谓词的元素是子集的成员,并将添加到将成为返回值的数组中。示例:
let a = [5, 4, 3, 2, 1];
a.filter(x => x < 3) // => [2, 1]; values less than 3
a.filter((x,i) => i%2 === 0) // => [5, 3, 1]; every other value
请注意,filter() 跳过稀疏数组中的缺失元素,并且其返回值始终是密集的。要填补稀疏数组中的空白,您可以这样做:
let dense = sparse.filter(() => true);
要填补空白并删除未定义和空元素,您可以使用 filter,如下所示:
a = a.filter(x => x !== undefined && x !== null);
find() 和 findIndex()
find() 和 findIndex() 方法类似于 filter(),它们遍历数组,寻找使谓词函数返回真值的元素。然而,这两种方法在谓词第一次找到元素时停止遍历。当这种情况发生时,find() 返回匹配的元素,而 findIndex() 返回匹配元素的索引。如果找不到匹配的元素,find() 返回 undefined,而 findIndex() 返回 -1:
let a = [1,2,3,4,5];
a.findIndex(x => x === 3) // => 2; the value 3 appears at index 2
a.findIndex(x => x < 0) // => -1; no negative numbers in the array
a.find(x => x % 5 === 0) // => 5: this is a multiple of 5
a.find(x => x % 7 === 0) // => undefined: no multiples of 7 in the array
every() 和 some()
every() 和 some() 方法是数组谓词:它们将您指定的谓词函数应用于数组的元素,然后返回 true 或 false。
every() 方法类似于数学中的“对于所有”量词 ∀:仅当它的谓词函数对数组中的所有元素返回 true 时,它才返回 true:
let a = [1,2,3,4,5];
a.every(x => x < 10) // => true: all values are < 10.
a.every(x => x % 2 === 0) // => false: not all values are even.
some()方法类似于数学中的“存在”量词∃:如果数组中存在至少一个使谓词返回true的元素,则返回true,如果谓词对数组的所有元素返回false,则返回false:
let a = [1,2,3,4,5];
a.some(x => x%2===0) // => true; a has some even numbers.
a.some(isNaN) // => false; a has no non-numbers.
请注意,every()和some()都会在他们知道要返回的值时停止迭代数组元素。some()在您的谓词第一次返回true时返回true,只有在您的谓词始终返回false时才会遍历整个数组。every()则相反:当您的谓词第一次返回false时返回false,只有在您的谓词始终返回true时才会迭代所有元素。还要注意,按照数学约定,当在空数组上调用every()时,every()返回true,而在空数组上调用some时,some返回false。
reduce()和 reduceRight()
reduce()和reduceRight()方法使用您指定的函数组合数组的元素,以产生单个值。这是函数式编程中的常见操作,也称为“注入”和“折叠”。示例有助于说明它是如何工作的:
let a = [1,2,3,4,5];
a.reduce((x,y) => x+y, 0) // => 15; the sum of the values
a.reduce((x,y) => x*y, 1) // => 120; the product of the values
a.reduce((x,y) => (x > y) ? x : y) // => 5; the largest of the values
reduce()接受两个参数。第一个是执行减少操作的函数。这个减少函数的任务是以某种方式将两个值组合或减少为单个值,并返回该减少值。在我们这里展示的示例中,这些函数通过相加、相乘和选择最大值来组合两个值。第二个(可选)参数是传递给函数的初始值。
使用reduce()的函数与forEach()和map()中使用的函数不同。熟悉的值、索引和数组值作为第二、第三和第四个参数传递。第一个参数是到目前为止减少的累积结果。在第一次调用函数时,这个第一个参数是您作为reduce()的第二个参数传递的初始值。在后续调用中,它是前一个函数调用返回的值。在第一个示例中,减少函数首先使用参数 0 和 1 进行调用。它将它们相加并返回 1。然后再次使用参数 1 和 2 调用它并返回 3。接下来,它计算 3+3=6,然后 6+4=10,最后 10+5=15。这个最终值 15 成为reduce()的返回值。
您可能已经注意到此示例中对reduce()的第三次调用只有一个参数:没有指定初始值。当您像这样调用reduce()而没有初始值时,它将使用数组的第一个元素作为初始值。这意味着减少函数的第一次调用将具有数组的第一个和第二个元素作为其第一个和第二个参数。在求和和乘积示例中,我们可以省略初始值参数。
在空数组上调用reduce()且没有初始值参数会导致 TypeError。如果您只使用一个值调用它——要么是一个具有一个元素且没有初始值的数组,要么是一个空数组和一个初始值——它将简单地返回那个值,而不会调用减少函数。
reduceRight()的工作方式与reduce()完全相同,只是它从最高索引到最低索引(从右到左)处理数组,而不是从最低到最高。如果减少操作具有从右到左的结合性,您可能希望这样做,例如:
// Compute 2^(3⁴). Exponentiation has right-to-left precedence
let a = [2, 3, 4];
a.reduceRight((acc,val) => Math.pow(val,acc)) // => 2.4178516392292583e+24
请注意,reduce()和reduceRight()都不接受一个可选参数,该参数指定要调用减少函数的this值。可选的初始值参数代替了它。如果您需要将您的减少函数作为特定对象的方法调用,请参阅Function.bind()方法(§8.7.5)。
到目前为止所展示的示例都是为了简单起见而是数值的,但reduce()和reduceRight()并不仅仅用于数学计算。任何能将两个值(如两个对象)合并为相同类型值的函数都可以用作缩减函数。另一方面,使用数组缩减表达的算法可能很快变得复杂且难以理解,你可能会发现,如果使用常规的循环结构来处理数组,那么阅读、编写和推理代码会更容易。
7.8.2 使用 flat()和flatMap()展平数组
在 ES2019 中,flat()方法创建并返回一个新数组,其中包含调用它的数组的相同元素,除了那些本身是数组的元素被“展平”到返回的数组中。例如:
[1, [2, 3]].flat() // => [1, 2, 3]
[1, [2, [3]]].flat() // => [1, 2, [3]]
当不带参数调用时,flat()会展平一层嵌套。原始数组中本身是数组的元素会被展平,但那些数组的元素不会被展平。如果你想展平更多层次,请向flat()传递一个数字:
let a = [1, [2, [3, [4]]]];
a.flat(1) // => [1, 2, [3, [4]]]
a.flat(2) // => [1, 2, 3, [4]]
a.flat(3) // => [1, 2, 3, 4]
a.flat(4) // => [1, 2, 3, 4]
flatMap()方法的工作方式与map()方法相同(参见map()),只是返回的数组会自动展平,就像传递给flat()一样。也就是说,调用a.flatMap(f)与(但更有效率)a.map(f).flat()相同:
let phrases = ["hello world", "the definitive guide"];
let words = phrases.flatMap(phrase => phrase.split(" "));
words // => ["hello", "world", "the", "definitive", "guide"];
你可以将flatMap()视为map()的一般化,允许输入数组的每个元素映射到输出数组的任意数量的元素。特别是,flatMap()允许你将输入元素映射到一个空数组,这在输出数组中展平为无内容:
// Map non-negative numbers to their square roots
[-2, -1, 1, 2].flatMap(x => x < 0 ? [] : Math.sqrt(x)) // => [1, 2**0.5]
7.8.3 使用 concat()添加数组
concat()方法创建并返回一个新数组,其中包含调用concat()的原始数组的元素,后跟concat()的每个参数。如果其中任何参数本身是一个数组,则连接的是数组元素,而不是数组本身。但请注意,concat()不会递归展平数组的数组。concat()不会修改调用它的数组:
let a = [1,2,3];
a.concat(4, 5) // => [1,2,3,4,5]
a.concat([4,5],[6,7]) // => [1,2,3,4,5,6,7]; arrays are flattened
a.concat(4, [5,[6,7]]) // => [1,2,3,4,5,[6,7]]; but not nested arrays
a // => [1,2,3]; the original array is unmodified
注意concat()会在调用时创建原始数组的新副本。在许多情况下,这是正确的做法,但这是一个昂贵的操作。如果你发现自己写的代码像a = a.concat(x),那么你应该考虑使用push()或splice()来就地修改数组,而不是创建一个新数组。
7.8.4 使用 push()、pop()、shift()和 unshift()实现栈和队列
push()和pop()方法允许你像处理栈一样处理数组。push()方法将一个或多个新元素附加到数组的末尾,并返回数组的新长度。与concat()不同,push()不会展平数组参数。pop()方法则相反:它删除数组的最后一个元素,减少数组长度,并返回它删除的值。请注意,这两种方法都会就地修改数组。push()和pop()的组合允许你使用 JavaScript 数组来实现先进后出的栈。例如:
let stack = []; // stack == []
stack.push(1,2); // stack == [1,2];
stack.pop(); // stack == [1]; returns 2
stack.push(3); // stack == [1,3]
stack.pop(); // stack == [1]; returns 3
stack.push([4,5]); // stack == [1,[4,5]]
stack.pop() // stack == [1]; returns [4,5]
stack.pop(); // stack == []; returns 1
push()方法不会展平你传递给它的数组,但如果你想将一个数组的所有元素推到另一个数组中,你可以使用展开运算符(§8.3.4)来显式展平它:
a.push(...values);
unshift()和shift()方法的行为与push()和pop()类似,只是它们是从数组的开头而不是末尾插入和删除元素。unshift()在数组开头添加一个或多个元素,将现有数组元素向较高的索引移动以腾出空间,并返回数组的新长度。shift()移除并返回数组的第一个元素,将所有后续元素向下移动一个位置以占据数组开头的新空间。您可以使用unshift()和shift()来实现堆栈,但与使用push()和pop()相比效率较低,因为每次在数组开头添加或删除元素时都需要将数组元素向上或向下移动。不过,您可以通过使用push()在数组末尾添加元素并使用shift()从数组开头删除元素来实现队列数据结构:
let q = []; // q == []
q.push(1,2); // q == [1,2]
q.shift(); // q == [2]; returns 1
q.push(3) // q == [2, 3]
q.shift() // q == [3]; returns 2
q.shift() // q == []; returns 3
unshift()的一个值得注意的特点是,当向unshift()传递多个参数时,它们会一次性插入,这意味着它们以与逐个插入时不同的顺序出现在数组中:
let a = []; // a == []
a.unshift(1) // a == [1]
a.unshift(2) // a == [2, 1]
a = []; // a == []
a.unshift(1,2) // a == [1, 2]
7.8.5 使用 slice()、splice()、fill()和 copyWithin()创建子数组
数组定义了一些在连续区域、子数组或数组的“切片”上工作的方法。以下部分描述了用于提取、替换、填充和复制切片的方法。
slice()
slice()方法返回指定数组的切片或子数组。它的两个参数指定要返回的切片的起始和结束。返回的数组包含由第一个参数指定的元素和直到第二个参数指定的元素之前的所有后续元素(不包括该元素)。如果只指定一个参数,则返回的数组包含从起始位置到数组末尾的所有元素。如果任一参数为负数,则它指定相对于数组长度的数组元素。例如,参数-1 指定数组中的最后一个元素,参数-2 指定该元素之前的元素。请注意,slice()不会修改调用它的数组。以下是一些示例:
let a = [1,2,3,4,5];
a.slice(0,3); // Returns [1,2,3]
a.slice(3); // Returns [4,5]
a.slice(1,-1); // Returns [2,3,4]
a.slice(-3,-2); // Returns [3]
splice()
splice()是一个通用的方法,用于向数组中插入或删除元素。与slice()和concat()不同,splice()会修改调用它的数组。请注意,splice()和slice()的名称非常相似,但执行的操作有很大不同。
splice()可以从数组中删除元素、向数组中插入新元素,或同时执行这两个操作。数组中插入或删除点之后的元素的索引会根据需要增加或减少,以使它们与数组的其余部分保持连续。splice()的第一个参数指定插入和/或删除开始的数组位置。第二个参数指定应从数组中删除的元素数量。(请注意,这是这两种方法之间的另一个区别。slice()的第二个参数是结束位置。splice()的第二个参数是长度。)如果省略了第二个参数,则从起始元素到数组末尾的所有数组元素都将被删除。splice()返回一个包含已删除元素的数组,如果没有删除元素,则返回一个空数组。例如:
let a = [1,2,3,4,5,6,7,8];
a.splice(4) // => [5,6,7,8]; a is now [1,2,3,4]
a.splice(1,2) // => [2,3]; a is now [1,4]
a.splice(1,1) // => [4]; a is now [1]
splice()的前两个参数指定要删除的数组元素。这些参数后面可以跟任意数量的额外参数,这些参数指定要插入到数组中的元素,从第一个参数指定的位置开始。例如:
let a = [1,2,3,4,5];
a.splice(2,0,"a","b") // => []; a is now [1,2,"a","b",3,4,5]
a.splice(2,2,[1,2],3) // => ["a","b"]; a is now [1,2,[1,2],3,3,4,5]
请注意,与concat()不同,splice()插入的是数组本身,而不是这些数组的元素。
填充()
fill()方法将数组或数组的一个片段的元素设置为指定的值。它会改变调用它的数组,并返回修改后的数组:
let a = new Array(5); // Start with no elements and length 5
a.fill(0) // => [0,0,0,0,0]; fill the array with zeros
a.fill(9, 1) // => [0,9,9,9,9]; fill with 9 starting at index 1
a.fill(8, 2, -1) // => [0,9,8,8,9]; fill with 8 at indexes 2, 3
fill()的第一个参数是要设置数组元素的值。可选的第二个参数指定开始索引。如果省略,填充将从索引 0 开始。可选的第三个参数指定结束索引——将填充到该索引之前的数组元素。如果省略此参数,则数组将从开始索引填充到结束。您可以通过传递负数来指定相对于数组末尾的索引,就像对slice()一样。
copyWithin()
copyWithin()将数组的一个片段复制到数组内的新位置。它会就地修改数组并返回修改后的数组,但不会改变数组的长度。第一个参数指定要复制第一个元素的目标索引。第二个参数指定要复制的第一个元素的索引。如果省略第二个参数,则使用 0。第三个参数指定要复制的元素片段的结束。如果省略,将使用数组的长度。从开始索引到结束索引之前的元素将被复制。您可以通过传递负数来指定相对于数组末尾的索引,就像对slice()一样:
let a = [1,2,3,4,5];
a.copyWithin(1) // => [1,1,2,3,4]: copy array elements up one
a.copyWithin(2, 3, 5) // => [1,1,3,4,4]: copy last 2 elements to index 2
a.copyWithin(0, -2) // => [4,4,3,4,4]: negative offsets work, too
copyWithin()旨在作为一种高性能方法,特别适用于类型化数组(参见§11.2)。它模仿了 C 标准库中的memmove()函数。请注意,即使源区域和目标区域之间存在重叠,复制也会正确工作。
7.8.6 数组搜索和排序方法
数组实现了indexOf()、lastIndexOf()和includes()方法,这些方法与字符串的同名方法类似。还有sort()和reverse()方法用于重新排列数组的元素。这些方法将在接下来的小节中描述。
indexOf()和 lastIndexOf()
indexOf()和lastIndexOf()搜索具有指定值的元素的数组,并返回找到的第一个这样的元素的索引,如果找不到则返回-1。indexOf()从开头到结尾搜索数组,lastIndexOf()从结尾到开头搜索:
let a = [0,1,2,1,0];
a.indexOf(1) // => 1: a[1] is 1
a.lastIndexOf(1) // => 3: a[3] is 1
a.indexOf(3) // => -1: no element has value 3
indexOf()和lastIndexOf()使用等价于===运算符的方式将它们的参数与数组元素进行比较。如果您的数组包含对象而不是原始值,这些方法将检查两个引用是否确实指向完全相同的对象。如果您想要实际查看对象的内容,请尝试使用带有自定义谓词函数的find()方法。
indexOf()和lastIndexOf()接受一个可选的第二个参数,该参数指定开始搜索的数组索引。如果省略此参数,indexOf()从开头开始,lastIndexOf()从末尾开始。第二个参数允许使用负值,并被视为从数组末尾的偏移量,就像slice()方法一样:例如,-1 表示数组的最后一个元素。
以下函数搜索数组中指定值的所有匹配索引,并返回一个所有匹配索引的数组。这演示了如何使用indexOf()的第二个参数来查找第一个之外的匹配项。
// Find all occurrences of a value x in an array a and return an array
// of matching indexes
function findall(a, x) {
let results = [], // The array of indexes we'll return
len = a.length, // The length of the array to be searched
pos = 0; // The position to search from
while(pos < len) { // While more elements to search...
pos = a.indexOf(x, pos); // Search
if (pos === -1) break; // If nothing found, we're done.
results.push(pos); // Otherwise, store index in array
pos = pos + 1; // And start next search at next element
}
return results; // Return array of indexes
}
请注意,字符串具有类似这些数组方法的indexOf()和lastIndexOf()方法,只是负的第二个参数被视为零。
includes()
ES2016 的includes()方法接受一个参数,如果数组包含该值则返回true,否则返回false。它不会告诉您该值的索引,只会告诉您它是否存在。includes()方法实际上是用于数组的集合成员测试。但是请注意,数组不是集合的有效表示形式,如果您处理的元素超过几个,应该使用真正的 Set 对象(§11.1.1)。
includes()方法与indexOf()方法在一个重要方面略有不同。indexOf()使用与===运算符相同的算法进行相等性测试,该相等性算法认为非数字值与包括它本身在内的每个其他值都不同。includes()使用略有不同的相等性版本,它确实认为NaN等于它本身。这意味着indexOf()不会在数组中检测到NaN值,但includes()会:
let a = [1,true,3,NaN];
a.includes(true) // => true
a.includes(2) // => false
a.includes(NaN) // => true
a.indexOf(NaN) // => -1; indexOf can't find NaN
sort()
sort()对数组的元素进行原地排序并返回排序后的数组。当不带参数调用sort()时,它会按字母顺序对数组元素进行排序(如果需要,会临时将它们转换为字符串进行比较):
let a = ["banana", "cherry", "apple"];
a.sort(); // a == ["apple", "banana", "cherry"]
如果数组包含未定义的元素,则它们将被排序到数组的末尾。
要将数组按照字母顺序以外的某种顺序排序,您必须将比较函数作为参数传递给sort()。此函数决定哪个参数应该首先出现在排序后的数组中。如果第一个参数应该出现在第二个参数之前,则比较函数应返回小于零的数字。如果第一个参数应该在排序后的数组中出现在第二个参数之后,则函数应返回大于零的数字。如果两个值相等(即,如果它们的顺序无关紧要),则比较函数应返回 0。因此,例如,要将数组元素按照数字顺序而不是字母顺序排序,您可以这样做:
let a = [33, 4, 1111, 222];
a.sort(); // a == [1111, 222, 33, 4]; alphabetical order
a.sort(function(a,b) { // Pass a comparator function
return a-b; // Returns < 0, 0, or > 0, depending on order
}); // a == [4, 33, 222, 1111]; numerical order
a.sort((a,b) => b-a); // a == [1111, 222, 33, 4]; reverse numerical order
作为对数组项进行排序的另一个示例,您可以通过传递一个比较函数对字符串数组进行不区分大小写的字母排序,该函数在比较之前将其两个参数都转换为小写(使用toLowerCase()方法):
let a = ["ant", "Bug", "cat", "Dog"];
a.sort(); // a == ["Bug","Dog","ant","cat"]; case-sensitive sort
a.sort(function(s,t) {
let a = s.toLowerCase();
let b = t.toLowerCase();
if (a < b) return -1;
if (a > b) return 1;
return 0;
}); // a == ["ant","Bug","cat","Dog"]; case-insensitive sort
reverse()
reverse()方法颠倒数组的元素顺序并返回颠倒的数组。它在原地执行此操作;换句话说,它不会创建一个重新排列元素的新数组,而是在已经存在的数组中重新排列它们:
let a = [1,2,3];
a.reverse(); // a == [3,2,1]
7.8.7 数组转换为字符串
Array 类定义了三种可以将数组转换为字符串的方法,通常在创建日志和错误消息时可能会使用。 (如果要以文本形式保存数组的内容以供以后重用,请使用JSON.stringify() [§6.8]来序列化数组,而不是使用这里描述的方法。)
join()方法将数组的所有元素转换为字符串并连接它们,返回生成的字符串。您可以指定一个可选的字符串,用于分隔生成的字符串中的元素。如果未指定分隔符字符串,则使用逗号:
let a = [1, 2, 3];
a.join() // => "1,2,3"
a.join(" ") // => "1 2 3"
a.join("") // => "123"
let b = new Array(10); // An array of length 10 with no elements
b.join("-") // => "---------": a string of 9 hyphens
join()方法是String.split()方法的反向操作,它通过将字符串分割成片段来创建数组。
数组,就像所有 JavaScript 对象一样,都有一个toString()方法。对于数组,此方法的工作方式与没有参数的join()方法相同:
[1,2,3].toString() // => "1,2,3"
["a", "b", "c"].toString() // => "a,b,c"
[1, [2,"c"]].toString() // => "1,2,c"
请注意,输出不包括方括号或任何其他类型的分隔符。
toLocaleString()是toString()的本地化版本。它通过调用元素的toLocaleString()方法将每个数组元素转换为字符串,然后使用特定于区域设置(和实现定义的)分隔符字符串连接生成的字符串。
7.8.8 静态数组函数
除了我们已经记录的数组方法之外,Array 类还定义了三个静态函数,您可以通过Array构造函数而不是在数组上调用这些函数。Array.of()和Array.from()是用于创建新数组的工厂方法。它们在§7.1.4 和§7.1.5 中有记录。
另一个静态数组函数是Array.isArray(),用于确定未知值是否为数组:
Array.isArray([]) // => true
Array.isArray({}) // => false
7.9 类似数组对象
正如我们所见,JavaScript 数组具有其他对象没有的一些特殊功能:
-
当向列表添加新元素时,
length属性会自动更新。 -
将
length设置为较小的值会截断数组。 -
数组从
Array.prototype继承了有用的方法。 -
对于数组,
Array.isArray()返回true。
这些是使 JavaScript 数组与常规对象不同的特点。但它们并不是定义数组的基本特征。将任何具有数值length属性和相应非负整数属性的对象视为一种数组通常是完全合理的。
这些“类似数组”的对象实际上在实践中偶尔会出现,尽管你不能直接在它们上面调用数组方法或期望length属性有特殊行为,但你仍然可以使用与真实数组相同的代码迭代它们。事实证明,许多数组算法与类似数组对象一样有效,就像它们与真实数组一样有效一样。特别是如果你的算法将数组视为只读,或者至少保持数组长度不变时,这一点尤为真实。
以下代码将常规对象转换为类似数组对象,然后遍历生成的伪数组的“元素”:
let a = {}; // Start with a regular empty object
// Add properties to make it "array-like"
let i = 0;
while(i < 10) {
a[i] = i * i;
i++;
}
a.length = i;
// Now iterate through it as if it were a real array
let total = 0;
for(let j = 0; j < a.length; j++) {
total += a[j];
}
在客户端 JavaScript 中,许多用于处理 HTML 文档的方法(例如document.querySelectorAll())返回类似数组的对象。以下是您可能用于测试类似数组对象的函数:
// Determine if o is an array-like object.
// Strings and functions have numeric length properties, but are
// excluded by the typeof test. In client-side JavaScript, DOM text
// nodes have a numeric length property, and may need to be excluded
// with an additional o.nodeType !== 3 test.
function isArrayLike(o) {
if (o && // o is not null, undefined, etc.
typeof o === "object" && // o is an object
Number.isFinite(o.length) && // o.length is a finite number
o.length >= 0 && // o.length is non-negative
Number.isInteger(o.length) && // o.length is an integer
o.length < 4294967295) { // o.length < 2³² - 1
return true; // Then o is array-like.
} else {
return false; // Otherwise it is not.
}
}
我们将在后面的部分看到字符串的行为类似于数组。然而,对于类似数组对象的此类测试通常对字符串返回false——最好将其处理为字符串,而不是数组。
大多数 JavaScript 数组方法都故意定义为通用的,以便在应用于类似数组对象时与真实数组一样正确工作。由于类似数组对象不继承自Array.prototype,因此不能直接在它们上调用数组方法。但是,您可以间接使用Function.call方法调用它们(有关详细信息,请参见§8.7.4):
let a = {"0": "a", "1": "b", "2": "c", length: 3}; // An array-like object
Array.prototype.join.call(a, "+") // => "a+b+c"
Array.prototype.map.call(a, x => x.toUpperCase()) // => ["A","B","C"]
Array.prototype.slice.call(a, 0) // => ["a","b","c"]: true array copy
Array.from(a) // => ["a","b","c"]: easier array copy
此代码倒数第二行在类似数组对象上调用 Array slice()方法,以将该对象的元素复制到真实数组对象中。这是一种成语技巧,存在于许多传统代码中,但现在使用Array.from()更容易实现。
7.10 字符串作为数组
JavaScript 字符串表现为 UTF-16 Unicode 字符的只读数组。您可以使用方括号而不是charAt()方法访问单个字符:
let s = "test";
s.charAt(0) // => "t"
s[1] // => "e"
当然,对于字符串,typeof运算符仍然返回“string”,如果您将其传递给Array.isArray()方法,则返回false。
可索引字符串的主要好处仅仅是我们可以用方括号替换charAt()的调用,这样更简洁、可读,并且可能更高效。然而,字符串表现得像数组意味着我们可以将通用数组方法应用于它们。例如:
Array.prototype.join.call("JavaScript", " ") // => "J a v a S c r i p t"
请记住,字符串是不可变的值,因此当它们被视为数组时,它们是只读数组。像push()、sort()、reverse()和splice()这样的数组方法会就地修改数组,不适用于字符串。然而,尝试使用数组方法修改字符串不会导致错误:它只是悄无声息地失败。
7.11 总结
本章深入讨论了 JavaScript 数组,包括稀疏数组和类数组对象的奇特细节。从本章中可以得出的主要观点是:
-
数组字面量是用方括号括起来的逗号分隔的值列表编写的。
-
通过在方括号内指定所需的数组索引来访问单个数组元素。
-
ES6 中引入的
for/of循环和...扩展运算符是迭代数组的特别有用的方式。 -
Array 类定义了一组丰富的方法来操作数组,你应该确保熟悉 Array API。
第八章:函数
本章涵盖了 JavaScript 函数。函数是 JavaScript 程序的基本构建块,也是几乎所有编程语言中的常见特性。您可能已经熟悉了类似于子程序或过程的函数概念。
函数是一段 JavaScript 代码块,定义一次但可以执行或调用任意次数。JavaScript 函数是参数化的:函数定义可能包括一个标识符列表,称为参数,它们在函数体内作为局部变量。函数调用为函数的参数提供值,或参数,函数通常使用它们的参数值来计算返回值,该返回值成为函数调用表达式的值。除了参数之外,每次调用还有另一个值—调用上下文—它是this关键字的值。
如果函数分配给对象的属性,则称为该对象的方法。当在对象上调用函数时,该对象是函数的调用上下文或this值。用于初始化新创建对象的函数称为构造函数。构造函数在§6.2 中有描述,并将在第九章中再次介绍。
在 JavaScript 中,函数是对象,可以被程序操作。JavaScript 可以将函数分配给变量并将它们传递给其他函数,例如。由于函数是对象,您可以在它们上设置属性,甚至在它们上调用方法。
JavaScript 函数定义可以嵌套在其他函数中,并且可以访问在定义它们的作用域中的任何变量。这意味着 JavaScript 函数是闭包,并且它们可以实现重要且强大的编程技术。
8.1 定义函数
定义 JavaScript 函数最直接的方法是使用function关键字,可以用作声明或表达式。ES6 定义了一种重要的新定义函数的方式,即“箭头函数”没有function关键字:箭头函数具有特别简洁的语法,并且在将一个函数作为另一个函数的参数传递时非常有用。接下来的小节将介绍这三种定义函数的方式。请注意,涉及函数参数的函数定义语法的一些细节将推迟到§8.3 中。
在对象字面量和类定义中,有一种方便的简写语法用于定义方法。这种简写语法在§6.10.5 中介绍过,相当于使用函数定义表达式并将其分配给对象属性,使用基本的name:value对象字面量语法。在另一种特殊情况下,您可以在对象字面量中使用关键字get和set来定义特殊的属性获取器和设置器方法。这种函数定义语法在§6.10.6 中介绍过。
请注意,函数也可以使用Function()构造函数来定义,这是§8.7.7 的主题。此外,JavaScript 定义了一些特殊类型的函数。function*定义生成器函数(参见第十二章),而async function定义异步函数(参见第十三章)。
8.1.1 函数声明
函数声明由function关键字后跟这些组件组成:
-
用于命名函数的标识符。名称是函数声明的必需部分:它用作变量的名称,并且新定义的函数对象分配给该变量。
-
一对括号围绕着一个逗号分隔的零个或多个标识符列表。这些标识符是函数的参数名称,并且在函数体内部起到类似局部变量的作用。
-
一对大括号内包含零个或多个 JavaScript 语句。这些语句是函数的主体:每当调用函数时,它们都会被执行。
这里是一些示例函数声明:
// Print the name and value of each property of o. Return undefined.
function printprops(o) {
for(let p in o) {
console.log(`${p}: ${o[p]}\n`);
}
}
// Compute the distance between Cartesian points (x1,y1) and (x2,y2).
function distance(x1, y1, x2, y2) {
let dx = x2 - x1;
let dy = y2 - y1;
return Math.sqrt(dx*dx + dy*dy);
}
// A recursive function (one that calls itself) that computes factorials
// Recall that x! is the product of x and all positive integers less than it.
function factorial(x) {
if (x <= 1) return 1;
return x * factorial(x-1);
}
关于函数声明的重要事项之一是,函数的名称成为一个变量,其值为函数本身。函数声明语句被“提升”到封闭脚本、函数或块的顶部,以便以这种方式定义的函数可以从定义之前的代码中调用。另一种说法是,在 JavaScript 代码块中声明的所有函数将在该块中定义,并且它们将在 JavaScript 解释器开始执行该块中的任何代码之前定义。
我们描述的 distance() 和 factorial() 函数旨在计算一个值,并使用 return 将该值返回给调用者。return 语句导致函数停止执行并将其表达式的值(如果有)返回给调用者。如果 return 语句没有关联的表达式,则函数的返回值为 undefined。
printprops() 函数有所不同:它的作用是输出对象属性的名称和值。不需要返回值,并且函数不包括 return 语句。调用 printprops() 函数的值始终为 undefined。如果函数不包含 return 语句,它只是执行函数体中的每个语句,直到达到结尾,并将 undefined 值返回给调用者。
在 ES6 之前,只允许在 JavaScript 文件的顶层或另一个函数内部定义函数声明。虽然一些实现弯曲了规则,但在循环、条件语句或其他块的主体内定义函数实际上是不合法的。然而,在 ES6 的严格模式下,允许在块内部声明函数。在块内定义的函数仅存在于该块内部,并且在块外部不可见。
8.1.2 函数表达式
函数表达式看起来很像函数声明,但它们出现在更大表达式或语句的上下文中,名称是可选的。这里是一些示例函数表达式:
// This function expression defines a function that squares its argument.
// Note that we assign it to a variable
const square = function(x) { return x*x; };
// Function expressions can include names, which is useful for recursion.
const f = function fact(x) { if (x <= 1) return 1; else return x*fact(x-1); };
// Function expressions can also be used as arguments to other functions:
[3,2,1].sort(function(a,b) { return a-b; });
// Function expressions are sometimes defined and immediately invoked:
let tensquared = (function(x) {return x*x;}(10));
请注意,对于定义为表达式的函数,函数名称是可选的,我们展示的大多数前面的函数表达式都省略了它。函数声明实际上 声明 了一个变量,并将函数对象分配给它。另一方面,函数表达式不声明变量:如果您需要多次引用它,您需要将新定义的函数对象分配给常量或变量。对于函数表达式,最好使用 const,这样您不会意外地通过分配新值来覆盖函数。
对于需要引用自身的函数(如阶乘函数),允许为函数指定名称。如果函数表达式包含名称,则该函数的本地函数作用域将包括将该名称绑定到函数对象。实际上,函数名称成为函数内部的局部变量。大多数作为表达式定义的函数不需要名称,这使得它们的定义更加紧凑(尽管不像下面描述的箭头函数那样紧凑)。
使用函数声明定义函数f()与在创建后将函数分配给变量f之间有一个重要的区别。当使用声明形式时,函数对象在包含它们的代码开始运行之前就已经创建,并且定义被提升,以便您可以从出现在定义语句上方的代码中调用这些函数。然而,对于作为表达式定义的函数来说,情况并非如此:这些函数直到定义它们的表达式实际被评估之后才存在。此外,为了调用一个函数,您必须能够引用它,而在将函数定义为表达式之前,您不能引用一个函数,因此使用表达式定义的函数在定义之前不能被调用。
8.1.3 箭头函数
在 ES6 中,你可以使用一种特别简洁的语法来定义函数,称为“箭头函数”。这种语法类似于数学表示法,并使用=>“箭头”来分隔函数参数和函数主体。不使用function关键字,而且,由于箭头函数是表达式而不是语句,因此也不需要函数名称。箭头函数的一般形式是用括号括起来的逗号分隔的参数列表,后跟=>箭头,再后跟用花括号括起来的函数主体:
const sum = (x, y) => { return x + y; };
但是箭头函数支持更紧凑的语法。如果函数的主体是一个单独的return语句,您可以省略return关键字、与之配套的分号和花括号,并将函数主体写成要返回其值的表达式:
const sum = (x, y) => x + y;
此外,如果箭头函数只有一个参数,您可以省略参数列表周围的括号:
const polynomial = x => x*x + 2*x + 3;
请注意,一个没有任何参数的箭头函数必须用一个空的括号对写成:
const constantFunc = () => 42;
请注意,在编写箭头函数时,不要在函数参数和=>箭头之间加入新行。否则,您可能会得到一行像const polynomial = x这样的行,这是一个语法上有效的赋值语句。
此外,如果箭头函数的主体是一个单独的return语句,但要返回的表达式是一个对象字面量,则必须将对象字面量放在括号内,以避免在函数主体的花括号和对象字面量的花括号之间产生语法歧义:
const f = x => { return { value: x }; }; // Good: f() returns an object
const g = x => ({ value: x }); // Good: g() returns an object
const h = x => { value: x }; // Bad: h() returns nothing
const i = x => { v: x, w: x }; // Bad: Syntax Error
在此代码的第三行中,函数h()确实是模棱两可的:您打算作为对象字面量的代码可以被解析为标记语句,因此创建了一个返回undefined的函数。然而,在第四行,更复杂的对象字面量不是一个有效的语句,这种非法代码会导致语法错误。
箭头函数简洁的语法使它们在需要将一个函数传递给另一个函数时非常理想,这在像map()、filter()和reduce()这样的数组方法中是常见的做法(参见§7.8.1):
// Make a copy of an array with null elements removed.
let filtered = [1,null,2,3].filter(x => x !== null); // filtered == [1,2,3]
// Square some numbers:
let squares = [1,2,3,4].map(x => x*x); // squares == [1,4,9,16]
箭头函数与其他方式定义的函数在一个关键方面有所不同:它们继承自定义它们的环境中的this关键字的值,而不是像其他方式定义的函数那样定义自己的调用上下文。这是箭头函数的一个重要且非常有用的特性,我们将在本章后面再次回到这个问题。箭头函数还与其他函数不同之处在于它们没有prototype属性,这意味着它们不能用作新类的构造函数(参见§9.2)。
8.1.4 嵌套函数
在 JavaScript 中,函数可以嵌套在其他函数中。例如:
function hypotenuse(a, b) {
function square(x) { return x*x; }
return Math.sqrt(square(a) + square(b));
}
嵌套函数的有趣之处在于它们的变量作用域规则:它们可以访问嵌套在其中的函数(或函数)的参数和变量。例如,在这里显示的代码中,内部函数 square() 可以读取和写入外部函数 hypotenuse() 定义的参数 a 和 b。嵌套函数的这些作用域规则非常重要,我们将在 §8.6 中再次考虑它们。
8.2 调用函数
JavaScript 函数体组成的代码在定义函数时不会执行,而是在调用函数时执行。JavaScript 函数可以通过五种方式调用:
-
作为函数
-
作为方法
-
作为构造函数
-
通过它们的
call()和apply()方法间接调用 -
隐式地,通过 JavaScript 语言特性,看起来不像正常函数调用
8.2.1 函数调用
函数可以作为函数或方法通过调用表达式调用(§4.5)。调用表达式由一个求值为函数对象的函数表达式、一个开括号、一个逗号分隔的零个或多个参数表达式和一个闭括号组成。如果函数表达式是一个属性访问表达式——如果函数是对象的属性或数组的元素——那么它就是一个方法调用表达式。这种情况将在下面的示例中解释。以下代码包含了许多常规函数调用表达式:
printprops({x: 1});
let total = distance(0,0,2,1) + distance(2,1,3,5);
let probability = factorial(5)/factorial(13);
在调用中,每个参数表达式(括号之间的表达式)都会被求值,得到的值作为函数的参数。这些值被分配给函数定义中命名的参数。在函数体中,对参数的引用会求值为相应的参数值。
对于常规函数调用,函数的返回值成为调用表达式的值。如果函数返回是因为解释器到达末尾,返回值是 undefined。如果函数返回是因为解释器执行了 return 语句,则返回值是跟在 return 后面的表达式的值,如果 return 语句没有值,则返回值是 undefined。
在非严格模式下进行函数调用时,调用上下文(this 值)是全局对象。然而,在严格模式下,调用上下文是 undefined。请注意,使用箭头语法定义的函数行为不同:它们始终继承在定义它们的地方生效的 this 值。
为了作为函数调用而编写的函数(而不是作为方法调用),通常根本不使用 this 关键字。然而,可以使用该关键字来确定是否启用了严格模式:
// Define and invoke a function to determine if we're in strict mode.
const strict = (function() { return !this; }());
8.2.2 方法调用
方法 只不过是存储在对象属性中的 JavaScript 函数。如果有一个函数 f 和一个对象 o,你可以用以下代码定义 o 的名为 m 的方法:
o.m = f;
定义了对象 o 的方法 m() 后,可以像这样调用它:
o.m();
或者,如果 m() 预期有两个参数,你可以这样调用它:
o.m(x, y);
此示例中的代码是一个调用表达式:它包括一个函数表达式 o.m 和两个参数表达式 x 和 y。函数表达式本身是一个属性访问表达式,这意味着该函数被作为方法而不是作为常规函数调用。
方法调用的参数和返回值的处理方式与常规函数调用完全相同。然而,方法调用与函数调用有一个重要的区别:调用上下文。属性访问表达式由两部分组成:一个对象(在本例中是 o)和一个属性名(m)。在这样的方法调用表达式中,对象 o 成为调用上下文,函数体可以通过关键字 this 引用该对象。以下是一个具体示例:
let calculator = { // An object literal
operand1: 1,
operand2: 1,
add() { // We're using method shorthand syntax for this function
// Note the use of the this keyword to refer to the containing object.
this.result = this.operand1 + this.operand2;
}
};
calculator.add(); // A method invocation to compute 1+1.
calculator.result // => 2
大多数方法调用使用点表示法进行属性访问,但使用方括号的属性访问表达式也会导致方法调用。例如,以下两者都是方法调用:
o"m"; // Another way to write o.m(x,y).
a0 // Also a method invocation (assuming a[0] is a function).
方法调用也可能涉及更复杂的属性访问表达式:
customer.surname.toUpperCase(); // Invoke method on customer.surname
f().m(); // Invoke method m() on return value of f()
方法和this关键字是面向对象编程范式的核心。任何用作方法的函数实际上都会传递一个隐式参数——通过它被调用的对象。通常,方法在该对象上执行某种操作,而方法调用语法是一种优雅地表达函数正在操作对象的方式。比较以下两行:
rect.setSize(width, height);
setRectSize(rect, width, height);
在这两行代码中调用的假设函数可能对(假设的)对象rect执行完全相同的操作,但第一行中的方法调用语法更清楚地表明了对象rect是操作的主要焦点。
请注意this是一个关键字,不是变量或属性名。JavaScript 语法不允许您为this赋值。
this关键字的作用域不同于变量,除了箭头函数外,嵌套函数不会继承包含函数的this值。如果嵌套函数被作为方法调用,其this值将是调用它的对象。如果嵌套函数(不是箭头函数)被作为函数调用,那么其this值将是全局对象(非严格模式)或undefined(严格模式)。假设在方法内部定义的嵌套函数并作为函数调用时可以使用this获取方法的调用上下文是一个常见的错误。以下代码演示了这个问题:
let o = { // An object o.
m: function() { // Method m of the object.
let self = this; // Save the "this" value in a variable.
this === o // => true: "this" is the object o.
f(); // Now call the helper function f().
function f() { // A nested function f
this === o // => false: "this" is global or undefined
self === o // => true: self is the outer "this" value.
}
}
};
o.m(); // Invoke the method m on the object o.
在嵌套函数f()内部,this关键字不等于对象o。这被广泛认为是 JavaScript 语言的一个缺陷,因此重要的是要意识到这一点。上面的代码演示了一个常见的解决方法。在方法m内部,我们将this值分配给变量self,在嵌套函数f内部,我们可以使用self而不是this来引用包含的对象。
在 ES6 及更高版本中,另一个解决此问题的方法是将嵌套函数f转换为箭头函数,这样将正确继承this值。
const f = () => {
this === o // true, since arrow functions inherit this
};
将函数定义为表达式而不是语句的方式不会被提升,因此为了使这段代码正常工作,函数f的定义需要移动到方法m内部,以便在调用之前出现。
另一个解决方法是调用嵌套函数的bind()方法来定义一个新函数,该函数将隐式在指定对象上调用:
const f = (function() {
this === o // true, since we bound this function to the outer this
}).bind(this);
我们将在§8.7.5 中更详细地讨论bind()。
8.2.3 构造函数调用
如果函数或方法调用之前带有关键字new,那么这是一个构造函数调用。(构造函数调用在§4.6 和§6.2.2 中介绍过,并且构造函数将在第九章中更详细地讨论。)构造函数调用在处理参数、调用上下文和返回值方面与常规函数和方法调用不同。
如果构造函数调用包括括号中的参数列表,则这些参数表达式将被计算并传递给函数,方式与函数和方法调用相同。虽然不常见,但您可以在构造函数调用中省略一对空括号。例如,以下两行是等价的:
o = new Object();
o = new Object;
构造函数调用创建一个新的空对象,该对象继承自构造函数的prototype属性指定的对象。构造函数旨在初始化对象,这个新创建的对象被用作调用上下文,因此构造函数可以使用this关键字引用它。请注意,即使构造函数调用看起来像方法调用,新对象也被用作调用上下文。也就是说,在表达式new o.m()中,o不被用作调用上下文。
构造函数通常不使用return关键字。它们通常在初始化新对象后隐式返回,当它们到达函数体的末尾时。在这种情况下,新对象是构造函数调用表达式的值。然而,如果构造函数显式使用return语句返回一个对象,则该对象成为调用表达式的值。如果构造函数使用没有值的return,或者返回一个原始值,那么返回值将被忽略,新对象将作为调用的值。
8.2.4 间接调用
JavaScript 函数是对象,和所有 JavaScript 对象一样,它们有方法。其中两个方法,call()和apply(),间接调用函数。这两种方法允许您明确指定调用的this值,这意味着您可以将任何函数作为任何对象的方法调用,即使它实际上不是该对象的方法。这两种方法还允许您指定调用的参数。call()方法使用其自己的参数列表作为函数的参数,而apply()方法期望使用作为参数的值数组。call()和apply()方法在§8.7.4 中有详细描述。
8.2.5 隐式函数调用
有各种 JavaScript 语言特性看起来不像函数调用,但会导致函数被调用。在编写可能被隐式调用的函数时要特别小心,因为这些函数中的错误、副作用和性能问题比普通函数更难诊断和修复,因为从简单检查代码时可能不明显它们何时被调用。
可能导致隐式函数调用的语言特性包括:
-
如果对象定义了 getter 或 setter,则查询或设置其属性的值可能会调用这些方法。更多信息请参见§6.10.6。
-
当对象在字符串上下文中使用(例如与字符串连接时),会调用其
toString()方法。类似地,当对象在数值上下文中使用时,会调用其valueOf()方法。详细信息请参见§3.9.3。 -
当您遍历可迭代对象的元素时,会发生许多方法调用。第十二章解释了迭代器在函数调用级别上的工作原理,并演示了如何编写这些方法,以便您可以定义自己的可迭代类型。
-
标记模板字面量是一个伪装成函数调用的函数。§14.5 演示了如何编写可与模板字面量字符串一起使用的函数。
-
代理对象(在§14.7 中描述)的行为完全由函数控制。对这些对象的几乎任何操作都会导致函数被调用。
8.3 函数参数和参数
JavaScript 函数定义不指定函数参数的预期类型,函数调用也不对传递的参数值进行任何类型检查。事实上,JavaScript 函数调用甚至不检查传递的参数数量。接下来的小节描述了当函数被调用时传入的参数少于声明的参数数量或多于声明的参数数量时会发生什么。它们还演示了如何显式测试函数参数的类型,如果需要确保函数不会被不适当的参数调用。
8.3.1 可选参数和默认值
当函数被调用时传入的参数少于声明的参数数量时,额外的参数将被设置为它们的默认值,通常是undefined。编写一些参数是可选的函数通常很有用。以下是一个例子:
// Append the names of the enumerable properties of object o to the
// array a, and return a. If a is omitted, create and return a new array.
function getPropertyNames(o, a) {
if (a === undefined) a = []; // If undefined, use a new array
for(let property in o) a.push(property);
return a;
}
// getPropertyNames() can be invoked with one or two arguments:
let o = {x: 1}, p = {y: 2, z: 3}; // Two objects for testing
let a = getPropertyNames(o); // a == ["x"]; get o's properties in a new array
getPropertyNames(p, a); // a == ["x","y","z"]; add p's properties to it
在这个函数的第一行使用if语句的地方,你可以以这种成语化的方式使用||运算符:
a = a || [];
回想一下§4.10.2 中提到的||运算符,如果第一个参数为真,则返回第一个参数,否则返回第二个参数。在这种情况下,如果将任何对象作为第二个参数传递,函数将使用该对象。但如果省略第二个参数(或传递null或另一个假值),则将使用一个新创建的空数组。
注意,在设计具有可选参数的函数时,应确保将可选参数放在参数列表的末尾,以便可以省略它们。调用函数的程序员不能省略第一个参数并传递第二个参数:他们必须明确地将undefined作为第一个参数传递。
在 ES6 及更高版本中,你可以直接在函数参数列表中为每个参数定义默认值。只需在参数名称后面加上等号和默认值,当没有为该参数提供参数时将使用默认值:
// Append the names of the enumerable properties of object o to the
// array a, and return a. If a is omitted, create and return a new array.
function getPropertyNames(o, a = []) {
for(let property in o) a.push(property);
return a;
}
参数默认表达式在调用函数时进行求值,而不是在定义函数时进行求值,因此每次调用getPropertyNames()函数时,都会创建一个新的空数组并传递。² 如果参数默认值是常量(或类似[]和{}的文字表达式),那么函数的推理可能是最简单的。但这不是必需的:你可以使用变量或函数调用来计算参数的默认值。一个有趣的情况是,对于具有多个参数的函数,可以使用前一个参数的值来定义其后参数的默认值:
// This function returns an object representing a rectangle's dimensions.
// If only width is supplied, make it twice as high as it is wide.
const rectangle = (width, height=width*2) => ({width, height});
rectangle(1) // => { width: 1, height: 2 }
这段代码演示了参数默认值如何与箭头函数一起使用。对于方法简写函数和所有其他形式的函数定义也是如此。
8.3.2 Rest 参数和可变长度参数列表
参数默认值使我们能够编写可以用比参数更少的参数调用的函数。Rest 参数使相反的情况成为可能:它们允许我们编写可以用任意多个参数调用的函数。以下是一个期望一个或多个数字参数并返回最大值的示例函数:
function max(first=-Infinity, ...rest) {
let maxValue = first; // Start by assuming the first arg is biggest
// Then loop through the rest of the arguments, looking for bigger
for(let n of rest) {
if (n > maxValue) {
maxValue = n;
}
}
// Return the biggest
return maxValue;
}
max(1, 10, 100, 2, 3, 1000, 4, 5, 6) // => 1000
rest 参数由三个点前置,并且必须是函数声明中的最后一个参数。当你使用 rest 参数调用函数时,你传递的参数首先被分配给非 rest 参数,然后任何剩余的参数(即“剩余”的参数)都存储在一个数组中,该数组成为 rest 参数的值。这一点很重要:在函数体内,rest 参数的值始终是一个数组。数组可能为空,但 rest 参数永远不会是undefined。(由此可知,为 rest 参数定义参数默认值从未有用过,也不合法。)
像前面的例子那样可以接受任意数量参数的函数称为可变参数函数、可变参数函数或vararg 函数。本书使用最口语化的术语varargs,这个术语可以追溯到 C 编程语言的早期。
不要混淆函数定义中定义 rest 参数的 ... 与 §8.3.4 中描述的展开运算符的 ...,后者可用于函数调用中。
8.3.3 Arguments 对象
Rest 参数是在 ES6 中引入 JavaScript 的。在该语言版本之前,可变参数函数是使用 Arguments 对象编写的:在任何函数体内,标识符 arguments 指的是该调用的 Arguments 对象。Arguments 对象是一个类似数组的对象(参见 §7.9),允许按数字而不是名称检索传递给函数的参数值。以下是之前的 max() 函数,重写以使用 Arguments 对象而不是 rest 参数:
function max(x) {
let maxValue = -Infinity;
// Loop through the arguments, looking for, and remembering, the biggest.
for(let i = 0; i < arguments.length; i++) {
if (arguments[i] > maxValue) maxValue = arguments[i];
}
// Return the biggest
return maxValue;
}
max(1, 10, 100, 2, 3, 1000, 4, 5, 6) // => 1000
Arguments 对象可以追溯到 JavaScript 最早的日子,并且携带一些奇怪的历史包袱,使其在严格模式之外尤其难以优化和难以使用。你可能仍然会遇到使用 Arguments 对象的代码,但是在编写任何新代码时应避免使用它。在重构旧代码时,如果遇到使用 arguments 的函数,通常可以用 ...args rest 参数来替换它。Arguments 对象的不幸遗产之一是,在严格模式下,arguments 被视为保留字,你不能使用该名称声明函数参数或局部变量。
8.3.4 函数调用的展开运算符
展开运算符 ... 用于在期望单个值的上下文中解包或“展开”数组(或任何其他可迭代对象,如字符串)的元素。我们在 §7.1.2 中看到展开运算符与数组文字一起使用。该运算符可以以相同的方式在函数调用中使用:
let numbers = [5, 2, 10, -1, 9, 100, 1];
Math.min(...numbers) // => -1
请注意,... 不是真正的运算符,因为它不能被评估为产生一个值。相反,它是一种特殊的 JavaScript 语法,可用于数组文字和函数调用中。
当我们在函数定义中而不是函数调用中使用相同的 ... 语法时,它的效果与展开运算符相反。正如我们在 §8.3.2 中看到的,使用 ... 在函数定义中将多个函数参数收集到一个数组中。Rest 参数和展开运算符通常一起使用,如下面的函数,该函数接受一个函数参数,并返回一个用于测试的函数的版本:
// This function takes a function and returns a wrapped version
function timed(f) {
return function(...args) { // Collect args into a rest parameter array
console.log(`Entering function ${f.name}`);
let startTime = Date.now();
try {
// Pass all of our arguments to the wrapped function
return f(...args); // Spread the args back out again
}
finally {
// Before we return the wrapped return value, print elapsed time.
console.log(`Exiting ${f.name} after ${Date.now()-startTime}ms`);
}
};
}
// Compute the sum of the numbers between 1 and n by brute force
function benchmark(n) {
let sum = 0;
for(let i = 1; i <= n; i++) sum += i;
return sum;
}
// Now invoke the timed version of that test function
timed(benchmark)(1000000) // => 500000500000; this is the sum of the numbers
8.3.5 将函数参数解构为参数
当你使用一系列参数值调用函数时,这些值最终被分配给函数定义中声明的参数。函数调用的初始阶段很像变量赋值。因此,我们可以使用解构赋值技术(参见 §3.10.3)与函数一起使用,这并不奇怪。
如果你定义一个带有方括号内参数名称的函数,那么你告诉函数期望传递一个数组值以用于每对方括号。在调用过程中,数组参数将被解包到各个命名参数中。举个例子,假设我们将 2D 向量表示为包含两个数字的数组,其中第一个元素是 X 坐标,第二个元素是 Y 坐标。使用这种简单的数据结构,我们可以编写以下函数来添加两个向量:
function vectorAdd(v1, v2) {
return [v1[0] + v2[0], v1[1] + v2[1]];
}
vectorAdd([1,2], [3,4]) // => [4,6]
如果我们将两个向量参数解构为更清晰命名的参数,代码将更容易理解:
function vectorAdd([x1,y1], [x2,y2]) { // Unpack 2 arguments into 4 parameters
return [x1 + x2, y1 + y2];
}
vectorAdd([1,2], [3,4]) // => [4,6]
同样,如果你正在定义一个期望对象参数的函数,你可以解构该对象的参数。再次使用矢量示例,假设我们将矢量表示为具有x和y参数的对象:
// Multiply the vector {x,y} by a scalar value
function vectorMultiply({x, y}, scalar) {
return { x: x*scalar, y: y*scalar };
}
vectorMultiply({x: 1, y: 2}, 2) // => {x: 2, y: 4}
将单个对象参数解构为两个参数的示例相当清晰,因为我们使用的参数名称与传入对象的属性名称匹配。当你需要将具有一个名称的属性解构为具有不同名称的参数时,语法会更冗长且更令人困惑。这里是基于对象的矢量的矢量加法示例的实现:
function vectorAdd(
{x: x1, y: y1}, // Unpack 1st object into x1 and y1 params
{x: x2, y: y2} // Unpack 2nd object into x2 and y2 params
)
{
return { x: x1 + x2, y: y1 + y2 };
}
vectorAdd({x: 1, y: 2}, {x: 3, y: 4}) // => {x: 4, y: 6}
关于解构语法如{x:x1, y:y1},让人难以记住哪些是属性名称,哪些是参数名称。要记住解构赋值和解构函数调用的规则是,被声明的变量或参数放在你期望值在对象字面量中的位置。因此,属性名称始终在冒号的左侧,参数(或变量)名称在右侧。
你可以使用解构参数定义参数默认值。这里是适用于 2D 或 3D 矢量的矢量乘法:
// Multiply the vector {x,y} or {x,y,z} by a scalar value
function vectorMultiply({x, y, z=0}, scalar) {
return { x: x*scalar, y: y*scalar, z: z*scalar };
}
vectorMultiply({x: 1, y: 2}, 2) // => {x: 2, y: 4, z: 0}
一些语言(如 Python)允许函数的调用者以name=value形式指定参数调用函数,当存在许多可选参数或参数列表足够长以至于难以记住正确顺序时,这是很方便的。JavaScript 不直接允许这样做,但你可以通过将对象参数解构为函数参数来近似实现。考虑一个函数,它从一个数组中复制指定数量的元素到另一个数组中,并为每个数组指定可选的起始偏移量。由于有五个可能的参数,其中一些具有默认值,并且调用者很难记住传递参数的顺序,我们可以像这样定义和调用arraycopy()函数:
function arraycopy({from, to=from, n=from.length, fromIndex=0, toIndex=0}) {
let valuesToCopy = from.slice(fromIndex, fromIndex + n);
to.splice(toIndex, 0, ...valuesToCopy);
return to;
}
let a = [1,2,3,4,5], b = [9,8,7,6,5];
arraycopy({from: a, n: 3, to: b, toIndex: 4}) // => [9,8,7,6,1,2,3,5]
当你解构一个数组时,你可以为被解构的数组中的额外值定义一个剩余参数。方括号内的剩余参数与函数的真正剩余参数完全不同:
// This function expects an array argument. The first two elements of that
// array are unpacked into the x and y parameters. Any remaining elements
// are stored in the coords array. And any arguments after the first array
// are packed into the rest array.
function f([x, y, ...coords], ...rest) {
return [x+y, ...rest, ...coords]; // Note: spread operator here
}
f([1, 2, 3, 4], 5, 6) // => [3, 5, 6, 3, 4]
在 ES2018 中,当你解构一个对象时,也可以使用剩余参数。该剩余参数的值将是一个对象,其中包含未被解构的任何属性。对象剩余参数通常与对象展开运算符一起使用,这也是 ES2018 的一个新功能:
// Multiply the vector {x,y} or {x,y,z} by a scalar value, retain other props
function vectorMultiply({x, y, z=0, ...props}, scalar) {
return { x: x*scalar, y: y*scalar, z: z*scalar, ...props };
}
vectorMultiply({x: 1, y: 2, w: -1}, 2) // => {x: 2, y: 4, z: 0, w: -1}
最后,请记住,除了解构参数对象和数组外,你还可以解构对象数组、具有数组属性的对象以及具有对象属性的对象,实际上可以解构到任何深度。考虑表示圆的图形代码,其中圆被表示为具有x、y、半径和颜色属性的对象,其中颜色属性是红色、绿色和蓝色颜色分量的数组。你可以定义一个函数,该函数期望传递一个圆对象,但将该圆对象解构为六个单独的参数:
function drawCircle({x, y, radius, color: [r, g, b]}) {
// Not yet implemented
}
如果函数参数解构比这更复杂,我发现代码变得更难阅读,而不是更简单。有时,明确地访问对象属性和数组索引会更清晰。
8.3.6 参数类型
JavaScript 方法参数没有声明类型,并且不对传递给函数的值执行类型检查。通过为函数参数选择描述性名称并在每个函数的注释中仔细记录它们,可以帮助使代码自我描述。(或者,参见§17.8 中允许你在常规 JavaScript 之上添加类型检查的语言扩展。)
如 §3.9 中所述,JavaScript 根据需要执行自由的类型转换。因此,如果您编写一个期望字符串参数的函数,然后使用其他类型的值调用该函数,那么当函数尝试将其用作字符串时,您传递的值将被简单地转换为字符串。所有原始类型都可以转换为字符串,所有对象都有 toString() 方法(不一定是有用的),因此在这种情况下不会发生错误。
然而,这并不总是正确的。再次考虑之前显示的 arraycopy() 方法。它期望一个或两个数组参数,并且如果这些参数的类型错误,则会失败。除非您正在编写一个只会从代码附近的部分调用的私有函数,否则值得添加代码来检查参数的类型。当传递错误的值时,最好让函数立即和可预测地失败,而不是开始执行然后在稍后失败并显示可能不清晰的错误消息。这里有一个执行类型检查的示例函数:
// Return the sum of the elements an iterable object a.
// The elements of a must all be numbers.
function sum(a) {
let total = 0;
for(let element of a) { // Throws TypeError if a is not iterable
if (typeof element !== "number") {
throw new TypeError("sum(): elements must be numbers");
}
total += element;
}
return total;
}
sum([1,2,3]) // => 6
sum(1, 2, 3); // !TypeError: 1 is not iterable
sum([1,2,"3"]); // !TypeError: element 2 is not a number
8.4 函数作为值
函数最重要的特点是它们可以被定义和调用。函数的定义和调用是 JavaScript 和大多数其他编程语言的语法特性。然而,在 JavaScript 中,函数不仅仅是语法,还是值,这意味着它们可以被分配给变量,存储在对象的属性或数组的元素中,作为函数的参数传递等。³
要理解函数如何既可以是 JavaScript 数据又可以是 JavaScript 语法,请考虑这个函数定义:
function square(x) { return x*x; }
这个定义创建了一个新的函数对象并将其分配给变量 square。函数的名称实际上并不重要;它只是一个指向函数对象的变量的名称。该函数可以分配给另一个变量,仍然可以正常工作:
let s = square; // Now s refers to the same function that square does
square(4) // => 16
s(4) // => 16
函数也可以被分配给对象属性而不是变量。正如我们之前讨论过的,当我们这样做时,我们将这些函数称为“方法”:
let o = {square: function(x) { return x*x; }}; // An object literal
let y = o.square(16); // y == 256
函数甚至不需要名称,比如当它们被分配给数组元素时:
let a = [x => x*x, 20]; // An array literal
a0 // => 400
最后一个示例的语法看起来很奇怪,但仍然是一个合法的函数调用表达式!
作为将函数视为值的有用性的一个例子,考虑 Array.sort() 方法。该方法对数组的元素进行排序。由于有许多可能的排序顺序(数字顺序、字母顺序、日期顺序、升序、降序等),sort() 方法可以选择接受一个函数作为参数,告诉它如何执行排序。这个函数的工作很简单:对于传递给它的任何两个值,它返回一个指定哪个元素在排序后的数组中首先出现的值。这个函数参数使 Array.sort() 变得非常通用和无限灵活;它可以将任何类型的数据按照任何可想象的顺序进行排序。示例在 §7.8.6 中展示。
示例 8-1 展示了当函数被用作值时可以做的事情。这个例子可能有点棘手,但注释解释了发生了什么。
示例 8-1。将函数用作数据
// We define some simple functions here
function add(x,y) { return x + y; }
function subtract(x,y) { return x - y; }
function multiply(x,y) { return x * y; }
function divide(x,y) { return x / y; }
// Here's a function that takes one of the preceding functions
// as an argument and invokes it on two operands
function operate(operator, operand1, operand2) {
return operator(operand1, operand2);
}
// We could invoke this function like this to compute the value (2+3) + (4*5):
let i = operate(add, operate(add, 2, 3), operate(multiply, 4, 5));
// For the sake of the example, we implement the simple functions again,
// this time within an object literal;
const operators = {
add: (x,y) => x+y,
subtract: (x,y) => x-y,
multiply: (x,y) => x*y,
divide: (x,y) => x/y,
pow: Math.pow // This works for predefined functions too
};
// This function takes the name of an operator, looks up that operator
// in the object, and then invokes it on the supplied operands. Note
// the syntax used to invoke the operator function.
function operate2(operation, operand1, operand2) {
if (typeof operators[operation] === "function") {
return operatorsoperation;
}
else throw "unknown operator";
}
operate2("add", "hello", operate2("add", " ", "world")) // => "hello world"
operate2("pow", 10, 2) // => 100
8.4.1 定义自己的函数属性
在 JavaScript 中,函数不是原始值,而是一种特殊的对象,这意味着函数可以有属性。当一个函数需要一个“静态”变量,其值在调用之间保持不变时,通常方便使用函数本身的属性。例如,假设你想编写一个函数,每次调用时都返回一个唯一的整数。该函数可能两次返回相同的值。为了管理这个问题,函数需要跟踪它已经返回的值,并且这个信息必须在函数调用之间保持不变。你可以将这个信息存储在一个全局变量中,但这是不必要的,因为这个信息只被函数本身使用。最好将信息存储在 Function 对象的属性中。下面是一个示例,每次调用时都返回一个唯一的整数:
// Initialize the counter property of the function object.
// Function declarations are hoisted so we really can
// do this assignment before the function declaration.
uniqueInteger.counter = 0;
// This function returns a different integer each time it is called.
// It uses a property of itself to remember the next value to be returned.
function uniqueInteger() {
return uniqueInteger.counter++; // Return and increment counter property
}
uniqueInteger() // => 0
uniqueInteger() // => 1
举个例子,考虑下面的factorial()函数,它利用自身的属性(将自身视为数组)来缓存先前计算的结果:
// Compute factorials and cache results as properties of the function itself.
function factorial(n) {
if (Number.isInteger(n) && n > 0) { // Positive integers only
if (!(n in factorial)) { // If no cached result
factorial[n] = n * factorial(n-1); // Compute and cache it
}
return factorial[n]; // Return the cached result
} else {
return NaN; // If input was bad
}
}
factorial[1] = 1; // Initialize the cache to hold this base case.
factorial(6) // => 720
factorial[5] // => 120; the call above caches this value
8.5 函数作为命名空间
在函数内声明的变量在函数外部是不可见的。因此,有时候定义一个函数仅仅作为一个临时的命名空间是很有用的,你可以在其中定义变量而不会使全局命名空间混乱。
例如,假设你有一段 JavaScript 代码块,你想在许多不同的 JavaScript 程序中使用(或者对于客户端 JavaScript,在许多不同的网页上使用)。假设这段代码,像大多数代码一样,定义变量来存储计算的中间结果。问题在于,由于这段代码将在许多不同的程序中使用,你不知道它创建的变量是否会与使用它的程序创建的变量发生冲突。解决方案是将代码块放入一个函数中,然后调用该函数。这样,原本将是全局的变量变为函数的局部变量:
function chunkNamespace() {
// Chunk of code goes here
// Any variables defined in the chunk are local to this function
// instead of cluttering up the global namespace.
}
chunkNamespace(); // But don't forget to invoke the function!
这段代码只定义了一个全局变量:函数名chunkNamespace。如果即使定义一个属性也太多了,你可以在单个表达式中定义并调用一个匿名函数:
(function() { // chunkNamespace() function rewritten as an unnamed expression.
// Chunk of code goes here
}()); // End the function literal and invoke it now.
定义和调用一个函数的单个表达式的技术经常被使用,已经成为惯用语,并被称为“立即调用函数表达式”。请注意前面代码示例中括号的使用。在function之前的开括号是必需的,因为没有它,JavaScript 解释器会尝试将function关键字解析为函数声明语句。有了括号,解释器正确地将其识别为函数定义表达式。前导括号还有助于人类读者识别何时定义一个函数以立即调用,而不是为以后使用而定义。
当我们在命名空间函数内部定义一个或多个函数,并使用该命名空间内的变量,然后将它们作为命名空间函数的返回值传递出去时,函数作为命名空间的用法变得非常有用。这样的函数被称为闭包,它们是下一节的主题。
8.6 闭包
像大多数现代编程语言一样,JavaScript 使用词法作用域。这意味着函数在定义时使用的变量作用域,而不是在调用时使用的变量作用域。为了实现词法作用域,JavaScript 函数对象的内部状态必须包括函数的代码以及函数定义所在的作用域的引用。在计算机科学文献中,函数对象和作用域(一组变量绑定)的组合,用于解析函数变量的作用域,被称为闭包。
从技术上讲,所有的 JavaScript 函数都是闭包,但由于大多数函数是从定义它们的同一作用域中调用的,通常并不重要闭包是否涉及其中。当闭包从与其定义所在不同的作用域中调用时,闭包就变得有趣起来。这种情况最常见于从定义它的函数中返回嵌套函数对象时。有许多强大的编程技术涉及到这种嵌套函数闭包,它们在 JavaScript 编程中的使用变得相对常见。当你第一次遇到闭包时,它们可能看起来令人困惑,但重要的是你要足够了解它们以便舒适地使用它们。
理解闭包的第一步是复习嵌套函数的词法作用域规则。考虑以下代码:
let scope = "global scope"; // A global variable
function checkscope() {
let scope = "local scope"; // A local variable
function f() { return scope; } // Return the value in scope here
return f();
}
checkscope() // => "local scope"
checkscope()函数声明了一个局部变量,然后定义并调用一个返回该变量值的函数。你应该清楚为什么调用checkscope()会返回“local scope”。现在,让我们稍微改变一下代码。你能告诉这段代码会返回什么吗?
let scope = "global scope"; // A global variable
function checkscope() {
let scope = "local scope"; // A local variable
function f() { return scope; } // Return the value in scope here
return f;
}
let s = checkscope()(); // What does this return?
在这段代码中,一对括号已经从checkscope()内部移到了外部。现在,checkscope()不再调用嵌套函数并返回其结果,而是直接返回嵌套函数对象本身。当我们在定义它的函数之外调用该嵌套函数(在代码的最后一行中的第二对括号中)时会发生什么?
记住词法作用域的基本规则:JavaScript 函数是在定义它们的作用域中执行的。嵌套函数f()是在一个作用域中定义的,该作用域中变量scope绑定到值“local scope”。当执行f时,这个绑定仍然有效,无论从哪里执行。因此,前面代码示例的最后一行返回“local scope”,而不是“global scope”。这就是闭包的令人惊讶和强大的本质:它们捕获了它们所定义的外部函数的局部变量(和参数)绑定。
在§8.4.1 中,我们定义了一个uniqueInteger()函数,该函数使用函数本身的属性来跟踪下一个要返回的值。这种方法的一个缺点是,有错误或恶意代码可能会重置计数器或将其设置为非整数,导致uniqueInteger()函数违反其“unique”或“integer”部分的约定。闭包捕获了单个函数调用的局部变量,并可以将这些变量用作私有状态。下面是我们如何使用立即调用函数表达式来重新编写uniqueInteger(),以定义一个命名空间和使用该命名空间来保持其状态私有的闭包:
let uniqueInteger = (function() { // Define and invoke
let counter = 0; // Private state of function below
return function() { return counter++; };
}());
uniqueInteger() // => 0
uniqueInteger() // => 1
要理解这段代码,你必须仔细阅读它。乍一看,代码的第一行看起来像是将一个函数赋给变量uniqueInteger。实际上,代码正在定义并调用一个函数(第一行的开括号提示了这一点),因此将函数的返回值赋给了uniqueInteger。现在,如果我们研究函数体,我们会发现它的返回值是另一个函数。正是这个嵌套函数对象被赋给了uniqueInteger。嵌套函数可以访问其作用域中的变量,并且可以使用外部函数中定义的counter变量。一旦外部函数返回,其他代码就无法看到counter变量:内部函数对其具有独占访问权限。
像counter这样的私有变量不一定是单个闭包的专有:完全可以在同一个外部函数中定义两个或更多个嵌套函数并共享相同的作用域。考虑以下代码:
function counter() {
let n = 0;
return {
count: function() { return n++; },
reset: function() { n = 0; }
};
}
let c = counter(), d = counter(); // Create two counters
c.count() // => 0
d.count() // => 0: they count independently
c.reset(); // reset() and count() methods share state
c.count() // => 0: because we reset c
d.count() // => 1: d was not reset
counter()函数返回一个“计数器”对象。这个对象有两个方法:count()返回下一个整数,reset()重置内部状态。首先要理解的是,这两个方法共享对私有变量n的访问。其次要理解的是,每次调用counter()都会创建一个新的作用域——独立于先前调用使用的作用域,并在该作用域内创建一个新的私有变量。因此,如果您两次调用counter(),您将得到两个具有不同私有变量的计数器对象。在一个计数器对象上调用count()或reset()对另一个没有影响。
值得注意的是,您可以将闭包技术与属性的 getter 和 setter 结合使用。下面这个counter()函数的版本是§6.10.6 中出现的代码的变体,但它使用闭包来实现私有状态,而不是依赖于常规对象属性:
function counter(n) { // Function argument n is the private variable
return {
// Property getter method returns and increments private counter var.
get count() { return n++; },
// Property setter doesn't allow the value of n to decrease
set count(m) {
if (m > n) n = m;
else throw Error("count can only be set to a larger value");
}
};
}
let c = counter(1000);
c.count // => 1000
c.count // => 1001
c.count = 2000;
c.count // => 2000
c.count = 2000; // !Error: count can only be set to a larger value
注意,这个counter()函数的版本并没有声明一个局部变量,而是只是使用其参数n来保存属性访问方法共享的私有状态。这允许counter()的调用者指定私有变量的初始值。
示例 8-2 是通过我们一直在演示的闭包技术对共享私有状态进行泛化的一个例子。这个示例定义了一个addPrivateProperty()函数,该函数定义了一个私有变量和两个嵌套函数来获取和设置该变量的值。它将这些嵌套函数作为您指定对象的方法添加。
示例 8-2. 使用闭包的私有属性访问方法
// This function adds property accessor methods for a property with
// the specified name to the object o. The methods are named get<name>
// and set<name>. If a predicate function is supplied, the setter
// method uses it to test its argument for validity before storing it.
// If the predicate returns false, the setter method throws an exception.
//
// The unusual thing about this function is that the property value
// that is manipulated by the getter and setter methods is not stored in
// the object o. Instead, the value is stored only in a local variable
// in this function. The getter and setter methods are also defined
// locally to this function and therefore have access to this local variable.
// This means that the value is private to the two accessor methods, and it
// cannot be set or modified except through the setter method.
function addPrivateProperty(o, name, predicate) {
let value; // This is the property value
// The getter method simply returns the value.
o[`get${name}`] = function() { return value; };
// The setter method stores the value or throws an exception if
// the predicate rejects the value.
o[`set${name}`] = function(v) {
if (predicate && !predicate(v)) {
throw new TypeError(`set${name}: invalid value ${v}`);
} else {
value = v;
}
};
}
// The following code demonstrates the addPrivateProperty() method.
let o = {}; // Here is an empty object
// Add property accessor methods getName and setName()
// Ensure that only string values are allowed
addPrivateProperty(o, "Name", x => typeof x === "string");
o.setName("Frank"); // Set the property value
o.getName() // => "Frank"
o.setName(0); // !TypeError: try to set a value of the wrong type
现在我们已经看到了许多例子,其中两个闭包在同一个作用域中定义并共享对相同私有变量或变量的访问。这是一个重要的技术,但同样重要的是要认识到闭包无意中共享对不应共享的变量的访问。考虑以下代码:
// This function returns a function that always returns v
function constfunc(v) { return () => v; }
// Create an array of constant functions:
let funcs = [];
for(var i = 0; i < 10; i++) funcs[i] = constfunc(i);
// The function at array element 5 returns the value 5.
funcs[5]() // => 5
在处理像这样使用循环创建多个闭包的代码时,一个常见的错误是尝试将循环移到定义闭包的函数内部。例如,考虑以下代码:
// Return an array of functions that return the values 0-9
function constfuncs() {
let funcs = [];
for(var i = 0; i < 10; i++) {
funcs[i] = () => i;
}
return funcs;
}
let funcs = constfuncs();
funcs[5]() // => 10; Why doesn't this return 5?
这段代码创建了 10 个闭包并将它们存储在一个数组中。这些闭包都在同一个函数调用中定义,因此它们共享对变量i的访问。当constfuncs()返回时,变量i的值为 10,所有 10 个闭包都共享这个值。因此,返回的函数数组中的所有函数都返回相同的值,这并不是我们想要的。重要的是要记住,与闭包相关联的作用域是“活动的”。嵌套函数不会创建作用域的私有副本,也不会对变量绑定进行静态快照。从根本上说,这里的问题是使用var声明的变量在整个函数中都被定义。我们的for循环使用var i声明循环变量,因此变量i在整个函数中被定义,而不是更窄地限制在循环体内。这段代码展示了 ES5 及之前版本中常见的一类错误,但 ES6 引入的块作用域变量解决了这个问题。如果我们只是用let或const替换var,问题就消失了。因为let和const是块作用域的,循环的每次迭代都定义了一个独立于所有其他迭代的作用域,并且每个作用域都有自己独立的i绑定。
写闭包时要记住的另一件事是,this是 JavaScript 关键字,而不是变量。正如前面讨论的,箭头函数继承了包含它们的函数的this值,但使用function关键字定义的函数不会。因此,如果您编写一个需要使用其包含函数的this值的闭包,您应该在返回之前使用箭头函数或调用bind(),或将外部this值分配给闭包将继承的变量:
const self = this; // Make the this value available to nested functions
8.7 函数属性、方法和构造函数
我们已经看到函数在 JavaScript 程序中是值。当应用于函数时,typeof运算符返回字符串“function”,但函数实际上是 JavaScript 对象的一种特殊类型。由于函数是对象,它们可以像任何其他对象一样具有属性和方法。甚至有一个Function()构造函数来创建新的函数对象。接下来的小节记录了length、name和prototype属性;call()、apply()、bind()和toString()方法;以及Function()构造函数。
8.7.1 length 属性
函数的只读length属性指定函数的arity——它在参数列表中声明的参数数量,通常是函数期望的参数数量。如果函数有一个剩余参数,那么这个参数不会计入length属性的目的。
8.7.2 名称属性
函数的只读name属性指定函数在定义时使用的名称,如果它是用名称定义的,或者在创建时未命名的函数表达式被分配给的变量或属性的名称。当编写调试或错误消息时,此属性非常有用。
8.7.3 prototype 属性
所有函数,除了箭头函数,都有一个prototype属性,指向一个称为原型对象的对象。每个函数都有一个不同的原型对象。当一个函数被用作构造函数时,新创建的对象会从原型对象继承属性。原型和prototype属性在§6.2.3 中讨论过,并将在第九章中再次涉及。
8.7.4 call()和 apply()方法
call()和apply()允许您间接调用(§8.2.4)一个函数,就好像它是另一个对象的方法一样。call()和apply()的第一个参数是要调用函数的对象;这个参数是调用上下文,并在函数体内成为this关键字的值。要将函数f()作为对象o的方法调用(不传递参数),可以使用call()或apply():
f.call(o);
f.apply(o);
这两行代码中的任何一行与以下代码类似(假设o尚未具有名为m的属性):
o.m = f; // Make f a temporary method of o.
o.m(); // Invoke it, passing no arguments.
delete o.m; // Remove the temporary method.
请记住,箭头函数继承了定义它们的上下文的this值。这不能通过call()和apply()方法覆盖。如果在箭头函数上调用这些方法之一,第一个参数实际上会被忽略。
在第一个调用上下文参数之后的任何call()参数都是传递给被调用函数的值(对于箭头函数,这些参数不会被忽略)。例如,要向函数f()传递两个数字,并将其作为对象o的方法调用,可以使用以下代码:
f.call(o, 1, 2);
apply()方法类似于call()方法,只是要传递给函数的参数被指定为一个数组:
f.apply(o, [1,2]);
如果一个函数被定义为接受任意数量的参数,apply() 方法允许你在任意长度的数组内容上调用该函数。在 ES6 及更高版本中,我们可以直接使用扩展运算符,但你可能会看到使用 apply() 而不是扩展运算符的 ES5 代码。例如,要在不使用扩展运算符的情况下找到数组中的最大数,你可以使用 apply() 方法将数组的元素传递给 Math.max() 函数:
let biggest = Math.max.apply(Math, arrayOfNumbers);
下面定义的 trace() 函数类似于 §8.3.4 中定义的 timed() 函数,但它适用于方法而不是函数。它使用 apply() 方法而不是扩展运算符,通过这样做,它能够以与包装方法相同的参数和 this 值调用被包装的方法:
// Replace the method named m of the object o with a version that logs
// messages before and after invoking the original method.
function trace(o, m) {
let original = o[m]; // Remember original method in the closure.
o[m] = function(...args) { // Now define the new method.
console.log(new Date(), "Entering:", m); // Log message.
let result = original.apply(this, args); // Invoke original.
console.log(new Date(), "Exiting:", m); // Log message.
return result; // Return result.
};
}
8.7.5 bind() 方法
bind() 的主要目的是将函数绑定到对象。当你在函数 f 上调用 bind() 方法并传递一个对象 o 时,该方法会返回一个新函数。调用新函数(作为函数)会将原始函数 f 作为 o 的方法调用。传递给新函数的任何参数都会传递给原始函数。例如:
function f(y) { return this.x + y; } // This function needs to be bound
let o = { x: 1 }; // An object we'll bind to
let g = f.bind(o); // Calling g(x) invokes f() on o
g(2) // => 3
let p = { x: 10, g }; // Invoke g() as a method of this object
p.g(2) // => 3: g is still bound to o, not p.
箭头函数从定义它们的环境继承它们的 this 值,并且该值不能被 bind() 覆盖,因此如果前面代码中的函数 f() 被定义为箭头函数,绑定将不起作用。然而,调用 bind() 最常见的用例是使非箭头函数的行为类似箭头函数,因此在实践中,对绑定箭头函数的限制并不是问题。
bind() 方法不仅仅是将函数绑定到对象,它还可以执行部分应用:在第一个参数之后传递给 bind() 的任何参数都与 this 值一起绑定。bind() 的这种部分应用特性适用于箭头函数。部分应用是函数式编程中的常见技术,有时被称为柯里化。以下是 bind() 方法用于部分应用的一些示例:
let sum = (x,y) => x + y; // Return the sum of 2 args
let succ = sum.bind(null, 1); // Bind the first argument to 1
succ(2) // => 3: x is bound to 1, and we pass 2 for the y argument
function f(y,z) { return this.x + y + z; }
let g = f.bind({x: 1}, 2); // Bind this and y
g(3) // => 6: this.x is bound to 1, y is bound to 2 and z is 3
由 bind() 返回的函数的 name 属性是调用 bind() 的函数的名称属性,前缀为“bound”。
8.7.6 toString() 方法
像所有 JavaScript 对象一样,函数有一个 toString() 方法。ECMAScript 规范要求该方法返回一个遵循函数声明语法的字符串。实际上,大多数(但不是所有)实现这个 toString() 方法的实现会返回函数的完整源代码。内置函数通常返回一个包含类似“[native code]”的字符串作为函数体的字符串。
8.7.7 Function() 构造函数
因为函数是对象,所以有一个 Function() 构造函数可用于创建新函数:
const f = new Function("x", "y", "return x*y;");
这行代码创建了一个新函数,它与使用熟悉语法定义的函数更或多少等效:
const f = function(x, y) { return x*y; };
Function() 构造函数期望任意数量的字符串参数。最后一个参数是函数体的文本;它可以包含任意 JavaScript 语句,用分号分隔。构造函数的所有其他参数都是指定函数参数名称的字符串。如果你定义一个不带参数的函数,你只需将一个字符串(函数体)传递给构造函数。
注意 Function() 构造函数没有传递任何指定创建的函数名称的参数。与函数字面量一样,Function() 构造函数创建匿名函数。
有几点很重要需要了解关于 Function() 构造函数:
-
Function()构造函数允许在运行时动态创建和编译 JavaScript 函数。 -
Function()构造函数解析函数体并在每次调用时创建一个新的函数对象。如果构造函数的调用出现在循环中或在频繁调用的函数内部,这个过程可能效率低下。相比之下,在循环中出现的嵌套函数和函数表达式在遇到时不会重新编译。 -
关于
Function()构造函数的最后一个非常重要的观点是,它创建的函数不使用词法作用域;相反,它们总是被编译为顶级函数,如下面的代码所示:let scope = "global"; function constructFunction() { let scope = "local"; return new Function("return scope"); // Doesn't capture local scope! } // This line returns "global" because the function returned by the // Function() constructor does not use the local scope. constructFunction()() // => "global"
Function()构造函数最好被视为eval()的全局作用域版本(参见§4.12.2),它在自己的私有作用域中定义新的变量和函数。你可能永远不需要在你的代码中使用这个构造函数。
8.8 函数式编程
JavaScript 不像 Lisp 或 Haskell 那样是一种函数式编程语言,但 JavaScript 可以将函数作为对象进行操作的事实意味着我们可以在 JavaScript 中使用函数式编程技术。数组方法如map()和reduce()特别适合函数式编程风格。接下来的部分演示了 JavaScript 中函数式编程的技术。它们旨在探索 JavaScript 函数的强大功能,而不是规范良好的编程风格。
8.8.1 使用函数处理数组
假设我们有一个数字数组,我们想要计算这些值的均值和标准差。我们可以像这样以非函数式的方式进行:
let data = [1,1,3,5,5]; // This is our array of numbers
// The mean is the sum of the elements divided by the number of elements
let total = 0;
for(let i = 0; i < data.length; i++) total += data[i];
let mean = total/data.length; // mean == 3; The mean of our data is 3
// To compute the standard deviation, we first sum the squares of
// the deviation of each element from the mean.
total = 0;
for(let i = 0; i < data.length; i++) {
let deviation = data[i] - mean;
total += deviation * deviation;
}
let stddev = Math.sqrt(total/(data.length-1)); // stddev == 2
我们可以使用数组方法map()和reduce()以简洁的函数式风格执行相同的计算,如下所示(参见§7.8.1 回顾这些方法):
// First, define two simple functions
const sum = (x,y) => x+y;
const square = x => x*x;
// Then use those functions with Array methods to compute mean and stddev
let data = [1,1,3,5,5];
let mean = data.reduce(sum)/data.length; // mean == 3
let deviations = data.map(x => x-mean);
let stddev = Math.sqrt(deviations.map(square).reduce(sum)/(data.length-1));
stddev // => 2
这个新版本的代码看起来与第一个版本非常不同,但仍然在对象上调用方法,因此仍然保留了一些面向对象的约定。让我们编写map()和reduce()方法的函数式版本:
const map = function(a, ...args) { return a.map(...args); };
const reduce = function(a, ...args) { return a.reduce(...args); };
有了这些定义的map()和reduce()函数,我们现在计算均值和标准差的代码如下:
const sum = (x,y) => x+y;
const square = x => x*x;
let data = [1,1,3,5,5];
let mean = reduce(data, sum)/data.length;
let deviations = map(data, x => x-mean);
let stddev = Math.sqrt(reduce(map(deviations, square), sum)/(data.length-1));
stddev // => 2
8.8.2 高阶函数
高阶函数是一个操作函数的函数,它接受一个或多个函数作为参数并返回一个新函数。这里有一个例子:
// This higher-order function returns a new function that passes its
// arguments to f and returns the logical negation of f's return value;
function not(f) {
return function(...args) { // Return a new function
let result = f.apply(this, args); // that calls f
return !result; // and negates its result.
};
}
const even = x => x % 2 === 0; // A function to determine if a number is even
const odd = not(even); // A new function that does the opposite
[1,1,3,5,5].every(odd) // => true: every element of the array is odd
这个not()函数是一个高阶函数,因为它接受一个函数参数并返回一个新函数。再举一个例子,考虑接下来的mapper()函数。它接受一个函数参数并返回一个使用该函数将一个数组映射到另一个数组的新函数。这个函数使用了之前定义的map()函数,你需要理解这两个函数的不同之处很重要:
// Return a function that expects an array argument and applies f to
// each element, returning the array of return values.
// Contrast this with the map() function from earlier.
function mapper(f) {
return a => map(a, f);
}
const increment = x => x+1;
const incrementAll = mapper(increment);
incrementAll([1,2,3]) // => [2,3,4]
这里是另一个更一般的例子,它接受两个函数f和g,并返回一个计算f(g())的新函数:
// Return a new function that computes f(g(...)).
// The returned function h passes all of its arguments to g, then passes
// the return value of g to f, then returns the return value of f.
// Both f and g are invoked with the same this value as h was invoked with.
function compose(f, g) {
return function(...args) {
// We use call for f because we're passing a single value and
// apply for g because we're passing an array of values.
return f.call(this, g.apply(this, args));
};
}
const sum = (x,y) => x+y;
const square = x => x*x;
compose(square, sum)(2,3) // => 25; the square of the sum
在接下来的部分中定义的partial()和memoize()函数是另外两个重要的高阶函数。
8.8.3 函数的部分应用
函数f的bind()方法(参见§8.7.5)返回一个在指定上下文中调用f并带有指定参数集的新函数。我们说它将函数绑定到一个对象并部分应用参数。bind()方法在左侧部分应用参数,也就是说,你传递给bind()的参数被放在传递给原始函数的参数列表的开头。但也可以在右侧部分应用参数:
// The arguments to this function are passed on the left
function partialLeft(f, ...outerArgs) {
return function(...innerArgs) { // Return this function
let args = [...outerArgs, ...innerArgs]; // Build the argument list
return f.apply(this, args); // Then invoke f with it
};
}
// The arguments to this function are passed on the right
function partialRight(f, ...outerArgs) {
return function(...innerArgs) { // Return this function
let args = [...innerArgs, ...outerArgs]; // Build the argument list
return f.apply(this, args); // Then invoke f with it
};
}
// The arguments to this function serve as a template. Undefined values
// in the argument list are filled in with values from the inner set.
function partial(f, ...outerArgs) {
return function(...innerArgs) {
let args = [...outerArgs]; // local copy of outer args template
let innerIndex=0; // which inner arg is next
// Loop through the args, filling in undefined values from inner args
for(let i = 0; i < args.length; i++) {
if (args[i] === undefined) args[i] = innerArgs[innerIndex++];
}
// Now append any remaining inner arguments
args.push(...innerArgs.slice(innerIndex));
return f.apply(this, args);
};
}
// Here is a function with three arguments
const f = function(x,y,z) { return x * (y - z); };
// Notice how these three partial applications differ
partialLeft(f, 2)(3,4) // => -2: Bind first argument: 2 * (3 - 4)
partialRight(f, 2)(3,4) // => 6: Bind last argument: 3 * (4 - 2)
partial(f, undefined, 2)(3,4) // => -6: Bind middle argument: 3 * (2 - 4)
这些部分应用函数使我们能够轻松地从已定义的函数中定义有趣的函数。以下是一些示例:
const increment = partialLeft(sum, 1);
const cuberoot = partialRight(Math.pow, 1/3);
cuberoot(increment(26)) // => 3
当我们将部分应用与其他高阶函数结合时,部分应用变得更加有趣。例如,以下是使用组合和部分应用定义前面刚刚展示的not()函数的一种方法:
const not = partialLeft(compose, x => !x);
const even = x => x % 2 === 0;
const odd = not(even);
const isNumber = not(isNaN);
odd(3) && isNumber(2) // => true
我们还可以使用组合和部分应用来以极端函数式风格重新执行我们的均值和标准差计算:
// sum() and square() functions are defined above. Here are some more:
const product = (x,y) => x*y;
const neg = partial(product, -1);
const sqrt = partial(Math.pow, undefined, .5);
const reciprocal = partial(Math.pow, undefined, neg(1));
// Now compute the mean and standard deviation.
let data = [1,1,3,5,5]; // Our data
let mean = product(reduce(data, sum), reciprocal(data.length));
let stddev = sqrt(product(reduce(map(data,
compose(square,
partial(sum, neg(mean)))),
sum),
reciprocal(sum(data.length,neg(1)))));
[mean, stddev] // => [3, 2]
请注意,这段用于计算均值和标准差的代码完全是函数调用;没有涉及运算符,并且括号的数量已经变得如此之多,以至于这段 JavaScript 代码开始看起来像 Lisp 代码。再次强调,这不是我推崇的 JavaScript 编程风格,但看到 JavaScript 代码可以有多函数式是一个有趣的练习。
8.8.4 Memoization
在§8.4.1 中,我们定义了一个阶乘函数,它缓存了先前计算的结果。在函数式编程中,这种缓存称为memoization。接下来的代码展示了一个高阶函数,memoize(),它接受一个函数作为参数,并返回该函数的一个记忆化版本:
// Return a memoized version of f.
// It only works if arguments to f all have distinct string representations.
function memoize(f) {
const cache = new Map(); // Value cache stored in the closure.
return function(...args) {
// Create a string version of the arguments to use as a cache key.
let key = args.length + args.join("+");
if (cache.has(key)) {
return cache.get(key);
} else {
let result = f.apply(this, args);
cache.set(key, result);
return result;
}
};
}
memoize()函数创建一个新对象用作缓存,并将此对象分配给一个局部变量,以便它对(在返回的函数的闭包中)是私有的。返回的函数将其参数数组转换为字符串,并将该字符串用作缓存对象的属性名。如果缓存中存在值,则直接返回它。否则,调用指定的函数来计算这些参数的值,缓存该值,并返回它。以下是我们如何使用memoize():
// Return the Greatest Common Divisor of two integers using the Euclidian
// algorithm: http://en.wikipedia.org/wiki/Euclidean_algorithm
function gcd(a,b) { // Type checking for a and b has been omitted
if (a < b) { // Ensure that a >= b when we start
[a, b] = [b, a]; // Destructuring assignment to swap variables
}
while(b !== 0) { // This is Euclid's algorithm for GCD
[a, b] = [b, a%b];
}
return a;
}
const gcdmemo = memoize(gcd);
gcdmemo(85, 187) // => 17
// Note that when we write a recursive function that we will be memoizing,
// we typically want to recurse to the memoized version, not the original.
const factorial = memoize(function(n) {
return (n <= 1) ? 1 : n * factorial(n-1);
});
factorial(5) // => 120: also caches values for 4, 3, 2 and 1.
8.9 总结
关于本章的一些关键要点如下:
-
您可以使用
function关键字和 ES6 的=>箭头语法定义函数。 -
您可以调用函数,这些函数可以用作方法和构造函数。
-
一些 ES6 功能允许您为可选函数参数定义默认值,使用 rest 参数将多个参数收集到一个数组中,并将对象和数组参数解构为函数参数。
-
您可以使用
...扩展运算符将数组或其他可迭代对象的元素作为参数传递给函数调用。 -
在封闭函数内部定义并返回的函数保留对其词法作用域的访问权限,因此可以读取和写入外部函数中定义的变量。以这种方式使用的函数称为closures,这是一种值得理解的技术。
-
函数是 JavaScript 可以操作的对象,这使得函数式编程成为可能。
¹ 这个术语是由 Martin Fowler 创造的。参见http://martinfowler.com/dslCatalog/methodChaining.html。
² 如果你熟悉 Python,注意这与 Python 不同,其中每次调用都共享相同的默认值。
³ 这可能看起来不是特别有趣,除非您熟悉更静态的语言,在这些语言中,函数是程序的一部分,但不能被程序操纵。
第九章:类
JavaScript 对象在第六章中有所涉及。该章将每个对象视为一组独特的属性,与其他对象不同。然而,通常有必要定义一种共享某些属性的对象类。类的成员或实例具有自己的属性来保存或定义它们的状态,但它们还具有定义其行为的方法。这些方法由类定义,并由所有实例共享。例如,想象一个名为 Complex 的类,表示并对复数执行算术运算。Complex 实例将具有保存复数的实部和虚部(状态)的属性。Complex 类将定义执行这些数字的加法和乘法(行为)的方法。
在 JavaScript 中,类使用基于原型的继承:如果两个对象从同一原型继承属性(通常是函数值属性或方法),那么我们说这些对象是同一类的实例。简而言之,这就是 JavaScript 类的工作原理。JavaScript 原型和继承在§6.2.3 和§6.3.2 中有所涉及,您需要熟悉这些部分的内容才能理解本章。本章在§9.1 中涵盖了原型。
如果两个对象从同一原型继承,这通常(但不一定)意味着它们是由同一构造函数或工厂函数创建和初始化的。构造函数在§4.6、§6.2.2 和§8.2.3 中有所涉及,本章在§9.2 中有更多内容。
JavaScript 一直允许定义类。ES6 引入了全新的语法(包括class关键字),使得创建类变得更加容易。这些新的 JavaScript 类与旧式类的工作方式相同,本章首先解释了创建类的旧方法,因为这更清楚地展示了在幕后使类起作用的原理。一旦我们解释了这些基础知识,我们将转而开始使用新的简化类定义语法。
如果您熟悉像 Java 或 C++这样的强类型面向对象编程语言,您会注意到 JavaScript 类与这些语言中的类有很大不同。虽然有一些语法上的相似之处,并且您可以在 JavaScript 中模拟许多“经典”类的特性,但最好事先了解 JavaScript 的类和基于原型的继承机制与 Java 和类似语言的类和基于类的继承机制有很大不同。
9.1 类和原型
在 JavaScript 中,类是一组从同一原型对象继承属性的对象。因此,原型对象是类的核心特征。第六章介绍了Object.create()函数,该函数返回一个从指定类型对象继承的新创建对象。如果我们定义一个原型对象,然后使用Object.create()创建从中继承的对象,我们就定义了一个 JavaScript 类。通常,类的实例需要进一步初始化,通常定义一个函数来创建和初始化新对象。示例 9-1 演示了这一点:它定义了一个代表值范围的类的原型对象,并定义了一个工厂函数,用于创建和初始化类的新实例。
示例 9-1 一个简单的 JavaScript 类
// This is a factory function that returns a new range object.
function range(from, to) {
// Use Object.create() to create an object that inherits from the
// prototype object defined below. The prototype object is stored as
// a property of this function, and defines the shared methods (behavior)
// for all range objects.
let r = Object.create(range.methods);
// Store the start and end points (state) of this new range object.
// These are noninherited properties that are unique to this object.
r.from = from;
r.to = to;
// Finally return the new object
return r;
}
// This prototype object defines methods inherited by all range objects.
range.methods = {
// Return true if x is in the range, false otherwise
// This method works for textual and Date ranges as well as numeric.
includes(x) { return this.from <= x && x <= this.to; },
// A generator function that makes instances of the class iterable.
// Note that it only works for numeric ranges.
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
},
// Return a string representation of the range
toString() { return "(" + this.from + "..." + this.to + ")"; }
};
// Here are example uses of a range object.
let r = range(1,3); // Create a range object
r.includes(2) // => true: 2 is in the range
r.toString() // => "(1...3)"
[...r] // => [1, 2, 3]; convert to an array via iterator
在示例 9-1 的代码中有一些值得注意的事项:
-
此代码定义了一个用于创建新 Range 对象的工厂函数
range()。 -
它使用了
range()函数的methods属性作为一个方便的存储原型对象的地方,该原型对象定义了类。将原型对象放在这里并没有什么特殊或成语化的地方。 -
range()函数在每个 Range 对象上定义了from和to属性。这些是定义每个独立 Range 对象的唯一状态的非共享、非继承属性。 -
range.methods对象使用了 ES6 的简写语法来定义方法,这就是为什么你在任何地方都看不到function关键字的原因。(查看§6.10.5 来回顾对象字面量简写方法语法。) -
原型中的一个方法具有计算名称(§6.10.2),即
Symbol.iterator,这意味着它正在为 Range 对象定义一个迭代器。这个方法的名称前缀为*,表示它是一个生成器函数而不是常规函数。迭代器和生成器在第十二章中有详细介绍。目前,要点是这个 Range 类的实例可以与for/of循环和...扩展运算符一起使用。 -
在
range.methods中定义的共享的继承方法都使用了在range()工厂函数中初始化的from和to属性。为了引用它们,它们使用this关键字来引用通过其调用的对象。这种对this的使用是任何类的方法的基本特征。
9.2 类和构造函数
示例 9-1 演示了定义 JavaScript 类的一种简单方法。然而,这并不是惯用的做法,因为它没有定义构造函数。构造函数是为新创建的对象初始化而设计的函数。构造函数使用new关键字调用,如§8.2.3 所述。使用new调用构造函数会自动创建新对象,因此构造函数本身只需要初始化该新对象的状态。构造函数调用的关键特征是构造函数的prototype属性被用作新对象的原型。§6.2.3 介绍了原型并强调,几乎所有对象都有一个原型,但只有少数对象有一个prototype属性。最后,我们可以澄清这一点:函数对象具有prototype属性。这意味着使用相同构造函数创建的所有对象都继承自同一个对象,因此它们是同一类的成员。示例 9-2 展示了如何修改示例 9-1 的 Range 类以使用构造函数而不是工厂函数。示例 9-2 展示了在不支持 ES6 class关键字的 JavaScript 版本中创建类的惯用方法。即使现在class得到了很好的支持,仍然有很多旧的 JavaScript 代码定义类的方式就像这样,你应该熟悉这种习惯用法,这样你就可以阅读旧代码,并且当你使用class关键字时,你能理解发生了什么“底层”操作。
示例 9-2。使用构造函数的 Range 类
// This is a constructor function that initializes new Range objects.
// Note that it does not create or return the object. It just initializes this.
function Range(from, to) {
// Store the start and end points (state) of this new range object.
// These are noninherited properties that are unique to this object.
this.from = from;
this.to = to;
}
// All Range objects inherit from this object.
// Note that the property name must be "prototype" for this to work.
Range.prototype = {
// Return true if x is in the range, false otherwise
// This method works for textual and Date ranges as well as numeric.
includes: function(x) { return this.from <= x && x <= this.to; },
// A generator function that makes instances of the class iterable.
// Note that it only works for numeric ranges.
[Symbol.iterator]: function*() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
},
// Return a string representation of the range
toString: function() { return "(" + this.from + "..." + this.to + ")"; }
};
// Here are example uses of this new Range class
let r = new Range(1,3); // Create a Range object; note the use of new
r.includes(2) // => true: 2 is in the range
r.toString() // => "(1...3)"
[...r] // => [1, 2, 3]; convert to an array via iterator
值得仔细比较示例 9-1 和 9-2,并注意这两种定义类的技术之间的区别。首先,注意到我们将range()工厂函数重命名为Range()当我们将其转换为构造函数时。这是一个非常常见的编码约定:构造函数在某种意义上定义了类,而类的名称(按照约定)以大写字母开头。常规函数和方法的名称以小写字母开头。
接下来,请注意在示例末尾使用new关键字调用Range()构造函数,而range()工厂函数在没有使用new的情况下调用。示例 9-1 使用常规函数调用(§8.2.1)创建新对象,而示例 9-2 使用构造函数调用(§8.2.3)。因为使用new调用Range()构造函数,所以不需要调用Object.create()或采取任何操作来创建新对象。新对象在构造函数调用之前自动创建,并且可以作为this值访问。Range()构造函数只需初始化this。构造函数甚至不必返回新创建的对象。构造函数调用会自动创建一个新对象,将构造函数作为该对象的方法调用,并返回新对象。构造函数调用与常规函数调用如此不同的事实是我们给构造函数名称以大写字母开头的另一个原因。构造函数被编写为以构造函数方式调用,并且如果以常规函数方式调用,它们通常不会正常工作。将构造函数函数与常规函数区分开的命名约定有助于程序员知道何时使用new。
示例 9-1 和 9-2 之间的另一个关键区别是原型对象的命名方式。在第一个示例中,原型是range.methods。这是一个方便且描述性强的名称,但是任意的。在第二个示例中,原型是Range.prototype,这个名称是强制的。对Range()构造函数的调用会自动使用Range.prototype作为新 Range 对象的原型。
最后,还要注意示例 9-1 和 9-2 之间没有变化的地方:两个类的范围方法的定义和调用方式是相同的。因为示例 9-2 演示了在 ES6 之前 JavaScript 版本中创建类的惯用方式,它没有在原型对象中使用 ES6 的简写方法语法,并且明确用function关键字拼写出方法。但你可以看到两个示例中方法的实现是相同的。
重要的是,要注意两个范围示例在定义构造函数或方法时都没有使用箭头函数。回想一下§8.1.3 中提到的,以这种方式定义的函数没有prototype属性,因此不能用作构造函数。此外,箭头函数从定义它们的上下文中继承this关键字,而不是根据调用它们的对象设置它,这使它们对于方法是无用的,因为方法的定义特征是它们使用this来引用被调用的实例。
幸运的是,新的 ES6 类语法不允许使用箭头函数定义方法,因此在使用该语法时不会出现这种错误。我们很快将介绍 ES6 的class关键字,但首先,还有更多关于构造函数的细节需要讨论。
9.2.1 构造函数、类标识和 instanceof
正如我们所见,原型对象对于类的标识是至关重要的:两个对象只有在它们继承自相同的原型对象时才是同一类的实例。初始化新对象状态的构造函数并不是基本的:两个构造函数可能具有指向相同原型对象的prototype属性。然后,这两个构造函数都可以用于创建同一类的实例。
尽管构造函数不像原型那样基础,但构造函数作为类的公共面孔。最明显的是,构造函数的名称通常被采用为类的名称。例如,我们说 Range() 构造函数创建 Range 对象。然而,更根本的是,构造函数在测试对象是否属于类时作为 instanceof 运算符的右操作数。如果我们有一个对象 r 并想知道它是否是 Range 对象,我们可以写:
r instanceof Range // => true: r inherits from Range.prototype
instanceof 运算符在 §4.9.4 中有描述。左操作数应该是正在测试的对象,右操作数应该是命名类的构造函数。表达式 o instanceof C 在 o 继承自 C.prototype 时求值为 true。继承不必是直接的:如果 o 继承自继承自继承自 C.prototype 的对象,表达式仍将求值为 true。
从技术上讲,在前面的代码示例中,instanceof 运算符并不是在检查 r 是否实际由 Range 构造函数初始化。相反,它是在检查 r 是否继承自 Range.prototype。如果我们定义一个函数 Strange() 并将其原型设置为与 Range.prototype 相同,那么使用 new Strange() 创建的对象在 instanceof 方面将被视为 Range 对象(但实际上它们不会像 Range 对象一样工作,因为它们的 from 和 to 属性尚未初始化):
function Strange() {}
Strange.prototype = Range.prototype;
new Strange() instanceof Range // => true
即使 instanceof 无法实际验证构造函数的使用,但它仍将构造函数作为其右操作数,因为构造函数是类的公共标识。
如果您想要测试对象的原型链以查找特定原型而不想使用构造函数作为中介,可以使用 isPrototypeOf() 方法。例如,在 示例 9-1 中,我们定义了一个没有构造函数的类,因此无法使用该类的 instanceof。然而,我们可以使用以下代码测试对象 r 是否是该无构造函数类的成员:
range.methods.isPrototypeOf(r); // range.methods is the prototype object.
9.2.2 构造函数属性
在 示例 9-2 中,我们将 Range.prototype 设置为一个包含我们类方法的新对象。虽然将这些方法表达为单个对象字面量的属性很方便,但实际上并不需要创建一个新对象。任何常规的 JavaScript 函数(不包括箭头函数、生成器函数和异步函数)都可以用作构造函数,并且构造函数调用需要一个 prototype 属性。因此,每个常规的 JavaScript 函数¹ 自动具有一个 prototype 属性。该属性的值是一个具有单个、不可枚举的 constructor 属性的对象。constructor 属性的值是函数对象:
let F = function() {}; // This is a function object.
let p = F.prototype; // This is the prototype object associated with F.
let c = p.constructor; // This is the function associated with the prototype.
c === F // => true: F.prototype.constructor === F for any F
具有预定义原型对象及其 constructor 属性的存在意味着对象通常继承一个指向其构造函数的 constructor 属性。由于构造函数作为类的公共标识,这个构造函数属性给出了对象的类:
let o = new F(); // Create an object o of class F
o.constructor === F // => true: the constructor property specifies the class
图 9-1 展示了构造函数、其原型对象、原型指向构造函数的反向引用以及使用构造函数创建的实例之间的关系。

图 9-1. 一个构造函数、其原型和实例
注意图 9-1 使用我们的Range()构造函数作为示例。实际上,然而,在示例 9-2 中定义的 Range 类覆盖了预定义的Range.prototype对象为自己的对象。并且它定义的新原型对象没有constructor属性。因此,如定义的 Range 类的实例没有constructor属性。我们可以通过显式向原型添加构造函数来解决这个问题:
Range.prototype = {
constructor: Range, // Explicitly set the constructor back-reference
/* method definitions go here */
};
另一种在旧版 JavaScript 代码中常见的技术是使用预定义的原型对象及其具有constructor属性,并使用以下代码逐个添加方法:
// Extend the predefined Range.prototype object so we don't overwrite
// the automatically created Range.prototype.constructor property.
Range.prototype.includes = function(x) {
return this.from <= x && x <= this.to;
};
Range.prototype.toString = function() {
return "(" + this.from + "..." + this.to + ")";
};
9.3 使用class关键字的类
类自从语言的第一个版本以来就一直是 JavaScript 的一部分,但在 ES6 中,它们终于得到了自己的语法,引入了class关键字。示例 9-3 展示了使用这种新语法编写的 Range 类的样子。
示例 9-3. 使用class重写的 Range 类
class Range {
constructor(from, to) {
// Store the start and end points (state) of this new range object.
// These are noninherited properties that are unique to this object.
this.from = from;
this.to = to;
}
// Return true if x is in the range, false otherwise
// This method works for textual and Date ranges as well as numeric.
includes(x) { return this.from <= x && x <= this.to; }
// A generator function that makes instances of the class iterable.
// Note that it only works for numeric ranges.
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}
// Return a string representation of the range
toString() { return `(${this.from}...${this.to})`; }
}
// Here are example uses of this new Range class
let r = new Range(1,3); // Create a Range object
r.includes(2) // => true: 2 is in the range
r.toString() // => "(1...3)"
[...r] // => [1, 2, 3]; convert to an array via iterator
重要的是要理解,在示例 9-2 和 9-3 中定义的类的工作方式完全相同。引入class关键字到语言中并不改变 JavaScript 基于原型的类的基本性质。尽管示例 9-3 使用了class关键字,但生成的 Range 对象是一个构造函数,就像在示例 9-2 中定义的版本一样。新的class语法干净方便,但最好将其视为对在示例 9-2 中显示的更基本的类定义机制的“语法糖”。
注意示例 9-3 中类语法的以下几点:
-
使用
class关键字声明类,后面跟着类名和用大括号括起来的类体。 -
类体包括使用对象字面量方法简写的方法定义(我们在示例 9-1 中也使用了),其中省略了
function关键字。然而,与对象字面量不同,没有逗号用于将方法彼此分隔开。 (尽管类体在表面上与对象字面量相似,但它们并不是同一回事。特别是,它们不支持使用名称/值对定义属性。) -
关键字
constructor用于为类定义构造函数。但实际上定义的函数并不真正命名为constructor。class声明语句定义了一个新变量Range,并将这个特殊的constructor函数的值赋给该变量。 -
如果你的类不需要进行任何初始化,你可以省略
constructor关键字及其主体,将为你隐式创建一个空的构造函数。
如果你想定义一个继承自另一个类的类,你可以使用extends关键字和class关键字:
// A Span is like a Range, but instead of initializing it with
// a start and an end, we initialize it with a start and a length
class Span extends Range {
constructor(start, length) {
if (length >= 0) {
super(start, start + length);
} else {
super(start + length, start);
}
}
}
创建子类是一个独立的主题。我们将在§9.5 中返回并解释这里显示的extends和super关键字。
类声明与函数声明一样,既有语句形式又有表达式形式。就像我们可以写:
let square = function(x) { return x * x; };
square(3) // => 9
我们也可以写:
let Square = class { constructor(x) { this.area = x * x; } };
new Square(3).area // => 9
与函数定义表达式一样,类定义表达式可以包括一个可选的类名。如果提供了这样的名称,那个名称仅在类体内部定义。
尽管函数表达式非常常见(特别是使用箭头函数简写),在 JavaScript 编程中,类定义表达式不是你经常使用的东西,除非你发现自己正在编写一个以类作为参数并返回子类的函数。
我们将通过提及一些重要的事项来结束对class关键字的介绍,这些事项从class语法中并不明显:
-
class声明体内的所有代码都隐式地处于严格模式中(§5.6.3),即使没有出现"use strict"指令。这意味着,例如,你不能在类体内使用八进制整数字面量或with语句,并且如果你忘记在使用之前声明一个变量,你更有可能得到语法错误。 -
与函数声明不同,类声明不会“被提升”。回想一下§8.1.1 中提到的函数定义行为,就好像它们已经被移动到了包含文件或包含函数的顶部,这意味着你可以在实际函数定义之前的代码中调用函数。尽管类声明在某些方面类似于函数声明,但它们不共享这种提升行为:你不能在声明类之前实例化它。
9.3.1 静态方法
你可以通过在class体中的方法声明前加上static关键字来定义一个静态方法。静态方法被定义为构造函数的属性,而不是原型对象的属性。
例如,假设我们在示例 9-3 中添加了以下代码:
static parse(s) {
let matches = s.match(/^\((\d+)\.\.\.(\d+)\)$/);
if (!matches) {
throw new TypeError(`Cannot parse Range from "${s}".`)
}
return new Range(parseInt(matches[1]), parseInt(matches[2]));
}
这段代码定义的方法是Range.parse(),而不是Range.prototype.parse(),你必须通过构造函数调用它,而不是通过实例调用:
let r = Range.parse('(1...10)'); // Returns a new Range object
r.parse('(1...10)'); // TypeError: r.parse is not a function
有时你会看到静态方法被称为类方法,因为它们是使用类/构造函数的名称调用的。当使用这个术语时,是为了将类方法与在类的实例上调用的常规实例方法进行对比。因为静态方法是在构造函数上调用而不是在任何特定实例上调用,所以在静态方法中几乎不可能使用this关键字。
我们将在示例 9-4 中看到静态方法的示例。
9.3.2 获取器、设置器和其他方法形式
在class体内,你可以像在对象字面量中一样定义获取器和设置器方法(§6.10.6),唯一的区别是在类体中,你不在获取器或设置器后面加逗号。示例 9-4 包括了一个类中获取器方法的实际示例。
一般来说,在对象字面量中允许的所有简写方法定义语法在类体中也是允许的。这包括生成器方法(用*标记)和方法的名称是方括号中表达式的值的方法。事实上,你已经在示例 9-3 中看到了一个具有计算名称的生成器方法,使得 Range 类可迭代:
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}
9.3.3 公共、私有和静态字段
在这里讨论使用class关键字定义的类时,我们只描述了类体内的方法定义。ES6 标准只允许创建方法(包括获取器、设置器和生成器)和静态方法;它不包括定义字段的语法。如果你想在类实例上定义一个字段(这只是面向对象的“属性”同义词),你必须在构造函数中或在其中一个方法中进行定义。如果你想为一个类定义一个静态字段,你必须在类体之外,在类定义之后进行定义。示例 9-4 包括了这两种字段的示例。
然而,标准化正在进行中,允许扩展类语法来定义实例和静态字段,包括公共和私有形式。截至 2020 年初,本节其余部分展示的代码尚不是标准 JavaScript,但已经在 Chrome 中得到支持,并在 Firefox 中部分支持(仅支持公共实例字段)。公共实例字段的语法已经被使用 React 框架和 Babel 转译器的 JavaScript 程序员广泛使用。
假设你正在编写一个像这样的类,其中包含一个初始化三个字段的构造函数:
class Buffer {
constructor() {
this.size = 0;
this.capacity = 4096;
this.buffer = new Uint8Array(this.capacity);
}
}
使用可能会被标准化的新实例字段语法,你可以这样写:
class Buffer {
size = 0;
capacity = 4096;
buffer = new Uint8Array(this.capacity);
}
字段初始化代码已经从构造函数中移出,现在直接出现在类体中。(当然,该代码仍然作为构造函数的一部分运行。如果你没有定义构造函数,那么字段将作为隐式创建的构造函数的一部分进行初始化。)出现在赋值左侧的this.前缀已经消失,但请注意,即使在初始化赋值的右侧,你仍然必须使用this.来引用这些字段。以这种方式初始化实例字段的优势在于,这种语法允许(但不要求)你将初始化器放在类定义的顶部,清楚地告诉读者每个实例的状态将由哪些字段保存。你可以通过只写字段名称后跟一个分号来声明没有初始化器的字段。如果这样做,字段的初始值将为undefined。对于所有类字段,始终明确指定初始值是更好的风格。
在添加此字段语法之前,类体看起来很像使用快捷方法语法的对象文字,只是逗号已被移除。这种带有等号和分号而不是冒号和逗号的字段语法清楚地表明类体与对象文字完全不同。
与寻求标准化这些实例字段的提案相同,还定义了私有实例字段。如果你使用前面示例中显示的实例字段初始化语法来定义一个以#开头的字段(这在 JavaScript 标识符中通常不是合法字符),那么该字段将可以在类体内(带有#前缀)使用,但对于类体外的任何代码来说是不可见和不可访问的(因此是不可变的)。如果对于前面的假设的 Buffer 类,你希望确保类的用户不能无意中修改实例的size字段,那么你可以使用一个私有的#size字段,然后定义一个获取器函数来提供只读访问权限:
class Buffer {
#size = 0;
get size() { return this.#size; }
}
请注意,私有字段必须在使用之前使用这种新字段语法进行声明。除非在类体中直接包含字段的“声明”,否则不能在类的构造函数中只写this.#size = 0;。
最后,一个相关的提案旨在标准化static关键字用于字段。如果在公共或私有字段声明之前添加static,那么这些字段将作为构造函数的属性而不是实例的属性创建。考虑我们定义的静态Range.parse()方法。它包含一个可能很好地分解为自己的静态字段的相当复杂的正则表达式。使用提议的新静态字段语法,我们可以这样做:
static integerRangePattern = /^\((\d+)\.\.\.(\d+)\)$/;
static parse(s) {
let matches = s.match(Range.integerRangePattern);
if (!matches) {
throw new TypeError(`Cannot parse Range from "${s}".`)
}
return new Range(parseInt(matches[1]), matches[2]);
}
如果我们希望这个静态字段只能在类内部访问,我们可以使用类似#pattern的私有名称。
9.3.4 示例:复数类
示例 9-4 定义了一个表示复数的类。这个类相对简单,但包括实例方法(包括获取器)、静态方法、实例字段和静态字段。它包含了一些被注释掉的代码,演示了我们如何使用尚未标准化的语法来在类体内定义实例字段和静态字段。
示例 9-4. Complex.js:一个复数类
/**
* Instances of this Complex class represent complex numbers.
* Recall that a complex number is the sum of a real number and an
* imaginary number and that the imaginary number i is the square root of -1.
*/
class Complex {
// Once class field declarations are standardized, we could declare
// private fields to hold the real and imaginary parts of a complex number
// here, with code like this:
//
// #r = 0;
// #i = 0;
// This constructor function defines the instance fields r and i on every
// instance it creates. These fields hold the real and imaginary parts of
// the complex number: they are the state of the object.
constructor(real, imaginary) {
this.r = real; // This field holds the real part of the number.
this.i = imaginary; // This field holds the imaginary part.
}
// Here are two instance methods for addition and multiplication
// of complex numbers. If c and d are instances of this class, we
// might write c.plus(d) or d.times(c)
plus(that) {
return new Complex(this.r + that.r, this.i + that.i);
}
times(that) {
return new Complex(this.r * that.r - this.i * that.i,
this.r * that.i + this.i * that.r);
}
// And here are static variants of the complex arithmetic methods.
// We could write Complex.sum(c,d) and Complex.product(c,d)
static sum(c, d) { return c.plus(d); }
static product(c, d) { return c.times(d); }
// These are some instance methods that are defined as getters
// so they're used like fields. The real and imaginary getters would
// be useful if we were using private fields this.#r and this.#i
get real() { return this.r; }
get imaginary() { return this.i; }
get magnitude() { return Math.hypot(this.r, this.i); }
// Classes should almost always have a toString() method
toString() { return `{${this.r},${this.i}}`; }
// It is often useful to define a method for testing whether
// two instances of your class represent the same value
equals(that) {
return that instanceof Complex &&
this.r === that.r &&
this.i === that.i;
}
// Once static fields are supported inside class bodies, we could
// define a useful Complex.ZERO constant like this:
// static ZERO = new Complex(0,0);
}
// Here are some class fields that hold useful predefined complex numbers.
Complex.ZERO = new Complex(0,0);
Complex.ONE = new Complex(1,0);
Complex.I = new Complex(0,1);
使用示例 9-4 中定义的 Complex 类,我们可以使用构造函数、实例字段、实例方法、类字段和类方法的代码如下:
let c = new Complex(2, 3); // Create a new object with the constructor
let d = new Complex(c.i, c.r); // Use instance fields of c
c.plus(d).toString() // => "{5,5}"; use instance methods
c.magnitude // => Math.hypot(2,3); use a getter function
Complex.product(c, d) // => new Complex(0, 13); a static method
Complex.ZERO.toString() // => "{0,0}"; a static property
9.4 为现有类添加方法
JavaScript 的基于原型的继承机制是动态的:一个对象从其原型继承属性,即使原型的属性在对象创建后发生变化。这意味着我们可以通过简单地向其原型对象添加新方法来增强 JavaScript 类。
例如,这里是为计算复共轭添加一个方法到示例 9-4 的 Complex 类的代码:
// Return a complex number that is the complex conjugate of this one.
Complex.prototype.conj = function() { return new Complex(this.r, -this.i); };
JavaScript 内置类的原型对象也是开放的,这意味着我们可以向数字、字符串、数组、函数等添加方法。这对于在语言的旧版本中实现新的语言特性很有用:
// If the new String method startsWith() is not already defined...
if (!String.prototype.startsWith) {
// ...then define it like this using the older indexOf() method.
String.prototype.startsWith = function(s) {
return this.indexOf(s) === 0;
};
}
这里是另一个例子:
// Invoke the function f this many times, passing the iteration number
// For example, to print "hello" 3 times:
// let n = 3;
// n.times(i => { console.log(`hello ${i}`); });
Number.prototype.times = function(f, context) {
let n = this.valueOf();
for(let i = 0; i < n; i++) f.call(context, i);
};
像这样向内置类型的原型添加方法通常被认为是一个坏主意,因为如果 JavaScript 的新版本定义了同名方法,将会导致混乱和兼容性问题。甚至可以向Object.prototype添加方法,使其对所有对象可用。但这绝不是一个好主意,因为添加到Object.prototype的属性对for/in循环可见(尽管您可以通过使用Object.defineProperty()[§14.1]使新属性不可枚举来避免这种情况)。
9.5 子类
在面向对象编程中,一个类 B 可以扩展或子类化另一个类 A。我们说 A 是超类,B 是子类。B 的实例继承 A 的方法。类 B 可以定义自己的方法,其中一些可能会覆盖类 A 定义的同名方法。如果 B 的方法覆盖了 A 的方法,那么 B 中的覆盖方法通常需要调用 A 中被覆盖的方法。同样,子类构造函数B()通常必须调用超类构造函数A(),以确保实例完全初始化。
本节首先展示了如何以旧的、ES6 之前的方式定义子类,然后迅速转向演示使用class和extends关键字以及使用super关键字调用超类构造方法的子类化。接下来是一个关于避免子类化,依靠对象组合而不是继承的子节。本节以一个定义了一系列 Set 类的扩展示例结束,并演示了如何使用抽象类来将接口与实现分离。
9.5.1 子类和原型
假设我们想要定义一个 Span 子类,继承自示例 9-2 的 Range 类。这个子类将像 Range 一样工作,但不是用起始和结束来初始化,而是指定一个起始和一个距离,或者跨度。Span 类的一个实例也是 Range 超类的一个实例。跨度实例从Span.prototype继承了一个定制的toString()方法,但为了成为 Range 的子类,它还必须从Range.prototype继承方法(如includes())。
示例 9-5. Span.js:Range 的一个简单子类
// This is the constructor function for our subclass
function Span(start, span) {
if (span >= 0) {
this.from = start;
this.to = start + span;
} else {
this.to = start;
this.from = start + span;
}
}
// Ensure that the Span prototype inherits from the Range prototype
Span.prototype = Object.create(Range.prototype);
// We don't want to inherit Range.prototype.constructor, so we
// define our own constructor property.
Span.prototype.constructor = Span;
// By defining its own toString() method, Span overrides the
// toString() method that it would otherwise inherit from Range.
Span.prototype.toString = function() {
return `(${this.from}... +${this.to - this.from})`;
};
为了使 Span 成为 Range 的一个子类,我们需要让Span.prototype从Range.prototype继承。在前面示例中的关键代码行是这一行,如果这对你有意义,你就理解了 JavaScript 中子类是如何工作的:
Span.prototype = Object.create(Range.prototype);
使用Span()构造函数创建的对象将从Span.prototype对象继承。但我们创建该对象是为了从Range.prototype继承,因此 Span 对象将同时从Span.prototype和Range.prototype继承。
你可能注意到我们的Span()构造函数设置了与Range()构造函数相同的from和to属性,因此不需要调用Range()构造函数来初始化新对象。类似地,Span 的toString()方法完全重新实现了字符串转换,而不需要调用 Range 的toString()版本。这使 Span 成为一个特殊情况,我们只能在了解超类的实现细节时才能这样做。一个健壮的子类化机制需要允许类调用其超类的方法和构造函数,但在 ES6 之前,JavaScript 没有简单的方法来做这些事情。
幸运的是,ES6 通过super关键字作为class语法的一部分解决了这些问题。下一节将演示它是如何工作的。
9.5.2 使用 extends 和 super 创建子类
在 ES6 及更高版本中,你可以通过在类声明中添加extends子句来简单地创建一个超类,甚至可以对内置类这样做:
// A trivial Array subclass that adds getters for the first and last elements.
class EZArray extends Array {
get first() { return this[0]; }
get last() { return this[this.length-1]; }
}
let a = new EZArray();
a instanceof EZArray // => true: a is subclass instance
a instanceof Array // => true: a is also a superclass instance.
a.push(1,2,3,4); // a.length == 4; we can use inherited methods
a.pop() // => 4: another inherited method
a.first // => 1: first getter defined by subclass
a.last // => 3: last getter defined by subclass
a[1] // => 2: regular array access syntax still works.
Array.isArray(a) // => true: subclass instance really is an array
EZArray.isArray(a) // => true: subclass inherits static methods, too!
这个 EZArray 子类定义了两个简单的 getter 方法。EZArray 的实例表现得像普通数组,我们可以使用继承的方法和属性,比如push()、pop()和length。但我们也可以使用子类中定义的first和last getter。不仅实例方法像pop()被继承了,静态方法像Array.isArray也被继承了。这是 ES6 类语法启用的一个新特性:EZArray()是一个函数,但它继承自Array():
// EZArray inherits instance methods because EZArray.prototype
// inherits from Array.prototype
Array.prototype.isPrototypeOf(EZArray.prototype) // => true
// And EZArray inherits static methods and properties because
// EZArray inherits from Array. This is a special feature of the
// extends keyword and is not possible before ES6.
Array.isPrototypeOf(EZArray) // => true
我们的 EZArray 子类过于简单,无法提供很多指导性。示例 9-6 是一个更加完整的示例。它定义了一个 TypedMap 的子类,继承自内置的 Map 类,并添加了类型检查以确保地图的键和值是指定类型(根据typeof)。重要的是,这个示例演示了使用super关键字来调用超类的构造函数和方法。
示例 9-6. TypedMap.js:检查键和值类型的 Map 子类
class TypedMap extends Map {
constructor(keyType, valueType, entries) {
// If entries are specified, check their types
if (entries) {
for(let [k, v] of entries) {
if (typeof k !== keyType || typeof v !== valueType) {
throw new TypeError(`Wrong type for entry [${k}, ${v}]`);
}
}
}
// Initialize the superclass with the (type-checked) initial entries
super(entries);
// And then initialize this subclass by storing the types
this.keyType = keyType;
this.valueType = valueType;
}
// Now redefine the set() method to add type checking for any
// new entries added to the map.
set(key, value) {
// Throw an error if the key or value are of the wrong type
if (this.keyType && typeof key !== this.keyType) {
throw new TypeError(`${key} is not of type ${this.keyType}`);
}
if (this.valueType && typeof value !== this.valueType) {
throw new TypeError(`${value} is not of type ${this.valueType}`);
}
// If the types are correct, we invoke the superclass's version of
// the set() method, to actually add the entry to the map. And we
// return whatever the superclass method returns.
return super.set(key, value);
}
}
TypedMap()构造函数的前两个参数是期望的键和值类型。这些应该是字符串,比如“number”和“boolean”,这是typeof运算符返回的。你还可以指定第三个参数:一个包含[key,value]数组的数组(或任何可迭代对象),指定地图中的初始条目。如果指定了任何初始条目,构造函数首先验证它们的类型是否正确。接下来,构造函数使用super关键字调用超类构造函数,就像它是一个函数名一样。Map()构造函数接受一个可选参数:一个包含[key,value]数组的可迭代对象。因此,TypedMap()构造函数的可选第三个参数是Map()构造函数的可选第一个参数,我们使用super(entries)将其传递给超类构造函数。
在调用超类构造函数初始化超类状态后,TypedMap()构造函数接下来通过设置this.keyType和this.valueType来初始化自己的子类状态。它需要设置这些属性以便在set()方法中再次使用它们。
在构造函数中使用super()时,有一些重要的规则你需要知道:
-
如果你用
extends关键字定义一个类,那么你的类的构造函数必须使用super()来调用超类构造函数。 -
如果你在子类中没有定义构造函数,系统会自动为你定义一个。这个隐式定义的构造函数简单地接受传递给它的任何值,并将这些值传递给
super()。 -
在调用
super()之前,你不能在构造函数中使用this关键字。这强制了一个规则,即超类在子类之前初始化。 -
特殊表达式
new.target在没有使用new关键字调用的函数中是未定义的。然而,在构造函数中,new.target是对被调用的构造函数的引用。当子类构造函数被调用并使用super()来调用超类构造函数时,那个超类构造函数将会把子类构造函数视为new.target的值。一个设计良好的超类不应该知道自己是否被子类化,但在日志消息中使用new.target.name可能会很有用。
在构造函数之后,示例 9-6 的下一部分是一个名为set()的方法。Map 超类定义了一个名为set()的方法来向地图添加新条目。我们说这个 TypedMap 中的set()方法覆盖了其超类的set()方法。这个简单的 TypedMap 子类对于向地图添加新条目一无所知,但它知道如何检查类型,所以首先进行类型检查,验证要添加到地图中的键和值是否具有正确的类型,如果不是则抛出错误。这个set()方法没有任何方法将键和值添加到地图本身,但这就是超类set()方法的作用。因此,我们再次使用super关键字来调用超类的方法版本。在这个上下文中,super的工作方式很像this关键字:它引用当前对象但允许访问在超类中定义的重写方法。
在构造函数中,你必须在访问this并自己初始化新对象之前调用超类构造函数。当你重写一个方法时,没有这样的规则。重写超类方法的方法不需要调用超类方法。如果它确实使用super来调用被重写的方法(或超类中的任何方法),它可以在重写方法的开始、中间或结尾进行调用。
最后,在我们离开 TypedMap 示例之前,值得注意的是,这个类是使用私有字段的理想候选。目前这个类的写法,用户可以更改keyType或valueType属性以规避类型检查。一旦支持私有字段,我们可以将这些属性更改为#keyType和#valueType,这样它们就无法从外部更改。
9.5.3 代理而非继承
extends关键字使创建子类变得容易。但这并不意味着你应该创建大量子类。如果你想编写一个共享某个其他类行为的类,你可以尝试通过创建子类来继承该行为。但通常更容易和更灵活的方法是通过让你的类创建另一个类的实例并根据需要简单地委托给该实例来获得所需的行为。你创建一个新类不是通过子类化,而是通过包装或“组合”其他类。这种委托方法通常被称为“组合”,并且面向对象编程的一个经常引用的格言是应该“优先选择组合而非继承”。²
例如,假设我们想要一个直方图类,其行为类似于 JavaScript 的 Set 类,但不仅仅是跟踪值是否已添加到集合中,而是维护值已添加的次数。因为这个直方图类的 API 类似于 Set,我们可以考虑继承 Set 并添加一个count()方法。另一方面,一旦我们开始考虑如何实现这个count()方法,我们可能会意识到直方图类更像是一个 Map 而不是一个 Set,因为它需要维护值和它们被添加的次数之间的映射关系。因此,我们可以创建一个定义了类似 Set API 的类,但通过委托给内部 Map 对象来实现这些方法。示例 9-7 展示了我们如何做到这一点。
示例 9-7. Histogram.js:使用委托实现的类似 Set 的类
/**
* A Set-like class that keeps track of how many times a value has
* been added. Call add() and remove() like you would for a Set, and
* call count() to find out how many times a given value has been added.
* The default iterator yields the values that have been added at least
* once. Use entries() if you want to iterate [value, count] pairs.
*/
class Histogram {
// To initialize, we just create a Map object to delegate to
constructor() { this.map = new Map(); }
// For any given key, the count is the value in the Map, or zero
// if the key does not appear in the Map.
count(key) { return this.map.get(key) || 0; }
// The Set-like method has() returns true if the count is non-zero
has(key) { return this.count(key) > 0; }
// The size of the histogram is just the number of entries in the Map.
get size() { return this.map.size; }
// To add a key, just increment its count in the Map.
add(key) { this.map.set(key, this.count(key) + 1); }
// Deleting a key is a little trickier because we have to delete
// the key from the Map if the count goes back down to zero.
delete(key) {
let count = this.count(key);
if (count === 1) {
this.map.delete(key);
} else if (count > 1) {
this.map.set(key, count - 1);
}
}
// Iterating a Histogram just returns the keys stored in it
[Symbol.iterator]() { return this.map.keys(); }
// These other iterator methods just delegate to the Map object
keys() { return this.map.keys(); }
values() { return this.map.values(); }
entries() { return this.map.entries(); }
}
示例 9-7 中的Histogram()构造函数只是创建了一个 Map 对象。大多数方法都只是简单地委托给地图的一个方法,使得实现非常简单。因为我们使用了委托而不是继承,一个 Histogram 对象不是 Set 或 Map 的实例。但 Histogram 实现了许多常用的 Set 方法,在像 JavaScript 这样的无类型语言中,这通常已经足够了:正式的继承关系有时很好,但通常是可选的。
9.5.4 类层次结构和抽象类
示例 9-6 演示了我们如何继承 Map。示例 9-7 演示了我们如何委托给一个 Map 对象而不实际继承任何东西。使用 JavaScript 类来封装数据和模块化代码通常是一个很好的技术,你可能会经常使用class关键字。但你可能会发现你更喜欢组合而不是继承,并且很少需要使用extends(除非你使用要求你扩展其基类的库或框架)。
然而,在某些情况下,多级子类化是合适的,我们将以一个扩展示例结束本章,该示例演示了代表不同类型集合的类的层次结构。(示例 9-8 中定义的集合类与 JavaScript 内置的 Set 类类似,但不完全兼容。)
示例 9-8 定义了许多子类,但它还演示了如何定义抽象类——不包括完整实现的类——作为一组相关子类的共同超类。抽象超类可以定义所有子类继承和共享的部分实现。然后,子类只需要通过实现超类定义但未实现的抽象方法来定义自己的独特行为。请注意,JavaScript 没有任何正式定义抽象方法或抽象类的规定;我在这里仅仅是使用这个名称来表示未实现的方法和未完全实现的类。
示例 9-8 有很好的注释,可以独立运行。我鼓励你将其作为本章关于类的顶尖示例来阅读。在示例 9-8 中的最终类使用了&、|和~运算符进行大量的位操作,你可以在§4.8.3 中复习。
示例 9-8. Sets.js:抽象和具体集合类的层次结构
/**
* The AbstractSet class defines a single abstract method, has().
*/
class AbstractSet {
// Throw an error here so that subclasses are forced
// to define their own working version of this method.
has(x) { throw new Error("Abstract method"); }
}
/**
* NotSet is a concrete subclass of AbstractSet.
* The members of this set are all values that are not members of some
* other set. Because it is defined in terms of another set it is not
* writable, and because it has infinite members, it is not enumerable.
* All we can do with it is test for membership and convert it to a
* string using mathematical notation.
*/
class NotSet extends AbstractSet {
constructor(set) {
super();
this.set = set;
}
// Our implementation of the abstract method we inherited
has(x) { return !this.set.has(x); }
// And we also override this Object method
toString() { return `{ x| x ∉ ${this.set.toString()} }`; }
}
/**
* Range set is a concrete subclass of AbstractSet. Its members are
* all values that are between the from and to bounds, inclusive.
* Since its members can be floating point numbers, it is not
* enumerable and does not have a meaningful size.
*/
class RangeSet extends AbstractSet {
constructor(from, to) {
super();
this.from = from;
this.to = to;
}
has(x) { return x >= this.from && x <= this.to; }
toString() { return `{ x| ${this.from} ≤ x ≤ ${this.to} }`; }
}
/*
* AbstractEnumerableSet is an abstract subclass of AbstractSet. It defines
* an abstract getter that returns the size of the set and also defines an
* abstract iterator. And it then implements concrete isEmpty(), toString(),
* and equals() methods on top of those. Subclasses that implement the
* iterator, the size getter, and the has() method get these concrete
* methods for free.
*/
class AbstractEnumerableSet extends AbstractSet {
get size() { throw new Error("Abstract method"); }
[Symbol.iterator]() { throw new Error("Abstract method"); }
isEmpty() { return this.size === 0; }
toString() { return `{${Array.from(this).join(", ")}}`; }
equals(set) {
// If the other set is not also Enumerable, it isn't equal to this one
if (!(set instanceof AbstractEnumerableSet)) return false;
// If they don't have the same size, they're not equal
if (this.size !== set.size) return false;
// Loop through the elements of this set
for(let element of this) {
// If an element isn't in the other set, they aren't equal
if (!set.has(element)) return false;
}
// The elements matched, so the sets are equal
return true;
}
}
/*
* SingletonSet is a concrete subclass of AbstractEnumerableSet.
* A singleton set is a read-only set with a single member.
*/
class SingletonSet extends AbstractEnumerableSet {
constructor(member) {
super();
this.member = member;
}
// We implement these three methods, and inherit isEmpty, equals()
// and toString() implementations based on these methods.
has(x) { return x === this.member; }
get size() { return 1; }
*[Symbol.iterator]() { yield this.member; }
}
/*
* AbstractWritableSet is an abstract subclass of AbstractEnumerableSet.
* It defines the abstract methods insert() and remove() that insert and
* remove individual elements from the set, and then implements concrete
* add(), subtract(), and intersect() methods on top of those. Note that
* our API diverges here from the standard JavaScript Set class.
*/
class AbstractWritableSet extends AbstractEnumerableSet {
insert(x) { throw new Error("Abstract method"); }
remove(x) { throw new Error("Abstract method"); }
add(set) {
for(let element of set) {
this.insert(element);
}
}
subtract(set) {
for(let element of set) {
this.remove(element);
}
}
intersect(set) {
for(let element of this) {
if (!set.has(element)) {
this.remove(element);
}
}
}
}
/**
* A BitSet is a concrete subclass of AbstractWritableSet with a
* very efficient fixed-size set implementation for sets whose
* elements are non-negative integers less than some maximum size.
*/
class BitSet extends AbstractWritableSet {
constructor(max) {
super();
this.max = max; // The maximum integer we can store.
this.n = 0; // How many integers are in the set
this.numBytes = Math.floor(max / 8) + 1; // How many bytes we need
this.data = new Uint8Array(this.numBytes); // The bytes
}
// Internal method to check if a value is a legal member of this set
_valid(x) { return Number.isInteger(x) && x >= 0 && x <= this.max; }
// Tests whether the specified bit of the specified byte of our
// data array is set or not. Returns true or false.
_has(byte, bit) { return (this.data[byte] & BitSet.bits[bit]) !== 0; }
// Is the value x in this BitSet?
has(x) {
if (this._valid(x)) {
let byte = Math.floor(x / 8);
let bit = x % 8;
return this._has(byte, bit);
} else {
return false;
}
}
// Insert the value x into the BitSet
insert(x) {
if (this._valid(x)) { // If the value is valid
let byte = Math.floor(x / 8); // convert to byte and bit
let bit = x % 8;
if (!this._has(byte, bit)) { // If that bit is not set yet
this.data[byte] |= BitSet.bits[bit]; // then set it
this.n++; // and increment set size
}
} else {
throw new TypeError("Invalid set element: " + x );
}
}
remove(x) {
if (this._valid(x)) { // If the value is valid
let byte = Math.floor(x / 8); // compute the byte and bit
let bit = x % 8;
if (this._has(byte, bit)) { // If that bit is already set
this.data[byte] &= BitSet.masks[bit]; // then unset it
this.n--; // and decrement size
}
} else {
throw new TypeError("Invalid set element: " + x );
}
}
// A getter to return the size of the set
get size() { return this.n; }
// Iterate the set by just checking each bit in turn.
// (We could be a lot more clever and optimize this substantially)
*[Symbol.iterator]() {
for(let i = 0; i <= this.max; i++) {
if (this.has(i)) {
yield i;
}
}
}
}
// Some pre-computed values used by the has(), insert() and remove() methods
BitSet.bits = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
BitSet.masks = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);
9.6 总结
本章已经解释了 JavaScript 类的关键特性:
-
同一类的对象从相同的原型对象继承属性。原型对象是 JavaScript 类的关键特性,可以仅使用
Object.create()方法定义类。 -
在 ES6 之前,类通常是通过首先定义构造函数来定义的。使用
function关键字创建的函数具有一个prototype属性,该属性的值是一个对象,当使用new作为构造函数调用函数时,该对象被用作所有创建的对象的原型。通过初始化这个原型对象,您可以定义类的共享方法。虽然原型对象是类的关键特征,但构造函数是类的公共标识。 -
ES6 引入了
class关键字,使得定义类更容易,但在底层,构造函数和原型机制仍然保持不变。 -
子类是在类声明中使用
extends关键字定义的。 -
子类可以使用
super关键字调用其父类的构造函数或重写的方法。
¹ 除了 ES5 的Function.bind()方法返回的函数。绑定函数没有自己的原型属性,但如果作为构造函数调用它们,则它们使用基础函数的原型。
² 例如,参见 Erich Gamma 等人的设计模式(Addison-Wesley Professional)或 Joshua Bloch 的Effective Java(Addison-Wesley Professional)。
第十章:模块
模块化编程的目标是允许从不同作者和来源的代码模块组装大型程序,并且所有这些代码在各个模块作者未预料到的代码存在的情况下仍能正确运行。 从实际角度来看,模块化主要是关于封装或隐藏私有实现细节,并保持全局命名空间整洁,以便模块不会意外修改其他模块定义的变量、函数和类。
直到最近,JavaScript 没有内置模块支持,而在大型代码库上工作的程序员尽力利用类、对象和闭包提供的弱模块化。基于闭包的模块化,结合代码捆绑工具的支持,形成了一种基于require()函数的实用模块化形式,这被 Node 所采用。 基于require()的模块是 Node 编程环境的基本组成部分,但从未被正式纳入 JavaScript 语言的一部分。 相反,ES6 使用import和export关键字定义模块。 尽管import和export多年来一直是语言的一部分,但它们最近才被 Web 浏览器和 Node 实现。 作为一个实际问题,JavaScript 模块化仍然依赖于代码捆绑工具。
接下来的章节涵盖:
-
使用类、对象和闭包自行创建模块
-
使用
require()的 Node 模块 -
使用
export、import和import()的 ES6 模块
10.1 使用类、对象和闭包的模块
尽管这可能是显而易见的,但值得指出的是,类的一个重要特性是它们作为其方法的模块。 回想一下示例 9-8。 该示例定义了许多不同的类,所有这些类都有一个名为has()的方法。 但是,您可以毫无问题地编写一个使用该示例中多个集合类的程序:例如,SingletonSet 的has()方法不会覆盖 BitSet 的has()方法。
一个类的方法独立于其他不相关类的方法的原因是,每个类的方法被定义为独立原型对象的属性。 类是模块化的原因是对象是模块化的:在 JavaScript 对象中定义属性很像声明变量,但向对象添加属性不会影响程序的全局命名空间,也不会影响其他对象的属性。 JavaScript 定义了相当多的数学函数和常量,但是不是将它们全部定义为全局的,而是将它们作为单个全局 Math 对象的属性分组。 这种技术可以在示例 9-8 中使用。 该示例可以被编写为仅定义一个名为 Sets 的全局对象,其属性引用各种类。 使用此 Sets 库的用户可以使用类似Sets.Singleton和Sets.Bit的名称引用类。
在 JavaScript 编程中,使用类和对象进行模块化是一种常见且有用的技术,但这还不够。 特别是,它没有提供任何隐藏模块内部实现细节的方法。 再次考虑示例 9-8。 如果我们将该示例编写为一个模块,也许我们希望将各种抽象类保留在模块内部,只将具体子类提供给模块的用户。 同样,在 BitSet 类中,_valid()和_has()方法是内部实用程序,不应该真正暴露给类的用户。 BitSet.bits和BitSet.masks是最好隐藏的实现细节。
正如我们在 §8.6 中看到的,函数内声明的局部变量和嵌套函数对该函数是私有的。这意味着我们可以使用立即调用的函数表达式通过将实现细节和实用函数隐藏在封闭函数中,使模块的公共 API 成为函数的返回值来实现一种模块化。对于 BitSet 类,我们可以将模块结构化如下:
const BitSet = (function() { // Set BitSet to the return value of this function
// Private implementation details here
function isValid(set, n) { ... }
function has(set, byte, bit) { ... }
const BITS = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
const MASKS = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);
// The public API of the module is just the BitSet class, which we define
// and return here. The class can use the private functions and constants
// defined above, but they will be hidden from users of the class
return class BitSet extends AbstractWritableSet {
// ... implementation omitted ...
};
}());
当模块中有多个项时,这种模块化方法变得更加有趣。例如,以下代码定义了一个迷你统计模块,导出 mean() 和 stddev() 函数,同时隐藏了实现细节:
// This is how we could define a stats module
const stats = (function() {
// Utility functions private to the module
const sum = (x, y) => x + y;
const square = x => x * x;
// A public function that will be exported
function mean(data) {
return data.reduce(sum)/data.length;
}
// A public function that we will export
function stddev(data) {
let m = mean(data);
return Math.sqrt(
data.map(x => x - m).map(square).reduce(sum)/(data.length-1)
);
}
// We export the public function as properties of an object
return { mean, stddev };
}());
// And here is how we might use the module
stats.mean([1, 3, 5, 7, 9]) // => 5
stats.stddev([1, 3, 5, 7, 9]) // => Math.sqrt(10)
10.1.1 自动化基于闭包的模块化
请注意,将 JavaScript 代码文件转换为这种模块的过程是一个相当机械化的过程,只需在文件开头和结尾插入一些文本即可。所需的只是一些约定,用于指示哪些值要导出,哪些不要导出。
想象一个工具,它接受一组文件,将每个文件的内容包装在立即调用的函数表达式中,跟踪每个函数的返回值,并将所有内容连接成一个大文件。结果可能看起来像这样:
const modules = {};
function require(moduleName) { return modules[moduleName]; }
modules["sets.js"] = (function() {
const exports = {};
// The contents of the sets.js file go here:
exports.BitSet = class BitSet { ... };
return exports;
}());
modules["stats.js"] = (function() {
const exports = {};
// The contents of the stats.js file go here:
const sum = (x, y) => x + y;
const square = x = > x * x;
exports.mean = function(data) { ... };
exports.stddev = function(data) { ... };
return exports;
}());
将模块捆绑成一个单一文件,就像前面示例中所示的那样,你可以想象编写以下代码来利用这些模块:
// Get references to the modules (or the module content) that we need
const stats = require("stats.js");
const BitSet = require("sets.js").BitSet;
// Now write code using those modules
let s = new BitSet(100);
s.insert(10);
s.insert(20);
s.insert(30);
let average = stats.mean([...s]); // average is 20
这段代码是对代码捆绑工具(如 webpack 和 Parcel)在 web 浏览器中的工作原理的粗略草图,也是对类似于 Node 程序中使用的 require() 函数的简单介绍。
10.2 Node 模块
在 Node 编程中,将程序分割为尽可能多的文件是很正常的。这些 JavaScript 代码文件都假定存在于一个快速的文件系统上。与 web 浏览器不同,后者必须通过相对较慢的网络连接读取 JavaScript 文件,将 Node 程序捆绑成一个单一的 JavaScript 文件既没有必要也没有好处。
在 Node 中,每个文件都是具有私有命名空间的独立模块。在一个文件中定义的常量、变量、函数和类对该文件是私有的,除非文件导出它们。一个模块导出的值只有在另一个模块明确导入它们时才能看到。
Node 模块通过 require() 函数导入其他模块,并通过设置 Exports 对象的属性或完全替换 module.exports 对象来导出它们的公共 API。
10.2.1 Node 导出
Node 定义了一个全局的 exports 对象,它总是被定义。如果你正在编写一个导出多个值的 Node 模块,你可以简单地将它们分配给这个对象的属性:
const sum = (x, y) => x + y;
const square = x => x * x;
exports.mean = data => data.reduce(sum)/data.length;
exports.stddev = function(d) {
let m = exports.mean(d);
return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};
然而,通常情况下,你可能只想定义一个仅导出单个函数或类的模块,而不是一个充满函数或类的对象。为此,你只需将要导出的单个值分配给 module.exports:
module.exports = class BitSet extends AbstractWritableSet {
// implementation omitted
};
module.exports 的默认值是 exports 所指向的相同对象。在之前的 stats 模块中,我们可以将 mean 函数分配给 module.exports.mean 而不是 exports.mean。像 stats 模块这样的模块的另一种方法是在模块末尾导出一个单一对象,而不是在导出函数时逐个导出:
// Define all the functions, public and private
const sum = (x, y) => x + y;
const square = x => x * x;
const mean = data => data.reduce(sum)/data.length;
const stddev = d => {
let m = mean(d);
return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};
// Now export only the public ones
module.exports = { mean, stddev };
10.2.2 Node 导入
一个 Node 模块通过调用 require() 函数来导入另一个模块。这个函数的参数是要导入的模块的名称,返回值是该模块导出的任何值(通常是一个函数、类或对象)。
如果你想导入 Node 内置的系统模块或通过包管理器在系统上安装的模块,那么你只需使用模块的未限定名称,不需要任何将其转换为文件系统路径的“/”字符:
// These modules are built in to Node
const fs = require("fs"); // The built-in filesystem module
const http = require("http"); // The built-in HTTP module
// The Express HTTP server framework is a third-party module.
// It is not part of Node but has been installed locally
const express = require("express");
当您想要导入自己代码的模块时,模块名称应该是包含该代码的文件的路径,相对于当前模块文件。使用以/字符开头的绝对路径是合法的,但通常,当导入属于您自己程序的模块时,模块名称将以*./ 或有时是../ *开头,以指示它们相对于当前目录或父目录。例如:
const stats = require('./stats.js');
const BitSet = require('./utils/bitset.js');
(您也可以省略导入文件的.js后缀,Node 仍然可以找到这些文件,但通常会看到这些文件扩展名明确包含在内。)
当一个模块只导出一个函数或类时,您只需导入它。当一个模块导出一个具有多个属性的对象时,您可以选择:您可以导入整个对象,或者只导入您打算使用的对象的特定属性(使用解构赋值)。比较这两种方法:
// Import the entire stats object, with all of its functions
const stats = require('./stats.js');
// We've got more functions than we need, but they're neatly
// organized into a convenient "stats" namespace.
let average = stats.mean(data);
// Alternatively, we can use idiomatic destructuring assignment to import
// exactly the functions we want directly into the local namespace:
const { stddev } = require('./stats.js');
// This is nice and succinct, though we lose a bit of context
// without the 'stats' prefix as a namspace for the stddev() function.
let sd = stddev(data);
10.2.3 Web 上的 Node 风格模块
具有 Exports 对象和require()函数的模块内置于 Node 中。但是,如果您愿意使用像 webpack 这样的捆绑工具处理您的代码,那么也可以将这种模块样式用于旨在在 Web 浏览器中运行的代码。直到最近,这是一种非常常见的做法,您可能会看到许多仍在这样做的基于 Web 的代码。
现在 JavaScript 有了自己的标准模块语法,然而,使用捆绑工具的开发人员更有可能使用带有import和export语句的官方 JavaScript 模块。
10.3 ES6 中的模块
ES6 为 JavaScript 添加了import和export关键字,最终将真正的模块化支持作为核心语言特性。ES6 的模块化在概念上与 Node 的模块化相同:每个文件都是自己的模块,文件中定义的常量、变量、函数和类除非明确导出,否则都是私有于该模块。从一个模块导出的值可以在明确导入它们的模块中使用。ES6 模块在导出和导入的语法以及在 Web 浏览器中定义模块的方式上与 Node 模块不同。接下来的部分将详细解释这些内容。
首先,请注意,ES6 模块在某些重要方面也与常规 JavaScript“脚本”不同。最明显的区别是模块化本身:在常规脚本中,变量、函数和类的顶级声明进入由所有脚本共享的单个全局上下文中。使用模块后,每个文件都有自己的私有上下文,并且可以使用import和export语句,这毕竟是整个重点。但模块和脚本之间还有其他区别。ES6 模块中的代码(就像 ES6 class定义内的代码一样)自动处于严格模式(参见§5.6.3)。这意味着,当您开始使用 ES6 模块时,您将永远不必再编写"use strict"。这意味着模块中的代码不能使用with语句或arguments对象或未声明的变量。ES6 模块甚至比严格模式稍微严格:在严格模式中,作为函数调用的函数中,this是undefined。在模块中,即使在顶级代码中,this也是undefined。(相比之下,Web 浏览器和 Node 中的脚本将this设置为全局对象。)
Web 上和 Node 中的 ES6 模块
多年来,借助像 webpack 这样的代码捆绑工具,ES6 模块已经在 Web 上得到了应用,这些工具将独立的 JavaScript 代码模块组合成大型、非模块化的捆绑包,适合包含在网页中。然而,在撰写本文时,除了 Internet Explorer 之外,所有 Web 浏览器终于原生支持 ES6 模块。在原生支持时,ES6 模块通过特殊的<script type="module">标签添加到 HTML 页面中,本章后面将对此进行描述。
与此同时,作为 JavaScript 模块化的先驱,Node 发现自己处于一个尴尬的位置,必须支持两种不完全兼容的模块系统。Node 13 支持 ES6 模块,但目前,绝大多数 Node 程序仍然使用 Node 模块。
10.3.1 ES6 导出
要从 ES6 模块中导出常量、变量、函数或类,只需在声明之前添加关键字export:
export const PI = Math.PI;
export function degreesToRadians(d) { return d * PI / 180; }
export class Circle {
constructor(r) { this.r = r; }
area() { return PI * this.r * this.r; }
}
作为在模块中散布export关键字的替代方案,你可以像通常一样定义常量、变量、函数和类,不写任何export语句,然后(通常在模块的末尾)写一个单独的export语句,声明在一个地方精确地导出了什么。因此,与在前面的代码中写三个单独的导出相反,我们可以在末尾写一行等效的代码:
export { Circle, degreesToRadians, PI };
这个语法看起来像是export关键字后跟一个对象字面量(使用简写表示法)。但在这种情况下,花括号实际上并没有定义一个对象字面量。这种导出语法只需要在花括号内写一个逗号分隔的标识符列表。
编写只导出一个值(通常是函数或类)的模块很常见,在这种情况下,我们通常使用export default而不是export:
export default class BitSet {
// implementation omitted
}
默认导出比非默认导出稍微容易导入,因此当只有一个导出值时,使用export default会使使用你导出值的模块更容易。
使用export进行常规导出只能用于具有名称的声明。使用export default进行默认导出可以导出任何表达式,包括匿名函数表达式和匿名类表达式。这意味着如果你使用export default,你可以导出对象字面量。因此,与export语法不同,如果你在export default后看到花括号,那么实际上导出的是一个对象字面量。
模块既有一组常规导出又有默认导出是合法的,但有些不太常见。如果一个模块有默认导出,它只能有一个。
最后,请注意export关键字只能出现在你的 JavaScript 代码的顶层。你不能从类、函数、循环或条件语句中导出值。(这是 ES6 模块系统的一个重要特性,它实现了静态分析:模块的导出在每次运行时都是相同的,并且可以在模块实际运行之前确定导出的符号。)
10.3.2 ES6 导入
你可以使用import关键字导入其他模块导出的值。最简单的导入形式用于定义默认导出的模块:
import BitSet from './bitset.js';
这是import关键字,后面跟着一个标识符,然后是from关键字,后面是一个字符串字面量,命名了我们要导入默认导出的模块。指定模块的默认导出值成为当前模块中指定标识符的值。
导入值分配给的标识符是一个常量,就好像它已经用const关键字声明过一样。与导出一样,导入只能出现在模块的顶层,不允许在类、函数、循环或条件语句中。根据普遍惯例,模块所需的导入应放在模块的开头。然而有趣的是,这并非必须:像函数声明一样,导入被“提升”到顶部,所有导入的值在模块的任何代码运行时都可用。
导入值的模块被指定为一个常量字符串文字,用单引号或双引号括起来。(您不能使用值为字符串的变量或其他表达式,也不能在反引号内使用字符串,因为模板文字可以插入变量并且不总是具有常量值。)在 Web 浏览器中,此字符串被解释为相对于执行导入操作的模块的位置的 URL。(在 Node 中,或者使用捆绑工具时,该字符串被解释为相对于当前模块的文件名,但在实践中这几乎没有区别。)模块规范符字符串必须是以“/”开头的绝对路径,或以“./”或“../”开头的相对路径,或具有协议和主机名的完整 URL。ES6 规范不允许未经限定的模块规范符字符串,如“util.js”,因为不清楚这是否意味着要命名与当前目录中的模块或某种安装在某个特殊位置的系统模块。 (这个对“裸模块规范符”的限制不被像 webpack 这样的代码捆绑工具所遵守,它可以很容易地配置为在您指定的库目录中找到裸模块。)语言的未来版本可能允许“裸模块规范符”,但目前不允许。如果要从与当前目录相同的目录导入模块,只需在模块名称前加上“./”,并从“./util.js”而不是“util.js”导入。
到目前为止,我们只考虑了从使用export default的模块导入单个值的情况。要从导出多个值的模块导入值,我们使用稍微不同的语法:
import { mean, stddev } from "./stats.js";
请记住,默认导出在定义它们的模块中不需要名称。相反,当我们导入这些值时,我们提供一个本地名称。但是,模块的非默认导出在导出模块中有名称,当我们导入这些值时,我们通过这些名称引用它们。导出模块可以导出任意数量的命名值。引用该模块的import语句可以通过在花括号内列出它们的名称来导入这些值的任意子集。花括号使这种import语句看起来有点像解构赋值,实际上,解构赋值是这种导入样式在做的事情的一个很好的类比。花括号内的标识符都被提升到导入模块的顶部,并且行为像常量。
样式指南有时建议您明确导入模块将使用的每个符号。但是,当从定义许多导出的模块导入时,您可以轻松地使用像这样的import语句导入所有内容:
import * as stats from "./stats.js";
像这样的import语句会创建一个对象,并将其赋值给名为stats的常量。被导入模块的每个非默认导出都成为这个stats对象的属性。非默认导出始终有名称,并且这些名称在对象内部用作属性名称。这些属性实际上是常量:它们不能被覆盖或删除。在前面示例中显示的通配符导入中,导入模块将通过stats对象使用导入的mean()和stddev()函数,调用它们为stats.mean()和stats.stddev()。
模块通常定义一个默认导出或多个命名导出。一个模块同时使用export和export default是合法的,但有点不常见。但是当一个模块这样做时,您可以使用像这样的import语句同时导入默认值和命名值:
import Histogram, { mean, stddev } from "./histogram-stats.js";
到目前为止,我们已经看到了如何从具有默认导出的模块和具有非默认或命名导出的模块导入。但是还有一种import语句的形式,用于没有任何导出的模块。要将没有任何导出的模块包含到您的程序中,只需使用import关键字与模块规范符:
import "./analytics.js";
这样的模块在第一次导入时运行。(随后的导入不会执行任何操作。)一个仅定义函数的模块只有在导出其中至少一个函数时才有用。但是,如果一个模块运行一些代码,那么即使没有符号,导入它也是有用的。一个用于 Web 应用程序的分析模块可能会运行代码来注册各种事件处理程序,然后在适当的时候使用这些事件处理程序将遥测数据发送回服务器。该模块是自包含的,不需要导出任何内容,但我们仍然需要import它,以便它实际上作为我们程序的一部分运行。
请注意,即使有导出的模块,您也可以使用这种导入空内容的import语法。如果一个模块定义了独立于其导出值的有用行为,并且您的程序不需要任何这些导出值,您仍然可以导入该模块。只是为了那个默认行为。
10.3.3 导入和重命名导出
如果两个模块使用相同名称导出两个不同的值,并且您想要导入这两个值,那么在导入时您将需要重命名其中一个或两个值。同样,如果您想要导入一个名称已在您的模块中使用的值,那么您将需要重命名导入的值。您可以使用带有命名导入的as关键字来重命名它们:
import { render as renderImage } from "./imageutils.js";
import { render as renderUI } from "./ui.js";
这些行将两个函数导入当前模块。这两个函数在定义它们的模块中都被命名为render(),但在导入时使用更具描述性和消除歧义的名称renderImage()和renderUI()。
请记住,默认导出没有名称。导入模块在导入默认导出时总是选择名称。因此,在这种情况下不需要特殊的重命名语法。
话虽如此,但在导入时重命名的可能性提供了另一种从定义默认导出和命名导出的模块中导入的方式。回想一下前一节中的“./histogram-stats.js”模块。以下是导入该模块的默认导出和命名导出的另一种方式:
import { default as Histogram, mean, stddev } from "./histogram-stats.js";
在这种情况下,JavaScript 关键字default充当占位符,并允许我们指示我们要导入并为模块的默认导出提供名称。
也可以在导出时重命名值,但只能在使用花括号变体的export语句时。通常不需要这样做,但如果您在模块内选择了简短、简洁的名称,您可能更喜欢使用更具描述性的名称导出值,这样就不太可能与其他模块发生冲突。与导入一样,您可以使用as关键字来执行此操作:
export {
layout as calculateLayout,
render as renderLayout
};
请记住,尽管花括号看起来有点像对象文字,但它们并不是,export关键字在as之前期望一个标识符,而不是一个表达式。这意味着不幸的是,您不能像这样使用导出重命名:
export { Math.sin as sin, Math.cos as cos }; // SyntaxError
10.3.4 重新导出
在本章中,我们讨论了一个假设的“./stats.js”模块,该模块导出mean()和stddev()函数。如果我们正在编写这样一个模块,并且我们认为该模块的许多用户只想要其中一个函数,那么我们可能希望在“./stats/mean.js”模块中定义mean(),在“./stats/stddev.js”中定义stddev()。这样,程序只需要导入它们需要的函数,而不会因导入不需要的代码而臃肿。
即使我们将这些统计函数定义在单独的模块中,我们可能仍然希望有很多程序需要这两个函数,并且希望有一个方便的“./stats.js”模块,可以在一行中导入这两个函数。
鉴于现在实现在单独的文件中,定义这个“./stat.js”模块很简单:
import { mean } from "./stats/mean.js";
import { stddev } from "./stats/stddev.js";
export { mean, stdev };
ES6 模块预期这种用法并为其提供了特殊的语法。不再简单地导入一个符号再导出它,你可以将导入和导出步骤合并为一个单独的“重新导出”语句,使用export关键字和from关键字:
export { mean } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";
请注意,此代码中实际上没有使用mean和stddev这两个名称。如果我们不选择性地重新导出并且只想从另一个模块导出所有命名值,我们可以使用通配符:
export * from "./stats/mean.js";
export * from "./stats/stddev.js";
重新导出语法允许使用as进行重命名,就像常规的import和export语句一样。假设我们想要重新导出mean()函数,但同时为该函数定义average()作为另一个名称。我们可以这样做:
export { mean, mean as average } from "./stats/mean.js";
export { stddev } from "./stats/stddev.js";
该示例中的所有重新导出都假定“./stats/mean.js”和“./stats/stddev.js”模块使用export而不是export default导出它们的函数。实际上,由于这些是只有一个导出的模块,定义为export default是有意义的。如果我们这样做了,那么重新导出语法会稍微复杂一些,因为它需要为未命名的默认导出定义一个名称。我们可以这样做:
export { default as mean } from "./stats/mean.js";
export { default as stddev } from "./stats/stddev.js";
如果你想要将另一个模块的命名符号重新导出为你的模块的默认导出,你可以进行import,然后进行export default,或者你可以将这两个语句结合起来,像这样:
// Import the mean() function from ./stats.js and make it the
// default export of this module
export { mean as default } from "./stats.js"
最后,要将另一个模块的默认导出重新导出为你的模块的默认导出(尽管不清楚为什么要这样做,因为用户可以直接导入另一个模块),你可以这样写:
// The average.js module simply re-exports the stats/mean.js default export
export { default } from "./stats/mean.js"
10.3.5 Web 上的 JavaScript 模块
前面的章节以一种相对抽象的方式描述了 ES6 模块及其import和export声明。在本节和下一节中,我们将讨论它们在 Web 浏览器中的实际工作方式,如果你还不是一名经验丰富的 Web 开发人员,你可能会发现在阅读第十五章之后更容易理解本章的其余内容。
截至 2020 年初,使用 ES6 模块的生产代码仍然通常与类似 webpack 的工具捆绑在一起。这样做存在一些权衡之处,¹但总体上,代码捆绑往往能提供更好的性能。随着网络速度的增长和浏览器厂商继续优化他们的 ES6 模块实现,这种情况可能会发生变化。
尽管捆绑工具在生产中仍然可取,但在开发中不再需要,因为所有当前的浏览器都提供了对 JavaScript 模块的原生支持。请记住,模块默认使用严格模式,this不指向全局对象,并且顶级声明默认情况下不会在全局范围内共享。由于模块必须以与传统非模块代码不同的方式执行,它们的引入需要对 HTML 和 JavaScript 进行更改。如果你想在 Web 浏览器中原生使用import指令,你必须通过使用<script type="module">标签告诉 Web 浏览器你的代码是一个模块。
ES6 模块的一个很好的特性是每个模块都有一个静态的导入集合。因此,给定一个起始模块,Web 浏览器可以加载所有导入的模块,然后加载第一批模块导入的所有模块,依此类推,直到完整的程序被加载。我们已经看到import语句中的模块指示符可以被视为相对 URL。<script type="module">标签标记了模块化程序的起点。然而,它导入的模块都不应该在<script>标签中,而是按需作为常规 JavaScript 文件加载,并像常规 ES6 模块一样以严格模式执行。使用<script type="module">标签来定义模块化 JavaScript 程序的主入口点可以像这样简单:
<script type="module">import "./main.js";</script>
内联<script type="module">标签中的代码是 ES6 模块,因此可以使用export语句。然而,这样做没有任何意义,因为 HTML <script>标签语法没有提供任何定义内联模块名称的方式,因此,即使这样的模块导出一个值,也没有办法让另一个模块导入它。
带有type="module"属性的脚本会像带有defer属性的脚本一样加载和执行。代码加载会在 HTML 解析器遇到<script>标签时开始(在模块的情况下,这个代码加载步骤可能是一个递归过程,加载多个 JavaScript 文件)。但是代码执行直到 HTML 解析完成才开始。一旦 HTML 解析完成,脚本(模块和非模块)将按照它们在 HTML 文档中出现的顺序执行。
您可以使用async属性修改模块的执行时间,这对于模块和常规脚本的工作方式是相同的。async模块将在代码加载后立即执行,即使 HTML 解析尚未完成,即使这会改变脚本的相对顺序。
支持<script type="module">的 Web 浏览器也必须支持<script nomodule>。了解模块的浏览器会忽略带有nomodule属性的任何脚本并且不会执行它。不支持模块的浏览器将不识别nomodule属性,因此它们会忽略它并运行脚本。这为处理浏览器兼容性问题提供了一个强大的技术。支持 ES6 模块的浏览器还支持其他现代 JavaScript 特性,如类、箭头函数和for/of循环。如果您编写现代 JavaScript 并使用<script type="module">加载它,您知道它只会被支持的浏览器加载。作为 IE11 的备用方案(在 2020 年,实际上是唯一一个不支持 ES6 的浏览器),您可以使用类似 Babel 和 webpack 的工具将您的代码转换为非模块化的 ES5 代码,然后通过<script nomodule>加载这些效率较低的转换代码。
常规脚本和模块脚本之间的另一个重要区别与跨域加载有关。常规的<script>标签将从互联网上的任何服务器加载 JavaScript 代码文件,互联网的广告、分析和跟踪代码基础设施依赖于这一事实。但是<script type="module">提供了一种加强这一点的机会,模块只能从包含 HTML 文档的同一源加载,或者在适当的 CORS 标头放置以安全地允许跨源加载时才能加载。这种新的安全限制的一个不幸副作用是,它使得使用file: URL 在开发模式下测试 ES6 模块变得困难。使用 ES6 模块时,您可能需要设置一个静态 Web 服务器进行测试。
一些程序员喜欢使用文件扩展名.mjs来区分他们的模块化 JavaScript 文件和传统.js扩展名的常规非模块化 JavaScript 文件。对于 Web 浏览器和<script>标签来说,文件扩展名实际上是无关紧要的。(但 MIME 类型是相关的,因此如果您使用.mjs文件,您可能需要配置您的 Web 服务器以相同的 MIME 类型提供它们,如.js文件。)Node 对 ES6 的支持确实使用文件扩展名作为提示来区分它加载的每个文件使用的模块系统。因此,如果您编写 ES6 模块并希望它们能够在 Node 中使用,采用.mjs命名约定可能会有所帮助。
10.3.6 使用 import()进行动态导入
我们已经看到 ES6 的 import 和 export 指令是完全静态的,并且使 JavaScript 解释器和其他 JavaScript 工具能够在加载模块时通过简单的文本分析确定模块之间的关系,而无需实际执行模块中的任何代码。使用静态导入的模块,你可以确保导入到模块中的值在你的模块中的任何代码开始运行之前就已经准备好供使用。
在 Web 上,代码必须通过网络传输,而不是从文件系统中读取。一旦传输,该代码通常在相对较慢的移动设备上执行。这不是静态模块导入(需要在任何代码运行之前加载整个程序)有很多意义的环境。
Web 应用程序通常只加载足够的代码来渲染用户看到的第一页。然后,一旦用户有一些初步内容可以交互,它们就可以开始加载通常需要更多的代码来完成网页应用程序的其余部分。Web 浏览器通过使用 DOM API 将新的 <script> 标签注入到当前 HTML 文档中,使动态加载代码变得容易,而 Web 应用程序多年来一直在这样做。
尽管动态加载已经很久了,但它并不是语言本身的一部分。这在 ES2020 中发生了变化(截至 2020 年初,支持 ES6 模块的所有浏览器都支持动态导入)。你将一个模块规范传递给 import(),它将返回一个代表加载和运行指定模块的异步过程的 Promise 对象。当动态导入完成时,Promise 将“完成”(请参阅 第十三章 了解有关异步编程和 Promise 的完整细节),并产生一个对象,就像你使用静态导入语句的 import * as 形式一样。
因此,我们可以像这样静态导入“./stats.js”模块:
import * as stats from "./stats.js";
我们可以像这样动态导入并使用它:
import("./stats.js").then(stats => {
let average = stats.mean(data);
})
或者,在一个 async 函数中(再次,你可能需要在理解这段代码之前阅读 第十三章),我们可以用 await 简化代码:
async analyzeData(data) {
let stats = await import("./stats.js");
return {
average: stats.mean(data),
stddev: stats.stddev(data)
};
}
import() 的参数应该是一个模块规范,就像你会在静态 import 指令中使用的那样。但是使用 import(),你不受限于使用常量字符串文字:任何表达式只要以正确形式评估为字符串即可。
动态 import() 看起来像一个函数调用,但实际上不是。相反,import() 是一个操作符,括号是操作符语法的必需部分。这种不寻常的语法之所以存在是因为 import() 需要能够将模块规范解析为相对于当前运行模块的 URL,这需要一些实现魔法,这是不合法的放在 JavaScript 函数中的。在实践中,函数与操作符的区别很少有影响,但如果尝试编写像 console.log(import); 或 let require = import; 这样的代码,你会注意到这一点。
最后,请注意动态 import() 不仅适用于 Web 浏览器。代码打包工具如 webpack 也可以很好地利用它。使用代码捆绑器的最简单方法是告诉它程序的主入口点,让它找到所有静态 import 指令并将所有内容组装成一个大文件。然而,通过策略性地使用动态 import() 调用,你可以将这个单一的庞大捆绑拆分成一组可以按需加载的较小捆绑。
10.3.7 import.meta.url
ES6 模块系统的最后一个特性需要讨论。在 ES6 模块中(但不在常规的 <script> 或使用 require() 加载的 Node 模块中),特殊语法 import.meta 指的是一个包含有关当前执行模块的元数据的对象。该对象的 url 属性是加载模块的 URL。(在 Node 中,这将是一个 file:// URL。)
import.meta.url 的主要用例是能够引用存储在与模块相同目录中(或相对于模块)的图像、数据文件或其他资源。URL() 构造函数使得相对 URL 相对于绝对 URL(如 import.meta.url)容易解析。例如,假设你编写了一个模块,其中包含需要本地化的字符串,并且本地化文件存储在与模块本身相同目录中的 l10n/ 目录中。你的模块可以使用类似这样的函数创建的 URL 加载其字符串:
function localStringsURL(locale) {
return new URL(`l10n/${locale}.json`, import.meta.url);
}
10.4 总结
模块化的目标是允许程序员隐藏其代码的实现细节,以便来自各种来源的代码块可以组装成大型程序,而不必担心一个代码块会覆盖另一个的函数或变量。本章已经解释了三种不同的 JavaScript 模块系统:
-
在 JavaScript 的早期,模块化只能通过巧妙地使用立即调用的函数表达式来实现。
-
Node 在 JavaScript 语言之上添加了自己的模块系统。Node 模块通过
require()导入,并通过设置 Exports 对象的属性或设置module.exports属性来定义它们的导出。 -
在 ES6 中,JavaScript 终于拥有了自己的模块系统,使用
import和export关键字,而 ES2020 正在添加对使用import()进行动态导入的支持。
¹ 例如:经常进行增量更新并且用户频繁返回访问的 Web 应用程序可能会发现,使用小模块而不是大捆绑包可以更好地利用用户浏览器缓存,从而导致更好的平均加载时间。
第十一章:JavaScript 标准库
一些数据类型,如数字和字符串(第三章)、对象(第六章)和数组(第七章)对于 JavaScript 来说是如此基础,以至于我们可以将它们视为语言本身的一部分。本章涵盖了其他重要但不太基础的 API,可以被视为 JavaScript 的“标准库”:这些是内置于 JavaScript 中的有用类和函数,可供所有 Web 浏览器和 Node 中的 JavaScript 程序使用。¹
本章的各节相互独立,您可以按任意顺序阅读它们。它们涵盖了:
-
Set 和 Map 类,用于表示值的集合和从一个值集合到另一个值集合的映射。
-
类型化数组(TypedArrays)等类似数组的对象,表示二进制数据的数组,以及用于从非数组二进制数据中提取值的相关类。
-
正则表达式和 RegExp 类,定义文本模式,对文本处理很有用。本节还详细介绍了正则表达式语法。
-
Date 类用于表示和操作日期和时间。
-
Error 类及其各种子类的实例,当 JavaScript 程序发生错误时抛出。
-
JSON 对象,其方法支持对由对象、数组、字符串、数字和布尔值组成的 JavaScript 数据结构进行序列化和反序列化。
-
Intl 对象及其定义的类,可帮助您本地化 JavaScript 程序。
-
Console 对象,其方法以特别有用的方式输出字符串,用于调试程序和记录程序的行为。
-
URL 类简化了解析和操作 URL 的任务。本节还涵盖了用于对 URL 及其组件进行编码和解码的全局函数。
-
setTimeout()及相关函数用于指定在经过指定时间间隔后执行的代码。
本章中的一些部分,尤其是关于类型化数组和正则表达式的部分,由于您需要理解的重要背景信息较多,因此相当长。然而,其他许多部分很短:它们只是介绍一个新的 API 并展示其使用示例。
11.1 集合和映射
JavaScript 的 Object 类型是一种多功能的数据结构,可以用来将字符串(对象的属性名称)映射到任意值。当被映射的值是像true这样固定的值时,那么对象实际上就是一组字符串。
在 JavaScript 编程中,对象实际上经常被用作映射和集合,但由于限制为字符串并且对象通常继承具有诸如“toString”之类名称的属性,这使得使用起来有些复杂,通常这些属性并不打算成为映射或集合的一部分。
出于这个原因,ES6 引入了真正的 Set 和 Map 类,我们将在接下来的子章节中介绍。
11.1.1 Set 类
集合是一组值,类似于数组。但与数组不同,集合没有顺序或索引,并且不允许重复:一个值要么是集合的成员,要么不是成员;无法询问一个值在集合中出现多少次。
使用Set()构造函数创建一个 Set 对象:
let s = new Set(); // A new, empty set
let t = new Set([1, s]); // A new set with two members
Set()构造函数的参数不一定是数组:任何可迭代对象(包括其他 Set 对象)都是允许的:
let t = new Set(s); // A new set that copies the elements of s.
let unique = new Set("Mississippi"); // 4 elements: "M", "i", "s", and "p"
集合的size属性类似于数组的length属性:它告诉你集合包含多少个值:
unique.size // => 4
创建集合时无需初始化。您可以随时使用add()、delete()和clear()添加和删除元素。请记住,集合不能包含重复项,因此向集合添加已包含的值不会产生任何效果:
let s = new Set(); // Start empty
s.size // => 0
s.add(1); // Add a number
s.size // => 1; now the set has one member
s.add(1); // Add the same number again
s.size // => 1; the size does not change
s.add(true); // Add another value; note that it is fine to mix types
s.size // => 2
s.add([1,2,3]); // Add an array value
s.size // => 3; the array was added, not its elements
s.delete(1) // => true: successfully deleted element 1
s.size // => 2: the size is back down to 2
s.delete("test") // => false: "test" was not a member, deletion failed
s.delete(true) // => true: delete succeeded
s.delete([1,2,3]) // => false: the array in the set is different
s.size // => 1: there is still that one array in the set
s.clear(); // Remove everything from the set
s.size // => 0
关于这段代码有几个重要的要点需要注意:
-
add()方法接受一个参数;如果传递一个数组,它会将数组本身添加到集合中,而不是单独的数组元素。但是,add()始终返回调用它的集合,因此如果要向集合添加多个值,可以使用链式方法调用,如s.add('a').add('b').add('c');。 -
delete()方法也仅一次删除单个集合元素。但是,与add()不同,delete()返回一个布尔值。如果您指定的值实际上是集合的成员,则delete()会将其删除并返回true。否则,它不执行任何操作并返回false。 -
最后,非常重要的是要理解集合成员是基于严格的相等性检查的,就像
===运算符执行的那样。集合可以包含数字1和字符串"1",因为它认为它们是不同的值。当值为对象(或数组或函数)时,它们也被视为使用===进行比较。这就是为什么我们无法从此代码中的集合中删除数组元素的原因。我们向集合添加了一个数组,然后尝试通过向delete()方法传递一个不同的数组(尽管具有相同元素)来删除该数组。为了使其工作,我们必须传递对完全相同的数组的引用。
注意
Python 程序员请注意:这是 JavaScript 和 Python 集合之间的一个重要区别。Python 集合比较成员的相等性,而不是身份,但这样做的代价是 Python 集合只允许不可变成员,如元组,并且不允许将列表和字典添加到集合中。
在实践中,我们与集合最重要的事情不是向其中添加和删除元素,而是检查指定的值是否是集合的成员。我们使用has()方法来实现这一点:
let oneDigitPrimes = new Set([2,3,5,7]);
oneDigitPrimes.has(2) // => true: 2 is a one-digit prime number
oneDigitPrimes.has(3) // => true: so is 3
oneDigitPrimes.has(4) // => false: 4 is not a prime
oneDigitPrimes.has("5") // => false: "5" is not even a number
关于集合最重要的一点是它们被优化用于成员测试,无论集合有多少成员,has()方法都会非常快。数组的includes()方法也执行成员测试,但所需时间与数组的大小成正比,使用数组作为集合可能比使用真正的 Set 对象慢得多。
Set 类是可迭代的,这意味着您可以使用for/of循环枚举集合的所有元素:
let sum = 0;
for(let p of oneDigitPrimes) { // Loop through the one-digit primes
sum += p; // and add them up
}
sum // => 17: 2 + 3 + 5 + 7
因为 Set 对象是可迭代的,您可以使用...扩展运算符将它们转换为数组和参数列表:
[...oneDigitPrimes] // => [2,3,5,7]: the set converted to an Array
Math.max(...oneDigitPrimes) // => 7: set elements passed as function arguments
集合经常被描述为“无序集合”。然而,对于 JavaScript Set 类来说,这并不完全正确。JavaScript 集合是无索引的:您无法像数组那样请求集合的第一个或第三个元素。但是 JavaScript Set 类始终记住元素插入的顺序,并且在迭代集合时始终使用此顺序:插入的第一个元素将是首个迭代的元素(假设您尚未首先删除它),最近插入的元素将是最后一个迭代的元素。²
除了可迭代外,Set 类还实现了类似于数组同名方法的forEach()方法:
let product = 1;
oneDigitPrimes.forEach(n => { product *= n; });
product // => 210: 2 * 3 * 5 * 7
数组的forEach()将数组索引作为第二个参数传递给您指定的函数。集合没有索引,因此 Set 类的此方法简单地将元素值作为第一个和第二个参数传递。
11.1.2 Map 类
Map 对象表示一组称为键的值,其中每个键都有另一个与之关联(或“映射到”)的值。在某种意义上,映射类似于数组,但是不同于使用一组顺序整数作为键,映射允许我们使用任意值作为“索引”。与数组一样,映射很快:查找与键关联的值将很快(尽管不像索引数组那样快),无论映射有多大。
使用Map()构造函数创建一个新的映射:
let m = new Map(); // Create a new, empty map
let n = new Map([ // A new map initialized with string keys mapped to numbers
["one", 1],
["two", 2]
]);
Map() 构造函数的可选参数应该是一个可迭代对象,产生两个元素 [key, value] 数组。在实践中,这意味着如果你想在创建 map 时初始化它,你通常会将所需的键和关联值写成数组的数组。但你也可以使用 Map() 构造函数复制其他 map,或从现有对象复制属性名和值:
let copy = new Map(n); // A new map with the same keys and values as map n
let o = { x: 1, y: 2}; // An object with two properties
let p = new Map(Object.entries(o)); // Same as new map([["x", 1], ["y", 2]])
一旦你创建了一个 Map 对象,你可以使用 get() 查询与给定键关联的值,并可以使用 set() 添加新的键/值对。但请记住,map 是一组键,每个键都有一个关联的值。这与一组键/值对并不完全相同。如果你使用一个已经存在于 map 中的键调用 set(),你将改变与该键关联的值,而不是添加一个新的键/值映射。除了 get() 和 set(),Map 类还定义了类似 Set 方法的方法:使用 has() 检查 map 是否包含指定的键;使用 delete() 从 map 中删除一个键(及其关联的值);使用 clear() 从 map 中删除所有键/值对;使用 size 属性查找 map 包含多少个键。
let m = new Map(); // Start with an empty map
m.size // => 0: empty maps have no keys
m.set("one", 1); // Map the key "one" to the value 1
m.set("two", 2); // And the key "two" to the value 2.
m.size // => 2: the map now has two keys
m.get("two") // => 2: return the value associated with key "two"
m.get("three") // => undefined: this key is not in the set
m.set("one", true); // Change the value associated with an existing key
m.size // => 2: the size doesn't change
m.has("one") // => true: the map has a key "one"
m.has(true) // => false: the map does not have a key true
m.delete("one") // => true: the key existed and deletion succeeded
m.size // => 1
m.delete("three") // => false: failed to delete a nonexistent key
m.clear(); // Remove all keys and values from the map
像 Set 的 add() 方法一样,Map 的 set() 方法可以链接,这允许初始化 map 而不使用数组的数组:
let m = new Map().set("one", 1).set("two", 2).set("three", 3);
m.size // => 3
m.get("two") // => 2
与 Set 一样,任何 JavaScript 值都可以用作 Map 中的键或值。这包括 null、undefined 和 NaN,以及对象和数组等引用类型。与 Set 类一样,Map 通过标识比较键,而不是通过相等性比较,因此如果你使用对象或数组作为键,它将被认为与每个其他对象和数组都不同,即使它们具有完全相同的属性或元素:
let m = new Map(); // Start with an empty map.
m.set({}, 1); // Map one empty object to the number 1.
m.set({}, 2); // Map a different empty object to the number 2.
m.size // => 2: there are two keys in this map
m.get({}) // => undefined: but this empty object is not a key
m.set(m, undefined); // Map the map itself to the value undefined.
m.has(m) // => true: m is a key in itself
m.get(m) // => undefined: same value we'd get if m wasn't a key
Map 对象是可迭代的,每个迭代的值都是一个包含两个元素的数组,第一个元素是键,第二个元素是与该键关联的值。如果你使用展开运算符与 Map 对象一起使用,你将得到一个类似于我们传递给 Map() 构造函数的数组的数组。在使用 for/of 循环迭代 map 时,惯用的做法是使用解构赋值将键和值分配给单独的变量:
let m = new Map([["x", 1], ["y", 2]]);
[...m] // => [["x", 1], ["y", 2]]
for(let [key, value] of m) {
// On the first iteration, key will be "x" and value will be 1
// On the second iteration, key will be "y" and value will be 2
}
像 Set 类一样,Map 类按插入顺序进行迭代。迭代的第一个键/值对将是最近添加到 map 中的键/值对,而迭代的最后一个键/值对将是最近添加的键/值对。
如果你想仅迭代 map 的键或仅迭代关联的值,请使用 keys() 和 values() 方法:这些方法返回可迭代对象,按插入顺序迭代键和值。(entries() 方法返回一个可迭代对象,按键/值对迭代,但这与直接迭代 map 完全相同。)
[...m.keys()] // => ["x", "y"]: just the keys
[...m.values()] // => [1, 2]: just the values
[...m.entries()] // => [["x", 1], ["y", 2]]: same as [...m]
Map 对象也可以使用首次由 Array 类实现的 forEach() 方法进行迭代。
m.forEach((value, key) => { // note value, key NOT key, value
// On the first invocation, value will be 1 and key will be "x"
// On the second invocation, value will be 2 and key will be "y"
});
可能会觉得上面的代码中值参数在键参数之前有些奇怪,因为在 for/of 迭代中,键首先出现。正如本节开头所述,你可以将 map 视为一个广义的数组,其中整数数组索引被任意键值替换。数组的 forEach() 方法首先传递数组元素,然后传递数组索引,因此,类比地,map 的 forEach() 方法首先传递 map 值,然后传递 map 键。
11.1.3 WeakMap 和 WeakSet
WeakMap 类是 Map 类的变体(但不是实际的子类),不会阻止其键值被垃圾回收。垃圾回收是 JavaScript 解释器回收不再“可达”的对象内存的过程,这些对象不能被程序使用。常规映射保持对其键值的“强”引用,它们通过映射保持可达性,即使所有对它们的其他引用都消失了。相比之下,WeakMap 对其键值保持“弱”引用,因此它们不可通过 WeakMap 访问,它们在映射中的存在不会阻止其内存被回收。
WeakMap()构造函数与Map()构造函数完全相同,但 WeakMap 和 Map 之间存在一些重要的区别:
-
WeakMap 的键必须是对象或数组;原始值不受垃圾回收的影响,不能用作键。
-
WeakMap 只实现了
get()、set()、has()和delete()方法。特别是,WeakMap 不可迭代,并且不定义keys()、values()或forEach()。如果 WeakMap 是可迭代的,那么它的键将是可达的,它就不会是弱引用的。 -
同样,WeakMap 也不实现
size属性,因为 WeakMap 的大小随时可能会随着对象被垃圾回收而改变。
WeakMap 的预期用途是允许您将值与对象关联而不会导致内存泄漏。例如,假设您正在编写一个函数,该函数接受一个对象参数并需要对该对象执行一些耗时的计算。为了效率,您希望缓存计算后的值以供以后重用。如果使用 Map 对象来实现缓存,将阻止任何对象被回收,但使用 WeakMap,您可以避免这个问题。(您通常可以使用私有 Symbol 属性直接在对象上缓存计算后的值来实现类似的结果。参见§6.10.3。)
WeakSet 实现了一组对象,不会阻止这些对象被垃圾回收。WeakSet()构造函数的工作方式类似于Set()构造函数,但 WeakSet 对象与 Set 对象的区别与 WeakMap 对象与 Map 对象的区别相同:
-
WeakSet 不允许原始值作为成员。
-
WeakSet 只实现了
add()、has()和delete()方法,并且不可迭代。 -
WeakSet 没有
size属性。
WeakSet 并不经常使用:它的用例类似于 WeakMap。如果你想标记(或“品牌化”)一个对象具有某些特殊属性或类型,例如,你可以将其添加到 WeakSet 中。然后,在其他地方,当你想检查该属性或类型时,可以测试该 WeakSet 的成员资格。使用常规集合会阻止所有标记对象被垃圾回收,但使用 WeakSet 时不必担心这个问题。
11.2 类型化数组和二进制数据
常规 JavaScript 数组可以具有任何类型的元素,并且可以动态增长或缩小。JavaScript 实现执行许多优化,使得 JavaScript 数组的典型用法非常快速。然而,它们与低级语言(如 C 和 Java)的数组类型仍然有很大不同。类型化数组是 ES6 中的新功能,³它们更接近这些语言的低级数组。类型化数组在技术上不是数组(Array.isArray()对它们返回false),但它们实现了§7.8 中描述的所有数组方法以及一些自己的方法。然而,它们与常规数组在一些非常重要的方面有所不同:
-
类型化数组的元素都是数字。然而,与常规 JavaScript 数字不同,类型化数组允许您指定要存储在数组中的数字的类型(有符号和无符号整数和 IEEE-754 浮点数)和大小(8 位到 64 位)。
-
创建类型化数组时必须指定其长度,并且该长度永远不会改变。
-
类型化数组的元素在创建数组时始终初始化为 0。
11.2.1 类型化数组类型
JavaScript 没有定义 TypedArray 类。相反,有 11 种类型化数组,每种具有不同的元素类型和构造函数:
| 构造函数 | 数值类型 |
|---|---|
Int8Array() |
有符号字节 |
Uint8Array() |
无符号字节 |
Uint8ClampedArray() |
无溢出的无符号字节 |
Int16Array() |
有符号 16 位短整数 |
Uint16Array() |
无符号 16 位短整数 |
Int32Array() |
有符号 32 位整数 |
Uint32Array() |
无符号 32 位整数 |
BigInt64Array() |
有符号 64 位 BigInt 值(ES2020) |
BigUint64Array() |
无符号 64 位 BigInt 值(ES2020) |
Float32Array() |
32 位浮点值 |
Float64Array() |
64 位浮点值:普通的 JavaScript 数字 |
名称以 Int 开头的类型保存有符号整数,占用 1、2 或 4 字节(8、16 或 32 位)。名称以 Uint 开头的类型保存相同长度的无符号整数。名称为 “BigInt” 和 “BigUint” 的类型保存 64 位整数,以 BigInt 值的形式表示在 JavaScript 中(参见 §3.2.5)。以 Float 开头的类型保存浮点数。Float64Array 的元素与普通的 JavaScript 数字相同类型。Float32Array 的元素精度较低,范围较小,但只需一半的内存。 (在 C 和 Java 中,此类型称为 float。)
Uint8ClampedArray 是 Uint8Array 的特殊变体。这两种类型都保存无符号字节,可以表示 0 到 255 之间的数字。对于 Uint8Array,如果将大于 255 或小于零的值存储到数组元素中,它会“环绕”,并且会得到其他值。这是计算机内存在低级别上的工作原理,因此速度非常快。Uint8ClampedArray 进行了一些额外的类型检查,以便如果存储大于 255 或小于 0 的值,则会“夹紧”到 255 或 0,而不会环绕。 (这种夹紧行为是 HTML <canvas> 元素的低级 API 用于操作像素颜色所必需的。)
每个类型化数组构造函数都有一个 BYTES_PER_ELEMENT 属性,其值为 1、2、4 或 8,取决于类型。
11.2.2 创建类型化数组
创建类型化数组的最简单方法是调用适当的构造函数,并提供一个数字参数,指定数组中要包含的元素数量:
let bytes = new Uint8Array(1024); // 1024 bytes
let matrix = new Float64Array(9); // A 3x3 matrix
let point = new Int16Array(3); // A point in 3D space
let rgba = new Uint8ClampedArray(4); // A 4-byte RGBA pixel value
let sudoku = new Int8Array(81); // A 9x9 sudoku board
通过这种方式创建类型化数组时,数组元素都保证初始化为 0、0n 或 0.0。但是,如果您知道要在类型化数组中使用的值,也可以在创建数组时指定这些值。每个类型化数组构造函数都有静态的 from() 和 of() 工厂方法,类似于 Array.from() 和 Array.of():
let white = Uint8ClampedArray.of(255, 255, 255, 0); // RGBA opaque white
请记住,Array.from() 工厂方法的第一个参数应为类似数组或可迭代对象。对于类型化数组变体也是如此,只是可迭代或类似数组的对象还必须具有数值元素。例如,字符串是可迭代的,但将它们传递给类型化数组的 from() 工厂方法是没有意义的。
如果只使用 from() 的单参数版本,可以省略 .from 并直接将可迭代或类似数组对象传递给构造函数,其行为完全相同。请注意,构造函数和 from() 工厂方法都允许您复制现有的类型化数组,同时可能更改类型:
let ints = Uint32Array.from(white); // The same 4 numbers, but as ints
当从现有数组、可迭代对象或类似数组对象创建新的类型化数组时,值可能会被截断以符合数组的类型约束。当发生这种情况时,不会有警告或错误:
// Floats truncated to ints, longer ints truncated to 8 bits
Uint8Array.of(1.23, 2.99, 45000) // => new Uint8Array([1, 2, 200])
最后,还有一种使用 ArrayBuffer 类型创建 typed arrays 的方法。ArrayBuffer 是一个对一块内存的不透明引用。你可以用构造函数创建一个,只需传入你想要分配的内存字节数:
let buffer = new ArrayBuffer(1024*1024);
buffer.byteLength // => 1024*1024; one megabyte of memory
ArrayBuffer 类不允许你读取或写入你分配的任何字节。但你可以创建使用 buffer 内存的 typed arrays,并且允许你读取和写入该内存。为此,调用 typed array 构造函数,第一个参数是一个 ArrayBuffer,第二个参数是数组缓冲区内的字节偏移量,第三个参数是数组长度(以元素而不是字节计算)。第二和第三个参数是可选的。如果两者都省略,则数组将使用数组缓冲区中的所有内存。如果只省略长度参数,则你的数组将使用从起始位置到数组结束的所有可用内存。关于这种形式的 typed array 构造函数还有一件事要记住:数组必须是内存对齐的,所以如果你指定了一个字节偏移量,该值应该是你的类型大小的倍数。例如,Int32Array() 构造函数需要四的倍数,而 Float64Array() 需要八的倍数。
给定之前创建的 ArrayBuffer,你可以创建这样的 typed arrays:
let asbytes = new Uint8Array(buffer); // Viewed as bytes
let asints = new Int32Array(buffer); // Viewed as 32-bit signed ints
let lastK = new Uint8Array(buffer, 1023*1024); // Last kilobyte as bytes
let ints2 = new Int32Array(buffer, 1024, 256); // 2nd kilobyte as 256 integers
这四种 typed arrays 提供了对由 ArrayBuffer 表示的内存的四种不同视图。重要的是要理解,所有 typed arrays 都有一个底层的 ArrayBuffer,即使你没有明确指定一个。如果你调用一个 typed array 构造函数而没有传递一个 buffer 对象,一个适当大小的 buffer 将会被自动创建。正如后面所描述的,任何 typed array 的 buffer 属性都指向它的底层 ArrayBuffer 对象。直接使用 ArrayBuffer 对象的原因是有时你可能想要有一个单一 buffer 的多个 typed array 视图。
11.2.3 使用 Typed Arrays
一旦你创建了一个 typed array,你可以用常规的方括号表示法读取和写入它的元素,就像你对待任何其他类似数组的对象一样:
// Return the largest prime smaller than n, using the sieve of Eratosthenes
function sieve(n) {
let a = new Uint8Array(n+1); // a[x] will be 1 if x is composite
let max = Math.floor(Math.sqrt(n)); // Don't do factors higher than this
let p = 2; // 2 is the first prime
while(p <= max) { // For primes less than max
for(let i = 2*p; i <= n; i += p) // Mark multiples of p as composite
a[i] = 1;
while(a[++p]) /* empty */; // The next unmarked index is prime
}
while(a[n]) n--; // Loop backward to find the last prime
return n; // And return it
}
这里的函数计算比你指定的数字小的最大质数。代码与使用常规 JavaScript 数组完全相同,但在我的测试中使用 Uint8Array() 而不是 Array() 使代码运行速度超过四倍,并且使用的内存少了八倍。
Typed arrays 不是真正的数组,但它们重新实现了大多数数组方法,所以你可以几乎像使用常规数组一样使用它们:
let ints = new Int16Array(10); // 10 short integers
ints.fill(3).map(x=>x*x).join("") // => "9999999999"
记住,typed arrays 有固定的长度,所以 length 属性是只读的,而改变数组长度的方法(如 push()、pop()、unshift()、shift() 和 splice())对 typed arrays 没有实现。改变数组内容而不改变长度的方法(如 sort()、reverse() 和 fill())是实现的。返回新数组的 map() 和 slice() 等方法返回与调用它们的 typed array 相同类型的 typed array。
11.2.4 Typed Array 方法和属性
除了标准数组方法外,typed arrays 也实现了一些自己的方法。set() 方法通过将常规或 typed array 的元素复制到 typed array 中一次设置多个元素:
let bytes = new Uint8Array(1024); // A 1K buffer
let pattern = new Uint8Array([0,1,2,3]); // An array of 4 bytes
bytes.set(pattern); // Copy them to the start of another byte array
bytes.set(pattern, 4); // Copy them again at a different offset
bytes.set([0,1,2,3], 8); // Or just copy values direct from a regular array
bytes.slice(0, 12) // => new Uint8Array([0,1,2,3,0,1,2,3,0,1,2,3])
set() 方法以数组或 typed array 作为第一个参数,以元素偏移量作为可选的第二个参数,如果未指定则默认为 0。如果你从一个 typed array 复制值到另一个,这个操作可能会非常快。
Typed arrays 还有一个 subarray 方法,返回调用它的数组的一部分:
let ints = new Int16Array([0,1,2,3,4,5,6,7,8,9]); // 10 short integers
let last3 = ints.subarray(ints.length-3, ints.length); // Last 3 of them
last3[0] // => 7: this is the same as ints[7]
subarray()接受与slice()方法相同的参数,并且似乎工作方式相同。但有一个重要的区别。slice()返回一个新的、独立的类型化数组,其中包含指定的元素,不与原始数组共享内存。subarray()不复制任何内存;它只返回相同底层值的新视图:
ints[9] = -1; // Change a value in the original array and...
last3[2] // => -1: it also changes in the subarray
subarray()方法返回现有数组的新视图,这让我们回到了 ArrayBuffers 的话题。每个类型化数组都有三个与底层缓冲区相关的属性:
last3.buffer // The ArrayBuffer object for a typed array
last3.buffer === ints.buffer // => true: both are views of the same buffer
last3.byteOffset // => 14: this view starts at byte 14 of the buffer
last3.byteLength // => 6: this view is 6 bytes (3 16-bit ints) long
last3.buffer.byteLength // => 20: but the underlying buffer has 20 bytes
buffer属性是数组的 ArrayBuffer。byteOffset是数组数据在底层缓冲区中的起始位置。byteLength是数组数据的字节长度。对于任何类型化数组a,这个不变式应该始终成立:
a.length * a.BYTES_PER_ELEMENT === a.byteLength // => true
ArrayBuffer 只是不透明的字节块。您可以使用类型化数组访问这些字节,但 ArrayBuffer 本身不是类型化数组。但要小心:您可以像在任何 JavaScript 对象上一样使用数字数组索引访问 ArrayBuffers。这样做并不会让您访问缓冲区中的字节,但可能会导致混乱的错误:
let bytes = new Uint8Array(8);
bytes[0] = 1; // Set the first byte to 1
bytes.buffer[0] // => undefined: buffer doesn't have index 0
bytes.buffer[1] = 255; // Try incorrectly to set a byte in the buffer
bytes.buffer[1] // => 255: this just sets a regular JS property
bytes[1] // => 0: the line above did not set the byte
我们之前看到,您可以使用ArrayBuffer()构造函数创建一个 ArrayBuffer,然后创建使用该缓冲区的类型化数组。另一种方法是创建一个初始类型化数组,然后使用该数组的缓冲区创建其他视图:
let bytes = new Uint8Array(1024); // 1024 bytes
let ints = new Uint32Array(bytes.buffer); // or 256 integers
let floats = new Float64Array(bytes.buffer); // or 128 doubles
11.2.5 DataView 和字节顺序
类型化数组允许您以 8、16、32 或 64 位的块查看相同的字节序列。这暴露了“字节序”:字节被排列成更长字的顺序。为了效率,类型化数组使用底层硬件的本机字节顺序。在小端系统上,数字的字节从最不重要到最重要的顺序排列在 ArrayBuffer 中。在大端平台上,字节从最重要到最不重要的顺序排列。您可以使用以下代码确定底层平台的字节顺序:
// If the integer 0x00000001 is arranged in memory as 01 00 00 00, then
// we're on a little-endian platform. On a big-endian platform, we'd get
// bytes 00 00 00 01 instead.
let littleEndian = new Int8Array(new Int32Array([1]).buffer)[0] === 1;
如今,最常见的 CPU 架构是小端。然而,许多网络协议和一些二进制文件格式要求大端字节顺序。如果您正在使用来自网络或文件的数据的类型化数组,您不能仅仅假设平台的字节顺序与数据的字节顺序相匹配。一般来说,在处理外部数据时,您可以使用 Int8Array 和 Uint8Array 将数据视为单个字节的数组,但不应使用其他具有多字节字长的类型化数组。相反,您可以使用 DataView 类,该类定义了用于从具有明确定义的字节顺序的 ArrayBuffer 中读取和写入值的方法:
// Assume we have a typed array of bytes of binary data to process. First,
// we create a DataView object so we can flexibly read and write
// values from those bytes
let view = new DataView(bytes.buffer,
bytes.byteOffset,
bytes.byteLength);
let int = view.getInt32(0); // Read big-endian signed int from byte 0
int = view.getInt32(4, false); // Next int is also big-endian
int = view.getUint32(8, true); // Next int is little-endian and unsigned
view.setUint32(8, int, false); // Write it back in big-endian format
DataView 为每个 10 个类型化数组类定义了 10 个get方法(不包括 Uint8ClampedArray)。它们的名称类似于getInt16()、getUint32()、getBigInt64()和getFloat64()。第一个参数是 ArrayBuffer 中数值开始的字节偏移量。除了getInt8()和getUint8()之外,所有这些获取方法都接受一个可选的布尔值作为第二个参数。如果省略第二个参数或为false,则使用大端字节顺序。如果第二个参数为true,则使用小端顺序。
DataView 还定义了 10 个相应的 Set 方法,用于将值写入底层 ArrayBuffer。第一个参数是值开始的偏移量。第二个参数是要写入的值。除了setInt8()和setUint8()之外,每个方法都接受一个可选的第三个参数。如果省略参数或为false,则以大端格式写入值,最重要的字节在前。如果参数为true,则以小端格式写入值,最不重要的字节在前。
类型化数组和 DataView 类为您提供了处理二进制数据所需的所有工具,并使您能够编写执行诸如解压缩 ZIP 文件或从 JPEG 文件中提取元数据等操作的 JavaScript 程序。
11.3 使用正则表达式进行模式匹配
正则表达式是描述文本模式的对象。JavaScript RegExp 类表示正则表达式,String 和 RegExp 都定义了使用正则表达式执行强大的模式匹配和搜索替换功能的方法。然而,为了有效地使用 RegExp API,您还必须学习如何使用正则表达式语法描述文本模式,这本质上是一种自己的迷你编程语言。幸运的是,JavaScript 正则表达式语法与许多其他编程语言使用的语法非常相似,因此您可能已经熟悉它。 (如果您不熟悉,学习 JavaScript 正则表达式所投入的努力可能也对您在其他编程环境中有所帮助。)
接下来的小节首先描述了正则表达式语法,然后,在解释如何编写正则表达式之后,它们解释了如何使用它们与 String 和 RegExp 类的方法。
11.3.1 定义正则表达式
在 JavaScript 中,正则表达式由 RegExp 对象表示。当然,RegExp 对象可以使用RegExp()构造函数创建,但更常见的是使用特殊的字面量语法创建。正如字符串字面量是在引号内指定的字符一样,正则表达式字面量是在一对斜杠(/)字符内指定的字符。因此,您的 JavaScript 代码可能包含如下行:
let pattern = /s$/;
此行创建一个新的 RegExp 对象,并将其赋给变量pattern。这个特定的 RegExp 对象匹配任何以字母“s”结尾的字符串。这个正则表达式也可以用RegExp()构造函数定义,就像这样:
let pattern = new RegExp("s$");
正则表达式模式规范由一系列字符组成。大多数字符,包括所有字母数字字符,只是描述要匹配的字符。因此,正则表达式/java/匹配包含子字符串“java”的任何字符串。正则表达式中的其他字符不是字面匹配的,而是具有特殊意义。例如,正则表达式/s$/包含两个字符。第一个“s”是字面匹配的。第二个“$”是一个特殊的元字符,匹配字符串的结尾。因此,这个正则表达式匹配任何以字母“s”作为最后一个字符的字符串。
正如我们将看到的,正则表达式也可以有一个或多个标志字符,影响它们的工作方式。标志是在 RegExp 文本的第二个斜杠字符后指定的,或者作为RegExp()构造函数的第二个字符串参数。例如,如果我们想匹配以“s”或“S”结尾的字符串,我们可以在正则表达式中使用i标志,表示我们要进行不区分大小写的匹配:
let pattern = /s$/i;
以下各节描述了 JavaScript 正则表达式中使用的各种字符和元字符。
字面字符
所有字母字符和数字在正则表达式中都以字面意义匹配自身。JavaScript 正则表达式语法还支持以反斜杠(\)开头的转义序列表示某些非字母字符。例如,序列\n在字符串中匹配一个字面换行符。表 11-1 列出了这些字符。
表 11-1. 正则表达式字面字符
| 字符 | 匹配 |
|---|---|
| 字母数字字符 | 本身 |
\0 |
NUL 字符(\u0000) |
\t |
制表符(\u0009) |
\n |
换行符(\u000A) |
\v |
垂直制表符(\u000B) |
\f |
换页符(\u000C) |
\r |
回车符(\u000D) |
\xnn |
十六进制数字 nn 指定的拉丁字符;例如,\x0A 等同于 \n。 |
\uxxxx |
十六进制数字 xxxx 指定的 Unicode 字符;例如,\u0009 等同于 \t。 |
\u{n} |
由代码点 n 指定的 Unicode 字符,其中 n 是 0 到 10FFFF 之间的一到六个十六进制数字。请注意,此语法仅在使用 u 标志的正则表达式中受支持。 |
\cX |
控制字符 ^X;例如,\cJ 等同于换行符 \n。 |
许多标点符号在正则表达式中具有特殊含义。它们是:
^ $ . * + ? = ! : | \ / ( ) [ ] { }
这些字符的含义将在接下来的章节中讨论。其中一些字符仅在正则表达式的某些上下文中具有特殊含义,在其他上下文中被视为文字。然而,作为一般规则,如果要在正则表达式中字面包含任何这些标点符号,必须在其前面加上 \。其他标点符号,如引号和 @,没有特殊含义,只是在正则表达式中字面匹配自身。
如果你记不清哪些标点符号需要用反斜杠转义,你可以安全地在任何标点符号前面放置一个反斜杠。另一方面,请注意,许多字母和数字在前面加上反斜杠时具有特殊含义,因此任何你想字面匹配的字母或数字不应该用反斜杠转义。要在正则表达式中字面包含反斜杠字符,当然必须用反斜杠转义它。例如,以下正则表达式匹配包含反斜杠的任意字符串:/\\/。(如果你使用 RegExp() 构造函数,请记住你的正则表达式中的任何反斜杠都需要加倍,因为字符串也使用反斜杠作为转义字符。)
字符类
通过将单个文字字符组合到方括号中,可以形成字符类。字符类匹配其中包含的任意一个字符。因此,正则表达式 /[abc]/ 匹配字母 a、b 或 c 中的任意一个。也可以定义否定字符类;这些匹配除方括号中包含的字符之外的任意字符。否定字符类通过在左方括号内的第一个字符处放置插入符号(^)来指定。正则表达式 /[^abc]/ 匹配除 a、b 或 c 之外的任意一个字符。字符类可以使用连字符指示字符范围。要匹配拉丁字母表中的任意一个小写字母,请使用 /[a-z]/,要匹配拉丁字母表中的任意字母或数字,请使用 /[a-zA-Z0-9]/。(如果要在字符类中包含实际连字符,只需将其放在右方括号之前。)
由于某些字符类通常被使用,JavaScript 正则表达式语法包括特殊字符和转义序列来表示这些常见类。例如,\s 匹配空格字符、制表符和任何其他 Unicode 空白字符;\S 匹配任何非 Unicode 空白字符。表 11-2 列出了这些字符并总结了字符类语法。(请注意,其中几个字符类转义序列仅匹配 ASCII 字符,并未扩展为适用于 Unicode 字符。但是,你可以显式定义自己的 Unicode 字符类;例如,/[\u0400-\u04FF]/ 匹配任意一个西里尔字母字符。)
表格 11-2. 正则表达式字符类
| 字符 | 匹配 |
|---|---|
[...] |
方括号内的任意一个字符。 |
[^...] |
方括号内的任意一个字符。 |
. |
除换行符或其他 Unicode 行终止符之外的任何字符。或者,如果 RegExp 使用 s 标志,则句点匹配任何字符,包括行终止符。 |
\w |
任何 ASCII 单词字符。等同于 [a-zA-Z0-9_]。 |
\W |
任何不是 ASCII 单词字符的字符。等同于 [^a-zA-Z0-9_]。 |
\s |
任何 Unicode 空白字符。 |
\S |
任何不是 Unicode 空白字符的字符。 |
\d |
任何 ASCII 数字。等同于 [0-9]。 |
\D |
任何 ASCII 数字之外的字符。等同于 [⁰-9]。 |
[\b] |
一个字面退格(特殊情况)。 |
请注意,特殊字符类转义可以在方括号内使用。\s 匹配任何空白字符,\d 匹配任何数字,因此 /[\s\d]/ 匹配任何一个空白字符或数字。请注意有一个特殊情况。正如稍后将看到的,\b 转义具有特殊含义。但是,在字符类中使用时,它表示退格字符。因此,要在正则表达式中字面表示退格字符,请使用具有一个元素的字符类:/[\b]/。
重复
到目前为止,您学到的正则表达式语法可以将两位数描述为 /\d\d/,将四位数描述为 /\d\d\d\d/。但是,您没有任何方法来描述,例如,可以具有任意数量的数字或三个字母后跟一个可选数字的字符串。这些更复杂的模式使用指定正则表达式元素可以重复多少次的正则表达式语法。
指定重复的字符始终跟随其应用的模式。由于某些类型的重复非常常见,因此有特殊字符来表示这些情况。例如,+ 匹配前一个模式的一个或多个出现。
表 11-3 总结了重复语法。
表 11-3. 正则表达式重复字符
| 字符 | 含义 |
|---|---|
{n,m} |
匹配前一个项目至少 n 次但不超过 m 次。 |
{n,} |
匹配前一个项目 n 次或更多次。 |
{n} |
匹配前一个项目的 n 次出现。 |
? |
匹配前一个项目的零次或一次出现。也就是说,前一个项目是可选的。等同于 {0,1}。 |
+ |
匹配前一个项目的一个或多个出现。等同于 {1,}。 |
* |
匹配前一个项目的零次或多次。等同于 {0,}。 |
以下行显示了一些示例:
let r = /\d{2,4}/; // Match between two and four digits
r = /\w{3}\d?/; // Match exactly three word characters and an optional digit
r = /\s+java\s+/; // Match "java" with one or more spaces before and after
r = /[^(]*/; // Match zero or more characters that are not open parens
请注意,在所有这些示例中,重复说明符应用于它们之前的单个字符或字符类。如果要匹配更复杂表达式的重复,您需要使用括号定义一个组,这将在以下部分中解释。
使用 * 和 ? 重复字符时要小心。由于这些字符可能匹配前面的内容的零次,它们允许匹配空内容。例如,正则表达式 /a*/ 实际上匹配字符串“bbbb”,因为该字符串不包含字母 a 的任何出现!
非贪婪重复
表 11-3 中列出的重复字符尽可能多次匹配,同时仍允许正则表达式的任何后续部分匹配。我们说这种重复是“贪婪的”。还可以指定以非贪婪方式进行重复。只需在重复字符后面跟一个问号:??、+?、*?,甚至 {1,5}?。例如,正则表达式 /a+/ 匹配一个或多个字母 a 的出现。当应用于字符串“aaa”时,它匹配所有三个字母。但是 /a+?/ 匹配一个或多个字母 a 的出现,尽可能少地匹配字符。当应用于相同字符串时,此模式仅匹配第一个字母 a。
使用非贪婪重复可能不总是产生您期望的结果。考虑模式/a+b/,它匹配一个或多个 a,后跟字母 b。当应用于字符串“aaab”时,它匹配整个字符串。现在让我们使用非贪婪版本:/a+?b/。这应该匹配由尽可能少的 a 前导的字母 b。当应用于相同字符串“aaab”时,您可能希望它仅匹配一个 a 和最后一个字母 b。但实际上,此模式与贪婪版本的模式一样匹配整个字符串。这是因为正则表达式模式匹配是通过找到字符串中可能发生匹配的第一个位置来完成的。由于从字符串的第一个字符开始就可能发生匹配,因此从后续字符开始的较短匹配甚至不会被考虑。
备选项、分组和引用
正则表达式语法包括用于指定备选项、分组子表达式和引用先前子表达式的特殊字符。|字符分隔备选项。例如,/ab|cd|ef/匹配字符串“ab”或字符串“cd”或字符串“ef”。而/\d{3}|[a-z]{4}/匹配三个数字或四个小写字母中的任何一个。
请注意,备选项从左到右考虑,直到找到匹配项。如果左侧备选项匹配,则右侧备选项将被忽略,即使它可能产生“更好”的匹配。因此,当将模式/a|ab/应用于字符串“ab”时,它仅匹配第一个字母。
括号在正则表达式中有几个目的。一个目的是将单独的项目分组为单个子表达式,以便可以通过|、*、+、?等将项目视为单个单元。例如,/java(script)?/匹配“java”后跟可选的“script”。而/(ab|cd)+|ef/匹配字符串“ef”或一个或多个重复的字符串“ab”或“cd”中的任何一个。
正则表达式中括号的另一个目的是在完整模式内定义子模式。当正则表达式成功匹配目标字符串时,可以提取匹配任何特定括号子模式的目标字符串部分。(您将在本节后面看到如何获取这些匹配的子字符串。)例如,假设您正在寻找一个或多个小写字母后跟一个或多个数字。您可能会使用模式/[a-z]+\d+/。但是假设您只关心每个匹配末尾的数字。如果将模式的这部分放在括号中(/[a-z]+(\d+)/),您可以提取任何找到的匹配中的数字,如后面所述。
括号子表达式的一个相关用途是允许您在同一正则表达式中稍后引用子表达式。这是通过在\字符后跟一个或多个数字来完成的。这些数字指的是正则表达式中括号子表达式的位置。例如,\1引用第一个子表达式,\3引用第三个。请注意,由于子表达式可以嵌套在其他子表达式中,因此计算的是左括号的位置。例如,在以下正则表达式中,嵌套的子表达式([Ss]cript)被称为\2:
/([Jj]ava([Ss]cript)?)\sis\s(fun\w*)/
对正则表达式的先前子表达式的引用不是指该子表达式的模式,而是指匹配该模式的文本。因此,引用可用于强制要求字符串的不同部分包含完全相同的字符。例如,以下正则表达式匹配单引号或双引号内的零个或多个字符。但是,它不要求开头和结尾引号匹配(即,都是单引号或双引号):
/['"][^'"]*['"]/
要求引号匹配,请使用引用:
/(['"])[^'"]*\1/
\1匹配第一个括号子表达式匹配的内容。在此示例中,它强制约束闭合引号与开放引号匹配。此正则表达式不允许单引号在双引号字符串内部,反之亦然。(在字符类内部使用引用是不合法的,因此不能写成:/(['"])[^\1]*\1/。)
当我们稍后讨论 RegExp API 时,您会看到对括号子表达式的引用是正则表达式搜索和替换操作的一个强大功能。
也可以在正则表达式中分组项目而不创建对这些项目的编号引用。不要简单地在(和)内部分组项目,而是从(?:开始组,以)结束。考虑以下模式:
/([Jj]ava(?:[Ss]cript)?)\sis\s(fun\w*)/
在此示例中,子表达式(?:[Ss]cript)仅用于分组,因此?重复字符可以应用于该组。这些修改后的括号不生成引用,因此在此正则表达式中,\2指的是由(fun\w*)匹配的文本。
表格 11-4 总结了正则表达式的交替、分组和引用操作符。
表格 11-4. 正则表达式的交替、分组和引用字符
| 字符 | 含义 |
|---|---|
| |
交替:匹配左侧子表达式或右侧子表达式。 |
(...) |
分组:将项目分组为一个单元,可以与*、+、?、|等一起使用。还要记住匹配此组的字符,以便后续引用。 |
(?:...) |
仅分组:将项目分组为一个单元,但不记住匹配此组的字符。 |
\n |
匹配在第一次匹配组号 n 时匹配的相同字符。组是括号内的子表达式(可能是嵌套的)。组号是通过从左到右计算左括号来分配的。使用(?:形成的组不编号。 |
指定匹配位置
正如前面所述,正则表达式的许多元素匹配字符串中的单个字符。例如,\s匹配单个空白字符。其他正则表达式元素匹配字符之间的位置而不是实际字符。例如,\b匹配 ASCII 单词边界——\w(ASCII 单词字符)和\W(非单词字符)之间的边界,或者 ASCII 单词字符和字符串的开头或结尾之间的边界。[⁴] 元素如\b不指定要在匹配的字符串中使用的任何字符;但它们指定的是合法的匹配位置。有时这些元素被称为正则表达式锚点,因为它们将模式锚定到搜索字符串中的特定位置。最常用的锚定元素是^,将模式绑定到字符串的开头,以及$,将模式锚定到字符串的结尾。
例如,要匹配单独一行的单词“JavaScript”,可以使用正则表达式/^JavaScript$/。如果要搜索“Java”作为单独的单词(而不是作为“JavaScript”中的前缀),可以尝试模式/\sJava\s/,这需要单词前后有空格。但是这种解决方案有两个问题。首先,它不匹配字符串的开头或结尾的“Java”,而只有在两侧有空格时才匹配。其次,当此模式找到匹配时,返回的匹配字符串具有前导和尾随空格,这不是所需的。因此,与其用\s匹配实际空格字符,不如用\b匹配(或锚定)单词边界。得到的表达式是/\bJava\b/。元素\B将匹配锚定到不是单词边界的位置。因此,模式/\B[Ss]cript/匹配“JavaScript”和“postscript”,但不匹配“script”或“Scripting”。
您还可以使用任意正则表达式作为锚定条件。如果在(?=和)字符之间包含一个表达式,那么这是一个前瞻断言,并且它指定封闭字符必须匹配,而不实际匹配它们。例如,要匹配一个常见编程语言的名称,但只有在后面跟着一个冒号时,您可以使用/[Jj]ava([Ss]cript)?(?=\:)/。这个模式匹配“JavaScript”中的单词“JavaScript: The Definitive Guide”,但不匹配“Java in a Nutshell”中的“Java”,因为它后面没有跟着冒号。
如果您使用(?!引入断言,那么这是一个负向前瞻断言,指定接下来的字符不得匹配。例如,/Java(?!Script)([A-Z]\w*)/匹配“Java”后跟一个大写字母和任意数量的其他 ASCII 单词字符,只要“Java”后面不跟着“Script”。它匹配“JavaBeans”但不匹配“Javanese”,它匹配“JavaScrip”但不匹配“JavaScript”或“JavaScripter”。表 11-5 总结了正则表达式锚点。
表 11-5. 正则表达式锚点字符
| 字符 | 含义 |
|---|---|
^ |
匹配字符串的开头或者在使用m标志时,匹配行的开头。 |
| ` | 字符 |
| --- | --- |
^ |
匹配字符串的开头或者在使用m标志时,匹配行的开头。 |
匹配字符串的结尾,并且在使用m标志时,匹配行的结尾。 |
|
\b |
匹配单词边界。也就是说,匹配\w字符和\W字符之间的位置,或者匹配\w字符和字符串的开头或结尾之间的位置。(但请注意,[\b]匹配退格键。) |
\B |
匹配不是单词边界的位置。 |
(?=p) |
正向前瞻断言。要求接下来的字符匹配模式p,但不包括这些字符在匹配中。 |
(?!p) |
负向前瞻断言。要求接下来的字符不匹配模式p。 |
标志
每个正则表达式都可以有一个或多个与之关联的标志,以改变其匹配行为。JavaScript 定义了六个可能的标志,每个标志由一个字母表示。标志在正则表达式字面量的第二个/字符之后指定,或者作为传递给RegExp()构造函数的第二个参数的字符串。支持的标志及其含义如下:
g
g标志表示正则表达式是“全局”的,也就是说,我们打算在字符串中找到所有匹配项,而不仅仅是找到第一个匹配项。这个标志不会改变匹配模式的方式,但正如我们稍后将看到的,它确实以重要的方式改变了 String match()方法和 RegExp exec()方法的行为。
i
i标志指定匹配模式时应该忽略大小写。
m
m标志指定匹配应该在“多行”模式下进行。它表示正则表达式将与多行字符串一起使用,并且^和$锚点应该匹配字符串的开头和结尾,以及字符串中各行的开头和结尾。
s
与m标志类似,s标志在处理包含换行符的文本时也很有用。通常,正则表达式中的“.”匹配除行终止符之外的任何字符。但是,当使用s标志时,“.”将匹配任何字符,包括行终止符。s标志在 ES2018 中添加到 JavaScript 中,并且截至 2020 年初,在 Node、Chrome、Edge 和 Safari 中支持,但在 Firefox 中不支持。
u
u标志代表 Unicode,它使正则表达式匹配完整的 Unicode 代码点,而不是匹配 16 位值。这个标志是在 ES6 中引入的,你应该养成在所有正则表达式上使用它的习惯,除非你有某种理由不这样做。如果你不使用这个标志,那么你的正则表达式将无法很好地处理包含表情符号和其他需要超过 16 位的字符(包括许多中文字符)的文本。没有u标志,"."字符匹配任何 1 个 UTF-16 16 位值。然而,有了这个标志,"."匹配一个 Unicode 代码点,包括那些超过 16 位的代码点。在正则表达式上设置u标志还允许你使用新的\u{...}转义序列来表示 Unicode 字符,并且还启用了\p{...}表示 Unicode 字符类。
y
y标志表示正则表达式是“粘性”的,应该在字符串的开头或上一个匹配项后的第一个字符处匹配。当与旨在找到单个匹配项的正则表达式一起使用时,它有效地将该正则表达式视为以^开头以将其锚定到字符串开头。这个标志在重复使用用于在字符串中找到所有匹配项的正则表达式时更有用。在这种情况下,它导致 String match()方法和 RegExp exec()方法的特殊行为,以强制每个后续匹配项都锚定到上一个匹配项结束的字符串位置。
这些标志可以以任何组合和任何顺序指定。例如,如果你希望你的正则表达式能够识别 Unicode 以进行不区分大小写的匹配,并且打算在字符串中查找多个匹配项,你可以指定标志uig,gui或这三个字母的任何其他排列。
11.3.2 用于模式匹配的字符串方法
到目前为止,我们一直在描述用于定义正则表达式的语法,但没有解释这些正则表达式如何在 JavaScript 代码中实际使用。我们现在转而介绍使用 RegExp 对象的 API。本节首先解释了使用正则表达式执行模式匹配和搜索替换操作的字符串方法。接下来的部分将继续讨论使用 JavaScript 正则表达式进行模式匹配,讨论 RegExp 对象及其方法和属性。
search()
字符串支持四种使用正则表达式的方法。最简单的是search()。这个方法接受一个正则表达式参数,并返回第一个匹配子字符串的起始字符位置,如果没有匹配则返回-1:
"JavaScript".search(/script/ui) // => 4
"Python".search(/script/ui) // => -1
如果search()的参数不是正则表达式,则首先通过将其传递给RegExp构造函数将其转换为正则表达式。search()不支持全局搜索;它会忽略其正则表达式参数的g标志。
replace()
replace()方法执行搜索替换操作。它将正则表达式作为第一个参数,替换字符串作为第二个参数。它在调用它的字符串中搜索与指定模式匹配的内容。如果正则表达式设置了g标志,replace()方法将在字符串中替换所有匹配项为替换字符串;否则,它只会替换找到的第一个匹配项。如果replace()的第一个参数是一个字符串而不是正则表达式,该方法会直接搜索该字符串而不是像search()那样将其转换为正则表达式。例如,你可以使用replace()如下提供文本字符串中“JavaScript”一词的统一大写格式:
// No matter how it is capitalized, replace it with the correct capitalization
text.replace(/javascript/gi, "JavaScript");
然而,replace()比这更强大。回想一下,正则表达式的括号子表达式从左到右编号,并且正则表达式记住了每个子表达式匹配的文本。如果替换字符串中出现了$后跟一个数字,replace()将用指定子表达式匹配的文本替换这两个字符。这是一个非常有用的功能。例如,你可以使用它将字符串中的引号替换为其他字符:
// A quote is a quotation mark, followed by any number of
// nonquotation mark characters (which we capture), followed
// by another quotation mark.
let quote = /"([^"]*)"/g;
// Replace the straight quotation marks with guillemets
// leaving the quoted text (stored in $1) unchanged.
'He said "stop"'.replace(quote, '«$1»') // => 'He said «stop»'
如果你的正则表达式使用了命名捕获组,那么你可以通过名称而不是数字引用匹配的文本:
let quote = /"(?<quotedText>[^"]*)"/g;
'He said "stop"'.replace(quote, '«$<quotedText>»') // => 'He said «stop»'
不需要将替换字符串作为第二个参数传递给replace(),你也可以传递一个函数作为替换值的计算方法。替换函数会被调用并传入多个参数。首先是整个匹配的文本。接下来,如果正则表达式有捕获组,那么被这些组捕获的子字符串将作为参数传递。下一个参数是匹配被找到的字符串中的位置。之后,调用replace()的整个字符串也会被传递。最后,如果正则表达式包含任何命名捕获组,替换函数的最后一个参数是一个对象,其属性名与捕获组名匹配,值为匹配的文本。例如,这里是使用替换函数将字符串中的十进制整数转换为十六进制的代码:
let s = "15 times 15 is 225";
s.replace(/\d+/gu, n => parseInt(n).toString(16)) // => "f times f is e1"
match()
match()方法是 String 正则表达式方法中最通用的。它将正则表达式作为唯一参数(或通过将其传递给RegExp()构造函数将其参数转换为正则表达式)并返回一个包含匹配结果的数组,如果没有找到匹配则返回null。如果正则表达式设置了g标志,该方法将返回出现在字符串中的所有匹配项的数组。例如:
"7 plus 8 equals 15".match(/\d+/g) // => ["7", "8", "15"]
如果正则表达式没有设置g标志,match()不会进行全局搜索;它只是搜索第一个匹配项。在这种非全局情况下,match()仍然返回一个数组,但数组元素完全不同。没有g标志时,返回数组的第一个元素是匹配的字符串,任何剩余的元素是正则表达式中括号捕获组匹配的子字符串。因此,如果match()返回一个数组a,a[0]包含完整匹配,a[1]包含匹配第一个括号表达式的子字符串,依此类推。与replace()方法类比,a[1]与$1相同,a[2]与$2相同,依此类推。
例如,考虑使用以下代码解析 URL⁵:
// A very simple URL parsing RegExp
let url = /(\w+):\/\/([\w.]+)\/(\S*)/;
let text = "Visit my blog at http://www.example.com/~david";
let match = text.match(url);
let fullurl, protocol, host, path;
if (match !== null) {
fullurl = match[0]; // fullurl == "http://www.example.com/~david"
protocol = match[1]; // protocol == "http"
host = match[2]; // host == "www.example.com"
path = match[3]; // path == "~david"
}
在这种非全局情况下,match()返回的数组除了编号数组元素外还有一些对象属性。input属性指的是调用match()的字符串。index属性是匹配开始的字符串位置。如果正则表达式包含命名捕获组,那么返回的数组还有一个groups属性,其值是一个对象。这个对象的属性与命名组的名称匹配,值为匹配的文本。例如,我们可以像这样重新编写之前的 URL 解析示例:
let url = /(?<protocol>\w+):\/\/(?<host>[\w.]+)\/(?<path>\S*)/;
let text = "Visit my blog at http://www.example.com/~david";
let match = text.match(url);
match[0] // => "http://www.example.com/~david"
match.input // => text
match.index // => 17
match.groups.protocol // => "http"
match.groups.host // => "www.example.com"
match.groups.path // => "~david"
我们已经看到,match() 的行为在正则表达式是否设置了 g 标志时会有很大不同。当设置了 y 标志时,行为也会有重要但不那么显著的差异。请记住,y 标志通过限制匹配开始的位置使正则表达式“粘滞”。如果一个正则表达式同时设置了 g 和 y 标志,那么 match() 返回一个匹配字符串的数组,就像在设置了 g 而没有设置 y 时一样。但第一个匹配必须从字符串的开头开始,每个后续匹配必须从前一个匹配的字符紧随其后开始。
如果设置了 y 标志但没有设置 g,那么 match() 会尝试找到单个匹配,并且默认情况下,此匹配受限于字符串的开头。然而,您可以通过设置 RegExp 对象的 lastIndex 属性来更改此默认匹配开始位置,指定要匹配的索引位置。如果找到匹配,那么 lastIndex 将自动更新为匹配后的第一个字符,因此如果再次调用 match(),它将寻找下一个匹配。(lastIndex 可能看起来是一个奇怪的属性名称,它指定开始 下一个 匹配的位置。当我们讨论 RegExp exec() 方法时,我们将再次看到它,这个名称在那种情况下可能更有意义。)
let vowel = /[aeiou]/y; // Sticky vowel match
"test".match(vowel) // => null: "test" does not begin with a vowel
vowel.lastIndex = 1; // Specify a different match position
"test".match(vowel)[0] // => "e": we found a vowel at position 1
vowel.lastIndex // => 2: lastIndex was automatically updated
"test".match(vowel) // => null: no vowel at position 2
vowel.lastIndex // => 0: lastIndex gets reset after failed match
值得注意的是,将非全局正则表达式传递给字符串的 match() 方法与将字符串传递给正则表达式的 exec() 方法是相同的:返回的数组及其属性在这两种情况下都是相同的。
matchAll()
matchAll() 方法在 ES2020 中定义,并且在 2020 年初已被现代 Web 浏览器和 Node 实现。matchAll() 期望一个设置了 g 标志的正则表达式。然而,与 match() 返回匹配子字符串的数组不同,它返回一个迭代器,该迭代器产生与使用非全局 RegExp 时 match() 返回的匹配对象相同的对象。这使得 matchAll() 成为遍历字符串中所有匹配的最简单和最通用的方法。
您可以使用 matchAll() 遍历文本字符串中的单词,如下所示:
// One or more Unicode alphabetic characters between word boundaries
const words = /\b\p{Alphabetic}+\b/gu; // \p is not supported in Firefox yet
const text = "This is a naïve test of the matchAll() method.";
for(let word of text.matchAll(words)) {
console.log(`Found '${word[0]}' at index ${word.index}.`);
}
您可以设置 RegExp 对象的 lastIndex 属性,告诉 matchAll() 在字符串中的哪个索引开始匹配。然而,与其他模式匹配方法不同,matchAll() 永远不会修改您调用它的 RegExp 的 lastIndex 属性,这使得它在您的代码中更不容易出错。
split()
String 对象的正则表达式方法中的最后一个是 split()。这个方法将调用它的字符串分割成一个子字符串数组,使用参数作为分隔符。它可以像这样使用一个字符串参数:
"123,456,789".split(",") // => ["123", "456", "789"]
split() 方法也可以接受正则表达式作为参数,这样可以指定更通用的分隔符。在这里,我们使用一个包含任意数量空白的分隔符来调用它:
"1, 2, 3,\n4, 5".split(/\s*,\s*/) // => ["1", "2", "3", "4", "5"]
令人惊讶的是,如果你使用包含捕获组的正则表达式分隔符调用 split(),那么匹配捕获组的文本将包含在返回的数组中。例如:
const htmlTag = /<([^>]+)>/; // < followed by one or more non->, followed by >
"Testing<br/>1,2,3".split(htmlTag) // => ["Testing", "br/", "1,2,3"]
11.3.3 RegExp 类
本节介绍了 RegExp() 构造函数、RegExp 实例的属性以及 RegExp 类定义的两个重要模式匹配方法。
RegExp() 构造函数接受一个或两个字符串参数,并创建一个新的 RegExp 对象。这个构造函数的第一个参数是一个包含正则表达式主体的字符串——在正则表达式字面量中出现在斜杠内的文本。请注意,字符串字面量和正则表达式都使用 \ 字符作为转义序列,因此当您将正则表达式作为字符串字面量传递给 RegExp() 时,必须将每个 \ 字符替换为 \\。RegExp() 的第二个参数是可选的。如果提供,它表示正则表达式的标志。它应该是 g、i、m、s、u、y,或这些字母的任意组合。
例如:
// Find all five-digit numbers in a string. Note the double \\ in this case.
let zipcode = new RegExp("\\d{5}", "g");
RegExp() 构造函数在动态创建正则表达式时非常有用,因此无法使用正则表达式字面量语法表示。例如,要搜索用户输入的字符串,必须在运行时使用 RegExp() 创建正则表达式。
除了将字符串作为 RegExp() 的第一个参数传递之外,您还可以传递一个 RegExp 对象。这允许您复制正则表达式并更改其标志:
let exactMatch = /JavaScript/;
let caseInsensitive = new RegExp(exactMatch, "i");
RegExp 属性
RegExp 对象具有以下属性:
source
这是正则表达式的源文本的只读属性:在 RegExp 字面量中出现在斜杠之间的字符。
flags
这是一个只读属性,指定表示 RegExp 标志的字母集合的字符串。
global
一个只读的布尔属性,如果设置了 g 标志,则为 true。
ignoreCase
一个只读的布尔属性,如果设置了 i 标志,则为 true。
multiline
一个只读的布尔属性,如果设置了 m 标志,则为 true。
dotAll
一个只读的布尔属性,如果设置了 s 标志,则为 true。
unicode
一个只读的布尔属性,如果设置了 u 标志,则为 true。
sticky
一个只读的布尔属性,如果设置了 y 标志,则为 true。
lastIndex
这个属性是一个读/写整数。对于具有 g 或 y 标志的模式,它指定下一次搜索开始的字符位置。它由 exec() 和 test() 方法使用,这两个方法在下面的两个小节中描述。
test()
RegExp 类的 test() 方法是使用正则表达式的最简单的方法。它接受一个字符串参数,并在字符串与模式匹配时返回 true,否则返回 false。
test() 的工作原理是简单地调用(更复杂的)下一节中描述的 exec() 方法,并在 exec() 返回非空值时返回 true。因此,如果您使用带有 g 或 y 标志的 RegExp 来使用 test(),那么它的行为取决于 RegExp 对象的 lastIndex 属性的值,这个值可能会意外更改。有关更多详细信息,请参阅“lastIndex 属性和 RegExp 重用”。
exec()
RegExp exec() 方法是使用正则表达式的最通用和强大的方式。它接受一个字符串参数,并在该字符串中查找匹配项。如果找不到匹配项,则返回 null。但是,如果找到匹配项,则返回一个数组,就像对于非全局搜索的 match() 方法返回的数组一样。数组的第 0 个元素包含与正则表达式匹配的字符串,任何后续的数组元素包含与任何捕获组匹配的子字符串。返回的数组还具有命名属性:index 属性包含匹配发生的字符位置,input 属性指定被搜索的字符串,如果定义了 groups 属性,则指的是一个保存与任何命名捕获组匹配的子字符串的对象。
与 String 的 match() 方法不同,exec() 无论正则表达式是否有全局 g 标志,都返回相同类型的数组。回想一下,当传递一个全局正则表达式时,match() 返回一个匹配数组。相比之下,exec() 总是返回一个单一匹配,并提供关于该匹配的完整信息。当在具有全局 g 标志或粘性 y 标志的正则表达式上调用 exec() 时,它会查看 RegExp 对象的 lastIndex 属性,以确定从哪里开始查找匹配。如果设置了 y 标志,它还会限制匹配从该位置开始。对于新创建的 RegExp 对象,lastIndex 为 0,并且搜索从字符串的开头开始。但每次 exec() 成功找到一个匹配时,它会更新 lastIndex 属性为匹配文本后面的字符的索引。如果 exec() 未找到匹配,它会将 lastIndex 重置为 0。这种特殊行为允许你重复调用 exec() 以循环遍历字符串中的所有正则表达式匹配。例如,以下代码中的循环将运行两次:
let pattern = /Java/g;
let text = "JavaScript > Java";
let match;
while((match = pattern.exec(text)) !== null) {
console.log(`Matched ${match[0]} at ${match.index}`);
console.log(`Next search begins at ${pattern.lastIndex}`);
}
11.4 日期和时间
Date 类是 JavaScript 用于处理日期和时间的 API。使用 Date() 构造函数创建一个 Date 对象。如果没有参数,它会返回一个代表当前日期和时间的 Date 对象:
let now = new Date(); // The current time
如果你传递一个数字参数,Date() 构造函数会将该参数解释为自 1970 年起的毫秒数:
let epoch = new Date(0); // Midnight, January 1st, 1970, GMT
如果你指定两个或更多整数参数,它们会被解释为年、月、日、小时、分钟、秒和毫秒,使用你的本地时区,如下所示:
let century = new Date(2100, // Year 2100
0, // January
1, // 1st
2, 3, 4, 5); // 02:03:04.005, local time
Date API 的一个怪癖是,一年中的第一个月是数字 0,但一个月中的第一天是数字 1。如果省略时间字段,Date() 构造函数会将它们全部默认为 0,将时间设置为午夜。
请注意,当使用多个数字调用 Date() 构造函数时,它会使用本地计算机设置的任何时区进行解释。如果你想在 UTC(协调世界时,又称 GMT)中指定日期和时间,那么你可以使用 Date.UTC()。这个静态方法接受与 Date() 构造函数相同的参数,在 UTC 中解释它们,并返回一个毫秒时间戳,你可以传递给 Date() 构造函数:
// Midnight in England, January 1, 2100
let century = new Date(Date.UTC(2100, 0, 1));
如果你打印一个日期(例如使用 console.log(century)),默认情况下会以你的本地时区打印。如果你想在 UTC 中显示一个日期,你应该明确地将其转换为字符串,使用 toUTCString() 或 toISOString()。
最后,如果你将一个字符串传递给 Date() 构造函数,它将尝试将该字符串解析为日期和时间规范。构造函数可以解析由 toString()、toUTCString() 和 toISOString() 方法生成的格式指定的日期:
let century = new Date("2100-01-01T00:00:00Z"); // An ISO format date
一旦你有了一个 Date 对象,各种获取和设置方法允许你查询和修改 Date 的年、月、日、小时、分钟、秒和毫秒字段。每个方法都有两种形式:一种使用本地时间进行获取或设置,另一种使用 UTC 时间进行获取或设置。例如,要获取或设置 Date 对象的年份,你可以使用 getFullYear()、getUTCFullYear()、setFullYear() 或 setUTCFullYear():
let d = new Date(); // Start with the current date
d.setFullYear(d.getFullYear() + 1); // Increment the year
要获取或设置 Date 的其他字段,将方法名称中的“FullYear”替换为“Month”、“Date”、“Hours”、“Minutes”、“Seconds”或“Milliseconds”。一些日期设置方法允许你一次设置多个字段。setFullYear() 和 setUTCFullYear() 还可选择设置月份和日期。而 setHours() 和 setUTCHours() 还允许你指定分钟、秒和毫秒字段,除了小时字段。
请注意,查询日期的方法是getDate()和getUTCDate()。更自然的函数getDay()和getUTCDay()返回星期几(星期日为 0,星期六为 6)。星期几是只读的,因此没有相应的setDay()方法。
11.4.1 时间戳
JavaScript 将日期内部表示为整数,指定自 1970 年 1 月 1 日午夜(或之前)以来的毫秒数。支持的整数最大为 8,640,000,000,000,000,因此 JavaScript 在 270,000 年后不会用尽毫秒。
对于任何日期对象,getTime()方法返回内部值,而setTime()方法设置它。因此,您可以像这样为日期添加 30 秒:
d.setTime(d.getTime() + 30000);
这些毫秒值有时被称为时间戳,直接使用它们而不是 Date 对象有时很有用。静态的Date.now()方法返回当前时间作为时间戳,当您想要测量代码运行时间时很有帮助:
let startTime = Date.now();
reticulateSplines(); // Do some time-consuming operation
let endTime = Date.now();
console.log(`Spline reticulation took ${endTime - startTime}ms.`);
11.4.2 日期算术
可以使用 JavaScript 的标准<、<=、>和>=比较运算符比较日期对象。您可以从一个日期对象中减去另一个日期对象以确定两个日期之间的毫秒数。(这是因为 Date 类定义了一个返回时间戳的valueOf()方法。)
如果要从日期中添加或减去指定数量的秒、分钟或小时,通常最简单的方法是修改时间戳,就像前面示例中添加 30 秒到日期一样。如果要添加天数,这种技术变得更加繁琐,对于月份和年份则根本不起作用,因为它们的天数不同。要进行涉及天数、月份和年份的日期算术,可以使用setDate()、setMonth()和setYear()。例如,以下是将三个月和两周添加到当前日期的代码:
let d = new Date();
d.setMonth(d.getMonth() + 3, d.getDate() + 14);
即使溢出,日期设置方法也能正常工作。当我们向当前月份添加三个月时,可能得到大于 11 的值(代表 12 月)。setMonth()通过根据需要递增年份来处理这一点。同样,当我们将月份的日期设置为大于该月份天数的值时,月份会适当递增。
11.4.3 格式化和解析日期字符串
如果您使用 Date 类实际跟踪日期和时间(而不仅仅是测量时间间隔),那么您可能需要向代码的用户显示日期和时间。Date 类定义了许多不同的方法来将 Date 对象转换为字符串。以下是一些示例:
let d = new Date(2020, 0, 1, 17, 10, 30); // 5:10:30pm on New Year's Day 2020
d.toString() // => "Wed Jan 01 2020 17:10:30 GMT-0800 (Pacific Standard Time)"
d.toUTCString() // => "Thu, 02 Jan 2020 01:10:30 GMT"
d.toLocaleDateString() // => "1/1/2020": 'en-US' locale
d.toLocaleTimeString() // => "5:10:30 PM": 'en-US' locale
d.toISOString() // => "2020-01-02T01:10:30.000Z"
这是 Date 类的字符串格式化方法的完整列表:
toString()
此方法使用本地时区,但不以区域感知方式格式化日期和时间。
toUTCString()
此方法使用 UTC 时区,但不以区域感知方式格式化日期。
toISOString()
此方法以 ISO-8601 标准的标准年-月-日小时:分钟:秒.ms 格式打印日期和时间。字母“T”将输出的日期部分与时间部分分开。时间以 UTC 表示,并且最后一个字母“Z”表示这一点。
toLocaleString()
此方法使用本地时区和适合用户区域的格式。
toDateString()
此方法仅格式化日期部分并省略时间。它使用本地时区,不进行区域适当的格式化。
toLocaleDateString()
此方法仅格式化日期。它使用本地时区和适合区域的日期格式。
toTimeString()
此方法仅格式化时间并省略日期。它使用本地时区,但不以区域感知方式格式化时间。
toLocaleTimeString()
这种方法以区域感知方式格式化时间,并使用本地时区。
当将日期和时间格式化为向最终用户显示时,这些日期转换为字符串的方法都不是理想的。查看 §11.7.2 以获取更通用且区域感知的日期和时间格式化技术。
最后,除了这些将 Date 对象转换为字符串的方法之外,还有一个静态的 Date.parse() 方法,它以字符串作为参数,尝试将其解析为日期和时间,并返回表示该日期的时间戳。Date.parse() 能够解析 Date() 构造函数可以解析的相同字符串,并且保证能够解析 toISOString()、toUTCString() 和 toString() 的输出。
11.5 错误类
JavaScript 的 throw 和 catch 语句可以抛出和捕获任何 JavaScript 值,包括原始值。没有必须用于信号错误的异常类型。但是,JavaScript 确实定义了一个 Error 类,并且在使用 throw 信号错误时传统上使用 Error 的实例或子类。使用 Error 对象的一个很好的理由是,当您创建一个 Error 时,它会捕获 JavaScript 堆栈的状态,如果异常未被捕获,堆栈跟踪将显示在错误消息中,这将帮助您调试问题。(请注意,堆栈跟踪显示 Error 对象的创建位置,而不是 throw 语句抛出它的位置。如果您总是在使用 throw new Error() 抛出之前创建对象,这将不会引起任何混淆。)
Error 对象有两个属性:message 和 name,以及一个 toString() 方法。message 属性的值是您传递给 Error() 构造函数的值,必要时转换为字符串。对于使用 Error() 创建的错误对象,name 属性始终为“Error”。toString() 方法简单地返回 name 属性的值,后跟一个冒号和空格,以及 message 属性的值。
尽管它不是 ECMAScript 标准的一部分,但 Node 和所有现代浏览器也在 Error 对象上定义了一个 stack 属性。该属性的值是一个多行字符串,其中包含 JavaScript 调用堆栈在创建 Error 对象时的堆栈跟踪。当捕获到意外错误时,这可能是有用的信息进行记录。
除了 Error 类之外,JavaScript 还定义了一些子类,用于信号 ECMAScript 定义的特定类型的错误。这些子类包括 EvalError、RangeError、ReferenceError、SyntaxError、TypeError 和 URIError。如果看起来合适,您可以在自己的代码中使用这些错误类。与基本 Error 类一样,这些子类的每个都有一个接受单个消息参数的构造函数。并且每个这些子类的实例都有一个 name 属性,其值与构造函数名称相同。
您可以随意定义最能封装您自己程序的错误条件的 Error 子类。请注意,您不仅限于 name 和 message 属性。如果创建一个子类,您可以定义新属性以提供错误详细信息。例如,如果您正在编写解析器,可能会发现定义一个具有指定解析失败确切位置的 line 和 column 属性的 ParseError 类很有用。或者,如果您正在处理 HTTP 请求,可能希望定义一个具有保存失败请求的 HTTP 状态码(例如 404 或 500)的 status 属性的 HTTPError 类。
例如:
class HTTPError extends Error {
constructor(status, statusText, url) {
super(`${status} ${statusText}: ${url}`);
this.status = status;
this.statusText = statusText;
this.url = url;
}
get name() { return "HTTPError"; }
}
let error = new HTTPError(404, "Not Found", "http://example.com/");
error.status // => 404
error.message // => "404 Not Found: http://example.com/"
error.name // => "HTTPError"
11.6 JSON 序列化和解析
当程序需要保存数据或需要将数据通过网络连接传输到另一个程序时,它必须将其内存中的数据结构转换为一串字节或字符,这些字节或字符可以被保存或传输,然后稍后被解析以恢复原始的内存中的数据结构。将数据结构转换为字节流或字符流的过程称为序列化(或编组甚至腌制)。
在 JavaScript 中序列化数据的最简单方法使用了一种称为 JSON 的序列化格式。这个首字母缩写代表“JavaScript 对象表示法”,正如名称所示,该格式使用 JavaScript 对象和数组文字语法将由对象和数组组成的数据结构转换为字符串。JSON 支持原始数字和字符串,以及值true、false和null,以及由这些原始值构建的数组和对象。JSON 不支持 Map、Set、RegExp、Date 或类型化数组等其他 JavaScript 类型。尽管如此,它已被证明是一种非常多才多艺的数据格式,即使在非基于 JavaScript 的程序中也被广泛使用。
JavaScript 支持使用两个函数JSON.stringify()和JSON.parse()进行 JSON 序列化和反序列化,这两个函数在§6.8 中简要介绍过。给定一个不包含任何非可序列化值(如 RegExp 对象或类型化数组)的对象或数组(任意深度嵌套),您可以通过将其传递给JSON.stringify()来简单地序列化对象。正如名称所示,此函数的返回值是一个字符串。并且给定JSON.stringify()返回的字符串,您可以通过将字符串传递给JSON.parse()来重新创建原始数据结构:
let o = {s: "", n: 0, a: [true, false, null]};
let s = JSON.stringify(o); // s == '{"s":"","n":0,"a":[true,false,null]}'
let copy = JSON.parse(s); // copy == {s: "", n: 0, a: [true, false, null]}
如果我们忽略序列化数据保存到文件或通过网络发送的部分,我们可以将这对函数用作创建对象的深层副本的一种效率较低的方式:
// Make a deep copy of any serializable object or array
function deepcopy(o) {
return JSON.parse(JSON.stringify(o));
}
JSON 是 JavaScript 的一个子集
当数据序列化为 JSON 格式时,结果是一个有效的 JavaScript 源代码,用于评估为原始数据结构的副本。如果您在 JSON 字符串前面加上var data =并将结果传递给eval(),您将获得将原始数据结构的副本分配给变量data的结果。但是,您绝对不应该这样做,因为这是一个巨大的安全漏洞——如果攻击者可以将任意 JavaScript 代码注入 JSON 文件中,他们可以使您的程序运行他们的代码。只需使用JSON.parse()来解码 JSON 格式化数据,这样更快速和安全。
JSON 有时被用作人类可读的配置文件格式。如果您发现自己手动编辑 JSON 文件,请注意 JSON 格式是 JavaScript 的一个非常严格的子集。不允许注释,属性名称必须用双引号括起来,即使 JavaScript 不需要这样做。
通常,您只向JSON.stringify()和JSON.parse()传递单个参数。这两个函数都接受一个可选的第二个参数,允许我们扩展 JSON 格式,接下来将对此进行描述。JSON.stringify()还接受一个可选的第三个参数,我们将首先讨论这个参数。如果您希望您的 JSON 格式化字符串可读性强(例如用作配置文件),那么应将null作为第二个参数传递,并将数字或字符串作为第三个参数传递。第三个参数告诉JSON.stringify()应该将数据格式化为多个缩进行。如果第三个参数是一个数字,则它将使用该数字作为每个缩进级别的空格数。如果第三个参数是一个空格字符串(例如'\t'),它将使用该字符串作为每个缩进级别。
let o = {s: "test", n: 0};
JSON.stringify(o, null, 2) // => '{\n "s": "test",\n "n": 0\n}'
JSON.parse()会忽略空格,因此向JSON.stringify()传递第三个参数对我们将字符串转换回数据结构的能力没有影响。
11.6.1 JSON 自定义
如果JSON.stringify()被要求序列化一个 JSON 格式不支持的值,它会查看该值是否有一个toJSON()方法,如果有,它会调用该方法,然后将返回值序列化以替换原始值。Date 对象实现了toJSON():它返回与toISOString()方法相同的字符串。这意味着如果序列化包含 Date 的对象,日期将自动转换为字符串。当您解析序列化的字符串时,重新创建的数据结构将不会与您开始的完全相同,因为它将在原始对象有 Date 的地方有一个字符串。
如果需要重新创建 Date 对象(或以任何其他方式修改解析的对象),可以将“恢复器”函数作为第二个参数传递给JSON.parse()。如果指定了,这个“恢复器”函数将被用于从输入字符串解析的每个原始值(但不包含这些原始值的对象或数组)。该函数被调用时带有两个参数。第一个是属性名称—一个对象属性名称或转换为字符串的数组索引。第二个参数是该对象属性或数组元素的原始值。此外,该函数作为包含原始值的对象或数组的方法被调用,因此您可以使用this关键字引用该包含对象。
恢复函数的返回值将成为命名属性的新值。如果它返回其第二个参数,则属性将保持不变。如果返回undefined,则在JSON.parse()返回给用户之前,命名属性将从对象或数组中删除。
作为示例,这里是一个调用JSON.parse()的示例,使用恢复器函数来过滤一些属性并重新创建 Date 对象:
let data = JSON.parse(text, function(key, value) {
// Remove any values whose property name begins with an underscore
if (key[0] === "_") return undefined;
// If the value is a string in ISO 8601 date format convert it to a Date.
if (typeof value === "string" &&
/^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d.\d\d\dZ$/.test(value)) {
return new Date(value);
}
// Otherwise, return the value unchanged
return value;
});
除了前面描述的toJSON()的使用,JSON.stringify()还允许通过将数组或函数作为可选的第二个参数来自定义其输出。
如果作为第二个参数传递的是字符串数组(或数字—它们会被转换为字符串),那么这些将被用作对象属性(或数组元素)的名称。任何名称不在数组中的属性都将被省略。此外,返回的字符串将按照它们在数组中出现的顺序包括属性(在编写测试时非常有用)。
如果传递一个函数,它是一个替换函数—实际上是您可以传递给JSON.parse()的可选恢复函数的反函数。如果指定了替换函数,那么替换函数将被用于要序列化的每个值。替换函数的第一个参数是该对象中值的对象属性名称或数组索引,第二个参数是值本身。替换函数作为包含要序列化值的对象或数组的方法被调用。替换函数的返回值将被序列化以替换原始值。如果替换函数返回undefined或根本没有返回任何内容,则该值(及其数组元素或对象属性)将被省略在序列化中。
// Specify what fields to serialize, and what order to serialize them in
let text = JSON.stringify(address, ["city","state","country"]);
// Specify a replacer function that omits RegExp-value properties
let json = JSON.stringify(o, (k, v) => v instanceof RegExp ? undefined : v);
这里的两个JSON.stringify()调用以一种良性的方式使用第二个参数,产生的序列化输出可以在不需要特殊恢复函数的情况下反序列化。然而,一般来说,如果为类型定义了toJSON()方法,或者使用一个实际上用可序列化值替换不可序列化值的替换函数,那么通常需要使用自定义恢复函数与JSON.parse()一起来获取原始数据结构。如果这样做,你应该明白你正在定义一种自定义数据格式,并牺牲了与大量 JSON 兼容工具和语言的可移植性和兼容性。
11.7 国际化 API
JavaScript 国际化 API 由三个类 Intl.NumberFormat、Intl.DateTimeFormat 和 Intl.Collator 组成,允许我们以区域设置适当的方式格式化数字(包括货币金额和百分比)、日期和时间,并以区域设置适当的方式比较字符串。这些类不是 ECMAScript 标准的一部分,但作为ECMA402 标准的一部分定义,并得到 Web 浏览器的良好支持。Intl API 也受 Node 支持,但在撰写本文时,预构建的 Node 二进制文件不包含所需的本地化数据,以使它们能够与除美国英语以外的区域设置一起使用。因此,为了在 Node 中使用这些类,您可能需要下载一个单独的数据包或使用自定义构建的 Node。
国际化中最重要的部分之一是显示已翻译为用户语言的文本。有各种方法可以实现这一点,但这些方法都不在此处描述的 Intl API 的范围内。
11.7.1 格式化数字
世界各地的用户期望以不同的方式格式化数字。小数点可以是句点或逗号。千位分隔符可以是逗号或句点,并且并非在所有地方每三位数字都使用。一些货币被分成百分之一,一些被分成千分之一,一些没有细分。最后,尽管所谓的“阿拉伯数字”0 到 9 在许多语言中使用,但这并非普遍,一些国家的用户期望看到使用其自己脚本中的数字编写的数字。
Intl.NumberFormat 类定义了一个format()方法,考虑到所有这些格式化可能性。构造函数接受两个参数。第一个参数指定应为其格式化数字的区域设置,第二个是一个对象,指定有关如何格式化数字的更多详细信息。如果省略或undefined第一个参数,则将使用系统区域设置(我们假设为用户首选区域设置)。如果第一个参数是字符串,则指定所需的区域设置,例如"en-US"(美国使用的英语)、"fr"(法语)或"zh-Hans-CN"(中国使用简体汉字书写系统)。第一个参数也可以是区域设置字符串数组,在这种情况下,Intl.NumberFormat 将选择最具体且受支持的区域设置。
如果指定了Intl.NumberFormat()构造函数的第二个参数,则应该是一个定义一个或多个以下属性的对象:
style
指定所需的数字格式化类型。默认值为"decimal"。指定"percent"将数字格式化为百分比,或指定"currency"将数字格式化为货币金额。
currency
如果样式为"currency",则需要此属性来指定所需货币的三个字母 ISO 货币代码(例如"USD"表示美元或"GBP"表示英镑)。
currencyDisplay
如果样式为"currency",则此属性指定货币的显示方式。默认值"symbol"使用货币符号(如果货币有符号)。值"code"使用三个字母 ISO 代码,值"name"以长形式拼写货币名称。
useGrouping
将此属性设置为false,如果您不希望数字具有千位分隔符(或其相应的区域设置等价物)。
minimumIntegerDigits
用于显示数字整数部分的最小位数。如果数字的位数少于此值,则将在左侧用零填充。默认值为 1,但可以使用高达 21 的值。
minimumFractionDigits,maximumFractionDigits
这两个属性控制数字的小数部分的格式。如果一个数字的小数位数少于最小值,它将在右侧用零填充。如果小数位数超过最大值,那么小数部分将被四舍五入。这两个属性的合法值介于 0 和 20 之间。默认最小值为 0,最大值为 3,除了在格式化货币金额时,小数部分的长度会根据指定的货币而变化。
minimumSignificantDigits,maximumSignificantDigits
这些属性控制在格式化数字时使用的有效数字位数,使其适用于格式化科学数据等情况。如果指定了这些属性,它们将覆盖先前列出的整数和小数位数属性。合法值介于 1 和 21 之间。
一旦您使用所需的区域设置和选项创建了一个 Intl.NumberFormat 对象,您可以通过将数字传递给其format()方法来使用它,该方法将返回一个适当格式化的字符串。例如:
let euros = Intl.NumberFormat("es", {style: "currency", currency: "EUR"});
euros.format(10) // => "10,00 €": ten euros, Spanish formatting
let pounds = Intl.NumberFormat("en", {style: "currency", currency: "GBP"});
pounds.format(1000) // => "£1,000.00": One thousand pounds, English formatting
Intl.NumberFormat(以及其他 Intl 类)的一个有用功能是它的format()方法绑定到它所属的 NumberFormat 对象。因此,您可以将format()方法分配给一个变量,并像独立函数一样使用它,而不是定义一个引用格式化对象的变量,然后在该变量上调用format()方法,就像这个例子中一样:
let data = [0.05, .75, 1];
let formatData = Intl.NumberFormat(undefined, {
style: "percent",
minimumFractionDigits: 1,
maximumFractionDigits: 1
}).format;
data.map(formatData) // => ["5.0%", "75.0%", "100.0%"]: in en-US locale
一些语言,比如阿拉伯语,使用自己的脚本来表示十进制数字:
let arabic = Intl.NumberFormat("ar", {useGrouping: false}).format;
arabic(1234567890) // => "١٢٣٤٥٦٧٨٩٠"
其他语言,比如印地语,使用自己的数字字符集,但默认情况下倾向于使用 ASCII 数字 0-9。如果要覆盖用于数字的默认字符集,请在区域设置中添加-u-nu-,然后跟上简写的字符集名称。例如,您可以这样格式化数字,使用印度风格的分组和天城数字:
let hindi = Intl.NumberFormat("hi-IN-u-nu-deva").format;
hindi(1234567890) // => "१,२३,४५,६७,८९०"
在区域设置中的-u-指定接下来是一个 Unicode 扩展。nu是编号系统的扩展名称,deva是 Devanagari 的缩写。Intl API 标准为许多其他编号系统定义了名称,主要用于南亚和东南亚的印度语言。
11.7.2 格式化日期和时间
Intl.DateTimeFormat 类与 Intl.NumberFormat 类非常相似。Intl.DateTimeFormat()构造函数接受与Intl.NumberFormat()相同的两个参数:区域设置或区域设置数组以及格式选项对象。使用 Intl.DateTimeFormat 实例的方法是调用其format()方法,将 Date 对象转换为字符串。
如§11.4 中所述,Date 类定义了简单的toLocaleDateString()和toLocaleTimeString()方法,为用户的区域设置生成适当的输出。但是这些方法不会让您控制显示的日期和时间字段。也许您想省略年份,但在日期格式中添加一个工作日。您希望月份是以数字形式表示还是以名称拼写出来?Intl.DateTimeFormat 类根据传递给构造函数的第二个参数中的选项对象中的属性提供对输出的细粒度控制。但是,请注意,Intl.DateTimeFormat 不能总是精确显示您要求的内容。如果指定了格式化小时和秒的选项但省略了分钟,您会发现格式化程序仍然会显示分钟。这个想法是您使用选项对象指定要向用户呈现的日期和时间字段以及您希望如何格式化这些字段(例如按名称或按数字),然后格式化程序将查找最接近您要求的内容的适合区域设置的格式。
可用的选项如下。只为您希望出现在格式化输出中的日期和时间字段指定属性。
年
使用"numeric"表示完整的四位数年份,或使用"2-digit"表示两位数缩写。
月
使用"numeric"表示可能的短数字,如“1”,或"2-digit"表示始终有两位数字的数字表示,如“01”。使用"long"表示全名,如“January”,"short"表示缩写,如“Jan”,"narrow"表示高度缩写,如“J”,不保证唯一。
day
使用"numeric"表示一位或两位数字,或"2-digit"表示月份的两位数字。
weekday
使用"long"表示全名,如“Monday”,"short"表示缩写,如“Mon”,"narrow"表示高度缩写,如“M”,不保证唯一。
era
此属性指定日期是否应以时代(如 CE 或 BCE)格式化。如果您正在格式化很久以前的日期或使用日本日历,则可能很有用。合法值为"long"、"short"和"narrow"。
hour、minute、second
这些属性指定您希望如何显示时间。使用"numeric"表示一位或两位数字字段,或"2-digit"强制将单个数字左侧填充为 0。
timeZone
此属性指定应为其格式化日期的所需时区。如果省略,将使用本地时区。实现始终识别“UTC”,并且还可以识别互联网分配的数字管理局(IANA)时区名称,例如“America/Los_Angeles”。
timeZoneName
此属性指定应如何在格式化的日期或时间中显示时区。使用"long"表示完全拼写的时区名称,"short"表示缩写或数字时区。
hour12
这个布尔属性指定是否使用 12 小时制。默认是与地区相关的,但你可以用这个属性来覆盖它。
hourCycle
此属性允许您指定午夜是写作 0 小时、12 小时还是 24 小时。默认是与地区相关的,但您可以用此属性覆盖默认值。请注意,hour12优先于此属性。使用值"h11"指定午夜为 0,午夜前一小时为 11pm。使用"h12"指定午夜为 12。使用"h23"指定午夜为 0,午夜前一小时为 23。使用"h24"指定午夜为 24。
以下是一些示例:
let d = new Date("2020-01-02T13:14:15Z"); // January 2nd, 2020, 13:14:15 UTC
// With no options, we get a basic numeric date format
Intl.DateTimeFormat("en-US").format(d) // => "1/2/2020"
Intl.DateTimeFormat("fr-FR").format(d) // => "02/01/2020"
// Spelled out weekday and month
let opts = { weekday: "long", month: "long", year: "numeric", day: "numeric" };
Intl.DateTimeFormat("en-US", opts).format(d) // => "Thursday, January 2, 2020"
Intl.DateTimeFormat("es-ES", opts).format(d) // => "jueves, 2 de enero de 2020"
// The time in New York, for a French-speaking Canadian
opts = { hour: "numeric", minute: "2-digit", timeZone: "America/New_York" };
Intl.DateTimeFormat("fr-CA", opts).format(d) // => "8 h 14"
Intl.DateTimeFormat 可以使用除基于基督教时代的默认儒略历之外的其他日历显示日期。尽管一些地区可能默认使用非基督教日历,但您始终可以通过在地区后添加-u-ca-并在其后跟日历名称来明确指定要使用的日历。可能的日历名称包括“buddhist”、“chinese”、“coptic”、“ethiopic”、“gregory”、“hebrew”、“indian”、“islamic”、“iso8601”、“japanese”和“persian”。继续前面的示例,我们可以确定各种非基督教历法中的年份:
let opts = { year: "numeric", era: "short" };
Intl.DateTimeFormat("en", opts).format(d) // => "2020 AD"
Intl.DateTimeFormat("en-u-ca-iso8601", opts).format(d) // => "2020 AD"
Intl.DateTimeFormat("en-u-ca-hebrew", opts).format(d) // => "5780 AM"
Intl.DateTimeFormat("en-u-ca-buddhist", opts).format(d) // => "2563 BE"
Intl.DateTimeFormat("en-u-ca-islamic", opts).format(d) // => "1441 AH"
Intl.DateTimeFormat("en-u-ca-persian", opts).format(d) // => "1398 AP"
Intl.DateTimeFormat("en-u-ca-indian", opts).format(d) // => "1941 Saka"
Intl.DateTimeFormat("en-u-ca-chinese", opts).format(d) // => "36 78"
Intl.DateTimeFormat("en-u-ca-japanese", opts).format(d) // => "2 Reiwa"
11.7.3 比较字符串
将字符串按字母顺序排序(或对于非字母脚本的更一般“排序顺序”)的问题比英语使用者通常意识到的更具挑战性。英语使用相对较小的字母表,没有重音字母,并且我们有字符编码(ASCII,已合并到 Unicode 中)的好处,其数值完全匹配我们的标准字符串排序顺序。在其他语言中情况并不那么简单。例如,西班牙语将ñ视为一个独立的字母,位于 n 之后和 o 之前。立陶宛语将 Y 排在 J 之前,威尔士语将 CH 和 DD 等二合字母视为单个字母,CH 排在 C 之后,DD 排在 D 之后。
如果要按用户自然顺序显示字符串,仅使用数组字符串的sort()方法是不够的。但是,如果创建 Intl.Collator 对象,可以将该对象的compare()方法传递给sort()方法,以执行符合区域设置的字符串排序。Intl.Collator 对象可以配置为使compare()方法执行不区分大小写的比较,甚至只考虑基本字母并忽略重音和其他变音符号的比较。
与Intl.NumberFormat()和Intl.DateTimeFormat()一样,Intl.Collator()构造函数接受两个参数。第一个指定区域设置或区域设置数组,第二个是一个可选对象,其属性精确指定要执行的字符串比较类型。支持的属性如下:
usage
此属性指定如何使用排序器对象。默认值为"sort",但也可以指定"search"。想法是,在对字符串进行排序时,通常希望排序器尽可能区分多个字符串以产生可靠的排序。但是,在比较两个字符串时,某些区域设置可能希望进行较不严格的比较,例如忽略重音。
sensitivity
此属性指定比较字符串时,排序器是否对大小写和重音敏感。值为"base"会忽略大小写和重音,只考虑每个字符的基本字母。(但请注意,某些语言认为某些带重音的字符是不同的基本字母。)"accent"考虑重音但忽略大小写。"case"考虑大小写但忽略重音。"variant"执行严格的比较,考虑大小写和重音。当usage为"sort"时,此属性的默认值为"variant"。如果usage为"search",则默认灵敏度取决于区域设置。
ignorePunctuation
将此属性设置为true以在比较字符串时忽略空格和标点符号。将此属性设置为true后,例如,字符串“any one”和“anyone”将被视为相等。
numeric
如果要比较的字符串是整数或包含整数,并且希望它们按数字顺序而不是按字母顺序排序,请将此属性设置为true。设置此选项后,例如,字符串“Version 9”将在“Version 10”之前排序。
caseFirst
此属性指定哪种大小写应该优先。如果指定为"upper",则“A”将在“a”之前排序。如果指定为"lower",则“a”将在“A”之前排序。无论哪种情况,请注意相同字母的大写和小写变体将按顺序排列在一起,这与 Unicode 词典排序(数组sort()方法的默认行为)不同,在该排序中,所有 ASCII 大写字母都排在所有 ASCII 小写字母之前。此属性的默认值取决于区域设置,并且实现可能会忽略此属性并不允许您覆盖大小写排序顺序。
一旦为所需区域设置和选项创建了 Intl.Collator 对象,就可以使用其compare()方法比较两个字符串。此方法返回一个数字。如果返回值小于零,则第一个字符串在第二个字符串之前。如果大于零,则第一个字符串在第二个字符串之后。如果compare()返回零,则这两个字符串在此排序器的意义上相等。
此接受两个字符串并返回小于、等于或大于零的数字的compare()方法正是数组sort()方法期望的可选参数。此外,Intl.Collator 会自动将compare()方法绑定到其实例,因此可以直接将其传递给sort(),而无需编写包装函数并通过排序器对象调用它。以下是一些示例:
// A basic comparator for sorting in the user's locale.
// Never sort human-readable strings without passing something like this:
const collator = new Intl.Collator().compare;
["a", "z", "A", "Z"].sort(collator) // => ["a", "A", "z", "Z"]
// Filenames often include numbers, so we should sort those specially
const filenameOrder = new Intl.Collator(undefined, { numeric: true }).compare;
["page10", "page9"].sort(filenameOrder) // => ["page9", "page10"]
// Find all strings that loosely match a target string
const fuzzyMatcher = new Intl.Collator(undefined, {
sensitivity: "base",
ignorePunctuation: true
}).compare;
let strings = ["food", "fool", "Føø Bar"];
strings.findIndex(s => fuzzyMatcher(s, "foobar") === 0) // => 2
一些地区有多种可能的排序顺序。例如,在德国,电话簿使用的排序顺序比字典稍微更加语音化。在西班牙,在 1994 年之前,“ch” 和 “ll” 被视为单独的字母,因此该国现在有现代排序顺序和传统排序顺序。在中国,排序顺序可以基于字符编码、每个字符的基本部首和笔画,或者基于字符的拼音罗马化。这些排序变体不能通过 Intl.Collator 选项参数进行选择,但可以通过在区域设置字符串中添加 -u-co- 并添加所需变体的名称来选择。例如,在德国使用 "de-DE-u-co-phonebk" 进行电话簿排序,在台湾使用 "zh-TW-u-co-pinyin" 进行拼音排序。
// Before 1994, CH and LL were treated as separate letters in Spain
const modernSpanish = Intl.Collator("es-ES").compare;
const traditionalSpanish = Intl.Collator("es-ES-u-co-trad").compare;
let palabras = ["luz", "llama", "como", "chico"];
palabras.sort(modernSpanish) // => ["chico", "como", "llama", "luz"]
palabras.sort(traditionalSpanish) // => ["como", "chico", "luz", "llama"]
11.8 控制台 API
你在本书中看到了 console.log() 函数的使用:在网页浏览器中,它会在浏览器的开发者工具窗格的“控制台”选项卡中打印一个字符串,这在调试时非常有帮助。在 Node 中,console.log() 是一个通用输出函数,将其参数打印到进程的 stdout 流中,在终端窗口中通常会显示给用户作为程序输出。
控制台 API 除了 console.log() 外还定义了许多有用的函数。该 API 不是任何 ECMAScript 标准的一部分,但受到浏览器和 Node 的支持,并已经正式编写和标准化在 https://console.spec.whatwg.org。
控制台 API 定义了以下函数:
console.log()
这是控制台函数中最为人熟知的。它将其参数转换为字符串并将它们输出到控制台。它在参数之间包含空格,并在输出所有参数后开始新的一行。
console.debug(), console.info(), console.warn(), console.error()
这些函数几乎与 console.log() 完全相同。在 Node 中,console.error() 将其输出发送到 stderr 流而不是 stdout 流,但其他函数是 console.log() 的别名。在浏览器中,每个函数生成的输出消息可能会以指示其级别或严重性的图标为前缀,并且开发者控制台还可以允许开发者按级别过滤控制台消息。
console.assert()
如果第一个参数为真值(即如果断言通过),则此函数不执行任何操作。但如果第一个参数为 false 或其他假值,则剩余的参数将被打印,就像它们已经被传递给带有“Assertion failed”前缀的 console.error() 一样。请注意,与典型的 assert() 函数不同,当断言失败时,console.assert() 不会抛出异常。
console.clear()
此函数在可能的情况下清除控制台。这在浏览器和在 Node 将其输出显示到终端时有效。但是,如果 Node 的输出已被重定向到文件或管道,则调用此函数没有效果。
console.table()
这个函数是一个非常强大但鲜为人知的功能,用于生成表格输出,特别适用于需要总结数据的 Node 程序。console.table()尝试以表格形式显示其参数(尽管如果无法做到这一点,它会使用常规的console.log()格式)。当参数是一个相对较短的对象数组,并且数组中的所有对象具有相同(相对较小)的属性集时,这种方法效果最佳。在这种情况下,数组中的每个对象被格式化为表格的一行,每个属性是表格的一列。您还可以将属性名称数组作为可选的第二个参数传递,以指定所需的列集。如果传递的是对象而不是对象数组,则输出将是一个具有属性名称列和属性值列的表格。或者,如果这些属性值本身是对象,则它们的属性名称将成为表格中的列。
console.trace()
这个函数像console.log()一样记录其参数,并且在输出后跟随一个堆栈跟踪。在 Node 中,输出会发送到 stderr 而不是 stdout。
console.count()
这个函数接受一个字符串参数,并记录该字符串,然后记录调用该字符串的次数。在调试事件处理程序时,这可能很有用,例如,如果需要跟踪事件处理程序被触发的次数。
console.countReset()
这个函数接受一个字符串参数,并重置该字符串的计数器。
console.group()
这个函数将其参数打印到控制台,就像它们已被传递给console.log()一样,然后设置控制台的内部状态,以便所有后续的控制台消息(直到下一个console.groupEnd()调用)将相对于刚刚打印的消息进行缩进。这允许将一组相关消息视觉上分组并缩进。在 Web 浏览器中,开发者控制台通常允许将分组消息折叠和展开为一组。console.group()的参数通常用于为组提供解释性名称。
console.groupCollapsed()
这个函数与console.group()类似,但在 Web 浏览器中,默认情况下,该组将“折叠”,并且它包含的消息将被隐藏,除非用户点击以展开该组。在 Node 中,此函数是console.group()的同义词。
console.groupEnd()
这个函数不接受任何参数。它不产生自己的输出,但结束了由最近调用的console.group()或console.groupCollapsed()引起的缩进和分组。
console.time()
这个函数接受一个字符串参数,记录调用该字符串的时间,并不产生输出。
console.timeLog()
这个函数将一个字符串作为其第一个参数。如果该字符串之前已传递给console.time(),则打印该字符串,然后是自console.time()调用以来经过的时间。如果console.timeLog()有任何额外的参数,它们将被打印,就像它们已被传递给console.log()一样。
console.timeEnd()
这个函数接受一个字符串参数。如果之前已将该参数传递给console.time(),则打印该参数和经过的时间。在调用console.timeEnd()之后,再次调用console.timeLog()而不先调用console.time()是不合法的。
11.8.1 使用控制台进行格式化输出
类似console.log()打印其参数的控制台函数有一个鲜为人知的功能:如果第一个参数是包含%s、%i、%d、%f、%o、%O或%c的字符串,则此第一个参数将被视为格式字符串,⁶,并且后续参数的值将替换两个字符%序列的位置。
序列的含义如下:
%s
参数被转换为字符串。
%i 和 %d
参数被转换为数字,然后截断为整数。
%f
参数被转换为数字
%o 和 %O
参数被视为对象,并显示属性名称和值。(在 Web 浏览器中,此显示通常是交互式的,用户可以展开和折叠属性以探索嵌套的数据结构。)%o 和 %O 都显示对象的详细信息。大写变体使用一个依赖于实现的输出格式,被认为对软件开发人员最有用。
%c
在 Web 浏览器中,参数被解释为一串 CSS 样式,并用于为接下来的任何文本设置样式(直到下一个 %c 序列或字符串结束)。在 Node 中,%c 序列及其对应的参数会被简单地忽略。
请注意,通常不需要在控制台函数中使用格式字符串:通常只需将一个或多个值(包括对象)传递给函数,让实现以有用的方式显示它们即可。例如,请注意,如果将 Error 对象传递给 console.log(),它将自动打印出其堆栈跟踪。
11.9 URL API
由于 JavaScript 在 Web 浏览器和 Web 服务器中被广泛使用,JavaScript 代码通常需要操作 URL。URL 类解析 URL 并允许修改(例如添加搜索参数或更改路径)现有的 URL。它还正确处理了 URL 的各个组件的转义和解码这一复杂主题。
URL 类不是任何 ECMAScript 标准的一部分,但它在 Node 和除了 Internet Explorer 之外的所有互联网浏览器中都可以使用。它在 https://url.spec.whatwg.org 上标准化。
使用 URL() 构造函数创建一个 URL 对象,将绝对 URL 字符串作为参数传递。或者将相对 URL 作为第一个参数传递,将其相对的绝对 URL 作为第二个参数传递。一旦创建了 URL 对象,它的各种属性允许您查询 URL 的各个部分的未转义版本:
let url = new URL("https://example.com:8000/path/name?q=term#fragment");
url.href // => "https://example.com:8000/path/name?q=term#fragment"
url.origin // => "https://example.com:8000"
url.protocol // => "https:"
url.host // => "example.com:8000"
url.hostname // => "example.com"
url.port // => "8000"
url.pathname // => "/path/name"
url.search // => "?q=term"
url.hash // => "#fragment"
尽管不常用,URL 可以包含用户名或用户名和密码,URL 类也可以解析这些 URL 组件:
let url = new URL("ftp://admin:1337!@ftp.example.com/");
url.href // => "ftp://admin:1337!@ftp.example.com/"
url.origin // => "ftp://ftp.example.com"
url.username // => "admin"
url.password // => "1337!"
这里的 origin 属性是 URL 协议和主机(包括指定的端口)的简单组合。因此,它是一个只读属性。但前面示例中演示的每个其他属性都是读/写的:您可以设置这些属性中的任何一个来设置 URL 的相应部分:
let url = new URL("https://example.com"); // Start with our server
url.pathname = "api/search"; // Add a path to an API endpoint
url.search = "q=test"; // Add a query parameter
url.toString() // => "https://example.com/api/search?q=test"
URL 类的一个重要特性是在需要时正确添加标点符号并转义 URL 中的特殊字符:
let url = new URL("https://example.com");
url.pathname = "path with spaces";
url.search = "q=foo#bar";
url.pathname // => "/path%20with%20spaces"
url.search // => "?q=foo%23bar"
url.href // => "https://example.com/path%20with%20spaces?q=foo%23bar"
这些示例中的 href 属性是一个特殊的属性:读取 href 等同于调用 toString():它将 URL 的所有部分重新组合成 URL 的规范字符串形式。将 href 设置为新字符串会重新运行 URL 解析器,就好像再次调用 URL() 构造函数一样。
在前面的示例中,我们一直使用 search 属性来引用 URL 的整个查询部分,该部分由问号到 URL 结尾的第一个井号字符组成。有时,将其视为单个 URL 属性就足够了。然而,HTTP 请求通常使用 application/x-www-form-urlencoded 格式将多个表单字段或多个 API 参数的值编码到 URL 的查询部分中。在此格式中,URL 的查询部分是一个问号,后面跟着一个或多个名称/值对,它们之间用和号分隔。同一个名称可以出现多次,导致具有多个值的命名搜索参数。
如果你想将这些名称/值对编码到 URL 的查询部分中,那么searchParams属性比search属性更有用。search属性是一个可读/写的字符串,允许你获取和设置 URL 的整个查询部分。searchParams属性是一个只读引用,指向一个 URLSearchParams 对象,该对象具有用于获取、设置、添加、删除和排序编码到 URL 查询部分的参数的 API:
let url = new URL("https://example.com/search");
url.search // => "": no query yet
url.searchParams.append("q", "term"); // Add a search parameter
url.search // => "?q=term"
url.searchParams.set("q", "x"); // Change the value of this parameter
url.search // => "?q=x"
url.searchParams.get("q") // => "x": query the parameter value
url.searchParams.has("q") // => true: there is a q parameter
url.searchParams.has("p") // => false: there is no p parameter
url.searchParams.append("opts", "1"); // Add another search parameter
url.search // => "?q=x&opts=1"
url.searchParams.append("opts", "&"); // Add another value for same name
url.search // => "?q=x&opts=1&opts=%26": note escape
url.searchParams.get("opts") // => "1": the first value
url.searchParams.getAll("opts") // => ["1", "&"]: all values
url.searchParams.sort(); // Put params in alphabetical order
url.search // => "?opts=1&opts=%26&q=x"
url.searchParams.set("opts", "y"); // Change the opts param
url.search // => "?opts=y&q=x"
// searchParams is iterable
[...url.searchParams] // => [["opts", "y"], ["q", "x"]]
url.searchParams.delete("opts"); // Delete the opts param
url.search // => "?q=x"
url.href // => "https://example.com/search?q=x"
searchParams属性的值是一个 URLSearchParams 对象。如果你想将 URL 参数编码到查询字符串中,可以创建一个 URLSearchParams 对象,追加参数,然后将其转换为字符串并设置在 URL 的search属性上:
let url = new URL("http://example.com");
let params = new URLSearchParams();
params.append("q", "term");
params.append("opts", "exact");
params.toString() // => "q=term&opts=exact"
url.search = params;
url.href // => "http://example.com/?q=term&opts=exact"
11.9.1 传统 URL 函数
在之前描述的 URL API 定义之前,已经有多次尝试在核心 JavaScript 语言中支持 URL 转义和解码。第一次尝试是全局定义的escape()和unescape()函数,现在已经被弃用,但仍然被广泛实现。不应该使用它们。
当escape()和unescape()被弃用时,ECMAScript 引入了两对替代的全局函数:
encodeURI()和decodeURI()
encodeURI()以字符串作为参数,返回一个新字符串,其中非 ASCII 字符和某些 ASCII 字符(如空格)被转义。decodeURI()则相反。需要转义的字符首先被转换为它们的 UTF-8 编码,然后该编码的每个字节被替换为一个%xx转义序列,其中xx是两个十六进制数字。因为encodeURI()旨在对整个 URL 进行编码,它不会转义 URL 分隔符字符,如/、?和#。但这意味着encodeURI()无法正确处理 URL 中包含这些字符的各个组件的 URL。
encodeURIComponent()和decodeURIComponent()
这一对函数的工作方式与encodeURI()和decodeURI()完全相同,只是它们旨在转义 URI 的各个组件,因此它们还会转义用于分隔这些组件的字符,如/、?和#。这些是传统 URL 函数中最有用的,但请注意,encodeURIComponent()会转义路径名中的/字符,这可能不是你想要的。它还会将查询参数中的空格转换为%20,尽管在 URL 的这部分中应该用+转义空格。
所有这些传统函数的根本问题在于,它们试图对 URL 的所有部分应用单一的编码方案,而事实上 URL 的不同部分使用不同的编码。如果你想要一个格式正确且编码正确的 URL,解决方案就是简单地使用 URL 类来进行所有的 URL 操作。
11.10 定时器
自 JavaScript 诞生以来,Web 浏览器就定义了两个函数——setTimeout()和setInterval()——允许程序要求浏览器在指定的时间过去后调用一个函数,或者在指定的时间间隔内重复调用函数。这些函数从未作为核心语言的一部分标准化,但它们在所有浏览器和 Node 中都有效,并且是 JavaScript 标准库的事实部分。
setTimeout()的第一个参数是一个函数,第二个参数是一个数字,指定在调用函数之前应该经过多少毫秒。在指定的时间过去后(如果系统繁忙可能会稍长一些),函数将被调用,不带任何参数。这里,例如,是三个setTimeout()调用,分别在一秒、两秒和三秒后打印控制台消息:
setTimeout(() => { console.log("Ready..."); }, 1000);
setTimeout(() => { console.log("set..."); }, 2000);
setTimeout(() => { console.log("go!"); }, 3000);
请注意,setTimeout()在返回之前不会等待时间过去。这个示例中的三行代码几乎立即运行,但在经过 1,000 毫秒后才会发生任何事情。
如果省略setTimeout()的第二个参数,则默认为 0。然而,这并不意味着您指定的函数会立即被调用。相反,该函数被注册为“尽快”调用。如果浏览器忙于处理用户输入或其他事件,可能需要 10 毫秒或更长时间才能调用该函数。
setTimeout()注册一个函数,该函数将在一次调用后被调用。有时,该函数本身会调用setTimeout()以安排在将来的某个时间再次调用。然而,如果要重复调用一个函数,通常更简单的方法是使用setInterval()。setInterval()接受与setTimeout()相同的两个参数,但每当指定的毫秒数(大约)过去时,它会重复调用函数。
setTimeout()和setInterval()都会返回一个值。如果将此值保存在变量中,您随后可以使用它通过传递给clearTimeout()或clearInterval()来取消函数的执行。返回的值在 Web 浏览器中通常是一个数字,在 Node 中是一个对象。实际类型并不重要,您应该将其视为不透明值。您可以使用此值的唯一操作是将其传递给clearTimeout()以取消使用setTimeout()注册的函数的执行(假设尚未调用)或停止使用setInterval()注册的函数的重复执行。
这是一个示例,演示了如何使用setTimeout()、setInterval()和clearInterval()来显示一个简单的数字时钟与 Console API:
// Once a second: clear the console and print the current time
let clock = setInterval(() => {
console.clear();
console.log(new Date().toLocaleTimeString());
}, 1000);
// After 10 seconds: stop the repeating code above.
setTimeout(() => { clearInterval(clock); }, 10000);
当我们讨论异步编程时,我们将再次看到setTimeout()和setInterval(),详见第十三章。
11.11 总结
学习一门编程语言不仅仅是掌握语法。同样重要的是研究标准库,以便熟悉语言附带的所有工具。本章记录了 JavaScript 的标准库,其中包括:
-
重要的数据结构,如 Set、Map 和类型化数组。
-
用于处理日期和 URL 的 Date 和 URL 类。
-
JavaScript 的正则表达式语法及其用于文本模式匹配的 RegExp 类。
-
JavaScript 的国际化库,用于格式化日期、时间和数字以及对字符串进行排序。
-
用于序列化和反序列化简单数据结构的
JSON对象和用于记录消息的console对象。
¹ 这里记录的并非 JavaScript 语言规范定义的所有内容:这里记录的一些类和函数首先是在 Web 浏览器中实现的,然后被 Node 采用,使它们成为 JavaScript 标准库的事实成员。
² 这种可预测的迭代顺序是 JavaScript 集合中的另一件事,可能会让 Python 程序员感到惊讶。
³ 当 Web 浏览器添加对 WebGL 图形的支持时,类型化数组首次引入到客户端 JavaScript 中。ES6 中的新功能是它们已被提升为核心语言特性。
⁴ 除了在字符类(方括号)内部,\b匹配退格字符。
⁵ 使用正则表达式解析 URL 并不是一个好主意。请参见§11.9 以获取更健壮的 URL 解析器。
⁶ C 程序员将从printf()函数中认出许多这些字符序列。
第十二章:迭代器和生成器
可迭代对象及其相关的迭代器是 ES6 的一个特性,在本书中我们已经多次见到。数组(包括 TypedArrays)、字符串以及 Set 和 Map 对象都是可迭代的。这意味着这些数据结构的内容可以被迭代——使用for/of循环遍历,就像我们在§5.4.4 中看到的那样:
let sum = 0;
for(let i of [1,2,3]) { // Loop once for each of these values
sum += i;
}
sum // => 6
迭代器也可以与...运算符一起使用,将可迭代对象展开或“扩展”到数组初始化程序或函数调用中,就像我们在§7.1.2 中看到的那样:
let chars = [..."abcd"]; // chars == ["a", "b", "c", "d"]
let data = [1, 2, 3, 4, 5];
Math.max(...data) // => 5
迭代器可以与解构赋值一起使用:
let purpleHaze = Uint8Array.of(255, 0, 255, 128);
let [r, g, b, a] = purpleHaze; // a == 128
当你迭代 Map 对象时,返回的值是[key, value]对,这与for/of循环中的解构赋值很好地配合使用:
let m = new Map([["one", 1], ["two", 2]]);
for(let [k,v] of m) console.log(k, v); // Logs 'one 1' and 'two 2'
如果你只想迭代键或值而不是键值对,可以使用keys()和values()方法:
[...m] // => [["one", 1], ["two", 2]]: default iteration
[...m.entries()] // => [["one", 1], ["two", 2]]: entries() method is the same
[...m.keys()] // => ["one", "two"]: keys() method iterates just map keys
[...m.values()] // => [1, 2]: values() method iterates just map values
最后,一些常用于 Array 对象的内置函数和构造函数实际上(在 ES6 及更高版本中)被编写为接受任意迭代器。Set()构造函数就是这样一个 API:
// Strings are iterable, so the two sets are the same:
new Set("abc") // => new Set(["a", "b", "c"])
本章解释了迭代器的工作原理,并演示了如何创建自己的可迭代数据结构。在解释基本迭代器之后,本章涵盖了生成器,这是 ES6 的一个强大新功能,主要用作一种特别简单的创建迭代器的方法。
12.1 迭代器的工作原理
for/of循环和展开运算符与可迭代对象无缝配合,但值得理解实际上是如何使迭代工作的。在理解 JavaScript 中的迭代过程时,有三种不同的类型需要理解。首先是可迭代对象:这些是可以被迭代的类型,如 Array、Set 和 Map。其次,是执行迭代的迭代器对象本身。第三,是保存迭代每一步结果的迭代结果对象。
可迭代对象是任何具有特殊迭代器方法的对象,该方法返回一个迭代器对象。迭代器是任何具有返回迭代结果对象的next()方法的对象。而迭代结果对象是具有名为value和done的属性的对象。要迭代可迭代对象,首先调用其迭代器方法以获取一个迭代器对象。然后,重复调用迭代器对象的next()方法,直到返回的值的done属性设置为true为止。关于这一点的棘手之处在于,可迭代对象的迭代器方法没有传统的名称,而是使用符号Symbol.iterator作为其名称。因此,对可迭代对象iterable进行简单的for/of循环也可以以较困难的方式编写,如下所示:
let iterable = [99];
let iterator = iterable[Symbol.iterator]();
for(let result = iterator.next(); !result.done; result = iterator.next()) {
console.log(result.value) // result.value == 99
}
内置可迭代数据类型的迭代器对象本身也是可迭代的。(也就是说,它有一个名为Symbol.iterator的方法,该方法返回自身。)这在以下代码中偶尔会有用,当你想要遍历“部分使用过”的迭代器时:
let list = [1,2,3,4,5];
let iter = list[Symbol.iterator]();
let head = iter.next().value; // head == 1
let tail = [...iter]; // tail == [2,3,4,5]
12.2 实现可迭代对象
在 ES6 中,可迭代对象非常有用,因此当它们表示可以被迭代的内容时,你应该考虑使自己的数据类型可迭代。在第 9-2 和第 9-3 示例中展示的 Range 类是可迭代的。这些类使用生成器函数使自己可迭代。我们稍后会介绍生成器,但首先,我们将再次实现 Range 类,使其可迭代而不依赖于生成器。
要使类可迭代,必须实现一个方法,其名称为符号Symbol.iterator。该方法必须返回具有next()方法的迭代器对象。而next()方法必须返回具有value属性和/或布尔done属性的迭代结果对象。示例 12-1 实现了一个可迭代的 Range 类,并演示了如何创建可迭代、迭代器和迭代结果对象。
示例 12-1. 一个可迭代的数字范围类
/*
* A Range object represents a range of numbers {x: from <= x <= to}
* Range defines a has() method for testing whether a given number is a member
* of the range. Range is iterable and iterates all integers within the range.
*/
class Range {
constructor (from, to) {
this.from = from;
this.to = to;
}
// Make a Range act like a Set of numbers
has(x) { return typeof x === "number" && this.from <= x && x <= this.to; }
// Return string representation of the range using set notation
toString() { return `{ x | ${this.from} ≤ x ≤ ${this.to} }`; }
// Make a Range iterable by returning an iterator object.
// Note that the name of this method is a special symbol, not a string.
[Symbol.iterator]() {
// Each iterator instance must iterate the range independently of
// others. So we need a state variable to track our location in the
// iteration. We start at the first integer >= from.
let next = Math.ceil(this.from); // This is the next value we return
let last = this.to; // We won't return anything > this
return { // This is the iterator object
// This next() method is what makes this an iterator object.
// It must return an iterator result object.
next() {
return (next <= last) // If we haven't returned last value yet
? { value: next++ } // return next value and increment it
: { done: true }; // otherwise indicate that we're done.
},
// As a convenience, we make the iterator itself iterable.
[Symbol.iterator]() { return this; }
};
}
}
for(let x of new Range(1,10)) console.log(x); // Logs numbers 1 to 10
[...new Range(-2,2)] // => [-2, -1, 0, 1, 2]
除了使您的类可迭代之外,定义返回可迭代值的函数也非常有用。考虑这些基于迭代的替代方案,用于 JavaScript 数组的map()和filter()方法:
// Return an iterable object that iterates the result of applying f()
// to each value from the source iterable
function map(iterable, f) {
let iterator = iterable[Symbol.iterator]();
return { // This object is both iterator and iterable
[Symbol.iterator]() { return this; },
next() {
let v = iterator.next();
if (v.done) {
return v;
} else {
return { value: f(v.value) };
}
}
};
}
// Map a range of integers to their squares and convert to an array
[...map(new Range(1,4), x => x*x)] // => [1, 4, 9, 16]
// Return an iterable object that filters the specified iterable,
// iterating only those elements for which the predicate returns true
function filter(iterable, predicate) {
let iterator = iterable[Symbol.iterator]();
return { // This object is both iterator and iterable
[Symbol.iterator]() { return this; },
next() {
for(;;) {
let v = iterator.next();
if (v.done || predicate(v.value)) {
return v;
}
}
}
};
}
// Filter a range so we're left with only even numbers
[...filter(new Range(1,10), x => x % 2 === 0)] // => [2,4,6,8,10]
可迭代对象和迭代器的一个关键特性是它们本质上是惰性的:当需要计算下一个值时,该计算可以推迟到实际需要该值时。例如,假设您有一个非常长的文本字符串,您希望将其标记为以空格分隔的单词。您可以简单地使用字符串的split()方法,但如果这样做,那么必须在使用第一个单词之前处理整个字符串。并且您最终会为返回的数组及其中的所有字符串分配大量内存。以下是一个函数,允许您惰性迭代字符串的单词,而无需一次性将它们全部保存在内存中(在 ES2020 中,使用返回迭代器的matchAll()方法更容易实现此函数,该方法在 §11.3.2 中描述):
function words(s) {
var r = /\s+|$/g; // Match one or more spaces or end
r.lastIndex = s.match(/[^ ]/).index; // Start matching at first nonspace
return { // Return an iterable iterator object
[Symbol.iterator]() { // This makes us iterable
return this;
},
next() { // This makes us an iterator
let start = r.lastIndex; // Resume where the last match ended
if (start < s.length) { // If we're not done
let match = r.exec(s); // Match the next word boundary
if (match) { // If we found one, return the word
return { value: s.substring(start, match.index) };
}
}
return { done: true }; // Otherwise, say that we're done
}
};
}
[...words(" abc def ghi! ")] // => ["abc", "def", "ghi!"]
12.2.1 “关闭”迭代器:返回方法
想象一个(服务器端)JavaScript 变体的words()迭代器,它不是以源字符串作为参数,而是以文件流作为参数,打开文件,从中读取行,并迭代这些行中的单词。在大多数操作系统中,打开文件以从中读取的程序在完成读取后需要记住关闭这些文件,因此这个假设的迭代器将确保在next()方法返回其中的最后一个单词后关闭文件。
但迭代器并不总是运行到结束:for/of循环可能会被break、return或异常终止。同样,当迭代器与解构赋值一起使用时,next()方法只会被调用足够次数以获取每个指定变量的值。迭代器可能有更多值可以返回,但它们永远不会被请求。
如果我们假设的文件中的单词迭代器从未完全运行到结束,它仍然需要关闭打开的文件。因此,迭代器对象可能会实现一个return()方法,与next()方法一起使用。如果在next()返回具有done属性设置为true的迭代结果之前迭代停止(通常是因为您通过break语句提前离开了for/of循环),那么解释器将检查迭代器对象是否具有return()方法。如果存在此方法,解释器将以无参数调用它,使迭代器有机会关闭文件,释放内存,并在完成后进行清理。return()方法必须返回一个迭代结果对象。对象的属性将被忽略,但返回非对象值是错误的。
for/of循环和展开运算符是 JavaScript 的非常有用的特性,因此在创建 API 时,尽可能使用它们是一个好主意。但是,必须使用可迭代对象、其迭代器对象和迭代器的结果对象来处理过程有些复杂。幸运的是,生成器可以极大地简化自定义迭代器的创建,我们将在本章的其余部分中看到。
12.3 生成器
生成器是一种使用强大的新 ES6 语法定义的迭代器;当要迭代的值不是数据结构的元素,而是计算结果时,它特别有用。
要创建一个生成器,你必须首先定义一个生成器函数。生成器函数在语法上类似于普通的 JavaScript 函数,但是用关键字function*而不是function来定义。(从技术上讲,这不是一个新关键字,只是在关键字function之后和函数名之前加上一个*。)当你调用一个生成器函数时,它实际上不会执行函数体,而是返回一个生成器对象。这个生成器对象是一个迭代器。调用它的next()方法会导致生成器函数的主体从头开始运行(或者从当前位置开始),直到达到一个yield语句。yield在 ES6 中是新的,类似于return语句。yield语句的值成为迭代器上next()调用返回的值。通过示例可以更清楚地理解这一点:
// A generator function that yields the set of one digit (base-10) primes.
function* oneDigitPrimes() { // Invoking this function does not run the code
yield 2; // but just returns a generator object. Calling
yield 3; // the next() method of that generator runs
yield 5; // the code until a yield statement provides
yield 7; // the return value for the next() method.
}
// When we invoke the generator function, we get a generator
let primes = oneDigitPrimes();
// A generator is an iterator object that iterates the yielded values
primes.next().value // => 2
primes.next().value // => 3
primes.next().value // => 5
primes.next().value // => 7
primes.next().done // => true
// Generators have a Symbol.iterator method to make them iterable
primes[Symbol.iterator]() // => primes
// We can use generators like other iterable types
[...oneDigitPrimes()] // => [2,3,5,7]
let sum = 0;
for(let prime of oneDigitPrimes()) sum += prime;
sum // => 17
在这个例子中,我们使用了function*语句来定义一个生成器。然而,和普通函数一样,我们也可以以表达式形式定义生成器。再次强调,我们只需在function关键字后面加上一个星号:
const seq = function*(from,to) {
for(let i = from; i <= to; i++) yield i;
};
[...seq(3,5)] // => [3, 4, 5]
在类和对象字面量中,我们可以使用简写符号来完全省略定义方法时的function关键字。在这种情况下定义生成器,我们只需在方法名之前使用一个星号,而不是使用function关键字:
let o = {
x: 1, y: 2, z: 3,
// A generator that yields each of the keys of this object
*g() {
for(let key of Object.keys(this)) {
yield key;
}
}
};
[...o.g()] // => ["x", "y", "z", "g"]
请注意,没有办法使用箭头函数语法编写生成器函数。
生成器通常使得定义可迭代类变得特别容易。我们可以用一个更简短的*Symbol.iterator]()生成器函数来替换[示例 12-1 中展示的[Symbol.iterator]()方法,代码如下:
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
}
查看第九章中的示例 9-3 以查看上下文中基于生成器的迭代器函数。
12.3.1 生成器示例
如果生成器实际上生成它们通过进行某种计算来产生的值,那么生成器就更有趣了。例如,这里是一个产生斐波那契数的生成器函数:
function* fibonacciSequence() {
let x = 0, y = 1;
for(;;) {
yield y;
[x, y] = [y, x+y]; // Note: destructuring assignment
}
}
注意,这里的fibonacciSequence()生成器函数有一个无限循环,并且永远产生值而不返回。如果这个生成器与...扩展运算符一起使用,它将循环直到内存耗尽并且程序崩溃。然而,经过谨慎处理,可以在for/of循环中使用它:
// Return the nth Fibonacci number
function fibonacci(n) {
for(let f of fibonacciSequence()) {
if (n-- <= 0) return f;
}
}
fibonacci(20) // => 10946
这种无限生成器与这样的take()生成器结合使用更有用:
// Yield the first n elements of the specified iterable object
function* take(n, iterable) {
let it = iterable[Symbol.iterator](); // Get iterator for iterable object
while(n-- > 0) { // Loop n times:
let next = it.next(); // Get the next item from the iterator.
if (next.done) return; // If there are no more values, return early
else yield next.value; // otherwise, yield the value
}
}
// An array of the first 5 Fibonacci numbers
[...take(5, fibonacciSequence())] // => [1, 1, 2, 3, 5]
这里是另一个有用的生成器函数,它交错多个可迭代对象的元素:
// Given an array of iterables, yield their elements in interleaved order.
function* zip(...iterables) {
// Get an iterator for each iterable
let iterators = iterables.map(i => i[Symbol.iterator]());
let index = 0;
while(iterators.length > 0) { // While there are still some iterators
if (index >= iterators.length) { // If we reached the last iterator
index = 0; // go back to the first one.
}
let item = iterators[index].next(); // Get next item from next iterator.
if (item.done) { // If that iterator is done
iterators.splice(index, 1); // then remove it from the array.
}
else { // Otherwise,
yield item.value; // yield the iterated value
index++; // and move on to the next iterator.
}
}
}
// Interleave three iterable objects
[...zip(oneDigitPrimes(),"ab",[0])] // => [2,"a",0,3,"b",5,7]
12.3.2 yield* 和递归生成器
除了在前面的示例中定义的zip()生成器之外,可能还有一个类似的生成器函数很有用,它按顺序而不是交错地产生多个可迭代对象的元素。我们可以这样编写这个生成器:
function* sequence(...iterables) {
for(let iterable of iterables) {
for(let item of iterable) {
yield item;
}
}
}
[...sequence("abc",oneDigitPrimes())] // => ["a","b","c",2,3,5,7]
在生成器函数中产生其他可迭代对象的元素的过程在生成器函数中是很常见的,ES6 为此提供了特殊的语法。yield*关键字类似于yield,不同之处在于,它不是产生单个值,而是迭代一个可迭代对象并产生每个结果值。我们使用的sequence()生成器函数可以用yield*简化如下:
function* sequence(...iterables) {
for(let iterable of iterables) {
yield* iterable;
}
}
[...sequence("abc",oneDigitPrimes())] // => ["a","b","c",2,3,5,7]
数组的forEach()方法通常是遍历数组元素的一种优雅方式,因此你可能会尝试像这样编写sequence()函数:
function* sequence(...iterables) {
iterables.forEach(iterable => yield* iterable ); // Error
}
然而,这是行不通的。yield和yield*只能在生成器函数内部使用,但是这段代码中的嵌套箭头函数是一个普通函数,而不是function*生成器函数,因此不允许使用yield。
yield*可以与任何类型的可迭代对象一起使用,包括使用生成器实现的可迭代对象。这意味着yield*允许我们定义递归生成器,你可以使用这个特性来允许对递归定义的树结构进行简单的非递归迭代,例如。
12.4 高级生成器功能
生成器函数最常见的用途是创建迭代器,但生成器的基本特性是允许我们暂停计算,产生中间结果,然后稍后恢复计算。这意味着生成器具有超出迭代器的功能,并且我们将在以下部分探讨这些功能。
12.4.1 生成器函数的返回值
到目前为止,我们看到的生成器函数没有return语句,或者如果有的话,它们被用来导致早期返回,而不是返回一个值。不过,与任何函数一样,生成器函数可以返回一个值。为了理解在这种情况下会发生什么,回想一下迭代的工作原理。next()函数的返回值是一个具有value属性和/或done属性的对象。对于典型的迭代器和生成器,如果value属性被定义,则done属性未定义或为false。如果done为true,则value为未定义。但是对于返回值的生成器,最后一次调用next会返回一个同时定义了value和done的对象。value属性保存生成器函数的返回值,done属性为true,表示没有更多的值可迭代。这个最终值被for/of循环和展开运算符忽略,但对于手动使用显式调用next()的代码是可用的:
function *oneAndDone() {
yield 1;
return "done";
}
// The return value does not appear in normal iteration.
[...oneAndDone()] // => [1]
// But it is available if you explicitly call next()
let generator = oneAndDone();
generator.next() // => { value: 1, done: false}
generator.next() // => { value: "done", done: true }
// If the generator is already done, the return value is not returned again
generator.next() // => { value: undefined, done: true }
12.4.2 yield 表达式的值
在前面的讨论中,我们将yield视为接受值但没有自身值的语句。实际上,yield是一个表达式,它可以有一个值。
当调用生成器的next()方法时,生成器函数运行直到达到yield表达式。yield关键字后面的表达式被评估,该值成为next()调用的返回值。此时,生成器函数在评估yield表达式的过程中停止执行。下次调用生成器的next()方法时,传递给next()的参数成为暂停的yield表达式的值。因此,生成器通过yield向其调用者返回值,调用者通过next()向生成器传递值。生成器和调用者是两个独立的执行流,来回传递值(和控制)。以下代码示例:
function* smallNumbers() {
console.log("next() invoked the first time; argument discarded");
let y1 = yield 1; // y1 == "b"
console.log("next() invoked a second time with argument", y1);
let y2 = yield 2; // y2 == "c"
console.log("next() invoked a third time with argument", y2);
let y3 = yield 3; // y3 == "d"
console.log("next() invoked a fourth time with argument", y3);
return 4;
}
let g = smallNumbers();
console.log("generator created; no code runs yet");
let n1 = g.next("a"); // n1.value == 1
console.log("generator yielded", n1.value);
let n2 = g.next("b"); // n2.value == 2
console.log("generator yielded", n2.value);
let n3 = g.next("c"); // n3.value == 3
console.log("generator yielded", n3.value);
let n4 = g.next("d"); // n4 == { value: 4, done: true }
console.log("generator returned", n4.value);
当运行这段代码时,会产生以下输出,展示了两个代码块之间的来回交互:
generator created; no code runs yet
next() invoked the first time; argument discarded
generator yielded 1
next() invoked a second time with argument b
generator yielded 2
next() invoked a third time with argument c
generator yielded 3
next() invoked a fourth time with argument d
generator returned 4
注意这段代码中的不对称性。第一次调用next()启动了生成器,但传递给该调用的值对生成器不可访问。
12.4.3 生成器的 return()和 throw()方法
我们已经看到可以接收生成器函数产生的值。您可以通过在调用生成器的next()方法时传递这些值来向正在运行的生成器传递值。
除了使用next()向生成器提供输入外,还可以通过调用其return()和throw()方法来更改生成器内部的控制流。如其名称所示,调用这些方法会导致生成器返回一个值或抛出异常,就好像生成器中的下一条语句是return或throw一样。
在本章的前面提到,如果迭代器定义了一个return()方法并且迭代提前停止,那么解释器会自动调用return()方法,以便让迭代器有机会关闭文件或进行其他清理工作。对于生成器来说,你不能定义一个自定义的return()方法来处理清理工作,但你可以结构化生成器代码以使用try/finally语句,在生成器返回时确保必要的清理工作已完成(在finally块中)。通过强制生成器返回,生成器的内置return()方法确保在生成器不再使用时运行清理代码。
就像生成器的next()方法允许我们向正在运行的生成器传递任意值一样,生成器的throw()方法给了我们一种向生成器发送任意信号(以异常的形式)的方法。调用throw()方法总是在生成器内部引发异常。但如果生成器函数编写了适当的异常处理代码,异常不必是致命的,而可以是改变生成器行为的手段。例如,想象一个计数器生成器,产生一个不断增加的整数序列。这可以被编写成使用throw()发送的异常将计数器重置为零。
当生成器使用yield*从其他可迭代对象中产生值时,那么对生成器的next()方法的调用会导致对可迭代对象的next()方法的调用。return()和throw()方法也是如此。如果生成器在可迭代对象上使用yield*,那么在生成器上调用return()或throw()会导致依次调用迭代器的return()或throw()方法。所有迭代器必须有一个next()方法。需要在不完整迭代后进行清理的迭代器应该定义一个return()方法。任何迭代器可以定义一个throw()方法,尽管我不知道任何实际原因这样做。
12.4.4 关于生成器的最后说明
生成器是一种非常强大的通用控制结构。它们使我们能够使用yield暂停计算,并在任意后续时间点以任意输入值重新启动。可以使用生成器在单线程 JavaScript 代码中创建一种协作线程系统。也可以使用生成器掩盖程序中的异步部分,使你的代码看起来是顺序和同步的,尽管你的一些函数调用实际上是异步的并依赖于网络事件。
尝试用生成器做这些事情会导致代码难以理解或解释。然而,已经做到了,唯一真正实用的用例是管理异步代码。然而,JavaScript 现在有async和await关键字(见第十三章)用于这个目的,因此不再有任何滥用生成器的理由。
12.5 总结
在本章中,你学到了:
-
for/of循环和...扩展运算符适用于可迭代对象。 -
如果一个对象有一个名为
[Symbol.iterator]的方法返回一个迭代器对象,那么它就是可迭代的。 -
迭代器对象有一个
next()方法返回一个迭代结果对象。 -
迭代结果对象有一个
value属性,保存下一个迭代的值(如果有的话)。如果迭代已完成,则结果对象必须将done属性设置为true。 -
你可以通过定义一个
[Symbol.iterator]()方法返回一个具有next()方法返回迭代结果对象的对象来实现自己的可迭代对象。你也可以实现接受迭代器参数并返回迭代器值的函数。 -
生成器函数(使用
function*而不是function定义的函数)是定义迭代器的另一种方式。 -
当调用生成器函数时,函数体不会立即运行;相反,返回值是一个可迭代的迭代器对象。每次调用迭代器的
next()方法时,生成器函数的另一个块会运行。 -
生成器函数可以使用
yield运算符指定迭代器返回的值。每次调用next()都会导致生成器函数运行到下一个yield表达式。该yield表达式的值然后成为迭代器返回的值。当没有更多的yield表达式时,生成器函数返回,迭代完成。
第十三章:异步 JavaScript
一些计算机程序,如科学模拟和机器学习模型,是计算密集型的:它们持续运行,不间断,直到计算出结果为止。然而,大多数现实世界的计算机程序都是显著异步的。这意味着它们经常需要在等待数据到达或某个事件发生时停止计算。在 Web 浏览器中,JavaScript 程序通常是事件驱动的,这意味着它们等待用户点击或轻触才会实际执行任何操作。而基于 JavaScript 的服务器通常在等待客户端请求通过网络到达之前不会执行任何操作。
这种异步编程在 JavaScript 中很常见,本章记录了三个重要的语言特性,帮助简化处理异步代码。Promise 是 ES6 中引入的对象,表示尚未可用的异步操作的结果。关键字async和await是在 ES2017 中引入的,通过允许你将基于 Promise 的代码结构化为同步的形式,简化了异步编程的语法。最后,在 ES2018 中引入了异步迭代器和for/await循环,允许你使用看似同步的简单循环处理异步事件流。
具有讽刺意味的是,尽管 JavaScript 提供了这些强大的功能来处理异步代码,但核心语法本身没有异步特性。因此,为了演示 Promise、async、await和for/await,我们将首先进入客户端和服务器端 JavaScript,解释 Web 浏览器和 Node 的一些异步特性。(你可以在第十五章和第十六章了解更多关于客户端和服务器端 JavaScript 的内容。)
13.1 使用回调进行异步编程
在 JavaScript 中,异步编程的最基本层次是通过回调完成的。回调是你编写并传递给其他函数的函数。当满足某些条件或发生某些(异步)事件时,另一个函数会调用(“回调”)你的函数。你提供的回调函数的调用会通知你条件或事件,并有时,调用会包括提供额外细节的函数参数。通过一些具体的例子更容易理解,接下来的小节演示了使用客户端 JavaScript 和 Node 进行基于回调的异步编程的各种形式。
13.1.1 定时器
最简单的异步之一是当你想在一定时间后运行一些代码时。正如我们在§11.10 中看到的,你可以使用setTimeout()函数来实现:
setTimeout(checkForUpdates, 60000);
setTimeout()的第一个参数是一个函数,第二个是以毫秒为单位的时间间隔。在上述代码中,一个假设的checkForUpdates()函数将在setTimeout()调用后的 60,000 毫秒(1 分钟)后被调用。checkForUpdates()是你的程序可能定义的回调函数,setTimeout()是你调用以注册回调函数并指定在何种异步条件下调用它的函数。
setTimeout()调用指定的回调函数一次,不传递任何参数,然后忘记它。如果你正在编写一个真正检查更新的函数,你可能希望它重复运行。你可以使用setInterval()而不是setTimeout()来实现这一点:
// Call checkForUpdates in one minute and then again every minute after that
let updateIntervalId = setInterval(checkForUpdates, 60000);
// setInterval() returns a value that we can use to stop the repeated
// invocations by calling clearInterval(). (Similarly, setTimeout()
// returns a value that you can pass to clearTimeout())
function stopCheckingForUpdates() {
clearInterval(updateIntervalId);
}
13.1.2 事件
客户端 JavaScript 程序几乎普遍是事件驱动的:而不是运行某种预定的计算,它们通常等待用户执行某些操作,然后响应用户的动作。当用户在键盘上按键、移动鼠标、点击鼠标按钮或触摸触摸屏设备时,Web 浏览器会生成一个事件。事件驱动的 JavaScript 程序在指定的上下文中为指定类型的事件注册回调函数,当指定的事件发生时,Web 浏览器会调用这些函数。这些回调函数称为事件处理程序或事件监听器,并使用addEventListener()进行注册:
// Ask the web browser to return an object representing the HTML
// <button> element that matches this CSS selector
let okay = document.querySelector('#confirmUpdateDialog button.okay');
// Now register a callback function to be invoked when the user
// clicks on that button.
okay.addEventListener('click', applyUpdate);
在这个例子中,applyUpdate()是一个我们假设在其他地方实现的虚构回调函数。调用document.querySelector()返回一个表示 Web 页面中单个指定元素的对象。我们在该元素上调用addEventListener()来注册我们的回调。然后addEventListener()的第一个参数是一个字符串,指定我们感兴趣的事件类型——在这种情况下是鼠标点击或触摸屏点击。如果用户点击或触摸 Web 页面的特定元素,那么浏览器将调用我们的applyUpdate()回调函数,传递一个包含有关事件的详细信息(如时间和鼠标指针坐标)的对象。
13.1.3 网络事件
JavaScript 编程中另一个常见的异步来源是网络请求。在浏览器中运行的 JavaScript 可以使用以下代码从 Web 服务器获取数据:
function getCurrentVersionNumber(versionCallback) { // Note callback argument
// Make a scripted HTTP request to a backend version API
let request = new XMLHttpRequest();
request.open("GET", "http://www.example.com/api/version");
request.send();
// Register a callback that will be invoked when the response arrives
request.onload = function() {
if (request.status === 200) {
// If HTTP status is good, get version number and call callback.
let currentVersion = parseFloat(request.responseText);
versionCallback(null, currentVersion);
} else {
// Otherwise report an error to the callback
versionCallback(response.statusText, null);
}
};
// Register another callback that will be invoked for network errors
request.onerror = request.ontimeout = function(e) {
versionCallback(e.type, null);
};
}
客户端 JavaScript 代码可以使用 XMLHttpRequest 类加上回调函数来进行 HTTP 请求,并在服务器响应到达时异步处理。¹ 这里定义的getCurrentVersionNumber()函数(我们可以想象它被假设的checkForUpdates()函数使用,我们在§13.1.1 中讨论过)发出 HTTP 请求,并定义在接收到服务器响应或超时或其他错误导致请求失败时将被调用的事件处理程序。
请注意,上面的代码示例不像我们之前的示例那样调用addEventListener()。对于大多数 Web API(包括此示例),可以通过在生成事件的对象上调用addEventListener()并传递感兴趣的事件名称以及回调函数来定义事件处理程序。通常,您也可以通过将其直接分配给对象的属性来注册单个事件监听器。这就是我们在这个示例代码中所做的,将函数分配给onload、onerror和ontimeout属性。按照惯例,像这样的事件监听器属性总是以on开头的名称。addEventListener()是更灵活的技术,因为它允许注册多个事件处理程序。但在确保没有其他代码需要为相同的对象和事件类型注册监听器的情况下,直接将适当的属性设置为您的回调可能更简单。
在这个示例代码中关于getCurrentVersionNumber()函数的另一点需要注意的是,由于它发出了一个异步请求,它无法同步返回调用者感兴趣的值(当前版本号)。相反,调用者传递一个回调函数,当结果准备就绪或发生错误时调用。在这种情况下,调用者提供了一个期望两个参数的回调函数。如果 XMLHttpRequest 正常工作,那么getCurrentVersionNumber()会用null作为第一个参数,版本号作为第二个参数调用回调。或者,如果发生错误,那么getCurrentVersionNumber()会用错误详细信息作为第一个参数,null作为第二个参数调用回调。
13.1.4 Node 中的回调和事件
Node.js 服务器端 JavaScript 环境是深度异步的,并定义了许多使用回调和事件的 API。例如,读取文件内容的默认 API 是异步的,并在文件内容被读取后调用回调函数:
const fs = require("fs"); // The "fs" module has filesystem-related APIs
let options = { // An object to hold options for our program
// default options would go here
};
// Read a configuration file, then call the callback function
fs.readFile("config.json", "utf-8", (err, text) => {
if (err) {
// If there was an error, display a warning, but continue
console.warn("Could not read config file:", err);
} else {
// Otherwise, parse the file contents and assign to the options object
Object.assign(options, JSON.parse(text));
}
// In either case, we can now start running the program
startProgram(options);
});
Node 的fs.readFile()函数将一个两参数回调作为其最后一个参数。它异步读取指定的文件,然后调用回调。如果文件成功读取,它将文件内容作为第二个回调参数传递。如果出现错误,它将错误作为第一个回调参数传递。在这个例子中,我们将回调表达为箭头函数,这是一种简洁和自然的语法,适用于这种简单操作。
Node 还定义了许多基于事件的 API。以下函数展示了如何在 Node 中请求 URL 的内容。它有两层通过事件监听器处理的异步代码。请注意,Node 使用on()方法来注册事件监听器,而不是addEventListener():
const https = require("https");
// Read the text content of the URL and asynchronously pass it to the callback.
function getText(url, callback) {
// Start an HTTP GET request for the URL
request = https.get(url);
// Register a function to handle the "response" event.
request.on("response", response => {
// The response event means that response headers have been received
let httpStatus = response.statusCode;
// The body of the HTTP response has not been received yet.
// So we register more event handlers to to be called when it arrives.
response.setEncoding("utf-8"); // We're expecting Unicode text
let body = ""; // which we will accumulate here.
// This event handler is called when a chunk of the body is ready
response.on("data", chunk => { body += chunk; });
// This event handler is called when the response is complete
response.on("end", () => {
if (httpStatus === 200) { // If the HTTP response was good
callback(null, body); // Pass response body to the callback
} else { // Otherwise pass an error
callback(httpStatus, null);
}
});
});
// We also register an event handler for lower-level network errors
request.on("error", (err) => {
callback(err, null);
});
}
13.2 承诺
现在我们已经在客户端和服务器端 JavaScript 环境中看到了回调和基于事件的异步编程的示例,我们可以介绍承诺,这是一个旨在简化异步编程的核心语言特性。
承诺是表示异步计算结果的对象。该结果可能已经准备好,也可能尚未准备好,承诺 API 故意对此保持模糊:没有同步获取承诺值的方法;您只能要求承诺在值准备好时调用回调函数。如果您正在定义一个类似前一节中的getText()函数的异步 API,但希望将其基于承诺,省略回调参数,而是返回一个承诺对象。调用者可以在这个承诺对象上注册一个或多个回调,当异步计算完成时,它们将被调用。
因此,在最简单的层面上,承诺只是一种与回调一起工作的不同方式。然而,使用它们有实际的好处。基于回调的异步编程的一个真正问题是,通常会出现回调内嵌在回调内嵌在回调中的情况,代码行缩进如此之深,以至于难以阅读。承诺允许将这种嵌套回调重新表达为更线性的承诺链,这样更容易阅读和推理。
回调函数的另一个问题是,它们可能会使处理错误变得困难。如果异步函数(或异步调用的回调)抛出异常,那么这个异常就无法传播回异步操作的发起者。这是关于异步编程的一个基本事实:它破坏了异常处理。另一种方法是通过回调参数和返回值来细致地跟踪和传播错误,但这样做很繁琐,很难做到正确。承诺在这里有所帮助,通过标准化处理错误的方式,并提供一种让错误正确传播通过一系列承诺的方法。
请注意,承诺代表单个异步计算的未来结果。然而,它们不能用于表示重复的异步计算。在本章的后面,我们将编写一个基于承诺的setTimeout()函数的替代方案。但我们不能使用承诺来替代setInterval(),因为该函数会重复调用回调函数,而这是承诺设计上不支持的。同样地,我们可以使用承诺来替代 XMLHttpRequest 对象的“load”事件处理程序,因为该回调只会被调用一次。但通常情况下,我们不会使用承诺来替代 HTML 按钮对象的“click”事件处理程序,因为我们通常希望允许用户多次点击按钮。
接下来的小节将:
-
解释承诺术语并展示基本承诺用法
-
展示 Promises 如何被链式调用
-
展示如何创建自己的基于 Promise 的 API
重要
起初,Promise 似乎很简单,事实上,Promise 的基本用例确实简单明了。但是,对于超出最简单用例的任何情况,它们可能变得令人惊讶地令人困惑。Promise 是异步编程的强大习语,但你需要深入理解才能正确自信地使用它们。然而,花时间深入了解是值得的,我敦促你仔细研究这一长章节。
13.2.1 使用 Promises
随着 Promises 在核心 JavaScript 语言中的出现,Web 浏览器已经开始实现基于 Promise 的 API。在前一节中,我们实现了一个getText()函数,该函数发起了一个异步的 HTTP 请求,并将 HTTP 响应的主体作为字符串传递给指定的回调函数。想象一个这个函数的变体,getJSON(),它将 HTTP 响应的主体解析为 JSON,并返回一个 Promise,而不是接受一个回调参数。我们将在本章后面实现一个getJSON()函数,但现在,让我们看看如何使用这个返回 Promise 的实用函数:
getJSON(url).then(jsonData => {
// This is a callback function that will be asynchronously
// invoked with the parsed JSON value when it becomes available.
});
getJSON()启动一个异步的 HTTP 请求,请求指定的 URL,然后,在该请求挂起期间,它返回一个 Promise 对象。Promise 对象定义了一个then()实例方法。我们不直接将回调函数传递给getJSON(),而是将其传递给then()方法。当 HTTP 响应到达时,该响应的主体被解析为 JSON,并将解析后的值传递给我们传递给then()的函数。
你可以将then()方法看作是一个回调注册方法,类似于用于在客户端 JavaScript 中注册事件处理程序的addEventListener()方法。如果多次调用 Promise 对象的then()方法,每个指定的函数都将在承诺的计算完成时被调用。
与许多事件侦听器不同,Promise 代表一个单一的计算,每个注册到then()的函数只会被调用一次。值得注意的是,无论何时调用then(),你传递给then()的函数都会异步调用,即使异步计算在调用then()时已经完成。
在简单的语法层面上,then()方法是 Promise 的独特特征,习惯上直接将.then()附加到返回 Promise 的函数调用上,而不是将 Promise 对象分配给变量的中间步骤。
习惯上,将返回 Promises 的函数和使用 Promises 结果的函数命名为动词,这些习惯导致的代码特别易于阅读:
// Suppose you have a function like this to display a user profile
function displayUserProfile(profile) { /* implementation omitted */ }
// Here's how you might use that function with a Promise.
// Notice how this line of code reads almost like an English sentence:
getJSON("/api/user/profile").then(displayUserProfile);
使用 Promises 处理错误
异步操作,特别是涉及网络的操作,通常会以多种方式失败,必须编写健壮的代码来处理不可避免发生的错误。
对于 Promises,我们可以通过将第二个函数传递给then()方法来实现:
getJSON("/api/user/profile").then(displayUserProfile, handleProfileError);
Promise 代表在 Promise 对象创建后发生的异步计算的未来结果。因为计算是在 Promise 对象返回给我们后执行的,所以传统上计算无法返回一个值或抛出我们可以捕获的异常。我们传递给then()的函数提供了替代方案。当同步计算正常完成时,它只是将其结果返回给调用者。当基于 Promise 的异步计算正常完成时,它将其结果传递给作为then()的第一个参数的函数。
当同步计算出现问题时,它会抛出一个异常,该异常会向上传播到调用堆栈,直到有一个catch子句来处理它。当异步计算运行时,其调用者不再在堆栈上,因此如果出现问题,就不可能将异常抛回给调用者。
相反,基于 Promise 的异步计算将异常(通常作为某种类型的 Error 对象,尽管这不是必需的)传递给then()的第二个函数。因此,在上面的代码中,如果getJSON()正常运行,它会将结果传递给displayUserProfile()。如果出现错误(用户未登录、服务器宕机、用户的互联网连接中断、请求超时等),那么getJSON()会将一个 Error 对象传递给handleProfileError()。
在实践中,很少看到两个函数传递给then()。在处理 Promise 时,有一种更好的、更符合习惯的处理错误的方式。要理解这一点,首先考虑一下如果getJSON()正常完成,但displayUserProfile()中出现错误会发生什么。当getJSON()返回时,回调函数会异步调用,因此它也是异步的,不能有意义地抛出异常(因为没有代码在调用堆栈上处理它)。
在这段代码中处理错误的更符合习惯的方式如下:
getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileError);
使用这段代码,getJSON()的正常结果仍然会传递给displayUserProfile(),但是getJSON()或displayUserProfile()中的任何错误(包括displayUserProfile抛出的任何异常)都会传递给handleProfileError()。catch()方法只是调用then()的一种简写形式,第一个参数为null,第二个参数为指定的错误处理函数。
当我们讨论下一节的 Promise 链时,我们将会更多地谈到catch()和这种错误处理习惯。
13.2.2 链式 Promise
Promise 最重要的好处之一是它们提供了一种自然的方式来将一系列异步操作表达为then()方法调用的线性链,而无需将每个操作嵌套在前一个操作的回调中。例如,这里是一个假设的 Promise 链:
fetch(documentURL) // Make an HTTP request
.then(response => response.json()) // Ask for the JSON body of the response
.then(document => { // When we get the parsed JSON
return render(document); // display the document to the user
})
.then(rendered => { // When we get the rendered document
cacheInDatabase(rendered); // cache it in the local database.
})
.catch(error => handle(error)); // Handle any errors that occur
这段代码说明了一系列 Promise 如何简单地表达一系列异步操作的过程。然而,我们不会讨论这个特定的 Promise 链。不过,我们将继续探讨使用 Promise 链进行 HTTP 请求的想法。
在本章的前面,我们看到了在 JavaScript 中使用 XMLHttpRequest 对象进行 HTTP 请求。这个奇怪命名的对象具有一个古老且笨拙的 API,它已经大部分被新的、基于 Promise 的 Fetch API(§15.11.1)所取代。在其最简单的形式中,这个新的 HTTP API 就是函数fetch()。你传递一个 URL 给它,它会返回一个 Promise。当 HTTP 响应开始到达并且 HTTP 状态和头部可用时,这个 Promise 就会被实现:
fetch("/api/user/profile").then(response => {
// When the promise resolves, we have status and headers
if (response.ok &&
response.headers.get("Content-Type") === "application/json") {
// What can we do here? We don't actually have the response body yet.
}
});
当fetch()返回的 Promise 被实现时,它会将一个 Response 对象传递给您传递给其then()方法的函数。这个响应对象让您可以访问请求状态和头部,并且还定义了像text()和json()这样的方法,分别以文本和 JSON 解析形式访问响应主体。但是尽管初始 Promise 被实现,响应主体可能尚未到达。因此,用于访问响应主体的这些text()和json()方法本身返回 Promise。以下是使用fetch()和response.json()方法获取 HTTP 响应主体的一种天真的方法:
fetch("/api/user/profile").then(response => {
response.json().then(profile => { // Ask for the JSON-parsed body
// When the body of the response arrives, it will be automatically
// parsed as JSON and passed to this function.
displayUserProfile(profile);
});
});
这是一种天真地使用 Promise 的方式,因为我们像回调一样嵌套它们,这违背了初衷。更好的习惯是使用 Promise 在一个顺序链中编写代码,就像这样:
fetch("/api/user/profile")
.then(response => {
return response.json();
})
.then(profile => {
displayUserProfile(profile);
});
让我们看一下这段代码中的方法调用,忽略传递给方法的参数:
fetch().then().then()
当在一个表达式中调用多个方法时,我们称之为方法链。我们知道fetch()函数返回一个 Promise 对象,我们可以看到这个链中的第一个.then()调用在返回的 Promise 对象上调用一个方法。但是链中还有第二个.then(),这意味着then()方法的第一次调用本身必须返回一个 Promise。
有时,当设计 API 以使用这种方法链时,只有一个对象,并且该对象的每个方法都返回对象本身以便于链接。然而,这并不是 Promise 的工作方式。当我们编写一系列.then()调用时,我们并不是在单个 Promise 对象上注册多个回调。相反,then()方法的每次调用都会返回一个新的 Promise 对象。直到传递给then()的函数完成,新的 Promise 对象才会被实现。
让我们回到上面原始fetch()链的简化形式。如果我们在其他地方定义传递给then()调用的函数,我们可以重构代码如下:
fetch(theURL) // task 1; returns promise 1
.then(callback1) // task 2; returns promise 2
.then(callback2); // task 3; returns promise 3
让我们详细讨论一下这段代码:
-
在第一行,使用一个 URL 调用
fetch()。它为该 URL 发起一个 HTTP GET 请求并返回一个 Promise。我们将这个 HTTP 请求称为“任务 1”,将 Promise 称为“promise 1”。 -
在第二行,我们调用 promise 1 的
then()方法,传递我们希望在 promise 1 实现时调用的callback1函数。then()方法将我们的回调存储在某个地方,然后返回一个新的 Promise。我们将在这一步返回的新 Promise 称为“promise 2”,并且我们将说“任务 2”在调用callback1时开始。 -
在第三行,我们调用 promise 2 的
then()方法,传递我们希望在 promise 2 实现时调用的callback2函数。这个then()方法记住我们的回调并返回另一个 Promise。我们将说“任务 3”在调用callback2时开始。我们可以称这个最新的 Promise 为“promise 3”,但实际上我们不需要为它命名,因为我们根本不会使用它。 -
前三个步骤都是在表达式首次执行时同步发生的。现在,在 HTTP 请求在步骤 1 中发出并通过互联网发送时,我们有一个异步暂停。
-
最终,HTTP 响应开始到达。
fetch()调用的异步部分将 HTTP 状态和标头包装在一个 Response 对象中,并使用该 Response 对象作为值来实现 promise 1。 -
当 promise 1 被实现时,它的值(Response 对象)被传递给我们的
callback1()函数,任务 2 开始。这个任务的工作是,给定一个 Response 对象作为输入,获取响应主体作为 JSON 对象。 -
让我们假设任务 2 正常完成,并且能够解析 HTTP 响应的主体以生成一个 JSON 对象。这个 JSON 对象用于实现 promise 2。
-
实现 promise 2 的值成为传递给
callback2()函数时任务 3 的输入。当任务 3 完成(假设它正常完成)时,promise 3 将被实现。但因为我们从未对 promise 3 做任何操作,当该 Promise 完成时什么也不会发生,异步计算链在这一点结束。
13.2.3 解决 Promise
在上一节中解释了与列表中的 URL 获取 Promise 链相关的内容时,我们谈到了 promise 1、2 和 3。但实际上还涉及第四个 Promise 对象,这将引出我们对 Promise“解决”意味着什么的重要讨论。
请记住,fetch()返回一个 Promise 对象,当实现时,将传递一个 Response 对象给我们注册的回调函数。这个 Response 对象有.text()、.json()和其他方法以各种形式请求 HTTP 响应的主体。但是由于主体可能尚未到达,这些方法必须返回 Promise 对象。在我们一直在研究的示例中,“任务 2”调用.json()方法并返回其值。这是第四个 Promise 对象,也是callback1()函数的返回值。
让我们再次以冗长和非成语化的方式重写 URL 获取代码,使回调和 Promises 明确:
function c1(response) { // callback 1
let p4 = response.json();
return p4; // returns promise 4
}
function c2(profile) { // callback 2
displayUserProfile(profile);
}
let p1 = fetch("/api/user/profile"); // promise 1, task 1
let p2 = p1.then(c1); // promise 2, task 2
let p3 = p2.then(c2); // promise 3, task 3
为了使 Promise 链有用地工作,任务 2 的输出必须成为任务 3 的输入。在我们正在考虑的示例中,任务 3 的输入是获取的 URL 主体,解析为 JSON 对象。但是,正如我们刚才讨论的,回调c1的返回值不是 JSON 对象,而是该 JSON 对象的 Promisep4。这似乎是一个矛盾,但实际上不是:当p1被实现时,c1被调用,任务 2 开始。当p2被实现时,c2被调用,任务 3 开始。但是仅仅因为在调用c1时任务 2 开始,并不意味着任务 2 在c1返回时必须结束。毕竟,Promises 是关于管理异步任务的,如果任务 2 是异步的(在这种情况下是),那么在回调返回时该任务将尚未完成。
现在我们准备讨论您需要真正掌握 Promises 的最后一个细节。当您将回调c传递给then()方法时,then()返回一个 Promisep并安排在稍后的某个时间异步调用c。回调执行一些计算并返回一个值v。当回调返回时,p被解析为值v。当一个 Promise 被解析为一个不是 Promise 的值时,它会立即被实现为该值。因此,如果c返回一个非 Promise,那么返回值就成为p的值,p被实现,我们完成了。但是如果返回值v本身是一个 Promise,那么p被解析但尚未实现。在这个阶段,p不能解决,直到 Promisev解决。如果v被实现,那么p将被实现为相同的值。如果v被拒绝,那么p将因同样的原因被拒绝。这就是 Promise“解析”状态的含义:Promise 已经与另一个 Promise 关联或“锁定”。我们还不知道p是否会被实现或被拒绝,但是我们的回调c不再控制这一点。p“解析”意味着它的命运现在完全取决于 Promisev的发生。
让我们回到我们的 URL 获取示例。当c1返回p4时,p2被解析。但被解析并不意味着被实现,所以任务 3 还没有开始。当完整的 HTTP 响应主体可用时,.json()方法可以解析它并使用解析后的值来实现p4。当p4被实现时,p2也会自动被实现,具有相同的解析 JSON 值。此时,解析后的 JSON 对象被传递给c2,任务 3 开始。
这可能是 JavaScript 中最难理解的部分之一,您可能需要阅读本节不止一次。图 13-1 以可视化形式呈现了这个过程,可能有助于为您澄清。

图 13-1. 使用 Promises 获取 URL
13.2.4 更多关于 Promises 和错误
在本章的前面,我们看到您可以将第二个回调函数传递给.then()方法,并且如果 Promise 被拒绝,则将调用此第二个函数。当发生这种情况时,传递给此第二个回调函数的参数是一个值—通常是代表拒绝原因的 Error 对象。我们还了解到,通过向 Promise 链中添加.catch()方法调用来处理 Promise 相关的错误是不常见的(甚至是不成文的)。现在我们已经检查了 Promise 链,我们可以回到错误处理并更详细地讨论它。在讨论之前,我想强调的是,在进行异步编程时,仔细处理错误非常重要。对于同步代码,如果您省略了错误处理代码,您至少会得到一个异常和堆栈跟踪,以便您可以找出出了什么问题。对于异步代码,未处理的异常通常不会被报告,错误可能会悄无声息地发生,使得调试变得更加困难。好消息是,.catch()方法使得在处理 Promise 时处理错误变得容易。
catch 和 finally 方法
Promise 的.catch()方法只是一种使用null作为第一个参数并将错误处理回调作为第二个参数调用.then()的简写方式。给定任何 Promisep和回调c,以下两行是等效的:
p.then(null, c);
p.catch(c);
.catch()简写更受欢迎,因为它更简单,并且名称与try/catch异常处理语句中的catch子句匹配。正如我们讨论过的,普通异常在异步代码中不起作用。Promise 的.catch()方法是一种适用于异步代码的替代方法。当同步代码出现问题时,我们可以说异常“沿着调用堆栈上升”直到找到catch块。对于 Promise 链的异步链,类似的隐喻可能是错误“沿着链路下滑”,直到找到.catch()调用。
在 ES2018 中,Promise 对象还定义了一个.finally()方法,其目的类似于try/catch/finally语句中的finally子句。如果您在 Promise 链中添加一个.finally()调用,那么您传递给.finally()的回调将在您调用它的 Promise 完成时被调用。如果 Promise 完成或拒绝,都会调用您的回调,并且不会传递任何参数,因此您无法找出它是完成还是拒绝。但是,如果您需要在任一情况下运行某种清理代码(例如关闭打开的文件或网络连接),则.finally()回调是执行此操作的理想方式。与.then()和.catch()一样,.finally()返回一个新的 Promise 对象。.finally()回调的返回值通常被忽略,而由.finally()返回的 Promise 通常将使用与调用.finally()的 Promise 解析或拒绝的相同值解析或拒绝。但是,如果.finally()回调引发异常,则由.finally()返回的 Promise 将以该值拒绝。
我们在前几节中学习的 URL 获取代码没有进行任何错误处理。现在让我们通过代码的更实际版本来纠正这一点:
fetch("/api/user/profile") // Start the HTTP request
.then(response => { // Call this when status and headers are ready
if (!response.ok) { // If we got a 404 Not Found or similar error
return null; // Maybe user is logged out; return null profile
}
// Now check the headers to ensure that the server sent us JSON.
// If not, our server is broken, and this is a serious error!
let type = response.headers.get("content-type");
if (type !== "application/json") {
throw new TypeError(`Expected JSON, got ${type}`);
}
// If we get here, then we got a 2xx status and a JSON content-type
// so we can confidently return a Promise for the response
// body as a JSON object.
return response.json();
})
.then(profile => { // Called with the parsed response body or null
if (profile) {
displayUserProfile(profile);
}
else { // If we got a 404 error above and returned null we end up here
displayLoggedOutProfilePage();
}
})
.catch(e => {
if (e instanceof NetworkError) {
// fetch() can fail this way if the internet connection is down
displayErrorMessage("Check your internet connection.");
}
else if (e instanceof TypeError) {
// This happens if we throw TypeError above
displayErrorMessage("Something is wrong with our server!");
}
else {
// This must be some kind of unanticipated error
console.error(e);
}
});
让我们通过分析当事情出错时会发生什么来分析这段代码。我们将使用之前使用的命名方案:p1是fetch()调用返回的 Promise。p2是第一个.then()调用返回的 Promise,c1是我们传递给该.then()调用的回调。p3是第二个.then()调用返回的 Promise,c2是我们传递给该调用的回调。最后,c3是我们传递给.catch()调用的回调。(该调用返回一个 Promise,但我们不需要通过名称引用它。)
可能失败的第一件事是 fetch() 请求本身。如果网络连接断开(或由于某种原因无法进行 HTTP 请求),那么 Promise p1 将被拒绝,并带有一个 NetworkError 对象。我们没有将错误处理回调函数作为第二个参数传递给 .then() 调用,因此 p2 也将以相同的 NetworkError 对象被拒绝。(如果我们向第一个 .then() 调用传递了错误处理程序,错误处理程序将被调用,如果它正常返回,p2 将被解析和/或完成,并带有该处理程序的返回值。)然而,没有处理程序,p2 被拒绝,然后 p3 由于相同原因被拒绝。此时,c3 错误处理回调被调用,并其中的 NetworkError 特定代码运行。
我们的代码可能失败的另一种方式是,如果我们的 HTTP 请求返回 404 Not Found 或其他 HTTP 错误。这些是有效的 HTTP 响应,因此 fetch() 调用不认为它们是错误。fetch() 将 404 Not Found 封装在一个 Response 对象中,并用该对象完成 p1,导致调用 c1。我们在 c1 中的代码检查 Response 对象的 ok 属性,以检测是否收到了正常的 HTTP 响应,并通过简单返回 null 处理这种情况。因为这个返回值不是一个 Promise,它立即完成 p2,并用这个值调用 c2。我们在 c2 中明确检查和处理 falsy 值,通过向用户显示不同的结果来处理这种情况。这是一个我们将异常条件视为非错误并在不使用错误处理程序的情况下处理它的案例。
如果我们得到一个正常的 HTTP 响应代码,但 Content-Type 头部未正确设置,c1 中会发生一个更严重的错误。我们的代码期望一个 JSON 格式的响应,所以如果服务器发送给我们 HTML、XML 或纯文本,我们将会遇到问题。c1 包含了检查 Content-Type 头部的代码。如果头部错误,它将把这视为一个不可恢复的问题并抛出一个 TypeError。当传递给 .then()(或 .catch())的回调抛出一个值时,作为 .then() 调用的返回值的 Promise 将被拒绝,并带有该抛出的值。在这种情况下,引发 TypeError 的 c1 中的代码导致 p2 被拒绝,并带有该 TypeError 对象。由于我们没有为 p2 指定错误处理程序,p3 也将被拒绝。c2 将不会被调用,并且 TypeError 将传递给 c3,它具有明确检查和处理这种类型错误的代码。
关于这段代码有几点值得注意。首先,请注意,使用常规的同步 throw 语句抛出的错误对象最终会在 Promise 链中的 .catch() 方法调用中异步处理。这应该清楚地说明为什么这种简写方法优先于向 .then() 传递第二个参数,并且为什么在 Promise 链末尾使用 .catch() 调用是如此习惯化的。
在我们离开错误处理的话题之前,我想指出,虽然习惯于在每个 Promise 链的末尾使用 .catch() 来清理(或至少记录)链中发生的任何错误,但在 Promise 链的其他地方使用 .catch() 也是完全有效的。如果你的 Promise 链中的某个阶段可能会因错误而失败,并且如果错误是某种可恢复的错误,不应该阻止链的其余部分运行,那么你可以在链中插入一个 .catch() 调用,代码可能看起来像这样:
startAsyncOperation()
.then(doStageTwo)
.catch(recoverFromStageTwoError)
.then(doStageThree)
.then(doStageFour)
.catch(logStageThreeAndFourErrors);
请记住,您传递给 .catch() 的回调只有在前一个阶段的回调抛出错误时才会被调用。如果回调正常返回,那么 .catch() 回调将被跳过,并且前一个回调的返回值将成为下一个 .then() 回调的输入。还要记住,.catch() 回调不仅用于报告错误,还用于处理和恢复错误。一旦错误传递给 .catch() 回调,它就会停止在 Promise 链中传播。.catch() 回调可以抛出新错误,但如果它正常返回,那么返回值将用于解析和/或实现相关的 Promise,并且错误将停止传播。
让我们具体说明一下:在前面的代码示例中,如果 startAsyncOperation() 或 doStageTwo() 抛出错误,则将调用 recoverFromStageTwoError() 函数。如果 recoverFromStageTwoError() 正常返回,则其返回值将传递给 doStageThree(),异步操作将继续正常进行。另一方面,如果 recoverFromStageTwoError() 无法恢复,则它将抛出错误(或重新抛出传递给它的错误)。在这种情况下,doStageThree() 和 doStageFour() 都不会被调用,并且由 recoverFromStageTwoError() 抛出的错误将传递给 logStageThreeAndFourErrors()。
有时,在复杂的网络环境中,错误可能更多或更少地随机发生,通过简单地重试异步请求来处理这些错误可能是合适的。想象一下,您已经编写了一个基于 Promise 的操作来查询数据库:
queryDatabase()
.then(displayTable)
.catch(displayDatabaseError);
现在假设瞬时网络负载问题导致失败率约为 1%。一个简单的解决方案可能是使用 .catch() 调用重试查询:
queryDatabase()
.catch(e => wait(500).then(queryDatabase)) // On failure, wait and retry
.then(displayTable)
.catch(displayDatabaseError);
如果假设的故障确实是随机的,那么添加这一行代码应该将您的错误率从 1% 降低到 0.01%。
13.2.5 并行的 Promises
我们花了很多时间讨论 Promise 链,用于顺序运行更大异步操作的异步步骤。但有时,我们希望并行执行多个异步操作。函数 Promise.all() 可以做到这一点。Promise.all() 接受一个 Promise 对象数组作为输入,并返回一个 Promise。如果任何输入 Promise 被拒绝,则返回的 Promise 将被拒绝。否则,它将以每个输入 Promise 的实现值数组实现。因此,例如,如果您想获取多个 URL 的文本内容,您可以使用以下代码:
// We start with an array of URLs
const urls = [ /* zero or more URLs here */ ];
// And convert it to an array of Promise objects
promises = urls.map(url => fetch(url).then(r => r.text()));
// Now get a Promise to run all those Promises in parallel
Promise.all(promises)
.then(bodies => { /* do something with the array of strings */ })
.catch(e => console.error(e));
Promise.all() 稍微比之前描述的更灵活。输入数组可以包含 Promise 对象和非 Promise 值。如果数组的元素不是 Promise,则会被视为已实现 Promise 的值,并且会被简单地复制到输出数组中。
Promise.all() 返回的 Promise 在任何输入 Promise 被拒绝时也会被拒绝。这会立即发生在第一个拒绝时,而其他输入 Promise 仍在等待的情况下也可能发生。在 ES2020 中,Promise.allSettled() 接受一个输入 Promise 数组并返回一个 Promise,就像 Promise.all() 一样。但是 Promise.allSettled() 永远不会拒绝返回的 Promise,并且在所有输入 Promise 都已完成之前不会实现该 Promise。该 Promise 解析为一个对象数组,每个输入 Promise 都有一个对象。每个返回的对象都有一个 status 属性,设置为“fulfilled”或“rejected”。如果状态是“fulfilled”,那么对象还将有一个 value 属性,给出实现值。如果状态是“rejected”,那么对象还将有一个 reason 属性,给出相应 Promise 的错误或拒绝值:
Promise.allSettled([Promise.resolve(1), Promise.reject(2), 3]).then(results => {
results[0] // => { status: "fulfilled", value: 1 }
results[1] // => { status: "rejected", reason: 2 }
results[2] // => { status: "fulfilled", value: 3 }
});
有时,您可能希望同时运行多个 Promise,但可能只关心第一个实现的值。在这种情况下,您可以使用Promise.race()而不是Promise.all()。它返回一个 Promise,当输入数组中的 Promise 中的第一个实现或拒绝时,该 Promise 将实现或拒绝。(或者,如果输入数组中有任何非 Promise 值,则简单地返回其中的第一个。)
13.2.6 创建 Promises
在许多先前的示例中,我们使用了返回 Promise 的函数fetch(),因为它是内置到 Web 浏览器中的最简单的返回 Promise 的函数之一。我们对 Promises 的讨论还依赖于假设的返回 Promise 的函数getJSON()和wait()。编写返回 Promises 的函数确实非常有用,本节展示了如何创建基于 Promise 的 API。特别是,我们将展示getJSON()和wait()的实现。
基于其他 Promises 的 Promises
如果您有其他返回 Promise 的函数作为起点,编写返回 Promise 的函数就很容易。给定一个 Promise,您可以通过调用.then()来创建(并返回)一个新的 Promise。因此,如果我们使用现有的fetch()函数作为起点,我们可以这样编写getJSON():
function getJSON(url) {
return fetch(url).then(response => response.json());
}
代码很简单,因为fetch()API 的 Response 对象具有预定义的json()方法。json()方法返回一个 Promise,我们从回调中返回该 Promise(回调是一个带有单表达式主体的箭头函数,因此返回是隐式的),因此getJSON()返回的 Promise 解析为response.json()返回的 Promise。当该 Promise 实现时,由getJSON()返回的 Promise 也实现为相同的值。请注意,此getJSON()实现中没有错误处理。我们不检查response.ok和 Content-Type 头,而是允许json()方法拒绝返回的 Promise,如果响应主体无法解析为 JSON,则会引发 SyntaxError。
让我们再写一个返回 Promise 的函数,这次使用getJSON()作为初始 Promise 的来源。
function getHighScore() {
return getJSON("/api/user/profile").then(profile => profile.highScore);
}
我们假设这个函数是某种基于 Web 的游戏的一部分,并且 URL“/api/user/profile”返回一个包含highScore属性的 JSON 格式数据结构。
基于同步值的 Promises
有时,您可能需要实现现有的基于 Promise 的 API,并从函数返回一个 Promise,即使要执行的计算实际上不需要任何异步操作。在这种情况下,静态方法Promise.resolve()和Promise.reject()将实现您想要的效果。Promise.resolve()以其单个参数作为值,并返回一个将立即(但异步地)实现为该值的 Promise。类似地,Promise.reject()接受一个参数,并返回一个将以该值为原因拒绝的 Promise。(要明确:这些静态方法返回的 Promises 在返回时并未已实现或已拒绝,但它们将在当前同步代码块运行完毕后立即实现或拒绝。通常,除非有许多待处理的异步任务等待运行,否则这将在几毫秒内发生。)
请回顾§13.2.3 中的内容,已解决的 Promise 与已实现的 Promise 不是同一回事。当我们调用Promise.resolve()时,通常会传递实现值以创建一个 Promise 对象,该对象将很快实现为该值。但是该方法的名称不是Promise.fulfill()。如果将 Promisep1传递给Promise.resolve(),它将返回一个新的 Promisep2,该 Promise 立即解决,但直到p1实现或拒绝之前,它才会实现或拒绝。
可以编写一个基于 Promise 的函数,其中值是同步计算的,并使用Promise.resolve()异步返回,尽管这种情况可能不太常见。然而,在异步函数中有同步特殊情况是相当常见的,你可以使用Promise.resolve()和Promise.reject()来处理这些特殊情况。特别是,如果在开始异步操作之前检测到错误条件(例如错误的参数值),你可以通过返回使用Promise.reject()创建的 Promise 来报告该错误。(在这种情况下,你也可以同步抛出错误,但这被认为是不好的做法,因为调用者需要同时编写同步的catch子句和使用异步的.catch()方法来处理错误。)最后,Promise.resolve()有时用于在 Promise 链中创建初始 Promise。我们将看到一些以这种方式使用它的示例。
从头开始的 Promises
对于getJSON()和getHighScore(),我们首先调用现有函数以获取初始 Promise,并通过调用该初始 Promise 的.then()方法创建并返回一个新 Promise。但是,当你无法使用另一个返回 Promise 的函数作为起点时,如何编写返回 Promise 的函数呢?在这种情况下,你可以使用Promise()构造函数创建一个全新的 Promise 对象,你可以完全控制它。操作如下:你调用Promise()构造函数并将一个函数作为其唯一参数传递。你传递的函数应该预期两个参数,按照惯例,应该命名为resolve和reject。构造函数会同步调用带有resolve和reject参数的函数。在调用你的函数后,Promise()构造函数会返回新创建的 Promise。返回的 Promise 受你传递给构造函数的函数控制。该函数应执行一些异步操作,然后调用resolve函数以解析或实现返回的 Promise,或调用reject函数以拒绝返回的 Promise。你的函数不必是异步的:如果这样做,即使你同步调用resolve或reject,Promise 仍将异步解析、实现或拒绝。
通过阅读关于将函数传递给构造函数的函数的功能可能很难理解,但希望一些示例能够澄清这一点。以下是如何编写基于 Promise 的wait()函数的方法,我们在本章的早期示例中使用过:
function wait(duration) {
// Create and return a new Promise
return new Promise((resolve, reject) => { // These control the Promise
// If the argument is invalid, reject the Promise
if (duration < 0) {
reject(new Error("Time travel not yet implemented"));
}
// Otherwise, wait asynchronously and then resolve the Promise.
// setTimeout will invoke resolve() with no arguments, which means
// that the Promise will fulfill with the undefined value.
setTimeout(resolve, duration);
});
}
请注意,用于控制使用Promise()构造函数创建的 Promise 的命运的一对函数的名称分别为resolve()和reject(),而不是fulfill()和reject()。如果将一个 Promise 传递给resolve(),则返回的 Promise 将解析为该新 Promise。然而,通常情况下,你会传递一个非 Promise 值,这将用该值实现返回的 Promise。
示例 13-1 是另一个使用Promise()构造函数的示例。这个示例实现了我们的getJSON()函数,用于在 Node 中使用,因为fetch()API 没有内置。请记住,我们在本章一开始讨论了异步回调和事件。这个示例同时使用了回调和事件处理程序,因此很好地演示了我们如何在其他类型的异步编程风格之上实现基于 Promise 的 API。
示例 13-1. 一个异步的 getJSON() 函数
const http = require("http");
function getJSON(url) {
// Create and return a new Promise
return new Promise((resolve, reject) => {
// Start an HTTP GET request for the specified URL
request = http.get(url, response => { // called when response starts
// Reject the Promise if the HTTP status is wrong
if (response.statusCode !== 200) {
reject(new Error(`HTTP status ${response.statusCode}`));
response.resume(); // so we don't leak memory
}
// And reject if the response headers are wrong
else if (response.headers["content-type"] !== "application/json") {
reject(new Error("Invalid content-type"));
response.resume(); // don't leak memory
}
else {
// Otherwise, register events to read the body of the response
let body = "";
response.setEncoding("utf-8");
response.on("data", chunk => { body += chunk; });
response.on("end", () => {
// When the response body is complete, try to parse it
try {
let parsed = JSON.parse(body);
// If it parsed successfully, fulfill the Promise
resolve(parsed);
} catch(e) {
// If parsing failed, reject the Promise
reject(e);
}
});
}
});
// We also reject the Promise if the request fails before we
// even get a response (such as when the network is down)
request.on("error", error => {
reject(error);
});
});
}
13.2.7 顺序执行的 Promises
Promise.all() 让并行运行任意数量的 Promises 变得容易。Promise 链使得表达一系列固定数量的 Promises 变得容易。然而,按顺序运行任意数量的 Promises 就比较棘手了。例如,假设你有一个要获取的 URL 数组,但为了避免过载网络,你希望一次只获取一个。如果数组长度和内容未知,你无法提前编写 Promise 链,因此需要动态构建一个,代码如下:
function fetchSequentially(urls) {
// We'll store the URL bodies here as we fetch them
const bodies = [];
// Here's a Promise-returning function that fetches one body
function fetchOne(url) {
return fetch(url)
.then(response => response.text())
.then(body => {
// We save the body to the array, and we're purposely
// omitting a return value here (returning undefined)
bodies.push(body);
});
}
// Start with a Promise that will fulfill right away (with value undefined)
let p = Promise.resolve(undefined);
// Now loop through the desired URLs, building a Promise chain
// of arbitrary length, fetching one URL at each stage of the chain
for(url of urls) {
p = p.then(() => fetchOne(url));
}
// When the last Promise in that chain is fulfilled, then the
// bodies array is ready. So let's return a Promise for that
// bodies array. Note that we don't include any error handlers:
// we want to allow errors to propagate to the caller.
return p.then(() => bodies);
}
有了定义的 fetchSequentially() 函数,我们可以一次获取一个 URL,代码与我们之前用来演示 Promise.all() 的并行获取代码类似:
fetchSequentially(urls)
.then(bodies => { /* do something with the array of strings */ })
.catch(e => console.error(e));
fetchSequentially() 函数首先创建一个 Promise,在返回后立即实现。然后,它基于该初始 Promise 构建一个长的线性 Promise 链,并返回链中的最后一个 Promise。这就像设置一排多米诺骨牌,然后推倒第一个。
我们可以采取另一种(可能更优雅)的方法。与其提前创建 Promises,我们可以让每个 Promise 的回调创建并返回下一个 Promise。也就是说,我们不是创建和链接一堆 Promises,而是创建解析为其他 Promises 的 Promises。我们不是创建一条多米诺般的 Promise 链,而是创建一个嵌套在另一个内部的 Promise 序列,就像一组套娃一样。采用这种方法,我们的代码可以返回第一个(最外层)Promise,知道它最终会实现(或拒绝!)与序列中最后一个(最内层)Promise 相同的值。接下来的 promiseSequence() 函数编写为通用的,不特定于 URL 获取。它在我们讨论 Promises 的最后,因为它很复杂。然而,如果你仔细阅读了本章,希望你能理解它是如何工作的。特别要注意的是,promiseSequence() 中的嵌套函数似乎递归调用自身,但因为“递归”调用是通过 then() 方法进行的,实际上并没有传统的递归发生:
// This function takes an array of input values and a "promiseMaker" function.
// For any input value x in the array, promiseMaker(x) should return a Promise
// that will fulfill to an output value. This function returns a Promise
// that fulfills to an array of the computed output values.
//
// Rather than creating the Promises all at once and letting them run in
// parallel, however, promiseSequence() only runs one Promise at a time
// and does not call promiseMaker() for a value until the previous Promise
// has fulfilled.
function promiseSequence(inputs, promiseMaker) {
// Make a private copy of the array that we can modify
inputs = [...inputs];
// Here's the function that we'll use as a Promise callback
// This is the pseudorecursive magic that makes this all work.
function handleNextInput(outputs) {
if (inputs.length === 0) {
// If there are no more inputs left, then return the array
// of outputs, finally fulfilling this Promise and all the
// previous resolved-but-not-fulfilled Promises.
return outputs;
} else {
// If there are still input values to process, then we'll
// return a Promise object, resolving the current Promise
// with the future value from a new Promise.
let nextInput = inputs.shift(); // Get the next input value,
return promiseMaker(nextInput) // compute the next output value,
// Then create a new outputs array with the new output value
.then(output => outputs.concat(output))
// Then "recurse", passing the new, longer, outputs array
.then(handleNextInput);
}
}
// Start with a Promise that fulfills to an empty array and use
// the function above as its callback.
return Promise.resolve([]).then(handleNextInput);
}
这个 promiseSequence() 函数是故意通用的。我们可以用它来获取 URL,代码如下:
// Given a URL, return a Promise that fulfills to the URL body text
function fetchBody(url) { return fetch(url).then(r => r.text()); }
// Use it to sequentially fetch a bunch of URL bodies
promiseSequence(urls, fetchBody)
.then(bodies => { /* do something with the array of strings */ })
.catch(console.error);
13.3 async 和 await
ES2017 引入了两个新关键字——async 和 await——代表了 JavaScript 异步编程的范式转变。这些新关键字极大地简化了 Promises 的使用,并允许我们编写基于 Promise 的异步代码,看起来像阻塞的同步代码,等待网络响应或其他异步事件。虽然理解 Promises 如何工作仍然很重要,但当与 async 和 await 一起使用时,它们的复杂性(有时甚至是它们的存在本身!)会消失。
正如我们在本章前面讨论的那样,异步代码无法像常规同步代码那样返回值或抛出异常。这就是 Promises 设计的原因。已实现的 Promise 的值就像同步函数的返回值一样。而拒绝的 Promise 的值就像同步函数抛出的值一样。后者的相似性通过 .catch() 方法的命名得到明确体现。async 和 await 采用高效的基于 Promise 的代码,并隐藏了 Promises,使得你的异步代码可以像低效、阻塞的同步代码一样易于阅读和推理。
13.3.1 await 表达式
await关键字接受一个 Promise 并将其转换为返回值或抛出异常。给定一个 Promise 对象p,表达式await p会等待直到p完成。如果p成功,那么await p的值就是p的成功值。另一方面,如果p被拒绝,那么await p表达式会抛出p的拒绝值。我们通常不会使用一个保存 Promise 的变量来使用await;相反,我们会在返回 Promise 的函数调用之前使用它:
let response = await fetch("/api/user/profile");
let profile = await response.json();
立即理解await关键字不会导致程序阻塞并直到指定的 Promise 完成。代码仍然是异步的,await只是掩饰了这一事实。这意味着任何使用await的代码本身都是异步的。
13.3.2 async 函数
因为任何使用await的代码都是异步的,有一个关键规则:只能在使用async关键字声明的函数内部使用await关键字。以下是本章前面提到的getHighScore()函数的一个使用async和await重写的版本:
async function getHighScore() {
let response = await fetch("/api/user/profile");
let profile = await response.json();
return profile.highScore;
}
声明一个函数为async意味着函数的返回值将是一个 Promise,即使函数体中没有任何与 Promise 相关的代码。如果一个async函数看起来正常返回,那么作为真正返回值的 Promise 对象将解析为该表面返回值。如果一个async函数看起来抛出异常,那么它返回的 Promise 对象将被拒绝并带有该异常。
getHighScore()函数被声明为async,因此它返回一个 Promise。由于它返回一个 Promise,我们可以使用await关键字:
displayHighScore(await getHighScore());
但请记住,那行代码只有在另一个async函数内部才能起作用!你可以无限嵌套await表达式在async函数内部。但如果你在顶层²或者由于某种原因在一个非async函数内部,那么你就不能使用await,而必须以常规方式处理返回的 Promise:
getHighScore().then(displayHighScore).catch(console.error);
你可以在任何类型的函数中使用async关键字。它可以与function关键字一起作为语句或表达式使用。它可以与箭头函数一起使用,也可以与类和对象字面量中的方法快捷形式一起使用。(有关编写函数的各种方式,请参见第八章。)
13.3.3 等待多个 Promises
假设我们使用async编写了我们的getJSON()函数:
async function getJSON(url) {
let response = await fetch(url);
let body = await response.json();
return body;
}
现在假设我们想要使用这个函数获取两个 JSON 值:
let value1 = await getJSON(url1);
let value2 = await getJSON(url2);
这段代码的问题在于它是不必要的顺序执行:第二个 URL 的获取将等到第一个 URL 的获取完成后才开始。如果第二个 URL 不依赖于从第一个 URL 获取的值,那么我们可能应该尝试同时获取这两个值。这是async函数的基于 Promise 的特性的一个案例。为了等待一组并发执行的async函数,我们使用Promise.all(),就像直接使用 Promises 一样:
let [value1, value2] = await Promise.all([getJSON(url1), getJSON(url2)]);
13.3.4 实现细节
最后,为了理解async函数的工作原理,可能有助于思考底层发生了什么。
假设你写了一个这样的async函数:
async function f(x) { /* body */ }
你可以将这看作是一个包装在原始函数体周围的返回 Promise 的函数:
function f(x) {
return new Promise(function(resolve, reject) {
try {
resolve((function(x) { /* body */ })(x));
}
catch(e) {
reject(e);
}
});
}
用这种方式来表达await关键字比较困难。但可以将await关键字看作是一个标记,将函数体分解为单独的同步块。ES2017 解释器可以将函数体分解为一系列单独的子函数,每个子函数都会传递给前面的await标记的 Promise 的then()方法。
13.4 异步迭代
我们从回调和基于事件的异步性讨论开始了本章,当我们介绍 Promise 时,我们注意到它们对于单次异步计算很有用,但不适用于重复异步事件的源,比如setInterval()、Web 浏览器中的“click”事件或 Node 流上的“data”事件。因为单个 Promise 不能用于序列的异步事件,所以我们也不能使用常规的async函数和await语句来处理这些事情。
然而,ES2018 提供了一个解决方案。异步迭代器类似于第十二章中描述的迭代器,但它们是基于 Promise 的,并且旨在与一种新形式的for/of循环一起使用:for/await。
13.4.1 for/await循环
Node 12 使其可读流异步可迭代。这意味着您可以使用像这样的for/await循环从流中读取连续的数据块:
const fs = require("fs");
async function parseFile(filename) {
let stream = fs.createReadStream(filename, { encoding: "utf-8"});
for await (let chunk of stream) {
parseChunk(chunk); // Assume parseChunk() is defined elsewhere
}
}
像常规的await表达式一样,for/await循环是基于 Promise 的。粗略地说,异步迭代器生成一个 Promise,for/await循环等待该 Promise 实现,将实现值分配给循环变量,并运行循环体。然后它重新开始,从迭代器获取另一个 Promise 并等待该新 Promise 实现。
假设您有一个 URL 数组:
const urls = [url1, url2, url3];
您可以对每个 URL 调用fetch()以获取 Promise 数组:
const promises = urls.map(url => fetch(url));
我们在本章的前面看到,现在我们可以使用Promise.all()等待数组中所有 Promise 被实现。但假设我们希望在第一个 fetch 的结果变为可用时获取结果,并且不想等待所有 URL 被获取。 (当然,第一个 fetch 可能比其他任何 fetch 都要花费更长的时间,因此这不一定比使用Promise.all()更快。)数组是可迭代的,因此我们可以使用常规的for/of循环遍历 Promise 数组:
for(const promise of promises) {
response = await promise;
handle(response);
}
这个示例代码使用了一个常规的for/of循环和一个常规的迭代器。但由于这个迭代器返回的是 Promise,我们也可以使用新的for/await来编写稍微更简单的代码:
for await (const response of promises) {
handle(response);
}
在这种情况下,for/await循环只是将await调用嵌入到循环中,使我们的代码稍微更加紧凑,但这两个例子实际上做的事情是完全一样的。重要的是,这两个例子只有在声明为async的函数内部才能工作;for/await循环在这方面与常规的await表达式没有区别。
然而,重要的是要意识到,在这个例子中我们使用for/await与一个常规迭代器。使用完全异步迭代器会更有趣。
13.4.2 异步迭代器
让我们回顾一下第十二章中的一些术语。可迭代对象是可以与for/of循环一起使用的对象。它定义了一个符号名称为Symbol.iterator的方法。该方法返回一个迭代器对象。迭代器对象具有一个next()方法,可以重复调用以获取可迭代对象的值。迭代器对象的next()方法返回迭代结果对象。迭代结果对象具有一个value属性和/或一个done属性。
异步迭代器与常规迭代器非常相似,但有两个重要的区别。首先,异步可迭代对象实现了一个符号名称为Symbol.asyncIterator的方法,而不是Symbol.iterator。 (正如我们之前看到的,for/await与常规可迭代对象兼容,但它更喜欢异步可迭代对象,并在尝试Symbol.iterator方法之前尝试Symbol.asyncIterator方法。)其次,异步迭代器的next()方法返回一个解析为迭代器结果对象的 Promise,而不是直接返回迭代器结果对象。
注意
在前一节中,当我们在常规的同步可迭代的 Promise 数组上使用for/await时,我们正在处理同步迭代器结果对象,其中value属性是一个 Promise 对象,但done属性是同步的。真正的异步迭代器会返回 Promise 以进行迭代结果对象,并且value和done属性都是异步的。区别是微妙的:使用异步迭代器时,关于何时结束迭代的选择可以异步进行。
13.4.3 异步生成器
正如我们在第十二章中看到的,实现迭代器的最简单方法通常是使用生成器。对于异步迭代器也是如此,我们可以使用声明为async的生成器函数来实现。异步生成器具有异步函数和生成器的特性:你可以像在常规异步函数中一样使用await,也可以像在常规生成器中一样使用yield。但你yield的值会自动包装在 Promise 中。甚至异步生成器的语法也是一个组合:async function和function *组合成async function *。下面是一个示例,展示了如何使用异步生成器和for/await循环以循环语法而不是setInterval()回调函数重复在固定间隔运行代码:
// A Promise-based wrapper around setTimeout() that we can use await with.
// Returns a Promise that fulfills in the specified number of milliseconds
function elapsedTime(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// An async generator function that increments a counter and yields it
// a specified (or infinite) number of times at a specified interval.
async function* clock(interval, max=Infinity) {
for(let count = 1; count <= max; count++) { // regular for loop
await elapsedTime(interval); // wait for time to pass
yield count; // yield the counter
}
}
// A test function that uses the async generator with for/await
async function test() { // Async so we can use for/await
for await (let tick of clock(300, 100)) { // Loop 100 times every 300ms
console.log(tick);
}
}
13.4.4 实现异步迭代器
除了使用异步生成器来实现异步迭代器外,还可以通过定义一个具有返回一个返回解析为迭代器结果对象的 Promise 的next()方法的对象的Symbol.asyncIterator()方法来直接实现它们。在下面的代码中,我们重新实现了前面示例中的clock()函数,使其不是一个生成器,而是只返回一个异步可迭代对象。请注意,在这个示例中,next()方法并没有显式返回一个 Promise;相反,我们只是声明next()为 async:
function clock(interval, max=Infinity) {
// A Promise-ified version of setTimeout that we can use await with.
// Note that this takes an absolute time instead of an interval.
function until(time) {
return new Promise(resolve => setTimeout(resolve, time - Date.now()));
}
// Return an asynchronously iterable object
return {
startTime: Date.now(), // Remember when we started
count: 1, // Remember which iteration we're on
async next() { // The next() method makes this an iterator
if (this.count > max) { // Are we done?
return { done: true }; // Iteration result indicating done
}
// Figure out when the next iteration should begin,
let targetTime = this.startTime + this.count * interval;
// wait until that time,
await until(targetTime);
// and return the count value in an iteration result object.
return { value: this.count++ };
},
// This method means that this iterator object is also an iterable.
[Symbol.asyncIterator]() { return this; }
};
}
这个基于迭代器的clock()函数版本修复了基于生成器的版本中的一个缺陷。请注意,在这个更新的代码中,我们针对每次迭代应该开始的绝对时间,并从中减去当前时间以计算传递给setTimeout()的间隔。如果我们在for/await循环中使用clock(),这个版本将更精确地按照指定的间隔运行循环迭代,因为它考虑了实际运行循环体所需的时间。但这个修复不仅仅是关于时间精度。for/await循环总是在开始下一次迭代之前等待一个迭代返回的 Promise 被实现。但如果你在没有for/await循环的情况下使用异步迭代器,就没有任何阻止你在想要的任何时候调用next()方法。使用基于生成器的clock()版本,如果连续调用next()方法三次,你将得到三个几乎在同一时间实现的 Promise,这可能不是你想要的。我们在这里实现的基于迭代器的版本没有这个问题。
异步迭代器的好处在于它们允许我们表示异步事件或数据流。之前讨论的clock()函数相对简单,因为异步性的源是我们自己进行的setTimeout()调用。但是当我们尝试处理其他异步源时,比如触发事件处理程序,实现异步迭代器就变得相当困难——通常我们有一个响应事件的单个事件处理程序函数,但是迭代器的每次调用next()方法必须返回一个不同的 Promise 对象,并且在第一个 Promise 解析之前可能会多次调用next()。这意味着任何异步迭代器方法必须能够维护一个内部 Promise 队列,以便按顺序解析异步事件。如果我们将这种 Promise 队列行为封装到一个 AsyncQueue 类中,那么基于 AsyncQueue 编写异步迭代器就会变得更容易。³
接下来的 AsyncQueue 类具有enqueue()和dequeue()方法,就像你期望的队列类一样。然而,dequeue()方法返回一个 Promise 而不是实际值,这意味着在调用enqueue()之前调用dequeue()是可以的。AsyncQueue 类也是一个异步迭代器,并且旨在与一个for/await循环一起使用,其主体在每次异步排队新值时运行一次。 (AsyncQueue 有一个close()方法。一旦调用,就不能再排队更多的值。当一个关闭的队列为空时,for/await循环将停止循环。)
请注意,AsyncQueue 的实现不使用async或await,而是直接使用 Promises。这段代码有些复杂,你可以用它来测试你对我们在这一长章节中涵盖的内容的理解。即使你不完全理解 AsyncQueue 的实现,也请看一下后面的简短示例:它在 AsyncQueue 的基础上实现了一个简单但非常有趣的异步迭代器。
/**
* An asynchronously iterable queue class. Add values with enqueue()
* and remove them with dequeue(). dequeue() returns a Promise, which
* means that values can be dequeued before they are enqueued. The
* class implements [Symbol.asyncIterator] and next() so that it can
* be used with the for/await loop (which will not terminate until
* the close() method is called.)
*/
class AsyncQueue {
constructor() {
// Values that have been queued but not dequeued yet are stored here
this.values = [];
// When Promises are dequeued before their corresponding values are
// queued, the resolve methods for those Promises are stored here.
this.resolvers = [];
// Once closed, no more values can be enqueued, and no more unfulfilled
// Promises returned.
this.closed = false;
}
enqueue(value) {
if (this.closed) {
throw new Error("AsyncQueue closed");
}
if (this.resolvers.length > 0) {
// If this value has already been promised, resolve that Promise
const resolve = this.resolvers.shift();
resolve(value);
}
else {
// Otherwise, queue it up
this.values.push(value);
}
}
dequeue() {
if (this.values.length > 0) {
// If there is a queued value, return a resolved Promise for it
const value = this.values.shift();
return Promise.resolve(value);
}
else if (this.closed) {
// If no queued values and we're closed, return a resolved
// Promise for the "end-of-stream" marker
return Promise.resolve(AsyncQueue.EOS);
}
else {
// Otherwise, return an unresolved Promise,
// queuing the resolver function for later use
return new Promise((resolve) => { this.resolvers.push(resolve); });
}
}
close() {
// Once the queue is closed, no more values will be enqueued.
// So resolve any pending Promises with the end-of-stream marker
while(this.resolvers.length > 0) {
this.resolvers.shift()(AsyncQueue.EOS);
}
this.closed = true;
}
// Define the method that makes this class asynchronously iterable
[Symbol.asyncIterator]() { return this; }
// Define the method that makes this an asynchronous iterator. The
// dequeue() Promise resolves to a value or the EOS sentinel if we're
// closed. Here, we need to return a Promise that resolves to an
// iterator result object.
next() {
return this.dequeue().then(value => (value === AsyncQueue.EOS)
? { value: undefined, done: true }
: { value: value, done: false });
}
}
// A sentinel value returned by dequeue() to mark "end of stream" when closed
AsyncQueue.EOS = Symbol("end-of-stream");
因为这个 AsyncQueue 类定义了异步迭代的基础,我们可以通过异步排队值来创建自己的更有趣的异步迭代器。下面是一个示例,它使用 AsyncQueue 生成一个可以用for/await循环处理的 web 浏览器事件流:
// Push events of the specified type on the specified document element
// onto an AsyncQueue object, and return the queue for use as an event stream
function eventStream(elt, type) {
const q = new AsyncQueue(); // Create a queue
elt.addEventListener(type, e=>q.enqueue(e)); // Enqueue events
return q;
}
async function handleKeys() {
// Get a stream of keypress events and loop once for each one
for await (const event of eventStream(document, "keypress")) {
console.log(event.key);
}
}
13.5 总结
在本章中,你已经学到了:
-
大多数真实世界的 JavaScript 编程是异步的。
-
传统上,异步性是通过事件和回调函数来处理的。然而,这可能会变得复杂,因为你可能会得到多层嵌套在其他回调内部的回调,并且很难进行健壮的错误处理。
-
Promises 提供了一种新的组织回调函数的方式。如果使用正确(不幸的是,Promises 很容易被错误使用),它们可以将原本嵌套的异步代码转换为
then()调用的线性链,其中一个计算的异步步骤跟随另一个。此外,Promises 允许你将错误处理代码集中到一条catch()调用中,放在then()调用链的末尾。 -
async和await关键字允许我们编写基于 Promise 的异步代码,但看起来像同步代码。这使得代码更容易理解和推理。如果一个函数声明为async,它将隐式返回一个 Promise。在async函数内部,你可以像同步计算 Promise 值一样await一个 Promise(或返回 Promise 的函数)。 -
可以使用
for/await循环处理异步可迭代对象。你可以通过实现[Symbol.asyncIterator]()方法或调用async function *生成器函数来创建异步可迭代对象。异步迭代器提供了一种替代 Node 中“data”事件的方式,并可用于表示客户端 JavaScript 中用户输入事件的流。
¹ XMLHttpRequest 类与 XML 无关。在现代客户端 JavaScript 中,它大部分被fetch() API 取代,该 API 在§15.11.1 中有介绍。这里展示的代码示例是本书中仅剩的基于 XMLHttpRequest 的示例。
² 通常可以在浏览器的开发者控制台中的顶层使用await。而且有一个未决提案,允许在未来版本的 JavaScript 中使用顶层await。
³ 我从https://2ality.com博客中了解到了这种异步迭代的方法,作者是 Axel Rauschmayer 博士。
第十四章:元编程
本章介绍了一些高级 JavaScript 功能,这些功能在日常编程中并不常用,但对于编写可重用库的程序员可能很有价值,并且对于任何想要深入了解 JavaScript 对象行为细节的人也很有趣。
这里描述的许多功能可以宽泛地描述为“元编程”:如果常规编程是编写代码来操作数据,那么元编程就是编写代码来操作其他代码。在像 JavaScript 这样的动态语言中,编程和元编程之间的界限模糊——甚至简单地使用for/in循环迭代对象的属性的能力对更习惯于更静态语言的程序员来说可能被认为是“元编程”。
本章涵盖的元编程主题包括:
-
§14.1 控制对象属性的可枚举性、可删除性和可配置性
-
§14.2 控制对象的可扩展性,并创建“封闭”和“冻结”对象
-
§14.3 查询和设置对象的原型
-
§14.4 使用众所周知的符号微调类型的行为
-
§14.5 使用模板标签函数创建 DSL(领域特定语言)
-
§14.6 使用
reflect方法探查对象 -
§14.7 使用代理控制对象行为
14.1 属性特性
JavaScript 对象的属性当然有名称和值,但每个属性还有三个关联属性,指定该属性的行为方式以及您可以对其执行的操作:
-
可写 属性指定属性的值是否可以更改。
-
可枚举 属性指定属性是否由
for/in循环和Object.keys()方法枚举。 -
可配置 属性指定属性是否可以被删除,以及属性的属性是否可以更改。
在对象字面量中定义的属性或通过普通赋值给对象的属性是可写的、可枚举的和可配置的。但是,JavaScript 标准库中定义的许多属性并非如此。
本节解释了查询和设置属性特性的 API。这个 API 对于库作者尤为重要,因为:
-
它允许他们向原型对象添加方法并使它们不可枚举,就像内置方法一样。
-
它允许它们“锁定”它们的对象,定义不能被更改或删除的属性。
请回顾§6.10.6,在那里提到,“数据属性”具有值,“访问器属性”则具有 getter 和/或 setter 方法。对于本节的目的,我们将考虑访问器属性的 getter 和 setter 方法为属性特性。按照这种逻辑,我们甚至会说数据属性的值也是一个属性。因此,我们可以说属性有一个名称和四个属性。数据属性的四个属性是值、可写、可枚举和可配置。访问器属性没有值属性或可写属性:它们的可写性取决于是否存在 setter。因此,访问器属性的四个属性是获取、设置、可枚举和可配置。
JavaScript 用于查询和设置属性的方法使用一个称为属性描述符的对象来表示四个属性的集合。属性描述符对象具有与其描述的属性相同名称的属性。因此,数据属性的属性描述符对象具有名为value、writable、enumerable和configurable的属性。访问器属性的描述符具有get和set属性,而不是value和writable。writable、enumerable和configurable属性是布尔值,get和set属性是函数值。
要获取指定对象的命名属性的属性描述符,请调用Object.getOwnPropertyDescriptor():
// Returns {value: 1, writable:true, enumerable:true, configurable:true}
Object.getOwnPropertyDescriptor({x: 1}, "x");
// Here is an object with a read-only accessor property
const random = {
get octet() { return Math.floor(Math.random()*256); },
};
// Returns { get: /*func*/, set:undefined, enumerable:true, configurable:true}
Object.getOwnPropertyDescriptor(random, "octet");
// Returns undefined for inherited properties and properties that don't exist.
Object.getOwnPropertyDescriptor({}, "x") // => undefined; no such prop
Object.getOwnPropertyDescriptor({}, "toString") // => undefined; inherited
如其名称所示,Object.getOwnPropertyDescriptor()仅适用于自有属性。要查询继承属性的属性,必须显式遍历原型链。(参见§14.3 中的Object.getPrototypeOf());另请参阅§14.6 中的类似Reflect.getOwnPropertyDescriptor()函数。
要设置属性的属性或使用指定属性创建新属性,请调用Object.defineProperty(),传递要修改的对象、要创建或更改的属性的名称和属性描述符对象:
let o = {}; // Start with no properties at all
// Add a non-enumerable data property x with value 1.
Object.defineProperty(o, "x", {
value: 1,
writable: true,
enumerable: false,
configurable: true
});
// Check that the property is there but is non-enumerable
o.x // => 1
Object.keys(o) // => []
// Now modify the property x so that it is read-only
Object.defineProperty(o, "x", { writable: false });
// Try to change the value of the property
o.x = 2; // Fails silently or throws TypeError in strict mode
o.x // => 1
// The property is still configurable, so we can change its value like this:
Object.defineProperty(o, "x", { value: 2 });
o.x // => 2
// Now change x from a data property to an accessor property
Object.defineProperty(o, "x", { get: function() { return 0; } });
o.x // => 0
你传递给Object.defineProperty()的属性描述符不必包含所有四个属性。如果你正在创建一个新属性,那么被省略的属性被视为false或undefined。如果你正在修改一个现有属性,那么你省略的属性将保持不变。请注意,此方法会更改现有的自有属性或创建新的自有属性,但不会更改继承的属性。另请参阅§14.6 中的非常相似的Reflect.defineProperty()函数。
如果要一次创建或修改多个属性,请使用Object.defineProperties()。第一个参数是要修改的对象。第二个参数是将要创建或修改的属性的名称映射到这些属性的属性描述符的对象。例如:
let p = Object.defineProperties({}, {
x: { value: 1, writable: true, enumerable: true, configurable: true },
y: { value: 1, writable: true, enumerable: true, configurable: true },
r: {
get() { return Math.sqrt(this.x*this.x + this.y*this.y); },
enumerable: true,
configurable: true
}
});
p.r // => Math.SQRT2
这段代码从一个空对象开始,然后向其添加两个数据属性和一个只读访问器属性。它依赖于Object.defineProperties()返回修改后的对象(Object.defineProperty()也是如此)。
Object.create() 方法是在§6.2 中引入的。我们在那里学到,该方法的第一个参数是新创建对象的原型对象。该方法还接受第二个可选参数,与Object.defineProperties()的第二个参数相同。如果你向Object.create()传递一组属性描述符,那么它们将用于向新创建的对象添加属性。
如果尝试创建或修改属性不被允许,Object.defineProperty()和Object.defineProperties()会抛出 TypeError。如果你尝试向不可扩展的对象添加新属性,就会发生这种情况(参见§14.2)。这些方法可能抛出 TypeError 的其他原因与属性本身有关。可写属性控制对值属性的更改尝试。可配置属性控制对其他属性的更改尝试(并指定属性是否可以被删除)。然而,规则并不完全直观。例如,如果属性是可配置的,那么即使该属性是不可写的,也可以更改该属性的值。此外,即使属性是不可配置的,也可以将属性从可写更改为不可写。以下是完整的规则。调用Object.defineProperty()或Object.defineProperties()尝试违反这些规则会抛出 TypeError:
-
如果一个对象不可扩展,你可以编辑其现有的自有属性,但不能向其添加新属性。
-
如果一个属性不可配置,你就无法改变它的可配置或可枚举属性。
-
如果一个访问器属性不可配置,你就无法更改其 getter 或 setter 方法,也无法将其更改为数据属性。
-
如果一个数据属性不可配置,你就无法将其更改为访问器属性。
-
如果一个数据属性不可配置,你就无法将其可写属性从
false更改为true,但你可以将其从true更改为false。 -
如果一个数据属性不可配置且不可写,你就无法改变它的值。但是,如果一个属性是可配置但不可写的,你可以改变它的值(因为这与使其可写,然后改变值,然后将其转换回不可写是一样的)。
§6.7 描述了Object.assign()函数,它将一个或多个源对象的属性值复制到目标对象中。Object.assign()只复制可枚举属性和属性值,而不是属性属性。这通常是我们想要的,但这意味着,例如,如果一个源对象具有一个访问器属性,那么复制到目标对象的是 getter 函数返回的值,而不是 getter 函数本身。示例 14-1 演示了如何使用Object.getOwnPropertyDescriptor()和Object.defineProperty()创建Object.assign()的变体,该变体复制整个属性描述符而不仅仅是复制属性值。
示例 14-1. 从一个对象复制属性及其属性到另一个对象
/*
* Define a new Object.assignDescriptors() function that works like
* Object.assign() except that it copies property descriptors from
* source objects into the target object instead of just copying
* property values. This function copies all own properties, both
* enumerable and non-enumerable. And because it copies descriptors,
* it copies getter functions from source objects and overwrites setter
* functions in the target object rather than invoking those getters and
* setters.
*
* Object.assignDescriptors() propagates any TypeErrors thrown by
* Object.defineProperty(). This can occur if the target object is sealed
* or frozen or if any of the source properties try to change an existing
* non-configurable property on the target object.
*
* Note that the assignDescriptors property is added to Object with
* Object.defineProperty() so that the new function can be created as
* a non-enumerable property like Object.assign().
*/
Object.defineProperty(Object, "assignDescriptors", {
// Match the attributes of Object.assign()
writable: true,
enumerable: false,
configurable: true,
// The function that is the value of the assignDescriptors property.
value: function(target, ...sources) {
for(let source of sources) {
for(let name of Object.getOwnPropertyNames(source)) {
let desc = Object.getOwnPropertyDescriptor(source, name);
Object.defineProperty(target, name, desc);
}
for(let symbol of Object.getOwnPropertySymbols(source)) {
let desc = Object.getOwnPropertyDescriptor(source, symbol);
Object.defineProperty(target, symbol, desc);
}
}
return target;
}
});
let o = {c: 1, get count() {return this.c++;}}; // Define object with getter
let p = Object.assign({}, o); // Copy the property values
let q = Object.assignDescriptors({}, o); // Copy the property descriptors
p.count // => 1: This is now just a data property so
p.count // => 1: ...the counter does not increment.
q.count // => 2: Incremented once when we copied it the first time,
q.count // => 3: ...but we copied the getter method so it increments.
14.2 对象的可扩展性
对象的可扩展属性指定了是否可以向对象添加新属性。普通的 JavaScript 对象默认是可扩展的,但你可以通过本节描述的函数来改变这一点。
要确定一个对象是否可扩展,请将其传递给Object.isExtensible()。要使对象不可扩展,请将其传递给Object.preventExtensions()。一旦这样做,任何尝试向对象添加新属性的操作在严格模式下都会抛出 TypeError,在非严格模式下会静默失败而不会报错。此外,尝试更改不可扩展对象的原型(参见§14.3)将始终抛出 TypeError。
请注意,一旦将对象设置为不可扩展,就没有办法再使其可扩展。另外,请注意,调用Object.preventExtensions()只影响对象本身的可扩展性。如果向不可扩展对象的原型添加新属性,那么不可扩展对象将继承这些新属性。
有两个类似的函数,Reflect.isExtensible()和Reflect.preventExtensions(),在§14.6 中描述。
可扩展属性的目的是能够将对象“锁定”到已知状态,并防止外部篡改。对象的可扩展属性通常与属性的可配置和可写属性一起使用,JavaScript 定义了使设置这些属性变得容易的函数:
-
Object.seal()的作用类似于Object.preventExtensions(),但除了使对象不可扩展外,它还使该对象的所有自有属性不可配置。这意味着无法向对象添加新属性,也无法删除或配置现有属性。但是,可写的现有属性仍然可以设置。无法取消密封的对象。你可以使用Object.isSealed()来确定对象是否被密封。 -
Object.freeze()更加严格地锁定对象。除了使对象不可扩展和其属性不可配置外,它还使对象的所有自有数据属性变为只读。(如果对象具有具有 setter 方法的访问器属性,则这些属性不受影响,仍然可以通过对属性赋值来调用。)使用Object.isFrozen()来确定对象是否被冻结。
需要理解的是 Object.seal() 和 Object.freeze() 只会影响它们所传递的对象:它们不会影响该对象的原型。如果你想完全锁定一个对象,可能需要同时封闭或冻结原型链中的对象。
Object.preventExtensions(), Object.seal(), 和 Object.freeze() 都会返回它们所传递的对象,这意味着你可以在嵌套函数调用中使用它们:
// Create a sealed object with a frozen prototype and a non-enumerable property
let o = Object.seal(Object.create(Object.freeze({x: 1}),
{y: {value: 2, writable: true}}));
如果你正在编写一个 JavaScript 库,将对象传递给库用户编写的回调函数,你可能会在这些对象上使用 Object.freeze() 来防止用户的代码修改它们。这样做很容易和方便,但也存在一些权衡:冻结的对象可能会干扰常见的 JavaScript 测试策略,例如。
14.3 原型属性
一个对象的 prototype 属性指定了它继承属性的对象。(查看 §6.2.3 和 §6.3.2 了解更多关于原型和属性继承的内容。)这是一个非常重要的属性,我们通常简单地说“o 的原型”而不是“o 的 prototype 属性”。还要记住,当 prototype 出现在代码字体中时,它指的是一个普通对象属性,而不是 prototype 属性:第九章 解释了构造函数的 prototype 属性指定了使用该构造函数创建的对象的 prototype 属性。
prototype 属性在对象创建时设置。通过对象字面量创建的对象使用 Object.prototype 作为它们的原型。通过 new 创建的对象使用它们构造函数的 prototype 属性的值作为它们的原型。通过 Object.create() 创建的对象使用该函数的第一个参数(可能为 null)作为它们的原型。
你可以通过将对象传递给 Object.getPrototypeOf() 来查询任何对象的原型:
Object.getPrototypeOf({}) // => Object.prototype
Object.getPrototypeOf([]) // => Array.prototype
Object.getPrototypeOf(()=>{}) // => Function.prototype
一个非常相似的函数 Reflect.getPrototypeOf() 在 §14.6 中描述。
要确定一个对象是否是另一个对象的原型(或是原型链的一部分),使用 isPrototypeOf() 方法:
let p = {x: 1}; // Define a prototype object.
let o = Object.create(p); // Create an object with that prototype.
p.isPrototypeOf(o) // => true: o inherits from p
Object.prototype.isPrototypeOf(p) // => true: p inherits from Object.prototype
Object.prototype.isPrototypeOf(o) // => true: o does too
注意,isPrototypeOf() 执行类似于 instanceof 运算符的功能(参见 §4.9.4)。
对象的 prototype 属性在对象创建时设置并通常保持不变。但是,你可以使用 Object.setPrototypeOf() 改变对象的原型:
let o = {x: 1};
let p = {y: 2};
Object.setPrototypeOf(o, p); // Set the prototype of o to p
o.y // => 2: o now inherits the property y
let a = [1, 2, 3];
Object.setPrototypeOf(a, p); // Set the prototype of array a to p
a.join // => undefined: a no longer has a join() method
通常不需要使用 Object.setPrototypeOf()。JavaScript 实现可能会基于对象原型是固定且不变的假设进行激进的优化。这意味着如果你调用 Object.setPrototypeOf(),使用修改后的对象的任何代码可能比通常运行得慢得多。
一个类似的函数 Reflect.setPrototypeOf() 在 §14.6 中描述。
一些早期的浏览器实现暴露了对象的prototype属性,通过__proto__属性(以两个下划线开头和结尾)。尽管这种做法早已被弃用,但网络上存在大量依赖__proto__的现有代码,因此 ECMAScript 标准要求所有在 Web 浏览器中运行的 JavaScript 实现都必须支持它(Node 也支持,尽管标准不要求 Node 支持)。在现代 JavaScript 中,__proto__是可读写的,你可以(尽管不应该)将其用作Object.getPrototypeOf()和Object.setPrototypeOf()的替代方法。然而,__proto__的一个有趣用途是定义对象字面量的原型:
let p = {z: 3};
let o = {
x: 1,
y: 2,
__proto__: p
};
o.z // => 3: o inherits from p
14.4 众所周知的符号
Symbol 类型是在 ES6 中添加到 JavaScript 中的,这样做的一个主要原因是可以安全地向语言添加扩展,而不会破坏已部署在 Web 上的代码的兼容性。我们在第十二章中看到了一个例子,我们学到可以通过实现一个方法,其“名称”是符号Symbol.iterator,使一个类可迭代。
Symbol.iterator是“众所周知的符号”中最为人熟知的例子。这些是一组作为Symbol()工厂函数属性存储的符号值,用于允许 JavaScript 代码控制对象和类的某些低级行为。接下来的小节描述了每个这些众所周知的符号,并解释了它们的用途。
14.4.1 Symbol.iterator 和 Symbol.asyncIterator
Symbol.iterator 和 Symbol.asyncIterator 符号允许对象或类使自己可迭代或异步可迭代。它们在第十二章和§13.4.2 中有详细介绍,这里仅为完整性而提及。
14.4.2 Symbol.hasInstance
当描述instanceof运算符时,在§4.9.4 中我们说右侧必须是一个构造函数,并且表达式o instanceof f通过查找o的原型链中的值f.prototype来进行评估。这仍然是正确的,但在 ES6 及更高版本中,Symbol.hasInstance提供了一种替代方法。在 ES6 中,如果instanceof的右侧是具有[Symbol.hasInstance]方法的任何对象,则该方法将以其参数作为左侧值调用,并且方法的返回值,转换为布尔值,成为instanceof运算符的值。当然,如果右侧的值没有[Symbol.hasInstance]方法但是一个函数,则instanceof运算符会按照其普通方式行为。
Symbol.hasInstance意味着我们可以使用instanceof运算符来进行通用类型检查,只需定义适当的伪类型对象即可。例如:
// Define an object as a "type" we can use with instanceof
let uint8 = {
Symbol.hasInstance {
return Number.isInteger(x) && x >= 0 && x <= 255;
}
};
128 instanceof uint8 // => true
256 instanceof uint8 // => false: too big
Math.PI instanceof uint8 // => false: not an integer
请注意,这个例子很巧妙但令人困惑,因为它使用了一个非类对象,而通常期望的是一个类。对于你的代码读者来说,编写一个isUint8()函数而不依赖于Symbol.hasInstance行为会更容易理解。
14.4.3 Symbol.toStringTag
如果调用基本 JavaScript 对象的toString()方法,你会得到字符串“[object Object]”:
{}.toString() // => "[object Object]"
如果将相同的Object.prototype.toString()函数作为内置类型的实例方法调用,你会得到一些有趣的结果:
Object.prototype.toString.call([]) // => "[object Array]"
Object.prototype.toString.call(/./) // => "[object RegExp]"
Object.prototype.toString.call(()=>{}) // => "[object Function]"
Object.prototype.toString.call("") // => "[object String]"
Object.prototype.toString.call(0) // => "[object Number]"
Object.prototype.toString.call(false) // => "[object Boolean]"
使用Object.prototype.toString().call()技术可以获取任何 JavaScript 值的“类属性”,其中包含了否则无法获取的类型信息。下面的classof()函数可能比typeof运算符更有用,因为它可以区分不同类型的对象:
function classof(o) {
return Object.prototype.toString.call(o).slice(8,-1);
}
classof(null) // => "Null"
classof(undefined) // => "Undefined"
classof(1) // => "Number"
classof(10n**100n) // => "BigInt"
classof("") // => "String"
classof(false) // => "Boolean"
classof(Symbol()) // => "Symbol"
classof({}) // => "Object"
classof([]) // => "Array"
classof(/./) // => "RegExp"
classof(()=>{}) // => "Function"
classof(new Map()) // => "Map"
classof(new Set()) // => "Set"
classof(new Date()) // => "Date"
在 ES6 之前,Object.prototype.toString()方法的这种特殊行为仅适用于内置类型的实例,如果你在自己定义的类的实例上调用classof()函数,它将简单地返回“Object”。然而,在 ES6 中,Object.prototype.toString()会在其参数上查找一个名为Symbol.toStringTag的符号名称属性,如果存在这样的属性,它将在输出中使用属性值。这意味着如果你定义了自己的类,你可以轻松地使其与classof()等函数一起工作:
class Range {
get [Symbol.toStringTag]() { return "Range"; }
// the rest of this class is omitted here
}
let r = new Range(1, 10);
Object.prototype.toString.call(r) // => "[object Range]"
classof(r) // => "Range"
14.4.4 Symbol.species
在 ES6 之前,JavaScript 没有提供任何真正的方法来创建 Array 等内置类的强大子类。然而,在 ES6 中,你可以简单地使用class和extends关键字来扩展任何内置类。§9.5.2 演示了这个简单的 Array 子类:
// A trivial Array subclass that adds getters for the first and last elements.
class EZArray extends Array {
get first() { return this[0]; }
get last() { return this[this.length-1]; }
}
let e = new EZArray(1,2,3);
let f = e.map(x => x * x);
e.last // => 3: the last element of EZArray e
f.last // => 9: f is also an EZArray with a last property
Array 定义了concat()、filter()、map()、slice()和splice()等方法,它们返回数组。当我们创建一个像 EZArray 这样继承这些方法的数组子类时,继承的方法应该返回 Array 的实例还是 EZArray 的实例?对于任一选择都有充分的理由,但 ES6 规范表示(默认情况下)这五个返回数组的方法将返回子类的实例。
它是如何工作的:
-
在 ES6 及以后的版本中,
Array()构造函数有一个名为Symbol.species的符号属性。(请注意,此 Symbol 用作构造函数的属性名称。这里描述的大多数其他知名 Symbols 用作原型对象的方法名称。) -
当我们使用
extends创建一个子类时,结果子类构造函数会继承自超类构造函数的属性。(这是正常继承的一种,其中子类的实例继承自超类的方法。)这意味着每个 Array 的子类构造函数也会继承一个名为Symbol.species的继承属性。(或者子类可以定义自己的具有此名称的属性,如果需要的话。) -
在 ES6 及以后的版本中,像
map()和slice()这样创建并返回新数组的方法稍作调整。它们(实际上)调用new this.constructor[Symbol.species]()来创建新数组。
现在这里有趣的部分。假设Array[Symbol.species]只是一个常规的数据属性,像这样定义:
Array[Symbol.species] = Array;
在这种情况下,子类构造函数将以Array()构造函数作为其“species”继承,并在数组子类上调用map()将返回超类的实例而不是子类的实例。然而,ES6 实际上并不是这样行为的。原因是Array[Symbol.species]是一个只读的访问器属性,其 getter 函数简单地返回this。子类构造函数继承了这个 getter 函数,这意味着默认情况下,每个子类构造函数都是其自己的“species”。
然而,有时这种默认行为并不是你想要的。如果你希望 EZArray 的返回数组方法返回常规的 Array 对象,你只需要将EZArray[Symbol.species]设置为Array。但由于继承的属性是只读访问器,你不能简单地使用赋值运算符来设置它。不过,你可以使用defineProperty():
EZArray[Symbol.species] = Array; // Attempt to set a read-only property fails
// Instead we can use defineProperty():
Object.defineProperty(EZArray, Symbol.species, {value: Array});
最简单的选择可能是在创建子类时明确定义自己的Symbol.speciesgetter:
class EZArray extends Array {
static get [Symbol.species]() { return Array; }
get first() { return this[0]; }
get last() { return this[this.length-1]; }
}
let e = new EZArray(1,2,3);
let f = e.map(x => x - 1);
e.last // => 3
f.last // => undefined: f is a regular array with no last getter
创建有用的 Array 子类是引入Symbol.species的主要用例,但这并不是这个众所周知的 Symbol 被使用的唯一场合。Typed array 类使用 Symbol 的方式与 Array 类相同。类似地,ArrayBuffer 的slice()方法查看this.constructor的Symbol.species属性,而不是简单地创建一个新的 ArrayBuffer。而像then()这样返回新 Promise 对象的 Promise 方法也通过这种 species 协议创建这些对象。最后,如果您发现自己正在对 Map(例如)进行子类化并定义返回新 Map 对象的方法,您可能希望为了您的子类的好处自己使用Symbol.species。
14.4.5 Symbol.isConcatSpreadable
Array 方法concat()是前一节描述的使用Symbol.species确定返回的数组要使用的构造函数之一。但concat()还使用Symbol.isConcatSpreadable。回顾§7.8.3,数组的concat()方法将其this值和其数组参数与非数组参数区别对待:非数组参数简单地附加到新数组,但this数组和任何数组参数被展平或“展开”,以便将数组的元素连接起来而不是数组参数本身。
在 ES6 之前,concat()只是使用Array.isArray()来确定是否将一个值视为数组。在 ES6 中,算法略有改变:如果传递给concat()的参数(或this值)是一个对象,并且具有符号名称Symbol.isConcatSpreadable的属性,则该属性的布尔值用于确定是否应“展开”参数。如果不存在这样的属性,则像语言的早期版本一样使用Array.isArray()。
有两种情况下您可能想使用这个 Symbol:
-
如果您创建一个类似数组的对象(参见§7.9),并希望在传递给
concat()时表现得像真正的数组,您可以简单地向您的对象添加这个符号属性:let arraylike = { length: 1, 0: 1, [Symbol.isConcatSpreadable]: true }; [].concat(arraylike) // => [1]: (would be [[1]] if not spread) -
默认情况下,数组子类是可展开的,因此,如果您正在定义一个数组子类,而不希望在使用
concat()时表现得像数组,那么您可以在子类中添加一个类似这样的 getter:class NonSpreadableArray extends Array { get [Symbol.isConcatSpreadable]() { return false; } } let a = new NonSpreadableArray(1,2,3); [].concat(a).length // => 1; (would be 3 elements long if a was spread)
14.4.6 模式匹配符号
§11.3.2 记载了使用 RegExp 参数执行模式匹配操作的 String 方法。在 ES6 及更高版本中,这些方法已被泛化,可以与 RegExp 对象或任何定义了通过具有符号名称的属性进行模式匹配行为的对象一起使用。对于每个字符串方法match()、matchAll()、search()、replace()和split(),都有一个对应的众所周知的 Symbol:Symbol.match、Symbol.search等等。
正则表达式是描述文本模式的一种通用且非常强大的方式,但它们可能会很复杂,不太适合模糊匹配。通过泛化的字符串方法,您可以使用众所周知的 Symbol 方法定义自己的模式类,以提供自定义匹配。例如,您可以使用 Intl.Collator(参见§11.7.3)执行字符串比较,以在匹配时忽略重音。或者您可以基于 Soundex 算法定义一个模式类,根据其近似音调匹配单词或宽松匹配给定的 Levenshtein 距离内的字符串。
通常,当您在模式对象上调用这五个 String 方法之一时:
string.method(pattern, arg)
那个调用会变成在您的模式对象上调用一个以符号命名的方法:
patternsymbol
举个例子,考虑下一个示例中的模式匹配类,它使用简单的*和?通配符实现模式匹配,你可能从文件系统中熟悉这些通配符。这种模式匹配风格可以追溯到 Unix 操作系统的早期,这些模式通常被称为globs:
class Glob {
constructor(glob) {
this.glob = glob;
// We implement glob matching using RegExp internally.
// ? matches any one character except /, and * matches zero or more
// of those characters. We use capturing groups around each.
let regexpText = glob.replace("?", "([^/])").replace("*", "([^/]*)");
// We use the u flag to get Unicode-aware matching.
// Globs are intended to match entire strings, so we use the ^ and $
// anchors and do not implement search() or matchAll() since they
// are not useful with patterns like this.
this.regexp = new RegExp(`^${regexpText}$`, "u");
}
toString() { return this.glob; }
Symbol.search { return s.search(this.regexp); }
Symbol.match { return s.match(this.regexp); }
Symbol.replace {
return s.replace(this.regexp, replacement);
}
}
let pattern = new Glob("docs/*.txt");
"docs/js.txt".search(pattern) // => 0: matches at character 0
"docs/js.htm".search(pattern) // => -1: does not match
let match = "docs/js.txt".match(pattern);
match[0] // => "docs/js.txt"
match[1] // => "js"
match.index // => 0
"docs/js.txt".replace(pattern, "web/$1.htm") // => "web/js.htm"
14.4.7 Symbol.toPrimitive
§3.9.3 解释了 JavaScript 有三种稍有不同的算法来将对象转换为原始值。粗略地说,对于期望或偏好字符串值的转换,JavaScript 首先调用对象的toString()方法,如果未定义或未返回原始值,则回退到valueOf()方法。对于偏好数值的转换,JavaScript 首先尝试valueOf()方法,如果未定义或未返回原始值,则回退到toString()。最后,在没有偏好的情况下,它让类决定如何进行转换。日期对象首先使用toString()进行转换,而所有其他类型首先尝试valueOf()。
在 ES6 中,著名的 Symbol Symbol.toPrimitive 允许你重写默认的对象到原始值的行为,并完全控制你自己类的实例将如何转换为原始值。为此,请定义一个具有这个符号名称的方法。该方法必须返回某种代表对象的原始值。你定义的方法将被调用一个字符串参数,告诉你 JavaScript 正在尝试对你的对象进行什么样的转换:
-
如果参数是
"string",这意味着 JavaScript 在一个期望或偏好(但不是必须)字符串的上下文中进行转换。例如,当你将对象插入模板文字中时会发生这种情况。 -
如果参数是
"number",这意味着 JavaScript 在一个期望或偏好(但不是必须)数字值的上下文中进行转换。当你使用对象与<或>运算符或使用-和*等算术运算符时会发生这种情况。 -
如果参数是
"default",这意味着 JavaScript 在一个数字或字符串值都可以使用的上下文中转换你的对象。这发生在+、==和!=运算符中。
许多类可以忽略参数,并在所有情况下简单地返回相同的原始值。如果你希望你的类的实例可以使用<和>进行比较和排序,那么这是定义[Symbol.toPrimitive]方法的一个很好的理由。
14.4.8 Symbol.unscopables
我们将在这里介绍的最后一个著名 Symbol 是一个晦涩的 Symbol,它是为了解决由于已弃用的with语句引起的兼容性问题而引入的。回想一下,with语句接受一个对象,并执行其语句体,就好像它在对象的属性是变量的作用域中执行一样。当向 Array 类添加新方法时,这导致了兼容性问题,并且破坏了一些现有代码。Symbol.unscopables 就是解决方案。在 ES6 及更高版本中,with语句已经稍作修改。当与对象o一起使用时,with语句计算Object.keys(o[Symbol.unscopables]||{}),并在创建模拟作用域以执行其语句体时忽略其名称在生成的数组中的属性。ES6 使用这个方法向Array.prototype添加新方法,而不会破坏网络上的现有代码。这意味着你可以通过评估来找到最新的 Array 方法列表:
let newArrayMethods = Object.keys(Array.prototype[Symbol.unscopables]);
14.5 模板标签
反引号内的字符串称为“模板字面量”,在 §3.3.4 中有介绍。当一个值为函数的表达式后面跟着一个模板字面量时,它变成了一个函数调用,并且我们称之为“标记模板字面量”。为标记模板字面量定义一个新的标记函数可以被视为元编程,因为标记模板经常用于定义 DSL—领域特定语言—并且定义一个新的标记函数就像向 JavaScript 添加新的语法。标记模板字面量已经被许多前端 JavaScript 包采用。GraphQL 查询语言使用 gql 标签函数来允许查询嵌入到 JS 代码中。以及,Emotion 库使用css标记函数来使 CSS 样式嵌入到 JavaScript 中。本节演示了如何编写自己的类似这样的标记函数。
标记函数没有什么特别之处:它们是普通的 JavaScript 函数,不需要特殊的语法来定义它们。当一个函数表达式后面跟着一个模板字面量时,该函数被调用。第一个参数是一个字符串数组,然后是零个或多个额外参数,这些参数可以是任何类型的值。
参数的数量取决于插入到模板字面量中的值的数量。如果模板字面量只是一个没有插值的常量字符串,那么标记函数将被调用一个包含那个字符串的数组和没有额外参数的数组。如果模板字面量包含一个插入值,那么标记函数将被调用两个参数。第一个是包含两个字符串的数组,第二个是插入的值。初始数组中的字符串是插入值左侧的字符串和右侧的字符串,其中任何一个都可能是空字符串。如果模板字面量包含两个插入值,那么标记函数将被调用三个参数:一个包含三个字符串和两个插入值的数组。这三个字符串(任何一个或全部可能为空)是第一个值左侧的文本、两个值之间的文本和第二个值右侧的文本。在一般情况下,如果模板字面量有 n 个插入值,那么标记函数将被调用 n+1 个参数。第一个参数将是一个包含 n+1 个字符串的数组,其余参数是 n 个插入值,按照它们在模板字面量中出现的顺序。
模板字面量的值始终是一个字符串。但是标记模板字面量的值是标记函数返回的任何值。这可能是一个字符串,但是当标记函数用于实现 DSL 时,返回值通常是一个非字符串数据结构,它是字符串的解析表示。
作为一个返回字符串的模板标签函数的示例,考虑以下 html 模版,当你打算将值安全插入 HTML 字符串时比较实用。在使用它构建最终字符串之前,该标签在每个值上执行 HTML 转义:
function html(strings, ...values) {
// 将每个值转换为字符串并转义特殊的 HTML 字符
let escaped = values.map(v => String(v)
.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
.replace('"', """)
.replace("'", "'"));
// 返回连接的字符串和转义的值
let result = strings[0];
for(let i = 0; i < escaped.length; i++) {
result += escaped[i] + strings[i+1];
}
return result;
}
let operator = "<";
html`<b>x ${operator} y</b>` // => "<b>x < y</b>"
let kind = "game", name = "D&D";
html`<div class="${kind}">${name}</div>` // =>'<div class="game">D&D</div>'
对于不返回字符串而是返回字符串的解析表示的标记函数的示例,回想一下 14.4.6 中定义的 Glob 模式类。由于Glob()构造函数采用单个字符串参数,因此我们可以定义一个标记函数来创建新的 Glob 对象:
function glob(strings, ...values) {
// 将字符串和值组装成一个字符串
let s = strings[0];
for(let i = 0; i < values.length; i++) {
s += values[i] + strings[i+1];
}
// 返回该字符串的解析表示
return new Glob(s);
}
let root = "/tmp";
let filePattern = glob`${root}/*.html`; // 一个 RegExp 替代方案
"/tmp/test.html".match(filePattern)[1] // => "test"
3.3.4 节中提到的特性之一是String.raw标签函数,以其“原始”形式返回一个字符串,而不解释任何反斜杠转义序列。这是使用我们尚未讨论过的标签函数调用的一个特性实现的。当调用标签函数时,我们已经看到它的第一个参数是一个字符串数组。但是这个数组还有一个名为 raw 的属性,该属性的值是另一个具有相同数量元素的字符串数组。参数数组包含已解释转义序列的字符串。原始数组包含未解释转义序列的字符串。如果要定义使用反斜杠的语法的语法的 DSL,这个晦涩的特性是重要的。例如,如果我们想要我们的 glob 标签函数支持 Windows 风格路径上的模式匹配(它使用反斜杠而不是正斜杠),并且我们不希望标签的用户必须双写每个反斜杠,我们可以重写该函数来使用strings.raw[]而不是strings[]。 当然,缺点可能是我们不能再在我们的 glob 字面值中使用转义,例如\u。
14.6 Reflect API
Reflect 对象不是一个类;像 Math 对象一样,其属性只是定义了一组相关函数。这些函数在 ES6 中添加,定义了一个 API,用于“反射”对象及其属性。这里几乎没有新功能:Reflect 对象定义了一组方便的函数,全部在一个命名空间中,模仿核心语言语法的行为并复制各种现有 Object 函数的功能。
尽管 Reflect 函数不提供任何新功能,但它们将功能组合在一个便利的 API 中。而且,重要的是,Reflect 函数集与我们将在§14.7 中学习的 Proxy 处理程序方法一一对应。
Reflect API 包括以下函数:
Reflect.apply(f, o, args)
此函数将函数f作为o的方法调用(如果o为null,则作为没有this值的函数调用),并将args数组中的值作为参数传递。它等效于f.apply(o, args)。
Reflect.construct(c, args, newTarget)
此函数调用构造函数c,就像使用new关键字一样,并将数组args的元素作为参数传递。如果指定了可选的newTarget参数,则它将用作构造函数调用中的new.target值。如果未指定,则new.target值将为c。
Reflect.defineProperty(o, name, descriptor)
此函数在对象o上定义属性,使用name(字符串或符号)作为属性的名称。描述符对象应该定义属性的值(或 getter 和/或 setter)和属性的属性。Reflect.defineProperty()与Object.defineProperty()非常相似,但成功时返回true,失败时返回false。(Object.defineProperty()成功时返回o,失败时抛出 TypeError。)
Reflect.deleteProperty(o, name)
此函数从对象o中删除具有指定字符串或符号名称的属性,如果成功(或不存在此属性),则返回true,如果无法删除属性,则返回false。调用此函数类似于编写delete o[name]。
Reflect.get(o, name, receiver)
此函数返回具有指定名称(字符串或符号)的对象o的属性的值。如果属性是具有 getter 的访问器方法,并且指定了可选的receiver参数,则 getter 函数将作为receiver的方法调用,而不是作为o的方法调用。调用此函数类似于评估o[name]。
Reflect.getOwnPropertyDescriptor(o, name)
此函数返回描述对象,描述对象描述了对象o的名为name的属性的属性,如果不存在此属性,则返回undefined。此函数与Object.getOwnPropertyDescriptor()几乎相同,只是 Reflect API 版本的函数要求第一个参数是对象,如果不是则抛出 TypeError。
Reflect.getPrototypeOf(o)
此函数返回对象o的原型,如果对象没有原型则返回null。如果o是原始值而不是对象,则抛出 TypeError。此函数与Object.getPrototypeOf()几乎相同,只是Object.getPrototypeOf()仅对null和undefined参数抛出 TypeError,并将其他原始值强制转换为其包装对象。
Reflect.has(o, name)
如果对象o具有指定的name属性(必须是字符串或符号),则此函数返回true。调用此函数类似于评估name in o。
Reflect.isExtensible(o)
此函数返回true如果对象o是可扩展的(§14.2),如果不可扩展则返回false。如果o不是对象,则抛出 TypeError。Object.isExtensible()类似,但当传递一个不是对象的参数时,它只返回false。
Reflect.ownKeys(o)
此函数返回对象o的属性名称的数组,如果o不是对象则抛出 TypeError。返回的数组中的名称将是字符串和/或符号。调用此函数类似于调用Object.getOwnPropertyNames()和Object.getOwnPropertySymbols()并组合它们的结果。
Reflect.preventExtensions(o)
此函数将对象o的可扩展属性(§14.2)设置为false,并返回true以指示成功。如果o不是对象,则抛出 TypeError。Object.preventExtensions()具有相同的效果,但返回o而不是true,并且不会为非对象参数抛出 TypeError。
Reflect.set(o, name, value, receiver)
此函数将对象o的指定name属性设置为指定的value。成功时返回true,失败时返回false(如果属性是只读的,则可能失败)。如果o不是对象,则抛出 TypeError。如果指定的属性是具有 setter 函数的访问器属性,并且传递了可选的receiver参数,则将调用 setter 作为receiver的方法,而不是作为o的方法。调用此函数通常与评估o[name] = value相同。
Reflect.setPrototypeOf(o, p)
此函数将对象o的原型设置为p,成功时返回true,失败时返回false(如果o不可扩展或操作会导致循环原型链)。如果o不是对象或p既不是对象也不是null,则抛出 TypeError。Object.setPrototypeOf()类似,但成功时返回o,失败时抛出 TypeError。请记住,调用这些函数之一可能会使您的代码变慢,因为它会破坏 JavaScript 解释器的优化。
14.7 代理对象
ES6 及更高版本中提供的 Proxy 类是 JavaScript 最强大的元编程功能。它允许我们编写改变 JavaScript 对象基本行为的代码。在§14.6 中描述的 Reflect API 是一组函数,它们直接访问 JavaScript 对象上的一组基本操作。Proxy 类的作用是允许我们自己实现这些基本操作并创建行为与普通对象不同的对象。
创建代理对象时,我们指定另外两个对象,目标对象和处理程序对象:
let proxy = new Proxy(target, handlers);
生成的代理对象没有自己的状态或行为。每当对其执行操作(读取属性、写入属性、定义新属性、查找原型、将其作为函数调用),它都将这些操作分派到处理程序对象或目标对象。
代理对象支持的操作与 Reflect API 定义的操作相同。假设p是代理对象,您写delete p.x。Reflect.deleteProperty()函数的行为与delete运算符相同。当使用delete运算符删除代理对象的属性时,它会在处理程序对象上查找deleteProperty()方法。如果存在这样的方法,则调用它。如果处理程序对象上不存在这样的方法,则代理对象将在目标对象上执行属性删除。
对于所有基本操作,代理对象都是这样工作的:如果处理程序对象上存在适当的方法,则调用该方法执行操作。(方法名称和签名与§14.6 中涵盖的 Reflect 函数相同。)如果处理程序对象上不存在该方法,则代理对象将在目标对象上执行基本操作。这意味着代理对象可以从目标对象或处理程序对象获取其行为。如果处理程序对象为空,则代理基本上是目标对象周围的透明包装器:
let t = { x: 1, y: 2 };
let p = new Proxy(t, {});
p.x // => 1
delete p.y // => true: 删除代理的属性 y
t.y // => undefined: 这也会在目标中删除它
p.z = 3; // 在代理上定义一个新属性
t.z // => 3: 在目标上定义属性
这种透明包装代理本质上等同于底层目标对象,这意味着没有理由使用它而不是包装对象。然而,当创建为“可撤销代理”时,透明包装器可以很有用。你可以使用 Proxy.revocable() 工厂函数,而不是使用 Proxy() 构造函数来创建代理。这个函数返回一个对象,其中包括一个代理对象和一个 revoke() 函数。一旦调用 revoke() 函数,代理立即停止工作:
function accessTheDatabase() { /* 实现省略 */ return 42; }
let {proxy, revoke} = Proxy.revocable(accessTheDatabase, {});
proxy() // => 42: 代理提供对基础目标函数的访问
revoke(); // 但是访问可以随时关闭
proxy(); // !TypeError: 我们不能再调用这个函数
注意,除了演示可撤销代理之外,上述代码还演示了代理可以与目标函数以及目标对象一起使用。但这里的主要观点是可撤销代理是一种代码隔离的基础,当你处理不受信任的第三方库时,可能会用到它们。例如,如果你必须将一个函数传递给一个你无法控制的库,你可以传递一个可撤销代理,然后在完成与库的交互后撤销代理。这可以防止库保留对你的函数的引用,并在意想不到的时候调用它。这种防御性编程在 JavaScript 程序中并不典型,但 Proxy 类至少使其成为可能。
如果我们将非空处理程序对象传递给 Proxy() 构造函数,那么我们不再定义一个透明的包装器对象,而是为我们的代理实现自定义行为。通过正确设置处理程序,底层目标对象基本上变得无关紧要。
例如,以下代码展示了如何实现一个看起来具有无限只读属性的对象,其中每个属性的值与属性的名称相同:
// 我们使用代理创建一个对象, 看起来拥有每个
// 可能的属性, 每个属性的值都等于其名称
let identity = new Proxy({}, {
// 每个属性都以其名称作为其值
get(o, name, target) { return name; },
// 每个属性名称都已定义
has(o, name) { return true; },
// 有太多属性要枚举, 所以我们只是抛出
ownKeys(o) { throw new RangeError("属性数量无限"); },
// 所有属性都存在且不可写, 不可配置或可枚举。
getOwnPropertyDescriptor(o, name) {
return {
value: name,
enumerable: false,
writable: false,
configurable: false
};
},
// 所有属性都是只读的, 因此无法设置
set(o, name, value, target) { return false; },
// 所有属性都是不可配置的, 因此它们无法被删除
deleteProperty(o, name) { return false; },
// 所有属性都存在且不可配置, 因此我们无法定义更多
defineProperty(o, name, desc) { return false; },
// 实际上, 这意味着对象不可扩展
isExtensible(o) { return false; },
// 所有属性已在此对象上定义, 因此无法
// 即使它有原型对象, 也不会继承任何东西。
getPrototypeOf(o) { return null; },
// 对象不可扩展, 因此我们无法更改原型
setPrototypeOf(o, proto) { return false; },
});
identity.x // => "x"
identity.toString // => "toString"
identity[0] // => "0"
identity.x = 1; // 设置属性没有效果
identity.x // => "x"
delete identity.x // => false: 也无法删除属性
identity.x // => "x"
Object.keys(identity); // !RangeError: 无法列出所有键
for(let p of identity) ; // !RangeError
代理对象可以从目标对象和处理程序对象派生其行为,到目前为止我们看到的示例都使用了一个对象或另一个对象。但通常更有用的是定义同时使用两个对象的代理。
例如,下面的代码使用 Proxy 创建了一个目标对象的只读包装器。当代码尝试从对象中读取值时,这些读取会正常转发到目标对象。但如果任何代码尝试修改对象或其属性,处理程序对象的方法会抛出 TypeError。这样的代理可能有助于编写测试:假设你编写了一个接受对象参数的函数,并希望确保你的函数不会尝试修改输入参数。如果你的测试传入一个只读包装器对象,那么任何写入操作都会抛出异常,导致测试失败:
function readOnlyProxy(o) {
function readonly() { throw new TypeError("只读"); }
return new Proxy(o, {
set: readonly,
defineProperty: readonly,
deleteProperty: readonly,
setPrototypeOf: readonly,
});
}
let o = { x: 1, y: 2 }; // 普通可写对象
let p = readOnlyProxy(o); // 它的只读版本
p.x // => 1: 读取属性有效
p.x = 2; // !TypeError: 无法更改属性
delete p.y; // !TypeError: 无法删除属性
p.z = 3; // !TypeError: 无法添加属性
p.__proto__ = {}; // !TypeError: 无法更改原型
写代理时的另一种技术是定义处理程序方法,拦截对象上的操作,但仍将操作委托给目标对象。Reflect API(§14.6)的函数具有与处理程序方法完全相同的签名,因此它们使得执行这种委托变得容易。
例如,这是一个代理,它将所有操作委托给目标对象,但使用处理程序方法记录操作:
/*
* 返回一个代理对象, 包装 o, 将所有操作委托给
* 在记录每个操作后, 该对象的名称是一个字符串
* 将出现在日志消息中以标识对象。如果 o 具有自有
* 其值为对象或函数, 则如果您查询
* 这些属性的值是对象或函数, 则返回代理而不是
* 此代理的记录行为是“传染性的”。
*/
function loggingProxy(o, objname) {
// 为我们的记录代理对象定义处理程序。
// 每个处理程序记录一条消息, 然后委托给目标对象。
const handlers = {
// 这个处理程序是一个特例, 因为对于自有属性
// 其值为对象或函数, 则返回代理而不是值本身。
// 返回值本身。
get(target, property, receiver){
// 记录获取操作
console.log(`Handler get(${objname}, ${property.toString()})`);
// 使用 Reflect API 获取属性值
let value = Reflect.get(target, property, receiver);
// 如果属性是目标的自有属性且
// 值是对象或函数, 则返回其代理。
if(Reflect.ownKeys(target).includes(property)&&
(typeof value === "object" || typeof value === "function")){
return loggingProxy(value, `${objname}.${property.toString()}`);
}
// 否则返回未修改的值。
返回值;
},
// 以下三种方法没有特殊之处:
// 它们记录操作并委托给目标对象。
// 它们是一个特例, 只是为了避免记录
// 可能导致无限递归的接收器对象。
set(target, prop, value, receiver){
console.log(`Handler set(${objname}, ${prop.toString()}, ${value})`);
return Reflect.set(target, prop, value, receiver);
},
apply(target, receiver, args){
console.log(`Handler ${objname}(${args})`);
return Reflect.apply(target, receiver, args);
},
构造(target, args, receiver){
console.log(`Handler ${objname}(${args})`);
return Reflect.construct(target, args, receiver);
}
};
// 我们可以自动生成其余的处理程序。
// 元编程 FTW!
Reflect.ownKeys(Reflect).forEach(handlerName => {
if(!(handlerName in handlers)){
handlers[handlerName] = function(target, ...args){
// 记录操作
console.log(`Handler ${handlerName}(${objname}, ${args})`);
// 委托操作
return Reflect[handlerName](target, ...args);
};
}
});
// 返回一个对象的代理, 使用这些记录处理程序
return new Proxy(o, handlers);
}
之前定义的 loggingProxy() 函数创建了记录其使用方式的代理。如果你试图了解一个未记录的函数如何使用你传递给它的对象,使用记录代理可以帮助。
考虑以下示例,这些示例对数组迭代产生了一些真正的见解:
// 定义一个数据数组和一个具有函数属性的对象
let data = [10,20];
let methods = { square: x => x*x };
// 为数组和具有函数属性的对象创建记录代理
let proxyData = loggingProxy(data, "data");
let proxyMethods = loggingProxy(methods, "methods");
// 假设我们想要了解 Array.map()方法的工作原理
data.map(methods.square) // => [100, 400]
// 首先, 让我们尝试使用一个记录代理数组
proxyData.map(methods.square) // => [100, 400]
// 它产生以下输出:
// Handler get(data, map)
// Handler get(data, length)
// Handler get(data, constructor)
// Handler has(data, 0)
// Handler get(data, 0)
// Handler has(data, 1)
// Handler get(data, 1)
// 现在让我们尝试使用一个代理方法对象
data.map(proxyMethods.square) // => [100, 400]
// 记录输出:
// Handler get(methods, square)
// Handler methods.square(10, 0, 10, 20)
// Handler methods.square(20, 1, 10, 20)
// 最后, 让我们使用一个记录代理来了解迭代协议
for(let x of proxyData) console.log("data", x);
// 记录输出:
// Handler get(data, Symbol(Symbol.iterator))
// Handler get(data, length)
// Handler get(data, 0)
// data 10
// Handler get(data, length)
// Handler get(data, 1)
// data 20
// Handler get(data, length)
从第一块日志输出中,我们了解到 Array.map() 方法在实际读取元素值之前明确检查每个数组元素的存在性(导致调用 has() 处理程序),然后读取元素值(触发 get() 处理程序)。这可能是为了区分不存在的数组元素和存在但为 undefined 的元素。
第二块日志输出可能会提醒我们,我们传递给 Array.map() 的函数会使用三个参数调用:元素的值、元素的索引和数组本身。(我们的日志输出中存在问题:Array.toString() 方法在其输出中不包含方括号,并且如果在参数列表中包含它们,日志消息将更清晰 (10,0,[10,20])。)
第三块日志输出向我们展示了 for/of 循环是通过查找具有符号名称 [Symbol.iterator] 的方法来工作的。它还演示了 Array 类对此迭代器方法的实现在每次迭代时都会检查数组长度,并且不假设数组长度在迭代过程中保持不变。
14.7.1 代理不变性
之前定义的 readOnlyProxy() 函数创建了实际上是冻结的代理对象:任何尝试更改属性值、属性属性或添加或删除属性的尝试都会引发异常。但只要目标对象未被冻结,我们会发现如果我们可以使用 Reflect.isExtensible() 和 Reflect.getOwnPropertyDescriptor() 查询代理,它会告诉我们应该能够设置、添加和删除属性。因此,readOnlyProxy() 创建了处于不一致状态的对象。我们可以通过添加 isExtensible() 和 getOwnPropertyDescriptor() 处理程序来解决这个问题,或者我们可以接受这种轻微的不一致性。
代理处理程序 API 允许我们定义具有主要不一致性的对象,但在这种情况下,代理类本身将阻止我们创建不良不一致的代理对象。在本节开始时,我们将代理描述为没有自己行为的对象,因为它们只是将所有操作转发到处理程序对象和目标对象。但这并不完全正确:在转发操作后,代理类会对结果执行一些检查,以确保不违反重要的 JavaScript 不变性。如果检测到违规,代理将抛出 TypeError,而不是让操作继续执行。
例如,如果为不可扩展对象创建代理,则如果 isExtensible() 处理程序返回 true,代理将抛出 TypeError:
let target = Object.preventExtensions({});
let proxy = new Proxy(target, { isExtensible(){ return true; });
Reflect.isExtensible(proxy); // !TypeError:不变违规
相关地,对于不可扩展目标的代理对象可能没有返回除目标的真实原型对象之外的 getPrototypeOf() 处理程序。此外,如果目标对象具有不可写、不可配置的属性,则代理类将在 get() 处理程序返回除实际值之外的任何内容时抛出 TypeError:
let target = Object.freeze({x: 1});
let proxy = new Proxy(target, { get(){ return 99; });
proxy.x; // !TypeError:get()返回的值与目标不匹配
代理强制执行许多附加不变性,几乎所有这些不变性都与不可扩展的目标对象和目标对象上的不可配置属性有关。
14.8 总结
在本章中,您已经学到了:
-
JavaScript 对象具有可扩展属性,对象属性具有可写、可枚举和可配置属性,以及值和 getter 和/或 setter 属性。您可以使用这些属性以各种方式“锁定”您的对象,包括创建“密封”和“冻结”对象。
-
JavaScript 定义了一些函数,允许您遍历对象的原型链,甚至更改对象的原型(尽管这样做可能会使您的代码变慢)。
-
Symbol对象的属性具有“众所周知的符号”值,您可以将其用作您定义的对象和类的属性或方法名称。这样做可以让您控制对象与 JavaScript 语言特性和核心库的交互方式。例如,众所周知的符号允许您使您的类可迭代,并控制将实例传递给Object.prototype.toString()时显示的字符串。在 ES6 之前,这种定制仅适用于内置到实现中的本机类。 -
标记模板字面量是一种函数调用语法,定义一个新的标签函数有点像向语言添加新的文字语法。定义一个解析其模板字符串参数的标签函数允许您在 JavaScript 代码中嵌入 DSL。标签函数还提供对原始、未转义的字符串文字的访问,其中反斜杠没有特殊含义。
-
代理类和相关的 Reflect API 允许对 JavaScript 对象的基本行为进行低级控制。代理对象可以用作可选撤销包装器,以改善代码封装,并且还可以用于实现非标准对象行为(例如早期 Web 浏览器定义的一些特殊情况 API)。
¹ V8 JavaScript 引擎中的一个错误意味着这段代码在 Node 13 中无法正常工作。
第十五章:JavaScript 在 Web 浏览器中
JavaScript 语言是在 1994 年创建的,旨在使 Web 浏览器显示的文档具有动态行为。自那时以来,该语言已经发生了显著的演变,与此同时,Web 平台的范围和功能也迅速增长。今天,JavaScript 程序员可以将 Web 视为一个功能齐全的应用程序开发平台。Web 浏览器专门用于显示格式化文本和图像,但是,像本机操作系统一样,浏览器还提供其他服务,包括图形、视频、音频、网络、存储和线程。JavaScript 是一种使 Web 应用程序能够使用 Web 平台提供的服务的语言,本章演示了您如何使用这些最重要的服务。
本章从网络平台的编程模型开始,解释了脚本如何嵌入在 HTML 页面中(§15.1),以及 JavaScript 代码如何通过事件异步触发(§15.2)。接下来的部分将记录启发性材料之后的核心 JavaScript API,使您的 Web 应用程序能够:
-
控制文档内容(§15.3)和样式(§15.4)
-
确定文档元素的屏幕位置(§15.5)
-
创建可重用的用户界面组件(§15.6)
-
绘制图形(§15.7 和§15.8)
-
播放和生成声音(§15.9)
-
管理浏览器导航和历史记录(§15.10)
-
在网络上交换数据(§15.11)
-
在用户计算机上存储数据(§15.12)
-
使用线程执行并发计算(§15.13)
本书的早期版本试图全面涵盖 Web 浏览器定义的所有 JavaScript API,结果,十年前这本书太长了。Web API 的数量和复杂性继续增长,我不再认为尝试在一本书中涵盖它们所有是有意义的。截至第七版,我的目标是全面覆盖 JavaScript 语言,并提供深入介绍如何在 Node 和 Web 浏览器中使用该语言。本章无法涵盖所有 Web API,但它以足够的细节介绍了最重要的 API,以便您可以立即开始使用它们。并且,学习了这里介绍的核心 API 后,您应该能够在需要时学习新的 API(比如§15.15 中总结的那些)。
Node 有一个单一的实现和一个单一的权威文档来源。相比之下,Web API 是由主要的 Web 浏览器供应商之间的共识定义的,权威文档采用了面向实现 API 的 C++程序员的规范形式,而不是面向将使用它的 JavaScript 程序员。幸运的是,Mozilla 的“MDN web docs”项目是 Web API 文档的一个可靠和全面的来源¹。
15.1 Web 编程基础
本节解释了 Web 上的 JavaScript 程序的结构,它们如何加载到 Web 浏览器中,如何获取输入,如何产生输出,以及如何通过响应事件异步运行。
15.1.1 HTML 中的 JavaScript <script>标签
Web 浏览器显示 HTML 文档。如果您希望 Web 浏览器执行 JavaScript 代码,您必须在 HTML 文档中包含(或引用)该代码,这就是 HTML <script>标签的作用。
JavaScript 代码可以内联出现在 HTML 文件中的<script>和</script>标签之间。例如,这是一个包含 JavaScript 代码的脚本标签的 HTML 文件,动态更新文档的一个元素,使其表现得像一个数字时钟:
<!DOCTYPE html> <!-- This is an HTML5 file -->
<html> <!-- The root element -->
<head> <!-- Title, scripts & styles can go here -->
<title>Digital Clock</title>
<style> /* A CSS stylesheet for the clock */
#clock { /* Styles apply to element with id="clock" */
font: bold 24px sans-serif; /* Use a big bold font */
background: #ddf; /* on a light bluish-gray background. */
padding: 15px; /* Surround it with some space */
border: solid black 2px; /* and a solid black border */
border-radius: 10px; /* with rounded corners. */
}
</style>
</head>
<body> <!-- The body holds the content of the document. -->
<h1>Digital Clock</h1> <!-- Display a title. -->
<span id="clock"></span> <!-- We will insert the time into this element. -->
<script>
// Define a function to display the current time
function displayTime() {
let clock = document.querySelector("#clock"); // Get element with id="clock"
let now = new Date(); // Get current time
clock.textContent = now.toLocaleTimeString(); // Display time in the clock
}
displayTime() // Display the time right away
setInterval(displayTime, 1000); // And then update it every second.
</script>
</body>
</html>
尽管 JavaScript 代码可以直接嵌入在<script>标签中,但更常见的做法是使用<script>标签的src属性来指定包含 JavaScript 代码的文件的 URL(绝对 URL 或相对于显示的 HTML 文件的 URL)。如果我们将这个 HTML 文件中的 JavaScript 代码提取出来并存储在自己的scripts/digital_clock.js文件中,那么<script>标签可能会引用该代码文件,如下所示:
<script src="scripts/digital_clock.js"></script>
一个 JavaScript 文件包含纯 JavaScript,没有<script>标签或任何其他 HTML。按照惯例,JavaScript 代码文件的名称以.js结尾。
带有src属性的<script>标签的行为与指定的 JavaScript 文件的内容直接出现在<script>和</script>标签之间完全相同。请注意,即使指定了src属性,HTML 文档中也需要关闭</script>标签:HTML 不支持<script/>标签。
使用src属性有许多优点:
-
通过允许您从 HTML 文件中删除大块 JavaScript 代码,简化了您的 HTML 文件 - 也就是说,它有助于保持内容和行为分离。
-
当多个网页共享相同的 JavaScript 代码时,使用
src属性可以让您仅维护该代码的单个副本,而无需在代码更改时编辑每个 HTML 文件。 -
如果一个 JavaScript 代码文件被多个页面共享,只需要被第一个使用它的页面下载一次,随后的页面可以从浏览器缓存中检索它。
-
因为
src属性以任意 URL 作为其值,所以来自一个 web 服务器的 JavaScript 程序或网页可以使用其他 web 服务器导出的代码。许多互联网广告都依赖于这一点。
模块
§10.3 文档了 JavaScript 模块,并涵盖它们的import和export指令。如果您使用模块编写了 JavaScript 程序(并且没有使用代码捆绑工具将所有模块组合成单个非模块化的 JavaScript 文件),那么您必须使用带有type="module"属性的<script>标签加载程序的顶层模块。如果这样做,那么您指定的模块将被加载,它导入的所有模块也将被加载,以及(递归地)导入的所有模块也将被加载。详细信息请参见§10.3.5。
指定脚本类型
在 web 的早期,人们认为浏览器可能会实现除 JavaScript 外的其他语言,程序员们在他们的<script>标签中添加了language="javascript"和type="application/javascript"等属性。这是完全不必要的。JavaScript 是 web 的默认(也是唯一)语言。language属性已被弃用,只有两个原因可以在<script>标签上使用type属性:
-
指定脚本为模块
-
将数据嵌入网页而不显示它(参见§15.3.4)
脚本何时运行:异步和延迟
当 JavaScript 首次添加到 web 浏览器时,没有 API 可以遍历和操作已经呈现的文档的结构和内容。JavaScript 代码影响文档内容的唯一方法是在文档加载过程中动态生成内容。它通过使用document.write()方法将 HTML 文本注入到脚本位置来实现这一点。
使用document.write()不再被认为是良好的风格,但它是可能的事实意味着当 HTML 解析器遇到<script>元素时,默认情况下必须运行脚本,以确保它在恢复解析和呈现文档之前不输出任何 HTML。这可能会显著减慢网页的解析和呈现速度。
幸运的是,默认的同步或阻塞脚本执行模式并不是唯一的选择。<script>标签可以具有defer和async属性,这会导致脚本以不同的方式执行。这些是布尔属性——它们没有值;它们只需要出现在<script>标签上。请注意,这些属性仅在与src属性一起使用时才有意义:
<script defer src="deferred.js"></script>
<script async src="async.js"></script>
defer和async属性都是告诉浏览器链接的脚本不使用document.write()来生成 HTML 输出的方式,因此浏览器可以在下载脚本的同时继续解析和渲染文档。defer属性会导致浏览器推迟执行脚本,直到文档完全加载和解析完成,并且准备好被操作。async属性会导致浏览器尽快运行脚本,但不会在下载脚本时阻止文档解析。如果一个<script>标签同时具有这两个属性,async属性优先。
注意,延迟脚本按照它们在文档中出现的顺序运行。异步脚本在加载时运行,这意味着它们可能无序执行。
带有type="module"属性的脚本默认在文档加载后执行,就像它们有一个defer属性一样。您可以使用async属性覆盖此默认行为,这将导致代码在模块及其所有依赖项加载后立即执行。
一个简单的替代方案是async和defer属性——特别是对于直接包含在 HTML 中的代码——只需将脚本放在 HTML 文件的末尾。这样,脚本可以运行,知道它前面的文档内容已被解析并准备好被操作。
按需加载脚本
有时,您可能有一些 JavaScript 代码在文档首次加载时不被使用,只有在用户执行某些操作,如点击按钮或打开菜单时才需要。如果您正在使用模块开发代码,可以使用import()按需加载模块,如§10.3.6 中所述。
如果您不使用模块,可以在希望脚本加载时向文档添加一个<script>标签来按需加载 JavaScript 文件:
// Asynchronously load and execute a script from a specified URL
// Returns a Promise that resolves when the script has loaded.
function importScript(url) {
return new Promise((resolve, reject) => {
let s = document.createElement("script"); // Create a <script> element
s.onload = () => { resolve(); }; // Resolve promise when loaded
s.onerror = (e) => { reject(e); }; // Reject on failure
s.src = url; // Set the script URL
document.head.append(s); // Add <script> to document
});
}
这个importScript()函数使用 DOM API(§15.3)来创建一个新的<script>标签,并将其添加到文档的<head>中。它使用事件处理程序(§15.2)来确定脚本何时成功加载或加载失败。
15.1.2 文档对象模型
在客户端 JavaScript 编程中最重要的对象之一是文档对象,它代表在浏览器窗口或标签中显示的 HTML 文档。用于处理 HTML 文档的 API 称为文档对象模型,或 DOM,在§15.3 中有详细介绍。但是 DOM 在客户端 JavaScript 编程中如此重要,以至于应该在这里介绍。
HTML 文档包含嵌套在一起的 HTML 元素,形成一棵树。考虑以下简单的 HTML 文档:
<html>
<head>
<title>Sample Document</title>
</head>
<body>
<h1>An HTML Document</h1>
<p>This is a <i>simple</i> document.
</body>
</html>
顶层的<html>标签包含<head>和<body>标签。<head>标签包含一个<title>标签。<body>标签包含<h1>和<p>标签。<title>和<h1>标签包含文本字符串,<p>标签包含两个文本字符串,中间有一个<i>标签。
DOM API 反映了 HTML 文档的树结构。对于文档中的每个 HTML 标签,都有一个对应的 JavaScript Element 对象,对于文档中的每个文本运行,都有一个对应的 Text 对象。Element 和 Text 类,以及 Document 类本身,都是更一般的 Node 类的子类,Node 对象组织成 JavaScript 可以使用 DOM API 查询和遍历的树结构。此文档的 DOM 表示是 图 15-1 中描绘的树。

图 15-1。HTML 文档的树形表示
如果您对计算机编程中的树结构不熟悉,了解它们从家谱中借来的术语会有所帮助。直接在节点上方的节点是该节点的父节点。直接在另一个节点下一级的节点是该节点的子节点。在同一级别且具有相同父节点的节点是兄弟节点。在另一个节点下的任意级别的节点是该节点的后代节点。父节点、祖父节点和其他所有在节点上方的节点都是该节点的祖先节点。
DOM API 包括用于创建新的 Element 和 Text 节点,并将它们作为其他 Element 对象的子节点插入文档的方法。还有用于在文档中移动元素和完全删除它们的方法。虽然服务器端应用程序可能通过使用 console.log() 写入字符串来生成纯文本输出,但客户端 JavaScript 应用程序可以通过使用 DOM API 构建或操作文档树来生成格式化的 HTML 输出。
每个 HTML 标签类型都对应一个 JavaScript 类,文档中每个标签的出现都由该类的一个实例表示。例如,<body> 标签由 HTMLBodyElement 的一个实例表示,<table> 标签由 HTMLTableElement 的一个实例表示。JavaScript 元素对象具有与标签的 HTML 属性对应的属性。例如,代表 <img> 标签的 HTMLImageElement 实例具有一个与标签的 src 属性对应的 src 属性。src 属性的初始值是出现在 HTML 标签中的属性值,使用 JavaScript 设置此属性会改变 HTML 属性的值(并导致浏览器加载和显示新图像)。大多数 JavaScript 元素类只是反映 HTML 标签的属性,但有些定义了额外的方法。例如,HTMLAudioElement 和 HTMLVideoElement 类定义了像 play() 和 pause() 这样的方法,用于控制音频和视频文件的播放。
15.1.3 Web 浏览器中的全局对象
每个浏览器窗口或标签页都有一个全局对象(§3.7)。在该窗口中运行的所有 JavaScript 代码(除了在工作线程中运行的代码;参见§15.13)共享这个单一全局对象。无论文档中有多少脚本或模块,这一点都是真实的:文档中的所有脚本和模块共享一个全局对象;如果一个脚本在该对象上定义了一个属性,那么其他所有脚本也能看到这个属性。
全局对象是 JavaScript 标准库的定义位置——parseInt() 函数、Math 对象、Set 类等等。在 Web 浏览器中,全局对象还包含各种 Web API 的主要入口点。例如,document 属性代表当前显示的文档,fetch() 方法发起 HTTP 网络请求,Audio() 构造函数允许 JavaScript 程序播放声音。
在 Web 浏览器中,全局对象承担双重职责:除了定义内置类型和函数之外,它还表示当前 Web 浏览器窗口,并定义诸如 history(§15.10.2)这样的属性,表示窗口的浏览历史,以及 innerWidth,保存窗口的宽度(以像素为单位)。这个全局对象的一个属性名为 window,其值是全局对象本身。这意味着您可以简单地在客户端代码中输入 window 来引用全局对象。在使用特定于窗口的功能时,通常最好包含一个 window. 前缀:例如,window.innerWidth 比 innerWidth 更清晰。
15.1.4 脚本共享命名空间
使用模块时,在模块顶层(即在任何函数或类定义之外)定义的常量、变量、函数和类对于模块是私有的,除非它们被明确导出,这样,其他模块可以有选择地导入它们。(请注意,模块的这个属性也受到代码捆绑工具的尊重。)
然而,对于非模块脚本,情况完全不同。如果脚本中的顶层代码定义了常量、变量、函数或类,那个声明将对同一文档中的所有其他脚本可见。如果一个脚本定义了一个函数 f(),另一个脚本定义了一个类 c,那么第三个脚本可以调用该函数并实例化该类,而无需采取任何导入操作。因此,如果您不使用模块,在您的文档中的独立脚本共享一个单一命名空间,并且表现得好像它们都是单个更大脚本的一部分。这对于小型程序可能很方便,但在更大的程序中,特别是当一些脚本是第三方库时,需要避免命名冲突可能会成为问题。
这个共享命名空间的工作方式有一些历史上的怪癖。在顶层使用 var 和 function 声明会在共享的全局对象中创建属性。如果一个脚本定义了一个顶层函数 f(),那么同一文档中的另一个脚本可以将该函数调用为 f() 或 window.f()。另一方面,ES6 声明 const、let 和 class 在顶层使用时不会在全局对象中创建属性。然而,它们仍然在共享的命名空间中定义:如果一个脚本定义了一个类 C,其他脚本将能够使用 new C() 创建该类的实例,但不能使用 new window.C()。
总结一下:在模块中,顶层声明的作用域是模块,并且可以被明确导出。然而,在非模块脚本中,顶层声明的作用域是包含文档,并且这些声明被文档中的所有脚本共享。旧的 var 和 function 声明通过全局对象的属性共享。新的 const、let 和 class 声明也是共享的,并具有相同的文档作用域,但它们不作为 JavaScript 代码可以访问的任何对象的属性存在。
15.1.5 JavaScript 程序的执行
在客户端 JavaScript 中,程序 没有正式的定义,但我们可以说 JavaScript 程序包括文档中的所有 JavaScript 代码或引用的代码。这些独立的代码片段共享一个全局 Window 对象,使它们可以访问表示 HTML 文档的相同底层 Document 对象。不是模块的脚本还共享一个顶层命名空间。
如果网页包含嵌入的框架(使用 <iframe> 元素),嵌入文档中的 JavaScript 代码具有不同的全局对象和文档对象,与包含文档中的代码不同,并且可以被视为一个单独的 JavaScript 程序。但请记住,JavaScript 程序的边界没有正式的定义。如果容器文档和包含文档都是从同一服务器加载的,那么一个文档中的代码可以与另一个文档中的代码互动,并且您可以将它们视为单个程序的两个互动部分,如果您愿意的话。§15.13.6 解释了一个 JavaScript 程序如何与在 <iframe> 中运行的 JavaScript 代码发送和接收消息。
你可以将 JavaScript 程序执行看作是分为两个阶段进行的。在第一阶段中,文档内容被加载,<script> 元素中的代码(包括内联脚本和外部脚本)被运行。脚本通常按照它们在文档中出现的顺序运行,尽管这种默认顺序可以通过我们描述的 async 和 defer 属性进行修改。单个脚本中的 JavaScript 代码从上到下运行,当然,受 JavaScript 的条件语句、循环和其他控制语句的影响。在第一阶段中,一些脚本实际上并没有执行任何操作,而是仅仅定义函数和类供第二阶段使用。其他脚本可能在第一阶段做了大量工作,然后在第二阶段不做任何事情。想象一下一个位于文档末尾的脚本,它会查找文档中的所有 <h1> 和 <h2> 标签,并通过在文档开头生成并插入目录来修改文档。这完全可以在第一阶段完成。(参见 §15.3.6 中的一个实现此功能的示例。)
一旦文档加载完成并且所有脚本都运行完毕,JavaScript 执行进入第二阶段。这个阶段是异步和事件驱动的。如果一个脚本要参与这个第二阶段,那么,在第一阶段必须至少注册一个事件处理程序或其他回调函数,这些函数将被异步调用。在这个事件驱动的第二阶段,Web 浏览器根据异步发生的事件调用事件处理程序函数和其他回调。事件处理程序通常是响应用户输入(鼠标点击、按键等)而被调用,但也可能是由网络活动、文档和资源加载、经过的时间或 JavaScript 代码中的错误触发。事件和事件处理程序在 §15.2 中有详细描述。
在事件驱动阶段最先发生的一些事件是“DOMContentLoaded”和“load”事件。“DOMContentLoaded”在 HTML 文档完全加载和解析后触发。“load”事件在文档的所有外部资源(如图像)也完全加载后触发。JavaScript 程序通常使用其中一个事件作为触发器或启动信号。通常可以看到这样的程序,其脚本定义函数但除了注册一个事件处理程序函数以在执行的事件驱动阶段开始时由“load”事件触发外不执行任何操作。然后,这个“load”事件处理程序会操作文档并执行程序应该执行的任何操作。请注意,在 JavaScript 编程中,像这里描述的“load”事件处理程序这样的事件处理程序函数通常会注册其他事件处理程序。
JavaScript 程序的加载阶段相对较短:理想情况下不超过一秒。一旦文档加载完成,基于事件驱动的阶段将持续到网页被浏览器显示的整个时间。由于这个阶段是异步和事件驱动的,可能会出现长时间的不活动期,期间不执行任何 JavaScript,然后会因用户或网络事件触发而出现活动突发。接下来我们将更详细地介绍这两个阶段。
客户端 JavaScript 线程模型
JavaScript 是一种单线程语言,单线程执行使编程变得简单得多:您可以编写代码,确保两个事件处理程序永远不会同时运行。您可以操作文档内容,知道没有其他线程同时尝试修改它,而在编写 JavaScript 代码时永远不需要担心锁、死锁或竞争条件。
单线程执行意味着在脚本和事件处理程序执行时,Web 浏览器停止响应用户输入。这给 JavaScript 程序员带来了负担:这意味着 JavaScript 脚本和事件处理程序不能运行太长时间。如果脚本执行了计算密集型任务,它将延迟文档加载,用户将在脚本完成之前看不到文档内容。如果事件处理程序执行了计算密集型任务,浏览器可能会变得无响应,可能导致用户认为它已崩溃。
Web 平台定义了一种受控并发形式,称为“Web Worker”。Web Worker 是用于执行计算密集型任务的后台线程,而不会冻结用户界面。在 Web Worker 线程中运行的代码无法访问文档内容,也不与主线程或其他 Worker 共享任何状态,并且只能通过异步消息事件与主线程和其他 Worker 进行通信,因此主线程无法检测到并发,Web Worker 不会改变 JavaScript 程序的基本单线程执行模型。有关 Web 安全线程机制的完整详细信息,请参见§15.13。
客户端 JavaScript 时间轴
我们已经看到 JavaScript 程序开始于脚本执行阶段,然后过渡到事件处理阶段。这两个阶段可以进一步分解为以下步骤:
-
Web 浏览器创建一个 Document 对象并开始解析网页,随着解析 HTML 元素及其文本内容,将 Element 对象和 Text 节点添加到文档中。此时
document.readyState属性的值为“loading”。 -
当 HTML 解析器遇到一个没有任何
async、defer或type="module"属性的<script>标签时,它将该脚本标签添加到文档中,然后执行该脚本。脚本是同步执行的,而 HTML 解析器在脚本下载(如果需要)和运行时暂停。这样的脚本可以使用document.write()将文本插入输入流,当解析器恢复时,该文本将成为文档的一部分。这样的脚本通常只是定义函数并注册事件处理程序以供以后使用,但它可以遍历和操作文档树,就像它在那个时候存在的那样。也就是说,没有async或defer属性的非模块脚本可以看到自己的<script>标签和在它之前出现的文档内容。 -
当解析器遇到设置了
async属性的<script>元素时,它开始下载脚本文本(如果脚本是一个模块,它还会递归下载所有脚本的依赖项),并继续解析文档。脚本将在下载后尽快执行,但解析器不会停止等待它下载。异步脚本不能使用document.write()方法。它们可以看到自己的<script>标签和在它之前出现的所有文档内容,并且可能或可能不具有对额外文档内容的访问权限。 -
当文档完全解析时,
document.readyState属性更改为“interactive”。 -
任何设置了
defer属性的脚本(以及没有设置async属性的任何模块脚本)按照它们在文档中出现的顺序执行。异步脚本也可能在此时执行。延迟脚本可以访问完整的文档,它们不能使用document.write()方法。 -
浏览器在 Document 对象上触发“DOMContentLoaded”事件。这标志着从同步脚本执行阶段到程序执行的异步、事件驱动阶段的转变。但请注意,此时可能仍有尚未执行的
async脚本。 -
此时文档已完全解析,但浏览器可能仍在等待其他内容(如图像)加载。当所有这些内容加载完成,并且所有
async脚本已加载和执行时,document.readyState属性将更改为“complete”,并且网络浏览器在 Window 对象上触发“load”事件。 -
从这一点开始,事件处理程序将异步调用以响应用户输入事件、网络事件、定时器到期等。
15.1.6 程序输入和输出
与任何程序一样,客户端 JavaScript 程序处理输入数据以生成输出数据。有各种可用的输入:
-
文档本身的内容,JavaScript 代码可以使用 DOM API(§15.3)访问。
-
用户输入,以事件的形式,例如鼠标点击(或触摸屏点击)HTML
<button>元素,或输入到 HTML<textarea>元素中的文本,例如。§15.2 演示了 JavaScript 程序如何响应这些用户事件。 -
正在显示的文档的 URL 可以作为
document.URL在客户端 JavaScript 中使用。如果将此字符串传递给URL()构造函数(§11.9),您可以轻松访问 URL 的路径、查询和片段部分。 -
HTTP“Cookie”请求头的内容可以作为
document.cookie在客户端代码中使用。Cookie 通常由服务器端代码用于维护用户会话,但如果必要,客户端代码也可以读取(和写入)它们。有关详细信息,请参见§15.12.2。 -
全局的
navigator属性提供了关于网络浏览器、其运行的操作系统以及每个操作系统的功能的信息。例如,navigator.userAgent是一个标识网络浏览器的字符串,navigator.language是用户首选语言,navigator.hardwareConcurrency返回可用于网络浏览器的逻辑 CPU 数量。类似地,全局的screen属性通过screen.width和screen.height属性提供了用户的显示尺寸访问。在某种意义上,这些navigator和screen对象对于网络浏览器来说就像环境变量对于 Node 程序一样。
客户端 JavaScript 通常通过使用 DOM API(§15.3)操纵 HTML 文档或使用更高级的框架如 React 或 Angular 来操纵文档来生成输出。客户端代码还可以使用 console.log() 和相关方法(§11.8)生成输出。但这些输出只在 Web 开发者控制台中可见,因此在调试时很有用,但不适用于用户可见的输出。
15.1.7 程序错误
与直接运行在操作系统之上的应用程序(如 Node 应用程序)不同,Web 浏览器中的 JavaScript 程序实际上不能真正“崩溃”。如果在运行 JavaScript 程序时发生异常,并且没有 catch 语句来处理它,将在开发者控制台中显示错误消息,但已注册的任何事件处理程序仍在运行并响应事件。
如果您想定义一个最后一道防线的错误处理程序,在发生此类未捕获异常时调用,将 Window 对象的 onerror 属性设置为一个错误处理程序函数。当未捕获的异常传播到调用堆栈的最顶层并且即将在开发者控制台中显示错误消息时,window.onerror 函数将被调用,带有三个字符串参数。window.onerror 的第一个参数是描述错误的消息。第二个参数是一个包含导致错误的 JavaScript 代码的 URL 的字符串。第三个参数是错误发生的文档中的行号。如果 onerror 处理程序返回 true,它告诉浏览器处理程序已处理了错误,不需要进一步操作——换句话说,浏览器不应显示自己的错误消息。
当 Promise 被拒绝且没有 .catch() 函数来处理它时,这就像未处理的异常:您的程序中出现了意外错误或逻辑错误。您可以通过定义 window.onunhandledrejection 函数或使用 window.addEventListener() 注册一个“unhandledrejection”事件处理程序来检查这种情况。传递给此处理程序的事件对象将具有一个 promise 属性,其值是被拒绝的 Promise 对象,以及一个 reason 属性,其值是将传递给 .catch() 函数的内容。与前面描述的错误处理程序一样,如果在未处理的拒绝事件对象上调用 preventDefault(),它将被视为已处理,并且不会在开发者控制台中引发错误消息。
定义 onerror 或 onunhandledrejection 处理程序通常不是必需的,但如果您想要将客户端错误报告给服务器(例如使用 fetch() 函数进行 HTTP POST 请求),以便获取有关用户浏览器中发生的意外错误的信息,这可能非常有用。
15.1.8 Web 安全模型
Web 页面可以在您的个人设备上执行任意 JavaScript 代码这一事实具有明显的安全影响,浏览器供应商努力平衡两个竞争目标:
-
定义强大的客户端 API 以实现有用的 Web 应用程序
-
防止恶意代码读取或更改您的数据,危害您的隐私,欺诈您,或浪费您的时间
接下来的小节快速概述了您作为 JavaScript 程序员应该了解的安全限制和问题。
JavaScript 不能做什么
Web 浏览器对抗恶意代码的第一道防线是它们根本不支持某些功能。例如,客户端 JavaScript 不提供任何方法来写入或删除客户端计算机上的任意文件或列出任意目录。这意味着 JavaScript 程序无法删除数据或植入病毒。
同样,客户端 JavaScript 没有通用的网络功能。客户端 JavaScript 程序可以发出 HTTP 请求(§15.11.1)。另一个名为 WebSockets 的标准(§15.11.3)定义了一个类似套接字的 API,用于与专用服务器通信。但是这些 API 都不允许直接访问更广泛的网络。通用的互联网客户端和服务器不能使用客户端 JavaScript 编写。
同源策略
同源策略是对 JavaScript 代码可以与之交互的 Web 内容的广泛安全限制。当一个网页包含<iframe>元素时,通常会出现这种情况。在这种情况下,同源策略规定了一个框架中的 JavaScript 代码与其他框架内容的交互。具体来说,脚本只能读取与包含脚本的文档具有相同源的窗口和文档的属性。
文档的源被定义为文档加载的 URL 的协议、主机和端口。从不同 web 服务器加载的文档具有不同的源。通过同一主机的不同端口加载的文档具有不同的源。使用http:协议加载的文档与使用https:协议加载的文档具有不同的源,即使它们来自同一 web 服务器。浏览器通常将每个file: URL 视为单独的源,这意味着如果您正在开发一个显示来自同一服务器的多个文档的程序,您可能无法使用file: URL 在本地进行测试,而必须在开发过程中运行一个静态 web 服务器。
重要的是要理解脚本本身的源对同源策略不重要:重要的是脚本嵌入的文档的源。例如,假设由主机 A 托管的脚本被包含在由主机 B 提供的网页中(使用<script>元素的src属性)。该脚本的源是主机 B,并且脚本可以完全访问包含它的文档的内容。如果文档包含一个来自主机 B 的第二个文档的<iframe>,那么脚本也可以完全访问该第二个文档的内容。但是,如果顶级文档包含另一个显示来自主机 C(甚至来自主机 A)的文档的<iframe>,那么同源策略就会生效,并阻止脚本访问这个嵌套文档。
同源策略也适用于脚本化的 HTTP 请求(参见§15.11.1)。JavaScript 代码可以向包含文档所在的 web 服务器发出任意 HTTP 请求,但它不允许脚本与其他 web 服务器通信(除非这些 web 服务器通过 CORS 选择加入,我们将在下文描述)。
同源策略对使用多个子域的大型网站造成问题。例如,源自orders.example.com的脚本可能需要从example.com的文档中读取属性。为了支持这种多域网站,脚本可以通过将document.domain设置为域后缀来更改其源。因此,源自https://orders.example.com的脚本可以通过将document.domain设置为“example.com”来将其源更改为https://example.com。但是该脚本不能将document.domain设置为“orders.example”、“ample.com”或“com”。
放宽同源策略的第二种技术是跨域资源共享(CORS),它允许服务器决定愿意提供哪些来源。CORS 使用一个新的 Origin: 请求头和一个新的 Access-Control-Allow-Origin 响应头来扩展 HTTP。它允许服务器使用一个头来明确列出可以请求文件的来源,或者使用通配符允许任何站点请求文件。浏览器遵守这些 CORS 头,并且除非它们存在,否则不放宽同源限制。
跨站脚本
跨站脚本,或 XSS,是一种安全问题类别,攻击者向目标网站注入 HTML 标记或脚本。客户端 JavaScript 程序员必须意识到并防范跨站脚本。
如果网页动态生成文档内容并且基于用户提交的数据而不先通过“消毒”该数据来删除其中嵌入的 HTML 标记,则该网页容易受到跨站脚本攻击。作为一个简单的例子,考虑以下使用 JavaScript 通过名称向用户问候的网页:
<script>
let name = new URL(document.URL).searchParams.get("name");
document.querySelector('h1').innerHTML = "Hello " + name;
</script>
这个两行脚本从文档 URL 的“name”查询参数中提取输入。然后使用 DOM API 将 HTML 字符串注入到文档中的第一个 <h1> 标签中。此页面旨在通过以下 URL 调用:
http://www.example.com/greet.html?name=David
当像这样使用时,它会显示文本“Hello David。”但考虑一下当它被调用时会发生什么:
name=%3Cimg%20src=%22x.png%22%20onload=%22alert(%27hacked%27)%22/%3E
当 URL 转义参数被解码时,此 URL 导致以下 HTML 被注入到文档中:
Hello <img src="x.png" onload="alert('hacked')"/>
图像加载完成后,onload 属性中的 JavaScript 字符串将被执行。全局 alert() 函数会显示一个模态对话框。单个对话框相对无害,但表明在该网站上可能存在任意代码执行,因为它显示了未经过滤的 HTML。
跨站脚本攻击之所以被称为如此,是因为涉及到多个站点。站点 B 包含一个特制链接(就像前面示例中的那个)到站点 A。如果站点 B 能说服用户点击该链接,他们将被带到站点 A,但该站点现在将运行来自站点 B 的代码。该代码可能破坏页面或导致其功能失效。更危险的是,恶意代码可能读取站点 A 存储的 cookie(也许是账号号码或其他个人身份信息)并将数据发送回站点 B。注入的代码甚至可以跟踪用户的按键操作并将数据发送回站点 B。
通常,防止 XSS 攻击的方法是在使用未受信任的数据创建动态文档内容之前,从中删除 HTML 标记。你可以通过用等效的 HTML 实体替换未受信任输入字符串中的特殊 HTML 字符来修复之前显示的 greet.html 文件:
name = name
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/\//g, "/")
解决 XSS 问题的另一种方法是构建您的 Web 应用程序,使得不受信任的内容始终显示在具有设置为禁用脚本和其他功能的 sandbox 属性的 <iframe> 中。
跨站脚本是一种根深蒂固的漏洞,其根源深入到网络架构中。值得深入了解这种漏洞,但进一步讨论超出了本书的范围。有许多在线资源可帮助您防范跨站脚本。
15.2 事件
客户端 JavaScript 程序使用异步事件驱动的编程模型。在这种编程风格中,当文档或浏览器或与之关联的某个元素或对象发生有趣的事情时,Web 浏览器会生成一个事件。例如,当 Web 浏览器完成加载文档时,当用户将鼠标移动到超链接上时,或者当用户在键盘上按下键时,Web 浏览器会生成一个事件。如果 JavaScript 应用程序关心特定类型的事件,它可以注册一个或多个函数,在发生该类型的事件时调用这些函数。请注意,这并不是 Web 编程的独有特性:所有具有图形用户界面的应用程序都是这样设计的——它们等待与之交互(即,它们等待事件发生),然后做出响应。
在客户端 JavaScript 中,事件可以发生在 HTML 文档中的任何元素上,这一事实使得 Web 浏览器的事件模型比 Node 的事件模型复杂得多。我们从一些重要的定义开始,这些定义有助于解释事件模型:
事件类型
此字符串指定发生的事件类型。例如,“mousemove”类型表示用户移动了鼠标。“keydown”类型表示用户按下键盘上的键。而“load”类型表示文档(或其他资源)已经从网络加载完成。由于事件类型只是一个字符串,有时被称为事件名称,确实,我们使用这个名称来识别我们所讨论的事件类型。
事件目标
这是事件发生的对象或与之相关联的对象。当我们谈论事件时,必须同时指定类型和目标。例如,窗口上的加载事件,或<button>元素上的点击事件。窗口、文档和元素对象是客户端 JavaScript 应用程序中最常见的事件目标,但有些事件会在其他类型的对象上触发。例如,Worker 对象(一种线程,在§15.13 中介绍)是“message”事件的目标,当工作线程向主线程发送消息时会触发该事件。
事件处理程序,或事件监听器
此函数处理或响应事件。² 应用程序通过指定事件类型和事件目标向 Web 浏览器注册其事件处理程序函数。当指定类型的事件发生在指定目标上时,浏览器会调用处理程序函数。当为对象调用事件处理程序时,我们说浏览器已经“触发”、“触发”或“分发”了事件。有多种注册事件处理程序的方法,处理程序注册和调用的详细信息在§15.2.2 和§15.2.3 中有解释。
事件对象
此对象与特定事件相关联,并包含有关该事件的详细信息。事件对象作为参数传递给事件处理程序函数。所有事件对象都有一个type属性,指定事件类型,以及一个target属性,指定事件目标。每种事件类型为其关联的事件对象定义了一组属性。与鼠标事件相关联的对象包括鼠标指针的坐标,例如,与键盘事件相关联的对象包含有关按下的键和按下的修改键的详细信息。许多事件类型仅定义了一些标准属性,如type和target,并不包含其他有用信息。对于这些事件,事件的简单发生才是重要的,而不是事件的详细信息。
事件传播
这是浏览器决定触发事件处理程序的对象的过程。对于特定于单个对象的事件(例如 Window 对象上的“load”事件或 Worker 对象上的“message”事件),不需要传播。但是,对于发生在 HTML 文档中的元素上的某些类型的事件,它们会传播或“冒泡”到文档树上。如果用户将鼠标移动到超链接上,那么 mousemove 事件首先在定义该链接的<a>元素上触发。然后在包含元素上触发:可能是一个<p>元素,一个<section>元素,以及文档对象本身。有时,在文档或其他容器元素上注册一个事件处理程序比在每个感兴趣的单个元素上注册处理程序更方便。事件处理程序可以阻止事件的传播,使其不会继续冒泡并且不会触发包含元素上的处理程序。处理程序通过调用事件对象的方法来执行此操作。在另一种事件传播形式中,称为事件捕获,在容器元素上特别注册的处理程序有机会在事件传递到其实际目标之前拦截(或“捕获”)事件。事件冒泡和捕获在§15.2.4 中有详细介绍。
一些事件与默认操作相关联。例如,当单击超链接时,浏览器的默认操作是跟随链接并加载新页面。事件处理程序可以通过调用事件对象的方法来阻止此默认操作。这有时被称为“取消”事件,并在§15.2.5 中有介绍。
15.2.1 事件类别
客户端 JavaScript 支持如此多的事件类型,以至于本章无法涵盖所有事件。然而,将事件分组到一些一般类别中可能是有用的,以说明支持的事件范围和各种各样的事件:
与设备相关的输入事件
这些事件与特定的输入设备直接相关,例如鼠标或键盘。它们包括“mousedown”,“mousemove”,“mouseup”,“touchstart”,“touchmove”,“touchend”,“keydown”和“keyup”等事件类型。
与设备无关的输入事件
这些输入事件与特定的输入设备没有直接关联。例如,“click”事件表示链接或按钮(或其他文档元素)已被激活。通常是通过鼠标点击完成,但也可以通过键盘或(在触摸设备上)通过轻触完成。 “input”事件是“keydown”事件的与设备无关的替代品,并支持键盘输入以及剪切和粘贴以及用于表意文字的输入方法等替代方法。 “pointerdown”,“pointermove”和“pointerup”事件类型是鼠标和触摸事件的与设备无关的替代品。它们适用于鼠标类型指针,触摸屏幕以及笔或笔式输入。
用户界面事件
UI 事件是更高级别的事件,通常在 HTML 表单元素上定义 Web 应用程序的用户界面。它们包括“focus”事件(当文本输入字段获得键盘焦点时),“change”事件(当用户更改表单元素显示的值时)和“submit”事件(当用户单击表单中的提交按钮时)。
状态更改事件
一些事件不是直接由用户活动触发的,而是由网络或浏览器活动触发的,并指示某种生命周期或状态相关的变化。“load”和“DOMContentLoaded”事件分别在文档加载结束时在 Window 和 Document 对象上触发,可能是最常用的这些事件(参见“客户端 JavaScript 时间线”)。浏览器在网络连接状态发生变化时在 Window 对象上触发“online”和“offline”事件。浏览器的历史管理机制(§15.10.4)在响应浏览器的后退按钮时触发“popstate”事件。
特定于 API 的事件
HTML 和相关规范定义的许多 Web API 包括它们自己的事件类型。HTML <video> 和 <audio> 元素定义了一长串相关事件类型,如“waiting”、“playing”、“seeking”、“volumechange”等,您可以使用它们来自定义媒体播放。一般来说,异步的 Web 平台 API 在 JavaScript 添加 Promise 之前是基于事件的,并定义了特定于 API 的事件。例如,IndexedDB API(§15.12.3)在数据库请求成功或失败时触发“success”和“error”事件。虽然用于发出 HTTP 请求的新 fetch() API(§15.11.1)是基于 Promise 的,但它替代的 XMLHttpRequest API 定义了许多特定于 API 的事件类型。
注册事件处理程序
注册事件处理程序有两种基本方法。第一种是来自 Web 早期的,在事件目标上设置对象或文档元素的属性。第二种(更新且更通用)技术是将处理程序传递给对象或元素的 addEventListener() 方法。
设置事件处理程序属性
注册事件处理程序的最简单方法是将事件目标的属性设置为所需的事件处理程序函数。按照惯例,事件处理程序属性的名称由单词“on”后跟事件名称组成:onclick、onchange、onload、onmouseover等。请注意,这些属性名称区分大小写,并且全部小写书写,即使事件类型(如“mousedown”)由多个单词组成。以下代码包括两种此类事件处理程序的注册:
// Set the onload property of the Window object to a function.
// The function is the event handler: it is invoked when the document loads.
window.onload = function() {
// Look up a <form> element
let form = document.querySelector("form#shipping");
// Register an event handler function on the form that will be invoked
// before the form is submitted. Assume isFormValid() is defined elsewhere.
form.onsubmit = function(event) { // When the user submits the form
if (!isFormValid(this)) { // check whether form inputs are valid
event.preventDefault(); // and if not, prevent form submission.
}
};
};
事件处理程序属性的缺点在于,它们设计时假设事件目标最多只有一个每种事件类型的处理程序。通常最好使用 addEventListener() 注册事件处理程序,因为该技术不会覆盖任何先前注册的处理程序。
设置事件处理程序属性
文档元素的事件处理程序属性也可以直接在 HTML 文件中作为相应 HTML 标记的属性定义。在 HTML 中,可以使用在 <body> 标记上的属性定义应该在 JavaScript 中注册在 Window 元素上的处理程序。尽管这种技术在现代 Web 开发中通常不受欢迎,但它是可能的,并且在此处记录,因为您可能仍然在现有代码中看到它。
当将事件处理程序定义为 HTML 属性时,属性值应为 JavaScript 代码的字符串。该代码应为事件处理程序函数的主体,而不是完整的函数声明。换句话说,您的 HTML 事件处理程序代码不应被大括号包围并以 function 关键字为前缀。例如:
<button onclick="console.log('Thank you');">Please Click</button>
如果 HTML 事件处理程序属性包含多个 JavaScript 语句,则必须记住使用分号分隔这些语句或将属性值跨多行断开。
当您将 JavaScript 代码的字符串指定为 HTML 事件处理程序属性的值时,浏览器会将您的字符串转换为一个类似于这个函数的函数:
function(event) {
with(document) {
with(this.form || {}) {
with(this) {
/* your code here */
}
}
}
}
event参数意味着您的处理程序代码可以将当前事件对象称为event。with语句意味着您的处理程序代码可以直接引用目标对象、包含的<form>(如果有)和包含的文档对象的属性,就像它们是作用域中的变量一样。with语句在严格模式下是禁止的(§5.6.3),但是 HTML 属性中的 JavaScript 代码永远不会是严格模式。以这种方式定义的事件处理程序在定义了意外变量的环境中执行。这可能是令人困惑的错误源,是避免在 HTML 中编写事件处理程序的一个很好的理由。
addEventListener()
任何可以成为事件目标的对象——包括 Window 和 Document 对象以及所有文档元素——都定义了一个名为addEventListener()的方法,您可以使用该方法为该目标注册事件处理程序。addEventListener()接受三个参数。第一个是要注册处理程序的事件类型。事件类型(或名称)是一个字符串,不包括在设置事件处理程序属性时使用的“on”前缀。addEventListener()的第二个参数是应在发生指定类型事件时调用的函数。第三个参数是可选的,下面会解释。
以下代码为<button>元素注册了两个“click”事件处理程序。请注意两种技术之间的区别:
<button id="mybutton">Click me</button>
<script>
let b = document.querySelector("#mybutton");
b.onclick = function() { console.log("Thanks for clicking me!"); };
b.addEventListener("click", () => { console.log("Thanks again!"); });
</script>
调用addEventListener()时,第一个参数为“click”不会影响onclick属性的值。在此代码中,单击按钮将向开发者控制台记录两条消息。如果我们先调用addEventListener()然后设置onclick,我们仍然会记录两条消息,只是顺序相反。更重要的是,您可以多次调用addEventListener()为同一对象的同一事件类型注册多个处理程序函数。当对象上发生事件时,为该类型事件注册的所有处理程序按照注册顺序被调用。在同一对象上多次调用具有相同参数的addEventListener()不会产生任何效果——处理程序函数仅注册一次,并且重复调用不会改变调用处理程序的顺序。
addEventListener()与removeEventListener()方法配对使用,它期望相同的两个参数(加上可选的第三个参数),但是从对象中删除事件处理程序函数而不是添加它。通常有用的是暂时注册事件处理程序,然后不久之后将其删除。例如,当您获得“mousedown”事件时,您可能会为“mousemove”和“mouseup”事件注册临时事件处理程序,以便查看用户是否拖动鼠标。然后,当“mouseup”事件到达时,您将取消注册这些处理程序。在这种情况下,您的事件处理程序移除代码可能如下所示:
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
addEventListener()的可选第三个参数是布尔值或对象。如果传递true,则您的处理程序函数将被注册为捕获事件处理程序,并在事件分发的不同阶段被调用。我们将在§15.2.4 中介绍事件捕获。如果在注册事件监听器时传递第三个参数为true,那么如果要删除处理程序,则必须在removeEventListener()的第三个参数中也传递true。
注册捕获事件处理程序只是addEventListener()支持的三个选项之一,而不是传递单个布尔值,您还可以传递一个明确指定所需选项的对象:
document.addEventListener("click", handleClick, {
capture: true,
once: true,
passive: true
});
如果 Options 对象的capture属性设置为true,那么事件处理程序将被注册为捕获处理程序。如果该属性为false或被省略,则处理程序将为非捕获。
如果 Options 对象的once属性设置为true,则事件侦听器将在触发一次后自动删除。如果此属性为false或省略,则处理程序永远不会自动删除。
如果 Options 对象的passive属性设置为true,则表示事件处理程序永远不会调用preventDefault()来取消默认操作(参见§15.2.5)。这对于移动设备上的触摸事件尤为重要 - 如果“touchmove”事件的事件处理程序可以阻止浏览器的默认滚动操作,那么浏览器无法实现平滑滚动。这个passive属性提供了一种注册这种潜在干扰性事件处理程序的方法,但让 Web 浏览器知道它可以安全地开始其默认行为 - 例如滚动 - 而事件处理程序正在运行。平滑滚动对于良好的用户体验非常重要,因此 Firefox 和 Chrome 默认将“touchmove”和“mousewheel”事件设置为被动。因此,如果您确实想要注册一个调用preventDefault()的处理程序来处理这些事件中的一个,那么应明确将passive属性设置为false。
您还可以向removeEventListener()传递一个 Options 对象,但capture属性是唯一相关的属性。在移除侦听器时,无需指定once或passive,这些属性将被忽略。
15.2.3 事件处理程序调用
一旦注册了事件处理程序,当指定类型的事件发生在指定对象上时,Web 浏览器将自动调用它。本节详细描述了事件处理程序的调用,解释了事件处理程序参数、调用上下文(this值)以及事件处理程序的返回值的含义。
事件处理程序参数
事件处理程序以一个 Event 对象作为它们的唯一参数调用。Event 对象的属性提供有关事件的详细信息:
type
发生的事件类型。
target
事件发生的对象。
currentTarget
事件传播时,此属性是当前事件处理程序注册的对象。
timeStamp
代表事件发生时间的时间戳(以毫秒为单位),但不代表绝对时间。您可以通过从第一个事件的时间戳中减去第二个事件的时间戳来确定两个事件之间的经过时间。
isTrusted
如果事件是由 Web 浏览器本身分派的,则此属性将为true,如果事件是由 JavaScript 代码分派的,则此属性将为false。
特定类型的事件具有额外的属性。例如,鼠标和指针事件具有clientX和clientY属性,指定事件发生时的窗口坐标。
事件处理程序上下文
当您通过设置属性注册事件处理程序时,看起来就像您正在为目标对象定义一个新方法:
target.onclick = function() { /* handler code */ };
因此,不足为奇,事件处理程序作为定义它们的对象的方法调用。也就是说,在事件处理程序的主体内,this关键字指的是注册事件处理程序的对象。
处理程序以目标作为它们的this值调用,即使使用addEventListener()注册。但是对于定义为箭头函数的处理程序,这种方式不起作用:箭头函数始终具有与其定义的作用域相同的this值。
处理程序返回值
在现代 JavaScript 中,事件处理程序不应返回任何内容。您可能会在旧代码中看到返回值的事件处理程序,返回值通常是向浏览器发出信号,告诉它不要执行与事件关联的默认操作。例如,如果表单中的提交按钮的onclick处理程序返回false,那么 Web 浏览器将不会提交表单(通常是因为事件处理程序确定用户的输入未通过客户端验证)。
阻止浏览器执行默认操作的标准和首选方法是在事件对象上调用preventDefault()方法(§15.2.5)。
调用顺序
事件目标可能对特定类型的事件注册了多个事件处理程序。当发生该类型的事件时,浏览器按照注册的顺序调用所有处理程序。有趣的是,即使您混合使用addEventListener()注册的事件处理程序和在对象属性上注册的事件处理程序(如onclick),这也是正确的。
15.2.4 事件传播
当事件的目标是 Window 对象或其他独立对象时,浏览器只需调用该对象上的适当处理程序来响应事件。但是,当事件目标是 Document 或文档元素时,情况就更加复杂。
在调用目标元素上注册的事件处理程序后,大多数事件会在 DOM 树中“冒泡”。将调用目标父级的事件处理程序。然后调用目标祖父级上注册的处理程序。这将一直持续到 Document 对象,然后继续到 Window 对象。事件冒泡提供了一种替代方法,可以在共同祖先元素上注册单个处理程序,并在那里处理事件,而不是在许多单独的文档元素上注册处理程序。例如,您可以在<form>元素上注册一个“change”处理程序,而不是为表单中的每个元素注册“change”处理程序。
大多数发生在文档元素上的事件都会冒泡。值得注意的例外是“focus”、“blur”和“scroll”事件。文档元素上的“load”事件会冒泡,但在 Document 对象处停止冒泡,不会传播到 Window 对象上。(仅当整个文档加载完成时,Window 对象的“load”事件处理程序才会被触发。)
事件冒泡是事件传播的第三个“阶段”。目标对象本身的事件处理程序的调用是第二阶段。第一阶段,甚至在调用目标处理程序之前发生,称为“捕获”阶段。请记住,addEventListener()接受一个可选的第三个参数。如果该参数为true或{capture:true},则事件处理程序将被注册为捕获事件处理程序,在事件传播的第一阶段调用。事件传播的捕获阶段类似于反向的冒泡阶段。首先调用 Window 对象的捕获处理程序,然后调用 Document 对象的捕获处理程序,然后是 body 对象,依此类推,直到调用事件目标的父级的捕获事件处理程序。在事件目标本身上注册的捕获事件处理程序不会被调用。
事件捕获提供了一个机会,在事件传递到目标之前查看事件。捕获事件处理程序可用于调试,或者可以与下一节描述的事件取消技术一起使用,以过滤事件,从而永远不会实际调用目标事件处理程序。事件捕获的一个常见用途是处理鼠标拖动,其中需要由被拖动的对象处理鼠标移动事件,而不是文档元素。
15.2.5 事件取消
浏览器会响应许多用户事件,即使您的代码没有:当用户在超链接上单击鼠标时,浏览器会跟随链接。如果 HTML 文本输入元素具有键盘焦点并且用户键入键,则浏览器将输入用户的输入。如果用户在触摸屏设备上移动手指,则浏览器会滚动。如果您为此类事件注册了事件处理程序,可以通过调用事件对象的preventDefault()方法来阻止浏览器执行其默认操作。(除非您使用passive选项注册了处理程序,这会使preventDefault()无效。)
取消与事件关联的默认操作只是一种事件取消的方式。我们还可以通过调用事件对象的stopPropagation()方法来取消事件的传播。如果在同一对象上定义了其他处理程序,则其余处理程序仍将被调用,但在调用stopPropagation()后不会调用任何其他对象上的事件处理程序。stopPropagation()在捕获阶段、事件目标本身以及冒泡阶段起作用。stopImmediatePropagation()的工作方式类似于stopPropagation(),但它还阻止调用在同一对象上注册的任何后续事件处理程序。
15.2.6 分派自定义事件
客户端 JavaScript 的事件 API 是一个相对强大的 API,您可以使用它来定义和分派自己的事件。例如,假设您的程序需要定期执行长时间计算或进行网络请求,并且在此操作挂起期间,其他操作是不可能的。您希望通过显示“旋转器”来告知用户应用程序正在忙碌。但是忙碌的模块不需要知道旋转器应该显示在哪里。相反,该模块可能只需分派一个事件来宣布它正在忙碌,然后在不再忙碌时再分派另一个事件。然后,UI 模块可以为这些事件注册事件处理程序,并采取适当的 UI 操作来通知用户。
如果一个 JavaScript 对象有一个addEventListener()方法,那么它是一个“事件目标”,这意味着它也有一个dispatchEvent()方法。您可以使用CustomEvent()构造函数创建自己的事件对象,并将其传递给dispatchEvent()。CustomEvent()的第一个参数是一个字符串,指定您的事件类型,第二个参数是一个指定事件对象属性的对象。将此对象的detail属性设置为表示事件内容的字符串、对象或其他值。如果计划在文档元素上分派事件并希望它冒泡到文档树,将bubbles:true添加到第二个参数中:
// Dispatch a custom event so the UI knows we are busy
document.dispatchEvent(new CustomEvent("busy", { detail: true }));
// Perform a network operation
fetch(url)
.then(handleNetworkResponse)
.catch(handleNetworkError)
.finally(() => {
// After the network request has succeeded or failed, dispatch
// another event to let the UI know that we are no longer busy.
document.dispatchEvent(new CustomEvent("busy", { detail: false }));
});
// Elsewhere, in your program you can register a handler for "busy" events
// and use it to show or hide the spinner to let the user know.
document.addEventListener("busy", (e) => {
if (e.detail) {
showSpinner();
} else {
hideSpinner();
}
});
15.3 脚本化文档
客户端 JavaScript 存在的目的是将静态 HTML 文档转换为交互式 Web 应用程序。因此,脚本化 Web 页面的内容确实是 JavaScript 的核心目的。
每个 Window 对象都有一个指向 Document 对象的document属性。Document 对象代表窗口的内容,本节的主题就是它。然而,Document 对象并不是独立存在的。它是 DOM 中用于表示和操作文档内容的中心对象。
DOM 是在§15.1.2 中介绍的。本节详细解释了 API。它涵盖了:
-
如何从文档中查询或选择单个元素。
-
如何遍历文档,以及如何找到任何文档元素的祖先、同级和后代。
-
如何查询和设置文档元素的属性。
-
如何查询、设置和修改文档的内容。
-
如何通过创建、插入和删除节点来修改文档的结构。
15.3.1 选择文档元素
客户端 JavaScript 程序经常需要操作文档中的一个或多个元素。全局的document属性指向 Document 对象,而 Document 对象有head和body属性,分别指向<head>和<body>标签的 Element 对象。但是,想要操作文档中嵌套更深的元素的程序必须以某种方式获取或选择指向这些文档元素的 Element 对象。
使用 CSS 选择器选择元素
CSS 样式表具有非常强大的语法,称为选择器,用于描述文档中的元素或元素集。DOM 方法querySelector()和querySelectorAll()允许我们查找与指定 CSS 选择器匹配的文档中的元素或元素。在介绍这些方法之前,我们将从快速教程开始,介绍 CSS 选择器语法。
CSS 选择器可以根据标签名、它们的id属性的值或它们的class属性中的单词描述元素:
div // Any <div> element
#nav // The element with id="nav"
.warning // Any element with "warning" in its class attribute
#字符用于基于id属性匹配,.字符用于基于class属性匹配。也可以根据更一般的属性值选择元素:
p[lang="fr"] // A paragraph written in French: <p lang="fr">
*[name="x"] // Any element with a name="x" attribute
请注意,这些示例将标签名选择器(或*标签名通配符)与属性选择器结合使用。还可以使用更复杂的组合:
span.fatal.error // Any <span> with "fatal" and "error" in its class
span[lang="fr"].warning // Any <span> in French with class "warning"
选择器还可以指定文档结构:
#log span // Any <span> descendant of the element with id="log"
#log>span // Any <span> child of the element with id="log"
body>h1:first-child // The first <h1> child of the <body>
img + p.caption // A <p> with class "caption" immediately after an <img>
h2 ~ p // Any <p> that follows an <h2> and is a sibling of it
如果两个选择器用逗号分隔,这意味着我们选择了匹配任一选择器的元素:
button, input[type="button"] // All <button> and <input type="button"> elements
正如您所看到的,CSS 选择器允许我们通过类型、ID、类、属性和文档中的位置引用文档中的元素。querySelector()方法将 CSS 选择器字符串作为其参数,并返回在文档中找到的第一个匹配元素,如果没有匹配项,则返回null:
// Find the document element for the HTML tag with attribute id="spinner"
let spinner = document.querySelector("#spinner");
querySelectorAll()类似,但它返回文档中所有匹配的元素,而不仅仅返回第一个:
// Find all Element objects for <h1>, <h2>, and <h3> tags
let titles = document.querySelectorAll("h1, h2, h3");
querySelectorAll()的返回值不是 Element 对象的数组。相反,它是一种称为 NodeList 的类似数组的对象。NodeList 对象具有length属性,并且可以像数组一样进行索引,因此您可以使用传统的for循环对它们进行循环。NodeLists 也是可迭代的,因此您也可以将它们与for/of循环一起使用。如果要将 NodeList 转换为真正的数组,只需将其传递给Array.from()。
querySelectorAll()返回的 NodeList 如果文档中没有任何匹配的元素,则length属性将设置为 0。
querySelector()和querySelectorAll()也由 Element 类和 Document 类实现。当在元素上调用这些方法时,它们只会返回该元素的后代元素。
请注意,CSS 定义了::first-line和::first-letter伪元素。在 CSS 中,这些匹配文本节点的部分而不是实际元素。如果与querySelectorAll()或querySelector()一起使用,它们将不匹配。此外,许多浏览器将拒绝返回:link和:visited伪类的匹配项,因为这可能会暴露用户的浏览历史信息。
另一种基于 CSS 的元素选择方法是closest()。该方法由 Element 类定义,以选择器作为其唯一参数。如果选择器与调用它的元素匹配,则返回该元素。否则,返回选择器匹配的最近祖先元素,如果没有匹配项,则返回null。在某种意义上,closest()是querySelector()的相反:closest()从一个元素开始,并在树中查找匹配项,而querySelector()从一个元素开始,并在树中查找匹配项。当您在文档树的高级别注册事件处理程序时,closest()可能很有用。例如,如果您处理“click”事件,您可能想知道它是否是单击超链接。事件对象将告诉您目标是什么,但该目标可能是链接内部的文本而不是超链接的<a>标签本身。您的事件处理程序可以这样查找最近的包含超链接:
// Find the closest enclosing <a> tag that has an href attribute.
let hyperlink = event.target.closest("a[href]");
这是您可能使用closest()的另一种方式:
// Return true if the element e is inside of an HTML list element
function insideList(e) {
return e.closest("ul,ol,dl") !== null;
}
相关方法matches()不返回祖先或后代:它只是测试一个元素是否被 CSS 选择器匹配,并在是这样时返回true,否则返回false:
// Return true if e is an HTML heading element
function isHeading(e) {
return e.matches("h1,h2,h3,h4,h5,h6");
}
其他元素选择方法
除了querySelector()和querySelectorAll(),DOM 还定义了一些更或多或少已经过时的元素选择方法。你可能仍然会看到一些这些方法(尤其是getElementById())在使用中,然而:
// Look up an element by id. The argument is just the id, without
// the CSS selector prefix #. Similar to document.querySelector("#sect1")
let sect1 = document.getElementById("sect1");
// Look up all elements (such as form checkboxes) that have a name="color"
// attribute. Similar to document.querySelectorAll('*[name="color"]');
let colors = document.getElementsByName("color");
// Look up all <h1> elements in the document.
// Similar to document.querySelectorAll("h1")
let headings = document.getElementsByTagName("h1");
// getElementsByTagName() is also defined on elements.
// Get all <h2> elements within the sect1 element.
let subheads = sect1.getElementsByTagName("h2");
// Look up all elements that have class "tooltip."
// Similar to document.querySelectorAll(".tooltip")
let tooltips = document.getElementsByClassName("tooltip");
// Look up all descendants of sect1 that have class "sidebar"
// Similar to sect1.querySelectorAll(".sidebar")
let sidebars = sect1.getElementsByClassName("sidebar");
像querySelectorAll()一样,这段代码中的方法返回一个 NodeList(除了getElementById(),它返回一个单个的 Element 对象)。然而,与querySelectorAll()不同,这些旧的选择方法返回的 NodeList 是“活动的”,这意味着如果文档内容或结构发生变化,列表的长度和内容也会发生变化。
预选元素
由于历史原因,Document 类定义了一些快捷属性来访问某些类型的节点。例如,images、forms和links属性提供了对文档中<img>、<form>和<a>元素(但只有具有href属性的<a>标签)的简单访问。这些属性指的是 HTMLCollection 对象,它们很像 NodeList 对象,但可以通过元素 ID 或名称进行索引。例如,通过document.forms属性,你可以访问<form id="address">标签:
document.forms.address;
一个更过时的用于选择元素的 API 是document.all属性,它类似于文档中所有元素的 HTMLCollection。document.all已被弃用,你不应该再使用它。
15.3.2 文档结构和遍历
一旦你从文档中选择了一个元素,有时候你需要找到文档的结构相关部分(父元素、兄弟元素、子元素)。当我们主要关注文档中的元素而不是其中的文本(以及文本之间的空白,这也是文本),有一个遍历 API 允许我们将文档视为元素对象树,忽略文档中也包含的文本节点。这个遍历 API 不涉及任何方法;它只是一组元素对象上的属性,允许我们引用给定元素的父元素、子元素和兄弟元素:
parentNode
这个元素的属性指的是元素的父元素,它将是另一个元素或一个文档对象。
children
这个 NodeList 包含一个元素的元素子节点,但不包括非元素子节点,比如文本节点(和注释节点)。
childElementCount
元素子节点的数量。返回与children.length相同的值。
firstElementChild, lastElementChild
这些属性指的是一个元素的第一个和最后一个元素子节点。如果元素没有元素子节点,则它们为null。
nextElementSibling, previousElementSibling
这些属性指的是元素的前一个或后一个兄弟元素,如果没有这样的兄弟元素则为null。
使用这些元素属性,文档的第一个子元素的第二个子元素可以用以下任一表达式引用:
document.children[0].children[1]
document.firstElementChild.firstElementChild.nextElementSibling
(在标准的 HTML 文档中,这两个表达式都指的是文档的<body>标签。)
这里有两个函数,演示了如何使用这些属性递归地对文档进行深度优先遍历,对文档中的每个元素调用指定的函数:
// Recursively traverse the Document or Element e, invoking the function
// f on e and on each of its descendants
function traverse(e, f) {
f(e); // Invoke f() on e
for(let child of e.children) { // Iterate over the children
traverse(child, f); // And recurse on each one
}
}
function traverse2(e, f) {
f(e); // Invoke f() on e
let child = e.firstElementChild; // Iterate the children linked-list style
while(child !== null) {
traverse2(child, f); // And recurse
child = child.nextElementSibling;
}
}
以节点树的形式的文档
如果你想遍历文档或文档的某个部分,并且不想忽略文本节点,你可以使用所有 Node 对象上定义的另一组属性。这将允许你看到元素、文本节点,甚至注释节点(代表文档中的 HTML 注释)。
所有 Node 对象定义以下属性:
parentNode
这个节点的父节点,对于没有父节点的节点来说为null。
childNodes
一个只读的 NodeList,包含节点的所有子节点(不仅仅是元素子节点)。
firstChild, lastChild
一个节点的第一个和最后一个子节点,或者如果节点没有子节点则为null。
nextSibling, previousSibling
节点的下一个和上一个兄弟节点。这些属性将节点连接成一个双向链表。
nodeType
一个指定节点类型的数字。文档节点的值为 9。元素节点的值为 1。文本节点的值为 3。注释节点的值为 8。
nodeValue
Text 或 Comment 节点的文本内容。
nodeName
Element 的 HTML 标签名,转换为大写。
使用这些 Node 属性,可以使用以下表达式引用文档的第一个子节点的第二个子节点:
document.childNodes[0].childNodes[1]
document.firstChild.firstChild.nextSibling
假设所讨论的文档如下:
<html><head><title>Test</title></head><body>Hello World!</body></html>
然后,第一个子节点的第二个子节点是<body>元素。它的nodeType为 1,nodeName为“BODY”。
但是,请注意,此 API 对文档文本的变化非常敏感。例如,如果在<html>和<head>标签之间插入一个换行符修改了文档,那么表示该换行符的 Text 节点将成为第一个子节点的第一个子节点,第二个子节点将是<head>元素,而不是<body>元素。
为了演示基于 Node 的遍历 API,这里是一个返回元素或文档中所有文本的函数:
// Return the plain-text content of element e, recursing into child elements.
// This method works like the textContent property
function textContent(e) {
let s = ""; // Accumulate the text here
for(let child = e.firstChild; child !== null; child = child.nextSibling) {
let type = child.nodeType;
if (type === 3) { // If it is a Text node
s += child.nodeValue; // add the text content to our string.
} else if (type === 1) { // And if it is an Element node
s += textContent(child); // then recurse.
}
}
return s;
}
此函数仅用于演示—在实践中,您只需编写e.textContent即可获取元素e的文本内容。
15.3.3 属性
HTML 元素由标签名和一组称为属性的名称/值对组成。例如,定义超链接的<a>元素使用其href属性的值作为链接的目的地。
Element 类定义了用于查询、设置、测试和删除元素属性的通用getAttribute()、setAttribute()、hasAttribute()和removeAttribute()方法。但是 HTML 元素的属性值(对于所有标准 HTML 元素的标准属性)作为表示这些元素的 HTMLElement 对象的属性可用,并且通常更容易作为 JavaScript 属性处理,而不是调用getAttribute()和相关方法。
HTML 属性作为元素属性
表示 HTML 文档元素的 Element 对象通常定义了反映元素 HTML 属性的读/写属性。Element 定义了通用 HTML 属性的属性,如id、title、lang和dir,以及像onclick这样的事件处理程序属性。特定于元素的子类型定义了特定于这些元素的属性。例如,要查询图像的 URL,可以使用表示<img>元素的 HTMLElement 的src属性:
let image = document.querySelector("#main_image");
let url = image.src; // The src attribute is the URL of the image
image.id === "main_image" // => true; we looked up the image by id
同样地,你可以使用以下代码设置<form>元素的表单提交属性:
let f = document.querySelector("form"); // First <form> in the document
f.action = "https://www.example.com/submit"; // Set the URL to submit it to.
f.method = "POST"; // Set the HTTP request type.
对于一些元素,例如<input>元素,一些 HTML 属性名称映射到不同命名的属性。例如,<input>的 HTML value属性在 JavaScript 中由defaultValue属性镜像。<input>元素的 JavaScript value属性包含用户当前的输入,但对value属性的更改不会影响defaultValue属性或value属性。
HTML 属性不区分大小写,但 JavaScript 属性名称区分大小写。要将属性名称转换为 JavaScript 属性,将其写成小写。但是,如果属性超过一个单词,将第一个单词后的每个单词的第一个字母大写:例如,defaultChecked和tabIndex。但是,事件处理程序属性如onclick是一个例外,它们以小写形式编写。
一些 HTML 属性名称在 JavaScript 中是保留字。对于这些属性,一般规则是在属性名称前加上“html”。例如,HTML <label>元素的for属性变为 JavaScript 的htmlFor属性。class是 JavaScript 中的保留字,而非常重要的 HTML class属性是规则的例外:在 JavaScript 代码中变为className。
代表 HTML 属性的属性通常具有字符串值。但是,当属性是布尔值或数字值(例如 <input> 元素的 defaultChecked 和 maxLength 属性)时,属性是布尔值或数字,而不是字符串。事件处理程序属性始终具有函数(或 null)作为它们的值。
请注意,用于获取和设置属性值的基于属性的 API 不定义任何删除元素属性的方法。特别是,delete 运算符不能用于此目的。如果需要删除属性,请使用 removeAttribute() 方法。
class 属性
HTML 元素的 class 属性是一个特别重要的属性。它的值是一个空格分隔的 CSS 类列表,适用于元素并影响其在 CSS 中的样式。由于 class 在 JavaScript 中是一个保留字,因此此属性的值可以通过 Element 对象上的 className 属性获得。className 属性可以设置和返回 class 属性的值作为字符串。但是 class 属性的命名不太合适:它的值是 CSS 类的列表,而不是单个类,通常在客户端 JavaScript 编程中,希望从此列表中添加和删除单个类名,而不是将列表作为单个字符串处理。
因此,Element 对象定义了一个 classList 属性,允许您将 class 属性视为列表。classList 属性的值是一个可迭代的类似数组的对象。尽管属性的名称是 classList,但它更像是一组类,并定义了 add()、remove()、contains() 和 toggle() 方法:
// When we want to let the user know that we are busy, we display
// a spinner. To do this we have to remove the "hidden" class and add the
// "animated" class (assuming the stylesheets are configured correctly).
let spinner = document.querySelector("#spinner");
spinner.classList.remove("hidden");
spinner.classList.add("animated");
数据集属性
有时,在 HTML 元素上附加额外信息是有用的,通常是当 JavaScript 代码将选择这些元素并以某种方式操作它们时。在 HTML 中,任何名称为小写并以前缀“data-”开头的属性都被视为有效,您可以将它们用于任何目的。这些“数据集属性”不会影响它们所在元素的呈现,并且它们定义了一种标准的方法来附加额外数据,而不会影响文档的有效性。
在 DOM 中,Element 对象具有一个 dataset 属性,指向一个对象,该对象具有与其前缀去除的 data- 属性对应的属性。因此,dataset.x 将保存 data-x 属性的值。连字符属性映射到驼峰命名属性名称:属性 data-section-number 变为属性 dataset.sectionNumber。
假设一个 HTML 文档包含以下文本:
<h2 id="title" data-section-number="16.1">Attributes</h2>
然后,您可以编写如下 JavaScript 代码来访问该部分编号:
let number = document.querySelector("#title").dataset.sectionNumber;
15.3.4 元素内容
再次查看 图 15-1 中显示的文档树,并问问自己 <p> 元素的“内容”是什么。我们可能以两种方式回答这个问题:
-
内容是 HTML 字符串“This is a simple document”。
-
内容是纯文本字符串“This is a simple document”。
这两种答案都是有效的,每个答案在其自身的方式上都是有用的。接下来的部分将解释如何处理元素内容的 HTML 表示和纯文本表示。
元素内容作为 HTML
读取 Element 的 innerHTML 属性会返回该元素的内容作为标记字符串。在元素上设置此属性会调用 Web 浏览器的解析器,并用新字符串的解析表示替换元素的当前内容。您可以通过打开开发者控制台并输入以下内容来测试:
document.body.innerHTML = "<h1>Oops</h1>";
您会看到整个网页消失,并被单个标题“Oops”替换。Web 浏览器非常擅长解析 HTML,并且设置innerHTML通常相当高效。但请注意,使用+=运算符将文本附加到innerHTML属性不高效,因为它需要序列化步骤将元素内容转换为字符串,然后需要解析步骤将新字符串转换回元素内容。
警告
在使用这些 HTML API 时,非常重要的一点是绝对不要将用户输入插入文档中。如果这样做,您将允许恶意用户将自己的脚本注入到您的应用程序中。有关详细信息,请参见“跨站脚本”。
元素的outerHTML属性类似于innerHTML,只是它的值包括元素本身。当您查询outerHTML时,该值包括元素的开头和结尾标记。当您在元素上设置outerHTML时,新内容将替换元素本身。
一个相关的元素方法是insertAdjacentHTML(),它允许您在指定元素的“相邻”位置插入任意 HTML 标记的字符串。标记作为第二个参数传递给此方法,而“相邻”的确切含义取决于第一个参数的值。第一个参数应该是一个带有“beforebegin”、“afterbegin”、“beforeend”或“afterend”值之一的字符串。这些值对应于图 15-2 中说明的插入点。

图 15-2. insertAdjacentHTML()的插入点
元素内容作为纯文本
有时,您希望将元素的内容查询为纯文本,或者将纯文本插入文档中(而无需转义 HTML 标记中使用的尖括号和和号)。标准的做法是使用textContent属性:
let para = document.querySelector("p"); // First <p> in the document
let text = para.textContent; // Get the text of the paragraph
para.textContent = "Hello World!"; // Alter the text of the paragraph
textContent属性由 Node 类定义,因此适用于文本节点和元素节点。对于元素节点,它会查找并返回元素所有后代中的所有文本。
Element 类定义了类似于textContent的innerText属性。innerText具有一些不寻常和复杂的行为,例如尝试保留表格格式。然而,它在各个浏览器之间的规范和实现并不一致,因此不应再使用。
15.3.5 创建、插入和删除节点
我们已经看到如何使用 HTML 字符串和纯文本查询和更改文档内容。我们还看到我们可以遍历文档以检查它由哪些单独的元素和文本节点组成。还可以在单个节点级别更改文档。Document 类定义了用于创建元素对象的方法,而 Element 和 Text 对象具有在树中插入、删除和替换节点的方法。
使用 Document 类的createElement()方法创建一个新元素,并使用其append()和prepend()方法将文本字符串或其他元素附加到其中:
let paragraph = document.createElement("p"); // Create an empty <p> element
let emphasis = document.createElement("em"); // Create an empty <em> element
emphasis.append("World"); // Add text to the <em> element
paragraph.append("Hello ", emphasis, "!"); // Add text and <em> to <p>
paragraph.prepend("¡"); // Add more text at start of <p>
paragraph.innerHTML // => "¡Hello <em>World</em>!"
append()和prepend()接受任意数量的参数,可以是节点对象或字符串。字符串参数会自动转换为文本节点。(您可以使用document.createTextNode()显式创建文本节点,但很少有理由这样做。)append()将参数添加到子节点列表的末尾。prepend()将参数添加到子节点列表的开头。
如果您想要将元素或文本节点插入包含元素的子节点列表的中间位置,则append()或prepend()都不适用。在这种情况下,您应该获取一个兄弟节点的引用,并调用before()在该兄弟节点之前插入新内容,或者调用after()在该兄弟节点之后插入新内容。例如:
// Find the heading element with class="greetings"
let greetings = document.querySelector("h2.greetings");
// Now insert the new paragraph and a horizontal rule after that heading
greetings.after(paragraph, document.createElement("hr"));
像append()和prepend()一样,after()和before()接受任意数量的字符串和元素参数,并在将字符串转换为文本节点后将它们全部插入文档中。append()和prepend()仅在 Element 对象上定义,但after()和before()适用于 Element 和 Text 节点:您可以使用它们相对于 Text 节点插入内容。
请注意,元素只能插入文档中的一个位置。如果元素已经在文档中并且您将其插入到其他位置,它将被移动到新位置,而不是复制:
// We inserted the paragraph after this element, but now we
// move it so it appears before the element instead
greetings.before(paragraph);
如果您确实想要复制一个元素,请使用cloneNode()方法,传递true以复制其所有内容:
// Make a copy of the paragraph and insert it after the greetings element
greetings.after(paragraph.cloneNode(true));
您可以通过调用其remove()方法从文档中删除 Element 或 Text 节点,或者您可以通过调用replaceWith()来替换它。remove()不接受任何参数,replaceWith()接受任意数量的字符串和元素,就像before()和after()一样:
// Remove the greetings element from the document and replace it with
// the paragraph element (moving the paragraph from its current location
// if it is already inserted into the document).
greetings.replaceWith(paragraph);
// And now remove the paragraph.
paragraph.remove();
DOM API 还定义了一组用于插入和删除内容的较旧一代方法。appendChild()、insertBefore()、replaceChild()和removeChild()比这里显示的方法更难使用,而且永远不应该需要。
15.3.6 示例:生成目录
示例 15-1 展示了如何为文档动态创建目录。它演示了前几节描述的许多文档脚本化技术。示例有很好的注释,您应该没有问题跟踪代码。
示例 15-1。使用 DOM API 生成目录
/**
* TOC.js: create a table of contents for a document.
*
* This script runs when the DOMContentLoaded event is fired and
* automatically generates a table of contents for the document.
* It does not define any global symbols so it should not conflict
* with other scripts.
*
* When this script runs, it first looks for a document element with
* an id of "TOC". If there is no such element it creates one at the
* start of the document. Next, the function finds all <h2> through
* <h6> tags, treats them as section titles, and creates a table of
* contents within the TOC element. The function adds section numbers
* to each section heading and wraps the headings in named anchors so
* that the TOC can link to them. The generated anchors have names
* that begin with "TOC", so you should avoid this prefix in your own
* HTML.
*
* The entries in the generated TOC can be styled with CSS. All
* entries have a class "TOCEntry". Entries also have a class that
* corresponds to the level of the section heading. <h1> tags generate
* entries of class "TOCLevel1", <h2> tags generate entries of class
* "TOCLevel2", and so on. Section numbers inserted into headings have
* class "TOCSectNum".
*
* You might use this script with a stylesheet like this:
*
* #TOC { border: solid black 1px; margin: 10px; padding: 10px; }
* .TOCEntry { margin: 5px 0px; }
* .TOCEntry a { text-decoration: none; }
* .TOCLevel1 { font-size: 16pt; font-weight: bold; }
* .TOCLevel2 { font-size: 14pt; margin-left: .25in; }
* .TOCLevel3 { font-size: 12pt; margin-left: .5in; }
* .TOCSectNum:after { content: ": "; }
*
* To hide the section numbers, use this:
*
* .TOCSectNum { display: none }
**/
document.addEventListener("DOMContentLoaded", () => {
// Find the TOC container element.
// If there isn't one, create one at the start of the document.
let toc = document.querySelector("#TOC");
if (!toc) {
toc = document.createElement("div");
toc.id = "TOC";
document.body.prepend(toc);
}
// Find all section heading elements. We're assuming here that the
// document title uses <h1> and that sections within the document are
// marked with <h2> through <h6>.
let headings = document.querySelectorAll("h2,h3,h4,h5,h6");
// Initialize an array that keeps track of section numbers.
let sectionNumbers = [0,0,0,0,0];
// Now loop through the section header elements we found.
for(let heading of headings) {
// Skip the heading if it is inside the TOC container.
if (heading.parentNode === toc) {
continue;
}
// Figure out what level heading it is.
// Subtract 1 because <h2> is a level-1 heading.
let level = parseInt(heading.tagName.charAt(1)) - 1;
// Increment the section number for this heading level
// and reset all lower heading level numbers to zero.
sectionNumbers[level-1]++;
for(let i = level; i < sectionNumbers.length; i++) {
sectionNumbers[i] = 0;
}
// Now combine section numbers for all heading levels
// to produce a section number like 2.3.1.
let sectionNumber = sectionNumbers.slice(0, level).join(".");
// Add the section number to the section header title.
// We place the number in a <span> to make it styleable.
let span = document.createElement("span");
span.className = "TOCSectNum";
span.textContent = sectionNumber;
heading.prepend(span);
// Wrap the heading in a named anchor so we can link to it.
let anchor = document.createElement("a");
let fragmentName = `TOC${sectionNumber}`;
anchor.name = fragmentName;
heading.before(anchor); // Insert anchor before heading
anchor.append(heading); // and move heading inside anchor
// Now create a link to this section.
let link = document.createElement("a");
link.href = `#${fragmentName}`; // Link destination
// Copy the heading text into the link. This is a safe use of
// innerHTML because we are not inserting any untrusted strings.
link.innerHTML = heading.innerHTML;
// Place the link in a div that is styleable based on the level.
let entry = document.createElement("div");
entry.classList.add("TOCEntry", `TOCLevel${level}`);
entry.append(link);
// And add the div to the TOC container.
toc.append(entry);
}
});
15.4 脚本化 CSS
我们已经看到 JavaScript 可以控制 HTML 文档的逻辑结构和内容。它还可以通过脚本化 CSS 来控制这些文档的视觉外观和布局。以下各小节解释了 JavaScript 代码可以使用的几种不同技术来处理 CSS。
这是一本关于 JavaScript 的书,不是关于 CSS 的书,本节假设您已经掌握了如何使用 CSS 来为 HTML 内容设置样式的工作知识。但值得一提的是,一些常常从 JavaScript 中脚本化的 CSS 样式:
-
将
display样式设置为“none”可以隐藏一个元素。稍后可以通过将display设置为其他值来显示元素。 -
您可以通过将
position样式设置为“absolute”、“relative”或“fixed”,然后将top和left样式设置为所需的坐标来动态定位元素。在使用 JavaScript 显示动态内容(如模态对话框和工具提示)时,这一点很重要。 -
您可以使用
transform样式来移动、缩放和旋转元素。 -
您可以使用
transition样式对其他 CSS 样式的更改进行动画处理。这些动画由 Web 浏览器自动处理,不需要 JavaScript,但您可以使用 JavaScript 来启动动画。
15.4.1 CSS 类
使用 JavaScript 影响文档内容的样式的最简单方法是从 HTML 标签的class属性中添加和删除 CSS 类名。这很容易通过 Element 对象的classList属性来实现,如“class 属性”中所述。
例如,假设您的文档样式表包含一个“hidden”类的定义:
.hidden {
display:none;
}
使用这种定义的样式,您可以通过以下代码隐藏(然后显示)一个元素:
// Assume that this "tooltip" element has class="hidden" in the HTML file.
// We can make it visible like this:
document.querySelector("#tooltip").classList.remove("hidden");
// And we can hide it again like this:
document.querySelector("#tooltip").classList.add("hidden");
15.4.2 内联样式
继续上一个工具提示示例,假设文档结构中只有一个工具提示元素,并且我们希望在显示之前动态定位它。一般来说,我们无法为工具提示的每种可能位置创建不同的样式表类,因此classList属性无法帮助我们定位。
在这种情况下,我们需要脚本化工具提示元素的style属性,以设置特定于该元素的内联样式。DOM 为所有 Element 对象定义了一个与style属性对应的style属性。然而,与大多数这样的属性不同,style属性不是一个字符串。相反,它是一个 CSSStyleDeclaration 对象:CSS 样式的解析表示形式,它以文本形式出现在style属性中。为了使用 JavaScript 显示和设置我们假设的工具提示的位置,我们可能会使用类似于以下代码:
function displayAt(tooltip, x, y) {
tooltip.style.display = "block";
tooltip.style.position = "absolute";
tooltip.style.left = `${x}px`;
tooltip.style.top = `${y}px`;
}
当使用 CSSStyleDeclaration 对象的样式属性时,请记住所有值必须指定为字符串。在样式表或style属性中,您可以这样写:
display: block; font-family: sans-serif; background-color: #ffffff;
要在 JavaScript 中为具有相同效果的元素e执行相同的操作,您必须引用所有值:
e.style.display = "block";
e.style.fontFamily = "sans-serif";
e.style.backgroundColor = "#ffffff";
请注意,分号放在字符串外部。这些只是普通的 JavaScript 分号;您在 CSS 样式表中使用的分号不是 JavaScript 中设置的字符串值的一部分。
此外,请记住,许多 CSS 属性需要像“px”表示像素或“pt”表示点这样的单位。因此,像这样设置marginLeft属性是不正确的:
e.style.marginLeft = 300; // Incorrect: this is a number, not a string
e.style.marginLeft = "300"; // Incorrect: the units are missing
在 JavaScript 中设置样式属性时需要单位,就像在样式表中设置样式属性时一样。将元素e的marginLeft属性值设置为 300 像素的正确方法是:
e.style.marginLeft = "300px";
如果要将 CSS 属性设置为计算值,请确保在计算结束时附加单位:
e.style.left = `${x0 + left_border + left_padding}px`;
请记住,一些 CSS 属性,例如margin,是其他属性的快捷方式,例如margin-top,margin-right,margin-bottom和margin-left。CSSStyleDeclaration 对象具有与这些快捷属性对应的属性。例如,您可以这样设置margin属性:
e.style.margin = `${top}px ${right}px ${bottom}px ${left}px`;
有时,您可能会发现将元素的内联样式设置或查询为单个字符串值比作为 CSSStyleDeclaration 对象更容易。为此,您可以使用 Element 的getAttribute()和setAttribute()方法,或者您可以使用 CSSStyleDeclaration 对象的cssText属性:
// Copy the inline styles of element e to element f:
f.setAttribute("style", e.getAttribute("style"));
// Or do it like this:
f.style.cssText = e.style.cssText;
当查询元素的style属性时,请记住它仅表示元素的内联样式,大多数元素的大多数样式是在样式表中指定而不是内联的。此外,当查询style属性时获得的值将使用实际在 HTML 属性上使用的任何单位和任何快捷属性格式,并且您的代码可能需要进行一些复杂的解析来解释它们。一般来说,如果您想查询元素的样式,您可能需要计算样式,下面将讨论。
15.4.3 计算样式
元素的计算样式是浏览器从元素的内联样式加上所有样式表中的所有适用样式规则推导(或计算)出的属性值集合:它是实际用于显示元素的属性集合。与内联样式一样,计算样式用 CSSStyleDeclaration 对象表示。然而,与内联样式不同,计算样式是只读的。您不能设置这些样式,但是元素的计算 CSSStyleDeclaration 对象可以让您确定浏览器在呈现该元素时使用了哪些样式属性值。
使用 Window 对象的getComputedStyle()方法获取元素的计算样式。此方法的第一个参数是所需的计算样式的元素。可选的第二个参数用于指定 CSS 伪元素,例如“::before”或“::after”:
let title = document.querySelector("#section1title");
let styles = window.getComputedStyle(title);
let beforeStyles = window.getComputedStyle(title, "::before");
getComputedStyle()的返回值是一个表示应用于指定元素(或伪元素)的所有样式的 CSSStyleDeclaration 对象。表示内联样式的 CSSStyleDeclaration 对象和表示计算样式的 CSSStyleDeclaration 对象之间有一些重要的区别:
-
计算样式属性是只读的。
-
计算样式属性是绝对的:相对单位如百分比和点会被转换为绝对值。任何指定大小的属性(如边距大小或字体大小)将具有以像素为单位的值。这个值将是一个带有“px”后缀的字符串,因此你仍然需要解析它,但你不必担心解析或转换其他单位。值为颜色的属性将以“rgb()”或“rgba()”格式返回。
-
快捷属性不会被计算,只有它们所基于的基本属性会被计算。例如,不要查询
margin属性,而是使用marginLeft、marginTop等。同样,不要查询border甚至borderWidth,而是使用borderLeftWidth、borderTopWidth等。 -
计算样式的
cssText属性是未定义的。
通过getComputedStyle()返回的 CSSStyleDeclaration 对象通常包含有关元素的更多信息,而不是从该元素的内联style属性获取的 CSSStyleDeclaration。但计算样式可能会有些棘手,查询它们并不总是提供你期望的信息。考虑font-family属性:它接受一个逗号分隔的所需字体系列列表,以实现跨平台可移植性。当你查询计算样式的fontFamily属性时,你只是获取适用于元素的最具体font-family样式的值。这可能返回一个值,如“arial,helvetica,sans-serif”,这并不告诉你实际使用的字体。同样,如果一个元素没有绝对定位,尝试通过计算样式的top和left属性查询其位置和大小通常会返回值auto。这是一个完全合法的 CSS 值,但这可能不是你要找的。
尽管 CSS 可以精确指定文档元素的位置和大小,但查询元素的计算样式并不是确定元素大小和位置的首选方法。查看§15.5.2 以获取更简单、可移植的替代方法。
15.4.4 脚本样式表
除了操作类属性和内联样式,JavaScript 还可以操作样式表本身。样式表与 HTML 文档关联,可以通过<style>标签或<link rel="stylesheet">标签进行关联。这两者都是常规的 HTML 标签,因此你可以给它们都添加id属性,然后使用document.querySelector()查找它们。
<style>和<link>标签的 Element 对象都有一个disabled属性,你可以使用它来禁用整个样式表。你可以使用如下代码:
// This function switches between the "light" and "dark" themes
function toggleTheme() {
let lightTheme = document.querySelector("#light-theme");
let darkTheme = document.querySelector("#dark-theme");
if (darkTheme.disabled) { // Currently light, switch to dark
lightTheme.disabled = true;
darkTheme.disabled = false;
} else { // Currently dark, switch to light
lightTheme.disabled = false;
darkTheme.disabled = true;
}
}
另一种简单的脚本样式表的方法是使用我们已经看过的 DOM 操作技术将新样式表插入文档中。例如:
function setTheme(name) {
// Create a new <link rel="stylesheet"> element to load the named stylesheet
let link = document.createElement("link");
link.id = "theme";
link.rel = "stylesheet";
link.href = `themes/${name}.css`;
// Look for an existing link with id "theme"
let currentTheme = document.querySelector("#theme");
if (currentTheme) {
// If there is an existing theme, replace it with the new one.
currentTheme.replaceWith(link);
} else {
// Otherwise, just insert the link to the theme stylesheet.
document.head.append(link);
}
}
更直接地,你也可以将一个包含<style>标签的 HTML 字符串插入到你的文档中。例如:
document.head.insertAdjacentHTML(
"beforeend",
"<style>body{transform:rotate(180deg)}</style>"
);
浏览器定义了一个 API,允许 JavaScript 查看样式表内部,查询、修改、插入和删除该样式表中的样式规则。这个 API 是如此专门化,以至于这里没有记录。你可以在 MDN 上搜索“CSSStyleSheet”和“CSS Object Model”来了解它。
15.4.5 CSS 动画和事件
假设你在样式表中定义了以下两个 CSS 类:
.transparent { opacity: 0; }
.fadeable { transition: opacity .5s ease-in }
如果你将第一个样式应用于一个元素,它将完全透明,因此看不见。但如果你应用第二个样式,告诉浏览器当元素的不透明度发生变化时,该变化应该在 0.5 秒内进行动画处理,“ease-in”指定不透明度变化动画应该从缓慢开始然后加速。
现在假设你的 HTML 文档包含一个带有“fadeable”类的元素:
<div id="subscribe" class="fadeable notification">...</div>
在 JavaScript 中,你可以添加“transparent”类:
document.querySelector("#subscribe").classList.add("transparent");
此元素已配置为动画不透明度变化。添加“transparent”类会改变不透明度并触发动画:浏览器会使元素“淡出”,使其在半秒钟内完全透明。
这也适用于相反的情况:如果您删除“fadeable”元素的“transparent”类,那也是一个不透明度变化,元素会重新淡入并再次变得可见。
JavaScript 不需要做任何工作来实现这些动画:它们是纯 CSS 效果。但是 JavaScript 可以用来触发它们。
JavaScript 也可以用于监视 CSS 过渡的进度,因为 Web 浏览器在过渡开始和结束时会触发事件。当过渡首次触发时,会分发“transitionrun”事件。这可能发生在任何视觉变化开始之前,当指定了transition-delay样式时。一旦视觉变化开始,就会分发“transitionstart”事件,当动画完成时,就会分发“transitionend”事件。当然,所有这些事件的目标都是正在进行动画的元素。传递给这些事件处理程序的事件对象是一个 TransitionEvent 对象。它有一个propertyName属性,指定正在进行动画的 CSS 属性,以及一个elapsedTime属性,对于“transitionend”事件,它指定自“transitionstart”事件以来经过了多少秒。
除了过渡效果,CSS 还支持一种更复杂的动画形式,简称为“CSS 动画”。这些使用 CSS 属性,如animation-name和animation-duration,以及特殊的@keyframes规则来定义动画细节。CSS 动画的工作原理超出了本书的范围,但再次,如果您在 CSS 类上定义了所有动画属性,那么您可以通过将该类添加到要进行动画处理的元素来使用 JavaScript 触发动画。
与 CSS 过渡类似,CSS 动画也会触发事件,您的 JavaScript 代码可以监听这些事件。“animationstart”在动画开始时分发,“animationend”在动画完成时分发。如果动画重复多次,则在每次重复之后(除最后一次)都会分发“animationiteration”事件。事件目标是被动画化的元素,传递给处理程序函数的事件对象是一个 AnimationEvent 对象。这些事件包括一个animationName属性,指定定义动画的animation-name属性,以及一个elapsedTime属性,指定自动画开始以来经过了多少秒。
15.5 文档几何和滚动
到目前为止,在本章中,我们已经将文档视为元素和文本节点的抽象树。但是当浏览器在窗口中呈现文档时,它会创建文档的视觉表示,其中每个元素都有位置和大小。通常,Web 应用程序可以将文档视为元素树,而无需考虑这些元素如何在屏幕上呈现。然而,有时需要确定元素的精确几何形状。例如,如果您想使用 CSS 动态定位一个元素(如工具提示)在一些普通的浏览器定位元素旁边,您需要能够确定该元素的位置。
以下小节解释了如何在文档的抽象、基于树的模型和在浏览器窗口中布局的几何、基于坐标的视图之间来回切换。
15.5.1 文档坐标和视口坐标
文档元素的位置以 CSS 像素为单位,x 坐标向右增加,y 坐标向下增加。然而,我们可以使用两个不同的点作为坐标系原点:元素的 x 和 y 坐标可以相对于文档的左上角或相对于显示文档的视口的左上角。在顶级窗口和标签中,“视口”是实际显示文档内容的浏览器部分:它不包括浏览器的“chrome”(如菜单、工具栏和标签)。对于在 <iframe> 标签中显示的文档,DOM 中定义嵌套文档的视口的是 iframe 元素。无论哪种情况,当我们谈论元素的位置时,必须清楚我们是使用文档坐标还是视口坐标。(请注意,有时视口坐标被称为“窗口坐标”。)
如果文档比视口小,或者没有滚动,文档的左上角在视口的左上角,文档和视口坐标系是相同的。然而,一般来说,要在两个坐标系之间转换,必须添加或减去滚动偏移量。例如,如果一个元素在文档坐标中有 200 像素的 y 坐标,而用户向下滚动了 75 像素,那么该元素在视口坐标中的 y 坐标为 125 像素。同样,如果一个元素在用户水平滚动视口 200 像素后在视口坐标中有 400 的 x 坐标,那么元素在文档坐标中的 x 坐标为 600。
如果我们使用印刷纸质文档的思维模型,逻辑上可以假设文档中的每个元素在文档坐标中必须有一个唯一的位置,无论用户滚动了多少。这是纸质文档的一个吸引人的特性,对于简单的网页文档也适用,但总的来说,在网页上文档坐标实际上并不起作用。问题在于 CSS overflow 属性允许文档中的元素包含比其能显示的更多内容。元素可以有自己的滚动条,并作为包含的内容的视口。网页允许在滚动文档中滚动元素意味着不可能使用单个 (x,y) 点描述文档中元素的位置。
因为文档坐标实际上不起作用,客户端 JavaScript 倾向于使用视口坐标。例如,下面描述的 getBoundingClientRect() 和 elementFromPoint() 方法使用视口坐标,而鼠标和指针事件对象的 clientX 和 clientY 属性也使用这个坐标系。
当你使用 CSS position:fixed 明确定位元素时,top 和 left 属性是以视口坐标解释的。如果使用 position:relative,元素的定位是相对于如果没有设置 position 属性时的位置。如果使用 position:absolute,那么 top 和 left 是相对于文档或最近的包含定位元素的。这意味着,例如,相对定位元素位于相对定位元素内部,是相对于容器元素而不是相对于整个文档的。有时候,创建一个相对定位的容器并将 top 和 left 设置为 0(使容器正常布局)非常有用,以便为其中包含的绝对定位元素建立一个新的坐标系原点。我们可能将这个新的坐标系称为“容器坐标”,以区别于文档坐标和视口坐标。
15.5.2 查询元素的几何信息
您可以通过调用其getBoundingClientRect()方法来确定元素的大小(包括 CSS 边框和填充,但不包括边距)和位置(在视口坐标中)。它不带参数并返回一个具有属性left、right、top、bottom、width和height的对象。left和top属性给出元素左上角的x和y坐标,right和bottom属性给出右下角的坐标。这些值之间的差异是width和height属性。
块元素,如图像、段落和<div>元素在浏览器布局时始终是矩形的。然而,内联元素,如<span>、<code>和<b>元素,可能跨越多行,因此可能由多个矩形组成。例如,想象一下,某些文本在<em>和</em>标签中显示,跨越两行。其矩形包括第一行的末尾和第二行的开头。如果您在此元素上调用getBoundingClientRect(),边界矩形将包括两行的整个宽度。如果要查询内联元素的各个矩形,请调用getClientRects()方法以获取一个只读的类似数组的对象,其元素是类似于getBoundingClientRect()返回的矩形对象。
15.5.3 确定点处的元素
getBoundingClientRect()方法允许我们确定元素在视口中的当前位置。有时我们想要反向操作,并确定视口中给定位置的元素是哪个。您可以使用文档对象的elementFromPoint()方法来确定这一点。使用点的x和y坐标调用此方法(使用视口坐标,而不是文档坐标:例如,鼠标事件的clientX和clientY坐标)。elementFromPoint()返回一个在指定位置的元素对象。用于选择元素的命中检测算法没有明确定义,但此方法的意图是返回该点处最内部(最深度嵌套)和最上层(最高 CSS z-index属性)的元素。
15.5.4 滚动
Window 对象的scrollTo()方法接受点的x和y坐标(在文档坐标中)并将其设置为滚动条偏移量。也就是说,它滚动窗口,使指定点位于视口的左上角。如果指定的点太靠近文档的底部或右边缘,浏览器会尽可能将其移动到左上角,但无法完全到达那里。以下代码将浏览器滚动,以便看到文档的最底部页面:
// Get the heights of the document and viewport.
let documentHeight = document.documentElement.offsetHeight;
let viewportHeight = window.innerHeight;
// And scroll so the last "page" shows in the viewport
window.scrollTo(0, documentHeight - viewportHeight);
Window 的scrollBy()方法类似于scrollTo(),但其参数是相对的,并添加到当前滚动位置:
// Scroll 50 pixels down every 500 ms. Note there is no way to turn this off!
setInterval(() => { scrollBy(0,50)}, 500);
如果你想要使用scrollTo()或scrollBy()平滑滚动,请传递一个对象参数,而不是两个数字,就像这样:
window.scrollTo({
left: 0,
top: documentHeight - viewportHeight,
behavior: "smooth"
});
通常,我们不是要在文档中滚动到数值位置,而是要滚动以使文档中的某个特定元素可见。您可以使用所需 HTML 元素上的scrollIntoView()方法来实现这一点。此方法确保调用它的元素在视口中可见。默认情况下,它尝试将元素的顶部边缘放在视口的顶部或附近。如果将false作为唯一参数传递,它将尝试将元素的底部边缘放在视口的底部。浏览器还将根据需要水平滚动视口以使元素可见。
您还可以将对象传递给scrollIntoView(),设置behavior:"smooth"属性以实现平滑滚动。您可以设置block属性以指定元素在垂直方向上的位置,并设置inline属性以指定水平滚动时元素的位置。这些属性的合法值为start、end、nearest和center。
视口大小、内容大小和滚动位置
正如我们所讨论的,浏览器窗口和其他 HTML 元素可以显示滚动内容。在这种情况下,我们有时需要知道视口的大小、内容的大小以及内容在视口内的滚动偏移量。本节涵盖了这些细节。
对于浏览器窗口,视口大小由window.innerWidth和window.innerHeight属性给出。(为移动设备优化的网页通常在<head>中使用<meta name="viewport">标签来设置页面所需的视口宽度。)文档的总大小与<html>元素的大小相同,即document.documentElement。您可以在document.documentElement上调用getBoundingClientRect()来获取文档的宽度和高度,或者您可以使用document.documentElement的offsetWidth和offsetHeight属性。文档在其视口内的滚动偏移量可通过window.scrollX和window.scrollY获得。这些是只读属性,因此您无法设置它们来滚动文档:请改用window.scrollTo()。
对于元素来说情况会有些复杂。每个 Element 对象定义以下三组属性:
offsetWidth clientWidth scrollWidth
offsetHeight clientHeight scrollHeight
offsetLeft clientLeft scrollLeft
offsetTop clientTop scrollTop
offsetParent
元素的offsetWidth和offsetHeight属性返回其在屏幕上的大小(以 CSS 像素为单位)。返回的大小包括元素的边框和填充,但不包括边距。offsetLeft和offsetTop属性返回元素的x和y坐标。对于许多元素,这些值是文档坐标。但对于定位元素的后代和一些其他元素(如表格单元格),这些属性返回相对于祖先元素而不是文档本身的坐标。offsetParent属性指定这些属性相对于哪个元素。这些偏移属性都是只读的。
clientWidth和clientHeight类似于offsetWidth和offsetHeight,只是它们不包括边框大小,只包括内容区域及其填充。clientLeft和clientTop属性并不是很有用:它们返回元素的填充外部与边框外部之间的水平和垂直距离。通常,这些值只是左边框和上边框的宽度。这些客户端属性都是只读的。对于像<i>、<code>和<span>这样的内联元素,它们都返回 0。
scrollWidth和scrollHeight返回元素内容区域的大小加上其填充加上任何溢出内容。当内容适合内容区域而不溢出时,这些属性与clientWidth和clientHeight相同。但当存在溢出时,它们包括溢出的内容并返回大于clientWidth和clientHeight的值。scrollLeft和scrollTop给出元素内容在元素视口内的滚动偏移量。与这里描述的所有其他属性不同,scrollLeft和scrollTop是可写属性,您可以设置它们来滚动元素内的内容。(在大多数浏览器中,Element 对象也像 Window 对象一样具有scrollTo()和scrollBy()方法,但这些方法尚未得到普遍支持。)
Web 组件
HTML 是一种用于文档标记的语言,为此定义了一套丰富的标签。在过去的三十年里,它已经成为描述 Web 应用程序用户界面的语言,但基本的 HTML 标签如<input>和<button>对于现代 UI 设计来说是不足够的。Web 开发人员可以让其工作,但只能通过使用 CSS 和 JavaScript 来增强基本 HTML 标签的外观和行为。考虑一个典型的用户界面组件,比如在图 15-3 中显示的搜索框。

图 15-3。一个搜索框用户界面组件
HTML <input>元素可用于接受用户的单行输入,但它没有任何显示图标的方法,比如左侧的放大镜和右侧的取消 X。为了在 Web 上实现这样一个现代用户界面元素,我们至少需要使用四个 HTML 元素:一个<input>元素用于接受和显示用户的输入,两个<img>元素(或在这种情况下,两个显示 Unicode 图标的<span>元素),以及一个容器<div>元素来容纳这三个子元素。此外,我们必须使用 CSS 来隐藏<input>元素的默认边框,并为容器定义一个边框。我们还需要使用 JavaScript 使所有 HTML 元素协同工作。当用户点击 X 图标时,我们需要一个事件处理程序来清除<input>元素中的输入,例如。
每次想在 Web 应用程序中显示一个搜索框都需要做很多工作,而今天大多数 Web 应用程序并不是使用“原始”HTML 编写的。相反,许多 Web 开发人员使用像 React 和 Angular 这样的框架,支持创建可重用的用户界面组件,比如这里显示的搜索框。Web 组件是一个基于 Web 标准的浏览器原生替代方案,它基于三个相对较新的 Web 标准添加,允许 JavaScript 使用新的标签扩展 HTML,这些标签可以作为独立的、可重用的 UI 组件。
接下来的小节将解释如何在自己的 Web 页面中使用其他开发人员定义的 Web 组件,然后解释 Web 组件基于的三种技术,并最终在一个示例中将这三种技术结合起来,实现图 15-3 中显示的搜索框元素。
15.6.1 使用 Web 组件
Web 组件是用 JavaScript 定义的,因此为了在 HTML 文件中使用 Web 组件,你需要包含定义组件的 JavaScript 文件。由于 Web 组件是一种相对较新的技术,它们通常被编写为 JavaScript 模块,因此你可以像这样在 HTML 中包含一个:
<script type="module" src="components/search-box.js">
Web 组件定义自己的 HTML 标签名称,重要的限制是这些标签名称必须包含连字符。这意味着未来版本的 HTML 可以引入不带连字符的新标签,而且不会与任何人的 Web 组件冲突。要使用 Web 组件,只需在 HTML 文件中使用其标签:
<search-box placeholder="Search..."></search-box>
Web 组件可以像常规 HTML 标签一样具有属性;你使用的组件的文档应告诉你支持哪些属性。Web 组件不能用自闭合标签来定义。例如,你不能写<search-box/>。你的 HTML 文件必须包含开放标签和闭合标签。
像常规 HTML 元素一样,一些 Web 组件被编写为期望有子元素,而另一些则被编写为不期望(也不会显示)子元素。一些 Web 组件被编写为可以选择接受特殊标记的子元素,这些子元素将出现在命名的“插槽”中。图 15-3 中显示的<search-box>组件,并在示例 15-3 中实现,使用“插槽”来显示两个图标。如果你想使用带有不同图标的<search-box>,可以使用如下 HTML:
<search-box>
<img src="images/search-icon.png" slot="left"/>
<img src="images/cancel-icon.png" slot="right"/>
</search-box>
slot 属性是 HTML 的扩展,用于指定哪些子元素应该放在哪里。在这个示例中定义的插槽名称“left”和“right”由 Web 组件定义。如果您使用的组件支持插槽,那么这一点应该包含在其文档中。
我之前提到,Web 组件通常作为 JavaScript 模块实现,并且可以通过<script type="module">标签加载到 HTML 文件中。您可能还记得本章开头提到的模块在文档内容解析后加载,就像它们有一个deferred标签一样。这意味着 Web 浏览器通常会在运行告诉它<search-box>是什么的代码之前解析和呈现<search-box>等标签。这在使用 Web 组件时是正常的。Web 浏览器中的 HTML 解析器对于它们不理解的输入非常灵活和宽容。当它们在组件被定义之前遇到一个 Web 组件标签时,它们会向 DOM 树添加一个通用的 HTMLElement,即使它们不知道如何处理它。稍后,当自定义元素被定义时,通用元素会被“升级”,以便看起来和行为符合预期。
如果一个 Web 组件有子元素,在组件定义之前这些子元素可能会显示不正确。您可以使用以下 CSS 来保持 Web 组件隐藏,直到它们被定义:
/*
* Make the <search-box> component invisible before it is defined.
* And try to duplicate its eventual layout and size so that nearby
* content does not move when it becomes defined.
*/
search-box:not(:defined) {
opacity:0;
display: inline-block;
width: 300px;
height: 50px;
}
像常规 HTML 元素一样,Web 组件可以在 JavaScript 中使用。如果在 Web 页面中包含了<search-box>标签,那么您可以使用querySelector()和适当的 CSS 选择器获取对它的引用,就像对任何其他 HTML 标签一样。通常,只有在定义组件的模块运行后才有意义这样做,因此在查询 Web 组件时要小心不要太早。Web 组件实现通常(但这不是必需的)为它们支持的每个 HTML 属性定义一个 JavaScript 属性。而且,像 HTML 元素一样,它们也可以定义有用的方法。再次强调,您使用的 Web 组件的文档应该指定哪些属性和方法对您的 JavaScript 代码是可用的。
现在您已经了解如何使用 Web 组件,接下来的三节将介绍允许我们实现它们的三个 Web 浏览器功能。
15.6.2 HTML 模板
HTML <template> 标签与 Web 组件只有松散的关系,但它确实为在 Web 页面中频繁出现的组件提供了一个有用的优化。<template> 标签及其子元素从不被 Web 浏览器呈现,仅在使用 JavaScript 的 Web 页面上才有用。这个标签的理念是,当一个 Web 页面包含多个相同基本 HTML 结构的重复(例如表中的行或 Web 组件的内部实现)时,我们可以使用 <template> 一次定义该元素结构,然后使用 JavaScript 根据需要复制该结构多次。
在 JavaScript 中,<template> 标签由 HTMLTemplateElement 对象表示。这个对象定义了一个content属性,这个属性的值是<template>的所有子节点的 DocumentFragment。您可以克隆这个 DocumentFragment,然后根据需要将克隆的副本插入到您的文档中。片段本身不会被插入,但它的子节点会被插入。假设您正在处理一个包含<table>和<template id="row">标签的文档,模板定义了该表的行结构。您可以像这样使用模板:
let tableBody = document.querySelector("tbody");
let template = document.querySelector("#row");
let clone = template.content.cloneNode(true); // deep clone
// ...Use the DOM to insert content into the <td> elements of the clone...
// Now add the cloned and initialized row into the table
tableBody.append(clone);
模板元素不必在 HTML 文档中直接出现才能发挥作用。您可以在 JavaScript 代码中创建模板,使用innerHTML创建其子元素,然后根据需要制作尽可能多的克隆而无需解析innerHTML的开销。这就是 HTML 模板在 Web 组件中通常的用法,示例 15-3 演示了这种技术。
15.6.3 自定义元素
使 Web 组件能够实现的第二个 Web 浏览器功能是“自定义元素”:将 JavaScript 类与 HTML 标签名称关联起来,以便文档中的任何此类标签自动转换为 DOM 树中的类实例。customElements.define() 方法以 Web 组件标签名称作为第一个参数(请记住标签名称必须包含连字符),以 HTMLElement 的子类作为第二个参数。文档中具有该标签名称的任何现有元素都会“升级”为新创建的类实例。如果浏览器将来解析任何 HTML,它将自动为遇到的每个标签创建一个类的实例。
传递给 customElements.define() 的类应该扩展 HTMLElement,而不是更具体的类型,如 HTMLButtonElement。回想一下第九章中提到的,当 JavaScript 类扩展另一个类时,构造函数必须在使用 this 关键字之前调用 super(),因此如果自定义元素类有构造函数,它应该在执行任何其他操作之前调用 super()(不带参数)。
浏览器将自动调用自定义元素类的某些“生命周期方法”。当自定义元素的实例插入文档中时,将调用 connectedCallback() 方法,许多元素使用此方法执行初始化。还有一个 disconnectedCallback() 方法在元素从文档中移除时(如果有的话)被调用,尽管这不太常用。
如果自定义元素类定义了一个静态的 observedAttributes 属性,其值是属性名称数组,并且如果在自定义元素的实例上设置(或更改)了任何命名属性,则浏览器将调用 attributeChangedCallback() 方法,传递属性名称、其旧值和新值。此回调可以采取任何必要步骤来根据其属性值更新组件。
自定义元素类也可以定义任何其他属性和方法。通常,它们会定义获取器和设置器方法,使元素的属性可以作为 JavaScript 属性使用。
作为自定义元素的一个示例,假设我们希望能够在常规文本段落中显示圆形。我们希望能够编写类似于以下 HTML 以渲染像图 15-4 中显示的数学问题:
<p>
The document has one marble: <inline-circle></inline-circle>.
The HTML parser instantiates two more marbles:
<inline-circle diameter="1.2em" color="blue"></inline-circle>
<inline-circle diameter=".6em" color="gold"></inline-circle>.
How many marbles does the document contain now?
</p>

图 15-4. 内联圆形自定义元素
我们可以使用 示例 15-2 中显示的代码来实现这个 <inline-circle> 自定义元素:
示例 15-2. <inline-circle> 自定义元素
customElements.define("inline-circle", class InlineCircle extends HTMLElement {
// The browser calls this method when an <inline-circle> element
// is inserted into the document. There is also a disconnectedCallback()
// that we don't need in this example.
connectedCallback() {
// Set the styles needed to create circles
this.style.display = "inline-block";
this.style.borderRadius = "50%";
this.style.border = "solid black 1px";
this.style.transform = "translateY(10%)";
// If there is not already a size defined, set a default size
// that is based on the current font size.
if (!this.style.width) {
this.style.width = "0.8em";
this.style.height = "0.8em";
}
}
// The static observedAttributes property specifies which attributes
// we want to be notified about changes to. (We use a getter here since
// we can only use "static" with methods.)
static get observedAttributes() { return ["diameter", "color"]; }
// This callback is invoked when one of the attributes listed above
// changes, either when the custom element is first parsed, or later.
attributeChangedCallback(name, oldValue, newValue) {
switch(name) {
case "diameter":
// If the diameter attribute changes, update the size styles
this.style.width = newValue;
this.style.height = newValue;
break;
case "color":
// If the color attribute changes, update the color styles
this.style.backgroundColor = newValue;
break;
}
}
// Define JavaScript properties that correspond to the element's
// attributes. These getters and setters just get and set the underlying
// attributes. If a JavaScript property is set, that sets the attribute
// which triggers a call to attributeChangedCallback() which updates
// the element styles.
get diameter() { return this.getAttribute("diameter"); }
set diameter(diameter) { this.setAttribute("diameter", diameter); }
get color() { return this.getAttribute("color"); }
set color(color) { this.setAttribute("color", color); }
});
15.6.4 影子 DOM
在示例 15-2 中展示的自定义元素没有很好地封装。当设置其 diameter 或 color 属性时,它会通过更改自己的 style 属性来响应,这不是我们从真正的 HTML 元素中期望的行为。要将自定义元素转变为真正的 Web 组件,它应该使用强大的封装机制,即影子 DOM。
Shadow DOM 允许将“影子根”附加到自定义元素(以及 <div>、<span>、<body>、<article>、<main>、<nav>、<header>、<footer>、<section>、<p>、<blockquote>、<aside> 或 <h1> 到 <h6> 元素)上,称为“影子主机”。影子主机元素,像所有 HTML 元素一样,已经是后代元素和文本节点的普通 DOM 树的根。影子根是另一个更私密的后代元素树的根,从影子主机发芽,可以被视为一个独立的小型文档。
“shadow DOM” 中的 “shadow” 一词指的是从影子根源的元素“隐藏在阴影中”:它们不是正常 DOM 树的一部分,不出现在其宿主元素的 children 数组中,并且不会被正常的 DOM 遍历方法(如 querySelector())访问。相比之下,影子宿主的正常、常规 DOM 子元素有时被称为 “light DOM”。
要理解影子 DOM 的目的,想象一下 HTML <audio> 和 <video> 元素:它们显示了一个用于控制媒体播放的非平凡用户界面,但播放和暂停按钮以及其他 UI 元素不是 DOM 树的一部分,也不能被 JavaScript 操纵。鉴于 Web 浏览器设计用于显示 HTML,浏览器供应商自然希望使用 HTML 显示这些内部 UI。事实上,大多数浏览器长期以来一直在做类似的事情,而影子 DOM 使其成为 Web 平台的标准部分。
影子 DOM 封装
影子 DOM 的关键特征是提供的封装。影子根的后代元素对于常规 DOM 树是隐藏的,并且独立的,几乎就像它们在一个独立的文档中一样。影子 DOM 提供了三种非常重要的封装类型:
-
如前所述,影子 DOM 中的元素对于像
querySelectorAll()这样的常规 DOM 方法是隐藏的。当创建一个影子根并将其附加到其影子宿主时,它可以以 “open” 或 “closed” 模式创建。尽管更常见的是,影子根以 “open” 模式创建,这意味着影子宿主具有一个shadowRoot属性,JavaScript 可以使用它来访问影子根的元素,如果有某种原因需要这样做。 -
在影子根下定义的样式是私有的,并且永远不会影响外部的 light DOM 元素。(影子根可以为其宿主元素定义默认样式,但这些样式将被 light DOM 样式覆盖。)同样,适用于影子宿主元素的 light DOM 样式对影子根的后代元素没有影响。影子 DOM 中的元素将从 light DOM 继承诸如字体大小和背景颜色之类的属性,并且影子 DOM 中的样式可以选择使用在 light DOM 中定义的 CSS 变量。然而,在大多数情况下,light DOM 的样式和影子 DOM 的样式是完全独立的:Web 组件的作者和 Web 组件的用户不必担心样式表之间的冲突或冲突。以这种方式“范围” CSS 可能是影子 DOM 最重要的特性。
-
在影子 DOM 中发生的一些事件(如 “load”)被限制在影子 DOM 中。其他事件,包括焦点、鼠标和键盘事件会冒泡并传播出去。当起源于影子 DOM 的事件越过边界并开始在 light DOM 中传播时,其
target属性会更改为影子宿主元素,因此看起来好像是直接在该元素上发生的。
影子 DOM 插槽和 light DOM 子元素
作为影子宿主的 HTML 元素有两个后代树。一个是 children[] 数组—宿主元素的常规 light DOM 后代—另一个是影子根及其所有后代,您可能想知道如何在同一宿主元素内显示两个不同的内容树。工作原理如下:
-
影子根的后继元素始终显示在影子宿主内。
-
如果这些后代包括一个
<slot>元素,则主机元素的常规 light DOM 子元素将显示为该<slot>的子元素,替换插槽中的任何 shadow DOM 内容。如果 shadow DOM 不包含<slot>,则主机的任何 light DOM 内容都不会显示。如果 shadow DOM 有一个<slot>,但 shadow host 没有 light DOM 子元素,则插槽的 shadow DOM 内容将作为默认显示。 -
当 light DOM 内容显示在 shadow DOM 插槽中时,我们说这些元素已被“分发”,但重要的是要理解这些元素实际上并未成为 shadow DOM 的一部分。它们仍然可以使用
querySelector()进行查询,并且它们仍然显示在 light DOM 中,作为主机元素的子元素或后代。 -
如果 shadow DOM 定义了多个带有
name属性命名的<slot>,那么 shadow host 的子元素可以通过指定slot="slotname"属性来指定它们想要出现在哪个插槽中。我们在 §15.6.1 中演示了这种用法的示例,当我们演示如何自定义<search-box>组件显示的图标时。
Shadow DOM API
尽管 Shadow DOM 功能强大,但它的 JavaScript API 并不多。要将 light DOM 元素转换为 shadow host,只需调用其 attachShadow() 方法,将 {mode:"open"} 作为唯一参数传递。此方法返回一个 shadow root 对象,并将该对象设置为主机的 shadowRoot 属性的值。shadow root 对象是一个 DocumentFragment,您可以使用 DOM 方法向其添加内容,或者只需将其 innerHTML 属性设置为 HTML 字符串。
如果您的 Web 组件需要知道 shadow DOM <slot> 的 light DOM 内容何时更改,它可以直接在 <slot> 元素上注册“slotchanged”事件的监听器。
15.6.5 示例:一个 Web 组件
图 15-3 展示了一个 <search-box> Web 组件。示例 15-3 演示了定义 Web 组件的三种启用技术:它将 <search-box> 组件实现为使用 <template> 标签提高效率和使用 shadow root 封装的自定义元素。
此示例展示了如何直接使用低级 Web 组件 API。实际上,今天开发的许多 Web 组件都是使用诸如 “lit-element” 等更高级别库创建的。使用库的原因之一是创建可重用和可定制组件实际上是非常困难的,并且有许多细节需要正确处理。示例 15-3 演示了 Web 组件并进行了一些基本的键盘焦点处理,但忽略了可访问性,并且没有尝试使用正确的 ARIA 属性使组件与屏幕阅读器和其他辅助技术配合使用。
示例 15-3。实现一个 Web 组件
/**
* This class defines a custom HTML <search-box> element that displays an
* <input> text input field plus two icons or emoji. By default, it displays a
* magnifying glass emoji (indicating search) to the left of the text field
* and an X emoji (indicating cancel) to the right of the text field. It
* hides the border on the input field and displays a border around itself,
* creating the appearance that the two emoji are inside the input
* field. Similarly, when the internal input field is focused, the focus ring
* is displayed around the <search-box>.
*
* You can override the default icons by including <span> or <img> children
* of <search-box> with slot="left" and slot="right" attributes.
*
* <search-box> supports the normal HTML disabled and hidden attributes and
* also size and placeholder attributes, which have the same meaning for this
* element as they do for the <input> element.
*
* Input events from the internal <input> element bubble up and appear with
* their target field set to the <search-box> element.
*
* The element fires a "search" event with the detail property set to the
* current input string when the user clicks on the left emoji (the magnifying
* glass). The "search" event is also dispatched when the internal text field
* generates a "change" event (when the text has changed and the user types
* Return or Tab).
*
* The element fires a "clear" event when the user clicks on the right emoji
* (the X). If no handler calls preventDefault() on the event then the element
* clears the user's input once event dispatch is complete.
*
* Note that there are no onsearch and onclear properties or attributes:
* handlers for the "search" and "clear" events can only be registered with
* addEventListener().
*/
class SearchBox extends HTMLElement {
constructor() {
super(); // Invoke the superclass constructor; must be first.
// Create a shadow DOM tree and attach it to this element, setting
// the value of this.shadowRoot.
this.attachShadow({mode: "open"});
// Clone the template that defines the descendants and stylesheet for
// this custom component, and append that content to the shadow root.
this.shadowRoot.append(SearchBox.template.content.cloneNode(true));
// Get references to the important elements in the shadow DOM
this.input = this.shadowRoot.querySelector("#input");
let leftSlot = this.shadowRoot.querySelector('slot[name="left"]');
let rightSlot = this.shadowRoot.querySelector('slot[name="right"]');
// When the internal input field gets or loses focus, set or remove
// the "focused" attribute which will cause our internal stylesheet
// to display or hide a fake focus ring on the entire component. Note
// that the "blur" and "focus" events bubble and appear to originate
// from the <search-box>.
this.input.onfocus = () => { this.setAttribute("focused", ""); };
this.input.onblur = () => { this.removeAttribute("focused");};
// If the user clicks on the magnifying glass, trigger a "search"
// event. Also trigger it if the input field fires a "change"
// event. (The "change" event does not bubble out of the Shadow DOM.)
leftSlot.onclick = this.input.onchange = (event) => {
event.stopPropagation(); // Prevent click events from bubbling
if (this.disabled) return; // Do nothing when disabled
this.dispatchEvent(new CustomEvent("search", {
detail: this.input.value
}));
};
// If the user clicks on the X, trigger a "clear" event.
// If preventDefault() is not called on the event, clear the input.
rightSlot.onclick = (event) => {
event.stopPropagation(); // Don't let the click bubble up
if (this.disabled) return; // Don't do anything if disabled
let e = new CustomEvent("clear", { cancelable: true });
this.dispatchEvent(e);
if (!e.defaultPrevented) { // If the event was not "cancelled"
this.input.value = ""; // then clear the input field
}
};
}
// When some of our attributes are set or changed, we need to set the
// corresponding value on the internal <input> element. This life cycle
// method, together with the static observedAttributes property below,
// takes care of that.
attributeChangedCallback(name, oldValue, newValue) {
if (name === "disabled") {
this.input.disabled = newValue !== null;
} else if (name === "placeholder") {
this.input.placeholder = newValue;
} else if (name === "size") {
this.input.size = newValue;
} else if (name === "value") {
this.input.value = newValue;
}
}
// Finally, we define property getters and setters for properties that
// correspond to the HTML attributes we support. The getters simply return
// the value (or the presence) of the attribute. And the setters just set
// the value (or the presence) of the attribute. When a setter method
// changes an attribute, the browser will automatically invoke the
// attributeChangedCallback above.
get placeholder() { return this.getAttribute("placeholder"); }
get size() { return this.getAttribute("size"); }
get value() { return this.getAttribute("value"); }
get disabled() { return this.hasAttribute("disabled"); }
get hidden() { return this.hasAttribute("hidden"); }
set placeholder(value) { this.setAttribute("placeholder", value); }
set size(value) { this.setAttribute("size", value); }
set value(text) { this.setAttribute("value", text); }
set disabled(value) {
if (value) this.setAttribute("disabled", "");
else this.removeAttribute("disabled");
}
set hidden(value) {
if (value) this.setAttribute("hidden", "");
else this.removeAttribute("hidden");
}
}
// This static field is required for the attributeChangedCallback method.
// Only attributes named in this array will trigger calls to that method.
SearchBox.observedAttributes = ["disabled", "placeholder", "size", "value"];
// Create a <template> element to hold the stylesheet and the tree of
// elements that we'll use for each instance of the SearchBox element.
SearchBox.template = document.createElement("template");
// We initialize the template by parsing this string of HTML. Note, however,
// that when we instantiate a SearchBox, we are able to just clone the nodes
// in the template and do have to parse the HTML again.
SearchBox.template.innerHTML = `
<style>
/*
* The :host selector refers to the <search-box> element in the light
* DOM. These styles are defaults and can be overridden by the user of the
* <search-box> with styles in the light DOM.
*/
:host {
display: inline-block; /* The default is inline display */
border: solid black 1px; /* A rounded border around the <input> and <slots> */
border-radius: 5px;
padding: 4px 6px; /* And some space inside the border */
}
:host([hidden]) { /* Note the parentheses: when host has hidden... */
display:none; /* ...attribute set don't display it */
}
:host([disabled]) { /* When host has the disabled attribute... */
opacity: 0.5; /* ...gray it out */
}
:host([focused]) { /* When host has the focused attribute... */
box-shadow: 0 0 2px 2px #6AE; /* display this fake focus ring. */
}
/* The rest of the stylesheet only applies to elements in the Shadow DOM. */
input {
border-width: 0; /* Hide the border of the internal input field. */
outline: none; /* Hide the focus ring, too. */
font: inherit; /* <input> elements don't inherit font by default */
background: inherit; /* Same for background color. */
}
slot {
cursor: default; /* An arrow pointer cursor over the buttons */
user-select: none; /* Don't let the user select the emoji text */
}
</style>
<div>
<slot name="left">\u{1f50d}</slot> <!-- U+1F50D is a magnifying glass -->
<input type="text" id="input" /> <!-- The actual input element -->
<slot name="right">\u{2573}</slot> <!-- U+2573 is an X -->
</div>
`;
// Finally, we call customElement.define() to register the SearchBox element
// as the implementation of the <search-box> tag. Custom elements are required
// to have a tag name that contains a hyphen.
customElements.define("search-box", SearchBox);
15.7 SVG:可缩放矢量图形
SVG(可缩放矢量图形)是一种图像格式。其名称中的“矢量”一词表明它与像 GIF、JPEG 和 PNG 这样指定像素值矩阵的位图图像格式 fundamentally fundamentally 不同。相反,SVG “图像”是绘制所需图形的步骤的精确、与分辨率无关(因此“可缩放”)描述。SVG 图像由使用 XML 标记语言的文本文件描述,这与 HTML 非常相似。
在 Web 浏览器中有三种使用 SVG 的方式:
-
您可以像使用 .png 或 .jpeg 图像一样使用 .svg 图像文件与常规 HTML
<img>标签。 -
由于基于 XML 的 SVG 格式与 HTML 如此相似,您实际上可以直接将 SVG 标记嵌入到 HTML 文档中。如果这样做,浏览器的 HTML 解析器允许您省略 XML 命名空间,并将 SVG 标记视为 HTML 标记。
-
您可以使用 DOM API 动态创建 SVG 元素以根据需要生成图像。
接下来的小节演示了 SVG 的第二和第三种用法。但请注意,SVG 具有庞大且稍微复杂的语法。除了简单的形状绘制原语外,它还包括对任意曲线、文本和动画的支持。SVG 图形甚至可以包含 JavaScript 脚本和 CSS 样式表,以添加行为和呈现信息。SVG 的完整描述远远超出了本书的范围。本节的目标只是向您展示如何在 HTML 文档中使用 SVG 并使用 JavaScript 进行脚本化。
15.7.1 HTML 中的 SVG
当然,SVG 图像可以使用 HTML <img>标签显示。但您也可以直接在 HTML 中嵌入 SVG。如果这样做,甚至可以使用 CSS 样式表来指定字体、颜色和线宽等内容。例如,这里是一个使用 SVG 显示模拟时钟表盘的 HTML 文件:
<html>
<head>
<title>Analog Clock</title>
<style>
/* These CSS styles all apply to the SVG elements defined below */
#clock { /* Styles for everything in the clock:*/
stroke: black; /* black lines */
stroke-linecap: round; /* with rounded ends */
fill: #ffe; /* on an off-white background */
}
#clock .face { stroke-width: 3; } /* Clock face outline */
#clock .ticks { stroke-width: 2; } /* Lines that mark each hour */
#clock .hands { stroke-width: 3; } /* How to draw the clock hands */
#clock .numbers { /* How to draw the numbers */
font-family: sans-serif; font-size: 10; font-weight: bold;
text-anchor: middle; stroke: none; fill: black;
}
</style>
</head>
<body>
<svg id="clock" viewBox="0 0 100 100" width="250" height="250">
<!-- The width and height attributes are the screen size of the graphic -->
<!-- The viewBox attribute gives the internal coordinate system -->
<circle class="face" cx="50" cy="50" r="45"/> <!-- the clock face -->
<g class="ticks"> <!-- tick marks for each of the 12 hours -->
<line x1='50' y1='5.000' x2='50.00' y2='10.00'/>
<line x1='72.50' y1='11.03' x2='70.00' y2='15.36'/>
<line x1='88.97' y1='27.50' x2='84.64' y2='30.00'/>
<line x1='95.00' y1='50.00' x2='90.00' y2='50.00'/>
<line x1='88.97' y1='72.50' x2='84.64' y2='70.00'/>
<line x1='72.50' y1='88.97' x2='70.00' y2='84.64'/>
<line x1='50.00' y1='95.00' x2='50.00' y2='90.00'/>
<line x1='27.50' y1='88.97' x2='30.00' y2='84.64'/>
<line x1='11.03' y1='72.50' x2='15.36' y2='70.00'/>
<line x1='5.000' y1='50.00' x2='10.00' y2='50.00'/>
<line x1='11.03' y1='27.50' x2='15.36' y2='30.00'/>
<line x1='27.50' y1='11.03' x2='30.00' y2='15.36'/>
</g>
<g class="numbers"> <!-- Number the cardinal directions-->
<text x="50" y="18">12</text><text x="85" y="53">3</text>
<text x="50" y="88">6</text><text x="15" y="53">9</text>
</g>
<g class="hands"> <!-- Draw hands pointing straight up. -->
<line class="hourhand" x1="50" y1="50" x2="50" y2="25"/>
<line class="minutehand" x1="50" y1="50" x2="50" y2="20"/>
</g>
</svg>
<script src="clock.js"></script>
</body>
</html>
您会注意到<svg>标签的后代不是普通的 HTML 标签。<circle>、<line>和<text>标签具有明显的目的,因此这个 SVG 图形的工作原理应该很清楚。然而,还有许多其他 SVG 标签,您需要查阅 SVG 参考资料以了解更多信息。您可能还会注意到样式表很奇怪。像fill、stroke-width和text-anchor这样的样式不是正常的 CSS 样式属性。在这种情况下,CSS 基本上用于设置文档中出现的 SVG 标签的属性。还要注意,CSS 的font简写属性不适用于 SVG 标签,您必须显式设置font-family、font-size和font-weight等单独的样式属性。
15.7.2 脚本化 SVG
将 SVG 直接嵌入 HTML 文件中(而不仅仅使用静态的<img>标签)的一个原因是,这样做可以使用 DOM API 来操纵 SVG 图像。假设您在 Web 应用程序中使用 SVG 显示图标。您可以在<template>标签中嵌入 SVG(§15.6.2),然后在需要将该图标的副本插入 UI 时克隆模板内容。如果您希望图标对用户活动做出响应——例如,当用户将指针悬停在其上时更改颜色——通常可以使用 CSS 实现。
还可以动态操作直接嵌入 HTML 中的 SVG 图形。前一节中的时钟示例显示了一个静态时钟,时针和分针指向正上方,显示中午或午夜时间。但您可能已经注意到 HTML 文件包含了一个<script>标签。该脚本定期运行一个函数来检查时间,并根据需要旋转时针和分针的适当角度,使时钟实际显示当前时间,如图 15-5 所示。

图 15-5. 一个脚本化的 SVG 模拟时钟
操纵时钟的代码很简单。它根据当前时间确定时针和分针的正确角度,然后使用querySelector()查找显示这些指针的 SVG 元素,然后在它们上设置transform属性以围绕时钟表盘的中心旋转它们。该函数使用setTimeout()确保它每分钟运行一次:
(function updateClock() { // Update the SVG clock graphic to show current time
let now = new Date(); // Current time
let sec = now.getSeconds(); // Seconds
let min = now.getMinutes() + sec/60; // Fractional minutes
let hour = (now.getHours() % 12) + min/60; // Fractional hours
let minangle = min * 6; // 6 degrees per minute
let hourangle = hour * 30; // 30 degrees per hour
// Get SVG elements for the hands of the clock
let minhand = document.querySelector("#clock .minutehand");
let hourhand = document.querySelector("#clock .hourhand");
// Set an SVG attribute on them to move them around the clock face
minhand.setAttribute("transform", `rotate(${minangle},50,50)`);
hourhand.setAttribute("transform", `rotate(${hourangle},50,50)`);
// Run this function again in 10 seconds
setTimeout(updateClock, 10000);
}()); // Note immediate invocation of the function here.
15.7.3 使用 JavaScript 创建 SVG 图像
除了简单地在 HTML 文档中嵌入脚本化的 SVG 图像外,您还可以从头开始构建 SVG 图像,这对于创建动态加载数据的可视化效果非常有用。示例 15-4 演示了如何使用 JavaScript 创建 SVG 饼图,就像在图 15-6 中显示的那样。
尽管 SVG 标记可以包含在 HTML 文档中,但它们在技术上是 XML 标记,而不是 HTML 标记,如果要使用 JavaScript DOM API 创建 SVG 元素,就不能使用在§15.3.5 中介绍的普通createElement()函数。相反,必须使用createElementNS(),它的第一个参数是 XML 命名空间字符串。对于 SVG,该命名空间是字面字符串“http://www.w3.org/2000/svg”。

图 15-6. 使用 JavaScript 构建的 SVG 饼图(数据来自 Stack Overflow 的 2018 年开发者调查最受欢迎技术)
除了使用createElementNS()之外,示例 15-4 中的饼图绘制代码相对简单。有一点数学计算将被绘制的数据转换为饼图角度。然而,示例的大部分是创建 SVG 元素并在这些元素上设置属性的 DOM 代码。
这个示例中最不透明的部分是绘制实际饼图片段的代码。用于显示每个片段的元素是<path>。这个 SVG 元素描述由线条和曲线组成的任意形状。形状描述由<path>元素的d属性指定。该属性的值使用字母代码和数字的紧凑语法,指定坐标、角度和其他值。例如,字母 M 表示“移动到”,后面跟着x和y坐标。字母 L 表示“线到”,从当前点画一条线到其后面的坐标。这个示例还使用字母 A 来绘制弧线。这个字母后面跟着描述弧线的七个数字,如果想了解更多,可以在线查找语法。
示例 15-4. 使用 JavaScript 和 SVG 绘制饼图
/**
* Create an <svg> element and draw a pie chart into it.
*
* This function expects an object argument with the following properties:
*
* width, height: the size of the SVG graphic, in pixels
* cx, cy, r: the center and radius of the pie
* lx, ly: the upper-left corner of the chart legend
* data: an object whose property names are data labels and whose
* property values are the values associated with each label
*
* The function returns an <svg> element. The caller must insert it into
* the document in order to make it visible.
*/
function pieChart(options) {
let {width, height, cx, cy, r, lx, ly, data} = options;
// This is the XML namespace for svg elements
let svg = "http://www.w3.org/2000/svg";
// Create the <svg> element, and specify pixel size and user coordinates
let chart = document.createElementNS(svg, "svg");
chart.setAttribute("width", width);
chart.setAttribute("height", height);
chart.setAttribute("viewBox", `0 0 ${width} ${height}`);
// Define the text styles we'll use for the chart. If we leave these
// values unset here, they can be set with CSS instead.
chart.setAttribute("font-family", "sans-serif");
chart.setAttribute("font-size", "18");
// Get labels and values as arrays and add up the values so we know how
// big the pie is.
let labels = Object.keys(data);
let values = Object.values(data);
let total = values.reduce((x,y) => x+y);
// Figure out the angles for all the slices. Slice i starts at angles[i]
// and ends at angles[i+1]. The angles are measured in radians.
let angles = [0];
values.forEach((x, i) => angles.push(angles[i] + x/total * 2 * Math.PI));
// Now loop through the slices of the pie
values.forEach((value, i) => {
// Compute the two points where our slice intersects the circle
// These formulas are chosen so that an angle of 0 is at 12 o'clock
// and positive angles increase clockwise.
let x1 = cx + r * Math.sin(angles[i]);
let y1 = cy - r * Math.cos(angles[i]);
let x2 = cx + r * Math.sin(angles[i+1]);
let y2 = cy - r * Math.cos(angles[i+1]);
// This is a flag for angles larger than a half circle
// It is required by the SVG arc drawing component
let big = (angles[i+1] - angles[i] > Math.PI) ? 1 : 0;
// This string describes how to draw a slice of the pie chart:
let path = `M${cx},${cy}` + // Move to circle center.
`L${x1},${y1}` + // Draw line to (x1,y1).
`A${r},${r} 0 ${big} 1` + // Draw an arc of radius r...
`${x2},${y2}` + // ...ending at to (x2,y2).
"Z"; // Close path back to (cx,cy).
// Compute the CSS color for this slice. This formula works for only
// about 15 colors. So don't include more than 15 slices in a chart.
let color = `hsl(${(i*40)%360},${90-3*i}%,${50+2*i}%)`;
// We describe a slice with a <path> element. Note createElementNS().
let slice = document.createElementNS(svg, "path");
// Now set attributes on the <path> element
slice.setAttribute("d", path); // Set the path for this slice
slice.setAttribute("fill", color); // Set slice color
slice.setAttribute("stroke", "black"); // Outline slice in black
slice.setAttribute("stroke-width", "1"); // 1 CSS pixel thick
chart.append(slice); // Add slice to chart
// Now draw a little matching square for the key
let icon = document.createElementNS(svg, "rect");
icon.setAttribute("x", lx); // Position the square
icon.setAttribute("y", ly + 30*i);
icon.setAttribute("width", 20); // Size the square
icon.setAttribute("height", 20);
icon.setAttribute("fill", color); // Same fill color as slice
icon.setAttribute("stroke", "black"); // Same outline, too.
icon.setAttribute("stroke-width", "1");
chart.append(icon); // Add to the chart
// And add a label to the right of the rectangle
let label = document.createElementNS(svg, "text");
label.setAttribute("x", lx + 30); // Position the text
label.setAttribute("y", ly + 30*i + 16);
label.append(`${labels[i]} ${value}`); // Add text to label
chart.append(label); // Add label to the chart
});
return chart;
}
图 15-6 中的饼图是使用示例 15-4 中的pieChart()函数创建的,如下所示:
document.querySelector("#chart").append(pieChart({
width: 640, height:400, // Total size of the chart
cx: 200, cy: 200, r: 180, // Center and radius of the pie
lx: 400, ly: 10, // Position of the legend
data: { // The data to chart
"JavaScript": 71.5,
"Java": 45.4,
"Bash/Shell": 40.4,
"Python": 37.9,
"C#": 35.3,
"PHP": 31.4,
"C++": 24.6,
"C": 22.1,
"TypeScript": 18.3,
"Ruby": 10.3,
"Swift": 8.3,
"Objective-C": 7.3,
"Go": 7.2,
}
}));
15.8
<canvas>元素本身没有自己的外观,但在文档中创建了一个绘图表面,并向客户端 JavaScript 公开了强大的绘图 API。<canvas> API 与 SVG 之间的主要区别在于,使用 canvas 时通过调用方法创建绘图,而使用 SVG 时通过构建 XML 元素树创建绘图。这两种方法具有同等的强大功能:任何一种都可以模拟另一种。然而,在表面上,它们是非常不同的,每种方法都有其优势和劣势。例如,SVG 图形很容易通过从描述中删除元素来编辑。要从<canvas>中的相同图形中删除元素,通常需要擦除绘图并从头开始重绘。由于 Canvas 绘图 API 基于 JavaScript 且相对紧凑(不像 SVG 语法),因此在本书中对其进行了更详细的文档记录。
大部分 Canvas 绘图 API 并不是在<canvas>元素本身上定义的,而是在通过 canvas 的getContext()方法获得的“绘图上下文”对象上定义的。使用参数“2d”调用getContext()以获得一个 CanvasRenderingContext2D 对象,您可以使用它将二维图形绘制到画布上。
作为 Canvas API 的一个简单示例,以下 HTML 文档使用<canvas>元素和一些 JavaScript 来显示两个简单的形状:
<p>This is a red square: <canvas id="square" width=10 height=10></canvas>.
<p>This is a blue circle: <canvas id="circle" width=10 height=10></canvas>.
<script>
let canvas = document.querySelector("#square"); // Get first canvas element
let context = canvas.getContext("2d"); // Get 2D drawing context
context.fillStyle = "#f00"; // Set fill color to red
context.fillRect(0,0,10,10); // Fill a square
canvas = document.querySelector("#circle"); // Second canvas element
context = canvas.getContext("2d"); // Get its context
context.beginPath(); // Begin a new "path"
context.arc(5, 5, 5, 0, 2*Math.PI, true); // Add a circle to the path
context.fillStyle = "#00f"; // Set blue fill color
context.fill(); // Fill the path
</script>
我们已经看到 SVG 将复杂形状描述为由线条和曲线组成的“路径”。Canvas API 也使用路径的概念。路径不是通过字母和数字的字符串描述,而是通过一系列方法调用来定义,例如前面代码中的beginPath()和arc()调用。一旦定义了路径,其他方法,如fill(),就会对该路径进行操作。上下文对象的各种属性,如fillStyle,指定了这些操作是如何执行的。
接下来的小节演示了 2D Canvas API 的方法和属性。后面的示例代码大部分操作一个名为c的变量。这个变量保存了画布的 CanvasRenderingContext2D 对象,但有时初始化该变量的代码并没有显示。为了使这些示例运行,你需要添加 HTML 标记来定义一个带有适当width和height属性的画布,然后添加像这样的代码来初始化变量c:
let canvas = document.querySelector("#my_canvas_id");
let c = canvas.getContext('2d');
15.8.1 路径和多边形
在画布上绘制线条并填充由这些线条围起来的区域时,首先需要定义一个路径。路径是一个或多个子路径的序列。子路径是由线段(或者后面我们将看到的曲线段)连接的两个或多个点的序列。使用beginPath()方法开始一个新路径。使用moveTo()方法开始一个新的子路径。一旦用moveTo()确定了子路径的起始点,你可以通过调用lineTo()将该点连接到一个新点形成一条直线。以下代码定义了包含两条线段的路径:
c.beginPath(); // Start a new path
c.moveTo(100, 100); // Begin a subpath at (100,100)
c.lineTo(200, 200); // Add a line from (100,100) to (200,200)
c.lineTo(100, 200); // Add a line from (200,200) to (100,200)
这段代码仅仅定义了一个路径;它并没有在画布上绘制任何东西。要绘制(或“描边”)路径中的两条线段,调用stroke()方法;要填充由这些线段定义的区域,调用fill():
c.fill(); // Fill a triangular area
c.stroke(); // Stroke two sides of the triangle
这段代码(以及一些额外的用于设置线宽和填充颜色的代码)生成了图 15-7 中显示的图形。

图 15-7. 一个简单的路径,填充和描边
注意在图 15-7 中定义的子路径是“开放”的。它只包含两条线段,结束点没有连接回起始点。这意味着它没有围起一个区域。fill()方法通过假设一条直线连接子路径中的最后一个点和第一个点来填充开放的子路径。这就是为什么这段代码填充了一个三角形,但只描绘了三角形的两条边。
如果你想要描绘刚才显示的三角形的所有三条边,你可以调用closePath()方法将子路径的结束点连接到起始点。(你也可以调用lineTo(100,100),但那样你会得到三条共享起始点和结束点但并非真正闭合的线段。当使用宽线条绘制时,如果使用closePath()效果更好。)
还有另外两点关于stroke()和fill()需要注意。首先,这两个方法都作用于当前路径中的所有子路径。假设我们在前面的代码中添加了另一个子路径:
c.moveTo(300,100); // Begin a new subpath at (300,100);
c.lineTo(300,200); // Draw a vertical line down to (300,200);
如果我们随后调用了stroke(),我们将绘制一个三角形的两条相连边和一条不相连的垂直线。
关于stroke()和fill()的第二点是,它们都不会改变当前路径:你可以调用fill(),而当你调用stroke()时,路径仍然存在。当你完成一个路径并想要开始另一个路径时,你必须记得调用beginPath()。如果不这样做,你将不断向现有路径添加新的子路径,并且可能会一遍又一遍地绘制那些旧的子路径。
示例 15-5 定义了一个用于绘制正多边形的函数,并演示了使用moveTo()、lineTo()和closePath()定义子路径以及使用fill()和stroke()绘制这些路径。它生成了图 15-8 中显示的图形。

图 15-8. 正多边形
示例 15-5. 使用 moveTo()、lineTo()和 closePath()绘制正多边形
// Define a regular polygon with n sides, centered at (x,y) with radius r.
// The vertices are equally spaced along the circumference of a circle.
// Put the first vertex straight up or at the specified angle.
// Rotate clockwise, unless the last argument is true.
function polygon(c, n, x, y, r, angle=0, counterclockwise=false) {
c.moveTo(x + r*Math.sin(angle), // Begin a new subpath at the first vertex
y - r*Math.cos(angle)); // Use trigonometry to compute position
let delta = 2*Math.PI/n; // Angular distance between vertices
for(let i = 1; i < n; i++) { // For each of the remaining vertices
angle += counterclockwise?-delta:delta; // Adjust angle
c.lineTo(x + r*Math.sin(angle), // Add line to next vertex
y - r*Math.cos(angle));
}
c.closePath(); // Connect last vertex back to the first
}
// Assume there is just one canvas, and get its context object to draw with.
let c = document.querySelector("canvas").getContext("2d");
// Start a new path and add polygon subpaths
c.beginPath();
polygon(c, 3, 50, 70, 50); // Triangle
polygon(c, 4, 150, 60, 50, Math.PI/4); // Square
polygon(c, 5, 255, 55, 50); // Pentagon
polygon(c, 6, 365, 53, 50, Math.PI/6); // Hexagon
polygon(c, 4, 365, 53, 20, Math.PI/4, true); // Small square inside the hexagon
// Set some properties that control how the graphics will look
c.fillStyle = "#ccc"; // Light gray interiors
c.strokeStyle = "#008"; // outlined with dark blue lines
c.lineWidth = 5; // five pixels wide.
// Now draw all the polygons (each in its own subpath) with these calls
c.fill(); // Fill the shapes
c.stroke(); // And stroke their outlines
请注意,此示例绘制了一个六边形,内部有一个正方形。正方形和六边形是分开的子路径,但它们重叠。当发生这种情况(或者当单个子路径相交时),画布需要能够确定哪些区域在路径内部,哪些在外部。画布使用称为“非零环绕规则”的测试来实现这一点。在这种情况下,正方形的内部没有填充,因为正方形和六边形是以相反的方向绘制的:六边形的顶点是沿着圆周顺时针连接的线段。正方形的顶点是逆时针连接的。如果正方形也是顺时针绘制的,那么调用fill()将填充正方形的内部。
15.8.2 画布尺寸和坐标
<canvas>元素的width和height属性以及 Canvas 对象的对应width和height属性指定了画布的尺寸。默认的画布坐标系统将原点(0,0)放在画布的左上角。x坐标向右增加,y坐标向下增加。可以使用浮点值指定画布上的点。
画布的尺寸不能在不完全重置画布的情况下进行更改。设置 Canvas 的width或height属性(即使将它们设置为当前值)都会清除画布,擦除当前路径,并将所有图形属性(包括当前变换和裁剪区域)重置为其原始状态。
画布的width和height属性指定了画布可以绘制的实际像素数。每个像素分配了四个字节的内存,因此如果width和height都设置为 100,画布将分配 40,000 字节来表示 10,000 个像素。
width和height属性还指定了画布在屏幕上显示的默认大小(以 CSS 像素为单位)。如果window.devicePixelRatio为 2,则 100×100 个 CSS 像素实际上是 40,000 个硬件像素。当画布的内容绘制到屏幕上时,内存中的 10,000 个像素需要放大到覆盖屏幕上的 40,000 个物理像素,这意味着您的图形不会像它们本应该那样清晰。
为了获得最佳的图像质量,您不应该使用width和height属性来设置画布的屏幕大小。相反,应该使用 CSS 的width和height样式属性设置画布的所需屏幕大小的 CSS 像素大小。然后,在开始 JavaScript 代码绘制之前,将画布对象的width和height属性设置为 CSS 像素乘以window.devicePixelRatio的数量。继续前面的例子,这种技术会导致画布显示为 100×100 个 CSS 像素,但分配内存为 200×200 个像素。(即使使用这种技术,用户也可以放大画布,如果放大,可能会看到模糊或像素化的图形。这与 SVG 图形形成对比,无论屏幕大小或缩放级别如何,SVG 图形始终保持清晰。)
15.8.3 图形属性
示例 15-5 在画布的上下文对象上设置了 fillStyle、strokeStyle 和 lineWidth 属性。这些属性是指定由 fill() 和 stroke() 使用的颜色以及由 stroke() 绘制的线条的宽度的图形属性。请注意,这些参数不是传递给 fill() 和 stroke() 方法的,而是画布的一般 图形状态 的一部分。如果定义了一个绘制形状的方法,并且没有自己设置这些属性,那么调用该方法的调用者可以在调用方法之前通过设置 strokeStyle 和 fillStyle 属性来定义形状的颜色。图形状态与绘图命令的分离是 Canvas API 的基础,并类似于通过将 CSS 样式表应用于 HTML 文档来实现的演示与内容的分离。
画布的上下文对象上有许多属性(以及一些方法),它们会影响画布的图形状态。下面详细介绍了它们。
线条样式
lineWidth 属性指定了 stroke() 绘制的线条的宽度(以 CSS 像素为单位)。默认值为 1。重要的是要理解线条宽度是在调用 stroke() 时由 lineWidth 属性确定的,而不是在调用 lineTo() 和其他构建路径方法时确定的。要完全理解 lineWidth 属性,重要的是将路径视为无限细的一维线条。stroke() 方法绘制的线条和曲线位于路径的中心,lineWidth 的一半位于路径的两侧。如果要描边一个闭合路径,并且只希望线条出现在路径外部,先描边路径,然后用不透明颜色填充以隐藏出现在路径内部的描边部分。或者如果只希望线条出现在闭合路径内部,先调用 save() 和 clip() 方法,然后调用 stroke() 和 restore()。(save()、restore() 和 clip() 方法将在后面描述。)
当绘制宽度超过大约两个像素的线条时,lineCap 和 lineJoin 属性会对路径端点的视觉外观以及两个路径段相遇的顶点产生显著影响。图 15-9 展示了 lineCap 和 lineJoin 的值及其结果的图形外观。

图 15-9. lineCap 和 lineJoin 属性
lineCap 的默认值为“butt”。lineJoin 的默认值为“miter”。但是,请注意,如果两条线以非常狭窄的角度相交,则结果的斜接可能会变得非常长并且在视觉上会分散注意力。如果给定顶点处的斜接长度超过线宽的一半乘以 miterLimit 属性的值,那么该顶点将以斜角连接而不是斜接连接绘制。miterLimit 的默认值为 10。
stroke() 方法可以绘制虚线、点线以及实线,画布的图形状态包括一个作为“虚线模式”的数字数组,指定要绘制多少像素,然后要省略多少像素。与其他线条绘制属性不同,虚线模式是使用 setLineDash() 和 getLineDash() 方法设置和查询的,而不是使用属性。要指定一个点线模式,可以像这样使用 setLineDash():
c.setLineDash([18, 3, 3, 3]); // 18px dash, 3px space, 3px dot, 3px space
最后,lineDashOffset 属性指定了从哪里开始绘制虚线模式。默认值为 0。使用这里显示的虚线模式描绘的路径以一个 18 像素的虚线开始,但如果将 lineDashOffset 设置为 21,则相同的路径将以一个点开始,然后是一个空格和一个虚线。
颜色、图案和渐变
fillStyle和strokeStyle属性指定如何填充和描边路径。单词“style”通常表示颜色,但这些属性也可用于指定颜色渐变或用于填充和描边的图像。(请注意,绘制线基本上与在线两侧填充一个窄区域相同,填充和描边本质上是相同的操作。)
如果要使用纯色(或半透明颜色)进行填充或描边,只需将这些属性设置为有效的 CSS 颜色字符串即可。不需要其他操作。
要使用颜色渐变进行填充(或描边),将fillStyle(或strokeStyle)设置为上下文的createLinearGradient()或createRadialGradient()方法返回的 CanvasGradient 对象。createLinearGradient()的参数是定义颜色沿其变化的线的两点的坐标(它不需要是水平或垂直的)。createRadialGradient()的参数指定两个圆的中心和半径。(它们不需要同心,但第一个圆通常完全位于第二个圆内部。)小圆内部或大圆外部的区域将填充为纯色;两者之间的区域将填充为颜色渐变。
创建定义将填充画布区域的 CanvasGradient 对象后,必须通过调用 CanvasGradient 的addColorStop()方法来定义渐变颜色。该方法的第一个参数是介于 0.0 和 1.0 之间的数字。第二个参数是 CSS 颜色规范。您必须至少调用此方法两次来定义简单的颜色渐变,但可以调用多次。0.0 处的颜色将出现在渐变的起始处,而 1.0 处的颜色将出现在结束处。如果指定了其他颜色,它们将出现在渐变中指定的分数位置。在您指定的点之间,颜色将平滑插值。以下是一些示例:
// A linear gradient, diagonally across the canvas (assuming no transforms)
let bgfade = c.createLinearGradient(0,0,canvas.width,canvas.height);
bgfade.addColorStop(0.0, "#88f"); // Start with light blue in upper left
bgfade.addColorStop(1.0, "#fff"); // Fade to white in lower right
// A gradient between two concentric circles. Transparent in the middle
// fading to translucent gray and then back to transparent.
let donut = c.createRadialGradient(300,300,100, 300,300,300);
donut.addColorStop(0.0, "transparent"); // Transparent
donut.addColorStop(0.7, "rgba(100,100,100,.9)"); // Translucent gray
donut.addColorStop(1.0, "rgba(0,0,0,0)"); // Transparent again
关于渐变的一个重要点是,它们不是位置无关的。创建渐变时,您为渐变指定边界。如果您尝试填充超出这些边界的区域,您将得到渐变的一端或另一端定义的纯色。
除了颜色和颜色渐变外,您还可以使用图像进行填充和描边。要实现这一点,将fillStyle或strokeStyle设置为上下文对象的createPattern()方法返回的 CanvasPattern。该方法的第一个参数应为包含您要填充或描边的图像的<img>或<canvas>元素。(请注意,源图像或画布不需要插入文档中才能以这种方式使用。)createPattern()的第二个参数是字符串“repeat”,“repeat-x”,“repeat-y”或“no-repeat”,指定背景图像是否(以及在哪些维度上)重复。
文本样式
font属性指定文本绘制方法fillText()和strokeText()使用的字体(请参阅“文本”)。font属性的值应为与 CSS font属性相同语法的字符串。
textAlign属性指定文本在调用fillText()或strokeText()时相对于传递给 X 坐标的水平对齐方式。合法值为“start”,“left”,“center”,“right”和“end”。默认值为“start”,对于从左到右的文本,其含义与“left”相同。
textBaseline属性指定文本在y坐标上如何与垂直对齐。默认值为“alphabetic”,适用于拉丁文和类似脚本。值“ideographic”适用于中文和日文等脚本。值“hanging”适用于梵文和类似脚本(用于印度许多语言)。“top”、“middle”和“bottom”基线纯粹是几何基线,基于字体的“em 方块”。
阴影
上下文对象的四个属性控制阴影的绘制。如果适当设置这些属性,你绘制的任何线条、区域、文本或图像都将产生阴影,使其看起来好像漂浮在画布表面之上。
shadowColor属性指定阴影的颜色。默认为完全透明的黑色,除非将此属性设置为半透明或不透明颜色,否则阴影将不会出现。此属性只能设置为颜色字符串:不允许使用图案和渐变来创建阴影。使用半透明阴影颜色会产生最逼真的阴影效果,因为它允许背景透过阴影显示出来。
shadowOffsetX和shadowOffsetY属性指定阴影的 X 和 Y 偏移量。两个属性的默认值都为 0,将阴影直接放在你的绘图下方,看不见。如果将这两个属性都设置为正值,阴影将出现在你绘制的下方和右侧,就好像有一个光源在屏幕外部的左上方映射到画布上。较大的偏移量会产生更大的阴影,并使绘制的对象看起来好像漂浮在画布上方。这些值不受坐标变换的影响(§15.8.5):阴影方向和“高度”保持一致,即使形状被旋转和缩放。
shadowBlur属性指定阴影边缘的模糊程度。默认值为 0,产生清晰、未模糊的阴影。较大的值会产生更多模糊,直到达到一个实现定义的上限。
半透明和合成
如果你想使用半透明颜色描边或填充路径,可以使用支持 alpha 透明度的 CSS 颜色语法,如“rgba(…)”来设置strokeStyle或fillStyle。 “RGBA”中的“a”代表“alpha”,取值范围在 0(完全透明)和 1(完全不透明)之间。但 Canvas API 提供了另一种处理半透明颜色的方式。如果你不想为每种颜色显式指定 alpha 通道,或者想要向不透明图像或图案添加半透明度,可以设置globalAlpha属性。你绘制的每个像素的 alpha 值都将乘以globalAlpha。默认值为 1,不添加透明度。如果将globalAlpha设置为 0,则绘制的所有内容将完全透明,画布上将不会显示任何内容。但如果将此属性设置为 0.5,则原本不透明的像素将变为 50% 不透明,原本 50% 不透明的像素将变为 25% 不透明。
当你描边线条、填充区域、绘制文本或复制图像时,通常期望新像素绘制在已经存在于画布中的像素之上。如果绘制的是不透明像素,它们将简单地替换已经存在的像素。如果绘制的是半透明像素,则新的(“源”)像素将与旧的(“目标”)像素结合,使旧像素透过新像素显示出来,透明度取决于该像素的透明度。
将新的(可能是半透明的)源像素与现有的(可能是半透明的)目标像素组合的过程称为合成,先前描述的合成过程是 Canvas API 结合像素的默认方式。但是,您可以设置globalCompositeOperation属性以指定其他组合像素的方式。默认值是“source-over”,这意味着源像素被绘制在目标像素“上方”,如果源是半透明的,则与目标像素组合。但是,如果将globalCompositeOperation设置为“destination-over”,则画布将像新的源像素被绘制在现有目标像素下方一样组合像素。如果目标是半透明或透明的,则结果颜色中的一些或全部源像素颜色是可见的。作为另一个示例,合成模式“source-atop”将源像素与目标像素的透明度组合,以便在已完全透明的画布部分上不绘制任何内容。globalCompositeOperation有许多合法值,但大多数只有专门用途,这里里不涵盖。
保存和恢复图形状态
由于 Canvas API 在上下文对象上定义了图形属性,您可能会尝试多次调用getContext()以获取多个上下文对象。如果可以这样做,您可以在每个上下文中定义不同的属性:每个上下文将像不同的画笔一样,可以使用不同的颜色绘制或绘制不同宽度的线条。不幸的是,您不能以这种方式使用画布。每个<canvas>元素只有一个上下文对象,每次调用getContext()都会返回相同的 CanvasRenderingContext2D 对象。
尽管 Canvas API 只允许您一次定义一组图形属性,但它允许您保存当前的图形状态,以便稍后可以更改它并轻松地恢复它。save()方法将当前的图形状态推送到保存状态的堆栈上。restore()方法弹出堆栈并恢复最近保存的状态。本节中描述的所有属性都是保存状态的一部分,当前的变换和裁剪区域也是如此(稍后将对两者进行解释)。重要的是,当前定义的路径和当前点不是图形状态的一部分,不能保存和恢复。
15.8.4 画布绘图操作
我们已经看到了一些基本的画布方法——beginPath()、moveTo()、lineTo()、closePath()、fill() 和 stroke()——用于定义、填充和绘制线条和多边形。但 Canvas API 还包括其他绘图方法。
矩形
CanvasRenderingContext2D 定义了四种绘制矩形的方法。这四种矩形方法都需要两个参数,指定矩形的一个角,然后是矩形的宽度和高度。通常,您指定左上角,然后传递正宽度和正高度,但也可以指定其他角并传递负尺寸。
fillRect() 使用当前的fillStyle填充指定的矩形。strokeRect() 使用当前的strokeStyle和其他线条属性描绘指定矩形的轮廓。clearRect() 类似于fillRect(),但它忽略当前的填充样式,并用透明黑色像素(所有空画布的默认颜色)填充矩形。这三种方法的重要之处在于它们不会影响当前路径或路径中的当前点。
最后一个矩形方法被命名为rect(),它会影响当前路径:它将指定的矩形添加到路径的子路径中。与其他定义路径方法一样,它本身不填充或描边任何内容。
曲线
路径是子路径的序列,子路径是连接点的序列。在我们在§15.8.1 中定义的路径中,这些点是用直线段连接的,但这并不总是这样。CanvasRenderingContext2D 对象定义了许多方法,这些方法向子路径添加一个新点,并使用曲线将当前点连接到该新点:
arc()
这种方法向路径中添加一个圆或圆的一部分(弧)。要绘制的圆弧由六个参数指定:圆的中心的x和y坐标,圆的半径,圆弧的起始和结束角度,以及这两个角度之间的圆弧的方向(顺时针或逆时针)。如果路径中有当前点,则此方法将当前点与圆弧的起始点用一条直线连接(在绘制楔形或饼状图时很有用),然后将圆弧的起始点与圆弧的结束点用一部分圆连接起来,将圆弧的结束点作为新的当前点。如果在调用此方法时没有当前点,则它只会将圆弧添加到路径中。
ellipse()
这种方法与arc()非常相似,但它向路径中添加一个椭圆或椭圆的一部分。它有两个半径而不是一个:一个x轴半径和一个y轴半径。此外,由于椭圆不是径向对称的,因此此方法需要另一个参数,指定椭圆围绕其中心顺时针旋转的弧度数。
arcTo()
这种方法绘制一条直线和一个圆弧,就像arc()方法一样,但它使用不同的参数指定要绘制的圆弧。arcTo()的参数指定了点 P1 和 P2 以及半径。添加到路径中的圆弧具有指定的半径。它从当前点到 P1 的切线点开始,并在 P1 和 P2 之间的(虚拟)线的切线点结束。这种看似不寻常的指定圆弧的方法实际上非常有用,用于绘制具有圆角的形状。如果指定半径为 0,此方法只会从当前点画一条直线到 P1。然而,如果半径不为零,则它会从当前点沿着 P1 的方向画一条直线,然后将该线围绕成一个圆,直到指向 P2 的方向。
bezierCurveTo()
这种方法向子路径添加一个新点 P,并使用三次贝塞尔曲线将其连接到当前点。曲线的形状由两个“控制点”C1 和 C2 指定。在曲线的起始点(当前点处),曲线朝向 C1 的方向。在曲线的结束点(点 P 处),曲线从 C2 的方向到达。在这些点之间,曲线的方向平滑变化。点 P 成为子路径的新当前点。
quadraticCurveTo()
这种方法类似于bezierCurveTo(),但它使用二次贝塞尔曲线而不是三次贝塞尔曲线,并且只有一个控制点。
您可以使用这些方法绘制类似于图 15-10 中的路径。

图 15-10。画布中的曲线路径
示例 15-6 显示了用于创建图 15-10 的代码。此代码中演示的方法是 Canvas API 中最复杂的方法之一;请参考在线参考资料以获取有关这些方法及其参数的完整详细信息。
示例 15-6。向路径添加曲线
// A utility function to convert angles from degrees to radians
function rads(x) { return Math.PI*x/180; }
// Get the context object of the document's canvas element
let c = document.querySelector("canvas").getContext("2d");
// Define some graphics attributes and draw the curves
c.fillStyle = "#aaa"; // Gray fills
c.lineWidth = 2; // 2-pixel black (by default) lines
// Draw a circle.
// There is no current point, so draw just the circle with no straight
// line from the current point to the start of the circle.
c.beginPath();
c.arc(75,100,50, // Center at (75,100), radius 50
0,rads(360),false); // Go clockwise from 0 to 360 degrees
c.fill(); // Fill the circle
c.stroke(); // Stroke its outline.
// Now draw an ellipse in the same way
c.beginPath(); // Start new path not connected to the circle
c.ellipse(200, 100, 50, 35, rads(15), // Center, radii, and rotation
0, rads(360), false); // Start angle, end angle, direction
// Draw a wedge. Angles are measured clockwise from the positive x axis.
// Note that arc() adds a line from the current point to the arc start.
c.moveTo(325, 100); // Start at the center of the circle.
c.arc(325, 100, 50, // Circle center and radius
rads(-60), rads(0), // Start at angle -60 and go to angle 0
true); // counterclockwise
c.closePath(); // Add radius back to the center of the circle
// Similar wedge, offset a bit, and in the opposite direction
c.moveTo(340, 92);
c.arc(340, 92, 42, rads(-60), rads(0), false);
c.closePath();
// Use arcTo() for rounded corners. Here we draw a square with
// upper left corner at (400,50) and corners of varying radii.
c.moveTo(450, 50); // Begin in the middle of the top edge.
c.arcTo(500,50,500,150,30); // Add part of top edge and upper right corner.
c.arcTo(500,150,400,150,20); // Add right edge and lower right corner.
c.arcTo(400,150,400,50,10); // Add bottom edge and lower left corner.
c.arcTo(400,50,500,50,0); // Add left edge and upper left corner.
c.closePath(); // Close path to add the rest of the top edge.
// Quadratic Bezier curve: one control point
c.moveTo(525, 125); // Begin here
c.quadraticCurveTo(550, 75, 625, 125); // Draw a curve to (625, 125)
c.fillRect(550-3, 75-3, 6, 6); // Mark the control point (550,75)
// Cubic Bezier curve
c.moveTo(625, 100); // Start at (625, 100)
c.bezierCurveTo(645,70,705,130,725,100); // Curve to (725, 100)
c.fillRect(645-3, 70-3, 6, 6); // Mark control points
c.fillRect(705-3, 130-3, 6, 6);
// Finally, fill the curves and stroke their outlines.
c.fill();
c.stroke();
文本
要在画布中绘制文本,通常使用fillText()方法,该方法使用fillStyle属性指定的颜色(或渐变或图案)绘制文本。对于大文本尺寸的特殊效果,可以使用strokeText()绘制单个字体字形的轮廓。这两种方法的第一个参数是要绘制的文本,第二个和第三个参数是文本的x和y坐标。这两种方法都不会影响当前路径或当前点。
fillText()和strokeText()接受一个可选的第四个参数。如果提供了这个参数,则指定要显示的文本的最大宽度。如果使用font属性绘制的文本宽度超过指定值,画布将通过缩放或使用更窄或更小的字体来适应它。
如果需要在绘制文本之前自行测量文本,请将其传递给measureText()方法。该方法返回一个指定使用当前font绘制时文本测量的 TextMetrics 对象。在撰写本文时,TextMetrics对象中唯一包含的“度量”是宽度。像这样查询字符串的屏幕宽度:
let width = c.measureText(text).width;
如果您想在画布中居中显示一串文本,这将非常有用。
图像
除了矢量图形(路径、线条等)外,Canvas API 还支持位图图像。drawImage()方法将源图像的像素(或源图像内的矩形)复制到画布上,并根据需要对图像的像素进行缩放和旋转。
drawImage()可以使用三、五或九个参数调用。在所有情况下,第一个参数都是要复制像素的源图像。这个图像参数通常是一个<img>元素,但也可以是另一个<canvas>元素,甚至是一个<video>元素(从中将复制一帧)。如果指定的<img>或<video>元素仍在加载数据,则drawImage()调用将不起作用。
在drawImage()的三参数版本中,第二个和第三个参数指定要绘制图像左上角的x和y坐标。在此方法的版本中,整个源图像都会被复制到画布上。x和y坐标在当前坐标系中解释,并且根据当前生效的画布变换,必要时会对图像进行缩放和旋转。
drawImage()的五参数版本在前述的x和y参数中添加了width和height参数。这四个参数定义了画布内的目标矩形。源图像的左上角位于(x,y),右下角位于(x+width, y+height)。同样,整个源图像都会被复制。使用此方法的版本,源图像将被缩放以适应目标矩形。
drawImage()的九参数版本同时指定源矩形和目标矩形,并仅复制源矩形内的像素。第二至第五个参数指定源矩形,它们以 CSS 像素为单位。如果源图像是另一个画布,则源矩形使用该画布的默认坐标系,并忽略已指定的任何变换。第六至第九个参数指定将绘制图像的目标矩形,并且以画布的当前坐标系而不是默认坐标系为准。
除了将图像绘制到画布中,我们还可以使用 toDataURL() 方法将画布的内容提取为图像。与这里描述的所有其他方法不同,toDataURL() 是 Canvas 元素本身的方法,而不是上下文对象的方法。通常不带参数调用 toDataURL(),它会将画布的内容作为 PNG 图像编码为字符串返回,使用 data: URL。返回的 URL 适用于 <img> 元素的使用,您可以使用类似以下代码对画布进行静态快照:
let img = document.createElement("img"); // Create an <img> element
img.src = canvas.toDataURL(); // Set its src attribute
document.body.appendChild(img); // Append it to the document
15.8.5 坐标系变换
正如我们所指出的,画布的默认坐标系将原点放在左上角,x 坐标向右增加,y 坐标向下增加。在此默认系统中,点的坐标直接映射到 CSS 像素(然后直接映射到一个或多个设备像素)。某些画布操作和属性(例如提取原始像素值和设置阴影偏移)始终使用此默认坐标系。除了默认坐标系外,每个画布还有一个“当前变换矩阵”作为其图形状态的一部分。该矩阵定义了画布的当前坐标系。在大多数画布操作中,当您指定点的坐标时,它被视为当前坐标系中的点,而不是默认坐标系中的点。当前变换矩阵用于将您指定的坐标转换为默认坐标系中的等效坐标。
setTransform() 方法允许您直接设置画布的变换矩阵,但坐标系变换通常更容易指定为一系列平移、旋转和缩放操作。图 15-11 说明了这些操作及其对画布坐标系的影响。生成该图的程序连续七次绘制了相同的坐标轴。每次变化的唯一事物是当前变换。请注意,变换不仅影响绘制的线条,还影响文本。

图 15-11. 坐标系变换
translate() 方法简单地将坐标系的原点向左、向右、向上或向下移动。rotate() 方法按指定角度顺时针旋转坐标轴。(Canvas API 总是用弧度指定角度。要将度数转换为弧度,除以 180 并乘以 Math.PI。)scale() 方法沿着 x 或 y 轴拉伸或收缩距离。
将负的比例因子传递给 scale() 方法会使该轴在原点处翻转,就像在镜子中反射一样。这就是在 图 15-11 的左下角所做的事情:translate() 用于将原点移动到画布的左下角,然后 scale() 用于翻转 y 轴,使得随着页面向上移动,y 坐标增加。这样的翻转坐标系在代数课上很常见,可能对绘制图表上的数据点有用。但请注意,这会使文本难以阅读!
数学上理解变换
我发现最容易理解变换的方法是几何上的,将 translate()、rotate() 和 scale() 视为转换坐标系的轴,如 图 15-11 所示。也可以将变换理解为代数方程,这些方程将变换后坐标系中点 (x,y) 的坐标映射回先前坐标系中相同点 (x',y') 的坐标。
方法调用 c.translate(dx,dy) 可以用以下方程描述:
x' = x + dx; // An X coordinate of 0 in the new system is dx in the old
y' = y + dy;
缩放操作有类似简单的方程。调用 c.scale(sx,sy) 可以描述如下:
x' = sx * x;
y' = sy * y;
旋转更加复杂。调用 c.rotate(a) 由以下三角函数方程描述:
x' = x * cos(a) - y * sin(a);
y' = y * cos(a) + x * sin(a);
注意变换的顺序很重要。 假设我们从画布的默认坐标系开始,然后将其平移,然后缩放。 为了将当前坐标系中的点(x,y)映射回默认坐标系中的点(x'',y''),我们必须首先应用缩放方程将点映射到平移但未缩放的坐标系中的中间点(x',y'),然后使用平移方程从这个中间点映射到(x'',y'')。 结果如下:
x'' = sx*x + dx;
y'' = sy*y + dy;
另一方面,如果我们在调用translate()之前调用了scale(),则得到的方程将不同:
x'' = sx*(x + dx);
y'' = sy*(y + dy);
在代数上考虑变换序列时,要记住的关键是必须从最后(最近)的变换向前工作到第一个。 然而,在几何上考虑变换的轴时,您从第一个变换向最后一个变换工作。
画布支持的变换称为仿射变换。 仿射变换可以修改点之间的距离和线之间的角度,但平行线在仿射变换后始终保持平行——例如,不可能用仿射变换指定鱼眼镜头畸变。 任意仿射变换可以用这些方程中的六个参数a到f来描述:
x' = ax + cy + e
y' = bx + dy + f
您可以通过将这六个参数传递给transform()方法,对当前坐标系应用任意变换。 图 15-11 展示了两种类型的变换——倾斜和围绕指定点旋转——您可以像这样使用transform()方法实现:
// Shear transform:
// x' = x + kx*y;
// y' = ky*x + y;
function shear(c, kx, ky) { c.transform(1, ky, kx, 1, 0, 0); }
// Rotate theta radians counterclockwise around the point (x,y)
// This can also be accomplished with a translate, rotate, translate sequence
function rotateAbout(c, theta, x, y) {
let ct = Math.cos(theta);
let st = Math.sin(theta);
c.transform(ct, -st, st, ct, -x*ct-y*st+x, x*st-y*ct+y);
}
setTransform()方法接受与transform()相同的参数,但是不是转换当前坐标系,而是忽略当前系统,转换默认坐标系,并使结果成为新的当前坐标系。 setTransform()对于临时将画布重置为其默认坐标系很有用:
c.save(); // Save current coordinate system
c.setTransform(1,0,0,1,0,0); // Revert to the default coordinate system
// Perform operations using default CSS pixel coordinates
c.restore(); // Restore the saved coordinate system
变换示例
示例 15-7 通过递归使用translate()、rotate()和scale()方法来绘制科赫雪花分形图,展示了坐标系变换的强大功能。 此示例的输出显示在图 15-12 中,显示了具有 0、1、2、3 和 4 个递归级别的科赫雪花。

图 15-12. 科赫雪花
生成这些图形的代码很简洁,但其使用递归坐标系变换使其有些难以理解。 即使您不理解所有细微之处,也请注意代码中仅包含一次对lineTo()方法的调用。 图 15-12 中的每个线段都是这样绘制的:
c.lineTo(len, 0);
变量len的值在程序执行过程中不会改变,因此每个线段的位置、方向和长度由平移、旋转和缩放操作确定。
示例 15-7. 具有变换的科赫雪花
let deg = Math.PI/180; // For converting degrees to radians
// Draw a level-n Koch snowflake fractal on the canvas context c,
// with lower-left corner at (x,y) and side length len.
function snowflake(c, n, x, y, len) {
c.save(); // Save current transformation
c.translate(x,y); // Translate origin to starting point
c.moveTo(0,0); // Begin a new subpath at the new origin
leg(n); // Draw the first leg of the snowflake
c.rotate(-120*deg); // Now rotate 120 degrees counterclockwise
leg(n); // Draw the second leg
c.rotate(-120*deg); // Rotate again
leg(n); // Draw the final leg
c.closePath(); // Close the subpath
c.restore(); // And restore original transformation
// Draw a single leg of a level-n Koch snowflake.
// This function leaves the current point at the end of the leg it has
// drawn and translates the coordinate system so the current point is (0,0).
// This means you can easily call rotate() after drawing a leg.
function leg(n) {
c.save(); // Save the current transformation
if (n === 0) { // Nonrecursive case:
c.lineTo(len, 0); // Just draw a horizontal line
} // _ _
else { // Recursive case: draw 4 sub-legs like: \/
c.scale(1/3,1/3); // Sub-legs are 1/3 the size of this leg
leg(n-1); // Recurse for the first sub-leg
c.rotate(60*deg); // Turn 60 degrees clockwise
leg(n-1); // Second sub-leg
c.rotate(-120*deg); // Rotate 120 degrees back
leg(n-1); // Third sub-leg
c.rotate(60*deg); // Rotate back to our original heading
leg(n-1); // Final sub-leg
}
c.restore(); // Restore the transformation
c.translate(len, 0); // But translate to make end of leg (0,0)
}
}
let c = document.querySelector("canvas").getContext("2d");
snowflake(c, 0, 25, 125, 125); // A level-0 snowflake is a triangle
snowflake(c, 1, 175, 125, 125); // A level-1 snowflake is a 6-sided star
snowflake(c, 2, 325, 125, 125); // etc.
snowflake(c, 3, 475, 125, 125);
snowflake(c, 4, 625, 125, 125); // A level-4 snowflake looks like a snowflake!
c.stroke(); // Stroke this very complicated path
15.8.6 裁剪
定义路径后,通常会调用stroke()或fill()(或两者)。 您还可以调用clip()方法来定义裁剪区域。 一旦定义了裁剪区域,就不会在其外部绘制任何内容。 图 15-13 展示了使用裁剪区域生成的复杂图形。 图中垂直条纹沿中间运行,底部的文本是在定义三角形裁剪区域之后未裁剪的描边,然后填充的。

图 15-13. 未裁剪的笔画和裁剪的填充
图 15-13 是使用示例 15-5 的polygon()方法和以下代码生成的:
// Define some drawing attributes
c.font = "bold 60pt sans-serif"; // Big font
c.lineWidth = 2; // Narrow lines
c.strokeStyle = "#000"; // Black lines
// Outline a rectangle and some text
c.strokeRect(175, 25, 50, 325); // A vertical stripe down the middle
c.strokeText("<canvas>", 15, 330); // Note strokeText() instead of fillText()
// Define a complex path with an interior that is outside.
polygon(c,3,200,225,200); // Large triangle
polygon(c,3,200,225,100,0,true); // Smaller reverse triangle inside
// Make that path the clipping region.
c.clip();
// Stroke the path with a 5 pixel line, entirely inside the clipping region.
c.lineWidth = 10; // Half of this 10 pixel line will be clipped away
c.stroke();
// Fill the parts of the rectangle and text that are inside the clipping region
c.fillStyle = "#aaa"; // Light gray
c.fillRect(175, 25, 50, 325); // Fill the vertical stripe
c.fillStyle = "#888"; // Darker gray
c.fillText("<canvas>", 15, 330); // Fill the text
需要注意的是,当你调用clip()时,当前路径本身会被剪切到当前剪切区域,然后被剪切的路径成为新的剪切区域。这意味着clip()方法可以缩小剪切区域,但不能扩大它。没有方法可以重置剪切区域,因此在调用clip()之前,通常应该调用save(),这样以后就可以restore()未剪切的区域。
15.8.7 像素处理
getImageData()方法返回一个表示画布矩形区域的原始像素(作为 R、G、B 和 A 分量)的 ImageData 对象。您可以使用createImageData()创建空的ImageData对象。ImageData 对象中的像素是可写的,因此您可以按照自己的方式设置它们,然后使用putImageData()将这些像素复制回画布。
这些像素处理方法提供了对画布的非常低级访问。您传递给getImageData()的矩形位于默认坐标系统中:其尺寸以 CSS 像素为单位,不受当前变换的影响。当您调用putImageData()时,您指定的位置也是以默认坐标系统中的尺寸来衡量的。此外,putImageData()忽略所有图形属性。它不执行任何合成,不将像素乘以globalAlpha,也不绘制阴影。
像素处理方法对于实现图像处理非常有用。示例 15-8 展示了如何创建一个简单的运动模糊或“涂抹”效果,就像图 15-14 中显示的那样。

图 15-14. 通过图像处理创建的运动模糊效果
以下代码演示了getImageData()和putImageData(),并展示了如何迭代并修改 ImageData 对象中的像素值。
示例 15-8. 使用 ImageData 进行运动模糊
// Smear the pixels of the rectangle to the right, producing a
// sort of motion blur as if objects are moving from right to left.
// n must be 2 or larger. Larger values produce bigger smears.
// The rectangle is specified in the default coordinate system.
function smear(c, n, x, y, w, h) {
// Get the ImageData object that represents the rectangle of pixels to smear
let pixels = c.getImageData(x, y, w, h);
// This smear is done in-place and requires only the source ImageData.
// Some image processing algorithms require an additional ImageData to
// store transformed pixel values. If we needed an output buffer, we could
// create a new ImageData with the same dimensions like this:
// let output_pixels = c.createImageData(pixels);
// Get the dimensions of the grid of pixels in the ImageData object
let width = pixels.width, height = pixels.height;
// This is the byte array that holds the raw pixel data, left-to-right and
// top-to-bottom. Each pixel occupies 4 consecutive bytes in R,G,B,A order.
let data = pixels.data;
// Each pixel after the first in each row is smeared by replacing it with
// 1/nth of its own value plus m/nths of the previous pixel's value
let m = n-1;
for(let row = 0; row < height; row++) { // For each row
let i = row*width*4 + 4; // The offset of the second pixel of the row
for(let col = 1; col < width; col++, i += 4) { // For each column
data[i] = (data[i] + data[i-4]*m)/n; // Red pixel component
data[i+1] = (data[i+1] + data[i-3]*m)/n; // Green
data[i+2] = (data[i+2] + data[i-2]*m)/n; // Blue
data[i+3] = (data[i+3] + data[i-1]*m)/n; // Alpha component
}
}
// Now copy the smeared image data back to the same position on the canvas
c.putImageData(pixels, x, y);
}
15.9 音频 API
HTML <audio>和<video>标签允许您轻松地在网页中包含声音和视频。这些是具有重要 API 和复杂用户界面的复杂元素。您可以使用play()和pause()方法控制媒体播放。您可以设置volume和playbackRate属性来控制音频音量和播放速度。您可以通过设置currentTime属性跳转到媒体中的特定时间。
我们不会在这里进一步详细介绍<audio>和<video>标签。以下小节演示了两种向网页添加脚本化声音效果的方法。
15.9.1 Audio()构造函数
您不必在 HTML 文档中包含<audio>标签以在网页中包含声音效果。您可以使用普通的 DOMdocument.createElement()方法动态创建<audio>元素,或者作为快捷方式,您可以简单地使用Audio()构造函数。您不必将创建的元素添加到文档中才能播放它。您只需调用其play()方法即可:
// Load the sound effect in advance so it is ready for use
let soundeffect = new Audio("soundeffect.mp3");
// Play the sound effect whenever the user clicks the mouse button
document.addEventListener("click", () => {
soundeffect.cloneNode().play(); // Load and play the sound
});
注意这里使用了cloneNode()。如果用户快速点击鼠标,我们希望能够同时播放多个重叠的声音效果副本。为了做到这一点,我们需要多个音频元素。因为音频元素没有添加到文档中,所以当它们播放完毕时会被垃圾回收。
15.9.2 WebAudio API
除了使用 Audio 元素播放录制的声音外,Web 浏览器还允许使用 WebAudio API 生成和播放合成声音。使用 WebAudio API 就像连接旧式电子合成器的插线一样。使用 WebAudio,您创建一组 AudioNode 对象,表示波形的源、变换或目的地,然后将这些节点连接到一个网络中以产生声音。API 并不特别复杂,但要全面解释需要理解超出本书范围的电子音乐和信号处理概念。
下面的代码示例使用 WebAudio API 合成一个在大约一秒钟内淡出的短和弦。这个示例演示了 WebAudio API 的基础知识。如果你对此感兴趣,你可以在网上找到更多关于这个 API 的信息:
// Begin by creating an audioContext object. Safari still requires
// us to use webkitAudioContext instead of AudioContext.
let audioContext = new (this.AudioContext||this.webkitAudioContext)();
// Define the base sound as a combination of three pure sine waves
let notes = [ 293.7, 370.0, 440.0 ]; // D major chord: D, F# and A
// Create oscillator nodes for each of the notes we want to play
let oscillators = notes.map(note => {
let o = audioContext.createOscillator();
o.frequency.value = note;
return o;
});
// Shape the sound by controlling its volume over time.
// Starting at time 0 quickly ramp up to full volume.
// Then starting at time 0.1 slowly ramp down to 0.
let volumeControl = audioContext.createGain();
volumeControl.gain.setTargetAtTime(1, 0.0, 0.02);
volumeControl.gain.setTargetAtTime(0, 0.1, 0.2);
// We're going to send the sound to the default destination:
// the user's speakers
let speakers = audioContext.destination;
// Connect each of the source notes to the volume control
oscillators.forEach(o => o.connect(volumeControl));
// And connect the output of the volume control to the speakers.
volumeControl.connect(speakers);
// Now start playing the sounds and let them run for 1.25 seconds.
let startTime = audioContext.currentTime;
let stopTime = startTime + 1.25;
oscillators.forEach(o => {
o.start(startTime);
o.stop(stopTime);
});
// If we want to create a sequence of sounds we can use event handlers
oscillators[0].addEventListener("ended", () => {
// This event handler is invoked when the note stops playing
});
15.10 位置、导航和历史
location属性既可以用于 Window 对象,也可以用于 Document 对象,它指的是 Location 对象,代表着窗口中显示的文档的当前 URL,并提供了一个 API 用于在窗口中加载新文档。
Location 对象非常类似于 URL 对象(§11.9),你可以使用protocol、hostname、port和path等属性来访问当前文档的 URL 的各个部分。href属性返回整个 URL 作为字符串,toString()方法也是如此。
Location 对象的hash和search属性是比较有趣的。hash属性返回 URL 中的“片段标识符”部分,如果有的话:一个井号(#)后跟一个元素 ID。search属性类似。它返回以问号开头的 URL 部分:通常是某种查询字符串。一般来说,URL 的这部分用于对 URL 进行参数化,并提供了一种在其中嵌入参数的方式。虽然这些参数通常是为在服务器上运行的脚本而设计的,但也可以在启用 JavaScript 的页面中使用。
URL 对象有一个searchParams属性,它是search属性的解析表示。Location 对象没有searchParams属性,但如果你想解析window.location.search,你可以简单地从 Location 对象创建一个 URL 对象,然后使用 URL 的searchParams:
let url = new URL(window.location);
let query = url.searchParams.get("q");
let numResults = parseInt(url.searchParams.get("n") || "10");
除了可以引用为window.location或document.location的 Location 对象,以及我们之前使用的URL()构造函数,浏览器还定义了一个document.URL属性。令人惊讶的是,这个属性的值不是一个 URL 对象,而只是一个字符串。该字符串保存当前文档的 URL。
15.10.1 加载新文档
如果你将一个字符串分配给window.location或document.location,那么该字符串会被解释为一个 URL,浏览器会加载它,用新文档替换当前文档:
window.location = "http://www.oreilly.com"; // Go buy some books!
你也可以将相对 URL 分配给location。它们相对于当前 URL 解析:
document.location = "page2.html"; // Load the next page
一个裸的片段标识符是一种特殊类型的相对 URL,它不会导致浏览器加载新文档,而只是滚动,以使具有与片段匹配的id或name的文档元素在浏览器窗口顶部可见。作为一个特例,片段标识符#top会使浏览器跳转到文档的开头(假设没有元素具有id="top"属性):
location = "#top"; // Jump to the top of the document
Location 对象的各个属性都是可写的,设置它们会改变位置 URL,并导致浏览器加载一个新文档(或者在hash属性的情况下,在当前文档内导航):
document.location.path = "pages/3.html"; // Load a new page
document.location.hash = "TOC"; // Scroll to the table of contents
location.search = "?page=" + (page+1); // Reload with new query string
你也可以通过向 Location 对象的assign()方法传递一个新字符串来加载新页面。这与将字符串分配给location属性相同,因此并不特别有趣。
另一方面,Location 对象的replace()方法非常有用。当你向replace()传递一个字符串时,它被解释为一个 URL,并导致浏览器加载一个新页面,就像assign()一样。不同之处在于replace()替换了浏览器历史中的当前文档。如果文档 A 中的脚本设置location属性或调用assign()加载文档 B,然后用户点击返回按钮,浏览器将返回到文档 A。如果你使用replace(),那么文档 A 将从浏览器历史中删除,当用户点击返回按钮时,浏览器将返回到在显示文档 A 之前显示的文档。
当脚本无条件加载新文档时,replace() 方法比 assign() 更好。否则,点击返回按钮会将浏览器返回到原始文档,并且同样的脚本会再次加载新文档。假设你有一个使用 JavaScript 增强的页面版本和一个不使用 JavaScript 的静态版本。如果确定用户的浏览器不支持你想要使用的 Web 平台 API,你可以使用 location.replace() 来加载静态版本:
// If the browser does not support the JavaScript APIs we need,
// redirect to a static page that does not use JavaScript.
if (!isBrowserSupported()) location.replace("staticpage.html");
注意,传递给 replace() 的 URL 是相对的。相对 URL 被解释为相对于其出现的页面,就像它们在超链接中使用时一样。
除了 assign() 和 replace() 方法,Location 对象还定义了 reload(),它简单地使浏览器重新加载文档。
15.10.2 浏览历史
Window 对象的 history 属性指的是窗口的 History 对象。History 对象将窗口的浏览历史建模为文档和文档状态的列表。History 对象的 length 属性指定浏览历史列表中的元素数量,但出于安全原因,脚本不允许访问存储的 URL。(如果允许,任何脚本都可以窥探您的浏览历史。)
History 对象有 back() 和 forward() 方法,行为类似于浏览器的返回和前进按钮:它们使浏览器在其浏览历史中向后或向前移动一步。第三个方法 go() 接受一个整数参数,可以在历史记录列表中跳过任意数量的页面向前(对于正参数)或向后(对于负参数):
history.go(-2); // Go back 2, like clicking the Back button twice
history.go(0); // Another way to reload the current page
如果一个窗口包含子窗口(如 <iframe> 元素),子窗口的浏览历史与主窗口的历史按时间顺序交错。这意味着在主窗口上调用 history.back()(例如)可能会导致其中一个子窗口导航回到先前显示的文档,但保持主窗口处于当前状态。
这里描述的 History 对象可以追溯到 Web 早期,当时文档是被动的,所有计算都在服务器上执行。如今,Web 应用程序经常动态生成或加载内容,并显示新的应用程序状态,而不实际加载新文档。如果这样的应用程序希望用户能够使用返回和前进按钮(或等效手势)以直观的方式从一个应用程序状态导航到另一个状态,它们必须执行自己的历史管理。有两种方法可以实现这一点,将在接下来的两个部分中描述。
15.10.3 使用 hashchange 事件进行历史管理
一种历史管理技术涉及 location.hash 和“hashchange”事件。以下是您需要了解的关键事实,以理解这种技术:
-
location.hash属性设置 URL 的片段标识符,传统上用于指定要滚动到的文档部分的 ID。但location.hash不一定要是元素 ID:你可以将其设置为任何字符串。只要没有元素恰好将该字符串作为其 ID,当你像这样设置hash属性时,浏览器不会滚动。 -
设置
location.hash属性会更新在位置栏中显示的 URL,并且非常重要的是,会向浏览器的历史记录中添加一个条目。 -
每当文档的片段标识符发生变化时,浏览器会在 Window 对象上触发“hashchange”事件。如果你明确设置了
location.hash,就会触发“hashchange”事件。正如我们所提到的,对 Location 对象的更改会在浏览器的浏览历史中创建一个新条目。因此,如果用户现在点击“后退”按钮,浏览器将返回到在设置location.hash之前的上一个 URL。但这意味着片段标识符再次发生了变化,因此在这种情况下会再次触发“hashchange”事件。这意味着只要你能为应用程序的每种可能状态创建一个唯一的片段标识符,“hashchange”事件将通知你用户在浏览历史中前进和后退的情况。
要使用这种历史管理机制,您需要能够将渲染应用程序“页面”所需的状态信息编码为相对较短的文本字符串,适合用作片段标识符。您还需要编写一个将页面状态转换为字符串的函数,以及另一个函数来解析字符串并重新创建它表示的页面状态。
一旦你编写了这些函数,剩下的就很容易了。定义一个window.onhashchange函数(或使用addEventListener()注册一个“hashchange”监听器),读取location.hash,将该字符串转换为你的应用程序状态的表示,并采取必要的操作来显示新的应用程序状态。
当用户与您的应用程序交互(例如点击链接)以导致应用程序进入新状态时,不要直接呈现新状态。相反,将所需的新状态编码为字符串,并将location.hash设置为该字符串。这将触发“hashchange”事件,您对该事件的处理程序将显示新状态。使用这种迂回的技术确保新状态被插入到浏览历史中,以便“后退”和“前进”按钮继续工作。
15.10.4 使用 pushState() 进行历史管理
管理历史的第二种技术略微复杂,但比“hashchange”事件更不像是一种黑客技巧。这种更健壮的历史管理技术基于history.pushState()方法和“popstate”事件。当 Web 应用程序进入新状态时,它调用history.pushState()将表示状态的对象添加到浏览器的历史记录中。如果用户然后点击“后退”按钮,浏览器将触发一个带有保存的状态对象副本的“popstate”事件,应用程序使用该对象重新创建其先前的状态。除了保存的状态对象外,应用程序还可以保存每个状态的 URL,如果您希望用户能够将应用程序的内部状态添加到书签并共享链接,则这一点很重要。
pushState()的第一个参数是一个包含恢复文档当前状态所需的所有状态信息的对象。这个对象使用 HTML 的结构化克隆算法保存,比JSON.stringify()更灵活,可以支持 Map、Set 和 Date 对象以及类型化数组和 ArrayBuffers。
第二个参数原本是状态的标题字符串,但大多数浏览器不支持,你应该只传递一个空字符串。第三个参数是一个可选的 URL,将立即显示在位置栏中,用户通过“后退”和“前进”按钮返回到该状态时也会显示。相对 URL 会相对于文档的当前位置解析。将每个状态与 URL 关联起来允许用户将应用程序的内部状态添加到书签。但请记住,如果用户保存了书签,然后一天后访问它,你将不会收到关于该访问的“popstate”事件:你将需要通过解析 URL 来恢复应用程序状态。
除了pushState()方法外,History 对象还定义了replaceState(),它接受相同的参数,但是替换当前历史状态而不是向浏览历史添加新状态。当首次加载使用pushState()的应用程序时,通常最好调用replaceState()来为应用程序的初始状态定义一个状态对象。
当用户使用“后退”或“前进”按钮导航到保存的历史状态时,浏览器在 Window 对象上触发“popstate”事件。与事件相关联的事件对象有一个名为state的属性,其中包含您传递给pushState()的状态对象的副本(另一个结构化克隆)。
示例 15-9 是一个简单的 Web 应用程序——在图 15-15 中显示的猜数字游戏——它使用pushState()保存其历史记录,允许用户“返回”以查看或重新做出他们的猜测。

图 15-15。一个猜数字游戏
示例 15-9。使用 pushState()进行历史管理
<html><head><title>I'm thinking of a number...</title>
<style>
body { height: 250px; display: flex; flex-direction: column;
align-items: center; justify-content: space-evenly; }
#heading { font: bold 36px sans-serif; margin: 0; }
#container { border: solid black 1px; height: 1em; width: 80%; }
#range { background-color: green; margin-left: 0%; height: 1em; width: 100%; }
#input { display: block; font-size: 24px; width: 60%; padding: 5px; }
#playagain { font-size: 24px; padding: 10px; border-radius: 5px; }
</style>
</head>
<body>
<h1 id="heading">I'm thinking of a number...</h1>
<!-- A visual representation of the numbers that have not been ruled out -->
<div id="container"><div id="range"></div></div>
<!-- Where the user enters their guess -->
<input id="input" type="text">
<!-- A button that reloads with no search string. Hidden until game ends. -->
<button id="playagain" hidden onclick="location.search='';">Play Again</button>
<script>
/**
* An instance of this GameState class represents the internal state of
* our number guessing game. The class defines static factory methods for
* initializing the game state from different sources, a method for
* updating the state based on a new guess, and a method for modifying the
* document based on the current state.
*/
class GameState {
// This is a factory function to create a new game
static newGame() {
let s = new GameState();
s.secret = s.randomInt(0, 100); // An integer: 0 < n < 100
s.low = 0; // Guesses must be greater than this
s.high = 100; // Guesses must be less than this
s.numGuesses = 0; // How many guesses have been made
s.guess = null; // What the last guess was
return s;
}
// When we save the state of the game with history.pushState(), it is just
// a plain JavaScript object that gets saved, not an instance of GameState.
// So this factory function re-creates a GameState object based on the
// plain object that we get from a popstate event.
static fromStateObject(stateObject) {
let s = new GameState();
for(let key of Object.keys(stateObject)) {
s[key] = stateObject[key];
}
return s;
}
// In order to enable bookmarking, we need to be able to encode the
// state of any game as a URL. This is easy to do with URLSearchParams.
toURL() {
let url = new URL(window.location);
url.searchParams.set("l", this.low);
url.searchParams.set("h", this.high);
url.searchParams.set("n", this.numGuesses);
url.searchParams.set("g", this.guess);
// Note that we can't encode the secret number in the url or it
// will give away the secret. If the user bookmarks the page with
// these parameters and then returns to it, we will simply pick a
// new random number between low and high.
return url.href;
}
// This is a factory function that creates a new GameState object and
// initializes it from the specified URL. If the URL does not contain the
// expected parameters or if they are malformed it just returns null.
static fromURL(url) {
let s = new GameState();
let params = new URL(url).searchParams;
s.low = parseInt(params.get("l"));
s.high = parseInt(params.get("h"));
s.numGuesses = parseInt(params.get("n"));
s.guess = parseInt(params.get("g"));
// If the URL is missing any of the parameters we need or if
// they did not parse as integers, then return null;
if (isNaN(s.low) || isNaN(s.high) ||
isNaN(s.numGuesses) || isNaN(s.guess)) {
return null;
}
// Pick a new secret number in the right range each time we
// restore a game from a URL.
s.secret = s.randomInt(s.low, s.high);
return s;
}
// Return an integer n, min < n < max
randomInt(min, max) {
return min + Math.ceil(Math.random() * (max - min - 1));
}
// Modify the document to display the current state of the game.
render() {
let heading = document.querySelector("#heading"); // The <h1> at the top
let range = document.querySelector("#range"); // Display guess range
let input = document.querySelector("#input"); // Guess input field
let playagain = document.querySelector("#playagain");
// Update the document heading and title
heading.textContent = document.title =
`I'm thinking of a number between ${this.low} and ${this.high}.`;
// Update the visual range of numbers
range.style.marginLeft = `${this.low}%`;
range.style.width = `${(this.high-this.low)}%`;
// Make sure the input field is empty and focused.
input.value = "";
input.focus();
// Display feedback based on the user's last guess. The input
// placeholder will show because we made the input field empty.
if (this.guess === null) {
input.placeholder = "Type your guess and hit Enter";
} else if (this.guess < this.secret) {
input.placeholder = `${this.guess} is too low. Guess again`;
} else if (this.guess > this.secret) {
input.placeholder = `${this.guess} is too high. Guess again`;
} else {
input.placeholder = document.title = `${this.guess} is correct!`;
heading.textContent = `You win in ${this.numGuesses} guesses!`;
playagain.hidden = false;
}
}
// Update the state of the game based on what the user guessed.
// Returns true if the state was updated, and false otherwise.
updateForGuess(guess) {
// If it is a number and is in the right range
if ((guess > this.low) && (guess < this.high)) {
// Update state object based on this guess
if (guess < this.secret) this.low = guess;
else if (guess > this.secret) this.high = guess;
this.guess = guess;
this.numGuesses++;
return true;
}
else { // An invalid guess: notify user but don't update state
alert(`Please enter a number greater than ${
this.low} and less than ${this.high}`);
return false;
}
}
}
// With the GameState class defined, making the game work is just a matter
// of initializing, updating, saving and rendering the state object at
// the appropriate times.
// When we are first loaded, we try get the state of the game from the URL
// and if that fails we instead begin a new game. So if the user bookmarks a
// game that game can be restored from the URL. But if we load a page with
// no query parameters we'll just get a new game.
let gamestate = GameState.fromURL(window.location) || GameState.newGame();
// Save this initial state of the game into the browser history, but use
// replaceState instead of pushState() for this initial page
history.replaceState(gamestate, "", gamestate.toURL());
// Display this initial state
gamestate.render();
// When the user guesses, update the state of the game based on their guess
// then save the new state to browser history and render the new state
document.querySelector("#input").onchange = (event) => {
if (gamestate.updateForGuess(parseInt(event.target.value))) {
history.pushState(gamestate, "", gamestate.toURL());
}
gamestate.render();
};
// If the user goes back or forward in history, we'll get a popstate event
// on the window object with a copy of the state object we saved with
// pushState. When that happens, render the new state.
window.onpopstate = (event) => {
gamestate = GameState.fromStateObject(event.state); // Restore the state
gamestate.render(); // and display it
};
</script>
</body></html>
15.11 网络
每次加载网页时,浏览器都会使用 HTTP 和 HTTPS 协议进行网络请求,获取 HTML 文件以及文件依赖的图像、字体、脚本和样式表。但除了能够响应用户操作进行网络请求外,Web 浏览器还公开了用于网络的 JavaScript API。
本节涵盖了三个网络 API:
-
fetch()方法为进行 HTTP 和 HTTPS 请求定义了基于 Promise 的 API。fetch()API 使基本的 GET 请求变得简单,但也具有全面的功能集,支持几乎任何可能的 HTTP 用例。 -
服务器发送事件(Server-Sent Events,简称 SSE)API 是 HTTP“长轮询”技术的一种方便的基于事件的接口,其中 Web 服务器保持网络连接打开,以便在需要时向客户端发送数据。
-
WebSockets 是一种不是 HTTP 的网络协议,但设计用于与 HTTP 互操作。它定义了一个异步消息传递 API,客户端和服务器可以相互发送和接收消息,类似于 TCP 网络套接字。
15.11.1 fetch()
对于基本的 HTTP 请求,使用fetch()是一个三步过程:
-
调用
fetch(),传递要检索内容的 URL。 -
获取由第 1 步异步返回的响应对象,当 HTTP 响应开始到达时调用此响应对象的方法来请求响应的主体。
-
获取由第 2 步异步返回的主体对象,并根据需要进行处理。
fetch() API 完全基于 Promise,并且这里有两个异步步骤,因此当使用fetch()时,通常期望有两个then()调用或两个await表达式。(如果你忘记了它们是什么,可能需要重新阅读第十三章后再继续本节。)
如果你使用then()并期望服务器响应你的请求是 JSON 格式的,fetch()请求看起来是这样的:
fetch("/api/users/current") // Make an HTTP (or HTTPS) GET request
.then(response => response.json()) // Parse its body as a JSON object
.then(currentUser => { // Then process that parsed object
displayUserInfo(currentUser);
});
使用async和await关键字向返回普通字符串而不是 JSON 对象的 API 发出类似请求:
async function isServiceReady() {
let response = await fetch("/api/service/status");
let body = await response.text();
return body === "ready";
}
如果你理解了这两个代码示例,那么你就知道了使用fetch() API 所需了解的 80%。接下来的小节将演示如何进行比这里显示的更复杂的请求和接收响应。
HTTP 状态码、响应头和网络错误
在§15.11.1 中显示的三步fetch()过程省略了所有错误处理代码。这里是一个更现实的版本:
fetch("/api/users/current") // Make an HTTP (or HTTPS) GET request.
.then(response => { // When we get a response, first check it
if (response.ok && // for a success code and the expected type.
response.headers.get("Content-Type") === "application/json") {
return response.json(); // Return a Promise for the body.
} else {
throw new Error( // Or throw an error.
`Unexpected response status ${response.status} or content type`
);
}
})
.then(currentUser => { // When the response.json() Promise resolves
displayUserInfo(currentUser); // do something with the parsed body.
})
.catch(error => { // Or if anything went wrong, just log the error.
// If the user's browser is offline, fetch() itself will reject.
// If the server returns a bad response then we throw an error above.
console.log("Error while fetching current user:", error);
});
fetch() 返回的 Promise 解析为一个 Response 对象。这个对象的 status 属性是 HTTP 状态码,比如成功请求的 200 或“未找找”响应的 404。(statusText 给出与数字状态码相对应的标准英文文本。)方便的是,Response 的 ok 属性在 status 为 200 或 200 到 299 之间的任何代码时为 true,对于其他任何代码都为 false。
fetch() 在服务器响应开始到达时解析其 Promise,通常在完整响应体到达之前。即使响应体还不可用,你也可以在 fetch 过程的第二步检查头部信息。Response 对象的 headers 属性是一个 Headers 对象。使用它的 has() 方法测试头部是否存在,或使用 get() 方法获取头部的值。HTTP 头部名称不区分大小写,因此你可以向这些函数传递小写或混合大小写的头部名称。
Headers 对象也是可迭代的,如果你需要的话:
fetch(url).then(response => {
for(let [name,value] of response.headers) {
console.log(`${name}: ${value}`);
}
});
如果 Web 服务器响应你的 fetch() 请求,那么返回的 Promise 将以 Response 对象实现,即使服务器的响应是 404 Not Found 错误或 500 Internal Server Error。fetch() 仅在无法完全联系到 Web 服务器时拒绝其返回的 Promise。如果用户的计算机离线,服务器无响应,或 URL 指定的主机名不存在,就会发生这种情况。因为这些情况可能发生在任何网络请求上,所以每次进行 fetch() 调用时都包含一个 .catch() 子句总是一个好主意。
设置请求参数
有时候在发起请求时,你可能想要传递额外的参数。这可以通过在 URL 后面添加 ? 后的名称/值对来实现。URL 和 URLSearchParams 类(在 §11.9 中介绍)使得构造这种形式的 URL 变得容易,fetch() 函数接受 URL 对象作为其第一个参数,因此你可以像这样在 fetch() 请求中包含请求参数:
async function search(term) {
let url = new URL("/api/search");
url.searchParams.set("q", term);
let response = await fetch(url);
if (!response.ok) throw new Error(response.statusText);
let resultsArray = await response.json();
return resultsArray;
}
设置请求头部
有时候你需要在 fetch() 请求中设置头部。例如,如果你正在进行需要凭据的 Web API 请求,那么可能需要包含包含这些凭据的 Authorization 头部。为了做到这一点,你可以使用 fetch() 的两个参数版本。与之前一样,第一个参数是指定要获取的 URL 的字符串或 URL 对象。第二个参数是一个对象,可以提供额外的选项,包括请求头部:
let authHeaders = new Headers();
// Don't use Basic auth unless it is over an HTTPS connection.
authHeaders.set("Authorization",
`Basic ${btoa(`${username}:${password}`)}`);
fetch("/api/users/", { headers: authHeaders })
.then(response => response.json()) // Error handling omitted...
.then(usersList => displayAllUsers(usersList));
在 fetch() 的第二个参数中可以指定许多其他选项,我们稍后会再次看到它。将两个参数传递给 fetch() 的替代方法是将相同的两个参数传递给 Request() 构造函数,然后将生成的 Request 对象传递给 fetch():
let request = new Request(url, { headers });
fetch(request).then(response => ...);
解析响应体
在我们演示的三步 fetch() 过程中,第二步通过调用 Response 对象的 json() 或 text() 方法结束,并返回这些方法返回的 Promise 对象。然后,第三步开始,当该 Promise 解析为响应体解析为 JSON 对象或简单文本字符串时。
这可能是两种最常见的情况,但并不是获取 Web 服务器响应体的唯一方式。除了 json() 和 text(),Response 对象还有这些方法:
arrayBuffer()
这个方法返回一个解析为 ArrayBuffer 的 Promise。当响应包含二进制数据时,这是很有用的。你可以使用 ArrayBuffer 创建一个类型化数组(§11.2)或一个 DataView 对象(§11.2.5),从中读取二进制数据。
blob()
此方法返回一个解析为 Blob 对象的 Promise。本书未详细介绍 Blob,但其名称代表“二进制大对象”,在期望大量二进制数据时非常有用。如果要求响应主体为 Blob,则浏览器实现可能会将响应数据流式传输到临时文件,然后返回表示该临时文件的 Blob 对象。因此,Blob 对象不允许像 ArrayBuffer 一样随机访问响应主体。一旦有了 Blob,你可以使用 URL.createObjectURL() 创建一个引用它的 URL,或者你可以使用基于事件的 FileReader API 异步获取 Blob 的内容作为字符串或 ArrayBuffer。在撰写本文时,一些浏览器还定义了基于 Promise 的 text() 和 arrayBuffer() 方法,提供了更直接的方式来获取 Blob 的内容。
formData()
此方法返回一个解析为 FormData 对象的 Promise。如果你期望 Response 的主体以“multipart/form-data”格式编码,则应使用此方法。这种格式在向服务器发出 POST 请求时很常见,但在服务器响应中不常见,因此此方法不经常使用。
流式传输响应主体
除了异步返回完整响应主体的五种响应方法之外,还有一种选项可以流式传输响应主体,这在网络上到达响应主体的块时可以进行某种处理时非常有用。但是,如果你想要显示进度条,让用户看到下载的进度,流式传输响应也很有用。
Response 对象的 body 属性是一个 ReadableStream 对象。如果你已经调用了像 text() 或 json() 这样读取、解析和返回主体的响应方法,那么 bodyUsed 将为 true,表示 body 流已经被读取。但是,如果 bodyUsed 为 false,则表示流尚未被读取。在这种情况下,你可以调用 response.body 上的 getReader() 来获取一个流读取器对象,然后使用该读取器对象的 read() 方法异步从流中读取文本块。read() 方法返回一个解析为具有 done 和 value 属性的对象的 Promise。如果整个主体已被读取或流已关闭,则 done 将为 true。而 value 将是下一个块,作为 Uint8Array,如果没有更多块,则为 undefined。
如果使用 async 和 await,则此流式 API 相对简单,但如果尝试使用原始 Promise,则会出乎意料地复杂。示例 15-10 通过定义一个 streamBody() 函数来演示该 API。假设你想要下载一个大型 JSON 文件并向用户报告下载进度。你无法使用 Response 对象的 json() 方法来实现,但可以使用 streamBody() 函数,如下所示(假设已定义了一个 updateProgress() 函数来设置 HTML <progress> 元素的 value 属性):
fetch('big.json')
.then(response => streamBody(response, updateProgress))
.then(bodyText => JSON.parse(bodyText))
.then(handleBigJSONObject);
可以按照 示例 15-10 中所示实现 streamBody() 函数。
示例 15-10. 从 fetch() 请求中流式传输响应主体
/**
* An asynchronous function for streaming the body of a Response object
* obtained from a fetch() request. Pass the Response object as the first
* argument followed by two optional callbacks.
*
* If you specify a function as the second argument, that reportProgress
* callback will be called once for each chunk that is received. The first
* argument passed is the total number of bytes received so far. The second
* argument is a number between 0 and 1 specifying how complete the download
* is. If the Response object has no "Content-Length" header, however, then
* this second argument will always be NaN.
*
* If you want to process the data in chunks as they arrive, specify a
* function as the third argument. The chunks will be passed, as Uint8Array
* objects, to this processChunk callback.
*
* streamBody() returns a Promise that resolves to a string. If a processChunk
* callback was supplied then this string is the concatenation of the values
* returned by that callback. Otherwise the string is the concatenation of
* the chunk values converted to UTF-8 strings.
*/
async function streamBody(response, reportProgress, processChunk) {
// How many bytes are we expecting, or NaN if no header
let expectedBytes = parseInt(response.headers.get("Content-Length"));
let bytesRead = 0; // How many bytes received so far
let reader = response.body.getReader(); // Read bytes with this function
let decoder = new TextDecoder("utf-8"); // For converting bytes to text
let body = ""; // Text read so far
while(true) { // Loop until we exit below
let {done, value} = await reader.read(); // Read a chunk
if (value) { // If we got a byte array:
if (processChunk) { // Process the bytes if
let processed = processChunk(value); // a callback was passed.
if (processed) {
body += processed;
}
} else { // Otherwise, convert bytes
body += decoder.decode(value, {stream: true}); // to text.
}
if (reportProgress) { // If a progress callback was
bytesRead += value.length; // passed, then call it
reportProgress(bytesRead, bytesRead / expectedBytes);
}
}
if (done) { // If this is the last chunk,
break; // exit the loop
}
}
return body; // Return the body text we accumulated
}
此流式 API 在撰写本文时是新的,并预计会发展。特别是,计划使 ReadableStream 对象异步可迭代,以便可以与 for/await 循环一起使用(§13.4.1)。
指定请求方法和请求体
到目前为止,我们展示的每个 fetch() 示例都是进行了 HTTP(或 HTTPS)GET 请求。如果你想要使用不同的请求方法(如 POST、PUT 或 DELETE),只需使用 fetch() 的两个参数版本,传递一个带有 method 参数的 Options 对象:
fetch(url, { method: "POST" }).then(r => r.json()).then(handleResponse);
POST 和 PUT 请求通常具有包含要发送到服务器的数据的请求体。只要 method 属性未设置为 "GET" 或 "HEAD"(不支持请求体),您可以通过设置 Options 对象的 body 属性来指定请求体:
fetch(url, {
method: "POST",
body: "hello world"
})
当您指定请求体时,浏览器会自动向请求添加适当的 “Content-Length” 头。当请求体是字符串时,如前面的示例中,浏览器将默认将 “Content-Type” 头设置为 “text/plain;charset=UTF-8”。如果您指定了更具体类型的字符串体,如 “text/html” 或 “application/json”,则可能需要覆盖此默认值:
fetch(url, {
method: "POST",
headers: new Headers({"Content-Type": "application/json"}),
body: JSON.stringify(requestBody)
})
fetch() 选项对象的 body 属性不一定要是字符串。如果您有二进制数据在类型化数组、DataView 对象或 ArrayBuffer 中,可以将 body 属性设置为该值,并指定适当的 “Content-Type” 头。如果您有 Blob 形式的二进制数据,只需将 body 设置为 Blob。Blob 具有指定其内容类型的 type 属性,该属性的值用作 “Content-Type” 头的默认值。
使用 POST 请求时,将一组名称/值参数传递到请求体中(而不是将它们编码到 URL 的查询部分)是相当常见的。有两种方法可以实现这一点:
-
您可以使用 URLSearchParams 指定参数名称和值(我们在本节前面看到过,并在 §11.9 中有文档),然后将 URLSearchParams 对象作为
body属性的值传递。如果这样做,请求体将设置为类似 URL 查询部分的字符串,并且“Content-Type” 头将自动设置为 “application/x-www-form-urlencoded;charset=UTF-8”。 -
如果您使用 FormData 对象指定参数名称和值,请求体将使用更详细的多部分编码,并且“Content-Type” 将设置为 “multipart/form-data; boundary=…”,其中包含一个与请求体匹配的唯一边界字符串。当您要上传的值很长,或者是每个都可能具有自己的“Content-Type”的文件或 Blob 对象时,使用 FormData 对象特别有用。可以通过将
<form>元素传递给FormData()构造函数来创建和初始化 FormData 对象的值。但也可以通过调用不带参数的FormData()构造函数并使用set()和append()方法初始化它表示的名称/值对来创建“multipart/form-data” 请求体。
使用 fetch() 上传文件
从用户计算机上传文件到 Web 服务器是一项常见任务,可以使用 FormData 对象作为请求体。获取 File 对象的常见方法是在 Web 页面上显示一个 <input type="file"> 元素,并侦听该元素上的 “change” 事件。当发生 “change” 事件时,输入元素的 files 数组应至少包含一个 File 对象。还可以通过 HTML 拖放 API 获取 File 对象。该 API 不在本书中介绍,但您可以从传递给 “drop” 事件的事件对象的 dataTransfer.files 数组中获取文件。
还要记住,File 对象是 Blob 的一种,有时上传 Blob 可能很有用。假设您编写了一个允许用户在 <canvas> 元素中创建绘图的 Web 应用程序。您可以使用以下代码将用户的绘图上传为 PNG 文件:
// The canvas.toBlob() function is callback-based.
// This is a Promise-based wrapper for it.
async function getCanvasBlob(canvas) {
return new Promise((resolve, reject) => {
canvas.toBlob(resolve);
});
}
// Here is how we upload a PNG file from a canvas
async function uploadCanvasImage(canvas) {
let pngblob = await getCanvasBlob(canvas);
let formdata = new FormData();
formdata.set("canvasimage", pngblob);
let response = await fetch("/upload", { method: "POST", body: formdata });
let body = await response.json();
}
跨域请求
大多数情况下,fetch() 被 Web 应用程序用于从自己的 Web 服务器请求数据。这些请求被称为同源请求,因为传递给 fetch() 的 URL 与包含发出请求的脚本的文档具有相同的源(协议加主机名加端口)。
出于安全原因,Web 浏览器通常禁止(尽管对于图像和脚本有例外)跨域网络请求。然而,跨域资源共享(CORS)使安全的跨域请求成为可能。当fetch()与跨域 URL 一起使用时,浏览器会向请求添加一个“Origin”头部(并且不允许通过headers属性覆盖它)以通知 Web 服务器请求来自具有不同来源的文档。如果服务器用适当的“Access-Control-Allow-Origin”头部响应请求,那么请求会继续。否则,如果服务器没有明确允许请求,那么fetch()返回的 Promise 将被拒绝。
中止请求
有时候你可能想要中止一个已经发出的fetch()请求,也许是因为用户点击了取消按钮或请求花费的时间太长。fetch API 允许使用 AbortController 和 AbortSignal 类来中止请求。(这些类定义了一个通用的中止机制,适用于其他 API 的使用。)
如果你想要中止一个fetch()请求的选项,那么在开始请求之前创建一个 AbortController 对象。控制器对象的signal属性是一个 AbortSignal 对象。将这个信号对象作为你传递给fetch()的选项对象的signal属性的值。这样做后,你可以调用控制器对象的abort()方法来中止请求,这将导致与 fetch 请求相关的任何 Promise 对象拒绝并抛出异常。
这里是使用 AbortController 机制强制执行 fetch 请求超时的示例:
// This function is like fetch(), but it adds support for a timeout
// property in the options object and aborts the fetch if it is not complete
// within the number of milliseconds specified by that property.
function fetchWithTimeout(url, options={}) {
if (options.timeout) { // If the timeout property exists and is nonzero
let controller = new AbortController(); // Create a controller
options.signal = controller.signal; // Set the signal property
// Start a timer that will send the abort signal after the specified
// number of milliseconds have passed. Note that we never cancel
// this timer. Calling abort() after the fetch is complete has
// no effect.
setTimeout(() => { controller.abort(); }, options.timeout);
}
// Now just perform a normal fetch
return fetch(url, options);
}
杂项请求选项
我们已经看到 Options 对象可以作为fetch()的第二个参数(或Request()构造函数的第二个参数)传递,以指定请求方法、请求头和请求体。它还支持许多其他选项,包括这些:
cache
使用这个属性来覆盖浏览器的默认缓存行为。HTTP 缓存是一个复杂的主题,超出了本书的范围,但如果你了解它的工作原理,你可以使用以下cache的合法值:
"default"
这个值指定了默认的缓存行为。缓存中的新鲜响应直接从缓存中提供,而陈旧响应在提供之前会被重新验证。
"no-store"
这个值使浏览器忽略其缓存。当请求发出时,不会检查缓存是否匹配,并且当响应到达时也不会更新缓存。
"reload"
这个值告诉浏览器始终进行正常的网络请求,忽略缓存。然而,当响应到达时,它会被存储在缓存中。
"no-cache"
这个(误导性命名的)值告诉浏览器不要从缓存中提供新鲜值。在返回之前,新鲜或陈旧的缓存值会被重新验证。
"force-cache"
这个值告诉浏览器即使缓存中的响应是陈旧的也要提供。
redirect
这个属性控制浏览器如何处理来自服务器的重定向响应。三个合法的值是:
"follow"
这是默认值,它使浏览器自动跟随重定向。如果使用这个默认值,通过fetch()获取的 Response 对象不应该有status在 300 到 399 范围内。
"error"
这个值使fetch()在服务器返回重定向响应时拒绝其返回的 Promise。
"manual"
这个值意味着你想要手动处理重定向响应,并且fetch()返回的 Promise 可能会解析为一个带有status在 300 到 399 范围内的 Response 对象。在这种情况下,你将不得不使用 Response 的“Location”头部来手动跟随重定向。
referrer
您可以将此属性设置为包含相对 URL 的字符串,以指定 HTTP“Referer”标头的值(历史上错误拼写为三个 R 而不是四个)。如果将此属性设置为空字符串,则“Referer”标头将从请求中省略。
15.11.2 服务器发送事件
HTTP 协议构建在其上的 Web 的一个基本特性是客户端发起请求,服务器响应这些请求。然而,一些 Web 应用程序发现,当事件发生时,让服务器向它们发送通知很有用。这对 HTTP 来说并不是自然的,但已经设计了一种技术,即客户端向服务器发出请求,然后客户端和服务器都不关闭连接。当服务器有事情要告诉客户端时,它会向连接写入数据但保持连接打开。效果就好像客户端发出网络请求,服务器以缓慢且突发的方式做出响应,活动之间有显著的暂停。这样的网络连接通常不会永远保持打开,但如果客户端检测到连接已关闭,它可以简单地发出另一个请求以重新打开连接。
允许服务器向客户端发送消息的这种技术非常有效(尽管在服务器端可能会很昂贵,因为服务器必须维护与所有客户端的活动连接)。由于这是一种有用的编程模式,客户端 JavaScript 通过 EventSource API 支持它。要创建这种长连接到 Web 服务器的请求连接,只需将 URL 传递给EventSource()构造函数。当服务器向连接写入(格式正确的)数据时,EventSource 对象将这些数据转换为您可以监听的事件:
let ticker = new EventSource("stockprices.php");
ticker.addEventListener("bid", (event) => {
displayNewBid(event.data);
}
与消息事件相关联的事件对象具有一个data属性,其中保存服务器作为此事件的有效负载发送的任何字符串。事件对象还具有一个type属性,就像所有事件对象一样,指定事件的名称。服务器确定生成的事件的类型。如果服务器在写入的数据中省略了事件名称,则事件类型默认为“message”。
服务器发送事件协议很简单。客户端启动与服务器的连接(当创建EventSource对象时),服务器保持此连接处于打开状态。当事件发生时,服务器向连接写入文本行。如果省略了注释,通过网络传输的事件可能如下所示:
event: bid // sets the type of the event object
data: GOOG // sets the data property
data: 999 // appends a newline and more data
// a blank line marks the end of the event
协议的一些附加细节允许事件被赋予 ID,并允许重新连接的客户端告诉服务器它收到的最后一个事件的 ID,以便服务器可以重新发送任何错过的事件。然而,这些细节在客户端端是不可见的,并且这里不讨论。
服务器发送事件的一个明显应用是用于多用户协作,如在线聊天。聊天客户端可能使用fetch()来向聊天室发布消息,并使用 EventSource 对象订阅聊天内容的流。示例 15-11 演示了如何使用 EventSource 轻松编写这样的聊天客户端。
示例 15-11. 使用 EventSource 的简单聊天客户端
<html>
<head><title>SSE Chat</title></head>
<body>
<!-- The chat UI is just a single text input field -->
<!-- New chat messages will be inserted before this input field -->
<input id="input" style="width:100%; padding:10px; border:solid black 2px"/>
<script>
// Take care of some UI details
let nick = prompt("Enter your nickname"); // Get user's nickname
let input = document.getElementById("input"); // Find the input field
input.focus(); // Set keyboard focus
// Register for notification of new messages using EventSource
let chat = new EventSource("/chat");
chat.addEventListener("chat", event => { // When a chat message arrives
let div = document.createElement("div"); // Create a <div>
div.append(event.data); // Add text from the message
input.before(div); // And add div before input
input.scrollIntoView(); // Ensure input elt is visible
});
// Post the user's messages to the server using fetch
input.addEventListener("change", ()=>{ // When the user strikes return
fetch("/chat", { // Start an HTTP request to this url.
method: "POST", // Make it a POST request with body
body: nick + ": " + input.value // set to the user's nick and input.
})
.catch(e => console.error); // Ignore response, but log any errors.
input.value = ""; // Clear the input
});
</script>
</body>
</html>
这个聊天程序的服务器端代码并不比客户端代码复杂多少。示例 15-12 是一个简单的 Node HTTP 服务器。当客户端请求根 URL“/”时,它发送了示例 15-11 中显示的聊天客户端代码。当客户端请求 URL“/chat”时,它保存响应对象并保持该连接打开。当客户端向“/chat”发出 POST 请求时,它使用请求的主体作为聊天消息,并使用“text/event-stream”格式将其写入到每个保存的响应对象中。服务器代码监听端口 8080,因此在使用 Node 运行后,将浏览器指向http://localhost:8080以连接并开始与自己聊天。
示例 15-12. 一个服务器发送事件聊天服务器
// This is server-side JavaScript, intended to be run with NodeJS.
// It implements a very simple, completely anonymous chat room.
// POST new messages to /chat, or GET a text/event-stream of messages
// from the same URL. Making a GET request to / returns a simple HTML file
// that contains the client-side chat UI.
const http = require("http");
const fs = require("fs");
const url = require("url");
// The HTML file for the chat client. Used below.
const clientHTML = fs.readFileSync("chatClient.html");
// An array of ServerResponse objects that we're going to send events to
let clients = [];
// Create a new server, and listen on port 8080.
// Connect to http://localhost:8080/ to use it.
let server = new http.Server();
server.listen(8080);
// When the server gets a new request, run this function
server.on("request", (request, response) => {
// Parse the requested URL
let pathname = url.parse(request.url).pathname;
// If the request was for "/", send the client-side chat UI.
if (pathname === "/") { // A request for the chat UI
response.writeHead(200, {"Content-Type": "text/html"}).end(clientHTML);
}
// Otherwise send a 404 error for any path other than "/chat" or for
// any method other than "GET" and "POST"
else if (pathname !== "/chat" ||
(request.method !== "GET" && request.method !== "POST")) {
response.writeHead(404).end();
}
// If the /chat request was a GET, then a client is connecting.
else if (request.method === "GET") {
acceptNewClient(request, response);
}
// Otherwise the /chat request is a POST of a new message
else {
broadcastNewMessage(request, response);
}
});
// This handles GET requests for the /chat endpoint which are generated when
// the client creates a new EventSource object (or when the EventSource
// reconnects automatically).
function acceptNewClient(request, response) {
// Remember the response object so we can send future messages to it
clients.push(response);
// If the client closes the connection, remove the corresponding
// response object from the array of active clients
request.connection.on("end", () => {
clients.splice(clients.indexOf(response), 1);
response.end();
});
// Set headers and send an initial chat event to just this one client
response.writeHead(200, {
"Content-Type": "text/event-stream",
"Connection": "keep-alive",
"Cache-Control": "no-cache"
});
response.write("event: chat\ndata: Connected\n\n");
// Note that we intentionally do not call response.end() here.
// Keeping the connection open is what makes Server-Sent Events work.
}
// This function is called in response to POST requests to the /chat endpoint
// which clients send when users type a new message.
async function broadcastNewMessage(request, response) {
// First, read the body of the request to get the user's message
request.setEncoding("utf8");
let body = "";
for await (let chunk of request) {
body += chunk;
}
// Once we've read the body send an empty response and close the connection
response.writeHead(200).end();
// Format the message in text/event-stream format, prefixing each
// line with "data: "
let message = "data: " + body.replace("\n", "\ndata: ");
// Give the message data a prefix that defines it as a "chat" event
// and give it a double newline suffix that marks the end of the event.
let event = `event: chat\n${message}\n\n`;
// Now send this event to all listening clients
clients.forEach(client => client.write(event));
}
15.11.3 WebSockets
WebSocket API 是一个简单接口,用于复杂和强大的网络协议。WebSockets 允许浏览器中的 JavaScript 代码与服务器轻松交换文本和二进制消息。与服务器发送事件一样,客户端必须建立连接,但一旦连接建立,服务器可以异步向客户端发送消息。与 SSE 不同,支持二进制消息,并且消息可以在双向发送,不仅仅是从服务器到客户端。
使 WebSockets 能够连接的网络协议是 HTTP 的一种扩展。虽然 WebSocket API 让人想起低级网络套接字。但连接端点并不是通过 IP 地址和端口来标识的。相反,当你想使用 WebSocket 协议连接到一个服务时,你使用 URL 指定服务,就像你为 Web 服务所做的那样。然而,WebSocket URL 以wss://开头,而不是https://。 (浏览器通常限制 WebSockets 仅在通过安全的https://连接加载的页面中工作)。
要建立 WebSocket 连接,浏览器首先建立一个 HTTP 连接,并发送一个Upgrade: websocket头部到服务器,请求将连接从 HTTP 协议切换到 WebSocket 协议。这意味着为了在客户端 JavaScript 中使用 WebSockets,你需要与一个也支持 WebSocket 协议的 Web 服务器一起工作,并且需要编写服务器端代码来使用该协议发送和接收数据。如果你的服务器是这样设置的,那么本节将解释一切你需要了解的来处理连接的客户端端。如果你的服务器不支持 WebSocket 协议,考虑使用服务器发送事件(§15.11.2)。
创建、连接和断开 WebSocket
如果你想与支持 WebSocket 的服务器通信,创建一个 WebSocket 对象,指定wss:// URL 来标识你想使用的服务器和服务:
let socket = new WebSocket("wss://example.com/stockticker");
当你创建一个 WebSocket 时,连接过程会自动开始。但是当首次返回时,新创建的 WebSocket 不会连接。
socket 的readyState属性指定连接所处的状态。该属性可以有以下值:
WebSocket.CONNECTING
这个 WebSocket 正在连接。
WebSocket.OPEN
这个 WebSocket 已连接并准备好通信。
WebSocket.CLOSING
这个 WebSocket 连接正在关闭。
WebSocket.CLOSED
这个 WebSocket 已经关闭;无法进行进一步的通信。当初始连接尝试失败时,也会出现这种状态。
当 WebSocket 从连接状态转换到打开状态时,它会触发一个“open”事件,你可以通过设置 WebSocket 的onopen属性或在该对象上调用addEventListener()来监听此事件。
如果 WebSocket 连接发生协议或其他错误,WebSocket 对象会触发一个“error”事件。你可以设置onerror来定义一个处理程序,或者使用addEventListener()。
当您完成一个 WebSocket 时,可以通过调用 WebSocket 对象的 close() 方法来关闭连接。当 WebSocket 变为 CLOSED 状态时,它会触发一个“close”事件,您可以设置 onclose 属性来监听此事件。
通过 WebSocket 发送消息
要向 WebSocket 连接的另一端的服务器发送消息,只需调用 WebSocket 对象的 send() 方法。send() 需要一个消息参数,可以是字符串、Blob、ArrayBuffer、类型化数组或 DataView 对象。
send() 方法缓冲要传输的指定消息,并在消息实际发送之前返回。WebSocket 对象的 bufferedAmount 属性指定已缓冲但尚未发送的字节数。(令人惊讶的是,当此值达到 0 时,WebSocket 不会触发任何事件。)
从 WebSocket 接收消息
要从服务器通过 WebSocket 接收消息,请为“message”事件注册事件处理程序,可以通过设置 WebSocket 对象的 onmessage 属性或调用 addEventListener() 来实现。与“message”事件关联的对象是一个具有包含服务器消息的 data 属性的 MessageEvent 实例。如果服务器发送 UTF-8 编码的文本,则 event.data 将是包含该文本的字符串。
如果服务器发送的消息由二进制数据而不是文本组成,则 data 属性(默认情况下)将是表示该数据的 Blob 对象。如果您希望将二进制消息接收为 ArrayBuffer 而不是 Blob,请将 WebSocket 对象的 binaryType 属性设置为字符串“arraybuffer”。
有许多 Web API 使用 MessageEvent 对象交换消息。其中一些 API 使用结构化克隆算法(参见“结构化克隆算法”)允许复杂的数据结构作为消息负载。WebSocket 不是这些 API 之一:通过 WebSocket 交换的消息要么是单个 Unicode 字符串,要么是单个字节字符串(表示为 Blob 或 ArrayBuffer)。
协议协商
WebSocket 协议允许交换文本和二进制消息,但对这些消息的结构或含义一无所知。使用 WebSocket 的应用程序必须在这种简单的消息交换机制之上构建自己的通信协议。使用 wss:// URL 有助于此:每个 URL 通常都有自己的消息交换规则。如果您编写代码连接到 wss://example.com/stockticker,那么您可能知道您将收到有关股票价格的消息。
然而,协议往往会发展。如果一个假设的股票行情协议被更新,你可以定义一个新的 URL 并连接到更新的服务,如 wss://example.com/stockticker/v2。基于 URL 的版本控制并不总是足够的。对于随时间演变的复杂协议,您可能会遇到支持多个协议版本的部署服务器和支持不同协议版本集的部署客户端。
为了预料到这种情况,WebSocket 协议和 API 包括一个应用级协议协商功能。当你调用 WebSocket() 构造函数时,wss:// URL 是第一个参数,但你也可以将字符串数组作为第二个参数传递。如果这样做,你正在指定一个你知道如何处理的应用程序协议列表,并要求服务器选择一个。在连接过程中,服务器将选择一个协议(或者如果不支持客户端的任何选项,则会失败并显示错误)。一旦建立连接,WebSocket 对象的 protocol 属性指定服务器选择的协议版本。
存储
Web 应用程序可以使用浏览器 API 在用户计算机上本地存储数据。这种客户端存储用于给 Web 浏览器提供内存。Web 应用程序可以存储用户偏好设置,例如,甚至可以存储它们的完整状态,以便它们可以在上次访问结束时恢复到离开的地方。客户端存储按来源分隔,因此来自一个站点的页面无法读取另一个站点的页面存储的数据。但来自同一站点的两个页面可以共享存储并将其用作通信机制。例如,一个页面上的表单中输入的数据可以在另一个页面上的表格中显示。Web 应用程序可以选择存储数据的生命周期:数据可以临时存储,以便仅在窗口关闭或浏览器退出时保留,或者可以保存在用户计算机上并永久存储,以便在几个月或几年后可用。
客户端存储有许多形式:
Web 存储
Web 存储 API 由localStorage和sessionStorage对象组成,它们本质上是将字符串键映射到字符串值的持久对象。Web 存储非常易于使用,适用于存储大量(但不是巨大量)的数据。
Cookies
Cookies 是一种旧的客户端存储机制,设计用于服务器端脚本使用。一个笨拙的 JavaScript API 使得 cookies 在客户端端可脚本化,但它们很难使用,只适用于存储少量文本数据。此外,任何存储为 cookie 的数据都会随着每个 HTTP 请求传输到服务器,即使数据只对客户端感兴趣。
IndexedDB
IndexedDB 是支持索引的对象数据库的异步 API。
15.12.1 localStorage 和 sessionStorage
Window 对象的localStorage和sessionStorage属性指向 Storage 对象。Storage 对象的行为类似于常规的 JavaScript 对象,只是:
-
Storage 对象的属性值必须是字符串。
-
存储在 Storage 对象中的属性是持久的。如果你设置了 localStorage 对象的一个属性,然后用户重新加载页面,你保存在该属性中的值仍然对你的程序可用。
你可以像这样使用 localStorage 对象,例如:
let name = localStorage.username; // Query a stored value.
if (!name) {
name = prompt("What is your name?"); // Ask the user a question.
localStorage.username = name; // Store the user's response.
}
你可以使用delete运算符从localStorage和sessionStorage中删除属性,并且可以使用for/in循环或Object.keys()来枚举 Storage 对象的属性。如果你想要移除存储对象的所有属性,调用clear()方法:
localStorage.clear();
Storage 对象还定义了getItem()、setItem()和deleteItem()方法,如果你想要的话,可以使用这些方法代替直接属性访问和delete运算符。
请记住,Storage 对象的属性只能存储字符串。如果你想要存储和检索其他类型的数据,你必须自己进行编码和解码。
例如:
// If you store a number, it is automatically converted to a string.
// Don't forget to parse it when retrieving it from storage.
localStorage.x = 10;
let x = parseInt(localStorage.x);
// Convert a Date to a string when setting, and parse it when getting
localStorage.lastRead = (new Date()).toUTCString();
let lastRead = new Date(Date.parse(localStorage.lastRead));
// JSON makes a convenient encoding for any primitive or data structure
localStorage.data = JSON.stringify(data); // Encode and store
let data = JSON.parse(localStorage.data); // Retrieve and decode.
存储的生命周期和范围
localStorage和sessionStorage之间的区别涉及存储的生命周期和范围。通过localStorage存储的数据是永久的:它不会过期,并且会一直存储在用户设备上,直到 Web 应用将其删除或用户要求浏览器(通过某些特定于浏览器的 UI)将其删除。
localStorage的范围是文档来源。正如在“同源策略”中解释的那样,文档的来源由其协议、主机名和端口定义。具有相同来源的所有文档共享相同的localStorage数据(无论实际访问localStorage的脚本的来源如何)。它们可以读取彼此的数据,并且可以覆盖彼此的数据。但具有不同来源的文档永远无法读取或覆盖彼此的数据(即使它们都从同一个第三方服务器运行脚本)。
请注意,localStorage也受浏览器实现的范围限制。如果您使用 Firefox 访问网站,然后再次使用 Chrome 访问(例如),则在第一次访问期间存储的任何数据在第二次访问期间将无法访问。
通过sessionStorage存储的数据的生存期与存储它的脚本所在的顶级窗口或浏览器标签页的生存期相同。当窗口或标签页永久关闭时,通过sessionStorage存储的任何数据都将被删除。(但是,请注意,现代浏览器具有重新打开最近关闭的标签页并恢复上次浏览会话的功能,因此这些标签页及其关联的sessionStorage的生存期可能比看起来更长。)
与localStorage类似,sessionStorage也受文档源的范围限制,因此具有不同源的文档永远不会共享sessionStorage。但是,sessionStorage还受每个窗口的范围限制。如果用户有两个显示来自相同源的浏览器标签页,这两个标签页具有单独的sessionStorage数据:运行在一个标签页中的脚本无法读取或覆盖另一个标签页中的脚本写入的数据,即使这两个标签页正在访问完全相同的页面并运行完全相同的脚本。
存储事件
每当存储在localStorage中的数据发生变化时,浏览器会在任何其他可见该数据的窗口对象上触发“storage”事件(但不会在进行更改的窗口上触发)。如果浏览器有两个打开到具有相同源的页面的标签页,并且其中一个页面在localStorage中存储一个值,另一个标签页将接收到“storage”事件。
通过设置window.onstorage或调用window.addEventListener()并设置事件类型为“storage”来为“storage”事件注册处理程序。
与“storage”事件关联的事件对象具有一些重要属性:
key
设置或删除的项目的名称或键。如果调用了clear()方法,则此属性将为null。
newValue
如果存在新项目的新值,则保存该值。如果调用了removeItem(),则此属性将不存在。
oldValue
保存更改或删除的现有项目的旧值。如果调用了removeItem(),则此属性将不存在。
storageArea
发生更改的 Storage 对象。这通常是localStorage对象。
url
使此存储更改的文档的 URL(作为字符串)。
请注意,localStorage和“storage”事件可以作为浏览器向当前访问同一网站的所有窗口发送消息的广播机制。例如,如果用户要求网站停止执行动画,网站可能会将该偏好存储在localStorage中,以便在将来的访问中遵守该偏好。通过存储偏好,它生成一个事件,允许显示相同网站的其他窗口也遵守请求。
另一个例子是,想象一个基于 Web 的图像编辑应用程序,允许用户在单独的窗口中显示工具面板。当用户选择工具时,应用程序使用localStorage保存当前状态,并向其他窗口生成通知,表示已选择新工具。
15.12.2 Cookies
Cookie是由 Web 浏览器存储的一小部分命名数据,并与特定网页或网站相关联。 Cookie 是为服务器端编程设计的,在最低级别上,它们是作为 HTTP 协议的扩展实现的。 Cookie 数据会在 Web 浏览器和 Web 服务器之间自动传输,因此服务器端脚本可以读取和写入存储在客户端上的 Cookie 值。 本节演示了客户端脚本如何使用 Document 对象的cookie属性来操作 Cookie。
操纵 cookie 的 API 是一个古老而神秘的 API。没有涉及方法:通过读取和写入 Document 对象的cookie属性,使用特殊格式的字符串来查询、设置和删除 cookie。每个 cookie 的生存期和范围可以通过 cookie 属性单独指定。这些属性也是通过设置在同一cookie属性上的特殊格式的字符串来指定的。
接下来的小节将解释如何查询和设置 cookie 的值和属性。
读取 cookie
当你读取document.cookie属性时,它会返回一个包含当前文档适用的所有 cookie 的字符串。该字符串是一个由分号和空格分隔的名称/值对列表。cookie 值只是值本身,不包括与该 cookie 相关的任何属性。(我们将在下面讨论属性。)为了利用document.cookie属性,你通常需要调用split()方法将其分割成单独的名称/值对。
一旦从cookie属性中提取了 cookie 的值,你必须根据 cookie 创建者使用的格式或编码来解释该值。例如,你可以将 cookie 值传递给decodeURIComponent(),然后再传递给JSON.parse()。
接下来的代码定义了一个getCookie()函数,该函数解析document.cookie属性并返回一个对象,该对象的属性指定了文档的 cookie 的名称和值:
// Return the document's cookies as a Map object.
// Assume that cookie values are encoded with encodeURIComponent().
function getCookies() {
let cookies = new Map(); // The object we will return
let all = document.cookie; // Get all cookies in one big string
let list = all.split("; "); // Split into individual name/value pairs
for(let cookie of list) { // For each cookie in that list
if (!cookie.includes("=")) continue; // Skip if there is no = sign
let p = cookie.indexOf("="); // Find the first = sign
let name = cookie.substring(0, p); // Get cookie name
let value = cookie.substring(p+1); // Get cookie value
value = decodeURIComponent(value); // Decode the value
cookies.set(name, value); // Remember cookie name and value
}
return cookies;
}
Cookie 属性:生存期和范围
除了名称和值之外,每个 cookie 还有可选属性来控制其生存期和范围。在我们描述如何使用 JavaScript 设置 cookie 之前,我们需要解释 cookie 属性。
默认情况下,cookie 是短暂的;它们存储的值在 Web 浏览器会话期间持续,但当用户退出浏览器时会丢失。如果你希望 cookie 在单个浏览会话之外持续存在,你必须告诉浏览器你希望它保留 cookie 的时间(以秒为单位),通过指定max-age属性。如果指定了生存期,浏览器将把 cookie 存储在一个文件中,并在它们过期时才删除。
Cookie 的可见性受文档来源的范围限制,就像localStorage和sessionStorage一样,但还受文档路径的限制。这个范围可以通过 cookie 属性path和domain进行配置。默认情况下,一个 cookie 与创建它的网页以及该目录或该目录的任何子目录中的任何其他网页相关联并可访问。例如,如果网页example.com/catalog/index.html创建了一个 cookie,那么该 cookie 也对example.com/catalog/order.html和example.com/catalog/widgets/index.html可见,但对example.com/about.html不可见。
默认的可见性行为通常是你想要的。但有时,你可能希望在整个网站中使用 cookie 值,无论哪个页面创建了 cookie。例如,如果用户在一个页面的表单中输入了他们的邮寄地址,你可能希望保存该地址以便在他们下次返回该页面时作为默认地址,并且在另一个页面的一个完全不相关的表单中也作为默认地址。为了允许这种用法,你需要为 cookie 指定一个path。然后,任何以你指定的路径前缀开头的同一 Web 服务器的网页都可以共享该 cookie。例如,如果由example.com/catalog/widgets/index.html设置的 cookie 的路径设置为“/catalog”,那么该 cookie 也对example.com/catalog/order.html可见。或者,如果路径设置为“/”,则该 cookie 对example.com域中的任何页面都可见,使得该 cookie 的范围类似于localStorage。
默认情况下,cookie 由文档来源限定。然而,大型网站可能希望 cookie 在子域之间共享。例如,order.example.com服务器可能需要读取从catalog.example.com设置的 cookie 值。这就是domain属性发挥作用的地方。如果由catalog.example.com页面创建的 cookie 将其path属性设置为“/”并将其domain属性设置为“.example.com”,那么该 cookie 对catalog.example.com、orders.example.com和example.com域中的所有网页都可用。请注意,您不能将 cookie 的域设置为服务器的父域之外的域。
最后一个 cookie 属性是一个名为secure的布尔属性,指定 cookie 值在网络上传输的方式。默认情况下,cookie 是不安全的,这意味着它们通过普通的不安全 HTTP 连接传输。但是,如果标记了安全的 cookie,则只有在浏览器和服务器通过 HTTPS 或其他安全协议连接时才会传输。
存储 cookie
要将瞬态 cookie 值与当前文档关联起来,只需将cookie属性设置为name=value字符串。例如:
document.cookie = `version=${encodeURIComponent(document.lastModified)}`;
下次读取cookie属性时,您存储的名称/值对将包含在文档的 cookie 列表中。Cookie 值不能包含分号、逗号或空格。因此,您可能希望在将其存储在 cookie 中之前使用核心 JavaScript 全局函数encodeURIComponent()对值进行编码。如果这样做,读取 cookie 值时必须使用相应的decodeURIComponent()函数。
使用简单的名称/值对编写的 cookie 在当前的 Web 浏览会话中持续存在,但当用户退出浏览器时会丢失。要创建一个可以跨浏览器会话持续存在的 cookie,请使用max-age属性指定其生存期(以秒为单位)。您可以通过将cookie属性设置为形式为name=value; max-age=seconds的字符串来实现。以下函数设置了一个带有可选max-age属性的 cookie:
// Store the name/value pair as a cookie, encoding the value with
// encodeURIComponent() in order to escape semicolons, commas, and spaces.
// If daysToLive is a number, set the max-age attribute so that the cookie
// expires after the specified number of days. Pass 0 to delete a cookie.
function setCookie(name, value, daysToLive=null) {
let cookie = `${name}=${encodeURIComponent(value)}`;
if (daysToLive !== null) {
cookie += `; max-age=${daysToLive*60*60*24}`;
}
document.cookie = cookie;
}
类似地,您可以通过在document.cookie属性上附加形式为;path=value或;domain=value的字符串来设置 cookie 的path和domain属性。要设置secure属性,只需附加;secure。
要更改 cookie 的值,只需再次使用相同的名称、路径和域以及新值设置其值。通过指定新的max-age属性,您可以在更改其值时更改 cookie 的生存期。
要删除 cookie,只需再次使用相同的名称、路径和域设置它,指定任意(或空)值,并将max-age属性设置为 0。
15.12.3 IndexedDB
传统上,Web 应用程序架构在客户端使用 HTML、CSS 和 JavaScript,在服务器上使用数据库。因此,您可能会惊讶地发现,Web 平台包括一个简单的对象数据库,具有 JavaScript API,用于在用户计算机上持久存储 JavaScript 对象并根据需要检索它们。
IndexedDB 是一个对象数据库,而不是关系数据库,比支持 SQL 查询的数据库简单得多。然而,它比localStorage提供的键/值存储更强大、高效和健壮。与localStorage一样,IndexedDB 数据库的作用域限定在包含文档的来源:具有相同来源的两个网页可以访问彼此的数据,但来自不同来源的网页则不能。
每个源可以拥有任意数量的 IndexedDB 数据库。每个数据库都有一个在源内必须是唯一的名称。在 IndexedDB API 中,数据库只是一组命名的对象存储。顾名思义,对象存储存储对象。对象使用结构化克隆算法(参见“结构化克隆算法”)序列化到对象存储中,这意味着您存储的对象可以具有值为 Maps、Sets 或类型化数组的属性。每个对象必须有一个键,通过该键可以对其进行排序并从存储中检索。键必须是唯一的——同一存储中的两个对象不能具有相同的键——并且它们必须具有自然排序,以便对其进行排序。JavaScript 字符串、数字和日期对象是有效的键。IndexedDB 数据库可以自动生成每个插入到数据库中的对象的唯一键。不过,通常情况下,您插入到对象存储中的对象已经具有适合用作键的属性。在这种情况下,您在创建对象存储时指定该属性的“键路径”。在概念上,键路径是一个值,告诉数据库如何从对象中提取对象的键。
除了通过其主键值从对象存储中检索对象之外,您可能希望能够根据对象中其他属性的值进行搜索。为了能够做到这一点,您可以在对象存储上定义任意数量的索引。(对对象存储进行索引的能力解释了“IndexedDB”这个名称。)每个索引为存储的对象定义了一个次要键。这些索引通常不是唯一的,多个对象可能匹配单个键值。
IndexedDB 提供原子性保证:对数据库的查询和更新被分组在一个事务中,以便它们一起成功或一起失败,并且永远不会使数据库处于未定义的、部分更新的状态。IndexedDB 中的事务比许多数据库 API 中的事务更简单;我们稍后会再次提到它们。
从概念上讲,IndexedDB API 相当简单。要查询或更新数据库,首先打开您想要的数据库(通过名称指定)。接下来,创建一个事务对象,并使用该对象按名称查找数据库中所需的对象存储。最后,通过调用对象存储的get()方法查找对象,或通过调用put()(或通过调用add(),如果要避免覆盖现有对象)存储新对象。
如果要查找一系列键的对象,可以创建一个指定范围的 IDBRange 对象,并将其传递给对象存储的getAll()或openCursor()方法。
如果要使用次要键进行查询,可以查找对象存储的命名索引,然后调用索引对象的get()、getAll()或openCursor()方法,传递单个键或 IDBRange 对象。
然而,IndexedDB API 的概念简单性受到了其异步性的影响(以便 Web 应用程序可以在不阻塞浏览器主 UI 线程的情况下使用它)。IndexedDB 在 Promises 广泛支持之前就已定义,因此 API 是基于事件而不是基于 Promise 的,这意味着它不支持async和await。
创建事务、查找对象存储和索引都是同步操作。但打开数据库、更新对象存储和查询存储或索引都是异步操作。这些异步方法都会立即返回一个请求对象。当请求成功或失败时,浏览器会在请求对象上触发成功或错误事件,并且你可以使用 onsuccess 和 onerror 属性定义处理程序。在 onsuccess 处理程序中,操作的结果可以作为请求对象的 result 属性获得。另一个有用的事件是当事务成功完成时在事务对象上分派的“complete”事件。
这个异步 API 的一个便利特性是它简化了事务管理。IndexedDB API 强制你创建一个事务对象,以便获取可以执行查询和更新的对象存储。在同步 API 中,你期望通过调用 commit() 方法显式标记事务的结束。但是在 IndexedDB 中,当所有 onsuccess 事件处理程序运行并且没有更多引用该事务的待处理异步请求时,事务会自动提交(如果你没有显式中止它们)。
IndexedDB API 中还有一个重要的事件。当你首次打开数据库,或者增加现有数据库的版本号时,IndexedDB 会在由 indexedDB.open() 调用返回的请求对象上触发一个“upgradeneeded”事件。对于“upgradeneeded”事件的事件处理程序的工作是定义或更新新数据库(或现有数据库的新版本)的模式。对于 IndexedDB 数据库,这意味着创建对象存储并在这些对象存储上定义索引。实际上,IndexedDB API 允许你创建对象存储或索引的唯一时机就是响应“upgradeneeded”事件。
有了对 IndexedDB 的高级概述,你现在应该能够理解 示例 15-13。该示例使用 IndexedDB 创建和查询一个将美国邮政编码(邮政编码)映射到美国城市的数据库。它展示了 IndexedDB 的许多基本特性,但并非全部。示例 15-13 非常长,但有很好的注释。
示例 15-13。一个美国邮政编码的 IndexedDB 数据库
// This utility function asynchronously obtains the database object (creating
// and initializing the DB if necessary) and passes it to the callback.
function withDB(callback) {
let request = indexedDB.open("zipcodes", 1); // Request v1 of the database
request.onerror = console.error; // Log any errors
request.onsuccess = () => { // Or call this when done
let db = request.result; // The result of the request is the database
callback(db); // Invoke the callback with the database
};
// If version 1 of the database does not yet exist, then this event
// handler will be triggered. This is used to create and initialize
// object stores and indexes when the DB is first created or to modify
// them when we switch from one version of the DB schema to another.
request.onupgradeneeded = () => { initdb(request.result, callback); };
}
// withDB() calls this function if the database has not been initialized yet.
// We set up the database and populate it with data, then pass the database to
// the callback function.
//
// Our zip code database includes one object store that holds objects like this:
//
// {
// zipcode: "02134",
// city: "Allston",
// state: "MA",
// }
//
// We use the "zipcode" property as the database key and create an index for
// the city name.
function initdb(db, callback) {
// Create the object store, specifying a name for the store and
// an options object that includes the "key path" specifying the
// property name of the key field for this store.
let store = db.createObjectStore("zipcodes", // store name
{ keyPath: "zipcode" });
// Now index the object store by city name as well as by zip code.
// With this method the key path string is passed directly as a
// required argument rather than as part of an options object.
store.createIndex("cities", "city");
// Now get the data we are going to initialize the database with.
// The zipcodes.json data file was generated from CC-licensed data from
// www.geonames.org: https://download.geonames.org/export/zip/US.zip
fetch("zipcodes.json") // Make an HTTP GET request
.then(response => response.json()) // Parse the body as JSON
.then(zipcodes => { // Get 40K zip code records
// In order to insert zip code data into the database we need a
// transaction object. To create our transaction object, we need
// to specify which object stores we'll be using (we only have
// one) and we need to tell it that we'll be doing writes to the
// database, not just reads:
let transaction = db.transaction(["zipcodes"], "readwrite");
transaction.onerror = console.error;
// Get our object store from the transaction
let store = transaction.objectStore("zipcodes");
// The best part about the IndexedDB API is that object stores
// are *really* simple. Here's how we add (or update) our records:
for(let record of zipcodes) { store.put(record); }
// When the transaction completes successfully, the database
// is initialized and ready for use, so we can call the
// callback function that was originally passed to withDB()
transaction.oncomplete = () => { callback(db); };
});
}
// Given a zip code, use the IndexedDB API to asynchronously look up the city
// with that zip code, and pass it to the specified callback, or pass null if
// no city is found.
function lookupCity(zip, callback) {
withDB(db => {
// Create a read-only transaction object for this query. The
// argument is an array of object stores we will need to use.
let transaction = db.transaction(["zipcodes"]);
// Get the object store from the transaction
let zipcodes = transaction.objectStore("zipcodes");
// Now request the object that matches the specified zipcode key.
// The lines above were synchronous, but this one is async.
let request = zipcodes.get(zip);
request.onerror = console.error; // Log errors
request.onsuccess = () => { // Or call this function on success
let record = request.result; // This is the query result
if (record) { // If we found a match, pass it to the callback
callback(`${record.city}, ${record.state}`);
} else { // Otherwise, tell the callback that we failed
callback(null);
}
};
});
}
// Given the name of a city, use the IndexedDB API to asynchronously
// look up all zip code records for all cities (in any state) that have
// that (case-sensitive) name.
function lookupZipcodes(city, callback) {
withDB(db => {
// As above, we create a transaction and get the object store
let transaction = db.transaction(["zipcodes"]);
let store = transaction.objectStore("zipcodes");
// This time we also get the city index of the object store
let index = store.index("cities");
// Ask for all matching records in the index with the specified
// city name, and when we get them we pass them to the callback.
// If we expected more results, we might use openCursor() instead.
let request = index.getAll(city);
request.onerror = console.error;
request.onsuccess = () => { callback(request.result); };
});
}
15.13 工作线程和消息传递
JavaScript 的一个基本特性是它是单线程的:浏览器永远不会同时运行两个事件处理程序,也永远不会在事件处理程序运行时触发计时器,例如。对应用程序状态或文档的并发更新根本不可能,客户端程序员不需要考虑或甚至理解并发编程。一个推论是客户端 JavaScript 函数不能运行太长时间;否则,它们将占用事件循环,并且 Web 浏览器将对用户输入无响应。这就是例如 fetch() 是异步函数的原因。
Web 浏览器非常谨慎地通过 Worker 类放宽了单线程要求:这个类的实例代表与主线程和事件循环并发运行的线程。工作者生存在一个独立的执行环境中,具有完全独立的全局对象,没有访问 Window 或 Document 对象的权限。工作者只能通过异步消息传递与主线程通信。这意味着 DOM 的并发修改仍然是不可能的,但也意味着你可以编写不会阻塞事件循环并使浏览器挂起的长时间运行函数。创建一个新的工作者不像打开一个新的浏览器窗口那样消耗资源,但工作者也不是轻量级的“纤程”,创建新的工作者来执行琐碎的操作是没有意义的。复杂的 Web 应用程序可能会发现创建数十个工作者很有用,但是一个具有数百或数千个工作者的应用程序可能并不实用。
当你的应用程序需要执行计算密集型任务时,工作者非常有用,比如图像处理。使用工作者将这样的任务移出主线程,以免浏览器变得无响应。而且工作者还提供了将工作分配给多个线程的可能性。但是当你需要执行频繁的中等强度计算时,工作者也非常有用。例如,假设你正在实现一个简单的浏览器内代码编辑器,并且想要包含语法高亮显示。为了正确高亮显示,你需要在每次按键时解析代码。但如果你在主线程上这样做,很可能解析代码会阻止响应用户按键的事件处理程序及时运行,用户的输入体验将会变得迟缓。
与任何线程 API 一样,Worker API 有两个部分。第一个是 Worker 对象:这是一个工作者从外部看起来的样子,对于创建它的线程来说。第二个是 WorkerGlobalScope:这是一个新工作者的全局对象,对于工作者线程来说,它是内部的样子。
以下部分涵盖了 Worker 和 WorkerGlobalScope,并解释了允许工作者与主线程和彼此通信的消息传递 API。相同的通信 API 也用于在文档和包含在文档中的<iframe>元素之间交换消息,这也在以下部分中进行了介绍。
15.13.1 工作者对象
要创建一个新的工作者,调用Worker()构造函数,传递一个指定要运行的 JavaScript 代码的 URL:
let dataCruncher = new Worker("utils/cruncher.js");
如果你指定一个相对 URL,它将相对于包含调用Worker()构造函数的脚本的文档的 URL 进行解析。如果你指定一个绝对 URL,它必须与包含文档的原点(相同的协议、主机和端口)相同。
一旦你有了一个 Worker 对象,你可以使用postMessage()向其发送数据。你传递给postMessage()的值将使用结构化克隆算法进行复制(参见“结构化克隆算法”),并且生成的副本将通过消息事件传递给工作者:
dataCruncher.postMessage("/api/data/to/crunch");
在这里我们只是传递了一个单个字符串消息,但你也可以使用对象、数组、类型化数组、Map、Set 等等。你可以通过在 Worker 对象上监听“message”事件来接收来自工作者的消息:
dataCruncher.onmessage = function(e) {
let stats = e.data; // The message is the data property of the event
console.log(`Average: ${stats.mean}`);
}
像所有事件目标一样,Worker 对象定义了标准的addEventListener()和removeEventListener()方法,你可以在这些方法中使用onmessage。
除了postMessage()之外,Worker 对象只有另一个方法,terminate(),它可以强制停止一个工作者线程的运行。
15.13.2 工作者中的全局对象
当使用 Worker() 构造函数创建一个新的 worker 时,您需要指定一个 JavaScript 代码文件的 URL。该代码在一个新的、干净的 JavaScript 执行环境中执行,与创建 worker 的脚本隔离。该新执行环境的全局对象是一个 WorkerGlobalScope 对象。WorkerGlobalScope 不仅仅是核心 JavaScript 全局对象,但也不是完整的客户端 Window 对象。
WorkerGlobalScope 对象具有一个 postMessage() 方法和一个 onmessage 事件处理程序属性,与 Worker 对象的类似,但工作方向相反:在 worker 内部调用 postMessage() 会在 worker 外部生成一个消息事件,而从 worker 外部发送的消息会被转换为事件并传递给 onmessage 处理程序。因为 WorkerGlobalScope 是 worker 的全局对象,对于 worker 代码来说,postMessage() 和 onmessage 看起来像是全局函数和全局变量。
如果将对象作为 Worker() 构造函数的第二个参数传递,并且该对象具有一个 name 属性,则该属性的值将成为 worker 全局对象中 name 属性的值。worker 可能会在使用 console.warn() 或 console.error() 打印的任何消息中包含此名称。
close() 函数允许 worker 终止自身,其效果类似于 Worker 对象的 terminate() 方法。
由于 WorkerGlobalScope 是 worker 的全局对象,它具有核心 JavaScript 全局对象的所有属性,例如 JSON 对象、isNaN() 函数和 Date() 构造函数。但是,WorkerGlobalScope 还具有客户端 Window 对象的以下属性:
-
self是全局对象本身的引用。WorkerGlobalScope 不是 Window 对象,也不定义window属性。 -
定时器方法
setTimeout()、clearTimeout()、setInterval()和clearInterval()。 -
一个描述传递给
Worker()构造函数的 URL 的location属性。该属性引用一个 Location 对象,就像 Window 的location属性一样。然而,在 worker 中,这些属性是只读的。 -
一个
navigator属性,指向一个具有类似于窗口 Navigator 对象的属性的对象。worker 的 Navigator 对象具有appName、appVersion、platform、userAgent和onLine属性。 -
常见的事件目标方法
addEventListener()和removeEventListener()。
最后,WorkerGlobalScope 对象包括重要的客户端 JavaScript API,包括 Console 对象、fetch() 函数和 IndexedDB API。WorkerGlobalScope 还包括 Worker() 构造函数,这意味着 worker 线程可以创建自己的 workers。
15.13.3 将代码导入到 Worker 中
在 JavaScript 没有模块系统之前,Web 浏览器中定义了 workers,因此 workers 具有一个独特的包含额外代码的系统。WorkerGlobalScope 将 importScripts() 定义为所有 workers 都可以访问的全局函数:
// Before we start working, load the classes and utilities we'll need
importScripts("utils/Histogram.js", "utils/BitSet.js");
importScripts() 接受一个或多个 URL 参数,每个参数应该指向一个 JavaScript 代码文件。相对 URL 是相对于传递给 Worker() 构造函数的 URL 解析的(而不是相对于包含文档)。importScripts() 同步加载并依次执行这些文件,按照指定的顺序。如果加载脚本导致网络错误,或者执行引发任何错误,那么后续的脚本都不会加载或执行。使用 importScripts() 加载的脚本本身可以调用 importScripts() 来加载其依赖的文件。但请注意,importScripts() 不会尝试跟踪已加载的脚本,并且不会阻止依赖循环。
importScripts() 是一个同步函数:直到所有脚本都加载并执行完毕后才会返回。一旦importScripts()返回,你就可以立即开始使用加载的脚本:不需要回调、事件处理程序、then()方法或await。一旦你内化了客户端 JavaScript 的异步特性,再回到简单的同步编程会感到奇怪。但这就是线程的美妙之处:你可以在工作线程中使用阻塞函数调用,而不会阻塞主线程中的事件循环,也不会阻塞其他工作线程同时执行的计算。
15.13.4 工作线程执行模型
工作线程从上到下同步运行其代码(以及所有导入的脚本或模块),然后进入一个异步阶段,响应事件和定时器。如果工作线程注册了“message”事件处理程序,只要仍有可能到达消息事件,它就永远不会退出。但如果工作线程不监听消息,它将一直运行,直到没有进一步的待处理任务(如fetch()承诺和定时器)并且所有与任务相关的回调都已调用。一旦所有注册的回调都被调用,工作线程就无法开始新任务,因此线程可以安全退出,它会自动执行。工作线程还可以通过调用全局的close()函数显式关闭自身。请注意,工作线程对象上没有指定工作线程是否仍在运行的属性或方法,因此工作线程不应在没有与父线程协调的情况下关闭自身。
工作线程中的错误
如果工作线程中发生异常并且没有被任何catch子句捕获,那么将在工作线程的全局对象上触发一个“error”事件。如果处理了此事件并且处理程序调用了事件对象的preventDefault()方法,则错误传播结束。否则,“error”事件将在 Worker 对象上触发。如果在那里调用了preventDefault(),则传播结束。否则,将在开发者控制台中打印错误消息,并调用 Window 对象的 onerror 处理程序(§15.1.7)。
// Handle uncaught worker errors with a handler inside the worker.
self.onerror = function(e) {
console.log(`Error in worker at ${e.filename}:${e.lineno}: ${e.message}`);
e.preventDefault();
};
// Or, handle uncaught worker errors with a handler outside the worker.
worker.onerror = function(e) {
console.log(`Error in worker at ${e.filename}:${e.lineno}: ${e.message}`);
e.preventDefault();
};
与窗口类似,工作线程可以注册一个处理程序,在 Promise 被拒绝且没有.catch()函数处理时调用。在工作线程中,你可以通过定义一个self.onunhandledrejection函数或使用addEventListener()注册一个全局处理程序来检测这种情况。传递给此处理程序的事件对象将具有一个promise属性,其值是被拒绝的 Promise 对象,以及一个reason属性,其值是将传递给.catch()函数的值。
15.13.5 postMessage()、MessagePorts 和 MessageChannels
Worker 对象的postMessage()方法和工作线程内部定义的全局postMesage()函数都通过调用一对自动与工作线程一起创建的 MessagePort 对象的postMessage()方法来工作。客户端 JavaScript 无法直接访问这些自动创建的 MessagePort 对象,但可以使用MessageChannel()构造函数创建新的连接端口对:
let channel = new MessageChannel; // Create a new channel.
let myPort = channel.port1; // It has two ports
let yourPort = channel.port2; // connected to each other.
myPort.postMessage("Can you hear me?"); // A message posted to one will
yourPort.onmessage = (e) => console.log(e.data); // be received on the other.
MessageChannel 是一个具有port1和port2属性的对象,这些属性指向一对连接的 MessagePort 对象。MessagePort 是一个具有postMessage()方法和onmessage事件处理程序属性的对象。当在连接的一对端口上调用postMessage()时,另一端口将触发“message”事件。您可以通过设置onmessage属性或使用addEventListener()注册“message”事件的监听器来接收这些“message”事件。
发送到端口的消息将排队,直到定义了 onmessage 属性或直到在端口上调用了 start() 方法。这可以防止一个通道的一端发送的消息被另一端错过。如果您在 MessagePort 上使用 addEventListener(),不要忘记调用 start(),否则您可能永远看不到消息被传递。
到目前为止,我们看到的所有 postMessage() 调用都接受一个单一的消息参数。但该方法还接受一个可选的第二个参数。这个第二个参数是一个要传输到通道另一端的项目数组,而不是在通道上发送一个副本。可以传输而不是复制的值包括 MessagePorts 和 ArrayBuffers。 (一些浏览器还实现了其他可传输类型,如 ImageBitmap 和 OffscreenCanvas。然而,并非所有浏览器都支持,本书不涵盖这些内容。)如果 postMessage() 的第一个参数包含一个 MessagePort(在消息对象的任何地方嵌套),那么该 MessagePort 也必须出现在第二个参数中。如果这样做,那么 MessagePort 将在通道的另一端变为可用,并且在您的端口上立即变为不可用。假设您创建了一个 worker 并希望有两个用于与其通信的通道:一个用于普通数据交换,一个用于高优先级消息。在主线程中,您可以创建一个 MessageChannel,然后在 worker 上调用 postMessage() 以将其中一个 MessagePorts 传递给它:
let worker = new Worker("worker.js");
let urgentChannel = new MessageChannel();
let urgentPort = urgentChannel.port1;
worker.postMessage({ command: "setUrgentPort", value: urgentChannel.port2 },
[ urgentChannel.port2 ]);
// Now we can receive urgent messages from the worker like this
urgentPort.addEventListener("message", handleUrgentMessage);
urgentPort.start(); // Start receiving messages
// And send urgent messages like this
urgentPort.postMessage("test");
如果您创建了两个 worker 并希望它们直接进行通信而不需要主线程上的代码来中继消息,则 MessageChannels 也非常有用。
postMessage() 的第二个参数的另一个用途是在 worker 之间传递 ArrayBuffers 而无需复制它们。对于像保存图像数据的大型 ArrayBuffers 这样的情况,这是一个重要的性能增强。当一个 ArrayBuffer 被传输到一个 MessagePort 上时,该 ArrayBuffer 在原始线程中变得不可用,因此不可能同时访问其内容。如果 postMessage() 的第一个参数包含一个 ArrayBuffer,或者包含一个具有 ArrayBuffer 的任何值(例如一个 typed array),那么该缓冲区可能会出现在第二个 postMessage() 参数中作为一个数组元素。如果出现了,那么它将被传输而不是复制。如果没有出现,那么该 ArrayBuffer 将被复制而不是传输。示例 15-14 将演示如何使用这种传输技术与 ArrayBuffers。
15.13.6 使用 postMessage() 进行跨源消息传递
在客户端 JavaScript 中,postMessage() 方法还有另一个用例。它涉及窗口而不是 worker,但两种情况之间有足够的相似之处,我们将在这里描述 Window 对象的 postMessage() 方法。
当文档包含一个 <iframe> 元素时,该元素充当一个嵌入但独立的窗口。代表 <iframe> 的 Element 对象具有一个 contentWindow 属性,该属性是嵌入文档的 Window 对象。对于在嵌套 iframe 中运行的脚本,window.parent 属性指的是包含的 Window 对象。当两个窗口显示具有相同源的文档时,那么每个窗口中的脚本都可以访问另一个窗口的内容。但是当文档具有不同的源时,浏览器的同源策略会阻止一个窗口中的 JavaScript 访问另一个窗口的内容。
对于 worker,postMessage()提供了两个独立线程之间进行通信而不共享内存的安全方式。对于窗口,postMessage()提供了两个独立来源之间安全交换消息的受控方式。即使同源策略阻止你的脚本查看另一个窗口的内容,你仍然可以在该窗口上调用postMessage(),这将导致该窗口上触发“message”事件,可以被该窗口脚本中的事件处理程序看到。
然而,Window 的postMessage()方法与 Worker 的postMessage()方法有些不同。第一个参数仍然是将通过结构化克隆算法复制的任意消息。但是,列出要传输而不是复制的对象的可选第二个参数变成了可选的第三个参数。窗口的postMessage()方法将字符串作为其必需的第二个参数。这第二个参数应该是一个指定你期望接收消息的来源(协议、主机名和可选端口)的来源。如果你将字符串“https://good.example.com”作为第二个参数传递,但你要发送消息的窗口实际上包含来自“https://malware.example.com”的内容,那么你发送的消息将不会被传递。如果你愿意将消息发送给任何来源的内容,那么可以将通配符“*”作为第二个参数传递。
在窗口或<iframe>中运行的 JavaScript 代码可以通过定义该窗口的onmessage属性或调用addEventListener()来接收发送到该窗口或帧的消息。与 worker 一样,当你接收到窗口的“message”事件时,事件对象的data属性就是发送的消息。此外,传递给窗口的“message”事件还定义了source和origin属性。source属性指定发送事件的 Window 对象,你可以使用event.source.postMessage()来发送回复。origin属性指定源窗口中内容的来源。这不是消息发送者可以伪造的内容,当你接收到“message”事件时,通常会希望验证它来自你期望的来源。
15.14 示例:曼德勃罗特集
这一章关于客户端 JavaScript 的内容以一个长篇示例告终,演示了如何使用 worker 和消息传递来并行化计算密集型任务。但它被写成一个引人入胜的、真实的 Web 应用程序,还演示了本章中展示的其他 API,包括历史管理;使用带有<canvas>的 ImageData 类;以及键盘、指针和调整大小事件的使用。它还演示了重要的核心 JavaScript 功能,包括生成器和对 Promise 的复杂使用。
该示例是一个用于显示和探索曼德勃罗特集的程序,这是一个包含美丽图像的复杂分形,如图 15-16 所示。

图 15-16. 曼德勃罗特集的一部分
Mandelbrot 集合被定义为复平面上的点集,当通过复数乘法和加法的重复过程产生一个值,其大小保持有界时。集合的轮廓非常复杂,计算哪些点是集合的成员,哪些不是,是计算密集型的:要生成一个 500×500 的 Mandelbrot 集合图像,您必须分别计算图像中的 250,000 个像素中的每一个的成员资格。为了验证与每个像素关联的值保持有界,您可能需要重复进行复数乘法的过程 1,000 次或更多。 (更多的迭代会产生更清晰定义的集合边界;更少的迭代会产生模糊的边界。)要生成一个高质量的 Mandelbrot 集合图像,需要进行高达 2.5 亿步的复数运算,您可以理解为什么使用 worker 是一种有价值的技术。示例 15-14 显示了我们将使用的 worker 代码。这个文件相对紧凑:它只是更大程序的原始计算力量。但是,关于它有两件值得注意的事情:
-
Worker 创建一个 ImageData 对象来表示它正在计算 Mandelbrot 集合成员资格的像素的矩形网格。但是,它不是在 ImageData 中存储实际的像素值,而是使用自定义类型的数组将每个像素视为 32 位整数。它在此数组中存储每个像素所需的迭代次数。如果为每个像素计算的复数的大小超过四,则从那时起它在数学上保证会无限增长,我们称之为“逃逸”。因此,该 worker 为每个像素返回的值是逃逸前的迭代次数。我们告诉 worker 它应该为每个值尝试的最大迭代次数,并且达到此最大次数的像素被视为在集合中。
-
Worker 将与 ImageData 关联的 ArrayBuffer 传回主线程,因此不需要复制与之关联的内存。
示例 15-14. 计算 Mandelbrot 集合区域的 Worker 代码
// This is a simple worker that receives a message from its parent thread,
// performs the computation described by that message and then posts the
// result of that computation back to the parent thread.
onmessage = function(message) {
// First, we unpack the message we received:
// - tile is an object with width and height properties. It specifies the
// size of the rectangle of pixels for which we will be computing
// Mandelbrot set membership.
// - (x0, y0) is the point in the complex plane that corresponds to the
// upper-left pixel in the tile.
// - perPixel is the pixel size in both the real and imaginary dimensions.
// - maxIterations specifies the maximum number of iterations we will
// perform before deciding that a pixel is in the set.
const {tile, x0, y0, perPixel, maxIterations} = message.data;
const {width, height} = tile;
// Next, we create an ImageData object to represent the rectangular array
// of pixels, get its internal ArrayBuffer, and create a typed array view
// of that buffer so we can treat each pixel as a single integer instead of
// four individual bytes. We'll store the number of iterations for each
// pixel in this iterations array. (The iterations will be transformed into
// actual pixel colors in the parent thread.)
const imageData = new ImageData(width, height);
const iterations = new Uint32Array(imageData.data.buffer);
// Now we begin the computation. There are three nested for loops here.
// The outer two loop over the rows and columns of pixels, and the inner
// loop iterates each pixel to see if it "escapes" or not. The various
// loop variables are the following:
// - row and column are integers representing the pixel coordinate.
// - x and y represent the complex point for each pixel: x + yi.
// - index is the index in the iterations array for the current pixel.
// - n tracks the number of iterations for each pixel.
// - max and min track the largest and smallest number of iterations
// we've seen so far for any pixel in the rectangle.
let index = 0, max = 0, min=maxIterations;
for(let row = 0, y = y0; row < height; row++, y += perPixel) {
for(let column = 0, x = x0; column < width; column++, x += perPixel) {
// For each pixel we start with the complex number c = x+yi.
// Then we repeatedly compute the complex number z(n+1) based on
// this recursive formula:
// z(0) = c
// z(n+1) = z(n)² + c
// If |z(n)| (the magnitude of z(n)) is > 2, then the
// pixel is not part of the set and we stop after n iterations.
let n; // The number of iterations so far
let r = x, i = y; // Start with z(0) set to c
for(n = 0; n < maxIterations; n++) {
let rr = r*r, ii = i*i; // Square the two parts of z(n).
if (rr + ii > 4) { // If |z(n)|² is > 4 then
break; // we've escaped and can stop iterating.
}
i = 2*r*i + y; // Compute imaginary part of z(n+1).
r = rr - ii + x; // And the real part of z(n+1).
}
iterations[index++] = n; // Remember # iterations for each pixel.
if (n > max) max = n; // Track the maximum number we've seen.
if (n < min) min = n; // And the minimum as well.
}
}
// When the computation is complete, send the results back to the parent
// thread. The imageData object will be copied, but the giant ArrayBuffer
// it contains will be transferred for a nice performance boost.
postMessage({tile, imageData, min, max}, [imageData.data.buffer]);
};
使用该 worker 代码的 Mandelbrot 集合查看器应用程序显示在 示例 15-15 中。现在您几乎已经到达本章的末尾,这个长示例是一个汇总体验,汇集了许多重要的核心和客户端 JavaScript 功能和 API。代码有详细的注释,我鼓励您仔细阅读。
示例 15-15. 用于显示和探索 Mandelbrot 集合的 Web 应用程序
/*
* This class represents a subrectangle of a canvas or image. We use Tiles to
* divide a canvas into regions that can be processed independently by Workers.
*/
class Tile {
constructor(x, y, width, height) {
this.x = x; // The properties of a Tile object
this.y = y; // represent the position and size
this.width = width; // of the tile within a larger
this.height = height; // rectangle.
}
// This static method is a generator that divides a rectangle of the
// specified width and height into the specified number of rows and
// columns and yields numRows*numCols Tile objects to cover the rectangle.
static *tiles(width, height, numRows, numCols) {
let columnWidth = Math.ceil(width / numCols);
let rowHeight = Math.ceil(height / numRows);
for(let row = 0; row < numRows; row++) {
let tileHeight = (row < numRows-1)
? rowHeight // height of most rows
: height - rowHeight * (numRows-1); // height of last row
for(let col = 0; col < numCols; col++) {
let tileWidth = (col < numCols-1)
? columnWidth // width of most columns
: width - columnWidth * (numCols-1); // and last column
yield new Tile(col * columnWidth, row * rowHeight,
tileWidth, tileHeight);
}
}
}
}
/*
* This class represents a pool of workers, all running the same code. The
* worker code you specify must respond to each message it receives by
* performing some kind of computation and then posting a single message with
* the result of that computation.
*
* Given a WorkerPool and message that represents work to be performed, simply
* call addWork(), with the message as an argument. If there is a Worker
* object that is currently idle, the message will be posted to that worker
* immediately. If there are no idle Worker objects, the message will be
* queued and will be posted to a Worker when one becomes available.
*
* addWork() returns a Promise, which will resolve with the message recieved
* from the work, or will reject if the worker throws an unhandled error.
*/
class WorkerPool {
constructor(numWorkers, workerSource) {
this.idleWorkers = []; // Workers that are not currently working
this.workQueue = []; // Work not currently being processed
this.workerMap = new Map(); // Map workers to resolve and reject funcs
// Create the specified number of workers, add message and error
// handlers and save them in the idleWorkers array.
for(let i = 0; i < numWorkers; i++) {
let worker = new Worker(workerSource);
worker.onmessage = message => {
this._workerDone(worker, null, message.data);
};
worker.onerror = error => {
this._workerDone(worker, error, null);
};
this.idleWorkers[i] = worker;
}
}
// This internal method is called when a worker finishes working, either
// by sending a message or by throwing an error.
_workerDone(worker, error, response) {
// Look up the resolve() and reject() functions for this worker
// and then remove the worker's entry from the map.
let [resolver, rejector] = this.workerMap.get(worker);
this.workerMap.delete(worker);
// If there is no queued work, put this worker back in
// the list of idle workers. Otherwise, take work from the queue
// and send it to this worker.
if (this.workQueue.length === 0) {
this.idleWorkers.push(worker);
} else {
let [work, resolver, rejector] = this.workQueue.shift();
this.workerMap.set(worker, [resolver, rejector]);
worker.postMessage(work);
}
// Finally, resolve or reject the promise associated with the worker.
error === null ? resolver(response) : rejector(error);
}
// This method adds work to the worker pool and returns a Promise that
// will resolve with a worker's response when the work is done. The work
// is a value to be passed to a worker with postMessage(). If there is an
// idle worker, the work message will be sent immediately. Otherwise it
// will be queued until a worker is available.
addWork(work) {
return new Promise((resolve, reject) => {
if (this.idleWorkers.length > 0) {
let worker = this.idleWorkers.pop();
this.workerMap.set(worker, [resolve, reject]);
worker.postMessage(work);
} else {
this.workQueue.push([work, resolve, reject]);
}
});
}
}
/*
* This class holds the state information necessary to render a Mandelbrot set.
* The cx and cy properties give the point in the complex plane that is the
* center of the image. The perPixel property specifies how much the real and
* imaginary parts of that complex number changes for each pixel of the image.
* The maxIterations property specifies how hard we work to compute the set.
* Larger numbers require more computation but produce crisper images.
* Note that the size of the canvas is not part of the state. Given cx, cy, and
* perPixel we simply render whatever portion of the Mandelbrot set fits in
* the canvas at its current size.
*
* Objects of this type are used with history.pushState() and are used to read
* the desired state from a bookmarked or shared URL.
*/
class PageState {
// This factory method returns an initial state to display the entire set.
static initialState() {
let s = new PageState();
s.cx = -0.5;
s.cy = 0;
s.perPixel = 3/window.innerHeight;
s.maxIterations = 500;
return s;
}
// This factory method obtains state from a URL, or returns null if
// a valid state could not be read from the URL.
static fromURL(url) {
let s = new PageState();
let u = new URL(url); // Initialize state from the url's search params.
s.cx = parseFloat(u.searchParams.get("cx"));
s.cy = parseFloat(u.searchParams.get("cy"));
s.perPixel = parseFloat(u.searchParams.get("pp"));
s.maxIterations = parseInt(u.searchParams.get("it"));
// If we got valid values, return the PageState object, otherwise null.
return (isNaN(s.cx) || isNaN(s.cy) || isNaN(s.perPixel)
|| isNaN(s.maxIterations))
? null
: s;
}
// This instance method encodes the current state into the search
// parameters of the browser's current location.
toURL() {
let u = new URL(window.location);
u.searchParams.set("cx", this.cx);
u.searchParams.set("cy", this.cy);
u.searchParams.set("pp", this.perPixel);
u.searchParams.set("it", this.maxIterations);
return u.href;
}
}
// These constants control the parallelism of the Mandelbrot set computation.
// You may need to adjust them to get optimum performance on your computer.
const ROWS = 3, COLS = 4, NUMWORKERS = navigator.hardwareConcurrency || 2;
// This is the main class of our Mandelbrot set program. Simply invoke the
// constructor function with the <canvas> element to render into. The program
// assumes that this <canvas> element is styled so that it is always as big
// as the browser window.
class MandelbrotCanvas {
constructor(canvas) {
// Store the canvas, get its context object, and initialize a WorkerPool
this.canvas = canvas;
this.context = canvas.getContext("2d");
this.workerPool = new WorkerPool(NUMWORKERS, "mandelbrotWorker.js");
// Define some properties that we'll use later
this.tiles = null; // Subregions of the canvas
this.pendingRender = null; // We're not currently rendering
this.wantsRerender = false; // No render is currently requested
this.resizeTimer = null; // Prevents us from resizing too frequently
this.colorTable = null; // For converting raw data to pixel values.
// Set up our event handlers
this.canvas.addEventListener("pointerdown", e => this.handlePointer(e));
window.addEventListener("keydown", e => this.handleKey(e));
window.addEventListener("resize", e => this.handleResize(e));
window.addEventListener("popstate", e => this.setState(e.state, false));
// Initialize our state from the URL or start with the initial state.
this.state =
PageState.fromURL(window.location) || PageState.initialState();
// Save this state with the history mechanism.
history.replaceState(this.state, "", this.state.toURL());
// Set the canvas size and get an array of tiles that cover it.
this.setSize();
// And render the Mandelbrot set into the canvas.
this.render();
}
// Set the canvas size and initialize an array of Tile objects. This
// method is called from the constructor and also by the handleResize()
// method when the browser window is resized.
setSize() {
this.width = this.canvas.width = window.innerWidth;
this.height = this.canvas.height = window.innerHeight;
this.tiles = [...Tile.tiles(this.width, this.height, ROWS, COLS)];
}
// This function makes a change to the PageState, then re-renders the
// Mandelbrot set using that new state, and also saves the new state with
// history.pushState(). If the first argument is a function that function
// will be called with the state object as its argument and should make
// changes to the state. If the first argument is an object, then we simply
// copy the properties of that object into the state object. If the optional
// second argument is false, then the new state will not be saved. (We
// do this when calling setState in response to a popstate event.)
setState(f, save=true) {
// If the argument is a function, call it to update the state.
// Otherwise, copy its properties into the current state.
if (typeof f === "function") {
f(this.state);
} else {
for(let property in f) {
this.state[property] = f[property];
}
}
// In either case, start rendering the new state ASAP.
this.render();
// Normally we save the new state. Except when we're called with
// a second argument of false which we do when we get a popstate event.
if (save) {
history.pushState(this.state, "", this.state.toURL());
}
}
// This method asynchronously draws the portion of the Mandelbrot set
// specified by the PageState object into the canvas. It is called by
// the constructor, by setState() when the state changes, and by the
// resize event handler when the size of the canvas changes.
render() {
// Sometimes the user may use the keyboard or mouse to request renders
// more quickly than we can perform them. We don't want to submit all
// the renders to the worker pool. Instead if we're rendering, we'll
// just make a note that a new render is needed, and when the current
// render completes, we'll render the current state, possibly skipping
// multiple intermediate states.
if (this.pendingRender) { // If we're already rendering,
this.wantsRerender = true; // make a note to rerender later
return; // and don't do anything more now.
}
// Get our state variables and compute the complex number for the
// upper left corner of the canvas.
let {cx, cy, perPixel, maxIterations} = this.state;
let x0 = cx - perPixel * this.width/2;
let y0 = cy - perPixel * this.height/2;
// For each of our ROWS*COLS tiles, call addWork() with a message
// for the code in mandelbrotWorker.js. Collect the resulting Promise
// objects into an array.
let promises = this.tiles.map(tile => this.workerPool.addWork({
tile: tile,
x0: x0 + tile.x * perPixel,
y0: y0 + tile.y * perPixel,
perPixel: perPixel,
maxIterations: maxIterations
}));
// Use Promise.all() to get an array of responses from the array of
// promises. Each response is the computation for one of our tiles.
// Recall from mandelbrotWorker.js that each response includes the
// Tile object, an ImageData object that includes iteration counts
// instead of pixel values, and the minimum and maximum iterations
// for that tile.
this.pendingRender = Promise.all(promises).then(responses => {
// First, find the overall max and min iterations over all tiles.
// We need these numbers so we can assign colors to the pixels.
let min = maxIterations, max = 0;
for(let r of responses) {
if (r.min < min) min = r.min;
if (r.max > max) max = r.max;
}
// Now we need a way to convert the raw iteration counts from the
// workers into pixel colors that will be displayed in the canvas.
// We know that all the pixels have between min and max iterations
// so we precompute the colors for each iteration count and store
// them in the colorTable array.
// If we haven't allocated a color table yet, or if it is no longer
// the right size, then allocate a new one.
if (!this.colorTable || this.colorTable.length !== maxIterations+1){
this.colorTable = new Uint32Array(maxIterations+1);
}
// Given the max and the min, compute appropriate values in the
// color table. Pixels in the set will be colored fully opaque
// black. Pixels outside the set will be translucent black with higher
// iteration counts resulting in higher opacity. Pixels with
// minimum iteration counts will be transparent and the white
// background will show through, resulting in a grayscale image.
if (min === max) { // If all the pixels are the same,
if (min === maxIterations) { // Then make them all black
this.colorTable[min] = 0xFF000000;
} else { // Or all transparent.
this.colorTable[min] = 0;
}
} else {
// In the normal case where min and max are different, use a
// logarithic scale to assign each possible iteration count an
// opacity between 0 and 255, and then use the shift left
// operator to turn that into a pixel value.
let maxlog = Math.log(1+max-min);
for(let i = min; i <= max; i++) {
this.colorTable[i] =
(Math.ceil(Math.log(1+i-min)/maxlog * 255) << 24);
}
}
// Now translate the iteration numbers in each response's
// ImageData to colors from the colorTable.
for(let r of responses) {
let iterations = new Uint32Array(r.imageData.data.buffer);
for(let i = 0; i < iterations.length; i++) {
iterations[i] = this.colorTable[iterations[i]];
}
}
// Finally, render all the imageData objects into their
// corresponding tiles of the canvas using putImageData().
// (First, though, remove any CSS transforms on the canvas that may
// have been set by the pointerdown event handler.)
this.canvas.style.transform = "";
for(let r of responses) {
this.context.putImageData(r.imageData, r.tile.x, r.tile.y);
}
})
.catch((reason) => {
// If anything went wrong in any of our Promises, we'll log
// an error here. This shouldn't happen, but this will help with
// debugging if it does.
console.error("Promise rejected in render():", reason);
})
.finally(() => {
// When we are done rendering, clear the pendingRender flags
this.pendingRender = null;
// And if render requests came in while we were busy, rerender now.
if (this.wantsRerender) {
this.wantsRerender = false;
this.render();
}
});
}
// If the user resizes the window, this function will be called repeatedly.
// Resizing a canvas and rerendering the Mandlebrot set is an expensive
// operation that we can't do multiple times a second, so we use a timer
// to defer handling the resize until 200ms have elapsed since the last
// resize event was received.
handleResize(event) {
// If we were already deferring a resize, clear it.
if (this.resizeTimer) clearTimeout(this.resizeTimer);
// And defer this resize instead.
this.resizeTimer = setTimeout(() => {
this.resizeTimer = null; // Note that resize has been handled
this.setSize(); // Resize canvas and tiles
this.render(); // Rerender at the new size
}, 200);
}
// If the user presses a key, this event handler will be called.
// We call setState() in response to various keys, and setState() renders
// the new state, updates the URL, and saves the state in browser history.
handleKey(event) {
switch(event.key) {
case "Escape": // Type Escape to go back to the initial state
this.setState(PageState.initialState());
break;
case "+": // Type + to increase the number of iterations
this.setState(s => {
s.maxIterations = Math.round(s.maxIterations*1.5);
});
break;
case "-": // Type - to decrease the number of iterations
this.setState(s => {
s.maxIterations = Math.round(s.maxIterations/1.5);
if (s.maxIterations < 1) s.maxIterations = 1;
});
break;
case "o": // Type o to zoom out
this.setState(s => s.perPixel *= 2);
break;
case "ArrowUp": // Up arrow to scroll up
this.setState(s => s.cy -= this.height/10 * s.perPixel);
break;
case "ArrowDown": // Down arrow to scroll down
this.setState(s => s.cy += this.height/10 * s.perPixel);
break;
case "ArrowLeft": // Left arrow to scroll left
this.setState(s => s.cx -= this.width/10 * s.perPixel);
break;
case "ArrowRight": // Right arrow to scroll right
this.setState(s => s.cx += this.width/10 * s.perPixel);
break;
}
}
// This method is called when we get a pointerdown event on the canvas.
// The pointerdown event might be the start of a zoom gesture (a click or
// tap) or a pan gesture (a drag). This handler registers handlers for
// the pointermove and pointerup events in order to respond to the rest
// of the gesture. (These two extra handlers are removed when the gesture
// ends with a pointerup.)
handlePointer(event) {
// The pixel coordinates and time of the initial pointer down.
// Because the canvas is as big as the window, these event coordinates
// are also canvas coordinates.
const x0 = event.clientX, y0 = event.clientY, t0 = Date.now();
// This is the handler for move events.
const pointerMoveHandler = event => {
// How much have we moved, and how much time has passed?
let dx=event.clientX-x0, dy=event.clientY-y0, dt=Date.now()-t0;
// If the pointer has moved enough or enough time has passed that
// this is not a regular click, then use CSS to pan the display.
// (We will rerender it for real when we get the pointerup event.)
if (dx > 10 || dy > 10 || dt > 500) {
this.canvas.style.transform = `translate(${dx}px, ${dy}px)`;
}
};
// This is the handler for pointerup events
const pointerUpHandler = event => {
// When the pointer goes up, the gesture is over, so remove
// the move and up handlers until the next gesture.
this.canvas.removeEventListener("pointermove", pointerMoveHandler);
this.canvas.removeEventListener("pointerup", pointerUpHandler);
// How much did the pointer move, and how much time passed?
const dx = event.clientX-x0, dy=event.clientY-y0, dt=Date.now()-t0;
// Unpack the state object into individual constants.
const {cx, cy, perPixel} = this.state;
// If the pointer moved far enough or if enough time passed, then
// this was a pan gesture, and we need to change state to change
// the center point. Otherwise, the user clicked or tapped on a
// point and we need to center and zoom in on that point.
if (dx > 10 || dy > 10 || dt > 500) {
// The user panned the image by (dx, dy) pixels.
// Convert those values to offsets in the complex plane.
this.setState({cx: cx - dx*perPixel, cy: cy - dy*perPixel});
} else {
// The user clicked. Compute how many pixels the center moves.
let cdx = x0 - this.width/2;
let cdy = y0 - this.height/2;
// Use CSS to quickly and temporarily zoom in
this.canvas.style.transform =
`translate(${-cdx*2}px, ${-cdy*2}px) scale(2)`;
// Set the complex coordinates of the new center point and
// zoom in by a factor of 2.
this.setState(s => {
s.cx += cdx * s.perPixel;
s.cy += cdy * s.perPixel;
s.perPixel /= 2;
});
}
};
// When the user begins a gesture we register handlers for the
// pointermove and pointerup events that follow.
this.canvas.addEventListener("pointermove", pointerMoveHandler);
this.canvas.addEventListener("pointerup", pointerUpHandler);
}
}
// Finally, here's how we set up the canvas. Note that this JavaScript file
// is self-sufficient. The HTML file only needs to include this one <script>.
let canvas = document.createElement("canvas"); // Create a canvas element
document.body.append(canvas); // Insert it into the body
document.body.style = "margin:0"; // No margin for the <body>
canvas.style.width = "100%"; // Make canvas as wide as body
canvas.style.height = "100%"; // and as high as the body.
new MandelbrotCanvas(canvas); // And start rendering into it!
15.15 总结和进一步阅读建议
这一长章节涵盖了客户端 JavaScript 编程的基础知识:
-
脚本和 JavaScript 模块如何包含在网页中以及它们何时以及如何执行。
-
客户端 JavaScript 的异步、事件驱动的编程模型。
-
允许 JavaScript 代码检查和修改其嵌入的文档的 HTML 内容的文档对象模型(DOM)。这个 DOM API 是所有客户端 JavaScript 编程的核心。
-
JavaScript 代码如何操作应用于文档内容的 CSS 样式。
-
JavaScript 代码如何获取浏览器窗口中和文档内部的文档元素的坐标。
-
如何使用 JavaScript、HTML 和 CSS 利用自定义元素和影子 DOM API 创建可重用的 UI “Web 组件”。
-
如何使用 SVG 和 HTML
<canvas>元素显示和动态生成图形。 -
如何向您的网页添加脚本化的声音效果(录制和合成的)。
-
JavaScript 如何使浏览器加载新页面,在用户的浏览历史记录中前进和后退,甚至向浏览历史记录添加新条目。
-
JavaScript 程序如何使用 HTTP 和 WebSocket 协议与 Web 服务器交换数据。
-
JavaScript 程序如何在用户的浏览器中存储数据。
-
JavaScript 程序如何使用工作线程实现一种安全的并发形式。
这是本书迄今为止最长的一章。但它远远不能涵盖 Web 浏览器可用的所有 API。Web 平台庞大且不断发展,我这一章的目标是介绍最重要的核心 API。有了本书中的知识,你已经具备了学习和使用新 API 的能力。但如果你不知道某个新 API 的存在,就无法学习它,因此接下来的简短部分以一个快速列表结束本章,列出了未来可能想要调查的 Web 平台功能。
15.15.1 HTML 和 CSS
Web 是建立在三个关键技术上的:HTML、CSS 和 JavaScript,只有掌握 JavaScript 的知识,作为 Web 开发者,你的能力是有限的,除非你还提升自己在 HTML 和 CSS 方面的专业知识。重要的是要知道如何使用 JavaScript 操纵 HTML 元素和 CSS 样式,但只有当你知道使用哪些 HTML 元素和 CSS 样式时,这些知识才更有用。
在你开始探索更多 JavaScript API 之前,我建议你花一些时间掌握 Web 开发者工具包中的其他工具。例如,HTML 表单和输入元素具有复杂的行为,很重要理解,而 CSS 中的 flexbox 和 grid 布局模式非常强大。
在这个领域值得特别关注的两个主题是可访问性(包括 ARIA 属性)和国际化(包括支持从右到左的书写方向)。
15.15.2 性能
一旦你编写了一个 Web 应用并发布到世界上,不断优化使其变得更快的任务就开始了。然而,优化你无法测量的东西是困难的,因此值得熟悉性能 API。window 对象的performance属性是这个 API 的主要入口点。它包括一个高分辨率时间源performance.now(),以及用于标记代码中关键点和测量它们之间经过的时间的方法performance.mark()和performance.measure()。调用这些方法会创建 PerformanceEntry 对象,你可以通过performance.getEntries()访问。浏览器在加载新页面或通过网络获取文件时会添加自己的 PerformanceEntry 对象,这些自动创建的 PerformanceEntry 对象包含应用程序网络性能的细粒度计时详细信息。相关的 PerformanceObserver 类允许你指定一个函数,在创建新的 PerformanceEntry 对象时调用。
15.15.3 安全
本章介绍了如何防御网站中的跨站脚本(XSS)安全漏洞的一般思路,但没有详细展开。网络安全是一个重要的话题,你可能想花一些时间了解更多。除了 XSS 外,值得学习的还有Content-Security-Policy HTTP 头部,了解 CSP 如何让你要求网络浏览器限制它授予 JavaScript 代码的能力。理解跨域资源共享(CORS)也很重要。
15.15.4 WebAssembly
WebAssembly(或“wasm”)是一种低级虚拟机字节码格式,旨在与 Web 浏览器中的 JavaScript 解释器很好地集成。有编译器可以让你将 C、C++ 和 Rust 程序编译为 WebAssembly 字节码,并在 Web 浏览器中以接近本机速度运行这些程序,而不会破坏浏览器的沙箱或安全模型。WebAssembly 可以导出函数,供 JavaScript 程序调用。WebAssembly 的一个典型用例是将标准的 C 语言 zlib 压缩库编译,以便 JavaScript 代码可以访问高速压缩和解压缩算法。了解更多请访问https://webassembly.org。
15.15.5 更多文档和窗口功能
Window 和 Document 对象具有许多本章未涵盖的功能:
-
Window 对象定义了
alert()、confirm()和prompt()方法,用于向用户显示简单的模态对话框。这些方法会阻塞主线程。confirm()方法同步返回一个布尔值,而prompt()同步返回用户输入的字符串。这些方法不适合生产使用,但对于简单项目和原型设计可能会有用。 -
Window 对象的
navigator和screen属性在本章开头简要提到过,但它们引用的 Navigator 和 Screen 对象具有一些这里未描述的功能,您可能会发现它们有用。 -
任何 Element 对象的
requestFullscreen()方法请求该元素(例如<video>或<canvas>元素)以全屏模式显示。Document 的exitFullscreen()方法返回正常显示模式。 -
requestAnimationFrame()方法是 Window 对象的一个方法,它接受一个函数作为参数,并在浏览器准备渲染下一帧时执行该函数。当您进行视觉变化(特别是重复或动画变化)时,将您的代码包装在requestAnimationFrame()调用中可以帮助确保变化平滑地呈现,并以浏览器优化的方式呈现。 -
如果用户在您的文档中选择文本,您可以使用 Window 方法
getSelection()获取该选择的详细信息,并使用getSelection().toString()获取所选文本。在某些浏览器中,navigator.clipboard是一个具有异步 API 的对象,用于读取和设置系统剪贴板的内容,以便与浏览器外的应用程序进行复制和粘贴交互。 -
Web 浏览器的一个鲜为人知的功能是具有
contenteditable="true"属性的 HTML 元素允许编辑其内容。document.execCommand()方法为可编辑内容启用富文本编辑功能。 -
MutationObserver 允许 JavaScript 监视文档中指定元素的更改或下方的更改。使用
MutationObserver()构造函数创建 MutationObserver,传递应在进行更改时调用的回调函数。然后调用 MutationObserver 的observe()方法指定要监视的哪些元素的哪些部分。 -
IntersectionObserver 允许 JavaScript 确定哪些文档元素在屏幕上,哪些接近屏幕。对于希望根据用户滚动动态加载内容的应用程序,它特别有用。
15.15.6 事件
Web 平台支持的事件数量和多样性令人生畏。本章讨论了各种事件类型,但以下是一些您可能会发现有用的其他事件:
-
当浏览器获得或失去互联网连接时,浏览器会在 Window 对象上触发“online”和“offline”事件。
-
当文档变得可见或不可见(通常是因为用户切换选项卡)时,浏览器会在 Document 对象上触发“visiblitychange”事件。JavaScript 可以检查
document.visibilityState以确定其文档当前是“可见”还是“隐藏”。 -
浏览器支持复杂的 API 以支持拖放 UI 和与浏览器外的应用程序进行数据交换。该 API 涉及许多事件,包括“dragstart”、“dragover”、“dragend”和“drop”。正确使用此 API 可能有些棘手,但在需要时非常有用。如果您想要使用户能够从其桌面拖动文件到您的 Web 应用程序中,则了解此重要 API 是很重要的。
-
指针锁定 API 使 JavaScript 能够隐藏鼠标指针,并获取原始鼠标事件作为相对移动量,而不是屏幕上的绝对位置。这通常对游戏很有用。在您希望所有鼠标事件指向的元素上调用
requestPointerLock()。这样做后,传递给该元素的“mousemove”事件将具有movementX和movementY属性。 -
游戏手柄 API 添加了对游戏手柄的支持。使用
navigator.getGamepads()来获取连接的游戏手柄对象,并在 Window 对象上监听“gamepadconnected”事件,以便在插入新控制器时收到通知。游戏手柄对象定义了一个用于查询控制器按钮当前状态的 API。
15.15.7 渐进式网络应用和服务工作者
渐进式网络应用(Progressive Web Apps,PWAs)是一个流行词,用来描述使用一些关键技术构建的网络应用程序。对这些关键技术进行仔细的文档记录需要一本专门的书,我在本章中没有涵盖它们,但你应该了解所有这些 API。值得注意的是,像这样强大的现代 API 通常只设计用于安全的 HTTPS 连接。仍在使用http://URL 的网站将无法利用这些:
-
服务工作者是一种具有拦截、检查和响应来自其“服务”的网络应用程序的网络请求能力的工作者线程。当一个网络应用程序注册一个服务工作者时,该工作者的代码将持久保存在浏览器的本地存储中,当用户再次访问相关网站时,服务工作者将被重新激活。服务工作者可以缓存网络响应(包括 JavaScript 代码文件),这意味着使用服务工作者的网络应用程序可以有效地安装到用户的计算机上,以实现快速启动和离线使用。Service Worker Cookbook 是一个了解服务工作者及其相关技术的宝贵资源。
-
缓存 API 设计用于服务工作者(但也可用于工作者之外的常规 JavaScript 代码)。它与
fetch()API 定义的 Request 和 Response 对象一起工作,并实现了 Request/Response 对的缓存。缓存 API 使服务工作者能够缓存其提供的网络应用程序的脚本和其他资产,并且还可以帮助实现网络应用程序的离线使用(这对移动设备尤为重要)。 -
Web 清单是一个 JSON 格式的文件,描述了一个网络应用程序,包括名称、URL 和各种尺寸的图标链接。如果您的网络应用程序使用服务工作者,并包含一个引用
.webmanifest文件的<link rel="manifest">标签,则浏览器(尤其是移动设备上的浏览器)可能会给您添加一个图标的选项,以便将网络应用程序添加到您的桌面或主屏幕上。 -
通知 API 允许网络应用程序在移动设备和桌面设备上使用本机操作系统通知系统显示通知。通知可以包括图像和文本,如果用户点击通知,您的代码可以接收到事件。使用此 API 的复杂之处在于您必须首先请求用户的权限来显示通知。
-
推送 API 允许具有服务工作者(并且获得用户许可)的网络应用程序订阅来自服务器的通知,并在应用程序本身未运行时显示这些通知。推送通知在移动设备上很常见,推送 API 使网络应用程序更接近移动设备上本机应用程序的功能。
15.15.8 移动设备 API
有许多网络 API 主要适用于在移动设备上运行的网络应用程序。(不幸的是,其中一些 API 仅适用于 Android 设备,而不适用于 iOS 设备。)
-
地理位置 API 允许 JavaScript(在用户许可的情况下)确定用户的物理位置。它在桌面和移动设备上得到很好的支持,包括 iOS 设备。使用
navigator.geolocation.getCurrentPosition()请求用户当前位置,并使用navigator.geolocation.watchPosition()注册一个回调函数,当用户位置发生变化时调用该函数。 -
navigator.vibrate()方法会使移动设备(但不包括 iOS)震动。通常只允许在响应用户手势时使用,但调用此方法将允许您的应用程序提供无声反馈,表示已识别到手势。 -
ScreenOrientation API 允许 Web 应用程序查询移动设备屏幕的当前方向,并锁定自身为横向或纵向方向。
-
窗口对象上的 “devicemotion” 和 “deviceorientation” 事件报告设备的加速计和磁力计数据,使您能够确定设备如何加速以及用户如何在空间中定位设备。(这些事件在 iOS 上也有效。)
-
Sensor API 在 Chrome on Android 设备之外尚未得到广泛支持,但它使 JavaScript 能够访问完整套移动设备传感器,包括加速计、陀螺仪、磁力计和环境光传感器。这些传感器使 JavaScript 能够确定用户面向的方向或检测用户何时摇动他们的手机,例如。
15.15.9 二进制 API
Typed arrays、ArrayBuffers 和 DataView 类(在 §11.2 中有介绍)使 JavaScript 能够处理二进制数据。正如本章前面所述,fetch() API 使 JavaScript 程序能够通过网络加载二进制数据。另一个二进制数据的来源是用户本地文件系统中的文件。出于安全原因,JavaScript 不能直接读取本地文件。但是如果用户选择上传文件(使用 <input type="file> 表单元素)或使用拖放将文件拖放到您的 Web 应用程序中,那么 JavaScript 就可以访问该文件作为一个 File 对象。
File 是 Blob 的一个子类,因此它是一个数据块的不透明表示。您可以使用 FileReader 类异步地将文件内容获取为 ArrayBuffer 或字符串。(在某些浏览器中,您可以跳过 FileReader,而是使用 Blob 类定义的基于 Promise 的 text() 和 arrayBuffer() 方法,或者用于对文件内容进行流式访问的 stream() 方法。)
在处理二进制数据,特别是流式二进制数据时,您可能需要将字节解码为文本或将文本编码为字节。TextEncoder 和 TextDecoder 类有助于完成这项任务。
15.15.10 媒体 API
navigator.mediaDevices.getUserMedia() 函数允许 JavaScript 请求访问用户的麦克风和/或摄像头。成功的请求会返回一个 MediaStream 对象。视频流可以在 <video> 标签中显示(通过将 srcObject 属性设置为该流)。视频的静止帧可以通过在一个离屏 <canvas> 中使用 canvas 的 drawImage() 函数捕获,从而得到一个相对低分辨率的照片。由 getUserMedia() 返回的音频和视频流可以被记录并编码为一个 Blob 对象。
更复杂的 WebRTC API 允许在网络上传输和接收 MediaStreams,例如实现点对点视频会议。
15.15.11 加密和相关 API
Window 对象的 crypto 属性公开了一个用于生成密码安全伪随机数的 getRandomValues() 方法。通过 crypto.subtle 还可以使用其他加密、解密、密钥生成、数字签名等方法。这个属性的名称是对所有使用这些方法的人的警告,即正确使用加密算法是困难的,除非你真正知道自己在做什么,否则不应该使用这些方法。此外,crypto.subtle 的方法仅对通过安全的 HTTPS 连接加载的文档中运行的 JavaScript 代码可用。
凭据管理 API 和 Web 认证 API 允许 JavaScript 生成、存储和检索公钥(以及其他类型的)凭据,并实现无需密码的帐户创建和登录。JavaScript API 主要由函数 navigator.credentials.create() 和 navigator.credentials.get() 组成,但在服务器端需要大量基础设施来使这些方法工作。这些 API 尚未得到普遍支持,但有潜力彻底改变我们登录网站的方式。
支付请求 API 为网页上的信用卡支付添加了浏览器支持。它允许用户在浏览器中安全存储他们的支付详细信息,这样他们每次购买时就不必输入信用卡号码。想要请求支付的网络应用程序会创建一个 PaymentRequest 对象,并调用其 show() 方法来向用户显示请求。
¹ 本书的早期版本包含了一个广泛的参考部分,涵盖了 JavaScript 标准库和 Web API。第七版中将其删除,因为 MDN 已经使其过时:今天,在 MDN 上查找信息比翻书更快,而我在 MDN 的前同事比这本书更擅长保持在线文档的更新。
² 一些来源,包括 HTML 规范,根据它们的注册方式在处理程序和监听器之间做了技术区分。在本书中,我们将这两个术语视为同义词。
³ 如果你使用 React 框架创建客户端用户界面,这可能会让你感到惊讶。React 对客户端事件模型进行了一些微小的更改,其中之一是在 React 中,事件处理程序属性名称采用驼峰式写法:onClick、onMouseOver 等。然而,在原生的 Web 平台上工作时,事件处理程序属性完全采用小写形式。
⁴ 自定义元素规范允许对 <button> 和其他特定元素类进行子类化,但 Safari 不支持这一点,使用扩展除 HTMLElement 之外的自定义元素需要不同的语法。
第十六章:用 Node 进行服务器端 JavaScript
Node 是 JavaScript 与底层操作系统的绑定,使得编写 JavaScript 程序读写文件、执行子进程和在网络上通信成为可能。这使得 Node 作为以下用途变得有用:
-
现代替代 shell 脚本的方式,不受 bash 和其他 Unix shell 繁琐语法的困扰。
-
用于运行受信任程序的通用编程语言,不受 Web 浏览器对不受信任代码施加的安全约束。
-
编写高效且高度并发的 Web 服务器的流行环境。
Node 的定义特点是其单线程事件驱动并通过默认异步 API 实现的并发性。如果你已经在其他语言中编程过但并没有做过太多 JavaScript 编码,或者如果你是一位经验丰富的客户端 JavaScript 程序员,习惯为 Web 浏览器编写代码,那么使用 Node 将需要一些调整,就像任何新的编程语言或环境一样。本章首先解释了 Node 的编程模型,重点是并发性,Node 用于处理流数据的 API,以及 Node 用于处理二进制数据的缓冲区类型。这些初始部分之后是突出和演示一些最重要的 Node API 的部分,包括用于处理文件、网络、进程和线程的 API。
一章不足以记录所有 Node 的 API,但我希望这一章能够解释足够的基础知识,让你能够在 Node 上提高效率,并确信你可以掌握任何你需要的新 API。
16.1 Node 编程基础
我们将从快速了解 Node 程序的结构以及它们如何与操作系统交互开始这一章节。
16.1.1 控制台输出
如果你习惯于为 Web 浏览器编程的 JavaScript,那么关于 Node 的一个小惊喜是 console.log() 不仅用于调试,而且是 Node 显示消息给用户或者更一般地向 stdout 流发送输出的最简单方式。以下是 Node 中经典的“Hello World”程序:
console.log("Hello World!");
有更低级别的方法可以写入 stdout,但没有比简单调用 console.log() 更花哨或更正式的方式。
在 Web 浏览器中,console.log()、console.warn() 和 console.error() 通常在开发者控制台中的输出旁边显示小图标,以指示日志消息的种类。Node 不会这样做,但使用 console.error() 显示的输出与使用 console.log() 显示的输出有所区别,因为 console.error() 写入 stderr 流。如果你正在使用 Node 编写一个程序,该程序旨在将 stdout 重定向到文件或管道,你可以使用 console.error() 将文本显示到用户将看到的控制台,即使使用 console.log() 打印的文本是隐藏的。
16.1.2 命令行参数和环境变量
如果你之前编写过设计为从终端或其他命令行界面调用的类 Unix 风格程序,你会知道这些程序通常主要从命令行参数获取输入,其次从环境变量获取输入。
Node 遵循这些 Unix 约定。一个 Node 程序可以从字符串数组 process.argv 中读取其命令行参数。这个数组的第一个元素始终是 Node 可执行文件的路径。第二个参数是 Node 正在执行的 JavaScript 代码文件的路径。在这个数组中的任何剩余元素都是你在调用 Node 时通过命令行传递的以空格分隔的参数。
例如,假设你将这个非常简短的 Node 程序保存到名为 argv.js 的文件中:
console.log(process.argv);
然后你可以执行该程序并看到如下输出:
$ node --trace-uncaught argv.js --arg1 --arg2 filename
[
'/usr/local/bin/node',
'/private/tmp/argv.js',
'--arg1',
'--arg2',
'filename'
]
这里有几点需要注意:
-
process.argv的第一个和第二个元素将是完全限定的文件系统路径,指向 Node 可执行文件和正在执行的 JavaScript 文件,即使你没有以这种方式输入它们。 -
用于 Node 可执行文件本身的命令行参数由 Node 可执行文件消耗,不会出现在
process.argv中。(在上面的示例中,--trace-uncaught命令行参数实际上并没有做任何有用的事情;它只是用来演示它不会出现在输出中。)任何出现在 JavaScript 文件名之后的参数(如--arg1和filename)将出现在process.argv中。
Node 程序也可以从类 Unix 环境变量中获取输入。Node 通过process.env对象使这些变量可用。该对象的属性名称是环境变量名称,属性值(始终为字符串)是这些变量的值。
这是我系统上环境变量的部分列表:
$ node -p -e 'process.env'
{
SHELL: '/bin/bash',
USER: 'david',
PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
PWD: '/tmp',
LANG: 'en_US.UTF-8',
HOME: '/Users/david',
}
你可以使用node -h或node --help来查找-p和-e命令行参数的作用。然而,作为提示,注意你可以将上面的行重写为node --eval 'process.env' --print。
16.1.3 程序生命周期
node命令需要一个命令行参数来指定要运行的 JavaScript 代码文件。这个初始文件通常导入其他 JavaScript 代码模块,并可能定义自己的类和函数。然而,从根本上说,Node 会按顺序执行指定文件中的 JavaScript 代码。一些 Node 程序在执行文件中的最后一行代码后完成执行时退出。然而,通常情况下,一个 Node 程序将在执行初始文件后继续运行。正如我们将在接下来的章节中讨论的那样,Node 程序通常是异步的,基于回调和事件处理程序。Node 程序直到运行完初始文件并且所有回调都被调用且没有更多待处理事件时才会退出。一个基于 Node 的服务器程序监听传入的网络连接,理论上会永远运行,因为它总是会等待更多事件。
一个程序可以通过调用process.exit()来强制退出。用户通常可以通过在运行程序的终端窗口中键入 Ctrl-C 来终止 Node 程序。程序可以通过注册一个信号处理程序函数process.on("SIGINT", ()=>{})来忽略 Ctrl-C。
如果程序中的代码抛出异常而没有catch子句捕获它,程序将打印堆栈跟踪并退出。由于 Node 的异步特性,发生在回调或事件处理程序中的异常必须在本地处理或根本不处理,这意味着处理程序中异步部分发生的异常可能是一个困难的问题。如果你不希望这些异常导致程序完全崩溃,注册一个全局处理程序函数将被调用而不是崩溃:
process.setUncaughtExceptionCaptureCallback(e => {
console.error("Uncaught exception:", e);
});
如果你的程序创建的 Promise 被拒绝并且没有.catch()调用来处理它,会出现类似的情况。截至 Node 13,这不是导致程序退出的致命错误,但会在控制台打印详细的错误消息。在未来的某个 Node 版本中,未处理的 Promise 拒绝预计将成为致命错误。如果你不希望未处理的拒绝打印错误消息或终止程序,注册一个全局处理程序函数:
process.on("unhandledRejection", (reason, promise) => {
// reason is whatever value would have been passed to a .catch() function
// promise is the Promise object that rejected
});
16.1.4 Node 模块
第十章记录了 JavaScript 模块系统,涵盖了 Node 模块和 ES6 模块。因为 Node 是在 JavaScript 拥有模块系统之前创建的,所以 Node 不得不创建自己的模块系统。Node 的模块系统使用require()函数将值导入模块,使用exports对象或module.exports属性从模块导出值。这些是 Node 编程模型的基本部分,并在§10.2 中详细介绍。
Node 13 添加了对标准 ES6 模块和基于 require 的模块(Node 称之为“CommonJS 模块”)的支持。这两种模块系统并不完全兼容,因此这有点棘手。Node 需要在加载模块之前知道该模块是否将使用require()和module.exports,还是将使用import和export。当 Node 将 JavaScript 代码文件加载为 CommonJS 模块时,它会自动定义require()函数以及标识符exports和module,并且不会启用import和export关键字。另一方面,当 Node 将代码文件加载为 ES6 模块时,它必须启用import和export声明,并且不能定义额外的标识符如require、module和exports。
告诉 Node 正在加载的模块的类型最简单的方法是将这些信息编码在文件扩展名中。如果您将 JavaScript 代码保存在以.mjs结尾的文件中,那么 Node 将始终将其作为 ES6 模块加载,期望它使用import和export,并且不会提供require()函数。如果您将代码保存在以.cjs结尾的文件中,那么 Node 将始终将其视为 CommonJS 模块,提供require()函数,并且如果您使用import或export声明,则会抛出 SyntaxError。
对于没有明确.mjs或.cjs扩展名的文件,Node 会在与文件相同的目录中查找名为package.json的文件,然后在每个包含目录中查找。一旦找到最近的package.json文件,Node 会检查 JSON 对象中的顶级type属性。如果type属性的值是“module”,那么 Node 会将文件加载为 ES6 模块。如果该属性的值是“commonjs”,那么 Node 会将文件加载为 CommonJS 模块。请注意,您不需要有package.json文件来运行 Node 程序:当找不到这样的文件时(或者找到文件但它没有type属性时),Node 会默认使用 CommonJS 模块。只有当您想要在 Node 中使用 ES6 模块而不想使用.mjs文件扩展名时,才需要使用这个package.json技巧。
由于有大量使用 CommonJS 模块格式编写的现有 Node 代码,Node 允许 ES6 模块使用import关键字加载 CommonJS 模块。然而,反之则不成立:CommonJS 模块无法使用require()加载 ES6 模块。
16.1.5 Node 包管理器
安装 Node 时,通常也会得到一个名为 npm 的程序。这是 Node 包管理器,它帮助您下载和管理程序所依赖的库。npm 会在项目的根目录中的名为package.json的文件中跟踪这些依赖项(以及关于您的程序的其他信息)。由 npm 创建的这个package.json文件是您想要为项目使用 ES6 模块时会添加"type":"module"的地方。
本章不会详细介绍 npm(但请参见§17.4 以获取更多深入信息)。我在这里提到它是因为除非您编写的程序不使用任何外部库,否则您几乎肯定会使用 npm 或类似工具。例如,假设您将要开发一个 Web 服务器,并计划使用 Express 框架(https://expressjs.com)来简化任务。要开始,您可以为项目创建一个目录,然后在该目录中输入npm init。npm 会询问您项目名称、版本号等信息,然后根据您的回答创建一个初始的package.json文件。
现在要开始使用 Express,您可以输入npm install express。这告诉 npm 下载 Express 库以及其所有依赖项,并将所有包安装在本地node_modules/目录中:
$ npm install express
npm notice created a lockfile as package-lock.json. You should commit this file.
npm WARN my-server@1.0.0 No description
npm WARN my-server@1.0.0 No repository field.
+ express@4.17.1
added 50 packages from 37 contributors and audited 126 packages in 3.058s
found 0 vulnerabilities
当你使用 npm 安装一个包时,npm 会记录这种依赖关系——即你的项目依赖于 Express——在 package.json 文件中。有了在 package.json 中记录的这种依赖关系,你可以将你的代码和 package.json 的副本交给另一个程序员,他们只需输入 npm install 就可以自动下载和安装程序运行所需的所有库。
16.2 节点默认是异步的
JavaScript 是一种通用编程语言,因此完全可以编写乘法大矩阵或执行复杂统计分析等 CPU 密集型程序。但 Node 是为 I/O 密集型的程序(如网络服务器)而设计和优化的。特别是,Node 的设计使得轻松实现高度并发的服务器成为可能,可以同时处理许多请求。
然而,与许多编程语言不同,Node 不使用线程来实现并发。多线程编程通常很难正确完成,也很难调试。此外,线程是一个相对较重的抽象,如果你想编写一个能够处理数百个并发请求的服务器,使用数百个线程可能需要大量的内存。因此,Node 采用了 Web 使用的单线程 JavaScript 编程模型,这实际上是一种巨大的简化,使得创建网络服务器成为一种常规技能而不是一种神秘技能。
Node 通过将其 API 默认设置为异步和非阻塞来保持高并发水平,同时保持单线程编程模型。Node 非常认真地采取了非阻塞的方法,甚至可能会让你感到惊讶。你可能期望从网络读取和写入的函数是异步的,但 Node 更进一步,为从本地文件系统读取和写入文件定义了非阻塞异步函数。这是有道理的,当你考虑到:Node API 是在旋转硬盘仍然是标准的时代设计的,而在进行文件操作之前确实有毫秒级的阻塞“寻道时间”,等待磁盘旋转以开始文件操作。在现代数据中心,所谓的“本地”文件系统实际上可能在网络的某个地方,上面还有网络延迟。但即使异步读取文件对你来说是正常的,Node 仍然更进一步:例如,用于启动网络连接或查找文件修改时间的默认函数也是非阻塞的。
Node 的 API 中有一些同步但非阻塞的函数:它们运行完成并返回而无需阻塞。但大多数有趣的函数执行某种输入或输出,这些是异步函数,因此它们可以避免甚至最微小的阻塞。Node 是在 JavaScript 拥有 Promise 类之前创建的,因此异步 Node API 是基于回调的。(如果你还没有阅读或已经忘记第十三章,现在是回到那一章的好时机。)通常,你传递给异步 Node 函数的最后一个参数是一个回调函数。Node 使用 错误优先回调,通常用两个参数调用。错误优先回调的第一个参数通常在没有错误发生的情况下为 null,第二个参数是由你调用的原始异步函数产生的数据或响应。将错误参数放在第一位的原因是为了让你无法忽略它,你应该始终检查这个参数中是否有非空值。如果它是一个错误对象,甚至是一个整数错误代码或字符串错误消息,那么出现了问题。在这种情况下,你回调函数的第二个参数可能是 null。
以下代码演示了如何使用非阻塞的readFile()函数读取配置文件,将其解析为 JSON,然后将解析后的配置对象传递给另一个回调函数:
const fs = require("fs"); // Require the filesystem module
// Read a config file, parse its contents as JSON, and pass the
// resulting value to the callback. If anything goes wrong,
// print an error message to stderr and invoke the callback with null
function readConfigFile(path, callback) {
fs.readFile(path, "utf8", (err, text) => {
if (err) { // Something went wrong reading the file
console.error(err);
callback(null);
return;
}
let data = null;
try {
data = JSON.parse(text);
} catch(e) { // Something went wrong parsing the file contents
console.error(e);
}
callback(data);
});
}
Node 早于标准化的 promises,但由于它在错误优先回调方面相当一致,使用util.promisify()包装器可以轻松创建基于 Promise 的变体。这是我们如何重写readConfigFile()函数以返回一个 Promise:
const util = require("util");
const fs = require("fs"); // Require the filesystem module
const pfs = { // Promise-based variants of some fs functions
readFile: util.promisify(fs.readFile)
};
function readConfigFile(path) {
return pfs.readFile(path, "utf-8").then(text => {
return JSON.parse(text);
});
}
我们还可以使用async和await简化前面基于 Promise 的函数(再次,如果您尚未阅读第十三章,现在是一个好时机):
async function readConfigFile(path) {
let text = await pfs.readFile(path, "utf-8");
return JSON.parse(text);
}
util.promisify()包装器可以生成许多 Node 函数的基于 Promise 的版本。在 Node 10 及更高版本中,fs.promises对象有许多预定义的基于 Promise 的函数,用于处理文件系统。我们将在本章后面讨论它们,但请注意,在前面的代码中,我们可以用fs.promises.readFile()替换pfs.readFile()。
我们曾经说过,Node 的编程模型是默认异步的。但为了程序员的方便,Node 确实定义了许多阻塞的同步变体函数,特别是在文件系统模块中。这些函数通常以Sync结尾的名称清晰标记。
当服务器首次启动并读取其配置文件时,它尚未处理网络请求,实际上几乎不可能发生并发。因此,在这种情况下,没有必要避免阻塞,我们可以安全地使用像fs.readFileSync()这样的阻塞函数。我们可以从这段代码中删除async和await,并编写我们的readConfigFile()函数的纯同步版本。这个函数不是调用回调或返回 Promise,而是简单地返回解析后的 JSON 值或抛出异常:
const fs = require("fs");
function readConfigFileSync(path) {
let text = fs.readFileSync(path, "utf-8");
return JSON.parse(text);
}
除了其错误优先的两参数回调之外,Node 还有许多使用基于事件的异步性的 API,通常用于处理流数据。我们稍后会更详细地介绍 Node 事件。
现在我们已经讨论了 Node 的积极非阻塞 API,让我们回到并发的话题。Node 的内置非阻塞函数使用操作系统版本的回调和事件处理程序。当您调用这些函数之一时,Node 会采取行动启动操作,然后向操作系统注册某种事件处理程序,以便在操作完成时通知它。您传递给 Node 函数的回调被内部存储,以便 Node 在操作系统向其发送适当事件时调用您的回调。
这种并发通常称为基于事件的并发。在其核心,Node 有一个运行“事件循环”的单个线程。当一个 Node 程序启动时,它运行您告诉它运行的任何代码。这段代码可能调用至少一个非阻塞函数,导致注册回调或事件处理程序与操作系统。 (如果没有,那么您编写了一个同步的 Node 程序,当它到达末尾时,Node 简单地退出。) 当 Node 到达程序末尾时,它会阻塞,直到发生事件,此时操作系统再次启动它。Node 将操作系统事件映射到您注册的 JavaScript 回调,然后调用该函数。您的回调函数可能调用更多的非阻塞 Node 函数,导致注册更多的操作系统事件处理程序。一旦您的回调函数运行完毕,Node 再次进入休眠状态,循环重复。
对于 Web 服务器和其他大部分时间都在等待输入和输出的 I/O 密集型应用程序来说,这种基于事件的并发方式是高效且有效的。只要使用非阻塞 API 并且存在一种从网络套接字到 JavaScript 函数的内部映射,Web 服务器就可以同时处理来自 50 个不同客户端的请求,而无需使用 50 个不同的线程。
16.3 缓冲区
在 Node 中你经常会使用的一种数据类型是 Buffer 类。一个 Buffer 很像一个字符串,只不过它是一系列字节而不是一系列字符。在核心 JavaScript 支持类型化数组之前(参见 §11.2),也没有 Uint8Array 来表示无符号字节的数组。Node 定义了 Buffer 类来填补这个需求。现在 Uint8Array 是 JavaScript 语言的一部分,Node 的 Buffer 类是 Uint8Array 的子类。
Buffer 与其 Uint8Array 超类的区别在于它设计用于与 JavaScript 字符串互操作:缓冲区中的字节可以从字符字符串初始化或转换为字符字符串。字符编码将某个字符集中的每个字符映射到一个整数。给定一个文本字符串和一个字符编码,我们可以将字符串中的字符 编码 为一系列字节。给定一个(正确编码的)字节序列和一个字符编码,我们可以将这些字节 解码 为一系列字符。Node 的 Buffer 类有执行编码和解码的方法,你可以通过这些方法来识别,因为它们期望一个 encoding 参数来指定要使用的编码。
在 Node 中,编码是以字符串形式指定的。支持的编码有:
"utf8"
这是在没有指定编码时的默认编码,也是你最有可能使用的 Unicode 编码。
"utf16le"
两字节的 Unicode 字符,采用小端序排序。编码为 \uffff 以上的码点会被编码为一对两字节序列。编码 "ucs2" 是一个别名。
"latin1"
每个字符一个字节的 ISO-8859-1 编码,定义了适用于许多西欧语言的字符集。因为字节和 latin-1 字符之间有一对一的映射,所以这种编码也被称为 "binary"。
"ascii"
仅包含 7 位英文 ASCII 编码,是 "utf8" 编码的严格子集。
"hex"
这种编码将每个字节转换为一对 ASCII 十六进制数字。
"base64"
这种编码将每个三字节序列转换为四个 ASCII 字符。
这里有一些示例代码,演示如何使用 Buffer 以及如何进行字符串和 Buffer 之间的转换:
let b = Buffer.from([0x41, 0x42, 0x43]); // <Buffer 41 42 43>
b.toString() // => "ABC"; default "utf8"
b.toString("hex") // => "414243"
let computer = Buffer.from("IBM3111", "ascii"); // Convert string to Buffer
for(let i = 0; i < computer.length; i++) { // Use Buffer as byte array
computer[i]--; // Buffers are mutable
}
computer.toString("ascii") // => "HAL2000"
computer.subarray(0,3).map(x=>x+1).toString() // => "IBM"
// Create new "empty" buffers with Buffer.alloc()
let zeros = Buffer.alloc(1024); // 1024 zeros
let ones = Buffer.alloc(128, 1); // 128 ones
let dead = Buffer.alloc(1024, "DEADBEEF", "hex"); // Repeating pattern of bytes
// Buffers have methods for reading and writing multi-byte values
// from and to a buffer at any specified offset.
dead.readUInt32BE(0) // => 0xDEADBEEF
dead.readUInt32BE(1) // => 0xADBEEFDE
dead.readBigUInt64BE(6) // => 0xBEEFDEADBEEFDEADn
dead.readUInt32LE(1020) // => 0xEFBEADDE
如果你编写一个实际操作二进制数据的 Node 程序,你可能会大量使用 Buffer 类。另一方面,如果你只是处理从文件或网络读取或写入的文本,那么你可能只会遇到 Buffer 作为数据的中间表示。许多 Node API 可以将输入或返回输出作为字符串或 Buffer 对象。通常,如果你从这些 API 中传递一个字符串,或者期望返回一个字符串,你需要指定要使用的文本编码的名称。如果你这样做了,那么你可能根本不需要使用 Buffer 对象。
16.4 事件和 EventEmitter
正如描述的那样,Node 的所有 API 默认都是异步的。对于其中的许多 API,这种异步性采用的形式是两个参数的错误优先回调,当请求的操作完成时调用。但是一些更复杂的 API 是基于事件的。当 API 设计围绕对象而不是函数时,或者当需要多次调用回调函数时,或者当可能需要多种类型的回调函数时,通常会出现这种情况。例如,考虑 net.Server 类:这种类型的对象是一个服务器套接字,用于接受来自客户端的传入连接。当它首次开始监听连接时,会发出“listening”事件,每当客户端连接时会发出“connection”事件,当关闭并不再监听时会发出“close”事件。
在 Node 中,发出事件的对象是 EventEmitter 的实例或 EventEmitter 的子类:
const EventEmitter = require("events"); // Module name does not match class name
const net = require("net");
let server = new net.Server(); // create a Server object
server instanceof EventEmitter // => true: Servers are EventEmitters
EventEmitters 的主要特点是它们允许您使用 on() 方法注册事件处理程序函数。EventEmitters 可以发出多种类型的事件,事件类型通过名称标识。要注册事件处理程序,请调用 on() 方法,传递事件类型的名称以及当事件发生时应该调用的函数。EventEmitters 可以使用任意数量的参数调用处理程序函数,您需要阅读特定 EventEmitter 的特定类型事件的文档,以了解您应该期望传递的参数:
const net = require("net");
let server = new net.Server(); // create a Server object
server.on("connection", socket => { // Listen for "connection" events
// Server "connection" events are passed a socket object
// for the client that just connected. Here we send some data
// to the client and disconnect.
socket.end("Hello World", "utf8");
});
如果您更喜欢使用更明确的方法名称来注册事件侦听器,也可以使用 addListener()。您可以使用 off() 或 removeListener() 来删除先前注册的事件侦听器。作为特例,您可以通过调用 once() 而不是 on() 来注册一个在第一次触发后将自动删除的事件侦听器。
当特定类型的事件发生在特定的 EventEmitter 对象上时,Node 会调用该 EventEmitter 上当前注册的所有处理程序函数来处理该类型的事件。它们按照从第一个注册到最后注册的顺序依次调用。如果有多个处理程序函数,它们将在单个线程上依次调用:请记住,Node 中没有并行处理。重要的是,事件处理函数是同步调用的,而不是异步调用的。这意味着 emit() 方法不会将事件处理程序排队以在以后的某个时间调用。emit() 会依次调用所有已注册的处理程序,并且在最后一个事件处理程序返回之前不会返回。
实际上,这意味着当内置的 Node API 发出事件时,该 API 基本上会阻塞在您的事件处理程序上。如果编写一个调用像 fs.readFileSync() 这样的阻塞函数的事件处理程序,直到同步文件读取完成,将不会发生进一步的事件处理。如果您的程序是一个需要响应的网络服务器之类的程序,那么重要的是保持事件处理程序函数非阻塞和快速。如果需要在事件发生时进行大量计算,通常最好使用处理程序使用 setTimeout() 异步调度该计算(参见 §11.10)。Node 还定义了 setImmediate(),它会在处理完所有挂起的回调和事件后立即调度一个函数。
EventEmitter 类还定义了一个emit()方法,导致注册的事件处理程序函数被调用。如果您正在定义自己的基于事件的 API,这很有用,但在使用现有 API 进行编程时通常不常用。emit()必须以事件类型的名称作为第一个参数调用。传递给emit()的任何其他参数都成为注册的事件处理程序函数的参数。处理程序函数还使用设置为 EventEmitter 对象本身的this值调用,这通常很方便。(请记住,箭头函数总是使用定义它们的上下文的this值,并且不能使用任何其他this值调用。尽管如此,箭头函数通常是编写事件处理程序的最方便方式。)
事件处理程序函数返回的任何值都会被忽略。但是,如果事件处理程序函数抛出异常,则它会从emit()调用中传播出去,并阻止执行任何在抛出异常之后注册的处理程序函数。
请记住,Node 的基于回调的 API 使用错误优先回调,重要的是您始终检查第一个回调参数以查看是否发生错误。对于基于事件的 API,等效的是“error”事件。由于基于事件的 API 通常用于网络和其他形式的流式 I/O,它们容易受到不可预测的异步错误的影响,大多数 EventEmitters 在发生错误时定义了一个“error”事件。每当使用基于事件的 API 时,您应该养成注册“error”事件处理程序的习惯。“error”事件在 EventEmitter 类中得到特殊处理。如果调用emit()来发出“error”事件,并且没有为该事件类型注册处理程序,则将抛出异常。由于这是异步发生的,因此您无法在catch块中处理异常,因此这种错误通常会导致程序退出。
16.5 流
在实现处理数据的算法时,几乎总是最容易将所有数据读入内存,进行处理,然后将数据写出。例如,您可以编写一个 Node 函数来复制文件,就像这样。¹
const fs = require("fs");
// An asynchronous but nonstreaming (and therefore inefficient) function.
function copyFile(sourceFilename, destinationFilename, callback) {
fs.readFile(sourceFilename, (err, buffer) => {
if (err) {
callback(err);
} else {
fs.writeFile(destinationFilename, buffer, callback);
}
});
}
这个copyFile()函数使用异步函数和回调函数,因此不会阻塞,并适用于像服务器这样的并发程序。但请注意,它必须分配足够的内存来一次性容纳整个文件的内容。在某些情况下这可能没问题,但如果要复制的文件非常大,或者您的程序高度并发且可能同时复制许多文件时,它就会开始出现问题。这个copyFile()实现的另一个缺点是它在完成读取旧文件之前无法开始写入新文件。
解决这些问题的方法是使用流算法,其中数据“流”进入您的程序,被处理,然后流出您的程序。思路是您的算法以小块处理数据,完整数据集不会一次性保存在内存中。当流式解决方案可行时,它们更节省内存,并且也可能更快。Node 的网络 API 是基于流的,Node 的文件系统模块为读取和写入文件定义了流 API,因此您可能会在编写的许多 Node 程序中使用流 API。我们将在“流动模式”中看到copyFile()函数的流式版本。
Node 支持四种基本的流类型:
可读
可读流是数据的来源。例如,由fs.createReadStream()返回的流是可以读取指定文件内容的流。process.stdin是另一个可读流,返回标准输入的数据。
可写
可写流是数据的接收端或目的地。例如,fs.createWriteStream() 的返回值是一个可写流:它允许以块的形式向其写入数据,并将所有数据输出到指定的文件。
双工
双工流将可读流和可写流合并为一个对象。例如,net.connect() 返回的 Socket 对象和其他 Node 网络 API 返回的对象都是双工流。如果向套接字写入数据,则数据将通过网络发送到套接字连接的计算机。如果从套接字读取数据,则可以访问另一台计算机写入的数据。
转换
转换流也是可读和可写的,但与双工流有一个重要的区别:写入转换流的数据变得可读,通常以某种转换形式从同一流中读取。例如,zlib.createGzip() 函数返回一个转换流,用于压缩(使用 gzip 算法)写入其中的数据。类似地,crypto.createCipheriv() 函数返回一个转换流,用于加密或解密写入其中的数据。
默认情况下,流读取和写入缓冲区。如果调用可读流的 setEncoding() 方法,它将返回解码后的字符串,而不是 Buffer 对象。如果向可写缓冲区写入字符串,它将自动使用缓冲区的默认编码或您指定的任何编码进行编码。Node 的流 API 还支持“对象模式”,其中流读取和写入比缓冲区和字符串更复杂的对象。Node 的核心 API 都不使用此对象模式,但您可能会在其他库中遇到它。
可读流必须从某处读取数据,可写流必须将数据写入某处,因此每个流都有两个端点:一个输入和一个输出,或者一个源和一个目的地。基于流的 API 的棘手之处在于流的两端几乎总是以不同的速度流动。也许从流中读取数据的代码想要比实际写入流中的数据更快地读取和处理数据。或者反过来:也许数据被写入流中的速度比从流的另一端读取和提取数据的速度更快。流实现几乎总是包含一个内部缓冲区,用于保存已写入但尚未读取的数据。缓冲有助于确保在请求时有可读取的数据,并且在写入数据时有空间可用于保存数据。但是这两件事情都无法保证,基于流的编程的本质是读取者有时必须等待数据被写入(因为流缓冲区为空),写入者有时必须等待数据被读取(因为流缓冲区已满)。
在使用基于线程的并发性编程环境中,流式 API 通常具有阻塞调用:读取数据的调用在数据到达流之前不会返回,写入数据的调用会阻塞,直到流的内部缓冲区有足够的空间来容纳新数据。然而,在基于事件的并发模型中,阻塞调用是没有意义的,Node 的流式 API 是基于事件和回调的。与其他 Node API 不同,本章后面将描述的方法没有“Sync”版本。
通过事件协调流的可读性(缓冲区不为空)和可写性(缓冲区不满)的需求使得 Node 的流式 API 稍显复杂。这一复杂性加剧了这些 API 多年来的演变和变化:对于可读流,有两种完全不同的 API 可供使用。尽管复杂,但值得理解和掌握 Node 的流式 API,因为它们能够在程序中实现高吞吐量的 I/O。
接下来的小节演示了如何从 Node 的流类中读取和写入。
16.5.1 管道
有时,您需要从流中读取数据,然后将相同的数据写入另一个流。例如,想象一下,您正在编写一个简单的 HTTP 服务器,用于提供静态文件目录。在这种情况下,您需要从文件输入流中读取数据,并将其写入网络套接字。但是,您可以简单地将两个套接字连接在一起作为“管道”,让 Node 为您处理复杂性,而不是编写自己的处理读取和写入的代码。只需将可写流传递给可读流的pipe()方法:
const fs = require("fs");
function pipeFileToSocket(filename, socket) {
fs.createReadStream(filename).pipe(socket);
}
以下实用函数将一个流导向另一个流,并在完成或发生错误时调用回调函数:
function pipe(readable, writable, callback) {
// First, set up error handling
function handleError(err) {
readable.close();
writable.close();
callback(err);
}
// Next define the pipe and handle the normal termination case
readable
.on("error", handleError)
.pipe(writable)
.on("error", handleError)
.on("finish", callback);
}
转换流在管道中特别有用,并创建涉及两个以上流的管道。以下是一个压缩文件的示例函数:
const fs = require("fs");
const zlib = require("zlib");
function gzip(filename, callback) {
// Create the streams
let source = fs.createReadStream(filename);
let destination = fs.createWriteStream(filename + ".gz");
let gzipper = zlib.createGzip();
// Set up the pipeline
source
.on("error", callback) // call callback on read error
.pipe(gzipper)
.pipe(destination)
.on("error", callback) // call callback on write error
.on("finish", callback); // call callback when writing is complete
}
使用pipe()方法将数据从一个可读流复制到一个可写流很容易,但在实践中,通常需要以某种方式处理数据,因为它在程序中流动。一种方法是实现自己的转换流来进行处理,这种方法允许您避免手动读取和写入流。例如,下面是一个类似 Unix grep实用程序的函数:它从输入流中读取文本行,但只写入与指定正则表达式匹配的行:
const stream = require("stream");
class GrepStream extends stream.Transform {
constructor(pattern) {
super({decodeStrings: false});// Don't convert strings back to buffers
this.pattern = pattern; // The regular expression we want to match
this.incompleteLine = ""; // Any remnant of the last chunk of data
}
// This method is invoked when there is a string ready to be
// transformed. It should pass transformed data to the specified
// callback function. We expect string input so this stream should
// only be connected to readable streams that have had
// setEncoding() called on them.
_transform(chunk, encoding, callback) {
if (typeof chunk !== "string") {
callback(new Error("Expected a string but got a buffer"));
return;
}
// Add the chunk to any previously incomplete line and break
// everything into lines
let lines = (this.incompleteLine + chunk).split("\n");
// The last element of the array is the new incomplete line
this.incompleteLine = lines.pop();
// Find all matching lines
let output = lines // Start with all complete lines,
.filter(l => this.pattern.test(l)) // filter them for matches,
.join("\n"); // and join them back up.
// If anything matched, add a final newline
if (output) {
output += "\n";
}
// Always call the callback even if there is no output
callback(null, output);
}
// This is called right before the stream is closed.
// It is our chance to write out any last data.
_flush(callback) {
// If we still have an incomplete line, and it matches
// pass it to the callback
if (this.pattern.test(this.incompleteLine)) {
callback(null, this.incompleteLine + "\n");
}
}
}
// Now we can write a program like 'grep' with this class.
let pattern = new RegExp(process.argv[2]); // Get a RegExp from command line.
process.stdin // Start with standard input,
.setEncoding("utf8") // read it as Unicode strings,
.pipe(new GrepStream(pattern)) // pipe it to our GrepStream,
.pipe(process.stdout) // and pipe that to standard out.
.on("error", () => process.exit()); // Exit gracefully if stdout closes.
16.5.2 异步迭代
在 Node 12 及更高版本中,可读流是异步迭代器,这意味着在async函数中,您可以使用for/await循环从流中读取字符串或缓冲区块,使用的代码结构类似于同步代码。 (有关异步迭代器和for/await循环的更多信息,请参见§13.4。)
使用异步迭代器几乎和使用pipe()方法一样简单,当您需要以某种方式处理每个读取的块时,可能更容易。以下是我们如何使用async函数和for/await循环重写前一节中的grep程序的方法:
// Read lines of text from the source stream, and write any lines
// that match the specified pattern to the destination stream.
async function grep(source, destination, pattern, encoding="utf8") {
// Set up the source stream for reading strings, not Buffers
source.setEncoding(encoding);
// Set an error handler on the destination stream in case standard
// output closes unexpectedly (when piping output to `head`, e.g.)
destination.on("error", err => process.exit());
// The chunks we read are unlikely to end with a newline, so each will
// probably have a partial line at the end. Track that here
let incompleteLine = "";
// Use a for/await loop to asynchronously read chunks from the input stream
for await (let chunk of source) {
// Split the end of the last chunk plus this one into lines
let lines = (incompleteLine + chunk).split("\n");
// The last line is incomplete
incompleteLine = lines.pop();
// Now loop through the lines and write any matches to the destination
for(let line of lines) {
if (pattern.test(line)) {
destination.write(line + "\n", encoding);
}
}
}
// Finally, check for a match on any trailing text.
if (pattern.test(incompleteLine)) {
destination.write(incompleteLine + "\n", encoding);
}
}
let pattern = new RegExp(process.argv[2]); // Get a RegExp from command line.
grep(process.stdin, process.stdout, pattern) // Call the async grep() function.
.catch(err => { // Handle asynchronous exceptions.
console.error(err);
process.exit();
});
16.5.3 写入流和处理背压
前面代码示例中的异步grep()函数演示了如何将可读流用作异步迭代器,但它还演示了您可以通过将其传递给write()方法来简单地向可写流写入数据。write()方法将缓冲区或字符串作为第一个参数。 (对象流期望其他类型的对象,但超出了本章的范围。)如果传递缓冲区,则将直接写入该缓冲区的字节。如果传递字符串,则在写入之前将其编码为字节的缓冲区。当您将字符串作为write()的唯一参数传递时,可写流具有默认编码。默认编码通常为“utf8”,但您可以通过在可写流上调用setDefaultEncoding()来显式设置它。或者,当您将字符串作为write()的第一个参数传递时,可以将编码名称作为第二个参数传递。
write()可选地将回调函数作为其第三个参数。当数据实际写入并不再位于可写流的内部缓冲区中时,将调用此函数。 (如果发生错误,也可能调用此回调,但不能保证。您应在可写流上注册“error”事件处理程序以检测错误。)
write()方法具有非常重要的返回值。当您在流上调用write()时,它将始终接受并缓冲您传递的数据块。如果内部缓冲区尚未满,则返回true。或者,如果缓冲区现在已满或过满,则返回false。此返回值是建议性的,您可以忽略它——如果您继续调用write(),可写流将根据需要扩大其内部缓冲区。但请记住,首先使用流式 API 的原因是避免一次性在内存中保存大量数据的成本。
从write()方法返回false的返回值是一种背压形式:流向你发送的消息,表示你写入数据的速度比处理速度快。对这种背压的正确响应是停止调用write(),直到流发出“drain”事件,表示缓冲区中再次有空间。例如,下面是一个向流写入数据的函数,并在可以继续向流写入更多数据时调用回调函数:
function write(stream, chunk, callback) {
// Write the specified chunk to the specified stream
let hasMoreRoom = stream.write(chunk);
// Check the return value of the write() method:
if (hasMoreRoom) { // If it returned true, then
setImmediate(callback); // invoke callback asynchronously.
} else { // If it returned false, then
stream.once("drain", callback); // invoke callback on drain event.
}
}
有时可以连续调用write()多次,有时必须在写入之间等待事件,这导致算法变得笨拙。这就是使用pipe()方法如此吸引人的原因之一:当你使用pipe()时,Node 会自动为你处理背压。
如果你在程序中使用await和async,并将可读流视为异步迭代器,那么实现上面的write()实用程序的基于 Promise 的版本以正确处理背压是很简单的。在我们刚刚看过的异步grep()函数中,我们没有处理背压。下面示例中的异步copy()函数演示了如何正确处理背压。请注意,此函数只是将源流中的块复制到目标流中,并调用copy(source, destination)就像调用source.pipe(destination)一样:
// This function writes the specified chunk to the specified stream and
// returns a Promise that will be fulfilled when it is OK to write again.
// Because it returns a Promise, it can be used with await.
function write(stream, chunk) {
// Write the specified chunk to the specified stream
let hasMoreRoom = stream.write(chunk);
if (hasMoreRoom) { // If buffer is not full, return
return Promise.resolve(null); // an already resolved Promise object
} else {
return new Promise(resolve => { // Otherwise, return a Promise that
stream.once("drain", resolve); // resolves on the drain event.
});
}
}
// Copy data from the source stream to the destination stream
// respecting backpressure from the destination stream.
// This is much like calling source.pipe(destination).
async function copy(source, destination) {
// Set an error handler on the destination stream in case standard
// output closes unexpectedly (when piping output to `head`, e.g.)
destination.on("error", err => process.exit());
// Use a for/await loop to asynchronously read chunks from the input stream
for await (let chunk of source) {
// Write the chunk and wait until there is more room in the buffer.
await write(destination, chunk);
}
}
// Copy standard input to standard output
copy(process.stdin, process.stdout);
在我们结束对流写入的讨论之前,再次注意,不响应背压可能导致程序使用的内存超出预期,当可写流的内部缓冲区溢出并不断增大时。如果你正在编写一个网络服务器,这可能是一个远程可利用的安全问题。假设你编写了一个通过网络传输文件的 HTTP 服务器,但你没有使用pipe(),也没有花时间处理write()方法的背压。攻击者可以编写一个 HTTP 客户端,发起对大文件(如图像)的请求,但实际上从未读取请求的主体。由于客户端没有从网络中读取数据,而服务器也没有响应背压,服务器上的缓冲区将会溢出。如果攻击者有足够的并发连接,这可能会演变成一个拒绝服务攻击,使你的服务器变慢甚至崩溃。
16.5.4 使用事件读取流
Node 的可读流有两种模式,每种模式都有自己的读取 API。如果你的程序不能使用管道或异步迭代,你将需要选择这两种基于事件的 API 之一来处理流。重要的是你只使用其中一种 API,不要混合使用这两种 API。
流动模式
在流动模式中,当可读数据到达时,它会立即以“data”事件的形式发出。要在此模式下从流中读取数据,只需为“data”事件注册事件处理程序,流将在数据块(缓冲区或字符串)可用时将其推送给你。请注意,在流动模式下不需要调用read()方法:你只需要处理“data”事件。请注意,新创建的流不会立即处于流动模式。注册“data”事件处理程序会将流切换到流动模式。方便的是,这意味着流在注册第一个“data”事件处理程序之前不会发出“data”事件。
如果你正在使用流模式从可读流中读取数据,处理数据,然后将其写入可写流,那么你可能需要处理可写流的背压。如果write()方法返回false表示写入缓冲区已满,你可以在可读流上调用pause()来暂时停止data事件。然后,当你从可写流中收到“drain”事件时,你可以在可读流上调用resume()来重新开始data事件的流动。
流在流动模式下在达到流的末尾时会发出一个“end”事件。这个事件表示不会再发出更多的“data”事件。并且,像所有流一样,如果发生错误,将会发出一个“error”事件。
在流部分的开头,我们展示了一个非流式的copyFile()函数,并承诺会有一个更好的版本。以下代码展示了如何实现一个使用流动模式 API 并处理背压的流式copyFile()函数。这本来更容易通过pipe()调用来实现,但在这里作为协调从一个流到另一个流的数据流的多个事件处理程序的有用演示。
const fs = require("fs");
// A streaming file copy function, using "flowing mode".
// Copies the contents of the named source file to the named destination file.
// On success, invokes the callback with a null argument. On error,
// invokes the callback with an Error object.
function copyFile(sourceFilename, destinationFilename, callback) {
let input = fs.createReadStream(sourceFilename);
let output = fs.createWriteStream(destinationFilename);
input.on("data", (chunk) => { // When we get new data,
let hasRoom = output.write(chunk); // write it to the output stream.
if (!hasRoom) { // If the output stream is full
input.pause(); // then pause the input stream.
}
});
input.on("end", () => { // When we reach the end of input,
output.end(); // tell the output stream to end.
});
input.on("error", err => { // If we get an error on the input,
callback(err); // call the callback with the error
process.exit(); // and quit.
});
output.on("drain", () => { // When the output is no longer full,
input.resume(); // resume data events on the input
});
output.on("error", err => { // If we get an error on the output,
callback(err); // call the callback with the error
process.exit(); // and quit.
});
output.on("finish", () => { // When output is fully written
callback(null); // call the callback with no error.
});
}
// Here's a simple command-line utility to copy files
let from = process.argv[2], to = process.argv[3];
console.log(`Copying file ${from} to ${to}...`);
copyFile(from, to, err => {
if (err) {
console.error(err);
} else {
console.log("done.");
}
});
暂停模式
可读流的另一种模式是“暂停模式”。这是流开始的模式。如果你从未注册“data”事件处理程序,也从未调用pipe()方法,那么可读流将保持在暂停模式。在暂停模式下,流不会以“data”事件的形式向你推送数据。相反,你需要通过显式调用其read()方法来从流中拉取数据。这不是一个阻塞调用,如果流上没有可读数据,它将返回null。由于没有同步 API 来等待数据,暂停模式 API 也是基于事件的。在暂停模式下,当流上有数据可读时,可读流会发出“readable”事件。作为响应,你的代码应该调用read()方法来读取数据。你必须在循环中这样做,重复调用read()直到它返回null。这样完全排空流的缓冲区是必要的,以便在将来触发新的“readable”事件。如果在仍然有可读数据时停止调用read(),你将不会收到另一个“readable”事件,你的程序可能会挂起。
暂停模式下的流会像流动模式下的流一样发出“end”和“error”事件。如果你正在编写一个从可读流读取数据并将其写入可写流的程序,那么暂停模式可能不是一个好选择。为了正确处理背压,你只想在输入流可读且输出流没有积压时才读取。在暂停模式下,这意味着读取和写入直到read()返回null或write()返回false,然后在readable或drain事件上重新开始读取或写入。这是不够优雅的,你可能会发现在这种情况下流动模式(或管道)更容易。
以下代码演示了如何计算指定文件内容的 SHA256 哈希。它使用一个处于暂停模式的可读流以块的形式读取文件的内容,然后将每个块传递给计算哈希的对象。(请注意,在 Node 12 及更高版本中,使用for/await循环编写此函数会更简单。)
const fs = require("fs");
const crypto = require("crypto");
// Compute a sha256 hash of the contents of the named file and pass the
// hash (as a string) to the specified error-first callback function.
function sha256(filename, callback) {
let input = fs.createReadStream(filename); // The data stream.
let hasher = crypto.createHash("sha256"); // For computing the hash.
input.on("readable", () => { // When there is data ready to read
let chunk;
while(chunk = input.read()) { // Read a chunk, and if non-null,
hasher.update(chunk); // pass it to the hasher,
} // and keep looping until not readable
});
input.on("end", () => { // At the end of the stream,
let hash = hasher.digest("hex"); // compute the hash,
callback(null, hash); // and pass it to the callback.
});
input.on("error", callback); // On error, call callback
}
// Here's a simple command-line utility to compute the hash of a file
sha256(process.argv[2], (err, hash) => { // Pass filename from command line.
if (err) { // If we get an error
console.error(err.toString()); // print it as an error.
} else { // Otherwise,
console.log(hash); // print the hash string.
}
});
16.6 进程、CPU 和操作系统详细信息
全局 Process 对象具有许多有用的属性和函数,通常与当前运行的 Node 进程的状态有关。请查阅 Node 文档以获取完整详情,但以下是一些你应该知道的属性和函数:
process.argv // An array of command-line arguments.
process.arch // The CPU architecture: "x64", for example.
process.cwd() // Returns the current working directory.
process.chdir() // Sets the current working directory.
process.cpuUsage() // Reports CPU usage.
process.env // An object of environment variables.
process.execPath // The absolute filesystem path to the node executable.
process.exit() // Terminates the program.
process.exitCode // An integer code to be reported when the program exits.
process.getuid() // Return the Unix user id of the current user.
process.hrtime.bigint() // Return a "high-resolution" nanosecond timestamp.
process.kill() // Send a signal to another process.
process.memoryUsage() // Return an object with memory usage details.
process.nextTick() // Like setImmediate(), invoke a function soon.
process.pid // The process id of the current process.
process.ppid // The parent process id.
process.platform // The OS: "linux", "darwin", or "win32", for example.
process.resourceUsage() // Return an object with resource usage details.
process.setuid() // Sets the current user, by id or name.
process.title // The process name that appears in `ps` listings.
process.umask() // Set or return the default permissions for new files.
process.uptime() // Return Node's uptime in seconds.
process.version // Node's version string.
process.versions // Version strings for the libraries Node depends on.
“os”模块(与process不同,需要使用require()显式加载)提供了关于 Node 运行的计算机和操作系统的类似低级细节的访问。你可能永远不需要使用这些功能中的任何一个,但值得知道 Node 提供了它们:
const os = require("os");
os.arch() // Returns CPU architecture. "x64" or "arm", for example.
os.constants // Useful constants such as os.constants.signals.SIGINT.
os.cpus() // Data about system CPU cores, including usage times.
os.endianness() // The CPU's native endianness "BE" or "LE".
os.EOL // The OS native line terminator: "\n" or "\r\n".
os.freemem() // Returns the amount of free RAM in bytes.
os.getPriority() // Returns the OS scheduling priority of a process.
os.homedir() // Returns the current user's home directory.
os.hostname() // Returns the hostname of the computer.
os.loadavg() // Returns the 1, 5, and 15-minute load averages.
os.networkInterfaces() // Returns details about available network. connections.
os.platform() // Returns OS: "linux", "darwin", or "win32", for example.
os.release() // Returns the version number of the OS.
os.setPriority() // Attempts to set the scheduling priority for a process.
os.tmpdir() // Returns the default temporary directory.
os.totalmem() // Returns the total amount of RAM in bytes.
os.type() // Returns OS: "Linux", "Darwin", or "Windows_NT", e.g.
os.uptime() // Returns the system uptime in seconds.
os.userInfo() // Returns uid, username, home, and shell of current user.
16.7 处理文件
Node 的“fs”模块是一个用于处理文件和目录的全面 API。它由“path”模块补充,后者定义了用于处理文件和目录名称的实用函数。“fs”模块包含一些高级函数,用于轻松读取、写入和复制文件。但是,该模块中的大多数函数都是低级 JavaScript 绑定到 Unix 系统调用(以及它们在 Windows 上的等效物)。如果之前有过低级文件系统调用的经验(在 C 或其他语言中),那么 Node API 对你来说将是熟悉的。如果没有,你可能会发现“fs”API 的某些部分很简洁和不直观。例如,删除文件的函数称为unlink()。
“fs”模块定义了一个庞大的 API,主要是因为通常每个基本操作都有多个变体。正如本章开头所讨论的,大多数函数(如fs.readFile())都是非阻塞的、基于回调的和异步的。通常情况下,每个函数都有一个同步阻塞的变体,比如fs.readFileSync()。在 Node 10 及更高版本中,许多这些函数还有基于 Promise 的异步变体,比如fs.promises.readFile()。大多数“fs”函数的第一个参数是一个字符串,指定要操作的文件的路径(文件名加可选的目录名)。但是其中一些函数也支持一个以整数“文件描述符”作为第一个参数而不是路径的变体。这些变体的名称以字母“f”开头。例如,fs.truncate()截断由路径指定的文件,而fs.ftruncate()截断由文件描述符指定的文件。还有一个基于 Promise 的fs.promises.truncate(),它期望一个路径,还有另一个基于 Promise 的版本,它作为 FileHandle 对象的方法实现。(FileHandle 类相当于 Promise-based API 中的文件描述符。)最后,在“fs”模块中有一些函数的变体的名称以字母“l”开头。这些“l”变体类似于基本函数,但不会遵循文件系统中的符号链接,而是直接操作符号链接本身。
16.7.1 路径、文件描述符和 FileHandles
要使用“fs”模块处理文件,首先需要能够命名要处理的文件。文件通常由路径指定,这意味着文件本身的名称,以及文件所在的目录层次结构。如果路径是绝对的,这意味着指定了一直到文件系统根目录的所有目录。否则,路径是相对的,只有与其他路径相关时才有意义,通常是当前工作目录。处理路径可能有点棘手,因为不同的操作系统使用不同的字符来分隔目录名称,当连接路径时很容易意外加倍这些分隔符字符,并且../父目录路径段需要特殊处理。Node 的“path”模块和其他几个重要的 Node 功能有所帮助:
// Some important paths
process.cwd() // Absolute path of the current working directory.
__filename // Absolute path of the file that holds the current code.
__dirname // Absolute path of the directory that holds __filename.
os.homedir() // The user's home directory.
const path = require("path");
path.sep // Either "/" or "\" depending on your OS
// The path module has simple parsing functions
let p = "src/pkg/test.js"; // An example path
path.basename(p) // => "test.js"
path.extname(p) // => ".js"
path.dirname(p) // => "src/pkg"
path.basename(path.dirname(p)) // => "pkg"
path.dirname(path.dirname(p)) // => "src"
// normalize() cleans up paths:
path.normalize("a/b/c/../d/") // => "a/b/d/": handles ../ segments
path.normalize("a/./b") // => "a/b": strips "./" segments
path.normalize("//a//b//") // => "/a/b/": removes duplicate /
// join() combines path segments, adding separators, then normalizes
path.join("src", "pkg", "t.js") // => "src/pkg/t.js"
// resolve() takes one or more path segments and returns an absolute
// path. It starts with the last argument and works backward, stopping
// when it has built an absolute path or resolving against process.cwd().
path.resolve() // => process.cwd()
path.resolve("t.js") // => path.join(process.cwd(), "t.js")
path.resolve("/tmp", "t.js") // => "/tmp/t.js"
path.resolve("/a", "/b", "t.js") // => "/b/t.js"
请注意,path.normalize()只是一个字符串操作函数,没有访问实际文件系统。fs.realpath()和fs.realpathSync()函数执行文件系统感知的规范化:它们解析符号链接并解释相对于当前工作目录的相对路径名。
在前面的示例中,我们假设代码在基于 Unix 的操作系统上运行,path.sep是“/”。如果想在 Windows 系统上使用 Unix 风格的路径,可以使用path.posix而不是path。反之,如果想在 Unix 系统上使用 Windows 路径,可以使用path.win32。path.posix和path.win32定义了与path本身相同的属性和函数。
我们将在接下来的章节中介绍一些“fs”函数,它们期望一个文件描述符而不是文件名。文件描述符是作为操作系统级别引用“打开”文件的整数。通过调用fs.open()(或fs.openSync())函数,你可以为给定的名称获取一个描述符。进程一次只能打开有限数量的文件,因此当你使用完文件描述符时,调用fs.close()是很重要的。如果你想要使用最底层的fs.read()和fs.write()函数,允许你在文件中跳转,不同时间读取和写入文件的位,你需要打开文件。在“fs”模块中有其他使用文件描述符的函数,但它们都有基于名称的版本,只有当你打算打开文件进行读取或写入时,才真正有意义使用基于描述符的函数。
最后,在fs.promises定义的基于 Promise 的 API 中,fs.open()的等价物是fs.promises.open(),它返回一个解析为 FileHandle 对象的 Promise。这个 FileHandle 对象用于与文件描述符具有相同的目的。然而,除非你需要使用 FileHandle 的最底层的read()和write()方法,否则真的没有理由创建一个。如果你确实创建了一个 FileHandle,记得在使用完毕后调用它的close()方法。
16.7.2 读取文件
Node 允许你一次性读取文件内容,通过流,或使用低级别的 API。
如果你的文件很小,或者内存使用和性能不是最高优先级,那么通常最容易的方法是一次性读取整个文件的内容。你可以同步地、通过回调或 Promise 来做到这一点。默认情况下,你会得到文件的字节作为缓冲区,但如果指定了编码,你将得到一个解码后的字符串。
const fs = require("fs");
let buffer = fs.readFileSync("test.data"); // Synchronous, returns buffer
let text = fs.readFileSync("data.csv", "utf8"); // Synchronous, returns string
// Read the bytes of the file asynchronously
fs.readFile("test.data", (err, buffer) => {
if (err) {
// Handle the error here
} else {
// The bytes of the file are in buffer
}
});
// Promise-based asynchronous read
fs.promises
.readFile("data.csv", "utf8")
.then(processFileText)
.catch(handleReadError);
// Or use the Promise API with await inside an async function
async function processText(filename, encoding="utf8") {
let text = await fs.promises.readFile(filename, encoding);
// ... process the text here...
}
如果你能够按顺序处理文件的内容,并且不需要同时将文件的整个内容保存在内存中,那么通过流来读取文件可能是最有效的方法。我们已经广泛讨论了流:这里是你如何使用流和pipe()方法将文件的内容写入标准输出的示例:
function printFile(filename, encoding="utf8") {
fs.createReadStream(filename, encoding).pipe(process.stdout);
}
最后,如果你需要对从文件中读取的字节以及何时读取它们进行低级别的控制,你可以打开一个文件以获取文件描述符,然后使用fs.read()、fs.readSync()或fs.promises.read()从文件的指定源位置读取指定数量的字节到指定的缓冲区的指定目标位置:
const fs = require("fs");
// Reading a specific portion of a data file
fs.open("data", (err, fd) => {
if (err) {
// Report error somehow
return;
}
try {
// Read bytes 20 through 420 into a newly allocated buffer.
fs.read(fd, Buffer.alloc(400), 0, 400, 20, (err, n, b) => {
// err is the error, if any.
// n is the number of bytes actually read
// b is the buffer that they bytes were read into.
});
}
finally { // Use a finally clause so we always
fs.close(fd); // close the open file descriptor
}
});
如果你需要从文件中读取多个数据块,基于回调的read()API 使用起来很麻烦。如果你可以使用同步 API(或基于 Promise 的 API 与await),那么从文件中读取多个数据块变得很容易:
const fs = require("fs");
function readData(filename) {
let fd = fs.openSync(filename);
try {
// Read the file header
let header = Buffer.alloc(12); // A 12 byte buffer
fs.readSync(fd, header, 0, 12, 0);
// Verify the file's magic number
let magic = header.readInt32LE(0);
if (magic !== 0xDADAFEED) {
throw new Error("File is of wrong type");
}
// Now get the offset and length of the data from the header
let offset = header.readInt32LE(4);
let length = header.readInt32LE(8);
// And read those bytes from the file
let data = Buffer.alloc(length);
fs.readSync(fd, data, 0, length, offset);
return data;
} finally {
// Always close the file, even if an exception is thrown above
fs.closeSync(fd);
}
}
16.7.3 写入文件
在 Node 中写入文件与读取文件非常相似,但有一些额外的细节需要了解。其中一个细节是,创建一个新文件的方式就是简单地向一个尚不存在的文件名写入。
与读取类似,Node 中有三种基本的写入文件的方式。如果文件的整个内容是一个字符串或缓冲区,你可以使用fs.writeFile()(基于回调)、fs.writeFileSync()(同步)或fs.promises.writeFile()(基于 Promise)一次性写入整个内容:
fs.writeFileSync(path.resolve(__dirname, "settings.json"),
JSON.stringify(settings));
如果要写入文件的数据是字符串,并且想要使用除了“utf8”之外的编码,请将编码作为可选的第三个参数传递。
相关的函数fs.appendFile()、fs.appendFileSync()和fs.promises.appendFile()类似,但当指定的文件已经存在时,它们会将数据追加到末尾而不是覆盖现有文件内容。
如果要写入文件的数据不是一个块,或者不是同时在内存中的所有数据,那么使用 Writable 流是一个不错的方法,假设您计划从头到尾写入数据而不跳过文件中的位置:
const fs = require("fs");
let output = fs.createWriteStream("numbers.txt");
for(let i = 0; i < 100; i++) {
output.write(`${i}\n`);
}
output.end();
最后,如果您想要将数据写入文件的多个块,并且希望能够控制写入每个块的确切位置,那么可以使用fs.open()、fs.openSync()或fs.promises.open()打开文件,然后使用结果文件描述符与fs.write()或fs.writeSync()函数。这些函数有不同形式的字符串和缓冲区。字符串变体接受文件描述符、字符串和要写入该字符串的文件位置(可选的第四个参数为编码)。缓冲区变体接受文件描述符、缓冲区、偏移量和长度,指定缓冲区内的数据块,并指定要写入该块的字节的文件位置。如果您有要写入的 Buffer 对象数组,可以使用单个fs.writev()或fs.writevSync()。使用fs.promises.open()和它生成的 FileHandle 对象写入缓冲区和字符串存在类似的低级函数。
你可以使用fs.truncate()、fs.truncateSync()或fs.promises.truncate()来截断文件的末尾。这些函数以路径作为第一个参数,长度作为第二个参数,并修改文件使其具有指定的长度。如果省略长度,则使用零,并且文件变为空。尽管这些函数的名称是这样的,但它们也可以用于扩展文件:如果指定的长度比当前文件大小长,文件将扩展为零字节到新大小。如果您已经打开要修改的文件,可以使用带有文件描述符或 FileHandle 的ftruncate()或ftruncateSync()。
这里描述的各种文件写入函数在数据“写入”后返回或调用其回调或解析其 Promise,这意味着 Node 已将数据交给操作系统。但这并不一定意味着数据实际上已经写入到持久存储中:至少您的一些数据可能仍然在操作系统中的某个地方或设备驱动程序中缓冲,等待写入磁盘。如果调用fs.writeSync()同步将一些数据写入文件,并且在函数返回后立即发生停电,您可能仍会丢失数据。如果要强制将数据写入磁盘,以确保它已经安全保存,使用fs.fsync()或fs.fsyncSync()。这些函数仅适用于文件描述符:没有基于路径的版本。
16.7.4 文件操作
Node 的流类的前面讨论包括两个copyFile()函数的示例。这些不是您实际使用的实用程序,因为“fs”模块定义了自己的fs.copyFile()方法(当然还有fs.copyFileSync()和fs.promises.copyFile())。
这些函数将原始文件的名称和副本的名称作为它们的前两个参数。这些可以指定为字符串或 URL 或缓冲区对象。可选的第三个参数是一个整数,其位指定控制copy操作细节的标志。对于基于回调的fs.copyFile(),最后一个参数是在复制完成时不带参数调用的回调函数,或者如果出现错误则带有错误参数调用。以下是一些示例:
// Basic synchronous file copy.
fs.copyFileSync("ch15.txt", "ch15.bak");
// The COPYFILE_EXCL argument copies only if the new file does not already
// exist. It prevents copies from overwriting existing files.
fs.copyFile("ch15.txt", "ch16.txt", fs.constants.COPYFILE_EXCL, err => {
// This callback will be called when done. On error, err will be non-null.
});
// This code demonstrates the Promise-based version of the copyFile function.
// Two flags are combined with the bitwise OR opeartor |. The flags mean that
// existing files won't be overwritten, and that if the filesystem supports
// it, the copy will be a copy-on-write clone of the original file, meaning
// that no additional storage space will be required until either the original
// or the copy is modified.
fs.promises.copyFile("Important data",
`Important data ${new Date().toISOString()}"
fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE)
.then(() => {
console.log("Backup complete");
});
.catch(err => {
console.error("Backup failed", err);
});
fs.rename()函数(以及通常的同步和基于 Promise 的变体)移动和/或重命名文件。调用它时,传入当前文件的路径和所需的新文件路径。没有标志参数,但基于回调的版本将回调作为第三个参数:
fs.renameSync("ch15.bak", "backups/ch15.bak");
请注意,没有标志可以防止重命名覆盖现有文件。同时请记住,文件只能在文件系统内重命名。
函数fs.link()和fs.symlink()及其变体具有与fs.rename()相同的签名,并且类似于fs.copyFile(),只是它们分别创建硬链接和符号链接,而不是创建副本。
最后,fs.unlink()、fs.unlinkSync()和fs.promises.unlink()是 Node 用于删除文件的函数。(这种不直观的命名是从 Unix 继承而来,其中删除文件基本上是创建其硬链接的相反操作。)调用此函数并传递一个回调(如果使用基于回调的版本)来删除要删除的文件的字符串、缓冲区或 URL 路径:
fs.unlinkSync("backups/ch15.bak");
16.7.5 文件元数据
fs.stat()、fs.statSync()和fs.promises.stat()函数允许您获取指定文件或目录的元数据。例如:
const fs = require("fs");
let stats = fs.statSync("book/ch15.md");
stats.isFile() // => true: this is an ordinary file
stats.isDirectory() // => false: it is not a directory
stats.size // file size in bytes
stats.atime // access time: Date when it was last read
stats.mtime // modification time: Date when it was last written
stats.uid // the user id of the file's owner
stats.gid // the group id of the file's owner
stats.mode.toString(8) // the file's permissions, as an octal string
返回的 Stats 对象包含其他更隐晦的属性和方法,但此代码演示了您最有可能使用的属性。
fs.lstat()及其变体的工作方式与fs.stat()完全相同,只是如果指定的文件是符号链接,则 Node 将返回链接本身的元数据,而不是跟随链接。
如果您已打开文件以生成文件描述符或 FileHandle 对象,则可以使用fs.fstat()或其变体获取已打开文件的元数据信息,而无需再次指定文件名。
除了使用fs.stat()及其所有变体查询元数据外,还有用于更改元数据的函数。
fs.chmod()、fs.lchmod()和fs.fchmod()(以及同步和基于 Promise 的版本)设置文件或目录的“模式”或权限。模式值是整数,其中每个位具有特定含义,并且在八进制表示法中最容易理解。例如,要使文件对其所有者只读且对其他人不可访问,请使用0o400:
fs.chmodSync("ch15.md", 0o400); // Don't delete it accidentally!
fs.chown()、fs.lchown()和fs.fchown()(以及同步和基于 Promise 的版本)设置文件或目录的所有者和组(作为 ID)。 (这很重要,因为它们与fs.chmod()设置的文件权限交互。)
最后,您可以使用fs.utimes()和fs.futimes()及其变体设置文件或目录的访问时间和修改时间。
16.7.6 处理目录
在 Node 中创建新目录,使用fs.mkdir()、fs.mkdirSync()或fs.promises.mkdir()。第一个参数是要创建的目录的路径。可选的第二个参数可以是指定新目录的模式(权限位)的整数。或者您可以传递一个带有可选mode和recursive属性的对象。如果recursive为true,则此函数将创建路径中尚不存在的任何目录:
// Ensure that dist/ and dist/lib/ both exist.
fs.mkdirSync("dist/lib", { recursive: true });
fs.mkdtemp()及其变体接受您提供的路径前缀,将一些随机字符附加到其后(这对安全性很重要),创建一个以该名称命名的目录,并将目录路径返回(或传递给回调)给您。
要删除一个目录,使用fs.rmdir()或其变体之一。请注意,在删除之前目录必须为空:
// Create a random temporary directory and get its path, then
// delete it when we are done
let tempDirPath;
try {
tempDirPath = fs.mkdtempSync(path.join(os.tmpdir(), "d"));
// Do something with the directory here
} finally {
// Delete the temporary directory when we're done with it
fs.rmdirSync(tempDirPath);
}
“fs”模块为列出目录内容提供了两种不同的 API。首先,fs.readdir()、fs.readdirSync()和fs.promises.readdir()一次性读取整个目录,并向您提供一个字符串数组或指定每个项目的名称和类型(文件或目录)的 Dirent 对象数组。这些函数返回的文件名只是文件的本地名称,而不是整个路径。以下是示例:
let tempFiles = fs.readdirSync("/tmp"); // returns an array of strings
// Use the Promise-based API to get a Dirent array, and then
// print the paths of subdirectories
fs.promises.readdir("/tmp", {withFileTypes: true})
.then(entries => {
entries.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.forEach(name => console.log(path.join("/tmp/", name)));
})
.catch(console.error);
如果你预计需要列出可能有数千条条目的目录,你可能更喜欢 fs.opendir() 及其变体的流式处理方法。这些函数返回表示指定目录的 Dir 对象。你可以使用 Dir 对象的 read() 或 readSync() 方法逐个读取 Dirent。如果向 read() 传递一个回调函数,它将调用该回调。如果省略回调参数,它将返回一个 Promise。当没有更多目录条目时,你将得到 null 而不是 Dirent 对象。
使用 Dir 对象最简单的方法是作为异步迭代器与 for/await 循环一起使用。以下是一个使用流式 API 列出目录条目、对每个条目调用 stat() 并打印文件和目录名称及大小的函数示例:
const fs = require("fs");
const path = require("path");
async function listDirectory(dirpath) {
let dir = await fs.promises.opendir(dirpath);
for await (let entry of dir) {
let name = entry.name;
if (entry.isDirectory()) {
name += "/"; // Add a trailing slash to subdirectories
}
let stats = await fs.promises.stat(path.join(dirpath, name));
let size = stats.size;
console.log(String(size).padStart(10), name);
}
}
16.8 HTTP 客户端和服务器
Node 的 “http”,“https” 和 “http2” 模块是完整功能但相对低级的 HTTP 协议实现。它们定义了全面的 API 用于实现 HTTP 客户端和服务器。由于这些 API 相对较低级,本章节无法覆盖所有功能。但接下来的示例演示了如何编写基本的客户端和服务器。
发起基本的 HTTP GET 请求的最简单方法是使用 http.get() 或 https.get()。这些函数的第一个参数是要获取的 URL。(如果是一个 http:// URL,你必须使用 “http” 模块,如果是一个 https:// URL,你必须使用 “https” 模块。)第二个参数是一个回调函数,当服务器的响应开始到达时将调用该回调,并传入一个 IncomingMessage 对象。当回调被调用时,HTTP 状态和头部信息是可用的,但正文可能还没有准备好。IncomingMessage 对象是一个可读流,你可以使用本章前面演示的技术从中读取响应正文。
§13.2.6 结尾的 getJSON() 函数使用了 http.get() 函数作为 Promise() 构造函数演示的一部分。现在你已经了解了 Node 流和 Node 编程模型,值得重新访问该示例,看看如何使用 http.get()。
http.get() 和 https.get() 是稍微简化的 http.request() 和 https.request() 函数的变体。以下的 postJSON() 函数演示了如何使用 https.request() 发起包含 JSON 请求体的 HTTPS POST 请求。与 第十三章 的 getJSON() 函数一样,它期望一个 JSON 响应,并返回一个解析后的该响应的 Promise:
const https = require("https");
/*
* Convert the body object to a JSON string then HTTPS POST it to the
* specified API endpoint on the specified host. When the response arrives,
* parse the response body as JSON and resolve the returned Promise with
* that parsed value.
*/
function postJSON(host, endpoint, body, port, username, password) {
// Return a Promise object immediately, then call resolve or reject
// when the HTTPS request succeeds or fails.
return new Promise((resolve, reject) => {
// Convert the body object to a string
let bodyText = JSON.stringify(body);
// Configure the HTTPS request
let requestOptions = {
method: "POST", // Or "GET", "PUT", "DELETE", etc.
host: host, // The host to connect to
path: endpoint, // The URL path
headers: { // HTTP headers for the request
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(bodyText)
}
};
if (port) { // If a port is specified,
requestOptions.port = port; // use it for the request.
}
// If credentials are specified, add an Authorization header.
if (username && password) {
requestOptions.auth = `${username}:${password}`;
}
// Now create the request based on the configuration object
let request = https.request(requestOptions);
// Write the body of the POST request and end the request.
request.write(bodyText);
request.end();
// Fail on request errors (such as no network connection)
request.on("error", e => reject(e));
// Handle the response when it starts to arrive.
request.on("response", response => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP status ${response.statusCode}`));
// We don't care about the response body in this case, but
// we don't want it to stick around in a buffer somewhere, so
// we put the stream into flowing mode without registering
// a "data" handler so that the body is discarded.
response.resume();
return;
}
// We want text, not bytes. We're assuming the text will be
// JSON-formatted but aren't bothering to check the
// Content-Type header.
response.setEncoding("utf8");
// Node doesn't have a streaming JSON parser, so we read the
// entire response body into a string.
let body = "";
response.on("data", chunk => { body += chunk; });
// And now handle the response when it is complete.
response.on("end", () => { // When the response is done,
try { // try to parse it as JSON
resolve(JSON.parse(body)); // and resolve the result.
} catch(e) { // Or, if anything goes wrong,
reject(e); // reject with the error
}
});
});
});
}
除了发起 HTTP 和 HTTPS 请求, “http” 和 “https” 模块还允许你编写响应这些请求的服务器。基本的方法如下:
-
创建一个新的 Server 对象。
-
调用其
listen()方法开始监听指定端口的请求。 -
为 “request” 事件注册一个事件处理程序,使用该处理程序来读取客户端的请求(特别是
request.url属性),并编写你的响应。
接下来的代码创建了一个简单的 HTTP 服务器,从本地文件系统提供静态文件,并实现了一个调试端点,通过回显客户端的请求来响应。
// This is a simple static HTTP server that serves files from a specified
// directory. It also implements a special /test/mirror endpoint that
// echoes the incoming request, which can be useful when debugging clients.
const http = require("http"); // Use "https" if you have a certificate
const url = require("url"); // For parsing URLs
const path = require("path"); // For manipulating filesystem paths
const fs = require("fs"); // For reading files
// Serve files from the specified root directory via an HTTP server that
// listens on the specified port.
function serve(rootDirectory, port) {
let server = new http.Server(); // Create a new HTTP server
server.listen(port); // Listen on the specified port
console.log("Listening on port", port);
// When requests come in, handle them with this function
server.on("request", (request, response) => {
// Get the path portion of the request URL, ignoring
// any query parameters that are appended to it.
let endpoint = url.parse(request.url).pathname;
// If the request was for "/test/mirror", send back the request
// verbatim. Useful when you need to see the request headers and body.
if (endpoint === "/test/mirror") {
// Set response header
response.setHeader("Content-Type", "text/plain; charset=UTF-8");
// Specify response status code
response.writeHead(200); // 200 OK
// Begin the response body with the request
response.write(`${request.method} ${request.url} HTTP/${
request.httpVersion
}\r\n`);
// Output the request headers
let headers = request.rawHeaders;
for(let i = 0; i < headers.length; i += 2) {
response.write(`${headers[i]}: ${headers[i+1]}\r\n`);
}
// End headers with an extra blank line
response.write("\r\n");
// Now we need to copy any request body to the response body
// Since they are both streams, we can use a pipe
request.pipe(response);
}
// Otherwise, serve a file from the local directory.
else {
// Map the endpoint to a file in the local filesystem
let filename = endpoint.substring(1); // strip leading /
// Don't allow "../" in the path because it would be a security
// hole to serve anything outside the root directory.
filename = filename.replace(/\.\.\//g, "");
// Now convert from relative to absolute filename
filename = path.resolve(rootDirectory, filename);
// Now guess the type file's content type based on extension
let type;
switch(path.extname(filename)) {
case ".html":
case ".htm": type = "text/html"; break;
case ".js": type = "text/javascript"; break;
case ".css": type = "text/css"; break;
case ".png": type = "image/png"; break;
case ".txt": type = "text/plain"; break;
default: type = "application/octet-stream"; break;
}
let stream = fs.createReadStream(filename);
stream.once("readable", () => {
// If the stream becomes readable, then set the
// Content-Type header and a 200 OK status. Then pipe the
// file reader stream to the response. The pipe will
// automatically call response.end() when the stream ends.
response.setHeader("Content-Type", type);
response.writeHead(200);
stream.pipe(response);
});
stream.on("error", (err) => {
// Instead, if we get an error trying to open the stream
// then the file probably does not exist or is not readable.
// Send a 404 Not Found plain-text response with the
// error message.
response.setHeader("Content-Type", "text/plain; charset=UTF-8");
response.writeHead(404);
response.end(err.message);
});
}
});
}
// When we're invoked from the command line, call the serve() function
serve(process.argv[2] || "/tmp", parseInt(process.argv[3]) || 8000);
Node 的内置模块就足以编写简单的 HTTP 和 HTTPS 服务器。但请注意,生产服务器通常不直接构建在这些模块之上。相反,大多数复杂的服务器是使用外部库实现的——比如 Express 框架——提供了后端 web 开发人员所期望的 “中间件” 和其他更高级的实用工具。
16.9 非 HTTP 网络服务器和客户端
Web 服务器和客户端已经变得如此普遍,以至于很容易忘记可以编写不使用 HTTP 的客户端和服务器。 尽管 Node 以编写 Web 服务器的良好环境而闻名,但 Node 还完全支持编写其他类型的网络服务器和客户端。
如果您习惯使用流,那么网络相对简单,因为网络套接字只是一种双工流。 “net”模块定义了 Server 和 Socket 类。 要创建一个服务器,调用net.createServer(),然后调用生成的对象的listen()方法,告诉服务器在哪个端口上监听连接。 当客户端在该端口上连接时,Server 对象将生成“connection”事件,并传递给事件侦听器的值将是一个 Socket 对象。 Socket 对象是一个双工流,您可以使用它从客户端读取数据并向客户端写入数据。 在 Socket 上调用end()以断开连接。
编写客户端甚至更容易:将端口号和主机名传递给net.createConnection()以创建一个套接字,用于与在该主机上运行并在该端口上监听的任何服务器通信。 然后使用该套接字从服务器读取和写入数据。
以下代码演示了如何使用“net”模块编写服务器。 当客户端连接时,服务器讲一个 knock-knock 笑话:
// A TCP server that delivers interactive knock-knock jokes on port 6789.
// (Why is six afraid of seven? Because seven ate nine!)
const net = require("net");
const readline = require("readline");
// Create a Server object and start listening for connections
let server = net.createServer();
server.listen(6789, () => console.log("Delivering laughs on port 6789"));
// When a client connects, tell them a knock-knock joke.
server.on("connection", socket => {
tellJoke(socket)
.then(() => socket.end()) // When the joke is done, close the socket.
.catch((err) => {
console.error(err); // Log any errors that occur,
socket.end(); // but still close the socket!
});
});
// These are all the jokes we know.
const jokes = {
"Boo": "Don't cry...it's only a joke!",
"Lettuce": "Let us in! It's freezing out here!",
"A little old lady": "Wow, I didn't know you could yodel!"
};
// Interactively perform a knock-knock joke over this socket, without blocking.
async function tellJoke(socket) {
// Pick one of the jokes at random
let randomElement = a => a[Math.floor(Math.random() * a.length)];
let who = randomElement(Object.keys(jokes));
let punchline = jokes[who];
// Use the readline module to read the user's input one line at a time.
let lineReader = readline.createInterface({
input: socket,
output: socket,
prompt: ">> "
});
// A utility function to output a line of text to the client
// and then (by default) display a prompt.
function output(text, prompt=true) {
socket.write(`${text}\r\n`);
if (prompt) lineReader.prompt();
}
// Knock-knock jokes have a call-and-response structure.
// We expect different input from the user at different stages and
// take different action when we get that input at different stages.
let stage = 0;
// Start the knock-knock joke off in the traditional way.
output("Knock knock!");
// Now read lines asynchronously from the client until the joke is done.
for await (let inputLine of lineReader) {
if (stage === 0) {
if (inputLine.toLowerCase() === "who's there?") {
// If the user gives the right response at stage 0
// then tell the first part of the joke and go to stage 1.
output(who);
stage = 1;
} else {
// Otherwise teach the user how to do knock-knock jokes.
output('Please type "Who\'s there?".');
}
} else if (stage === 1) {
if (inputLine.toLowerCase() === `${who.toLowerCase()} who?`) {
// If the user's response is correct at stage 1, then
// deliver the punchline and return since the joke is done.
output(`${punchline}`, false);
return;
} else {
// Make the user play along.
output(`Please type "${who} who?".`);
}
}
}
}
这样的简单基于文本的服务器通常不需要一个定制的客户端。如果您的系统上安装了nc(“netcat”)实用程序,您可以使用它来与这个服务器通信,方法如下:
$ nc localhost 6789
Knock knock!
>> Who's there?
A little old lady
>> A little old lady who?
Wow, I didn't know you could yodel!
另一方面,在 Node 中编写一个定制的客户端对于笑话服务器来说很容易。 我们只需连接到服务器,然后将服务器的输出导向 stdout,并将 stdin 导向服务器的输入:
// Connect to the joke port (6789) on the server named on the command line
let socket = require("net").createConnection(6789, process.argv[2]);
socket.pipe(process.stdout); // Pipe data from the socket to stdout
process.stdin.pipe(socket); // Pipe data from stdin to the socket
socket.on("close", () => process.exit()); // Quit when the socket closes.
除了支持基于 TCP 的服务器,Node 的“net”模块还支持通过“Unix 域套接字”进行进程间通信,这些套接字通过文件系统路径而不是端口号进行标识。 我们不打算在本章中涵盖这种类型的套接字,但 Node 文档中有详细信息。 我们在这里没有空间涵盖的其他 Node 功能包括“dgram”模块用于基于 UDP 的客户端和服务器,以及“tls”模块,它类似于“https”对“http”的关系。 tls.Server和tls.TLSSocket类允许创建使用 SSL 加密连接的 TCP 服务器(如 knock-knock 笑话服务器),就像 HTTPS 服务器一样。
16.10 使用子进程进行操作
除了编写高度并发的服务器,Node 还适用于编写执行其他程序的脚本。 在 Node 中,“child_process”模块定义了许多函数,用于作为子进程运行其他程序。 本节演示了其中一些函数,从最简单的开始,逐渐过渡到更复杂的函数。
16.10.1 execSync()和 execFileSync()
运行另一个程序的最简单方法是使用child_process.execSync()。 此函数将要运行的命令作为其第一个参数。 它创建一个子进程,在该进程中运行一个 shell,并使用 shell 执行您传递的命令。 然后它阻塞,直到命令(和 shell)退出。 如果命令以错误退出,则execSync()会抛出异常。 否则,execSync()返回命令写入其 stdout 流的任何输出。 默认情况下,此返回值是一个缓冲区,但您可以在可选的第二个参数中指定编码以获得一个字符串。 如果命令将任何输出写入 stderr,则该输出将直接传递到父进程的 stderr 流。
所以,例如,如果您正在编写一个脚本,性能不是一个问题,您可能会使用child_process.execSync()来列出一个目录,而不是使用fs.readdirSync()函数:
const child_process = require("child_process");
let listing = child_process.execSync("ls -l web/*.html", {encoding: "utf8"});
execSync() 调用完整的 Unix shell 意味着您传递给它的字符串可以包含多个以分号分隔的命令,并且可以利用 shell 功能,如文件名通配符、管道和输出重定向。这也意味着您必须小心,永远不要将来自用户输入或类似不受信任来源的命令传递给 execSync()。shell 命令的复杂语法很容易被利用,以允许攻击者运行任意代码。
如果您不需要 shell 的功能,可以通过使用 child_process.execFileSync() 避免启动 shell 的开销。此函数直接执行程序,而不调用 shell。但由于不涉及 shell,它无法解析命令行,您必须将可执行文件作为第一个参数传递,并将命令行参数数组作为第二个参数传递:
let listing = child_process.execFileSync("ls", ["-l", "web/"],
{encoding: "utf8"});
16.10.2 exec() 和 execFile()
execSync() 和 execFileSync() 函数是同步的:它们会阻塞并在子进程退出之前不返回。使用这些函数很像在终端窗口中输入 Unix 命令:它们允许您逐个运行一系列命令。但是,如果您正在编写一个需要完成多个任务且这些任务彼此不依赖的程序,那么您可能希望并行运行它们并同时运行多个命令。您可以使用异步函数 child_process.exec() 和 child_process.execFile() 来实现这一点。
exec() 和 execFile() 与它们的同步变体类似,只是它们立即返回一个代表正在运行的子进程的 ChildProcess 对象,并且它们将错误优先的回调作为最后一个参数。当子进程退出时,将调用回调,并实际上会使用三个参数调用它。第一个是错误(如果有的话);如果进程正常终止,则为 null。第二个参数是发送到子进程标准输出流的收集输出。第三个参数是发送到子进程标准错误流的任何输出。
exec() 和 execFile() 返回的 ChildProcess 对象允许您终止子进程,并向其写入数据(然后可以从其标准输入读取)。当我们讨论 child_process.spawn() 函数时,我们将更详细地介绍 ChildProcess。
如果您计划同时执行多个子进程,则最简单的方法可能是使用 exec() 的“promisified”版本,它返回一个 Promise 对象,如果子进程无错误退出,则解析为具有 stdout 和 stderr 属性的对象。例如,这是一个接受 shell 命令数组作为输入并返回一个 Promise 的函数,该 Promise 解析为所有这些命令的结果:
const child_process = require("child_process");
const util = require("util");
const execP = util.promisify(child_process.exec);
function parallelExec(commands) {
// Use the array of commands to create an array of Promises
let promises = commands.map(command => execP(command, {encoding: "utf8"}));
// Return a Promise that will fulfill to an array of the fulfillment
// values of each of the individual promises. (Instead of returning objects
// with stdout and stderr properties we just return the stdout value.)
return Promise.all(promises)
.then(outputs => outputs.map(out => out.stdout));
}
module.exports = parallelExec;
16.10.3 spawn()
到目前为止描述的各种 exec 函数——同步和异步——都设计用于与快速运行且不产生大量输出的子进程一起使用。即使是异步的 exec() 和 execFile() 也不是流式的:它们在进程退出后才一次性返回进程输出。
child_process.spawn() 函数允许您在子进程仍在运行时流式访问子进程的输出。它还允许您向子进程写入数据(子进程将把该数据视为其标准输入流上的输入):这意味着可以动态与子进程交互,根据其生成的输出发送输入。
spawn() 默认不使用 shell,因此您必须像使用 execFile() 一样调用它,提供要运行的可执行文件以及一个单独的命令行参数数组传递给它。spawn() 返回一个类似于 execFile() 的 ChildProcess 对象,但它不接受回调参数。您可以监听 ChildProcess 对象及其流上的事件,而不是使用回调函数。
由spawn()返回的 ChildProcess 对象是一个事件发射器。你可以监听“exit”事件以在子进程退出时收到通知。ChildProcess 对象还有三个流属性。stdout和stderr是可读流:当子进程写入其 stdout 和 stderr 流时,该输出通过 ChildProcess 流变得可读。请注意这里名称的倒置。在子进程中,stdout是一个可写输出流,但在父进程中,ChildProcess 对象的stdout属性是一个可读输入流。
类似地,ChildProcess 对象的stdin属性是一个可写流:你写入到这个流的任何内容都会在子进程的标准输入上可用。
ChildProcess 对象还定义了一个pid属性,指定子进程的进程 ID。它还定义了一个kill()方法,用于终止子进程。
16.10.4 fork()
child_process.fork()是一个专门用于在子 Node 进程中运行 JavaScript 代码模块的函数。fork()期望与spawn()相同的参数,但第一个参数应指定 JavaScript 代码文件的路径,而不是可执行二进制文件。
使用fork()创建的子进程可以通过其标准输入和标准输出流与父进程通信,就像在spawn()的前一节中描述的那样。但是,fork()还为父子进程之间提供了另一个更简单的通信渠道。
当你使用fork()创建一个子进程时,你可以使用返回的 ChildProcess 对象的send()方法向子进程发送一个对象的副本。你可以监听 ChildProcess 上的“message”事件来接收子进程发送的消息。在子进程中运行的代码可以使用process.send()向父进程发送消息,并且可以监听process上的“message”事件来接收父进程发送的消息。
这里,例如,是一些使用fork()创建子进程的代码,然后向该子进程发送消息并等待响应的代码:
const child_process = require("child_process");
// Start a new node process running the code in child.js in our directory
let child = child_process.fork(`${__dirname}/child.js`);
// Send a message to the child
child.send({x: 4, y: 3});
// Print the child's response when it arrives.
child.on("message", message => {
console.log(message.hypotenuse); // This should print "5"
// Since we only send one message we only expect one response.
// After we receive it we call disconnect() to terminate the connection
// between parent and child. This allows both processes to exit cleanly.
child.disconnect();
});
这里是在子进程中运行的代码:
// Wait for messages from our parent process
process.on("message", message => {
// When we receive one, do a calculation and send the result
// back to the parent.
process.send({hypotenuse: Math.hypot(message.x, message.y)});
});
启动子进程是一个昂贵的操作,子进程必须进行数量级更多的计算才能使用fork()和这种方式的进程间通信才有意义。如果你正在编写一个需要对传入事件非常敏感并且还需要执行耗时计算的程序,那么你可能会考虑使用一个单独的子进程来执行计算,以便它们不会阻塞事件循环并降低父进程的响应性。(尽管在这种情况下,线程—参见§16.11—可能比子进程更好的选择。)
send()的第一个参数将使用JSON.stringify()进行序列化,并在子进程中使用JSON.parse()进行反序列化,因此你应该只包含 JSON 格式支持的值。然而,send()有一个特殊的第二个参数,允许你传输 Socket 和 Server 对象(来自“net”模块)到子进程。网络服务器往往是 IO 绑定的,而不是计算绑定的,但如果你编写了一个需要进行比单个 CPU 处理更多计算的服务器,并且在拥有多个 CPU 的机器上运行该服务器,那么你可以使用fork()创建多个子进程来处理请求。在父进程中,你可能会监听 Server 对象上的“connection”事件,然后从该“connection”事件中获取 Socket 对象,并使用特殊的第二个参数send()到一个子进程中处理。(请注意,这是一个不太常见的情况的不太可能的解决方案。与编写分叉子进程的服务器相比,保持服务器单线程并在生产环境中部署多个实例来处理负载可能更简单。)
16.11 Worker Threads
正如本章开头所解释的,Node 的并发模型是单线程和基于事件的。但在版本 10 及更高版本中,Node 确实允许真正的多线程编程,其 API 与由 Web 浏览器定义的 Web Workers API(§15.13)非常相似。多线程编程以难度大而著称。这几乎完全是因为需要仔细同步线程对共享内存的访问。但 JavaScript 线程(无论是在 Node 还是浏览器中)默认不共享内存,因此使用线程的危险和困难不适用于 JavaScript 中的这些“工作线程”。
JavaScript 的工作线程通过消息传递进行通信,而不是使用共享内存。主线程可以通过调用表示该线程的 Worker 对象的postMessage()方法向工作线程发送消息。工作线程可以通过监听“message”事件来接收来自其父级的消息。工作线程可以通过自己的postMessage()方法向主线程发送消息,父级可以通过自己的“message”事件处理程序接收消息。示例代码将清楚地说明这是如何工作的。
有三个原因可能会让你想在 Node 应用程序中使用工作线程:
-
如果您的应用程序实际上需要进行比一个 CPU 核心处理更多的计算,那么线程可以让您在多个核心之间分配工作,这在今天的计算机上已经很普遍。如果您在 Node 中进行科学计算、机器学习或图形处理,那么您可能希望使用线程来为问题提供更多的计算能力。
-
即使您的应用程序没有充分利用一个 CPU 的全部性能,您可能仍然希望使用线程来保持主线程的响应性。考虑一个处理大型但相对不频繁请求的服务器。假设它每秒只收到一个请求,但需要大约半秒钟的(阻塞 CPU 密集型)计算来处理每个请求。平均而言,它将有 50% 的空闲时间。但当两个请求在几毫秒内同时到达时,服务器甚至无法开始响应第二个请求,直到第一个响应的计算完成。相反,如果服务器使用工作线程执行计算,服务器可以立即开始响应两个请求,并为服务器的客户提供更好的体验。假设服务器有多个 CPU 核心,它还可以并行计算两个响应的主体,但即使只有一个核心,使用工作线程仍然可以提高响应性。
-
一般来说,工作线程允许我们将阻塞的同步操作转换为非阻塞的异步操作。如果您正在编写一个依赖不可避免同步的传统代码的程序,您可以使用工作线程来避免在需要调用该传统代码时阻塞。
工作线程并不像子进程那样沉重,但也不轻量级。通常情况下,除非有大量工作要做,否则创建工作线程是没有意义的。一般来说,如果您的程序既不受 CPU 限制,也没有响应问题,那么您可能不需要工作线程。
16.11.1 创建工作线程并传递消息
定义工作线程的 Node 模块被称为“worker_threads”。在本节中,我们将使用标识符threads来引用它:
const threads = require("worker_threads");
该模块定义了一个 Worker 类来表示一个工作线程,您可以使用threads.Worker()构造函数创建一个新线程。以下代码演示了如何使用此构造函数创建一个工作线程,并展示了如何从主线程向工作线程传递消息,以及从工作线程向主线程传递消息。它还演示了一个技巧,允许您将主线程代码和工作线程代码放在同一个文件中。²
const threads = require("worker_threads");
// The worker_threads module exports the boolean isMainThread property.
// This property is true when Node is running the main thread and it is
// false when Node is running a worker. We can use this fact to implement
// the main and worker threads in the same file.
if (threads.isMainThread) {
// If we're running in the main thread, then all we do is export
// a function. Instead of performing a computationally intensive
// task on the main thread, this function passes the task to a worker
// and returns a Promise that will resolve when the worker is done.
module.exports = function reticulateSplines(splines) {
return new Promise((resolve,reject) => {
// Create a worker that loads and runs this same file of code.
// Note the use of the special __filename variable.
let reticulator = new threads.Worker(__filename);
// Pass a copy of the splines array to the worker
reticulator.postMessage(splines);
// And then resolve or reject the Promise when we get
// a message or error from the worker.
reticulator.on("message", resolve);
reticulator.on("error", reject);
});
};
} else {
// If we get here, it means we're in the worker, so we register a
// handler to get messages from the main thread. This worker is designed
// to only receive a single message, so we register the event handler
// with once() instead of on(). This allows the worker to exit naturally
// when its work is complete.
threads.parentPort.once("message", splines => {
// When we get the splines from the parent thread, loop
// through them and reticulate all of them.
for(let spline of splines) {
// For the sake of example, assume that spline objects usually
// have a reticulate() method that does a lot of computation.
spline.reticulate ? spline.reticulate() : spline.reticulated = true;
}
// When all the splines have (finally!) been reticulated
// pass a copy back to the main thread.
threads.parentPort.postMessage(splines);
});
}
Worker() 构造函数的第一个参数是要在线程中运行的 JavaScript 代码文件的路径。在上面的代码中,我们使用预定义的 __filename 标识符创建一个加载和运行与主线程相同文件的工作线程。不过,一般来说,你会传递一个文件路径。请注意,如果指定相对路径,则相对于 process.cwd(),而不是相对于当前运行的模块。如果你想要一个相对于当前模块的路径,可以使用类似 path.resolve(__dirname, 'workers/reticulator.js') 的方式。
Worker() 构造函数还可以接受一个对象作为其第二个参数,该对象的属性为工作线程提供可选配置。我们稍后会介绍其中一些选项,但现在请注意,如果将 {eval: true} 作为第二个参数传递,那么 Worker() 的第一个参数将被解释为要评估的 JavaScript 代码字符串,而不是文件名:
new threads.Worker(`
const threads = require("worker_threads");
threads.parentPort.postMessage(threads.isMainThread);
`, {eval: true}).on("message", console.log); // This will print "false"
Node 在传递给 postMessage() 的对象上创建一个副本,而不是直接与工作线程共享。这样可以防止工作线程和主线程共享内存。你可能会期望这种复制是通过 JSON.stringify() 和 JSON.parse()(§11.6)来完成的。但事实上,Node 借用了一种更强大的技术,即从 Web 浏览器中知名的结构化克隆算法。
结构化克隆算法可以序列化大多数 JavaScript 类型,包括 Map、Set、Date 和 RegExp 对象以及类型化数组,但通常无法复制由 Node 主机环境定义的类型,如套接字和流。然而,需要注意的是,Buffer 对象部分支持:如果你将一个 Buffer 传递给 postMessage(),它将被接收为 Uint8Array,并且可以使用 Buffer.from() 转换回 Buffer。在 “结构化克隆算法” 中了解更多关于结构化克隆算法的信息。
16.11.2 工作线程执行环境
在大多数情况下,Node 中的工作线程中的 JavaScript 代码运行方式与在 Node 的主线程中一样。有一些差异需要注意,其中一些差异涉及到 Worker() 构造函数的可选第二个参数的属性:
-
正如我们所见,
threads.isMainThread在主线程中为true,但在任何工作线程中始终为false。 -
在工作线程中,你可以使用
threads.parentPort.postMessage()向父线程发送消息,使用threads.parentPort.on注册来自父线程的消息的事件处理程序。在主线程中,threads.parentPort始终为null。 -
在工作线程中,
threads.workerData被设置为Worker()构造函数的第二个参数的workerData属性的副本。在主线程中,此属性始终为null。你可以使用这个workerData属性向工作线程传递一个初始消息,该消息将在工作线程启动后立即可用,这样工作线程就不必等待“message”事件才能开始工作。 -
默认情况下,在工作线程中,
process.env是父线程中process.env的副本。但父线程可以通过设置Worker()构造函数的第二个参数的env属性来指定一组自定义的环境变量。作为一个特殊(可能危险)的情况,父线程可以将env属性设置为threads.SHARE_ENV,这将导致两个线程共享一组环境变量,以便一个线程中的更改在另一个线程中可见。 -
默认情况下,在工作线程中,
process.stdin流永远没有可读数据。你可以通过在Worker()构造函数的第二个参数中传递stdin: true来更改此默认行为。如果这样做,那么 Worker 对象的stdin属性将是一个可写流。父进程写入worker.stdin的任何数据在工作线程中的process.stdin上变为可读。 -
默认情况下,工作线程中的
process.stdout和process.stderr流会简单地传输到父线程中对应的流。这意味着,例如,console.log()和console.error()在工作线程中的输出方式与主线程中完全相同。你可以通过在Worker()构造函数的第二个参数中传递stdout:true或stderr:true来覆盖此默认行为。如果这样做,那么工作线程写入这些流的任何输出都可以在父线程的worker.stdout和worker.stderr流中读取到。(这里存在一个潜在的令人困惑的流方向倒置,我们在本章前面的子进程中也看到了相同的情况:工作线程的输出流是父线程的输入流,工作线程的输入流是父线程的输出流。) -
如果工作线程调用
process.exit(),只有该线程退出,整个进程不会退出。 -
工作线程不允许更改它们所属进程的共享状态。当从工作线程调用
process.chdir()和process.setuid()等函数时,会抛出异常。 -
操作系统信号(如
SIGINT和SIGTERM)只会传递给主线程;它们无法在工作线程中接收或处理。
16.11.3 通信通道和 MessagePorts
创建新的工作线程时,会同时创建一个通信通道,允许工作线程和父线程之间传递消息。正如我们所见,工作线程使用threads.parentPort与父线程发送和接收消息,父线程使用 Worker 对象与工作线程发送和接收消息。
工作线程 API 还允许使用由 Web 浏览器定义并在 §15.13.5 中介绍的 MessageChannel API 创建自定义通信通道。如果你已经阅读了该部分,接下来的内容会让你感到很熟悉。
假设一个工作线程需要处理主线程中两个不同模块发送的两种不同消息。这两个不同模块可以共享默认通道,并使用worker.postMessage()发送消息,但如果每个模块都有自己的私有通道向工作线程发送消息会更清晰。或者考虑主线程创建两个独立工作线程的情况。自定义通信通道可以让这两个工作线程直接相互通信,而不必通过父线程发送所有消息。
使用MessageChannel()构造函数创建一个新的消息通道。一个 MessageChannel 对象有两个属性,名为port1和port2。这些属性指向一对 MessagePort 对象。在其中一个端口上调用postMessage()将导致另一个端口生成“message”事件,并携带 Message 对象的结构化克隆:
const threads = require("worker_threads");
let channel = new threads.MessageChannel();
channel.port2.on("message", console.log); // Log any messages we receive
channel.port1.postMessage("hello"); // Will cause "hello" to be printed
你也可以在任一端口上调用close()来断开两个端口之间的连接,并表示不会再交换更多消息。当任一端口上调用close()时,将向两个端口传递“close”事件。
注意,上面的代码示例创建了一对 MessagePort 对象,然后使用这些对象在主线程内传输消息。为了在工作线程中使用自定义通信通道,我们必须将两个端口中的一个从创建它的线程传输到将要使用它的线程。下一节将解释如何做到这一点。
16.11.4 传输 MessagePorts 和 Typed Arrays
postMessage() 函数使用结构化克隆算法,正如我们所指出的,它不能复制像 SSockets 和 Streams 这样的对象。它可以处理 MessagePort 对象,但只能使用一种特殊技术作为特例。postMessage() 方法(Worker 对象的方法,threads.parentPort 的方法,或任何 MessagePort 对象的方法)接受一个可选的第二个参数。这个参数(称为 transferList)是一个要在线程之间传输而不是复制的对象数组。
MessagePort 对象不能被结构化克隆算法复制,但可以被传输。如果 postMessage() 的第一个参数包含了一个或多个 MessagePorts(在 Message 对象中任意深度嵌套),那么这些 MessagePort 对象也必须作为第二个参数传递的数组的成员出现。这样做告诉 Node 不需要复制 MessagePort,并且可以直接将现有对象交给另一个线程。然而,关于在线程之间传输值的关键是,一旦值被传输,它就不能再在调用 postMessage() 的线程中使用。
下面是如何创建一个新的 MessageChannel 并将其中一个 MessagePort 传输给工作线程的方法:
// Create a custom communication channel
const threads = require("worker_threads");
let channel = new threads.MessageChannel();
// Use the worker's default channel to transfer one end of the new
// channel to the worker. Assume that when the worker receives this
// message it immediately begins to listen for messages on the new channel.
worker.postMessage({ command: "changeChannel", data: channel.port1 },
[ channel.port1 ]);
// Now send a message to the worker using our end of the custom channel
channel.port2.postMessage("Can you hear me now?");
// And listen for responses from the worker as well
channel.port2.on("message", handleMessagesFromWorker);
MessagePort 对象并不是唯一可以传输的对象。如果你使用一个类型化数组作为消息调用 postMessage()(或者消息中包含一个或多个任意深度嵌套的类型化数组),那么这个类型化数组(或这些类型化数组)将会被结构化克隆算法简单地复制。但是类型化数组可能很大;例如,如果你正在使用一个工作线程对数百万像素进行图像处理。因此,为了效率起见,postMessage() 还给了我们传输类型化数组而不是复制它们的选项。(线程默认共享内存。JavaScript 中的工作线程通常避免共享内存,但当我们允许这种受控传输时,可以非常高效地完成。)这种安全性的保证在于,当一个类型化数组被传输到另一个线程时,它在传输它的线程中将变得无法使用。在图像处理场景中,主线程可以将图像的像素传输给工作线程,然后工作线程在完成后可以将处理后的像素传回主线程。内存不需要被复制,但永远不会被两个线程同时访问。
要传输一个类型化数组而不是复制它,将支持数组的 ArrayBuffer 包含在 postMessage() 的第二个参数中:
let pixels = new Uint32Array(1024*1024); // 4 megabytes of memory
// Assume we read some data into this typed array, and then transfer the
// pixels to a worker without copying. Note that we don't put the array
// itself in the transfer list, but the array's Buffer object instead.
worker.postMessage(pixels, [ pixels.buffer ]);
与传输的 MessagePort 一样,一旦传输了一个类型化数组,它就变得无法使用。如果尝试使用已经传输的 MessagePort 或类型化数组,不会抛出异常;当与它们交互时,这些对象只是停止执行任何操作。
16.11.5 在线程之间共享类型化数组
除了在线程之间传输类型化数组,实际上还可以在线程之间共享类型化数组。只需创建一个所需大小的 SharedArrayBuffer,然后使用该缓冲区创建一个类型化数组。当通过 postMessage() 传递由 SharedArrayBuffer 支持的类型化数组时,底层内存将在线程之间共享。在这种情况下,不应该将共享缓冲区包含在 postMessage() 的第二个参数中。
然而,你真的不应该这样做,因为 JavaScript 从未考虑过线程安全,并且多线程编程非常难以正确实现。(这也是为什么 SharedArrayBuffer 没有在 §11.2 中涵盖的原因:它是一个难以正确实现的小众功能。)即使简单的 ++ 运算符也不是线程安全的,因为它需要读取一个值,递增它,然后写回。如果两个线程同时递增一个值,它通常只会被递增一次,如下面的代码所示:
const threads = require("worker_threads");
if (threads.isMainThread) {
// In the main thread, we create a shared typed array with
// one element. Both threads will be able to read and write
// sharedArray[0] at the same time.
let sharedBuffer = new SharedArrayBuffer(4);
let sharedArray = new Int32Array(sharedBuffer);
// Now create a worker thread, passing the shared array to it with
// as its initial workerData value so we don't have to bother with
// sending and receiving a message
let worker = new threads.Worker(__filename, { workerData: sharedArray });
// Wait for the worker to start running and then increment the
// shared integer 10 million times.
worker.on("online", () => {
for(let i = 0; i < 10_000_000; i++) sharedArray[0]++;
// Once we're done with our increments, we start listening for
// message events so we know when the worker is done.
worker.on("message", () => {
// Although the shared integer has been incremented
// 20 million times, its value will generally be much less.
// On my computer the final value is typically under 12 million.
console.log(sharedArray[0]);
});
});
} else {
// In the worker thread, we get the shared array from workerData
// and then increment it 10 million times.
let sharedArray = threads.workerData;
for(let i = 0; i < 10_000_000; i++) sharedArray[0]++;
// When we're done incrementing, let the main thread know
threads.parentPort.postMessage("done");
}
有一种情况下可能合理使用 SharedArrayBuffer,即当两个线程在共享内存的完全不同部分上操作时。你可以通过创建两个作为非重叠区域视图的类型化数组来强制执行这一点,然后让你的两个线程使用这两个单独的类型化数组。例如,可以这样执行并行归并排序:一个线程对数组的下半部分进行排序,另一个线程对数组的上半部分进行排序。或者某些类型的图像处理算法也适合这种方法:多个线程在图像的不同区域上工作。
如果你确实需要允许多个线程访问共享数组的同一区域,你可以通过使用 Atomics 对象定义的函数向线程安全迈出一步。当 SharedArrayBuffer 添加到 JavaScript 时,Atomics 也被添加以定义共享数组元素上的原子操作。例如,Atomics.add()函数读取共享数组的指定元素,将指定值添加到其中,并将总和写回数组。它以原子方式执行此操作,就好像它是一个单独的操作,并确保在操作进行时没有其他线程可以读取或写入该值。Atomics.add()允许我们重新编写我们刚刚查看的并获得正确结果的并行增量代码,即对共享数组元素进行 2000 万次增量:
const threads = require("worker_threads");
if (threads.isMainThread) {
let sharedBuffer = new SharedArrayBuffer(4);
let sharedArray = new Int32Array(sharedBuffer);
let worker = new threads.Worker(__filename, { workerData: sharedArray });
worker.on("online", () => {
for(let i = 0; i < 10_000_000; i++) {
Atomics.add(sharedArray, 0, 1); // Threadsafe atomic increment
}
worker.on("message", (message) => {
// When both threads are done, use a threadsafe function
// to read the shared array and confirm that it has the
// expected value of 20,000,000.
console.log(Atomics.load(sharedArray, 0));
});
});
} else {
let sharedArray = threads.workerData;
for(let i = 0; i < 10_000_000; i++) {
Atomics.add(sharedArray, 0, 1); // Threadsafe atomic increment
}
threads.parentPort.postMessage("done");
}
这个新版本的代码正确地打印出数字 20,000,000。但它比它替换的不正确代码慢大约九倍。在一个线程中执行所有 2000 万次增量会更简单、更快。还要注意,原子操作可能能够确保图像处理算法的线程安全,其中每个数组元素都是完全独立于所有其他值的值。但在大多数实际程序中,多个数组元素通常彼此相关,并且需要某种高级别的线程同步。低级别的Atomics.wait()和Atomics.notify()函数可以帮助解决这个问题,但本书不涉及它们的使用讨论。
16.12 总结
尽管 JavaScript 是为在 Web 浏览器中运行而创建的,但 Node 已经将 JavaScript 变成了一种通用编程语言。它特别受欢迎用于实现 Web 服务器,但它与操作系统的深层绑定意味着它也是 shell 脚本的一个很好的替代品。
这一长章节涵盖的最重要主题包括:
-
Node 的默认异步 API 和其单线程、回调和基于事件的并发风格。
-
Node 的基本数据类型、缓冲区和流。
-
Node 的“fs”和“path”模块用于处理文件系统。
-
Node 的“http”和“https”模块用于编写 HTTP 客户端和服务器。
-
Node 的“net”模块用于编写非 HTTP 客户端和服务器。
-
Node 的“child_process”模块用于创建和与子进程通信。
-
Node 的“worker_threads”模块用于使用消息传递而不是共享内存进行真正的多线程编程。
¹ Node 定义了一个fs.copyFile()函数,实际上你会在实践中使用它。
² 将工作代码定义在一个单独的文件中通常更清晰、更简单。但当我第一次遇到 Unix 的fork()系统调用时,两个线程运行同一文件的不同部分的技巧让我大吃一惊。我认为值得演示这种技术,仅仅因为它的奇怪优雅。
第十七章:JavaScript 工具和扩展
恭喜您达到本书的最后一章。如果您已经阅读了前面的所有内容,现在您对 JavaScript 语言有了详细的了解,并知道如何在 Node 和 Web 浏览器中使用它。本章是一种毕业礼物:它介绍了许多 JavaScript 程序员发现有用的重要编程工具,并描述了核心 JavaScript 语言的两个广泛使用的扩展。无论您是否选择为自己的项目使用这些工具和扩展,您几乎肯定会在其他项目中看到它们的使用,因此至少了解它们是很重要的。
本章涵盖的工具和语言扩展包括:
-
ESLint 用于在代码中查找潜在的错误和样式问题。
-
使用 Prettier 以标准化方式格式化您的 JavaScript 代码。
-
Jest 作为编写 JavaScript 单元测试的一体化解决方案。
-
npm 用于管理和安装程序依赖的软件库。
-
代码捆绑工具——如 webpack、Rollup 和 Parcel——将您的 JavaScript 代码模块转换为用于 Web 的单个捆绑包。
-
Babel 用于将使用全新语言特性(或语言扩展)的 JavaScript 代码转换为可以在当前 Web 浏览器中运行的 JavaScript 代码。
-
JSX 语言扩展(由 React 框架使用)允许您使用类似 HTML 标记的 JavaScript 表达式描述用户界面。
-
Flow 语言扩展(或类似的 TypeScript 扩展)允许您使用类型注释注释您的 JavaScript 代码,并检查代码是否具有类型安全性。
本章不会以任何全面的方式记录这些工具和扩展。目标只是以足够深度解释它们,以便您了解它们为何有用以及何时可能需要使用它们。本章涵盖的所有内容在 JavaScript 编程世界中被广泛使用,如果您决定采用工具或扩展,您会在网上找到大量文档和教程。
17.1 使用 ESLint 进行 Linting
在编程中,术语lint指的是在技术上正确但不雅观、可能存在错误或以某种方式不够优化的代码。linter是一种用于检测代码中 lint 的工具,linting是在代码上运行 linter 的过程(然后修复代码以消除 lint,使 linter 不再抱怨)。
今天 JavaScript 最常用的 linter 是ESLint。如果您运行它,然后花时间实际修复它指出的问题,它将使您的代码更清洁,更不容易出现错误。考虑以下代码:
var x = 'unused';
export function factorial(x) {
if (x == 1) {
return 1;
} else {
return x * factorial(x-1)
}
}
如果您在此代码上运行 ESLint,可能会得到如下输出:
$ eslint code/ch17/linty.js
code/ch17/linty.js
1:1 error Unexpected var, use let or const instead no-var
1:5 error 'x' is assigned a value but never used no-unused-vars
1:9 warning Strings must use doublequote quotes
4:11 error Expected '===' and instead saw '==' eqeqeq
5:1 error Expected indentation of 8 spaces but found 6 indent
7:28 error Missing semicolon semi
✖ 6 problems (5 errors, 1 warning)
3 errors and 1 warning potentially fixable with the `--fix` option.
有时 linter 可能看起来很挑剔。我们是使用双引号还是单引号真的很重要吗?另一方面,正确的缩进对于可读性很重要,使用===和let而不是==和var可以保护您免受微妙错误的影响。未使用的变量是代码中的累赘——没有理由保留它们。
ESLint 定义了许多 linting 规则,并具有添加许多其他规则的插件生态系统。但 ESLint 是完全可配置的,您可以定义一个配置文件来调整 ESLint 以强制执行您想要的规则,仅限于这些规则。
17.2 使用 Prettier 进行 JavaScript 格式化
一些项目使用 linter 的原因之一是强制执行一致的编码风格,以便当程序员团队共同工作在共享的代码库上时,他们使用兼容的代码约定。这包括代码缩进规则,但也可以包括诸如首选引号类型以及for关键字和其后的开括号之间是否应该有空格等内容。
强制代码格式规则的现代替代方法是采用类似 Prettier 的工具,自动解析和重新格式化所有代码。
假设你已经编写了以下函数,它可以工作,但格式不太规范:
function factorial(x)
{
if(x===1){return 1}
else{return x*factorial(x-1)}
}
运行 Prettier 对这段代码进行了缩进修复,添加了缺失的分号,围绕二进制运算符添加了空格,并在 { 之后和 } 之前插入了换行符,使代码看起来更加传统:
$ prettier factorial.js
function factorial(x) {
if (x === 1) {
return 1;
} else {
return x * factorial(x - 1);
}
}
如果你使用 --write 选项调用 Prettier,它将简单地在原地重新格式化指定的文件,而不是打印重新格式化的版本。如果你使用 git 管理你的源代码,你可以在提交钩子中使用 --write 选项调用 Prettier,这样代码就会在检入之前自动格式化。
如果你配置你的代码编辑器在每次保存文件时自动运行 Prettier,Prettier 就会变得非常强大。我觉得写松散的代码然后看到它被自动修复很解放。
Prettier 是可配置的,但只有少数选项。你可以选择最大行长度、缩进量、是否应该使用分号、字符串是单引号还是双引号,以及其他一些内容。一般来说,Prettier 的默认选项是相当合理的。其思想是你只需为你的项目采用 Prettier,然后就再也不用考虑代码格式了。
就我个人而言,我非常喜欢在 JavaScript 项目中使用 Prettier。然而,在这本书中的代码中我没有使用它,因为在我的许多代码中,我依赖仔细的手动格式化来垂直对齐我的注释,而 Prettier 会搞乱它们。
17.3 使用 Jest 进行单元测试
写测试是任何非平凡编程项目的重要部分。像 JavaScript 这样的动态语言支持测试框架,大大减少了编写测试所需的工作量,几乎让测试编写变得有趣!JavaScript 有很多测试工具和库,许多都是以模块化的方式编写的,因此可以选择一个库作为测试运行器,另一个库用于断言,第三个库用于模拟。然而,在本节中,我们将描述 Jest ,这是一个流行的框架,包含了你在一个单一包中所需的一切。
假设你已经编写了以下函数:
const getJSON = require("./getJSON.js");
/**
* getTemperature() takes the name of a city as its input, and returns
* a Promise that will resolve to the current temperature of that city,
* in degrees Fahrenheit. It relies on a (fake) web service that returns
* world temperatures in degrees Celsius.
*/
module.exports = async function getTemperature(city) {
// Get the temperature in Celsius from the web service
let c = await getJSON(
`https://globaltemps.example.com/api/city/${city.toLowerCase()}`
);
// Convert to Fahrenheit and return that value.
return (c * 5 / 9) + 32; // TODO: double-check this formula
};
对于这个函数的一个很好的测试集可能会验证 getTemperature() 是否获取了正确的 URL,并且是否正确地转换了温度标度。我们可以使用类似下面的基于 Jest 的测试来做到这一点。这段代码定义了 getJSON() 的模拟实现,以便测试实际上不会发出网络请求。由于 getTemperature() 是一个异步函数,所以测试也是异步的——测试异步函数可能有些棘手,但 Jest 让它相对容易:
// Import the function we are going to test
const getTemperature = require("./getTemperature.js");
// And mock the getJSON() module that getTemperature() depends on
jest.mock("./getJSON");
const getJSON = require("./getJSON.js");
// Tell the mock getJSON() function to return an already resolved Promise
// with fulfillment value 0.
getJSON.mockResolvedValue(0);
// Our set of tests for getTemperature() begins here
describe("getTemperature()", () => {
// This is the first test. We're ensuring that getTemperature() calls
// getJSON() with the URL that we expect
test("Invokes the correct API", async () => {
let expectedURL = "https://globaltemps.example.com/api/city/vancouver";
let t = await(getTemperature("Vancouver"));
// Jest mocks remember how they were called, and we can check that.
expect(getJSON).toHaveBeenCalledWith(expectedURL);
});
// This second test verifies that getTemperature() converts
// Celsius to Fahrenheit correctly
test("Converts C to F correctly", async () => {
getJSON.mockResolvedValue(0); // If getJSON returns 0C
expect(await getTemperature("x")).toBe(32); // We expect 32F
// 100C should convert to 212F
getJSON.mockResolvedValue(100); // If getJSON returns 100C
expect(await getTemperature("x")).toBe(212); // We expect 212F
});
});
写好测试后,我们可以使用 jest 命令来运行它,然后我们发现我们的一个测试失败了:
$ jest getTemperature
FAIL ch17/getTemperature.test.js
getTemperature()
✓ Invokes the correct API (4ms)
✕ Converts C to F correctly (3ms)
● getTemperature() › Converts C to F correctly
expect(received).toBe(expected) // Object.is equality
Expected: 212
Received: 87.55555555555556
29 | // 100C should convert to 212F
30 | getJSON.mockResolvedValue(100); // If getJSON returns 100C
> 31 | expect(await getTemperature("x")).toBe(212); // Expect 212F
| ^
32 | });
33 | });
34 |
at Object.<anonymous> (ch17/getTemperature.test.js:31:43)
Test Suites: 1 failed, 1 total
Tests: 1 failed, 1 passed, 2 total
Snapshots: 0 total
Time: 1.403s
Ran all test suites matching /getTemperature/i.
我们的 getTemperature() 实现使用了错误的公式将摄氏度转换为华氏度。它将乘以 5 再除以 9,而不是乘以 9 再除以 5。如果我们修复代码并再次运行 Jest,我们可以看到测试通过。而且,作为一个奖励,如果我们在调用 jest 时添加 --coverage 参数,它将计算并显示我们测试的代码覆盖率:
$ jest --coverage getTemperature
PASS ch17/getTemperature.test.js
getTemperature()
✓ Invokes the correct API (3ms)
✓ Converts C to F correctly (1ms)
------------------|--------|---------|---------|---------|------------------|
File | % Stmts| % Branch| % Funcs| % Lines| Uncovered Line #s|
------------------|--------|---------|---------|---------|------------------|
All files | 71.43| 100| 33.33| 83.33| |
getJSON.js | 33.33| 100| 0| 50| 2|
getTemperature.js| 100| 100| 100| 100| |
------------------|--------|---------|---------|---------|------------------|
Test Suites: 1 passed, 1 total
Tests: 2 passed, 2 total
Snapshots: 0 total
Time: 1.508s
Ran all test suites matching /getTemperature/i.
运行我们的测试为我们正在测试的模块提供了 100% 的代码覆盖率,这正是我们想要的。它只为 getJSON() 提供了部分覆盖,但我们对该模块进行了模拟,并且并不打算测试它,所以这是预期的。
17.4 使用 npm 进行包管理
在现代软件开发中,编写的任何非平凡程序都可能依赖于第三方软件库。例如,如果您在 Node 中编写 Web 服务器,可能会使用 Express 框架。如果您正在创建要在 Web 浏览器中显示的用户界面,则可能会使用像 React、LitElement 或 Angular 这样的前端框架。包管理器使查找和安装这些第三方包变得容易。同样重要的是,包管理器会跟踪代码所依赖的包,并将此信息保存到文件中,以便其他人想要尝试您的程序时,他们可以下载您的代码和依赖项列表,然后使用自己的包管理器安装代码所需的所有第三方包。
npm 是随 Node 捆绑的包管理器,并在§16.1.5 中介绍。它对于客户端 JavaScript 编程和 Node 服务器端编程同样有用。
如果您尝试其他人的 JavaScript 项目,那么在下载他们的代码后,您通常会首先执行npm install。这会读取package.json文件中列出的依赖项,并下载项目需要的第三方包并将其保存在node_modules/目录中。
您还可以输入npm install <package-name>来将特定包安装到项目的node_modules/目录中:
$ npm install express
除了安装命名的包外,npm 还会在项目的package.json文件中记录依赖关系。以这种方式记录依赖关系是让其他人通过输入npm install来安装这些依赖关系的原因。
另一种依赖关系是开发人员工具的依赖,这些工具是开发人员想要在项目上工作时需要的,但实际上不需要运行代码。例如,如果项目使用 Prettier 来确保所有代码格式一致,那么 Prettier 就是一个“dev dependency”,您可以使用--save-dev来安装和记录其中之一:
$ npm install --save-dev prettier
有时您可能希望全局安装开发工具,以便它们可以在任何地方访问,即使不是正式项目的代码也可以使用package.json文件和node_modules/目录。为此,您可以使用-g(全局)选项:
$ npm install -g eslint jest
/usr/local/bin/eslint -> /usr/local/lib/node_modules/eslint/bin/eslint.js
/usr/local/bin/jest -> /usr/local/lib/node_modules/jest/bin/jest.js
+ jest@24.9.0
+ eslint@6.7.2
added 653 packages from 414 contributors in 25.596s
$ which eslint
/usr/local/bin/eslint
$ which jest
/usr/local/bin/jest
除了“install”命令,npm 还支持“uninstall”和“update”命令,其功能如其名称所示。npm 还有一个有趣的“audit”命令,您可以使用它来查找并修复依赖项中的安全漏洞:
$ npm audit --fix
=== npm audit security report ===
found 0 vulnerabilities
in 876354 scanned packages
当您为项目本地安装类似 ESLint 这样的工具时,eslint 脚本会出现在./node_modules/.bin/eslint中,这使得运行命令变得笨拙。幸运的是,npm 捆绑了一个名为“npx”的命令,您可以使用它来运行本地安装的工具,如npx eslint或npx jest。(如果您使用 npx 调用尚未安装的工具,它将为您安装它。)
npm 背后的公司还维护着https://npmjs.com包仓库,其中包含数十万个开源包。但您不必使用 npm 包管理器来访问这个包仓库。替代方案包括yarn和pnpm。
17.5 代码捆绑
如果您正在编写一个大型的 JavaScript 程序以在网络浏览器中运行,那么您可能需要使用一个代码捆绑工具,特别是如果您使用作为模块交付的外部库。多年来,网络开发人员一直在使用 ES6 模块(§10.3),早在网络上支持import和export关键字之前。为了做到这一点,程序员使用一个代码捆绑工具,从程序的主入口点(或入口点)开始,并遵循import指令的树,以找到程序所依赖的所有模块。然后,它将所有这些单独的模块文件组合成一个 JavaScript 代码的单个捆绑包,并重写import和export指令,使代码在这种新形式下工作。结果是一个单个的代码文件,可以加载到不支持模块的网络浏览器中。
ES6 模块现在几乎被所有的网络浏览器支持,但网络开发人员在发布生产代码时仍倾向于使用代码捆绑工具。开发人员发现,当用户首次访问网站时,加载一个中等大小的代码捆绑包比加载许多小模块时用户体验更好。
注意
网络性能是一个众所周知的棘手话题,有很多要考虑的变量,包括浏览器供应商的持续改进,因此确保加载代码的最快方式的唯一方法是进行彻底的测试和仔细的测量。请记住,有一个完全在您控制之下的变量:代码大小。较少的 JavaScript 代码总是比较多的 JavaScript 代码加载和运行更快!
有许多优秀的 JavaScript 捆绑工具可供选择。常用的捆绑工具包括webpack、Rollup和Parcel。捆绑工具的基本功能大致相同,它们的区别在于可配置性或易用性。Webpack 已经存在很长时间,拥有庞大的插件生态系统,可高度配置,并且可以支持旧的非模块化库。但它也可能复杂且难以配置。另一端是 Parcel,它被设计为一个零配置的替代方案,只需简单地做正确的事情。
除了执行基本的捆绑外,捆绑工具还可以提供一些附加功能:
-
一些程序可能有多个入口点。例如,一个具有多个页面的网络应用程序可以为每个页面编写不同的入口点。打包工具通常允许您为每个入口点创建一个捆绑包,或者创建一个支持多个入口点的单个捆绑包。
-
程序可以使用
import()的功能形式(§10.3.6),而不是静态形式,在实际需要时动态加载模块,而不是在程序启动时静态加载它们。这通常是改善程序启动时间的好方法。支持import()的捆绑工具可能能够生成多个输出捆绑包:一个在启动时加载,一个或多个在需要时动态加载。如果动态加载的模块共享依赖关系,那么确定要生成多少个捆绑包就变得棘手了,您可能需要手动配置捆绑工具来解决这个问题。 -
捆绑工具通常可以输出一个源映射文件,定义了捆绑包中代码行与原始源文件中对应行之间的映射关系。这使得浏览器开发工具可以自动显示 JavaScript 错误的原始未捆绑位置。
-
有时当你将一个模块导入到你的程序中时,你可能只使用其中的一部分功能。一个好的打包工具可以分析代码以确定哪些部分是未使用的,可以从捆绑包中省略。这个功能被戏称为“tree-shaking”。
-
打包工具通常具有基于插件的架构,并支持插件,允许导入和捆绑实际上不是 JavaScript 代码文件的“模块”。假设你的程序包含一个大型的 JSON 兼容数据结构。代码打包工具可以配置允许你将该数据结构移动到一个单独的 JSON 文件中,然后通过类似
import widgets from "./big-widget-list.json"的声明将其导入到你的程序中。同样,将 CSS 嵌入到 JavaScript 程序中的 web 开发人员可以使用打包工具插件,允许他们使用import指令导入 CSS 文件。但是请注意,如果导入的不是 JavaScript 文件,你正在使用一个非标准的 JavaScript 扩展,并使你的代码依赖于打包工具。 -
在像 JavaScript 这样不需要编译的语言中,运行一个打包工具感觉像是一个编译步骤,每次在运行代码之前都必须运行一个打包工具,这让人感到沮丧。打包工具通常支持文件系统监视器,检测项目目录中任何文件的编辑,并自动重新生成必要的捆绑包。有了这个功能,你通常可以保存你的代码,然后立即重新加载你的 web 浏览器窗口以尝试它。
-
一些打包工具还支持开发人员的“热模块替换”模式,每次重新生成捆绑包时,它会自动加载到浏览器中。当这个功能起作用时,对开发人员来说是一种神奇的体验,但在幕后进行了一些技巧使其工作,它并不适用于所有项目。
17.6 使用 Babel 进行转译
Babel 是一个工具,将使用现代语言特性编写的 JavaScript 编译成不使用这些现代语言特性的 JavaScript。由于它将 JavaScript 编译成 JavaScript,因此有时称为“转译器”。Babel 的创建是为了让 web 开发人员能够使用 ES6 及更高版本的新语言特性,同时仍针对只支持 ES5 的 web 浏览器。
诸如**指数运算符和箭头函数之类的语言特性可以相对容易地转换为Math.pow()和function表达式。其他语言特性,如class关键字,需要进行更复杂的转换,而且一般来说,Babel 输出的代码并不是为了人类可读性。然而,像打包工具一样,Babel 可以生成源映射,将转换后的代码位置映射回原始源位置,这在处理转换后的代码时非常有帮助。
浏览器供应商正在更好地跟上 JavaScript 语言的演变,今天几乎不需要编译掉箭头函数和类声明。当你想要使用最新功能,如数字文字中的下划线分隔符时,Babel 仍然可以帮助。
像本章描述的大多数其他工具一样,你可以使用 npm 安装 Babel,并使用 npx 运行它。Babel 读取一个 .babelrc 配置文件,告诉它如何转换你的 JavaScript 代码。Babel 定义了“预设”,你可以根据想要使用的语言扩展和你想要多么积极地转换标准语言特性来选择。Babel 的一个有趣的预设是用于通过缩小来进行代码压缩(去除注释和空格,重命名变量等)。
如果你使用 Babel 和一个代码捆绑工具,你可以设置代码捆绑器在构建捆绑包时自动运行 Babel 来处理你的 JavaScript 文件。如果是这样,这可能是一个方便的选项,因为它简化了生成可运行代码的过程。例如,Webpack 支持一个“babel-loader”模块,你可以安装并配置它在捆绑时运行 Babel 来处理每个 JavaScript 模块。
即使今天对核心 JavaScript 语言的转换需求较少,Babel 仍然常用于支持语言的非标准扩展,我们将在接下来的章节中描述其中的两个语言扩展。
17.7 JSX:JavaScript 中的标记表达式
JSX 是核心 JavaScript 的扩展,使用类似 HTML 的语法来定义元素树。JSX 与 React 框架最为密切相关,用于 Web 上的用户界面。在 React 中,使用 JSX 定义的元素树最终会被渲染成 HTML 在 Web 浏览器中。即使你没有计划自己使用 React,但由于其流行,你可能会看到使用 JSX 的代码。本节将解释你需要了解的内容以理解它。(本节关于 JSX 语言扩展,不是关于 React,仅解释 React 的部分内容以提供 JSX 语法的上下文。)
你可以将 JSX 元素视为一种新类型的 JavaScript 表达式语法。JavaScript 字符串字面量用引号界定,正则表达式字面量用斜杠界定。同样,JSX 表达式字面量用尖括号界定。这是一个非常简单的例子:
let line = <hr/>;
如果你使用 JSX,你将需要使用 Babel(或类似工具)将 JSX 表达式编译成常规 JavaScript。转换足够简单,以至于一些开发人员选择在不使用 JSX 的情况下使用 React。Babel 将此赋值语句中的 JSX 表达式转换为简单的函数调用:
let line = React.createElement("hr", null);
JSX 语法类似 HTML,并且像 HTML 元素一样,React 元素可以具有以下属性:
let image = <img src="logo.png" alt="The JSX logo" hidden/>;
当一个元素有一个或多个属性时,它们成为传递给createElement()的第二个参数的对象的属性:
let image = React.createElement("img", {
src: "logo.png",
alt: "The JSX logo",
hidden: true
});
像 HTML 元素一样,JSX 元素可以具有字符串和其他元素作为子元素。就像 JavaScript 的算术运算符可以用于编写任意复杂度的算术表达式一样,JSX 元素也可以任意深度地嵌套以创建元素树:
let sidebar = (
<div className="sidebar">
<h1>Title</h1>
<hr/>
<p>This is the sidebar content</p>
</div>
);
常规 JavaScript 函数调用表达式也可以任意深度地嵌套,这些嵌套的 JSX 表达式转换为一组嵌套的createElement()调用。当一个 JSX 元素有子元素时,这些子元素(通常是字符串和其他 JSX 元素)作为第三个及后续参数传递:
let sidebar = React.createElement(
"div", { className: "sidebar"}, // This outer call creates a <div>
React.createElement("h1", null, // This is the first child of the <div/>
"Title"), // and its own first child.
React.createElement("hr", null), // The second child of the <div/>.
React.createElement("p", null, // And the third child.
"This is the sidebar content"));
React.createElement()返回的值是 React 用于在浏览器窗口中呈现输出的普通 JavaScript 对象。由于本节是关于 JSX 语法而不是关于 React,我们不会详细介绍返回的元素对象或呈现过程。值得注意的是,你可以配置 Babel 将 JSX 元素编译为调用不同函数的调用,因此如果你认为 JSX 语法是表达其他类型嵌套数据结构的有用方式,你可以将其用于自己的非 React 用途。
JSX 语法的一个重要特点是你可以在 JSX 表达式中嵌入常规 JavaScript 表达式。在 JSX 表达式中,花括号内的文本被解释为普通 JavaScript。这些嵌套表达式允许作为属性值和子元素。例如:
function sidebar(className, title, content, drawLine=true) {
return (
<div className={className}>
<h1>{title}</h1>
{ drawLine && <hr/> }
<p>{content}</p>
</div>
);
}
sidebar()函数返回一个 JSX 元素。它接受四个参数,这些参数在 JSX 元素中使用。花括号语法可能会让你想起使用${}在字符串中包含 JavaScript 表达式的模板字面量。由于我们知道 JSX 表达式编译为函数调用,因此包含任意 JavaScript 表达式并不奇怪,因为函数调用也可以用任意表达式编写。Babel 将此示例代码转换为以下内容:
function sidebar(className, title, content, drawLine=true) {
return React.createElement("div", { className: className },
React.createElement("h1", null, title),
drawLine && React.createElement("hr", null),
React.createElement("p", null, content));
}
这段代码易于阅读和理解:花括号消失了,生成的代码以自然的方式将传入的函数参数传递给React.createElement()。请注意我们在这里使用drawLine参数和短路&&运算符的巧妙技巧。如果你只用三个参数调用sidebar(),那么drawLine默认为true,并且外部createElement()调用的第四个参数是<hr/>元素。但如果将false作为第四个参数传递给sidebar(),那么外部createElement()调用的第四个参数将计算为false,并且永远不会创建<hr/>元素。这种使用&&运算符的习惯用法在 JSX 中是一种常见的习语,根据某些其他表达式的值有条件地包含或排除子元素。(这种习惯用法在 React 中有效,因为 React 简单地忽略false或null的子元素,并且不为它们生成任何输出。)
当你在 JSX 表达式中使用 JavaScript 表达式时,你不仅限于前面示例中的字符串和布尔值等简单值。任何 JavaScript 值都是允许的。事实上,在 React 编程中使用对象、数组和函数是非常常见的。例如,考虑以下函数:
// Given an array of strings and a callback function return a JSX element
// representing an HTML <ul> list with an array of <li> elements as its child.
function list(items, callback) {
return (
<ul style={ {padding:10, border:"solid red 4px"} }>
{items.map((item,index) => {
<li onClick={() => callback(index)} key={index}>{item}</li>
})}
</ul>
);
}
此函数将对象字面量用作<ul>元素上style属性的值。(请注意,这里需要双大括号。)<ul>元素只有一个子元素,但该子元素的值是一个数组。子数组是通过在输入数组上使用map()函数创建<li>元素数组而创建的数组。(这在 React 中有效,因为 React 库在渲染时会展平元素的子元素。具有一个数组子元素的元素与该元素的每个数组元素作为子元素相同。)最后,请注意每个嵌套的<li>元素都有一个onClick事件处理程序属性,其值是一个箭头函数。JSX 代码编译为以下纯 JavaScript 代码(我已使用 Prettier 格式化):
function list(items, callback) {
return React.createElement(
"ul",
{ style: { padding: 10, border: "solid red 4px" } },
items.map((item, index) =>
React.createElement(
"li",
{ onClick: () => callback(index), key: index },
item
)
)
);
}
JSX 中对象表达式的另一个用途是使用对象扩展运算符(§6.10.4)一次指定多个属性。假设你发现自己编写了许多重复一组常见属性的 JSX 表达式。你可以通过将属性定义为对象的属性并将它们“扩展到”你的 JSX 元素中来简化表达式:
let hebrew = { lang: "he", dir: "rtl" }; // Specify language and direction
let shalom = <span className="emphasis" {...hebrew}>שלום</span>;
Babel 将其编译为使用_extends()函数(此处省略)将className属性与hebrew对象中包含的属性组合在一起:
let shalom = React.createElement("span",
_extends({className: "emphasis"}, hebrew),
"\u05E9\u05DC\u05D5\u05DD");
最后,还有一个 JSX 的重要特性我们还没有涉及。正如你所见,所有 JSX 元素在开角括号后立即以标识符开头。如果此标识符的第一个字母是小写(就像在这里的所有示例中一样),那么该标识符将作为字符串传递给createElement()。但如果标识符的第一个字母是大写,则将其视为实际标识符,并将该标识符的 JavaScript 值作为createElement()的第一个参数传递。这意味着 JSX 表达式<Math/>编译为将全局 Math 对象传递给React.createElement()的 JavaScript 代码。
对于 React 来说,将非字符串值作为createElement()的第一个参数传递的能力使得创建组件成为可能。组件是一种编写简单 JSX 表达式(使用大写组件名称)来表示更复杂表达式(使用小写 HTML 标签名称)的方式。
在 React 中定义一个新组件的最简单方法是编写一个以“props 对象”作为参数的函数,并返回一个 JSX 表达式。props 对象只是一个表示属性值的 JavaScript 对象,就像作为createElement()的第二个参数传递的对象一样。例如,这里是我们sidebar()函数的另一种写法:
function Sidebar(props) {
return (
<div>
<h1>{props.title}</h1>
{ props.drawLine && <hr/> }
<p>{props.content}</p>
</div>
);
}
这个新的Sidebar()函数与之前的sidebar()函数非常相似。但这个函数以大写字母开头的名称,并接受一个对象参数而不是单独的参数。这使它成为一个 React 组件,并意味着它可以在 JSX 表达式中替代 HTML 标签名称使用:
let sidebar = <Sidebar title="Something snappy" content="Something wise"/>;
这个<Sidebar/>元素编译如下:
let sidebar = React.createElement(Sidebar, {
title: "Something snappy",
content: "Something wise"
});
这是一个简单的 JSX 表达式,但当 React 渲染它时,它会将第二个参数(Props 对象)传递给第一个参数(Sidebar()函数),并将该函数返回的 JSX 表达式替换为<Sidebar>表达式的位置。
17.8 使用 Flow 进行类型检查
Flow是一种语言扩展,允许您在 JavaScript 代码中添加类型信息,并用于检查您的 JavaScript 代码(包括带注释和不带注释的代码)中的类型错误。要使用 Flow,您开始使用 Flow 语言扩展编写代码以添加类型注解。然后运行 Flow 工具分析您的代码并报告类型错误。一旦您修复了错误并准备运行代码,您可以使用 Babel(可能作为代码捆绑过程的一部分自动执行)来剥离代码中的 Flow 类型注解。(Flow 语言扩展的一个好处是,Flow 没有必须编译或转换的新语法。您使用 Flow 语言扩展向代码添加注解,而 Babel 只需剥离这些注解以将您的代码返回到标准 JavaScript。)
使用 Flow 需要承诺,但我发现对于中大型项目来说,额外的努力是值得的。为代码添加类型注解,每次编辑代码时运行 Flow,以及修复它报告的类型错误都需要额外的时间。但作为回报,Flow 将强制执行良好的编码纪律,并不允许你采取可能导致错误的捷径。当我在使用 Flow 的项目上工作时,我对它在我的代码中发现的错误数量感到印象深刻。在这些问题变成错误之前修复这些问题是一种很棒的感觉,并让我对我的代码正确性更有信心。
当我第一次开始使用 Flow 时,我发现有时很难理解它为什么会抱怨我的代码。然而,通过一些实践,我开始理解它的错误消息,并发现通常很容易对我的代码进行微小更改,使其更安全并满足 Flow 的要求。¹ 如果你仍然觉得自己在学习 JavaScript 本身,我不建议使用 Flow。但一旦你对这门语言有信心,将 Flow 添加到你的 JavaScript 项目中将推动你将编程技能提升到下一个水平。这也是为什么我将这本书的最后一节专门用于 Flow 教程的原因:因为了解 JavaScript 类型系统提供了另一种编程水平或风格的一瞥。
本节是一个教程,不打算全面涵盖 Flow。如果您决定尝试 Flow,几乎肯定会花时间阅读https://flow.org上的文档。另一方面,您不需要在掌握 Flow 类型系统之前就能开始在项目中实际使用它:这里描述的 Flow 的简单用法将带您走很远。
17.8.1 安装和运行 Flow
像本章中描述的其他工具一样,您可以使用包管理器安装 Flow 类型检查工具,使用类似npm install -g flow-bin或npm install --save-dev flow-bin的命令。如果使用-g全局安装工具,那么可以使用flow运行它。如果在项目中使用--save-dev本地安装它,那么可以使用npx flow运行它。在使用 Flow 进行类型检查之前,首次在项目的根目录中运行flow --init以创建.flowconfig配置文件。您可能永远不需要向此文件添加任何内容,但 Flow 需要知道您的项目根目录在哪里。
运行 Flow 时,它会找到项目中的所有 JavaScript 源代码,但只会为已通过在文件顶部添加// @flow注释而“选择加入”类型检查的文件报告类型错误。这种选择加入的行为很重要,因为这意味着您可以为现有项目采用 Flow,然后逐个文件地开始转换代码,而不会受到尚未转换的文件上的错误和警告的困扰。
即使您只是通过// @flow注释选择加入,Flow 也可能能够找到代码中的错误。即使您不使用 Flow 语言扩展并且不向代码添加任何类型注释,Flow 类型检查工具仍然可以推断程序中的值,并在您不一致地使用它们时提醒您。
考虑以下 Flow 错误消息:
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ variableReassignment.js:6:3
Cannot assign 1 to i.r because:
• property r is missing in number [1].
2│ let i = { r: 0, i: 1 }; // The complex number 0+1i
[1] 3│ for(i = 0; i < 10; i++) { // Oops! The loop variable overwrites i
4│ console.log(i);
5│ }
6│ i.r = 1; // Flow detects the error here
在这种情况下,我们声明变量i并将一个对象赋给它。然后我们再次使用i作为循环变量,覆盖了对象。Flow 注意到这一点,并在我们尝试像仍然保存对象一样使用i时标记错误。(一个简单的修复方法是写for(let i = 0;使循环变量局部于循环。)
这是 Flow 即使没有类型注释也能检测到的另一个错误:
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ size.js:3:14
Cannot get x.length because property length is missing in Number [1].
1│ // @flow
2│ function size(x) {
3│ return x.length;
4│ }
[1] 5│ let s = size(1000);
Flow 看到size()函数接受一个参数。它不知道该参数的类型,但可以看到该参数应具有length属性。当看到使用数字参数调用此size()函数时,它会正确地标记此为错误,因为数字没有length属性。
17.8.2 使用类型注释
当声明 JavaScript 变量时,可以在变量名称后面加上冒号和类型来添加 Flow 类型注释:
let message: string = "Hello world";
let flag: boolean = false;
let n: number = 42;
即使您没有为这些变量添加注释,Flow 也会知道这些变量的类型:它可以看到您为每个变量分配的值,并跟踪它们。但是,如果添加了类型注释,Flow 既知道变量的类型,又知道您已表达了该变量应始终为该类型的意图。因此,如果使用类型注释,如果将不同类型的值分配给该变量,Flow 将标记错误。对于变量,类型注释也特别有用,如果您倾向于在函数使用之前在函数顶部声明所有变量。
函数参数的类型注释与变量的注释类似:在函数参数名称后面跟着冒号和类型名称。在注释函数时,通常还会为函数的返回类型添加注释。这在函数体的右括号和左花括号之间。返回空值的函数使用 Flow 类型void。
在前面的示例中,我们定义了一个期望具有length属性的参数的size()函数。下面是如何将该函数更改为明确指定它期望一个字符串参数并返回一个数字。请注意,即使在这种情况下函数可以正常工作,Flow 现在也会标记错误,如果我们将数组传递给函数:
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ size2.js:5:18
Cannot call size with array literal bound to s because array literal [1]
is incompatible with string [2].
[2] 2│ function size(s: string): number {
3│ return s.length;
4│ }
[1] 5│ console.log(size([1,2,3]));
使用箭头函数的类型注解也是可能的,尽管这可能会将这个通常简洁的语法变得更冗长:
const size = (s: string): number => s.length;
关于 Flow 的一个重要事项是,JavaScript 值null具有 Flow 类型null,JavaScript 值undefined具有 Flow 类型void。但这两个值都不是任何其他类型的成员(除非你明确添加它)。如果你声明一个函数参数为字符串,那么它必须是一个字符串,传递null、传递undefined或省略参数(基本上与传递undefined相同)都是错误的:
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ size3.js:3:18
Cannot call size with null bound to s because null [1] is incompatible
with string [2].
1│ // @flow
[2] 2│ const size = (s: string): number => s.length;
[1] 3│ console.log(size(null));
如果你想允许null和undefined作为变量或函数参数的合法值,只需在类型前加上问号。例如,使用?string或?number代替string或number。如果我们将size()函数更改为期望类型为?string的参数,那么当我们将null传递给函数时,Flow 不会抱怨。但现在它有其他事情要抱怨:
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ size4.js:3:14
Cannot get s.length because property length is missing in null or
undefined [1].
1│ // @flow
[1] 2│ function size(s: ?string): number {
3│ return s.length;
4│ }
5│ console.log(size(null));
Flow 在这里告诉我们的是,在我们的代码中,写s.length是不安全的,因为此处的s可能是null或undefined,而这些值没有length属性。这就是 Flow 确保我们不会偷懒的地方。如果一个值可能是null,Flow 会坚持要求我们在执行任何依赖于该值不是null的操作之前检查该情况。
在这种情况下,我们可以通过更改函数主体来解决问题如下:
function size(s: ?string): number {
// At this point in the code, s could be a string or null or undefined.
if (s === null || s === undefined) {
// In this block, Flow knows that s is null or undefined.
return -1;
} else {
// And in this block, Flow knows that s is a string.
return s.length;
}
}
当函数首次调用时,参数可以有多种类型。但通过添加类型检查代码,我们在代码中创建了一个块,Flow 可以确定参数是一个字符串。当我们在该块内使用s.length时,Flow 不会抱怨。请注意,Flow 不要求你编写冗长的代码。如果我们只是用return s ? s.length : -1;替换size()函数的主体,Flow 也会满意。
Flow 语法允许在任何类型规范之前加上问号,以指示除了指定的类型外,null和undefined也是允许的。问号也可以出现在参数名后,以指示参数本身是可选的。因此,如果我们将参数s的声明从s: ?string更改为s? : string,那意味着可以用没有参数调用size()(或值为undefined,这与省略它相同),但如果我们用除undefined之外的参数调用它,那么该参数必须是一个字符串。在这种情况下,null不是合法值。
到目前为止,我们已经讨论了原始类型string、number、boolean、null和void,并演示了如何在变量声明、函数参数和函数返回值中使用它们。接下来的小节描述了 Flow 支持的一些更复杂的类型。
17.8.3 类型类
除了 Flow 了解的原始类型外,它还了解所有 JavaScript 的内置类,并允许你使用类名作为类型。例如,以下函数使用类型注解指示应使用一个 Date 对象和一个 RegExp 对象调用它:
// @flow
// Return true if the ISO representation of the specified date
// matches the specified pattern, or false otherwise.
// E.g: const isTodayChristmas = dateMatches(new Date(), /^\d{4}-12-25T/);
export function dateMatches(d: Date, p: RegExp): boolean {
return p.test(d.toISOString());
}
如果你使用class关键字定义自己的类,那些类会自动成为有效的 Flow 类型。然而,为了使其工作,Flow 确实要求你在类中使用类型注解。特别是,类的每个属性必须声明其类型。这里是一个简单的复数类示例,演示了这一点:
// @flow
export default class Complex {
// Flow requires an extended class syntax that includes type annotations
// for each of the properties used by the class.
i: number;
r: number;
static i: Complex;
constructor(r: number, i:number) {
// Any properties initialized by the constructor must have Flow type
// annotations above.
this.r = r;
this.i = i;
}
add(that: Complex) {
return new Complex(this.r + that.r, this.i + that.i);
}
}
// This assignment would not be allowed by Flow if there was not a
// type annotation for i inside the class.
Complex.i = new Complex(0,1);
17.8.4 对象类型
描述对象的 Flow 类型看起来很像对象字面量,只是属性值被属性类型替换。例如,这里是一个期望具有数字 x 和 y 属性的对象的函数:
// @flow
// Given an object with numeric x and y properties, return the
// distance from the origin to the point (x,y) as a number.
export default function distance(point: {x:number, y:number}): number {
return Math.hypot(point.x, point.y);
}
在这段代码中,文本 {x:number, y:number} 是一个 Flow 类型,就像 string 或 Date 一样。与任何类型一样,你可以在前面加上问号来表示 null 和 undefined 也应该被允许。
在对象类型中,你可以在任何属性名称后面加上问号,表示该属性是可选的,可以省略。例如,你可以这样写一个表示 2D 或 3D 点的对象类型:
{x: number, y: number, z?: number}
如果在对象类型中未标记属性为可选,则该属性是必需的,如果实际值中缺少适当的属性,Flow 将报告错误。然而,通常情况下,Flow 容忍额外的属性。如果你向上面的 distance() 函数传递一个具有 w 属性的对象,Flow 不会抱怨。
如果你希望 Flow 严格执行对象除了在其类型中明确声明的属性之外没有其他属性,你可以通过在花括号中添加竖线来声明精确对象类型:
{| x: number, y: number |}
JavaScript 的对象有时被用作字典或字符串值映射。当以这种方式使用对象时,属性名称事先不知道,也不能在 Flow 类型中声明。如果你以这种方式使用对象,你仍然可以使用 Flow 来描述数据结构。假设你有一个对象,其中属性是世界主要城市的名称,这些属性的值是指定这些城市地理位置的对象。你可以这样声明这个数据结构:
// @flow
const cityLocations : {[string]: {longitude:number, latitude:number}} = {
"Seattle": { longitude: 47.6062, latitude: -122.3321 },
// TODO: if there are any other important cities, add them here.
};
export default cityLocations;
17.8.5 类型别名
对象可以有许多属性,描述这样一个对象的 Flow 类型将会很长且难以输入。即使相对较短的对象类型也可能令人困惑,因为它们看起来非常像对象字面量。一旦我们超越了像 number 和 ?string 这样的简单类型,为我们的 Flow 类型定义名称通常是有用的。事实上,Flow 使用 type 关键字来做到这一点。在 type 关键字后面跟上标识符、等号和 Flow 类型。一旦你这样做了,该标识符将成为该类型的别名。例如,这里是我们如何使用显式定义的 Point 类型重写上一节中的 distance() 函数:
// @flow
export type Point = {
x: number,
y: number
};
// Given a Point object return its distance from the origin
export default function distance(point: Point): number {
return Math.hypot(point.x, point.y);
}
请注意,此代码导出了 distance() 函数,并且还导出了 Point 类型。其他模块可以使用 import type Point from './distance.js' 如果他们想使用该类型定义。但请记住,import type 是一个 Flow 语言扩展,而不是真正的 JavaScript 导入指令。类型导入和导出被 Flow 类型检查器使用,但像所有其他 Flow 语言扩展一样,在代码运行之前它们都会被剥离。
最后,值得注意的是,与其定义一个代表点的 Flow 对象类型的名称,可能更简单和更清晰的是只定义一个 Point 类并将该类用作类型。
17.8.6 数组类型
描述数组的 Flow 类型是一个复合类型,还包括数组元素的类型。例如,这里是一个期望数字数组的函数,以及如果尝试使用具有非数字元素的数组调用该函数时 Flow 报告的错误:
Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ average.js:8:16
Cannot call average with array literal bound to data because string [1]
is incompatible with number [2] in array element.
[2] 2│ function average(data: Array<number>) {
3│ let sum = 0;
4│ for(let x of data) sum += x;
5│ return sum/data.length;
6│ }
7│
[1] 8│ average([1, 2, "three"]);
表示数组的 Flow 类型是 Array,后面跟着尖括号中的元素类型。你也可以通过在元素类型后面加上开放和关闭方括号来表示数组类型。因此,在这个例子中,我们可以写成 number[] 而不是 Array<number>。我更喜欢尖括号表示法,因为,正如我们将看到的,还有其他使用这种尖括号语法的 Flow 类型。
所示的 Array 类型语法适用于具有任意数量元素的数组,所有元素都具有相同的类型。Flow 有一种不同的语法来描述元组的类型:一个具有固定数量元素的数组,每个元素可能具有不同的类型。要表示元组的类型,只需写出每个元素的类型,用逗号分隔,然后将它们都括在方括号中。
例如,一个返回 HTTP 状态码和消息的函数可能如下所示:
function getStatus():[number, string] {
return [getStatusCode(), getStatusMessage()];
}
返回元组的函数在不使用解构赋值的情况下很难处理:
let [code, message] = getStatus();
解构赋值,再加上 Flow 的类型别名功能,使得元组易于处理,以至于你可能会考虑它们作为简单数据类型的替代方案:
// @flow
export type Color = [number, number, number, number]; // [r, g, b, opacity]
function gray(level: number): Color {
return [level, level, level, 1];
}
function fade([r,g,b,a]: Color, factor: number): Color {
return [r, g, b, a/factor];
}
let [r, g, b, a] = fade(gray(75), 3);
现在我们有了一种表达数组类型的方法,让我们回到之前的size()函数,并修改它以接受一个数组参数而不是一个字符串参数。我们希望函数能够接受任意长度的数组,因此元组类型不合适。但我们也不希望将函数限制为仅适用于所有元素类型相同的数组。解决方案是类型Array<mixed>:
// @flow
function size(s: Array<mixed>): number {
return s.length;
}
console.log(size([1,true,"three"]));
元素类型mixed表示数组的元素可以是任何类型。如果我们的函数实际上对数组进行索引并尝试使用其中的任何元素,Flow 将坚持要求我们使用typeof检查或其他测试来确定元素的类型,然后再执行任何不安全的操作。(如果你愿意放弃类型检查,也可以使用any代替mixed:它允许你对数组的值做任何想做的事情,而不必确保这些值是你期望的类型。)
17.8.7 其他参数化类型
我们已经看到,当您将一个值注释为Array时,Flow 要求您还必须在尖括号内指定数组元素的类型。这个额外的类型被称为类型参数,而 Array 并不是唯一一个被参数化的 JavaScript 类。
JavaScript 的 Set 类是一个元素集合,就像数组一样,你不能单独使用Set作为一种类型,而是必须在尖括号内包含一个类型参数来指定集合中包含的值的类型。(尽管如果集合可能包含多种类型的值,你可以使用mixed或any。)以下是一个示例:
// @flow
// Return a set of numbers with members that are exactly twice those
// of the input set of numbers.
function double(s: Set<number>): Set<number> {
let doubled: Set<number> = new Set();
for(let n of s) doubled.add(n * 2);
return doubled;
}
console.log(double(new Set([1,2,3]))); // Prints "Set {2, 4, 6}"
Map 是另一种参数化类型。在这种情况下,必须指定两个类型参数;键的类型和值的类型:
// @flow
import type { Color } from "./Color.js";
let colorNames: Map<string, Color> = new Map([
["red", [1, 0, 0, 1]],
["green", [0, 1, 0, 1]],
["blue", [0, 0, 1, 1]]
]);
Flow 还允许您为自己的类定义类型参数。以下代码定义了一个 Result 类,但使用一个 Error 类型和一个 Value 类型对该类进行参数化。我们在代码中使用占位符E和V来表示这些类型参数。当这个类的用户声明一个 Result 类型的变量时,他们将指定实际类型来替换E和V。变量声明可能如下所示:
let result: Result<TypeError, Set<string>>;
下面是参数化类的定义方式:
// @flow
// This class represents the result of an operation that can either
// throw an error of type E or a value of type V.
export class Result<E, V> {
error: ?E;
value: ?V;
constructor(error: ?E, value: ?V) {
this.error = error;
this.value = value;
}
threw(): ?E { return this.error; }
returned(): ?V { return this.value; }
get():V {
if (this.error) {
throw this.error;
} else if (this.value === null || this.value === undefined) {
throw new TypeError("Error and value must not both be null");
} else {
return this.value;
}
}
}
甚至可以为函数定义类型参数:
// @flow
// Combine the elements of two arrays into an array of pairs
function zip<A,B>(a:Array<A>, b:Array<B>): Array<[?A,?B]> {
let result:Array<[?A,?B]> = [];
let len = Math.max(a.length, b.length);
for(let i = 0; i < len; i++) {
result.push([a[i], b[i]]);
}
return result;
}
// Create the array [[1,'a'], [2,'b'], [3,'c'], [4,undefined]]
let pairs: Array<[?number,?string]> = zip([1,2,3,4], ['a','b','c'])
17.8.8 只读类型
Flow 定义了一些特殊的参数化“实用类型”,其名称以$开头。这些类型中的大多数都有我们这里不打算涵盖的高级用例。但其中两个在实践中非常有用。如果你有一个对象类型 T,并想要创建该类型的只读版本,只需编写$ReadOnly<T>。类似地,您可以编写$ReadOnlyArray<T>来描述一个具有类型 T 的只读数组。
使用这些类型的原因不是因为它们可以提供任何对象或数组不能被修改的保证(如果你想要真正的只读对象,请参见 §14.2 中的 Object.freeze()),而是因为它可以帮助你捕捉由无意修改引起的错误。如果你编写一个接受对象或数组参数并且不改变对象的任何属性或数组的元素的函数,那么你可以用 Flow 的只读类型注释函数参数。如果你这样做,那么如果你忘记并意外修改输入值,Flow 将报告错误。以下是两个示例:
// @flow
type Point = {x:number, y:number};
// This function takes a Point object but promises not to modify it
function distance(p: $ReadOnly<Point>): number {
return Math.hypot(p.x, p.y);
}
let p: Point = {x:3, y:4};
distance(p) // => 5
// This function takes an array of numbers that it will not modify
function average(data: $ReadOnlyArray<number>): number {
let sum = 0;
for(let i = 0; i < data.length; i++) sum += data[i];
return sum/data.length;
}
let data: Array<number> = [1,2,3,4,5];
average(data) // => 3
17.8.9 函数类型
我们已经看到如何添加类型注释来指定函数参数和返回类型的类型。但是当函数的一个参数本身是一个函数时,我们需要能够指定该函数参数的类型。
要使用 Flow 表达函数的类型,需要写出每个参数的类型,用逗号分隔,将它们括在括号中,然后跟上一个箭头和函数的返回类型。
这里是一个期望传递回调函数的示例函数。请注意我们为回调函数的类型定义了一个类型别名:
// @flow
// The type of the callback function used in fetchText() below
export type FetchTextCallback = (?Error, ?number, ?string) => void;
export default function fetchText(url: string, callback: FetchTextCallback) {
let status = null;
fetch(url)
.then(response => {
status = response.status;
return response.text()
})
.then(body => {
callback(null, status, body);
})
.catch(error => {
callback(error, status, null);
});
}
17.8.10 Union 类型
让我们再次回到 size() 函数。创建一个什么都不做,只返回数组长度的函数并没有太多意义。数组有一个完全好用的 length 属性。但如果 size() 能够接受任何类型的集合对象(数组或 Set 或 Map)并返回集合中元素的数量,那么它可能会有用。在常规的未类型化 JavaScript 中,编写一个这样的 size() 函数很容易。但是在 Flow 中,我们需要一种方式来表达一个允许数组、Set 和 Map 的类型,但不允许任何其他类型值。
Flow 将这种类型称为 Union 类型,并允许你通过简单列出所需类型并用竖线字符分隔它们来表达它们:
// @flow
function size(collection: Array<mixed>|Set<mixed>|Map<mixed,mixed>): number {
if (Array.isArray(collection)) {
return collection.length;
} else {
return collection.size;
}
}
size([1,true,"three"]) + size(new Set([true,false])) // => 5
Union 类型可以用“或”这个词来阅读——“一个数组或一个 Set 或一个 Map”——因此,这种 Flow 语法使用与 JavaScript 的 OR 运算符相同的竖线字符是有意的。
我们之前看到在类型前面加一个问号允许 null 和 undefined 值。现在你可以看到,? 前缀只是一个为类型添加 |null|void 后缀的快捷方式。
一般来说,当你用 Union 类型注释一个值时,Flow 不会允许你使用该值,直到你进行足够的测试以确定实际值的类型。在我们刚刚看过的 size() 示例中,我们需要明确检查参数是否为数组,然后再尝试访问参数的 length 属性。请注意,我们不必区分 Set 参数和 Map 参数,然而:这两个类都定义了 size 属性,因此只要参数不是数组,else 子句中的代码就是安全的。
17.8.11 枚举类型和辨别联合
Flow 允许你使用原始字面量作为只包含一个单一值的类型。如果你写 let x:3;,那么 Flow 将不允许你给该变量赋值除了 3 之外的任何值。定义只有一个成员的类型通常不太有用,但字面量类型的联合可能会有用。你可能可以想象出这些类型的用途,例如:
type Answer = "yes" | "no";
type Digit = 0|1|2|3|4|5|6|7|8|9;
如果你使用由字面量组成的类型,你需要理解只有字面值是允许的:
let a: Answer = "Yes".toLowerCase(); // Error: can't assign string to Answer
let d: Digit = 3+4; // Error: can't assign number to Digit
当 Flow 检查你的类型时,它实际上并不执行计算:它只检查计算的类型。Flow 知道 toLowerCase() 返回一个字符串,+ 运算符在数字上返回一个数字。尽管我们知道这两个计算返回的值都在类型内,但 Flow 无法知道这一点,并在这两行上标记错误。
像Answer和Digit这样的字面类型的联合类型是枚举类型的一个例子。枚举类型的一个典型用例是表示扑克牌的花色:
type Suit = "Clubs" | "Diamonds" | "Hearts" | "Spades";
更相关的例子可能是 HTTP 状态码:
type HTTPStatus =
| 200 // OK
| 304 // Not Modified
| 403 // Forbidden
| 404; // Not Found
新手程序员经常听到的建议之一是避免在代码中使用字面量,而是定义符号常量来代表这些值。这样做的一个实际原因是避免拼写错误的问题:如果你拼错了一个字符串字面量,比如“Diamonds”,JavaScript 可能不会抱怨,但你的代码可能无法正常工作。另一方面,如果你拼错了一个标识符,JavaScript 很可能会抛出一个你会注意到的错误。然而,在使用 Flow 时,这个建议并不总是适用。如果你用类型 Suit 注释一个变量,然后尝试将一个拼写错误的 suit 赋给它,Flow 会提醒你错误。
字面类型的另一个重要用途是创建辨别联合体。当你使用联合类型(由实际不同类型组成,而不是字面量)时,通常需要编写代码来区分可能的类型。在前一节中,我们编写了一个函数,它可以接受一个数组、一个 Set 或一个 Map 作为其参数,并且必须编写代码来区分数组输入和 Set 或 Map 输入。如果你想创建一个对象类型的联合体,可以通过在每个单独的对象类型中使用字面类型来使这些类型易于区分。
举个例子来说明。假设你正在 Node 中使用工作线程(§16.11),并且正在使用postMessage()和“message”事件在主线程和工作线程之间发送基于对象的消息。工作线程可能想要向主线程发送多种类型的消息,但我们希望编写一个描述所有可能消息的 Flow 联合类型。考虑以下代码:
// @flow
// The worker sends a message of this type when it is done
// reticulating the splines we sent it.
export type ResultMessage = {
messageType: "result",
result: Array<ReticulatedSpline>, // Assume this type is defined elsewhere.
};
// The worker sends a message of this type if its code failed with an exception.
export type ErrorMessage = {
messageType: "error",
error: Error,
};
// The worker sends a message of this type to report usage statistics.
export type StatisticsMessage = {
messageType: "stats",
splinesReticulated: number,
splinesPerSecond: number
};
// When we receive a message from the worker it will be a WorkerMessage.
export type WorkerMessage = ResultMessage | ErrorMessage | StatisticsMessage;
// The main thread will have an event handler function that is passed
// a WorkerMessage. But because we've carefully defined each of the
// message types to have a messageType property with a literal type,
// the event handler can easily discriminate among the possible messages:
function handleMessageFromReticulator(message: WorkerMessage) {
if (message.messageType === "result") {
// Only ResultMessage has a messageType property with this value
// so Flow knows that it is safe to use message.result here.
// And Flow will complain if you try to use any other property.
console.log(message.result);
} else if (message.messageType === "error") {
// Only ErrorMessage has a messageType property with value "error"
// so knows that it is safe to use message.error here.
throw message.error;
} else if (message.messageType === "stats") {
// Only StatisticsMessage has a messageType property with value "stats"
// so knows that it is safe to use message.splinesPerSecond here.
console.info(message.splinesPerSecond);
}
}
17.9 总结
JavaScript 是当今世界上使用最广泛的编程语言。它是一种活跃的语言,不断发展和改进,周围有着繁荣的库、工具和扩展生态系统。本章介绍了其中一些工具和扩展,但还有许多其他内容需要了解。JavaScript 生态系统蓬勃发展,因为 JavaScript 开发者社区活跃而充满活力,同行们通过博客文章、视频和会议演讲分享他们的知识。当你结束阅读这本书,加入这个社区时,你会发现有很多信息源可以让你与 JavaScript 保持联系并继续学习。
祝一切顺利,David Flanagan,2020 年 3 月
¹ 如果你有 Java 编程经验,可能在第一次编写使用类型参数的通用 API 时会遇到类似的情况。我发现学习 Flow 的过程与 2004 年 Java 添加泛型时经历的过程非常相似。


浙公网安备 33010602011771号