js 模块化

7/20/2022 JavaScript

# 模块化,是工程化的前提

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

# 在历史中学习

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

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

函数算模块化吗?

function f1() {}

function f2() {}
1
2
3

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

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

这样把函数定义到对象里面,然后用对象调用这个函数的方法,本质是模仿命名空间这种概念,来解决模块和命名冲突的问题。 但是这离完美差了很远,因为这些所谓的模块中的数据没有任何安全性可言,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();
1
2
3
4
5
6
7
8
9
10
11
12
13
14

上面的例子就是利用了闭包,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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14

上面的代码其实是把模块挂在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)
1
2
3
4
5
6
7
8
9
10
11

# 标准时代:CJS、AMD、CMD

不论是简单的运动形式,或复杂的运动形式,不论是客观现象,或思想现象,矛盾是普遍地存在着

# 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>
1
2
3
4
5
6
7
8
9

其中a.js

// a.js
function fun1(){
  alert("it works");
}

fun1();
1
2
3
4
5
6

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

  • define,定义模块;
  • require,加载模块; 使用的时候就长这个样子:
requirejs.config({ /** config **/});
// 定义模块
define(['jquery'], function (jq) {
    return jq.noConflict( true );
});
// 引用模块
requirejs(['jquery'], function( $ ) {
    console.log( $ ) // OK
});
1
2
3
4
5
6
7
8
9

现在2022年了,基本没有人用,不过,简单的讨论讨论其实现还是有用的: 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,作者就是玉伯。

# 后现代: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
1
2
3
4
5
6
7
8
9
10
11
12
13
14

上面的代码是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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 实现

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

<script type="module">
	import * as cjj from './csb'
</script>
1
2
3