Skip to content

Latest commit

 

History

History
1309 lines (1020 loc) · 50.1 KB

08、对象、类与面向对象编程(下).md

File metadata and controls

1309 lines (1020 loc) · 50.1 KB

对象(Objects)、类(Class)与面向对象编程(OOP)(下)

本章内容

  • 理解对象
  • 理解对象创建过程
  • 理解继承
  • 理解类

本节讲解下半段:继承和类。

继承(Inheritance)

很多面向对象语言都支持两种继承:接口继承和实现继承。前者只继承方法签名,后者继承实际的方法。

接口继承在 ECMAScript 中是不可能的,因为函数没有签名。

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

扩展阅读:Signature (functions)(函数签名)

  • 一个函数签名 (或类型签名,或方法签名) 定义了函数或方法的输入与输出。
  • 签名可以用来实现类型检查、函数重载、接口等等,而这些东西 js 都没有。
  • 一个签名可以包括:
    • 参数及参数的类型
    • 一个返回值及其类型
    • 可能会抛出或传回的异常
    • 有关面向对象程序中方法可用性的信息 (例如关键字 public、static 或 prototype)。

原型链(prototype chain)

ECMA-262 把原型链定义为 ECMAScript 的主要继承方式。

其基本思想就是通过原型继承多个引用类型的属性和方法:

  • 构造函数、原型和实例的关系:每个构造函数都有一个原型对象,原型有一个属性指回构造函数,而实例有一个内部指针指向原型。

    person1.__proto__ = Person.prototype
    Person.prototype.constructor = Person
  • 原型链的基本构想:如果原型是另一个类型的实例,那就意味着这个原型本身有一个内部指针指向另一个原型,相应地另一个原型也有一个指针指向另一个构造函数。这样就在实例和原型之间构造了一条原型链。

    前置:
    person1.__proto__ = Person.prototype
    Person.prototype.constructor = Person
    
    原型链的构想:
    1 原型是另一个类型的实例:
    Coder.prototype = person1
    2 这个原型本身有一个内部指针指向另一个原型:
    coder1.__proto__ = Coder.prototype
    coder1.__proto__.__proto__ = Coder.prototype.__proto__ = person1.__proto__ = Person.prototype
    3 另一个原型也有一个指针指向另一个构造函数:
    coder1.__proto__.__proto__.constructor = Coder.prototype.__proto__.constructor = Person.prototype.constructor = Person
    4 简而言之的实例和原型之间构造了一条原型链:
    coder1.__proto__.__proto__.constructor  = Person
    
// SuperType 定义了一个属性和一个方法
function SuperType() {
  this.property = "this is property";
}
SuperType.prototype.getSuperValue = function () {
  return this.property;
};

// SubType 定义了一个属性
function SubType() {
  this.subproperty = "this is subproperty";
}

// SubType 通过创建 SuperType 的实例并将其赋值给自己的原型 SubTtype.prototype 实现了对 SuperType 的继承。
// 这个赋值重写了 SubType 最初的原型,将其替换为SuperType 的实例。
// 这意味着 SuperType 实例可以访问的所有属性和方法也会存在于 SubType.prototype 。
SubType.prototype = new SuperType();

// 这样实现继承之后,代码紧接着又给 SubType.prototype ,也就是这个 SuperType 的实例添加了一个新方法。
SubType.prototype.getSubValue = function () {
  return this.subproperty;
};

// 最后又创建了 SubType 的实例并调用了它继承的 getSuperValue() 方法。
let instance = new SubType();
console.log(instance); // SuperType { subproperty: 'this is subproperty' }
console.log(instance.getSuperValue()); // this is property // 继承的方法

console.log(instance.subproperty); // this is subproperty  // 自己的属性
console.log(instance.property); // this is property  // 继承的属性

// 根据原型链的构想,可以得到:
// instance.__proto__.__proto__.constructor = SuperType
// 这样一来, SubType 的实例不仅能从 SuperType 的实例中继承属性和方法,而且还与 SuperType 的原型挂上了钩。

// 原型与实例的关系的确定:instanceof 操作符 和 isPrototypeOf()实例方法 (下详)
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

原型链扩展了前面描述的原型搜索机制:

  • 在读取实例上的属性时,首先会在实例上搜索这个属性。如果没找到,则会继承搜索实例的原型。
  • 在通过原型链实现继承之后,搜索就可以继承向上,搜索原型的原型。
  • 对属性和方法的搜索会一直持续到原型链的末端

对前面的例子而言,调用 instance.getSuperValue() 经过了 3 步搜索:instance 、SubType.prototype 和 SuperType.prototype ,最后一步才找到这个方法。

1. 默认原型

默认情况下,所有引用类型都继承自 Object ,这也是通过原型链实现的。

  • 任何函数的默认原型都是一个 Object 的实例,这意味着这个实例有一个内部指针指向 Object.prototype 。
  • 这也是为什么自定义类型能够继承包括 toString() 、 valueOf() 在内的所有默认方法的原因。

因此前面的例子还有额外一层继承关系:

前面得到的内容:  
`coder1.__proto__.__proto__.constructor = Coder.prototype.__proto__.constructor = Person.prototype.constructor = Person`

因为`Person.prototype.__proto__` 也是一个普通对象:  
`Person.prototype.__proto__ = Object.prototype`

所以可得:  
`Person.prototype.__proto__.constructor = Object`

综上所述:  
`coder1.__proto__.__proto__.__proto__.constructor = Object`
`coder1.__proto__.__proto__.__proto__ = Object.prototype`

此外:  
`Object.prototype.__proto__ = null` //  原型链到此停止
所以:
`coder1.__proto__.__proto__.__proto__.__proto__ = null`

也就是说:code1 随着原型链的向上搜索(不含实例本身),最多就 4 次:Coder.prototype、Person.prototype、Object.prototype、null

2. 原型与继承关系

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

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

3. 关于方法

子类有时候需要覆盖父类的方法,或者增加父类没有的方法。为此,这些方法必须在原型赋值之后再添加到原型上

function SuperType() {
  this.property = "this is property";
}
SuperType.prototype.getSuperValue = function () {
  return this.property;
};

function SubType() {
  this.subproperty = "this is subproperty";
}

// 继承 SuperType
SubType.prototype = new SuperType();

// 以下两个方法都是在把原型赋值为 SuperType 的实例之后定义的。
//  getSubValue() 是 SubType 的新方法
SubType.prototype.getSubValue = function () {
  return this.subproperty;
};

//  getSuperValue() 是原型链上已经存在但在这里被遮蔽的方法
SubType.prototype.getSuperValue = function () {
  return "subType getSuperValue";
};

// 在 SubType 实例上调用 getSuperValue() 时调用的是这个覆盖后的方法
let instance = new SubType();
console.log(instance.getSuperValue()); // subType getSuperValue

// SuperType 的实例仍然会调用最初的方法
let superInstance = new SuperType();
console.log(superInstance.getSuperValue()); // this is property

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

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()); // TypeError: instance.getSuperValue is not a function

4. 原型链的问题

  • 主要问题出现在原型中包含引用值的时候
    • 原型中包含的引用值会在所有实例间共享,这也是为什么属性通常会在构造函数中定义而不会定义在原型上的原因。
    • 在使用原型实现继承时,原型实际上变成了另一个类型的实例。
    • 这意味着原先的实例属性摇身一变成为了原型属性。
  • 第二个问题是,子类型在实例化时不能给父类型的构造函数传参
    • 事实上,无法在不影响所有对象实例的情况下把参数传进父类的构造函数。
    • 再加上之前提到的原型中包含引用值的问题,就导致原型链基本不会被单独使用。

盗用构造函数(constructor stealing)

为了解决原型包含引用值导致的继承问题,一种叫作“盗用构造函数”(也称“对象伪装”或“经典继承”)的技术在开发社区流行起来。

基本思路很简单:在子类构造函数中调用父类构造函数

  • 因为毕竟函数就是在特定上下文中执行代码的简单对象,所以可以使用 apply() 和 call() 方法以新创建的对象为上下文执行构造函数
  • 相比于使用原型链,盗用构造函数的一个优点就是可以在子类构造函数中向父类构造函数传参
  • 盗用构造函数的主要缺点,也是使用构造函数模式自定义类型的问题:必须在构造函数中定义方法,因此函数不能重用
  • 此外,子类也不能访问父类原型上定义的方法,因此所有类型只能使用构造函数模式。
  • 由于存在这些问题,盗用构造函数基本上也不能单独使用
function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

function SubType() {
  // 继承 SuperType 并传参 ,在子类构造函数中向父类构造函数传参
  SuperType.call(this, "Nicholas");

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

  // 实例属性
  this.age = 29;
}

let instance1 = new SubType();
instance1.colors.push("black");
console.log(instance1.colors); // [ 'red', 'blue', 'green', 'black' ]
console.log(instance1.name); // "Nicholas";
console.log(instance1.age); // 29

let instance2 = new SubType();
console.log(instance2.colors); // [ 'red', 'blue', 'green' ]
console.log(instance2.name); // "Nicholas";
console.log(instance2.age); // 29

组合继承(combination inheritance)

组合继承(有时候也叫伪经典继承)综合了原型链和盗用构造函数,将两者的优点集中了起来。

基本的思路是使用原型链继承原型上的属性和方法,而通过盗用构造函数继承实例属性。

这样既可以把方法定义在原型上以实现重用,又可以让每个实例都有自己的属性。

组合继承弥补了原型链和盗用构造函数的不足,是 JavaScript 中使用最多的继承模式。

而且组合继承也保留了 instanceof 操作符和 isPrototypeOf() 方法识别合成对象的能力。

// SuperType 构造函数定义了两个属性, name 和 colors ,而它的原型上也定义了一个方法叫 sayName() 。
function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}
SuperType.prototype.sayName = function () {
  console.log(this.name);
};

// SubType 构造函数调用了 SuperType 构造函数,传入了 name 参数,然后又定义了自己的属性 age 。
function SubType(name, age) {
  // 继承属性
  SuperType.call(this, name);
  this.age = age;
}

// 继承方法
// SubType.prototype 也被赋值为 SuperType 的实例。
SubType.prototype = new SuperType();

// 原型赋值之后,又在这个原型上添加了新方法 sayAge() 。
SubType.prototype.sayAge = function () {
  console.log(this.age);
};

// 创建两个 SubType 实例,让这两个实例都有自己的属性,包括 colors ,同时还共享相同的方法
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

原型式继承(Prototypal Inheritance )

原型式继承适用于这种情况:你有一个对象,想在它的基础上再创建一个新对象。你需要把这个对象先传给 object() ,然后再对返回的对象进行适当修改。

ECMAScript 5 通过增加 Object.create() 方法将原型式继承的概念规范化了。

  • 这个方法接收两个参数:作为新对象原型的对象,以及给新对象定义额外属性的对象(第二个可选)。
  • 的第二个参数与 Object.defineProperties() 的第二个参数一样:每个新增属性都通过各自的描述符来描述。以这种方式添加的属性会遮蔽原型对象上的同名属性

Object.create()方法创建一个新对象,使用现有的对象来提供新创建的对象的__proto__。

let person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"],
};

let anotherPerson = Object.create(person, {
  name: {
    value: "Greg",
  },
});

console.log(anotherPerson); // {}
// 注意,在浏览器中可见具体内容:
// {
// name: "Greg"
// [[Prototype]]: Object
// friends: (3) ['Shelby', 'Court', 'Van']
// name: "Nicholas"
// [[Prototype]]: Object
// }
console.log(anotherPerson.name); // Greg

原型式继承非常适合不需要单独创建构造函数,但仍然需要在对象间共享信息的场合。

但属性中包含的引用值始终会在相关对象间共享,跟使用原型模式是一样的。

寄生式继承(Parasitic Inheritance)

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

//  原型式继承 , object() 是对传入的对象执行了一次浅复制。
// object() 函数不是寄生式继承所必需的,任何返回新对象的函数都可以在这里使用。
function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

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"

寄生式继承同样适合主要关注对象,而不在乎类型和构造函数的场景。

注意: 通过寄生式继承给对象添加函数会导致函数难以重用,与构造函数模式类似。

组合继承其实也存在效率问题。

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

寄生式组合继承(Parasitic Combination Inheritance)

寄生式组合继承通过盗用构造函数继承属性,但使用混合式原型链继承方法。

基本思路是不通过调用父类构造函数给子类原型赋值,而是取得父类原型的一个副本。

说到底就是使用寄生式继承来继承父类原型,然后将返回的新对象赋值给子类原型。

function object(o) {
  function F() {}
  F.prototype = o;
  return new F();
}

// 这个 inheritPrototype() 函数实现了寄生式组合继承的核心逻辑。
// 这个函数接收两个参数:子类构造函数和父类构造函数。
// 在这个函数内部,第一步是创建父类原型的一个副本。
// 然后,给返回的prototype 对象设置 constructor 属性,解决由于重写原型导致默认 constructor 丢失的问题。
// 最后将新创建的对象赋值给子类型的原型。
function inheritPrototype(subType, superType) {
  let prototype = object(superType.prototype); // 创建对象
  prototype.constructor = subType; // 增强对象
  subType.prototype = prototype; // 赋值对象
}

function SuperType(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

SuperType.prototype.sayName = function () {
  console.log(this.name);
};

// 里只调用了一次 SuperType 构造函数,避免了 SubType.prototype 上不必要也用不到的属性
function SubType(name, age) {
  SuperType.call(this, name);
  this.age = age;
}

inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function () {
  console.log(this.age);
};

let instance = new SubType("zhansan", 28);
console.log(instance.age); // 28
console.log(instance.colors); // [ 'red', 'blue', 'green' ]
instance.sayAge(); // 28
instance.sayName(); // zhansan

原型链仍然保持不变 , 因此 instanceof 操作符和 isPrototypeOf() 方法正常有效。

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

类(Class)

ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。类(class)是 ECMAScript 中新的基础性语法糖结构。

表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念

类定义

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

// 类声明
class Person {}

// 类表达式
const Animal = class {};
  • 与函数表达式类似,类表达式在它们被求值前也不能引用
  • 与函数定义不同的,虽然函数声明可以提升,但类定义不能
  • 与跟函数声明不同,函数受函数作用域限制,而类受块作用域限制

MDN Hoisting(变量提升)

// 函数表达式(不以function开头的函数语句就是函数表达式定义) 函数提升
console.log(FunctionExpression); // undefined
var FunctionExpression = function () {};
console.log(FunctionExpression); // [Function: FunctionExpression]

// 函数声明
console.log(FunctionDeclaration); // [Function: FunctionDeclaration]
function FunctionDeclaration() {}
console.log(FunctionDeclaration); // [Function: FunctionDeclaration]

// 变量提升
console.log(ClassExpression); // undefined
var ClassExpression = class {};
console.log(ClassExpression); // [Function: ClassExpression]

// 类定义不能提升
// ReferenceError: Cannot access 'ClassDeclaration' before initialization
// console.log(ClassDeclaration);     // ReferenceError: ClassDeclaration is not defined
class ClassDeclaration {}
console.log(ClassDeclaration); // [Function: ClassDeclaration]

// 函数受函数作用域限制,而类受块作用域限制
{
  function FunctionDeclaration2() {}
  class ClassDeclaration2 {}
}
console.log(FunctionDeclaration2); // [Function: FunctionDeclaration2]
console.log(ClassDeclaration2); // ReferenceError: ClassDeclaration2 is not defined

类的构成

  • 类可以包含构造函数方法、实例方法、获取函数、设置函数和静态类方法,但这些都不是必需的
    • 空的类定义照样有效。默认情况下,类定义中的代码都在严格模式下执行。
  • 与函数构造函数一样,建议类名的首字母要大写,以区别于通过它创建的实例。
  • 类表达式的名称是可选的。
    • 在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。
    • 但不能在类表达式作用域外部访问这个标识符。
// 空类定义,有效
class Foo {}

// 有构造函数的类,有效
class Bar {
  constructor() {}
}

// 有获取函数的类,有效
class Baz {
  get myBaz() {}
}

// 有静态方法的类,有效
class Qux {
  static myQux() {}
}

// 类表达式的名称是可选的。
// 在把类表达式赋值给变量后,可以通过 name 属性取得类表达式的名称字符串。
// 但不能在类表达式作用域外部访问这个标识符。
let Person = class PersonName {
  identify() {
    console.log(Person.name, PersonName.name);
  }
};

let p = new Person();

p.identify(); // PersonName PersonName

console.log(Person.name); // PersonName
console.log(PersonName); // ReferenceError: PersonName is not defined

类构造函数

constructor 关键字用于在类定义块内部创建类的构造函数。

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

constructor 是一种用于创建和初始化 class 创建的对象的特殊方法。

  • 在一个类中只能有一个名为 “constructor” 的特殊方法。
    • 一个类中出现多次构造函数 (constructor)方法将会抛出一个 SyntaxError 错误。
  • 在一个构造方法中可以使用 super 关键字来调用一个父类的构造方法。
  • 如果没有显式指定构造方法,则会添加默认的 constructor 方法。
    • 对于基类,默认构造函数是:
      constructor() {}
    • 对于派生类,默认构造函数是:
      constructor(...args) {
        super(...args);
      }
  • 如果不指定一个构造函数(constructor)方法, 则使用一个默认的构造函数(constructor)。

1. 实例化

class Animal {}

class Person {
  constructor() {
    console.log("person ctor");
  }
}

// 类实例化时传入的参数会用作构造函数的参数。
class Coder {
  constructor(name) {
    console.log(arguments.length);
    this.name = name || null;
  }
}

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

// 如果不需要参数,则类名后面的括号也是可选的
let p1 = new Coder(); // 0
console.log(p1.name); // null
let p2 = new Coder(); // 0
console.log(p2.name); // null
let p3 = new Coder("Jake"); // 1
console.log(p3.name); // Jake

使用 new 操作符实例化 Person 的操作等于使用 new 调用其构造函数。

唯一可感知的不同之处就是,JavaScript 解释器知道使用 new 和类意味着应该使用 constructor 函数进行实例化。

使用 new 调用类的构造函数会执行如下操作:

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

类实例化时传入的参数会用作构造函数的参数。如果不需要参数,则类名后面的括号也是可选的。

默认情况下,类构造函数会在执行之后返回 this 对象

  • 构造函数返回的对象会被用作实例化的对象,如果没有什么引用新创建的 this 对象,那么这个对象会被销毁。
  • 不过,如果返回的不是 this 对象,而是其他对象,那么这个对象不会通过 instanceof 操作符检测出跟类有关联,
  • 因为这个对象的原型指针并没有被修改。
class Person {
  constructor(override) {
    this.foo = "foo";
    if (override) {
      return {
        bar: "bar",
      };
    }
  }
}

let p1 = new Person(),
  p2 = new Person(true);
// 构造函数返回的对象会被用作实例化的对象
console.log(p1); // Person{ foo: 'foo' }
console.log(p1 instanceof Person); // true

// 返回的是其他对象,因为这个其他对象的原型指针并没有被修改,即没有指向该类
// 所以 instanceof 操作符检测不出跟类有关联
console.log(p2); // { bar: 'bar' }
console.log(p2 instanceof Person); // false

类构造函数与构造函数的主要区别是:

  • 调用类构造函数必须使用 new 操作符。
  • 而普通构造函数如果不使用 new 调用,那么就会以全局的 this (通常是 window )作为内部对象。
  • 调用类构造函数时如果忘了使用 new 则会抛出错误。
function Person() {}
class Animal {}

// 把 window 作为 this 来构建实例
let p = Person();
let a = Animal(); // TypeError: class constructor Animal cannot be invoked without 'new'

类构造函数没有什么特殊之处,实例化之后,它会成为普通的实例方法(但作为类构造函数,仍然要使用 new 调用)。 因此,实例化之后可以在实例上引用它。

class Person {}

// 使用类创建一个新实例
let p1 = new Person();

console.log(p1.constructor == Person); // true

// p1.constructor(); // TypeError: Class constructor Person cannot be invoked without 'new'
// 使用对类构造函数的引用创建一个新实例
let p2 = new p1.constructor();

2. 把类当成特殊函数

从各方面来看,ECMAScript 类就是一种特殊函数。

  • 1 声明一个类之后,通过 typeof 操作符检测类标识符,表明它是一个函数。
  • 2 类标识符有 prototype 属性,而这个原型也有一个 constructor 属性指向类自身。
  • 3 与普通构造函数一样,可以使用 instanceof 操作符检查构造函数原型是否存在于实例的原型链中。
  • 4 类本身具有与普通构造函数一样的行为。
    • 在类的上下文中,类本身在使用 new 调用时就会被当成构造函数。
    • 重点在于,类中定义的 constructor 方法不会被当成构造函数,在对它使用 instanceof 操作符时会返回 false 。
    • 但是,如果在创建实例时直接将类构造函数当成普通构造函数来使用,那么 instanceof 操作符的返回值会反转。
  • 5 类是 JavaScript 的一等公民(first-class citizens),因此可以像其他对象或函数引用一样把类作为参数传递。
    • 类其实是函数,与函数是 JavaScript 的一等公民一致。
class Person {}
// 1
console.log(Person); // [Function: Person]  // chrome中显示: class Person { }
console.log(typeof Person); // function

// 2
console.log(Person.prototype); // Person {} // chrome中显示: { constructor: f() }
console.log(Person === Person.prototype.constructor); // true

// 3
let p = new Person();
console.log(p instanceof Person); // true

// 4
let p1 = new Person();
console.log(p1.constructor === Person); // true
console.log(p1 instanceof Person); // true
console.log(p1 instanceof Person.constructor); // false
let p2 = new Person.constructor();
console.log(p2.constructor === Person); // false
console.log(p2 instanceof Person); // false
console.log(p2 instanceof Person.constructor); // true

// 5
// 类可以像函数一样在任何地方定义,比如在数组中
let classList = [
  class {
    constructor(id) {
      this.id_ = id;
      console.log(`instance ${this.id_}`);
    }
  },
];

function createInstance(classDefinition, id) {
  return new classDefinition(id);
}

let foo = createInstance(classList[0], 3141); // instance 3141
// 与立即调用函数表达式相似,类也可以立即实例化:
// 因为是一个类表达式,所以类名是可选的
let fo = new (class Foo {
  constructor(x) {
    console.log(x);
  }
})("bar"); // bar

console.log(fo); // Foo {}

实例、原型和类成员

类的语法可以非常方便地定义应该存在于实例上的成员、应该存在于原型上的成员,以及应该存在于类本身的成员。

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(); // [String: 'Jack']
p2.sayName(); // [String: '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. 原型方法与访问器

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

  • 可以把方法定义在类构造函数中或者类块中,但不能在类块中给原型添加原始值或对象作为成员数据。
  • 类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键
  • 类定义也支持获取和设置访问器。语法与行为跟普通对象一样
const symbolKey = Symbol("symbolKey");
class Person {
  constructor() {
    // 添加到 this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log("instance");
  }
  // 在类块中定义的所有内容都会定义在类的原型上
  locate() {
    console.log("prototype");
  }
  // 1 不能在类块中给原型添加原始值或对象作为成员数据
  // name: 'Jake' // SyntaxError: Unexpected identifier

  // 2 类方法等同于对象属性,因此可以使用字符串、符号或计算的值作为键
  stringKey() {
    console.log("invoked stringKey");
  }
  [symbolKey]() {
    console.log("invoked symbolKey");
  }
  ["computed" + "Key"]() {
    console.log("invoked computedKey");
  }
  // 3 类定义也支持获取和设置访问器。语法与行为跟普通对象一样
  set name(newName) {
    this.name_ = newName;
  }
  get name() {
    return this.name_;
  }
}

let p = new Person();
p.locate(); // instance
Person.prototype.locate(); // prototype

let p2 = new Person();
p2.stringKey(); // invoked stringKey
p2[symbolKey](); // invoked symbolKey
p2.computedKey(); // invoked computedKey

let p3 = new Person();
p3.name = "Jake";
console.log(p3.name); // Jake

3. 静态类方法

可以在类上定义静态方法。

  • 这些方法通常用于执行不特定于实例的操作,也不要求存在类的实例。
  • Like prototype members, these are only ever created once per class.与原型成员类似,静态成员每个类上只能有一个
  • 静态类成员在类定义中使用 static 关键字作为前缀。
  • 在静态成员中, this 引用类自身。其他所有约定跟原型成员一样。
  • 静态类方法非常适合作为实例工厂。

2022-03-02 释疑:

  • 1 在《JavaScript 高级程序设计(第 4 版)》的中文中,出现的这句话:“与原型成员类似,静态成员每个类上只能有一个。”给我产生了一个疑问:为什么每个类只能有一个静态成员?这个一个具体是什么意思?
    英文原文是这样的:“ Like prototype members, these are only ever created once per class.”意思应该是:静态成员在每个类上只能创建一次,不能重复创建。
  • 2 随后又开始怀疑,所谓“原型成员”、“静态成员”是不是有别的深意。全书中只出现了 3 次“原型成员(prototype members)”,2 次“静态成员”(英文版只有一次 static members)。
    类的静态成员,指类拥有的静态方法和静态属性。在 MDN 的 static 示例中有一句How a static member (method or property) is defined on a class.
    至于原型成员,大概也就是Person.prototype(原型对象)上的属性和方法吧。
    静态方法定义在类的内部(不是定义在实例对象 this 上),静态属性通过对象的属性访问器定义(新提案提供了关键字 static 用于定义)。
  • 3 其实,各是各的:
    原型成员 原型方法 原型属性
    静态成员 静态方法 静态属性
    实例成员 实例方法 实例属性
class Person {
  constructor(age) {
    this.age_ = age || 18;
    // 添加到 this 的所有内容都会存在于不同的实例上
    this.locate = () => console.log("instance", this);
  }

  // 定义在类的原型对象上
  locate() {
    console.log("prototype", this);
  }
  sayAge() {
    console.log(this.age_);
  }

  // 定义在类本身上
  static locate() {
    console.log("class", this);
  }
  // 静态类方法非常适合作为实例工厂
  static create() {
    // 使用随机年龄创建并返回一个 Person 实例
    return new Person(Math.floor(Math.random() * 100));
  }
}

let p = new Person();

p.locate(); // instance Person { age_: 18, locate: [Function] }
Person.prototype.locate(); // prototype Person {}
Person.locate(); // class [Function: Person]

console.log(Person.create()); // Person { age_: <随机数>, locate: [Function] }

4. 非函数原型和类成员

虽然类定义并不显式支持在原型或类上添加成员数据,但在类定义外部,可以手动添加:

class Person {
  sayName() {
    console.log(`${Person.greeting} ${this.name}`);
  }
}

// 在类上定义数据成员
Person.greeting = "My name is";
// 在原型上定义数据成员
Person.prototype.name = "Jake";

let p = new Person();
p.sayName(); // My name is Jake
console.log(p); // Person {}
console.log(p.__proto__); // Person { name: 'Jake' }
console.log(Person); // [Function: Person] { greeting: 'My name is' }

注意,类定义中之所以没有显式支持添加数据成员,是因为在共享目标(原型和类)上添加可变(可修改)数据成员是一种反模式。一般来说,对象实例应该独自拥有通过 this 引用的数据。

5. 迭代器与生成器方法

  • 类定义语法支持在原型和类本身上定义生成器方法。
  • 因为支持生成器方法,所以可以通过添加一个默认的迭代器,把类实例变成可迭代对象。
    • 也可以只返回迭代器实例。
class Person {
  // 在原型上定义生成器方法
  *createNicknameIterator() {
    yield "Jack";
    yield "Jake";
    yield "J-Dog";
  }

  // 在类上定义生成器方法
  static *createJobIterator() {
    yield "Butcher";
    yield "Baker";
    yield "Candlestick maker";
  }

  constructor() {
    this.nicknames = ["Cate", "Ceth", "Coph"];
  }

  // 添加一个默认的迭代器,把类实例变成可迭代对象
  *[Symbol.iterator]() {
    yield* this.nicknames.entries();
  }

  // // 或只返回迭代器实例(实例使用是一样的)
  // [Symbol.iterator]() {
  //     return this.nicknames.entries();
  // }
}

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

let p2 = new Person();
for (let [idx, nickname] of p2) {
  console.log(nickname);
}
// Cate
// Ceth
// Coph

继承

ECMAScript 6 新增特性中最出色的一个就是原生支持了类继承机制。虽然类继承使用的是新语法,但背后依旧使用的是原型链

一个 ECMAScript 类只能有一个单超类(单继承)。

1. 继承基础

ES6 类支持单继承

  • 使用 extends 关键字,就可以继承任何拥有 [[Construct]] 和原型的对象。
  • 很大程度上,这意味着不仅可以继承一个类,也可以继承普通的构造函数(保持向后兼容)
  • 派生类都会通过原型链访问到类和原型上定义的方法。 this 的值会反映调用相应方法的实例或者类。
    • 继承自其他类的类被称作派生类。被继承的类一般称为“超类”,也有叫做父类。
    • 如果在派生类中指定了构造函数则必须要调用 super(),如果不这样做程序就会报错。
    • 如果选择不使用构造函数,则当创建新的类实例时会自动调用 super()并传入所有参数。
  • 注意 extends 关键字也可以在类表达式中使用,因此 let Bar = class extends Foo {}是有效的语法。

基类、父类、超类是指被继承的类,派生类、子类是指继承于基类的类。通俗点:基类->派生类 <=> 父类->子类

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

console.log(b instanceof Bus); // true
console.log(b instanceof Vehicle); // true

// 派生类都会通过原型链访问到类和原型上定义的方法。 this 的值会反映调用相应方法的实例或者类
b.identifyPrototype("bus"); // bus Bus {}
v.identifyPrototype("vehicle"); // vehicle Vehicle {}

Bus.identifyClass("bus"); // bus [Function: Bus]  // chrome浏览器显示: bus class Bus extends Vehicle { }
Vehicle.identifyClass("vehicle"); // vehicle [Function: Vehicle] // chrome浏览器显示: vehicle class Vehicle {...}

function Person() {}
// 继承普通构造函数
class Engineer extends Person {}

let e = new Engineer();
console.log(e instanceof Engineer); // true
console.log(e instanceof Person); // true

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

派生类的方法可以通过 super 关键字引用它们的原型。

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

注意:
ES6 给类构造函数和静态方法添加了内部特性 [[HomeObject]] ,这个特性是一个指针,指向定义该方法的对象。
这个指针是自动赋值的,而且只能在 JavaScript 引擎内部访问。
super 始终会定义为 [[HomeObject]] 的原型。

class Vehicle {
  constructor() {
    this.hasEngine = true;
  }
  static identify() {
    console.log("vehicle");
  }
}

class Bus extends Vehicle {
  constructor() {
    // 不要在调用 super()之前引用 this,否则会抛出 ReferenceError

    // 在类构造函数中使用 super 可以调用父类构造函数
    super(); // 相当于 super.constructor()

    console.log(this instanceof Vehicle); // true
    console.log(this); // Bus { hasEngine: true }
  }

  // 在静态方法中可以通过 super 调用继承的类上定义的静态方法
  static identify() {
    super.identify();
  }
}

new Bus();
Bus.identify(); // vehicle

使用 super 时要注意几个问题:

  • super 只能在派生类构造函数和静态方法中使用。
  • 不能单独引用 super 关键字,要么用它调用构造函数,要么用它引用静态方法。
  • 调用 super() 会调用父类构造函数,并将返回的实例赋值给 this 。
  • super() 的行为如同调用构造函数,如果需要给父类构造函数传参,则需要手动传入。
  • 如果没有定义类构造函数,在实例化派生类时会调用 super() ,而且会传入所有传给派生类的参数。
  • 在类构造函数中,不能在调用 super() 之前引用 this 。
  • 如果在派生类中显式定义了构造函数,则要么必须在其中调用 super() ,要么必须在其中返回一个对象。
class Vehicle {}

// 没有显式指定构造方法,则会添加默认的 constructor 方法。
class Car extends Vehicle {}

// 显式定义了构造函数,则要么必须在其中调用 super() ,
class Bus extends Vehicle {
  constructor() {
    super();
  }
}

// 显式定义了构造函数,要么必须在其中返回一个对象。
class Van extends Vehicle {
  constructor() {
    return {};
  }
}

console.log(new Car()); // Car {}
console.log(new Bus()); // Bus {}
console.log(new Van()); // {}

3. 抽象基类

  • 1 有时候可能需要定义这样一个类,它可供其他类继承,但本身不会被实例化
    • 虽然 ECMAScript 没有专门支持这种类的语法 ,但通过 new.target 也很容易实现。
    • new.target 保存通过 new 关键字调用的类或函数。
    • 通过在实例化时检测 new.target 是不是抽象基类,可以阻止对抽象基类的实例化
  • 2 通过在抽象基类构造函数中进行检查,可以要求派生类必须定义某个方法
    • 因为原型方法在调用类构造函数之前就已经存在了,所以可以通过 this 关键字来检查相应的方法。
// 抽象基类
class Vehicle {
  constructor() {
    console.log(new.target);
    if (new.target === Vehicle) {
      throw new Error("Vehicle cannot be directly instantiated");
    }
    // 可以要求派生类必须定义某个方法。
    if (!this.foo) {
      throw new Error("Inheriting class must define foo()");
    }
    console.log("success!");
  }
}

// 派生类
class Bus extends Vehicle {
  foo() {}
}
// 派生类
class Van extends Vehicle {}

new Bus();
// [Function: Bus] // chrome : class Bus extends Vehicle { }
// success!

// 抽象基类不可实例化
new Vehicle();
// [Function: Vehicle] // chrome : class Vehicle {...}
// Error: Vehicle cannot be directly instantiated

// 派生类必须定义某个方法
new Van();
// [Function: Van] // chrome : class Van extends Vehicle { }
// Error: Inheriting class must define foo()

4. 继承内置类型

1 可方便通过继承 ES6 内置引用类型,扩展其功能。

2 有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的。

3 如果想覆盖这个默认行为,则可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类。

//  1 可继承 ES6 类内置引用类型
class SuperArray extends Array {
  shuffle() {
    // 洗牌算法
    for (let i = this.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [this[i], this[j]] = [this[j], this[i]];
    }
  }
}

let a = new SuperArray(1, 2, 3, 4, 5);

console.log(a instanceof Array); // true
console.log(a instanceof SuperArray); // true
console.log(a); // SuperArray(5) [1, 2, 3, 4, 5]
a.shuffle();
console.log(a); // SuperArray(5) [3, 1, 4, 5, 2]

// 2 有些内置类型的方法会返回新实例。默认情况下,返回实例的类型与原始实例的类型是一致的:
let a1 = new SuperArray(1, 2, 3, 4, 5);
let a2 = a1.filter((x) => !!(x % 2));

console.log(a1); // SuperArray(5) [1, 2, 3, 4, 5]
console.log(a2); // SuperArray(3) [1, 3, 5]
console.log(a1 instanceof SuperArray); // true
console.log(a2 instanceof SuperArray); // true

// 3 如果想覆盖这个默认行为,则可以覆盖 Symbol.species 访问器,这个访问器决定在创建返回的实例时使用的类:
class SuperArray2 extends Array {
  static get [Symbol.species]() {
    return Array;
  }
}

let a3 = new SuperArray2(1, 2, 3, 4, 5);
let a4 = a3.filter((x) => !!(x % 2));

console.log(a3); // SuperArray2(5) [1, 2, 3, 4, 5]
console.log(a4); // [1, 3, 5]
console.log(a3 instanceof SuperArray2); // true
console.log(a4 instanceof SuperArray2); // false

5. 类混入(Class Mixins)

把不同类的行为集中到一个类是一种常见的 JavaScript 模式。

虽然 ES6 没有显式支持多类继承,但通过现有特性可以轻松地模拟这种行为。

注意: Object.assign() 方法是为了混入对象行为而设计的。只有在需要混入类的行为时才有必要自己实现混入表达式。如果只是需要混入多个对象的属性,那么使用 Object.assign() 就可以了。

混入模式可以通过在一个表达式中连缀多个混入元素来实现,这个表达式最终会解析为一个可以被继承的类。

  • 如果 Person 类需要组合 A、B、C,则需要某种机制实现 B 继承 A,C 继承 B,而 Person 再继承 C,从而把 A、B、C 组合到这个超类中。
  • 实现这种模式有不同的策略。
    • 一个策略是定义一组“可嵌套”的函数,每个函数分别接收一个超类作为参数,而将混入类定义为这个参数的子类,并返回这个类。这些组合函数可以连缀调用,最终组合成超类表达式。

注意: 很多 JavaScript 框架(特别是 React)已经抛弃混入模式,转向了组合模式(把方法提取到独立的类和辅助对象中,然后把它们组合起来,但不使用继承)。这反映了那个众所周知的软件设计原则:“组合胜过继承(composition over inheritance)。”这个设计原则被很多人遵循,在代码设计中能提供极大的灵活性。

小结

JavaScript 的继承主要通过原型链来实现:

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

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

  • 原型式继承可以无须明确定义构造函数而实现继承,本质上是对给定对象执行浅复制。
    • 这种操作的结果之后还可以再进一步增强。
  • 与原型式继承紧密相关的是寄生式继承,即先基于一个对象创建一个新对象,然后再增强这个新对象,最后返回新对象。
    • 这个模式也被用在组合继承中,用于避免重复调用父类构造函数导致的浪费。
  • 寄生组合继承被认为是实现基于类型继承的最有效方式。

ECMAScript 6 新增的类很大程度上是基于既有原型机制的语法糖。

  • 的语法让开发者可以优雅地定义向后兼容的类,既可以继承内置类型,也可以继承自定义类型
  • 类有效地跨越了对象实例、对象原型和对象类之间的鸿沟。