乐趣区

关于javascript:深入Nodejs的模块加载机制手写require函数

模块是 Node.js 外面一个很根本也很重要的概念,各种原生类库是通过模块提供的,第三方库也是通过模块进行治理和援用的。本文会从根本的模块原理登程,到最初咱们会利用这个原理,本人实现一个简略的模块加载机制,即本人实现一个require

本文残缺代码已上传 GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

简略例子

老规矩,讲原理前咱们先来一个简略的例子,从这个例子动手一步一步深刻原理。Node.js 外面如果要导出某个内容,须要应用 module.exports,应用module.exports 简直能够导出任意类型的 JS 对象,包含字符串,函数,对象,数组等等。咱们先来建一个 a.js 导出一个最简略的hello world:

// a.js 
module.exports = "hello world";

而后再来一个 b.js 导出一个函数:

// b.js
function add(a, b) {return a + b;}

module.exports = add;

而后在 index.js 外面应用他们,即 require 他们,require函数返回的后果就是对应文件 module.exports 的值:

// index.js
const a = require('./a.js');
const add = require('./b.js');

console.log(a);      // "hello world"
console.log(add(1, 2));    // b 导出的是一个加法函数,能够间接应用,这行后果是 3 

require 会先运行指标文件

当咱们 require 某个模块时,并不是只拿他的 module.exports,而是会从头开始运行这个文件,module.exports = XXX 其实也只是其中一行代码,咱们前面会讲到,这行代码的成果其实就是批改模块外面的 exports 属性。比方咱们再来一个c.js

// c.js
let c = 1;

c = c + 1;

module.exports = c;

c = 6;

c.js 外面咱们导出了一个 c,这个c 通过了几步计算,当运行到 module.exports = c; 这行时 c 的值为 2,所以咱们requirec.js的值就是 2,前面将c 的值改为了 6 并不影响后面的这行代码:

const c = require('./c.js');

console.log(c);  // c 的值是 2 

后面 c.js 的变量 c 是一个根本数据类型,所以前面的 c = 6; 不影响后面的module.exports,那他如果是一个援用类型呢?咱们间接来试试吧:

// d.js
let d = {num: 1};

d.num++;

module.exports = d;

d.num = 6;

而后在 index.js 外面 require 他:

const d = require('./d.js');

console.log(d);     // {num: 6}

咱们发现在 module.exports 前面给 d.num 赋值依然失效了,因为 d 是一个对象,是一个援用类型,咱们能够通过这个援用来批改他的值。其实对于援用类型来说,不仅仅在 module.exports 前面能够批改他的值,在模块里面也能够批改,比方 index.js 外面就能够间接改:

const d = require('./d.js');

d.num = 7;
console.log(d);     // {num: 7}

requiremodule.exports 不是黑魔法

咱们通过后面的例子能够看进去,requiremodule.exports 干的事件并不简单,咱们先假如有一个全局对象 {},初始状况下是空的,当你require 某个文件时,就将这个文件拿进去执行,如果这个文件外面存在 module.exports,当运行到这行代码时将module.exports 的值退出这个对象,键为对应的文件名,最终这个对象就长这样:

{
  "a.js": "hello world",
  "b.js": function add(){},
  "c.js": 2,
  "d.js": {num: 2}
}

当你再次 require 某个文件时,如果这个对象外面有对应的值,就间接返回给你,如果没有就反复后面的步骤,执行指标文件,而后将它的 module.exports 退出这个全局对象,并返回给调用者。这个全局对象其实就是咱们常常据说的缓存。所以 requiremodule.exports并没有什么黑魔法,就只是运行并获取指标文件的值,而后退出缓存,用的时候拿进去用就行。再看看这个对象,因为 d.js 是一个援用类型,所以你在任何中央获取了这个援用都能够更改他的值,如果不心愿本人模块的值被更改,须要本人写模块时进行解决,比方应用 Object.freeze()Object.defineProperty() 之类的办法。

模块类型和加载程序

这一节的内容都是一些概念,比拟干燥,然而也是咱们须要理解的。

模块类型

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. 如果这两步都找不到就报错了。

反对的文件类型

require次要反对三种文件类型:

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

手写require

后面其实咱们曾经将原理讲的七七八八了,上面来到咱们的重头戏,本人实现一个 require。实现require 其实就是实现整个 Node.js 的模块加载机制,咱们再来理一下须要解决的问题:

  1. 通过传入的路径名找到对应的文件。
  2. 执行找到的文件,同时要注入 modulerequire这些办法和属性,以便模块文件应用。
  3. 返回模块的module.exports

本文的手写代码全副参照 Node.js 官网源码,函数名和变量名尽量保持一致,其实就是精简版的源码,大家能够对照着看,写到具体方法时我也会贴上对应的源码地址。总体的代码都在这个文件外面:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

Module 类

Node.js 模块加载的性能全副在 Module 类外面,整个代码应用面向对象的思维,如果你对 JS 的面向对象还不是很相熟能够先看看这篇文章。Module类的构造函数也不简单,次要是一些值的初始化,为了跟官网 Module 名字辨别开,咱们本人的类命名为MyModule

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 办法,源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L970。精简版的代码如下:

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

MyModule._load

MyModule._load是一个静态方法,这才是 require 办法的真正主体,他干的事件其实是:

  1. 先查看申请的模块在缓存中是否曾经存在了,如果存在了间接返回缓存模块的exports
  2. 如果不在缓存中,就 new 一个 Module 实例,用这个实例加载对应的模块,并返回模块的exports

咱们本人来实现下这两个需要,缓存间接放在 Module._cache 这个动态变量上,这个变量官网初始化应用的是Object.create(null),这样能够使创立进去的原型指向null,咱们也这样做吧:

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;
}

上述代码对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L735

能够看到上述源码还调用了两个办法:MyModule._resolveFilenameMyModule.prototype.load,上面咱们来实现下这两个办法。

MyModule._resolveFilename

MyModule._resolveFilename从名字就可以看进去,这个办法是通过用户传入的 require 参数来解析到真正的文件地址的,源码中这个办法比较复杂,因为依照后面讲的,他要反对多种参数:内置模块,相对路径,绝对路径,文件夹和第三方模块等等,如果是文件夹或者第三方模块还要解析外面的 package.jsonindex.js。咱们这里次要讲原理,所以咱们就只实现通过相对路径和绝对路径来查找文件,并反对主动增加 jsjson两种后缀名:

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._extensions,这个变量是用来存各种文件对应的解决办法的,咱们前面会实现他。

MyModule._resolveFilename对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L822

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 实例,因为他是一个实例办法。对应的源码看这里: https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L942

加载 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 来执行他。对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1098

编译执行 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];
};

这样通过 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 文件其实曾经记录完了,对应的源码看这里:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js#L1043

加载 json 文件: MyModule._extensions[‘.json’]

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

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

exportsmodule.exports 的区别

网上常常有人问,node.js外面的 exportsmodule.exports到底有什么区别,其实后面咱们的手写代码曾经给出答案了,咱们这里再就这个问题具体解说下。exportsmodule.exports 这两个变量都是通过上面这行代码注入的。

compiledWrapper.call(this.exports, this.exports, this.require, this,
    filename, dirname);

初始状态下,exports === module.exports === {}exportsmodule.exports 的一个援用,如果你始终是这样应用的:

exports.a = 1;
module.exports.b = 2;

console.log(exports === module.exports);   // true

上述代码中,exportsmodule.exports 都是指向同一个对象 {},你往这个对象上增加属性并没有扭转这个对象自身的援用地址,所以exports === module.exports 始终成立。

然而如果你哪天这样应用了:

exports = {a: 1}

或者这样应用了:

module.exports = {b: 2}

那其实你是给 exports 或者 module.exports 从新赋值了,扭转了他们的援用地址,那这两个属性的连贯就断开了,他们就不再相等了。须要留神的是,你对 module.exports 的从新赋值会作为模块的导出内容,然而你对 exports 的从新赋值并不能扭转模块导出内容,只是扭转了 exports 这个变量而已,因为模块始终是module,导出内容是module.exports

循环援用

Node.js 对于循环援用是进行了解决的,上面是官网例子:

a.js:

console.log('a 开始');
exports.done = false;
const b = require('./b.js');
console.log('在 a 中,b.done = %j', b.done);
exports.done = true;
console.log('a 完结');

b.js:

console.log('b 开始');
exports.done = false;
const a = require('./a.js');
console.log('在 b 中,a.done = %j', a.done);
exports.done = true;
console.log('b 完结');

main.js:

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

main.js 加载 a.js 时,a.js 又加载 b.js。此时,b.js 会尝试去加载 a.js。为了避免有限的循环,会返回一个 a.jsexports 对象的 未实现的正本b.js 模块。而后 b.js 实现加载,并将 exports 对象提供给 a.js 模块。

那么这个成果是怎么实现的呢?答案就在咱们的 MyModule._load 源码外面,留神这两行代码的程序:

MyModule._cache[filename] = module;

module.load(filename);

上述代码中咱们是先将缓存设置了,而后再执行的真正的load,顺着这个思路我能来理一下这里的加载流程:

  1. main加载 aa 在真正加载前先去缓存中占一个地位
  2. a在正式加载时加载了b
  3. b又去加载了 a,这时候缓存中曾经有a 了,所以间接返回 a.exports,即便这时候的exports 是不残缺的。

总结

  1. require不是黑魔法,整个 Node.js 的模块加载机制都是 JS 实现的。
  2. 每个模块外面的 exports, require, module, __filename, __dirname 五个参数都不是全局变量,而是模块加载的时候注入的。
  3. 为了注入这几个变量,咱们须要将用户的代码用一个函数包裹起来,拼一个字符串而后调用沙盒模块 vm 来实现。
  4. 初始状态下,模块外面的 this, exports, module.exports 都指向同一个对象,如果你对他们从新赋值,这种连贯就断了。
  5. module.exports 的从新赋值会作为模块的导出内容,然而你对 exports 的从新赋值并不能扭转模块导出内容,只是扭转了 exports 这个变量而已,因为模块始终是module,导出内容是module.exports
  6. 为了解决循环援用,模块在加载前就会被退出缓存,下次再加载会间接返回缓存,如果这时候模块还没加载完,你可能拿到未实现的exports
  7. Node.js 实现的这套加载机制叫CommonJS

本文残缺代码已上传 GitHub:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/Node.js/Module/MyModule/index.js

参考资料

Node.js 模块加载源码:https://github.com/nodejs/node/blob/c6b96895cc74bc6bd658b4c6d5ea152d6e686d20/lib/internal/modules/cjs/loader.js

Node.js 模块官网文档:http://nodejs.cn/api/modules.html

文章的最初,感激你破费贵重的工夫浏览本文,如果本文给了你一点点帮忙或者启发,请不要悭吝你的赞和 GitHub 小星星,你的反对是作者继续创作的能源。

作者博文 GitHub 我的项目地址:https://github.com/dennis-jiang/Front-End-Knowledges

退出移动版