模块机制
在最开始的浏览器中,其实是没有模块的概念,在浏览器中想要共享以全局变量,直接通过将其挂载在window对象属性/方法中,任何人都可以对该 属性/方法进行访问以及修改,那么每个人都可以对对象/属性进行修改,那么谁在哪个时刻修改了什么值,就很难跟踪到了,因此,我们需要一种 机制/规范来限定对该对象的管理,对具有共性的对象群进行包合并,并对外暴露统一的一个"口子",所有的包内属性/方法的访问都只能通过这个"口子"。 nodejs通过**借鉴于CommonJS规范,建立了一套模块系统体系,并携带了npm来对包进行规范与管理!!!
什么是CommonJS?node建立起来的模块体系是怎样的?如何来更好地理解node模块系统?如何来使用node中的模块系统?
一、CommonJS规范
首先它是一个规范,规定了对模块的定义,所有的代码都运行在模块作用域下,不会污染全局作用域,一般 以下三种属性:
- 模块引用: 提供了require()方法,通过传递的模块标识来引入一个模块api到当前上下文;
- 模块定义: 提供的exports对象,用于导出当前模块(文件)中的方法以及变量,而且是唯一的导出接口,同时还提供了module,代表着模块本身,exports作为module的一个属性;
- 模块标识: 传递给require()函数的参数,参数格式的不同代表着它有不同的加载机制
二、node模块
node模块参考了CommonJS规范,但其中关于node中模块是如何被加载与使用的,我们需要具体深入了解一下,这将有益于我们编写的代码性能。 node引入模块,需要经历以下 个步骤:
- 路径分析;
- 文件定位;
- 编译执行。
2.1、模块组成(文件模块 + 核心模块)
在开始分析node模块之前,先来了解以下什么是模块! 在node中,模块分为两类:
- node提供的模块,称为核心模块,一般在node的源码中的
lib
以及src
文件夹中,lib
主要是js实现的对c/c++层的访问,src
则是c/c++实现的,其中有一部分是node程序启动的时候,就直接载入到内存中的,所以无需定位模块,加载模块,而是直接载入模块,所以它的一个速度最快;- 用户编写的模块,称为文件模块,需要经过模块引入的 个步骤
2.2、模块的加载过程
路径分析 文件定位 编译执行 与浏览器的静态文件存储类似,node模块会对引入过的模块进行一个缓存,下次则可直接编译,这也是编码过程中的一种常见编码模式(懒加载)
2.2.1、路径分析
一切,从require('标识')开始,这里由于不同的标识,将会有不同的加载机制,一般 以下几种:
- 核心模块,有js/c++实现的,比如是
fs
、http
等,它的加载优先级仅次于缓存加载,因为它在node源码编译的时候,就已经编译成可执行文件,直接被执行的,因而加载速度较快;- 路径形式的文件模块(绝对路径或者是相对路径的文件模块),
require()
方法会将其转换为真实的路径,并以这个路径来作为索引,将编译执行后的结果放入到缓存中,供二次使用前的判断;- 自定义模块(3方库),一般是一个包或者是一个文件,它是最费时间的,而且也是最慢的一种。
为什么自定义模块的加载,会是最慢的一个呢?先来看 的一个例子:
console.info(module.paths);
从 我们可以看出node模块的加载路径,它是从当前路径,逐渐向上去加载的。这有点类似于js中的原型查找一样,当文件的层级 越深,模块加载所消耗的时间也就越长,这也就是自定义模块加载的时间最慢的原因了
附带node模块加载的顺序图
2.2.2、文件定位
从 的node模块加载顺序图我们可以看出所有的模块都优先从缓存中加载,这可以减少对象的二次访问性能的损耗。 在分析完成路径之后,开始进行模块的加载,这里需要清楚的是,不同的文件扩展名、目录和包的处理过程,还拥有着不一样的加载逻辑!
- require()允许传递不带文件后缀的模块标识,按照
js > json > node
的顺序来加载模块,而在这个过程中,它会使用fs
模块阻塞式地来执行这个过程,:point_right: 这里可能会引起一个性能问题;- 如果require引用的是一个目录或者是包名,那么这里的加载过程则比较复杂,如下流程图所示:
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 = []; }
根据 的对象,来载入对应的模块,这里采用的
Module
函数对象,来进行对js的装载, 这里的parent属性,代表的是加载当前模块的模块,如果是通过node xxx.js
,则parent为null,而如果通过模块reqiure另外一个模块,则parent为前者。 根据 所习得的,任何的模块在被加载时,都会先去查看以下缓存中是否有加载过, 那么,这里的缓存是如何被知晓并存储下来的?
// 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
对象来缓存加载到的真实路径,用于后续的索引
根据 的一个函数,我们可以进行 的一个尝试:
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模块文件模块文件中本来就没有这个export
、require
、module
、__filename
、__dirname
这些变量,为什么放到了node环境中的时候,就可以直接在模块中直接访问到这些变量,
那么这些变量都是怎么来的呢?:point_right: 主要是node在运行这个脚本的时候,会自动加上这几个变量,那么,这几个变量是如何被加入进来的呢?具体来看 的一个代码:
// 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;
}
});
从 我们可以看出,node提供了一个wrap包装器,通过包装器,将require、exports、module、__filename、__dirname
给作为函数参数并
包装到模块外面,形成一个新的函数包装器字符串返回,然后当这个包装函数被执行的时候,将在Module对象中加入参数属性,然后去调用vm.runInThisContext
方法(类似于eval,只是具有明确上下文,不污染全局变量),返回一个具体的function函数,如 所示:
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;
}
这就是这些变量没有定义在每一个模块中,但能够在每个模块中能够访问到的原因,每个模块之间都做了作用域隔离!!!
在node模块中有不同的加载器(loader),这有点类似于java中的ClassLoader,用于加载对应文件扩展名的文件,不同的文件扩展名,
它拥有着不同的加载方式,目前node模块中有.node、.js、.json
加载器,分别存储在Module的_extends
属性中
而对于不同的node模块加载器,它们各自的过程都是不一样的:
- .js文件:通过fs模块同步读取文件后编译执行;
- .node文件:通过dlopen方法加载最后生成的编译文件;
- .json文件:通过fs模块同步读取后,采用JSON.parse方法来获取一个结果对象。
node模块中已经有exports
属性了,为什么还存在module.exports
??
因为exports指向的是模块暴露API的集合体,而module是当前的模块,两者都是通过传递参数的形式来追加到模块中,假如对两者进行一个直接的
引用修改的话,那么将会导致直接改写了内部的引用,不能够正常继续工作下去,出现未知的问题,因此通过在module.exports属性,来表示当前
整个模块对象
三、包与npm管理
我们平时所引用的库,其实都是一个个的包,包它通过对外暴露的一个模块,来向外部提供对内部各个模块的一个访问,实现针对接口编程, 只需统一引用这个公共的模块即可访问到内部各个模块,通过描述文件,对外提供如何访问这个包的方式(根据前面所提及的package.json的规则)
CommonJS的包规范定义由包结构和包描述文件两个部分组成,前者用于组织包中的各种文件,后者用于描述包的相关信息,以供外部读取文件。
3.1、包结构
其实就是一个被解压后的文件夹,其中包括有:
- package.json: 包描述文件;
- bin: 存放可执行二进制文件的目录;
- lib: 用于存在javascript代码的目录;
- doc: 用于存放文档的目录;
- test: 用于存放单元测试用例的代码目录
从 我们可以看出,当我们要编写一个库对外暴露的时候,一般都需要拥有对应上述5个目录/文件,这样子才算是一个标准的值得考验的库
3.2、包描述文件与npm
CommonJS包规范是理论,而npm是其中的一种实践!! 包中存在的
package.json
,作为包描述文件,提供的与代码无关的信息,但又包含着非常重要的信息,它是一个JSON格式的文件,位于包根目录下, 一般的这个package.json文件 以下的属性:
属性值 | 描述 |
---|---|
name | 包名,由小写字母+数字组成,对外暴露的唯一标识,不允许重复 |
description | 包简介 |
version | 版本号,一般用来作为版本控制的字段,有主版本、次版本、补丁版本,major.minor.revision格式组成 |
keywords | 关键词数组,主要用于分类搜索 |
maintainers | 包维护者数组列表,每个item都由name、email、web这3个属性组成 |
contributors | 贡献者列表,其格式与maintainers 一致 |
bugs | 一个可以反馈bug的网页地址或者邮件列表 |
licenses | 当前包所使用的许可证数组列表,每一个item由type、url组成 |
repositories | 源代码仓库地址列表 |
dependencies | 当前包所依赖的包列表,npm会自动加载该依赖包 |
必选字段 | 非必选字段 |
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 | 脚本说明对象,主要被包管理用来安装、编译、测试和卸载包 |
npm所添加的属性 | |
author | 包作者 |
bin | 一些包作者希望包可以作为命令行工具来使用 |
main | 模块引入方法require() 优先检查的入口 |
devDependencies | 一些只在开发阶段被依赖的模块 |
这里关键介绍以下关于scripts
与bin
属性
scripts
: 主要是被npm用来安装、编译、测试和卸载的脚本描述对象,如下例子所示:
{
"scripts": {
"install": "install.js",
"uninstall": "uninstall.js",
"build": "build.js",
"doc": "make-doc.js",
"test": "test.js"
}
}
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的内容就是实际的代码内容,比如 定义了一个模块:
define(function() {
var exports = {};
exports.sayHello = function() {
alert('hello from module:' + module.id);
};
return exports;
})
这里与node模块比较类似,所不同的是AMD需要显示地用define()
函数来定义一个AMD模块,两者都进行各自的一个作用域隔离操作,
避免过去那种通过全局变量或者全局命名空间的方式,防止污染变量或者不小心修改到变量
4.2 CMD
CMD,全称是: Common Module Definition,也就是公共模块定义,其模块的定义如下:
define(dependencies?, factory){}
dependencies为一个依赖的字符串数组,而factory则是一个函数,也可以是一个字符串或者是一个对象,当factory为一个字符串或者 是一个对象的时候,则代表当前模块是一个静态字符串或者是静态对象,而如果是一个函数的话,那么可以通过动态化传递参数的方式,将需要的模块传递 进来,如下所示:
define(function(require, exports, module) { });
这三者通过传参的方式来传递,当需要依赖模块的时候,随意可以调用require来引用即可!!