JS核心知识-模块化

在前端技术飞速迭代的过程中,模块化始终是贯穿发展的核心命题之一。前端从早期的几行JS代码能搞定一个页面,到如今动辄百万行代码的大型单页面应用,开发者面临的最大挑战早已不是实现功能,而是如何让代码可维护、可复用、可扩展。当一个项目的JS文件堆成数百行,变量和函数在全局环境下互相打架,团队协作时稍微不注意就会引起命名冲突,上线前还要靠人为的梳理几十个script标签的加载顺序----这样的场景让无数开发者头疼,这正是模块化诞生的使命。

本文将以前端发展历程为主线,从最原始的代码组织方式开始,一步步拆解模块化如何从 "临时解决方案" 进化为 "官方标准",深入剖析每个阶段的核心方案、解决的痛点与局限,并重点详解当前主流的 CommonJS 与 ESModule 规范,帮助你彻底理清前端模块化的来龙去脉。

前端蛮荒时代

在前端还处于刀耕火种的初级阶段时,网页主要以展示为主,交互逻辑简单,JS代码通常只有几十到几百行。开发者代码组织方式就是将所有函数和变量直接定义在全局作用域(window)中,通过script标签引入到HTML文件中。

js 复制代码
// global-utils.js
function formatDate(date) {
 // 日期格式化逻辑
 return date.toLocaleDateString();
}

let userInfo = {
  name: "前端小白",
  role: "visitor"
};

// 直接在全局作用域挂载函数和变量
window.formatDate = formatDate;
window.userInfo = userInfo;

在HTML中引入上述文件,然后在通过另一个script标签调用执行即可

html 复制代码
<script src="global-utils.js"></script>
<script>
  // 直接使用全局变量和函数
  console.log(formatDate(new Date()));
  console.log(userInfo.name);
</script>

这种方式虽然简单直接,但是当项目日益庞大时,这种全局暴露方式存在严重问题:

  1. 命名冲突:多个脚本文件中若定义了同名函数(如两个文件都有formatDate),后加载的脚本会覆盖先加载的,导致逻辑错乱

  2. 依赖混乱:若项目依赖多个<script>标签,必须严格保证加载顺序(如a.js依赖b.js,则b.js必须放在前面),一旦顺序出错,就会报 "变量未定义" 错误

  3. 维护困难:全局作用域下的变量和函数没有 "边界",后期修改一个函数时,无法快速定位它被哪些地方引用,容易引发 "牵一发而动全身" 的 bug

  4. 污染全局环境:过多的全局变量会占用window对象的属性,可能与浏览器原生 API 或第三方库冲突(如自定义$变量可能与 jQuery 冲突)

此时的前端项目代码,就像是一间没有隔离板的大办公室,所有人的物品随意摆放,毫无秩序。

IIFE带来的改善和局限

为了解决全局变量污染问题,开发者开始利用作用域特性的方案。在ES6之前,JS中只有函数作用域,因此立即执行函数表达式(IIFE)成为了当时最为主流的临时解决方案。

原理: 通过 "定义一个匿名函数并立即执行",创建一个独立的函数作用域,将变量和函数包裹在内部,只对外暴露需要共享的接口,从而避免全局污染。

js 复制代码
// 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;

使用时通过全局命名空间调用即可,避免了多个全局变量

js 复制代码
<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,完成后再继续执行当前模块。这种机制在服务器端非常合适 ------ 因为服务器端加载的是本地文件,速度极快,同步加载不会造成性能问题。

导出模块

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');

导入模块

js 复制代码
// 导入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同一模块时,直接返回缓存的结果,不会重新执行模块代码。

这意味着模块中的代码(如初始化逻辑)只会执行一次,避免重复计算和资源浪费。

js 复制代码
// 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 的加载流程是:

  1. 浏览器执行require(["main"], ...),发起main.js的请求
  2. 解析main.js时,发现其依赖math模块,发起math.js的请求(与其他请求并行)
  3. math.js加载完成后,再执行main模块的工厂函数,避免阻塞。 代码演示

步骤1 定义main模块

js 复制代码
// 定义math模块,依赖为空(无其他模块依赖)
define("math", [], function () {
  return {
    add: function (a, b) {
      return a + b;
    },
    subtract: function (a, b) {
      return a - b;
    },
  };
});

步骤2 定义模块main的依赖模块

js 复制代码
// 定义main模块,依赖"math"模块
define("main", ["math"], function (math) {
  console.log(math.add(3, 5)); // 8
  console.log(math.subtract(10, 4)); // 6
});

步骤3 在HTML中通过requireJS加载模块

js 复制代码
<script src="https://cdn.jsdelivr.net/npm/requirejs@2.3.6/require.js" data-main="main"></script>

如果需要加载第三方库(如jQuery),只要符合AMD规范即可,无需额外包装。

js 复制代码
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 的加载流程是 "异步加载模块文件,但延迟执行依赖模块":

  1. 浏览器加载模块文件时,会先解析模块代码,收集所有require()调用的依赖路径(但不立即加载)
  2. 执行模块工厂函数时,当遇到require('./math'),才会加载并执行math模块
  3. 若某个依赖只在特定条件下使用(如if (isDebug) require('./debug')),则只有条件满足时才会加载,实现真正的 "按需加载"

2. 代码演示

步骤1 定义math模块

js 复制代码
// 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模块

js 复制代码
// 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 加载入口模块

js 复制代码
<!-- 引入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)的崛起,其局限性逐渐凸显:

  1. 生态碎片化:CMD 主要在国内推广,生态规模远小于 AMD 和 CommonJS,第三方库支持不足

  2. 工具链替代:Webpack 等构建工具支持按需加载(如import()动态导入),且能兼容多种模块化规范,CMD 的 "按需加载" 优势被覆盖

  3. 维护停滞: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 导出的是 "值的引用",且保持动态绑定:若导出模块中的值发生变化,导入方获取的值也会同步更新

js 复制代码
// 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 的导出分为 "命名导出" 和 "默认导出",二者可组合使用,但一个模块只能有一个默认导出。

单个命名导出

js 复制代码
// 导出单个变量
export const name = "ESModule";
// 导出单个函数
export function formatDate(date) {
  return date.toISOString();
}

批量命名导出

js 复制代码
// 先定义成员,再批量导出
const version = "1.0.0";
function logInfo(info) {
  console.log(`[INFO] ${info}`);
}
// 批量导出(可指定别名,如version→moduleVersion)
export { version as moduleVersion, logInfo };

默认导出

默认导出用于导出模块的 "主要成员",导入时可自定义成员名称(无需与导出名称一致)

1. 直接默认导出
js 复制代码
// 导出默认函数
export default function(a, b) {
  return a + b;
}
// 导出默认对象
export default {
  name: "MathUtils",
  add: (a, b) => a + b
};
2. 先定义后默认导出
js 复制代码
class User {
  constructor(name) {
    this.name = name;
  }
}

// 先定义类,再默认导出(注意:default后无大括号)
export default User;
3. 混合导出
js 复制代码
// 命名导出:辅助工具函数
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. 导入命名导入

js 复制代码
// 导入指定命名成员(名称需与导出一致)
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. 导入默认导出成员

导入默认成员时,可自定义名称,无需使用大括号

js 复制代码
// 导入默认函数(自定义名称为add)
import add from "./module.js";
console.log(add(2, 3)); // 5

// 导入默认类(自定义名称为UserClass)
import UserClass from "./module.js";
const user = new UserClass("张三");

3. 混合导入

js 复制代码
// 方式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 成功后,通过解构或对象访问模块成员

js 复制代码
// 场景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 生态中的深厚根基,在服务端开发中仍占据重要地位。

理解模块化的发展历程和各规范的特点,不仅有助于我们在不同场景下做出合理的技术选型,更能深刻体会前端工程化思想的演进脉络,为构建可维护、可扩展的大型应用打下坚实基础。

相关推荐
崔庆才丨静觅11 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606112 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了12 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅12 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅12 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅13 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment13 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅13 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端
爱敲代码的小鱼13 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax