第 6 章 对象

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

6.1 介绍对象

对象是一个复合值:它聚合了多个值(原始值或其他对象),并允许按名称存储和获取这些值。对象是属性的无序集合,每个属性都有一个名称和一个值。属性名通常是字符串(尽管,正如我们将在 §6.10.3 中看到的,属性名也可以是 Symbol),所以我们可以说对象将字符串映射到值。这种字符串到值的映射有不同的名称——可能已经熟悉这种基本数据结构的别的名称“散列”(hash)、“散列表”(hashtable)、“字典”(dictionary)或“关联数组”(associative array)。然而,对象不仅仅是简单的字符串到值的映射。除了维护自己的属性集,JavaScript 对象还继承另一个对象的属性,即它的“原型”。对象的方法通常是继承的属性,而这种“原型继承”是 JavaScript 的一个关键特性。

JavaScript 对象是动态的——属性通常可以添加和删除——但它们可以用来模拟静态类型语言的静态对象和“结构体”(struct)。有时它们也用做字符串的集 合(忽略名/值对中的值)。

JavaScript 中任何不是字符串、数字、Symbol 或 true、false、null 或 undefined 的值都是对象。即使字符串、数字和布尔值不是对象,它们的行为和不可变对象非常类似。

回想一下 §3.8,对象是可变的,通过引用而不是值来操作。如果变量 x 指向一个对象的引用,执行 y = x; 时,变量 y 也是指向同一对象的引用,而不是该对象的副本。通过变量 y 修改这个对象也会对变量 x 造成影响。

对对象最常见的操作是创建它们并设置、查询、删除、测试和枚举它们的属性。这些基本的操作将在本章的开头部分进行描述。之后的部分将介绍更高级的主题。

属性有一个名称和一个值。属性名可以是任何字符串,包括空字符串(或任何 Symbol),但任何对象都不能有两个同名的属性。值可以是任何 JavaScript 值,也可以是 getter 或 setter 函数(或两者都是)。我们将在 §6.10.6 中学习 getter 和 setter 函数。

有时,能够区分直接在对象上定义的属性和从原型对象继承的属性是很重要的。JavaScript 使用术语“自有属性”来指代非继承属性。

除了名称和值之外,每个属性还有三个属性属性:

  • writable 属性指定是否可以设置属性的值。
  • enumerable 属性指定 for/in 循环是否返回属性名称。
  • 可配置属性指定该属性是否可以删除,是否可以修改其属性。

许多 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

除了这些内置构造函数,常常用自定义构造函数来初始化新对象。 第 9 章将详细讲述其中的细节。

6.2.3 原型

在讲述第三种对象创建技术之前,我们应当首先解释一下原型。每一个 JavaScript 对象都和另一个对象相关联。“另一个”对象就是我们熟知的原型,每一个对象都从原型继承属性。

所有通过对象字面量创建的对象都具有同一个原型对象,并可以通过 JavaScript 代码 Object.prototype 获得对原型对象的引用。通过关键字 new 和构造函数调用创建的对象的原型就是构造函数的 prototype 属性的值。因此,同使用 {} 创建对象一样,通过 new Object() 创建的对象也继承自Object.prototype。同样,通过 new Array() 创建的对象的原型就是 Array.prototype,通过 new Date() 创建的对象的原型就是 Date.prototype。当第一次学习 JavaScript 时,这可能令人困惑。请记住:几乎所有对象都有原型,但只有相对较少的对象具有原型属性。正是这些具有原型属性的对象定义了所有其他对象的原型。

没有原型的对象为数不多,Object.prototype就是其中之一:它不继承任何属性。其他原型对象都是普通对象,普通对象都具有原型。大部分的内置构造函数(以及大部分自定义的构造函数)都具有一个继承自 Object.prototype 的原型。例如, Date.prototype 的属性继承自 Object.prototype,因此由 new Date() 创建的 Date 对象的属性同时继承自 Date.prototype 和 Object.prototype。这一系列链接的原型对象就是所谓 “原型链”(prototype chain)。

§6.3.2 节讲述属性继承的工作机制。第 9 章将会更详细地讨论原型和构造函数:包括如何通过编写构造函数定义对象的“类”,以及给构造函数的 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.

当使用方括号时,我们说方括号内的表达式必须返回字符串。其实更严格地讲,表达式必须返回字符串或返回一个可以转换为字符串的值或 Symbol(§6.10.3)。在第 7 章里有一些例子中的方括号内使用了数字,这情况是非常常用的。

6.3.1 对象作关联数组

上文提到,下面两个表达式有相同的值:

object.property
object["property"]

第一种语法使用点运算符和一个标识符,这和 C 和 Java 中访问一个结构体或对象的静态字段非常类似。第二种语法使用方括号和一个字符串,看起来更像数组,只是这个数组元素是通过字符串索引而不是数字索引。这种数组就是我们所说的关联数组(associative array)(也称做散列、映射或字典)。JavaScript 对象都是关联数组,本节将讨论它的重要性。

在 C、C++、Java 和一些强类型语言中,对象只能拥有固定数目的属性,并且这些属性的名称必须提前定义好。由于 JavaScript 是一个弱类型语言,因此不适用这条规则:对象在程序中可以创建任意数量的属性。当使用 . 运算符访问对象的属性时,属性名用一个标识符来表示。标识符必须直接出现在 JavaScript 程序中,它们不是数据类型,所以无法在程序中修改。

另一种方式,当通过 [] 来访问对象的属性时,属性名通过字符串来表示。字符串是 JavaScript 的数据类型,在程序运行时可以修改和创建它们。因此,可以在 JavaScript 中使用下面这种代码:

let addr = "";
for(let i = 0; i < 4; i++) {
    addr += customer[`address${i}`] + "\n";
}

这段代码读取 customer 对象的 address0、address1、address2 和 address3 属性,并将它们连接起来。

这个例子主要说明了通过字符串表达式使用数组标记来访问对象属性的灵活性。这段代码也可以通过点运算符来重写,但是一些场景只能使用数组写法来完成。假设你正在写一个程序,这个程序利用网络资源计算用户股票市场投资的当前价值。程序允许用户输入他们拥有的股票名称以及对应的数量。你可以用一个名为 portfolio 的对象来储存这些信息。每一个股票在对象中都有一个属性与之对应。属性名是股票名,属性值是股票持有份额。例如,如果用户持有 IBM 的 50 股,那么 portfolio.ibm 属性的值就为 50。

下面是程序的部分代码,这个函数用来给 portifolio 添加新的股票:

function addstock(portfolio, stockname, shares) {
    portfolio[stockname] = shares;
}

由于用户是在程序运行时输入股票名称,因此在之前无法得知这些股票的名称是什么。而由于在写程序的时候不知道属性名称,因此无法通过点运算符(.)来访问对象 portfolio 的属性。但可以使用 [] 运算符,因为它使用字符串值(字符串值是动态的,可以在运行时更改)而不是标识符(标识符是静态的,必须写死在程序中)作为索引对属性进行访问。

第 5 章介绍了 for/in 循环(§6.6 节还会进一步介绍)。当使用 for/in 循环遍历关联数组时,就可以清晰地体会到 for/in 的强大之处。下面的例子就是利用 for/in 计算 portfolio 的合计值:

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 之后使用 Map 类常常是一个更好的选择,我们将在 §11.1.2 中进行描述。

6.3.2 继承

JavaScript 对象中有一组“自有属性”,也有一组属性是继承自它的原型对象。想要理解属性继承,必须更深入地了解属性访问的细节。这一节的例子通过使用 Object.create() 函数创建对象来指定它的原型。我们会在第 9 章再次看到它,但是,每次使用 new 创建类的实例时,都会创建一个从原型对象继承属性的对象。

假设要查询对象 o 的属性 x。如果 o 中不存在 x 名称的自由属性,那么将会继续在 o 的原型对象中查询属性 x。如果原型对象中也没有 x,但这个原型对象也有原型,那么继续在这个原型对象的原型上执行查询,直到找到 x 或者查找到一个原型是 null 的对象为止。可以看到,对象的原型属性构成了一个“链”,通过这个“链”可以实现属性的继承。

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(这个属性不是继承来的),那么这个赋值操作只改变这个已有属性 x 的值。否则,赋值操作给 o 添加一个新属性 x。如果之前 o 继承自属性 x,那么这个继承的属性就被新创建的同名属性覆盖了。

属性赋值操作检查原型链只是判断是否允许赋值操作。例如,如果 o 继承自一个只读属性 x,那么赋值操作是不允许的(§6.3.3 将对此进行详细讨论)。 如果允许属性赋值操作,它也总是在原始对象上创建属性或对已有的属性赋值,而不会去修改原型链。在 JavaScript 中,只有在查询属性时才会体会到继承的存在,而设置属性则和继承无关,这是 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。回想一下我们的 book 对象有属性“sub-title”,而没有属性“subtitle”:

book.subtitle    // => undefined: property doesn't exist

但是,如果对象不存在,那么试图查询这个不存在的对象的属性就会报错。null 和 undefined 值都没有属性,因此查询这些值的属性会报错,接上例:

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

如果 . 的左边是 null 或 undefined 时,其属性表达式会失败。所以当写一个像 book.author.surname 一样的表达式时,如果你不确定 book 和 book.author 确实被定义就要小心了。下面提供了两种避免出错的方法:

// A verbose and explicit technique
let surname = undefined;
if (book) {
    if (book.author) {
        surname = book.author.surname;
    }
}

// A concise and idiomatic alternative to get surname or null or undefined
surname = book && book.author && book.author.surname;

为了理解为什么这里的第二种方法可以避免类型错误异常,可以参照 §4.10.1节 中关于 && 运算符的短路行为。

如 §4.4.1 中所描述,ES2020 支持用 ?. 条件属性访问,它允许这样重写上面的赋值表达式:

let surname = book?.author?.surname;

当然,给 null 和 undefined 设置属性也会报类型错误。给其他值设置属性也不总是成功,有一些属性是只读的,不能重新赋值,有一些对象不允许新增属性。在严格模式下(§5.6.3),属性设定失败时会抛出 TypeError 异常。在非严格模式下,这些失败的处理经常没有任何反应。

尽管属性赋值成功或失败的规律看起来很简单,但要描述清楚并不容易。在这些场景下给对象 o 设置属性 p 会失败:

o 中的属性 p 是只读的:不能给只读属性重新赋值。

o 中的属性 p 是继承属性,且它是只读的:不能通过同名自有属性覆盖只读的继承属性。

o 中不存在自有属性 p:o 没有使用 setter 方法继承属性 p,并且o的可扩展性是(见 §14.2)false。如果 o 中不存在 p,而且没有 setter 方法可供调用,则 p 一定会添加至 o 中。但如果 o 不是可扩展的,那么在 o 中不能定义新属性。

6.4 删除属性

删除运算符(§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 运算符只删除自有属性,不删除继承属性。(想要删除一个继承属性,必须从定义这个属性的原型对象上删除它。这会影响所有继承这个原型的对象。)

如果删除成功或删除没有任何影响时删除表达式计算结果是 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 的属性。某些内置对象的属性是不可配置的,比如通过变量声明和函数声明创建的全局对象的属性。在严格模式中,删除一个不可配置属性会报一个类型错误。在非严格模式中,在这些情况下的 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

然而在严格模式中,delete 后跟随一个非法的操作数(比如 x),则会报一个语法错误,因此必须显式指定对象及其属性:

delete x;               // SyntaxError in strict mode
delete globalThis.x;    // This works

6.5 测试属性

JavaScript 对象可以看作属性的集合,我们经常会检测集合中成员的所属关系——判断某个属性是否存在于某个对象中。可以用 in 运算符、hasOwnPreperty() 和 propertyIsEnumerable() 方法来完成这个工作,甚至仅通过属性查询也可以做到这一点。这节的例子都是用字符串作为属性名称,但是也可以用 Symbol 作为属性名(§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 运算符之外,另一种更简便的方法是使用 !== 判断一个属性是否是 undefined:

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 枚举属性

除了检测对象的属性是否存在,我们还会经常遍历对象的属性。有几种不同的方法可以做到这一点。

§5.4.5 讨论过 for/in 循环,其可以在循环体中遍历指定对象中所有可枚举的属性(包括自有属性和继承的属性),把属性名称赋值给循环变量。对象继承的 内置方法不可枚举的,但在代码中给对象添加的属性都是可枚举的(除非用下文中提到的一个方法将它们转换为不可枚举的)。例如:

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 中有例子使用 for/of 循环 Object.keys()。

6.6.1 属性枚举顺序

ES6 正式定义元素的自有属性的枚举顺序。Object.keys()、Object.getOwnPropertyNames()、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() 需要两个或多个对象作为其实参。它修改并返回第一个实参,即目标对象,但不会改变第二个或任何后续参数,这些参数是源对象。对于每个源对象,它将该对象的可枚举自有属性(包括名称为 Symbol 的属性)复制到目标对象中。它按源对象在实参列表顺序中的顺序处理,所以第一个源对象中的属性会重写在目标对象中的同名属性,然后以第二个源对象中的同名属性(如果有第二个源对象)再次重写第一个源对象重写后的属性。

Object.assign() 通过普通属性的 get 和 set 操作复制属性,因此,如果源对象具有 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 序列化对象

对象序列化(serialization)是指将对象的状态转换为字符串,也可将字符串还原为对象。函数 JSON.stringify() 和 JSON.parse() 用来序列化和还原 JavaScript 对象。这些方法都使用 JSON 作为数据交换格式,JSON 的全称是“JavaScript Object Notation”——JavaScript 对象表示法,它的语法和 JavaScript 对象与数组字面量的语法非常相近:

let o = {x: 1, y: {z: [false, null, ""]}}; // Define a test object
let s = JSON.stringify(o);   // s == '{"x":1,"y":{"z":[false,null,""]}}'
let p = JSON.parse(s);       // p == {x: 1, y: {z: [false, null, ""]}}

JSON 的语法是 JavaScript 语法的子集,它并不能表示 JavaScript 里的所有值。支持对象、数组、字符串、无穷大数字、true、false 和 null,并且它们可以序列化和还原。NaN、Infinity 和 -Infinity 序列化的结果是 null,日期对象序列化的结果是 ISO 格式的日期字符串(参照 Date.toJSON() 函数),但 JSON.parse() 依然保留它们的字符串形态,而不会将它们还原为原始日期对象。函数、RegExp、Error 对象和 undefined 值不能序列化和还原。JSON.stringify() 只能序列化对象可枚举的自有属性。对于一个不能序列化的属性来说,在序列化后的输出字符串中会将这个属性省略掉。JSON.stringify() 和 JSON.parse() 都可以接收第二个可选实参,通过传入需要序列化或还原的属性列表来定制自定义的序列化或还原操作。§11.6 有关于这些函数的详细文档。

6.9 对象方法

上文已经讨论过,所有的 JavaScript 对象都从 Object.prototype 继承属性(除了那些不通过原型显式创建的对象)。这些继承属性主要是方法,因为 JavaScript 程序员普遍对继承方法更感兴趣。例如我们已经见过的 hasOwnProperty() 和 propertyIsEnumerable() 方法。(并且我们也已经提到了一小部分定义在对象构造函数中的静态函数,像 Object.create() 和 Object.keys()。)本节介绍在 Object.prototype 上定义的少数通用对象方法,但是这些方法经常会被更专业的实现所取代。在下面的各节中,我们将展示在单个对象上定义这些方法的示例。在第 9 章中,将学习如何更常规化地为整个对象类定义这些方法。

6.9.1 toString() 方法

toString() 方法没有实参,它将返回一个表示调用这个方法的对象值的字符串。在需要将对象转换为字符串的时候,JavaScript 都会调用这个方法。比如,当使用 + 运算符连接一个字符串和一个对象时或者在希望使用字符串的方法中使用了对象时都会调用 toString()。

默认的 toString() 方法的返回值带有的信息量很少(尽管它在检测对象的类型时非常有用,参照 §14.4.3),例如,下面这行代码的计算结果为字符串”[object Object]”:

let s = { x: 1, y: 1 }.toString();  // s == "[object Object]"

由于默认的 toString() 方法并不会输出很多有用的信息,因此很多类都带有自定义的 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

在 §11.7 的国际化类中,toLocaleString() 方法的实现是非常有用的。

6.9.3 valueOf() 方法

valueOf() 方法和 toString() 方法非常类似,但往往当 JavaScript 需要将对象转换为某种原始值而非字符串的时候才会调用它,尤其是转换为数字的时候。如果在需要使用原始值的上下文中使用了对象,JavaScript 就会自动调用这个方法。默认的 valueOf() 方法不足为奇,但有些内置类自定义了 valueOf() 方法. Date 类定义 valueOf() 将日期转化成数值型,并且这允许 Date 对象使用 < 和 > 按时间先手顺序比较。可以对 point 对象做同样的事,定义一个 valueOf() 方法返回原点到点的距离:

let point = {
    x: 3,
    y: 4,
    valueOf: function() { return Math.hypot(this.x, this.y); }
};
Number(point)  // => 5: valueOf() is used for conversions to numbers
point > 4      // => true
point > 5      // => false
point < 6      // => true

6.9.4 toJSON() 方法

Object.prototype 实际上没有定义 toJSON() 方法,但对于需要执行序列化的对象来说,JSON.stringify() 方法(见 §6.8)会调用 toJSON() 方法。如果在待序列化的对象中存在这 个方法,则调用它,返回值即是序列化的结果,而不是原始的对象。Date 类(§11.4)定义了 toJSON() 方法返回日期的序列化字符串。我们可以这样对 point 对象做同样的事:

let point = {
    x: 1,
    y: 2,
    toString: function() { return `(${this.x}, ${this.y})`; },
    toJSON: function() { return this.toString(); }
};
JSON.stringify([point])   // => '["(1, 2)"]'

6.10 扩展的对象字面量语法

JavaScript 的最新版本扩展了许多有用的对象字面量相关的语法。以下小节解释这些扩展。

6.10.1 速记属性

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

let x = 1, y = 2;
let o = {
    x: x,
    y: y
};

在 ES6 之后,可以删除标识符的冒号和一个副本,最终使用更简单的代码:

let x = 1, y = 2;
let o = { x, y };
o.x + o.y  // => 3

6.10.2 计算属性名

有时需要创建具有特定属性的对象,但该属性的名称不是可以在源代码中键入的编译时常量。相反,需要的属性名称存储在变量中,或者是调用的函数的返回值。不能对此类属性使用基本对象字面量。而必须创建一个对象,通过额外的步骤,添加所需的属性:

const PROPERTY_NAME = "p1";
function computePropertyName() { return "p" + 2; }

let o = {};
o[PROPERTY_NAME] = 1;
o[computePropertyName()] = 2;

使用称为计算属性的 ES6 特性设置这样的对象要简单得多,该功能允许从前面的代码写入方括内并直接移动到对象字面量中:

const PROPERTY_NAME = "p1";
function computePropertyName() { return "p" + 2; }

let p = {
    [PROPERTY_NAME]: 1,
    [computePropertyName()]: 2
};

p.p1 + p.p2 // => 3

使用这种新语法,方括号将其分隔成 JavaScript 表达式。计算该表达式,并将结果值(如有必要转换为字符串)用作属性名称。

可能想要使用计算属性的一个情况是,有一个 JavaScript 代码库,该库希望传递具有一组特定属性的对象,并且这些属性的名称定义为该库中的常量。如果要编写代码以创建将传递给该库的对象,可以硬编码属性名称,但如果在任何地方键入错误的属性名称,则存在错误风险;如果库的新版本更改了所需的属性名称,则存在版本不匹配问题的风险。相反,可能会发现,使用计算属性语法与库定义的属性名称常量时,它使代码更加健壮。

6.10.3 Symbol 作属性名

计算属性语法启用了另一个非常重要的对象字面量特性。在 ES6 之后,属性名称可以是字符串或 Symbol。如果将 Symbol 分配给变量或常量,则可以使用计算属性语法将该 Symbol 用作属性名称:

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,这意味着 Symbol 适合创建唯一的属性名称。通过调用 Symbol() 工厂函数创建新 Symbol。(Symbol 是原始值,而不是对象,因此 Symbol() 不是使用 new 调用的构造函数。)Symbol() 返回的值不等于任何其他 Symbol 或其他值。可以将字符串传递给 Symbol(),当 Symbol 转换为字符串时,将使用此字符串。但是,这只是一个调试帮助:使用同一字符串参数创建的两个 Symbol 仍然彼此不同。

Symbol 的要点不是安全性,而是为 JavaScript 对象定义一个安全的扩展机制。如果从第三方代码获取对象,您无法控制该对象,并且需要向该对象添加自己的一些属性,但希望确保属性不会与对象上可能存在的任何属性冲突,可以安全地使用 Symbol 作为属性名称。如果这样做,还可以确信第三方代码不会意外更改 Symbol 命名的属性。(当然,该第三方代码可以使用 Object.getOwnPropertySymbols() 来发现你使用的 Symbol,然后可以更改或删除你的属性。这就是为什么符号不是安全机制。)

6.10.4 展开运算符

在 ES2018 之后,可以使用展开运算符 ... 将现有的对象中的属性复制到新的对象中:

let position = { x: 0, y: 0 };
let dimensions = { width: 100, height: 75 };
let rect = { ...position, ...dimensions };
rect.x + rect.y + rect.width + rect.height // => 175

在此代码中,position 和 dimensions 对象的属性被展开到 rect 对象字面量中,就像它们以字面量的方式写入这些大括号中一样。请注意,... 语法通常称为展开运算符,但在任何情况下都不是真正的 JavaScript 运算符。相反,它是一种特殊情况下语法,仅在对象文本中可用。(... 在别的 JavaScript 上下文中有其他用途,但是对象字面量上下文中只有这一种用法。)

如果展开的目标对象和源对象中具有相同的名称,则该属性的值将是位置处于后面的值:

let o = { x: 1 };
let p = { x: 0, ...o };
p.x   // => 1: the value from object o overrides the initial value
let q = { ...o, x: 2 };
q.x   // => 2: the value 2 overrides the previous value from o.

还有注意展开运算符只展开对象的自有属性,不展开继承属性:

let o = Object.create({x: 1}); // o inherits the property x
let p = { ...o };
p.x                            // => undefined

最后,值得注意的是,虽然展开运算符在代码中只是三个小点,但它对 JavaScript 解释器来说可以代表大量的工作。如果对象具有 n 个属性,则将这些属性分散到另一个对象的过程很可能是 O(n) 操作。这意味着,如果你发现自己在使用 ... 在循环或递归函数中,类似将数据累积到一个大对象中的方法,您可能正在编写一个低效的 O(n2) 算法,该算法不会随着 n 变大而扩展。

6.10.5 速记方法

当函数被定义为对象的属性时,我们称该函数为方法(我们将在第 8 章和第 9 章中对方法有更多的描述)。在 ES6 之前,在对象字面量中用函数定义表达式定义一个方法和在对象中定义其他属性一样:

let square = {
    area: function() { return this.side * this.side; },
    side: 10
};
square.area() // => 100

但是,在 ES6 中,对象字面量语法(以及我们将在第 9 章中看到的类定义语法)已扩展成允许省略函数关键字和冒号的快捷方式,可以写成这样的代码:

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](x) { return x + 2; },
    [symbol](x) { return x + 3; }
};
weirdMethods["method With Spaces"](1)  // => 2
weirdMethods[METHOD_NAME](1)           // => 3
weirdMethods[symbol](1)                // => 4

使用 Symbol 作为方法名称并不像看起来那么奇怪。为了使对象可迭代(因此它可以与 for/of 循环一起使用),必须定义一个具有符号名称 Symbol.iterator 的方法,并且在第 12 章中有这样做的示例。

6.10.6 属性 Getter 和 Setter 方法

到目前为止,本节我们所讨论的所有的对象都是具有名称和普通值的数据属性。JavaScript 还支持存取器属性,这些属性没有单个值,而是具有一个或两个存取器方法:getter 和或是或 setter。

当程序查询存取器属性的值时,JavaScript 调用 getter 方法(无实参)。这个方法的返回值就是属性存取表达式的值。当程序设置一个存取器属性的值时, JavaScript 调用 setter 方法,将赋值表达式右侧的值当做参数传入 setter。从某种意义上讲,这个方法负责“设置”属性值。可以忽略 setter 方法的返回值。

如果属性同时有 getter 和 setter 方法,则它是一个可读写属性。如果它只含有 getter 方法,它是一个只读属性。如果它只有 setter 方法,它是一个只可写属性(这对一个数据属性来说是不可能的),如果尝试去读它,计算结果永远是 undefined。

存储器属性可以通过表达式在对象字面量语法中定义(不像我们在这里看到的其他 ES6 扩展,getter 和 setter 是 ES5 中的内容):

let o = {
    // An ordinary data property
    dataProp: value,

    // An accessor property defined as a pair of functions.
    get accessorProp() { return this.dataProp; },
    set accessorProp(value) { this.dataProp = value; }
};

存储器属性定义为名称与属性名称相同的一个或两个方法。这些方法看起来像使用 ES6 速记定义的普通方法,只不过 getter 和 setter 定义使用 get 或 set 前缀。(在 ES6 中,在定义 getter 和 setter 时还可以使用计算属性名称。只需将带方括号的表达式替换属性名称即可。

上面定义的存储器方法只需获取并设置数据属性的值,没有理由将存储器属性替换数据属性。但作为一个更有趣的示例,请考虑以下表示 2D 笛卡尔点的对象。它具有表示点的 x 和 y 坐标的普通数据属性,并且具有提供点等效极坐标的存储器属性:

let p = {
    // x and y are regular read-write data properties.
    x: 1.0,
    y: 1.0,

    // r is a read-write accessor property with getter and setter.
    // Don't forget to put a comma after accessor methods.
    get r() { return Math.hypot(this.x, this.y); },
    set r(newvalue) {
        let oldvalue = Math.hypot(this.x, this.y);
        let ratio = newvalue/oldvalue;
        this.x *= ratio;
        this.y *= ratio;
    },

    // theta is a read-only accessor property with getter only.
    get theta() { return Math.atan2(this.y, this.x); }
};
p.r     // => Math.SQRT2
p.theta // => Math.PI / 4

请注意,在本示例中的 getter 和 setter 中使用 this 关键字。JavaScript 以对象的方法的方式调用这些函数,这意味着在函数体中,this 指的是点对象 p。因此,r 属性的 getter 方法通过 this.x 和 this.y 获取到 x 和 y 属性的引用。方法以及 this 关键字在 §8.2.2 中详细介绍。

存储器属性是可继承的,就像数据属性一样,因此可以使用上面定义的对象 p 作为其他点的原型。可以为新对象提供自有的 x 和 y 属性,它们将继承 r 和 theta 属性:

let q = Object.create(p); // A new object that inherits getters and setters
q.x = 3; q.y = 4;         // Create q's own data properties
q.r                       // => 5: the inherited accessor properties work
q.theta                   // => Math.atan2(4, 3)

上述代码使用存储器属性定义一个 API,该 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 的非原始值都是对象。这包括数组和函数,这是接下来两章的主题。