在找工作的伙伴,可以看看这里:双越老师联合几位博主(包括我)搞了一个前端面试网站 面试派 ------ 常见面试题 + 大厂面试流程 + 面试技巧。做一个真正专业的前端面试网站,旨在解决前端面试资料碎片化、老旧化、非专业化等一系列问题,网站开源免费且持续更新题库!
前言
早期的 js 语言仅仅是个脚本,代码很简单,实现的功能也不复杂,但是后面却被运用到复杂的项目中,那个时候也不存在 js 模块化一说,那么当时的前端切图仔们是如何在没有 import
的时代硬生生地设计出模块化思想的。本期文章我们来聊聊 js 模块化的发展历程。
另外理解 js 模块化发展我们可以很好理解
esm
和cjs
的区别。
2009 年之前
为什么需要模块化?
模块化实际上就是实现特定功能的一组方法,只要把不同的函数以及用来记录状态的变量放到一起就是一个模块;js 需要模块化主要就是为了解决代码复杂度增加后的可维护性,复用性,依赖管理等问题;这么看模块化就是工程化的地基,有了模块化才有工程化。
原始阶段
所有代码都通过 <script>
标签直接暴露在全局作用域中,导致严重问题
html
<!-- index.html -->
<script src="module1.js"></script>
<script src="module2.js"></script>
js
// module1.js
var data = "Hello"; // 污染全局作用域
// module2.js
var data = "World"; // 覆盖 module1 的 data
console.log(data); // "World"
问题:变量和函数命名冲突,无法管理依赖
对象命名空间
既然直接写命名会冲突,那不妨把变量和方法挂载到对象属性上,这就是对象命名空间
,如下
js
// module1.js
var MyModule = {
data: "Hello",
log: function() { console.log(this.data); }
};
// module2.js
var AnotherModule = {
data: "World",
log: function() { console.log(this.data); }
};
MyModule.log(); // "Hello"
AnotherModule.log(); // "World"
MyModule.data = 'Hacked' // 可以被修改
优点:将变量和方法挂载到对象属性上(如 MyModule.data
),减少全局变量冲突。
缺点:对象属性仍可能被覆盖(如 MyModule.data = "Hacked"
);
IIFE( Immediately Invoked Function Expression) 自执行函数 + 闭包
自执行函数通过闭包隔离作用域,实现真正的私有变量和模块封装:
js
// module1.js
var MyModule = (function() {
var privateData = "Hello"; // 私有变量,外部无法访问
return {
log: function() { console.log(privateData); }
};
})();
// module2.js
var AnotherModule = (function() {
var privateData = "World";
return {
log: function() { console.log(privateData); }
};
})();
MyModule.log(); // "Hello"
AnotherModule.log(); // "World"
优点:
- 函数立即执行,返回一个对象(模块的公共接口)。
- 内部变量(如
privateData
)被闭包保护,外部无法直接访问。
缺点:
-
手动管理依赖:需通过
<script>
标签顺序或参数传递依赖,容易出错。html<!-- 必须确保 jQuery 先加载 --> <script src="jquery.js"></script> <script src="my-module.js"></script>
-
无动态加载:无法按需加载模块,所有代码需一次性引入。
-
全局暴露入口:模块入口(如
MyModule
)仍需挂载到全局变量。 -
无统一规范:不同开发者写法各异,协作困难。
2009年 node.js 发布,采用 CommonJS
CommonJS 的本质
require
本质上就是一个函数,而这个函数其实就是 node
环境中的一个参数,我们不妨在 node
环境中打印 arguments
试试看
输出如下:
js
console.log(arguments);
// 输出如下
[Arguments] {
'0': {},
'1': [Function: require] {
resolve: [Function: resolve] { paths: [Function: paths] },
main: {
id: '.',
path: '***',
exports: {},
filename: '***',
loaded: false,
children: [],
paths: [Array],
[Symbol(kIsMainSymbol)]: true,
[Symbol(kIsCachedByESMLoader)]: false,
[Symbol(kIsExecuting)]: true
},
extensions: [Object: null prototype] {
'.js': [Function (anonymous)],
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
},
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
},
'.node': [Function (anonymous)]
},
},
cache: [Object: null prototype] {
'***': [Object]
'***': [Object]
}
}
},
},
'2': {
id: '.',
id: '.',
path: '***',
exports: {},
filename: '***',
loaded: false,
children: [],
paths: [
'***',
'***',
'***',
'***',
'***'
],
[Symbol(kIsMainSymbol)]: true,
[Symbol(kIsCachedByESMLoader)]: false,
[Symbol(kIsExecuting)]: true
},
'3': '当前文件的绝对路径',
'4': '当前文件夹的绝对路径'
}
你会好奇怎么在 node
单个文件中直接打印 arguments
也能有值。
实际上,在 node
模块化系统中,每个文件都相当于被包装在了一个立即执行函数(IIFE
)里,这个包装函数会传入五个参数:exports
, require
, module
, __filename
, __dirname
;若我们用了 esm
语法就看不到这些参数了,就不是函数环境了
所以我们是可以直接在 node
中打印上面这五个参数的,大家感兴趣可以自行打印看看长啥样
下面是一段 CommonJS
的伪代码实现,理解这段代码可以很好理解 CommonJS
js
function require (modulePath) {
// 根据模块路径获取模块绝对路径,用作唯一 id
var moduleId = getModuleId(modulePath);
// 如果模块已经加载过,直接返回缓存结果
if (cache[moduleId]) {
return cache[moduleId];
}
function _require (exports, require, module, __filename, __dirname) {
// 将目标模块的代码包裹在一个函数中,并执行
}
// 创建模块对象,用于存储模块的导出结果
var module = {
exports: {}
}
var exports = module.exports;
var __filename = module.filename;
var __dirname = getDirname(__filename);
_require.call(exports, exports, require, module, __filename, __dirname);
// 将模块对象添加到缓存中
cache[moduleId] = module.exports;
return module.exports;
}
第一步就是要给文件一个唯一 ID,而文件的绝对路径一定就是唯一ID
第二步就是判断缓存,若此前运行过就直接返回,这就解释了为什么 CommonJS
的模块只运行一次
第三步就是将导入的模块代码放到一个函数中,这也能解释为什么 node
模块化中的环境就是函数环境,并且有五个参数
第四步就是集齐五个参数,第一个参数 exports
,这个参数初始值就是空对象,第二个参数 require
就是函数本身,第三个参数 __filename
就是文件的绝对路径,第五个__dirname
就是文件夹的绝对路径
第五步通过 call
改变 this
指向到 exports
去执行函数 _require
所以我们不难看出 exports
和 module.exports
以及 this
都是一个东西
java
console.log(exports === module.exports, exports === this, module.exports === this); // true true true
CommonJS 的优缺点
CommonJS
由于本质上就是个 IIFE
函数,这样可以有效防止全局变量污染,甚至向每个模块中传入了这五个参数exports
, require
, module
, __filename
, __dirname
。
不知道是否有人像我一样好奇,为什么 CommonJS
内部都有了 I/O
流(文件路径的读取)却还是同步执行。我们通常理解 I/O
操作时都认定为异步操作,但是像是 路径的读取
实际上是同步进行的,并且 node 中的 fs
模块提供同步和异步的方法。其实在 node 模块加载的过程中,同步读取文件就是为了确保模块之间的加载顺序,并且文件通常位于本地文件系统,读取速度非常快,这种同步加载不会对性能产生明显的影响
优点(相对此前) | 缺点(相对之后) |
---|---|
标准化接口 :通过统一的 require() 和 module.exports 定义模块,避免各自实现差异 |
同步加载:模块加载采用同步文件 I/O,在浏览器环境下会阻塞后续代码执行 |
模块缓存机制:首次加载后缓存模块,避免重复执行,提升性能 | 浏览器原生不支持:浏览器中没有内置 CommonJS 环境,必须借助打包工具转换 |
依赖管理清晰 :显式调用 require() ,使模块依赖关系一目了然 |
静态分析受限:模块输出为值的拷贝,难以在编译时对依赖进行优化(如 tree shaking) |
自动作用域隔离:模块代码自动被包装在私有作用域中,避免全局变量污染 | |
生态系统完善:Node.js 社区和众多工具(如 Browserify、Webpack)广泛支持 CommonJS 模块 |
2010-2011年 AMD(RequireJS), CMD(Sea.js)出现
CommonJS
主要用于服务器端,如Node.js
就是同步加载模块的。而 AMD
是针对浏览器的异步加载,因为浏览器需要避免阻塞。CommonJS
的同步特性在服务器端没问题,因为模块在本地硬盘,加载快。但浏览器端如果同步加载,会因为网络问题导致性能低下,所以 AMD
应运而生,解决了这个问题。
AMD
和 CMD
都是前端模块化规范中定义模块的一套 API(语法规范),它们并非语言内置语法,而是由相应的库(如 RequireJS
和 Sea.js
)提供支持,所以像 AMD
的 define
函数通常是全局可用的,无需额外引入。
AMD(RequireJS)
js
// utils.js 定义一个模块
define([], function () {
return {
add: function (a, b) {
return a + b;
}
}
})
// main.js 使用模块
require(['./utils'], function (utils) {
console.log(utils.add(1, 2));
})
AMD
特点如下
- 依赖必须在
define
语句中提前声明(依赖前置)。 - 使用
require
进行异步加载,不会阻塞页面渲染。 - 适用于浏览器环境,尤其适合需要动态加载多个模块的场景。
CMD(Sea.js)
js
// 定义一个模块
define(function(require, exports, module) {
// 依赖按需引入(就近依赖)
let dep1 = require('dependency1');
let dep2 = require('dependency2');
exports.result = dep1.someFunction() + dep2.someOtherFunction();
});
// 使用模块
seajs.use(['moduleA'], function(moduleA) {
console.log(moduleA.result);
});
CMD
特点如下:
- 依赖在模块内部按需
require()
,符合开发者直觉(就近依赖)。 - 仍然是异步加载,但
require()
只有在执行到时才真正加载模块,而AMD
是提前加载依赖。 - 适用于浏览器环境,但设计理念更接近
CommonJS
,方便前端开发者上手。
AMD 出现后,为什么 CMD 又出现?
CMD
的出现并非去完全替代AMD
,而是针对 AMD
的部分问题进行改进,比如 AMD
的依赖必须前置,也就是依赖必须在模块定义时声明,而 CMD
不需要在定义时显示声明所有依赖,而是按需 require()
,更加符合开发者的书写习惯,简化了模块加载逻辑,其实 AMD
就是更加贴近 CommonJS
,CMD
只有在 require()
时才触发加载对应的模块
CommonJS || AMD || CMD 对比
特点 | CommonJS | AMD (RequireJS) | CMD (Sea.js) |
---|---|---|---|
加载机制 | 同步加载(适用于服务器端,本地 I/O 很快) | 异步加载(适用于浏览器环境,避免阻塞页面渲染) | 异步加载(与 AMD 类似,但提倡"就近依赖") |
模块定义语法 | 使用 require() 加载和 module.exports 导出 |
使用 define(id?, [deps], factory) 来定义模块 |
使用 define(function(require, exports, module){ ... }) |
依赖声明方式 | 在代码中动态调用 require() |
依赖前置:在模块定义时通过依赖数组提前声明依赖 | 就近依赖:在模块工厂函数内按需调用 require() |
静态分析能力 | 不支持静态分析 | 支持静态分析(依赖数组明确,便于预加载和优化) | 较弱,依赖位置灵活不便静态分析 |
适用环境 | 主要用于 Node.js 等服务器端环境 | 专为浏览器设计,适合网络环境下的异步加载 | 主要用于浏览器环境,兼顾前端开发者的书写习惯 |
模块缓存机制 | 模块加载后会被缓存,避免重复执行 | 同样支持缓存机制,但由于异步加载,依赖关系由框架管理 | 具有模块缓存机制,加载完成后缓存模块 |
2015年 ES6 发布,引入 ESModule
15年,js 官方终于提出了 ESM
,定义了一个新的模块化语法,包括 import 和 export,目的是提供一种原生支持的,统一的模块化方案,以取代 CommonJS
,AMD
,CMD
等第三方规范。然而15 年时,ESModule
只是语言规范的一部分,浏览器和 Node.js
还不支持,因此 ESM
仍然无法在浏览器中直接运行,需要使用 Babel
或 Webpack
进行转译。
2017年浏览器原生支持 ESModule
17 年,现代浏览器(如 Chrome 61+、Firefox 60+、Safari 10.1+、Edge 16+)开始正式支持 ESModule
,允许开发者直接在 <script>
标签中使用 ESM
,而无需依赖工具转换。更新内容如下:
-
原生支持
import
和export
语法,可以在浏览器端直接加载ESM
模块文件。 -
新增
<script type="module">
,用于告诉浏览器该脚本是ESM
,支持模块化特性(如自动延迟执行、作用域隔离)。
html
<script type="module">
import { sayHello } from './module.js';
sayHello();
</script>
如果不加 type="module"
,浏览器会把 import
语法当作普通脚本解析,导致语法错误。
此外,浏览器对 ESM
还做了一些额外的优化:
- 默认延迟执行(defer):所有
<script type="module">
脚本都会自动延迟执行,等 HTML 解析完后再运行,无需手动加defer
。 - 严格模式(Strict Mode):ESModule 自动使用严格模式,避免一些 js 旧特性带来的问题。
- 跨域加载限制:浏览器要求 ESM 模块必须符合 CORS 规则,不能直接加载跨域的 js 文件(除非服务器允许
Access-Control-Allow-Origin
)。
时间 | 事件 | 说明 |
---|---|---|
2015 年 | ES6 规范引入 ESModule | import/export 语法被定义,但浏览器和 Node.js 还不支持,需要 Babel/Webpack 转换 |
2017 年 | 浏览器原生支持 ESModule | 现代浏览器可以直接解析 <script type="module"> ,无需编译即可运行 ESModule |
ESM 的工作原理
ESM
有两种形式,一个我们常见的静态导入,一个是 动态 导入 import()
下面举个🌰
html
<body>
<script src='./index.js' type='module'></script>
</body>
js
// index.js
import foo from './foo.js';
import('./dynamic.js').then(module => {
console.log('dynamic.js', module.default);
});
console.log('index.js', foo, bar);
import bar from './bar.js';
// foo.js
import bar from './bar.js';
console.log('foo.js', bar);
export default 'foo';
// bar.js
const bar = 'bar';
console.log('bar.js', bar);
export default bar;
// dynamic.js
import bar from './bar.js';
console.log('dynamic.js', bar);
export default 'dynamic';
// 最终输出如下
bar.js bar
foo.js bar
index.js foo bar
dynamic.js bar
dynamic.js dynamic
先说结论:ESM
的工作原理是先解析再运行,运行之前完成所有的解析工作,解析的工作就是看 import
和 export
,export
的结果相当于会生成一个对象,这个对象的key
,value
就是你抛出的内容;另外 import()
是一个动态导入,这个执行的时机发生在运行时;
实际上
<script src='./index.js' type='module'></script>
会将 src 属性转换成一个 url 地址,把文件内容下载下来进行解析
第一步解析:从 html 看,先引入了 index.js
,这个 文件 里顶部只有一个 import,中间有一个 动态 import()
先不看,下面也有个 import,其实浏览器会帮我们把 import 提前,所以 index.js
在解析时有两个 import,分别将两个文件下载下来先,然后分别分析这两个文件的 import,先看 foo,foo 里也有一个 import bar,而 bar 此前在 index.js 中已经解析好了,不会再重复解析,而 bar.js 没有任何 import;
第二步运行:从 index.js
看先执行 import foo,再执行 import bar,我们先进入到 foo,foo 确又是先执行 import bar,所以这里还是先执行 bar,于是先输出 bar,bar.js 中有个默认导出,其实就是抛出了个对象 key 为 default,value 为 bar。bar 执行完毕后回到 foo,这里的 import bar 其实是给 bar 一个引用地址,bar 和 'bar'
共用一个内存空间,于是打印 foo.js,然后foo抛出一个对象 key 为 default, value 为 foo,注意每个对象都是属于当前文件的,不会产生冲突;
现在回到 index.js 的 import bar,此前执行过就不会再执行了,执行的目的就是为了获取这个对象,也就是 key 为 default
,value 为 bar
;随后执行动态 import,动态import 不难看出本质是个 promise 函数,then 回调就是个微任务,但是 动态导入 的 ./dynamic.js
还是会正常解析运行的,解析其实就是下载文件,这个过程又是异步的,所以先执行后面的同步打印语句,最后才是执行 解析动态 导入,这个过程同样是先下载好文件,然后解析文件中是否还存在 import,这里虽存在 bar 的导入,但是 bar 此前已经生成了 key 为 default
,value 为 bar
的对象,所以直接拿取,然后执行 dynamic
文件,dynamic
文件 export
了 dynamic
字符串,这个返回结果就是 then 回调的入参,因此 module.default
就是 dynamic
js 的基本数据类型是值传递还是址传递?
其实上面的 esm
导出基本类型时非常特殊,这个变量和原模块的变量共用一个内存空间,只有这里是址传递
js 的基本类型是值传递,所谓引用传递如下这样,真正意义上只要是赋值都是共用一个内存空间
js
let a = 1
b = a
a = 2
// 引用传递下 b 也会变成 2,可 js 不是
但是 es6
之后有了引用传递,就是在 esm
时
js
// a.js
export const a = 1
// b.js
import { a } from './a.js'
console.log(a);
在 esm
时,a.js
和 b.js
是共用一个内存空间的,所以 a.js
的 a
变了,b.js
的 a
也会变,所以我们导出基本类型尽量去用 const
去定义常量
所以对于基本类型:总是按值传递,复制出新值。
对于引用类型:传递的是引用的副本,但多个变量指向同一内存,修改对象内容会相互影响。
ESM
的导入导出:使用活绑定机制,导入的变量始终与导出模块中对应的变量保持同步(类似"按引用传递"的效果)。这有个专业名词叫做符号绑定
2019-2020年 Node.js 正式支持 ESM
esm
的崛起不得不让 cjs
这种非官方标准服软,为了让 node.js
兼容 esm
,更新了以下主要内容
- 原生支持 import/export 语法:开发者可以直接在 Node.js 中编写 ES 模块,无需通过 Babel 转译。这意味着你可以使用标准的
import
和export
语法来组织代码。 - 文件扩展名和 package.json 配置:
- 使用
.mjs
扩展名:Node.js 会将这些文件视为 ES 模块。 - 或者在项目的 package.json 中设置
"type": "module"
,这样默认情况下.js
文件就会按照 ES 模块来解析,而不是 CommonJS 模块。因此 package.json 若是没有设置 type 就是默认 cjs 模块机制
- 使用
面试官:说说 ESModule
和 CommonJS
的区别
标准来源不同
node 是在 09 年诞生比 es6 的 15年 早了 6 年,由于 node 模块化是由 node 社区定义的,这是非官方的机构,因此它无法改动你语法层面的东西,你可以理解为 CommonJS 的 require
其实就是它新增的 API 属性,module.exports
就是它新增的一个对象,module
就是一个全局可用的对象;
而 ESModule
是一个官方标准,直接新增了语法,这其实就是一个权威和非权威的对抗,最终 ESM
才能一统
我们需要认定的一个现象是,非官方往往做出的改变都是 API 层面,不是语法层面,因为它没有这个权利
时态时态不同
CommonJS
仅支持编译时,ESModule
编译时和运行时都支持
前端就是两个时态,一个编译时,一个运行时。编译时是 V8
引擎在作用,而一个非官方的 CommonJS
是无法干预编译时,所以它就是运行时,在运行时确定依赖关系,所以 CommonJS
是可以写出如下这样的代码
js
if (xxx) {
require(xxx)
} else {
require(xxx)
}
而 ESM
支持的编译时就是我们平常使用 import
,export
导入导出语法,而运行时在 es7
开始支持,语法为 import()
编译时态我们一般也称之为静态,编译时态还没有运行,所以 import xxx from a
, 这个 a
一定不能是一个变量,变量值的一定是要运行才能确定;在不讨论 import()
动态导入语法的情况下,我们称 esm 就是编译时,编译过程就能确定好依赖关系,也就是说运行之前;所以我们不能将 import
写在 判断体中,甚至我们只能把 import
语句都写在顶部,一定是在运行所有代码前就确定好依赖关系
因为 esm
在编译时就能确定好依赖关系的这个特点,tree-shaking
树摇优化才能发挥它在编译时工作的能力,毕竟打包时还没运行
最后
从最初的 IIFE 到 cjs,再到 amd, cmd 最后 esm 的大一统趋势,IIFE 虽然解决了命名冲突问题,但是写法上很难统一,cjs 虽成体系但不作用于浏览器,且同步会阻塞代码执行,这当然对 node 端不成影响,amd,cmd 虽然异步且可作用于浏览器但是究竟是个第三方库,且 api 用法冗长,也不好静态分析,模块化也不统一,最后 esm 通通解决了这些痛点
这么来看 js 模块化的发展历程就是一个从混乱到规范,从分散到标准化的过程,涉及多个技术方案和社区实践到最后落地的演变。
文章中若出现错误内容还请各位大佬见谅并指正。如果有任何问题或建议,欢迎指出,另外,有不懂之处欢迎在评论区留言。如果觉得文章对你的学习有所帮助,还请 关注、点赞、收藏 一键三连,感谢支持!欢迎关注我的公众号:
Dolphin_Fung