说实话,刚接触 TypeScript 的时候,最让人头大的不是类型定义,而是“怎么把代码拆分开,又怎么把它们连起来”。
以前用 JS 写项目,直接 <script src="a.js"><script src="b.js"> 完事,或者 Node.js 时代 require('module') 走天下。但 TypeScript 出来之后,模块化变得既强大又混乱——ES Modules、CommonJS、命名空间(namespace)……到底该用哪个?为什么有时候 export 报了错,有时候 import 找不到模块?
今天这篇内容,我就用最直白的大白话,配合真实项目中的例子,带你彻底搞懂 TypeScript 模块化开发。无论你是刚入门的小白,还是想梳理知识体系的开发者,都能有所收获。
为什么 TypeScript 需要模块化?
先别急着看代码,我们想想:如果你在一个文件里写了 5000 行代码,维护起来是不是很痛苦?
模块化就是把大代码拆成小模块,每个模块负责一件事。这样做的目的是:
- 代码复用:写一次的逻辑,到处都能用
- 职责分离:不同人负责不同模块,互不干扰
- 易于测试:单个模块可以独立测试
- 加载优化:按需加载,提升性能
TypeScript 作为 JavaScript 的超集,完美继承了 JS 的模块化需求,而且提供了更安全的类型检查。
命名空间(namespace):老朋友的最后光芒
在 ES Modules 普及之前,TypeScript 开发者主要用 namespace 来组织代码。它有点像 JavaScript 里的 IIFE(立即执行函数),用来避免全局变量污染。
什么是命名空间?
想象一下,你家里有很多房间,每个房间有不同的用途:厨房做饭、卧室睡觉、书房工作。命名空间就像这些房间,把相关的功能放在同一个“房间”里。
// math-operations.ts
namespace MathOperations {
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
// 非导出的函数,只在命名空间内部使用
function helper(a: number): number {
return a * 2;
}
export function doubleAdd(a: number, b: number): number {
return helper(a) + helper(b);
}
}
// main.ts
/// <reference path="math-operations.ts" />
const result = MathOperations.add(5, 3);
console.log(result); // 8
// 注意:helper 函数无法从外部访问
// MathOperations.helper(5); // 报错!
命名空间的优缺点
优点:
- 语法简单,上手快
- 在浏览器环境中不需要打包工具
- 适合小型项目或库的包装
缺点:
- 全局污染风险:如果加载顺序错了,可能覆盖已有变量
- 类型检查弱:编译器难以追踪跨文件的类型关系
- 现代工具链支持差:webpack、Vite 等现代构建工具对 namespace 支持不佳
- 无法动态导入:
import()语法不支持 namespace
什么时候还能用命名空间?
虽然官方推荐 ESM,但在以下场景 namespace 仍然有用:
- 编写浏览器端的库,不想引入打包工具
- ** legacy 项目维护**,重构成本高
- TypeScript 编译器插件,需要特殊处理
CommonJS(CJS):Node.js 的默认选择
CommonJS 是 Node.js 早期使用的模块化方案,语法简洁,很多 TypeScript 项目仍然在使用。
CJS 基本语法
// math.ts
export = {
add(a: number, b: number): number {
return a + b;
},
subtract(a: number, b: number): number {
return a - b;
}
};
// 或者使用 module.exports
module.exports = class Calculator {
add(a: number, b: number): number {
return a + b;
}
};
// main.ts
import Calculator = require('./math');
const calc = new Calculator();
console.log(calc.add(5, 3)); // 8
tsconfig.json 中的 CJS 配置
{
"compilerOptions": {
"module": "commonjs",
"target": "es2015",
"outDir": "./dist",
"rootDir": "./src"
}
}
CJS 的特点
优点:
- Node.js 原生支持,无需转换
- 同步加载,性能稳定
- 社区资源丰富,很多库使用 CJS
缺点:
- 运行时加载:模块在运行时才解析,无法进行静态分析
- 打包困难:现代打包工具(如 webpack)对 CJS 支持不如 ESM
- 循环依赖问题:容易出现
undefined的情况 - 无法 tree-shaking:无法移除未使用的代码
ES Modules(ESM):现代前端的标准
ES Modules 是 JavaScript 的官方模块化标准,TypeScript 全面支持。这是目前最推荐的方式。
ESM 基本语法
// utils.ts
export const PI = 3.14159;
export function calculateArea(radius: number): number {
return PI * radius * radius;
}
export default class Circle {
constructor(public radius: number) {}
getArea(): number {
return calculateArea(this.radius);
}
}
// main.ts
import Circle, { PI, calculateArea } from './utils';
const circle = new Circle(5);
console.log(`半径为5的圆面积:${circle.getArea()}`);
console.log(`π的值为:${PI}`);
默认导出 vs 命名导出
// 默认导出(每个文件只能有一个)
export default class UserService { ... }
// 命名导出(每个文件可以有多个)
export interface User { ... }
export function createUser() { ... }
export const DEFAULT_PAGE_SIZE = 10;
// 导入方式
import UserService from './UserService'; // 导入默认导出
import { User, createUser } from './types'; // 导入命名导出
import * as AllUtils from './utils'; // 导入所有导出
tsconfig.json 中的 ESM 配置
{
"compilerOptions": {
"module": "ESNext",
"target": "es2020",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
ESM 的优势
- 静态分析:编译器可以在编译时检查模块依赖
- Tree-shaking:打包工具可以移除未使用的代码
- 异步加载:支持
import()动态导入 - 更好的类型检查:跨模块类型追踪更准确
ESM 与 CJS 的核心区别
这是很多开发者混淆的地方,我来详细解释:
1. 加载时机不同
CJS(同步加载):
// 运行时加载
const math = require('./math');
// 此时 module.exports 已经被赋值
ESM(静态加载):
// 编译时加载
import { add } from './math.js';
// 在代码执行前,模块依赖已经被解析
2. 导出方式不同
// CJS 导出
module.exports = { add, subtract };
// 或者
exports.add = function() { ... };
// ESM 导出
export const add = () => { ... };
export default class Calculator { ... };
3. 导入方式不同
// CJS 导入
const { add } = require('./math');
// 或者
const math = require('./math');
// ESM 导入
import { add } from './math.js';
import math from './math.js';
4. 模块标识不同
CJS 使用文件名(如 ./math),ESM 通常要求完整的文件扩展名(如 ./math.js)。
实际项目中的选择策略
那么,你的项目到底该用哪种?
场景一:Node.js 后端项目
推荐使用 CJS 或 ESM
// package.json
{
"type": "module" // 使用 ESM
// 或者不设置,默认使用 CJS
}
如果你使用的是 Express、NestJS 等框架:
- Express:两种都可以,CJS 生态更丰富
- NestJS:推荐使用 ESM,但 CJS 也可以
场景二:前端 React/Vue 项目
强烈推荐 ESM
现代前端构建工具(Vite、Webpack 5)都优先支持 ESM。
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
// 确保使用 ESM
input: './src/main.ts'
}
}
});
场景三:TypeScript 库开发
根据目标环境选择
// package.json
{
"main": "./dist/cjs/index.js", // CommonJS 入口
"module": "./dist/esm/index.js", // ES Modules 入口
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
}
}
}
这样你的库可以同时支持 CJS 和 ESM 环境。
常见报错及解决方法
这是大家最常遇到的问题,我整理了一些典型场景:
报错 1:Cannot find module
错误信息:
error TS2307: Cannot find module './utils' or its corresponding type declarations.
原因:
- 路径错误
- 缺少类型声明
- tsconfig 配置问题
解决方法:
// 1. 检查路径是否正确(注意扩展名)
import { add } from './utils.js'; // ESM 需要扩展名
// 2. 检查 tsconfig.json
{
"compilerOptions": {
"moduleResolution": "node" // 或 "bundler"
}
}
// 3. 如果使用的是第三方库,安装类型声明
npm install @types/lodash
报错 2:Module has no exported member
错误信息:
error TS2305: Module '"./utils"' has no exported member 'add'.
原因:
- 导入的成员不存在
- 导出和导入不匹配
解决方法:
// utils.ts
export const add = (a: number, b: number) => a + b; // 正确导出
// main.ts
import { add } from './utils'; // 正确导入
报错 3:Default export is not a constructor
错误信息:
error TS2749: 'default' refers to a value, but is being used as a type here.
原因: ESM 默认导出可能是值而非类型
解决方法:
// 方法一:使用命名导出
export class UserService { ... }
import { UserService } from './service';
// 方法二:使用 import type
import type UserService from './service';
报错 4:Mixed exports in ESM
错误信息:
error TS1259: Module '".../node_modules/some-lib/index"' can only be default-imported using the 'esModuleInterop' flag
原因: 模块同时有默认导出和命名导出,但配置不正确
解决方法:
{
"compilerOptions": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
报错 5:Circular dependency
错误信息:
DeprecationWarning: Module A depends on Module B, which depends on Module A
原因: 模块之间互相依赖,形成循环
解决方法:
// 重构代码,打破循环依赖
// 错误示例:
// user.ts 导入 userService.ts
// userService.ts 导入 user.ts
// 正确做法:提取公共接口到单独文件
// types.ts
export interface User { ... }
// user.ts
import { User } from './types';
// 只使用类型,不导入 userService
// userService.ts
import { User } from './types';
// 只导入类型定义
完整实战示例
让我们来看一个完整的 TypeScript 模块化项目结构:
my-project/
├── src/
│ ├── utils/
│ │ ├── math.ts
│ │ └── string.ts
│ ├── models/
│ │ └── user.ts
│ ├── services/
│ │ └── userService.ts
│ └── main.ts
├── dist/
├── package.json
└── tsconfig.json
math.ts
export const PI = 3.14159265359;
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export default class MathHelper {
static calculateCircleArea(radius: number): number {
return PI * radius * radius;
}
}
string.ts
export function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function truncate(str: string, length: number): string {
return str.length > length ? str.slice(0, length) + '...' : str;
}
user.ts
export interface User {
id: number;
name: string;
email: string;
}
export class UserEntity implements User {
constructor(
public id: number,
public name: string,
public email: string
) {}
getDisplayName(): string {
return `${this.name} (${this.email})`;
}
}
userService.ts
import { User, UserEntity } from '../models/user';
import { capitalize } from '../utils/string';
export class UserService {
private users: UserEntity[] = [];
addUser(name: string, email: string): UserEntity {
const capitalized = capitalize(name);
const user = new UserEntity(this.users.length + 1, capitalized, email);
this.users.push(user);
return user;
}
findUserById(id: number): UserEntity | undefined {
return this.users.find(u => u.id === id);
}
getAllUsers(): UserEntity[] {
return [...this.users];
}
}
main.ts
import { UserService } from './services/userService';
import MathHelper, { add, PI } from './utils/math';
import { truncate } from './utils/string';
const service = new UserService();
const user = service.addUser('john', 'john@example.com');
console.log(`用户:${user.getDisplayName()}`);
console.log(`面积计算:${MathHelper.calculateCircleArea(5)}`);
console.log(`截断字符串:${truncate('Hello TypeScript', 10)}`);
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
最佳实践建议
- 优先使用 ESM:除非有特殊需求,否则选择 ES Modules
- 保持一致性:项目中不要混用 CJS 和 ESM
- 使用路径别名:避免深层导入路径
{
"compilerOptions": {
"paths": {
"@utils/*": ["src/utils/*"],
"@services/*": ["src/services/*"]
}
}
}
import { UserService } from '@services/userService';
- 类型导入优化:使用
import type减少运行时依赖
import type { User } from './types';
- 动态导入:对于大型模块,使用懒加载
const UserService = await import('./services/userService');
- 避免循环依赖:定期检查项目依赖图
总结
TypeScript 模块化开发的核心就是:理解不同模块系统的特性,根据项目需求选择合适的方案。
- 命名空间:适合小型项目、浏览器端库开发,但不推荐新项目使用
- CommonJS:Node.js 后端项目的稳妥选择,生态丰富
- ES Modules:现代前端项目的首选,工具链支持最好
记住,模块化不仅仅是技术选择,更是项目架构思维的体现。好的模块划分能让你的代码更清晰、更易维护、更易测试。
希望这篇指南能帮你理清 TypeScript 模块化的迷雾。如果有具体问题,欢迎在评论区讨论!
