本文面向:正在学习 TypeScript 工程化配置、需要在 monorepo 中管理多包编译的开发者。
预计阅读时间:10 分钟
最终效果:掌握 tsconfig 核心字段、ESM 模块配置、路径别名设置,以及 monorepo 下多包协作的配置策略。
为什么 tsconfig 这么重要
TypeScript 编译器的行为完全由 tsconfig.json 控制。同一个 TypeScript 代码,在不同的 tsconfig 配置下可能编译出完全不同的结果:模块格式不同、目标语法不同、甚至有些写法能过编译有些不能。
很多初学者遇到的"明明类型检查过了但运行报错"问题,根源往往就是 tsconfig 配置不对。
ChatCrystal 的项目结构
ChatCrystal 是一个 npm workspaces monorepo,包含三个工作区:
ruby
ChatCrystal/
├── tsconfig.base.json # 共享的基础配置
├── shared/ # @chatcrystal/shared --- 纯类型定义
│ ├── tsconfig.json
│ ├── package.json # "type": "module", main 指向 types/index.ts
│ └── types/
├── server/ # @chatcrystal/server --- Fastify 后端
│ ├── tsconfig.json
│ ├── package.json # "type": "module"
│ └── src/
├── client/ # React 前端
│ ├── tsconfig.json # 项目引用入口
│ ├── tsconfig.app.json # 应用代码配置
│ ├── tsconfig.node.json # Vite 配置文件的配置
│ ├── package.json # "type": "module"
│ ├── vite.config.ts
│ └── src/
└── electron/ # Electron 桌面应用(独立编译)
├── tsconfig.json
└── main.ts
四个子项目,四种不同的编译需求。下面逐一拆解。
第一层:共享基础配置 tsconfig.base.json
为了避免四个子项目的 tsconfig.json 里重复写大量相同的选项,ChatCrystal 在根目录维护了一个 tsconfig.base.json,各子项目通过 extends 继承它:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}
逐个解释关键字段:
target: "ES2022" --- 编译输出的 JavaScript 语法版本。ES2022 意味着 async/await、?.、??、class fields 等语法都不会被降级,直接保留原样。如果你的运行环境是 Node.js 18+ 或现代浏览器,ES2022 是一个安全的选择。
module: "ESNext" --- 输出的模块格式。ESNext 表示使用原生 ESM 的 import/export 语法,不做任何转换。配合 "type": "module" 的 package.json,Node.js 会以 ESM 模式加载这些文件。
moduleResolution: "bundler" --- 这是 TypeScript 5.0 引入的新模块解析策略。它假设代码最终会经过打包器(Vite、webpack 等)处理,因此允许省略 .js 扩展名、支持 package.json 的 exports 字段。这是目前前端项目和使用打包器的 Node.js 项目的推荐选择。
strict: true --- 开启所有严格类型检查选项,包括 strictNullChecks、noImplicitAny、strictFunctionTypes 等。这是 TypeScript 团队推荐的默认配置,能在编译阶段捕获大量潜在错误。
isolatedModules: true --- 确保每个文件都能被独立编译。像 esbuild、swc 这类工具是逐文件转译的,不理解跨文件的类型信息。开启此选项后,TypeScript 会禁止那些依赖跨文件类型推断的写法(比如 const enum 的跨文件使用),保证代码能被这些工具正确处理。
declaration: true 和 declarationMap: true --- 生成 .d.ts 类型声明文件和对应的 source map。这对 shared 包特别重要,因为它直接导出 TypeScript 源码,消费方需要类型声明才能获得正确的类型提示。
第二层:shared 包 --- 零构建的类型共享
shared 包的定位很纯粹:只提供类型定义,不需要编译产物。它的 package.json 直接指向 TypeScript 源文件:
perl
{
"name": "@chatcrystal/shared",
"type": "module",
"main": "./types/index.ts",
"types": "./types/index.ts"
}
tsconfig.json 继承基础配置,加上 composite: true:
json
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./types",
"composite": true
},
"include": ["types/**/*"]
}
composite: true 是 project references 所必需的。它告诉 TypeScript 这个项目可以被其他项目引用,并且会生成 .tsbuildinfo 文件来追踪增量编译状态。当 server 或 client 引用 shared 时,TypeScript 能知道 shared 的哪些文件发生了变化,只重新检查变化的部分。
为什么 shared 可以直接导出 .ts 文件?因为 server 用 tsx 运行(直接支持 TypeScript),client 用 Vite 构建(原生支持 TypeScript 导入)。两个消费方都不需要 shared 提前编译成 JavaScript。
第三层:server 包 --- tsx 直接运行
server 的 tsconfig.json:
perl
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "..",
"jsx": "react-jsx",
"paths": {
"@chatcrystal/shared": ["../shared/types/index.ts"]
}
},
"include": ["src/**/*", "../shared/types/**/*"]
}
开发阶段,server 通过 tsx watch src/index.ts 启动。tsx 是一个 TypeScript 执行器,它在运行时即时转译 TypeScript,不需要预先编译。这让开发体验非常流畅 --- 改了代码,tsx 自动重启。
但生产构建仍然用 tsc:
json
"build": "tsc && node scripts/copy-seed-data.mjs"
这里有个细节:rootDir 设为 ..(项目根目录),而不是 ./。这是因为 server 的代码里会 import shared 包的类型,而 shared 位于 ../shared/。如果 rootDir 只设为 ./,TypeScript 会因为引用了 outsideRootDir 的文件而报错。把 rootDir 提升到根目录后,编译输出的目录结构会保留完整的路径层级:dist/server/src/... 和 dist/shared/types/...。
paths 配置让 TypeScript 知道 import { Note } from '@chatcrystal/shared' 应该解析到 ../shared/types/index.ts。这只影响类型检查,运行时的模块解析由 Node.js 的 workspaces 机制处理。
第四层:client 包 --- Vite 的双 tsconfig 模式
client 的配置最复杂,因为它用了 TypeScript 的 project references 模式,把配置拆成了三个文件。
tsconfig.json --- 纯粹的引用入口:
json
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
它本身不包含任何编译选项,只是告诉 IDE 和 tsc -b(增量构建)去检查两个子配置。
tsconfig.app.json --- 应用代码的配置:
json
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}
noEmit: true 是关键。Vite 使用 esbuild 做转译,TypeScript 只负责类型检查,不负责输出 JavaScript。这就是为什么 client 的 build 脚本是 tsc -b && vite build --- tsc 只做类型检查,真正的构建由 vite 完成。
jsx: "react-jsx" --- 使用 React 17+ 的 JSX 转换,不需要在每个组件文件里 import React。
路径别名 @/* --- 这个配置让 import { useNotes } from '@/hooks/useNotes' 能被 TypeScript 正确解析。但这只是类型检查层面的。运行时还需要 Vite 的 resolve.alias 配置配合:
csharp
// vite.config.ts
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
两边都配了,路径别名才能在开发和构建中正常工作。
tsconfig.node.json --- 专门给 vite.config.ts 用的配置:
json
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
为什么需要单独一个配置?因为 vite.config.ts 运行在 Node.js 环境,不需要 DOM 类型,但需要 Node.js 类型。它有独立的 types: ["node"] 设置。如果和应用代码共用一个 tsconfig,要么应用代码会看到 Node.js 的全局类型(可能产生误用),要么 vite.config.ts 会缺少 Node.js 类型。
第五层:electron --- 传统 tsc 编译
electron 的 tsconfig.json 是最传统的配置:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"declaration": false,
"sourceMap": true
},
"include": ["*.ts"]
}
注意两个关键差异:
module: "commonjs" --- Electron 的主进程在 Node.js 环境运行,但 electron-builder 打包时对 ESM 的支持有限。使用 CommonJS 格式可以避免各种打包兼容性问题。这也是为什么主项目的 server 和 client 都用 ESM,唯独 electron 用 CJS。
moduleResolution: "node" --- 对应 CommonJS 的传统模块解析策略。它不支持 exports 字段,要求使用 index.js 入口,扩展名可以省略但只按 .js、.ts 等顺序尝试。
declaration: false --- electron 不是 library,不需要对外暴露类型声明。
ESM 全家桶的配置要点
ChatCrystal 的 shared、server、client 都使用 ESM。配置 ESM 需要三个层面配合:
- package.json 设置
"type": "module"--- 告诉 Node.js 以 ESM 模式加载.js文件 - tsconfig 设置
"module": "ESNext"--- 编译输出保留import/export语法 - tsconfig 设置
"moduleResolution": "bundler"--- 允许打包器风格的导入(省略扩展名等)
如果某个环节不匹配,就会出现经典的 ERR_MODULE_NOT_FOUND 或 SyntaxError: Cannot use import statement 错误。
构建脚本速查
arduino
# shared --- 无需构建,直接导出 .ts 源码
# server --- 开发用 tsx,生产用 tsc
npm run dev -w server # tsx watch src/index.ts
npm run build -w server # tsc && copy-seed-data
npm run start -w server # node dist/server/src/index.js
# client --- tsc 类型检查 + vite 构建
npm run dev -w client # vite (HMR)
npm run build -w client # tsc -b && vite build
# electron --- 独立 tsc 编译
tsc -p electron/tsconfig.json
小结
ChatCrystal 的 tsconfig 设计遵循一个原则:每个子项目只配置自己需要的东西,共享的部分通过 extends 复用。shared 用 composite 支持 project references,server 用 tsx 简化开发流程,client 用 project references 拆分应用代码和工具配置,electron 用 CJS 保证打包兼容性。
理解了这些配置背后的"为什么",你就能在自己的项目中做出合理的选择,而不是从网上复制一份模板然后祈祷它能用。
本文基于 ChatCrystal v0.4.10 的实际配置。项目地址:github.com/ZengLiangYi...
项目地址:github.com/ZengLiangYi...
如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。