node_modules 现状

这张图想必前端同学都不陌生,当前吐槽的 node_modules 的依赖问题,从 2020 年回过头来看,不仅没有解决,反而越来越明显。我们看很多包的时候都是,“WTF,我啥时候安装过这个依赖?”的状态,大家可以看看自己前端项目里面的 node_modules,没有 500M 都不好意思说自己是做前端的,而在这些依赖当中,有多少是真的要用在最终产品里面的依赖呢?又有多少是开发过程、构建过程中,工具的依赖呢?

笔者做了一个简单的实验:

  • 单独安装 React 和 ReactDOM,只占用 3.9M 空间。
  • 单独安装 Vue,也只占用 3.6M 空间。
  • 使用 create-react-app 创建一个空白 React 项目,占用 189.6M 空间。
  • 使用 vue-cli 创建一个空白 Vue 项目,占用 164.5M 空间。

虽然不能代表全部情况,但是想必也能反映一些问题,大家可以回忆一下自己的开发过程中用到了哪些工具:打包工具、开发服务器、测试框架、各种 linters、TypeScript 编译器、Babel 等等。这些工具又都有自己的依赖,子又生孙,孙又生子,子子孙孙无穷尽也,很快啊,你的硬盘就装不下了。而这些工具依赖,只是在开发和构建过程中使用,甚至是在不同的阶段才会使用,比如很多单元测试,其实是在线上 CI 的过程才会跑,但是却都会一股脑儿的装进 node_modules 文件夹里,和业务依赖搅在一起。

DevDependency 带来的问题

工具依赖与业务依赖共用 node_modules,带来的不仅是文件夹莫名增大,npm install 缓慢的问题,同时更会带来依赖版本漂移,引起各种莫名其妙的 BUG。

前端工程发展到今天,已经进入一个复杂度暴涨的时代,这是由于前端要处理的资源种类暴涨带来的,不同的资源,又需要不同的工具来进行处理,再叠加上前端技术的高速迭代,5年的时间,构建工具就从 Grunt、Gulp 变化到了 Webpack、Parcel、Rollup,未来更有 Vite、Snowpack、Esbuild 等,这样高速的工具更新,再乘上资源种类的增长,带来的是工具复杂度的急速提升,同时也带来对于工具版本控制的强烈依赖。而在目前 semantic version 的管理方法下,一个小小的业务依赖的 npm install 下,都有可能引起工具依赖各种未知的版本漂移,对于整个构建过程的稳定性,都带来极大的挑战。删除重装一时爽,版本不对火葬场。

另一方面,随着前端项目越来越复杂,越来越多的前端项目,采用 Monorepo 的架构,并且需要经过线上的 CI 流程,进行发布,而现在的 devDependency 的设计方式,并不能适应于这样的构建方式。

my-mono-repo
├── package.json
└── packages
    ├── A
    │   ├── package.json
    │   ├── node_modules
    │   └── src
    ├── B
    │   ├── package.json
    │   ├── node_modules
    │   └── src
    └── C
        ├── package.json
        ├── node_modules
        └── src

先说说 Monorepo 的问题,上面是一种很自然的目录设计,A、B、C 各自有各自的 node_modules,而这就带来一个问题,devDependency 安装在哪里,如果安装在各自的 node_modules,那么大量的空间实际被冗余的工具依赖占据,如果说要统一安装在父目录的 node_modules 里,那么又需要解决解决不同子目录依赖的版本问题,即使可以使用 lerna 等工具进行自动的管理,在子目录下的 npm install 也有可能引起父目录中某些共同依赖的版本漂移,对其他子目录的开发、构建引入未知的变动。

除了不适用 Monorepo 架构之外,在线上 CI 的过程中,devDependency 的设计也会带来各种问题。首先,冗余的 node_modules 带来的是对于空间和网络更大的开销,使得 CI 过程中环境初始化的过程更长,其实整个 CI 过程中,并不会用到 devDependency 中的所有工具依赖,比如打包、Lint、测试等过程,依赖的工具都不一样,但是每一步都需要下载全量的 devDependency;另一方面,业务依赖的升级,也往往使得工具依赖在不知不觉中被动升级,从而导致之前缓存的 devDependency 失效,如果没有及时清空缓存,更新版本,很容易导致构建与开发环境的不一致,引起未知的版本问题。这一切脆弱性的源头,都在于目前前端项目的复杂性,已经超过了当初设计的 devDependency 的负载,把 devDependency 和 dependency 不加区分的都放在 node_modules 里面,就像打鸡蛋的时候,把鸡蛋壳也搅进去了,然后还得把鸡蛋壳从打好的蛋液里挑出来,无奈~

未曾设想的道路

写到这里,我们已经谈了很多 devDependency 带来的问题,那么我们如何解决这些问题呢?

首先,我们先要定义问题的根本原因是什么,这里我直接说出我的结论,这一切问题的原因,在于工具依赖与业务依赖未做到关注点分离。如果大家有一些其他编程语言的使用经验,可以回想一下,无论是 Python,还是 Java、C++,从来都没有将工具依赖与业务依赖混装在一起过,这是因为两者的作用、更新频率、使用要求,都不一样,对于业务依赖,我们最终是要集成进产品中去的,是带有业务属性的,需要能够及时解决业务问题,更新频率上会频繁一些,尤其是在现在 Monorepo 和私有 NPM 盛行的当下;而对于工具依赖,我们的需求是稳定、统一、高效,并不需要频繁的变更,或者说即使变更,也应该对业务开发者是无感和透明的,更不能因为业务依赖的变更,就导致工具的不稳定。

既然我们找到了问题的根源,那么我们的解决方案就显而易见了:

一方面,对于 devDependency 的工具依赖,我们将其从 node_modules 里面拆离出来,更进一步,我们可以把这些工具依赖封装成一个团队专属的 build 工具,然后每个业务开发的同学只需要将其安装到全局,在自己的项目里甚至连 Babel 都不需要,就安装几个业务上需要的依赖,这样的开发体验,岂不爽哉!对于封装的工具,可以交给专门的构建小组进行维护,甚至可以封装成二级制的包,比如采用 pkgdeno compile 更进一步的提高效能。

另一方面,对于dependency 的业务依赖,我们可以继续留在 node_modules 里面,更进一步,我们可以将 node_modules 纳入到 git 的版本控制中。由于工具依赖已经拆离出去了,剩下的都是业务依赖,本来就是要构建到最终产品中的,我们需要保证在各个环境中的强一致性,同时拆离了工具依赖的 node_modules 大小也会降到一个合理的水平,纳入到 git 的控制下,并不会带来多大的额外开销。

既然已经有了指导方向,那么我们现在可以开始着手进行具体的改造了:

首先,最简单快捷的方式,便是将 dependency 和 devDependency 分别拆分到两个 package.json 中,然后将 devDependency 的目录结构提升一个层次,利用 node.js 的模块层层向上查找的特性,基本不需要改动任何代码,即可完成对于 dependency 和 devDependency 的拆分,具体目录结构如下

|-- node_modules # 安装 devDependency 的依赖
|-- package.json # 记录 devDependency 的依赖
|-- myApp
    |-- node_modules # 安装 dependency 的依赖
    |-- src # 业务代
    |-- package.json # 记录 dependency 的依赖
|-- .gitignore

接着,我们在 .gitignore 文件中,排除掉安装 devDependency 依赖的 node_modules,而安装 dependency 依赖的 node_modules 则需要保留在 git 仓库中,具体内容如下

node_modules
!myApp/node_modules

最后,建议将最外层的 package.json 中依赖库的版本锁定,或者交由专门的同学进行统一管理,业务的同学只需要关心自己的业务依赖。

当然,以上的方案只是最简单的改造,主要是为了给大家一个可以参考的思路,基本思想就是关注点分离,工具的归工具,业务的归业务,对于不同项目的实际情况,大家可以在以上思路的基础上,更进一步的摸索,找到最符合自己团队的维护方式。

对未来的一点展望

在前端工程化的发展过程中, node.js 的作用可谓居功至伟,甚至可以说,正是有了 node.js,才真正带领前端走到了工程化的领域,以前虽然也有通过 Java 或者 Python 来处理前端代码的应用,但是对于前端程序员来说,需要再掌握另一门语言,始终总是感觉隔着一层窗户纸,而将这层窗户纸捅破的正是 node.js。

我们不应该忽视 node.js 对于前端工程化带来的贡献,同时我们也要意识到 node.js 在设计上的局限性,毕竟最初 node.js 的设计目的,无论是 Common.js 的模块规范,还是 module.paths 的依赖路径查找方式,在最初设计时,更多是在为了使用 node.js 进行服务端编程服务的,其使用的 dependency 和 devDependency 的依赖安装方式,也并不是专门为了前端工程化来设计的,这导致的一个问题就是,我们在享受 node.js 带来的工程化的能力时,也由于前端项目本身的特点,使得直接采用 node.js 的依赖管理方式变得脆弱、不可靠。

前端工程化发展到今天,也面临着越来越多的挑战:

  1. vite 引领的 bundless 潮流下,前端工程化该怎么做?
  2. monorepo 架构下,怎么保证开发、构建的效率?
  3. 微前端架构下,又该如何开发、构建?

这些新的情况,都是当初设计 node.js 时,人们所未曾面对过的全新情况,我们不能要求 node.js 的设计者在一开始,就把各种情况都考虑的面面俱到,那是不现实的,我们更应该做的,是去分析问题的本质,在前人的肩上更进一步,去找到更适合当前情况下的解决方案。

本文也只是尝试从 dependency 和 devDependency 入手,来剖析目前使用 node.js 进行前端工程化的一些问题,剖砖引玉,望能给诸位读者带来一些不一样的视角,有任何问题,欢迎在评论区留言,一起探讨:)~

03-05 23:25