60 秒回答模板

CommonJS 用 require 和 module.exports,模块在运行时同步执行,第一次 require 会执行文件并缓存 module.exports,后续 require 复用缓存;ESModule 用 import 和 export,是语言标准,依赖关系在执行前就能静态分析,先建立导入导出绑定再执行模块。语义上,CJS 拿到的是 module.exports 当时暴露出来的值或对象引用,解构后通常不会自动更新;ESM 的 import 是只读 live binding,导出方变量变了,导入方读到的是最新绑定。工程里还要注意 Tree Shaking 依赖 ESM 静态结构和副作用判断,循环依赖时 CJS 可能拿到未初始化完成的 exports,ESM 可能遇到 TDZ,Node 和构建工具混用时要看 default 导出、package.json type、.cjs/.mjs 和转换语义。

考点 核心机制与工程取舍
难度 中高频面试题
回答目标 按定义、机制、场景讲清楚

深入解析

01

语法和 API 边界

CommonJS 的导出入口是 module.exports,exports 只是初始指向 module.exports 的快捷引用;exports.foo = foo 能生效,exports = foo 只是改了本地变量。ESModule 的 import/export 是语法层能力,import 必须在顶层静态声明,导入名本身不能被重新赋值。

02

加载时机和执行流程

CJS 是运行时同步加载:require 时解析路径,命中缓存就返回缓存的 module.exports;未命中就创建 module 对象,把文件包装成函数执行,执行结束后缓存结果。ESM 先解析静态 import/export,构建模块依赖图,实例化导入导出绑定,再按依赖顺序求值。

03

CJS 当前值与 ESM live binding

CJS 不能简单说全是值拷贝。require 返回 module.exports 的当前值;如果是对象,消费者持有同一个对象引用;如果消费者解构出属性,拿到的是那一刻的局部值;如果导出方后来整体替换 module.exports,旧消费者不会自动更新。ESM 的 import 是导出方绑定的只读视图,导出方变量变化后,导入方再次读取会看到新值。

04

静态分析和 Tree Shaking

ESM 的 import/export 结构在执行前可见,打包器可以建立依赖图,判断某个导出是否被使用,再结合 sideEffects 标记和副作用分析删除未使用代码。但 Tree Shaking 不是用了 ESM 就一定生效,顶层副作用、动态访问、转译方式和包配置都会影响结果。

05

循环依赖的失败模型

CJS 会先把正在初始化的 module 放进缓存,循环另一端 require 回来时可能拿到半成品 exports。ESM 在求值前已经创建绑定,循环里如果读取尚未初始化的 let、const 或 class 绑定,可能触发 TDZ;如果延迟到函数调用后再读,通常可以工作。

06

Node 和构建工具互操作

Node 项目会受 package.json type、.cjs、.mjs、exports 条件导出影响。ESM 引 CJS 时默认导入通常对应整个 module.exports,具名导入是否稳定依赖运行时或打包器推断;CJS 引 ESM 常用动态 import。Babel、TypeScript、Webpack、Rollup、Vite 还会引入 default 包装和 __esModule 兼容层。

易错点

  • 把 exports 当成真正导出对象,写 exports = function App() {},结果 require 到的仍是默认空对象。
  • 把 import { count } 当成 const { count } 解构,答不出导出方 count++ 后导入方为什么会看到新值。
  • 把 CJS 简化成永远值拷贝,忽略对象引用、消费者解构、整体替换 module.exports 三种语义。
  • 只说 CJS 同步、ESM 异步,却讲不出 ESM 的静态链接、实例化绑定和求值阶段。
  • 认为 ESM 必然 Tree Shaking、CJS 完全不能优化,忽略副作用和打包器转换。
  • 循环依赖只背会有问题,说不出 CJS 半初始化 exports 和 ESM TDZ 是两套机制。
  • 忽略 package.json type、.cjs、.mjs 和条件导出,导致本地、测试和打包后被不同 loader 解释。

面试官追问

为什么 exports = fn 不会导出 fn?

exports 初始只是 module.exports 的引用。exports.foo = foo 是在 module.exports 对象上加属性;exports = fn 只是让局部变量 exports 指向新函数。require 返回的是 module.exports,所以整体替换必须写 module.exports = fn。

CJS 到底是值拷贝还是引用?

require 返回 module.exports 的当前值。如果它是对象,消费者持有对象引用;如果解构出属性,拿到的是那一刻的局部值;如果导出方后面整体替换 module.exports,旧消费者不会自动跳到新对象。

ESM live binding 和 const 解构有什么区别?

import { count } 不是 const { count } = obj。它是导入方对导出方 count 绑定的只读视图,导出方 count++ 后,导入方再次读取会看到新值,但导入方不能给 count 重新赋值。

循环依赖时 CJS 和 ESM 分别可能发生什么?

CJS 可能拿到半初始化的 exports,字段是 undefined 或旧对象上的部分字段;ESM 先创建绑定再求值,若读取尚未初始化的 let/const/class 绑定会触发 TDZ。

为什么 ESM 更利于 Tree Shaking?

ESM 的静态 import/export 让打包器在执行前知道依赖图和导出使用情况,这是 Tree Shaking 的前提;但顶层副作用、sideEffects 配置和转译产物会影响能否安全删除。

ESM 引 CJS、CJS 引 ESM 最容易出什么问题?

ESM 引 CJS 时默认导入通常拿到整个 module.exports,具名导入依赖运行时或打包器推断;CJS 引 ESM 往往要动态 import。排查时还要看 __esModule、default 包装和 esModuleInterop。