深入了解ESmodule和CommonJS
GZHDEV: 2022/1/16
1. 两种的模块的使用分析
我们都知道在过去很长一段时间里, js都是没有语法层面的模块机制的, 对于开发大型项目模块的管理就非常的不方便. 于是社区就诞生了AMD、UMD、CDM等模块规范, nodeJS的出现使得js可以在服务端有所作为. 同时也带来了CommonJS模块规范. 但是一直没有语法层面的模块规范. 对js的发展局限很大. 于是2015年我们迎来了ES6. 其带来了语法层面的ESModule模块规范. 由此我们可以发现模块规范真的很重要, 那么彻底弄懂CommonJS模块规范和EsModule规范就是进阶高级前端的必经之路了.
关于CommonJS及ESModule的使用方式这里就不多说了, 本文主要对这两种模块规范的特性及原理进行深入理解.
首先本文主要围绕以下几个方面进行讨论学习
- 重复导入同一模块如何处理
- 静态导入(ESModule)及动态导入(CommonJS)
- 同步导入(CommonJS)及异步导入(ESModule)
- 导入一个模块程序在背后做了什么工作
- 两种模块机制是如何避免循环引用问题的
(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.js、yy.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会得到上面的结果, 我们来分析下执行流程
-
require('module-a')加载module-a-
查询是否已经缓存
module-a--> 未缓存 -
创建module对象,
moduleA = { exports: {}, loaded: false, ... } -
缓存
Module._cache[id] = module -
真实的去加载执行
module-a.js文件, 并执行module-a.js文件-
第一行又加载了
module-b -
查询是否已经缓存 --> 未缓存
-
创建module对象
moduleB = { exports: {}, loaded: false, ... } -
真实执行
module-b.js文件, 并执行module-b.js文件- 第一行加载
module-a - 查询是否已经缓存
module-a--> 已缓存 - 直接使用, 但是此时module.exports还是{}, 所以输出结果 this is module-b.js y: undefined
- 第一行加载
-
导出{ x: 888 }, 此时
moduleB = { exports: { x: 888 }, loaded: true }
-
-
继续执行
module-a文件剩余内容 this is module-a.js x: 888
-
-
程序执行结束
2. 总结
好了今天关于CommonJS及ESModule的深入了解到这里就结束了, 这里顺便提一嘴, CommonJS导出的值是可以改变的, 而ESModule导出的值是只读的. 这个从上文分析的CommonJS加载原理可以得出结论, CommonJS导出的就是一个对象值的拷贝. 但是在使用中不建议直接修改导入的值.
最后, 如果你有缘看到这篇文章, 觉得对你有点帮助可以关注我微信公众号GZHDEV查看更多文章, 一起学习交流哦❤️