本文作者:Jacob Beltran
編譯:胡子大哈翻譯原文:http://huziketang.com/blog/posts/detail?postId=58eaf471a58c240ae35bb8e3
英文連接:Requiring modules in Node.js: Everything you need to know
Node 中有兩個核心模塊來對模塊依賴進行管理:
require
模塊。全局范圍生效,不需要require('require')
。module
模塊。全局范圍生效,不需要require('module')
。
你可以把 require
當做是命令行,而把 module
當做是所有引入模塊的組織者。
在 Node 中引入模塊並不是什么復雜的概念,見下面例子:
const config = require('/path/to/file');
require
引入的對象主要是函數。當 Node 調用 require()
函數,並且傳遞一個文件路徑給它的時候,Node 會經歷如下幾個步驟:
- Resolving:找到文件的絕對路徑;
- Loading:判斷文件內容類型;
- Wrapping:打包,給這個文件賦予一個私有作用范圍。這是使
require
和module
模塊在本地引用的一種方法; - Evaluating:VM 對加載的代碼進行處理的地方;
- Caching:當再次需要用這個文件的時候,不需要重復一遍上面步驟。
本文中,我會用不同的例子來解釋上面的各個步驟,並且介紹在 Node 中它們對我們寫的模塊有什么樣的影響。
為了方便大家看文章和理解命令,我首先創建一個目錄,后面的操作都會在這個目錄中進行。
mkdir ~/learn-node && cd ~/learn-node
文章中接下來的部分都會在 ~/learn-node
文件夾下運行。
1. Resolving - 解析本地路徑
首先來為你介紹 module
對象,可以先在控制台中看一下:
~/learn-node $ node > module Module { id: '<repl>', exports: {}, parent: undefined, filename: null, loaded: false, children: [], paths: [ ... ] }
每一個模塊都有 id
屬性來唯一標示它。id
通常是文件的完整路徑,但是在控制台中一般顯示成<repl>
。
Node 模塊和文件系統中的文件通常是一一對應的,引入一個模塊需要把文件內容加載到內存中。因為 Node 有很多種方法引入一個文件(例如相對路徑,或者提前配置好的路徑),所以首先需要找到文件的絕對路徑。
如果我引入了一個 'find-me'
模塊,並沒有指定它的路徑的話:
require('find-me');
Node 會按照 module.paths
所指定的文件目錄順序依次尋找 find-me.js
。
~/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.”錯誤。
~/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')
就能找到了。
~/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
文件。
$ mkdir ~/node_modules $ echo "console.log('I am the root of all problems');" > ~/node_modules/find-me.js
當我們從 learn-node
目錄中執行 require('find-me')
的時候,由於 learn-node
有自己的node_modules/find-me.js
,這時不會加載 home 目錄下的 find-me.js
:
~/learn-node $ node > require('find-me') I am not lost {} >
假設我們把 learn-node
目錄下的 node_modules
移到 ~/learn-node
,再重新執行require('find-me')
的話,按照上面規定的順序查找文件,這時候 home 目錄下的 node_modules
就會被使用了。
~/learn-node $ rm -r node_modules/ ~/learn-node $ node > require('find-me') I am the root of all problems {} >
require 一個文件夾
模塊不一定非要是文件,也可以是個文件夾。我們可以在 node_modules
中創建一個 find-me
文件夾,並且放一個 index.js
文件在其中。那么執行 require('find-me')
將會使用 index.js
文件:
~/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. {} >
這里注意,我們本目錄下創建了 node_modules
文件夾,就不會使用 home 目錄下的 node_modules
了。
當引入一個文件夾的時候,默認會去找 index.js
文件,這也可以手動控制指定到其他文件,利用package.json
的 main
屬性就可以。例如,我們執行 require('find-me')
,並且要從 find-me
文件夾下的 start.js
文件開始解析,那么用 package.json
的做法如下:
~/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
函數所做的事情一模一樣,除了不加載文件。當沒找到文件的時候也會拋出錯誤,如果找到會返回文件的完整路徑。
> 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
下,我們可以這樣引入:
require('./lib/find-me');
文件的 parent-child 關系
創建一個文件 lib/util.js
並且寫一行 console.log
在里面來標識它,當然,這個 console.log
就是模塊本身。
~/learn-node $ mkdir lib ~/learn-node $ echo "console.log('In util', module);" > lib/util.js
在 index.js
中寫上將要執行的 node 命令,並且在 index.js
中引入 lib/util.js
:
~/learn-node $ echo "console.log('In index', module); require('./lib/util');" > index.js
現在在 node 中執行 index.js
:
~/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
的 parent 屬性中。而 lib/util
並沒有被列到 index
的 children 屬性,而是用一個 [Circular]
代替的。這是因為這是個循環引用,如果這里使用 lib/util
的話,那就變成一個無限循環了。這就是為什么在 index
中使用 [Circular]
來替代lib/util
。
那么重點來了,如果在 lib/util
中引入了 index
模塊會怎么樣?這就是我們所謂的模塊循環依賴問題,在 Node 中是允許這樣做的。
但是 Node 如何處理這種情況呢?為了更好地理解這一問題,我們先來了解一下模塊對象的其他知識。
2. Loading - exports,module.exports,和模塊的同步加載
在所有的模塊中,exports 都是一個特殊的對象。如果你有注意的話,上面我們每次打印模塊信息的時候,都有一個是空值的 exports 屬性。我們可以給這個 exports 對象加任何想加的屬性,例如在 index.js
和lib/util.js
中給它添加一個 id
屬性:
// 在 lib/util.js 的最上面添加這行 exports.id = 'lib/util'; // 在 index.js 的最上面添加這行 exports.id = 'index';
執行 index.js
,可以看到我們添加的屬性已經存在於模塊對象中:
~/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 對象變成函數,做法如下:
// 在 index.js 的 console.log 前面添加這行 module.exports = function() {};
當你執行 index.js
的時候,你會看到如下信息:
~/learn-node $ node index.js In index Module { id: '.', exports: [Function], loaded: false, ... }
這里注意我們沒有使用 export = function() {}
來改變 exports
對象。沒有這樣做是因為在模塊中的exports
變量實際上是 module.exports
的一個引用,而 module.exports
才是控制所有對外屬性的。exports
和 module.exports
指向同一塊內存,如果把 exports
指向一個函數,那么相當於改變了 exports
的指向,exports
就不再是引用了。即便你改變了 exports
,module.exports
也是不變的。
模塊的 module.exports
是一個模塊的對外接口,就是當你使用 require
函數時所返回的東西。例如把index.js
中的代碼改一下:
const UTIL = require('./lib/util'); console.log('UTIL:', UTIL);
上面的代碼將會捕獲 lib/util
中輸出的屬性,賦值給 UTIL
常量。當執行 index.js
的時候,最后一行將會輸出:
UTIL: { id: 'lib/util' }
接下來聊一下 loaded
屬性。上面我們每次輸出模塊信息,都能看到一個 loaded
屬性,值是 false
。
module
模塊使用 loaded
屬性來追蹤哪些模塊已經加載完畢,哪些模塊正在加載。例如我們可以調用setImmediate
來打印 module
對象,用它可以看到 index.js
的完全加載信息:
// In index.js setImmediate(() => { console.log('The index.js module object is now loaded!', module) });
輸出結果:
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' ] }
可以注意到 lib/util.js
和 index.js
都已經加載完畢了。
當一個模塊加載完成的時候,exports
對象才完整,整個加載的過程都是同步的。這也是為什么在一個事件循環后所有的模塊都處於完全加載狀態的原因。
這也意味着不能異步改變 exports
對象,例如,對任何模塊做下面這樣的事情:
fs.readFile('/etc/passwd', (err, data) => { if (err) throw err; exports.data = data; // Will not work. });
模塊循環依賴
我們現在來回答上面說到的循環依賴的問題:模塊 1 依賴模塊 2,模塊 2 也依賴模塊 1,會發生什么?
現在來創建兩個文件,lib/module1.js
和 lib/module2.js
,並且讓它們相互引用:
// 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
,可以看到:
~/learn-node $ node lib/module1.js Module1 is partially loaded here { a: 1 }
在 module1
完全加載之前需要先加載 module2
,而 module2
的加載又需要 module1
。這種狀態下,我們從 exports
對象中能得到的就是在發生循環依賴之前的這部分。上面代碼中,只有 a
屬性被引入,因為 b
和 c
都需要在引入 module2
之后才能加載進來。
Node 使這個問題簡單化,在一個模塊加載期間開始創建 exports
對象。如果它需要引入其他模塊,並且有循環依賴,那么只能部分引入,也就是只能引入發生循環依賴之前所定義的這部分。
JSON 和 C/C++ 擴展文件
我們可以使用 require 函數本地引入 JSON 文件和 C++ 擴展文件,理論上來講,不需要指定其擴展名。
如果沒有指定擴展名,Node 會先嘗試將其按 .js
文件來解析,如果不是 .js
文件,再嘗試按 .json
文件來解析。如果都不是,會嘗試按 .node
二進制文件解析。但是為了使程序更清晰,當引入除了 .js
文件的時候,你都應該指定文件擴展名。
如果你要操作的文件是一些靜態配置值,或者是需要定期從外部文件中讀取的值,那么引入 JSON 是很好的一個選擇。例如有如下的 config.json
文件:
{
"host": "localhost", "port": 8080 }
我們可以直接像這樣引用:
const { host, port } = require('./config'); console.log(`Server will run at http://${host}:${port}`);
運行上面的代碼會得到這樣的輸出:
Server will run at http://localhost:8080
如果 Node 按 .js
和 .json
解析都失敗的話,它會按 .node
解析,把這個文件當做一個已編譯的擴展模塊來解析。
Node 文檔中有一個 C++ 寫的示例擴展文件,它只暴露出一個 hello()
函數,並且函數輸出 “world”。
你可以使用 node-gyp
包編譯 .cc
文件,生成 .addon
文件。只需要配置 binding.gyp 文件來告訴node-gyp
需要做什么就可以了。
當你有了 addon.node
文件(名字你可以在 binding.gyp
中隨意配置)以后,你就可以在本地像引入其他模塊一樣引入它了:
const addon = require('./addon'); console.log(addon.hello());
可以通過 require.extensions
來查看對三種文件的支持情況:
可以清晰地看到 Node 對每種擴展名所使用的函數及其操作:對 .js
文件使用 module._compile
;對.json
文件使用 JSON.parse
;對 .node
文件使用 process.dlopen
。
3. Wrapping - 你在 Node 中所寫的所有代碼都會被打包成函數
Node 的打包模塊不是很好理解,首先要先知道 exports
/ module.exports
的關系。
我們可以用 exports
對象來輸出屬性,但是不能直接對 exports
進行賦值(替換整個 exports
對象),因為它僅僅是 module.exports
的引用。
exports.id = 42; // This is ok. exports = { id: 42 }; // This will not work. module.exports = { id: 42 }; // This is ok.
在介紹 Node 的打包過程之前先來了解另一個問題,通常情況下,在瀏覽器中我們在腳本中定義一個變量:
var answer = 42;
這種方式定義以后,answer
變量就是一個全局變量了。其他腳本中依然可以訪問。而 Node 中不是這樣,你在一個模塊中定義一個變量,程序的其他模塊是不能訪問的。Node 是如何做到的呢?
答案很簡單,在編譯成模塊之前,Node 把模塊代碼都打包成函數,可以用 module
的 wrapper
屬性來查看。
~ $ 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
指這個打包模塊的絕對路徑和目錄路徑。
在腳本的第一行輸入有問題的代碼,就能看到 Node 打包的行為;
~/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
關鍵字來訪問函數的參數:
~/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
的實例;最后兩個參數是文件路徑和目錄路徑。
打包函數的返回值是 module.exports
。在模塊內部,可以使用 exports
對象來改變 module.exports
屬性,但是不能對 exports
重新賦值,因為它只是 module.exports
的引用。
相當於如下代碼:
function (require, module, __filename, __dirname) { let exports = module.exports; // Your Code... return module.exports; }
如果對 exports
重新賦值(改變整個 exports
對象),那它就不是 module.exports
的引用了。這是 JavaScript 引用的工作原理,不僅僅是在這里是這樣。
4. Evaluating - require 對象
require
沒有什么特別的,通常作為一個函數返回 module.exports
對象,函數參數是一個模塊名或者一個路徑。如果你想的話,盡可以根據自己的邏輯重寫 require
對象。
例如,為了達到測試的目的,我們希望所有的 require
都默認返回一個 mock 值來替代真實的模塊返回值。可以簡單地實現如下:
require = function() { return { mocked: true }; }
這樣重寫了 require
以后,每個 require('something')
調用都會返回一個模擬對象。
require
對象也有自己的屬性。上面已經見過了 resolve
屬性,它的任務是處理引入模塊過程中的解析步驟,上面還提到過 require.extensions
也是 require
的屬性。還有 require.main
,它用於判斷一個腳本是否應該被引入還是直接執行。
例如,在 print-in-frame.js
中有一個 printInFrame
函數。
// In print-in-frame.js const printInFrame = (size, header) => { console.log('*'.repeat(size)); console.log(header); console.log('*'.repeat(size)); };
函數有兩個參數,一個是數字類型參數 size
,一個是字符串類型參數 header
。函數功能很簡單,這里不贅述。
我們想用兩種方式使用這個文件:
1.直接使用命令行:
~/learn-node $ node print-in-frame 8 Hello
傳遞 8 和 “Hello” 兩個參數進去,打印 8 個星星包裹下的 “Hello”。
2.使用 require
。假設所引入的模塊對外接口是 printInFrame
函數,我們可以這樣調用:
const print = require('./print-in-frame'); print(5, 'Hey');
傳遞的參數是 5 和 “Hey”。
這是兩種不同的用法,我們需要一種方法來判斷這個文件是作為獨立的腳本來運行,還是需要被引入到其他的腳本中才能執行。可以使用簡單的 if 語句來實現:
if (require.main === module) { // 這個文件直接執行(不需要 require) }
繼續演化,可以使用不同的調用方式來實現最初的需求:
// 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; }
當文件不需要被 require 時,直接通過 process.argv
調用 printInFrame
函數即可。否則直接把module.exports
變成 printInFrame
就可以了,即模塊接口是 printInFrame
。
5. Caching - 所有的模塊都會被緩存
對緩存的理解特別重要,我用簡單的例子來解釋緩存。
假設你有一個 ascii-art.js
文件,打印很酷的 header:
我們想要在每次 require
這個文件的時候,都打印出 header。所以把這個文件引入兩次:
require('./ascii-art') // 顯示 header require('./ascii-art') // 不顯示 header.
第二個 require 不會顯示 header,因為模塊被緩存了。Node 把第一個調用緩存起來,第二次調用的時候就不加載文件了。
可以在第一次引入文件以后,使用 require.cache
來看一下都緩存了什么。緩存中實際上是一個對象,這個對象中包含了引入模塊的屬性。我們可以從 require.cache
中把相應的屬性刪掉,以使緩存失效,這樣 Node 就會重新加載模塊並且將其重新緩存起來。
對於這個問題,這並不是最有效的解決方案。最簡單的解決方案是把 ascii-art.js
中的打印代碼打包成一個函數,並且 export 這個函數。這樣當我們引入 ascii-art.js
文件時,我們獲取到的是這個函數,所以可以每次都能打印出想要的內容了:
require('./ascii-art')() // 打印出 header. require('./ascii-art')() // 也會打印出 header.
總結
這就是我所要介紹的內容。回顧一下通篇,分別講述了:
- Resolving
- Loading
- Wrapping
- Evaluating
- Caching
即解析、加載、打包、VM功能處理和緩存五大步驟,以及五大步驟中每個步驟都涉及到了什么內容。