跳到主要内容

Node.js 模块系统

问题

Node.js 支持哪些模块系统?CommonJS 和 ESM 有什么区别?require 的原理是什么?

答案

Node.js 支持两种模块系统:CommonJS(CJS)ES Modules(ESM)。CommonJS 是 Node.js 的原生模块系统,ESM 是 JavaScript 标准模块系统,Node.js 12+ 开始原生支持。


CommonJS (CJS)

基本语法

// math.js - 导出
const add = (a: number, b: number): number => a + b;
const subtract = (a: number, b: number): number => a - b;

module.exports = { add, subtract };
// 或
exports.add = add;
exports.subtract = subtract;

// main.js - 导入
const { add, subtract } = require('./math');
const math = require('./math');

console.log(add(1, 2)); // 3
console.log(math.subtract(5, 3)); // 2

exports vs module.exports

// exports 是 module.exports 的引用
console.log(exports === module.exports); // true

// ❌ 错误:直接赋值 exports 会断开引用
exports = { foo: 'bar' }; // 不会导出

// ✅ 正确:修改 module.exports
module.exports = { foo: 'bar' };

// ✅ 正确:给 exports 添加属性
exports.foo = 'bar';

// 最佳实践:统一使用 module.exports
module.exports = {
add,
subtract
};

require 原理

// require 的简化实现
function require(modulePath: string) {
// 1. 解析路径
const absolutePath = Module._resolveFilename(modulePath);

// 2. 检查缓存
if (Module._cache[absolutePath]) {
return Module._cache[absolutePath].exports;
}

// 3. 创建模块对象
const module = new Module(absolutePath);

// 4. 缓存模块(在执行前缓存,处理循环依赖)
Module._cache[absolutePath] = module;

// 5. 加载模块
module.load(absolutePath);

// 6. 返回 exports
return module.exports;
}

// 模块包装器
// Node.js 会将模块代码包装在一个函数中
(function(exports, require, module, __filename, __dirname) {
// 你的模块代码在这里
const add = (a, b) => a + b;
module.exports = { add };
});

模块解析顺序

require('module-name');

解析顺序:

  1. 核心模块fspathhttp 等内置模块
  2. 文件模块
    • 精确匹配:./module.js
    • 补全扩展名:./module./module.js./module.json./module.node
  3. 目录模块
    • ./dir/package.jsonmain 字段
    • ./dir/index.js
  4. node_modules
    • 当前目录的 node_modules
    • 父目录的 node_modules
    • 一直向上查找到根目录
// 模块扩展名解析顺序
require('./file');
// 1. ./file.js
// 2. ./file.json
// 3. ./file.node

// 目录模块
require('./lib');
// 1. ./lib/package.json → main 字段
// 2. ./lib/index.js
// 3. ./lib/index.json
// 4. ./lib/index.node

ES Modules (ESM)

启用方式

// package.json - 方式一
{
"type": "module"
}

// 方式二:使用 .mjs 扩展名
// file.mjs

基本语法

// math.mjs - 命名导出
export const add = (a: number, b: number): number => a + b;
export const subtract = (a: number, b: number): number => a - b;

// math.mjs - 默认导出
export default function multiply(a: number, b: number): number {
return a * b;
}

// main.mjs - 导入
import { add, subtract } from './math.mjs';
import multiply from './math.mjs';
import * as math from './math.mjs';

console.log(add(1, 2)); // 3
console.log(multiply(2, 3)); // 6
console.log(math.subtract(5, 3)); // 2

动态导入

// 动态导入返回 Promise
async function loadModule() {
const { add } = await import('./math.mjs');
console.log(add(1, 2));
}

// 条件导入
async function loadConfig() {
const env = process.env.NODE_ENV;
const config = await import(`./config.${env}.mjs`);
return config;
}

ESM 特殊变量

// ESM 中没有 __dirname 和 __filename
// 需要这样获取:
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// import.meta 包含模块元信息
console.log(import.meta.url); // file:///path/to/module.mjs
console.log(import.meta.resolve); // 解析模块路径

CommonJS vs ESM

特性CommonJSESM
加载方式同步(运行时)异步(编译时)
导入require()import
导出module.exportsexport
thisexports 对象undefined
顶层 await❌ 不支持✅ 支持
动态导入✅ 支持import()
循环依赖返回部分导出引用(可能未初始化)
Tree Shaking❌ 困难✅ 支持
文件扩展名.js.cjs.mjs.js(type: module)
// CommonJS - 同步加载
const fs = require('fs');
// 代码继续执行

// ESM - 静态分析
import fs from 'fs';
// 在模块求值前就已经加载

// ESM - 顶层 await
const data = await fetch('https://api.example.com/data');
// CommonJS 中不支持顶层 await

循环依赖

CommonJS 处理方式

// a.js
console.log('a.js 开始');
exports.done = false;
const b = require('./b.js');
console.log('在 a.js 中,b.done =', b.done);
exports.done = true;
console.log('a.js 结束');

// b.js
console.log('b.js 开始');
exports.done = false;
const a = require('./a.js');
console.log('在 b.js 中,a.done =', a.done);
exports.done = true;
console.log('b.js 结束');

// main.js
const a = require('./a.js');
const b = require('./b.js');

// 输出:
// a.js 开始
// b.js 开始
// 在 b.js 中,a.done = false ← 获取到部分导出
// b.js 结束
// 在 a.js 中,b.done = true
// a.js 结束

ESM 处理方式

// a.mjs
console.log('a.mjs 开始');
import { b } from './b.mjs';
console.log('a.mjs 中 b =', b);
export const a = 'a';
console.log('a.mjs 结束');

// b.mjs
console.log('b.mjs 开始');
import { a } from './a.mjs';
console.log('b.mjs 中 a =', a);
export const b = 'b';
console.log('b.mjs 结束');

// main.mjs
import { a } from './a.mjs';

// 输出:
// b.mjs 开始
// b.mjs 中 a = undefined ← 引用存在但未初始化
// b.mjs 结束
// a.mjs 开始
// a.mjs 中 b = b
// a.mjs 结束
避免循环依赖

循环依赖会导致难以预测的行为,应该:

  • 重新设计模块结构
  • 提取共享代码到独立模块
  • 延迟导入(动态 import)

互操作性

ESM 导入 CommonJS

// commonjs-module.cjs
module.exports = {
foo: 'bar',
baz: 42
};

// esm-module.mjs
import cjsModule from './commonjs-module.cjs';
console.log(cjsModule.foo); // 'bar'

// 注意:只能默认导入,不能命名导入
// ❌ import { foo } from './commonjs-module.cjs';

CommonJS 导入 ESM

// esm-module.mjs
export const foo = 'bar';
export default { baz: 42 };

// commonjs-module.cjs
// ❌ 不能使用 require
// const esm = require('./esm-module.mjs');

// ✅ 使用动态 import
async function main() {
const { foo, default: defaultExport } = await import('./esm-module.mjs');
console.log(foo); // 'bar'
console.log(defaultExport); // { baz: 42 }
}

main();

条件导出

// package.json
{
"name": "my-package",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
},
"./utils": {
"import": "./dist/esm/utils.mjs",
"require": "./dist/cjs/utils.cjs"
}
}
}

常见面试问题

Q1: require 的加载过程是什么?

答案

  1. 路径解析:将模块标识符解析为绝对路径
  2. 缓存检查:检查 require.cache,有则直接返回
  3. 模块创建:创建新的 Module 对象
  4. 缓存模块:将模块加入缓存(处理循环依赖)
  5. 加载执行:读取文件内容,包装后执行
  6. 返回导出:返回 module.exports
// 查看缓存
console.log(require.cache);

// 清除缓存
delete require.cache[require.resolve('./module')];

// 重新加载
const fresh = require('./module');

Q2: CommonJS 和 ESM 的加载时机有什么不同?

答案

CommonJS:运行时加载

  • require() 是一个函数调用
  • 可以在任何位置调用
  • 返回的是 module.exports 对象的拷贝

ESM:编译时加载

  • import 是静态声明
  • 必须在模块顶层
  • 返回的是绑定(引用)
// CommonJS - 运行时,可以动态
if (condition) {
const module = require('./a');
}

// ESM - 编译时,必须静态
import { a } from './a'; // 必须在顶层

// ESM 动态导入
if (condition) {
const module = await import('./a');
}

Q3: 什么是模块缓存?如何清除?

答案

Node.js 会缓存已加载的模块,避免重复加载。

// 模块只会执行一次
// first.js
console.log('加载模块');
module.exports = { count: 0 };

// main.js
const a = require('./first'); // 输出:加载模块
const b = require('./first'); // 无输出,使用缓存

a.count++;
console.log(b.count); // 1,a 和 b 是同一个对象

// 清除缓存
delete require.cache[require.resolve('./first')];
const c = require('./first'); // 输出:加载模块

Q4: ESM 中如何获取 __dirname 和 __filename?

答案

import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

// 获取当前文件路径
const __filename = fileURLToPath(import.meta.url);

// 获取当前目录路径
const __dirname = dirname(__filename);

// 使用
const configPath = join(__dirname, 'config.json');

Q5: 如何写一个同时支持 CJS 和 ESM 的包?

答案

// package.json
{
"name": "my-dual-package",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.mjs",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
}
},
"files": ["dist"]
}
// src/index.ts - 源代码
export function greet(name: string): string {
return `Hello, ${name}!`;
}

// 构建脚本生成两种格式
// dist/cjs/index.cjs - CommonJS
// dist/esm/index.mjs - ESM
// tsconfig.cjs.json
{
"compilerOptions": {
"module": "CommonJS",
"outDir": "./dist/cjs"
}
}

// tsconfig.esm.json
{
"compilerOptions": {
"module": "ESNext",
"outDir": "./dist/esm"
}
}

相关链接