首页 / Node.js 教程 / ESM 模块与互操作

Node.js 教程

ESM 模块与互操作

本教程共 76 篇 · 第 7 篇 · 更新于 2026-07-25 · 约 7 分钟阅读

Node.jsESMimport模块互操作

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'));
Warning

ESM 里导入本地文件,必须写扩展名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');
Tip

ESM 里没有内置的 __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 是还在场的配角。