8.1 理解对象

创建自定义对象的通常方式是创建 Object 的一个新实例,然后再给它添加属性和方法。

  1. let person = new Object();
  2. person.name = "ming";
  3. person.age = 23;
  4. person.job = "社会人";
  5. person.sayName = function() {
  6. console.log(this.name);
  7. };

前面的例子如果使用对象字面量则可以这样写。

  1. let person = {
  2. name: "ming",
  3. age: 23,
  4. job: "社会人",
  5. sayName() {
  6. console.log(this.name);
  7. }
  8. };

8.1.1 属性类型

属性分两种:数据属性和访问器属性。

8.1.1.1 数据属性

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

  • configurable :表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为访问器属性。默认为 true
  • enumerable :表示属性是否可以通过 for-in 循环返回。默认为 true
  • writable :表示属性的值是否可以被修改。默认为true
  • value :包含属性实际的值。这就是前面提到的那个读取和写入属性值的位置,默认为undefined。

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

  1. let person = {};
  2. Object.defineProperty(person, "name", {
  3. writable: false,
  4. value: "ming"
  5. });
  6. console.log(person.name); // "ming"
  7. person.name = "Greg";
  8. console.log(person.name); // "ming"

8.1.1.2 访问器属性

访问器属性不包含数据值。
在读取访问器属性时,会调用获取函数,这个函数的责任就是返回一个有效的值。
在写入访问器属性时,会调用设置函数并传入新值,这个函数必须决定对数据做出什么修改。
访问器属性有 4 个特性描述它们的行为。

  • configurable :表示属性是否可以通过 delete 删除并重新定义,是否可以修改它的特性,以及是否可以把它改为数据属性。默认为 true
  • enumerable :表示属性是否可以通过 for-in 循环返回。默认为 true
  • get :获取函数,在读取属性时调用。默认为undefined
  • set :设置函数,在写入属性时调用。默认值为 undefined

8.1.2 定义多个属性

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

  1. let book = {};
  2. Object.defineProperties(book, {
  3. year_: {
  4. value: 2017
  5. },
  6. edition: {
  7. value: 1
  8. },
  9. year: {
  10. get() {
  11. return this.year_;
  12. },
  13. set(newValue) {
  14. if (newValue > 2017) {
  15. this.year_ = newValue;
  16. this.edition += newValue - 2017;
  17. }
  18. }
  19. }
  20. });

8.1.3 读取属性的特性

使用 Object.getOwnPropertyDescriptor()方法可以取得指定属性的属性描述符。
这个方法接收两个参数:属性所在的对象和要取得其描述符的属性名。
返回值是一个对象,对于访问器属性包含configurable、enumerable、get 和 set 属性,
对于数据属性包含 configurable、enumerable、writable 和 value 属性。

  1. let book = {};
  2. Object.defineProperties(book, {
  3. year_: {
  4. value: 2017
  5. },
  6. edition: {
  7. value: 1
  8. },
  9. year: {
  10. get: function() {
  11. return this.year_;
  12. },
  13. set: function(newValue){
  14. if (newValue > 2017) {
  15. this.year_ = newValue;
  16. this.edition += newValue - 2017;
  17. }
  18. }
  19. }
  20. });
  21. let descriptor = Object.getOwnPropertyDescriptor(book, "year_");
  22. console.log(descriptor.value); // 2017
  23. console.log(descriptor.configurable); // false
  24. console.log(typeof descriptor.get); // "undefined"
  25. let descriptor = Object.getOwnPropertyDescriptor(book, "year");
  26. console.log(descriptor.value); // undefined
  27. console.log(descriptor.enumerable); // false
  28. console.log(typeof descriptor.get); // "function"

8.1.4 合并对象

ECMAScript 6 专门为合并对象提供了 Object.assign()方法。
这个方法接收一个目标对象和一个或多个源对象作为参数,
然后将每个源对象中可枚举(Object.propertyIsEnumerable()返回 true)和自有(Object.hasOwnProperty()返回 true)属性复制到目标对象。
以字符串和符号为键的属性会被复制。

  1. let dest, src, result;
  2. /**
  3. * 简单复制
  4. */
  5. dest = {};
  6. src = { id: 'src' };
  7. result = Object.assign(dest, src);
  8. // Object.assign 修改目标对象
  9. // 也会返回修改后的目标对象
  10. console.log(dest === result); // true
  11. console.log(dest !== src); // true
  12. console.log(result); // { id: src }
  13. console.log(dest); // { id: src }
  14. /**
  15. * 多个源对象
  16. */
  17. dest = {};
  18. result = Object.assign(dest, { a: 'foo' }, { b: 'bar' });
  19. console.log(result); // { a: foo, b: bar }

8.1.5 对象标识及相等判定

在 ECMAScript 6 之前,有些特殊情况即使是===操作符也无能为力

  1. // 这些是===符合预期的情况
  2. console.log(true === 1); // false
  3. console.log({} === {}); // false
  4. console.log("2" === 2); // false
  5. // 这些情况在不同 JavaScript 引擎中表现不同,但仍被认为相等
  6. console.log(+0 === -0); // true
  7. console.log(+0 === 0); // true
  8. console.log(-0 === 0); // true
  9. // 要确定 NaN 的相等性,必须使用极为讨厌的 isNaN()
  10. console.log(NaN === NaN); // false
  11. console.log(isNaN(NaN)); // true

ECMAScript 6 规范新增了 Object.is(),这个方法与===很像,但同时也考虑到了上述边界情形。

  1. console.log(Object.is(true, 1)); // false
  2. console.log(Object.is({}, {})); // false
  3. console.log(Object.is("2", 2)); // false
  4. // 正确的 0、-0、+0 相等/不等判定
  5. console.log(Object.is(+0, -0)); // false
  6. console.log(Object.is(+0, 0)); // true
  7. console.log(Object.is(-0, 0)); // false
  8. // 正确的 NaN 相等判定
  9. console.log(Object.is(NaN, NaN)); // true

8.1.6 增强的对象语法

8.1.6.1 属性值简写

简写属性名只要使用变量名(不用再写冒号)就会自动被解释为同名的属性键。
如果没有找到同名变量,则会抛出 ReferenceError。

  1. let name = 'ming';
  2. let person = {
  3. name: name
  4. };
  5. console.log(person); // { name: 'ming' }
  6. //以下等同于之前的代码
  7. let person2 = {
  8. name
  9. };
  10. console.log(person2); // { name: 'ming' }

8.1.6.2 可计算属性

有了可计算属性,就可以在对象字面量中完成动态属性赋值。

  1. const nameKey = 'name';
  2. const ageKey = 'age';
  3. const jobKey = 'job';
  4. let person = {
  5. [nameKey]: 'Matt',
  6. [ageKey]: 27,
  7. [jobKey]: 'Software engineer'
  8. };
  9. console.log(person); // { name: 'Matt', age: 27, job: 'Software engineer' }

8.1.6.3 简写方法名

  1. let person = {
  2. sayName: function(name) {
  3. console.log(`My name is ${name}`);
  4. }
  5. };
  6. person.sayName('ming'); // My name is ming
  7. //以下等同于之前的代码
  8. let person = {
  9. sayName(name) {
  10. console.log(`My name is ${name}`);
  11. }
  12. };
  13. person.sayName('ming'); // My name is ming

8.1.7 对象解构

  1. let person = {
  2. name: 'ming',
  3. age: 23
  4. };
  5. // 不使用对象解构
  6. let personName = person.name,
  7. personAge = person.age;
  8. // 使用对象解构
  9. let { name: personName, age: personAge } = person;
  10. console.log(personName); // ming
  11. console.log(personAge); // 23

也可以在解构赋值的同时定义默认值

  1. const obj = {
  2. age: 23,
  3. // job: '社会人',
  4. name: 'ming'
  5. };
  6. const { age, job = '前端', name } = obj;
  7. console.log(age, name, job); //23 ming 前端

8.2 创建对象

8.2.1 概述

8.2.2 工厂模式

工厂模式是一种众所周知的设计模式,用于抽象创建特定对象的过程。

  1. function createPerson(name, age, job) {
  2. let o = new Object();
  3. o.name = name;
  4. o.age = age;
  5. o.job = job;
  6. o.sayName = function() {
  7. console.log(this.name);
  8. };
  9. return o;
  10. }
  11. let person1 = createPerson("Nicholas", 29, "Software Engineer");
  12. let person2 = createPerson("Greg", 27, "Doctor");

这种工厂模式虽然可以解决创建多个类似对象的问题,但没有解决对象标识问题(即新创建的对象是什么类型)。

8.2.3 构造函数模式

可以自定义构造函数,以函数的形式为自己的对象类型定义属性和方法。

  1. function Person(name, age, job){
  2. this.name = name;
  3. this.age = age;
  4. this.job = job;
  5. this.sayName = function() {
  6. console.log(this.name);
  7. };
  8. }
  9. let person1 = new Person("Nicholas", 29, "Software Engineer");
  10. let person2 = new Person("Greg", 27, "Doctor");
  11. person1.sayName(); // Nicholas
  12. person2.sayName(); // Greg

Person()内部的代码跟 createPerson()基本是一样的,只是有如下区别。

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

要创建 Person 的实例,应使用 new 操作符。以这种方式调用构造函数会执行如下操作

  1. 在内存中创建一个新对象。
  2. 这个新对象内部的Prototype特性被赋值为构造函数的 prototype 属性。
  3. 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
  4. 执行构造函数内部的代码(给新对象添加属性)。
  5. 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

构造函数的主要问题在于,其定义的方法会在每个实例上都创建一遍。

8.2.4 原型模式

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。
这个对象就是通过调用构造函数创建的对象的原型。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。

  1. function Person() {}
  2. Person.prototype.name = "ming";
  3. Person.prototype.age = 23;
  4. Person.prototype.job = "社会人";
  5. Person.prototype.sayName = function() {
  6. console.log(this.name);
  7. };
  8. let person1 = new Person();
  9. person1.sayName(); // "ming"
  10. let person2 = new Person();
  11. person2.sayName(); // "ming"
  12. console.log(person1.sayName == person2.sayName); // true

8.2.5 对象迭代

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

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

8.3 继承

8.3.1 原型链

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。其基本思想就是通过原型继承多个引用类型的属性和方法。

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/680077/1625648566829-1d086199-ab37-4fb9-9f86-2ae691d60503.png#align=left&display=inline&height=326&margin=%5Bobject%20Object%5D&name=image.png&originHeight=652&originWidth=1388&size=136584&status=done&style=none&width=694)
  1. function SuperType() {
  2. this.property = true;
  3. }
  4. SuperType.prototype.getSuperValue = function() {
  5. return this.property;
  6. };
  7. function SubType() {
  8. this.subproperty = false;
  9. }
  10. // 继承 SuperType
  11. SubType.prototype = new SuperType();
  12. SubType.prototype.getSubValue = function () {
  13. return this.subproperty;
  14. };
  15. let instance = new SubType();
  16. console.log(instance.getSuperValue()); // true

8.3.1.1 默认原型

默认情况下,所有引用类型都继承自 Object,这也是通过原型链实现的。
任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向Object.prototype。
这也是为什么自定义类型能够继承包括 toString()、valueOf()在内的所有默认方法的原因。因此前面的例子还有额外一层继承关系。

  1. ![image.png](https://cdn.nlark.com/yuque/0/2021/png/680077/1625648674724-58299527-4688-40a5-a1ca-4c9c7169650c.png#align=left&display=inline&height=572&margin=%5Bobject%20Object%5D&name=image.png&originHeight=1144&originWidth=1318&size=256197&status=done&style=none&width=659)

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

8.3.1.2 原型与继承关系

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

  1. console.log(instance instanceof Object); // true
  2. console.log(instance instanceof SuperType); // true
  3. console.log(instance instanceof SubType); // true
  4. // instance 是 Object、SuperType 和 SubType 的实例,因为 instance 的原型链中包含这些构造函数的原型。
  5. // 结果就是 instanceof 对所有这些构造函数都返回 true。
  6. console.log(Object.prototype.isPrototypeOf(instance)); // true
  7. console.log(SuperType.prototype.isPrototypeOf(instance)); // true
  8. console.log(SubType.prototype.isPrototypeOf(instance)); // true

8.3.1.3 原型链的问题

主要问题出现在原型中包含引用值的时,原型中包含的引用值会在所有实例间共享。
这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。

  1. function SuperType() {
  2. this.colors = ["red", "blue", "green"];
  3. }
  4. function SubType() {}
  5. // 继承 SuperType
  6. SubType.prototype = new SuperType();
  7. let instance1 = new SubType();
  8. instance1.colors.push("black");
  9. console.log(instance1.colors); // "red,blue,green,black"
  10. let instance2 = new SubType();
  11. console.log(instance2.colors); // "red,blue,green,black"

原型链的第二个问题是,子类型在实例化时不能给父类型的构造函数传参。
我们无法在不影响所有对象实例的情况下把参数传进父类的构造函数。
再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。

8.3.2 盗用构造函数

在子类构造函数中调用父类构造函数。
函数就是在特定上下文中执行代码的简单对象,所以可以使用apply()和 call()方法以新创建的对象为上下文执行构造函数。

  1. function SuperType() {
  2. this.colors = ["red", "blue", "green"];
  3. }
  4. function SubType() {
  5. // 继承 SuperType
  6. SuperType.call(this);
  7. }
  8. let instance1 = new SubType();
  9. instance1.colors.push("black");
  10. console.log(instance1.colors); // "red,blue,green,black"
  11. let instance2 = new SubType();
  12. console.log(instance2.colors); // "red,blue,green"

8.3.2.1 传递参数

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

  1. function SuperType(name){
  2. this.name = name;
  3. }
  4. function SubType() {
  5. // 继承 SuperType 并传参
  6. SuperType.call(this, "ming");
  7. // 实例属性
  8. this.age = 23;
  9. }
  10. let instance = new SubType();
  11. console.log(instance.name); // "ming";
  12. console.log(instance.age); // 23

8.3.2.2 盗用构造函数的问题

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

8.3.3 组合继承

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

  1. function SuperType(name){
  2. this.name = name;
  3. this.colors = ["red", "blue", "green"];
  4. }
  5. SuperType.prototype.sayName = function() {
  6. console.log(this.name);
  7. };
  8. function SubType(name, age){
  9. // 继承属性
  10. SuperType.call(this, name);
  11. this.age = age;
  12. }
  13. // 继承方法
  14. SubType.prototype = new SuperType();
  15. SubType.prototype.sayAge = function() {
  16. console.log(this.age);
  17. };
  18. let instance1 = new SubType("ming", 23);
  19. instance1.colors.push("black");
  20. console.log(instance1.colors); // "red,blue,green,black"
  21. instance1.sayName(); // "ming";
  22. instance1.sayAge(); // 23
  23. let instance2 = new SubType("Greg", 27);
  24. console.log(instance2.colors); // "red,blue,green"
  25. instance2.sayName(); // "Greg";
  26. instance2.sayAge(); // 27

8.3.4 原型式继承

  1. let person = {
  2. name: "Nicholas",
  3. friends: ["Shelby", "Court", "Van"]
  4. };
  5. let anotherPerson = object(person);
  6. anotherPerson.name = "Greg";
  7. anotherPerson.friends.push("Rob");
  8. let yetAnotherPerson = object(person);
  9. yetAnotherPerson.name = "Linda";
  10. yetAnotherPerson.friends.push("Barbie");
  11. console.log(person.friends); // "Shelby,Court,Van,Rob,Barbie"

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。
属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

8.3.5 寄生式继承

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

  1. function createAnother(original){
  2. let clone = object(original); // 通过调用函数创建一个新对象
  3. clone.sayHi = function() { // 以某种方式增强这个对象
  4. console.log("hi");
  5. };
  6. return clone; // 返回这个对象
  7. }
  8. let person = {
  9. name: "Nicholas",
  10. friends: ["Shelby", "Court", "Van"]
  11. };
  12. let anotherPerson = createAnother(person);
  13. anotherPerson.sayHi(); // "hi"

8.3.6 寄生组合继承

组合继承其实也存在效率问题。最主要的效率问题就是父类构造函数始终会被调用两次:
一次在是创建子类原型时调用,另一次是在子类构造函数中调用。

8.4 类

8.4.1 类定义

与函数类型相似,定义类也有两种主要方式:类声明和类表达式。这两种方式都使用 class 关键字加大括号

  1. // 类声明
  2. class Person {}
  3. // 类表达式
  4. const Animal = class {};

与函数表达式类似,类表达式在它们被求值前也不能引用。与函数定义不同的是,虽然函数声明可以提升,但类定义不能
另一个跟函数声明不同的地方是,函数受函数作用域限制,而类受块作用域限制

8.4.2 类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。
方法名 constructor 会告诉解释器在使用 new 操作符创建类的新实例时,应该调用这个函数。
构造函数的定义不是必需的,不定义构造函数相当于将构造函数定义为空函数。

使用 new 调用类的构造函数会执行如下操作。
(1) 在内存中创建一个新对象。
(2) 这个新对象内部的[[Prototype]]指针被赋值为构造函数的 prototype 属性。
(3) 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)。
(4) 执行构造函数内部的代码(给新对象添加属性)。
(5) 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象。

类本身具有与普通构造函数一样的行为。
在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。
重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用instanceof 操作符时会返回 false。
但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会反转

  1. class Person {}
  2. let p1 = new Person();
  3. console.log(p1.constructor === Person); // true
  4. console.log(p1 instanceof Person); // true
  5. console.log(p1 instanceof Person.constructor); // false
  6. let p2 = new Person.constructor();
  7. console.log(p2.constructor === Person); // false
  8. console.log(p2 instanceof Person); // false
  9. console.log(p2 instanceof Person.constructor); // true

8.4.3 实例、原型和类成员

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

  1. class Person {
  2. constructor() {
  3. // 添加到 this 的所有内容都会存在于不同的实例上
  4. this.locate = () => console.log('instance');
  5. }
  6. // 在类块中定义的所有内容都会定义在类的原型上
  7. locate() {
  8. console.log('prototype');
  9. }
  10. }
  11. let p = new Person();
  12. p.locate(); // instance
  13. Person.prototype.locate(); // prototype

8.4.4 继承

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

  1. class Vehicle {}
  2. // 继承类
  3. class Bus extends Vehicle {}
  4. let b = new Bus();
  5. console.log(b instanceof Bus); // true
  6. console.log(b instanceof Vehicle); // true
  7. function Person() {}
  8. // 继承普通构造函数
  9. class Engineer extends Person {}
  10. let e = new Engineer();
  11. console.log(e instanceof Engineer); // true
  12. console.log(e instanceof Person); // true

派生类的方法可以通过 super 关键字引用它们的原型。
这个关键字只能在派生类中使用,而且仅限于类构造函数、实例方法和静态方法内部。
在类构造函数中使用 super 可以调用父类构造函数。

  1. class Vehicle {
  2. constructor() {
  3. this.hasEngine = true;
  4. }
  5. }
  6. class Bus extends Vehicle {
  7. constructor() {
  8. // 不要在调用 super()之前引用 this,否则会抛出 ReferenceError
  9. super(); // 相当于 super.constructor()
  10. console.log(this instanceof Vehicle); // true
  11. console.log(this); // Bus { hasEngine: true }
  12. }
  13. }
  14. new Bus();