如果要我總結一下學習前端以來我遇到了哪些瓶頸,那麼物件導向一定是第一個毫不猶豫想到的。儘管我現在對於物件導向有了一些的瞭解,但是當初的那種似懂非懂的痛苦,依然歷歷在目。
為了幫助大家能夠更加直觀的學習和了解物件導向,我會用盡量簡單易懂的描述來展示物件導向的相關知識。並且也準備了一些實用的例子幫助大家更加快速的掌握物件導向的真諦。
- jQuery的物件導向實現
- 封裝拖拽
- 簡易版運動框架封裝
這可能會花一點時間,但是卻值得期待。所以如果有興趣的朋友可以來簡書和公眾號關注我。
而這篇文章主要來聊一聊關於物件導向的一些重要的基本功。
一、物件的定義
在ECMAScript-262中,物件被定義為“無序屬性的集合,其屬性可以包含基本值,物件或者函式”。
也就是說,在JavaScript中,物件無非就是由一些列無序的key-value
對組成。其中value可以是基本值,物件或者函式。
1 2 3 4 5 6 7 |
// 這裡的person就是一個物件 var person = { name: 'Tom', age: 18, getName: function() {}, parent: {} } |
建立物件
我們可以通過new的方式建立一個物件。
1 |
var obj = new Object(); |
也可以通過物件字面量的形式建立一個簡單的物件。
1 |
var obj = {}; |
當我們想要給我們建立的簡單物件新增方法時,可以這樣表示。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 可以這樣 var person = {}; person.name = "TOM"; person.getName = function() { return this.name; } // 也可以這樣 var person = { name: "TOM", getName: function() { return this.name; } } |
訪問物件的屬性和方法
假如我們有一個簡單的物件如下:
1 2 3 4 5 6 7 |
var person = { name: 'TOM', age: '20', getName: function() { return this.name } } |
當我們想要訪問他的name屬性時,可以用如下兩種方式訪問。
1 2 3 4 |
person.name // 或者 person['name'] |
如果我們想要訪問的屬性名是一個變數時,常常會使用第二種方式。例如我們要同時訪問person的name與age,可以這樣寫:
1 2 3 |
['name', 'age'].forEach(function(item) { console.log(person[item]); }) |
這種方式一定要重視,記住它以後在我們處理複雜資料的時候會有很大的幫助。
二、工廠模式
使用上面的方式建立物件很簡單,但是在很多時候並不能滿足我們的需求。就以person物件為例。假如我們在實際開發中,不僅僅需要一個名字叫做TOM的person物件,同時還需要另外一個名為Jake的person物件,雖然他們有很多相似之處,但是我們不得不重複寫兩次。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
var perTom = { name: 'TOM', age: 20, getName: function() { return this.name } }; var perJake = { name: 'Jake', age: 22, getName: function() { return this.name } } |
很顯然這並不是合理的方式,當相似物件太多時,大家都會崩潰掉。
我們可以使用工廠模式的方式解決這個問題。顧名思義,工廠模式就是我們提供一個模子,然後通過這個模子複製出我們需要的物件。我們需要多少個,就複製多少個。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
var createPerson = function(name, age) { // 宣告一箇中間物件,該物件就是工廠模式的模子 var o = new Object(); // 依次新增我們需要的屬性與方法 o.name = name; o.age = age; o.getName = function() { return this.name; } return o; } // 建立兩個例項 var perTom = createPerson('TOM', 20); var PerJake = createPerson('Jake', 22); |
相信上面的程式碼並不難理解,也不用把工廠模式看得太過高大上。很顯然,工廠模式幫助我們解決了重複程式碼上的麻煩,讓我們可以寫很少的程式碼,就能夠建立很多個person物件。但是這裡還有兩個麻煩,需要我們注意。
第一個麻煩就是這樣處理,我們沒有辦法識別物件例項的型別。使用instanceof可以識別物件的型別,如下例子:
1 2 3 4 5 |
var obj = {}; var foo = function() {} console.log(obj instanceof Object); // true console.log(foo instanceof Function); // true |
因此在工廠模式的基礎上,我們需要使用建構函式的方式來解決這個麻煩。
三、建構函式
在JavaScript中,new關鍵字可以讓一個函式變得與眾不同。通過下面的例子,我們來一探new關鍵字的神奇之處。
1 2 3 4 5 6 |
function demo() { console.log(this); } demo(); // window new demo(); // demo |
為了能夠直觀的感受他們不同,建議大家動手實踐觀察一下。很顯然,使用new之後,函式內部發生了一些變化,讓this指向改變。那麼new關鍵字到底做了什麼事情呢。嗯,其實我之前在文章裡用文字大概表達了一下new到底幹了什麼,但是一些同學好奇心很足,總期望用程式碼實現一下,我就大概以我的理解來表達一下吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// 先一本正經的建立一個建構函式,其實該函式與普通函式並無區別 var Person = function(name, age) { this.name = name; this.age = age; this.getName = function() { return this.name; } } // 將建構函式以引數形式傳入 function New(func) { // 宣告一箇中間物件,該物件為最終返回的例項 var res = {}; if (func.prototype !== null) { // 將例項的原型指向建構函式的原型 res.__proto__ = func.prototype; } // ret為建構函式執行的結果,這裡通過apply,將建構函式內部的this指向修改為指向res,即為例項物件 var ret = func.apply(res, Array.prototype.slice.call(arguments, 1)); // 當我們在建構函式中明確指定了返回物件時,那麼new的執行結果就是該返回物件 if ((typeof ret === "object" || typeof ret === "function") && ret !== null) { return ret; } // 如果沒有明確指定返回物件,則預設返回res,這個res就是例項物件 return res; } // 通過new宣告建立例項,這裡的p1,實際接收的正是new中返回的res var p1 = New(Person, 'tom', 20); console.log(p1.getName()); // 當然,這裡也可以判斷出例項的型別了 console.log(p1 instanceof Person); // true |
JavaScript內部再通過其他的一些特殊處理,將
var p1 = New(Person, 'tom', 20);
等效於var p1 = new Person('tom', 20);
。就是我們認識的new關鍵字了。具體怎麼處理的,我也不知道,別刨根問底了,一直回答下去太難 – -!
老實講,你可能很難在其他地方看到有如此明確的告訴你new關鍵字到底對建構函式幹了什麼的文章了。理解了這段程式碼,你對JavaScript的理解又比別人深刻了一分,所以,一本正經厚顏無恥求個贊可好?
當然,很多朋友由於對於前面幾篇文章的知識理解不夠到位,會對new的實現表示非常困惑。但是老實講,如果你讀了我的前面幾篇文章,一定會對這裡new的實現有似曾相識的感覺。而且我這裡已經盡力做了詳細的註解,剩下的只能靠你自己了。
但是隻要你花點時間,理解了他的原理,那麼困擾了無數人的建構函式中this到底指向誰就變得非常簡單了。
所以,為了能夠判斷例項與物件的關係,我們就使用建構函式來搞定。
1 2 3 4 5 6 7 8 9 10 11 12 |
var Person = function(name, age) { this.name = name; this.age = age; this.getName = function() { return this.name; } } var p1 = new Person('Ness', 20); console.log(p1.getName()); // Ness console.log(p1 instanceof Person); // true |
關於建構函式,如果你暫時不能夠理解new的具體實現,就先記住下面這幾個結論吧。
- 與普通函式相比,建構函式並沒有任何特別的地方,首字母大寫只是我們約定的小規定,用於區分普通函式;
- new關鍵字讓建構函式具有了與普通函式不同的許多特點,而new的過程中,執行了如下過程:
- 宣告一箇中間物件;
- 將該中間物件的原型指向建構函式的原型;
- 將建構函式的this,指向該中間物件;
- 返回該中間物件,即返回例項物件。
四、原型
雖然建構函式解決了判斷例項型別的問題,但是,說到底,還是一個物件的複製過程。跟工廠模式頗有相似之處。也就是說,當我們宣告瞭100個person物件,那麼就有100個getName方法被重新生成。
這裡的每一個getName方法實現的功能其實是一模一樣的,但是由於分別屬於不同的例項,就不得不一直不停的為getName分配空間。這就是工廠模式存在的第二個麻煩。
顯然這是不合理的。我們期望的是,既然都是實現同一個功能,那麼能不能就讓每一個例項物件都訪問同一個方法?
當然能,這就是原型物件要幫我們解決的問題了。
我們建立的每一個函式,都可以有一個prototype屬性,該屬性指向一個物件。這個物件,就是我們這裡說的原型。
當我們在建立物件時,可以根據自己的需求,選擇性的將一些屬性和方法通過prototype屬性,掛載在原型物件上。而每一個new出來的例項,都有一個__proto__
屬性,該屬性指向建構函式的原型物件,通過這個屬性,讓例項物件也能夠訪問原型物件上的方法。因此,當所有的例項都能夠通過__proto__
訪問到原型物件時,原型物件的方法與屬性就變成了共有方法與屬性。
我們通過一個簡單的例子與圖示,來了解建構函式,例項與原型三者之間的關係。
由於每個函式都可以是建構函式,每個物件都可以是原型物件,因此如果在理解原型之初就想的太多太複雜的話,反而會阻礙你的理解,這裡我們要學會先簡化它們。就單純的剖析這三者的關係。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// 宣告建構函式 function Person(name, age) { this.name = name; this.age = age; } // 通過prototye屬性,將方法掛載到原型物件上 Person.prototype.getName = function() { return this.name; } var p1 = new Person('tim', 10); var p2 = new Person('jak', 22); console.log(p1.getName === p2.getName); // true |
通過圖示我們可以看出,建構函式的prototype與所有例項物件的__proto__
都指向原型物件。而原型物件的constructor指向建構函式。
除此之外,還可以從圖中看出,例項物件實際上對前面我們所說的中間物件的複製,而中間物件中的屬性與方法都在建構函式中新增。於是根據建構函式與原型的特性,我們就可以將在建構函式中,通過this宣告的屬性與方法稱為私有變數與方法,它們被當前被某一個例項物件所獨有。而通過原型宣告的屬性與方法,我們可以稱之為共有屬性與方法,它們可以被所有的例項物件訪問。
當我們訪問例項物件中的屬性或者方法時,會優先訪問例項物件自身的屬性和方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function Person(name, age) { this.name = name; this.age = age; this.getName = function() { console.log('this is constructor.'); } } Person.prototype.getName = function() { return this.name; } var p1 = new Person('tim', 10); p1.getName(); // this is constructor. |
在這個例子中,我們同時在原型與建構函式中都宣告瞭一個getName函式,執行程式碼的結果表示原型中的訪問並沒有被訪問。
我們還可以通過in來判斷,一個物件是否擁有某一個屬性/方法,無論是該屬性/方法存在與例項物件還是原型物件。
1 2 3 4 5 6 7 8 9 10 11 12 |
function Person(name, age) { this.name = name; this.age = age; } Person.prototype.getName = function() { return this.name; } var p1 = new Person('tim', 10); console.log('name' in p1); // true |
in的這種特性最常用的場景之一,就是判斷當前頁面是否在移動端開啟。
1 2 3 |
isMobile = 'ontouchstart' in document; // 很多人喜歡用瀏覽器UA的方式來判斷,但並不是很好的方式 |
更簡單的原型寫法
根據前面例子的寫法,如果我們要在原型上新增更多的方法,可以這樣寫:
1 2 3 4 5 6 |
function Person() {} Person.prototype.getName = function() {} Person.prototype.getAge = function() {} Person.prototype.sayHello = function() {} ... ... |
除此之外,我還可以使用更為簡單的寫法。
1 2 3 4 5 6 7 8 |
function Person() {} Person.prototype = { constructor: Person, getName: function() {}, getAge: function() {}, sayHello: function() {} } |
這種字面量的寫法看上去簡單很多,但是有一個需要特別注意的地方。Person.prototype = {}
實際上是重新建立了一個{}
物件並賦值給Person.prototype,這裡的{}
並不是最初的那個原型物件。因此它裡面並不包含constructor
屬性。為了保證正確性,我們必須在新建立的{}
物件中顯示的設定constructor
的指向。即上面的constructor: Person
。
四、原型鏈
原型物件其實也是普通的物件。幾乎所有的物件都可能是原型物件,也可能是例項物件,而且還可以同時是原型物件與例項物件。這樣的一個物件,正是構成原型鏈的一個節點。因此理解了原型,那麼原型鏈並不是一個多麼複雜的概念。
我們知道所有的函式都有一個叫做toString的方法。那麼這個方法到底是在哪裡的呢?
先隨意宣告一個函式:
1 |
function foo() {} |
那麼我們可以用如下的圖來表示這個函式的原型鏈。
其中foo是Function物件的例項。而Function的原型物件同時又是Object的例項。這樣就構成了一條原型鏈。原型鏈的訪問,其實跟作用域鏈有很大的相似之處,他們都是一次單向的查詢過程。因此例項物件能夠通過原型鏈,訪問到處於原型鏈上物件的所有屬性與方法。這也是foo最終能夠訪問到處於Object原型物件上的toString方法的原因。
基於原型鏈的特性,我們可以很輕鬆的實現繼承。
五、繼承
我們常常結合建構函式與原型來建立一個物件。因為建構函式與原型的不同特性,分別解決了我們不同的困擾。因此當我們想要實現繼承時,就必須得根據建構函式與原型的不同而採取不同的策略。
我們宣告一個Person物件,該物件將作為父級,而子級cPerson將要繼承Person的所有屬性與方法。
1 2 3 4 5 6 7 8 |
function Person(name, age) { this.name = name; this.age = age; } Person.prototype.getName = function() { return this.name; } |
首先我們來看建構函式的繼承。在上面我們已經理解了建構函式的本質,它其實是在new內部實現的一個複製過程。而我們在繼承時想要的,就是想父級建構函式中的操作在子級的建構函式中重現一遍即可。我們可以通過call方法來達到目的。
1 2 3 4 5 |
// 建構函式的繼承 function cPerson(name, age, job) { Person.call(this, name, age); this.job = job; } |
而原型的繼承,則只需要將子級的原型物件設定為父級的一個例項,加入到原型鏈中即可。
1 2 3 4 5 |
// 繼承原型 cPerson.prototype = new Person(name, age); // 新增更多方法 cPerson.prototype.getLive = function() {} |
當然關於繼承還有更好的方式,這裡就不做深入介紹了,以後有機會再詳細解讀吧。
六、總結
關於物件導向的基礎知識大概就是這些了。我從最簡單的建立一個物件開始,解釋了為什麼我們需要建構函式與原型,理解了這其中的細節,有助於我們在實際開發中靈活的組織自己的物件。因為我們並不是所有的場景都會使用建構函式或者原型來建立物件,也許我們需要的物件並不會宣告多個例項,或者不用區分物件的型別,那麼我們就可以選擇更簡單的方式。
我們還需要關注建構函式與原型的各自特性,有助於我們在建立物件時準確的判斷我們的屬性與方法到底是放在建構函式中還是放在原型中。如果沒有理解清楚,這會給我們在實際開發中造成非常大的困擾。
最後接下來的幾篇文章,我會挑幾個物件導向的例子,繼續幫助大家掌握物件導向的實際運用。