在編程中,我們經常會想獲取並擴展壹些東西。
例如,我們有壹個 user
對象及其屬性和方法,並希望將 admin
和 guest
作爲基于 user
稍加修改的變體。我們想重用 user
中的內容,而不是複制/重新實現它的方法,而只是在其之上構建壹個新的對象。
原型繼承(Prototypal inheritance) 這個語言特性能夠幫助我們實現這壹需求。
在 JavaScript 中,對象有壹個特殊的隱藏屬性 [[Prototype]]
(如規範中所命名的),它要麽爲 null
,要麽就是對另壹個對象的引用。該對象被稱爲“原型”:
當我們從 object
中讀取壹個缺失的屬性時,JavaScript 會自動從原型中獲取該屬性。在編程中,這被稱爲“原型繼承”。很快,我們將通過很多示例來學習此類繼承,以及基于此類繼承的更炫酷的語言功能。
屬性 [[Prototype]]
是內部的而且是隱藏的,但是這兒有很多設置它的方式。
其中之壹就是使用特殊的名字 __proto__
,就像這樣:
let animal = { eats: true }; let rabbit = { jumps: true }; rabbit.__proto__ = animal; // 設置 rabbit.[[Prototype]] = animal
現在,如果我們從 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)
現在,如果我們從 longEar
中讀取壹些它不存在的內容,JavaScript 會先在 rabbit
中查找,然後在 animal
中查找。
這裏只有兩個限制:
引用不能形成閉環。如果我們試圖給 __proto__
賦值但會導致引用形成閉環時,JavaScript 會抛出錯誤。
__proto__
的值可以是對象,也可以是 null
。而其他的類型都會被忽略。
當然,這可能很顯而易見,但是仍然要強調:只能有壹個 [[Prototype]]
。壹個對象不能從其他兩個對象獲得繼承。
__proto__
是 [[Prototype]]
的因曆史原因而留下來的 getter/setter
初學者常犯壹個普遍的錯誤,就是不知道 __proto__
和 [[Prototype]]
的區別。
請注意,__proto__
與內部的 [[Prototype]]
不壹樣。__proto__
是 [[Prototype]]
的 getter/setter。稍後,我們將看到在什麽情況下理解它們很重要,在建立對 JavaScript 語言的理解時,讓我們牢記這壹點。
__proto__
屬性有點過時了。它的存在是出于曆史的原因,現代編程語言建議我們應該使用函數 Object.getPrototypeOf/Object.setPrototypeOf
來取代 __proto__
去 get/set 原型。稍後我們將介紹這些函數。
根據規範,__proto__
必須僅受浏覽器環境的支持。但實際上,包括服務端在內的所有環境都支持它,因此我們使用它是非常安全的。
由于 __proto__
標記在觀感上更加明顯,所以我們在後面的示例中將使用它。
原型僅用于讀取屬性。
對于寫入/刪除操作可以直接在對象上進行。
在下面的示例中,我們將爲 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"; // (**) alert(admin.fullName); // Alice Cooper,admin 的內容被修改了 alert(user.fullName); // John Smith,user 的內容被保護了
在 (*)
行中,屬性 admin.fullName
在原型 user
中有壹個 getter,因此它會被調用。在 (**)
行中,屬性在原型中有壹個 setter,因此它會被調用。
在上面的例子中可能會出現壹個有趣的問題:在 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
循環也會叠代繼承的屬性。
例如:
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
。
因此,我們可以過濾掉繼承的屬性(或對它們進行其他操作):
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
的其他屬性,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
循環在其自身和繼承的屬性上進行叠代。所有其他的鍵/值獲取方法僅對對象本身起作用。
重要程度: 5
下面這段代碼創建了壹對對象,然後對它們進行修改。
過程中會顯示哪些值?
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
,不再有這樣的屬性存在。
重要程度: 5
本題目有兩個部分。
給定以下對象:
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__
:
let head = { glasses: 1 }; let table = { pen: 3, __proto__: head }; let bed = { sheet: 1, pillow: 2, __proto__: table }; let pockets = { money: 2000, __proto__: bed }; alert( pockets.pen ); // 3 alert( bed.glasses ); // 1 alert( table.money ); // undefined
在現代引擎中,從性能的角度來看,我們是從對象還是從原型鏈獲取屬性都是沒區別的。它們(引擎)會記住在哪裏找到的該屬性,並在下壹次請求中重用它。
例如,對于 pockets.glasses
來說,它們(引擎)會記得在哪裏找到的 glasses
(在 head
中),這樣下次就會直接在這個位置進行搜索。並且引擎足夠聰明,壹旦有內容更改,它們就會自動更新內部緩存,因此,該優化是安全的。
重要程度: 5
我們有從 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
的情況下執行。
重要程度: 5
我們有兩只倉鼠:speedy
和 lazy
都繼承自普通的 hamster
對象。
當我們喂其中壹只的時候,另壹只也吃飽了。爲什麽?如何修複它?
let hamster = { stomach: [], eat(food) { this.stomach.push(food); } }; let speedy = { __proto__: hamster }; let lazy = { __proto__: hamster }; // 這只倉鼠找到了食物 speedy.eat("apple"); alert( speedy.stomach ); // apple // 這只倉鼠也找到了食物,爲什麽?請修複它。 alert( lazy.stomach ); // apple
我們仔細研究壹下在調用 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
,都應該被寫入該對象中。這樣可以避免此類問題。