當我們使用Nodejs 進行日常開發時,經常會使用require 導入兩類模組,一類是我們自己寫的模組或使用npm 安裝的第三方模組,這類模組在Node 中稱為文件模块
;另一類則是Node 內建的提供給我們使用的模組,如os
、 fs
等模組,這些模組稱為核心模块
。
需要注意的是,文件模組與核心模組的差異不僅僅在於是否被Node 內置,具體到模組的文件定位、編譯和執行過程,兩者之間都存在明顯的差別。不僅如此,檔案模組還可以細分為普通檔案模組、自訂模組或C/C++ 擴充模組等等,不同的模組在檔案定位、編譯等流程也存在許多細節上的差異。
本文會就這些問題,理清文件模組與核心模組的概念以及它們在文件定位、編譯或執行等流程的具體過程和需要注意的細節,希望對你有所幫助。
我們先從文件模組講起。
什麼是文件模組呢?
在Node 中,使用.、.. 或/
開頭的模組標識符(也就是使用相對路徑或絕對路徑)來require 的模組,都會被當作文件模組。另外,還有一類特殊的模組,雖然不含有相對路徑或絕對路徑,也不是核心模組,但是會指向一個包,Node 在定位這類模組時,會用模块路径
逐個查找該模組,這類模組被稱為自訂模組。
因此,檔案模組包含兩類,一類是帶有路徑的普通檔案模組,一類是不帶路徑的自訂模組。
文件模組在運行時動態加載,需要完整的文件定位、編譯執行過程,速度比核心模組慢。
對於檔案定位而言,Node 對這兩類檔案模組的處理有所不同。我們來具體看看這兩類文件模組的查找流程。
對於普通的文件模組,由於攜帶路徑,指向非常明確,查找耗時不會很久,因此查找效率比下文要介紹的自訂模組要高一些。不過還是有兩點要注意。
一是通常情況下,使用require 引入檔案模組時一般都不會指定檔案副檔名,例如:
const math = require("math");
由於沒有指定副檔名,Node 還不能確定最終的檔案。在這種情況下,Node 會依照.js、.json、.node
的順序補足副檔名,依序嘗試,這個過程稱為文件扩展名分析
。
另外要注意的是,在實際開發中,除了require 一個特定的檔案外,我們通常還會指定一個目錄,例如:
const axios = require("../network");
在這種情況下,Node 會先進行文件副檔名分析,如果沒有查找到對應文件,但得到了一個目錄,此時Node 會將該目錄當作一個包來處理。
具體而言,Node 會將目錄中的package.json
的main
欄位所指向的檔案作為查找結果傳回。如果main 所指向的檔案錯誤,或是壓根不存在package.json
文件,Node 會使用index
作為預設檔名,然後依序使用.js
、 .node
進行副檔名分析,逐一尋找目標文件,如果沒有找到的話就會拋出錯誤。
(當然,由於Node 存在兩類模組系統CJS 和ESM,除了查找main 字段外,Node 還會採用其他方式,由於不在本文討論範圍內,就不再贅述了。)
剛才提到, Node 在尋找自訂模組的過程中,會使用到模組路徑,那什麼是模組路徑呢?
熟悉模組解析的朋友應該都知道,模組路徑是一個由路徑組成的數組,具體的值可以看以下這個範例:
// example.js console.log(module.paths);
列印結果:
可以看到,Node 中的模組存在一個模組路徑數組,存放在module.paths
中,用於規定Node 如何查找目前模組引用的自訂模組。
具體來講,Node 會遍歷模組路徑數組,逐一嘗試其中的路徑,查找該路徑對應的node_modules
目錄中是否有指定的自訂模組,如果沒有就向上逐級遞歸,一直到根目錄下的node_modules
目錄,直到找到目標模組為止,如果找不到的話就會拋出錯誤。
可以看出,逐級向上遞迴查找node_modules
目錄是Node 查找自訂模組的策略,而模組路徑便是這個策略的具體實作。
同時我們也得出一個結論,在尋找自訂模組時,層級越深,對應的查找耗時就會越多。因此相較於核心模組和普通的檔案模組,自訂模組的載入速度是最慢的。
當然,根據模組路徑查找到的只是一個目錄,並不是一個具體的文件,在查找到目錄後,同樣地,Node 會根據上文所描述的包處理流程進行查找,具體過程不再贅述了。
以上是普通檔案模組和自訂模組的檔案定位的流程和需要注意的細節,接下來我們來看者兩類模組是如何編譯執行的。
當定位到require 所指向的檔案後,通常模組標識符都不帶有副檔名,根據上文提到的檔案副檔名分析我們可以知道,Node 支援三種副檔名的編譯執行:
JavaScript 檔。透過fs
模組同步讀取檔案後編譯執行。除了.node
和.json
文件,其他文件都會被當作.js
檔案載入。
.node
文件,這是用C/C++ 編寫後編譯生成的擴充文件,Node 透過process.dlopen()
方法載入該文件。
json 文件,透過fs
模組同步讀取文件後,使用JSON.parse()
解析並傳回結果。
在對檔案模組進行編譯執行之前,Node 會使用如下所示的模組封裝器對其進行包裝:
(function(exports, require, module, __filename, __dirname) { // 模組程式碼});
可以看到,透過模組封裝器,Node 將模組包裝進函數作用域中,與其他作用域隔離,避免變數的命名衝突、污染全域作用域等問題,同時,透過傳入exports、require 參數,使此模組具備應有的導入與導出能力。這便是Node 對模組的實作。
了解了模組封裝器後,我們先來看看json 檔案的編譯執行流程。
json 檔案的編譯執行是最簡單的。在透過fs
模組同步讀取JSON 檔案的內容後,Node 會使用JSON.parse() 解析出JavaScript 對象,然後將它賦給該模組的exports 對象,最後再返回給引用它的模組,過程十分簡單粗暴。
在使用模組包裝器對JavaScript 檔案進行包裝後,包裝之後的程式碼會透過vm
模組的runInThisContext()
(類似eval) 方法執行,傳回一個function 物件。
然後,將該JavaScript 模組的exports、require、module 等參數傳遞給這個function 執行,執行之後,模組的exports 屬性被傳回給呼叫方,這就是JavaScript 檔案的編譯執行過程。
在講解C/C++ 擴充模組的編譯執行之前,先介紹一下什麼是C/C++ 擴充模組。
C/C++ 擴充模組屬於文件模組中的一類,顧名思義,這類模組由C/C++ 編寫,與JavaScript 模組的區別在於其加載之後不需要編譯,直接執行之後就可以被外部調用了,因此其加載速度比JavaScript 模組略快。相較於以JS 編寫的檔案模組,C/C++ 擴充模組明顯更有效能上的優勢。對於Node 核心模組中無法覆蓋的功能或有特定的效能需求,使用者可以編寫C/C++ 擴充模組來達到目的。
那.node
檔又是什麼呢,它跟C/C++ 擴充模組有什麼關係?
事實上,編寫好之後的C/C++ 擴充模組經過編譯之後就產生了.node
檔。也就是說,身為模組的使用者,我們不會直接引入C/C++ 擴充模組的原始碼,而是引入C/C++ 擴充模組經過編譯之後的二進位。因此, .node
檔並不需要編譯,Node 在查找到.node
檔後,只需載入和執行該檔即可。在執行的過程中,模組的exports 物件被填充,然後傳回給呼叫者。
值得注意的是,C/C++ 擴充模組編譯產生的.node
檔案在不同平台下有不同的形式:在*nix
系統下C/C++ 擴充模組被g++/gcc 等編譯器編譯為動態連結共用物件文件,副檔名為.so
;在Windows
下則被Visual C++ 編譯器編譯為動態連結函式庫文件,副檔名為.dll
。但是在我們實際使用時使用的擴展名卻是.node
,事實上.node
的擴展名只是為了看起來更自然一點,實際上,在Windows
下它是一個.dll
文件,在*nix
下則是一個.so
文件。
Node 在查找到要require 的.node
檔案之後,會呼叫process.dlopen()
方法對該檔案進行載入和執行。由於.node
檔案在不同平台下是不同的檔案形式,為了實現跨平台, dlopen()
方法在Windows
和*nix
平台下分別有不同的實現,然後透過libuv
相容層進行封裝。下圖是C/C++ 擴充模組在不同平台下編譯和載入的過程:
核心模組在Node 原始碼的編譯過程中,就編譯進了二進位執行檔。在Node 進程啟動時,部分核心模組就直接載入進記憶體中,所以這部分核心模組引入時,檔案定位和編譯執行這兩個步驟可以省略掉,並且在路徑分析中會比檔案模組優先判斷,所以它的載入速度是最快的。
核心模組其實分為C/C++ 編寫的和JavaScript 編寫的兩個部分,其中C/C++ 檔案存放在Node 專案的src 目錄下,JavaScript 檔案存放在lib 目錄下。顯然,這兩部分模組的編譯執行流程都有所不同。
對於JavaScript 核心模組的編譯,在Node 原始碼的編譯過程中,Node 會採用V8 隨附的js2c.py 工具,將所有內建的JavaScript 程式碼,包含JavaScript 核心模組,轉換為C++ 裡的數組,JavaScript 程式碼就這樣以字串的形式儲存在node 命名空間中。在啟動Node 進程時,JavaScript 程式碼就會直接載入進記憶體。
當引入JavaScript 核心模組時,Node 會呼叫process.binding()
透過模組識別碼分析定位到其在記憶體中的位置,將其取出。取出後,JavaScript 核心模組同樣會經歷模組包裝器的包裝,然後被執行,導出exports 對象,返回給呼叫者。
在核心模組中,有些模組全部由C/C++ 編寫,有些模組則由C/C++ 完成核心部分,其他部分則由JavaScript 實作包裝或向外導出,以滿足效能需求,像是buffer
、 fs
、 os
等模組都是部分透過C/C++ 寫的。這種C++ 模組主內完成核心,JavaScript 模組主外實作封裝的模式是Node 提高效能的常見方式。
核心模組中由純C/C++ 編寫的部分稱為內建模區塊,如node_fs
、 node_os
等,它們通常不會直接被使用者調用,而是被JavaScript 核心模組直接依賴。因此,在Node 的核心模組的引入過程中,存在這樣一條引用鏈:
那JavaScript 核心模組是如何載入內建模區塊的呢?
還記得process.binding()
方法嗎,Node 透過呼叫該方法實作將JavaScript 核心模組從記憶體中取出。此方法同樣適用於JavaScript 核心模組,來協助載入內建模區塊。
具體到該方法的實現,加載內建模塊時,首先創建一個exports 空對象,然後調用get_builtin_module()
方法取出內建模塊對象,通過執行register_func()
填充exports 對象,最後返回給調用方完成導出。這就是內建模區塊的載入和執行過程。
透過上述分析,對於引入核心模組這樣一條引用鏈,以os 模組為例,大致的流程如下:
總結來說,引入os 模組的過程經歷JavaScript 檔案模組的引入、JavaScript 核心模組的載入和執行和內建模區塊的載入執行,過程十分繁瑣複雜,但是對於模組的呼叫者來說,由於屏蔽了底層的複雜實作與細節,光是透過require() 就可完成整個模組的導入,十分簡潔。友好。
本文介紹了文件模組與核心模組的基本概念以及它們在文件定位、編譯或執行等流程的具體過程和需要注意的細節。具體而言:
文件模組根據文件定位過程的不同可以分為普通文件模組和自訂模組。普通檔案模組由於路徑明確,可以直接定位,有時會涉及到檔案副檔名分析、目錄分析的過程;自訂模組會根據模組路徑進行查找,查找成功之後也會透過目錄分析進行最終的檔案定位。
檔案模組根據編譯執行流程的差異可以分為JavaScript 模組和C/C++ 擴充模組。 JavaScript 模組被模組封裝器包裝之後透過vm
模組的runInThisContext
方法執行;C/C++ 擴充模組由於已經是經過編譯之後產生的可執行文件,因此可直接執行,返回導出物件給呼叫方。
核心模組分為JavaScript 核心模組和內建模區塊。 JavaScript 核心模組在Node 程序啟動時便被載入進記憶體中,透過process.binding()
方法可將其取出,然後執行;內建模區塊的編譯執行會經歷process.binding()
、 get_builtin_module()
和register_func()
函數的處理。
除此之外,我們也得到了Node 引進核心模組的引用鏈,也就是檔案模組-->JavaScript 核心模組-->內建模區塊,也學習了C++ 模組主內完成核心,JavaScript 模組主外實作封裝的模組編寫方式。