第6章:模块化工程 —— tsconfig、模块、声明文件与迁移
约 4 分钟 · 更新于 2026-09-01
第6章:模块化工程 —— tsconfig、模块、声明文件与迁移
从单文件进入真实项目后,难点从语法转向边界:哪些文件属于项目、模块怎样解析、类型从哪里来、第三方 JavaScript 如何接入,以及旧项目如何渐进迁移。
一、ES 模块是默认选择
ts
// user.ts
export interface User {
id: string;
name: string;
}
export function createUser(id: string, name: string): User {
return { id, name };
}
ts
// main.ts
import { createUser, type User } from "./user.js";
const user: User = createUser("1", "Ada");
import type 明确导入只用于类型,可帮助理解类型擦除并减少某些构建配置下的运行时导入。
二、默认导出还是命名导出
命名导出通常更适合大型项目:
- 导入名稳定。
- IDE 自动导入更明确。
- 一个模块可公开多个相关能力。
- 重构时更容易全局查找。
默认导出适合模块确实只有一个主要实体、框架约定或既有生态。团队应统一,不必绝对化。
三、理解 tsconfig 的层次
json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"declaration": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
需要区分:
- 文件集合:files、include、exclude。
- 语言输出:target、lib。
- 模块系统:module、moduleResolution。
- 类型规则:strict 及其子项。
- 产物:outDir、declaration、sourceMap。
- 性能:incremental、skipLibCheck、项目引用。
四、模块解析必须匹配运行环境
常见组合:
- Node.js ESM/CJS:优先理解 NodeNext。
- 由现代 bundler 处理:可使用匹配工具链的 bundler 解析模式。
- 库发布:必须同时考虑 package.json 的 type、exports、类型声明和消费端。
模块错误常常不是 TypeScript 语法问题,而是以下约定不一致:
text
源码 import 写法
↕
tsconfig module/moduleResolution
↕
package.json type/exports
↕
Node 或 bundler 的真实解析规则
五、路径映射不是运行时能力
json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@app/*": ["src/*"]
}
}
}
ts
import { UserService } from "@app/services/user-service";
paths 主要告诉 TypeScript 如何解析类型,不保证 Node.js 或浏览器能直接理解。Bundler、测试工具和运行环境必须配置对应别名,否则会“类型检查通过、运行时报找不到模块”。
六、命名空间与模块
历史代码或无模块脚本可能使用 namespace:
ts
namespace Geometry {
export interface Point { x: number; y: number }
}
现代应用一般优先 ES 模块。命名空间仍可能出现在:
- 老式全局脚本。
- 某些声明文件。
- 需要声明合并的库类型。
不要在新模块化项目中同时建立两套组织体系。
七、声明文件 .d.ts
声明文件描述现有 JavaScript 的类型,不提供实现:
ts
// legacy-calculator.d.ts
export function add(left: number, right: number): number;
常见来源:
- 包自己附带类型。
- @types/* 社区声明。
- 项目本地声明。
- 全局环境声明。
声明写错会让编译器相信错误事实,因此应以真实运行时 API 为准,并配套类型测试或集成测试。
八、为无类型模块补声明
ts
declare module "legacy-sdk" {
export interface ClientOptions {
endpoint: string;
}
export class Client {
constructor(options: ClientOptions);
request(path: string): Promise<unknown>;
}
}
不要一开始写:
ts
declare module "legacy-sdk";
这会把整个模块视为 any。可以临时使用,但必须登记后续精化任务。
九、从 JavaScript 渐进迁移
一种稳妥路径:
- 建立 tsconfig.json。
- 开启 allowJs,暂时允许 JS 与 TS 共存。
- 使用 checkJs 或 // @ts-check 检查关键 JS。
- 用 JSDoc 补充边界类型。
- 从无依赖工具模块和领域模型开始改 .ts。
- 逐步开启严格选项。
- 清理 any、@ts-ignore 和宽泛声明。
JSDoc 示例:
js
// @ts-check
/**
* @param {number[]} values
* @returns {number}
*/
export function total(values) {
return values.reduce((sum, value) => sum + value, 0);
}
十、抑制错误的工具
- @ts-expect-error:期望下一行存在错误;错误消失时会提醒,优于永久忽略。
- @ts-ignore:无条件忽略,容易留下沉默债务。
- as unknown as T:强行穿透检查,应极少使用。
- skipLibCheck:跳过声明文件内部检查,不等于跳过自己代码的类型安全。
推荐给每次抑制添加原因和清理条件。
十一、边界设计
模块不应只按“controllers/services/utils”机械分层,也可按领域能力组织:
text
src/
├── booking/
│ ├── model.ts
│ ├── service.ts
│ └── api.ts
├── payment/
└── shared/
公共模块需要明确:
- 哪些类型和函数是稳定 API。
- 哪些只是内部实现。
- 是否暴露第三方依赖类型。
- 是否会造成循环依赖。
十二、常见误区
- paths 配好后认为 Node 自动支持别名。
- 混淆模块目标和模块解析策略。
- 通过一个 declare module 把第三方库整体变成 any。
- 迁移时一次开启所有严格选项,导致项目失控。
- 长期保留 @ts-ignore,没有债务清单。
- 声明文件与真实 JavaScript 实现不同步。
- 只按技术层分目录,领域边界持续互相引用。
- 库发布只验证自己能构建,不验证消费端导入。
十三、实践练习
- 将单文件项目拆成 model.ts、service.ts、main.ts。
- 配置路径别名,并分别为 TypeScript、测试工具和运行环境配置解析。
- 给一个无类型 JavaScript 函数编写 .d.ts。
- 用 allowJs + checkJs 检查一个 JS 模块。
- 搜索项目中的 @ts-ignore,改为 @ts-expect-error 或消除根因。
十四、总结
- 现代 TypeScript 项目优先使用 ES 模块。
- tsconfig 同时决定文件集合、检查规则、模块解析和产物。
- 模块配置必须与 Node、bundler 和 package.json 一致。
- paths 不是运行时别名实现。
- .d.ts 是对运行时事实的声明,错误声明同样危险。
- JavaScript 迁移应渐进推进,并持续缩小 any 与抑制指令。
请继续阅读:第7章:高级类型。
原始资料引用