想象你经营一家大型餐厅。早期的后厨没有独立工位,所有食材、调料、刀具都堆在同一张大桌子上。一百个厨师伸手就能拿到任何东西——表面上是方便,可一旦有人动了你的盐罐、切了你的葱、拿走了你的刀,整个后厨就乱成一锅粥。更麻烦的是,谁也不知道谁用了什么,出了错也查不到源头。 你决定改革:把后厨分成一个个独立的工位——备菜台、炒锅区、甜点房、酒水吧。每个工位有自己的小仓库,明确写着“我需要什么、我提供什么”。其他工位需要时,就按规定的通道去取。这套分工逻辑,就是 JavaScript 的模块系统。 从把所有代码塞进一堆 script 标签,到 CommonJS、AMD、UMD,再到最终被语言标准化的 ESM,JavaScript 模块化走过了一条从“手工管理”到“标准化自动化”的路。读懂这段故事,你就同时理解了 require 和 import 为什么长得不一样、为什么浏览器和 Node 加载模块的方式不同,以及你的项目该选哪种模块方案。

岗位关联

前端工程化绕不开模块选型。理清 CommonJS 到 ESM 的演进脉络,配置 Webpack/Vite、发布 npm 包、排查依赖报错时,你就不再靠猜。

目录
  1. 可视化演示
  2. 模块化演进:从大锅饭到独立工位
  3. CommonJS规范:后厨现场取料
  4. AMD规范:异步点餐提前下单
  5. UMD兼容方案:万能适配插头
  6. ESM标准:中央厨房的菜单制
  7. 加载机制对比:动态取料 vs 静态配餐
  8. 工程实践:新老厨房的过渡与混用
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示CommonJS同步加载机制、ESM静态导入分析及模块加载五步流程,交互切换理解JavaScript模块化演进核心原理。

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

'+modeData.cjs.caption+'

模块化演进:从大锅饭到独立工位

早期的 JavaScript 没有一个正式的模块系统。页面里要引入多个 JS 文件,就在 HTML 里依次写上多个 script 标签,所有变量和函数被倒入同一个全局作用域。就像餐厅后厨把所有食材堆在一张大桌子上,看似伸手就能拿到,实则谁都能改谁的,出了错根本无从追查。

当页面越来越复杂,脚本越来越多,“大锅饭”的混乱就爆发了:后加载的脚本可能覆盖先加载的同名变量,某个文件依赖的库没有被提前引入就直接报错。开发者只能靠“手动调整 script 标签顺序”来维持脆弱的平衡,这就是依赖管理混乱的根源。于是,模块化的呼声出现了——每个文件都应该有自己的作用域,只暴露必要的接口,明确声明需要的依赖。

模块化的核心诉求可以浓缩为一个简单的公式:把每个脚本的“领地”隔离出来。

正是因为认清了大锅饭的问题,社区开始探索模块化方案,才有了后面 CommonJS、AMD、UMD、ESM 这一路演进。记住这个起点:模块化要解决的,就是把“一张大桌子”改造成“一个个带独立仓库的工位”。

全局命名空间=∑i=1nscripti 的变量与函数\displaystyle \text{全局命名空间} = \sum_{i=1}^{n} \text{script}_i \text{ 的变量与函数}
这个公式表示:所有 script 标签产生的变量和函数,全都堆在同一个全局空间里,脚本越多,命名冲突和依赖错乱的风险就越大。
  1. 全局命名空间浏览器中的 window、Node 中的 global,是所有脚本共享的顶层作用域
    全局命名空间=window (浏览器) 或 global (Node)\displaystyle \text{全局命名空间} = \text{window (浏览器)} \text{ 或 } \text{global (Node)}
  2. ∑ 求和符号把每个 script 标签的影响叠加到同一个空间里
    ∑i=1nscripti\displaystyle \sum_{i=1}^{n} \text{script}_i
  3. 变量与函数每个 JS 文件里顶层声明的所有东西,都会成为全局对象的一个属性
    var a⇒window.a\displaystyle \text{var a} \Rightarrow \text{window.a}
  4. 叠加的后果后加载的同名变量会覆盖先加载的,依赖顺序完全靠手工保证
    同名覆盖风险∝脚本数量\displaystyle \text{同名覆盖风险} \propto \text{脚本数量}

CommonJS规范:后厨现场取料

CommonJS 像后厨传统的“传菜口取料”模式:厨师做到一半发现需要面粉,就放下锅铲走到仓库门口喊一声“我要面粉”,管仓人当面把面粉递给他,他拿到手才回去继续炒菜。整个过程当场发生、一步等一步、拿到才能继续。这就是 CommonJS 的哲学——运行时同步加载。

CommonJS 诞生于 Node.js 环境。服务端读本地文件非常快,同步等待造成的阻塞几乎可以忽略,所以这种“现场取料”的模式完全够用。它的核心是两个 API:require() 是加载函数,参数是模块路径;module.exports 是导出对象,模块通过它公开自己的接口。开发者也可以把 exports 当作 module.exports 的别名来添加属性。

CommonJS 的调用链路可以表达为:

注意,CommonJS 的 require 是函数调用,所以它可以在 if 语句里、循环里、甚至任意运行时位置动态拼接路径。这就是“动态加载”——执行到哪一行,才加载那一个模块。就像厨师做到一半才发现没葱,临时跑一趟仓库。

CommonJS 让 Node.js 后厨第一次有了“独立工位”的秩序:每个文件有自己的作用域,通过 require 和 module.exports 明确往来。但它有一个明显的局限:它是为 Node 设计的同步方案,浏览器端无法直接使用——浏览器下载文件是异步的,不能像读本地磁盘一样瞬间返回。

require(模块id)→同步执行module.exports→返回调用方\displaystyle \text{require}(\text{模块id}) \xrightarrow{\text{同步执行}} \text{module.exports} \xrightarrow{\text{返回}} \text{调用方}
这个公式描述了一次 CommonJS 加载的全过程:通过路径找到模块、同步执行模块代码、最后把导出对象交给调用方。
  1. require(模块id)CommonJS 的加载入口,参数是相对路径或包名
    require(′./math′)\displaystyle \text{require}('./math')
  2. 同步执行Node 挂起当前任务,读取并执行目标模块的全部代码
    同步执行⇒阻塞后续代码\displaystyle \text{同步执行} \Rightarrow \text{阻塞后续代码}
  3. module.exports被加载模块把对外接口挂到这个对象上
    module.exports={add,subtract}\displaystyle \text{module.exports} = \{ \text{add}, \text{subtract} \}
  4. 返回调用方require 执行结束后,把 exports 对象交给等在前面的代码
    const math=require(′./math′)\displaystyle \text{const math} = \text{require}('./math')

AMD规范:异步点餐提前下单

浏览器环境完全不一样。页面通过 HTTP 加载 JS 文件,网络延迟不可控,如果像 CommonJS 那样同步等待,页面会卡死。想象前厅点餐:客人坐下,服务员先记录下所有需要的菜(声明依赖),然后一次性下单,厨房做好一道端一道——客人不需要自己跑去厨房干等。这就是 AMD 的思路:异步模块定义。

AMD(Asynchronous Module Definition)的核心函数是 define。开发者声明一个模块时,把它的名字、依赖数组和工厂回调一起交给 define。依赖模块全部异步加载完成后,工厂回调才被调用,回调的返回值就是模块对外开放的接口。RequireJS 是 AMD 最流行的实现。这种“先点单、后上菜”的模式很好地解决了浏览器的异步问题。

AMD 的定义结构可以写成:

AMD 的“前置依赖声明”是一大进步:模块的依赖关系在定义时就能看到,不再需要手动调整 script 标签顺序。但它也有代价——写起来繁琐,define 嵌套层级一多,代码可读性直线下降。它像前厅点餐:等待时间体验好了,但点餐流程比“直接去厨房拿”要复杂得多。

AMD 证明了“异步加载”在浏览器端的价值,但它的方案没有被语言采纳,更像一个过渡性的“点餐系统”。真正终结这场混乱的,是语言层面的标准答案——ESM。

define(id,[依赖...],factory)→异步加载依赖模块接口\displaystyle \text{define}(id, [\text{依赖...}], \text{factory}) \xrightarrow{\text{异步加载依赖}} \text{模块接口}
这个公式展示了 AMD 模块的标准形态:声明 id、列出依赖、依赖就绪后执行工厂函数产出接口。
  1. defineAMD 的核心函数,用来定义模块
    define(′math′,[],function(){...})\displaystyle \text{define}('math', [], \text{function}() \{...\})
  2. id给模块起一个唯一标识名,方便其他模块引用
    id=’math’\displaystyle id = \text{'math'}
  3. 依赖数组提前声明该模块需要的所有其他模块,浏览器据此并行下载
    [’jquery’,’lodash’] → 并行加载\displaystyle [\text{'jquery'}, \text{'lodash'}]\text{ → 并行加载}
  4. factory依赖全部就绪后才执行的回调,返回值即对外 API
    factory(依赖...)→module interface\displaystyle \text{factory}(\text{依赖...}) \rightarrow \text{module interface}

UMD兼容方案:万能适配插头

AMD 和 CommonJS 分庭抗礼时,开发者发布一个库面临一个尴尬的问题:写 CommonJS 风格,浏览器用不了;写 AMD 风格,Node 用不了;写全局变量风格,又显得不够现代。于是 UMD(Universal Module Definition)出现了。它像一个万能适配插头——你出差到一个陌生国家,不确定酒店插座是美标、欧标还是英标,万能插头全都能插。

UMD 不是一种独立的新规范,而是把 CommonJS、AMD 和全局变量三种方案“缝”进同一个文件。它用一个立即执行函数包裹库代码,在函数开头做环境检测:如果 module 对象存在,就走 CommonJS 的 module.exports;如果 define.amd 存在,就走 AMD 的 define;如果两者都不在,就把库挂到 window 上作为全局变量。

UMD 的逻辑可以表示为一个分支结构:

UMD 的确做到了“一个文件走天下”,但它只是个权宜之计:代码里充满了环境判断的样板,既不优雅,也无法被静态分析优化。万能插头虽然百搭,但真正先进的酒店早就统一了插座标准——那个标准就是 ESM。UMD 作为历史过渡产物,如今主要还出现在一些老牌 npm 包的 dist 文件里。

UMD={module.exports(Node 环境)define(AMD 环境)全局变量(无模块系统)\displaystyle \text{UMD} = \begin{cases} \text{module.exports} & \text{(Node 环境)} \\ \text{define} & \text{(AMD 环境)} \\ \text{全局变量} & \text{(无模块系统)} \end{cases}
这个公式表示 UMD 根据运行环境自动选择导出方式,保证同一个库在任何地方都能被加载。
  1. UMD =一个环境分支判断,本质是模块导出的多路分发
    UMD=dispatch(environment)\displaystyle \text{UMD} = \text{dispatch}(\text{environment})
  2. module.exports(Node 环境)检测到 CommonJS 的 module 对象时,走 Node 导出
    typeof module !== ’undefined’⇒module.exports=factory()\displaystyle \text{typeof module !== 'undefined'} \Rightarrow \text{module.exports} = \text{factory}()
  3. define(AMD 环境)检测到 define.amd 标记时,走 AMD 导出
    typeof define === ’function’⇒define(factory)\displaystyle \text{typeof define === 'function'} \Rightarrow \text{define}(\text{factory})
  4. 全局变量(无模块系统)两者都不存在时,把库挂到 window 上兜底
    global.MyLib=factory()\displaystyle \text{global.MyLib} = \text{factory}()

ESM标准:中央厨房的菜单制

ESM(ES Module)像现代中央厨房的管理模式:餐厅开门营业前,主厨已经把一周的菜单、每道菜的食材清单、供应商、配送时间全部定好了。开工前食材已经按份配好,谁也不用临时跑到仓库去拿。这种“提前规划”就是 ESM 的精髓——编译期静态分析。

ESM 是 ECMAScript 2015(ES6)正式加入语言规范的原生模块系统,由 TC39 标准委员会制定。它用 import 和 export 两个声明式关键字替代了 require 函数和 module.exports 对象。import 和 export 必须写在文件顶层,不能嵌套在条件语句里——故意的,因为正是这种“死板”让引擎在代码运行前就能完整地分析出依赖关系。

ESM 的静态特性可以表示为:

ESM 不仅支持命名导出 export { a, b },还支持默认导出 export default,以及动态导入 import()(返回 Promise,可用于按需加载)。现代浏览器通过 <script type="module"> 原生支持 ESM,Node.js 14+ 也通过 "type": "module" 配置完整支持。

中央厨房的菜单制让一切变得可规划、可优化。开工前食材清单就打印好了,每个工位按清单领料,不需要做到一半才跑仓库。这就是 ESM“静态”两个字的真正价值——它不只是规范了语法,更让整个工具链(打包器、编译器、浏览器)得以围绕模块结构进行深度优化。

import {所需导出} from ’模块路径’ →编译期静态分析依赖关系图\displaystyle \text{import } \{ \text{所需导出} \} \text{ from '模块路径' } \xrightarrow{\text{编译期静态分析}} \text{依赖关系图}
这个公式说明 ESM 的模块关系在编译阶段就被确定,引擎和打包工具可以基于这张依赖图做各种优化。
  1. import { 所需导出 }声明式导入,用花括号列出需要使用的具名导出
    import {add} from ′./math′\displaystyle \text{import } \{ \text{add} \} \text{ from } './math'
  2. from '模块路径'指定模块的来源路径或包名
    from ′./math′ 或 ’lodash’\displaystyle \text{from } './math' \text{ 或 } \text{'lodash'}
  3. 编译期静态分析代码尚未执行,引擎就能解析出所有 import 关系
    静态分析⇒依赖确定⇒无需运行代码\displaystyle \text{静态分析} \Rightarrow \text{依赖确定} \Rightarrow \text{无需运行代码}
  4. 依赖关系图所有 import 连接成的树状结构,供引擎并行加载和打包器优化
    依赖关系图=Graph(模块,依赖边)\displaystyle \text{依赖关系图} = \text{Graph}(\text{模块}, \text{依赖边})

加载机制对比:动态取料 vs 静态配餐

CommonJS 和 ESM 最核心的差异,可以浓缩成一句话:开火后找菜 vs 开工前备菜。CommonJS 的 require 是一个函数调用,代码执行到那一行才去加载模块,加载期间阻塞后续代码,而且路径可以是运行时拼接的变量——这是“动态取料”。ESM 的 import 是声明式语句,在代码执行前就被静态分析,引擎和打包器可以预先知道整个依赖图,浏览器甚至可以并行下载多个模块——这是“静态配餐”。

这两种机制决定了它们的能力边界。因为 CommonJS 是动态的,工具无法在不运行代码的情况下知道一个模块到底导出了什么,所以没法做 tree-shaking(摇树优化)。而 ESM 是静态的,export 和 import 的名字在编译期就一目了然,打包器可以安全地删除那些没有被任何 import 引用的导出,从而减小最终的代码体积。

这两者的差异可以概括为:

除了 tree-shaking,加载同步性的差异也很关键:CommonJS 同步阻塞,但 Node 读本地文件快,影响不大;ESM 支持异步协议,浏览器并行下载多个模块文件时优势明显。另外,CommonJS 的 require 可以写在函数里实现条件加载,ESM 的静态 import 不能——但有 import() 动态导入函数来弥补这个缺口。

理解“动态取料 vs 静态配餐”,你就掌握了两套模块哲学的本质。选型时不是看哪个“更好”,而是看你的运行环境和优化需求。最后走到工程实践,答案就清楚了。

静态依赖图 (ESM)⇒tree-shaking=剔除未引用导出\displaystyle \text{静态依赖图 (ESM)} \Rightarrow \text{tree-shaking} = \text{剔除未引用导出}
这个公式说明:ESM 的静态结构是 tree-shaking 的前提,正是因为它能在编译期确定哪些导出没被使用,才能安全地删除。
  1. 静态依赖图 (ESM)ESM 在编译期即可构建完整的模块依赖关系
    依赖图={模块→依赖集合}\displaystyle \text{依赖图} = \{ \text{模块} \rightarrow \text{依赖集合} \}
  2. ⇒ 推导出静态结构带来的直接能力
    静态结构⇒可分析性\displaystyle \text{静态结构} \Rightarrow \text{可分析性}
  3. tree-shakingWebpack、Rollup 等打包器的“摇树”优化
    tree-shaking:代码体积→有效代码\displaystyle \text{tree-shaking} : \text{代码体积} \rightarrow \text{有效代码}
  4. 剔除未引用导出没有被任何 import 使用的 export 会被安全移除
    删除{dead code}=体积减小\displaystyle \text{删除} \{ \text{dead code} \} = \text{体积减小}

工程实践:新老厨房的过渡与混用

现实中的餐厅不可能一夜之间改成中央厨房——老后厨还在用传菜口,新员工已经习惯菜单制。JavaScript 世界也处在这个过渡期:新的 ESM 生态在蓬勃生长,海量的老 npm 包仍是 CommonJS。于是“双厨房”并行成了常态:有的档口用老办法,有的档口用新办法,中间靠几个“传菜员”(打包器与兼容层)衔接。

工程实践的原则可以归结为三句话。第一,新项目优先用 ESM:Node.js 18+ 上在 package.json 里设置 "type": "module" 即可,浏览器端直接写 <script type="module">;第二,老项目不必强制迁移:CommonJS 依然被完全支持,.cjs 扩展名可以显式声明一个文件是 CommonJS;第三,用打包器统一入口:Vite、Webpack、Rollup 会自动把源码里的 ESM 编译成目标环境能跑的格式,开发者不需要手动切换。

项目选型可以用一个决策表表示:

跨体系互引也有成熟方案:CommonJS 代码可以通过动态 import() 加载 ESM 模块,这是 Node 官方推荐的过渡方式;ESM 加载 CommonJS 模块时,Node 会把 module.exports 包装成默认导出。打包器则会自动处理大部分互操作细节,你在配置 exports 字段时同时提供 "import" 和 "require" 两个入口。

新老厨房的过渡期会持续很多年,但方向已经明确:ESM 是终局。就像中央厨房的菜单制最终会取代传菜口的临时取料——不是因为它最“潮”,而是因为它是语言标准、可静态分析、能优化打包体积,整个前端工具链都在向它靠拢。

项目配置={"type": "module"(Node 18+ 新项目)require + exports(老 Node / CJS 项目)<script type="module">(现代浏览器)\displaystyle \text{项目配置} = \begin{cases} \text{"type": "module"} & \text{(Node 18+ 新项目)} \\ \text{require + exports} & \text{(老 Node / CJS 项目)} \\ \text{<script type="module">} & \text{(现代浏览器)} \end{cases}
这个公式总结了三种典型场景的模块系统选型:新 Node 项目用 ESM,老项目保持 CommonJS,浏览器直接使用原生 ESM。
  1. 项目配置根据运行环境和维护成本决定模块系统
    配置=f(环境,团队,生态)\displaystyle \text{配置} = f(\text{环境}, \text{团队}, \text{生态})
  2. "type""module":Node.js 项目开启 ESM 解析的关键字段
    package.json→{"type":"module"}\displaystyle \text{package.json} \rightarrow \{ \text{"type"}: \text{"module"} \}
  3. require + exports老项目保持 CommonJS 语法,不做破坏性迁移
    保持{require,module.exports}\displaystyle \text{保持} \{ \text{require}, \text{module.exports} \}
  4. script type=module浏览器原生加载 ESM 的方式
    <script type="module">→浏览器ESM 模块图\displaystyle \text{<script type="module">} \xrightarrow{\text{浏览器}} \text{ESM 模块图}

AI 应用场景

场景一:前端工程化构建配置

阶段:AI 应用前端交付与集成

解决的问题:AI 应用的前端界面往往需要加载可视化图表库、状态管理库、HTTP 请求库等多个第三方包。传统方式用多个 script 标签手动引入,版本冲突和加载顺序问题频发;切到 ESM + 打包器后,依赖关系由编译期静态分析自动处理,还能通过 tree-shaking 大幅减小最终包体。

具体案例:用 Vite 搭建一个 AI 对话仪表盘,源码全部采用 ESM 编写,package.json 配置 "type": "module";引入 ECharts 时使用具名导入只打包用到的图表类型;遇到老旧的 CommonJS 库,Vite 的 optimizeDeps 会自动预构建成 ESM 格式,开发时开箱即用。

场景二:Node AI 服务与 SDK 发布

阶段:模型服务部署与封装

解决的问题:后端 AI 服务需要用 Node.js 暴露推理 API,既要支持内部工具用 require 调用,又要给浏览器端页面用 import 加载。如果模块系统选错,会出现 require is not defined 或 Unexpected token 'export' 这类运行时错误。

具体案例:开发一个基于 Transformers.js 的轻量推理服务,服务端代码用 ESM 编写,Node 18 直接运行;对外发布推理 SDK 时,用打包工具同时产出 ESM 和 CJS 两个入口,package.json 的 exports 字段用条件导出区分 "import" 和 "require",让任何消费方都能正确加载。

Python 演示

python80 行
import time  class Module:    """单个模块:名称、依赖列表、工厂函数、导出对象"""     def __init__(self, name, deps, factory):        self.name = name        self.deps = deps        self.factory = factory        self.exports = {}  def run_commonjs(modules, entry):    """模拟 CommonJS:执行到 require 才同步加载"""    cache = {}     def require(name, trace):        if name in cache:            return cache[name]        mod = modules[name]        for dep in mod.deps:            require(dep, trace + " -> " + name)        mod.factory(mod.exports)        cache[name] = mod.exports        print("[require] 同步加载:", trace, "->", name)        return mod.exports     t0 = time.time()    require(entry, "entry")    print("CommonJS 总耗时: %.4fs" % (time.time() - t0))  def run_esm(modules, entry):    """模拟 ESM:编译期先静态分析整棵依赖树,再执行"""    def analyze(name, seen):        mod = modules[name]        for dep in mod.deps:            analyze(dep, seen + [name])        return mod     print("--- 编译期静态分析 ---")    analyze(entry, [])    print("依赖树已构建:", list(modules.keys()))     executed = {}     def execute(name):        if name in executed:            return executed[name]        mod = modules[name]        for dep in mod.deps:            execute(dep)        mod.factory(mod.exports)        executed[name] = mod.exports        print("[import] 执行:", name)     t0 = time.time()    execute(entry)    print("ESM 总耗时: %.4fs" % (time.time() - t0))  def build_modules():    """组装依赖链: main -> utils -> tools, main -> math"""    tools = Module("tools", [],                   lambda e: e.update({"rate": 10}))    utils = Module("utils", ["tools"],                   lambda e: e.update({"add": lambda a, b: a + b + e["rate"]}))    math = Module("math", [],                  lambda e: e.update({"sqr": lambda x: x * x}))    main = Module("main", ["utils", "math"],                  lambda e: e.update({"calc": lambda x: e["sqr"](e["add"](x, 1))}))    return {m.name: m for m in [tools, utils, math, main]}  if __name__ == "__main__":    print("===== CommonJS 同步加载 =====")    run_commonjs(build_modules(), "main")    print("\n===== ESM 编译期静态分析 =====")    run_esm(build_modules(), "main")
目的:用最小模型演示 CommonJS 与 ESM 在加载时机和依赖处理方式上的本质区别。
  1. def require(name, trace):模拟 CommonJS 的 require 函数,依赖深度优先同步加载
  2. mod.factory(mod.exports)执行模块工厂函数,填充导出对象,模拟 module.exports
  3. def run_esm(modules, entry):模拟 ESM 加载流程:先 analyze 后 execute
  4. analyze(entry, [])编译期完成整棵依赖树的静态分析,此时不执行任何模块代码
  5. executed = {}与 CommonJS 的 cache 类似,保证每个模块只执行一次

常见误区

误区我听说 ESM 支持异步加载,那它是不是比 CommonJS 慢?毕竟 require 是同步的,看起来更“直接”。

正解加载速度不能简单用“同步 vs 异步”判断。CommonJS 同步加载意味着一次只加载一个、加载完才能执行下一步;ESM 的静态依赖图让浏览器可以并行下载多个模块文件,整体加载效率往往更高。import 语句本身是声明性的,它不负责“下载”这个动作,真正的加载由引擎调度。现代的 HTTP/2 多路复用之下,并行下载多个小文件比串行下载更快。<br>如果把 CommonJS 比作“每次只能跑一趟仓库拿一种食材”,ESM 就是“开工前一次性下单,供应商并行送货上门”,哪个更快,一目了然。

为何常见:看到“异步”两个字就联想到“慢”,忽略了异步的核心优势是“并发”而非“延迟”。

误区Node.js 就是为 CommonJS 而生的,所以我用 Node 写代码只能用 require,不能写 import。

正解Node.js 从 12 版本开始实验性支持 ESM,14 版本稳定支持,18 版本已经可以无障碍地直接用 import/export。你只需要把文件扩展名改为 .mjs,或者在 package.json 中设置 "type": "module"。Node 官方已经明确把 ESM 作为未来的主推方向,CommonJS 则作为兼容方案继续保留。<br>这就像老后厨虽然还在用传菜口,但新的中央厨房已经正式营业,新员工完全可以按新流程工作。

为何常见:大量早期教程和 Stack Overflow 老答案都是基于 Node 6~10 的 CommonJS 时代,信息更新滞后。

误区UMD 和 ESM 都是 JavaScript 的模块系统,我项目里选一个用就行,反正都是“模块标准”。

正解UMD 不是一种与新规范平级的标准,它是把 CommonJS、AMD、全局变量三种方案“缝”进一个文件的兼容层。ESM 才是语言级标准,由 TC39 制定,得到了浏览器和 Node 的原生支持。两者定位完全不同:UMD 解决的是“一个库在不同环境都能跑”的过渡问题,ESM 解决的是“JavaScript 语言层面到底该怎么组织模块”的根本问题。<br>把 UMD 和 ESM 相提并论,就像把“万能转换插头”和“国际统一插座标准”当成同类物品——前者是应急工具,后者是终极方案。

为何常见:看到 UMD 和 ESM 都是三个字母的缩写,且都出现在模块相关语境中,容易误以为它们是同一层次的概念。

误区`exports` 和 `module.exports` 不是同一个东西吗?那我写 `exports = { add: ... }` 也能正常导出吧?

正解初始时 exports 确实是指向 module.exports 的引用,但直接给 exports 重新赋值会让它指向一个全新的对象,彻底切断与 module.exports 的联系,导出自然失效。正确做法有两种:给 exports 添加属性(exports.add = ...),或者直接修改 module.exports(module.exports = { add: ... })。<br>就像你把快递单上的收件人改成别人,快递员照单投递,就再也不会送到你手上了——引用指向变了,对象还是那个对象吗?

为何常见:只记住了“exports 是 module.exports 的简写”,忽略了引用赋值的底层机制。

误区浏览器只能通过 script 标签加载 JS 文件,import/export 是 Node 和打包工具才支持的东西。

正解现代浏览器(Chrome 61+、Firefox 60+、Safari 11+)早已原生支持 ESM,直接在 HTML 里写 <script type="module"> 就能使用 import/export,不需要任何打包工具。不仅如此,浏览器原生 ESM 还支持模块的并行下载和按需执行。打包器(Webpack/Vite)之所以仍然存在,是为了处理 JSX、TypeScript、资源文件等更广泛的需求,而不是因为浏览器不能用 ESM。<br>这就好比你家的烤箱早就支持新式烤法了,但专业面包房仍然用商用级设备——不是因为家用不支持,而是因为商用设备能干更多活。

为何常见:E SM 规范是 2015 年推出的,很多人对浏览器支持的记忆停留在 2015 年前后,多年未更新认知。

面试题

Q1. CommonJS 中 require('./math') 的加载行为是?

单选题 · easy

  • A.同步阻塞加载← 正确答案
  • B.异步并行加载
  • C.编译期静态解析
  • D.预加载后回调执行

答案:A。CommonJS 的 require 是函数调用,执行到那一行才去读取并执行目标模块,加载期间阻塞后续代码。B 是 AMD 或 ESM 并行加载的特征,C 是 ESM 的静态分析,D 是 AMD 工厂回调的模式。就像厨师现场去仓库取料,拿到手才继续炒菜。

Q2. 以下哪个是 ESM 的导入语法?

单选题 · easy

  • A.const fs = require('fs')
  • B.import fs from 'fs'← 正确答案
  • C.include fs from 'fs'
  • D.using fs = require('fs')

答案:B。import fs from 'fs' 是 ESM 的默认导入语法。A 是 CommonJS 的 require,C 和 D 是编造的干扰项。记住特征:ESM 用 import 关键字,CommonJS 用 require 函数,这是两者的直观分水岭。

Q3. AMD 规范诞生的核心原因是?

单选题 · easy

  • A.解决浏览器同步阻塞← 正确答案
  • B.替代Node运行时
  • C.统一全局变量命名
  • D.支持CSS样式加载

答案:A。AMD 的诞生背景是浏览器环境——网络加载 JS 无法像 Node 读本地文件那样同步等待,AMD 用“前置声明依赖 + 异步加载”解决了阻塞问题。B 说反了,AMD 是给浏览器用的;C 是模块化的动机之一但非 AMD 特有;D 与 JS 模块系统无关,CSS 有自己的加载机制。

Q4. 关于 CommonJS 与 ESM 差异描述正确的有?

多选题 · mid

  • A.CommonJS 动态加载同步执行← 正确答案
  • B.ESM 依赖关系在编译期确定← 正确答案
  • C.CommonJS 原生支持 tree-shaking
  • D.ESM 浏览器端已原生可用← 正确答案

答案:ABD。CommonJS 是运行时动态加载、同步执行,A 正确;ESM 的 import/export 在编译期就能解析出完整依赖图,B 正确;tree-shaking 依赖静态结构,CommonJS 无法实现,C 错误;现代浏览器通过 <script type="module"> 原生支持 ESM,D 正确。C 选项是常见的混淆点——tree-shaking 是 ESM 的专属福利。

Q5. UMD 模块的本质是?

单选题 · mid

  • A.一种标准化的语言规范
  • B.三种环境的兼容包装← 正确答案
  • C.Node专属的加载API
  • D.浏览器打包工具插件

答案:B。UMD(Universal Module Definition)不是语言规范,而是把 CommonJS、AMD、全局变量三种导出方式缝进同一个文件的兼容包装,运行时通过环境检测自动选择。它像万能适配插头,让一个库在 Node、RequireJS、直接挂 window 三种场景下都能工作。A 错在它不是独立规范,C/D 更是完全偏题。

Q6. 以下哪些是 ESM 的合法 export 写法?

多选题 · mid

  • A.export const version = 1← 正确答案
  • B.export function greet() {}← 正确答案
  • C.module.exports = { greet }
  • D.export default { version }← 正确答案

答案:ABD。A 是具名导出变量,B 是具名导出函数,D 是默认导出对象,三者都是 ESM 的合法语法。C 是 CommonJS 的写法,混入 ESM 会报语法错误。可以这样记:ESM 使用 export 关键字直接修饰声明,CommonJS 则依赖 module.exports 这个特殊对象。

Q7. Node.js 项目切换为 ESM 的推荐配置是?

单选题 · mid

  • A.安装 Babel 并改写全部语法
  • B.设置 type 字段为 module← 正确答案
  • C.修改 Node 进程环境变量
  • D.使用 webpack 替代 Node 运行

答案:B。在 package.json 中设置 "type": "module",Node 就会以 ESM 模式解析 .js 文件,这是官方推荐做法。A 中的 Babel 是转译工具但非必须;C 没有这种环境变量;D 是打包器,不能替代 Node 运行时。设置好 type 字段后,就可以直接使用 import/export 语法。

Q8. tree-shaking 得以实现的前提条件是?

单选题 · hard

  • A.require 的可动态拼接路径
  • B.import 的静态可分析结构← 正确答案
  • C.define 的前置依赖数组
  • D.UMD 的运行时环境分支

答案:B。tree-shaking 依赖模块的导出和引用关系在编译期就完全确定,ESM 的 import/export 是顶层静态声明,满足这个条件。CommonJS 的 require 路径可以动态拼接,工具无法静态分析,所以做不了 tree-shaking;AMD 的前置依赖数组只声明了模块级依赖,无法精细到导出名;UMD 是运行时分支,与静态分析无关。

Q9. 关于动态 import() 的说法正确的有?

多选题 · hard

  • A.返回 Promise 对象← 正确答案
  • B.支持代码分割懒加载← 正确答案
  • C.只能在 Node 端使用
  • D.可以在条件语句中调用← 正确答案

答案:ABD。动态 import() 返回 Promise,可以在运行时按需加载,非常适合路由懒加载和代码分割,A/B 正确。它可以在 if 语句、循环等任意位置调用,D 正确。C 错误——动态 import() 在浏览器和 Node 中都被支持。它既打破了静态 import 不能在条件中使用的限制,又保持了模块化的按需加载能力。

Q10. CommonJS 模块被 ESM import 引入时,Node 内部做了什么?

单选题 · extreme

  • A.直接执行 CJS 代码
  • B.将 CJS 包装为 ESM 默认导出← 正确答案
  • C.强制转换为 AMD 模块
  • D.报错并终止运行

答案:B。Node 加载 CommonJS 模块时,会将其 module.exports 整体包装成 ESM 的默认导出,因此你可以用 import pkg from 'cjs-module' 来引入。如果 CJS 模块在 module.exports 上挂的是具名属性,Node 还会尝试做静态分析,尽量提供具名导出的支持,但底层机制始终是“默认导出 + 属性探测”。A 是浏览器全局脚本的行为,C/D 不符合 Node 的实际设计。