JavaScript 模塊化編程 - Module Pattern

## 前言

The Module Pattern,模塊模式,也譯為模組模式,是一種通用的對代碼進行模塊化組織與定義的方式。這里所說的模塊(Modules),是指實現某特定功能的一組方法和代碼。許多現代語言都定義了代碼的模塊化組織方式,比如 Golang 和 Java,它們都使用 package 與 import 來管理與使用模塊,而目前版本的 JavaScript 并未提供一種原生的、語言級別的模塊化組織模式,而是將模塊化的方法交由開發者來實現。因此,出現了很多種 JavaScript 模塊化的實現方式,比如,CommonJS Modules、AMD 等。

以 AMD 為例,該規范使用 define 函數來定義模塊。使用 AMD 規范進行模塊化編程是很簡單的,大致上的結構是這樣的:

define(factory(){

// 模塊代碼

// return something;

});

目前尚在制定中的 Harmony/ECMAScript 6(也稱為 ES.next),會對模塊作出語言級別的定義,但距離實用尚遙不可及,這里暫時不討論它。

作為一種模式,模塊模式其實一直伴隨著 JavaScript 存在,與 ES 6 無關。最近我需要重構自己的一些代碼,因此我參考和總結了一些實用的模塊化編程實踐,以便更好的組織我的代碼。需要注意的是,本文只是個人的一個總結,比較簡單和片面,詳盡的內容與剖析請參看文后的參考資料,它們寫得很好。本文并不關心模塊如何載入,只關心現今該如何組織模塊化的代碼。還有,不必過于糾結所謂的模式,真正重要的其實還是模塊代碼及思想。所謂模式,不過是我們書寫代碼的一些技巧和經驗的總結,是一些慣用法,實踐中應靈活運用。

## 模塊模式

### 閉包與 IIFE (Immediately-Invoked Function Expression)

模塊模式使用了 JavaScript 的一個特性,即閉包(Closures)。現今流行的一些 JS 庫中經常見到以下形式的代碼:

;(function(參數) {

// 模塊代碼

// return something;

})(參數);

上面的代碼定義了一個匿名函數,并立即調用自己,這叫做自調用匿名函數(SIAF),更準確一點,稱為立即調用的函數表達 (Immediately-Invoked Function Expression, IIFE–讀做“iffy”)。

在閉包中,可以定義私有變量和函數,外部無法訪問它們,從而做到了私有成員的隱藏和隔離。而通過返回對象或函數,或是將某對象作為參數傳入,在函數體內對該對象進行操作,就可以公開我們所希望對外暴露的公開的方法與數據。

這,其實就是模塊模式的本質。

注1:上面的代碼中,最后的一對括號是對匿名函數的調用,因此必不可少。而前面的一對圍繞著函數表達式的一對括號并不是必需的,但它可以用來給開發人員一個指示 -- 這是一個 IIFE。也有一些開發者在函數表達式前面加上一個驚嘆號(!)或分號(;),而不是用括號包起來。比如 knockoutjs 的源碼大致就是這樣的:

!function(參數) {

// 代碼

// return something

}(參數);

還有些人喜歡用括號將整個 IIFE 圍起來,這樣就變成了以下的形式:

(function(參數) {

// 代碼

// return something

}(參數));

注2:在有些人的代碼中,將 undefined 作為上面代碼中的一個參數,他們那樣做是因為 undefined 并不是 JavaScript 的保留字,用戶也可以定義它,這樣,當判斷某個值是否是 undefined 的時候,判斷可能會是錯誤的。將 undefined 作為一個參數傳入,是希望代碼能按預期那樣運行。不過我認為,一般情況下那樣做并沒太大意義。

### 參數輸入

JavaScript 有一個特性叫做隱式全局變量(implied globals),當使用一個變量名時,JavaScript 解釋器將反向遍歷作用域鏈來查找變量的聲明,如果沒有找到,就假定該變量是全局變量。這種特性使得我們可以在閉包里隨處引用全局變量,比如 jQuery 或 window。然而,這是一種不好的方式。

考慮模塊的獨立性和封裝,對其它對象的引用應該通過參數來引入。如果模塊內需要使用其它全局對象,應該將這些對象作為參數來顯式引用它們,而非在模塊內直接引用這些對象的名字。以 jQuery 為例,若在參數中沒有輸入 jQuery 對象就在模塊內直接引用 $ 這個對象,是有出錯的可能的。正確的方式大致應該是這樣的:

;(function(q, w) {

// q is jQuery

// w is window

// 局部變量及代碼

// 返回

})(jQuery, window);

相比隱式全局變量,將引用的對象作為參數,使它們得以和函數內的其它局部變量區分開來。這樣做還有個好處,我們可以給那些全局對象起一個別名,比如上例中的 "q"。現在看看你的代碼,是否沒有經過對 jQuery 的引用就到處都是"$"?

### 模塊輸出(Module Export)

有時我們不只是要使用全局變量,我們也要聲明和輸出模塊中的對象,這可以通過匿名函數的 return 語句來達成,而這也構成了一個完整的模塊模式。來看一個完整的例子:

varMODULE = (function() {

varmy = {},

privateVariable = 1;

functionprivateMethod() {

// ...

}

my.moduleProperty = 1;

my.moduleMethod =function() {

// ...

};

returnmy;

}());

這段代碼聲明了一個變量 MODULE,它帶有兩個可訪問的屬性:moduleProperty 和 moduleMethod,其它的代碼都封裝在閉包中保持著私有狀態。參考以前提過的參數輸入,我們還可以通過參數引用其它全局變量。

#### 輸出簡單對象

很多時候我們 return 一個對象作為模塊的輸出,比如上例就是。

另外,使用對象直接量(Object Literal Notation)來表達 JavaScript 對象是很常見的。比如:var x = { p1: 1, p2: "2", f: function(){ /*... */ } }

很多時候我們都能見到這樣的模塊化代碼:

varModule1 = (function() {

varprivate_variable = 1;

functionprivate_method() {/*...*/}

varmy = {

property1: 1,

property2: private_variable,

method1: private_method,

method2:function() {

// ...

}

};

returnmy;

}());

另外,對于簡單的模塊化代碼,若不涉及私有成員等,其實也可以直接使用對象直接量來表達一個模塊:

varWidget1 = {

name:"who am i?",

settings: {

x: 0,

y: 0

},

call_me:function() {

// ...

}

};

有一篇文章講解了這種形式:How Do You Structure JavaScript? The Module Pattern Edition

不過這只是一種簡單的形式,你可以將它看作是模塊模式的一種基礎的簡單表達形式,而把閉包形式看作是對它的一個封裝。

#### 輸出函數

有時候我們希望返回的并不是一個對象,而是一個函數。有兩種需求要求我們返回一個函數,一種情況是我們需要它是一個函數,比如 jQuery,它是一個函數而不是一個簡單對象;另一種情況是我們需要的是一個“類”而不是一個直接量,之后我們可以用 "new" 來實例它。目前版本的 JavaScript 并沒有專門的“類”定義,但它卻可以通過 function 來表達。

varCat = (function() {

// 私有成員及代碼 ...

returnfunction(name) {

this.name = name;

this.bark =function() {/*...*/}

};

}());

vartomcat =newCat("Tom");

tomcat.bark();

為什么不直接定義一個 function 而要把它放在閉包里呢?簡單點的情況,確實不需要使用 IIFE 這種形式,但復雜點的情況,在構造我們所需要的函數或是“類”時,若需要定義一些私有的函數,就有必要使用 IIFE 這種形式了。

另外,在 ECMAScript 第五版中,提出了 Object.create() 方法。這時可以將一個對象視作“類”,并使用 Object.create() 進行實例化,不需使用 "new"。

### Revealing Module Pattern

前面已經提到一種形式是輸出對象直接量(Object Literal Notation),而 Revealing Module Pattern 其實就是這種形式,只是做了一些限定。這種模式要求在私有范圍內中定義變量和函數,然后返回一個匿名對象,在該對象中指定要公開的成員。參見下面的代碼:

varMODULE = (function() {

// 私有變量及函數

varx = 1;

functionf1() {}

functionf2() {}

return{

public_method1: f1,

public_method2: f2

};

}());

## 模塊模式的變化

### 擴展

上面的舉例都是在一個地方定義模塊,如果我們需要在數個文件中分別編寫一個模塊的不同部分該怎么辦呢?或者說,如果我們需要對已有的模塊作出擴展該怎么辦呢?其實也很簡單,將模塊對象作為參數輸入,擴展后再返回自己就可以了。比如:

varMODULE = (function(my) {

my.anotherMethod =function() {

// added method...

};

returnmy;

}(MODULE));

上面的代碼為對象 MODULE 增加了一個 "anotherMethod" 方法。

### 松耦合擴展(Loose Augmentation)

上面的代碼要求 MODULE 對象是已經定義過的。如果這個模塊的各個組成部分并沒有加載順序要求的話,其實可以允許輸入的參數為空對象,那么我們將上例中的參數由 MODULE 改為 MODULE || {} 就可以了:

varMODULE = (function(my) {

// add capabilities...

returnmy;

}(MODULE || {}));

### 緊耦合擴展(Tight Augmentation)

與上例不同,有時我們要求在擴展時調用以前已被定義的方法,這也有可能被用于覆蓋已有的方法。這時,對模塊的定義順序是有要求的。

varMODULE = (function(my) {

varold_moduleMethod = my.moduleMethod;

my.moduleMethod =function() {

// 方法重載

// 可通過 old_moduleMethod 調用以前的方法...

};

returnmy;

}(MODULE));

### 克隆與繼承(Cloning and Inheritance)

varMODULE_TWO = (function(old) {

varmy = {},

key;

for(keyinold) {

if(old.hasOwnProperty(key)) {

my[key] = old[key];

}

}

varsuper_moduleMethod = old.moduleMethod;

my.moduleMethod =function() {

// override method on the clone, access to super through super_moduleMethod

};

returnmy;

}(MODULE));

有時我們需要復制和繼承原對象,上面的代碼演示了這種操作,但未必完美。如果你可以使用 Object.create() 的話,請使用 Object.create() 來改寫上面的代碼:

varMODULE_TWO = (function(old) {

varmy = Object.create(old);

varsuper_moduleMethod = old.moduleMethod;

my.moduleMethod =function() {

// override method ...

};

returnmy;

}(MODULE));

### 子模塊(Sub-modules)

模塊對象當然可以再包含子模塊,形如 MODULE.Sub=(function(){}()) 之類,這里不再展開敘述了。

### 各種形式的混合

以上介紹了常見的幾種模塊化形式,實際應用中有可能是這些形式的混合體。比如:

varUTIL = (function(parent, $) {

varmy = parent.ajax = parent.ajax || {};

my.get =function(url, params, callback) {

// ok, so I'm cheating a bit :)

return$.getJSON(url, params, callback);

};

// etc...

returnparent;

}(UTIL || {}, jQuery));

## 與其它模塊規范或 JS 庫的適配

### 模塊環境探測

現今,CommonJS Modules 與 AMD 有著廣泛的應用,如果確定 AMD 的 define 是可用的,我們當然可以使用 define 來編寫模塊化的代碼。然而,我們不能假定我們的代碼必然運行于 AMD 環境下。有沒有辦法可以讓我們的代碼既兼容于 CommonJS Modules 或 AMD 規范,又能在一般環境下運行呢?

其實我們只需要在某個地方加上對 CommonJS Modules 與 AMD 的探測并根據探測結果來“注冊”自己就可以了,以上那些模塊模式仍然有用。

AMD 定義了 define 函數,我們可以使用 typeof 探測該函數是否已定義。若要更嚴格一點,可以繼續判斷 define.amd 是否有定義。另外,SeaJS 也使用了 define 函數,但和 AMD 的 define 又不太一樣。

對于 CommonJS,可以檢查 exports 或是 module.exports 是否有定義。

現在,我寫一個比較直白的例子來展示這個過程:

varMODULE = (function() {

varmy = {};

// 代碼 ...

if(typeofdefine =='function') {

define(function(){returnmy; } );

}elseif(typeofmodule !='undefined'&& module.exports) {

module.exports = my;

}

returnmy;

}());

上面的代碼在返回 my 對象之前,先檢測自己是否是運行在 AMD 環境之中(檢測 define 函數是否有定義),如果是,就使用 define 來定義模塊,否則,繼續檢測是否運行于 CommonJS 中,比如 NodeJS,如果是,則將 my 賦值給 module.exports。因此,這段代碼應該可以同時運行于 AMD、CommonJS 以及一般的環境之中。另外,我們的這種寫法應該也可在 SeaJS 中正確執行。

### 其它一些 JS 庫的做法

現在許多 JS 庫都加入了對 AMD 或 CommonJS Modules 的適應,比如 jQuery, Mustache, doT, Juicer 等。

jQuery 的寫法可參考 exports.js:

if(typeofmodule ==="object"&& module &&typeofmodule.exports ==="object") {

module.exports = jQuery;

}else{

if(typeofdefine ==="function"&& define.amd ) {

define("jquery", [],function() {returnjQuery; } );

}

}

if(typeofwindow ==="object"&&typeofwindow.document ==="object") {

window.jQuery = window.$ = jQuery;

}

與前面我寫的那段代碼有些不同,在對 AMD 和 CommonJS 探測之后,它將 jQuery 注冊成了 window 對象的成員。

然而,jQuery 是一個瀏覽器端的 JS 庫,它那樣寫當然沒問題。但如果我們所寫的是一個通用的庫,就不應使用 window 對象了,而應該使用全局對象,而這一般可以使用 this 來得到。

我們看看 Mustache 是怎么做的:

(function(root, factory) {

if(typeofexports ==="object"&& exports) {

factory(exports);// CommonJS

}else{

varmustache = {};

factory(mustache);

if(typeofdefine ==="function"&& define.amd) {

define(mustache);// AMD

}else{

root.Mustache = mustache;//

}

}

}(this,function(mustache) {

// 模塊主要的代碼放在這兒

});

這段代碼與前面介紹的方式不太一樣,它使用了兩個匿名函數。后面那個函數可以看作是模塊代碼的工廠函數,它是模塊的主體部分。前面那個函數對運行環境進行檢測,根據檢測的結果對模塊的工廠函數進行調用。另外,作為一個通用庫,它并沒使用 window 對象,而是使用了 this,因為在簡單的函數調用中,this 其實就是全局對象。

再看看 doT 的做法。doT 的做法與 Mustache 不同,而是更接近于我在前面介紹 AMD 環境探測的那段代碼:

(function() {

"use strict";

vardoT = {

version:'1.0.0',

templateSettings: {/*...*/},

template: undefined,//fn, compile template

compile:? undefined//fn, for express

};

if(typeofmodule !=='undefined'&& module.exports) {

module.exports = doT;

}elseif(typeofdefine ==='function'&& define.amd) {

define(function(){returndoT;});

}else{

(function(){returnthis|| (0,eval)('this'); }()).doT = doT;

}

// ...

}());

這段代碼里的 (0, eval)('this') 是一個小技巧,這個表達式用來得到 Global 對象,'this' 其實是傳遞給 eval 的參數,但由于 eval 是經由 (0, eval) 這個表達式間接得到的,因此 eval 將會在全局對象作用域中查找 this,結果得到的是全局對象。若是代碼運行于瀏覽器中,那么得到的其實是 window 對象。這里有一個針對它的討論:http://stackoverflow.com/questions/14119988/return-this-0-evalthis/14120023#14120023

其實也有其它辦法來獲取全局對象的,比如,使用函數的 call 或 apply,但不給參數,或是傳入 null:

varglobal_object = (function(){returnthis; }).call();

你可以參考這篇文章:Javascript的this用法

Juicer 則沒有檢測 AMD,它使用了如下的語句來檢測 CommonJS Modules:

typeof(module) !=='undefined'&& module.exports ? module.exports = juicer :this.juicer = juicer;

另外,你還可以參考一下這個:https://gist.github.com/kitcambridge/1251221

(function(root, Library) {

// The square bracket notation is used to avoid property munging by the Closure Compiler.

if(typeofdefine =="function"&&typeofdefine["amd"] =="object"&& define["amd"]) {

// Export for asynchronous module loaders (e.g., RequireJS, `curl.js`).

define(["exports"], Library);

}else{

// Export for CommonJS environments, web browsers, and JavaScript engines.

Library = Library(typeofexports =="object"&& exports || (root["Library"] = {

"noConflict": (function(original) {

functionnoConflict() {

root["Library"] = original;

// `noConflict` can't be invoked more than once.

deleteLibrary.noConflict;

returnLibrary;

}

returnnoConflict;

})(root["Library"])

}));

}

})(this,function(exports) {

// ...

returnexports;

});

我覺得這個寫得有些復雜了,我也未必需要我的庫帶有 noConflict 方法。不過,它也可以是個不錯的參考。

## JavaScript 模塊化的未來

未來的模塊化方案會是什么樣的?我不知道,但不管將來如何演化,作為一種模式,模塊模式是不會過時和消失的。

如前所述,尚在制定中的 ES 6 會對模塊作出語言級別的定義。我們來看一個實例,以下的代碼段摘自“ES6:JavaScript中將會有的幾個新東西”:

module Car {

// 內部變量

varlicensePlateNo ='556-343';

// 暴露到外部的變量和函數

exportfunctiondrive(speed, direction) {

console.log('details:', speed, direction);

}

exportmodule engine{

exportfunctioncheck() { }

}

exportvarmiles = 5000;

exportvarcolor ='silver';

};

我不知道 ES 6 將來會否對此作出改變,對上面的這種代碼形式,不同的人會有不同的看法。就我個人而言,我十分不喜歡這種形式!

確實,我們可能需要有一種統一的模塊化定義方式。發明 AMD 和 RequireJS 的人也說過 AMD 和 RequireJS 應該被淘汰了,運行環境應該提供模塊的原生支持。然而,ES 6 中的模塊定義是否是正確的?它是否是一個好的解決方案呢?我不知道,但我個人真的很不喜歡那種方式。很多人十分喜歡把其它語言的一些東西生搬硬套到 JavaScript 中,或是孜孜不倦地要把 JavaScript 變成另外一種語言,我相當討厭這種行為。我并非一個保守的人,我樂意接受新概念、新語法,只要它是好的。但是,ES 6 草案中的模塊規范是我不喜歡的,起碼,我認為它脫離了現實,否定了開源社區的實踐和經驗,是一種意淫出來的東西,這使得它在目前不能解決任何實際問題,反而是來添亂的。

按目前的 ES6 草案所給出的模塊化規范,它并沒有采用既有的 CommonJS Modules 和 AMD 規范,而是定義了一種新的規范,而且這種規范修改了 JavaScript 既有的語法形式,使得它沒有辦法像 ES5 中的 Object.create、Array.forEach 那樣可以利用現有版本的 JavaScript 編寫一些代碼來實現它。這也使得 ES 6 的模塊化語法將在一段時期內處于不可用的狀態。

引入新的語法也不算是問題,然而,為了模塊而大費周折引出那么多新的語法和定義,真的是一種好的選擇么?話說,它解決了什么實質性的問題而非如此不可?現今流行的 AMD 其實簡單到只定義了一個 "define" 函數,它有什么重大問題?就算那些專家因種種原因或目的而無法接受 AMD 或其它開源社區的方案,稍作出一些修改和中和總是可以的吧,非要把 JavaScript 改頭換面不可么?確實有人寫了一些觀點來解釋為何不用 AMD,然而,那些解釋和觀點其實大都站不住腳。比如說,其中一個解釋是 AMD 規范不兼容于 ES 6!可笑不可笑?ES 6 尚未正式推出,完全實現了 ES 6 的 JavaScript 運行時也沒幾個,而 AMD 在開源社區中早已十分流行,這個時候說 AMD 不兼容 ES 6,我不知道這是什么意思。

就我看來,現今各種形形色色的所謂標準化工作組,很多時候像是高高在上的神仙,他們拉不下臉全身心地參與到開源社區之中,他們就是要作出與開源社區不同的規范,以此來彰顯他們的工作、專業與權威。而且,很多時候他們過于官僚,又或者夾雜在各大商業集團之間舉棋不定。我不否認他們工作的重要性,然而,以專家自居而脫離或否定開源社區的實踐,以及商業與政治的利益均衡等,使得他們的工作與開源社區相比,在技術的推動與發展上成效不足甚至添亂。

回到 ES 6 中的模塊,想想看,我需要修改我的代碼,在其中加上諸如 module, export, import 之類的新的語法,修改之后的代碼卻沒辦法在現今版本的 JavaScript 中運行,而且,與現今流行的模塊化方案相比,這些工作也沒什么實質性的幫助,想想這些,我只感覺像是吃了一個蒼蠅。

ES 6 的發展當然不會因為我的吐嘈而有任何變化,我也不愿再展開討論。未來的模塊化方案具體是什么樣的無法知曉,但起碼我可以得到以下的結論:

模塊模式不會過時

ES 6 不會接納 AMD 等現有方案,但不管如何,JavaScript 將會有語言級別的模塊定義

ES 6 中的模塊在一段時期內是不可用的

即使 ES 6 已達到實用階段,現今的模塊化方案仍會存在和發展

## 參考資料

JavaScript Module Pattern: In-Depth/深入理解JavaScript 模塊模式[譯]

Learning JavaScript Design Patterns

JavaScript Modules/JavaScript模塊化開發一瞥[譯]

JavaScript Closures and the Module Pattern/JavaScript閉包和模塊模式[譯]

閉包漫談(從抽象代數及函數式編程角度)

(完)

版權聲明:自由轉載-非商用-非衍生-保持署名 |Creative Commons BY-NC-ND 3.0

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容