想象一下,你正坐在电脑前,屏幕上是密密麻麻的 require() 调用和一堆令人头秃的 @types 包。你的项目像是一个拼凑起来的弗兰肯斯坦怪物:有些模块是 CommonJS (CJS),有些是 ES Modules (ESM),而 TypeScript 的类型系统就像是在迷雾中开车——你总觉得哪里不对劲,但就是抓不住那个导致运行时崩溃的幽灵 bug。
别担心,这种情况我太熟悉了。就在上周,我还帮一个团队重构了他们庞大的遗留代码库,他们的问题和你现在面临的一模一样:依赖冲突让构建速度慢得像蜗牛,类型定义缺失让 IDE 的智能提示变成了摆设,而代码复用率低得让人想哭。
今天,我们不谈枯燥的理论,我们来聊聊怎么把这些烂摊子收拾得井井有条。我们要做的,不仅仅是“升级”,而是一场关于确定性的革命。
为什么我们要告别 CommonJS?
首先,让我们坦诚一点:CommonJS (require) 在 Node.js 早期确实好用。它是同步的,模块加载简单,大家都能懂。但是,随着项目变大,它的缺点就像老房子的地基裂缝一样,越来越明显。
- 静态分析困难:CJS 是动态的。
require('./' + moduleName)这种写法,编译器根本不知道你要加载什么。这意味着 Tree Shaking(摇树优化)几乎不可能生效,你的最终打包文件里塞满了从未使用过的代码。 - 循环依赖噩梦:两个模块互相引用?在 CJS 中,这通常会导致
undefined或者部分初始化的对象,调试起来简直是心理折磨。 - 类型推断的盲区:虽然 TypeScript 可以通过
tsconfig.json中的allowJs或esModuleInterop来缓解一些问题,但它本质上还是在处理 JavaScript 的动态特性,而不是真正的静态模块系统。
相比之下,ES Modules (ESM) 是语言层面的标准。它是静态的,这意味着编译器可以在编译时就看清所有的导入导出关系。这不仅让 Tree Shaking 成为可能,还让 TypeScript 的类型检查变得无比精准。
第一步:配置你的 tsconfig.json 战场
在动手写代码之前,我们需要先调整我们的武器库。很多开发者忽略了 tsconfig.json 中的关键配置,导致即使写了 ESM,Node.js 还是把它当成 CJS 处理,或者 TypeScript 报出一堆莫名其妙的错误。
创建一个干净的项目,打开 tsconfig.json,确保你有以下核心配置:
{
"compilerOptions": {
// 目标 ES 版本,建议 ES2020 或更高,以获得更好的原生支持
"target": "ES2020",
// 模块系统:这是最关键的部分!
// 'node16' 或 'nodenext' 是最现代的选择,它们严格遵循 Node.js 的 ESM 规范
// 如果你的构建工具(如 Webpack/Vite)处理模块解析,也可以用 'esnext'
"module": "Node16",
// 模块解析策略,必须与 module 匹配
"moduleResolution": "Node16",
// 允许导入 .js 扩展名的文件(在 ESM 中这是必须的)
"allowJs": true,
// 生成声明文件,方便其他项目引用你的类型
"declaration": true,
"declarationMap": true,
// 确保模块路径正确解析
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
// 其他常用配置
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
这里有个坑要注意:如果你使用 "module": "Node16",那么你在导入本地模块时,必须加上 .js 后缀,哪怕你的源文件是 .ts。这是因为 Node.js 的 ESM 实现是基于文件扩展名来判断模块类型的。
// ❌ 错误写法:Node16 模块解析下,这会导致运行时错误
import { helper } from './helper';
// ✅ 正确写法:加上 .js 后缀
import { helper } from './helper.js';
这看起来有点反直觉,对吧?但在 ESM 的世界里,这是为了保证浏览器和 Node.js 行为的一致性。
第二步:重构代码结构,拥抱纯 ESM
现在,让我们看看如何实际编写模块。我们将通过一个具体的例子——一个简单的日志记录器和数据验证库——来展示从混乱到清晰的转变。
1. 创建可复用的核心模块
假设我们有一个 utils/validator.ts 文件,里面包含了一些通用的验证函数。
// src/utils/validator.ts
// 使用命名导出,明确每个导出的用途
export function isEmail(value: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value);
}
export function isPositiveNumber(value: number): boolean {
return Number.isFinite(value) && value > 0;
}
// 导出一个接口,方便其他模块使用类型定义
export interface ValidationResult {
success: boolean;
message?: string;
}
export function validateInput(input: unknown): ValidationResult {
if (typeof input !== 'string') {
return { success: false, message: 'Input must be a string' };
}
if (!isEmail(input)) {
return { success: false, message: 'Invalid email format' };
}
return { success: true };
}
注意,我们没有使用 module.exports,也没有使用 export default。我们使用了具名导出。这在 ESM 中是推荐的做法,因为它使得 Tree Shaking 更加高效,并且在使用 IDE 时能提供更准确的自动补全。
2. 构建服务层
接下来,我们创建一个 services/userService.ts,它依赖于上面的验证器。
// src/services/userService.ts
import { validateInput, ValidationResult } from '../utils/validator.js';
import { Logger } from '../utils/logger.js';
// 假设我们有一个 Logger 类
class Logger {
log(message: string) {
console.log(`[USER_SERVICE]: ${message}`);
}
error(message: string, error: Error) {
console.error(`[USER_SERVICE ERROR]: ${message}`, error);
}
}
export class UserService {
private logger: Logger;
constructor() {
this.logger = new Logger();
}
async registerUser(email: string) {
this.logger.log(`Attempting to register user: ${email}`);
const validation: ValidationResult = validateInput(email);
if (!validation.success) {
this.logger.error('Validation failed', new Error(validation.message!));
throw new Error(validation.message || 'Unknown validation error');
}
// 模拟数据库操作
await this.saveToDatabase(email);
this.logger.log('User registered successfully');
return { id: 1, email };
}
private async saveToDatabase(email: string): Promise<void> {
// 模拟异步 IO
return new Promise((resolve) => setTimeout(resolve, 100));
}
}
// 导出单例实例,方便全局使用
export const userServiceInstance = new UserService();
这里的关键点在于:
- 显式依赖:每一行
import都清晰地表明了数据来源。 - 类型安全:
validateInput返回的是强类型的ValidationResult,IDE 会立刻告诉你有哪些字段可用。 - 无副作用:
UserService是一个类,它的状态是封装的,不会像 CJS 那样因为模块加载顺序不同而产生难以预测的全局状态污染。
3. 入口点
最后,我们的 index.ts 变得极其简洁:
// src/index.ts
import { userServiceInstance } from './services/userService.js';
async function main() {
try {
const user = await userServiceInstance.registerUser('test@example.com');
console.log('Registered User:', user);
} catch (error) {
console.error('Failed to register user:', error);
}
}
main();
第三步:解决“类型定义缺失”的痛点
这是大多数 TypeScript 项目中最头疼的问题。当你安装一个第三方库时,往往会出现 Could not find a declaration file for module 'some-lib' 的错误。
方案 A:寻找官方类型
首先,检查 npm 包本身是否自带 .d.ts 文件。现代优秀的库都会这样做。如果没有,去 @types 组织看看有没有社区维护的类型定义包。
npm install --save-dev @types/lodash
方案 B:处理没有类型的第三方库
如果某个库既没有自带类型,也没有 @types 包怎么办?比如一个老旧的 CJS 库,或者你自己写的内部工具库。
1. 使用 declare module
你可以创建一个 .d.ts 文件来手动声明类型。
// types/custom-lib.d.ts
declare module 'custom-lib' {
export function doSomething(): string;
export const CONFIG: {
version: string;
debug: boolean;
};
}
然后在你的代码中使用:
import { doSomething, CONFIG } from 'custom-lib';
console.log(doSomething()); // TypeScript 知道这是一个 string
2. 更高级的做法:创建你自己的类型包装器
与其直接导入不安全的库,不如创建一个安全的包装层。
// src/wrappers/customLibWrapper.ts
import * as rawCustomLib from 'custom-lib'; // 假设这里没有类型
// 定义我们期望的接口
interface CustomLibExports {
doSomething: () => string;
CONFIG: { version: string; debug: boolean };
}
// 创建一个类型断言,告诉 TypeScript 这个模块长什么样
const customLib = rawCustomLib as unknown as CustomLibExports;
export { customLib };
这样,项目中其他所有地方都通过 customLibWrapper 来访问该库,而不是直接访问原始模块。这不仅解决了类型问题,还隔离了潜在的运行时风险。
第四步:解决依赖冲突与版本锁定
当你的项目依赖越来越多时,package.json 里的 dependencies 和 devDependencies 会变得难以管理。特别是当不同的库依赖于同一库的不同版本时,冲突就来了。
1. 使用 pnpm 或 yarn workspaces
传统的 npm install 会在 node_modules 中创建扁平的结构,但这往往导致重复安装。pnpm 使用符号链接和硬链接,不仅节省磁盘空间,还能强制严格的依赖可见性。
如果你使用 pnpm,它会阻止你意外地访问未明确声明的依赖。这听起来很麻烦,但实际上它防止了很多“在我机器上能跑,在你那里报错”的问题。
2. 精确锁定版本
始终使用 package-lock.json (npm) 或 yarn.lock / pnpm-lock.yaml。这些文件确保了每个开发者、每个 CI/CD 环境安装的完全相同的依赖树。
// package.json 片段
{
"dependencies": {
"express": "^4.18.2",
"lodash": "^4.17.21"
},
"devDependencies": {
"typescript": "^5.3.0",
"@types/express": "^4.17.21"
}
}
注意 ^ 的使用。在生产环境中,你可能希望更严格一些,或者使用 pnpm 的 strict-peer-dependencies 选项来在构建失败时立即通知你依赖冲突。
3. 处理 Peer Dependencies
很多库(尤其是 UI 组件库)需要宿主应用提供特定版本的 React 或 Vue。如果版本不匹配,npm/yarn/pnpm 会发出警告甚至错误。
最佳实践:
- 在项目根目录明确声明所有 peer dependencies 的版本。
- 使用
peerDependenciesMeta来标记可选的 peer dependencies。
{
"peerDependencies": {
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
}
第五步:提升代码复用率的架构技巧
模块化不仅仅是语法上的改变,更是架构思维的转变。如何让代码更容易被复用?
1. 单一职责原则 (SRP) 的极致应用
不要把一个大文件拆成小文件就叫模块化。真正的模块化是每个模块只做一件事,并且做得很好。
- 坏例子:
utils.ts包含了字符串处理、日期格式化、HTTP 请求封装和数据库连接。 - 好例子:
utils/string.tsutils/date.tsnetwork/http-client.tsdatabase/connection-pool.ts
2. 使用 Barrel Files 谨慎导出
Barrel File (index.ts) 是一种将多个模块合并为一个接口的模式。它可以简化导入语句,但也可能阻碍 Tree Shaking。
// src/utils/index.ts
export * from './string.js';
export * from './date.js';
建议:
- 对于核心库,可以使用 Barrel File 提供统一的入口。
- 对于大型应用,鼓励直接导入具体模块,以最大化构建优化。
- 永远不要使用
export * from 'lib'这种方式暴露整个第三方库,除非你明确知道自己在做什么。
3. 依赖注入 (DI) 提高可测试性和复用性
不要在你的模块内部硬编码依赖。通过构造函数或参数注入依赖。
// 之前的 UserService 示例其实已经体现了这一点
// 如果需要更复杂的 DI,可以引入一个简单的容器或使用框架如 InversifyJS
这使得你可以轻松地为 UserService 提供 Mock 的数据库连接或 Logger,从而在不修改业务逻辑的情况下进行单元测试。
实战演练:迁移指南
如果你有一个现有的 CJS 项目,不要试图一次性全部重写。采取渐进式策略:
第一阶段:配置环境
- 更新
tsconfig.json为 ESM 模式。 - 将所有
.ts文件的编译输出设置为.mjs或确保 Node.js 识别为 ESM。 - 在
package.json中添加"type": "module"。
- 更新
第二阶段:替换内部模块
- 从最核心的、依赖关系简单的模块开始。
- 将
require改为import。 - 将
module.exports改为export。 - 添加
.js后缀到相对路径导入中。
第三阶段:处理第三方依赖
- 对于支持 ESM 的库,直接替换。
- 对于仅支持 CJS 的库,使用
createRequire进行兼容,或者寻找替代方案。
import { createRequire } from 'module'; const require = createRequire(import.meta.url); const cjsModule = require('legacy-cjs-module');- 注意:尽量避免混合使用,长期来看,推动社区或自己贡献 ESM 支持是更好的选择。
第四阶段:清理与优化
- 移除不再使用的导入。
- 运行类型检查
tsc --noEmit。 - 运行测试套件。
- 优化构建脚本,启用 Tree Shaking。
给小朋友也能听懂的比喻
如果把你的代码项目比作一个乐高城堡:
- CommonJS 就像是你有一大袋散乱的积木,每次你想加一个塔楼,你得从袋子里翻半天,而且有时候两块积木拼在一起会突然散开,因为你不知道它们是不是真的兼容。
- ES Modules 就像是乐高官方的套装盒子。每个盒子(模块)都有明确的标签,告诉你里面有什么零件。你可以清楚地看到哪个零件连到哪里。如果你想换掉一个窗户,你只需要拿出对应的那个小盒子,而不必拆开整个城堡。
- TypeScript 类型 就像是说明书上的彩色图示。它告诉你:“嘿,这个蓝色的方块只能连在这个红色的圆柱上,不能连在那个黄色的板上。” 这样,你就不会在拼完后发现城堡歪歪扭扭,甚至站不稳。
结语:迈向确定性的未来
从 CommonJS 迁移到 ES Modules 并不只是一次技术升级,它是一种思维方式的转变。它强迫你思考模块之间的边界,明确数据的流向,并拥抱静态分析的威力。
在这个过程中,你可能会遇到一些挫折:奇怪的导入错误、类型定义的缺失、构建速度的暂时下降。但请记住,这些都是暂时的。一旦你跨过了这道门槛,你会发现你的代码变得更加健壮、更易维护、更具可扩展性。
你不再需要猜测 this 指向哪里,不再需要担心模块加载的顺序,不再需要面对那些神秘的 undefined 错误。你拥有的,是一套清晰、透明、可预测的系统。
现在,打开你的编辑器,开始重构吧。你的未来代码会感谢你的。
附录:常见陷阱自查清单
| 问题 | 原因 | 解决方案 |
|---|---|---|
SyntaxError: Cannot use import statement outside a module |
Node.js 默认以 CJS 模式运行 | 在 package.json 中添加 "type": "module" |
ERR_REQUIRE_ESM |
尝试用 require 加载 ESM 模块 |
改用 import,或使用 createRequire |
Cannot resolve module |
缺少 .js 扩展名 |
在所有相对路径导入中添加 .js 后缀 |
Module not found: Can't resolve ... |
路径错误或别名配置不当 | 检查 tsconfig.json 中的 paths 和 baseUrl |
类型错误:Property 'x' does not exist on type 'typeof import(...)' |
默认导出与具名导出混淆 | 检查是 export default 还是 export const,并使用正确的导入语法 |
希望这篇指南能成为你 TypeScript 模块化之旅中的得力助手。如果有具体问题,欢迎随时深入探讨。
