模块化,是工程化的前提
没有模块化,就没有工程化。
什么是模块化?模块,这个词就涉及对整体的拆分和组织。简单来说,就是组织代码。把代码合理的拆分成粒度更小的‘块’,让他们职责更清晰,自身高内聚,彼此耦合低。
按最初JS的设计初衷看,确实没必要搞模块化:最初也就是负责浏览器端最简单交互。
但是,JS的发展让人意想不到,需要交给JS的事情越来越多,已经从简单交互发发请求的时代,过渡到了页面几乎都是JS生成的,再到页面是虽然和过去一样是后端生成的,但这个后端是JS写的。
JS要干的事情越来越多,对工程化的需求就越来越大。但是,一切,都得等我们得先解决解决模块化的问题。
在历史中学习
上古时代:作用域和对象模块化
古者三百步为里,名曰井田 —- 周代 井田制
函数算模块化吗?
function f1() {}
function f2() {}
通过函数,我们是把代码的粒度拆分了,但是,函数在同一个文件中随意的调用,并且还存在命名冲突的风险。
不过,命名的问题至少可以用另外一种思路解决:对象;
const m1 = {
data: 'data1',
f1: function () {},
f2: function () {},
}
const m2 = {
data: 'data2',
f1: function () {},
f2: function () {},
}
m1.f1()
这样把函数定义到对象里面,然后用对象调用这个函数的方法,本质是模仿命名空间这种概念,来解决模块和命名冲突的问题。
但是这离完美差了很远,因为这些所谓的模块中的数据没有任何安全性可言,m1.data = ‘sucks’,谁都可以改动这些数据。
启蒙时代:闭包和IIFE
自由不是无限制的自由,自由是一种能做法律许可的任何事的权力 —- 启蒙运动 孟德斯鸠
闭包是自由的,尽管他的规则可能是一种约束。不过自由也不是无限制的自由,自由和约束是对立且统一,相互成就促进发展(老马克思了)。
好言归正传,闭包这个JS特别的特性似乎天生就是为了解决数据安全而存在的:
const module1 = (function() {
let innner_data = '';
let visit = function () {
return `I Got ${inner_data}`
}
return {
visit_func: visit,
}
})()
// use
module1.visit_func();
上面的例子就是利用了闭包,return的对象中引用了当前作用域的某些变量,对这些变量的访问只能通过return的函数了。
另外要说一个东西就是IIFE(Immediately-invoked function expression),直接翻译就是立即调用函数表达式。
有了IIFE,我们似乎离模块化就进了一大步了。至少我们解决了把一个模块封装好的问题。
现在考虑另一个问题,假如现在存在好几个文件,那么我们打包的时候怎么把他们串起来呢?
这有一个思路:把模块全部挂到全局对象上:
(function(global) {
let innner_data = '';
let visit = function () {
return `I Got ${inner_data}`
}
global.module1 = {
visit_func: visit,
};
})(window)
// use
module1.visit_func()
上面的代码其实是把模块挂在global对象上了,比如window。这样使用起来就像这样module1.visit_func(),而同时,模块内部的变量是被封装起来,不可直接访问的。
另外,模块之间的依赖可以进一步借助参数传递来实现,比如,有外部函数库csgUtils:
(function(global, utils) {
let innner_data = '';
let visit = function () {
return utils.toBinary(`I Got ${inner_data}`);
}
global.module1 = {
visit_func: visit,
};
})(window, csgUtils)
标准时代:CJS、AMD、CMD、UMD
不论是简单的运动形式,或复杂的运动形式,不论是客观现象,或思想现象,矛盾是普遍地存在着
CommonJS
首先,CommonJS 不完全是一个模块化规范。我们在讨论模块化时候说的CommonJS,其实是说CommonJS中的模块化规范。
CommonJS是一个规范集合,其初衷是为了标准化一套JS在浏览器之外的运行环境。所以,在CommonJS中规范化了File System、Stream、Buffer、Module等。而且,正是因为有了CommonJS这样美好的愿景,影响了后面Node.js的出现。
(关于CommonJS的更多细节可以在深入浅出Node.js中了解更多)
那么,抛开上述背景,我们在这里只讨论CommonJS的模块化规范的特点。
- 引用模块用require函数;
- 导出模块用exports;
- exports是当前文件上下文的一个对象,用来对应挂上某个文件的导出内容,所以可以exports.xxx = ‘xxx’。这里还需要说明的一点是,每一个文件都存在一个module对象,用来指代文件自身。而上面说的exports本质是module对象的一个属性。
- 输出的东西是值的拷贝,所以,当这个值被输出之后,就算模块内的值发生了变化,被输出的值也不会更新;
- 多次引入同一个模块,第一引入之后会被缓存,后面的引入都会先在缓存中读取;
- 同步按顺序加载多个模块;
AMD
AMD是Asynchronous Module Define 的缩写。
Async很明显的是异步的意思,也就是这是一种异步加载的模块化规范。
为什么要异步加载呢?CommonJS处理的Node端,是Server端,Server端同步加载无所谓,但是如果在浏览器端的话,同步加载多文件可能会导致白屏等待时间过长,比如下面的例子中,当alert弹出的时候,body是没有渲染出来的:
<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="a.js"></script>
</head>
<body>
<span>body</span>
</body>
</html>
其中a.js
function fun1(){
alert("it works");
}
fun1();
AMD的规范有一个比较有名的库叫require.js, 核心是这两个方法:
- define,定义模块;
- require,加载模块;
使用的时候就长这个样子:
requirejs.config({ /** config **/});
// 定义模块
define(['jquery'], function (jq) {
return jq.noConflict( true );
});
// 引用模块
requirejs(['jquery'], function( $ ) {
console.log( $ ) // OK
});
现在2020年了,基本没有人用,不过,简单的讨论讨论其实现还是有用的:
define函数接受了一系列依赖名字和回调函数,把依赖和回调放入一个全局的Queue中。这样,当我们通过某种方式拿到依赖之后,依赖的数据会被以参数的形式注入的回调函数中,这样,对于回调函数这个作用域内的代码来讲,相当于就把这个库加入到当前的模块空间里面了。
require具体是怎么拿数据呢?
他针对每一个依赖,新建一个script标签,async=true表示异步加载不阻塞后续流程,等拿到数据之后,触发node.addEventListener(‘load’, / /),那么当加载完了,load事件触发后,调用回调函数,就是在define中放入Queue的。
CMD
CMD(Common Module Definition),和AMD的区别主要有两点:
- CMD,不完全是异步加载,支持同步require和异步require.async;
- AMD是依赖前置的,就是你在模块头部先声明出模块中要使用的依赖;但是在CMD中,随时需要随时引入;
CMD的一个著名实现,Sea.js,作者就是玉伯。
UMD
UMD(Universal Module Definition)的出现最初是为了解决大家在代码混用CMD、ADM甚至CommonJS的问题,既然大家混用,那索性就都支持好了。
所以他做的最核心的事情就是,判断当前环境支持那种方式:
((root, factory) => {
if (typeof define === 'function' && define.amd) {
// AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// CommonJS
var $ = requie('jquery');
module.exports = factory($);
}
// ....
})(this, ($) => {
// todo in 模块
});
后现代:ES module
ES module(ESM),既然‘ES’了,那么它就是最官方的规范。
我们谈到ESM的时候,有这几个关键点是要格外注意的:静态、实现
静态
ESM是静态引入的,所谓的静态,就是编译时,就能确定模块的依赖关系。CommonJS就不是静态的,它必须到运行的时候才能确定关系,拿到值。
这里说拿到值,但ESM和CJS拿到的值,本质是不一样的。ESM在引入模块时候拿到的是模块的引用,或者说是值的引用,而CJS拿到的是值的拷贝,这就会导致一个现象:
// ESM
// -------- in a.js
export a = 'a';
export function change() {
a = 'A';
}
// in main.js
import { a, change } from 'a';
console.log(a); // a
change();
console.log(a); // A
上面的代码是ESM的方式,可以看到,因为我们import的时候拿到的都是引用,所以在main.js的输出值会因为修改了原来的数据而变化。
但是CJS就不一样了:
// CJS
// -------- in a.js
let a = 'a';
function change() {
a = 'A';
}
exports.a = a;
exports.change = change;
// in main.js
let a = require('a').a;
let change = require('a').change;
console.log(a); // a
change();
console.log(a); // a
实现
为什么要讨论实现?因为ESM是一个规范,规范是需要具体实现的。
以前我总认为,ESM规范还没有得到广泛的实现,我们平时在项目中肆无忌惮的使用,是因为Webpack实现了ESM规范,这话不假,Webpack的确实现了ESM,而且还实现了更多,比如支持inline import语法之类的。
但是,我后面了解到,在浏览器中对ESM其实是有实现的。
比如我们的script标签中,加上属性type=”module”,这就可以使用import了。
<script type="module">
import * as cjj from './csb'
</script>
面向未来发问
CJS 中exports和module.exports的区别?
上文中我们讲 :“exports是当前文件上下文的一个对象,用来对应挂上某个文件的导出内容,所以可以exports.xxx = ‘xxx’。这里还需要说明的一点是,每一个文件都存在一个module对象,用来指代文件自身。而上面说的exports本质是module对象的一个属性”。
所以本质上exports和module.exports是一个东西,都指向一处。
他们的关系是:
moudule.exports = {};
exports = module.exports;
所以,使用方法有所区别:
- 使用moudule.exports导出 赋值的时候一定要赋值成对象;
- 使用exports的时候一定 不要 直接赋值赋值;
- exports = changeValueFunc; ❌
- exports.changeValueFunc = changeValueFunc; ✅
仔细想想不难理解,因为exports本身是module.exports的引用,如果你对export重新赋值,那么这个引用就失效了呀,那么,原本期望导出的东西自然导出不成,因为modules.exports并没有任何变化。
ESM 中 export default 和 export 的区别?
最基本的区别:
- export default是唯一的,导出没有{},如 export default a,import a from
- export可以是多个,且要加{},如 export {a,b},import {a,b} from
上述答案只是一个及格答案,我们还有深入讨论…
继续,为什么有人会说:最好用export,而不是用export default呢?(比如这里有一个BBS帖子,就在探讨这样的问题)
我们深入研究之后发现,这个性能影响确实存在,体现在tree shaking方面,原来是,export default不利于tree shaking优化,比如export default出去的东西,后面就算没有使用,tree shaking也优化不到这些没有使用的代码,从而倒是最终的打包结果没有做到精简。
参考阅读:这篇文章写了tree shaking的整体流程,而又有这篇文章更直接,作者就针对两种方式export之后,采用import {xx}、import * as xxx、以及 import XXX分别打包,实打实的查看了打包生成的文件,验证了我们提出的问题。
永远都有人愿意把问题弄清楚,为这些走在自己前面的同学,点赞👍
ESM 为什么要设计成静态的?
所谓静态,换个角度理解下,ESM你引入的东西是什么?是引入的代码片段,这些代码片段还没有执行,可能执行了以后就变成对象了,但是这些都是后话,你反正引入的时候还没执行呢。但是它使用没使用,你是可以通过依赖关系,或者说从入口文件代码遍历下去可以知道的,所谓依赖分析。
那么,从tree shaking的角度考虑,只有静态的模块化方式,才能通过分析导入依赖关系,对无用代码进行清理。
我们在对比下CJS吧,CJS不是静态的,意思就是,程序已经运行起来了,你在模块中导出的都是对象,(不是代码片段)所以,在编译阶段,我们没办法进行无用代码剔除,也就是tree shaking。
(tree shaking的目的就是剔除无用代码,减小代码体积,这个事情的性质决定了只能在编译阶段做。)