ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?
最近一段日子,編寫高效的 JavaScript 應用變得越來越複雜。早在幾年前,大家都開始合併指令碼來減少 HTTP 請求數;後來有了壓縮工具,人們為了壓縮程式碼而縮短變數名,甚至連程式碼的最後一位元組都要省出來。
今天,我們有了 tree shaking 和各種模組打包器,我們為了不在首屏載入時阻塞主程式又開始進行程式碼分割,加快互動時間。我們還開始轉譯一切東西:感謝 Babel,讓我們能夠在現在就使用未來的特性。
ES6 模組由 ECMAScript 標準制定,定稿有些時日了。社群為它寫了很多的文章,講解如何通過 Babel 使用它們,以及 import
和 Node.js 的 require
的區別。但是要在瀏覽器中真正實現它還需要一點時間。我驚喜地發現 Safari 在它的 technology preview 版本中第一個裝載了 ES6 模組,並且 Edge 和 Firefox Nightly 版本也將要支援 ES6 模組——雖然目前還不支援。在使用 RequireJS
和 Browserify
之類的工具後(還記得關於 AMD 與 CommonJS 的討論嗎?),至少看起來瀏覽器終於能支援模組了。讓我們來看看明朗的未來帶來了怎樣的禮物吧!?
傳統方法
構建 web 應用的常用方式就是使用由 Browserify、Rollup、Webpack 等工具構建的程式碼包(bundle)。而不使用 SPA(單頁面應用)技術的網站則通常由服務端生成 HTML,在其中引入一個 JavaScript 程式碼包。
<html>
<head>
<title>ES6 modules tryout</title>
<!-- defer to not block rendering -->
<script src="dist/bundle.js" defer></script>
</head>
<body>
<!-- ... -->
</body>
</html>複製程式碼
我們使用 Webpack 打包的程式碼包中包括了 3 個 JavaScript 檔案,這些檔案使用了 ES6 模組:
// app/index.js
import dep1 from './dep-1';
function getComponent () {
var element = document.createElement('div');
element.innerHTML = dep1();
return element;
}
document.body.appendChild(getComponent());
// app/dep-1.js
import dep2 from './dep-2';
export default function() {
return dep2();
}
// app/dep-2.js
export default function() {
return 'Hello World, dependencies loaded!';
}複製程式碼
這個 app 將會顯示“Hello world”。在下文中顯示“Hello world”即表示指令碼載入成功。
裝載一個程式碼包(bundle)
配置使用 Webpack 建立一個程式碼包相對來說比較直觀。在構建過程中,除了打包和使用 UglifyJS 壓縮 JavaScript 檔案之外並沒有做別的什麼事。
// webpack.config.js
const path = require('path');
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
module.exports = {
entry: './app/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist')
},
plugins: [
new UglifyJSPlugin()
]
};複製程式碼
3 個基礎檔案比較小,加起來只有 347 位元組。
$ ll app
total 24
-rw-r--r-- 1 stefanjudis staff 75B Mar 16 19:33 dep-1.js
-rw-r--r-- 1 stefanjudis staff 75B Mar 7 21:56 dep-2.js
-rw-r--r-- 1 stefanjudis staff 197B Mar 16 19:33 index.js複製程式碼
在我通過 Webpack 構建之後,我得到了一個 856 位元組的程式碼包,大約增大了 500 位元組。增加這麼些位元組還是可以接受的,這個程式碼包與我們平常生產環境中做程式碼裝載沒啥區別。感謝 Webpack,我們已經可以使用 ES6 模組了。
$ webpack
Hash: 4a237b1d69f142c78884
Version: webpack 2.2.1
Time: 114ms
Asset Size Chunks Chunk Names
bundle.js 856 bytes 0 [emitted] main
[0] ./app/dep-1.js 78 bytes {0}[built]
[1] ./app/dep-2.js 75 bytes {0}[built]
[2] ./app/index.js 202 bytes {0}[built]複製程式碼
使用原生支援的 ES6 模組的新設定
現在,我們得到了一個“傳統的打包程式碼”,現在所有還不支援 ES6 模組的瀏覽器都支援這種打包的程式碼。我們可以開始玩一些有趣的東西了。讓我們在 index.html
中加上一個新的 script 元素指向 ES6 模組,為其加上 type="module"
。
<html><head><title>ES6 modules tryout</title><!-- in case ES6 modules are supported --><script src="app/index.js"type="module"></script><script src="dist/bundle.js"defer></script></head><body><!-- ... --></body></html>複製程式碼
然後我們在 Chrome 中看看,發現並沒有發生什麼事。
程式碼包還是和之前一樣載入,“Hello world!” 也正常顯示。雖然沒看到效果,但是這說明瀏覽器可以接受這種它們並不理解的命令而不會報錯,這是極好的。Chrome 忽略了這個它無法判斷型別的 script 元素。
接下來,讓我們在 Safari technology preview 中試試:
遺憾的是,它並沒有顯示另外的“Hello world”。造成問題的原因是構建工具與原生 ES 模組的差異:Webpack 是在構建的過程中找到那些需要 include 的檔案,而 ES 模組是在瀏覽器中執行的時候才去取檔案的,因此我們需要為此指定正確的檔案路徑:
// app/index.js
// 這樣寫不行
// import dep1 from './dep-1';
// 這樣寫能正常工作
import dep1 from './dep-1.js';複製程式碼
改了檔案路徑之後它能正常工作了,但事實上 Safari Preview 載入了程式碼包,以及三個獨立的模組,這意味著我們的程式碼被執行了兩次。
這個問題的解決方案就是加上 nomodule
屬性,我們可以在載入程式碼包的 script 元素里加上這個屬性。這個屬性是最近才加入標準中的,Safari Preview 也是在一月底才支援它的。這個屬性會告訴 Safari,這個 script 是當不支援 ES6 模組時的“退路”。在這個例子中,瀏覽器支援 ES6 模組因此加上這個屬性的 script 元素中的程式碼將不會執行。
<html>
<head>
<title>ES6 modules tryout</title>
<!-- in case ES6 modules are supported -->
<script src="app/index.js" type="module"></script>
<!-- in case ES6 modules aren't supported -->
<script src="dist/bundle.js" defer nomodule></script>
</head>
<body>
<!-- ... -->
</body>
</html>複製程式碼
現在好了。通過結合使用 type="module"
與 nomodule
,我們現在可以在不支援 ES6 模組的瀏覽器中載入傳統的程式碼包,在支援 ES6 模組的瀏覽器中載入 JavaScript 模組。
你可以在 es-module-on.stefans-playground.rocks 檢視這個尚在制定的規範。
模組與指令碼的不同
這兒有幾個問題。首先,JavaScript 在 ES6 模組中執行與平常在 script 元素中不同。Axel Rauschmayer 在他的探索 ES6一書中很好地討論了這個問題。我推薦你點選上面的連結閱讀這本書,但是在此我先快速地總結一下主要的不同點:
- ES6 模組預設在嚴格模式下執行(因此你不需要加上
use strict
了)。 - 最外層的
this
指向undefined
(而不是 window)。 - 最高階變數是 module 的區域性變數(而不是 global)。
- ES6 模組會在瀏覽器完成 HTML 的分析之後非同步載入與執行。
我認為,這些特性是巨大進步。模組是區域性的——這意味著我們不再需要到處使用 IIFE 了,而且我們不用再擔心全域性變數洩露。而且預設在嚴格模式下執行,意味著我們可以在很多地方拋棄 use strict
宣告。
譯註:IIFE 全稱 immediately-invoked function expression,即立即執行函式,也就是大家熟知的在函式後面加括號。
從改善效能的觀點來看(可能是最重要的進步),模組預設會延遲載入與執行。因此我們將不再會不小心給我們的網站加上了阻礙載入的程式碼,使用 type="module"
的 script 元素也不再會有 SPOF 問題。我們也可以給它加上一個 async
屬性,它將會覆蓋預設的延遲載入行為。不過使用 defer
在現在也是一個不錯的選擇。
譯註:SPOF 全稱 Single Points Of Failure——單點故障
<!-- not blocking with defer default behavior -->
<script src="app/index.js" type="module"></script>
<!-- executed after HTML is parsed -->
<script type="module">
console.log('js module');
</script>
<!-- executed immediately -->
<script>
console.log('standard module');
</script>複製程式碼
如果你想詳細瞭解這方面內容,可以閱讀 script 元素說明,這篇文章簡單易讀,並且包含了一些示例。
壓縮純 ES6 程式碼
還沒完!我們現在能為 Chrome 提供壓縮過的程式碼包,但是還不能為 Safari Preview 提供單獨壓縮過的檔案。我們如何讓這些檔案變得更小呢?UglifyJS 能完成這項任務嗎?
然而必須指出,UglifyJS 並不能完全處理好 ES6 程式碼。雖然它有個 harmony
開發版分支(地址)支援ES6,但不幸的是在我寫這 3 個 JavaScript 檔案的時候它並不能正常工作。
$ uglifyjs dep-1.js -o dep-1.min.js
Parse error at dep-1.js:3,23
export default function() {
^
SyntaxError: Unexpected token: punc (()
// ..
FAIL: 1複製程式碼
但是現在 UglifyJS 幾乎存在於所有工具鏈中,那全部使用 ES6 編寫的工程應該怎麼辦呢?
通常的流程是使用 Babel 之類的工具將程式碼轉換為 ES5,然後使用 Uglify 對 ES5 程式碼進行壓縮處理。但是在這篇文章裡我不想使用 ES5 翻譯工具,因為我們現在是要尋找面向未來的處理方式!Chrome 已經覆蓋了 97% ES6 規範 ,而 Safari Preview 版自 verion 10 之後已經 100% 很好地支援 ES6了。
我在推特中提問是否有能夠處理 ES6 的壓縮工具,Lars Graubner 告訴我可以使用 Babili。使用 Babili,我們能夠輕鬆地對 ES6 模組進行壓縮。
// app/dep-2.js
export default function() {
return 'Hello World. dependencies loaded.';
}
// dist/modules/dep-2.js
export default function(){return 'Hello World. dependencies loaded.'}複製程式碼
使用 Babili CLI 工具,可以輕鬆地分別壓縮各個檔案。
$ babili app -d dist/modules
app/dep-1.js -> dist/modules/dep-1.js
app/dep-2.js -> dist/modules/dep-2.js
app/index.js -> dist/modules/index.js複製程式碼
最終結果:
$ ll dist
-rw-r--r-- 1 stefanjudis staff 856B Mar 16 22:32 bundle.js
$ ll dist/modules
-rw-r--r-- 1 stefanjudis staff 69B Mar 16 22:32 dep-1.js
-rw-r--r-- 1 stefanjudis staff 68B Mar 16 22:32 dep-2.js
-rw-r--r-- 1 stefanjudis staff 161B Mar 16 22:32 index.js複製程式碼
程式碼包仍然是大約 850B,所有檔案加起來大約是 300B。我沒有使用 GZIP,因為它並不能很好地處理小檔案。(我們稍後會提到這個)
能通過 rel=preload 來加速 ES6 的模組載入嗎?
對單個 JS 檔案進行壓縮取得了很好的效果。檔案大小從 856B 降低到了 298B,但是我們還能進一步地加快載入速度。通過使用 ES6 模組,我們可以裝載更少的程式碼,但是看看瀑布圖你會發現,request 會按照模組的依賴鏈一個一個連續地載入。
那如果我們像之前在瀏覽器中對程式碼進行預載入那樣,用 <link rel="preload" as="script">
元素告知瀏覽器要載入額外的 request,是否會加快模組的載入速度呢?在 Webpack 中,我們已經有了類似的工具,比如 Addy Osmani 的 Webpack 預載入外掛可以對分割的程式碼進行預載入,那 ES6 模組有沒有類似的方法呢?如果你還不清楚 rel="preload"
是如何運作的,你可以先閱讀 Yoav Weiss 在 Smashing Magazine 發表的相關文章:點選閱讀
但是,ES6 模組的預載入並不是那麼簡單,他們與普通的指令碼有很大的不同。那麼問題來了,對一個 link 元素加上 rel="preload"
將會怎樣處理 ES6 模組呢?它也會取出所有的依賴檔案嗎?這個問題顯而易見(可以),但是使用 preload
命令載入模組,需要解決更多瀏覽器的內部實現問題。Domenic Denicola 在一個 GitHub issue 中討論了這方面的問題,如果你感興趣的話可以點進去看一看。但是事實證明,使用 rel="preload"
載入指令碼與載入 ES6 模組是截然不同的。可能以後最終的解決方案是用另一個 rel="modulepreload"
命令來專門載入模組。在本文寫作時,這個 pull request 還在稽核中,你可以點進去看看未來我們可能會怎樣進行模組的預載入。
加入真實的依賴
僅僅 3 個檔案當然沒法做一個真正的 app,所以讓我們給它加一些真實的依賴。Lodash 根據 ES6 模組對它的功能進行了分割,並分別提供給使用者。我取出其中一個功能,然後使用 Babili 進行壓縮。現在讓我們對 index.js
檔案進行修改,引入這個 Lodash 的方法。
import dep1 from './dep-1.js';
import isEmpty from './lodash/isEmpty.js';
function getComponent() {
const element = document.createElement('div');
element.innerHTML = dep1() + ' ' + isEmpty([]);
return element;
}
document.body.appendChild(getComponent());複製程式碼
在這個例子中,isEmpty
基本上沒有被使用,但是在加上它的依賴後,我們可以看看發生了什麼:
可以看到 request 數量增加到了 40 個以上,頁面在普通 wifi 下的載入時間從大約 100 毫秒上升到了 400 到 800 毫秒,載入的資料總大小在沒有壓縮的情況下增加到了大約 12KB。可惜的是 WebPagetest 在 Safari Preview 中不可用,我們沒法給它做可靠的標準檢測。
但是,Chrome 收到打包後的 JavaScript 資料比較小,只有大約 8KB。
這 4KB 的差距是不能忽視的。你可以在 lodash-module-on.stefans-playground.rocks 找到本示例。
壓縮工作僅對大檔案表現良好
如果你仔細看上面 Safari 開發者工具的截圖,你可能會注意到傳輸後的檔案大小其實比原始碼還要大。在很大的 JavaScript app 中這個現象會更加明顯,一堆的小 Chunk 會造成檔案大小的很大不同,因為 GZIP 並不能很好地壓縮小檔案。
Khan Academy 在前一段時間探究了同樣的問題,他是用 HTTP/2 進行研究的。裝載更小的檔案能夠很好地確保快取命中率,但到最後它一般都會作為一個權衡方案,而且它的效果會被很多因素影響。對於一個很大的程式碼庫來說,分解成若干個 chunk(一個 vendor 檔案和一個 app bundle)是理所當然的,但是要裝載數千個不能被壓縮的小檔案可能並不是一種明智的方法。
Tree shaking 是個超 COOL 的技術
必須要說:感謝非常新潮的 tree shaking 技術,通過它,構建程式可以將沒有使用過以及沒有被其它模組引用的程式碼刪除。第一個支援這個技術的構建工具是 Rollup,現在 Webpack 2 也支援它——只要我們在 babel 中禁用 module
選項。
我們試著改一改 dep-2.js
,讓它包含一些不會在 dep-1.js
中使用的東西。
export default function() {
return 'Hello World. dependencies loaded.';
}
export const unneededStuff = [
'unneeded stuff'
];複製程式碼
Babili 只會壓縮檔案, Safari Preview 在這種情況下會接收到這幾行沒有用過的程式碼。而另一方面,Webpack 或者 Rollup 打的包將不會包含這個 unnededStuff
。Tree shaking 省略了大量程式碼,它毫無疑問應當被用在真實的產品程式碼庫中。
儘管未來很明朗,但是現在的構建過程仍然不會變動
ES6 模組即將到來,但是直到它最終在各大主流瀏覽器中實現前,我們的開發並不會發生什麼變化。我們既不會裝載一堆小檔案來確保壓縮率,也不會為了使用 tree shaking 和死碼刪除來拋棄構建過程。前端開發現在及將來都會一如既往地複雜。
不要把所有東西都進行分割然後就假設它會改善效能。我們即將迎來 ES6 模組的瀏覽器原生支援,但是這不意味著我們可以拋棄構建過程與合適的打包策略。在我們 Contentful 這兒,將繼續堅持我們的構建過程,以及繼續使用我們的 JavaScript SDKs 進行打包。
然而,我們必須承認現在前端的開發體驗仍然良好。JavaScript 仍在進步,最終我們將能夠使用語言本身提供的模組系統。在幾年後,原生模組對 JavaScript 生態的影響以及最佳實踐方法將會是怎樣的呢?讓我們拭目以待。
其它資源
- ES6 模組系列文章 作者:Serg Hospodarets
- 《探索 ES6》 的 模組章節
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。