从前端模块化历史到大厂面试题
最近在辅导同学准备春招/实习时,发现很多同学对模块化的理解不够清晰,今天小圆就来系统性地讲解一下。
🧩什么是模块
将一个复杂的程序依据一定的规则(规范)封装成几个块(文件), 并进行组合在一起。块的内部数据与实现是私有的, 只是向外部暴露一些接口(方法)与外部其它模块通信。
简单来说也就是,模块就是把一段独立、可复用的逻辑封装起来,并且可以导入导出。
就像我们平时玩的乐高或者拼图,每个乐高小块就是一个模块。你可以单独制造、保存它,然后在不同的拼搭里拿来用。它自己有形状(接口),别人只需要知道怎么拼接,不用管内部细节。
原始阶段:全局变量、函数阶段
在早期的JavaScript开发中,JavaScript没有内置的模块系统,通常使用全局变量、函数来组织代码
// a.js
function add(x, y) {
return x + y;
}
var sum = add(1, 2);
console.log(sum);
// b.js
function add() {}
存在的问题
随着文件越来越多,你可能在另一个文件里又写了同名的变量或函数:
function add(x, y) {
return x - y;
}
函数add的逻辑完全被改变了,而且这些覆盖并不会报错,开发者难以发现哪里出现了bug——这就是所谓的全局污染。
另外,依赖关系也完全靠人记住加载顺序,比如:
<script src="./util.js"></script> <script src="./main.js"></script>
如果你不小心把顺序写反了:
<script src="./main.js"></script> <script src="./util.js"></script>
那 main.js 想用 util.js 里的方法时,就会直接报错。总结
- 容易出现命名冲突以及代码复杂性的问题
- 模块成员之间看不出直接关系
命名空间(namespace)
针对全局变量、函数这种方式存在代码污染和命名冲突的问题,引入了命名空间的概念,通过将相关的函数、变量和对象放在命名空间中,实现了代码的封装和组织
var MyApp = {
score: 100,
add: function (x, y) {
return x + y + this.score;
},
};
var sum = MyApp.add(1, 2);
这样至少不会出现冲突了,但是新的问题也随之出现...
存在的问题
对象里的东西全都暴露在外面,数据不安全(外部可以直接修改模块内部的数据),无法按需导出
如果我只想暴露 add 方法,我的 score 属性也不得不暴露,并且外部还可以直接修改 score 属性
MyApp.score = 1;
造成了数据的不安全
立即执行函数 IIFE
接着大家想到:干脆用函数作用域,把内部变量关起来,只暴露需要的...那么为了解决命名空间无法按需导出、数据不安全问题,使用闭包特性将代码包装在一个匿名函数中,创建私有作用域,通过返回对象或函数来暴露需要用到的公共接口,避免污染全局命名空间,并立即执行这个函数,这种方式叫立即执行函数
用法
(function (x) {
console.log(x);
})(1);
例子
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:
// 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:
// 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
例子
// 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
// 定义名称,依赖项,导出模块
define('math', [], function () {
return {
add: function (a, b) {
return a + b;
},
};
});
main.js
// 加载完成后将math返回的对象以参数传递给回调函数
require(['math'], function (utils) {
console.log(utils.add(1, 2));
});
index.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规范允许就近定义依赖,更加灵活。
用法
//定义没有依赖的模块
define(function(require, exports, module){
exports.xxx = value
module.exports = value
})
//定义有依赖的模块
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是服务器优先,同步加载。
(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 提供了一种静态分析的模块加载方式,使得代码更易于优化和打包。
用法
导入导出
// 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 对象,使得可以在异步操作中使用。
button.addEventListener('click', async () => {
const { add } = await import('./math.js') // 在点击时才加载
console.log(add(2, 3))
})
处理循环依赖
以下有两个模块,a.js 和 b.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实现模块的导入和导出
- 加载方式不同编译时加载:ES6 模块不是对象,而是通过export显式指定输出的代码,import时采用静态命令的形式。即在import时可以指定加载某个输出值,而不是加载整个模块,这种加载称为“编译时加载”运行时加载:CommonJS模块就是对象,即在输入时是先加载整个模块,生成一个对象,然后再从这个对象上面读取方法,也就是“运行时加载”
- 导入和导出特性不同ES module 支持异步导入,动态导入和命名导入等特性,可以根据需要动态导入导出,模块里面的变量绑定其所在的模块CommonJs 只支持同步导入导出
- 循环依赖处理方式不同ES module 采用链接+求值的两阶段机制,在编译阶段建立好导出变量和导入变量的绑定关系制造活绑定(live binding),通过使用模块间的依赖地图来解决死循环问题,标记进入过的模块为“获取中”,所以循环引用时不会再次进入;但要注意变量在真正赋值前被读取很可能遇到TDZ(Temporal Dead Zone也就是我们熟知的暂时性死区),这种情况会导致报错/得到undefinedCJS 通过第一次被require时就会执行并缓存其 exports 对象。这样在循环引用中,CJS 就会提供一个部分导出对象(partial exports),从而打破无限循环,但可能导致运行时拿到不完整对象。如下,a 文件引用 b,b 文件引用 a<!---->
- 兼容性不同ES module 需要在支持 ES6 的浏览器或者 Node.js 版本才能使用而 CJS 的兼容性会更好
- CommonJs 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用CommonJs 模块输出的是值的拷贝(浅拷贝),也就是说,一旦输出一个值,模块内部的变化就影响不到这个值ES6 模块的运行机制与 CommonJS 不一样,JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行的时候,再根据这个只读引用,到被加载到那个模块里面去取值。原始值变了,import 加载的值也会跟着变。因此,ES6 是动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
✨ 感谢阅读!
如果你:
- 正在准备前端面试,需要系统梳理知识点
- 感觉学习效率低下,想要一份定制化的学习路线
- 缺乏项目经验,不知道如何写进简历
欢迎联系我,了解更多关于一对一前端辅导的详情~
#前端八股文##26届的你,投了哪些公司?##面试##日常实习##秋招#
查看13道真题和解析