对象
一、理解对象
1. 属性的类型
(1) 数据属性
[[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true,如前面的例子所示。
[[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
[[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
[[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性的默认值为 undefined。
要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数:要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包含: configurable、 enumerable、 writable 和 value,跟相关特性的名称一一对应。
Object.defineProperty(person, "name", { writable: false, value: "Nicholas" });
(2) 访问器属性
[[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
[[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对象上的属性的这个特性都是 true。
[[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
[[Set]]:设置函数,在写入属性时调用。默认值为 undefined。
访问器属性是不能直接定义的,必须使用 Object.defineProperty() 。
let 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
//year_中的下划线常用来表示该属性并不希望在对象方法的外部被访问。
2. 定义多个属性
let 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;
}
}
}
});
3. 读取属性的特性
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"
ECMAScript 2017 新增了 Object.getOwnPropertyDescriptors()静态方法。这个方法实际上会在每个自有属性上调用 Object.getOwnPropertyDescriptor()并在一个新对象中返回它们。
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
// }
// }
4. 合并对象
Object.assign()方法。这个方法接收一个目标对象和一个或多个源对象作为参数浅复制对每个符合条件的属性,这个方法会使用源对象上的[[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 }
5. 增强的对象语法
(1) 属性值简写
在给对象添加变量的时候,开发者经常会发现属性名和变量名是一样的。例如:
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 }; } let person = makePerson('Matt'); console.log(person.name); // Matt
在这里,即使参数标识符只限定于函数作用域,编译器也会保留初始的 name 标识符。如果使用 Google Closure 编译器压缩,那么函数参数会被缩短,而属性名不变:
function makePerson(a) { return { name: a }; } var person = makePerson("Matt"); console.log(person.name); // Matt
(2) 可计算属性
以变量值为属性名
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let person = {
[nameKey]: 'Matt',
[ageKey]: 27,
[jobKey]: 'Software engineer'
};
(3) 简写方法名
在给对象定义方法时,通常都要写一个方法名、冒号,然后再引用一个匿名函数表达式,如下所示:
let 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
简写方法名对获取函数和设置函数也是适用的:
let 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'; let person = { [methodKey](name) { console.log(`My name is ${name}`); } } person.sayName('Matt'); // My name is Matt
6. 对象解构
对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。
let person = { name: 'Matt', age: 27 }; let { name, age } = person;
也可以在解构赋值的同时定义默认值
let { name, job='Software engineer' } = person;
嵌套解构
let person = { name: 'Matt', age: 27, job: { title: 'Software engineer' } }; let personCopy = {}; ({ name: personCopy.name, age: personCopy.age, job: personCopy.job } = person);
部分解构:如果一个解构表达式涉及多个赋值,开始的赋值成功而后面的赋值出错,则整个解构赋值只会完成一部分。
二、创建对象
虽然使用 Object 构造函数或对象字面量可以方便地创建对象,但这些方式也有明显不足:创建具有同样接口的多个对象需要重复编写很多代码。
1. 工厂模式
function createPerson(name, age, job) {
let o = new Object();
o.name = name;
o.age = age;
o.job = job;
o.sayName = function() {
console.log(this.name);
};
return o;
}
let person1 = createPerson("Nicholas", 29, "Software Engineer");
let person2 = createPerson("Greg", 27, "Doctor");
但没有解决对象标识问题(即新创建的对象是什么类型)
2. 构造函数模式
function Person(name, age, job){ // 匿名函数 let Person = function(){} this.name = name; this.age = age; this.job = job; this.sayName = function() { console.log(this.name); }; } let person1 = new Person("Nicholas", 29, "Software Engineer"); let person2 = new Person("Greg", 27, "Doctor"); person1.sayName(); // Nicholas person2.sayName(); // Greg
以这种方式调用构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性。
(3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。
构造函数也是函数,任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操作符调用的函数就是普通函数。
构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。因此对前面的例子而言, person1 和 person2 都有名为 sayName()的方法,但这两个方法不是同一个 Function 实例。我们知道, ECMAScript 中的函数是对象,因此每次定义函数时,都会初始化一个对象。
要解决这个问题,可以把函数定义转移到构造函数外部:function Person(name, age, job){ this.name = name; this.age = age; this.job = job; this.sayName = sayName; } function sayName() { console.log(this.name); } let person1 = new Person("Nicholas", 29, "Software Engineer"); let person2 = new Person("Greg", 27, "Doctor"); person1.sayName(); // Nicholas person2.sayName(); // Greg
3. 原型模式
每个函数都会创建一个 prototype 属性,这个属性是一个对象,实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。
function Person() {} //let Person = function() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
console.log(this.name);
};
let person1 = new Person();
person1.sayName(); // "Nicholas"
let person2 = new Person();
person2.sayName(); // "Nicholas"
console.log(person1.sayName == person2.sayName); // true
4. 原型
(1) 理解原型
创建一个函数,就会按照特定的规则为这个函数创建一个 prototype 属性(指向原型对象)。而原型对象自动获得一个名为 constructor 的属性,指回与之关联的构造函数。对上面的例子而言, Person.prototype.constructor 指向Person。
function Person() {}
/**
* 声明之后,构造函数就有了一个与之关联的原型对象:
*/
console.log(Person.prototype);
// {
// constructor: f Person(),
// __proto__: Object
// }
/**
*如前所述,构造函数有一个 prototype 属性引用其原型对象,而这个原型对象也有
*一个constructor 属性,引用这个构造函数 换句话说,两者循环引用:
*/
console.log(Person.prototype.constructor === Person); // true
脚本中没有访问这个[[Prototype]]特性的标准方式, 但 Firefox、Safari 和 Chrome会在每个对象上暴露__proto__属性,通过这个属性可以访问对象的原型。
let person1 = new Person(),
person2 = new Person();
/**
* 构造函数、原型对象和实例是 3 个完全不同的对象:
*/
console.log(person1 !== Person); // true
console.log(person1 !== Person.prototype); // true
console.log(Person.prototype !== Person); // true
/**
* 实例通过__proto__链接到原型对象,它实际上指向隐藏特性[[Prototype]]
* 构造函数通过 prototype 属性链接到原型对象
* 实例与构造函数没有直接联系,与原型对象有直接联系
*/
console.log(person1.__proto__ === Person.prototype); // true
conosle.log(person1.__proto__.constructor === Person); // true
虽然不是所有实现都对外暴露了[[Prototype]],但可以使用 isPrototypeOf()方法确定两个对象之间的这种关系。如下所示:
console.log(Person.prototype.isPrototypeOf(person1)); // true console.log(Person.prototype.isPrototypeOf(person2)); // true
这里通过原型对象调用 isPrototypeOf()方法检查了 person1 和 person2。因为这两个例子内部都有链接指向 Person.prototype,所以结果都返回 true。
ECMAScript 的 Object 类型有一个方法叫 Object.getPrototypeOf(),返回参数的内部特性[[Prototype]]的值。例如:console.log(Object.getPrototypeOf(person1) == Person.prototype); // true console.log(Object.getPrototypeOf(person1).name); // "Nicholas"
使用 Object.getPrototypeOf()可以方便地取得一个对象的原型,而这在通过原型实现继承时显得尤为重要。
Object 类型还有一个 setPrototypeOf()方法,可以向实例的私有特性[[Prototype]]写入一个新值。这样就可以重写一个对象的原型继承关系:慎用let biped = { numLegs: 2 }; let person = { name: 'Matt' }; Object.setPrototypeOf(person, biped); console.log(person.name); // Matt console.log(person.numLegs); // 2 console.log(Object.getPrototypeOf(person) === biped); // true
为避免使用 Object.setPrototypeOf()可能造成的性能下降,可以通过 Object.create()来创建一个新对象,同时为其指定原型:
let biped = { numLegs: 2}; let person = Object.create(biped); person.name = 'Matt'; console.log(person.name); // Matt console.log(person.numLegs); // 2 console.log(Object.getPrototypeOf(person) === biped); // true
原型图示
(2) 原型层级
对象访问属性时,搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。实例拥有与原型同名的属性,实例属性就会遮蔽( shadow)原型对象上的同名属性,delete可以取消遮蔽。
hasOwnProperty()方法用于确定某个属性是在实例上还是在原型对象上,会在属性存在于调用它的对象实例上时返回 true:
function Person() {} Person.prototype.name = "Nicholas"; Person.prototype.age = 29; Person.prototype.job = "Software Engineer"; Person.prototype.sayName = function() { console.log(this.name); }; let person1 = new Person(); let person2 = new Person(); console.log(person1.hasOwnProperty("name")); // false person1.name = "Greg"; console.log(person1.hasOwnProperty("name")); // true console.log(person2.hasOwnProperty("name")); // false delete person1.name; console.log(person1.hasOwnProperty("name")); // false
(3) 原型和 in 操作符
在单独使用时, in 操作符会在可以通过对象访问指定属性时返回 true,无论该属性是在实例上还是在原型上。
console.log(person1.hasOwnProperty("name")); // false console.log("name" in person1); // true
在 for-in 循环中使用 in 操作符时,可以通过对象访问且可以被枚举的属性都会返回,包括实例属性和原型属性。
Object.keys()方法。这个方法接收一个对象作为参数,返回包含该对象所有可枚举属性名称的字符串数组。
如果想列出所有实例属性,无论是否可以枚举,都可以使用
Object.getOwnPropertyNames(): let keys = Object.getOwnPropertyNames(Person.prototype); console.log(keys); // "[constructor,name,age,job,sayName]"
注意,返回的结果中包含了一个不可枚举的属性 constructor。
(4) 属性枚举顺序
for-in 循环和 Object.keys()的枚举顺序是不确定,Object.getOwnPropertyNames()、 Object.getOwnPropertySymbols()和 Object.assign()的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。
5. 原型迭代
Object.values()返回对象值的数组, Object.entries()返回键/值对的数组。
function Person() {} Person.prototype = { name: "Nicholas", age: 29, job: "Software Engineer", sayName() { console.log(this.name); } }
完全重写了默认的 prototype 对象,因此其 constructor 属性也指向了完全不同的新对象( Object 构造函数),不再指向原来的构造函数。instanceof 操作符还能可靠地返回值。可以手动添加constructor: Person,但constructor变成可枚举属性,使用 Object.defineProperty()方法来定义 constructor 属性。
创建对象之后,修改对象原型,会马上反映在对象上。
原生对象原型:
所有原生引用类型的构造函数(包括 Object、 Array、 String 等)都在原型上定义了实例方法。比如,数组实例的 sort()方法就是 Array.prototype 上定义的,而字符串包装对象的 substring()方法也是在 String.prototype 上定义的。
可以给原生类型的实例定义新的方法,尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。推荐的做法是创建一个自定义的类,继承原生类型。
原型的问题:
原型模式也不是没有问题。首先,它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。
真正的问题来自包含引用值的属性。一个实例修改引用值,另一个也会被修改。