乐趣区

关于javascript:绝对是讲的最清楚的NodeJS模块系统


highlight: a11y-dark

theme: smartblue

NodeJS 目前有两个零碎:一套是 CommonJS(简称 CJS),另一套是 ECMAScript modules(简称 ESM); 本篇内容次要三个话题:

  1. CommonJS 的外部原理
  2. NodeJS 平台的 ESM 模块零碎
  3. 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

    在上述例子中尽管 myAppdepBdepC 都依赖了 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 模块最终加载结束的一个状态
上面用一个示例图来示意这个过程

上面是具体的流程解释

  1. 整个流程从 main.js 开始,这个模块一开始开始导入 a.js 模块
  2. a.js 首先要做的,是导出一个名为 loaded 的值,并把该值设为 false
  3. a.js 模块要求导入 b.js 模块
  4. 与 a.js 相似,b.js 首先也是导出 loaded 为 false 的变量
  5. b.js 继续执行,须要导入 a.js
  6. 因为零碎曾经开始解决 a.js 模块了,所以 b.js 会把 a.js 曾经导出的内容,立刻复制到本模块中
  7. b.js 会把本人导出的 loaded 值改为 false
  8. 因为 b 曾经执行实现,控制权会回到 a.js,他会把 b.js 模块的状态拷贝一份
  9. a.js 继续执行,批改导出值 loaded 为 true
  10. 最初就执行 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 语句,那就会以深度优先的形式递归,直到所有的代码都解析结束。
这个过程可细分为三个过程:

  1. 分析:找到所有的引入语句,并递归从相干文件中加载每个模块的内容
  2. 实例化:针对某个导出的实体,在内存中保留一个带名称的引入,但暂且不给他赋值。此时还要依据 import 和 export 关键字建设依赖关系,此时不执行 js 代码
  3. 执行:到了这个阶段,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,有模块拿到的状态是不残缺的状态。

分析

上面来解析一下其中的过程:

已上图为例:

  1. 从 main.js 开始分析,首先发现了一条 import 语句,而后进入 a.js
  2. 从 a.js 开始执行,发现了另外一条 import 语句,执行 b.js
  3. 在 b.js 开始执行,发现了一条 import 语句,引入 a.js,因为之前 a.js 曾经被依赖过,咱们不会再去执行这条门路
  4. b.js 持续往下执行,发现没有别的 import 语句。回到 a.js 之后,也发现没有其余的 import 语句,而后间接回到 main.js 入口文件。持续往下执行,发现要求引入 b.js,然而这个模块之前被拜访过了,因而这条门路不会执行
    通过深度优先的形式,模块依赖关系图曾经造成一个树状图,而后解释器在通过这个依赖图执行代码
    在这个阶段,解释器要从入口点开始,开始剖析各模块之间的依赖关系。这个阶段解释器只关怀零碎的 import 语句,并把这些语句想要引入的模块给加载进来,并以深度优先的形式摸索依赖图。依照这种办法遍历依赖关系,失去一种树状的构造

    实例化

    在这一阶段,解释器会从树状构造的底部开始,逐步向顶部走。没走到一个模块,它就会寻找该模块所要导出的所有属性,并在内存中构建一张隐射表,以寄存此模块所要导出的属性名称与该属性行将领有的取值
    如下图所示:


从上图能够看到,模块是依照什么程序来实例化的

  1. 解释器首先从 b.js 模块开始,它发现这个模块要导出 loaded 和 a
  2. 而后解释器又剖析 a.js 模块,他发现这个模块要导出 loaded 和 b
  3. 最初剖析 main.js 模块,他发现这个模块不导出任何性能
  4. 实例化阶段所结构的这套 exports 隐射图,只记录导出的名称与该名称行将领有的值之间关系,至于这个值自身,既不在本阶段初始化。
    走完上述流程后,解析器还须要在执行一遍,这次他会把各模块所导出的名称与引入这些的那些模块关联起来,如下图所示:


这次的步骤为:

  1. 模块 b.js 要与模块 b.js 所导出的内容相连接,这条链接叫作 aModule
  2. 模块 a.js 要与模块 a.js 所导出的内容相连接,这条链接叫作 bModule
  3. 最初模块 main.js 要与模块 b.js 所导出的内容相连接
  4. 在这个阶段,所有的值并没有初始化,咱们只是建设相应的链接,可能让这些链接指向相应的值,至于值自身,须要等到下一阶段能力确定

    执行

    这这个阶段,零碎终于要执行每份文件里边的代码。他依照后序的深度优先程序,由下而上的拜访最后那张依赖图,并一一执行拜访到的文件。在本例中,main.js 会放在最初执行。这种执行后果保障了,程序在运行主逻辑的时候,各模块所导出的那些值,全副失去了初始化


以上图具体步骤为:

  1. 从 b.js 开始执行。首先要执行的这行代码,会把该模块所导出的 loaded 初始化为 false
  2. 接下来往下执行,会把 aModule 复制给 a,这个时候 a 拿到的是一个援用值,这个值就是 a.js 模块
  3. 而后设置 loaded 的值为 true。这个时候 b 模块所有的值都全副确定了下来
  4. 当初执行 a.js。首先初始化导出值 loaded 为 false
  5. 接下来将该模块导出的 b 属性值得到初始值,这个值是 bModule 的援用
  6. 最初把 loaded 的值改为 true。到了这里,咱们就把 a.js 模块零碎导出的这些属性所对应的值,最终确定了下来
    走完这些步骤后,零碎就能够正式执行 main.js 文件,这个时候,各模块所导出的属性全都曾经求值结束,因为零碎是通过援用而不是复制来引入模块,所以就算模块之间有循环依赖关系,每个模块还是可能残缺看到对方的最终状态

    CommonJS 与 ESM 的区别与交互应用

    这里讲 CommonJS 和 ESM 之间几个重要的区别,以及如何在必要的时候搭配应用这两种模块

    ESM 不反对 CommonJS 提供的某些援用

    CommonJS 提供一些要害援用,不受 ESM 反对,这包含 requireexportsmodule.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

退出移动版