
咱们做前端开发这几年模块化这东西绕不开天天写require和import但真要说清楚 CommonJS 和 ES6 Modules 到底差在哪儿很多人其实心里是虚的。面试被问到能答上几句“一个是运行时加载一个是静态加载”真到排查线上 bug 的时候又搞不清为什么循环依赖会拿到undefined为什么打包之后代码体积突然大了为什么在 ESM 里用不了__dirname。这篇就把这两个模块系统的来龙去脉、底层机制、互相之间的坑一次讲透省得每次都得临时翻文档。这篇文章不算科普灌水而是基于我这些年写 Node 服务、搞前端工程化、把老项目从 CJS 往 ESM 迁移时踩过的真实坑总结出来的。不管是刚入行想理清概念的还是写了几年想彻底搞明白的应该都能从中捞到点干货。1. 模块化到底解决什么问题先回到“没有模块”的日子1.1 从全局变量污染说起早期 JavaScript 根本没有模块概念多个脚本文件加载到页面里本质上就是往同一个全局作用域里塞东西。A 文件里声明一个var name tomB 文件里再声明一个同名变量后者直接覆盖前者代码量一大冲突就是家常便饭。更麻烦的是依赖顺序完全靠手动控制你得在 HTML 里小心翼翼地按顺序写script标签漏一个或者顺序写错页面直接白屏。这种原始方式在页面逻辑简单时还能凑合一旦项目复杂起来维护成本就成了灾难。社区里陆续出现了各种模块化方案像 AMD、CMD、UMD都是为了解决“作用域隔离”和“依赖管理”这两个核心问题只不过各自想出的招不太一样。CommonJS 是服务端 Node.js 采用的方案ES6 Modules 则是从语言标准层面定下来的官方方案这也是今天讲的重点。1.2 “作用域隔离”与“依赖管理”这两件事模块化的本质就两件事一是把代码拆成独立的、彼此隔离的作用域外面碰不到里面的内部变量只通过显式的导出接口暴露能力二是让代码能显式声明自己依赖什么运行时或者编译时按需加载依赖不用再靠人工维护脚本顺序。这两点听起来简单但实现机制不同直接影响了加载时机、性能表现和代码可分析性。CommonJS 选择在“运行时”通过require函数加载文件同步执行、返回导出对象好处是简单直观服务端本地读文件速度极快所以不在乎同步阻塞ES6 Modules 则把 import 和 export 设计成“静态声明”在代码解析阶段就能确定依赖关系好处是工具可以静态分析代码做 tree-shaking、按需加载这类优化。这两种选择各有其历史和适用场景理解了它们各自的出发点后面那些“为什么”就都能顺下来。2. CommonJS 核心机制require 背后那些“顺理成章”的设计2.1 模块加载与缓存require 返回的到底是什么CommonJS 是 Node.js 从 2009 年左右开始采用并推广的模块规范它的核心 API 就俩require用来导入module.exports和exports用来导出。写 Node 代码的人都知道require(./utils)返回的是那个模块的导出对象。但很多人没细想过这个返回的对象其实经过了 Node 的一层缓存处理。Node 加载一个模块文件后会把module.exports的返回值缓存在require.cache里下次再用require加载同一个路径不再重新执行文件里的代码直接返回缓存里的对象。这个设计对性能很友好也保证了一个模块的副作用只在进程生命周期内执行一次。我遇到过一次真实问题一个配置模块在加载时读取了一次环境变量后来测试代码里动态修改环境变量再require同一路径时拿到的还是旧值差点被带偏思路最后翻文档才意识到是缓存的原因。从语法角度看require是个普通函数可以出现在任意位置可以传变量拼接路径甚至可以在if条件里按需加载。这种灵活性在早期确实方便但也埋下了隐患既然依赖关系是代码执行到那一行才建立的那任何静态分析工具都无法在不运行代码的情况下知道这个模块到底依赖谁。这直接决定了 CommonJS 做不了 tree-shaking——打包器想删掉没有用到的导出项前提是能确定地知道哪些导出项没被用到而 CJS 的导出对象是运行时动态生成的静态分析根本无从下手。2.2 同步加载为什么浏览器用不了 CommonJSCommonJS 的require是同步的Node 在执行require(./a)时会同步读文件、执行代码、拿到导出然后才继续往下跑。这在服务端完全没问题文件都在本地磁盘读取和执行的耗时通常微秒级同步可以保证逻辑顺序清晰不会出现“依赖还没加载完就开始用”的窗口期。但把这个模式搬到浏览器环境就不行了浏览器没法像 Node 一样从本地磁盘读文件需要通过网络请求远程加载 JS 文件同步等待就会彻底卡死页面主线程用户体验是灾难级别的。这就是为什么 CommonJS 从来没能成为浏览器端的主流方案。浏览器端要么用 AMD 那套回调式的异步加载要么像现在的打包器一样把 CJS 代码提前编译转化成浏览器能跑的形态。理解了“同步”这个点就能明白为什么早期构建工具要把 CommonJS 模块转换成别的格式才能在浏览器里跑。2.3 循环依赖时的 undefined赋值时机的坑CommonJS 处理循环依赖的方式是“执行到哪算哪”。假设 a.js 第一行require(./b)b.js 又反向require(./a)此时 a.js 的代码还没往下执行导出对象里自然还是空壳b.js 拿到的是不完整的导出。等 a.js 整个执行完b.js 里存的引用才指向完整对象。如果 b.js 在 require 返回后立刻解构取属性拿到的就是undefined。这个坑的根源在于 CJS 的“执行时赋值”导出对象里的属性是在模块代码执行过程中逐个exports.xxx ...赋上去的依赖方什么时候拿到完整对象取决于模块执行进度。提示CJS 循环依赖里凡是出现undefined优先检查是不是在模块顶层代码里直接解构引用了循环方。把解构改成“延迟到实际使用时再通过完整对象取属性”大多数情况下都能避开这个问题。3. ES6 Modules 的设计哲学从语言层面把“静态”做到底3.1 静态结构到底带来了什么ES6 Modules 在 2015 年随着 ECMAScript 6 标准推出是 JavaScript 语言自带的第一套模块规范。它在设计上走了和 CommonJS 完全不同的路线import 和 export 只能写在模块顶层不能嵌套在 if 或函数里导入导出名字必须是字面量字符串不能像require(path)一样动态拼路径。这种“死板”正是刻意为之目的是让模块的依赖关系在代码解析阶段就能完整提取出来形成一张清晰的依赖图。有了这张图工具可以做很多事打包器可以分析出哪些导出项从来没被引用过在压缩阶段把它们删掉tree-shaking浏览器可以在执行代码之前就并行下载所有依赖模块减少网络请求串行的等待时间。这些优化在 CJS 里根本推不动因为依赖关系是运行时才确定的。在实际工作中我见过有人吐槽 ES6 的静态语法限制太死不能像require那样在函数里按需加载。其实真正需要运行时判断加载路径的场景极少绝大多数情况下静态声明不但够用而且更好维护。如果真的有个别场景需要动态加载ES6 Modules 也提供了import()函数它返回一个 Promise彻底解决了按需异步加载的需求。所以设计上并非剥夺动态能力而是把“常规依赖”和“异步按需加载”这两个场景用两种语法分开各司其职。3.2 Live Bindings拿到的不是拷贝而是实时视图ES6 Modules 里有一个特别容易被人忽略的重要特性——live bindings实时绑定。CommonJS 里require拿到的导出对象是对导出值的引用视图属性值的更新可以反映到依赖方但基本类型的导出值则是一次性拷贝。ES6 Modules 完全不一样import进来的变量不是拷贝而是一个指向源模块内部变量的实时绑定源模块里改了值导入方再访问同一个变量时看到的是新值。这个机制结合顶层的 TDZ暂时性死区可以在循环依赖场景下发挥大作用。举例说明A 模块导出一个变量aB 模块 import 了这个a并在后面再赋值的场景在正常模块里很常见问题在于循环依赖时能不能拿到“将来才赋值”的值。因为 ESM 的绑定是实时指向源模块内部的只要源模块最终完成了赋值导入方在使用时就能读到正确值而不会像 CJS 那样收到早期的空壳。这个差异是 ESM 处理循环依赖比 CJS 稳健的一个重要原因。3.3 浏览器原生支持script typemodule 的异步加载ES6 Modules 在设计时就把浏览器原生支持放在核心位置。现在主流浏览器都支持script typemodule直接加载 ES Module加载过程是异步的模块依赖会按照解析出的依赖图先并行下载全部下载完成后再按依赖顺序执行。这个行为从根本上解决了同步 require 在浏览器里不可用的问题。因为模块代码默认处于严格模式顶层作用域也不会再污染全局每个模块天然自带私有作用域一套规则同时覆盖了作用域隔离和依赖管理语言层面一刀切解决。原生 ES Module 还有一个特性是“this是undefined而不是window”很多人第一次写纯 ESM 的浏览器代码时踩到this取值不对还以为是自己代码逻辑问题。理解这点就不会困惑了。同时要注意本地直接打开 HTML 文件用file://协议加载 ESM 会触发跨域限制这也是原生 ESM 的常见排查点。4. CommonJS 与 ES6 Modules 对比清单逐条说清核心差异4.1 语法、加载时机、值传递方式横向对比把两者放到一张表里看差异一目了然。对比维度CommonJSES6 Modules导入语法require(./a)import a from ./a导出语法module.exports {}/exports.xxxexport xxx/export default xxx加载时机运行时代码执行到 require 那一行才加载编译时确定依赖关系加载和解析先于执行加载方式同步加载读本地文件可异步加载浏览器并行下载依赖值传递导出值是拷贝/引用基本类型脱离绑定实时绑定live binding动态同步依赖分析不可静态分析无法 tree-shaking纯静态结构支持 tree-shaking循环依赖容易出现 undefined需要小心处理绑定实时性更好容错更强顶层 this指向模块导出对象或 sandbox 环境中的 exportsundefined严格模式默认非严格除非显式声明默认严格模式动态加载可以直接用变量拼路径支持import()异步动态加载表里有一点我得特别提一下CJS 的值传递并不是教科书写的那种“完全拷贝”。对于module.exports { obj }这种引用类型require 方拿到的仍是同一个对象引用改对象的属性两边都能看到。真正有区别的是基本类型——exports.count 1require 方解构得到count后源模块再把count改成 2require 方看到的还是 1。这种差异在实际开发中会导致一种很隐蔽的 bug两个文件共享一个计数器之类的状态源文件改了值依赖方还拿着旧值在算定位半天才发现是 CJS 的值拷贝机制在起作用。ESM 的 live binding 就不会有这个问题但反过来也意味着你要小心在导入方意外“观察到”源模块值变动带来的时序问题。4.2 为什么 tree-shaking 只对 ESM 有效tree-shaking 已经被聊烂了但我还是想多说一句因为在面试里经常能听到错误的回答。tree-shaking 的本质是“静态分析删冗余”依赖的是模块导出结构的确定性。ESM 的 import/export 语法迫使开发者把所有依赖和导出写成静态可解析的形式那么构建工具比如 Rollup、webpack 和 esbuild就可以在编译阶段分析出哪些导出项在项目里没有被任何模块引用进而把相关代码从最终产物里移除。CommonJS 呢require可以拼接路径可以写在函数内部module.exports可以被整体替换成一个全新的对象导出结构充满不确定性工具只能默认保留所有导出项自然也就没法安全删除任何可能被用到的代码。这也是为什么现在几乎所有打包工具在优化生产构建时都强烈建议大家用 ES Module 源码来做 tree-shaking 分析。有一些老包只提供 CJS 版本打包器比如 webpack 也能解析并使用它们但因为无法静态分析包里的无用导出和死代码极大概率会被原封不动打进产物里。如果你的项目依赖了比较大的 CJS 库构建出来的 bundle 体积明显比预期大基本就是这个原因。4.3 严格模式和顶层 this 带来的隐性差异除了上面那些大项ESM 和 CJS 在运行环境上还有一些容易忽略的差异。ESM 默认运行在严格模式下这意味着不能使用未声明的变量赋值函数里的this不再默认指向全局对象对象字面量里不能有重复属性等等。CJS 默认不是严格模式除非文件里手动写use strict。把老代码从 CJS 迁移到 ESM 时严格模式下会突然冒出一堆之前被宽松模式容忍的问题比如给未定义变量赋值直接抛 ReferenceError这一点我在迁移项目时踩过。顶层this的差异也很实际CJS 模块里this指向该模块的导出对象相当于一个“模块上下文”ESM 里顶层this是undefined很多同学刚切换时在模块顶层写this.xxx拿不到预期值就是这个原因。5. 实操Node.js 环境里 CJS 与 ESM 的共存、互操作和迁移5.1 文件扩展名与 package.json 的 type 字段如果你在用 Node.js 写代码文件扩展名和package.json的type字段决定了当前文件按哪套模块系统解析。默认情况下type缺省时按 CommonJS 解析文件扩展名.js也一样。想在同一个项目里按 ESM 使用需要把package.json的type值设为module这样.js文件就按 ESM 解析与此同时.cjs扩展名仍然强制按 CommonJS 解析.mjs扩展名则强制按 ES Module 解析。这就在同一个项目里实现了双模块系统共存。实际项目迁移时我通常建议把要迁移的文件统一改成.mjs或者整体把type设为module后再逐步改造避免混用.js导致一堆解析歧义问题。注意如果package.json里type是commonjs那么.js文件用import语法会直接报错SyntaxError: Cannot use import statement outside a module。这个错误大概率就是模块系统判定和你文件写法不匹配导致的。5.2 ESM 里怎么用 __dirname、__filename、requireESM 里没有__dirname和__filename这两个全局变量这是迁移时高频遇到的报错。在 ESM 中需要通过import.meta.url来获取当前模块的完整 URL 路径再用fileURLToPath转成普通路径。这个方法需要url模块配合。至于在 ESM 里用requireNode.js 允许用createRequire从module模块中创建一个 require 函数然后像在 CJS 里一样使用。这里有一个实用细节createRequire可以在 ESM 文件里构造一个基于当前文件路径的 require 函数用于加载 CJS 模块或者 JSON 文件非常方便。从 Node.js 22 开始Node 甚至原生支持在 CJS 里require(ESM)不过条件是目标 ESM 不能含顶层 await且执行是同步的。我一般不太依赖这种特性还是用import()动态导入来做 CJS 与 ESM 的桥接更稳当。5.3 CJS 和 ESM 互相导入的规则速查很多人在混用 CJS 和 ESM 时搞不清哪些导入方式是合法的尤其新人很容易把两种模块系统混在一个文件里写。我整理了一份快速对比表。场景写法是否可用CJS 模块导入 ESM 模块const mod await import(./esm.mjs)可用动态 import() 返回 PromiseCJS 模块导入 ESM 模块const mod require(./esm.mjs)Node 22 可用有同步限制ESM 模块导入 CJS 模块import mod from ./cjs.cjs可用默认导入为整个module.exportsESM 模块导入 CJS 模块import { xxx } from ./cjs.cjs可用Node 会做静态分析尝试命名导出ESM 模块导入 JSON 文件import data from ./data.json with { type: json }新版 Node 需带导入属性再说一个坑ESM 里用import直接导入 CJS 模块时默认导入返回的是整个module.exports对象。如果一个 CJS 模块用module.exports { a: 1, b: 2 }这种方式导出那import foo from ./cjs.cjs拿到的foo就等于那个对象foo.a是 1但如果 CJS 模块用的是exports.a 1; exports.b 2虽然本质上一样但 Node 的 cjs-module-lexer 能静态识别出命名导出这时import { a } from ./cjs.cjs也是能用的。如果识别不到就老老实实用默认导入再通过解构取属性。5.4 迁移老项目从 CJS 到 ESM 的渐进式改造把老项目从 CJS 迁移到 ESM我不建议一把梭快速改完最好是渐进式按模块依赖层级从低到高逐层迁移。第一步是把package.json的type改成module或者干脆把待迁移文件改成.mjs扩展名。第二步处理全局变量差异逐个搜索__dirname、__filename、require改成import.meta.url和createRequire。第三步处理循环依赖逐一测试之前可能有隐患的模块ESM 的 live binding 特性会帮上忙但前提是不能在模块顶层过早访问循环依赖的值。第四步跑全量测试重点看有没有线上才会触发的时序问题。迁移过程中我最常见的报错就是ERR_REQUIRE_ESM这个错误发生在 CJS 代码里用require加载一个 ESM 模块的旧版 Node 环境。解决办法依 Node 版本而定升级 Node 版本用require(esm)新特性或者把加载改成await import()动态导入。如果这个 require 出现在一个不方便改 async 的同步函数里那就只能再微调模块导出方式把 ESM 模块改回 CJS 格式或者封装一层 CJS 入口再被 require。6. 常见问题与排查技巧实录6.1 “Cannot use import statement outside a module” 到底是谁在报错这个报错在 Node 环境里基本就是“模块判定”问题。代码里写了import但 Node 按 CommonJS 来解析这个文件自然不认识这种语法。排查顺序很固定先看package.json的type字段如果是commonjs或者没写就把文件后缀改成.mjs或者在package.json的type改成module如果是.ts文件经过编译产物是 CJS需要改tsconfig.json的module配置支持产物输出为 ESM 格式。另外还要检查运行环境是否真的支持 ESM浏览器和旧版 Node 对 ESM 的支持差异比较大Node 12 以前基本只能靠 Babel 编译转换Node 12.20 之后才正式默认支持 ESM 语法。6.2 循环依赖定位技巧CJS undefined 与 ESM 的边界情况CJS 循环依赖出现undefined我在前面讲过常见的原因是在模块顶层直接解构了循环依赖方的导出值。排查技巧是先找模块依赖形成的环可以临时在文件顶部打印Object.keys(require.cache)辅助确认加载顺序然后把解构改成“在函数内部再通过整个对象取属性”。ESM 循环依赖虽然容错更强但也不是万能如果模块顶层执行时调用了一个来自循环依赖方且尚未初始化的绑定同样会触发 ReferenceError 或拿到undefined。比如 A 模块顶层就console.log(b)B 模块还没执行到导出赋值这里就会报 TDZ 错误。简单说顶层代码访问循环依赖的值无论在哪个系统都要非常谨慎最好的写法是把访问时机推迟到函数调用阶段。6.3 打包后体积偏大八成是 CJS 库拖了后腿如果构建产物体积明显大于预期大概率是依赖里的 CJS 模块没法做 tree-shaking所有导出都被打包器保守地保留进了最终 bundle。排查方法是用source-map-explorer或者 webpack Bundle Analyzer 看产物构成找出体积异常的大库再确认它是 ESM 还是 CJS 格式。解决方案有几种优先选择同时提供 ES Module 产物的替代包有些库通过 package.json 的module或exports字段来暴露 ESM 版本构建工具会优先取用实在换不掉可以试试按需加载或者从子路径引入减少全量导入。记住一件事所有构建工具对源码做 tree-shaking 都依赖静态可分析的 ESM这是根本。6.4 Node 版本差异导致的行为不一致同一段 ESM 代码在不同 Node 大版本下表现可能完全不同。Node 12 刚支持 ESM 时是实验特性需要带 flag 开启Node 14 开始默认可用但不稳定Node 16 之后稳定化程度已经很高Node 20/22 接着补上了 require(CJS) 加载 ESM 和更完善的互操作能力。如果你的项目要同时支持多个 Node 大版本建议在 CI 里用矩阵测试分别跑。另外注意import.meta.resolve在不同版本下的同步返回和异步返回差异这个函数可以用来做模块解析但用法在新旧版本中不一致我因为这个问题折腾过一晚上。7. 最后分享一个实际工作中的体会模块化这些东西刚接触时觉得只是语法不同用久了才发现背后是两种完全不同的工程思维。CommonJS 像传统的命令式接口每一步都按顺序执行灵活但没法做深度的静态优化ES6 Modules 像一份结构化的清单牺牲了一点运行时的灵活性换来了更清晰的结构和更强大的工具链支持。现在我写新代码通常优先用 ESM需要动态加载或兼容老库的边界场景再用import()和createRequire兜底。如果这篇文章看完你还是记不住所有细节我建议只记住这三条CJS 是运行时同步加载ESM 是编译时静态声明CJS 的基本类型导出是拷贝ESM 是实时绑定迁移项目中遇到问题先去查文件后缀、package.json 的 type 字段和 Node 版本这三大件。把这三条刻在脑子里模块化焦虑基本就消了大半剩下的边角料遇到了查文档也就几分钟的事。