首頁 > web前端 > js教程 > 深入淺析Node.js的模組載入機制

深入淺析Node.js的模組載入機制

青灯夜游
發布: 2020-09-02 10:36:43
轉載
2358 人瀏覽過

深入淺析Node.js的模組載入機制

模組是Node.js裡面一個很基本也很重要的概念,各種原生類別庫是透過模組提供的,第三方函式庫也是透過模組進行管理和引用的。本文會從基本的模組原理出發,到最後我們會利用這個原理,自己實作一個簡單的模組載入機制,也就是自己實作一個require。        

Node 使用 JavaScript 與 commonjs 模組,並將 npm/yarn 為其套件管理器。

【影片教學推薦:node 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.jsonpackage.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.Jjs

#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[&#39;.js&#39;] = function (module, filename) {
  const content = fs.readFileSync(filename, &#39;utf8&#39;);
  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 = [
  &#39;(function (exports, require, module, __filename, __dirname) { &#39;,
  &#39;\n});&#39;
];
登入後複製

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

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[&#39;.json&#39;] = function (module, filename) {
  const content = fs.readFileSync(filename, &#39;utf8&#39;);
  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(&#39;a 开始&#39;);
exports.done = false;
const b = require(&#39;./b.js&#39;);
console.log(&#39;在 a 中,b.done = %j&#39;, b.done);
exports.done = true;
console.log(&#39;a 结束&#39;);
登入後複製

b.js:

console.log(&#39;b 开始&#39;);
exports.done = false;
const a = require(&#39;./a.js&#39;);
console.log(&#39;在 b 中,a.done = %j&#39;, a.done);
exports.done = true;
console.log(&#39;b 结束&#39;);
登入後複製

main.js:

console.log(&#39;main 开始&#39;);
const a = require(&#39;./a.js&#39;);
const b = require(&#39;./b.js&#39;);
console.log(&#39;在 main 中,a.done=%j,b.done=%j&#39;, 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

更多编程相关知识,可访问:编程教学!!

以上是深入淺析Node.js的模組載入機制的詳細內容。更多資訊請關注PHP中文網其他相關文章!

相關標籤:
來源:segmentfault.com
本網站聲明
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
最新問題
熱門教學
更多>
最新下載
更多>
網站特效
網站源碼
網站素材
前端模板