模块化发展历程
2009: CommonJS 诞生(Node.js 御用)
2015: ES6 Modules 标准化
2020: Node.js 13+ 正式支持 ES Modules
2023: 90% 的新项目首选 ES Modules
引言
首先问一下自己,为什么需要了解他们的区别。任何知识点的学习都需要建立在一个合理的求知欲上,如果只是单纯的了解,但是缺乏动机,结果就是学了等于没学。所以先问问自己的动机是什么,是为了面试?还是为了开发时能正确引入第三方库?或者是为了解决项目中包体积问题?
概念篇
核心区别
首先结合记忆锚点对他们的核心区别产生基本印象,在有了印象后的这个阶段,可能会精彩混淆两者的特点,原因很简单,缺乏实际场景以及具体运用。下面我将对他们的特征举例展开,一起学起来!
| 特征 | CommonJS | ES6 Modules | 记忆锚点 |
|---|---|---|---|
| 加载时机 | 运行时动态加载 | 编译时静态解析 | 运行时 vs 编译时 |
| 加载方式 | 同步加载(阻塞执行) | 异步加载(非阻塞) | 同步 vs 异步 |
| 值传递 | 导出基本类型时值拷贝 | 导出值始终是实时引用 | 值拷贝 vs 值引用 |
| 顶层作用域 | 自由变量(非严格模式) | 自动严格模式 | 自由 vs 严格 |
| 循环依赖处理 | 可能得到未初始化的值 | 自动处理未完成绑定 | 危险 vs 安全 |
| 浏览器支持 | 需打包工具转换 | 原生支持(type="module") | 需转换 vs 原生 |
基本用法
导入导出
CommonJS 使用 require、module.exports,ES Module 使用 import、export
// CommonJS
const { readFile } = require('fs.cjs'); // 同步加载
module.exports = { data: 1 };
// ES6
import { readFile } from 'fs/promises'; // 异步加载
export const data = 1;
值传递
以下是值传递的两个代码例子:
// CommonJS(值拷贝)
// lib.js
let count = 0;
exports.count = count; // 显式导出值拷贝
exports.increment = () => { count++ };
// main.js
console.log(require('./lib').count); // 0(缓存中的拷贝值)
require('./lib').increment();
console.log(require('./lib').count); // 仍然 0(拷贝值未更新)
// ES6(实时绑定)
// lib.mjs
export let count = 0;
export const increment = () => { count++ };
// main.mjs
import { count, increment } from './lib.mjs';
increment();
console.log(count); // 1 ✅
通过上面两个例子,可以得出CommonJS和ES Modules本质的导出区别:
| 特性 | CommonJS | ES Modules |
|---|---|---|
| 导出本质 | 导出值的拷贝 | 导出值的引用 |
| 内存关系 | 每个 require() 持有独立拷贝 | 所有 import 共享同一内存地址 |
| 变量可见性 | 只能通过导出的方法间接修改内部状态 | 导出变量可直接被外部修改 |
| 模块缓存 | 模块加载后结果被缓存 | 实时绑定无缓存机制 |
| 典型场景 | Node.js 服务端开发 | 现代浏览器和 Node.js 新版本 |
动态加载
// CommonJS 动态加载(无 Tree Shaking)
// 引入位置不限制
const moduleName = 'utils';
const utils = require(`./${moduleName}`); // 动态路径
// ES6 静态结构(支持 Tree Shaking)
// 需要在作用域顶层引入
import { funcA } from './utils.js'; // 路径必须静态
应用篇
决策指南
根据概念篇的模块特点,整理了一份决策指南,下面会举例相关的应用场景。
| 考虑维度 | 推荐选择 | 理由说明 |
|---|---|---|
| 浏览器项目 | ESM | 原生支持 + Tree Shaking |
| Node.js 服务 | CommonJS(旧版) | 生态兼容性 |
| 通用工具库 | 双格式输出 | 最大兼容性 |
| 代码可维护性 | ESM | 静态分析优势 |
| 旧系统维护 | CommonJS | 无需改造历史代码 |
浏览器项目
<!-- 现代浏览器原生支持 -->
<script type="module">
import { renderChart } from 'https://cdn.com/data-viz.esm.js';
// Tree Shaking 优化示例
import { debounce } from 'lodash-es'; // 仅打包使用的方法
</script>
<!-- 对比 CommonJS 方案 -->
<script src="bundle.js"></script>
<!-- 需通过 Webpack 转换,且无法利用原生模块缓存优势 -->
核心优势:
- 预加载优化:支持
<link rel="modulepreload">提前加载关键模块 - 代码分割:原生支持动态
import() - 性能提升:利用浏览器并行加载能力
- 体积优化:通过 Tree Shaking 平均减少 30%-50% 代码体积
Node.js 服务
// CommonJS 典型场景(Express 路由)
// -------------------------------
// routes/user.js
module.exports = (db) => { // 依赖注入
return {
getUsers: async (req, res) => {
const data = await db.query('SELECT...');
res.json(data);
}
};
};
// app.js
const userRoutes = require('./routes/user')(db); // 动态初始化
// ESM 新型实践(Node.js 14+)
// -------------------------------
// routes/product.mjs
export const getProducts = (pool) => ({ // 更清晰的接口定义
list: async (req, res) => {
const [rows] = await pool.query('...');
res.json(rows);
}
});
// app.mjs
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacyModule = require('./old-module.cjs'); // 混合加载
版本决策矩阵:
| Node.js 版本 | 推荐模块系统 | 注意事项 |
|---|---|---|
| < 12.x | CommonJS | 无原生 ESM 支持 |
| 12.x-14.x | CommonJS | 需 --experimental-modules 标志 |
| ≥ 14.x | ESM | 推荐使用 .mjs 扩展名 |
通用代码库
双格式输出配置
如果不太清楚什么是双格式输出,我们可以反向了解什么是双格式。下面这段代码我们应该很熟悉,在日常开发中输出npm包时,我们需要在导出的时候,针对不同的运行环境做相应的处理。
// 应对不同环境的入口文件
// src/entry.js
if (typeof require !== 'undefined' && require.main === module) {
module.exports = require('./node-entry.js'); // CommonJS
} else {
export * from './browser-entry.js'; // ESM
}
上面的例子中,Commonjs导出的文件是.js结尾,而不是.cjs结尾,和用法篇中的例子有区别。这是为啥?嗯,如果你有这样的疑问,那么我们可以开始介绍双格式输出配置了,下面是一个配置例子(遵循的是Node Pakage Export规范):
// package.json
{
"name": "universal-lib",
"main": "./dist/cjs/index.js", // CommonJS 入口
"module": "./dist/esm/index.mjs", // ESM 入口
"exports": {
".": {
"require": "./dist/cjs/index.js", // Node.js 标准
"import": "./dist/esm/index.mjs" // ESM 标准
},
"./utils": {
"require": "./dist/cjs/utils.js",
"import": "./dist/esm/utils.mjs"
}
},
"types": "./dist/types/index.d.ts" // TypeScript 类型声明
}
上面的配置例子里,因为缺少 type 字段,所以模块类型默认是commonjs。通过module、exports字段来定义导出的ES Module模块的信息,完成双格式输出配置。
各字段作用详解表
| 字段 | 规范来源 | 典型值示例 | 作用场景 |
|---|---|---|---|
main | npm/Node.js | ./dist/cjs/index.js | 兼容旧版 Node.js 和打包工具 |
module | 社区构建工具约定 | ./dist/esm/index.mjs | 优化 Tree Shaking 的 ESM 入口 |
exports | Node.js 标准 | 见上文 | 现代模块解析和条件导出 |
types | TypeScript | ./dist/types/index.d.ts | 类型声明入口 |
type | Node.js | "module" 或 "commonjs" | 定义默认模块类型 |
sideEffects | Webpack/Rollup | false 或文件列表 | 优化 Tree Shaking |
构建工具配置示例
Rollup
// rollup.config.js
export default {
input: 'src/index.js',
output: [
{
dir: 'dist/cjs',
format: 'cjs',
exports: 'auto'
},
{
dir: 'dist/esm',
format: 'esm',
preserveModules: true
}
]
};
Webpack 5
// webpack.config.js
const path = require('path');
const createConfig = (format) => ({
entry: './src/index.js',
mode: 'production',
output: {
path: path.resolve(__dirname, `dist/${format}`),
filename: 'index.js',
library: {
type: format === 'esm' ? 'module' : 'commonjs2'
},
chunkFormat: format === 'esm' ? 'module' : 'commonjs',
environment: {
module: format === 'esm' // 环境特性检测
}
},
experiments: {
outputModule: format === 'esm'
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [
['@babel/preset-env', {
modules: false // 保留 ESM 语法
}]
]
}
}
}
]
}
});
module.exports = [createConfig('esm'), createConfig('cjs')];
Rspack
// rspack.config.js
const { defineConfig } = require('@rspack/cli');
// 双格式一体化配置
module.exports = defineConfig([
// CommonJS 配置
{
mode: 'production',
entry: './src/index.js',
output: {
path: 'dist/cjs',
filename: 'index.js',
library: {
type: 'commonjs2'
}
},
builtins: {
treeShaking: true // 内置 Tree Shaking
}
},
// ESM 配置
{
mode: 'production',
entry: './src/index.js',
output: {
path: 'dist/esm',
filename: 'index.mjs', // 推荐使用 .mjs 扩展名
library: {
type: 'module'
}
},
experiments: {
outputModule: true
}
}
]);
面试篇
我考考你
面试官:说说 CommonJS 和 ESM 模块的区别?
你:
- 加载机制:CommonJS 是运行时同步加载,ESM 是编译时异步加载
- 值传递:CommonJS 导出基本类型是值拷贝,ESM 是实时引用
- 应用场景:CommonJS 主要用于 Node.js,ESM 是现代浏览器和 Node 新版本的标准
- 附加优势:ESM 支持 Tree Shaking 和静态分析,更适合构建优化
- 兼容方案:可通过
createRequire和动态import()实现互操作
面试官:为什么 CommonJS 的计数器不更新,而 ESM 可以?
你:这体现了两种模块系统的核心差异:
- CommonJS 导出的是值的拷贝,每次
require()获取的是模块的缓存副本。案例中的count是模块内部变量,外部只能通过导出的方法修改,但无法直接读取最新值 - ES Modules 通过实时绑定机制,所有
import都指向同一内存地址。修改导出变量会同步到所有引用处,因此能立即获取最新值
这种差异导致在需要状态共享的场景下,ES Modules 是更优选择
面试官:为什么 ESM 支持 Tree Shaking 而 CommonJS 不行?
你:当然是因为👇
-
ESM:编译时静态分析依赖关系,构建工具(如 Webpack)可准确识别未使用的导出
// 可被 Tree Shaking export const used = () => {...}; export const unused = () => {...}; -
CommonJS:动态加载特性导致无法在构建阶段确定依赖
// 无法 Tree Shaking const utils = require('./utils'); if (condition) { utils.dynamicMethod(); // 动态调用无法分析 }
面试官:两者的模块加载流程有何不同?
你:加载流程如下!
CommonJS 加载流程:
- 解析模块路径
- 检查缓存(
require.cache) - 同步读取文件内容
- 包裹函数:
(exports, require, module, __filename, __dirname) => { ... } - 执行模块代码,填充
module.exports - 返回
module.exports并缓存
ESM 加载流程:
- 解析依赖图(编译阶段)
- 异步下载所有模块文件
- 解析模块(Parse)
- 实例化模块(内存分配)
- 执行代码(变量绑定)
- 缓存结果
面试官:如何在 ESM 中加载 CommonJS 模块
你:嗯嗯有两种方式
// 方法1:默认导入
import cjsModule from 'commonjs-module';
console.log(cjsModule.namedExport); // 需要模块兼容
// 方法2:通过 createRequire
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const legacy = require('commonjs-module');
面试官:为什么 Node.js 最初选择 CommonJS?
你:
- 前端当时无模块标准
- 同步加载适合服务端 I/O 阻塞场景
- 简单易实现(2009年时的技术限制)
面试官:如何将旧项目从 CommonJS 迁移到 ESM?
你:采用渐进式迁移的方法最为保守,在不影响存量代码的基础上升级新代码的导出模式。
阶段一:混合模式(不修改旧文件)
// package.json
{
"type": "commonjs", // 显式声明默认类型
"scripts": {
"start": "node src/index.js" // 常规启动
}
}
操作步骤:
-
新文件使用
.mjs扩展名// src/new-module.mjs export const hello = () => console.log('ESM'); -
旧文件保持
.js不变 -
在 CommonJS 中加载 ESM:
// src/legacy.js import('new-module.mjs').then(({ hello }) => { hello(); // 正确执行 });
阶段二:渐进改造
// package.json
{
"type": "module", // 新默认类型
"scripts": {
"start": "node --experimental-modules src/index.js"
}
}
操作步骤:
-
将改造完成的旧文件重命名为
.mjsmv src/utils.js src/utils.mjs -
未改造的文件保持
.cjs扩展名// src/unmodified.cjs module.exports = { key: 'value' }; // 明确 CommonJS
阶段三:完全迁移
// package.json
{
"type": "module",
"scripts": {
"start": "node src/index.js" // 无需实验性标志
}
}
操作步骤:
-
删除所有
.cjs文件 -
全局替换
require()为import -
处理特殊变量:
// 替换 __dirname import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url));
迁移工具链配置
- Babel 兼容方案
// .babelrc
{
"presets": [
["@babel/preset-env", {
"modules": false // 保留 ESM 语法
}]
]
}
- TypeScript 配置
// tsconfig.json
{
"compilerOptions": {
"module": "ESNext", // 输出 ESM
"moduleResolution": "NodeNext"
}
}
- 混合加载工具函数
// src/loaders.js
import { createRequire } from 'module';
export const loadCJS = (path) => {
const require = createRequire(import.meta.url);
return require(path);
};
// 使用示例
const legacyModule = loadCJS('./legacy.cjs');