天天看點

深入了解JavaScript的原型鍊——關于prototype,__proto__,constructor那些你可能沒有徹底搞懂的關系

說起原型鍊,戳進來看這篇部落格的同學必然不會陌生,這是JavaScript中最核心的特性之一。那麼,原型鍊到底是一個什麼東西,它的工作原理是什麼?這是本篇文章探讨的内容

對于原型鍊,我們可以這麼了解,每一個JavaScript對象中,都存在一個内部屬性,我們稱之為[[prototype]](注意,此處的[[prototype]]屬性并不是prototype屬性,對于prototype屬性,我們之後會提到),這個内部屬性會指向另一個對象,而另一個對象也存在這樣的一個屬性,最終,在Object.propotype這個對象上面終結,至此,形成了我們所謂的原型鍊

原型鍊有這樣一個特性:當通路目前對象的某個屬性或者方法時,如果在目前對象通路不到,會沿着原型鍊一直往上走,直到找到對應的屬性,找不到,則傳回undefined。當我們對一個對象的屬性進行寫操作時,如果這個屬性在原型鍊的高層存在,此時不會影響到高層的屬性,隻會作用在目前的對象上,這個特性,我們稱之為遮蔽。例如下面的代碼

var parent={
    name:1
};
var son=Object.create(parent);
console.log(son.name);//輸出1
son.name=2;
console.log(son.name);//輸出2
console.log(parent.name);//輸出1           

如上所示,Object.create建構了一個son對象,這個對象的[[prototype]]屬性會指向parent,此時,雖然son并沒有name屬性,但是我們卻能夠擷取得到son.name的值,而當我們修改son的name屬性時,則會直接作用在son上面,形成一個遮蔽屬性,并不會對parent産生影響

但問題在于,大多數人對于原型鍊的了解,就僅停留于此,并認為對于一個屬性,如果這個屬性已經存在于[[prototype]]鍊的高層的,那麼對它進行指派,将總是造成遮蔽,但事實上,這是錯誤的,這裡面還有很多細節需要我們細細的品味

關于遮蔽

對于對象的遮蔽,事實上它存在以下三種情況

1、如果一個名為name的資料通路屬性在[[prototype]]鍊的高層某處被找到,并且沒有被标記為隻讀,那麼對于名為name的屬性的寫操作将直接作用在目前對象上,并形成一個遮蔽屬性。上面第一段代碼舉得例子可以印證我們這個結論

2、如果一個名為name的屬性在[[prototype]]鍊的高層某處被找到,但它被标記為隻讀(writeable:false),那麼設定屬性name或者在目前對象上建立遮蔽屬性,都是不被允許的,我們可以看下面的例子

var parent={
    name:1
};
Object.defineProperty(parent,'name', {writable: false});

var son=Object.create(parent);
son.name=2;
console.log(son.name);//非嚴格模式下輸出1
console.log(parent.name);//非嚴格模式下輸出1           

通過上面的代碼我們發現,對son對象的name屬性進行寫操作時,被無聲的忽略了,不單單是parent的name無法被寫入,甚至無法被遮蔽。事實上,如果在strict模式下,執行son.name=2時,會直接報錯。是以不管在什麼情況下,當[[prototype]]鍊的高層屬性被設定為不可寫狀态時,遮蔽效果消失

3、如果一個名為name的屬性在[[prototype]]鍊的高層被找到,但是它是一個setter,那麼這個setter總是會被調用,沒有新的name屬性會被添加到目前對象上,也就是說,此時遮蔽效果也不會出現,我們來看下面的例子

var parent = {
    _name:'test'
};

Object.defineProperty(parent, 'name', {
    get: function () {
        return this._name
    }, set: function (value) {
        this._name+=value//傳入的值與this._name相加
    }
});

var son = Object.create(parent);
son.name = '2';
console.log(son.name);//輸出'test2'
console.log(parent.name);//輸出'test'
console.log(son.hasOwnProperty('name'));//false           

如果我們這個時候不調用hasOwnProperty驗證一下的話,可能會以為name屬性已經産生了遮蔽,但事實上并沒有,之是以最終name的值有差異,是因為setter總是被調用,而this的指向不同罷了,這裡需要注意的是,雖然name屬性沒有發生遮蔽,但是_name屬性是發生了遮蔽的,這是産生差異的原因

關于原型鍊的擷取

上文說到,原型鍊的基礎是一個存在于對象内部的屬性,我們稱之為[[prototype]],既然是内部屬性,那麼意味着我們無法直接擷取到它。但是es5提供了一個标準方法用來擷取[[prototype]],正确的姿勢如下

Object.getPrototypeOf(son)           

鑒于[[prototype]]并不是隻讀的,是以我們也可以通過下面的方法來改變原型鍊的指向,但這是強烈不建議使用的。事實上我們一般不應該改變一個既存對象的[[prototype]]

Object.setPrototypeOf(son,parent2);           

到了es6,es6将之前部分浏覽器支援的__proto__屬性标準化,現在在es6中我們可以直接用son.__proto__來通路[[prototype]],請看下面的代碼,執行之後控制台輸出的是true

console.log(Object.getPrototypeOf(son)==son.__proto__)//true           

關于prototype屬性

首先再次說明一下,prototype屬性不等于[[prototype]],prototype屬性僅存在于函數中,而[[prototype]]存在于所有的對象中,盡管二者偶爾會指向同一個地方,但卻不是同一個東西。

MDN上有這麼一段解釋:

每個執行個體對象( object )都有一個私有屬性(稱之為 __proto__ )指向它的構造函數的原型對象(prototype )。該原型對象也有一個自己的原型對象( __proto__ ) ,層層向上直到一個對象的原型對象為 

null

。根據定義,

null

 沒有原型,并作為這個原型鍊中的最後一個環節。幾乎所有 JavaScript 中的對象都是位于原型鍊頂端的Object的執行個體。

我們可以這麼了解這段話,所有對象的[[prototype]]都指向它的構造函數的prototype,而構造函數的prototype也是一個對象,也有原型鍊,因為所有的對象都是作為Object的執行個體由Object構造出來的,是以所有的原型鍊的實際上都會指向Object.prototype,最終,Object.prototype的[[prototype]]會指向null,最終結束。

是以我們得出本篇的第一個結論:函數的prototype屬性可以了解為函數執行個體的公有屬性,每一個函數(稱之為Foo吧)被new之後,其執行個體的原型鍊都會指向Foo.prototype。并且,prototype僅存在于函數中,在普通對象中,并不存在prototype屬性

穿插一點科普

在這裡,我們順便科普一下,所有的對象,都是Object函數的執行個體,這個很容易了解,從建立一個對象的方式就可以看出來

var myObj=new Object();           

而所有的函數,則都是Function函數的執行個體,我們平時聲明函數是這個樣子的

function test(){
alert('test');
}           

但事實上我們可以寫成這個樣子

var test=new Function('alert("test")');           

兩種寫法的效果是一樣的,但是,這麼寫隻是為了解釋為什麼所有的函數都是Function的執行個體,實際開發過程中也是強烈不建議這麼做,會造成維護方面的困難。

那麼這就解釋了為什麼所有的對象最終都會指向Object.prototype。而Object本身也是一個函數,是以Object.__proto__事實上最終也指向了Function.prototype,但是Function.prototype本身又是一個對象,是以Function.prototype的原型鍊,也就是[[prototype]]最終也指向了Object.prototype,并在這裡終結,指向了null,嗯,雖然有點亂,但是記住上文的結論,一步一步推導,也就不會亂了

關于constructor

接着,我們來看一下下面這段代碼

function MyConstructor(){
    this.test=1;
}
var myObj=new MyConstructor();
console.log(myObj.constructor.name);//MyConstructor           

從直覺來看,執行個體myObj存在存在了一個constructor屬性,這個屬性指向了它的構造函數,然而,打臉依舊來得如此之快,這是錯的,了解一下以下兩個輸出

console.log(myObj.hasOwnProperty('constructor'));//false
console.log(MyConstructor.prototype.constructor==myObj.constructor);//true           

我們發現,constructor事實上是存在于MyConstructor.prototype上的,并且這個值指向了MyConstructor自身,myObj這個執行個體本身是沒有constructor這個屬性的,它是從原型鍊上繼承來的,這也是很多時候,我們替換掉構造函數的prototype時constructor會丢失的原因

這裡我們得出結論:constructor指向執行個體的構造函數,并且存在于構造函數的prototype上

關于instanceof

instanceof 關鍵字左邊接受一個普通對象,右邊接受一個函數,它經常用來檢查執行個體和構造函數的關系,例如

function MyConstructor(){
    this.test=1;
}
var myObj=new MyConstructor();
console.log(myObj instanceof MyConstructor);//true           

但事實上,對于上述代碼而言,instanceof所回答的問題是,在myobj的整個[[prototype]]鍊條中,是否出現過任何一個對象,指向了MyConstructor.prototype

我們看一下下面的代碼

var a={};
var b=Object.create(a);
function MyConstructor(){
    this.test=1;
}
MyConstructor.prototype=a;
console.log(b instanceof MyConstructor);//true           

并沒有構造函數與執行個體的關系,但instanceof确實傳回了true,是以,對于instanceof所做的事有正确的認識非常重要

總結

最後,附上一張其他部落格作者寫類似文章總結的圖,個人覺得總結得比較好,貼出來友善大家對這幾個概念的了解

深入了解JavaScript的原型鍊——關于prototype,__proto__,constructor那些你可能沒有徹底搞懂的關系

如果上面的内容都了解了的話,個人覺得這張圖應該不難看得懂,可能有幾個點會比較難了解

1、關于Foo的Constructor,上文我們提到,Function函數時所有的函數的構造函數,是以Foo的Constructor指向Function沒毛病,Foo.__proto__指向Function.prototype也就是理所當然的事

2、關于Function本身,還是由于Function是所有函數的構造函數,是以Function是它自己的構造函數,按照這麼了解,Function的constructor指向它自身沒毛病,根據原型鍊的規則,Function.__proto__也應該指向它的構造函數,也就是Function.prototype

3、關于Object,将它暫時了解為一個普通函數,則它的原型鍊和Constructor的指向和Foo的對比一下,其實非常清晰

參考資料:

《你不懂js:this與對象原型》

繼承與原型鍊

https://blog.csdn.net/cc18868876837/article/details/81211729

繼續閱讀