JS核心知识-模块化
在前端技术飞速迭代的过程中,模块化
始终是贯穿发展的核心命题之一。前端从早期的几行JS代码能搞定一个页面,到如今动辄百万行代码的大型单页面应用,开发者面临的最大挑战早已不是实现功能,而是如何让代码可维护、可复用、可扩展。当一个项目的JS文件堆成数百行,变量和函数在全局环境下互相打架
,团队协作时稍微不注意就会引起命名冲突,上线前还要靠人为的梳理几十个script标签的加载顺序----这样的场景让无数开发者头疼,这正是模块化
诞生的使命。
本文将以前端发展历程为主线,从最原始的代码组织方式开始,一步步拆解模块化如何从 “临时解决方案” 进化为 “官方标准”,深入剖析每个阶段的核心方案、解决的痛点与局限,并重点详解当前主流的 CommonJS 与 ESModule 规范,帮助你彻底理清前端模块化的来龙去脉。
前端蛮荒时代
在前端还处于刀耕火种
的初级阶段时,网页主要以展示为主,交互逻辑简单,JS代码通常只有几十到几百行。开发者代码组织方式就是将所有函数和变量直接定义在全局作用域(window)
中,通过script
标签引入到HTML文件中。
// global-utils.js
function formatDate(date) {
// 日期格式化逻辑
return date.toLocaleDateString();
}
let userInfo = {
name: "前端小白",
role: "visitor"
};
// 直接在全局作用域挂载函数和变量
window.formatDate = formatDate;
window.userInfo = userInfo;
在HTML中引入上述文件,然后在通过另一个script
标签调用执行即可
<script src="global-utils.js"></script>
<script>
// 直接使用全局变量和函数
console.log(formatDate(new Date()));
console.log(userInfo.name);
</script>
这种方式虽然简单直接,但是当项目日益庞大时,这种全局暴露
方式存在严重问题:
-
命名冲突:
多个脚本文件中若定义了同名函数(如两个文件都有formatDate),后加载的脚本会覆盖先加载的,导致逻辑错乱 -
依赖混乱:
若项目依赖多个<script>标签,必须严格保证加载顺序(如a.js依赖b.js,则b.js必须放在前面),一旦顺序出错,就会报 “变量未定义” 错误 -
维护困难:
全局作用域下的变量和函数没有 “边界”,后期修改一个函数时,无法快速定位它被哪些地方引用,容易引发 “牵一发而动全身” 的 bug -
污染全局环境:
过多的全局变量会占用window对象的属性,可能与浏览器原生 API 或第三方库冲突(如自定义$变量可能与 jQuery 冲突)
此时的前端项目代码,就像是一间没有隔离板的大办公室,所有人的物品随意摆放,毫无秩序。
IIFE带来的改善和局限
为了解决全局变量污染问题,开发者开始利用作用域
特性的方案。在ES6之前,JS中只有函数作用域,因此立即执行函数表达式(IIFE)
成为了当时最为主流的临时解决方案。
原理: 通过 “定义一个匿名函数并立即执行”,创建一个独立的函数作用域,将变量和函数包裹在内部,只对外暴露需要共享的接口,从而避免全局污染。
// math-module.js
const MathModule = (function () {
// 私有变量:仅在IIFE内部可见
const PI = 3.14159;
// 私有函数:不对外暴露
function validateNumber(num) {
return typeof num === "number";
}
// 公开接口:通过返回对象暴露给全局
return {
circleArea: function (radius) {
if (!validateNumber(radius)) return 0;
return PI * radius * radius;
},
rectangleArea: function (width, height) {
if (!validateNumber(width) || !validateNumber(height)) return 0;
return width * height;
},
};
})();
// 挂载到全局(仅暴露一个命名空间)
window.MathModule = MathModule;
使用时通过全局命名空间调用即可,避免了多个全局变量
<script src="math-module.js"></script>
<script>
console.log(MathModule.circleArea(5)); // 78.53975
console.log(MathModule.rectangleArea(3, 4)); // 12
// 无法访问私有成员PI和validateNumber
console.log(MathModule.PI); // undefined
</script>
解决的问题:
-
作用域隔离:
通过函数作用域将大部分的变量和函数放入IIFE内部,仅仅暴露了一个全局命名空间,很大程度上减少了全局变量数量,降低命名冲突风险 -
实现封装:
以私有变量+公开接口的方式,实现了代码的初步封装,提高了代码安全性
存在的局限
IIFE虽然减少了全局污染,但是并没有解决核心痛点----依赖管理
-
依赖手动维护:
若MathModule依赖另一个 IIFE 模块(如ValidationModule),仍需手动在 HTML 中调整<script>加载顺序(先加载ValidationModule.js,再加载math-module.js),项目规模大时(如几十个模块),加载顺序管理会变得极其复杂 -
无法按需加载:
所有模块都在页面初始化时同步加载,即使某个模块只在用户点击按钮后才需要,也会提前加载,增加页面首屏加载时间 -
命名空间冲突风险:
若两个团队都定义了MathModule全局命名空间,依然会发生冲突(虽概率低于全局函数,但未彻底解决)
IIFE的方案,就像在大办公室添加简单隔板,但是同事之间的协作流程(依赖)仍然需要手动维护。效率低下。
CommonJS
在2009年,Node.js横空出世,随之而来的还有CommonJS。最初是为了解决服务器端JS的模块化问题,但由于其简洁语法和明确的规则,很快在前端社区借鉴。
CommonJS规范简介
CommonJS的核心思想是:
- 每个文件视为一个模块
- 每个模块拥有单独的作用域
- 通过module.exports导出公开接口
- 通过require()函数导入其他模块 它有三个核心变量:
-
module:
代表当前模块,包含了模块的元信息(如模块ID、模块名称) -
module.exports:
模块的公开接口,导出的内容被require()
获取 -
require()
:同步加载模块函数,接收模块路径(相对路径、绝对路径、第三方包名),返回模块导出的内容
特性及使用
1. 同步加载特性
CommonJS 采用同步加载机制:执行require('./a.js')时,会暂停当前模块的执行,先去加载并执行a.js,完成后再继续执行当前模块。这种机制在服务器端非常合适 —— 因为服务器端加载的是本地文件,速度极快,同步加载不会造成性能问题。
导出模块
// 方式1:直接给module.exports赋值(导出单个对象/函数)
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
// 导出多个函数(通过对象字面量)
module.exports = {
add: add,
multiply: multiply,
};
// 简写(ES6对象属性简写)
// module.exports = { add, multiply };
// 方式2:使用exports导出多个属性
exports.add = add;
exports.multiply = multiply;
// 方式3:导出默认值
// 一个模块只能有一个默认导出
function subtract(a, b) {
return a - b;
}
module.exports = subtract;
// 导入时可以用任意变量名接收
// const mySubtract = require('./module');
导入模块
// 导入math模块(相对路径需加./,后缀.js可省略)
const math = require("./math");
console.log(math.add(2, 3)); // 5
console.log(math.multiply(4, 5)); // 20
// 解构导入(直接获取需要的成员)
const { add } = require("./math");
console.log(add(10, 20)); // 30
2. 缓存机制
CommonJS 有一个重要特性:模块被加载一次后,会被缓存到require.cache中,后续再require同一模块时,直接返回缓存的结果,不会重新执行模块代码。
这意味着模块中的代码(如初始化逻辑)只会执行一次,避免重复计算和资源浪费。
// counter.js(带有初始化逻辑的模块)
let count = 0;
count++; // 每次加载模块时会执行,但因缓存只执行一次
module.exports = {
getCount: () => count
};
// main1.js
const counter1 = require('./counter');
console.log(counter1.getCount()); // 1
// main2.js
const counter2 = require('./counter');
console.log(counter2.getCount()); // 1(缓存生效,count未重新加1)
// 清除缓存(不推荐,可能导致模块重复加载)
delete require.cache[require.resolve('./counter')];
const counter3 = require('./counter');
console.log(counter3.getCount()); // 1?不,清除缓存后重新加载,count会重新初始化并加1,输出1?
// 纠正:清除缓存后重新require,counter.js会重新执行,count从0开始加1,输出1(因为每次重新执行都是0→1)
Common.js的不足
CommonJS 虽然在服务器端大放异彩,但直接移植到前端浏览器时,暴露了严重的性能问题:
-
同步加载阻塞页面:
浏览器加载 JS 文件需要通过网络请求(而非本地文件),同步加载会导致 JS 执行线程阻塞 —— 若某个模块体积大或网络慢,整个页面会卡住,直到模块加载完成,严重影响用户体验 -
浏览器不原生支持:
浏览器环境中没有module、module.exports和require这三个变量,需要通过工具(如 Browserify、Webpack)将 CommonJS 模块打包成浏览器可识别的全局代码,增加了构建成本 -
无法按需加载:
同步加载机制决定了所有依赖模块必须在页面初始化时全部加载,无法根据用户操作(如点击按钮)动态加载模块,导致首屏加载体积过大
由此可见CommonJS更加适合服务器端,前端专门针对浏览器的模块化规范 ---- AMD应运而生。
AMD规范
为了解决 CommonJS 在浏览器端的同步加载问题,2011 年,AMD 规范(Asynchronous Module Definition,异步模块定义) 正式推出,其核心思想是 异步加载模块,依赖前置,回调执行
,最具代表性的实现库是 RequireJS。
AMD规范
AMD的核心函数:
-
define(id?, dependencies?, factory):
用于定义模块-
id
模块的唯一标识,若不指定,默认为模块文件的路径 -
dependencies
当前模块依赖的其他模块数组,若不指定,默认是['require', 'exports', 'module']
-
factory
模块的工厂函数,依赖模块加载完成后会执行该函数,函数的参数依次对应dependencies
中的模块,返回值为模块的导出内容
-
-
require(dependencies, callback):
用于加载模块-
dependencies
参数与define的dependencies和factory类似。
-
AMD 的关键特性是异步加载
:当加载一个模块时,浏览器会并行请求其依赖的模块,不会阻塞当前 JS 执行;所有依赖加载完成后,再执行工厂函数或回调函数。
异步加载原理及使用
异步加载原理:
以 “main模块依赖math模块” 为例,AMD 的加载流程是:
- 浏览器执行require(["main"], ...),发起main.js的请求
- 解析main.js时,发现其依赖math模块,发起math.js的请求(与其他请求并行)
- math.js加载完成后,再执行main模块的工厂函数,避免阻塞。
代码演示
步骤1 定义main模块
// 定义math模块,依赖为空(无其他模块依赖)
define("math", [], function () {
return {
add: function (a, b) {
return a + b;
},
subtract: function (a, b) {
return a - b;
},
};
});
步骤2 定义模块main的依赖模块
// 定义main模块,依赖"math"模块
define("main", ["math"], function (math) {
console.log(math.add(3, 5)); // 8
console.log(math.subtract(10, 4)); // 6
});
步骤3 在HTML中通过requireJS加载模块
<script src="https://cdn.jsdelivr.net/npm/requirejs@2.3.6/require.js" data-main="main"></script>
如果需要加载第三方库(如jQuery),只要符合AMD规范即可,无需额外包装。
define(["jquery", "math"], function($, math) {
// 使用jQuery操作DOM
$("body").append(`<p>3+5=${math.add(3,5)}</p>`);
});
AMD 的最大问题是语法冗余:为了支持异步和依赖前置,需要编写嵌套的回调函数(虽然 RequireJS 支持简化语法,但仍比 CommonJS 繁琐);此外,“依赖前置” 意味着即使某个依赖在工厂函数中未被使用,也会提前加载,造成一定的资源浪费。
为了平衡 “异步加载” 和 “语法简洁”,另一种浏览器端规范 ——CMD 应运而生。
CMD规范
CMD(Common Module Definition,通用模块定义)是由国内前端开发者玉伯(支付宝前端团队)提出的规范,核心实现库是 SeaJS。CMD 的设计理念是 “延迟加载,按需执行”,试图在 AMD 的异步优势和 CommonJS 的简洁语法之间找到平衡。
CMD规范简介
CMD 与 AMD 的核心区别在于依赖加载时机
:
- AMD:依赖前置,在定义模块时就声明所有依赖,依赖模块会提前加载并执行
- CMD:依赖就近,在模块工厂函数中需要使用某个依赖时,才通过require()加载,实现 “按需加载”
CMD 的define
函数语法与 AMD 类似,但工厂函数的参数固定为require、exports、module(无需手动声明依赖数组):
-
require
:加载模块的函数(同步,因依赖已提前异步加载) -
exports
:模块的导出接口(与 CommonJS 的module.exports类似) -
module
:模块元信息对象。
延迟加载机制及使用
1. 延迟加载机制
CMD 的加载流程是 “异步加载模块文件,但延迟执行依赖模块”:
- 浏览器加载模块文件时,会先解析模块代码,收集所有require()调用的依赖路径(但不立即加载)
- 执行模块工厂函数时,当遇到require('./math'),才会加载并执行math模块
- 若某个依赖只在特定条件下使用(如if (isDebug) require('./debug')),则只有条件满足时才会加载,实现真正的 “按需加载”
2. 代码演示
步骤1 定义math模块
// CMD模块定义:无需声明依赖,通过exports导出
define(function (require, exports, module) {
// 私有函数
function validateNum(num) {
return typeof num === "number";
}
// 公开方法:通过exports挂载
exports.add = function (a, b) {
if (!validateNum(a) || !validateNum(b)) return 0;
return a + b;
};
exports.subtract = function (a, b) {
if (!validateNum(a) || !validateNum(b)) return 0;
return a - b;
};
});
步骤2 定义main模块 按需加载math模块
// CMD入口模块:依赖就近,需要时才加载
define(function (require, exports, module) {
// 1. 先执行非依赖逻辑
console.log("模块开始执行");
// 2. 需要使用math时,才调用require加载
const math = require("./math");
console.log("3+5 =", math.add(3, 5)); // 8
// 3. 条件依赖:仅在debug模式下加载debug模块
const isDebug = true;
if (isDebug) {
const debug = require("./debug");
debug.log("当前计算完成"); // 输出调试信息
}
});
步骤 3:在 HTML 中通过 SeaJS 加载入口模块
<!-- 引入SeaJS -->
<script src="https://cdn.jsdelivr.net/npm/seajs@3.0.3/dist/sea.js"></script>
<script>
// 配置基础路径(可选)
seajs.config({
base: "./js",
paths: {
math: "math.js",
debug: "debug.js",
},
});
// 加载入口模块main.js
seajs.use("./main");
</script>
CMD局限
尽管 CMD 兼顾了异步与简洁,但随着前端工具链(如 Webpack)的崛起,其局限性逐渐凸显:
-
生态碎片化:CMD 主要在国内推广,生态规模远小于 AMD 和 CommonJS,第三方库支持不足
-
工具链替代:Webpack 等构建工具支持按需加载(如import()动态导入),且能兼容多种模块化规范,CMD 的 “按需加载” 优势被覆盖
-
维护停滞:SeaJS 后续更新缓慢,逐渐被前端社区淘汰
CMD 的尝试为前端模块化提供了 “按需加载” 的思路,但真正终结 “规范混战” 的,是 ES6 推出的官方标准 ——ESModule。
ESModule:官方标准终极方案
2015 年,ES6(ECMAScript 2015)正式推出ESModule(简称 ESM) ,作为 JS 语言层面的官方模块化规范。它整合了之前各规范的优点(如 CommonJS 的简洁语法、AMD 的异步加载),解决了浏览器与服务器端模块化标准不统一的问题,成为当前前端模块化的绝对主流。
ESModule特性
1. 静态导入导出
ESM 最显著的特性是静态分析:import和export语句必须放在模块顶层(不能嵌套在if、函数等代码块中),依赖关系在代码编译阶段就能确定,而非运行时。这一特性让以下优化成为可能:
- Tree-Shaking:构建工具(如 Webpack、Rollup)可删除未使用的导出成员,减小打包体积
- TS 等类型语言可提前分析模块依赖的类型,提升开发体验
2. 值的动态引用
与 CommonJS 导出 “值的拷贝” 不同,ESM 导出的是 “值的引用”,且保持动态绑定:若导出模块中的值发生变化,导入方获取的值也会同步更新
// export-module.js(导出模块)
export let count = 0;
export function increment() {
count++;
}
// import-module.js(导入模块)
import { count, increment } from './export-module.js';
console.log(count); // 0(初始值)
increment(); // 调用导出模块的函数修改count
console.log(count); // 1(同步更新,体现动态引用)
3. 支持异步加载
ESM 既支持静态import(编译时加载),也支持动态import()(运行时加载)。动态import()返回一个 Promise 对象,可在需要时(如用户点击、路由切换)按需加载模块,避免首屏加载冗余代码。
4. 自动启用严格模式
所有 ESM 模块默认运行在严格模式('use strict') 下,即使未显式声明
- 禁止使用未声明的变量
- 禁止this指向全局对象(this为undefined)
- 禁止删除变量、函数等
5. 独立作用域与CORS请求
- 独立作用域:每个 ESM 模块都有独立的私有作用域,模块内定义的变量、函数不会污染全局
- CORS 请求:浏览器加载 ESM 模块时,会通过 CORS 机制验证跨域请求(需服务器返回Access-Control-Allow-Origin头),而普通脚本标签(type="text/javascript")无此限制
6. 延迟执行脚本
通过<script type="module">引入的 ESM 脚本,默认具备defer属性的行为
- 脚本加载时不阻塞 HTML 解析
- 脚本执行顺序与 HTML 中声明顺序一致
- 脚本在 DOM 解析完成后、DOMContentLoaded事件触发前执行
ESModule导出使用
ESM 的导出分为 “命名导出” 和 “默认导出”,二者可组合使用,但一个模块只能有一个默认导出。
单个命名导出
// 导出单个变量
export const name = "ESModule";
// 导出单个函数
export function formatDate(date) {
return date.toISOString();
}
批量命名导出
// 先定义成员,再批量导出
const version = "1.0.0";
function logInfo(info) {
console.log(`[INFO] ${info}`);
}
// 批量导出(可指定别名,如version→moduleVersion)
export { version as moduleVersion, logInfo };
默认导出
默认导出用于导出模块的 “主要成员”,导入时可自定义成员名称(无需与导出名称一致)
1. 直接默认导出
// 导出默认函数
export default function(a, b) {
return a + b;
}
// 导出默认对象
export default {
name: "MathUtils",
add: (a, b) => a + b
};
2. 先定义后默认导出
class User {
constructor(name) {
this.name = name;
}
}
// 先定义类,再默认导出(注意:default后无大括号)
export default User;
3. 混合导出
// 命名导出:辅助工具函数
export function validateNum(num) {
return typeof num === "number";
}
// 默认导出:主要功能函数
export default function calculateArea(radius) {
if (!validateNum(radius)) return 0;
return Math.PI * radius ** 2;
}
ESModule导入使用
导入语法需与导出语法对应,同时支持别名、整体导入、动态导入等灵活用法。
1. 导入命名导入
// 导入指定命名成员(名称需与导出一致)
import { name, formatDate } from "./module.js";
// 导入时指定别名(解决命名冲突)
import { version as moduleVersion, logInfo } from "./module.js";
// 整体导入:将所有命名成员挂载到一个对象上
import * as ModuleUtils from "./module.js";
console.log(ModuleUtils.name); // 访问整体导入的成员
ModuleUtils.logInfo("整体导入示例");
2. 导入默认导出成员
导入默认成员时,可自定义名称,无需使用大括号
// 导入默认函数(自定义名称为add)
import add from "./module.js";
console.log(add(2, 3)); // 5
// 导入默认类(自定义名称为UserClass)
import UserClass from "./module.js";
const user = new UserClass("张三");
3. 混合导入
// 方式1:分开导入
import calculateArea from "./module.js"; // 默认成员
import { validateNum } from "./module.js"; // 命名成员
// 方式2:合并导入(默认成员在前,命名成员在大括号内)
import calculateArea, { validateNum } from "./module.js";
// 使用示例
if (validateNum(5)) {
console.log(calculateArea(5)); // 78.5398...
}
4. 动态导入
动态import()可在任意代码位置使用,返回的 Promise 成功后,通过解构或对象访问模块成员
// 场景1:用户点击后加载模块
document.getElementById("loadBtn").addEventListener("click", async () => {
try {
// 动态导入模块
const { formatDate } = await import("./module.js");
console.log(formatDate(new Date()));
} catch (err) {
console.error("模块加载失败:", err);
}
});
// 场景2:路由切换时加载对应组件(Vue/React 路由懒加载原理)
function loadRouteComponent(route) {
switch (route) {
case "home":
return import("./HomeComponent.js");
case "about":
return import("./AboutComponent.js");
}
}
ESModule和CommonJS的差异
作为当前最主流的两种模块化规范,ESM 与 CommonJS 的差异直接影响开发选型,需重点掌握
对比维度 | ESM | CommonJS |
---|---|---|
标准归属 | JS官方标准 | 社区规范 |
加载时机 | 编译时静态加载 | 运行时动态加载 |
导出内容 | 值的引用 | 值的拷贝 |
this的指向 | undefined(严格模式) | 模块对象(module.exports) |
循环依赖处理 | 基于动态引用,支持循环依赖 | 基于缓存,可能导致依赖不完整 |
浏览器支持 | 原生支持 | 需构建工具(如webpack)配合 |
环境 | 浏览器 + Node.js(14.3.0+) | 主要用于Node.js |
Tree-Shaking | 支持 | 不支持 |
小结
前端模块化的发展历程,是一个从 "野蛮生长" 到 "规范统一" 的演进过程:
- 原始阶段:全局变量污染、依赖混乱、维护困难
- IIFE阶段:通过函数作用域实现初步封装,减少全局污染
- CommonJS:服务端模块化标准,同步加载机制适合Node.js环境
- AMD/CMD:浏览器端异步加载方案,解决前端性能问题
- ESModule:官方标准,统一前后端模块化,支持静态分析、动态导入等现代特性
当前,ESModule 已成为前端模块化的绝对主流,其官方标准地位、静态分析能力、Tree-Shaking 优化等特性,使其在现代化前端工程中不可或缺。而 CommonJS 凭借其在 Node.js 生态中的深厚根基,在服务端开发中仍占据重要地位。
理解模块化的发展历程和各规范的特点,不仅有助于我们在不同场景下做出合理的技术选型,更能深刻体会前端工程化思想的演进脉络,为构建可维护、可扩展的大型应用打下坚实基础。