TypeScript 模块化开发新手常踩的坑 从 commonjs 到 esm 模块迁移与路径别名配置指南
嘿,朋友!你是不是也在折腾 TypeScript 模块化开发?刚上手的时候,import/export 那些东西看起来挺简单,但真用起来的时候,各种报错能把你整崩溃。别慌,我见过太多人在这上面栽跟头,今天就带你把这摊子事儿掰扯清楚。
先说说咱们遇到的那些坑吧。很多人一开始跟着教程写代码,用的是 require 和 module.exports,后来又想升级到 ESM,结果发现整个世界都变了。更离谱的是配置路径别名的时候,运行好好的,打包就报错,或者反过来,开发没事,生产就炸。这些我都见过,今天咱们一个个来拆解。
一、先搞明白:CommonJS 和 ESM 到底有啥区别
很多新手上来就跟着教程复制粘贴配置,根本不清楚这两个东西有啥本质区别。咱们先从根儿上理解。
CommonJS 是 Node.js 最早用的模块系统,用的是 require() 和 module.exports。它的特点是同步加载,模块在运行的时候才加载。这意味着你在写代码的时候可以动态地 require,比如根据条件来决定加载哪个模块。
ESM 是 EcmaScript 标准的模块系统,用的是 import 和 export。它的特点是静态分析,模块在编译的时候就能确定依赖关系。这也是为什么 ESM 支持 tree-shaking —— 打包工具能知道哪些代码实际上没用到,直接帮你去掉。
咱们看个具体的例子就明白了:
// CommonJS 写法 (module.exports)
// 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 };
// 对应的 ESM 写法 (export)
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
用起来也不一样:
// CommonJS 引入
const { add, subtract } = require('./math');
// ESM 引入
import { add, subtract } from './math.js';
看到没?CommonJS 是动态的,你可以在运行时决定要啥;ESM 是静态的,import 语句必须在顶层,不能在函数里面写。这个区别很重要,后面踩坑都是因为这个。
二、TypeScript 的模块系统配置
TypeScript 本身不强制你用哪种模块系统,它通过 tsconfig.json 里的 module 选项来让你选择。这里就是新手最容易迷糊的地方。
先看看 tsconfig.json 里常见的配置:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"]
}
这里面的每个选项都有讲究。我一个个给你解释。
module 选项
这个决定了 TypeScript 编译出来的代码用啥模块格式。常见的值有:
commonjs:编译成 CommonJS 格式,适合 Node.js 环境esnext/es2020/es2015:保持 ES Module 语法,需要配合打包工具或者现代运行环境node16/nodenext:Node.js 原生支持 ESM 后推出的新选项
很多新手不知道,module 选 commonjs 的话,你写的 import 语法会被编译成 require。也就是说,TS 帮你做了转换,你在代码里写啥不重要,重要的是你想让最终输出啥。
// 你的源码
import { readFile } from 'fs';
// 编译成 commonjs 后变成
const fs_1 = require("fs");
(0, fs_1.readFile)(...);
看到没,TS 帮你把 ESM 语法转成了 CJS,所以你能在 Node.js 里放心用 import,不用担心报错。但这个转换过程就是很多人踩坑的地方。
moduleResolution 选项
这个决定了 TypeScript 怎么解析模块路径。常见的值有:
node:跟着 Node.js 的旧规则走,找node_modules里的包node16/nodenext:跟着 Node.js 的新规则走,支持 ESMbundler:跟着打包工具(Webpack/Vite 等)的规则走
新手一般用 node,但如果你想用 ESM,就得换成 node16 或 nodenext。
三、从 CommonJS 迁移到 ESM 的坑
好,说回正题。你的项目一开始是用 CommonJS 的,后来想升级到 ESM,会遇到哪些问题?
坑一:动态 import 不工作了
ESM 不支持动态 require,但你可以用动态 import。看这个例子:
// 这是 CommonJS,没问题
function loadModule(name: string) {
const mod = require(`./modules/${name}`);
return mod.default;
}
// 这改写成 ESM,就得这样
async function loadModule(name: string) {
const mod = await import(`./modules/${name}.js`);
return mod.default;
}
注意两点:第一,动态 import 返回的是 Promise,得用 await;第二,文件路径得加 .js 后缀,这是 ESM 的硬性要求。
坑二:__dirname 和 __filename 没了
CommonJS 里有 __dirname 和 __filename 这两个全局变量,ESM 里没有。怎么办?用 import.meta.url:
// CommonJS
console.log(__dirname);
console.log(__filename);
// ESM
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname);
console.log(__filename);
看,得多写几行代码。这就是迁移的代价,但换来的是更标准的模块系统。
坑三:默认导入行为变了
这是最让人头疼的。CommonJS 的 module.exports = something 在 ESM 里会被当作默认导出,但 TS 的编译行为有时候会跟你开玩笑。
// 你的源码文件
// utils.ts
export default {
helper: () => 'hello'
};
// 引入的时候
import utils from './utils';
console.log(utils.helper()); // 没问题
// 但如果你的 tsconfig 配置不对,可能会变成
import utils from './utils.js';
console.log(utils.default.helper()); // 多了一层 .default,傻眼了
这个问题跟 esModuleInterop 配置有关。如果你的 tsconfig.json 里没开这个选项,TypeScript 就不会帮你做兼容处理,你就得手动处理 .default。
坑四:package.json 里的 "type": "module"
这是 Node.js 判断用啥模块系统的依据。如果你的 package.json 里有 "type": "module",那所有 .js 文件都会被当成 ESM;如果没有,就默认是 CommonJS。
TypeScript 编译出来的文件是 .js,所以这个配置很重要。你得确保你的构建流程里,这个字段是配对的。
{
"name": "my-project",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
配了 "type": "module" 之后,你的 dist 目录下的 .js 文件就会用 ESM 语法运行。如果没配,Node.js 会按 CommonJS 解析,结果就炸了。
四、路径别名的配置与踩坑
路径别名这个东西,开发起来真的方便。你不用写那一长串相对路径 ../../../components/Button,直接用 @/components/Button 就完事了。但配置起来坑也不少。
基础配置
首先在 tsconfig.json 里配 paths:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@utils/*": ["src/utils/*"],
"@components/*": ["src/components/*"]
}
}
}
然后你得用路径插件让 TS 能识别这些别名:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
到这里,TypeScript 编译器能识别别名了,但你的运行环境和打包工具不一定能识别。
坑五:打包工具不认识路径别名
这是最常见的坑。你的 TypeScript 代码写得好好的,用 @/utils/helper 引入,TypeScript 编译不报错,但打包的时候就找不到文件了。
不同打包工具的配置不一样,我一个个说。
Vite 配置:
// vite.config.ts
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
});
Webpack 配置:
// webpack.config.js
const path = require('path');
module.exports = {
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
};
esbuild 配置:
// 如果用 esbuild 编译
import { build } from 'esbuild';
build({
entryPoints: ['src/index.ts'],
outfile: 'dist/index.js',
bundle: true,
platform: 'node',
alias: {
'@': './src'
}
});
看到了吗,每个工具都有自己的配置方式。你得把 tsconfig.json 的 paths 和打包工具的 alias 都配好,才能正常工作。
坑六:路径别名在运行时不生效
这个坑更隐蔽。你的代码在 TypeScript 阶段没问题,打包也过了,但运行起来就是找不到模块。
问题的根源在于:TypeScript 编译器会解析 paths,但 Node.js 运行时的模块解析不认识这些别名。如果你的项目是纯 Node.js 运行,没有用打包工具,你就得用 tsconfig-paths 这个包来帮你在运行时解析路径。
npm install -D tsconfig-paths
然后在启动命令里加个注册:
{
"scripts": {
"start": "tsx --projects tsconfig.json src/index.ts"
}
}
或者用 ts-node:
npm install -D ts-node tsconfig-paths
{
"scripts": {
"start": "ts-node -r tsconfig-paths/register src/index.ts"
}
}
这样 Node.js 在运行之前,会先读 tsconfig.json 里的 paths 配置,把别名替换成实际路径。
坑七:IDE 里的智能提示不工作
有时候代码能跑,打包也没问题,但 VS Code 里的智能提示告诉你找不到模块。这通常是 IDE 没正确读取 tsconfig.json 的路径配置。
解决方法:
- 确认
tsconfig.json里的baseUrl和paths配对了 - 重启 TypeScript 语言服务(VS Code 里按
Cmd/Ctrl + Shift + P,输入TypeScript: Restart TS Server) - 检查是否有多个
tsconfig.json文件,IDE 可能读错了一个
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "tests/**/*"]
}
注意 include 字段,确保你写的文件在 TS 的管辖范围内,否则路径别名对你的文件不生效。
五、完整的迁移案例
光说不练假把式,我来给你演示一个完整的迁移过程。
假设你有一个项目,一开始用 CommonJS:
my-project/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts
│ ├── utils/
│ │ └── helper.ts
│ └── components/
│ └── button.ts
初始状态:
// package.json
{
"name": "my-project",
"version": "1.0.0",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
// src/index.ts
import { sayHello } from './utils/helper';
console.log(sayHello('world'));
// src/utils/helper.ts
export function sayHello(name: string): string {
return `Hello, ${name}!`;
}
这个配置跑起来没问题。现在你要迁移到 ESM。
第一步:改 package.json
{
"name": "my-project",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}
加了 "type": "module",告诉 Node.js 用 ESM 解析。
第二步:改 tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "esnext",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInImports": true
},
"include": ["src/**/*"]
}
module 改成 esnext,这样编译出来的代码会保留 import/export 语法,而不是转成 require。
第三步:改源码里的 import 路径
ESM 要求 import 路径必须带扩展名:
// src/index.ts
import { sayHello } from './utils/helper.js';
console.log(sayHello('world'));
// src/utils/helper.ts
export function sayHello(name: string): string {
return `Hello, ${name}!`;
}
注意 .js 后缀。你可能会问,我写的是 .ts 文件啊,为啥要加 .js?因为 TypeScript 编译后会输出 .js 文件,ESM 解析的时候看到的是 .js,所以你得写 .js。TypeScript 在编译时会把 .js 自动映射到 .ts。
第四步:测试运行
npm run build
npm start
如果配置对了,应该能看到输出:
Hello, world!
六、用路径别名重构项目
迁移完成之后,我们可以加上路径别名,让代码更整洁。
更新 tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "esnext",
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInImports": true
},
"include": ["src/**/*"]
}
更新源码:
// src/index.ts
import { sayHello } from '@/utils/helper.js';
console.log(sayHello('world'));
// src/components/button.ts
export function renderButton(label: string): string {
return `<button>${label}</button>`;
}
// src/index.ts(引入组件)
import { sayHello } from '@/utils/helper.js';
import { renderButton } from '@/components/button.js';
console.log(sayHello('world'));
console.log(renderButton('Click me'));
你看,代码是不是清爽多了?不用再写那些 ../../components/button 了。
七、常见报错及解决方案
最后,我给你整理一下新手最容易遇到的报错,以及对应的解决办法。
报错 1:Cannot find module 'xxx' or its corresponding type declarations
原因: 路径配置不对,或者 moduleResolution 没配好。
解决:
{
"compilerOptions": {
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
如果是在 Node.js 运行时出问题,检查 package.json 里的 "type" 字段。
报错 2:SyntaxError: Cannot use import statement outside a module
原因: Node.js 把 .js 文件当 CommonJS 解析,但你写的是 ESM 语法。
解决:
- 在
package.json里加"type": "module" - 或者把文件扩展名改成
.mjs - 或者把
tsconfig.json的module改回commonjs
报错 3:require is not defined
原因: ESM 环境里用了 require。
解决: 把 require 改成 import:
// 错误写法
const fs = require('fs');
// 正确写法
import fs from 'fs';
// 或者
import * as fs from 'fs';
报错 4:default export is not a constructor
原因: esModuleInterop 没开,导致 CommonJS 的默认导出被当作命名导出处理。
解决:
{
"compilerOptions": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
报错 5:打包后路径别名不生效
原因: 打包工具没配置路径别名。
解决: 根据你用的打包工具配置对应的 alias,前面已经给过 Vite、Webpack、esbuild 的配置了。
八、一些实用的建议
聊了这么多坑,我给你几个实用的建议,帮你少走弯路。
建议 1:一开始就用 ESM,别先 CJS 再迁移
迁移是个痛苦的过程,你会遇到一堆兼容问题。如果新项目一开始就用 ESM,能省下很多麻烦。TypeScript 完全支持 ESM,不用纠结。
建议 2:esModuleInterop 一定要开
这个选项能帮你省去很多导入导出的烦恼。它会做兼容处理,让 CommonJS 和 ESM 的互操作更顺滑。
{
"compilerOptions": {
"esModuleInterop": true,
"allowSyntheticDefaultImports": true
}
}
建议 3:路径别名统一用 @ 开头
@ 是个约定俗成的前缀,表示”这是我的项目根目录”。别用别的符号,不然别人看不懂。
建议 4:import 路径一定要带扩展名
ESM 的硬性规定。虽然 TypeScript 能帮你处理,但写上更规范,也避免一些奇怪的报错。
// 推荐
import { helper } from '@/utils/helper.js';
// 不推荐(虽然 TS 能编译过,但运行时可能出问题)
import { helper } from '@/utils/helper';
建议 5:用 tsx 代替 ts-node
tsx 是现代的 TypeScript 运行器,对 ESM 的支持更好,配置也更简单。
npm install -D tsx
{
"scripts": {
"dev": "tsx watch src/index.ts"
}
}
九、总结
TypeScript 模块化开发看着简单,其实门道不少。从 CommonJS 到 ESM 的迁移,路径别名的配置,每一个环节都有坑等着新手。
我帮你捋一捋核心要点:
- 搞清楚 CommonJS 和 ESM 的本质区别,一个是动态加载,一个是静态分析
tsconfig.json里的module、moduleResolution、esModuleInterop是关键配置- 迁移到 ESM 要改
package.json的type字段,改源码的 import 路径加.js后缀 - 路径别名要同时配置
tsconfig.json和打包工具 - 运行时如果用路径别名,需要
tsconfig-paths或在启动命令里注册 - 遇到报错先检查配置,大部分问题都是配置没对齐
模块化开发是 TypeScript 项目的基石,把这个搞清楚了,后面写代码会顺很多。希望这篇文章能帮你避开那些坑,少掉一些头发。有啥问题,随时来问!
