首頁>技術>

我先來簡單介紹一下繼承的概念。繼承是面向物件的,使用這種方式我們可以更好地複用以前的開發程式碼,縮短開發的週期、提升開發效率。

繼承在各種程式語言中都充當著至關重要的角色,特別是在 JavaScript 中,它天生的靈活性,使應用場景更加豐富。JavaScript 的繼承也經常會在用在前端工程基礎庫的底層搭建上面,在整個 JavaScript 的學習中尤為重要。

因此,我希望這一講,能讓你對 JavaScript 的繼承有更深一步的理解,更加得心應手地運用在業務程式碼中,並可以輕鬆掌握和 JavaScript 繼承相關的面試題目。

那麼,為了方便你更好地理解本講的內容,開始前請你先思考幾個問題:

JS 的繼承到底有多少種實現方式呢?

ES6 的 extends 關鍵字是用哪種繼承方式實現的呢?

是不是這幾個問題並不是那麼容易地回答出來?那麼我們帶著思考,開始學習。

繼承概念的探究

說到繼承的概念,首先要說一個經典的例子。

先定義一個類(Class)叫汽車,汽車的屬性包括顏色、輪胎、品牌、速度、排氣量等,由汽車這個類可以派生出“轎車”和“貨車”兩個類,那麼可以在汽車的基礎屬性上,為轎車新增一個後備廂、給貨車新增一個大貨箱。這樣轎車和貨車就是不一樣的,但是二者都屬於汽車這個類,這樣從這個例子中就能詳細說明汽車、轎車以及卡車之間的繼承關係。

繼承可以使得子類別具有父類的各種方法和屬性,比如上面的例子中“轎車” 和 “貨車” 分別繼承了汽車的屬性,而不需要再次在“轎車”中定義汽車已經有的屬性。在“轎車”繼承“汽車”的同時,也可以重新定義汽車的某些屬性,並重寫或覆蓋某些屬性和方法,使其獲得與“汽車”這個父類不同的屬性和方法。

繼承的基本概念就初步介紹這些,下面我們就來看看 JavaScript 中都有哪些實現繼承的方法。

JS 實現繼承的幾種方式

第一種:原型鏈繼承

原型鏈繼承是比較常見的繼承方式之一,其中涉及的建構函式、原型和例項,三者之間存在著一定的關係,即每一個建構函式都有一個原型物件,原型物件又包含一個指向建構函式的指標,而例項則包含一個原型物件的指標。

下面我們結合程式碼來了解一下。

function Parent1() {  this.name = 'parent1';  this.play = [1, 2, 3]}function Child1() {  this.type = 'child2';}Child1.prototype = new Parent1();console.log(new Child1());

上面的程式碼看似沒有問題,雖然父類的方法和屬性都能夠訪問,但其實有一個潛在的問題,我再舉個例子來說明這個問題。

var s1 = new Child2();var s2 = new Child2();s1.play.push(4);console.log(s1.play, s2.play);

這段程式碼在控制檯執行之後,可以看到結果如下:

明明我只改變了 s1 的 play 屬性,為什麼 s2 也跟著變了呢?原因很簡單,因為兩個例項使用的是同一個原型物件。它們的記憶體空間是共享的,當一個發生變化的時候,另外一個也隨之進行了變化,這就是使用原型鏈繼承方式的一個缺點。

那麼要解決這個問題的話,我們就得再看看其他的繼承方式,下面我們看看能解決原型屬性共享問題的第二種方法。

第二種:建構函式繼承(藉助 call)

直接透過程式碼來了解,如下所示。

function Parent1(){  this.name = 'parent1';}Parent1.prototype.getName = function () {  return this.name;}function Child1(){  Parent1.call(this);  this.type = 'child1'}let child = new Child1();console.log(child);  // 沒問題console.log(child.getName());  // 會報錯

執行上面的這段程式碼,可以得到這樣的結果。

可以看到最後列印的 child 在控制檯顯示,除了 Child1 的屬性 type 之外,也繼承了 Parent1 的屬性 name。這樣寫的時候子類雖然能夠拿到父類的屬性值,解決了第一種繼承方式的弊端,但問題是,父類原型物件中一旦存在父類之前自己定義的方法,那麼子類將無法繼承這些方法。這種情況的控制檯執行結果如下圖所示。

因此,從上面的結果就可以看到建構函式實現繼承的優缺點,它使父類的引用屬性不會被共享,優化了第一種繼承方式的弊端;但是隨之而來的缺點也比較明顯——只能繼承父類的例項屬性和方法,不能繼承原型屬性或者方法。

上面的兩種繼承方式各有優缺點,那麼結合二者的優點,於是就產生了下面這種組合的繼承方式。

第三種:組合繼承(前兩種組合)

這種方式結合了前兩種繼承方式的優缺點,結合起來的繼承,程式碼如下。

function Parent3 () {  this.name = 'parent3';  this.play = [1, 2, 3];}Parent3.prototype.getName = function () {  return this.name;}function Child3() {  // 第二次呼叫 Parent3()  Parent3.call(this);  this.type = 'child3';}// 第一次呼叫 Parent3()Child3.prototype = new Parent3();// 手動掛上構造器,指向自己的建構函式Child3.prototype.constructor = Child3;var s3 = new Child3();var s4 = new Child3();s3.play.push(4);console.log(s3.play, s4.play);  // 不互相影響console.log(s3.getName()); // 正常輸出'parent3'console.log(s4.getName()); // 正常輸出'parent3'

執行上面的程式碼,可以看到控制檯的輸出結果,之前方法一和方法二的問題都得以解決。

但是這裡又增加了一個新問題:透過註釋我們可以看到 Parent3 執行了兩次,第一次是改變Child3 的 prototype 的時候,第二次是透過 call 方法呼叫 Parent3 的時候,那麼 Parent3 多構造一次就多進行了一次效能開銷,這是我們不願看到的。

那麼是否有更好的辦法解決這個問題呢?請你再往下學習,下面的第六種繼承方式可以更好地解決這裡的問題。

上面介紹的更多是圍繞著建構函式的方式,那麼對於 JavaScript 的普通物件,怎麼實現繼承呢?

第四種:原型式繼承

這裡不得不提到的就是 ES5 裡面的 Object.create 方法,這個方法接收兩個引數:一是用作新物件原型的物件、二是為新物件定義額外屬性的物件(可選引數)。

我們透過一段程式碼,看看普通物件是怎麼實現的繼承。

let parent4 = {  name: "parent4",  friends: ["p1", "p2", "p3"],  getName: function() {    return this.name;  }};let person4 = Object.create(parent4);person4.name = "tom";person4.friends.push("jerry");let person5 = Object.create(parent4);person5.friends.push("lucy");console.log(person4.name);console.log(person4.name === person4.getName());console.log(person5.name);console.log(person4.friends);console.log(person5.friends);

從上面的程式碼中可以看到,透過 Object.create 這個方法可以實現普通物件的繼承,不僅僅能繼承屬性,同樣也可以繼承 getName 的方法,請看這段程式碼的執行結果。

第一個結果“tom”,比較容易理解,person4 繼承了 parent4 的 name 屬性,但是在這個基礎上又進行了自定義。

第二個是繼承過來的 getName 方法檢查自己的 name 是否和屬性裡面的值一樣,答案是 true。

第三個結果“parent4”也比較容易理解,person5 繼承了 parent4 的 name 屬性,沒有進行覆蓋,因此輸出父物件的屬性。

最後兩個輸出結果是一樣的,講到這裡你應該可以聯想到 02 講中淺複製的知識點,關於引用資料型別“共享”的問題,其實 Object.create 方法是可以為一些物件實現淺複製的。

那麼關於這種繼承方式的缺點也很明顯,多個例項的引用型別屬性指向相同的記憶體,存在篡改的可能,接下來我們看一下在這個繼承基礎上進行最佳化之後的另一種繼承方式——寄生式繼承。

第五種:寄生式繼承

使用原型式繼承可以獲得一份目標物件的淺複製,然後利用這個淺複製的能力再進行增強,新增一些方法,這樣的繼承方式就叫作寄生式繼承。

雖然其優缺點和原型式繼承一樣,但是對於普通物件的繼承方式來說,寄生式繼承相比於原型式繼承,還是在父類基礎上添加了更多的方法。那麼我們看一下程式碼是怎麼實現。

let parent5 = {  name: "parent5",  friends: ["p1", "p2", "p3"],  getName: function() {    return this.name;  }};function clone(original) {  let clone = Object.create(original);  clone.getFriends = function() {    return this.friends;  };  return clone;}let person5 = clone(parent5);console.log(person5.getName());console.log(person5.getFriends());

透過上面這段程式碼,我們可以看到 person5 是透過寄生式繼承生成的例項,它不僅僅有 getName 的方法,而且可以看到它最後也擁有了 getFriends 的方法,結果如下圖所示。

從最後的輸出結果中可以看到,person5 透過 clone 的方法,增加了 getFriends 的方法,從而使 person5 這個普通物件在繼承過程中又增加了一個方法,這樣的繼承方式就是寄生式繼承。

我在上面第三種組合繼承方式中提到了一些弊端,即兩次呼叫父類的建構函式造成浪費,下面要介紹的寄生組合繼承就可以解決這個問題。

第六種:寄生組合式繼承

結合第四種中提及的繼承方式,解決普通物件的繼承問題的 Object.create 方法,我們在前面這幾種繼承方式的優缺點基礎上進行改造,得出了寄生組合式的繼承方式,這也是所有繼承方式裡面相對最優的繼承方式,程式碼如下。

function clone (parent, child) {  // 這裡改用 Object.create 就可以減少組合繼承中多進行一次構造的過程  child.prototype = Object.create(parent.prototype);  child.prototype.constructor = child;}function Parent6() {  this.name = 'parent6';  this.play = [1, 2, 3];}Parent6.prototype.getName = function () {  return this.name;}function Child6() {  Parent6.call(this);  this.friends = 'child5';}clone(Parent6, Child6);Child6.prototype.getFriends = function () {  return this.friends;}let person6 = new Child6();console.log(person6);console.log(person6.getName());console.log(person6.getFriends());

透過這段程式碼可以看出來,這種寄生組合式繼承方式,基本可以解決前幾種繼承方式的缺點,較好地實現了繼承想要的結果,同時也減少了構造次數,減少了效能的開銷,我們來看一下上面這一段程式碼的執行結果。

可以看到 person6 打印出來的結果,屬性都得到了繼承,方法也沒問題,可以輸出預期的結果。

整體看下來,這六種繼承方式中,寄生組合式繼承是這六種裡面最優的繼承方式。另外,ES6 還提供了繼承的關鍵字 extends,我們再看下 extends 的底層實現繼承的邏輯。

ES6 的 extends 關鍵字實現邏輯

我們可以利用 ES6 裡的 extends 的語法糖,使用關鍵詞很容易直接實現 JavaScript 的繼承,但是如果想深入瞭解 extends 語法糖是怎麼實現的,就得深入研究 extends 的底層邏輯。

我們先看下用利用 extends 如何直接實現繼承,程式碼如下。

class Person {  constructor(name) {    this.name = name  }  // 原型方法  // 即 Person.prototype.getName = function() { }  // 下面可以簡寫為 getName() {...}  getName = function () {    console.log('Person:', this.name)  }}class Gamer extends Person {  constructor(name, age) {    // 子類中存在建構函式,則需要在使用“this”之前首先呼叫 super()。    super(name)    this.age = age  }}const asuna = new Gamer('Asuna', 20)asuna.getName() // 成功訪問到父類的方法

因為瀏覽器的相容性問題,如果遇到不支援 ES6 的瀏覽器,那麼就得利用 babel 這個編譯工具,將 ES6 的程式碼編譯成 ES5,讓一些不支援新語法的瀏覽器也能執行。

那麼最後 extends 編譯成了什麼樣子呢?我們看一下轉譯之後的程式碼片段。

function _possibleConstructorReturn (self, call) {   // ...  return call && (typeof call === 'object' || typeof call === 'function') ? call : self; }function _inherits (subClass, superClass) {     // 這裡可以看到    subClass.prototype = Object.create(superClass && superClass.prototype, {   constructor: {     value: subClass,     enumerable: false,     writable: true,     configurable: true   }     });     if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; }var Parent = function Parent () {    // 驗證是否是 Parent 構造出來的 this    _classCallCheck(this, Parent);};var Child = (function (_Parent) {    _inherits(Child, _Parent);    function Child () {  _classCallCheck(this, Child);  return _possibleConstructorReturn(this, (Child.__proto__ || Object.getPrototypeOf(Child)).apply(this, arguments));}    return Child;}(Parent));

從上面編譯完成的原始碼中可以看到,它採用的也是寄生組合繼承方式,因此也證明了這種方式是較優的解決繼承的方式。

到這裡,JavaScript 中實現繼承的方式也基本講解差不多了,本課時也將告一段落。

總結

下面我將 JavaScript 的繼承方式做了個總結的腦圖,方便你更清晰地回顧本課時所講的內容。

透過 Object.create 來劃分不同的繼承方式,最後的寄生式組合繼承方式是透過組合繼承改造之後的最優繼承方式,而 extends 的語法糖和寄生組合繼承的方式基本類似。

綜上,我們可以看到不同的繼承方式有不同的優缺點,我們需要深入瞭解各種方式的優缺點,這樣才能在日常開發中,選擇最適合當前場景的繼承方式。

在日常的前端開發工作中,開發者往往會忽視對繼承相關的系統性學習,但因為繼承的方法比較多,每個實現的方法細節也比較零散,很多開發者很難有一個系統的、整體的認識,造成效率低下,以及程式碼能力難以進一步提升等問題。

因此我希望透過這一講的學習,你能很好地掌握 JavaScript 的繼承方式,以便在開發中規避我所說的這些問題。

10
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 完全加密公式破解 {衝浪預警公式原始碼}