内容简介(方便想要快速了解文章内容结论的同学)
- 先上结论,Node.js 将依赖分为 dependency 与 devDependency 两部分,但是却公用同一个 node_modules 文件夹的方式,在当下越来越复杂的前端项目开发过程中,已经不适用了。
- 工具依赖与业务依赖共用同一个 node_modules 文件夹,会使得开发与构建过程变得低效与脆弱,包括以下问题:node_modules 臃肿低效、版本漂移、Monorepo 架构不适配、CI 构建过程脆弱等。
- 解决方法是关注点分离,将业务依赖和工具依赖单独放置,一个思路是业务依赖纳入版本管理系统,工具依赖进行 precompile,并由专门的构建团队进行发布,开发者可以选择版本进行全局安装。
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 下,都有可能引起工具依赖各种未知的版本漂移,对于整个构建过程的稳定性,都带来极大的挑战。删除重装一时爽,版本不对火葬场。
关于 Lock 我要再说两句,Lock 的初衷是好的,希望能够通过 Lock 文件解决依赖版本不一致的问题,但是大家在使用过程中,想必都遇到过 npm install 新包的时候,和 Lock 文件冲突的情况,这时候怎么办呢?删除 node_modules,重新安装呀,那么恭喜你,喜提版本漂移大礼包~
另一方面,随着前端项目越来越复杂,越来越多的前端项目,采用 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 都不需要,就安装几个业务上需要的依赖,这样的开发体验,岂不爽哉!对于封装的工具,可以交给专门的构建小组进行维护,甚至可以封装成二级制的包,比如采用 pkg、deno compile 更进一步的提高效能。
另一方面,对于dependency 的业务依赖,我们可以继续留在 node_modules 里面,更进一步,我们可以将 node_modules 纳入到 git 的版本控制中。由于工具依赖已经拆离出去了,剩下的都是业务依赖,本来就是要构建到最终产品中的,我们需要保证在各个环境中的强一致性,同时拆离了工具依赖的 node_modules 大小也会降到一个合理的水平,纳入到 git 的控制下,并不会带来多大的额外开销。
关于把 node_modules 纳入 git 的管理,是否会使得开销过大,这里我们可以设想一下,在任何一个长期运行的项目中,业务依赖相对于自有代码,最多比例也就在 1:1,不可能会出现在一个成熟的商业项目中,自己写的代码还没有引入的依赖大,同时因为业务依赖最终是要打包成产品,发布到网上的,所以我们也有动力,去最大程度上缩减业务依赖的大小。综上所述,将业务依赖纳入版本管理的成本,相对于带来的强一致性的好处来说,是可以接受的。
既然已经有了指导方向,那么我们现在可以开始着手进行具体的改造了:
首先,最简单快捷的方式,便是将 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
这里将 dependency 依赖纳入 git 管理,有利有弊,坏处是会导致 git clone 下载的文件变大,好处是一方面我们可以通过 git 来保证业务依赖的强一致性,只要从同一个分支 checkout 出来的代码,业务依赖一定是完全一样的,另一方面如果有同学新增或者修改了业务依赖,也能够被 git 进行记录下来,做到变更的可追溯化,更进一步,还可以针对这种情况,进行专门的依赖评审,这在之前只是改改 package.json 就可以变更业务依赖的时候,是很容易就被忽略掉的,因此相对于大小问题,纳入 git 带来的稳定性与一致性的收益在我看来会更大一些。
最后,建议将最外层的 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 的依赖管理方式变得脆弱、不可靠。
前端工程化发展到今天,也面临着越来越多的挑战:
- vite 引领的 bundless 潮流下,前端工程化该怎么做?
- monorepo 架构下,怎么保证开发、构建的效率?
- 微前端架构下,又该如何开发、构建?
这些新的情况,都是当初设计 node.js 时,人们所未曾面对过的全新情况,我们不能要求 node.js 的设计者在一开始,就把各种情况都考虑的面面俱到,那是不现实的,我们更应该做的,是去分析问题的本质,在前人的肩上更进一步,去找到更适合当前情况下的解决方案。
本文也只是尝试从 dependency 和 devDependency 入手,来剖析目前使用 node.js 进行前端工程化的一些问题,剖砖引玉,望能给诸位读者带来一些不一样的视角,有任何问题,欢迎在评论区留言,一起探讨:)~
作者:ES2049 | 魔力圈圈
文章可随意转载,但请保留此原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 mailto:caijun.hcj@alibaba-inc...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。