
js模块化历史
JavaScript 最初只是给网页加一点交互的脚本语言,根本没有“模块”的概念。但当项目从几十行膨胀到几万行,命名冲突、全局污染、依赖顺序混乱、代码无法复用这些问题集中爆发,模块化才被一步步逼了出来。
本文按 为什么出现 → 具体怎么写 → 解决了什么问题 的顺序,把 JavaScript 模块化的演进讲清楚,并在最后整理成面试考点。
一、没有模块化时的三个痛点
早期网页通过 <script> 标签逐个引入脚本:
<script src="a.js"></script>
<script src="b.js"></script>
这种“平铺直叙”的写法有三个致命问题:
- 全局污染与命名冲突:所有顶层变量都挂在
window上,a.js里的var name会被b.js覆盖,且很难排查。 - 依赖顺序脆弱:必须手动保证
a.js先于b.js加载,一旦顺序错位就报undefined,而这种依赖关系没有任何地方记录下来。 - 无法复用与隔离:文件之间只能靠“约定”,无法声明依赖、无法隐藏内部实现、无法单独测试。
这三条正是模块化要解决的根本问题——封装(不污染全局)、依赖管理(显式声明依赖)、复用(可独立引入)。后文所有方案,本质上都是在用不同方式回答这三件事。
二、手工模块化:命名空间与 IIFE
在语言还没有模块语法之前,社区先用 JS 自身的特性“土法炼钢”。
1. 全局函数:最原始的写法
把逻辑拆成一个个全局函数,靠命名前缀避免冲突:
function getUserName() {}
function getUserAge() {}
问题显而易见:依然全在全局作用域里,前缀只是“人为约定”,一旦忘记就会冲突。
2. 命名空间:用一个对象收拢变量
用一个全局对象当“命名空间”,把相关方法挂上去:
var MyApp = MyApp || {};
MyApp.user = {
name: 'tom',
getName: function () {
return this.name;
},
};
减少了全局变量数量,但内部细节仍然完全暴露,MyApp.user.name 可以被任意改写,没有私有性。
3. IIFE:用函数作用域制造“私有空间”
立即执行函数表达式(Immediately Invoked Function Expression)利用函数作用域,把变量关进“小黑屋”,只把需要对外的方法 return 出来:
var counter = (function () {
var _count = 0; // 私有变量,外部访问不到
function _log() {
// 私有方法
console.log(_count);
}
return {
increment: function () {
_count++;
_log();
},
};
})();
counter.increment(); // 1
console.log(counter._count); // undefined —— 私有变量被成功隐藏
4. 揭示模块模式(Revealing Module Pattern)
IIFE 的一种常见变体:先在闭包里定义所有逻辑,最后统一决定“暴露哪些”:
var counter = (function () {
var count = 0;
function increment() {
count++;
}
function getCount() {
return count;
}
// 只暴露这两个,其余保持私有
return { increment, getCount };
})();
小结:IIFE 解决了封装问题(私有作用域 + 显式导出),但依赖管理仍然靠人工——模块之间怎么引用、按什么顺序加载,全靠开发者自己维护,无法自动化。于是规范登场了。
三、社区规范时代(2009—2014)
这一段是模块化的“战国时代”。理解它们的关键,是想清楚两个维度:同步还是异步加载,以及为服务端还是浏览器设计。
CommonJS:为服务端设计的同步方案
2009 年,Kevin Dangoor 发起 ServerJS 项目(后更名为 CommonJS),目标是把 JavaScript 带出浏览器;同年诞生的 Node.js 采用了这套风格。
它选择 同步加载:服务端模块都在本地磁盘,读取极快,同步阻塞可以接受。
// 导入
const fs = require('fs');
require('./app.js');
// 导出
exports.getStoreInfo = function () {};
module.exports = someValue;
关键点:
require是同步的,执行到这一行就立刻把模块整个加载并运行完。- 每个模块首次被
require时执行一次,结果被缓存;之后再require直接返回缓存,不会重复执行。
AMD:为浏览器设计的异步方案
浏览器加载脚本要走网络,同步加载会阻塞页面。2010 年,Kris Zyp 起草了 AMD(Asynchronous Module Definition)规范,James Burke 的 RequireJS 是它的最著名的实现。
AMD 的两个关键词是 异步加载 和 依赖前置:依赖必须在最前面声明好,等所有依赖加载完成,再执行回调。
// 定义
define(['./a', './b'], function (a, b) {
// 依赖必须一开始就写好
a.doSomething();
b.doSomething();
});
// 加载模块
require(['module', '../app'], function (module, app) {
// ...
});
优点是可以并行加载、不阻塞;缺点是依赖必须提前列全,哪怕某个依赖只在函数内部偶尔用到,也得先声明。
CMD:依赖就近的异步方案
CMD(Common Module Definition)由玉伯(阿里)主导的 Sea.js 推广,同样是异步加载,但主张 依赖就近——用到哪个再 require 哪个,写法上更接近 CommonJS:
define(function (require, exports, module) {
var a = require('./a');
a.doSomething();
// 依赖就近书写,什么时候用到什么时候引入
var b = require('./b');
b.doSomething();
});
AMD vs CMD 一句话区别:AMD 是依赖前置(先声明、后使用),CMD 是依赖就近(用到才声明)。二者都是异步加载,只是“依赖写在哪里”的哲学不同。
UMD:一套代码兼容所有环境
UMD(Universal Module Definition)不是新规范,而是一个兼容层:让同一份代码在 CommonJS、AMD 和全局变量三种环境下都能用。
一种常见的判断顺序是:先看是否支持 Node.js(判断 exports 是否存在)→ 再看是否支持 AMD(判断 define 是否存在)→ 都不支持就挂到全局。(umdjs 官方的 returnExports 模板则是先判 AMD 再判 CommonJS,webpack 产物与之相反——顺序并不唯一。)
(function (window, factory) {
if (typeof exports === 'object') {
// Node.js / CommonJS
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
// AMD
define(factory);
} else {
// 浏览器全局
window.eventUtil = factory();
}
})(this, function () {
// module ...
});
打包器与转译器:让模块跑进浏览器
社区规范解决的是“模块怎么写”,但浏览器原生既不认识 require 也不认识 define——真正让这些写法跑进浏览器并延续至今的,是同一时期崛起的工程化工具:
- Browserify(2011):让浏览器项目直接复用 CommonJS 写法,构建时递归解析
require,把整棵依赖树打成一个文件。 - webpack(2012):更进一步,JS、CSS、图片统统视为模块,配合 loader 处理任意资源,成为此后十年前端工程化的基石。
- Babel(2014,前身 6to5):把浏览器尚未支持的
import/export转译回 CommonJS/AMD,让 ESM 语法在标准落地前就提前可用。
“打包 + 转译”这条路线是 ESM 普及背后的真正推手——后文的 tree shaking、代码分割都发生在打包器里,第五节要澄清的“import/export 编译成 require/exports”的说法也源于此。
小结:为什么会冒出这么多规范?
- 加载时机不同:服务端能同步(CommonJS),浏览器必须异步(AMD / CMD)。
- 依赖声明哲学不同:AMD 依赖前置,CMD 依赖就近。
- 运行环境不同:UMD 负责抹平差异。
但它们有一个共同的硬伤:都是运行时加载——依赖关系只有在代码跑起来之后才能确定。打包器虽然能分析 require('./a.js') 这类字面量调用,但 require 只是普通函数,路径一拼变量,静态分析就断了。这为 ES Module 的登场埋下了伏笔。
四、语言标准时代:ES Module(ES2015)
2015 年发布的 ES6(ECMAScript 2015)在语言标准层面引入了模块,官方名称为 ES Module(ESM),浏览器和服务器从此有了统一的标准答案。不过标准从定稿到真正可用花了数年:浏览器 2017 年起才陆续原生支持(Chrome 61 / Safari 11),Node.js 更是到 2019 年(v13.2)才无需 flag 加载 ESM——这段空窗期里,撑起 ESM 普及的正是上一节的 Babel 转译 + webpack 打包。
ESM 的设计思想是 尽量静态化:让编译时就能确定模块的依赖关系,以及输入和输出的变量。而 CommonJS 和 AMD 只能在运行时确定这些东西。
一个容易误解的点:常说的“编译时加载”是相对说法。严格来讲 ESM 也要在运行时求值,真正的区别在于——依赖关系的解析(linking)发生在代码执行之前,所以工具能在不运行代码的前提下画出完整的依赖图。这正是“静态”二字的含义,也是后面 tree shaking 能成立的根本原因。
1. 静态 import
import { lastName } from './profile.js';
// 或重命名
import { lastName as surname } from './profile.js';
import { getArea, getRadius } from './circle';
// 或整体引入(命名空间)
import * as circle from './circle';
circle.getArea();
因为 import 在编译阶段处理,所以它会有**提升(hoisting)**效果,被提到模块顶部最先执行:
// main.js
import moduleA from './moduleA.js';
console.log(1);
import moduleB from './moduleB.js';
console.log(2);
// moduleA.js
console.log(3);
export default 'A';
// moduleB.js
console.log(4);
export default 'B';
// 控制台输出顺序:3 4 1 2
注意:
import的路径不能是表达式或变量,也不能写在if、函数等块级作用域里。因为它必须在编译时静态解析——这是“静态化”付出的代价,也是它换来优化能力的原因。
import { foo } from './f' + 'oo.js'; // ✗ 报错:路径必须是字符串字面量
if (x === 1) {
import { foo } from './a.js'; // ✗ 报错:import 只能在顶层
}
2. export
// 默认导出:一个模块只能有一个
export default function crc32() {
// ...
}
import crc32 from './crc32.js';
// 具名导出:可以有多个
export function crc32() {
// ...
}
import { crc32 } from './crc32.js';
3. 动态 import()
ES2020 引入了 import() 函数,支持运行时动态加载,返回一个 Promise:
const main = document.querySelector('main');
import(`./section-modules/${someVariable}.js`)
.then((module) => {
module.loadPageInto(main);
})
.catch((err) => {
main.textContent = err.message;
});
import()可以用在任何地方,包括非模块脚本,也能拼动态路径(这正是静态import做不到的)。- 它类似 Node 的
require,区别在于前者异步、后者同步。 - 典型场景:路由懒加载、按需加载大体积模块(如 ECharts)、条件加载 polyfill。
4. 在浏览器中使用:<script type="module">
<script type="module" src="./main.js"></script>
和普通 <script> 相比,它有几个默认行为:
| 行为 | 普通 script | type="module" |
|---|---|---|
| 执行时机 | 立即执行 | 默认 defer,等 HTML 解析完再执行 |
| 严格模式 | 需手动声明 | 自动严格模式 |
| 作用域 | 全局 | 模块作用域,变量不外泄到全局 |
| 顶层 this | window |
undefined |
| 加载协议 | 可用 file:// |
需通过 http(s),否则触发 CORS 报错 |
5. 在 Node.js 中使用 ESM
Node.js 默认把 .js 当 CommonJS(Node 22.7 起语法检测默认开启:没有 "type" 字段的 .js 会先按 CommonJS 解析,失败后自动按 ESM 重试)。要明确启用 ESM,用 .mjs 后缀,或在 package.json 里声明 "type": "module":
{
"type": "module"
}
需要注意,ESM 里没有 require、__dirname、__filename:
// ESM 中的替代写法
import { createRequire } from 'node:module';
console.log(import.meta.url); // 当前模块的 file: URL
console.log(import.meta.dirname); // 等价于 __dirname(Node 20.11+ / 21.2+)
console.log(import.meta.filename); // 等价于 __filename
const require = createRequire(import.meta.url); // 需要时手动构造 require
反过来,CommonJS 能不能加载 ESM? 从 Node.js 22.12 / 23.0 起,require() 已可默认加载同步的 ESM(无需 --experimental-require-module 标志):
// main.cjs
const math = require('./math.mjs');
// 返回模块命名空间对象:{ __esModule: true, add: [Function], default: [Function] }
但有一条硬性限制:被加载的 ESM 不能包含顶层 await,否则抛出 ERR_REQUIRE_ASYNC_MODULE,此时只能改用 import()。
6. import.meta 与顶层 await
import.meta(ES2020):只在模块内可用,import.meta.url是当前模块的 URL,Node 还提供import.meta.dirname/import.meta.filename。- 顶层
await(ES2022):模块顶层可以直接await,相当于把整个模块当成一个大 async 函数:
const colors = await fetch('colors.json').then((r) => r.json());
export { colors };
五、ES Module 与 CommonJS 的核心差异
这是面试最爱问的部分,抓住三个维度就够了。
先澄清一个说法:很多文章说“import/export 最终都会编译成 require/exports 执行”。这只在 Babel/Webpack 把 ESM 转译为 CJS 的场景下成立。像 Vite 开发环境、Rollup 输出 ESM 时,代码是原生的 ESM,并不转成 require。所以这句话不能当成普适结论。
1. 输出类型:值的拷贝 vs 值的引用
CommonJS:值的拷贝
require 得到的是模块导出值的拷贝副本。基本类型是值复制;引用类型复制的是引用值——两边指向同一个对象,改属性会互相影响,但模块内部之后的重新赋值不会反映到导出上。
// counter.js
let count = 0;
function add() {
count++;
}
module.exports = { count, add };
// main.js
const { count, add } = require('./counter.js');
console.log('before:', count); // 0
add();
console.log('after :', count); // 0 —— 仍是 0!count 只是拷贝
ES Module:值的引用
import 得到的是只读引用(live binding),指向导出模块里的真实变量。导出方改变量,导入方拿到的值也会跟着变。
// counter.mjs
export let count = 0;
export function add() {
count++;
}
// main.mjs
import { count, add } from './counter.mjs';
console.log('before:', count); // 0
add();
console.log('after :', count); // 1 —— 跟着变了!
注意:引用是只读的,不能对导入变量重新赋值(
count = 5会报语法错误),但可以修改引用对象的属性。上面两组输出是实际在 Node 里跑出来的,与阮一峰《ES6 标准入门》的描述一致。
2. 执行时机:运行时加载 vs 编译时输出接口
CommonJS:运行时加载
CommonJS 模块本身就是一个对象。require 是先把整个模块加载、生成一个对象,再从对象上读属性——所以只有运行时才知道有哪些方法,无法做静态优化。
// 等价于下面两步
let { stat, exists } = require('fs');
// ↓
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
ES Module:编译时输出接口
ESM 不是对象,而是通过 export 显式指定输出、import 静态引入。编译时就能确定“用到了哪些接口”,因此可以按需引入、做静态分析和优化。
3. 循环加载时的表现
CommonJS:输出已执行的部分
因为 CommonJS 是运行时加载、执行到 require 才运行代码,一旦出现循环加载,只输出已经执行的部分,未执行的部分不会输出。
// a.js
exports.done = false;
let b = require('./b.js');
console.log('a.js-1', b.done);
exports.done = true;
console.log('a.js-2', '执行完毕');
// b.js
exports.done = false;
let a = require('./a.js');
console.log('b.js-1', a.done);
exports.done = true;
console.log('b.js-2', '执行完毕');
// c.js
let a = require('./a.js');
let b = require('./b.js');
console.log('c.js-1', '执行完毕', a.done, b.done);
运行 node c.js,实际输出:
b.js-1 false // b 拿到的是 a 尚未执行完的半成品,done 还是 false
b.js-2 执行完毕
a.js-1 true // 回到 a,此时 b 已执行完,done 为 true
a.js-2 执行完毕
c.js-1 执行完毕 true true
ES Module:引用在前,取值在后
ESM 的 import 建立的是引用,不会缓存某个瞬时值。循环加载时,模块图会先完成“链接”,再执行代码,能不能取到值取决于取值那一刻变量是否已经初始化——需要开发者自己保证。
一个必须注意的坑:如果循环引用的模块里用了 let / const,在初始化之前取值会直接抛错(暂时性死区 TDZ),不是 undefined:
// a.mjs
export let a1 = 1;
import { b1, b2 } from './b.mjs';
console.log(b1, b2, 'a.js');
export let a2 = 11;
// b.mjs
export let b1 = 2;
import { a1, a2 } from './a.mjs';
console.log(a1, a2, 'b.js'); // ✗ ReferenceError: Cannot access 'a1' before initialization
export let b2 = 22;
把 let 换成 var(变量提升、初始值为 undefined),才会看到很多文章里说的 undefined undefined:
undefined undefined b.js
2 22 a.js
网上不少文章直接写“ESM 循环引用时打印 undefined”,其实是漏掉了
let会抛 TDZ 错误这一层。这两组输出都是实际运行验证过的,建议自己跑一遍加深印象。
六、模块化最终带来了什么
- 静态分析能力:ESM 的
import/export是静态的,打包工具能在不执行代码的情况下画出完整依赖图。 - Tree shaking:正因如此,只有 ESM 才能做 tree shaking——把没被引用的导出摇掉。CommonJS 的
require是动态函数调用,工具无法确定用到了哪些属性,基本无法优化。 - 单例与缓存:两种规范都保证同一模块只执行一次,后续引用拿到缓存结果。
- 更完整的工程化:静态依赖关系让类型检查、IDE 跳转、按需加载、代码分割都成为可能。
- 现代默认选择:新项目基本默认 ESM,CommonJS 主要用于兼容存量 Node 生态。
一句话记住整条脉络:IIFE 解决封装,CommonJS / AMD / CMD / UMD 解决跨环境的依赖管理,ES Module 用静态化一次性解决了封装、依赖管理和编译期优化,成为浏览器与服务器通用的最终答案。
一颗孤星上的邮局,用电波把信寄向深空。
—— 孤星邮局 ——