最近接到一个需求是将前端应用的 node_modules瘦身,目的是减少安装依赖所需时间。

下面记录下我的处理过程:

一、确定衡量标准及测量工具

目的很明确,减少安装依赖所需时间。目标平台是无SSD硬盘的Windows系统,使用自建npm镜像。

减少依赖安装时间有两个主要的策略,一是镜像加速,二是减少需要安装的包的数量,即瘦身。

镜像加速之前已经有自建镜像了,因此本次只做安装包的数量方面的瘦身。

衡量标准有以下几个:
uu

标准 稳定性 相关性 单位 初始值 当前值
npm包数量 稳定可重测 1617 1617
node_modules体积 稳定可重测 MB 473 473
安装时间 不稳定,多次测试之间有误差 目标本身 183.42 183.42

安装时间是目标本身,但是不够稳定,受干扰比较多。所以要多次测量求平均值,同时使用其他的稳定标准(如需要安装的包的数量、node_modules体积等)辅助参考。

使用测试工具如下:

  1. npm包数量和安装时间:删除 node_modulespackage-lock.json之后,执行 npm install --registryhttp://ynpm.yonyoucloud.com/repository/ynpm-all/ ,执行完成后会有一条信息报告安装包的数量和所耗费的时间。如 added 1617 packages from 1047 contributors in 183.42s
  2. node_modules体积:du -h -d 0 node_modules

二、观察现状

如上所述,待优化的工程目前的npm包数量是1617,安装所耗费的时间是183.42s。知道这个信息能提供很好的衡量标准,但是还不足以指导优化方向。

2.1 提出假设

我们可以提个假设,npm包体积与下载时间正相关,也与安装时间正相关,它大概率上是成立的。基于这个假设,我们可以通过降低 node_modules 的体积,来达到减少安装时间的目的。

分析下现在 node_modules中各个子目录的体积:

使用如下的命令:du -h -d 1 node_modules | sort -h 就可以得到按文件体积从小到大排列的子目录列表,如下图所示:

image.png

从图中可以看出各个包(或scope)占用的体积。上图中 antd、echarts、antd-mobile等包占据了较多的体积。

2.2 验证假设

为了验证之前的假设有效,我们在package.json中将比较大的几个包删除掉,再重新npm install试一下,看看结果是不是符合预期。
操作:从package.json中删除 antd、echarts、react-multi-crops、@icons、caniuse-db、antd-mobile、@mdf、@antv、rxjs(共占据199MB的体积,约47%),然后重新安装。
期望结果:安装时间下降 30% ~ 60% 之间。
实际结果:added 1314 packages from 828 contributors in 97.407s,安装时间下降了46%。
结论:实际结果符合期望,且表现出很强的相关性,假设成立。

2.3 小结

因为node_modules的体积与其安装时间有较强相关性(2.2中验证),所以我们的优化方向可以转变为更好测量的 node_modules体积上,且应该先从单包体积较大的入手。

实际上有些包不是项目直接依赖,而是某个依赖包二次依赖的的,如rxjs是因为被concurrently工具依赖的才会安装上的。这些包在衡量的时候,应该算到依赖方上。不过这个暂时不用过多考虑,具体情况具体分析即可。


三、可能的优化思路

既然现在优化的方向是减少包的体积,那么就自然地会有如下的一些优化方向:

3.1 提前打包依赖

以antd举例,它的包的体积达到了39MB,而一般来说一个前端应用构建后的bundle体积在5M以内,甚至更小,即使加上 source-map,也少于 antd 的体积。再算上 antd-mobile 等,预先打包 bundle 可能就能节省 (39 + 13 - 5) = 43M 这样的体积。
如果这个方式可行,能节省出较大的空间,大大降低node_modules的体积及下载所需时间。
这种方式可能还可以考虑像 electron-prebuilt 那样,直接发布二进制包,不过对项目的要求比较高,应该也会丧失较多的灵活性,所以暂时不考虑这方面。

3.2 Cli工具全局安装

上面提到的rxjs,占据了11M的空间,其实只是 concurrently 这个小工具的一个依赖项。我们项目对concurrently工具的依赖不太强烈,基于属于可有可无的状态。为此耗费这么多的空间,似乎不是那么值得。
另外像rimraf,cross-env等跨系统兼容cli工具,也可以考虑全局安装。

3.3 删除无用依赖

有些依赖项,可能在应用中已经不再使用了,但是由于历史遗留原因,没有清除。
使用类似 depcheck 之类的工具,可以检测出哪些依赖没有被使用,然后删除掉它们(需要注意不要误删一些cli工具,像webpack之类的)。
可以重点检查下babel插件、babel preset、webpack插件等。

3.4 依赖替换

这就涉及到代码上的变更了。对于一些体积较大的包,可以考虑使用同质的更小的包来实现。
比如用dayjs替换moment。


四、动手操作

从之前的分析结果可以看出,占据主要体积的是组件库相关的包,如antd、antd-mobile、@antv等,这部分会是优化中的重点,同时它也存在难点。
同时占据一定的体积,又比较好实现的,是一些cli工具包。柿子先挑软的捏,我们先从cli工具下手。

4.1 删除一些cli包,改为全局安装

可以先删除一些明显的、不需要本地安装的cli包,然后再查看 node_modules/.bin 目录中有哪些命令,看看有没有遗漏。
在示例项目中,我找到了 concurrently、cross-env、nodemon这几个包,删除掉改成全局安装。
类似babel、webpack之类的,因为不同版本之间差异比较大,不适合全局安装。
删除这几项之后,node_modules体积变化为 473MB -> 450MB。
优化不明显。

4.2 检测和删除无用包

使用depcheck工具可以检测出未使用的包。当然其中也会包括一些cli工具,这部分要忽略掉。
在示例项目中,使用depcheck工具查找到有下列包未被使用:

  1. eslint
  2. eslint-plugin-react
  3. SvgIcon
  4. axios
  5. css-format
  6. crypto-js
  7. react-multi-crops
  8. react-sortablejs
  9. sortablejs
  10. css-sourcemaps-webpack-plugin

删除这些包之后,node_modules的体积变为化 450MB -> 442MB,变化不大。
看来还是应该从大头出发。

4.3 测试打包到dll中的文件可否不再安装

尝试将已经在dll中的文件删除掉(react、react-dom等),然后尝试构建。
但是构建失败了,所以这种方式不可行,即使文件已经打包进了dll,还是需要本地安装的。

4.4 尝试将部分包打包成bundledDependencies

npm有提供一种机制,叫bundledDependencies,可以在发布npm包的时候,将指定依赖包同时打入package中。
这样虽然没有降低整体的体积,但是压缩成一个文件后下载可能会快一些?

可惜尝试了下,发现安装时间不降反升,这种方法不可取。

4.5 使用webpack、rollup等提前构建依赖

这种方法适用于减少依赖包的附属依赖。

比如 A 包依赖了 antd,其他包都没有。那么可以考虑将A提前build一下,出来一个 library.js、library.min.js。然而它有一些局限性:

  1. 不能解决主工程依赖的问题。如antd既在A中依赖,也在主工程中依赖,因为主工程不能打包,还是需要安装antd。
  2. 一般提供包的时候,library.js、library.min.js是和源代码一同发布的,有些dependency一般还是安装的,方便调试。如果只提供library打包后的文件,不提供源码,或虽然提供源码但是需要单独安装依赖,都不太便于调试。

    4.6 将固定依赖提交到代码仓库

    可以将依赖压缩成一个zip提交到代码仓库,这样不会污染git diff。
    但是它也有较大的成本,git仓库的体积可能会变很大,尤其在这个zip文件经常发生变化的时候。

    4.7 使用externals,将比较大的依赖换成CDN引用

    可以将一些关键包替换掉,但是需要库本身支持这种使用方式,像react/react-dom之类的库很容易就可以实现externals,其它的包就得分别验证了。
    同时externals不能使用dll中打包的资源,要从html link中引入,有可能存在命名空间冲突等问题。
    首先分析下有哪些包可以通过CDN引入:
  • react
  • react-dom
  • antd
  • antd-mobile
  • echarts-for-react
  • echarts
  • async-validator
  • cookies-js
  • classnames
  • immutable 可使用CDN,但是原来在使用多个版本,有一定风险
  • isomorphic-fetch
  • fixed-data-table-2
  • ~~ keymirror~~
  • ~~ ‘redux’~~
  • ~~ ‘echarts-for-react’~~
  • ~~ ‘rc-calendar’~~
  • ~~ ‘rc-form’~~
  • ~~ ‘rc-input-number’~~
  • ~~ ‘rc-notification’~~
  • ~~ ‘rc-pagination’~~
  • ~~ ‘rc-select’~~
  • ~~ ‘rc-slider’~~
  • ~~ ‘rc-table’~~
  • ~~ ‘rc-tooltip’~~
  • ~~ ‘rc-tree’~~
  • ~~ ‘rc-tree-select’~~

除了明确支持 external 的包,比如react/react-dom,其余的都不太适合这种方式。
另外有可能因为使用这种方式,使原来不同的版本强制转换成了一个相同的版本,有可能引入bug。
还得考虑到,使用这种方式后,不容易更新依赖包的版本。
综上:这种方式无法大规模推广。

4.8 替换一些相似的包

比如使用 dayjs 代替 moment,还有将项目中同时依赖 moment / dayjs 这种情况优化掉,类似这种思路。

这样需要修改源码,有可能影响功能,需要慢慢调整。而且可预期效果不会特别理想。


未完待续