从 Vite 的 RFC 吸取灵感:尝试优化 Monorepo 本地包的开发流程

288 阅读10分钟

前言

Monorepos 在 JS 生态系统中变得越来越流行,这部分归功于 npm/yarn/pnpm workspaces 的兴起。Monorepo 通过以下方式帮助团队提高开发速度:

  • 代码共置: 将相关代码放在同一个仓库中,便于管理和共享。
  • 代码共享: 不同项目可以共享代码,减少重复开发。
  • 并行开发: 团队成员可以同时在多个项目上工作,提高效率。
  • 消除发布周期: 不再需要繁琐的构建、发布、安装流程,加快开发迭代。

然而,工具链尚未完全适应这些新的开发流程。其中最大的挑战之一是如何处理 Monorepo 中的本地 package,以及如何以对开发者透明(理想情况下)的方式将它们打包。目前,无论是在社区中还是在我的实践过程中,虽然有一些解决方案能够应对这个问题,但尚未能在不同的框架中普遍实施。接下来,我将尝试实现这些方案,并记录过程中遇到的问题。

名词解释

  • Monorepo:是一种将多个项目或模块的代码统一管理在一个 git 仓库中的软件开发策略。
  • 本地包:这些包仅存在于 monorepo 中,不会发布到 npm ,也不会在外部 git 仓库中使用。它们可能有也可能没有构建步骤,具体取决于所有者的偏好。
  • 内部包:这些包发布到内部 npm,并在多个私有 git 仓库中使用,包括它当前的仓库。它们可能有也可能没有构建步骤,具体取决于所有者的偏好。
  • 外部包:这些包发布到公共 npm,并作为包依赖项在大量私有和公共 git 仓库中使用。他们几乎总是有一个构建步骤。
  • Package Workspaces(工作区):Monorepo 是一个包含多个包(库和应用)的代码仓库,由一个或多个 package.json 文件管理。工作区是包管理器(npm/pnpm/yarn)的一项特性,可以将仓库中所有 package.json 文件的依赖项安装到同一个 node_modules 文件夹中。除此以外,工作区还能让本地包利用 Node.js 的模块解析算法,通过符号链接(symlinked)到共享的 node_modules 文件夹,本地包可以被其他本地包“引用”,而无需发布到外部。
  • bare import(裸导入):在模块导入时,使用裸露的模块名称或路径,而不带任何前缀或后缀,如文件扩展名。例如,在 JavaScript 中,import React from 'react'; 是一个裸导入,因为它仅使用模块名 react,而没有指定具体的文件路径或扩展名。裸导入通常依赖于模块解析机制来定位实际的模块文件。

目标

  • 实现源文件引用:打包时引用源文件,避免为本地包构建进行额外的配置。
  • 透明且最小配置:理想情况下,理解与配置时间控制在5分钟内。

当前常见的实现方案

在开始实施我的探索前,先浅谈一下在这个方案以前我是如何进行对本地包进行引用的。从这部分开始,接下来,我们都以最简单的 monorepo 为例,方便大家理解,目录结构如下:

monorepo
├── app # web 应用
│   ├── index.html
│   ├── package.json
│   ├── src
│   ├── tsconfig.json
│   └── vite.config.ts
└── lib # js 库
    ├── package.json
    ├── src
    ├── tsconfig.json
    └── vite.config.ts

其中 app 依赖了 lib。app/package.json 如下:

{
  "name": "app",
  "dependencies": {
    "lib": "workspace:*"
  }
}

1)自定义 package.json export 条件

利用package.json文件的exports字段来为本地包指定源文件路径。配置一般如下:

lib/package.json:

  {
    "exports": {
      ".": {
        "dev": "./src/index.ts",
        "import": "./dist/index.es.js"
      },
      "./*": [
        "./*",
        "./*.d.ts"
      ]
    }
  }

app/vite.config.ts:

import { defineConfig } from 'vite';

export default defineConfig({
  resolve: {
    conditions: ['dev'],
  },
});

这种办法仅仅适用于本地包与内部包,但是并不适用于外部包。

在 lib/package.json 中我们需要自定义一个 exports 的条件字段,避免与 Node.js 模块解析时定义的条件导出字段有所冲突导致解析的文件不符合预期。Node.js 默认支持的条件导出字段可参考 Node.js 条件导出文档

然后 app/vite.config.ts 我们指示 vite 增加允许的条件导出字段。Vite 条件导出文档

2)resolve.alias别名映射

这是一种常用的打包本地包源代码的方法,将每一个包名映射到其源文件夹下。这种配置需要大量的手动配置与模板代码:

app/vite.config.ts:

import path from "node:path";
import { defineConfig } from "vite";

export default defineConfig({
  resolve: {
    alias: {
      "lib": path.join(__dirname, "../packages/lib/src"),
    },
  },
});

完成别名配置后,我们就可以在 app 里使用 lib 包,并且指向 lib 的源代码 src 目录。这个方法将 lib 的配置转移到了 app 中,所有依赖消费 lib 的包都需要配置别名映射。

在社区中还有其他的方案可以完成源码引入,有兴趣可以查看 vite-ts-monorepo-rfc。我们对这两种方法进行一下对比:

方案对比自定义 package.json export 条件resolve.alias别名映射
不需要配置 lib
不需要配置 app
不依赖 Node.js 模块解析
仅使用工具的标准能力
支持深度引入否需要额外实现不同文件后缀引入
可被其他构建工具使用

由此可见,如果我们了解 alias 的工作原理以及在每个消费 lib 的 app 中都配置别名,resolve.alias是一种不错的解决方案。那为什么我还要去尝试新的方案呢?

实现方案

配置 alias的最大缺点是需要模板代码配置,会随着仓库里的 package 增加而增加。随着继续翻阅 vite-ts-monorepo-rfc 文档,我参照 vite-ts-monorepo-rfc 中提到的方案5与方案6实施了我的方案。

这个方案实现起来并不复杂,最大的关键点是:如何判断某个依赖项的导入路径是本地 Monorepo 的,而不是package.json 中声明的 dependencies/devDependencies 的依赖项导入路径。

具体实现

一、搭建一个基于 unplugin 架构的插件

基于 unplugin-starter 初始化即可。

npx degit unplugin/unplugin-starter unplugin-monorepo

二、区分出依赖项导入路径

几乎所有的核心代码都在 resolveId hook 中。

  1. 过滤无关的引入 id,我们只处理 bare import (裸导入)。
const bareImportRE = /^(?![a-zA-Z]:)[\w@](?!.*://)/;

async resolveId(id, importer, options) {
   if (!bareImportRE.test(id) || id === '@vite/client' || id === '@vite/env') {
        return;
    }   
}
  1. 找到这个裸导入的 package.json 位置
export function getNpmPackageName(importPath: string): string | null {
  const parts = importPath.split('/');
  if (parts[0][0] === '@') {
    if (!parts[1]) {
      return null;
    }
    return `${parts[0]}/${parts[1]}`;
  } else {
    return parts[0];
  }
}

export async function findDepPkgJsonPath(dep: string, parent: string, preserveSymlinks = false) {
  if (pnp) {
    try {
      const depRoot = pnp.resolveToUnqualified(dep, parent);
      if (!depRoot) {
        return undefined;
      }
      return path.join(depRoot, 'package.json');
    } catch {
      return undefined;
    }
  }

  let root = parent;
  while (root) {
    const pkg = path.join(root, 'node_modules', dep, 'package.json');
    try {
      await fsp.access(pkg);
      // use 'node:fs' version to match 'vite:resolve' and avoid realpath.native quirk
      // https://github.com/sveltejs/vite-plugin-svelte/issues/525#issuecomment-1355551264
      return preserveSymlinks ? pkg : fs.realpathSync(pkg);
    } catch {}
    const nextRoot = path.dirname(root);
    if (nextRoot === root) {
      break;
    }
    root = nextRoot;
  }
  return undefined;
}

const depPkgPath = await findDepPkgJsonPath(getNpmPackageName(id)!, root) ?? '';

首先将裸导入 id 转换为 npm 包名,然后从当前 app 包的根目录,向上递归查找 node_modules/lib/package.json 的所在位置的真实文件路径。

  1. 检查裸导入路径的 package.json 是否为符号链接
const symlinksDepPkgPath = await findDepPkgJsonPath(getNpmPackageName(id)!, root, true) ?? '';
const pkgRootDirStat = await fsp.lstat(path.dirname(symlinksDepPkgPath));
debug('dep package.json path:', depPkgPath, '\nsymlinks: ', symlinksDepPkgPath);
// pkg root dir is not symlink or real dir in node_modules, we skip it
if (!pkgRootDirStat.isSymbolicLink() || isInNodeModules(depPkgPath)) {
  return;
}

以真实路径是否在 node_modules 中 或者裸导入路径的不是为符号链接,作为真实依赖项的判断依据。真实的依赖项除非是使用 pnpm link方式链接到仓库中,否则一般情况是不会为符号链接的。

  1. 根据 node_modules/lib 的真实文件路径和 lib 的入口文件路径,调用 resolve函数完成解析。resolve 函数可见 Rollup 文档
const packageMeta = loadPackageData(depPkgPath!);
const metaKey = packageMeta[packageMetaKey];

const { sourceDir } = metaKey || packageMetaDefaultValue || {};

if (sourceDir) {
  const sourceSrc = path.resolve(depPkgRoot, sourceDir);
  const sourceResolved = await (this as any).resolve(sourceSrc, importer, options);
  debug('resolved: ', sourceResolved);
  return sourceResolved;
}

以下是完整代码实现:

export const unpluginFactory: UnpluginFactory<Options | undefined> = (options) => {
  const { packageMetaDefaultValue, packageMetaKey = 'bundler' } = options || {};

  let resolvedConfig: ResolvedConfig;

  return {
    name: 'unplugin-monorepo',
    enforce: 'pre',

    async resolveId(id, importer, options) {
      if (!bareImportRE.test(id) || id === '@vite/client' || id === '@vite/env') {
        return;
      }

      const root = resolvedConfig.root;
      const depPkgPath = await findDepPkgJsonPath(getNpmPackageName(id)!, root) ?? '';
      const depPkgRoot = path.dirname(depPkgPath);
      const symlinksDepPkgPath = await findDepPkgJsonPath(getNpmPackageName(id)!, root, true) ?? '';
      const pkgRootDirStat = await fsp.lstat(path.dirname(symlinksDepPkgPath));
      debug('dep package.json path:', depPkgPath, '\nsymlinks: ', symlinksDepPkgPath);
      // pkg root dir is not symlink or real dir in node_modules, we skip it
      if (!pkgRootDirStat.isSymbolicLink() || isInNodeModules(depPkgPath)) {
        return;
      }
      const packageMeta = loadPackageData(depPkgPath!);
      const metaKey = packageMeta[packageMetaKey];

      const { sourceDir } = metaKey || packageMetaDefaultValue || {};

      if (sourceDir) {
        const sourceSrc = path.resolve(depPkgRoot, sourceDir);
        const sourceResolved = await (this as any).resolve(sourceSrc, importer, options);
        debug('resolved: ', sourceResolved);
        return sourceResolved;
      }

      return null;
    },
    vite: {
      configResolved(config) {
        resolvedConfig = config;
      },
    },
  };
};

三、定义插件的配置项

为了提高插件使用的便捷性和灵活性,我们添加了一些功能选项,以满足不同的需求。

export interface Options {
  /**
   * package.json special meta key
   * @zh 读取 `package.json` 自定义字段,用于配置源代码文件对应的解析字段。
   * @default 'bundler'
   */
  packageMetaKey?: string
  /**
   * package.json special meta default value
   * @zh 读取 `package.json` 自定义字段 `packageMetaKey` 后,解析字段值的默认值。
   * */
  packageMetaDefaultValue?: {
    /**
     * source entry
     * @zh 源代码入口目录
    */
    sourceDir: string
  }
}
  • packageMetaKey 该字段用于声明我们在 package.json 中自定义的字段名,默认为 bundler。可以标准化地使用 bundler,也可以为了避免与其他 key 冲突而声明新的自定义字段。
  • packageMetaDefaultValue 则表示我们解析自定义字段值的默认值。由于在同一个仓库中,代码规范通常是一致的,因此我们只需配置一次默认值,之后就无需在每个 lib/package.json 文件中重复配置入口目录。

注意事项

TypeScript 项目配置

在 TypeScript 项目中,需要使用 TypeScript 提供的 Project Reference 能力,它可以帮助我们结合 unplugin-monorepo使用源码开发。

Project reference 提供了以下能力:

  • 使 TypeScript 可以正确识别其他子项目的类型,而无须对子项目进行构建。
  • 在 VS Code 内进行代码跳转时,VS Code 可以自动跳转到对应模块的源代码文件。

根据上文的目录结构,我们需要在 app 的 tsconfig.json 内配置 compositereferences,并指向 lib 对应的相对目录:

app/tsconfig.json:

{
  "compilerOptions": {
    "composite": true
  },
  "references": [
    {
      "path": "../lib"
    }
  ]
}

另外,还需要在 lib 子项目中配置 rootDir:

lib/tsconfig.json:

{
  "compilerOptions": {
    "rootDir": "src"
  }
}

完成以上配置后,可以重启 VS Code 查看配置后的效果。

成果

结合前文的对比图了解这个方案与现有方案的差异:

方案对比unplugin-monorepo自定义 package.json export 条件resolve.alias别名映射
不需要配置 lib是,插件设置默认值后可以不配置
不需要配置 app否,不过仅需要接入插件
不依赖 Node.js 模块解析
仅使用工具的标准能力否,但是易于集成
支持深度引入否需要额外实现不同文件后缀引入
可被其他构建工具使用否,目前使用了特定的 resolve 上下文函数,后续可拓展

unplugin-monorepo 虽然能解决我这几年来 Monorepo 项目开发与构建的痛点,但是它目前还处于早期,仅实现了 Vite/Rollup 的逻辑。我已经将相关代码已经发布到 GitHub,您可以通过 github.com/ChuHoMan/un… 直接访问或者使用。

总结

本文探讨了在 JavaScript 生态系统中,随着 npm/yarn/pnpm workspaces 的流行,Monorepo(单一代码仓库)逐渐受到开发团队的青睐。Monorepo 提供了代码共置、共享及并行开发的优势,并简化了发布周期。然而,工具链并未完全适应这些新流程,尤其是在处理本地包的开发时,仍面临挑战。文章总结了当前常用的实现方案,并介绍了一种通过自定义 unplugin 插件的新方法,以解决 Monorepo 中包的源码引用问题。这种方法试图在不依赖繁琐配置的前提下,实现透明且简洁的包管理,并提供了一些 TypeScript 项目中的最佳实践。

结语

如果你对本文的内容感兴趣,或者想了解更多关于前端开发,欢迎关注我的公众号 Label 也是小猪呀

相关资料

  • vite-ts-monorepo-rfc 是该项目的主要灵感来源,在该方案实施前,我也只是使用了 vite.config.ts 中的 conditions 字段,但后来认知了 RFC 中提到的痛点,因此决定开发此插件。
  • @rsbuild/plugin-source-build 为本项目配置 TypeScript Project Reference 提供了灵感。
  • vite 绝大部分工具函数都来自 vite
  • parcel 阅读依赖解析的概念