ESM vs CJS 模块化差异对比

在 JavaScript 的发展历程中,模块化一直是一个核心话题。目前主流的两种规范------CommonJS (CJS)ES Modules (ESM)------在语法、机制和设计理念上有着本质的区别。

本文将跳出单纯的代码堆砌,从设计理念、运行机制和实际应用角度,带你深入理解这两者的差异。

核心差异速览

如果不想看长篇大论,这张表总结了最核心的区别:

特性 CommonJS (CJS) ES Modules (ESM)
主要环境 Node.js (服务端) 浏览器 & Node.js (通用)
设计理念 动态运行时对象 静态声明构建
加载方式 同步 (阻塞执行) 异步 (非阻塞)
依赖解析 运行时 (Runtime) 编译时 (Compile-time)
导出值 值拷贝 (导出后不再变化) 实时绑定 (引用值随内部变化)
Tree Shaking ❌ 不支持 ✅ 支持 (减少代码体积)

1. 语法与设计理念

CommonJS: "对象"的传递

CommonJS 诞生于 Node.js 早期,它的核心思想非常简单:模块就是一个对象 。 当你使用 require 时,你实际上是在获取这个对象;当你使用 module.exports 时,你是在给这个对象添加属性。

  • 直观感受:像是在操作一个普通的 JavaScript 对象。
  • 灵活性:因为是对象,你可以动态地修改它,甚至根据条件导出不同的内容。
javascript 复制代码
// CommonJS 就像在拼装一个对象
const lib = require('./lib'); // 同步读取文件并执行
if (process.env.NODE_ENV === 'development') {
  module.exports = { ...lib, debug: true }; // 动态修改导出
}

🔍 深入:CommonJS 导出的"潜规则" (exports vs module.exports)

很多开发者容易混淆 exportsmodule.exports。记住一句话:exports 只是 module.exports 的引用(快捷方式),Node.js 最终认的只有 module.exports

  1. 默认情况 :两者指向同一个空对象 {}
  2. 断开引用 :如果你直接给 exports 赋值,它就断开了与 module.exports 的联系,导致导出无效。
  3. 优先级 :如果两者发生冲突,module.exports 拥有最高优先级。
javascript 复制代码
// ✅ 正确:给引用对象添加属性
exports.a = 1; 
module.exports.b = 2;
// 最终导出:{ a: 1, b: 2 }

// ❌ 错误:直接赋值 exports (断开引用)
exports = { a: 1 }; 
// 最终导出:{} (默认值),因为 module.exports 还是空的

// ⚠️ 覆盖:module.exports 优先级最高
exports.a = 1;
module.exports = { b: 2 };
// 最终导出:{ b: 2 } (exports.a 的修改被丢弃了)

ES Modules: "静态"的契约

ES Modules 是 JavaScript 官方标准,它的设计目标是静态分析。 这意味着在代码运行之前,编译器就能知道模块之间的依赖关系。

  • 直观感受 :更像是一种声明式语法 (import/export),而不是执行语句。
  • 优势:正因为这种静态特性,工具链(如 Webpack, Vite)才能进行 Tree Shaking(摇树优化),移除未使用的代码。
javascript 复制代码
// ESM 是静态的声明
import { func } from './lib.js'; // 必须在顶层,不能在 if 语句中
export const value = 123;        // 明确的导出接口

2. 加载机制:同步 vs 异步

这是两者在运行时行为上最大的区别。

text 复制代码
┌──────────────────────┐          ┌──────────────────────┐
│  CommonJS (同步阻塞)  │          │   ES Modules (异步)   │
└──────────┬───────────┘          └──────────┬───────────┘
           │                                 │
           ▼                                 ▼
┌──────────────────────┐          ┌──────────────────────┐
│   开始执行 main.js     │          │     解析 main.js     │
└──────────┬───────────┘          └──────────┬───────────┘
           │                                 │
           ▼                                 ▼
┌──────────────────────┐          ┌──────────────────────┐
│     遇到 require      │          │     构建依赖图        │
└──────────┬───────────┘          └──────────┬───────────┘
           │                                 │
           ▼                                 ▼
╔══════════════════════╗          ╔══════════════════════╗
║ 🛑 暂停执行,加载文件    ║          ║ ⚡️ 并行下载/读取依赖     ║
║    (等待 I/O 完成)     ║          ║     (不阻塞主线程)      ║
╚══════════════════════╝          ╚══════════════════════╝
           │                                 │
           ▼                                 ▼
┌──────────────────────┐          ┌──────────────────────┐
│     获取导出对象       │          │   按序实例化与求值      │
└──────────┬───────────┘          └──────────┬───────────┘
           │                                 │
           ▼                                 ▼
┌──────────────────────┐          ┌──────────────────────┐
│   继续执行 main.js    │           │       执行代码       │
└──────────────────────┘          └──────────────────────┘

CommonJS 的同步阻塞

CommonJS 专为服务端设计,文件都在本地磁盘,读取速度快。因此,require()暂停当前代码的执行,直到模块加载完成。

  • 优点:逻辑简单,编写线性代码容易。
  • 缺点:不适合浏览器环境(网络请求慢,会造成页面卡顿)。

ES Modules 的异步加载

ESM 采用了两阶段过程:解析执行

  1. 解析阶段:浏览器或 Node.js 会先构建整个依赖图,并行下载所有模块。
  2. 执行阶段:按照依赖顺序执行代码。
  • 优点:性能更好,支持复杂的依赖关系处理,支持浏览器环境。

3. 深度解析:值拷贝 vs 实时绑定

这是最容易被忽视,但导致 Bug 最多的差异。

CommonJS:按下快门的瞬间 (Value Copy)

当 CommonJS 导出基础类型(数字、字符串)时,它导出的是那一瞬间的值的拷贝。 就像拍了一张照片,之后原模块里的值怎么变,照片里的样子都不会变。

javascript 复制代码
// counter.js
let count = 1;
module.exports = {
  count,       // 导出的是 1 这个数字
  inc: () => count++ 
};

// main.js
const mod = require('./counter');
mod.inc();
console.log(mod.count); // 仍然是 1!因为导出的是拷贝

ES Modules:连接的通道 (Live Binding)

ESM 导出的是引用的绑定。 就像建立了一个连接通道,当你访问导出的变量时,你直接读取的是原模块内部的那个变量。

javascript 复制代码
// counter.js
export let count = 1;
export const inc = () => count++;

// main.js
import { count, inc } from './counter.js';
inc();
console.log(count); // 变成了 2!这是实时更新的

4. 实际使用场景指南

与其列举代码,不如看看在什么情况下该用什么。

✅ 什么时候必须/应该用 ESM?

  1. 浏览器端开发:所有现代前端框架(React, Vue, etc.)和构建工具(Vite, Webpack)默认都是 ESM。
  2. 新开启的 Node.js 项目:Node.js 12+ 已完全支持 ESM,这是未来的标准。
  3. 需要代码优化 (Tree Shaking):如果你希望打包后的文件尽可能小,ESM 是必须的。
  4. 跨平台库开发:如果你开发的库既要在 Node.js 跑,又要在浏览器跑。

⚠️ 什么时候还得用 CommonJS?

  1. 维护遗留的 Node.js 服务:很多老项目深耦合了 CommonJS 特性(如动态 require)。
  2. 编写配置文件 :某些老工具的配置文件(如旧版 webpack.config.js, .eslintrc.js)可能仍默认使用 CJS。
  3. 非常特殊的动态加载需求 :虽然 ESM 有 import() 动态导入,但在某些极端灵活的同步加载场景下,require 仍有优势。

🌟 日常开发最佳实践

在日常开发中,遵循这些原则可以避免大部分坑:

  • 文件扩展名
    • 明确使用 .mjs (ESM) 或 .cjs (CJS) 可以避免歧义。
    • 如果在 package.json 中设置了 "type": "module",则 .js 默认为 ESM。
  • 导入路径
    • 在 ESM 中,导入路径必须 包含文件扩展名(如 import x from './file.js'),这与 CommonJS 不同。
  • 混合使用
    • 尽量避免在同一个项目中混用。
    • 如果必须混用,记住:ESM 可以导入 CJS (使用 import),但 CJS 无法 require ESM(因为 ESM 是异步的,CJS 是同步的)。
  • 默认导出 (Default Export)
    • 尽量使用命名导出 (Named Export)。它们对重构更友好,IDE 支持更好,且能更好地支持 Tree Shaking。

🚀 从 CJS 迁移到 ESM 的注意事项

如果你正在将老项目从 CJS 迁移到 ESM,注意这几个常见的"拦路虎":

  1. __dirname__filename 不见了
    • ESM 中没有这两个全局变量。
    • 解决方案 :使用 import.meta.url 结合 path 模块手动创建。
  2. JSON 文件的导入
    • CJS: require('./data.json') 直接用。
    • ESM: 需要使用断言语法 import data from './data.json' assert { type: 'json' }; (Node.js 17.5+)。
  3. 严格模式
    • ESM 默认开启严格模式 ('use strict'),这可能会暴露老代码中未声明变量等问题。

总结:ES Modules 是 JavaScript 的统一未来。虽然 CommonJS 在 Node.js 生态中仍将长期存在,但拥抱 ESM 意味着拥抱更好的性能、更标准的语法和更广阔的生态。

相关推荐
weibkreuz6 小时前
收集表单数据@10
开发语言·前端·javascript
在西安放羊的牛油果7 小时前
浅谈 import.meta.env 和 process.env 的区别
前端·vue.js·node.js
鹏北海7 小时前
从弹窗变胖到 npm 依赖管理:一次完整的问题排查记录
前端·npm·node.js
王林不想说话7 小时前
提升工作效率的Utils
前端·javascript·typescript
weixin_584121437 小时前
vue内i18n国际化移动端引入及使用
前端·javascript·vue.js
imkaifan8 小时前
bind函数--修改this指向,返回一个函数
开发语言·前端·javascript·bind函数
xkxnq8 小时前
第一阶段:Vue 基础入门(第 7 天)
前端·javascript·vue.js
光头闪亮亮8 小时前
企业协同办公系统(OA)-【图标选择器】模块开发详解
前端·javascript·vue.js
pas1368 小时前
22-mini-vue props
前端·javascript·vue.js
pas1368 小时前
23-mini-vue 实现 emit 功能
前端·javascript·vue.js