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');
解析顺序:
- 核心模块:
fs、path、http等内置模块 - 文件模块:
- 精确匹配:
./module.js - 补全扩展名:
./module→./module.js→./module.json→./module.node
- 精确匹配:
- 目录模块:
./dir/package.json的main字段./dir/index.js
- 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
| 特性 | CommonJS | ESM |
|---|---|---|
| 加载方式 | 同步(运行时) | 异步(编译时) |
| 导入 | require() | import |
| 导出 | module.exports | export |
| this | exports 对象 | 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 的加载过程是什么?
答案:
- 路径解析:将模块标识符解析为绝对路径
- 缓存检查:检查
require.cache,有则直接返回 - 模块创建:创建新的 Module 对象
- 缓存模块:将模块加入缓存(处理循环依赖)
- 加载执行:读取文件内容,包装后执行
- 返回导出:返回
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"
}
}