【红宝书笔记精简版】第八章 对象、类与面向对象编程

目录

8.1 理解对象

8.1.1 属性的类型

8.1.2 定义多个属性

8.1.3 读取属性的特性

8.1.4 合并对象

8.1.5 对象标识及相等判定

8.1.6 增强的对象语法

8.1.7 对象解构

8.2 创建对象

8.2.1 概述

8.2.2 工厂模式

8.2.3 构造函数模式

8.2.4 原型模式

8.2.5 对象迭代

8.3 继承

8.3.1 原型链

8.3.2 盗用构造函数

8.3.3 组合继承

8.3.4 原型式继承

8.3.5 寄生式继承

8.3.6 寄生式组合继承

8.4 类

8.4.1 类定义

8.4.2 类构造函数

8.4.3 实例、原型和类成员

8.4.4 继承

8.5 小结


8.1 理解对象

// 自定义创建对象
let person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";
person.sayName = function() {
 console.log(this.name);
}; 

// 字面量创建对象
let person = {
 name: "Nicholas",
 age: 29,
 job: "Software Engineer",
 sayName() {
 console.log(this.name);
 }
};

8.1.1 属性的类型

1. 数据属性

数据属性包含一个保存数据值的位置。值会从这个位置读取,也会写入到这个位置。数据属性有 4 个特性描述它们的行为。

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为访问器属性。默认情况下,所有直接定义在对象上的属性的这个特 性都是 true,如前面的例子所示。
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对 象上的属性的这个特性都是 true,如前面的例子所示。
  • [[Writable]]:表示属性的值是否可以被修改。默认情况下,所有直接定义在对象上的属性的 这个特性都是 true,如前面的例子所示。
  • [[Value]]:包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置。这个特性 的默认值为 undefined。
/**
将属性显式添加到对象之后,[[Configurable]]、[[Enumerable]]和
[[Writable]]都会被设置为 true,而[[Value]]特性会被设置为指定的值。
*/
let person = {
 name: "Nicholas"
}; 
/**
我们创建了一个名为 name 的属性,并给它赋予了一个值"Nicholas"。这意味着[[Value]]
特性会被设置为"Nicholas",之后对这个值的任何修改都会保存这个位置
*/

要修改属性的默认特性,就必须使用 Object.defineProperty()方法。这个方法接收 3 个参数: 要给其添加属性的对象、属性的名称和一个描述符对象。最后一个参数,即描述符对象上的属性可以包 含:configurable、enumerable、writable 和 value,跟相关特性的名称一一对应。根据要修改 的特性,可以设置其中一个或多个值:

/**
这个例子创建了一个名为 name 的属性并给它赋予了一个只读的值"Nicholas"。这个属性的值就
不能再修改了,在非严格模式下尝试给这个属性重新赋值会被忽略。在严格模式下,尝试修改只读属性
的值会抛出错误
*/
let person = {};
Object.defineProperty(person, "name", {
 writable: false,
 value: "Nicholas"
});
console.log(person.name); // "Nicholas"
person.name = "Greg";
console.log(person.name); // "Nicholas" 

/**
这个例子把 configurable 设置为 false,意味着这个属性不能从对象上删除。非严格模式下对
这个属性调用 delete 没有效果,严格模式下会抛出错误。此外,一个属性被定义为不可配置之后,就
不能再变回可配置的了。
*/
let person = {};
Object.defineProperty(person, "name", {
 configurable: false,
 value: "Nicholas"
});
console.log(person.name); // "Nicholas"
delete person.name;
console.log(person.name); // "Nicholas"

 在调用 Object.defineProperty()时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false。

2. 访问器属性

访问器属性不包含数据值。相反,它们包含一个获取(getter)函数和一个设置(setter)函数.

访 问器属性有 4 个特性描述它们的行为:

  • [[Configurable]]:表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特 性,以及是否可以把它改为数据属性。默认情况下,所有直接定义在对象上的属性的这个特性 都是 true。
  • [[Enumerable]]:表示属性是否可以通过 for-in 循环返回。默认情况下,所有直接定义在对 象上的属性的这个特性都是 true。
  • [[Get]]:获取函数,在读取属性时调用。默认值为 undefined。
  • [[Set]]:设置函数,在写入属性时调用。默认值为 undefined。
// 定义一个对象,包含伪私有成员 year_和公共成员 edition
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
/**
这是访问器属性的典型使用场景,即设置一个属性值会导致一些其他变化发生。
*/

8.1.2 定义多个属性

 ECMAScript 提供了 Object.defineProperties()方法。这个方法可以通过多个描述符一次性定义多个属性。它接收两个参数:要为之添 加或修改属性的对象和另一个描述符对象,其属性与要添加或修改的属性一一对应。

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;
         }
     }
 }
}); 
/**
这段代码在 book 对象上定义了两个数据属性 year_和 edition,还有一个访问器属性 year。
最终的对象跟上一节示例中的一样。唯一的区别是所有属性都是同时定义的,并且数据属性的
configurable、enumerable 和 writable 特性值都是 false。
*/

8.1.3 读取属性的特性

使用 Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。这个方法接 收两个参数:属性所在的对象和要取得其描述符的属性名。

let 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" 

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

8.1.4 合并对象

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 }

 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, result;
/**
 * 错误处理
 */
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 }

8.1.5 对象标识及相等判定

// ES6之前
// 这些是===符合预期的情况
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 

// ES6之后
// 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 

8.1.6 增强的对象语法

1. 属性值简写

let name = 'Matt';
let person = {
 name: name
};
console.log(person); // { name: 'Matt' }

// 简写
let name = 'Matt';
let person = {
 name
};
console.log(person); // { name: 'Matt' }

2. 可计算属性 

在引入可计算属性之前,如果想使用变量的值作为属性,那么必须先声明对象,然后使用中括号语 法来添加属性。换句话说,不能在对象字面量中直接动态命名属性。有了可计算属性,就可以在对象字面量中完成动态属性赋值。中括号包围的对象属性键告诉运行时 将其作为 JavaScript 表达式而不是字符串来求值:

// before
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let person = {};
person[nameKey] = 'Matt';
person[ageKey] = 27;
person[jobKey] = 'Software engineer';
console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' } 

// after

const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let person = {
 [nameKey]: 'Matt',
 [ageKey]: 27,
 [jobKey]: 'Software engineer'
};
console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }

3. 简写方法名

// before
let person = {
 sayName: function(name) {
 console.log(`My name is ${name}`);
 }
};
person.sayName('Matt'); // My name is Matt

// after
let person = {
 sayName(name) {
 console.log(`My name is ${name}`);
 }
};
person.sayName('Matt'); // My name is Matt

8.1.7 对象解构

ECMAScript 6 新增了对象解构语法,可以在一条语句中使用嵌套数据实现一个或多个赋值操作。简 单地说,对象解构就是使用与对象匹配的结构来实现对象属性赋值.

// 不使用对象解构
let person = {
 name: 'Matt',
 age: 27
};
let personName = person.name,
 personAge = person.age;
console.log(personName); // Matt
console.log(personAge); // 27

// 使用对象解构
let person = {
 name: 'Matt',
 age: 27
};
let { name: personName, age: personAge } = person;
console.log(personName); // Matt
console.log(personAge); // 27 

// 简写+对象解构
let person = {
 name: 'Matt',
 age: 27
};
let { name, age } = person;
console.log(name); // Matt
console.log(age); // 27

1、嵌套解构(可用来复制对象)

let person = {
 name: 'Matt',
 age: 27,
 job: {
 title: 'Software engineer'
 }
};
let 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' } } 

 在函数参数列表中也可以进行解构赋值。对参数的解构赋值不会影响 arguments 对象,但可以在函数签名中声明在函数体内使用局部变量:

let 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 

8.2 创建对象

8.2.1 概述

ECMAScript 6 开始正式支持类和继承。ES6 的类旨在完全涵盖之前规范设计的基于原型的继承模 式。不过,无论从哪方面看,ES6 的类都仅仅是封装了 ES5.1 构造函数加原型继承的语法糖而已。

8.2.2 工厂模式

工厂模式是一种众所周知的设计模式,广泛应用于软件工程领域,用于抽象创建特定对象的过程。

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");

 这里,函数 createPerson()接收 3 个参数,根据这几个参数构建了一个包含 Person 信息的对象。 可以用不同的参数多次调用这个函数,每次都会返回包含 3 个属性和 1 个方法的对象。这种工厂模式虽 然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)

8.2.3 构造函数模式

前面的例子使用构造函数模式可以这样写:

function Person(name, age, job){
 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 

在这个例子中,Person()构造函数代替了 createPerson()工厂函数。实际上,Person()内部 的代码跟 createPerson()基本是一样的,只是有如下区别:

  • 没有显式地创建对象。
  • 属性和方法直接赋值给了 this。
  • 没有 return。 

person1 和 person2 分别保存着 Person 的不同实例。这两个对象都有一个 constructor 属性指向 Person,用instanceof判断对象类型更可靠:

console.log(person1.constructor == Person); // true
console.log(person2.constructor == Person); // true

console.log(person1 instanceof Object); // true
console.log(person1 instanceof Person); // true
console.log(person2 instanceof Object); // true
console.log(person2 instanceof Person); // true

1. 构造函数也是函数

任何函数只要使用 new 操作符调用就是构造函数,而不使用 new 操 作符调用的函数就是普通函数

// 作为构造函数
let person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); // "Nicholas"
// 作为函数调用
Person("Greg", 27, "Doctor"); // 添加到 window 对象
window.sayName(); // "Greg"
// 在另一个对象的作用域中调用
let o = new Object();
Person.call(o, "Kristen", 25, "Nurse");
o.sayName(); // "Kristen" 

 2. 构造函数的问题

构造函数虽然有用,但也不是没有问题。构造函数的主要问题在于,其定义的方法会在每个实例上 都创建一遍。因此对前面的例子而言,person1 和 person2 都有名为 sayName()的方法,但这两个方 法不是同一个 Function 实例。

因为都是做一样的事,所以没必要定义两个不同的 Function 实例。况且,this 对象可以把函数 与对象的绑定推迟到运行时。 要解决这个问题,可以把函数定义转移到构造函数外部:

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 

这样虽然解决了相同逻辑的函数重复定义的问题,但全局作用域也因此被搞乱了,因为那个函数实际上只能在一个对象上调用。如果这个对象需要多个方法, 那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。这个新问题可以通过原型模式来解决 

8.2.4 原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例 共享的属性和方法。实际上,这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。原来在构造函数中直接赋给对象实例的值,可以 直接赋值给它们的原型,如下所示:

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();
person1.sayName(); // "Nicholas"
let person2 = new Person();
person2.sayName(); // "Nicholas"
console.log(person1.sayName == person2.sayName); // true

这里,所有属性和 sayName()方法都直接添加到了 Person 的 prototype 属性上,构造函数体中 什么也没有。但这样定义之后,调用构造函数创建的新对象仍然拥有相应的属性和方法。与构造函数模 式不同,使用这种原型模式定义的属性和方法是由所有实例共享的。因此 person1 和 person2 访问的 都是相同的属性和相同的 sayName()函数。要理解这个过程,就必须理解 ECMAScript 中原型的本质。 

1. 理解原型

 

console.log(person1 instanceof Person); // true
console.log(person1 instanceof Object); // true
console.log(Person.prototype instanceof Object); // true 

前面例子中的 Person 构造函数和 Person.prototype,可以通过下图看出各个对象之间的关系: 

 

上图展示了 Person 构造函数、Person 的原型对象和 Person 现有两个实例之间的关系。注意, Person.prototype 指向原型对象,而 Person.prototype.contructor 指回 Person 构造函数。原 型对象包含 constructor 属性和其他后来添加的属性。Person 的两个实例 person1 和 person2 都只有一个内部属性指回 Person.prototype,而且两者都与构造函数没有直接联系。另外要注意,虽然这两个实例都没有属性和方法,但 person1.sayName()可以正常调用。这是由于对象属性查找机制的原因。 

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.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. 原型层级 

在调用 person1.sayName()时,会发生两步搜索。首先,JavaScript 引擎会问:“person1 实例有 sayName 属性吗?”答案是没有。然后, 继续搜索并问:“person1 的原型有 sayName 属性吗?”答案是有。于是就返回了保存在原型上的这 个函数。在调用 person2.sayName()时,会发生同样的搜索过程,而且也会返回相同的结果。这就是原型用于在多个对象实例间共享属性和方法的原理。

虽然可以通过实例读取原型对象上的值,但不可能通过实例重写这些值。如果在实例上添加了一个 与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会遮住原型对象上的属性:

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();
person1.name = "Greg";
console.log(person1.name); // "Greg",来自实例
console.log(person2.name); // "Nicholas",来自原型

在这个例子中,person1 的 name 属性遮蔽了原型对象上的同名属性。虽然 person1.name 和 person2.name 都返回了值,但前者返回的是"Greg"(来自实例),后者返回的是"Nicholas"(来自 原型)。当 console.log()访问 person1.name 时,会先在实例上搜索个属性。因为这个属性在实例 上存在,所以就不会再搜索原型对象了。而在访问 person2.name 时,并没有在实例上找到这个属性, 所以会继续搜索原型对象并使用定义在原型上的属性。

不过,使用 delete 操作符可以完全删除实例上的这个属性,从而让标识符解析过程能够继续搜索 原型对象。

通过调用 hasOwnProperty()能够清楚地看到访问的是实例属性还是原型属性。调用 person1.hasOwnProperty("name")只在重写 person1 上 name 属性的情况下才返回 true,表明此时 name 是一个实例属性,不是原型属性。

3. 原型和 in 操作符

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
console.log("name" in person1); // true
person1.name = "Greg";
console.log(person1.name); // "Greg",来自实例
console.log(person1.hasOwnProperty("name")); // true
console.log("name" in person1); // true
console.log(person2.name); // "Nicholas",来自原型
console.log(person2.hasOwnProperty("name")); // false
console.log("name" in person2); // true
delete person1.name;
console.log(person1.name); // "Nicholas",来自原型
console.log(person1.hasOwnProperty("name")); // false
console.log("name" in person1); // true

 只要通过对象可以访问,in 操作符就返回 true,而 hasOwnProperty()只有属性存在于实例上时才返回 true。因此,只要 in 操作符返回 true 且 hasOwnProperty()返回 false,就说明该属性是一个原型属性.

function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
 console.log(this.name);
};
let person = new Person();
console.log(hasPrototypeProperty(person, "name")); // true
person.name = "Greg";
console.log(hasPrototypeProperty(person, "name")); // false
/**
里,name 属性首先只存在于原型上,所以 hasPrototypeProperty()返回 true。而在实例
上重写这个属性后,实例上也有了这个属性,因此 hasPrototypeProperty()返回 false。即便此时
原型对象还有 name 属性,但因为实例上的属性遮蔽了它,所以不会用到。
*/

4. 属性枚举顺序

for-in 循环和 Object.keys() 的枚举顺序是不确定的,取决于 JavaScript 引擎,可能因浏览器而异。 Object.getOwnPropertyNames()、Object.getOwnPropertySymbols()和 Object.assign() 的枚举顺序是确定性的。先以升序枚举数值键,然后以插入顺序枚举字符串和符号键。

8.2.5 对象迭代

ECMAScript 2017 新增了两 个静态方法,用于将对象内容转换为序列化的——更重要的是可迭代的——格式。这两个静态方法 Object.values()和 Object.entries()接收一个对象,返回它们内容的数组。Object.values() 返回对象值的数组,Object.entries()返回键/值对的数组。

const o = {
 foo: 'bar',
 baz: 1,
 qux: {}
};
console.log(Object.values(o));
// ["bar", 1, {}]
console.log(Object.entries((o)));
// [["foo", "bar"], ["baz", 1], ["qux", {}]]

1. 其他原型语法 

在前面的例子中,每次定义一个属性或方法都会把 Person.prototype 重 写一遍。为了减少代码冗余,也为了从视觉上更好地封装原型功能,直接通过一个包含所有属性和方法 的对象字面量来重写原型成为了一种常见的做法:

function Person() {}
Person.prototype = {
 name: "Nicholas",
 age: 29,
 job: "Software Engineer",
 sayName() {
 console.log(this.name);
 }
}; 

在这个例子中,Person.prototype 被设置为等于一个通过对象字面量创建的新对象。最终结果 是一样的,只有一个问题:这样重写之后,Person.prototype 的 constructor 属性就不指向 Person 了。在创建函数时,也会创建它的 prototype 对象,同时会自动给这个原型的 constructor 属性赋 值。而上面的写法完全重写了默认的 prototype 对象,因此其 constructor 属性也指向了完全不同 的新对象(Object 构造函数),不再指向原来的构造函数 :

let friend = new Person();
console.log(friend instanceof Object); // true
console.log(friend instanceof Person); // true
console.log(friend.constructor == Person); // false
console.log(friend.constructor == Object); // true 

如果 constructor 的值很重要,则可以像下面这样在重写原型对象时专门设置一 下它的值:

function Person() {
}
Person.prototype = {
 constructor: Person,
 name: "Nicholas",
 age: 29,
 job: "Software Engineer",
 sayName() {
 console.log(this.name);
 }
}; 

2. 原型的动态性

从原型上搜索值的过程是动态的,所以即使实例在修改原型之前已经存在,任何时候对原型对 象所做的修改也会在实例上反映出来。

let friend = new Person();
Person.prototype.sayHi = function() {
 console.log("hi");
};
friend.sayHi(); // "hi",没问题!

重写整个原型会切断最初原型与构造函数的联系,但实例引用的仍然是最初的原型。 记住,实例只有指向原型的指针,没有指向构造函数的指针。

function Person() {}
let friend = new Person();
Person.prototype = {
 constructor: Person,
 name: "Nicholas",
 age: 29,
 job: "Software Engineer",
 sayName() {
 console.log(this.name);
 }
};
friend.sayName(); // 错误

 在这个例子中,Person 的新实例是在重写原型对象之前创建的。在调用 friend.sayName()的时 候,会导致错误。这是因为 firend 指向的原型还是最初的原型,而这个原型上并没有 sayName 属性。

 3. 原生对象原型

所有原生引用类型的构造函数(包括 Object、Array、String 等)都在原型上定义了实例方法。比如, 数组实例的 sort()方法就是 Array.prototype 上定义的,而字符串包装对象的 substring()方法也 是在 String.prototype 上定义的.

通过原生对象的原型可以取得所有默认方法的引用,也可以给原生类型的实例定义新的方法。可以 像修改自定义对象原型一样修改原生对象原型,因此随时可以添加方法。

注意 尽管可以这么做,但并不推荐在产品环境中修改原生对象原型。这样做很可能造成 误会,而且可能引发命名冲突

4. 原型的问题 

原型模式也不是没有问题。它弱化了向构造函数传递初始化参数的能力,会导致所有实例默认都取得相同的属性值。虽然这会带来不便,但还不是原型的最大问题。原型的最主要问题源自它的共 享特性。

function Person() {}
Person.prototype = {
 constructor: Person,
 name: "Nicholas",
 age: 29,
 job: "Software Engineer",
 friends: ["Shelby", "Court"],
 sayName() {
   console.log(this.name);
 }
};
let person1 = new Person();
let person2 = new Person();
person1.friends.push("Van");
console.log(person1.friends); // "Shelby,Court,Van"
console.log(person2.friends); // "Shelby,Court,Van"
console.log(person1.friends === person2.friends); // true

由于这个 friends 属性存在于 Person.prototype 而非 person1 上,新加的这个字符串也会在(指向同一个 数组的)person2.friends 上反映出来。如如果这是有意在多个实例间共享数组,那没什么问题。但一般来说,不同的实例应该有属于自己的属性副本。这就是实际开发中通常不单独使用原型模式的原因。 

8.3 继承

实现继承是 ECMAScript 唯一支持的继承方式,而这主要是通过原型链实现的。

8.3.1 原型链

function SuperType() {
 this.property = true;
} 
SuperType.prototype.getSuperValue = function() {
 return this.property;
};
function SubType() {
 this.subproperty = false;
}
// 继承 SuperType
// 值重写了 SubType 最初的原型,将其替换为SuperType 的实例
SubType.prototype = new SuperType(); 
SubType.prototype.getSubValue = function () { 
 return this.subproperty;
};
let instance = new SubType();
console.log(instance.getSuperValue()); // true

调用 instance.getSuperValue()经过了 3 步搜索:instance、 SubType.prototype 和 SuperType.prototype,最后一步才找到这个方法。对属性和方法的搜索会 一直持续到原型链的末端

1. 默认原型 

默认情况下,所有引用类型都继承自 Object,这也是通过原型链实 现的。任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype。

 SubType 继承 SuperType,而 SuperType 继承 Object。在调用 instance.toString()时,实 际上调用的是保存在 Object.prototype 上的方法。

2. 原型与继承关系

原型与实例的关系可以通过两种方式来确定:

第一种方式是使用 instanceof 操作符,如果一个实 例的原型链中出现过相应的构造函数,则 instanceof 返回 true。第二种方式是使用 isPrototypeOf()方法。原型链中的每个原型都可以调用这个 方法,只要原型链中包含这个原型,这个方法就返回 true.

console.log(instance instanceof Object); // true
console.log(instance instanceof SuperType); // true
console.log(instance instanceof SubType); // true 
console.log(Object.prototype.isPrototypeOf(instance)); // true
console.log(SuperType.prototype.isPrototypeOf(instance)); // true
console.log(SubType.prototype.isPrototypeOf(instance)); // true

3. 关于方法 

以对象字面量方式创建原型方法会破坏之前的原型链,因为这相当于重写 了原型链。

function SuperType() {
 this.property = true;
}
SuperType.prototype.getSuperValue = function() {
 return this.property;
};
function SubType() {
 this.subproperty = false;
} 
// 继承 SuperType
SubType.prototype = new SuperType();
// 通过对象字面量添加新方法,这会导致上一行无效
SubType.prototype = {
 getSubValue() {
 return this.subproperty;
 },
 someOtherMethod() {
 return false;
 }
};
let instance = new SubType();
console.log(instance.getSuperValue()); // 出错!

 在这段代码中,子类的原型在被赋值为 SuperType 的实例后,又被一个对象字面量覆盖了。覆盖 后的原型是一个 Object 的实例,而不再是 SuperType 的实例。因此之前的原型链就断了。SubType 和 SuperType 之间也没有关系了。

4. 原型链的问题

主要问题出现在原型中包含引用值的时候。前面在谈到原型的问题时也提到过,原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。在使用原型实现继承时,原型实际上变成了另一个类型 的实例。这意味着原先的实例属性摇身一变成为了原型属性。

function SuperType() {
 this.colors = ["red", "blue", "green"];
}
function SubType() {}
// 继承 SuperType
SubType.prototype = new SuperType();
let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green,black"
在这个例子中,SuperType 构造函数定义了一个 colors 属性,其中

当 SubType 通过原型继承 SuperType 后,SubType.prototype 变成了 SuperType 的一个实例,因而也获得了自己的 colors 属性。这类似于创建了 SubType.prototype.colors 属性。最终结果是,SubType 的所有实例都会 共享这个 colors 属性。这一点通过 instance1.colors 上的修改也能反映到 instance2.colors 上就可以看出来。 

8.3.2 盗用构造函数

为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(constructor stealing)的技 术在开发社区流行起来(这种技术有时也称作“对象伪装”或“经典继承”)基本思路很简单:在子类 构造函数中调用父类构造函数。因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply()和 call()方法以新创建的对象为上下文执行构造函数

function SuperType() {
 this.colors = ["red", "blue", "green"];
}
function SubType() {
 // 继承 SuperType
 SuperType.call(this);
}
let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
let instance2 = new SubType();
console.log(instance2.colors); // "red,blue,green" 

通过使用 call()(或 apply())方法,SuperType 构造函数在为 SubType 的实例创建的新对象的上下文中执行了。这相当于新的 SubType 对象上运行了 SuperType()函数中的所有初始化代码。结果就是每个实例都会有自己的 colors 属性 

1. 传递参数

相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参

function SuperType(name){
 this.name = name;
}
function SubType() {
 // 继承 SuperType 并传参
 SuperType.call(this, "Nicholas");
 // 实例属性
 this.age = 29;
}
let instance = new SubType();
console.log(instance.name); // "Nicholas";
console.log(instance.age); // 29 

2. 盗用构造函数的问题 

盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用。此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。由于存在这些问题,盗用构造函数基本上也不能单独使用。

8.3.3 组合继承

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。基 本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。这样既可以把方 法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

function SuperType(name){
 this.name = name;
 this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
 console.log(this.name);
};
function SubType(name, age){
 // 继承属性
 SuperType.call(this, name);
 this.age = age;
}
// 继承方法
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() {
 console.log(this.age);
};
let instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors); // "red,blue,green,black"
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29
let instance2 = new SubType("Greg", 27);
console.log(instance2.colors); // "red,blue,green"
instance2.sayName(); // "Greg";
instance2.sayAge(); // 27 

组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。而且组合继 承也保留了 instanceof 操作符和 isPrototypeOf()方法识别合成对象的能力。 

8.3.4 原型式继承

这个 object()函数会创建一个临时构造函数,将传入的对象赋值给这个构造函数的原型,然后返 回这个临时类型的一个实例。本质上,object()是对传入的对象执行了一次浅复制。

let person = {
 name: "Nicholas",
 friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = object(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
let yetAnotherPerson = object(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"

ECMAScript 5 通过增加 Object.create()方法将原型式继承的概念规范化了。这个方法接收两个 参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。在只有一个参数时, Object.create()与这里的 object()方法效果相同:

let person = {
 name: "Nicholas",
 friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = Object.create(person);
anotherPerson.name = "Greg";
anotherPerson.friends.push("Rob");
let yetAnotherPerson = Object.create(person);
yetAnotherPerson.name = "Linda";
yetAnotherPerson.friends.push("Barbie");
console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"

8.3.5 寄生式继承

寄生式继承背后的思路类似于寄生构造函数和工厂模式:创建一个实现继承的函数,以某种 方式增强对象,然后返回这个对象。基本的寄生继承模式如下: 

/**
createAnother()函数接收一个参数,就是新对象的基准对象。这个对象 original
会被传给 object()函数,然后将返回的新对象赋值给 clone。接着给 clone 对象添加一个新方法
sayHi()。
*/
function createAnother(original){
 let clone = object(original); // 通过调用函数创建一个新对象
 clone.sayHi = function() { // 以某种方式增强这个对象
 console.log("hi");
 };
 return clone; // 返回这个对象
} 

// 或者是这样

let person = {
 name: "Nicholas",
 friends: ["Shelby", "Court", "Van"]
};
let anotherPerson = createAnother(person);
anotherPerson.sayHi(); // "hi" 

8.3.6 寄生式组合继承

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:一次在是 创建子类原型时调用,另一次是在子类构造函数中调用。本质上,子类原型最终是要包含超类对象的所 有实例属性,子类构造函数只要在执行时重写自己的原型就行了。

function SuperType(name) {
 this.name = name;
 this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function() {
 console.log(this.name);
};
function SubType(name, age){
 SuperType.call(this, name); // 第二次调用 SuperType()
 this.age = age;
}
SubType.prototype = new SuperType(); // 第一次调用 SuperType()
SubType.prototype.constructor = SubType;
SubType.prototype.sayAge = function() {
 console.log(this.age);
};

寄生式组合继承可以算是引用类型继承的最佳模式。 

8.4 类

各种策略都有自己的问题,也有相应的妥协。正因为如此,实现继承的代码也显得非常冗长和混乱。ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。类(class)是 ECMAScript 中新的基础性语法糖结构,虽然 ECMAScript 6 类表面 上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

8.4.1 类定义

定义类也有两种主要方式:类声明和类表达式。

// 类声明
class Person {}
// 类表达式
const Animal = class {};

与函数定义不同的是,虽然函数 声明可以提升,但类定义不能。 

类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的。 空的类定义照样有效。 

// 空类定义,有效
class Foo {}
// 有构造函数的类,有效
class Bar {
 constructor() {}
}
// 有获取函数的类,有效
class Baz {
 get myBaz() {}
}
// 有静态方法的类,有效
class Qux {
 static myQux() {}
} 

8.4.2 类构造函数

1. 实例化 

class Animal {}
class Person {
 constructor() {
 console.log('person ctor');
 }
}
class Vegetable {
 constructor() {
 this.color = 'orange';
 }
}
let a = new Animal();
let p = new Person(); // person ctor
let v = new Vegetable();
console.log(v.color); // orange

 8.4.3 实例、原型和类成员

1. 实例成员

每次通过new调用类标识符时,都会执行类构造函数。在这个函数内部,可以为新创建的实例(this) 添加“自有”属性。每个实例都对应一个唯一的成员对象,这意味着所有成员都不会在原型上共享:

class Person {
 constructor() {
 // 这个例子先使用对象包装类型定义一个字符串
 // 为的是在下面测试两个对象的相等性
 this.name = new String('Jack');
 this.sayName = () => console.log(this.name);
 this.nicknames = ['Jake', 'J-Dog']
 }
}
let p1 = new Person(),
 p2 = new Person();
p1.sayName(); // Jack
p2.sayName(); // Jack
console.log(p1.name === p2.name); // false
console.log(p1.sayName === p2.sayName); // false
console.log(p1.nicknames === p2.nicknames); // false
p1.name = p1.nicknames[0];
p2.name = p2.nicknames[1];
p1.sayName(); // Jake
p2.sayName(); // J-Dog

2. 原型方法与访问器 

为了在实例间共享方法,类定义语法把在类块中定义的方法作为原型方法。

class Person {
 constructor() {
 // 添加到 this 的所有内容都会存在于不同的实例上
 this.locate = () => console.log('instance');
 } 
// 在类块中定义的所有内容都会定义在类的原型上
 locate() {
 console.log('prototype');
 }
}
let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype

类定义也支持获取和设置访问器。语法与行为跟普通对象一样: 

class Person {
 set name(newName) {
 this.name_ = newName;
 }
 get name() {
 return this.name_;
 }
}
let p = new Person();
p.name = 'Jake';
console.log(p.name); // Jake

3. 静态类方法 

与原型成员类似,静态成员每个类上只能有一个。 静态类成员在类定义中使用 static 关键字作为前缀。在静态成员中,this 引用类自身。其他所 有约定跟原型成员一样:

class Person {
 constructor() {
 // 添加到 this 的所有内容都会存在于不同的实例上
 this.locate = () => console.log('instance', this);
 }
 // 定义在类的原型对象上
 locate() {
 console.log('prototype', this);
 }
 // 定义在类本身上
 static locate() {
 console.log('class', this);
 }
}
let p = new Person();
p.locate(); // instance, Person {}
Person.prototype.locate(); // prototype, {constructor: ... }
Person.locate(); // class, class Person {} 

5. 迭代器与生成器方法 

class Person {
 // 在原型上定义生成器方法
 *createNicknameIterator() {
 yield 'Jack';
 yield 'Jake';
 yield 'J-Dog';
 }
 // 在类上定义生成器方法
 static *createJobIterator() {
 yield 'Butcher';
 yield 'Baker';
 yield 'Candlestick maker';
 }
}
let jobIter = Person.createJobIterator();
console.log(jobIter.next().value); // Butcher
console.log(jobIter.next().value); // Baker
console.log(jobIter.next().value); // Candlestick maker
let p = new Person();
let nicknameIter = p.createNicknameIterator();
console.log(nicknameIter.next().value); // Jack
console.log(nicknameIter.next().value); // Jake
console.log(nicknameIter.next().value); // J-Dog 

8.4.4 继承

1. 继承基础

ES6 类支持单继承。使用 extends 关键字,就可以继承任何拥有[[Construct]]和原型的对象。

派生类都会通过原型链访问到类和原型上定义的方法。this 的值会反映调用相应方法的实例或者类:

class Vehicle {
 identifyPrototype(id) {
 console.log(id, this);
 }
static identifyClass(id) {
 console.log(id, this);
 }
}
class Bus extends Vehicle {}
let v = new Vehicle();
let b = new Bus();
b.identifyPrototype('bus'); // bus, Bus {}
v.identifyPrototype('vehicle'); // vehicle, Vehicle {}
Bus.identifyClass('bus'); // bus, class Bus {}
Vehicle.identifyClass('vehicle'); // vehicle, class Vehicle {}

2. 构造函数、HomeObject 和 super() 

3. 抽象基类

有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化。虽然 ECMAScript 没 有专门支持这种类的语法 ,但通过 new.target 也很容易实现。new.target 保存通过 new 关键字调 用的类或函数。通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类的实例化

4. 继承内置类型

8.5 小结

对象在代码执行过程中的任何时候都可以被创建和增强,具有极大的动态性,并不是严格定义的实 体。下面的模式适用于创建对象。

 工厂模式就是一个简单的函数,这个函数可以创建对象,为它添加属性和方法,然后返回这个 对象。这个模式在构造函数模式出现后就很少用了。

 使用构造函数模式可以自定义引用类型,可以使用 new 关键字像创建内置类型实例一样创建自 定义类型的实例。不过,构造函数模式也有不足,主要是其成员无法重用,包括函数。考虑到 函数本身是松散的、弱类型的,没有理由让函数不能在多个对象实例间共享。

 原型模式解决了成员共享的问题,只要是添加到构造函数 prototype 上的属性和方法就可以共 享。而组合构造函数和原型模式通过构造函数定义实例属性,通过原型定义共享的属性和方法。

JavaScript 的继承主要通过原型链来实现。原型链涉及把构造函数的原型赋值为另一个类型的实例。 这样一来,子类就可以访问父类的所有属性和方法,就像基于类的继承那样。

原型链的问题是所有继承 的属性和方法都会在对象实例间共享,无法做到实例私有。盗用构造函数模式通过在子类构造函数中调 用父类构造函数,可以避免这个问题。这样可以让每个实例继承的属性都是私有的,但要求类型只能通 过构造函数模式来定义(因为子类不能访问父类原型上的方法)。目前最流行的继承模式是组合继承, 即通过原型链继承共享的属性和方法,通过盗用构造函数继承实例属性。

除上述模式之外,还有以下几种继承模式。

 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。这种操 作的结果之后还可以再进一步增强。

 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个 新对象,最后返回新对象。这个模式也被用在组合继承中,用于避免重复调用父类构造函数导 致的浪费。

 寄生组合继承被认为是实现基于类型继承的最有效方式。

ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。类的语法让开发者可以优雅地定 义向后兼容的类,既可以继承内置类型,也可以继承自定义类型。类有效地跨越了对象实例、对象原型 和对象类之间的鸿沟。

猜你喜欢

转载自blog.csdn.net/weixin_41950078/article/details/121095669