模块机制

在最开始的浏览器中,其实是没有模块的概念,在浏览器中想要共享以全局变量,直接通过将其挂载在window对象属性/方法中,任何人都可以对该 属性/方法进行访问以及修改,那么每个人都可以对对象/属性进行修改,那么谁在哪个时刻修改了什么值,就很难跟踪到了,因此,我们需要一种 机制/规范来限定对该对象的管理,对具有共性的对象群进行包合并,并对外暴露统一的一个"口子",所有的包内属性/方法的访问都只能通过这个"口子"。 nodejs通过**借鉴于CommonJS规范,建立了一套模块系统体系,并携带了npm来对包进行规范与管理!!!

:trollface: 什么是CommonJS?node建立起来的模块体系是怎样的?如何来更好地理解node模块系统?如何来使用node中的模块系统?

一、CommonJS规范

首先它是一个规范,规定了对模块的定义,所有的代码都运行在模块作用域下,不会污染全局作用域,一般 :u6307: 以下三种属性:

  1. 模块引用: 提供了require()方法,通过传递的模块标识来引入一个模块api到当前上下文;
  2. 模块定义: 提供的exports对象,用于导出当前模块(文件)中的方法以及变量,而且是唯一的导出接口,同时还提供了module,代表着模块本身,exports作为module的一个属性;
  3. 模块标识: 传递给require()函数的参数,参数格式的不同代表着它有不同的加载机制

CommonJS模块引用

二、node模块

node模块参考了CommonJS规范,但其中关于node中模块是如何被加载与使用的,我们需要具体深入了解一下,这将有益于我们编写的代码性能。 node引入模块,需要经历以下 :three: 个步骤:

  1. 路径分析;
  2. 文件定位;
  3. 编译执行。

2.1、模块组成(文件模块 + 核心模块)

在开始分析node模块之前,先来了解以下什么是模块! 在node中,模块分为两类:

  1. node提供的模块,称为核心模块,一般在node的源码中的lib以及src文件夹中,lib主要是js实现的对c/c++层的访问,src则是c/c++实现的,其中有一部分是node程序启动的时候,就直接载入到内存中的,所以无需定位模块,加载模块,而是直接载入模块,所以它的一个速度最快;
  2. 用户编写的模块,称为文件模块,需要经过模块引入的 :three: 个步骤

2.2、模块的加载过程

:one: 路径分析 :point_right: :two: 文件定位 :point_right: :three: 编译执行 与浏览器的静态文件存储类似,node模块会对引入过的模块进行一个缓存,下次则可直接编译,这也是编码过程中的一种常见编码模式(懒加载)

2.2.1、路径分析

一切,从require('标识')开始,这里由于不同的标识,将会有不同的加载机制,一般 :u6709: 以下几种:

  1. 核心模块,有js/c++实现的,比如是fshttp等,它的加载优先级仅次于缓存加载,因为它在node源码编译的时候,就已经编译成可执行文件,直接被执行的,因而加载速度较快;
  2. 路径形式的文件模块(绝对路径或者是相对路径的文件模块),require()方法会将其转换为真实的路径,并以这个路径来作为索引,将编译执行后的结果放入到缓存中,供二次使用前的判断;
  3. 自定义模块(3方库),一般是一个包或者是一个文件,它是最费时间的,而且也是最慢的一种。

:question: 为什么自定义模块的加载,会是最慢的一个呢?先来看 :point_down: 的一个例子:

  console.info(module.paths);

node模块加载路径:point_up_2: 我们可以看出node模块的加载路径,它是从当前路径,逐渐向上去加载的。这有点类似于js中的原型查找一样,当文件的层级 越深,模块加载所消耗的时间也就越长,这也就是自定义模块加载的时间最慢的原因了

:star2: 附带node模块加载的顺序图 node模块加载顺序

2.2.2、文件定位

:point_up_2: 的node模块加载顺序图我们可以看出所有的模块都优先从缓存中加载,这可以减少对象的二次访问性能的损耗。 在分析完成路径之后,开始进行模块的加载,这里需要清楚的是,不同的文件扩展名、目录和包的处理过程,还拥有着不一样的加载逻辑!

  1. require()允许传递不带文件后缀的模块标识,按照js > json > node的顺序来加载模块,而在这个过程中,它会使用fs模块阻塞式地来执行这个过程,:point_right: 这里可能会引起一个性能问题;
  2. 如果require引用的是一个目录或者是包名,那么这里的加载过程则比较复杂,如下流程图所示:

node模块文件包名定位顺序

2.2.3、编译执行

在node中,每一个文件都是一个模块,它的定义如下:

// node-main/lib/internal/modules/cjs/loaders.js/第172行
function Module(id = '', parent) {
  this.id = id;
  this.path = path.dirname(id);
  this.exports = {};
  moduleParentCache.set(this, parent);     // moduleParentCache为父目录缓存对象
  updateChildren(parent, this, false);     // 更新父目录中的孩子目录方法
  this.filename = null;
  this.loaded = false;
  this.children = [];
}

根据 :point_up_2: 的对象,来载入对应的模块,这里采用的Module函数对象,来进行对js的装载, 这里的parent属性,代表的是加载当前模块的模块,如果是通过node xxx.js,则parent为null,而如果通过模块reqiure另外一个模块,则parent为前者。 根据 :point_up_2: 所习得的,任何的模块在被加载时,都会先去查看以下缓存中是否有加载过, :question: 那么,这里的缓存是如何被知晓并存储下来的?

// node-main/lib/internal/modules/cjs/loaders.js/第199行
Module._cache = ObjectCreate(null);
Module._pathCache = ObjectCreate(null);
Module._extensions = ObjectCreate(null);
let modulePaths = [];
Module.globalPaths = [];

在这个模块中创建出对应的_cache对象来缓存加载到的对象,利用_pathCache对象来缓存加载到的真实路径,用于后续的索引

:stars: 根据 :point_up: 的一个函数,我们可以进行 :point_down: 的一个尝试:

console.info(module.id);
console.info(module.exports);
console.info(module.parent);
console.info(module.filename);
console.info(module.loaded);
console.info(module.children);
console.info(module.path);

node模块的组成

:question: node模块文件模块文件中本来就没有这个exportrequiremodule__filename__dirname这些变量,为什么放到了node环境中的时候,就可以直接在模块中直接访问到这些变量, 那么这些变量都是怎么来的呢?:point_right: 主要是node在运行这个脚本的时候,会自动加上这几个变量,那么,这几个变量是如何被加入进来的呢?具体来看 :point_down: 的一个代码:

// node-main/lib/internal/modules/cjs/loaders.js/第208行
let wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];
let wrapperProxy = new Proxy(wrapper, {
  __proto__: null,
  set(target, property, value, receiver) {
    patched = true;
    return ReflectSet(target, property, value, receiver);
  },
  defineProperty(target, property, descriptor) {
    patched = true;
    return ObjectDefineProperty(target, property, descriptor);
  }
});
ObjectDefineProperty(Module, 'wrap', {
  __proto__: null,
  get() {
    return wrap;
  },
  set(value) {
    patched = true;
    wrap = value;
  }
});
ObjectDefineProperty(Module, 'wrapper', {
  __proto__: null,
  get() {
    return wrapperProxy;
  },
  set(value) {
    patched = true;
    wrapperProxy = value;
  }
});

:point_up: 我们可以看出,node提供了一个wrap包装器,通过包装器,将require、exports、module、__filename、__dirname给作为函数参数并 包装到模块外面,形成一个新的函数包装器字符串返回,然后当这个包装函数被执行的时候,将在Module对象中加入参数属性,然后去调用vm.runInThisContext 方法(类似于eval,只是具有明确上下文,不污染全局变量),返回一个具体的function函数,如 :point_down: 所示:

if (patched) {
    const wrapper = Module.wrap(content);
    return vm.runInThisContext(wrapper, {
      filename,
      lineOffset: 0,
      displayErrors: true,
      importModuleDynamically: async (specifier, _, importAssertions) => {
        const loader = asyncESM.esmLoader;
        return loader.import(specifier, normalizeReferrerURL(filename),
                             importAssertions);
      },
    });
  }
  try {
    return vm.compileFunction(content, [
      'exports',
      'require',
      'module',
      '__filename',
      '__dirname',
    ], {
      filename,
      importModuleDynamically(specifier, _, importAssertions) {
        const loader = asyncESM.esmLoader;
        return loader.import(specifier, normalizeReferrerURL(filename),
                             importAssertions);
      },
    });
  } catch (err) {
    if (process.mainModule === cjsModuleInstance)
      enrichCJSError(err, content);
    throw err;
  }

:stars: 这就是这些变量没有定义在每一个模块中,但能够在每个模块中能够访问到的原因,每个模块之间都做了作用域隔离!!!

:star2: 在node模块中有不同的加载器(loader),这有点类似于java中的ClassLoader,用于加载对应文件扩展名的文件,不同的文件扩展名, 它拥有着不同的加载方式,目前node模块中有.node、.js、.json加载器,分别存储在Module的_extends属性中

不同的node模块加载器

而对于不同的node模块加载器,它们各自的过程都是不一样的:

  1. .js文件:通过fs模块同步读取文件后编译执行;
  2. .node文件:通过dlopen方法加载最后生成的编译文件;
  3. .json文件:通过fs模块同步读取后,采用JSON.parse方法来获取一个结果对象。

:question: node模块中已经有exports属性了,为什么还存在module.exports?? 因为exports指向的是模块暴露API的集合体,而module是当前的模块,两者都是通过传递参数的形式来追加到模块中,假如对两者进行一个直接的 引用修改的话,那么将会导致直接改写了内部的引用,不能够正常继续工作下去,出现未知的问题,因此通过在module.exports属性,来表示当前 整个模块对象

三、包与npm管理

我们平时所引用的库,其实都是一个个的包,包它通过对外暴露的一个模块,来向外部提供对内部各个模块的一个访问,实现针对接口编程, 只需统一引用这个公共的模块即可访问到内部各个模块,通过描述文件,对外提供如何访问这个包的方式(根据前面所提及的package.json的规则) 模块与包的关系

:trollface: CommonJS的包规范定义由包结构和包描述文件两个部分组成,前者用于组织包中的各种文件,后者用于描述包的相关信息,以供外部读取文件。

3.1、包结构

其实就是一个被解压后的文件夹,其中包括有:

  1. package.json: 包描述文件;
  2. bin: 存放可执行二进制文件的目录;
  3. lib: 用于存在javascript代码的目录;
  4. doc: 用于存放文档的目录;
  5. test: 用于存放单元测试用例的代码目录

:point_up: 我们可以看出,当我们要编写一个库对外暴露的时候,一般都需要拥有对应上述5个目录/文件,这样子才算是一个标准的值得考验的库

3.2、包描述文件与npm

CommonJS包规范是理论,而npm是其中的一种实践!! 包中存在的package.json,作为包描述文件,提供的与代码无关的信息,但又包含着非常重要的信息,它是一个JSON格式的文件,位于包根目录下, 一般的这个package.json文件 :u6709: 以下的属性:

属性值 描述
name 包名,由小写字母+数字组成,对外暴露的唯一标识,不允许重复
description 包简介
version 版本号,一般用来作为版本控制的字段,有主版本、次版本、补丁版本,major.minor.revision格式组成
keywords 关键词数组,主要用于分类搜索
maintainers 包维护者数组列表,每个item都由name、email、web这3个属性组成
contributors 贡献者列表,其格式与maintainers一致
bugs 一个可以反馈bug的网页地址或者邮件列表
licenses 当前包所使用的许可证数组列表,每一个item由type、url组成
repositories 源代码仓库地址列表
dependencies 当前包所依赖的包列表,npm会自动加载该依赖包
:point_up: 必选字段 :point_down: 非必选字段
homepage 当前包的网站地址
os 操作系统支持列表,可支持:aix、freebsd、linux、macos、solaris、vxworks、windows
cpu cpu架构的支持列表,可支持arm、mips、ppc、sparc、x86、x86_64
engine 支持的js引擎列表,可支持ejs、flusspferd、gpsee、jsc、spidermonkey、narwhal、node、v8
builtin 标识当前包是否内建在底层系统的标准组件
directories 包目录说明
implements 实现规范列表
scripts 脚本说明对象,主要被包管理用来安装、编译、测试和卸载包
:point_down: npm所添加的属性
author 包作者
bin 一些包作者希望包可以作为命令行工具来使用
main 模块引入方法require()优先检查的入口
devDependencies 一些只在开发阶段被依赖的模块

:star: 这里关键介绍以下关于scriptsbin属性 :point_right: scripts: 主要是被npm用来安装、编译、测试和卸载的脚本描述对象,如下例子所示:

{
  "scripts": {
    "install": "install.js",
    "uninstall": "uninstall.js",
    "build": "build.js",
    "doc": "make-doc.js",
    "test": "test.js"
  }
}

:point_right: bin: 主要可以是通过npm install -g XXX命令,将包的脚本模块添加到全局路径下,然后就可以直接在命令行中执行该命令

关于这个npm的使用,可以看另外的一篇文章

四、前端模块化

既然CommonJS可以在后端使用,那么是否也可以适用于前端呢? 起初,由于前后端环境的差别,是不能够直接在前端中使用CommonJs规范的,因为CommonJs对于模块的引用基本上是同步引用的, 若直接在前端中来使用CommonJs规范的话,则会引起某些模块还没有被加载,然后其他的模块被加载了,出现模块中方法/属性undefined 的情况。后面才会衍生出其他适用于前端方面的规范:AMD与CMD

4.1、AMD

AMD,全称是: Asynchronous Module Definition,也就是异步模块定义,其模块的定义如下:

  define(id?, dependencies?, factory);

模块id与依赖dependencies都是可选的,依赖项参数是可选的。如果省略,则默认为 ["require", "exports", "module"]; 第三个参数 factory 是一个函数,应该被执行以实例化模块或对象。如果工厂是一个函数,它应该只执行一次。 如果 factory 参数是一个对象,则该对象应指定为模块的导出值。 与node模块相似的地方就是factory的内容就是实际的代码内容,比如 :point_down: 定义了一个模块:

  define(function() {
    var exports = {};
    exports.sayHello = function() {
      alert('hello from module:' + module.id);
    };
    return exports;
  })

:point_up: 这里与node模块比较类似,所不同的是AMD需要显示地用define()函数来定义一个AMD模块,两者都进行各自的一个作用域隔离操作, 避免过去那种通过全局变量或者全局命名空间的方式,防止污染变量或者不小心修改到变量

4.2 CMD

CMD,全称是: Common Module Definition,也就是公共模块定义,其模块的定义如下:

  define(dependencies?, factory){}

:point_up: dependencies为一个依赖的字符串数组,而factory则是一个函数,也可以是一个字符串或者是一个对象,当factory为一个字符串或者 是一个对象的时候,则代表当前模块是一个静态字符串或者是静态对象,而如果是一个函数的话,那么可以通过动态化传递参数的方式,将需要的模块传递 进来,如下所示:

  define(function(require, exports, module) {
  });

这三者通过传参的方式来传递,当需要依赖模块的时候,随意可以调用require来引用即可!!

results matching ""

    No results matching ""