人人都会AI编程

5.1 CommonJS 规范核心机制

更新时间:2026-07-11

在 Node.js 中,require 是引入模块的核心机制。它看似简单的一行代码,背后却有一套完整的加载流程:路径解析 → 文件定位 → 编译执行 → 缓存返回。理解这四个步骤,不仅有助于掌握模块系统的本质,还能在遇到模块找不到、循环引用等问题时迅速定位原因。

5.1.1 路径解析:从字符串到绝对路径

当写下 require('./utils')require('express') 时,Node.js 首先要将传入的标识符转换为一个真实的绝对路径。这一步称为 路径解析

Node.js 根据标识符的类型采用不同的解析策略:

  1. 内置模块:如 require('fs')require('http'),这些模块名称被识别为 Node.js 原生提供的模块,直接返回内置模块而无需经过文件系统查找。内置模块的优先级最高。
  1. 相对路径/绝对路径:以 ./..// 开头的标识符。Node.js 会将当前模块所在目录(__dirname)与标识符拼接,生成一个绝对路径。由于路径已明确,解析速度很快(除非后续文件定位中需要试探扩展名)。
  1. 裸路径(bare specifier):如 require('express'),不是内置模块也没有路径前缀。这是最常见也最复杂的解析过程。Node.js 会在当前模块目录下的 node_modules 中查找,如果找不到,则逐级向上查找父目录的 node_modules,直到文件系统根目录。这种“向上递归”保证了上层依赖能被全局项目引用,同时不同位置的模块可以拥有自己的私有依赖。

路径解析过程会读取 package.jsonmain 字段来确定入口文件,若未指定则默认使用 index.js。从 Node.js 12.7.0 起也支持 exports 字段进行条件导出,提供更精确的包入口控制。

实用细节

  • require 的标识符不带扩展名时,Node.js 会按 .js.json.node 的顺序尝试追加扩展名。因此书写时尽量带上 .js 可以减少文件系统的试探次数,略微提升性能。
  • node_modules 的层级查找机制在某些边角场景会导致“幽灵依赖”——某个包能够 require 到自身未直接声明的依赖,因为该依赖恰好存在于上层 node_modules 中。这也是 pnpm 等严格模式受到青睐的原因。

5.1.2 文件定位:从路径到真实的文件

路径解析后,Node.js 得到一个没有扩展名的路径(假设写的是 require('./utils'))。此时需要进行 文件定位,即依次尝试各种扩展名并判断文件类型。

Node.js 的试探顺序是:

  1. 尝试原样文件(可能是无扩展名的文件)。
  2. .js 扩展名尝试。
  3. .json 扩展名尝试。
  4. .node 扩展名尝试(C++ 编译的二进制模块)。

如果都没有找到,Node.js 会认为这可能是一个目录,于是查找该目录下的 package.json,依据其中的 main 字段确定文件。如果没有 main 则尝试 index.jsindex.json。如果依然找不到,则抛出 MODULE_NOT_FOUND 错误。

性能提示

  • 在大型项目中,过多的文件试探会增加启动时间。使用显式的完整文件名(带 .js)可以避免试探,尤其是在 require 调用频繁的热路径上。
  • 利用 module.paths 可以查看当前模块的搜索路径列表,便于调试“为什么找不到模块”的问题。

5.1.3 编译执行:从文件内容到模块对象

当绝对路径确定后,Node.js 会检查文件扩展名,调用对应的编译器对文件内容进行处理。

  • .js 文件:Node.js 读取文件内容,将其包裹在一个函数中:
(function(exports, require, module, __filename, __dirname) {
  // 模块代码在这里
});

通过这种包裹,模块代码拥有自己的作用域,不会污染全局空间。同时向模块注入了 exportsrequiremodulefilenamedirname 五个局部变量。

  • .json 文件:直接使用 JSON.parse 解析,将结果赋值给 module.exports。非常高效,常用于配置加载。
  • .node 文件:通过 process.dlopen 加载编译好的 C++ 扩展,直接提供给模块系统。
  • 其他扩展名:被当作 .js 处理。

编译执行的过程中,模块代码被执行一次,并将导出内容赋值给 module.exports。这里有一个重要的细节:exportsmodule.exports 的引用。如果直接给 exports 赋值新对象(如 exports = { foo: 'bar' }),不会改变模块的实际导出,因为 require 最终返回的是 module.exports。这也是为什么许多教程建议统一使用 module.exports 来导出的原因。

循环引用时的编译行为:如果模块 A 引用了模块 B,而 B 又引用了 A,Node.js 不会陷入无限递归,因为模块在被 require 时,一旦开始编译就会在缓存中占位(一个未完成的 module.exports 对象)。当 B 再次 require(A) 时,会拿到 A 的部分导出,从而避免死循环。这种行为导致循环引用时可能取到不完整的导出值,应尽量避免或采用延迟访问。

5.1.4 缓存返回:避免重复加载与加快速度

模块加载完成后,Node.js 会将编译好的模块对象(包括 exports)放入一个内部缓存 require.cache 中,键为模块的绝对路径。当相同的模块再次被 require 时,直接返回缓存中的 module.exports 对象,不再重复执行模块代码。

这个简单的机制带来了三个好处:

  • 性能提升:避免重复的文件读取、解析和编译。
  • 单例行为:整个应用中对同一个 require 路径返回的是同一个对象,可以方便地共享状态(数据库连接、配置等)。
  • 避免无限循环:结合上述占位机制,循环引用时不会导致栈溢出。

开发者可以手动操作 require.cache,清除某个模块的缓存,强制重新加载。这在某些测试场景(如需要重置模块内部状态)或热重载场景中很有用:

// 删除缓存后重新 require,模块代码会重新执行
delete require.cache[require.resolve('./config')];
const freshConfig = require('./config');

缓存带来的“单例”特性也提示我们:如果模块内部维护了可变状态,所有引用者都会共享该状态,可能引发意料之外的副作用。需要隔离时,应导出工厂函数而不是直接导出对象。

5.1.5 全流程串联与调试技巧

综合起来,一个 require('./lib/helper') 的完整旅程如下:

  1. 路径解析:结合当前文件的 __dirname 得到 /project/src/lib/helper
  2. 文件定位:依次尝试 /project/src/lib/helper/project/src/lib/helper.js.json.node,如果存在 helper.js 则选中该文件。
  3. 编译执行:读取 helper.js 内容,包裹成函数,注入局部变量,执行代码。代码里可能又包含其他 require,形成递归加载。
  4. 缓存返回:将 module.exports 存入 require.cache['/project/src/lib/helper.js'],并将同一个导出对象返回给调用方。此后任何地方 require 该路径都直接命中缓存。

当加载出现问题时,可以利用以下技巧来排查:

  • 打印 Error 对象的 code 属性区分 MODULE_NOT_FOUND 还是其他错误。
  • 使用 require.resolve 仅做路径解析,不加载模块,用于验证模块的最终路径。
  • 在调试循环引用时,在模块代码中打印 module.children 查看当前的依赖树,帮助定位问题。

理解 require 的这四步过程,是把 Node.js 模块系统从“魔法”变成可控工具的关键。在实践中,这些细节会直接影响到性能优化、包管理策略以及代码结构设计。