项目中我们常常会接触到模块,最为典型代表的是
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