《JavaScript 模式》讀書筆記(6)— 程式碼復用模式3
- 2020 年 4 月 30 日
- 筆記
我們之前聊了聊基本的繼承的概念,也聊了很多在JavaScript中模擬類的方法。這篇文章,我們主要來學習一下現代繼承的一些方法。
九、原型繼承
下面我們開始討論一種稱之為原型繼承(prototype inheritance)的「現代」無類繼承模式。在本模式中並不涉及類,這裡的對象都是繼承自其他對象。以這種方式考慮:有一個想要復用的對象,並且想創建的第二個對象需要從第一個對象中獲取其功能。
下面的程式碼展示了該如何開始著手實現這種模式:
// 要繼承的對象 var parent = { name:"Papa" }; // 新對象 var child = object(parent); // 測試 alert(child.name) //"Papa"
在前面的程式碼片段中,存在一個以對象字面量(object literal)創建的名為parent的現有對象,並且要創建另外一個與parent具有相同屬性和方法的名為child的對象。child對象是由一個名為object()的函數所創建。JavaScript中並不存在該函數(不要與構造函數object()弄混淆),為此,讓我們看看該如何定義該函數。
與類似繼承模式的聖杯版本相似,首先,可以使用空的臨時構造函數F()。然後,將F()的原型屬性設置為父對象。最後,返回一個臨時構造函數的新實例:
function object(o) { function F() {} F.prototype = o; return new F(); }
下圖展示了在使用原型繼承模式時的原型鏈。圖中的child最初是一個空對象,他沒有自身的屬性,但是同時他又通過受益於__proto__鏈接而具有其父對象的全部功能。
討論
在原型繼承模式中,並不需要使用字面量符合(literal notation)來創建父對象(儘管這可能是一種比較常見的方式)。如下程式碼所示,可以使用構造函數創建父對象,請注意,如果這樣做的話,「自身」屬性和構造函數的原型的屬性都將被繼承:
// 父構造函數 function Person() { // an "own" property this.name = "Adam" } // 添加到原型的屬性 Person.prototype.getName = function () { return this.name; }; // 創建一個新的Person類對象 var papa = new Person(); // 繼承 var kid = object(papa); // 測試自身的屬性 // 和繼承的原型屬性 console.log(kid.getName());
在本模式的另外一個變化中,可以選擇僅繼承現有構造函數的原型對象。請記住,對象繼承自對象,而不論父對象是如何創建的。下面使用了前面的例子演示該變化,僅需稍加修改程式碼即可:
// 父構造函數 function Person() { // an "own" property this.name = "Adam" } // 添加到原型的屬性 Person.prototype.getName = function () { return this.name; }; // 繼承 var kid = object(Person.prototype); console.log(typeof kid.getName); console.log(typeof kid.name);
增加到ECMAScript5中
在ECMAScript5中,原型繼承模式已經正式成為該語言的一部分。這種模式是通過方法Object.create()來實現的。也就是說,不需要推出與object()類似的函數,它已經內嵌在語言中:
var child = Object.create(parent);
Object.create()接受一個額外的參數,即一個對象。這個額外對象的屬性將會被添加到新對象中,以此作為新對象自身的屬性,然後Object.create()返回該新對象。這提供了很大的方便,使您可以僅採用一個方法調用即可實現繼承並在此基礎上構建子對象。比如:
var child = Object.create(parent,{ age : { value : 2} }); child.hasOwnProperty("age");
可能還會發現一些JavaScript庫中已經實現了原型繼承模式。例如,在YUI3中是Y.Object()方法。
十、通過複製屬性實現繼承
讓我們看另一種繼承模式,即通過複製屬性實現繼承。在這種模式中,對象將從另一個對象中獲取功能,其方法是僅需將其複製即可。下面是一個示例函數extend()實現複製繼承的例子:
function extend(parent, child){ var i; child = child || {}; for(i in parent) { if(parent.hasOwnProperty(i)) { child[i] = parent[i] } } return child }
上面點的程式碼是一個簡單的實現,它僅遍歷父對象的成員並將其複製出來。在本示例實現中,child對象是可選的。如果不傳遞需要擴展的已有對象,那麼他會創建並返回一個全新的對象。
var dad = {name : "Adam"}; var kid = extend(dad); console.log(kid.name)
上面給出的是一種所謂淺複製的對象。另一方面,深度複製意味著屬性檢查,如果即將複製的屬性是一個對象或者一個數組,這樣的話,它將會遞歸遍歷該屬性並且還會將屬性中的元素複製出來。在使用前複製(由於JavaScript中的對象是通過引用而傳遞的)的時候,如果改變了子對象的屬性,並且該屬性恰好是一個對象,那麼這種操作表示也正在修改父對象。其實,這也是更可取的方法,但是當處理其他對象和數組時,這種前複製也可能導致意外發生。考慮下列情況:
var dad = { counts:[1,2,3], reads:{paper:true} } var kid = extend(dad); kid.counts.push(4); console.log(dad.counts.toString()); console.log(dad.reads === kid.reads)
現在讓我們修改extend()函數以實現深度複製。所有需要做的事情就是檢查某個屬性的類型是否為對象,如果是這樣的話,需要遞歸複製出該對象的屬性。另外,還需要檢查該對象是否為一個真實對象或者一個數組,我們可以使用第三章中討論的方法檢查其數組性質。因此,深度複製版本的extend()函數看起來是這樣的:
function extendDeep(parent,child) { var i, toStr = Object.prototype.toString, astr = "[object Array]"; child = child || {}; for(i in parent) { if(parent.hasOwnProperty(i)) { if(typeof parent[i] === 'object'){ child[i] = (toStr.call(parent[i]) === astr) ? [] : {}; extendDeep(parent[i],child[i]) } else { child[i] = parent[i] } } } return child; }
現在開始測試這種新的實現方式,由於它能夠為我們創建對象的真實副本,因此子對象的修改並不會影響其父對象。
var kid = extendDeep(dad); kid.counts.push(4); console.log(kid.counts.toString()); console.log(dad.counts.toString()); console.log(dad.reads === kid.reads); kid.reads.paper = false; kid.reads.web = true; console.log(dad.reads.paper)
這種屬性複製模式比較簡單且得到了廣泛的應用。值得注意的是,本模式中根本沒有涉及到任何原型,本模式僅與對象以及它們自身的屬性相關。
混入
可以針對這種通過屬性複製實現繼承的思想作進一步的擴展,現在讓我們思考一種「mix-in」混入模式。mix-in模式並不是複製一個完整的對象,而是從多個對象中複製出任意的成員並將這些成員組合成一個新的對象。
mix-in實現比較簡單,只需遍歷每個參數,並且複製出傳遞給該函數的每個對象中的每個屬性。
function mix() { var arg,prop,child = {}; for(arg = 0;arg < arguments.length; arg += 1) { for(prop in arguments[arg]) { if(arguments[arg].hasOwnProperty(prop)) { child[prop] = arguments[arg][prop]; } } } return child; }
現在,您有一個通用的mix-in函數,可以向他傳遞任意數量的對象,其結果將獲得一個具有所有源對象屬性的新對象。下面是一個使用示例:
var cake = mix( {eggs:2,large:true}, {butter:2,salted:true}, {flour:'3 cups'}, {sugar:'sure!'} ) console.dir(cake)
注意:如果已經學習過那些正式包含mix-in概念的語言,並且習慣於mix-in的概念,那麼可能希望修改一個或多個父對象時可以影響其子對象,但是在本節給定的實現中並不是這樣的。子啊這裡我們僅簡單循環、複製自身的屬性,以及斷開與父對象之間的鏈接。
十一、借用方法
有時候,可能恰好僅需要現有對象其中的一個或兩個方法。在想要重用這些方法的同時,但是又不希望與源對象形成父-子繼承關係。也就是說,指向使用所需要的方法,而不希望繼承那些永遠都不會用到的其他方法。在這種情況下,可以通過使用借用方法模式來實現,而這時受益於call()和apply()函數方法。您已經在本書中見到過這種模式,比如,甚至於在本章中extendDeep()函數的實現內部都見到過。
如您所知,JavaScript中的函數也是對象,並且它們自身也附帶著一些有趣的方法,比如apply()和call()方法。這兩者之間的唯一區別在於其中一個可以接受傳遞給將被調用方法的參數數組,而另一個僅逐個接受參數。可以使用這些方法以借用現有對象的功能。
//call()例子 notmyobj.doStuff.call(myobj,param1,p2,p3); // apply()例子 notmyobj.doStuff.apply(myobj,[param1,p2,p3]);
在以上程式碼中,存在一個名為MyObj的對象,並且還知道其他名為notmyobj的對象中有一個名為doStuff()的有用方法。您無需經歷繼承所帶來的麻煩,也無需繼承myobj對象永遠都不會用到的一些方法,可以僅臨時性的借用方法doStuff()即可。
可以傳遞對象、任意參數以及借用方法,並將它們綁定到您的對象中以作為this本身的成員。從根本上說,您的對象將在一小段時間內偽裝成其他對象,從而借用其所需的方法。這就像得到了繼承的好處,但是卻無需支付遺產稅(這裡指其他您不需要的屬性或方法)。
例子:借用數組方法
本模式的一個常見實現方法是借用數組方法。
數組具有一些有用的放啊,而形如arguments的類似數組的對象並不具有這些方法。因此,arguments可以借用數組的方法,比如slice()方法:
function f() { var args = [].slice.call(arguments,1,3); return args; } console.log(f(1,2,3,4,5,6));
在這個例子中,創建一個空數組的原因只是為了使用數組的方法。此外,能夠實現同樣功能但是語句稍微長一點的方式是直接從Array的原型中借用方法,即使用Array.prototype.slice.call()方法。這種方式需要輸入更長一點的字元,但是卻可以節省創建一個空數組的工作。
借用和綁定
考慮到借用方法不是通過調用call()/apply()就是通過簡單的賦值,在借用方法的內部,this所指向的對象是基於調用表達式而確定的。但是有時候,最好能夠「鎖定」this的值,或者將其綁定到特定對象並預先確定該對象。
讓我們看下面這個例子,其中存在一個名為one的對象,且具有say()方法:
var one = { name:"object", say: function(greet) { return greet + ', ' + this.name; } }; // 測試 console.log(one.say('hi')); //結果為「hi,object」
現在,另一個對象two中並沒有say()方法,但是可以從對象one中借用該方法,如下所示:
var two = { name:"another object" }; console.log(one.say.apply(two,["hello"]));
在上述例子中,借用的say()方法內部的this指向了two對象,因而this.name的值為「another object」。但是在什麼樣的場景中,應該將函數指針賦值給一個全局變數,或者將該函數作為回調函數來傳遞?在客戶端編程中有許多事件和回調函數,因此確實發生了很多這樣混淆的事情。
// 給變數賦值 // this將指向全局變數 var say = one.say; console.log(say('hoho')); // 作為回調函數傳遞 var yetanother = { name:"Yet another object", method:function(callback) { return callback("Hola"); } }; console.log(yetanother.method(one.say));
在以上兩種情況下,say()方法內部的this指向了全局對象,並且整個程式碼段都無法按照預期正常運行。為了修復(也就是說,綁定)對象與方法之間的關係,我們可以使用如下的一個簡單函數:
function bind(o,m) { return function () { return m.apply(o,[].slice.call(arguments)) } }
這個bind()函數接受了一個對象o和一個方法m,並且將兩者綁定起來,然後返回另一個函數。其中,返回的函數可以通過閉包來訪問o和m。因此,即時在bind()返回後,內部函數熱盎然可以訪問o和m,並且總是指向原始對象和方法。下面,讓我們使用bind()創建一個新的函數:
var twosay = bind(two,one.say); console.log(twosay('yo'))
正如您上面所看到的,即時twosay()以全局函數方式而創建,但是say()方法內部的this並沒有指向全局對象,實際上它指向了傳遞給bind()的對象two。無論您如何調用twosay(),該方法永遠是綁定到對象two上。
奢侈的擁有綁定所需要付出的代價就是額外的必報的開銷。
Function.prototype.bind()
ECMAScript5中將bind()方法添加到了Function.prototype中,使得bind()就像apply()和call()一樣簡單易用。因此,可以執行如下表達式:
var newFunc = obj.someFunc.bind(myobj,1,2,3);
上述表達式的含義是將someFunc()和myobj綁定在一起,並且預填充someFunc()期望的前三個參數。這也是第四章中所討論的部分函數應用的一個例子。
當程式在ES5之前的環境運行時,讓我們看看應該如何實現Function.prototype.bind():
if(typeof Function.prototype.bind === 'undefined') { Function.prototype.bind = function(thisArg) { var fn = this, slice = Array.prototype.slice, args = slice.call(arguments,1); return function () { return fn.apply(thisArg,args.concat(slice.call(arguments))); } } }
這個實現看起來可能有點熟悉,它使用了部分應用並拼接了參數列表,即那些傳遞給bind()的參數(除了第一個以外),以及那些傳遞給由bind()所返回的新函數的參數,其中該新函數將在以後被調用。下面是一個使用示例:
var twosay2 = one.say.bind(two); console.log(twosay2("Bonjour"));
在前面的例子中,除了提供了將被綁定的對象以外,並沒有向bind()傳遞任何參數。在下面的例子,讓我們傳遞一個參數以實現部分應用:
var twosay3 = one.say.bind(two,"Enchante"); console.log(twosay3())
小結
當在JavaScript中涉及到繼承時,有很多可供選擇的方法。這些方法對於學習和理解多種不同的模式大有裨益,因為它們有助於提高您對語言的掌握程度。在本章中,您了解了幾種類式繼承模式以及集中現代繼承模式,從而可以解決繼承相關的問題。
然而,在開發過程中經常面臨的繼承可能並不是一個問題。其中一部分的原因在於,事實上使用的JavaScript庫可能以這樣或那樣的方式解決了該問題,而另一個方面的原因在於很少需要在JavaScript中建立長而且複雜的繼承鏈。在靜態強類型的語言中,繼承可能是唯一復用程式碼的方法。在JavaScript中,經常有更簡潔且優美的方法,其中包括借用方法、綁定、複製屬性以及從多個對象中混入屬性等多種方法。
最後,請記住,程式碼重用才是最終目的,而繼承只是實現這一目標的方法之一。
到這裡,這一篇就結束了,後面,我們開始學習設計模式!