書名: JavaScript設(shè)計模式與開發(fā)實踐作者名: 曾探本章字?jǐn)?shù): 6317字更新時間: 2020-01-10 15:38:13
1.4 原型模式和基于原型繼承的JavaScript對象系統(tǒng)
在Brendan Eich為JavaScript設(shè)計面向?qū)ο笙到y(tǒng)時,借鑒了Self和Smalltalk這兩門基于原型的語言。之所以選擇基于原型的面向?qū)ο笙到y(tǒng),并不是因為時間匆忙,它設(shè)計起來相對簡單,而是因為從一開始Brendan Eich就沒有打算在JavaScript中加入類的概念。
在以類為中心的面向?qū)ο缶幊陶Z言中,類和對象的關(guān)系可以想象成鑄模和鑄件的關(guān)系,對象總是從類中創(chuàng)建而來。而在原型編程的思想中,類并不是必需的,對象未必需要從類中創(chuàng)建而來,一個對象是通過克隆另外一個對象所得到的。就像電影《第六日》一樣,通過克隆可以創(chuàng)造另外一個一模一樣的人,而且本體和克隆體看不出任何區(qū)別。
原型模式不單是一種設(shè)計模式,也被稱為一種編程泛型。
本節(jié)我們將首先學(xué)習(xí)第一個設(shè)計模式——原型模式。隨后會了解基于原型的Io語言,借助對Io語言的了解,我們對JavaScript的面向?qū)ο笙到y(tǒng)也將有更深的認(rèn)識。在本節(jié)的最后,我們將詳細(xì)了解JavaScript語言如何通過原型來構(gòu)建一個面向?qū)ο笙到y(tǒng)。
1.4.1 使用克隆的原型模式
從設(shè)計模式的角度講,原型模式是用于創(chuàng)建對象的一種模式,如果我們想要創(chuàng)建一個對象,一種方法是先指定它的類型,然后通過類來創(chuàng)建這個對象。原型模式選擇了另外一種方式,我們不再關(guān)心對象的具體類型,而是找到一個對象,然后通過克隆來創(chuàng)建一個一模一樣的對象。
既然原型模式是通過克隆來創(chuàng)建對象的,那么很自然地會想到,如果需要一個跟某個對象一模一樣的對象,就可以使用原型模式。
假設(shè)我們在編寫一個飛機大戰(zhàn)的網(wǎng)頁游戲。某種飛機擁有分身技能,當(dāng)它使用分身技能的時候,要在頁面中創(chuàng)建一些跟它一模一樣的飛機。如果不使用原型模式,那么在創(chuàng)建分身之前,無疑必須先保存該飛機的當(dāng)前血量、炮彈等級、防御等級等信息,隨后將這些信息設(shè)置到新創(chuàng)建的飛機上面,這樣才能得到一架一模一樣的新飛機。
如果使用原型模式,我們只需要調(diào)用負(fù)責(zé)克隆的方法,便能完成同樣的功能。
原型模式的實現(xiàn)關(guān)鍵,是語言本身是否提供了clone方法。ECMAScript 5提供了Object.create方法,可以用來克隆對象。代碼如下:
var Plane = function(){ this.blood = 100; this.attackLevel = 1; this.defenseLevel = 1; }; var plane = new Plane(); plane.blood = 500; plane.attackLevel = 10; plane.defenseLevel = 7; var clonePlane = Object.create( plane ); console.log( clonePlane.blood ) //輸出500 console.log( clonePlane.attackLevel ) //輸出10 console.log( clonePlane.defenseLevel ) //輸出7
在不支持Object.create方法的瀏覽器中,則可以使用以下代碼:
Object.create = Object.create || function( obj ){ var F = function(){}; F.prototype = obj; return new F(); }
1.4.2 克隆是創(chuàng)建對象的手段
通過上一節(jié)的代碼,我們看到了如何通過原型模式來克隆出一個一模一樣的對象。但原型模式的真正目的并非在于需要得到一個一模一樣的對象,而是提供了一種便捷的方式去創(chuàng)建某個類型的對象,克隆只是創(chuàng)建這個對象的過程和手段。
在用Java等靜態(tài)類型語言編寫程序的時候,類型之間的解耦非常重要。依賴倒置原則提醒我們創(chuàng)建對象的時候要避免依賴具體類型,而用new XXX創(chuàng)建對象的方式顯得很僵硬。工廠方法模式和抽象工廠模式可以幫助我們解決這個問題,但這兩個模式會帶來許多跟產(chǎn)品類平行的工廠類層次,也會增加很多額外的代碼。
原型模式提供了另外一種創(chuàng)建對象的方式,通過克隆對象,我們就不用再關(guān)心對象的具體類型名字。這就像一個仙女要送給三歲小女孩生日禮物,雖然小女孩可能還不知道飛機或者船怎么說,但她可以指著商店櫥柜里的飛機模型說“我要這個”。
當(dāng)然在JavaScript這種類型模糊的語言中,創(chuàng)建對象非常容易,也不存在類型耦合的問題。從設(shè)計模式的角度來講,原型模式的意義并不算大。但JavaScript本身是一門基于原型的面向?qū)ο笳Z言,它的對象系統(tǒng)就是使用原型模式來搭建的,在這里稱之為原型編程范型也許更合適。
1.4.3 體驗Io語言
前面說過,原型模式不僅僅是一種設(shè)計模式,也是一種編程范型。JavaScript就是使用原型模式來搭建整個面向?qū)ο笙到y(tǒng)的。在JavaScript語言中不存在類的概念,對象也并非從類中創(chuàng)建出來的,所有的JavaScript對象都是從某個對象上克隆而來的。
對于習(xí)慣了以類為中心語言的人來說,也許一時不容易理解這種基于原型的語言。即使是對于JavaScript語言的熟練使用者而言,也可能會有一種“不識廬山真面目,只緣身在此山中”的感覺。事實上,使用原型模式來構(gòu)造面向?qū)ο笙到y(tǒng)的語言遠(yuǎn)非僅有JavaScript一家。
JavaScript基于原型的面向?qū)ο笙到y(tǒng)參考了Self語言和Smalltalk語言,為了搞清JavaScript中的原型,我們本該尋根溯源去瞧瞧這兩門語言。但由于這兩門語言距離現(xiàn)在實在太遙遠(yuǎn),我們不妨轉(zhuǎn)而了解一下另外一種輕巧又基于原型的語言——Io語言。
Io語言在2002年由Steve Dekorte發(fā)明。可以從http://iolanguage.com下載到Io語言的解釋器,安裝好之后打開Io解釋器,輸入經(jīng)典的“Hello World”程序。解釋器打印出了Hello World的字符串,這說明我們已經(jīng)可以使用Io語言來編寫一些小程序了,如圖1-1所示。
作為一門基于原型的語言,Io中同樣沒有類的概念,每一個對象都是基于另外一個對象的克隆。
就像吸血鬼的故事里必然有一個吸血鬼祖先一樣,既然每個對象都是由其他對象克隆而來的,那么我們猜測Io語言本身至少要提供一個根對象,其他對象都發(fā)源于這個根對象。這個猜測是正確的,在Io中,根對象名為Object。
這一節(jié)我們依然拿動物世界的例子來講解Io語言。在下面的代碼中,通過克隆根對象Object,就可以得到另外一個對象Animal。雖然Animal是以大寫開頭的,但是記住Io中沒有類,Animal跟所有的數(shù)據(jù)一樣都是對象。
Animal := Object clone //克隆動物對象
現(xiàn)在通過克隆根對象Object得到了一個新的Animal對象,所以O(shè)bject就被稱為Animal的原型。目前Animal對象和它的原型Object對象一模一樣,還沒有任何屬于它自己方法和能力。我們假設(shè)在Io的世界里,所有的動物都會發(fā)出叫聲,那么現(xiàn)在就給Animal對象添加makeSound方法吧。代碼如下:
Animal makeSound := method( "animal makeSound " print );
好了,現(xiàn)在所有的動物都能夠發(fā)出叫聲了,那么再來繼續(xù)創(chuàng)建一個Dog對象。顯而易見,Animal對象可以作為Dog對象的原型,Dog對象從Animal對象克隆而來:
Dog := Animal clone
可以確定,Dog一定懂得怎么吃食物,所以接下來給Dog對象添加eat方法:
Dog eat := method( "dog eat " print );
現(xiàn)在已經(jīng)完成了整個動物世界的構(gòu)建,通過一次次克隆,Io的對象世界里不再只有形單影只的根對象Object,而是多了兩個新的對象:Animal對象和Dog對象。其中Dog的原型是Animal, Animal對象的原型是Object。最后我們來測試Animal對象和Dog對象的功能。
先嘗試調(diào)用Animal的makeSound方法,可以看到,動物順利發(fā)出了叫聲:
Animal makeSound //輸出:animal makeSound
然后再調(diào)用Dog的eat方法,同樣我們也看到了預(yù)期的結(jié)果:
Dog eat //輸出:dog eat
1.4.4 原型編程范型的一些規(guī)則
從上一節(jié)的講解中,我們看到了如何在Io語言中從無到有地創(chuàng)建一些對象。跟使用“類”的語言不一樣的地方是,Io語言中最初只有一個根對象Object,其他所有的對象都克隆自另外一個對象。如果A對象是從B對象克隆而來的,那么B對象就是A對象的原型。
在上一小節(jié)的例子中,Object是Animal的原型,而Animal是Dog的原型,它們之間形成了一條原型鏈。這個原型鏈?zhǔn)呛苡杏锰幍模?dāng)我們嘗試調(diào)用Dog對象的某個方法時,而它本身卻沒有這個方法,那么Dog對象會把這個請求委托給它的原型Animal對象,如果Animal對象也沒有這個屬性,那么請求會順著原型鏈繼續(xù)被委托給Animal對象的原型Object對象,這樣一來便能得到繼承的效果,看起來就像Animal是Dog的“父類”, Object是Animal的“父類”。
這個機制并不復(fù)雜,卻非常強大,Io和JavaScript一樣,基于原型鏈的委托機制就是原型繼承的本質(zhì)。
我們來進行一些測試。在Io的解釋器中執(zhí)行Dog makeSound時,Dog對象并沒有makeSound方法,于是把請求委托給了它的原型Animal對象,而Animal對象是有makeSound方法的,所以該條語句可以順利得到輸出,如圖1-2所示。
現(xiàn)在我們明白了原型編程中的一個重要特性,即當(dāng)對象無法響應(yīng)某個請求時,會把該請求委托給它自己的原型。
最后整理一下本節(jié)的描述,我們可以發(fā)現(xiàn)原型編程范型至少包括以下基本規(guī)則。
? 所有的數(shù)據(jù)都是對象。
? 要得到一個對象,不是通過實例化類,而是找到一個對象作為原型并克隆它。
? 對象會記住它的原型。
? 如果對象無法響應(yīng)某個請求,它會把這個請求委托給它自己的原型。
1.4.5 JavaScript中的原型繼承
剛剛我們已經(jīng)體驗過同樣是基于原型編程的Io語言,也已經(jīng)了解了在Io語言中如何通過原型鏈來實現(xiàn)對象之間的繼承關(guān)系。在原型繼承方面,JavaScript的實現(xiàn)原理和Io語言非常相似,JavaScript也同樣遵守這些原型編程的基本規(guī)則。
? 所有的數(shù)據(jù)都是對象。
? 要得到一個對象,不是通過實例化類,而是找到一個對象作為原型并克隆它。
? 對象會記住它的原型。
? 如果對象無法響應(yīng)某個請求,它會把這個請求委托給它自己的原型。
下面我們來分別討論JavaScript是如何在這些規(guī)則的基礎(chǔ)上來構(gòu)建它的對象系統(tǒng)的。
1.所有的數(shù)據(jù)都是對象
JavaScript在設(shè)計的時候,模仿Java引入了兩套類型機制:基本類型和對象類型。基本類型包括undefined、number、boolean、string、function、object。從現(xiàn)在看來,這并不是一個好的想法。
按照J(rèn)avaScript設(shè)計者的本意,除了undefined之外,一切都應(yīng)是對象。為了實現(xiàn)這一目標(biāo),number、boolean、string這幾種基本類型數(shù)據(jù)也可以通過“包裝類”的方式變成對象類型數(shù)據(jù)來處理。
我們不能說在JavaScript中所有的數(shù)據(jù)都是對象,但可以說絕大部分?jǐn)?shù)據(jù)都是對象。那么相信在JavaScript中也一定會有一個根對象存在,這些對象追根溯源都來源于這個根對象。
事實上,JavaScript中的根對象是Object.prototype對象。Object.prototype對象是一個空的對象。我們在JavaScript遇到的每個對象,實際上都是從Object.prototype對象克隆而來的,Object.prototype對象就是它們的原型。比如下面的obj1對象和obj2對象:
var obj1 = new Object(); var obj2 = {};
可以利用ECMAScript 5提供的Object.getPrototypeOf來查看這兩個對象的原型:
console.log( Object.getPrototypeOf( obj1 ) === Object.prototype ); //輸出:true console.log( Object.getPrototypeOf( obj2 ) === Object.prototype ); //輸出:true
2.要得到一個對象,不是通過實例化類,而是找到一個對象作為原型并克隆它
在Io語言中,克隆一個對象的動作非常明顯,我們可以在代碼中清晰地看到clone的過程。比如以下代碼:
Dog := Animal clone
但在JavaScript語言里,我們并不需要關(guān)心克隆的細(xì)節(jié),因為這是引擎內(nèi)部負(fù)責(zé)實現(xiàn)的。我們所需要做的只是顯式地調(diào)用var obj1 = new Object()或者var obj2 = {}。此時,引擎內(nèi)部會從Object.prototype上面克隆一個對象出來,我們最終得到的就是這個對象。
再來看看如何用new運算符從構(gòu)造器中得到一個對象,下面的代碼我們再熟悉不過了:
function Person( name ){ this.name = name; }; Person.prototype.getName = function(){ return this.name; }; var a = new Person( 'sven' ) console.log( a.name ); // 輸出:sven console.log( a.getName() ); // 輸出:sven console.log( Object.getPrototypeOf( a ) === Person.prototype ); // 輸出:true
在JavaScript中沒有類的概念,這句話我們已經(jīng)重復(fù)過很多次了。但剛才不是明明調(diào)用了new Person()嗎?
在這里Person并不是類,而是函數(shù)構(gòu)造器,JavaScript的函數(shù)既可以作為普通函數(shù)被調(diào)用,也可以作為構(gòu)造器被調(diào)用。當(dāng)使用new運算符來調(diào)用函數(shù)時,此時的函數(shù)就是一個構(gòu)造器。用new運算符來創(chuàng)建對象的過程,實際上也只是先克隆Object.prototype對象,再進行一些其他額外操作的過程。
在Chrome和Firefox等向外暴露了對象__proto__屬性的瀏覽器下,我們可以通過下面這段代碼來理解new運算的過程:
function Person( name ){ this.name = name; }; Person.prototype.getName = function(){ return this.name; }; var objectFactory = function(){ var obj = new Object(), // 從Object.prototype上克隆一個空的對象 Constructor = [].shift.call( arguments ); // 取得外部傳入的構(gòu)造器,此例是Person obj.__proto__ = Constructor.prototype; // 指向正確的原型 var ret = Constructor.apply( obj, arguments ); // 借用外部傳入的構(gòu)造器給obj設(shè)置屬性 return typeof ret === 'object' ? ret : obj; // 確保構(gòu)造器總是會返回一個對象 }; var a = objectFactory( Person, 'sven' ); console.log( a.name ); // 輸出:sven console.log( a.getName() ); // 輸出:sven console.log( Object.getPrototypeOf( a ) === Person.prototype ); // 輸出:true
我們看到,分別調(diào)用下面兩句代碼產(chǎn)生了一樣的結(jié)果:
var a = objectFactory( A, 'sven' ); var a = new A( 'sven' );
3.對象會記住它的原型
如果請求可以在一個鏈條中依次往后傳遞,那么每個節(jié)點都必須知道它的下一個節(jié)點。同理,要完成Io語言或者JavaScript語言中的原型鏈查找機制,每個對象至少應(yīng)該先記住它自己的原型。
目前我們一直在討論“對象的原型”,就JavaScript的真正實現(xiàn)來說,其實并不能說對象有原型,而只能說對象的構(gòu)造器有原型。對于“對象把請求委托給它自己的原型”這句話,更好的說法是對象把請求委托給它的構(gòu)造器的原型。那么對象如何把請求順利地轉(zhuǎn)交給它的構(gòu)造器的原型呢?
JavaScript給對象提供了一個名為__proto__的隱藏屬性,某個對象的__proto__屬性默認(rèn)會指向它的構(gòu)造器的原型對象,即{Constructor}.prototype。在一些瀏覽器中,__proto__被公開出來,我們可以在Chrome或者Firefox上用這段代碼來驗證:
var a = new Object(); console.log ( a.__proto__=== Object.prototype ); // 輸出:true
實際上,__proto__就是對象跟“對象構(gòu)造器的原型”聯(lián)系起來的紐帶。正因為對象要通過__proto__屬性來記住它的構(gòu)造器的原型,所以我們用上一節(jié)的objectFactory函數(shù)來模擬用new創(chuàng)建對象時,需要手動給obj對象設(shè)置正確的__proto__指向。
obj.__proto__ = Constructor.prototype;
通過這句代碼,我們讓obj.__proto__ 指向Person.prototype,而不是原來的Object.prototype。
4.如果對象無法響應(yīng)某個請求,它會把這個請求委托給它的構(gòu)造器的原型
這條規(guī)則即是原型繼承的精髓所在。從對Io語言的學(xué)習(xí)中,我們已經(jīng)了解到,當(dāng)一個對象無法響應(yīng)某個請求的時候,它會順著原型鏈把請求傳遞下去,直到遇到一個可以處理該請求的對象為止。
JavaScript的克隆跟Io語言還有點不一樣,Io中每個對象都可以作為原型被克隆,當(dāng)Animal對象克隆自O(shè)bject對象,Dog對象又克隆自Animal對象時,便形成了一條天然的原型鏈,如圖1-3所示。
而在JavaScript中,每個對象都是從Object.prototype對象克隆而來的,如果是這樣的話,我們只能得到單一的繼承關(guān)系,即每個對象都繼承自O(shè)bject.prototype對象,這樣的對象系統(tǒng)顯然是非常受限的。
實際上,雖然JavaScript的對象最初都是由Object.prototype對象克隆而來的,但對象構(gòu)造器的原型并不僅限于Object.prototype上,而是可以動態(tài)指向其他對象。這樣一來,當(dāng)對象a需要借用對象b的能力時,可以有選擇性地把對象a的構(gòu)造器的原型指向?qū)ο骲,從而達到繼承的效果。下面的代碼是我們最常用的原型繼承方式:
var obj = { name: 'sven' }; var A = function(){}; A.prototype = obj; var a = new A(); console.log( a.name ); // 輸出:sven
我們來看看執(zhí)行這段代碼的時候,引擎做了哪些事情。
? 首先,嘗試遍歷對象a中的所有屬性,但沒有找到name這個屬性。
? 查找name屬性的這個請求被委托給對象a的構(gòu)造器的原型,它被a.__proto__ 記錄著并且指向A.prototype,而A.prototype被設(shè)置為對象obj。
? 在對象obj中找到了name屬性,并返回它的值。
當(dāng)我們期望得到一個“類”繼承自另外一個“類”的效果時,往往會用下面的代碼來模擬實現(xiàn):
var A = function(){}; A.prototype = { name: 'sven' }; var B = function(){}; B.prototype = new A(); var b = new B(); console.log( b.name ); // 輸出:sven
再看這段代碼執(zhí)行的時候,引擎做了什么事情。
? 首先,嘗試遍歷對象b中的所有屬性,但沒有找到name這個屬性。
? 查找name屬性的請求被委托給對象b的構(gòu)造器的原型,它被b.__proto__記錄著并且指向B.prototype,而B.prototype被設(shè)置為一個通過new A()創(chuàng)建出來的對象。
? 在該對象中依然沒有找到name屬性,于是請求被繼續(xù)委托給這個對象構(gòu)造器的原型A.prototype。
? 在A.prototype中找到了name屬性,并返回它的值。
和把B.prototype直接指向一個字面量對象相比,通過B.prototype = new A()形成的原型鏈比之前多了一層。但二者之間沒有本質(zhì)上的區(qū)別,都是將對象構(gòu)造器的原型指向另外一個對象,繼承總是發(fā)生在對象和對象之間。
最后還要留意一點,原型鏈并不是無限長的。現(xiàn)在我們嘗試訪問對象a的address屬性。而對象b和它構(gòu)造器的原型上都沒有address屬性,那么這個請求會被最終傳遞到哪里呢?
實際上,當(dāng)請求達到A.prototype,并且在A.prototype中也沒有找到address屬性的時候,請求會被傳遞給A.prototype的構(gòu)造器原型Object.prototype,顯然Object.prototype中也沒有address屬性,但Object.prototype的原型是null,說明這時候原型鏈的后面已經(jīng)沒有別的節(jié)點了。所以該次請求就到此打住,a.address返回undefined。
a.address // 輸出:undefined
1.4.6 原型繼承的未來
設(shè)計模式在很多時候其實都體現(xiàn)了語言的不足之處。Peter Norvig曾說,設(shè)計模式是對語言不足的補充,如果要使用設(shè)計模式,不如去找一門更好的語言。這句話非常正確。不過,作為Web前端開發(fā)者,相信JavaScript在未來很長一段時間內(nèi)都是唯一的選擇。雖然我們沒有辦法換一門語言,但語言本身也在發(fā)展,說不定哪天某個模式在JavaScript中就已經(jīng)是天然的存在,不再需要拐彎抹角來實現(xiàn)。比如Object.create就是原型模式的天然實現(xiàn)。使用Object.create來完成原型繼承看起來更能體現(xiàn)原型模式的精髓。目前大多數(shù)主流瀏覽器都提供了Object.create方法。
但美中不足是在當(dāng)前的JavaScript引擎下,通過Object.create來創(chuàng)建對象的效率并不高,通常比通過構(gòu)造函數(shù)創(chuàng)建對象要慢。此外還有一些值得注意的地方,比如通過設(shè)置構(gòu)造器的prototype來實現(xiàn)原型繼承的時候,除了根對象Object.prototype本身之外,任何對象都會有一個原型。而通過Object.create( null )可以創(chuàng)建出沒有原型的對象。
另外,ECMAScript 6帶來了新的Class語法。這讓JavaScript看起來像是一門基于類的語言,但其背后仍是通過原型機制來創(chuàng)建對象。通過Class創(chuàng)建對象的一段簡單示例代碼如下所示:
class Animal { constructor(name) { this.name = name; } getName() { return this.name; } } class Dog extends Animal { constructor(name) { super(name); } speak() { return "woof"; } } var dog = new Dog("Scamp"); console.log(dog.getName() + ' says ' + dog.speak());
1.4.7 小結(jié)
本節(jié)講述了本書的第一個設(shè)計模式——原型模式。原型模式是一種設(shè)計模式,也是一種編程泛型,它構(gòu)成了JavaScript這門語言的根本。本節(jié)首先通過更加簡單的Io語言來引入原型模式的概念,隨后學(xué)習(xí)了JavaScript中的原型模式。原型模式十分重要,和JavaScript開發(fā)者的關(guān)系十分密切。通過原型來實現(xiàn)的面向?qū)ο笙到y(tǒng)雖然簡單,但能力同樣強大。
- TypeScript Blueprints
- Android和PHP開發(fā)最佳實踐(第2版)
- Building a Game with Unity and Blender
- Interactive Data Visualization with Python
- 精通搜索分析
- Nginx實戰(zhàn):基于Lua語言的配置、開發(fā)與架構(gòu)詳解
- Learning JavaScript Data Structures and Algorithms
- 深入理解Elasticsearch(原書第3版)
- Instant Nancy Web Development
- QPanda量子計算編程
- Django Design Patterns and Best Practices
- 測試架構(gòu)師修煉之道:從測試工程師到測試架構(gòu)師
- 深入理解Kafka:核心設(shè)計與實踐原理
- SQL Server 2014 Development Essentials
- Go語言高級編程(第2版)