JavaScript 七大繼承全解析

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

原型鏈繼承

上一篇文章《從感性角度談原型 / 原型鏈》介紹了什麼是原型和原型鏈。咱們簡單回憶一下構造函數、原型、原型鏈之間的關係:每一個構造函數有一個 prototype 屬性,它指向原型對象,而原型對象都有一個指向構造函數的指針 constructor,實例對象都包含指向原型對象的內部指針 [[prototype]]。若是咱們讓原型對象等於另外一個構造函數的實例,那麼此原型對象就會包含一個指向另外一個原型的指針。這樣一層一層,逐級向上,就造成了原型鏈。前端

根據上面的介紹,咱們能夠寫出 原型鏈繼承git

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 的實例。github

原型鏈繼承

可是該方式有幾個缺點:面試

  • 多個實例對引用類型的操做會被篡改微信

  • 子類型的原型上的 constructor 屬性被重寫了函數

  • 給子類型原型添加屬性和方法必須在替換原型以後工具

  • 建立子類型實例時沒法向父類型的構造函數傳參post

缺點 1

從上圖能夠看出,父類的實例屬性被添加到了實例的原型中,當原型的屬性爲引用類型時,就會形成數據篡改。ui

咱們新增一個實例叫作 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 經常使用八種繼承方案

相關文章
相關標籤/搜索