Node.js: 模块系统
最后更新:2026-08-26
Charlie 的项目从 3 个文件膨胀到了 30 个文件。所有函数、配置、工具类堆在一个巨大的 app.js 里,改一个函数要在 2000 行代码里翻找半小时。他决定把代码拆成独立模块,却发现 require 和 import 长得不一样,module.exports 和 exports 总是搞混,循环依赖还让程序输出了一堆 undefined。这节课,我们陪 Charlie 一起搞懂 Node.js 的模块系统,让代码从一团乱麻变成井然有序的积木。
你将学到:
- 使用 CommonJS 的
require/module.exports/exports组织代码 - 使用 ES Modules 的
import/export及"type":"module"配置 - 理解
require的模块查找机制(内置 → node_modules → 路径) - 理解模块缓存机制及
require.cache的作用 - 识别循环依赖问题并掌握 Node.js 的处理方式
1. CommonJS 模块
(1) module.exports 导出
Node.js 默认采用 CommonJS 模块规范。每个文件就是一个模块,通过 module.exports 导出值,其他文件用 require() 加载。
// math.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
module.exports = { add, subtract };
(2) require 加载模块
require() 接受模块标识符,返回该模块的 module.exports 值。
// app.js
const math = require('./math');
console.log(math.add(10, 3)); // 13
console.log(math.subtract(10, 3)); // 7
(3) exports 快捷方式
exports 是 module.exports 的引用,适合逐个添加属性。
// logger.js
exports.info = function (msg) {
console.log(`[INFO] ${msg}`);
};
exports.error = function (msg) {
console.log(`[ERROR] ${msg}`);
};
▶ 示例:导出单个函数 vs 导出对象
// greet.js — 导出单个函数
module.exports = function (name) {
return `Hello, ${name}!`;
};
// config.js — 导出对象
module.exports = {
port: 3000,
host: 'localhost',
debug: true,
};
// app.js
const greet = require('./greet');
const config = require('./config');
console.log(greet('Charlie')); // Hello, Charlie!
console.log(`Server: ${config.host}:${config.port}`); // Server: localhost:3000
(4) module.exports vs exports 区别
| 特性 | module.exports | exports |
|---|---|---|
| 本质 | 模块真正的导出对象 | module.exports 的引用 |
| 赋值导出 | ✅ module.exports = fn |
❌ exports = fn 断开引用 |
| 逐个添加 | ✅ module.exports.foo = fn |
✅ exports.foo = fn |
| 导出单个值 | ✅ 推荐 | ❌ 无法使用 |
| 安全性 | 始终有效 | 重新赋值后失效 |
核心原则:如果需要导出单个函数、类或全新对象,必须用
module.exports;exports只能用来追加属性。
2. ES Modules
(1) 基本语法
ES Modules(ESM)是 JavaScript 官方模块标准,使用 export 和 import 语法。
// utils.mjs
export function square(n) {
return n * n;
}
export const VERSION = '2.0.0';
export default function greet(name) {
return `Hello, ${name}!`;
}
// app.mjs
import greet, { square, VERSION } from './utils.mjs';
console.log(greet('Charlie')); // Hello, Charlie!
console.log(square(5)); // 25
console.log(VERSION); // 2.0.0
(2) 启用 ESM 的三种方式
| 方式 | 说明 |
|---|---|
文件后缀 .mjs |
Node.js 自动按 ESM 处理 |
package.json 中 "type": "module" |
项目内 .js 文件默认为 ESM |
--input-type=module |
命令行参数,用于 stdin 输入 |
// package.json
{
"type": "module"
}
▶ 示例:(3) 命名导出与默认导出
// shapes.mjs
export const PI = 3.14159;
export function circleArea(radius) {
return PI * radius * radius;
}
export default class Shape {
constructor(name) {
this.name = name;
}
describe() {
return `This is a ${this.name}`;
}
}
▶ 示例:统一导出与重导出
// api.mjs — 统一导出
export { addUser, removeUser } from './users.mjs';
export { logError } from './logger.mjs';
// 也可以重命名
export { add as addUser } from './math.mjs';
3. CommonJS vs ESM 对比
(1) 核心差异
| 维度 | CommonJS | ES Modules |
|---|---|---|
| 语法 | require() / module.exports |
import / export |
| 加载方式 | 同步,运行时加载 | 异步,编译时静态分析 |
| 值的类型 | 值的拷贝(原始类型) | 值的绑定(实时引用) |
| this 顶层 | module.exports |
undefined |
| 循环依赖 | 返回未完成的 exports | 引用绑定,但可能 TDZ |
| 使用场景 | Node.js 项目(默认) | 新项目、浏览器共享代码 |
| 文件后缀 | .js / .cjs |
.mjs / .js(type:module) |
▶ 示例:(2) 值的拷贝 vs 绑定
// counter.cjs — CommonJS
let count = 0;
function increment() {
count++;
}
module.exports = { count, increment };
// counter.mjs — ESM
export let count = 0;
export function increment() {
count++;
}
// CJS: count 是拷贝,不会变
const c = require('./counter.cjs');
c.increment();
console.log(c.count); // 0(仍是初始值)
// ESM: count 是绑定,实时更新
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1(已更新)
▶ 示例:在 ESM 中导入 CJS 模块
// legacy.cjs
module.exports = { legacyMethod() { return 'old school'; } };
// app.mjs
import cjs from './legacy.cjs';
console.log(cjs.legacyMethod()); // old school
在 ESM 中
import一个 CJS 模块时,module.exports的值会作为默认导出。
4. require 模块查找机制
(1) 查找流程
当你写 require('express') 时,Node.js 按以下顺序查找:
flowchart TD
A["require('express')"] --> B{是否内置模块?}
B -- 是 --> C[返回内置模块]
B -- 否 --> D{路径以 ./ 或 / 开头?}
D -- 是 --> E[按路径查找文件]
E --> E1[尝试 .js / .json / .node]
E1 --> E2[尝试 index.js]
D -- 否 --> F[查找 node_modules]
F --> F1[当前目录/node_modules/express]
F1 --> F2[父目录/node_modules/express]
F2 --> F3[逐级向上直到根目录]
F3 --> F4{找到?}
F4 -- 否 --> G[抛出 MODULE_NOT_FOUND]
F4 -- 是 --> H[加载并缓存模块]
E2 --> H
C --> H
(2) 路径解析规则
| require 参数 | 解析方式 | 示例 |
|---|---|---|
./math |
相对当前文件的路径 | ./math → /project/src/math.js |
../utils |
相对父目录 | ../utils → /project/utils.js |
/abs/path |
绝对路径 | /lib/helper.js |
express |
内置模块 → node_modules | 逐级查找 |
@scope/pkg |
作用域包 | @org/utils → node_modules/@org/utils |
▶ 示例:查看模块解析路径
// show-paths.js
console.log(module.paths);
[
'/project/src/node_modules',
'/project/node_modules',
'/node_modules',
'C:\\Users\\Charlie\\.node_modules',
'C:\\Users\\Charlie\\.node_libraries',
'C:\\Program Files\\nodejs\\lib\\node'
]
5. 模块缓存机制
(1) 缓存原理
require 第一次加载模块时会执行模块代码并缓存结果。后续 require 同一模块直接返回缓存,不会重新执行。
// counter.js
console.log('counter.js 被执行了!');
let count = 0;
module.exports = {
increment() { return ++count; },
getCount() { return count; },
};
// app.js
const c1 = require('./counter'); // counter.js 被执行了!
const c2 = require('./counter'); // (无输出,使用缓存)
console.log(c1 === c2); // true
console.log(c1.increment()); // 1
console.log(c2.getCount()); // 1(共享状态)
(2) require.cache
所有已加载模块缓存在 require.cache 对象中,键为模块的绝对路径。
// inspect-cache.js
const path = require('path');
const math = require('./math');
const cacheKey = path.resolve(__dirname, 'math.js');
console.log(require.cache[cacheKey] !== undefined); // true
console.log(require.cache[cacheKey].exports === math); // true
▶ 示例:清除缓存实现热重载
// hot-reload.js
function loadConfig() {
const path = require('path');
const cacheKey = path.resolve(__dirname, 'config.js');
delete require.cache[cacheKey];
return require('./config');
}
const cfg1 = loadConfig();
// ... config.js 被修改 ...
const cfg2 = loadConfig(); // 重新执行,加载最新内容
删除
require.cache中的条目后再次require,Node.js 会重新执行该模块。这在开发环境的热重载场景中很有用,但生产环境慎用。
6. 内置模块概览
(1) 常用内置模块速查
Node.js 自带大量内置模块,无需安装即可使用。
| 模块 | 用途 | 常用方法/属性 |
|---|---|---|
fs |
文件系统操作 | readFile, writeFile, readdir, stat |
path |
路径处理 | join, resolve, parse, extname, basename |
http |
HTTP 服务器/客户端 | createServer, get, request |
https |
HTTPS 服务器/客户端 | createServer, get, request |
url |
URL 解析与构建 | URL, fileURLToPath, pathToFileURL |
os |
操作系统信息 | cpus, freemem, hostname, platform |
events |
事件发射器 | EventEmitter, on, emit, off |
stream |
流式数据处理 | Readable, Writable, Transform, pipe |
crypto |
加密与哈希 | createHash, createHmac, randomBytes |
util |
实用工具 | promisify, callbackify, format, inspect |
child_process |
子进程管理 | exec, spawn, fork |
buffer |
二进制数据处理 | Buffer.alloc, Buffer.from, concat |
▶ 示例:(2) 内置模块无需安装
const fs = require('fs');
const path = require('path');
const os = require('os');
console.log(os.platform()); // win32 / darwin / linux
console.log(path.join('/project', 'src', 'app.js')); // /project/src/app.js
▶ 示例:快速使用 path 和 os
const path = require('path');
const os = require('os');
const filePath = '/project/src/utils/helper.js';
console.log(path.extname(filePath)); // .js
console.log(path.dirname(filePath)); // /project/src/utils
console.log(path.basename(filePath)); // helper.js
console.log(`CPU cores: ${os.cpus().length}`);
console.log(`Free memory: ${(os.freemem() / 1024 / 1024).toFixed(0)} MB`);
7. 循环依赖
(1) 什么是循环依赖
模块 A require 模块 B,模块 B 又 require 模块 A,形成循环引用。Node.js 不会死循环,而是返回当前已执行部分的 exports。
▶ 示例:(2) Node.js 的处理方式
// a.js
exports.loaded = false;
const b = require('./b');
exports.loaded = true;
console.log('a.js - b.loaded =', b.loaded);
// b.js
exports.loaded = false;
const a = require('./a'); // 拿到 a 的未完成 exports { loaded: false }
exports.loaded = true;
console.log('b.js - a.loaded =', a.loaded);
node a.js
b.js - a.loaded = false
a.js - b.loaded = true
当 b.js 执行 require('./a') 时,a.js 尚未执行完毕,Node.js 返回此时 a.js 已赋值的部分({ loaded: false })。
(3) 避免循环依赖的策略
| 策略 | 说明 |
|---|---|
| 提取共享逻辑 | 将公共部分抽到第三个模块 |
| 延迟 require | 把 require 移到函数内部,调用时才加载 |
| 事件解耦 | 用 EventEmitter 替代直接调用 |
| 依赖注入 | 通过参数传入依赖,而非硬编码 require |
▶ 示例:延迟 require 解决循环依赖
// user.js
exports.getName = function () {
return 'Charlie';
};
exports.getProfile = function () {
const format = require('./format'); // 延迟到调用时才 require
return format.upper(exports.getName());
};
// format.js
exports.upper = function (str) {
return str.toUpperCase();
};
exports.getUserDisplay = function () {
const user = require('./user'); // 延迟 require
return `User: ${user.getName()}`;
};
延迟 require 使模块在首次调用方法时才加载依赖,此时两个模块都已初始化完毕,避免了拿到未完成的 exports。
8. 综合示例:模块化项目
下面创建一个包含工具模块、日志模块和主入口的模块化项目:
// math.js — 工具模块
const PI = 3.14159;
function circleArea(radius) {
return PI * radius * radius;
}
function rectangleArea(width, height) {
return width * height;
}
function round(value, decimals = 2) {
const factor = Math.pow(10, decimals);
return Math.round(value * factor) / factor;
}
module.exports = { circleArea, rectangleArea, round };
// logger.js — 日志模块
const LEVELS = { INFO: 'INFO', WARN: 'WARN', ERROR: 'ERROR' };
function formatMessage(level, msg) {
const timestamp = new Date().toISOString();
return `[${timestamp}] [${level}] ${msg}`;
}
function info(msg) {
console.log(formatMessage(LEVELS.INFO, msg));
}
function warn(msg) {
console.warn(formatMessage(LEVELS.WARN, msg));
}
function error(msg) {
console.error(formatMessage(LEVELS.ERROR, msg));
}
module.exports = { info, warn, error, LEVELS };
// app.js — 主入口
const { circleArea, rectangleArea, round } = require('./math');
const { info, error } = require('./logger');
const radius = 5;
const area = round(circleArea(radius));
info(`Circle area (r=${radius}): ${area}`);
const roomArea = rectangleArea(4.5, 6.2);
info(`Room area: ${round(roomArea)} sqm`);
if (radius < 0) {
error('Radius cannot be negative');
} else {
info('Calculation complete');
}
node app.js
[2026-07-03T10:30:00.000Z] [INFO] Circle area (r=5): 78.54
[2026-07-03T10:30:00.001Z] [INFO] Room area: 27.9 sqm
[2026-07-03T10:30:00.001Z] [INFO] Calculation complete
❓ 常见问题
📖 小节
- CommonJS 是 Node.js 默认模块规范,用
require加载、module.exports导出 - ES Modules 是 JS 官方标准,用
import/export,通过.mjs后缀或"type":"module"启用 require查找顺序:内置模块 → 相对/绝对路径 → node_modules 逐级向上- 模块首次加载后缓存于
require.cache,再次 require 直接返回缓存 exports是module.exports的引用,重新赋值会断开引用- 循环依赖时 Node.js 返回未完成的 exports,可通过延迟 require 等策略规避
- 内置模块(fs/path/http/os 等)无需安装,直接 require 即可使用
📝 作业
- 创建
calculator.js模块,导出add、subtract、multiply、divide四个函数,在main.js中 require 并使用 - 将上题改为 ESM 版本:使用
export语法和.mjs后缀,通过import加载 - 编写代码验证
require.cache的存在:require 一个模块后,打印require.cache中该模块的信息 - 故意制造一个循环依赖(a.js require b.js,b.js require a.js),观察输出结果,然后用延迟 require 的方式修复
- 使用
path和os模块,打印当前操作系统平台、CPU 核心数和当前文件所在目录的绝对路径