模块系统深入
一句话概述:CommonJS vs ES Modules、require 缓存机制、循环依赖处理、package.json exports——理解 Node.js 模块加载的完整链路
什么是模块系统?
定义:模块系统是 Node.js 组织和复用代码的机制,支持 CommonJS(CJS)和 ES Modules(ESM)两种标准。
涉及场景:
- 代码组织:拆分大型应用为多个模块
- 依赖管理:引入第三方库
- 命名空间隔离:避免全局变量污染
- 按需加载:动态导入模块
作用:
- 提高代码可维护性
- 支持代码复用
- 实现依赖注入
一、CommonJS vs ES Modules
核心差异
| 特性 | CommonJS | ES Modules |
|---|---|---|
| 语法 | require()/module.exports | import/export |
| 加载时机 | 运行时同步加载 | 编译时静态分析 |
| 值类型 | 值拷贝 | 活绑定(引用) |
| 文件扩展名 | .js(默认)、.cjs | .mjs、.js(需 "type": "module") |
| 动态导入 | 天然支持 | 需要 import() |
| Tree-shaking | 不支持 | 支持 |
| 顶层 await | 不支持 | 支持 |
| this 指向 | exports 对象 | undefined |
| 循环依赖 | 部分导出 | 更好的支持 |
值拷贝 vs 活绑定
javascript
// CommonJS - 值拷贝
// counter.js
let count = 0;
module.exports = {
count,
increment() { count++; }
};
// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0(未变化)
// ES Modules - 活绑定
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1(已变化)require() 实现原理
简化实现
javascript
function require(modulePath) {
// 1. 解析模块路径
const filename = Module._resolveFilename(modulePath);
// 2. 检查缓存
if (require.cache[filename]) {
return require.cache[filename].exports;
}
// 3. 创建模块对象
const module = {
id: filename,
exports: {},
loaded: false
};
// 4. 缓存模块
require.cache[filename] = module;
// 5. 加载并编译模块
Module._load(filename, module);
// 6. 返回 exports
return module.exports;
}
// 模块包装函数
Module.wrap = function(script) {
return `(function(exports, require, module, __filename, __dirname) {
${script}
});`;
};关键步骤
- 路径解析:查找模块文件
- 缓存检查:避免重复加载
- 模块包装:注入
require、module、exports等变量 - 执行代码:运行包装后的函数
- 返回导出:返回
module.exports
import/export 实现原理
静态分析阶段
javascript
// 编译时就能确定依赖关系
import { foo } from './module.js'; // ✅ 静态
import('./module.js'); // ✅ 动态导入
const modulePath = './module.js';
import { foo } from modulePath; // ❌ 错误:必须是字符串字面量模块记录(Module Record)
javascript
// ESM 加载过程
// 1. 构建阶段:解析模块,构建模块图
// 2. 实例化阶段:分配内存,建立导入导出绑定
// 3. 求值阶段:执行模块代码
// module.mjs
export let value = 1;
export function increment() { value++; }
// main.mjs
import { value, increment } from './module.mjs';
// value 是对 module.mjs 中 value 的活绑定Tree-shaking 支持
javascript
// utils.js
export function used() { return 'used'; }
export function unused() { return 'unused'; }
// main.js
import { used } from './utils.js';
// 打包时 unused 会被移除(Tree-shaking)二、模块缓存机制
require.cache 详解
缓存机制
javascript
// 首次加载
const mod1 = require('./module');
// 从缓存加载(同一个对象)
const mod2 = require('./module');
console.log(mod1 === mod2); // true
// 查看缓存
console.log(require.cache);
// {
// '/path/to/module.js': Module { ... },
// ...
// }清除缓存实现热重载
javascript
function hotReload(modulePath) {
// 解析完整路径
const fullPath = require.resolve(modulePath);
// 删除缓存
delete require.cache[fullPath];
// 重新加载
return require(modulePath);
}
// 使用
const config = hotReload('./config');缓存键规则
javascript
// 缓存键是绝对路径
require('./module'); // /path/to/module.js
require('./module.js'); // /path/to/module.js(同一个)
require('../module'); // /path/module.js(不同路径)ESM 模块缓存
模块映射(Module Map)
javascript
// ESM 使用 URL 作为缓存键
import mod1 from './module.mjs';
import mod2 from './module.mjs';
// mod1 和 mod2 是同一个模块实例
// 不同的 URL = 不同的模块
import a from './module.mjs';
import b from './module.mjs?v=1';
// a 和 b 是不同的模块实例单例模式
javascript
// singleton.mjs
class Singleton {
constructor() {
if (Singleton.instance) {
return Singleton.instance;
}
this.data = [];
Singleton.instance = this;
}
}
export default new Singleton();
// 所有导入都获得同一个实例
import singleton from './singleton.mjs';ESM 缓存无法清除
javascript
// ❌ ESM 没有类似 require.cache 的机制
// 无法在运行时清除模块缓存
// 这是设计决策,保证模块的不可变性三、循环依赖处理
CommonJS 循环依赖
问题示例
javascript
// a.js
console.log('a starting');
exports.done = false;
const b = require('./b');
console.log('in a, b.done =', b.done);
exports.done = true;
console.log('a done');
// b.js
console.log('b starting');
exports.done = false;
const a = require('./a'); // 循环依赖
console.log('in b, a.done =', a.done);
exports.done = true;
console.log('b done');
// main.js
const a = require('./a');
console.log('in main, a.done =', a.done);
// 输出:
// a starting
// b starting
// in b, a.done = false ← a 还未执行完
// b done
// in a, b.done = true
// a done
// in main, a.done = true解决方案
javascript
// 1. 延迟引用
// a.js
exports.getB = () => require('./b');
// 2. 重构代码,消除循环依赖
// 提取公共依赖到第三个模块ESM 循环依赖
活绑定的优势
javascript
// a.mjs
import { b } from './b.mjs';
export let a = 'a';
console.log('a:', a, 'b:', b);
// b.mjs
import { a } from './a.mjs';
export let b = 'b';
console.log('b:', b, 'a:', a);
// 输出:
// b: b a: undefined ← TDZ 问题
// a: a b: bTDZ(暂时性死区)问题
javascript
// counter.mjs
export let count = 0;
import { increment } from './utils.mjs';
export { increment };
// utils.mjs
import { count } from './counter.mjs';
export function increment() {
count++; // ❌ 错误:count 是 const,不能修改导入的绑定
}
// 正确做法:导出函数而不是值
// counter.mjs
let count = 0;
export function getCount() { return count; }
export function increment() { count++; }四、package.json 配置详解
main vs module vs exports
json
{
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}优先级:exports > module > main
type 字段
json
{
"type": "module" // 默认为 ESM
}"type": "module":.js文件按 ESM 处理"type": "commonjs":.js文件按 CJS 处理(默认)
exports 字段高级用法
json
{
"exports": {
".": "./index.js",
"./utils": "./src/utils.js",
"./package.json": "./package.json"
}
}五、模块解析算法
Node.js 模块查找顺序
- 核心模块(如
fs、http) - 文件模块(相对/绝对路径)
node_modules目录(逐级向上查找)
ESM 解析策略
- 必须写扩展名:
import './module.js' - 支持
package.json的exports字段
面试高频题
1. CommonJS 和 ES Modules 的本质区别?
加载时机:CJS 运行时加载,ESM 编译时静态分析 值类型:CJS 值拷贝,ESM 活绑定 Tree-shaking:ESM 支持,CJS 不支持
2. require() 的实现原理?
- 路径解析
- 缓存检查
- 模块包装(注入 require、module、exports)
- 执行代码
- 返回 module.exports
3. 如何处理循环依赖?
CJS:延迟引用或重构代码 ESM:利用活绑定,但注意 TDZ 问题
4. package.json 的 exports 字段有什么作用?
- 定义包的导出入口
- 支持条件导出(import/require)
- 封装内部模块
5. 为什么 ESM 导入时要写 .js 扩展名?
ESM 规范要求明确的文件路径,不支持自动补全扩展名,确保跨平台一致性。