第04期 | 模块系统:CommonJS与ES Modules 第04期 | 模块系统CommonJS与ES Modules 学习目标彻底理解CommonJS模块系统require的加载原理、缓存机制、循环依赖处理掌握ES Modules的import/export语法、静态分析优势、动态import()理解两种模块系统的本质区别知道在什么场景下选哪个学会在同一项目中安全混用CommonJS和ES Modules掌握Node.js模块解析算法理解node_modules查找机制 核心知识一、为什么需要模块系统没有模块系统的世界是这样的!-- 前端的黑暗时代 --scriptsrcjquery.js/scriptscriptsrclodash.js/scriptscriptsrcutils.js/scriptscriptsrcapp.js/script所有变量都在全局作用域文件之间通过全局变量通信。问题显而易见命名冲突两个文件都定义了utils变量后者覆盖前者依赖顺序app.js依赖utils.js必须保证utils先加载无法管理依赖谁依赖谁删掉一个文件会不会崩无法复用想在其他项目复用复制粘贴吧模块系统解决的就是这些问题每个文件是一个独立作用域通过明确的接口导入/导出通信。Node.js有两种模块系统特性CommonJS (CJS)ES Modules (ESM)语法require()/module.exportsimport/export加载方式运行时加载动态编译时确定静态this指向module.exportsundefined是否支持顶层await❌✅__dirname/__filename✅ 有❌ 需要polyfillJSON导入require(./data.json)import data from ./data.json(需assert)主流场景Node.js传统项目现代项目/前端/全栈二、CommonJS深入解析基本用法// math.js - 导出模块functionadd(a,b){returnab;}functionsubtract(a,b){returna-b;}// 方式1逐个导出module.exports.addadd;module.exports.subtractsubtract;// 方式2整体导出module.exports{add,subtract,};// 方式3简写推荐exports.addadd;exports.subtractsubtract;// app.js - 导入模块constmathrequire(./math);console.log(math.add(1,2));// 3console.log(math.subtract(5,3));// 2// 解构导入const{add,subtract}require(./math);console.log(add(10,20));// 30⚠️ exports vs module.exports 的陷阱这是CommonJS最常见的坑// ❌ 错误写法这样导出的是空对象exports{add,subtract,};// ✅ 正确写法1用module.exportsmodule.exports{add,subtract,};// ✅ 正确写法2用exports添加属性exports.addadd;exports.subtractsubtract;为什么exports {...}不行Node.js在每个模块内部注入了一个隐含代码// Node.js在每个模块开头隐含执行的代码function(exports,require,module,__filename,__dirname){// 你的代码在这里}exports实际上是module.exports的引用var exports module.exports。当你写exports {...}时你改变了exports的指向让它不再指向module.exports。而Node.js最终导出的是module.exports不是exports。初始状态 module.exports ──→ {} ←── exports exports.add fn module.exports ──→ { add: fn } ←── exports 同一个对象 exports { add: fn } module.exports ──→ {} module.exports没变 exports ──→ { add: fn } exports指向了新对象但module.exports还是空require的加载原理当你调用require(./math)时Node.js做了以下事情1. 路径解析 require(./math) → 解析为绝对路径 require(express) → 在node_modules中查找 2. 缓存检查 如果该模块之前被加载过直接返回缓存中的module.exports → 这就是为什么require不会重复执行模块代码 3. 文件定位 ./math → 尝试以下顺序 a. ./math exact match无扩展名时继续 b. ./math.js c. ./math.json d. ./math.nodeC扩展 e. ./math/index.js如果是目录 f. ./math/package.json中的main字段 4. 编译执行 把模块代码包裹在函数中 (function(exports, require, module, __filename, __dirname) { // 你的代码 }) 然后执行这个函数 5. 返回module.exports缓存机制详解// counter.jsletcount0;functionincrement(){count;returncount;}functiongetCount(){returncount;}module.exports{increment,getCount};// app.jsconstcounterArequire(./counter);constcounterBrequire(./counter);// 不会重新加载返回缓存console.log(counterAcounterB);// true是同一个对象console.log(counterA.increment());// 1console.log(counterA.increment());// 2console.log(counterB.getCount());// 2共享同一个count变量关键认知require是单例的。同一个模块无论被require多少次只执行一次后续返回缓存。如果你想重新加载一个模块清除缓存// 删除缓存deleterequire.cache[require.resolve(./counter)];// 重新requireconstcounterCrequire(./counter);console.log(counterC.getCount());// 0重新开始了循环依赖CommonJS处理循环依赖的方式是返回已执行部分的快照// a.jsconsole.log(a.js开始执行);constbrequire(./b);console.log(a.js中b的值,b);// b已执行但b.value可能还没赋值console.log(a.js结束执行);module.exports{value:from a};// b.jsconsole.log(b.js开始执行);constarequire(./a);// a还没执行完返回a的module.exports的当前状态可能是不完整的console.log(b.js中a的值,a);console.log(b.js结束执行);module.exports{value:from b};// main.jsrequire(./a);执行node main.js的输出a.js开始执行 b.js开始执行 b.js中a的值{} ← 空对象因为a.js还没执行到module.exports b.js结束执行 a.js中b的值{ value: from b } a.js结束执行分析main.js开始加载a.jsa.js执行到require(./b)暂停a.js开始加载b.jsb.js执行到require(./a)发现a.js在缓存中虽然不完整返回当前的module.exports空对象{}b.js继续执行完成导出{ value: from b }回到a.jsrequire(./b)返回完整的b模块a.js继续执行完成导出避免循环依赖的最佳实践把共享的代码提取到第三个模块中延迟require在函数内部require而不是文件顶部重新设计模块结构消除循环// 延迟require示例// a.jsfunctiondoSomething(){constbrequire(./b);// 在函数内部require避免循环依赖问题b.helper();}模块查找算法node_modules当你require(express)不是以./或../开头Node.js会按以下顺序查找当前文件: /home/user/project/src/app.js require(express) 查找路径从近到远 1. /home/user/project/src/node_modules/express 2. /home/user/project/node_modules/express 3. /home/user/node_modules/express 4. /home/node_modules/express 5. /node_modules/express 如果都找不到 → throw Cannot find module express可以用module.paths查看查找路径console.log(module.paths);// 输出// [// /home/user/project/src/node_modules,// /home/user/project/node_modules,// /home/user/node_modules,// /home/node_modules,// /node_modules// ]三、ES Modules深入解析基本用法// math.mjs - 导出模块// 命名导出exportfunctionadd(a,b){returnab;}exportfunctionsubtract(a,b){returna-b;}exportconstPI3.14159;// 默认导出每个模块只能有一个defaultexportdefaultfunctionmultiply(a,b){returna*b;}// app.mjs - 导入模块// 导入命名导出import{add,subtract,PI}from./math.mjs;// 导入默认导出importmultiplyfrom./math.mjs;// 同时导入默认和命名importmultiply,{add,PI}from./math.mjs;// 导入所有命名空间导入import*asmathfrom./math.mjs;console.log(math.add(1,2));console.log(math.default(3,4));// 默认导出在default属性上// 重命名导入import{addasplus}from./math.mjs;// 仅导入副作用不绑定任何变量import./polyfill.mjs;ESM与CJS的核心区别1. 静态分析 vs 运行时加载CommonJS是运行时加载可以在任何地方require// CommonJS - 可以在条件语句中requireif(process.env.NODE_ENVproduction){constprodConfigrequire(./config/prod);}else{constdevConfigrequire(./config/dev);}ESM是编译时确定依赖关系import必须在顶层// ESM - import必须在顶层不能在if语句中importprodConfigfrom./config/prod;// ✅importdevConfigfrom./config/dev;// ✅// ❌ 语法错误import不能在条件语句中if(condition){importsomethingfrom./something;}// ✅ 但可以用动态import()if(condition){import(./something).then(module{module.doSomething();});}静态分析的优势Tree Shaking摇树优化打包工具可以删除未使用的导出静态检查编辑器/IDE可以更好地做自动补全和类型检查循环依赖处理更好ESM的绑定是活的2. 值的拷贝 vs 值的绑定这是最微妙的区别// CommonJS - 值的拷贝// counter.jsletcount0;functionincrement(){count;}module.exports{count,increment};// ⚠️ 导出时count的值被拷贝后续count变化不会反映到导出值// app.jsconst{count,increment}require(./counter);console.log(count);// 0increment();console.log(count);// 还是0因为count是导入时的拷贝// ESM - 值的绑定// counter.mjsexportletcount0;exportfunctionincrement(){count;}// ✅ 导出的是绑定引用count变化会反映到导入方// app.mjsimport{count,increment}from./counter.mjs;console.log(count);// 0increment();console.log(count);// 1绑定是活的3. 顶层await// ESM支持顶层awaitNode.js 14.8// config.mjsconstresponseawaitfetch(https://api.example.com/config);exportconstconfigawaitresponse.json();// app.mjsimport{config}from./config.mjs;// 会等config.mjs的await完成console.log(config);// 已经是解析好的配置CommonJS不支持顶层await必须包裹在async函数中。动态import()即使在ESM中也可以用动态import()实现条件加载// 动态加载大模块减少启动时间asyncfunctionloadImageProcessor(){constsharpawaitimport(sharp);returnsharp.default;}// 条件加载asyncfunctiongetDatabase(){if(process.env.DB_TYPEmongodb){returnawaitimport(mongoose);}else{returnawaitimport(sequelize);}}// 懒加载路由处理函数constroutes{/users:()import(./handlers/users.js),/posts:()import(./handlers/posts.js),/auth:()import(./handlers/auth.js),};四、如何在Node.js中使用ES Modules有三种方式启用ESM方式1package.json配置{type:module}设置后所有.js文件按ESM处理。如果同时需要CJS文件用.cjs扩展名。方式2文件扩展名# .mjs文件总是按ESM处理不管package.json的typenodeapp.mjs方式3命令行参数node--input-typemodule-eimport express from express; console.log(express)五、CommonJS和ESM混用在ESM中导入CJS// ESM中导入CJS模块importexpressfromexpress;// default导入// 等价于import{defaultasexpress}fromexpress;// ⚠️ 不能对CJS模块使用命名导入import{Router}fromexpress;// ❌ 可能不工作// ✅ 正确方式importexpressfromexpress;const{Router}express;// 或者用createRequireimport{createRequire}frommodule;constrequirecreateRequire(import.meta.url);constexpressrequire(express);const{Router}express;在CJS中导入ESM// CJS中只能用动态import()导入ESMasyncfunctionmain(){const{add}awaitimport(./math.mjs);console.log(add(1,2));}main();__dirname和__filename在ESM中的替代方案// CommonJSconsole.log(__dirname);// /home/user/project/srcconsole.log(__filename);// /home/user/project/src/app.js// ESM - 没有__dirname和__filenameimport{fileURLToPath}fromurl;import{dirname,join}frompath;const__filenamefileURLToPath(import.meta.url);const__dirnamedirname(__filename);console.log(__filename);// /home/user/project/src/app.jsconsole.log(__dirname);// /home/user/project/src// 拼接路径constconfigPathjoin(__dirname,config.json);也可以写一个工具函数复用// utils/paths.jsimport{fileURLToPath}fromurl;import{dirname}frompath;exportfunctiongetDirname(importMetaUrl){returndirname(fileURLToPath(importMetaUrl));}exportfunctiongetFilename(importMetaUrl){returnfileURLToPath(importMetaUrl);}// 使用// import { getDirname } from ./utils/paths.js;// const __dirname getDirname(import.meta.url);六、2026年该选哪个推荐用ES Modules的场景新项目没有历史包袱前后端同构项目前端用ESM后端也用ESM保持一致需要Tree Shaking需要顶层await用TypeScriptTS天然支持ESM语法推荐用CommonJS的场景老项目维护大量依赖CJS生态某些包只支持CJS不想处理ESM/CJS互操作性问题实际建议2026年新项目建议用ES Modulestype: module遇到不兼容的CJS包用createRequire处理。ESM是未来趋势。️ 实战练习练习1基础题 — 模块拆分把以下代码拆分成多个模块// 把这段代码拆成// 1. utils/string.js - 字符串工具capitalize, truncate, camelCase// 2. utils/array.js - 数组工具unique, chunk, shuffle// 3. utils/math.js - 数学工具random, clamp, average// 4. utils/index.js - 统一导出barrel file// 5. app.js - 使用这些工具// 提示// - 用ES Modules语法// - utils/index.js用export * from重新导出// - app.js中只需要 import { capitalize, unique, random } from ./utils/index.js练习2进阶题 — 配置模块创建一个配置管理模块要求// config/index.js// 要求// 1. 根据NODE_ENV加载不同的配置文件dev/test/prod// 2. 支持从.env文件读取环境变量覆盖默认配置// 3. 提供getConfig(key)函数获取配置项// 4. 提供setConfig(key, value)函数动态修改配置// 5. 配置项有类型校验PORT必须是数字DB_URL必须是字符串// config/default.js - 默认配置// config/dev.js - 开发环境覆盖// config/prod.js - 生产环境覆盖练习3挑战题 — 插件系统实现一个简单的插件系统// 要求// 1. 实现PluginManager类// 2. loadPlugin(name) - 动态import插件// 3. 插件需要实现install(context)接口// 4. 插件可以注册hookbeforeRequest, afterRequest等// 5. 主程序在关键点调用hook// 6. 支持插件的依赖关系A插件依赖B插件先加载// 插件示例// plugins/logger.jsexportdefault{name:logger,dependencies:[],install(ctx){ctx.hooks.on(beforeRequest,(req){console.log([Logger]${req.method}${req.url});});}}; 面试考点Q1: require的缓存机制是什么如何强制重新加载require会缓存已加载的模块同一个模块无论require多少次只执行一次。缓存在require.cache对象中key是模块的绝对路径。要强制重新加载用delete require.cache[require.resolve(‘./module’)]删除缓存后重新require。Q2: CommonJS和ES Modules的本质区别是什么1加载时机CJS运行时加载ESM编译时确定依赖。2导出方式CJS导出值的拷贝ESM导出值的绑定引用。3语法CJS用require/module.exportsESM用import/export。4顶层await只有ESM支持。5Tree Shaking只有ESM支持因为静态分析。Q3: exports和module.exports有什么区别exports是module.exports的引用。给exports添加属性exports.a 1等同于给module.exports添加属性。但直接赋值exports {}会断开引用导致module.exports不变空对象模块导出失败。建议统一使用module.exports。Q4: 循环依赖在CommonJS中如何处理CommonJS返回模块在循环点时的不完整快照。当A require BB又require A时B拿到的A是A在require B之前已导出的部分可能是不完整的对象。解决方式延迟require在函数内部require、提取共享代码到第三方模块、或重新设计模块结构。Q5: Node.js如何查找node_modules中的模块Node.js从当前文件所在目录开始逐级向上查找node_modules目录直到根目录。查找路径存在module.paths数组中。如果都找不到抛出Cannot find module错误。 本期要点CommonJS用require/module.exports运行时加载导出值的拷贝ES Modules用import/export编译时确定导出值的绑定活的引用exports {…} 是常见陷阱必须用module.exports或exports.xxxrequire有缓存同一模块只执行一次可手动清缓存循环依赖CJS返回不完整快照ESM通过绑定解决混用方案ESM中用createRequire导入CJSCJS中用动态import()导入ESM2026年建议新项目用ESM遇到不兼容的包用createRequire处理 下期预告第05期《全局对象与processNode.js运行环境》—— 我们将探索Node.js的全局对象global、process、Buffer、console等深入理解process对象的功能环境变量、命令行参数、标准输入输出、信号处理、退出机制以及如何在生产环境中优雅地管理Node.js进程的生命周期。如果你没有苹果电脑需要上传ios到APPStore可以访问以下网站iPA上传工具 - IPA解析与AppStore提交