深入 JavaScript 常用的8種繼承方案

迪斯馬斯克發表於2019-04-03

本文基於《JavaScript 常用八種繼承方案》,細化了原理分析和程式碼註釋,從原型鏈開始逐漸深入至 ES6 的 extends

原型鏈繼承

這個是大家都知道的:

function Parent(name) {
  this.name = name
  this.relation = ['grandpa', 'grandma']
}
Parent.prototype.say = function () {/*...*/}

function Child() {}
// 繼承
p = new Parent('father')
Child.prototype = p

c1 = new Child()
c2 = new Child()
// 可以呼叫原型鏈上的方法
c1.say()
// 也可以獲取父類例項的屬性
console.log(c1.name, c2.relation)
// 直接修改父類例項屬性
p.name = 'mother'
// 或者通過子類例項修改父類上的引用型別
c1.relation.push('grandson')
// 子類例項都會被影響
console.log(c1.name, c2.relation)
複製程式碼

原型鏈繼承的不足:

  • 修改父類例項上的屬性時,所有在此原型鏈上的物件的屬性都會受影響
  • 當父類例項上有屬性為引用型別時,所有在此原型鏈上的物件修改該屬性時其他物件都會受影響
  • 呼叫子類建構函式時,不能向父類的建構函式傳遞引數

雖然這裡只是建構函式,不是真正的類 class,不過姑且使用這個叫法

實踐中,很少直接用原型鏈實現繼承。

借用建構函式繼承

constructor stealing

在子類建構函式中使用 applycall 呼叫父類建構函式。

本來,父類建構函式中的 this 將會指向父類的例項,但是在子類建構函式中 call(this) 把上下文修改為了子類例項,相當於把父類例項的屬性給子類例項複製了一份

function Parent(name) {
  this.name = name
}
function Child(name) {
  Parent.call(this, name)
}
c = new Child('child')
// c 本身就有 name 屬性
console.log(c)
複製程式碼

使用原型鏈繼承時,如果訪問一個子類例項的屬性,但是子類例項並沒有這個屬性,那麼會在子類例項的原型鏈上尋找,如果發現父類例項有這個屬性,那麼訪問到的值是父類例項的,即原型鏈上的。同理,如果修改,也是修改的原型鏈上的。
而借用建構函式的方式,使得子類例項本身就有了這個屬性,不需要再去原型鏈上找了。

這樣一來:

  • 可以在 call() 中向父類建構函式傳遞引數
  • 仍然可以訪問父類例項上的屬性,但是這些屬性已經複製給了 c 自己,不是 c.__proto__ 上的,所以修改時不會影響其他子類例項
  • 因為沒有使用原型鏈,所以子類例項不能訪問父類原型物件上的屬性和方法

實踐中也很少使用。

到這裡應該可以發現,當實現繼承的時候,主要是針對下面兩部分:

  • 父類例項上的例項屬性和方法
  • 父類原型物件上的屬性和方法

《當我談繼承時,我談些什麼》

組合繼承

就是原型鏈繼承+借用建構函式。

既然原型鏈繼承讓子類例項可以訪問父類的原型物件;而借用建構函式讓子類例項可以訪問父類例項,並且修改父類例項屬性時不影響其他子類例項,那麼把兩者結合一下豈不是美滋滋?

組合繼承的原理就是這樣:

  • 使用借用建構函式的方法,複製一份父類例項 p 的屬性到子類例項 c
  • 使用原型鏈的方法,把子類例項新增到原型鏈上,使得子類例項也能夠訪問父類原型物件上的屬性和方法,當然,這些屬性方法仍然是位於 c.__proto__.__proto__ 上的

實現:

function Father(name) {
  // 父類例項屬性
  this.first_name = name
  this.last_name = 'vue'
  this.age = 40
  this.address = {
    country: 'china',
    province: 'shanghai'
  }
}
// 父類原型方法
Father.prototype.say = function () {
  console.log(`I am ${this.last_name} ${this.first_name}`)
}
f = new Father('js')

// 子類
// 1. 借用建構函式
function Child1(name) {
  Father.call(this, name)
  // 注意,要先 call 父建構函式,再定義子類例項自己的屬性
  // 否則子類例項屬性會被父類例項同名屬性覆蓋
  this.age = 10
}
// 2. 原型鏈
// 修改原型物件
Child1.prototype = f
// 修改原型物件的建構函式
Child1.prototype.constructor = Child1

// 同樣方法再建一個子類
function Child2(name) {
  Father.call(this, name)
  this.age = 9
}
Child2.prototype = f
Child2.prototype.constructor = Child2

c1 = new Child1('router')
c2 = new Child2('x')

print()
// 修改一下,不會對其他例項有影響
c1.address.country = 'usa'
f.last_name = 'react'
print()

function print() {
  console.log(c1)
  console.log(c2)
  console.log(f)
  // 子類例項也能訪問父類原型物件上的方法
  c1.say()
}
複製程式碼

不過這裡有一點瑕疵:一個子類例項將會持有兩份父類例項的資料。

因為使用了原型鏈。
一份是 Father.call(this) 複製到子類例項 c 上的資料,一份是父類例項原本的資料,位於 c.__proto__ 上。

雖然冗餘,不過使用效果上沒有太大影響。
也有處理方案,就是後面的寄生組合式繼承。

這是實踐中常用的繼承方式。

原型式繼承

下面是《繼8》中原型式繼承的例子,附加了一些註釋:

// 為一個物件生成子類例項的函式。其實 Object.create() 就是這樣實現的
function object(obj){
  // 傳入的引數 obj 就相當於是父類例項
  // F 就相當於子類建構函式,不過是空的,啥也沒
  function F(){}
  // 把子類建構函式的原型物件設定為父類例項
  F.prototype = obj
  // 呼叫子類建構函式,建立一個例項並返回
  return new F()
}
// 相當於父類例項
var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
}
// 子類例項
var anotherPerson = object(person)
// 為子類例項新增例項屬性
anotherPerson.name = "Greg"
// 再建立一個子類例項
var yetAnotherPerson = object(person)
yetAnotherPerson.name = "Linda"
// 修改子類例項的一個引用型別屬性
anotherPerson.friends.push("Rob")
yetAnotherPerson.friends.push("Barbie")
// 父類例項上的屬性也變了
console.log(person.friends) // "Shelby,Court,Van,Rob,Barbie"
複製程式碼

上面的 object() 函式其實就是 Object.create()
MDN 提供的 Object.create()polyfill 的核心程式碼就是上面 object() 的程式碼。

目前看來,感覺跟原型鏈繼承好像是沒多大差別的。尤其是 object() 函式內部的程式碼,完全就是原型鏈繼承的套路。

以上面的程式碼為例分析一下的話:

  • 原型鏈繼承,是先在子類建構函式中定義好了例項屬性等等,然後 new 一個父類例項,把子類建構函式的原型指向該例項
  • 而原型式繼承,已經有了一個父類例項,最後也同樣是把子類建構函式的原型指向該例項,只不過在中間定義子類建構函式的時候,定義了一個空的函式

實際上,這個“只不過定義了一個空函式”正是跟原型鏈繼承最大的區別。
後面的寄生組合式繼承就會體現出它的作用了。

寄生式繼承

是原型式繼承的增強版。

在通過原型式繼承生成了子類例項後,在返回之前處理了一下子類例項,新增了一些屬性或方法:

function createAnother(original){
  // 使用前面的 object 函式,生成了一個子類例項
  var clone = object(original)
  // 先在子類例項上新增一點屬性或方法
  clone.sayHi = function(){
    console.log("hi")
  }
  // 再返回
  return clone
}
var person = {
  name: "Nicholas",
  friends: ["Shelby", "Court", "Van"]
}
var anotherPerson = createAnother(person)
anotherPerson.sayHi()
複製程式碼

寄生組合式繼承

就是寄生式繼承+借用建構函式繼承。

前面在借用建構函式部分的結尾,總結了一下“究竟要繼承哪些東西”,得出了兩點:

  • 父類例項上的屬性和方法
  • 父類原型物件上的屬性和方法

借用建構函式實現了第一點,那麼這裡寄生式繼承只要實現第二點就好了。

不對,不應該是“只要實現第二點就好了”,前面的原型鏈繼承也可以實現第二點。
寄生式繼承需要比原型鏈繼承更優秀,不然就沒什麼意義了。

怎麼才能“優秀”呢?
組合繼承的結尾也提到了,它的一個缺點是會有兩份父類例項的資料。
那麼是不是可以把這一點優化掉?

這兩份資料中,通過 Father.call(this) 複製到子類例項 c 上的這一份是真正需要的,而 c.__proto__ 上的這一份是多餘的,是把子類例項放到原型鏈上時產生的副作用。

也就是說,需要讓子類例項位於原型鏈上,但是不能讓父類例項的屬性位於原型鏈上

可以想到兩個方法:

  • 一般來說,為了把子類例項掛到原型鏈上,是需要一個父類例項的,如果能建立一個沒有例項屬性的父類例項就好了
  • 或者讓子類例項繞過父類例項,直接繼承父類的原型物件

寄生組合式繼承使用了第一種方法。

對於一個建構函式 Test() 及其原型物件 Test.prorotype,使用 new Test()Object.create(Test.prototype) 都可以生成繼承了該原型物件 Test.prorotype 的例項。
但是不同的是,Object.create() 生成的例項可以沒有例項屬性:

function Test(name) {
  this.name = name
  this.age = 20
}

t1 = new Test()
t2 = Object.create(Test.prototype)

console.log(t1) // Test {name: undefined, age: 20}
console.log(t2) // Test {}
複製程式碼

建構函式只是建立原型鏈的途徑,就算不通過建構函式也可以生成原型鏈。
MDN 關於 Object.create()介紹正是“使用現有的物件來提供新建立的物件的 __proto__”。

那麼,相當於是把原型鏈繼承中使用 new 建立父類例項改為使用 Object.create()

實現一下:

function Parent(name) {
  this.name = name
  this.age = 40
  this.relation = ['grandma', 'grandpa']
}
Parent.prototype.say = function () {
  console.log(this.name)
}
function Child(name) {
  Parent.call(this, name)
}

// 開始實現繼承
// Object.create 建立沒有例項屬性的父類例項
p = Object.create(Parent.prototype)
// 修改子類建構函式原型物件
Child.prototype = p
// 這裡的 p 只是個普通物件,沒有 constructor 屬性,手動新增一下
p.constructor = Child

// 測試一下
p1 = new Parent('father')
c1 = new Child('child 1')
c2 = new Child('child 2')
// 可以發現沒有兩份重複資料了
print()
// 修改父類例項,對子類例項沒有影響
p1.age = 50
p1.relation.push('child 3')
// 修改父類原型物件,子類例項能夠訪問到新方法 speak
Parent.prototype.speak = function () {
  console.log('speak')
}
// 修改子類原型物件,其他子類例項也能夠訪問到新方法 marry
Child.prototype.marry = function () {
  console.log('married')
}
// 修改一個子類例項,對其他子類例項沒有影響
c1.name = 'child 2 plus'
c1.relation.push('grandson')
print()

function print() {
  console.log(p1)
  console.log(Parent)
  console.log(c1)
  console.log(c2)
}
複製程式碼

這是最成熟的方法,也是現在庫實現的方法。
ES6 的 extends 實現與寄生組合式繼承基本一致。

上面還提到另一種方法,讓子類例項繞過父類例項,直接繼承父類的原型物件。

首先,這裡關於“父類”和“子類”的叫法不夠嚴謹。
僅僅是在所謂的子類的建構函式中執行了一行 Parent.call(this) ,並不能讓兩個函式產生繼承關係。而且這裡目的只是想把 Parent() 例項的屬性複製一份到 Child() 的例項中,本來跟繼承也沒有半點關係。

父類和子類的區分是在設定原型物件之後才產生的。

所以,如果把 Child() 的原型物件設定為 Parent.prototype,當然可以,不過從程式碼上來說,Child() 其實變成了 Parent() 的兄弟;而從表現上來說,因為 Child() 的例項持有一份 Parent() 的例項屬性,倒也能算是 Parent() 的子類。

說到底,這第二種方法到底可不可行,會有什麼問題,期待大家留言。

ES6 extends

這一部分只講解一下 extends 的原理,至於 class 和 extends 的使用,看阮一峰的《ES6 入門 - Class 的繼承》就好。

不過,看過這部分之後,一定會對 class 和 extends 的使用有更深入的認識。

前面說,ES6 的 extends 核心程式碼與寄生組合式繼承基本一致。
那麼先看看下面的程式碼,是使用 Babel 解析後的 extends 的部分實現:

可以去 Babel 的線上編輯器上自己試一下

function _inherits(subClass, superClass) {
  if (typeof superClass !== "function" && superClass !== null) {
    throw new TypeError("Super expression must either be null or a function")
  }
  // 這裡其實就是寄生式繼承,使得子類例項能夠訪問父類原型物件上的屬性和方法
  // 建立了一個沒有例項屬性的父類例項,新增一個 constructor 屬性,然後賦值給子類的原型物件
  subClass.prototype = Object.create(superClass && superClass.prototype, {
    constructor: {
      value: subClass,
      writable: true,
      configurable: true
    }
  })
  // 如果是寄生組合式繼承,還需要使得父類的例項屬性在子類上也有一份
  // 這裡應該需要借用建構函式了,但是好像跟前面的借用建構函式不太像?
  if (superClass) _setPrototypeOf(subClass, superClass)
}
function _setPrototypeOf(subClass, superClass) {
  // 判斷當前環境是不是有 Object.setPrototypeOf 方法,沒有的話就實現一個
  _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(subClass, superClass) {
    // 把子類的 __proto__ 設定為父類
    subClass.__proto__ = superClass
    return subClass
  }
  return _setPrototypeOf(subClass, superClass)
}
複製程式碼

還是像前面說的一樣,要繼承的內容有兩部分:父類原型物件上的和父類例項上的
寄生式繼承已經實現了前者,那麼這個 _setPrototypeOf() 函式按道理應該就是實現了後者了。

但是我尋思這也不像之前的借用建構函式方法的 Father.call(this) 啊。

繼續看 Babel 解析的 extends 的其他部分,還有這麼一段:

// ...
_inherits(subClass, superClass); // 這一步執行完時,subClass.__proto__ = superClass
function subClass() {
  _classCallCheck(this, subClass);
  // 有了
  // 在這裡通過 _getPrototypeOf 取出了 superClass,然後執行了 apply
  return _possibleConstructorReturn(this, _getPrototypeOf(subClass).apply(this, arguments));
}
// ...
複製程式碼

看到這裡就足夠了,說明 extends 的實現確實跟寄生組合式繼承基本一致。

混入式繼承

mixin

說白了就是把一個物件的屬性複製到另一個物件上去。

比如使用 Object.assign(target, source)。這個方法將所有可列舉的屬性的值從一個或多個源物件複製到目標物件,並返回目標物件。

是淺拷貝。

《繼8》裡的例子通過借用建構函式的方式為子類例項新增父類例項的屬性,通過混入的方式為子類例項新增父類原型物件的屬性:

function Mother() {
  this.a = 'mom'
}
Mother.prototype.comfort = function () {
  console.log("that's ok")
}
function Father() {
  this.b = 'dad'
}
Father.prototype.hit = function () {
  console.log("you bastard!")
}
function Me() {
  // 借用建構函式,獲得了 a 和 b 兩個例項屬性
  Mother.call(this)
  Father.call(this)
}

// 建立一個沒有例項屬性的 Mother 的例項
m = Object.create(Mother.prototype)
// 修改 Me 的原型物件,現在 Me 位於 Mother 例項的原型鏈上了
Me.prototype = m
// 修改建構函式
Me.prototype.constructor = Me
// 再把 Father 原型物件上的屬性方法複製到 Me 的原型物件 m 上
// 現在,雖然 Me 的例項並不在 Father 例項的原型鏈上
// 但是也可以訪問 Father.prototype 上的屬性方法
Object.assign(Me.prototype, Father.prototype)

me = new Me()
console.log(me)
複製程式碼

實際上,考慮到父類的例項和父類的原型物件都是物件,所以在為子類例項新增父類例項的屬性的時候,也可以直接使用混入。上面的程式碼可以修改為:

/**
 * Father Mother Me 的建構函式
 */
// 跳過 Object.create,直接放在 Object.assign 裡
m = Object.assign({}, Mother.prototype, Father.prototype)
Me.prototype = m

me = new Me()
console.log(me)
複製程式碼

打個廣告

我的其他文章:

超詳細的10種排序演算法原理及 JS 實現》
《免費為網站新增 SSL 證照》
《詳解 new/bind/apply/call 的模擬實現》

相關文章