【JS面試向】深入原型鏈之class的繼承

一顆賽艇?發表於2019-02-20

class 是如何實現繼承的?

我相信時至今日,大部分同學看完題目都能很快的寫出答案。

使用 ES 6 提供的,能夠很快的實現繼承。

class Parent {
  constructor() {
    this.name = '爸爸';
    this.books = ['JAVA']
  }

  showName() {
    console.log(this.name)
  }
}

class Child extends Parent {
  constructor() {
    super();
    this.name = '兒子';
    this.school = 'high school';
  }

  showSchool() {
    console.log(this.school);
  }
}
複製程式碼

但是,其實 JavaScript 本身是沒有類這個概念的,class 只是一個語法糖,Parent 與 Child 本質上還是函式。所以 class 提供的繼承是基於 JavaScript 的原型鏈實現的。那麼如何通過原型鏈實現繼承呢?

ES5中的繼承

JavaScript 中繼承的本質就是子類共享父類的原型物件。

ps:僅個人觀點,有什麼不妥請大家及時反駁。

類式繼承

類式繼承通過子類的原型物件指向父類的例項,完成繼承。

程式碼

function Parent() {
  this.name = '爸爸';
  this.books = ['JAVA'];
}

Parent.prototype.showName = function() {
  console.log(this.name);
}

function Child() {
  this.name = '兒子';
  this.school = 'high school'
}

Child.prototype = new Parent(); // 子類的原型物件指向父類的例項,子類就能訪問 Parent 原型物件上的方法了

Child.prototype.showSchool = function() {
  console.log(this.school);
}
複製程式碼

驗證

const c = new Child();
// 呼叫父類方法
c.showName(); // 兒子
// 呼叫子類方法
c.showSchool(); // high school
複製程式碼

不足

看起來類式繼承很完美的完成了工作,Child 的例項化的物件正常呼叫了父類的方法。但是我們看看 c 到底長什麼樣子。

{
  name: '兒子',
  school: 'high school',
  __proto__: {
    books: ['JAVA'],
    name: '爸爸',
    showSchool: f (),
    __proto__: {
      showName: f(),
      constructor: f Parent()
      // ...
    }
  }
}
複製程式碼

可以看出,類式繼承具有以下幾個問題:

  • 父類的屬性只例項化了一次,如果所有例項共享 books 這個屬性,任意一個修改了例項修改了 books 的話,而其他例項不知情,這種情況是很危險的。由於資訊不對稱,很容易出現bug。
  • 物件 c 的 proto 沒有 constructor。
  • 有兩個name變數,節約記憶體,可以去掉一個。

寄生組合式繼承

  • 通過每次呼叫父類,傳入this,使引用型別的值不共享。
  • 建立一箇中間物件,顯式的設定 constructor。
  • 子類的原型物件指向這個中間物件,中間物件的__proto__指向父類的原型物件。

ps:提個問題,這裡的中間物件其實可以不用建立,直接指定子類的原型物件的__proto__為父類的原型物件(Object.setPrototypeOf)是否可行?文末說下一我的看法。

程式碼

function Parent() {
  this.name = '爸爸';
  this.books = ['JAVA'];
}

Parent.prototype.showName = function() {
  console.log(this.name);
}

function Child() {
  Parent.call(this); // 像不像 class 中的 super()
  this.name = '兒子';
  this.school = 'high school'
}

// Object.create 建立一箇中間物件
// 中間物件的__proto__指向父類的原型物件
// 子類的原型物件指向這個中間物件
Child.prototype = Object.create(Parent.prototype, {
  // 顯示的指定 constructor
  constructor: {
    value: Child,
    enumerable: false, // 不可遍歷
    writable: true, // 可改寫
    configurable: true
  }
})

Child.prototype.showSchool = function() {
  console.log(this.school);
}
複製程式碼

驗證

const c = new Child();
// 呼叫父類方法
c.showName(); // 兒子
// 呼叫子類方法
c.showSchool(); // high school
複製程式碼

可以看一下 c 的結構

{
  name: "兒子", 
  books: ['JAVA'], 
  school: "high school",
  __proto__: {
    showSchool: ƒ (),
    constructor: ƒ Child(),
    __proto__: {
      showName: ƒ (),
      constructor: ƒ Parent(),
      // ...
    }
  }
}
複製程式碼

上面提到的問題都得到了解決,有興趣的同學可以去對比一下與class例項化後的繼承的結構是否有區別。

小結

  1. JavaScript 本身是沒有類的概念,繼承也是通過原型鏈實現的,
  2. JavaScript 中繼承的本質就是子類共享父類的原型物件。

ps: 上面的問題,我覺得是可行的。但是

由於現代 JavaScript 引擎優化屬性訪問所帶來的特性的關係,更改物件的 [[Prototype]]在各個瀏覽器和 JavaScript 引擎上都是一個很慢的操作。其在更改繼承的效能上的影響是微妙而又廣泛的,這不僅僅限於 obj.proto = ... 語句上的時間花費,而且可能會延伸到任何程式碼,那些可以訪問任何[[Prototype]]已被更改的物件的程式碼。如果你關心效能,你應該避免設定一個物件的 [[Prototype]]。相反,你應該使用 Object.create()來建立帶有你想要的[[Prototype]]的新物件。 ——摘自 MDN

參考資料:

  • 【JavaScript 設計模式】 —— 張容銘
  • MDN

相關文章