本节目标:把「模块从哪来」这件事从随手写相对路径,升级为一套可维护的工程约定。读完后你能配置
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 环境变量与配置的类型化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。