☰
第04期 | 模块系统:CommonJS与ES Modules
2026/10/17 17:37:59 网站建设 项目流程

第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.exportsimport/export
加载方式运行时加载(动态)编译时确定(静态)
this指向module.exportsundefined
是否支持顶层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结束执行

分析:

  1. main.js开始加载a.js
  2. a.js执行到require('./b'),暂停a.js,开始加载b.js
  3. b.js执行到require('./a'),发现a.js在缓存中(虽然不完整),返回当前的module.exports(空对象{})
  4. b.js继续执行,完成导出{ value: 'from b' }
  5. 回到a.js,require('./b')返回完整的b模块
  6. 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错误。


📌 本期要点

  1. CommonJS用require/module.exports,运行时加载,导出值的拷贝
  2. ES Modules用import/export,编译时确定,导出值的绑定(活的引用)
  3. exports = {…} 是常见陷阱,必须用module.exports或exports.xxx
  4. require有缓存,同一模块只执行一次,可手动清缓存
  5. 循环依赖:CJS返回不完整快照,ESM通过绑定解决
  6. 混用方案:ESM中用createRequire导入CJS,CJS中用动态import()导入ESM
  7. 2026年建议新项目用ESM,遇到不兼容的包用createRequire处理

🔗 下期预告

第05期《全局对象与process:Node.js运行环境》—— 我们将探索Node.js的全局对象(global、process、Buffer、console等),深入理解process对象的功能(环境变量、命令行参数、标准输入输出、信号处理、退出机制),以及如何在生产环境中优雅地管理Node.js进程的生命周期。
如果你没有苹果电脑,需要上传ios到APPStore可以访问以下网站
iPA上传工具 - IPA解析与AppStore提交

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询