在介紹我們的主角之前,我們先來回顧一下經典的類和繼承的設計模式。
如果你對這塊內容比較熟悉的,可以直接跳過這篇,看(下)篇。
不過也可以看一遍,因為這塊會講得比較細,相信你會有新的收獲。
先上代碼
function Foo() {} Foo.prototype.print = function() { console.log('print !'); } var a = new Foo(); a.print(); // print !
JavaScript中每一個對象都有一個內置屬性[[Prototype]],它是不可枚舉的,在絕大多數瀏覽器中,可以通過__proto__來訪問這個內置屬性,這個內置屬性有什么用?當我們訪問一個對象的屬性的時候,會經歷哪些步驟?(這也是一道經典面試題)
完整的答案是:
1,會觸發[[Get]]操作;(這里補充一下,[[Get]]不是一個內置屬性,而是一個內置操作,更像是執行一個內置函數調用[[Get]](),有[[Get]]就有對應的[[Put]],[[Get]]是獲取屬性值的操作,[[Put]]是設置或創建屬性的操作,很多人會把它們和getter和setter搞混,這個后面會提到)
2,先檢查該對象是否有該屬性的訪問描述符getter,如果有,則返回getter返回的值,否則繼續;
3,在對象中查找是否有名稱相同的屬性,如果有則返回這個屬性值,否則繼續;
4,如果對象中沒有,則會繼續訪問該對象的[[Prototype]]鏈,也就是我們常說的原型鏈,一層一層查找,找到則返回,否則繼續;
5,[[Prototype]]鏈的最底層是Object.prototype,找到這一層就不會再繼續了,如果還是沒有該屬性,則返回undefined,查找結束!
這里提一下第二點,比較容易被忽視:
function A() { this.name = 'A'; } var a = new A(); ...... a.name = 'a'; console.log(a.name);
這段代碼很多人都知道會打印a,而不是A,原因是發生了遮蔽效應。
但是思考一下,上面這段代碼一定會打印'a'嗎,不一定:
function A() { this.name = 'A' } var a = new A(); Object.defineProperty(a, 'name', { get: function() { return 'b' } }); a.name = 'a'; console.log(a.name); // b
無論給a.name賦值多少,獲取出來都是b,因為屬性檢索會優先考慮getter。
扯得有點遠了。。。。
我們再來看這段代碼,function A() {},很多人誤以為這個A是一個類,因為看到了new關鍵字,所以有點類似于執行了類的構造函數,這里的new在調用的同時會默認執行以下三步:
1,綁定this;
2,如果“構造函數”里沒有返回一個對象,那么則自動返回一個對象;
3,將實例的[[Prototype]]指向該“構造函數”的原型;
前兩個就不說了,重點在第三個,由上面的代碼我們得到:
a.__proto__ === A.prototype; // true
關于JavaScript中的“構造函數”,還有一個特性:
每一個函數在創建的時候,會自動創建一個原型(prototype)屬性,它會自動指向該函數的原型對象,而這個原型對象會自動獲得一個constructor(內置且不可枚舉)屬性,又指向這個函數自身。而該函數生成的實例也會有constructor屬性,指向該函數自身。
添加圖片注釋,不超過 140 字(可選)
A.prototype.constructor === A; // true a.constructor === A; // true
constructor是什么?很多人認為constructor就是它的字面意思,構造器屬性,指向構造它的函數,也就是我們上面提到的構造函數,那我們來驗證一下:
function A() {} A.prototype = {}; var a = new A(); a.constructor === A; // false a.constructor === Object; // true
如果constructor是構造器屬性,那么由構造函數A創建的實例a的構造器屬性應該指向A才是,但是現在指向了一個看似和它沒關系的Object,那顯然這個是一個錯誤的觀點!
constructor并非指向構造函數
那為什么實例的constructor會指向構造函數A呢,為什么重寫了A.prototype之后,指向會變呢?我們再來看重寫prototype的代碼:
A.prototype.constructor === A; // false A.prototype.constructor === Object; // true
我們發現,A.prototype的constructor也指向了Object,那我們知道了,回顧一下之前提到的,訪問一個對象的屬性的時候會經過的步驟,我們發現,最后一步會去該對象的原型鏈上去找,a本身是沒有這個constructor屬性的,但是a.__proto__,也就是A.prototype上有,A.prototype的constructor是指向A自身的,所以a.constructor也會指向A,當我們重寫了A.prototype的時候,它原來擁有的constructor屬性就沒有了,更不存在指向A了,所以a.constructor一定不指向A!
那為什么指向Object,那既然這一層原型鏈上沒有找到,就會繼續去上一層,上一層就是Object.prototype,它是有這個constructor屬性的,并且指向自身Object,找到了!
添加圖片注釋,不超過 140 字(可選)
因此a.constructor的指向就是Object!
我們再來思考一個問題:
function A() {} var a = new A(); A.prototype = {}; var b = new A(); A.prototype.constructor === A; // false b.constructor === A; // false a.constructor === A; // true a.__proto__ === A.prototype; // false a.__proto__.constructor === A; // true
上面兩行就不說了,已經講過了為什么,但是為什么a.constructor還是指向A呢? a.__proto__已經不是A的原型了,為什么還有一個constructor屬性指向A呢?
有點暈了,別著急,一個一個看:
a.constructor指向誰,實際上就是看a.__proto__.constructor指向誰,為了解釋這一塊細節,先來看一段代碼:
function f(x) { x.push(1); x = []; x.push(2); } var a = []; f(a); a;
先思考一下,上面的a是多少?
答案是1,為什么不是2,明明傳入f函數的是一個引用類型的值,一個空數組對象,函數體的參數實際上進行了引用復制,a復制給了x,復制的是引用。
關鍵就是x = [];這里強行修改了x的引用地址,此時的x不再是指向原先x指向的那個值了,關鍵點來了:
一個引用無法更改另一個引用的指向!
這個很關鍵,雖然x指向變了,但是原先的x指向依然還在,也就是說原先a的指向也還在,也許此時函數體內最終的x改變了值,但是我們不關心這個,我們關心的是當初復制引用給它的a。
我懂了。
回到我們上面思考的問題,關鍵點在于A.prototype = {}; 此時A.prototype的指向變了,但是原先A.prototype的指向沒變,所以先前創建的a.__proto__的指向也沒變,它依然是指向原來指向的地方,只不過現在那個地方已經不再是A.prototype[old]了,準確的說,其實還是A.prototype,只不過當我們再次訪問A.prototype的時候,實際上已經被重寫了,因此訪問的是一個全新的A.prototype[new],所以之前的那個A.prototype[old].constructor還是指向A,因為我們只是重寫了A.prototype,而沒有重寫A,所以A還是一樣的引用地址,因此a.__proto__.constructor === A成立!!!
而后面新生成的實例b或者c或者更多,對于他們而言,就不存在什么A.prototype[old] 或[new]了,就一個[new],引用地址就一個,所以都是新的,因此不需要這么糾結!
現在很多檢測工具,像eslint這種,對于構造函數名非大寫時會報一個error,所以很多人誤以為JavaScript中有類這一概念,也有構造函數這一概念,實則是沒有的,對于引擎來說,調用構造函數和調用普通函數沒什么區別,而特殊是在搭配new關鍵字之后,它就由一個普通函數調用變成了一個構造函數調用(也就有了上面說的那三步)。
好了,現在清楚了,雖然JavaScript中沒有類的概念,但是人們卻極力去模仿類的繼承,原因是[[Prototype]]這個內置屬性的存在可以很輕松地實現類的繼承設計模式。
我們來看一下最經典的原型繼承的風格代碼:
function Foo(name) { this.name = name; } Foo.prototype.sayName = function() { console.log(this.name) } function Bar(name, age) { Foo.call(this, name); this.age = age; } Bar.prototype = Object.create(Foo.prototype); Bar.prototype.sayAge = function() { console.log(this.age); } var a = new Bar('Yan', 24); a.sayName(); // Yan a.sayAge(); // 24
這里的Object.create的polyfill代碼就是:
if (!Object.create) { Object.create = function(o) { function F() {} F.prototype = o; return new F() } }
es6也有一個方法,實現了同Object.create的方法,叫Object.setPrototypeof(),上面的代碼等同于:
Object.setPrototypeof(Bar.prototype, Foo.prototype);
但是Object.setPrototypeof相比于Object.create有一個優勢,就是它會自動綁定constructor的指向,因為重寫了子類的prototype之后,constructor指向會變掉,所以需要我們手動“指正”,但是Object.setPrototypeof會自動糾正這一問題,所以建議使用這個方法。
我們再來回顧一下之前說的[[Prototype]]屬性,我們通過一個對象的[[Prototype]]屬性,能訪問到它的構造函數的原型上的屬性或方法,并不是因為我們在創建這個對象實例的時候會復制一遍原型上所有的屬性和方法,而是用一種委托行為,建立起一個關系,而[[Prototype]]的機制就是指對象中的一個內部鏈接引用另一個對象,這兩個對象之間的關系,就是委托。
本篇最后,我們做一個小結:
JavaScript中沒有類的概念,也沒有構造函數的概念,雖然有new關鍵字的構造函數調用,很像傳統面向類語言中的類初始化和類繼承,但是其實并不是這樣,它只是用一種委托的行為來建立兩個對象之間的聯系。既然沒有類的概念,那這種極力去模仿類繼承的設計模式會存在一些問題,既然我們有委托這么一個行為,那我們應該針對委托這一特性,來設計一種JavaScript獨有的模式,(下)篇中我們會具體細講這種面向委托的設計思想以及實現方式。
補充一個很有意思的點,無意間發現的:
任何一個對象(包括函數)都有[[Prototype]]內置屬性,除了兩個特殊的(一個是null,另一個是Object.create(null)),那么像一些最頂層的構造函數,比如Function,String,Number,Boolean,Object這些,他們的[[Prototype]]指向哪里呢?這個我們可以在控制臺打印出來看下,但是在打印前可以思考一下,而不是立馬去看答案
添加圖片注釋,不超過 140 字(可選)
不過記住一點,如果一個對象有[[Prototype]]內置屬性,那么它最后一層一定是Object.prototype,再后面就是null了。
浙公網安備 33010602011771號