重新認識JavaScript面向對象: 從ES5到ES6

JavaScript是一門面向對象的語言

在說明JavaScript是一個面向對象的語言之前, 我們來探討一下面向對象的三大基本特徵:封裝, 繼承, 多態。

封裝

把抽象出來的屬性和對方法組合在一起, 且屬性值被保護在內部, 只有通過特定的方法進行改變和讀取稱為封裝

我們以代碼舉例, 首先我們構造一個Person構造函數, 它有name和id兩個屬性, 並有一個sayHi方法用於打招呼:

重新認識JavaScript面向對象: 從ES5到ES6

現在我們生成一個實例對象p1, 並調用sayHi()方法

//實例化對象
let p1 = new Person('阿輝', 1234);
//調用sayHi方法
p1.sayHi();

在上述的代碼中, p1這個對象並不知道sayHi()這個方法是如何實現的, 但是仍然可以使用這個方法. 這其實就是封裝. 你也可以實現對象屬性的私有和公有, 我們在構造函數中聲明一個salary作為私有屬性, 有且只有通過getSalary()方法查詢到薪資.

重新認識JavaScript面向對象: 從ES5到ES6

繼承

可以讓某個類型的對象獲得另一個類型的對象的屬性和方法稱為繼承

以剛才的Person作為父類構造器, 我們來新建一個子類構造器Student, 這裡我們使用call()方法實現繼承

重新認識JavaScript面向對象: 從ES5到ES6

多態

同一操作作用於不同的對象產生不同的執行結果, 這稱為多態

JavaScript中函數沒有重載, 所以JavaScript中的多態是靠函數覆蓋實現的。

同樣以剛才的Person構造函數為例, 我們為Person構造函數添加一個study方法

重新認識JavaScript面向對象: 從ES5到ES6

同樣, 我們新建一個Student和Teacher構造函數, 該構造函數繼承Person, 並也添加study方法

重新認識JavaScript面向對象: 從ES5到ES6

測試我們新建一個函數doStudy

function doStudy(role) {
 if(role instanceof Person) {
 role.study();
 }
}

此時我們分別實例化Student和Teacher, 並調用doStudy方法

let student = new Student('前端開發');
let teacher = new Teacher('前端開發');
doStudy(student); //阿輝在學習前端開發
doStudy(teacher); //老夫子為了教學在學習前端開發

對於同一函數doStudy, 由於參數的不同, 導致不同的調用結果,這就實現了多態.

JavaScript的面向對象

從上面的分析可以論證出, JavaScript是一門面向對象的語言, 因為它實現了面向對象的所有特性. 其實, 面向對象僅僅是一個概念或者一個編程思想而已, 它不應該依賴於某個語言存在, 比如Java採用面向對象思想構造其語言, 它實現了類, 繼承, 派生, 多態, 接口等機制. 但是這些機制,只是實現面向對象的一種手段, 而非必須。換言之, 一門語言可以根據自身特性選擇合適的方式來實現面向對象。 由於大多數程序員首先學習的是Java, C++等高級編程語言, 因而先入為主的接受了“類”這個面向對象實際方式,所以習慣性的用類式面嚮對象語言中的概念來判斷該語言是否是面向對象的語言。這也是很多有其他編程語言經驗的人在學習JavaScript對象時,感覺到很困難的地方。

實際上, JavaScript是通過一種叫原型(prototype)的方式來實現面向對象編程的。下面我們就來討論一下基於類(class-basesd)的面向對象和基於原型(protoype-based)的面向對象這兩者的差別。

基於類的面向對象和基於原型的面向對象的比較

基於類的面向對象

在基於類的面嚮對象語言中(比如Java和C++), 是構建在類(class)和實例(instance)上的。其中類定義了所有用於具有某一特徵對象的屬性。類是抽象的事物, 而不是其所描述的全部對象中的任何特定的個體。另一方面, 一個實例是一個類的實例化,是其中的一個成員。

基於原型的面向對象

在基於原型的語言中(如JavaScript)並不存在這種區別:它只有對象!不論是構造函數(constructor),實例(instance),原型(prototype)本身都是對象。基於原型的語言具有所謂的原型對象的概念,新對象可以從中獲得原始的屬性。

所以,在JavaScript中有一個很有意思的__proto__屬性(ES6以下是非標準屬性)用於訪問其原型對象, 你會發現,上面提到的構造函數,實例,原型本身都有__proto__指向原型對象。其最後順著原型鏈都會指向Object這個構造函數,然而Object的原型對象的原型是null,不信, 你可以嘗試一下Object.prototype.__proto__ === null為true。然而typeof null === 'object'為true。到這裡, 我相信你應該就能明白為什麼JavaScript這類基於原型的語言中沒有類和實例的區別, 而是萬物皆對象!

差異總結

重新認識JavaScript面向對象: 從ES5到ES6

ES5中的面向對象

*這裡的ES5並不特指ECMAScript 5, 而是代表ECMAScript 6 之前的ECMAScript!

(一) ES5中對象的創建

在ES5中創建對象有兩種方式, 第一種是使用對象字面量的方式, 第二種是使用構造函數的方式。該兩種方法在特定的使用場景分別有其優點和缺點, 下面我們來分別介紹這兩種創建對象的方式。

使用對象字面量的方式

我們通過對象字面量的方式創建兩個student對象,分別是student1和student2。

var student1 = {
 name: '阿輝',
 age: 22,
 subject: '前端開發'
};
var student2 = {
 name: '阿光‘,
 age: 22,
 subject: '大數據開發'
};

上面的代碼就是使用對象字面量的方式創建實例對象, 使用對象字面量的方式在創建單一簡單對象的時候是非常方便的。但是,它也有其缺點:

  • 在生成多個實例對象時, 我們需要每次重複寫name,age,subject屬性,寫起來特別的麻煩
  • 雖然都是學生的對象, 但是看不出student1和student2之間有什麼聯繫。

為了解決以上兩個問題, JavaScript提供了構造函數創建對象的方式。

使用構造函數的方式

構造函數就其實就是一個普通的函數,當對構造函數使用new進行實例化時,會將其內部this的指向綁定實例對象上,下面我們來創建一個Student構造函數(構造函數約定使用大寫開頭,和普通函數做區分)。

重新認識JavaScript面向對象: 從ES5到ES6

我特意在構造函數中打印出this的指向。上面我們提到,構造函數其實就是一個普通的函數, 那麼我們使用普通函數的調用方式嘗試調用Student。

Student('阿輝', 22, '前端開發'); //window{}

採用普通方式調用Student時, this的指向是window。下面使用new來實例化該構造函數, 生成一個實例對象student1。

let student1 = new Student('阿輝', 22, '前端開發'); //Student {name: "阿輝", age: 22, subject: "前端開發"}

當我們採用new生成實例化對象student1時, this不再指向window, 而是指向的實例對象本身。這些, 都是new幫我們做的。上面的就是採用構造函數的方式生成實例對象的方式, 並且當我們生成其他實例對象時,由於都是採用Student這個構造函數實例化而來的, 我們能夠清楚的知道各實例對象之間的聯繫。

let student1 = new Student('阿輝', 22, '前端開發');
let student2 = new Student('阿傻', 22, '大數據開發');
let student3 = new Student('阿呆', 22, 'Python');
let student4 = new Student('阿笨', 22, 'Java');

(二) ES5中對象的繼承

prototype的原型繼承

prototype是JavaScript這類基於原型繼承的核心, 只要弄明白了原型和原型鏈, 就基本上完全理解了JavaScript中對象的繼承。下面我將著重的講解為什麼要使用prototype和使用prototype實現繼承的方式。

為什麼要使用prototype?

我們給之前的Student構造函數新增一個study方法

重新認識JavaScript面向對象: 從ES5到ES6

現在我們來實例化Student構造函數, 生成student1和``student2, 並分別調用其study`方法。

let student1 = new Student('阿輝', 22, '前端開發');
let student2 = new Student('阿光', 22, '大數據開發');
student1.study(); //我在學習前端開發
student2.study(); //我在學習大數據開發
 

這樣生成的實例對象表面上看沒有任何問題, 但是其實是有很大的性能問題!我們來看下面一段代碼:

console.log(student1.study === student2.study); //false

其實對於每一個實例對象studentx,其study方法的函數體是一模一樣的,方法的執行結果只根據其實例對象決定,然而生成的每個實例都需要生成一個study方法去佔用一份內存。這樣是非常不經濟的做法。新手可能會認為, 上面的代碼中也就多生成了一個study方法, 對於內存的佔用可以忽略不計。

那麼我們在MDN中看一下在JavaScript中我們使用的String實例對象有多少方法?

重新認識JavaScript面向對象: 從ES5到ES6

String中的方法

上面的方法只是String實例對象中的一部分方法(我一個屏幕截取不完!), 這也就是為什麼我們的字符串能夠使用如此多便利的原生方法的原因。設想一下, 如果這些方法不是掛載在String.prototype上, 而是像上面Student一樣寫在String構造函數上呢?那麼我們項目中的每一個字符串,都會去生成這幾十種方法去佔用內存,這還沒考慮Math,Array,Number,Object等對象!

現在我們應該知道應該將study方法掛載到Student.prototype原型對象上才是正確的寫法,所有的studentx實例都能繼承該方法。

重新認識JavaScript面向對象: 從ES5到ES6

現在我們實例化student1和student2

let student1 = new Student('阿輝', 22, '前端開發');
let student2 = new Student('阿光', 22, '大數據開發');
student1.study(); //我在學習前端開發
student2.study(); //我在學習大數據開發
console.log(student1.study === student2.study); //true

從上面的代碼我們可以看出, student1和student2的study方法執行結果沒有發生變化,但是study本身指向了一個內存地址。這就是為什麼我們要使用prototype進行掛載方法的原因。接下來我們來講解一下如何使用prototype來實現繼承。

如何使用prototype實現繼承?

“學生”這個對象可以分為小學生, 中學生和大學生等。我們現在新建一個小學生的構造函數Pupil。

function Pupil(school) {
 this.school = school;
}

那麼如何讓Pupil使用prototype繼承Student呢? 其實我們只要將Pupil的prototype指向Student的一個實例即可。

Pupil.prototype = new Student('小輝', 8, '小學義務教育課程');
Pupil.prototype.constructor = Pupil;
let pupil1 = new Pupil('北大附小');

代碼的第一行, 我們將Pupil的原型對象(Pupil.prototype)指向了Student的實例對象。

Pupil.prototype = new Student('小輝', 8, '小學義務教育課程');

代碼的第二行也許有的讀者會不能理解是什麼意思。

Pupil.prototype.constructor = Pupil;

Pupil作為構造函數有一個protoype屬性指向原型對象Pupil.prototype,而原型對象Pupil.prototype也有一個constructor屬性指回它的構造函數Pupil。如下圖所示:

重新認識JavaScript面向對象: 從ES5到ES6

prototype和constructor的指向

然而, 當我們使用實例化Student去覆蓋Pupil.prototype後, 如果沒有第二行代碼的情況下, Pupil.prototype.constructor指向了Student構造函數, 如下圖所示:

重新認識JavaScript面向對象: 從ES5到ES6

prototype和constructor的指向錯誤

而且, pupil1.constructor會默認調用Pupil.prototype.constructor, 這個時候pupil1.constructor指向了Student:

Pupil.prototype = new Student('小輝', 8, '小學義務教育課程');
let pupil1 = new Pupil('北大附小');
console.log(pupil1.constructor === Student); //true

這明顯是錯誤的, pupil1明明是用Pupil構造函數實例化出來的, 怎麼其constructor指向了Student構造函數呢。所以, 我們就需要加入第二行, 修正其錯誤:

Pupil.prototype = new Student('小輝', 8, '小學義務教育課程');
//修正constructor的指向錯誤
Pupil.prototype.constructor = Pupil;
let pupil1 = new Pupil('北大附小');
console.log(pupil1.constructor === Student); //false
console.log(pupil1.constructor === Pupil); //ture

上面就是我們的如何使用prototype實現繼承的例子, 需要特別注意的: 如果替換了prototype對象, 必須手動將prototype.constructor重新指向其構造函數。

使用call和apply方法實現繼承

使用call和apply是我個人比較喜歡的繼承方式, 因為只需要一行代碼就可以實現繼承。但是該方法也有其侷限性,call和apply不能繼承原型上的屬性和方法, 下面會有詳細說明。

使用call實現繼承

同樣對於上面的Student構造函數, 我們使用call實現Pupil繼承Student的全部屬性和方法:

重新認識JavaScript面向對象: 從ES5到ES6

需要注意的是, call和apply只能繼承本地屬性和方法, 而不能繼承原型上的屬性和方法,如下面的代碼所示, 我們給Student掛載study方法,Pupil使用call繼承Student後, 調用pupil2.study()會報錯:

重新認識JavaScript面向對象: 從ES5到ES6

使用apply實現繼承

使用apply實現繼承的方式和call類似, 唯一的不同只是參數需要使用數組的方法。下面我們使用apply來實現上面Pupil繼承Student的例子。

重新認識JavaScript面向對象: 從ES5到ES6

其他繼承方式

JavaScript中的繼承方式不僅僅只有上面提到的幾種方法, 在《JavaScript高級程序設計》中, 還有實例繼承,拷貝繼承,組合繼承,寄生組合繼承等眾多繼承方式。在寄生組合繼承中, 就很好的彌補了call和apply無法繼承原型屬性和方法的缺陷,是最完美的繼承方法。這裡就不詳細的展開論述,感興趣的可以自行閱讀《JavaScript高級程序設計》。

ES6中的面向對象

基於原型的繼承方式,雖然實現了代碼複用,但是行文鬆散且不夠流暢,可閱讀性差,不利於實現擴展和對源代碼進行有效的組織管理。不得不承認,基於類的繼承方式在語言實現上更健壯,且在構建可服用代碼和組織架構程序方面具有明顯的優勢。所以,ES6中提供了基於類class的語法。但class本質上是ES6提供的一顆語法糖,正如我們前面提到的,JavaScript是一門基於原型的面嚮對象語言。

ES6中對象的創建

我們使用ES6的class來創建Student

重新認識JavaScript面向對象: 從ES5到ES6

上面的代碼定義了一個Student類, 可以看到裡面有一個constructor方法, 這就是構造方法,而this關鍵字則代表實例對象。也就是說,ES5中的構造函數Student, 對應的是E6中Student類中的constructor方法。

Student類除了構造函數方法,還定義了一個study方法。需要特別注意的是,在ES6中定義類中的方法的時候,前面不需要加上function關鍵字,直接把函數定義進去就可以了。另外,方法之間不要用逗號分隔,加了會報錯。而且,類中的方法全部是定義在原型上的,我們可以用下面的代碼進行驗證。

console.log(student3.__proto__.study === Student.prototype.study); //true
console.log(student3.hasOwnProperty('study')); // false

上面的第一行的代碼中, student3.__proto__是指向的原型對象,其中Student.prototype也是指向的原型的對象,結果為true就能很好的說明上面的結論: 類中的方法全部是定義在原型上的。第二行代碼是驗證student3實例中是否有study方法,結果為false, 表明實例中沒有study方法,這也更好的說明了上面的結論。其實,只要理解了ES5中的構造函數對應的是類中的constructor方法,就能推斷出上面的結論。

ES6中對象的繼承

ES6中class可以通過extends關鍵字來實現繼承, 這比前面提到的ES5中使用原型鏈來實現繼承, 要清晰和方便很多。下面我們使用ES6的語法來實現Pupil。

重新認識JavaScript面向對象: 從ES5到ES6

上面代碼代碼中, 我們通過了extends實現Pupil子類繼承Student父類。需要特別注意的是,子類必須在constructor方法中首先調用super方法,否則實例化時會報錯。這是因為子類沒有自己的this對象, 而是繼承父類的this對象,然後對其加工。如果不調用super方法,子類就得不到this對象。

結束語

JavaScript 被認為是世界上最受誤解的編程語言,因為它身披 c 語言家族的外衣,表現的卻是 LISP 風格的函數式語言特性;沒有類,卻實也徹底實現了面向對象。要對這門語言有透徹的理解,就必須扒開其 c 語言的外衣,從新回到函數式編程的角度,同時摒棄原有類的面向對象概念去學習領悟它。

現在的前端中不僅普遍的使用了ES6的新語法,而且在JavaScript的基礎上還出現了TypeScript、CoffeeScript這樣的超集。可以預見的是,目前在前端生態圈一片繁榮的情況下,對JSer的需求也會越來越多,但同時也對前端開發者的JavaScript的水平提出了更加嚴苛的要求。使用面向對象的思想去開發前端項目也是未來對JSer的基本要求之一!


分享到:


相關文章: