《TypeScript编程实战》2.1 路径别名与 monorepo 结构

本节讲清路径别名与 monorepo 结构。先用相对路径失控的场景引出 paths 与 baseUrl 的编译期别名,再拆解别名运行时失效的根本原因,给出 tsc-alias、打包器 alias、Node subpath imports 三种补救方案;随后用 pnpm workspace 与 project references 组织多包仓库。读完你能设计一套可编译、可运行、可发布的目录结构。

本节目标:把「模块从哪来」这件事从随手写相对路径,升级为一套可维护的工程约定。读完后你能配置 paths 别名并知道它在运行时为什么失效,能说清 monorepo 的三种组织形态,能用 pnpm workspace 与 project references 搭出一个多包仓库。

2.1 路径别名与 monorepo 结构

在 1.1 节我们用 pnpm / tsx / tsup 搭起了一个单包项目,目录浅、文件少,import { foo } from "./utils/foo" 这种相对路径完全够用。但只要项目长到十几层目录,或者拆成多个包,相对路径就会立刻变成负担。

本节处理的就是「模块从哪来」这个问题:先讲别名,再讲承载别名的 monorepo 结构。两者是绑在一起的——单包项目用别名的收益有限,而 monorepo 不用别名几乎无法维护。

2.1.1 相对路径是怎么失控的

先看一个真实的目录,这是后端项目长到半年后的常见样子:

src/
  modules/
    order/
      services/
        order.service.ts
        order-pricing.service.ts
      repositories/
        order.repository.ts

现在 order.service.ts 想引用一个公共的日志工具:

// src/modules/order/services/order.service.ts
import { createLogger } from "../../../common/logger";
import { OrderRepository } from "../repositories/order.repository";
import { calcDiscount } from "./order-pricing.service";

问题不在于这一行难写,而在于它不稳定。你把这个文件从 services/ 挪到 handlers/,所有 ../ 的数量都要重算。你从 order.service.ts 复制一段代码到别的目录,../ 的数量又得改一遍。更糟的是编辑器自动补全给的是相对路径,于是同一次重构里会出现 ../../../common/logger 与 ../../../../common/logger 并存的情况——它们指向同一个文件,但没有任何工具会告诉你这一点。

业界对此的共识是:跨模块引用用绝对别名,模块内引用才用相对路径。也就是「近的相对、远的绝对」。

2.1.2 paths + baseUrl:编译期别名

TypeScript 在 tsconfig.json 里提供了两个选项来定义别名:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@common/*": ["src/common/*"],
      "@modules/*": ["src/modules/*"]
    }
  }
}

改完之后,前面那行 import 变成:

import { createLogger } from "@common/logger";
import { OrderRepository } from "@modules/order/repositories/order.repository";

这里有两个容易混淆的点,必须一次说清:

选项作用是否必需
baseUrl非相对导入的解析起点现代 TS 里可选,paths 的值可以直接写 ./src/*
paths把别名模式映射到实际文件路径是别名的核心

paths 的映射规则是「模式匹配 + 替换」:@common/* 里的 * 匹配任意一段路径,替换到 src/common/* 里。因此 @common/logger 被解析为 src/common/logger,再按标准模块解析规则补上 .ts。

早期版本要求 baseUrl 必须存在,paths 里的路径相对于它解析。较新的 TypeScript 已经允许省略 baseUrl,此时 paths 的值相对于 tsconfig.json 所在目录解析。新项目建议省略 baseUrl,少一个隐式依赖。

2.1.3 别名在运行时为什么会失效

这是本章最重要的一个「为什么」,也是无数人卡住一整天的原因。

paths 是 TypeScript 编译器的解析规则,它只影响两件事:类型检查时去哪找类型、以及 tsc 输出的 .d.ts 里写什么。它不会改写编译产物里的 import 语句。

我们做个实验。源文件:

// src/index.ts
import { createLogger } from "@common/logger";

const log = createLogger("app");
log.info("hello");

执行 tsc 之后,产物长这样:

// dist/index.js —— import 路径原封不动
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const logger_1 = require("@common/logger");
const log = (0, logger_1.createLogger)("app");
log.info("hello");

然后运行 node dist/index.js,得到:

Error: Cannot find module '@common/logger'

原因很直白:Node 不认识 @common/*。别名是 TypeScript 的私有约定,Node 的模块解析器只认相对路径、node_modules、以及包 exports 字段里声明的路径。tsc 出于「保留开发者意图」的考虑,故意不改写路径——它假设下游有一个打包器或运行时会处理别名。

记住这条判据:凡是只在 tsconfig.json 里配了 paths 就能跑起来的项目,一定是中间有别的工具在替你改写路径。要么是打包器,要么是运行时加载器。

2.1.4 三种让别名在运行时生效的方案

方案一:打包器 alias(推荐)

如果你用 tsup、esbuild、Vite 构建,在它们的配置里重复声明一次别名即可:

// tsup.config.ts
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  dts: true,
  clean: true,
  esbuildOptions(options) {
    options.alias = {
      "@common": "./src/common",
      "@modules": "./src/modules",
    };
  },
});

tsup 基于 esbuild,alias 会在打包阶段把别名替换成真实路径。这是生产环境最稳的做法,因为产物里根本不存在别名,运行时零依赖。

代价是你要维护两份别名声明:tsconfig.json 一份给类型检查,tsup.config.ts 一份给打包。两者不一致时,表现为「编辑器不报错但构建失败」或反过来。缓解办法是把别名抽到一个共享文件里,两边都 import 它——这也是 monorepo 里常见的 tsconfig.base.json 存在的理由之一。

方案二:tsc-alias 后处理

如果你的构建链路是纯 tsc(不打包),可以在编译后加一步改写:

npx tsc && npx tsc-alias

tsc-alias 读取 tsconfig.json 的 paths,把产物 .js 与 .d.ts 里的别名替换成相对路径。它不引入打包,产物仍是「一文件对一文件」,适合要发布到 npm 的库。

方案三:Node subpath imports

Node 从 12.19 起支持 package.json 的 imports 字段,用 # 前缀定义包内别名:

{
  "imports": {
    "#common/*": "./dist/common/*.js",
    "#modules/*": "./dist/modules/*.js"
  }
}
import { createLogger } from "#common/logger";

这是运行时原生支持的方案,不需要任何工具改写。注意两点:前缀必须是 #(这是规范要求,不能用 @),且它描述的是产物路径而非源码路径,因此 tsconfig.json 的 paths 仍要单独配一份给编辑器用。

三种方案的选择可以归纳成一张表:

方案适用场景产物是否含别名需维护几份配置
打包器 alias应用(后端服务、前端应用)否2
tsc-alias纯 tsc 构建的库否1
subpath imports纯 Node 运行、不想引入工具否2

2.1.5 monorepo 的三种组织形态

别名解决的是「同一个包内部怎么引用」,而当项目拆成多个包时,问题升级为「包与包之间怎么引用」。先看仓库的三种形态:

形态结构优点缺点
单体仓库一个 package.json,所有代码在 src/最简单,无跨包解析问题无法独立发布、依赖无法隔离
多包一仓(monorepo)packages/* 各自一个 package.json,共享一个仓库可独立发布、依赖共享、改动原子提交工具链复杂,构建顺序需管理
多仓多包(polyrepo)每个包一个 git 仓库权限与发布完全隔离跨仓改动需多次提交与发布,联调痛苦

判断标准是发布单元:如果这些代码永远一起发布,就留在一个包里;如果它们有不同的发布节奏、不同的使用方(比如 @acme/ui 要被三个前端项目引用),就该拆包。

拆包不是为了「看起来专业」。一个被拆坏的 monorepo 比单体仓库痛苦得多——构建慢、依赖循环、类型找不到,都是常见后果。先单体,等到真的疼了再拆。

2.1.6 用 pnpm workspace 搭一个多包仓库

pnpm 的 workspace 是最轻量的 monorepo 方案。根目录放一份 pnpm-workspace.yaml:

packages:
  - "packages/*"
  - "apps/*"

目录结构:

.
├── pnpm-workspace.yaml
├── package.json
├── tsconfig.base.json
├── packages/
│   ├── core/
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   └── src/index.ts
│   └── utils/
│       ├── package.json
│       ├── tsconfig.json
│       └── src/index.ts
└── apps/
    └── api/
        ├── package.json
        ├── tsconfig.json
        └── src/index.ts

关键在于包之间怎么互相引用。不要用 paths 指向别的包的 src,那样会让 apps/api 直接编译 packages/core 的源码,破坏包的边界。正确做法是用 workspace: 协议声明依赖:

{
  "name": "@acme/api",
  "dependencies": {
    "@acme/core": "workspace:*",
    "@acme/utils": "workspace:*"
  }
}

然后在代码里正常按包名引用:

// apps/api/src/index.ts
import { createOrder } from "@acme/core";
import { formatMoney } from "@acme/utils";

pnpm 会把 node_modules/@acme/core 做成指向 packages/core 的符号链接。这样 apps/api 看到的是 packages/core 的 package.json 里 main / types / exports 指向的产物,包边界天然成立。workspace:* 在发布时会被自动替换成真实版本号。

2.1.7 tsconfig 分层与 project references

每个包都写一遍 compilerOptions 显然不现实。标准做法是在根目录放一份 tsconfig.base.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "skipLibCheck": true,
    "esModuleInterop": true
  }
}

包内的 tsconfig.json 只写差异部分:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}

extends 是合并而非覆盖:子配置里出现的字段会覆盖父级同名字段,未出现的继承。注意 include / exclude / files 这类「文件列表」字段在 extends 中的行为是整体替换,且路径相对于父配置文件所在目录解析——这是最容易踩的坑,多包项目里务必在子配置里显式重写 include。

当包多到需要「按依赖顺序增量构建」时,用 project references:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "rootDir": "./src",
    "outDir": "./dist"
  },
  "references": [{ "path": "../core" }]
}

composite: true 会强制开启 declaration 并生成 .tsbuildinfo,让 TypeScript 能判断哪些包需要重编。配套命令是 npx tsc --build:它会沿着 references 的图拓扑排序,先编依赖再编依赖方,并且只重编过期的包、跳过未改动的包。这正是 monorepo 里「构建顺序」问题的官方答案。关于 tsconfig 的完整分层策略,1.2 节 1.2 严格模式与 tsconfig 分层 已经讲过原则,此处只补充多包场景的差异。

2.1.8 四类典型报错

报错一:Cannot find module '@common/logger' or its corresponding type declarations.

编辑器里正常、运行时报错,说明是 2.1.3 讲的运行时问题,加打包器 alias 或 tsc-alias。如果编辑器里也报错,检查 paths 的模式是否写成了 "@common" 而非 "@common/*"——缺少 * 时只能匹配精确的 @common。

报错二:error TS6059: File 'xxx.ts' is not under 'rootDir'

某个被引用的文件落在 rootDir 之外。多包项目里常见于用 paths 指向了别的包的 src。解决:改用 workspace: 依赖 + 包名引用。

报错三:error TS6307: File 'xxx.ts' is not listed within the file list of project

project references 模式下,某个文件被 import 了却不在任何 project 的 include 里。检查每个包的 include 是否覆盖了它的全部源码。

报错四:改了 packages/utils 但 apps/api 没重新编译

--build 模式依赖 .tsbuildinfo 判断过期,若 .tsbuildinfo 被误删或时间戳异常,可能误判。解决:npx tsc --build --force 强制全量重建一次。另外记得把 *.tsbuildinfo 加入 .gitignore——它是本地缓存,提交上去会造成 CI 与本地互相污染。

2.1.9 别名的取舍:什么时候不要用

最后给出一个反向建议。别名不是越多越好:

  • 别用 @/* 泛别名。它等于把整个 src 暴露成扁平命名空间,@/foo 与 @/bar/foo 可能同时存在,命名冲突难以发现。按领域建别名(@common/*、@modules/*)比一个万能 @/* 更有约束力。
  • 别在库包里用别名。库要发布到 npm,别名会给使用方带来额外的构建配置负担。库内部老老实实用相对路径,或者用 subpath imports。
  • 别用别名绕开包边界。别名只能指向同一个包内,跨包引用必须走包名。一旦你用 paths 指向 ../core/src,就等于把两个包焊死了,拆分失去意义。

小结

本节先解释了相对路径为什么会随目录深度与文件移动而失控,引出「近的相对、远的绝对」这条约定;随后用 paths 与 baseUrl 建立了编译期别名,并重点剖析了别名在运行时失效的根本原因——paths 只服务于类型检查与声明文件生成,不改写产物 import。围绕这个事实,我们给出三种补救方案:打包器 alias(应用首选)、tsc-alias(纯 tsc 库首选)、Node subpath imports(原生运行时方案)。

后半节把视角抬到仓库层:按「发布单元」判断是否拆包,用 pnpm workspace 的 workspace:* 协议建立包间依赖,用 tsconfig.base.json + extends 消除配置重复,用 project references 与 tsc --build 解决构建顺序与增量编译。

到这里目录结构与模块来源都定下来了,但项目里还有一类值不属于任何模块——它们随部署环境变化,且常常包含密钥。下一节 2.2 环境变量与配置的类型化 会把它们也纳入类型系统,让「配置缺了一项」从运行时崩溃变成启动时的编译错误。如果你还想对比不同 monorepo 方案的取舍,可以延伸阅读 TypeScript monorepo 与 Turborepo 与 前端 monorepo 方案对比 。

阅读导航:上一节:1.3 代码规范与提交门禁(ESLint / Biome / husky) · 下一节:2.2 环境变量与配置的类型化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes