想象一座城市里同时存在两套生活系统。一套是外卖:你随时下单,商家接到订单才开火,配菜、装盒、派送一气呵成,送来的菜装在一次性餐盒里,拿到手是什么样就是什么样。另一套是图书馆:开馆前管理员就把全馆书目录入系统,你按索引去书架取书,书被更新时索引永远指向最新版本。JavaScript 的世界里,CommonJS 就是那家外卖公司,ESM 就是那座图书馆。它们在同一个 Node 运行时里并轨运行,语法不同、加载时机不同、处理循环依赖的方式不同,甚至在顶层 this 上都各执一词。这篇文章就用这套比喻,把两大模块规范之争讲透:它们是怎么诞生的、关键差异在哪、怎么在项目中和平共处,以及如何把老项目安全地迁移到新规范。

岗位关联

理解 ESM 与 CommonJS 的差异,是前端工程化和 Node 服务端开发的刚需基本功。读完后你不仅能解释清楚两者的加载机制与互操作规则,还能在实际项目中安全混用、稳步迁移,避免循环依赖和 this 陷阱。

目录
  1. 可视化演示
  2. 诞生背景:两套系统的由来
  3. 加载机制:先看菜单 vs 边吃边点
  4. 导出引入:一个大箱子 vs 一排标签
  5. this指向:外卖员的箱子 vs 图书馆的系统身份
  6. 循环依赖:互相点单的厨房 vs 互相借书的图书馆
  7. 顶层等待:等新书入库 vs 必须包一层
  8. 混用兼容:给包裹贴上标签
  9. 迁移方案:把外卖店改造成图书馆分馆
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

观察样本均值随采样次数增加逐渐收敛到期望值的过程,切换骰子与硬币模式对比不同分布的收敛行为。

本节配有交互动效(进入视口后自动加载,约 3KB)。

大数定律:样本均值收敛 紫色曲线为累积样本均值,随采样增加趋于期望值 粉色虚线为理论期望值 E(X) lim(n→∞) X̄ₙ = E(X) 调节速度观察收敛过程

诞生背景:两套系统的由来

十几年前,JavaScript 还只是浏览器里的“堂食厨师”,只能在自己的餐厅里干活。Node.js 把厨师请进后厨做外卖,于是诞生了 CommonJS——一家只为本地商圈服务的外卖公司:随叫随到,一单一单地送。后来,ECMAScript 官方觉得外卖和堂食应该遵循同一套标准,于是推出了 ESM——一座全国联网的图书馆索引系统:开馆前编好全部书目,借书人按索引取阅。

CommonJS(简称 CJS)是 Node.js 社区在 2009 年前后自发设计的模块规范,核心 API 是 require 与 module.exports;ESM 则是 ECMAScript 2015(ES6)写入语言标准的标准模块系统,语法是 import/export。两者的目标都是“把代码拆成小文件再组装回程序”,但设计哲学完全相反:一个务实地服务当下,一个严谨地规划全局。

一个文件就是一个模块。CJS 中每个 .js 文件被 Node 包在一个函数里执行,文件里的 var/function 不会泄漏到全局;ESM 天然处于严格模式,拥有自己的模块作用域,同样隔离。不同的是:CJS 是“跑起来才知道要什么”,ESM 是“跑之前就列好清单”——这个差异决定了后面所有行为的分歧。

如今,同一座城市(Node 运行时)里,外卖公司还在运营,图书馆也已经开张。程序里两种写法并存,于是就有了这篇文章要讲的“规范之争”。

CJS⊂Node.js 生态ESM⊂ECMAScript 标准\text{CJS} \subset \text{Node.js 生态}\qquad\text{ESM} \subset \text{ECMAScript 标准}
两个集合关系式:CJS 属于 Node 生态的民间规范,ESM 属于 ECMAScript 的官方标准,地位与约束力都不同。
  1. $\text{CJS}$CommonJS 规范:CommonJS 是 Node.js 社区为服务端 JavaScript 制定的模块规范,以 require 与 module.exports 为核心 API,像一家只为本地商圈服务的外卖公司。
    CJS=CommonJS 模块规范\text{CJS} = \text{CommonJS 模块规范}
  2. $\subset$从属关系:表示“属于某个更大体系”。CJS 不是语言标准,而是 Node 生态内部的约定,出了 Node 环境就不再有意义。
    ⊂ ⇒ 从属于\subset \ \Rightarrow\ \text{从属于}
  3. $\text{ESM}$官方标准:ESM 是 ECMAScript 2015 起写入语言规范的标准模块系统,浏览器、Node、Deno 都遵循同一套 import/export 语法,像全国通用的图书馆编目规则。
    ESM⊂ECMAScript 标准\text{ESM} \subset \text{ECMAScript 标准}

加载机制:先看菜单 vs 边吃边点

把 CJS 比作外卖:你下单(require)的那一刻,商家才接单、配菜、装盒、派送。代码运行到 require 那一行,Node 才真正读取并执行目标模块文件。如果 require 写在一个 if 分支里,条件不满足就永远不加载,这就是“运行时动态加载”。

而 ESM 像图书馆:开馆前,管理员已经把全馆书目录入系统,读者按索引找书架即可。ESM 的 import 语句必须写在文件顶层,路径必须是写死的字面量,不能是变量拼接。编译器在代码运行前就能扫描出全部依赖,构建出一张完整的“依赖图”。

机制差异带来连锁反应。CJS 的 require 可以出现在任何位置、任何条件里,甚至路径可以是变量——极度灵活,但打包工具无法提前知道你到底用了哪些模块,做不了 tree-shaking(摇树优化)。ESM 的静态结构让工具链能分析出哪些导出没被用到,打包时直接剪掉,这正是现代前端工程化偏爱 ESM 的根本原因。

外卖送得再快也是一单一单地送(运行时顺序执行);图书馆的效率来自开馆前的精心编目(编译期静态分析)。你无法用外卖的零碎拼出图书馆的整体规划——因为哲学就不一样。

CJS: M(t)=require(m1)→require(m2)→⋯→require(mn)\text{CJS: } M(t) = \text{require}(m_1) \to \text{require}(m_2) \to \cdots \to \text{require}(m_n)
ESM: G=(V,E),E 在编译期确定\text{ESM: } G = (V, E),\quad E \text{ 在编译期确定}
CJS 的加载是时间序列上的逐个 require;ESM 的加载是先有一张完整的依赖图,再按图执行。
  1. $M(t)$已加载模块集合:CJS 的模块集合随执行时间 t 逐步扩张,代码跑到哪一行 require 就加载到哪个模块,像外卖一单一单地现做现送。
    M(t)={m1,m2,… }M(t) = \{ m_1, m_2, \dots \}
  2. $\to$顺序执行:require 按代码书写顺序执行,先加载先完成,模块之间天然有先后次序。
    require(m1)→require(m2)\text{require}(m_1) \to \text{require}(m_2)
  3. $G=(V,E)$依赖图:ESM 把每个模块看作顶点 V、依赖关系看作边 E,整个图在编译期搭建完成,像图书馆开馆前已录好全部书目。
    G=(V,E)G = (V, E)
  4. $E$ 编译期确定ESM 的边不允许运行时动态生成,路径必须静态写死,这是 tree-shaking 等静态优化的前提。
    E={静态依赖边}E = \{ \text{静态依赖边} \}

导出引入:一个大箱子 vs 一排标签

外卖的交付方式很简单:一个箱子装上所有东西。CJS 里模块用 module.exports 指定要送出的全部内容,require 返回的要么是一个对象(默认),要么是被整体赋值的函数、类等任意值。你拿到的是这个箱子的“一次性复印件”。

ESM 的导出更像图书馆的标签架:export 可以把每个函数、变量挂上名字(命名导出 named export),也可以用 export default 提供“默认读物”。import 时用花括号点名要哪几个,或者直接接收默认导出。整排标签清晰可见,编译器一眼就能看出哪些标签被借走、哪些没人碰。

语法示例与限制:CJS 写 const m = require('./math'),然后 m.add(...);ESM 写 import { add } from './math.js'。ESM 的 import 必须写在顶层且路径静态;CJS 的 require 本质上是一次函数调用,随处可写。ESM 还要求文件扩展名写全(.js/.mjs),CJS 可以省略扩展名按解析规则自动补全。

一个箱子 vs 一排标签,对应了“整体接收”与“按名取用”两种思维。CJS 适合快速封装,ESM 便于静态分析和按需打包。

CJS: const c=require(s)ESM: import {a,b} from s\text{CJS: } \text{const } c = \text{require}(s)\qquad\text{ESM: } \text{import } \{ a, b \} \text{ from } s
左边是整体接收一个对象,右边是按名字精确取用若干个导出,两种“取货”姿势决定了后续优化的空间。
  1. $\text{require}(s)$整体接收:CJS 的 require 返回整个导出对象(或 module.exports 被赋成的任意值),像外卖把一箱菜直接塞给你。
    const c=require(s)\text{const } c = \text{require}(s)
  2. $\text{import} \{ a, b \}$按名取用:ESM 用花括号列出需要的导出名,编译器按名字在依赖图上定位,像图书馆按索书号取对应的书。
    import {a,b} from s\text{import } \{ a, b \} \text{ from } s
  3. $s$模块路径:两边都要指明来源路径,但 CJS 允许路径是变量或条件拼接,ESM 必须是字面量字符串。
    s=模块标识符s = \text{模块标识符}

this指向:外卖员的箱子 vs 图书馆的系统身份

外卖员随身带一个配送箱,这个箱子(this)就是他的身份标识。CJS 模块文件被执行时,Node 用一层包装函数把它包起来,并以 module.exports 作为 this 调用——所以模块顶层 this 指向的就是导出对象,写 this.foo = 1 等价于 exports.foo = 1。

ESM 则默认全员严格模式,且没有包装函数。模块顶层不是任何函数的内部,this 没有意义,干脆是 undefined。想在 ESM 顶层获取“模块自己的信息”,靠的是 import.meta(比如 import.meta.url 拿到当前文件路径)。

这个差异常常导致迁移时的隐性 bug:老代码里用 this 挂全局状态,搬到 ESM 后 this 变 undefined,直接报错。正确做法是把 this.x 改写为 exports.x(CJS 风格)或显式导出(ESM 风格),而不是依赖 this 的魔法。

简言之:外卖员出门必带箱子,所以 this 有实体;图书馆员不把个人物品带上岗,顶层没有一个“我”,所以 this 是 undefined。

thisCJS=module.exportsthisESM=undefined\text{this}_{\text{CJS}} = \text{module.exports}\qquad\text{this}_{\text{ESM}} = \text{undefined}
CJS 的顶层 this 是导出对象,ESM 的顶层 this 是 undefined——“有没有一个我”是两套系统最直观的人格差异。
  1. $\text{this}_{\text{CJS}}$包装函数的 this:CJS 文件被 Node 包进一个函数,并以 module.exports 作为 this 调用,所以顶层 this 就是导出对象,写 this.x 等于写 exports.x。
    thisCJS=module.exports\text{this}_{\text{CJS}} = \text{module.exports}
  2. $\text{this}_{\text{ESM}}$没有“我”:ESM 没有包装函数且默认严格模式,顶层 this 无处可指,固定为 undefined,想拿模块信息要用 import.meta。
    thisESM=undefined\text{this}_{\text{ESM}} = \text{undefined}

循环依赖:互相点单的厨房 vs 互相借书的图书馆

两家餐厅互相点对方的外卖:A 厨师做菜需要 B 的调料,B 做菜需要 A 的锅。CJS 的处理方式是“先占位再补货”:模块开始执行时先把一个空的 exports 对象放进缓存,再一行一行执行;如果中途反向 require 到还没执行完的模块,只能拿到这个半成品空对象——读到未赋值的属性就是 undefined。

ESM 靠活绑定(live binding)化解这种尴尬。import 进来的不是一个值的快照,而是一个指向源模块导出槽位的“书架索引”。即使源模块还没执行完,只要后续赋值发生,再读就能拿到最新值。

但 ESM 也有边界:变量存在暂时性死区(TDZ),如果 B 在 A 赋值之前就真实读取了 A 导出的 const/let 变量,依然会抛 ReferenceError。因此最佳实践依然是:模块设计时尽量避免循环依赖,提取公共模块,或把互相依赖的部分放到函数体内延迟访问(函数声明有提升,可以先调用后定义)。

外卖逻辑下,你下了一单,对方厨房还在忙,你只能拿到空盒子;图书馆逻辑下,索引永远指向那本书的最新位置,等书上架了就能读到——但书架上空着的时候硬要读,也读不到。所以不是 ESM 万能,而是两套系统对“未完成”的容忍度不同。

CJS: exports.v=undefined半成品ESM: 引用(v)→v最新\text{CJS: } \text{exports}.v = \text{undefined}_{\text{半成品}}\qquad\text{ESM: } \text{引用}(v) \to v_{\text{最新}}
CJS 读到的是未赋值的半成品,ESM 的引用像一个指针,指向源模块的最新值;但指针指向空位时硬读依然会报错。
  1. $\text{exports}.v = \text{undefined}$半成品:CJS 中 A 先占位缓存,B 反向引用 A 时若赋值还没执行,读到的是 undefined。
    exports.v=undefined半成品\text{exports}.v = \text{undefined}_{\text{半成品}}
  2. $\text{引用}(v)$活绑定:ESM 的 import 更像是存储了一个“书架位置”指针,读取时动态去找源模块的最新值。
    引用(v)=指针→v\text{引用}(v) = \text{指针} \to v
  3. $v_{\text{最新}}$延迟读取:等源模块完成赋值后再读,就能拿到正确值;但在暂时性死区内提前读仍会报错。
    v最新=赋值完成后的值v_{\text{最新}} = \text{赋值完成后的值}

顶层等待:等新书入库 vs 必须包一层

图书馆迎接新书入库:书架开放前,管理员可以先等运送车到货(await 入库)再挂牌开放。ESM 支持顶层 await——直接在模块顶层写 await xxx,模块会等这个 Promise 完成再继续执行,让“模块加载时初始化异步资源”变成一件自然的事。

外卖系统没有这个能力:CJS 的 require 是同步的,顶层不能写 await。想在 CJS 里做异步初始化,通常有两种方式:包一层 async 函数(async function init(){ await ...; });或者用动态 import(),它返回 Promise,可以在任何地方 await。

典型场景:读取远程配置、连接数据库、初始化 SDK。ESM 里可以写 export const db = await initDB();CJS 里只能先导出空对象,等异步初始化完成后再填充,或者导出 Promise 让调用方自行处理。

顶层 await 是 ESM“先规划后执行”哲学的延伸:整馆书目可以等最后一本书上架;而外卖一单归一单,单子没有“等”这个动作。

ESM: await∈顶层作用域CJS: await∉顶层作用域\text{ESM: } \text{await} \in \text{顶层作用域}\qquad\text{CJS: } \text{await} \notin \text{顶层作用域}
顶层 await 是 ESM 独有的语法权限;CJS 因为 require 同步加载,顶层语法里没有 await 的位置。
  1. $\in$属于顶层:ESM 语法允许在模块最外层直接写 await,模块会等待 Promise 完成再继续,像图书馆等新书上架再开放。
    await∈顶层作用域\text{await} \in \text{顶层作用域}
  2. $\notin$不属于顶层:CJS 的 require 同步执行,顶层语法不包含 await,想用就必须包一个 async 函数。
    await∉顶层作用域\text{await} \notin \text{顶层作用域}

混用兼容:给包裹贴上标签

一个项目里既有外卖标签又有图书馆标签怎么办?Node 提供了明确的分流规则:文件扩展名就是最醒目的标签——.mjs 强制按 ESM 解析,.cjs 强制按 CJS 解析。这样你可以在同一个项目里安心混用。

没有扩展名标签的 .js 文件,由 package.json 的 "type" 字段决定:默认(无 type 或 "type": "commonjs")按 CJS 解析;"type": "module" 则按 ESM 解析。如同整栋楼的收件方式默认配置,个别房间可以用 .cjs/.mjs 覆盖。

跨规范互操作时:ESM 可以 import 一个 CJS 模块,默认导入拿到的就是它的 module.exports 整体对象;Node 还会通过 cjs-module-lexer 静态分析 CJS 的命名导出,支持部分具名导入。反过来,CJS 想加载 ESM:早期只能 dynamic import(),Node 22+ 开始支持 require(esm)(有限制)。打包工具(Vite/Webpack/Rollup)则会帮你把两边统一成一套产物。

标签贴对了,外卖和图书馆就能在同一栋楼共存:ESM 图书馆可以借阅 CJS 外卖箱里的东西(取 module.exports 整体),外卖小哥也能通过“代下单通道”(import())拿到图书馆的书。

.mjs↔ESM.cjs↔CJStype="module"⇒默认 ESM. \text{mjs} \leftrightarrow \text{ESM}\qquad. \text{cjs} \leftrightarrow \text{CJS}\qquad\text{type} = \text{"module"} \Rightarrow \text{默认 ESM}
后缀与 type 字段共同构成“标签系统”:谁走图书馆通道、谁走外卖通道,一目了然。
  1. $\leftrightarrow$一一对应:.mjs 文件强制按 ESM 解析,.cjs 强制按 CJS 解析,后缀就是模块系统的标签。
    .mjs↔ESM.cjs↔CJS. \text{mjs} \leftrightarrow \text{ESM} \qquad . \text{cjs} \leftrightarrow \text{CJS}
  2. $\text{type}$ 字段package.json 的 type 决定没有后缀标签的 .js 文件默认走哪套系统,默认值是 commonjs。
    type:"module" ∣ "commonjs"\text{type}: \text{"module"} \ | \ \text{"commonjs"}
  3. $\Rightarrow$推导关系:设置 type 为 module 后,未打标签的 .js 文件一律按 ESM 解析。
    type="module"⇒默认 ESM\text{type} = \text{"module"} \Rightarrow \text{默认 ESM}

迁移方案:把外卖店改造成图书馆分馆

把一座全是外卖店的城市改造成图书馆联网,不能一夜强拆。推荐的渐进式迁移:先给所有老文件改名 .cjs 保持 CJS 解析(或把 package.json 设为 type: module 后老文件改 .cjs),新文件直接用 .mjs;优先迁移没有依赖的“叶子模块”,一层层向内推进。

工具辅助:用 ESLint 插件(如 import/no-commonjs)提示待改写点;借助 codemod 脚本批量把 require 改成 import;构建工具(esbuild/Rollup/Vite)可以同时处理两种规范,让迁移过程中产物照常产出。反向迁移(ESM 回 CJS)则有 Babel 的 @babel/plugin-transform-modules-commonjs 兜底。

三类高频坑:__dirname/__filename 在 ESM 不存在,要用 import.meta.url 配合 fileURLToPath;动态路径 require 无法直接翻译,要改成 import() 并调整调用为异步;JSON 导入在 ESM 需要 import attributes(with { type: 'json' }),而不是 require(xxx.json)。

迁移的本质是“改标签 + 改流程”:外卖店贴上新标签(.mjs),把内部依赖理顺(不再互相点单),最后整个城市连入图书馆网络。一次只改造一层,每一层都可回归测试——这就是工业级的迁移节奏。

cost=∑i∈M(改写量i+循环风险i)\text{cost} = \sum_{i \in \mathcal{M}} \big( \text{改写量}_i + \text{循环风险}_i \big)
迁移总成本等于每个模块的改写量与循环依赖风险的累加——先把高风险环拆掉,再动手改写。
  1. $\text{cost}$总迁移成本:把每个模块的改写量与风险相加,得到整个项目的迁移工作量,用来排优先级。
    cost=总工作量\text{cost} = \text{总工作量}
  2. $\sum_{i \in \mathcal{M}}$遍历全部模块:对项目中的每个模块都要做一次评估,先迁叶子模块、无依赖的纯工具模块。
    M={项目全部模块}\mathcal{M} = \{ \text{项目全部模块} \}
  3. 改写量 + 循环风险两类主要负担——动态 require 要改成 import() 产生异步改动;循环依赖需要在迁移前拆解。
    改写量i+循环风险i\text{改写量}_i + \text{循环风险}_i

AI 应用场景

场景一:Vite 构建工程——从 CJS 打包升级到 ESM 优先

阶段:前端工程化 / 构建优化

解决的问题:传统 CJS 打包产物无法利用 tree-shaking,大项目首屏体积膨胀;依赖图是运行时动态的,难以静态剪枝。ESM 优先让 Vite 在开发期按需编译、生产期精确摇树,像图书馆开馆前的编目一样提前知道哪些书没人借。

具体案例:某中后台项目从 Webpack 4 + CJS 迁移到 Vite 5,全量依赖以 ESM 形态提供,构建产物体积下降约 35%,冷启动耗时从 8 秒降到 1 秒以内。

场景二:Node 服务端双模共存——老库不动、新代码 ESM

阶段:服务端开发 / 依赖治理

解决的问题:大型 Node 服务积累了上百个 CJS 模块,直接全量迁移风险高;“老文件 .cjs + 新文件 .mjs”让新旧代码并存,互相通过默认导入 / import() 通信,像同一栋楼里外卖和图书馆各有各的通道。

具体案例:某订单服务把新增业务模块全部用 ESM 编写,继续 require 老的 CJS 中间件;通过 type 字段统一 .js 解析为 CJS,老代码零改动上线。

场景三:AI 工具链的异步加载——按需拉起模型 SDK

阶段:AI 应用开发 / 模型服务编排

解决的问题:AI 网关进程启动时若同步加载所有模型 SDK,内存和延迟都不可接受;利用 ESM 顶层 await 与 import() 按需加载,让“配置就绪”成为可等待的模块级状态,像图书馆等新书到齐才开放对应书架。

具体案例:在 ESM 网关模块里写 export const client = await initClient(remoteConfig),进程启动时并行拉取多个远程配置,全部就绪后模块才对外提供服务。

Python 演示

python50 行
"""Python 模拟:CommonJS 快照与 ESM 活绑定的加载差异"""  class CommonJSModule:    """CommonJS 模块:exports 是导出对象,require 拿到副本"""     def __init__(self, name, values):        self.name = name        self.exports = dict(values)     def set(self, key, value):        self.exports[key] = value  class ESMModule:    """ESM 模块:内部值通过活绑定读取,永远指向最新值"""     def __init__(self, name):        self.name = name        self._values = {}     def set(self, key, value):        self._values[key] = value     def get(self, key):        return self._values[key]  def demo_snapshot():    print("--- CommonJS 快照行为 ---")    a = CommonJSModule("a", {"count": 1})    snap = a.exports                  # 相当于 const m = require("./a")    a.set("count", 2)                 # 源模块后续修改    print("导入副本计数值:", snap["count"],          "| 源模块计数值:", a.exports["count"])  def demo_live_binding():    print("--- ESM 活绑定行为 ---")    a = ESMModule("a")    read_a = lambda key: a.get(key)   # 相当于 import { count } from "./a"    a.set("count", 1)    print("第一次读取:", read_a("count"))    a.set("count", 2)    print("第二次读取:", read_a("count"))  if __name__ == "__main__":    demo_snapshot()    demo_live_binding()
目的:用两个小类分别模拟 CommonJS 的“导出快照”和 ESM 的“活绑定”,跑一遍就能直观看到:CJS 拿到副本后源模块再改也不变,ESM 每次读取都指向最新值。
  1. snap = a.exports模拟 const m = require("./a"),拿到的是导出对象本体
  2. a.set("count", 2)模拟源模块在导出之后继续修改自己的状态
  3. read_a = lambda key: a.get(key)模拟 import { count } from "./a" 的活绑定引用
  4. print("第二次读取:", read_a("count"))活绑定每次读取都重新取最新值,不像快照固定不变
  5. if __name__ == "__main__":可执行入口,依次运行两个对照实验

常见误区

误区“我用 `const m = require('./conf')` 导入了配置,之后有人改了 conf 模块里的值,我觉得 `m.count` 应该自动变成新值,结果还是旧值,是不是缓存坏了?”

正解这不是缓存问题,而是 CJS 的导出语义本就是“值的快照”:基本类型是拷贝,对象类型是引用。require 执行的是“取货”动作,货物装箱后就固定了;ESM 的 import 才是活绑定,像书架索引一样永远指向最新位置。所以 CJS 里想共享“实时状态”,应当导出对象/函数,让调用方读写对象的属性,而不是解构出基本类型。

为何常见:很多人先接触对象类型,发现改对象属性两边都能看到,就误以为所有导出都是引用;基本类型(数字/字符串)的拷贝行为暴露后反而觉得是 bug。

误区“我 `package.json` 里写了 `type: module`,所有 js 文件都按 ESM 跑了,require 全局不能用了,这总该彻底迁移完了吧?”

正解type: module 只改变 .js 文件的默认解析方式,不代表代码本身能跑。__dirname 没有了、require 没有了、JSON 导入要换新语法、顶层 this 变 undefined……这些都需要逐文件改写。而且 node_modules 里的老依赖往往是 CJS,不受你项目 type 字段影响。真正的迁移是“标签 + 语法 + 依赖”三件事同时完成。

为何常见:人们容易把“解析方式”和“代码内容”混为一谈,以为改一个配置就完成切换,忽略了 ESM 语法层面的诸多约束。

误区“既然 ESM 有活绑定,我可以在 A 里 import B、B 里 import A,反正最终都能拿到最新值,怎么引用都不怕。”

正解活绑定救的是“延迟读取”,不是“提前读取”。B 模块执行时如果立刻使用 A 尚未完成初始化的 const/let 变量,会因暂时性死区(TDZ)直接抛 ReferenceError。安全的循环依赖用法依赖“函数声明提升”或“只在调用时访问”,本质上需要严格的设计纪律。维护上依旧建议拆出公共模块、消除环。

为何常见:只看到“活绑定”这一卖点,忽略了 TDZ 的时序约束,容易把“更安全”误解成“绝对安全”。

误区“我一直在 CJS 模块里用 `this.xxx` 挂导出,书上说顶层 this 就是 exports,所以 this 和 module.exports 永远是一回事。”

正解一开始确实如此:Node 用 module.exports 作为 this 调用包装函数。但如果你写了 module.exports = function(){} 整体替换导出对象,this 仍指向旧的空对象,两者就此分家。反过来,ESM 顶层 this 是 undefined,根本没有“挂 this”这回事。规范的做法是始终显式使用 exports.x 或 module.exports,别依赖 this。

为何常见:“初始相等”被简化成“永远相等”,只有在整体重赋值这种少见操作时才暴露差异,所以很多人多年没踩到就以为不存在。

误区“Node 里 CJS 不能 `require` ESM,所以老的 CJS 项目永远用不了新的 ESM 库,只能整个项目推倒重来。”

正解旧项目不想全量迁移,但 ESM 库是可以用的:CJS 模块里写 import('esm-lib') 会返回 Promise,在 async 函数里 await 即可;Node 22+ 起 require(esm) 也不再是实验特性(对有同步入口的 ESM 模块可用)。反过来 ESM 导入 CJS 更是原生支持。两条路都通,“推倒重来”是夸张说法。

为何常见:很多人把早期 Node 版本的局限当成了永久限制,没有跟进版本演进;遇到一次 require 报错就下结论说完全不能互操作。

面试题

Q1. 下列哪种导入方式属于 CommonJS 规范?

单选题 · easy

  • A.import { a } from './mod'
  • B.const m = require('./mod')← 正确答案
  • C.const m = import('./m')
  • D.export { a } from './mod'

答案:B。CJS 用 require 返回整个导出对象,像外卖一单取一个箱子;A、C 是 ESM 的 import 语法,D 是 ESM 的再导出语法。选 B。

Q2. ESM 模块顶层的 this 指向什么?

单选题 · easy

  • A.指向浏览器 window
  • B.指向导出对象 exports
  • C.严格模式下 undefined← 正确答案
  • D.指向全局 globalThis

答案:C。ESM 没有 CJS 那样的包装函数,且默认严格模式,顶层 this 无处可指,固定为 undefined。C 是正确答案;A 是浏览器全局,B 是 CJS 的顶层 this,D 是全局对象。

Q3. 循环依赖时,CommonJS 可能遇到的问题是什么?

单选题 · easy

  • A.编译阶段直接报语法错误无法启动
  • B.读取到尚未完工赋值的半成品值← 正确答案
  • C.两个模块自动合并成同一个文件
  • D.程序进入无限循环导致内存泄漏宕机

答案:B。CJS 采用“先占位再补货”,执行到一半被反向引用时,导出对象里还没有赋值完成的属性,读到的就是 undefined 半成品,像外卖厨房还在忙时你只拿到空盒子。

Q4. 下面哪个场景必须使用动态 import() 而不是顶层 await?

单选题 · mid

  • A.ESM 模块读取远程配置文件
  • B.CJS 模块按条件异步加载模块← 正确答案
  • C.ESM 模块导入 JSON 配置文件
  • D.ESM 模块做摇树优化处理

答案:B。CJS 顶层语法不包含 await,想异步加载只能用 import() 返回 Promise 再处理。A、C 在 ESM 里可以用顶层 await 或静态 import;D 与加载方式无关。

Q5. package.json 中的 "type": "module" 作用是什么?

单选题 · mid

  • A.强制项目内所有 js 文件按 ESM 解析← 正确答案
  • B.只影响 .mjs 文件的解析方式
  • C.让 require 在 ESM 中也能直接使用
  • D.把 CommonJS 代码转换成 ESM 语法

答案:A。type: module 决定没有 .cjs/.mjs 标签的 .js 文件默认按 ESM 解析。B 错误:.mjs 本来就强制 ESM,不受 type 影响;C、D 都不是配置字段能做的事。

Q6. 从 ESM 模块中导入 CommonJS 模块时,默认导入拿到的是什么?

单选题 · mid

  • A.整个 exports 导出对象← 正确答案
  • B.取其中第一个被命名导出的值
  • C.拿到模块的默认导出函数对象值
  • D.返回一个空对象作为临时占位符

答案:A。ESM 导入 CJS 时,默认导入就是 CJS 的 module.exports 整体对象,像图书馆直接借走外卖箱里的一整箱货物。具名导入能否识别取决于 Node 的静态分析结果。

Q7. ESM 模块中,替代 __dirname 的正确写法是什么?

单选题 · hard

  • A.用 import.meta.url 转路径← 正确答案
  • B.用 __dirname 拿到目录
  • C.用 process.cwd() 拼接路径
  • D.用 require.main 取目录信息

答案:A。ESM 没有 __dirname、__filename 和 require.main,需要用 import.meta.url 拿到当前文件 URL,再用 fileURLToPath 转成路径。B、D 在 ESM 中不存在,C 得到的是进程启动目录而非文件目录。

Q8. 关于 ESM 的描述,正确的有哪些?

多选题 · mid

  • A.依赖在编译期静态解析← 正确答案
  • B.支持顶层 await 语法← 正确答案
  • C.导出值是活绑定的最新值← 正确答案
  • D.允许通过变量拼接路径导入

答案:ABC。A、B、C 都是 ESM 的核心特性:编译期静态解析、顶层 await、活绑定。D 错误——ESM 的 import 路径必须是静态字面量,不能变量拼接,这正是它区别于 CJS 动态 require 的关键。

Q9. 在 Node 项目中 CJS 与 ESM 混用时,正确的做法有哪些?

多选题 · hard

  • A.ESM 中可以正常导入 CJS 模块文件← 正确答案
  • B.CJS 用 import() 异步加载 ESM← 正确答案
  • C..cjs 文件强制按 CommonJS 解析← 正确答案
  • D.所有版本都能同步 require ESM

答案:ABC。ESM 导入 CJS 原生支持,默认导入是 module.exports;CJS 加载 ESM 早期只能用 import() 异步进行;.cjs 后缀强制 CJS 解析。D 错误:早期 Node 无法同步 require ESM,直到 Node 22+ 才实验性支持。

Q10. 关于 CJS 顶层 this 与导出对象的关系,正确的是?

单选题 · hard

  • A.this 始终指向当前的导出对象
  • B.this 在重赋值后指向旧的对象← 正确答案
  • C.this 在重赋值后指向新的对象
  • D.this 指向 undefined 全局值

答案:B。Node 以初始的 module.exports 作为 this 调用包装函数。如果 module.exports 被整体重赋值,this 仍指向旧对象,两者脱钩,像外卖员换了新箱子却还揣着旧箱子的钥匙。A、C 把关系绝对化了,D 是 ESM 的行为。