ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CommonJS与ES6 Module深度对比:加载机制、循环依赖与工程排雷

CommonJS与ES6 Module深度对比:加载机制、循环依赖与工程排雷 前端面试里CommonJS 和 ES6 Module 这两个词基本绕不开。很多写了三五年 JavaScript 的同学import和require都会用但真被问到底层差异、加载时机、循环依赖表现、Node.js 里怎么混用的时候经常含糊其辞。这篇我用自己的项目经历把这两套模块系统从头到尾捋一遍包括语法差异、加载机制、互操作细节以及我在实际工程里踩过的坑。不想只贴文档我会直接给结论和排雷经验适合所有写过 Node.js 或前端工程化代码的开发者。1. 两套模块规范各自从哪来解决了什么1.1 模块化之前的日子在 ES6 Module 出现之前JavaScript 在浏览器里做模块化基本靠script标签叠加。你引入一个jquery.js再引入一个utils.js它们共享同一个全局作用域后者能用前者挂到window上的变量。这种方案在小页面里没问题但是项目一大就全是问题变量名冲突没人管脚本之间依赖顺序必须手动手工维护想拆一个库出来复用都费劲更别说测试了。我当时维护过一个老门户页面十几个script标签铺在 HTML 底部谁先谁后全靠注释提示加一个功能能连环炸三四个脚本现在回想起来都心有余悸。所以大家开始想能不能像后端语言一样把代码拆成模块明确谁导入谁、谁导出什么于是浏览器端出现了 AMD、CMD 这类异步模块方案但真正让模块化成为共识的是服务器端 Node.js 选择了 CommonJS以及后来 ECMAScript 官方标准里定下的 ES6 Module。1.2 CommonJS为服务器而生CommonJS 最初的目标是让 JavaScript 在浏览器之外也能有一套模块机制。Node.js 直接沿用了这套思路。它的核心就是两个东西require负责导入module.exports或者exports.xxx负责导出。// math.js const add (a, b) a b; module.exports { add };// main.js const math require(./math.js); console.log(math.add(1, 2));这套机制非常直接模块代码会被包在一个函数里执行避免污染全局。require是一个普通函数可以写在任意位置哪怕写在if分支里也行。模块在被第一次require的时候执行一次之后结果会缓存在require.cache里重复引用拿到的都是同一个对象。这种设计非常适合当时 Node.js 的服务器场景文件就在本机磁盘上同步读取不心疼加载顺序天然可控。CommonJS 的模块加载是运行时的require执行的瞬间才去读取并执行目标模块文件前面代码执行完后面的require结果才知道。这个特点在后面的对比里很重要。1.3 ES6 Module官方标准一锤定音ES6 Module简称 ESM是 ECMAScript 2015 标准里正式定义的模块系统。它不像 CommonJS 那样是个民间共识而是语言层面的规范所以语法是声明式的。// math.js export const add (a, b) a b; export const sub (a, b) a - b;// main.js import { add, sub } from ./math.js; console.log(add(1, 2));ESM 生来就是给浏览器和服务器共同使用的设计上考虑了静态分析、异步加载、作用域隔离这些需求。浏览器里script typemodule就能直接用Node.js 从 12 版本开始也逐步原生支持。它和 CommonJS 最本质的分水岭在于ESM 的导入导出是静态结构import和export必须写在模块顶层不能塞进条件分支里。这个限制让引擎在代码执行之前就能把所有模块的依赖关系梳理清楚从而为后续的优化腾出空间。这一点后面展开讲。1.4 一张表先看总览维度CommonJSES6 Module导入导出require/module.exportsimport/export加载时机运行时编译期静态解析加载方式同步可异步值的传递基本是值拷贝/引用拷贝实时绑定live binding顶层thismodule.exportsundefined严格模式默认非严格默认严格循环依赖易出现undefined有 TDZ 风险但更可预测依赖分析难以静态分析天然支持用途Node.js 传统方案标准方案前后端通用2. 语法上的区别不只是 import 和 require2.1 各种导入导出的姿势很多同学觉得这两套系统差别就是关键词不一样其实细节挺多。先看 CommonJS 的导出方式// 方式一直接赋值 module.exports module.exports { name: cjs, version: 1.0 }; // 方式二给 exports 挂属性 exports.name cjs; exports.version 1.0; // 注意这两种方式不能混用思路exports 是 module.exports 的引用 // 一旦你写了 module.exports xxxexports 就失效了导入方式const all require(./pkg); // 拿到整个 module.exports 对象 const { name } require(./pkg); // 解构拿属性ESM 这边导出有命名导出和默认导出// 命名导出 export const name esm; export const version 1.0; // 也可以集中导出 const name2 esm2; export { name2 }; // 默认导出 export default { name: esm-default };导入相应也分几种import { name } from ./pkg.mjs; // 命名导入 import { name as alias } from ./pkg.mjs; // 重命名 import * as all from ./pkg.mjs; // 命名空间 import pkg from ./pkg.mjs; // 默认导入 import pkg, { name } from ./pkg.mjs; // 混着用注意ESM 的import语句会被提升到模块顶部所以写在哪一行其实不影响语义但规范要求它书写在顶层不能写进if、for或者函数体里。2.2 默认导出最容易绕晕ESM 的默认导出和 CommonJS 的module.exports表面看着像实际不是一回事。在 Node.js 里如果 ESM 去导入一个 CommonJS 模块import pkg from ./cjs.js拿到的就是这个 CJS 模块的module.exports这算是 Node 给的特例兼容。但如果在纯 ESM 世界里export default就是一个独立的默认导出名称和命名导出共用同一个模块命名空间。我见过很多项目把 CJS 模块通过打包器转成 ESM 之后默认导出的行为跑偏比如import React from react在有些配置下会拿到{ default: React, ... }的整个模块对象。这就是经典的esModuleInterop问题TypeScript 里不开esModuleInterop默认导入和require之间的互操作就会有偏差经常报一个函数调用不了、对象嵌套的诡异错误。2.3 一个被忽略的细节模块顶层的 this模块顶层this的值很容易被忽略。CommonJS 模块顶层this指向module.exports所以有人会写this.foo 1来间接导出。ESM 顶层this是undefined你再写this.foo 1直接报错。这背后原因是 ESM 强调模块作用域干净不挂到全局也不给顶层隐式绑定对象。ESM 还有一点模块默认处于严格模式。意味着你不能在模块里随便给未声明变量赋值delete等操作也有额外限制。从 CommonJS 迁到 ESM 时如果代码里隐式创建全局变量会一片片地报ReferenceError。3. 加载机制这才是分水岭3.1 require 是函数import 是声明CommonJS 里require就是个普通函数它遵守 JavaScript 正常的执行逻辑。你可以把require放在函数体里、循环里、甚至用变量动态拼接路径const fs require(fs); const name process.env.MODULE_NAME || default; const mod require(./mods/${name}.js); // 动态去加载这种灵活带来一个副作用静态分析工具根本不知道你最终会加载哪些模块打包器很难对你做精确的冗余消除。webpack、Rollup 在打包 CommonJS 模块时只能通过正则和启发式规则去猜依赖关系猜不对就得全量打包。而 ESM 的import是声明模块依赖关系在代码执行前就已经固定。引擎扫描一遍模块文件就知道它依赖什么、导出什么。这让三件事变成了可能并行加载多个模块、按依赖图执行而非按书写顺序盲目执行、以及打包阶段做 tree-shaking。ESM 也有动态导入的能力用import()表达式它返回一个 Promiseif (needAdmin) { const admin await import(./admin.mjs); admin.init(); }这个 API 在两个模块系统里都能用Node.js 里require()也能触发它去异步加载。区别在于 CommonJS 本来就支持动态加载而 ESM 用import()是为了补足动态场景。3.2 值拷贝 vs 实时绑定CommonJS 导入得到的是导出对象的一个引用缓存但从语法表现上它有值拷贝的意味。来看例子// counter.cjs let count 0; module.exports { count }; setTimeout(() { count 100; module.exports.count 100; }, 1000);// main.cjs const { count } require(./counter.cjs); console.log(count); // 0 setTimeout(() { console.log(count); // 还是 0解构已经把它取出来了 }, 2000);我一开始也以为require拿到的永远是对象的引用所以解构出来的count应该和模块内部同步变化结果不是。解构是一次性的取值动作模块内部后来怎么改都不会反映到已解构的变量上。但如果直接访问module.exports的属性const counter require(./counter.cjs); console.log(counter.count); // 2 秒后变成 100这才有引用语义。所以严格说 CommonJS 是导入的就是导出对象的引用但基础类型属性取出来后不会自动更新。ESM 完全不是这样。import建立的是实时绑定// counter.mjs export let count 0; setTimeout(() { count 100; }, 1000);// main.mjs import { count } from ./counter.mjs; console.log(count); // 0 setTimeout(() { console.log(count); // 100绑定是活的 }, 2000);这是因为 ESM 的导入导出之间建立的是绑定关系引擎会把导出变量的实际位置传给导入方导入方访问到的永远是当时的值不是一个拷贝。这个特性在循环依赖的场景下表现得尤为明显。3.3 循环依赖两套系统的不同表情循环依赖在大型项目里很难完全避免。CommonJS 处理循环依赖的方式很原始当 A 加载到一半发现需要 B转去加载 BB 又需要 A此时 A 还在执行中module.exports还没有被完整赋值B 拿到的就是 A 的部分导出。如果 B 在 A 导出之前就访问 A 的某个属性拿到的是undefined。// a.cjs const b require(./b.cjs); console.log(a 里打印 b:, b.name); module.exports { name: a };// b.cjs const a require(./a.cjs); console.log(b 里打印 a:, a.name); // undefined因为 a 还没执行完 module.exports { name: b };运行node a.cjs输出顺序是b 里打印 a: undefined然后 a 里打印 b: b。原因是a.cjs先执行遇到require(./b.cjs)暂停转去执行b.cjsb.cjs里require(./a.cjs)发现 a 已经在加载中于是直接返回当前不完整的module.exports空对象所以a.name是undefined。这是 CommonJS 循环依赖最常见的坑。ESM 处理循环依赖用的是实时绑定挂载。引擎在模块初始化阶段先把所有绑定关系建立起来再去执行模块体。也就是说A 导入 B 时绑定已经存在只是 B 可能还没初始化完。// a.mjs import { b } from ./b.mjs; console.log(a 里打印 b:, b); export const a a;// b.mjs import { a } from ./a.mjs; console.log(b 里打印 a:, a); export const b b;这里运行node a.mjs同样会先进入 a遇到导入 b 就去初始化 bb 里导入 a 时 a 还没初始化完。但因为绑定存在b模块里访问a会触发刚介绍过的实时绑定机制但此时a还没有初始化赋值处于暂时性死区TDZ直接访问会抛ReferenceError: Cannot access a before initialization。所以在 ESM 循环依赖里如果 A 模块的代码在 B 模块导入它之后立刻访问导出值同样会炸。但和 CommonJS 的静默 undefined相比ESM 的错误更明确而且只要你不急着在初始化阶段读取对方的值仅把引用放在函数里延迟调用循环依赖是能正常工作的。实操心得无论哪套系统循环依赖都是设计异味。能在模块初始化阶段不互相访问就尽量别访问导出函数、把读取动作放到函数体内再执行是规避循环依赖问题最有效的路。3.4 异步加载与顶层 awaitCommonJS 是同步加载天然不支持顶层await。ESM 支持顶层await也就是说await可以直接写在模块顶层// data.mjs const data await fetchData(); export default data;这给模块加载带来新的可能性模块可以异步等待资源就绪后再导出。这也是早期 Node.js 无法直接把 ESM 当作同步引入的原因之一。你在 CommonJS 里想模拟这种效果只能导出 Promise让消费方去await用起来差很多。4. Node.js 环境下的互操作实战4.1 怎么区分模块类型Node.js 从 13.2 版本开始可以正经地用 ESM但怎么判断一个.js文件是 CommonJS 还是 ESM规则是先看package.json里的type字段。type默认是commonjs.js文件就是 CommonJS如果设成module.js文件就是 ESM。除了type字段文件后缀是硬规则.cjs永远是 CommonJS.mjs永远是 ESM。我自己维护的库里为了避免混用导致的问题约定很死新写的源码全部用.mjs给老 Node 项目保留的构建产物用.cjs。这样不会因为某个依赖的package.json没写好就把一个模块的角色搞混。实际项目中最典型翻车场景就是项目package.json里没写type: module但新代码用了importNode 直接报SyntaxError: Cannot use import statement outside a module。解决方式要么加type: module要么把文件改成.mjs。4.2 ESM 里怎么导入 CommonJSNode.js 允许 ESM 导入 CommonJS 模块默认导入拿到的是整个module.exportsimport cjs from ./legacy.cjs; console.log(cjs.someFn());命名导入就微妙了。Node 内部用cjs-module-lexer去解析 CommonJS 文件尝试静态猜测它导出了哪些命名属性。像module.exports { a, b }这样手写对象字面量的基本能识别出来所以下面的写法多数时候能工作import { a } from ./legacy.cjs;但一旦 CommonJS 模块是动态组装导出的比如module.exports condition ? { a } : { b }或者直接module.exports createExports()静态分析猜不出来命名导入就会报SyntaxError: Named export a not found。保险做法是用默认导入导入整个对象再手动解构import legacy from ./legacy.cjs; const { a } legacy;注意ESM 导入 CommonJS 的时机是微妙的。CJS 模块被 ESM 引入后仍按 CommonJS 的规则执行同步加载。但如果这个 CJS 模块是被多个 ESM 模块导入Node 也会走自己的缓存机制不会重复执行。还有一个易踩的点__dirname和__filename在 ESM 里不存在。很多老代码从 CJS 改成 ESM 后第一个报错就是ReferenceError: __dirname is not defined。ESM 里用import.meta.url替代import { fileURLToPath } from node:url; import { dirname } from node:path; const __filename fileURLToPath(import.meta.url); const __dirname dirname(__filename);4.3 CommonJS 里怎么加载 ES Module传统上 CommonJS 无法同步require()一个 ESM 模块只能用动态import()// legacy.cjs (async () { const esm await import(./modern.mjs); esm.run(); })();这是因为 ESM 可能包含顶层await加载过程是异步的同步的require()语义上接不住。老版本 Node 里require()一个.mjs文件直接报ERR_REQUIRE_ESM。不过 Node.js 已经从 20.19 和 22.12 版本开始默认允许require()加载不含顶层await的 ESM 模块了。这算是一个大变化意味着很多场景下不再需要把项目整体切到 ESM 才能互相消费。前提是那个 ESM 模块不能使用顶层 await否则仍然抛错。实际项目里如果你的依赖里藏着顶层awaitrequire()依然会碰一鼻子灰。4.4 发布 npm 包如何同时支持两种如果你在维护一个 npm 包想让 CommonJS 和 ESM 的消费者都能用最规范的做法是用package.json的exports字段分别声明入口{ name: my-lib, type: module, main: ./dist/index.cjs, exports: { import: ./dist/index.mjs, require: ./dist/index.cjs } }Node 会根据消费方的模块类型自动选对应文件。构建产物用 Rollup、tsup 这类工具双格式输出即可。但这里有个著名的坑双包陷阱Dual Package Hazard。如果 ESM 和 CJS 两份入口分别执行了模块级的初始化产生了两份互相独立的状态副本同一个库在项目里可能出现两个单例导致instanceof判断失败、全局状态分裂。我做过一个 SDK里面有一个全局配置存储就因为这个双包问题出现过这边设置了 token那边读不到的诡异故障。规避方案通常是让required入口直接导入 ESM 的产物保证只有一份真实实现比如 CJS 产物只做 re-export// index.cjs module.exports require(./index.mjs);不过这会要求 Node 版本支持require(esm)或者在构建时做特殊处理。关于这个取舍我建议在发布包时把exports的default条件作为兜底别只写import和require否则某些打包器或老环境会解析不到入口。5. 工程选型与高频踩坑5.1 新项目到底选 CJS 还是 ESM放到 2025 年新项目的默认答案基本是 ESM。原因很实际官方标准、浏览器原生支持、生态里主流库全部产出 ESM、tree-shaking 效果更可控。前端构建链路里Vite、Rollup、webpack 都能把 ESM 代码打包成最终产物选 ESM 做源码格式没有理由再退回 CJS。但如果你写的是纯 Node.js 服务端代码还是得看现实条件。在几年前很多 Native 插件、部分工具链对 ESM 支持不完善被迫留在 CJS。到今天绝大多数场景切 ESM 已经没有阻碍唯一要检查的是你的依赖链里有没有老包只提供 CJS这种包在 ESM 里 import 通常也能工作因为 Node 的互操作会把module.exports当作默认导出。我的建议框架纯新项目源码用 ESM需要直接运行在低版本 Node 的脚本考虑 CJS 或经构建降级发布给外界广泛消费的工具类 npm 包做双格式导出如果你在维护一个 monorepo不同子包尽量统一格式别一会儿 CJS 一会儿 ESM否则跨包引用时互操作报错会让人崩溃。5.2 tree-shaking 到底能省多少体积tree-shaking摇树优化是 ESM 静态分析的直接红利。把没有用到的导出从打包产物里删掉减少最终体积。Rollup 是最早把这件事做得透彻的工具webpack 在production模式下默认也会开sideEffects: false配合摇树。CommonJS 能被摇树吗只能靠打包器猜。esbuild、Rollup 的 commonjs 插件会尽力做静态分析但遇到require动态拼路径、module.exports运行时赋值就老老实实把整包打进去。我优化过一个老包从 CJS 切到 ESM 源码形态后产物从 90KB 掉到 40KB就是因为原来被全部引入而没有摇掉的工具函数终于能被滚掉了。想吃到摇树红利有一点要注意如果你在 package.json 里声明了sideEffects: false就要确保你的模块确实没有副作用。如果有个模块一加载就往全局注册东西打包器可能把它当副作用保留也可能误删。我见过一个库因为错标sideEffects: false木偶组件样式文件被丢了页面样式直接崩。5.3 高频报错与解决办法速查报错信息原因解决办法Cannot use import statement outside a module文件被当作 CJS 运行但里面写了import加type: module或改成.mjsSyntaxError: Unexpected token export运行环境不支持 ESM 或按 CJS 解析检查 Node 版本和模块类型配置Cannot access a before initializationESM 循环依赖初始化前访问绑定延迟访问不在模块顶层读取对方值require is not defined in ES module scopeESM 模块里用了require改用import或createRequireERR_REQUIRE_ESM老 Node 版本在 CJS 里require()ESM改用动态import()或升级 Node__dirname is not definedESM 没有__dirname用import.meta.url配合fileURLToPathNamed export xxx not foundESM 命名导入 CJS 动态导出静态分析失败改用默认导入整个对象再解构ERR_MODULE_NOT_FOUNDESM 导入时省略了文件后缀或路径不对ESM 必须写完整文件名和扩展名还有个高频坑require(./data.json)在 CJS 里很正常但 ESM 里import data from ./data.json在某些 Node 版本需要带with { type: json }导入属性否则报ERR_IMPORT_ATTRIBUTE_MISSING。不同版本语法还不太一样建议查你目标 Node 版本对应的写法。5.4 几个来自一线的实操心得最后分享几个我在项目里逐步踩出来的习惯。第一写工具库时坚持只导出纯函数和类不导出可变全局单例。这样无论消费者用 CJS 还是 ESM都不会因为双包陷阱产生状态分裂。真需要全局状态就提供工厂函数让使用方自己创建实例。第二迁移老项目不要太激进。如果一个项目已经稳定运行且依赖里有大量 CJS 包不必为了先进全面切 ESM。可以先从新增文件开始用.mjs逐步验证互操作再全量替换。第三团队协作时把模块规则写进 lint 和 CI。我见过最离谱的事故是有人提交了一个文件后缀是.js但内容全用 ESM 语法package.json 又没配type: moduleCI 一跑全红。这类问题靠人肉检查不现实加一个 eslint 插件检查模块类型比事后救火有用多了。第四遇到循环依赖优先重构而不是急着解释。好的模块边界应该是单向依赖的A 依赖 BB 绝不反向依赖 A。如果拆不出至少保证循环引用发生在函数内部而不是模块初始化阶段。我在一次订单模块重构里把两个 service 互相调用的关系拆成事件发布/订阅循环依赖直接消失改动量不大但心智负担轻了一个量级。CommonJS 和 ES6 Module 看着只是两套写法背后的加载模型差别是整个 JavaScript 模块化演进的缩影。把两者的差异吃透你在 Node.js 开发和前端工程化里遇到的很多诡异报错都能一眼定位到根因。希望这篇笔记能帮你少走一些弯路。
返回列表