2022 年 5 月 6 日

原型繼承

在程式設計中,我們常常想要取得某個東西並加以延伸。

例如,我們有一個具有其屬性和方法的 user 物件,並希望將 adminguest 作為其略微修改的變體。我們希望重複使用我們在 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 中找到它(從下往上看)

在這裡,我們可以說「animalrabbit 的原型」或「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 中尋找它。

只有兩個限制

  1. 參考不能循環。如果我們嘗試在循環中指定 __proto__,JavaScript 會擲出錯誤。
  2. __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.namethis.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 繼承的 birdsnake 等,它們也會取得 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 沒有像 eatsjumps 那樣出現在 for..in 迴圈中?

答案很簡單:它不可列舉。就像 Object.prototype 的所有其他屬性一樣,它有 enumerable: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 迴圈會反覆運算自己的屬性和繼承的屬性。所有其他取得金鑰/值的函式只會對物件本身進行操作。

任務

重要性: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 個答案。

  1. true,取自 rabbit
  2. null,取自 animal
  3. undefined,不再有這樣的屬性。
重要性:5

此任務分為兩部分。

給定下列物件

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. 回答問題:取得 glasses 的速度是透過 pockets.glasses 較快還是透過 head.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 中),下次會直接在那裡搜尋。它們也足夠聰明,可以在有變動時更新內部快取,因此最佳化是安全的。

重要性:5

我們有 rabbit 繼承自 animal

如果我們呼叫 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

我們有兩隻倉鼠:speedylazy,繼承自一般的 hamster 物件。

當我們餵其中一隻時,另一隻也吃飽了。為什麼?我們可以怎麼解決?

let hamster = {
  stomach: [],

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

let speedy = {
  __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// This one found the food
speedy.eat("apple");
alert( speedy.stomach ); // apple

// This one also has it, why? fix please.
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. 然後它會在上面呼叫 push,將食物加入原型的胃中。

所以所有倉鼠共用一個胃!

對於 lazy.stomach.push(...)speedy.stomach.push(),屬性 stomach 都會在原型中找到(因為它不在物件本身中),然後將新資料推入其中。

請注意,在簡單的指定 this.stomach= 的情況下不會發生這種情況。

let hamster = {
  stomach: [],

  eat(food) {
    // assign to this.stomach instead of this.stomach.push
    this.stomach = [food];
  }
};

let speedy = {
   __proto__: hamster
};

let lazy = {
  __proto__: hamster
};

// Speedy one found the food
speedy.eat("apple");
alert( speedy.stomach ); // apple

// Lazy one's stomach is empty
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 one found the food
speedy.eat("apple");
alert( speedy.stomach ); // apple

// Lazy one's stomach is empty
alert( lazy.stomach ); // <nothing>

作為一個通用的解決方案,所有描述特定物件狀態的屬性,例如上面的 stomach,都應該寫入該物件中。這可以防止此類問題發生。

教學課程地圖

評論

在評論前請先閱讀…
  • 如果您有改進建議 - 請 提交 GitHub 問題 或發起拉取請求,而不是評論。
  • 如果您無法理解文章中的某些內容 - 請說明。
  • 若要插入幾行程式碼,請使用 <code> 標籤,對於多行 - 將其包裝在 <pre> 標籤中,對於 10 行以上 - 使用沙箱 (plnkrjsbincodepen…)