CommonJS 与 ES Module 完全入门指南:从基础概念到项目实战
面向刚刚接触模块化的同学,本文会循序渐进地带你理解 CommonJS(CJS) 和 ES Module(ESM) 两套主流模块系统的来龙去脉、语法差异以及在真实项目中的落地方式。阅读完后,你应该能够:
- 说清楚 CommonJS 与 ES Module 的历史背景与核心理念
- 熟练书写两种模块语法,并知道默认导出、命名导出等概念
- 分辨二者在运行时行为上的关键差别(例如同步/异步加载、值复制与值引用)
- 在 Node.js、浏览器与打包工具中正确配置和混用两种模块系统
- 避开常见坑并能制定升级/迁移策略
1. 为什么需要模块化?
- 代码拆分:把复杂项目拆成可维护的小文件。
- 作用域隔离:避免变量全局污染。
- 依赖管理:显式声明需要的函数、对象或配置。
- 重用与共享:复用公共逻辑,方便团队协作。
这里的“模块”可以理解为“一个文件 + 它愿意分享出去的接口”。文件内部的变量默认只在本文件可见,想给别人用,就通过导出(CommonJS 的 module.exports 或 ESM 的 export)把它公开出来;想使用别人提供的功能,就通过导入(require 或 import)拿到。这是整个模块化体系的核心。
早期 JavaScript 没有官方模块方案,只能使用 <script> 标签串联文件或借助 IIFE(立即执行函数)模拟。随着项目体量增长,工程化需求催生了社区与官方的两套标准:
| 时间 | 模块方案 | 应用场景 |
|---|---|---|
| 2009 | CommonJS | Node.js 服务器端 |
| 2015 | ES Module | ECMAScript 标准,浏览器与 Node.js |
2. CommonJS(CJS)快速上手
2.1 核心概念
- 同步加载:在
require()时立即执行并返回导出的对象。 - 单文件单模块:
module对象对应当前文件,exports是最终暴露的接口。 - 运行在 Node.js 中:浏览器原生不支持,需要打包工具处理。
换句话说:当你在某个文件里写下
require('./math'),Node 会立刻跳到math.js把里面的代码跑完,再把需要导出的内容丢回给你。这个过程像“打电话让朋友帮忙查询结果”,挂断电话前必须等对方查完才能继续往下执行。
2.2 最常用的写法(一步步)
// math.js —— 先写一个模块
const PI = 3.14;
function area(r) {return PI * r * r;
}module.exports = { PI, area }; // 将需要的内容导出去
// app.js —— 再在另一个文件里导入使用
const math = require('./math'); // require 返回导出的对象
console.log(math.area(2));
2.3 扩展导入方式
// 1. 解构直接拿需要的函数
const { area } = require('./math');// 2. 给导入的对象起别名
const mathLib = require('./math');// 3. 导入 JSON / Node 内置模块 / 第三方包
const pkg = require('./package.json');
const fs = require('node:fs');
const express = require('express');// 4. 根据条件再 require(同步执行,但可以放在 if/函数中)
if (process.env.NODE_ENV === 'development') {const devTools = require('./dev-tools');devTools.init();
}
- 路径小贴士:以
./或../开头表示相对路径,不加前缀默认去node_modules中寻找;省略扩展名时会按.js → .json → .node顺序尝试。 - JSON 支持:
require('./data.json')会自动解析成对象,同样只会在第一次require时读取磁盘并缓存。 - 执行时机:
require()总是同步执行,放在函数里也一样会阻塞当前调用,适合 Node.js 服务器端但不适合浏览器。
小白提示:CommonJS 中
require()调用时机就是模块执行时机,每次require()整个模块都会先跑一遍,然后把module.exports的内容返回回来。
2.4 模块缓存与单例
- 首次
require()会执行模块并缓存结果。 - 后续重复
require()会直接读取缓存(单例模式)。 - 如果希望重新执行模块,需要删除
require.cache中的记录,但不常见。
**把它想象成“第一次问朋友借工具”。**第一次 require('./math') 时,Node 会去仓库(磁盘)把工具(模块)拿出来并登记放到公共柜子(缓存)里;以后别人再来借同一件工具,就直接从柜子里取走,不会再次跑去仓库。这也是为什么在 CommonJS 中常说“模块是单例”,因为整个进程里只有这一份共享副本。
// demo-cache.js
const counterA = require('./counter');
const counterB = require('./counter'); // 不会重新执行 counter.jsconsole.log(counterA === counterB); // true,拿到的是缓存里同一个对象
2.5 exports 与 module.exports
- 默认
module.exports = {}。 exports是对module.exports的引用;直接修改exports =会断开引用。- 建议始终使用
module.exports或保持exports.xxx =写法的一致性。
// ✅ 推荐
exports.area = area;
exports.PI = PI;// ✅ 等价写法
module.exports = { area, PI };// 🚫 错误:重新赋值会丢失导出
exports = { area, PI }; // 外部 require 拿到的还是旧对象
2.6 常见坑
- 只能在顶层同步
require(),无法在条件内动态导入(尽管可以写,但执行时是同步的)。 - CJS 导出的对象是 值拷贝(浅拷贝),不像 ESM 那样保持实时引用。
记住:“同步”意味着会卡住当前线程,所以不要在浏览器端直接使用
require(),页面会因为等待文件加载而卡死;“值拷贝”则意味着要么导出函数、要么导出可变对象,才能让调用方看到更新后的状态。
3. ES Module(ESM)快速上手
3.1 核心理念
- 静态分析:编译阶段即可确定依赖关系(利于 Tree Shaking)。
- 异步加载:浏览器可并行加载多个模块。
- 实时绑定(Live Binding):导出的变量是引用,导入方能感知到值的变化。
逐句理解:ES Module 的
import会在代码运行前就被“读透”,编译器能提前知道哪些模块被引用、哪些函数被用到,从而把没用的代码剪掉(Tree Shaking)。浏览器加载时也可以一边解析 HTML,一边请求多个模块文件,不需要按顺序阻塞。最关键的是,ESM 不是把值拷贝给你,而是给你一个“指向真实变量的遥控器”,变量变了你这里立刻能看到。
3.2 语法速览
// math.mjs
export const PI = 3.14159;
export function area(r) {return PI * r * r;
}const SECRET = 'local';
export default function circumference(r) {return 2 * PI * r;
}
// app.mjs
import circumference, { area, PI } from './math.mjs';
console.log(area(2));
console.log(circumference(2));
3.3 导入导出完整速查
// 1. 命名导出
export const PI = 3.14159;
export function area(r) {}// 2. 默认导出(模块只想暴露一个主功能时使用)
export default function circumference(r) {}// 3. 统一导出已有变量
const version = '1.0.0';
function calc() {}
export { version, calc as calculate };// 4. 一次性导入
import circumference, { area, PI as PI_VALUE } from './math.mjs';// 5. 导入整个命名空间
import * as math from './math.mjs';
console.log(math.area(2));// 6. 仅执行模块的副作用(不获取任何变量)
import './init-logger.js';
- 固定规则:
import/export必须写在模块的顶层作用域,不能包在if或函数里;需要按需加载时请使用import()。 - 路径解析:浏览器里
import './math.js'会基于当前文件的 URL 解析;Node.js 支持文件系统路径,还可以写import fs from 'node:fs'使用内置模块。 - 命名检查:命名导入必须和导出的名字对应不上就会在构建阶段报错,这也是它更易调试的原因。
- 命名空间导入:
import * as math会把模块里所有命名导出打包到一个对象里,便于查看完整 API,但无法获取默认导出。 - 默认导出理解:可以把默认导出想成“模块的主角”,导入时名字随便起;命名导出则是“配角列表”,导入时名字必须严格对应。
3.4 动态导入(懒加载)
// 可写在 if、函数或事件回调中
if (window.innerWidth > 1024) {const { createChart } = await import('./heavy-chart.mjs');createChart();
}
import()返回 Promise,首次加载后自动缓存。- 浏览器和支持 ESM 的 Node.js 环境都可以使用。
import.meta.url可以拿到当前模块的文件地址,配合new URL('./file', import.meta.url)读取同目录资源。- 适合场景:大体积图表库、仅在特定按钮点击后才需要的逻辑、根据用户权限动态开启的功能等,把不常用的代码拆开按需加载可以显著缩短首屏时间。
4. CommonJS vs ES Module 核心对比
| 维度 | CommonJS | ES Module |
|---|---|---|
| 设计目标 | Node.js 服务器端 | ECMAScript 标准(前后端统一) |
| 加载方式 | 同步执行,返回导出对象 | 静态解析,默认异步加载 |
| 导出类型 | module.exports 单对象 | 命名导出 + 默认导出 |
| 导入写法 | const lib = require('lib') | import lib from 'lib' |
| 值传递 | 导出时拷贝值副本 | 导出变量的实时引用 |
| 动态加载 | require() 可放在代码块内 | import() 返回 Promise |
| 顶层语法 | 可写在任意位置 | import/export 必须处于顶层 |
| Tree Shaking | 静态性弱,需工具分析 | 静态结构,易于 Tree Shaking |
如果一句话概括:CommonJS 像“立刻运行、拿到一个打包好的盒子”,写法更随意;ES Module 像“提前登记好清单、按需快递过来”,写法更严格但能让工具读懂、浏览器自带支持。
4.1 导入写法一览表
| 场景 | CommonJS | ES Module | 备注 |
|---|---|---|---|
| 默认导出/整体导出 | const math = require('./math') | import math from './math.js' | CJS 返回对象,ESM 得到默认导出 |
| 命名导出/解构 | const { area } = require('./math') | import { area } from './math.js' | ESM 支持别名 area as calcArea |
| 整个命名空间 | 手动写 const math = require(...) | import * as math from './math.js' | ESM 推荐方式 |
| 仅执行模块副作用 | require('./setup') | import './setup.js' | 两者都会执行模块顶层代码 |
| 动态导入 | const math = require('./math') | const math = await import('./math.js') | ESM 需等待 Promise |
4.2 深入理解:值拷贝 vs 实时引用
// counter.cjs
let count = 0;
function add() {count += 1;
}
module.exports = { count, add };
// counter-user.cjs
const counter = require('./counter.cjs');
counter.add();
console.log(counter.count); // => 0 !!! (拿到的是导出时的旧值)
原因:CommonJS 导出的是一个对象的副本,count 的值在导出时就被拷贝了。若想获取最新值,需要导出函数 getCount 或者导出整个对象并在对象属性上更新:
// counter-fixed.cjs
module.exports = {state: { count: 0 },add() {this.state.count += 1;}
};
const counter = require('./counter-fixed.cjs');
counter.add();
console.log(counter.state.count); // => 1
而在 ES Module 中,同样的代码会表现为“实时绑定”:
// counter.mjs
export let count = 0;
export function add() {count += 1;
}
// counter-user.mjs
import { add, count } from './counter.mjs';
add();
console.log(count); // => 1 (始终读取最新值)
浏览器和 Node.js 在编译阶段就知道 count 的引用位置,因此可以实现实时更新。
小结:遇到“为什么我导入的值不更新”的问题,先看你用的是哪种模块。如果是 CommonJS,就要导出函数或对象再访问;如果是 ES Module,记得不要把导入的值重新赋值(例如
count = 5会报错),而是通过调用导出的函数来修改源头。
5. Node.js 中两种模块的配置与互操作
5.1 通过文件扩展名区分
.cjs:强制视为 CommonJS。.mjs:强制视为 ES Module。.js:根据package.json中type字段决定。
规则记忆:文件后缀优先级最高,只要写了
.cjs/.mjs就不会再看type。只有普通.js文件才会参考package.json。所以常见做法是——老代码先保持.cjs,逐步把新文件写成.mjs,最后再切换type: "module"。
{"name": "demo","type": "module", // 默认为 "commonjs""main": "./index.js"
}
5.2 在 CommonJS 中使用 ES Module
// index.cjs
(async () => {const { area } = await import('./math.mjs');console.log(area(2));
})();
5.3 在 ES Module 中引入 CommonJS
// index.mjs
import pkg from './legacy.cjs';
const { area } = pkg;
注意:ESM 默认把 CJS 导出视为默认导出对象;若 CJS 使用
module.exports = function,ESM 中import pkg from './legacy.cjs'会得到函数本身。
两个世界互相调用的口诀:
- CJS 调 ESM:一定要用
await import(),因为 ESM 是异步的。 - ESM 调 CJS:用
import pkg from 'cjs',再从pkg对象里取需要的成员。 - 想要双向兼容:在 npm 包里同时导出两份产物,在
package.json的exports字段上根据import/require指向不同文件(第 8 节有示例)。
6. 浏览器环境下的实践
6.1 原生支持
<script type="module" src="./main.js"></script>
<script type="module">import { area } from './math.js';console.log(area(2));
</script>
- 模块脚本自动延迟执行(
defer),不会阻塞 HTML 解析。 - 同源策略限制:本地测试需通过 HTTP 服务器,而非直接打开文件。
- 浏览器会把每个模块文件当成独立的作用域,顶层变量不会跑到
window上,这也是模块隔离的一部分。
6.2 兼容旧浏览器
- 打包工具(Webpack、Rollup、Vite)可将 ESM 转译为浏览器支持的格式。
- 可使用
<script nomodule>提供降级脚本。
小白提醒:直接双击打开 HTML 会看到 “Cannot use import statement outside a module” 或跨域报错,这是因为浏览器禁止文件协议去加载模块。最简单的做法是在项目根目录启动一个静态服务器,例如
npx serve或npm run dev。
7. 打包工具中的模块处理
- Webpack:默认支持 CommonJS 与 ES Module,配置
module.rules和resolve.extensions即可;package.json中的"module"字段用于指向 ESM 版本,供 Tree Shaking 使用。 - Rollup:以 ES Module 为第一公民,对 CommonJS 需使用
@rollup/plugin-commonjs。 - Vite:基于 ES Module 开发,内部使用 Rollup 构建发布产物;无需额外配置即可消费两种模块。
- esbuild / SWC:这些新型打包器也都默认理解两种格式,并能非常快地互相转换。
小结:现代打包器通常会先把所有模块转换为统一格式(通常是 ESM),再进行优化和打包。
记忆技巧:只要你看到工具里有 “Tree Shaking”“按需加载”“产出多格式” 等配置,本质上都是在处理 ESM 的静态结构。CommonJS 进来后也会被转换成类似 ESM 的内部表示,以便做进一步优化。
8. 常见混用场景与解决方案
| 场景 | 问题 | 解决方式 |
|---|---|---|
| 老项目 CJS,想引入新库 ESM | require() 无法直接加载 | 使用 async import() 或将新库打包后引入 |
| 新项目 ESM,依赖老库 CJS | import 得到默认导出对象 | const { xxx } = await import('cjs-lib'); 或在打包阶段转换 |
| 同一仓库需要同时输出 CJS 和 ESM 版本 | npm 包需要兼容 | exports 字段区分入口,使用打包工具产出双版本 |
{"name": "pkg","exports": {".": {"import": "./dist/index.esm.js","require": "./dist/index.cjs"}}
}
实战建议:当你需要同时面对老旧脚手架(只识别
require)和现代工具链(喜欢import)时,不要强行让对方改变。最稳妥的做法是自己用打包工具产出两份文件,一个.cjs给旧系统,一个.esm.js给新系统,再在package.json里用exports或main/module指明入口。
9. 迁移策略:从 CommonJS 走向 ES Module
- 梳理依赖:确认有哪些内部模块与外部包仍是 CJS。
- 先局部再整体:从叶子模块(无下游依赖)开始改写为 ESM。
- 保持接口稳定:使用
exports字段同时输出 CJS 与 ESM。 - 检查工具链:确保测试、打包、Lint 等工具支持 ESM。
- 逐步启用
"type": "module":先在子包或独立目录尝试,再推广到主包。
迁移口诀:**先清点 → 小范围试点 → 双格式并存 → 全面切换。**不要一口气改全仓库,尤其是服务器端项目,谨防线上环境 Node 版本过低或第三方库尚未提供 ESM。
10. 常见问题答疑(FAQ)
-
Q:ES Module 一定比 CommonJS 快吗?
A:并非绝对。ESM 更适合静态分析与 Tree Shaking,但实际性能取决于打包策略与运行环境。 -
Q:浏览器中可以直接使用 CommonJS 吗?
A:不行,需要通过打包或转译工具转换为浏览器支持的语法。 -
Q:
require()和import()可以混用吗?
A:在 Node.js 中可以互相调用,但需遵循前文的互操作规则;在浏览器中不建议使用require()。 -
Q:默认导出与命名导出怎么选?
A:若模块只暴露一个主功能,可以使用默认导出;若需要导出多个 API,使用命名导出更利于提示与重构。 -
Q:为什么说 ESM 的导出是“实时绑定”?
A:ESM 导出的变量是引用,导入方访问时会读取最新值;CommonJS 在导出时把值拷贝给调用方。
11. 总结与最佳实践
- 理解目标环境:后端运行时优先考虑 Node.js 的支持;前端看浏览器兼容性。
- 优先选择 ESM:新项目建议以 ES Module 为主,获得更好的工具链支持。
- 保留兼容层:对外发布的 npm 包同时提供
import与require入口,兼容旧生态。 - 慎用默认导出:团队内部统一规范,避免同时使用默认导出与命名导出导致混乱。
- 善用动态导入:把大体积模块按需加载,提高首屏性能。
只要掌握上述知识点,你就能在任何 JavaScript 项目中自如地选择并配置适合的模块系统。建议结合实际项目多尝试、调试,逐步建立对模块加载流程的直觉。
最后再筛一遍:看到
module.exports/require就想到 CommonJS,“同步、值拷贝、Node 侧”;看到export/import就想到 ES Module,“静态、实时引用、浏览器 Node 都支持”。脑子里放一张表,遇到问题对照“导入失败?看路径和顶层语法”“值没更新?看是不是 CJS 拷贝”。循序渐进地练习,你就能从小白快速成长为模块化的老司机。
