嘿,朋友。如果你现在正盯着屏幕上那行刺眼的 SyntaxError: Cannot use import statement outside a module 或者 Module not found: Can't resolve '...' 发呆,先别急着抓头发。这种痛苦我太懂了。就在上周,我还因为忘记配置 type: "module" 在 package.json 里,导致整个项目跑不起来,差点就把电脑屏幕给砸了(当然,最后还是忍住了,毕竟修电脑比写代码贵多了)。
今天咱们不聊那些枯燥的理论定义,我就想和你像老朋友一样,聊聊 TypeScript 模块化这个“老大难”问题。我们要一起趟过 Node.js 的后端深水区,还要蹚过浏览器前端的浅滩,看看怎么把这些散落的模块拼成一幅完整的图画。我会把那些踩过的坑、查过的文档、甚至是我自己写代码时的习惯,全都掏心窝子讲给你听。
为什么模块化这么让人头大?
首先得承认,JavaScript 的历史包袱太重了。从最初的 <script> 标签全局变量混乱,到 CommonJS (require) 统治 Node.js 时代,再到 ES Modules (import/export) 成为标准,最后加上 TypeScript 的类型检查,这中间简直是乱成一锅粥。
对于初学者来说,最大的困惑通常不是“怎么用”,而是“为什么这里能用,那里就不能用”。
- Node.js 默认使用 CommonJS (
cjs),但现代 Node (v12+) 支持 ES Modules (esm)。 - 浏览器 原生支持 ES Modules,但对 CommonJS 的支持非常有限(需要打包工具)。
- TypeScript 编译器 (
tsc) 有自己的模块解析策略,它不直接运行代码,而是把 TS 编译成 JS,这时候如果配置不对,编译出来的 JS 可能根本跑不通。
所以,我们的目标很明确:搞懂 export 和 import 的本质,理顺不同环境下的配置差异,并学会如何快速排查那些让人抓狂的错误。
第一部分:核心语法——export 与 import 的那些事儿
在 TypeScript 中,模块化是类型安全的基础。只有明确了模块边界,TS 才能正确地推断类型。
1. 命名导出 (Named Exports) vs 默认导出 (Default Export)
这是新手最容易混淆的地方。咱们用代码说话,直观一点。
假设我们有一个数学工具库 mathUtils.ts。
场景 A:命名导出(推荐用于工具函数)
// mathUtils.ts
// 你可以有多个 export
export const PI = 3.1415926;
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
怎么引入呢?
// main.ts
// 注意大括号 {},这叫解构赋值风格的导入
import { add, subtract } from './mathUtils';
console.log(add(10, 20)); // 30
为什么叫命名导出? 因为你在导入时必须知道具体的名字,而且名字必须完全匹配(大小写敏感)。
场景 B:默认导出(推荐用于类、组件或主入口)
// User.ts
// 只能有一个 default export
export default class User {
constructor(public name: string) {}
greet(): string {
return `Hello, I am ${this.name}`;
}
}
怎么引入呢?
// main.ts
// 注意没有大括号,而且你可以随意命名
import MyUserClass from './User';
const user = new MyUserClass('Agnes');
console.log(user.greet()); // Hello, I am Agnes
关键区别:
- 一个文件可以有无数个命名导出,但只能有一个默认导出。
- 导入默认导出时,文件名不重要,变量名也不重要(除了引用本身),只要你能找到那个文件即可。
- 避坑指南: 很多人喜欢混用。比如既
export default class又export const helper。这在 TS 中是允许的,但在引入时要小心:import DefaultClass, { helper } from './mixedFile'; // 默认导出在前,命名导出在后,用逗号隔开
2. 重新导出 (Re-exporting)
有时候,你会看到这样的代码:
// index.ts
export { add, subtract } from './mathUtils';
export { default as User } from './User';
这叫做“ barrel pattern ”(桶模式)。它的目的是简化导入路径。原本你需要从深层目录导入,现在只需要从 index.ts 导入即可。这对于构建大型库特别有用。
第二部分:Node.js 环境——从 CommonJS 到 ES Modules 的过渡
Node.js 是 TypeScript 后端开发的主战场。这里的变化最快,坑也最多。
1. 识别当前环境
打开你的 package.json,找一下有没有 "type": "module" 这一行。
- 如果没有这一行: Node.js 默认认为
.js和.ts(编译后) 文件是 CommonJS (CJS) 格式。 - 如果有这一行: Node.js 认为所有
.js文件都是 ES Modules (ESM) 格式。.mjs文件永远是 ESM,.cjs文件永远是 CJS。
2. TypeScript 配置 (tsconfig.json)
在 Node.js 环境下,tsconfig.json 里的 module 和 moduleResolution 设置至关重要。
方案一:使用 CommonJS (传统稳健派)
适合老项目,或者部署在较旧版本的 Node.js 上。
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs", // 编译输出 CJS 格式
"moduleResolution": "node", // 按照 node_modules 规则查找模块
"esModuleInterop": true, // 关键!允许 import require() 风格导入
"allowSyntheticDefaultImports": true // 允许默认导入非默认导出的模块
}
}
代码示例:
// server.ts
// 即使 mathUtils 是用 export default 写的,因为 esModuleInterop: true
// 你也可以这样写而不报错(虽然不推荐混用风格)
import * as MathUtils from './mathUtils';
// 或者更常见的 CJS 风格引入(如果编译目标是 CJS)
// const fs = require('fs'); // 在 TS 中建议用 import
方案二:使用 ES Modules (现代先锋派)
适合新项目,Node.js v12+ 支持良好,也是未来的趋势。
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext", // 或者 "ESNext",配合 package.json 中的 type: module
"moduleResolution": "NodeNext", // 严格遵循 Node.js 的 ESM 规则
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
重要步骤: 你必须确保 package.json 中有 "type": "module"。否则,即使你写了 import,Node.js 也会报错。
代码示例:
// server.ts
import express from 'express'; // 假设 express 支持 ESM 或有兼容层
import { readFile } from 'fs/promises';
// 注意:在 ESM 中,没有 __dirname 和 __filename 全局变量
// 需要通过以下方式获取:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function startServer() {
const app = express();
// ... 启动逻辑
}
3. 常见错误排查:ERR_REQUIRE_ESM
错误现象:
Error [ERR_REQUIRE_ESM]: require() of ES Module ... is not supported.
原因分析:
你试图用 require() (CommonJS) 去加载一个标记为 ES Module 的包。这通常发生在你的项目是 CJS,但依赖了一个纯 ESM 的库(比如某些新版库)。
解决方案:
- 升级项目到 ESM: 将
package.json改为"type": "module",并将tsconfig.json的module设为NodeNext。 - 动态导入: 如果不想改整个项目,可以在 CJS 文件中用
import()函数动态加载 ESM 模块:// server.cjs async function loadModule() { const dynamicModule = await import('./esModuleLib.mjs'); return dynamicModule.default; }
第三部分:浏览器环境——前端开发的艺术
在浏览器中,情况稍微简单一点,但也更“原生”。浏览器不支持 require,只支持 <script type="module"> 和 import。
1. 直接引入 (No Build Step)
你可以直接把编译后的 .js 文件放入 HTML。
HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>TypeScript Browser Demo</title>
</head>
<body>
<h1>Hello World</h1>
<!-- 注意 type="module" -->
<script type="module" src="./app.js"></script>
</body>
</html>
TypeScript (app.ts):
import { greet } from './utils.js'; // 注意:浏览器中导入必须带 .js 后缀!
console.log(greet('Browser'));
编译命令:
tsc --module ESNext --outDir ./dist
关键点:
- 后缀名强制: 在浏览器 ESM 中,相对路径导入必须包含文件扩展名(如
.js)。这是和 Node.js 最大的不同之一。Node.js 可以自动推断.ts->.js,但浏览器不行,因为它不知道.ts是什么,而且 TypeScript 文件不能直接在浏览器运行。 - 本地文件限制: 出于安全原因,浏览器不允许通过
file://协议加载模块(除非你用特殊参数启动 Chrome)。你需要一个简单的本地服务器,比如npx serve或 VS Code 的 Live Server 插件。
2. 使用打包工具 (Webpack / Vite / Rollup)
在实际生产中,我们很少手动管理浏览器模块。我们会用打包工具。
为什么需要打包?
- 代码分割: 只加载需要的部分。
- 转换: 将 TS 转 JS,将 CSS 转 JS,处理图片等。
- 兼容性: 打包成浏览器支持的旧版 JS 语法。
Vite 示例 (目前最火的工具)
Vite 利用浏览器原生的 ESM 支持,速度极快。
安装:
npm create vite@latest my-app -- --template vanilla-ts
cd my-app
npm install
npm run dev
目录结构:
my-app/
├── index.html
├── src/
│ ├── main.ts
│ └── utils.ts
├── package.json
└── tsconfig.json
src/utils.ts:
export const formatDate = (date: Date): string => {
return date.toLocaleDateString();
};
src/main.ts:
import { formatDate } from './utils.js'; // Vite 会自动处理 .ts 到 .js 的映射,但你最好写 .js
const now = new Date();
console.log(`Today is: ${formatDate(now)}`);
注意: 即使在 Vite 中,导入路径写 .js 也是最佳实践,因为最终编译出来的是 .js。
3. 浏览器常见错误排查
错误 1:Uncaught SyntaxError: Cannot use import statement outside a module
- 原因: 你的
<script>标签里没有加type="module",或者打包工具配置错误。 - 解决: 检查 HTML,确保
<script type="module" src="...">。
错误 2:Failed to resolve dependency: xxx, present in 'optimizeDeps'
- 原因: 通常是 Vite 或 Webpack 缓存问题,或者引入了不支持 ESM 的老旧库。
- 解决: 删除
node_modules和package-lock.json,重新npm install。如果是第三方库问题,尝试寻找替代库或查看该库是否有 ESM 版本。
错误 3:跨域问题 (CORS)
- 原因: 当你从
http://localhost:3000加载http://localhost:8080/module.js时,浏览器会阻止。 - 解决: 确保所有资源来自同一源,或者配置服务器允许跨域。使用本地开发服务器(如 Vite Dev Server)通常会自动处理这个问题。
第四部分:高级技巧与最佳实践
作为一个“资深”开发者,我想分享几个能让你的代码更健壮、更易维护的技巧。
1. 类型声明文件 (.d.ts)
当你导入一个没有类型的第三方库时,TS 会报错。
import someLib from 'some-old-lib'; // Error: Could not find a declaration file...
解决方法:
- 安装类型包:
npm install @types/some-old-lib - 创建声明文件: 如果没找到,可以自己写一个
some-old-lib.d.ts:declare module 'some-old-lib' { export default function someFunction(): void; }
2. 路径别名 (Path Aliases)
随着项目变大,../../components/Button 这种路径让人崩溃。我们可以配置路径别名。
tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@utils/*": ["src/utils/*"]
}
}
}
使用:
import Button from '@components/Button';
import { formatDate } from '@utils/date';
注意: 这需要打包工具(如 Webpack, Vite)的支持才能生效。TSC 编译器本身不理解 paths,它只负责类型检查。
3. 循环依赖 (Circular Dependencies)
场景: A 导入 B,B 又导入 A。
// a.ts
import { funcB } from './b';
export function funcA() { funcB(); }
// b.ts
import { funcA } from './a';
export function funcB() { funcA(); }
后果: 运行时可能报错,或者导出的值是 undefined。
解决方案:
- 重构代码: 提取公共逻辑到一个新模块 C,A 和 B 都导入 C。
- 延迟导入: 在函数内部使用动态
import()。// b.ts export function funcB() { // 此时 a 已经被完全加载 import('./a').then(({ funcA }) => funcA()); }
第五部分:实战演练——构建一个简单的全栈模块结构
让我们把学到的东西结合起来。假设我们要做一个简单的博客系统,后端用 Node/Express,前端用 React/Vue (这里用原生 TS 模拟)。
后端 (server/)
// server/tsconfig.json
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist"
}
}
// server/src/posts.ts
export interface Post {
id: number;
title: string;
content: string;
}
export const posts: Post[] = [];
export function addPost(post: Omit<Post, 'id'>): Post {
const newPost = { ...post, id: posts.length + 1 };
posts.push(newPost);
return newPost;
}
// server/src/index.ts
import express from 'express';
import { addPost, posts } from './posts.js'; // 记得加 .js
const app = express();
app.use(express.json());
app.get('/posts', (req, res) => {
res.json(posts);
});
app.post('/posts', (req, res) => {
const newPost = addPost(req.body);
res.status(201).json(newPost);
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
前端 (client/)
// client/tsconfig.json
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler", // Vite/Webpack 常用
"jsx": "react-jsx", // 如果用 React
"outDir": "./dist"
}
}
// client/src/api.ts
import type { Post } from '../server/src/posts'; // 跨项目引用类型,实际项目中通常会发布 npm 包
export async function fetchPosts(): Promise<Post[]> {
const response = await fetch('http://localhost:3000/posts');
return response.json();
}
export async function createPost(title: string, content: string): Promise<Post> {
const response = await fetch('http://localhost:3000/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content })
});
return response.json();
}
// client/src/main.ts
import { fetchPosts, createPost } from './api';
async function init() {
const allPosts = await fetchPosts();
console.log('All Posts:', allPosts);
// 模拟创建
// await createPost('My First Post', 'Hello World');
}
init();
结语:保持好奇,持续调试
模块化开发就像是在玩拼图。一开始,你可能觉得每一块碎片(模块)都很奇怪,它们之间的连接方式(import/export)也很晦涩。但当你掌握了 Node.js 的 NodeNext 策略,理解了浏览器的原生 ESM 限制,学会了用 Vite 这样的现代工具加速开发,你会发现这一切都变得井然有序。
记住,出错不可怕。每一次 SyntaxError 都是系统在教你新的规矩。多看看官方文档,多试试不同的配置组合,你的直觉会越来越准。
希望这篇文章能帮你理清 TypeScript 模块化的脉络。如果你在某个具体环节卡住了,不妨停下来,检查一下 tsconfig.json 和 package.json,这通常能解决 80% 的问题。
祝编码愉快!🚀
