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