阅读 188

ESmodule和CommonJS模块原理你了解多少?

深入了解ESmodule和CommonJS

GZHDEV: 2022/1/16

1. 两种的模块的使用分析

我们都知道在过去很长一段时间里, js都是没有语法层面的模块机制的, 对于开发大型项目模块的管理就非常的不方便. 于是社区就诞生了AMDUMDCDM等模块规范, nodeJS的出现使得js可以在服务端有所作为. 同时也带来了CommonJS模块规范. 但是一直没有语法层面的模块规范. 对js的发展局限很大. 于是2015年我们迎来了ES6. 其带来了语法层面的ESModule模块规范. 由此我们可以发现模块规范真的很重要, 那么彻底弄懂CommonJS模块规范和EsModule规范就是进阶高级前端的必经之路了.

关于CommonJS及ESModule的使用方式这里就不多说了, 本文主要对这两种模块规范的特性及原理进行深入理解.

首先本文主要围绕以下几个方面进行讨论学习

  1. 重复导入同一模块如何处理

  2. 静态导入(ESModule)及动态导入(CommonJS)

  3. 同步导入(CommonJS)及异步导入(ESModule)

  4. 导入一个模块程序在背后做了什么工作

  5. 两种模块机制是如何避免循环引用问题的

(1) 重复导入同一模块的处理

答案: 只有第一次导入有效

/* ESModule */
// xx.js
console.log('this is ESModule!');
export const a = 123;
export const b = 321;
// index.js
import { a } from './xx.js';
import { b } from './xx.js';
// this is ESModule!

/* CommonJS */
// yy.js
console.log('this is CommmonJS!');
module.exports = {
 a: 123,
 b: 321,
}
// index.js
const { a } = require('./yy.js');
const { b } = require('./yy.js');
// this is CommonJS!复制代码

上面的代码中我们分别引入了两次相同的模块文件, 无论是ESModule还是CommonJS都只输出了一次日志, 这说明我们的模块文件xx.jsyy.js都只加载了一次, 但是最终a, b的值我们都可以正常输出, 这是怎么做到的我们下文会详细介绍.

(2) 动态导入及静态导入

答案: CommonJS是默认动态导入的, 而ESModule是静态导入的

这个理解起来比较简单, 动态导入就是可以在运行时期间进行文件的导入, 而静态导入是指在编译阶段(JIT)进行导入. 举个例子.

// ESModule
import { moduleA } from './xx.js';

// CommonJS
if (condition === true) {
 const moduleB = require('./yy.js');
}复制代码

如上面的两个使用例子, ESModule的import ... from ... 语法值能在顶级作用域中进行使用, 而CommonsJS的require导入语法可以在任意作用域中进行导入使用(因为: require的本质就是一个函数).

从上面这个简单的使用例子中看, 我们可能会有这样的疑问. ESModule只能在顶级作用域中导入相比于CommonJS看似失去了更大的自由度和灵活性. 确实是这样的, 所以在ES2020的提案中增加了import()语法, 支持像require()一样在任意作用域中导入模块. 不过话说回来, 静态导入有什么好处呢?

静态导入可以提高程序的执行效率, 还有现在很多ESModule的组件库都支持按需引入. 这些功能的实现都依赖于ESModule的静态引入, 可以支持编译时进行依赖的静态分析.

这里来简单分析下按需引入的基本原理

// lib.js
export const getRandomInt = () => ~~(Math.random() * 99999);
export const getNetworkStatus = () => navigator.onLine;

// index.js
import { getRandomInt } from './lib.js';复制代码

如上例子, lib.js模块导出了两个函数, 但是打包index.js的时候, 可以分析出我们只引用了getRandomInt函数, 而没用到的getNetworkStatus就可以不到打包到产物中, 以实现Tree sharking功能. 从而减少产物的体积.

(3) 同步导入及异步导入

答案: CommonJS是同步导入、ESModule是异步导入

首先CommonJS模块规范主要用于NodeJS, 而NodeJS是js的服务端运行时, 所以大部分使用场景都是导入的本地文件, 读取文地文件的速度是非常快的, 所以同步的方式加载也是可以接受的. 但是ESModule的使用场景是需要考虑浏览器端的, 而浏览器中请求远程的文件时间是无法确定的, 如果设计成同步加载将会阻塞页面的渲染.

看个例子, ESModule是支持直接导入网络内容的

<script type="module">
import dayjs from 'https://cdn.skypack.dev/dayjs';

console.log(dayjs(Date.now()).format('YYYY-MM-DD'));
</script>复制代码

上面的例子, 我们直接通过网络引入了一个day.js库, 并且获取了当前的时间格式化字符串.

(4) 模块化的工作原理

说了这么多我们来看下CommonJS的工作原理吧. 首先从使用中我们可以确定require的本质就是个函数, 如果对NodeJS比较熟悉的朋友都知道, 在NodeJS中我们是可以直接在代码中使用__dirname__filename常量的, 而根据我们对js的理解, 这两个值应该是全局变量. 而在ES6出来之前JS除了全局作用域, 还有个函数作用域. 也就是说函数体内是存在独立作用域的. CommonJS模块化的实现也是基于函数作用域, 每个文件都会被NodeJS包裹一个外层函数.

((exports, module, require, __dirname, __filename) => {
 /* 我们真实书写的js代码 */
 const path = require('path');

 console.log(path.resolive(__dirname, './dist'));

 const sum = (a, b) => a + b;

 module.exports = { sum };
 /* 我们真实书写的js代码 */
});复制代码

从上面的代码中我们可以看到, 我们写的代码会被NodeJS包裹一层函数, 实现变量的隔离. 而包裹只有模块的加载流程是怎样的呢? 我们继续往下看.

// id为我们需要导入的模块的id
function require(id) {
 // 1. 从Module缓存中查找require的模块是否已经加载过
 const cachedModule = Module._cache[id];

 // 2. 如果已经加载过了, 就别去加载了, 直接从缓存中读取(这就是前面我们提到重复导入只执行第一次导入的原因)
 if (cachedModule) {
  return cachedModule.exports;
 }

 // 3. 如果没有导入过, 就创建一个module对象
 const module = {
  exports: {}, // 用于后续存放id模块导出的内容
  loaded: false, // 标志该模块是否已经加载
  ... // 其他内容
 };

 // 4. 缓存上面创建的module对象
 Module._cache[id] = module;

 // 5. 加载真实的模块文件, 并赋值给上文创建的module.exports
 runInThisContext(wrapper('/* 我们的javascript代码 */'))(
  // 从这我们可以得知exports其实是module.exports的引用
  module.exports, 
  module, 
  require, 
  __dirname, 
  __filename
 );

 // 标志模块id加载成功
 module.loaded = true;

 // 返回结果
 return module.exports;
}复制代码

看完了CommonJS的加载过程, 我们接着分析下循环引用的例子来加深下对CommonJS加载流程的理解.

(5) 如何处理循环引用

首先明确下什么叫循环引用, 即A模块引用了B模块、同时B模块又引用了A模块.

// module-a.js
const { x } = require('./module-b');

console.log('this is module-a.js');
console.log('x: ', x);

module.exports = {
 y: 666,
};

// module-b.js
const { y } = require('./module-a');

console.log('this is module-b.js');
console.log('y: ', y);

module.exports = {
 x: 888,
};

// main.js
require('module-a');复制代码

这是一个循环引用的例子, 我们来看下会输出什么结果.

node ./main.js
------------------------
this is module-b.js
y:  undefined
this is module-a.js
x:  888
(node:28640) Warning: Accessing non-existent property 'y' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)复制代码

执行main.js会得到上面的结果, 我们来分析下执行流程

  1. require('module-a') 加载module-a

    1. 第一行又加载了module-b

    2. 查询是否已经缓存 --> 未缓存

    3. 创建module对象moduleB = { exports: {}, loaded: false, ... }

    4. 真实执行module-b.js文件, 并执行module-b.js文件

    5. 导出{ x: 888 }, 此时moduleB = { exports: { x: 888 }, loaded: true }

    6. 第一行加载module-a

    7. 查询是否已经缓存module-a --> 已缓存

    8. 直接使用, 但是此时module.exports还是{}, 所以输出结果 this is module-b.js y: undefined

    9. 查询是否已经缓存module-a --> 未缓存

    10. 创建module对象, moduleA = { exports: {}, loaded: false, ... }

    11. 缓存Module._cache[id] = module

    12. 真实的去加载执行module-a.js文件, 并执行module-a.js文件

    13. 继续执行module-a文件剩余内容 this is module-a.js x: 888

  2. 程序执行结束

2. 总结

好了今天关于CommonJS及ESModule的深入了解到这里就结束了, 这里顺便提一嘴, CommonJS导出的值是可以改变的, 而ESModule导出的值是只读的. 这个从上文分析的CommonJS加载原理可以得出结论, CommonJS导出的就是一个对象值的拷贝. 但是在使用中不建议直接修改导入的值.

最后, 如果你有缘看到这篇文章, 觉得对你有点帮助可以关注我微信公众号GZHDEV查看更多文章, 一起学习交流哦❤️


作者:GZHDEV
链接:https://juejin.cn/post/7053823393539293191


文章分类
代码人生
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐