繼承作為基本功和麵試必考點,必須要熟練掌握才行。小公司可能僅讓你手寫繼承(一般寫
寄生組合式繼承
即可),大廠就得要求你全面分析各個繼承的優缺點了。這篇文章深入淺出,讓你全面瞭解 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
會得到 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 繼承
功利主義來講,在 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