引言:JavaScript 模块化的必要性

在过去十年中,JavaScript 从一个简单的脚本语言演变为构建大型企业级应用的强大平台。随着单页应用(SPA)、微前端架构和 Node.js 后端服务的兴起,代码规模呈指数级增长,模块化已经从”锦上添花”变成了”刚需”。一个没有模块系统的 JavaScript 项目,在面对数千个文件、错综复杂的依赖关系时,几乎不可能维持可维护性。
模块化的核心目标包括:命名空间隔离(避免全局变量污染)、依赖管理(显式声明依赖)、代码复用(DRY 原则)、按需加载(减少首屏体积)以及可测试性(解耦模块便于单元测试)。本文将系统梳理 JavaScript 模块系统的演进历程,从原始的全局函数到如今的 ES Module,并深入剖析每种方案的设计思路、实现原理和适用场景。
第一阶段:原始时代的模块化尝试
全局函数与命名空间
在 ES6 之前,JavaScript 语言本身并没有提供模块化机制。最朴素的做法是直接定义全局函数:
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
这种方式的问题显而易见:所有函数都暴露在全局作用域(window)下,项目稍大就会发生命名冲突。更糟糕的是,依赖关系完全不可见,开发者需要手动保证脚本的加载顺序。一个常见的改进是使用命名空间模式,即用一个全局对象来收纳相关功能:
var MathUtils = {
add: function(a, b) { return a + b; },
subtract: function(a, b) { return a - b; },
PI: 3.14159
};
这虽然减少了全局污染,但问题在于:MathUtils 本身仍然是全局的,且所有成员都是公开的,无法实现私有变量。此外,开发者仍然需要手动管理脚本加载顺序,任何一个依赖类库的 JS 文件如果加载顺序错误,就会抛出 undefined is not a function 这类令人头疼的错误。
IIFE 模式:模块化的雏形
真正意义上的模块化探索始于立即执行函数表达式(IIFE)。IIFE 利用 JavaScript 的函数作用域创建了私有变量空间,同时通过返回对象的方式暴露公共接口,堪称”闭包模块”的鼻祖:
var Calculator = (function() {
// 私有变量
var result = 0;
// 私有函数
function validate(num) {
return typeof num === 'number' && !isNaN(num);
}
// 公有接口
return {
add: function(num) {
if (validate(num)) result += num;
return this;
},
subtract: function(num) {
if (validate(num)) result -= num;
return this;
},
getResult: function() {
return result;
},
reset: function() {
result = 0;
return this;
}
};
})();
Calculator.add(10).subtract(3).getResult(); // 7
IIFE 模式带来了三个关键进步:私有变量(validate 函数和 result 变量对外不可见)、链式调用(通过 return this 实现)以及模块封装(所有逻辑都在函数作用域内)。这种模式后来被广泛用于 jQuery 插件开发和各种开源库中。
Revealing Module Pattern(揭示模块模式)是 IIFE 的一种变体,它在私有作用域内定义所有函数和变量,最后统一暴露公有接口:
var UserModule = (function() {
var users = [];
var idCounter = 0;
function _findById(id) {
return users.find(u => u.id === id);
}
function addUser(name, email) {
var user = { id: ++idCounter, name: name, email: email };
users.push(user);
return user;
}
function removeUser(id) {
var index = users.findIndex(u => u.id === id);
if (index !== -1) return users.splice(index, 1)[0];
return null;
}
// 统一暴露
return {
add: addUser,
remove: removeUser
};
})();
第二阶段:CommonJS — Node.js 的选择
CommonJS 的设计哲学
2009 年,随着 Node.js 的诞生,JavaScript 首次进入服务端领域。服务端场景天然需要文件系统和网络 I/O,模块化成为必不可少的基础设施。CommonJS 规范应运而生,它的核心思想是:每个文件就是一个模块,文件内的变量和函数默认对外不可见,通过 module.exports 导出接口,通过 require() 同步加载依赖。
// math.js
const PI = 3.14159;
function circleArea(radius) {
return PI * radius * radius;
}
function circleCircumference(radius) {
return 2 * PI * radius;
}
module.exports = {
circleArea,
circleCircumference,
PI
};
// app.js
const math = require('./math.js');
console.log(math.circleArea(5)); // 78.53975
CommonJS 的几个重要特性:
- 同步加载:服务器端读取本地文件是微秒级的 I/O 操作,同步加载不会产生性能问题。这也是 CommonJS 没有被浏览器端直接采用的根本原因。
- 值拷贝:
require返回的是导出值的浅拷贝。如果导出的是一个基本类型值,模块内部后续修改不会影响外部已经获取到的值。 - 模块缓存:每个模块第一次被
require后会被缓存,后续再次require直接返回缓存结果,保证单例行为。 - 动态加载:
require可以在代码的任何位置调用,可以根据条件按需加载模块。
exports vs module.exports
初学者经常混淆 exports 和 module.exports。实际上,exports 只是 module.exports 的一个引用(类似 var exports = module.exports)。Node.js 在每个模块开头隐式做了如下操作:
var module = { exports: {} };
var exports = module.exports; // exports 指向同一个对象
// 正确用法:给 exports 添加属性
exports.add = function(a, b) { return a + b; };
// ❌ 错误用法:直接给 exports 重新赋值会切断引用
exports = { add: function(a, b) { return a + b; } };
// 此时 module.exports 仍然是空对象 {}
理解这个机制对于调试诡异的模块导出问题至关重要。特别需要注意的是,exports 和 module.exports 不能混用——如果给 module.exports 赋值为一个全新对象,之前挂载在 exports 上的属性都会丢失。
CommonJS 在浏览器中的困境
浏览器环境无法使用 CommonJS 的原生方案,因为:
| 特性 | CommonJS | 浏览器要求 |
|---|---|---|
| 加载方式 | 同步(同步 I/O) | 异步(网络请求不可阻塞) |
| 模块系统 | 运行时解析 | 需要提前打包或异步加载 |
| 全局对象 | 依赖 module、exports、require |
浏览器默认没有这些对象 |
为了解决这个矛盾,社区开发了 Browserify 和 Webpack 等打包工具。它们会在构建阶段分析所有 require() 调用,将 CommonJS 模块打包成一个或多个浏览器可执行的 bundle 文件,在打包后的代码中模拟 require 函数和模块缓存机制。
第三阶段:AMD 与 RequireJS — 浏览器的异步方案
AMD 规范
与 CommonJS 的同步哲学不同,AMD(Asynchronous Module Definition) 从一开始就为浏览器环境设计。它的核心 API 是 define() 函数,通过回调函数的方式实现异步加载:
// 定义模块 math.js
define('math', ['dependency'], function(dep) {
var PI = 3.14159;
function circleArea(radius) {
return PI * radius * radius;
}
return {
circleArea: circleArea,
PI: PI
};
});
// 使用模块
require(['math'], function(math) {
console.log(math.circleArea(5));
});
RequireJS 是 AMD 规范最流行的实现。它的工作原理是:当 require(['math'], callback) 被调用时,RequireJS 会动态创建 script 标签插入到页面中,监听脚本加载完成后执行回调。这种方式真正实现了按需加载,对于大型单页应用的性能优化至关重要。
AMD 的优缺点
AMD 的优势在于:
- 异步加载:不会阻塞页面渲染,适合浏览器环境
- 依赖前置:模块的依赖在
define的第一个数组参数中显式声明,清晰可读 - 插件机制:RequireJS 支持 text!、css! 等插件,可以加载非 JS 资源
然而 AMD 也有明显的缺点:
- 语法复杂:相比 CommonJS 的自然写法,AMD 需要嵌套
define()和require(),代码不够直观 - 文件体积:运行时需要加载 RequireJS 库本身
- 构建步骤复杂:生产环境通常还需要 r.js 这样的优化工具将模块合并
AMD 曾经在前端社区占据重要地位(jQuery、Dojo、Backbone 等框架都曾采用 AMD),但随着 ES Module 标准的到来和打包工具的成熟,AMD 逐渐退出了历史舞台的主流位置。
第四阶段:UMD — 兼容性的过渡方案
UMD(Universal Module Definition) 与其说是一种独立的模块规范,不如说是一个兼容性适配器。它的目标是一个模块同时支持 CommonJS、AMD 和全局变量三种环境。典型的 UMD 模式如下:
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD 环境
define(['jquery'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS 环境
module.exports = factory(require('jquery'));
} else {
// 全局变量
root.MyModule = factory(root.jQuery);
}
})(typeof self !== 'undefined' ? self : this, function($) {
// 模块代码
function myPlugin() {
$('body').css('background', 'red');
}
return { myPlugin: myPlugin };
});
UMD 在 npm 生态早期非常流行,许多库(如 Lodash、Moment.js)都使用 UMD 格式发布。但由于 UMD 本质上是一种妥协方案,代码冗余且运行时检测环境增加了体积,随着 ES Module 成为浏览器和 Node.js 的通用标准,UMD 的适用范围越来越窄。目前 UMD 的主要用途是发布兼容性 npm 包,特别是在需要支持 Script 标签直接引用的场景下。
第五阶段:ES Module — 官方标准

ES Module 的设计
ECMAScript 2015(ES6)在语言规范层面正式引入了模块系统,使用 import 和 export 关键字。这是 JavaScript 模块化的里程碑事件——模块支持终于成为了语言的一等公民。
// utils/math.js
export const PI = 3.14159;
export function circleArea(radius) {
return PI * radius * radius;
}
// 默认导出
export default function sum(a, b) {
return a + b;
}
// app.js
import sum, { PI, circleArea } from './utils/math.js';
console.log(circleArea(5)); // 78.53975
ES Module 与 CommonJS 的核心区别:
| 特性 | CommonJS | ES Module |
|---|---|---|
| 语法 | 运行时 API(require() 是函数) |
静态语法(import/export 是关键字) |
| 加载 | 同步 | 异步(支持 import() 动态导入) |
| 绑定 | 值拷贝 | 动态绑定(导出值的实时引用) |
| 静态分析 | 不支持(require 可以写在任何位置) |
支持(import/export 必须在顶层) |
| 循环依赖 | 部分支持(已加载部分可用) | 良好支持 |
| 树摇(Tree Shaking) | 困难(动态特性导致死代码难以剔除) | 天然支持(静态结构便于 Dead Code Elimination) |
动态导入与代码分割
ES Module 不仅提供了静态 import 语法,还引入了 import() 动态导入函数,它返回一个 Promise 对象,真正实现了运行时按需加载:
// 按需加载对话框模块
async function openDialog(dialogType) {
try {
const module = await import(`./dialogs/${dialogType}.js`);
module.show();
} catch (err) {
console.error('Failed to load dialog:', err);
}
}
// 结合 React.lazy 实现组件级代码分割
const AdminPanel = React.lazy(() => import('./admin/AdminPanel'));
function App() {
return (
<React.Suspense fallback={<Loading />}>
<AdminPanel />
</React.Suspense>
);
}
动态导入配合 Webpack 或 Vite 等打包工具,可以实现路由级代码分割,让用户只下载当前页面需要的代码,大幅减少首屏加载时间。根据 Google Web Vitals 的数据,合理的代码分割可以将首屏 JavaScript 体积减少 40%-60%。
Tree Shaking 原理
Tree Shaking(树摇)是 ES Module 带来的最重要工程化收益之一。其核心原理基于 ES Module 的静态结构:因为 import 和 export 必须在模块顶层,依赖关系在编译时就完全确定,打包工具可以安全地”摇掉”那些没有被引入的导出。
// 假设 jquery 中有几十个工具函数,我们只用了两个
import { throttle, debounce } from 'lodash-es';
// 如果使用 CommonJS 的 lodash,需要加载整个库
const _ = require('lodash');
_.throttle(fn, 1000);
在 lodash-es 的 ESM 版本中,像 throttle 和 debounce 各自是一个独立命名的导出模块,打包工具会沿着 import 链追踪,只保留被引用的部分。而 CommonJS 的 require('lodash') 由于无法在编译时确定具体访问了哪些属性,打包工具只能将整个 lodash 包含进来。这也是为什么现代库(如 antd、Element Plus)都提供了 ESM 版本。
第六阶段:Node.js 对 ES Module 的支持
双模块系统的融合
Node.js 从 v12 开始逐步支持 ES Module,但 CommonJS 的遗产太深,无法一刀切切换。Node.js 采用了一种渐进兼容的策略:
.mjs扩展名:强制以 ES Module 解析.cjs扩展名:强制以 CommonJS 解析.js扩展名:取决于最近package.json的"type": "module"字段"type": "commonjs"(默认):以 CommonJS 解析.js文件
{
"name": "my-app",
"version": "1.0.0",
"type": "module", // 项目使用 ES Module
"exports": {
".": "./src/index.js",
"./utils": "./src/utils/index.js"
}
}
exports 字段比传统的 main 字段更强大,它不仅支持子路径导出映射,还能根据不同的环境(Node.js vs 浏览器)提供不同的入口文件:
{
"exports": {
".": {
"import": "./dist/index.esm.js", // 打包工具和 Node ESM 使用
"require": "./dist/index.cjs.js" // CommonJS 使用
}
}
}
require 与 import 的交互相应
在实际项目中,经常需要混用 CommonJS 和 ES Module。Node.js 提供了一些互操作规则:
- ES Module 可以
importCommonJS 模块:Node.js 会将 CommonJS 的module.exports作为默认导入暴露出来 - CommonJS 不能
require()ES Module:因为 ES Module 支持异步加载,而require()是同步的。必须使用import()动态导入 - 在 ES Module 中无法使用
__dirname、__filename、require等 CJS 特有变量:需要使用import.meta.url+fileURLToPath替代
// 在 ESM 中模拟 __dirname
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
console.log(__dirname); // /path/to/current/dir
模块系统的未来趋势
回顾 JavaScript 模块化的演进史——从 IIFE 模式的手动命名空间,到 CommonJS 的同步推进,再到 AMD 的异步探索,最终汇聚到 ES Module 这个官方标准——我们可以看到一条清晰的技术演进脉络:从临时方案走向语言原生支持,从运行时约定走向编译时确定。
展望未来,以下几个趋势值得关注:
- Import Assertions / Import Attributes:TC39 正在推进的提案,允许在
import时声明模块类型,如import json from './data.json' assert { type: 'json' },这将进一步统一资源加载方式。 - Bundleless 开发:Vite 和 Turbopack 等新一代工具在开发模式下直接利用浏览器原生 ESM,跳过打包步骤,实现了极速热更新(HMR),彻底改变了前端开发体验。
- Isolated Declarations:TypeScript 5.0+ 的
--isolatedDeclarations选项让类型声明生成更加模块化,进一步提升了大型项目的编译性能。 - ES Module 在边缘计算中的普及:Cloudflare Workers、Deno Deploy 等边缘运行时原生支持 ES Module,让模块化从浏览器、服务端延伸到边缘计算层。
对于今天的开发者而言,建议优先使用 ES Module 编写新项目,逐步迁移遗留的 CommonJS 代码。理解这几种模块方案的差异和设计背后的权衡,不仅能帮助你写出更健壮的代码,也能在遇到诡异的问题时快速定位根源——无论是打包后的 require is not defined,还是 ESM 中的 __dirname 报错,你都能从容应对。
汤不热吧