深入理解 JavaScript 原型

無亦情發表於2017-11-14

前言

原型,作為前端開發者,或多或少都有聽說。你可能一直想了解它,但是由於各種原因還沒有了解,現在就跟隨我來一起探索它吧。本文將由淺入深,一點一點揭開 JavaScript 原型的神祕面紗。(需要了解基本的 JavaScript 物件知識)

原始碼:GitHub

原型

1. 原型是什麼?

在我們深入探索之前,當然要先了解原型是什麼了,不然一切都無從談起。談起原型,那得先從物件說起,且讓我們慢慢說起。

我們都知道,JavaScript 是一門基於物件的指令碼語言,但是它卻沒有類的概念,所以 JavaScript 中的物件和基於類的語言(如 Java)中的物件有所不同。JavaScript 中的物件是無序屬性的集合,其屬性可以包含基本值,物件或者函式,聽起來更像是鍵值對的集合,事實上也比較類似。有了物件,按理說得有繼承,不然物件之間沒有任何聯絡,也就真淪為鍵值對的集合了。那沒有類的 JavaScript 是怎麼實現繼承的呢?

我們知道,在 JavaScript 中可以使用建構函式語法(通過 new 呼叫的函式通常被稱為建構函式)來建立一個新的物件,像下面這樣:

// 建構函式,無返回值
function Person(name) {
  this.name = name;
}
// 通過 new 新建一個物件
var person = new Person('Mike');複製程式碼

這和一般物件導向程式語言中建立物件(Java 或 C++)的語法很類似,只不過是一種簡化的設計,new 後面跟的不是類,而是建構函式。這裡的建構函式可以看做是一種型別,就像物件導向程式語言中的類,但是這樣建立的物件除了屬性一樣外,並沒有其他的任何聯絡,物件之間無法共享屬性和方法。每當我們新建一個物件時,都會方法和屬性分配一塊新的記憶體,這是極大的資源浪費。考慮到這一點,JavaScript 的設計者 Brendan Eich 決定為建構函式設定一個屬性。這個屬性指向一個物件,所有例項物件需要共享的屬性和方法,都放在這個物件裡面,那些不需要共享的屬性和方法,就放在建構函式裡面。例項物件一旦建立,將自動引用這個物件的屬性和方法。也就是說,例項物件的屬性和方法,分成兩種,一種是本地的,不共享的,另一種是引用的,共享的。這個物件就是原型(prototype)物件,簡稱為原型。

我們通過函式宣告或函式表示式建立的函式都有一個 prototype(原型)屬性,這個屬性是一個指標,指向一個物件,這個物件就是呼叫建構函式而建立的物件例項的原型。特別的,在 ECMA-262 規範中,通過 Function.prototype.bind 建立的函式沒有prototype屬性。原型可以包含所有例項共享的屬性和方法,也就是說只要是原型有的屬性和方法,通過呼叫建構函式而生成的物件例項都會擁有這些屬性和方法。看下面的程式碼:

function Person(name) {
  this.name = name;
}

Person.prototype.age = '20';
Person.prototype.sayName = function() {
  console.log(this.name);
}

var person1 = new Person('Jack');
var person2 = new Person('Mike');

person1.sayName(); // Jack
person2.sayName(); // Mike
console.log(person1.age); // 20
console.log(person2.age); // 20複製程式碼

這段程式碼中我們宣告瞭一個 Person 函式,並在這個函式的原型上新增了 age 屬性和 sayName 方法,然後生成了兩個物件例項 person1person2,這兩個例項分別擁有自己的屬性 name 和原型的屬性 age 以及方法 sayName。所有的例項物件共享原型物件的屬性和方法,那麼看起來,原型物件就像是類,我們就可以用原型來實現繼承了。

2. constructor 與 [[Prototype]]

我們知道每個函式都有一個 prototype 屬性,指向函式的原型,因此當我們拿到一個函式的時候,就可以確定函式的原型。反之,如果給我們一個函式的原型,我們怎麼知道這個原型是屬於哪個函式的呢?這就要說說原型的 constructor 屬性了:

在預設情況下,所有原型物件都會自動獲得一個 constructor (建構函式)屬性,這個屬性包含一個指向 prototype 屬性所在函式的指標。

也就是說每個原型都有都有一個 constructor 屬性,指向了原型所在的函式,拿前面的例子來說 Person.prototype.constructor 指向 Person。下面是建構函式和原型的關係說明圖:

繼續,讓我們說說 [[prototype]]

當我們呼叫建構函式建立一個新的例項(新的物件)之後,比如上面例子中的 person1,例項的內部會包含一個指標(內部屬性),指向建構函式的原型。ECMA-262 第 5 版中管這個指標叫[[Prototype]]。我們可與更新函式和原型的關係圖:

不過在指令碼中沒有標準的方式訪問 [[Prototype]] , 但在 Firefox、Safari 和 Chrome 中可以通過 __proto__屬性訪問。而在其他實現中,這個屬性對指令碼則是完全不可見的。不過,要明確的真正重要的一點就是,這個連線存在於例項與建構函式的原型物件之間,而不是存在於例項與建構函式之間。

在 VSCode 中開啟除錯模式,我們可以看到這些關係:

從上圖中我們可以看到 Personprototype 屬性和 person1__proto__ 屬性是完全一致的,Person.prototype 包含了一個 constructor 屬性,指向了 Person 函式。這些可以很好的印證我們上面所說的建構函式、原型、constructor 以及 __proto__ 之間的關係。

3. 物件例項與原型

瞭解完建構函式,原型,物件例項之間的關係後,下面我們來深入探討一下物件和原型之間的關係。

1. 判斷物件例項和原型之間的關係

因為我們無法直接訪問例項物件的 __proto__ 屬性,所以當我們想要確定一個物件例項和某個原型之間是否存在關係時,可能會有些困難,好在我們有一些方法可以判斷。

我們可以通過 isPrototypeOf() 方法判斷某個原型和物件例項是否存在關係,或者,我們也可以使用 ES5 新增的方法 Object.getPrototypeOf() 獲取一個物件例項 __proto__ 屬性的值。看下面的例子:

console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true複製程式碼

2. 物件例項屬性和方法的獲取

每當程式碼讀取某個物件的某個屬性時,都會執行一次搜尋,目標是具有給定名字的屬性。搜尋首先從物件例項本身開始。如果在例項物件中找到了具有給定名字的屬性,則返回該屬性的值。如果沒有找到,則繼續搜尋 __proto__ 指標指向的原型物件,在原型物件中查詢具有給定名字的屬性,如果在原型物件中找到了這個屬性,則返回該屬性的值。如果還找不到,就會接著查詢原型的原型,直到最頂層為止。這正是多個物件例項共享原型所儲存的屬性和方法的基本原理。

雖然可以通過物件例項訪問儲存在原型中的值,但卻不能通過物件例項重寫原型中的值。我們在例項中新增的一個屬性,會遮蔽原型中的同名的可寫屬性,如果屬性是隻讀的,嚴格模式下會觸發錯誤,非嚴格模式下則無法遮蔽。另外,通過 hasOwnProperty 方法能判斷物件例項中是否存在某個屬性(不能判斷物件原型中是否存在該屬性)。來看下面的例子:

function Person() {}

Person.prototype.name = 'Nicholas';
Person.prototype.age = 29;
Person.prototype.job = 'Software Engineer';
Person.prototype.sayName = function () {
  console.log(this.name);
};

var person1 = new Person();
var person2 = new Person();

// 設定 phone 屬性為不可寫
Object.defineProperty(person1, 'phone', {
  writable: false,
  value: '100'
});

// 新增一個訪問器屬性 address
Object.defineProperty(person1, 'address', {
  set: function(value) {
    console.log('set');
    address = value;
  },
  get: function() {
    return address;
  }
});

// 注意,此處不能用 name,因為函式本身存在 name 屬性
console.log(person1.hasOwnProperty('age')); // false
console.log(Person.hasOwnProperty('age')); // false

person1.name = 'Greg';
console.log(person1.hasOwnProperty('name')); // true
console.log(person1.name); //'Greg'——來自例項
console.log(person2.name); //'Nicholas'——來自原型

person1.phone = '123'; // 嚴格模式下報錯
person1.address = 'china hua'; // 呼叫 set 方法,輸出 'set'
console.log(person1.address); // 'china hua'
console.log(person1.phone); // 100複製程式碼

3. in 操作符

有兩種方式使用 in 操作符:

  • 單獨使用

    在單獨使用時,in 操作符會在通過物件能夠訪問給定屬性時返回 true,無論該屬性存在於例項中還是原型中。

  • for-in 迴圈中使用。

    在使用 for-in 迴圈時,返回的是所有能夠通過物件訪問的、可列舉的(enumerated)屬性,其中既包括存在於例項中的屬性, 也包括存在於原型中的屬性。如果需要獲取所有的屬性(包括不可列舉的屬性),可以使用 Object.getOwnPropertyNames() 方法。

看下面的例子:

function Person(){
  this.name = 'Mike';
}

Person.prototype.age = 29;
Person.prototype.job = 'Software Engineer';
Person.prototype.sayName = function(){ console.log(this.name); };

var person = new Person();

for(var item in person) {
  console.log(item); // name age job sayName
}

console.log('name' in person); // true - 來自例項
console.log('age' in person); //  true - 來自原型複製程式碼

4. 原型的動態性

由於在物件中查詢屬性的過程是一次搜尋,而例項與原型之間的連線只不過是一個指標,而非一個副本,因此我們對原型物件所做的任何修改都能夠立即從例項上反映出來——即使是先建立了例項後修改原型也照樣如此:

var person = new Person();

Person.prototype.sayHi = function(){ console.log("hi"); };
person.sayHi(); // "hi"複製程式碼

上面的程式碼中,先建立了 Person 的一個例項,並將其儲存在 person 中。然後,下一條語句在 Person.prototype 中新增了一個方法 sayHi()。即使 person 例項是在新增新方法之前建立的,但它仍然可以訪問這個新方法。在呼叫這個方法時,首先會查詢 person 例項中是否有這個方法,發現沒有,然後到 person 的原型物件中查詢,原型中存在這個方法,查詢結束。;

但是下面這種程式碼所得到的結果就完全不一樣了:

function Person() {}

var person = new Person();

Person.prototype = {
  constructor: Person,
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName: function () {
    console.log(this.name);
  }
};

person.sayName(); // error複製程式碼

仔細觀察上面的程式碼,我們直接用物件字面量語法給 Person.prototype 賦值,這似乎沒有什麼問題。但是我們要知道字面量語法會生成一個新的物件,也就是說這裡的 Person.prototype 是一個新的物件,和 person__proto__ 屬性不再有任何關係了。此時,我們再嘗試呼叫 sayName 方法就會報錯,因為 person__proto__ 屬性指向的還是原來的原型物件,而原來的原型物件上並沒有 sayName 方法,所以就會報錯。

原型鏈

1. 原型的原型

在前面的例子,我們是直接在原型上新增屬性和方法,或者用一個新的物件賦值給原型,那麼如果我們讓原型物件等於另一個型別的例項,結果會怎樣呢?

function Person() {
  this.age = '20';
}

Person.prototype.weight = '120';

function Engineer() {
  this.work = 'Front-End';
}

Engineer.prototype = new Person(); // 此時 Engineer.prototype 沒有 constructor 屬性
Engineer.prototype.constructor = Engineer;

Engineer.prototype.getAge = function() {
  console.log(this.age);
}

var person = new Person();
var engineer = new Engineer();

console.log(person.age); // 20
engineer.getAge(); // 20
console.log(engineer.weight); // 120
console.log(Engineer.prototype.__proto__ == Person.prototype); // true複製程式碼

在上面程式碼中,有兩個建構函式 PersonEngineer,可以看做是兩個型別,Engineer 的原型是 Person 的一個例項,也就是說 Engineer 的原型指向了 Person 的原型(注意上面的最後一行程式碼)。然後我們分別新建一個 PersonEngineer 的例項物件,可以看到 engineer 例項物件能夠訪問到 Personageweight 屬性,這很好理解:Engineer 的原型是 Person 的例項物件,Person 的例項物件包含了 age 屬性,而 weight 屬性是 Person 原型物件的屬性,Person 的例項物件自然可以訪問原型中的屬性,同理,Engineer 的例項物件 engineer 也能訪問 Engineer 原型上的屬性,間接的也能訪問 Person 原型的屬性。

看起來關係有些複雜,不要緊,我們用一張圖片來解釋這些關係:

是不是一下就很清楚了,順著圖中紅色的線,engineer 例項物件可以順利的獲取 Person 例項的屬性以及 Person 原型的屬性。至此,已經鋪墊的差不多了,我們理解了原型的原型之後,也就很容易理解原型鏈了。

2. 原型鏈

原型鏈其實不難理解,上圖中的紅色線組成的鏈就可以稱之為原型鏈,只不過這是一個不完整的原型鏈。我們可以這樣定義原型鏈:

原型物件可以包含一個指向另一個原型(原型2)的指標,相應地,另一個原型(原型2)中也可以包含著一個指向對應建構函式(原型2 的建構函式)的指標。假如另一個原型(原型2)又是另一個型別(原型3 的建構函式)的例項,那麼上述關係依然成立,如此層層遞進,就構成了例項與原型的鏈條。這就是所謂原型鏈的基本概念。

結合上面的圖,這個概念不難理解。上面的圖中只有兩個原型,那麼當有更多的原型之後,這個紅色的線理論上可以無限延伸,也就構成了原型鏈。

通過實現原型鏈,本質上擴充套件了前面提到過的原型搜尋機制:當以讀取模式訪問一個例項的屬性時,首先會在例項中搜尋該屬性。如果沒有找到該屬性,則會繼續搜尋例項的原型。在通過原型鏈實現繼承的情況下,搜尋過程就得以沿著原型鏈繼續向上。在找不到屬性或方法的情況下,搜尋過程總是要一環一環地前行到原型鏈末端才會停下來。

那麼原型鏈的末端又是什麼呢?我們要知道,所有函式的 預設原型 都是 Object 的例項,因此預設原型都會包含一個內部指標,指向 Object.prototype。我們可以在上面程式碼的尾部加上一行程式碼進行驗證:

console.log(Person.prototype.__proto__ == Object.prototype); // true複製程式碼

Object.prototype 的原型又是什麼呢,不可能沒有終點啊?聰明的小夥伴可能已經猜到了,沒錯,就是 null,null 表示此處不應該有值,也就是終點了。我們可以在 Chrome 的控制檯或 Node 中驗證一下:

console.log(Object.prototype.__proto__); // null複製程式碼

我們更新一下關係圖:

至此,一切已經很清楚了,下面我們來說說原型鏈的用處。

繼承

繼承是面嚮物件語言中的一個很常見的概念,在閱讀前面程式碼的過程中,我們其實已經實現了簡單的繼承關係,細心的小夥伴可能已經發現了。在 JavaScript 中,實現繼承主要是依靠原型鏈來實現的。

1. 原型鏈實現

一個簡的基於原型鏈的繼承實現看起來是這樣的:

// 父型別
function Super(){
    this.flag = 'super';
}

Super.prototype.getFlag = function(){
    return this.flag;
}
// 子型別
function Sub(){
    this.subFlag = 'sub';
}
// 實現繼承
Sub.prototype = new Super();
Sub.prototype.getSubFlag = function(){
    return this.subFlag;
}

var instance = new Sub();

console.log(instance.subFlag); // sub
console.log(instance.flag); // super複製程式碼

原型鏈雖然很強大,可以實現繼承,但是會存在一些問題:

  1. 引用型別的原型屬性會被所有例項共享。
    在通過原型鏈來實現繼承時,引用型別的屬性被會所有例項共享,一旦一個例項修改了引用型別的值,會立刻反應到其他例項上。由於基本型別不是共享的,所以彼此不會影響。

  2. 建立子型別的例項時,不能向父型別的建構函式傳遞引數。
    實際上,應該說是沒有辦法在不影響所有物件例項的情況下,給父型別的建構函式傳遞引數,我們傳遞的引數會成為所有例項的屬性。

基於上面兩個問題,實踐中很少單獨使用原型鏈實現繼承。

2. 借用建構函式

為了解決上面出現的問題,出現了一種叫做 借用建構函式的技術。這種技術的基本思想很簡單:apply()call() 方法,在子型別建構函式的內部呼叫父型別的建構函式,使得子型別擁有父型別的屬性和方法。

function Super(properties){
  this.properties = [].concat(properties);
  this.colors = ['red', 'blue', 'green'];
}

function Sub(properties){
  // 繼承了 Super,傳遞引數,互不影響
  Super.apply(this, properties);
}

var instance1 = new Sub(['instance1']);
instance1.colors.push('black');
console.log(instance1.colors); // 'red, blue, green, black'
console.log(instance1.properties[0]); // 'instance1'

var instance2 = new Sub();
console.log(instance2.colors); // 'red, blue, green'
console.log(instance2.properties[0]); // 'undefined'複製程式碼

借用建構函式的確可以解決上面提到的兩個問題,例項間不會共享屬性,也可以向父型別傳遞引數,但是這種方法任然存在一些問題:子型別無法繼承父型別原型中的屬性。我們只在子型別的建構函式中呼叫了父型別的建構函式,沒有做其他的,子型別和父型別的原型也就沒有任何聯絡。考慮到這個問題,借用建構函式的技術也是很少單獨使用的。

3. 組合繼承

上面兩個方法能夠互補彼此的不足之處,我們把這兩個方法結合起來,就能比較完美的解決問題了,這就是組合繼承。其背後的思路是使用原型鏈實現對原型屬性和方法的繼承,而通過借用建構函式來實現對例項屬性的繼承。這樣,既通過在原型上定義方法實現了函式複用,又能夠保證每個例項都有它自己的屬性,從而發揮二者之長。看一個簡單的實現:

function Super(properties){
  this.properties = [].concat(properties);
  this.colors = ['red', 'blue', 'green'];
}

Super.prototype.log = function() {
  console.log(this.properties[0]);
}

function Sub(properties){
  // 繼承了 Super,傳遞引數,互不影響
  Super.apply(this, properties);
}
// 繼承了父型別的原型
Sub.prototype = new Super();
// isPrototypeOf() 和 instance 能正常使用
Sub.prototype.constructor = Sub;

var instance1 = new Sub(['instance1']);
instance1.colors.push('black');
console.log(instance1.colors); // 'red,blue,green,black'
instance1.log(); // 'instance1'

var instance2 = new Sub();
console.log(instance2.colors); // 'red,blue,green'
instance2.log(); // 'undefined'複製程式碼

組合繼承避免了原型鏈和借用建構函式的缺陷,融合了它們的優點,是 JavaScript 中最常用的繼承模式。組合繼承看起來很不錯,但是也有它的缺點:無論什麼情況下,組合繼承都會呼叫兩次父型別的建構函式:一次是在建立子型別原型的時候,另一次是在子型別建構函式內部。

4. 寄生組合式繼承

為了解決上面組合繼承的問題,一種新的繼承方式出現了-寄生組合繼承,可以說是 JavaScript 中繼承最理想的解決方案。

// 用於繼承的函式
function inheritPrototype(child, parent) {
  var F = function () {}
  F.prototype = parent.prototype;
  child.prototype = new F();
  child.prototype.constructor = child;
}
// 父型別
function Super(name) {
  this.name = name;
  this.colors = ["red", "blue", "green"];
}

Super.prototype.sayName = function () {
  console.log(this.name);
};
// 子型別
function Sub(name, age) {
  // 繼承基本屬性和方法
  SuperType.call(this, name);
  this.age = age;
}

// 繼承原型上的屬性和方法
inheritPrototype(Sub, Spuer);

Sub.prototype.log = function () {
  console.log(this.age);
};複製程式碼

所謂寄生組合式繼承,即通過借用建構函式來繼承屬性,通過借用臨時建構函式來繼承原型。其背後的基本思路是:不必為了指定子型別的原型而呼叫父型別的建構函式,我們所需要的無非就是父型別原型的一個副本而已。

參考

  1. 《JavaScript 高階程式設計》
  2. Javascript繼承機制的設計思想

相關文章