《JavaScript 模式》讀書筆記(6)— 程式碼復用模式2
- 2020 年 4 月 19 日
- 筆記
上一篇講了最簡單的程式碼復用模式,也是最基礎的,我們普遍知道的繼承模式,但是這種繼承模式卻有不少缺點,我們下面再看看其它可以實現繼承的模式。
四、類式繼承模式#2——借用構造函數
本模式解決了從子構造函數道父構造函數的參數傳遞問題。本模式借用了父構造函數,它傳遞子對象以綁定到this,並且還轉發任意參數。
function Child(a,c,b,d) { parent.apply(this,arguments); }
在這種方式中,只能繼承在父構造函數中添加到this的屬性。同時,並不能繼承那些已添加到原型中的成員。
使用該借用構造函數模式時,子對象獲得了繼承成員的副本,這與類式繼承模式#1中,僅獲取引用的方式是不同的。下面的例子演示了其差異:
// 父構造函數 function Article() { this.tags = ['js','css']; } var article = new Article(); // blog 文章對象繼承了article對象 // via the classical pattern #1 function BlogPost() {} BlogPost.prototype = article; var blog = new BlogPost(); // 注意以上程式碼,你不需要new Article() // 是因為你已經有一個可用的實例 // static page (靜態頁面)繼承了article // 通過借用構造函數模式 function StaticPage() { Article.call(this); } var page = new StaticPage(); console.log(article.hasOwnProperty('tags')); //true console.log(blog.hasOwnProperty('tags')); //false console.log(page.hasOwnProperty('tags')); //true
在以上程式碼片段中,有兩種方式都繼承了父構造函數Article()。默認模式導致了blog對象通過原型以獲得tags屬性的訪問,因此blog對象中沒有將article作為自身的屬性,因此當調用hasOwnProperty()時會返回false。相反,page對象本身則具有一個tags屬性,這是由於它在使用借用構造函數的時候,新對象會獲得父對象中tags成員的副本(不是引用)。
請注意修改繼承的tags屬性時表現出來的差異:
blog.tags.push('html'); page.tags.push('php'); console.log(article.tags.join(', '));// 'js, css, html'
在上面這個例子中,子對象blog修改了其tags屬性,而這種方式同時也會修改父對象article,這是由於本質上blog.tags和article.tags都指向了同一個數組。但是,修改page.tags時卻不會影響其父對象article,這是由於在繼承過程中page.tags是獨立創建的一個副本。
原型鏈
當使用本模式以及熟悉的Parent()和Child()構造函數時,讓我們來看原型鏈(prototype chain)的工作流程。其中,Child()需要根據這個新模式的需求略加修改:
// 父構造函數 function Parent(name) { this.name = name || 'Adam'; } // 向該原型添加功能 Parent.prototype.say = function () { return this.name; }; // 子構造函數 function Child(name) { Parent.apply(this,arguments); } var kid = new Child('Patrick'); console.log(kid.name); // 輸出「Patrick」 console.log(typeof kid.say); //輸出undefined
如果仔細查看下圖,將會注意到在new Child對象和Parent對象之間不再有鏈接。出現這種現象的原因在於本模式中根本就沒有使用Child.prototype,並且它只是指向一個空對象。使用本模式時,kid獲得了自身的屬性name,但是卻從未繼承過say()方法,如果試圖調用該方法將會導致錯誤。繼承是一個一次性的操作,它僅會複製父對象的屬性並將其作為子對象自身的屬性,僅此而已。因此,也就不會保留__proto__鏈接。
通過借用構造函數實現多重繼承
當使用借用構造函數模式時,可以通過借用多個構造函數從而簡單的實現多重繼承。
function Cat() { this.legs = 4; this.say = function () { return "meaowww"; } } function Bird() { this.wings = 2; this.fly = true; } function CatWings() { Cat.apply(this); Bird.apply(this); } var jane = new CatWings(); console.log(jane);
上述程式碼的運行結果是這樣的:
legs: 4 say: ƒ () wings: 2 fly: true
在解析任意的副本屬性時,將會通過最後一個獲勝的方式來解析該屬性(這句話的意思是,如果複製的屬性中有相同的屬性名,那麼會後者優先)。
借用構造函數模式的優缺點
借用構造函數模式的缺點是很明顯的,如前面所述,其問題在於根本無法從原型中繼承任何東西,並且原型也僅是添加可重用方法以及屬性的位置,它並不會為每個實例重新創建原型。
本模式的一個優點在於可以獲得父對象自身成員的真實副本,並且也不會存在於子對象意外覆蓋父對象屬性的風險。
因此,在前面的情況中,如何才能使子對象也能夠繼承原型屬性?以及如何使kid能夠訪問say()方法?下面這個模式將解決這個問題
五、類式繼承模式#3——借用和設置原型
類式繼承模式#3主要思想是結合前兩種模式,即先借用構造函數,然後還設置子構造函數的原型使其指向一個構造函數創建的新實例。如下所示:
function Child(a,c,b,d) { Parent.apply(this,arguments); } Child.prototype = new Parent()
這樣做的優點在於,以上程式碼運行後的結果對象能夠獲得父對象本身的成員副本以及指向父對象中可復用功能(以原型成員方式實現的那些功能)的引用。同時,子對象也能夠將任意參數傳遞到父構造函數中。這種行為可能是最接近您希望在Java中實現的方式。可以繼承父對象中的一切東西,同時這種方法也能夠安全的修改自身屬性,且不會帶來修改其父對象的風險。
這種模式的一個缺點是,父構造函數被調用了兩次,因此這導致了其效率低下的問題。最後,自身的屬性(比如本例中扽ame屬性)會被繼承兩次:
function Parent(name) { this.name = name || 'Adam'; } // adding functionality to the prototype Parent.prototype.say = function () { return this.name; } // 子構造函數 function Child(name) { Parent.apply(this,arguments); } Child.prototype = new Parent(); var kid = new Child('Patrick'); console.log(kid.name); //輸出「Patrick」 console.log(kid.say());// 輸出「Patrick」 delete kid.name; console.log(kid.say());// 輸出「Adam」
在上面的程式碼中,不同於先前的模式,現在say()方法已被正確的繼承。還可以注意到name屬性卻被繼承了兩次,在我們刪除了kid本身的name屬性的副本後,隨後看到的輸出是原型鏈表現出來所引出的name屬性。
下圖顯示了對象之間的鏈接關係。這些關係非常類似於之前#1模式的最後一張圖中所示的原型鏈,但這裡我們所採用的繼承方式是不同的。
六、類式繼承模式#4——共享原型
不同於前面的那種需要兩次調用父構造函數的模式(類式繼承模式#3),接下來介紹的模式根本就不涉及調用任何父構造函數。
本模式的經驗法則在於:可復用成員應該轉移到原型中而不是放置在this中。因此,出於繼承的目的,任何值得繼承的東西都應該放置在原型中實現。所以,可以僅將子對象的原型與父對象的原型設置為相同的即可:
function inherit(C, P){ C.prototype = P.prototype; }
這種模式能夠向您提供剪短而迅速的原型鏈查詢,這是由於所有的對象實際上共享了同一個原型。但是,這同時也是一個缺點,因為如果在繼承鏈下方的某處存在一個子對象或者孫子對象修改了原型,它將會影響到所有的父對象和祖先對象。
如下圖所示,下面的子對象和父對象共享了同一個原型,並且可以同等的訪問say()方法。然而,需要注意到子對象並沒有繼承name屬性。
七、類式繼承模式#5——臨時構造函數
類式繼承模式#5通過斷開父對象與子對象的原型之間的直接鏈接關係,從而解決共享同一個原型所帶來的問題,而且同時還能夠繼續受益於原型鏈帶來的好處。
下面的程式碼是本模式的一種實現方式,在該程式碼中有一個空白函數F(),該函數充當了子對象與父對象之間的代理。F()的prototype屬性指向父對象的原型。子對象的原型則是一個空白函數實例。
function inherit(C, P){ var F = function(){}; F.prototype = P.prototype; C.prototype = new F(); }
這種模式在行為上與默認模式(類式繼承模式#1)略有不同,這是由於這裡的子對象僅繼承了原型的屬性(見下圖)。這種情況通常來說是很好的,實際上也是更加可取的,因為原型也正是放置可復用功能的位置。在這種模式中,父構造函數添加到this中的任何成員都不會被繼承。
讓我們創建一個新的子對象,並審查其行為:
var kid = new Child();
如果訪問kid.name,其結果將是undefined類型。在這種情況下,name是父對象所擁有的一個屬性,然而在繼承的時候我們實際上從未調用過new Parent(),因此也從未創建過該屬性。當您訪問kid.say()時,在對象#3中該方法並不可用,因此需要開始查詢原型鏈。然而對象#4中也沒有該方法,但是對象#1中確實存在該方法並且位於記憶體中的同一個位置,因此所有繼承了Parent()的不同構造函數,以及所有由其子構造函數所創建的對象都可重用該say()方法。
存儲超類
在上面模式的基礎上,還可以添加一個指向原始父對象的引用。這就像在其他程式語言中訪問超類一樣,這可以偶爾派上用場。
該屬性被稱之為uber,這僅是由於「super」是保留的關鍵詞,並且「superclass」可能導致存心的程式設計師不加思考便順勢根據該關鍵詞認為JavaScript中具有類(class)。下面是該類式繼承模式的一個改進實現:
function inherit(C, P){ var F = function(){}; F.prototype = P.prototype; C.prototype = new F(); C.uber = P.prototype; }
重置構造函數指針
最後,針對這個幾乎完美的類式繼承函數,還需要做的一件事情就是重置該構造函數的指針,以免在將來的某個時候還需要該構造函數。
如果不重置該構造函數的指針,那麼所有子對象將會報告Parent()是它們的構造函數,這是沒有任何用處的。因此,使用前面的inherit()實現程式碼,可以觀察到此行為:
// 父子繼承 function Parent() {} function Child() {} inherit(Child,Parent); // 投石問路 var kid = new Child(); console.log(kid.constructor.name); //Parent console.log(kid.constructor === Parent); //true
雖然我們很少用到constructor屬性,但是這種功能卻可以很方便的用於運行時對象的內省。可以重置constructor屬性使其指向期望的構造函數且不會影響其功能,這是由於該屬性主要是用於提供對象的資訊。
這個類式繼承模式最後的聖杯版本看起來如下所示:
function inherit(C, P){ var F = function(){}; F.prototype = P.prototype; C.prototype = new F(); C.uber = P.prototype; C.prototype.constructor = C; }
如果認為這種模式是適用於項目中的最佳方法,需要說明的是,在開源YUI庫或者其他庫中也存在一個與本函數相似的函數,並且它還在沒有類的情況下實現了類式繼承。
對於該聖杯模式的一個常見優化是避免在每次需要繼承時都創建臨時(代理)構造函數。僅創建一次臨時構造函數,並且修改它的原型,這已經是非常充分的。在具體實現方式上,可以使用即時函數並且在閉包中存儲代理函數。
var inherit = (function () { var F = function () { }; return function (C, P) { F.prototype = P.prototype; C.prototype = new F(); C.uber = P.prototype; C.prototype.constructor = C; } }());
最基本的類式繼承模式到這裡就告一段落類,但是這遠遠不是結束。