前言
通過本文您能學到什麼?
在 JavaScript 中,所有的物件都有一個隱藏的
[[Prototype]]
屬性,它要麼是另一個物件,要麼就是null
。我們可以使用
obj.__proto__
訪問它(歷史遺留下來的 getter/setter,這兒還有其他方法,很快我們就會講到)。通過
[[Prototype]]
引用的物件被稱為“原型”。如果我們想要讀取
obj
的一個屬性或者呼叫一個方法,並且它不存在,那麼 JavaScript 就會嘗試在原型中查詢它。寫/刪除操作直接在物件上進行,它們不使用原型(假設它是資料屬性,不是 setter)。
如果我們呼叫
obj.method()
,而且method
是從原型中獲取的,this
仍然會引用obj
。因此,方法始終與當前物件一起使用,即使方法是繼承的。
for..in
迴圈在其自身和繼承的屬性上進行迭代。所有其他的鍵/值獲取方法僅對物件本身起作用。
在程式設計中,我們經常會想獲取並擴充套件一些東西。
例如,我們有一個 user
物件及其屬性和方法,並希望將 admin
和 guest
作為基於 user
稍加修改的變體。我們想重用 user
中的內容,而不是複製/重新實現它的方法,而只是在其至上構建一個新的物件。
原型繼承(Prototypal inheritance) 這個語言特效能夠幫助我們實現這一需求。
[[Prototype]]
在 JavaScript 中,物件有一個特殊的隱藏屬性 [[Prototype]]
(如規範中所命名的),它要麼為 null
,要麼就是對另一個物件的引用。該物件被稱為“原型”:
原型有點“神奇”。當我們想要從 object
中讀取一個缺失的屬性時,JavaScript 會自動從原型中獲取該屬性。在程式設計中,這種行為被稱為“原型繼承”。許多炫酷的語言特性和程式設計技巧都基於此。
屬性 [[Prototype]]
是內部的而且是隱藏的,但是這兒有很多設定它的方式。
其中之一就是使用特殊的名字 __proto__
,就像這樣:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal;複製程式碼
__proto__
是 [[Prototype]]
的因歷史原因而留下來的 getter/setter請注意,__proto__
與 [[Prototype]]
不一樣。__proto__
是 [[Prototype]]
的 getter/setter。
__proto__
的存在是歷史的原因。在現代程式語言中,將其替換為函式 Object.getPrototypeOf/Object.setPrototypeOf
也能 get/set 原型。我們稍後將學習造成這種情況的原因以及這些函式。
根據規範,__proto__
必須僅在瀏覽器環境下才能得到支援,但實際上,包括服務端在內的所有環境都支援它。目前,由於 __proto__
標記在觀感上更加明顯,所以我們在後面的示例中將使用它。
如果我們在 rabbit
中查詢一個缺失的屬性,JavaScript 會自動從 animal
中獲取它。
例如:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // (*)
// 現在這兩個屬性我們都能在 rabbit 中找到:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true複製程式碼
這裡的 (*)
行將 animal
設定為 rabbit
的原型。
當 alert
試圖讀取 rabbit.eats
(**)
時,因為它不存在於 rabbit
中,所以 JavaScript 會順著 [[Prototype]]
引用,在 animal
中查詢(自下而上):
在這兒我們可以說 "animal
是 rabbit
的原型",或者說 "rabbit
的原型是從 animal
繼承而來的"。
因此,如果 animal
有許多有用的屬性和方法,那麼它們將自動地變為在 rabbit
中可用。這種屬性被稱為“繼承”。
如果我們在 animal
中有一個方法,它可以在 rabbit
中被呼叫:
let animal = {
eats: true,
walk() {
alert("Animal walk");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
// walk 方法是從原型中獲得的
rabbit.walk(); // Animal walk複製程式碼
該方法是自動地從原型中獲得的,像這樣:
原型鏈可以很長:
let animal = {
eats: true,
walk() {
alert("Animal walk");
}
};
let rabbit = {
jumps: true,
__proto__: animal
};
let longEar = {
earLength: 10,
__proto__: rabbit
};
// walk 是通過原型鏈獲得的
longEar.walk(); // Animal walk
alert(longEar.jumps); // true(從 rabbit)複製程式碼
這裡只有兩個限制:
- 引用不能形成閉環。如果我們試圖在一個閉環中分配
__proto__
,JavaScript 會丟擲錯誤。 __proto__
的值可以是物件,也可以是null
。而其他的型別都會被忽略。
當然,這可能很顯而易見,但是仍然要強調:只能有一個 [[Prototype]]
。一個物件不能從其他兩個物件獲得繼承。
寫入不使用原型
原型僅用於讀取屬性。
對於寫入/刪除操作可以直接在物件上進行。
在下面的示例中,我們將為 rabbit
分配自己的 walk
:
let animal = {
eats: true,
walk() {
/* rabbit 不會使用此方法 */
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("Rabbit! Bounce-bounce!");
};
rabbit.walk(); // Rabbit! Bounce-bounce!複製程式碼
從現在開始,rabbit.walk()
將立即在物件中找到該方法並執行,而無需使用原型:
訪問器(accessor)屬性是一個例外,因為分配(assignment)操作是由 setter 函式處理的。因此,寫入此類屬性實際上與呼叫函式相同。
也就是這個原因,所以下面這段程式碼中的 admin.fullName
能夠正常執行:
let user = {
name: "John",
surname: "Smith",
set fullName(value) {
[this.name, this.surname] = value.split(" ");
},
get fullName() {
return `${this.name} ${this.surname}`;
}
};
let admin = {
__proto__: user,
isAdmin: true
};
alert(admin.fullName); // John Smith (*)
// setter triggers!
admin.fullName = "Alice Cooper"; // (**)複製程式碼
在 (*)
行中,屬性 admin.fullName
在原型 user
中有一個 getter,因此它會被呼叫。在 (**)
行中,屬性在原型中有一個 setter,因此它會被呼叫。
“this” 的值
在上面的例子中可能會出現一個有趣的問題:在 set fullName(value)
中 this
的值是什麼?屬性 this.name
和 this.surname
被寫在哪裡:在 user
還是 admin
?
答案很簡單:this
根本不受原型的影響。
無論在哪裡找到方法:在一個物件還是在原型中。在一個方法呼叫中,this
始終是點符號 .
前面的物件。
因此,setter 呼叫 admin.fullName=
使用 admin
作為 this
,而不是 user
。
這是一件非常重要的事兒,因為我們可能有一個帶有很多方法的大物件,並且還有從其繼承的物件。當繼承的物件執行繼承的方法時,它們將僅修改自己的狀態,而不會修改大物件的狀態。
例如,這裡的 animal
代表“方法儲存”,rabbit
在使用其中的方法。
呼叫 rabbit.sleep()
會在 rabbit
物件上設定 this.isSleeping
:
// animal 有一些方法
let animal = {
walk() {
if (!this.isSleeping) {
alert(`I walk`);
}
},
sleep() {
this.isSleeping = true;
}
};
let rabbit = {
name: "White Rabbit",
__proto__: animal
};
// 修改 rabbit.isSleeping
rabbit.sleep();
alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined(原型中沒有此屬性)複製程式碼
結果示意圖:
如果我們還有從 animal
繼承的其他物件,像 bird
和 snake
等,它們也將可以訪問 animal
的方法。但是,每個方法呼叫中的 this
都是在呼叫時(點符號前)評估的對應的物件,而不是 animal
。因此,當我們將資料寫入 this
時,會將其儲存到這些物件中。
所以,方法是共享的,但物件狀態不是。
for…in 迴圈
for..in
迴圈也會迭代繼承的屬性。
例如:
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
// Object.keys 只返回自己的 key
alert(Object.keys(rabbit)); // jumps
// for..in 會遍歷自己以及繼承的鍵
for(let prop in rabbit) alert(prop); // jumps,然後是 eats複製程式碼
如果這不是我們想要的,並且我們想排除繼承的屬性,那麼這兒有一個內建方法 obj.hasOwnProperty(key):如果 obj
具有自己的(非繼承的)名為 key
的屬性,則返回 true
。
因此,我們可以過濾掉繼承的屬性(或對它們進行其他操作):
這裡我們有以下繼承鏈:rabbit
從 animal
中繼承,animal
從 Object.prototype
中繼承(因為 animal
是物件字面量 {...}
,所以這是預設的繼承),然後再向上是 null
:
注意,這有一件很有趣的事兒。方法 rabbit.hasOwnProperty
來自哪兒?我們並沒有定義它。從上圖中的原型鏈我們可以看到,該方法是 Object.prototype.hasOwnProperty
提供的。換句話說,它是繼承的。
……如果 for..in
迴圈會列出繼承的屬性,那為什麼 hasOwnProperty
沒有像 eats
和 jumps
那樣出現在 for..in
迴圈中?
答案很簡單:它是不可列舉的。就像 Object.prototype
的其他屬性,hasOwnProperty
有 enumerable:false
標誌。並且 for..in
只會列出可列舉的屬性。這就是為什麼它和其餘的 Object.prototype
屬性都未被列出。
幾乎所有其他鍵/值獲取方法,例如 Object.keys
和 Object.values
等,都會忽略繼承的屬性。
它們只會對物件自身進行操作。不考慮 繼承自原型的屬性。
總結
- 在 JavaScript 中,所有的物件都有一個隱藏的
[[Prototype]]
屬性,它要麼是另一個物件,要麼就是null
。 - 我們可以使用
obj.__proto__
訪問它(歷史遺留下來的 getter/setter,這兒還有其他方法,很快我們就會講到)。 - 通過
[[Prototype]]
引用的物件被稱為“原型”。 - 如果我們想要讀取
obj
的一個屬性或者呼叫一個方法,並且它不存在,那麼 JavaScript 就會嘗試在原型中查詢它。 - 寫/刪除操作直接在物件上進行,它們不使用原型(假設它是資料屬性,不是 setter)。
- 如果我們呼叫
obj.method()
,而且method
是從原型中獲取的,this
仍然會引用obj
。因此,方法始終與當前物件一起使用,即使方法是繼承的。 for..in
迴圈在其自身和繼承的屬性上進行迭代。所有其他的鍵/值獲取方法僅對物件本身起作用。
幾個小栗子
重要程度: *****
下面這段程式碼建立了一對物件,然後對它們進行修改。
過程中會顯示哪些值?
let animal = {
jumps: null
};
let rabbit = {
__proto__: animal,
jumps: true
};
alert( rabbit.jumps ); // ? (1)
delete rabbit.jumps;
alert( rabbit.jumps ); // ? (2)
delete animal.jumps;
alert( rabbit.jumps ); // ? (3)複製程式碼
應該有 3 個答案。
true
,來自於rabbit
。null
,來自於animal
。undefined
,不再有這樣的屬性存在。
重要程度: *****
本題目有兩個部分。
給定以下物件:
let head = {
glasses: 1
};
let table = {
pen: 3
};
let bed = {
sheet: 1,
pillow: 2
};
let pockets = {
money: 2000
};複製程式碼
- 使用
__proto__
來分配原型,以使得任何屬性的查詢都遵循以下路徑:pockets
→bed
→table
→head
。例如,pockets.pen
應該是3
(在table
中找到),bed.glasses
應該是1
(在head
中找到)。 - 回答問題:通過
pockets.glasses
或head.glasses
獲取glasses
,哪個更快?必要時需要進行基準測試。
讓我們新增
__proto__
:在現代引擎中,從效能的角度來看,我們是從物件還是從原型鏈獲取屬性都是沒區別的。它們(引擎)會記住在哪裡找到的該屬性,並在下一次請求中重用它。
例如,對於
pockets.glasses
來說,它們(引擎)會記得在哪裡找到的glasses
(在head
中),這樣下次就會直接在這個位置進行搜尋。並且引擎足夠聰明,一旦有內容更改,它們就會自動更新內部快取,因此,該優化是安全的。
重要程度: *****
我們有從 animal
中繼承的 rabbit
。
如果我們呼叫 rabbit.eat()
,哪一個物件會接收到 full
屬性:animal
還是 rabbit
?
let animal = {
eat() {
this.full = true;
}
};
let rabbit = {
__proto__: animal
};
rabbit.eat();複製程式碼
答案:rabbit
。
這是因為 this
是點符號前面的這個物件,因此 rabbit.eat()
修改了 rabbit
。
屬性查詢和執行是兩回事兒。
首先在原型中找到 rabbit.eat
方法,然後在 this=rabbit
的情況下執行。
重要程度: *****
我們有兩隻倉鼠:speedy
和 lazy
都繼承自普通的 hamster
物件。
當我們喂其中一隻的時候,另一隻也吃飽了。為什麼?如何修復它?
我們仔細研究一下在呼叫 speedy.eat("apple")
的時候,發生了什麼。
speedy.eat
方法在原型(=hamster
)中被找到,然後執行this=speedy
(在點符號前面的物件)。this.stomach.push()
需要找到stomach
屬性,然後對其呼叫push
。它在this
(=speedy
)中查詢stomach
,但並沒有找到。然後它順著原型鏈,在
hamster
中找到stomach
。然後它對
stomach
呼叫push
,將食物新增到stomach
的原型 中。
因此,所有的倉鼠共享了同一個胃!
對於 lazy.stomach.push(...)
和 speedy.stomach.push()
而言,屬性 stomach
被在原型中找到(不是在物件自身),然後向其中 push
了新資料。
請注意,在簡單的賦值 this.stomach=
的情況下不會出現這種情況:
let hamster = {
stomach: [],
eat(food) {
// 分配給 this.stomach 而不是 this.stomach.push
this.stomach = [food];
}
};
let speedy = {
__proto__: hamster
};
let lazy = {
__proto__: hamster
};
// 倉鼠 Speedy 找到了食物
speedy.eat("apple");
alert( speedy.stomach ); // apple
// 倉鼠 Lazy 的胃是空的
alert( lazy.stomach ); // <nothing>複製程式碼
現在,一切都執行正常,因為 this.stomach=
不會執行對 stomach
的查詢。該值會被直接寫入 this
物件。
此外,我們還可以通過確保每隻倉鼠都有自己的胃來完全迴避這個問題:
let hamster = {
stomach: [],
eat(food) {
this.stomach.push(food);
}
};
let speedy = {
__proto__: hamster,
stomach: []
};
let lazy = {
__proto__: hamster,
stomach: []
};
// 倉鼠 Speedy 找到了食物
speedy.eat("apple");
alert( speedy.stomach ); // apple
// 倉鼠 Lazy 的胃是空的
alert( lazy.stomach ); // <nothing>複製程式碼
作為一種常見的解決方案,所有描述特定物件狀態的屬性,例如上面的 stomach
,都應該被寫入該物件中。這樣可以避免此類問題。