ESM 模块与互操作
本教程共 76 篇 · 第 7 篇 · 更新于 2026-07-25 · 约 7 分钟阅读
7. ESM 模块与互操作
本节目标:import/export 语法、ESM 与 CommonJS 互操作、顶层 await 及迁移要点。
ESM(ECMAScript Modules)是 JavaScript 官方的模块标准。从 ES6(2015年)提出,到 Node.js v12 实验性支持,再到 v24 LTS 完全成熟,ESM 已经是我们写新项目时的默认选择。
如果说 CommonJS 是「老小区的楼梯房」,ESM 就是「带电梯的新楼盘」。两者都能住人,但新楼盘的设计更符合现代需求。
启用 ESM 的三种方式
Node.js 默认把 .js 文件当 CommonJS 处理。要让代码被识别为 ESM,有三种办法:
1. 改扩展名为 .mjs
最直观,零配置。文件叫 .mjs,Node.js 自动按 ESM 解析。
// hello.mjs
export function greet(name) {
return `Hello, ${name}!`;
}
// main.mjs
import { greet } from './hello.mjs';
console.log(greet('World'));
WarningESM 里导入本地文件,必须写扩展名。
import { greet } from './hello'会直接报错。这点和 CommonJS 不一样,我踩过好几次坑。
2. 在 package.json 里声明 "type": "module"
项目里全是 .js 文件,不想改后缀?在 package.json 加一行:
{
"name": "my-app",
"version": "1.0.0",
"type": "module"
}
这下整个项目的 .js 文件都变成 ESM 了。如果你又有个别文件想回退到 CommonJS,把那文件改 .cjs 后缀即可。
3. 命令行临时指定
node --input-type=module script.js
适合跑单个脚本,日常开发用得少。
导出语法:named export 与 default export
ESM 的导出比 CommonJS 更灵活。你主要会遇到两种形式:
具名导出(Named Exports)
一个模块可以导出多个名字:
// math.mjs
export const PI = 3.14159;
export function circleArea(r) {
return PI * r * r;
}
// 也可以先定义,最后统一导出
function cube(x) {
return x ** 3;
}
export { cube };
导入时按名取用:
import { PI, circleArea, cube } from './math.mjs';
默认导出(Default Export)
每个模块只能有一个默认导出,通常用来导出「这个模块最主要的东西」:
// logger.mjs
export default class Logger {
constructor(label) {
this.label = label;
}
log(msg) {
console.log(`[${this.label}] ${msg}`);
}
}
导入时名字可以随便起:
import Logger from './logger.mjs';
const log = new Logger('app');
log.log('系统启动');
混合导出
默认导出和具名导出可以同时存在:
// api.mjs
const BASE_URL = 'https://api.example.com';
export async function fetchUser(id) {
const res = await fetch(`${BASE_URL}/users/${id}`);
return res.json();
}
export default BASE_URL;
import baseUrl, { fetchUser } from './api.mjs';
Tip如果导出的名字和当前模块的变量冲突,可以用
as重命名:import { fetchUser as getUser } from './api.mjs'。也可以一口气全导入为一个对象:import * as api from './api.mjs',然后通过api.fetchUser()调用。
动态导入:import()
import ... from ... 是静态的,必须写在模块顶层,不能放在 if 语句里。有时候你需要按需加载,这时用动态导入:
// 只在需要时加载 heavy 模块
if (process.env.NODE_ENV === 'production') {
const { optimize } = await import('./optimizer.mjs');
optimize();
}
import() 返回一个 Promise,可以用 await 等待。这在代码分割、条件加载场景非常实用。
顶层 await(Top-level Await)
ESM 支持在模块顶层直接写 await,不用包在 async 函数里。
// config.mjs
const res = await fetch('https://api.example.com/config');
export const config = await res.json();
// main.mjs
import { config } from './config.mjs';
// 这里拿到的 config 已经是解析完成的对象
console.log(config.apiKey);
Note顶层 await 是 v14.8+ 引入、v24 稳定支持的特性。导入带有顶层 await 的模块时,当前模块的执行会等待它完成。别滥用,否则可能拖慢启动速度。
ESM 与 CommonJS 的互操作
现实很骨感:npm 上大量包还是 CommonJS,你的新项目可能是 ESM。两者怎么打交道?
ESM 导入 CommonJS
这是最常见的场景,Node.js 已经帮你处理好了:
// 在 ESM 文件里直接 import CJS 模块
import fs from 'fs'; // 内置 CJS 模块
import lodash from 'lodash'; // 第三方 CJS 包
// 也可以用 named import(如果 CJS 模块导出了对应属性)
import { readFile } from 'fs';
CommonJS 的 module.exports 会被当作 ESM 的默认导出。如果 CJS 模块用 exports.foo = ... 导出了多个属性,ESM 里也能通过命名导入拿到,但机制上其实是先拿到整个对象再解构,不是真正的静态绑定。
CommonJS 导入 ESM
CommonJS 不能直接用 require() 加载 ESM 模块,会报错。必须走动态 import():
// 在 CJS 文件里加载 ESM 模块
async function load() {
const { default: esmModule } = await import('./my-esm.mjs');
esmModule.run();
}
load();
Warning混用两种模块系统时,最容易出的问题:ESM 文件里写
const x = require('x'),或者 CJS 文件里写import x from 'x'。记住这个边界——require只能在 CJS 里用,import语法只能在 ESM 里用(import()函数除外)。
package.json 的 exports 字段
如果你要发布一个同时支持 ESM 和 CommonJS 的包,光有 main 字段不够。现代做法是用 exports 字段,明确告诉 Node.js 不同导入方式该走哪个入口:
{
"name": "my-utils",
"version": "1.0.0",
"type": "module",
"main": "./index.cjs",
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
},
"./package.json": "./package.json"
}
}
import对应 ESM 环境require对应 CommonJS 环境"./package.json"显式暴露出来,方便工具读取元数据
如果不做这种区分,用户用 require('my-utils') 和 import('my-utils') 可能拿到两份独立的模块实例,导致状态不共享。这就是著名的「双包危害(Dual Package Hazard)」。
文件路径的那些坑
ESM 对路径要求更严格,我列几个常见坑:
| 写法 | 结果 |
|---|---|
import './utils' | ❌ 报错,缺少扩展名 |
import './utils.js' | ✅ 正确(如果是 .js 文件) |
import './utils/index' | ❌ 报错,不能省略 index.js |
import './utils/index.js' | ✅ 正确 |
import '/absolute/path' | ❌ 不支持裸绝对路径(要用 file:// URL) |
需要绝对路径或动态拼接时,可以用 import.meta.url:
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dataPath = join(__dirname, 'data.json');
TipESM 里没有内置的
__dirname和__filename,上面这段是标准写法,建议封装成一个utils/path.mjs,到处复用。
实战:搭一个双模块系统的小项目
光说不练假把式,我们来搭个能跑的小项目,同时感受 ESM 和互操作。
项目结构:
esm-demo/
├── package.json
├── cjs-legacy.js # CommonJS 旧模块
├── esm-modern.mjs # ESM 新模块
└── main.mjs # 入口
// package.json
{
"name": "esm-demo",
"version": "1.0.0",
"type": "module"
}
// cjs-legacy.js
module.exports = {
legacyGreet(name) {
return `Hi from CJS, ${name}`;
}
};
// esm-modern.mjs
export function modernGreet(name) {
return `Hello from ESM, ${name}`;
}
// main.mjs
import { modernGreet } from './esm-modern.mjs';
import cjs from './cjs-legacy.js'; // ESM 直接 import CJS
console.log(modernGreet('Node.js'));
console.log(cjs.legacyGreet('Node.js'));
运行:
node main.mjs
输出:
Hello from ESM, Node.js
Hi from CJS, Node.js
看,两者和平共处。这就是 v24 时代 Node.js 的模块生态现状:ESM 是主角,CommonJS 是还在场的配角。