模块系统演进
模块化是 JavaScript 工程化的基石。从浏览器里"裸写全局变量"的混沌时代,到如今浏览器原生支持的 ES Module,模块系统经历了漫长的演进。理解这段历史,才能真正明白现代构建工具(Webpack、Vite)为什么长成今天这个样子。
一、模块化解决的问题
没有模块系统的年代,多个 <script> 标签引入的脚本共享同一个全局作用域,随之而来两个致命问题:
| 问题 | 表现 | 后果 |
|---|---|---|
| 全局污染 | 所有变量挂在 window 上 | 命名冲突、覆盖难以排查 |
| 依赖管理缺失 | 脚本加载顺序靠手动保证 | 顺序错了直接报错、加载冗余 |
<!-- 早期写法:顺序必须严格,a.js 依赖 b.js 就写在前 -->
<script src="b.js"></script>
<script src="a.js"></script>一个好的模块系统需要提供:独立作用域(变量不泄漏)、显式依赖(谁依赖谁一目了然)、按需加载(避免全量引入)。
二、历史演进总览
无模块(全局变量)
→ IIFE(模拟私有作用域)
→ CommonJS(Node.js,同步)
→ AMD(RequireJS,浏览器异步)
→ UMD(通用兼容)
→ ES Module(语言标准,同步可静态分析 + 动态 import)| 阶段 | 代表 | 加载时机 | 运行环境 | 是否原生支持 |
|---|---|---|---|---|
| 无模块 | <script> | 同步 | 浏览器 | 是 |
| IIFE | 手动封装 | 同步 | 浏览器 | 是 |
| CommonJS | Node.js | 同步 | Node.js | Node 原生 |
| AMD | RequireJS | 异步 | 浏览器 | 否(库实现) |
| UMD | 通用包装 | 两者皆可 | 双端 | 否(库实现) |
| ESM | 语言标准 | 同步/异步 | 浏览器与 Node | 是 |
三、IIFE 模块模式
IIFE(立即执行函数表达式) 用函数作用域制造"私有空间",是模块思想的最早形态:
// 传统写法:global.count 被暴露,可能被覆盖
var count = 0;
function add() { count++; }
// IIFE 写法:内部状态完全私有
const counter = (function () {
let count = 0; // 闭包变量,外部无法访问
function add() { count++; return count; }
function reset() { count = 0; }
return { add, reset }; // 只暴露公共 API
})();
counter.add(); // 1
counter.add(); // 2
console.log(counter.count); // undefined,私有变量不可访问3.1 传参与依赖注入
IIFE 可以通过参数显式声明依赖,即"依赖注入":
const app = (function ($, utils) {
// 显式依赖 window.jQuery 与 utils
function init() {
$(".btn").on("click", utils.handleClick);
}
return { init };
})(window.jQuery, window.utils);IIFE 的局限:依赖仍需手动保证加载顺序,且无法实现异步加载。但它"作用域隔离 + 显式暴露"的思想,被所有后续模块规范继承。
四、CommonJS
CommonJS 由 Node.js 采用,核心语法是 require 与 module.exports:
// math.js
const PI = 3.14159;
function area(r) { return PI * r * r; }
module.exports = { area, PI };
// main.js
const math = require("./math"); // 同步加载
console.log(math.area(2)); // 12.56636也可以直接导出单个值:
// 写法等价
module.exports = function greet(name) { return `你好,${name}`; };
exports.greet = function () {}; // exports 是 module.exports 的别名4.1 特性:同步、缓存、值拷贝
| 特性 | 说明 |
|---|---|
| 同步加载 | require 在运行时同步读取并执行模块文件 |
| 缓存机制 | 模块首次加载后缓存,重复 require 返回同一对象 |
| 导出是值拷贝 | 基础类型导出的是副本,对象导出的是引用 |
| 运行时解析 | 加载时机由代码执行位置决定,无法静态分析 |
// 缓存演示:只有第一次 require 会执行模块代码
console.log(require("./math.js")); // 模块代码执行一次
console.log(require("./math.js")); // 命中缓存,不再执行4.2 循环依赖
CommonJS 遇到循环依赖时,导出的是"未完成的对象":
// a.js
const b = require("./b");
console.log("a 中 b =", b); // b.js 尚未完成,可能拿到空对象
module.exports = { name: "A" };
// b.js
const a = require("./a");
console.log("b 中 a =", a);
module.exports = { name: "B" };执行 node a.js 时,a.js 先加载 b.js,而 b.js 又回头加载 a.js——此时 a.js 的导出还没赋值,b 拿到的 a 是空对象。实践上应尽量避免循环依赖,或用"延迟到使用时再 require"规避。
五、AMD 与 RequireJS
AMD(Asynchronous Module Definition)专为浏览器设计,解决 CommonJS 同步加载在浏览器中阻塞渲染的问题。RequireJS 是其最著名的实现:
// 定义模块:define(id?, dependencies?, factory)
define("math", [], function () {
return { area: (r) => 3.14 * r * r };
});
// 依赖数组显式声明,所有依赖异步并行加载完成后再执行工厂函数
define("main", ["math", "jquery"], function (math, $) {
return { init() { $(".x").html(math.area(2)); } };
});<!-- 浏览器入口:require 异步拉取依赖 -->
<script src="require.js" data-main="main"></script>AMD 的优点:异步加载、依赖数组直观、支持并行下载。缺点:语法啰嗦、文件数量多时请求数爆炸,需要配合打包器使用。
六、UMD
UMD(Universal Module Definition)是一套"万能包装",同时兼容 CommonJS、AMD 与全局变量三种环境:
(function (root, factory) {
if (typeof module === "object" && module.exports) {
// CommonJS 环境(Node.js)
module.exports = factory(require("jquery"));
} else if (typeof define === "function" && define.amd) {
// AMD 环境(RequireJS)
define(["jquery"], factory);
} else {
// 浏览器全局变量
root.myLib = factory(root.jQuery);
}
})(this, function ($) {
// 模块实际实现
return { version: "1.0.0", greet: () => "hello" };
});UMD 让同一个文件可以同时被 require、define、<script> 三种方式使用,是库作者发布通用包时的经典选择。缺点是包装代码本身占体积,且三个分支都要维护。
七、ES Module(ESM)
ES Module 是 ECMAScript 2015 引入的语言级模块标准,浏览器与 Node.js 均原生支持。
7.1 基本语法
// 具名导出与导入
// lib.js
export const VERSION = "1.0";
export function sum(a, b) { return a + b; }
// main.js
import { VERSION, sum } from "./lib.js";
import { VERSION as v } from "./lib.js"; // 重命名
import * as lib from "./lib.js"; // 命名空间导入
// 默认导出与导入
// button.js
export default class Button {}
// main.js
import Button from "./button.js";
// 混合导入
import Button, { VERSION } from "./button.js";7.2 静态分析
import/export 必须写在顶层作用域且不能出现在条件语句中,这使得引擎在解析阶段就能确定模块依赖图。静态分析带来三大能力:
- Tree Shaking:打包器可删除未被使用的导出(摇树优化);
- 循环依赖更健壮:ESM 导出的是"活的绑定"(live binding),依赖模块读到的总是最新值;
- 加载优化:浏览器可以并行预取所有依赖。
// 错误写法:import 不能嵌套在语句里
if (condition) {
import { sum } from "./lib.js"; // SyntaxError
}7.3 浏览器原生使用
<!-- type="module" 告诉浏览器按 ESM 解析,且模块默认启用严格模式 -->
<script type="module" src="./main.js"></script>// 浏览器内直接使用原生 ESM
import { sum } from "./lib.js";模块脚本与普通脚本的差异:自动启用严格模式("use strict")、延迟执行(默认 defer)、跨域受 CORS 约束(本地 file:// 直接打开会报错,需本地服务器)。
7.4 动态 import()
静态 import 要求路径在编译期确定。运行时按需加载使用 import(),它返回 Promise:
// 点击按钮时才加载大模块
button.addEventListener("click", async () => {
const { openEditor } = await import("./editor.js");
openEditor();
});
// 动态路径(模板字符串)
const lang = "zh";
const i18n = await import(`./locales/${lang}.js`);import() 在 Webpack/Vite 中自动触发代码分割,把动态导入的模块单独打包成 chunk,实现懒加载。
7.5 import.meta
import.meta 暴露当前模块的元信息,最常见的用途是拿到模块 URL:
console.log(import.meta.url); // 模块文件的完整 URL
const dir = new URL("./data.json", import.meta.url); // 相对模块解析资源八、各格式对比总表
| 维度 | IIFE | CommonJS | AMD | UMD | ESM |
|---|---|---|---|---|---|
| 加载方式 | 同步 | 同步 | 异步 | 兼容 | 同步+异步 |
| 运行时/构建时 | 运行时 | 运行时 | 运行时 | 运行时 | 静态分析 |
| Tree Shaking | 不支持 | 不支持 | 不支持 | 不支持 | 支持 |
| 循环依赖 | 无此机制 | 拿到部分导出 | 支持 | 视环境 | 支持(活绑定) |
| 浏览器原生 | 是 | 否 | 否 | 否 | 是 |
| Node.js 原生 | 否 | 是 | 否 | 否 | 是 |
| 语法简洁度 | 中 | 高 | 低 | 低 | 高 |
如今,书写代码用 ESM,Node.js 与打包器内部兼容 CommonJS,是社区的主流实践。新项目应优先选择 ESM;对第三方库的兼容性问题,交给打包工具的 interop 机制处理即可。