跳到主要内容

请介绍一下 require 的模块加载机制:解析、缓存、执行分别发生了什么?

面试速答(30 秒版 TL;DR)

  • require 是 CommonJS 的加载机制,核心流程可以概括成:解析路径 -> 读取文件 -> 包装模块 -> 执行代码 -> 返回导出 -> 放入缓存
  • 它的关键特征是 同步加载、运行时执行、有缓存,这非常符合早期 Node 服务端本地文件读取的场景。
  • require() 不只是“把文件读进来”,它本质是在创建一个 Module 实例,并把文件内容包进一个函数作用域里执行。
  • 第一次加载会执行模块代码;后续再次 require 同一模块,通常直接命中缓存,不会重复执行。
  • 面试一句话:CommonJS 的 require 是“加载并执行模块,然后拿到 module.exports”,不是单纯的文本包含。

心智模型:require 做的是“求值后的模块对象复用”

很多人会把它理解成:

  • require("./a") = 把 a.js 文件内容复制过来

这其实不对。

更准确地说:

  • Node 会把模块当成一个独立单元;
  • 加载时先解析出要找哪个文件;
  • 再创建模块对象;
  • 再执行模块代码;
  • 最终返回这个模块暴露出来的 exports

所以 require 既包含 定位,也包含 执行,还包含 缓存复用


一、整体流程图

先记主链路:解析 -> 命中文件 -> 查缓存 -> 创建模块 -> 读取并包装 -> 执行 -> 返回导出

如果面试官追问细节,你就沿这条链往下展开。


二、第一步:模块解析

require("xxx") 不是总按同一规则找文件,它会先判断“你到底在要什么”。

1. 核心模块

比如:

require('node:fs')
require('fs')

这类优先走 Node 内置模块,不去磁盘里找你项目中的同名文件。

2. 相对路径或绝对路径模块

比如:

require('./utils')
require('../config/index')

这类会按路径去解析,常见过程是:

  • 先看是不是一个文件;
  • 如果没写后缀,尝试补常见扩展名;
  • 如果是目录,再尝试按目录模块规则解析。

3. 第三方包

比如:

require('koa')

这类会沿当前目录不断向上找 node_modules

面试常见口径:

  • 先从当前模块所在目录找 node_modules/koa
  • 找不到就去上一级目录继续找
  • 一直找到文件系统根目录为止

4. 目录模块

如果解析到一个目录,常见会继续找:

  • package.json 里的入口字段
  • 对应入口文件
  • 找不到时再尝试默认入口

现代 Node 里还可能受到 exportstype 等字段影响,所以更严谨的说法是:

  • 目录和包的解析会结合 package.json 配置决定入口。

三、第二步:缓存检查

这是面试高频点。

const a1 = require('./a')
const a2 = require('./a')

console.log(a1 === a2) // true

为什么通常是 true

  • 因为 Node 第一次加载模块后,会把模块对象放进缓存。
  • 后续再 require 同一个已解析路径的模块,通常直接返回缓存里的导出结果。

面试口径:

  • 缓存的是模块实例及其导出结果,不只是原始源码字符串。
  • 所以模块顶层代码默认只执行一次。

这也是单例配置、数据库连接复用这类写法经常成立的原因。


四、第三步:创建 Module 实例

如果缓存没命中,Node 会为这个模块创建一个 Module 对象。

可以把它理解成一个载体,里面至少会记录:

  • 模块的 id
  • 文件名 filename
  • 父子模块关系
  • exports
  • 是否已加载完成

你可以把它粗略理解成:

const module = {
exports: {},
loaded: false,
filename: '/abs/path/a.js',
}

这一步很重要,因为后面返回给调用方的,就是这个 module.exports


五、第四步:模块包装

这是很多人不会答的加分点。

Node 不会把 CommonJS 文件直接扔到全局执行,而是会先包成类似这样:

;(function (exports, require, module, __filename, __dirname) {
// 模块源码
})

这一步带来几个后果:

  • 每个模块有自己的私有作用域,不会把顶层变量直接挂到全局。
  • 你在模块里能直接用 exportsrequiremodule__filename__dirname,就是因为它们是包装函数的参数。

所以面试里如果被问“为什么模块里能直接写 __dirname”,答案就是:

  • 因为 Node 在执行前做了函数包装,并把这些变量注入进去了。

六、第五步:执行模块代码

包装好之后,Node 会真正执行这个模块函数。

这里要注意:

  • CommonJS 是 运行时执行,不是像 ESM 那样静态分析后再链接。
  • 所以 require() 可以写在条件判断里、函数里、循环里。

例如:

if (process.env.NODE_ENV === 'development') {
const debug = require('./debug')
debug.start()
}

这在 CommonJS 里是合法且常见的。


七、第六步:返回 module.exports

模块执行结束后,require() 的返回值其实就是:

module.exports

例如:

// math.js
module.exports = {
add(a, b) {
return a + b
},
}

// app.js
const math = require('./math')
console.log(math.add(1, 2))

这里 math 拿到的不是整个模块对象,而是该模块最终的 module.exports


八、循环依赖为什么会拿到“不完整导出”

这是深一点的面试题。

假设:

// a.js
exports.done = false
const b = require('./b')
console.log('in a, b.done =', b.done)
exports.done = true

// b.js
exports.done = false
const a = require('./a')
console.log('in b, a.done =', a.done)
exports.done = true

为什么会出现“读到半成品”?

因为:

  1. a 开始加载,先进入执行。
  2. a 还没执行完时去 require("./b")
  3. b 再反过来 require("./a")
  4. 这时 a 已经有模块对象和 exports 了,但还没完全执行完,所以 b 拿到的是一个“当前进度下的导出对象”。

面试结论:

  • CommonJS 为了打破循环依赖死锁,会先暴露一个“正在构建中的导出对象”。
  • 所以循环依赖场景下,拿到未完成值是正常现象。

九、require 的优缺点怎么答

优点

  • 简单直接,学习成本低。
  • 同步加载适合服务端本地文件场景。
  • 有缓存,重复加载成本低。
  • 支持运行时按条件加载。

缺点

  • 同步加载不适合浏览器原生模块场景。
  • 依赖关系在运行时确定,不如 ESM 易于静态分析。
  • 循环依赖容易出现半初始化对象。
  • Tree Shaking 等编译期优化能力不如 ESM。

典型题 & 标准答法

Q1:require 加载模块会经历哪些步骤?

  • 先解析模块标识符,定位到具体模块。
  • 再检查缓存。
  • 未命中则创建模块对象,读取并包装代码。
  • 执行模块,得到 module.exports
  • 写入缓存并返回导出结果。

Q2:为什么 CommonJS 有缓存?

  • 为了避免同一模块反复读取和执行。
  • 同时也让模块天然具备“单例复用”的特征。

Q3:为什么模块里能直接用 exportsmodule__dirname

  • 因为 Node 在执行前会把模块包进一个函数。
  • 这些变量来自包装函数参数,不是全局变量。

Q4:为什么循环依赖时拿到的值可能不完整?

  • 因为模块执行是有过程的。
  • 为了支持循环引用,Node 会先暴露当前已有的 exports,即使它还没完全初始化完。

易错点 / 坑

  • require 说成“复制文件内容”。
  • 只会背“有缓存”,却说不清缓存的是模块实例还是源码。
  • 忽略模块包装函数这一层。
  • 以为 require 和 ESM import 的加载机制完全一样。
  • 不知道 CommonJS 循环依赖为什么会读到半成品。

速记要点(可背诵)

  • require 机制 = 解析 -> 缓存 -> 创建模块 -> 包装 -> 执行 -> 返回导出
  • 返回值本质是 module.exports
  • CommonJS 模块有私有作用域,因为 Node 做了函数包装。
  • 同一模块默认只执行一次,后续多半走缓存。
  • 循环依赖时可能读到“未完全初始化”的导出对象。