CommonJS模块机制详解:require原理、循环依赖与ESM互操作

发布时间:2026/10/16 3:43:13
CommonJS模块机制详解:require原理、循环依赖与ESM互操作 1. 为什么还需要专门写一篇 CommonJS 的文章1.1 你以为天天在用其实没细看过的模块约定妻子不止一次问我在电脑前敲那些require是什么我每次都简单回一句引包用的。直到有一次排查一个线上服务崩溃真正卡在模块加载顺序上我才发现自己对 CommonJS 的理解也就停在会用这个层面。这件事让我花了整整一个周末把 CommonJS 规范从头理了一遍也确实起了大作用——后来团队里几个同事遇到类似的依赖问题基本都能快速定位。这里要先说清楚一个事实CommonJS 不是什么浏览器或标准组织制定的规范它最初是社区在 Node.js 出现前后为了让 JavaScript 具备模块能力而提出的一套约定。后来 Node.js 把这套约定变成了默认的模块机制。所以说你是离不开它的——哪怕项目已经全面转向 ES Module构建工具在做代码拆分和依赖分析的时候依然绕不开 CommonJS 的语义。很多人学 JavaScript 的时候跳过了模块化这部分就直接上手框架。等真正维护一个稍微大点的 Node.js 服务就会发现明明代码没问题就是运行结果不对。这正是因为 CommonJS 当作理所当然的东西其实隐藏着一整套执行细节。1.2 读透这套机制到底能解决什么实际问题我在实际开发里遇到过的问题大致可以归成这几类循环引用两个模块时其中一个拿到的不是完整导出对象而是空对象或者undefinedexports {}这种写法在模块里静默失效内容没导出去修改了某个模块的代码重启服务以后发现运行结果还是旧的最后才意识到是require.cache没清掉从 CommonJS 迁移到 ESM 时import进来的模块缺了默认导出或者命名导出对不上打包时依赖关系混乱最终产物里面模块执行顺序被改变。这些问题如果只靠搜答案往往是你抄一段代码发现能用但换个场景又出问题。因为你不懂背后原因就无法判断哪些方案是真的对哪些只是碰巧没踩到雷。所以这篇文章不打算给你速查手册而是把 CommonJS 的运行机制拆开讲透模块对象长什么样、如何解析、如何加载缓存、循环依赖为什么会有问题、以及与 ESM 互操作时到底发生了什么。这些都是实际排查时必然会遇到的核心点。2. 一个模块的完整生命周期从源码文件到模块实例2.1 文件被读取后Node 到底对代码做了什么在 Node.js 里执行一个入口文件比如node index.js事件循环启动之前会先完成模块的加载。核心逻辑并不复杂但每一步都有讲究。我习惯把 CommonJS 的加载过程想象成一条流水线路径解析把require(lodash)这种裸包名解析成实际的文件绝对路径检查缓存看这份文件是不是已经加载过加载过就直接返回缓存的module.exports读取文件根据路径把文件内容读进内存模块包装把文件源代码包进一个函数里执行并缓存运行包装函数把执行结果存进缓存最后返回module.exports。其中第 4 步是最容易被忽略的。Node 在编译阶段不会直接 eval 你的文件内容而是把它包成一个函数function (exports, require, module, __filename, __dirname) { // 你的源码被放在这里 }为什么非要包这一层关键在于作用域隔离。文件顶部如果直接写var name hello不包函数就会变成全局变量包了函数之后变量就只存在于函数作用域内部。exports、require、module这些参数就是这个包装器注入的环境变量所以你可以在文件里直接用不用显式引入。还有一个容易忽略的点包装器执行时this的值不是全局对象而是exports没错在 CommonJS 里顶层this exports。我见过不少面试题问Node.js 模块顶层 this 指向什么答案就在这里。2.2 module 对象上那些不起眼但关键时刻救命的字段每个 CommonJS 模块都会有一个对应的module对象它是加载流程中实际被缓存的主体。打印一个常见模块的module你会看到类似这样{ id: ., path: /Users/tester/project, filename: /Users/tester/project/index.js, loaded: false, children: [], paths: [ /Users/tester/project/node_modules, /Users/tester/node_modules, /node_modules ], exports: {} }几个字段的实际用途我逐个说下module.id模块的标识通常就是解析后的绝对路径入口模块是.module.filename完整的文件路径module.loaded模块当前是否执行完毕被标记为完成module.children这个模块直接require的子模块列表module.paths解析裸包名require(xxx)时查找node_modules的目录路径序列module.exports模块对外输出的对象最终会被require返回。调试模块引用关系时module.children和parent非常有用。我曾经排查过一个某模块被重复初始化的问题就是在启动脚本里打印require.main.children才定位到有两处入口分别加载了同一个模块导致两套独立实例同时存在。这类问题靠搜索引擎很难直接找到答案但理解了children和缓存的作用排查思路就清晰了。2.3 require 解析路径的完整算法和你容易忽略的扩展名顺序require(./utils)和require(lodash)的解析过程并不一样。Node 内部走的是Module._resolveFilename这个方法规则大致如下如果require参数以/、./、../开头视为相对或绝对路径直接去对应位置找文件如果不是路径形式则视为包名从当前文件所在目录逐级向上在每个node_modules目录里查找同名目录找到目录后先看包里的package.json中main字段指向哪里如果没写main默认取目录下的index.js找不到就报错。还有个细节假设你写的是require(./utils)省略了扩展名Node 会按.js、.json、.node的顺序依次尝试补齐。这意味着同样名字的utils.js和utils.json同时存在时加载的一定是.js。这个顺序我踩过坑某个项目里为了改配置方便加了一个config.json但同一目录下还有一份旧的config.js怎么改 JSON 都不生效代码走的永远是.js找问题找了半天。裸包名解析的paths机制也是面试高频点理解它有助于解决为什么同一个包能有两份副本挂在不同目录下的影子依赖问题。node_modules里套node_modules极其常见尤其是版本依赖不一致的时候require会从最近的目录优先找找到哪个算哪个所以你觉得加载的是全局版本实际可能是子目录里的旧副本。3. 导出与缓存的背后接口设计、动态特性和性能权衡3.1 exports 与 module.exports为什么说前者只是后者的别名很多新手阶段都写过类似代码// math.js exports.add function (a, b) { return a b; };这段能正常工作。但换一种写法就出问题了// math.js exports { add: function (a, b) { return a b; } }; // 使用方 const math require(./math); console.log(math.add); // undefined原因并不神秘。模块包装器执行时exports参数默认指向module.exports对象的引用。exports.add ...是在修改同一个对象而exports {...}是让exports变量指向了一个新对象它已经和module.exports断开了联系。最后require返回的一定是module.exports于是前面的赋值就 静默丢失 了。我见到过一个相对隐蔽的替代写法module.exports.add function (a, b) { return a b; }; module.exports.sub function (a, b) { return a - b; };这种写法适合一个模块导出多个方法。但如果想把整个对象整体替换就只能用module.exports {...}同时保证exports不再参与赋值。写导出逻辑时我的经验是要么只操作module.exports要么只操作exports不要混着来。混用虽然技术上没错但对后续维护者来说极容易看晕。还有一个实用场景当你需要导出一个构造函数或一个类时最自然的写法是class Queue { constructor() { this.items []; } push(item) { this.items.push(item); } } module.exports Queue;如果这时候你在某个文件里还想给这个类追加一个静态方法可以这样const Queue require(./queue); Queue.create function () { return new Queue(); };这种导出后再扩展的模式正好依赖 CommonJS 的缓存机制——同一个模块在全局只保留一份实例对象。3.2 缓存机制的本质为什么说它是双刃剑require加载过的模块会被缓存在Module._cache对象里键是解析后的绝对路径值是module对象。这样设计是为了避免重复读取文件和重复执行代码对于大型应用来说性能收益非常明显。但缓存也会带来副作用。最常见的就是你改了被依赖的配置文件服务不重启就不生效或者单元测试里要测同一个模块的不同状态结果发现每次拿到的都是同一个实例。清缓存的办法倒不难核心代码两行function cleanRequireCache(modulePath) { const resolved require.resolve(modulePath); delete require.cache[resolved]; }注意require.cache本质上就是Module._cache。不过在服务环境里我不建议滥用这个技巧因为它很容易引入隐性状态。真有需要更推荐把可变的配置设计成函数入参而不是依赖清缓存这种偏 hack 的方式。缓存还带来一个微妙的点循环依赖里缓存的存在决定了模块能拿到什么。后续第 4 节会详细展开这里只需记住CommonJS 的缓存是先到先得——第一次加载时就把可能还没赋完值的module.exports放进缓存后面再 require 这个模块拿到的就是那个缓存对象。另有一个开发时的高频场景修改了源码用nodemon这类工具热重启为什么有时候改完没生效不一定是缓存问题而是你修改的文件位于某个node_modules的内部依赖目录这个目录没被监听。这类问题顺着缓存和监听这两个方向排查很快就能找到根源。3.3 动态求值带来的设计空间与负担CommonJS 是典型的动态模块系统。require可以在函数体内部调用可以在条件分支里调用甚至可以传一个变量作为路径function loadModule(name) { const modulePath ./plugins/${name}.js; return require(modulePath); }这在构建工具和框架设计中很常见。但也正是这种动态特性让静态分析变得困难——打包工具在没法确定require参数的情况下只能把整个目录或整个包都打入产物结果体积变大。反过来ES Module 的静态特性允许工具构建更精确的依赖图。如今比较新的 Vite 等工具之所以能做到按需引入部分前提就在模块系统的静态可分析性上。我个人的建议是业务代码里尽量把require写到文件顶层并且路径写成字面量。这不是约束爱好而是给未来的自己和打包工具留一条活路。尤其是前端工程化项目里动脑筋用拼接路径的方式引用模块压缩混淆或迁移构建工具时一定会碰到坑。4. 循环依赖怎么触发、为何乱序、如何强力兜底4.1 最小复现两个模块互相 require先让大家看一个最精简的循环依赖例子// a.js const b require(./b); console.log(a.js 中打印 b:, b); module.exports { name: a };// b.js const a require(./a); console.log(b.js 中打印 a:, a); module.exports { name: b };假设入口从a.js开始执行输出结果如下b.js 中打印 a: {} a.js 中打印 b: { name: b }也就是说b.js里拿到的a是一个空对象而不是{ name: a }。很多人第一次看到会懵——明明a.js里的导出写在前面为什么b.js拿不到4.2 乱序的本质缓存、执行阶段与半成品导出这个现象根因是当a.js第一行执行require(./b)时a模块自己的代码只运行到第一行module.exports { name: a }这一句还没执行。此时a模块的module.exports还是初始的空对象{}。Node 把这个空对象丢进缓存然后去加载b.jsb.js再回头require(./a)时从缓存里拿到的就是那个尚未完成的空对象。所以这不是死锁也不是报错而是b.js拿到的a是半成品导出。所谓半成品取决于a.js执行到require(b)之前到底往module.exports上挂了哪些属性。举个例子// a.js module.exports.early 我已经暴露了; const b require(./b); module.exports.later 我还没暴露;b.js里拿到的a会是{ early: 我已经暴露了 }没有later字段。这就是 CommonJS 循环依赖问题的精确定位不是不允许循环而是你最早被对方看到的时机决定了能拿到多少导出内容。理解这一点后再解释另一个常见现象module.exports { ... }整对象替换式导出在循环依赖下更容易出问题。因为整体替换发生在代码末尾如果对方在你整体替换之前就已经 require 了你对方拿到的是默认空对象并且后续也不会再同步更新因为缓存的是同一个module但module.exports已经换成了新对象旧引用还是指向旧对象。而exports.name xxx式导出的风险则小一些——对方拿到的虽是半成品但等你后续执行完同一个对象里的属性会被补全对方最终使用的时候通常已经是完整状态。4.3 面对循环依赖的正确姿势与常见兜底策略循环依赖在小型模块里可能只是控制台打印顺序不对但在大型项目里会演化成某个方法调用时是undefined、初始化数据为空这类难排查的问题。我的处理策略按优先级排重构消除循环。把公共部分抽到第三个模块里让两个模块都依赖公共模块而不是互相依赖。这是最彻底的方案。实际中很大部分的循环依赖底层都是公共逻辑放错了位置导致的。延迟使用导出内容。如果循环依赖一时半会儿消不掉把需要依赖对方导出的逻辑放到函数体内而不是在模块顶层立即调用// a.js const b require(./b); function sayHello() { // 等调用的时候b 的导出早已完整 return hello ${b.name}; } module.exports { sayHello };这一招利用了函数执行的延迟性把读取时机从模块加载期推迟到了调用期规避了半成品导出问题。依赖注入。不直接 require而是把对方的实例作为参数传进来// a.js function createA(b) { return { name: a, getBNickname: () b.nickname }; } module.exports { createA };这种方式会让模块之间关系更清晰也更好做单元测试在架构偏重的系统里尤其划算。还有一种兜底手段是修改加载顺序。由于循环依赖的表现主要取决于入口先加载谁你可以调整入口文件里require的调用顺序让先被加载的模块把关键导出提前。但这种办法更像止血不建议当成长期方案。我前面说的那个线上服务崩溃最后就是靠着把公共依赖抽出来 延迟读取双重处理解决的。5. 在 ESM 时代CommonJS 仍是底色互操作中的关键差异5.1 CJS 与 ESM 的底层世界观为什么不同ES Module简称 ESM是语言层面的标准语法本身是静态的import和export必须出现在代码顶层不能放在条件分支里。这个静态性决定了构建工具可以在不执行代码的情况下建立模块依赖图做 tree-shaking删除未使用代码也更可靠。CommonJS 则是动态的require是一个普通的函数调用可以在任意位置执行。两者执行时机也不同CJS 是同步加载加载完就执行代码并返回导出ESM 是异步的并且引入了import提升、实时绑定live binding等机制。一个直观差异是在 CJS 里module.exports可以在加载完之后再动态改变后面 require 到这个模块的人看到的导出内容是对应当时的快照而 ESM 里导入的绑定是实时的你可以通过export导出一个变量导入方看到的是该变量后续的变化。我见过一个典型的不理解差异导致翻车的场景团队从 CJS 迁移到 ESM发现原来某个模块里的setTimeout回调会修改导出的对象但import过来的代码拿不到更新后的值。原因是打包工具或 Node 在做 CJS 到 ESM 转换时默认导出被包装成值复制或只读语义跟原生 ESM 的实时绑定不一致。要判断你的运行时到底是哪种语义最简单的办法是在被导入模块里写一句延时修改导出内容的代码然后观察导入方打印的值。5.2 在 Node.js 里 CJS 与 ESM 能否互相 require / importNode.js 的模块互操作规则比较特殊说几个关键点在 CommonJS 文件里用require()无法直接加载一个 ESM 模块至少传统的同步 require 不行因为 ESM 是异步的。现代 Node 版本提供了require(esm)的支持但这是实验性质的还存在一些限制比如顶层 await 的使用范围在 ESM 文件里import一个 CommonJS 模块Node.js 会自动把module.exports作为默认导出default export暴露出来。这就解释了为什么import cjs from ./cjs-module通常能用但import { name } from ./cjs-module不一定有效——命名导出需要依赖静态分析 Node 内部的cjs-module-lexer去猜猜到了就能用猜不到就报SyntaxError如果 CJS 里写的是module.exports { add, sub }ESM 侧通常只能稳定拿到default对象import { add }这类命名导入要看工具和 Node 能否识别成功。所以跨模块类型互操作最稳妥的写法是在 ESM 里 import CJS 时默认导入整个对象再解构import cjsModule from ./legacy-cjs.js; const { add } cjsModule;反过来如果某个包在 CJS 里是一个函数或类import进来之后cjsModule本身就是这个函数/类可以直接调用或new。这也是打包工具做互操作时最常采用的合成默认导出方案。5.3 通过 package.json 的 type 字段控制代码语义从 Node 12 开始支持在package.json里写type字段决定.js文件按哪种模块系统解释顶层package.json不写type字段默认是commonjs写成type: module则.js文件按 ESM 解析想在同一项目里混合使用可以把部分文件后缀改成.cjs强制按 CommonJS 解析或.mjs强制按 ESM 解析。这个后缀约定我强烈建议形成一个团队规范。只靠type字段控制全局很容易出现这个文件为什么按 ESM 解析的困惑。显式命名.cjs/.mjs虽然看起来不优雅但每次看到文件名就能立刻知道模块语义排查问题能省很多时间。此外如果某个 CommonJS 包想跨到 ESM 侧使用但又想保留命名导出的能力可以考虑在包里额外提供一个 ESM wrapper 文件。比如// index.mjs import cjsModule from ./index.cjs; export const add cjsModule.add; export default cjsModule;这个模式在很多开源包的源码里都能看到本质就是在两种模块系统之间做了一层显式适配层比依赖工具的自动猜测要可靠得多。6. 实践技巧排查问题与日常调优的一些经验总结6.1 定位模块加载问题的四个实际手段当模块行为异常我常用的排查步骤能分成四步这里按顺序说第一确认当前模块类型。先看文件后缀和最近的package.json里type字段排除语义不符的问题。很多人在type: module项目里直接写require报错ReferenceError: require is not defined其实根本不是 require 的语法错了而是当前文件按 ESM 解析压根没有注入require。第二打印解析后的路径。require.resolve(./xxx)可以拿到 Node 实际解析到的绝对路径。如果怀疑加载了错误的文件副本先打印这个路径再用fs.realpathSync解析一下真实路径因为可能落到 symlink 或软链的目录。第三查看缓存与模块对象。在入口文件里临时加上console.log(require.cache);或者打印具体模块console.log(require.cache[require.resolve(./target)]);可以看到模块是否已被缓存、loaded状态、children关联关系。排查改动没生效和实例不唯一这两类问题这个方法非常直接。第四借助 Node 的调试器。在模块加载和函数执行的地方打断点观察执行顺序。如果觉得console.log太粗糙可以用node --inspect-brk启动浏览器 DevTools 里把调用栈、作用域、模块对象翻个遍。定位循环依赖导致的乱序执行时这种方式比加日志高效得多。6.2 工程里面对 CommonJS 遗留问题时我的几条实际建议很多团队处于新代码用 ESM老代码还是 CJS的过渡期最容易出现的是混用问题。我的习惯性做法是所有纯工具函数模块统一写 CJS 还是 ESM 都行但一旦决定就别在交付物里随意切换避免每个文件被不同解析规则处理需要被业务方动态加载或做依赖注入的模块用 CJS 写成导出函数或类会更自然因为require的运行时行为更灵活框架插件或配置类模块尽量用 CJS兼容面更广也方便在运行期重载配置公共组件库如果要发布给外部使用最好同时提供 CJS 版本和 ESM 版本并在package.json里通过main和module字段分开指向。注意仅凭module字段并不能让老版本工具正确选择还是要配合exports字段做条件导出。再说一个很多人踩过的坑项目里package.json没有写type: module但用了某个打包工具它内部默认把.js文件当成 ESM 处理导致你的 CJS 代码在构建时报各种import相关的错误。遇到这类情况最快的排查方向不是改代码逻辑而是先确认构建工具默认的模块解析规则和你的文件后缀是否匹配。6.3 值得稳定维护的一份自查清单最后把这几年碰到的 CommonJS 相关高频问题整理了一张清单可以复制进团队文档里遇到类似症状时先过一遍症状优先检查项常见根因require 得到 undefined循环依赖、导出赋值顺序被依赖模块尚未执行到赋值语句修改文件后结果不变require.cache文件被缓存或监听的目录不对两个入口加载同一模块模块父子关系、入口文件结构多入口各自初始化了一份实例ESM 导入 CJS 命名导出失败包内部导出方式直接 module.exports 对象未提供命名导出require 在某些文件里报未定义package.json 的 type 字段当前文件按 ESM 解析路径对了但加载的版本不对require.resolve 路径、node_modules 层级依赖提升或目录嵌套导致版本分裂我自己在实操中养成了一个习惯每次排查模块相关问题时先在脑子里过一遍这个模块从哪里来 → 什么时候被加载 → 运行时导出了什么 → 谁在什么时候引用了它。这个四段式思维比背多少条 API 都管用尤其是碰到循环依赖和跨模块类型互操作这类症状诡异的问题时把链路捋直了解决方案自然就出来了。Node.js 的模块机制看似简单实际上每个细节都影响着大型项目的架构可维护性。这套 CommonJS 规范虽然诞生得早在今天却依然是绝大多数 Node.js 应用的地基值得每个做后端或工程化的开发者认真啃一遍。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询