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.js exports.loaded = false; const b = require('./b'); module.exports = { b, loaded: true } // b.js exports.loaded = false; const a = require('./a') module.exports = { a, loaded: false } // main.js const 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 = null
if (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.js import * as bModule from './b.js'; export let loaded = false; export const b = bModule; loaded = true; // b.js import * as aModule from './b.js'; export let loaded = false; export const a = aModule; loaded = true; // main.js import * 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