前言
去年開始我給自己畫了一張知識體系的思維導圖,用於規劃自己的學習範圍和方向。但是我犯了一個大錯,我的思維導圖只是一個全局的藍圖,而在學習某個知識點的時候沒有系統化,知識太過於零散,另一方面也很容易遺忘,回頭複習時沒有一個提綱,整體的學習效率不高。意識到這一點,我最近開始用思維導圖去學習和總結具體的知識點,效果還不錯。試想一下,一張思維導圖的某個端點是另一張思維導圖,這樣串起來的知識鏈條是多麼“酸爽”!當然,YY一下就好了,我保證你沒有足夠的時間給所有知識點都畫上思維導圖,挑重點即可。
提綱思路
當我們要研究一個問題或者知識點時,關注點無非是:
-
是什麼?
-
做什麼?
-
爲什麼?
很明顯,搞懂“是什麼”是最最基礎的,而這部分卻很重要。萬丈高樓平地起,如果連基礎都不清楚,何談應用實踐(“做什麼”),更加也不會理解問題的本質(“爲什麼”)。
而要整理一篇高質量的思維導圖,必須充分利用“總-分”的思路,首先要形成一個基本的提綱,然後從各個方面去延伸拓展,最後得到一棵較爲完整的知識樹。分解知識點後,在細究的過程中,你可能還會驚喜地發現一個知識點的各個組成部分之間的關聯,對知識點有一個更爲飽滿的認識。
梳理提綱需要對知識點有一個整體的認識。如果是學習比較陌生的領域知識,我的策略是從相關書籍或官方文檔的目錄中提煉出提綱。
下面以我複習javascript
對象這塊知識時的一些思路爲例說明。
javascript對象
在複習javascript
對象這塊知識時,我從過往的一些使用經驗,書籍,文檔資料中提煉出了這麼幾個方面作爲提綱,分別是:
-
對象的分類
-
對象的三個重要概念:類,原型,實例
-
創建對象的方法
-
對象屬性的訪問和設置
-
原型和繼承
-
靜態方法和原型方法
由此展開得到了這樣一個思維導圖:
對象的分類
對象主要分爲這麼三大類:
-
內置對象:ECMAScript規範中定義的類或對象,比如
Object
,Array
,Date
等。 -
宿主對象:由javascript解釋器所嵌入的宿主環境提供。比如瀏覽器環境會提供
window
,HTMLElement
等瀏覽器特有的宿主對象。Nodejs會提供global
全局對象 -
自定義對象:由javascript開發者自行創建的對象,用以實現特定業務。就比如我們熟悉的Vue,它就是一個自定義對象。我們可以對Vue這個對象進行實例化,用於生成基於Vue的應用。
對象的三個重要概念
類
javascript在ES6之前沒有class
關鍵字,但這不影響javascript可以實現面向對象編程,javascript的類名對應構造函數名。
在ES6之前,如果我們要定義一個類,其實是藉助函數來實現的。
function Person(name) {
this.name = name;
}
Person.prototype.sayHello = function() {
console.log(this.name + ': hello!');
}
var person = new Person('Faker');
person.sayHello();
ES6明確定義了class
關鍵字。
class Person {
constructor(name) {
this.name = name;
}
sayHello() {
console.log(this.name + ': hello!');
}
}
var person = new Person('Faker');
person.sayHello();
原型
原型是類的核心,用於定義類的屬性和方法,這些屬性和方法會被實例繼承。
定義原型屬性和方法需要用到構造函數的prototype
屬性,通過prototype
屬性可以獲取到原型對象的引用,然後就可以擴展原型對象了。
function Person(name) {
this.name = name;
}
Person.prototype.sexList = ['man', 'woman'];
Person.prototype.sayHello = function() {
console.log(this.name + ': hello!');
}
實例
類是抽象的概念,相當於一個模板,而實例是類的具體表現。就比如Person
是一個類,而根據Person
類,我們可以實例化多個對象,可能有小明,小紅,小王等等,類的實例都是一個個獨立的個體,但是他們都有共同的原型。
var xiaoMing = new Person('小明');
var xiaoHong = new Person('小紅');
// 擁有同一個原型
Object.getPrototypeOf(xiaoMing) === Object.getPrototypeOf(xiaoHong); // true
如何創建對象
對象直接量
對象直接量也稱爲對象字面量。直接量就是不需要實例化,直接寫鍵值對即可創建對象,堪稱“簡單粗暴”。
var xiaoMing = { name: '小明' };
每寫一個對象直接量相當於創建了一個新的對象。即使兩個對象直接量看起來一模一樣,它們指向的堆內存地址也是不一樣的,而對象是按引用訪問的,所以這兩個對象是不相等的。
var xiaoMing1 = { name: '小明' };
var xiaoMing2 = { name: '小明' };
xiaoMing1 === xiaoMing2; // false
new 構造函數
可以通過關鍵詞new
調用javascript對象的構造函數來獲得對象實例。比如:
- 創建內置對象實例
var o = new Object();
- 創建自定義對象實例
function Person(name) {
this.name = name;
};
new Person('Faker');
Object.create
Object.create
用於創建一個對象,接受兩個參數,使用語法如下;
Object.create(proto[, propertiesObject]);
第一個參數proto
用於指定新創建對象的原型;
第二個參數propertiesObject
是新創建對象的屬性名及屬性描述符組成的對象。
proto
可以指定爲null
,但是意味着新對象的原型是null
,它不會繼承Object
的方法,比如toString()
等。
propertiesObject
參數與Object.defineProperties
方法的第二個參數格式相同。
var o = Object.create(Object.prototype, {
// foo會成爲所創建對象的數據屬性
foo: {
writable:true,
configurable:true,
value: "hello"
},
// bar會成爲所創建對象的訪問器屬性
bar: {
configurable: false,
get: function() { return 10 },
set: function(value) {
console.log("Setting o.bar to", value);
}
}
});
屬性查詢和設置
屬性查詢
屬性查詢也可以稱爲屬性訪問。在javascript中,對象屬性查詢非常靈活,支持點號查詢,也支持字符串索引查詢(之所以說是“字符串索引”,是因爲寫法看起像數組,索引是字符串而不是數字)。
通過點號加屬性名訪問屬性的行爲很像一些靜態類型語言,如java,C等。屬性名是javascript標識符,必須直接寫在屬性訪問表達式中,不能動態訪問。
var o = { name: '小明' };
o.name; // "小明"
而根據字符串索引查詢對象屬性就比較靈活了,屬性名就是字符串表達式的值,而一個表達式是可以接受變量的,這意味着可以動態訪問屬性,這賦予了javascript程序員很大的靈活性。下面是一個很簡單的示例,而這種特性在業務實踐中作用很大,比如深拷貝的實現,你往往不知道你要拷貝的對象中有哪些屬性。
var o = { chineseName: '小明', englishName: 'XiaoMing' };
['chinese', 'english'].forEach(lang => {
var property = lang + 'Name';
console.log(o[property]); // 這裏使用了字符串索引訪問對象屬性
})
對了,屬性查詢不僅可以查詢自由屬性,也可以查詢繼承屬性。
var protoObj = { age: 18 };
var o = Object.create(protoObj);
o.age; // 18,這裏訪問的是原型屬性,也就是繼承得到的屬性
屬性設置
通過屬性訪問表達式,我們可以得到屬性的引用,就可以據此設置屬性了。這裏主要注意一下只讀屬性和繼承屬性即可,細節不再展開。
原型和繼承
原型
前面也提到了,原型是實現繼承的基礎。那麼如何去理解原型呢?
首先,要明確原型概念中的三角關係,三個主角分別是構造函數,原型,實例。我這裏畫了一張比較簡單的圖來幫助理解下。
原型這東西吧,我感覺“沒人能幫你理解,只有你自己去試過纔是懂了”。
不過這裏說說我剛學習原型時的疑惑,疑惑的是爲什麼構造函數有屬性prototype
指向原型,而實例又可以通過__proto__
指向原型,究竟prototype
和__proto__
誰是原型?其實這明顯是沒有理解對象是按引用訪問這個特點了。原型對象永遠只有一個,它存儲於堆內存中,而構造函數的prototype
屬性只是獲得了原型的引用,通過這個引用可以操作原型。
同樣地,__proto__
也只是原型的引用,但是要注意了,__proto__
不是ECMAScript
規範裏的東西,所以千萬不要用在生產環境中。
至於爲什麼不可以通過__proto__
訪問原型,原因也很簡單。通過實例直接獲得了原型的訪問和修改權限,這本身是一件很危險的事情。
舉個例子,這裏有一個類LatinDancer
,意思是拉丁舞者。經過實例化操作,得到了多個拉丁舞者。
function LatinDancer(name) {
this.name = name;
};
LatinDancer.prototype.dance = function() {
console.log(this.name + '跳拉丁舞...');
}
var dancer1 = new LatinDancer('小明');
var dancer2 = new LatinDancer('小紅');
var dancer3 = new LatinDancer('小王');
dancer1.dance(); // 小明跳拉丁舞...
dancer2.dance(); // 小紅跳拉丁舞...
dancer3.dance(); // 小王跳拉丁舞...
大家歡快地跳着拉丁舞,突然小王這個傢伙心血來潮,說:“我要做b-boy,我要跳Breaking”。於是,他私下改了原型方法dance()
。
dancer3.__proto__.dance = function() {
console.log(this.name + '跳breaking...');
}
dancer1.dance(); // 小明跳breaking...
dancer2.dance(); // 小紅跳breaking...
dancer3.dance(); // 小王跳breaking...
這個時候就不對勁了,小明和小紅正跳着拉丁,突然身體不受控制了,跳起了Breaking,心裏暗罵:“沃尼瑪,勞資不是跳拉丁的嗎?”
這裏只是舉個例子哈,沒有對任何舞種或者舞者不敬的意思,抱歉抱歉。
所以,大家應該也明白了爲什麼不能使用__proto__
了吧。
原型鏈
在javascript中,任何對象都有原型,除了Object.prototype
,它沒有原型,或者說它的原型是null
。
那麼什麼是原型鏈呢?javascript程序在查找一個對象的屬性或方法時,會首先在對象本身上進行查找,如果找不到則會去對象的原型上進行查找。按照這樣一個遞歸關係,如果原型上找不到,就會到原型的原型上找,這樣一直查找下去,就會形成一個鏈,它的終點是null
。
還要注意的一點是,構造函數也是一個對象,也存在原型,它的原型可以通過Function.prototype
獲得,而Function.prototype
的原型則可以通過Object.prototype
獲得。
繼承
說到繼承,可能大家腦子裏已經冒出來“原型鏈繼承”,“借用構造函數繼承”,“寄生式繼承”,“原型式繼承”,“寄生組合繼承”這些概念了吧。說實話,一開始我也是這麼記憶,但是發現好像不是那麼容易理解啊。最後,我發現,只要從原型三角關係入手,就能理清實現繼承的思路。
我們知道,對象實例能訪問的屬性和方法一共有三個來源,分別是:調用構造函數時掛載到實例上的屬性,原型屬性,對象實例化後自身新增的屬性。
很明顯,第三個來源不是用來做繼承的,那麼前兩個來源用來做繼承分別有什麼優缺點呢?很明顯,如果只基於其中一種來源做繼承,都不可能全面地繼承來自父類的屬性或方法。
首先明確下繼承中三個主體:父類,子類,子類實例。那麼怎麼才能讓子類實例和父類搭上關係呢?
原型鏈繼承
所謂繼承,簡單說就是能通過子類實例訪問父類的屬性和方法。而利用原型鏈可以達成這樣的目的,所以只要父類原型、子類原型、子類實例形成原型鏈關係即可。
代碼示例:
function Father() {
this.nationality = 'Han';
};
Father.prototype.propA = '我是父類原型上的屬性';
function Child() {};
Child.prototype = new Father();
Child.prototype.constructor = Child; // 修正原型上的constructor屬性
Child.prototype.propB = '我是子類原型上的屬性';
var child = new Child();
console.log(child.propA, child.propB, child.nationality); // 都可以訪問到
child instanceof Father; // true
可以看到,在上述代碼中,我們做了這樣一個特殊處理Child.prototype.constructor = Child;
。一方面是爲了保證constructor
的指向正確,畢竟實例由子類實例化得來,如果constructor
指向父類構造函數也不太合適吧。另一方面是爲了防止某些方法顯示調用constructor
時帶來的麻煩。具體解釋見Why is it necessary to set the prototype constructor?
關鍵點:讓子類原型成爲父類的實例,子類實例也是父類的實例。
缺點:無法繼承父類構造函數給實例掛載的屬性。
借用構造函數
在調用子類構造函數時,通過call調用父類構造函數,同時指定this值。
function Father() {
this.nationality = 'Han';
};
Father.prototype.propA = '我是父類原型上的屬性';
function Child() {
Father.call(this);
};
Child.prototype.propB = '我是子類原型上的屬性';
var child = new Child();
console.log(child.propA, child.propB, child.nationality);
這裏的child.propA
是undefined
,因爲子類實例不是父類的實例,無法繼承父類原型屬性。
child instanceof Father; // false
關鍵點:構造函數的複用。
缺點:子類實例不是父類的實例,無法繼承父類原型屬性。
組合繼承
所謂組合繼承,就是綜合上述兩種方法。實現代碼如下:
function Father() {
this.nationality = 'Han';
};
Father.prototype.propA = '我是父類原型上的屬性';
function Child() {
Father.call(this);
};
Child.prototype = new Father();
Child.prototype.constructor = Child; // 修正原型上的constructor屬性
Child.prototype.propB = '我是子類原型上的屬性';
var child = new Child();
console.log(child.propA, child.propB, child.nationality); // 都能訪問到
一眼看上去沒什麼問題,但是Father()
構造函數其實是被調用了兩次的。第一次發生在Child.prototype = new Father();
,此時子類原型成爲了父類實例,執行父類構造函數Father()
時,獲得了實例屬性nationality
;第二次發生在var child = new Child();
,此時執行子類構造函數Child()
,而Child()
中通過call()
調用了父類構造函數,所以子類實例也獲得了實例屬性nationality
。這樣理解起來可能有點晦澀難懂,我們可以看看子類實例的對象結構:
可以看到,子類實例和子類原型上都掛載了執行父類構造函數時獲得的屬性nationality
。然而我們做繼承的目的是很單純的,即“讓子類繼承父類屬性和方法”,但並不應該給子類原型掛載不必要的屬性而導致污染子類原型。
有人會說“這麼一點副作用怕什麼”。當然,對於這麼簡單的父類而言,這種副作用微乎其微。假設父類有幾百個屬性或方法呢,這種白白耗費性能和內存的行爲是有必要的嗎?答案顯而易見。
關鍵點:實例屬性和原型屬性都得以繼承。
缺點:父類構造函數被執行了兩次,污染了子類原型。
原型式繼承
原型式繼承是相對於原型鏈繼承而言的,與原型鏈繼承的不同點在於,子類原型在創建時,不會執行父類構造函數,是一個純粹的空對象。
function Father() {
this.nationality = 'Han';
};
Father.prototype.propA = '我是父類原型上的屬性';
function Child() {};
Child.prototype = Object.create(Father.prototype);
Child.prototype.constructor = Child; // 修正原型上的constructor屬性
Child.prototype.propB = '我是子類原型上的屬性';
var child = new Child();
console.log(child.propA, child.propB, child.nationality); // 都可以訪問到
child instanceof Father; // true
在ES5
之前,可以這樣模擬Object.create
:
function create(proto) {
function F() {}
F.prototype = proto;
return new F();
}
關鍵點:利用一個空對象過渡,解除子類原型和父類構造函數的強關聯關係。這也意味着繼承可以是純對象之間的繼承,無需構造函數介入。
缺點:無法繼承父類構造函數給實例掛載的屬性,這一點和原型鏈繼承並無差異。
寄生式繼承
寄生式繼承有借鑑工廠函數的設計模式,將繼承的過程封裝到一個函數中並返回對象,並且可以在函數中擴展對象方法或屬性。
var obj = {
nationality: 'Han'
};
function inherit(proto) {
var o = Object.create(proto);
o.extendFunc = function(a, b) {
return a + b;
}
return o;
}
var inheritObj = inherit(obj);
這裏inheritObj
不僅繼承了obj
,而且也擴展了extendFunc
方法。
關鍵點:工廠函數,封裝過程函數化。
缺點:如果在工廠函數中擴展對象屬性或方法,無法得到複用。
寄生組合繼承
用以解決組合繼承過程中存在的“父類構造函數多次被調用”問題。
function inherit(childType, fatherType) {
childType.prototype = Object.create(fatherType.prototype);
childType.prototype.constructor = childType;
}
function Father() {
this.nationality = 'Han';
}
Father.prototype.propA = '我是父類原型上的屬性';
function Child() {}
inherit(Child, Father); // 繼承
Child.prototype.propB = '我是子類原型上的屬性';
var child = new Child();
console.log(child);
關鍵點:解決父類構造函數多次執行的問題,同時讓子類原型變得更加純粹。
靜態方法
何謂“靜態方法”?靜態方法爲類所有,不歸屬於任何一個實例,需要通過類名直接調用。
function Child() {}
Child.staticMethod = function() { console.log("我是一個靜態方法") }
var child = new Child();
Child.staticMethod(); // "我是一個靜態方法"
child.staticMethod(); // Uncaught TypeError: child.staticMethod is not a function
Object
類有很多的靜態方法,我學習的時候習慣把它們分爲這麼幾類(當然,這裏沒有全部列舉開來,只挑了常見的方法)。
創建和複製對象
-
Object.create()
:基於原型和屬性描述符集合創建一個新對象。 -
Object.assign()
:合併多個對象,會影響源對象。所以在合併對象時,爲了避免這個問題,一般會這樣做:
var mergedObj = Object.assign({}, a, b);
屬性相關
-
Object.defineProperty
:通過屬性描述符來定義或修改對象屬性,主要涉及value
,configurable
,writable
,enumerable
四個特性。 -
Object.defineProperties
:是defineProperty
的升級版本,一次性定義或修改多個屬性。 -
Object.getOwnPropertyDescriptor
:獲取屬性描述符,是一個對象,包含value
,configurable
,writable
,enumerable
四個特性。 -
Object.getOwnPropertyNames
:返回一個由指定對象的所有自身屬性的屬性名(包括不可枚舉屬性但不包括Symbol值作爲名稱的屬性)組成的數組。 -
Object.keys
:會返回一個由一個給定對象的自身可枚舉屬性組成的數組,與getOwnPropertyNames
最大的不同點在於:keys
只返回enumerable
爲true
的屬性,並且會返回原型對象上的屬性。
原型相關
Object.getPrototypeOf
:返回指定對象的原型。
function Child() {}
var child = new Child();
Object.getPrototypeOf(child) === Child.prototype; // true
Object.setPrototypeOf
:設置指定對象的原型。這是一個比較危險的動作,同時也是一個性能不佳的方法,不推薦使用。
行爲控制
以下列舉的這三個方式是一個遞進的關係,我們按序來看:
-
Object.preventExtensions
:讓一個對象變的不可擴展,也就是永遠不能再添加新的屬性。 -
Object.seal
:封閉一個對象,阻止添加新屬性並將所有現有屬性標記爲不可配置。也就是說Object.seal
在Object.preventExtensions
的基礎上,給對象屬性都設置了configurable
爲false
。
這裏有一個坑是:對於configurable
爲false
的屬性,雖然不能重新設置它的configurable
和enumerable
特性,但是可以把它的writable
特性從true
改爲false
(反之不行)。
Object.freeze
:凍結一個對象,不能新增,修改,刪除屬性,也不能修改屬性的原型。這裏還有一個深凍結deepFreeze
的概念,有點類似深拷貝的意思,遞歸凍結。
檢測能力
-
Object.isExtensible
:檢測對象是否可擴展。 -
Object.isSealed
:檢測對象是否被封閉。 -
Object.isFrozen
:檢測對象是否被凍結。
兼容性差
-
Object.entries
-
Object.values
-
Object.fromEntries
原型方法
原型方法是指掛載在原型對象上的方法,可以通過實例調用,本質上是藉助原型對象調用。例如:
function Child() {}
Child.prototype.protoMethod = function() { console.log("我是一個原型方法") }
var child = new Child();
child.protoMethod(); // "我是一個原型方法"
ECMAScript給Object
定義了很多原型方法。
hasOwnProperty
該方法會返回一個布爾值,指示對象自身屬性中是否具有指定的屬性(也就是,是否有指定的鍵),常配合for ... in
語句一起使用,用來遍歷對象自身可枚舉屬性。
isPrototypeOf
該方法用於測試一個對象是否存在於另一個對象的原型鏈上。Object.prototype.isPrototypeOf
與Object.getPrototypeOf
不同點在於:
-
Object.prototype.isPrototypeOf
判斷的是原型鏈關係,並且返回一個布爾值。 -
Object.getPrototypeOf
是獲取目標對象的直接原型,返回的是目標對象的原型對象
PropertyIsEnumerable
該方法返回一個布爾值,表示指定的屬性是否可枚舉。它檢測的是對象屬性的enumerable
特性。
valueOf & toString
對象轉原始值會用到的方法,之前寫過一篇筆記,具體見js數據類型很簡單,卻也不簡單。
toLocaleString
toLocaleString
方法返回一個該對象的字符串表示。此方法被用於派生對象爲了特定語言環境的目的(locale-specific purposes)而重載使用。常見於日期對象。
最後
通過閱讀本文,讀者們可以對Javascript對象有一個基本的認識。對象是Javascript中非常複雜的部分,絕非一篇筆記或一張思維導圖可囊括,諸多細節不便展開,可關注我留言交流,回覆“思維導圖”可獲取我整理的思維導圖。