Node系列 · Node基础:模块化(CommonJS)
CommonJS 不是 Node 发明的,但 Node 让它真正落地。理解"模块如何被找到"和"模块缓存了什么",就能解释循环引用为什么不死、为什么改了文件不生效、为什么
exports = ...总是丢赋值。
一、模块化的两个核心问题
任何一个模块系统都要回答两个问题:
- 怎么找——给定一个标识符(
require('./foo')、require('express')),对应的文件在哪 - 怎么隔离——每个模块要有自己的作用域,不能直接污染全局
CommonJS 的回答:
- 怎么找:
require解析算法(路径 / 内置 / node_modules / 后缀补全 / 文件名补全) - 怎么隔离:每个模块在执行前被包进一个函数,参数是
exports / require / module / __filename / __dirname,执行环境与外部隔离
INFO
本章只覆盖 CommonJS——Node 默认行为。Node 也支持 ECMAScript Module(ESM),两者在加载方式、关键字、tree-shaking 上有明显差异,文末有对比表。
二、require 解析算法
require(X) 时,X 会被依次按以下规则解析,直到命中为止:
2.1 优先级一览
2.2 规则详解
规则 1:核心模块(built-in)
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');require 直接返回内置模块实现,不走文件系统。即使你 npm install fs 也不会生效——核心模块永远优先。
规则 2:路径形式
require('./foo'); // 相对当前文件
require('../foo'); // 相对父目录
require('/abs/foo'); // 绝对路径(不推荐,跨平台会出问题)按以下顺序尝试:
- 精确匹配
X - 依次补全后缀:
X.js、X.json、X.node、X.mjs - 若 X 是目录:尝试
X/package.json的main字段;找不到则尝试X/index.js等
require('./foo'); // → 尝试 ./foo, ./foo.js, ./foo.json, ./foo.node, ./foo.mjs
require('./foo.json'); // → 直接命中
require('./bar'); // → ./bar 是目录?尝试 ./bar/package.json/main,否则 ./bar/index.jsTIP
后缀补全是 CommonJS 才有的便利。在 ESM 下 import './foo' 必须写完整路径,否则报错。这就是为什么早期 Node 项目里到处是 .js,ESM 项目里到处是显式后缀。
规则 3:模块名形式(require('express'))
按目录层级向上查找 node_modules:
/Users/you/project/src/routes/user.js
↑ 1. /Users/you/project/src/routes/node_modules
↑ 2. /Users/you/project/src/node_modules
↑ 3. /Users/you/project/node_modules
↑ 4. /Users/you/node_modules
↑ 5. /Users/node_modules
↑ 6. /node_modules命中后:
- 读包目录的
package.json - 取
main字段;未指定则默认index.js - 同样按后缀补全规则解析
main
{
"name": "express",
"main": "lib/express.js" // 入口
}2.3 一段代码演示整个解析过程
项目结构:
/app/
├── index.js
├── lib/util.js
└── node_modules/lodash/index.jsindex.js:
require('fs'); // → 核心模块
require('./lib/util'); // → /app/lib/util.js
require('lodash'); // → /app/node_modules/lodash/index.js
require('lodash/index'); // → 同上(不会重复加载,缓存命中)2.4 模块缓存
模块一旦加载,结果会缓存到 require.cache。同一个标识符第二次 require 直接返回缓存的 module.exports。项目结构:
demo/
├── a.js
└── b.jsa.js:
console.log('a.js 执行');
module.exports = { name: 'A' };b.js 两次 require:
const a1 = require('./a');
const a2 = require('./a');
console.log(a1 === a2); // true$ node b.js
a.js 执行
true实际意义:
- 模块代码只执行一次——副作用(如
console.log、全局状态修改)只发生一次 - 同一进程内多次
require拿到的是同一个对象引用 - 想重新加载?删
require.cache中对应键(很少见,但热加载工具会用到)
WARNING
缓存按绝对路径作 key。require('./foo') 和 require('/abs/path/foo') 解析到同一文件时,命中同一个缓存条目——不会出现"两份 foo 实例"。
三、require 函数
require 本质是一个函数,常见用法:
// 1. 加载模块
const express = require('express');
// 2. 加载并解构
const { join } = require('path');
// 3. 条件加载(避免没装某个包就崩)
let optional;
try {
optional = require('optional-dep');
} catch (e) {
optional = null;
}
// 4. resolve 只解析路径不执行
const configPath = require.resolve('./config.json');
console.log(configPath);require.resolve(X) 走和 require(X) 完全一样的解析算法,但只返回路径,不执行模块——也就是说它不会触发模块顶层 console.log、副作用和缓存写入。常用于不改代码确认模块路径,或与 require.cache 配合做热加载。
四、module 对象
每个模块内部都有一个 module 变量,它的常用属性:
| 属性 | 类型 | 含义 |
|---|---|---|
module.id | string | 模块标识,默认等于文件名 |
module.filename | string | 绝对路径 |
module.loaded | boolean | 是否已加载完成 |
module.exports | any | 模块对外的导出对象 |
module.children | array | 当前模块 require 进来的子模块列表 |
module.parent | object | 谁 require 了当前模块(已弃用) |
module.paths | array | Node 搜索 node_modules 的候选路径 |
console.log(module);输出(简化):
Module {
id: '.',
filename: '/app/index.js',
loaded: false,
children: [],
parent: null,
paths: [
'/app/node_modules',
'/node_modules'
]
}逐项解释:
id: '.'——入口模块(被node命令直接执行的那个)的 id 固定是.;被require引入的模块,id 是它的绝对路径parent: null——同样是入口模块的特征;非入口模块会指向"谁 require 了它"loaded: false——打印发生在模块执行中,所以是false;模块代码全部跑完后,Node 会把它置为truepaths——Node 向上查找node_modules的候选路径列表,从当前目录一直列到根/node_modules
五、module.exports / this / exports:三者到底是什么关系
XMind 里把它们并列写了,但它们的真实关系是:
// 每个模块在执行前会被包成这样的函数(伪代码)
(function (exports, require, module, __filename, __dirname) {
// 你的模块代码
});module是当前模块对象,由 Node 创建module.exports是模块导出的最终对象,默认是{}exports是module.exports的初始引用(exports === module.exports)
console.log(exports === module.exports); // true
exports.a = 1; // ✅ 挂属性到 module.exports
module.exports.b = 2; // ✅ 同上
// ❌ 重新赋值会让 exports 不再指向 module.exports
exports = { c: 3 };
console.log(exports === module.exports); // false
console.log(require('./exports-demo')); // { a: 1, b: 2 } ← c 丢了5.1 三种导出写法对比
| 写法 | 示例 | 适用场景 |
|---|---|---|
| 挂属性 | exports.foo = ... | 多工具函数聚合导出 |
| 整体替换 | module.exports = function () {} | 导出单个东西(类、函数、对象字面量) |
| 整体替换对象 | module.exports = { foo, bar } | 推荐写法,行为最可预测 |
WARNING
exports = ... 始终是错的。Node 官方文档明确写:"If you want to export a function, use module.exports, not exports"。代码里看到 exports = ...,结果一定是导出对象里没那个属性。
简而言之:
- 想要多个具名导出:
exports.foo = ...或module.exports = { foo } - 想要单个默认导出:
module.exports = something - 永远不要对
exports整体赋值
5.2 实际验证
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }
exports.add = add;
module.exports = { sub };const math = require('./math');
console.log(math); // { sub: [Function: sub] }
console.log(math.add); // undefined ← 第二次 module.exports 整体替换抹掉了前面挂的属性5.3 this 在模块里的指向
模块顶层 this 指向 module.exports:
console.log(this === module.exports); // true
console.log(this === exports); // true所以 this.foo = 1 等价于 exports.foo = 1。但箭头函数不行——箭头函数没有自己的 this,会沿用外层作用域的 this,此时 this 不再是 module.exports。
WARNING
严格模式下顶层 this 是 undefined,不是 module.exports。Node CJS 模块默认不是严格模式,所以上面 console.log(this === module.exports) 输出 true;但只要在文件顶部加 "use strict"(或 .mjs / ESM 模块),顶层 this 立刻变成 undefined。在严格模式下写 this.foo = 1 会直接抛 TypeError: Cannot set properties of undefined。不要在 CJS 与 ESM 混用的项目里靠 this 做模块导出。
六、循环引用:为什么不会死
A 引入 B,B 又引入 A——不会无限递归,因为模块缓存机制。
project/
├── a.js
└── b.jsconsole.log('a.js start');
exports.done = false;
const b = require('./b'); // 触发加载 b.js
console.log('a.js end, b.done =', b.done);
exports.done = true;console.log('b.js start');
exports.done = false;
const a = require('./a'); // 此时 a.js 还没执行完,缓存里是部分值
console.log('b.js end, a.done =', a.done); // false(a 还没执行到最后)
exports.done = true;$ cd project && node a.js
a.js start
b.js start
b.js end, a.done = false
a.js end, b.done = true执行轨迹:
关键观察:
- 循环引用时,先被加载的那个模块会拿到一个"半成品"导出对象
- 解决办法:把
require写在函数体内(懒加载),而不是模块顶层
// ✅ 安全的循环引用写法
function getB() {
return require('./b'); // 真正使用时才加载
}七、模块隔离与作用域
每个 JS 文件都可能在项目里"裸跑"——如果不做隔离,文件 A 里的 const user = ... 会和文件 B 里的同名 const user 互相覆盖;文件 A 顶部 var foo = 1 会泄漏成全局变量污染浏览器 window。CommonJS 的隔离手段是把每个模块包成一个函数再执行,函数有自己的作用域:
// 你的 a.js
const x = 1;Node 实际执行的是:
(function (exports, require, module, __filename, __dirname) {
const x = 1;
});包装函数的 5 个参数由 Node 注入:
| 参数 | 用途 |
|---|---|
exports | module.exports 的初始引用(见 §五) |
require | 当前模块的 require 函数 |
module | 当前模块对象(见 §四) |
__filename | 当前文件的绝对路径(仅 CJS) |
__dirname | 当前文件所在目录的绝对路径(仅 CJS) |
实际效果:
- 模块顶层
var/const/let不会污染全局对象 - 模块内显式写
global.foo = 1才会污染全局(不要这样做)
foo = 1; // ❌ 没有 var/let/const,隐式全局(严格模式下报错)
global.bar = 2; // ❌ 显式全局(也不推荐)
const baz = 3; // ✅ 模块局部八、常见错误与排查
| 报错 | 原因 | 解决 |
|---|---|---|
Cannot find module 'X' | 包没装 / 路径错 | npm ls X / require.resolve(X) |
X is not a function | 拿到的是 module.exports 对象但当成函数调用 | 用 X.default 或修导出方式 |
Cannot find module './foo',但文件存在 | 后缀名错 / 在 ESM 下不补全 | 写完整 require('./foo.cjs') 或改用 import |
| 改了文件不生效 | 缓存 | 重启进程;或 delete require.cache[require.resolve('./foo')] |
"改了文件不生效"的典型场景是开发期调试——你修改了某个模块文件,但 Node 不会重新读取。完整可复制的修复代码:
// 1. 解析模块路径(带后缀)
const path = require.resolve('./foo.js');
// 2. 从 require 缓存中删除该条目
delete require.cache[path];
// 3. 重新 require,这次会真正执行一次模块代码
const fresh = require('./foo.js');WARNING
热加载通常意味着代码组织有问题。生产环境不要这么用——模块被多个地方引用时,删缓存只会让"这次 require 拿到新值",之前已经持有旧引用的代码看不到变更,状态会分裂。开发期单文件调试可以临时用,但上线前务必移除。
九、CommonJS vs ESM:何时用哪个
| 维度 | CommonJS | ESM |
|---|---|---|
| 加载方式 | 同步、运行时 | 异步、静态分析 |
| 关键字 | require / module.exports | import / export |
| Tree-shaking | 困难(运行时才知道导出什么) | 天然支持 |
顶层 await | 不支持 | 支持(Node 14.8+) |
| 适用 | 老项目、Node CLI、配置文件 | 现代前端、库发布、tree-shaking 场景 |
Node 14+ 已经在原生 ESM 上做了大量优化,新项目默认用 ESM。但 CommonJS 仍是大量老库的发布格式,且 Node 自身内置模块全用 CJS。
十、小结
require解析优先级:核心模块 → 路径形式(带后缀/文件名补全)→ node_modules 向上查找- 模块执行结果缓存到
require.cache,同一进程只加载一次 module.exports是最终导出对象;exports是它的初始引用,不能整体赋值- 循环引用安全靠缓存;半成品模块是常见坑
- 模块代码被包在函数中执行,顶层作用域天然隔离
