模块化这个话题看起来是每个前端开发都绕不开的基础课但真要细说我身边能把它讲利索的人其实不多。不少干了三五年的人写代码时照样被require和import的混用坑得头皮发麻。我们经常挂在嘴边的 CommonJS 和 ES6 Modules表面上只是两种语法实际上背后是两套完全不同生态的设计取舍。这篇文章不打算铺开讲历史教科书而是从一个纯实战角度把我这些年遇到过的模块化问题、原理层面的困惑以及迁移方案掰开揉碎说清楚。如果你正在因为“Node 环境到底该用哪个”“工程里报错Cannot use import statement outside a module”这类问题掉头发那这篇文章应该能把你的焦虑一次解决掉。1. 为什么会有两套模块体系从三个时代的痛点说起先说一个我在线下分享时经常问的问题你觉得require和import只是长得不一样吗如果你点头后面所有关于模块化的困惑都会接踵而来。这两套体系诞生的时间、服务的对象、运行的环境完全不同理解这个“出身不同”才能明白为什么工程里老有人打架。1.1 浏览器时代全局变量统治下的混乱在 ES6 之前JavaScript 这门语言本身是没有模块概念的。浏览器环境下你想在 a.js 里用 b.js 的函数唯一方式就是把两个文件都用script标签引进来并且要求 b.js 在 a.js 之前加载。变量全部挂在全局对象上互相污染、命名冲突、依赖顺序全靠人肉保证。我当时在维护一个老项目时全局变量有几十个过段时间没人说得清谁依赖谁。这种“地球村模式”必然不可持续于是社区就有人站出来搞方案了。1.2 CommonJSNode.js 生态的默契选择CommonJS 是社区提出的规范主要目标就是服务服务器端。Node.js 在设计之初就选定了它作为内置的模块方案。核心就两件事module.exports导出require导入。它的特点是同步、运行时加载。因为 Node 运行在操作系统之上模块文件都在本地磁盘读文件是很快的同步操作不涉及网络等待所以这套机制非常契合服务端场景。而且它有一种独特的缓存机制——每个模块第一次被 require 后会缓存在内存里之后再 require 同一个模块直接返回缓存结果不会重新执行模块代码。这听起来很美好但在某些场景下会给你埋下“坑”后面讲热更新和循环依赖时会展开。CommonJS 的哲学是“简单直接先跑起来再说”带着很浓的实用主义气息。1.3 ES Modules浏览器与标准化的必然翻身等到 ES2015 规范发布JavaScript 终于有了官方模块方案也就是 ES Modules简称 ESM。它跟 CommonJS 最大的不同在于ESM 是静态的。import语句必须写在模块顶层导入的模块名必须是字符串字面量。这就让引擎在代码执行前就能构建出完整的依赖关系图module graph从而做静态分析和优化。浏览器环境之前无法用 CommonJS 是因为 require 是同步的页面加载模块如果靠网络请求就会长时间阻塞渲染。ESM 天生支持异步加载浏览器可以在下载模块的时候继续干其他事这恰好解决了浏览器环境的致命痛点。从同步到异步从运行时到编译时这不是一次语法升级而是一次架构思想的换代。2. 核心差异解构语法不是表面功夫先说结论语法差异只会让你纠结require还是import真正的差异藏在加载时机、依赖分析、缓存机制和循环依赖处理逻辑里。这几项才是判断一段代码在 A 环境里没问题挪到 B 环境就爆炸的关键。2.1 语法与交互方式模块的出口和入口怎么定义CommonJS 的导出是通过给module.exports赋值实现的也能简写成exports.foo xxx。这里有个新手特别容易犯的错直接exports { foo: bar }是无效导出的因为exports只是module.exports的一个引用重新赋值等于切断了这个引用。这个坑我在面试里问了至少三年踩过的人不在少数。ESM 的导出则更直接export关键字可以导出变量、函数、类也能用export default导出匿名默认值。import 侧对应有命名导入和默认导入。需要特别注意的是ESM 的导入和导出语句是“活绑定”live binding导出的变量在导入侧会保持引用关系。什么意思就是如果导出模块里某个变量的值后续改变了导入侧看到的值也会跟着变。CommonJS 是拷贝值模块执行完了导入侧拿到的是快照。这一点在跨模块共享状态时会产生完全不同的行为表现。对比维度CommonJSES Modules语法形态require/module.exportsimport/export加载时机运行时同步编译时静态依赖分析执行到才加载引擎提前构建依赖图值传递值拷贝原始类型活绑定引用式异步支持同步为主支持异步加载静态优化不支持 tree-shaking天然支持 tree-shaking执行环境Node 原生支持现代浏览器 Node需配置表格列出来看差异就直观了。后面针对每一行我把工程里的实战影响说透。2.2 加载时机与依赖构建同步与异步的路线之争CommonJS 的模块在代码运行时才开始加载require语句写在函数体内部也是完全合法的甚至可以写在 if 判断里裸奔式的“动态加载”。这个灵活性的代价是——引擎在运行代码之前不知道项目到底依赖了谁没法做全局优化。ESM 则完全不同。它的模块依赖在解析阶段就已经确定顶层import会提升到模块顶部因此你写的 import 顺序其实对加载结果没有影响。引擎先把所有依赖模块拉下来构建完整的模块实例和依赖图然后才进入求值阶段。这个机制还带来了一个额外好处循环依赖的处理逻辑比 CommonJS 更安全。后面我专门用一个小节讲循环依赖的原因和解法。2.3 tree-shaking 和死代码消除为什么只有 ESM 能做到现在做前端工程化打包工具人人都在谈 tree-shaking。说人话就是把没用的代码从产物里抖掉。这个优化依赖“静态分析”也就是打包器得看得懂代码的导入导出关系。CommonJS 的模块语法太动态了。比如require(condition ? a.js : b.js)这种写法打包器根本不可能确定依赖图也很难安全地判断某个导出是否被使用。ESM 的import天然就是静态的每个导出项都能被精确追踪到。像 Rollup 和 webpack 这种工具其实就是从 ESM 模块上递归分析引用关系把没被引用过的导出标记为 dead code再在打包阶段剔除。我见过很多项目明明在用 ESM 语法但 tree-shaking 效果依然很差原因通常是文件被 Babel 编译成了 CommonJS或者 package 里 sideEffects 字段没正确声明导致打包器不敢动任何文件。这个点实操性极强但项目文档里很少会直接提醒你。2.4 循环依赖与加载顺序最容易暴雷的场景循环依赖是指 a.js require 了 b.jsb.js 又 require 了 a.js。在一些大型复杂项目里几乎没法完全避免。我拿个简化例子说明差异。CommonJS 下循环依赖时如果 a 先执行执行到 require b 时b 的模块还没加载完b 里如果立刻 require a此时拿到的是 a 模块不完整的 exports 对象可能是个空壳。等你访问 b 导出的某个属性时发现是 undefined却能访问另一个属性这种“薛定谔的导出”就十分折磨人。ESM 的活绑定机制能相对规避这种情况。依赖图是提前构建好的循环节点在执行时只要访问命名导出会通过绑定关系去拿实际值。只要不在模块顶层立即求值导出值而是在调用函数时才去取值循环依赖一般都能正常跑通。解法听从一句话循环依赖里尽量导出函数而非基础数值。这是我踩坑总结出来的铁律。3. 工程里最常翻车的三个实操坑位关于两套模块的差异化影响纸上谈兵说再多都不如列几个真实场景。这几个坑我在不同项目里都撞见过属于排雷大数据级别的经验。3.1 require 真的完全拿不到 import 导出的东西吗这是混用场景里问得最多的一个问题。答案很简单看环境。在 Node 环境里如果你的 main.js 是 CommonJS 风格.cjs 或包没开 type: module而某个依赖 m.js 是 ESM 风格.mjsNode 的 require 无法直接加载 .mjs 文件。但 import 却可以加载 CommonJS 模块因为 Node 在底层做了兼容转换。ESM 导入 CommonJS 时会把 module.exports 整体作为默认导出同时借助 cjs-module-lexer 工具尝试识别命名的静态导出。所以我的第一建议是项目内尽量保持一套体系不要让语言状态割裂。如果非要混用比如老代码库渐进升级连接线的方向必须是“ESM → CommonJS”单向通行反向会非常痛苦。3.2 一个文件两种语言的边界怪象还有个高频场景是 Babel/ts-node 这类工具编译后的产物。简单说源码写的是 import/export编译选项如果配的是 CommonJS最终产出的是 require/module.exports。这会造成一个怪象你在浏览器调试工具里明明看到了 import 语句但打包产物里全是 require。源代码的静态特性根本传递不到运行时。真正排查问题的时候一定得先确认——当前跑起来的分明是编译产物不是源码。我去年帮人定位一个 SSR 数据不一致的 bug最后发现原因是 ts-node 默认编译成了 CommonJS导致整棵组件树的导入时机和浏览器端完全不同步。这个坑隐蔽程度极高。3.3 动态导入与代码分割的取舍说完静态再聊动态。ESM 规范里有个动态导入语法import()在代码里任何位置都可以调用返回 Promise支持异步加载。它打破了 ESM 静态加载的限制也成了 webpack 做代码分割的基础。比如路由懒加载() import(./page.vue)这种写法就是靠动态 import 实现的。CommonJS 的 require 也能做“动态”甚至更随意但它没有 Promise 语义是同步的。在浏览器环境用 require 动态加载模块会阻塞主线程在 Node 环境则在性能热点上不宜频繁调用每次 require 都会有缓存查找开销。所以工程实践中路由懒加载用import()后端工具链用顶层静态import不会有人拿 require 去实现前端异步加载。4. 从 CommonJS 迁移到 ESM 的完整实操路径很多团队不是想不想迁移的问题是老项目实在跑不动了必须迁。但迁移的复杂度往往超预期——牵扯文件扩展名、package.json 配置、依赖兼容性、工具链更新。这一章只给落地方案照着做基本不会出大幺蛾子。4.1 先确认运行环境处在哪一层迁移前别急着敲代码第一步是明确代码最终跑在哪。纯 Node 服务端Node 从 12.17 开始对 ESM 提供了稳定支持但默认不支持。要在 package.json 里加type: module或者把文件改名成 .mjs。浏览器端不用管 type直接用script typemodule src引入。同构/SSR必须同时看清 Node 和浏览器双端配置最容易出不一致的就是这层。还有个参数值得备注Node 在加载 ESM 时__dirname和__filename这两个传统变量是不可用的。这几乎是迁移后最普遍的报错点。替代方案是import { fileURLToPath } from url import path from path const __filename fileURLToPath(import.meta.url) const __dirname path.dirname(__filename)4.2 package.json 和文件扩展名如何表态package.json 的type字段被低估了。它直接决定了.js文件被 Node 认为是 CommonJS 还是 ESM。{ name: demo-project, version: 1.0.0, type: module, exports: { .: ./src/index.js, ./utils: ./src/utils.js } }配置了type: module后所有.js文件默认是 ESM。老的 CommonJS 文件必须改成.cjs后缀才能继续使用 require。反过来不配置时默认是 CommonJS要把个别文件升级为 ESM 就改名.mjs。另外一个新手容易忽略的是exports字段。它不只是“规划入口”而是直接限制了外部能 require/import 你包的哪些路径。这有利于包作者做安全边界但也意味着老用户乱 import 内部路径的代码会立刻断掉。所以迁移时exports字段的路径规划设计要留出足够的兼容空间。4.3 迁移节奏与类型边界对照对于大型项目我不建议一次性把 require 全部替换成 import。工程上性价比最高的迁移路径是先保证文件能运行在 ESM 的“兼容模式”下再逐步清理循环依赖和状态共享的边界。代码特征迁移动作风险等级纯工具函数、无状态导出直接改成 export 命名导出低模块内保存共享可变状态保留 CommonJS 或使用依赖注入重构高存在循环依赖边界优先导出函数而非变量高依赖老式 CJS 第三方包保留 require 或通过 createRequire 桥接中文件读取、路径操作等 Node 内置模块注意 import.meta.url 替代代码低-中我见过一个比较平稳的过渡方案底层所有新代码用 ESM 写老代码维持 CommonJS中间通过 Node 的兼容层桥接。等改动面小一点再逐层替换不让迁移和能力建设抢跑整体风险会小很多。Node 里如果非要在 ESM 文件中用 require可以用createRequire来显式创建import { createRequire } from module const require createRequire(import.meta.url) const oldPackage require(./legacy-module.cjs)4.4 打包工具和 TypeScript 配置同步迁移不只是改源码后缀。TypeScript 项目里module和moduleResolution字段要对应调整。如果 target 是现代浏览器module可以设为esnext如果产物要 node 跑通常设为nodenext。这里特别容易踩坑module: commonjs配置下TS 会把import编译成require所以就算源码写得再 ESM跑出来还是老一套。类似 Vite 这种打包器默认就是 ESM 优先webpack 5 也把output.module作为一等公民支持了。这意味着工具链不用大改但配置文件的写法往往要从module.exports {...}改成export default {...}。千万别小看这一行——它能让整个配置文件从“编译后运行”变成“直接被 Node 解析”。5. 常见报错与排查技巧速查表这一节当作独立速查表都是真实调试过的高频报错。遇到问题先来这里翻一翻基本能省下半天时间。报错/现象根因解决方案Cannot use import statement outside a module文件被当作 CommonJS 解析但代码里写了 import改扩展名为 .mjs 或 .js 并在 package.json 加type: modulerequire is not defined in ES module scopeESM 环境里直接调 require用import替代或 createRequire 桥接__dirname is not definedESM 里没有该变量按 4.1 节用 import.meta.url 转换循环依赖导致 is not a functionCommonJS 缓存导致导出不完整改成 ESM、让循环边界导出函数、调整调用时机tree-shaking 失效产物巨大模块被编译为 CommonJS或者 sideEffects 配置错误确认 target 支持 ESM、检查 package.json 的 sideEffects 字段、使用纯 ESM 依赖package 入口路径被拒绝访问包的 exports 字段限制了导入路径调整包内导出规则或重新规划路径设计Node 报ERR_REQUIRE_ESMrequire 试图加载 ESM 文件改用 import 或 async import()再补一个自己的排查心得报错信息里带 “ESM” 和 “CommonJS” 字样的九成是环境判定错误而不是语法错误。先检查文件后缀和 package.json 的 type再检查编译配置最后才去查代码逻辑顺序不要搞反。6. 一些真实的工程体会这几轮摸爬滚打下来我最大的体会是不要试图让一套语法在所有环境里通吃模块化的核心永远是“明确边界”。在纯 Node 服务端项目里如果你不需要浏览器和打包的参与CommonJS 依然非常可靠社区工具链成熟度高但新项目我已经全部切到 ESM 了因为生态在往那边走上游依赖和工具链支持摆在那里硬守着老语法只会越来越别扭。在需要打包、做前端工程化的项目里ESM 是唯一理智的选择静态分析和异步加载带来的收益是实打实的。还有一个实践细节在同一个仓库里做渐进迁移时我会强制规定“文件按目录划分语言体系”比如 legacy 目录里的 .cjs 文件保持一致新业务目录里全部 .js ESM绝不允许在同一目录里混两种模块风格。这样做的目的很简单——代码审查时看到导入符号就知道文件的模块体系不用每次翻 package.json。最后再分享一个小事迁移项目时先在.mjs扩展名上把核心工具函数迁移完跑通测试再大规模推进这样串起来会非常稳。模块化的焦虑说白了就是“不确定发生了什么”的焦虑。当你把加载时机、缓存机制、依赖边界的每一个为什么都理解了这种焦虑自然就不存在了。