深刻 JavaScript 經常使用的8種繼承方案

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

原型鏈繼承

這個是你們都知道的:react

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)
複製代碼

原型鏈繼承的不足:es6

  • 修改父類實例上的屬性時,全部在此原型鏈上的對象的屬性都會受影響
  • 當父類實例上有屬性爲引用類型時,全部在此原型鏈上的對象修改該屬性時其餘對象都會受影響
  • 調用子類構造函數時,不能向父類的構造函數傳遞參數

雖然這裏只是構造函數,不是真正的類 class,不過姑且使用這個叫法算法

實踐中,不多直接用原型鏈實現繼承。express

借用構造函數繼承

constructor stealingbabel

在子類構造函數中使用 applycall 調用父類構造函數。app

原本,父類構造函數中的 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)
複製代碼

使用原型鏈繼承時,若是訪問一個子類實例的屬性,可是子類實例並無這個屬性,那麼會在子類實例的原型鏈上尋找,若是發現父類實例有這個屬性,那麼訪問到的值是父類實例的,即原型鏈上的。同理,若是修改,也是修改的原型鏈上的。
而借用構造函數的方式,使得子類實例自己就有了這個屬性,不須要再去原型鏈上找了。函數

這樣一來:post

  • 能夠在 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 的模擬實現》

相關文章
相關標籤/搜索