大約一年之前,我在将一個大型 javascript 代碼庫重構為更小的子產品時發現了 browserify 和 webpack 中一個令人沮喪的事實:
“代碼越子產品化,代碼體積就越大。:< ” - nolan lawson
“超過 400 ms 的時間單純的花費在了周遊 browserify 樹上。” - sam saccone
在本篇文章中,我将示範小子產品可能會根據你選擇的打包器bundler和子產品系統module system而出現高得驚人的性能開銷。此外,我還将解釋為什麼這種方法不但影響你自己代碼的子產品,也會影響依賴項中的子產品,這也正是第三方代碼在性能開銷上很少提及的方面。
<a target="_blank"></a>
一個頁面中包含的 javascript 腳本越多,頁面加載也将越慢。龐大的 javascript 包會導緻浏覽器花費更多的時間去下載下傳、解析和執行,這些都将加長載入時間。
是以在小子產品下,你将不需要這樣:
<code>var _ = require('lodash')</code>
<code>_.uniq([1,2,2,3])</code>
而是可以如此:
<code>var uniq = require('lodash.uniq')</code>
<code>uniq([1,2,2,3])</code>
需要強調的是這裡我提到的“子產品”并不同于 npm 中的“包”的概念。當你從 npm 安裝一個包時,它會将該子產品通過公用 api 展現出來,但是在這之下其實是一個許多子產品的聚合物。
<code>$ npm install qs</code>
<code>$ browserify node_modules/qs | browserify-count-modules</code>
<code>4</code>
這說明了一個包可以包含一個或者多個子產品。這些子產品也可以依賴于其他的包,而這些包又将附帶其自己所依賴的包與子產品。由此可以确定的事就是任何一個包将包含至少一個子產品。
一個典型的網頁應用中會包含多少個子產品呢?我在一些流行的使用 browserify 的網站上運作 browserify-count-moduleson 并且得到了以下結果:
我構造了一個能導入 100、1000 和 5000 個其他小子產品的測試子產品,其中每個小子產品僅僅導出一個數字。而父子產品則将這些數字求和并記錄結果:
<code>// index.js</code>
<code>var total = 0</code>
<code>total += require('./module_0')</code>
<code>total += require('./module_1')</code>
<code>total += require('./module_2')</code>
<code>// etc.</code>
<code>console.log(total)</code>
<code></code>
<code>// module_1.js</code>
<code>module.exports = 1</code>
為了更好地模拟一個生産環境,我對所有的包采用帶 <code>-mangle</code> 和 <code>-compress</code> 參數的 <code>uglify</code> ,并且使用 gzip 壓縮後通過 github pages 用 https 協定進行傳輸。對于每個包,我一共下載下傳并執行 15 次,然後取其平均值,并使用 <code>performance.now()</code> 函數來記錄載入時間(未使用緩存)與執行時間。
在我們檢視測試結果之前,我們有必要先來看一眼我們要測試的封包件。以下是每個包最小處理後但并未使用 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
1649
13800
64513
1464
11903
56335
693
5027
26363
300
2145
11510
302
2140
11789
browserify 和 webpack 的工作方式是隔離各個子產品到各自的函數空間,然後聲明一個全局載入器,并在每次 <code>require()</code> 函數調用時定位到正确的子產品處。下面是我們的 browserify 包的樣子:
<code>(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 '"+o+"'");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</code>
而 rollup 和 closure 包看上去則更像你親手寫的一個大子產品。這是 rollup 打包的包:
<code>(function () {</code>
<code>'use strict';</code>
<code>total += 0</code>
<code>total += 1</code>
<code>total += 2</code>
如果你清楚在 javascript 中使用嵌套函數與在關聯數組查找一個值的固有開銷, 那麼你将很容易了解出現以下測試的結果的原因。
我選擇在搭載 android 5.1.1 與 chrome 52 的 nexus 5(代表中低端裝置)和運作 ios 9 的第 6 代 ipod touch(代表高端裝置)上進行測試。
nexus 5 結果
ipod touch 結果
在 100 個子產品時,各包的差異是微不足道的,但是一旦子產品數量達到 1000 個甚至 5000 個時,差異将會變得非常巨大。ipod touch 在不同包上的差異并不明顯,而對于具有一定年代的 nexus 5 來說,browserify 和 webpack 明顯耗時更多。
與此同時,我發現有意思的是 rollup 和 closure 的運作開銷對于 ipod 而言幾乎可以忽略,并且與子產品的數量關系也不大。而對于 nexus 5 來說,運作的開銷并非完全可以忽略,但 rollup/closure 仍比 browserify/webpack 低很多。後者若未在幾百毫秒内完成加載則将會占用主線程的好幾幀的時間,這就意味着使用者界面将當機并且等待直到子產品載入完成。
nexus 5 3g 結果
還有一件事需要指出,那就是這個測試并非測量 100 個、1000 個或者 5000 個子產品的每個子產品的精确運作時間。因為這還與你對 <code>require()</code> 函數的使用有關。在這些包中,我采用的是對每個子產品調用一次<code>require()</code> 函數。但如果你每個子產品調用了多次 <code>require()</code> 函數(這在代碼庫中非常常見)或者你多次動态調用 <code>require()</code> 函數(例如在子函數中調用 <code>require()</code> 函數),那麼你将發現明顯的性能退化。
reddit 的移動站點就是一個很好的例子。雖然該站點有 1050 個子產品,但是我測量了它們使用 browserify 的實際執行時間後發現比“1000 個子產品”的測試結果差好多。當使用那台運作 chrome 的 nexus 5 時,我測出 reddit 的 browserify require() 函數耗時 2.14 秒。而那個“1000 個子產品”腳本中的等效函數隻需要 197 毫秒(在搭載 i7 處理器的 surface book 上的桌面版 chrome,我測出的結果分别為 559 毫秒與 37 毫秒,雖然給出桌面平台的結果有些令人驚訝)。
這結果提示我們有必要對每個子產品使用多個 <code>require()</code> 函數的情況再進行一次測試。不過,我并不認為這對 browserify 和 webpack 會是一個公平的測試,因為 rollup 和 closure 都會将重複的 es6 庫導入處理為一個的頂級變量聲明,同時也阻止了頂層空間以外的其他區域的導入。是以根本上來說,rollup 和 closure 中一個導入和多個導入的開銷是相同的,而對于 browserify 和 webpack,運作開銷随<code>require()</code> 函數的數量線性增長。
為了我們這個分析的目的,我認為最好假設子產品的數量是性能的短闆。而事實上,“5000 個子產品”也是一個比“5000 個 <code>require()</code> 函數調用”更好的度量标準。
首先,bundle-collapser 對 browserify 來說是一個非常有用的插件。如果你在産品中還沒使用它,那麼你的包将相對來說會略大且運作略慢(雖然我得承認這之間的差異非常小)。另一方面,你還可以轉換到 webpack 以獲得更快的包而不需要額外的配置(其實我非常不願意這麼說,因為我是個頑固的 browserify 粉)。
是以結論如下:一個大的 javascript 包比一百個小 javascript 子產品要快。盡管這是事實,我依舊希望我們社群能最終發現我們所處的困境————提倡小子產品的原則對開發者有利,但是對使用者不利。同時希望能優化我們的工具,使得我們可以對兩方面都有利。
通常來說我喜歡在移動裝置上運作性能測試,因為在這裡我們能更清楚的看到差異。但是出于好奇,我也分别在一台搭載 i7 的 surface book 上的 chrome 52、edge 14 和 firefox 48 上運作了測試。這分别是它們的測試結果:
chrome 結果
edge 結果
firefox 結果
我在這些結果中發現的有趣的地方如下:
bundle-collapser 總是與 slam-dunk 完全不同。
rollup 和 closure 的下載下傳時間與運作時間之比總是非常高,它們的運作時間基本上微不足道。chakracore 和 spidermonkey 運作最快,v8 緊随其後。
如果你的 javascript 非常大并且是延遲加載,那麼第二點将非常重要。因為如果你可以接受等待網絡下載下傳的時間,那麼使用 rollup 和 closure 将會有避免界面線程當機的優點。也就是說,它們将比 browserify 和 webpack 更少出現界面阻塞。
nexus 5 (3g) requirejs 結果
原文釋出時間為:2017-11-17
本文來自雲栖社群合作夥伴“linux中國”