第六章:对象
对象是 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]