跳到主要内容

9. 继承和类

继承

下面整理常见的继承方式:

  1. 经典继承 / 借用构造函数
  2. 组合继承
  3. 原型继承
  4. 寄生继承
  5. 寄生组合继承
  6. 最佳实践

简单整理:

// 1 原型链继承:子类原型继承父类属性
Son.prototype = new Father();
// 2 经典继承 / 借用构造函数:在Son内不用new直接调用Father构造函数
Function Son(name) {Father.call(this, arguments);}
// 1和2优缺点是互斥的,由此引出方法3:
// 「原型链继承」解决了父类 **方法复用** 的问题,出现了父类 **属性不独立** 的问题。
// 「借用构造函数」解决父类 **属性独立** 的问题,出现了父类 **方法无法复用** 的问题。

// 3 组合继承:调用2次 new Father() => Son内部调用(继承属性), Son.prototype绑定调用(继承方法)
function Son(name,age){ Father.call(this,name) }
Son.prototype = new Father()

// 4 原型式继承:自定义objet,父类子类所有方法都共用
// ES5-
function object(obj) {
function Son() {}
Son.prototype = obj;
return new Son()
}
// ES5
Student.prototype = Object.create(Person.prototype) // Student原型被重定义,constructor需要重新指向。
// ES6
Object.setPrototypeOf(Son.prototype, Father.prototype)

// 5 寄生式继承:是原型式的升级版,有object()的基础上,利用 createSon()实现Son增添新属性和方法。
function createSon(original) {
// let Son = Object.create(original);
let son = object(original);
son.sayHi = function () {
console.log('hi')
}
return son
}

// 4 和 5 都不会生成构造函数和类的概念,而是直接构造了实例对象:
let son1 = object(father); // 原型式
let son1 = createSon(father); // 寄生式

// 6 寄生组合式,继承父类方法调用inheritePrototype绑定原型链,继承父类方法用new Father()调用构造函数。
function inheritPrototype(Sub, Super) {
let subPrototype = Object.create(Super.prototype)
subPrototype.constructor = Sub
Sub.prototype = subPrototype
}

引子1:new 运算符

new 一个新对象的过程,发生了什么?

let person1 = new Person("Moxy", 15);

要创建 Person 的新实例,必须使用 new 操作符。以这种方式调用构造函数实际上会经历以下 5 个步骤:

  1. 创建一个 新对象 {}
  2. 为新对象绑定 原型链{}.__proto__ = Person.prototype
  3. 将构造函数的作用域 this 赋给新对象 {}
  4. 执行构造函数中的代码,为 {} 添加属性:Person.call(this)
  5. 如果构造函数最终会返回一个对象,就返回 构造函数中的对象
  6. 如果构造函数没有返回其他对象,就会返回 新对象

最终,代码中左侧的 person1 变量接收到了新创建的那个对象。

引子2:分析角度

不同继承方法的区别,主要从以下几个角度分析:

  1. 继承的类型主要有 3 种:基本数据类型、引用数据类型、方法;
  2. 实现继承的方式主要有 3 种:原型链式、构造函数式、原型式。 通过这 3 种的相互借鉴 / 引申 ,衍生了:
    • 组合式(原型链 + 构造函数);
    • 寄生式(原型式衍生);
    • 寄生组合式(寄生 + 原型链 + 构造函数)。
  3. 继承方法的记忆顺序:原型链、构造函数、原型、组合、寄生、寄生组合。一共有 6 种。

下面介绍的各种继承形式的整体模型,子类 Son 去继承父类 Father:

父类 Father

  • 基本属性:name
  • 引用属性:color
  • 方法:sayName()

子类 Son

  • 基本属性:age
  • 方法:sayAge()

事实上,没有必要去区分引用属性和基本属性。引用属性是引用类型,保存的是一个地址值,这一点和类中的方法一样。与方法不同的是,通常引用属性(比如数组)是实例属性,要在实例化对象的时候初始化一个实例化对象独有的属性;而方法则是要通过原型链共享,不会在实例化对象中重新再创建一遍。

引子3:其他概念

1. 多态

Java等语言中的多态:

不同的数据类型(输入),进行同一个操作(方法),会产生不同的行为(输出),就是多态。

JavaScript 的多态:

Js 中并不能完全实现多态,

父类的通用行为,可以被子类用更特殊的行为重写,这就是多态。

实际上,相对多态性允许我们从重写行为中引用基础行为。

所以,当子类使用和父类相同的名称命名一个新方法时,就发生了多态。

  • 比如父类有一个方法:Person.prototype.callName(),子类拥有一个相同的方法:Student.prototype.callName(),就会发生覆盖,这就是多态。
// js 不需要继承,也能有多态的体现
function sum(m, n) {
return m + n;
}

// 传入不同的数据类型,得出的结果也不相同,这也是多态的体现。
sum(20, 30);
sum('ninjee', 'moxy');

附:传统面向对象语言对多态的 3 个前提:

  1. 必须有继承(多态的前提)
  2. 必须有重写(子类重写父类的方法)
  3. 必须有父类的引用指向子类的对象

2. 构造函数

类的实例化是通过一个特殊的 方法 构造的,这个方法的名称通常和类名相同,被称为 构造函数

构造函数的主要任务就是初始化实例需要的所有信息和状态。

3. 继承

定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。

子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新的行为。

4. 混入 mixin

定义:把另一个对象中的部分方法和属性,复制到目标对象中,达到增强目标对象功能的效果。

最典型的混入,就是 Object.assign(targetObj, ...sourcesObj) 方法。该方法会将 source 对象里面的可枚举属性复制到targetObj。如果和 target 对象已有属性重名,则会覆盖。同时后续的 source对象 会覆盖前面的 source 对象的同名属性。

引子4:原型对象

类中原型对象主要有 3 个作用:

  • 通过原型对象的 .constructor,让实例化对象找到构造它的函数;
  • 实例对象公用的方法,会绑定在它的原型对象上。
  • 通过原型对象,形成一条最终通向 Object.prototype 的完整原型链,实现其他方法的继承。

1 原型链继承

基本思想:继承父类的属性和方法,全部依赖原型链实现。

核心代码:Son.prototype = new Father();

  1. 得到父类的实例化对象,
  2. 把子类原型对象修改为第一步得到的实例化对象。

Son的原型对象 Son.prototype 不再是原配,而变成 Father的实例化对象,所以此时 Son 原型对象的构造函数属性Son.prototype.constructor 丢失,需要重新绑定。

截屏2022-07-26 15.57.02

优点:

  • 方法复用,父类的方法绑定在原型链上,可以被正确的复用。

缺点:

  • 属性不独立,原型链继承,父类的属性无法实例化到对象上,而是和方法一样被复用、共用。
    1. 不可初始化。子类实例对象无法对继承的父类属性进行初始化,只能初始化子类的属性。
    2. 内容修改。子类实例对象(instance1 和 instance2)会指向同一个引用属性(如 array),这导致如果其中一个实例对象对引用属性的 内容 进行修改 instance1.color.push('pink'),instance2 的 color 也会跟着改变。
    3. 显式打印。继承的父类属性,无法显式通过 console.log(instance1) 打印出来。
  • 子类原型对象的构造函数属性丢失,需要重新绑定 .constructor
// 父类构造函数、父类属性
function Father() {
this.name = "Moxy";
this.color = ['red', 'yellow', 'black'];
}
// 父类原型、父类方法
Father.prototype.sayName = function () {
console.log(this.name);
};

// 子类构造函数、子类属性
function Son(age) {
this.age = age;
};
// 子类原型、继承父类属性
Son.prototype = new Father();
// 子类方法
Son.prototype.sonFunc = function() {
console.log("Son Function");
}

// 实例化测试:
let instance1 = new Son("12");
let instance2 = new Son("18");
console.log(instance1.__proto__.color === instance2.__proto__.color)
// true,父类属性被子类实例化对象公用
instance1.sayName(); // Moxy,方法成功继承


// 关于公用属性测试:
instance1.color.push('pink'); // 4
instance2.color; // ['red', 'yellow', 'black', 'pink'] 对引用属性的操作,不会发生赋值屏蔽

instance1.name = "ninjee" // 'ninjee' 对原型链上的属性 name 赋值屏蔽,
instance2.name; // 'Moxy' 而不是直接修改原型链的原属性值

instance1.color = "hello world"; //'hello world' 这里发生了赋值屏蔽。
instance2.color; // ['red', 'yellow', 'black', 'pink']

2 经典继承 / 借用构造函数

基本思想:在子类的构造函数内,调用父类的构造函数,从而引入父类的属性和方法。

总体评价:总体来说,经典继承和原型链继承的优缺点是相反的。经典继承解决了原型链继承的缺点,原型链继承解决了经典继承的缺点。同时,父类的所有属性(方法),全部重新创建到了子类中。

截屏2022-07-26 15.57.22

优点:

  • 属性独立,原型链中引用类型值独立,不被所有实例共享;
  • 可传递参数,子类型创建时也能够向父类型传递参数,用于不同实例属性值的初始化。

å缺点:

  • 方法无法复用。
    • 要继承的方法必须在父类的构造函数中定义,调用父类构造函数后,实例化对象内部也创建了对应的方法,没有复用函数。
    • 父类定义的方法(不在构造函数内部的),对子类不可见,因为此处 没有涉及到原型链
function Father(name){
this.name = name;
this.colors = ["red","blue","green"];
}
function Son(name){
Father.call(this, arguments); //继承了Father,且向父类型传递参数
}

// 实例化测试:
let instance1 = new Son("Moxy");
let instance2 = new Son("Ninjee");

instance1.colors.push("black");
console.log(instance1.colors); //"red,blue,green,black"
console.log(instance2.colors); //"red,blue,green" 引用类型值是独立的

小总结:

原型链继承和借用构造函数继承,完全到了两种不同的思路,优缺点几乎可以认为是互斥的:

原型链继承解决了父类 方法复用 的问题,出现了父类 属性不独立 的问题。

借用构造函数解决了父类 属性独立 的问题出现了父类 方法无法复用 的问题。

  • 尤其注意:引用数据类型的复用问题。

核心:构造函数内部,只适用于存放未来会实例化的属性;原型链,只适用于绑定复用的方法。

3 组合继承

总体评价:原型链 + 经典继承。JavaScript 中 最常用 的继承模式。

基本思想:

  1. 利用构造函数:子类借用构造函数,来实现对父类属性的继承。

    Father.call(this,name);

  2. 利用原型链:子类原型指向实例化的父类,来实现对父类方法的继承。 Son.prototype = new Father();

截屏2022-07-26 15.57.38

优点

避免了原型链和借用构造函数的各自的缺陷,融合了它们的优点。

  1. 原型链优点:父类的方法得到了复用;

  2. 构造函数优点:父类属性都可以重定义在实例化对象中。

  3. 此外,instanceofisPrototypeOf( ) 可正确使用,表明实例化对象的类型。

缺点:

额外的开销。在定义子类继承父类方法的时候,直接调用 Son.prototype = new Father() 这造成了两个后果:

  1. 额外调用了 1 次 Father(),造成了额外的开销。如果有实例化 n 个对象,一共调用了 1 + n 次。
  2. new Father() 附带了父类构造函数的属性,这些属性也绑定在了 Son.prototype 子类原型上,多了额外无用的变量。
  3. Son 的新原型对象(Father 实例对象),需要重新指向.constructor
// 父类构造函数、父类属性
function Father(name){
this.name = name;
this.colors = ["red","blue","green"];
}
// 父类原型、父类方法
Father.prototype.sayName = function(){
console.log(this.name);
};

// 子类构造函数、子类属性、继承父类属性
function Son(name,age){
Father.call(this,name); //继承父类属性。实例化1次,就调用1次Father()
this.age = age;
}
// 子类原型、继承父类方法
Son.prototype = new Father(); //只在定义子类时,调用1次Father()
Son.prototype.sayAge = function(){
console.log(this.age);
}


// 实例化测试:
let instance1 = new Son("Moxy",16);
let instance2 = new Son("Ninjee",26);
instance1.colors.push("black");

instance1 // Son {name: "Moxy", colors: Array(4), age: 16}
instance2 // Son {name: "Ninjee", colors: Array(3), age: 26}

4 原型式继承

场景:当你有一个对象,想在它的基础上再创建一个新对象,然后再对新对象进行适当的修改,使用此方式继承。

基本思想:利用一个自定义的 object() 方法,将旧对象(父对象)绑定到实例化对象(子对象)中。这里没有了父类和子类的概念,而是只有父对象和子对象。

形式一:自定义 object()

  1. 创建一个空的子类构造函数 Son
  2. 把要定义的父类对象和方法,绑定在子类原型的原型链上。
  3. 调用子类构造函数,实例化对象。
  4. 返回实例化的对象。

截屏2022-07-26 15.58.12

优点:

  1. 弱化了子类和父类的概念,只是把"子类"的构造函数当作一个壳子,用于把原有对象绑定到新创建的对象上,相当于:son1.__proto__ 指向 father
  2. 即使不自定义类型,也可以通过原型实现对象之间的属性、方法共享,就像使用原型模式一样。
  3. 换句话说,如果你有一个对象,想在它的基础上再创建一个新对象。则利用 object() 便可以实现。

缺点:

  1. 属性和方法全部共用。这其实相当于把原有对象(代码中的 father)的全部属性和方法,全绑定到实例化对象的 原型链 上。这导致所有实例化对象(son1, son2)都是一模一样的,而且属性和方法全部共用。
  2. 父对象的方法、属性原封不动的照搬。无法在实例化子对象的过程中,创建新的属性和方法(object 中,子对象的构造函数是空的)。
// 形式一:自定义object()
function object(obj) {
function Son() {}
Son.prototype = obj;
return new Son();
}

// or ES6
function object2(obj) {
const son = {};
Object.setPrototypeOf(son, obj);
return son;
}

// or ES5
// Object.create(obj) 就是规范化了的 object 函数,直接使用

// 定义父对象(无构造函数、无原型链)
const father = {
name: "Moxy",
colors: ["red", "blue", "green"],
sayName: function () {
console.log(this.name)
}
};

// 实例化测试:
const son1 = object(father);
const son2 = object(father);
son1.colors.push("black");
son2.colors.push("pink");
console.log(father.colors); //(5) ["red", "blue", "green", "black", "pink"]

形式二:ES5 的方法 object.create()

在 ECMAScript5 中,通过新增 object.create() 方法规范化了上面的原型式继承,在实现原理上是一摸一样的。

object.create() 会返回一个绑定好原型链的新对象,可以接收两个参数:

  1. 一个对象:新对象原型链指向的对象;

  2. 一个对象(可选):为新对象定义额外属性。

  • 第二个参数,与 Object.defineProperties() 方法的第二个参数格式相同:
    1. 每个属性都是通过自己的描述符定义的;
    2. 以这种方式指定的 任何属性 都会覆盖原型对象上的同名属性。

截屏2022-07-26 16.22.07

优点:

  1. 规范了形式一(引子),这里额外增加了类与类之间的关系。
  2. 实例属性和共用方法都可以正确的被定义:父类的方法可以被子类复用,父类的实例属性子类可以单独创建出来。

缺点:调用 Student.prototype = Object.create(Person.prototype)

  • Object.create() 会创建一个新对象,原有的 Student 原型对象被替换掉了。这导致 3 个问题:
    1. .constructor 丢失 ,子类新的原型对象的 .constructor 属性需要重新指向其构造函数。
    2. 子类方法顺序 ,需要先替换子类的原型对象 Student.prototype ,后在新原型对象上定义子类的公有方法。
    3. 性能损失。子类的旧原型对象没有实际用处,出生就被弃用,造成额外的内存和任务开销。
// 定义父类:实例属性 + 公有方法
function Father(name) {
this.name = name
this.colors = ["red", "blue", "green"]
}
Father.prototype.printName = function () {
console.log(this.name)
}

// 定义子类:实例属性
function Son(name, age) {
Father.call(this, name)
this.age = age
};
// 方法二:原型式继承。子类重新创建一个原型对象,然后恢复constructor的正确指向。
Son.prototype = Object.create(Father.prototype);
Son.prototype.constructor = Son;

// 定义子类:公有方法,只有在更换了原型对象后,子类才能定义公有方法
Son.prototype.printAge = function () {
console.log(this.age)
}

// 实例化测试:
let instance1 = new Son("Moxy", 99); // Son {name: "Moxy", colors: Array(3), age: 99}
let instance2 = new Son("Ninjee", 5); // Son {name: "Ninjee", colors: Array(3), age: 5}
instance1.printName === instance2.printName // true
Son.prototype.__proto__ === Father.prototype // true

形式三:ES6 方法:Object.setPrototypeOf(Son, Father)

🌟最佳实践:该方法是 ES6的最佳实践,也是寄生组合式继承。

正因为 ES5 Object.create() 会有重新定义子类原型对象的问题。所以 ES6 的新方法 Object.setPrototypeOf(Son.prototype, Father.prototype) 应运而生。

它的实际作用就是:Son.prototype.__proto__ == Father.prototype

  • 为什么不直接使用 __proto__ 属性呢?因为直接使用 __proto__ 不安全,它不是 JS 官方标准,而是主流浏览器为了方便去定义的一个属性。这个属性并不能完全的兼容所有浏览器等其他环境。

截屏2022-07-26 16.25.18

优点:解决上形式二的 3 个缺点。

  1. 没有 Object.Object() 的性能损失。因为不需要抛弃子类的旧原型对象,而不会导致垃圾回收;
  2. 没有 Object.Object() 的混乱代码顺序和 .constroctor 的重定向问题。

缺点:

  1. 真看不出有什么缺点。可能最重要的缺点就是,该方法是 ES6 方法,需要 JS 支持 ES6 环境。
// 定义父类:实例属性 + 公有方法
function Father(name) {
this.name = name
this.colors = ["red", "blue", "green"]
}
Father.prototype.printName = function () {
console.log(this.name)
}

// 定义子类:实例属性 + 公有方法。现在公有方法可以紧接着实例属性去定义了
function Son(name, age) {
Father.call(this, name)
this.age = age
};
Son.prototype.printAge = function () {
console.log(this.age)
}
// 方法三:原型式继承。使用ES6方法,
Object.setPrototypeOf(Son.prototype, Father.prototype)

// 实例化测试:
let instance1 = new Son("Moxy", 99); // Son {name: "Moxy", colors: Array(3), age: 99}
let instance2 = new Son("Ninjee", 5); // Son {name: "Ninjee", colors: Array(3), age: 5}
instance1.printName === instance2.printName // true
Son.prototype.__proto__ === Father.prototype // true

5 寄生式继承

基本思想:

  1. “寄”:利用原型式,在有一个对象的基础上,创建出子对象。让子对象通过原型链继承父对象的全部属性和方法。
  2. “生”:增强子对象,为子对象创建自己的属性和方法。

优点:

  1. 原型式继承的全部优点:不定义类型,便可以通过原型实现对象之间的属性、方法共享。
  2. 避免了原型式继承的第二个缺点。寄生式继承可以在实例化子对象的过程中,创建新的属性和方法。

缺点:

  1. 父类的属性和方法全部复用。
  2. 子类的属性无法在创建时初始化
// 形式二:自定义object()
function object(obj) {
function Son() {}
Son.prototype = obj;
return new Son();
}

// 定义父对象(无构造函数、无原型链)
const father = {
name: "Moxy",
colors: ["red", "blue", "green"],
sayName: function () {
console.log(this.name)
}
};

// 【工厂函数】增强子对象、为子对象添加自己的方法
function createSon(original, age) {
// 形式一:ES5的Object.create()
// const Son = Object.create(original);

const son = object(original);
// 【寄生】增强对象,添加属于自己的属性+方法
son.age = age;
son.sayHi = function () {
console.log('hi')
};
return son
}

/// 实例化测试
const son1 = createSon(father, 12);
const son2 = createSon(father, 18);
son1 // Son {age: 12, sayHi: ƒ}
son2 // Son {age: 18, sayHi: ƒ}
son1.colors.push("black");
son2.colors.push("pink");
console.log(father.colors); // (5) ["red", "blue", "green", "black", "pink"]

son1 // Son {sayHi: ƒ}

小总结

总结1:原型式继承、寄生式继承和浅拷贝对象:

问题:上文说过,原型式继承的优点是即使不自定义类型,也可以通过原型实现对象之间的属性、方法共享。那么问题来了,新对象直接浅拷贝原对象,然后在给自己添加额外的方法,不也实现了属性和方法的共享吗?

解答:浅拷贝不是 “动态的”。新对象通过原型式继承,获得了旧对象的全部属性和方法,如果此后旧对象的属性和方法发生变动,因为原型链的关系,新对象继承的属性和方法也会 “动态” 的发生变动。而单纯的浅拷贝是静态的,旧对象的基本数据属性发生变动,新对象不会更新。(旧对象的引用数据源类型和方法都是地址值传递,新对象也会得到更新)

总结 2:原型式继承和寄生式继承的区别:

原型式继承:基于已有的对象(原型对象)创建新对象

  • 原型式继承实现了 Object.create() 的第一个参数;

寄生式继承:创建一个用于封装继承过程的函数。函数内部不仅实现了 ”基于已有的对象(原型对象)创建新对象“ 的原型式继承,也实现了对新对象的增强,添加更多属性和方法

  • 寄生式继承实现了Object.create() 的第一个和第二个参数;

6 寄生组合式继承

寄生组合式的本质原理就是原型式继承,只是把原型式继承的多行代码,通过 inheritPrototype 📦 封装起来,更简洁了。

// 【📦】中间函数、传入子类、父类两个构造函数
function inheritPrototype(Sub, Super) {
//重定义一个[子类原型对象]subPrototype,此时有__proto__和constructor两个属性,即:
//Sub.prototype.__proto__ 指向 Super.prototype 父类原型对象
//Sub.prototype.constructor 指向 Sub 子类构造函数
// 这里使用了现成的Object.create,最原始应该是自定义的3行object函数
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
}

// 父类构造函数:属性+方法
function Father(name) {
this.name = name;
this.color = ["red", "green", "blue"];
}
Father.prototype.sayName = function () {
console.log(this.name);
}

// 子类构造函数:继承父类属性+子类属性
function Son(name, age) {
Father.call(this, name); // 只调用一次父类构造方法
this.age = age;
}

// 子类继承父类方法
inheritPrototype(Son, Father);
// or 方法三:原型式继承。使用ES6方法,这个方法没有顺序规定
// Object.setPrototypeOf(Son.prototype, Father.prototype)

// 子类方法,在Son.prototype被正确定义后才能添加
Son.prototype.sayAge = function () {
console.log(this.age);
}

// 实例化测试:
let instance1 = new Son("Moxy", 18)
let instance2 = new Son("Ninjee", 28)
instance1.color.push("pink")
console.log(instance1.color) // (4) ["red", "green", "blue", "pink"]
console.log(instance2.color) // (3) ["red", "green", "blue"]

instance1 // Son {name: "Moxy", color: Array(4), age: 18}
console.log(instance1.__proto__) // Father {constructor: ƒ, sayAge: ƒ}
console.log(instance1.__proto__.__proto__) // {sayName: ƒ, constructor: ƒ}

组合式继承原本需要调用两次父类的构造方法:

  1. 在子类的构造函数中调用,目的是把 父类的属性 复制到子类的实例化对象中;
  2. 在设置子类继承父类时调用,目的是继承 父类的方法 ,由此产生的代价就是在子类的原型链上,多了一组 父类的属性。

而寄生组合式,只调用一次父类构造方法,为了把 父类的属性 复制到实例化对象中。子类继承父类的方法,通过寄生方式进行。即父类方法直接设置在子类实例化对象的 prototype 原型链上。

  • 父类方法的继承:通过原型链在实例化的时候绑定。
  • 父类属性的继承:通过构造函数在实例化的时候重新创建。

Object.create()

Object.create()方法的实质是:新建一个空的构造函数 F,然后让 F.prototype 属性指向参数对象o,最后返回一个 F 的实例,从而实现让该实例继承 obj 的属性。

实际上,Object.create() 方法可以用下面的代码代替。

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

// 因为构造函数F中没有任何代码, new F() 返回了一个空对象,所以这也相当于:
Object.create = function (o) {
let newObj = {};
nreObj.__proto__ = o;
return newObj;
}

在寄生式继承中,Object.create() 实际上做了如下操作:

let obj = Object.create(superType)

// 替换为:
let obj = {}
obj.__proto__ = superType

在寄生组合式继承中,Object.create() 实际上做了如下操作:

let subPrototype = Object.create(Super.prototype)

// 替换为:
let subPrototype = {}
subPrototype.__proto__ = Super.prototype

7 继承的最佳实践:

  1. ES5 版本最佳实践:寄生组合式继承,使用 Object.create()
  2. ES6 版本最佳实践:原型式继承的形式三:Object.setPrototypeOf(Son.prototype, Father.prototype)
  3. ES5 以下最佳实践:寄生组合式继承,使用 object 自定义函数;

(3)ES5 最原始版本寄生组合式继承:

// 📦中间函数
function inheritPrototype(Sub, Super) {
Sub.prototype = object(Super.prototype);
Sub.prototype.constructor = Sub;

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

// 父类
function Father(name) {
this.name = name;
this.color = ["red", "green", "blue"];
}
Father.prototype.sayName = function () {
console.log(this.name);
}

// 子类
function Son(name, age) {
Father.call(this, name);
this.age = age;
}
inheritPrototype(Son, Father);
Son.prototype.sayAge = function () {
console.log(this.age);
}


// 实例化测试:
let instance1 = new Son("Moxy", 18)
let instance2 = new Son("Ninjee", 28)
instance1.color.push("pink")
console.log(instance1.color) // (4) ["red", "green", "blue", "pink"]
console.log(instance2.color) // (3) ["red", "green", "blue"]

instance1 // Son {name: "Moxy", color: Array(4), age: 18}
console.log(instance1.__proto__) // Father {constructor: ƒ, sayAge: ƒ}
console.log(instance1.__proto__.__proto__) // {sayName: ƒ, constructor: ƒ}

(2)ES5 的中间函数:

// 📦中间函数
function inheritPrototype(Sub, Super) {
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
}

(1)ES6 的继承:

// 父类
function Father(name) {
this.name = name;
this.colors = ["red", "blue", "green"];
}
Father.prototype.printName = function () {
console.log(this.name);
}

// 子类:
function Son(name, age) {
Father.call(this, name);
this.age = age;
};
Son.prototype.printAge = function () {
console.log(this.age);
}

Object.setPrototypeOf(Son.prototype, Father.prototype);

// 实例化测试:
let instance1 = new Son("Moxy", 99); // Son {name: "Moxy", colors: Array(3), age: 99}
let instance2 = new Son("Ninjee", 5); // Son {name: "Ninjee", colors: Array(3), age: 5}
instance1.printName === instance2.printName // true
Son.prototype.__proto__ === Father.prototype // true

引用:

8 Class 类

class Person 相当于 function Person(),是一个 function Person 的语法糖。

8.1 类的结构

包含:构造函数 (类名)、构造器 constructor、实例方法、获取函数 get、设置函数 set、静态方法 static

// 构造函数
class Person {
// 构造器
constructor(name, age){
this.name = name;
this.age = age;
this.nickname_ = "none";
}
// 实例方法:在类的原型对象上定义
callName(){
console.log(this.name)
}
// 静态方法:在类(构造函数)上定义
static callHello() {
console.log("Hello")
}
// get 函数:在类的原型对象上定义
get nickname() {
return this.nickname_;
}
// set 函数:在类的原型对象上定义
set nickname(newName) {
if(newName !== this.nickname_) {
this.nickname_ = newName;
} else {
console.log("Cannot duplicate the original nickname")
}
}

// 在constructor外设置属性,和在 constructor 内一样,会赋值到实例化对象中,不论是 属性 还是 方法。
// - 通过属性名的方式定义的方法,就会绑定到实例化对象上,而不是在原型上。
perName = "Happy";
callPerName = function() { // 函数表达式
console.log(this.perName)
};
}

// 测试:
// 类的实例化:
let p = new Person("Moxy", 18)
// 实例方法
p.callName() // Moxy
// 静态方法
Person.callHello() // Hello
// get 函数
p.nickname = "Ninjee" // 'Ninjee'
// set 函数
p.nickname; // 'Ninjee'
// 实例化属性 + 方法
p1.perName // 'Happy'
p1.callPerName() // Happy
// typeof
typeof Person // function,class Person 实际上是一个函数

注意:

  • 在 constructor 外定义方法:最后会在 Person.prototype 类的原型上定义,是公有方法;
  • 在 constructor 外定义属性:最后会在 "类的实例化对象" 上定义,是实例化属性的私有方法;
    • 定义属性也可以赋值为一个方法(函数表达式),这样就拥有一个实例化方法了;
    • 这其实和在 constructor 中定义是一模一样的。
1.1.1 类的实例化
let p = new Person()

使用 new 调用 class ,会执行如下操作:

  1. 创建一个 新对象 {}
  2. 为新对象绑定 原型链{}.__proto__ = Person.prototype
  3. 将 class 的作用域 this 赋给新对象 {}
  4. 执行 class 中的代码,为 {} 添加属性: constructor.call(this, params...);
    • 在 constructor 外的、没有 getsetstatic 标识符的属性,全部定义到 {} 中。
  5. 如果构造函数最终会返回一个对象,就返回 构造函数中的对象
  6. 如果构造函数没有返回其他对象,就会返回 新对象

最终,代码中左侧的 p 变量接收到了新创建的那个对象。

1.1.2 static 静态方法

静态方法,会把这个方法添加到 class 构造函数自身(类名上),而不是其 .prototype 原型链上。所以实例化对象无法使用这个函数,而是通过 类名直接调用。

class Foo{
constructor(){}
static callHello(){
console.log("Hello")
}
}

Foo.callHello() // Hello

let f = new Foo()
f.callHello() // f.callHello is not a function

static 的一种常见用法,可以随机实例化对象,下面就是随机化生成 Person 实例化的例子:

class Person {
constructor(name, age){
this.name = name;
this.age = age;
this.nickname_ = "none";
}

callName(){
console.log(this.name)
}

static randomPerson() {
const names = ["Jack", "Marry", "Bob", "Jobs", "Happy", "Moon", "Ted"];
const nameIndex = Math.floor(Math.random() * names.length);
const name = names[nameIndex];
const age = Math.floor(Math.random() * 30);
return new Person(name, age);
}
}

for (let i = 0; i < 10; i++)
console.log(Person.randomPerson());
// Person {name: 'Ted', age: 21, nickname_: 'none'}
// Person {name: 'Moon', age: 15, nickname_: 'none'}
// Person {name: 'Marry', age: 6, nickname_: 'none'}
// Person {name: 'Ted', age: 14, nickname_: 'none'}
// Person {name: 'Ted', age: 26, nickname_: 'none'}
// Person {name: 'Happy', age: 20, nickname_: 'none'}
// Person {name: 'Bob', age: 22, nickname_: 'none'}
// Person {name: 'Jack', age: 5, nickname_: 'none'}
// Person {name: 'Ted', age: 20, nickname_: 'none'}
// Person {name: 'Happy', age: 3, nickname_: 'none'}

1.1.3 可迭代的类

可以在类中定义 generator,也可以直接把类改为一个可迭代对象。

class Person {
constructor() {
this.nicknames = ["Moxy", "Ninjee", "walnut"];
}

*[Symbol.iterator]() {
yield* this.nicknames.entries();
}
}

let p = new Person()
for (let [idx, v] of p) {
console.log(`id: ${idx}, nickName: ${v}`)
}
// id: 0, nickName: Moxy
// id: 1, nickName: Ninjee
// id: 2, nickName: walnut

或者直接返回一个 iterator 实例。

class Person {
constructor() {
this.nicknames = ["Moxy", "Ninjee", "walnut"];
}

[Symbol.iterator]() {
return this.nicknames.entries(); // entries会返回一个iterator
}
}

let p = new Person()
for (let [idx, v] of p) {
console.log(`id: ${idx}, nickName: ${v}`)
}
// id: 0, nickName: Moxy
// id: 1, nickName: Ninjee
// id: 2, nickName: walnut

1.1.4 备注:

类方法是不可枚举的,对象的方法是默认可枚举的。

function Foo 相比,class Foo 的特点是:

  • 不提升。function Foo 存在变量提升,而 class 类不存在变量提升。
  • 无法直接调用。class Foo 无法直接调用,必须 new 实例化有才可以调用。而 function Foo 的可以当作一个普通函数那样使用: Foo.call(obj)

8.2 extends 继承

  • extends 继承,是一个语法糖,用来在两个函数原型之间建立 [[Prototype]] 链接。
  • static ,子类也继承了父类的 static 静态方法,可以直接 Son.fatherStaticMethod 调用。
  • super 自动指向父构造器,也就是指向父对象。
  • super 有两种用法:
    • super 指向父构造器 constructor,主要是为了实现父类属性的继承;
    • super 指向父对象 class,主要是为了让子类可以在自定义方法中调用父类方法。

用法一:super 实现继承

class Foo {
constructor(a, b) {
this.x = a;
this.y = b;
}
gimmeXY() {
return this.x * this.y;
}
}

class Bar extends Foo {
constructor(a, b, c) {
super(a, b);
this.z = c;
}
gimmeXYZ() {
return super.gimmeXY() * this.z;
}
}

let b = new Bar(5, 15, 25);
b.x; // 5
b.y; // 15
b.z; // 25
b.gimmeXYZ() // 1875

Foo.prototype === Bar.prototype.__proto__ // true
  • class Bar extends Foo 表示把 Bar.prototype[[Prototype]] 指向了 Foo.prototype
  • super 是静态绑定的。
  • 子类构造器 constructor 中,必须先使用 super,才能使用 this
  • 如果在继承父类后,没有定义 constructor,则引擎会默认帮你调一次 constructor,并在其中也调用一次 super,传递所有值给 super,像下面这样:
// 默认的 constructor
constructor(...args){
super(...args);
}

用法二:super 子类调用父类方法

下例中,子类重载了父类的 func 方法,使用 super,子类的 func 方法先执行了父类原方法,再额外新增逻辑:

class Father {
func() {
console.log('逻辑1');
console.log('逻辑2');
console.log('逻辑3');
}

static StaticMethod() {
console.log('Father StaticMethod');
}

static FatherStaticMethod() {
console.log('Father FatherStaticMethod');
}
}

class Son extends Father {
constructor(){
super();
}

// 重写父类方法
func() {
super.func(); // 复用父类方法
console.log('新增:逻辑4');
console.log('新增:逻辑5');
}

// 重写父类静态方法
static StaticMethod() {
super.StaticMethod() // 复用父类静态方法,⚠️这里静态方法的调用采用 super.,而不是类名 Father.
console.log('son static method');
}
}

const s = new Son();

// 父类方法的复用和重载
s.func()
// 逻辑1
// 逻辑2
// 逻辑3
// 新增:逻辑4
// 新增:逻辑5


// 父类静态方法的复用和重载
Son.StaticMethod()
// Father StaticMethod
// son static method


// 父类静态方法的继承
Son.FatherStaticMethod()
// Father FatherStaticMethod

8.2.1 new.target

new.target 是一个元属性(meta property),它总是指向 new 实际上直接调用的构造器。

class Foo {
constructor() {
console.log("Foo:", new.target.name)
}
}

class Bar extends Foo {
constructor() {
super()
console.log("Bar:", new.target.name)
}
baz() {
console.log("baz:", new.target)
}
}
let a = new Bar()
// Foo: Bar
// Bar: Bar
a.baz()
// baz: undefined

可以看到,即使在子类中通过 super 去调用父类构造器,new.target 也会正确的指向子类构造器: new 调用的是谁 (Bar),new.target 就永远指向谁的构造器 (Barconstructor)。

如果 new.target 值为 undefined ,则表明这个函数不是通过 new 来调用的。

8.2.2 抽象基类(接口)

是一个接口,它可以供其他类继承,但本身不会被实例化。可以通过 new.target 来实现。

通过在实例化时,检测 new.target,判断该类是否为一个抽象类,然后阻止可能的实例化动作。

// 接口
class Vehicle {
constructor() {
console.log(new.target.name);
if (new.target === Vehicle)
throw new Error('Vehicle cannot be derectly instantiated')
}
}
// 派生类(子类)
class Bus extends Vehicle {}

// 实例化测试
new Vehicle() // Vehicle
// Uncaught Error: Vehicle cannot be derectly instantiated
new Bus() // Bus

8.3 使用 Bebel 转化

🔗链接:

Babel 官网

Babel 代码转化测试

  • Targets:not ie 10 让浏览器支持 ie 10 以上版本(ES6以下)

让一个函数只能通过 new 调用:

// es6 语法:
Person {}

// bebel 转化的 es5语法:
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) { // 判断 instance 实例原型链上是否有 Constructor
throw new TypeError("Cannot call a class as a function");
}
}

var Person = /*#__PURE__*/(function() {
function Person() {
// 如果是 new 调用,this是一个新创建的对象,其原型链指向了Person
_classCallCheck(this, Person);
}
return Person;
})();

// test
new Person() // Person {}
Person() // Uncaught TypeError: Cannot call a class as a function
  • 使用 var Person = (function() {}) ...
    • 这里使用一个立即执行函数,隔绝了外部作用域,使内部变量的名称不会泄露到外部。
    • /*#__PURE__#*/ 表明了这个立即执行函数是一个 纯函数,这可以让 webpack 在打包时的 tree shaking 环节时判断,如果这个函数没有实际使用过,则会删掉这个纯函数,从而减小打包体积。

实现 class Person:

// 🏠 es6 源代码
class Person {
constructor(name, age) {
this.name = name
this.age = age
}

eating() {
console.log(this.name + " eating~")
}
}

// 🔥 babel 转换
"use strict";

// 判断是否用 new 调用,如果不是则抛出错误
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}

// 遍历所有的属性,然后设置每个属性的特性enumerable, configurable, writable
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
descriptor.enumerable = descriptor.enumerable || false;
descriptor.configurable = true;
if ("value" in descriptor) descriptor.writable = true;
Object.defineProperty(target, descriptor.key, descriptor);
}
}

// 给构造函数添加方法(公有方法+静态方法)
function _createClass(Constructor, protoProps, staticProps) {
// 定义构造函数的原型对象上的方法
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
// 定义构造函数上的静态方法(如果有)
if (staticProps) _defineProperties(Constructor, staticProps);
return Constructor;
}


var Person = /*#__PURE__*/ (function () {
// 构造函数
function Person(name, age) {
this.name = name;
this.age = age;
}
// 原型对象上添加方法 + 构造函数上添加静态方法
_createClass(Person, [
{
key: "eating",
value: function eating() {
console.log(this.name + " eating~");
}
}
]);

return Person;
})();

8.4 技巧:内置继承

如果创建一个 class Person,js 会自动继承 Object:

class Person {}
// js 自动继承:
class Person extends Object {}

技巧:可以继承内置类,来对内置类进行增强

如,继承 Array 类,然后扩展出自己想要的额外功能,增强 Array 类:

// 额外添加 firstItem 和 lastItem 方法:
class MyArray extends Array {
firstItem() {
return this.length ? this[0] : null;
}

lastItem() {
return this.length ? this[this.length - 1] : null;
}
}

const arr1 = new MyArray(1,2,3,4);
console.log(arr1.firstItem()); // 1
console.log(arr1.lastItem()); // 4

const arr2 = new MyArray();
console.log(arr2.firstItem()); // null
console.log(arr2.lastItem()); // null

8.5 技巧:类的混入mixin

Js 只支持继承一个父类,不支持多继承。但如何实现多继承呢?

下例中,Student 类继承自 Person,又希望混入(mixin)Runner 和 Eater类。

  • 本质,虽然不能支持多继承,但可以让 Runner 和 Eater 与 Student 串联起来继承,也就是说相当于 “Stuedent extends Person extends Eater extends Runner ”
class Person{}

class Student extends Person {}

function mixinRunner(BaseClass) {
// 返回匿名类(Runner)
return class extends BaseClass {
running() {console.log('running~')};
}
}

function mixinEater(BaseClass) {
// 返回匿名类(Eating)
return class extends BaseClass {
eating() {console.log('eating~')};
}
}

const NewStudent = mixinEater(mixinRunner(Student));
const student = new NewStudent();
student.running(); // running~
student.eating(); // eating~

react 高阶组件

在 react 的类组件编写时,导出一个类通常是这样的形式:

// Banner 类
export default connect(props, dispatch)(Banner);

这其实就是对原本的 Banner 类进行了 mixin 增强。

  • connect 函数实现了柯里化;
  • connect 的 props 和 dispatch 增强了 Banner 组件,把 redux 的 API 注入了 Banner 组件中,让 Banner 组件可以调用 dispatch 和获取 props 数据。
  • 所以,在最后导出的组件已经不再是 Banner 组件了,而是经过 mixin 的新组件。