JavaScript 七大繼承全解析

YanceyOfficial發表於2019-04-11

繼承作為基本功和麵試必考點,必須要熟練掌握才行。小公司可能僅讓你手寫繼承(一般寫 寄生組合式繼承 即可),大廠就得要求你全面分析各個繼承的優缺點了。這篇文章深入淺出,讓你全面瞭解 JavaScript 繼承及其優缺點,以在寒冬中立於不敗之地。

原型鏈繼承

上一篇文章《從感性角度談原型 / 原型鏈》介紹了什麼是原型和原型鏈。我們簡單回憶一下建構函式、原型、原型鏈之間的關係:每個建構函式有一個 prototype 屬性,它指向原型物件,而原型物件都有一個指向建構函式的指標 constructor,例項物件都包含指向原型物件的內部指標 [[prototype]]。如果我們讓原型物件等於另一個建構函式的例項,那麼此原型物件就會包含一個指向另一個原型的指標。這樣一層一層,逐級向上,就形成了原型鏈。

根據上面的介紹,我們可以寫出 原型鏈繼承

function Vehicle(powerSource) {
  this.powerSource = powerSource;
  this.components = ['座椅', '輪子'];
}

Vehicle.prototype.run = function() {
  console.log('running~');
};

function Car(wheelNumber) {
  this.wheelNumber = wheelNumber;
}

Car.prototype.playMusic = function() {
  console.log('sing~');
};

// 將父建構函式的例項賦值給子建構函式的原型
Car.prototype = new Vehicle();

const car1 = new Car(4);
複製程式碼

上面這個例子中,首先定義一個叫做 交通工具 的建構函式,它有兩個屬性分別是是 驅動方式組成部分,還有一個原型方法是 ;接下來定義叫做 汽車 的建構函式,它有 輪胎數量 屬性和 播放音樂 方法。我們將 Vehicle 的例項賦值給 Car 的原型,並建立一個名叫 car1 的例項。

原型鏈繼承

但是該方式有幾個缺點:

  • 多個例項對引用型別的操作會被篡改

  • 子型別的原型上的 constructor 屬性被重寫了

  • 給子型別原型新增屬性和方法必須在替換原型之後

  • 建立子型別例項時無法向父型別的建構函式傳參

缺點 1

從上圖可以看出,父類的例項屬性被新增到了例項的原型中,當原型的屬性為引用型別時,就會造成資料篡改。

我們新增一個例項叫做 car2,並給 car2.components 追加一個新元素。列印 car1,發現 car1.components 也發生了變化。這就是所謂多個例項對引用型別的操作會被篡改。

const car2 = new Car(8);

car2.components.push('燈具');

car2.components; // ['座椅', '輪子', '燈具']
car1.components; // ['座椅', '輪子', '燈具']
複製程式碼

缺點 2

該方式導致 Car.prototype.constructor 被重寫,它指向的是 Vehicle 而非 Car。因此你需要手動將 Car.prototype.constructor 指回 Car

Car.prototype = new Vehicle();
Car.prototype.constructor === Vehicle; // true

// 重寫 Car.prototype 中的 constructor 屬性,指向自己的建構函式 Car
Car.prototype.constructor = Car;
複製程式碼

缺點 3

因為 Car.prototype = new Vehicle(); 重寫了 Car 的原型物件,所以導致 playMusic 方法被覆蓋掉了,因此給子類新增原型方法必須在替換原型之後。

function Car(wheelNumber) {
  this.wheelNumber = wheelNumber;
}

Car.prototype = new Vehicle();

// 給子類新增原型方法必須在替換原型之後
Car.prototype.playMusic = function() {
  console.log('sing~');
};
複製程式碼

缺點 4

顯然,建立 car 例項時無法向父類的建構函式傳參,也就是無法初始化 powerSource 屬性。

const car = new Car(4);

// 只能建立例項之後再修改父類的屬性
car.powerSource = '汽油';
複製程式碼

借用建構函式繼承

該方法又叫 偽造物件經典繼承。它的實質是 在建立子類例項時呼叫父類的建構函式

function Vehicle(powerSource) {
  this.powerSource = powerSource;
  this.components = ['座椅', '輪子'];
}

Vehicle.prototype.run = function() {
  console.log('running~');
};

function Car(wheelNumber) {
  this.wheelNumber = wheelNumber;

  // 繼承父類屬性並且可以傳參
  Vehicle.call(this, '汽油');
}

Car.prototype.playMusic = function() {
  console.log('sing~');
};

const car = new Car(4);
複製程式碼

借用建構函式繼承

使用經典繼承的好處是可以給父類傳參,並且該方法不會重寫子類的原型,故也不會損壞子類的原型方法。此外,由於每個例項都會將父類中的屬性複製一份,所以也不會發生多個例項篡改引用型別的問題(因為父類的例項屬性不在原型中了)。

然而缺點也是顯而易見的,我們絲毫找不到 run 方法的影子,這是因為該方式只能繼承父類的例項屬性和方法,不能繼承原型上的屬性和方法。

回憶上一篇文章講到的建構函式,為了將公有方法放到所有例項都能訪問到的地方,我們一般將它們放到建構函式的原型中。而如果讓 借用建構函式繼承 運作下去,顯然需要將 公有方法 寫在建構函式裡而非其原型,這在建立多個例項時勢必造成浪費。

組合繼承

組合繼承吸收上面兩種方式的優點,它使用原型鏈實現對原型方法的繼承,並借用建構函式來實現對例項屬性的繼承。

function Vehicle(powerSource) {
  this.powerSource = powerSource;
  this.components = ['座椅', '輪子'];
}

Vehicle.prototype.run = function() {
  console.log('running~');
};

function Car(wheelNumber) {
  this.wheelNumber = wheelNumber;
  Vehicle.call(this, '汽油'); // 第二次呼叫父類
}

Car.prototype = new Vehicle(); // 第一次呼叫父類

// 修正建構函式的指向
Car.prototype.constructor = Car;

Car.prototype.playMusic = function() {
  console.log('sing~');
};

const car = new Car(4);
複製程式碼

組合繼承

雖然該方式能夠成功繼承到父類的屬性和方法,但它卻呼叫了兩次父類。第一次呼叫父類的建構函式時,Car.prototype 會得到 powerSourcecomponents 兩個屬性;當呼叫 Car 建構函式生成例項時,又會呼叫一次 Vehicle 建構函式,此時會在這個例項上建立 powerSourcecomponents。根據原型鏈的規則,例項上的這兩個屬性會遮蔽原型鏈上的兩個同名屬性。

原型式繼承

該方式通過藉助原型,基於已有物件建立新的物件。

首先建立一個名為 object 的函式,然後在裡面中建立一個空的函式 F,並將該函式的 prototype 指向傳入的物件,最後返回該函式的例項。本質來講,object() 對傳入的物件做了一次 淺拷貝

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

const cat = {
  name: 'Lolita',
  friends: ['Yancey', 'Sayaka', 'Mitsuha'],
  say() {
    console.log(this.name);
  },
};

const cat1 = object(cat);
複製程式碼

原型式繼承

雖然這種方式很簡潔,但仍然有一些問題。因為 原型式繼承 相當於 淺拷貝,所以會導致 引用型別 被多個例項篡改。下面這個例子中,我們給 cat1.friends 追加一個元素,卻導致 cat.friends 被篡改了。

cat1.friends.push('Hachi');

cat.friends; // ['Yancey', 'Sayaka', 'Mitsuha', 'Hachi']
複製程式碼

如果你讀過 Object.create() 的 polyfill,應該不會對上面的程式碼感到陌生。該方法規範了原型式繼承,它接收兩個引數:第一個引數傳入用作新物件原型的物件,第二個引數傳入屬性描述符物件或 null。關於此 API 的詳細文件可以點選 Object.create() | JavaScript API 全解析

const cat = {
  name: 'Lolita',
  friends: ['Yancey', 'Sayaka', 'Mitsuha'],
  say() {
    console.log(this.name);
  },
};

const cat1 = Object.create(cat, {
  name: {
    value: 'Kitty',
    writable: false,
    enumerable: true,
    configurable: false,
  },
  friends: {
    get() {
      return ['alone'];
    },
  },
});
複製程式碼

寄生式繼承

該方式建立一個僅用於封裝繼承過程的函式,該函式在內部以某種方式來增強物件,最後再像真的是它做了所有工作一樣返回物件。

const cat = {
  name: 'Lolita',
  friends: ['Yancey', 'Sayaka', 'Mitsuha'],
  say() {
    console.log(this.name);
  },
};

function createAnother(original) {
  const clone = Object.create(original); // 獲取源物件的副本

  clone.gender = 'female';

  clone.fly = function() {
    // 增強這個物件
    console.log('I can fly.');
  };

  return clone; // 返回這個物件
}

const cat1 = createAnother(cat);
複製程式碼

寄生式繼承

原型式繼承 一樣,該方式會導致 引用型別 被多個例項篡改,此外,fly 方法存在於 例項 而非 原型 中,因此 函式複用 無從談起。

寄生組合式繼承

上面我們談到了 組合繼承,它的缺點是會呼叫兩次父類,因此父類的例項屬性會在子類的例項和其原型上各自建立一份,這會導致例項屬性遮蔽原型鏈上的同名屬性。

好在我們有 寄生組合式繼承,它本質上是通過 寄生式繼承 來繼承父類的原型,然後再將結果指定給子類的原型。這可以說是在 ES6 之前最好的繼承方式了,面試寫它沒跑了。

function inheritPrototype(child, parent) {
  const prototype = Object.create(parent.prototype); // 建立父類原型的副本
  prototype.constructor = child; // 將副本的建構函式指向子類
  child.prototype = prototype; // 將該副本賦值給子類的原型
}
複製程式碼

然後我們嘗試寫一個例子。

function Vehicle(powerSource) {
  this.powerSource = powerSource;
  this.components = ['座椅', '輪子'];
}

Vehicle.prototype.run = function() {
  console.log('running~');
};

function Car(wheelNumber) {
  this.wheelNumber = wheelNumber;
  Vehicle.call(this, '汽油');
}

inheritPrototype(Car, Vehicle);

Car.prototype.playMusic = function() {
  console.log('sing~');
};
複製程式碼

寄生組合式繼承

看上面這張圖就知道為什麼這是最好的方法了。它只呼叫了一次父類,因此避免了在子類的原型上建立多餘的屬性,並且原型鏈結構還能保持不變。

硬要說缺點的話,給子型別原型新增屬性和方法仍要放在 inheritPrototype 函式之後。

ES6 繼承

功利主義來講,在 ES6 新增 class 語法之後,上述幾種方法已淪為面試專用。當然 class 僅僅是一個語法糖,它的核心思想仍然是 寄生組合式繼承,下面我們看一看怎樣用 ES6 的語法實現一個繼承。

class Vehicle {
  constructor(powerSource) {
    // 用 Object.assign() 會更加簡潔
    Object.assign(
      this,
      { powerSource, components: ['座椅', '輪子'] },

      // 當然你完全可以用傳統的方式
      // this.powerSource = powerSource;
      // this.components = ['座椅', '輪子'];
    );
  }

  run() {
    console.log('running~');
  }
}

class Car extends Vehicle {
  constructor(powerSource, wheelNumber) {
    // 只有 super 方法才能呼叫父類例項
    super(powerSource, wheelNumber);
    this.wheelNumber = wheelNumber;
  }

  playMusic() {
    console.log('sing~');
  }
}

const car = new Car('核動力', 3);
複製程式碼

“類”繼承

下面程式碼是繼承的 polyfill,思路和 寄生組合式繼承 一致。

function _inherits(subType, superType) {
  // 建立物件,建立父類原型的一個副本

  subType.prototype = Object.create(superType && superType.prototype, {
    // 增強物件,彌補因重寫原型而失去的預設的constructor 屬性
    constructor: {
      value: subType,
      enumerable: false,
      writable: true,
      configurable: true,
    },
  });
  if (superType) {
    // 指定物件,將新建立的物件賦值給子類的原型
    Object.setPrototypeOf
      ? Object.setPrototypeOf(subType, superType)
      : (subType.__proto__ = superType);
  }
}
複製程式碼

ES5 和 ES6 繼承的比較

ES5 是用建構函式建立類,因此會發生 函式提升;而 class 類似於 let 和 const,因此不能夠先建立例項,再宣告類,否則直接報錯。

// Uncaught ReferenceError: Rectangle is not defined
let p = new Rectangle();

class Rectangle {}
複製程式碼

ES5 的繼承實質上是先建立子類的例項物件,然後再將父類的方法新增到 this 上,即 Parent.call(this).

ES6 的繼承有所不同,實質上是先建立父類的例項物件 this,然後再用子類的建構函式修改 this。因為子類沒有自己的 this 物件,所以必須先呼叫父類的 super()方法,否則新建例項報錯。

最後

歡迎關注我的微信公眾號:進擊的前端

進擊的前端

參考

《JavaScript 高階程式設計 (第三版)》 —— Nicholas C. Zakas

[進階 5-2 期] 圖解原型鏈及其繼承

JavaScript 常用八種繼承方案

相關文章