模块系统
概述
Node.js 的模块系统是整个运行时的基石之一。它提供了代码组织、依赖管理、作用域隔离的核心能力。Node.js 先后实现了两套模块规范:CommonJS (CJS) 和 ECMAScript Modules (ESM)。CJS 是 Node.js 诞生之初的默认模块系统,采用同步 require 加载和 module.exports 导出;ESM 则是 JavaScript 语言层面的标准模块系统,采用静态 import/export 语法,支持异步加载和树摇优化。理解两套系统的运行机制、差异与互操作方式,是 Node.js 开发者的必修课。
What — 核心概念
CommonJS (CJS)
CommonJS 是 Node.js 最初采用的模块规范,由 Mozilla 工程师 Kevin Dangoor 于 2009 年发起。每个文件就是一个模块,拥有独立的作用域。
核心 API:
require(id):同步加载模块,返回模块的module.exports对象module.exports:定义模块对外暴露的接口exports:module.exports的引用简写module.id:模块标识符module.filename:模块文件的绝对路径module.loaded:模块是否已加载完毕(布尔值)module.parent:首次加载该模块的模块(已废弃)module.children:该模块 require 的所有子模块module.paths:模块搜索路径数组
基本用法:
// math.js — CJS 导出
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };
// 等价写法:使用 exports 简写(注意陷阱)
exports.add = add;
exports.subtract = subtract;
// ❌ 错误写法:exports = { add, subtract } 会断开与 module.exports 的引用
// app.js — CJS 导入
const { add, subtract } = require('./math');
console.log(add(1, 2)); // 3
console.log(subtract(5, 3)); // 2
// 动态导入(运行时决定加载哪个模块)
const moduleName = process.env.USE_FAST ? './fast-lib' : './slow-lib';
const lib = require(moduleName);
ECMAScript Modules (ESM)
ESM 是 ECMAScript 2015 (ES6) 引入的语言标准模块系统,Node.js 从 v12.11.1 起稳定支持(需 package.json 中设置 "type": "module" 或文件使用 .mjs 后缀)。
核心语法:
export:命名导出 / 默认导出import:静态导入import():动态导入(返回 Promise)export ... from ...:再导出
基本用法:
// math.mjs — ESM 命名导出
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// 默认导出(每个模块只能有一个)
export default class Calculator {
constructor() { this.history = []; }
compute(fn, a, b) {
const result = fn(a, b);
this.history.push({ fn: fn.name, a, b, result });
return result;
}
}
// app.mjs — ESM 导入
import Calculator, { add, subtract, PI } from './math.mjs';
// 或全部导入
import * as MathModule from './math.mjs';
console.log(add(1, 2)); // 3
console.log(PI); // 3.14159
// 动态导入
const module = await import('./dynamic-module.mjs');
加载机制
CJS 加载流程(require 的完整过程):
- 路径解析:调用
Module._resolveFilename(id, parent)解析模块路径- 内置模块(如
fs、path)直接返回 - 相对/绝对路径直接解析
- 裸模块标识符沿
node_modules逐级查找
- 内置模块(如
- 缓存检查:检查
Module._cache[filename]是否已存在 - 创建模块对象:
new Module(id, parent),初始化exports = {} - 加入缓存:在执行模块代码之前就将模块加入缓存(防止循环依赖导致无限递归)
- 编译执行:调用
Module._extensions[extension]()编译并执行模块代码.js:调用vm.compileFunction在模块作用域中执行.json:JSON.parse后赋值给module.exports.node:process.dlopen加载 C++ 插件
- 返回导出:返回
module.exports
ESM 加载流程:
- 构建(Construction):下载/解析模块文件,构建 Module Record
- 实例化(Instantiation):为所有导出绑定内存位置(live binding)
- 求值(Evaluation):按依赖顺序执行模块代码
ESM 的加载是异步的(分为三个阶段),而 CJS 的 require 是同步的(一次性完成所有步骤)。
循环依赖
CJS 的循环依赖处理:
当模块 A require 模块 B,而 B 又 require A 时,CJS 的处理策略是返回 B 执行到 require(A) 那一刻 A 已经导出的部分(即 module.exports 的当前状态)。由于 CJS 在执行代码前就将模块加入缓存,B 中拿到的 A 的导出可能是不完整的。
// a.js
console.log('a.js 开始执行');
exports.loaded = false;
const b = require('./b');
console.log('a.js 中 b.done =', b.done);
exports.loaded = true;
exports.done = true;
// b.js
console.log('b.js 开始执行');
exports.done = false;
const a = require('./a'); // 拿到 a 的部分导出 { loaded: false }
console.log('b.js 中 a.loaded =', a.loaded); // false
console.log('b.js 中 a.done =', a.done); // undefined
exports.done = true;
运行 node a.js 输出:
a.js 开始执行
b.js 开始执行
b.js 中 a.loaded = false
b.js 中 a.done = undefined
a.js 中 b.done = true
ESM 的循环依赖处理:
ESM 采用 Live Binding 机制,导出的是绑定而非值的快照。当循环依赖发生时,可以获取到模块最终导出的值。但如果在模块顶层 await 之前就访问循环依赖的导出,仍会得到 undefined(TDZ,暂时性死区)。
模块缓存
CJS 通过 Module._cache 对象缓存已加载的模块。这意味着同一模块无论被 require 多少次,只执行一次,后续调用直接返回缓存。
// 查看缓存
console.log(require.cache); // 所有已缓存模块
console.log(Object.keys(require.cache).length); // 已缓存模块数量
// 清除缓存(热更新场景)
delete require.cache[require.resolve('./hot-module')];
const freshModule = require('./hot-module'); // 重新执行
ESM 的缓存由引擎内部管理,存储在 import.meta.cache(实验性)或通过 import() 的缓存行为体现。ESM 模块同样只执行一次,后续 import() 返回缓存的命名空间对象。
条件导出 (Conditional Exports)
package.json 的 exports 字段允许为不同运行环境提供不同入口,这是现代 Node.js 包的标准做法。
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
},
"./features": {
"import": "./dist/features.mjs",
"require": "./dist/features.cjs"
}
}
}
条件导出的匹配顺序:
import— ESM import 使用require— CJS require 使用default— 兜底方案node/browser/deno/worker等条件
package.json 的 type 字段
type 字段决定 .js 文件被视为 CJS 还是 ESM:
| type 值 | .js 文件解释为 | .cjs 文件解释为 | .mjs 文件解释为 |
|---|---|---|---|
| 未设置 / “commonjs” | CJS | CJS | ESM |
| ”module” | ESM | CJS | ESM |
这是一个项目级配置,影响所有 .js 文件的解析方式。使用时需特别注意,将已有 CJS 项目改为 "type": "module" 可能导致全项目 require 语法报错。
运行机制
内存模型:Module._cache
Node.js 的 CJS 模块系统维护了一个全局缓存对象 Module._cache,其键为模块的绝对文件路径,值为 Module 实例对象。
// Module._cache 的内部结构(简化)
Module._cache = {
'/absolute/path/to/module.js': {
id: '/absolute/path/to/module.js',
path: '/absolute/path/to',
exports: { /* 模块导出内容 */ },
filename: '/absolute/path/to/module.js',
loaded: true,
children: [/* 子模块 */],
paths: [/* 模块搜索路径 */]
}
};
关键特性:
- 缓存键是解析后的绝对路径,而非模块 ID。这意味着
require('./a')和require('./a.js')如果解析到同一文件,共享同一缓存 loaded属性标记模块是否已完全执行,循环依赖中可用来判断模块是否处于加载中状态- 缓存在进程生命周期内持续存在,除非手动删除
ESM 的缓存模型由 V8 引擎管理,通过 import() 获取的命名空间对象在模块求值后保持 live binding。ESM 的模块记录 (Module Record) 一旦创建就被缓存,后续 import() 不会重新构建和实例化。
执行模型:同步 vs 异步
CJS — 同步加载模型:
主模块 → require('A') → 执行 A → require('B') → 执行 B → 返回 B.exports → 继续执行 A → 返回 A.exports → 继续主模块
CJS 的 require 是完全同步的阻塞调用。模块代码在 require 调用时立即执行,执行完毕后才返回。这意味着:
- 模块顶层代码按 require 的调用顺序依次执行
- 不支持顶层 await
- 适合服务端启动阶段加载,不适合运行时按需加载
ESM — 异步三阶段模型:
阶段1: 构建 (Construction) — 下载/解析所有模块,建立依赖图
阶段2: 实例化 (Instantiation) — 为所有导出创建内存绑定
阶段3: 求值 (Evaluation) — 按依赖拓扑序执行模块代码
ESM 的加载过程是异步的,即使本地文件也需经过完整三阶段。这带来以下差异:
- 支持顶层
await(Top-level Await) - 静态
import在构建阶段就确定依赖关系,可实现 Tree Shaking import()动态导入返回 Promise,适合代码分割
并发模型:模块初始化锁
Node.js 是单线程的,但在模块初始化过程中仍需处理循环依赖的”重入”问题。CJS 的处理方式是在模块开始执行前就将其加入缓存,这相当于一种”初始化锁”——当模块 A 正在加载中又遇到对 A 的 require 时,直接返回当前的 module.exports(可能是空对象或部分导出),而不是再次执行 A 的代码。
ESM 的处理方式更为严格。ESM 的模块记录在构建阶段创建,实例化阶段建立绑定,求值阶段按拓扑排序执行。如果检测到循环依赖,不会无限递归,但访问尚未求值的绑定会触发 TDZ 错误。
CJS 重入模型:
require(A) → 缓存 A({}) → 执行 A → require(B) → 缓存 B({}) → 执行 B → require(A) → 返回缓存 A({loaded: false}) → 继续执行 B → 返回 B.exports → 继续执行 A → 返回 A.exports
ESM 重入模型:
import A → 构建 A Record → import B → 构建 B Record → import A → 发现已有 A Record → 实例化绑定 → 求值 B → 访问 A 的绑定(live binding) → 求值 A → 绑定值更新
类型系统
CJS 动态导出 vs ESM 静态导出
| 维度 | CJS 动态导出 | ESM 静态导出 |
|---|---|---|
| 确定时机 | 运行时确定导出内容 | 编译时/解析时确定导出内容 |
| 可否条件导出 | 可以(if/else 控制导出) | 顶层 export 不可条件化,但可重新赋值 |
| Tree Shaking | 不支持(无法静态分析) | 支持(可精确分析依赖) |
| 导出类型 | 值的拷贝(基本类型) | 值的绑定(Live Binding) |
| 修改导出 | 可随时修改 module.exports | 顶层绑定不可重新赋值 |
| 类型推断 | 工具难以推断完整类型 | 工具可精确推断类型 |
// CJS — 动态导出
if (process.env.NODE_ENV === 'production') {
module.exports = require('./prod-config');
} else {
module.exports = require('./dev-config');
}
// ESM — 静态导出(顶层不可条件化)
export const config = process.env.NODE_ENV === 'production'
? (await import('./prod-config.mjs')).default
: (await import('./dev-config.mjs')).default;
命名导出 vs 默认导出
命名导出 (Named Export):
// ESM 命名导出
export const name = 'utils';
export function helper() { /* ... */ }
export class Calculator { /* ... */ }
// CJS 命名导出模拟
exports.name = 'utils';
exports.helper = function() { /* ... */ };
默认导出 (Default Export):
// ESM 默认导出
export default class App { /* ... */ }
// 等价于
class App { /* ... */ }
export { App as default };
// CJS 默认导出模拟
module.exports = class App { /* ... */ };
最佳实践:优先使用命名导出。命名导出使得 Tree Shaking 有效、类型推断更精确、IDE 自动补全更友好、重构更安全。默认导出在重命名时更容易出错,且不利于 Tree Shaking。
// ✅ 推荐:命名导出
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
// ⚠️ 谨慎使用:默认导出
export default class Calculator { /* ... */ }
// 导入时可以任意命名:import Calc from './calc' 或 import Foo from './calc'
Why — 适用场景与对比
适用场景
CJS 适用场景:
- Node.js 传统项目,大量已有代码使用
require - 需要运行时动态加载模块(如配置驱动的插件加载)
- 使用
require.cache实现热更新 - 依赖大量仅提供 CJS 格式的 npm 包
ESM 适用场景:
- 新项目,追求现代开发体验
- 需要与浏览器共享代码(同构/通用应用)
- 需要 Tree Shaking 减小包体积
- 库开发(现代包推荐 ESM-first)
- 使用顶层 await
Monorepo 场景:
- 推荐统一使用 ESM,利用
package.json的exports字段管理包入口 - 内部包使用
"type": "module",对外提供双格式(CJS + ESM)分发 - 使用工具(tsup、unbuild、rollup)自动生成双格式产物
CJS vs ESM 四维对比表
| 维度 | CommonJS | ESM |
|---|---|---|
| 语法 | require() / module.exports | import / export / import() |
| 加载方式 | 同步、运行时求值 | 异步三阶段(构建→实例化→求值) |
| 静态分析 | 不支持,无法 Tree Shaking | 支持,可 Tree Shaking |
| 导出语义 | 值拷贝(基本类型),引用拷贝(对象) | Live Binding(实时绑定) |
| 循环依赖 | 返回部分导出(可能不完整) | Live Binding 可获取最终值,但可能 TDZ |
| 动态导入 | require(variable) 原生支持 | import() 返回 Promise |
| 顶层 await | 不支持 | 支持 |
| 浏览器兼容 | 不兼容 | 原生支持 |
| 文件扩展名 | .js / .cjs | .mjs / .js(需 type:module) |
| this 顶层值 | module.exports | undefined |
优缺点
CJS 优点:
- 生态成熟,大量 npm 包支持
- 同步加载在服务端启动阶段直观简单
- 动态 require 支持运行时条件加载
require.cache支持热更新
CJS 缺点:
- 无法静态分析,不支持 Tree Shaking
- 不兼容浏览器
- 同步加载不适合网络环境
- 循环依赖处理不优雅
ESM 优点:
- JavaScript 语言标准,浏览器/Node.js 统一
- 静态分析支持 Tree Shaking
- Live Binding 语义更合理
- 异步加载更适合网络环境和代码分割
ESM 缺点:
- Node.js 生态仍在迁移中,部分包不支持
- 文件必须写扩展名(Node.js ESM 要求)
__dirname/__filename不可用,需用import.meta- CJS 互操作有坑
How — 代码示例与最佳实践
示例1:CJS 基础用法
// ===== db.js — 数据库连接模块 =====
const { MongoClient } = require('mongodb');
// 私有变量(模块作用域隔离)
let client = null;
let db = null;
async function connect(uri, dbName) {
if (db) return db; // 单例模式
client = new MongoClient(uri);
await client.connect();
db = client.db(dbName);
console.log(`Connected to database: ${dbName}`);
return db;
}
function getDb() {
if (!db) throw new Error('Database not initialized. Call connect() first.');
return db;
}
async function disconnect() {
if (client) {
await client.close();
client = null;
db = null;
console.log('Database disconnected');
}
}
module.exports = { connect, getDb, disconnect };
// ===== app.js — 应用入口 =====
const { connect, getDb, disconnect } = require('./db');
async function main() {
try {
await connect('mongodb://localhost:27017', 'myapp');
const db = getDb();
const users = db.collection('users');
const result = await users.insertOne({ name: 'Alice', age: 30 });
console.log('Inserted:', result.insertedId);
} finally {
await disconnect();
}
}
main().catch(console.error);
示例2:ESM 基础用法
// ===== config.mjs — 配置模块 =====
export const APP_NAME = 'MyApp';
export const PORT = process.env.PORT || 3000;
export const DB_CONFIG = Object.freeze({
host: process.env.DB_HOST || 'localhost',
port: Number(process.env.DB_PORT) || 5432,
database: process.env.DB_NAME || 'myapp_dev',
});
// 命名导出 + 默认导出可共存
export default class Application {
constructor(config) {
this.config = config;
this.startTime = null;
}
start() {
this.startTime = Date.now();
console.log(`${this.config.APP_NAME} started on port ${this.config.PORT}`);
}
uptime() {
return this.startTime ? Date.now() - this.startTime : 0;
}
}
// ===== server.mjs — 服务启动 =====
import Application, { APP_NAME, PORT, DB_CONFIG } from './config.mjs';
import { createServer } from 'http';
const app = new Application({ APP_NAME, PORT, DB_CONFIG });
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
app: APP_NAME,
uptime: app.uptime(),
db: DB_CONFIG.host,
}));
});
server.listen(PORT, () => app.start());
示例3:CJS 与 ESM 混合使用方案
在现代 Node.js 项目中,经常需要处理 CJS 和 ESM 的互操作。以下是常见的混合方案:
// ===== 方案A:ESM 中导入 CJS 模块 =====
// utils.cjs
module.exports = { format: (s) => s.trim().toLowerCase() };
module.exports.version = '1.0.0';
// app.mjs — ESM 导入 CJS 模块
// Node.js 会将 module.exports 作为 default 导出
import cjsUtils from './utils.cjs';
console.log(cjsUtils.format(' Hello ')); // "hello"
console.log(cjsUtils.version); // "1.0.0"
// 也可以使用命名空间导入
import * as Utils from './utils.cjs';
console.log(Utils.default.format(' World ')); // "world"
// ===== 方案B:CJS 中导入 ESM 模块(必须使用动态 import) =====
// lib.mjs
export const greet = (name) => `Hello, ${name}!`;
export default class Greeter {
hello(name) { return greet(name); }
}
// app.cjs — CJS 导入 ESM 模块
// ❌ 不能使用 require() 加载 ESM 模块
// const lib = require('./lib.mjs'); // Error [ERR_REQUIRE_ESM]
// ✅ 必须使用动态 import()
async function main() {
const lib = await import('./lib.mjs');
console.log(lib.greet('World')); // "Hello, World!"
const greeter = new lib.default();
console.log(greeter.hello('Node')); // "Hello, Node!"
}
main();
示例4:条件导出 package.exports
{
"name": "my-universal-lib",
"version": "2.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.cjs"
},
"./server": {
"node": "./dist/server.mjs",
"default": "./dist/server-browser.mjs"
},
"./experimental": {
"import": "./dist/experimental.mjs"
},
"./package.json": "./package.json"
},
"files": ["dist"]
}
// 构建脚本(使用 tsup 生成双格式产物)
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts', 'src/server.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
outDir: 'dist',
});
踩坑表
| 坑点 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| CJS 中 require ESM 模块 | Error [ERR_REQUIRE_ESM]: require() of ES Module | ESM 模块不支持同步 require 加载 | 改用 await import() 动态导入 |
| ESM 中未写文件扩展名 | ERR_MODULE_NOT_FOUND | Node.js ESM 要求 import 路径包含完整扩展名 | 添加 .mjs/.js 扩展名,或配置 --experimental-specifier-resolution=node |
__dirname 在 ESM 中不可用 | ReferenceError: __dirname is not defined | ESM 不注入 __dirname/__filename | 使用 import.meta.dirname(Node 21.2+)或 fileURLToPath(import.meta.url) |
| ESM 中 require CJS 命名导出 | 只能通过 default 访问,命名导出需 default.xxx | CJS 的 module.exports 整体作为 ESM 的 default | 使用 import { name } from './cjs-module.cjs'(Node.js 支持命名导出检测) |
| exports 字段限制文件访问 | ERR_PACKAGE_PATH_NOT_EXPORTED | exports 字段启用后,未声明的子路径被禁止访问 | 在 exports 中添加对应的路径映射 |
| 顶层 await 阻塞后续 import | 后续模块等待 await 完成 | 顶层 await 会阻塞依赖该模块的其他模块 | 谨慎使用顶层 await,避免长时间阻塞 |
| CJS 循环依赖导出不完整 | 获取到 undefined 属性 | require 时模块尚未执行完毕 | 重构代码消除循环,或将导出放在模块顶部 |
| JSON 模块导入方式不同 | CJS: require('./data.json') 直接返回对象 | ESM 需要 assertion | ESM 使用 import data from './data.json' with { type: 'json' } |
最佳实践
-
新项目优先使用 ESM:设置
"type": "module",享受静态分析、Tree Shaking、浏览器兼容等优势。这是 Node.js 社区的明确方向。 -
库开发提供双格式:通过
package.json的exports字段同时提供 CJS 和 ESM 入口,最大化兼容性。推荐使用 tsup、unbuild 等工具自动生成。 -
消除循环依赖:无论是 CJS 还是 ESM,循环依赖都是代码异味。重构方式包括:提取共享逻辑到第三个模块、使用依赖注入、延迟 require(放在函数内部而非模块顶层)。
-
明确文件扩展名:在 ESM 中始终写完整扩展名(
.js/.mjs/.ts),这不仅是 Node.js 要求,也让代码意图更清晰。 -
使用 exports 字段定义公共 API:通过
exports字段精确控制包的公共接口,防止内部文件被外部直接引用,同时为不同环境提供最佳入口。 -
避免在 CJS 中混用 exports 和 module.exports:
exports是module.exports的引用,同时使用两者会导致module.exports覆盖exports添加的属性。 -
合理利用模块缓存:单例模式天然契合 CJS 模块缓存。如需热更新,清除缓存后重新 require;如需多次创建实例,导出工厂函数而非实例。
-
ESM 中使用 import.meta:替代 CJS 的
__dirname/__filename,使用import.meta.url、import.meta.dirname(Node 21.2+)等 API。
面试题
1. CJS 与 ESM 的核心区别是什么?
答:核心区别体现在五个方面:
| 区别点 | CJS | ESM |
|---|---|---|
| 加载时机 | 运行时加载(同步) | 编译时确定依赖(异步三阶段) |
| 导出语义 | 值拷贝 | Live Binding |
| 静态分析 | 不支持 | 支持(Tree Shaking 基础) |
| 语法 | require()/module.exports | import/export |
| this 顶层 | module.exports | undefined |
最本质的区别是静态 vs 动态:ESM 的 import/export 在解析时就确定了依赖关系和导出接口,这使得 Tree Shaking、类型推断、IDE 支持成为可能;CJS 的 require 是运行时调用,可以动态计算模块路径,但牺牲了静态分析能力。
2. 请描述 require 的完整加载过程。
答:require(id) 的加载流程如下:
- 解析路径:
Module._resolveFilename(id, parent)将模块 ID 转换为绝对路径。内置模块直接返回,相对路径基于当前文件解析,裸标识沿node_modules逐级查找。 - 检查缓存:查询
Module._cache[filename],如命中直接返回module.exports。 - 创建模块:
new Module(id, parent),初始化exports = {}、children = []等。 - 加入缓存:在执行代码前将模块加入
Module._cache(这是处理循环依赖的关键)。 - 编译执行:根据扩展名调用对应加载器:
.js:包装为函数(exports, require, module, __filename, __dirname) => { ... }并执行.json:JSON.parse后赋值给module.exports.node:process.dlopen加载 C++ 原生模块
- 返回导出:返回
module.exports对象。
3. 循环依赖在 CJS 和 ESM 中分别是如何处理的?
答:
CJS:在模块开始执行前就将其加入缓存(module.exports = {})。当发生循环依赖时,require 返回的是对方模块执行到 require 语句那一刻的 module.exports,可能是不完整的。例如 A require B,B require A 时,B 拿到的 A 的导出只有 A 在 require B 之前定义的属性。
ESM:通过 Live Binding 机制处理。ESM 的导出是绑定(binding)而非值的拷贝,当模块 A 的变量在模块 B 中被访问时,获取的是 A 中该变量的最终值(而非某个时刻的快照)。但如果在模块顶层代码执行前就访问循环依赖的绑定,会触发 TDZ(暂时性死区)错误,获取到 undefined。
最佳实践:无论哪种模块系统,循环依赖都应尽量避免。重构手段包括提取共享模块、使用依赖注入、延迟导入等。
4. module.exports 和 exports 有什么区别?
答:
在 Node.js 模块包装函数中,exports 是 module.exports 的引用:
// Node.js 内部等价于:
const module = { exports: {} };
const exports = module.exports; // exports 指向同一对象
exports.xxx = value:向module.exports对象添加属性,正确module.exports = value:替换整个导出对象,正确exports = value:只改变了exports变量的指向,module.exports仍为空对象{},导出失效
当需要导出单个类、函数或原始值时,必须使用 module.exports = value。一旦使用了 module.exports = value,之前通过 exports.xxx 添加的属性都会丢失,因为 exports 和 module.exports 不再指向同一对象。
5. ESM 的静态分析有什么优势?
答:
ESM 的 import/export 语句必须在模块顶层声明(不能放在 if/for 中),且字符串必须是字面量(不能是变量)。这种限制带来了显著的静态分析优势:
- Tree Shaking:打包工具可以精确分析哪些导出未被使用,将其从最终产物中移除,减小包体积
- 类型推断:TypeScript 和 IDE 可以在不执行代码的情况下推断导出的类型
- 依赖检测:在构建时就能发现缺失的依赖或拼写错误的导入路径
- 代码分割:静态的 import 结构让打包工具可以自动进行代码分割
- 安全审计:静态分析工具可以不执行代码就了解模块间的依赖关系
CJS 的 require(variable) 使得这些分析无法进行,因为只有运行时才知道加载了什么模块。
6. 什么是条件导出 (Conditional Exports)?如何使用?
答:
条件导出是 package.json 中 exports 字段的高级用法,允许根据导入者的环境(CJS/ESM、Node/Browser、Deno 等)提供不同的模块入口。
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"browser": "./dist/index.browser.mjs",
"default": "./dist/index.cjs"
}
}
}
关键要点:
exports字段一旦设置,只有其中声明的路径才能被外部导入,未列出的子路径会报ERR_PACKAGE_PATH_NOT_EXPORTED- 条件匹配顺序由调用者定义的条件集合决定,Node.js 默认条件集为
["node", "import"]或["node", "require"] - 建议始终包含
default条件作为兜底 "types"条件应放在最前面,确保 TypeScript 优先找到类型定义
7. CJS 与 ESM 互操作有哪些限制?
答:
| 方向 | 行为 | 限制 |
|---|---|---|
| ESM import CJS | module.exports 作为 default 导出 | CJS 命名导出需通过 default.xxx 或命名空间导入 |
| CJS require ESM | 不允许 | 必须使用 await import() 异步导入 |
| ESM import CJS 命名导出 | Node.js 支持,通过静态分析 | 仅限于 module.exports 的顶层属性,深层嵌套可能无法检测 |
| CJS 中使用 ESM 的顶层 await | 不适用 | CJS 不支持顶层 await |
| JSON 模块 | CJS: require('./data.json') | ESM: import data from './data.json' with { type: 'json' }(需 import assertion) |
.node 原生模块 | CJS 原生支持 | ESM 支持但语法不同 |
最大的限制是CJS 无法同步 require ESM 模块,这是因为 ESM 的加载是异步的,而 require 是同步 API。这是迁移到 ESM 的主要障碍之一。
8. 模块缓存与热更新的关系是什么?
答:
CJS 的模块缓存机制(Module._cache)确保同一模块只执行一次,后续 require 返回缓存的 module.exports。这在正常情况下是高效的行为,但在开发时需要热更新就会成为障碍。
热更新的实现方式:
// 清除单个模块缓存
function hotReload(modulePath) {
const resolved = require.resolve(modulePath);
// 递归清除子模块缓存
const mod = require.cache[resolved];
if (mod) {
mod.children.forEach((child) => {
delete require.cache[child.id];
});
delete require.cache[resolved];
}
return require(modulePath); // 重新加载
}
// 使用
const freshHandler = hotReload('./api-handler');
注意事项:
- 清除缓存后,之前通过 require 拿到的旧引用仍然指向旧的导出对象
- 如果模块有副作用(如注册全局中间件),热更新可能导致副作用重复执行
- ESM 没有暴露缓存 API,无法直接实现热更新;需依赖构建工具(Vite HMR 等)
- 生产环境不建议使用热更新,应通过进程管理(PM2 restart 等)实现
相关链接
- 事件循环 — 理解模块加载与事件循环的关系,ESM 异步加载如何融入事件循环
- npm与包管理 — package.json 配置、exports 字段、node_modules 解析规则
- TypeScript与Node — TypeScript 模块解析策略、esModuleInterop、declarationMap
外部链接:
- Node.js Modules 官方文档 — CJS 模块系统完整参考
- Node.js ESM 官方文档 — ESM 在 Node.js 中的实现细节
- Node.js Package Entry Points — 条件导出与 package.json 配置详解