你也许不需要 devDependencies

3,475 阅读13分钟

敢问 9102 年的前端同学们,上次你折腾依赖和构建配置是为了什么,又花了多少时间呢?对于现在前端项目中常令人诟病的开发环境稳定性问题,笔者认为 NPM 的一个设计难辞其咎,那就是 devDependencies

一切都是从这条脍炙人口的命令开始的:

npm install

毫无疑问,这是条伟大的命令。少了 npm install,估计能废掉今天一个前端的八成功力。它的作用说简单也很简单,那就是把前端项目中始于 dependenciesdevDependencies 的依赖,递归地安装到 node_modules 目录下。时至今日,相信大家拿到一个前端项目时,潜意识都是「先用一条命令装好全部东西,然后再一条命令跑起开发环境」了吧。相比于刀耕火种的年代,这显然是个巨大的进步。

然而这个初看之下简单易用的工具背后,槽点一直不少。比如不少同学都可能看过这张生动鲜明的对比:

你的项目有多大的 node_modules 呢?对于要正经上线的项目,这目录里的东西没个 500M,恐怕都不好意思和人打招呼了吧。不过,依赖包体积并不是本文的关注点——毕竟这是前端工程化水平飞速发展的最好证明嘛(认真脸求别误伤),这里想要首先指出的,是前端项目的一种特殊性。

前端项目的特殊性

前端领域具备一个非常特别的性质,那就是构建项目所需的资源,不光种类繁多,而且碎片化程度极高

何谓「资源种类繁多」呢?对于常见的编程语言,它们的包管理器都是专门为这种语言定制的,从 Python 的 PIP 到 Java 的 Maven 再到 Rust 的 Cargo 无不如此。这些语言的文件格式也都是唯一确定的。然而,前端项目中所需要承载的资源类型,基本上除了 JavaScript 之外,还会包括 CSS、HTML、SVG、字体、图像……这里每种资源的构建、转译、打包、优化……等工具,几乎都是 JS 写出来的,要通过 NPM 来安装。所以说今天的前端项目里,要通过 NPM 安装依赖来搞定的资源种类,早就包罗万象了。

那么,「碎片化程度极高」又是什么概念呢?前端社区的奇妙之处,在于对上面提到的每种资源,都有一大堆百花齐放的解决方案:

  • 你想写 JS?光是官方标准就有 ES2015/2017/2018/2019……这么多,还有各种 Stage 的魔改语法,更不要说以 TypeScript 为代表的各种 XXScript 了。
  • 你想写 CSS?听说 Less、Sass、Stylus 都过时了,现在到底流行的是 PostCSS 还是 CSS Modules 呀?哦好像还有个 Styled Components 好像也能用?
  • 你想写 HTML?来看看你是喜欢 React 党的 JSX 还是 Vue 帮的 .vue 呢?别忘了还有什么 EJS 啊 Pug 啊等等数不胜数的模板语法,任君选择。

这里的麻烦之处,不在于如何挑选、对比或使用具体的某种工具(这对不少同学来说,反而有着女人挑衣服般的快感),而是你必须做出高度碎片化的选择。要知道,上面的每种解决方案(或者标准),几乎都有一到多种构建工具,每种工具除了可能有自己的插件体系外,其版本还会不停更新。你的构建方案选型,几乎注定是海量可能性之中的沧海一粟而已——所以,我们不就正应该把这些碎片都放到 devDependencies 里面去管理吗?这不正是个非常合适的设计吗?

理论上确实是这样没错。但最麻烦的地方在于,devDependencies 可是和 dependencies 共用同一份 node_modules 目录的

devDependencies 的脆弱性

先想想编写其他语言的时候,你是怎么更新依赖的吧:更新某个 Python 爬虫包的时候,你会想顺带更新 Python 解释器的版本吗?谁没事这么折腾自己啊——实际的业务逻辑代码及其依赖,相比于用来构建项目的工具,几乎从来就是两个互相独立的东西。然而对于前端领域来说,由于前端工具链一大部分都在 node_modules 里面,因此在更新业务逻辑依赖的时候,非常容易影响到你的工具链。

可能很多同学还没有意识到,node_modules 里工具链类型的依赖,已经有多么重了。以 React 和 Vue 为例,社区的脚手架工具,分别会带来多少 dependenciesdevDependencies 呢?笔者做了个简单的尝试如下:

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

掐指一算不难发现,现在主流框架默认搭出来的前端项目里,有 98% 的依赖是 devDependencies 啊!虽然实际项目中的业务依赖肯定会更多,但浏览器端的业务基础库极少有复杂的重型依赖结构,反倒是根据项目需要改进构建配置的时候,很容易大幅增加工具链的整体体积。因此,认为实际前端项目中半壁江山以上的依赖属于 devDependencies,应该是个合理的假设。这带来的问题并不在于绝对的体积大小,而在于构建资源的高度碎片化会使得构建工具也需要快速迭代,带来大量的依赖版本。这么多依赖的版本一旦意外漂移,组装出来的稳定程度未必让人放心。这不是 JS 语言层面的问题,而是任何大型软件在系统层面的问题。相信只要是折腾过一些激进 Linux 发行版图形界面依赖的同学,都应该能理解这一点吧。

对了,虽然 create-react-app 掩耳盗铃地把 eject 后的所有依赖全部算在了 dependencies 里面,从应用与类库的角度来看这也说得通,但这并不影响我们的结论。

除了 npm update 之外,每次 npm installyarn install,都可能(注意是可能,不是一定)体贴地基于语义化的版本规范,帮你把工具链版本都更新一遍,进而引入潜在的不稳定问题。同一份 package.json 间隔几天后再全量重装一次,devDependencies 里对应的构建工具版本几乎肯定会有些不一样。你说有谁会日常勤奋地更新 GCC / XCode / Android Studio 这类玩意呢?许多前端项目里恰恰就会发生这一点,因为 node_modules 经常变,而构建许多资源的核心工具都在里面呢。

看到这里肯定很多同学会坐不住了:不是有专治版本漂移的 Lock 文件吗?没错,Lock 确实能锁住版本,但别忘了 Lock 容易冲突可是天生的,一旦冲突该怎么解决呢?删掉重装啊——恭喜你再次喜提全量更新大礼包。实际上对于下面这些场景,最终 devDependencies 的工具链都很容易被牵连到:

  • 项目依赖了内部的 NPM 包,并经常需要在不同开发分支上并行更新时
  • 项目需要 checkout 到某个老版本修复问题,重新同步业务依赖时
  • 团队内需要使用特殊的私有仓库配置时
  • 团队成员使用的包管理工具版本不完全一致时

再说得过分一点,想要保证真实场景下任意两次安装都能生成同样的 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 都可以有自己的 dependenciesdevDependencies,好像没有问题吧?

我们确实是能这么做的。但问题在于,只要宏仓库里每个包都引入了各自不同的 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 里就好了呀……在这方面,很容易想到不少简单方便的实践,比如:

  • 对简单的前端应用项目,直接用全局的 Parcel 甚至浏览器原生的 ES Module 就足够了,无需专门配置打包工具。
  • 对常见的前端应用项目,可以区分 buildsrc 两个路径,让二者都具备自己的 package.json 文件,从而把 node_modules 隔离开。这样它们也都不需要 devDependencies 了。
  • 对于宏仓库类型的前端项目,可以将每个包里沉重的 devDependencies 提取出来,专门建立一个用来构建的顶层模块。这个顶层模块可以将整个仓库打包为一个来正式发布,而每个小包则直接分发源码即可。
  • 对于存在一定规范的多个前端应用项目,除了脚手架外,还可以考虑为它们封装一个特化而稳定的 build 工具到全局,类似于非常特化的 Parcel——只要安装它到全局,每个项目里甚至连 Babel 插件都可以不必安装,只要装几个 depencencies 就够了。这造轮子的感觉岂不比素质三连更好吗 XD
  • 对于需要 CI 的项目来说,专门隔离出 build 类型的依赖,也有利于提高构建速度,以及提高增量构建的稳定性。
  • 噢可别忘了测试工具。它们确实很适合归属在 devDependencies 里,也相对较少造成令人困扰的构建问题。当然,把它的包隔离出去也是容易做到的,具体就见仁见智啦。

总结来说,本文看似写了很多东西,但其实真正重要的也就这么几点:

  • 因为资源的复杂性,用于构建的 devDependencies 依赖很容易大幅膨胀。
  • 这些构建用的大量依赖,会与项目的实际依赖共用 Lock 和 node_modules,变得较为脆弱而低效。
  • 只要把这部分臃肿但可以长期稳定的依赖从 node_modules 中分离出去,就很容易改善构建稳定性问题。这种分离实现起来也相当容易。

说到底,devDependencies 也确实是有存在价值的。但今天我们面对的复杂情况,会使得它很容易超过设计时的负载。因此,前端开发环境的稳定性问题,很大程度上与项目的内在性质,以及我们目前对工具链惯用的用法有关,并不该一言不合就怪到 JS 的弱类型头上。希望本文能减少点日常的折腾,增强些大家对社区的信心吧 :D