不一樣的面向對象,javaScript原型揭祕

一、導讀

本篇文章將說清楚javaScript的原型、原型鏈機制,說的不對評論區砸板磚~
如果你看了很多篇博客仍然搞不清楚prototype 、_ _ proto _ _、new、constructor的關係,請往下看!
如果你剛從java換坑到javaScript,請往下看!
如果你還在滿口“實例”、“構造”去理解/閱讀javaScript代碼的話,請往下看!
總之,請往下看!

二、[[prototype]] 屬性才真正的叫做原型

JS中所有的對象被創建時會被賦予一個特殊的隱式屬性——[[prototype]] ,它是另一個對象的引用,並且幾乎所有的對象的 [[prototype]] 屬性都是非空的值。一個對象的 [[prototype]] 屬性值就被稱爲這個對象的原型。一個對象成爲另一個對象的原型,就稱這兩個對象被一條原型鏈關聯起來。如果某個對象A,它的 [[prototype]] 屬性值是對象B的引用的話,就稱B成爲了A的原型。
JS函數的prototype 屬性、_ _ proto _ _屬性等其他的和“proto”字眼沾邊兒的,我們統統不稱爲原型。所以下文任何地方出現的“原型”都只表示 [[prototype]] 屬性。
值得一提,上面說所有對象都會有 [[prototype]] 屬性。嚴謹點說是除了null和undefined外所有JS對象都有,包括引用類型和數字、字符串這些基本類型。聲明let a = 'hello' 那a也是有原型的,默認是String.prototype

2.1、用JS標準api -- getPrototypeOf、setPrototypeOf訪問原型

開頭講到,如果某個對象A,它的 [[prototype]] 屬性值是對象B的引用,就稱B成爲了A的原型,A的這個 [[prototype]] 屬性是隱式的(JS裏應該稱爲非公有屬性),也就是說不能通過.點操作符去訪問它,它是JS底層機制。爲了靈活性,JS標準依然提供了兩個api去訪問它(setter和getter)-- getPrototypeOf、setPrototypeOf,Object.getPrototypeOf(A) 返回對象A的原型Object.setPrototypeOf(A,B) 把B設置成A的原型
從這兩個api可以看出,A的原型不是生來就唯一確定的,可以中途修改,所以對象和它原型的關係完全不是“類和實例的關係”,如果用類和實例的思維先入爲主的理解話,很多現象解釋不通。比如,我定義了一個Bird()函數用來創建一隻鳥,語句let myBird = new Bird()執行後,如果認爲myBird是Bird的實例好像一點都不違和,好像Bird就是myBird的構造函數,原型就約等於類。但事實是myBird這隻鳥的原型是可以改的,let frog = {}; Object.setPrototypeOf(myBird,frog),frog(青蛙)成了這隻鳥的原型,這個時候你還能說原型是類嗎?顯然是解釋不通的。這種“死衚衕”的現象有很多,造成混淆的一個因素是new操作符,它和java裏面的new可不是一回事,後面會解釋。所以理解JS原型鏈的第一步是摒棄“類”、“實例”、“構造”這些思想。

我認爲“貸款人--擔保人”是比較合適的例子去形容原型關係:A去銀行辦貸款,銀行要求A填寫一個擔保人B,如果A沒錢還款,銀行就找擔保人還款。B就可以稱爲A的原型。這裏A可以指定父母、朋友,甚至是一家公司(和A不是一個數據類型)作爲自己的擔保人,A也可以隨時找銀行申請重新指定擔保人。這裏“銀行找A還款無果再找B還款”的動作就類似於訪問A的屬性,不存在的話則沿着原型鏈往上找,找到則返回,一直找到原型鏈盡頭(和麪向對象中訪問子類屬性找不到後訪問父類、祖父類的現象很像)。這個動作在JS被稱爲委託(本篇不準備展開講委託)

2.2、JS對象的 _ _ proto _ _屬性

_ _ proto _ _屬性想必大家非常熟悉,非JS標準,是各大瀏覽器爲了方便程序員訪問對象的原型而提供的顯式屬性,可以這樣方便的訪問對象A的原型:A. _ _ proto _ _,當然 _ _ proto _ _屬性也是可以修改的,A. _ _proto _ _ = B是有效的。這個屬性也許會成爲你理解原型鏈的絆腳石,到此爲止你完全可以認爲 _ _ proto _ _ 完全等效於 [[prototype]] 屬性,從目前來看他倆意義是一樣的,都是表示原型。但是下文將不再出現 _ _ proto _ _ ,僅僅用[[prototype]] 表示原型,一是希望大家將原型當做是JS對象間產生聯繫的機制,而不是一個屬性,二是儘管各家瀏覽器都“不約而同”的使用了 _ _ proto _ _ 顯式的表示原型,但是最終JS標準組也沒將它納入標準中,肯定有個中原因的。
(不約而同實際都是瀏覽器廠商向市場的妥協,帶頭大哥chrome用了 _ _ proto _ _ ,程序員寫的網頁在chrome上跑得風生水起,在你xxx瀏覽器就報錯,用戶只會說xxx瀏覽器真垃圾,哪會說你chrome不按標準來呢,所以xxx瀏覽器只能跟上大哥腳步)。

2.3、JS函數的 prototype 屬性

先說結論:JS函數的prototype屬性命名是極爲失敗的,隨便換個名稱都能讓JS原型鏈更容易理解,它不表示函數的原型
無疑,JS函數的prototype 是原型鏈非常重要的一環。它是JS專門爲函數賦予的一個屬性,並且是顯式屬性,它默認也是指向另一個對象。前面講過,每個對象都有 [[prototype]] 屬性(原型),並且只有 [[prototype]] 屬性才表示原型。函數也是對象,所以它既有原型又有prototype屬性,所以千萬別被prototype這個名字給騙了,它不表示函數的原型,包括函數在內的所有對象的原型有且只用[[prototype]] 屬性表示
許多博客解釋原型鏈喜歡用這個例子:

let Foo = function(){}
let myFoo = new Foo()

雖然,new是關鍵,但我不打算管new操作,希望大家先看看let Foo = function(){}的時候發生了什麼,然後引出我的觀點。分析下面的代碼:

let Foo = function(){}
console.log(Foo.prototype) //{constructor: ƒ},是一個帶有constructor屬性的對象
Object.getPrototypeOf(Foo) === Foo.prototype //false
Object.getPrototypeOf(Foo) === Function.prototype //true

從上面的代碼可以看出,我聲明瞭一個name爲Foo的函數,Foo便自動獲得prototype屬性,並且指向了一個包含constructor屬性的全新對象(constructor屬性後面再討論)。再看一下,Object.getPrototypeOf(Foo) === Function.prototype 返回true說明JS系統指定Function.prototype這個對象成爲了Foo的原型,Function是JS內置的函數,常常被當做所謂的“構造函數”使用(new Function()),也就是說Foo與系統中某些已經存在的對象產生了聯繫,一種稱爲原型的聯繫。我聲明瞭個函數,系統就指派Function.prototype作爲它的原型,顯然是根據數據類型去指派的,如果聲明一個字符串,那麼原型將被指派爲String.prototype,JS數據類型就那麼幾個,他們的原型也是能枚舉出來的,我稱它爲固有原型,下一章就做這個事。

再回頭說說constructor屬性,constructor屬性也是JS命名的一大敗筆,它妄想用“構造函數/構造器”的語義讓程序員以爲它是構造函數,但它卻根本沒有“構造”的含義在裏面,是constructor這個名字誤導了我們。前面說到,let myBird = new Bird()讓程序員認爲Bird是一個類的原因是new關鍵字,那麼到目前爲止我們又遇到一個因素了,這個constructor變本加厲的誤導程序員。constructor屬性所指向的函數對象是和原型一樣,是可以更改的。
分析下面代碼:

let Bird= function(){}
let Frog= function(){}
let myBird = new Bird()
myBird.constructor === Bird //true
myBird.constructor = Frog //代碼生效,構造函數重新指向到Frog
myBird.constructor === Bird //false

如果你把constructor 當做構造函數去看待,那表示我很輕易的將小鳥的構造函數改成了青蛙,以後小鳥就是由青蛙構造而來的(WTF)含義上根本說不通。另外,你會發現constructor 到目前爲止幾乎沒有其他用武之地,它純粹是湊數的屬性,所以在你徹底理解原型鏈之前,請放下這個屬性,不要糾結。

2.5、固有原型鏈

首先我認爲:每一個對象都不是孤立的,每一個對象誕生之初就已經身在一條原型鏈之中,只是根據自己的類型不同,身處的位置就不一樣,因此程序員每一次的聲明、賦值都意義重大,實際是解鏈再建鏈的過程。如果你按字面量給對象賦值時,那麼就表示讓系統給你分配原型,這個系統分配的原型就可以稱爲固有原型,如果你想自己指定原型就要用Object.create或者new操作符自行指定。先說一下讓系統分配原型的情況:

2.5.1、系統默認分配原型的情況

1、Number類型—— let obj0 = 1024
2、String類型—— let obj1 = 'hello'
3、Boolean類型—— let obj2 = true
4、Symbol類型—— let obj3 = Symbol('id')
5、Function類型—— let obj4 = function ()
6、Object類型—— let obj5 = {name:'張三'}
7、Array類型—— let obj6 = [1,2,3]
以上7種方式創建的對象我們在控制檯打印出來,它們的原型是:

let obj0 = 1024
let obj1 = 'hello'
let obj2 = true
let obj3 = Symbol('id')
let obj4 = function (){}
let obj5 = {name:'張三'} 
let obj6 = [1,2,3]
Object.getPrototypeOf(obj0) === Number.prototype  //true
Object.getPrototypeOf(obj1) === String.prototype  //true
Object.getPrototypeOf(obj2) === Boolean.prototype  //true
Object.getPrototypeOf(obj3) === Symbol.prototype  //true
Object.getPrototypeOf(obj4) === Function.prototype  //true
Object.getPrototypeOf(obj5) === Object.prototype  //true
Object.getPrototypeOf(obj6) === Array.prototype  //true

拿let obj0 = 1024舉例,可以看到,聲明obj0 並直接賦初值1024時,JS系統就默認指定了obj0的原型爲Number.prototype(嚴格上應該說成obj0的原型和Number的prototype屬性指向了同一個對象,因爲總不能拿內存地址來解釋,只能暫時把這個對象就稱爲Number.prototype)。前面說到,JS只有函數纔有prototype屬性,所以Number是個函數,並且Number.prototype是個object

typeof Number === 'function' //true
typeof Number.prototype === 'object' //true

那麼Number和Number.prototype也都有各自的原型。這裏直接給出結果:


Number.prototype的原型是Object.prototype,Number的原型是Function.prototype,Function.prototype的原型也是Object.prototype,再往上游Object.prototype的原型就是null了,意味着鏈到頭兒了。其他類型的對象的原型和Number類型對象如出一轍,用一張總圖表示:


1、 Number.prototype > Object.prototype > null,這樣的一條原型鏈便是固有原型鏈,每一個變量對象的誕生,都伴隨着一條鏈的誕生。任何一個對象一定處在一條原型鏈的其中一環。
2、前面講到,可以用setPrototypeOf這個api隨意設置對象的原型,固有的原型鏈也可以改,但是99.99%的情況下,你不會去改它的。MDN上說改變一個對象的原型,開銷比較大,更合適的做法是用Object.create創造一個新的原型鏈。
3、前面講到Number、String、Boolean、Symbol、Function、Object、Array這些都是函數,所以它們各自的原型都是Function.prototype。
4、大部分情況下,typeof爲object的對象,它的原型是Object.prototype,只是數組有些區別,JS的怪異行爲(缺陷)導致typeof [1,2,3] 等於object,而數組對象的原型是Array.prototype,而不是Object.prototype。
5、null是所有原型鏈的盡頭。null和undefined本身語義就表示“空的”、“沒定義”,因此沒有原型。嘗試用getPrototypeOf(null)、getPrototypeOf(undefined)會報語法錯。
6、雖說setPrototypeOf可以隨意設置,但是類型必須是object類型的對象、函數或者null)。嘗試設置setPrototypeOf({},1024)會報語法錯。
7、前面講到,嘗試訪問對象A的屬性,不存在的話則沿着原型鏈往上找,找到則返回,一直找到原型鏈盡頭。所以系統內置的這幾個固有原型,上面承載了很多通用api。比如:
number類型對象的原型Number.prototype,就被JS內置了toFixed、toPrecision等api,

let obj0 = 1024
obj0.toFixed(2)  //1024.00,精確到小數點後兩位

就是obj0對象去自己原型上借來的toFixed函數使用。


2.6、自行指定原型

可以通過Object.create和new操作符,在賦值時自行指定原型。

1、new操作符方式

再來看看前面的示例代碼:

let Foo = function(){}
let myFoo = new Foo()

我們知道了Foo是函數類型的對象,所以它有Foo.prototype屬性,並且Foo的原型是Function.prototype。new操作符會執行Foo函數,並且創造一個object類型的空對象,並返回,同時把這個空對象的原型指定爲Foo.prototype。所以myFoo是一個空的對象,它的原型是Foo.prototype。
1、new操作符會自動把返回的對象的原型指定爲Foo的prototype。

let Foo = function(){}
let myFoo = new Foo()
Object.getPrototypeOf(myFoo) === Foo.prototype //true

2、myFoo是空對象這一點很重要,常常被忽略,(除非Foo執行完有確實返回了對象)。myFoo之所以能訪問到Foo上的屬性,完全是通過原型機制實現的,這和傳統面向對象的實例化概念是完全不一樣的,傳統面向對象類定義的屬性,在類實例化時,實例就實實在在的擁有了這個屬性。
3、無論Foo執行完返回的類型是什麼,經過new後都會變爲object類型。

let Foo = function(){ 
    return 1024
 }
let myFoo = new Foo()
typeof myFoo //object

4、既然new會把myFoo的原型指定爲Foo的prototype,而函數纔有prototype屬性,所以new只能作用函數對象,作用於其他類型的對象會報語法錯。

let Foo = {name:'張三'}
let myFoo = new Foo() //報Foo is not a constructor

所以到這裏就能看清new的面目了,和構造實例一點兒關係都沒有。再看看和new作用差不多的Object.create。

2、Object.create方式
let Foo = function(){}
let myFoo = Object.create(Foo)

1、Object.create(Foo)執行的結果是創造一個空的對象,並返回,同時將返回的對象的原型指定爲Foo本身

let Foo = function(){}
let myFoo = Object.create(Foo)
Object.getPrototypeOf(myFoo) === Foo //true

2、Object.create(Foo)和new操作符不同,不必要求Foo是函數。但是因爲Foo要作爲別人的原型,所以Foo就要滿足原型的類型限制(object對象、函數或者null),否則會報語法錯。
3、如果Foo是函數,new操作符會執行一次Foo,而Object.create不會執行Foo,因爲它的本質只是要取Foo本身作爲它的原型而已,不需要Foo執行。因爲Foo不會執行,所以Foo函數返回任何都不會影響到myFoo,所以無論Foo是函數還是普通對象,Object.create(Foo)返回的永遠是空對象

let Foo =  { age:24 }
let myFoo = Object.create(Foo)
myFoo.age //24
myFoo.hasOwnProperty('age')  //false

let Foo = function(){
  console.log('Foo執行')
  return { age:24 }
}
let myFoo = Object.create(Foo)  //Foo沒執行
myFoo.age //undefined
myFoo.hasOwnProperty('age')  //false

這點非常重要,myFoo能訪問Foo的屬性完全是因爲Foo是myFoo的原型。一步小心就會造成下面這種情況:

let Foo =  { age:24 }
let myFoo = Object.create(Foo)
let myFoo1 = Object.create(Foo)
myFoo.age = 25
myFoo1.age //25

myFoo通過=賦值改變了原型鏈上的age屬性值,導致污染全局。

3、instanceof操作符

解釋了new操作符和構造實例沒關係,還有一個和實例有關係的操作符——instanceof,它和實例也一點關係都沒有。myFoo instanceof Foo 回答的不是myFoo是否是Foo的一個實例,而是回答Foo.prototype是否是myFoo原型鏈上的一環,看兩個例子:

let Foo = function(){ }
let myFoo = new Foo()
Object.getPrototypeOf(myFoo) === Foo.prototype //true
myFoo instanceof Foo  //true,顯然Foo.prototype就是myFoo的原型(第一環)。所以返回true。

接着這個例子,我們創造一個新的原型鏈:

let a0 = {age:24}
let a1 = Object.create(a0)
let a2 = Objece.create(a1)

經過前文Object.create的作用和固有原型鏈的介紹,我們知道,JS爲我們創建了這麼一條原型鏈
a2 > a1 > a0 > Object.prototype > null
這時執行:

Object.setPrototypeOf(myFoo,a2)  //把myFoo的原型指定爲a2

那麼myFoo的原型鏈就是:
myFoo > a2 > a1 > a0 > Object.prototype > null
再執行:

Foo.prototype = a0 //Foo.prototype屬性重新賦值
myFoo instanceof Foo //true

Foo.prototype重新指向了a0,a0位於myFoo的原型鏈上,所以myFoo instanceof Foo返回true。同理把Foo.prototype指向a2、a1、a0甚至Object.prototype,myFoo instanceof Foo都會返回true。所以instanceof 雖然有“實例”的語義,卻和實例沒有關係,本質還是原型鏈機制。

ES6有提出一個isPrototypeOf的api,它的作用和instanceof很像,但也有點誤導人,Foo.isPropertyOf(myFoo)語義上好像是回答Foo是myFoo的原型嗎?但是實際是回答Foo是myFoo原型鏈上的一環嗎?所以上面a1.isPrototypeOf(myFoo)a2.isPrototypeOf(myFoo)都會返回true。

總結:

1、JS的原型鏈機制和類-實例機制完全不同,有相似之處,但不要用傳統面向對象的思想去理解JS代碼,遇到10個場景可能有9個都能解釋得通,但總有那麼1個你解釋不了,比如前文講到的“鳥由青蛙構造而來”一樣。

2、JS只有[[prototype]]這隱式屬性才叫做原型,prototype是函數的一個屬性,專屬於函數,這個屬性和原型大有關係。反之,有prototype屬性的對象一定是函數。

3、JS兩個對象之間通過原型鏈產生聯繫,原型鏈上的對象之間不會發生複製,儘管你是我的原型,但你還是你,我還是我,我不會複製你身上的屬性到我身上,我只是想辦法引用你。

4、每個對象誕生之時就已經處於一條原型鏈中,如果不指定就由JS自動分配原型,JS根據賦值時數據類型來分配到具體的鏈上,稱爲固有原型鏈。原型鏈上可以定義通用的api。一旦發生賦值操作,如果賦值前後的類型不一致,就會發生斷鏈和接新鏈的操作,這種操作是有開銷的,所以儘量保持變量類型一致。

5、拋棄了“構造”、“實例”,忘掉了_ _ proto _ _、constructor纔能有助於理解原型。

6、new操作符和構造實例一點兒關係都沒有,它的功能是創造新的原型鏈,和它功能很相似的是Object.create,但是兩者有很多區別。

7、instanceof 也和實例沒有關係,myFoo instanceof Foo 回答的不是myFoo是否是Foo的一個實例,而是回答Foo.prototype是否是myFoo原型鏈上的一環。

8、JS標準小組努力把自己往傳統面向對象上靠,包括ES6出的class,但是太倉促了,有時候某些機制簡直匪夷所思。

舉個匪夷所思的例子,看下面代碼:

let obj = {age:24}
Object.defineProperty(obj,'age',{
  writable:false,
  configurable:false,
  enumerable:true,
})
obj.age = 25  //writable爲false,只讀,所以賦值不生效

let newObj = Object.create(obj)
newObj.hasOwnProperty('age') //false。前文分析過,obj1是空對象,沒有age屬性
newObj.age = 26
newObj.hasOwnProperty('age') //false,爲什麼還是false?

Object.defineProperty是用來設置對象屬性的描述符,將obj的age屬性的writable設置爲false(只讀),所以用=賦值25不生效,這個沒問題,但是用Object.create創建newObj後發現newObj .age = 26 竟然也沒有生效!!newObj依然沒有age這個屬性。這個很奇怪,newObj竟然被原型鏈對象內的同名屬性影響到了,貌似“繼承”了writable:false,但事實卻不是繼承,只是JS在刻意的模仿類屬性的繼承,這麼模仿的結果就是讓程序員匪夷所思,不知道改怎麼辦!!
更令人費解的是,newObj用=賦值沒生效,但用Object.defineProperty賦值又可以。接上面的代碼:

Object.defineProperty(newObj,'age',{
  value:27,
  writable:false,
  configurable:false,
  enumerable:true,
})
newObj.hasOwnProperty('age') //true
newObj.age //27

用Object.defineProperty賦值27是生效的,並且把writable改成true也是生效的。
還有一點是newObj對象age屬性的configurable不管原型上設置的是什麼,都不會產生影響(不繼承,符合預期),有的會有繼承的現象發生,有的又不會,我太難了~~。
JS發展十多年來,不少設計缺陷令人抓狂,要修復bug,兼容是不可能了,沒有別的招,只能新推出嚴格模式,或者靠TpyeScript力挽狂瀾。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章