9

一种前端项目依赖管理的未曾设想的道路

 2 years ago
source link: https://segmentfault.com/a/1190000040354133
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

内容简介(方便想要快速了解文章内容结论的同学)

  1. 先上结论,Node.js 将依赖分为 dependency 与 devDependency 两部分,但是却公用同一个 node_modules 文件夹的方式,在当下越来越复杂的前端项目开发过程中,已经不适用了。
  2. 工具依赖与业务依赖共用同一个 node_modules 文件夹,会使得开发与构建过程变得低效与脆弱,包括以下问题:node_modules 臃肿低效、版本漂移、Monorepo 架构不适配、CI 构建过程脆弱等。
  3. 解决方法是关注点分离,将业务依赖和工具依赖单独放置,一个思路是业务依赖纳入版本管理系统,工具依赖进行 precompile,并由专门的构建团队进行发布,开发者可以选择版本进行全局安装。

node_modules 现状

undefined

这张图想必前端同学都不陌生,当前吐槽的 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 都不需要,就安装几个业务上需要的依赖,这样的开发体验,岂不爽哉!对于封装的工具,可以交给专门的构建小组进行维护,甚至可以封装成二级制的包,比如采用 pkgdeno 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 的依赖管理方式变得脆弱、不可靠。

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

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

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

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

作者:ES2049 | 魔力圈圈

文章可随意转载,但请保留此原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 mailto:caijun.hcj@alibaba-inc...


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK