深度阐述 Nodejs 模块机制
我们都知道 Nodejs 遵循的是CommonJS
规范,当我们require('moduleA')
时,模块是怎么通过名字或者路径获取到模块的呢?首先要聊一下模块引用、模块定义、模块标识三个概念。
1 CommonJS
规范
1.1 模块引用
模块上下文提供require()
方法来引入外部模块,看似简单的 require 函数, 其实内部做了大量工作。示例代码如下:
1.2 模块定义
模块上下文提供了exports
对象用于导入导出当前模块的方法或者变量,并且它是唯一的导出出口。模块中存在一个module
对象,它代表模块自身,exports
是 module 的属性。一个文件就是一个模块,将方法作为属性挂载在 exports 上就可以定义导出的方式:
这样就可像test.js
里那样在 require()之后调用模块的属性或者方法了。
1.3 模块标识
模块标识就是传递给require()
方法的参数,它必须是符合小驼峰命名的字符串,或者以.
、..
开头的相对路径或者绝对路径,可以没有文件后缀名.js
.
2. Node 的模块实现
在 Node 中引入模块,需要经历如下四个步骤:
路径分析
文件定位
编译执行
加入内存
2.1 路径分析
Node.js 中模块可以通过文件路径或名字获取模块的引用。模块的引用会映射到一个 js 文件路径。 在 Node 中模块分为两类:
一是 Node 提供的模块,称为核心模块(内置模块),内置模块公开了一些常用的 API 给开发者,并且它们在 Node 进程开始的时候就预加载了。
另一类是用户编写的模块,称为文件模块。如通过 NPM 安装的第三方模块(third-party modules)或本地模块(local modules),每个模块都会暴露一个公开的 API。以便开发者可以导入。如
执行后,Node 内部会载入内置模块或通过 NPM 安装的模块。require 函数会返回一个对象,该对象公开的 API 可能是函数、对象或者属性如函数、数组甚至任意类型的 JS 对象。
核心模块是 Node 源码在编译过程中编译进了二进制执行文件。在 Node 启动时这些模块就被加载进内存中,所以核心模块引入时省去了文件定位和编译执行两个步骤,并且在路径分析中优先判断,因此核心模块的加载速度是最快的。文件模块则是在运行时动态加载,速度比核心模块慢。
这里列下 node 模块的载入及缓存机制:
1、载入内置模块(A Core Module)
2、载入文件模块(A File Module)
3、载入文件目录模块(A Folder Module)
4、载入 node_modules 里的模块
5、自动缓存已载入模块
1、载入内置模块
Node 的内置模块被编译为二进制形式,引用时直接使用名字而非文件路径。当第三方的模块和内置模块同名时,内置模块将覆盖第三方同名模块。因此命名时需要注意不要和内置模块同名。如获取一个 http 模块
返回的 http 即是实现了 HTTP 功能 Node 的内置模块。
2、载入文件模块
绝对路径的
或相对路径的
注意,这里忽略了扩展名.js
,以下是对等的
3、载入文件目录模块
可以直接 require 一个目录,假设有一个目录名为 folder,如
此时,Node 将搜索整个 folder 目录,Node 会假设 folder 为一个包并试图找到包定义文件 package.json。如果 folder 目录里没有包含package.json
文件,Node 会假设默认主文件为index.js
,即会加载index.js
。如果index.js
也不存在, 那么加载将失败。
4、载入 node_modules 里的模块
如果模块名不是路径,也不是内置模块,Node 将试图去当前目录的node_modules
文件夹里搜索。如果当前目录的node_modules
里没有找到,Node 会从父目录的node_modules
里搜索,这样递归下去直到根目录。参考 nodejs 进阶视频讲解:进入学习
5、自动缓存已载入模块
对于已加载的模块 Node 会缓存下来,而不必每次都重新搜索。下面是一个示例
命令行node init.js
执行:
可以看到虽然 require 了两次,但 modA.js 仍然只执行了一次。mod1 和 mod2 是相同的,即两个引用都指向了同一个模块对象。
优先从缓存加载
和浏览器会缓存静态 js 文件一样,Node 也会对引入的模块进行缓存,不同的是,浏览器仅仅缓存文件,而 nodejs 缓存的是编译和执行后的对象(缓存内存) require()
对相同模块的二次加载一律采用缓存优先的方式,这是第一优先级的,核心模块缓存检查先于文件模块的缓存检查。
基于这点:我们可以编写一个模块,用来记录长期存在的变量。例如:我可以编写一个记录接口访问数的模块:
我们在路由的 action
或 controller
里这样引用:
以上便完成了对接口调用数的统计,但这只是个 demo,因为数据存储在内存,服务器重启后便会清空。真正的计数器一定是要结合持久化存储器的。
在进入路径查找之前有必要描述一下module path
这个 Node.js 中的概念。对于每一个被加载的文件模块,创建这个模块对象的时候,这个模块便会有一个 paths 属性,其值根据当前文件的路径 计算得到。我们创建modulepath.js
这样一个文件,其内容为:
我们将其放到任意一个目录中执行 node modulepath.js 命令,将得到以下的输出结果。
2.2 文件定位
1.文件扩展名分析
调用require()
方法时若参数没有文件扩展名,Node 会按.js
、.json
、.node
的顺寻补足扩展名,依次尝试。
在尝试过程中,需要调用 fs 模块阻塞式地判断文件是否存在。因为 Node 的执行是单线程的,这是一个会引起性能问题的地方。如果是.node
或者·.json·文件可以加上扩展名加快一点速度。另一个诀窍是:同步配合缓存。
2.目录分析和包
require()
分析文件扩展名后,可能没有查到对应文件,而是找到了一个目录,此时 Node 会将目录当作一个包来处理。
首先, Node 在挡墙目录下查找package.json
,通过JSON.parse()
解析出包描述对象,从中取出 main 属性指定的文件名进行定位。若 main 属性指定文件名错误,或者没有pachage.json
文件,Node 会将 index 当作默认文件名。
简而言之,如果require
绝对路径的文件,查找时不会去遍历每一个node_modules
目录,其速度最快。其余流程如下:
1.从module path
数组中取出第一个目录作为查找基准。
2.直接从目录中查找该文件,如果存在,则结束查找。如果不存在,则进行下一条查找。
3.尝试添加.js
、.json
、.node
后缀后查找,如果存在文件,则结束查找。如果不存在,则进行下一条。
4.尝试将require
的参数作为一个包来进行查找,读取目录下的package.json
文件,取得main
参数指定的文件。
5.尝试查找该文件,如果存在,则结束查找。如果不存在,则进行第 3 条查找。
6.如果继续失败,则取出module path
数组中的下一个目录作为基准查找,循环第 1 至 5 个步骤。
7.如果继续失败,循环第 1 至 6 个步骤,直到 module path 中的最后一个值。
8.如果仍然失败,则抛出异常。
整个查找过程十分类似原型链的查找和作用域的查找。所幸 Node.js 对路径查找实现了缓存机制,否则由于每次判断路径都是同步阻塞式进行,会导致严重的性能消耗。
一旦加载成功就以模块的路径进行缓存
2.3 模块编译
每个模块文件模块都是一个对象,它的定义如下:
对于不同扩展名,其载入方法也有所不同:
.js
通过 fs 模块同步读取文件后编译执行。.node
这是 C/C++编写的扩展文件,通过dlopen()
方法加载最后编译生成的文件.json
同过 fs 模块同步读取文件后,用JSON.pares()
解析返回结果
其他当作.js
每一个编译成功的模块都会将其文件路径作为索引缓存在Module._cache
对象上。
json
文件的编译
.json
文件调用的方法如下:其实就是调用JSON.parse
Module._extensions
会被赋值给require()
的extensions
属性,所以可以用:console.log(require.extensions)
;输出系统中已有的扩展加载方式。当然也可以自己增加一些特殊的加载:
但是官方不鼓励通过这种方式自定义扩展名加载,而是期望先将其他语言或文件编译成 JavaScript 文件后再加载,这样的好处在于不讲烦琐的编译加载等过程引入 Node 的执行过程。
js
模块的编译 在编译的过程中,Node 对获取的 javascript 文件内容进行了头尾包装,将文件内容包装在一个 function 中:
包装之后的代码会通过 vm 原生模块的runInThisContext()
方法执行(具有明确上下文,不污染全局),返回一个具体的 function 对象,最后传参执行,执行后返回module.exports
.
核心模块编译
核心模块分为C/C++
编写和 JavaScript 编写的两个部分,其中C/C++
文件放在 Node 项目的 src 目录下,JavaScript 文件放在 lib 目录下。
1.转存为 C/C++代码
Node 采用了 V8 附带的 js2c.py 工具,将所有内置的 JavaScript 代码转换成 C++里的数组,生成 node_natives.h 头文件:
在这个过程中,JavaScript 代码以字符串形式存储在 node 命名空间中,是不可直接执行的。在启动 Node 进程时,js 代码直接加载到内存中。在加载的过程中,js 核心模块经历标识符分析后直接定位到内存中。
2.编译 js 核心模块
lib 目录下的模块文件也在引入过程中经历了头尾包装的过程,然后才执行和导出了 exports 对象。与文件模块的区别在于:获取源代码的方式(核心模块从内存加载)和缓存执行结果的位置。
js 核心模块源文件通过process.binding('natives')
取出,编译成功的模块缓存到NativeModule._cache
上。代码如下:
3 import
和require
简单的说一下import
和require
的本质区别
import
是 ES6 的模块规范,require
是 commonjs 的模块规范,详细的用法我不介绍,我只想说一下他们最基本的区别,import 是静态(编译时)加载模块,require(运行时)是动态加载,那么静态加载和动态加载的区别是什么呢?
静态加载时代码在编译的时候已经执行了,动态加载是编译后在代码运行的时候再执行,那么具体点是什么呢?先说说 import,如下代码
上面的代码表示main.js
文件里引入了name.js
文件导出的变量,在代码编译阶段执行后的代码如下:
这个是我自己理解的,其实就是直接把name.js
里的代码放到了main.js
文件里,好比是在main.js
文件中声明一样。再来看看 require
require 是在运行阶段,需要把 obj 对象整个加载进内存,之后用到哪个变量就用哪个,这里再对比一下import
,import
是静态加载,如果只引入了 name,age 是不会引入的,所以是按需引入,性能更好一点。
4 nodejs 清除 require 缓存
开发 nodejs 应用时会面临一个麻烦的事情,就是修改了配置数据之后,必须重启服务器才能看到修改后的结果。
于是问题来了,挖掘机哪家强?噢,no! no! no!怎么做到修改文件之后,自动重启服务器。
server.js
中的片段:
假定我们现在是这样的, app.js 的片段:
如果我们在 server.js 中启动了服务器,我们停止服务器可以在 app.js 中调用
但是当我们重新引入 server.js
的时候会发现并不是用的最新的 server.js 文件,原因是 require 的缓存机制,在第一次调用require('./server.js')
的时候缓存下来了。
这个时候怎么办?
下面的代码解决了这个问题:
评论