模块与导入导出
随着 JavaScript 应用规模的增长,模块化成为组织代码的必备手段。JavaScript 主要有两套模块系统:CommonJS(CJS)和 ES Modules(ESM),它们在设计理念和语法上有着本质区别。
CommonJS(CJS)
CommonJS 是 Node.js 最初的模块系统,使用 require 和 module.exports。
基本语法
// math.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = { add, multiply };
// 也可以这样导出单个值
// module.exports = add;// main.js
const math = require('./math.js');
console.log(math.add(2, 3)); // 5
console.log(math.multiply(4, 5)); // 20
// 解构导入
const { add, multiply } = require('./math.js');CJS 的特点
// 1. 运行时加载 —— 可以在条件语句中使用
if (process.env.NODE_ENV === 'development') {
const logger = require('./dev-logger');
logger.init();
}
// 2. 同步加载
const fs = require('fs');
const data = fs.readFileSync('./data.json');
// 3. 值的拷贝 —— 导出后修改内部值不影响已导入的副本
let count = 0;
module.exports = { count };
// 导入方拿到的是 count=0 的拷贝module.exports 与 exports
// exports 是 module.exports 的引用
exports.add = add; // OK
exports = { add }; // 危险!切断了与 module.exports 的链接
module.exports = { add }; // OK,推荐写法ES Modules(ESM)
ESM 是 ECMAScript 官方标准,使用 import 和 export,现代浏览器和 Node.js 均原生支持。
命名导出与导入
// utils.js
export const PI = 3.14159;
export function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export class Validator {
static isEmail(str) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
}
}// main.js
import { PI, capitalize, Validator } from './utils.js';
console.log(PI); // 3.14159
console.log(capitalize('hello')); // 'Hello'
console.log(Validator.isEmail('test@example.com')); // true
// 重命名导入
import { capitalize as cap } from './utils.js';
console.log(cap('world')); // 'World'
// 批量导入
import * as utils from './utils.js';
console.log(utils.PI);默认导出
一个模块只能有一个默认导出:
// config.js
const config = {
apiBase: 'https://api.example.com',
timeout: 5000,
retries: 3
};
export default config;// main.js
import config from './config.js'; // 任意命名
import myConfig from './config.js'; // 可以叫任何名字
import { default as config } from './config.js'; // 等价写法混合导出
// api.js
export const API_VERSION = 'v2';
export function get(url) { /* ... */ }
export function post(url, data) { /* ... */ }
// 默认导出 Axios 风格实例
export default {
get,
post,
request(config) { /* ... */ }
};import api, { API_VERSION, get } from './api.js';CJS vs ESM 对比
| 特性 | CommonJS | ES Modules |
|---|---|---|
| 语法 | require / module.exports | import / export |
| 加载时机 | 运行时同步加载 | 编译时静态解析 |
| 加载方式 | 同步 | 异步(浏览器)/ 同步(Node) |
顶层 this | module.exports | undefined |
| 条件导入 | 支持 | 不支持顶层,需用动态导入 |
| 循环依赖 | 部分支持 | 支持(更优) |
| tree-shaking | 困难 | 天然支持 |
动态导入
ESM 的 import() 函数允许在运行时动态加载模块,返回一个 Promise:
// 基本用法
async function loadPlugin(name) {
const plugin = await import(`./plugins/${name}.js`);
plugin.init();
}
// 条件加载
if (process.env.ENABLE_ANALYTICS) {
const { track } = await import('./analytics.js');
track('page_view');
}
// 同时加载多个
const [moduleA, moduleB] = await Promise.all([
import('./a.js'),
import('./b.js')
]);动态导入是实现代码分割(Code Splitting)和懒加载的核心手段:
// React 中的路由懒加载
const Dashboard = lazy(() => import('./pages/Dashboard.jsx'));循环依赖
循环依赖(A 依赖 B,B 又依赖 A)在复杂项目中难以避免。
CJS 中的循环依赖
// a.js
console.log('a.js 开始执行');
exports.done = false;
const b = require('./b.js');
console.log('a.js: b.done =', b.done);
exports.done = true;
console.log('a.js 执行完毕');
// b.js
console.log('b.js 开始执行');
const a = require('./a.js');
console.log('b.js: a.done =', a.done);
exports.done = true;
console.log('b.js 执行完毕');输出:
a.js 开始执行
b.js 开始执行
b.js: a.done = false // a 还没执行完,拿到不完整导出
b.js 执行完毕
a.js: b.done = true
a.js 执行完毕CJS 在循环依赖时返回已执行部分的导出副本,可能导致拿到未完成的模块。
ESM 中的循环依赖
ESM 通过实时绑定(Live Binding)更好地处理循环依赖:
// a.mjs
console.log('a.mjs 开始');
export let done = false;
import * as b from './b.mjs';
console.log('a.mjs: b.done =', b.done);
done = true;
console.log('a.mjs 结束');
// b.mjs
console.log('b.mjs 开始');
import * as a from './a.mjs';
console.log('b.mjs: a.done =', a.done);
export let done = true;
console.log('b.mjs 结束');ESM 的导出是引用绑定而非值拷贝,当 a.done 被赋值为 true 时,所有引用它的地方都能看到最新值。
避免循环依赖的建议
- 提取公共模块:将共享代码提取到第三个模块
- 延迟引用:在函数内部动态 require/import
- 重构代码:重新考虑模块边界
// 方案:在函数内部延迟引用
// a.js
import { helper } from './common.js';
export function doA() {
// 延迟加载,避免顶层循环依赖
const b = require('./b.js');
return helper() + b.doB();
}实际项目中的模块策略
Node.js 中使用 ESM
// package.json
{
"type": "module",
"scripts": {
"start": "node server.js"
}
}设置 "type": "module" 后,所有 .js 文件按 ESM 解析。如需使用 CJS,将文件改为 .cjs。
导入路径映射
// package.json
{
"imports": {
"#config/*": "./config/*",
"#utils/*": "./src/utils/*"
}
}import { db } from '#config/database.js';
import { formatDate } from '#utils/date.js';包导出映射(exports field)
{
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./helpers": {
"import": "./dist/helpers.mjs",
"require": "./dist/helpers.cjs"
}
}
}这样包可以同时支持 CJS 和 ESM 用户。
小结
| 场景 | 推荐方案 |
|---|---|
| 新项目(Node.js 14+) | ESM + "type": "module" |
| 浏览器应用 | ESM(配合打包工具) |
| 需要动态加载 | import() 动态导入 |
| 库作者 | 同时提供 CJS + ESM 双格式 |
| 避免循环依赖 | 提取公共模块、延迟引用 |
ESM 是 JavaScript 模块化的未来,CJS 在 Node.js 生态中仍有大量存量代码。理解两者的差异和互操作方式,是现代化 JavaScript 开发的必修课。
评论
Written by
AI-Writer
Related Articles
手写栈与队列:从基础结构到实战应用
从零实现 Stack、Queue、Deque 与 Priority Queue,深入理解后进先出与先进先出的底层原理,并通过括号匹配、任务调度等实战案例巩固知识。
Read MoreES2021~ES2025 新特性
系统梳理 ES2021 到 ES2025 每年引入的语言新特性,包括逻辑赋值运算符、顶层 await、装饰器、Records & Tuples 等前沿语法。
Read More