资讯动态

ES Module 与 CommonJS 模块系统全解:从语法到 2026 年工程实践避坑指南

发布时间:2026/10/15 15:02:44 来源:尧图企业网站定制
我真正下定决心把所有新项目切成 ES Module不是因为社区口号喊得响而是被一个线上问题逼的。那个老项目一直用 CommonJSrequire 用得飞起后来想加一个用 import 写的新功能结果编译没问题跑起来直接报Cannot use import statement outside a module。那一瞬间我才意识到ESM 和 CJS 不是“换个写法”那么简单背后完全是两套加载逻辑。后来几年我在老项目迁移、新项目搭建、给第三方库打补丁这些事上反复和模块系统打交道把 ESM 入门的语法、Node 环境下的行为、跟 CJS 互操作的边界基本都摸了一遍。这篇内容就是这些经验的整理覆盖从语法基础到 2026 年工具链配置着重讲那些文档里不会主动告诉你的坑。适合想把新项目直接写成 ESM、或者正在把老 CJS 项目逐步改造的开发者。1. ES Module 与 CommonJS两套完全不同的加载哲学1.1 CommonJS 的核心是“运行时拷贝”CommonJS 是 Node 早期搞出来的模块方案核心机制就一句话加载模块时把module.exports这个对象完整读出来然后赋值给require()的返回值。整个过程发生在运行时同步完成读到的就是一个普通对象后续再改导出方内部的值导入方不会自动跟着变。// cjs-counter.js let count 0; module.exports { getCount: () count, inc: () count, };// cjs-main.js const counter require(./cjs-counter); console.log(counter.getCount()); // 0 counter.inc(); console.log(counter.getCount()); // 1表面看上面这段没什么问题因为导出的是一组函数函数内部闭包访问的是同一个count。但如果你直接导出基本类型的值问题就来了// cjs-value.js let answer 42; module.exports answer;// cjs-main.js const answer require(./cjs-value); // 这里拿到的就是 42一个快照CJS 对基本类型就是一次性拷贝对对象类型也只是拷贝引用。这种机制很好理解也给早期 Node 生态提供了稳定基础。但它带来一个关键限制模块结构是“运行后才知道的”静态分析很难从require里提取出确定的导入关系。所以打包工具想做 tree shaking或者浏览器想提前知道依赖图在 CJS 上都比较别扭。1.2 ESM 的核心是“静态分析 活绑定”ES Module 是语言标准层面的模块系统设计目标从一开始就不只是 Node 自己的事。浏览器、Node、打包器、各种运行时都要能认。ESM 里import和export是顶层声明它们的依赖关系在代码执行之前就可以被解析出来。这也是 Vite、Rollup 这类工具做 tree shaking 的前提编译器能确定地知道某个导出有没有被用到没用到的部分可以安全剪掉。另一个重要特点是“活绑定”也就是 real-time binding。导入方拿到的是导出方的实时引用而不是拷贝。看这个例子// esm-value.js export let answer 42; export function updateAnswer(value) { answer value; }// esm-main.js import { answer, updateAnswer } from ./esm-value.js; console.log(answer); // 42 updateAnswer(100); console.log(answer); // 100这里能拿到更新后的值如果换成 CJS 的const answer require(...)这个行为就不成立。ESM 的导入名还只能是只读的不能直接给answer重新赋值否则会直接报错。这实际上是语言层面强制你遵守清晰的数据流不让你在导入方乱改导出模块的内部状态。1.3 从“为什么”看 ESM 替代 CJS 的必然性CommonJS 的诞生背景是 Node 早期没有任何模块规范所以它设计得足够简单、同步、贴近服务端场景。但它不是浏览器能原生支持的东西。浏览器如果要加载 JS最原始的方式就是一排script标签靠全局变量互相访问很容易冲突。ESM 把模块化统一到了语言里浏览器原生支持Node 也支持打包器也认。这意味着同一套代码可以在不同环境下跑不需要为了环境差异再去写一套require映射。到 2026 年Node 对 ESM 的支持已经非常成熟第三方生态里 ESM-first 的包也越来越多。虽然 CJS 不会被立刻淘汰但新项目、新库默认选择 ESM几乎已经是共识。2. ES Module 入门语法拆开讲2.1 六种 import/export 写法一次说清ESM 的语法并不复杂但花样很多。我把日常开发里最常用的几种写法全列出来方便照抄。命名导出与命名导入// util.js export function formatTime(ts) { return new Date(ts).toLocaleString(); } export const VERSION 1.0.0;// main.js import { formatTime, VERSION } from ./util.js;默认导出与默认导入// config.js export default { appName: demo, port: 3000, };// main.js import config from ./config.js;混合导入import config, { VERSION } from ./config.js;重命名导出与导入// util.js export { formatTime as fmt };// main.js import { fmt } from ./util.js;命名空间导入import * as util from ./util.js; console.log(util.formatTime(Date.now()));副作用导入import ./polyfill.js;这几种写法里最容易踩坑的是“默认导出”和“命名空间导入”同时用时默认导出的访问方式在不同环境下不完全一致。在原生 ESM 环境里import * as util from ./util.js拿到的对象带有Symbol.toStringTag并且util.default才是默认导出有些打包器会把整个模块对象直接作为default导致代码在 Node 和浏览器之间表现不一致。所以我个人建议写公共库时尽量少用默认导出多用命名导出。2.2 默认导出与命名导出选错就踩坑默认导出的好处是导入时方便不需要关注内部结构。但它在工具链和互操作里并不是最安全的选择。第一默认导出不利于 tree shaking。打包器很难静态判断默认导出对象里的哪些属性会被用到经常导致把整个对象都留下。第二默认导出的互操作语义在 Node 里有一个特殊的映射当你从 ESM 里import一个 CJS 模块时Node 会把整个module.exports作为default。这个我们后面单独讲。第三默认导出容易产生误用。比如一个包声明了export default { a, b }另一个包想把import { a } from ...用如果对方没有具名导出就直接报错。为了生态友好我更推荐写这种风格// logger.js export function log(msg) { ... } export const LEVEL info;即使只有一个功能也建议用命名导出用户导入时可以写import { log } from ./logger.js非常清晰。2.3 聚合导出、路径解析与 import.meta 边界聚合导出主要用在 index 文件里把多个模块的接口统一暴露出去// index.js export { formatTime } from ./util.js; export { default as createStore } from ./store.js;注意export * from ./module.js会转发该模块的所有命名导出但不会转发默认导出。如果你想让默认导出也透传需要手动export { default } from ./module.js。路径解析这块有个容易忽略的点原生 ESM 的导入路径必须带完整文件名不能像 CJS 那样省略扩展名。也就是说import { foo } from ./foo.js; // 正确 import { foo } from ./foo; // 浏览器和 Node 原生 ESM 会报错大部分打包器为了兼容 CJS 习惯会帮你自动补全扩展名但如果你在 Node 原生环境下跑就必须写全。这个细节在从 CJS 迁移时会非常扎手。import.meta.url是 ES Module 独有的元信息可以用来获取当前模块的 URL尤其在 Node 环境里它是替代__filename和__dirname的基础。console.log(import.meta.url);在浏览器里import.meta.url是当前脚本的完整 URL在 Node 里它是file://协议的文件路径。有了这个后面才能实现__dirname的 ESM 版本。3. 从 CJS 项目迁移到 ESM 的实操路线3.1 package.json 的 type 字段与 .mjs/.cjs 扩展名Node 判断一个.js文件是 ESM 还是 CJS规则并不复杂看最近的package.json里的type字段。如果type是module.js文件按 ESM 执行如果type是commonjs或没写.js按 CJS 执行。为了避免歧义Node 还支持两个专属扩展名.mjs永远是 ESM.cjs永远是 CJS。这个规则基本免疫package.json的影响。我建议老项目改造时不要一步到位改type而是先把新写的文件用.mjs扩展名跑起来等确认互操作没问题再整体切。这样做的好处是即使某个文件因为require或__dirname出问题影响范围也仅限于这个文件不会让整个项目一起崩。{ name: legacy-project, type: commonjs, scripts: { start: node src/main.mjs } }如果你新项目根本不存在兼容包袱那就直接{ type: module }这样所有.js文件都会按 ESM 解析。3.2 新项目初始化从第一行配置开始初始化一个 Node ESM 项目需要做的几件事先建package.json加上type: module。再写一个入口文件比如src/index.js然后正常使用import和export。{ name: esm-demo, version: 1.0.0, type: module, main: src/index.js, scripts: { start: node src/index.js } }这里最常遇到的问题就是type: module已经写了但还是报Cannot use import statement outside a module。大概率是某个文件用了.cjs扩展名或者被另一个没有type: module的目录包含。Node 是逐级向上找最近的package.json所以如果项目根目录之外还有一个package.json也可能干扰。另外如果你要在 ESM 里读取当前目录常见写法是import { fileURLToPath } from node:url; import path from node:path; const __filename fileURLToPath(import.meta.url); const __dirname path.dirname(__filename);这个写法可以“骗”过很多依赖__dirname的旧代码比如const configPath path.join(__dirname, config.json);在迁移阶段我甚至会在入口文件里先定义全局的__dirname和__filename让旧代码少动。但这只能作为过渡新代码还是应该直接面向import.meta.url设计。3.3 浏览器原生 ESM 与打包器的差异浏览器原生支持 ESM 已经很多年了用法是在script标签加typemodulescript typemodule src/src/main.js/script这样script内部可以写import浏览器会自动处理模块加载。模块默认是延迟执行的所以不需要defer。但浏览器原生 ESM 有一个比较大的限制它不能直接加载 Node 里的 CJS 包。你在 npm 里装一个纯 CJS 的库如果不经过打包器处理在浏览器里import往往不行。所以实际做前端项目时我更倾向用现代打包器比如 Vite 这类工具。它们会把 CJS 依赖转换成浏览器能跑的 ESM同时保留开发体验。原生 ES Module 更适合在以下场景使用轻量页面不需要复杂构建流程纯浏览器环境依赖很少微前端场景多个子应用通过原生模块解耦。4. ESM 与 CJS 互操作核心要点与避坑实录4.1 在 ESM 中加载 CJS默认导出映射与具名导入的边界Node 为了让两边能互通定义了一条规则在 ESM 里import一个 CJS 模块时module.exports整体会被当作默认导出。// cjs-lib.js module.exports { add(a, b) { return a b; }, version: 2.0.0, };// main.mjs import lib from ./cjs-lib.js; console.log(lib.add(1, 2)); // 3 console.log(lib.version); // 2.0.0上面的写法是稳的。很多人喜欢直接用具名导入import { add, version } from ./cjs-lib.js;这个能否成功取决于 Node 内置的cjs-module-lexer能不能静态分析出module.exports上的属性。对于上面这种静态定义的module.exports { add, version }Node 可以识别所以能成功。但如果是动态导出的// dynamic-cjs.js const result {}; if (process.env.NODE_ENV development) { result.devOnly true; } module.exports result;cjs-module-lexer可能分析不出devOnly这时候import { devOnly }就会报“Named export not found”。解决方法就是退回默认导入再用解构import lib from ./dynamic-cjs.js; const { devOnly } lib;所以从 ESM 里加载 CJS最安全的策略永远是默认导入具名导入是锦上添花不能当绝对依赖。4.2 在 CJS 中加载 ESM动态 import 是唯一出入口反过来CJS 模块里不能用require直接加载 ESM。因为 ESM 本身是异步加载体系而require是同步的。Node 直接给你抛错误Error [ERR_REQUIRE_ESM]: require() of ES Module ... not supported.解决办法是改用动态import()。动态导入返回的是一个 Promise所以在 CJS 里可以用async函数包一层// cjs-main.cjs async function loadESM() { const esmModule await import(./esm-lib.mjs); return esmModule.default; } loadESM().then((lib) { lib.run(); });这里还要注意import(./esm-lib.mjs)得到的是模块命名空间对象默认导出在.default上。如果 ESM 模块用的是命名导出直接解构命名属性就行。还有一个小技巧在 CJS 里如果你因为历史原因不能改整个模块体系但某个新依赖只有 ESM 版那么可以用“异步工厂函数”来做适配。相当于在 CJS 的外壳里包一层动态加载至少能把跨体系的调用隔离在最小范围内。4.3 __dirname 和 __filename 在 ESM 里的替代方案这个问题几乎是每个从 CJS 迁到 ESM 的人都会撞到。ESM 里根本没有__dirname、__filename这两个变量直接用会报ReferenceError。正确姿势如下import { fileURLToPath } from node:url; import path from node:path; const __filename fileURLToPath(import.meta.url); const __dirname path.dirname(__filename);如果你只需要按当前模块路径拼接文件另一个更省事的写法是import path from node:path; const moduleDir path.dirname(fileURLToPath(import.meta.url));不过还有一种更贴合import.meta思路的方法直接基于new URL()来做const configUrl new URL(./config.json, import.meta.url);在 Node 里new URL(./config.json, import.meta.url)可以得到一个完整的file://地址。如果要传给fs.readFileSync还不够得转一下import { readFileSync } from node:fs; import { fileURLToPath } from node:url; const configPath fileURLToPath(new URL(./config.json, import.meta.url)); const config JSON.parse(readFileSync(configPath, utf8));为什么推荐这种写法因为它天然支持“基于当前模块 URL 找相对路径”无论文件在磁盘上哪个位置只要import.meta.url是对的结果就正确。老代码里的path.join(__dirname, ...)也能用但每处都要先定义__dirname很烦。4.4 循环依赖快照拷贝 vs 实时绑定循环依赖在大型项目里很难完全避免两个模块互相引用非常常见。CJS 和 ESM 在循环依赖下的表现完全不同这是最容易出诡异 bug 的点。CJS 遇到循环依赖时遇到require会先把已经执行完的部分导出去。换句话说如果模块还没执行完导入方拿到的就是一个不完整的半成品对象。常见问题就是调用为undefined// a.cjs const b require(./b.cjs); exports.aValue 1; exports.useB function () { console.log(b.bValue); };// b.cjs const a require(./a.cjs); exports.bValue 2; exports.useA function () { console.log(a.aValue); // a 还没导出完aValue 可能 undefined };ESM 的实时绑定会稍微好点因为模块间通过绑定关联但也要小心“暂时性死区”// a.mjs import { bValue } from ./b.mjs; export const aValue 1; console.log(a sees, bValue);// b.mjs import { aValue } from ./a.mjs; export const bValue 2; console.log(b sees, aValue);两个模块谁先执行决定了谁访问对方时可能报错。所以解决方案不是依赖模块系统“聪明”而是尽量把公共依赖抽到独立模块消除循环。如果抽不动那就让循环中的一方延迟访问比如在运行时函数内再读对方的导出而不是在初始化阶段直接用。4.5 双包发布同时发 ESM 和 CJS 的风险如果一个库作者想同时兼容 ESM 和 CJS常规做法是在package.json里配置exports字段分别指向不同入口{ name: dual-package-demo, exports: { import: ./dist/index.mjs, require: ./dist/index.cjs } }这样 ESM 用户导入时拿到.mjsCJS 用户拿到.cjs。听起来很完美但这里有个著名的坑如果同一个应用中一部分代码通过 ESM 加载这个包另一部分通过 CJS 加载那么实际上包会被加载两次产生两份实例。两份实例会带来什么后果instanceof判断失败模块内维护的全局单例状态被拆成两份如果你在一个实例上注册了事件另一个实例完全感知不到。这个现象叫 dual package hazard。2026 年依然存在因为很多项目处在半迁移状态同一个依赖既被import又被require引用实在太常见。我自己遇到过一次一个状态管理库ESM 入口和 CJS 入口各自创建了自己的 store结果组件里useStore()拿到的 store 和手动useStore()拿到的不是同一个调试了整一下午。最后只能把应用里所有引用改成同一个模块体系问题才消失。所以我的建议是新发布的库尽量只保留 ESM 入口如果必须支持 CJS一定要在文档里提醒用户“不要在同一个进程里同时用两种方式加载”。如果是应用内部尽量避免同时混用。5. 2026 年工具链下的 ESM 配置建议5.1 选择 Node 版本与运行时的注意点到 2026 年Node 的 LTS 版本对 ESM 的支持已经非常稳定原生 ESM 在性能、错误消息、调试体验上都比前几年好太多。如果你还在用很老的版本很多问题不是代码写错而是平台不支持。我建议新项目直接把运行环境定在当前 LTS 版本上。日常开发可以用node --watch这种内置能力不需要额外装一堆监听工具。如果你写的库要同时支持 ESM 和 CJS最低支持的 Node 版本也要提前想清楚因为旧版本对exports条件导出的支持程度不同。一个容易被忽略的点是node:前缀。ESM 项目里推荐写import { readFile } from node:fs/promises;而不是import { readFile } from fs/promises;node:前缀可以明确告诉运行时这是内置模块减少解析歧义也避免某些打包器把fs当作第三方包处理。5.2 TypeScript 项目里的 ESM 配置TypeScript 在 ESM 项目里最容易出问题的地方是module和moduleResolution配置不对。如果你写的是纯 Node ESM 项目推荐这种配置{ compilerOptions: { module: nodenext, moduleResolution: nodenext, target: es2022, verbatimModuleSyntax: true } }nodenext模式会强制 TypeScript 按 Node 的真实规则处理模块文件是.mts编译成.mjs文件是.cts编译成.cjs。同时要求在导入路径里带真实扩展名这个一开始会很不习惯但它能保证编译结果在原生 Node 环境里直接跑。verbatimModuleSyntax的作用是让 TS 严格区分类型导入和值导入import type { User } from ./types.js; import { createUser } from ./service.js;没有这个选项的旧版 TS可能会在编译时自动丢弃一些类型导入导致在isolatedModules或某些转译场景下产生额外问题。从我使用体验看verbatimModuleSyntax开启后代码更干净重构时也少了很多“导入了类型但没用到”的假报错。如果你当前项目还在用module: commonjs又想向 ESM 迁移不要一次全量改。先让编译器输出.mjs确认行为一致后再改package.json的type字段。5.3 打包器选型与构建产物的 ESM 落地前端打包器主流已经全面转向 ESM 优先。例如 Vite 这类工具在开发时直接利用浏览器原生 ES Module构建时再把依赖打包成兼容产物。它好用的核心在于开发服务器能按需加载模块修改代码的反馈速度比传统的整体打包快很多。但在打包器中混用 CJS 时仍然有坑。很多老包在 npm 上是 CJS 格式打包器会做“CJS 转 ESM”的转换。大多数情况下没问题但有些包依赖动态require、module.exports覆盖等行为转换成 ESM 之后会行为异常。遇到这种依赖我的排查顺序是先看这个包有没有 ESM 入口再看它的exports字段配置是否规范。如果都没有就考虑在打包器配置里把它强制当成 CJS 处理或者用别名指向一个修过的版本。构建产物方面如果做的是库我建议提供以下内容ESM 入口比如dist/index.jsCJS 入口比如dist/index.cjsTypeScript 类型比如dist/index.d.tsexports字段明确区分import和require。对应用项目则相反不需要追求“多种模块格式”只要浏览器能加载就行。5.4 为什么互操作问题到 2026 年还没消失很多人以为 2026 年了CJS 应该彻底退场了。但现实是npm 上还有海量存量包是 CJS或者“CJS 为主、ESM 靠转换”。企业内部的旧项目也不是说改就能改很多业务核心模块仍然挂在require上。我做过一个统计某个中型项目依赖树里大约三成包还是纯 CJS。这些包也许不会影响当下运行但只要你切换到纯 ESM 的 Node 环境或者想要更激进的 tree shaking它们就会变成瓶颈。所以互操作不是过渡期的临时问题而是未来很多年还要持续面对的现实。最好的心态不是“消灭 CJS”而是理解两套体系的边界把混用控制在小范围内。6. 避坑速查与排查心得6.1 高频报错对照表报错信息常见原因解决方法Cannot use import statement outside a module.js文件没被当作 ESM检查package.json的type或把文件改成.mjsERR_REQUIRE_ESM在 CJS 里用require加载 ESM 模块改为await import()动态加载Named export xxx not foundCJS 模块的命名导出无法被静态识别改用默认导入import pkg from ...ReferenceError: __dirname is not definedESM 中没有__dirname用import.meta.url转换得到SyntaxError: Unexpected token export当前环境不支持 ESM确认运行环境和文件格式ERR_UNKNOWN_FILE_EXTENSION导入路径带了未知扩展名确认路径和 package 配置这些报错里前三个占了日常问题的大头。看到它们不要慌按表格里的方向排查基本都是有效的。6.2 一次第三方库互操问题的完整排查过程有次我在一个迁移项目里遇到一个奇怪问题某第三方库从import pkg from some-lib改成import { feature } from some-lib后直接报命名导出不存在。但这个库的文档里明明写了feature是导出的。我先查了node_modules/some-lib/package.json发现它的入口是 CJS 文件并且没有exports字段。也就是说 Node 只能靠扫描module.exports来识别具名导出。我又去看了源码发现feature是挂在module.exports的一个属性上且定义在条件分支里if (someCondition) { module.exports.feature feature; }这样的动态赋值cjs-module-lexer根本识别不到。最后我改成默认导入再解构import someLib from some-lib; const { feature } someLib;问题解决。这其实也验证了一件事不要轻信文档里的“支持 ESM 具名导入”对于 CJS 包默认导入是最稳的。文档说的是理想情况Node 静态分析能力是现实底线。6.3 我的个人避坑顺序与经验杂谈如果现在让我给团队写一份 ESM 项目规则我会把这几条放在最前面新文件一律用 ESM 语法扩展名根据项目状态选.mjs或.js。不要在一个文件里混用import和require。引入 CJS 库时优先默认导入再用解构拿属性。修改package.json的type字段时先全项目搜__dirname和require做到心里有数。发布库时同时提供 ESM 和 CJS 入口并强烈测试双包场景。还有一个小技巧如果你不确定某个文件在 Node 里会被当成 ESM 还是 CJS可以直接跑一条命令看import.meta.url是否存在。存在就是 ESM不存在就是 CJS。这个方法在排查一些莫名其妙的报错时比看配置更快。模块系统这东西刚开始接触会觉得只是语法差异等你实际迁过一个项目处理过循环依赖和双包问题才会明白它背后是运行机制、工具链、生态习惯共同作用的结果。我自己踩过不少坑也因为这些坑把机制想得更清楚了。希望这篇内容能让你在 ESM 这条路上少走几步弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑