模块化双雄彻底搞懂 CommonJS vs ES6 Module 为什么会有两种模块规范CommonJS (CJS)诞生于 Node.js 早期旨在为服务器端提供模块化方案。它是同步的因为服务器读取本地文件很快不需要异步。ES6 Module (ESM)诞生于 ECMAScript 2015 (ES6)是 JavaScript 语言层面的标准。它设计之初就考虑了浏览器环境因此支持异步加载和静态分析。通俗比喻CommonJS像去图书馆借书你必须亲自走到书架前同步把书拿回来拷贝值。如果书的内容更新了你手里的副本不会变除非你再去借一次。ES6 Module像在线观看文档你拿到的是一个链接引用。无论文档后台怎么更新你看到的永远是最新版本。而且图书馆管理员编译器在你进门前就知道你要借哪几本书静态分析可以提前准备好。 目录⚔️ 核心差异对比表 导出与导入值拷贝 vs 实时引用⏱️ 加载时机运行时加载 vs 编译时输出 循环依赖谁更稳健 Tree Shaking为什么 ESM 更适合打包 实战如何在 Node.js 中混合使用 总结1. ⚔️ 核心差异对比表特性CommonJS (CJS)ES6 Module (ESM)标准来源Node.js 社区规范ECMA 国际标准 (ES6)语法关键字require(),module.exportsimport,export加载方式同步加载异步加载浏览器中/ 同步Node中预加载输出值类型值拷贝(Copy)实时引用(Live Binding)执行时机运行时加载代码执行到才加载编译时输出接口代码解析阶段确定依赖顶层 this指向module.exports指向undefined动态性动态路径 (require(var))静态结构 (import) 必须在顶层)Tree Shaking不支持难以静态分析原生支持2. 导出与导入值拷贝 vs 实时引用这是两者最本质的区别也是产生 Bug 的高发区。✅ CommonJS值拷贝CJS 导出的是值的副本。一旦导出模块内部的变化不会影响外部已导入的值。// counter.js (CJS)letcount0;functionincrement(){count;}module.exports{count,increment,};// main.js (CJS)constmodrequire(./counter);console.log(mod.count);// 0mod.increment();console.log(mod.count);// 0 ❌ 还是 0因为 count 是基本类型拷贝的是值 0注意如果导出的是对象拷贝的是对象的引用地址。修改对象内部的属性会生效但重新赋值整个对象则不会生效。✅ ES6 Module实时引用ESM 导出的是接口的引用。模块内部变量变化外部导入的值也会同步更新。// counter.js (ESM)exportletcount0;exportfunctionincrement(){count;}// main.js (ESM)import{count,increment}from./counter.js;console.log(count);// 0increment();console.log(count);// 1 ✅ 变成了 1因为是实时绑定3. ⏱️ 加载时机运行时加载 vs 编译时输出 CommonJS运行时加载require()是一个函数可以在代码的任何地方调用甚至可以是动态路径。// CJS 允许动态加载if(condition){constmoduleArequire(./moduleA);}else{constmoduleBrequire(./moduleB);}优点灵活支持条件加载。缺点无法在编译阶段确定依赖关系导致打包工具难以进行静态分析和优化。 ES6 Module编译时输出import和export必须是静态的位于文件顶层不能使用变量或逻辑判断。// ESM 不允许动态路径标准 importimportmoduleAfrom./moduleA;// ✅// import moduleA from variable; // ❌ 报错优点编译器可以在代码运行前就构建出完整的依赖图便于优化如 Tree Shaking。补充ESM 也提供了动态导入函数import()返回一个 Promise用于按需加载。// ESM 动态导入button.addEventListener(click,(){import(./heavyModule.js).then((module){module.doSomething();});});4. 循环依赖谁更稳健循环依赖是指 A 依赖 BB 又依赖 A。 CommonJS 的处理CJS 在加载模块时会缓存module.exports。如果出现循环依赖A 加载 B。B 加载 A。此时 A 尚未执行完module.exports可能只是一个空对象{}或部分导出的内容。B 拿到的是 A 的不完整副本。// a.jsconstbrequire(./b);console.log(In A, b is:,b);// { done: true } (如果 b 先执行完) 或 {} (如果 b 没执行完)module.exports{done:true};// b.jsconstarequire(./a);console.log(In B, a is:,a);// {} (因为 a 还没执行完导出的是初始空对象)module.exports{done:true};结果取决于加载顺序容易导致undefined错误。 ES6 Module 的处理ESM 导出的是引用。即使模块未执行完引用的指向已经建立。A 加载 B。B 加载 A。B 拿到的是 A 中变量的引用虽然此时值可能是undefined但当 A 执行完后值会更新。// a.jsimport{foo}from./b.js;console.log(In A, foo is:,foo);// undefined (因为 b 还没执行到赋值)exportletbarbar;// b.jsimport{bar}from./a.js;console.log(In B, bar is:,bar);// bar (因为 a 的 export 声明已提升引用已建立)exportletfoofoo;ESM 对循环依赖的处理更加健壮但仍建议避免循环依赖。5. Tree Shaking为什么 ESM 更适合打包Tree Shaking是指打包工具如 Webpack, Rollup, Vite移除项目中未被使用的代码。CommonJS由于require是动态的且导出的是对象副本打包工具很难在编译阶段确定哪些代码被使用了。通常只能对整个模块进行引入导致包体积较大。ES6 Module由于import/export是静态的打包工具可以清晰地知道用户只引入了utils.js中的formatDate。utils.js中的parseNumber没有被引入。结论安全地删除parseNumber的代码。现状现代前端项目几乎全部基于 ESM以享受 Tree Shaking 带来的体积优化。6. 实战如何在 Node.js 中混合使用随着 Node.js 14 对 ESM 的稳定支持我们经常需要在项目中混用两者。✅ 配置package.json{type:module}设置type: module所有.js文件被视为 ESM。如需使用 CJS需将文件后缀改为.cjs。设置type: commonjs默认所有.js文件被视为 CJS。如需使用 ESM需将文件后缀改为.mjs。✅ 在 ESM 中引入 CJS// app.mjs (ESM)importcjsModulefrom./lib.cjs;// ✅ 默认导入兼容良好// import { namedExport } from ./lib.cjs; // ❌ 通常不支持具名导入除非 CJS 模块特殊处理✅ 在 CJS 中引入 ESM// app.cjs (CJS)// ❌ 不能直接使用 require 引入 ESM// const esmModule require(./lib.mjs);// ✅ 必须使用动态 import(async(){constesmModuleawaitimport(./lib.mjs);console.log(esmModule.default);})();7. 总结场景推荐方案前端项目(Vue/React)ESM(标配支持 Tree Shaking)Node.js 新项目ESM(趋势生态正在迁移)Node.js 老项目/脚本CommonJS(兼容性好简单快捷)需要动态加载插件ESMimport()或CJSrequire() 博主寄语CommonJS 是 Node.js 辉煌的奠基者而 ES6 Module 是 JavaScript 走向标准化的未来。理解它们的区别不仅仅是为了应付面试更是为了在构建大型应用时能够做出正确的架构选型避免潜在的 Bug 和性能瓶颈。记住口诀CJS 同步拷贝值运行加载灵活性。ESM 异步引地址编译静态优打包。循环依赖 ESM 稳Tree Shaking 减体积。前端开发首选 ENode 过渡看语境。希望这篇文档能帮你彻底搞懂 CommonJS 和 ES6 Module 的区别如果有疑问欢迎在评论区留言。喜欢这篇文章吗记得点赞、收藏、转发哦❤️