原型,做爲前端開發者,或多或少都有據說。你可能一直想了解它,可是因爲各類緣由尚未了解,如今就跟隨我來一塊兒探索它吧。本文將由淺入深,一點一點揭開 JavaScript 原型的神祕面紗。(須要瞭解基本的 JavaScript 對象知識)javascript
源代碼:GitHubhtml
在咱們深刻探索以前,固然要先了解原型是什麼了,否則一切都無從談起。談起原型,那得先從對象提及,且讓咱們慢慢提及。前端
咱們都知道,JavaScript 是一門基於對象的腳本語言,可是它卻沒有類的概念,因此 JavaScript 中的對象和基於類的語言(如 Java)中的對象有所不一樣。JavaScript 中的對象是無序屬性的集合,其屬性能夠包含基本值,對象或者函數,聽起來更像是鍵值對的集合,事實上也比較相似。有了對象,按理說得有繼承,否則對象之間沒有任何聯繫,也就真淪爲鍵值對的集合了。那沒有類的 JavaScript 是怎麼實現繼承的呢?java
咱們知道,在 JavaScript 中可使用構造函數語法(經過 new 調用的函數一般被稱爲構造函數)來建立一個新的對象,像下面這樣:git
// 構造函數,無返回值
function Person(name) {
this.name = name;
}
// 經過 new 新建一個對象
var person = new Person('Mike');複製代碼
這和通常面向對象編程語言中建立對象(Java 或 C++)的語法很相似,只不過是一種簡化的設計,new
後面跟的不是類,而是構造函數。這裏的構造函數能夠看作是一種類型,就像面向對象編程語言中的類,可是這樣建立的對象除了屬性同樣外,並無其餘的任何聯繫,對象之間沒法共享屬性和方法。每當咱們新建一個對象時,都會方法和屬性分配一塊新的內存,這是極大的資源浪費。考慮到這一點,JavaScript 的設計者 Brendan Eich 決定爲構造函數設置一個屬性。這個屬性指向一個對象,全部實例對象須要共享的屬性和方法,都放在這個對象裏面,那些不須要共享的屬性和方法,就放在構造函數裏面。實例對象一旦建立,將自動引用這個對象的屬性和方法。也就是說,實例對象的屬性和方法,分紅兩種,一種是本地的,不共享的,另外一種是引用的,共享的。這個對象就是原型(prototype)對象,簡稱爲原型。github
咱們經過函數聲明或函數表達式建立的函數都有一個 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
。全部的實例對象共享原型對象的屬性和方法,那麼看起來,原型對象就像是類,咱們就能夠用原型來實現繼承了。app
咱們知道每一個函數都有一個 prototype 屬性,指向函數的原型,所以當咱們拿到一個函數的時候,就能夠肯定函數的原型。反之,若是給咱們一個函數的原型,咱們怎麼知道這個原型是屬於哪一個函數的呢?這就要說說原型的 constructor 屬性了:編程語言
在默認狀況下,全部原型對象都會自動得到一個 constructor (構造函數)屬性,這個屬性包含一個指向 prototype 屬性所在函數的指針。ide
也就是說每一個原型都有都有一個 constructor 屬性,指向了原型所在的函數,拿前面的例子來講 Person.prototype.constructor 指向 Person。下面是構造函數和原型的關係說明圖:
繼續,讓咱們說說 [[prototype]]
。
當咱們調用構造函數建立一個新的實例(新的對象)以後,好比上面例子中的 person1
,實例的內部會包含一個指針(內部屬性),指向構造函數的原型。ECMA-262 第 5 版中管這個指針叫[[Prototype]]。咱們可與更新函數和原型的關係圖:
不過在腳本中沒有標準的方式訪問 [[Prototype]] , 但在 Firefox、Safari 和 Chrome 中能夠經過 __proto__
屬性訪問。而在其餘實現中,這個屬性對腳本則是徹底不可見的。不過,要明確的真正重要的一點就是,這個鏈接存在於實例與構造函數的原型對象之間,而不是存在於實例與構造函數之間。
在 VSCode 中開啓調試模式,咱們能夠看到這些關係:
從上圖中咱們能夠看到 Person
的 prototype
屬性和 person1
的 __proto__
屬性是徹底一致的,Person.prototype
包含了一個 constructor
屬性,指向了 Person
函數。這些能夠很好的印證咱們上面所說的構造函數、原型、constructor
以及 __proto__
之間的關係。
瞭解完構造函數,原型,對象實例之間的關係後,下面咱們來深刻探討一下對象和原型之間的關係。
由於咱們沒法直接訪問實例對象的 __proto__
屬性,因此當咱們想要肯定一個對象實例和某個原型之間是否存在關係時,可能會有些困難,好在咱們有一些方法能夠判斷。
咱們能夠經過 isPrototypeOf()
方法判斷某個原型和對象實例是否存在關係,或者,咱們也可使用 ES5 新增的方法 Object.getPrototypeOf()
獲取一個對象實例 __proto__
屬性的值。看下面的例子:
console.log(Person.prototype.isPrototypeOf(person1)); // true
console.log(Object.getPrototypeOf(person1) == Person.prototype); // true複製代碼
每當代碼讀取某個對象的某個屬性時,都會執行一次搜索,目標是具備給定名字的屬性。搜索首先從對象實例自己開始。若是在實例對象中找到了具備給定名字的屬性,則返回該屬性的值。若是沒有找到,則繼續搜索 __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複製代碼
有兩種方式使用 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 - 來自原型複製代碼
因爲在對象中查找屬性的過程是一次搜索,而實例與原型之間的鏈接只不過是一個指針,而非一個副本,所以咱們對原型對象所作的任何修改都可以當即從實例上反映出來——即便是先建立了實例後修改原型也照樣如此:
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
方法,因此就會報錯。
在前面的例子,咱們是直接在原型上添加屬性和方法,或者用一個新的對象賦值給原型,那麼若是咱們讓原型對象等於另外一個類型的實例,結果會怎樣呢?
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
原型的屬性。
看起來關係有些複雜,沒關係,咱們用一張圖片來解釋這些關係:
是否是一下就很清楚了,順着圖中紅色的線,engineer
實例對象能夠順利的獲取 Person
實例的屬性以及 Person
原型的屬性。至此,已經鋪墊的差很少了,咱們理解了原型的原型以後,也就很容易理解原型鏈了。
原型鏈其實不難理解,上圖中的紅色線組成的鏈就能夠稱之爲原型鏈,只不過這是一個不完整的原型鏈。咱們能夠這樣定義原型鏈:
原型對象能夠包含一個指向另外一個原型(原型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 中,實現繼承主要是依靠原型鏈來實現的。
一個簡的基於原型鏈的繼承實現看起來是這樣的:
// 父類型
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複製代碼
原型鏈雖然很強大,能夠實現繼承,可是會存在一些問題:
引用類型的原型屬性會被全部實例共享。
在經過原型鏈來實現繼承時,引用類型的屬性被會全部實例共享,一旦一個實例修改了引用類型的值,會馬上反應到其餘實例上。因爲基本類型不是共享的,因此彼此不會影響。
建立子類型的實例時,不能向父類型的構造函數傳遞參數。
實際上,應該說是沒有辦法在不影響全部對象實例的狀況下,給父類型的構造函數傳遞參數,咱們傳遞的參數會成爲全部實例的屬性。
基於上面兩個問題,實踐中不多單獨使用原型鏈實現繼承。
爲了解決上面出現的問題,出現了一種叫作 借用構造函數的技術
。這種技術的基本思想很簡單: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'複製代碼
借用構造函數的確能夠解決上面提到的兩個問題,實例間不會共享屬性,也能夠向父類型傳遞參數,可是這種方法任然存在一些問題:子類型沒法繼承父類型原型中的屬性。咱們只在子類型的構造函數中調用了父類型的構造函數,沒有作其餘的,子類型和父類型的原型也就沒有任何聯繫。考慮到這個問題,借用構造函數的技術也是不多單獨使用的。
上面兩個方法可以互補彼此的不足之處,咱們把這兩個方法結合起來,就能比較完美的解決問題了,這就是組合繼承。其背後的思路是使用原型鏈實現對原型屬性和方法的繼承,而經過借用構造函數來實現對實例屬性的繼承。這樣,既經過在原型上定義方法實現了函數複用,又可以保證每一個實例都有它本身的屬性,從而發揮兩者之長。看一個簡單的實現:
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 中最經常使用的繼承模式。組合繼承看起來很不錯,可是也有它的缺點:不管什麼狀況下,組合繼承都會調用兩次父類型的構造函數:一次是在建立子類型原型的時候,另外一次是在子類型構造函數內部。
爲了解決上面組合繼承的問題,一種新的繼承方式出現了-寄生組合繼承,能夠說是 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);
};複製代碼
所謂寄生組合式繼承,即經過借用構造函數來繼承屬性,經過借用臨時構造函數來繼承原型。其背後的基本思路是:沒必要爲了指定子類型的原型而調用父類型的構造函數,咱們所須要的無非就是父類型原型的一個副本而已。