Nx带来极致的前端开发体验——代码库的结构类型

319 阅读11分钟

首发于公众号 code进化论,欢迎关注。

前言

在 monorepo(多 package) 架构下的代码库允许开发者将应用程序的功能模块拆分成独立的 package,每个 package 能够扩展自己的测试、规范检查、构建流程,更好地实现关注点分离和代码可重用。

虽然多 package 架构下能够给开发者带来很优质的功能,但是也正因为将项目拆分成多个独立的 package,而项目又需要与这些 package 建立联系,这也带来了两个新的问题:

  • 如何保证项目在构建的时候 bunder 工具能够正常找到这些本地以来库。
  • 对于 typescript 项目,如何保证项目本地开发时能够正确找到本地库的类型。

下面会介绍 Nx 支持的两种方案,这两种方案也对应着两种不同的代码库结构。

基于包的代码库

基于包的代码库就是使用 npm/yarn/pnpm 的 workspace 功能搭建的代码库。

什么是 workspace?

workspace 允许开发者在单个存储库中管理多个 package,每个 package 可以有自己的依赖项、脚本和版本,package 之间的依赖关系可以在每个 package 的 package.json 文件中进行定义。

创建 workspace 项目

下面使用 npm workspace 来作为示例.

初始化项目

首先使用 npm init 初始化一个项目,并在 package.json 文件中添加 workspace 功能。

{
    "name": "workspace",
    "workspaces": [
        "packages/*"
    ]
}

在 workspace 数组中需要开发者指定包含的 package 范围,运行 npm install 之后,packages 内的所有包都将创建符号链接到根目录的 node_modules 文件夹里。

创建本地 package

使用 npm init -w packages/[packageName] -y 命令在 packages 文件夹下创建一个 package。下面是创建好的 packages 目录。

+ packages
    + product1
        + package.json
    + shared-ui
        + package.json
    + utils
        + package.json

建立依赖关系

前面讲过在根目录运行 npm install 之后,packages 内的所有包都将创建符号链接到根目录的 node_modules 文件夹里,那么如果想建立 package 之间的依赖关系,只需要将依赖的 packgae 作为 npm 依赖添加到 package.json 的依赖项中,比如将 shared-ui 和 utils 作为依赖项添加到 product1 中:

{
	"name": "product1",
	"dependencies": {
        "shared-ui": "^1.0.0",
        "utils": "^1.0.0"
	}
}

这种方式能够很好的解决前面提到的两个问题:

  • 构建时如何正常找到本地依赖库: 通过在根目录的 package.json 中定义 workspaces,npm/yarn/pnpm 会自动为本地 package 创建符号链接到根目录的node_modules 文件夹里,这样 bunder 工具在构建的时候能够在根目录的 node_modules 中找到本地库。

  • TypeScript 项目如何找到本地库类型: 由于本地依赖库被链接到根目录的 node_modules 中,TypeScript 能够像查找 npm 第三方库的类型定义一样正确的找到本地库的类型定义。

第三方依赖管理

在 workspace 中,每个 package 下都使用一个单独的 package.json 来维护依赖,在 package.json 中定义自己在开发期间需要哪些依赖,这种策略下有几个优势:

  • 独立性

    每个项目可以独立管理自己的依赖关系和版本,这意味着每个项目可以选择使用不同版本的依赖项,而不会影响其他项目。

  • 可维护性

    每个项目的 package.json 文件相对较小,更容易维护和管理。开发者可以更容易地理解和处理与项目相关的依赖项。

当然这种策略下也存在着一些棘手的问题,开发者一旦不注意就会导致整个项目报错,比较突出的连两个问题如下:

  • 幻影依赖

    当开发者使用 npm 或者 Yarn Classic 来管理依赖时,最终会在项目根目录下的 node_modules 中安装所有的依赖,比如说有一个项目使用 lodash,另一个项目使用了 ramda,最终的目录结构如下:

    workspace
    ├── node_modules
    │   ├── lodash
    │   └── ramda
    ├── projectA (dependencies: {lodash: '4.17.21'})
    └── projectB (dependencies: {ramda: '0.28.0'})
    

    尽管 projectA 的 package.json 中仅依赖了 lodash,但是 projectA 仍然可以访问 ramda,因此如果在 projectA 中导入 ramda 项目是能够正常运行的,但是这会带来三个问题,

    • 一旦 projectA 作为 npm 包发布并被安装,代码就会出错,因为 projectA 本身缺少 ramda 依赖。
    • 一旦 projectB 对 ramda 进行升级,那可能会导致 projectA 在运行时出错,因为 projectA 还是用的是 ramda 旧版本的 API。
    • 如果 ramda 是作为 projectB 的 devDependencies,可能本地开发的时候 projectA 能正常运行,但是一旦上到生产环境就会出错,因为 ramda 最终不会被打包到最终的产物中。
  • 多版本冲突

    在 monorepo 项目中出现相同依赖多个版本的问题其根源在于每一个项目都单独维护自己的 package.json,如果每个项目都是由不同的团队进行协作开发维护,一旦没有一套统一的开发规范就很容易造成各项项目的依赖版本不一致。

    比如说存在以下依赖关系:

    使用 npm/yarn 最终依赖安装可能存在两种结果:

    workspace
    ├── node_modules
    │   ├── lodash@^1
    ├── projectA
    └── projectB
    │    └── node_modules
    │        └── lodash@^2
    └── projectC
        └── node_modules
            └── lodash@^2
    

    或者

    workspace
    ├── node_modules
    │   ├── lodash@^2
    ├── projectA
    │    └── node_modules
    │        └── lodash@^1
    └── projectB
    └── projectC
    
    

    这取决于使用的依赖管理工具及其依赖处理模式,不管哪种情况都会出现两个问题:

    • 重复安装同一个依赖的同一个版本。
    • 安装同一个依赖的多个版本。

    那重复安装依赖会出现哪些问题呢?对与这个问题可以从两个方向去分析:

    • 依赖本身不允许多个版本共存

      最直接的例子就是 react,react 在官网中强调最新的 react-hooks 特性就要求使用 hooks 时必须要在同一个 react 上下文

    • 依赖本身允许多个版本共存

      对于允许多个版本共存的依赖来说,重复安装会导致依赖冗余,这会占用额外的磁盘空间,并且在构建和部署过程中可能会增加时间和资源的消耗。

      同时多版本共存下代码的共享也是非常困难的,如果 projectA 和 projectB 使用两个不同版本的依赖,那他们的共享代码应该正对哪个版本的依赖进行编写?无论怎么回答,系统都会引入错误,并且这切错误通常发生在运行时并且很难定位。

项目运行

在 workspace 项目中,运行流程通常遵循以下步骤:

  • 安装依赖:在项目根目录运行 npm install(或 yarn installpnpm install),安装所有 package 的依赖。

  • 构建底层依赖库:通常需要先构建被其他 package 依赖的基础库。例如:

    npm run build --workspace=shared-ui
    npm run build --workspace=utils
    

    这确保了其他 package 可以使用最新版本的底层依赖。

  • 运行应用或服务:

    npm run start --workspace=product1
    

在这个执行流程中,开发者重点关注的是要保证所有 package 的构建顺序,一旦执行顺序错误可能就会导致某些 package 构建失败。

小结

基于包的代码库(workspace)是一种多 package 结构,它具有以下特点:

  • 允许在单个存储库中管理多个 package,每个 package 可以有独立的依赖项、脚本和版本。
  • 使用 npm/yarn/pnpm 的 workspace 功能来创建和管理。
  • 通过符号链接解决了本地依赖库的构建和类型查找问题。
  • 每个 package 独立管理依赖,提高了独立性和可维护性。
  • 灵活性高,可以在不修改现有工具或文件结构的情况下添加编译缓存(增量编译)和任务调度。

然而,这种结构也存在一些潜在问题:

  • 可能出现幻影依赖,导致代码在不同环境下行为不一致。
  • 多版本冲突可能导致依赖重复安装或版本不一致,影响性能和代码共享。

总的来说,基于包的代码库结构提供了良好的模块化和独立性,喜欢灵活性的人会倾向于这种代码库类型,但需要谨慎管理依赖关系以避免潜在问题。

集成式代码库

集成式代码库是 Nx 默认采用的一种多 package 架构,它没有依赖 npm/yarn/pnpm 的 workspace 功能,而是使用 TypeScript 路径别名(在tsconfig.base.json中定义)来链接代码库中的 package,构建时通过读取 TypeScript 路径别名来确定 package 的位置。这使得代码组织更简洁,也使 pacakge 间的依赖更加明确和可管理。

TypeScript路径别名 & 构建工具resolve.alias

TypeScript路径别名和构建工具中的 resolve.alias 是用来简化项目内部模块引用、增强代码可读性。它们虽然有不同的应用场景(TypeScript 和构建工具),但目的是类似的。

TypeScript路径别名:

TypeScript 路径别名是通过在 tsconfig.json 文件中定义的别名,将复杂、冗长的模块路径简化为简洁、易读的路径。利用 TypeScript 路径别名能够很好的解决多 package 项目中 package 相互引用时类型查找问题。比如下面这个例子:

{
    "compilerOptions": {
        "paths": {
            "shared-ui": ["packages/shared-ui/src/index.ts"],
            "utils": ["packages/utils/src/index.ts"]
        }
    }
}

这种方式相比于基于包的代码库,它能够实时获取到 package 最新的类型定义。

构建工具的resolve.alias:

resolve.alias 是在构建工具(如 Webpack、Vite、Rollup 等)中用于设置路径别名的配置项。它的作用类似于 TypeScript 的路径别名,但作用于整个打包流程,比如下面这个例子:

// webpack.config.js
module.exports = {
  resolve: {
    alias: {
      'shared-ui': path.resolve(__dirname, 'packages/shared-ui/src/index.ts'),  // 将 @components 映射到 src/components
      'utils': path.resolve(__dirname, 'packages/utils/src/index.ts'),            // 将 @utils 映射到 src/utils
    },
  },
};

依赖管理

集成式代码库管理依赖的策略是在根目录下的 package.json 文件去定义所有的依赖,从而强制所有的项目都使用依赖的同一版本,进而避免出现多版本依赖和幻影依赖的问题。每个 package 下仍然会保留独立的 package.json 文件,但是里面只会去定义这个 package 的元数据,不会定义依赖。

当然这种策略也有开发者对依赖的协调更新表示担忧,如果两个不同的团队在一个存储库下开发 React 应用,他们就需要就何时升级 React 达成一致,这是一个合理的担忧,但却不是 Nx 该考虑的。

如果开发人员无法合作,那是不是可以将项目拆分到单独的仓库中去维护。另一方面,如果团队能够达成一致,那么同时升级整个存储库的工作量就会比在几个月或几年内多次执行相同的升级过程要少得多。

项目运行

集成式代码库的运行方式与独立程序一样,分为两个步骤:

  • 安装依赖:在项目根目录运行 npm install(或 yarn installpnpm install),安装所有 package 的依赖。

  • 运行应用或服务:

    npm run start --workspace=product1
    

    该过程会编译构建项目及其依赖的所有 package,随着项目的不断增大构建时间也会随之变长,后续会介绍如何基于 Module Federation 实现增量构建。

小结

集成式代码库是一种多 package 架构,具有以下特点:

  • 使用 TypeScript 路径别名链接代码库中的 package,而非依赖 npm/yarn/pnpm 的 workspace 功能。
  • 在根目录的 package.json 文件中定义所有依赖,强制所有项目使用相同版本的依赖。
  • 构建过程会编译构建项目及其依赖的所有 package。

他的优点非常明显:

  • 更清晰的代码组织和依赖管理。
  • 减少版本冲突和幻影依赖问题。

但是这种开发模式和现有的基于 workspace 开发的项目兼容性不好,上手门槛高,在某些场景下还存在找不到 references 的问题,因此官方在 2024 年提出了一个 RFC 计划在新版本的 Nx 中将使用 workspace 功能来替代 compilerOptions.paths 配置并给出了详细的解释。 目前在 20+ 的版本中已经实现了该功能。

总结

本文详细探讨了 Nx 支持的两种主要的代码库结构类型:基于包的代码库和集成式代码库。每种结构都有其独特的特点、优势和潜在挑战,适用于不同的开发场景和团队需求。

值得注意的是,Nx 作为一个强大的开发工具集,这三种代码库结构都能集成 Nx,无论您选择哪种结构,Nx 都能为我们的项目提供高效的开发体验和强大的工具链支持。