0
点赞
收藏
分享

微信扫一扫

JavaScript 权威指南第七版(GPT 重译)(三)

求索大伟 03-24 10:30 阅读 2
javascript

第六章:对象

对象是 JavaScript 中最基本的数据类型,您在本章之前的章节中已经多次看到它们。因为对象对于 JavaScript 语言非常重要,所以您需要详细了解它们的工作原理,而本章提供了这些细节。它从对象的正式概述开始,然后深入到关于创建对象和查询、设置、删除、测试和枚举对象属性的实用部分。这些以属性为重点的部分之后是关于如何扩展、序列化和定义对象重要方法的部分。最后,本章以关于 ES6 和更高版本语言中新对象字面量语法的长篇部分结束。

6.1 对象简介

对象是一个复合值:它聚合了多个值(原始值或其他对象),并允许您通过名称存储和检索这些值。对象是一个无序的属性集合,每个属性都有一个名称和一个值。属性名称通常是字符串(尽管,正如我们将在§6.10.3 中看到的,属性名称也可以是符号),因此我们可以说对象将字符串映射到值。这种字符串到值的映射有各种名称——您可能已经熟悉了以“哈希”、“哈希表”、“字典”或“关联数组”命名的基本数据结构。然而,对象不仅仅是一个简单的字符串到值的映射。除了维护自己的一组属性外,JavaScript 对象还继承另一个对象的属性,称为其“原型”。对象的方法通常是继承的属性,这种“原型继承”是 JavaScript 的一个关键特性。

JavaScript 对象是动态的——属性通常可以添加和删除——但它们可以用来模拟静态类型语言的静态对象和“结构”。它们也可以被用来(通过忽略字符串到值映射的值部分)表示字符串集合。

任何在 JavaScript 中不是字符串、数字、符号、truefalsenullundefined 的值都是对象。即使字符串、数字和布尔值不是对象,它们也可以像不可变对象一样行事。

从§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.prototypeObject.prototype继承属性,因此通过new Date()创建的 Date 对象从Date.prototypeObject.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对象的address0address1address2address3属性。

这个简短的示例展示了使用数组表示法访问对象属性时的灵活性。这段代码可以使用点表示法重写,但有些情况下只有数组表示法才能胜任。例如,假设你正在编写一个程序,该程序使用网络资源计算用户股票市场投资的当前价值。该程序允许用户输入他们拥有的每支股票的名称以及每支股票的股数。你可以使用一个名为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

然而,尝试查询不存在的对象的属性是错误的。nullundefined值没有属性,查询这些值的属性是错误的。继续前面的例子:

let len = book.subtitle.length; // !TypeError: undefined doesn't have length

如果.的左侧是nullundefined,则属性访问表达式将失败。因此,在编写诸如book.author.surname的表达式时,如果不确定bookbook.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;

尝试在 nullundefined 上设置属性也会导致 TypeError。在其他值上尝试设置属性也不总是成功:某些属性是只读的,无法设置,某些对象不允许添加新属性。在严格模式下(§5.6.3),每当尝试设置属性失败时都会抛出 TypeError。在非严格模式下,这些失败通常是静默的。

指定属性赋值何时成功何时失败的规则是直观的,但难以简洁表达。在以下情况下,尝试设置对象 o 的属性 p 失败:

  • o 有一个自己的只读属性 p:无法设置只读属性。

  • o 具有一个继承的只读属性 p:无法通过具有相同名称的自有属性隐藏继承的只读属性。

  • o 没有自己的属性 po 没有继承具有 setter 方法的属性 p,且 o可扩展 属性(见 §14.2)为 false。由于 op 不存在,并且没有 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 值。支持并可以序列化和还原的有对象、数组、字符串、有限数字、truefalsenullNaNInfinity-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 简写属性

假设你有存储在变量 xy 中的值,并且想要创建一个具有名为 xy 的属性的对象,其中包含这些值。使用基本对象字面量语法,你将重复每个标识符两次:

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

在这段代码中,positiondimensions对象的属性被“展开”到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 定义前缀为getset。(在 ES6 中,当定义 getter 和 setter 时,也可以使用计算属性名称。只需在getset后用方括号中的表达式替换属性名称。)

上面定义的访问器方法只是获取和设置数据属性的值,并没有理由优先使用访问器属性而不是数据属性。但作为一个更有趣的例子,考虑以下表示 2D 笛卡尔点的对象。它具有普通数据属性来表示点的xy坐标,并且具有访问器属性来给出点的等效极坐标:

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 方法可以将xy属性称为this.xthis.y。更详细地讨论方法和this关键字在§8.2.2 中有介绍。

访问器属性是继承的,就像数据属性一样,因此可以将上面定义的对象p用作其他点的原型。您可以为新对象提供它们自己的xy属性,并且它们将继承rtheta属性:

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() 方法返回一个包含调用它的数组的元素子集的数组。传递给它的函数应该是谓词:一个返回 truefalse 的函数。谓词的调用方式与 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() 方法是数组谓词:它们将您指定的谓词函数应用于数组的元素,然后返回 truefalse

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()搜索具有指定值的元素的数组,并返回找到的第一个这样的元素的索引,如果找不到则返回-1indexOf()从开头到结尾搜索数组,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对象字面量语法。在另一种特殊情况下,您可以在对象字面量中使用关键字getset来定义特殊的属性获取器和设置器方法。这种函数定义语法在§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() 定义的参数 ab。嵌套函数的这些作用域规则非常重要,我们将在 §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 和两个参数表达式 xy。函数表达式本身是一个属性访问表达式,这意味着该函数被作为方法而不是作为常规函数调用。

方法调用的参数和返回值的处理方式与常规函数调用完全相同。然而,方法调用与函数调用有一个重要的区别:调用上下文。属性访问表达式由两部分组成:一个对象(在本例中是 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]

同样,如果你正在定义一个期望对象参数的函数,你可以解构该对象的参数。再次使用矢量示例,假设我们将矢量表示为具有xy参数的对象:

// 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}

最后,请记住,除了解构参数对象和数组外,你还可以解构对象数组、具有数组属性的对象以及具有对象属性的对象,实际上可以解构到任何深度。考虑表示圆的图形代码,其中圆被表示为具有xy半径颜色属性的对象,其中颜色属性是红色、绿色和蓝色颜色分量的数组。你可以定义一个函数,该函数期望传递一个圆对象,但将该圆对象解构为六个单独的参数:

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 引入的块作用域变量解决了这个问题。如果我们只是用letconst替换var,问题就消失了。因为letconst是块作用域的,循环的每次迭代都定义了一个独立于所有其他迭代的作用域,并且每个作用域都有自己独立的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()构造函数来创建新的函数对象。接下来的小节记录了lengthnameprototype属性;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]

这里是另一个更一般的例子,它接受两个函数fg,并返回一个计算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 函数的部分应用

函数fbind()方法(参见§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 不同,其中每次调用都共享相同的默认值。

³ 这可能看起来不是特别有趣,除非您熟悉更静态的语言,在这些语言中,函数是程序的一部分,但不能被程序操纵。

举报

相关推荐

0 条评论