JavaScript 模块系统

深入解析 CommonJS 与 ES Module 的本质区别、静态结构 vs 运行时求值、循环导入的处理,以及 import() 动态导入与代码分割。

为什么模块系统是 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';

六、延展阅读