模块系统

概述

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:定义模块对外暴露的接口
  • exportsmodule.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 的完整过程):

  1. 路径解析:调用 Module._resolveFilename(id, parent) 解析模块路径
    • 内置模块(如 fspath)直接返回
    • 相对/绝对路径直接解析
    • 裸模块标识符沿 node_modules 逐级查找
  2. 缓存检查:检查 Module._cache[filename] 是否已存在
  3. 创建模块对象new Module(id, parent),初始化 exports = {}
  4. 加入缓存:在执行模块代码之前就将模块加入缓存(防止循环依赖导致无限递归)
  5. 编译执行:调用 Module._extensions[extension]() 编译并执行模块代码
    • .js:调用 vm.compileFunction 在模块作用域中执行
    • .jsonJSON.parse 后赋值给 module.exports
    • .nodeprocess.dlopen 加载 C++ 插件
  6. 返回导出:返回 module.exports

ESM 加载流程:

  1. 构建(Construction):下载/解析模块文件,构建 Module Record
  2. 实例化(Instantiation):为所有导出绑定内存位置(live binding)
  3. 求值(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.jsonexports 字段允许为不同运行环境提供不同入口,这是现代 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"
    }
  }
}

条件导出的匹配顺序:

  1. import — ESM import 使用
  2. require — CJS require 使用
  3. default — 兜底方案
  4. node / browser / deno / worker 等条件

package.json 的 type 字段

type 字段决定 .js 文件被视为 CJS 还是 ESM:

type 值.js 文件解释为.cjs 文件解释为.mjs 文件解释为
未设置 / “commonjs”CJSCJSESM
”module”ESMCJSESM

这是一个项目级配置,影响所有 .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.jsonexports 字段管理包入口
  • 内部包使用 "type": "module",对外提供双格式(CJS + ESM)分发
  • 使用工具(tsup、unbuild、rollup)自动生成双格式产物

CJS vs ESM 四维对比表

维度CommonJSESM
语法require() / module.exportsimport / export / import()
加载方式同步、运行时求值异步三阶段(构建→实例化→求值)
静态分析不支持,无法 Tree Shaking支持,可 Tree Shaking
导出语义值拷贝(基本类型),引用拷贝(对象)Live Binding(实时绑定)
循环依赖返回部分导出(可能不完整)Live Binding 可获取最终值,但可能 TDZ
动态导入require(variable) 原生支持import() 返回 Promise
顶层 await不支持支持
浏览器兼容不兼容原生支持
文件扩展名.js / .cjs.mjs / .js(需 type:module)
this 顶层值module.exportsundefined

优缺点

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 ModuleESM 模块不支持同步 require 加载改用 await import() 动态导入
ESM 中未写文件扩展名ERR_MODULE_NOT_FOUNDNode.js ESM 要求 import 路径包含完整扩展名添加 .mjs/.js 扩展名,或配置 --experimental-specifier-resolution=node
__dirname 在 ESM 中不可用ReferenceError: __dirname is not definedESM 不注入 __dirname/__filename使用 import.meta.dirname(Node 21.2+)或 fileURLToPath(import.meta.url)
ESM 中 require CJS 命名导出只能通过 default 访问,命名导出需 default.xxxCJS 的 module.exports 整体作为 ESM 的 default使用 import { name } from './cjs-module.cjs'(Node.js 支持命名导出检测)
exports 字段限制文件访问ERR_PACKAGE_PATH_NOT_EXPORTEDexports 字段启用后,未声明的子路径被禁止访问在 exports 中添加对应的路径映射
顶层 await 阻塞后续 import后续模块等待 await 完成顶层 await 会阻塞依赖该模块的其他模块谨慎使用顶层 await,避免长时间阻塞
CJS 循环依赖导出不完整获取到 undefined 属性require 时模块尚未执行完毕重构代码消除循环,或将导出放在模块顶部
JSON 模块导入方式不同CJS: require('./data.json') 直接返回对象ESM 需要 assertionESM 使用 import data from './data.json' with { type: 'json' }

最佳实践

  1. 新项目优先使用 ESM:设置 "type": "module",享受静态分析、Tree Shaking、浏览器兼容等优势。这是 Node.js 社区的明确方向。

  2. 库开发提供双格式:通过 package.jsonexports 字段同时提供 CJS 和 ESM 入口,最大化兼容性。推荐使用 tsup、unbuild 等工具自动生成。

  3. 消除循环依赖:无论是 CJS 还是 ESM,循环依赖都是代码异味。重构方式包括:提取共享逻辑到第三个模块、使用依赖注入、延迟 require(放在函数内部而非模块顶层)。

  4. 明确文件扩展名:在 ESM 中始终写完整扩展名(.js/.mjs/.ts),这不仅是 Node.js 要求,也让代码意图更清晰。

  5. 使用 exports 字段定义公共 API:通过 exports 字段精确控制包的公共接口,防止内部文件被外部直接引用,同时为不同环境提供最佳入口。

  6. 避免在 CJS 中混用 exports 和 module.exportsexportsmodule.exports 的引用,同时使用两者会导致 module.exports 覆盖 exports 添加的属性。

  7. 合理利用模块缓存:单例模式天然契合 CJS 模块缓存。如需热更新,清除缓存后重新 require;如需多次创建实例,导出工厂函数而非实例。

  8. ESM 中使用 import.meta:替代 CJS 的 __dirname/__filename,使用 import.meta.urlimport.meta.dirname(Node 21.2+)等 API。


面试题

1. CJS 与 ESM 的核心区别是什么?

:核心区别体现在五个方面:

区别点CJSESM
加载时机运行时加载(同步)编译时确定依赖(异步三阶段)
导出语义值拷贝Live Binding
静态分析不支持支持(Tree Shaking 基础)
语法require()/module.exportsimport/export
this 顶层module.exportsundefined

最本质的区别是静态 vs 动态:ESM 的 import/export 在解析时就确定了依赖关系和导出接口,这使得 Tree Shaking、类型推断、IDE 支持成为可能;CJS 的 require 是运行时调用,可以动态计算模块路径,但牺牲了静态分析能力。

2. 请描述 require 的完整加载过程。

require(id) 的加载流程如下:

  1. 解析路径Module._resolveFilename(id, parent) 将模块 ID 转换为绝对路径。内置模块直接返回,相对路径基于当前文件解析,裸标识沿 node_modules 逐级查找。
  2. 检查缓存:查询 Module._cache[filename],如命中直接返回 module.exports
  3. 创建模块new Module(id, parent),初始化 exports = {}children = [] 等。
  4. 加入缓存:在执行代码前将模块加入 Module._cache(这是处理循环依赖的关键)。
  5. 编译执行:根据扩展名调用对应加载器:
    • .js:包装为函数 (exports, require, module, __filename, __dirname) => { ... } 并执行
    • .jsonJSON.parse 后赋值给 module.exports
    • .nodeprocess.dlopen 加载 C++ 原生模块
  6. 返回导出:返回 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 模块包装函数中,exportsmodule.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 添加的属性都会丢失,因为 exportsmodule.exports 不再指向同一对象。

5. ESM 的静态分析有什么优势?

ESM 的 import/export 语句必须在模块顶层声明(不能放在 if/for 中),且字符串必须是字面量(不能是变量)。这种限制带来了显著的静态分析优势:

  1. Tree Shaking:打包工具可以精确分析哪些导出未被使用,将其从最终产物中移除,减小包体积
  2. 类型推断:TypeScript 和 IDE 可以在不执行代码的情况下推断导出的类型
  3. 依赖检测:在构建时就能发现缺失的依赖或拼写错误的导入路径
  4. 代码分割:静态的 import 结构让打包工具可以自动进行代码分割
  5. 安全审计:静态分析工具可以不执行代码就了解模块间的依赖关系

CJS 的 require(variable) 使得这些分析无法进行,因为只有运行时才知道加载了什么模块。

6. 什么是条件导出 (Conditional Exports)?如何使用?

条件导出是 package.jsonexports 字段的高级用法,允许根据导入者的环境(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 CJSmodule.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

外部链接: