第04期 | 模块系统:CommonJS与ES Modules
🎯 学习目标
- 彻底理解CommonJS模块系统:require的加载原理、缓存机制、循环依赖处理
- 掌握ES Modules的import/export语法、静态分析优势、动态import()
- 理解两种模块系统的本质区别,知道在什么场景下选哪个
- 学会在同一项目中安全混用CommonJS和ES Modules
- 掌握Node.js模块解析算法,理解node_modules查找机制
📖 核心知识
一、为什么需要模块系统?
没有模块系统的世界是这样的:
<!-- 前端的"黑暗时代" --><scriptsrc="jquery.js"></script><scriptsrc="lodash.js"></script><scriptsrc="utils.js"></script><scriptsrc="app.js"></script>所有变量都在全局作用域,文件之间通过全局变量通信。问题显而易见:
- 命名冲突:两个文件都定义了
utils变量,后者覆盖前者 - 依赖顺序:app.js依赖utils.js,必须保证utils先加载
- 无法管理依赖:谁依赖谁?删掉一个文件会不会崩?
- 无法复用:想在其他项目复用?复制粘贴吧
模块系统解决的就是这些问题:每个文件是一个独立作用域,通过明确的接口(导入/导出)通信。
Node.js有两种模块系统:
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 语法 | require()/module.exports | import/export |
| 加载方式 | 运行时加载(动态) | 编译时确定(静态) |
| this指向 | module.exports | undefined |
| 是否支持顶层await | ❌ | ✅ |
| __dirname/__filename | ✅ 有 | ❌ 需要polyfill |
| JSON导入 | require('./data.json') | import data from './data.json'(需assert) |
| 主流场景 | Node.js传统项目 | 现代项目/前端/全栈 |
二、CommonJS深入解析
基本用法
// math.js - 导出模块functionadd(a,b){returna+b;}functionsubtract(a,b){returna-b;}// 方式1:逐个导出module.exports.add=add;module.exports.subtract=subtract;// 方式2:整体导出module.exports={add,subtract,};// 方式3:简写(推荐)exports.add=add;exports.subtract=subtract;// app.js - 导入模块constmath=require('./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.add=add;exports.subtract=subtract;为什么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.node(C++扩展) e. ./math/index.js(如果是目录) f. ./math/package.json中的main字段 4. 编译执行 把模块代码包裹在函数中: (function(exports, require, module, __filename, __dirname) { // 你的代码 }) 然后执行这个函数 5. 返回module.exports缓存机制详解
// counter.jsletcount=0;functionincrement(){count++;returncount;}functiongetCount(){returncount;}module.exports={increment,getCount};// app.jsconstcounterA=require('./counter');constcounterB=require('./counter');// 不会重新加载,返回缓存console.log(counterA===counterB);// true,是同一个对象console.log(counterA.increment());// 1console.log(counterA.increment());// 2console.log(counterB.getCount());// 2,共享同一个count变量关键认知:require是单例的。同一个模块无论被require多少次,只执行一次,后续返回缓存。
如果你想"重新加载"一个模块(清除缓存):
// 删除缓存deleterequire.cache[require.resolve('./counter')];// 重新requireconstcounterC=require('./counter');console.log(counterC.getCount());// 0,重新开始了循环依赖
CommonJS处理循环依赖的方式是"返回已执行部分的快照":
// a.jsconsole.log('a.js开始执行');constb=require('./b');console.log('a.js中b的值:',b);// b已执行,但b.value可能还没赋值console.log('a.js结束执行');module.exports={value:'from a'};// b.jsconsole.log('b.js开始执行');consta=require('./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.js
- a.js执行到
require('./b'),暂停a.js,开始加载b.js - b.js执行到
require('./a'),发现a.js在缓存中(虽然不完整),返回当前的module.exports(空对象{}) - b.js继续执行,完成导出
{ value: 'from b' } - 回到a.js,
require('./b')返回完整的b模块 - a.js继续执行,完成导出
避免循环依赖的最佳实践:
- 把共享的代码提取到第三个模块中
- 延迟require(在函数内部require而不是文件顶部)
- 重新设计模块结构,消除循环
// 延迟require示例// a.jsfunctiondoSomething(){constb=require('./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){returna+b;}exportfunctionsubtract(a,b){returna-b;}exportconstPI=3.14159;// 默认导出(每个模块只能有一个default)exportdefaultfunctionmultiply(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_ENV==='production'){constprodConfig=require('./config/prod');}else{constdevConfig=require('./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.jsletcount=0;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.mjsexportletcount=0;exportfunctionincrement(){count++;}// ✅ 导出的是绑定(引用),count变化会反映到导入方// app.mjsimport{count,increment}from'./counter.mjs';console.log(count);// 0increment();console.log(count);// 1!绑定是活的3. 顶层await
// ESM支持顶层await(Node.js 14.8+)// config.mjsconstresponse=awaitfetch('https://api.example.com/config');exportconstconfig=awaitresponse.json();// app.mjsimport{config}from'./config.mjs';// 会等config.mjs的await完成console.log(config);// 已经是解析好的配置CommonJS不支持顶层await,必须包裹在async函数中。
动态import()
即使在ESM中,也可以用动态import()实现条件加载:
// 动态加载大模块,减少启动时间asyncfunctionloadImageProcessor(){constsharp=awaitimport('sharp');returnsharp.default;}// 条件加载asyncfunctiongetDatabase(){if(process.env.DB_TYPE==='mongodb'){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:
方式1:package.json配置
{"type":"module"}设置后,所有.js文件按ESM处理。如果同时需要CJS文件,用.cjs扩展名。
方式2:文件扩展名
# .mjs文件总是按ESM处理,不管package.json的typenodeapp.mjs方式3:命令行参数
node--input-type=module-e"import express from 'express'; console.log(express)"五、CommonJS和ESM混用
在ESM中导入CJS
// ESM中导入CJS模块importexpressfrom'express';// default导入// 等价于import{defaultasexpress}from'express';// ⚠️ 不能对CJS模块使用命名导入import{Router}from'express';// ❌ 可能不工作// ✅ 正确方式importexpressfrom'express';const{Router}=express;// 或者用createRequireimport{createRequire}from'module';constrequire=createRequire(import.meta.url);constexpress=require('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}from'url';import{dirname,join}from'path';const__filename=fileURLToPath(import.meta.url);const__dirname=dirname(__filename);console.log(__filename);// /home/user/project/src/app.jsconsole.log(__dirname);// /home/user/project/src// 拼接路径constconfigPath=join(__dirname,'config.json');也可以写一个工具函数复用:
// utils/paths.jsimport{fileURLToPath}from'url';import{dirname}from'path';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
- 用TypeScript(TS天然支持ESM语法)
推荐用CommonJS的场景:
- 老项目维护
- 大量依赖CJS生态(某些包只支持CJS)
- 不想处理ESM/CJS互操作性问题
实际建议:2026年新项目建议用ES Modules("type": "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. 插件可以注册hook(beforeRequest, 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.exports,ESM用import/export。4)顶层await:只有ESM支持。5)Tree 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 B,B又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.xxx
- require有缓存,同一模块只执行一次,可手动清缓存
- 循环依赖:CJS返回不完整快照,ESM通过绑定解决
- 混用方案:ESM中用createRequire导入CJS,CJS中用动态import()导入ESM
- 2026年建议新项目用ESM,遇到不兼容的包用createRequire处理
🔗 下期预告
第05期《全局对象与process:Node.js运行环境》—— 我们将探索Node.js的全局对象(global、process、Buffer、console等),深入理解process对象的功能(环境变量、命令行参数、标准输入输出、信号处理、退出机制),以及如何在生产环境中优雅地管理Node.js进程的生命周期。
如果你没有苹果电脑,需要上传ios到APPStore可以访问以下网站
iPA上传工具 - IPA解析与AppStore提交