深入解讀JavaScript物件導向程式設計實踐

wwsun發表於2016-06-11

  物件導向程式設計是用抽象方式建立基於現實世界模型的一種程式設計模式,主要包括模組化、多型、和封裝幾種技術。對JavaScript而言,其核心是支援物件導向的,同時它也提供了強大靈活的基於原型的物件導向程式設計能力。本文將會深入的探討有關使用JavaScript進行物件導向程式設計的一些核心基礎知識,包括物件的建立,繼承機制, 最後還會簡要的介紹如何藉助ES6提供的新的類機制重寫傳統的JavaScript物件導向程式碼。

 物件導向的幾個概念

  在進入正題前,先了解傳統的物件導向程式設計(例如Java)中常會涉及到的概念,大致可以包括:

  • 類:定義物件的特徵。它是物件的屬性和方法的模板定義。
  • 物件(或稱例項):類的一個例項。
  • 屬性:物件的特徵,比如顏色、尺寸等。
  • 方法:物件的行為,比如行走、說話等。
  • 建構函式:物件初始化的瞬間被呼叫的方法。
  • 繼承:子類可以繼承父類的特徵。例如,貓繼承了動物的一般特性。
  • 封裝:一種把資料和相關的方法繫結在一起使用的方法。
  • 抽象:結合複雜的繼承、方法、屬性的物件能夠模擬現實的模型。
  • 多型:不同的類可以定義相同的方法或屬性。

  在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操作符的更多內容請參考這篇文件

  在上面的例子中,我們建立了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'; // 公共屬性,所有例項共享

  當我們new Person()時,返回的Person例項會結合建構函式中定義的屬性、行為和原型中定義的屬性、行為, 生成最終屬於Person例項的屬性和行為。

  建構函式中定義的屬性和行為的優先順序要比原型中定義的屬性和行為的優先順序高,如果建構函式和原型中定義了同名的屬性或行為, 建構函式中的屬性或行為會覆蓋原型中的同名的屬性或行為。

  原型物件

  現在我們來深入的理解一下什麼是原型物件。

  只要建立了一個新函式,就會根據一組特定的規則為該函式建立一個prototype屬性,這個屬性指向函式的原型物件。 在預設情況下,所有原型物件都會自動獲得一個constructor屬性,這個屬性包含一個指向prototype屬性所在函式的指標。 也就是說:Person.prototype.constructor指向Person建構函式。

  建立了自定義的建構函式之後,其原型物件預設只會取得constructor屬性;至於其他方法,則都是從Object繼承而來的。 當呼叫建構函式建立一個新例項後,該例項內部將包含一個指標(內部屬性),指向建構函式的原型物件。ES5中稱這個指標為[[Prototype]], 在Firefox、Safari和Chrome在每個物件上都支援一個屬性__proto__(目前已被廢棄);而在其他實現中,這個屬性對指令碼則是完全不可見的。 要注意,這個連結存在於例項與建構函式的原型物件之間,而不是例項與建構函式之間。

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

prototype graph

  上圖展示了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只支援實現繼承,而且其實現繼承主要依靠原型鏈來實現。

  前面我們知道,JavaScript中例項的屬性和行為是由建構函式和原型兩部分共同組成的。如果我們想讓Child繼承Father, 那麼我們就需要把Father建構函式和原型中屬性和行為全部傳給Child的建構函式和原型。

  原型鏈繼承

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

  • 每個建構函式都有一個原型物件(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中了。

  這個例子中的例項以及建構函式和原型之間的關係如下圖所示:

prototype chain inheritance

  在上面的程式碼中,我們沒有使用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,是為了修正Father內部this的指向
  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仍然是基於原型的,這些新的關鍵字包括classconstructorstaticextends、 和super

  class關鍵字不過是提供了一種在本文中所討論的基於原型模式和構造器模式的物件導向的繼承方式的語法糖(syntactic sugar)。

  對前面的程式碼修改如下:

'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. 繼承與原型鏈
  6. Understanding the prototype property in JavaScript

相關文章