在 Monorepo 里统一 TypeScript 路径别名:一次 Vite、TS Server 与测试工具的对齐记录

最近整理一个前端 Monorepo 时,我又踩到了那个很典型的问题:编辑器里 @shared/utils 能正常跳转,类型提示也没问题,但一到构建阶段,Vite 就报模块找不到。更糟糕的是,测试环境和本地开发环境表现还不一致。

这种问题的根源通常不是某一个工具配置错了,而是“我们以为只有一个别名系统,实际上每个工具都在各自解析路径”。

1. TypeScript 的 paths 只解决类型系统,不自动影响运行时

这是最容易被忽略的点。tsconfig.json 里的 compilerOptions.paths 本质上是告诉 TypeScript:

“当你做类型检查和模块解析推断时,可以把这个别名映射到这些目录。”

它并不会自动修改 Vite、Node.js、Vitest、Jest、ESLint 或打包产物里的真实导入路径。

所以当我看到编辑器一切正常时,我一度误以为别名已经配置完成。实际上只是 TS Server 帮我“看懂了”,真正运行代码的工具链并没有同步知道这层映射。

2. Monorepo 场景更容易失配

单仓库多包结构下,问题会进一步放大,因为常见目录形态是这样的:

  1. apps/web
  2. apps/admin
  3. packages/shared
  4. packages/ui

如果根目录有一份 tsconfig.base.json,子应用又各自 extends 它,那么路径别名经常在“类型层面共享成功”,但具体打包器是在每个应用目录启动的。此时工具解析根相对路径、工作目录、软链接包、源码入口都可能产生分歧。

我后来统一的原则是:

  1. 根层定义类型语义。
  2. 每个运行时工具都显式继承或读取同一份映射。
  3. 优先让包通过标准导出工作,路径别名只作为开发体验增强,而不是跨包依赖的唯一方式。

3. 先问自己:你是在做“包依赖”还是“源码别名”?

这一步非常关键。我把依赖关系分成两类:

  1. 业务应用依赖公共包,例如 @roper/shared
  2. 应用内部为了减少相对路径层级,用 @/components 指向本地源码目录。

这两类需求不要混在一起。

如果是第一类,最好走标准 package 导出,也就是在 packages/shared/package.json 中声明 nameexportstypes 等,让应用像依赖正常 npm 包一样依赖它。这样打包器、测试器、IDE 对它的认知会更一致。

如果是第二类,本地源码别名才适合用 pathsresolve.alias。这种别名的边界应该尽量局限在单个应用内部,避免跨应用、跨包互相引用源码目录。

4. Vite 与 Vitest 需要明确共享解析配置

我最初只在 vite.config.ts 里写了 resolve.alias,结果 vitest 虽然能复用一部分配置,但在某些拆分配置场景下仍然没有完整继承,最后表现成“页面能跑,测试却找不到模块”。

后来我做了两件事:

  1. 把路径映射抽到一个共享函数里。
  2. 明确让 Vite 与 Vitest 都读取同一份解析规则。

这样做的价值不只是少写几行配置,而是降低“有的人改了构建配置,却忘了同步测试配置”的概率。路径别名这种基础设施类配置,越分散越容易慢慢漂移。

5. Node 运行脚本时要额外留心

很多团队会在仓库里保留一些直接用 Node 执行的脚本,比如代码生成、数据迁移、构建辅助命令。此时如果脚本源码里也用了 TypeScript 别名,问题会再次出现,因为裸 Node 并不认识 tsconfig.paths

这时候通常有三种选择:

  1. 构建后再执行,让打包器或转译器处理掉路径。
  2. 使用支持别名解析的运行器。
  3. 干脆让这类脚本只使用相对路径或 package 名称,避免再引一层复杂度。

我最后倾向第三种。工程中有些地方值得抽象,有些地方最好老老实实朴素一点,维护成本反而更低。

6. 这次整理后我保留的实践

最后落下来的做法如下:

  1. tsconfig.base.json 只放通用编译选项与跨项目共享的基础别名。
  2. 应用内别名统一用 @/* 指向自身 src/*
  3. 跨包引用优先使用 package name 与 exports,不直接别名到另一个包的 src
  4. Vite、Vitest、ESLint 解析器尽量复用同一份路径信息。
  5. 能不用别名的简单脚本就不用别名。

整理完之后,最明显的收益其实不是“错误没了”,而是新同事进入项目时更容易理解:什么场景该用包导出,什么场景只是为了避免 ../../../。一套约定如果能被快速解释清楚,往往才是真正稳定的约定。