緣起
工作中需要用到Javascript,關注了Javascript中繼承複用的問題,翻閱了網上的各種關於Javascript繼承的帖子,感覺大都思考略淺,並沒有做過多說明,簡單粗暴的告訴你實現Javascript繼承有1.2.3.4.5幾種方式,名字都叫啥,然後貼幾行代碼就算是玩了。
不管你們懂沒懂,反正我着實沒懂。
隨後研讀了《Javascript高級程序設計》的部分章節,對Javascript的繼承機制略有體會。思考之後,遂而分享並且闡述瞭如何實現抽象類、接口、多態甚至是類型轉換的思路。
JS繼承,那就先說“繼承”
凡是玩過1、2種面向對象的語言的人大都不難歸納出繼承所有的幾個特性:
1. 子類繼承父類中的屬性和方法
2. 子類、父類的實例對象擁有兩份副本,改了其中之一,另一個實例對象的屬性並不會隨之改變
3. 子類可覆蓋父類的方法或屬性
4. 子類和父類的實例對象通過“[對象] instanceof [子類/父類]”判定的結果應該爲true
5. 子類和父類的實例對象的constructor指針應該分別指向子類和父類的構造函數
構造一個類
說到構造一個Javascript的類,網上的說法五花八門。
1. 有說JS中根本沒有類,用模擬實現的。對,但是也不對。
Javascript中的確沒有class關鍵字,但是這並不帶表我們封裝不出一個“類”一樣的東西來。只不過在Javascript中不叫這個名字而已。遂而有人會反駁,在ECMAScript 6標準中要加入class關鍵字了,這不是明顯表示javascript現在不存在“類”麼?對於這樣的擡槓,只能“呵呵”了。
2. 也有人說JS可以這麼構造一個類:
1 |
var Person
= function (name,
age) { |
5 |
var p
= new Person( "小王" ,
10); |
注1:此爲代碼1,後面可能作爲引用。
注2: var Person = function(){};
等同於 function
Persson(){}
,前一種定義函數的方式沒有名字,故而在var的後面跟上其名字,而後面function定義直接就跟了名字Person了。不過事實上我更喜歡後一種,因爲可以少寫一個var和分號。但是如果在局部作用域要定義一個臨時類,我還是喜歡前一種,這是一種變量的方式。在局部作用域我更喜歡定義變量而不是函數或者類等結構性的東西,C語言後遺症,呵呵。
注3:其實這種構建類的方式可以說成是通過構造器(constructor)來構造一個類。
3. 也有人說,應該用Prototype來構造一個類,簡要代碼如下:
1 |
var Person
= function (){}; |
2 |
Person.prototype.name
= "小王" ; |
3 |
Person.prototype.age
= 10; |
注4:這種構造方式,我們可以暫且稱之爲“用prototype”的方式來構造。
注5:此爲代碼2,後面可能作引用。
類的構建方式雖然五花八門,但是大抵都是以上兩種或者其組合的變種。可是我們什麼時候用構造函數來構建?什麼時候用prototype?什麼時候兩者結合使用呢?要明白這個,我們先來看看new關鍵字。
new,你到底幹了什麼事兒?
new關鍵字在絕大多數面向對象的語言中都扮演者舉足輕重的位置,javascript中也不例外。StackOverflow上有一篇帖子關於new關鍵字的玄機,我覺得說的很好:Javascript中的new關鍵字背後到底做了什麼
翻譯如下,爲了懶得移步的童鞋,PC端的童鞋可以直接點過去。
- 創建一個新的簡單的Object類型的的對象;
- 把Object的內部的[[prototype]]屬性設置爲構造函數prototype屬性。這個[[prototype]]屬性在Object內部是無法訪問到的,而構造函數的prototype是可以訪問到的;
- 執行構造函數,如果構造函數中用到了this關鍵字,那就把這個this替換爲剛剛創建的那個object對象。
注6:其實某個對象的[[prototype]]屬性在很多宿主環境中已經可以訪問到,例如Chrome和IE10都可以,用_proto_就可以訪問到,如果下面出現了_proto_字樣,那就代表一個對象的內部prototype。
上面說了一大通,又是構造器,又是prototype,不知所云。下面依次解釋。
prototype
prototype屬性在構造函數中可以訪問到,在對象中需要通過prototype訪問到。它到底是什麼?prototype中定義了一個類所共享的屬性和方法。這就意味着:一旦prototype中的某個屬性的值變了,那麼所有這個類的實例的該屬性的值都變了。請看代碼:
03 |
Person.prototype.name
= "小明" ; |
04 |
var p1
= new Person(); |
05 |
console.log(Person.prototype); |
06 |
console.log(p1.__proto__); |
07 |
var p2
= new Person(); |
08 |
console.log(p1.name
+ "\t" +
p2.name); |
09 |
Person.prototype.name
= "小王" ; |
10 |
console.log(p1.name
+ "\t" +
p2.name); |
注7:此爲代碼3。 輸出結果如下:
通過這個代碼3的實驗,我們可以得出以下結論:
1. prototype屬性其實就是一個實例對象,其內容爲:Person {name: "小明"}
2. 通過構造函數可以訪問到prototype屬性,通過對象的_proto_也可以訪問到prototype屬性。
3. prototype原型指向的內容是所有對象共享的,只要prototype對象的某個屬性或者方法變了,那麼所有的通過這個類new出來的實例對象的該屬性和方法都變了。
this和構造函數
看完了上面的new關鍵字做的第3步,我們不難得出,其實利用constructor的方式來構造類本質:先new一個臨時實例對象,將this關鍵字替換爲臨時實例對象關鍵字,然後使用[對象].[屬性]=xxx
的方式來構建一個對象,再將其返回。
可是這樣帶來一個問題就是:方法不被共享。
請看代碼4實驗:
03 |
this .showName
= function ()
{ |
04 |
console.log( this .name) |
07 |
var p1
= new Person(); |
08 |
var p2
= new Person(); |
11 |
p1.showName
= function ()
{ |
12 |
console.log( "我不是小明,我是小王" ); |
注8:以上爲代碼4。
其運行結果爲:
我們知道,類的同一個方法,應該儘量保持共享,因爲他們屬於同一個類,那麼這一個方法應該相同,所以應該保持共享,不然會浪費內存。
我們的Person類中含有方法showName,雖然p1和p2實例屬於兩個實例對象,但是其showName卻指向了不同的內存塊!
這可怎麼辦?
對,請出我們的prototype,它可以實現屬性和方法的共享。請看代碼5實驗:
04 |
Person.prototype.showName
= function ()
{ |
05 |
console.log( this .name); |
07 |
var p1
= new Person(); |
08 |
var p2
= new Person(); |
11 |
Person.prototype.showName
= function ()
{ |
12 |
console.log( "我的名字是" + this .name); |
注9:以上爲代碼5 。 運行結果如下:
這樣我們非常完美地完成了一個類的構建,他滿足:
1. 屬性非共享
2. 方法共享(其實對於需要共享的屬性,我們也可以用prototype來設置)
但是!大家在使用prototype來設置共享方法的時候千萬不要把構造函數的整個prototype都改寫了。這樣導致的結果就是:constructor不明。
請看代碼6實驗。
04 |
Person1.prototype.showName
= function ()
{ |
05 |
console.log( this .name); |
07 |
var p1
= new Person1(); |
08 |
console.log(p1 instanceof Person1); |
09 |
console.log(p1.constructor); |
14 |
showName
: function ()
{ |
15 |
console.log( this .name); |
18 |
var p2
= new Person2(); |
19 |
console.log(p2 instanceof Person2); |
20 |
console.log(p2.constructor); |
注10:以上爲代碼6 。 運行結果如下:
通過以上代碼6的實驗,我們可以看出:重寫整個prototype會將對象的constructor指針直接指向了Object,從而導致了constructor不明的問題。
如何解決呢?我們可以通過顯示指定其constructor爲Person即可。
請看代碼7:
05 |
constructor
: Person2, |
06 |
showName
: function ()
{ |
07 |
console.log( this .name); |
10 |
var p2
= new Person2(); |
11 |
console.log(p2 instanceof Person2); |
12 |
console.log(p2.constructor); |
注11:以上爲代碼7 。 運行結果如下:
對象、constructor和prototype三者之間的關係
上面說了那麼多,我想大家都有點被constructor、prototype、對象搞得雲裏霧裏的,其實我剛開始也是這樣。下面我總結敘述一下這三者之間的關係,相信看了之後就會逐漸明白的:
1. 構造函數有個prototype屬性,這個prototype屬性指向一個實例對象,這個對象的所有的屬性和方法爲所有該構造函數實例化的類所共享!
2. 對象的創建是通過constructor構造函數來創建的,每當new一次就調用一次構造函數,構造函數內部執行的機制是:new一個臨時Object實例空對象,然後把this關鍵字提換成這個臨時對象,然後依次設置這個臨時對象的各個屬性和方法,最後返回這個臨時實例對象。
3. 被實例化的對象本身有個_proto_指針,指向創建該對象的構造函數的的prototype對象。
如果你還是雲裏霧裏的,沒有關係,我們來看下Javascript的Object架構,看完這個你肯定就會明白的一清二楚了。
Javascript的Object架構
解釋如下:
1. var f1 = new Foo();
創建了一個Foo對象;
2. f1對象有個內部__proto__
屬性,指向了一個prototype的實例對象Foo.prototype
;
3. Foo.prototype
有個constructor
屬性,指向了Foo構造函數,這個屬性的值標明瞭,這個f1對象的類型,也即f1
instanceof Foo的結果爲true;
4. 構造函數Foo有個prototype屬性,指向了prototype實例對象,這個prototype屬性是通過Foo可以直接訪問到的Foo.prototype
;
5. 剩下的解釋,大家能看就看懂,看不懂我後續再出文章解釋吧。與本篇關係不是太大了。
Javascript對象的屬性查找方式
我們訪問一個Javascript對象的屬性(含“方法”)的時候,查找過程到底是什麼樣的呢?
先找先找對象屬性,對象的屬性中沒有,那就找對象的prototype共享屬性
請看代碼8:
8 |
Object.prototype.myName= "我的名字是小明" ; |
注12:以上爲代碼8 。
結果如下:
此處不難理解,不多做解釋。
按照“繼承”理念來實現JS繼承
在我們懂了prototype、constructor、對象、new之後,我們可以真正按照“繼承”的理念來實現javascript的繼承了。
原型鏈
試想一下,如果構造函數的prototype對象的_proto_指針(每個實例對象都有一個proto指針)指向的是另一個prototype對象(我們稱之爲prototype對象2)的話,而prototype對象2的constructor指向的是構建prototype對象2的構造函數。那麼依次往復,就構成了原型鏈。
上面的話有點繞口,大家多多體會。
我結合上面的Javascript對象的架構繼續給大家說說:
1. 大家可以看到Foo.prototype對象的_proto_指向了Object.prototype對象,而這個Object.prototype的constructor屬性指向的是Object構造函數。這裏就是一個簡單的原型鏈。
2. 所有的類都有原型鏈,最終指向Object。
大家或許已經懷疑,聽說Javascript的所有的對象都是繼承自Object對象,那麼Javascript繼承是不是就這個原型連搞的鬼呢?
是,但是不完全是。
原型鏈只能繼承共享的屬性和方法,對於非共享的屬性和方法,我們需要通過顯示調用父類構造函數來實現
查找對象的屬性的修正:
1. 查找對象是否含有該屬性;
2. 如果沒有改屬性,則查找其prototype是否含有該屬性;
3. 如果還是沒有,則向上查找原型鏈的上一級,查找其prototype的_proto_所指向的prototype是否含有該屬性,直到查找Object。
所以很簡單,我們想要實現Javascript的繼承已經呼之欲出了:
1. 繼承prototype中定義的屬性和方法;
2. 繼承構造函數中定義的屬性和方法;
3. 修改子類的prototype對象的constructor指針,使得constructor的判別正確。
繼承構造函數中定義的屬性和方法
我們通過call或者apply方法即可實現父類構造函數調用,然後把當前對象this和參數傳遞給父類,這樣就可以實現繼承構造函數中定義的屬性和方法了。請看代碼9:
01 |
function Person(name,
age) { |
05 |
Person.prototype.showName
= function ()
{ |
06 |
console.log( this .name); |
08 |
function Male(name,
age) { |
10 |
Person.apply( this ,
arguments); |
13 |
var m
= new Male( "小明" ,
20); |
15 |
console.log(m instanceof Male); |
16 |
console.log(m instanceof Person); |
17 |
console.log(m instanceof Object); |
執行結果如下:
1. 大家可以看到,m就是一個很簡單的對象,只有name,age,sex三個屬性,不含有showName方法,因爲這個是在Person.prototype
中繼承過來的。
2. m instanceof Person
結果爲false, 顯然m.\__proto\__.constructor
指向的是Male構造函數,而非Person。
3. 可是m instanceof Object
的結果卻爲true,那是因爲m的原型鏈的上一級爲Object類型,故而instance
of Object
的結果爲true。
繼承prototype中定義的屬性和方法,並且與繼承構造函數結合起來
如何繼承prototype中定義的屬性和方法呢?
直接把父類的prototype給子類的prototype不就行了。
的確,這樣是能夠實現方法共享,可是一旦子類的prototype的某個方法被重寫了,那麼父類也會擱着變動,怎麼辦?
new一個父類!賦值給子類的prototype。
請看代碼10:
01 |
function Person(name,
age) { |
05 |
Person.prototype.showName
= function ()
{ |
06 |
console.log( this .name); |
08 |
function Male(name,
age) { |
10 |
Person.apply( this ,
arguments); |
14 |
Male.prototype
= new Person(); |
15 |
var m
= new Male( "小明" ,
20); |
17 |
console.log(m instanceof Male); |
18 |
console.log(m instanceof Person); |
19 |
console.log(m instanceof Object); |
結果如下:
大家可以看到m對象不僅有name, age , sex三個屬性,而且通過其原型鏈可以找到showName方法。
如果大家仔細觀察,會發現多出了兩個undefined值的name和age!
爲什麼?!
究其原因,因爲在執行Male.prototype = new Person()
的時候,這兩個屬性就在內存中分配了值了。而且改寫了Male的整個prototype,導致Male對象的constructor也跟着變化了,這也不好。
這並不是我們想要的!我們只是單純的想要繼承prototype,而不想要其他的屬性。
怎麼辦?
借用一個空的構造函數,借殼繼承prototype,並且顯示設置constructor
代碼如下:
01 |
function Person(name,
age) { |
05 |
Person.prototype.showName
= function ()
{ |
06 |
console.log( this .name); |
08 |
function Male(name,
age) { |
10 |
Person.apply( this ,
arguments); |
15 |
F.prototype
= Person.prototype; |
17 |
Male.prototype
= new F(); |
19 |
Male.prototype.constructor
= Male; |
20 |
var m
= new Male( "小明" ,
20); |
23 |
console.log(m.constructor
== Male); |
24 |
console.log(m instanceof Person); |
25 |
console.log(m instanceof Male); |
26 |
console.log(m instanceof F); |
執行結果:
我們可喜的將m的constructor正本清源!而且instanceof類型判斷都沒有錯誤(instanceof本質上是通過原型鏈找的,只要有一個原型滿足了那結果就爲true)。
繼承prototype的封裝&測試
上述繼承prototype的代碼很是醜陋,讓我們封裝起來吧。並且測試了一下代碼:
02 |
function inheritPrototype(subType,
superType) { |
07 |
F.prototype
= superType.prototype; |
12 |
proto.constructor
= subType; |
14 |
subType.prototype
= proto; |
16 |
function Person(name,
age) { |
19 |
this .getName
= function ()
{ |
23 |
Person.prototype.getAge
= function ()
{ |
26 |
function Male(name,
age) { |
27 |
Person.apply( this ,
[name, age]); |
29 |
this .getSex
= function ()
{ |
33 |
inheritPrototype(Male,
Person); |
35 |
Male.prototype.getAge
= function ()
{ |
38 |
var p
= new Person( "好女人" ,
30); |
39 |
var m
= new Male( "好男人" ,
30); |
42 |
console.log(p.getAge()); |
43 |
console.log(m.getAge()); |
運行結果爲:
至此,我們已經完成了真正意義上的javascript繼承!
讓我們再來回頭驗證一下,TDD嘛~呵呵
1. 子類繼承父類中的屬性和方法。Check!
2. 子類、父類的實例對象擁有兩份副本,改了其中之一,另一個實例對象的屬性並不會隨之改變。Check!通過constructor繼承屬性,由於採用了new,故而每個實例對象的屬性肯定是有不同的副本。
3. 子類可覆蓋父類的方法或屬性。Check!由於方法的繼承是採用繼承prototype來實現的,借F的prototype來繼承,所以所有被繼承的方法都在new F()的一剎那存在了F中,而F是一個空構造函數,故而沒有多餘的屬性,只有被繼承的方法。我們再將這個F實例對象指向子類構造函數的prototype對象,即可實現方法繼承。從而在改寫子類的prototype中的方法並不會影響到父類的prototype中的方法,從而實現方法重寫!
4. 子類和父類的實例對象通過“[對象] instanceof [子類/父類]”判定的結果應該爲true。Check!原型鏈沒有斷掉。子類的_proto_指向F,F的_proto_指向父類。 5. 子類和父類的實例對象的constructor指針應該分別指向子類和父類的構造函數。Check!我們在寫的過程中顯示制定了constructor,所以constructor指針的指向也不會錯。
總結
我們是通過:
1. 繼承父類的構造函數來實現屬性繼承;
2. 借中間函數F,繼承父類的prototype來實現方法繼承&方法覆蓋;
3. 顯示指定constructor防止prototype改寫帶來的問題。
至此,較爲漂亮的完成了Javascript的繼承!
通過此思路,想要實現抽象類,接口等面向對象的概念應該也不是難事吧。呵呵。
抽象類:父類構造函數中只有方法定義,則該父類即爲抽象父類。
接口:父類構造函數中方法定義爲空。
多態:父類中調用一個未實現的函數,在子類中實現即可。
類型轉換:把中間層F斷掉,重新指定實例對象的_proto_指向的prototype對象,那麼F中繼承的方法將不復存在,故而調用方法就是直接調用被指向的prototype對象的方法了。關於類型轉換的代碼如下:
02 |
function inheritPrototype(subType,
superType) { |
07 |
F.prototype
= superType.prototype; |
12 |
proto.constructor
= subType; |
14 |
subType.prototype
= proto; |
16 |
function Person(name,
age) { |
19 |
this .getName
= function ()
{ |
23 |
Person.prototype.getAge
= function ()
{ |
26 |
function Male(name,
age) { |
27 |
Person.apply( this ,
[name, age]); |
29 |
this .getSex
= function ()
{ |
33 |
inheritPrototype(Male,
Person); |
35 |
Male.prototype.getAge
= function ()
{ |
38 |
var p
= new Person( "好女人" ,
30); |
39 |
var m
= new Male( "好男人" ,
30); |
43 |
m.__proto__
= Person.prototype; |
44 |
console.log(p.constructor
== Person); |
45 |
console.log(m.constructor
== Male); |
46 |
console.log(m instanceof Male); |
47 |
console.log(m instanceof Person); |
48 |
console.log(p.getAge()); |
49 |
console.log(m.getAge()); |
51 |
m.__proto__
= Male.prototype; |
52 |
console.log(p.constructor
== Person); |
53 |
console.log(m.constructor
== Male); |
54 |
console.log(m instanceof Male); |
55 |
console.log(m instanceof Person); |
56 |
console.log(p.getAge()); |
57 |
console.log(m.getAge()); |
運行結果:
大家可以看到類型轉換之後,調getAge()方法的不同了吧。
【文獻引用】
1.《Professional Javascript for Web Developers》 3rd. Edition 第六章