JavaScript:原型(prototype)

2022-12-23 18:00:53

物件導向有一個特徵是繼承,即重用某個已有類的程式碼,在其基礎上建立新的類,而無需重新編寫對應的屬性和方法,繼承之後拿來即用;

在其他的物件導向程式語言比如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沒有找到,於是去原型上去找,於是找到了obj1name屬性了,換句話說,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的值valueCat.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類似;

我們先看一下Objectprototype屬性吧,是的,類和函數一樣,也有這個屬性(注意,是類有這個屬性,而不是類的範例即物件有這個屬性);

看上圖,是不是很眼熟,這不就是字面量物件的原型嗎?

是的,如上圖所示,就是它;

還記得原型鏈吧,那麼這個原型物件還有原型嗎?

如上所示,沒有了,指向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.prototypevalue值,複製給c的隱藏屬性[[Prototype]],即c.__proto__ = classC.prototype

整個完整流程如上所示;

現在來試著對著流程看看let b = new classB()吧:

  • 首先建立字面量空物件;
  • 賦值給變數b
  • 執行classB()的語句,新增了屬性nameB
  • 沒有構造器,提供預設的構造器,執行super()即執行classA()的語句,於是新增了屬性nameA
  • 最後,複製b的原型為classB.prototypevalue值;

輸出結果也驗證了我們所說的;

操作原型的現代方法

之前已經說過,通過__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

這是一條完整的原型鏈,從中也能看出繼承是什麼樣的一個形式;