ECMA-262 将对象定义为一组属性的无序集合。严格来说,这意味着对象就是一组没有特定顺序的值。对象的每个属性或方法都由一个名称来标识,这个名称映射到一个值。正因为如此(以及其他还未讨论的原因),可以把 ECMAScript 的对象想象成一张散列表,其中的内容就是一组名/值对,值可以是数据或者函数。
创建自定义对象的通常方式是创建 Object 的一个新实例,然后再给它添加属性和方法,如下例所示:
let person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";
person.sayName = function() {
console.log(this.name);
};
这个例子创建了一个名为 person 的对象,而且有三个属性(name、age 和 job)和一个方法(sayName())。sayName() 方法会显示 this.name 的值,这个属性会解析为 person.name。早期 JavaScript 开发者频繁使用这种方式创建新对象。几年后,对象字面量变成了更流行的方式。前面的例子如果使用对象字面量则可以这样写:
let person = {
name: "Nicholas",
age: 29,
job: "Software Engineer",
sayName() {
console.log(this.name);
}
};
这个例子中的 person 对象跟前面例子中的 person 对象是等价的,它们的属性和方法都一样。这些属性都有自己的特征,而这些特征决定了它们在 JavaScript 中的行为。
数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。
在像前面例子中那样将属性显式添加到对象之后,[[Configurable]]、[[Enumerable]] 和
[[Writable]] 都会被设置为 true,而 [[Value]] 特性会被设置为指定的值。比如:
let person = {
name: "Nicholas"
};
这里,我们创建了一个名为 name 的属性,并给它赋予了一个值"Nicholas"。这意味着 [[Value]] 特性会被设置为"Nicholas",之后对这个值的任何修改都会保存这个位置。
要修改属性的默认特性,就必须使用 Object.defineProperty()
方法。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改的特性,可以设置其中一个或多个值。比如:
const person = {}
Object.defineProperty(person, 'name', {
writable: false,
value: 'Nicholas'
})
console.log(person.name) // "Nicholas"
person.name = 'Greg'
console.log(person.name) // "Nicholas"
这个例子创建了一个名为 name 的属性并给它赋予了一个只读的值"Nicholas"。这个属性的值就不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性的值会抛出错误。
类似的规则也适用于创建不可配置的属性。比如:
const person = {}
Object.defineProperty(person, 'name', {
configurable: false,
value: 'Nicholas'
})
console.log(person.name) // "Nicholas"
delete person.name
console.log(person.name) // "Nicholas"
这个例子把 configurable 设置为 false,意味着这个属性不能从对象上删除。非严格模式下对这个属性调用 delete 没有效果,严格模式下会抛出错误。此外,一个属性被定义为不可配置之后,就不能再变回可配置的了。再次调用 Object.defineProperty() 并修改任何非 writable 属性会导致错误:
const person = {}
Object.defineProperty(person, 'name', {
configurable: false,
value: 'Nicholas'
})
// 抛出错误
Object.defineProperty(person, 'name', {
configurable: true,
value: 'Nicholas'
})
因此,虽然可以对同一个属性多次调用 Object.defineProperty(),但在把 configurable 设置为 false 之后就会受限制了。
在调用 Object.defineProperty() 时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false。多数情况下,可能都不需要 Object.defineProperty() 提供的这些强大的设置,但要理解 JavaScript 对象,就要理解这些概念。
访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数,不过这两个函数不是必需的。在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。访问器属性有 4 个特性描述它们的行为。
访问器属性是不能直接定义的,必须使用 Object.defineProperty()。下面是一个例子:
// 定义一个对象,包含伪私有成员 year_和公共成员 edition
const book = {
year_: 2017,
edition: 1
}
Object.defineProperty(book, 'year', {
get () {
return this.year_
},
set (newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
}
})
book.year = 2018
console.log(book.edition) // 2
在这个例子中,对象 book 有两个默认属性:year_和 edition。year_中的下划线常用来表示该属性并不希望在对象方法的外部被访问。另一个属性 year 被定义为一个访问器属性,其中获取函数简单地返回 year_的值,而设置函数会做一些计算以决定正确的版本(edition)。因此,把 year 属性修改为 2018 会导致 year_变成 2018,edition 变成 2。这是访问器属性的典型使用场景,即设置一个属性值会导致一些其他变化发生。
获取函数和设置函数不一定都要定义。只定义获取函数意味着属性是只读的,尝试修改属性会被忽略。在严格模式下,尝试写入只定义了获取函数的属性会抛出错误。类似地,只有一个设置函数的属性是不能读取的,非严格模式下读取会返回 undefined,严格模式下会抛出错误。
在不支持 Object.defineProperty() 的浏览器中没有办法修改 [[Configurable]] 或 [[Enumerable]]。
注意:在 ECMAScript 5以前,开发者会使用两个非标准的访问创建访问器属性:defineGetter() 和 defineSetter()。这两个方法最早是 Firefox 引入的,后来 Safari、Chrome 和 Opera 也实现了。
在一个对象上同时定义多个属性的可能性是非常大的。为此,ECMAScript 提供了Object.defineProperties()
方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。比如:
const book = {}
Object.defineProperties(book, {
year_: {
value: 2017
},
edition: {
value: 1
},
year: {
get () {
return this.year_
},
set (newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
}
}
})
这段代码在 book 对象上定义了两个数据属性 year_和 edition,还有一个访问器属性 year。最终的对象跟上一节示例中的一样。唯一的区别是所有属性都是同时定义的,并且数据属性的configurable、enumerable 和 writable 特性值都是 false。
使用 Object.getOwnPropertyDescriptor()
方法可以取得指定属性的属性描述符。这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。返回值是一个对象,对于访问器属性包含 configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、writable 和 value 属性。比如:
const book = {}
Object.defineProperties(book, {
year_: {
value: 2017
},
edition: {
value: 1
},
year: {
get: function () {
return this.year_
},
set: function (newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
}
}
})
let descriptor = Object.getOwnPropertyDescriptor(book, 'year_')
console.log(descriptor.value) // 2017
console.log(descriptor.configurable) // false
console.log(typeof descriptor.get) // "undefined"
let descriptor = Object.getOwnPropertyDescriptor(book, 'year')
console.log(descriptor.value) // undefined
console.log(descriptor.enumerable) // false
console.log(typeof descriptor.get) // "function"
对于数据属性 year_,value 等于原来的值,configurable 是 false,get 是 undefined。对于访问器属性 year,value 是 undefined,enumerable 是 false,get 是一个指向获取函数的指针。
ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors()
静态方法。这个方法实际上会在每个自有属性上调用 Object.getOwnPropertyDescriptor() 并在一个新对象中返回它们。对于前面的例子,使用这个静态方法会返回如下对象:
const book = {}
Object.defineProperties(book, {
year_: {
value: 2017
},
edition: {
value: 1
},
year: {
get: function () {
return this.year_
},
set: function (newValue) {
if (newValue > 2017) {
this.year_ = newValue
this.edition += newValue - 2017
}
}
}
})
console.log(Object.getOwnPropertyDescriptors(book))
// {
// edition: {
// configurable: false,
// enumerable: false,
// value: 1,
// writable: false
// },
// year: {
// configurable: false,
// enumerable: false,
// get: f(),
// set: f(newValue),
// },
// year_: {
// configurable: false,
// enumerable: false,
// value: 2017,
// writable: false
// }
// }
JavaScript 开发者经常觉得“合并”(merge)两个对象很有用。更具体地说,就是把源对象所有的本地属性一起复制到目标对象上。有时候这种操作也被称为“混入”(mixin),因为目标对象通过混入源对象的属性得到了增强。
ECMAScript 6 专门为合并对象提供了 Object.assign()
方法。这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回true)和自有(Object.hasOwnProperty() 返回 true)属性复制到目标对象。以字符串和符号为键的属性会被复制。对每个符合条件的属性,这个方法会使用源对象上的 [[Get]] 取得属性的值,然后使用目标对象上的 [[Set]] 设置属性的值。
let dest, src, result
/**
* 简单复制
*/
dest = {}
src = { id: 'src' }
result = Object.assign(dest, src)
// Object.assign 修改目标对象
// 也会返回修改后的目标对象
console.log(dest === result) // true
console.log(dest !== src) // true
console.log(result) // { id: src }
console.log(dest) // { id: src }
/**
* 多个源对象
*/
dest = {}
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' })
console.log(result) // { a: foo, b: bar }
/**
* 获取函数与设置函数
*/
dest = {
set a (val) {
console.log(`Invoked dest setter with param ${val}`)
}
}
src = {
get a () {
console.log('Invoked src getter')
return 'foo'
}
}
Object.assign(dest, src)
// 调用 src 的获取方法
// 调用 dest 的设置方法并传入参数"foo"
// 因为这里的设置函数不执行赋值操作
// 所以实际上并没有把值转移过来
console.log(dest) // { set a(val) {...} }
Object.assign() 实际上对每个源对象执行的是浅复制。如果多个源对象都有相同的属性,则使用最后一个复制的值。此外,从源对象访问器属性取得的值,比如获取函数,会作为一个静态值赋给目标对象。换句话说,不能在两个对象间转移获取函数和设置函数。
let dest, src, result
/**
* 覆盖属性
*/
dest = { id: 'dest' }
result = Object.assign(dest, { id: 'src1', a: 'foo' }, { id: 'src2', b: 'bar' })
// Object.assign 会覆盖重复的属性
console.log(result) // { id: src2, a: foo, b: bar }
// 可以通过目标对象上的设置函数观察到覆盖的过程:
dest = {
set id (x) {
console.log(x)
}
}
Object.assign(dest, { id: 'first' }, { id: 'second' }, { id: 'third' })
// first
// second
// third
/**
* 对象引用
*/
dest = {}
src = { a: {} }
Object.assign(dest, src)
// 浅复制意味着只会复制对象的引用
console.log(dest) // { a :{} }
console.log(dest.a === src.a) // true
如果赋值期间出错,则操作会中止并退出,同时抛出错误。Object.assign() 没有“回滚”之前赋值的概念,因此它是一个尽力而为、可能只会完成部分复制的方法。
let dest, src
/**
* 错误处理
*/
dest = {}
src = {
a: 'foo',
get b () {
// Object.assign()在调用这个获取函数时会抛出错误
throw new Error()
},
c: 'bar'
}
try {
Object.assign(dest, src)
} catch (e) {}
// Object.assign()没办法回滚已经完成的修改
// 因此在抛出错误之前,目标对象上已经完成的修改会继续存在:
console.log(dest) // { a: foo }
在 ECMAScript 6 之前,有些特殊情况即使是===
操作符也无能为力:
// 这些是===符合预期的情况
console.log(true === 1); // false
console.log({} === {}); // false
console.log("2" === 2); // false
// 这些情况在不同 JavaScript 引擎中表现不同,但仍被认为相等
console.log(+0 === -0); // true
console.log(+0 === 0); // true
console.log(-0 === 0); // true
// 要确定 NaN 的相等性,必须使用极为讨厌的 isNaN()
console.log(NaN === NaN); // false
console.log(isNaN(NaN)); // true
为改善这类情况,ECMAScript 6 规范新增了 Object.is()
,这个方法与===
很像,但同时也考虑到了上述边界情形。这个方法必须接收两个参数:
console.log(Object.is(true, 1)); // false
console.log(Object.is({}, {})); // false
console.log(Object.is("2", 2)); // false
// 正确的 0、-0、+0 相等/不等判定
console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false
// 正确的 NaN 相等判定
console.log(Object.is(NaN, NaN)); // true
要检查超过两个值,递归地利用相等性传递即可:
function recursivelyCheckEqual (x, ...rest) {
return Object.is(x, rest[0]) && (rest.length < 2 || recursivelyCheckEqual(...rest))
}
注意:相比于以往的替代方案,本节介绍的增强对象语法可以说是一骑绝尘。因此本章及本书会默认使用这些新语法特性。
在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。例如:
let name = 'Matt';
let person = {
name: name
};
console.log(person); // { name: 'Matt' }
为此,简写属性名语法出现了。简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键。如果没有找到同名变量,则会抛出 ReferenceError。
以下代码和之前的代码是等价的:
let name = 'Matt';
let person = {
name
};
console.log(person); // { name: 'Matt' }
代码压缩程序会在不同作用域间保留属性名,以防止找不到引用。以下面的代码为例:
function makePerson (name) {
return {
name
}
}
const person = makePerson('Matt')
console.log(person.name) // Matt
在这里,即使参数标识符只限定于函数作用域,编译器也会保留初始的 name 标识符。如果使用 Google Closure 编译器压缩,那么函数参数会被缩短,而属性名不变:
function makePerson (a) {
return {
name: a
}
}
const person = makePerson('Matt')
console.log(person.name) // Matt
在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语法来添加属性。换句话说,不能在对象字面量中直接动态命名属性。比如:
const nameKey = 'name'
const ageKey = 'age'
const jobKey = 'job'
const person = {}
person[nameKey] = 'Matt'
person[ageKey] = 27
person[jobKey] = 'Software engineer'
console.log(person) // { name: 'Matt', age: 27, job: 'Software engineer' }
有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时将其作为 JavaScript 表达式而不是字符串来求值:
const nameKey = 'name'
const ageKey = 12
const jobKey = 'job'
const person = {
[nameKey]: 'Matt',
[ageKey]: 27,
[jobKey]: 'Software engineer'
}
console.log(person) // { '12': 27, name: 'Matt', job: 'Software engineer' }
因为被当作 JavaScript 表达式求值,所以可计算属性本身可以是复杂的表达式,在实例化时再求值:
const nameKey = 'name'
const ageKey = 'age'
const jobKey = 'job'
let uniqueToken = 0
function getUniqueKey (key) {
return `${key}_${uniqueToken++}`
}
const person = {
[getUniqueKey(nameKey)]: 'Matt',
[getUniqueKey(ageKey)]: 27,
[getUniqueKey(jobKey)]: 'Software engineer'
}
console.log(person) // { name_0: 'Matt', age_1: 27, job_2: 'Software engineer' }
注意:可计算属性表达式中抛出任何错误都会中断对象创建。如果计算属性的表达式有副作用,那就要小心了,因为如果表达式抛出错误,那么之前完成的计算是不能回滚的。
在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式,如下所示:
const person = {
sayName: function (name) {
console.log(`My name is ${name}`)
}
}
person.sayName('Matt') // My name is Matt
新的简写方法的语法遵循同样的模式,但开发者要放弃给函数表达式命名(不过给作为方法的函数命名通常没什么用)。相应地,这样也可以明显缩短方法声明。
以下代码和之前的代码在行为上是等价的:
let person = {
sayName(name) {
console.log(`My name is ${name}`);
}
};
person.sayName('Matt'); // My name is Matt
简写方法名对获取函数和设置函数也是适用的:
const person = {
name_: '',
get name () {
return this.name_
},
set name (name) {
this.name_ = name
},
sayName () {
console.log(`My name is ${this.name_}`)
}
}
person.name = 'Matt'
person.sayName() // My name is Matt
简写方法名与可计算属性键相互兼容:
const methodKey = 'sayName'
const person = {
[methodKey] (name) {
console.log(`My name is ${name}`)
}
}
person.sayName('Matt') // My name is Matt
注意:简写方法名对于本章后面介绍的 ECMAScript 6 的类更有用。
ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值。
下面的例子展示了两段等价的代码,首先是不使用对象解构的:
// 不使用对象解构
const person = {
name: 'Matt',
age: 27
}
const personName = person.name
const personAge = person.age
console.log(personName) // Matt
console.log(personAge) // 27
然后,是使用对象解构的:
// 使用对象解构
const person = {
name: 'Matt',
age: 27
}
const { name: personName, age: personAge } = person
console.log(personName) // Matt
console.log(personAge) // 27
const person = {
name: 'Matt',
age: 27
}
const { name, age } = person
console.log(name) // Matt
console.log(age) // 27
const person = {
name: 'Matt',
age: 27
}
const { name, job } = person
console.log(name) // Matt
console.log(job) // undefined
const person = {
name: 'Matt',
age: 27
}
const { name, job = 'Software engineer' } = person
console.log(name) // Matt
console.log(job) // Software engineer
ToObject()
(不能在运行时环境中直接访问)把源数据结构转换为对象。这意味着在对象解构的上下文中,原始值会被当成对象。这也意味着(根据 ToObject() 的定义),null 和 undefined 不能被解构,否则会抛出错误。const { length } = 'foobar'
console.log(length) // 6
const { constructor: c } = 4
console.log(c === Number) // true
let { _ } = null // TypeError
let { _ } = undefined // TypeError
let personName, personAge
let person = {
name: 'Matt',
age: 27
};
({ name: personName, age: personAge } = person)
console.log(personName, personAge) // Matt, 27
解构对于引用嵌套的属性或赋值目标没有限制。为此,可以通过解构来复制对象属性:
const person = {
name: 'Matt',
age: 27,
job: {
title: 'Software engineer'
}
}
const personCopy = {};
({ name: personCopy.name, age: personCopy.age, job: personCopy.job } = person)
// 因为一个对象的引用被赋值给 personCopy,所以修改
// person.job 对象的属性也会影响 personCopy
person.job.title = 'Hacker'
console.log(person)
// { name: 'Matt', age: 27, job: { title: 'Hacker' } }
console.log(personCopy)
// { name: 'Matt', age: 27, job: { title: 'Hacker' } }
解构赋值可以使用嵌套结构,以匹配嵌套的属性:
const person = {
name: 'Matt',
age: 27,
job: {
title: 'Software engineer'
}
}
// 声明 title 变量并将 person.job.title 的值赋给它
const {
job: { title }
} = person
console.log(title) // Software engineer
在外层属性没有定义的情况下不能使用嵌套解构。无论源对象还是目标对象都一样:
const person = {
job: {
title: 'Software engineer'
}
}
const personCopy = {};
// foo 在源对象上是 undefined
({
foo: { bar: personCopy.bar }
} = person);
// TypeError: Cannot destructure property 'bar' of 'undefined' or 'null'.
// job 在目标对象上是 undefined
({
job: { title: personCopy.job.title }
} = person)
// TypeError: Cannot set property 'title' of undefined
需要注意的是,涉及多个属性的解构赋值是一个输出无关的顺序化操作。如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分:
const person = {
name: 'Matt',
age: 27
}
let personName, personBar, personAge;
try {
// person.foo 是 undefined,因此会抛出错误
({
name: personName,
foo: { bar: personBar },
age: personAge
} = person)
} catch (e) {}
console.log(personName, personBar, personAge)
// Matt, undefined, undefined
在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量:
const person = {
name: 'Matt',
age: 27
}
function printPerson (foo, { name, age }, bar) {
console.log(arguments)
console.log(name, age)
}
function printPerson2 (foo, { name: personName, age: personAge }, bar) {
console.log(arguments)
console.log(personName, personAge)
}
printPerson('1st', person, '2nd')
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27
printPerson2('1st', person, '2nd')
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27