好好理解一下JavaScript中的原型
目錄
Table of Contents generated with DocToc
- 目錄
- 一、參考書籍和資料
- 二、原型,[[prototype]]和.prototype以及constructor
- 三、原型鏈
- for...in和in操作符
- 四、屬性設定和遮蔽
- 五、JavaScript只有物件
- 六、建構函式和new關鍵字
- 七、模仿類
- 八、對constructor的錯誤理解
- 九、原型繼承
- 十、類之間的關係
- 十一、總結
- 有問題就留言交流我很樂意
一、參考書籍和資料
翻看了幾本JS書籍,其中主要有以下幾本:《JavaScript高階程式設計第三版》、《你不知道的JavaScript卷一》、《JavaScript權威指南》以及查看了MDN文件。文章主要說了JavaScript中原型的一些概念知識,花了一點時間去總結,如任何問題的話可以提出來一起交流解決。文章中的圖大多是從網路和書中擷取下來,並非本人原創。
二、原型,[[prototype]]和.prototype以及constructor
結合書中的概念,原型是什麼這個問題,可以這樣去解釋:原型就是一個引用(也就是指標),指向原型物件。這並不是廢話,很多人說原型,實際上沒意識到它只是一個引用,指向原型物件。原型在例項物件和建構函式中有不同的名稱屬性,但總是指向原型物件。如圖所示:
- 其中的constructor是原型物件的屬性,引用的是物件關聯的函式,不可列舉,但這個屬性是可以修改的,因此不可靠。
- 在例項物件中,原型就是物件的
[[prototype]]
內建屬性(雙方括號代表這是JavaScript引擎內部使用的屬性/方法,正常JS程式碼無法訪問,但可以通過__proto__
- 在函式中,原型就是函式的
.prototype
屬性,在函式被建立時就包含該屬性,指向建構函式的原型物件 。
三、原型鏈
要理解原型鏈,首先需要明白原型物件的作用就是讓所有例項物件共享它的屬性和方法。根據上圖,不難發現,person1和person2中的內部屬性[[prototype]]都指向Person原型物件。當進行物件屬性查詢的時候,比如person1.name,首先會檢查物件本身是否有這個屬性,如果沒有就繼續去查詢該物件[[prototype]]指向的原型物件中是否有該屬性,如果還是沒有就繼續去找這個原型物件的[[prototype]]指向的原型物件(注意,原型物件也是有他自己的[[prototype]]屬性的)!這個過程會持續找到匹配的屬性名或查詢完整的原型鏈
Object.prototype
(因為所有物件都是源於Object.prototype,其中包含許多通用的功能方法)。顯然,如果找完這個原型鏈都找不到就會返回undefined。這個過程可以用一張圖描述:顯然,原型和原型鏈的作用就是:如果物件上沒有知道需要的屬性和方法引用,JS引擎就會繼續在[[prototype]]關聯的物件上進行查詢。這也是原型和原型鏈存在的意義。
for...in和in操作符
兩個跟原型鏈有關的操作
- for...in遍歷物件時,任何可以通過原型鏈訪問到的(並且是enumerable為true)屬性都會被列舉。
- in操作符用於檢測屬性在物件中是否存在,同樣是會查詢整條原型鏈。
function Person(name){
this.name = name;
}
Person.prototype.sayName = function() {
return this.name;
}
let myObject = new Person('練習生');
// 輸出兩個屬性:name和sayName,其中sayName是原型物件中的屬性
for(let key in myObject) {
console.log(key);
}
// 輸出true,表示不可列舉的constructor存在於myObject中。
// 事實上constructor是在Person.prototype物件中
console.log("constructor" in myObject);
四、屬性設定和遮蔽
給物件設定屬性並不僅僅是新增一個屬性或修改已有屬性。這個過程應該是這樣的:
// myObject的宣告在第一個程式碼塊
// 注意:sayName在Person.prototype中存在,將遮蔽原型鏈上的sayName方法
myObject.sayName = function() {
return `my name is:${this.name}`;
}
// 注意:age在myObject的整個原型鏈都不存在,將在例項中新建age屬性
myObject.age = 23;
// 完成上述對myObject屬性的設定,再新建一個物件
let myObject_1 = new Person('James');
// 查詢myObject的屬性和方法
myObject.age; //23
myObject.sayName(); // my name is: Bob
// 查詢myObject_1的屬性和方法
myObject.age; // undefined
myObject.sayName(); // 'Cat'
直接設定例項屬性,都會遮蔽原型鏈上的所有同名屬性(前提是屬性的writable為 true,並且屬性沒有setter),並有以下兩種情況:
- 當sayName屬性不直接存在物件中而存在於原型鏈上層時,將會在myObjet中直接新增sayName屬性,注意它只會阻止訪問原型鏈上層的sayName屬性,但不會修改按個屬性。
- 當原型鏈上找不到age,則age直接新增到myObject中。
五、JavaScript只有物件
在面嚮物件語言中,類是可以被例項化多次,就像使用模具製作東西一樣,對於每一個例項都會重複這個過程。但在JavaScript中,沒有類,沒有複製機制。只能建立多個物件,通過它們的內建[[prototype]]關聯同一個原型物件。預設情況下,它們是關聯的,並非複製,因為是同一個原型物件所以它們之間也不會完全失去聯絡。
比如說,new Person()生成一個物件,同時這個新物件的內建[[prototype]]關聯的是Person.prototype物件。這裡得到了兩個物件,它們之間僅僅互相關聯,並沒有初始化類,如圖所示:
這種機制也就是所謂的原型繼承。這種Person()函式不算是類,它只是利用了函式的prototype屬性“模仿類”而已!所以說,JavaScript沒有類只有物件。
六、建構函式和new關鍵字
文章第一個程式碼塊很容易讓人認為Person是一個建構函式,因為使用new呼叫並看到他構造了一個物件。但其實Person跟其他普通函式沒有什麼不同,函式本身不是建構函式,所有的一切只是在函式呼叫前加了new
關鍵字!這樣就會把這個函式呼叫變成一個“建構函式呼叫”。new會劫持所有普通函式並用構造物件的形式去呼叫它。下面這段程式碼可以證明這點:
function BaseFunction() {
console.log('Not a constructor!');
}
let myObject = new BaseFunction();
// Not a constructor.
typeof myObject; // object
BaseFunction是一個普通函式並非建構函式,但通過new呼叫,卻會構造出一個物件。因此,建構函式其實是所有帶new的函式呼叫。
七、模仿類
前面已經明確說過,JavaScript中只有物件,沒有真正的類,但JavaScript開發者通過下面兩種方法可以模擬類,如下程式碼所示:
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
}
let a = new Foo('a');
let b = new Foo('b');
a.myName(); // a
b.myName(); // b
- this.name = name 給每一個new呼叫構造出來的物件都添加了.name屬性(this綁定當前物件),這有點類似面向物件中“類例項封裝的資料值”。
- Foo.prototype.myName = ...,給原型物件新增方法,那麼通過該建構函式呼叫建立的例項就能共享原型物件的方法和屬性。因此,a.myName和b.myName都可以正常工作,這有點類似面向物件中的什麼?這點我還不知道,反正就是面向物件設計模式的一種。有知道的可以留言告訴我。
八、對constructor的錯誤理解
接上面的程式碼所示,如果繼續執行a.constructor === Foo,返回的是true,因此有這種錯誤觀點:物件由Foo構造。現在是時候把這個錯誤觀點改過來了。constructor是存在於Foo.prototype中,a物件只是[[prototype]]委託找到constructor!這和構造毫無關係,下面程式碼可以證明這一點:
function Foo(){}
//將Foo的原型物件指向一個空物件
Foo.prototype = {};
let a = new Foo();
a.constructor === Foo; //false
a.constructor === Object; // true
嗯哼?現在你還敢說constructor表示a由Foo構建嗎?按照這種錯誤觀點,a.constructor === Foo應該返回true!其實constructor在只是建立函式時一個預設屬性,指向prototype屬性所在的函式。constructor屬性時可以被修改的,讓原型物件指向新的物件的時候,為了讓constructor指向之前的函式,可以手動使用defineProperty方法新增一個不可列舉constructor屬性。但真的很麻煩,總而言之不要太信任constructor屬性!
九、原型繼承
從這張圖,可看出三點
- a1/a2到Foo.prototype,b1/b2到Bar.prototype的委託關聯
- Bar.Prototype到Foo.prototype的委託關聯
- 箭頭由下到上表明這是委託關聯而不是複製操作,否則如果是複製操作箭頭應該回事由上往下。
下面這段程式碼是典型的原型繼承風格
function Foo(name){
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
}
function Bar(name, label) {
Foo.call(this, name);
this.label = label;
}
// 將新的Bar原型物件和Foo的原型物件進行關聯
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.myLabel = function() {
return this.label;
}
let a = new Bar("a", "obj a");
a.myName();
a.myLabel();
- 上面程式碼中,Bar.prototype = Object.create(Foo.prototype)表示建立新的Bar.prototype物件並關聯到Foo.Prototype中。注意,這其實是把舊的Bar.prototype物件拋棄掉,再引用新的已關聯到Foo.prototype的物件。
- ES6新增
Object.setPrototypeOf(obj1, obj2)
,表示直接將obj1的[[prototype]]關聯到為obj2。
以下兩行程式碼都是錯誤的物件關聯做法:
Bar.prototype = Foo.prototype;
Bar.prototype = new Foo();
- 第一行程式碼只是讓Bar的原型物件直接引用Foo的原型物件。如果對Bar.prototype的屬性進行修改,則會影響到Foo.prototype本身。
- 第二行程式碼,在《JavaScript高階程式設計第三版》的示例程式碼出現。一開始覺得沒問題,後來在《你不知道的JavaScript》中,它指出是錯誤的做法,原因是Foo函式如果會有一些副作用(比如給this新增資料就很不好),會影響到Bar()的例項。
十、類之間的關係
檢查一個例項和祖先通常稱為反射或內省。在JavaScript中通常用到
- 使用
a instanceof Foo
操作符,instanceof表示的是:在物件a的原型鏈上是否有指向Foo.prototype的物件。注意,instanceof的左側是物件,右側是函式。 - 使用
a.isPrototypeOf(b)
,isPrototypeOf表示的是:在物件a的整條原型鏈上是否出現過b。 - 使用
Object.getPrototypeOf(a)
,可以直接得到一個物件a的原型鏈。
十一、總結
這裡例舉幾點比較重要的概念:
- 進行物件屬性查詢,首先會在當前物件查詢,如果沒有就會繼續去查詢內建[[prototype]]關聯的物件,這個原型鏈會一直到Object.prototype,如果還是找不到就返回undefined。
- 建構函式只是函式,沒有任何區別,使用new呼叫函式就是建構函式呼叫。
- JavaScript沒有類,預設下不會複製,物件之間通過[[prototype]]進行關聯,物件關聯是原型中很重要的概念!