在前端开发中,模块化是构建可维护、可扩展应用程序的基石。随着 JavaScript 生态的演进,出现了多种模块化规范,其中 ESM、CommonJS 和 UMD 是最具代表性的三种。本文将深入解析它们的差异、工作原理和最佳实践。
一、模块化发展简史
在深入细节之前,先了解模块化的发展脉络:
| 时期 | 方案 | 特点 |
|---|---|---|
| 早期 | 全局函数/命名空间 | 污染全局,依赖管理混乱 |
| 2009年 | CommonJS | Node.js 原生支持,同步加载 |
| 2011年 | AMD | 异步加载,适合浏览器 |
| 2015年 | ES6 Modules | 官方标准,静态分析 |
| 通用方案 | UMD | 兼容多种环境 |
二、ESM(ECMAScript Modules)
2.1 基本语法
// 导出模块:math.js
export const PI = 3.14159;
export function add(x, y) {
return x + y;
}
export default class Calculator {
multiply(x, y) {
return x * y;
}
}
// 导入模块:app.js
import Calculator, { PI, add } from './math.js';
const calc = new Calculator();
console.log(calc.multiply(2, 3)); // 6
console.log(add(PI, 1)); // 4.14159
2.2 核心特性
- 静态解析:在编译阶段确定依赖关系
- 实时绑定:导入的是值的引用,不是拷贝
- 异步加载:浏览器原生支持异步加载
- Tree Shaking:支持静态分析,删除未使用代码
2.3 浏览器使用方式
<!-- 传统脚本 -->
<script src="legacy.js"></script>
<!-- ESM 模块 -->
<script type="module">
import { func } from './module.js';
func();
</script>
<!-- 外部模块 -->
<script type="module" src="app.js"></script>
2.4 Node.js 中使用
package.json 配置:
{
"type": "module", // 启用 ESM
"main": "index.js",
"exports": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
}
}
文件扩展名:
.js:根据package.json的type决定.mjs:强制作为 ESM 模块.cjs:强制作为 CommonJS 模块
三、CommonJS(CJS)
3.1 基本语法
// 导出模块:utils.js
const version = '1.0.0';
const helper = () => {
console.log('Helper function');
};
module.exports = {
version,
helper,
// 或者逐个导出
// exports.version = version;
};
// 导入模块:app.js
const utils = require('./utils.js');
console.log(utils.version); // '1.0.0'
utils.helper(); // 'Helper function'
3.2 核心特性
- 动态加载:
require()可在代码任意位置调用 - 值拷贝:导出的是值的拷贝(对象是引用拷贝)
- 同步加载:适合服务器端文件系统
- 缓存机制:相同模块只加载一次
3.3 Node.js 模块系统
// Node.js 内部变量
console.log(__dirname); // 当前目录
console.log(__filename); // 当前文件路径
console.log(module); // 当前模块信息
console.log(exports); // 导出对象
console.log(require); // 导入函数
console.log(require.cache);// 模块缓存
四、UMD(Universal Module Definition)
4.1 基本结构
(function (root, factory) {
// 环境检测和适配
if (typeof define === 'function' && define.amd) {
// AMD 环境 (如 RequireJS)
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// CommonJS 环境 (Node.js)
module.exports = factory(require('jquery'));
} else {
// 浏览器全局环境
root.MyLibrary = factory(root.jQuery);
}
}(this, function ($) {
// 模块实际代码
function myFunction() {
console.log('UMD module loaded');
}
return {
myFunction: myFunction
};
}));
4.2 自动生成的 UMD 配置
现代打包工具可自动生成 UMD 模块:
webpack.config.js:
module.exports = {
output: {
library: 'MyLibrary',
libraryTarget: 'umd',
globalObject: 'this'
}
};
Rollup 配置:
export default {
output: {
file: 'bundle.js',
format: 'umd',
name: 'MyLibrary'
}
};
五、循环依赖深度解析
5.1 CommonJS 循环依赖原理
示例场景:
// a.js
console.log('a 开始执行');
exports.done = false;
const b = require('./b.js');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 执行完毕');
// b.js
console.log('b 开始执行');
exports.done = false;
const a = require('./a.js');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 执行完毕');
执行流程分析:
1. 加载 a.js
2. a.js 开始执行,导出 {done: false}
3. a.js 遇到 require('./b.js'),暂停执行,加载 b.js
4. b.js 开始执行,导出 {done: false}
5. b.js 遇到 require('./a.js'),返回 a.js 的缓存 {done: false}
6. b.js 继续执行,导出 {done: true}
7. 控制权返回 a.js,继续执行
结果特点:
- 模块部分加载时就被缓存
- 后续
require()返回缓存值 - 可能导致状态不一致
5.2 ESM 循环依赖原理
示例场景:
// a.mjs
console.log('a 开始');
import { bVal } from './b.mjs';
console.log('a 导入 bVal:', bVal);
export let aVal = 'a初始值';
console.log('a 设置 aVal:', aVal);
// b.mjs
console.log('b 开始');
import { aVal } from './a.mjs';
console.log('b 导入 aVal:', aVal); // undefined
export let bVal = 'b初始值';
console.log('b 设置 bVal:', bVal);
执行流程分析:
1. 静态分析阶段:建立模块依赖图
2. 发现 a.mjs 和 b.mjs 循环依赖
3. 执行阶段:
- 先执行 b.mjs
- 导入 aVal 时,a.mjs 未执行到初始化,得到 undefined
- 执行 a.mjs
- 导入 bVal 时,b.mjs 已执行完毕,得到 'b初始值'
结果特点:
- 导入导出是实时绑定
- 存在暂时性死区(TDZ)
- 值的变化会实时同步
5.3 循环依赖对比总结
| 特性 | CommonJS | ESM |
|---|---|---|
| 加载时机 | 运行时动态加载 | 编译时静态分析 |
| 值传递 | 值拷贝(对象是引用拷贝) | 实时绑定(引用传递) |
| 未完成模块 | 返回当前已导出部分 | 得到 undefined(TDZ) |
| 状态同步 | 不同步(独立拷贝) | 同步(实时更新) |
| 解决方案 | 函数封装、延迟获取 | 调整顺序、函数导出 |
5.4 处理循环依赖的最佳实践
方案1:依赖注入模式
// event-bus.js
export const EventBus = {
events: new Map(),
on(event, handler) {
if (!this.events.has(event)) this.events.set(event, []);
this.events.get(event).push(handler);
},
emit(event, data) {
(this.events.get(event) || []).forEach(handler => handler(data));
}
};
// a.js 和 b.js 都依赖 event-bus.js,而非相互依赖
方案2:提取公共模块
// 将公共逻辑提取到独立模块
// shared.js
export const sharedHelper = () => { /* 共享逻辑 */ };
// a.js 和 b.js 都导入 shared.js
方案3:函数导出法
// 导出函数而非直接导出值
// a.js
export function getValue() {
return computeValue(); // 运行时计算
}
// b.js
import { getValue } from './a.js';
const value = getValue(); // 延迟获取最新值
六、实际应用对比
6.1 构建工具配置示例
Webpack(支持混合使用)
// webpack.config.js
module.exports = {
// 默认支持 CommonJS 和 ESM
resolve: {
// 自动解析扩展名
extensions: ['.js', '.mjs', '.cjs', '.json']
},
module: {
rules: [
{
test: /\.js$/,
// 使用 Babel 转换
loader: 'babel-loader'
}
]
}
};
Babel 转换配置
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
modules: false, // 保持 ESM 语法
}]
]
};
6.2 Package.json 配置策略
多格式库的配置:
{
"name": "my-library",
"version": "1.0.0",
// 默认入口(CommonJS)
"main": "./dist/index.cjs.js",
// ESM 入口
"module": "./dist/index.esm.js",
// 浏览器入口(UMD)
"browser": "./dist/index.umd.js",
// 现代 Node.js 支持的 exports 字段
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js",
"browser": "./dist/index.umd.js"
},
"./style.css": "./dist/style.css"
},
"types": "./dist/index.d.ts",
"files": ["dist"]
}
6.3 实际项目选择指南
前端应用开发:
// 现代前端框架(React/Vue)推荐使用 ESM
// vite.config.js
export default {
build: {
// 默认输出 ESM 格式
target: 'esnext',
// 拆分包
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom']
}
}
}
}
};
Node.js 服务端:
// Node.js 14+ 可混合使用,推荐逐步迁移到 ESM
// 渐进式迁移方案:
// 1. 将配置文件改为 .cjs
// 2. 新文件使用 .mjs
// 3. 逐步迁移旧文件
开源库开发:
// 构建多格式输出
// rollup.config.js
export default [
{
input: 'src/index.js',
output: [
{ file: 'dist/index.cjs.js', format: 'cjs' },
{ file: 'dist/index.esm.js', format: 'es' },
{
file: 'dist/index.umd.js',
format: 'umd',
name: 'MyLibrary'
}
]
}
];
七、性能与优化
7.1 加载性能对比
| 场景 | CommonJS | ESM |
|---|---|---|
| Node.js 启动 | 较快(同步) | 稍慢(需解析) |
| 浏览器首次加载 | 需打包 | 原生支持,可并行 |
| 动态导入 | require() 同步 | import() 异步 |
| Tree Shaking | 不支持 | 原生支持 |
7.2 代码分割示例
ESM 动态导入:
// 按需加载,提高首屏性能
async function loadComponent() {
const { Component } = await import('./Component.js');
return Component;
}
// 预加载提示
import('./module.js')
.then(module => module.init())
.catch(err => console.error('加载失败:', err));
Webpack 代码分割:
// 魔法注释
import(
/* webpackChunkName: "lodash" */
/* webpackPrefetch: true */
'lodash'
).then(({ default: _ }) => {
// 使用 lodash
});
八、迁移策略
8.1 从 CommonJS 迁移到 ESM
步骤1:评估兼容性
{
"engines": {
"node": ">=14.0.0"
},
"type": "module"
}
步骤2:逐步迁移
# 1. 重命名文件
mv index.js index.cjs
mv new-module.js new-module.mjs
# 2. 更新导入导出语法
# CommonJS -> ESM
module.exports = {} -> export default {}
exports.func = func -> export function func() {}
require('./module') -> import module from './module.js'
# 3. 处理动态导入
const config = require(`./${env}.js`) -> const config = await import(`./${env}.js`)
步骤3:处理差异
// __dirname 和 __filename 的替代方案
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const filePath = join(__dirname, 'file.txt');
8.2 构建工具的迁移
Webpack 5+ 配置:
// webpack.config.js
module.exports = {
experiments: {
outputModule: true, // 输出 ESM
},
output: {
module: true,
environment: {
// 设置模块环境
module: true,
dynamicImport: true,
}
}
};
九、工具与生态
9.1 检测工具
# 检测循环依赖
npx madge --circular src/
# 分析包大小
npx source-map-explorer dist/*.js
# 查看模块树
npx npm ls --depth=5
9.2 开发工具支持
VSCode 配置:
{
"javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative",
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
]
}
ESLint 配置:
{
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"import/no-cycle": ["error", { "maxDepth": 2 }],
"import/no-relative-parent-imports": "off"
}
}
十、未来展望
10.1 发展趋势
- ESM 成为标准:浏览器、Node.js 原生支持
- 构建工具优化:Vite、Snowpack 等基于 ESM 的工具兴起
- TypeScript 支持:
moduleResolution: "nodenext"等新选项
10.2 最佳实践总结
- 新项目:直接使用 ESM
- 现有项目:渐进式迁移,优先迁移新模块
- 开源库:提供多格式输出,优先 ESM
- 避免循环依赖:通过设计减少模块间耦合
10.3 资源推荐
结语
模块化是 JavaScript 工程的基石,理解 ESM、CommonJS 和 UMD 的差异对于构建现代应用至关重要。随着生态的发展,ESM 正成为主流标准,但 CommonJS 在现有项目中仍有重要地位。掌握这些知识,将帮助您做出更合适的技术决策,构建更健壮的应用。
记住:没有最好的模块系统,只有最适合当前场景的选择。根据项目需求、团队技能和生态支持,合理选择并灵活运用,才是真正的工程智慧。