哎哟,看到这个问题,我仿佛听到了无数个 TypeScript 新手在深夜抓头发的声音。你是不是也遇到过这种情况:代码本地跑得好好的,一加 Webpack 或 Vite 打包,报错信息像天书一样;又或者,明明觉得自己的 import 写得对,但就是引用不到东西,甚至导出了一堆 undefined,让人怀疑人生。
其实,TypeScript 的模块系统(ES Modules)并没有那么神秘,但它确实有好多“坑”等着冒失鬼往里跳。今天咱们不背八股文,就把它当成一个真实的“文件传输与接收”的故事来讲。我会陪你把这层窗户纸捅破,从最基础的动作,一直讲到那些让你打包失败的真实案例。
把项目想象成一个快递站
在深入代码之前,我们先建个 mental model(心智模型)。想象你的 TypeScript 项目是一个巨大的快递站。
- 文件(.ts 文件):就是一个个独立的仓库房间。
- 变量/函数/类:里面存放的货物。
- export:贴标签,把货物打包好,贴上“可外借”的标签,放在门口。
- import:去别的房间取货,根据标签找到东西,搬回自己房间使用。
如果不贴标签(export),外面的房间是看不到你房间里的货的;如果你贴了标签但名字起得乱七八糟,或者收件人(import)名字写错了,快递就送不到了。这就是模块关系的核心:隔离与暴露。
第一步:搞清楚两种“快递模式”
在 TypeScript 里,export 主要有两种姿势:具名导出和默认导出。这俩是新手最容易混淆的地方,也是日后打包报错的重灾区。
1. 具名导出 (Named Export) —— “实名制快递”
你可以一个文件里导出多个东西,每个东西都有自己的名字。
// utils/calculator.ts
// 这里我们导出了两个东西:add 和 subtract
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
// 也可以导出常量、类型、接口
export const MAX_LIMIT = 100;
export interface User {
id: number;
name: string;
}
重点来了: 导出的名字必须和导入时的名字一模一样(除非你用别名,后面会说)。这就像快递单上写的是“苹果”,你不能去签收“梨”。
2. 默认导出 (Default Export) —— “盲盒快递”
一个文件里只能有一个默认导出。它像是一个主包裹,里面可以装很多东西,或者就只有一个主要功能。
// components/Button.ts
// 默认导出一个 React 组件(假设这里是 TSX)
export default function Button({ label }: { label: string }) {
return `<button>${label}</button>`;
}
// 或者默认导出一个类
export default class Logger {
log(msg: string) {
console.log(msg);
}
}
注意: 导入默认导出的时候,名字你可以随便起!因为它是“默认”的,你没有具体的标签去匹配,只能按顺序接收。
// app.ts
// 虽然原文件里叫 Button,但我导入时可以叫 MyBtn
import MyBtn from './components/Button';
// 虽然原文件里默认导出的是 Logger,但我导入时可以叫 myLogger
import myLogger from './utils/Logger';
3. 混合导出 —— “全家桶”
有时候你想既提供具名导出,又提供一个默认导出。
// config.ts
export const host = 'localhost';
export const port = 8080;
// 默认导出这个配置对象
export default { host, port };
导入的时候就可以这么写:
import config, { host, port } from './config';
// config 拿到了默认导出的对象
// host 和 port 拿到了具名导出的变量
第二步:导入的艺术 —— 三种写法别搞混
既然有 export,就有对应的 import。很多新手报错,就是因为 import 的写法没对上 export 的套路。
写法一:直接导入具名成员
// 必须用大括号 {},里面的名字必须和 export 时完全一致
import { add, subtract } from './utils/calculator';
console.log(add(1, 2)); // 3
如果你写成了 import { Add } from ...(大小写不对),或者 import { add } from './utils/calculator' 但文件里导出的是 export const add...(没错),但如果文件里根本没导出 add,TypeScript 编译器会直接报错,甚至在打包后运行时报 undefined is not a function。
写法二:导入默认成员
// 不需要大括号,名字随便起
import MyBtn from './components/Button';
写法三:全量导入 + 别名
// 把整个模块当成一个对象导入
import * as Calculator from './utils/calculator';
console.log(Calculator.add(1, 2));
console.log(Calculator.MAX_LIMIT);
这种方式在处理没有默认导出、或者导出非常多的工具库时非常有用。
写法四:导入时重命名 (Alias)
这是避免命名冲突的神器,也是你理解模块关系的关键。
// 假设 utils/calculator.ts 导出了 add
// 假设 utils/otherUtils.ts 也导出了一个 add 函数
import { add as mathAdd } from './utils/calculator';
import { add as stringAdd } from './utils/otherUtils';
mathAdd(1, 2); // 3
stringAdd('a', 'b'); // 'ab'
你看,import 的 as 关键字让你的代码变得无比清晰。这在大型项目中简直是救命稻草。
第三步:打包失败的真相 —— 这些坑你踩过吗?
现在进入最精彩的部分。为什么你的代码本地能跑,打包就挂了?或者反过来,打包工具(Webpack/Vite/Rollup)直接报错?
坑一:默认导出 vs 具名导出的混淆
这是新手遇到的第一大杀手。
假设你有一个文件 api.ts:
// api.ts
export const getUser = () => fetch('/user');
export const postUser = () => fetch('/user', { method: 'POST' });
注意,这里没有 export default。
错误写法 A:
// main.ts
import api from './api'; // 错!api 没有默认导出,它是具名导出
api.getUser(); // 运行时可能报错,或者 undefined
错误写法 B:
// main.ts
import { api } from './api'; // 错!你导入的是一个叫 api 的具名导出,但文件里只有 getUser 和 postUser
正确写法:
// main.ts
import { getUser, postUser } from './api';
// 或者
import * as Api from './api';
Api.getUser();
怎么避免? 养成习惯:看到 import ... from ... 如果没有大括号,就去检查源文件有没有 export default。如果没有,立马换大括号写法。
坑二:循环依赖 (Circular Dependency) —— 模块化地狱
想象 A 依赖 B,B 又依赖 A。这在大型项目中非常常见,尤其是当文件之间关系错综复杂时。
// a.ts
import { funcB } from './b';
export function funcA() {
return funcB() + " from A";
}
// b.ts
import { funcA } from './a'; // 哎呀!循环了!
export function funcB() {
return funcA() + " from B";
}
结果会怎样?
TypeScript 编译器可能不会报错,因为语法上是合法的。但是!当打包工具(如 Webpack)处理这个依赖图时,它会陷入死循环,或者在运行时输出 undefined。
// 运行时可能出现
console.log(funcA()); // "undefined from B" 或者无限递归导致栈溢出
怎么解决?
- 提取公共部分:把
funcA和funcB共同依赖的东西,提取到第三个文件c.ts里。 - 重构设计:重新思考模块的职责,让依赖单向流动。A -> C, B -> C,而不是 A <-> B。
坑三:路径解析失败 (Path Resolution)
这在 TypeScript 项目中尤其常见,特别是当你使用了 tsconfig.json 里的 paths 配置,但打包工具不认识这个配置时。
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@utils/*": ["src/utils/*"]
}
}
}
// main.ts
import { helper } from '@utils/helper'; // TypeScript 编辑器知道这是啥
问题来了:
- 如果你直接用
tsc编译,没问题,因为 TypeScript 编译器读懂了tsconfig。 - 但如果你用 Webpack 打包,Webpack 默认不认识
@utils这个别名。它会报错:Module not found: Can't resolve '@utils/helper'。
解决方案: 你需要配置打包工具,让它也识别这些别名。
- Webpack:在
webpack.config.js里配置resolve.alias。 - Vite:在
vite.config.ts里配置resolve.alias。 - 最简单的方法:避免使用路径别名,直接用相对路径
../../utils/helper,虽然丑一点,但绝对不会出错。
坑四:动态导入 (Dynamic Import) 的误解
有些人听说 import 可以是异步的,就乱用。
// 错误理解:以为这样能解决循环依赖
async function loadModule() {
const mod = await import('./heavy-module');
return mod.default;
}
这段代码在语法上是合法的,TypeScript 也能通过。但是,如果你是在根级别使用动态导入,它会返回一个 Promise。如果你期望它同步返回一个对象,就会出错。
// 错误用法
const mod = await import('./heavy-module'); // 这在 async 函数里是对的
// 但如果不在 async 函数里:
const mod = import('./heavy-module'); // 返回 Promise<Module>
mod.default; // 这里 mod 还是 Promise,没有 .default 属性!
记住: 动态导入返回的是 Promise,必须 await 或者 .then() 才能拿到里面的内容。
第四步:实战避坑指南 —— 给新手的三条黄金法则
为了让你不再被 undefined 和 Module not found 折磨,我总结了三条你可以立刻实践的黄金法则。
法则一:保持“单一职责”,避免大杂烩文件
一个文件只负责一件事。如果一个文件既导出了用户服务,又导出了数据库连接,还导出了 UI 组件,那它最终会变成一团乱麻,别人(和你未来的自己)根本不知道该怎么 import。
// 糟糕的设计: users.ts
export const users = [...];
export const connectDb = () => {...};
export const UserButton = () => {...};
// 优秀的设计: 拆分成三个文件
// services/userService.ts -> export const getUsers, export const connectDb
// components/UserButton.tsx -> export default UserButton
// models/User.ts -> export interface User
法则二:统一使用“具名导出”,谨慎使用默认导出
除非你明确知道自己在做什么,否则尽量全部使用具名导出。
为什么?因为具名导出有明确的名称,TypeScript 编辑器可以提供更好的智能提示,重构时也更安全(改名时所有引用都会被高亮)。默认导出容易搞混,而且当你在一个文件里写 export default class A {} 和 export class B {} 时,导入的人很容易搞错哪个是默认,哪个是具名。
建议: 在团队中约定,除非是 React 组件或主要 API 入口,否则一律用 export const/function/interface。
法则三:永远检查 tsconfig.json 和打包工具的 tsconfig 是否同步
这是导致“本地能跑,打包失败”的最常见原因。
很多项目有两个配置:
- 编译器的
tsconfig.json - 打包工具用的
tsconfig.build.json或其他配置文件
如果你在 tsconfig.json 里加了 paths 或 baseUrl,但打包工具读的是另一个配置文件,那必然出问题。
检查清单:
- 你的
tsconfig.json里的compilerOptions是否和打包工具(Webpack/Vite)读取的配置一致? - 是否使用了
tsc-alias或vite-tsconfig-paths这样的插件来同步配置?
第五步:代码示例 —— 一个完整的、健康的模块结构
让我们来看一个真实的项目结构,展示如何正确地组织模块,避免上述所有坑。
src/
├── types/
│ └── index.ts // 只导出类型,无运行时代码
│ export interface User { id: number; name: string; }
│ export type Status = 'pending' | 'success' | 'error';
│
├── services/
│ └── userService.ts // 具名导出
│ import { User } from '../types';
│ export const getUsers = async (): Promise<User[]> => { ... };
│ export const createUser = async (user: User): Promise<User> => { ... };
│
├── components/
│ └── UserProfile.tsx // 默认导出组件
│ import { User } from '../types';
│ export default function UserProfile({ user }: { user: User }) { ... }
│
└── app.ts // 入口文件
import { getUsers } from './services/userService';
import UserProfile from './components/UserProfile';
// 这里没有循环依赖,路径清晰,类型明确
getUsers().then(users => {
// 使用 users
});
在这个结构里:
- 没有默认导出混用具名导出(除了组件,这是行业惯例)。
- 类型单独放在
types目录,避免循环依赖。 - 服务层只负责数据获取,组件层只负责 UI。
- 导入路径清晰,没有歧义。
结语:模块化是一种思维习惯
最后,我想说,TypeScript 的 import/export 不仅仅是语法糖,它是一种强制你思考代码边界的机制。当你开始问自己“这个函数应该放在哪个文件?”、“它需要被谁导入?”、“它导出了什么?”的时候,你就已经踏上了成为高级工程师的道路。
不要害怕出错,每一次 Module not found 都是理解模块系统的一次机会。多查文档,多写小 Demo,多检查 tsconfig 和打包配置,你一定会从“一团乱麻”变成“条理清晰”。
记住,好的模块关系,就像好的社交关系:边界清晰,沟通顺畅,互不依赖(或少依赖)。祝你在 TypeScript 的世界里,玩得开心!
