TypeScript 项目配置:tsconfig、ESM、路径别名

本文面向:正在学习 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.jsonexports 字段。这是目前前端项目和使用打包器的 Node.js 项目的推荐选择。

strict: true --- 开启所有严格类型检查选项,包括 strictNullChecksnoImplicitAnystrictFunctionTypes 等。这是 TypeScript 团队推荐的默认配置,能在编译阶段捕获大量潜在错误。

isolatedModules: true --- 确保每个文件都能被独立编译。像 esbuild、swc 这类工具是逐文件转译的,不理解跨文件的类型信息。开启此选项后,TypeScript 会禁止那些依赖跨文件类型推断的写法(比如 const enum 的跨文件使用),保证代码能被这些工具正确处理。

declaration: truedeclarationMap: 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 需要三个层面配合:

  1. package.json 设置 "type": "module" --- 告诉 Node.js 以 ESM 模式加载 .js 文件
  2. tsconfig 设置 "module": "ESNext" --- 编译输出保留 import/export 语法
  3. 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 或私信交流,很乐意解答。

相关推荐
晓13131 小时前
【Cocos Creator 3.x】篇——第二章 入门
前端·javascript·游戏引擎
想要成为糕糕手1 小时前
前端必修课:JavaScript 数组与数据结构底层逻辑全解析
javascript·数据结构·面试
洞窝技术2 小时前
调教专属SKILL:周报助理,文案秘书
aigc
xiaofeichaichai2 小时前
React Hooks
前端·javascript·react.js
数据知道2 小时前
C++ 层拦截:修改 Blink 引擎与 V8 绑定的底层逻辑
javascript·数据采集·指纹浏览器·风控
手写码匠2 小时前
从零实现 Prompt 工程引擎:结构化提示、自动优化与多轮自省体系
人工智能·深度学习·算法·aigc
2301_773643622 小时前
ceph镜像
前端·javascript·ceph
To_OC3 小时前
万字解析《JS语言精粹》之第四章:函数15大核心精髓(JS灵魂核心)
前端·javascript·代码规范
宋拾壹3 小时前
同时添加多个类目
android·开发语言·javascript