《JavaScript 模式》讀書筆記(6)— 程式碼複用模式3

Zaking發表於2020-04-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中,經常有更簡潔且優美的方法,其中包括借用方法、繫結、複製屬性以及從多個物件中混入屬性等多種方法。

  最後,請記住,程式碼重用才是最終目的,而繼承只是實現這一目標的方法之一。

 

  到這裡,這一篇就結束了,後面,我們開始學習設計模式!

相關文章