作為一名JS初學者。假如你聽到了一些諸如“模塊化構建&模塊化載入” “Webpack&Browserify” 或者 “AMD&CMD”之類的術語,肯定瞬間就凌亂了。
JavaScript的模塊化聽起來挺深奧,可其實理解它對開發者來說特別實用。
在這篇文章里,我會盡量深入淺出地把這些深奧的術語翻譯成淺顯易懂的人話(加上一些代碼示例)。希望你多少能從中學到點東西。
為了避免長篇大論,整個內容會分為兩篇文章,這是第一部分,主要介紹模塊化是什么,為什么要使用模塊。之后的第二部分會介紹如何打包JS模塊,以及各類構建工具。
求解釋到底什么是模塊化
稱職的作家會把他的書分章節和段落;好的程序員會把他的代碼分成模塊。
就好像書籍的一章,模塊僅僅是一坨代碼而已。
好的代碼模塊分割的內容一定是很合理的,便于你增加減少或者修改功能,同時又不會影響整個系統。
為什么要使用模塊
模塊化可以使你的代碼低耦合,功能模塊直接不相互影響。我個人認為模塊化主要有以下幾點好處:
1.可維護性:根據定義,每個模塊都是獨立的。良好設計的模塊會盡量與外部的代碼撇清關系,以便于獨立對其進行改進和維護。維護一個獨立的模塊比起一團凌亂的代碼來說要輕松很多。
2.命名空間:在JavaScript中,最高級別的函數外定義的變量都是全局變量(這意味著所有人都可以訪問到它們)。也正因如此,當一些無關的代碼碰巧使用到同名變量的時候,我們就會遇到“命名空間污染”的問題。
這樣的問題在我們開發過程中是要極力避免的。
后面的內容里我也會舉一些具體的例子來說明這一點。
3.可復用性:現實來講,在日常工作中我們經常會復制自己之前寫過的代碼到新項目中。
復制粘貼雖然很快很方便,但難道我們找不到更好的辦法了么?要是……有一個可以重復利用的模塊豈不妙哉?
如何引入模塊
引入模塊有很多種方式,這里我們先介紹一些:
模塊模式
模塊模式一般用來模擬類的概念(因為原生JavaScript并不支持類,雖然最新的ES6里引入了Class不過還不普及)這樣我們就能把公有和私有方法還有變量存儲在一個對象中——這就和我們在Java或Python里使用類的感覺一樣。這樣我們就能在公開調用API的同時,仍然在一個閉包范圍內封裝私有變量和方法。
實現模塊模式的方法有很多種,下面的例子是通過匿名閉包函數的方法。(在JavaScript中,函數是創建作用域的唯一方式。)
例1:匿名閉包函數
(function () { // 在函數的作用域中下面的變量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; var average = function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item}, 0); return 'Your average grade is ' + total / myGrades.length + '.'; } var failing = function(){ var failingGrades = myGrades.filter(function(item) { return item < 70;}); return 'You failed ' + failingGrades.length + ' times.'; } console.log(failing()); }()); // 控制臺顯示:'You failed 2 times.'通過這種構造,我們的匿名函數有了自己的作用域或“閉包”。 這允許我們從父(全局)命名空間隱藏變量。
這種方法的好處在于,你可以在函數內部使用局部變量,而不會意外覆蓋同名全局變量,但仍然能夠訪問到全局變量,如下所示:
var global = 'Hello, I am a global variable :)'; (function () { // 在函數的作用域中下面的變量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; var average = function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item}, 0); return 'Your average grade is ' + total / myGrades.length + '.'; } var failing = function(){ var failingGrades = myGrades.filter(function(item) { return item < 70;}); return 'You failed ' + failingGrades.length + ' times.'; } console.log(failing()); console.log(global); }()); // 控制臺顯示:'You failed 2 times.' // 控制臺顯示:'Hello, I am a global variable :)'
要注意的是,一定要用括號把匿名函數包起來,以關鍵詞function開頭的語句總是會被解釋成函數聲明(JS中不允許沒有命名的函數聲明),而加上括號后,內部的代碼就會被識別為函數表達式。其實這個也叫作立即執行函數(IIFE)感興趣的同學可以在這里了解更多
例2:全局引入
另一種比較受歡迎的方法是一些諸如jQuery的庫使用的全局引入。和我們剛才舉例的匿名閉包函數很相似,只是傳入全局變量的方法不同:
(function (globalVariable) { // 在函數的作用域中下面的變量是私有的 var privateFunction = function() { console.log('Shhhh, this is private!'); } // 通過全局變量設置下列方法的外部訪問接口 // 與此同時這些方法又都在函數內部 globalVariable.each = function(collection, iterator) { if (Array.isArray(collection)) { for (var i = 0; i < collection.length; i++) { iterator(collection[i], i, collection); } } else { for (var key in collection) { iterator(collection[key], key, collection); } } }; globalVariable.filter = function(collection, test) { var filtered = []; globalVariable.each(collection, function(item) { if (test(item)) { filtered.push(item); } }); return filtered; }; globalVariable.map = function(collection, iterator) { var mapped = []; globalUtils.each(collection, function(value, key, collection) { mapped.push(iterator(value)); }); return mapped; }; globalVariable.reduce = function(collection, iterator, accumulator) { var startingValueMissing = accumulator === undefined; globalVariable.each(collection, function(item) { if(startingValueMissing) { accumulator = item; startingValueMissing = false; } else { accumulator = iterator(accumulator, item); } }); return accumulator; }; }(globalVariable));
在這個例子中,globalVariable 是唯一的全局變量。這種方法的好處是可以預先聲明好全局變量,讓你的代碼更加清晰可讀。
例3:對象接口
像下面這樣,還有一種創建模塊的方法是使用獨立的對象接口:
var myGradesCalculate = (function () { // 在函數的作用域中下面的變量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; // 通過接口在外部訪問下列方法 // 與此同時這些方法又都在函數內部 return { average: function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item; }, 0); return'Your average grade is ' + total / myGrades.length + '.'; }, failing: function() { var failingGrades = myGrades.filter(function(item) { return item < 70; }); return 'You failed ' + failingGrades.length + ' times.'; } } })(); myGradesCalculate.failing(); // 'You failed 2 times.' myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'
例4:揭示模塊模式 Revealing module pattern
這和我們之前的實現方法非常相近,除了它會確保,在所有的變量和方法暴露之前都會保持私有:
var myGradesCalculate = (function () { // 在函數的作用域中下面的變量是私有的 var myGrades = [93, 95, 88, 0, 55, 91]; var average = function() { var total = myGrades.reduce(function(accumulator, item) { return accumulator + item; }, 0); return'Your average grade is ' + total / myGrades.length + '.'; }; var failing = function() { var failingGrades = myGrades.filter(function(item) { return item < 70; }); return 'You failed ' + failingGrades.length + ' times.'; }; // 將公有指針指向私有方法 return { average: average, failing: failing } })(); myGradesCalculate.failing(); // 'You failed 2 times.' myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.'
到這里,其實我們只聊了模塊模式的冰山一角。
CommonJS & AMD
上述的所有解決方案都有一個共同點:使用單個全局變量來把所有的代碼包含在一個函數內,由此來創建私有的命名空間和閉包作用域。
雖然每種方法都比較有效,但也都有各自的短板。
有一點,作為開發者,你必須清楚地了解引入依賴文件的正確順序。就拿Backbone.js來舉個例子,想要使用Backbone就必須在你的頁面里引入Backbone的源文件。
然而Backbone又依賴 Underscore.js,所以Backbone的引入必須在其之后。
而在工作中,這些依賴管理經常會成為讓人頭疼的問題。
另外一點,這些方法也有可能引起命名空間沖突。舉個例子,要是你碰巧寫了倆重名的模塊怎么辦?或者你同時需要一個模塊的兩個版本時該怎么辦?
難道就沒有不通過全局作用域來實現的模塊方法么?
當然是有的。
接下來介紹兩種廣受歡迎的解決方案:CommonJS 和 AMD.
CommonJS
CommonJS 擴展了JavaScript聲明模塊的API.
CommonJS模塊可以很方便得將某個對象導出,讓他們能夠被其他模塊通過 require 語句來引入。要是你寫過 Node.js 應該很熟悉這些語法。
通過CommonJS,每個JS文件獨立地存儲它模塊的內容(就像一個被括起來的閉包一樣)。在這種作用域中,我們通過 module.exports 語句來導出對象為模塊,再通過 require 語句來引入。
還是舉個直觀的例子吧:
function myModule() { this.hello = function() { return 'hello!'; } this.goodbye = function() { return 'goodbye!'; } } module.exports = myModule;
通過指定導出的對象名稱,CommonJS模塊系統可以識別在其他文件引入這個模塊時應該如何解釋。
然后在某個人想要調用 myMoudle 的時候,只需要 require 一下:
var myModule = require('myModule'); var myModuleInstance = new myModule(); myModuleInstance.hello(); // 'hello!' myModuleInstance.goodbye(); // 'goodbye!'
這種實現比起模塊模式有兩點好處:
避免全局命名空間污染
明確代碼之間的依賴關系
并且這種書寫方式也非常舒服友好,我自己很喜歡。
需要注意的一點是,CommonJS以服務器優先的方式來同步載入模塊,假使我們引入三個模塊的話,他們會一個個地被載入。
它在服務器端用起來很爽,可是在瀏覽器里就不會那么高效了。畢竟讀取網絡的文件要比本地耗費更多時間。只要它還在讀取模塊,瀏覽器載入的頁面就會一直卡著不動。(在下一篇第二部分的教程里我們會討論如何解決這個問題)
AMD
CommonJS已經挺不錯了,但假使我們想要實現異步加載模塊該怎么辦?答案就是Asynchronous Module Definition(異步模塊定義規范),簡稱AMD.
通過AMD載入模塊的代碼一般這么寫:
define(['myModule', 'myOtherModule'], function(myModule, myOtherModule) { console.log(myModule.hello());});
這里我們使用 define 方法,第一個參數是依賴的模塊,這些模塊都會在后臺無阻塞地加載,第二個參數則作為加載完畢的回調函數。
拿 myModule 來舉個例子:
define([], function() { return { hello: function() { console.log('hello'); }, goodbye: function() { console.log('goodbye'); } }; });重申一下,不像CommonJS,AMD是優先瀏覽器的一種異步載入模塊的解決方案。(記得,很多人認為一個個地載入小文件是很低效的,我們將在下一篇文章理介紹如何打包模塊)
除了異步加載以外,AMD的另一個優點是你可以在模塊里使用對象、函數、構造函數、字符串、JSON或者別的數據類型,而CommonJS只支持對象。
再補充一點,AMD不支持Node里的一些諸如 IO,文件系統等其他服務器端的功能。另外語法上寫起來也比CommonJS麻煩一些。
UMD
在一些同時需要AMD和CommonJS功能的項目中,你需要使用另一種規范:Universal Module Definition(通用模塊定義規范)。
UMD創造了一種同時使用兩種規范的方法,并且也支持全局變量定義。所以UMD的模塊可以同時在客戶端和服務端使用。
下面是一個解釋其功能的例子:
(function (root, factory) { if (typeof define === 'function' && define.amd) { // AMD define(['myModule', 'myOtherModule'], factory); } else if (typeof exports === 'object') { // CommonJS module.exports = factory(require('myModule'), require('myOtherModule')); } else { // Browser globals (Note: root is window) root.returnExports = factory(root.myModule, root.myOtherModule); } }(this, function (myModule, myOtherModule) { // Methods function notHelloOrGoodbye(){}; // A private method function hello(){}; // A public method because it's returned (see below) function goodbye(){}; // A public method because it's returned (see below) // Exposed public methods return { hello: hello, goodbye: goodbye } }));
更多有關UMD的例子請看其Github上的官方repo.
原生JS
希望你堅持讀到了現在,我們最后再介紹一種定義模塊的方式。
你可能注意到了,上述的這幾種方法都不是JS原生支持的。要么是通過模塊模式來模擬,要么是使用CommonJS或AMD.
幸運的是在JS的最新規范ECMAScript 6 (ES6)中,引入了模塊功能。
ES6 的模塊功能汲取了CommonJS 和 AMD 的優點,擁有簡潔的語法并支持異步加載,并且還有其他諸多更好的支持。
我最喜歡的ES6 模塊功能的特性是,導入是實時只讀的。(CommonJS 只是相當于把導出的代碼復制過來)。
來看例子:
// lib/counter.js var counter = 1; function increment() { counter++; } function decrement() { counter--; } module.exports = { counter: counter, increment: increment, decrement: decrement }; // src/main.js var counter = require('../../lib/counter'); counter.increment(); console.log(counter.counter); // 1
上面這個例子中,我們一共創建了兩份模塊的實例,一個在導出的時候,一個在引入的時候。
在 main.js 當中的實例是和原本模塊完全不相干的。這也就解釋了為什么調用了 counter.increment() 之后仍然返回1。因為我們引入的 counter 變量和模塊里的是兩個不同的實例。
所以調用 counter.increment() 方法只會改變模塊中的 counter .想要修改引入的 counter 只有手動一下啦:
counter.counter++;console.log(counter.counter); // 2
而通過 import 語句,可以引入實時只讀的模塊:
// lib/counter.js export let counter = 1; export function increment() { counter++; } export function decrement() { counter--; } // src/main.js import * as counter from '../../counter'; console.log(counter.counter); // 1 counter.increment(); console.log(counter.counter); // 2
這看起來很酷不是么?這樣就實現了我們把模塊分隔在不同的文件里,需要的時候又可以合并在一起而且不影響它的功能。