Skip to content
雲里
里雾

JavaScript 模块系统 — ESM 与 CJS

mindgym 开发 更新于 2026/4/12

ESM(ECMAScript Modules)和 CJS(CommonJS)是 JavaScript 世界两套并存的模块系统。本页梳理它们的演进历史、五点核心差异、Node 与浏览器的立场差异、Vite/Metro 等打包工具的处理方式,以及 RN 里 require('./image.png') 的真实语义——它语法上是 CJS,但实际是 Metro 的 asset pipeline 借用了 CJS 语法。


概述:为什么需要模块系统

早期 JavaScript 没有模块概念。浏览器里用 <script> 标签按顺序加载文件,所有代码共享全局作用域,带来三个问题:

  1. 命名冲突:多个库定义同名全局变量就炸
  2. 依赖隐式:代码里看不出谁依赖谁
  3. 加载顺序靠人维护

模块系统要解决这三个问题——提供独立作用域、显式声明依赖、自动处理加载顺序。

ESM 是 ECMAScript 2015(ES6)标准化的官方模块语法;CJS 是 Node.js 在 2009 年自造的约定,早于 ECMAScript 规范。两者并存是历史偶然,但短期内无法统一。


工作方式:五个本质差异

1. 静态 vs 动态

ESM import 是静态语法——必须写在文件顶部,路径必须是字面量,不能放在 if 里。CJS require() 是普通函数,可以在任何位置、接受任意表达式参数。

这个限制是 ESM 最大的优势来源:静态可分析 = 打包工具能在编译期建立完整依赖图,做 tree-shaking、循环依赖检测、代码分割等优化。CJS 因为动态,打包工具不敢猜你用了什么,只能全量打包。

2. 同步 vs 异步

CJS 的 require() 是同步函数,立即读文件→编译→执行→返回值。适合服务器场景(文件在本地磁盘)。

ESM 的 import 是声明,实际加载由运行时调度,可以异步并行。浏览器友好,不阻塞 UI。

3. 值拷贝 vs 活引用(live binding)

// CJS: 导入是值快照
let count = 0
module.exports = { count }
// 另一文件 import 后 count 始终是 0,即使原模块修改了它

// ESM: 导入是活引用
export let count = 0
// 另一文件 import count 后,读取总是拿到最新值

ESM 的 export 是绑定(binding),这个设计让循环依赖能正确工作。

4. 文件类型判断

Node.js 根据以下规则区分:

规则结果
.mjsESM
.cjsCJS
.js + package.json"type": "module"ESM
.js + 无此字段或 "type": "commonjs"CJS

大多数老项目没设 "type": "module",所以 .js 默认是 CJS。

5. 顶层 this

CJS 里顶层 this === module.exports,ESM 里顶层 this === undefined


语法速查

场景CJSESM
命名导出module.exports.foo = ...export const foo = ...
默认导出module.exports = Xexport default X
命名导入const { foo } = require('./m')import { foo } from './m'
默认导入const M = require('./m')import M from './m'
重命名const { foo: bar } = require(...)import { foo as bar } from '...'
导入所有无直接对应import * as M from '...'
动态加载require(expr)const m = await import(expr)

互操作的痛点

报错 ERR_REQUIRE_ESM 就是这个互操作问题的典型表现。


RN 里的 require:一个 Metro 特例

RN 代码里常见这种写法:

<Image source={require('./logo.png')} />

语法上是 CJS 的 require()语义上不是 CJS 模块加载——它是 Metro bundler 在构建时做的特殊转换

  1. 构建期:把图片文件注册到 asset registry,分配数字 ID
  2. 代码转换:把 require('./logo.png') 替换成对注册表的调用
  3. 打包期:复制图片到 iOS .xcassets 或 Android res/drawable-*dpi/
  4. 运行期<Image> 拿 ID,让 native 层读取对应图片

为什么不用 import

Metro 其实也支持 import logo from './logo.png',但 RN 惯例一直用 require(),有两个原因:

  1. 历史惯性:官方文档从早期就用 require
  2. 动态灵活:require 可以写在表达式里
// require 能写在条件分支
const logo = isDark
  ? require('./logo-dark.png')
  : require('./logo-light.png')

// import 必须写在顶部,等价写法要先 import 两个再三元选一个

不支持动态路径

// ❌ 报错
const name = isDark ? 'logo-dark' : 'logo-light'
<Image source={require(`./${name}.png`)} />

Metro 的 asset transformation 是编译期做的,必须静态看出要 require 哪个文件。动态路径意味着运行时才知道路径,Metro 没法提前把图片打进 bundle。


打包工具的立场

工具输入开发期策略
Webpack都支持全量打包再增量更新
RollupESM 优先输出 ESM / CJS / UMD
esbuild都支持速度优先
ViteESM 优先开发期不打包,利用浏览器原生 ESM 按需请求
Metro都支持RN 专用,深度集成 native asset

Vite 速度的秘密:开发期不打包。浏览器支持 ESM 后,Vite 让浏览器按 import 关系按需请求每个文件,单文件独立缓存。修改一个文件只重编一个。Webpack 早期心智是”全量打包”,模块多了启动就慢。

Metro 的独特点:它是 RN 专用的 bundler,深度集成 native asset(iOS xcassets、Android res)、platform 分发(.ios.js / .android.js)、Hermes 字节码预编译。这些是 Webpack/Vite 做不到的。


生态现状(2026)


实践建议


参见


参考

  1. ECMAScript Modules Specification — ESM 官方规范
  2. Node.js Modules: ECMAScript modules — Node 的 ESM 文档
  3. Node.js Modules: CommonJS modules — Node 的 CJS 文档
  4. Sindre Sorhus: Pure ESM package — 为什么大量包在迁移 ESM-only
  5. Metro: Asset References — RN 资源加载文档
  6. Axel Rauschmayer: ECMAScript modules — 深度但易读的 ESM 教程