如果你剛接觸 JavaScript,想必已經被“module bundlers vs. module loaders”、“Webpack vs. Browserify”和“AMD vs. CommonJS” 等諸如此類的行業術語所嚇到。
JavaScript 模組系統聽起來挺嚇人的,但明白它是每個 Web 開發者所必備的要求。
在這篇文章中,我將拋開這些行業術語,用通俗易懂的語言(和一些程式碼案例)向你解釋清楚。希望你能從中收益!
注意:為了讓文章更易理解,我分為兩部分進行講述:第一部分會深入解釋「模組是什麼」和「為什麼要使用它們」。第二部分(下週釋出)講述「模組打包意味著什麼」和「用不同方式實現模組打包」。
Part 1:你能再次解釋模組是什麼嗎?
優秀的作者會將他的書分為章和節。同理,優秀的程式設計師能將他的程式劃分為各個模組。
就像書的章節,模組就是詞(或程式碼,視情況而定)的叢集。
好的模組擁有以下特點:不同功能是高度獨立的,並且它們允許被打亂、移除或在必要時進行補充,而不會擾亂系統作為一個整體。
為什麼使用模組?
使用模組有諸多好處,如利於建立一個擴充套件性強的、互相依賴的程式碼庫。而在我看來,其最重要是:
1)可維護性:根據定義,模組是獨立的。一個設計良好的模組意在儘可能減少對程式碼庫的依賴,所以它才能單獨地擴充套件與完善。更新一個從其它程式碼段解耦出來的獨立模組顯然來得更簡單。
回到書的案例,如果書的某個章節需要進行小改動,而該改動會牽涉到其它所有章節,這無疑是個夢魘。相反,如果每章節都以某種良好方式進行編寫,那麼改動某章節,則不會影響其它章節。
2)名稱空間:在 JavaScript 中,如果變數宣告在頂級函式的作用域外,那麼這些變數都是全域性的(意味著任何地方都能讀寫它)。因此,造成了常見的“名稱空間汙染”,從而導致完全無關的程式碼卻共享著全域性變數。
無關的程式碼間共享著全域性變數是一個嚴重的 程式設計禁忌。
我們將在本文後面看到,模組通過為變數建立一個私有空間,從而避免了名稱空間的汙染。
3)可重用性:坦誠地講:我們都試過複製舊專案的程式碼到新專案上。例如,我們複製以前專案的某些功能方法到當前專案中。
該做法看似可行,但如果發現那段程式碼有更好的實現方式(即需要改動),那麼你就不得不去追溯並更新任何你所貼上到的任何地方。
這無疑會浪費大量的時間。因此可複用的模組顯然讓你編碼輕鬆。
如何整合為模組?
整合為模組的方式有很多。下面就看看其中的一些方法:
模組模式(Module pattern)
模組模式用於模仿類(由於 JavaScript 並不支援原生的類),以致我們能在單個物件中儲存公有和私有變數與方法——類似於其它程式語言(如 Java 或 Python )中的類的用法。模組模式不僅允許我們建立公用介面 API(如果我們需要暴露方法時),而且也能在閉包作用域中封裝私有變數和方法。
下面有幾種方式能實現模組模式(module pattern)。第一個案例中,我將會使用匿名閉包。只需將所有程式碼放進匿名函式中,就能幫助我們實現目標(記住:在 JavaScript 中,函式是唯一建立新作用域的方式)。
Example 1:匿名閉包(Anonymous closure)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
(function () { // We keep these variables private inside this closure scope // 讓這些變數在閉包作用域內變為私有(外界訪問不到這些變數)。 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.’ |
通過這種結構,匿名函式擁有自身的求值環境或”閉包“,並立即執行它。這就實現了對上級(全域性)名稱空間的隱藏。
這種方法的好處是:能在函式內使用本地變數,而不會意外地重寫已存在的全域性變數。當然,你也能獲取全域性變數,如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
var global = 'Hello, I am a global variable :)'; (function () { // We keep these variables private inside this closure scope 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 開頭時,它會被認為是一個函式的宣告語句(記住,JavaScript 中不能擁有未命名的函式宣告語句)。因此,該括號會建立一個函式表示式代替它。欲知詳情,可點選 這裡。
Example 2:全域性匯入(Global import )
另一個常見的方式是類似於 jQuery 的全域性匯入(global import)。該方式與上述的匿名閉包相似,特別之處是傳入了一個全域性變數作為引數:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
(function (globalVariable) { // Keep this variables private inside this closure scope var privateFunction = function() { console.log('Shhhh, this is private!'); } // Expose the below methods via the globalVariable interface while // hiding the implementation of the method within the // function() block // 通過 globalVariable 介面暴露下面的方法。當然,這些方法的實現則隱藏在 function() 塊內 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 是唯一的全域性變數。這個相對於匿名閉包的優勢是:提前宣告瞭全域性變數,能讓別人更清晰地閱讀你的程式碼。
Example 3:物件介面(Object interface)
使用一個獨立的物件介面建立模組,如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
var myGradesCalculate = (function () { // Keep this variable private inside this closure scope var myGrades = [93, 95, 88, 0, 55, 91]; // Expose these functions via an interface while hiding // the implementation of the module within the function() block 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.' |
正如你所看到的,該方式讓你決定哪個變數/方法是私有的(如 myGrades),哪個變數/方法是需要暴露出來的(通過將需要暴露出來的變數/方法放在 return 語句中,如 average & failing)。
Example 4: 暴露模組模式(Revealing module pattern)
這與上一個方法非常類似,只不過該方法確保所有變數和方法都是私有的,除非顯式暴露它們:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
var myGradesCalculate = (function () { // Keep this variable private inside this closure scope 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.'; }; // Explicitly reveal public pointers to the private functions // that we want to reveal publicly return { average: average, failing: failing } })(); myGradesCalculate.failing(); // 'You failed 2 times.' myGradesCalculate.average(); // 'Your average grade is 70.33333333333333.' |
看似有許多知識需要我們吸收,但這只是模組模式(module patterns)的冰山一角。在我學習這方面知識時,發現了下面這些有用的資源:
- Learning JavaScript Design Patterns: 出自 Addy Osmani,他以極其簡潔的方式對模組模式進行詳細分析。
- Adequately Good by Ben Cherry:一篇通過案例對模組模式的高階用法進行概述的文章。
- Blog of Carl Danley:一篇對模組模式進行概述並擁有其它 JavaScript 模式資源的文章。
CommonJS and AMD
上述所有方法都有一個共同點:使用一個全域性變數將其程式碼封裝在一個函式中,從而利用閉包作用域為自身建立一個私有的名稱空間。
雖每種方式都有效,但他們也有消極的一面。
舉個例子說,作為一名開發者,需要以正確的依賴順序去載入你的檔案。更直接地說,假如你在專案中使用 Backbone,那麼你需要在檔案中用 script 標籤引入 Backbone 的原始碼。
然而,由於 Backbone 重度依賴於 Underscore.js,因此 Backbone 的 script 標籤不能放在 Underscore 的 script 標籤前。
作為一名開發者,有時會為了正確處理並管理好這種依賴關係而感到頭痛。
另一個消極部分是:他們仍會導致名稱空間汙染。例如,兩個模組擁有同樣的名字,或者一個模組擁有兩個版本,而且你同時需要他們倆。
所以,你可能會想到:我們能不能設計一種方法,無須通過全域性作用域去請求一個模組介面呢?
答案是能!
有兩種流行且實現良好的方法:CommonJS 和 AMD。
CommonJS
CommonJS 是一個志願工作組設計並實現的 JavaScript 宣告模組 APIs。
CommonJS 模組本質上是一片可重用的 JavaScript 程式碼段,將其以特定物件匯出後,其它模組即可引用它。如果你接觸過 Node.js,那麼你應該非常熟悉這種格式。
通過 CommonJS,每個 JavaScript 檔案儲存的模組都擁有其獨一無二的模組上下文(就像封裝在閉包內)。在此作用域中,我們使用 module.exports 物件匯出模組,然後通過 require 匯入它們。
當你定義一個 CommonJS 模組時,程式碼類似:
1 2 3 4 5 6 7 8 9 10 11 |
function myModule() { this.hello = function() { return 'hello!'; } this.goodbye = function() { return 'goodbye!'; } } module.exports = myModule; |
我們使用特定物件模組,並將 module.exports 指向我們的函式。這讓 CommonJS 模組系統知道我們想匯出什麼,並讓其它檔案能訪問到它。
然後,當有人想使用 myModule 時,他們可在檔案內將其 require 進來,如:
1 2 3 4 5 |
var myModule = require('myModule'); var myModuleInstance = new myModule(); myModuleInstance.hello(); // 'hello!' myModuleInstance.goodbye(); // 'goodbye!' |
該方法相對於我們先前討論的模組模式有兩個顯而易見的好處:
- 避免了全域性名稱空間的汙染
- 讓依賴關係更明確
此外,該語法非常緊湊簡單,我個人非常喜歡。
另外需要注意的一點是:CommonJS 採用伺服器優先的方式,並採用同步的方式載入模組。這點很重要,因為如果我們有其它三個模組需要 require 進來的話,這些模組會被一個接一個地載入。
這種工作方式很適合應用在伺服器上。但不幸的是,當你將這種方式應用在瀏覽器端時,就會出現問題。因為相對於硬碟,從 web 上讀取模組更耗時(網路傳輸等因素)。而且,只要模組正在載入,就會阻塞瀏覽器執行其它任務。這是由於 JavaScript 執行緒會在程式碼載入完成前被停止。(在 Part 2 的模組打包部分,我會告訴你如何解決此問題。而現在,只需瞭解到這)。
AMD
CommonJS 非常不錯,但如果我們想非同步載入模組呢?答案是非同步模組定義(Asynchronous Module Definition),或簡稱 AMD。
使用 AMD 載入模組的程式碼類似:
1 2 3 |
define(['myModule', 'myOtherModule'], function(myModule, myOtherModule) { console.log(myModule.hello()); }); |
define 函式的第一個引數是一個包含本模組所依賴的模組陣列。這些依賴都在後臺載入(以不阻塞的方式)。載入完成後,define 會呼叫其指定的回撥函式。
接著,回撥函式會將載入完成後的依賴作為其引數(一一對應)——在該案例中,是 myModule 和 myOtherModule。因此,回撥函式就能使用這些依賴。當然,這些依賴本身也需要通過 define 關鍵字定義。
例如,myModule 類似:
1 2 3 4 5 6 7 8 9 10 11 |
define([], function() { return { hello: function() { console.log('hello'); }, goodbye: function() { console.log('goodbye'); } }; }); |
與 CommonJS相反,AMD 採取瀏覽器優先的方式,通過非同步載入的方式完成任務。(注意,有很多人並不贊成此方式,因為他們堅信在程式碼開始執行時動態且逐個地載入檔案是不好的。我將會在下一節的模組構建(module-building)中探討更多相關資訊)。
除了非同步外,AMD 的另一個好處是:模組可以是一個物件、函式、建構函式、字串、JSON 或其它各種型別,而 CommonJS 僅支援物件作為模組。
話雖如此,AMD 不相容 io、檔案系統(filesystem)和其它通過 CommonJS 實現的面向伺服器的功能,而且其通過函式封裝的語法與簡單的 require 語句相比顯得有點囉嗦。
UMD
對於需要同時支援 AMD 和 CommonJS 特性的專案,你可選擇另一種規範:通用的模組定義(Universal Module Defintion,簡稱 UMD)。
UMD 在本質上建立了一種AMD和CommonJS 都能使用的方法,同時也支援定義全域性變數。因此,UMD 模組適用於客戶端和伺服器端。
下面快速瀏覽 UMD 是如何處理其業務的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
(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 上的 enlightening repo。
原生 JS(Native JS)
哊!我沒把你繞暈了吧?好吧,下面還有另一種定義模組的方式。
可能你已注意到:上述的模組都不是原生 JavaScript 模組。它們只不過是我們用模組模式(module pattern)、CommonJS 或 AMD 模仿的模組系統。
幸運的是,機智的標準制定者在 TC39(該標準定義了 ECMAScript 的語法與語義)已經為 ECMAScript 6(ES6)引入內建的模組系統了。
ES6 為匯入(importing)匯出(exporting)模組帶來了很多可能性。下面是很好的資源:
相對於 CommonJS 或 AMD,ES6 模組如何設法提供兩全其美的實現方案:簡潔緊湊的宣告式語法和非同步載入,另外能更好地支援迴圈依賴。
我最喜歡 ES6 模組的特性應該是被匯入的都是動態且只讀的檢視(CommonJS 匯入的都是匯出的副本,因此不是動態的)。
(上一句的原文是:Probably my favorite feature of ES6 modules is that imports are live read-only views of the exports. (Compare this to CommonJS, where imports are copies of exports and consequently not alive).)
下面這個例子展示了它(CommonJS)如何執行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 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 的副本與原來的模組是分離的。這就是為什麼當我們的計數器自增時,仍返回 1 —— 因為我們匯入的計數器變數(counter)與來自原本模組的計數器副本是分離的。
所以,計算器的自增只會在模組內自增,並不會在複製的版本自增。要修改複製版本的計數器的唯一方式是手動自增。
1 2 |
counter.counter++; console.log(counter.counter); // 2 |
對於ES6,它會在匯入時建立一個動態的、只讀的模組檢視。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// 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 |
很酷對吧?但我認為動態且只讀的檢視的真正引人注目的是,它允許你將模組分成更小的片段,而又不導致功能的缺失。
你可以反過來再次合併他們,且不會導致任何問題。
期待:模組打包(bundling modules)
哇!時間過得真快。這是個瘋狂之旅,但我真心希望本文能讓你更好地瞭解 JavaScript 模組。
在下一節,我將會講述模組打包(module bundling)和覆蓋以下核心主題:
- 為什麼需要模組打包
- 以不同方式進行打包
- ECMAScript 的模組載入 API
- 等等
注意:為了儘可能通俗易懂,我跳過了一些細節(如:迴圈依賴)。如果我漏了任何重要或有趣的知識,請在評論裡告訴我!
打賞支援我翻譯更多好文章,謝謝!
打賞譯者
打賞支援我翻譯更多好文章,謝謝!
任選一種支付方式