手写node系列之require模块的加载

简介: 手写node系列之require模块的加载

本文的主要内容如下:

  1. require模块加载的流程
  2. 手写一个require简版


这篇文章算是笔者的一个小笔记,并提出了几个小问题:

  1. import能加载json模块吗
  2. 将字符串变成函数执行的几种方法


内容大多搬运网上的文章,推荐大家直接阅读,如果大家能耐心的看完,我觉得还是有收获的

  1. 阮一峰Es6Module语法和Module加载实现
  2. 深入Node.js的模块加载机制,手写require函数


require加载流程



require命令第一次加载该脚本,就会执行整个脚本,然后在内存生成一个对象。


{
  this.id = id;       // 这个id其实就是我们require的路径,即模块名
  this.path = path.dirname(id);     // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径
  this.exports = {};        // 导出的东西放这里,初始化为空对象
  this.filename = null;     // 模块对应的文件名
  this.loaded = false;      // loaded用来标识当前模块是否已经加载
}


源码中的Module对象


image.png

以后需要用到这个模块的时候,就会到exports属性上面取值。即使再次执行require命令,也不会再次执行该模块,而是到缓存之中取值。也就是说,CommonJS 模块无论加载多少次,都只会在第一次加载时运行一次,以后再加载,就返回第一次运行的结果,除非手动清除系统缓存。


循环引用小案例


a.js文件


exports.done = false;
var b = require('./b.js');
console.log('在 a.js 之中,b.done = %j', b.done);
exports.done = true;
console.log('a.js 执行完毕');


执行过程:

  1. a模块导出一个变量,done = false
  2. 加载b模块
  3. a模块暂停执行,等到b模块执行完毕之后再执行a模块

b.js文件


exports.done = false;
var a = require('./a.js');
console.log('在 b.js 之中,a.done = %j', a.done);
exports.done = true;
console.log('b.js 执行完毕');

执行过程:

  1. b模块导出一个变量,done = false
  2. 加载a模块
  3. 我们上文说到,以后要用到这个模块的时候,先会去缓存中取值,如果缓存中没有,才会加载。 由于a模块之前已经加载过了,所以我们直接从缓存中取值, 此时a.done为false
  4. 更改导出done变量的值,done由false变成true
  5. console.log('b.js 执行完毕')


由于b模块加载完毕之后,会把执行权再次归还给a模块。a模块继续执行require之后的内容,改变导出done变量的值,为true,console.log('a.js 执行完毕')

写个main.js验证这个过程

var a = require('./a.js');
var b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

打印结果,和我们分析的流程一样

image.png

总结一下require的流程,当中没有提到的部分后面会涉及到

image.png




模块类型和加载顺序


模块类型


Node.js的模块有好几种类型,前面我们使用的其实都是文件模块,总结下来,主要有这两种类型

  1. 内置模块:就是Node.js原生提供的功能,比如fshttp等等,这些模块在Node.js进程起来时就加载了。
  2. 文件模块:我们前面写的几个模块,还有第三方模块,即node_modules下面的模块都是文件模块。


加载顺序


加载顺序是指当我们require(X)时,应该按照什么顺序去哪里找X,在官方文档上有详细伪代码,总结下来大概是这么个顺序:

  1. 优先加载内置模块,即使有同名文件,也会优先使用内置模块。
  2. 不是内置模块,先去缓存找。
  3. 缓存没有就去找对应路径的文件。
  4. 不存在对应的文件,就将这个路径作为文件夹加载。
  5. 对应的文件和文件夹都找不到就去node_modules下面找。
  6. 还找不到就报错了。


加载文件夹


前面提到找不到文件就找文件夹,但是不可能将整个文件夹都加载进来,加载文件夹的时候也是有一个加载顺序的:

  1. 先看看这个文件夹下面有没有package.json,如果有就找里面的main字段,main字段有值就加载对应的文件。所以如果大家在看一些第三方库源码时找不到入口就看看他package.json里面的main字段吧,比如jquerymain字段就是这样:"main": "dist/jquery.js"
  2. 如果没有package.json或者package.json里面没有main就找index文件。
  3. 如果这两步都找不到就报错了。


支持的文件类型


  1. .js.js文件是我们最常用的文件类型,加载的时候会先运行整个JS文件,然后将前面说的module.exports作为require的返回值。
  2. .json.json文件是一个普通的文本文件,直接用JSON.parse将其转化为对象返回就行。
  3. .node.node文件是C++编译后的二进制文件,纯前端一般很少接触这个类型。

注意:import可以引入json文件吗? NO!!!import不可以引入json文件

阮一峰Es6最新提案那节解释了,需要通过断言的方式,才能引入json

image.png


手写require



path模块相关用法


path.dirname() 方法返回 path 的目录名


path.dirname('/foo/bar/baz/asdf/quux');
// 返回: '/foo/bar/baz/asdf'

path.extname() 方法返回 path 的扩展名


path.extname('index.html');
// 返回: '.html'
path.extname('index.coffee.md');
// 返回: '.md'

path.resolve() 方法将路径或路径片段的序列解析为绝对路径。


path.resolve('wwwroot', 'static_files/png/', '../gif/image.gif');
// 通过当前目录的绝对路径和传入的参数,返回目标值的绝对路径
// 如果当前工作目录是 /home/myself/node,
// 则返回 '/home/myself/node/wwwroot/static_files/gif/image.gif'

构造Module类


function MyModule(id = '') {
  this.id = id;       // 这个id其实就是我们require的路径
  this.path = path.dirname(id);     // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径
  this.exports = {};        // 导出的东西放这里,初始化为空对象
  this.filename = null;     // 模块对应的文件名
  this.loaded = false;      // loaded用来标识当前模块是否已经加载
}

require方法


require其实是Module类的一个实例方法,内容很简单,先做一些参数检查,然后调用Module._load方法。

精简版


MyModule.prototype.require = function (id) {
  return Module._load(id);
}


Module._load方法


检查请求文件的缓存。

  1. 如果模块已经存在于缓存中:返回其exports对象。
  2. 如果模块是原生的:调用NativeModule.prototype.compileForPublicLoader()并返回导出项。
  3. 否则,为文件创建一个新模块,并将其保存到缓存中。然后让它在返回导出之前加载文件内容对象。


我们这里简化了一下,只加载文件模块


// 创建一个空对象,没有原型链
MyModule._cache = Object.create(null);
MyModule._load = function (request) {    // request是我们传入的路劲参数
  const filename = MyModule._resolveFilename(request);
  // 先检查缓存,如果缓存存在且已经加载,直接返回缓存
  const cachedModule = MyModule._cache[filename];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  // 如果缓存不存在,我们就加载这个模块
  // 加载前先new一个MyModule实例,然后调用实例方法load来加载
  // 加载完成直接返回module.exports
  const module = new MyModule(filename);
  // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存
  // 但是这个缓存里面的exports可能还没有或者不完整
  MyModule._cache[filename] = module;
  module.load(filename);
  return module.exports;
}

上述源码还调用了两个方法:MyModule._resolveFilenameMyModule.prototype.load,下面我们来实现下这两个方法。

只支持.js.json文件,不支持内置模块


MyModule._resolveFilename = function (request) {
  const filename = path.resolve(request);   // 获取传入参数对应的绝对路径
  const extname = path.extname(request);    // 获取文件后缀名
  // 如果没有文件后缀名,尝试添加.js和.json
  if (!extname) {
    const exts = Object.keys(MyModule._extensions);
    for (let i = 0; i < exts.length; i++) {
      const currentPath = `${filename}${exts[i]}`;
      // 如果拼接后的文件存在,返回拼接的路径
      if (fs.existsSync(currentPath)) {
        return currentPath;
      }
    }
  }
  return filename;
}

MyModule.prototype.load

MyModule.prototype.load是一个实例方法,这个方法就是真正用来加载模块的方法,这其实也是不同类型文件加载的一个入口,不同类型的文件会对应MyModule._extensions里面的一个方法:


MyModule.prototype.load = function (filename) {
  // 获取文件后缀名
  const extname = path.extname(filename);
  // 调用后缀名对应的处理函数来处理
  MyModule._extensions[extname](this, filename);
  this.loaded = true;
}

注意这段代码里面的this指向的是module实例,因为他是一个实例方法。


加载js文件: MyModule._extensions['.js']


前面我们说过不同文件类型的处理方法都挂载在MyModule._extensions上面的,我们先来实现.js类型文件的加载:


MyModule._extensions['.js'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module._compile(content, filename);
}

可以看到js的加载方法很简单,只是把文件内容读出来,然后调了另外一个实例方法_compile来执行他。


编译执行js文件:MyModule.prototype._compile


MyModule.prototype._compile是加载JS文件的核心所在,也是我们最常使用的方法,这个方法需要将目标文件拿出来执行一遍,执行之前需要将它整个代码包裹一层,以便注入exports, require, module, __dirname, __filename,这也是我们能在JS文件里面直接使用这几个变量的原因。要实现这种注入也不难,假如我们require的文件是一个简单的Hello World,长这样:


module.exports = "hello world";

那我们怎么来给他注入module这个变量呢?答案是执行的时候在他外面再加一层函数,使他变成这样:


function (module) { // 注入module变量,其实几个变量同理
  module.exports = "hello world";
}

所以我们如果将文件内容作为一个字符串的话,为了让他能够变成上面这样,我们需要再给他拼接上开头和结尾,我们直接将开头和结尾放在一个数组里面:

MyModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

注意我们拼接的开头和结尾多了一个()包裹,这样我们后面可以拿到这个匿名函数,在后面再加一个()就可以传参数执行了。然后将需要执行的函数拼接到这个方法中间:


MyModule.wrap = function (script) {
  return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};

小问题:如果将字符串变成函数执行呢?

大家首先想到的肯定是eval, 但是这个又给我们提供了一个新的思路。通过在外面包裹一层function,再去调用node的内置vm模块中vm.runInThisContext也可以将字符串变成函数


这样通过MyModule.wrap包装的代码就可以获取到exports, require, module, __filename, __dirname这几个变量了。知道了这些就可以来写MyModule.prototype._compile了:


MyModule.prototype._compile = function (content, filename) {
  const wrapper = Module.wrap(content);    // 获取包装后函数体
  // vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
  // 返回值就是转化后的函数,所以compiledWrapper是一个函数
  // 后面的对象应该是配置信息,无关紧要
  const compiledWrapper = vm.runInThisContext(wrapper, {
    filename,
    lineOffset: 0,
    displayErrors: true,
  });
  // 准备exports, require, module, __filename, __dirname这几个参数
  // exports可以直接用module.exports,即this.exports
  // require官方源码中还包装了一层,其实最后调用的还是this.require
  // module不用说,就是this了
  // __filename直接用传进来的filename参数了
  // __dirname需要通过filename获取下
  const dirname = path.dirname(filename);
  compiledWrapper.call(this.exports, this.exports, this.require, this,
    filename, dirname);
}

上述代码要注意我们注入进去的几个参数和通过call传进去的this:

  1. this:compiledWrapper是通过call调用的,第一个参数就是里面的this,这里我们传入的是this.exports,也就是module.exports,也就是说我们js文件里面this是对module.exports的一个引用。
  2. exports: compiledWrapper正式接收的第一个参数是exports,我们传的也是this.exports,所以js文件里面的exports也是对module.exports的一个引用。
  3. require: 这个方法我们传的是this.require,其实就是MyModule.prototype.require,也就是MyModule._load
  4. module: 我们传入的是this,也就是当前模块的实例。
  5. __filename:文件所在的绝对路径。
  6. __dirname: 文件所在文件夹的绝对路径。


到这里,我们的JS文件其实已经记载完了,对应的源码看这里:github.com/nodejs/node…


加载json文件: MyModule._extensions['.json']


加载json文件就简单多了,只需要将文件读出来解析成json就行了:


MyModule._extensions['.json'] = function (module, filename) {
  const content = fs.readFileSync(filename, 'utf8');
  module.exports = JSONParse(content);
}


总结



总结一下require的大致流程: 首先它会判断引入的是否是内置模块,优先加载内置模块。如果不是内置模块,将相对路径转化为绝对路径,根据路径判断是否有缓存。如果有缓存,直接返回当前模块下缓存中的exports内容,结束。 如果没有缓存,就去找对应路径的文件,若不存在对应的文件,就将这个路径作为文件夹加载。加载文件夹也会有顺序,首先看文件夹下面有没有package.json,如果有就找里面main字段对应的文件,如果没有,就去找文件夹下面的index文件,如果都找不到就报错。 找到对应的文件之后,就创建一个Module的实例缓存起来,然后调用对应后缀的加载方法。如果是json问价,则直接赋值给module.exports,结束。 如果是js文件,则将js内容包裹,再通过vm模块将字符串变成函数执行,利用call调用函数,从而修改module.exports的值,结束。


目录
相关文章
|
3月前
|
JavaScript
Node.js【GET/POST请求、http模块、路由、创建客户端、作为中间层、文件系统模块】(二)-全面详解(学习总结---从入门到深化)
Node.js【GET/POST请求、http模块、路由、创建客户端、作为中间层、文件系统模块】(二)-全面详解(学习总结---从入门到深化)
30 0
|
10天前
|
消息中间件 监控 JavaScript
Node.js中的进程管理:child_process模块与进程管理
【4月更文挑战第30天】Node.js的`child_process`模块用于创建子进程,支持执行系统命令、运行脚本和进程间通信。主要方法包括:`exec`(执行命令,适合简单任务)、`execFile`(安全执行文件)、`spawn`(实时通信,处理大量数据)和`fork`(创建Node.js子进程,支持IPC)。有效的进程管理策略涉及限制并发进程、处理错误和退出事件、使用流通信、谨慎使用IPC以及监控和日志记录,以确保应用的稳定性和性能。
|
12天前
|
缓存 JavaScript 前端开发
Node.js的模块系统:CommonJS模块系统的使用
【4月更文挑战第29天】Node.js采用CommonJS作为模块系统,每个文件视为独立模块,通过`module.exports`导出和`require`引入实现依赖。模块有独立作用域,保证封装性,防止命名冲突。引入的模块会被缓存,提高加载效率并确保一致性。利用CommonJS,开发者能编写更模块化、可维护的代码。
|
26天前
|
JavaScript API
node.js之模块系统
node.js之模块系统
|
28天前
|
Web App开发 JavaScript 前端开发
【Node系列】node核心模块util
Node.js的核心模块util为开发者提供了一些常用的实用工具函数。这些函数能够很方便地进行对象的继承、类型判断以及其他工具函数的实现。
22 2
|
28天前
|
域名解析 网络协议 JavaScript
【Node系列】node工具模块
Node.js有多个内置的工具模块,这些模块提供了用于执行各种任务的功能。
24 2
|
28天前
|
缓存 并行计算 JavaScript
【Node系列】模块系统
Node.js 的模块系统是其核心特性之一,允许开发者编写可复用的代码,并通过简单的导入和导出机制来共享和使用这些模块。
20 3
|
2月前
|
JavaScript 前端开发
Node.js之path路径模块
Node.js之path路径模块
|
2月前
|
JavaScript
Node.js之http模块
Node.js之http模块
|
3月前
|
资源调度 JavaScript 关系型数据库
Node.js【文件系统模块、路径模块 、连接 MySQL、nodemon、操作 MySQL】(三)-全面详解(学习总结---从入门到深化)
Node.js【文件系统模块、路径模块 、连接 MySQL、nodemon、操作 MySQL】(三)-全面详解(学习总结---从入门到深化)
36 0