在我們使用 進行日常開發時,經常會使用 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 模組主外實現封裝的模組編寫方式。
更多程式設計相關知識,請存取:!!
以上就是一文了解Node中的檔案模組和核心模組的詳細內容,更多請關注TW511.COM其它相關文章!