本文从源码出发,梳理一下nodeJS的模块加载机制。

核心模块

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

以上是模块加载的核心模块。我们每require一个模块,都会创造一个上面这种模块实例对象。那么我们在执行require的时候,有哪些步骤呢?答案就在_load方法中。

  1. MyModule._cache = Object.create(null);
  2. MyModule._load = function (request) {
  3. // step1:获取文件绝对路径以及完整文件名
  4. const filename = MyModule._resolveFilename(request);
  5. // step2:检查缓存,如果有缓存直接返回
  6. const cachedModule = MyModule._cache[filename];
  7. if (cachedModule !== undefined) {
  8. return cachedModule.exports;
  9. }
  10. // step3:构造模块实例对象,并加载模块
  11. const module = new MyModule(filename);
  12. MyModule._cache[filename] = module;
  13. module.load(filename);
  14. return module.exports;
  15. }
  16. // require的本质
  17. MyModule.prototype.require = function (id) {
  18. return MyModule._load(id);
  19. }

可以看到,当我们调用require方法时,我们拿到的实际上是_load方法的返回值,而这个返回值就是实例对象上的exports属性。在_load中,一共有三步。我们接下来会详细的讲解这三步。

step1:获取文件绝对路径以及完整文件名

我们来看_resolveFilename是怎么实现的

  1. MyModule._extensions = Object.create(null);
  2. MyModule._resolveFilename = function (request) {
  3. const filename = path.resolve(request); // 获取传入参数对应的绝对路径
  4. const extname = path.extname(request); // 获取文件后缀名
  5. // 如果没有文件后缀名,尝试添加.js和.json
  6. if (!extname) {
  7. const exts = Object.keys(MyModule._extensions);
  8. for (let i = 0; i < exts.length; i++) {
  9. const currentPath = `${filename}${exts[i]}`;
  10. // 如果拼接后的文件存在,返回拼接的路径
  11. if (fs.existsSync(currentPath)) {
  12. return currentPath;
  13. }
  14. }
  15. }
  16. return filename;
  17. }

我们在写require的时候,常常把文件扩展名省略掉了。但是在源码处理当中实际上是又给我们加上了。这个方法实际作用就是得到文件的绝对路径以及完整的文件名,并返回。如果文件扩展名被省略了,则将当前文件名和可支持的文件扩展名拼接,并搜索是否存在。最后返回拼接后的文件名。

step2:检查缓存,如果有缓存直接返回

step3:加载模块

这是模块加载的重头戏,先是创建模块实例对象,再把它加入缓存。这没啥好讲的,重点关注load方法。

  1. MyModule.prototype.load = function (filename) {
  2. // 获取文件后缀名
  3. const extname = path.extname(filename);
  4. // 调用后缀名对应的处理函数来处理
  5. MyModule._extensions[extname](this, filename);
  6. this.loaded = true;
  7. }
  8. MyModule._extensions['.js'] = function (module, filename) {
  9. const content = fs.readFileSync(filename, 'utf8'); // 同步读文件
  10. module._compile(content, filename);
  11. }
  12. MyModule._extensions['.json'] = function (module, filename) {
  13. const content = fs.readFileSync(filename, 'utf8');
  14. module.exports = JSON.parse(content);
  15. }

这部分内容比较多,我们按照顺序来讲。
load方法作用是什么?作用就是根据不同类型的文件,执行不同的加载策略。require支持js,json以及node格式的文件加载。本文只讨论js和json的加载。这里,我们再次遇到了MyModule._extensions这个对象。这个对象的key是类型名,value是处理该类型文件的方法。
对于JSON文件,我们读文件拿到内容后,直接使用JSON.parse就可以将读取的文件变为json对象形式。并将处理后的内容赋给module.exports
而对于js文件,我们的处理要更复杂一些。

js文件的处理

对于js的文件处理,主要集中在_compile这个方法中。

  1. MyModule.wrapper = [
  2. '(function (exports, require, module, __filename, __dirname) { ',
  3. '\n});'
  4. ];
  5. MyModule.wrap = function (script) {
  6. return MyModule.wrapper[0] + script + MyModule.wrapper[1];
  7. };
  8. MyModule.prototype._compile = function (content, filename) {
  9. const wrapper = MyModule.wrap(content); // 获取包装后函数体
  10. // vm是nodejs的虚拟机模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
  11. // 返回值就是转化后的函数,所以compiledWrapper是一个函数
  12. const compiledWrapper = vm.runInThisContext(wrapper, {
  13. filename,
  14. lineOffset: 0,
  15. displayErrors: true,
  16. });
  17. // 准备exports, require, module, __filename, __dirname这几个参数
  18. // exports可以直接用module.exports,即this.exports
  19. // require官方源码中还包装了一层,其实我们这里可以直接使用this.require
  20. // module不用说,就是this了
  21. // __filename直接用传进来的filename参数了
  22. // __dirname需要通过filename获取下
  23. const dirname = path.dirname(filename);
  24. compiledWrapper.call(this.exports, this.exports, this.require, this,
  25. filename, dirname);
  26. }

compile方法第一步就是将之前读取的js文件内容,包裹为一个方法。这样做的好处一是可以通过运行方法的方式执行一遍文件内部代码,从而拿到导出的值。另一个好处就是隔离var这种全局变量,避免全局污染。

包装为方法后,此时还是字符串,要变成可执行的方法体。要么通过eval实现,要么就用vm.runInThisContext实现,这里使用后者实现。之后调用call。执行方法。
关键来了,这里的参数很关键,其中一个就是export参数
此时传进去的export是形参,其地址和module.exports指向相同(因为是引用类型嘛)。但是内部不可将export直接赋值. 如: export = 1这样就会丢失module.exports的引用。

完整代码

  1. const path = require('path');
  2. const vm = require('vm');
  3. const fs = require('fs');
  4. function MyModule(id = '') {
  5. this.id = id; // 这个id其实就是我们require的路径
  6. this.path = path.dirname(id); // path是Node.js内置模块,用它来获取传入参数对应的文件夹路径
  7. this.exports = {}; // 导出的东西放这里,初始化为空对象
  8. this.filename = null; // 模块对应的文件名
  9. this.loaded = false; // loaded用来标识当前模块是否已经加载
  10. }
  11. MyModule._cache = Object.create(null);
  12. MyModule._extensions = Object.create(null);
  13. MyModule._load = function (request) { // request是我们传入的路劲参数
  14. const filename = MyModule._resolveFilename(request);
  15. // 先检查缓存,如果缓存存在且已经加载,直接返回缓存
  16. const cachedModule = MyModule._cache[filename];
  17. if (cachedModule !== undefined) {
  18. return cachedModule.exports;
  19. }
  20. // 如果缓存不存在,我们就加载这个模块
  21. // 加载前先new一个MyModule实例,然后调用实例方法load来加载
  22. // 加载完成直接返回module.exports
  23. const module = new MyModule(filename);
  24. // load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
  25. MyModule._cache[filename] = module;
  26. module.load(filename);
  27. return module.exports;
  28. }
  29. /**
  30. * 获取完整文件名(带绝对路径)
  31. * @param request 文件名
  32. * @returns {string} 完整文件名(带绝对路径)
  33. * @private
  34. */
  35. MyModule._resolveFilename = function (request) {
  36. const filename = path.resolve(request); // 获取传入参数对应的绝对路径
  37. const extname = path.extname(request); // 获取文件后缀名
  38. // 如果没有文件后缀名,尝试添加.js和.json
  39. if (!extname) {
  40. const exts = Object.keys(MyModule._extensions);
  41. for (let i = 0; i < exts.length; i++) {
  42. const currentPath = `${filename}${exts[i]}`;
  43. // 如果拼接后的文件存在,返回拼接的路径
  44. if (fs.existsSync(currentPath)) {
  45. return currentPath;
  46. }
  47. }
  48. }
  49. return filename;
  50. }
  51. MyModule.prototype.require = function (id) {
  52. return MyModule._load(id);
  53. }
  54. MyModule.prototype.load = function (filename) {
  55. // 获取文件后缀名
  56. const extname = path.extname(filename);
  57. // 调用后缀名对应的处理函数来处理
  58. MyModule._extensions[extname](this, filename);
  59. this.loaded = true;
  60. }
  61. MyModule._extensions['.js'] = function (module, filename) {
  62. const content = fs.readFileSync(filename, 'utf8'); // 同步读文件
  63. module._compile(content, filename);
  64. }
  65. MyModule.wrapper = [
  66. '(function (exports, require, module, __filename, __dirname) { ',
  67. '\n});'
  68. ];
  69. MyModule.wrap = function (script) {
  70. return MyModule.wrapper[0] + script + MyModule.wrapper[1];
  71. };
  72. MyModule.prototype._compile = function (content, filename) {
  73. const wrapper = MyModule.wrap(content); // 获取包装后函数体
  74. // vm是nodejs的虚拟机模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
  75. // 返回值就是转化后的函数,所以compiledWrapper是一个函数
  76. const compiledWrapper = vm.runInThisContext(wrapper, {
  77. filename,
  78. lineOffset: 0,
  79. displayErrors: true,
  80. });
  81. // 准备exports, require, module, __filename, __dirname这几个参数
  82. // exports可以直接用module.exports,即this.exports
  83. // require官方源码中还包装了一层,其实我们这里可以直接使用this.require
  84. // module不用说,就是this了
  85. // __filename直接用传进来的filename参数了
  86. // __dirname需要通过filename获取下
  87. const dirname = path.dirname(filename);
  88. compiledWrapper.call(this.exports, this.exports, this.require, this,
  89. filename, dirname);
  90. }
  91. MyModule._extensions['.json'] = function (module, filename) {
  92. const content = fs.readFileSync(filename, 'utf8');
  93. module.exports = JSON.parse(content);
  94. }
  95. // 起始require,源码没有这个。
  96. function myRequire(id) {
  97. return MyModule._load(id);
  98. }
  99. const a = myRequire('./a.js');
  100. const add = myRequire('./b.js');
  101. const c = myRequire('./c.js');
  102. const c_copy = myRequire('./c.js');
  103. const d = myRequire('./d.js');
  104. const e = myRequire('./e.json');
  105. console.log(a);
  106. console.log(add(1, 2));
  107. console.log(c);
  108. console.log(c_copy);
  109. console.log(d);

后记

我这里实现的只是简单版的requre,真正的requre的流程如下:

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

我只是实现了其中的2 3 而已。