Node.js 模塊化你所須要知道的事

1、前言

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

2、正文

在Node.js中,內置了兩個模塊來進行模塊化管理,這兩個模塊也是兩個咱們很是熟悉的關鍵字:require和module。內置意味着咱們能夠在全局範圍內使用這兩個模塊,而無需像其餘模塊同樣,須要先引用再使用。html

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

在Node.js中引用一個模塊並非什麼難事兒,很簡單:java

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

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

瞭解這五個步驟有助於咱們瞭解Node.js模塊化的基本原理,也能讓咱們甄別一些陷阱,讓咱們簡單歸納下這五個步驟都作了什麼:json

  • Resolving:找到待引用的目標模塊,並生成絕對路徑。
  • Loading:判斷待引用的模塊內容是什麼類型,它多是.json文件、.js文件或者.node文件。
  • Wrapping:顧名思義,包裝被引用的模塊。經過包裝,讓模塊具備私有做用域。
  • Evaluating:被加載的模塊被真正的解析和處理執行。
  • Caching:緩存模塊,這讓咱們在引入相同模塊時,不用再重複上述步驟。

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

By the way,若是有須要,能夠和我同樣,構建一個實驗目錄,跟着Demo進行實驗。緩存

2.1 什麼是模塊

想要了解模塊化,須要先直觀地看看模塊是什麼。app

咱們知道在Node.js中,文件即模塊,剛剛提到了模塊能夠是.js、.json或者.node文件,經過引用它們,能夠獲取工具函數、變量、配置等等,可是它的具體結構是怎樣呢?在命令行中簡單執行下面的命令就能夠看到模塊,也就是module對象的結構:dom

~/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/',
  exports: {},
  parent: null,
  filename: '/Users/samer/index.js',
  loaded: false,
  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這個值,緣由是當咱們打印模塊信息時,產生了循環的依賴關係,在子模塊信息中打印父模塊信息,又要在父模塊信息中打印子模塊信息,因此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' },
  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,
  ... }

能夠看到剛剛添加的兩個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',
       loaded: true,
       children: [],
       paths: [Object] } ],
  paths:
   [ '/Users/samer/learn-node/node_modules',
     '/Users/samer/node_modules',
     '/Users/node_modules',
     '/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', Module1); // 引用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, require, module, \_\_filename, \_\_dirname。咱們能夠經過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初始時爲空(未賦值),第2、三個參數require和module是和咱們引用的模塊相關的實例,它們倆不是全局的。第4、五個參數\_\_filename和\_\_dirname分別表示了文件路徑和目錄。

整個包裝後的函數所作的事兒約等於:

unction (require, module, __filename, __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: '.',
      exports: {},
      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來清空緩存內容,達到從新加載的目的,這裏再也不演示。

3、總結

本文概述了使用Node.js模塊化時須要瞭解到的一些基本原理和常識,但願幫助你們對Node.js模塊化有更清晰的認識。但更深刻的細節並未在本文中闡述,例如wrapper函數內部的處理邏輯,CommonJS的同步加載的問題、與ES模塊的區別等等。這些未提到的內容你們能夠在本文之外作更多探索。

做者:vivo-Wei Xing