單例模式的定義是:
保證一個類僅有一個執行個體,并提供一個通路它的全局通路點。
單例模式是一種常用的模式,有一些對象我們往往隻需要一個,比如線程池、全局緩存、浏覽器中的 window 對象等。在 JavaScript 開發中,單例模式的用途同樣非常廣泛。試想一下,當我們單擊登入按鈕的時候,頁面中會出現一個登入浮窗,而這個登入浮窗是唯一的,無論單擊多少次登入按鈕,這個浮窗都隻會被建立一次,那麼這個登入浮窗就适合用單例模式來建立
1.js中的單例設計模式
全局變量不是單例模式,但在 JavaScript 開發中,我們經常會把全局變量當成單例來使用。
例如為了實作滑鼠滑動特效,我們定義一些方法
function get(id) {
return document.getElementById(id);
}
function css(id, key, value) {
get(id).style[key] = value;
}
function attr(id, key, value) {
get(id)[key] = value;
}
function html(id, value) {
get(id).innerHTML = value;
}
function on(id, type, fn) {
get(id)[`on${type}`] = fn;
}
當用這種方式建立函數的時候,函數确實是獨一無二的。函數被聲明在全局作用域下, 則我們可以在代碼中的任何位置使用這個變量,全局變量提供給全局通路是理所當然的。這樣就滿足了單例模式的兩個條件。
但是全局變量存在很多問題,它很容易造成命名空間污染。在大中型項目中,如果不加以限制和管理,程式中可能存在很多這樣的變量。JavaScript 中的變量也很容易被不小心覆寫,相信每個 JavaScript 程式員都曾經曆過變量沖突的痛苦,就像上面的各種函數,随時有可能被别人覆寫。
1.1 命名空間
适當地使用命名空間,并不會杜絕全局變量,但可以減少全局變量的數量。 最簡單的方法依然是用對象字面量的方式:
我們可以直接将上面的代碼改為下面這樣:
var Hao = {
get(id) {
return document.getElementById(id);
},
css(id, key, value) {
this.get(id).style[key] = value;
},
// ....
}
1.2 子產品分明
在js中單例設計模式除了定義命名空間,還可以管理代碼庫中的各個設計子產品,比如早期百度tangram,雅虎的YUI都是通過單例設計模式來控制自己每個功能子產品的。其實就是将功能相關的放在同一個對象的同一個子產品中。
例如:
baidu.dom.addClass //添加元素累
baidu.dom.append //插入元素
baidu.event.stopPropagation // 阻止冒泡
baidu.event.preventDeafult //阻止預設行為
1.3使用閉包封裝私有變量
将一些變量封裝在函數的内部,,隻暴露一些接口給外部使用
var user = (function(){
var _name = ''hcd,
_age = 24;
return {
getUserInfo: function(){
return _name + '-' + _age;
}
}
})()
我們用下劃線來約定私有變量_name 和_age,它們被封裝在閉包産生的作用域中,外部是通路不到這兩個變量的,這就避免了對全局的指令污染。
2. 實作單例模式
2.1 簡單的單例模式
要實作一個标準的單例模式并不複雜,無非是用一個變量來标志目前是否已經為某個類建立過對象,如果是,則在下一次擷取該類的執行個體時,直接傳回之前建立的對象。代碼如下:
const Obj = function(name){
this.name = name;
this.instance = null;
}
Obj.prototype.say = function() {
console.log(this.name);
}
Obj.get = function(name) {
if(!this.instance) {
return this.instance = new Obj(name);
}
return this.instance;
}
let hcd = Obj.get('hcd');
let asd = Obj.get('asd');
console.log(hcd == asd); //true
我們通過
Obj.get
來擷取
Obj
類的唯一對象,這種方式相對簡單,但有 一個問題,就是增加了這個類的“不透明性”,
Obj
類的使用者必須知道這是一個單例類, 跟以往通過 new XXX 的方式來擷取對象不同,這裡偏要使用
Obj
來擷取對象
2.2 透明的單例模式
我們現在的目标是實作一個“透明”的單例類,使用者從這個類中建立對象的時候,可以像使用其他任何普通類一樣。通過
new
來建立對象。
var CreateDiv = (function () {
var instance;
var CreateDiv = function (html) {
if (instance) {
return instance;
}
this.html = html;
this.init();
return instance = this;
};
CreateDiv.prototype.init = function () {
var div = document.createElement('div');
div.innerHTML = this.html;
document.body.appendChild(div);
};
return CreateDiv;
})();
var a = new CreateDiv('sven1');
var b = new CreateDiv('sven2');
alert(a === b); // true
為了把 instance 封裝起來,我們使用了自執行的匿名函數和閉包,并且讓這個匿名函數傳回真正的 Singleton 構造方法,這增加了一些程式的複雜度,閱讀起來也不是很舒服。
觀察現在的 單例 構造函數:
var CreateDiv = function( html ){
if ( instance ){
return instance;
}
this.html = html;
this.init();
return instance = this;
};
在這段代碼中,CreateDiv 的構造函數實際上負責了兩件事情。第一是建立對象和執行初始化 init 方法,第二是保證隻有一個對象。
根據“單一職責原則”的概念, 這是一種不好的做法。
假設我們某天需要利用這個類,在頁面中建立千千萬萬的 div,即要讓這個類從單例類變成 一個普通的可産生多個執行個體的類,那我們必須得改寫 CreateDiv 構造函數,把控制建立唯一對象的那一段去掉,這種修改會給我們帶來不必要的煩惱。
2.3 代理實作單例模式
現在我們通過引入代理類的方式,來解決上面提到的問題。
我們依然使用上面的代碼,首先在 CreateDiv 構造函數中,把負責管理單例的代碼移除出去,使它成為一個普通的建立 div 的類:
var CreateDiv = function (html) {
this.html = html;
this.init();
};
CreateDiv.prototype.init = function () {
var div = document.createElement('div');
div.innerHTML = this.html;
document.body.appendChild(div);
};
var ProxySingletonCreateDiv = (function () {
var instance;
return function (html) {
if (!instance) {
instance = new CreateDiv(html);
}
return instance;
}
})();
var a = new ProxySingletonCreateDiv('sven1');
var b = new ProxySingle
通過引入代理類的方式,我們同樣完成了一個單例模式的編寫,跟之前不同的是,現在我們 把負責管理單例的邏輯移到了代理類 proxySingletonCreateDiv 中。這樣一來,CreateDiv 就變成了 一個普通的類,它跟 proxySingletonCreateDiv 組合起來可以達到單例模式的效果。
3. 惰性單例
惰性單例指的是在需要的時候才建立對象執行個體。
惰性單例是單例模式的重點,這種技術在實際開發中非常有用,有用的程度可能超出了我們的想象,在2.1的簡單單例中就使用過這種技術, instance 執行個體對象總是在我們調用
Obj.get
的時候才被建立,而不是在頁面加載好的時候就建立,代碼如下:
Obj.get = function(name) {
if(!this.instance) {
return this.instance = new Obj(name);
}
return this.instance;
}
3.1惰性單例執行個體
下面我們來舉一個WebQQ 的登入浮窗的例子。
假設我們是 WebQQ 的開發人員(網址是web.qq.com),當點選左邊導航裡 QQ 頭像時,會彈 出一個登入浮窗(如圖 4-1 所示),很明顯這個浮窗在頁面裡總是唯一的,不可能出現同時存在 兩個登入視窗的情況。
第一種解決方案:
在頁面加載完成的時候便建立好這個 div 浮窗,這個浮窗一開始肯定是隐藏狀态的,當使用者點選登入按鈕的時候,它才開始顯示:
<html>
<body>
<button id="loginBtn">登入</button> </body>
<script>
var loginLayer = (function(){
var div = document.createElement( 'div' );
div.innerHTML = '我是登入浮窗';
div.style.display = 'none';
document.body.appendChild( div );
return div;
})();
document.getElementById( 'loginBtn' ).onclick = function(){
loginLayer.style.display = 'block';
};
</script>
</html>
這種方式有一個問題,也許我們進入 WebQQ 隻是玩玩遊戲或者看看天氣,根本不需要進行 2 登入操作,因為登入浮窗總是一開始就被建立好,那麼很有可能将白白浪費一些 DOM 節點。
第二種解決方案:
使用者點選登入按鈕的時候才開始建立該浮窗:
<html>
<body>
<button id="loginBtn">登入</button> </body>
<script>
var createLoginLayer = function(){
var div = document.createElement( 'div' );
div.innerHTML = '我是登入浮窗';
div.style.display = 'none';
document.body.appendChild( div );
return div;
};
document.getElementById( 'loginBtn' ).onclick = function(){
var loginLayer = createLoginLayer();
loginLayer.style.display = 'block';
};
</script>
</html>
雖然現在達到了惰性的目的,但失去了單例的效果。當我們每次點選登入按鈕的時候,都會 建立一個新的登入浮窗 div。雖然我們可以在點選浮窗上的關閉按鈕時(此處未實作)把這個浮 窗從頁面中删除掉,但這樣頻繁地建立和删除節點明顯是不合理的,也是不必要的。
第三種解決方案:
我們可以用一個變量來判斷是否已經建立過登入浮窗
var createLoginLayer = (function(){
var div;
return function(){
if ( !div ){
div = document.createElement( 'div' );
div.innerHTML = '我是登入浮窗';
div.style.display = 'none';
document.body.appendChild( div );
}
return div;
}
})();
document.getElementById( 'loginBtn' ).onclick = function(){
var loginLayer = createLoginLayer();
loginLayer.style.display = 'block';
};
3.2通用的惰性單例
前面我們完成了一個可用的惰性單例,但是我們發現它還有如下一些問題。
這段代碼仍然是違反單一職責原則的,建立對象和管理單例的邏輯都放在 createLoginLayer 對象内部。
如果我們下次需要建立頁面中唯一的 iframe,或者 script 标簽,用來跨域請求資料,就 必須得如法炮制,把 createLoginLayer 函數幾乎照抄一遍:
var createIframe= (function(){
var iframe;
return function(){
if ( !iframe){
iframe= document.createElement( 'iframe' );
iframe.style.display = 'none';
document.body.appendChild( iframe);
}
return iframe;
}
})();
我們需要把不變的部分隔離出來,先不考慮建立一個 div 和建立一個 iframe 有多少差異,管 理單例的邏輯其實是完全可以抽象出來的,這個邏輯始終是一樣的:用一個變量來标志是否建立 過對象,如果是,則在下次直接傳回這個已經建立好的對象:
var obj;
if ( !obj ){
obj = xxx;
}
現在我們就把如何管理單例的邏輯從原來的代碼中抽離出來,這些邏輯被封裝在 getSingle 函數内部,建立對象的方法 fn 被當成參數動态傳入 get 函數
var getSingle = function( fn ){
var result;
return function(){
return result || ( result = fn .apply(this, arguments ) );
}
};
接下來将用于建立登入浮窗的方法用參數 fn 的形式傳入 getSingle,我們不僅可以傳入 createLoginLayer,還能傳入 createScript、createIframe、createXhr 等。之後再讓 getSingle 傳回 一個新的函數,并且用一個變量 result 來儲存 fn 的計算結果。result 變量因為身在閉包中,它永遠不會被銷毀。在将來的請求中,如果 result 已經被指派,那麼它将傳回這個值。代碼如下:
var createLoginLayer = function(){
var div = document.createElement( 'div' );
div.innerHTML = '我是登入浮窗';
div.style.display = 'none';
document.body.appendChild( div );
return div;
};
var createSingleLoginLayer = getSingle( createLoginLayer );
document.getElementById( 'loginBtn' ).onclick = function(){
var loginLayer = createSingleLoginLayer();
loginLayer.style.display = 'block';
};
在這個例子中,我們把建立執行個體對象的職責和管理單例的職責分别放置在兩個方法裡,這兩 個方法可以獨立變化而互不影響,當它們連接配接在一起的時候,就完成了建立唯一執行個體對象的功能, 看起來是一件挺奇妙的事情。
3.3 惰性單例的更過應用
我們通常渲染完頁面中的一個清單之後,接下來 9 要給這個清單綁定 click 事件,如果是通過 ajax 動态往清單裡追加資料,在使用事件代理的前提下,click 事件實際上隻需要在第一次渲染清單的時候被綁定一次,但是我們不想去判斷目前是 否是第一次渲染清單,如果借助于 jQuery,我們通常選擇給節點綁定 one 事件:
var bindEvent = function(){
$( 'div' ).one( 'click', function(){
alert ( 'click' );
});
};
var render = function(){
console.log( '開始渲染清單' );
bindEvent();
};
render();
render();
render();
如果利用通用惰性單例getSingle 函數,也能達到一樣的效果。代碼如下:
var bindEvent = getSingle(function(){
document.getElementById( 'div1' ).onclick = function(){
alert ( 'click' );
}
return true;
});
var render = function(){
console.log( '開始渲染清單' );
bindEvent();
};
render();
render();
render();
可以看到,render 函數和 bindEvent 函數都分别執行了 3 次,但 div 實際上隻被綁定了一個 事件。
4.總結
單例模式是一個隻允許執行個體化一次的對象類,有事這麼做也是為了節省資源。當然js中的單例模式經常被用作命名空間對象來實作,通過單例模式我們可以将各個子產品管理得井井有條。
是以如果你隻想在系統中存在一個同一類對象,可以考慮單例模式。
參考資料
JavaScript設計模式與開發實踐----曾探
JavaScript設計模式----張容銘