项目中我们常常会接触到模块,最为典型代表的是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]中写道
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源码解读[2]
另外,我们通常项目里可能会见到下面的代码
// 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
加载模块