前端必知必会:从 IIFE 到 ESM,模块化到底在解决什么?

打开一个现代前端项目,你会看到满屏的 importexport。但在早期,前端还在用 <script> 标签一个个加载 JS 文件,小心控制顺序,生怕变量污染。后来到 Node.js 后端,又有一套 require / module.exports 的写法。为什么前端和后端的模块化语法不一样?就让我们从零开始,理一理 JavaScript 模块化的完整进化脉络。

一、蛮荒时代:全局变量与 IIFE

最早的网页 JS 代码是这样的:

html 复制代码
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="app.js"></script>

所有脚本共享同一个全局作用域。utils.js 里定义了一个 var log = function() {...}app.js 里也定义了一个 var log = function() {...},后加载的就会悄悄覆盖前面的。更可怕的是,没人知道这个 log 到底来自哪个文件,项目一大,全局变量就成了灾难。

IIFE(立即执行函数表达式) 是那个年代最优雅的解决方案:

javascript 复制代码
// utils.js
var Utils = (function() {
  var privateKey = 'abc'; // 模块内部私有
  return {
    log: function(msg) {
      console.log(`[${privateKey}] ${msg}`);
    },
    formatDate: function(date) { /* ... */ }
  };
})();

通过函数作用域制造一个"隔离区",只暴露想暴露的接口。jQuery 本身就是这么写的:(function(window) { ... })(window)。这解决了命名冲突,但依赖关系依然靠脚本加载顺序来保证,多人协作时特别容易出错。

如果你维护过基于 jQuery + Bootstrap 的老后台,或者接手过一些没做工程化的老页面,IIFE 模式随处可见。它是前端模块化思维的起点。

二、CommonJS:服务端的"正统"模块化

2009 年 Node.js 诞生,服务端需要一个真正的模块化方案。CommonJS 规范应运而生,它设计了三个核心概念:

  • require():同步加载模块
  • module.exports:暴露接口
  • 每个文件就是一个模块,拥有独立作用域
javascript 复制代码
// math.js
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;
module.exports = { add, multiply };

// app.js
const math = require('./math');
console.log(math.add(1, 2)); // 3

为什么服务端可以用同步 require 因为 Node.js 从硬盘读取文件是毫秒级的,阻塞一下无所谓。但浏览器不行------如果 require 要同步通过网络加载一个远端 JS 文件,页面会直接卡死。所以 CommonJS 天然只适合服务端。

CommonJS 的重要特性:

  • 模块在第一次 require 时执行并缓存,之后再 require 直接返回缓存结果。
  • 导出的是值的拷贝 ,不是引用。如果你导出 var count = 0,并提供了一个 add() 方法修改 count,外部拿到的是 count 的副本,不会自动更新。

如果你写过 Node.js 脚本、Webpack 配置文件(webpack.config.js 本身就是 CommonJS),甚至一些老的 gulpfile.js,都在和 CommonJS 打交道。

三、AMD 与 RequireJS:浏览器的异步加载方案

既然 CommonJS 的同步 require 不适合浏览器,社区就搞出了 AMD(Asynchronous Module Definition)。它的理念是:模块提前声明依赖,加载完后通过回调执行。

RequireJS 是 AMD 最知名的实现:

javascript 复制代码
// 定义一个模块
define('math', [], function() {
  return {
    add: function(a, b) { return a + b; }
  };
});

// 使用模块
require(['math'], function(math) {
  console.log(math.add(1, 2)); // 3
});

AMD 解决了浏览器异步加载的问题,但写法太啰嗦------所有依赖都要包在一个回调函数里,代码一复杂就成了"回调金字塔"。而且它和 CommonJS 不兼容,同样的模块,服务端用 require,浏览器端就得改成 define,这催生了后来的 UMD(通用模块定义) ------一段长长的判断代码,同时兼容 AMD、CommonJS 和全局变量三种环境。你点开 node_modules 里很多库的 dist 文件,开头就是那段经典的 UMD 模板。

现在的项目基本不用 AMD 了,但 UMD 在很多老牌 npm 包(比如 Lodash、moment.js)的构建产物中依然存在,理解它有助于你读懂那些 dist 文件。

四、ES Modules:官方标准,统一前后端

2015 年 ES6 发布,ES Modules(ESM) 正式成为 JavaScript 的官方模块化标准。它吸取了 CommonJS 和 AMD 的经验,设计上做了几个关键改进:

4.1 静态语法

javascript 复制代码
// math.js
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;

// app.js
import { add, multiply } from './math.js';
console.log(add(1, 2));

importexport 必须写在模块顶层,不能放在 if 或函数里(动态 import() 除外)。这意味着模块的依赖关系在代码执行前就完全确定了------这正是 Tree-Shaking 的基础。

4.2 导出的是引用,不是拷贝

这是 ESM 和 CommonJS 最重要的区别。CommonJS 导出值的拷贝,而 ESM 导出的是活绑定------导出的变量和模块内部的变量是同一个引用:

javascript 复制代码
// counter.js
export let count = 0;
export function increment() {
  count++;
}

// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← CommonJS 会输出 0

4.3 动态导入 import()

ES2020 引入了动态导入,返回一个 Promise:

javascript 复制代码
// 按需加载
const module = await import('./heavyModule.js');
module.doSomething();

// 条件加载
if (user.lang === 'zh') {
  const zh = await import('./i18n/zh.js');
}

实战场景:React 的 React.lazy()、Vue Router 的路由懒加载,底层就是 import()

jsx 复制代码
// React 路由懒加载
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Settings = React.lazy(() => import('./pages/Settings'));

打包工具(Webpack / Vite)会把动态导入的模块拆成独立的 chunk,用户访问对应页面时才加载,这就是代码分割的核心原理。

4.4 import.meta

import.meta 是一个包含当前模块元信息的对象,最常用的是 import.meta.url(当前模块的 URL 路径):

javascript 复制代码
// 获取当前模块所在目录
const currentDir = new URL('.', import.meta.url).pathname;

// Vite 中判断开发/生产环境
if (import.meta.env.DEV) {
  console.log('开发模式');
}

Vite 利用 import.meta 实现了环境变量注入(import.meta.env.VITE_API_URL),Webpack 5 也支持了部分 import.meta 特性。

总结

JavaScript 模块化的演变,本质上是在解决三个核心问题:

  1. 作用域隔离(IIFE → 每个文件就是一个模块)
  2. 依赖管理 (手动维护顺序 → 静态 import 声明 → 打包工具自动分析)
  3. 加载性能(全量加载 → 按需异步加载 → Tree-Shaking 剔除无用代码)

这条进化脉络也反映了前端从"页面脚本"到"工程化应用"的蜕变。

相关推荐
渣波1 小时前
拒绝 SQL 焦虑!手把手带你用 NestJS + Prisma + DTO 写出“防弹”级后端代码
javascript·数据库·后端
槑有老呆2 小时前
每次跟大模型聊天,都是一次「失忆」的 HTTP 请求
javascript
笨鸟飞不快2 小时前
从单个服务到集群:一次完整的性能排查复盘
java·前端
sarasuki2 小时前
彻底搞懂JS闭包:从作用域链、形成条件到优缺点
javascript
糖拌西瓜皮2 小时前
TypeScript 进阶:泛型、条件类型、类型守卫与装饰器
javascript·node.js
禅思院2 小时前
Vite vs Webpack 深度对比:从启动原理到生产构建,一篇就够了
前端·架构·前端框架
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端
朦胧之12 小时前
AI 编程-老项目改造篇
java·前端·后端
swipe15 小时前
从 0 到 1 实现大文件上传:分片、秒传、断点续传、暂停、重试与服务端合并
前端·javascript·面试