Node 使用兩個核心模組來管理模組依賴:
- require 模組,在全域性範圍可用——無需 require(‘require’)。
- module 模組,在全域性範圍可用——無需 require(‘module’)。
你可以將 require 模組視為命令,將 module 模組視為所有必需模組的組織者。
在 Node 中獲取一個模組並不複雜。
1 |
const config = require('/path/to/file'); |
由 require 模組匯出的主要物件是一個函式(如上例所用)。 當 Node 使用本地檔案路徑作為函式的唯一引數呼叫該 require() 函式時,Node 將執行以下步驟:
- 解析:找到檔案的絕對路徑。
- 載入:確定檔案內容的型別.
- 封裝:給檔案其私有作用域。 這使得 require 和 module 物件兩者都可以下載我們需要的每個檔案。
- 評估:這是 VM 對載入的程式碼最後需要做的。
- 快取:當我們再次需要這個檔案時,不再重複所有的步驟。
在本文中,我將嘗試用示例解釋這些不同的階段,以及它們是如何影響我們在 Node 中編寫模組的方式的。
先在終端建立一個目錄來儲存所有示例:
1 |
mkdir ~/learn-node && cd ~/learn-node |
本文之後所有命令都在 ~/learn-node 下執行。
解析本地路徑
我現在向你介紹 module 物件。你可以在一個的 REPL(譯者注:Read-Eval-Print-Loop,就是一般控制檯乾的事情)會話中很容易地看到它:
1 2 3 4 5 6 7 8 9 10 |
~/learn-node $ node > module Module { id: '', exports: {}, parent: undefined, filename: null, loaded: false, children: [], paths: [ ... ] } |
每個模組物件都有一個 id 屬性作為標識。這個 id 通常是檔案的完整路徑,不過在 REPL 會話中,它只是 。
Node 模組與檔案系統有著一對一的關係。請求模組就是把檔案內容載入到記憶體中。
不過,因為 Node 中有很多方法用於請求檔案(比如,使用相對路徑,或預定義的路徑),在我們把檔案內容載入到記憶體之前,我們需要找到檔案的絕對位置。
現在請求 ‘find-me’ 模組,但不指定路徑:
1 |
require('find-me'); |
Node 會按順序在 module.paths 指定的路徑中去尋找 find-me.js。
1 2 3 4 5 6 7 8 9 10 |
~/learn-node $ node > module.paths [ '/Users/samer/learn-node/repl/node_modules', '/Users/samer/learn-node/node_modules', '/Users/samer/node_modules', '/Users/node_modules', '/node_modules', '/Users/samer/.node_modules', '/Users/samer/.node_libraries', '/usr/local/Cellar/node/7.7.1/lib/node' ] |
路徑列表基本上會是從當前目錄到根目錄下的每一個 node_modules 目錄。它也會包含一些不推薦使用的遺留目錄。
如果 Node 在這些目錄下仍然找不到 find-me.js,它會丟擲 “cannot find module error.(不能找到模組)” 這個錯誤訊息。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
~/learn-node $ node > require('find-me') Error: Cannot find module 'find-me' at Function.Module._resolveFilename (module.js:470:15) at Function.Module._load (module.js:418:25) at Module.require (module.js:498:17) at require (internal/module.js:20:19) at repl:1:1 at ContextifyScript.Script.runInThisContext (vm.js:23:33) at REPLServer.defaultEval (repl.js:336:29) at bound (domain.js:280:14) at REPLServer.runBound [as eval] (domain.js:293:12) at REPLServer.onLine (repl.js:533:10) |
現在建立一個區域性的 node_modules 目錄,放入一個 find-me.js,require(‘find-me’) 就能找到它。
1 2 3 4 5 6 7 |
~/learn-node $ mkdir node_modules ~/learn-node $ echo "console.log('I am not lost');" > node_modules/find-me.js ~/learn-node $ node > require('find-me'); I am not lost {} > |
如果別的路徑下存在另一個 find-me.js 檔案,例如在 home 目錄下存在 node_modules 目錄,其中有一個不同的 find-me.js:
1 2 |
$ mkdir ~/node_modules $ echo "console.log('I am the root of all problems');" > ~/node_modules/find-me.js |
現在 learn-node 目錄也包含 node_modules/find-me.js —— 在這個目錄下 require(‘find-me’),那麼 home 目錄下的 find-me.js 根本不會被載入:
1 2 3 4 5 |
~/learn-node $ node > require('find-me') I am not lost {} > |
如果刪除了~/learn-node 目錄下的的 node_modules 目錄,再次嘗試請求 find-me.js,就會使用 home 目錄下 node_modules 目錄中的 find-me.js 了:
1 2 3 4 5 6 |
~/learn-node $ rm -r node_modules/ ~/learn-node $ node > require('find-me') I am the root of all problems {} > |
請求一個目錄
模組不一定是檔案。我們也可以在 node_modules 目錄下建立一個 find-me 目錄,並在其中放一個 index.js 檔案。同樣的 require(‘find-me’) 會使用這個目錄下的 index.js 檔案:
1 2 3 4 5 6 7 |
~/learn-node $ mkdir -p node_modules/find-me ~/learn-node $ echo "console.log('Found again.');" > node_modules/find-me/index.js ~/learn-node $ node > require('find-me'); Found again. {} > |
注意如果存在區域性模組,home 下 node_modules 路徑中的相應模組仍然會被忽略。
在請求一個目錄的時候,預設會使用 index.js,不過我們可以通過 package.json 中的 main 選項來改變起始檔案。比如,希望 require(‘find-me’) 在 find-me 目錄下去使用另一個檔案,只需要在那個目錄下新增 package.json 檔案來完成這個事情:
1 2 3 4 5 6 7 |
~/learn-node $ echo "console.log('I rule');" > node_modules/find-me/start.js ~/learn-node $ echo '{ "name": "find-me-folder", "main": "start.js" }' > node_modules/find-me/package.json ~/learn-node $ node > require('find-me'); I rule {} > |
require.resolve
如果你只是想找到模組,並不想執行它,你可以使用 require.resolve 函式。除了不載入檔案,它的行為與主函式 require 完全相同。如果檔案不存在它會丟擲錯誤,如果找到了指定的檔案,它會返回完整路徑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
> require.resolve('find-me'); '/Users/samer/learn-node/node_modules/find-me/start.js' > require.resolve('not-there'); Error: Cannot find module 'not-there' at Function.Module._resolveFilename (module.js:470:15) at Function.resolve (internal/module.js:27:19) at repl:1:9 at ContextifyScript.Script.runInThisContext (vm.js:23:33) at REPLServer.defaultEval (repl.js:336:29) at bound (domain.js:280:14) at REPLServer.runBound [as eval] (domain.js:293:12) at REPLServer.onLine (repl.js:533:10) at emitOne (events.js:101:20) at REPLServer.emit (events.js:191:7) > |
這很有用,比如,檢查一個可選的包是否安裝並在它已安裝的情況下使用它。
相對路徑和絕對路徑
除了在 node_modules 目錄中查詢模組之外,我們也可以把模組放置於任何位置,然後通過相對路徑(./ 和 ../)請求,也可以通過以 / 開始的絕對路徑請求。
比如,如果 find-me.js 是放在 lib 目錄而不是 node_modules 目錄下,可以這樣請求:
1 |
require('./lib/find-me'); |
檔案中的父子關係
建立 lib/util.js 檔案並新增一行 console.log 程式碼來識別它。console.log 會輸出模組自身的 module 物件:
1 2 |
~/learn-node $ mkdir lib ~/learn-node $ echo "console.log('In util', module);" > lib/util.js |
在 index.js 檔案中幹同樣的事情,稍後我們會通過 node 命令執行這個檔案。讓 index.js 檔案請求 lib/util.js:
1 |
~/learn-node $ echo "console.log('In index', module); require('./lib/util');" > index.js |
現在用 node 執行 index.js:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
~/learn-node $ node index.js In index Module { id: '.', exports: {}, parent: null, filename: '/Users/samer/learn-node/index.js', loaded: false, children: [], paths: [ ... ] } In util Module { id: '/Users/samer/learn-node/lib/util.js', exports: {}, parent: Module { id: '.', exports: {}, parent: null, filename: '/Users/samer/learn-node/index.js', loaded: false, children: [ [Circular] ], paths: [...] }, filename: '/Users/samer/learn-node/lib/util.js', loaded: false, children: [], paths: [...] } |
注意到現在的列表中主模組 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 屬性:
1 2 3 4 |
// Add the following line at the top of lib/util.js exports.id = 'lib/util'; // Add the following line at the top of index.js exports.id = 'index'; |
現在執行 index.js,我們會看到這些屬性受到 module 物件管理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
~/learn-node $ node index.js In index Module { id: '.', exports: { id: 'index' }, loaded: false, ... } In util Module { id: '/Users/samer/learn-node/lib/util.js', exports: { id: 'lib/util' }, parent: Module { id: '.', exports: { id: 'index' }, loaded: false, ... }, loaded: false, ... } |
上面的輸出中我去掉了一些屬性,這樣看起來比較簡潔,不過請注意 exports 物件已經包含了我們在每個模組中定義的屬性。你可以在 exports 物件中任意新增屬性,也可以直接把 exports 整個替換成另一個物件。比如,可以把 exports 物件變成一個函式,我們會這樣做:
1 2 |
// Add the following line in index.js before the console.log module.exports = function() {}; |
現在執行 index.js,你會看到 exports 物件是一個函式:
1 2 3 4 5 6 |
~/learn-node $ node index.js In index Module { id: '.', exports: [Function], loaded: false, ... } |
注意,我沒有通過 exports = function() {} 來將 exports 物件改變為函式。這樣做是不行的,因為模組中的 exports 變數只是 module.exports 的引用,它用於管理匯出屬性。如果我們重新給 exports 變數賦值,就會丟失對 module.exports 的引用,實際會產生一個新的變數,而不是改變了 module.exports。
每個模組中的 module.exports 物件就是通過 require 函式請求那個模組返回的。比如,把 index.js 中的 require(‘./lib/util’) 改為:
1 2 |
const UTIL = require('./lib/util'); console.log('UTIL:', UTIL); |
這段程式碼會輸出 lib/util 匯出到 UTIL 常量中的屬性。現在執行 index.js,輸出如下:
1 |
UTIL: { id: 'lib/util' } |
再來談談每個模組的 loaded 屬性。到目前為止,每次我們列印一個模組物件的時候,都會看到這個物件的 loaded 屬性值為 false。
module 模組使用 loaded 屬性來跟蹤哪些模組是載入過的(true值),以及哪些模組還在載入中(false 值)。比如我們可以通過呼叫 setImmediate 來列印 modules 物件,在下一事件迴圈中看看完成載入的 index.js 模組:
1 2 3 4 |
// In index.js setImmediate(() => { console.log('The index.js module object is now loaded!', module) }); |
輸出是這樣的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
The index.js module object is now loaded! Module { id: '.', exports: [Function], parent: null, filename: '/Users/samer/learn-node/index.js', loaded: true, children: [ Module { id: '/Users/samer/learn-node/lib/util.js', exports: [Object], parent: [Circular], filename: '/Users/samer/learn-node/lib/util.js', loaded: true, children: [], paths: [Object] } ], paths: [ '/Users/samer/learn-node/node_modules', '/Users/samer/node_modules', '/Users/node_modules', '/node_modules' ] } |
注意理解它是如何推遲 console.log,使其在 lib/util.js 和 index.js 載入完成之後再產生輸出的。
Node 完成載入模組(並標記)之後 exports 物件就完成了。整個請求/載入某個模組的過程是同步的。因此我們可以在一個事件迴圈週期過後看到模組已經完成載入。
這也就是說,我們不能非同步改變 exports 物件。比如在某個模組中幹這樣的事情:
1 2 3 4 |
fs.readFile('/etc/passwd', (err, data) => { if (err) throw err; exports.data = data; // Will not work. }); |
迴圈依賴模組
現在來回答關於 Node 迴圈依賴模組這個重要的問題:如果模組1需要模組2,模組2也需要模組1,會發生什麼事情?
為了觀察結果,我們在 lib/ 下建立兩個檔案,module1.js 和 module2.js,它們相互請求物件:
1 2 3 4 5 6 7 8 9 |
// lib/module1.js exports.a = 1; require('./module2'); exports.b = 2; exports.c = 3; // lib/module2.js const Module1 = require('./module1'); console.log('Module1 is partially loaded here', Module1); |
執行 module1.js 可以看到:
1 2 |
~/learn-node $ node lib/module1.js Module1 is partially loaded here { a: 1 } |
我們在 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 檔案:
1 2 3 4 |
{ "host": "localhost", "port": 8080 } |
我們可以像這樣直接請求:
1 2 |
const { host, port } = require('./config'); console.log(`Server will run at http://${host}:${port}`); |
執行上面的程式碼,輸出如下:
1 |
Server will run at http://localhost:8080 |
如果 Node 不能找到 .js 或 .json 檔案,它會尋找 .node 檔案,它會被認為是編譯好的外掛模組。
Node 文件中有一個外掛檔案示例,它是用 C++ 寫的。它只是一個匯出了 hello() 函式的簡單模組,這個 hello 函式輸出 “world”。
你可以使用 node-gyp 包來編譯和構建 .cc 檔案,生成 .addon 檔案。只需要配置一個 binding.gyp 檔案來告訴 node-gyp 做什麼。
得到 addon.node (或其它在 binding.gyp 中指定的名稱)檔案後,你可以像請求其它模組一樣請求它:
1 2 |
const addon = require('./addon'); console.log(addon.hello()); |
我們可以在 require.extensions 中看到實際支援的三個副檔名:
看看每個副檔名對應的函式,你就清楚 Node 在怎麼使用它們。它使用 module._compile 處理 .js 檔案,使用 JSON.parse 處理 .json 檔案,以及使用 process.dlopen 處理 .node 檔案。
在 Node 編寫的所有程式碼將封裝到函式中
有人經常誤解 Node 的封裝模組的用途。讓我們通過 exports/module.exports 之間的關係來了解它。
我們可以使用 exports 物件匯出屬性,但是我們不能直接替換 exports 物件,因為它僅是對 module.exports 的引用
1 2 3 |
exports.id = 42; // This is ok. exports = { id: 42 }; // This will not work. module.exports = { id: 42 }; // This is ok. |
對於每個模組而言這個 exports 物件看似是全域性的,這和將其定義為 module 物件的引用,那到底什麼是 exports 物件呢?
在解釋 Node 的封裝過程之前,讓我再問一個問題。
在瀏覽器中,當我們在指令碼中如下所示地宣告一個變數:
1 |
var answer = 42; |
在定義 answer 變數的指令碼之後,該變數將在所有指令碼中全域性可見。
這在 Node 中根本不是問題。我們在某個模組中定義的變數,其它模組是訪問不到的。那麼為什麼 Node 中變數的作用域這麼神奇?
答案很簡單。在編譯模組之前,Node 會把模組程式碼封裝在一個函式中,我們可以通過 module 模組的 wrapper 屬性看出來。
1 2 3 4 5 |
~ $ node > require('module').wrapper [ '(function (exports, require, module, __filename, __dirname) { ', '\n});' ] > |
Node 不會直接執行你寫在檔案中的程式碼。它執行這個包裝函式,你寫的程式碼只是它的函式體。因此所有定義在模組中的頂層變數都受限於模組的作用域。
這個包裝函式有5個引數:exports, require, module, __filename 和 __dirname。它們看起來像是全域性的,但實際它們在每個模組內部。
所有這些引數都會在 Node 執行包裝函式的時候獲得值。exports 是 module.exports 的引用。require 和 module 都有特定的功能。__filename/__dirname 變數包含了模組檔名及其所有目錄的絕對路徑。
如果你的指令碼在第一行出現錯誤,你就會看到它是如何包裝的:
1 2 3 4 5 6 7 |
~/learn-node $ echo "euaohseu" > bad.js ~/learn-node $ node bad.js ~/bad.js:1 (function (exports, require, module, __filename, __dirname) { euaohseu ^ ReferenceError: euaohseu is not defined |
注意上例中的第一行並非是真的錯誤引用,而是為了在錯誤報告中輸出包裝函式。
此外,既然每個模組都封裝在函式中,我們可以通過 arguments 關鍵字來使用函式的引數:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
~/learn-node $ echo "console.log(arguments)" > index.js ~/learn-node $ node index.js { '0': {}, '1': { [Function: require] resolve: [Function: resolve], main: Module { id: '.', exports: {}, parent: null, filename: '/Users/samer/index.js', loaded: false, children: [], paths: [Object] }, extensions: { ... }, cache: { '/Users/samer/index.js': [Object] } }, '2': Module { id: '.', exports: {}, parent: null, filename: '/Users/samer/index.js', loaded: false, children: [], paths: [ ... ] }, '3': '/Users/samer/index.js', '4': '/Users/samer' } |
第一個引數是 exports 物件,它一開始是空的。然後是 require/module 物件,它們與在執行的 index.js 檔案的例項關聯,並非全域性變數。最後 2 個引數是檔案的路徑及其所在目錄的路徑。
包裝函式的返回值是 module.exports。在包裝函式的內部我們可以通過改變 module.exports 屬性來改變 exports 物件,但不能直接對 exports 賦值,因為它只是一個引用。
這個事情大致像這樣:
1 2 3 4 |
function (require, module, __filename, __dirname) { let exports = module.exports; // Your Code... return module.exports; } |
如果我們直接改變 exports 物件,它就不再是 module.exports 的引用。JavaScript 在任何地方都是這樣引用物件,並非只是在這個環境中。
require 物件
require 沒什麼特別,它主要是作為一個函式來使用,接受模組名稱或路徑作為引數,返回 module.exports 物件。如果我們想改變 require 物件的邏輯,也很容易。
比如,為了進行測試,我們想讓每個 require 呼叫都被模擬為返回一個假物件來代替模組匯出的物件。這個簡單的調整就像這樣:
1 2 3 |
require = function() { return { mocked: true }; } |
在上面重新對 require 賦值之後,呼叫 require(‘something’) 就會返回模擬的物件。
require 物件也有自己的屬性。我們已經看到了 resolve 屬性,它也是一個函式,是 require 處理過程中解析路徑的步驟。上面我們還看到了 require.extensions。
還有一個 require.main 可用於檢查程式碼是通過請求來執行的還是直接執行的。
再來看個例子,定義在 print-in-frame.js 中的 printInFrame 函式:
1 2 3 4 5 6 |
// In print-in-frame.js const printInFrame = (size, header) => { console.log('*'.repeat(size)); console.log(header); console.log('*'.repeat(size)); }; |
這個函式需要一個數值型的引數 size 和一個字串型的引數 header,它會在列印一個由指定數量的星號生成的框架,並在其中列印 header。
我們希望通過兩種方式來使用這個檔案:
- 從命令列直接執行:
1 |
~/learn-node $ node print-in-frame 8 Hello |
在命令列傳入 8 和 Hello 作為引數,它會列印出由 8 個星號組成的框架中的 “Hello”。
2. 通過 require 來使用。假設所需要的模組會匯出 printInFrame 函式,然後就可以這樣做:
1 2 |
const print = require('./print-in-frame'); print(5, 'Hey'); |
它在由 5 個星號組成的框架中列印 “Hey”。
這是兩種不同的使用方式。我們得想辦法檢測檔案是獨立執行的還是由其它指令碼請求的。
這裡用一個簡單的 if 語句來解決:
1 2 3 |
if (require.main === module) { // The file is being executed directly (not with require) } |
我們可以使用這個條件,以不同的方式呼叫 printInFrame 來滿足需求:
1 2 3 4 5 6 7 8 9 10 11 12 |
// In print-in-frame.js const printInFrame = (size, header) => { console.log('*'.repeat(size)); console.log(header); console.log('*'.repeat(size)); }; if (require.main === module) { printInFrame(process.argv[2], process.argv[3]); } else { module.exports = printInFrame; } |
如果檔案不是被請求的,我們使用 process.argv 來呼叫 printInFrame。否則,我們將 module.exports 修改為 printInFrame 引用。
所有模組都會被快取
理解快取很重要。我們用一個簡單的示例來說明快取。
假設有一個 ascii-art.js,可以列印炫酷的標頭:
我們想每次請求這個檔案的時候都能看到這些標頭,那麼如果我們請求這個檔案兩次,期望會看到兩次標頭輸出。
1 2 |
require('./ascii-art') // 會顯示標頭。 require('./ascii-art') // 不會顯示標頭。 |
因為模組快取,第二次請求不會顯示標頭。Node 會在第一次呼叫的時候快取檔案,所以第二次呼叫的時候就不會重新載入了。
我們可以在第一次請求之後通過列印 require.cache 來看快取的內容。快取登錄檔只是一個簡單的物件,它的每個屬性對應著每次請求的模組。那些屬性值是每個模組中的 module 物件。只需要從 require.cache 裡刪除某個屬性就可以使對應的快取失效。如果這樣做,Node 會再次載入模組並再加將它加入快取。
不過在現在這個情況下,這樣做並不是一個高效的解決辦法。簡單的辦法是在 ascii-art.js 中把輸出語句包裝為一個函式,然後匯出它。用這個辦法,我們請求 ascii-art.js 檔案的時候會得到一個函式,然後每次執行這個函式都可以看到輸出:
1 2 |
require('./ascii-art')() // 會顯示標頭。 require('./ascii-art')() // 也會顯示標頭。 |
以上,就是我這次要說的內容!