Linux中國

JavaScript 小模塊的開銷

大約一年之前,我在將一個大型 JavaScript 代碼庫重構為更小的模塊時發現了 Browserify 和 Webpack 中一個令人沮喪的事實:

「代碼越模塊化,代碼體積就越大。:< 」

  • Nolan Lawson

過了一段時間,Sam Saccone 發布了一些關於 TumblrImgur 頁面載入性能的出色的研究。其中指出:

「超過 400 ms 的時間單純的花費在了遍歷 Browserify 樹上。」

  • Sam Saccone

在本篇文章中,我將演示小模塊可能會根據你選擇的 打包器 bundler 模塊系統 module system 而出現高得驚人的性能開銷。此外,我還將解釋為什麼這種方法不但影響你自己代碼的模塊,也會影響依賴項中的模塊,這也正是第三方代碼在性能開銷上很少提及的方面。

網頁性能

一個頁面中包含的 JavaScript 腳本越多,頁面載入也將越慢。龐大的 JavaScript 包會導致瀏覽器花費更多的時間去下載、解析和執行,這些都將加長載入時間。

即使當你使用如 Webpack code splitting、Browserify factor bundles 等工具將代碼分解為多個包,該開銷也僅僅是被延遲到頁面生命周期的晚些時候。JavaScript 遲早都將有一筆開銷。

此外,由於 JavaScript 是一門動態語言,同時流行的 CommonJS 模塊也是動態的,所以這就使得在最終分發給用戶的代碼中剔除無用的代碼變得異常困難。譬如你可能只使用到 jQuery 中的 $.ajax,但是通過載入 jQuery 包,你將付出整個包的代價。

JavaScript 社區對這個問題提出的解決辦法是提倡 小模塊 的使用。小模塊不僅有許多 美好且實用的好處 如易於維護,易於理解,易於集成等,而且還可以通過鼓勵包含小巧的功能而不是龐大的庫來解決之前提到的 jQuery 的問題。

所以在小模塊下,你將不需要這樣:

var _ = require(&apos;lodash&apos;)
_.uniq([1,2,2,3])

而是可以如此:

var uniq = require(&apos;lodash.uniq&apos;)
uniq([1,2,2,3])

包與模塊

需要強調的是這裡我提到的「模塊」並不同於 npm 中的「包」的概念。當你從 npm 安裝一個包時,它會將該模塊通過公用 API 展現出來,但是在這之下其實是一個許多模塊的聚合物。

例如,我們來看一個包 is-array,它沒有別的依賴,並且只包含 一個 JavaScript 文件,所以它只有一個模塊。這算是足夠簡單的。

現在來看一個稍微複雜一點的包,如 once。它有一個依賴的包 wrappy 包都各自包含一個模塊,所以總模塊數為 2。至此,也還算好。

現在來一起看一個更為令人迷惑的例子:qs。因為它沒有依賴的包,所以你可能就認為它只有一個模塊,然而事實上,它有四個模塊!

你可以用一個我寫的工具 browserify-count-modules 來統計一個 Browserify 包的總模塊數:

$ npm install qs
$ browserify node_modules/qs | browserify-count-modules
4

這說明了一個包可以包含一個或者多個模塊。這些模塊也可以依賴於其他的包,而這些包又將附帶其自己所依賴的包與模塊。由此可以確定的事就是任何一個包將包含至少一個模塊。

模塊膨脹

一個典型的網頁應用中會包含多少個模塊呢?我在一些流行的使用 Browserify 的網站上運行 browserify-count-moduleson 並且得到了以下結果:

順帶一提,我寫過的最大的開源站點 Pokedex.org 包含了 4 個包,共 311 個模塊。

讓我們先暫時忽略這些 JavaScript 包的實際大小,我認為去探索一下一定數量的模塊本身開銷會是一件有意思的事。雖然 Sam Saccone 的文章 「2016 年 ES2015 轉譯的開銷」 已經廣為流傳,但是我認為他的結論還未到達足夠深度,所以讓我們挖掘的稍微再深一點吧。

測試環節!

我構造了一個能導入 100、1000 和 5000 個其他小模塊的測試模塊,其中每個小模塊僅僅導出一個數字。而父模塊則將這些數字求和並記錄結果:

// index.js
var total = 0
total += require(&apos;./module_0&apos;)
total += require(&apos;./module_1&apos;)
total += require(&apos;./module_2&apos;)
// etc.
console.log(total)

// module_1.js
module.exports = 1

我測試了五種打包方法:Browserify、帶 bundle-collapser 插件的 Browserify、Webpack、Rollup 和 Closure Compiler。對於 Rollup 和 Closure Compiler 我使用了 ES6 模塊,而對於 Browserify 和 Webpack 則用的是 CommonJS,目的是為了不涉及其各自缺點而導致測試的不公平(由於它們可能需要做一些轉譯工作,如 Babel 一樣,而這些工作將會增加其自身的運行時間)。

為了更好地模擬一個生產環境,我對所有的包採用帶 -mangle-compress 參數的 Uglify ,並且使用 gzip 壓縮後通過 GitHub Pages 用 HTTPS 協議進行傳輸。對於每個包,我一共下載並執行 15 次,然後取其平均值,並使用 performance.now() 函數來記錄載入時間(未使用緩存)與執行時間。

包大小

在我們查看測試結果之前,我們有必要先來看一眼我們要測試的包文件。以下是每個包最小處理後但並未使用 gzip 壓縮時的體積大小(單位:Byte):

100 個模塊 1000 個模塊 5000 個模塊
browserify 7982 79987 419985
browserify-collapsed 5786 57991 309982
webpack 3954 39055 203052
rollup 671 6971 38968
closure 758 7958 43955
100 個模塊 1000 個模塊 5000 個模塊
browserify 1649 13800 64513
browserify-collapsed 1464 11903 56335
webpack 693 5027 26363
rollup 300 2145 11510
closure 302 2140 11789

Browserify 和 Webpack 的工作方式是隔離各個模塊到各自的函數空間,然後聲明一個全局載入器,並在每次 require() 函數調用時定位到正確的模塊處。下面是我們的 Browserify 包的樣子:

(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module &apos;"+o+"&apos;");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o

而 Rollup 和 Closure 包看上去則更像你親手寫的一個大模塊。這是 Rollup 打包的包:

(function () {
        &apos;use strict&apos;;
        var total = 0
        total += 0
        total += 1
        total += 2
// etc.

如果你清楚在 JavaScript 中使用嵌套函數與在關聯數組查找一個值的固有開銷, 那麼你將很容易理解出現以下測試的結果的原因。

測試結果

我選擇在搭載 Android 5.1.1 與 Chrome 52 的 Nexus 5(代表中低端設備)和運行 iOS 9 的第 6 代 iPod Touch(代表高端設備)上進行測試。

這是 Nexus 5 下的測試結果(查看錶格):

Nexus 5 結果

這是 iPod Touch 下的測試結果(查看錶格):

iPod Touch 結果

在 100 個模塊時,各包的差異是微不足道的,但是一旦模塊數量達到 1000 個甚至 5000 個時,差異將會變得非常巨大。iPod Touch 在不同包上的差異並不明顯,而對於具有一定年代的 Nexus 5 來說,Browserify 和 Webpack 明顯耗時更多。

與此同時,我發現有意思的是 Rollup 和 Closure 的運行開銷對於 iPod 而言幾乎可以忽略,並且與模塊的數量關係也不大。而對於 Nexus 5 來說,運行的開銷並非完全可以忽略,但 Rollup/Closure 仍比 Browserify/Webpack 低很多。後者若未在幾百毫秒內完成載入則將會佔用主線程的好幾幀的時間,這就意味著用戶界面將凍結並且等待直到模塊載入完成。

值得注意的是前面這些測試都是在千兆網速下進行的,所以在網路情況來看,這只是一個最理想的狀況。藉助 Chrome 開發者工具,我們可以認為地將 Nexus 5 的網速限制到 3G 水平,然後來看一眼這對測試產生的影響(查看錶格):

Nexus 5 3G 結果

一旦我們將網速考慮進來,Browserify/Webpack 和 Rollup/Closure 的差異將變得更為顯著。在 1000 個模塊規模(接近於 Reddit 1050 個模塊的規模)時,Browserify 花費的時間比 Rollup 長大約 400 毫秒。然而 400 毫秒已經不是一個小數目了,正如 Google 和 Bing 指出的,亞秒級的延遲都會 對用戶的參與產生明顯的影響

還有一件事需要指出,那就是這個測試並非測量 100 個、1000 個或者 5000 個模塊的每個模塊的精確運行時間。因為這還與你對 require() 函數的使用有關。在這些包中,我採用的是對每個模塊調用一次 require() 函數。但如果你每個模塊調用了多次 require() 函數(這在代碼庫中非常常見)或者你多次動態調用 require() 函數(例如在子函數中調用 require() 函數),那麼你將發現明顯的性能退化。

Reddit 的移動站點就是一個很好的例子。雖然該站點有 1050 個模塊,但是我測量了它們使用 Browserify 的實際執行時間後發現比「1000 個模塊」的測試結果差好多。當使用那台運行 Chrome 的 Nexus 5 時,我測出 Reddit 的 Browserify require() 函數耗時 2.14 秒。而那個「1000 個模塊」腳本中的等效函數只需要 197 毫秒(在搭載 i7 處理器的 Surface Book 上的桌面版 Chrome,我測出的結果分別為 559 毫秒與 37 毫秒,雖然給出桌面平台的結果有些令人驚訝)。

這結果提示我們有必要對每個模塊使用多個 require() 函數的情況再進行一次測試。不過,我並不認為這對 Browserify 和 Webpack 會是一個公平的測試,因為 Rollup 和 Closure 都會將重複的 ES6 庫導入處理為一個的頂級變數聲明,同時也阻止了頂層空間以外的其他區域的導入。所以根本上來說,Rollup 和 Closure 中一個導入和多個導入的開銷是相同的,而對於 Browserify 和 Webpack,運行開銷隨 require() 函數的數量線性增長。

為了我們這個分析的目的,我認為最好假設模塊的數量是性能的短板。而事實上,「5000 個模塊」也是一個比「5000 個 require() 函數調用」更好的度量標準。

結論

首先,bundle-collapser 對 Browserify 來說是一個非常有用的插件。如果你在產品中還沒使用它,那麼你的包將相對來說會略大且運行略慢(雖然我得承認這之間的差異非常小)。另一方面,你還可以轉換到 Webpack 以獲得更快的包而不需要額外的配置(其實我非常不願意這麼說,因為我是個頑固的 Browserify 粉)。

不管怎樣,這些結果都明確地指出 Webpack 和 Browserify 相較 Rollup 和 Closure Compiler 而言表現都稍差,並且性能差異隨著模塊大小的增大而增大。不幸的是,我並不確定 Webpack 2 是否能解決這些問題,因為儘管他們將 從 Rollup 中借鑒一些想法,但是看起來他們的關注點更多在於 tree-shaking 方面 而不是在於 scope-hoisting 方面。(更新:一個更好的名字稱為 內聯 inlining ,並且 Webpack 團隊 正在做這方面的工作。)

給出這些結果之後,我對 Closure Compiler 和 Rollup 在 JavaScript 社區並沒有得到過多關注而感到驚訝。我猜測或許是因為(前者)需要依賴 Java,而(後者)仍然相當不成熟並且未能做到開箱即用(詳見 Calvin』s Metcalf 的評論 中作的不錯的總結)。

即使沒有足夠數量的 JavaScript 開發者加入到 Rollup 或 Closure 的隊伍中,我認為 npm 包作者們也已準備好了去幫助解決這些問題。如果你使用 npm 安裝 lodash,你將會發其現主要的導入是一個巨大的 JavaScript 模塊,而不是你期望的 Lodash 的 超模塊 hyper-modular 特性(require(&apos;lodash/uniq&apos;)require(&apos;lodash.uniq&apos;) 等等)。對於 PouchDB,我們做了一個類似的聲明以 使用 Rollup 作為預發布步驟,這將產生對於用戶而言儘可能小的包。

同時,我創建了 rollupify 來嘗試將這過程變得更為簡單一些,只需拖動到已存在的 Browserify 工程中即可。其基本思想是在你自己的項目中使用 導入 import 導出 export (可以使用 cjs-to-es6 來幫助遷移),然後使用 require() 函數來載入第三方包。這樣一來,你依舊可以在你自己的代碼庫中享受所有模塊化的優點,同時能導出一個適當大小的大模塊來發布給你的用戶。不幸的是,你依舊得為第三方庫付出一些代價,但是我發現這是對於當前 npm 生態系統的一個很好的折中方案。

所以結論如下:一個大的 JavaScript 包比一百個小 JavaScript 模塊要快。儘管這是事實,我依舊希望我們社區能最終發現我們所處的困境————提倡小模塊的原則對開發者有利,但是對用戶不利。同時希望能優化我們的工具,使得我們可以對兩方面都有利。

福利時間!三款桌面瀏覽器

通常來說我喜歡在移動設備上運行性能測試,因為在這裡我們能更清楚的看到差異。但是出於好奇,我也分別在一台搭載 i7 的 Surface Book 上的 Chrome 52、Edge 14 和 Firefox 48 上運行了測試。這分別是它們的測試結果:

Chrome 52 (查看錶格)

Chrome 結果

Edge 14 (查看錶格)

Edge 結果

Firefox 48 (查看錶格)

Firefox 結果

我在這些結果中發現的有趣的地方如下:

  1. bundle-collapser 總是與 slam-dunk 完全不同。
  2. Rollup 和 Closure 的下載時間與運行時間之比總是非常高,它們的運行時間基本上微不足道。ChakraCore 和 SpiderMonkey 運行最快,V8 緊隨其後。

如果你的 JavaScript 非常大並且是延遲載入,那麼第二點將非常重要。因為如果你可以接受等待網路下載的時間,那麼使用 Rollup 和 Closure 將會有避免界麵線程凍結的優點。也就是說,它們將比 Browserify 和 Webpack 更少出現界面阻塞。

更新:在這篇文章的回應中,JDD 已經 給 Webpack 提交了一個 issue。還有 一個是給 Browserify 的

更新 2:Ryan Fitzer 慷慨地增加了 RequireJS 和包含 Almond 的 RequireJS 的測試結果,兩者都是使用 AMD 而不是 CommonJS 或者 ES6。

測試結果表明 RequireJS 具有 最大的包大小 但是令人驚訝的是它的運行開銷 與 Rollup 和 Closure 非常接近。這是在運行 Chrome 52 的 Nexus 5 下限制網速為 3G 的測試結果:

Nexus 5 (3G) RequireJS 結果

更新 3: 我寫了一個 optimize-js ,它會減少一些函數內的函數的解析成本。

via: https://nolanlawson.com/2016/08/15/the-cost-of-small-modules/

作者:Nolan 譯者:Yinr 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國