ES Module 与 CommonJS

86 阅读4分钟

模块化概述

模块化是将程序分解为独立、可重用的代码单元的开发方式。JavaScript 的模块化经历了漫长的发展过程,目前主要有两种主流模块系统:

  1. ES Module (ESM) - ECMAScript 标准模块系统
  2. CommonJS (CJS) - Node.js 默认模块系统

为什么需要模块化

  1. 代码组织:将大型程序分解为小的、可管理的模块
  2. 避免命名冲突:模块有自己的作用域
  3. 可重用性:模块可以在不同项目中复用
  4. 依赖管理:明确模块间的依赖关系
  5. 可维护性:模块可以独立开发和测试

CommonJS 模块系统

CommonJS 是 Node.js 默认采用的模块系统,设计初衷是为服务器端 JavaScript 提供模块化支持。

基本语法

导出模块

// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;

// 方式1:导出对象
module.exports = {
  add,
  subtract
};

// 方式2:单独导出属性
exports.add = add;
exports.subtract = subtract;

导入模块

// app.js
const math = require('./math.js');

console.log(math.add(2, 3)); // 5
console.log(math.subtract(5, 2)); // 3

// 也可以直接解构
const { add, subtract } = require('./math.js');

CommonJS 特点

  1. 同步加载:模块在 require 时同步加载和执行
  2. 运行时加载:模块在代码运行时才被加载和解析
  3. 缓存机制:模块首次加载后会被缓存,后续 require 返回缓存
  4. 动态导入:require 可以出现在代码的任何地方
  5. module.exports 和 exports
    • module.exports 是真正的导出对象
    • exportsmodule.exports 的引用
    • 直接给 exports 赋值会断开引用关系

CommonJS 循环依赖

CommonJS 处理循环依赖时,可能返回未完全初始化的模块:

// a.js
exports.done = false;
const b = require('./b.js');
console.log('在 a.js 中,b.done = %j', b.done);
exports.done = true;

// b.js
exports.done = false;
const a = require('./a.js');
console.log('在 b.js 中,a.done = %j', a.done);
exports.done = true;

// main.js
const a = require('./a.js');
const b = require('./b.js');

输出:

在 b.js 中,a.done = false
在 a.js 中,b.done = true

ES Module (ESM)

ES Module 是 ECMAScript 2015 (ES6) 引入的官方模块系统,现代浏览器和 Node.js 都支持。 hacks.mozilla.org/2018/03/es-…

基本语法

导出模块

// math.js
// 命名导出
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

// 默认导出(每个模块只能有一个)
export default function multiply(a, b) {
  return a * b;
}

导入模块

// app.js
// 导入命名导出
import { add, subtract } from './math.js';

// 导入默认导出
import multiply from './math.js';

// 导入全部
import * as math from './math.js';

console.log(add(2, 3)); // 5
console.log(multiply(2, 3)); // 6

ES Module 特点

  1. 静态加载:import/export 必须在模块顶层,不能在条件语句中
  2. 编译时加载:模块依赖关系在代码编译阶段确定
  3. 实时绑定:导入的是值的引用,不是值的拷贝
  4. 严格模式:模块默认在严格模式下执行
  5. 异步加载:浏览器中 ESM 是异步加载的

ES Module 循环依赖

ESM 处理循环依赖时,由于实时绑定的特性,可以正确访问到其他模块导出的最新值:

// a.js
import { bar } from './b.js';
export function foo() {
  console.log('foo');
  bar();
}
console.log('a.js 执行完毕');

// b.js
import { foo } from './a.js';
export function bar() {
  console.log('bar');
  foo();
}
console.log('b.js 执行完毕');

// main.js
import { foo } from './a.js';
foo();

CommonJS 与 ES Module 的区别

特性CommonJSES Module
加载方式同步加载异步加载
加载时机运行时加载编译时静态解析
导入语法require()import
导出语法module.exports/exportsexport/export default
模块作用域文件作用域模块作用域
值传递值的拷贝值的引用(实时绑定)
动态导入支持(任何位置)支持(通过 import()
循环依赖处理可能返回未完全初始化的模块正确处理,实时绑定
顶层 this指向 module.exports指向 undefined
严格模式默认非严格默认严格
主要使用环境Node.js浏览器和现代 Node.js

Node.js 中的模块系统

Node.js 最初仅支持 CommonJS,从 v12 开始实验性支持 ESM,v14 后逐渐稳定。

在 Node.js 中使用 ESM

  1. 文件扩展名:使用 .mjs 扩展名,或
  2. package.json:设置 "type": "module"
  3. 命令行标志:使用 --input-type=module

双模块系统共存

Node.js 允许 CommonJS 和 ESM 互相调用,但有重要限制:

  1. CommonJS 导入 ESM

    • 必须使用动态 import()
    • 不能使用 require()
    // commonjs 文件
    (async () => {
      const esModule = await import('./es-module.mjs');
      esModule.someFunction();
    })();
    
  2. ESM 导入 CommonJS

    • 可以使用 import 语法
    • CommonJS 模块的 module.exports 会作为 ESM 的默认导出
    // esm 文件
    import cjsModule from './commonjs-module.cjs';
    console.log(cjsModule.someValue);
    

模块解析算法

Node.js 使用不同的算法解析模块路径:

  1. CommonJS

    • 优先尝试 .js.json.node 扩展名
    • 检查目录下的 index.jsindex.jsonindex.node
  2. ES Module

    • 必须提供完整路径或明确的扩展名
    • 可以配置 "exports" 字段实现更精细的控制

模块打包工具

由于浏览器对 ESM 的支持和兼容性问题,开发者常使用打包工具处理模块:

  1. Webpack:支持多种模块系统
  2. Rollup:专注于 ESM 打包
  3. Parcel:零配置打包工具
  4. Vite:基于原生 ESM 的开发服务器

最佳实践

  1. 新项目:优先使用 ES Module
  2. Node.js 项目
    • 新项目设置 "type": "module"
    • 旧项目继续使用 CommonJS 或逐步迁移
  3. 浏览器项目
    • 使用打包工具处理模块
    • 或直接使用 <script type="module">(现代浏览器)
  4. 混合项目
    • 明确文件扩展名(.cjs.mjs
    • package.json 中配置 "exports" 字段

未来趋势

  1. ES Module 成为标准:浏览器和 Node.js 都原生支持
  2. import maps:允许控制模块解析,减少对打包工具的依赖
  3. 顶级 await:在模块顶层直接使用 await
  4. WebAssembly 模块:与 JavaScript 模块系统集成