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 生态中的深厚根基,在服务端开发中仍占据重要地位。

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

相关推荐
Jonathan Star2 小时前
沉浸式雨天海岸:用A-Frame打造WebXR互动场景
前端·javascript
工业甲酰苯胺2 小时前
实现 json path 来评估函数式解析器的损耗
java·前端·json
老前端的功夫2 小时前
Web应用的永生之术:PWA落地与实践深度指南
java·开发语言·前端·javascript·css·node.js
LilySesy3 小时前
ABAP+WHERE字段长度不一致报错解决
java·前端·javascript·bug·sap·abap·alv
Wang's Blog4 小时前
前端FAQ: Vue 3 与 Vue 2 相⽐有哪些重要的改进?
前端·javascript·vue.js
再希4 小时前
React+Tailwind CSS+Shadcn UI
前端·react.js·ui
用户47949283569154 小时前
JavaScript 的 NaN !== NaN 之谜:从 CPU 指令到 IEEE 754 标准的完整解密
前端·javascript
群联云防护小杜4 小时前
国产化环境下 Web 应用如何满足等保 2.0?从 Nginx 配置到 AI 防护实战
运维·前端·nginx
醉方休5 小时前
Web3.js 全面解析
前端·javascript·electron
前端开发爱好者5 小时前
前端新玩具:Vike 发布!
前端·javascript