淺談JavaScript中的繼承

lanzhiheng發表於2018-05-29

近期,公司的業務處於上升期,對人才的需求似乎比以往任何時候都多。作為公司的前端,有幸窺探到了公司的前端面試題目,其中有一題大概是這樣的(別激動,題目已經改了)

請用你自己的方式來實現JavaScript的繼承。

這不是當年讓我束手無策的題目嗎?然而我卻痛苦地發現,在一年多以後的今天,即便我已經累計了不少的前端工作經驗,但再次面對這道題目的時候我依然束手無策。

JavaScript

如果當時稍微懂一點ES6的話可能想都不想就能回答出來

class A extends B {

}
複製程式碼

ES6總算給我們帶來了class關鍵字,這使得JavaScript用起來更有物件導向程式語言的味道。然而JavaScript基於原型這一本質並沒有變,今天就來談談語法糖衣背後的東西,在ES6還沒有盛行之前我們如何做繼承?瞭解了底層原理之後,上面的面試題就不再是問題了。

1. 基於原型

下面做一個簡單的類比,可能描述得並不是十分準確。

如果我把類想象成一個模子,物件則猶如是往模子澆灌材料所鑄造成的的器具。而這個模子,它規定了我們期望的器具的樣式,規格,形狀等屬性,就如同程式語言裡面類預先定製了它所能夠產生的物件的屬性,方法那樣。

而原型,則可以理解成一個通過模子製作的器具,它擁有模子所預設的各種屬性,它自身就是樣式,規格,形狀這些屬性的集合,我們可以根據這個已有的器具去仿製更多同樣的器具。

同樣是用於生產,只不過從行為上來看他們屬性的源頭稍微有點不一樣。一種就如同設計師給了你一份文件,告訴你我要這樣的產品,然後你把它量產。另一種就是給你一個成型的產品,告訴你我要一模一樣的產品,然後你根據已有的產品去量產更多的相似產品。 個人覺得JS基於原型的行為更像是後者。

2. JavaScript中的類定義

在ES5裡面我們沒有class關鍵字,這使得它物件導向的特性沒有常規的面嚮物件語言那麼直觀,我們只能夠通過方法來模擬類。我們來定義一個名為Person的類,並設定一個例項方法printInformation,程式碼如下

// 定義Person類
function Person(name, age) {
  this.name = name
  this.age = age
}

// 在原型上定義方法
Person.prototype.printInformation = function() {
  console.log("Name:" + this.name + " Age:" + this.age)
}

複製程式碼

可見上面的程式碼有點反人類,起碼不如一般的物件導向的程式語言直觀。為了方便,我採用node環境來執行上面程式碼,並檢視它的效果。

> me = new Person("Lan", 26)
Person { name: 'Lan', age: 26 }

> me.printInformation()
Name:Lan Age:26

> me.name
'Lan'

> me.age
26

> console.log(Person.prototype)
Person { printInformation: [Function] }
複製程式碼

物件中的nameage兩個屬性就像是我們平時接觸得比較多的例項變數,而放在原型中的printInformation方法就可以看成是例項方法。不過現在看來在JavaScript裡面他們之間的界限似乎有點模糊,因為他們都可以通過物件來直接訪問,他們之前的區別在後面講繼承的時候可能會越來越清晰。

3. ES5中的繼承

如果用ES6的語法來實現繼承的話,似乎沒有什麼難度,ES6提供了class語法,以及extends語法,使得我們很容易就能夠實現類與類之間的繼承,以下是React元件的官方推薦寫法。

import React from 'react'

class Button extends React.Component {
  constructor(props) {
    super(props)

    ....
  }

  ...
}
複製程式碼

語法十分簡練,然而,在ES5的時候我們似乎並沒有這麼幸運,為了實現子類繼承父類的行為,我們似乎需要做很多工作。下面就採用之前定義的Person來作為父類,另外再建立一個子類Student來繼承它。

1) 繼承父類例項變數

例項變數的初始化一般是放在建構函式中,而在ES5中我們可以直接把上面的Person這類函式看做是建構函式,另外Student建構函式也應該能夠初始化nameage這兩個例項變數。那麼如何讓Student所產生的物件也擁有兩個欄位呢?我們可以例項化的時候用當前上下文this去呼叫Person方法,那麼當前的上下文就能夠包含nameage這兩個屬性了。

function Student(name, age, school) {
  Person.call(this, name, age)
  this.school = school || ''
}
複製程式碼

簡單測試一下效果

> var student = new Student('Lan', 26)
undefined

> student
Student { name: 'Lan', age: 26, school: '' }
複製程式碼

我們所建立的事例已經具備了nameageschool這三個欄位了,然而例項方法呢?

> student.printInformation
undefined
複製程式碼

似乎Student並沒能繼承父類Person的相關例項方法,接下來我們看看如何從原型鏈中獲得父類的例項方法。

2) 從原型中獲取方法

JavaScript是一門基於原型的物件導向程式語言。這裡我們可以簡單地把原型理解為一個物件,它包含了一些方法或者屬性,只要為我們的設定了這個原型,它所初始化的物件就能夠擁有該原型中所包含的方法了。

> Person.prototype
Person { printInformation: [Function] }

> Student.prototype
Student {}
複製程式碼

可見Person的原型中包含了一個方法,Student的原型中啥都沒有。然而我們卻不能夠直接把Person的原型直接賦值給Student的原型,不然當Student往自身原型中新增方法的時候也會影響到Person的原型。怎麼破?把原型拷貝一份唄。

一開始說過JS裡面建立物件的過程有點像器具的仿造,所以我們可以用Person建立一個新的物件,然後把這個物件作為Student的原型。不過這樣的話會有一個問題,如果用傳統的new方法來建立物件的話,它還會包含一些雜質

> new Person()
Person { name: undefined, age: undefined }
複製程式碼

這些屬性應該在構造器中初始化的,我們不應該把他們放在原型中。這個時候我們可以藉助ES5提供方法,建立一個稍微純淨點的物件。

> Object.create(Person.prototype)
Person {}
複製程式碼

現在建立出來的物件沒有包含建構函式中的例項變數了,我們可以用它來作為原型。稍微深入窺探一下Object.create的原理,其實我們可以用JS程式碼來簡單模擬它(注意只是簡單模擬),更詳細的內容可以參考MDN文件,粗略的模擬程式碼如下

function createByPrototype(proto) {
  var F = function() {}
  F.prototype = proto
  return new F()
}
複製程式碼

它接收一個原型作為引數,然後在內部建立一個沒有例項變數的潔淨函式,並把傳入的引數設定為它的原型。最後使用這個函式來建立一個物件,所得到的物件就不會有例項變數了,但是它卻能夠訪問原型中的方法,我們可以把它理解成一個新的原型

> var createObject = createByPrototype(Person.prototype)
undefined
> createObject.printInformation
[Function]
複製程式碼

OK,理解了原理之後,我們依舊用Object.create來建立新的原型

Student.prototype = Object.create(Person.prototype)
複製程式碼

簡單演示一下

> var student = new Student('Lan', 26, 'GD')
undefined

> student
Person { name: 'Lan', age: 26, school: 'GD' }

> student.printInformation()
Name:Lan Age:26

// 小問題
> student.constructor
[Function: Person]
複製程式碼

上面的結果表明我們的繼承關係已經比較完善了,不過我遺留了一個小問題。我們從student例項去尋找它的構造器,卻找到了Person這個建構函式,這顯然是有問題的,原因我接下來講。

3) 構造器

通過student物件獲取構造器而時候無法得到Student這個建構函式,就相當於你問某個人的父親叫啥名字,他告訴了你他爺爺的名字一樣。我們大Ruby就沒有這種問題

[1] pry(main)> class A < String
[1] pry(main)* end
=> nil
[2] pry(main)> a = A.new
=> ""
[3] pry(main)> a.class
=> A
複製程式碼

在JavaScript中,物件在當前類的原型中找不到對應的屬性,就會沿著原型鏈繼續往上查詢。回到上面的例子,因為student例項在Student類的原型中找不到constructor這個屬性,所以它只能去更高層的Person的原型中去查詢,所以才會得到這種結果

> Person.prototype.constructor
[Function: Person]

> Student.prototype.constructor
[Function: Person]
複製程式碼

解決的辦法很簡單,就如同一個從小由爺爺扶養長大的孩子,很容易就把爺爺當成是父親,你要做的只是告訴他他的父親是誰,一句程式碼就可以了

Student.prototype.constructor = Student
....

> student.constructor
[Function: Student]
複製程式碼

4. 程式碼彙總

對前面所講的東西做個簡單的程式碼彙總

// 定義一個簡單的`類`,幷包含例項變數
function Person(name, age) {
  this.name = name
  this.age = age
}

// 在原型鏈中定義`printInformation`方法
Person.prototype.printInformation = function() {
  console.log("Name:" + this.name + " Age:" + this.age)
}


// 定義一個Student子類,它會收集Person中的例項變數,並且自己會有一個新的例項變數 school
function Student(name, age, school) {
  Person.call(this, name, age)
  this.school = school || ''
}

// 繼承Person原型中的方法,並在原型鏈中新增構造器屬性
Student.prototype = Object.create(Person.prototype)
Student.prototype.constructor = Student
複製程式碼

可見在ES5的時代連類的概念都不清晰,實現繼承都一大堆的麻煩,現在都ES6/7的時代了,一般人應該不會這樣寫程式碼了。

另外,我上面所做的ES5實現的繼承方式,跟如今Babel的做法並不完全一樣,Babel細節方面處理得會稍微多一些,這篇文章我只是闡述了大致的繼承原理。想要了解更多ES6轉換到ES5的細節,可以在Babel的網站上嘗試。

5. 尾聲

今天這篇文章主要闡述了JavaScript基於原型的物件導向特性,以及在JavaScript裡面要實現繼承的注意事項。我們需要通過手動呼叫父類建構函式來繼承父類的例項變數,還要通過設定原型來獲取父類原型中的方法或者屬性,最後要手動在原型鏈中設定constructor屬性來指向自身構造器。

Happy Coding

雖然在ES6的時代我們不再需要手動地做這些事情了,Babel這些現代編譯工具給我們提供了很多的語法糖衣。但是個人覺得有些時候掌握這些老掉牙的知識或許能夠讓你更加深刻地理解這門語言的內涵,而不至於在工具盛行的今天,被各種工具,語法糖衣搞得暈頭轉向。面向工具程式設計是個高效的事情,然而當沒有了工具就不會程式設計了,可就不是什麼好事情了。

Happy Coding and Writing!!

相關文章