📝 引言
📌 场景/痛点
你一定经历过这种崩溃时刻:
- 在项目里写了
import express from 'express',运行时报错SyntaxError: Cannot use import statement outside a module。 - 或者更离谱的
Default import is not a function,导致你不得不写成import * as ...这种丑陋的写法。 - 看到文件夹里既有
.js又有.cjs,还有.mjs,完全不知道该用哪个。
Node.js 20 已经原生支持 ESM,TypeScript 5.9 也完善了相关的 Node16/NodeNext 解析策略。 是时候彻底告别 require 和复杂的 webpack 别名配置,拥抱原生的模块化标准了。
✨ 最终效果
掌握本文配置后,你的项目将实现"类型"与"运行时"的完美统一。无论是开发环境还是打包产物,都不会再出现模块解析错误。
📖 内容概览
本文将带你理清 ESM 迁移的乱麻:
- 核心配置 :
package.json中的type字段与moduleResolution。 - 后缀名之谜 :
.mts,.cts,.d.mts到底是什么? - 互操作性:如何在 ESM 项目中正确引入 CommonJS 库。
- 动态导入:解决懒加载和循环依赖的利器。
🛠️ 正文
1. 环境准备
请确保 Node.js 版本 >= 20.10.0 (LTS)。
2. 第一步:确立项目性质
在现代 Node.js 项目中,第一步就是在 package.json 中决定你的"底色"。
2.1 开启 ESM 模式 (推荐)
在 package.json 根节点添加:
json
{
"type": "module"
}
这意味着:
- 默认情况下,
.js后缀的文件被视为 ESM 格式。 - 你可以自由使用
import/export语法。 - 如果你非要用
require,必须把文件后缀改成.cjs(Common JS)。
3. 第二步:配置 TypeScript (tsconfig.json)
TS 5.9 引入了 Node16 和 NodeNext 模式,专门用于完美对齐 Node.js 的 ESM 行为。
json
{
"compilerOptions": {
"target": "ES2022", // 现代语法
"module": "NodeNext", // 🔥 关键:匹配 Node.js 的 ESM 行为
"moduleResolution": "NodeNext", // 🔥 关键:使用 Node 20 的解析规则
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
},
"include": ["src/**/*"]
}
4. 第三步:理解后缀名的终极奥义
这是最容易晕的地方。请记住这张表:
| TS 源文件后缀 | 编译目标 | 说明 |
|---|---|---|
.ts |
.js |
普通文件,受 package.json 的 type 影响。 |
.mts |
.mjs |
强制 ESM 格式。无视 package.json 的设置。 |
.cts |
.cjs |
强制 CommonJS 格式(即使用户开启了 ESM)。 |
.d.ts |
- | 普通声明文件。 |
.d.mts |
- | ESM 专用声明文件。 |
.d.cts |
- | CommonJS 专用声明文件。 |
2026 年建议:
- 全源码项目:全部使用
.ts,根目录开"type": "module"。 - 发布给他人用的 npm 库:如果是 ESM 专用库,可以使用
.mts确保用户不能按 CJS 导入,避免require is not a function错误。
5. 实战:引入 CommonJS 依赖
2026 年依然有很多老库是 CJS 格式的(如某些老旧的 SDK)。
5.1 错误写法
typescript
// 如果库导出了 module.exports = fn
import express from 'express';
// 可能会报错:`express` is not a function
5.2 正确写法
在 module: NodeNext 模式下,TS 会严格检查。
- 如果库提供了
types字段,TS 会自动处理。 - 如果没有,或者你只想导入默认导出,推荐使用 命名空间导入 或 动态导入。
typescript
// 方案 A:命名空间导入(最稳妥)
import * as express from 'express';
const app = express();
// 方案 B:Dynamic Import (动态导入)
// 适用于懒加载或解决循环依赖
async function init() {
const { default: express } = await import('express');
return express();
}
6. 动态导入:顶级 await
在 ESM 模式下(TypeScript 中需开启 module: NodeNext 且 target 较新),你可以直接在文件顶层使用 await。
typescript
// src/config.ts
const dbConfig = await import('./db-connection.js'); // 注意:import里要写 .js
// 或者
const secret = await loadSecret();
console.log("Config loaded:", secret);
注意 :TypeScript 的 import 语法中,必须包含文件扩展名 (.js),因为在 ESM 标准中,扩展名是强制要求的,TS 只是把这个路径原样保留在生成的 JS 中。
❓ 常见问题
Q1: 报错 Unknown file extension ".ts" 或 Cannot find module
A:
- 检查
tsconfig.json中moduleResolution是否为NodeNext。 - 检查
package.json是否有"type": "module"。 - 如果是 Node.js 直接运行 TS (如
ts-node),请确保使用支持 ESM 的加载器(如tsx)。
Q2: 为什么我必须写 import ... from './utils.js' 而不是 .ts?
A:
- 开发阶段 :开启
allowImportingTsExtensions: true允许你写.ts。 - 运行时 :生成的 JS 里会变成
.js,这是正确的。如果你在源码里写.js,TS 也能推导回来,但为了 IDE 跳转体验,推荐写.ts并开启上述配置。
Q3: 我的项目很老,全是 require,想迁过来怎么办?
A: 别急!
- 先把
package.json的type改成module。 - 全局搜索替换
require为import(通常用 AST 转换工具,如import-cjs)。 - 遇到报错的地方,通常是默认导出的库,改成
import * as即可。
🎯 总结
在 Node.js 20+ 和 TS 5.9 的时代,ESM 互操作性已经非常成熟。本文重点掌握了:
- 配置底色 :
package.json中"type": "module"是开启 ESM 的钥匙。 - 解析策略 :使用
module: "NodeNext"和moduleResolution: "NodeNext"对齐 Node 行为。 - 后缀名 :理解
.mts(ESM) 和.cts(CJS) 的强制约束。 - 互操作 :使用
import * as处理老旧的 CommonJS 依赖。
🚀 下期预告:
我们统一了模块系统,也实现了全栈类型共享。但在业务层面,如何用类型系统防止"把金额传成数量"这种低级错误?
下一篇文章我们将深入 《品牌类型与领域驱动设计 (DDD)》,用类型构建业务护城河。
💬 互动环节:
你现在还在用 CommonJS 吗?迁移过程中遇到过什么奇葩报错?
如果觉得文章帮你理清了模块乱麻,请点赞👍、收藏⭐、关注👀,下期更精彩!