NodeJS require()原始碼解析

逆月翎發表於2019-06-24

最開始談NodeJS的時候寫過一篇文章談了它與Java各自的優缺點。NodeJS最早的定位是什麼樣的呢?最早開發者Ryan Dahl是想提升自己的工作效率,是為了開發一個高效能伺服器,那高效能伺服器的要求是什麼呢?他覺得一個高效能伺服器應該滿足“事件驅動,非阻塞I/O模型”。最後,Ryan Dahl基於ChromeV8引擎開發了NodeJS。正是由於NodeJS的出現,使得類似React/Vue/Angular這類前端框架大放異彩,NodeJS是這些框架開發環境的基礎。

NPM作為NodeJS的模組倉庫,到目前為止存放模組已經超過15萬個模組。瞭解過NodeJS的人都知道我們載入一個模組使用require語句去進行載入。那我們有必要去研究require語句的內部執行機制,它究竟是如何去載入一個模組的呢?

首先我們先看看require語句的基本用法:

NodeJS require()原始碼解析

require語句是如何去查詢並且載入到我們指定的檔案?NodeJS處理require語句時一般有以下三種處理方式:

NodeJS require()原始碼解析

我們在路徑D:\work\work\project\inde.js中引入crypto.js包,程式碼為:

var crypto = require('crypto');複製程式碼

搜尋時首先確定這是屬於上述的第三種情況,所以NodeJS內部執行過程是這樣滴:

首先,確定crypto可能存在的目錄位置:

D:\work\work\project\node_modules\crypto
D:\work\work\node_modules\crypto
D:\work\node_modules\crypto
D:\node_modules\crypto複製程式碼

然後將crypto當作一個檔名,依次進入目錄開始搜尋,只要搜尋一個檔案為crypto的檔案則立即返回。順序按照上面所說的逐一拼接檔案字尾進行嘗試:

crypto
crypto.js
crypto.json
crypto.node複製程式碼

如果在所有目錄中都沒有找到符合要求的檔案,則說明crypto可能是一個目錄。再次依次嘗試載入crypto中的index檔案。依次查詢順序為:

crypto/package.json
crypto/index.js
crypto/index.json
crypto/index.node複製程式碼

如果在所有目錄都無法找到crypto對應的檔案或者目錄中的index檔案,則返回異常。那麼瞭解了NodeJS內部執行邏輯以後,我們可以閱讀下NodeJS原始碼,看看require語句究竟是如何進行操作的。

首先我們下載一份NodeJS原始碼,require語句原始碼位置:

node\lib\internal\modules\cjs\loader.js複製程式碼

首先NodeJS有定義一個建構函式Module。所有的模組實質上都是建構函式Module的一個例項。

NodeJS require()原始碼解析

當前模組loader.js實際上也是Module的一個例項,在檔案底部定義:

Module.Module = Module;複製程式碼

每一個例項都擁有自己的屬性,一般有下列常用屬性:

id: 沒有父模組則id就是一個。若存在父模組則id和filename都是模組的絕對路徑。
parent:模組的父模組,模組沒有依賴父模組,則parent為空
filename:模組所在位置的決定路徑
loader:模組還未全部載入,則為false。模組全部載入則為true。
path:模組可能存在的位置,為一個陣列。複製程式碼

每個模組例項都存在一個require方法,所以require命令實質上是每個模組內部提供的一個內部方法。所以只有在模組內部才能使用require語句:

NodeJS require()原始碼解析

實際上require內部呼叫的方法為:

Module._load(path, this);複製程式碼

那我們再來看下_load()的原始碼部分:

NodeJS require()原始碼解析

我們來解讀一下這段原始碼:

1.計算絕對路徑,程式碼為:

var filename = Module._resolveFilename(request, parent, isMain);複製程式碼

2.判斷是否有快取,有快取則去除快取中的資料

var cachedModule = Module._cache[filename];
if (cachedModule) {     
    updateChildren(parent, cachedModule, true);     
    return cachedModule.exports;
}複製程式碼

3.判斷模組是否為核心模組

if (NativeModule.nonInternalExists(filename)) {      
    debug('load native module %s', request);      
    return NativeModule.require(filename);
}複製程式碼


4.生成模組的例項,並且將例項存入到快取中

var module = new Module(filename, parent);
if (isMain) {     
    process.mainModule = module;     
    module.id = '.';
}

Module._cache[filename] = module;複製程式碼


5.載入模組

try {    
    module.load(filename);    
    threw = false;
} finally {    
    if (threw) {        
        delete Module._cache[filename];    
    }
}複製程式碼


6.輸出模組的屬性

return module.exports;複製程式碼


從上面的原始碼分析可以看出,其實看出Module._load(path, this)其實最主要的兩個方法為:

Module._resolveFilename(request, parent, isMain):確定模組的絕對路徑
module.load(filename):載入模組複製程式碼

那我們接著去看下_resolveFilename()的原始碼:

NodeJS require()原始碼解析


還記得文章開頭我講到的NodeJS檔案查詢有三種處理方式,內部定義的原始碼就在這裡。接下來我們分析下原始碼:

  1. 如果為核心模組,直接返回模組,查詢模組結束:

if (NativeModule.nonInternalExists(request)) {        
    return request;
}複製程式碼

2.確定檔案可能存在的所有路徑

if (typeof options === 'object' && options !== null && Array.isArray(options.paths)) {        
    const fakeParent = new Module('', null);        
    paths = [];        
    for (var i = 0; i < options.paths.length; i++) {            
        const path = options.paths[i];            
        fakeParent.paths = Module._nodeModulePaths(path);            
        const lookupPaths = Module._resolveLookupPaths(request, fakeParent, true);            
        if (!paths.includes(path))                
            paths.push(path);            
            for (var j = 0; j < lookupPaths.length; j++) {                
                if (!paths.includes(lookupPaths[j]))                    
                    paths.push(lookupPaths[j]);            
                }        
            }    
        } else {        
            paths = Module._resolveLookupPaths(request, parent, true);    
        }
    }
} 
複製程式碼

3.確定哪一個路徑為模組真是路徑

var filename = Module._findPath(request, paths, isMain);    
if (!filename) { 
    var err = new Error(`Cannot find module '${request}'`);        
    err.code = 'MODULE_NOT_FOUND';        
    throw err;    
}    

return filename;複製程式碼

而我們可以發現在Module.resolveFilename()方法中最重要的兩個方法分別是:

Module._resolveLookupPaths():查詢模組所有可能存在的路徑
Module._findPath():判斷哪一個路徑為模組的真實路徑複製程式碼

我們繼續先看下Module._resolveLookupPaths()原始碼,由於本方法內容過多,只擷取關鍵程式碼部分:

NodeJS require()原始碼解析

查詢思想其實就是從目前所在的相對目錄一直往外層遞推去查詢node_modules目錄,最後以陣列的形式將所有目錄的路徑返回;

查詢到模組所有可能存在的路徑之後,我們再來分析下Module._findPath() 的原始碼,用來確定到底哪一個是正確路徑:

NodeJS require()原始碼解析

其實就是通過前面例項說過的幾根步驟去逐一進行判斷檔案實際存在於哪個目錄中:

1.列出所有可能的字尾名
2.如果是絕對路徑,則無需繼續搜尋。
3.如果當前路徑已在快取中,則直接返回快取
4.依次遍歷所有路徑,依次加上字尾看檔案是否存在。
5.檔案不存在則可能為目錄,判斷是否有目錄/index檔案或目錄/package.json檔案
6.若查詢到檔案則將檔案路徑存入快取,然後返回。
7.若檔案所有可能存在的路徑遍歷結束,未找到檔案,則返回false複製程式碼

查詢檔案的真實路徑說完了,那就只剩最後一個重點:關於載入模組的方法module.load()的原始碼分析:

NodeJS require()原始碼解析

其實就是根據模組字尾名的不同採用不同的載入方式。判斷模組字尾名呼叫了

findLongestRegisteredExtension(filename)複製程式碼

我們可以看下原始碼:

NodeJS require()原始碼解析

裡面使用了Module._extensions[currentExtension]來針對不同字尾檔案進行判斷:

NodeJS require()原始碼解析

最後需要編譯模組,用到了Module.prototype._compile()方法:

NodeJS require()原始碼解析


所以實質上載入模組的完整邏輯就是三個步驟:

1.傳參exports, require, module三個全域性變數
2.然後編譯執行模組的原始碼
3.將模組的export變數進行輸出。複製程式碼

本篇文章到這裡對require語句的原始碼分析就完成了。很多人覺得有事沒事扯原始碼目的就是提高逼格,其實讀讀原始碼我們可以學到很多東西,我們可以學習別人優美的程式碼書寫,學習別人對設計模式的熟練使用,或者對整個系統架構的佈局。對我們技術提升是有非常大的幫助的。

謝謝大家的觀看,如果喜歡我的文章,歡迎關注我的個人公眾號: 周先生自留地

NodeJS require()原始碼解析




相關文章