对象是 JavaScript 最基本的数据类型,详细了解对象的工作机制非常重要。这篇文章将学习 JavaScript 的对象。
对象简介
对象是一种复合值,它汇聚多个值(原始值或其他对象)并允许我们按名字存储和获取这些值。对象是一个属性的无序集合,每个属性都有名字和值:
- 属性名通常是字符串,也可以是符号
- 除了维持自己的属性之外,JavaScript 对象也可以从其他对象继承属性,这个其他对象称为其
原型
- JavaScript 对象是动态的,即可以动态添加和删除属性
- 对象是可修改的,是按引用操作而不是按值操作的
在 JavaScript 中,任何不是字符串、数值、符号、布尔值、null、undefined 的值都是对象。即使字符串、数值和布尔值不是对象,它们的行为也类似不可修改的对象。
有时候区分直接定义在对象上的属性和那些从原型对象上继承的属性很重要。JavaScript 使用术语 自有属性
指代非继承属性。
属性有一个名字和一个值。除了名字和值之外,每个属性还有 3 个属性特性(property attribute):
- writable(可写)特性指定是否可以设置属性的值
- enumerable(可枚举)特性指定是否可以在
for/in
循环中返回属性的名字 - configurable(可配置)特性指定是否可以删除属性,以及是否可修改其特性
很多 JavaScript 内置对象拥有只读、不可枚举或不可配置的属性。不过,默认情况下,我们所创建对象的所有属性都是可写、可枚举和可配置的。
创建对象
对象可以通过对象字面量、new 关键字和 Object.create() 函数来创建。
对象字面量
创建对象最简单的方式是在 JavaScript 代码中直接包含对象字面量。对象字面量的最简单形式是包含在一对花括号中的一组逗号分隔的 名:值
对。
- 属性名是 JavaScript 标识符或字符串字面量(允许空字符串),还可以是 Symbol
- 属性值是任何 JavaScript 表达式,它的值会变成属性值
1 | let empty = {}; |
对象字面量最后一个属性后面的逗号是合法的,有些编程风格指南鼓励添加这些逗号,以便将来在对象字面量末尾再增加新属性时不会导致语法错误。
对象字面量是一个表达式,每次求值都会创建并初始化一个新的、不一样的对象。字面量每次被求值的时候,它的每个属性的值也会被求值。
使用 new 创建对象
new 操作符用于创建和初始化一个新对象。new 关键字后面必须跟一个函数调用。以这种方式使用的函数被称为构造函数(constructor),目的是初始化新创建的对象。
1 | > let o = new Object() |
除了内置的构造函数,我们经常需要定义自己的构造函数来初始化新创建的对象
原型
几乎每个 JavaScript 对象都有另一个与之关联的对象。这另一个对象被称为原型(prototype),JavaScript 对象从这个原型继承属性。
- 通过对象字面量创建的所有对象都有相同的原型对象,在 JavaScript 代码中可以通过
Object.prototype
引用这个原型对象 - 使用 new 关键字和构造函数调用创建的对象,使用构造函数 prototype 属性的值作为它们的原型。
- 使用
new Object()
创建的对象继承自Object.prototype
,与通过 {} 创建的对象一样 - 通过
new Array()
创建的对象以Array.prototype
为原型 - 通过
new Date()
创建的对象以Date.prototype
为原型
- 使用
几乎所有对象都有原型,但只有少数对象有 prototype 属性。正是这些有 prototype 属性的对象为所有其他对象定义了原型。
Object.prototype
是为数不多的没有原型的对象,因为它不继承任何属性。其他原型对象都是常规对象,都有自己的原型。
- 多数内置构造函数(和多数用户定义的构造函数)的原型都继承自
Object.prototype
- 例如
Date.prototype
从Object.prototype
继承属性,因此通过new Date()
创建的日期对象从Date.prototype
和Object.prototype
继承属性。这种原型对象链接起来的序列被称为原型链
Objcet.create()
Object.create()
用于创建一个新对象,使用其第一个参数作为新对象的原型:
1 | > let o = Object.create({x:1, y:2}) // o 继承属性 x 和 y |
传入 null 可以创建一个没有原型的新对象。不过,这样创建的新对象不会继承任何东西,连 toString()
这种基本方法都没有。
如果想创建一个普通的空对象(类似{}或new Object()返回的对象),传入 Object.prototype
:
1 | > let o2 = Object.create(Object.prototype) |
能够以任意原型创建新对象是一种非常强大的技术,Object.create()
的一个用途是防止对象被某个第三方库函数意外(但非恶意)修改。这种情况下,不要直接把对象传给库函数,而要传入一个继承自它的对象。如果函数读取这个对象的属性,可以读到继承的值。而如果它设置这个对象的属性,则修改不会影响原始对象。
1 | let o = {x: "don't change this value"}; |
查询和设置属性
要获取或设置属性的值,可以使用 .
或 []
操作符:
- 操作符左边应该是一个表达式,其值为一个对象
- 如果使用点操作符,右边必须是一个命名属性的简单标识符
- 如果使用方括号,方括号中的值必须是一个表达式,其结果为包含目的属性名的字符串(或者可以转换为字符串或符号的值)
1 | > book.author |
JavaScript 对象是关联数组,book.author
和 book["author"]
是一样的。JavaScript 是松散类型语言。JavaScript 程序可以为任意对象创建任意数量的属性:
- 在使用
.
操作符访问对象的属性时,属性名是通过标识符来表示的。标识符必须直接书写在 JavaScript 程序中(这也就要求我们提前知道这些属性名称),它们不是一种数据类型,因此不能被程序操作。 - 在通过方括号
[]
这种数组表示法访问对象属性时,属性名是通过字符串来表示的。因此可以在程序运行期间动态修改属性名
1 | let addr = ""; |
这个例子展示了使用数组表示法通过字符串表达式访问对象属性的灵活性。字符串是一种数据类型,可以在运行是修改,而标识符是静态的,必须硬编码到程序中。
JavaScript 对象经常作为关联数组使用。ES6 及其之后版本,使用 Map 类通常比使用普通对象更好。
继承
JavaScript对象有一组 自有属性
,同时也从它们的原型对象继承一组属性。假设要从对象 o 中查询属性 x:
- 如果 o 没有叫这个名字的自有属性,则会从 o 的原型对象查询属性 x
- 如果原型对象也没有叫这个名字的自有属性,但它有自己的原型,则会继续查询这个原型的原型
- 这个过程一直持续,直至找到属性x或者查询到一个原型为null的对象。
假设你为对象 o 的 x 属性赋值:
- 如果 o 有一个名为 x 的自有(非继承)属性,这次赋值就会修改已有 x 属性的值
- 否则赋值会在对象 o 上创建一个名为 x 的新属性。如果 o 之前继承了属性 x,那么现在这个继承的属性会被新创建的同名属性隐藏
属性赋值查询原型链只为确定是否允许赋值:
- 如果 o 继承了一个名为 x 的只读属性,则不允许赋值
- 如果允许赋值,则只会在原始对象上创建或设置属性,而不会修改原型链中的对象
查询属性时会用到原型链,而设置属性时不影响原型链是一个重要的 JavaScript 特性,利用这一点,可以选择性地覆盖继承的属性。
1 | > let o1 = {r: 1} |
属性赋值要么失败要么在原始对象上创建或设置属性的规则有一个例外。如果 o 继承了属性 x,而该属性是一个通过设置方法定义的访问器属性,那么就会调用该设置方法而不会在 o 上创建新属性 x。要注意,此时会在对象 o 上而不是在定义该属性的原型对象上调用设置方法。因此如果这个设置方法定义了别的属性,那也会在 o 上定义同样的属性,但仍然不会修改原型链。
属性访问错误
属性访问表达式并不总是会返回或设置值:
- 查询不存在的属性不是错误。如果在 o 的自有属性和继承属性中都没找到属性 x,则属性访问表达式 o.x 的求值结果为 undefined
- 然而,查询不存在对象的属性则是错误。因为 null 和 undefined 值没有属性,查询这两个值的属性是错误(TypeError,属性访问表达式会失败)。使用
?.
条件式属性访问可以避免该错误:
1 | let surname = book?.author?.surname; |
尝试在 null 或 undefined 上设置属性也会导致 TypeError。而且,尝试在其他值上设置属性也不总是会成功,因为有些属性是只读的,不能设置,而有些对象不允许添加新属性。在严格模式下,只要尝试设置属性失败就会抛出 TypeError。在非严格模式下,这些失败通常是静默失败。
尝试在对象 o 上设置属性 p 在以下情况下会失败:
- o 有一个只读自有属性 p:不可能设置只读属性
- o 有一个只读继承属性 p:不可能用同名自有属性隐藏只读继承属性
- o 没有自有属性 p,o 没有继承
通过设置方法定义的属性 p
,o 的 extensible 特性为 false:- 如果没有要调用的设置方法,那么 p 必须要添加到 o 上。但如果 o 不可扩展(extensible为false),则不能在它上面定义新属性
删除属性
delete 操作符用于从对象中移除属性。它唯一的操作数应该是一个属性访问表达式。注意,delete 并不操作属性的值,而是操作属性本身。
1 | delete book.author; |
delete 操作符只删除自有属性,不删除继承属性。要删除继承属性,必须从定义属性的原型对象上删除。这样做会影响继承该原型的所有对象。
- 如果 delete 操作成功或没有影响(如删除不存在的属性),则 delete 表达式求值为true
- 对非属性访问表达式(无意义地)使用 delete,同样也会求值为 true
1 | > delete o2.x |
delete 不会删除 configurable 特性为 false 的属性(通过变量声明或函数声明创建的全局对象属性、某些内置对象属性都是不可配置的)。在严格模式下,尝试删除不可配置的属性会导致 TypeError。在非严格模式下,delete 直接求值为 false。
1 | > function f() {} // 声明一个全局函数 |
在非严格模式下删除全局对象可配置的属性时,可以省略对全局对象的引用,只在 delete 操作符后面加上属性名:
1 | > globalThis.x = 1 // 创建可配置的全局属性 |
在严格模式下,如果操作数是一个像 x 这样的非限定标识符,delete 会抛出 SyntaxError,即必须写出完整的属性访问表达式:
1 | delete x; // 严格模式下抛出 SyntaxError |
测试属性
JavaScript 对象可以被想象成一组属性,实际开发中经常需要测试这组属性的成员关系。为了检查对象是否有一个给定名字的属性,可以使用 in 操作符,或者 hasOwnProperty()
、propertyIsEnumerable()
方法,或者直接查询相应属性。
- in 操作符要求左边是一个属性名,右边是一个对象。如果对象有包含相应名字的自有属性或继承属性,将返回 true
1 | > let o = {x: 1} |
- 对象的 hasOwnProperty() 方法用于测试对象是否有给定名字的属性。对继承的属性,它返回 false
1 | > o.hasOwnProperty("x") |
propertyIsEnumerable()
方法细化了hasOwnProperty()
测试。如果传入的命名属性是自有属性且这个属性的 enumerable 特性为 true,这个方法会返回 true。某些内置属性是不可枚举的。使用常规 JavaScript 代码创建的属性都是可枚举的
1 | > o.propertyIsEnumerable("x") |
除了使用 in 操作符,通常简单的属性查询配合 !==
确保其不是未定义的就可以了:
1 | > o.x !== undefined |
但是 in 可以区分不存在的属性和存在但被设置为undefined的属性,这是 !==
无法做到的:
1 | > let o2 = {x: undefined} |
枚举属性
为了遍历或获取对象的所有属性,有如下方法:
for/in
循环对指定对象的每个可枚举(自有或继承)属性都会运行一次循环体,将属性的名字赋给循环变量。对象继承的内置方法是不可枚举的,但你的代码添加给对象的属性默认是可枚举的- 为防止通过 for/in 枚举继承的属性,可以在循环体内添加一个显式测试
hasOwnProperty()
- 为防止通过 for/in 枚举继承的属性,可以在循环体内添加一个显式测试
1 | > let o = {x:1, y:2, z:3} |
- 除了使用
for/in
循环,有时候可以先获取对象所有属性名的数组,然后再通过for/of
循环遍历该数组- Object.keys() 返回对象可枚举自有属性名的数组。不包含不可枚举属性、继承属性或名字是符号的属性
- Object.getOwnPropertyNames() 与 Object.keys() 类似,但会额外返回不可枚举的自有属性名,只要它们的名字是字符串
- Object.getOwnPropertySymbols() 返回名字是符号的自有属性,无论是否可枚举
- Reflect.ownKeys() 返回所有属性名,包括可枚举和不可枚举属性,以及字符串属性和符号属性
ES6 正式定义了枚举对象自有属性的顺序,Object.keys()
、Object.getOwnPropertyNames()
Obejct.getOwnPropertySymbols()
、Reflect.ownKeys()
等方法都按照如下顺序列出属性:
- 先列出名字为非负整数的字符串属性,按照数值顺序从最小到最大。这条规则意味着数组和类数组对象的属性会按照顺序被枚举
- 在列出类数组索引的所有属性之后,再列出所有剩下的字符串名字(包括看起来像负数或浮点数的名字)的属性。这些属性按照它们添加到对象的先后顺序列出
- 最后,名字为符号对象的属性按照它们添加到对象的先后顺序列出
for/in
循环的枚举顺序并不像上述枚举函数那么严格,但实现通常会按照上面描述的顺序枚举自有属性,然后再沿原型链上溯,以同样的顺序枚举每个原型对象的属性。不过要注意,如果已经有同名属性被枚举过了,甚至如果有一个同名属性是不可枚举的,那这个属性就不会枚举了。
扩展对象
在 JavaScript 程序中,把一个对象的属性复制到另一个对象上是很常见的。
1 | > let target = {x: 1} |
ES6 定义了 Object.assign()
方法,它接收两个或多个对象作为其参数:
- 它会修改并返回第一个参数,第一个参数是目标对象
- 之后的参数都是来源对象
- 对于每个来源对象,它会把该对象的可枚举自有属性(包括名字为符号的属性)复制到目标对象
- 它按照参数列表顺序逐个处理来源对象,第一个来源对象的属性会覆盖目标对象的同名属性,而第二个来源对象(如果有)的属性会覆盖第一个来源对象的同名属性,依次类推
- 该函数以普通的属性 get 和 set 方法来复制属性。因此如果源对象存在 getter 方法、或者目标对象存在 setter 方法,这些方法会在复制期间被调用,但这些方法本身不会被复制
如下是 Object.assign()
的一个典型使用方法:先把默认值复制到新对象中,然后再使用 o 的属性覆盖那些默认值:
1 | o = Object.assign({}, default, o) |
使用扩展操作符 … 也可以表达这种对象复制和覆盖操作:
1 | o = {...default, ...o}; |
为了避免额外的对象创建和复制,也可以自己实现一个 merge
函数,只复制那些不存在的属性:
1 | function merge(target, ...sources) { |
序列化对象
对象序列化(serialization)是把对象的状态转换为字符串的过程,之后可以从中恢复对象的状态。函数 JSON.stringify()
和 JSON.parse()
用于序列化和恢复 JavaScript 对象。这两个函数使用 JSON 数据交换格式。JSON 表示 JavaScript Object Notation(JavaScript对象表示法),其语法与 JavaScript 对象和数组字面量非常类似
1 | > let o = {x: 1, y:{z:[false, null, ""]}} |
JSON 语法是 JavaScript 语法的子集,不能表示所有 JavaScript 的值
- 可以序列化和恢复的值包括对象、数组、字符串、有限数值、true、false 和 null
NaN
、Infinity
和-Infinity
会被序列化为 null- 日期对象会被序列化为 ISO 格式的日期字符串,但 JSON.parse() 会保持其字符串形式,不会恢复原始的日期对象
- 函数、RegExp 和 Error 对象以及 undefined 值不能被序列化或恢复
1 | > let o2 = {x: NaN} |
JSON.stringify()
只序列化对象的可枚举自有属性。如果属性值无法序列化,则该属性会从输出的字符串中删除。JSON.stringify() 和 JSON.parse() 都接收可选的第二个参数,用于自定义序列化及恢复操作。
对象方法
所有 JavaScript 对象(除了那些显式创建为没有原型的)都从 Object.prototype 继承属性,这些继承的属性主要是方法,这里介绍 Object.prototype
上的几个通用方法:
toString 方法
toString() 方法不接收参数,返回表示调用它的对象的值的字符串。每当需要把一个对象转换为字符串时, JavaScript 就会调用该对象的这个方法。
默认的 toString() 方法实现并不会提供太多信息:
1 | > let o = {x:1, y:2} |
由于这个默认方法不会显示太有用的信息,很多类都会重新定义自己的 toString() 方法。
1 | > let o = {x:1, y:2, toString: function() { return `(x:${this.x}, y:${this.y})`}} |
toLocaleString 方法
除了基本的 toString()
方法之外,对象也都有一个 toLocaleString()
方法。这个方法的用途是返回对象的本地化字符串表示。Object 定义的默认 toLocaleString() 方法本身没有实现任何本地化,而是简单地调用 toString() 并返回该值。
Date 类就实现了自己的 toLocaleString()
方法,该方法返回一个本地化日期字符串。
1 | > let d = new Date() |
valueOf 方法
valueOf() 方法与 toString() 方法很相似,但会在 JavaScript 需要把对象转换为某些非字符串原始值(通常是数值)时被调用。默认的 valueOf() 方法并没有做什么,因此一些内置类定义了自己的 valueOf() 方法。Date类 定义的 valueOf() 方法可以将日期转换为数值,这样就让日期对象可以通过 < 和 > 操作符来进行比较。
toJSON 方法
Object.prototype
实际上并未定义 toJSON()
方法,但 JSON.stringify()
方法会从要序列化的对象上寻找 toJSON()
方法。如果要序列化的对象上存在这个方法,就会调用它,然后序列化该方法的返回值,而不是原始对象。
对象字面量扩展语法
最近的 JavaScript 版本从几个方面扩展了对象字面量语法。下面将讲解这些扩展。
简写属性
假设变量 x 和 y 中保存着值,而你想创建一个具有属性 x 和 y 且值分别为相应变量值的对象。在 ES6 及其之后版本,可以删除简化基本的对象字面量语法,删除标识符名称及其冒号:
1 | > let x = 1, y = 2; |
计算的属性名
有时候,我们需要创建一个具有特定属性的对象,但该属性的名字不是编译时可以直接写在源代码中的常量。相反,你需要的这个属性名保存在一个变量里,或者是调用的某个函数的返回值。不能对这种属性使用基本对象字面量。为此,必须先创建一个对象,然后再为它添加想要的属性:
1 | const PROPERTY_NAME = "p1"; |
使用 ES6 称为计算属性的特性可以更简单地创建类似对象,只需要直接将方括号放到对象字面量中即可:
1 | const PROPERTY_NAME = "p1"; |
有了这个语法,就可以在方括号中加入任意 JavaScript 表达式。对这个表达式求值得到的结果(必要时转换为字符串)会用作属性的名字。
符号作为属性名
计算属性语法也让另一个非常重要的对象字面量特性成为可能。在 ES6 及之后,属性名可以是字符串或符号。如果把符号赋值给一个变量或常量,那么可以使用计算属性语法将该符号作为属性名:
1 | > const extension = Symbol("test") |
符号是不透明值。除了用作属性名之外,不能用它们做任何事情。不过,每个符号都与其他符号不同,这意味着符号非常适合用于创建唯一属性名。创建新符号需要调用 Symbol() 工厂函数(符号是原始值,不是对象,因此 Symbol() 不是构造函数,不能使用 new 调用)。
Symbol() 返回的值不等于任何其他符号或其他值。可以给 Symbol() 传一个字符串,在把符号转换为字符串时会用到这个字符串。但这个字符串的作用仅限于辅助调试,使用相同字符串参数创建的两个符号依旧是不同的符号。
使用符号不是为了安全,而是为 JavaScript 对象定义安全的扩展机制。因为你可以放心地使用符号作为属性名,而不用担心冲突问题。
扩展操作符
在 ES2018 及之后,可以在对象字面量中使用扩展操作符 ...
把已有对象的属性复制到新对象中:
1 | > let o1 = {x:1, y:2} |
这个 ...
语法经常被称为扩展操作符,但却不是真正意义上的 JavaScript 操作符。实际上,它是仅在对象字面量中有效的一种特殊语法(在其他 JavaScript 上下文中,三个点有其他用途)。
- 如果扩展对象和被扩展对象有一个同名属性,那么这个属性的值由后面的属性值决定
- 扩展操作符只能复制对象的自有属性,不复制任何继承属性:
- 要额外注意扩展操作符可能带来的性能问题:如果对象有 n 个属性,把这个属性扩展到另一个对象可能是一种
O(n)
操作
1 | > let o3 = {...o1, ...o2, a:100} |
简写方法
在把函数定义为对象属性时,我们称该函数为方法。在 ES6 以前,需要像定义对象的其他属性一样,通过函数定义表达式在对象字面量中定义一个方法。但在 ES6 中,对象字面量语法经过扩展,允许一种省略 function 关键字和冒号的简写方法,结果代码如下:
1 | let square = { |
这种简写语法让人一看便知 area2
是方法。
在使用这种简写语法来写方法时,属性名可以是对象字面量允许的任何形式。除了像上面的 area2
一样的常规 JavaScript 标识符之外,也可以使用字符串字面量和计算的属性名,包括符号属性名:
属性的获取方法(getter)和设置方法(setter)
目前我们讨论的所有对象属性都是数据属性,即有一个名字和一个普通的值。除了数据属性之外,JavaScript 还支持为对象定义 访问器属性
(accessor property)。这种属性不是一个值,而是一个或两个访问器方法:一个获取方法(getter)和一个设置方法(setter)。
- 当程序查询一个访问器属性的值时,JavaScript 会调用获取方法(不传参数)。这个方法的返回值就是属性访问表达式的值
- 当程序设置一个访问器属性的值时,JavaScript会调用设置方法,传入赋值语句右边的值。这个方法负责
设置
属性的值。设置方法的返回值会被忽略
如果一个属性既有获取方法也有设置方法,则该属性是一个可读写属性;如果只有一个获取方法,那它就是只读属性;如果只有一个设置方法,那它就是只写属性,读取这种属性始终得到 undefined
。
访问器属性可以通过对象字面量的一个扩展语法来定义:
1 | let o = { |
- 访问器属性是通过一个或两个方法来定义的,方法名就是属性名
- 除了前缀是 get 和 set 之外,这两个方法看起来就像用 ES6 简写语法定义的普通方法一样(在 ES6 中,也可以使用计算的属性名来定义获取方法和设置方法。只要把 get 和 set 后面的属性名替换为用方括号包含的表达式即可)
- 与数据属性一样,访问器属性也是可以继承的
使用访问器属性的典型场景包括写入属性时进行合理性检查,以及每次读取属性时返回不同的值。