JavaScript 模組化程式設計 - Module Pattern

鴨脖發表於2016-06-27

## 前言

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 語句來達成,而這也構成了一個完整的模組模式。來看一個完整的例子:

var MODULE = (function () {
	var my = {},
		privateVariable = 1;

	function privateMethod() {
		// ...
	}

	my.moduleProperty = 1;
	my.moduleMethod = function () {
		// ...
	};

	return my;
}());

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

#### 輸出簡單物件

很多時候我們 return 一個物件作為模組的輸出,比如上例就是。

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

很多時候我們都能見到這樣的模組化程式碼:

var Module1 = (function () {
  var private_variable = 1;
  function private_method() { /*...*/ }

  var my = {
    property1: 1,
    property2: private_variable,
    method1: private_method,
    method2: function () {
        // ...
    }
  };
  return my;
}());

另外,對於簡單的模組化程式碼,若不涉及私有成員等,其實也可以直接使用物件直接量來表達一個模組:

var Widget1 = {
  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 來表達。

var Cat = (function () {
  // 私有成員及程式碼 ...

  return function(name) {
    this.name = name;
    this.bark = function() { /*...*/ }
  };
}());

var tomcat = new Cat("Tom");
tomcat.bark();

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

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

### Revealing Module Pattern

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

var MODULE = (function () {
  // 私有變數及函式
  var x = 1;
  function f1() {}
  function f2() {}

  return {
    public_method1: f1,
    public_method2: f2
  };
}());

## 模組模式的變化

### 擴充套件

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

var MODULE = (function (my) {
  my.anotherMethod = function () {
    // added method...
  };

  return my;
}(MODULE));

上面的程式碼為物件 MODULE 增加了一個 "anotherMethod" 方法。

### 鬆耦合擴充套件(Loose Augmentation)

上面的程式碼要求 MODULE 物件是已經定義過的。如果這個模組的各個組成部分並沒有載入順序要求的話,其實可以允許輸入的引數為空物件,那麼我們將上例中的引數由 MODULE 改為 MODULE || {} 就可以了:

var MODULE = (function (my) {
  // add capabilities...
  return my;
}(MODULE || {}));


### 緊耦合擴充套件(Tight Augmentation)

與上例不同,有時我們要求在擴充套件時呼叫以前已被定義的方法,這也有可能被用於覆蓋已有的方法。這時,對模組的定義順序是有要求的。

var MODULE = (function (my) {
  var old_moduleMethod = my.moduleMethod;

  my.moduleMethod = function () {
    // 方法過載
    // 可通過 old_moduleMethod 呼叫以前的方法...
  };

  return my;
}(MODULE));


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


var MODULE_TWO = (function (old) {
	var my = {},
		key;

	for (key in old) {
		if (old.hasOwnProperty(key)) {
			my[key] = old[key];
		}
	}

	var super_moduleMethod = old.moduleMethod;
	my.moduleMethod = function () {
		// override method on the clone, access to super through super_moduleMethod
	};

	return my;
}(MODULE));

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

var MODULE_TWO = (function (old) {
  var my = Object.create(old);

  var super_moduleMethod = old.moduleMethod;
  my.moduleMethod = function () {
    // override method ...
  };

  return my;
}(MODULE));


### 子模組(Sub-modules)

模組物件當然可以再包含子模組,形如 MODULE.Sub=(function(){}()) 之類,這裡不再展開敘述了。

### 各種形式的混合

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

var UTIL = (function (parent, $) {
	var my = parent.ajax = parent.ajax || {};

	my.get = function (url, params, callback) {
		// ok, so I'm cheating a bit :)
		return $.getJSON(url, params, callback);
	};

	// etc...

	return parent;
}(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 是否有定義。

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

var MODULE = (function () {
  var my = {};
  // 程式碼 ...

  if (typeof define == 'function') {
    define( function(){ return my; } );
  }else if (typeof module != 'undefined' && module.exports) {
    module.exports = my;
  }
  return my;
}());

上面的程式碼在返回 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 ( typeof module === "object" && module && typeof module.exports === "object" ) {
	module.exports = jQuery;
} else {
	if ( typeof define === "function" && define.amd ) {
		define( "jquery", [], function () { return jQuery; } );
	}
}

if ( typeof window === "object" && typeof window.document === "object" ) {
	window.jQuery = window.$ = jQuery;
}

與前面我寫的那段程式碼有些不同,在對 AMD 和 CommonJS 探測之後,它將 jQuery 註冊成了 window 物件的成員。

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

我們看看 Mustache 是怎麼做的:

(function (root, factory) {
  if (typeof exports === "object" && exports) {
    factory(exports); // CommonJS
  } else {
    var mustache = {};
    factory(mustache);
    if (typeof define === "function" && define.amd) {
      define(mustache); // AMD
    } else {
      root.Mustache = mustache; // <script>
    }
  }
}(this, function (mustache) {
  // 模組主要的程式碼放在這兒
});

這段程式碼與前面介紹的方式不太一樣,它使用了兩個匿名函式。後面那個函式可以看作是模組程式碼的工廠函式,它是模組的主體部分。前面那個函式對執行環境進行檢測,根據檢測的結果對模組的工廠函式進行呼叫。另外,作為一個通用庫,它並沒使用 window 物件,而是使用了 this,因為在簡單的函式呼叫中,this 其實就是全域性物件。

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

(function() {
	"use strict";

	var doT = {
		version: '1.0.0',
		templateSettings: { /*...*/ },
		template: undefined, //fn, compile template
		compile:  undefined  //fn, for express
	};

	if (typeof module !== 'undefined' && module.exports) {
		module.exports = doT;
	} else if (typeof define === 'function' && define.amd) {
		define(function(){return doT;});
	} else {
		(function(){ return this || (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:

var global_object = (function(){ return this; }).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 (typeof define == "function" && typeof define["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(typeof exports == "object" && exports || (root["Library"] = {
      "noConflict": (function (original) {
        function noConflict() {
          root["Library"] = original;
          // `noConflict` can't be invoked more than once.
          delete Library.noConflict;
          return Library;
        }
        return noConflict;
      })(root["Library"])
    }));
  }
})(this, function (exports) {
  // ...
  return exports;
});

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

## JavaScript 模組化的未來

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

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

module Car {  
  // 內部變數
  var licensePlateNo = '556-343';  
  // 暴露到外部的變數和函式
  export function drive(speed, direction) {  
    console.log('details:', speed, direction);  
  }  
  export module engine{  
    export function check() { }  
  }  
  export var miles = 5000;  
  export var color = '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 已達到實用階段,現今的模組化方案仍會存在和發展

## 參考資料

(完)

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


相關文章