開發者需要了解的nodejs中require的機制

warjiang發表於2018-11-04

原文地址:medium.freecodecamp.org/requiring-m…

image

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物件

image

每個module物件都有id屬性用來區分不同的模組。id屬性一般都是模組對應的絕對路徑,在REPL中會簡單的設定為<repl>。Node模組和系統磁碟上的檔案是一一對應的。引用模組實際上會將檔案中的內容載入到記憶體中。node支援通過多種方式來引用檔案(比如說通過相對路徑或者預配置路徑),在把檔案中內容載入到記憶體之前,需要先找到檔案的絕對路徑。 當不指定路徑直接引用find-me模組的時候:require('find-me'); node會依次遍歷module.paths指定的路徑來尋找find-me模組:

image

上面的路徑是從當前路徑到根目錄所有目錄下的node_modules資料夾的路徑,除此之外也包括一些遺留的但是已經不推薦使用的路徑。如果node在上述路徑中都找不到find-me.js就會丟擲一個“cannot find module error.”的異常。

image

如果在當前資料夾下建立一個node_modules資料夾,並建立一個find-me.js檔案,這時require('find-me')就能夠找到find-me了。

image

如果其他路徑下也存在find-me.js 比如在使用者的home目錄下的node_modules資料夾下面存在另外一個find-me.js:

image

當在learn-code目錄下執行require('find-me'),由於在learn-code下的node_modules目錄下有一個find-me.js,此時使用者的home目錄下的find-me.js並不會載入執行。

image

如果我們從~/learn-code目錄下刪除node_modules資料夾,再執行引用find-me,則會使用使用者home目錄下的node_modules下的fine-me:

image

Requiring a folder

模組不一定只是一個檔案,讀者也可以建立一個find-me資料夾,並且在資料夾中建立index.js,require('find-me')的時候會引用index.js:

image

注意此時由於當前目錄下有了find-me, 則此時引用find-me會忽略使用者home目錄下的node_modules。當引用目錄的時候,預設情況下會尋找index.js,但是我們可以通過package.json中的main屬性來指示用那個檔案。舉個例子,為了讓require('find-me')能夠解析到find-me資料夾下的其他檔案,我們需要在find-me目錄下加一個package.json,並指定應該解析到哪個檔案:

image

require.resolve

如果只想解析模組但不執行模組,可以使用require.resolve函式。resolverequire函式的表現除了不執行檔案之外,其他方面表現是一致的。當檔案找不到的時候仍然會丟擲一個異常,在找到檔案的情況下會返回檔案的絕對路徑。

image

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物件:

image

在index.js也加入類似的程式碼,後面我們通過node執行index.js。在index.js中引用lib/util.js:

image

在node中執行index.js:

image

注意index模組(id: '.')lib/util模組的父模組。但是輸出結果中lib/util模組並沒有顯示在index模組的子模組中。取而代之的是一個[Circular]的值,因為這兒是一個迴圈引用。此時如果node列印lib/utilindex的子模組的話,則會進入到死迴圈。這也可以解釋了為什麼需要簡單的用[Circular]來代替lib/util。 那麼如果在lib/util模組中引用index模組會發生什麼。這就是node中所允許的的迴圈引用。

為了能夠更好的理解迴圈依賴,首先需要了解一些關於module物件上的一些概念。

exports, module.exports, and synchronous loading of modules

任何模組中exports都是一個特別的物件。注意到上面的結果中,每次列印module物件,都會有一個為空物件的exports屬性。我們可以在這個特別的exports物件上加入一些屬性。比如為index.jslib/index.js暴露id屬性。

image

現在再執行index.js, 就能看到每個檔案的module物件上新增的屬性:

image

這裡為了簡潔,筆者刪除了輸出結果中的一些屬性,但是可以看到exports物件現在就有了我們之前定義的屬性。你可以在exports物件上增加任意數量的屬性,也可以把整個exports物件替換成其他東西。比如說想要把exports物件換成一個函式可以如下:

image

再執行index.js就可以看到exports物件變成了一個函式:

image

這裡把exports物件替換成函式並不是通過exports = function(){}來完成的。實際上我們也不能這麼做,因為模組中的exports物件只是module.exports的引用,而module.exports才是負責暴露出來的屬性。當我們給exports物件重新賦值的時候,會斷開對module.exports的引用,這種情況下只是引入了一個新的變數而不是修改module.exports屬性。

當我們引入某個模組,require函式返回的實際上是module.exports物件。舉個例子,把index.js中require('./lib/util')修改為:

image

上面的程式碼把lib/util中暴露出來的屬性賦值給UTIL常量。當我們執行index.js時,最後一行會返回如下結果:UTIL: { id: 'lib/util' }

下面來談談每個module物件上的loaded屬性。到目前為止,每次我們列印module物件的時候,loaded屬性都是為false。module物件通過loaded屬性來記錄哪些模組已經載入(loaded為true),哪些模組還未載入(loaded為false)。可以通過setImmediate方法來再下一個event loop中看到模組已經載入完成的資訊。

image

輸出結果如下:

image

再延遲的console.log中我們可以看到lib/util.jsindex.js已經被完全載入。 當node載入模組完成後,exports物件也會變成已完成狀態。requiring和loading的過程是同步的。這也是為什麼我們能夠在一個迴圈之後能夠看到模組載入完成資訊的原因。

同時這也意味著我們不能非同步的修改exports物件。比如我們不能像下面這麼做:

image

Circular module dependency

下面來回答前面提到的迴圈依賴的問題:如果模組1依賴模組2,同時模組2又依賴模組1,這時候會發生什麼呢? 為了找到答案,我們在lib目錄下建立兩個檔案,module1.jsmodule2.js,並讓他們互相引用:

image

當執行module1.js的時候,會看到如下結果:

image

我們在module1還沒有完全載入成功的情況下引用module2,由於module2中在module1還沒有完全載入成功的情況就引用module1,此時在module2中能夠得到的exports物件是迴圈依賴之前的全部屬性(也就是require('module2')之前)。此時只能訪問到a屬性,因為bc屬性在require('module2')之後。

node在迴圈依賴這塊的處理十分簡單。你可以引用哪些還沒有完全載入的模組,但是隻能拿到一部分屬性。

JSON and C/C++ addons

通過require函式我們可以原生的載入JSONC++擴充套件。使用的時候甚至不需要指定副檔名。在副檔名沒有指定的情況下,node首先會嘗試載入.js的檔案。如果.js的檔案沒有找到,則會嘗試載入.json檔案,如果找到.json檔案則會解析.json檔案。如果.json檔案也沒有找到,則會嘗試載入.node檔案。但是為了避免語義模糊,開發者應該在非.js的情況下指定檔案的副檔名。

載入.json檔案對於管理靜態配置、或者週期性的從外部檔案中讀取配置的場景是十分有用的。比如我們有如下json檔案:

image

我們可以直接使用它:

image

執行上面的程式碼會輸出: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成功後,就可以像引用其他模組一樣使用:

image

require.extensions可以看到目前只支援三種型別的擴充套件:

image

可以看到每種型別都有不同的載入函式。對於.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的引用。

image

準確的來說,exports物件對於每個模組來說是全域性的,定義為module物件上屬性的引用。 在解釋node包裝過程前,我們再來問一個問題。 在瀏覽器中,當我們在全域性環境中申明一個變數:var answer = 42; 在定義answer變數之後的指令碼中,answer變數就屬於全域性變數。 在node中並不是這樣的。當我們在一個模組中定義了一個變數,另外的模組並不能直接訪問該模組中的變數,那麼在node中變數是如何被區域性化的呢?

答案很簡單。在編譯模組之前,node把模組程式碼包裝在一個函式中,我們可以通過module物件上的wrapper屬性來看到這個函式:

image

node並不會直接執行你寫在檔案中的程式碼。而是執行包裹函式的程式碼,包裹函式會把你寫的程式碼包裝在函式體中。這就保證了任何模組中的頂級變數對於別的模組來說是區域性的。

wrapper函式有5個引數:exports,require,module,__filename__dirname。這也是為什麼對於每個模組來說,這些變數都像是全域性的原因,實際上對每個模組來說,這些變數都是獨立的。

當node執行包裝函式的時候,這些變數都已經被正確賦值。exports被定義為module.exports的引用,requiremodule都指向待執行的函式,__filename__dirname表示了被包裹模組的檔名和目錄的路徑。

如果你執行了一個出錯的模組,立馬就能看到包裹函式。

image

可以看到報錯的是wrapper函式的第一行。除此之外,由於每個模組都被函式包裹了一遍,我們可以通過arguments來訪問wrapper函式所有的引數。

image

第一個引數是exports物件,一開始是一個空物件,接著是require/module物件,這兩個物件不是全域性變數,都是與index.js相關的例項化物件。最後兩個參數列示檔案路徑和資料夾路徑。

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

上面描述的等價於下面的程式碼:

image

如果我們修改了exports物件,則exports物件不再是module.exports的引用。這種引用的方式不僅在這裡可以正常工作,在javascript中都是可以正常工作的。

The require object

require物件並沒有什麼特殊的。require是一個函式物件,接受模組名或者路徑名,並返回module.exports物件。如果我們想的話,可以隨意的覆蓋require物件。 比如為了測試,我們希望可以mock require函式的預設行為,返回一個模擬的物件,而不是引用模組返回module.exports物件。對require進行賦值可以實現這一目的:

image

在對require進行重新賦值之後,每次呼叫require('something')都會返回mock物件。 require物件也有自身的屬性。前面我們已經看到過了用於解析模組路徑的resolve屬性以及require.extensions屬性。 除此之外,還有require.main屬性用來區別當前模組是被引用還是直接執行的。比如說我們在print-in-frame.js檔案中有一個printInFrame函式:

image

這個函式接受一個數值型別的引數numberic和一個字串型別的引數header,函式中首先根據size引數列印指定個數*的frame,並在frame中列印header。 我們可以有兩種方式來使用這個函式:

  1. 命令列直接呼叫:~/learn-node $ node print-in-frame 8 Hello,命令列中給函式傳入8和Hello,列印一個8個*組成的frame,並在frame中輸出hello
  2. require方式呼叫:假設print-in-frame.js暴露出一個printInFrame函式,我們可以這樣呼叫:
    image

這樣會在5個*組成的frame 中列印Hey。 我們需要某種方式來區分當前模組是命令列單獨呼叫還是被其他模組引用的。這種情況,我們可以通過require.main 來做判斷:

image

這樣我們可以通過這個條件表示式來實現上述應用場景:

image

如果當前模組沒有以模組的方式被其他模組引用,我們可以根據命令列引數process.argv來呼叫printInFrame函式。否則,我們設定module.exports引數為printInFrame函式。

All modules will be cached

理解模組快取是十分重要的。我們來通過一個簡單的例子來講解下,比如說我們有一個如下的字元畫的js檔案:

image

我們希望每次require檔案的時候都能顯示字元畫。比如我們引用兩次字元畫的js,希望可以輸出兩次字元畫:

image

第二次引用並不會輸出字元畫,因為此時模組已經被快取了。在第一次引用後,我們可以通過require.cache來檢視模組快取情況。cache物件是一個簡單的鍵值對,每次引用的模組都會被快取在這個物件上。cache上的值就是每個模組對應的module物件。我們可以從require.cache上移除module物件來讓快取失效。如果我們從快取中快取中移除module物件,重新require的時候,node依然會重新載入該模組,並重新快取該模組。 但是,對於這種情況,上面的修改快取的方式並不是最好的方法。最簡單的方法是把ascii-art.js包裝在函式中然後暴露出去,這樣的話,當我們引用ascii-art.js的時候,會得到一個函式,每次執行的時候都會輸出字元畫。

image

相關文章