模块化与工程化
ESM、CommonJS 与打包工具(Tree Shaking、代码分割)是前端工程化的基础。入门见 JavaScript 基础 · 模块。
一、ES6 模块(ESM)
ES6 模块是 ECMAScript 2015 引入的官方模块化方案,具有静态结构、支持异步加载等特点。
1. export / import 基本用法
命名导出与导入
// 📁 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'; // 命名空间导入
默认导出与导入
每个模块最多有一个默认导出。
// 📁 logger.js
export default function log(message) {
console.log(message);
}
// 也可以 export default class / const
// 📁 main.js
import customName from "./logger.js"; // 默认导入可以任意命名
customName("Hello");
混合导出(默认 + 命名):
export default function main() { ... }
export const helper = ...;
// 导入时:import main, { helper } from './module.js';
2. 模块特点
- 静态解析:
import必须在模块顶层,不能嵌套在条件或函数中;这使构建工具可以进行静态分析(Tree Shaking)。 - 实时绑定(live binding):导入的是值的引用,当原始模块内的值变化时,导入方会同步更新(与 CommonJS 拷贝不同)。
- 严格模式自动启用:模块内自动处于严格模式。
- 支持异步加载(见下文动态导入)。
3. 动态导入 import()
返回一个 Promise,可以在运行时按需加载模块,实现代码分割。
button.addEventListener("click", async () => {
const module = await import("./heavy-module.js");
module.doSomething();
});
适用场景:路由懒加载、条件加载、按需加载大型依赖。
二、CommonJS(CJS)
CommonJS 最初为服务器端(Node.js)设计,采用同步加载,使用 require 和 module.exports。
1. 基本语法
// 📁 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(需 .mjs 或 type: module) | Node.js 传统,浏览器需打包 |
导出值拷贝示例(CJS):
// 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。专为浏览器设计,支持异步加载,语法较复杂。
define(["dependency"], function (dep) {
return { doSomething: () => {} };
});
2. UMD(Universal Module Definition)
一种兼容性模式,同时支持 AMD、CommonJS 和全局变量挂载。通常由构建工具(如 Rollup、Webpack)生成,用于发布跨环境使用的库。
(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声明。 - 生产模式开启优化。
// package.json
{
"sideEffects": false, // 所有模块无副作用
// 或指定有副作用的文件
"sideEffects": ["./polyfill.js"]
}
示例:import { add } from './math' 只打包 add 函数,未使用的 multiply 被移除。
2. 代码分割(Code Splitting)
将代码拆分成多个 chunk,按需加载,减少初始加载体积。
实现方式:
- 入口点分割:多入口配置,每个入口生成独立 bundle。
- 动态导入:
import()自动拆分成单独 chunk。 - 共享模块分割(如
SplitChunksPlugin提取公共依赖,避免重复)。
// 路由懒加载(React)
const Profile = lazy(() => import("./Profile"));
优势:缓存友好(公共库单独打包)、首屏加载更快。
3. 循环引用处理
循环依赖指模块 A 依赖 B,B 又依赖 A(直接或间接)。打包工具需要妥善处理。
CommonJS 中的循环引用
由于 CommonJS 是运行时同步加载,在模块加载时就会执行代码,可能得到不完整的导出。
// 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 输出:
b 中 a: { a: 'a1' }
a 中 b: { b: 'b2' }
加载 b 时,a 只执行到 exports.a = 'a1' 就暂停,所以 b 中看到的是部分 a。
ESM 中的循环引用
ESM 依靠“实时绑定”和“引用”机制,可以处理循环依赖,但需要确保在访问前已经导出。
// 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" 项目时,典型输出类似:
b 中 a: undefined
a 中 b: b1
(b 执行时 a 的 let 尚未完成初始化;之后通过绑定可读到更新后的 a。)
注意:在初始化阶段,b 试图访问 a 时,a 尚未赋值(undefined)。但后续 a 改变,b 中通过绑定也能看到新值(如果是在函数内部访问)。最佳实践:避免循环依赖,或将共享代码抽离到公共模块。
打包工具的处理策略
- Webpack:通过模块的
__webpack_require__和缓存机制,先返回一个不完整的exports对象,再递归加载。开发者应确保在循环依赖中,不要在初始化阶段直接访问不完整的导出。 - Rollup:利用 ESM 实时绑定,支持循环依赖但同样建议避免。
总结
| 模块方案 | 核心特点 | 适用场景 |
|---|---|---|
| ESM | 静态结构、实时绑定、支持异步导入、Tree Shaking 友好 | 现代浏览器、Node.js(新项目首选) |
| CommonJS | 运行时同步、值拷贝、动态 require | Node.js 传统项目、部分工具脚本 |
| AMD | 浏览器异步加载,回调风格 | 遗留项目(RequireJS) |
| UMD | 兼容多种环境,作为库的通用封装格式 | 发布通用 JavaScript 库 |
打包工具原理进阶:
- Tree Shaking 依赖静态 ESM,移除死代码。
- 代码分割通过动态导入和多入口实现。
- 循环引用需要警惕初始化阶段的不完整导出,ESM 通过绑定缓解但仍建议避免。
在实际工程中,推荐使用 ESM 编写代码,利用打包工具的优化能力,同时遵循单一职责原则,避免循环依赖,以构建高性能、可维护的应用。
参考文献
以下链接在编写时均可正常访问:
| 资料 | 说明 |
|---|---|
| MDN:JavaScript 模块 | ESM 指南 |
| MDN:import | 静态导入 |
| MDN:import() | 动态导入 |
| Node.js:Modules | Node ESM(英文) |
| webpack:Tree Shaking | Tree Shaking(英文) |
相关文章
错误处理与调试
结构化错误类型、try/catch 与全局未捕获处理是健壮应用的基础。
浏览器相关 API
DOM 事件、存储、fetch、Worker、Observer 等由浏览器提供的 Web API(非 ECMAScript 核心)。HTML 结构见 标签。
性能优化
防抖/节流、渲染与内存、资源预加载及引擎行为是前端性能的主要抓手。相关 CSS 见 视觉效果、Head 资源提示。
执行模型与作用域
执行上下文与词法作用域决定变量在何处可访问、闭包如何形成,以及 var / let / const 的差异。语言概览见 JavaScript 基础。
数据结构与集合增强
Map / Set、WeakMap / WeakSet、数组高阶方法、TypedArray 与 Symbol 等扩展了内置数据结构能力。见 语言基础。
JavaScript 基础
JavaScript(ECMAScript)是 Web 页面与 Node.js 等运行时的脚本语言:在浏览器中操作 DOM、发起网络请求;在服务端处理 I/O。语法与 HTML、CSS 配合构成前端技术栈。
Series
js
3 / 14