原文地址:https://medium.freecodecamp.o...
node中採用了兩個核心模塊來管理模塊依賴:
-
require
模塊:全局可見,不需要額外使用require('require')
-
module
模塊:全局可見,不需要額外使用require('module')
可以認爲require
模塊是一個command,module
模塊是所需模塊的organizer。
在Node中引用模塊並不是一件複雜的事情:const config = require('/path/to/file');
require
模塊暴露出一個函數(就像上面看到的那樣)。當require()
函數傳入一個path參數的時候,node會依次執行如下步驟:
- Resolving : 找到path的絕對路徑。
- Loading: 確定文件的內容。
- Wrapping:構造私有的作用域。Wrapping可以確保每次require文件的時候,require和exports都是私有的。
- Evaluating:evaluating環節是VM處理已加載文件的最後一個環節。
- Caching:爲了避免引用相同的文件情況下,不重複執行上面的步驟。
本文中筆者將通過案例講解上面提到的不同階段以及這些階段對於開發者開發node模塊的影響。
首先通過終端創建一個文件夾mkdir ~/learn-node && cd ~/learn-node
下面本文所有的命令都是在~/learn-node
中執行。
Resolving a local path
首先來介紹下module
對象。讀者可以通過REPL來看到module
對象
每個module對象都有id屬性用來區分不同的模塊。id屬性一般都是模塊對應的絕對路徑,在REPL中會簡單的設置爲<repl>
。Node模塊和系統磁盤上的文件是一一對應的。引用模塊實際上會將文件中的內容加載到內存中。node支持通過多種方式來引用文件(比如說通過相對路徑或者預配置路徑),在把文件中內容加載到內存之前,需要先找到文件的絕對路徑。
當不指定路徑直接引用find-me
模塊的時候:require('find-me');
node會依次遍歷module.paths
指定的路徑來尋找find-me
模塊:
上面的路徑是從當前路徑到根目錄所有目錄下的node_modules
文件夾的路徑,除此之外也包括一些遺留的但是已經不推薦使用的路徑。如果node在上述路徑中都找不到find-me.js
就會拋出一個“cannot find module error.”的異常。
如果在當前文件夾下創建一個node_modules文件夾,並創建一個find-me.js文件,這時require('find-me')
就能夠找到find-me了。
如果其他路徑下也存在find-me.js 比如在用戶的home目錄下的node_modules文件夾下面存在另外一個find-me.js:
當在learn-code目錄下執行require('find-me')
,由於在learn-code下的node_modules目錄下有一個find-me.js,此時用戶的home目錄下的find-me.js並不會加載執行。
如果我們從~/learn-code目錄下刪除node_modules文件夾,再執行引用find-me,則會使用用戶home目錄下的node_modules下的fine-me:
Requiring a folder
模塊不一定只是一個文件,讀者也可以創建一個find-me文件夾,並且在文件夾中創建index.js,require('find-me')
的時候會引用index.js:
注意此時由於當前目錄下有了find-me, 則此時引用find-me會忽略用戶home目錄下的node_modules。當引用目錄的時候,默認情況下會尋找index.js,但是我們可以通過package.json中的main屬性來指示用那個文件。舉個例子,爲了讓require('find-me')
能夠解析到find-me文件夾下的其他文件,我們需要在find-me目錄下加一個package.json,並指定應該解析到哪個文件:
require.resolve
如果只想解析模塊但不執行模塊,可以使用require.resolve
函數。resolve
和require
函數的表現除了不執行文件之外,其他方面表現是一致的。當文件找不到的時候仍然會拋出一個異常,在找到文件的情況下會返回文件的絕對路徑。
resolve
函數可以用來檢測是否安裝了某個模塊,並在檢查到模塊的情況下使用已安裝的模塊。
Relative and absolute paths
除了從node_modules中解析出模塊,我們也可以把模塊放在任何地方,通過相對路徑(./
或者../
打頭)或者絕對路徑(/
打頭)的方式來引用該模塊。
比如,如果find-me.js在lib目錄下而不是在node_modules目錄下,我們可以通過這種方式來引用find-me:require('./lib/find-me');
Parent-child relation between files
創建一個lib/util.js
並加入一行console.log來做區分,同時輸出module
對象:
在index.js也加入類似的代碼,後面我們通過node執行index.js。在index.js中引用lib/util.js
:
在node中執行index.js:
注意index模塊(id: '.')
是lib/util
模塊的父模塊。但是輸出結果中lib/util
模塊並沒有顯示在index
模塊的子模塊中。取而代之的是一個[Circular]
的值,因爲這兒是一個循環引用。此時如果node打印lib/util
爲index
的子模塊的話,則會進入到死循環。這也可以解釋了爲什麼需要簡單的用[Circular]
來代替lib/util
。
那麼如果在lib/util
模塊中引用index
模塊會發生什麼。這就是node中所允許的的循環引用。
爲了能夠更好的理解循環依賴,首先需要了解一些關於module對象上的一些概念。
exports, module.exports, and synchronous loading of modules
任何模塊中exports都是一個特別的對象。注意到上面的結果中,每次打印module對象,都會有一個爲空對象的exports屬性。我們可以在這個特別的exports對象上加入一些屬性。比如爲index.js
和lib/index.js
暴露id屬性。
現在再執行index.js, 就能看到每個文件的module對象上新增的屬性:
這裏爲了簡潔,筆者刪除了輸出結果中的一些屬性,但是可以看到exports
對象現在就有了我們之前定義的屬性。你可以在exports對象上增加任意數量的屬性,也可以把整個exports對象替換成其他東西。比如說想要把exports對象換成一個函數可以如下:
再執行index.js就可以看到exports對象變成了一個函數:
這裏把exports對象替換成函數並不是通過exports = function(){}
來完成的。實際上我們也不能這麼做,因爲模塊中的exports對象只是module.exports
的引用,而module.exports
纔是負責暴露出來的屬性。當我們給exports對象重新賦值的時候,會斷開對module.exports
的引用,這種情況下只是引入了一個新的變量而不是修改module.exports
屬性。
當我們引入某個模塊,require函數返回的實際上是module.exports
對象。舉個例子,把index.js中require('./lib/util')
修改爲:
上面的代碼把lib/util
中暴露出來的屬性賦值給UTIL常量。當我們執行index.js
時,最後一行會返回如下結果:UTIL: { id: 'lib/util' }
下面來談談每個module對象上的loaded屬性。到目前爲止,每次我們打印module對象的時候,loaded
屬性都是爲false
。module對象通過loaded
屬性來記錄哪些模塊已經加載(loaded爲true),哪些模塊還未加載(loaded爲false)。可以通過setImmediate
方法來再下一個event loop中看到模塊已經加載完成的信息。
輸出結果如下:
再延遲的console.log
中我們可以看到lib/util.js
和index.js
已經被完全加載。
當node加載模塊完成後,exports對象也會變成已完成狀態。 requiring和loading
的過程是同步的。這也是爲什麼我們能夠在一個循環之後能夠看到模塊加載完成信息的原因。
同時這也意味着我們不能異步的修改exports對象。比如我們不能像下面這麼做:
Circular module dependency
下面來回答前面提到的循環依賴的問題:如果模塊1依賴模塊2,同時模塊2又依賴模塊1,這時候會發生什麼呢?
爲了找到答案,我們在lib
目錄下創建兩個文件,module1.js
和module2.js
,並讓他們互相引用:
當執行module1.js
的時候,會看到如下結果:
我們在module1
還沒有完全加載成功的情況下引用module2
,由於module2
中在module1
還沒有完全加載成功的情況就引用module1
,此時在module2
中能夠得到的exports
對象是循環依賴之前的全部屬性(也就是require('module2')
之前)。此時只能訪問到a
屬性,因爲b
和c
屬性在require('module2')
之後。
node在循環依賴這塊的處理十分簡單。你可以引用哪些還沒有完全加載的模塊,但是隻能拿到一部分屬性。
JSON and C/C++ addons
通過require
函數我們可以原生的加載JSON
和C++
擴展。使用的時候甚至不需要指定擴展名。在文件擴展名沒有指定的情況下,node首先會嘗試加載.js
的文件。如果.js
的文件沒有找到,則會嘗試加載.json
文件,如果找到.json
文件則會解析.json
文件。如果.json
文件也沒有找到,則會嘗試加載.node
文件。但是爲了避免語義模糊,開發者應該在非.js
的情況下指定文件的擴展名。
加載.json
文件對於管理靜態配置、或者週期性的從外部文件中讀取配置的場景是十分有用的。比如我們有如下json文件:
我們可以直接使用它:
運行上面的代碼會輸出:Server will run at http://localhost:8080
如果node找不到.js
和.json
的情況下,會尋找.node
文件,並採用解析node擴展的方式來解析.node
文件。
Node 官方文檔中有一個c++寫的擴展案例。該案例暴露了一個hello()
函數,執行hello()
函數會輸出world
。你可以使用node-gyp
把.cc
文件編譯、構建成.node
文件。開發者需要配置binding.gyp
來告訴node-gyp
該做什麼。在構建addon.node
成功後,就可以像引用其他模塊一樣使用:
從require.extensions
可以看到目前只支持三種類型的擴展:
可以看到每種類型都有不同的加載函數。對於.js
文件使用module._compile
方法,對於.json
文件使用JSON.parse
方法,對於.node
文件使用process.dlopen
方法。
All code you write in Node will be wrapped in functions
node中對模塊的包裹常常被誤解,在理解node對模塊的包裹之前,先來回顧下exports/module.exports
的關係。
我們可以用exports
來暴露屬性,但是不能直接替換exports
對象,因爲exports
對象只是對module.exporst
的引用。
準確的來說,exports
對象對於每個模塊來說是全局的,定義爲module
對象上屬性的引用。
在解釋node包裝過程前,我們再來問一個問題。
在瀏覽器中,當我們在全局環境中申明一個變量:var answer = 42;
在定義answer
變量之後的腳本中,answer
變量就屬於全局變量。
在node中並不是這樣的。當我們在一個模塊中定義了一個變量,另外的模塊並不能直接訪問該模塊中的變量,那麼在node中變量是如何被局部化的呢?
答案很簡單。在編譯模塊之前,node把模塊代碼包裝在一個函數中,我們可以通過module
對象上的wrapper
屬性來看到這個函數:
node並不會直接執行你寫在文件中的代碼。而是執行包裹函數的代碼,包裹函數會把你寫的代碼包裝在函數體中。這就保證了任何模塊中的頂級變量對於別的模塊來說是局部的。
wrapper
函數有5個參數:exports
,require
,module
,__filename
和__dirname
。這也是爲什麼對於每個模塊來說,這些變量都像是全局的原因,實際上對每個模塊來說,這些變量都是獨立的。
當node執行包裝函數的時候,這些變量都已經被正確賦值。exports
被定義爲module.exports
的引用,require
和module
都指向待執行的函數,__filename
和__dirname
表示了被包裹模塊的文件名和目錄的路徑。
如果你運行了一個出錯的模塊,立馬就能看到包裹函數。
可以看到報錯的是wrapper函數的第一行。除此之外,由於每個模塊都被函數包裹了一遍,我們可以通過arguments
來訪問wrapper函數所有的參數。
第一個參數是exports
對象,一開始是一個空對象,接着是require/module
對象,這兩個對象不是全局變量,都是與index.js
相關的實例化對象。最後兩個參數表示文件路徑和文件夾路徑。
包裹函數的返回值是module.exporst
。在包裹函數內部,我們可以通過exports
對象來修改module.exports
的屬性,但是不能對exports
重新賦值,因爲exports
只是一個引用。
上面描述的等價於下面的代碼:
如果我們修改了exports
對象,則exports
對象不再是module.exports
的引用。這種引用的方式不僅在這裏可以正常工作,在javascript中都是可以正常工作的。
The require object
require
對象並沒有什麼特殊的。require
是一個函數對象,接受模塊名或者路徑名,並返回module.exports
對象。如果我們想的話,可以隨意的覆蓋require
對象。
比如爲了測試,我們希望可以mock require
函數的默認行爲,返回一個模擬的對象,而不是引用模塊返回module.exports
對象。對require
進行賦值可以實現這一目的:
在對require
進行重新賦值之後,每次調用require('something')
都會返回mock對象。require
對象也有自身的屬性。前面我們已經看到過了用於解析模塊路徑的resolve
屬性以及require.extensions
屬性。
除此之外,還有require.main
屬性用來區別當前模塊是被引用還是直接運行的。比如說我們在print-in-frame.js
文件中有一個printInFrame
函數:
這個函數接受一個數值類型的參數numberic
和一個字符串類型的參數header
,函數中首先根據size
參數打印指定個數*
的frame,並在frame中打印header
。
我們可以有兩種方式來使用這個函數:
- 命令行直接調用:
~/learn-node $ node print-in-frame 8 Hello
,命令行中給函數傳入8和Hello,打印一個8個*
組成的frame,並在frame中輸出hello
。 -
require
方式調用:假設print-in-frame.js
暴露出一個printInFrame
函數,我們可以這樣調用:
這樣會在5個*組成的frame 中打印Hey
。
我們需要某種方式來區分當前模塊是命令行單獨調用還是被其他模塊引用的。這種情況,我們可以通過require.main
來做判斷:
這樣我們可以通過這個條件表達式來實現上述應用場景:
如果當前模塊沒有以模塊的方式被其他模塊引用,我們可以根據命令行參數process.argv
來調用printInFrame
函數。否則,我們設置module.exports
參數爲printInFrame
函數。
All modules will be cached
理解模塊緩存是十分重要的。我們來通過一個簡單的例子來講解下,比如說我們有一個如下的字符畫的js文件:
我們希望每次require
文件的時候都能顯示字符畫。比如我們引用兩次字符畫的js,希望可以輸出兩次字符畫:
第二次引用並不會輸出字符畫,因爲此時模塊已經被緩存了。在第一次引用後,我們可以通過require.cache
來查看模塊緩存情況。cache
對象是一個簡單的鍵值對,每次引用的模塊都會被緩存在這個對象上。cache
上的值就是每個模塊對應的module
對象。我們可以從require.cache
上移除module
對象來讓緩存失效。如果我們從緩存中緩存中移除module
對象,重新require的時候,node依然會重新加載該模塊,並重新緩存該模塊。
但是,對於這種情況,上面的修改緩存的方式並不是最好的方法。最簡單的方法是把ascii-art.js
包裝在函數中然後暴露出去,這樣的話,當我們引用ascii-art.js
的時候,會得到一個函數,每次執行的時候都會輸出字符畫。