JavaScript 模組(1):初學者指南

劉健超-J.c發表於2016-03-09

如果你剛接觸 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


通過這種結構,匿名函式擁有自身的求值環境或”閉包“,並立即執行它。這就實現了對上級(全域性)名稱空間的隱藏。

這種方法的好處是:能在函式內使用本地變數,而不會意外地重寫已存在的全域性變數。當然,你也能獲取全域性變數,如:


這裡需要注意的是,匿名函式必須被小括號包裹住,這是因為當語句以關鍵字 function 開頭時,它會被認為是一個函式的宣告語句(記住,JavaScript 中不能擁有未命名的函式宣告語句)。因此,該括號會建立一個函式表示式代替它。欲知詳情,可點選 這裡

Example 2:全域性匯入(Global import 

另一個常見的方式是類似於 jQuery 的全域性匯入(global import)。該方式與上述的匿名閉包相似,特別之處是傳入了一個全域性變數作為引數:


在該案例中,globalVariable 是唯一的全域性變數。這個相對於匿名閉包的優勢是:提前宣告瞭全域性變數,能讓別人更清晰地閱讀你的程式碼。

Example 3:物件介面(Object interface

使用一個獨立的物件介面建立模組,如:


正如你所看到的,該方式讓你決定哪個變數/方法是私有的(如 myGrades),哪個變數/方法是需要暴露出來的(通過將需要暴露出來的變數/方法放在 return 語句中,如 averagefailing)。

Example 4: 暴露模組模式(Revealing module pattern)

這與上一個方法非常類似,只不過該方法確保所有變數和方法都是私有的,除非顯式暴露它們:


看似有許多知識需要我們吸收,但這只是模組模式(module patterns)的冰山一角。在我學習這方面知識時,發現了下面這些有用的資源:

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 模組時,程式碼類似:


我們使用特定物件模組,並將 module.exports 指向我們的函式。這讓 CommonJS 模組系統知道我們想匯出什麼,並讓其它檔案能訪問到它。

然後,當有人想使用 myModule 時,他們可在檔案內將其 require 進來,如:


該方法相對於我們先前討論的模組模式有兩個顯而易見的好處:

  1. 避免了全域性名稱空間的汙染
  2. 讓依賴關係更明確

此外,該語法非常緊湊簡單,我個人非常喜歡。

另外需要注意的一點是:CommonJS 採用伺服器優先的方式,並採用同步的方式載入模組。這點很重要,因為如果我們有其它三個模組需要 require 進來的話,這些模組會被一個接一個地載入。

這種工作方式很適合應用在伺服器上。但不幸的是,當你將這種方式應用在瀏覽器端時,就會出現問題。因為相對於硬碟,從 web 上讀取模組更耗時(網路傳輸等因素)。而且,只要模組正在載入,就會阻塞瀏覽器執行其它任務。這是由於 JavaScript 執行緒會在程式碼載入完成前被停止。(在 Part 2 的模組打包部分,我會告訴你如何解決此問題。而現在,只需瞭解到這)。

AMD

CommonJS 非常不錯,但如果我們想非同步載入模組呢?答案是非同步模組定義(Asynchronous Module Definition),或簡稱 AMD。

使用 AMD 載入模組的程式碼類似:


define 函式的第一個引數是一個包含本模組所依賴的模組陣列。這些依賴都在後臺載入(以不阻塞的方式)。載入完成後,define 會呼叫其指定的回撥函式。

接著,回撥函式會將載入完成後的依賴作為其引數(一一對應)——在該案例中,是 myModule 和 myOtherModule。因此,回撥函式就能使用這些依賴。當然,這些依賴本身也需要通過 define 關鍵字定義。

例如,myModule 類似:


與 CommonJS相反,AMD 採取瀏覽器優先的方式,通過非同步載入的方式完成任務。(注意,有很多人並不贊成此方式,因為他們堅信在程式碼開始執行時動態且逐個地載入檔案是不好的。我將會在下一節的模組構建(module-building)中探討更多相關資訊)。

除了非同步外,AMD 的另一個好處是:模組可以是一個物件、函式、建構函式、字串、JSON 或其它各種型別,而 CommonJS 僅支援物件作為模組。

話雖如此,AMD 不相容 io、檔案系統(filesystem)和其它通過 CommonJS 實現的面向伺服器的功能,而且其通過函式封裝的語法與簡單的 require 語句相比顯得有點囉嗦。

UMD

對於需要同時支援 AMD 和 CommonJS 特性的專案,你可選擇另一種規範:通用的模組定義(Universal Module Defintion,簡稱 UMD)。

UMD 在本質上建立了一種AMD和CommonJS 都能使用的方法,同時也支援定義全域性變數。因此,UMD 模組適用於客戶端和伺服器端。

下面快速瀏覽 UMD 是如何處理其業務的:


想獲取更多關於 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)如何執行:


在此案例中,我們主要構造了該模組的兩個副本:一個是在我們匯出它時,另一個是在我們引入它時。

此外,在 main.js 的副本與原來的模組是分離的。這就是為什麼當我們的計數器自增時,仍返回 1 —— 因為我們匯入的計數器變數(counter)與來自原本模組的計數器副本是分離的。

所以,計算器的自增只會在模組內自增,並不會在複製的版本自增。要修改複製版本的計數器的唯一方式是手動自增。


對於ES6,它會在匯入時建立一個動態的、只讀的模組檢視。


很酷對吧?但我認為動態且只讀的檢視的真正引人注目的是,它允許你將模組分成更小的片段,而又不導致功能的缺失。

你可以反過來再次合併他們,且不會導致任何問題。

期待:模組打包(bundling modules)

哇!時間過得真快。這是個瘋狂之旅,但我真心希望本文能讓你更好地瞭解 JavaScript 模組。

在下一節,我將會講述模組打包(module bundling)和覆蓋以下核心主題:

  • 為什麼需要模組打包
  • 以不同方式進行打包
  • ECMAScript 的模組載入 API
  • 等等

注意:為了儘可能通俗易懂,我跳過了一些細節(如:迴圈依賴)。如果我漏了任何重要或有趣的知識,請在評論裡告訴我!

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

JavaScript 模組(1):初學者指南 JavaScript 模組(1):初學者指南

相關文章