highlight: a11y-dark
theme: smartblue
NodeJS目前有两个零碎:一套是CommonJS(简称CJS),另一套是ECMAScript modules(简称ESM); 本篇内容次要三个话题:
- CommonJS的外部原理
- NodeJS平台的ESM模块零碎
- CommonJS与ESM的区别;如何在两套零碎进行转换
首先讲讲为什么要有模块零碎
为什么要有模块零碎
一门好的语言肯定要有模块零碎,因为它能为咱们解决工程中遇到的根本需要
- 把性能进行模块拆分,可能让代码更具备条理,更容易了解,可能让咱们独自开发并测试各个子模块的性能
- 可能对性能进行封装,而后再其余模块可能间接引入应用,进步复用性
- 实现封装:只须要对外提供简略的输入输出文档,外部实现可能对外屏蔽,缩小了解老本
- 治理依赖关系:好的模块零碎可能让开发者依据现有的第三方模块,轻松的构建其余模块。另外模块零碎可能让用户简略引入本人想要的模块,并且把依赖链上的模块进行引入
刚开始的时候,JavaScript并没有好的模块零碎,页面次要是通过多个script标签引入不同的资源。然而随着零碎的逐步复杂化,传统的script标签模式不能满足业务需要,所以才开始打算定义一套模块零碎,有AMD,UMD等等
NodeJS是运行在后盾的一门服务端语言,绝对于浏览器的html,不足script标签来引入文件,齐全依赖本地文件系统的js文件。于是NodeJS依照CommonJS标准实现了一套模块零碎
2015年ES2015标准公布,到了这个时候,JS才对模块零碎有了正式规范,依照这种规范打造的模块零碎叫作ESM零碎,他让浏览器和服务端在模块的治理形式上更加统一
CommonJS模块
CommonJS布局中有两个根本理念:
- 用户能够通过requeire函数,引入本地文件系统中的某个模块
通过exports和module.exports两个非凡变量,对外公布能力
模块加载器
上面来简略实现一个简略的模块加载器
首先是加载模块内容的函数,咱们把这个函数放在公有作用域里边防止净化全局环境,而后eval运行该函数function loadModule(filname, module, require) {const wrappedSrc = ` (function (module, exports, require) { ${fs.readFileSync(filename, 'utf-8')} })(module, module.exports, require)`eval(wrappedSrc)}
在代码中咱们通过同步办法
readFileSync
来读取了模块内容。一般来说,在调用文件系统API时,不应该应用同步版本,然而此处的确是应用了这个形式,Commonjs通过同步操作,来保障多个模块可能装置失常的依赖程序失去引入
当初在实现require
函数function require(moduleName) {const id = require.resolve(moduleName);if (require.cache[id]) { return require.cache[id].exports}// 模块的元数据const module = { exports: {}, id,}require.cache[id] = module;loadModule(id, module, require);// 返回导出的变量return module.exports}require.cache = {};require.resolve = (moduleName) => {// 依据ModuleName解析残缺的模块ID}
下面实现了一个简略的
require
函数,这个自制的模块零碎有几个不走须要解释- 输出模块的ModuleName当前,首先要解析出模块的残缺门路(如何解析前面会讲到),而后把这个后果保留在id的变量之中
- 如果该模块曾经被加载过了,会立即返回缓存中的后果
- 如果该模板没有被加载过,那么就配置一套环境。具体来说,先创立一个
module
变量,让他蕴含一个exports的属性。这个对象的内容,将由模块在导出API时所应用的的那些代码来填充 - 将module对象缓存起来
- 执行
loadModule
函数,传入刚建设的module对象,通过函数将另外一个模块的内容进行挂载 返回另外模块的导出内容
模块解析算法
在后面提到解析模块的残缺门路,咱们通过传入模块名,模块解析函数可能返回模块的对应的残缺门路,接下来通过门路来加载对应模块的代码,并用这个门路来标识模块的身份。
resolve
函数所用的解析函数次要是解决以下三种状况- 要加载的是不是文件模块? 如果moduleName以/结尾,那就视为一条绝对路径,加载时只须要装置该门路原样返回即可。如果moduleName以
./
结尾,那么就当成一条相对路径,这样相对路径是从申请载入该模块的这个目录算起的 - 要加载的是不是外围模块 如果
moduleName
不是以/
或者./
结尾,那么算法会首先尝试在NodeJS
的外围模块去寻找 要加载的是不是包模块 如果没有找到
moduleName
匹配的外围模块,那就从收回加载申请的这个模块开始,逐层向上搜查名为node_modules
的陌路,看看里边有没有可能与moduleName
匹配的模块,如果有就载入该模块。如果还没有,就沿着目录持续线上走,并在相应的node_modules
目录中搜查,始终到文件系统的根目录
通过这种形式就能实现两个模块依赖不同版本的包,然而依然可能失常加载
例如以下目录构造:myApp - index.js - node_modules - depA - index.js - depB - index.js - node_modules - depA - depC - index.js - node_modules - depA
在上述例子中尽管
myApp
、depB
、depC
都依赖了depA
然而加载进来的的确不同的模块。比方:- 在
/myApp/index.js
中,加载的起源是/myApp/node_modules/depA
- 在
/myApp/node_modules/depB/index.js
, 加载的是/myApp/node_modules/depB/node_modules/depA
在
/myApp/node_modules/depC/index.js
, 加载的是/myApp/node_modules/depC/node_modules/depA
NodeJs之所以可能把依赖关系治理好,就因为它背地有模块解析算法这样一个外围的局部,可能治理上千个包,而不会发生冲突或呈现版本不兼容的问题循环依赖
很多人感觉循环依赖是实践上的设计问题,然而这种问题很可能呈现在理论我的项目中,所以应该晓得CommonJS如何解决这种状况的。是看之前实现的require函数就可能意识到其中的危险。上面通过一个例子来解说
有个mian.js的模块,须要依赖了a.js和b.js两个模块,同时a.js须要依赖b.js,然而b.js又反过来依赖了a.js,这就造成了循环依赖,上面是源代码:// a.jsexports.loaded = false;const b = require('./b');module.exports = {b,loaded: true}// b.jsexports.loaded = false;const a = require('./a')module.exports = {a,loaded: false}// main.jsconst a = require('./a');const b = require('./b');console.log('A ->', JSON.stringify(a))console.log('B ->', JSON.stringify(b))
运行
main.js
会失去以下后果
从后果能够看到,CommonJS在循环依赖所引发的危险。b模块导入a模块的时候,内容并不是残缺的,具体来说他只是反馈了b.js模块申请a.js
模块时,该模块所处的状态,而无奈反馈a.js
模块最终加载结束的一个状态
上面用一个示例图来示意这个过程
上面是具体的流程解释
- 整个流程从main.js开始,这个模块一开始开始导入a.js模块
- a.js首先要做的,是导出一个名为loaded的值,并把该值设为false
- a.js模块要求导入b.js模块
- 与a.js相似,b.js首先也是导出loaded为false的变量
- b.js继续执行,须要导入a.js
- 因为零碎曾经开始解决a.js模块了,所以b.js会把a.js曾经导出的内容,立刻复制到本模块中
- b.js会把本人导出的loaded值改为false
- 因为b曾经执行实现,控制权会回到a.js,他会把b.js模块的状态拷贝一份
- a.js继续执行,批改导出值loaded为true
- 最初就执行main.js
下面能够看到因为是同步执行,导致b.js导入的a.js模块并不是残缺的,无奈反馈b.js的最终应有的状态。
在下面例子中能够看到,循环依赖所产生的的后果,这对大型项目来说,更加重大。
应用办法就比较简单了,篇幅无限就不在这篇文章中进行解说了
ESM
ESM是ECMAScript 2015标准的一部分,这份标准给Javascript制订了对立的模块零碎,以适应各种执行环境。ESM和CommonJS的一项重要区别,在于在ES模块是动态的,也就是说引入模块的语句必须要写在最顶层。另外受援用的模块只能应用常量字符串,不能依赖须要运行期动静求值的表达式。
比方咱们不能通过上面形式来引入ES模块
if (condition) { import module1 from 'module1'} else { import module2 from 'module2'}
而CommonJS可能依据条件导入不同的模块
let module = nullif (condition) { module = require("module1")} else { module = require("module2")}
看起来绝对CommonJS更严格了一些,然而正是因为这种动态引入机制,咱们可能对依赖关系进行动态剖析,去除不会执行的逻辑,这个就叫tree-shaking
模块加载过程
要想了解ESM零碎的运作原理,以及它解决循环依赖的关系,咱们须要明确零碎是如何解析并执行Javascript代码
载入模块的各个阶段
解释器的指标是构建一张图来形容所要载入的模块之间的依赖关系,这种图也叫做依赖图。
解释器正是通过这种依赖图,来判断模块的依赖关系,并决定本人应该依照什么程序去执行代码。例如咱们须要执行某个js文件,那么解释器会从入口开始,寻找所有的import语句,如果在寻找过程中又遇到了import语句,那就会以深度优先的形式递归,直到所有的代码都解析结束。
这个过程可细分为三个过程:
- 分析: 找到所有的引入语句,并递归从相干文件中加载每个模块的内容
- 实例化: 针对某个导出的实体,在内存中保留一个带名称的引入,但暂且不给他赋值。此时还要依据import和export关键字建设依赖关系,此时不执行js代码
执行:到了这个阶段,NodeJS开始执行代码,这可能让理论导出的实体,可能取得理论的取值
在CommonJS中,是边解析依赖,一边执行文件。所以当看到require的时候,就代表后面的代码曾经执行实现。因为require操作不肯定要在文件结尾,而是能够呈现在工作中央
然而ESM零碎不同,这三个阶段是离开的,它必须先把依赖图残缺的结构进去,而后才开始执行代码循环依赖
在之前提到的CommonJS循环依赖的例子,应用ESM的形式进行革新
// a.jsimport * as bModule from './b.js';export let loaded = false;export const b = bModule;loaded = true;// b.jsimport * as aModule from './b.js';export let loaded = false;export const a = aModule;loaded = true;// main.jsimport * as a from './a.js';import * as b from './b.js';console.log("A =>", a)console.log("B =>", b)
须要留神的是这里不能是用
JSON.strinfy
办法,因为这里应用了循环依赖
在下面执行后果中能够看到a.js和b.js都可能残缺的察看到对方,不同与CommonJS,有模块拿到的状态是不残缺的状态。
分析
上面来解析一下其中的过程:
已上图为例:
- 从main.js开始分析,首先发现了一条import语句,而后进入a.js
- 从a.js开始执行,发现了另外一条import语句,执行b.js
- 在b.js开始执行,发现了一条import语句,引入a.js,因为之前a.js曾经被依赖过,咱们不会再去执行这条门路
b.js持续往下执行,发现没有别的import语句。回到a.js之后,也发现没有其余的import语句,而后间接回到main.js入口文件。持续往下执行,发现要求引入b.js,然而这个模块之前被拜访过了,因而这条门路不会执行
通过深度优先的形式,模块依赖关系图曾经造成一个树状图,而后解释器在通过这个依赖图执行代码
在这个阶段,解释器要从入口点开始,开始剖析各模块之间的依赖关系。这个阶段解释器只关怀零碎的import语句,并把这些语句想要引入的模块给加载进来,并以深度优先的形式摸索依赖图。依照这种办法遍历依赖关系,失去一种树状的构造实例化
在这一阶段,解释器会从树状构造的底部开始,逐步向顶部走。没走到一个模块,它就会寻找该模块所要导出的所有属性,并在内存中构建一张隐射表,以寄存此模块所要导出的属性名称与该属性行将领有的取值
如下图所示:
从上图能够看到,模块是依照什么程序来实例化的
- 解释器首先从b.js模块开始,它发现这个模块要导出loaded和a
- 而后解释器又剖析a.js模块,他发现这个模块要导出loaded和b
- 最初剖析main.js模块,他发现这个模块不导出任何性能
- 实例化阶段所结构的这套exports隐射图,只记录导出的名称与该名称行将领有的值之间关系,至于这个值自身,既不在本阶段初始化。
走完上述流程后,解析器还须要在执行一遍,这次他会把各模块所导出的名称与引入这些的那些模块关联起来,如下图所示:
这次的步骤为:
- 模块b.js要与模块b.js所导出的内容相连接,这条链接叫作aModule
- 模块a.js要与模块a.js所导出的内容相连接,这条链接叫作bModule
- 最初模块main.js要与模块b.js所导出的内容相连接
在这个阶段,所有的值并没有初始化,咱们只是建设相应的链接,可能让这些链接指向相应的值,至于值自身,须要等到下一阶段能力确定
执行
这这个阶段,零碎终于要执行每份文件里边的代码。他依照后序的深度优先程序,由下而上的拜访最后那张依赖图,并一一执行拜访到的文件。在本例中,main.js会放在最初执行。这种执行后果保障了,程序在运行主逻辑的时候,各模块所导出的那些值,全副失去了初始化
以上图具体步骤为:
- 从b.js开始执行。首先要执行的这行代码,会把该模块所导出的loaded初始化为false
- 接下来往下执行,会把aModule复制给a,这个时候a拿到的是一个援用值,这个值就是a.js模块
- 而后设置loaded的值为true。这个时候b模块所有的值都全副确定了下来
- 当初执行a.js。首先初始化导出值loaded为false
- 接下来将该模块导出的b属性值得到初始值,这个值是bModule的援用
最初把loaded的值改为true。到了这里,咱们就把a.js模块零碎导出的这些属性所对应的值,最终确定了下来
走完这些步骤后,零碎就能够正式执行main.js文件,这个时候,各模块所导出的属性全都曾经求值结束,因为零碎是通过援用而不是复制来引入模块,所以就算模块之间有循环依赖关系,每个模块还是可能残缺看到对方的最终状态CommonJS与ESM的区别与交互应用
这里讲CommonJS和ESM之间几个重要的区别,以及如何在必要的时候搭配应用这两种模块
ESM不反对CommonJS提供的某些援用
CommonJS提供一些要害援用,不受ESM反对,这包含
require
、exports
、module.exports
、__filename
、__diranme
。如果在ES模块中应用这些,会到程序产生援用谬误的问题。
在ESM零碎中,咱们能够通过import.meta这个非凡对象来获取一个援用,这个援用指的是以后文件的URL。具体来说,就是通过import.meta.url这种写法,来获取以后模块的文件门路,这个门路相似于file: ///path/to/current_module.js
。咱们能够依据这条门路,结构出__filename
和__dirname
所示意的那两条绝对路径:import { fileURLToPath } from 'url';import { dirname } from 'path';const __dirname = fileURLToPath(import.meta.url);const __dirname = dirname(__filename);
CommonJS的require函数,也能够通过用上面这种办法,在ESM模块里边进行实现:
import { createRequire } from 'module';const require = createRequire(import.meta.url)
当初,就能够在ES模块零碎的环境下,用这个
require()
函数来加载Commonjs
模块在其中一个模块零碎中应用另外一个模块
在下面提到,在ESM模块中应用
module.createRequire
函数来加载commonJS
模块。除了这个办法,其实还能够通过import语言引入CommonJS模块。不过这种形式只会导出默认导出的内容;import pkg from 'commonJS-module'import { method1 } from 'commonJS-module' // 会报错
然而反过来没方法,咱们没方法在
commonJS
中引入ESM
模块
此外ESM不反对把json文件当成模块进行引入,这在commonjs却能够轻松实现
上面这种import语句,就会报错import json from 'data.json'
如果须要引入json文件,还须要借助
createRequire
函数:import { createRequire } from 'module';const require = createRequire(import.meta.url);const data = require("./data.json");console.log(data)
总结
本文次要解说了NodeJS中两种模块零碎是如何工作的,通过理解这些起因可能帮忙咱们编写防止一些难以排查的问题的bug