项目中我们常常会接触到模块,最为典型代表的是
esModule与commonjs,在es6之前还有AMD代表的seajs,requirejs,在项目模块加载的文件之间,我们如何选择,比如常常因为某个变量,我们需要动态加载某个文件,因此你想到了require('xxx'),我们也常常会用import方式导入路由组件或者文件,等等。因此我们有必要真正明白如何使用好它,并正确的用好它们。
以下是笔者对于模块理解,希望在实际项目中能给你带来一点思考和帮助。
正文开始...
关于script加载的那几个标识,defer、async、module
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>module</title>
</head>
<body>
<div id="app"></div>
<script src="./js/2.js" defer></script>
<script src="./js/1.js" async></script>
<script src="./js/3.js">
console.log('同步加载', 3)
</script>
</body>
</html>
// js/2.js
console.log('defer加载', 2);
// js/1.js
console.log('async异步加载不保证顺序', 1);
// js/3.js
console.log('同步加载', 3);
我们会发现执行顺序是3,1,2
defer与async是异步的,而同步加载的 3,在页面中优先执行了。在执行顺序中,我们可以知道标识的defer是等同步的3与async的1执行后才最后执行的。
为了证明这点,我们在1.js中加入一段代码
// 1.js
console.log('没有定时器的async', 1);
setTimeout(() => {
console.log('有定时器的async,异步加载不保证顺序', 1);
}, 1000);
最后我们发现打印的顺序,同步加载3,(没有定时器的async)1、defer加载2、有定时器的async,异步加载不保证顺序1
因为1.js加入了一段定时器,在事件循环中,它是一段宏任务,我们知道在浏览器处理事件循环中,同步任务>异步任务[微任务 promise>宏任务 setTimeout,事件等],在2.js中用defer标识了自己是异步,但是1.js中有定时器,2.js实际上是等了1.js执行完了,再执行的。
如果我在2.js中也加入定时器呢
console.log('没有定时器的defer加载', 2);
setTimeout(() => {
console.log('有定时器的defer加载', 2);
}, 1000);
我们会发现结果依然是如此
3.js 同步加载 3
1.js 没有定时器的async 1
2.js 没有定时器的defer加载 2
1.js 有定时器的async,异步加载不保证顺序 1
2.js 有定时器的defer加载 2
不难发现 defer中的定时器脚本虽然在async标识的脚本前面,但是,注意两个定时器实际上是会有前后顺序的,跟脚本的顺序没有关系
两个任务都是定时器,都是宏任务,在脚本的执行顺序中第一个定时器会先放到队列任务中,第二个定时器也会放到队列中,遵循先进先出,第一个宏任务(1.js 有定时器)先进队列,然后2.js定时器再进入队列,后面再执行。
但是注意,定时器时间短的优先进入队列。
好了,搞明白defer与async的区别了,总结一句,defer会等其他脚本加载完了再执行,而async是异步的,并不一定是在前面的就先执行。
module
接下来我们来看看module
module是浏览器直接加载es6,我们注意到加载module中有哪些特性?
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>module</title>
</head>
<body>
<div id="app"></div>
<script src="./js/2.js" defer></script>
<script src="./js/1.js" async></script>
<script src="./js/3.js"></script>
<script type="module">
import userInfo, { cityList } from './js/4.js';
console.log(userInfo);
// { name: 'Maic', age: 18}
console.log(cityList);
console.log(this); // undefined
/*
[ {
value: '北京',
code: 1
},
{
value: '上海',
code: 0
}
]
*/
</script>
</body>
</html>
在js/4.js中,我们可以看到可以用esModule的方式输出
export default {
name: 'Maic',
age: 18
};
const cityList = [
{
value: '北京',
code: 1
},
{
value: '上海',
code: 0
}
];
export { cityList };
在script用type="module"后,内部顶层this就不再是window对象了,并且引入的外部路径不能省略后缀,且脚本自动采用严格模式。
es6 的模块与 commonJS 的区别
通常我们在项目中都是es6模块,在nodejs中大量模块代码都是采用commonjs的方式,既然项目里都有用到,那么我们再次回顾下他们有什么区别
参考module 加载实现中写道
1、commonjs输出的是一个值的拷贝,而es6模块输出的是一个只读值的引用
2、commonjs是在运行时加载,而es6模块是在编译时输出接口
3、commonjs的require()是同步加载,而es6的import xx from xxx是异步加载,有一个独立的模块解析阶段
另外我们还要知道commonjs的require引入的是module.exports出来的对象或者属性。而es6模块不是对象,它对外暴露的接口是一种静态定义,在代码解析阶段就已经完成。
举个例子,commonjs
// 5.js
const userInfo = {
name: 'Maic',
age: 18
};
let count = 1;
const countAge = () => {
userInfo.age += 1;
count++;
console.log(`count:${count}`);
};
module.exports = {
userInfo,
countAge,
count
};
// 6.js
const { userInfo, countAge, count } = require('./5.js');
console.log(userInfo); // {name: 'Maic', age: 18}
countAge(); // count:2
console.log(userInfo); // {name: 'Maic', age: 19}
console.log(count); // 1
node 6.js 从打印里可以看出,一个原始的输出count,外部调用countAge并不会影响count输出的值,但是在内部countAge打印的仍是当前++后的值。
如果是es6模块,我们可以发现
const userInfo = {
name: 'Maic',
age: 18
};
let count = 1;
const countAge = () => {
userInfo.age += 1;
count++;
console.log('count', count);
};
export { userInfo, countAge, count };
在页面中引入,我们可以发现
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>module</title>
</head>
<body>
<div id="app"></div>
...
<script type="module">
import userInfo, { cityList } from './js/4.js';
import { userInfo as nuseInfo, count, countAge } from './js/7.js';
console.log(userInfo, cityList);
console.log(this);
// { name: 'Maic', age: 18}
countAge();
console.log(nuseInfo, count);
// {name: 'Maic', age: 19} 2
</script>
</body>
</html>
我们发现count导出后的值是实时的改变了。因为它是一个值的引用。
接下来有疑问,比如我有一个工具函数
function Utils() {
this.sum = 0;
this.add = function () {
this.sum += 1;
};
this.sub = function () {
this.sum-=1;
}
this.show = function () {
console.log(this.sum);
};
}
export new Utils;
这工具函数,在很多地方会有引用,比如A,B,C...等页面都会引入它,那么它会每次都会实例化Utils?
接下来我们实验下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>module</title>
</head>
<body>
<div id="app"></div>
...
<script type="module">
// A
import { utils } from './js/7.js';
utils.add();
console.log(utils);
</script>
<script type="module">
// B
import { utils } from './js/7.js';
console.log('sum=', utils.sum);
console.log(utils);
</script>
</body>
</html>
// 7.js
const userInfo = {
name: 'Maic',
age: 18
};
let count = 1;
const countAge = () => {
userInfo.age += 1;
count++;
console.log('count', count);
};
function Utils() {
this.sum = 0;
this.add = function () {
this.sum += 1;
};
this.sub = function () {
this.sum -= 1;
};
this.show = function () {
console.log(this.sum);
};
}
const utils = new Utils();
export { userInfo, countAge, count, utils };
我们会发现在A模块里调用utils.add()后,在B中打印utils.sum是1,那么证明B引入的utils与A是一样的。
如果我输出的仅仅是一个构造函数呢?看下面
// 7.js
...
function Utils() {
this.sum = 0;
this.add = function () {
this.sum += 1;
};
this.sub = function () {
this.sum-=1;
}
this.show = function () {
console.log(this.sum);
};
}
const utils = new Utils;
const cutils = Utils;
export {
userInfo,
countAge,
count,
utils,
cutils
};
页面同样引入
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>module</title>
</head>
<body>
<div id="app"></div>
...
<script type="module">
// A
import { utils, cutils } from './js/7.js';
countAge();
console.log(nuseInfo, count);
utils.add();
new cutils().add();
console.log(utils);
</script>
<script type="module">
// B
import { utils, cutils } from './js/7.js';
console.log('sum=', utils.sum);
console.log(utils);
console.log('sum2=', new cutils().sum); // 0
</script>
</body>
</html>
我们会发现A中new cutils().add()在B中new cutils().sum)访问,依然是0,所以当模块中导出的是一个构造函数时,每一个模块对应new 导出的构造函数都是重新开辟了一个新的内存空间。
因此可以得出结论,在es6模块中直接导出实例化对象的性能开销比直接导出构造函数更小些。
CommonJS 模块的加载原理
我们初步了解下CommonJS的加载
// A.js
module.exports = {
a: 1
};
// B.js
const { a } = require('./A.js');
console.log(a); // 1
在执行require时,实际上内部会在内存中生成一个对象,require是一个nodejs环境提供的一个全局函数。
{
id: '...',
exports: { ... },
loaded: true,
...
}
优先会从缓存中取值,缓存中没有就直接从exports中取值。具体更多可以参考这篇文章require 源码解读
另外,我们通常项目里可能会见到下面的代码
// A
exports.a = 1;
exports.b = 2;
// B
const a = require('./A.js');
console.log(a); // {a:1, b:2}
以上与下面等价
// A.js
module.exports = {
a: 1,
b: 2
};
// B.js
const a = require('./A.js');
console.log(a); // {a:1,b:2}
所以我们可以看出require实际上获取就是module.exports输出{}的一个值的拷贝。
当exports.xxx时,实际上require获取的值结果依旧是module.exports值的拷贝。也就是说,在运行时,当使用exports.xx时实际上会中间悄悄转换成module.exports了。
总结
1、比较script``type中引入的三种模式defer、async、module的不同。
2、在module下,浏览器支持es模块,import方式加载模块
3、commonjs是在运行时同步加载的,并且导出的值是值拷贝,无法做到像esMoule一样做静态分析,而且esModule导出是值是值引用。
4、esModule导出的对象,多个文件引用不会重复实例化,多个文件引入的对象是同一份对象。
5、commonjs加载原理,优先会从缓存中获取,然后再从loader加载模块
6、本文示例code example




