FORMA

模块化与工程化

ESM、CommonJS 与打包工具(Tree Shaking、代码分割)是前端工程化的基础。入门见 JavaScript 基础 · 模块

一、ES6 模块(ESM)

ES6 模块是 ECMAScript 2015 引入的官方模块化方案,具有静态结构、支持异步加载等特点。

1. export / import 基本用法

命名导出与导入

js
// 📁 math.js
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Circle { ... }

// 或集中导出
const E = 2.718;
function multiply(a, b) { return a * b; }
export { E, multiply };

// 📁 main.js
import { PI, add, multiply } from './math.js';
import * as MathLib from './math.js'; // 命名空间导入

默认导出与导入

每个模块最多有一个默认导出。

js
// 📁 logger.js
export default function log(message) {
  console.log(message);
}
// 也可以 export default class / const

// 📁 main.js
import customName from "./logger.js"; // 默认导入可以任意命名
customName("Hello");

混合导出(默认 + 命名):

js
export default function main() { ... }
export const helper = ...;
// 导入时:import main, { helper } from './module.js';

2. 模块特点

  • 静态解析import 必须在模块顶层,不能嵌套在条件或函数中;这使构建工具可以进行静态分析(Tree Shaking)。
  • 实时绑定(live binding):导入的是值的引用,当原始模块内的值变化时,导入方会同步更新(与 CommonJS 拷贝不同)。
  • 严格模式自动启用:模块内自动处于严格模式。
  • 支持异步加载(见下文动态导入)。

3. 动态导入 import()

返回一个 Promise,可以在运行时按需加载模块,实现代码分割。

js
button.addEventListener("click", async () => {
  const module = await import("./heavy-module.js");
  module.doSomething();
});

适用场景:路由懒加载、条件加载、按需加载大型依赖。

二、CommonJS(CJS)

CommonJS 最初为服务器端(Node.js)设计,采用同步加载,使用 requiremodule.exports

1. 基本语法

js
// 📁 lib.js
const privateVar = 42;
module.exports = {
  publicMethod() { ... },
  constant: 100
};
// 或者 exports.xxx = ...(exports 是 module.exports 的引用)

// 📁 main.js
const lib = require('./lib.js');
console.log(lib.constant);

2. 与 ESM 的核心差异

特性ES6 模块 (ESM)CommonJS (CJS)
加载时机编译时静态分析,运行时可能异步(import())运行时同步加载
导出值形式实时绑定(引用)值的拷贝(浅拷贝)
this 指向undefined当前模块的 exports 对象
循环依赖处理通过“绑定”可以部分解决(需提前导出)在加载时执行,可能得到不完整的导出
可静态分析是(支持 Tree Shaking)
使用环境浏览器、Node.js(需 .mjstype: moduleNode.js 传统,浏览器需打包

导出值拷贝示例(CJS):

js
// counter.js
let count = 0;
module.exports = {
  get count() {
    return count;
  }, // 若想动态获取需用 getter
  inc() {
    count++;
  },
};
// main.js
const { count, inc } = require("./counter.js");
console.log(count); // 0
inc();
console.log(count); // 0(仍是初始拷贝值,除非用 getter)

而在 ESM 中,导入的值会随原始模块变化而更新(因为绑定)。

三、AMD / UMD(简要)

1. AMD(Asynchronous Module Definition)

代表实现:RequireJS。专为浏览器设计,支持异步加载,语法较复杂。

js
define(["dependency"], function (dep) {
  return { doSomething: () => {} };
});

2. UMD(Universal Module Definition)

一种兼容性模式,同时支持 AMD、CommonJS 和全局变量挂载。通常由构建工具(如 Rollup、Webpack)生成,用于发布跨环境使用的库。

js
(function (root, factory) {
  if (typeof define === 'function' && define.amd) { define(['dep'], factory); }
  else if (typeof module === 'object' && module.exports) { module.exports = factory(require('dep')); }
  else { root.myLib = factory(root.dep); }
}(this, function(dep) { ... }));

如今,随着 ESM 成为标准,新项目推荐直接使用 ESM,UMD 主要用于向后兼容。

四、打包工具原理(以 Webpack / Rollup / Vite 为例)

打包工具将多个模块合并为一个或多个 bundle,并解决依赖关系。

1. Tree Shaking

原理:基于 ES 模块的静态结构,分析代码中哪些 export 被实际使用,然后剔除未使用的“死代码”。

条件

  • 使用 ESM 语法(静态 import/export)。
  • 无副作用(side effect)的模块可被安全删除;可在 package.json 中用 "sideEffects": false 声明。
  • 生产模式开启优化。
json
// package.json
{
  "sideEffects": false, // 所有模块无副作用
  // 或指定有副作用的文件
  "sideEffects": ["./polyfill.js"]
}

示例import { add } from './math' 只打包 add 函数,未使用的 multiply 被移除。

2. 代码分割(Code Splitting)

将代码拆分成多个 chunk,按需加载,减少初始加载体积。

实现方式

  • 入口点分割:多入口配置,每个入口生成独立 bundle。
  • 动态导入import() 自动拆分成单独 chunk。
  • 共享模块分割(如 SplitChunksPlugin 提取公共依赖,避免重复)。
js
// 路由懒加载(React)
const Profile = lazy(() => import("./Profile"));

优势:缓存友好(公共库单独打包)、首屏加载更快。

3. 循环引用处理

循环依赖指模块 A 依赖 B,B 又依赖 A(直接或间接)。打包工具需要妥善处理。

CommonJS 中的循环引用

由于 CommonJS 是运行时同步加载,在模块加载时就会执行代码,可能得到不完整的导出。

js
// a.js
exports.a = "a1";
const b = require("./b.js");
console.log("a 中 b:", b);
exports.a = "a2";

// b.js
exports.b = "b1";
const a = require("./a.js");
console.log("b 中 a:", a);
exports.b = "b2";

执行 node a.js 输出:

text
b 中 a: { a: 'a1' }
a 中 b: { b: 'b2' }

加载 b 时,a 只执行到 exports.a = 'a1' 就暂停,所以 b 中看到的是部分 a

ESM 中的循环引用

ESM 依靠“实时绑定”和“引用”机制,可以处理循环依赖,但需要确保在访问前已经导出。

js
// a.mjs
import { b } from "./b.mjs";
export let a = "a1";
console.log("a 中 b:", b);
a = "a2";

// b.mjs
import { a } from "./a.mjs";
export let b = "b1";
console.log("b 中 a:", a);
b = "b2";

在 Node.js 中运行 .mjs"type":"module" 项目时,典型输出类似:

text
b 中 a: undefined
a 中 b: b1

b 执行时 alet 尚未完成初始化;之后通过绑定可读到更新后的 a。)

注意:在初始化阶段,b 试图访问 a 时,a 尚未赋值(undefined)。但后续 a 改变,b 中通过绑定也能看到新值(如果是在函数内部访问)。最佳实践:避免循环依赖,或将共享代码抽离到公共模块。

打包工具的处理策略

  • Webpack:通过模块的 __webpack_require__ 和缓存机制,先返回一个不完整的 exports 对象,再递归加载。开发者应确保在循环依赖中,不要在初始化阶段直接访问不完整的导出。
  • Rollup:利用 ESM 实时绑定,支持循环依赖但同样建议避免。

总结

模块方案核心特点适用场景
ESM静态结构、实时绑定、支持异步导入、Tree Shaking 友好现代浏览器、Node.js(新项目首选)
CommonJS运行时同步、值拷贝、动态 requireNode.js 传统项目、部分工具脚本
AMD浏览器异步加载,回调风格遗留项目(RequireJS)
UMD兼容多种环境,作为库的通用封装格式发布通用 JavaScript 库

打包工具原理进阶

  • Tree Shaking 依赖静态 ESM,移除死代码。
  • 代码分割通过动态导入和多入口实现。
  • 循环引用需要警惕初始化阶段的不完整导出,ESM 通过绑定缓解但仍建议避免。

在实际工程中,推荐使用 ESM 编写代码,利用打包工具的优化能力,同时遵循单一职责原则,避免循环依赖,以构建高性能、可维护的应用。

参考文献

以下链接在编写时均可正常访问:

资料说明
MDN:JavaScript 模块ESM 指南
MDN:import静态导入
MDN:import()动态导入
Node.js:ModulesNode ESM(英文)
webpack:Tree ShakingTree Shaking(英文)

Series

js

3 / 14