1. 程式人生 > 程式設計 >詳解NodeJS模組化

詳解NodeJS模組化

一、前言

我們知道,Node.js是基於CommonJS規範進行模組化管理的,模組化是面對複雜的業務場景不可或缺的工具,或許你經常使用它,但卻從沒有系統的瞭解過,所以今天我們來聊一聊Node.js模組化你所需要知道的一些事兒,一探Node.js模組化的面貌。

二、正文

在Node.js中,內建了兩個模組來進行模組化管理,這兩個模組也是兩個我們非常熟悉的關鍵字:require和module。內建意味著我們可以在全域性範圍內使用這兩個模組,而無需像其他模組一樣,需要先引用再使用。

無需 require('require') or require('module')

在Node.js中引用一個模組並不是什麼難事兒,很簡單:

const config = require('/path/to/file')

但實際上,這句簡單的程式碼執行了一共五個步驟:

詳解NodeJS模組化

瞭解這五個步驟有助於我們瞭解Node.js模組化的基本原理,也能讓我們甄別一些陷阱,讓我們簡單概括下這五個步驟都做了什麼:

  • Resolving:找到待引用的目標模組,並生成絕對路徑。
  • Loading:判斷待引用的模組內容是什麼型別,它可能是.json檔案、.js檔案或者.node檔案。
  • Wrapping:顧名思義,包裝被引用的模組。通過包裝,讓模組具有私有作用域。
  • Evaluating:被載入的模組被真正的解析和處理執行。
  • Caching:快取模組,這讓我們在引入相同模組時,不用再重複上述步驟。

有些同學看完這五個步驟可能已經心知肚明,對這些原理輕車熟路,有些同學心中可能產生了更多疑惑,無論如何,接下來的內容會詳細解析上述的執行步驟,希望能幫助大家答疑解惑 or 鞏固知識、查缺補漏。

By the way,如果有需要,可以和我一樣,構建一個實驗目錄,跟著Demo進行實驗。

2.1、什麼是模組

想要了解模組化,需要先直觀地看看模組是什麼。

我們知道在Node.js中,檔案即模組,剛剛提到了模組可以是.js、.json或者.node檔案,通過引用它們,可以獲取工具函式、變數、配置等等,但是它的具體結構是怎樣呢?在命令列中簡單執行下面的命令就可以看到模組,也就是module物件的結構:

~/learn-node $ node

> module

Module {

  id: '<repl>',

  exports: {},

  parent: undefined,

  filename: null,

  loaded: false,

  children: [],

  paths: [ ... ] }

可以看到模組也就是一個普通物件,只不過結構中有幾個特殊的屬性值,需要我們一一去理解,有些屬性,例如id、parent、filename、children甚至都無需解釋,通過字面意思就可以理解。

後續的內容會幫助大家理解這些欄位的意義和作用。

2.2、Resolving

大致瞭解了什麼是模組後,我們從第一個步驟Resolving開始,瞭解模組化原理,也就是Node.js如何尋找目標模組,並生成目標模組的絕對路徑。

那麼什麼我們剛剛要先列印module物件,先讓大家瞭解module的結構呢?因為這裡有兩個欄位值id、paths和Resolving這個步驟息息相關。一起來看看吧。

首先是 id 屬性:

每個module都有id屬性,通常這個屬性值是模組的完整路徑,通過這個值Node.js可以標識和定位模組的所在位置。但是在這兒並沒有具體的模組,我們只是在命令列中輸出了module的結構,所以為預設的<repl>值(repl表示互動式直譯器)。

其次是paths屬性:

這個paths屬性有什麼作用呢?Node.js允許我們用多種方式來引用模組,比如相對路徑、絕對路徑、預置路徑(馬上會解釋),假設我們需要引用一個叫做find-me的模組,require如何幫助我們找到這個模組呢?

require('find-me')

我們先列印看看paths中是什麼內容:

~/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' ]
  

ok,其實就是一堆系統絕對路徑,這些路徑表示了所有目標模組可能出現的位置,並且它們是有序的,這意味著Node.js會按序查詢paths中列出的所有路徑,如果找到這個模組,就輸出該模組的絕對路徑供後續使用。

現在我們知道Node.js會在這一堆目錄中查詢module,嘗試執行require('find-me')來查詢find-me模組,由於我們並沒有在任何目錄放置find-me模組,所以Node.js在遍歷所有目錄之後並不能找到目標模組,因此報錯Cannot find module 'find-me',這個錯誤大家也許經常看到:

~/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)

現在,可以嘗試把需要引用的find-me模組放在上述的任意一個目錄下,在這裡我們建立一個node_modules目錄,並建立find-me.js檔案,讓Node.js能夠找到它:

~/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檔案後,Node.js果然找到了目標模組。當然,當Node.js本地的node_modules目錄中找到了find-me模組,就不會再去後續的目錄中繼續尋找了。

有Node.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.

{}

>

find-me目錄下的index.js檔案會被自動引入。

當然,這是有規則限制的,Node.js之所以能夠找到find-me目錄下的index.js檔案,是因為預設的模組引入規則是當具體的檔名缺失時尋找index.js檔案。我們也可以更改引入規則(通過修改package.json),比如把index -> main:

~/learn-node $ echo "console.log('I rule');" > node_modules/find-me/main.js

~/learn-node $ echo '{ "name": "find-me-folder","main": "main.js" }' > node_modules/find-me/package.json

~/learn-node $ node

> require('find-me');

I rule

{}

>

2.3、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.js會列印模組的完整路徑,如果未找到,就報錯。

瞭解了Node.js是如何尋找模組之後,來看看Node.js是如何載入模組的。

2.4、模組間的父子依賴關係

我們把模組間引用關係,表示為父子依賴關係。

簡單建立一個lib/util.js檔案,新增一行console.log語句,標識這是一個被引用的子模組。

~/learn-node $ mkdir lib

~/learn-node $ echo "console.log('In util');" > lib/util.js

在index.js也輸入一行console.log語句,標識這是一個父模組,並引用剛剛建立的lib/util.js作為子模組。

~/learn-node $ echo "require('./lib/util'); console.log('In index,parent',module);" > index.js

執行index.js,看看它們間的依賴關係:

~/learn-node $ node index.js

In util

In index <ref *1> Module {

  id: '.',

  path: '/Users/samer/',

  parent: null,

  filename: '/Users/samer/index.js',

  children: [

    Module {

      id: '/Users/samer/lib/util.js',

      path: '/Users/samer/lib',

      exports: {},

      parent: [Circular *1],

      filename: '/Users/samer/lib/util.js',

      loaded: true,

      children: [],

      paths: [Array]

    }

  ],

  paths: [...]

}

在這裡我們關注與依賴關係相關的兩個屬性:children和parent。

在列印的結果中,children欄位包含了被引入的util.js模組,這表明了util.js是index.js所依賴的子模組。

但仔細觀察util.js模組的parent屬性,發現這裡出現了Circular這個值,原因是當我們列印模組資訊http://www.cppcns.com時,產生了迴圈的依賴關係,在子模組資訊中列印父模組資訊,又要在父模組資訊中列印子模組資訊,所以Node.js簡單地將它處理標記為Circular。

為什麼需要了解父子依賴關係呢?因為這關係到Node.js是如何處理迴圈依賴關係的,後續會詳細描述。

在看迴圈依賴關係的處理問題之前,我們需要先了解兩個關鍵的概念:exports和module.exports。

2.5、exports,module.exports

exports:

exports是一個特殊的物件,它在Node.js中可以無需宣告,作為全域性變數直接使用。它實際上是module.exports的引用,通過修改exports可以達到修改module.exports的目的。

exports也是剛剛列印的module結構中的一個屬性值,但是剛剛打印出來的值都是空物件,因為我們並沒有在檔案中對它進行操作,現在我們可以嘗試簡單地為它賦值:

// 在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' },

  ... }

In util Module {

  id: '/Users/samer/learn-node/lib/util.js',

  exports: { id: 'lib/util' },

  parent:

   Module {

     id: '.',

     exports: { id: 'index' },

     loaded: false,

     ... },

  ... }

可以看到剛剛新增的兩個id屬性被成功新增到exports物件中。我們也可以新增除id以外的任意屬性,就像操作普通物件一樣,當然也可以把exports變成一個function,例如:

exports = function() {}

module.exports:

module.exports物件其實就是我們最終通過require所得到的東西。我們在編寫一個模組時,最終給module.exports賦什麼值,其他人引用該模組時就能得到什麼值。例如,結合剛剛對lib/util的操作:

const util = require('./lib/util');
  
console.log('UTIL:',util);
  
// 輸出結果
  
UTIL: { id: 'lib/util' }

由於我們剛剛通過exports物件為module.exports賦值{id: 'lib/util'},因此require的結果就相應地發生了變化。

現在我們大致瞭解了exports和module.exports都是什麼,但是有一個小細節需要注意,那就是Node.js的模組載入是個同步的過程。

我們回過頭來看看module結構中的loaded屬性,這個屬性標識這個模組是否被載入完成,通過這個屬性就能簡單驗證Node.js模組載入的同步性。

當模組被載入完成後,loaded值應該為true。但到目前為止每次我們列印module時,它的狀態都是false,這其實正是因為在Node.js中,模組的載入是同步的,當我們還未完成載入的動作(載入的動作包括對module進行標記,包括標記loaded屬性),因此打印出的結果就是預設的loaded: false。

我們用setImmediate來幫助我們驗證這個資訊:

// 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',children: [],paths: [Object] } ],paths:
   [ '/Users/samer/learn-node/node_moduleswww.cppcns.com','/node_modules' ] }

ok,由於console.log被後置到載入完成(打完標記)之後,因此現在載入狀態變成了loaded: true。這充分驗證了Node.js模組載入是一個同步過程。

瞭解了exports、module.exports以及模組載入的同步性後,來看看Node.js是如何處理模組的迴圈依賴關係。

2.6、模組迴圈依賴

在上述內容中,我們瞭解到了模組之間是存在父子依賴關係的,那如果模組之間產生了迴圈的依賴關係,Node.js會怎麼處理呢?假設有兩個模組,分別為module1.js和modole2.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',Modu程式設計客棧le1); // 引用module1並列印它

嘗試執行module1.js,可以看到輸出結果:

~/learn-node $ node lib/module1.js

Module1 is partially loaded here { a: 1 }

結果中只輸出了{a: 1},而{b: 2,c: 3}卻不見了。仔細觀察module1.js,發現我們在module1.js的中間位置添加了對module2.js的引用,也就是exports.b = 2和exports.c = 3還未執行之前的位置。如果我們把這個位置稱作發生迴圈依賴的位置,那麼我們得到的結果就是在迴圈依賴發生前被匯出的屬性,這也是基於我們上述驗證過的Node.js的模組載入是同步過程的結論。

Node.js就是這樣簡單地處理迴圈依賴。在載入模組的過程中,會逐步構建exports物件,為exports賦值。如果我們在模組被完全載入前就引用這個模組,那麼我們只能得到部分的exports物件屬性。

2.7、.json和.node

在Node.js中,我們不僅能用require來引用javascript檔案,還能用於引用JSON或C++外掛(.json和.node檔案)。我們甚至都不需要顯式地宣告對應的檔案字尾。

在命令列中也可以看到require所支援的檔案型別:

~ % node

> require.extensions

[Object: null prototype] {

  '.js': [Function (anonymous)],

  '.json': [Function (anonymous)],

  '.node': [Function (anonymous)]

}

當我們用require引用一個模組,首先Node.js會去匹配是否有.js檔案,如果沒有找到,再去匹配.json檔案,如果還沒找到,最後再嘗試匹配.node檔案。但是通常情況下,為了避免混淆和引用意圖不明,可以遵循在引用.json或.node檔案時顯式地指定字尾,引用.js時省略字尾(可選,或都加上字尾)。

.json檔案:

引用.json檔案很常用,例如一些專案中的靜態配置,使用.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檔案:

.node檔案是由C++檔案轉化而來,官網提供了一個簡單的由C++實現的hello外掛,它暴露了一個hello()方法,輸出字串world。有需要的話,可以跳轉連結做更多瞭解並進行實驗。

我們可以通過node-gyp來將.cc檔案編譯和構建成.node檔案,過程也非常簡單,只需要配置一個binding.gyp檔案即可。這裡不詳細闡述,只需要知道生成.node檔案後,就可以正常地引用該檔案,並使用其中的方法。

例如,將hello()轉化生成addon.node檔案後,引用並使用它:

const addon = require('./addon');
console.log(addon.hello());

2.8、Wrapping

其實在上述內容中,我們闡述了在Node.js中引用一個模組的前兩個步驟Resolving和Loading,它們分別解決了模組的路徑和載入的問題。接下來看看Wrapping都做了什麼。

Wrapping就是包裝,包裝的物件就是所有我們在模組中寫的程式碼。也就是我們引用模組時,其實經歷了一層『透明』的包裝。

要了解這個包裝過程,首先要理解exports和module.exports之間的區別。

exports是對module.exports的引用,我們可以在模組中使用exports來匯出屬性,但是不能直接替換它。例如:

exports.id = 42; // ok,此時exports指向module.exports,相當於修改了module.exports.
exports = { id: 42 }; // 無用,只是將它指向了{ id: 42 }物件而已,對module.exports不會產生實際改變.
module.exports = { id: 42 }; // ok,直接操作module.exports.

大家也許會有疑惑,為什麼這個exports物件似乎對每個模組來說都是一個全域性物件,但是它又能夠區分匯出的物件是來自於哪個模組,這是怎麼做到的。

在瞭解包裝(Wrapping)過程之前,來看一個小例子:

// In a.js
var value = 'global'
  
// In b.js
console.log(value)  // 輸出:global
  
// In c.js
console.log(value)  // 輸出:global
  
// In index.html
...
<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>

當我們在a.js指令碼中定義一個值value,這個值是全域性可見的,後續引入的b.js和c.js都是可以訪問該value值。但是在Node.js模組中卻並不是這樣,在一個模組中定義的變數具有私有作用域,在其它模組中無法直接訪問。這個私有作用域如何產生的?

答案很簡單,是因為在編譯模組之前,Node.js將模組中的內容包裝在了一個function中,通過函式作用域實現了私有作用域。

通過require('module').wrapper可以打印出wrapper屬性:

~ $ node

> require('module').wrapper

[ '(function (exports,require,module,__filename,__dirname) { ',

  '\n});' ]

>

Node.js不會直接執行檔案中的任何程式碼,但它會通過這個包裝後的function來執行程式碼,這讓我們的每個模組都有了私有作用域,不會互相影響。

這個包裝函式有五個引數:exports,__dirname。我們可以通過arguments引數直接訪問和列印這些引數:

/learn-node $ echo "console.log(arguments)" > index.js

~/learn-node $ node index.js

{ '0': {},

  '1':

   { [Function: require]

     resolve: [Function: resolve],

   www.cppcns.com;  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',

     child程式設計客棧ren: [],

     paths: [ ... ] },

  '3': '/Users/samer/index.js',

  '4': '/Users/samer' }

簡單瞭解一下這幾個引數,第一個引數exports初始時為空(未賦值),第二、三個引數require和module是和我們引用的模組相關的例項,它們倆不是全域性的。第四、五個引數__filename和__dirname分別表示了檔案路徑和目錄。

整個包裝後的函式所做的事兒約等於:

unction (require,__dirname) {
  let exports = module.exports;
    
  // Your Code...
    
  return module.exports;
}

總而言之,wrapping就是將我們的模組作用域私有化,以module.exports作為返回值將變數或方法暴露出來,以供使用。

2.9、Cache

快取很容易理解,通過一個案例來看看吧:

echo 'console.log(`log something.`)' > index.js

// In node repl

> require('./index.js')

log something.

{}

> require('./index.js')

{}

>

可以看到,兩次引用同一個模組,只打印了一次資訊,這是因為第二次引用時取的是快取,無需重新載入模組。

列印require.cache可以看到當前的快取資訊:

> require.cache

[Object: null prototype] {

  '/Users/samer/index.js': Module {

    id: '/Users/samer/index.js',

    path: '/Users/samer/',

    exports: {},

    parent: Module {

      id: '<repl>',

      path: '.',

      parent: undefined,

      filename: null,

      loaded: false,

      children: [Array],

      paths: [Array]

    },

    filename: '/Users/samer/index.js',

    loaded: true,

    children: [],

    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'

    ]

  }

}

可以看到剛剛引用的index.js檔案處於快取當中,因此不會重新載入模組。當然我們也可以通過刪除require.cache來清空快取內容,達到重新載入的目的,這裡不再演示。

三、總結

本文概述了使用Node.js模組化時需要了解到的一些基本原理和常識,希望幫助大家對Node.js模組化有更清晰的認識。但更深入的細節並未在本文中闡述,例如wrapper函式內部的處理邏輯,CommonJS的同步載入的問題、與ES模組的區別等等。這些未提到的內容大家可以在本文以外做更多探索。

以上就是詳解NodeJS模組化的詳細內容,更多關於NodeJS 模組化的資料請關注我們其它相關文章!