ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

lsvih發表於2017-05-04

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

最近一段日子,編寫高效的 JavaScript 應用變得越來越複雜。早在幾年前,大家都開始合併指令碼來減少 HTTP 請求數;後來有了壓縮工具,人們為了壓縮程式碼而縮短變數名,甚至連程式碼的最後一位元組都要省出來。

今天,我們有了 tree shaking 和各種模組打包器,我們為了不在首屏載入時阻塞主程式又開始進行程式碼分割,加快互動時間。我們還開始轉譯一切東西:感謝 Babel,讓我們能夠在現在就使用未來的特性。

ES6 模組由 ECMAScript 標準制定,定稿有些時日了。社群為它寫了很多的文章,講解如何通過 Babel 使用它們,以及 import 和 Node.js 的 require 的區別。但是要在瀏覽器中真正實現它還需要一點時間。我驚喜地發現 Safari 在它的 technology preview 版本中第一個裝載了 ES6 模組,並且 Edge 和 Firefox Nightly 版本也將要支援 ES6 模組——雖然目前還不支援。在使用 RequireJSBrowserify 之類的工具後(還記得關於 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 中看看,發現並沒有發生什麼事。

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

程式碼包還是和之前一樣載入,“Hello world!” 也正常顯示。雖然沒看到效果,但是這說明瀏覽器可以接受這種它們並不理解的命令而不會報錯,這是極好的。Chrome 忽略了這個它無法判斷型別的 script 元素。

接下來,讓我們在 Safari technology preview 中試試:

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

遺憾的是,它並沒有顯示另外的“Hello world”。造成問題的原因是構建工具與原生 ES 模組的差異:Webpack 是在構建的過程中找到那些需要 include 的檔案,而 ES 模組是在瀏覽器中執行的時候才去取檔案的,因此我們需要為此指定正確的檔案路徑:

// app/index.js

// 這樣寫不行
// import dep1 from './dep-1';

// 這樣寫能正常工作
import dep1 from './dep-1.js';複製程式碼

改了檔案路徑之後它能正常工作了,但事實上 Safari Preview 載入了程式碼包,以及三個獨立的模組,這意味著我們的程式碼被執行了兩次。

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

這個問題的解決方案就是加上 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>複製程式碼

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

現在好了。通過結合使用 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 基本上沒有被使用,但是在加上它的依賴後,我們可以看看發生了什麼:

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

可以看到 request 數量增加到了 40 個以上,頁面在普通 wifi 下的載入時間從大約 100 毫秒上升到了 400 到 800 毫秒,載入的資料總大小在沒有壓縮的情況下增加到了大約 12KB。可惜的是 WebPagetest 在 Safari Preview 中不可用,我們沒法給它做可靠的標準檢測。

但是,Chrome 收到打包後的 JavaScript 資料比較小,只有大約 8KB。

ES6 模組原生支援在瀏覽器中落地,是時候該重新考慮打包了嗎?

這 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 生態的影響以及最佳實踐方法將會是怎樣的呢?讓我們拭目以待。

其它資源


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章