深刻Node.js的模塊加載機制,手寫require函數

模塊是Node.js裏面一個很基本也很重要的概念,各類原生類庫是經過模塊提供的,第三方庫也是經過模塊進行管理和引用的。本文會從基本的模塊原理出發,到最後咱們會利用這個原理,本身實現一個簡單的模塊加載機制,即本身實現一個requirejavascript

本文完整代碼已上傳GitHub:github.com/dennis-jian…html

簡單例子

老規矩,講原理前咱們先來一個簡單的例子,從這個例子入手一步一步深刻原理。Node.js裏面若是要導出某個內容,須要使用module.exports,使用module.exports幾乎能夠導出任意類型的JS對象,包括字符串,函數,對象,數組等等。咱們先來建一個a.js導出一個最簡單的hello world:前端

// a.js 
module.exports = "hello world";
複製代碼

而後再來一個b.js導出一個函數:java

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

module.exports = add;
複製代碼

而後在index.js裏面使用他們,即require他們,require函數返回的結果就是對應文件module.exports的值:node

// 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.jsjquery

// 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並不影響前面的這行代碼:git

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

console.log(c);  // c的值是2
複製代碼

前面c.js的變量c是一個基本數據類型,因此後面的c = 6;不影響前面的module.exports,那他若是是一個引用類型呢?咱們直接來試試吧:github

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

d.num++;

module.exports = d;

d.num = 6;
複製代碼

而後在index.js裏面require他:json

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

console.log(d);     // { num: 6 }
複製代碼

咱們發如今module.exports後面給d.num賦值仍然生效了,由於d是一個對象,是一個引用類型,咱們能夠經過這個引用來修改他的值。其實對於引用類型來講,不只僅在module.exports後面能夠修改他的值,在模塊外面也能夠修改,好比index.js裏面就能夠直接改:api

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官方源碼,函數名和變量名儘可能保持一致,其實就是精簡版的源碼,你們能夠對照着看,寫到具體方法時我也會貼上對應的源碼地址。整體的代碼都在這個文件裏面:github.com/nodejs/node…

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方法,源碼看這裏:github.com/nodejs/node…。精簡版的代碼以下:

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;
}
複製代碼

上述代碼對應的源碼看這裏:github.com/nodejs/node…

能夠看到上述源碼還調用了兩個方法: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對應的源碼看這裏:github.com/nodejs/node…

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實例,由於他是一個實例方法。對應的源碼看這裏: github.com/nodejs/node…

加載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來執行他。對應的源碼看這裏:github.com/nodejs/node…

編譯執行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文件其實已經記載完了,對應的源碼看這裏: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);
}
複製代碼

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:github.com/dennis-jian…

參考資料

Node.js模塊加載源碼:github.com/nodejs/node…

Node.js模塊官方文檔:nodejs.cn/api/modules…

文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。

歡迎關注個人公衆號進擊的大前端第一時間獲取高質量原創~

「前端進階知識」系列文章:juejin.cn/post/684490…

「前端進階知識」系列文章源碼GitHub地址: github.com/dennis-jian…

相關文章
相關標籤/搜索