为什么模块系统是 JavaScript 工程化的基石
在现代前端项目中,模块系统无处不在——无论是 Node.js 的 require/module.exports,还是浏览器的 import/export。理解模块系统的运作机制,是理解打包工具(Webpack、Vite)、优化策略(Tree Shaking)、性能优化的前提。
这篇文章深入解析 CommonJS 和 ES Module 的本质差异,以及 JavaScript 模块系统的历史演进。
一、CommonJS vs ES Module
1.1 两种模块系统的对比
// CommonJS
const fs = require('fs');
const { readFile } = require('fs');
module.exports = { readFile };
// ES Module
import fs from 'fs';
import { readFile } from 'fs';
export { readFile };
export default fs;
| 特性 | CommonJS | ES Module |
|---|---|---|
| 语法 | require/module.exports | import/export |
| 加载时机 | 运行时 | 编译时 |
| 解析 | 同步 | 同步(但 import() 动态) |
| 输出 | 值拷贝 | 值引用(只读绑定) |
| 循环处理 | 欠套但有问题 | 欠套但有处理机制 |
1.2 运行时 vs 编译时
// CommonJS — 可以在运行时决定导入
const moduleName = 'fs';
const fs = require(moduleName);
// ES Module — 必须是静态字符串
import fs from 'fs'; // OK
import fs from './' + 'fs'; // SyntaxError — 不能使用表达式
// import() 动态导入可以运行时决定
const moduleName = await import('./module.js');
1.3 值拷贝 vs 值引用
// CommonJS — 值拷贝
// counter.js
let count = 0;
module.exports = { count };
// main.js
const { count } = require('./counter');
console.log(count); // 0
count = 10; // 修改的是局部变量,不影响模块内部
console.log(count); // 10
// ES Module — 值引用(只读绑定)
// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment(); // 调用函数修改 count
console.log(count); // 1 — count 变了
// count = 10; // TypeError — 只读绑定
二、ES Module 的导出类型
2.1 命名导出
// 方式一:逐个导出
export const PI = 3.14159;
export function add(a, b) { return a + b; }
// 方式二:集中导出
const PI = 3.14159;
function add(a, b) { return a + b; }
export { PI, add };
// 方式三:重命名导出
export { PI as π, add as sum };
2.2 默认导出
// 每个模块只能有一个 default 导出
export default class User {
constructor(name) {
this.name = name;
}
}
// 导入 default
import User from './user.js';
import MyUser from './user.js'; // 可以任意命名
2.3 混合导出
// module.js
export const A = 1;
export default function() { return 'default'; }
// main.js
import defaultFn, { A } from './module.js';
// 或者
import * as module from './module.js';
console.log(module.A); // 1
console.log(module.default()); // 'default'
三、循环导入(Circular Dependency)
3.1 循环导入问题
// a.js
const { b } = require('./b');
console.log('a:', b);
module.exports = { name: 'a' };
// b.js
const { a } = require('./a');
console.log('b:', a);
module.exports = { name: 'b' };
// main.js
require('./a');
// 输出:
// b: {}
// a: { name: 'b' }
3.2 ES Module 的循环处理
// a.mjs
import { b } from './b.mjs';
console.log('a:', b);
export const name = 'a';
// b.mjs
import { name } from './a.mjs';
console.log('b:', name); // undefined — a 还未完成初始化
export const b = { name: 'b' };
// 执行顺序:
// 1. 从 main.mjs 开始
// 2. 发现 import 'a.mjs',开始加载 a
// 3. a 依赖 b,开始加载 b
// 4. b 依赖 a,但 a 尚未完成,所以 b 看到的是未初始化的 a(TDZ)
3.3 解决循环依赖的方案
// 方案一:延迟导入(在函数内导入)
class A {
getB() {
// 在使用时才导入,避免初始化时序问题
const { B } = require('./B');
return new B();
}
}
// 方案二:重新设计模块结构
// 将公共依赖提取到独立模块
// 方案三:使用 import() 动态导入
async function loadB() {
const { B } = await import('./B');
return new B();
}
四、动态导入 import()
4.1 import() 返回 Promise
// 静态导入(同步)
import fs from 'fs';
// 动态导入(异步)
const fsModule = await import('fs');
// 代码分割:按需加载
button.addEventListener('click', async () => {
const { heavyModule } = await import('./heavy-module.js');
heavyModule.init();
});
4.2 预加载 preload
// 动态预加载
const link = document.createElement('link');
link.rel = 'modulepreload';
link.href = '/path/to/module.js';
document.head.appendChild(link);
// 或者使用
<link rel="modulepreload" href="/path/to/module.js">
4.3 导入断言(Import Assertions)
// 导入 JSON
import data from './data.json' assert { type: 'json' };
// 或使用 with
import data2 from './data2.json' with { type: 'json' };
// 浏览器端动态导入
const module = await import(url, { assert: { type: 'javascript' } });
五、模块解析算法
5.1 Node.js 解析算法
模块解析顺序:
1. 内置模块(fs, path, http 等)
2. 文件模块
- 相对路径:./foo, ../foo
- 绝对路径:/foo
- 裸模块:foo(查找 node_modules)
3. 目录作为包
- package.json 的 main 字段
- index.js
5.2 ES Module 路径规则
// .js / .mjs — ES Module
// .cjs — CommonJS
// .ts / .tsx — 需要构建工具处理
// 导入时必须包含扩展名
import foo from './foo.js'; // OK
import foo from './foo'; // 浏览器不支持,Node.js 可配置
5.3 路径别名
// vite.config.js
import path from 'path';
export default {
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@components': path.resolve(__dirname, './src/components')
}
}
};
// 使用
import Button from '@/components/Button.js';
import { useAuth } from '@components/useAuth.js';