
很多前端新人一听到 CommonJS 和 ES6 模块第一反应就是“背面试八股文”第二反应是“反正打包工具都帮我处理了知道个大概就行”。这两种反应我都见过不少直到有一天组里一个后端转前端的同事在 code review 时指着我的import语句问了一句“这个和require到底差在哪为什么 Node 老项目里全是require新项目又让写import”——我一时语塞才意识到自己其实也只是“会用”并没有真正“懂”。这篇内容就是专门来解决这个问题的我会把两种模块机制的来龙去脉、运行原理、混用场景和常见坑一次讲透适合所有对模块化认知还停留在“知道语法、不理解本质”阶段的人。1. 从 script 标签到模块化为什么“两套标准”这件事注定会发生前端模块化不是一个凭空设计出来的概念它是一路被真实工程问题逼出来的。理解这段演进历史你才能明白为什么社区里会同时存在 CommonJS 和 ES6 模块这两套东西而且到今天仍然共存。1.1 没有模块化的时代全局变量和加载顺序的地狱早年间写前端一个页面引入了五六个 script 标签所有 JS 文件共享同一个全局作用域。假设a.js里定义了一个var utils {...}b.js里也定义了一个var utils ...后者会把前者直接覆盖掉而且没有任何报错。更麻烦的是如果b.js的代码依赖a.js里的函数那么 script 标签的书写顺序就不能乱一旦有人调换了顺序运行时立刻给你一堆undefined is not a function。这种模式带来的问题命名冲突完全不可控所有文件都在一个全局命名空间里你没法保证第三方库不去污染全局变量。依赖关系只能靠人工约定代码里没有任何显式的声明告诉阅读者“我依赖谁”全靠 script 标签顺序暗示。无法按需加载所有 JS 不管用没用上页面一打开就全部下载并执行。当时社区出现了很多“妥协方案”比如用 IIFE 包一层作用域暴露到全局一个命名空间对象上这就是早期自制模块的雏形。但这里没有标准每个人封装的方式还不一样互相之间引用对方的模块时还是要靠全局变量本质问题没解决。1.2 Node.js 的出现与 CommonJS 的设计初衷2009 年 Node.js 问世它把 JavaScript 带到了服务端。服务端和浏览器有一个本质区别代码是要跑在本地文件系统上的。也就是说不存在“通过网络加载其他文件”这种网络延迟问题读文件几乎是瞬时完成的。Node 的社区很快意识到如果还像浏览器那样靠全局变量组织服务端代码那工程规模稍微一大就没法维护了。于是 CommonJS 规范被提出并由 Node.js 率先落地实现。它的核心设计是这样的一个文件就是一个模块模块内部的作用域是封闭的不会被外部直接访问。通过module.exports导出想要暴露的变量或函数。通过require同步加载其他模块。设计成同步加载在服务端是完全合理的因为require一个文件的 I/O 消耗跟网络加载相比约等于零。这套机制让代码的组织方式有了质的提升你终于可以在代码里显式声明“我用到了谁”了。1.3 ES6 模块浏览器环境终于等来了官方标准CommonJS 解决了服务端的问题但它有一个致命的点没法适应浏览器同步加载。浏览器脚本如果同步地require一个远程模块那页面渲染会被直接卡住体验极差。而且浏览器里你不能确定模块在哪个路径、加载要花多久、失败怎么重试这些通通需要异步处理。所以在 2015 年ES6ECMAScript 2015正式把模块化语法纳入语言标准也就是我们常说的 ES ModuleESM。它的关键词不再是require和module.exports而是import和export。语法层面是同步写的但底层加载机制是异步的由 JavaScript 引擎在编译阶段就分析出依赖关系浏览器再按依赖图去加载和执行。于是局面变成了这样Node 生态里大量历史代码和老牌 npm 包都是 CommonJS 写的而新项目前端代码普遍追随标准使用 ESM。两套体系在很长一段时间内会同时存在作为前端开发者你没法只懂其中任意一套就宣称自己“会模块化”。2. CommonJS 的运行时哲学require 本质上就是一次函数调用要真正理解 CommonJS最快的路径是看它是“怎么执行的”。CommonJS 没有什么魔法require是一个普通函数module是一个普通对象模块加载过程就是一次同步函数调用的过程。2.1 每个文件被一个函数包裹作用域隔离的真相Node 在执行一个 JS 文件时并不是直接跑你这个文件本身而是会把它包在一个函数里大致长这样(function (exports, require, module, __filename, __dirname) { // 你的代码在这里 const name hello; module.exports name; });也就是说你在文件里写的require、module、exports这些变量根本不是全局变量而是外围函数注入的参数。这也是为什么离开 Node 环境你在浏览器里直接写require会报“ReferenceError: require is not defined”——因为它本质是 Node 运行时传进来的一个局部变量。这个包裹函数的设计带来两个直接影响文件里的顶层var声明的变量作用域被限制在这个函数内不会泄漏到全局。exports只是module.exports引用的一份拷贝如果你直接给exports重新赋值它就断开了和module.exports的关联。2.2 module.exports 和 exports 那个经典“坑”这是我见过新人踩得最多的地方。看下面两段代码猜猜结果有什么不同// 写法 A exports.name hello; // 写法 B exports { name: hello };写法 A 可以正常导出{ name: hello }写法 B 导出的却是空对象。原因在前面已经提到了exports只是module.exports的引用。exports.name ...相当于修改引用所指对象上的属性所以有效。而exports ...是让局部变量exports指向了另一个新对象和module.exports没了任何关系Node 最终导出的是module.exports指向的那个对象。实操建议任何时候都用module.exports导出不要混用module.exports和exports特别是不要写一半module.exports xxx后面又用exports.yyy zzz你无法直观判断最终结果。2.3 模块缓存机制require 不会重复执行代码CommonJS 模块加载是带缓存的。同一个模块第一次被require时Node 会执行一次它的顶层代码然后将module.exports的结果缓存下来。后续再require同一个路径直接返回缓存不再执行模块内部代码。来看一个实际业务中常见的现象// counter.js let count 0; module.exports { increment() { count; }, getCount() { return count; }, };// a.js const counter require(./counter); counter.increment();// b.js const counter require(./counter); console.log(counter.getCount()); // 输出什么如果在入口文件里同时 require 了 a 和 b这里b里拿到的counter和a里的是同一个对象getCount()输出 1因为两个文件共享同一份模块实例。这一点在服务端开发中很有用它可以做全局状态共享但也是隐式的“全局变量”容易造成状态污染需要留意。2.4 缓存与循环引用的特殊性CommonJS 的循环引用处理策略依赖的就是这个缓存机制。假设a.jsrequire 了b.js而b.js又 require 了a.js执行过程大概是加载 a.js开始执行执行到require(./b)。加载 b.js开始执行 b.js 顶层代码执行到require(./a)。此时 a.js 还在第一次加载过程中并没有执行完但 Node 会检查缓存发现 a.js 已经存在一个“加载中的模块缓存记录”。于是 b.js 拿到的是 a.js 当前不完整的module.exports对象可能还是一个空对象因为 a.js 还没执行完导出语句。b.js 执行完毕返回 a.jsa.js 继续执行后面的代码。所以在 CommonJS 里循环引用不等于报错但拿到的内容可能是不完整的。如果 b.js 在加载时立刻调用了 a.js 导出的一个函数而 a.js 此时还没执行到导出那一行那就会崩溃。这种问题排查起来非常隐蔽常见解法是把模块顶层直接执行的逻辑包成函数延迟到真正被调用的时候再去执行。3. ESM 的静态解析哲学import/export 是写给编译器看的“依赖清单”ES6 模块跟 CommonJS 最大的不同在于它的设计目标从一开始就包含了“面向编译期优化”。import和export不是普通的函数调用或对象赋值而是一种声明式语法JavaScript 引擎拿到一个模块文件之后不需要执行它就能完整分析出它依赖了谁、导出了什么。3.1 静态分析能力带来的额外红利tree-shakingESM 是静态的这个特性直接催生了现代前端构建工具里的一个核心优化手段——tree-shaking。Webpack、Rollup、Vite 这些工具在打包时会先对你的模块依赖关系做静态分析把那些被 import 了但实际没有用到的导出在最终产物里直接剔除。// utils.js export function used() { return used; } export function unused() { return unused; }// main.js import { used } from ./utils.js;在上面的代码里如果打包工具开启 tree-shakingunused函数大概率不会出现在最终 bundle 里因为没有任何地方引用它而且引擎在编译期就能确定这一点。但是 CommonJS 做不到这个。因为require是运行时函数module.exports是一个普通对象打包工具没法静态地判断“这个对象的某个属性到底有没有被用到”。这也是为什么你在很多 npm 包的文档里会看到“请使用 ESM 版本以获得更好的 tree-shaking 效果”包作者会同时提供 CommonJS 和 ESM 两份产物把module字段指向 ESM 版本供打包工具使用。3.2 ESM 的模块命名空间与实时绑定ESM 导出不是“值的拷贝”而是建立了一种实时绑定关系。导入方和导出方共享同一个变量绑定导出方后续更新这个值时导入方读取到的一定是最新值。// counter.js export let count 0; export function increment() { count; }// main.js import { count, increment } from ./counter.js; console.log(count); // 0 increment(); console.log(count); // 1而不是像 CJS 那样仍然是 0同样是前面那个计数器例子换成 ESM 写法main.js里读到的count会实时变化。因为 ESM 的 import 绑定的是“活引用”CommonJS 的require是对导出对象的属性访问两者语义完全不同。从设计角度看这个实时绑定是编译期静态分析带给 ESM 的更自然的行为因为引擎在编译阶段就知道所有绑定关系根本不需要在运行阶段去做“拷贝”这个动作。3.3 ESM 的严格模式与顶层 this 之谜ES6 模块是天然严格模式的你在模块文件里不需要也不允许写use strict——写了反而多余。这一点对运行行为影响很大最典型的是this的指向在 CommonJS 模块顶层this指向module.exports。在 ESM 模块顶层this是undefined。这个差异很有迷惑性。有些老代码在 CommonJS 环境下依赖顶层的this比如this.name xxx平移成 ESM 之后会直接报错。所以做代码迁移时遇到顶层this的地方要格外小心。3.4 静态语法的“代价”条件导入被禁止ESM 的静态性让它无法做到“运行时根据条件来决定导入谁”下面这种写法直接语法报错if (isProduction) { import config from ./config.prod.js; // SyntaxError不允许 } else { import config from ./config.dev.js; }这种场景下只能改用动态import()const config await import(isProduction ? ./config.prod.js : ./config.dev.js);注意这里的import()返回的是一个 Promise需要await或者.then()来取结果。动态import()把加载时机从编译期推迟到运行期按需加载、懒加载都靠它实现Vue 里的路由懒加载和 Webpack 的动态分包背后用的就是它。4. 两套模块机制的硬核差异对照不只是“语法不一样”很多新人觉得 CommonJS 和 ESM 的区别就是require换成import、module.exports换成export如果只看到这个层面那你以后遇到的坑基本躲不掉。下面这些差异才是决定你代码行为的关键。4.1 一张表收下六个关键差异维度我不太喜欢长篇大论讲概念直接用表格把最核心的差异梳理清楚方便你对照着理解也方便以后面试前快速复习对比维度CommonJSES6 模块ESM语法require/module.exportsimport/export加载时机运行时加载模块代码执行到 require 那一刻才开始加载并执行依赖编译时静态解析依赖关系在模块代码执行前就分析完成加载方式同步加载编译期解析底层异步加载尤其浏览器环境输出方式值拷贝基本类型对象本身是共享引用实时绑定live binding始终读取最新值顶层 this指向module.exports指向undefined严格模式默认非严格默认严格循环引用可能拿到不完整的 exports容易出现 undefined 调用通过实时绑定和暂死区机制表现更稳定静态优化不支持 tree-shaking支持 tree-shaking里面最容易被忽略的是循环引用那条。ESM 的循环引用之所以表现更稳定是因为它建立了实时绑定当 b.js 在加载过程中 import 了 a.js 里的一个变量时它会拿到一个“尚未初始化的实时绑定”。如果 b.js 立刻读取这个绑定会触发暂时性死区TDZ报错但如果 b.js 只是把这个引用存在函数内部、延迟到函数被调用时才去读取那就能正常工作因为到了调用阶段 a.js 大概率已经初始化完成。4.2 从执行时机理解“为什么 CommonJS 是运行时ESM 是编译时”很多文章爱用“静态”和“动态”这对词来描述区别但对新人来说太抽象我换个角度解释。CommonJS 模块就是一个对象require是函数调用。这意味着你完全可以在条件分支里写require可以在try...catch里包require可以根据一个动态计算的路径去加载模块let lib; if (someCondition) { lib require(/path/to/a.js); } else { lib require(/path/to/b.js); }这些操作在运行时是合法且灵活的因为 Node 在执行到require()这一行的时候才真正去读取文件、执行模块代码。ESM 的import则完全不同它必须在模块顶层写出导入路径必须是静态的字符串字面量不能是拼接出来的变量。引擎在模块尚未执行前先做一遍全量扫描构建模块依赖图再按照依赖图去加载、链接模块。这种设计让工具链能在编译阶段做大量检测和优化但也牺牲了运行时的灵活性。4.3 举例理解圆形依赖下的两种结局这里我留一个完整可运行的代码示例你可以自己在 Node 里跑一下Node 可以直接用.mjs后缀跑 ESM。假设有两个模块互相引用// a.mjs import { b } from ./b.mjs; export const a a-value; console.log(a.mjs 执行完成, b);// b.mjs import { a } from ./a.mjs; export const b b-value; console.log(b.mjs 执行完成, a);在 ESM 中引擎会先深度遍历依赖把a.mjs和b.mjs的导出都链接好然后从入口开始执行。由于实时绑定的存在b.mjs 在执行时虽然 a.mjs 还没赋值完成但它的a是一个尚未初始化的绑定在初始化之前读取会报错——注意不是静默返回 undefined而是抛 ReferenceError。而在 CommonJS 版本的相同逻辑下b.js 拿到的a很可能是一个空对象因为 a.js 还没执行完导出那一行然后你在 b.js 顶层立即访问a.someProp得到的是undefined不会报错但结果不对。这就是两种机制在边界场景下的典型行为差异。5. 实战混用场景Node 端、浏览器端与打包工具的真实处理逻辑搞懂了理论差别接下来是更实际的问题我到底该怎么选项目里混合使用两种模块怎么办Node 新版本为什么可以直接跑 ESM5.1 Node.js 环境下两种模块的共存策略Node.js 从 12 版本之后开始逐步支持原生 ESM到 18/20 之后已经很成熟了。但 Node 默认把所有.js文件按 CommonJS 处理想让某个文件的某个模块按 ESM 解析有几个办法将文件后缀改成.mjsNode 明确把它当 ESM 处理。将文件后缀改成.cjsNode 明确把它当 CommonJS 处理。在项目最近的package.json中设置type: module此时.js文件默认按 ESM 处理如果想在项目内沿用 CommonJS则需要用.cjs后缀。这里有个非常典型的坑你把一个老项目的package.json加上type: module之后所有.js文件里的require全会报错ReferenceError: require is not defined。如果项目里 CJS 文件太多迁移成本不低如果只是新代码想用 ESM更稳妥的做法是保持package.json不设置 type把 ESM 文件写成.mjsCJS 文件维持.js后缀。5.2 从 ESM 导入 CommonJS 模块这是我在实际项目中用得最多的混用场景。在一个用 ESM 写的项目里引用了老牌的 npm 包它只提供了 CommonJS 的构建产物。Node 在支持原生 ESM 时提供了一个很好的兼容方案ESM 可以import一个 CommonJS 模块而且默认导入拿到的就是它的module.exports值。// cjs-module.jsCommonJS module.exports { name: hello, greet() { console.log(hi); }, };// esm-module.mjsESM 引入 CommonJS import cjsModule from ./cjs-module.js; console.log(cjsModule.name); // hello这背后的逻辑是Node 通过一个叫cjs-module-lexer的工具对 CommonJS 文件做静态分析尝试识别出module.exports上暴露了哪些属性尽量把它们映射成 ESM 的命名导出方便你写import { name } from ./cjs-module.js。但这个静态分析是有局限的如果 CommonJS 文件里用了循环赋值、动态属性、module.exports someFunction这种写法命名导出就可能识别不全。遇到这种模块稳妥的办法是用 default 导入整个对象再解构使用。5.3 从 CommonJS 导入 ESM 模块反过来从 CommonJS 里同步引入 ESM 模块是不行的。原因是 ESM 底层是异步的而 CommonJS 的require是同步的二者语义上冲突。Node 官方只允许你通过动态import()在 CommonJS 文件里引入 ESM 模块// cjs-file.cjs const esmModule await import(./esm-module.mjs); console.log(esmModule.default);注意这行代码所在文件必须是异步上下文如果是在一个普通同步函数里你需要用.then()来处理或者用async function包一层。实际业务中这个限制不常遇到但如果你在做一个工具库想同时兼容两类使用方就需要考虑这个结构。5.4 浏览器直接使用 ESMscript 标签加 type 属性现代浏览器原生支持 ESM但需要明确声明script typemodule src/src/main.js/script加了typemodule之后浏览器会按 ESM 规则解析这个文件它内部的import/export都能正常生效并且默认是延迟执行defer不会阻塞页面解析。在不支持的旧浏览器里这样写会直接忽略该 script所以生产环境几乎都会用打包工具把模块代码编译成普通脚本很少直接裸用原生 ESM。5.5 打包工具如何处理混用现在主流前端项目Vite、Webpack的入口文件都是 ESM 写法但node_modules里的依赖非常复杂CJS 和 ESM 混着来。打包工具的内部逻辑大致是入口文件按 ESM 解析建立模块依赖图。遇到 CommonJS 模块工具的解析器会做兼容转换Webpack 全家桶、Rollup 的 commonjs 插件把 CJS 模块包装成 ESM 可识别的格式。构建产物默认是 IIFE 或者 UMD 等格式最终在浏览器里根本不区分 CJS 还是 ESM。这也是为什么很多前端开发天天在用模块化但对这两套机制的感知很弱——是打包工具把底层细节全部吞掉了。不过一旦脱离打包工具比如 Node 原生环境这些细节就会全部浮出水面。6. 新人最容易踩的坑与面试高频题手写项目之外的“软实力”最后这部分我把这些年见过的、新人最容易踩的坑集中整理了一遍。很多问题不是原理层面难而是“没遇到过所以想不到”。6.1 陷阱一ESM 文件中使用 require 直接崩溃把一个 CommonJS 项目的文件后缀改成.mjs或者放在启用type: module的项目里文件里写的require会全部失效报ReferenceError: require is not defined。这不是简单的语法兼容问题而是两套模块机制本身的隔离。解决方案不是“在 ESM 里加载 require 的 polyfill”这种 hack 在部分运行时确实可行但极其不优雅而是把 CJS 文件后缀改回.cjs或者把require改成import/动态import()风格。6.2 陷阱二修改 Node 模块对象时无意间做了重复导出有些代码是从老项目抄来的看着既有module.exports ...又有exports.xxx ...在一个导出语句上下功夫结果导出的对象和预期的完全不同。再次强调任何情况下都别混用记住module.exports { ... } // 重置导出对象 exports.a 1 // 如果在这行之后修改的是新对象吗不此时 exports 可能已经和 module.exports 断开这行代码没有效果顺序不同结果也不同这种代码维护成本极高见一次改成纯module.exports风格一次。6.3 陷阱三值拷贝和实时绑定的认知错位如果你在项目里做过“模块内变量自增另一个文件读到的值却没变”的调试大概率就是遇到了 CommonJS 值拷贝的问题。基本类型如此对象类型要小心区分对象引用本身是共享的修改对象属性跨文件可见但如果你直接重新赋值module.exports newObj那其他文件已经持有的旧引用并不会更新。这种场景在写工具函数库时尤其要注意如果你期望“用户拿到的是最新状态”CJS 已经无法满足了直接用 ESM 或者暴露一个 getter 函数。6.4 面试高频题为什么 tree-shaking 对 CommonJS 不友好这道题基本是前端面试必考。回答核心思路是说清楚两点tree-shaking 需要静态分析ESM 的import和export是声明式语法依赖关系在编译期就能确定。CommonJS 的require/module.exports是运行时逻辑代码没执行之前连打包工具也不知道模块最终导出对象的形状。在面试现场可以顺手举一个require(condition ? a : b)的例子来证明 CJS 的依赖是运行时才确定的优秀的面试官会对这个理解深度印象深刻。6.5 面试高频题手写实现一个简易 require这道题考查的是你是否真的理解了 Node 模块系统的底层逻辑。根据我上面的分析一个最小实现大概要考虑四块路径解析把相对路径、绝对路径、node_modules查找逻辑处理掉。模块包装把源码包进function (exports, require, module, __filename, __dirname) {}里。缓存机制用Map记录已加载路径对应的module.exports二次 require 直接返回。循环引用处理模块实例在加载一开始就放入缓存状态是未完成后续引用方拿到的是这个未完成的实例。核心代码骨架大概是const Module require(module); const load (filePath) { const resolved resolver(filePath); if (Module._cache[resolved]) { return Module._cache[resolved].exports; } const module { exports: {} }; Module._cache[resolved] module; const wrapped Module.wrapper[0] fs.readFileSync(resolved, utf-8) Module.wrapper[1]; const compiled vm.runInThisContext(wrapped); compiled.call(module.exports, module.exports, load, module, resolved, path.dirname(resolved)); return module.exports; };这里用到了 Node 内部的Module.wrapper实际手写题你可以不用这个自己拼接一个函数字符串再 eval 也行关键是要体现出对“模块实例先入缓存再执行代码”的理解。6.6 答题组织逻辑面试时怎么说才显得有条理如果你正在准备面试我建议不要按语法背答案而是按这个逻辑组织你的回答先说 “CommonJS 是运行时加载ESM 是编译时解析” 这个本质差异再分别展开它带来的影响同步/异步、tree-shaking、循环引用表现、严格模式最后落到实际场景Node 端如何混用、浏览器如何加载。这样回答信息密度高而且每一句话都由前面的逻辑引出来不像背题。最后分享一个我自己的习惯新写的前端代码一律用 ESM因为打包工具生态、开发体验和 tree-shaking 收益都更优如果是写 Node 层的通用工具库、脚本或者需要依赖老 npm 包生态就老老实实评估一下是 CJS 还是 ESM 更省事。两套体系在未来很长一段时间还会并存与其纠结“哪个更好”不如把底层原理吃透将来在任何环境下切换都不慌。如果你在实际项目里遇到模块加载相关的诡异问题别急着查业务逻辑先检查一下当前文件是 CJS 还是 ESM 解释的很多疑难杂症就是这么定位出来的。