项目中我们常常会接触到模块,最为典型代表的是esModulecommonjs,在es6之前还有AMD代表的seajs,requirejs,在项目模块加载的文件之间,我们如何选择,比如常常因为某个变量,我们需要动态加载某个文件,因此你想到了require('xxx'),我们也常常会用import方式导入路由组件或者文件,等等。因此我们有必要真正明白如何使用好它,并正确的用好它们。

以下是笔者对于模块理解,希望在实际项目中能给你带来一点思考和帮助。

正文开始...

关于script加载的那几个标识,deferasyncmodule

<!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

deferasync是异步的,而同步加载的 3,在页面中优先执行了。在执行顺序中,我们可以知道标识的defer是等同步的3async的1执行后才最后执行的。

为了证明这点,我们在1.js中加入一段代码

// 1.js
console.log('没有定时器的async', 1);
setTimeout(() => {
  console.log('有定时器的async,异步加载不保证顺序', 1);
}, 1000);

最后我们发现打印的顺序,同步加载3(没有定时器的async)1defer加载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定时器再进入队列,后面再执行。

但是注意,定时器时间短的优先进入队列。

好了,搞明白deferasync的区别了,总结一句,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 };

scripttype="module"后,内部顶层this就不再是window对象了,并且引入的外部路径不能省略后缀,且脚本自动采用严格模式。

es6 的模块与 commonJS 的区别

通常我们在项目中都是es6模块,在nodejs中大量模块代码都是采用commonjs的方式,既然项目里都有用到,那么我们再次回顾下他们有什么区别

参考module 加载实现open in new window中写道

1、commonjs输出的是一个值的拷贝,而es6模块输出的是一个只读值的引用

2、commonjs是在运行时加载,而es6模块是在编译时输出接口

3、commonjsrequire()是同步加载,而es6import xx from xxx是异步加载,有一个独立的模块解析阶段

另外我们还要知道commonjsrequire引入的是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.sum1,那么证明B引入的utilsA是一样的。

如果我输出的仅仅是一个构造函数呢?看下面

// 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>

我们会发现Anew cutils().add()Bnew 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 源码解读open in new window

另外,我们通常项目里可能会见到下面的代码

// 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中引入的三种模式deferasyncmodule的不同。

2、在module下,浏览器支持es模块,import方式加载模块

3、commonjs是在运行时同步加载的,并且导出的值是值拷贝,无法做到像esMoule一样做静态分析,而且esModule导出是值是值引用。

4、esModule导出的对象,多个文件引用不会重复实例化,多个文件引入的对象是同一份对象。

5、commonjs加载原理,优先会从缓存中获取,然后再从loader加载模块

6、本文示例code exampleopen in new window

扫二维码,关注公众号
专注前端技术,分享web技术
加作者微信
扫二维码 备注 【加群】
教你聊天恋爱,助力脱单
微信扫小程序二维码,助你寻他/她
专注前端技术,分享Web技术
微信扫小程序二维码,一起学习,一起进步
前端面试大全
海量前端面试经典题,助力前端面试