在程式設計中,我們常常想要取得某個東西並加以延伸。
例如,我們有一個具有其屬性和方法的 user
物件,並希望將 admin
和 guest
作為其略微修改的變體。我們希望重複使用我們在 user
中的內容,而不是複製/重新實作其方法,只需在其上建立一個新的物件即可。
原型繼承 是一種有助於此的語言功能。
[[原型]]
在 JavaScript 中,物件有一個特殊的隱藏屬性 [[Prototype]]
(如規範中所述),它可以是 null
或參考另一個物件。該物件稱為「原型」
當我們從 object
讀取屬性,並且它不存在時,JavaScript 會自動從原型中取得它。在程式設計中,這稱為「原型繼承」。我們很快就會研究許多此類繼承的範例,以及建立在其上的更酷的語言功能。
屬性 [[Prototype]]
是內部且隱藏的,但有許多方法可以設定它。
其中一種方法是使用特殊名稱 __proto__
,如下所示
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // sets rabbit.[[Prototype]] = animal
現在,如果我們從 rabbit
讀取屬性,並且它不存在,JavaScript 會自動從 animal
取得它。
例如
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // (*)
// we can find both properties in rabbit now:
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 is taken from the prototype
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 is taken from the prototype chain
longEar.walk(); // Animal walk
alert(longEar.jumps); // true (from rabbit)
現在,如果我們從 longEar
讀取某些內容,並且它不存在,JavaScript 會在 rabbit
中尋找它,然後在 animal
中尋找它。
只有兩個限制
- 參考不能循環。如果我們嘗試在循環中指定
__proto__
,JavaScript 會擲出錯誤。 __proto__
的值可以是物件或null
。其他類型會被忽略。
雖然這點顯而易見,但還是要說:只有一個 [[Prototype]]
。物件無法同時繼承自兩個其他物件。
__proto__
是 [[Prototype]]
的歷史 getter/setter新手開發人員常犯的錯誤,就是不知道這兩者的差別。
請注意,__proto__
不等於 內部的 [[Prototype]]
屬性。它是 [[Prototype]]
的 getter/setter。稍後我們會看到這一點造成影響的情況,現在我們先記住這一點,並在我們建立對 JavaScript 語言的理解時,將其考慮進去。
__proto__
屬性有點過時了。它存在於歷史原因,現代 JavaScript 建議我們改用 Object.getPrototypeOf/Object.setPrototypeOf
函式,來取得/設定原型。我們稍後也會介紹這些函式。
根據規範,__proto__
只能由瀏覽器支援。但事實上,包括伺服器端在內的所有環境都支援 __proto__
,所以我們使用它相當安全。
由於 __proto__
表示法直觀上更明顯,因此我們在範例中使用它。
寫入不會使用原型
原型只用於讀取屬性。
寫入/刪除操作會直接作用於物件。
在以下範例中,我們將自己的 walk
方法指定給 rabbit
let animal = {
eats: true,
walk() {
/* this method won't be used by rabbit */
}
};
let rabbit = {
__proto__: animal
};
rabbit.walk = function() {
alert("Rabbit! Bounce-bounce!");
};
rabbit.walk(); // Rabbit! Bounce-bounce!
從現在開始,rabbit.walk()
呼叫會立即在物件中找到方法並執行它,而不會使用原型
存取器屬性是個例外,因為指定是由 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"; // (**)
alert(admin.fullName); // Alice Cooper, state of admin modified
alert(user.fullName); // John Smith, state of user protected
在這裡的 (*)
行中,屬性 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 has methods
let animal = {
walk() {
if (!this.isSleeping) {
alert(`I walk`);
}
},
sleep() {
this.isSleeping = true;
}
};
let rabbit = {
name: "White Rabbit",
__proto__: animal
};
// modifies rabbit.isSleeping
rabbit.sleep();
alert(rabbit.isSleeping); // true
alert(animal.isSleeping); // undefined (no such property in the prototype)
產生的畫面
如果我們有其他物件,例如從 animal
繼承的 bird
、snake
等,它們也會取得 animal
的方法存取權。但是每個方法呼叫中的 this
會是對應的物件,在呼叫時間(點號之前)評估,而不是 animal
。因此,當我們將資料寫入 this
時,它會儲存在這些物件中。
因此,方法是共用的,但物件狀態不是。
for…in 迴圈
for..in
迴圈也會遍歷繼承的屬性。
例如
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
// Object.keys only returns own keys
alert(Object.keys(rabbit)); // jumps
// for..in loops over both own and inherited keys
for(let prop in rabbit) alert(prop); // jumps, then eats
如果這不是我們想要的,而且我們想要排除繼承的屬性,有一個內建方法 obj.hasOwnProperty(key):如果 obj
有自己的(非繼承的)名為 key
的屬性,它會傳回 true
。
因此,我們可以過濾掉繼承的屬性(或對它們執行其他操作)
let animal = {
eats: true
};
let rabbit = {
jumps: true,
__proto__: animal
};
for(let prop in rabbit) {
let isOwn = rabbit.hasOwnProperty(prop);
if (isOwn) {
alert(`Our: ${prop}`); // Our: jumps
} else {
alert(`Inherited: ${prop}`); // Inherited: eats
}
}
這裡我們有以下繼承鏈:rabbit
繼承自 animal
,而 animal
繼承自 Object.prototype
(因為 animal
是文字物件 {...}
,所以它預設如此),然後是其上方的 null
請注意,有一件有趣的事。rabbit.hasOwnProperty
方法從何而來?我們沒有定義它。檢視鏈時,我們可以看到該方法是由 Object.prototype.hasOwnProperty
提供的。換句話說,它是繼承的。
…但是,如果 for..in
會列出繼承的屬性,為什麼 hasOwnProperty
沒有像 eats
和 jumps
那樣出現在 for..in
迴圈中?
答案很簡單:它不可列舉。就像 Object.prototype
的所有其他屬性一樣,它有 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
迴圈會反覆運算自己的屬性和繼承的屬性。所有其他取得金鑰/值的函式只會對物件本身進行操作。
評論
<code>
標籤,對於多行 - 將其包裝在<pre>
標籤中,對於 10 行以上 - 使用沙箱 (plnkr、jsbin、codepen…)