物件導向有一個特徵是繼承,即重用某個已有類的程式碼,在其基礎上建立新的類,而無需重新編寫對應的屬性和方法,繼承之後拿來即用;
在其他的物件導向程式語言比如Java中,通常是指,子類繼承父類的屬性和方法;
我們現在來看看,JS是如何實現繼承這一個特徵的;
要說明這個,我們首先要看看,每個物件都有的一個隱藏屬性[[Prototype]]
;
物件的隱藏屬性[[Prototype]]
在JS中,每個物件obj
,都有這樣一個隱藏屬性[[Prototype]]
,它的值要麼是null,要麼是對另一個物件anotherObj
的引用(不可以賦值為其他型別值),這另一個物件anotherObj
,就叫做物件obj
的原型;
通常說一個物件的原型,就是在說這個隱藏屬性[[Prototype]]
,也是在說它引用的那個物件,畢竟二者一致;
現在來建立一個非常簡單的字面量物件,來檢視一下這個屬性:
可以看到,物件obj
沒有自己的屬性和方法,但是它還有一個隱藏屬性[[Prototype]]
,資料型別是Object
,說明它指向了一個物件(即原型),這個原型物件裡面,有很多方法和一個屬性;
其他的暫且不論,我們先重點看一下,紅框的constructor()
方法和__proto__
屬性;
訪問器屬性(__proto__
)
訪問[[Prototype]]
從紅框可以看到,屬性__proto__
是一個訪問器屬性,有getter/setter特性(這個屬性名前後各兩個下劃線);
問題是,它是用來訪問哪個屬性的?
我們來呼叫一下看看:
可以看到,__proto__
訪問器屬性,訪問的正是隱藏屬性[[Prototype]]
,或者說,它指向的正是原型物件;
值得一提的是,這是一個老式的訪問原型物件的方法,現代程式語言建議使用Object.getPrototypeOf/setPrototypeOf
來訪問原型物件;
但是考慮相容性,使用__proto__
也是可以的;
請注意,__proto__
不能代表[[Prototype]]
本身,它只是其一個訪問器屬性;
設定[[Prototype]]
正因為它是訪問器屬性,也即具有getter和setter功能,我們現在可以控制物件的原型物件的指向了(並不建議這樣做):
如上圖,現在將其賦值為null,好了,現在obj
物件沒有原型了;
如上圖,建立了兩個物件,並且讓obj1
沒有了原型,讓obj2
的原型是obj1
;
看看,此時obj2.name
讀取到obj1
的屬性name
了,首先obj2
在自身屬性裡找name
沒有找到,於是去原型上去找,於是找到了obj1
的name
屬性了,換句話說,obj2
繼承了obj1
的屬性了;
這就是JS實現繼承的方式,透過原型這種機制;
讓我們看看下面的程式碼:
正常的obj2.name = 'Jerry'
的新增屬性的語句,會成為obj2
物件自己的屬性,而不會去覆蓋原型的同名屬性,這是再正常不過了,繼承得來的東西。只能讀取,不能修改(訪問器屬性__proto__
除外);
現在的問題是,為什麼obj2.__proto__
是undefined
?上面不是剛剛賦值為obj1
了嗎?
原因就在於__proto__
是訪問器屬性,我們讀取它實際上是在呼叫對應的getter/setter方法,而現在obj2
的原型(即obj1
)並沒有對應的getter/setter方法,自然是undefined
了;
現在綜合一下,看下面程式碼:
為什麼最後obj2.__proto__
輸出的是hello world
,為什麼__proto__
成了obj2
自己的屬性了?
關鍵就在於紅框的三句程式碼:
第一句let obj2 = {}
,此時obj2
有原型,有訪問器屬性__proto__
,一切正常;
第二句obj2.__proto__ = obj1
,這句呼叫__proto__
的setter方法,將[[Prototype]]
的引用指向了obj1
;
這一句完成以後,obj2
因為obj1
這個原型而沒有訪問器屬性__proto__
了;
所以第三句obj2.__proto__ = 'hello world'
的__proto__
已經不再是訪問器屬性了,而是一個普通的屬性名了,所以這句就是一個普通的新增屬性的語句了;
構造器(constructor)
在隱藏屬性[[Prottotype]]
那裡,看到其有一個constructor()
方法,顧名思義,這就是構造器了;
類物件與函式物件
- 類物件
在其他程式語言比如Java中,構造方法通常是和類名同名的函式,裡面定義了物件的一些初始化程式碼;
當需要一個物件時,就透過new
關鍵字去呼叫構造方法建立一個物件;
那在JS中,當我們let obj = {}
去建立一個字面量物件的時候,發生了什麼?
上面這句程式碼,其實就是let obj = new Object()
的簡寫,也是透過new
關鍵字去呼叫一個和類名同名的構造方法去建立一個物件,在這裡就是構造方法Object()
;
這種透過new className()
呼叫構造方法創造的物件,稱為類物件;
- 函式物件
但是,再等一下,JS早期是沒有類的概念的,那個時候大家又是怎麼去建立物件的呢?
想一下,建立物件是不是需要一個構造方法(即一個函式),本質上是不是new Function()
的形式去建立物件?
對咯,早期就是new Function()
去建立物件的,這個Function
就叫做建構函式;
這種透過new Function()
呼叫建構函式創造的物件,稱為函式物件;
建構函式和普通函式又有什麼區別呢?除了要求是用function
關鍵字宣告的函式,並且命名建議大駝峰以外,幾乎是沒有區別的:
看,我們宣告瞭一個建構函式Cat()
,並透過new Cat()
創造了一個物件tom
;
列印tom
發現,它有一個原型,這個原型和字面量物件的原型不一樣,它有一個方法一個屬性;
方法是constructor()
構造器,指向的正是Cat()
函式;
屬性是另一個隱藏屬性[[Prototype]]
,暫時不去探究它是誰;
也就是說,函式物件的原型,是由另一個原型和constructor()
方法組成的物件;
我們可以用程式碼來驗證一下,類物件和函式物件的原型的異同點:
如上所示,建立了一個函式物件tom
和一個類物件obj
;
可以看出:
函式物件的原型的方法constructor()
指向建構函式本身;
函式物件的原型的隱藏屬性[[Prototype]]
和字面量物件(Object物件)的隱藏屬性,他們兩的引用相同,指向的是同一個物件,暫時不去探究這個物件是什麼,就認為它是字面量物件的原型即可;
還可以看到,無論是類物件,還是函式物件,其原型都有constructor()
構造器;
這個構造器在建立物件的過程中,具體起了什麼樣的作用呢?
讓我們先看看函式物件tom
的這個原型是怎麼來的?我們之前一直都是在說物件有一個隱藏屬性[[Prototype]]
指向原型物件,究竟是哪一步,讓這個隱藏屬性指向了原型物件呢?
函式的普通屬性prototype
事實上,每個函式都有一個屬性prototype
,預設情況下,這個屬性prototype
是一個物件,其中只含有一個方法constructor
,而這個constructor
指向函式本身(還有一個隱藏屬性[[Prototype]]
,指向字面量物件的原型);
可以用程式碼佐證,如下所示:
注意,prototype
要麼是一個物件型別,要麼是null,不可以是其他型別,這聽起來很像隱藏屬性[[Prototype]]
,不過prototype
只是函式的一個普通屬性,物件是沒有這個屬性的;
來看下這個屬性的特性吧:
可以看到,它不是一個訪問器屬性,只是一個普通屬性,但是它不可配置不可列舉,只能修改值;
它的value
值,眼熟嗎?正是建構函式建立的函式物件的原型啊;
它居然還有一個特性[[Prototype]]
,不要把它和value
值裡面的屬性[[Prototype]]
弄混,前者是prototype
屬性的特性,後者是prototype
屬性的一個隱藏屬性,雖然此刻他們都指向字面量物件的原型,但是前者始終指向字面量物件的原型,後者則始終指向原型(而原型是會變的);
這裡也不再去追究為什麼它會有這樣一個特性了,讓我們把重點放在prototype
屬性本身;
new Function()的時候發生了什麼
事實上,只有在呼叫new Function()
作為建構函式的時候,才會使用到這個prototype
屬性;
我們來仔細分析一下上面程式碼具體發生了什麼:
let tom = new Cat()
這句程式碼的執行流程如下:
- 先呼叫
Cat.prototype
屬性的特性[[Prototype]]
(我們知道它指向字面量物件的原型)裡面的constructor()
構造器,建立一個字面量空物件,當然此時這個物件的隱藏屬性[[Prototype]]
也都已經存在了,將這個物件分配給this
指標; - 然後返回
this
指標給tom
,即tom
引用了這個字面量空物件,同時this
指向了tom
; - 然後執行建構函式
Cat()
本身的語句,即this.name = "Tom"
,於是tom
就有了一個屬性name
; - 然後將
Cat.prototype
屬性值value
,複製(注意,這裡是複製,不是賦值,這意味著這裡不是傳引用,而是傳值)給tom
的隱藏屬性[[Prototype]]
,即tom.__proto__ = Cat.prototype
;
如果我們用程式碼去描述上面整個過程,就類似於下面這樣:
// let tom = new Cat()的整個具體流程,類似於下面這樣
let tom = {}; //建立字面量物件,並賦值給變數tom
tom.name = "Tom"; // 執行Cat()函式
tom.__proto__ = Cat.prototype; // 將Cat的prototype的屬性值賦值給tom的隱藏屬性[[Prototype]]
現在已經說清楚了new Function()
發生的具體過程,上面程式碼的輸出結果也佐證了我們所說的:
函式物件tom
的原型正是Cat
函式的屬性prototype
的值value
,可以看到他們的constructor()
構造器都指向Cat
函式本身,並且tom.name
的值Tom
;
然後我們修改了Cat
函式的prototype
的值value
,Cat.prototype = Dog.prototype
語句將其設定成了Dog
函式的prototype
的值value
;
讓我們順著剛剛說的流程,看看let newTom = new Cat()
的執行過程:
- 先建立字面量空物件;
- 然後賦值給
newTom
; - 然後呼叫
Cat()
函式本身,即newTom.name = "Tom"
; - 然後執行語句
newTom.__proto__ = Cat.prototype
,而Cat.prototype = Dog.prototype
,所以newTom.__proto__ = Dog.prototype
;
輸出結果佐證了我們的執行過程,函式newTom
的原型正是Dog
函式的屬性prototype
的值value
,他們的constructor()
構造器都指向了Dog
函式本身,但是newTom.name
的值依然是"Tom";
從上面前後兩個輸出結果也可以看出來,最後一步的tom.__proto__ = Cat.prototype
確實是複製而不是賦值,否則在Cat.prototype = Dog.prototype
語句之後,tom.__proto__ = Cat.prototype = Dog.prototype
了,但是輸出結果表面並沒有改變;
現在我們已經明白了函式物件的原型為什麼是這個樣子的,也明白了函式物件的constructor()
構造器指向了建構函式本身;
現在讓我們像下面這樣,使用一下函式物件的constructor()
構造器吧:
看上面的程式碼,我們現在已經知道let tom = new Cat()
的時候都發生了什麼,也知道此時tom
的原型的constructor()
構造器指向的是Dog
函式;
所以let spike = new tom.constructor()
這句程式碼,當tom
去自己的屬性裡沒有找到constructor()
方法的時候,就去原型裡面去找,於是找到了指向Dog
函式的constructor()
構造器,所以這句程式碼就等於let spike = new Dog()
;
透過這段程式碼,好好體會一下函式物件的構造器吧。
建構函式和普通函式的區別
其實從技術上來講,建構函式和普通函式沒有區別;
只是預設建構函式採用大駝峰命名法,並透過new
運算子去建立一個函式物件;
-
new.target
我們怎樣去判斷一個函式的呼叫是普通呼叫,還是
new
運算子呼叫的呢?如上所示,透過
new.target
,可以判斷該函式是被普通呼叫的還是透過new
關鍵字呼叫的; -
建構函式的返回值
建構函式從技術上說,就是一個普通函式,所以當然也可能有
return
返回值(通常建構函式於情於理都是不會有return
語句的);之前說過
new Function()
的時候的具體流程,我們來看一下:-
先建立一個字面量空物件;
-
將空物件賦值給
tom
; -
執行
Cat()
函式,讓tom
有了屬性name
;但是
Cat()
函式有return
語句,返回了一個空物件{}
,由tom
接收了,也就是說tom
被覆蓋賦值了; -
所以最後
tom
指向的是return
語句的空物件,而不是最開始建立的空物件;
-
字面量物件的原型
new Object()的時候發生了什麼
我們剛剛說了new Function()
建立函式物件的時候,具體發生了什麼,現在來看看建立類物件的時候,具體發生了什麼;
以Object
為例,因為它是一個類,是JS其他所有類的祖先,這一點與Java類似;
我們先看一下Object
的prototype
屬性吧,是的,類和函式一樣,也有這個屬性(注意,是類有這個屬性,而不是類的例項即物件有這個屬性);
看上圖,是不是很眼熟,這不就是字面量物件的原型嗎?
是的,如上圖所示,就是它;
還記得原型鏈吧,那麼這個原型物件還有原型嗎?
如上所示,沒有了,指向null了,看樣子我們已經走到了原型鏈的原點了,為了方便,我們就稱呼Object.prototype
為原始原型吧;
看看它的特性吧:
和函式的prototype
屬性的特性,如出一轍,但是注意,它的writable
屬性是false
了,這意味著我們再也無法對這個屬性做任何操作了;
這是當然,它可是所有類的祖先,怎麼能隨意更改呢;
這下我們就能明白new ClassName()
的時候大概流程是什麼樣子了;
以let obj = {}
為例(其實就是let obj = new Object()
):
- 先呼叫
Objecet.prototype
屬性的特性[[Prototype]]
裡面的constructor()
構造器(不再繼續深究這個構造器了),建立一個字面量空物件,當然此時這個物件的隱藏屬性[[Prototype]]
也都已經存在了; - 然後將這個物件賦值給
obj
,即obj
引用了這物件,同時this
指標也就指向了obj
; - 然後執行構造方法
Object()
本身的語句,就不再進一步去研究這個構造方法了,總之此時obj
已經是一個有著很多內建方法的字面量物件了; - 然後將
Object.prototype
屬性值value
,複製給obj
的隱藏屬性[[Prototype]]
,即obj.__proto__ = Object.prototype
;
注意,其實流程不完全是上面這樣子,與建構函式的流程還有一點點區別,主要是第三步,還有一個構造器的執行,這和類的繼承有關係,詳細的在後面new className()的時候發生了什麼裡面具體說明;
更改原始原型
我們剛剛說了,Object.prototype
屬性的所有特性都是false
,意味著我們對這個屬性無法再做任何操作了;
這只是再說,我們不能對其本身做任何刪改的操作了,但是它本身依然是一個物件,這意味著我們可以正常的向其新增屬性和方法;
如上圖所示,我們向Object.prototype
屬性物件裡新增了hello()
方法,並且由obj
物件透過原型呼叫了這個方法;
類物件的原型
我們已經瞭解了函式物件的原型,和原始原型,再來看看類物件的原型;
我們把這三种放一起做個比較吧:
我們自定義了類classA
,自定義了函式functionA
,並建立了類物件clsA
和函式物件funcA
,以及字面量物件;
可以看出,類物件與函式物件的原型的形式,是一致的,只是各自原型裡的constructor()
指向各自的類/函式,即紅框部分不同;
而他們的原型的原型則是一致的,和字面量物件的原型一樣,都指向了原始原型,即綠框部分相同;
上面的輸出結果佐證了這一點;
從這也可以看出來,其他類都是繼承自原始類Object
的,只是原型鏈的長短罷了,最終都可以溯源到原始類Object
;
很顯然,類與建構函式,很類似;
類與建構函式的區別
儘管類物件和函式物件有相似的原型,但是不代表類與建構函式就完全一樣了,他們之間的區別還是很大的:
-
型別不同,定義形式不同
類名後不需要括號,建構函式名後需要加括號;
類的方法宣告形式和建構函式的方法不一樣;
列印類和建構函式,類前的型別是
class
,建構函式前的型別是f
,即function
;注意,不能使用
typeof
運算子,它會認為類和建構函式都是function
-
prototype不一樣
如上所示,類的方法,會成為
prototype
的方法,但是建構函式的方法不會成為prototype
的方法;也即建構函式的
prototype
始終由constructor()
和原始原型組成,函式物件無法透過原型去呼叫在建構函式里定義的方法;函式物件如果想要呼叫
method1()
方法,就不能寫成let method1 = function(){}
,而是this.method1 = function(){}
,將其變為函式物件自己的方法; -
prototype的特性不一樣
類的
prototype
是不可寫的,但是建構函式的prototype
是可寫的; -
方法的特性不一樣
由於函式物件不能透過原型繼承方法,這裡只展示類的方法的特性,如上所示,類的方法,是不可列舉的,也即不會被
for-in
語法遍歷到; -
模式不同
由於類是後來才有的概念,所以類總是使用嚴格模式,即不需要顯示使用
use strict
,類總是在嚴格模式下執行;而建構函式則不同,預設是普通模式,需要顯示使用
use strict
才會在嚴格模式下執行; -
[[IsClassConstructor]]
類有隱藏屬性
[[IsClassConstructor]]
,其值為true;這要求必須使用
new
關鍵字去呼叫它,像普通函式一樣呼叫會出錯:但是很顯然,建構函式本身就是一個函式,是可以像普通函式一樣去呼叫的;
-
構造器
constructor
由於函式物件不能透過原型繼承方法,所以無法自定義構造器;
但是類物件可以繼承啊,所以可以自定義構造器並在
new
的時候呼叫;從圖上可以看出,我們是無法去自定義建構函式的構造器的,它依然還是按照我們所說的流程去建立函式物件的;
我們現在看看,類自定義構造器,是怎麼按照我們的流程去建立類物件的:
-
先呼叫
classA.prototype
的特性[[Prototype]]
裡的構造器去建立一個字面量空物件; -
將空物件賦值給變數
clsA
; -
然後執行構造方法
classA()
本身的語句;首先新增了屬性
outterName
;然後又遇到了
constructor()
方法(注意該構造器與classA.prototype.constructor
不是同一個東西),於是又執行了這個構造器的語句,新增了屬性innerName
;
由此我們可以得出,類在建立類物件的時候,流程依然是我們所述的流程;
但是在遇到類裡面的同名方法
constructor()
時候,不會將其作為原型方法,而是會立即執行該構造器;另外,像
outterName
這樣的屬性,不會成為prototype
的屬性,也就是說,類只有定義的方法(除了constructor
構造器)會進入prototype
的屬性,成為原型被繼承; -
new className()的時候發生了什麼
上面剛剛描述了類自定義構造器之後,建立物件是一個什麼樣的流程;
現在來仔細理解一下類的構造器,事實上,如果我們不顯式自定義構造器,類也會預設提供一個下面這樣的構造器:
constructor() {
super();
}
這裡的super()
實際上就是在呼叫其父類的構造方法(注意不是指父類的構造器constructor()
,而是指父類自身);
用程式碼來驗證一下吧:
我們先來看一下let c = new classC()
的時候,具體流程是什麼樣的吧:
- 首先呼叫
classC.prototype
屬性的特性[[Prototype]]
(它總是指向原始原型),建立一個字面量空物件; - 然後將其賦值給變數
c
; - 然後執行構造方法
classC()
的語句,通常會有新增物件的屬性和方法的語句,這裡沒有; - 接著檢視是否顯式宣告瞭
constructor()
構造器(如果沒有就提供一個預設的構造器),這裡有,於是立即執行這個構造器;- 首先是
super()
,實際上就是執行建構函式classA()
的語句,於是新增了屬性nameA
; - 然後是
this.nameB = 'C'
,於是新增了屬性nameC
;
- 首先是
- 最後,將
classC.prototype
的value
值,複製給c
的隱藏屬性[[Prototype]]
,即c.__proto__ = classC.prototype
;
整個完整流程如上所示;
現在來試著對著流程看看let b = new classB()
吧:
- 首先建立字面量空物件;
- 賦值給變數
b
; - 執行
classB()
的語句,新增了屬性nameB
; - 沒有構造器,提供預設的構造器,執行
super()
即執行classA()
的語句,於是新增了屬性nameA
; - 最後,複製
b
的原型為classB.prototype
的value
值;
輸出結果也驗證了我們所說的;
操作原型的現代方法
之前已經說過,透過__proto__
屬性去操作原型的方法,是歷史的過時的方法,實際上並不推薦;
現代JS有以下方法,供我們去操作原型:
-
Object.getPrototypeOf(obj)
此方法,返回物件
obj
的隱藏屬性[[Prototype]]
; -
Object.setPrototypeOf(obj, proto)
此方法,將物件
obj
的隱藏屬性[[Prototype]]
指向新的物件proto
; -
Object.create(proto, descriptors)
此方法,建立一個空物件,並將其隱藏屬性
[[Prototype]]
指向proto
;同時,可選引數
descriptors
可以給空物件新增屬性,如下所示:
原型鏈與繼承
現在應該已經理解了原型是一個什麼樣的概念,以及如何去訪問原型;
正如繼承有兒子繼承父親,父親繼承爺爺一樣,有這樣一個往上溯源的關係,原型也可以這樣往上溯源,這就是原型鏈的概念;
用程式碼去理解一下吧:
我們定義了三個物件A/B/C,並且設定C的原型是B,B的原型是A;
讀取C.nameA
的時候,首先在C自己的屬性裡去找,沒有找到;
於是去原型B的屬性裡去找,沒有找到;
再去B的原型A的屬性裡去找,找到並輸出;
可以看C展開的一層層結構,可以很清晰的看到原型鏈的存在;
由此也可以看出,JS是單繼承的,同Java一致;
但是正常的繼承,肯定不是這樣手動去設定物件的原型的,而是自動去設定的;
在JS中,繼承的關鍵字也是extends
,也是描述類的父子關係的;
上面程式碼,classC
繼承classB
,而classB
繼承classA
;
所以classC
的物件,繼承了他們的屬性,便有了三個屬性nameA/nameB/nameC
,這也說明,屬性是不放在原型裡的,而是會在建立物件的時候,直接成為classC
的屬性;
classC
的原型,有一個屬性一個方法,方法是constructor()
構造器指向自己,屬性是另一個原型;
注意,列印出來的原型後面標註的classX
,原型指的是物件,不是類,所以classC
的原型不是指classB
這個類本身,而是指其來源於classB
;
紫色框:物件c
的原型,即c.__proto__ == classC.prototype
;
橘色框:classB.prototype
,即物件c
的原型的原型c.__proto__.__proto__ == classB.prototype
;
綠色框:classA.prototype
,即物件c
的原型的原型的原型c.__proto__.__proto__.__proto__ == classA.prototype
;
紅色框:Object.prototype
,也即原始原型c.__proto__.__proto__.__proto__.__proto__ == Object.prototype
;
這是一條完整的原型鏈,從中也能看出繼承是什麼樣的一個形式;