Javascript 原型繼承

2023-02-17 10:52 更新

在編程中,我們經(jīng)常會(huì)想獲取并擴(kuò)展一些東西。

例如,我們有一個(gè) user 對(duì)象及其屬性和方法,并希望將 admin 和 guest 作為基于 user 稍加修改的變體。我們想重用 user 中的內(nèi)容,而不是復(fù)制/重新實(shí)現(xiàn)它的方法,而只是在其之上構(gòu)建一個(gè)新的對(duì)象。

原型繼承(Prototypal inheritance) 這個(gè)語(yǔ)言特性能夠幫助我們實(shí)現(xiàn)這一需求。

[[Prototype]]

在 JavaScript 中,對(duì)象有一個(gè)特殊的隱藏屬性 [[Prototype]](如規(guī)范中所命名的),它要么為 null,要么就是對(duì)另一個(gè)對(duì)象的引用。該對(duì)象被稱為“原型”:


當(dāng)我們從 object 中讀取一個(gè)缺失的屬性時(shí),JavaScript 會(huì)自動(dòng)從原型中獲取該屬性。在編程中,這被稱為“原型繼承”。很快,我們將通過(guò)很多示例來(lái)學(xué)習(xí)此類繼承,以及基于此類繼承的更炫酷的語(yǔ)言功能。

屬性 [[Prototype]] 是內(nèi)部的而且是隱藏的,但是這兒有很多設(shè)置它的方式。

其中之一就是使用特殊的名字 __proto__,就像這樣:

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // 設(shè)置 rabbit.[[Prototype]] = animal

現(xiàn)在,如果我們從 rabbit 中讀取一個(gè)它沒有的屬性,JavaScript 會(huì)自動(dòng)從 animal 中獲取。

例如:

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // (*)

// 現(xiàn)在這兩個(gè)屬性我們都能在 rabbit 中找到:
alert( rabbit.eats ); // true (**)
alert( rabbit.jumps ); // true

這里的 (*) 行將 animal 設(shè)置為 rabbit 的原型。

當(dāng) alert 試圖讀取 rabbit.eats (**) 時(shí),因?yàn)樗淮嬖谟?nbsp;rabbit 中,所以 JavaScript 會(huì)順著 [[Prototype]] 引用,在 animal 中查找(自下而上):


在這兒我們可以說(shuō) "animal 是 rabbit 的原型",或者說(shuō) "rabbit 的原型是從 animal 繼承而來(lái)的"。

因此,如果 animal 有許多有用的屬性和方法,那么它們將自動(dòng)地變?yōu)樵?nbsp;rabbit 中可用。這種屬性被稱為“繼承”。

如果我們?cè)?nbsp;animal 中有一個(gè)方法,它可以在 rabbit 中被調(diào)用:

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// walk 方法是從原型中獲得的
rabbit.walk(); // Animal walk

該方法是自動(dòng)地從原型中獲得的,像這樣:


原型鏈可以很長(zhǎng):

let animal = {
  eats: true,
  walk() {
    alert("Animal walk");
  }
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

let longEar = {
  earLength: 10,
  __proto__: rabbit
};

// walk 是通過(guò)原型鏈獲得的
longEar.walk(); // Animal walk
alert(longEar.jumps); // true(從 rabbit)


現(xiàn)在,如果我們從 longEar 中讀取一些它不存在的內(nèi)容,JavaScript 會(huì)先在 rabbit 中查找,然后在 animal 中查找。

這里只有兩個(gè)限制:

  1. 引用不能形成閉環(huán)。如果我們?cè)噲D給 ?__proto__? 賦值但會(huì)導(dǎo)致引用形成閉環(huán)時(shí),JavaScript 會(huì)拋出錯(cuò)誤。
  2. ?__proto__? 的值可以是對(duì)象,也可以是 ?null?。而其他的類型都會(huì)被忽略。

當(dāng)然,這可能很顯而易見,但是仍然要強(qiáng)調(diào):只能有一個(gè) [[Prototype]]。一個(gè)對(duì)象不能從其他兩個(gè)對(duì)象獲得繼承。

?__proto__? 是 ?[[Prototype]]? 的因歷史原因而留下來(lái)的 getter/setter

初學(xué)者常犯一個(gè)普遍的錯(cuò)誤,就是不知道 __proto__ 和 [[Prototype]] 的區(qū)別。

請(qǐng)注意,__proto__ 與內(nèi)部的 [[Prototype]] 不一樣。__proto__ 是 [[Prototype]] 的 getter/setter。稍后,我們將看到在什么情況下理解它們很重要,在建立對(duì) JavaScript 語(yǔ)言的理解時(shí),讓我們牢記這一點(diǎn)。

__proto__ 屬性有點(diǎn)過(guò)時(shí)了。它的存在是出于歷史的原因,現(xiàn)代編程語(yǔ)言建議我們應(yīng)該使用函數(shù) Object.getPrototypeOf/Object.setPrototypeOf 來(lái)取代 __proto__ 去 get/set 原型。稍后我們將介紹這些函數(shù)。

根據(jù)規(guī)范,__proto__ 必須僅受瀏覽器環(huán)境的支持。但實(shí)際上,包括服務(wù)端在內(nèi)的所有環(huán)境都支持它,因此我們使用它是非常安全的。

由于 __proto__ 標(biāo)記在觀感上更加明顯,所以我們?cè)诤竺娴氖纠袑⑹褂盟?

寫入不使用原型

原型僅用于讀取屬性。

對(duì)于寫入/刪除操作可以直接在對(duì)象上進(jìn)行。

在下面的示例中,我們將為 rabbit 的 walk 屬性賦值:

let animal = {
  eats: true,
  walk() {
    /* rabbit 不會(huì)使用此方法 */
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.walk = function() {
  alert("Rabbit! Bounce-bounce!");
};

rabbit.walk(); // Rabbit! Bounce-bounce!

從現(xiàn)在開始,rabbit.walk() 將立即在對(duì)象中找到該方法并執(zhí)行,而無(wú)需使用原型:


訪問器(accessor)屬性是一個(gè)例外,因?yàn)橘x值(assignment)操作是由 setter 函數(shù)處理的。因此,寫入此類屬性實(shí)際上與調(diào)用函數(shù)相同。

也就是這個(gè)原因,所以下面這段代碼中的 admin.fullName 能夠正常運(yùn)行:

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 的內(nèi)容被修改了
alert(user.fullName);  // John Smith,user 的內(nèi)容被保護(hù)了

在 (*) 行中,屬性 admin.fullName 在原型 user 中有一個(gè) getter,因此它會(huì)被調(diào)用。在 (**) 行中,屬性在原型中有一個(gè) setter,因此它會(huì)被調(diào)用。

“this” 的值

在上面的例子中可能會(huì)出現(xiàn)一個(gè)有趣的問題:在 set fullName(value) 中 this 的值是什么?屬性 this.name 和 this.surname 被寫在哪里:在 user 還是 admin?

答案很簡(jiǎn)單:this 根本不受原型的影響。

無(wú)論在哪里找到方法:在一個(gè)對(duì)象還是在原型中。在一個(gè)方法調(diào)用中,this 始終是點(diǎn)符號(hào) . 前面的對(duì)象。

因此,setter 調(diào)用 admin.fullName= 使用 admin 作為 this,而不是 user。

這是一件非常重要的事兒,因?yàn)槲覀兛赡苡幸粋€(gè)帶有很多方法的大對(duì)象,并且還有從其繼承的對(duì)象。當(dāng)繼承的對(duì)象運(yùn)行繼承的方法時(shí),它們將僅修改自己的狀態(tài),而不會(huì)修改大對(duì)象的狀態(tài)。

例如,這里的 animal 代表“方法存儲(chǔ)”,rabbit 在使用其中的方法。

調(diào)用 rabbit.sleep() 會(huì)在 rabbit 對(duì)象上設(shè)置 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(原型中沒有此屬性)

結(jié)果示意圖:


如果我們還有從 animal 繼承的其他對(duì)象,像 bird 和 snake 等,它們也將可以訪問 animal 的方法。但是,每個(gè)方法調(diào)用中的 this 都是在調(diào)用時(shí)(點(diǎn)符號(hào)前)評(píng)估的對(duì)應(yīng)的對(duì)象,而不是 animal。因此,當(dāng)我們將數(shù)據(jù)寫入 this 時(shí),會(huì)將其存儲(chǔ)到這些對(duì)象中。

所以,方法是共享的,但對(duì)象狀態(tài)不是。

for…in 循環(huán)

for..in 循環(huán)也會(huì)迭代繼承的屬性。

例如:

let animal = {
  eats: true
};

let rabbit = {
  jumps: true,
  __proto__: animal
};

// Object.keys 只返回自己的 key
alert(Object.keys(rabbit)); // jumps

// for..in 會(huì)遍歷自己以及繼承的鍵
for(let prop in rabbit) alert(prop); // jumps,然后是 eats

如果這不是我們想要的,并且我們想排除繼承的屬性,那么這兒有一個(gè)內(nèi)建方法 obj.hasOwnProperty(key):如果 obj 具有自己的(非繼承的)名為 key 的屬性,則返回 true

因此,我們可以過(guò)濾掉繼承的屬性(或?qū)λ鼈冞M(jìn)行其他操作):

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 中繼承(因?yàn)?nbsp;animal 是對(duì)象字面量 {...},所以這是默認(rèn)的繼承),然后再向上是 null


注意,這有一件很有趣的事兒。方法 rabbit.hasOwnProperty 來(lái)自哪兒?我們并沒有定義它。從上圖中的原型鏈我們可以看到,該方法是 Object.prototype.hasOwnProperty 提供的。換句話說(shuō),它是繼承的。

……如果 for..in 循環(huán)會(huì)列出繼承的屬性,那為什么 hasOwnProperty 沒有像 eats 和 jumps 那樣出現(xiàn)在 for..in 循環(huán)中?

答案很簡(jiǎn)單:它是不可枚舉的。就像 Object.prototype 的其他屬性,hasOwnProperty 有 enumerable:false 標(biāo)志。并且 for..in 只會(huì)列出可枚舉的屬性。這就是為什么它和其余的 Object.prototype 屬性都未被列出。

幾乎所有其他鍵/值獲取方法都忽略繼承的屬性

幾乎所有其他鍵/值獲取方法,例如 Object.keys 和 Object.values 等,都會(huì)忽略繼承的屬性。

它們只會(huì)對(duì)對(duì)象自身進(jìn)行操作。不考慮 繼承自原型的屬性。

總結(jié)

  • 在 JavaScript 中,所有的對(duì)象都有一個(gè)隱藏的 ?[[Prototype]]? 屬性,它要么是另一個(gè)對(duì)象,要么就是 ?null?。
  • 我們可以使用 ?obj.__proto__? 訪問它(歷史遺留下來(lái)的 getter/setter,這兒還有其他方法,很快我們就會(huì)講到)。
  • 通過(guò) ?[[Prototype]]? 引用的對(duì)象被稱為“原型”。
  • 如果我們想要讀取 ?obj? 的一個(gè)屬性或者調(diào)用一個(gè)方法,并且它不存在,那么 JavaScript 就會(huì)嘗試在原型中查找它。
  • 寫/刪除操作直接在對(duì)象上進(jìn)行,它們不使用原型(假設(shè)它是數(shù)據(jù)屬性,不是 setter)。
  • 如果我們調(diào)用 ?obj.method()?,而且 ?method? 是從原型中獲取的,?this? 仍然會(huì)引用 ?obj?。因此,方法始終與當(dāng)前對(duì)象一起使用,即使方法是繼承的。
  • ?for..in? 循環(huán)在其自身和繼承的屬性上進(jìn)行迭代。所有其他的鍵/值獲取方法僅對(duì)對(duì)象本身起作用。

任務(wù)


使用原型

重要程度: 5

下面這段代碼創(chuàng)建了一對(duì)對(duì)象,然后對(duì)它們進(jìn)行修改。

過(guò)程中會(huì)顯示哪些值?

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)

應(yīng)該有 3 個(gè)答案。


解決方案

  1. ?true?,來(lái)自于 ?rabbit?。
  2. ?null?,來(lái)自于 ?animal?。
  3. ?undefined?,不再有這樣的屬性存在。

搜索算法

重要程度: 5

本題目有兩個(gè)部分。

給定以下對(duì)象:

let head = {
  glasses: 1
};

let table = {
  pen: 3
};

let bed = {
  sheet: 1,
  pillow: 2
};

let pockets = {
  money: 2000
};
  1. 使用 ?__proto__? 來(lái)分配原型,以使得任何屬性的查找都遵循以下路徑:?pockets? → ?bed? → ?table? → ?head?。例如,?pockets.pen? 應(yīng)該是 ?3?(在 ?table? 中找到),?bed.glasses? 應(yīng)該是 ?1?(在 ?head? 中找到)。
  2. 回答問題:通過(guò) ?pockets.glasses? 或 ?head.glasses? 獲取 ?glasses?,哪個(gè)更快?必要時(shí)需要進(jìn)行基準(zhǔn)測(cè)試。

解決方案

  1. 讓我們添加 __proto__
  2. 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
  3. 在現(xiàn)代引擎中,從性能的角度來(lái)看,我們是從對(duì)象還是從原型鏈獲取屬性都是沒區(qū)別的。它們(引擎)會(huì)記住在哪里找到的該屬性,并在下一次請(qǐng)求中重用它。
  4. 例如,對(duì)于 pockets.glasses 來(lái)說(shuō),它們(引擎)會(huì)記得在哪里找到的 glasses(在 head 中),這樣下次就會(huì)直接在這個(gè)位置進(jìn)行搜索。并且引擎足夠聰明,一旦有內(nèi)容更改,它們就會(huì)自動(dòng)更新內(nèi)部緩存,因此,該優(yōu)化是安全的。


寫在哪里?

重要程度: 5

我們有從 animal 中繼承的 rabbit。

如果我們調(diào)用 rabbit.eat(),哪一個(gè)對(duì)象會(huì)接收到 full 屬性:animal 還是 rabbit?

let animal = {
  eat() {
    this.full = true;
  }
};

let rabbit = {
  __proto__: animal
};

rabbit.eat();

解決方案

答案:rabbit。

這是因?yàn)?nbsp;this 是點(diǎn)符號(hào)前面的這個(gè)對(duì)象,因此 rabbit.eat() 修改了 rabbit

屬性查找和執(zhí)行是兩回事兒。

首先在原型中找到 rabbit.eat 方法,然后在 this=rabbit 的情況下執(zhí)行。


為什么兩只倉(cāng)鼠都飽了?

重要程度: 5

我們有兩只倉(cāng)鼠:speedy 和 lazy 都繼承自普通的 hamster 對(duì)象。

當(dāng)我們喂其中一只的時(shí)候,另一只也吃飽了。為什么?如何修復(fù)它?

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// 這只倉(cāng)鼠找到了食物
speedy.eat("apple");
alert( speedy.stomach ); // apple

// 這只倉(cāng)鼠也找到了食物,為什么?請(qǐng)修復(fù)它。
alert( lazy.stomach ); // apple

解決方案

我們仔細(xì)研究一下在調(diào)用 speedy.eat("apple") 的時(shí)候,發(fā)生了什么。

  1. ?speedy.eat? 方法在原型(?=hamster?)中被找到,然后執(zhí)行 ?this=speedy?(在點(diǎn)符號(hào)前面的對(duì)象)。
  2. ?this.stomach.push()? 需要找到 ?stomach? 屬性,然后對(duì)其調(diào)用 ?push?。它在 ?this?(?=speedy?)中查找 ?stomach?,但并沒有找到。
  3. 然后它順著原型鏈,在 ?hamster? 中找到 ?stomach?。
  4. 然后它對(duì) ?stomach? 調(diào)用 ?push?,將食物添加到 ?stomach? 的原型 中。

因此,所有的倉(cāng)鼠共享了同一個(gè)胃!

對(duì)于 lazy.stomach.push(...) 和 speedy.stomach.push() 而言,屬性 stomach 被在原型中找到(不是在對(duì)象自身),然后向其中 push 了新數(shù)據(jù)。

請(qǐng)注意,在簡(jiǎn)單的賦值 this.stomach= 的情況下不會(huì)出現(xiàn)這種情況:

let hamster = {
  stomach: [],

  eat(food) {
    // 分配給 this.stomach 而不是 this.stomach.push
    this.stomach = [food];
  }
};

let speedy = {
   __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// 倉(cāng)鼠 Speedy 找到了食物
speedy.eat("apple");
alert( speedy.stomach ); // apple

// 倉(cāng)鼠 Lazy 的胃是空的
alert( lazy.stomach ); // <nothing>

現(xiàn)在,一切都運(yùn)行正常,因?yàn)?nbsp;this.stomach= 不會(huì)執(zhí)行對(duì) stomach 的查找。該值會(huì)被直接寫入 this 對(duì)象。

此外,我們還可以通過(guò)確保每只倉(cāng)鼠都有自己的胃來(lái)完全回避這個(gè)問題:

let hamster = {
  stomach: [],

  eat(food) {
    this.stomach.push(food);
  }
};

let speedy = {
  __proto__: hamster,
  stomach: []
};

let lazy = {
  __proto__: hamster,
  stomach: []
};

// 倉(cāng)鼠 Speedy 找到了食物
speedy.eat("apple");
alert( speedy.stomach ); // apple

// 倉(cāng)鼠 Lazy 的胃是空的
alert( lazy.stomach ); // <nothing>

作為一種常見的解決方案,所有描述特定對(duì)象狀態(tài)的屬性,例如上面的 stomach,都應(yīng)該被寫入該對(duì)象中。這樣可以避免此類問題。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)