原型,繼承——原型繼承

王小醬發表於2020-03-15

前言

通過本文您能學到什麼?
  • 在 JavaScript 中,所有的物件都有一個隱藏的 [[Prototype]] 屬性,它要麼是另一個物件,要麼就是 null

  • 我們可以使用 obj.__proto__ 訪問它(歷史遺留下來的 getter/setter,這兒還有其他方法,很快我們就會講到)。

  • 通過 [[Prototype]] 引用的物件被稱為“原型”。

  • 如果我們想要讀取 obj 的一個屬性或者呼叫一個方法,並且它不存在,那麼 JavaScript 就會嘗試在原型中查詢它。

  • 寫/刪除操作直接在物件上進行,它們不使用原型(假設它是資料屬性,不是 setter)。

  • 如果我們呼叫 obj.method(),而且 method 是從原型中獲取的,this 仍然會引用 obj。因此,方法始終與當前物件一起使用,即使方法是繼承的。

  • for..in 迴圈在其自身和繼承的屬性上進行迭代。所有其他的鍵/值獲取方法僅對物件本身起作用。


在程式設計中,我們經常會想獲取並擴充套件一些東西。

例如,我們有一個 user 物件及其屬性和方法,並希望將 adminguest 作為基於 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 中查詢(自下而上):

原型,繼承——原型繼承

在這兒我們可以說 "animalrabbit 的原型",或者說 "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)複製程式碼

原型,繼承——原型繼承

這裡只有兩個限制:

  1. 引用不能形成閉環。如果我們試圖在一個閉環中分配 __proto__,JavaScript 會丟擲錯誤。
  2. __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.namethis.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 繼承的其他物件,像 birdsnake 等,它們也將可以訪問 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

因此,我們可以過濾掉繼承的屬性(或對它們進行其他操作):

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
  }
}複製程式碼

這裡我們有以下繼承鏈:rabbitanimal 中繼承,animalObject.prototype 中繼承(因為 animal 是物件字面量 {...},所以這是預設的繼承),然後再向上是 null

原型,繼承——原型繼承

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

……如果 for..in 迴圈會列出繼承的屬性,那為什麼 hasOwnProperty 沒有像 eatsjumps 那樣出現在 for..in 迴圈中?

答案很簡單:它是不可列舉的。就像 Object.prototype 的其他屬性,hasOwnPropertyenumerable:false 標誌。並且 for..in 只會列出可列舉的屬性。這就是為什麼它和其餘的 Object.prototype 屬性都未被列出。

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

幾乎所有其他鍵/值獲取方法,例如 Object.keysObject.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 個答案。

解決方案
  1. true,來自於 rabbit
  2. null,來自於 animal
  3. undefined,不再有這樣的屬性存在。

搜尋演算法

重要程度: *****

本題目有兩個部分。

給定以下物件:

let head = {
  glasses: 1
};

let table = {
  pen: 3
};

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

let pockets = {
  money: 2000
};複製程式碼
  1. 使用 __proto__ 來分配原型,以使得任何屬性的查詢都遵循以下路徑:pocketsbedtablehead。例如,pockets.pen 應該是 3(在 table 中找到),bed.glasses 應該是 1(在 head 中找到)。
  2. 回答問題:通過 pockets.glasseshead.glasses 獲取 glasses,哪個更快?必要時需要進行基準測試。
解決方案
  1. 讓我們新增 __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複製程式碼
  2. 在現代引擎中,從效能的角度來看,我們是從物件還是從原型鏈獲取屬性都是沒區別的。它們(引擎)會記住在哪裡找到的該屬性,並在下一次請求中重用它。

    例如,對於 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 的情況下執行。

為什麼兩隻倉鼠都飽了?

重要程度: *****

我們有兩隻倉鼠:speedylazy 都繼承自普通的 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") 的時候,發生了什麼。

  1. speedy.eat 方法在原型(=hamster)中被找到,然後執行 this=speedy(在點符號前面的物件)。

  2. this.stomach.push() 需要找到 stomach 屬性,然後對其呼叫 push。它在 this=speedy)中查詢 stomach,但並沒有找到。

  3. 然後它順著原型鏈,在 hamster 中找到 stomach

  4. 然後它對 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,都應該被寫入該物件中。這樣可以避免此類問題。


相關文章