☰
JavaScript模块化演进史:从全局作用域到ESM的工程化之路
2026/10/9 23:50:51 网站建设 项目流程

我大概是从 2012 年前后开始正儿八经写 JavaScript 的,那会儿项目里最经典的结构就是 script 标签按顺序铺一长条,谁依赖谁全凭约定。改一个全局变量,可能把一个页面改崩;删一个"看起来没用"的函数,可能第二天线上就出故障。后来我从 IIFE 一路用到 CommonJS、AMD、UMD,再看着 webpack 崛起,最后等到 ESM 成为语言标准——这十几年的时间里,JavaScript 模块化的演进史,本质上就是前端工程化从"靠自觉"走向"靠机制"的完整缩影。

这篇文章我想把这条演进路线完整梳理一遍:每个阶段到底解决什么问题、为什么被替代、关键设计背后的动机是什么,以及今天在项目里用 ESM 时有哪些值得注意的实操细节。不管你是刚接触模块化不久的新手,还是已经用 Vite 建过不少项目的老手,这篇文章都能帮你把"模块化"这三个字从会用到理解。

1. 模块化之前的日子:全局作用域与依赖顺序的双重失控

1.1 全局污染:改一个变量能把整个页面改崩

大概十年前我接过一个遗留项目,打开 HTML 是这么一副光景:七个 script 标签按某种"只有前任才懂的玄学顺序"排列在那里,没有注释解释为什么要这么排。我小心地加了一个工具函数,起名叫 init,结果页面直接白屏。查了半天发现,第三方统计脚本里也有一个全局的 init,后加载的覆盖了先加载的,初始化逻辑全乱了。

这就是"没有模块化"最直观的代价:所有 script 共享同一个全局作用域,用 var 声明的变量会挂到 window 上,谁后加载谁就拥有最终解释权。项目越大,变量名冲突的概率越高,而排查这种问题的成本又高得离谱——因为出错现场离"污染发生的地方"可能隔了十万八千里。

早期大家的应对方式也很朴素:约定命名前缀。jQuery 用 $,underscore 用,我的项目就用 myapp开头。这方案在中小型项目里勉强能用,但本质上是在赌团队纪律,赌每个人都记得查一遍全世界有没有重名。等你的应用到了几十个文件以后,这种"君子协定"必然撑不住。

1.2 依赖顺序:加载顺序错了,运行时报错伺候

全局污染之外,第二个痛点是依赖顺序。A 脚本要用 B 脚本里定义的方法,那 B 就必须排在 A 前面。早期根本没有"声明依赖"这种机制,全靠人工维护 script 标签的排列顺序。

我印象特别深的一个经历:项目里引了一个日期插件,文档要求必须在 jQuery 之后加载。某次改版的时候,有个同事图省事把两个标签调了个位置,结果本地跑得好好的,上线后一部分用户报错。为什么?本地浏览器缓存了旧版本,线上用户拿到的是新顺序,jQuery 还没加载完,插件就执行了,直接在 undefined 上调用方法。

这类问题用一句话总结就是:模块之间的依赖关系只存在于人的脑子里,而不存在于代码里。它不像 Java 或 C# 那样有 import 语句去明确声明"我需要谁",JavaScript 只能靠加载顺序去"猜"。一旦模块数量超过一个人能记住的范围,这种靠脑力的方案就会成为事故高发区。

1.3 模块化要做的核心其实就两件事

后来我回头看这段历史,发现所有模块化方案——不管名字多花哨——都是在解决两件事:

第一是封装。把代码放进独立作用域,内部变量外部看不见、改不到,只暴露必要的接口。第二是依赖管理。用声明式语法告诉环境"这个模块依赖谁、谁依赖这个模块",再按依赖关系决定加载顺序和执行时机。

只要这两件事没做好,工程体量一大,代码就会烂成一锅粥。理解了这一点,后面所有的演进就都能串成一条线了:每一种新方案都是在这两件事上,比前一种做得更彻底、更自动、更标准。而这个标准化的过程,从无到有走了整整十几年。

2. IIFE:闭包给了 JavaScript 第一个"模块"

2.1 IIFE 为什么能火这么多年

IIFE 全称 Immediately Invoked Function Expression,立即执行函数表达式。写法很简单:

(function () { var privateVar = '只有内部能访问'; function privateMethod() { console.log('我是私有方法'); } window.myModule = { publicMethod: function () { console.log(privateVar); privateMethod(); } }; })();

把代码包进一个函数并立即执行,函数作用域就成了天然的隔离边界。函数内部的 var 变量不会被泄漏到全局,而通过 return 或者挂 window 对象暴露出去的方法,则成了这个"模块"的公共接口。

很多人第一次接触这个概念会觉得平平无奇,但放在当年那个环境里,这已经是能想到的最优雅方案了。你仔细品一下,这个写法已经具备了模块化的两大核心要素:内部状态封装在闭包里,外部只能通过暴露的接口去操作;依赖关系确实还没有办法声明,但至少"变量污染"这个最致命的问题解决了。闭包带来的私有变量能力,至今仍然是 JavaScript 语言里最被低估的特性之一。

2.2 模块模式与命名空间:jQuery 时代的黄金组合

IIFE 再搭配"命名空间"思想,就形成了当年最主流的大型项目组织方式。命名空间的做法是在全局只挂一个对象,所有模块挂在它下面,从根上把全局变量的数量压到最少:

var MyApp = window.MyApp || {}; (function (namespace) { var config = { theme: 'dark' }; namespace.getConfig = function () { return config; }; })(MyApp); (function (namespace) { var users = []; namespace.addUser = function (user) { users.push(user); namespace.render(); }; namespace.render = function () { // 渲染用户列表 }; })(MyApp);

这种做法的好处是:即便两个文件都向 MyApp 上挂属性,只要属性名不冲突,就不会互相覆盖;而且模块之间可以通过 MyApp 这个中介互相调用,不再直接碰全局。jQuery 以及它的一众插件生态,基本都是这个套路。

后来还演化出 Revealing Module Pattern(揭示模块模式),把私有实现和公开接口分得非常清楚:内部定义全部函数,最后统一 return 一个白名单对象。阅读体验比边定义边暴露好得多,我在老项目里做重构时经常顺手把散落的 IIFE 改成这种结构,代码瞬间清爽不少。

2.3 但 IIFE 始终有两道过不去的坎

IIFE 方案最大的问题不是代码风格,而是两个结构性的缺陷。

首先是依赖顺序依然靠人肉维护。IIFE 本身没有声明依赖的能力,A 模块需要 B 模块,还是得保证 B 的 script 标签先加载。上一个例子里的 MyApp 依赖问题,本质上只是从"全局变量冲突"转移成了"全局命名空间上的属性顺序"。

其次是无法按需加载。所有代码都写进 HTML 的 script 标签里,页面一打开就要全部下载、全部执行。在 jQuery 时代大家还能忍受,等到了 AngularJS、Backbone 陆续出现的单页应用时代,一个应用动不动几百个模块,全量加载的白屏时间根本扛不住。

这两道坎,直接催生了后面那些真正意义上的模块规范。IIFE 的价值在于它确立了"函数作用域即模块边界"这个基本思想,这个思想后来一直延续到了所有方案里,包括 ESM 内部的模块封装逻辑——ESM 的模块作用域本质上就是函数作用域在语言层面的正式化。

3. CommonJS、AMD、UMD:三套方案与一条岔路

3.1 Node.js 的同步 require,是服务端环境的合理答案

2009 年 Node.js 诞生,JavaScript 第一次大规模跑到服务端。服务端遇到模块化问题的姿势跟浏览器完全不一样:文件都在本地磁盘上,require 一个模块就是一次同步读取,快得很。所以 CommonJS 规范选择了一种非常直白的模型:

// math.js const multiplier = 2; function double(n) { return n * multiplier; } module.exports = { double };
// main.js const math = require('./math'); console.log(math.double(21)); // 42

这个模型有几个关键点:一个文件就是一个模块;通过 module.exports 对外暴露;通过 require 同步加载,加载之后立刻拿到模块对象。代码写起来就是"平铺直叙"的,不需要任何包裹函数,阅读体验比 IIFE 好了一大截。

CommonJS 的设计在服务端是合理的,因为本地磁盘 I/O 的速度足够快到可以忽略同步阻塞。而这个合理性,恰恰成了它进不了浏览器的死穴。

3.2 为什么浏览器里跑不了 CommonJS

浏览器如果要跑 require,意味着在运行时去发 HTTP 请求拿模块文件。拿 CommonJS 的同步模型来说,require 一个模块就得同步等待一个网络请求完成,这期间整个页面都卡住。一个几十模块的应用,就得几十个串行请求,用户看到的就是一个加载半天、期间点哪儿都没反应的白屏页面。

有人会说,那异步 require 不就行了?可以,但那就不再是 CommonJS 的模型了。CommonJS 的哲学是"require 完立刻能用",这个语义在浏览器里必须配合"整个模块树已经全部下载好"这个前提才成立。浏览器恰恰做不到这一点,所以必须换一套思路。这个矛盾也说明了一个道理:没有放之四海皆准的方案,模块规范必须跟运行环境的能力匹配。

3.3 AMD:把"异步"写进规范的浏览器方案

AMD,全称 Asynchronous Module Definition,异步模块定义,它的代表作是 RequireJS。AMD 的思路是把模块定义变成一个带依赖声明的函数:

define(['jquery', './utils'], function ($, utils) { var privateState = {}; function fetchData() { return $.ajax('/api/data'); } return { load: function () { var data = utils.format(fetchData()); // ... } }; });

define 的第一个参数是依赖数组,AMD 加载器会先按这个数组去异步加载所有依赖,全部就绪之后再执行回调函数,同时把依赖实例作为参数传进来。这样依赖顺序就从"人工保证"变成了"加载器保证",封装和依赖管理这两件事,算是第一次在浏览器端同时解决了。

国内同期还有 Sea.js 推动的 CMD 规范,核心区别在于 CMD 推崇"就近依赖",在用到某个依赖的代码处才声明 require,加载时机是延迟的。但大方向一致,都是"异步加载 + 依赖声明"。后来 RequireJS 在竞争中逐渐占优,CMD 慢慢淡出,这段历史现在提的人不多了。我自己当年两个加载器都配过,印象里 Sea.js 的写法对新手更友好,但 RequireJS 生态更齐全,这可能也是它胜出的关键。

3.4 UMD:两头下注的兼容补丁

AMD 解决了浏览器,CommonJS 解决了 Node,那一个库应该按哪个标准写?如果只按 CommonJS 写,浏览器里直接 script 引入就用不了;只按 AMD 写,Node 里 require 又拿不到。UMD,Universal Module Definition,就是那个时代的"兼容补丁"。

UMD 不是一个真正的规范,而是一个模板,它做的事情是运行时判断当前环境支持哪种模块系统,然后投其所好:

(function (root, factory) { if (typeof module === 'object' && typeof module.exports === 'object') { // Node 环境:走 CommonJS module.exports = factory(require('jquery')); } else if (typeof define === 'function' && define.amd) { // 浏览器环境:走 AMD define(['jquery'], factory); } else { // 兜底:挂全局变量 root.MyLib = factory(root.jQuery); } })(typeof self !== 'undefined' ? self : this, function ($) { // 真正的模块实现 function greet(name) { return 'Hello, ' + name + '!'; } return { greet: greet }; });

我在写开源小工具的时候用过不少次这个模板。它的价值在于让一个库可以在任何环境下被引用,但也暴露了那个时代的一个尴尬:模块化本应是语言层面的能力,结果要靠社区规范加运行时 hack 来实现。这段"三套方案并存"的混乱状态,一直持续到打包器的出现。

4. 打包器时代:webpack 把模块规范统一到编译期

4.1 关键转折:不让浏览器运行时去解决模块问题

2011 年前后有 Browserify,2013 年前后有 webpack 1.x。这些工具的出现代表着一个思路上的根本转变:与其在浏览器运行时里模拟模块加载,不如在开发时把模块代码编译成一份或者几份普通脚本,让浏览器根本不感知模块的存在。

这个转变有多关键?之前 AMD 的思路是"在运行时做加载调度",这要求浏览器端有一套加载器在跑,还要处理网络请求的时序问题。而打包器的思路是"把开发时的模块世界翻译成运行时的普通脚本",把复杂度从运行时挪到了构建期。开发者平时用 CommonJS 或者 ES Module 写代码,构建之后拿到的是打包产物,浏览器只负责加载产物,不用理解什么模块。

这个思路后来被证明是决定性的。它让工程上的复杂度有了一个统一的"消化场所"——构建器,此后几乎所有前端工程化能力都在这里生长出来,而浏览器端保持简单和稳定。

4.2 打包器到底做了什么:一张模块表加一个迷你加载器

webpack 的核心机制其实不复杂。它把每个模块的内容包进一个函数,然后把这些函数放进一个对象里,key 是模块 id;再注入一段很小的运行时加载器代码,用 id 去查找模块函数并执行。示意如下:

(function (modules) { var cache = {}; function require(moduleId) { if (cache[moduleId]) { return cache[moduleId].exports; } var module = { exports: {} }; modules[moduleId](module, module.exports, require); cache[moduleId] = module.exports; return module.exports; } require(0); // 入口模块 })({ 0: function (module, exports, require) { // 入口文件代码,内部可以继续 require 其他模块 var helper = require(1); console.log(helper.add(1, 2)); }, 1: function (module, exports, require) { exports.add = function (a, b) { return a + b; }; } });

打包之后的产物是一个独立作用域的 IIFE,所有模块的变量都封装在模块函数里,不会泄漏到全局。我们前面说的模块化两大核心——封装和依赖管理——就这样被拍平到了构建期:代码里写的 require 被翻译成了运行时加载器的调用,模块之间的依赖图在构建时就静态确定了。

这也是为什么 webpack 能支持 CommonJS、AMD、ESM 多种写法共存的底层原因:它把这些写法全部翻译成同一种内部模块格式,开发时你爱用什么语法就什么语法,构建后统统变得兼容。这种"内部统一、外部兼容"的设计哲学,是 webpack 能成为那个时代事实标准的关键。

4.3 从打包整体到拆包优化:模块化开始反哺性能

有了模块化规范加上构建期分析,前端性能优化也上了一个台阶。以前只能用"去掉没用的文件"这种粗粒度手段,现在可以做代码分割。比如路由懒加载,把每个路由的页面代码单独打成一个 chunk,用户访问到哪个路由才加载哪个 chunk,首屏体积能降一大截。

webpack 里实现动态 import 是借助一个魔法注释和 Promise 机制:

// 路由懒加载 const routes = [ { path: '/dashboard', component: () => import(/* webpackChunkName: 'dashboard' */ './views/Dashboard.vue') } ];

这种能力放在 IIFE 时代是不可想象的,因为你根本定义不出"一堆代码、按需执行"这种模块视图。模块化的价值在这里已经从"代码组织"扩展到了"资源加载策略",这是很多人容易忽略的一点。回过头看,没有模块化做基础,今天的前端性能优化体系根本长不出来。

4.4 我配置 webpack 时踩过的模块相关坑

webpack 用久了,总有几个模块相关的报错让人印象深刻。

第一个是画蛇添足地在浏览器代码里用 process.env。很多人从 Node 的习惯里带过来,直接在源码里写 process.env.NODE_ENV,浏览器里根本没有 process 这个全局对象。webpack 的 DefinePlugin 或者 EnvironmentPlugin 会把 process.env.NODE_ENV 替换成字符串,但如果你用其它 process 属性,就得自己 polyfill 或者干脆别用。

第二个是循环依赖。A require B、B require A,CommonJS 的模块缓存机制在循环依赖下会导致一方拿到一个不完整的 exports 对象。我的经验是:能拆就拆,把公共依赖提取成单独的 C 模块;实在拆不了,在被引用方里把对对方的访问放到函数体内延迟执行,别在模块顶层直接调用。

第三个是不要用相对路径去引用跨越多个层级的模块,敲 ../../../../ 串很容易拼错。配一个别名,比如把 src 映射成 @,引用就变成 @/components/Modal.vue,清晰不少,这也是 webpack resolve.alias 最常见的用途之一。

5. ESM:语言标准亲自下场,终局方案长什么样

5.1 export/import 语法:从"机制各异"到"标准统一"

2015 年 ES6 正式发布了 import/export 语法,JavaScript 语言层面第一次有了模块的概念。写法很直观:

// utils.js export const VERSION = '1.0.0'; export function formatDate(date) { return date.toISOString().split('T')[0]; } export default class Logger { log(message) { console.log(message); } }
// main.js import Logger, { VERSION, formatDate } from './utils.js'; const logger = new Logger(); logger.log(`${VERSION} - ${formatDate(new Date())}`);

这个语法看起来只是换了个关键字,但背后有一个质的变化:import 和 export 是语言规范,任何环境——浏览器、Node、打包器——都必须按同一套语义来解析。当年 CommonJS 和 AMD 二选一的痛苦,从此没有了。

5.2 静态结构为什么是 ESM 的灵魂

ESM 最核心的设计,是它刻意让 import/export 变成了"静态结构"。什么意思?import 语句必须写在模块顶层,加载的路径必须是字符串字面量,不能写 import(path) 带变量;export 的名字也是编译时就能确定的。这些限制看似不灵活,但它换来了一个巨大的好处:解析工具和打包器不需要执行代码,就能完整分析出模块的依赖图和导出的符号。

CommonJS 做不到这一点。因为 require 可以写在任意位置,module.exports 可以被运行时任意赋值,构建工具要想知道一个 CJS 模块到底导出了什么,唯一的办法是把代码跑一遍。而 ESM 光靠静态扫描就够了。

这个静态性直接成就了 tree shaking。拿 webpack 举例,它分析出 main.js 只用了 utils.js 的 formatDate 和 VERSION,那 utils.js 里没被用到的其他导出函数,在构建阶段就会被安全地标记为"死代码",最终从产物里删掉。我在一个老项目里把入口从 CommonJS 切到 ESM 写法后,主包体积肉眼可见地小了十几 KB,就是因为去掉了一批被引用但只用到其中一两个方法的模块的冗余代码。

5.3 ESM 与 CommonJS 的差异对照表

经常有人问我 ESM 和 CommonJS 到底差在哪儿,我一般用一张表说清楚:

对比维度CommonJSESM
语法require / module.exportsimport / export
加载时机运行时同步加载编译期静态解析,运行时按需加载
导出方式动态赋值,运行时才知道导出什么静态声明,编译时就可确定
顶层 thisthis 指向模块的 exportsthis 是 undefined
循环依赖可能拿到不完整的导出对象有实时绑定(live binding),配合规范定义更安全
顶层 await不支持支持(模块顶层可以直接 await)
作用域模块作用域同样有模块作用域,但默认走严格模式

这里面的"实时绑定"值得多说一句。ESM 的 import 绑定指向的是原模块内部变量的引用,而不是值的拷贝。也就是说,被导入模块内部后续改了某个变量的值,导入方也能看到变化。这在处理循环依赖时比 CommonJS 的"快照式缓存"表现更好,但代价是要求开发者理解"绑定是活的"。我自己在实际开发中遇到循环依赖时依然会优先拆模块,因为"能工作"和"好理解"是两回事。

5.4 在 Node.js 里用 ESM 的实操细节

Node.js 从 12.x 开始逐步放开 ESM 支持,到今天已经完全可用,但实操里还是有几个容易踩的细节我要特意提一下。

第一,文件扩展名和 package.json 的 type 字段。一个 .js 文件在 Node 里到底按 CommonJS 还是 ESM 解析,取决于最近的 package.json 里有没有 "type": "module"。设了就按 ESM 解析,没设默认按 CommonJS。想明确强制某个文件走 ESM,可以把它命名为 .mjs;想强制某个文件走 CommonJS,命名为 .cjs。这是目前最清晰的表达方式。

第二,import 的路径要带完整扩展名。浏览器原生 ESM 要求 import './utils.js' 不能省略 .js,Node 也沿用了这个规则。很多人从 webpack 习惯里带过来,写 import './utils' 就报错找不到模块。这不是 bug,是浏览器和 Node 共同遵守的规范。

第三,__dirname 在 ESM 里不存在了。CommonJS 里的 __dirname 和 __filename 在 ESM 中需要用 import.meta.url 来换算:

import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);

第四,ESM 和 CommonJS 的互操作。Node 里可以用 import 加载 CommonJS 模块,默认拿到的是 module.exports 对应的默认导出;反过来,在 CommonJS 里用 require 加载 ESM 模块,旧版本 Node 是不支持的,会直接报错。Node 20.17 之后才开放了 require(esm) 的能力。所以如果你在维护一个同时给 CJS 和 ESM 用户使用的库,最好用条件导出(exports 字段里的 import 和 require 分支)来分别提供入口,而不是指望运行时自动帮你抹平一切。

6. 回看整条演进线:技术选型背后的规律与我的经验

6.1 演进的本质不是换语法,而是把约束从"人"挪到"机制"

把 IIFE、CommonJS、AMD、webpack、ESM 串起来看,会发现一条非常清晰的规律:每一次演进,都是在把原本要人靠自觉遵守的约束,变成某种机制自动保证的东西。

IIFE 解决了变量污染,但依赖顺序还得人肉排;CommonJS 把依赖声明写进了语法,但只能在同步的服务端环境用;AMD 用异步 define 解决了浏览器,却引入了一套不属于语言本身的运行时加载器;webpack 把复杂度挪到构建期,但工程配置本身又开始变得复杂;直到 ESM 出现,语言标准才真正把"封装 + 依赖管理"这两件事同时收编。

这套规律对今天做技术选型很有参考意义:当你发现某个方案需要靠一堆约定和纪律来维持时,就该警惕了——约定越多,崩溃点越多;机制越自动,系统的鲁棒性越好。

6.2 今天真实项目里应该怎么选模块方案

我自己目前的实践原则是三条。

新项目一律 ESM 优先。前端用 Vite,天然基于 ESM 开发,构建期再打包;Node 服务端也用 ESM,除非项目里有老依赖必须走 CommonJS 才能正常工作。ESM 带来的静态分析和逐步淘汰 CJS 的趋势已经不可逆。

老项目不要为了"潮"而强行迁移。如果项目跑在 webpack 4 上,业务代码全是 CommonJS 风格,硬切 ESM 语法收益有限,还得承担构建配置和第三方依赖的双向兼容风险。渐进式局部改造更稳妥:新写的模块用 ESM 语法,靠 webpack 的兼容能力去消化,等量变积累到一定程度再整体切换。

写开源库要考虑双入口。用 exports 字段分别声明 import 和 require 的入口,让 CJS 用户和 ESM 用户都能拿到适合自己的产物:

{ "name": "my-lib", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } }

这里有个容易被忽略的细节:main 字段是给老工具看的,exports 字段是现代工具看的,两者都要配,否则总有一批用户会拿到不兼容的版本。

6.3 关于模块化,我最想叮嘱新手的三件事

第一,不要只背 API,要理解每种方案的"为什么"。我见过不少简历上写着"熟悉 ESM",但被问到"为什么 ESM 能 tree shaking 而 CommonJS 不行"时答不上来。原因就在前面讲的静态结构上:ESM 的导入导出在编译期就是确定的,CommonJS 的导出是运行时才能确定的动态赋值。理解了"运行时赋值 vs 编译期静态声明"这个区别,很多问题都能自己推导出来。

第二,循环依赖能拆就拆。不管用哪种模块系统,循环依赖本质上都是设计层面的坏味道。它可能"能跑",但会让模块之间的关系变得纠缠不清。可维护性差的项目,往往是循环依赖最多的项目。

第三,别在浏览器里直接裸用 ESM 而完全不打包。原生 ESM 确实可以直接在浏览器跑,但生产环境里如果不加构建,光 HTTP 请求数量就够你受的——每个 import 都是一个请求。开发时用 Vite 的裸 ESM 体验很好,生产时还是要打包压缩。

写到这里,我想起自己第一次在项目里把所有 script 标签删掉、换成 webpack 打包时的那个下午。当时的我并不知道什么静态分析、tree shaking,只是觉得"代码终于能按照我想象的样子组织了"。后来一路从 IIFE 补课到 ESM,回头看才明白:模块化最了不起的地方,不是某一种语法,而是它让 JavaScript 从一个"无论多大的项目都只能靠约定维持秩序"的语言,变成了一个可以承载大型工程的平台。如果你现在正在一个乱糟糟的老项目里挣扎,别急着推倒重来,先把模块边界理清楚,这比什么魔法都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询