JS 原力覺醒 Day22 - 原型共享與原型繼承

前一天我們提到 JS 的原型,以及為什麼會有原型的出現 :為了模擬物件導向的行為。 那麼原型實際上帶來什麼好處?又是透過什麼方式達到繼承的目的?

Outline

  • 原型共享:原型的運作方式
  • 原型鍊
  • 原型繼承

原型共享:原型的運作方式

__proto__ 屬性會在物件產生的時候被加到這個物件上,這個 __proto__ 就是透過參考的方式,將「被生成物件」與函式的「原型物件」做連結 ( 看到 proto 前後的「_」有沒有把他跟「連結」做聯想,是不是覺得這個變數取的很好? )。這個自動產生 __proto__ 參考的行為是 JS 預設的動作,有一點像是這樣:

let user1 = new User() 
user1.__proto__ = User.prototype 

當然因為這件事情是自動發生的,所以我們不需要手動去做這件事情,在開發上也不建議操作 __proro__ 這個變數,請讓他自由,所以整理一下提到的兩個名詞。

  1. proto :會在物件被生成時一起被指派到物件上的屬性,他決定這個物件的原型物件是誰。
  2. prototype :會一直存在於建構函式上的屬性,所有透過該函式產生的物件都有能力存取。

當我們想要取用物件中的某個屬性時,JS 會先去物件中尋找該屬性,如果沒有,就會轉而透過__proto__ 往原型物件屬性,也就是 prototype 原型物件,去尋找這個屬性。由於原型物件 prototype 本身也是物件,所以我們在這個物件內也可以另外新增屬性,而透過前面的說明我們也可以知道原型物件是在被生成物件之間被共享的,所以我們就可以把一些共用的變數或是方法,放到這個共用的物件之內。

let defaultName = 'Darth Vader'
User.prototype.name = defaultName

let user1  = new User() 
let user2  = new User() 

user1.name // 'Darth Vader'
user2.name // 'Darth Vader'

這麼做有什麼好處? 把共用函式放在函式建構子裡面的話,每個被生成物件還是會有一樣的函式阿?是這樣沒錯,但是這樣等於是把同樣的數值或函式複製好幾次,生成幾個物件,JS 就會需要幾個記憶體空間;而要同樣的目的,其實只要放在 prototype 原型物件內就可以用較低成本的方式達成。

原型鍊

剛剛說到當 JS 引擎在物件內找不到某個屬性時會透過 __proto__ 去往 prototype 原型物件去搜尋這個屬性,如果原型物件裡還是找不到,這個原型物件上也還會有一個 __proto__,指向他所屬前代類別的原型物件,例如 JS 內 Array 其實也是物件,所以可以說他的前代就是 Object 物件:

Array.prototype.__proto__ === Object.prototype // true

因此 JS 引擎會再透過原型物件裡的 __proto__ 屬性往上一個原型物件尋找,直到真的找不到為止 ( 會找到 JS 內 Object 物件的原型物件為止,你可以再透過 __proto__ 往上找找,最後會發現他是 null )。這個行為跟當初我們講到範圍鍊 ( Scope Chain ) 的行為類似,所以也稱為「原型鍊 ( Prototype Chain )」。

https://ithelp.ithome.com.tw/upload/images/20191007/20106580PmyBG9ONck.jpg

原型繼承

最後這個部分就讓我實際的程式碼範例來實作繼承,順便藉此說明原型鍊概念的實用性,在繼承的行為裡,透過被繼承的「後代類別」,所產生出來的物件,一開始就應該要直接具有「前代類別」的屬性跟方法,我們來嘗試看看有沒有辦法透過 JS 達到這個目的。

現在假設:

  • 我們有一個 Human 類別跟 User 類別
  • 在 Human 類別的物件上有一個 getRace 方法
  • 在 User 類別的物件上有一個 getUserName 方法

我們的目標是:透過原型鍊實現 Human 與 User 兩者的繼承關係。

function Human (action, height,race){
	this.action = action 
	this.height = height 
	this.race = race
}


function User(fisrtname,lastname){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
}
 

Human.prototype.getHumanRace = function(){
	return this.race
} 

User.prototype.getFullName = function(){
	return this.firstname + this.lastname
}

在正式開始之前我們要先思考一下有哪些部分要處理,才能夠讓要繼承的函式建構子與被繼承的函式建構子共享屬性跟方法,主要有兩個方向:

1. 前後代類別原型物件繼承

因為透過 new 運算子生成物件的時候,這兩個建構函式上都會各有一個 protorype 物件,一般情況下他們各自為政 ,但是在處理繼承的時候我們必須同時考慮兩者之間的連結。

前面提到物件在找不到屬性時,就會往原型物件找,如果原型物件裡還是找不到,就會再透過原型物件裡的 __proto__ 屬性往上一個原型物件尋找,形成原型鍊。原型物件之間要做到繼承就代表了:

透過「後代類別」產生的物件,其上有屬性不管在物件內還是在原型物件上都無法找到時,會轉而往「前代類別」的原型物件尋找

能夠做到這樣子的行為,我們才能說我們透過建立原型屬性的原型鍊,而做到繼承的效果。為了達到這樣子的效果,很顯見的我們必須修改物件上的 __proto__ 連結,但是前面也有提過再開發上不建議直接修改__proto__ 的參考,因為會破壞物件的預設行為,儘管如此,我們還是可以用比較曖昧的方式來修改這個連結:

User.prototype = Object.create(Human.prototype)

我只用一個之前沒看過的 JS 內建方法 Object.create 修改了繼承物件 User 的 prototypeObject.create 可以用來創在一個全新的物件,而且他把第一個參數傳入的物件拿來當作這個新物件的 prototype ,之後我們就可以發現 User 的原型物件,被我們修改成一個新的空物件,而這個物件的原型,正是指向 Human , 透過這樣的方式 ,我們就把兩者之間繼承的原型鍊串起來了。

https://ithelp.ithome.com.tw/upload/images/20191007/20106580jQaA3vn2o0.png

但是如果你有注意到的話,原本在原型物件上都會有個指回建構函式的prototype.constructor 已經不見了,因此我們需要手動把他加回來,JS 才能夠查找到正確的建構函式。

User.prototype.constructor = User

https://ithelp.ithome.com.tw/upload/images/20191007/20106580jaRrWOjwPH.jpg

2. 前後代建構函式內容繼承

透過原型物件確實可以達成共享,但如果透過這個方法來共享某些特定屬性,因為屬性的記憶體空間只有一個,這麼一來如果是像「姓名」、「年齡」這種每個人(實體)都會有不同數值的資料,就不適合放在原型物件內,所以我們要想辦法讓我們在「後代」建構函式內可以直接取得「前代」建構函式內容。

簡單來說就是讓前代類別的內容出現在透過後代類別的建構函式所產生的物件上,這裡有一個很經典的辦法,那就是在後代 ( 繼承類別 ) 建構函式裡面執行前代( 被繼承類別 ) 建構函式:

function Human (height,race){
	this.height = height 
	this.race = race
}


function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
  Human(height,race) // This is not totally correct
}

這麼一來當 User 透過 new 被呼叫的時候,除了會將 User 內的 this 繫結綁到新生成物件上,還會有另外一個充滿使用 this 繫結來設定物件屬性的 Human 方法被執行,如此一來,前代類別的屬性設置就能夠與後代共用,而前兩行定義的 firstnamelastname ,也正好是 User 專屬,Human 不會有的資料屬性,當然我們也可以直接把 Human內定義的屬性搬到 User 內,不過這樣就會變成是重新定義一整個物件屬性,就失去繼承的意義了:

// dont do this if you want to make an inheritance.
//THIS IS AN ANTI-PATERN
function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
	this.height = height 
	this.race = race
}

但是還沒有完,這邊有一個前面提過很重要的觀念,那就是當我們 執行 Human 方法時,裡面的 this 繫結並非透過 new 被觸發,所以並不是指向剛剛透過 User 函式建構子被生成的新物件,這個時候我們要透過「明確的繫結」來修改 this 的指向,來把 User 內的 this 連結到 Human 函式的 this 上,這樣子我們就達成了所有物件屬性的繼承:

function Human (height,race){
	this.height = height 
	this.race = race
}

function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
  Human.call(this,height,race) 
}

Human.prototype.getHumanRace = function(){
	return this.race
} 

User.prototype.getFullName = function(){
	return this.firstname + this.lastname
} 

User.prototype = new Human()

let user1 = new User('John','Kai','black','179')   

https://ithelp.ithome.com.tw/upload/images/20191007/20106580dVHNzBM7ks.png

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×