模块化,是工程化的前提

没有模块化,就没有工程化。
什么是模块化?模块,这个词就涉及对整体的拆分和组织。简单来说,就是组织代码。把代码合理的拆分成粒度更小的‘块’,让他们职责更清晰,自身高内聚,彼此耦合低。
按最初JS的设计初衷看,确实没必要搞模块化:最初也就是负责浏览器端最简单交互。
但是,JS的发展让人意想不到,需要交给JS的事情越来越多,已经从简单交互发发请求的时代,过渡到了页面几乎都是JS生成的,再到页面是虽然和过去一样是后端生成的,但这个后端是JS写的。
JS要干的事情越来越多,对工程化的需求就越来越大。但是,一切,都得等我们得先解决解决模块化的问题。

在历史中学习

上古时代:作用域和对象模块化

古者三百步为里,名曰井田 —- 周代 井田制

函数算模块化吗?

  1. function f1() {}
  2. function f2() {}

通过函数,我们是把代码的粒度拆分了,但是,函数在同一个文件中随意的调用,并且还存在命名冲突的风险。
不过,命名的问题至少可以用另外一种思路解决:对象;

  1. const m1 = {
  2. data: 'data1',
  3. f1: function () {},
  4. f2: function () {},
  5. }
  6. const m2 = {
  7. data: 'data2',
  8. f1: function () {},
  9. f2: function () {},
  10. }
  11. m1.f1()

这样把函数定义到对象里面,然后用对象调用这个函数的方法,本质是模仿命名空间这种概念,来解决模块和命名冲突的问题。
但是这离完美差了很远,因为这些所谓的模块中的数据没有任何安全性可言,m1.data = ‘sucks’,谁都可以改动这些数据。

启蒙时代:闭包和IIFE

自由不是无限制的自由,自由是一种能做法律许可的任何事的权力 —- 启蒙运动 孟德斯鸠

闭包是自由的,尽管他的规则可能是一种约束。不过自由也不是无限制的自由,自由和约束是对立且统一,相互成就促进发展(老马克思了)。
好言归正传,闭包这个JS特别的特性似乎天生就是为了解决数据安全而存在的:

  1. const module1 = (function() {
  2. let innner_data = '';
  3. let visit = function () {
  4. return `I Got ${inner_data}`
  5. }
  6. return {
  7. visit_func: visit,
  8. }
  9. })()
  10. // use
  11. module1.visit_func();

上面的例子就是利用了闭包,return的对象中引用了当前作用域的某些变量,对这些变量的访问只能通过return的函数了。
另外要说一个东西就是IIFEImmediately-invoked function expression),直接翻译就是立即调用函数表达式。
有了IIFE,我们似乎离模块化就进了一大步了。至少我们解决了把一个模块封装好的问题。
现在考虑另一个问题,假如现在存在好几个文件,那么我们打包的时候怎么把他们串起来呢?
这有一个思路:把模块全部挂到全局对象上:

  1. (function(global) {
  2. let innner_data = '';
  3. let visit = function () {
  4. return `I Got ${inner_data}`
  5. }
  6. global.module1 = {
  7. visit_func: visit,
  8. };
  9. })(window)
  10. // use
  11. module1.visit_func()

上面的代码其实是把模块挂在global对象上了,比如window。这样使用起来就像这样module1.visit_func(),而同时,模块内部的变量是被封装起来,不可直接访问的。
另外,模块之间的依赖可以进一步借助参数传递来实现,比如,有外部函数库csgUtils:

  1. (function(global, utils) {
  2. let innner_data = '';
  3. let visit = function () {
  4. return utils.toBinary(`I Got ${inner_data}`);
  5. }
  6. global.module1 = {
  7. visit_func: visit,
  8. };
  9. })(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是没有渲染出来的:

  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4. <script type="text/javascript" src="a.js"></script>
  5. </head>
  6. <body>
  7. <span>body</span>
  8. </body>
  9. </html>

其中a.js

  1. function fun1(){
  2. alert("it works");
  3. }
  4. fun1();

AMD的规范有一个比较有名的库叫require.js, 核心是这两个方法:

  • define,定义模块;
  • require,加载模块;

使用的时候就长这个样子:

  1. requirejs.config({ /** config **/});
  2. // 定义模块
  3. define(['jquery'], function (jq) {
  4. return jq.noConflict( true );
  5. });
  6. // 引用模块
  7. requirejs(['jquery'], function( $ ) {
  8. console.log( $ ) // OK
  9. });

现在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的问题,既然大家混用,那索性就都支持好了。
所以他做的最核心的事情就是,判断当前环境支持那种方式:

  1. ((root, factory) => {
  2. if (typeof define === 'function' && define.amd) {
  3. // AMD
  4. define(['jquery'], factory);
  5. } else if (typeof exports === 'object') {
  6. // CommonJS
  7. var $ = requie('jquery');
  8. module.exports = factory($);
  9. }
  10. // ....
  11. })(this, ($) => {
  12. // todo in 模块
  13. });

后现代:ES module

ES module(ESM),既然‘ES’了,那么它就是最官方的规范。
我们谈到ESM的时候,有这几个关键点是要格外注意的:静态、实现

静态

ESM是静态引入的,所谓的静态,就是编译时,就能确定模块的依赖关系。CommonJS就不是静态的,它必须到运行的时候才能确定关系,拿到值。
这里说拿到值,但ESM和CJS拿到的值,本质是不一样的。ESM在引入模块时候拿到的是模块的引用,或者说是值的引用,而CJS拿到的是值的拷贝,这就会导致一个现象:

  1. // ESM
  2. // -------- in a.js
  3. export a = 'a';
  4. export function change() {
  5. a = 'A'
  6. }
  7. // in main.js
  8. import { a, change } from 'a';
  9. console.log(a); // a
  10. change();
  11. console.log(a); // A

上面的代码是ESM的方式,可以看到,因为我们import的时候拿到的都是引用,所以在main.js的输出值会因为修改了原来的数据而变化。
但是CJS就不一样了:

  1. // CJS
  2. // -------- in a.js
  3. let a = 'a';
  4. function change() {
  5. a = 'A'
  6. }
  7. exports.a = a;
  8. exports.change = change;
  9. // in main.js
  10. let a = require('a').a;
  11. let change = require('a').change;
  12. console.log(a); // a
  13. change();
  14. console.log(a); // a

实现

为什么要讨论实现?因为ESM是一个规范,规范是需要具体实现的。
以前我总认为,ESM规范还没有得到广泛的实现,我们平时在项目中肆无忌惮的使用,是因为Webpack实现了ESM规范,这话不假,Webpack的确实现了ESM,而且还实现了更多,比如支持inline import语法之类的。
但是,我后面了解到,在浏览器中对ESM其实是有实现的。
比如我们的script标签中,加上属性type=”module”,这就可以使用import了。

  1. <script type="module">
  2. import * as cjj from './csb'
  3. </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的目的就是剔除无用代码,减小代码体积,这个事情的性质决定了只能在编译阶段做。)