繼承做爲基本功和麪試必考點,必需要熟練掌握才行。小公司可能僅讓你手寫繼承(通常寫
寄生組合式繼承
便可),大廠就得要求你全面分析各個繼承的優缺點了。這篇文章深刻淺出,讓你全面瞭解 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
從上圖能夠看出,父類的實例屬性被添加到了實例的原型中,當原型的屬性爲引用類型時,就會形成數據篡改。ui
咱們新增一個實例叫作 car2
,並給 car2.components
追加一個新元素。打印 car1
,發現 car1.components
也發生了變化。這就是所謂多個實例對引用類型的操做會被篡改。
const car2 = new Car(8);
car2.components.push('燈具');
car2.components; // ['座椅', '輪子', '燈具']
car1.components; // ['座椅', '輪子', '燈具']
複製代碼
該方式致使 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;
複製代碼
由於 Car.prototype = new Vehicle();
重寫了 Car 的原型對象,因此致使 playMusic
方法被覆蓋掉了,所以給子類添加原型方法必須在替換原型以後。
function Car(wheelNumber) {
this.wheelNumber = wheelNumber;
}
Car.prototype = new Vehicle();
// 給子類添加原型方法必須在替換原型以後
Car.prototype.playMusic = function() {
console.log('sing~');
};
複製代碼
顯然,建立 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
會獲得 powerSource
和 components
兩個屬性;當調用 Car
構造函數生成實例時,又會調用一次 Vehicle
構造函數,此時會在這個實例上建立 powerSource
和 components
。根據原型鏈的規則,實例上的這兩個屬性會屏蔽原型鏈上的兩個同名屬性。
該方式經過藉助原型,基於已有對象建立新的對象。
首先建立一個名爲 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 新增 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 是用構造函數建立類,所以會發生 函數提高
;而 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