ESmodule和CommonJS模块原理你了解多少?
深入了解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-b
查询是否已经缓存 --> 未缓存
创建module对象
moduleB = { exports: {}, loaded: false, ... }
真实执行
module-b.js
文件, 并执行module-b.js
文件导出{ x: 888 }, 此时
moduleB = { exports: { x: 888 }, loaded: true }
第一行加载
module-a
查询是否已经缓存
module-a
--> 已缓存直接使用, 但是此时module.exports还是{}, 所以输出结果 this is module-b.js y: undefined
查询是否已经缓存
module-a
--> 未缓存创建module对象,
moduleA = { exports: {}, loaded: false, ... }
缓存
Module._cache[id] = module
真实的去加载执行
module-a.js
文件, 并执行module-a.js
文件继续执行
module-a
文件剩余内容 this is module-a.js x: 888程序执行结束
2. 总结
好了今天关于CommonJS及ESModule的深入了解到这里就结束了, 这里顺便提一嘴, CommonJS导出的值是可以改变的, 而ESModule导出的值是只读的. 这个从上文分析的CommonJS加载原理可以得出结论, CommonJS导出的就是一个对象值的拷贝. 但是在使用中不建议直接修改导入的值.
最后, 如果你有缘看到这篇文章, 觉得对你有点帮助可以关注我微信公众号GZHDEV
查看更多文章, 一起学习交流哦❤️
作者:GZHDEV
链接:https://juejin.cn/post/7053823393539293191