为什么要模块化
如果一个程序只有一个html文件,style标签里面写css,script标签里面写js代码,程序也是可以跑起来的。
html
<head>
<style>
/* 写css */
</style>
</head>
<body>
<!-- 写dom -->
<script>
// 写js
</script>
</body>
一开始前端没太复杂,只是做一些简单的表单验证和动画,把js代码都写到script标签这样做也是可以的,后面前端发展很快,代码量激增,如果还是代码都写在一个文件里面,维护麻烦
,增加开发人员心智负担,要找一个变量,找一个函数,还得把这个文件上下滚来滚去,而且也会有性能问题
,文件大了,资源下载速度势必变慢,白屏时间就长了。
所以要拆分文件,这里面除了不同开发人员写的文件,还有引入第三方库的文件,这又会产生其他问题,如有一个a.js
和b.js
文件,不小心都定义了同一个变量或者函数,就会出现命名冲突
。如果b.js依赖a.js的变量或者函数,依赖关系也看不出来,可读性差
:
js
// a.js
var name1 = "aaa";
function logName() {
console.log("aaa");
}
// b.js
var name1 = "bbb";//覆盖
logName();
js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./a.js"></script>
<script src="./b.js"></script>
</body>
</html>
什么是模块化
一个功能模块,一个文件就是一个模块,里面是相同功能的代码,有自己的内部实现,也可以提供接口暴露某些功能给外部使用
早期模块化方案:IIFE
js
// moduleA.js 模块A
var moduleA = (() => {
var aNumber = 1;
function getANumber() {
return aNumber;
}
return {
aNumber,
getANumber,
};
})();
// moduleB.js 模块B
var moduleB = (() => {
function getNumberFromA() {
console.log(moduleA.getANumber());
}
return {
getNumberFromA,
};
})();
// moduleC.js 模块C
(() => {
moduleB.getNumberFromA();
})();
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>IIFE</title>
</head>
<body>
<script src="./moduleA.js"></script>
<script src="./moduleB.js"></script>
<script src="./moduleC.js"></script>
</body>
</html>
这种做法可以在一定程度上解决命名冲突问题,但也有缺点:
1、因为有依赖关系,script标签顺序必须要把被依赖的模块文件写在前面,而且也看不出依赖关系,如果文件出现♻️依赖,都不知道该把哪个文件的标签引用写在前面
2、用到一个模块就引入一个脚本,项目大了,会定义非常多的模块,浏览器会发出很多请求
3、多人开发,也有可能出现模块名冲突的问题
总之,IIFE不能很好解决命名冲突和代码量大难管理的问题,所以标准的模块化方案呼之欲出
社区标准:CommonJS
在ES6(2015)
推出ES Module
模块化方案前,社区涌现了AMD,CMD,CommonJS
等方案,如今,最流行的社区的模块化方案是在Node
中实现的CommonJS
CommonJS是一个社区规范,最开始是在浏览器以外的环境使用,叫ServerJS,后面为了体现它的广泛性,改为CommonJS,也简称CJS。
node实现了CJS规范,浏览器可以通过 Browserify 来书写CJS格式的代码,webpack基于node运行,实现了对CJS的支持和转换。所以接下来的内容是CJS在node中的实现
在Node中引入模块,要经历:
路径分析、文件定位、编译执行
3个阶段。在Node中,模块分为两类:一类是Node提供的模块,称为
核心模块
;另一类是用户编写的模块,称为文件模块
。核心模块部分在Node源代码的编译过程中,编译进了二进制执行文件。在Node进程启动时,部分核心模块就被直接加载进内存中,所以这部分核心模块引入时,文件定位和编译执行这两个步骤可以省略掉,并且在路径分析中优先判断,所以它的加载速度是最快的。
文件模块则是在
运行时动态加载
,需要完整的路径分析、文件定位、编译执行过程,速度比核心模块慢。
require查找规则
1、优先从缓存加载
不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先
的方式,这是第一优先级的。不同之处在于核心模块的缓存检查先于文件模块的缓存检查
。
2、路径分析
require()接受一个标识符作为参数,这个标识符为以下几种:
① 核心模块,如http、fs、path等。
② 以.
或..
开始的相对路径文件模块。
③ 以/
开始的绝对路径文件模块
④ 非路径形式的文件模块,第三方库,如koa,axios模块。
核心模块:
优先级仅次于缓存加载;路径形式的文件模块:
require()方法会将路径转为真实路径
,并以真实路径作为索引,将编译执行后的结果存放到缓存中,以使二次加载时更快。给Node指明了确切的文件位置,所以在查找过程中可以节约大量时间,其加载速度慢于核心模块;非路径形式的文件模块:
可能是一个文件也可能是包,查找最慢。
3、模块路径
Node在定位文件模块的具体文件时要先生成一个模块路径,是一个路径数组,
它的生成规则是:
① 当前文件目录下的node_modules目录。
② 父目录下的node_modules目录。
③ 父目录的父目录下的node_modules目录。
④ 沿路径向上逐级递归,直到根目录下的node_modules目录。
在加载的过程中,Node会逐个尝试模块路径中的路径,直到找到目标文件为止。可以看出,当前文件的路径越深,模块查找耗时会越多,这是自定义模块的加载速度是最慢的原因。
4、定位文件
从缓存加载的优化策略使得二次引入时不需要路径分析、文件定位和编译执行的过程,大大提高了再次加载模块时的效率。但在文件的定位过程中,还有一些细节需要注意,这主要包括文件扩展名
的分析、目录
和包
的处理。
文件扩展名分析
如果require(X)传入的参数没有扩展名,Node会按照.js, .json, .node
的顺序补全扩展名。
目录和包分析
require()通过分析文件扩展名之后,可能没有查找到对应文件,但却得到一个目录,此时Node会将目录当做一个包来处理。
首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤;
首先,Node在当前目录下查找package.json,通过JSON.parse()解析出包描述对象,从中取出main属性指定的文件名进行定位。如果文件名缺少扩展名,将会进入扩展名分析的步骤;
如果main属性指定的文件名错误,或者压根没有package.json文件,Node会将index当做默认文件名,然后依次查找index.js、index.json、index.node
。
如果在目录分析的过程中没有定位成功任何文件,则自定义模块进入下一个模块路径进行查找。如果模块路径数组都被遍历完毕,依然没有查找到目标文件,则会抛出查找失败的异常。
5、模块查找总结
认识Module
Node 定义了一个构造函数 Module,所有的模块都是 Module 的实例
js
// node/lib/internal/modules/cjs/loader.js
// 简化源码
function Module(id = "", parent) {
this.id = id;
this.path = path.dirname(id);
setOwnProperty(this, "exports", {});
moduleParentCache.set(this, parent);
updateChildren(parent, this, false);
this.filename = null;
this.loaded = false;
this.children = [];
}
// 缓存
Module._cache = { __proto__: null }
js
// a.js
const abc = "abc";
exports.abc = abc;
console.log("a:", module);
// a: Module {
// id: 'd:\\源码\\alianxi\\algorithm\\a.js',
// path: 'd:\\源码\\alianxi\\algorithm',
// exports: { abc: 'abc' },
// filename: 'd:\\源码\\alianxi\\algorithm\\a.js',
// loaded: false,
// children: [],
// paths: [
// 'd:\\源码\\alianxi\\algorithm\\node_modules',
// 'd:\\源码\\alianxi\\node_modules',
// 'd:\\源码\\node_modules',
// 'd:\\node_modules'
// ]
// }
// b.js
const a = require("./a");
console.log("b:",module);
// b: Module {
// id: '.',
// path: 'd:\\源码\\alianxi\\algorithm',
// exports: {},
// filename: 'd:\\源码\\alianxi\\algorithm\\b.js',
// loaded: false,
// children: [
// Module {
// id: 'd:\\源码\\alianxi\\algorithm\\a.js',
// path: 'd:\\源码\\alianxi\\algorithm',
// exports: [Object],
// filename: 'd:\\源码\\alianxi\\algorithm\\a.js',
// loaded: true, //被引入加载过了
// children: [],
// paths: [Array]
// }
// ],
// paths: [
// 'd:\\源码\\alianxi\\algorithm\\node_modules',
// 'd:\\源码\\alianxi\\node_modules',
// 'd:\\源码\\node_modules',
// 'd:\\node_modules'
// ]
// }
require、exports、module、__filename、__dirname 从何而来
编译和执行是引入文件模块的最后一个阶段,每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache
对象上,以提高二次引入的性能。
每个模块文件中存在着require、exports、module、__filename、__dirname这5个变量,但是它们在模块文件中并没有定义,从何而来?
在编译的过程中,Node对获取的JavaScript文件内容进行了头尾包装。在头部添加了(function (exports, require, module, __filename, __dirname) {
,在尾部添加了\n})
,这样每个模块文件之间都进行了作用域隔离
js
// node/lib/internal/modules/cjs/loader.js 简化源码
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});',
];
let wrapperProxy = new Proxy(wrapper, {
__proto__: null,
defineProperty(target, property, descriptor) {
return ObjectDefineProperty(target, property, descriptor);
},
});
ObjectDefineProperty(Module, 'wrapper', {
__proto__: null,
get() {
return wrapperProxy;
},
});
let wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
最后,wrap会被处理成一个function对象,将当前模块对象的exports属性、require()方法、module(模块对象自身),以及在文件定位中得到的完整文件路径__filename和文件目录__dirname作为参数传给这个function
exports和module.exports区别
exports
是一个对象,可以往这个对象添加属性,它会被导出
js
// a.js
let a = 1;
setTimeout(() => {
exports.a = 2;
}, 1000);
exports.a = a;
js
// b.js
// moduleA就是导出的对象exports,是同一个引用
const moduleA = require("./a");
console.log(moduleA.a);
setTimeout(() => {
console.log(moduleA.a);
}, 2000);
平时更多是使用module.exports
来批量导出
js
let a = 1;
let b = "b";
let c = true;
function d() {
console.log(d);
}
/** 使用exports导出 */
// exports.a = a;
// exports.b = b;
// exports.c = c;
// exports.d = d;
// 使用 module.exports导出 方式一
// 跟使用exports导出一样的
// module.exports.a = a;
// module.exports.b = b;
// module.exports.c = c;
// module.exports.d = d;
// 使用 module.exports导出 方式二
module.exports = { a, b, c, d };
module.exports和exports指向同一个引用地址
,但是CJS中,模块真正导出的对象是module.exports
,require
真正引入的是module.exports这个对象
js
// a.js
let a = 1;
let b = "b";
let c = true;
function d() {
console.log(d);
}
// exports被重新赋值了一个引用地址,不再指向module.exports
// 最终被导出的对象module.exports里面没有属性,什么都没有导出
exports = { a, b, c, d };
// b.js
// moduleA指向的是module.exports对象
const moduleA = require("./a");
console.log("模块a:", moduleA);
模块执行、循环引用
不论是核心模块还是文件模块,require()方法对相同模块的二次加载都一律采用缓存优先
的方式,这是第一优先级的。编译和执行是引入文件模块的最后一个阶段,每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache
对象上,以提高二次引入的性能。
模块在被第一次引入时,模块中的 js 代码会被执行一次,每个模块对象有一个 loaded
属性,加载了 loaded 就变为 true 了,后面的引入,就会直接返回缓存中该模块的module.exports
。即使循环引用,也不会出现无限循环
Node加载模块是深度优先遍历 :
js
// a.js
console.log("a-0");
require("./b");
require("./c");
console.log("a-1");
// b.js
console.log("b-0");
require("./c");
require("./d");
console.log("b-1");
// c.js
console.log("c-0");
require("./d");
require("./e");
console.log("c-1");
// d.js
console.log("d-0");
require("./e");
require("./f");
console.log("d-1");
// e.js
console.log("e-0");
require("./f");
require("./a");
console.log("e-1");
// f.js
console.log("f-0");
require("./b");
require("./a");
console.log("f-1");
CommonJS 为什么不适合浏览器
CommonJS 模块是同步加载
的:
同步意味着需要等模块加载完毕后,后面的逻辑才会执行, 这个在服务端问题不大,因为服务器加载的是本地 JS 文件,等待时间就是硬盘的读取时间,速度会比较快,当在浏览器端,加载的模块在资源服务器,网速如果不行,浏览器会处于一个较长时间的"假死"状态
官方标准:ES Module
ES Module的语法可以查看阮一峰老师的 《ECMAScript 6 入门》 Module的语法
浏览器加载 ES6 模块
浏览器加载 ES6 模块,也使用<script>
标签,但是要加入type="module"
属性:
<script defer>
-
下载时不会阻塞 HTML 解析;
-
在DOM 解析之后执行;
-
defer保证多个脚本执行之间的
相对顺序
(如果它们都有src
); -
阻止
DOMContentLoaded
事件; -
将 script 放在 body 的末尾与 script defer 具有相同的效果,但 script defer 使浏览器有机会提前下载和解析
<script async>
-
下载时不会阻塞 HTML 解析;
-
不等待 HTML 解析完成;可能会中断 DOM 构建(特别是当它从浏览器的缓存中获取服务时);
-
无序执行;尽快执行;async
不保证
脚本执行之间的相对顺序
; -
阻止
load
事件(但不阻止DOMContentLoaded
事件)
<script type='module'>
- 等同于defer
保证
所有模块脚本(没有async的type='module')相对顺序
- 仅执行一次 ,即使具有相同内容的脚本
src
被加载多次
<script type=module async>
- 不保证脚本执行相对顺序
- 不等待 HTML 解析完成;可能会中断 DOM 构建
- defer 对模块脚本没有影响
循环加载
有时候可能在项目中不小心写了一个循环加载,但没有正确取到值报错。
通过import引入的是一个动态只读引用,等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。只要保证取值的时候有值就不会报错
js
// a.js
import { b } from "./b.js";
console.log(b);
export let a = "a";
// b.js
import { a } from "./a.js";
console.log(a);
export let b = "b";
<script type="module" src="./a.js"></script>
js
// 修改一下 a.js
// 让变量a在编译时赋值undefined
export var a = "a";
与 CommonJS差异
输出值不同
CommonJS输出的值是被复制后的基本数据类型
,或者浅拷贝后的引用类型地址
,所以输出的是一个新的值
。模块内部数据改变不影响这个值
js
// b.js
let a = "a";
let b = { c: "c" };
module.exports = {
a,
b,
};
setTimeout(() => {
a = 1;
b = 2;
}, 1000);
// a.js
const moduleB = require("./b");
console.log(moduleB);
setTimeout(() => {
console.log(moduleB);
}, 2000);
js
// b.js
let a = "a";
let b = { c: "c" };
module.exports = {
a,
b,
};
setTimeout(() => {
console.log("moduleB:", a, b);
}, 2000);
// a.js
const moduleB = require("./b");
setTimeout(() => {
moduleB.a = 1;
moduleB.b = 2;
}, 1000);
但引用数据类型是浅拷贝,修改引用数据类型的属性,会改变这个输出值
js
// b.js
let b = { c: "c" };
module.exports = { b };
setTimeout(() => {
b.c = 1;
}, 1000);
// a.js
const moduleB = require("./b");
console.log(moduleB);
setTimeout(() => {
console.log(moduleB);
}, 2000);
ES6 模块不一样,通过import引入的是一个动态只读引用,等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值.
动态 是因为ES6 模块不缓存
运行结果,原始值发生变化,import加载的值也会发生变化。不论是基本数据类型还是复杂数据类型。
js
// b.js
export let b = { c: "c" };
export let d = "111";
setTimeout(() => {
b = 2;
d = '222'
}, 1000);
// a.js
import { b, d } from "./b.js";
console.log(b, d);
setTimeout(() => {
console.log(b, d);
}, 2000);
只读,即不允许修改引入变量的值,import的变量是只读的,不论是基本数据类型还是复杂数据类型
js
// b.js
export let b = { c: "c" };
export let d = "111";
// a.js
import { b, d } from "./b.js";
d = 2;
CJS运行时加载,ES6编译输出接口
CommonJS 导出的module.exports
是一个对象,该对象只有在脚本运行完才会生成。而 ES6 模块不是对象,它的对外接口只是一种静态定义,在代码静态解析阶段就会生成。
require()同步加载,import()异步加载
import是在编译时解析,缺点是只能放在模块顶层,如果放在if语句,只有运行时才能知道,所以不能动态加载,优点是可以用来做Tree-shaking
import()可以动态加载模块,可以在条件语句中使用,而且是异步