【譯】關於 JavaScript 的原型你應該知道的所有事情

toddmark發表於2019-04-08

原文地址

【譯】關於 JavaScript 的原型你應該知道的所有事情

大多數時候, JavaScript 原型讓剛開始學習 JavaScript 的人困惑——尤其是有 C++ 或者 Java 背景的人。

在 JavaScript 中,相較於 C++ 和 Java,繼承有一些不同的作用。JavaScript 繼承是眾所周知的 “原型繼承”。

當你在 JavaScript 中遇到 時,事情就變得有點困難了。新的語法 class 看起來像 C++ 或者 Java,但實際上,它們的作用是不同的。

這篇文章中,我們將嘗試理解 JavaScript 中的“原型繼承”。我們也會看看新的語法 class 並且嘗試理解它真正是什麼。現在開始吧。

首先,我們用老派 JavaScript 函式和原型開始。

理解 prototype 的需要

如果你曾經跟 JavaScript 的陣列,物件或者字串打交道,你應該注意過預設地很多可用的方法。

舉個例子:

var arr = [1,2,3,4];
arr.reverse(); // returns [4,3,2,1]
var obj = {id: 1, value: "Some value"};
obj.hasOwnProperty('id'); // returns true
var str = "Hello World";
str.indexOf('W'); // returns 6
複製程式碼

你曾經好奇過這些方法是哪裡來的嗎?你自定並未定義過它們。

你可以像這樣定義自己的方法嗎?你或許說可以像這樣做:

var arr = [1,2,3,4];
arr.test = function() {
    return 'Hi';
}
arr.test(); // will return 'Hi'
複製程式碼

這是有效的,但是隻對叫 arr 的變數有效。我們用另一個變數 arr2 呼叫 arr2.test() 將會跑出一個錯誤:“TypeError:arr2.test is not function”。

那麼如何處理這些方法才能讓每一個 array/string/object 的例項變得可用?你能建立自己的方法用同樣的行為嗎?答案是肯定的。你需要用正確的方法處理。要這樣做,JavaScript 的原型就出現了。

首先看看那些方法來自哪裡。考慮下面的程式碼:

var arr1 = [1,2,3,4];
var arr2 = Array(1,2,3,4);
複製程式碼

我們用兩種不同的方式建立陣列:arr1 使用陣列字面量和 arr2 使用 Array 建構函式。它們兩者是相等的,有一些不同,但不是這篇文章的問題。

現在看看建構函式 Array——在 JavaScript 中是預定義的建構函式。如果你開啟 Chrome 開發這工具,然後在控制檯輸入 console.log(Array.prototype) 輸入回車,你會看見下面這些內容:

【譯】關於 JavaScript 的原型你應該知道的所有事情

在這裡你可以看到所有的我們好奇的方法。這裡就是我們得到函式方法的地方。自己試試 String.prototypeObject.prototype

我們建立一個自己的簡單建構函式:

var foo = function(name) {
 this.myName = name;
 this.tellMyName = function() {
   console.log(this.myName);
 }
}
var fooObj1 = new foo('James');
fooObj1.tellMyName(); // will print James
var fooObj2 = new foo('Mike');
fooObj2.tellMyName(); // will print Mike
複製程式碼

你能找出上面程式碼的基本問題嗎?問題在於我們在上述處理中浪費了內容。注意這個方法 tellMyName,在 foo 的例項中,每一個都是一樣的。每次我們建立一個 foo 例項方法 tellMyName,都佔用一部分系統記憶體。如果 tellName 對所有例項都是一樣的,它最好保留在一個地方,並且所有我們的例項都來自這個地方。我們看看如何實現:

var foo = function(name) {
 this.myName = name;
}
foo.prototype.tellMyName = function() {
   console.log(this.myName);
}
var fooObj1 = new foo('James');
fooObj1.tellMyName(); // will print James
var fooObj2 = new foo('Mike');
fooObj2.tellMyName(); // will print Mike
複製程式碼

來比較下上面和之前的實現。在上面的實現中,如果你 console.dir() 這個例項,會得到以下內容:

【譯】關於 JavaScript 的原型你應該知道的所有事情

注意例項的屬性只有 myNametellMyName 是定義在 __prototype__ 之下。之後我們再討論 __prototype__。更要注意的是,兩個例項的 tellMyName 是相等的。如果它們的引用相同,在 JavaScript 中函式比較就相等。這說明 tellMyName 在多個例項中沒有消耗額外的空間。

我們看看之前的例子:

【譯】關於 JavaScript 的原型你應該知道的所有事情

注意這次 tellMyName 定義為例項的屬性。它不再 __proto__ 下面。同樣的,注意這次比較函式等價的結果是 false。這是因為他們在不同的記憶體位置,並且他們的引用也不相同。

我希望你現在理解了 __prototype 的必要性。

所有的 就JavaScript 函式都有一個 prototype 屬性,是一個 object 型別。你可以在 prototype 下面定義自己的屬性。當你使用函式作為建構函式時,所有的例項將會繼承來自 object 的 prototype

現在我們來看看上面的 __prototype____prototype__ 是原型物件的簡單引用,例項繼承了原型物件。聽起來很複雜?實際上一點都不。我們看一個可見的例子。

考慮下面程式碼。我們已經建立了一個陣列,通過陣列字面量來建立的,它的屬性來自於 Array.prototype

var arr = [1, 2, 3, 4];
複製程式碼

上面我剛剛提到:“__prototype__ 是原型物件的簡單引用,例項繼承了原型物件。”所以,arr.__prototype__ 應該和 Array.prototype 是相同的。來證明看看:

【譯】關於 JavaScript 的原型你應該知道的所有事情

我們不應當用 __proto__ 訪問原型物件。根據 MDN 的參考,__proto__ 是非常不推薦的,並且不是在所以瀏覽器都支援。正確的方法如下:

var arr = [1, 2, 3, 4];
var prototypeOfArr = Object.getPrototypeOf(arr);
prototypeOfArr === Array.prototype;
prototypeOfArr === arr.__proto__;
複製程式碼

【譯】關於 JavaScript 的原型你應該知道的所有事情

上面程式碼片段展示了 __proto__Ojbect.getPrototypeof 返回的東西一樣。

現在來休息一下。喝點咖啡,試試上面的例子。等你準備好了,我們再繼續。

原型鏈和繼承

在上面第二張圖中,注意到在第一個 __proto__ 物件裡有另一個 __proto__ 了嗎?如果沒有回去看看第二張圖。我們現在討論它的實際意義。這就是著名的原型鏈。

在 JavaScript 中,我們通過原型鏈實現繼承。

考慮下面的例子:我們都理解術語“機車”。公共汽車可以被叫做看做機車。小汽車也能被當做機車。公共汽車,小汽車和摩托車都有共同的屬性,這就是為什麼他們能被稱作機車。舉個例子,它們可以從一個地方移動到另一個地方。它們有輪子,有喇叭等等。

當然,公共汽車,小汽車和摩托有不同的型別,比如 Mercedes, BMW,Honda 等等。

【譯】關於 JavaScript 的原型你應該知道的所有事情

上面的圖表中,公共汽車從機車整合了一些屬性。Mercedes Benz 從公共汽車繼承了一些屬性。類似的還有汽車和摩托車。

我們在 JavaScript 中建立這種關係。

首先,為了簡單的緣故我們假定一些觀點:

  1. 公共汽車有 6 個輪子
  2. 公共汽車,小汽車,摩托的加速和剎車不相同,所有的公共汽車,小汽車和摩托都一樣。
  3. 所有的機車都能鳴笛。
function Vehicle(vehicleType) {  // 機車構造
    this.vehicleType = vehicleType;
}
Vehicle.prototype.blowHorn = function () {
    console.log('Honk! Honk! Honk!'); // 所有的機車可以鳴笛
}
function Bus(make) { // 公共汽車構造
  Vehicle.call(this, "Bus");
  this.make = make
}
Bus.prototype = Object.create(Vehicle.prototype); // 使公共汽車繼承來自機車的屬性
Bus.prototype.noOfWheels = 6; // 假設所有的公共汽車有 6 個輪子
Bus.prototype.accelerator = function() {
    console.log('Accelerating Bus'); // 公共汽車加速
}
Bus.prototype.brake = function() {
    console.log('Braking Bus'); // 公共汽車減速
}
function Car(make) {
  Vehicle.call(this, "Car");
  this.make = make;
}
Car.prototype = Object.create(Vehicle.prototype);
Car.prototype.noOfWheels = 4;
Car.prototype.accelerator = function() {
    console.log('Accelerating Car');
}
Car.prototype.brake = function() {
    console.log('Braking Car');
}
function MotorBike(make) {
  Vehicle.call(this, "MotorBike");
  this.make = make;
}
MotorBike.prototype = Object.create(Vehicle.prototype);
MotorBike.prototype.noOfWheels = 2;
MotorBike.prototype.accelerator = function() {
    console.log('Accelerating MotorBike');
}
MotorBike.prototype.brake = function() {
    console.log('Braking MotorBike');
}
var myBus = new Bus('Mercedes');
var myCar = new Car('BMW');
var myMotorBike = new MotorBike('Honda');
複製程式碼

來解釋一下上述程式碼:

我們有個 Vehicle 構造器,它是機車型別。所有的機車都能鳴笛,在 Vehicle 原型上有個 blowHorn 屬性。

作為 Bus,是一種機車,從 Vehicle 物件裡繼承屬性。

我們假設所有的公共汽車有 6 個輪子,同時有加速和剎車程式。所以我們在 Bus 原型上定義有 noOfWheels,acceleratorbrake屬性。

小汽車和摩托車類似。

來 Chrome 開發者工具的 console 裡面執行程式碼。

執行以後,我們得到 3 個物件 myBus, myCarmyMotorBike

輸入 console.dir(mybus),按下回車。點選三角形圖示展開內容,你會看到如下:

【譯】關於 JavaScript 的原型你應該知道的所有事情

myBus 之下,我們有 makevehicleType 屬性。注意 Bus 的原型的 __proto__ 的值。它的原型的所有屬性這裡是可見的:acceleratorbrakenoOfWheels

現在我們看看第一個 __proto__ 物件。這個隨想有另一個 __proto__ 作為它的屬性。

在這之下,我們有 blowHornconstructor 屬性。

Bus.prototype = Object.create(Vehicle.prototype);
複製程式碼

記著這行程式碼嗎? Object.create(Vehicle.prototype) 將會建立一個空物件,這個物件的原型是 Vehicle.prototype。我們設定這個物件作為 Bus 的原型。對於 Vehicle.prototype 我們沒有特殊定義任何原型,所以預設地繼承來自 Object.prototype

我們來看看下面的魔法:

【譯】關於 JavaScript 的原型你應該知道的所有事情

我們可以訪問 make 屬性作為 myBus 自己的屬性。 我們可以訪問 brake 屬性,從 myBus 的原型中。 我們可以訪問 blowHorn 屬性 從 myBus 的原型的原型。 我們可以訪問 hasOwnProperty 屬性 從 myBus 的原型的原型的原型。:)

這叫做原型鏈。無論何時在 JavaScript 中我們訪問一個物件的原型時,它首先檢查是否這個屬性在物件內部。如果不在就檢查它的原型物件。如果在,就會得到這個原型值。否則,它會檢查屬性是否存在原型的原型上,如果也不在,那麼檢查原型的原型的原型,一直如此下去。

那麼這種方式將會檢查多久?如果屬性在任何一個位置被發現或者任何位置上 __proto__ 的值是 null 或者 undefined 的時候就停止。接著會丟擲一個錯誤,告訴你這個查詢的值不存在。

這是在 JavaScript 中通過原型鏈的幫助,繼承是如何運作的。

隨便試試上面的例子,用 myCarmyMotorBike

正如我們知道的,JavaScript 中一切都是物件。在每個例項中你都能找到它,原型鏈結束於 Object.prototype

如果你通過 Objec.create(null) 建立一個物件,上面的規則就是個例外了。

var obj = Object.create(null)
複製程式碼

上面的 obj 程式碼,將是一個空物件,,沒有任何原型。

【譯】關於 JavaScript 的原型你應該知道的所有事情

更多關於 Object.create 的資訊,請查閱 MDN。

你能改變一個已經存在的物件的原型嗎?答案顯而易見,通過 Object.setPrototypeOf() 就可以。具體資訊參考 MDN。

想知道一個屬性是否是物件自己的屬性?你已經知道如何這麼做了。 Object.hasOwnProperty 將會告訴你,是否這個屬性來自物件自己或者來自它的原型鏈。具體資訊參考 MDN。

注意 __proto__ 也作為 [[prototype]] 引用。

現在休息一下。我們將繼續最後一部分內容。

理解 JavaScript 的類

根據 MDN:

JavaScript 類,釋出於 ECMAScript 2015,是一種語法糖,覆蓋了 JavaScript存在的基於原型的繼承。對於 JavaScript 而言,類語法沒有引進新的物件導向繼承。

JavaScript 中的類提供更好的語法去實現我們在上面做的事情,這種語法要更清晰。我們來一睹為快。

class Myclass {
  constructor(name) {
    this.name = name;
  }

  tellMyName() {
    console.log(this.name)
  }
}
const myObj = new Myclass("John");
複製程式碼

constructor 方法是一種特殊的方法。無論何時,你建立了類的例項,它會自動執行。這類裡只可能有一個constructor

你在類裡定義的方法將會移動到原型物件上。

如果你想在例項裡有一些屬性你可以在構造器上定義它,正如我們做的那樣 this.name = name

來看看我們的 myObj

【譯】關於 JavaScript 的原型你應該知道的所有事情

注意我們在例項內部有一個 name 屬性,同時在原型上有一個 tellMyName 方法。

考慮如下程式碼:

class Myclass {
  constructor(firstName) {
    this.name = firstName;
  }

  tellMyName() {
    console.log(this.name)
  }
  lastName = "lewis";
}
const myObj = new Myclass("John");
複製程式碼

我們看看輸出:

【譯】關於 JavaScript 的原型你應該知道的所有事情

lastname 移動到了例項而不是原型。只有方法,那些你宣告在類體裡的方法會移動到原型。儘管這有點意外。

考慮如下程式碼:

class Myclass {
  constructor(firstName) {
    this.name = firstName;
  }

  tellMyName = () => {
    console.log(this.name)
  }
  lastName = "lewis";
}
const myObj = new Myclass("John");
複製程式碼

輸出:

【譯】關於 JavaScript 的原型你應該知道的所有事情

注意 tellMyName 現在是一個箭頭函式,同時它被移動到了例項而不是原型。所以記著箭頭函式會總是移動到例項,請小心使用它們。

我們來看看靜態類屬性:

class Myclass {
  static welcome() {
    console.log("Hello World");
  }
}
Myclass.welcome();
const myObj = new Myclass();
myObj.welcome();
複製程式碼

輸出:

【譯】關於 JavaScript 的原型你應該知道的所有事情

靜態屬性是你可以不用建立類的例項就能訪問的。另一方面,例項也不能訪問類的靜態屬性。

那麼靜態屬性是一個只在類中的新的概念,並且不在舊 JavaScript 中的嗎?不,舊的JavaScript也支援。舊的JavaScript這樣實現靜態類:

function Myclass() {
}
Myclass.welcome = function() {
  console.log("Hello World");
複製程式碼

現在看看如何在類中實現繼承:

class Vehicle {
  constructor(type) {
    this.vehicleType= type;
  }
  blowHorn() {
    console.log("Honk! Honk! Honk!");
  }
}
class Bus extends Vehicle {
  constructor(make) {
    super("Bus");
    this.make = make;
  }
  accelerator() {
    console.log('Accelerating Bus');
  }
  brake() {
    console.log('Braking Bus');
  }
}
Bus.prototype.noOfWheels = 6;
const myBus = new Bus("Mercedes");
複製程式碼

我們繼承其他類使用 extends 關鍵字。

super() 將會簡單地執行父類的構造器。如果你從其他類繼承,同時在子類使用構造器,那麼必須在子類的構造器中呼叫 super(),以免丟擲錯誤。

我們已經知道在類體中,如果定義了不是常規函式的任何屬性,它將被移動到例項中而不是原型鏈。我們我們在 Bus.prototype 上定義 noOfWheel

在類體中,如果你想去執行父類方法,你可以使用 super.parentClassMethod()

輸出:

https://cdn-images-1.medium.com/max/800/1*62igbvXqzZZBvlH7_jNPBw.png
複製程式碼

上面的內容看起來跟我們的第七張圖很像。

總結

那麼你是否應該使用新的類語法或者舊的構造器語法呢?我覺得沒有一定的答案。它取決於你的場景。

這篇文章中,類的部分我已經證明了你可以用原型的方式繼承類。關於 JavaScript 類有更多的東西需要了解,但是超過了本文的範圍。看看 MDN 上關於類的文件。或者以後我會寫一篇關於類的文章。

如果這篇文章幫到了你,那麼點個讚我會很感激。

感謝閱讀 :)

pic

相關文章