學習目標
理解面向對象開發思想
掌握 JavaScript 面向對象開發相關模式
面向對象介紹
什么是對象
Everything is object (一切皆對象)
我們可以從兩個層次來理解對象:
(1) 對象是單個事物的抽象。
????一本書、一輛汽車、一個人都可以是對象,一個數據庫、一張網頁、一個與遠程服務器的連接也可以是對象。當實物被抽象成對象,實物之間的關系就變成了對象之間的關系,從而就可以模擬現實情況,針對對象進行編程。
(2) 對象是一個容器,封裝了屬性(property)和方法(method)。
????屬性是對象的狀態,方法是對象的行為(完成某種任務)。比如,我們可以把動物抽象為animal對象,使用“屬性”記錄具體是那一種動物,使用“方法”表示動物的某種行為(奔跑、捕獵、休息等等)。
????在實際開發中,對象是一個抽象的概念,可以將其簡單理解為:數據集或功能集。ECMAScript-262 把對象定義為:無序屬性的集合,其屬性可以包含基本值、對象或者函數。嚴格來講,這就相當于說對象是一組沒有特定順序的值。對象的每個屬性或方法都有一個名字,而每個名字都 映射到一個值。
提示:每個對象都是基于一個引用類型創建的,這些類型可以是系統內置的原生類型,也可以是開發人員自定義的類型。
什么是面向對象
面向對象不是新的東西,它只是過程式代碼的一種高度封裝,目的在于提高代碼的開發效率和可維護性。
????面向對象編程 —— Object Oriented Programming,簡稱 OOP ,是一種編程開發思想。它將真實世界各種復雜的關系,抽象為一個個對象,然后由對象之間的分工與合作,完成對真實世界的模擬。
????在面向對象程序開發思想中,每一個對象都是功能中心,具有明確分工,可以完成接受信息、處理數據、發出信息等任務。因此,面向對象編程具有靈活、代碼可復用、高度模塊化等特點,容易維護和開發,比起由一系列函數或指令組成的傳統的過程式編程(procedural programming),更適合多人合作的大型軟件項目。
面向對象與面向過程區別:
面向過程就是親力親為,事無巨細,面面俱到,步步緊跟,有條不紊。
面向對象就是找一個對象,指揮得結果。
面向對象將執行者轉變成指揮者。
面向對象不是面向過程的替代,而是面向過程的封裝。
面向對象的特性:
封裝性
繼承性
多態性
JavaScript 中面向對象的基本體現
????在 JavaScript 中,所有數據類型都可以視為對象,當然也可以自定義對象。自定義的對象數據類型就是面向對象中的類( Class )的概念。
????我們以一個例子來說明面向過程和面向對象在程序流程上的不同之處。
????假設我們要處理學生的成績表,為了表示一個學生的成績,面向過程的程序可以用一個對象表示:
var std1 = { name: '張三', score: 98 }var std2 = { name: '李四', score: 81 }
????而處理學生成績可以通過函數實現,比如打印學生的成績:
function printScore (student) { console.log('姓名:' + student.name + ' ' + '成績:' + student.score)}
????如果采用面向對象的程序設計思想,我們首選思考的不是程序的執行流程, 而是 Student 這種數據類型應該被視為一個對象,這個對象擁有 name 和 score 這兩個屬性(Property)。如果要打印一個學生的成績,首先必須創建出這個學生對應的對象,然后,給對象發一個 printScore 消息,讓對象自己把自己的數據打印出來。
抽象數據行為模板(Class):
function Student (name, score) { this.name = name this.score = score}Student.prototype.printScore = function () { console.log('姓名:' + this.name + ' ' + '成績:' + this.score)}
根據模板創建具體實例對象(Instance):
var std1 = new Student('張三', 98)var std2 = new Student('李四', 81)
實例對象具有自己的具體行為(給對象發消息):
std1.printScore() // => 姓名:張三 成績:98std2.printScore() // => 姓名:李四 成績 81
????面向對象的設計思想是從自然界中來的,因為在自然界中,類(Class)和實例(Instance)的概念是很自然的。Class 是一種抽象概念,比如我們定義的 Class——Student ,是指學生這個概念, 而實例(Instance)則是一個個具體的 Student ,比如, 張三 和 李四 是兩個具體的 Student 。
面向對象的設計思想是:
抽象出 Class
根據 Class 創建 Instance
指揮 Instance 得結果
????面向對象的抽象程度比函數要高,因為一個 Class 既包含數據,又包含操作數據的方法。
JavaScript 如何創建對象
字面量方式
????我們可以直接通過 new Object()?創建:
var person = new Object()person.name = '張三'person.age = 18person.sayName = function () { console.log(this.name)}
????每次創建通過 new Object()?比較麻煩,所以可以通過它的簡寫形式對象字面量來創建:
var person = { name: '張三', age: 18, sayName: function () { console.log(this.name) }}
????上面的寫法是沒有問題的,但是假如我們要生成兩個 person 實例對象呢?
var person1 = { name: '張三', age: 18, sayName: function () { console.log(this.name) }}var person2 = { name: '李四', age: 16, sayName: function () { console.log(this.name) }}
????通過上面的代碼我們不難看出,這樣寫的代碼太過冗余,重復性太高。
簡單方式的改進:工廠函數
????我們可以寫一個函數,解決上邊代碼重復的問題:
function createPerson (name, age) { return { name: name, age: age, sayName: function () { console.log(this.name) } }}
????生成實例對象:
var p1 = createPerson('張三', 18)var p2 = createPerson('李四', 18)
????這樣封裝比上邊的方式好多了,通過工廠模式我們解決了創建多個相似對象代碼冗余的問題, 但卻沒有解決對象識別的問題(即怎樣知道一個對象的類型)。
更優雅的工廠函數:構造函數
????一種更優雅的工廠函數就是下面這樣,構造函數:
function Person (name, age) { this.name = name this.age = age this.sayName = function () { console.log(this.name) }}var p1 = new Person('張三', 18)p1.sayName() // => 張三var p2 = new Person('李四', 23)p2.sayName() // => 李四
????在上面的示例中,Person()?函數取代了 createPerson()?函數,但是實現效果是一樣的。這是為什么呢?
????我們注意到,Person()?中的代碼與?createPerson()?有以下幾點不同之處:
沒有顯示的創建對象
直接將屬性和方法賦給了?this?對象
沒有?return?語句
函數名使用的是大寫的?Person
構造函數代碼執行過程
????要創建 Person 實例,則必須使用 new 操作符。以這種方式調用構造函數會經歷以下 4 個步驟:
創建一個新對象。
將構造函數的作用域賦給新對象(因此 this 就指向了這個新對象)。
執行構造函數中的代碼。
返回新對象。
????下面是具體的偽代碼:
function Person (name, age) { // 當使用 new 操作符調用 Person() 的時候,實際上這里會先創建一個對象 // var instance = {} // 然后讓內部的 this 指向 instance 對象 // this = instance // 接下來所有針對 this 的操作實際上操作的就是 instance this.name = name this.age = age this.sayName = function () { console.log(this.name) } // 在函數的結尾處會將 this 返回,也就是 instance // return this}
構造函數和實例對象的關系
????使用構造函數的好處不僅僅在于代碼的簡潔性,更重要的是我們可以識別對象的具體類型了。在每一個實例對象中的_proto_中同時有一個 constructor 屬性,該屬性指向創建該實例的構造函數:
console.log(p1.constructor === Person) // => trueconsole.log(p2.constructor === Person) // => trueconsole.log(p1.constructor === p2.constructor) // => true
????對象的 constructor 屬性最初是用來標識對象類型的, 但是,如果要檢測對象的類型,還是使用 instanceof 操作符更可靠一些:
console.log(p1 instanceof Person) // => trueconsole.log(p2 instanceof Person) // => true
總結:
構造函數是根據具體的事物抽象出來的抽象模板。
實例對象是根據抽象的構造函數模板得到的具體實例對象。
每一個實例對象都具有一個 constructor 屬性,指向創建該實例的構造函數。( 此處constructor 是實例的屬性的說法不嚴謹,具體后面的原型會講到)
可以通過實例的 constructor 屬性判斷實例和構造函數之間的關系。(這種方式不嚴謹,推薦使用 instanceof 操作符,后面學原型會解釋為什么)
構造函數的問題
????使用構造函數帶來的最大的好處就是創建對象更方便了,但是其本身也存在一個浪費內存的問題:
function Person (name, age) { this.name = name this.age = age this.type = '學生' this.sayHello = function () { console.log('hello ' + this.name) }}var p1 = new Person('王五', 18)var p2 = new Person('李四', 16)
????上邊的代碼,從表面看上好像沒什么問題,但是實際上這樣做,有一個很大的弊端。那就是對于每一個實例對象,type和 sayHello 都是一模一樣的內容, 每一次生成一個實例,都必須為重復的內容,多占用一些內存,如果實例對象很多,會造成極大的內存浪費。
console.log(p1.sayHello === p2.sayHello) // => false
????對于這種問題我們可以把需要共享的函數定義到構造函數外部:
function sayHello = function () { console.log('hello ' + this.name)}function Person (name, age) { this.name = name this.age = age this.type = '學生' this.sayHello = sayHello}var p1 = new Person('王五', 18)var p2 = new Person('李四', 16)console.log(p1.sayHello === p2.sayHello) // => true
????這樣確實可以了,但是如果有多個需要共享的函數的話就會造成全局命名空間沖突的問題。如何解決這個問題呢?你肯定想到了可以把多個函數放到一個對象中用來避免全局命名空間沖突的問題:
var fns = { sayHello: function () { console.log('hello ' + this.name) }, sayAge: function () { console.log(this.age) }}function Person (name, age) { this.name = name this.age = age this.type = '學生' this.sayHello = fns.sayHello this.sayAge = fns.sayAge}var p1 = new Person('王五', 18)var p2 = new Person('李四', 16)console.log(p1.sayHello === p2.sayHello) // => trueconsole.log(p1.sayAge === p2.sayAge) // => true
????至此,我們利用自己的方式基本上解決了構造函數的內存浪費問題。但是代碼看起來還是那么的格格不入,那有沒有更好的方式呢?
原型
更好的解決方案:prototype
??? Javascript 規定,每一個構造函數都有一個 prototype 屬性,指向另一個對象。這個對象的所有屬性和方法,都會被構造函數的實例繼承。
????這也就意味著,我們可以把所有對象實例需要共享的屬性和方法直接定義在 prototype 對象上。
function Person (name, age) { this.name = name this.age = age}console.log(Person.prototype)Person.prototype.type = '學生'Person.prototype.sayName = function () { console.log(this.name)}var p1 = new Person(...)var p2 = new Person(...)console.log(p1.sayName === p2.sayName) // => true
????這時所有實例的 type 屬性和 sayName()?方法, 其實都是同一個內存地址,指向 prototype 對象,因此就提高了運行效率。
?構造函數、實例、原型三者之間的關系:

????任何函數都有一個 prototype 屬性,該屬性是一個對象。
function F () {}console.log(F.prototype) // => objectF.prototype.sayHi = function () { console.log('hi!')}
????構造函數的 prototype 對象默認都有一個 constructor 屬性,指向 prototype 對象所在函數。
console.log(F.constructor === F) // => true
????通過構造函數得到的實例對象內部會包含一個指向構造函數的 prototype 對象的指針?__proto__。
var instance = new F()console.log(instance.__proto__ === F.prototype) // => true
`__proto__` 是非標準屬性。
實例對象可以直接訪問原型對象成員:
instance.sayHi() // => hi!
總結:
任何函數都具有一個 prototype 屬性,該屬性是一個對象。
構造函數的 prototype 對象默認都有一個 constructor 屬性,指向 prototype 對象所在函數。
通過構造函數得到的實例對象內部會包含一個指向構造函數的 prototype 對象的指針?__proto__。
所有實例都直接或間接繼承了原型對象的成員。
屬性成員的搜索原則:原型鏈
????了解了?構造函數-實例-原型對象?三者之間的關系后,接下來我們來解釋一下為什么實例對象可以訪問原型對象中的成員
????每當代碼讀取某個對象的某個屬性時,都會執行一次搜索,目標是具有給定名字的屬性。
搜索首先從對象實例本身開始。
如果在實例中找到了具有給定名字的屬性,則返回該屬性的值。
如果沒有找到,則繼續搜索指針指向的原型對象,在原型對象中查找具有給定名字的屬性。
如果在原型對象中找到了這個屬性,則返回該屬性的值。
也就是說,在我們調用 person1.sayName()?的時候,會先后執行兩次搜索:
首先,解析器會問:
“實例 person1 有 sayName 屬性嗎?
”答:
“沒有。
然后,它繼續搜索,再問:“ person1 的原型有 sayName 屬性嗎?”答:“有。
于是,它就讀取那個保存在原型對象中的函數。
當我們調用 person2.sayName() 時,將會重現相同的搜索過程,得到相同的結果。
????這就是多個對象實例共享原型所保存的屬性和方法的基本原理。
總結:
先在自己身上找,找到即返回。
自己身上找不到,則沿著原型鏈向上查找,找到即返回。
如果一直到原型鏈的末端還沒有找到,則返回 undefined。
實例對象讀寫原型對象成員
讀取:
先在自己身上找,找到即返回。
自己身上找不到,則沿著原型鏈向上查找,找到即返回。
如果一直到原型鏈的末端還沒有找到,則返回 undefined。
值類型成員寫入(實例對象.值類型成員 = xx):
當實例期望重寫原型對象中的某個普通數據成員時實際上會把該成員添加到自己身上。
也就是說該行為實際上會屏蔽掉對原型對象成員的訪問。
引用類型成員寫入(實例對象.引用類型成員 = xx):同上。
復雜類型修改(實例對象.成員.xx = xx):
同樣會先在自己身上找該成員,如果自己身上找到則直接修改。
如果自己身上找不到,則沿著原型鏈繼續查找,如果找到則修改。
如果一直到原型鏈的末端還沒有找到該成員,則報錯(實例對象.undefined.xx = xx)。
更簡單的原型語法
????我們注意到,前面例子中每添加一個屬性和方法就要敲一遍 Person.prototype 。?為減少不必要的輸入,更常見的做法是用一個包含所有屬性和方法的對象字面量來重寫整個原型對象:
function Person (name, age) { this.name = name this.age = age}Person.prototype = { type: '學生', sayHello: function () { console.log('我叫' + this.name + ',我今年' + this.age + '歲了') }}
????在該示例中,我們將 Person.prototype 重置到了一個新的對象。這樣做的好處就是為 Person.prototype 添加成員簡單了,但是也會帶來一個問題,那就是原型對象丟失了 constructor 成員。
????所以,我們為了保持 constructor 的指向正確,建議的寫法是:
function Person (name, age) { this.name = name this.age = age}Person.prototype = { constructor: Person, // => 手動將 constructor 指向正確的構造函數 type: '學生', sayHello: function () { console.log('我叫' + this.name + ',我今年' + this.age + '歲了') }}
原生對象的原型
所有函數都有 prototype 屬性對象。
Object.prototype
Function.prototype
Array.prototype
String.prototype
Number.prototype
Date.prototype
...
????為數組對象和字符串對象擴展原型方法:
//為內置對象添加原型方法//我們在系統的對象的原型中添加方法,相當于在改變源碼//我希望字符串中有一個倒序字符串的方法String.prototype.myReverse = function() {for (var i = this.length - 1; i >= 0; i--) {console.log(this[i]);}};var str = "abcdefg";str.myReverse();//為Array內置對象的原型對象中添加方法Array.prototype.mySort = function() {for (var i = 0; i < this.length - 1; i++) {for (var j = 0; j < this.length - 1 - i; j++) {if (this[j] < this[j + 1]) {var temp = this[j];this[j] = this[j + 1];this[j + 1] = temp;} //end if} // end for} //end for};var arr = [100, 3, 56, 78, 23, 10];arr.mySort();console.log(arr);String.prototype.sayHi = function() {console.log(this + "哈哈,我又變帥了");};//字符串就有了打招呼的方法var str2 = "小楊";str2.sayHi();
原型對象的一些問題
共享數組
共享對象
????如果真的希望可以被實例對象之間共享和修改這些共享數據那就不是問題。但是如果不希望實例之間共享和修改這些共享數據則會出現問題。一個更好的建議是,最好不要讓實例之間互相共享數組或者對象成員,一旦修改的話會導致數據的走向很不明確而且難以維護。
原型對象使用建議:
私有成員(一般就是非函數成員)放到構造函數中。
共享成員(一般就是函數)放到原型對象中。
如果重置了 prototype 記得修正 constructor 的指向。