说到模块化,很多刚转TypeScript或者从JavaScript过渡过来的朋友,心里可能都在犯嘀咕:为什么我明明写了import,运行起来却报Cannot find module?为什么我的项目里既有.js又有.ts,导入导出逻辑乱成一团麻?甚至有时候两个模块互相引用,程序直接卡死,浏览器控制台还给你甩一个undefined的脸色?
别急,这事儿咱们得从头捋清楚。模块化不仅仅是把代码拆开存文件那么简单,它关乎你项目的可维护性、构建效率,甚至是运行时稳定性。今天咱们不整那些虚头巴脑的理论定义,我就当作是你旁边那位写了十几年代码的老同事,咱们一边聊一边把这块硬骨头啃下来。
为什么要折腾模块化?先看看“裸奔”的痛苦
在模块化诞生之前,我们是怎么写JS的?
想象一下,你有一个大项目,所有的逻辑都塞进一个巨大的app.js里。或者更糟糕的情况:你引入了几个第三方的库,每个库都用全局变量暴露接口。比如jQuery挂在$上,Lodash挂在_上。这时候,如果两个库都用同一个全局变量名怎么办?冲突了。如果我想复用某个函数,我得把它暴露到全局,这就意味着任何人都能修改它,内存泄漏、变量污染随之而来。
这就是为什么我们需要模块。模块的核心思想就两个词:封装和隔离。
- 封装:模块内部的东西,外面访问不到,除非我主动导出来。
- 隔离:每个模块有自己的作用域,不会污染全局命名空间。
在TypeScript里,这种隔离尤其重要,因为TS的静态类型检查依赖明确的类型声明,如果模块边界不清,类型推导就会乱套。
CommonJS:Node.js的老朋友,但有其局限
在ES Modules成为标准之前,Node.js社区用的是一套叫CommonJS (CJS) 的规范。你可能在很多老的项目或者node_modules里见过这样的代码:
// math.ts
function add(a: number, b: number): number {
return a + b;
}
function subtract(a: number, b: number): number {
return a - b;
}
// 导出
module.exports = {
add,
subtract
};
然后在使用的时候:
// main.ts
const math = require('./math');
console.log(math.add(5, 3)); // 8
或者更常见的解构写法(虽然严格来说require返回的是对象,解构是JS特性):
const { add, subtract } = require('./math');
CommonJS的特点
- 同步加载:
require是同步的。这意味着当你的程序运行到这一行时,必须先把这个文件加载完、执行完,才能继续往下走。这在浏览器端是个大问题,因为网络请求通常是异步的。 - 运行时确定:你可以在任何地方调用
require,甚至根据条件动态加载模块。
if (process.env.NODE_ENV === 'production') {
const lib = require('./prod-lib');
} else {
const lib = require('./dev-lib');
}
这在服务端(Node.js)非常有用,因为你可以按需加载。但在浏览器打包工具(如Webpack、Rollup)中,这种动态加载会让代码分割和优化变得复杂。
CommonJS在TypeScript中的坑
当你用TypeScript写CommonJS风格的代码时,TS编译器(tsc)默认会生成CJS格式的JS,除非你在tsconfig.json里明确指定输出格式。但这里有个巨大的陷阱:
类型导出 vs 值导出的分离问题。
在CJS中,module.exports是一个对象,TS很难精确推断这个对象的类型,尤其是当你使用export =语法时:
// math.ts
export = {
add(a: number, b: number): number {
return a + b;
},
subtract(a: number, b: number): number {
return a - b;
}
};
使用时:
import math = require('./math'); // 注意这个语法!
这种import = require的语法非常别扭,而且一旦你混用export =和普通export {},TS的类型检查器可能会懵掉,报出'X' is not a namespace之类的错误。
更重要的是,CommonJS不支持静态分析。打包工具不知道你有没有用到某个导出,只能全盘打包,导致生成的bundle体积臃肿。
ES Modules (ESM):现代前端的标准答案
ESM是TC39制定的官方模块标准,它被设计为静态的、声明式的。这意味着,模块的依赖关系在代码运行前就完全确定了,打包工具可以轻易地做优化(Tree Shaking)。
ESM的基本语法
让我们用同样的数学例子,看看ESM怎么写:
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
// 也可以默认导出
export default function multiply(a: number, b: number): number {
return a * b;
}
使用方:
// main.ts
// 命名导入
import { add, subtract } from './math';
// 默认导入
import multiply from './math';
// 整体导入(不推荐,但有时有用)
import * as MathLib from './math';
console.log(add(5, 3)); // 8
console.log(multiply(5, 3)); // 15
ESM vs CJS:关键区别
- 静态结构:
import和export必须在模块的顶层,不能在if语句或函数内部使用(除非你用动态import())。这使得打包工具可以在编译期就画出完整的依赖图。 - 异步加载:虽然
import语句本身看起来是同步的,但ESM模块实际上是异步加载和执行的。这在浏览器中非常友好,不会阻塞渲染。 - 严格模式:ESM代码默认运行在严格模式下,
this在模块顶层是undefined,而不是全局对象。 - 单一导出:ESM只有一个默认导出(
export default),但可以有很多命名导出(export const)。
TypeScript配置ESM
要在TypeScript中使用ESM,你需要调整tsconfig.json:
{
"compilerOptions": {
"module": "ESNext",
"target": "ES2020",
"moduleResolution": "node" // 或者 "bundler" / "node16" / "nodenext"
}
}
注意:moduleResolution的选择非常关键。
"node":传统的Node.js解析策略,兼容CJS和ESM。"bundler":适用于Webpack、Vite等打包工具,解析更灵活。"node16"或"nodenext":严格的Node.js ESM解析策略,要求你在package.json中标记"type": "module",并且导入时必须带文件扩展名(如./math.js而不是./math)。
强烈建议:如果你是在写现代化的前端项目,使用"module": "ESNext"和"moduleResolution": "bundler"。如果你是在写Node.js后端且追求严格规范,使用"module": "NodeNext"和"moduleResolution": "NodeNext"。
循环依赖:模块化开发的头号杀手
讲完基础,咱们得聊聊那个让无数开发者头疼的问题:循环依赖(Circular Dependency)。
什么是循环依赖?就是模块A依赖模块B,模块B又依赖模块A。
// user.ts
import { Post } from './post';
export class User {
constructor(public name: string, public posts: Post[]) {}
}
// post.ts
import { User } from './user';
export class Post {
constructor(public title: string, public author: User) {}
}
当你尝试运行这段代码时,会发生什么?
不同环境下的表现
在Node.js (CJS) 中:
CJS是同步加载的。当user.ts被加载时,它会立即执行require('./post')。此时post.ts还没加载完,正在执行中。post.ts又执行require('./user'),但user.ts已经加载了一部分,但还没加载完(因为上面那行import还没执行完)。结果就是,post.ts拿到的是一个部分初始化的User模块,其中某些导出可能是undefined。
在浏览器/ESM中: ESM是异步加载的。模块会被解析,但执行会被延迟。当A导入B,B导入A时,加载器会创建一个依赖图,确保所有模块都被加载和实例化后,才真正执行模块体内的代码。所以,ESM通常能容忍循环依赖,而不像CJS那样容易静默出错。
但是!这并不意味着你可以随意使用循环依赖。
为什么循环依赖是坏味道?
- 可读性差:当你阅读代码时,你无法一眼看出数据的流向。模块A和B互相纠缠,难以维护。
- 测试困难:Mock一个循环依赖的模块非常麻烦,因为你需要确保两个模块的初始化顺序正确。
- 重构陷阱:如果你后来想把
User和Post拆分成不同的微服务或独立包,循环依赖会让这个过程变得极其痛苦。 - 性能开销:加载和初始化循环依赖的模块需要额外的解析和等待时间。
如何识别循环依赖?
你可以使用一些工具来检测:
madge:一个Node.js工具,可以生成依赖图并检测循环。npx madge --circular src/- Webpack/Vite分析插件:如
webpack-bundle-analyzer,可以看到模块之间的依赖关系。
如何打破循环依赖?
这是本文的核心实战部分。我们有几种经典的方法:
方法一:提取公共接口(依赖倒置)
循环依赖往往是因为两个模块互相需要对方的类型或功能。我们可以提取一个公共的接口或基类,让两者都依赖这个公共层,而不是直接依赖对方。
// types.ts (新增的公共层)
export interface IUser {
name: string;
posts: IPost[];
}
export interface IPost {
title: string;
author: IUser;
}
// user.ts
import { IPost } from './types';
export class User implements IUser {
constructor(public name: string, public posts: IPost[]) {}
}
// post.ts
import { IUser } from './types';
export class Post implements IPost {
constructor(public title: string, public author: IUser) {}
}
现在,User和Post都只依赖types,而没有相互依赖。依赖方向变成了:User -> types <- Post,一个清晰的倒三角结构。
方法二:使用依赖注入(Setter注入)
如果业务逻辑上确实需要互相引用,可以考虑在构造函数之后,通过setter方法注入依赖。这样可以打破初始化时的直接依赖。
// user.ts
import { Post } from './post';
export class User {
public posts: Post[] = [];
constructor(public name: string) {}
// 延迟注入
setPosts(posts: Post[]) {
this.posts = posts;
}
}
// post.ts
import { User } from './user';
export class Post {
constructor(public title: string, public author: User) {}
}
// main.ts
import { User } from './user';
import { Post } from './post';
const user = new User('Alice');
const post1 = new Post('Hello World', user);
const post2 = new Post('TypeScript Tips', user);
// 在初始化完成后,再注入依赖
user.setPosts([post1, post2]);
这种方法在大型框架(如Angular)中非常常见。它允许你先创建对象,再填充依赖,从而避免循环初始化。
方法三:使用工厂函数
将模块的创建逻辑提取到一个工厂函数中,工厂函数可以作为第三方,协调两个模块的创建顺序。
// user.ts
import { Post } from './post';
export class User {
constructor(public name: string, public posts: Post[]) {}
}
export function createUser(name: string): User {
return new User(name, []); // 初始为空,后续通过其他方式填充
}
// post.ts
import { User } from './user';
export class Post {
constructor(public title: string, public author: User) {}
}
export function createPost(title: string, user: User): Post {
return new Post(title, user);
}
然后在业务层统一协调:
// app.ts
import { createUser, User } from './user';
import { createPost, Post } from './post';
const user = createUser('Bob');
const post1 = createPost('My First Post', user);
const post2 = createPost('My Second Post', user);
// 这里可能需要额外的逻辑来更新user.posts,或者直接让Post持有引用即可
方法四:代码分割与异步加载(针对运行时循环)
如果是动态循环依赖(比如根据条件加载),可以使用动态import(),它返回一个Promise,允许你在运行时处理依赖。
// user.ts
export class User {
constructor(public name: string) {}
async getPosts(): Promise<any[]> {
// 动态导入,避免启动时的循环依赖
const { Post } = await import('./post');
return [new Post('Post 1', this)];
}
}
// post.ts
export class Post {
constructor(public title: string, public author: User) {}
}
这种方式特别适合那些只在特定场景下才需要的依赖,比如懒加载的组件或插件。
Tree Shaking:ESM带来的最大红利
说了这么多,ESM相比CJS还有一个巨大的优势:Tree Shaking(摇树优化)。
什么是Tree Shaking?
想象你有一个巨大的工具库,里面包含了100个函数。但你的项目只用到了其中的3个。
- 在CJS中,如果你
require了这个库,你可能把整个库都打包进去了,即使你只用了一点点。 - 在ESM中,因为
import是静态的,打包工具(如Webpack、Rollup、esbuild)可以分析出哪些导出确实被用到了,然后把未使用的代码“摇掉”,只保留你需要的部分。
如何在TypeScript中启用Tree Shaking?
- 使用ESM模块格式:确保
tsconfig.json中"module"设置为"ESNext"或"ES2020"等。 - 使用命名导出:尽量使用
export const foo = ...而不是export default。虽然export default也能被Tree Shake,但命名导出更清晰,且支持重命名导入。 - 避免副作用:如果你的模块在加载时会执行一些全局操作(如修改全局变量、发起网络请求),打包工具可能不敢删掉这个模块。此时你需要用
/*#__PURE__*/注释或配置打包工具来忽略副作用。
// utils.ts - 纯函数,无副作用,可以被Tree Shake
export function formatPrice(price: number): string {
return `$${price.toFixed(2)}`;
}
export function formatDate(date: Date): string {
return date.toISOString();
}
// app.ts
import { formatPrice } from './utils'; // 只导入用到的
console.log(formatPrice(100));
当使用Webpack或Vite打包时,formatDate函数会被自动移除,减小bundle体积。
CJS无法Tree Shake的原因
CJS的require是运行时调用的,打包工具在静态分析阶段无法确定require('./utils').formatDate是否会被执行。为了安全起见,它只能把整个utils.js都打包进去。
实战案例:构建一个可维护的TypeScript项目结构
让我们把学到的知识整合起来,看看一个典型的现代TypeScript项目应该怎么组织模块化代码。
项目结构
src/
├── index.ts # 应用入口
├── config/
│ └── app.config.ts # 配置模块
├── services/
│ ├── user.service.ts
│ └── post.service.ts
├── models/
│ ├── user.model.ts
│ └── post.model.ts
├── types/
│ └── index.ts # 公共类型定义
└── utils/
└── helpers.ts # 工具函数
代码示例
1. 定义公共类型(打破循环依赖的第一层)
// src/types/index.ts
export interface BaseDocument {
id: string;
createdAt: Date;
updatedAt: Date;
}
export interface UserDocument extends BaseDocument {
name: string;
email: string;
posts: string[]; // 只存Post ID,避免循环引用
}
export interface PostDocument extends BaseDocument {
title: string;
content: string;
authorId: string; // 只存User ID,避免循环引用
}
2. 定义数据模型(Service层之上)
”`typescript // src/models/user.model.ts import { UserDocument } from ‘../types’;
export class User {
constructor(
public id: string,
public name: string,
public email: string,
public createdAt: Date,
public updatedAt: Date
) {}
static fromDoc(doc: UserDocument): User {
return new User(doc.id, doc.name, doc.email, doc.createdAt, doc.updatedAt);
}
}
