写点什么

深度阐述 Nodejs 模块机制

作者:coder2028
  • 2022-11-18
    浙江
  • 本文字数:5794 字

    阅读完需:约 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.jsexports.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.jsconsole.log('模块modA开始加载...')exports = function() {    console.log('Hi')}console.log('模块modA加载完毕')
复制代码


//init.jsvar 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.jsconsole.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 .jsonModule._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');
复制代码


用户头像

coder2028

关注

还未添加个人签名 2022-09-08 加入

还未添加个人简介

评论

发布
暂无评论
深度阐述Nodejs模块机制_node.js_coder2028_InfoQ写作社区