深入Node.js的模組載入機制,手寫require函式

蔣鵬飛發表於2020-08-31

模組是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

相關文章