JavaScript語言與傳統的面嚮物件語言(如Java)有點不一樣,js語言設計的簡單靈活,沒有class、namespace等相關概念,而是萬物皆物件。雖然js不是一個純正的面嚮物件語言,但依然可以對js物件導向程式設計。java語言物件導向程式設計的基礎是類,而js語言物件導向程式設計的基礎是原型
。
原型是學習js的基礎之一,由它衍生出許多像原型鏈、this指向、繼承等問題。所以深入掌握js原型,才能對其衍生的問題有很好的理解。網上有很多文章解釋原型裡的等式關係,那樣有些晦澀難懂,這裡筆者從js設計歷史來逐步解釋js原型。
歷史
在ES6前,js語法是沒有class
的。倒不是js語言作者Brendan Eich忘記引入class語法,而是因為當初設計js語言時,只想解決表單驗證等簡單問題(估計js作者沒想到後來js成為最流行的語言之一),沒必要引入class這種重型武器,不然就跟Java這種基於class的面嚮物件語言一樣了。具體可以看下阮一峰老師的Javascript繼承機制的設計思想。
雖然設計js語言時,更多的考慮輕量級靈活,但依然要在語言層面考慮物件封裝以及多個物件之間複用的問題。先看下使用傳統方式進行封裝:
function Person(name) {
return {
name: name,
sleep: function() {
console.log( 'go to sleep' )
}
}
}
var person1 = Person('tom')
var person2 = Person('lucy')
... personN
複製程式碼
傳統方式有以下兩個弊端:
- person1、person2...personN 例項物件沒有內在聯絡,它們只是基於相同的工廠函式生成。
- 浪費記憶體,無法共享屬性和方法。比如每個人的sleep方法是相同的,但生成10000個person例項會有10000個sleep方法佔據記憶體
既然無意引入class語法,同時需要滿足物件的封裝以及複用問題,那就需要在js語言層面引入一種機制來處理class問題。
原型
js作者使用了原型概念來解決class問題
。那什麼是原型?原型是如何在語法層實現的?會涉及到哪些概念?
constructor
js原型概念通俗講有點像Java中的類概念,多個例項是基於共同的型別定義,就像tom、lucy這些真實的人(佔據空間)基於Person概念(不佔空間,只是定義)。java中類是基於class關鍵字的,但js中沒有class關鍵字,有的是function。而java類定義中都有個建構函式,例項化物件時會執行該建構函式,所以js作者簡化把建構函式constructor作為原型(代替class)的定義
。同時規定建構函式需要滿足以下條件:
- 首字母大寫
- 內部使用this
- 搭配使用new生成例項
// java定義類
class Person {
// java類中都有建構函式
constructor(name) {
this.name = name
}
public void sleep() {
....
}
}
複製程式碼
// js使用建構函式代替類的作用
function Person(name) {
this.name = name
this.sleep = function() { ... }
}
複製程式碼
new
以上通過建構函式定義了Person這個類。但如何區分一個function定義是建構函式還是普通函式?難道是看定義的函式裡面是否有this來判斷?
當然不是,js作者引入new關鍵字來解決該問題
。因為建構函式只是定義原型(不佔據記憶體),最終還是需要產生例項(佔據記憶體)來處理流程,所以使用new關鍵字來產生例項。同時規定new後面跟的是建構函式,而不是普通函式。這樣就區分出定義的function,是建構函式還是普通函式。
// new 關鍵字後跟上建構函式,生成例項
// 語法層面上也和Java例項類一致
var tom = new Person('tom')
var lucy = new Person('lucy')
複製程式碼
你肯定注意到建構函式中this的疑問
,它到底是在哪定義的?this又代表什麼?其實在執行new的過程中,它會發生以下一些事:
- 新的物件tom被建立,佔據記憶體。
- 把this指向tom例項,任何this上的引用最終都是指向tom
- 新增__proto__屬性到tom例項上,並且把tom.__proto__指向Person.prototype(後續會講的原型鏈)
- 執行建構函式,最終返回tom物件
// 模擬new的實現
function objectFactory() {
var obj = new Object(),
Constructor = [].shift.call(arguments); // 取出第一個引數,即建構函式
obj.__proto__ = Constructor.prototype;
var ret = Constructor.apply(obj, arguments);
return typeof ret === 'object' ? ret : obj;
};
var tom = objectFactory(Person, 'tom')
// 賦值的this === tom
console.log(tom.name) // tom
console.log(tom.sleep) // Function
複製程式碼
prototype
new 和 constructor 解決了模擬class類的概念,使得產生的多個例項物件有共同的原型,同型別物件內在有了一些聯絡。看上去很完美,但還有個問題:每個例項物件本質上還是拷貝了建構函式物件裡的屬性和方法。tom和lucy例項的sleep方法依然建立了兩個記憶體空間進行儲存,而不是一個。這樣不僅無法資料共享,對記憶體的浪費也是極大的(想象下再生成10000個tom)。那js作者是如何解決這個問題的?
Brendan Eich為建構函式設定一個prototype屬性來儲存這些公用的方法或屬性
。prototype屬性是一個物件,你可以擴充套件該物件,也可以覆寫該物件。當你通過new constructor() 生成例項時,這些例項的公用方法(如:tom.sleep方法)並不會在記憶體中建立多份,而是通過指標都指向建構函式的prototype屬性(如:Person.prototype)。
注意:Person建構函式和Person.prototype都是物件,擁有諸多屬性。並且物件的屬性依然可以是物件,萬物皆物件核心。
function Person(name) {
this.name = name
}
// 建構函式都有一個非空的prototype物件
// 可以擴充套件該物件,也可以覆寫該物件,以下在原型上擴充套件sleep方法
Person.prototype.sleep = function() { ... }
var tom = new Person('tom')
var lucy = new Person('lucy')
tom.sleep === lucy.sleep // true
複製程式碼
由於所有例項物件共享同一個prototype物件(建構函式的prototype屬性),那麼從外界看起來,prototype物件就好像是例項物件的原型,而例項物件則好像"繼承"了prototype物件一樣。這就是我們通俗講的:js物件導向程式設計是基於原型
。
Javascript規定,每一個建構函式都有一個prototype屬性,指向另一個物件。這個物件的所有屬性和方法,都會被建構函式的例項繼承。
原型鏈
我們再深入思考下,js是如何把各個例項跟建構函式的prototype物件(以下稱原型物件)聯絡起來的?它們之間的通道是如何建立起來的?答案是使用new關鍵字。在上面我們模擬new關鍵字流程中,有個步驟是: 新增__proto__屬性到tom例項上,並且把tom.__proto__指向Person.prototype。所以可以得到結論:例項與原型物件間關聯起來是通過__proto__屬性
。
__proto__屬性有什麼用?當訪問例項物件的屬性或方法時,如果沒有在例項上找到,js會順著__proto__去查詢它的原型,如果找到就返回。由於原型物件(如Person.prototype)也是一個物件,它也可以有自己的原型物件(比如覆寫它),這樣層層上溯,就形成了一個類似連結串列的結構,這就是原型鏈(prototype chain)
。而通過覆寫子類原型物件,再根據js原型鏈機制,可以讓子類擁有父類的內容,就像繼承一樣,所以原型鏈是js繼承的基礎
。
tom.__proto__ === lucy.__proto__ === Person.prototype // true
tom.sleep() // sleep方法是在原型鏈上找到的
複製程式碼
注意new關鍵字以及原型鏈查詢都是js語言內建的
總結
- 對原型的概念理解,語法層面不僅僅是prototype,還有constructor、new。
- 可以把建構函式當作是特殊的函式,但記住它終歸只是一個函式。
- prototype屬性是每個函式都有的,並且值是個不為空的物件,這在js語法層面就確定的
- __proto__屬性是在例項物件上,prototype屬性是在建構函式上,並且在new關鍵字作用下兩者指向同一個地方。
- js物件導向是利用原型來實現,js繼承是利用原型鏈來實現的