1.2 CommonJS模块系统:require的加载与缓存


1.2 CommonJS 模块系统:require 的加载与缓存

本节摘要:CommonJS 是 Node 默认的模块规范,核心是 require 加载与 module.exports 导出。本节讲清模块解析顺序、缓存机制如何让每个模块天然成为单例、循环依赖发生时的半成品导出行为,以及它与事件循环的一个关键区别——模块加载是同步的。

学习目标

  1. 能说出 require 一个模块时的完整解析顺序
  2. 能解释模块缓存的存在及其带来的单例效果
  3. 能复现并诊断一次循环依赖
  4. 能区分 CommonJS 与 ESM 在加载时机上的本质差异

1. 为什么 Node 需要模块系统

JavaScript 原生没有「文件级作用域」这个概念——浏览器时代靠 IIFE 和命名空间硬撑,代码一大就互相污染。Node 面对的是成百上千个文件的服务端工程,必须先解决这个问题。2009 年定下的 CommonJS 规范给了答案:每个文件就是一个模块,有自己的作用域;想暴露什么就挂到 module.exports 上,想用什么就 require 进来。

// 最简单的模块:一个计数器 let count = 0; function add(n) { count += n; return count; } function current() { return count; } module.exports = { add, current };

使用方:

const counter = require('./counter'); counter.add(5); counter.add(3); console.log(counter.current()); // 8

2. require 的解析顺序

当你写下 require('http')require('./counter') 时,Node 走的是两条完全不同的路:

  1. 核心模块(http、fs、path、events……):名字直接命中核心模块表,跳过所有查找,速度最快。
  2. 文件与目录(以 ./../ 或绝对路径开头):先按确切文件名找;找不到再依次补 .js.json.node 扩展名;如果是个目录,读它的 package.json 里 main 字段;再退回目录下的 index.js。
  3. 第三方包(裸名字如 express):沿着父目录一路向上找 node_modules/express,直到文件系统根。这正是你能在项目深层目录里直接 require 依赖、也能「就近」覆盖版本的原因。

💡 关键直觉:查找路径向上穿透的规则,让同一个依赖可以在不同层级存在不同版本——这是灵活性,也是第 4 章「依赖地狱」的种子。

3. 缓存:每个模块只跑一次

require 的返回值会被缓存到 require.cache 里,键是解析后的绝对文件名。第二次 require 同一模块时直接返回缓存对象,模块顶层代码不会重新执行。

// counter.js 再被别处 require 时,顶层的 let count = 0 不会重跑 // 所以整个进程里 counter 只有一份——天然单例

这个设计可以用 module 对象亲自验证:

console.log(module); // Module { // id: '.', // path: [...], // exports: {}, // filename: '...counter.js', // loaded: false, // ... // }

loaded 字段很值得注意:模块代码执行期间它是 false,执行完才变 true。这个细节直接决定了循环依赖的行为。

4. 循环依赖:拿到半成品

假设 a.js require 了 b.js,b.js 又回头 require 了 a.js。Node 的处理方式不是报错,而是立刻返回 a 此刻尚未完成的 exports——一个可能只有部分属性的半成品对象。

// a.js console.log('a 开始'); exports.name = 'a'; const b = require('./b'); console.log('b 里看到的 a:', b.sawA); // b.js console.log('b 开始'); const a = require('./a'); exports.sawA = a.name; // a 已执行到 exports.name 赋值之后,所以能取到

运行 a.js 的输出顺序是:a 开始 → b 开始 → b 里看到的 a: a。如果 b 在 a 赋值 exports.name 之前就去 require a,拿到的就是空对象。没有报错、没有警告,只有静默的 undefined——这类 bug 在大型项目里极难排查。

⚠️ 常见坑:循环依赖的典型症状是「某个 import 进来的值是 undefined,但单独看两个文件都没问题」。排查思路是画出依赖图找环,然后通过延迟 require(挪到函数内部)或抽公共模块来解环。

5. 同步加载与事件循环的第一次相遇

CommonJS 的 require 是同步的:模块顶层的代码会阻塞后续语句,直到依赖全部加载完成。这在服务端启动阶段不是问题——启动只发生一次;但如果在请求回调里动态 require 一个大模块,每个请求都要付一次同步加载的代价(好在有缓存,只有第一个请求付全款)。

后来的 ESM(第 7 章会展开)改成了异步加载与静态分析,支持顶层 await。两套体系的核心差异可以用一张表说清:

维度 CommonJS ESM
加载时机 运行时同步加载 编译期静态分析
导出形态 值的拷贝(快照) 绑定(实时映射)
顶层 await 不支持 支持
加载失败 运行时抛错 解析阶段即可发现

「值的拷贝」值得单独记:CommonJS 里导出的是一个数字,模块内部后来改了它,使用方看到的还是旧值;ESM 导出的是绑定,两边实时同步。这个差异在面试和真实 bug 里出镜率都很高。

本节要点回顾

  • 解析三层次:核心模块直查、相对路径按规则补全、裸名沿 node_modules 向上冒泡。
  • 缓存即单例:模块顶层代码每个进程只执行一次,require.cache 是所有模块的注册表。
  • 循环依赖:返回半成品 exports,症状是神秘的 undefined,解法是解环或延迟 require。
  • 同步性:require 阻塞后续代码,动态 require 要放进启动期而不是热路径。
  • 与 ESM 的分野:同步/异步加载、拷贝/绑定导出——这组对比贯穿到第 7 章的新特性讨论。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U