"面向對象" 是以 "對象" 爲中心的編程思想,它的思惟方式是構造。javascript
"面向對象" 編程的三大特色:"封裝、繼承、多態」:html
"面向對象" 編程的核心,離不開 "類" 的概念。簡單地理解下 "類",它是一種抽象方法。經過 "類" 的方式,能夠建立出多個具備相同屬性和方法的對象。前端
可是!可是!可是JavaScript中並無 "類" 的概念,對的,沒有。java
ES6 新增的 class
語法,只是一種模擬 "類" 的語法糖,底層機制依舊不能算是標準 "類" 的實現方式。git
在理解JavaScript中如何實現 "面向對象" 編程以前,有必要對JavaScript中的對象先做進一步地瞭解。github
對象是"無序屬性"的集合,表現爲"鍵/值對"的形式。屬性值可包含任何類型值(基本類型、引用類型:對象/函數/數組)。編程
有些文章指出"JS中一切都是對象",略有偏頗,修正爲:"JS中一切引用類型都是對象"更爲穩妥些。segmentfault
函數 / 數組都屬於對象,數組就是對象的一種子類型,不過函數稍微複雜點,它跟對象的關係,有點"雞生蛋,蛋生雞"的關係,可先記住:"對象由函數建立"。設計模式
new
操做符調用 Object
函數// 字面量 let person = { name: '以樂之名' }; // new Object() let person = new Object(); person.name = '以樂之名';
以上兩種建立對象的方式,並不具有建立多個具備相同屬性的對象。數組
TIPS:new
操做符會對全部函數進行劫持,將函數變成構造函數(對函數的構造調用)。
.
操做符訪問 (也稱 "鍵訪問")[]
操做符訪問(也稱 "屬性訪問").
操做符 VS []
操做符:.
訪問屬性時,屬性名需遵循標識符規範,兼容性比 []
略差;[]
接受任意UTF-8/Unicode字符串做爲屬性名;[]
支持動態屬性名(變量);[]
支持表達式計算(字符串鏈接 / ES6的Symbol
)TIPS: 標識符命名規範 —— 數字/英文字母/下劃線組成,開頭不能是數字。
// 任意UTF-8/Unicode字符串做爲屬性名 person['$my-name']; // 動態屬性名(變量) let attrName = 'name'; person[attrName]; // 表達式計算 let attrPrefix = 'my_'; person[attrPrefix + 'name']; // person['my_name'] person[Symbol.name]; // Symbol在屬性名的應用
ES5新增 "屬性描述符",可針對對象屬性的特性進行配置。
Configurable
可配置(可刪除)?[true|false]
Enumerable
可枚舉 [true|false]
Writable
可寫? [true|false]
Value
值?默認undefined
Get [[Getter]]
讀取方法Set [[Setter]]
設置方法訪問器屬性會優於 writeable/value
get()
,會忽略其 value
值,直接調用 get()
;set()
,會忽略 writable
的設置,直接調用 set()
;訪問器屬性平常應用:
set()
制定邏輯修改屬性值)Object.defineProperty()
定義單個屬性Object.defineProperties()
定義多個屬性let Person = {}; Object.defineProperty(Person, 'name', { writable: true, enumerable: true, configurable: true, value: '以樂之名' }); Person.name; // 以樂之名
TIPS:使用 Object.defineProperty/defineProperties
定義屬性時,屬性特性 configurable/enumerable/writable
值默認爲 false
,value
默認爲 undefined
。其它方式建立對象屬性時,前三者值都爲 true
。
可以使用Object.getOwnPropertyDescriptor()
來獲取對象屬性的特性描述。
JavaScript中模擬 "面向對象" 中 "類" 的實現方式,是利用了JavaScript中函數的一個特性(屬性)——prototype
(自己是一個對象)。
每一個函數默認都有一個 prototype
屬性,它就是咱們所說的 "原型",或稱 "原型對象"。每一個實例化建立的對象都有一個 __proto__
屬性(隱式原型),它指向建立它的構造函數的 prototype
屬性。
let Person = function(name, age) { this.name = name; this.age = age; }; Person.prototype.say = function() {}; let father = new Person('David', 48); let mother = new Person('Kelly', 46);
new
操做符的執行過程,會對實例對象進行 "原型關聯",或稱 "原型連接"。
__proto__
會指向函數的prototype
)」this
會指向這個新對象,並對this
屬性進行賦值return
,通常不會有return
)"對象由函數建立",既然 prototype
也是對象,那麼它的 __proto__
原型鏈上應該還有屬性。Person.prototype.__proto__
指向 Function.prototype
,而Function.prototype.__proto__
最終指向 Object.prototype
。
TIPS:Object.prototype.__proto__
指向 null
(特例)。
平常調用對象的 toString()/valueOf()
方法,雖然沒有去定義它們,但卻能正常使用。實際上這些方法來自 Object.prototype
,全部普通對象的原型鏈最終都會指向 Object.prototype
,而對象經過原型鏈關聯(繼承)的方式,使得實例對象能夠調用 Object.prototype
上的屬性 / 方法。
訪問一個對象的屬性時,會先在其基礎屬性上查找,找到則返回值;若是沒有,會沿着其原型鏈上進行查找,整條原型鏈查找不到則返回 undefined
。這就是原型鏈查找。
判斷對象基礎屬性中是否有該屬性,基礎屬性返回 true
。
for...in...
遍歷對象全部可枚舉屬性in
判斷對象是否擁有該屬性Object.keys(...)
返回全部可枚舉屬性Object.getOwnPropertyNames(...)
返回全部屬性修改對象屬性時,若是屬性名與原型鏈上屬性重名,則在實例對象上建立新的屬性,屏蔽對象對原型屬性的使用(發生屏蔽屬性)。屏蔽屬性的前提是,對象基礎屬性名與原型鏈上屬性名存在重名。
set()
,調用 set()
建立多個具備相同屬性的對象
function createPersonFactory(name, age) { var obj = new Object(); obj.name = name; obj.age = age; obj.say = function() { console.log(`My name is ${this.name}, i am ${this.age}`); }; return obj; } var father = createPersonFactory('David', 48); var mother = createPersonFactory('Kelly', 46); father.say(); // 'My name is David, i am 48' mother.say(); // 'My name is Kelly, i am 46'
缺點:
say
方法沒有共用內存空間obj.say = function(){...}
實例化一個對象時都會開闢新的內存空間,去存儲function(){...}
,形成沒必要要的內存開銷。
father.say == mother.say; // false
new
)function Person(name, age) { this.name = name; this.age = age; this.say = function() { console.log(`My name is ${this.name}, i am ${this.age}`); } } let father = new Person('David', 48);
缺點:屬性值爲引用類型(say
方法)時沒法共用,不一樣實例對象的 say
方法沒有共用內存空間(與工廠模式同樣)。
function Person() {} Person.prototype.name = 'David'; Person.prototype.age = 48; Person.prototype.say = function() { console.log(`My name is ${this.name}, i am ${this.age}`); }; let father = new Person();
優勢:解決公共方法內存佔用問題(全部實例屬性的 say
方法共用內存)
缺點:屬性值爲引用類型時,因內存共用,一個對象修改屬性會形成其它對象使用屬性發生改變。
Person.prototype.like = ['sing', 'dance']; let father = new Person(); let mother = new Person(); father.like.push('travel'); // 引用類型共用內存,一個對象修改屬性,會影響其它對象 father.like; // ['sing', 'dance', 'travel'] mother.like; // ['sing', 'dance', 'travel']
function Person(name, age) { this.name = name; this.age = age; } Person.prototype.say = function() { console.log(`My name is ${this.name}, i am ${this.age}`); }
原理:結合構造函數和原型的優勢,"構造函數初始化屬性,原型定義公共方法"。
構造函數 + 原型的組合方式,區別於其它 "面向對象" 語言的聲明方式。屬性方法的定義並無統一在構造函數中。所以動態原型建立對象的方式,則是在 "構造函數 + 原型組合" 基礎上,優化了定義方式(區域)。
function Person(name, age) { this.name = name; this.age = age; // 判斷原型是否有方法,沒有則添加; // 原型上的屬性在構造函數內定義,僅執行一次 if (!Person.prototype.say) { Person.prototype.say = function() { console.log(`My name is ${this.name}, i am ${this.age}`); } } }
優勢:屬性方法統一在構造函數中定義。
除了以上介紹的幾種對象建立方式,此外還有"寄生構造函數模式"、"穩妥構造函數模式"。平常開發較少使用,感興趣的夥伴們可自行了解。
傳統的面嚮對象語言中,"類" 繼承的原理是 "類" 的複製。但JavaScript模擬 "類" 繼承則是經過 "原型關聯" 來實現,並非 "類" 的複製。正如《你不知道的JavaScript》中提出的觀點,這種模擬 "類" 繼承的方式,更像是 "委託",而不是 "繼承"。
如下列舉JavaScript中經常使用的繼承方式,預先定義兩個類:
// 父類統必定義 function Person(name, age) { // 構造函數定義初始化屬性 this.name = name; this.age = age; } // 原型定義公共方法 Person.prototype.eat = function() {}; Person.prototype.sleep = function() {};
// 原型繼承 function Student(name, age, grade) { this.grade = grade; }; Student.prototype = new Person(); // Student原型指向Person實例對象 Student.prototype.constructor = Student; // 原型對象修改,須要修復constructor屬性 let pupil = new Student(name, age, grade);
子類的原型對象爲父類的實例對象,所以子類原型對象中擁有父類的全部屬性
// 構造函數繼承 function Student(name, age, grade) { Person.call(this, name, age); this.grade = grade; }
調用父類構造函數,傳入子類的上下文對象,實現子類參數初始化賦值。僅實現部分繼承,沒法繼承父類原型上的屬性。可 call
多個父類構造函數,實現多繼承。
屬性值爲引用類型時,需開闢多個內存空間,多個實例對象沒法共享公共方法的存儲,形成沒必要要的內存佔用。
// 原型 + 構造函數繼承 function Student(name, age, grade) { Person.call(this, name, age); // 第一次調用父類構造函數 this.grade = grade; } Student.prototype = new Person(); // 第二次調用父類構造函數 Student.prototype.constructor = Student; // 修復constructor屬性
結合原型繼承 + 構造函數繼承二者的優勢,"構造函數繼承並初始化屬性,原型繼承公共方法"。
父類構造函數被調用了兩次。
待優化:父類構造函數第一次調用時,已經完成父類構造函數中 "屬性的繼承和初始化",第二次調用時只須要 "繼承父類原型屬性" 便可,無須再執行父類構造函數。
// 寄生組合式繼承 function Student(name, age, grade) { Person.call(this, name, age); this.grade = grade; } Student.prototype = Object.create(Person.prototype); // Object.create() 會建立一個新對象,該對象的__proto__指向Person.prototype Student.prototype.constructor = Student; let pupil = new Student('小明', 10, '二年級');
建立一個新對象,將該對象原型關聯至父類的原型對象,子類 Student
已使用 call
來調用父類構造函數完成初始化,因此只需再繼承父類原型屬性便可,避免了經典組合繼承調用兩次父類構造函數。(較完美的繼承方案)
class Person { constructor(name, age) { this.name = name; this.grade = grade; } eat () { //... } sleep () { //... } } class Student extends Person { constructor (name, age, grade) { super(name, age); this.grade = grade; } play () { //... } }
優勢:ES6提供的 class
語法使得類繼承代碼語法更加簡潔。
Object.create()
方法會建立一個新對象,使用現有對象來提供新建立的對象的__proto__
Object.create
實現的實際上是"對象關聯",直接上代碼更有助於理解:
let person = { eat: function() {}; sleep: function() {}; } let father = Object.create(person); // father.__proto__ -> person, 所以father上有eat/sleep/talk等屬性 father.eat(); father.sleep();
上述代碼中,咱們並無使用構造函數 / 類繼承的方式,但 father
卻可使用來自 person
對象的屬性方法,底層原理依賴於原型和原型鏈的魔力。
// Object.create實現原理/模擬 Object.create = function(o) { function F() {} F.prototype = o; return new F(); }
Object.create(...)
實現的 "對象關聯" 的設計模式與 "面向對象" 模式不一樣,它並無父類,子類的概念,甚至沒有 "類" 的概念,只有對象。它倡導的是 "委託" 的設計模式,是基於 "面向委託" 的一種編程模式。
文章篇幅有限,僅做淺顯瞭解,後續可另開一章講講 "面向對象" VS "面向委託",孰優孰劣,說一道二。
instanceof
只能處理對象與函數的關係判斷。instanceof
左邊是對象,右邊是函數。判斷規則:沿着對象的 __proto__
進行查找,沿着函數的 prototype
進行查找,若是有關聯引用則返回 true
,不然返回 false
。
let pupil = new Student(); pupil instanceof Student; // true pupil instanceof Person; // true Student繼承了Person
Object.prototype.isPrototyepOf(...)
能夠識別對象與對象,也能夠是對象與函數。
let pupil = new Student(); Student.prototype.isPrototypeOf(pupil); // true
判斷規則:在對象 pupil
原型鏈上是否出現過 Student.prototype
, 若是有則返回 true
, 不然返回 false
ES6新增修改對象原型的方法: Object.setPrototypeOf(obj, prototype)
,存在有性能問題,僅做了解,更推薦使用 Object.create(...)
。
Student.prototype = Object.create(Person.prototype); // setPrototypeOf改寫上行代碼 Object.setPrototypeOf(Student.prototype, Person.prototype);
"面向對象" 是程序編程的一種設計模式,具有 "封裝,繼承,多態" 的特色,在ES6的 class
語法未出來以前,原型繼承確實是JavaScript入門的一個難點,特別是對新入門的朋友,理解起來並不友好,模擬繼承的代碼寫的冗餘又難懂。好在ES6有了 class
語法糖,沒必要寫冗餘的類繼承代碼,代碼寫少了,眼鏡片都亮堂了。
老話說的好,「會者不難」。深刻理解面向對象,原型,繼承,對往後代碼能力的提高及編碼方式優化都有益處。好的方案不僅有一種,明白箇中原因,帶你走進新世界大門。
參考文檔:
本文首發Github,期待Star!
https://github.com/ZengLingYong/blog
做者:以樂之名 本文原創,有不當的地方歡迎指出。轉載請指明出處。