前置知识: HTML5、CSS

JavaScript 模块化

17 min中级

CommonJS、ES Modules 与模块打包工具。

前置知识

学习目标

  • 掌握「1. 历史动机与背景」的核心机制、典型用法与常见陷阱
  • 掌握「1. 形式化定义」的核心机制、典型用法与常见陷阱
  • 掌握「2. 理论推导」的核心机制、典型用法与常见陷阱
  • 掌握「3. CommonJS 详解」的核心机制、典型用法与常见陷阱
  • 掌握「4. ES Modules 详解」的核心机制、典型用法与常见陷阱

1. 历史动机与背景

1.1 没有模块化的痛点

早期 JavaScript 没有原生模块系统,所有 <script> 标签共享全局作用域:

<script src="lib.js"></script>
<script src="utils.js"></script>
<script src="app.js"></script>

这种模式带来三类核心问题:

  • 全局变量污染:不同脚本中的同名变量互相覆盖,运行时错误难以追踪;
  • 依赖关系隐式:脚本加载顺序决定运行结果,删除或重排任意一个 <script> 都可能破坏功能;
  • 难以维护与复用:项目变大后,变量来源无法静态分析,代码无法作为库被其他项目复用。

1.2 模块化的核心目标

一个合格的模块系统应满足以下四个目标:

  1. 作用域隔离:模块内部变量不泄漏到全局;
  2. 显式依赖声明:依赖关系在代码中明确表达,可静态分析;
  3. 可复用性:模块可被多个项目、多次引用而不产生冲突;
  4. 支持打包优化:构建工具可基于依赖图进行 Tree-shaking、Code Splitting、按需加载。

1.3 演进时间线

年份范式代表实现关键贡献
2005全局函数原生简单直接
2007命名空间YUI、jQuery减少全局污染
2009IIFE 模块模式jQuery 插件私有作用域
2009CommonJSNode.js、RequireJS服务端模块标准
2011AMDRequireJS浏览器异步模块
2011CMDSeaJS国内方案,依赖就近
2012UMD通用规范跨环境兼容
2015ES Modules浏览器、Node.js语言原生模块
2024require(esm)Node.js 22CJS 可直接 require ESM

1.4 IIFE 模块模式(早期方案)

IIFE(Immediately Invoked Function Expression)是早期模拟模块化的方式,利用闭包实现私有作用域:

// IIFE 模块模式:revealing module pattern
const counter = (function () {
  // 私有状态,外部无法访问
  let count = 0;

  // 私有方法
  function validate(n) {
    if (typeof n !== 'number') throw new TypeError('必须是数字');
  }

  // 公开 API
  return {
    increment() {
      count++;
      return count;
    },
    decrement() {
      count--;
      return count;
    },
    getCount() {
      return count;
    },
    setCount(n) {
      validate(n);
      count = n;
    },
  };
})();

console.log(counter.increment()); // 1
console.log(counter.getCount()); // 1
console.log(counter.count); // undefined(私有)

局限性:无法管理依赖关系、无法按需加载、无法被其他模块静态分析。

1. 形式化定义

1.1 模块系统的形式化模型

一个模块系统可形式化为五元组:

ModuleSystem=(M,E,I,R,resolve)\text{ModuleSystem} = (M, E, I, R, \text{resolve})

其中:

  • MM 为模块集合,每个模块 m∈Mm \in M 包含源代码 src(m)\text{src}(m) 与唯一标识符 id(m)\text{id}(m)
  • E⊆M×M×ΣE \subseteq M \times M \times \Sigma 为依赖边集合,(m1,m2,s)(m_1, m_2, s) 表示 m1m_1 从 m2m_2 导入符号 ss
  • I:M→2ΣI : M \to 2^\Sigma 为每个模块的导入符号集
  • R:M→2ΣR : M \to 2^\Sigma 为每个模块的导出符号集
  • resolve:(m,s)→m′\text{resolve} : (m, s) \to m' 为符号解析函数,返回符号 ss 在模块 mm 中对应的源模块 m′m'

1.2 依赖图与拓扑排序

模块依赖构成有向图 G=(M,E)G = (M, E)。若 GG 为有向无环图(DAG),则存在拓扑排序 σ\sigma,使得模块按 σ\sigma 顺序加载时所有依赖已就绪:

∀(m1,m2)∈E:σ(m2)<σ(m1)\forall (m_1, m_2) \in E: \sigma(m_2) < \sigma(m_1)

若 GG 包含环(循环依赖),则不存在拓扑排序,模块系统需采用特殊策略(如返回半成品、延迟求值)。

1.3 CommonJS 的值拷贝语义

CommonJS 的 require 返回的是导出对象的值拷贝(对原始类型)或引用(对对象)。形式化地,对于模块 mm 的导出对象 EmE_m:

require(m)=clone(snapshot(Em,trequire))\text{require}(m) = \text{clone}(\text{snapshot}(E_m, t_{\text{require}}))

其中 trequiret_{\text{require}} 是 require 调用时刻。后续 EmE_m 的修改对已 require 的引用不可见(原始类型)或可见(对象属性)。

1.4 ESM 的 Live Binding 语义

ESM 的 import 是实时绑定,导入符号是源模块变量的间接引用:

import(m,s)=indirectRef(m,s)\text{import}(m, s) = \text{indirectRef}(m, s)

任何时刻读取导入符号 ss,等价于读取源模块 mm 中 ss 的当前值。修改源模块的 ss 会立即反映到所有导入处(但导入方不可直接修改 ss)。

1.5 静态分析的形式化

ESM 的 import/export 必须在顶层静态声明,这意味着依赖图 GG 可在编译时构建:

G=buildGraph(parse(src))G = \text{buildGraph}(\text{parse}(\text{src}))

而 CommonJS 的 require 可在任意位置、任意条件分支调用,依赖图只能在运行时构建:

G(t)=buildGraphRuntime(execute(src),t)G(t) = \text{buildGraphRuntime}(\text{execute}(\text{src}), t)

这是 ESM 支持 Tree-shaking 的根本原因:未使用的导出可在编译时安全删除。

2. 理论推导

2.1 模块解析算法复杂度

Node.js 的模块解析算法涉及 node_modules 向上查找:

require('lodash') from /a/b/c/d.js
查找路径:
1. /a/b/c/node_modules/lodash
2. /a/b/node_modules/lodash
3. /a/node_modules/lodash
4. /node_modules/lodash

设项目深度为 dd,每个目录的查找开销为 O(1)O(1)(文件系统 stat),则单次 require 的最坏复杂度为 O(d)O(d)。对 nn 个模块的项目,总解析开销为 O(n⋅d)O(n \cdot d)。

ESM 的解析规则更严格(必须包含扩展名),但 node_modules 查找逻辑相同。

2.2 Tree-shaking 的可达性分析

Tree-shaking 本质是依赖图的可达性分析:

Reachable(m,s)={true若 s 被 entry 直接导入true若 ∃m′,s′:Reachable(m′,s′)∧(m′,s′) 导入 (m,s)falseotherwise\text{Reachable}(m, s) = \begin{cases} \text{true} & \text{若 } s \text{ 被 entry 直接导入} \\ \text{true} & \text{若 } \exists m', s' : \text{Reachable}(m', s') \land (m', s') \text{ 导入 } (m, s) \\ \text{false} & \text{otherwise} \end{cases}

未被标记为 Reachable 的导出可安全删除。但存在以下”副作用”陷阱:

  • 顶层有副作用的代码(如 window.foo = bar)不可删除;
  • 动态属性访问(obj[someVar]())难以静态分析;
  • Class 方法可能通过原型链被外部调用。

2.3 循环依赖的处理策略

设模块 AA 与 BB 互相依赖(A→B→AA \to B \to A),加载顺序为 AA 先执行:

模块系统加载时 BB 拿到的 AA加载完成后 BB 拿到的 AA
CommonJS{}(空对象,未完成导出)仍是空对象(值拷贝)
ESM{}(live binding 未初始化)live binding,可访问已完成导出

关键差异:ESM 的 live binding 在循环依赖场景下最终能拿到完整值,而 CommonJS 拿到的是加载时刻的快照。

2.4 模块缓存的内存影响

CommonJS 的 require.cache 永久持有所有已加载模块的导出对象:

MemoryCJS=∑m∈loadedsize(Em)\text{Memory}_{\text{CJS}} = \sum_{m \in \text{loaded}} \text{size}(E_m)

在长期运行的服务中,缓存可能导致内存泄漏。ESM 的模块记录同样不可回收,但 ESM 模块通常是单例且设计为无状态,泄漏风险较低。

3. CommonJS 详解

3.1 核心语法

// add.cjs
function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

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

// 导出方式 2:逐个挂载
exports.multiply = function (a, b) {
  return a * b;
};
// main.cjs
const { add, subtract, multiply } = require('./add.cjs');

console.log(add(1, 2)); // 3
console.log(subtract(5, 3)); // 2
console.log(multiply(4, 6)); // 24

3.2 module.exports vs exports 陷阱

// 陷阱:exports 是 module.exports 的引用,重新赋值 exports 不生效
exports = { add }; // 错误!只修改了局部变量 exports
console.log(module.exports.add); // undefined

// 正确写法 1:直接修改 module.exports
module.exports = { add };

// 正确写法 2:挂载到 exports(等同于挂载到 module.exports)
exports.add = function (a, b) {
  return a + b;
};

原理:Node.js 在模块包装时执行:

(function (exports, require, module, __filename, __dirname) {
  // 模块代码
});

其中 exports = module.exports = {},故 exports.add = ... 等价于 module.exports.add = ...,但 exports = {...} 只修改了局部变量。

3.3 require() 解析规则

require 的解析遵循以下顺序:

  1. 核心模块:require('fs') 直接返回 Node.js 内置模块;
  2. 文件模块:require('./math') 按以下顺序查找:
    • ./math
    • ./math.js
    • ./math.json
    • ./math.node
    • ./math/index.js
    • ./math/package.json 的 main 字段
  3. 目录模块:require('./dir') 查找 dir/index.js 或 dir/package.json 的 main;
  4. node_modules:require('lodash') 从当前目录向上逐级查找 node_modules/lodash;
  5. NODE_PATH:环境变量指定的额外查找路径(不推荐使用)。

3.4 模块缓存机制

CommonJS 模块首次 require 后被缓存,后续 require 返回同一对象:

// counter.cjs
let count = 0;
module.exports = {
  increment() {
    return ++count;
  },
  getCount() {
    return count;
  },
};
// main.cjs
const a = require('./counter.cjs');
const b = require('./counter.cjs');
console.log(a === b); // true,同一对象
a.increment();
console.log(b.getCount()); // 1,共享状态

清除缓存(特殊场景,如测试热重载):

// 删除单个模块缓存
delete require.cache[require.resolve('./counter.cjs')];

// 删除所有缓存(谨慎使用)
Object.keys(require.cache).forEach((key) => {
  delete require.cache[key];
});

3.5 CommonJS 循环依赖详解

// a.cjs
const b = require('./b.cjs');
console.log('a: b.loaded =', b.loaded); // undefined(b 拿到半成品 a)
exports.loaded = true;
console.log('a: 执行完毕');
// b.cjs
const a = require('./a.cjs');
console.log('b: a.loaded =', a.loaded); // undefined(a 还未导出 loaded)
exports.loaded = true;
console.log('b: 执行完毕');
// main.cjs
require('./a.cjs');

执行流程:

  1. main 加载 a.cjs,将 a 加入缓存(半成品 {});
  2. a 执行到 require('./b.cjs'),暂停 a;
  3. b.cjs 执行到 require('./a.cjs'),返回缓存的半成品 a = {};
  4. b 输出 a.loaded = undefined;
  5. b 导出 loaded = true,执行完毕;
  6. 回到 a,b.loaded = true,a 导出 loaded = true,执行完毕。

应对策略:

// 策略 1:延迟使用依赖(在函数内 require)
const b = require('./b.cjs');
exports.doSomething = function () {
  return b.doOther(); // 调用时 b 已完成初始化
};

// 策略 2:重构模块结构,消除循环
// 将共享逻辑提取到 c.cjs,a 和 b 都依赖 c

// 策略 3:使用工具检测循环依赖
// npx madge --circular src/

4. ES Modules 详解

4.1 具名导出与导入

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

export const PI = 3.14159;
// main.js
import { add, subtract, PI } from './math.js';

console.log(add(1, 2)); // 3
console.log(PI); // 3.14159

重命名导入:

import { add as sum, PI as pi } from './math.js';
console.log(sum(1, 2)); // 3

重命名导出:

// math.js
export { add as sum, subtract as minus };

4.2 默认导出与导入

// logger.js
export default function log(message) {
  console.log(`[LOG] ${message}`);
}

// 等价写法
function log(message) {
  console.log(`[LOG] ${message}`);
}
export { log as default };
// main.js
import log from './logger.js'; // 不需要花括号
log('hello');

默认导出与具名导出共存:

// utils.js
export default function main() {
  console.log('main function');
}

export function helper() {
  console.log('helper function');
}

export const VERSION = '1.0.0';
// main.js
import mainFn, { helper, VERSION } from './utils.js';
// mainFn 是默认导出,helper 和 VERSION 是具名导出

4.3 命名空间导入

import * as math from './math.js';

console.log(math.add(1, 2)); // 3
console.log(math.PI); // 3.14159

注意:命名空间导入的对象是只读视图,不能修改:

math.add = null; // TypeError: Cannot assign to read only property 'add'

4.4 动态导入 import()

动态导入返回 Promise,适合按需加载与代码分割:

// 按路由懒加载
async function loadPage(route) {
  const module = await import(`./pages/${route}.js`);
  return module.default;
}

// 条件加载
if (supportsWebGL()) {
  const { render3D } = await import('./renderer-3d.js');
  render3D();
} else {
  const { render2D } = await import('./renderer-2d.js');
  render2D();
}

// 错误降级
try {
  const mod = await import('./feature.js');
  mod.init();
} catch (err) {
  console.warn('Feature unavailable, falling back to basic mode');
}

4.5 import.meta

ESM 模块中可访问模块自身元信息:

// 浏览器:返回模块的完整 URL
console.log(import.meta.url);
// https://example.com/js/app.js

// Node.js:返回 file:// 协议路径
console.log(import.meta.url);
// file:///home/user/project/app.js

// 获取当前模块路径(Node.js)
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

4.6 顶层 await(ES2022)

在 ESM 模块顶层可直接使用 await:

// config.js
export const config = await fetch('/api/config').then((r) => r.json());

限制:

  • 仅在 ESM 中可用(CommonJS 不可);
  • 顶层 await 会阻塞依赖该模块的所有其他模块;
  • Webpack 需配置 experiments.topLevelAwait: true。

4.7 ESM 的 Live Binding 实证

// counter.js
export let count = 0;

export function increment() {
  count++;
}

export function getCount() {
  return count;
}
// main.js
import { count, increment, getCount } from './counter.js';

console.log(count); // 0
increment();
console.log(count); // 1(live binding,立即反映)
console.log(getCount()); // 1

// 尝试修改导入会报错
count = 10; // TypeError: Assignment to constant variable

对比 CommonJS 的值拷贝:

// counter.cjs
let count = 0;
function increment() {
  count++;
}
module.exports = { count, increment };
// main.cjs
const { count, increment } = require('./counter.cjs');

console.log(count); // 0
increment();
console.log(count); // 0(值拷贝,不反映)

5. ESM vs CommonJS 关键差异

5.1 完整对比表

维度CommonJSES Modules
加载时机运行时静态分析 + 运行时
语法位置require 可在任意位置import/export 必须顶层(动态导入除外)
导出绑定值拷贝/对象引用(可变)实时绑定(live binding,只读)
循环依赖拿到半成品拿到 live binding(最终完整)
Tree-shaking困难(运行时加载)原生支持(静态分析)
this 顶层值module.exportsundefined
__dirname/__filename可用不可用(需 import.meta.url)
require/module可用不可用
JSON 导入require('./data.json')需 import attributes(import x from './data.json' with { type: 'json' })
文件扩展名.cjs / .js.mjs / .js(type:module)
生态Node 传统浏览器/现代 Node/打包器

5.2 导出绑定差异示例

// CommonJS:值拷贝
// mod.cjs
let value = 1;
setTimeout(() => {
  value = 2;
}, 100);
module.exports = { value };
// main.cjs
const { value } = require('./mod.cjs');
setTimeout(() => {
  console.log(value); // 1(不变)
}, 200);
// ESM:live binding
// mod.js
export let value = 1;
setTimeout(() => {
  value = 2;
}, 100);
// main.js
import { value } from './mod.js';
setTimeout(() => {
  console.log(value); // 2(反映修改)
}, 200);

5.3 this 顶层差异

// CommonJS
console.log(this === module.exports); // true

// ESM
console.log(this); // undefined

6. AMD 与 CMD(历史方案)

6.1 AMD(Asynchronous Module Definition)

AMD 是浏览器端最早的异步模块规范,代表实现是 RequireJS:

// 定义模块,声明依赖
define(['jquery', 'underscore'], function ($, _) {
  function doSomething() {
    return _.map([1, 2, 3], (n) => n * 2);
  }

  return { doSomething };
});

// 使用模块
require(['myModule'], function (myModule) {
  myModule.doSomething();
});

特点:

  • 异步加载:浏览器环境不阻塞页面渲染;
  • 依赖前置:所有依赖在 define 第一个参数声明,加载后执行回调;
  • 回调函数:模块定义在回调函数中。

6.2 CMD(Common Module Definition)

CMD 是国内提出的规范,代表实现是 SeaJS:

define(function (require, exports, module) {
  const $ = require('jquery');

  function doSomething() {
    const _ = require('underscore'); // 按需 require
    return _.map([1, 2, 3], (n) => n * 2);
  }

  exports.doSomething = doSomething;
});

特点:

  • 依赖就近:require 可在函数体内任意位置调用;
  • 按需加载:只在真正使用时加载依赖;
  • 写法更接近 CommonJS。

6.3 AMD vs CMD 对比

对比项AMDCMD
代表实现RequireJSSeaJS
依赖声明前置(define 参数)就近(函数体内 require)
执行时机依赖全部加载后执行遇到 require 时执行
推广范围国际国内(阿里系)
现状已淘汰已淘汰

现状:AMD/CMD 已被 ESM 完全取代,了解即可。现代项目统一使用 ESM。

7. UMD(通用模块规范)

UMD 是一组跨环境兼容的模板,让同一份代码同时支持 CommonJS、AMD 与全局变量:

// UMD 模板
(function (root, factory) {
  if (typeof define === 'function' && define.amd) {
    // AMD
    define(['jquery'], factory);
  } else if (typeof module === 'object' && module.exports) {
    // CommonJS
    module.exports = factory(require('jquery'));
  } else {
    // 浏览器全局
    root.MyLibrary = factory(root.jQuery);
  }
})(typeof self !== 'undefined' ? self : this, function ($) {
  // 模块实际代码
  function MyLibrary() {
    // ...
  }
  return MyLibrary;
});

适用场景:发布的库需要兼容旧环境(如不支持 ESM 的旧浏览器与 Node.js)。现代库推荐优先发布 ESM,CJS 作为回退。

8. Node.js 中的 ESM 实践

8.1 启用 ESM 的方式

// 方式 1:package.json 设置 type: module,.js 视为 ESM
{
  "name": "my-project",
  "type": "module"
}
// 方式 2:使用 .mjs 后缀明确为 ESM
// app.mjs
import { add } from './math.mjs';
// 方式 3:继续用 .cjs 保持 CommonJS
// legacy.cjs
const { add } = require('./math.cjs');

8.2 ESM 中的文件扩展名

ESM 的 import 要求相对路径必须包含完整扩展名:

// 正确
import { add } from './math.js';
import { add } from './math.mjs';
import { add } from './utils/index.js';

// 错误(ESM 严格模式)
import { add } from './math'; // ERR_MODULE_NOT_FOUND
import { add } from './utils'; // ERR_MODULE_NOT_FOUND

这与 CommonJS 不同(CJS 可省略扩展名)。

8.3 ESM 与 CommonJS 互操作

ESM 导入 CommonJS

// CommonJS 模块 cjs-pkg.cjs
module.exports = {
  method1() {
    return 'method1';
  },
  method2() {
    return 'method2';
  },
};
// ESM 导入 CommonJS
import pkg from './cjs-pkg.cjs';
console.log(pkg.method1()); // 'method1'

// 具名导入需要解构
import cjsPkg from './cjs-pkg.cjs';
const { method1, method2 } = cjsPkg;
console.log(method1()); // 'method1'

// 命名空间导入
import * as cjsPkg from './cjs-pkg.cjs';
console.log(cjsPkg.default.method1()); // 'method1'(default 是整个 module.exports)

CommonJS 引入 ESM

// CommonJS 中使用动态 import 引入 ESM
async function main() {
  const esmMod = await import('./esm-module.js');
  esmMod.default(); // 默认导出
  esmMod.namedExport(); // 具名导出
}
main();

限制:CJS 中只能使用动态 import() 引入 ESM,不能使用 require()(Node.js 22 的 require(esm) 实验特性除外)。

Node.js 22 的 require(esm)

Node.js 22 引入了实验性的 require(esm) 特性,允许 CJS 直接 require ESM 模块:

// ESM 模块
// esm-mod.mjs
export const greeting = 'Hello from ESM';
export function greet(name) {
  return `Hello, ${name}!`;
}
// CJS 模块(Node.js 22+)
const esmMod = require('./esm-mod.mjs');
console.log(esmMod.greeting); // 'Hello from ESM'
console.log(esmMod.greet('World')); // 'Hello, World!'

限制:仅支持无顶层 await 的 ESM 模块;需启用 --experimental-require-module 标志。

8.4 package.json 的 exports 字段

exports 是现代 npm 包控制入口的核心字段:

{
  "name": "my-lib",
  "type": "module",
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.js",
      "default": "./dist/cjs/index.js"
    },
    "./utils": {
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.js"
    },
    "./package.json": "./package.json"
  }
}

exports 的能力:

  • 为 ESM 和 CJS 提供不同入口;
  • 控制哪些子路径可被外部导入(限制内部模块暴露);
  • 优先于 main 字段;
  • 配合 "type": "module" 决定 .js 文件的解析方式。

条件解析顺序:

  1. import:ESM 导入时匹配
  2. require:CJS require 时匹配
  3. node:Node.js 环境匹配
  4. browser:浏览器环境匹配
  5. default:兜底

8.5 双格式发布的完整配置

{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/cjs/index.cjs"
    },
    "./utils": {
      "types": "./dist/types/utils.d.ts",
      "import": "./dist/esm/utils.js",
      "require": "./dist/cjs/utils.cjs"
    },
    "./package.json": "./package.json"
  },
  "files": ["dist"],
  "sideEffects": false,
  "engines": {
    "node": ">=18.0.0"
  }
}

关键字段说明:

  • "type": "module":默认 ESM
  • "sideEffects": false:声明无副作用,启用 Tree-shaking
  • "files":发布时包含的文件
  • "types":TypeScript 类型入口

9. 模块打包工具

9.1 为什么需要打包工具

浏览器不支持 require(),也不支持 Node.js 的模块解析规则。打包工具解决:

  • 模块语法转换(ESM/CJS → 浏览器可执行代码);
  • 依赖图构建与打包;
  • 代码分割(Code Splitting);
  • 资源处理(CSS、图片、字体等);
  • 开发服务器与热更新(HMR);
  • 性能优化(压缩、Tree-shaking、Scope Hoisting)。

9.2 Webpack

Webpack 是最成熟的打包工具,核心概念:

// webpack.config.js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].chunk.js',
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env'],
          },
        },
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader'],
      },
      {
        test: /\.(png|jpg|gif)$/,
        type: 'asset/resource',
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({ template: './public/index.html' }),
  ],
  optimization: {
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
    usedExports: true, // 启用 Tree-shaking
    sideEffects: true,
  },
  mode: 'production',
};

核心概念:

  • Entry:打包入口
  • Output:输出配置
  • Loader:处理非 JS 文件(CSS、图片等)
  • Plugin:扩展功能(压缩、HTML 生成等)
  • Code Splitting:代码分割,按需加载
  • Tree-shaking:删除未使用代码(需 ESM)

动态导入与代码分割:

// React.lazy 按需加载
const Dashboard = React.lazy(() => import('./pages/Dashboard'));

// 路由级分割
const routes = [
  {
    path: '/dashboard',
    component: React.lazy(() => import('./pages/Dashboard')),
  },
];

9.3 Vite

Vite 是新一代构建工具,开发时利用浏览器原生 ESM,生产构建使用 Rollup:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['react', 'react-dom'],
          utils: ['lodash-es', 'dayjs'],
        },
      },
    },
    chunkSizeWarningLimit: 1000,
  },
  optimizeDeps: {
    include: ['lodash-es'],
  },
});

Vite 的工作原理:

  • 开发模式:浏览器原生 ESM,按需编译,秒级启动;
  • 生产模式:Rollup 打包,Tree-shaking,Code Splitting。

Vite vs Webpack 对比:

对比项WebpackVite
开发启动全量打包后启动按需编译,秒级启动
HMR 速度随项目增大变慢始终快速(基于 ESM)
生产构建自身打包Rollup
配置复杂度较高较低
生态成熟度非常成熟快速成长中
适用场景大型/复杂项目新项目/快速迭代

9.4 Rollup

Rollup 专注于库打包,Tree-shaking 效果最好:

// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import { dts } from 'rollup-plugin-dts';

export default [
  {
    input: 'src/index.js',
    output: [
      {
        file: 'dist/esm/index.js',
        format: 'esm',
        sourcemap: true,
      },
      {
        file: 'dist/cjs/index.cjs',
        format: 'cjs',
        sourcemap: true,
      },
      {
        file: 'dist/umd/index.umd.js',
        format: 'umd',
        name: 'MyLib',
        sourcemap: true,
      },
    ],
    plugins: [resolve(), commonjs(), terser()],
    external: ['react', 'react-dom'], // 外部依赖不打包
  },
  {
    input: 'src/index.ts',
    output: { file: 'dist/types/index.d.ts', format: 'esm' },
    plugins: [dts()],
  },
];

适用场景:发布 npm 库;Tree-shaking 要求高;输出多种格式。

9.5 esbuild

esbuild 由 Go 语言编写,编译速度极快:

// esbuild.config.js
const esbuild = require('esbuild');

esbuild.build({
  entryPoints: ['src/index.js'],
  bundle: true,
  minify: true,
  sourcemap: true,
  target: ['es2020'],
  format: 'esm',
  outfile: 'dist/index.js',
  splitting: true, // 代码分割
  plugins: [],
});

特点:

  • 极快:比 Webpack 快 10-100 倍;
  • 零配置:API 简洁;
  • 不支持 HMR:适合构建而非开发服务器;
  • 被 Vite 用作依赖预构建。

9.6 Turbopack

Turbopack 是 Vercel 推出的增量打包工具(Next.js 集成):

  • 基于 Rust 实现,性能优于 Webpack;
  • 增量计算,复用上次构建结果;
  • 与 Next.js 深度集成。

9.7 Parcel

Parcel 是零配置打包工具:

// package.json
{
  "scripts": {
    "build": "parcel build src/index.html",
    "dev": "parcel src/index.html"
  }
}

特点:零配置开箱即用;适合快速原型;生态不如 Webpack/Vite 丰富。

9.8 打包工具选型决策

场景推荐工具理由
大型企业应用Webpack生态成熟、插件丰富、可定制性强
新项目/快速迭代Vite秒级启动、HMR 快、配置简单
发布 npm 库RollupTree-shaking 最佳、多格式输出
极致构建速度esbuildGo 实现、10-100x 快于 Webpack
Next.js 项目Turbopack与 Next.js 深度集成
快速原型Parcel零配置

10. 模块化最佳实践

10.1 导出设计原则

原则 1:一个模块一个职责

// 推荐:每个文件专注一件事
// formatDate.js
export function formatDate(date) {
  // ...
}
export function parseDate(str) {
  // ...
}

// 不推荐:单文件混入多个不相关功能
// utils.js
export function formatDate() {}
export function calculateTax() {}
export function sendEmail() {}

原则 2:优先使用具名导出

// 推荐:具名导出
export class User {
  // ...
}
export function validateUser() {
  // ...
}

// 不推荐:默认导出 + 大量具名导出混合
export default class User {
  // ...
}
export function validateUser() {
  // ...
}

具名导出的优势:

  • 重构时 IDE 可自动更新导入;
  • Tree-shaking 更精确;
  • 导入名称一致,避免不同文件中同一模块不同命名;
  • 类型推断更友好(TypeScript)。

原则 3:避免 Barrel Export 滥用

// models/index.js(Barrel Export)
export { User } from './User.js';
export { Post } from './Post.js';
export { Comment } from './Comment.js';
export { Tag } from './Tag.js';
// 使用方
import { User, Post } from './models/index.js';

Barrel Export 的陷阱:

  • 可能导致 Tree-shaking 失效(打包工具难以确定哪些导出实际被使用);
  • 增加模块解析开销;
  • 在库开发中慎用。

改进:在 package.json 配置 sideEffects: false,并测试 Tree-shaking 效果。

10.2 循环依赖检测与消除

检测工具:

# 使用 madge 检测循环依赖
npx madge --circular src/

# 使用 circular-dependency-plugin(Webpack)
const CircularDependencyPlugin = require('circular-dependency-plugin');
plugins: [
  new CircularDependencyPlugin({
    exclude: /node_modules/,
    failOnError: true,
  }),
];

消除策略:

// 策略 1:提取共享模块
// 原始:a.js <-> b.js(循环)
// a.js
import { shared } from './b.js';
export function a() {
  return shared();
}

// 重构:提取 c.js
// c.js
export function shared() {
  // ...
}

// a.js(不再依赖 b)
import { shared } from './c.js';

// b.js(不再依赖 a)
import { shared } from './c.js';
// 策略 2:延迟依赖(在函数内 import)
// a.js
export function a() {
  // 动态 import 避免顶层循环
  import('./b.js').then(({ b }) => b());
}
// 策略 3:依赖注入
// a.js
export function createA(b) {
  return {
    doSomething() {
      return b.doOther();
    },
  };
}

// main.js
import { createB } from './b.js';
import { createA } from './a.js';
const b = createB();
const a = createA(b);

10.3 Tree-shaking 友好写法

// 反模式:整体导入 lodash,无法 Tree-shake
import _ from 'lodash';
_.map([1, 2, 3], (n) => n * 2);

// 正确:按需导入
import { map } from 'lodash-es'; // 需要 ESM 版本
map([1, 2, 3], (n) => n * 2);

// 更优:直接导入具体函数
import map from 'lodash-es/map';
// 反模式:副作用代码阻止 Tree-shaking
// utils.js
window.myUtils = {}; // 副作用,整个文件不能被 shake

export function helper() {}
// 正确:声明无副作用
// package.json
{
  "sideEffects": false
}

// 或精确声明有副作用的文件
{
  "sideEffects": ["./src/polyfills.js", "*.css"]
}

10.4 模块设计模式

10.4.1 工厂模式

// logger.js
export function createLogger(options = {}) {
  const { level = 'info', transport = console } = options;

  return {
    log(message) {
      if (level === 'info') transport.log(message);
    },
    error(message) {
      transport.error(message);
    },
  };
}

// 使用
import { createLogger } from './logger.js';
const logger = createLogger({ level: 'error' });

10.4.2 单例模式(谨慎使用)

// config.js
let instance = null;

export function getConfig() {
  if (!instance) {
    instance = {
      apiUrl: process.env.API_URL,
      timeout: 5000,
    };
  }
  return instance;
}

注意:模块缓存本身已实现单例,通常无需手动实现。直接导出常量即可:

// config.js
export const config = {
  apiUrl: process.env.API_URL,
  timeout: 5000,
};

10.4.3 策略模式

// strategies.js
export const strategies = {
  fast: {
    execute: (data) => quickProcess(data),
  },
  accurate: {
    execute: (data) => thoroughProcess(data),
  },
};

// 使用
import { strategies } from './strategies.js';
const result = strategies[mode].execute(data);

10.5 模块版本管理与 SemVer

// package.json
{
  "dependencies": {
    "lodash-es": "^4.17.21",     // 兼容 4.x
    "react": "~18.2.0",           // 兼容 18.2.x
    "dayjs": "1.11.10",           // 精确版本
    "axios": ">=1.0.0 <2.0.0"     // 范围
  }
}

SemVer 规则:MAJOR.MINOR.PATCH

  • MAJOR:不兼容的 API 变更
  • MINOR:向后兼容的新功能
  • PATCH:向后兼容的 bug 修复

符号说明:

  • ^1.2.3:兼容 1.x.x(≥1.2.3 <2.0.0)
  • ~1.2.3:兼容 1.2.x(≥1.2.3 <1.3.0)
  • 1.2.3:精确版本
  • *:任意版本

11. 案例研究

11.1 案例 1:发布双格式 npm 包

场景:开发一个工具库 string-utils,需同时支持 ESM 和 CJS 用户,并最大化 Tree-shaking。

项目结构:

flowchart TD
    T0["string-utils/"]
    T1["src/"]
    T2["index.ts"]
    T3["camelCase.ts"]
    T4["kebabCase.ts"]
    T5["snakeCase.ts"]
    T6["dist/"]
    T7["esm/"]
    T8["index.js"]
    T9["camelCase.js"]
    T10["kebabCase.js"]
    T11["snakeCase.js"]
    T12["cjs/"]
    T13["index.cjs"]
    T14["camelCase.cjs"]
    T15["kebabCase.cjs"]
    T16["snakeCase.cjs"]
    T17["types/"]
    T18["index.d.ts"]
    T19["package.json"]
    T20["tsconfig.json"]
    T21["rollup.config.js"]
    T0 --> T1
    T5 --> T6
    T18 --> T19
    T18 --> T20
    T18 --> T21

package.json:

{
  "name": "string-utils",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/cjs/index.cjs",
  "module": "./dist/esm/index.js",
  "types": "./dist/types/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/types/index.d.ts",
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs"
    },
    "./camelCase": {
      "types": "./dist/types/camelCase.d.ts",
      "import": "./dist/esm/camelCase.js",
      "require": "./dist/cjs/camelCase.cjs"
    },
    "./package.json": "./package.json"
  },
  "sideEffects": false,
  "files": ["dist"],
  "scripts": {
    "build": "rollup -c",
    "test": "jest"
  }
}

src/index.ts:

export { camelCase } from './camelCase';
export { kebabCase } from './kebabCase';
export { snakeCase } from './snakeCase';

rollup.config.js:

import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import { dts } from 'rollup-plugin-dts';

export default [
  {
    input: 'src/index.ts',
    output: [
      { dir: 'dist/esm', format: 'esm', preserveModules: true },
      { dir: 'dist/cjs', format: 'cjs', entryFileNames: '[name].cjs', preserveModules: true },
    ],
    plugins: [resolve(), typescript()],
    external: [],
  },
  {
    input: 'src/index.ts',
    output: { dir: 'dist/types', format: 'esm' },
    plugins: [dts()],
  },
];

验证 Tree-shaking:

// 用户代码
import { camelCase } from 'string-utils';
console.log(camelCase('hello world'));

// 构建后应只包含 camelCase 的代码,不包含 kebabCase 和 snakeCase

11.2 案例 2:CommonJS 项目迁移到 ESM

场景:一个使用 CommonJS 的 Node.js 项目需迁移到 ESM,要求平滑过渡。

步骤 1:评估依赖兼容性

# 检查依赖是否支持 ESM
npx is-esm lodash
# 检查项目中的 require 调用
grep -r "require(" src/

步骤 2:修改 package.json

{
  "name": "my-project",
  "type": "module",
  "scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js"
  }
}

步骤 3:批量转换 require → import

// 转换前
const fs = require('fs');
const path = require('path');
const express = require('express');
const { Router } = express;

// 转换后
import fs from 'fs';
import path from 'path';
import express from 'express';
import { Router } from 'express';

步骤 4:处理 __dirname 和 __filename

// ESM 中没有 __dirname 和 __filename
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

步骤 5:处理 JSON 导入

// CommonJS
const config = require('./config.json');

// ESM(Node.js 22+)
import config from './config.json' with { type: 'json' };

// 或使用 fs 读取
import fs from 'fs';
const config = JSON.parse(fs.readFileSync('./config.json', 'utf-8'));

步骤 6:处理动态 require

// CommonJS
const handler = require(`./handlers/${name}.js`);

// ESM
const handler = await import(`./handlers/${name}.js`);

步骤 7:测试与验证

# 运行测试
npm test

# 检查是否有遗漏的 require
grep -r "require(" src/

# 检查是否有 __dirname
grep -r "__dirname" src/

11.3 案例 3:大型项目的模块边界管理

场景:一个 Monorepo 包含多个包,需管理模块边界,防止内部实现被外部引用。

项目结构:

flowchart TD
    T0["my-monorepo/"]
    T1["packages/"]
    T2["core/"]
    T3["src/"]
    T4["index.ts          # 公开 API"]
    T5["internal/         # 内部实现"]
    T6["utils.ts"]
    T7["helpers.ts"]
    T8["public/"]
    T9["api.ts"]
    T10["types.ts"]
    T11["package.json"]
    T12["ui/"]
    T13["src/"]
    T14["package.json"]
    T15["utils/"]
    T16["src/"]
    T17["package.json"]
    T18["pnpm-workspace.yaml"]
    T19["package.json"]
    T0 --> T1
    T17 --> T18
    T17 --> T19

packages/core/package.json:

{
  "name": "@my-org/core",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js"
    },
    "./api": {
      "import": "./dist/public/api.js"
    },
    "./types": {
      "import": "./dist/public/types.js"
    }
  },
  "sideEffects": false
}

效果:

// 合法导入
import { mainApi } from '@my-org/core';
import { api } from '@my-org/core/api';
import { types } from '@my-org/core/types';

// 非法导入(被 exports 阻止)
import { utils } from '@my-org/core/internal/utils'; // ERR_PACKAGE_PATH_NOT_EXPORTED

11.4 案例 4:动态导入实现按需加载

场景:一个 SPA 应用,路由页面较多,需实现按需加载以减小首屏体积。

实现:

// router.js
const routes = [
  {
    path: '/',
    component: () => import('./pages/Home.js'),
  },
  {
    path: '/dashboard',
    component: () => import('./pages/Dashboard.js'),
  },
  {
    path: '/settings',
    component: () => import('./pages/Settings.js'),
  },
  {
    path: '/admin',
    component: () => import('./pages/Admin.js'),
    // 管理员页面预加载
    prefetch: true,
  },
];

// 预加载逻辑
function prefetchRoute(route) {
  if (route.prefetch) {
    // 在空闲时间预加载
    requestIdleCallback(() => {
      route.component();
    });
  }
}

// 路由切换时加载
async function navigate(path) {
  const route = routes.find((r) => r.path === path);
  if (!route) {
    console.error('路由不存在');
    return;
  }

  // 显示加载状态
  showLoading();

  try {
    const module = await route.component();
    const Component = module.default;
    render(Component);
  } catch (err) {
    console.error('加载失败:', err);
    showErrorPage();
  } finally {
    hideLoading();
  }
}

// 鼠标悬停时预加载(hint)
document.addEventListener('mouseover', (e) => {
  const link = e.target.closest('a');
  if (link) {
    const path = link.getAttribute('href');
    const route = routes.find((r) => r.path === path);
    if (route) {
      route.component(); // 触发加载但不渲染
    }
  }
});

12. 常见陷阱与反模式

12.1 混用 ESM 和 CJS 导入

// 反模式:ESM 中使用 require
import fs from 'fs';
const path = require('path'); // SyntaxError: Cannot use require in ESM

// 正确
import fs from 'fs';
import path from 'path';

12.2 忘记 await 动态 import

// 反模式:动态 import 返回 Promise
const module = import('./feature.js');
module.init(); // TypeError: module.init is not a function

// 正确
const module = await import('./feature.js');
module.init();

12.3 Barrel Export 破坏 Tree-shaking

// models/index.js
export { User } from './User.js';
export { Post } from './Post.js';
export { Comment } from './Comment.js';
export { Tag } from './Tag.js';

// 使用方
import { User } from './models/index.js';
// 某些打包工具会把 User、Post、Comment、Tag 全部打包

解决:

// package.json
{
  "sideEffects": false
}
// 或直接从源文件导入
import { User } from './models/User.js';

12.4 循环依赖导致 undefined

// a.js
import { b } from './b.js';
export const a = () => b();
console.log('a loaded, b =', b); // b 可能是 undefined

// b.js
import { a } from './a.js';
export const b = () => a();
console.log('b loaded, a =', a); // a 可能是 undefined

修复:提取共享逻辑或使用延迟调用。

12.5 在 ESM 中修改导入

// counter.js
export let count = 0;

// main.js
import { count } from './counter.js';
count = 10; // TypeError: Assignment to constant variable

原因:ESM 导入是只读 live binding。

12.6 顶层 await 阻塞依赖

// config.js
export const config = await fetch('/api/config').then((r) => r.json());
// 所有依赖 config.js 的模块都会被阻塞

解决:避免在共享模块中使用顶层 await;改为导出 Promise。

// config.js
export const configPromise = fetch('/api/config').then((r) => r.json());

// 使用方
import { configPromise } from './config.js';
const config = await configPromise;

12.7 exports 字段配置错误

// 反模式:缺少 default 条件
{
  "exports": {
    "import": "./dist/esm/index.js",
    "require": "./dist/cjs/index.cjs"
  }
}
// 某些工具(如 TypeScript)可能无法解析
// 正确:包含 default
{
  "exports": {
    ".": {
      "import": "./dist/esm/index.js",
      "require": "./dist/cjs/index.cjs",
      "default": "./dist/cjs/index.cjs"
    }
  }
}

13. 工程实践

13.1 模块解析加速

// webpack.config.js - 减少 resolve 范围
module.exports = {
  resolve: {
    modules: [path.resolve(__dirname, 'src'), 'node_modules'],
    extensions: ['.js', '.jsx'], // 减少扩展名尝试
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components'),
    },
  },
};

13.2 Tree-shaking 验证

# 使用 webpack-bundle-analyzer
npm install --save-dev webpack-bundle-analyzer

# package.json
{
  "scripts": {
    "analyze": "webpack --profile --json > stats.json && webpack-bundle-analyzer stats.json"
  }
}

13.3 Monorepo 模块管理

# pnpm-workspace.yaml
packages:
  - 'packages/*'
  - 'apps/*'
// packages/core/package.json
{
  "name": "@my-org/core",
  "dependencies": {
    "@my-org/utils": "workspace:*"
  }
}

13.4 模块热替换(HMR)

// Vite HMR API
import { hot } from 'vite/node/importMeta';

export let count = 0;

if (hot) {
  hot.accept((newModule) => {
    // 接受新模块
    count = newModule.count;
  });

  hot.dispose(() => {
    // 清理旧模块
    cleanup();
  });
}

13.5 模块预加载与预获取

// Webpack 魔法注释
import(/* webpackPrefetch: true */ './pages/Dashboard.js'); // 空闲时预获取
import(/* webpackPreload: true */ './pages/Dashboard.js'); // 父 chunk 加载时并行预加载

简答题知识点讲解

题目 4:解释 ESM 的 Tree-shaking 比 CommonJS 更好的根本原因。

解析讲解:ESM 的 import/export 必须在顶层静态声明,依赖图可在编译时构建,打包工具能进行可达性分析删除未使用代码。CommonJS 的 require 可在运行时任意位置调用,依赖图只能运行时构建,无法静态分析哪些导出未使用。

题目 5:为什么 exports = { add } 在 CommonJS 中不生效?

解析讲解:exports 是 module.exports 的引用,exports.add = ... 等价于 module.exports.add = ...,但 exports = {...} 只修改了局部变量 exports,不影响 module.exports。

编程题知识点讲解

题目 6:实现一个支持双格式(CJS+ESM)的工具模块,提供 add 和 subtract 函数。

// math.js(ESM 入口)
export function add(a, b) {
  return a + b;
}
export function subtract(a, b) {
  return a - b;
}
// math.cjs(CJS 入口)
'use strict';

function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

module.exports = { add, subtract };
// package.json
{
  "name": "math-utils",
  "version": "1.0.0",
  "type": "module",
  "main": "./math.cjs",
  "module": "./math.js",
  "exports": {
    ".": {
      "import": "./math.js",
      "require": "./math.cjs",
      "default": "./math.cjs"
    }
  }
}

题目 7:实现一个循环依赖检测函数,输入模块图,输出所有循环依赖路径。

function detectCycles(graph) {
  const cycles = [];
  const visited = new Set();
  const recursionStack = new Set();
  const path = [];

  function dfs(node) {
    if (recursionStack.has(node)) {
      const cycleStart = path.indexOf(node);
      cycles.push([...path.slice(cycleStart), node]);
      return;
    }
    if (visited.has(node)) return;

    visited.add(node);
    recursionStack.add(node);
    path.push(node);

    const dependencies = graph[node] || [];
    for (const dep of dependencies) {
      dfs(dep);
    }

    path.pop();
    recursionStack.delete(node);
  }

  for (const node of Object.keys(graph)) {
    dfs(node);
  }

  return cycles;
}

// 使用示例
const graph = {
  a: ['b'],
  b: ['c'],
  c: ['a'], // 循环:a -> b -> c -> a
  d: ['e'],
  e: [],
};
console.log(detectCycles(graph));
// [['a', 'b', 'c', 'a']]

题目 8:实现一个简单的模块加载器,支持 require、module.exports、缓存。

class ModuleLoader {
  constructor() {
    this.cache = new Map();
    this.modules = new Map(); // 模拟文件系统
  }

  // 注册模块源码
  register(path, source) {
    this.modules.set(path, source);
  }

  // require 实现
  require(path) {
    // 检查缓存
    if (this.cache.has(path)) {
      return this.cache.get(path).exports;
    }

    // 创建模块对象
    const module = { exports: {} };
    this.cache.set(path, module);

    // 获取源码
    const source = this.modules.get(path);
    if (!source) throw new Error(`Module not found: ${path}`);

    // 包装执行
    const wrapper = new Function('module', 'exports', 'require', source);
    wrapper(module, module.exports, (p) => this.require(p));

    return module.exports;
  }
}

// 使用示例
const loader = new ModuleLoader();
loader.register('./math.js', `
  function add(a, b) { return a + b; }
  module.exports = { add };
`);
loader.register('./main.js', `
  const { add } = require('./math.js');
  module.exports = add(1, 2);
`);

console.log(loader.require('./main.js')); // 3

16.1 规范与提案

16.2 打包工具文档

16.3 进阶主题

16.4 实战资源

16.5 迁移与现代化

附录 A:模块系统 API 速查

A.1 CommonJS

// 导出
module.exports = { add, subtract };
exports.multiply = (a, b) => a * b;

// 导入
const { add, subtract } = require('./math.js');
const math = require('./math.js');

// 元信息
console.log(__dirname);  // 当前目录
console.log(__filename); // 当前文件路径
console.log(require.cache); // 模块缓存
console.log(require.main); // 入口模块

// 动态 require
const handler = require(`./handlers/${name}.js`);

A.2 ES Modules

// 具名导出
export function add(a, b) {
  return a + b;
}
export const PI = 3.14;

// 默认导出
export default function main() {}

// 重命名导出
export { add as sum };

// 具名导入
import { add, PI } from './math.js';

// 默认导入
import main from './main.js';

// 混合导入
import main, { add, PI } from './utils.js';

// 命名空间导入
import * as math from './math.js';

// 动态导入
const module = await import('./feature.js');

// 元信息
console.log(import.meta.url);

A.3 Node.js 特有

// ESM 中获取 __dirname
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// JSON 导入(Node.js 22+)
import data from './data.json' with { type: 'json' };

// 检测是否在 ESM 中
import { isMainThread } from 'worker_threads';

// process 对象(ESM 中也可用)
import process from 'process';
console.log(process.version);

附录 B:模块解析流程详解

B.1 CommonJS 解析流程

flowchart TD
    A[require('lodash')] --> B{核心模块?}
    B -- 是 --> C[返回内置模块]
    B -- 否 --> D{相对路径?}
    D -- 是 --> E[查找文件]
    E --> E1[./math]
    E --> E2[./math.js]
    E --> E3[./math.json]
    E --> E4[./math.node]
    E --> E5[./math/index.js]
    E --> E6[./math/package.json main]
    D -- 否 --> F[node_modules 查找]
    F --> F1[/当前/node_modules/lodash]
    F --> F2[/父/node_modules/lodash]
    F --> F3[/祖父/node_modules/lodash]
    F --> F4[/node_modules/lodash]

B.2 ESM 解析流程

flowchart TD
    A[import 'lodash'] --> B{裸模块说明符?}
    B -- 是 --> C[node_modules 查找(同 CJS)<br/>优先使用 package.json exports]
    B -- 否 --> D[相对/绝对路径,必须包含扩展名]
    D --> D1[./math.js]
    D --> D2[./math.mjs]
    D --> D3[目录: ./math/index.js]
    A --> E[URL 导入(浏览器)]
    E --> E1[import 'https://cdn.skypack.dev/lodash']

B.3 条件导出解析

// package.json
{
  "exports": {
    ".": {
      "types": "./types/index.d.ts",
      "import": "./esm/index.js",
      "require": "./cjs/index.cjs",
      "browser": "./browser/index.js",
      "default": "./cjs/index.cjs"
    }
  }
}

解析顺序:

  1. types:TypeScript 解析类型
  2. import:ESM import 时
  3. require:CJS require 时
  4. browser:浏览器环境
  5. default:兜底

附录 C:模块化性能优化 Checklist

C.1 打包体积优化

  • 使用 ESM 而非 CJS(启用 Tree-shaking)
  • package.json 配置 "sideEffects": false
  • 按需导入第三方库(import { map } from 'lodash-es')
  • 使用 Code Splitting 按路由分割
  • 删除未使用的依赖
  • 压缩与混淆代码

C.2 构建速度优化

  • 减少 resolve.extensions 尝试
  • 使用 resolve.alias 缩短路径
  • 排除 node_modules(exclude: /node_modules/)
  • 使用缓存(cache: true)
  • 并行处理(thread-loader、terser-webpack-plugin parallel)
  • 使用 esbuild/Vite 替代 Webpack

C.3 运行时性能

  • 按需加载大模块(import())
  • 预获取关键路由(webpackPrefetch)
  • 避免顶层 await 阻塞
  • 减少循环依赖
  • 避免模块顶层重计算

C.4 可维护性

  • 每个模块单一职责
  • 优先具名导出
  • 避免深度嵌套导入路径
  • 使用 exports 控制公开 API
  • 定期检测循环依赖(madge --circular)
  • 文档化模块边界

附录 D:ESM 与 CommonJS 互操作矩阵

场景支持情况说明
ESM import CJS支持CJS 的 module.exports 作为 default
CJS require ESMNode.js 22+ 支持需 --experimental-require-module
CJS 动态 import() ESM支持返回 Promise
ESM import CJS 具名部分支持需 CJS 模块静态分析可识别导出
ESM 顶层 await支持CJS require 不支持顶层 await 模块

附录 E:常见问题 FAQ

E.1 为什么 ESM 的 import 必须在顶层?

ESM 设计目标是支持静态分析(Tree-shaking、循环依赖检测)。若允许 import 在条件分支中,依赖图只能在运行时构建,丧失静态分析能力。动态需求使用 import()。

E.2 为什么 ESM 的 import 必须包含扩展名?

ESM 规范要求路径精确,避免不同环境(浏览器、Node.js)解析不一致。浏览器原生 ESM 强制要求完整 URL,Node.js 为兼容性也要求扩展名。

E.3 如何在 ESM 中使用 __dirname?

import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

E.4 如何在 ESM 中导入 JSON?

// Node.js 22+
import data from './data.json' with { type: 'json' };

// 旧版本 Node.js
import fs from 'fs';
const data = JSON.parse(fs.readFileSync('./data.json', 'utf-8'));

E.5 exports 和 main 字段冲突时哪个优先?

exports 优先。main 仅作为兜底,当 exports 未定义时使用。

E.6 如何检测模块是否被正确 Tree-shake?

# 使用 webpack-bundle-analyzer
npm run analyze

# 检查打包后的文件是否包含未使用的导出
grep "unusedFunction" dist/bundle.js  # 应无结果

E.7 循环依赖一定会导致 bug 吗?

不一定。若循环依赖的模块在顶层不立即使用对方导出,而是在函数内延迟使用,则不会出问题:

// a.js
import { b } from './b.js';
export function a() {
  return b(); // 调用时 b 已完成初始化
}

// b.js
import { a } from './a.js';
export function b() {
  return a();
}

附录 F:模块化演进趋势

F.1 Import Attributes(ES2024)

支持导入 JSON、CSS、WebAssembly 等非 JS 模块:

import data from './data.json' with { type: 'json' };
import styles from './styles.css' with { type: 'css' };
import wasm from './module.wasm' with { type: 'webassembly' };

F.2 Module Declarations(提案)

在 HTML 中直接声明模块,无需 <script type="module">:

<script type="module">
  module foo {
    export function greet() {
      console.log('hello');
    }
  }
</script>

F.3 Import Maps

浏览器原生模块映射,无需打包:

<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.skypack.dev/lodash-es",
    "react": "https://esm.sh/react@18"
  }
}
</script>

<script type="module">
  import _ from 'lodash';
  import React from 'react';
</script>

F.4 Module Federation(微前端)

Webpack 5 引入的模块联邦,支持跨应用共享模块:

// host webpack.config.js
new ModuleFederationPlugin({
  remotes: {
    app1: 'app1@http://localhost:3001/remoteEntry.js',
  },
});

// host 代码
import Button from 'app1/Button';

F.5 Node.js 22 的 require(esm)

打破 CJS 与 ESM 的最后壁垒:

// CJS 直接 require ESM(Node.js 22+,实验性)
const esmMod = require('./esm-mod.mjs');

未来展望:ESM 将成为唯一的模块标准,CommonJS 逐步退出历史舞台。打包工具将更专注于性能优化而非语法转换。


总结:JavaScript 模块化从 IIFE 到 ESM 经历了 30 年演进,ESM 以静态分析、Live Binding、Tree-shaking 能力成为最终标准。理解 CommonJS 与 ESM 的本质差异、掌握 package.json 的 exports 配置、选择合适的打包工具、避免循环依赖与 Tree-shaking 失效,是构建高质量 JavaScript 项目的关键。Node.js 22 的 require(esm) 标志着两大模块系统的最终融合,未来 ESM 将一统天下,但过渡期内仍需关注双格式发布与互操作细节。

export 导出

基本写法:命名导出变量 export let <变量名> = <值>;

// 导出变量
export let name = "Alice";

基本写法:命名导出函数 export function <函数名>() { }

// 导出函数
export function greet() {
}

基本写法:命名导出类 export class <类名> { }

// 导出类
export class Person {
}

基本写法:命名导出常量 export const <常量名> = <值>;

// 导出常量
export const PI = 3.14159;

基本写法:统一导出 export { <标识符1>, <标识符2> };

// 统一导出多个标识符
export { name, greet, Person };

基本写法:导出时重命名 export { <原名> as <新名> };

// 导出时重命名标识符
export { greet as sayHello };

基本写法:默认导出 export default <表达式>

// 默认导出
export default function() {
}

基本写法:默认导出命名函数 export default function <函数名>() { }

// 默认导出命名函数
export default function main() {
}

基本写法:默认导出类 export default class <类名> { }

// 默认导出类
export default class App {
}

基本写法:默认导出对象 export default { <属性> };

// 默认导出对象
export default {
    name: "Alice",
    age: 25
};

import 导入

基本写法:导入命名导出 import { <标识符> } from "<模块>";

// 导入指定的命名导出
import { name } from "./module.js";

单行写法:导入多个命名导出 import { <标识符1>, <标识符2> } from "<模块>";

// 单行导入多个命名导出
import { name, greet, Person } from "./module.js";

换行写法:导入多个命名导出 import { <标识符1>, <标识符2>, <标识符3> } from "<模块>";

// 换行导入多个命名导出
import {
    name,
    greet,
    Person
} from "./module.js";

基本写法:导入时重命名 import { <原名> as <新名> } from "<模块>";

// 导入时重命名标识符
import { greet as sayHello } from "./module.js";

基本写法:导入默认导出 import <名称> from "<模块>";

// 导入默认导出
import main from "./module.js";

基本写法:混合导入 import <默认>, { <命名1>, <命名2> } from "<模块>";

// 同时导入默认导出和命名导出
import main, { name, greet } from "./module.js";

基本写法:命名空间导入 import * as <命名空间> from "<模块>";

// 导入整个模块作为命名空间
import * as utils from "./module.js";

基本写法:使用命名空间 <命名空间>.<标识符>

// 通过命名空间访问导出
utils.greet();

基本写法:副作用导入 import "<模块>";

// 仅执行模块不导入内容
import "./polyfill.js";

基本写法:动态导入 import("<模块>")

// 动态导入返回 Promise
import("./module.js").then(module => {
});

基本写法:await 动态导入 const <模块> = await import("<模块>");

// 使用 await 等待动态导入
const module = await import("./module.js");

re-export 重新导出

基本写法:重新导出 export { <标识符> } from "<模块>";

// 从其他模块重新导出
export { name } from "./module.js";

基本写法:重新导出全部 export * from "<模块>";

// 重新导出模块全部内容
export * from "./module.js";

基本写法:重新导出默认 export { default } from "<模块>";

// 重新导出默认导出
export { default } from "./module.js";

基本写法:重新导出并重命名 export { <原名> as <新名> } from "<模块>";

// 重新导出并重命名
export { greet as sayHello } from "./module.js";

模块模式

基本写法:模块文件结构 // <导出> <导入>

// 模块文件包含导入和导出
import { helper } from "./helper.js";
export function main() {
}

基本写法:具名导入与默认导入 import <默认>, * as <命名空间> from "<模块>";

// 同时导入默认和命名空间
import main, * as utils from "./module.js";

CommonJS 模块

基本写法:module.exports module.exports = <值>;

// CommonJS 导出模块
module.exports = {
    name: "Alice"
};

基本写法:exports 属性 exports.<属性> = <值>;

// CommonJS 导出属性
exports.greet = function() {
};

基本写法:require 导入 const <模块> = require("<模块>");

// CommonJS 导入模块
const fs = require("fs");

基本写法:require 解构导入 const { <属性> } = require("<模块>");

// CommonJS 解构导入
const { readFile } = require("fs");

模块路径

基本写法:相对路径 import <模块> from "./<文件>";

// 使用相对路径导入
import { helper } from "./utils.js";

基本写法:上级目录 import <模块> from "../<文件>";

// 使用上级目录路径
import { config } from "../config.js";

基本写法:包名导入 import <模块> from "<包名>";

// 导入 npm 包
import React from "react";

基本写法:子路径导入 import <模块> from "<包名>/<子路径>";

// 导入包的子路径
import { button } from "react-bootstrap";

ES2025 Import Attributes

基本写法:import json 模块 import <内容> from "<模块>" with { type: "json" }

// 使用 import attributes 显式声明模块类型
import config from "./config.json" with { type: "json" };

基本写法:动态 import 断言 import("<模块>", { with: { type: "json" } })

// 动态导入时使用 with 选项声明模块类型
let mod = await import("./config.json", { with: { type: "json" } });

基本写法:import attributes 与 import assertions 区别 // assert 已废弃改用 with 关键字

// 旧写法 import assertions 使用 assert 已废弃
// import x from "./a.json" assert { type: "json" };
// 新写法 import attributes 使用 with 关键字
import x from "./a.json" with { type: "json" };