关于node.js:深度阐述Nodejs模块机制

38次阅读

共计 7474 个字符,预计需要花费 19 分钟才能阅读完成。

咱们都晓得 Nodejs 遵循的是 CommonJS 标准,当咱们 require('moduleA') 时,模块是怎么通过名字或者门路获取到模块的呢?首先要聊一下模块援用、模块定义、模块标识三个概念。

1 CommonJS标准

1.1 模块援用

模块上下文提供 require() 办法来引入内部模块,看似简略的 require 函数,其实外部做了大量工作。示例代码如下:

//test.js
// 引入一个模块到以后上下文中
const math = require('math');
math.add(1, 2);

1.2 模块定义

模块上下文提供了 exports 对象用于导入导出以后模块的办法或者变量,并且它是惟一的导出进口。模块中存在一个 module 对象,它代表模块本身,exports是 module 的属性。一个文件就是一个模块,将办法作为属性挂载在 exports 上就能够定义导出的形式:

//math.js
exports.add = function () {
    let sum = 0, i = 0, args = arguments, l = args.length;
    while(i < l) {sum += args[i++];
    }
    return sum;
}

这样就可像 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。以便开发者能够导入。如
const mod = require('module_name')
const {methodA} = require('module_name')

执行后,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 模块

const http = require('http')

返回的 http 即是实现了 HTTP 性能 Node 的内置模块。

2、载入文件模块

绝对路径的

const myMod = require('/home/base/my_mod')

或相对路径的

const myMod = require('./my_mod')

留神,这里疏忽了扩展名.js,以下是对等的

const myMod = require('./my_mod')
const myMod = require('./my_mod.js')

3、载入文件目录模块

能够间接 require 一个目录,假如有一个目录名为 folder,如

const myMod = 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 里搜寻,这样递归上来直到根目录。

5、主动缓存已载入模块

对于已加载的模块 Node 会缓存下来,而不用每次都从新搜寻。上面是一个示例

// modA.js
console.log('模块 modA 开始加载...')
exports = function() {console.log('Hi')
}
console.log('模块 modA 加载结束')
//init.js
var mod1 = require('./modA')
var mod2 = require('./modA')
console.log(mod1 === mod2)

命令行 node init.js 执行:

模块 modA 开始加载...
模块 modA 加载结束
true

能够看到尽管 require 了两次,但 modA.js 依然只执行了一次。mod1 和 mod2 是雷同的,即两个援用都指向了同一个模块对象。

优先从缓存加载

和浏览器会缓存动态 js 文件一样,Node 也会对引入的模块进行缓存,不同的是,浏览器仅仅缓存文件,而 nodejs 缓存的是编译和执行后的对象(缓存内存 require() 对雷同模块的二次加载一律采纳缓存优先的形式,这是第一优先级的,外围模块缓存查看先于文件模块的缓存查看。

基于这点:咱们能够编写一个模块,用来记录长期存在的变量。例如:我能够编写一个记录接口拜访数的模块:

let count = {}; // 因模块是关闭的,这里实际上借用了 js 闭包的概念
exports.count = function(name){if(count[name]){count[name]++;
     }else{count[name] = 1;
     }
     console.log(name + '被拜访了' + count[name] + '次。');
};

咱们在路由的 actioncontroller里这样援用:

let count = require('count');

export.index = function(req, res){count('index');
};

以上便实现了对接口调用数的统计,但这只是个 demo,因为数据存储在内存,服务器重启后便会清空。真正的计数器肯定是要联合长久化存储器的。

在进入门路查找之前有必要形容一下 module path 这个 Node.js 中的概念。对于每一个被加载的文件模块,创立这个模块对象的时候,这个模块便会有一个 paths 属性,其值依据以后文件的门路 计算失去。咱们创立 modulepath.js 这样一个文件,其内容为:

// modulepath.js
console.log(module.paths);

咱们将其放到任意一个目录中执行 node modulepath.js 命令,将失去以下的输入后果。

[ '/home/ikeepstudying/research/node_modules',
'/home/ikeepstudying/node_modules',
'/home/node_modules',
'/node_modules' ]

参考 nodejs 进阶视频解说:进入学习

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 模块编译

每个模块文件模块都是一个对象,它的定义如下:

function Module(id, parent) {
    this.id = id;
    this.exports = {};
    this.parent = parent;
    if(parent && parent.children) {parent.children.push(this);
    }
    this.filename = null;
    this.loaded = false;
    this.children = [];}

对于不同扩展名,其载入办法也有所不同:

  • .js通过 fs 模块同步读取文件后编译执行。
  • .node这是 C /C++ 编写的扩大文件,通过 dlopen() 办法加载最初编译生成的文件
  • .json同过 fs 模块同步读取文件后,用 JSON.pares() 解析返回后果

其余当作.js

每一个编译胜利的模块都会将其文件门路作为索引缓存在 Module._cache 对象上。

json 文件的编译

.json文件调用的办法如下: 其实就是调用JSON.parse

//Native extension for .json
Module._extensions['.json'] = function(module, filename) {var content = NativeModule.require('fs').readFileSync(filename, 'utf-8');
    try {module.exports = JSON.parse(stripBOM(content));
    } catch(err) {
        err.message = filename + ':' + err.message;
        throw err;
    }
}

Module._extensions会被赋值给 require()extensions属性,所以能够用:console.log(require.extensions); 输入零碎中已有的扩大加载形式。
当然也能够本人减少一些非凡的加载:

require.extensions['.txt'] = function(){//code};。

然而官网不激励通过这种形式自定义扩展名加载,而是冀望先将其余语言或文件编译成 JavaScript 文件后再加载,这样的益处在于不讲繁缛的编译加载等过程引入 Node 的执行过程。

js模块的编译 在编译的过程中,Node 对获取的 javascript 文件内容进行了头尾包装,将文件内容包装在一个 function 中:

(function (exports, require, module, __filename, __dirname) {var math = require(‘math‘);
    exports.area = function(radius) {return Math.PI * radius * radius;}
})

包装之后的代码会通过 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 头文件:

namespace node {const char node_native[] = {47, 47, ..};
    const char dgram_native[] = { 47, 47, ..};
    const char console_native = {47, 47, ..};
    const char buffer_native = {47, 47, ..};
    const char querystring_native = {47, 47, ..};
    const char punycode_native = {47, 47, ..};
    ...
    struct _native {
        const char* name;
        const char* source;
        size_t source_len;
    }
    static const struct _native natives[] = {{ "node", node_native, sizeof(node_native)-1},
      {"dgram", dgram_native, sizeof(dgram_native)-1},
      ...
    };
}

在这个过程中,JavaScript 代码以字符串模式存储在 node 命名空间中, 是不可间接执行的。在启动 Node 过程时,js 代码间接加载到内存中。在加载的过程中,js 外围模块经验标识符剖析后间接定位到内存中。

2. 编译 js 外围模块

lib 目录下的模块文件也在引入过程中经验了头尾包装的过程,而后才执行和导出了 exports 对象。与文件模块的区别在于:获取源代码的形式(外围模块从内存加载)和缓存执行后果的地位。

js 外围模块源文件通过 process.binding('natives') 取出,编译胜利的模块缓存到 NativeModule._cache 上。代码如下:

function NativeModule() {
    this.filename = id + '.js';
    this.id = id;
    this.exports = {};
    this.loaded = fales;
}
NativeModule._source = process.binding('natives');
NativeModule._cache = {};

3 importrequire

简略的说一下 importrequire的本质区别

import是 ES6 的模块标准,require是 commonjs 的模块标准,具体的用法我不介绍,我只想说一下他们最根本的区别,import 是动态 (编译时) 加载模块,require(运行时)是动静加载,那么动态加载和动静加载的区别是什么呢?

动态加载时代码在编译的时候曾经执行了,动静加载是编译后在代码运行的时候再执行,那么具体点是什么呢?
先说说 import,如下代码

import {name} from 'name.js'

// name.js 文件
export let name = 'jinux'
export let age = 20

下面的代码示意 main.js 文件里引入了 name.js 文件导出的变量,在代码编译阶段执行后的代码如下:

let name = 'jinux'

这个是我本人了解的,其实就是间接把 name.js 里的代码放到了 main.js 文件里,好比是在 main.js 文件中申明一样。
再来看看 require

var obj = require('obj.js');

// obj.js 文件
var obj = {
  name: 'jinux',
  age: 20
}
module.export obj;

require 是在运行阶段,须要把 obj 对象整个加载进内存,之后用到哪个变量就用哪个,这里再比照一下 importimport 是动态加载,如果只引入了 name,age 是不会引入的,所以是按需引入,性能更好一点。

4 nodejs 革除 require 缓存

开发 nodejs 利用时会面临一个麻烦的事件,就是批改了配置数据之后,必须重启服务器能力看到批改后的后果。

于是问题来了,挖掘机哪家强?噢,no! no! no! 怎么做到批改文件之后,主动重启服务器。

server.js中的片段:

const port = process.env.port || 1337;
app.listen(port);
console.log("server start in" + port);
exports.app = app;

假设咱们当初是这样的, app.js 的片段:

const app = require('./server.js');

如果咱们在 server.js 中启动了服务器,咱们进行服务器能够在 app.js 中调用

app.app.close()

然而当咱们从新引入 server.js

app =  require('./server.js')

的时候会发现并不是用的最新的 server.js 文件,起因是 require 的缓存机制,在第一次调用 require('./server.js') 的时候缓存下来了。

这个时候怎么办?

上面的代码解决了这个问题:

delete require.cache[require.resolve('./server.js')];
app = require('./server.js');

正文完
 0