繼 JavaScript 模塊入門,再詳解“模塊捆綁”
繼 JavaScript 模塊入門,再詳解“模塊捆綁”
在這篇文章的第一部分,我談到了什么是模塊,開發者為什么使用它們,以及,在你的程序中實現模塊的不同方式。
在這第二部分,將會回答捆綁模塊到底意味著什么:為什么要捆綁,捆綁的不同方法,以及在網頁開發中模塊的未來發展。
1. 什么是模塊捆綁
抽象的概括,模塊捆綁就是這樣一個簡單的處理:把一組模塊以及它們的依賴,按照正確的次序,拼接在一個文件或一組文件里。
但正如網頁開發的其它方方面面,棘手的總是潛藏在具體細節里。
2. 為什么一定要捆綁模塊?
當你將程序劃分成模塊時,一般把它們組織成不同的文件或文件夾。很可能你還有一組所用庫的模塊,比如 Underscore、React。
結果,這些文件每一個都得用 <script> 標簽引入你的主 HTML 文件,當用戶訪問你的主頁時再由瀏覽器加載進來。每個文件都用單獨的 <script> 標簽引入,意味著瀏覽器不得不分別挨個加載它們。
對網頁加載時間來說這簡直是噩夢。
于是,為了解決這個問題,我們把所有文件捆綁,或“拼接”到一個文件(有時也是一組文件)中,正是為了減少請求數。當你聽到開發人員談論“構建步驟”或“構建過程”時,他們談的就是這個。
另一個加速構建操作的常用方法是,“縮減”捆綁后的代碼。縮減,是把源代碼中不需要的字符(如空格、評論、換行符等等)移除,從而減小了代碼的總體積,卻不改變其功能。
數據更少,瀏覽器處理的時間就更短,便減少了下載文件花費的時間。如果你見過帶 “min” 擴展名的文件,比如 “ underscore-min.js ”,可能就已經留意到,相比完整版,縮減版小了好多(不過很難閱讀)。
任務執行工具,如 Gulp、Grunt,讓開發者操作拼接和縮減更簡單便捷。一邊是展示給開發者看的人類可讀代碼,另一邊是提供給瀏覽器的捆綁后的計算機可讀代碼。
3. 捆綁模塊有哪些不同的方法?
如果你用的是一種標準模塊模式(在第一部分 討論過)來定義模塊,那么拼接和縮減文件不會出任何岔子,你其實就是把幾堆純 JavaScript 代碼捆成一束。
但如果你用的是非原生模塊系統,瀏覽器不能像 CommonJS、AMD、甚至原生 ES6 模塊格式那樣解析,你就需要用專門工具先轉化成排列正確、瀏覽器可識別的代碼。這正是 Browserify、RequireJS、Webpack 和其他模塊捆綁工具,或模塊加載工具粉墨登場的時候。
除了捆綁或加載你的模塊,模塊捆綁工具還有許多額外的功能,比如,當你修改或為了調試生成源碼映射時,它會自動重編譯你的代碼。
下面就來過一遍常用的模塊捆綁方法:
4. 捆綁 CommonJS
從第一部分已經知道,CommonJS 是同步加載模塊,雖然沒什么毛病,但對瀏覽器來說不太現實。我曾提過有方法可以解決——其中之一就是模塊捆綁工具 Browserify ,它是為瀏覽器編譯 CommonJS 模塊的工具。
舉個例子,假如你有一個個文件,它引入一個模塊以計算一組數值的平均值:
var myDependency = require(‘myDependency’);
var myGrades = [93, 95, 88, 0, 91];
var myAverageGrade = myDependency.average(myGrades);
在這個場景中,我們只有一個依賴。用下面的這個命令,Browserify 會以這個文件為入口把所有依賴的模塊遞歸地捆綁進一個文件:
browserify main.js -o bundle.js
Browserify 實際做的是,跳入文件為每一個依賴分析它的抽象語法樹,從而遍歷出工程的整個依賴關系。一旦它搞懂了你的依賴結構,就把它們按照正確的順序捆綁進一個文件。那時,你只需用一個 <script> 標簽,引入 bundle.js 到你的 HTML,從而只要一個 http 請求,就把你的所有源代碼下載下來了。哇,還不去捆綁!
與之類似,如果你有多個文件,每個文件又有多個依賴,你要做的也很簡單:告訴他你的入口文件,然后就可以癱坐在椅子上看好戲。
最后生成的捆綁文件,可以直接導向到一些工具做壓縮處理。
5. 捆綁 AMD
如果你用的是 AMD, 就需要像 RequireJS、Curl 那樣的 AMD 模塊加載工具。模塊加載工具(與模塊捆綁工具不同)會動態加載你的程序所需的模塊。
再提醒一下,AMD 跟 CommonJS 最大不同在于,AMD 會異步加載模塊。所以,如果你用 AMD,就無需捆綁模塊到一個文件里,技術上講也就不需要執行捆綁模塊動作的構建過程。因為異步加載模塊意味著在運行過程中逐步下載那些程序所必需的文件,而不是用戶剛進入頁面就一下把所有文件都下載下來。
然后實際中,每個用戶動作會額外產生大數據量的請求,這對產品而言很不合理。所以大多數網頁開發者仍然為了更優的性能,使用構建工具捆綁、縮減他們的 AMD 模塊,比如像 RequireJS 優化器 r.js 一樣的工具。
概括地說,在捆綁方面 AMD 和 CommonJS 的區別在于:開發時, 用 AMD 的程序可以省去構建過程。直到實際運行時,再讓 r.js 那些優化工具介入處理。
要想圍觀 CommonJS 和 AMD 更有意思的“互撕”。
6. Webpack
捆綁工具算有些年頭了,Webpack 則是初來乍到的新人。它的設計基于不知道你所用的模塊系統是什么,從而讓開發者各依所需選用 CommonJS、AMD 或 ES6。
你是不是納悶,已經有像 Browserify、RequireJS 那樣的捆綁工具各司其職、表現優異,為什么還需要 Webpack?好吧,至少因為一點,Webpack 額外提供了一些有用的特性,如“代碼分割”——把你的代碼庫分割成按需加載的“塊”。
比如,你有一個網頁應用程序,其中相當一塊代碼只在特定條件下才用到,那么把整個代碼庫都捆綁進一個大文件就不是很高效。這時,你可以用代碼分割功能,將那部分代碼抽離、捆綁到另外一塊,在執行時按需加載,從而避免在最開始就遇到大量負載的麻煩。其實大部分用戶只會用到你應用程序的核心代碼。
代碼分割僅僅是 Webpack 提供的眾多亮眼功能之一。
7. ES6 模塊
看完了嗎?好,接下來我想談談 ES6 模塊,它在未來會減少捆綁工具的使用。(你很快就會明白我的意思。)先來弄懂 ES6 模塊是怎么被加載的。
ES6 模塊同現有的 JS 模塊格式最關鍵的區別是,它在設計之初就考慮到了靜態分析。什么意思呢?當你導入模塊時,導入的模塊會在編譯階段,即代碼開始運行之前,被解析。于是,我們能夠在運行程序之前,把導出模塊中不被其他模塊使用的部分移除。這節省了很大的空間,減少了瀏覽器的壓力。
那么問題來了。你在用一些工具,像 Uglify.js,縮減代碼時,有一個死代碼去除的處理,它和 ES6 移除沒用的模塊又有什么不同呢?只能說“得看情況”。
(注:死代碼去除是可選步驟,即去除未使用的代碼和變量。就把它想成,扔掉捆綁后代碼中不需要運行的冗余部分,一定在捆綁之后。)
有時,死代碼去除在 Uglify.js 和 ES6 模塊中表現完全一樣,而有時也不同。如果你想驗證一下, Rollup’s wiki 里有個很好的示例。
ES6 的不同在于,它去除死代碼的方法不同,叫做“tree shaking”,本質上它是死代碼去除的反過程。它只保留你運行所需的代碼,而非排除你運行所不需要的。來看個例子:
我們有一個 util.js 文件,里面寫了一些函數,再用 ES6 語法將它們一一導出(exports):
export function each(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);
}
}
}
export function filter(collection, test) {
var filtered = [];
each(collection, function (item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
}
export function map(collection, iterator) {
var mapped = [];
each(collection, function (value, key, collection) {
mapped.push(iterator(value));
});
return mapped;
}
export function reduce(collection, iterator, accumulator) {
var startingValueMissing = accumulator === undefined;
each(collection, function (item) {
if (startingValueMissing) {
accumulator = item;
startingValueMissing = false;
} else {
accumulator = iterator(accumulator, item);
}
});
return accumulator;
}
然后,假設我們不知道自己的程序將要用到哪個功能函數,所以把這些模塊全都導入(import)到 main.js:
import * as Utils from ‘./utils.js’;
但到最后我們只用了 each 函數:
import * as Utils from ‘./utils.js’;
Utils.each([1, 2, 3], function (x) { console.log(x) });</code></pre>
經過“tree shaking”之后,把相應的模塊加載進來,新的 main.js 會是這個樣子:
function each(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);
}
}
};
each([1, 2, 3], function (x) { console.log(x) });</code></pre>
注意到了嗎,導出模塊中只有我們用到的 each 被引入進來了。
另外,如果我們突然決定用 filter 函數而非 each ,結果就會是這樣:
import * as Utils from ‘./utils.js’;
Utils.filter([1, 2, 3], function (x) { return x === 2 });</code></pre>
“tree shaking” 之后的樣子:
function each(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);
}
}
};
function filter(collection, test) {
var filtered = [];
each(collection, function (item) {
if (test(item)) {
filtered.push(item);
}
});
return filtered;
};
filter([1, 2, 3], function (x) { return x === 2 });</code></pre>
注意了(敲黑板),這次 each 和 filter 都包括了進來。這是因為 filter 定義中用到了 each ,所以要把兩者都導入才能讓模塊正常運行。
很炫吧?
想來點有難度的嗎,可以去 Rollup.js 的 live demo and editor 鼓搗鼓搗 “tree shaking”。
8. 構建 ES6 模塊
現在我們已經知道 ES6 模塊的加載方式與其它模塊格式不同,但仍未談到使用 ES6 模塊時它的構建過程是怎樣的?
而不盡完美的是,ES6 模塊還需要一些額外的處理,因為有關瀏覽器如何加載 ES6 模塊還沒有原生的實現方法。

下面列出一些可選方案來構建或轉換 ES6 模塊,以便在瀏覽器中運行。其中 第一條 是當下最常用的方法:
-
使用轉譯工具(如 Babel、Traceur)將你的 ES6 代碼轉譯成 ES5 中 CommonJS、AMD或 UMD 中任一格式。再以管道的方式導向一個模塊捆綁工具,如 Browserify、Webpack,捆綁成一個或多個文件。
-
用 Rollup.js 。它和第一條很類似,但捎帶上了 ES6 模塊的特性——在構建之前靜態分析 ES6 代碼和它的依賴關系。它利用“tree shaking”讓你的代碼包最小。大體來說,使用 Rollup.js 處理你的 ES6 模塊比使用 Browserify 或 Webpack 最主要的好處就是,“tree shaking”能讓你的代碼包更小。有一點要謹慎,Rollup 能讓你把代碼捆綁成多種格式,包括 ES6、CommonJS、AMD、UMD 和 IIFE。IIFE 和 UMD 代碼包在瀏覽器中照常運行,但如果你捆綁成 AMD、CommonJS 或 ES6,就得用另外的方法再將其轉化成瀏覽器能解析的格式(比如用 Browserify、Webpack、RequireJS 等等)。
9. 過關斬將
一個網頁開發者不得不過五關斬六將。把我們養眼的 ES6 模塊轉化成瀏覽器可解析的東西往往不是那么輕而易舉。
有人會問,什么時候 ES6 模塊可以直接運行在瀏覽器中,不用再做額外的修修補補?
慶幸的是,“遲早會的”。
ECMAScript 目前有一個解決方案叫 ECMAScript 6 module loader API 。概括地說,它是基于 Promise 的程序 API,將會動態加載模塊,并緩存起來,以免后面再引入時不再重新加載新版本。
像這樣:
myModule.js
export class myModule {
constructor() {
console.log('Hello, I am a module');
}
hello() {
console.log('hello!');
}
goodbye() {
console.log('goodbye!');
}
}</code></pre>
main.js
System.import(‘myModule’).then(function (myModule) {
new myModule.hello();
});
// ‘Hello!, I am a module!’</code></pre>
還有另一種方式,你可以在 script 標簽直接指定 “type=module” 來定義模塊:
<script type="module">
// loads the 'myModule' export from 'mymodule.js'
import { hello } from 'mymodule';
new Hello(); // 'Hello, I am a module!'
</script>
另外,如果你想以測試驅動方式推動這個方案,去看下 SystemJS ,它正是基于 ES6 Module Loader polyfill 。SystemJS 在瀏覽器和 Node 中動態加載任何格式的模塊(ES6 模塊、AMD、CommonJS、全局代碼)。它在一個“注冊表”里記錄所有加載過的模塊,以免重復加載已有的模塊。而且,它可以自動轉譯 ES6 模塊(你只需簡單設置一個選項),還能夠從其它任何類型的模塊中加載成任何類型的模塊。相當利索!
10. 有了原生 ES6 模塊,我們仍然需要捆綁工具嗎?
ES6 模塊的日益盛行引起了一些有趣的現象:
10.1、HTTP/2 會讓模塊捆綁工具被廢棄嗎?
HTTP/1 只允許一次 TCP 連接發送一個請求,所以在加載多個資源時得要多個請求。用HTTP/2 的話,一切都不一樣了。它是完全的多路復用,多個請求和響應可以并行發生。于是,我們可以用一個連接同時處理多個請求。
因為每個 HTTP 請求的代價大大低于 HTTP/1,長遠來看,加載一批模塊不再導致嚴重的性能問題。有人便說,這表示沒必要再捆綁模塊了。這肯定是大有可能,但實際上要看情況而定。
因為一點,模塊捆綁還有 HTTP/2 所沒有的好處,比如移除冗余的導出模塊以節省空間。如果你是建一個網站,性能至關重要、分秒必爭,那么長久下來,捆綁模塊或許能給你額外的優勢。不過,如果說你對性能要求沒有如此苛刻,那么跳過構建步驟是可以省下一些為了減小代碼包而花去的時間。
總的來說,絕大多數網站都用上 HTTP/2 的那個時候離我們現在還很遠。我預測構建過程將會保留, 至少 在近期內。
10.2、CommonJS、AMD、UMD 會被廢棄嗎?
一旦 ES6 真成了絕對的標準,我們還需要其它非原生的模塊格式嗎?
我覺得還有。
遵循單一標準在 JavaScript 中導入、導出模塊,而不需要中間步驟——網頁開發長期受益于此。得要多久才能來到 ES6 成為模塊標準的那一天?
有可能要相當一段時間。
況且很多人都喜歡有多種“口味”可選,“僅此一款”可能永遠不會成真。
11. 總結
我希望這篇分上下兩部分的文章有助于理清開發者談論模塊捆綁時所用的一些術語概念。如果你還不甚了了,倒回去再看看第一部分
來自:http://www.zcfy.cc/article/javascript-modules-part-2-module-bundling-1386.html