JavaScript系列 - 继承

245 阅读15分钟

本文整理了常见的继承方式,共有 6 种:

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

引子1:new 运算符

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

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

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

  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. 多态

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

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

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

  • 比如父类有一个方法:Person.prototype.callName(),子类拥有一个相同的方法:Student.prototype.callName(),就会发生覆盖,这就是多态。

2. 构造函数

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

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

3. 继承

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

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

4. 混入 mixin

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

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

1 原型链继承

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

优点:

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

缺点:

  • 属性不独立,原型链继承,父类的属性无法实例化到对象上,而是和方法一样被复用、共用。
// 父类构造函数、父类属性
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();
​
// 实例化测试: 
let instance1 = new Son();
let instance2 = new Son();
console.log(instance1.__proto__.color === instance2.__proto__.color) //true
instance1.sayName(); // Moxy

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

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

优点:

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

缺点:

  • 方法无法复用。

    • 要继承的方法都在父类的构造函数中定义,调用父类构造函数后,实例化对象内部也创建了对应的方法,没有复用函数。
    • 父类定义的方法(不在构造函数内部的)对子类不可见,因为此处没有涉及到原型链技术。
function Father(){
    this.name = name;
    this.colors = ["red","blue","green"];
}
function Son(){
    Father.call(this);  //继承了Father,且向父类型传递参数
}
​
​
// 实例化测试:
let instance1 = new Son("Moxy");
instance1.colors.push("black");
console.log(instance1.colors);  //"red,blue,green,black"let instance2 = new Son("Ninjee");
console.log(instance2.colors);  //"red,blue,green" 引用类型值是独立的

小总结:

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

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

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

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

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

3 组合继承

JavaScript 中 最常用 的继承模式。结合了原型链和上文的经典继承。

基本思想:

  1. 利用构造函数:子类借用构造函数,来实现对父类属性的继承。Father.call(this,name);

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

优点:

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

  1. 原型链优点:父类的方法得到了复用;
  2. 构造函数优点:属性(基本数据类型、引用数据类型)都可以定义在实例化对象中。
  3. 此外,instanceofisPrototypeOf() 可正确使用。

缺点:

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

  1. 额外调用了一次 Father(),造成了额外的开销。如果有实例化 n 个对象,就是 1 + n 次调用。
  2. new Father() 附带了父类构造函数的属性,这些属性也绑定在了 Son.prototype 子类原型上,多了额外无用的变量。
// 父类构造函数、父类属性
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. 返回实例化的对象。

优点:

  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();
}
​
// 定义父对象(无构造函数、无原型链)
var father = {
    name: "Moxy",
    colors: ["red", "blue", "green"],
    sayName: function () {
        console.log(this.name)
    }
};
​
// 实例化测试:
let son1 = object(father);
let 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() 方法可接收两个参数:

  • 参数一:一个对象:新对象原型的对象;

  • 参数二:一个对象(可选):为新对象定义额外属性。

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

优点:

  1. 相比形式一,有了类与类之间的关系。
  2. 实例属性和共用方法都可以正确的被定义:父类的方法可以被子类复用,父类的实例属性子类可以单独创建出来。

缺点:

调用 Student.prototype = Object.create(Person.prototype) ,会造成 3 个问题:

  1. Object.create() 会创建一个新的对象,这就意味着原有的 Student 原型对象实际上被替换掉了。
  2. 因为问题 1,导致子类的原型对象 Student.prototype 被替换后,原型对象的 .constructor 属性需要重新指向其构造函数。这就是所谓 constructor 丢失问题。
  3. 因为问题 1,导致只有当子类的原型对象 Student.prototype 被替换后,才能在新的原型对象上去定义子类的公有方法。
// 定义父类:实例属性 + 公有方法
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)

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

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

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

优点:

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

缺点:

真看不出有什么缺点。可能最重要的缺点就是,该方法是 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. 避免了原型式继承的第二个缺点。寄生式继承可以在实例化子对象的过程中,创建新的属性和方法。

缺点:

父类的属性和方法全部复用。

// 形式二:自定义object()
function object(obj) {
    function Son() {}
    Son.prototype = obj;
    return new Son();
}
​
// 定义父对象(无构造函数、无原型链)
let father = {
    name: "Moxy",
    colors: ["red", "blue", "green"],
    sayName: function () {
        console.log(this.name)
    }
};
​
// 增强子对象、为子对象添加自己的方法
function createSon(original) {
    // 形式一:ES5的Object.create()
    // let Son = Object.create(original);
    let son = object(original);
    //增强对象,添加属于自己的方法
    son.sayHi = function () {
        console.log('hi')
    }
    return son
}
​
/// 实例化测试
let son1 = createSon(father);
let son2 = createSon(father);
son1.colors.push("black");
son2.colors.push("pink");
console.log(father.colors); // (5) ["red", "blue", "green", "black", "pink"]
​
son1 // Son {sayHi: ƒ}

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

小总结

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

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

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

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

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

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

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

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

6 寄生组合式继承

// 中间函数、传入子类、父类两个构造函数
function inheritPrototype(Sub, Super) {
    // 创建一个Sub的原型,让它的原型链指向 Super的原型
    let subPrototype = Object.create(Super.prototype)
    subPrototype.constructor = Sub
    // 上面代码:相当于空对象subPrototype,此时有__proto__和constructor两个属性,即:
        // Sub.prototype.__proto__ 指向 Super.prototype
        // Sub.prototype.constructor 指向 Sub
​
    // 下面代码,subPrototype就是Sub的原型
    Sub.prototype = subPrototype
}
​
// 父类构造函数、父类属性
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被正确定义后才能添加
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 原型链上。

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

7 总结:

  1. ES5 版本最佳实践:寄生组合式继承
  2. ES6 版本最佳实践:原型式继承的形式三:Object.setPrototypeOf(Son.prototype, Father.prototype)

引用: