深刻理解 JavaScript 原型

前言

原型,做爲前端開發者,或多或少都有據說。你可能一直想了解它,可是因爲各類緣由尚未了解,如今就跟隨我來一塊兒探索它吧。本文將由淺入深,一點一點揭開 JavaScript 原型的神祕面紗。(須要瞭解基本的 JavaScript 對象知識)javascript

源代碼:GitHubhtml

原型

1. 原型是什麼?

在咱們深刻探索以前,固然要先了解原型是什麼了,否則一切都無從談起。談起原型,那得先從對象提及,且讓咱們慢慢提及。前端

咱們都知道,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 方法,而後生成了兩個對象實例 person1person2,這兩個實例分別擁有本身的屬性 name 和原型的屬性 age 以及方法 sayName。全部的實例對象共享原型對象的屬性和方法,那麼看起來,原型對象就像是類,咱們就能夠用原型來實現繼承了。app

2. constructor 與 [[Prototype]]

咱們知道每一個函數都有一個 prototype 屬性,指向函數的原型,所以當咱們拿到一個函數的時候,就能夠肯定函數的原型。反之,若是給咱們一個函數的原型,咱們怎麼知道這個原型是屬於哪一個函數的呢?這就要說說原型的 constructor 屬性了:編程語言

在默認狀況下,全部原型對象都會自動得到一個 constructor (構造函數)屬性,這個屬性包含一個指向 prototype 屬性所在函數的指針。ide

也就是說每一個原型都有都有一個 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繼承機制的設計思想
相關文章
相關標籤/搜索