聊聊JavaScript中實現繼承的6種方法

2022-11-02 22:00:31

前端(vue)入門到精通課程:進入學習
Apipost = Postman + Swagger + Mock + Jmeter 超好用的API偵錯工具:

面試官:「你說說 JavaScript 中實現繼承有哪幾種方法?」

緊張的萌新:「額,class 中用 extends 實現繼承,然後...沒了...」

面試官:「...」

······

想必絕大部分人一說繼承就會想到類中的繼承吧,但其實繼承可不是 class 的專利,本文將總結,JavaScript 中關於繼承的幾種方案,其中包括原型鏈,盜用建構函式、組合式等等,助你力壓面試官。

注意:本文比較適合具備一定 JS 進階基礎的同學(不會也沒關係,收藏就會了?),涉及知識點有:原型、原型鏈、建構函式、this指向等。如果文中有不對、疑惑的地方,歡迎在評論區留言指正?

0. 繼承

繼承是物件導向程式設計中討論最多的話題。很多物件導向語言都支援兩種繼承:介面繼承和實現繼承。前者只繼承方法簽名,後者繼承實際的方法。 介面繼承在 ECMAScript中 是不可能的,因為函數沒有簽名。實現繼承是 ECMAScript 唯一支援的繼承方式,而這主要是通過原型鏈實現的。

1. 原型鏈繼承【方案一】

ECMA-262 把原型鏈定義為 ECMAScript 的主要繼承方式。其基本思想就是通過原型繼承多個參照型別的屬性和方法。重溫一下建構函式、原型和範例的關係:

  • 每個建構函式都有一個prototype屬性指向原型物件
  • 所有原型物件自動獲得一個名為 constructor 的屬性,指回與之關聯的建構函式

而範例有一個內部指標指向原型。如果原型是另一個型別的範例呢?那就意味著這個原型本身有一個內部指標指向另一個原型,相應的另一個原型也有一個指標指向另一個建構函式。這樣就在範例和原型之間構造了一條原型鏈。這就是原型鏈的基本構想。

實現原型鏈繼承涉及如下程式碼模式

// 定義 Person 建構函式
function Person() {
  this.name = 'CoderBin'
}

// 給 Person 的原型上新增 getPersonValue 方法(原型方法)
Person.prototype.getPersonValue = function() {
  return this.name
}

// 定義 Student 建構函式
function Student() {
  this.sno = '001'
}

// 繼承 Person — 將 Peson 的範例賦值給 Student 的原型
Student.prototype = new Person()

Student.prototype.getStudentValue = function() {
  return this.sno
}

// 範例化 Student
let stu = new Student()

console.log(stu.getPersonValue()) // CoderBin
登入後複製

1.1 程式碼解讀

以上程式碼定義了兩個建構函式:Person 和 Student。這兩個建構函式分別定義了一個屬性和一個方法。

這兩個型別的主要區別是 Student 通過建立 Person 的範例並將其賦值給自己的原型 Student.prototype 實現了對 Person 的繼承。

這個賦值重寫了 Student 最初的原型,將其替換為 Person 的範例。這意味著 Person 範例可以存取的所有屬性和方法也會存在於Student.prototype。這樣實現繼承之後,程式碼緊接著又給Student.prototype,也就是這個 Person 的範例新增了 一個新方法。最後又建立了 Student 的範例並呼叫了它繼承的getPersonValue()方法。

下圖展示了子類的範例與兩個建構函式及其對應的原型之間的關係:

1.png

1.2 程式碼核心解析

這個例子中實現繼承的關鍵,是 Student 沒有使用預設原型,而是將其替換成了一個新的物件。這個新的物件恰好是 Person 的範例。這樣一來,Student 的範例不僅能從 Person 的範例中繼承屬性和方法,而且還與 Person 的原型掛上了鉤。於是 stu(通過內部的 [[Prototype]] )指向Student.prototype,而Student.prototype(作為 Person 的範例又通過內部的 [[Prototype]] )指向Person.prototype

注意1:getPersonValue() 方法還在Person.prototype物件上,而 name 屬性則在Student.prototype上。這是因為 getPersonValue() 是一個原型方法,而 name 是一個範例屬性。Student.prototype現在是 Person 的一個範例,因此 name 才會儲存在它上面。

注意2:由於 Student.prototype 的 constructor 屬性被重寫為指向 Person,所以 stu.constructor 也指向 Person 。

1.3 預設原型

實際上,原型鏈中還有一環。預設情況下,所有參照型別都繼承自 Object ,這也是通過原型鏈實現的。任何函數的預設原型都是一個 Object 的範例,這意味著這個範例有一個內部指標指向Object.prototype。這也是為什麼自定義型別能夠繼承包括 toString() 、valueOf() 在內的所有預設方法的原因。因此前面的例子還有額外一層繼承關係。

下圖展示了完整的原型鏈。

2.png

Student 繼承 Person ,而 Person 繼承 Object 。在呼叫 stu.toString() 時,實際上呼叫的是儲存在 Object.prototype 上的方法。

1.4 原型與繼承的關係

原型與範例的關係可以通過兩種方式來確定。

1.4.1 instanceof

第一種方式是使用instanceof操作符,如果一個範例的原型鏈中出現過相應的建構函式,則instanceof返回 true 。如下例所示:

console.log(stu instanceof Object)    // true
console.log(stu instanceof Person)    // true
console.log(stu instanceof Student)   // true
登入後複製

從技術上講,stu 是 Object、Person 和 Student 的範例,因為 stu 的原型鏈中包含這些建構函式的原型。結果就是 instanceof 對所有這些建構函式都返回 true 。

1.4.2 isPrototypeOf()

確定這種關係的第二種方式是使用isPrototypeOf()方法。原型鏈中的每個原型都可以呼叫這個方法,如下例所示,只要原型鏈中包含這個原型,這個方法就返回 true 。

console.log(Object.prototype.isPrototypeOf(stu))    // true
console.log(Person.prototype.isPrototypeOf(stu))    // true
console.log(Student.prototype.isPrototypeOf(stu))   // true
登入後複製

1.5 關於方法

子類有時候需要覆蓋父類別的方法,或者增加父類別沒有的方法。為此, 這些方法必須在原型賦值之後再新增到原型上。來看下面的例子:

// 定義 Person 建構函式
function Person() {
  this.name = 'CoderBin'
}

// 給 Person 的原型上新增 getPersonValue 方法(原型方法)
Person.prototype.getPersonValue = function() {
  return this.name
}

// 定義 Student 建構函式
function Student() {
  this.sno = '001'
}

// 繼承 Person
Student.prototype = new Person()

// 新方法 —— 1
Student.prototype.getStudentValue = function() {
  return this.sno
}

// 覆蓋已有的方法 —— 2
Student.prototype.getPersonValue = function() {
  return 'Bin'
}

// 範例化 Student
let stu = new Student()

console.log(stu.getPersonValue()) // Bin
登入後複製

在上面的程式碼中,註釋1、2的部分涉及兩個方法。

  • 第一個方法 getStudentValue() 是 Student 的新方法,
  • 第二個方法 getPersonValue() 是原型鏈上已經存在但在這裡被遮蔽的方法。

後面在 Student 範例上呼叫 getPersonValue() 時呼叫的是2這個方法。而 Person 的範例仍然會呼叫最初的方法。

重點一:上述兩個方法都是在把原型賦值為 Person 的範例之後定義的。

重點二:另一個要理解的重點是,以物件字面量方式建立原型方法會破壞之前的原型鏈,因為這相當於重寫了原型鏈。下面是一個例子:

// 定義 Person 建構函式
function Person() {
  this.name = 'CoderBin'
}

// 給 Person 的原型上新增 getPersonValue 方法(原型方法)
Person.prototype.getPersonValue = function() {
  return this.name
}

// 定義 Student 建構函式
function Student() {
  this.sno = '001'
}

// 繼承 Person
Student.prototype = new Person()

// 通過物件字面量新增新方法,這會導致上一行無效!!!
Student.prototype = {
  getStudentValue() {
    return this.sno
  },
  someOtherMethod() {
    return 'something'
  }
}

// 範例化 Student
let stu = new Student()

console.log(stu.getPersonValue())  // TypeError: stu.getPersonValue is not a function
登入後複製

在這段程式碼中,子類的原型在被賦值為 Person 的範例後,又被一個物件字面量覆蓋了。覆蓋後的原型是一個Object 的範例,而不再是 Person 的範例。因此之前的原型鏈就斷了。Student 和 Person 之間也沒有關係了。

1.6 原型鏈繼承的缺陷

原型鏈雖然是實現繼承的強大工具,但它也有問題。

主要問題出現在原型中包含參照值的時候。前面在談到原型的問題時也提到過,原型中包含的參照值會在所有範例間共用,這也是為什麼屬性通常會在建構函式中定義而不會定義在原型上的原因。在使用原型實現繼承時,原型實際上變成了另一個型別的範例【1】。這意味著原先的範例屬性搖身一變成為了原型屬性。下面的例子揭示了這個問題:

// 定義 Person 建構函式
function Person() {
  this.letters = ['a', 'b', 'c']
}

// 定義 Student 建構函式
function Student() {
  this.sno = '001'
}

// 繼承 Person
Student.prototype = new Person()

let stu1 = new Student()
let stu2 = new Student()

stu1.letters.push('d')

console.log(stu1.letters)  // ['a', 'b', 'c', 'd']
console.log(stu2.letters)  // ['a', 'b', 'c', 'd']
登入後複製

程式碼解析: 在這個例子中,Person 建構函式定義了一個 letters 屬性,其中包含一個陣列(參照值)。每個 Person 的範例都會有自己的 letters 屬性,包含自己的陣列。但是,當 Student 通過原型繼承 Person 後,Student.prototype變成了 Person 的一個範例,因而也獲得了自己的 letters 屬性。這類似於建立了Student.prototype.letters 屬性。最終結果是,Student 的所有範例都會共用這個 letters 屬性。這一點通過 stu1.letters 上的修改也能反映到 stu2.letters 上就可以看出來。

原型鏈的第二個問題是,子型別在範例化時不能給父類別型的建構函式傳參【2】。事實上,我們無法在不影響所有物件範例的情況下把引數傳進父類別的建構函式。再加上之前提到的原型中包含參照值的問題,就導致原型鏈基本不會被單獨使用。

2. 盜用建構函式繼承【方案二】

為了解決原型包含參照值導致的繼承問題,一種叫作「盜用建構函式」 (constructor stealing)的技術在開發社群流行起來(這種技術有時也稱作「物件偽裝」或「經典繼承」)。基本思路很簡單:在子類建構函式中呼叫父類別建構函式。 因為畢竟函數就是在特定上下文中執行程式碼的簡單物件,所以可以使用apply()call()方法以新建立的物件為上下文執 行建構函式。來看下面的例子:

// 定義 Person 建構函式
function Person() {
  this.letters = ['a', 'b', 'c']
}

// 定義 Student 建構函式
function Student() {
  // 繼承 Person — 使用 call() 方法呼叫 Person 建構函式
  Person.call(this)
}

let stu1 = new Student()
let stu2 = new Student()

stu1.letters.push('d')

console.log(stu1.letters)  // ['a', 'b', 'c', 'd']
console.log(stu2.letters)  // ['a', 'b', 'c']
登入後複製

程式碼解析: 範例中繼承 Person 那一行程式碼展示了盜用建構函式的呼叫。通過使用call() (或 apply() )方法,Person 建構函式在為 Student 的範例建立的新物件的上下文中執行了。這相當於新的 Student 物件上執行了 Person() 函數中的所有初始化程式碼。結果就是每個範例都會有自己的 letters 屬性。

2.1 傳遞引數

相比於使用原型鏈,盜用建構函式的一個優點就是可以在子類建構函式中向父類別建構函式傳參。來看下面的例子:

// 定義 Person 建構函式
function Person(name) {
  this.name = name
}

// 定義 Student 建構函式
function Student(name) {
  // 繼承 Person
  Person.call(this, name)
  // 範例屬性
  this.age = 18
}

let stu = new Student('CoderBin')

console.log(stu.name)   // CoderBin
console.log(stu.age)     // 18
登入後複製

程式碼解析:在這個例子中,Person 建構函式接收一個引數 name ,然後將它賦值給一個屬性。在 Student 建構函式中呼叫 Person 建構函式時傳入這個引數,實際上會在 Student 的範例上定義 name 屬性。為確保 Person 建構函式不會覆蓋 Student 定義的屬性,可以在呼叫父類別建構函式之後再給子類範例新增額外的屬性。

2.2 盜用建構函式繼承的缺陷

盜用建構函式的主要缺點,也是使用建構函式模式自定義型別的問題:必須在建構函式中定義方法,因此函數不能重用。此外,子類也不能存取父類別原型上定義的方法,因此所有型別只能使用建構函式模式。由於存在這些問題,盜用建構函式基本上也不能單獨使用。

3. 組合繼承【方案三】

組合繼承 (有時候也叫偽經典繼承)綜合了原型鏈和盜用建構函式,將兩者的優點集中了起來。基本的思路是:使用原型鏈繼承原型上的屬性和方法,而通過盜用建構函式繼承範例屬性。 這樣既可以把方法定義在原型上以實現重用,又可以讓每個範例都有自己的屬性。來看下面的例子:

// 定義 Person 建構函式
function Person(name) {
  this.name = name
  this.letters = ['a', 'b', 'c']
}

// 在 Person 的原型上新增 sayName 方法
Person.prototype.sayName = function() {
  console.log(this.name + ' 你好~')
}

// 定義 Student 建構函式
function Student(name, age) {
  // 繼承屬性
  Person.call(this, name)
  this.age = age
}

// 繼承方法
Student.prototype = new Person()

// 在 Student 的原型上新增 sayAge 方法
Student.prototype.sayAge = function() {
  console.log(this.age)
}

let stu1 = new Student('CoderBin', 18)
let stu2 = new Student('Bin', 23)

stu1.letters.push('d')

// 輸出 stu1 的資訊
console.log(stu1.letters)   // [ 'a', 'b', 'c', 'd' ]
stu1.sayName()               // CoderBin 你好~
stu1.sayAge()                 // 18

// 輸出 stu2 的資訊
console.log(stu2.letters)   // [ 'a', 'b', 'c']
stu2.sayName()               // Bin 你好~
stu2.sayAge()                 // 23
登入後複製

程式碼解析:在這個例子中,Person 建構函式定義了兩個屬性,name 和 letters ,而它的原型上也定義了一個方法叫 sayName() 。Student 建構函式呼叫了 Person 建構函式,傳入了 name 引數,然後又定義了自己的屬性 age 。

此外,Student.prototype 也被賦值為 Person 的範例。 原型賦值之後,又在這個原型上新增了新方法sayAge() 。這樣,就可以建立兩個 Student 範例,讓這兩個範例都有自己的屬性,包括 letters , 同時還共用相同的方法。

最後:組合繼承彌補了原型鏈和盜用建構函式的不足,是 JavaScript 中使用最多的繼承模式。而且組合繼承也保留了instanceof操作符和isPrototypeOf()方法識別合成物件的能力。

4. 原型式繼承【方案四】

2006年,Douglas Crockford(JSON之父) 寫了一篇文章:《JavaScript中的原型式繼承》(「Prototypal Inheritance in JavaScript」)。這篇文章介紹了 一種不涉及嚴格意義上建構函式的繼承方法。他的出發點是即使不自定義型別也可以通過原型實現物件之間的資訊共用。文章最終給出了一個函數:

function object(o) {
  function F() {}
  F.prototype = o
  return new F()
}
登入後複製

這個object() 函數會建立一個臨時建構函式,將傳入的物件賦值給這個建構函式的原型,然後返回這個臨時型別的一個範例。

4.1 方法一:object

本質上,object() 是對傳入的物件執行了一次淺複製。 來看下面的例子:

function object(o) {
  function F() {}
  F.prototype = o
  return new F()
}

let person = {
  name: 'CoderBin',
  letters: ['a', 'b', 'c']
}

let p1 = object(person)
let p2 = object(person)

p1.name = 'p1'
p1.letters.push('d')

p2.name = 'p2'
p2.letters.push('e')

console.log(person.letters)   // [ 'a', 'b', 'c', 'd', 'e' ]
登入後複製

程式碼解析:在這個例子中,person 物件定義了另一個物件也應該共用的資訊,把它傳給 object() 之後會返回一個新物件。這個新物件的原型是 person ,意味著它的原型上既有原始值屬性又有參照值屬性。這也意味著 person.letters 不僅是 person 的屬性,也會跟 p1 和 p2 共用。這裡實際上克隆了兩個 person 。

Crockford推薦的原型式繼承適用於這種情況:你有一個物件,想在它的基礎上再建立一個新物件。你需要把這個物件先傳給 object() ,然後再對返回的物件進行適當修改。

4.2 方法二:Object.create()

ECMAScript5 通過增加Object.create()方法將原型式繼承的概念規範化了。這個方法接收兩個引數:作為新物件原型的物件,以及給新物件定義額外屬性的物件(第二個可選)。在只有一個引數時,Object.create() 與這裡的object()方法效果相同:

let person = {
  name: 'CoderBin',
  letters: ['a', 'b', 'c']
}

let p1 = Object.create(person)
let p2 = Object.create(person)

p1.name = 'p1'
p1.letters.push('d')

p2.name = 'p2'
p2.letters.push('e')

console.log(person.letters)   // [ 'a', 'b', 'c', 'd', 'e' ]
登入後複製

Object.create()的第二個引數與Object.defineProperties()的第二個引數一樣:每個新增屬性都通過各自的描述符來描述。以這種方式新增的屬性會遮蔽原型物件上的同名屬性。比如:

let person = {
  name: 'CoderBin',
  letters: ['a', 'b', 'c']
}

let p1 = Object.create(person, {
  name: {
    value: 'CoderBin'
  }
})

console.log(p1.name)
登入後複製

原型式繼承非常適合不需要單獨建立建構函式,但仍然需要在物件間共用資訊的場合。但要記住,屬性中包含的參照值始終會在相關物件間共用,跟使用原型模式是一樣的。

5. 寄生式繼承【方案五】

與原型式繼承比較接近的一種繼承方式是寄生式繼承 (parasitic inheritance),也是Crockford首倡的一種模式。寄生式繼承背後的思路類似於寄生建構函式和工廠模式:建立一個實現繼承的函數,以某種方式增強物件,然後返回這個物件。基本的寄生繼承模式如下:

function inheritPrototype(o) {
  let clone = Object.create(o)  // 通過呼叫函數建立一個新物件
  clone.sayHi = function() {     // 以某種方式增強這個物件
    console.log('Hi~')
  }
  return clone  // 返回這個物件
}
登入後複製

程式碼解析:在這段程式碼中,inheritPrototype() 函數接收一個引數,就是新物件的基準物件。這個物件 o 會被傳給Object.create()函數,然後將返回的新物件賦值給 clone 。接著給 clone 物件新增一個新方法 sayHi() 。最後返回這個物件。可以像下面這樣使用 inheritPrototype() 函數:

let person = {
  name: 'CoderBin',
  letters: ['a', 'b', 'c']
}

let p1 = inheritPrototype(person)
p1.sayHi()  // Hi~
登入後複製

程式碼解析:這個例子基於 person 物件返回了一個新物件。新返回的 p1 物件具有 person 的所有屬性和方法,還有一個新方法叫 sayHi() 。寄生式繼承同樣適合主要關注物件,而不在乎型別和建構函式的場景。Object.create()函數不是寄生式繼承所必需的,任何返回新物件的函數都可以在這裡使用。

注意: 通過寄生式繼承給物件新增函數會導致函數難以重用,與建構函式模式類似。

6. 寄生式組合繼承【方案六】

組合繼承其實也存在效率問題。最主要的效率問題就是父類別建構函式始終會被呼叫兩次:一次在是建立子類原型時呼叫,另一次是在子類建構函式中呼叫。本質上,子類原型最終是要包含超類物件的所有範例屬性,子類建構函式只要在執行時重寫自己的原型就行了。

6.1 組合式繼承的缺陷

再來看一看這個組合繼承的例子:

// 定義 Person 建構函式
function Person(name) {
  this.name = name
  this.letters = ['a', 'b', 'c']
}

// 在 Person 的原型上新增 sayName 方法
Person.prototype.sayName = function() {
  console.log(this.name)
}

// 定義 Student 建構函式
function Student(name, age) {
  Person.call(this, name)   // 第一次呼叫 Person()
  this.age = age
}

Student.prototype = new Person()  // 第二次呼叫 Person()

// 讓 Student 的原型指回 Student
Student.prototype.constructor = Student

// 在 Student 的原型上新增 sayAge 方法
Student.prototype.sayAge = function() {
  console.log(this.age)
}

let stu = new Student('CoderBin', 18)

console.log(stu)
// 輸出:Student { name: 'CoderBin', letters: [ 'a', 'b', 'c' ], age: 18 }

console.log(Student.prototype)
// 輸出:
// Person {
//   name: undefined,
//   letters: [ 'a', 'b', 'c' ],      
//   constructor: [Function: Student],
//   sayAge: [Function (anonymous)]   
// }
登入後複製

程式碼解析:程式碼中註釋的部分是呼叫 Person 建構函式的地方。在上面的程式碼執行後,Student.prototype上會有兩個屬性:name 和 letters 。它們都是 Person 的範例屬性,但現在成為了 Student 的原型屬性。在呼叫 Student 建構函式時,也會呼叫 Person 建構函式,這一次會在新物件上建立範例屬性 name 和 letters 。這兩個範例屬性會遮蔽原型上同名的屬性。

所以,執行完上面的程式碼後,有兩組 name 和 letters 屬性:一組在範例上,另一組在 Student 的原型上。這是呼叫兩次 Person 建構函式的結果。

6.2 解決方法

寄生式組合繼承通過盜用建構函式繼承屬性,但使用混合式原型鏈繼承方法。基本思路是不通過呼叫父類別建構函式給子類原型賦值,而是取得父類別原型的一個副本。說到底就是使用寄生式繼承來繼承父類別原型,然後將返回的新物件賦值給子類原型。寄生式組合繼承的基本模式如下所示:

function inheritPrototype(subType, superType) {
  let prototype = Object.create(superType.prototype)   // 建立物件
  prototype.constructor = subType                             // 增強物件
  subType.prototype = prototype                               // 賦值物件
}
登入後複製

程式碼解析:這個 inheritPrototype() 函數實現了寄生式組合繼承的核心邏輯。這個函數接收兩個引數:子類建構函式和父類別建構函式。在這個函數內部,第一步是建立父類別原型的一個副本。然後,給返回的prototype 物件設定 constructor 屬性,解決由於重寫原型導致預設 constructor 丟失的問題。最後將新建立的物件賦值給子型別的原型。如下例所示,呼叫 inheritPrototype() 就可以實現前面例子中的子型別原型賦值:

// 定義 Person 建構函式
function Person(name) {
  this.name = name
  this.letters = ['a', 'b', 'c']
}

// 在 Person 的原型上新增 sayName 方法
Person.prototype.sayName = function() {
  console.log(this.name)
}

// 定義 Student 建構函式
function Student(name, age) {
  Person.call(this, name)
  this.age = age
}
// 呼叫 inheritPrototype() 函數,傳入 子類建構函式 和 父類別建構函式
inheritPrototype(Student, Person)

// 在 Person 的原型上新增 sayAge 方法
Student.prototype.sayAge = function() {
  console.log(this.age)
}

let stu = new Student('CoderBin', 18)

console.log(stu)
// 輸出:Student { name: 'CoderBin', letters: [ 'a', 'b', 'c' ], age: 18 }

console.log(Student.prototype)
// 輸出
// Person {
//   constructor: [Function: Student],
//   sayAge: [Function (anonymous)]   
// }
登入後複製

這裡只呼叫了一次 Person 建構函式,避免了Student.prototype上不必要也用不到的屬性,因此可以說這個例子的效率更高。而且,原型鏈仍然保持不變,因此instanceof操作符和isPrototypeOf()方法正常有效。寄生式組合繼承可以算是參照型別繼承的最佳模式。

7. 寫到最後

到此為止,關於 JavaScript 中實現繼承的六種方法就全部總結完畢了,如果你能堅持看到這裡,相信繼承這一塊的知識你已經足夠掌握了。當然,JS 還有其他相當重要的知識點,比如 this 指向等等,可以點選 前往學習。

【推薦學習:】

以上就是聊聊JavaScript中實現繼承的6種方法的詳細內容,更多請關注TW511.COM其它相關文章!