前端不能忘记的模块化演进史
模块化的进化史
早期 Javascript 当中的模块化就是以文件的划分进行实现的,这也就是 Web 中最原始的模块系统
文件划分
将每个功能和相关数据状态分别放在单独的文件里,约定每一个文件就是一个单独的模块,使用每个模块,直接调用这个模块的成员
// a.js
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
// b.js
var name = 'module-b'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
<script src="a.js"></script>
<script src="b.js"></script>
<script>
// 命名冲突
method1()
// 模块成员可以被修改
name = 'foo'
</script>
我们可以发现 a 和 b 两者都定义了相同的方法,并且可以通过 name = 'foo' 去改变 name 的值,所以我们可以得出以下问题:
- 污染全局作用域
- 命名冲突问题
- 无法管理模块依赖关系
总的来说,早期模块化完全依靠于约定(规范)
命名空间方式(匿名空间)
每个模块只暴露一个全局对象,所有模块成员都挂载到这个对象上,通过将每个模块包裹成一个全局对象的方式实现,类似于为每个模块添加命名空间的感觉
// module a
var moduleA = {
name: 'module-a',
method1: function () {
console.log(this.name + '#method1')
},
method2: function () {
console.log(this.name + '#method2')
}
}
// module b
var moduleB = {
name: 'module-b',
method1: function () {
console.log(this.name + '#method1')
},
method2: function () {
console.log(this.name + '#method2')
}
}
<script src="a.js"></script>
<script src="b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
// 模块成员可以被修改
moduleA.name = 'foo'
</script>
这种模块化可以解决命名冲突的问题,但是这种方式仍然没有私有空间,模块成员仍然可以外部被修改,另外依赖关系也没得到解决
- 命名冲突问题
- 无法管理模块依赖关系
IIFE(立即执行表达式)
使用IIFE(立即执行表达式)为模块提供四有空间,对于需要向外暴露的成员,挂载到全局对象上的方式实现
// module a
;(function () {
var name = 'module-a'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
window.moduleA = {
method1: method1,
method2: method2
}
})()
// module
;(function () {
var name = 'module-b'
function method1 () {
console.log(name + '#method1')
}
function method2 () {
console.log(name + '#method2')
}
window.moduleB = {
method1: method1,
method2: method2
}
})()
<script src="module-a.js"></script>
<script src="module-b.js"></script>
<script>
moduleA.method1()
moduleB.method1()
// 模块私有成员无法访问
console.log(moduleA.name) // => undefined
</script>
我们可以看出 IIFE 有了模块的私有空间,但依赖关系也没得到解决
模块化规范
模块化的贯彻执行离不开相应的约定,即规范
Commonjs
提到模块化规范,我第一个想到的就是 Commonjs 规范,它是 Nodejs 提出的一个标准,我们在 Nodejs 中必须遵守 Commonjs 规范
- 一个文件就是一个模块
- 每个模块都有单独的作用域
- 通过 module.exports 导出成员
- 通过 require 函数载入模块
多次导入同一个模块,只会加载一次模块文件,第一次加载会放入缓存,剩下的导入的其实是缓存
由于 CommonJS 模块加载是同步的,因此可以在非顶级代码块中使用reruire
导入模块
if (loadCondition) {
require('./moduleA.js') // 动态加载
}
那么如果 Commonjs 中存在循环引用,那么该怎么解决?
// a.js
const b = require('./b');
console.log('Print b in module a =>', b);
exports.name = 'a';
// b.js
const a = require('./a');
console.log('Print a in module b =>', a);
exports.name = 'b';
Node(v15.9.0)中运行上面的代码会报错,“RangeError: Maximum call stack size exceeded”,由于循环调用,最终栈溢出了
所以,模块系统必须要解决循环依赖的问题。规范中没有说明解决循环依赖的标准方法,但是声明了出现循环依赖时应该返回怎样的结果。即下面的代码中,运行 a.js,最终 b.js 中可以正确获取到导致循环依赖的 require 语句之前的导出结果
// a.js
exports.val = 100; //此处应该被正确导出,因为它在发生循环依赖的 require 语句之前
const b = require('./b'); //发生循环依赖
console.log('Print b in module a =>', b);
exports.name = 'a';
// b.js
const a = require('./a');
console.log('Print a in module b =>', a);
exports.name = 'b';
// 运行 a.js 后的打印结果
Print a in module b => { val: 100 }
Print b in module a => { name: 'b' }
但我们发现 Nodejs 中可以使用 Commonjs,而在浏览器中却一直没有遵循这个规范?
Nodejs 是启动时就去加载模块,执行过程中不需要去加载,所以 Commonjs 在 Node 中不会有问题。但是在浏览器使用 Commonjs,必然会使效率低下,因为每次页面加载,都会导致有大量的同步请求出现,所以在早期的前端模块中,并没有选择 Commonjs 规范,而是结合浏览器的特点,重新设计了一个规范,那就是 AMD
AMD
AMD 是 Asynchronous Module Definition 的简称,即“异步模块定义”,是从 CommonJS 讨论中诞生的。
AMD 优先照顾浏览器的模块加载场景,使用了异步加载和回调的方式。
浏览器端异步加载库 Require.js 实现的就是 AMD 规范,内部提供了 define 方法来导入或导出模块
所谓异步加载,就是指同时并发加载所依赖的模块,当所有依 赖模块都加载完成之后,再执行当前模块的回调函数。这种加载方式和浏览器环境的性能需求刚好吻合。
AMD 规范定义了一个全局函数 define,通过它就可以定义和引用模块,下列代码定义了 moduleA模块,它依赖于 moduleB 和 moduleC,并暴露了一个对象
define('moduleA', ['moduleB','moduleC'], function (moduleB,moduleC) {
// 模块内部的代码
// 导出的内容
return {
stuff: moduleB.dostuff(),
stuff1: moduleC.doStuff1()
}
});
我们可以看到每次使用 AMD 规范,我们都需要调用 define,并写一些操作模块的代码,这会导致我们的代码复杂度提高,如果模块分得太细致的话,JS文件请求就会过于频繁,从而导致页面效率特别低下,所以我觉得 AMD 只是前端模块化演进的一步,它是一种妥协的方式,而不是最终的解决方案,但它也是很有意义的,它在当时前端的模块化提供了标准
另外,在 AMD 中,则是在 define 引入依赖时,依赖模块就已经加载和执行了。我们可以将AMD总结为类似提前执行,所有的模块都是在最前面声明依赖时执行,而不是在代码中用到的地方执行。
除此之外,还有 Sea.js,它实现的是 CMD
CMD
CMD 有 Commonjs 和 AMD 的影子,它诞生的想法就是希望 CMD 写出来的代码像 Commonjs 类似,从而减轻开发者的学习成本,但后续被 require 兼容了
define(function(require, exports) {
var util = require('./util.js'); //在这里加载模块
exports.init = function() {
//...
};
});
ES Module
ECMAScript 是 JavaScript 的语言标准,目的是在各大浏览器中建立统一的规范。ES 2015 中提出了模块化的定义,如今各主流浏览器都对它进行了原生支持,也就代表着我们的模块语法可以直接在浏览器上跑啦
ES Module 的语法相信前端小伙伴们都很熟悉,如下是基本的几种导入导出语法。
import { xxx } from './utils/helper';
import randomSquare from './utils/time';
那么 ES Module 跟 Commonjs 的区别是什么?网上的很多文章,包括阮一峰写的 ES6 Module 中都有提到:CommonJS 模块是运行时加载,而ES6 模块是编译时输出接口
在 es-modules-a-cartoon-deep-dive 这篇文章中作者对 ES Module 在浏览器中的实现原理进行了解析,总结而言,就是在模块代码执行前,会有一个编译阶段,将所有的 import 和 export 指向对应变量的存储空间。
这也就是为什么在引入模块时无法使用变量的原因,因为编译阶段是做静态解析,代码不会运行,所以是无法获取到变量的值的。
import mod from `${path}/foo.js` // error
但有时候我们确实需要根据不同情况加载不同的模块代码,ES Module 提供了另一种方法, dynamic import(动态加载)来解决这个问题,示例如下:
import('/modules/a.js')
.then((module) => {
// Do something with the module.
});
“运行时”与“编译时”的区别就是,一个返回的是值的拷贝,而另一个返回的是值的引用
我们先看看 commonjs
// lib.js
let counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter,
incCounter,
};
// main.js
let mod = require('./lib');
console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 3
main.js 中调用 mod.incCounter() 方法会改变 lib.js 中定义的 counter 值,可这对 module.exports 毫无影响,最终两次打印的结果都是 3
我们再看看 es-module
// lib.js
export let counter = 3;
export function incCounter() {
counter++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4
打印的结果是 3,4,这说明 import 进来的 counter 就是 lib.js 中定义的 counter。
转载自:https://juejin.cn/post/7352075813259214898