你需要了解的 Node.js 模組

發表於2017-03-30

Node 使用兩個核心模組來管理模組依賴:

  • require 模組,在全域性範圍可用——無需 require(‘require’)。
  • module 模組,在全域性範圍可用——無需 require(‘module’)。

你可以將 require 模組視為命令,將 module 模組視為所有必需模組的組織者。

在 Node 中獲取一個模組並不複雜。

由 require 模組匯出的主要物件是一個函式(如上例所用)。 當 Node 使用本地檔案路徑作為函式的唯一引數呼叫該 require() 函式時,Node 將執行以下步驟:

  • 解析:找到檔案的絕對路徑。
  • 載入:確定檔案內容的型別.
  • 封裝:給檔案其私有作用域。 這使得 require 和 module 物件兩者都可以下載我們需要的每個檔案。
  • 評估:這是 VM 對載入的程式碼最後需要做的。
  • 快取:當我們再次需要這個檔案時,不再重複所有的步驟。

在本文中,我將嘗試用示例解釋這些不同的階段,以及它們是如何影響我們在 Node 中編寫模組的方式的。

先在終端建立一個目錄來儲存所有示例:

本文之後所有命令都在 ~/learn-node 下執行。

解析本地路徑

我現在向你介紹 module 物件。你可以在一個的 REPL(譯者注:Read-Eval-Print-Loop,就是一般控制檯乾的事情)會話中很容易地看到它:

每個模組物件都有一個 id 屬性作為標識。這個 id 通常是檔案的完整路徑,不過在 REPL 會話中,它只是 。

Node 模組與檔案系統有著一對一的關係。請求模組就是把檔案內容載入到記憶體中。

不過,因為 Node 中有很多方法用於請求檔案(比如,使用相對路徑,或預定義的路徑),在我們把檔案內容載入到記憶體之前,我們需要找到檔案的絕對位置。

現在請求 ‘find-me’ 模組,但不指定路徑:

Node 會按順序在 module.paths 指定的路徑中去尋找 find-me.js。

路徑列表基本上會是從當前目錄到根目錄下的每一個 node_modules 目錄。它也會包含一些不推薦使用的遺留目錄。

如果 Node 在這些目錄下仍然找不到 find-me.js,它會丟擲 “cannot find module error.(不能找到模組)” 這個錯誤訊息。

現在建立一個區域性的 node_modules 目錄,放入一個 find-me.js,require(‘find-me’) 就能找到它。

如果別的路徑下存在另一個 find-me.js 檔案,例如在 home 目錄下存在 node_modules 目錄,其中有一個不同的 find-me.js:

現在 learn-node 目錄也包含 node_modules/find-me.js —— 在這個目錄下 require(‘find-me’),那麼 home 目錄下的 find-me.js 根本不會被載入:

如果刪除了~/learn-node 目錄下的的 node_modules 目錄,再次嘗試請求 find-me.js,就會使用 home 目錄下 node_modules 目錄中的 find-me.js 了:

請求一個目錄

模組不一定是檔案。我們也可以在 node_modules 目錄下建立一個 find-me 目錄,並在其中放一個 index.js 檔案。同樣的 require(‘find-me’) 會使用這個目錄下的 index.js 檔案:

注意如果存在區域性模組,home 下 node_modules 路徑中的相應模組仍然會被忽略。

在請求一個目錄的時候,預設會使用 index.js,不過我們可以通過 package.json 中的 main 選項來改變起始檔案。比如,希望 require(‘find-me’) 在 find-me 目錄下去使用另一個檔案,只需要在那個目錄下新增  package.json 檔案來完成這個事情:

require.resolve

如果你只是想找到模組,並不想執行它,你可以使用 require.resolve 函式。除了不載入檔案,它的行為與主函式 require 完全相同。如果檔案不存在它會丟擲錯誤,如果找到了指定的檔案,它會返回完整路徑。

這很有用,比如,檢查一個可選的包是否安裝並在它已安裝的情況下使用它。

相對路徑和絕對路徑

除了在 node_modules 目錄中查詢模組之外,我們也可以把模組放置於任何位置,然後通過相對路徑(./ 和 ../)請求,也可以通過以 / 開始的絕對路徑請求。

比如,如果 find-me.js 是放在 lib 目錄而不是 node_modules 目錄下,可以這樣請求:

檔案中的父子關係

建立 lib/util.js 檔案並新增一行 console.log 程式碼來識別它。console.log 會輸出模組自身的 module 物件:

在 index.js 檔案中幹同樣的事情,稍後我們會通過 node 命令執行這個檔案。讓 index.js 檔案請求 lib/util.js:

現在用 node 執行 index.js:

注意到現在的列表中主模組 index (id: ‘.’) 是 lib/util 模組的父模組。不過 lib/util 模組並未作為 index 的子模組列出來。不過那裡有個 [Circular] 值因為那裡存在迴圈引用。如果 Node 列印 lib/util 模組物件,它就會陷入一個無限迴圈。因此這裡用 [Circular] 代替了 lib/util 引用。

現在更重要的問題是,如果 lib/util 模組又請求了 index 模組,會發生什麼事情?這就是我們需要了解的迴圈依賴,Node 允許這種情況存在。

在理解它之前,我們先來搞明白 module 物件中的另外一些概念。

exports、module.exports 以及同步載入模組

exports 是每個模組都有的一個特殊物件。如果你觀察仔細,會發現上面示例中每次列印的模組物件中都存在一個 exports 屬性,到目前為止它只是個空物件。我們可以給這個特殊的 exports 物件任意新增屬性。例如,我們為 index.js 和 lib/util.js 匯出 id 屬性:

現在執行 index.js,我們會看到這些屬性受到 module 物件管理:

上面的輸出中我去掉了一些屬性,這樣看起來比較簡潔,不過請注意 exports 物件已經包含了我們在每個模組中定義的屬性。你可以在 exports 物件中任意新增屬性,也可以直接把 exports 整個替換成另一個物件。比如,可以把 exports 物件變成一個函式,我們會這樣做:

現在執行 index.js,你會看到 exports 物件是一個函式:

注意,我沒有通過 exports = function() {} 來將 exports 物件改變為函式。這樣做是不行的,因為模組中的 exports 變數只是 module.exports 的引用,它用於管理匯出屬性。如果我們重新給 exports 變數賦值,就會丟失對 module.exports 的引用,實際會產生一個新的變數,而不是改變了 module.exports。

每個模組中的 module.exports 物件就是通過 require 函式請求那個模組返回的。比如,把 index.js 中的 require(‘./lib/util’) 改為:

這段程式碼會輸出 lib/util 匯出到 UTIL 常量中的屬性。現在執行 index.js,輸出如下:

再來談談每個模組的 loaded 屬性。到目前為止,每次我們列印一個模組物件的時候,都會看到這個物件的 loaded 屬性值為 false。

module 模組使用 loaded 屬性來跟蹤哪些模組是載入過的(true值),以及哪些模組還在載入中(false 值)。比如我們可以通過呼叫 setImmediate 來列印 modules 物件,在下一事件迴圈中看看完成載入的 index.js 模組:

輸出是這樣的:

注意理解它是如何推遲 console.log,使其在 lib/util.js 和 index.js 載入完成之後再產生輸出的。

Node 完成載入模組(並標記)之後 exports 物件就完成了。整個請求/載入某個模組的過程是同步的。因此我們可以在一個事件迴圈週期過後看到模組已經完成載入。

這也就是說,我們不能非同步改變 exports 物件。比如在某個模組中幹這樣的事情:

迴圈依賴模組

現在來回答關於 Node 迴圈依賴模組這個重要的問題:如果模組1需要模組2,模組2也需要模組1,會發生什麼事情?

為了觀察結果,我們在 lib/ 下建立兩個檔案,module1.js 和 module2.js,它們相互請求物件:

執行 module1.js 可以看到:

我們在 module1 完全載入前請求了 module2,而 module2 在未完全載入時又請求了 module1,那麼,在那一時刻,能得到的是在迴圈依賴之前匯出的屬性。只有 a 屬性列印出來了,因為 b 和 c 是在請求了module2 並列印了 module1 之後才匯出的。

Node 讓這件事變得簡單。在載入某個模組的時候,它會建立 exports 物件。你可以在一個模組載入完成之前請求它,但只會得到部分匯出的物件,它只包含到目前為止已經定義的項。

JSON 和 C/C++ addon

我們可以利用 require 函式在本地引入 JSON 檔案和 C++ addon 檔案。這麼做不需要指定副檔名。

如果沒有指定副檔名,Node 首先要處理 .js 檔案。如果找不到 .js 檔案,就會嘗試尋找 .json 檔案,如果發現為 JSON 文字檔案,便將其解析為 .json 檔案。 之後,它將嘗試找到一個二進位制 .node 檔案。為了消除歧義,當需要使用 .js 檔案以外的其他格式字尾時,你需要制定一個副檔名。

引入 JSON 檔案在某些情況下是很有用的,例如,當你在該檔案中需要管理的所有內容都是些靜態配置值時,或者你需要定期從某個外部源讀入值時。假設我們有以下 config.json 檔案:

我們可以像這樣直接請求:

執行上面的程式碼,輸出如下:

如果 Node 不能找到 .js 或 .json 檔案,它會尋找 .node 檔案,它會被認為是編譯好的外掛模組。

Node 文件中有一個外掛檔案示例,它是用 C++ 寫的。它只是一個匯出了 hello() 函式的簡單模組,這個 hello 函式輸出 “world”。

你可以使用 node-gyp 包來編譯和構建 .cc 檔案,生成 .addon 檔案。只需要配置一個 binding.gyp 檔案來告訴 node-gyp 做什麼。

得到 addon.node (或其它在 binding.gyp 中指定的名稱)檔案後,你可以像請求其它模組一樣請求它:

我們可以在 require.extensions 中看到實際支援的三個副檔名:

你需要了解的 Node.js 模組

看看每個副檔名對應的函式,你就清楚 Node 在怎麼使用它們。它使用 module._compile 處理 .js 檔案,使用 JSON.parse 處理 .json 檔案,以及使用 process.dlopen 處理 .node 檔案。

在 Node 編寫的所有程式碼將封裝到函式中

有人經常誤解 Node 的封裝模組的用途。讓我們通過 exports/module.exports 之間的關係來了解它。

我們可以使用 exports 物件匯出屬性,但是我們不能直接替換 exports 物件,因為它僅是對 module.exports 的引用

對於每個模組而言這個 exports 物件看似是全域性的,這和將其定義為 module 物件的引用,那到底什麼是 exports 物件呢?

在解釋 Node 的封裝過程之前,讓我再問一個問題。

在瀏覽器中,當我們在指令碼中如下所示地宣告一個變數:

在定義 answer 變數的指令碼之後,該變數將在所有指令碼中全域性可見。

這在 Node 中根本不是問題。我們在某個模組中定義的變數,其它模組是訪問不到的。那麼為什麼 Node 中變數的作用域這麼神奇?

答案很簡單。在編譯模組之前,Node 會把模組程式碼封裝在一個函式中,我們可以通過 module 模組的 wrapper 屬性看出來。

Node 不會直接執行你寫在檔案中的程式碼。它執行這個包裝函式,你寫的程式碼只是它的函式體。因此所有定義在模組中的頂層變數都受限於模組的作用域。

這個包裝函式有5個引數:exports, require, module, __filename 和 __dirname。它們看起來像是全域性的,但實際它們在每個模組內部。

所有這些引數都會在 Node 執行包裝函式的時候獲得值。exports 是 module.exports 的引用。require 和 module 都有特定的功能。__filename/__dirname 變數包含了模組檔名及其所有目錄的絕對路徑。

如果你的指令碼在第一行出現錯誤,你就會看到它是如何包裝的:

注意上例中的第一行並非是真的錯誤引用,而是為了在錯誤報告中輸出包裝函式。

此外,既然每個模組都封裝在函式中,我們可以通過 arguments 關鍵字來使用函式的引數:

第一個引數是 exports 物件,它一開始是空的。然後是 require/module 物件,它們與在執行的 index.js 檔案的例項關聯,並非全域性變數。最後 2 個引數是檔案的路徑及其所在目錄的路徑。

包裝函式的返回值是 module.exports。在包裝函式的內部我們可以通過改變 module.exports 屬性來改變 exports 物件,但不能直接對 exports 賦值,因為它只是一個引用。

這個事情大致像這樣:

如果我們直接改變 exports 物件,它就不再是 module.exports 的引用。JavaScript 在任何地方都是這樣引用物件,並非只是在這個環境中。

require 物件

require 沒什麼特別,它主要是作為一個函式來使用,接受模組名稱或路徑作為引數,返回 module.exports 物件。如果我們想改變 require 物件的邏輯,也很容易。

比如,為了進行測試,我們想讓每個 require 呼叫都被模擬為返回一個假物件來代替模組匯出的物件。這個簡單的調整就像這樣:

在上面重新對 require 賦值之後,呼叫 require(‘something’) 就會返回模擬的物件。

require 物件也有自己的屬性。我們已經看到了 resolve 屬性,它也是一個函式,是 require 處理過程中解析路徑的步驟。上面我們還看到了 require.extensions。

還有一個 require.main 可用於檢查程式碼是通過請求來執行的還是直接執行的。

再來看個例子,定義在 print-in-frame.js 中的 printInFrame 函式:

這個函式需要一個數值型的引數 size 和一個字串型的引數 header,它會在列印一個由指定數量的星號生成的框架,並在其中列印 header。

我們希望通過兩種方式來使用這個檔案:

  1. 從命令列直接執行:

在命令列傳入 8 和 Hello 作為引數,它會列印出由 8 個星號組成的框架中的 “Hello”。

2. 通過 require 來使用。假設所需要的模組會匯出 printInFrame 函式,然後就可以這樣做:

它在由 5 個星號組成的框架中列印 “Hey”。

這是兩種不同的使用方式。我們得想辦法檢測檔案是獨立執行的還是由其它指令碼請求的。

這裡用一個簡單的 if 語句來解決:

我們可以使用這個條件,以不同的方式呼叫 printInFrame 來滿足需求:

如果檔案不是被請求的,我們使用 process.argv 來呼叫 printInFrame。否則,我們將 module.exports 修改為 printInFrame 引用。

所有模組都會被快取

理解快取很重要。我們用一個簡單的示例來說明快取。

假設有一個 ascii-art.js,可以列印炫酷的標頭:

你需要了解的 Node.js 模組

我們想每次這個檔案的時候都能看到這些標頭,那麼如果我們請求這個檔案兩次,期望會看到兩次標頭輸出。

因為模組快取,第二次請求不會顯示標頭。Node 會在第一次呼叫的時候快取檔案,所以第二次呼叫的時候就不會重新載入了。

我們可以在第一次請求之後通過列印 require.cache 來看快取的內容。快取登錄檔只是一個簡單的物件,它的每個屬性對應著每次請求的模組。那些屬性值是每個模組中的 module 物件。只需要從 require.cache 裡刪除某個屬性就可以使對應的快取失效。如果這樣做,Node 會再次載入模組並再加將它加入快取。

不過在現在這個情況下,這樣做並不是一個高效的解決辦法。簡單的辦法是在 ascii-art.js 中把輸出語句包裝為一個函式,然後匯出它。用這個辦法,我們請求 ascii-art.js 檔案的時候會得到一個函式,然後每次執行這個函式都可以看到輸出:

以上,就是我這次要說的內容!

相關文章