JavaScript 模組(2):模組打包

劉健超-J.c發表於2016-08-11

在第一部分中,我講解了模組是什麼、為何要使用模組和將程式整合為模組的各種方式。而在第二部分,我將會詳細講解模組“打包”:為什麼要打包模組,以不同的方式進行打包和模組在 web 開發上的未來。

什麼是模組打包?

總體上看,模組打包只是簡單地將一組模組(和它們所依賴的模組)以正確的順序整合為單一檔案(或檔案組)。我們也知道:對於 web 開發,細節才是可怕的地方。 :)。

究竟為什麼需要打包模組?

當你將程式分為各個模組時,通常會將這些模組放到不同檔案或資料夾下。當然,你所使用的庫(如 Underscore 或 React)也是模組。

因此,每個檔案都必須以一個 <script> 標籤引入到主 HTML 檔案中。然後當使用者訪問你的主頁時,瀏覽器就會載入這些檔案。分離的 <script> 標籤就意味著瀏覽器必須單獨地載入每個檔案(一個接一個)。

…這對頁面載入的時間無疑是個噩耗。

為了解決該問題,我們需要打包或“拼接”所有檔案,從而生成一個大檔案(或幾個檔案,視情況而定)以減少請求數量。當你聽到開發者討論“構建步驟”或“構建處理”時,這大概就是他們所討論的內容了。

另一個加快打包操作的普遍做法是:“壓縮”打包的程式碼。壓縮就是從原始碼中移除不必要的字元(如空格、註釋和換行符等),這樣能減少內容的整體大小且不會改變程式碼的功能。

更少的資料就意味著瀏覽器處理的時間更短,而且另一方面來說也減少了下載檔案的時間。如果你曾看到檔案擁有副檔名“min”(如 underscore-min.js),你應該會注意到壓縮版本會比 完整版 小很多(當然,毫無可讀性)。

構建工具(如 Gulp 和 Grunt)能為開發者直接執行拼接(concatenation)和壓縮(minification)操作,並在確保打包生成利於瀏覽器執行的程式碼的同時,也會匯出一份開發者可讀的程式碼。

打包模組的不同方式是什麼?

當使用標準的模組模式(module pattern,在文章的前一節中所討論的)定義模組時,拼接和壓縮檔案都能很好執行。你實際所做的是將各個原生 JavaScript 程式碼混合在一起。

然而,如果你使用的是非原生的模組系統,如 CommonJS 或 AMD(甚至是原生的 ES6 模組格式,因為瀏覽器仍不支援該語法),瀏覽器就不能解析識別了。此時你需要使用特定工具將模組轉為順序正確且對瀏覽器友好的程式碼。這些工具可以是 Browserify、RequireJS、Webpack 或其它“模組打包工具”或“模組載入器”。

除了打包和(或)載入模組,模組打包工具也提供了很多額外功能,如自動重編譯(當你對程式碼作出修改或為了除錯而生成 source maps 時)。

下面是一些常見的模組打包方法:

打包 CommonJS

第一部分中可知,CommonJS 是同步載入模組的,但這對於瀏覽器來說並不切合實際。我在第一部分中提到了一種解決方案 —— 其中一種是模組打包工具 Browserify。Browserify 是一種將 CommonJS 模組編譯成瀏覽器能執行的程式碼的工具。

檔案匯入一個用於計算 number 陣列平均數的模組:

因此,main.js 檔案有一個依賴項(myDependency)。當使用以下命令時,Browserify 會遞迴打包所有由 main.js 檔案開始引入的模組,到一個名為 bundle.js 的檔案:

Browserify 要實現以上功能,它要解析 抽象語法樹(AST) 的每個 require 呼叫,以遍歷專案的整個依賴圖。一旦它解決了依賴的構造關係,就能將模組以正確的順序打包進一個單獨檔案內。然後,在 html 裡插入一個用於引入 “bundle.js”<script> 標籤,從而確保你的原始碼在一個 HTTP 請求中完成下載。

同樣地,如果多個檔案擁有多個依賴,你只需簡單地告訴 Browserify 你的入口檔案(entry file),然後休息一會等待它完成即可。

最終產品:打包檔案需要通過 Minify-JS 之類的工具壓縮打包後的程式碼。

打包 AMD

如果你使用的是 AMD,你需要使用 AMD 載入器,如 RequireJS 或 Curl。一個模組載入器(與打包工具不同)會動態載入程式需要執行的模組。

再次提醒,AMD 與 CommonJS 的主要區別是:AMD 以非同步的方式載入模組。也就是說, 對於 AMD,你實際上不需要將模組打包到一個檔案的這個構建步驟,因為它是以非同步方式載入模組——也就意味著當使用者第一次訪問網頁時,瀏覽器會循序漸進地下載程式實際需要執行的檔案,而不是一次性下載所有檔案。

然而,在實際生產環境中,隨著使用者操作,大容量的請求開銷並不會產生多大意義。但大多數開發者為了優化效能,仍然使用構建工具(如 RequireJS 優化工具和 r.js)打包和壓縮它們的 AMD 模組。

總的來說,AMD 與 CommonJS 之間的打包差異是:在開發期間,AMD 應用無須任何構建步驟即可執行。當然,在程式碼上線前,要使用優化工具(如 r.js)進行優化。

想了解更多關於 CommonJS vs AMD 的有趣討論,可看看 Tom Dale’s blog 的這篇文章 : )。

Webpack

就打包工具而言,Webpack 是這方面的新生兒。它與你所使用的具體模組系統無關,也就是說它允許開發者使用 CommonJS、AMD 或 ES6。

你可能會疑惑:我們已經有其它打包工具(如 Browserify 和 RequireJS)完成相應工作並做得相當好了,為什麼還需要 Webpack。沒錯,Webpack 提供了一些有用的功能,如“程式碼分割(code splitting)”——一種將程式碼庫分割為“塊(chunks)”的方式,從而能實現按需載入。

例如,如果 web 應用的某段程式碼塊在某種環境下才被用到時,卻直接將整個程式碼庫放進一個龐大的打包檔案,顯然不那麼高效。因此,你可使用“程式碼分割”,將其提取出來成為“打包塊(bundled chunks)”,然後按需載入。對於大多數使用者只需應用程式的核心部分這種情況,就避免了前期負荷過重的問題。

程式碼分割只是 Webpack 提供的眾多引人注目的功能之一,網上有很多關於 “Webpack 與 Browserify 誰更好”的激烈討論。下面列出了一些圍繞該問題的、能理清思路的討論:

  • https://gist.github.com/substack/68f8d502be42d5cd4942
  • http://mattdesl.svbtle.com/browserify-vs-webpack
  • http://blog.namangoel.com/browserify-vs-webpack-js-drama

ES6 模組

跟得上吧?很好!因為接下來要講 ES6 模組,某種意義上它在未來能削弱對打包工具的需求。(你馬上會明白我的意思。)首先,讓我們知道 ES6 模組如何被載入。

當前的 JS 模組規範(CommonJS、AMD)與 ES6 模組之間最重要的區別是:設計 ES6 模組時考慮到了靜態分析。其意思是:當你匯入模組時,該匯入在編譯時(換言之,在指令碼開始執行前。)已執行。這允許我們在執行程式前移除那些不被其它模組使用的匯出模組(exports)。移除不被使用的模組能節省空間,且有效地減少瀏覽器的壓力。

一個常被提起的問題是:使用 UglifyJS 之類的工具壓縮程式碼後(即消除冗餘程式碼)會有何不同?答案是:“視情況而定”。

(注意:消除冗餘程式碼是一個優化步驟,它能移除無用的程式碼和變數——即移除打包程式不需要執行的冗餘程式碼)。

有時 UglifyJS 與 ES6 模組的消除冗餘程式碼的工作完全相同,有時則不是。如果你想了解相關知識,可看看 Rollup’s wiki 的案例。

導致 ES6 模組不同的原因是它以不同方式去完成消除冗餘程式碼的效果,我們稱該方式為“tree shaking”。Tree shaking 本質與消除冗餘程式碼相反。它僅包含打包檔案需要執行的程式碼,而不是排除打包檔案不需要的程式碼。讓我們看看 tree shaking 的一個案例:

假設有一個帶有多個函式的 utils.js 檔案,每個函式都用 ES6 的語法匯出:

接著,假設我們不知道程式需要 utils.js 裡的哪些函式,所以直接將上述模組內的所有函式匯入到 main.js,如下:

最終我們只用到了 each 函式:

“tree shaken” 版本的 main.js 看起來如下(一旦模組被載入後):

注意:只匯出我們使用的 each 函式。

或者我們決定使用 filter 函式,而不是 each 函式,則最終看到的程式碼如下:

tree shaken 版本如下:

此刻,eachfilter 函式都被包含進來。這是因為 filter 在定義時使用了 each。因此也需要匯出該函式模組以保證程式正常執行。

很聰明,對吧?

我要向你發起挑戰,在 Rollup.js 的 線上案例與編輯器 中探索 tree shaking 吧。

構建 ES6 模組

現在我們知道載入 ES6 模組與其它模組規範是不同的,但我們還沒講使用 ES6 模組時的構建步驟。

不幸的是,由於瀏覽器到現在仍不支援載入原生 ES6 模組,如果現在要使用 ES6 模組則需要其它額外的工作。

1-lpAgpggDLcK1a3MBEbmODg

下面有兩個實現構建/轉化 ES6 模組(以至瀏覽器能執行)的方法,第一個是現在最常用的方式:

  1. 使用轉譯器(如 Babel 或 Traceur)以 CommonJS、AMD 或 UMD 其中一種規範將 ES6 程式碼轉譯為 ES5 程式碼。然後通過模組打包工具(如 Browserify 或 Webpack)將轉譯後的程式碼打包成一個或多個檔案。
  2. 使用 Rollup.js,這與前一個方式很相似,不同的是 Rollup 擁有 ES6 模組的靜態分析程式碼(ES6 程式碼)與依賴的能力。它利用 “tree shaking” 讓打包檔案擁有最精簡的程式碼。總言之,對於 ES6 模組,使用 Rollup.js (相較於 Browserify 或 Webpack)的最大好處是 tree shaking 能讓打包檔案更小。需要提醒你的是:Rollup 提供了幾種打包程式碼的規範,包括 ES6、CommonJS、AMD、UMD 和 IIFE(立即呼叫函式表示式)。IIFE 和 UMD 的打包能直接在瀏覽器執行,但如果你選擇打包 AMD、CommonJS 或 ES6 模組時,需要尋找能將程式碼轉成瀏覽器能理解執行的程式碼的方法(例如,使用 Broserify、Webpack、RequireJS 等)。

跨越障礙

作為 Web 開發者,我們不得不跨越很多障礙。例如,將優美的 ES6 模組轉為瀏覽器能識別的程式碼,其過程並不總是一帆風順。

問題是,ES6 模組什麼時候才能“消滅”上述的程式碼構建開銷呢?

答案是:“儘快”。

ECMAScript 目前有一個解決方案叫 ECMAScript 6 module loader API。簡言之,這是一個綱領性的、基於 Promise 的 API,它支援動態載入模組並快取模組,以便後續的匯入不需要重新載入模組。

如下:

myModule.js

main.js

你亦可直接對 script 標籤指定 “type=module” 來定義模組,如:

如果你還沒看過 the module API polyfill 的 repo,我強烈建議你 看看

此外,如果你想試試該方法,那就看看 SystemJS,它構建於 ES6 Module Loader polyfill 之上。SystemJS 能在瀏覽器和 Node 上動態載入任何模組規範(ES6 模組、AMD、CommonJS、全域性指令碼)。它在一個 “模組註冊器(module registry)”上儲存了所有已載入模組的路徑,從而避免重新載入先前已載入的模組。更不用說它能自動轉譯 ES6 模組(只需簡單配置)和擁有從任何型別模組中載入任何型別模組的能力了。

有了原生的 ES6 模組後,還需要模組打包嗎?

對於日益普及的 ES6 模組,下面有一些有趣的觀點:

HTTP/2 會淘汰模組打包嗎?

HTTP/1 只允許每個 TCP 連線帶一個請求。這就是載入多個資源時需要多個請求的原因。而 HTTP/2 是完全多路複用的,這意味著多個請求和響應可並行執行。因此,我們可用單獨一個連結同時處理多個請求。

由於每個 HTTP 請求(HTTP/2)的成本遠低於 HTTP/1,從長遠來說,載入多個模組不再是一個嚴重的效能問題。一些人認為模組打包不再需要了。這當然是有可能的,但這要具體情況具體分析了。

舉個例說,HTTP/2 不享有模組打包提供的優勢,例如移除未被使用的匯出模組以節省空間。如果一個網站的每一丁點效能都至關重要,那麼長遠來看,打包能帶來增量效益。當然,如果你對效能需求不那麼極端,你可能會通過跳過該構建步驟(打包檔案),以最小的成本節省時間。

總的來說,要讓大多數網站使用 HTTP/2 協議仍有很長的路要走。我預測構建處理至少在短期內仍會保留。

PS:如果你對 HTTP/2 與 HTTP/1.x 的差異感興趣,可看看這份 優秀的資源

CommonJS、AMD 與 UMD 會被淘汰嗎?

一旦 ES6 成為模組標準,我們還需要其它非原生的模組規範嗎?

我持懷疑態度。

若 Web 開發遵守一個標準方法進行匯入和匯出模組,將獲益匪淺,而且省去了中間步驟(譯者注:一些構建處理)。但 ES6 成為模組規範需要多長時間呢?

機會是有,但得等一段時間 ;)

再者,眾口難調,所以“一個標準的方法”可能永遠不會成為現實。

總結

我希望文章的兩章節能讓你理清一些開發者口中的模組和模組打包的相關概念。如果發現上文有令你困惑的地方,可看看第一部分

一如既往,可以在評論區和我盡情交流或提出問題!

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

打賞譯者

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

任選一種支付方式

JavaScript 模組(2):模組打包 JavaScript 模組(2):模組打包

相關文章