敢问 9102 年的前端同窗们,上次你折腾依赖和构建配置是为了什么,又花了多少时间呢?对于如今前端项目中常使人诟病的开发环境稳定性问题,笔者认为 NPM 的一个设计难辞其咎,那就是 devDependencies
。前端
一切都是从这条脍炙人口的命令开始的:vue
npm install
复制代码
毫无疑问,这是条伟大的命令。少了 npm install
,估计能废掉今天一个前端的八成功力。它的做用说简单也很简单,那就是把前端项目中始于 dependencies
和 devDependencies
的依赖,递归地安装到 node_modules
目录下。时至今日,相信你们拿到一个前端项目时,潜意识都是「先用一条命令装好所有东西,而后再一条命令跑起开发环境」了吧。相比于刀耕火种的年代,这显然是个巨大的进步。node
然而这个初看之下简单易用的工具背后,槽点一直很多。好比很多同窗均可能看过这张生动鲜明的对比:react
你的项目有多大的 node_modules
呢?对于要正经上线的项目,这目录里的东西没个 500M,恐怕都很差意思和人打招呼了吧。不过,依赖包体积并非本文的关注点——毕竟这是前端工程化水平飞速发展的最好证实嘛(认真脸求别误伤),这里想要首先指出的,是前端项目的一种特殊性。vue-cli
前端领域具有一个很是特别的性质,那就是构建项目所需的资源,不光种类繁多,并且碎片化程度极高。npm
何谓「资源种类繁多」呢?对于常见的编程语言,它们的包管理器都是专门为这种语言定制的,从 Python 的 PIP 到 Java 的 Maven 再到 Rust 的 Cargo 无不如此。这些语言的文件格式也都是惟一肯定的。然而,前端项目中所须要承载的资源类型,基本上除了 JavaScript 以外,还会包括 CSS、HTML、SVG、字体、图像……这里每种资源的构建、转译、打包、优化……等工具,几乎都是 JS 写出来的,要经过 NPM 来安装。因此说今天的前端项目里,要经过 NPM 安装依赖来搞定的资源种类,早就一应俱全了。编程
那么,「碎片化程度极高」又是什么概念呢?前端社区的奇妙之处,在于对上面提到的每种资源,都有一大堆百花齐放的解决方案:json
.vue
呢?别忘了还有什么 EJS 啊 Pug 啊等等数不胜数的模板语法,任君选择。这里的麻烦之处,不在于如何挑选、对比或使用具体的某种工具(这对很多同窗来讲,反而有着女人挑衣服般的快感),而是你必须作出高度碎片化的选择。要知道,上面的每种解决方案(或者标准),几乎都有一到多种构建工具,每种工具除了可能有本身的插件体系外,其版本还会不停更新。你的构建方案选型,几乎注定是海量可能性之中的沧海一粟而已——因此,咱们不就正应该把这些碎片都放到 devDependencies
里面去管理吗?这不正是个很是合适的设计吗?前端工程化
理论上确实是这样没错。但最麻烦的地方在于,devDependencies
但是和 dependencies
共用同一份 node_modules
目录的!浏览器
先想一想编写其余语言的时候,你是怎么更新依赖的吧:更新某个 Python 爬虫包的时候,你会想顺带更新 Python 解释器的版本吗?谁没事这么折腾本身啊——实际的业务逻辑代码及其依赖,相比于用来构建项目的工具,几乎历来就是两个互相独立的东西。然而对于前端领域来讲,因为前端工具链一大部分都在 node_modules
里面,所以在更新业务逻辑依赖的时候,很是容易影响到你的工具链。
可能不少同窗尚未意识到,node_modules
里工具链类型的依赖,已经有多么重了。以 React 和 Vue 为例,社区的脚手架工具,分别会带来多少 dependencies
和 devDependencies
呢?笔者作了个简单的尝试以下:
掐指一算不难发现,如今主流框架默认搭出来的前端项目里,有 98% 的依赖是 devDependencies
啊!虽然实际项目中的业务依赖确定会更多,但浏览器端的业务基础库极少有复杂的重型依赖结构,反却是根据项目须要改进构建配置的时候,很容易大幅增长工具链的总体体积。所以,认为实际前端项目中半壁江山以上的依赖属于 devDependencies
,应该是个合理的假设。这带来的问题并不在于绝对的体积大小,而在于构建资源的高度碎片化会使得构建工具也须要快速迭代,带来大量的依赖版本。这么多依赖的版本一旦意外漂移,组装出来的稳定程度未必让人放心。这不是 JS 语言层面的问题,而是任何大型软件在系统层面的问题。相信只要是折腾过一些激进 Linux 发行版图形界面依赖的同窗,都应该能理解这一点吧。
对了,虽然 create-react-app 掩耳盗铃地把
eject
后的全部依赖所有算在了dependencies
里面,从应用与类库的角度来看这也说得通,但这并不影响咱们的结论。
除了 npm update
以外,每次 npm install
和 yarn install
,均可能(注意是可能,不是必定)体贴地基于语义化的版本规范,帮你把工具链版本都更新一遍,进而引入潜在的不稳定问题。同一份 package.json 间隔几天后再全量重装一次,devDependencies
里对应的构建工具版本几乎确定会有些不同。你说有谁会平常勤奋地更新 GCC / XCode / Android Studio 这类玩意呢?许多前端项目里偏偏就会发生这一点,由于 node_modules
常常变,而构建许多资源的核心工具都在里面呢。
看到这里确定不少同窗会坐不住了:不是有专治版本漂移的 Lock 文件吗?没错,Lock 确实能锁住版本,但别忘了 Lock 容易冲突但是天生的,一旦冲突该怎么解决呢?删掉重装啊——恭喜你再次喜提全量更新大礼包。实际上对于下面这些场景,最终 devDependencies
的工具链都很容易被牵连到:
再说得过度一点,想要保证真实场景下任意两次安装都能生成一样的 Lock,简直就跟要求保证两台型号一致的手机要得到一致的跑分数值同样难办。倒不是说 Lock 设计得很差,只是按照如今的使用方式来讲,即使基于它来保证工具链的稳定,仍是存在很多意外可能性的。
因此咱们已经知道,无论有没有使用 Lock,只要是和业务依赖混在一块儿的 devDependencies
,都容易被意外更新,从而引入不稳定性。可是,这里「脆弱性」还不止体如今工具链版本容易波动上而已,还有其它麻烦的问题。
例如,devDependencies
可能影响宏仓库的开发体验。所谓宏仓库 (Mono-Repo),也就是把一堆 package 按这种形式放到一块儿管理的仓库:
my-mono-repo
├── package.json
└── packages
├── A
│ ├── package.json
│ ├── node_modules
│ └── src
├── B
│ ├── package.json
│ ├── node_modules
│ └── src
└── C
├── package.json
├── node_modules
└── src
复制代码
假设咱们本身维护了 A B C 三个包,这些包之间也有相互的依赖。那么通常来讲,基于 npm link
命令或者更自动化的 Lerna 工具,咱们能够经过软连接的方式,把它们之间的依赖关系维护好。若是须要你本身来维护这些包,那么一个很天然的想法就是,A B C 均可以有本身的 dependencies
和 devDependencies
,好像没有问题吧?
咱们确实是能这么作的。但问题在于,只要宏仓库里每一个包都引入了各自不一样的 devDependencies
,这些包每一个都会带来庞大的 node_modules
,不只会引入大量的冗余,还会减慢仓库的初始化过程,让连接关系更加脆弱,就像在几台巨大的机器之间搭飞线同样。若是仓库里的包还须要被连接到其它项目,那就更麻烦了。在咱们的实践中,若是宏仓库里的每一个包都各自依赖了 Babel 这样的大型构建工具,它们之间的微妙区别会使得不少时候都不得不从新配置连接关系,而后带来大量无心义的 Lock 文件改动。除非是为了整合老项目而临时处理,不然尽可能不要这么干噢。
某种意义上,宏仓库里折腾的 Link 操做,是 NPM 为了简单性付出的代价。NPM 基于文件系统的目录结构来映射依赖结构,你能够在开发时直接修改 node_modules
里 Webpack 和 Vue 这些基础库的代码看到效果,方便你为社区贡献 PR(逃)。Link 则就是个软连接,能在任意两个目录之间创建依赖关系。然而,须要本身 Link 来管理的依赖,基本都是以私有依赖和业务依赖为主,这时候它们也要和大量用于构建的依赖复用同一个 node_modules
「容器」,自己就是一种不稳定因素。
除了宏仓库和 Link 外,膨胀的 devDependencies
对于 CI 构建也是有影响的。对于本身维护业务 NPM 包的团队,业务依赖极可能被快速地更新。这也就意味着,在 CI 上执行 npm install
的时候,常常要为了微不足道的业务依赖 bugfix 升级,去惊动一整个混沌的 node_modules
,影响构建的速度和稳定性。固然,主流的构建系统都具有了构建缓存,但咱们仍是在生产环境中遇到过 Yarn 对依赖的依赖使用了错误的缓存版本,而致使线上出现问题的意外。这时候怎么办呢?清空构建缓存从头再来吧……对了,前段时间咱们构建用的的某台 dev 机器,其磁盘 inode 索引甚至已经被刷到归零了呢。固然,你能够说这些问题是构建缓存、Lock 和 Linux 的锅,但对于动辄带来几万行 Lock 文件的 devDependencies
,你等于……你也有责任吧。为何要把鸡蛋整个打进水里拌匀,而后再把蛋壳挑出来呢?
当前流行的脚手架工具,某种程度上也加重了这种环境配置的不稳定性。自从 create-react-app 带头示范了 eject
这个拔屌无情的玩法以后,主流的脚手架工具基本都是比较管生无论养的。很多公司内部的「统一脚手架」工具,也仍是以「复制一坨东西 -> install -> run」的素质三连为主。项目少的时候这确实也挺方便,但项目建出来以后的依赖管理,就比较棘手了。
写到这里,咱们已经说了不少 devDependencies
的行为所带来的问题了。那么咱们能作什么呢?其实很是简单,把 devDependencies
单独丢到另外一个 node_modules
里就行了呀……在这方面,很容易想到很多简单方便的实践,好比:
build
和 src
两个路径,让两者都具有本身的 package.json 文件,从而把 node_modules
隔离开。这样它们也都不须要 devDependencies
了。devDependencies
提取出来,专门创建一个用来构建的顶层模块。这个顶层模块能够将整个仓库打包为一个来正式发布,而每一个小包则直接分发源码便可。build
工具到全局,相似于很是特化的 Parcel——只要安装它到全局,每一个项目里甚至连 Babel 插件均可以没必要安装,只要装几个 depencencies
就够了。这造轮子的感受岂不比素质三连更好吗 XDbuild
类型的依赖,也有利于提升构建速度,以及提升增量构建的稳定性。devDependencies
里,也相对较少形成使人困扰的构建问题。固然,把它的包隔离出去也是容易作到的,具体就见仁见智啦。总结来讲,本文看似写了不少东西,但其实真正重要的也就这么几点:
devDependencies
依赖很容易大幅膨胀。node_modules
,变得较为脆弱而低效。node_modules
中分离出去,就很容易改善构建稳定性问题。这种分离实现起来也至关容易。说到底,devDependencies
也确实是有存在价值的。但今天咱们面对的复杂状况,会使得它很容易超过设计时的负载。所以,前端开发环境的稳定性问题,很大程度上与项目的内在性质,以及咱们目前对工具链惯用的用法有关,并不应一言不合就怪到 JS 的弱类型头上。但愿本文能减小点平常的折腾,加强些你们对社区的信心吧 :D