阅读 2231

浅聊前端依赖管理及优化(上)

一、引子

在npm、yarn等包管理工具的帮助下,前端工程化模块化的进程日益加快,项目的依赖包也日益增大,特别是若项目采用webpack来构建用到许多webpack的插件、一些辅助开发如(eslint、postcss、dev-server之类的库)以及一些单元测试(jest、mocha、enzyme)可能需要用到的插件,项目中的node_module就会变的十分庞大。

如图:

如果搭配这种情况是不是很绝望:

因此团队开发者每次重新初始化项目进行npm install会十分缓慢,并且若占用大量空间,更进一步来说项目中往往很多前端团队会用到一台构建服务器,对前端项目的代码获取、打包、构建、部署进行管理(例如笔者自己所在的团队就在使用jenkins对前端项目的打包、构建、部署进行自动化托管)。npm这样的项目级依赖管理的特性就会造成大量的时间以及资源的消耗。

同时笔者也想在文章开始之前表明一个态度,npm本身portable的模块化设计,使每个项目互不干系,也是一种它设计的精华所在,本文仅是针对实际使用中遇到的一些小困扰进行解读,希望提供一个新的思路。

补充一下,实际yarn已经有了解决方案 - workspaces:

Workspaces in Yarn

本文暂不把yarn容纳到讨论范围内(实际思路很相似,笔者也是写完之后才发现的)。

此本篇文章就在这样的背景下诞生了。纯粹是笔者在项目中积累的一些经验,若有不足望指出。

二、先来说说示例

github仓库地址:github.com/Roxyhuang/n…

通常现代前端的标准工程往往具备以下功能,为了让这个讨论更贴近实际开发,并且更直观,因此规定示例项目基本具备以下这些能力:

(1) 提供dev-server

(2) 提供语法转译(babel)

(3) 能够解析样式

(4) js代码风格插件以及样式风格检查

(5) 单元测试

(6) 提供样式兼容处理

当然以上部分只体现的依赖层面。因此可能有些依赖的引入可能出现错误或者不合理。

并假设目前团队使用了webpack和parcel两套前端构建方案,团队项目中有使用了React和Vue。

三、问题的总结

若不对项目做任何优化以及设置正常项目的依赖的结构应该是这样的:

如图:

然后我们再看一下node_modules整体所占的空间:

情况一:多个依赖模块完全相同的项目

这种情况相对很常见,实际就是相同依赖的项目有多个

如图:

可以想象这样模块的容量便是162.2mb x 3,同时文件数也会是1072 x 3。

由此可见实际我们对容量的消耗是是十分大的,笔者做了非精确统计实际:

生产中所需的模块容量与文件数(dependencies):

本地开发和测试所需的模块容量与文件数(devDependencies):

实际可以看到devDependencies才是我们整体依赖体积与数量如此庞大的“元凶”。

当然有部分生产中全局所需的库(如:React Vue)可以采用外部化,通过CDN来引入。(即图上CDN模块的复用)

情况二:仅生产中所需的模块依赖依赖重复 (dependencies)

如图:

生产中所需的模块容量与文件数(dependencies):

仅图中dependencies中红色标注的react-redux,redux版本以及使用了mobx react-mobx更换

本地开发和测试所需的模块容量与文件数(devDependencies):

无变化

实际这种情况和情况一类似只有dependencies中部分模块的依赖有差异或者版本不同。当然再进一步,这个部分每个项目确实可能出现较大的差异,同时着部分差异可能会引发devDependencies也出现变化(如:React Vue 可能用到webpack Loader eslint plugins等,之后会提及这块)。

实际依然devDependencies才是我们整体依赖体积与数量如此庞大的“元凶”。

情况三:本地开发和测试所需的包依赖重复且,但不存在版本差异 (devDependencies)

如图:

这种情况我们也囊括了情况二的差异(由于dependencies产生的差异,同时也会影响devDependencies的)

生产中所需的模块容量与文件数(dependencies):

如图所示,除了情况中出现的react-redux,redux版本有差异,由于出现了webpack + vue的项目,项目中dependencies的依赖需要引入vue或vue-router,除此都是一致的。

本地开发和测试所需的模块容量与文件数(devDependencies):

由于我们需要引入vue,导致我们devDependencies需要产生一些变化包括移除一些webpack与react相关的插件,eslint与react的插件等,变更为webpack与vue相关的插件,eslint与vue的插件等。

如图笔者非精确的统计一下这种情况仅devDependencies下的容量与文件数:

还是依然很大,并且实际还是存在大量的依赖重叠(例子中有17个依赖重叠)。

情况四:多个构件工具,存在本地开发和测试所需的包依赖重复,且存在在版本差异, (devDependencies)

这种是目前最复杂的情况,存在下列几个情况

(1) 前端构件方案存在多个, 例子中为webpack 和 parcel

(2) 生产中所需的模块存在差异,甚至技术栈不同

(3) 本地开发和测试所需的包依赖重复,但有略微不同,且存在版本不同

如图:

这种情况我们也囊括了情况二的差异,可以看到同一个构件工具下存在本地开发和测试所需的包依赖重叠的情况依然是较多的。

因此实际如果出现这种情况,我们仍需要考虑以及解决。

小小的总结一下:

结合情况一情况二情况三情况四的例子可以得出几点小小的“规律”:

(1) devDependencies无论从模块所占体积还是文件数量都在整体依赖模块中所占的比较较大

(2) dependencies下的包,可能会因为项目不同差异较大,同时也会导致devDependencies下模块有差异

(3) 即使devDependencies可能项目存在差异,但是仍有可能一部分重叠

(4) 多个模块化方案,可能会导致项目devDependencies差异较大

我们可以发现其中不管前三种情况的哪一种devDependencies都会有一部分的依赖模块是重叠的。那既然存在重复,那我们就有必要考虑“复用”

同时我们发现多个模块化方案(webpack, parcel)devDependencies下的模块差异会较大,这也是我们之后需要解决的。

锁定依赖模块的能力:

非本文重点,仅简述一下:

(1) lock or dont'lock

这个问题社区探讨的老问题,锁不锁都有支持的一方,然而这边笔者不表明态度,但是同时也希望我们之后的依赖管理的优化和“瘦身”能够具备锁定依赖的能力。

(2) npm 锁定依赖以及锁定依赖的内部循环的方式

  • 使用语义版本控制来锁定版本:

    关于semantic-versioning,这里我们不赘述,有兴趣可以看一下npm文档:

    docs.npmjs.com/about-seman…

    但是通过semantic-versioning来锁定版本,无法锁定依赖的内部循环(即依赖的依赖)。

  • npm-package-locks来锁定

    关于npm-package-locks,这里我们不赘述,有兴趣的可以看一下:

    docs.npmjs.com/files/packa…

我们关注的重点是我们需要依赖package-lock.json,来协助锁定依赖的版本以及依赖内部循环的版本。

再总结一下:

(1) devDependencies无论从模块所占体积还是文件数量都在整体依赖模块中所占的比较较大

(2) 项目的dependencies可能会因为项目不同差异较大,同时一部分可以外部化,因此不在本文想要探讨“瘦身管理”的范围内,列出主要是以便更接近现实状况

(3) 即使devDependencies可能项目存在差异,但是仍有大量的依赖重复与重叠,根据实际包的分析他们是目前依赖“瘦身管理”的主要目标

(4) 即使dependencies有差异的项目,devDependencies大部分可能相似(如情况三所示),因此我们仍可以提炼出一部分相同的devDependencies进行“瘦身管理”。

(5) 多个模块化方案,可能会导致项目devDependencie差异较大(如例子中webpack和parcel)可能会有一部分devDependencies可能版本也会不同,不建议两个项目差异极大的node_modules共享。

(6) 同一个模块可能会存在版本差异,因此我们可能会面对同时面对一个库存在多版本的情况

(7) 无论依赖锁定是否必要,但是希望提供的方案能够具备。

(8) 依赖锁定,需要依靠package-lock.json实现(shrinkwrap.json亦可)

四、优化方案以及存在的一些问题

下面的优化方案都依赖了NodeJS模块加载机制,因此先粗略的聊一下,这里不描述完整的过程,感兴趣的朋友可以查询官方文档或可以看一下朴灵大大的深入浅出NodeJS 。

如何利用NodeJS模块加载机制

简单来说 - 在NodeJS中引入模块,需要经历如下3个步骤:

  • 路径分析
  • 扩展名分析
  • 编译执行

我们利用的则是在路径分析中自定义文件模块(第三方npm包)的查找:

自定义文件模块查找顺序为:

  • 当前目录下node_modules目录
  • 父目录下node_modules目录
  • 向上逐级递归直到根目录下下node_modules目录
  • 递归至根目录

有点类似于JS的原型链查找,文件路径越深,模块查找越耗时,同时也是它慢的原因。

当然这是默认情况下,实际通畅我们可以通过配置NODE_PATH的方式,在递归至根目录后,若依然无法找到,给到一个路径或多个路径找到具体模块。

1. 配置NODE_PATH(方案一)

方案一实际就是利用了配置NODE_PATH,通常一般会将其配制成npm i -g所在的全局模块目录(实际可以改,本例暂定这个目录)。

结构变动

情况一、情况二、情况三都可以转化成如下结构:

情况三下为例子 - 如图:

具体实现:

在对应系统的环境变量配置文件中增加环境变量NODE_PATH,例如在MacOS中

vi /ect/profile  
# 或/etc/bashrc或~/.bashrc 
# 此处不赘述配置问题、加载顺序以及原理
复制代码
-> export PATH=$PATH: 
# 将 /usr/bin 追加到 PATH 变量中

-> export NODE_PATH="/usr/lib/node_modules;/usr/local/lib/node_modules"
# 指定 NODE_PATH 变量
复制代码

那 NODE_PATH 就是NODE中用来寻找模块所提供的路径注册环境变量。我们可以使用上面的方法指定NODE_PATH环境变量。并且用;分割多个不同的目录。

关于 node 的包加载机制我就不在这里赘述了。NODE_PATH中的路径被遍历是发生,从项目的根位置递归搜寻 node_modules 目录,直到文件系统根目录的 node_modules,如果还没有查找到指定模块的话,就会去 NODE_PATH中注册的路径中查找。

存在问题:

(1) 不会生成package.json,因此对依赖管理比较繁琐,实现增量安装比较繁琐

  • 当然其实可以不需要npm i -g去处理,可以手动自己维护一个目录包括package.json,以及package-lock.json,但如果这么只会形成只是一个线性关系,而非一个树状关系。

(2) 不支持同一模块同时存在不同版本,因此如果依赖出现版本差异,没有解决方案

  • 因为形成的是一个线性关系,而非一个树状关系实际项目若存同一依赖版本差异,就会有一个优先级的问题存在。

(3) 全局安装模块,无法生成package-lock.json无法锁定依赖内部循环(依赖模块的依赖)

  • 默认如果使用npm i -g安装,同样不会生成package-lock.json,因此无法锁定依赖内部循环。

2. 提升node_modules目录(方案二)

结合之前问题分析的我们得出的结论,因此实际上我们把项目中devDependencies依赖重叠的模块,在项目的父目录存放node_modules即可将依赖提升。即可以进行项目间的共享。

目前笔者的理想方案应该能够达到下列几个目的:

  • 可以将项目构建相似技术栈的项目统一在一起,共享依赖

  • 可以实现对公共模块的维护和管理,并实现增量安装

  • 对原来npm install流程改动不会太大

(1)结构变化后的依赖结构图

实际需要实现只要将项目目录改为以下结构

----|---wwwroot/ # 工作目录或部署目录
    |
    |---webpack/--|---webpack-react/---|---package.json
    |             |                    |---package-lock.json
    |             |                    |---node_modules/
    |             |                    |---src/
    |             |                    |
    |             |--- webpack-vue/--- |---package.json
    |             |                    |---package-lock.json
    |             |                    |---node_modules/
    |             |                    |---src/
    |             |---node_modules/     |
    |             |---package.json     |
    |             |---package.json-lock|
    |             |                    |
    |---parcel/---|--- parcel-react/---|---package.json
    |             |                    |---package-lock.json
    |             |                    |---node_modules/
    |             |                    |---src/
    |             |                    |
    |             |--- parcel-vue/ --- |---package.json
    |             |                    |---package-lock.json
    |             |                    |---node_modules/
    |             |                    |---src/
复制代码

情况一、情况二、情况三、情况四可以转化成如下结构:

情况四下为例子 - 如图:

说说目前笔者探索的两个实践:

(2)手工管理package.json

下一步说起来也十分简单:

实际只要在,维护一个package.json即可,实际常常只需要devDependencies,然后实际需要安装时只需要进行一次npm install即可,并且会生成package-lock.json。

即上面说到的这几个位置

|---webpack/--|---webpack-react/---|---package.json
|             |--- webpack-vue/--- |---package.json

|---parcel/---|--- parcel-react/---|---package.json
|             |--- parcel-vue/ --- |---package.json
复制代码

实际只要在devDependencies按正常package.json中的内容维护即可。

(3)利用 preinstall

“对原来npm install流程改动不会太大”,这一点上面对于手工管理方式实际并并不满足。并且手工进行管理十分繁琐,因此我们是否可以在npm install之前把devDependencies内重叠模块的node_modules进行提升呢?

答案是:可以的,主要利用的是npm script中的preinstall,关于npm script以及preinstall可以通过:docs.npmjs.com/misc/script…来了解。

npm的preinstall这个hook实际会在安装软件包之前运行。实际我们就可以通过preinstall去执行一些node.js的代码让我们的devDependencies内重叠模块的node_modules提升。

那具体来看一下步骤:

a.在项目package.json中增加preinstall
  "scripts": {
    "preinstall": "node build/scripts/preinstall.js",
    "start": "webpack-dev-server --mode development --hot --progress --colors --port 3000 --open"
  }
复制代码

这里我会在项目的build目录下的scripts执行preinstall.js这个文件内的代码

b.切换至项目父级目录(即约定的技术栈目录)

这里我仅进行了一个最简单的示范,其原理是在项目的package.json里增加devDependenciesGlobal 项:

实际可以看一下我提供的实现具体代码

实际最为关键的为:

const execPath = path.resolve('../');
const preListObj = preList.devDependenciesGlobal; // 项目package.json自己维护的devDependencies那些重叠依赖
const currentMd5 = md5(JSON.stringify(preListObj)); // 简单举例生成一个md5,实际应用中可以搭配其他机制
...

fs.writeFileSync(`${execPath}/package.json`, JSON.stringify({"md5": currentMd5,dependencies: preListObj})); // 在上级目录创建一个package.json文件并,写入md5以及devDependenciesGlobal的内容

let script = `cd ${execPath} && npm i`;

 // 切换至父级目录并执行npm install
 
exec(script, function (err, stdout, stderr) {
  console.log(stdout);
  if (err) {
   console.log('error:' + stderr);
  } else {
   console.log(stdout);
   console.log('package init success');
   console.log(`The global install in ${execPath}`);
  }
});
复制代码
c.实际所占空间的变化:

(此处就不贴图片了,大家可以clone实例自己试一下)

|---webpack/--|---webpack-react/---|---node_modules/ # 共21.1 MB,369项
|             |--- webpack-vue/--- |---node_modules/ # 共17.8 MB, 336项
|             |---node_modules/ # 共153.3 MB,994项
|             |
|---parcel/---|--- parcel-react/---|---node_modules/ # 89.4 MB,540项
|             |--- parcel-vue/ --- |---node_modules/ # 71.5 MB,491项
|             |---node_modules/ # 共21.1 MB,118项
复制代码

实际非常简单,即得执行目录的上级目录,此处为了举例我的例子里会在运行preinstall时根据devDependenciesGlobal生成一个md5,以便下次install时比对,若一致不执行preinstall内的流程。当然这并不是一个最佳实践(之后的各种实践方案在下篇中进一步给出)

当然实际这部分存在“无限”的可能性,可以根据自己的需求来完善(比如服务端获取devDependenciesGlobal以及md5)。

这仅仅是一个简单示范,实际可以通过配合一些服务端以及docker进一步提升。

存在问题:

(1) windows兼容性

笔者所在的团队在使用的过程中,有遇到windows的开发环境下的各种问题,比如:

  • win10下自定义的preinstall流程中的install执行十分缓慢,或无法执行
  • babel或抛出一些错误(这里不具体说明)

(2) 使用者无法做到无感知

也是最大的缺点,即使用的时候使用者会有感知,开发项目的时候要求使用一定是主动必须按特定的目录结构来安排自己的workspace。

(3) 本地npm版本,不一致,也可能对依赖造成的影响较大

(4) npm script相关的必须在devDependencies引入 (当然也可以通过一些方式避开,比如dev-server不直接使用npm script启动)

这仅仅是一个开始...待续

利用node模块加载机制,我们其实已经可以很大程度改进我们的依赖了,但如上所述我们还存在这些问题:

(1) windows兼容性

上文有提到过windows下,笔者实际实践发现会出现一些问题,是不是有办法可以统一环境呢?

(2) 无法做到让使用者到无感知

(3) 本地npm版本,对依赖造成的影响较大

(5) 管理公共依赖没有标准化和自动化

因此下篇笔者可能会主要进一步通过其他方案来配合这个解决方案

(1) 本文举例的完善版本 (通过shell远程下载公共依赖的package.json,也是笔者目前团队在使用的)

(2) 通过搭配docker进一步完善方案 (是笔者想进一步增强的)

(3) 搭配私有npm仓库

(4) 如何更标准化的管理公共依赖,使其可以自动化标准化

这里挖个坑下篇会更新这部分内容

结语以及本文不足之处

经过上述操作,我们便可以将前端项目根据框架进行依赖管理以及划分了,其实如此改进仍并非最完美以及优化,不足之处仍非常之多,期待更加优雅的解决方案,使我们前端项目的架构更佳健壮和灵活。也欢迎大家和我探讨和讨论,谢谢各位大佬能耐心看完。

关注下面的标签,发现更多相似文章
评论