请介绍一下 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 里还可能受到 exports、type 等字段影响,所以更严谨的说法是:
- 目录和包的解析会结合
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) {
// 模块源码
})
这一步带来几个后果:
- 每个模块有自己的私有作用域,不会把顶层变量直接挂到全局。
- 你在模块里能直接用
exports、require、module、__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
为什么会出现“读到半成品”?
因为:
a开始加载,先进入执行。a还没执行完时去require("./b")。b再反过来require("./a")。- 这时
a已经有模块对象和exports了,但还没完全执行完,所以b拿到的是一个“当前进度下的导出对象”。
面试结论:
- CommonJS 为了打破循环依赖死锁,会先暴露一个“正在构建中的导出对象”。
- 所以循环依赖场景下,拿到未完成值是正常现象。
九、require 的优缺点怎么答
优点
- 简单直接,学习成本低。
- 同步加载适合服务端本地文件场景。
- 有缓存,重复加载成本低。
- 支持运行时按条件加载。
缺点
- 同步加载不适合浏览器原生模块场景。
- 依赖关系在运行时确定,不如 ESM 易于静态分析。
- 循环依赖容易出现半初始化对象。
- Tree Shaking 等编译期优化能力不如 ESM。
典型题 & 标准答法
Q1:require 加载模块会经历哪些步骤?
- 先解析模块标识符,定位到具体模块。
- 再检查缓存。
- 未命中则创建模块对象,读取并包装代码。
- 执行模块,得到
module.exports。 - 写入缓存并返回导出结果。
Q2:为什么 CommonJS 有缓存?
- 为了避免同一模块反复读取和执行。
- 同时也让模块天然具备“单例复用”的特征。
Q3:为什么模块里能直接用 exports、module、__dirname?
- 因为 Node 在执行前会把模块包进一个函数。
- 这些变量来自包装函数参数,不是全局变量。
Q4:为什么循环依赖时拿到的值可能不完整?
- 因为模块执行是有过程的。
- 为了支持循环引用,Node 会先暴露当前已有的
exports,即使它还没完全初始化完。
易错点 / 坑
- 把
require说成“复制文件内容”。 - 只会背“有缓存”,却说不清缓存的是模块实例还是源码。
- 忽略模块包装函数这一层。
- 以为
require和 ESMimport的加载机制完全一样。 - 不知道 CommonJS 循环依赖为什么会读到半成品。
速记要点(可背诵)
require机制 = 解析 -> 缓存 -> 创建模块 -> 包装 -> 执行 -> 返回导出。- 返回值本质是
module.exports。 - CommonJS 模块有私有作用域,因为 Node 做了函数包装。
- 同一模块默认只执行一次,后续多半走缓存。
- 循环依赖时可能读到“未完全初始化”的导出对象。