前言
原型,作為前端開發者,或多或少都有聽說。你可能一直想了解它,但是由於各種原因還沒有了解,現在就跟隨我來一起探索它吧。本文將由淺入深,一點一點揭開 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
方法,然後生成了兩個物件例項 person1
和 person2
,這兩個例項分別擁有自己的屬性 name
和原型的屬性 age
以及方法 sayName
。所有的例項物件共享原型物件的屬性和方法,那麼看起來,原型物件就像是類,我們就可以用原型來實現繼承了。
2. constructor 與 [[Prototype]]
我們知道每個函式都有一個 prototype 屬性,指向函式的原型,因此當我們拿到一個函式的時候,就可以確定函式的原型。反之,如果給我們一個函式的原型,我們怎麼知道這個原型是屬於哪個函式的呢?這就要說說原型的 constructor 屬性了:
在預設情況下,所有原型物件都會自動獲得一個 constructor (建構函式)屬性,這個屬性包含一個指向 prototype 屬性所在函式的指標。
也就是說每個原型都有都有一個 constructor 屬性,指向了原型所在的函式,拿前面的例子來說 Person.prototype.constructor 指向 Person。下面是建構函式和原型的關係說明圖:
![](https://i.iter01.com/images/7916507aa773af85f18368f95aea0658d43629a3a76deaae159fe96ad3bd8b5a.png)
繼續,讓我們說說 [[prototype]]
。
當我們呼叫建構函式建立一個新的例項(新的物件)之後,比如上面例子中的 person1
,例項的內部會包含一個指標(內部屬性),指向建構函式的原型。ECMA-262 第 5 版中管這個指標叫[[Prototype]]。我們可與更新函式和原型的關係圖:
![](https://i.iter01.com/images/a9de17fce8840c5ad6032cf4001221e1824c25f47c721c3418b959132bb43cfb.png)
不過在指令碼中沒有標準的方式訪問 [[Prototype]] , 但在 Firefox、Safari 和 Chrome 中可以通過 __proto__
屬性訪問。而在其他實現中,這個屬性對指令碼則是完全不可見的。不過,要明確的真正重要的一點就是,這個連線存在於例項與建構函式的原型物件之間,而不是存在於例項與建構函式之間。
在 VSCode 中開啟除錯模式,我們可以看到這些關係:
![](https://i.iter01.com/images/2a8765ce0dc35abc50cef536f82d09d68e0fdb802a5355030d0cd9a1cbe7e22e.png)
從上圖中我們可以看到 Person
的 prototype
屬性和 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複製程式碼
在上面程式碼中,有兩個建構函式 Person
和 Engineer
,可以看做是兩個型別,Engineer
的原型是 Person
的一個例項,也就是說 Engineer
的原型指向了 Person
的原型(注意上面的最後一行程式碼)。然後我們分別新建一個 Person
和 Engineer
的例項物件,可以看到 engineer
例項物件能夠訪問到 Person
的 age
和 weight
屬性,這很好理解:Engineer
的原型是 Person
的例項物件,Person
的例項物件包含了 age
屬性,而 weight
屬性是 Person
原型物件的屬性,Person
的例項物件自然可以訪問原型中的屬性,同理,Engineer
的例項物件 engineer
也能訪問 Engineer
原型上的屬性,間接的也能訪問 Person
原型的屬性。
看起來關係有些複雜,不要緊,我們用一張圖片來解釋這些關係:
![](https://i.iter01.com/images/fecbd37f4352d933175f8de81f958fa6a1305b8b7d4870c41b5d7c7aef0e0b0f.png)
是不是一下就很清楚了,順著圖中紅色的線,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複製程式碼
我們更新一下關係圖:
![](https://i.iter01.com/images/1792b1bda5d2755bb70b627e511ad8b13045ecd797137dd1d8d17043b5539640.png)
至此,一切已經很清楚了,下面我們來說說原型鏈的用處。
繼承
繼承是面嚮物件語言中的一個很常見的概念,在閱讀前面程式碼的過程中,我們其實已經實現了簡單的繼承關係,細心的小夥伴可能已經發現了。在 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複製程式碼
原型鏈雖然很強大,可以實現繼承,但是會存在一些問題:
引用型別的原型屬性會被所有例項共享。
在通過原型鏈來實現繼承時,引用型別的屬性被會所有例項共享,一旦一個例項修改了引用型別的值,會立刻反應到其他例項上。由於基本型別不是共享的,所以彼此不會影響。建立子型別的例項時,不能向父型別的建構函式傳遞引數。
實際上,應該說是沒有辦法在不影響所有物件例項的情況下,給父型別的建構函式傳遞引數,我們傳遞的引數會成為所有例項的屬性。
基於上面兩個問題,實踐中很少單獨使用原型鏈實現繼承。
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);
};複製程式碼
所謂寄生組合式繼承,即通過借用建構函式來繼承屬性,通過借用臨時建構函式來繼承原型。其背後的基本思路是:不必為了指定子型別的原型而呼叫父型別的建構函式,我們所需要的無非就是父型別原型的一個副本而已。
參考
- 《JavaScript 高階程式設計》
- Javascript繼承機制的設計思想