深刻解讀JavaScript面向對象編程實踐

面向對象編程是用抽象方式建立基於現實世界模型的一種編程模式,主要包括模塊化、多態、和封裝幾種技術。編程


對JavaScript而言,其核心是支持面向對象的,同時它也提供了強大靈活的基於原型的面向對象編程能力。瀏覽器


本文將會深刻的探討有關使用JavaScript進行面向對象編程的一些核心基礎知識,包括對象的建立,繼承機制,最後還會簡要的介紹如何藉助ES6提供的新的類機制重寫傳統的JavaScript面向對象代碼。框架


面向對象的幾個概念模塊化


在進入正題前,先了解傳統的面向對象編程(例如Java)中常會涉及到的概念,大體能夠包括:函數


  • 類:定義對象的特徵。它是對象的屬性和方法的模板定義。工具

  • 對象(或稱實例):類的一個實例。性能

  • 屬性:對象的特徵,好比顏色、尺寸等。this

  • 方法:對象的行爲,好比行走、說話等。spa

  • 構造函數:對象初始化的瞬間被調用的方法。prototype

  • 繼承:子類能夠繼承父類的特徵。例如,貓繼承了動物的通常特性。

  • 封裝:一種把數據和相關的方法綁定在一塊兒使用的方法。

  • 抽象:結合複雜的繼承、方法、屬性的對象可以模擬現實的模型。

  • 多態:不一樣的類能夠定義相同的方法或屬性。


在JavaScript的面向對象編程中大致也包括這些。不過在稱呼上可能稍有不一樣,例如,JavaScript中沒有原生的「類」的概念,而只有對象的概念。所以,隨着你認識的深刻,咱們會混用對象、實例、構造函數等概念。


對象(類)的建立


在JavaScript中,咱們一般可使用構造函數來建立特定類型的對象。諸如Object和Array這樣的原生構造函數,在運行時會自動出如今執行環境中。


此外,咱們也能夠建立自定義的構造函數。例如:


function Person(name, age, job) {

  this.name = name;

  this.age = age;

  this.job = job;

}

 

var person1 = new Person('Weiwei', 27, 'Student');

var person2 = new Person('Lily', 25, 'Doctor');


按照慣例,構造函數始終都應該以一個大寫字母開頭(和Java中定義的類同樣),普通函數則小寫字母開頭。


要建立Person的新實例,必須使用new操做符。以這種方式調用構造函數實際上會經歷如下4個步驟:


  1. 建立一個新對象(實例)

  2. 將構造函數的做用域賦給新對象(也就是重設了this的指向,this就指向了這個新對象)

  3. 執行構造函數中的代碼(爲這個新對象添加屬性)

  4. 返回新對象


有關new操做符的更多內容請參考這篇文檔(https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/new)。


在上面的例子中,咱們建立了Person的兩個實例person1和person2。


這兩個對象默認都有一個constructor屬性,該屬性指向它們的構造函數Person,也就是說:


console.log(person1.constructor == Person);  //true

console.log(person2.constructor == Person);  //true


自定義對象的類型檢測


咱們可使用instanceof操做符進行類型檢測。咱們建立的全部對象既是Object的實例,同時也是Person的實例。


由於全部的對象都繼承自Object。


console.log(person1 instanceof Object);  //true

console.log(person1 instanceof Person);  //true

console.log(person2 instanceof Object);  //true

console.log(person2 instanceof Person);  //true


構造函數的問題


咱們不建議在構造函數中直接定義方法,若是這樣作的話,每一個方法都要在每一個實例上從新建立一遍,這將很是損耗性能。——不要忘了,ECMAScript中的函數是對象,每定義一個函數,也就實例化了一個對象。


幸運的是,在ECMAScript中,咱們能夠藉助原型對象來解決這個問題。


藉助原型模式定義對象的方法


咱們建立的每一個函數都有一個prototype屬性,這個屬性是一個指針,指向該函數的原型對象,該對象包含了由特定類型的全部實例共享的屬性和方法。也就是說,咱們能夠利用原型對象來讓全部對象實例共享它所包含的屬性和方法。


function Person(name, age, job) {

  this.name = name;

  this.age = age;

  this.job = job;

}

 

// 經過原型模式來添加全部實例共享的方法

// sayName() 方法將會被Person的全部實例共享,而避免了重複建立

Person.prototype.sayName = function () {

  console.log(this.name);

};

 

var person1 = new Person('Weiwei', 27, 'Student');

var person2 = new Person('Lily', 25, 'Doctor');

 

console.log(person1.sayName === person2.sayName); // true

 

person1.sayName(); // Weiwei

person2.sayName(); // Lily


正如上面的代碼所示,經過原型模式定義的方法sayName()爲全部的實例所共享。也就是,person1和person2訪問的是同一個sayName()函數。一樣的,公共屬性也可使用原型模式進行定義。例如:


function Chinese (name) {

    this.name = name;

}

 

Chinese.prototype.country = 'China'; // 公共屬性,全部實例共享


原型對象


如今咱們來深刻的理解一下什麼是原型對象。


只要建立了一個新函數,就會根據一組特定的規則爲該函數建立一個prototype屬性,這個屬性指向函數的原型對象。


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


也就是說:Person.prototype.constructor指向Person構造函數。


建立了自定義的構造函數以後,其原型對象默認只會取得constructor屬性;至於其餘方法,則都是從Object繼承而來的。


當調用構造函數建立一個新實例後,該實例內部將包含一個指針(內部屬性),指向構造函數的原型對象。ES5中稱這個指針爲[[Prototype]],在Firefox、Safari和Chrome在每一個對象上都支持一個屬性__proto__(目前已被廢棄);而在其餘實現中,這個屬性對腳本則是徹底不可見的。要注意,這個連接存在於實例與構造函數的原型對象之間,而不是實例與構造函數之間。


這三者關係的示意圖以下:



上圖展現了Person構造函數、Person的原型對象以及Person現有的兩個實例之間的關係。


  • Person.prototype指向了原型對象

  • Person.prototype.constructor又指回了Person構造函數

  • Person的每一個實例person1和person2都包含一個內部屬性(一般爲__proto__),person1.__proto__和person2.__proto__指向了原型對象


查找對象屬性


從上圖咱們發現,雖然Person的兩個實例都不包含屬性和方法,但咱們卻能夠調用person1.sayName()。


這是經過查找對象屬性的過程來實現的。


  1. 搜索首先從對象實例自己開始(實例person1有sayName屬性嗎?——沒有)

  2. 若是沒找到,則繼續搜索指針指向的原型對象(person1.__proto__有sayName屬性嗎?——有)


這也是多個對象實例共享原型所保存的屬性和方法的基本原理。


注意,若是咱們在對象的實例中重寫了某個原型中已存在的屬性,則該實例屬性會屏蔽原型中的那個屬性。此時,可使用delete操做符刪除實例上的屬性。


Object.getPrototypeOf()


根據ECMAScript標準,someObject.[[Prototype]] 符號是用於指派 someObject 的原型。


這個等同於 JavaScript 的 __proto__ 屬性(現已棄用)。


從ECMAScript 5開始, [[Prototype]] 能夠用Object.getPrototypeOf()和Object.setPrototypeOf()訪問器來訪問。


其中Object.getPrototypeOf()在全部支持的實現中,這個方法返回[[Prototype]]的值。例如:


person1.__proto__ === Object.getPrototypeOf(person1); // true

Object.getPrototypeOf(person1) === Person.prototype; // true


也就是說,Object.getPrototypeOf(p1)返回的對象實際就是這個對象的原型。

這個方法的兼容性請參考該連接。


Object.keys()


要取得對象上全部可枚舉的實例屬性,可使用ES5中的Object.keys()方法。例如:


Object.keys(p1); // ["name", "age", "job"]


此外,若是你想要獲得全部實例屬性,不管它是否可枚舉,均可以使用Object.getOwnPropertyName()方法。


更簡單的原型語法


在上面的代碼中,若是咱們要添加原型屬性和方法,就要重複的敲一遍Person.prototype。爲了減小這個重複的過程,更常見的作法是用一個包含全部屬性和方法的對象字面量來重寫整個原型對象。


function Person(name, age, job) {

  this.name = name;

  this.age = age;

  this.job = job;

}

 

Person.prototype = {

 

  // 這裏務必要從新將構造函數指回Person構造函數,不然會指向這個新建立的對象

  constructor: Person, // Attention!

 

  sayName: function () {

    console.log(this.name);

  }

};

 

var person1 = new Person('Weiwei', 27, 'Student');

var person2 = new Person('Lily', 25, 'Doctor');

 

console.log(person1.sayName === person2.sayName); // true

 

person1.sayName();  // Weiwei

person2.sayName();  // Lily


在上面的代碼中特地包含了一個constructor屬性,並將它的值設置爲Person,從而確保了經過該屬性可以訪問到適當的值。


注意,以這種方式重設constructor屬性會致使它的[[Enumerable]]特性設置爲true。默認狀況下,原生的constructor屬性是不可枚舉的。你可使用Object.defineProperty():


// 重設構造函數,只適用於ES5兼容的瀏覽器

Object.defineProperty(Person.prototype, "constructor", {

  enumerable: false,

  value: Person

});


組合使用構造函數模式和原型模式


建立自定義類型的最多見方式,就是組合使用構造函數模式與原型模式。構造函數模式用於定義實例屬性,而原型模式用於定義方法和共享的屬性。結果,每一個實例都會有本身的一份實例屬性的副本,但同時又共享着對方的引用,最大限度的節省了內存。


繼承


大多的面嚮對象語言都支持兩種繼承方式:接口繼承和實現繼承。ECMAScript只支持實現繼承,並且其實現繼承主要依靠原型鏈來實現。


原型鏈繼承


使用原型鏈做爲實現繼承的基本思想是:利用原型讓一個引用類型繼承另外一個引用類型的屬性和方法。首先咱們先回顧一些基本概念:


  • 每一個構造函數都有一個原型對象(prototype)

  • 原型對象包含一個指向構造函數的指針(constructor)

  • 實例都包含一個指向原型對象的內部指針([[Prototype]])


若是咱們讓原型對象等於另外一個類型的實現,結果會怎麼樣?顯然,此時的原型對象將包含一個指向另外一個原型的指針,相應的,另外一個原型中也包含着一個指向另外一個構造函數的指針。假如另外一個原型又是另外一個類型的實例,那麼上述關係依然成立,如此層層遞進,就構成了實例與原型的鏈條。


先看一個簡單的例子,它演示了使用原型鏈實現繼承的基本框架:


function Father () {

  this.fatherValue = true;

}

 

Father.prototype.getFatherValue = function () {

  console.log(this.fatherValue);

};

 

function Child () {

  this.childValue = false;

}

 

// 實現繼承:繼承自Father

Child.prototype = new Father();

 

Child.prototype.getChildValue = function () {

  console.log(this.childValue);

};

 

var instance = new Child();

instance.getFatherValue(); // true

instance.getChildValue();  // false


在上面的代碼中,原型鏈繼承的核心語句是Child.prototype = new Father(),它實現了Child對Father的繼承,而繼承是經過建立Father的實例,並將該實例賦給Child.prototype實現的。


實現的本質是重寫原型對象,代之以一個新類型的實例。也就是說,原來存在於Father的實例中的全部屬性和方法,如今也存在於Child.prototype中了。


這個例子中的實例以及構造函數和原型之間的關係以下圖所示:



在上面的代碼中,咱們沒有使用Child默認提供的原型,而是給它換了一個新原型;這個新原型就是Father的實例。


因而,新原型不只具備了做爲一個Father的實例所擁有的所有屬性和方法。並且其內部還有一個指針[[Prototype]],指向了Father的原型。


  • instance指向Child的原型對象

  • Child的原型對象指向Father的原型對象

  • getFatherValue()方法仍然還在Father.prototype中

  • 可是,fatherValue則位於Child.prototype中

  • instance.constructor如今指向的是Father


由於fatherValue是一個實例屬性,而getFatherValue()則是一個原型方法。既然Child.prototype如今是Father的實例,那麼fatherValue固然就位於該實例中。


經過實現原型鏈,本質上擴展了本章前面介紹的原型搜索機制。例如,instance.getFatherValue()會經歷三個搜索步驟:


  1. 搜索實例

  2. 搜索Child.prototype

  3. 搜索Father.prototype


別忘了Object


全部的函數都默認原型都是Object的實例,所以默認原型都會包含一個內部指針[[Prototype]],指向Object.prototype。


這也正是全部自定義類型都會繼承toString()、valueOf()等默認方法的根本緣由。因此,咱們說上面例子展現的原型鏈中還應該包括另一個繼承層次。關於Object的更多內容,能夠參考這篇博客。


也就是說,Child繼承了Father,而Father繼承了Object。當調用了instance.toString()時,實際上調用的是保存在Object.prototype中的那個方法。


原型鏈繼承的問題


首先是順序,必定要先繼承父類,而後爲子類添加新方法。


其次,使用原型鏈實現繼承時,不能使用對象字面量建立原型方法。由於這樣作就會重寫原型鏈,以下面的例子所示:


function Father () {

  this.fatherValue = true;

}

 

Father.prototype.getFatherValue = function () {

  console.log(this.fatherValue);

};

 

function Child () {

  this.childValue = false;

}

 

// 繼承了Father

// 此時的原型鏈爲 Child -> Father -> Object

Child.prototype = new Father();

 

// 使用字面量添加新方法,會致使上一行代碼無效

// 此時咱們設想的原型鏈被切斷,而是變成 Child -> Object

Child.prototype = {

  getChildValue: function () {

    console.log(this.childValue);

  }

};

 

var instance = new Child();

instance.getChildValue();  // false

instance.getFatherValue(); // error!


在上面的代碼中,咱們連續兩次修改了Child.prototype的值。因爲如今的原型包含的是一個Object的實例,而非Father的實例,所以咱們設想中的原型鏈已經被切斷——Child和Father之間已經沒有關係了。


最後,在建立子類型的實例時,不能向超類型的構造函數中傳遞參數。實際上,應該說是沒有辦法在不影響全部對象實例的狀況下,給超類型的構造函數傳遞參數。所以,咱們不多單獨使用原型鏈。


借用構造函數繼承


借用構造函數(constructor stealing)的基本思想以下:即在子類構造函數的內部調用超類型構造函數。


function Father (name) {

  this.name = name;

  this.colors = ['red', 'blue', 'green'];

}

 

function Child (name) {

  // 繼承了Father,同時傳遞了參數

  Father.call(this, name);

}

 

var instance1 = new Child("weiwei");

instance1.colors.push('black');

console.log(instance1.colors); // [ 'red', 'blue', 'green', 'black' ]

console.log(instance1.name); // weiwei

 

var instance2 = new Child("lily");

console.log(instance2.colors); // [ 'red', 'blue', 'green' ]

console.log(instance2.name); // lily


爲了確保Father構造函數不會重寫子類型的屬性,能夠在調用超類型構造函數後,再添加應該在子類型中定義的屬性。


借用構造函數的缺點


同構造函數同樣,沒法實現方法的複用。


組合使用原型鏈和借用構造函數


一般,咱們會組合使用原型鏈繼承和借用構造函數來實現繼承。也就是說,使用原型鏈實現對原型屬性和方法的繼承,而經過借用構造函數來實現對實例屬性的繼承。這樣,既經過在原型上定義方法實現了函數複用,又可以保證每一個實例都有它本身的屬性。


咱們改造最初的例子以下:


// 父類構造函數

function Person (name, age, job) {

  this.name = name;

  this.age = age;

  this.job = job;

}

 

// 父類方法

Person.prototype.sayName = function () {

  console.log(this.name);

};

 

// --------------

 

// 子類構造函數

function Student (name, age, job, school) {

  // 繼承父類的全部實例屬性

  Person.call(this, name, age, job);

  this.school = school; // 添加新的子類屬性

}

 

// 繼承父類的原型方法

Student.prototype = new Person();

 

// 新增的子類方法

Student.prototype.saySchool = function () {

  console.log(this.school);

};

 

var person1 = new Person('Weiwei', 27, 'Student');

var student1 = new Student('Lily', 25, 'Doctor', "Southeast University");

 

console.log(person1.sayName === student1.sayName); // true

 

person1.sayName();  // Weiwei

student1.sayName(); // Lily

student1.saySchool(); // Southeast University


組合集成避免了原型鏈和借用構造函數的缺陷,融合了它們的優勢,成爲了JavaScript中最經常使用的繼承模式。並且,instanceof和isPropertyOf()也可以用於識別基於組合繼承建立的對象。


組合繼承的改進版:使用Object.create()


在上面,咱們繼承父類的原型方法使用的是Student.prototype = new Person()。這樣作有不少的問題。改進方法是使用ES5中新增的Object.create()。能夠調用這個方法來建立一個新對象。新對象的原型就是調用create()方法傳入的第一個參數:


Student.prototype = Object.create(Person.prototype);

 

console.log(Student.prototype.constructor); // [Function: Person]

 

// 設置 constructor 屬性指向 Student

Student.prototype.constructor = Student;


詳細用法能夠參考文檔。


關於Object.create()的實現,咱們能夠參考一個簡單的polyfill:


function createObject(proto) {

    function F() { }

    F.prototype = proto;

    return new F();

}

 

// Usage:

Student.prototype = createObject(Person.prototype);


從本質上講,createObject()對傳入其中的對象執行了一次淺複製。


ES6中的面向對象語法


ES6中引入了一套新的關鍵字用來實現class。

JavaScript仍然是基於原型的,這些新的關鍵字包括class、constructor、static、extends、和super。


對前面的代碼修改以下:


'use strict';

 

class Person {

 

  constructor (name, age, job) {

    this.name = name;

    this.age = age;

    this.job = job;

  }

 

  sayName () {

    console.log(this.name);

  }

 

}

 

class Student extends Person {

 

  constructor (name, age, school) {

    super(name, age, 'Student');

    this.school = school;

  }

 

  saySchool () {

    console.log(this.school);

  }

 

}

 

var stu1 = new Student('weiwei', 20, 'Southeast University');

var stu2 = new Student('lily', 22, 'Nanjing University');

 

stu1.sayName(); // weiwei

stu1.saySchool(); // Southeast University

 

stu2.sayName(); // lily

stu2.saySchool(); // Nanjing University


類:class


是JavaScript中現有基於原型的繼承的語法糖。ES6中的類並非一種新的建立對象的方法,只不過是一種「特殊的函數」,

所以也包括類表達式和類聲明,

但須要注意的是,與函數聲明不一樣的是,類聲明不會被提高。

參考連接


類構造器:constructor


constructor()方法是有一種特殊的和class一塊兒用於建立和初始化對象的方法。注意,在ES6類中只能有一個名稱爲constructor的方法,不然會報錯。在constructor()方法中能夠調用super關鍵字調用父類構造器。若是你沒有指定一個構造器方法,類會自動使用一個默認的構造器。參考連接


類的靜態方法:static


靜態方法就是能夠直接使用類名調用的方法,而無需對類進行實例化,固然實例化後的類也沒法調用靜態方法。


靜態方法常被用於建立應用的工具函數。參考連接


繼承父類:extends


extends關鍵字能夠用於繼承父類。使用extends能夠擴展一個內置的對象(如Date),也能夠是自定義對象,或者是null。


關鍵字:super


super關鍵字用於調用父對象上的函數。

super.prop和super[expr]表達式在類和對象字面量中的任何方法定義中都有效。


super([arguments]); // 調用父類構造器

super.functionOnParent([arguments]); // 調用父類中的方法


若是是在類的構造器中,須要在this關鍵字以前使用。參考連接


小結


本文對JavaScript的面向對象機制進行了較爲深刻的解讀,尤爲是構造函數和原型鏈方式實現對象的建立、繼承、以及實例化。


此外,本文還簡要介紹瞭如在ES6中編寫面向對象代碼。


References


  1. 詳解Javascript中的Object對象

  2. new操做符

  3. JavaScript面向對象簡介

  4. Object.create()

  5. 繼承與原型鏈

相關文章
相關標籤/搜索