Node.js: 模块系统

最后更新:2026-08-26

Charlie 的项目从 3 个文件膨胀到了 30 个文件。所有函数、配置、工具类堆在一个巨大的 app.js 里,改一个函数要在 2000 行代码里翻找半小时。他决定把代码拆成独立模块,却发现 requireimport 长得不一样,module.exportsexports 总是搞混,循环依赖还让程序输出了一堆 undefined。这节课,我们陪 Charlie 一起搞懂 Node.js 的模块系统,让代码从一团乱麻变成井然有序的积木。

你将学到:


1. CommonJS 模块

(1) module.exports 导出

Node.js 默认采用 CommonJS 模块规范。每个文件就是一个模块,通过 module.exports 导出值,其他文件用 require() 加载。

JAVASCRIPT
// 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 值。

JAVASCRIPT
// app.js
const math = require('./math');

console.log(math.add(10, 3));      // 13
console.log(math.subtract(10, 3)); // 7

(3) exports 快捷方式

exportsmodule.exports 的引用,适合逐个添加属性。

JAVASCRIPT
// logger.js
exports.info = function (msg) {
  console.log(`[INFO] ${msg}`);
};

exports.error = function (msg) {
  console.log(`[ERROR] ${msg}`);
};

▶ 示例:导出单个函数 vs 导出对象

JAVASCRIPT
// greet.js — 导出单个函数
module.exports = function (name) {
  return `Hello, ${name}!`;
};

// config.js — 导出对象
module.exports = {
  port: 3000,
  host: 'localhost',
  debug: true,
};
▶ 试一试
JAVASCRIPT
// 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.exportsexports 只能用来追加属性。



2. ES Modules

(1) 基本语法

ES Modules(ESM)是 JavaScript 官方模块标准,使用 exportimport 语法。

JAVASCRIPT
// utils.mjs
export function square(n) {
  return n * n;
}

export const VERSION = '2.0.0';

export default function greet(name) {
  return `Hello, ${name}!`;
}
JAVASCRIPT
// 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 输入
JSON
// package.json
{
  "type": "module"
}

▶ 示例:(3) 命名导出与默认导出

JAVASCRIPT
// 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}`;
  }
}
▶ 试一试

▶ 示例:统一导出与重导出

JAVASCRIPT
// 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 绑定

JAVASCRIPT
// counter.cjs — CommonJS
let count = 0;
function increment() {
  count++;
}
module.exports = { count, increment };
▶ 试一试
JAVASCRIPT
// counter.mjs — ESM
export let count = 0;
export function increment() {
  count++;
}
JAVASCRIPT
// CJS: count 是拷贝,不会变
const c = require('./counter.cjs');
c.increment();
console.log(c.count); // 0(仍是初始值)
JAVASCRIPT
// ESM: count 是绑定,实时更新
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1(已更新)

▶ 示例:在 ESM 中导入 CJS 模块

JAVASCRIPT
// legacy.cjs
module.exports = { legacyMethod() { return 'old school'; } };
▶ 试一试
JAVASCRIPT
// 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 按以下顺序查找:

100%
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/utilsnode_modules/@org/utils

▶ 示例:查看模块解析路径

JAVASCRIPT
// show-paths.js
console.log(module.paths);
▶ 试一试
TEXT 📖 仅展示
[
  '/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 同一模块直接返回缓存,不会重新执行。

JAVASCRIPT
// counter.js
console.log('counter.js 被执行了!');
let count = 0;
module.exports = {
  increment() { return ++count; },
  getCount() { return count; },
};
JAVASCRIPT
// 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 对象中,键为模块的绝对路径。

JAVASCRIPT
// 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

▶ 示例:清除缓存实现热重载

JAVASCRIPT
// 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) 内置模块无需安装

JAVASCRIPT
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

JAVASCRIPT
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 的处理方式

JAVASCRIPT
// a.js
exports.loaded = false;
const b = require('./b');
exports.loaded = true;
console.log('a.js - b.loaded =', b.loaded);
▶ 试一试
JAVASCRIPT
// b.js
exports.loaded = false;
const a = require('./a');  // 拿到 a 的未完成 exports { loaded: false }
exports.loaded = true;
console.log('b.js - a.loaded =', a.loaded);
BASH
node a.js
TEXT 📖 仅展示
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 解决循环依赖

JAVASCRIPT
// user.js
exports.getName = function () {
  return 'Charlie';
};

exports.getProfile = function () {
  const format = require('./format'); // 延迟到调用时才 require
  return format.upper(exports.getName());
};
▶ 试一试
JAVASCRIPT
// format.js
exports.upper = function (str) {
  return str.toUpperCase();
};

exports.getUserDisplay = function () {
  const user = require('./user'); // 延迟 require
  return `User: ${user.getName()}`;
};

延迟 require 使模块在首次调用方法时才加载依赖,此时两个模块都已初始化完毕,避免了拿到未完成的 exports。



8. 综合示例:模块化项目

下面创建一个包含工具模块、日志模块和主入口的模块化项目:

JAVASCRIPT
// 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 };
JAVASCRIPT
// 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 };
JAVASCRIPT
// 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');
}
BASH
node app.js
TEXT 📖 仅展示
[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

❓ 常见问题

Q CommonJS 和 ESM 能混用吗?
A 有限制。在 ESM 中可以 import CJS 模块(module.exports 作为默认导出),但在 CJS 中不能用 require 加载 ESM 模块,需用动态 import()。建议项目统一一种模块规范。
Q require 是同步还是异步?
A 同步。require 会阻塞代码执行直到模块加载完毕。这也是为什么 Node.js 建议 require 放在文件顶部,且不要在运行时热路径中频繁 require 新模块。
Q module.exports 和 exports 有什么区别?
A exports 是 module.exports 的快捷引用。用 exports.xxx = ... 可以添加属性,但 exports = xxx 会断开引用,导致导出失败。需要导出单个函数或全新对象时,必须用 module.exports = xxx。
Q 如何查看模块的缓存?
A 通过 require.cache 对象查看,键是模块的绝对路径,值是模块对象。删除某个键(delete require.cache[key])后再次 require 会重新执行该模块。
Q 什么是循环依赖?Node.js 怎么处理?
A 循环依赖是两个或多个模块互相 require。Node.js 不会陷入死循环,而是返回循环点处尚未执行完毕的 exports(可能是不完整对象),这可能导致 undefined 属性。解决方法包括提取公共模块、延迟 require、事件解耦等。
Q 为什么 ESM 中 import 必须写在顶层?
A ESM 是静态分析的,编译阶段就确定依赖关系,这有利于 Tree Shaking 和优化。动态加载场景可以使用 import() 函数。
Q require 加载 JSON 文件时会发生什么?
A Node.js 会读取 JSON 文件并自动用 JSON.parse() 解析,返回解析后的 JavaScript 对象。常用于配置文件加载。

📖 小节


📝 作业

  1. 创建 calculator.js 模块,导出 addsubtractmultiplydivide 四个函数,在 main.js 中 require 并使用
  2. 将上题改为 ESM 版本:使用 export 语法和 .mjs 后缀,通过 import 加载
  3. 编写代码验证 require.cache 的存在:require 一个模块后,打印 require.cache 中该模块的信息
  4. 故意制造一个循环依赖(a.js require b.js,b.js require a.js),观察输出结果,然后用延迟 require 的方式修复
  5. 使用 pathos 模块,打印当前操作系统平台、CPU 核心数和当前文件所在目录的绝对路径
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏