模块的概念和特性

模块源于代码拆分的需求,是一种分治的思想。好处是分离关注点,提升代码复用性,避免全局的变量污染造成的混乱。这时一种很常见的思想,当系统的规模和复杂度上升后,不可避免要拆分,以更好地管理系统。

模块其实就是一系列的变量和方法的封装,包括私有的变量和方法、共有的变量和方法。

模块与模块之间是引用和被引用的关系,一个应用就是由被引用关系连接起来的大量模块组织起来的。

模块的关键因素是1. 模块标识,2. 模块定义,3. 模块引用。只有明确了模块定义,编译器才能知道模块要导出哪些变量和方法;只有明确了模块的引用,编译器才能知道一个模块要引用另一个模块的哪些变量和方法。

所以一个模块的基本内容是:依赖的模块、自己要做的事情、要导出的变量/方法。

历史

浏览器通过script加载js文件并执行,在ESM之前浏览器并没有原生支持JavaScript的模块化。前端项目代码量大时候,会把代码拆分,分成多个js文件引入。
为了避免全局环境污染,可以使用全局变量命名空间管理模块、闭包创建封闭模块。
但是这样没有显式导入导出,需要手动管理依赖。

Nodejs提出了Commonjs规范,其中包含了模块化的规范。

为了让浏览器支持JavaScript的模块化,分别有AMD和CMD两种规范。浏览器模块化一个特点就是异步,因为浏览器加载文件需要发送网络请求,请求过程中不能阻塞渲染和其他任务执行,因此异步的模块化规范的特性就是引用模块时候定义一个依赖的模块加载完成的回调。
AMD的实现是requirejs
CMD的实现是seajs

模块化带来的好处之一就是显式声明了依赖关系,让编译器可以自动管理js文件加载顺序。
UMD(Universal Module Definition)是通用模块规范,在开发中不会用到,是打包工具用来让打包产物适配各种环境而使用的模块规范。
AMD/CDM、Commonjs都是模块规范,都是用JavaScript实现的特性。而es6从语言层面支持了模块化,其模块化是在引擎层面支持的(打包工具在转译ES6时候会用CommonJS hack ESM)。ESM支持同步和异步引用模块,可以作为服务端和浏览器端的通用解决方案,Node13.2.0开始正式支持ESM特性。

Commonjs

Commonjs规范很简单,每个文件是一个模块,module.exports代表这个模块,require引用模块。

  1. // lib.js
  2. module.exports = {
  3. log: function () {console.log('test')}
  4. };
  5. // main.js
  6. const lib = require('./lib');
  7. lib.log();

CommonJS的实现主要是require时候根据传入的路径找到模块所在文件。

找到文件后用eval包装里面的内容执行,从而得到该文件中定义的模块。网站很多介绍require实现的文章。

在加载过程中缓存模块,下次再遇到缓存过的模块的引用,就直接返回,否则创建模块并加到缓存列表中,然后返回。

require查找模块的基本过程是:

  1. 判断是否为核心模块,在node本身提供的模块列表中进行查找,如果是就直接返回
  2. 判断是 moduleName是否以/或者./开头,如果是就统一转换成绝对路径进行加载后返回
  3. 如果前两步都没找到,就当做是包模块,到最近的node_moudles来找

ES6 module和CommonJS区别

ES6 module和CommonJS到底有什么区别?

“ES6 module是编译时加载,输出的是接口,CommonJS运行时加载,加载的是一个对象”,这里的“编译时”是什么意思?和运行时有什么区别?“接口”又是什么意思?

“ES6 模块输出的是值的引用,CommonJS 模块输出的是一个值的拷贝”,那么“值的引用”和“值的拷贝”对于开发者又有什么区别呢?

下面通过一些示例详细说明ES6 module和CommonJS的区别。

编译时导出接口 VS 运行时导出对象

CommonJS 模块是运行时加载,因为 CommonJS 加载的是一个对象(即module.exports属性),该对象只有在脚本运行完才会生成。

ES6 模块是它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。

这里的“编译时”,指的是js代码在运行之前的编译过程,我们熟悉的变量提升就发生在编译阶段,但是由于编译过程是引擎的行为,开发者没法在编译阶段做任何操作,所以不容易直观地理解“编译时导出接口和运行时导出对象”这个区别。

不过我们在循环引用这个场景就可以轻松地理解两者的差异。

来看下面CommonJS的代码

  1. // index.js
  2. const {log} = require('./lib.js');
  3. module.exports = {
  4. name: 'index'
  5. };
  6. log();
  7. // lib.js
  8. const {name} = require('./index.js');
  9. module.exports = {
  10. log: function () {
  11. console.log(name);
  12. }
  13. };

执行index.jsnode index.js结果会打印"undefined"

这里index模块和lib相互依赖。

模块化 - 图1

我们分析一下代码执行过程,首先const {log} = require('./lib.js');导入lib.js模块,这时候开始加载lib模块,lib会先导入index,const {name} = require('./index.js');但这个时候index还没有定义name,所以lib中这里的name是undefined,然后lib导出log方法。

接下来index导出name,然后执行log,由于lib中的name是undefined,因此最终结果是打印undefined。

整个过程模块都是运行时加载,代码依次执行,所以很容易分析出执行结果。

而ES6 module有所不同,接下来看一个es6 module的例子。代码内容和上面一样,只是把模块规范从CommonJS换成es6 module。

  1. // index.mjs
  2. import {log} from './lib.mjs';
  3. export const name = 'index';
  4. log();
  5. // lib.mjs
  6. import {name} from './index.mjs';
  7. export const log = () => {
  8. console.log(name);
  9. };

模块化 - 图2

首先import {log} from './lib.mjs';导入lib模块,注意这个时候虽然没有执行export const name = 'index';,index模块还没有导出name的值,但是index模块已经编译完成,lib已经可以获取到name的引用,只是还没有值。这非常像代码编译阶段的变量提升。

然后加载lib模块,import {name} from './index.mjs';这句导入了index模块的name,(这时候获取到的是name这个引用,但因为还没有值,因此如果马上打印name console.log(name)会报错。)接下来lib导出log方法。

然后index模块执行export const name = 'index';导出name,其值为"index"

最后执行log方法log();因为name已经赋值,所以lib中name的引用可以正常访问到值"index",所以最终结果是打印”index"

综上所述,es6 module虽然模块未初始化好时候就被lib导入,但因为获取的是导出的接口(接口编译阶段就已经输出了),等初始化好之后就能使用了。

引用 VS 值拷贝

ES6 module导入的模块不会缓存值,它是一个引用,这个在上面的例子中已经讨论过。
CommonJS会缓存值,这个很好理解,因为js中普通变量是值的拷贝,其实就是把模块中的值赋给一个新的变量。

看下CommonJS的一个例子

  1. // index.js
  2. const {name} = require('./lib.js'); // 等价于const lib = require('./lib'); const {name} = lib;
  3. setTimeout(() => {
  4. console.log(name); // 'Sam'
  5. }, 1000);
  6. // lib.js
  7. const lib = {
  8. name: 'Sam'
  9. };
  10. module.exports = lib;
  11. setTimeout(() => {
  12. lib.name = 'Bob';
  13. }, 500);

index模块中导入lib的nameconst {name} = require('./lib.js');其实就是把lib中的name赋给index里面一个name变量。后面lib中name的变化不会影响到index中的name变量。

而ES6中类似的引用语法,导入的则是引用

  1. // index.mjs
  2. import {name} from './lib.mjs';
  3. setTimeout(() => {
  4. console.log(name); // 'Bob'
  5. }, 1000);
  6. // lib.mjs
  7. export let name = 'Sam';
  8. setTimeout(() => {
  9. name = 'Bob';
  10. }, 500);

这里index模块中的name是lib导出的name的引用,因此lib中name变化会同步到index中。

当然这并不意味着ES6 module可以做到比CommonJS更多的事情,因为如果希望在CommonJS中获取到变化,也可以直接访问lib.name

  1. // index.js
  2. const lib = require('./lib.js');
  3. setTimeout(() => {
  4. console.log(lib.name); // 'Bob'
  5. }, 1000);

所以这个特性的区别只是需要我们在实现模块时候注意一下,避免预期之外的情况。

其实在上面循环引用的例子中,也能看到CommonJS拷贝值和ES6 module引用的区别,CommonJS因为是拷贝值,所以导入模块时候如果还没初始化好,就是undefined,而ES6 module是引用,所以初始化好之后就可以用了。

静态 VS 动态

ES6 module静态语法和CommonJS的动态语法是很重要的区别,

CommonJS的动态性体现在两个方面

  1. 可以根据条件判断是否加载模块
  1. if (condition) {
  2. require('./lib');
  3. }
  1. require的模块参数可以是一个变量
  1. require(`./${template}/index.js`);
  1. 这种动态性导致依赖关系不好分析,打包工具在静态代码分析阶段不容易知道模块是否需要被加载、模块的哪些部分需要被加载,哪些不会被用到。

相应地,ES6 module的静态性体现在

  1. import必须在顶层
  2. import的模块参数只能是字符串,不能是变量
    所以打包工具能够静态分析出依赖关系,并确定知道哪些模块需要被加载、模块的哪些部分被用到。

所以ES6 module静态语法支持打包tree-shaking,而CommonJS不行。

只读 VS 可变

CommonJS导入的模块和普通变量没有区别,ES6 module导入的模块则不同,import导入的模块是只读的。

  1. // demo-commonjs.js
  2. let path = require('path');
  3. path = 1;
  4. // demo-esm.js
  5. import path from 'path';
  6. path = 1; // Error: Cannot assign to 'path' because it is an import

异步 VS 同步

ES6 module支持异步加载,浏览器中会用到该特性,而Commonjs是不支持异步的,因为服务器端不需要异步加载。所以CommonJS不可替代ES6 module,ES6 module可以替代CommonJS。

总结

ES6 module和CommonJS的区别主要有5点

  1. ES6 module是编译时导出接口,CommonJS是运行时导出对象。
  2. ES6 module输出的值的引用,CommonJS输出的是一个值的拷贝。
  3. ES6 module语法是静态的,CommonJS语法是动态的。
  4. ES6 module导入模块的是只读的引用,CommonJS导入的是可变的,是一个普通的变量。
  5. ES6 module支持异步,CommonJS不支持异步。

ES6 module作为新的规范,可以替代之前的AMD、CMD、CommonJS作为浏览器和服务端的通用模块方案。NodeJS在13.2.0版本后已经开始完全支持ES6 module,ES6 module在未来也会实际的模块化标准。

AMD和CMD原理(更新中)

AMD和CMD都是通过<font style="color:#BFBFBF;">define</font>定义模块,通过<font style="color:#BFBFBF;">require</font>定义依赖。

<font style="color:#BFBFBF;">define</font><font style="color:#BFBFBF;">require</font>方法做的操作是注册模块,并声明依赖,define和require会对依赖的模块动态创建script标签,或者用ajax下载文件,然后运行文件。如果依赖的模块之前已经注册过,直接返回。


CMD中使用正则匹配,确定依赖并下载。因此能够实现就近依赖,看起来是同步的写法,实际执行还是异步的。

webpack模块化原理(更新中)

什么是webpack模块
• ES2015 import 语句
• CommonJS require() 语句
• AMD define 和 require 语句
• css/sass/less 文件中的 @import 语句。
• 样式(<font style="color:#BFBFBF;">url(...)</font>)或 HTML 文件(<font style="color:#BFBFBF;"><img src="..." /></font>)中的图片链接

https://www.webpackjs.com/concepts/modules/#支持的模块类型


webpack内置对常见的模块的支持,在打包过程中解析模块化语法,以分析出模块间的依赖关系。对于其他的模块,可以用相应的loader来支持解析。 webpack模块打包出来会是一个个的chunk,就是一个个的文件,包括同步的和异步的,同步的通过script注入到html中,根据依赖关系按照顺序排列。 webpack通过<font style="color:#BFBFBF;">webpackChunkwebpackmodule</font>全局变量管理模块,这是一个挂在window下的数组,其中保存着所有的模块。这个变量可以通过<font style="color:#BFBFBF;">output.jsonpFunction</font>选项指定。
webpack打包时候如何处理模块?如何转译ES6 module?都是转成CommonJS。
webpack对动态require(require的路径是个变量)(不怎么推荐这种方式,会生成一堆文件chunk)的处理。打包目录下所有的文件。
webpack对异步import的处理是生成运行时,异步加载模块,然后Promise resolve这个模块。
require.context遍历目录下所有文件,打包时候也会将目录下所有文件都生成chunk。

参考文章

https://juejin.cn/post/6844903759009595405#heading-9

https://zhuanlan.zhihu.com/p/41568986

https://zhuanlan.zhihu.com/p/42853909

https://segmentfault.com/a/1190000010349749

https://www.jianshu.com/p/ee88e9849a1b

https://www.zhihu.com/question/301856771

https://zhuanlan.zhihu.com/p/39641701