大约一年之前,我在将一个大型 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中国”