项目中我们常常会接触到模块,最为典型代表的是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>
代码语言:javascript复制// 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);
我们会发现结果依然是如此
代码语言:javascript复制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]中写道
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
}
在页面中引入,我们可以发现
代码语言:javascript复制<!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
导出后的值是实时的改变了。因为它是一个值的引用。
接下来有疑问,比如我有一个工具函数
代码语言:javascript复制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
?
接下来我们实验下
代码语言:javascript复制<!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>
代码语言:javascript复制// 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
是一样的。
如果我输出的仅仅是一个构造函数呢?看下面
代码语言:javascript复制// 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
};
页面同样引入
代码语言:javascript复制<!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源码解读[2]
另外,我们通常项目里可能会见到下面的代码
代码语言:javascript复制// A
exports.a = 1;
exports.b = 2;
// B
const a = require('./A.js');
console.log(a)// {a:1, b:2}
以上与下面等价
代码语言:javascript复制// 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
加载模块
参考资料
[1] module加载实现: https://www.wangdoc.com/es6/module-loader.html
[2] require源码解读: https://www.ruanyifeng.com/blog/2015/05/require.html