如何管理前端项目中的复杂依赖关系

随着前端工程规模的增长,各类第三方与自有依赖包的关系也日趋复杂。这时候可能产生什么问题,又该如何解决呢?这里分享咱们前端团队的一些实践。前端

何谓复杂依赖关系

安装依赖包,对于前端开发者来讲不过就是一句 npm install xxx 的事。那么,单纯靠这种方式给一个项目安装了不少依赖,就算是复杂的依赖关系吗?这里咱们这样定义「复杂」:node

  • 你须要本身维护多个不一样的包,来在最下游的业务项目中使用。
  • 除了被下游业务依赖外,这些包之间也可能存在依赖关系,它们也可能依赖上游的包。
  • 不一样的包可能位于不一样的 Git 仓库,还有各自独立的测试、构建与发布流程。

若是纯粹只靠 npm install,那么全部的包都必须发布到 NPM 以后才能被其余的包更新。在「联调」这些包的时候,每次稍有更改都走一遍正式的发布流程,无疑是很是繁琐而影响效率的。咱们有什么现成的工具来解决这个问题呢?git

社区工具 Takeaway

提到管理多个包之间的依赖关系,不少同窗应该能立刻想到很多现成的工具,好比:github

  • NPM 的 link 命令
  • Yarn 的 workspace 命令
  • Lerna 工具

这里的「万恶之源」就是 npm link 命令了。虽然熟悉它的同窗多半知道它有很多问题,但它确实能解决基本的连接问题。快速复习一下使用方式:假设你维护的下游业务项目叫作 app,上游的依赖叫作 dep,那么要想作到「dep 一改动,app 就能同步更新」,只须要这样:npm

# 1. 在 dep 所在路径执行
npm link

# 2. 在 app 所在路径执行
npm link dep
复制代码

这样就造成了 app 与 dep 之间基本的「连接」关系。只要进入 app 的 node_modules 查看一下,不难发现 NPM 其实就是替你创建了一个操做系统的「快捷方式」(软连接)跳到 dep 下而已。在存在多个互相依赖的包的时候,手动维护这个连接关系很是麻烦并且容易出错,这时候你能够用社区的 yarn workspace 或 Lerna 来自动帮你管理这些包。因为这两者至关接近,在此咱们只介绍在咱们生产环境下使用的 Lerna 工具。json

Lerna 的使用也是很是傻瓜的,你只需按下面的风格把各个依赖包放在同一个目录下就行,无需对它们具体的构建配置作任何改动:bootstrap

my-lerna-repo/
  package.json
  packages/
    dep-1/
      package.json
    dep-2/
      package.json
    dep-3/
      package.json
    ...
复制代码

而后一句 lerna bootstrap 就可以自动处理好它们之间的依赖关系了——这里每一个包的 package.json 均可以放心地写上其它包的名字了(注意这里依据的是 package.json 中的 name 字段,而非目录名)。这样,你能够放心地把这些包放置在同一个 Git 仓库里管理,而不用担忧繁琐的初始化过程了——如今的 Babel 和 React 就是这么干的。bash

固然了,实际的场景并非有了现成的命令或者工具就万事大吉了。下面总结一些实践中的依赖管理经验吧:app

循环依赖的产生与解除

在刚开始使用 Lerna 这样的依赖管理工具时,一些同窗可能会倾向于把依赖拆分得很是零散。这时是有可能出现循环依赖的情形的——A 包依赖了 B,而 B 包又依赖了 A。怎么会出现这种状况呢?举一个例子:编辑器

  1. 假设你在维护一个可复用的编辑器 editor 包。为了更好的 UI 组件化,你把它的 UI 部分拆分红了 editor-ui 包。
  2. editor-ui 的组件须要 editor 实例,所以你把 editor 列为了 editor-ui 的依赖。
  3. editor 的 Demo 页面中想要展现带完整 UI 的应用,所以你把 editor-ui 列为了 editor 的依赖。

这时候就出现了循环依赖。虽然 NPM 支持这种场景下的依赖安装,可是它的出现会让依赖关系变得难以理解,所以咱们但愿尽可能作到直接避免它。这里的好消息是,循环依赖多数都和不太符合直觉的需求有关,在上面的例子里,做为上游的 editor 包去依赖了下游的 editor-ui 包,这能够在方案评审时就明确指出,并只需改成在 editor-ui 包中展现 Demo 页便可——若是出现了循环依赖,大胆地运用「这个需求不合理」的否决权吧。

多依赖包的初始化和同步

咱们已经提到,lerna boostrap 可以正确地完成多个包的依赖安装和连接操做。但这是否意味着一个装载了多个包的 Lerna 仓库,只要这条命令就可以让这些包都正常地跑起来呢?这里存在一点细节须要注意。

若是你管理的多个包先是配置了各自的构建和发布命令,而后才经过 Lerna 合并到一块儿的话,可能出现这样的问题:它们在 package.main 字段下指定的入口都是形如 dist/index.js 下的构建后文件,但相应的产物代码在如今通常是不提交到 Git 的。这时候拉下全新的代码想要跑起来时,即使工具正确地处理了连接关系,仍然有可能出现某个子包没法打包成功的状况——这时,就去被依赖的包目录下手动 npm run build 一次了。固然,在这种状况下,更新了一个包的源码后,也须要对这个包作一次 build 操做生成产物后,其它的包才能同步。虽然这并无多少理解上的困难,但每每形成一些没必要要的困扰,故而在此特意说起。

存在上下游的依赖管理

在真实场景中,依赖其实并不能彻底经过 Lerna 等工具管理,而是存在着上下游的区分的。这是什么概念呢?以下图:

通常来讲,上游的基础库(如 Vue / Lodash 等)并不适合直接导入自有的宏仓库中维护,而下游的具体业务项目多数也是与这些自有依赖独立的,它们一样在 Lerna 工具的控制范围以外。这时,咱们仍然须要回到基本的 npm link 命令来创建本地的连接关系。但这可能会带来更多的问题。例如,假设你在 Lerna 中管理 editor 与 editor-ui 两个依赖,而业务项目 app 依赖了它们,这时候你不难把 editor 与 editor-ui 都 link 到 app 下。但这时的连接关系很容易被破坏,考虑下面的工做流:

  1. 你为了修复 app 中 editor 的一些问题,更新了 editor 的代码,并在本地验证经过。
  2. npm publish 了 editor 与 editor-ui 的新版本。
  3. 你在 app 中 npm install editor editor-ui 并提交相应的改动。

Boom!执行了最后一步后,不光 app 与 editor 之间的连接关系会被破坏,editor 与 editor-ui 之间的连接关系也会被破坏。这就是软连接的坏处了:下游的变动也会影响上游。这时,你须要从新作一次 lerna bootstrapnpm link 才能把这些依赖关系从新创建好,对于频繁迭代的业务项目来讲,这是至关棘手的。对这个问题,咱们提出的变通方案包括两部分:

  • 能够部署一个专门用于依赖安装的业务项目环境。
  • 能够编写本身的 link 命令来替代 npm link

前者听起来麻烦,但实际上只须要把 app 目录复制一份便可。假设复制后获得了 app-deps 目录,那么:

  • 将 editor-ui 与 editor 都 link 到 app 目录下,使用它们在本地开发。
  • 在须要更新依赖版本时,在 app-deps 目录下执行 npm install editor 便可。这不会 app 项目中破坏原有的连接关系。

固然,这时候 app 与 app-deps 之间的依赖可能不彻底同步——这个问题只要有 pull 代码的习惯就能解决。另外的一种问题情形在于,若是下游的业务项目采用了 CNPM 等非 NPM 的包管理器来安装依赖,那么这时候原生的 link 命令容易失败。仍是套用前面的例子,这时候咱们能够在 editor 项目中创建 link 命令,来替代 npm link

// link.js
const path = require('path');
const { exec } = require('./utils'); // 建议将 childProcess.exec 封装为 Promise

const target = process.argv[2];
console.log('Begin linking……');

if(!target) {
    console.warn('Invalid link target');
    return;
}

const baseDir = path.join(__dirname, '../');
// 区分相对路径与绝对路径
const targetDepsDir = target[0] === '/'
    ? path.join(target, 'node_modules/my-editor')
    : path.join(__dirname, '../', target, 'node_modules/my-editor');

console.log(`${baseDir}${targetDepsDir}`);

exec(`rm -rf ${targetDepsDir} && ln -s ${baseDir} ${targetDepsDir}`)
.then(() => {
    console.log('🌈 Link done!');
})
.catch(err => {
    console.error(err);
    process.exit(1);
});
复制代码

这样只要在 editor 的 package.json 中增长一条 "link": "node ./link.js" 配置,就能经过 npm link path/to/app 的形式来完成连接了。这个连接操做跳过了很多中间步骤,所以比 NPM 原生的 link 速度要高得多,也能适配 CNPM 安装的业务项目。

对于「自有依赖 → 下游业务」的情形,这两个方式基本能保证开发节奏的顺畅。但还有一个问题,就是「上游依赖 → 自有依赖」的时候,仍然可能须要折腾。这对应于什么状况呢?

通常来讲,最上游的基础库应当是至关稳定的。可是你一样可能须要修改甚至维护这样的基础库。好比,咱们的 editor 编辑器依赖了咱们开源的历史状态管理库 StateShot,这时候就须要本地连接 StateShot 到 editor 中了。

这个场景不能继续前面的 npm link 套路吗?固然能够,不过上游的基础库并不须要频繁的迭代来同步时,咱们建议使用 npm pack 命令来替代 link,以保证依赖结构的稳定性。如何使用这个命令呢?只须要这样:

  1. 假设你有上游的 base 包,那么在它的目录下构建它以后,运行 npm pack
  2. pack 生成 base.tgz 以后,在 Lerna 管理的 editor 包下运行 npm install path/to/base.tgz
  3. lerna bootstrap 保证连接关系正确。

pack 的好处在于避开了软连接的坑,还能更真实地模拟一个包从发布到安装的流程,这对于保证发布的包可以正常安装使用来讲,是颇有用的。

总结

前端的工程化还在演化之中,从最简单的 npm install 到各色命令与工具,相信将来的趋势必定是可以让咱们更加省心地维护好更大规模的项目,也但愿文中的一些实践可以对前端同窗有所帮助。

相关文章
相关标签/搜索