JavaScript模块化演进史:从全局作用域到ESM的工程化之路 我大概是从 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 又拿不到。UMDUniversal 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 年前后有 Browserify2013 年前后有 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 ACommonJS 的模块缓存机制在循环依赖下会导致一方拿到一个不完整的 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 不能省略 .jsNode 也沿用了这个规则。很多人从 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 从一个无论多大的项目都只能靠约定维持秩序的语言变成了一个可以承载大型工程的平台。如果你现在正在一个乱糟糟的老项目里挣扎别急着推倒重来先把模块边界理清楚这比什么魔法都管用。