「思維導圖學前端 」6k字一文搞懂Javascript對象,原型,繼承

前言

去年開始我給自己畫了一張知識體系的思維導圖,用於規劃自己的學習範圍和方向。但是我犯了一個大錯,我的思維導圖只是一個全局的藍圖,而在學習某個知識點的時候沒有系統化,知識太過於零散,另一方面也很容易遺忘,回頭複習時沒有一個提綱,整體的學習效率不高。意識到這一點,我最近開始用思維導圖去學習和總結具體的知識點,效果還不錯。試想一下,一張思維導圖的某個端點是另一張思維導圖,這樣串起來的知識鏈條是多麼“酸爽”!當然,YY一下就好了,我保證你沒有足夠的時間給所有知識點都畫上思維導圖,挑重點即可。

提綱思路

當我們要研究一個問題或者知識點時,關注點無非是:

  1. 是什麼?

  2. 做什麼?

  3. 爲什麼?

很明顯,搞懂“是什麼”是最最基礎的,而這部分卻很重要。萬丈高樓平地起,如果連基礎都不清楚,何談應用實踐(“做什麼”),更加也不會理解問題的本質(“爲什麼”)。

而要整理一篇高質量的思維導圖,必須充分利用“總-分”的思路,首先要形成一個基本的提綱,然後從各個方面去延伸拓展,最後得到一棵較爲完整的知識樹。分解知識點後,在細究的過程中,你可能還會驚喜地發現一個知識點的各個組成部分之間的關聯,對知識點有一個更爲飽滿的認識。

梳理提綱需要對知識點有一個整體的認識。如果是學習比較陌生的領域知識,我的策略是從相關書籍或官方文檔的目錄中提煉出提綱。

下面以我複習javascript對象這塊知識時的一些思路爲例說明。

javascript對象

在複習javascript對象這塊知識時,我從過往的一些使用經驗,書籍,文檔資料中提煉出了這麼幾個方面作爲提綱,分別是:

  • 對象的分類

  • 對象的三個重要概念:類,原型,實例

  • 創建對象的方法

  • 對象屬性的訪問和設置

  • 原型和繼承

  • 靜態方法和原型方法

由此展開得到了這樣一個思維導圖:

js對象

對象的分類

對象主要分爲這麼三大類:

  • 內置對象:ECMAScript規範中定義的類或對象,比如Object, Array, Date等。

  • 宿主對象:由javascript解釋器所嵌入的宿主環境提供。比如瀏覽器環境會提供windowHTMLElement等瀏覽器特有的宿主對象。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對象的構造函數來獲得對象實例。比如:

  1. 創建內置對象實例
var o = new Object();
  1. 創建自定義對象實例
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.propAundefined,因爲子類實例不是父類的實例,無法繼承父類原型屬性。

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只返回enumerabletrue的屬性,並且會返回原型對象上的屬性。

原型相關

  • Object.getPrototypeOf:返回指定對象的原型。
function Child() {}
var child = new Child();
Object.getPrototypeOf(child) === Child.prototype; // true
  • Object.setPrototypeOf:設置指定對象的原型。這是一個比較危險的動作,同時也是一個性能不佳的方法,不推薦使用。

行爲控制

以下列舉的這三個方式是一個遞進的關係,我們按序來看:

  • Object.preventExtensions:讓一個對象變的不可擴展,也就是永遠不能再添加新的屬性。

  • Object.seal:封閉一個對象,阻止添加新屬性並將所有現有屬性標記爲不可配置。也就是說Object.sealObject.preventExtensions的基礎上,給對象屬性都設置了configurablefalse

這裏有一個坑是:對於configurablefalse的屬性,雖然不能重新設置它的configurableenumerable特性,但是可以把它的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定義了很多原型方法。

Object原型方法

hasOwnProperty

該方法會返回一個布爾值,指示對象自身屬性中是否具有指定的屬性(也就是,是否有指定的鍵),常配合for ... in語句一起使用,用來遍歷對象自身可枚舉屬性。

isPrototypeOf

該方法用於測試一個對象是否存在於另一個對象的原型鏈上。Object.prototype.isPrototypeOfObject.getPrototypeOf不同點在於:

  • Object.prototype.isPrototypeOf判斷的是原型鏈關係,並且返回一個布爾值。

  • Object.getPrototypeOf是獲取目標對象的直接原型,返回的是目標對象的原型對象

PropertyIsEnumerable

該方法返回一個布爾值,表示指定的屬性是否可枚舉。它檢測的是對象屬性的enumerable特性。

valueOf & toString

對象轉原始值會用到的方法,之前寫過一篇筆記,具體見js數據類型很簡單,卻也不簡單

toLocaleString

toLocaleString方法返回一個該對象的字符串表示。此方法被用於派生對象爲了特定語言環境的目的(locale-specific purposes)而重載使用。常見於日期對象。

最後

通過閱讀本文,讀者們可以對Javascript對象有一個基本的認識。對象是Javascript中非常複雜的部分,絕非一篇筆記或一張思維導圖可囊括,諸多細節不便展開,可關注我留言交流,回覆“思維導圖”可獲取我整理的思維導圖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章