模块化概述
模块化是将程序分解为独立、可重用的代码单元的开发方式。JavaScript 的模块化经历了漫长的发展过程,目前主要有两种主流模块系统:
- ES Module (ESM) - ECMAScript 标准模块系统
- CommonJS (CJS) - Node.js 默认模块系统
为什么需要模块化
- 代码组织:将大型程序分解为小的、可管理的模块
- 避免命名冲突:模块有自己的作用域
- 可重用性:模块可以在不同项目中复用
- 依赖管理:明确模块间的依赖关系
- 可维护性:模块可以独立开发和测试
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 特点
- 同步加载:模块在 require 时同步加载和执行
- 运行时加载:模块在代码运行时才被加载和解析
- 缓存机制:模块首次加载后会被缓存,后续 require 返回缓存
- 动态导入:require 可以出现在代码的任何地方
- module.exports 和 exports:
module.exports是真正的导出对象exports是module.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 特点
- 静态加载:import/export 必须在模块顶层,不能在条件语句中
- 编译时加载:模块依赖关系在代码编译阶段确定
- 实时绑定:导入的是值的引用,不是值的拷贝
- 严格模式:模块默认在严格模式下执行
- 异步加载:浏览器中 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 的区别
| 特性 | CommonJS | ES Module |
|---|---|---|
| 加载方式 | 同步加载 | 异步加载 |
| 加载时机 | 运行时加载 | 编译时静态解析 |
| 导入语法 | require() | import |
| 导出语法 | module.exports/exports | export/export default |
| 模块作用域 | 文件作用域 | 模块作用域 |
| 值传递 | 值的拷贝 | 值的引用(实时绑定) |
| 动态导入 | 支持(任何位置) | 支持(通过 import()) |
| 循环依赖处理 | 可能返回未完全初始化的模块 | 正确处理,实时绑定 |
顶层 this | 指向 module.exports | 指向 undefined |
| 严格模式 | 默认非严格 | 默认严格 |
| 主要使用环境 | Node.js | 浏览器和现代 Node.js |
Node.js 中的模块系统
Node.js 最初仅支持 CommonJS,从 v12 开始实验性支持 ESM,v14 后逐渐稳定。
在 Node.js 中使用 ESM
- 文件扩展名:使用
.mjs扩展名,或 - package.json:设置
"type": "module" - 命令行标志:使用
--input-type=module
双模块系统共存
Node.js 允许 CommonJS 和 ESM 互相调用,但有重要限制:
-
CommonJS 导入 ESM:
- 必须使用动态
import() - 不能使用
require()
// commonjs 文件 (async () => { const esModule = await import('./es-module.mjs'); esModule.someFunction(); })(); - 必须使用动态
-
ESM 导入 CommonJS:
- 可以使用
import语法 - CommonJS 模块的
module.exports会作为 ESM 的默认导出
// esm 文件 import cjsModule from './commonjs-module.cjs'; console.log(cjsModule.someValue); - 可以使用
模块解析算法
Node.js 使用不同的算法解析模块路径:
-
CommonJS:
- 优先尝试
.js、.json、.node扩展名 - 检查目录下的
index.js、index.json、index.node
- 优先尝试
-
ES Module:
- 必须提供完整路径或明确的扩展名
- 可以配置
"exports"字段实现更精细的控制
模块打包工具
由于浏览器对 ESM 的支持和兼容性问题,开发者常使用打包工具处理模块:
- Webpack:支持多种模块系统
- Rollup:专注于 ESM 打包
- Parcel:零配置打包工具
- Vite:基于原生 ESM 的开发服务器
最佳实践
- 新项目:优先使用 ES Module
- Node.js 项目:
- 新项目设置
"type": "module" - 旧项目继续使用 CommonJS 或逐步迁移
- 新项目设置
- 浏览器项目:
- 使用打包工具处理模块
- 或直接使用
<script type="module">(现代浏览器)
- 混合项目:
- 明确文件扩展名(
.cjs或.mjs) - 在
package.json中配置"exports"字段
- 明确文件扩展名(
未来趋势
- ES Module 成为标准:浏览器和 Node.js 都原生支持
- import maps:允许控制模块解析,减少对打包工具的依赖
- 顶级 await:在模块顶层直接使用
await - WebAssembly 模块:与 JavaScript 模块系统集成