🧩什么是模块
将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起。块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信。
简单来说也就是,模块就是把一段独立、可复用的逻辑封装起来,并且可以导入导出。
就像我们平时玩的乐高或者拼图,每个乐高小块就是一个模块。你可以单独制造、保存它,然后在不同的拼搭里拿来用。它自己有形状(接口),别人只需要知道怎么拼接,不用管内部细节。
原始阶段:全局变量、函数阶段
在早期的JavaScript
开发中,JavaScript
没有内置的模块系统,通常使用全局变量、函数来组织代码
js
// a.js
function add(x, y) {
return x + y;
}
var sum = add(1, 2);
console.log(sum);
// b.js
function add() {}
存在的问题
随着文件越来越多,你可能在另一个文件里又写了同名的变量或函数:
js
function add(x, y) {
return x - y;
}
函数add的逻辑完全被改变了,而且这些覆盖并不会报错,开发者难以发现哪里出现了bug------这就是所谓的全局污染。
另外,依赖关系也完全靠人记住加载顺序,比如:
js
<script src="./util.js"></script>
<script src="./main.js"></script>
如果你不小心把顺序写反了:
js
<script src="./main.js"></script>
<script src="./util.js"></script>
那 main.js
想用 util.js
里的方法时,就会直接报错。 总结
- 容易出现命名冲突以及代码复杂性的问题
- 模块成员之间看不出直接关系
命名空间(namespace)
针对全局变量、函数这种方式存在代码污染和命名冲突的问题,引入了命名空间的概念,通过将相关的函数、变量和对象放在命名空间中,实现了代码的封装和组织
js
var MyApp = {
score: 100,
add: function (x, y) {
return x + y + this.score;
},
};
var sum = MyApp.add(1, 2);
这样至少不会出现冲突了,但是新的问题也随之出现...
存在的问题
对象里的东西全都暴露在外面,数据不安全(外部可以直接修改模块内部的数据),无法按需导出
如果我只想暴露 add 方法,我的 score 属性也不得不暴露,并且外部还可以直接修改 score 属性
js
MyApp.score = 1;
造成了数据的不安全
立即执行函数 IIFE
接着大家想到:干脆用函数作用域,把内部变量关起来,只暴露需要的...那么为了解决命名空间无法按需导出、数据不安全问题,使用闭包特性 将代码包装在一个匿名函数中,创建私有作用域 ,通过返回对象或函数来暴露需要用到的公共接口,避免污染全局命名空间,并立即执行这个函数,这种方式叫立即执行函数
用法
js
(function (x) {
console.log(x);
})(1);
例子
js
var MyApp = (function () {
var score = 100;
//暴露
return {
add: function (x, y) {
return x + y + score;
},
};
})();
var sum = MyApp.add(1, 2);
console.log(sum);

无法修改 score 的值,可以按需导出。
存在的问题
如果当前这个模块依赖另一个模块该怎么办?
IIFE模式增强:引入依赖
答案是 ------ 把依赖作为参数传进去 。
假设我们有一个工具模块 math.js
:
js
// math.js
var MathModule = (function () {
function add(x, y) {
return x + y;
}
function sub(x, y) {
return x - y;
}
return { add, sub };
})();
然后我们写一个业务模块 main.js
,它依赖 MathModule
:
js
// main.js
var MyApp = (function (math) {
var score = 100;
return {
calc: function (x, y) {
return math.add(x, y) + score;
},
};
})(MathModule);
console.log(MyApp.calc(1, 2)); // 输出 103
但这仍存在以下几个问题:
-
虽然能解决依赖问题,但随着依赖模块越来越多,参数会越来越臃肿。
-
加载顺序依旧要手动控制:必须先加载
math.js
,再加载main.js
,否则MathModule
还没定义就会报错。
不过到这里,现代模块化的雏形基本确立。
CommonJS
CommonJS
是为服务器端 开发提供了一种同步加载模块的方式,这种模块机制非常适合服务器端环境,因为文件系统的 IO 操作是同步的,解决模块化和依赖管理的问题
用法
- 导出用
module.exports/exports
- 导入用
require
例子
js
// add.js
function add(x, y) {
return x + y;
}
module.exports = add;
// main.js
const add = require('./add.js');
const sum = add(1, 2);
console.log(sum);
每个文件都是独立作用域,导入导出清晰。
存在的问题
CommonJS
是 同步加载 的,在服务端没问题,但浏览器不是文件系统,网络环境下同步加载会很慢,用户可能要等脚本全下完才能操作页面。
AMD
CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作。开发中对于异步加载的需求越来越多,RequireJS 推出了 AMD 规范,允许在代码运行时异步加载模块,通过define
和require
来定义和引用模块,解决了模块依赖管理和异步加载的问题。
例子
math.js
js
// 定义名称,依赖项,导出模块
define('math', [], function () {
return {
add: function (a, b) {
return a + b;
},
};
});
main.js
js
// 加载完成后将math返回的对象以参数传递给回调函数
require(['math'], function (utils) {
console.log(utils.add(1, 2));
});
index.html
html
<script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js"></script>
<script src="./main.js"></script>
存在的问题
AMD 的异步加载思想非常适合浏览器(能按需拉取),但写起来很繁琐,每个模块都要声明依赖和回调,模块一多,代码就变得很臃肿,可读性大大下降。而且如果引入了多余的依赖,没有进行区分是否调用,都会进行加载。
CMD
CMD(通用模块定义)是由 SeaJS 提出和实现的一种模块化规范。SeaJS 是一个遵循 CMD 规范的 JavaScript 模块加载器,可用于浏览器端的模块化开发。
CMD的特点是:
- 推崇依赖就近 原则,仅在需要使用某个模块的时候再去require它
- 模块加载是异步的,但定义的模块会延迟执行,直到需要时才执行
- 通过
define
定义模块,require
加载模块,易于使用。
相比于强调依赖前置的AMD,CMD规范允许就近定义依赖,更加灵活。
用法
js
//定义没有依赖的模块
define(function(require, exports, module){
exports.xxx = value
module.exports = value
})
js
//定义有依赖的模块
define(function(require, exports, module){
//引入依赖模块(同步)
var module2 = require('./module2')// 用到时再引入
//引入依赖模块(异步)
require.async('./module3', function (m3) {
})
//暴露模块
exports.xxx = value
})
SeaJS用法文档Sea.js - A Module Loader for the Web
存在的问题
CMD 倡导在模块内部按需 require
,写起来灵活、符合懒加载场景,但这种"运行时才确定依赖"的风格,会让静态工具更难做优化。如果希望更好的构建时优化,后面提到的ESM会是更好的选择。
UMD
UMD 是一种通用的模块定义规范,是AMD和CommonJS的一个糅合,旨在解决不同模块加载器和环境之间的兼容性问题。它的设计目标是使同一个模块可以在多种环境下使用,例如AMD是浏览器优先,异步加载;CommonJS是服务器优先,同步加载。
js
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define([], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS
module.exports = factory();
} else {
// 全局变量
root.myModule = factory();
}
})(this, function () {
return { add: (x, y) => x + y };
});
实现原理
UMD 的实现原理
- 先检测当前环境是否支持 AMD 规范
- 如果支持则采用 AMD 方式加载模块
- 如果不支持,再检测是否支持 CommonJS 规范
- 如果支持则采用 CommonJS 方式导出模块
- 如果两者都不支持,再将模块暴露为一个全局变量。这样一来,无论在什么环境下,都能够正确地加载和使用 UMD 模块。
存在的问题
UMD 的出发点是好的,既能在 AMD、CJS、也能在浏览器全局下运行。它适合做对外发布的库,但作为源码风格并不优雅,且容易让现代构建获得不到最大化优化。
如果在做库发布,常见做法是源码写 ESM,然后通过构建链输出多种格式(ESM + CJS + UMD),兼顾现代开发和老环境用户。
新的时代答案:ESM
随着 ES6 发布,JS 原生支持了模块化,引入import
和export
关键字来定义和引用模块。ESM 提供了一种静态分析的模块加载方式,使得代码更易于优化和打包。
用法
导入导出
js
// add.js导出
// 变量导出
export const PI = 3.1415;
// 命名导出,一个模块可以具有多个命名导出
export function add(x, y) {
return x + y;
}
//默认导出,一个模块最多只有一个默认导出
export default function sqrt(x) { return Math.sqrt(x); }
// main.js导入
import { add } from './add.js';
console.log(add(1, 2));
动态导入
ESM
模块支持通过 import()
函数动态地导入模块。这对于条件加载模块、按需加载和代码拆分 非常有用。import()
返回一个 Promise 对象,使得可以在异步操作中使用。
js
button.addEventListener('click', async () => {
const { add } = await import('./math.js') // 在点击时才加载
console.log(add(2, 3))
})
处理循环依赖
以下有两个模块,a.js
和 b.js
,它们互相依赖:
js
// a.js
import { b } from './b.js';
console.log('a.js', b);
export const a = 'A from a.js';
// b.js
import { a } from './a.js';
console.log('b.js', a);
export const b = 'B from b.js';
// console.log('b.js', a);
// ReferenceError: Cannot access 'a' before initialization
讲完了前端模块化的历史,相信聪明的你对前端模块化有了一个更全面的了解了吧~接下来趁热打铁看一道大厂面试题
CJS 和 ESM 区别是什么
-
用法不同
- ES module 使用
import/export
关键字实现模块的导入和导出。 - CJS 采用
require
和module.exports
实现模块的导入和导出
- ES module 使用
-
加载方式不同
- 编译时加载:ES6 模块不是对象,而是通过
export
显式指定输出的代码,import
时采用静态命令的形式。即在import
时可以指定加载某个输出值,而不是加载整个模块,这种加载称为"编译时加载" - 运行时加载:
CommonJS
模块就是对象,即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,也就是"运行时加载"
- 编译时加载:ES6 模块不是对象,而是通过
-
导入和导出特性不同
- ES module 支持异步导入,动态导入和命名导入等特性,可以根据需要动态导入导出,模块里面的变量绑定其所在的模块
- CommonJs 只支持同步导入导出
-
循环依赖处理方式不同
- ES module 采用链接+求值 的两阶段机制,在编译阶段 建立好导出变量和导入变量的绑定关系制造活绑定(live binding),通过使用模块间的依赖地图来解决死循环问题,标记进入过的模块为"获取中",所以循环引用时不会再次进入;但要注意变量在真正赋值前被读取很可能遇到TDZ(Temporal Dead Zone也就是我们熟知的暂时性死区),这种情况会导致报错/得到undefined
- CJS 通过第一次被
require
时就会执行并缓存其 exports 对象。这样在循环引用中,CJS 就会提供一个部分导出对象(partial exports),从而打破无限循环,但可能导致运行时拿到不完整对象。如下,a 文件引用 b,b 文件引用 a
cssmain.js └──> a.js └──> b.js └──> a.js (cached partial exports)
-
兼容性不同
- ES module 需要在支持 ES6 的浏览器或者 Node.js 版本才能使用
- 而 CJS 的兼容性会更好
-
CommonJs 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用
- CommonJs 模块输出的是值的拷贝(浅拷贝),也就是说,一旦输出一个值,模块内部的变化就影响不到这个值
js// cjs_snapshot.js let x = 1; module.exports = { x }; // 把当时的 x (1) 赋给 obj.x x = 2; // main.cjs const mod = require('./cjs_snapshot.js'); console.log(mod.x); // 1 (不是 2)
- ES6 模块的运行机制与 CommonJS 不一样,JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行的时候,再根据这个只读引用,到被加载到那个模块里面去取值。原始值变了,import 加载的值也会跟着变。因此,ES6 是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
js// counter.mjs export let count = 0; export function inc() { count++; } // main.mjs import { count, inc } from './counter.mjs'; console.log(count); // 0 inc(); console.log(count); // 1 (live binding)