JS設計模式深入理解—單例、工廠、構造函數(shù)、原型、組合構造原型、動態(tài)原型

了解并掌握各種JavaScript用于創(chuàng)建自定義類型對象的設計模式有利于幫助我們認識它們各自的優(yōu)缺點和適用場景,這樣我們在今后的開發(fā)過程中才能夠做到有的放矢,在正確的場合使用正確的模式創(chuàng)建對象。

一、單例模式

var person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";

person.sayName = function() {
    alert(this.name);
};

單例模式是指通過創(chuàng)建一個Object對象,并為其設置各種屬性和方法,以滿足自定義對象的使用需求,如上所示;很明顯,上面的多條語句顯得十分分散,為了更好地將它們組合起來,更好的辦法是使用對象字面量創(chuàng)建:

var person = {
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",

    sayName: function() {
        alert(this.name);
    }    
};

模式評價:雖然Object構造函數(shù)或對象字面量都可以用來創(chuàng)建單個對象,但這些方式有兩個明顯的缺點:

  • 沒有做到代碼的復用,即要是使用同樣的辦法創(chuàng)建多個對象,會產(chǎn)生大量的重復代碼。
  • 創(chuàng)建出的對象沒有具體的類型,它們只是Object類型的一個實例。

二、工廠模式

為了解決單例模式的代碼復用問題,更好的辦法是采用工廠模式:

function createPerson(name, age, job) {
    var o = new Object();
    o.name = name;
    o.age = age;
    o.job = job;
    o.sayName = function() {
        alert(this.name);
    };
    return o;
}

var person1 = createPerson("Nicholas", 29, "Software Engineer");
var person2 = createPerson("Greg", 27, "Doctor");

由于JavaScript中無法創(chuàng)建類,所以人們就發(fā)明了一種函數(shù),用函數(shù)來封裝以特定接口創(chuàng)建對象的細節(jié),這就是工廠模式的設計原理。

在上面的例子中,函數(shù)createPerson()能夠根據(jù)接受的參數(shù)來構建一個包含所有必要信息的Person對象,并且可以無數(shù)次地調用這個函數(shù),而每次調用都會返回一個包含三個屬性和一個方法的對象。

模式評價:工廠模式雖然解決了創(chuàng)建多個相似對象造成的代碼重復問題,但仍未解決對象類型的區(qū)分問題(怎樣知道一個對象的具體類型);通過工廠模式創(chuàng)建出的對象,其類型都是Object,如果能把上例中創(chuàng)建出的對象標記為Person類型就好了。

同時,通過工廠模式創(chuàng)建出的對象還存在著一個很嚴重的問題,那就是內(nèi)存浪費。每當調用一次createPerson()函數(shù)創(chuàng)建一個對象,就會在其內(nèi)部創(chuàng)建一個函數(shù)實例。在前面的例子中,person1person2都有一個名為sayName()的方法,但person1.sayName()person2.sayName()并不是引用的同一個函數(shù)實例,而是不同的實例,因為

o.sayName = function() {
    alert(this.name);
};

o.sayName = new Function("alert(this.name)");

在邏輯上是完全等價的。為了證明person1.sayName()person2.sayName()引用的是不同的函數(shù)實例,有:

alert(person1.sayName == person2.sayName)      //false

因此,若一個自定義對象類型中要是有多個方法,那么通過工廠模式定義出多個對象造成的內(nèi)存浪費就可想而知了。

三、構造函數(shù)模式

為了解決對象的類型問題,可以使用構造函數(shù)模式,JavaScript中的構造函數(shù)可以用來創(chuàng)建特定類型的對象:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = function() {
        alert(this.name);
    };
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

首先需要明確什么樣的函數(shù)稱為構造函數(shù),即通過new操作符+函數(shù)名的方式來創(chuàng)建對象的函數(shù),就叫做構造函數(shù)。

構造函數(shù)創(chuàng)建對象實例的過程:

  1. 創(chuàng)建一個新對象;
  2. 把當前構造函數(shù)內(nèi)的作用域賦給新創(chuàng)建的這個對象(此時構造函數(shù)內(nèi)部的this指針就指向了這個新對象);
  3. 執(zhí)行構造函數(shù)中的代碼;
  4. 返回新對象

同時構造函數(shù)還有一個極其重要的特性:默認情況下,若構造函數(shù)內(nèi)部沒有通過return語句返回其它類型的變量或對象,則通過構造函數(shù)創(chuàng)建并返回的對象其類型由構造函數(shù)名指定。

這個特性的意思就是,在上例中,通過名為Person的構造函數(shù)創(chuàng)建出的person1person2對象實例,其對象類型都是Person;也就是說,它們都是Person類型的對象實例(可以使用instanceof檢驗):

alert(person1 instanceof Person);      //true
alert(person2 instanceof Person);      //true

正因為這個特性,所以才為什么說構造函數(shù)可以用來創(chuàng)建特定類型的對象。

這樣一來,構造函數(shù)模式就很好理解了:

  1. 通過new Person()調用Person構造函數(shù),創(chuàng)建出一個Person類型的對象。
  2. Person構造函數(shù)內(nèi)部的作用域賦給該對象,即此時函數(shù)內(nèi)部的this指向了該對象。
  3. 執(zhí)行構造函數(shù)代碼,通過this為該對象創(chuàng)建屬性和方法。
  4. 由于Person構造函數(shù)內(nèi)部沒有用return語句返回其它變量和對象,所以Person構造函數(shù)返回該對象。

進一步優(yōu)化
為了解決工廠模式提到的內(nèi)存浪費問題,我們發(fā)現(xiàn)創(chuàng)建兩個完成同樣任務的Function實例的確沒有必要;況且有this對象在,根本不用在執(zhí)行代碼前就把函數(shù)綁定到特定對象上。因此,可以把公共函數(shù)的定義轉移到構造函數(shù)外部:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
    this.sayName = sayName;
}

function sayName() {
    alert(this.name);
};

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

模式評價
構造函數(shù)模式有效地解決了對象的類型問題,是比工廠模式更佳的解決方案。同時為了解決工廠模式中遇到的內(nèi)存浪費問題,選擇將公共函數(shù)的定義轉移到構造函數(shù)的外部,看樣子解決了目前遇到的所有問題。然而新的問題又來了:把公共函數(shù)定義在全局作用域中,而僅僅只是為了供對象調用,看起來似乎有些小題大做了。而更讓人感到違和的是,如果一個對象有很多公共函數(shù),或者把所有對象的公共函數(shù)定義都放在全局作用域中,暫且不說我們這個自定義的引用類型毫無封裝性可言,更有可能會與全局函數(shù)的定義混在一起難以區(qū)分,從而增加了代碼的維護成本。因此我們?nèi)孕枰乙粋€具有更好封裝性的解決方案。

四、原型模式

我們創(chuàng)建的每個函數(shù)都有一個prototype(原型)屬性,這個屬性是一個指針,指向一個對象(原型對象),而這個對象的用途是可以包含由特定類型的所有實例共享的屬性和方法。因此產(chǎn)生了原型模式:

function Person() {
}

Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
    alert(this.name);
};

var person1 = new Person();
person1.sayName();        //Nicholas

var person2 = new Person();
person2.sayName();        //Nicholas

alert(person1.sayName == person2.sayName)    //true

由于原型對象的存在,我們可以在構造函數(shù)中什么都不寫,只要是通過構造函數(shù)創(chuàng)建的對象實例(new操作符),都能夠與函數(shù)的原型對象相關聯(lián),從而訪問原型對象中的屬性和方法。這也是為什么person1.sayName == person2.sayName,因為它們訪問的屬性和方法都是位于原型對象中的同一份。

每當創(chuàng)建一個函數(shù)時,原型對象會自動獲得一個屬性:constructor,該屬性指向prototype所在函數(shù),即在本例中,Person.prototype.constructor == Person。當利用構造函數(shù)創(chuàng)建一個對象實例時,創(chuàng)建出的對象會自動獲得一個指向當前構造函數(shù)原型對象的內(nèi)部指針[[prototype]](雖然無法訪問但卻是真實存在的),這也解釋了為什么所有通過構造函數(shù)創(chuàng)建的實例能夠訪問同一個原型對象中的屬性和方法。

再看JavaScript引擎是如何搜索某個對象的某個屬性的:每當代碼讀取某個對象的某個屬性時,都會執(zhí)行一次搜索,目標是具有給定名字的屬性。搜索先從對象實例本身開始。如果在實例中找到了具有給定名字的屬性,則返回該屬性的值;否則繼續(xù)搜索
[[prototype]]內(nèi)部指針指向的原型對象,在原型對象中查找具有給定名字的屬性。如果在原型對象中找到了該屬性,則返回原型對象中的該屬性值。

原型模式利用了所有對象實例訪問同一個原型對象的機制來解決內(nèi)存浪費問題,不過在前面的例子中,每添加一個屬性和方法都要敲一遍Person.prototype。為減少冗余代碼,也為視覺上更好的封裝性,更常見的做法是用一個包含所有屬性和方法的對象字面量來重寫整個原型對象:

function Person() {
}

Person.prototype = {
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};

然而這樣又會帶來一個問題,那就是利用對象字面量來重寫Person.prototype后,Person.prototype.constructor已經(jīng)不再指向Person了;這是因為利用對象字面量創(chuàng)建出的對象是Object()構造函數(shù)創(chuàng)建出的實例,其prototype.constructor指向的是Object()構造函數(shù);因此,如果constructor十分重要的話,還需要將其顯式設置回原來的值:

function Person() {
}

Person.prototype = {
    construct: Person,
    name: "Nicholas",
    age: 29,
    job: "Software Engineer",
    sayName : function () {
        alert(this.name);
    }
};

模式評價:基于原型對象的特點,原型模式可以將自定義類型的方法定義在原型對象內(nèi)部,而不用再將其放到全局作用域中,因此從這一點考慮,它是比構造函數(shù)模式更優(yōu)的解決方案;然而原型模式也并非沒有缺點,那就是它省略了為構造函數(shù)傳遞初始化參數(shù)的這一環(huán)節(jié),結果使得所有實例在默認情況下都取得相同的屬性值,每個對象無法擁有自己獨特的屬性內(nèi)容,這顯然是與“利用相同模板創(chuàng)建不同對象”的理念背道而馳的。因此這也正是很少看到有人單獨使用原型模式創(chuàng)建對象的原因所在。

五、組合使用構造函數(shù)模式和原型模式

為了解決原型模式所遇到的困境,自然而然會想到利用原型對象定義公共方法,而把共享的屬性放到構造函數(shù)中的方式來創(chuàng)建對象,而這正是組合使用構造函數(shù)模式和原型模式的思路:

function Person(name, age, job) {
    this.name = name;
    this.age = age;
    this.job = job;
}

Person.prototype = {
    constructor: Person,
    sayName: function() {
        alert(this.name);
    }
};

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

這樣一來,每個對象都有自己的屬性,但同時又能共享一份相同的方法,最大限度地節(jié)約了內(nèi)存。
模式評價:這種混成模式集構造函數(shù)模式和原型模式各自之長,是目前JavaScript中使用最廣泛、認同度最高的一種創(chuàng)建自定義類型的方法??梢哉f,這是用來定義引用類型的一種默認模式。

六、動態(tài)原型模式

即便是已經(jīng)擁有了如此好的用來定義引用類型的設計模式,但習慣于使用Java/C++等語言的開發(fā)人員可能仍會認為:組合使用構造函數(shù)模式和原型模式的設計模式感覺還是和單獨使用構造函數(shù)模式差不多,前者雖然比后者好那么一些,但還是沒法做到徹底地封裝,畢竟構造函數(shù)和原型對象依然是分開定義的,從這一點來說,兩者并沒有多大差別。為了做到將兩者結合到一起,實現(xiàn)徹底的封裝,于是就有了動態(tài)原型模式:

function Person(name, age, job) {
    //屬性
    this.name = name;
    this.age = age;
    this.job = job;
    
    //方法
    if(typeof this.sayName != "function") {
        //所有的公有方法都在這里定義
        Person.prototype.sayName = function() {
            alert(this.name);
        };

        Person.prototype.sayJob = function() {
            alert(this.job);
        };


        Person.prototype.sayAge = function() {
            alert(this.age);
        };
    }
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

person1.sayName();        //Nicholas
person2.sayName();        //Greg

動態(tài)原型模式把所有信息都封裝到了構造函數(shù)中,既通過在構造函數(shù)中初始化原型(只會在第一次調用構造函數(shù)創(chuàng)建對象時進行),也保持了同時使用構造函數(shù)和原型的優(yōu)點。

我們可以分析一下該模式創(chuàng)建對象實例的過程:

  1. 首先通過new Person()調用構造函數(shù),此時立刻創(chuàng)建了一個Person類型的對象。
  2. 新創(chuàng)建的對象獲得了指向構造函數(shù)原型對象的指針,此時的原型對象中只有constructor屬性,且指向Person函數(shù)。
  3. Person構造函數(shù)內(nèi)部的作用域賦給該對象,即此時函數(shù)內(nèi)部的this指向了該對象。
  4. 進入構造函數(shù)執(zhí)行內(nèi)部語句,首先設置新對象的name,age,job屬性
  5. 隨后搜索并判斷新對象中是否已存在有效的sayName()函數(shù),如果不存在,說明原型對象中還沒有添加該方法,此時創(chuàng)建原型對象中的對應方法。
  6. 由于新對象中保存的是指向原型對象的指針,所以在原型對象中添加的方法能被新對象動態(tài)訪問到
  7. 返回新對象并退出構造函數(shù)

構造函數(shù)中通過檢查某個應該存在的方法是否有效,來決定是否需要初始化原型;這是一個非常巧妙的策略。若把所有需要初始化的公共方法和公共屬性都放在一起,僅僅只需要檢查其中一個即可知道是否已執(zhí)行過這段代碼。因此在上例中,只有第一次創(chuàng)建對象person1時才會初始化原型,而當創(chuàng)建對象person2時,由于原型已經(jīng)初始化了,所以將不再重復此過程。

不過需要特別注意的是,該模式下不能使用對象字面量構造原型,如下面這樣:

function Person(name, age, job) {
    //屬性
    this.name = name;
    this.age = age;
    this.job = job;
    
    //方法
    if(typeof this.sayName != "function") {
        //不能用這樣的方法初始化原型
        Person.prototype = {
            sayName: function() {
                alert(this.name);
            },

            sayJob: function() {
                alert(this.job);
            },

            sayAge: function() {
                alert(this.age);
            }
        };
    }
}

var person1 = new Person("Nicholas", 29, "Software Engineer");
var person2 = new Person("Greg", 27, "Doctor");

person1.sayName();        //出錯
person2.sayName();        //出錯

這是因為,若把原型對象的初始化過程放到構造函數(shù)中,那么當調用構造函數(shù)創(chuàng)建對象時,對象的創(chuàng)建總是先于原型對象的初始化的(在執(zhí)行構造函數(shù)內(nèi)部的代碼前對象就已經(jīng)完成了創(chuàng)建),由于對象獲得指向原型對象的指針發(fā)生在對象創(chuàng)建時刻,所以在這種情況下,創(chuàng)建的對象早已指向了原型對象,對于之前的情況,由于Person.prototype和新對象的[[prototype]]指向同一個對象,所以通過Person.prototype修改原型對象能被新對象的[[prototype]]訪問到;然而使用對象字面量時,Person.prototype通過賦值的方式指向了另一個對象,但此時[[prototype]]仍指向初始的原型對象,這也是為什么person1.sayName()出錯的原因。

總結:

通過對各個模式的分析發(fā)現(xiàn),組合使用構造函數(shù)模式和原型模式動態(tài)原型模式是所有介紹過的模式中最適合用來創(chuàng)建自定義類型對象的兩個模式。這兩者雖然從本質上看是相同的,但是由于實現(xiàn)細節(jié)的不同使得它們互有優(yōu)勢。對于前者來說,它能夠使用對象字面量的方式構造原型對象,適用于定義具有較多公共方法的對象類型,這樣可以簡化代碼,但其構造函數(shù)與原型對象是分開定義的,封裝性一般。而后者正好與之相反,后者雖不能使用對象字面量創(chuàng)建原型對象,但卻做到了將原型對象的初始化封裝到了構造函數(shù)中從而形成一個整體,這樣更加符合OO的思想。

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容