JavaScript 模块化三剑客:ESM、CommonJS、UMD 深度解析

7 阅读5分钟

在前端开发中,模块化是构建可维护、可扩展应用程序的基石。随着 JavaScript 生态的演进,出现了多种模块化规范,其中 ESMCommonJSUMD 是最具代表性的三种。本文将深入解析它们的差异、工作原理和最佳实践。

一、模块化发展简史

在深入细节之前,先了解模块化的发展脉络:

时期方案特点
早期全局函数/命名空间污染全局,依赖管理混乱
2009年CommonJSNode.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 核心特性

  1. 静态解析:在编译阶段确定依赖关系
  2. 实时绑定:导入的是值的引用,不是拷贝
  3. 异步加载:浏览器原生支持异步加载
  4. 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.jsontype 决定
  • .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 核心特性

  1. 动态加载require() 可在代码任意位置调用
  2. 值拷贝:导出的是值的拷贝(对象是引用拷贝)
  3. 同步加载:适合服务器端文件系统
  4. 缓存机制:相同模块只加载一次

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.mjsb.mjs 循环依赖
3. 执行阶段:
   - 先执行 b.mjs
   - 导入 aVal 时,a.mjs 未执行到初始化,得到 undefined
   - 执行 a.mjs
   - 导入 bVal 时,b.mjs 已执行完毕,得到 'b初始值'

结果特点:

  • 导入导出是实时绑定
  • 存在暂时性死区(TDZ)
  • 值的变化会实时同步

5.3 循环依赖对比总结

特性CommonJSESM
加载时机运行时动态加载编译时静态分析
值传递值拷贝(对象是引用拷贝)实时绑定(引用传递)
未完成模块返回当前已导出部分得到 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 加载性能对比

场景CommonJSESM
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 发展趋势

  1. ESM 成为标准:浏览器、Node.js 原生支持
  2. 构建工具优化:Vite、Snowpack 等基于 ESM 的工具兴起
  3. TypeScript 支持moduleResolution: "nodenext" 等新选项

10.2 最佳实践总结

  1. 新项目:直接使用 ESM
  2. 现有项目:渐进式迁移,优先迁移新模块
  3. 开源库:提供多格式输出,优先 ESM
  4. 避免循环依赖:通过设计减少模块间耦合

10.3 资源推荐

结语

模块化是 JavaScript 工程的基石,理解 ESM、CommonJS 和 UMD 的差异对于构建现代应用至关重要。随着生态的发展,ESM 正成为主流标准,但 CommonJS 在现有项目中仍有重要地位。掌握这些知识,将帮助您做出更合适的技术决策,构建更健壮的应用。

记住:没有最好的模块系统,只有最适合当前场景的选择。根据项目需求、团队技能和生态支持,合理选择并灵活运用,才是真正的工程智慧。