關於在 Node.js 中引用模塊,知道這些就夠了

Node.js 中模塊化的工做原理

Node 提供了兩個核心模塊來管理模塊依賴:javascript

  • require 模塊在全局範圍內可用,不須要寫 require('require').
  • module 模塊一樣在全局範圍內可用,不須要寫 require('module').

你能夠將 require 模塊理解爲命令,將 module 模塊理解爲全部引入模塊的組織者。html

在 Node 中引入一個模塊其實並非個多麼複雜的概念。java

const config = require('/path/to/file');複製代碼

require 模塊導出的主對象是一個函數(如上例)。當 Node 將本地文件路徑做爲惟一參數調用 require() 時,Node 將執行如下步驟:node

  • 解析:找到該文件的絕對路徑。
  • 加載:肯定文件內容的類型。
  • 打包:爲文件劃分私有做用域,這樣 requiremodule 兩個對象對於咱們要引入的每一個模塊來講就都是本地的。
  • 評估:最後由虛擬機對加載獲得的代碼作評估。
  • 緩存:當再次引用該文件時,無需再重複以上步驟。git

    在本文中,我將嘗試舉例說明這些不一樣階段的工做原理,以及它們是如何影響咱們在 Node 中編寫模塊的方式的。github

我先使用終端建立一個目錄來託管本文中的全部示例:json

mkdir ~/learn-node && cd ~/learn-node複製代碼

以後的全部命令都將在 ~/learn-node 目錄下運行。api

解析本地路徑

首先,讓我來介紹一下 module 對象。你能夠在一個簡單的 REPL 會話中查看該對象:數組

~/learn-node $ node
> module
Module {
  id: '<repl>',
  exports: {},
  parent: undefined,
  filename: null,
  loaded: false,
  children: [],
  paths: [ ... ]}複製代碼

每一個模塊對象都有一個用於識別該對象的 id 屬性。這個 id 一般是該文件的完整路徑,但在 REPL 會話中只會顯示爲 <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 從當前目錄開始一級級向上尋找 node_modules 目錄,這個數組大體就是當前目錄到全部 node_modules 目錄的相對路徑。其中還包括一些爲了兼容性保留的目錄,不推薦使用。

若是 Node 在以上路徑中都沒法找到 find-me.js ,將拋出一個 「找不到該模塊」 錯誤。

~/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 文件呢?例如,咱們在主目錄下的 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,主目錄下的 find-me.js 文件並不會被加載:

~/learn-node $ node
> require('find-me')
I am not lost
{}
>複製代碼

此時,若是咱們將 ~/learn-node 下的 node_modules 移除,再一次引入 find-me 模塊,那麼主目錄下的 node_modules 將會被加載:

~/learn-node $ rm -r node_modules/

~/learn-node $ node
> require('find-me')
I am the root of all problems
{}
>複製代碼

引入文件夾

模塊不必定是單個文件。咱們也能夠在 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 路徑。

當咱們引入一個文件夾時,將默認使用 index.js 文件,可是咱們能夠經過 package.json 中的 main 屬性指定主入口文件。例如,要令 require('find-me') 解析到 find-me 文件夾下的另外一個文件,咱們只須要在該文件夾下添加一個 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 文件並不在 node_modules 中,而在 lib 文件夾中。咱們可使用如下代碼引入它:

require('./lib/find-me');複製代碼

文件間的父子關係

如今咱們來建立一個 lib/util.js 文件,向文件添加一行 console.log 代碼做爲標識。打印出 module 對象自己:

~/learn-node $ mkdir lib
~/learn-node $ echo "console.log('In util', module);" > lib/util.js複製代碼

一樣的,向 index.js 文件中也添加一行打印 module 對象的代碼,並在文件中引入 lib/util.js,咱們將使用 node 命令運行該文件:

~/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 模塊的父模塊。但 lib/util 模塊並無被列爲 index 模塊的子模塊。相反,咱們在這裏獲得的值是 [Circular],由於這是一個循環引用。若是 Node 打印 lib/util 模塊對象,將進入一個無限循環。 所以 Node 使用 [Circular] 代替了 lib/util 引用。

重點來了,若是咱們在 lib/util 模塊中引入 index 主模塊會發生什麼?這就是 Node 中所支持的循環依賴。

爲了更好理解循環依賴,咱們先來了解一些關於 module 對象的概念。

exports、module.exports 和模塊異步加載

在全部模塊中,exports 都是一個特殊對象。你可能注意到了,以上咱們每打印一個 module 對象時,它都有一個空的 exports 屬性。咱們能夠向這個特殊的 exports 對象添加任意屬性。例如,咱們如今爲 index.jslib/util.js 的 exports 對象添加一個 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 對象替換爲其它對象。例如,咱們能夠經過如下方式將 exports 對象更改成一個函數:

// 將如下代碼添加在 index.js 中的 console.log 語句前

module.exports = function() {};複製代碼

再次運行 index.js,你將看到 exports 對象是一個函數:

~/learn-node $ node index.js
In index Module {
  id: '.',
  exports: [Function],
  loaded: false,
  ... }複製代碼

注意:咱們並無使用 exports = function() {} 來將 exports 對象更改成函數。實際上,因爲各模塊中的 exports 變量僅僅是對管理輸出屬性的 module.exports 的引用,當咱們對 exports 變量從新賦值時,引用就會丟失,所以咱們只須要引入一個新的變量,而不是對 module.exports 進行修改。

各模塊中的 module.exports 對象就是咱們在引入該模塊時 require 函數的返回值。例如,咱們將 index.js 中的 require('./lib/util') 改成:

const UTIL = require('./lib/util');

console.log('UTIL:', UTIL);複製代碼

以上代碼會將 lib/util 輸出的屬性賦值給 UTIL 常量。咱們如今運行 index.js,最後一行將輸出如下結果:

UTIL: { id: 'lib/util' }複製代碼

咱們再來談談各模塊中的 loaded 屬性。到目前爲止咱們打印的全部 module 對象中都有一個值爲 falseloaded 屬性。

module 模塊使用 loaded 屬性對模塊的加載狀態進行跟蹤,判斷哪些模塊已經加載完成(值爲 true)以及哪些模塊仍在加載(值爲 false)。例如,咱們可使用 setImmediate 在下一個事件循環中打印出它的 module 對象,以此來判斷 index.js 模塊是否已徹底加載。

// 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' ] }複製代碼

注意:這個延遲的 console.log 的輸出顯示了 lib/util.jsindex.js 都已徹底加載。

在 Node 完成加載模塊(並標記爲完成)時,exports 對象也就完成了。引入一個模塊的整個過程是 同步的,所以咱們才能在一個事件循環後看見模塊被徹底加載。

這也意味着咱們沒法異步地更改 exports 對象。例如,咱們在任何模塊中都沒法執行如下操做:

fs.readFile('/etc/passwd', (err, data) => {
  if (err) throw err;

  exports.data = data; // 無效
});複製代碼

模塊的循環依賴

咱們如今來回答關於 Node 中循環依賴的重要問題:當咱們在模塊1中引用模塊2,在模塊2中引用模塊1時會發生什麼?

爲了找到答案,咱們在 lib/ 下建立 module1.jsmodule2.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,而此時 module1 還沒有加載完,咱們從當前的 exports 對象中獲得的是在循環依賴以前導出的全部屬性。這裏被列出的只有屬性 a,由於屬性 bc 都是在 module2 引入並打印了 module1 後才導出的。

Node 使這個過程變得很是簡單。它在模塊加載時構建 exports 對象。你能夠在該模塊完成加載前引用它,而你將獲得此時已定義的部分導出對象。

使用 JSON 文件和 C/C++ 插件

咱們可使用自帶的 require 函數引用 JSON 文件和 C++ 插件。你甚至不須要爲此指定文件擴展名。

若是沒有指定文件擴展名,Node 會在第一時間嘗試解析 .js 文件。若是沒有找到 .js 文件,它將繼續尋找 .json 文件並在找到一個 JSON 文本文件後將其解析爲 .json 文件。隨後,Node 將會查找二進制的 .node 文件。爲了不產生歧義,你最好在引用除 .js 文件之外的文件類型時指定文件擴展名。

若是你須要在文件中放置的內容都是一些靜態的配置信息,或者須要按期從外部來源讀取一些值時,使用 JSON 文件將很是方便。例如,咱們有如下 config.json 文件:

{
  "host": "localhost",
  "port": 8080
}複製代碼

咱們能夠這樣直接引用它:

const { host, port } = require('./config');

console.log(`Server will run at [http://${host}:${port}](http://$%7Bhost%7D:$%7Bport%7D`));複製代碼

執行以上代碼將輸出如下結果:

Server will run at [http://localhost:8080](http://localhost:8080)複製代碼

若是 Node 找不到 .js.json 文件,它會尋找 .node 文件並將其做爲一個編譯好的插件模塊進行解析。

Node 文檔中有一個用 C++ 編寫的插件示例,該示例模塊提供了一個輸出 「world」 的 hello() 函數。

你可使用 node-gyp 插件將 .cc 文件編譯成 .addon 文件。只須要配置一個 binding.gyp 文件來告訴 node-gyp 要作什麼。

有了 addon.node 文件(你能夠在 binding.gyp 中聲明任意文件名),你就能夠像引用其餘模塊同樣引用它了。

const addon = require('./addon');

console.log(addon.hello());複製代碼

咱們能夠在 require.extensions 中查看 Node 對這三類擴展名的支持。

你能夠看到每一個擴展名分別對應的函數,從中瞭解 Node 會對它們作出怎樣的操做:對 .js 文件使用 module._compile,對 .json 文件使用 JSON.parse,對 .node 文件使用 process.dlopen

你在 Node 中寫的全部代碼都將被封裝成函數

經常有人誤解 Node 的模塊封裝。要了解它的原理,請回憶一下 exportsmodule.exports 的關係。

咱們可使用 exports 對象導出屬性,可是因爲 exports 對象僅僅是對 module.exports 的一個引用,咱們沒法直接對其執行替換操做。

exports.id = 42; // 有效

exports = { id: 42 }; // 無效

module.exports = { id: 42 }; // 有效複製代碼

這個 exports 對象看起來對全部模塊都是全局的,它是如何被定義成 module 對象的引用的呢?

在解釋 Node 的封裝過程前,讓咱們再來思考一個問題:

在瀏覽器中,咱們在腳本里聲明以下變量:

var answer = 42;複製代碼

answer 變量對聲明該變量的腳本後的全部腳原本說都是全局的。

然而在 Node 中卻不是這樣的。咱們在一個模塊中定義了變量,項目中的其餘模塊卻將沒法訪問該變量。那麼 Node 是如何神奇地作到爲變量限定做用域的呢?

答案很簡單。在編譯模塊前,Node 就將模塊代碼封裝在一個函數中,咱們可使用 module 模塊的 wrapper 屬性來查看。

~ $ node
> require('module').wrapper
[ '(function (exports, require, module, __filename, __dirname) { ',
  '\n});' ]
>複製代碼

Node 並不會直接執行你在文件中寫入的代碼。它執行的是封裝着你的代碼的函數。這就保證了全部模塊中定義的頂級變量的做用域都被限定在該模塊中。

這個封裝函數包含五個參數:exportsrequiremodule__filename__dirname。這些參數看起來像是全局的,實際上倒是每一個模塊特定的。

在 Node 執行封裝函數的同時,以上這幾個參數都獲取到了它們的值。exports 被定義爲對上一級 module.exports 的引用。requiremodule 都是特定於被執行函數的,而 __filename/__dirname 變量將包含被封裝模塊的文件名和目錄的絕對路徑。

若是你在一個腳本的第一行編寫一行錯誤代碼並執行它,你就能看到實際的封裝過程:

~/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 的屬性,可是因爲它僅僅是一個引用,咱們沒法對其從新賦值。

狀況大體以下:

function (require, module, __filename, __dirname) {
  let exports = module.exports;

  // 你的代碼…

  return module.exports;
}複製代碼

若是咱們更改了整個 exports 對象,它將再也不是對 module.exports 的引用。並不只僅是在這個上下文中,JavaScript 在任何狀況下引用對象都是這樣的。

require 對象

require 沒有什麼特別的。它做爲一個函數對象,接收一個模塊名稱或路徑,返回 module.exports 對象。咱們也能夠用咱們本身的邏輯重寫 require 對象。

舉個例子,爲了測試的目的,咱們但願每一個 require 的調用都返回一個僞造的 mocked 對象,而不是引用的模塊所導出的對象。這個對 require 的簡單從新賦值會這樣實現:

require = function() {

  return { mocked: true };

}複製代碼

通過以上對 require 從新賦值後,腳本中的每一個 require('something') 調用都會返回 mocked 對象。

require 對象也有它本身的屬性。咱們已經認識了 resolve 屬性,它是在 require 過程當中負責解析步驟的函數。咱們也見識了 require.extensions

還有 require.main 屬性,有助於判斷當前腳本是正被引用仍是直接執行。

舉個例子,咱們在 print-in-frame.js 中定義一個簡單的 printInFrame 函數:

// 在 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」。

  1. 使用 require。假設被引用的模塊會導出 printInFrame 函數,咱們能夠這樣調用它:
const print = require('./print-in-frame');

print(5, 'Hey');複製代碼

打印由五個星號組成的框以及其中的標題 「Hey」。

以上是兩種不一樣的用法。咱們須要一種方法來肯定該文件是做爲獨立腳本運行仍是被其餘腳本引用時運行。

此時咱們可使用簡單的 if 聲明語句:

if (require.main === module) {
  // 該文件正被直接運行
}複製代碼

因此咱們可使用該條件判斷來知足上述使用需求,經過不一樣的方式調用 printInFrame 函數。

// 在 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;
}複製代碼

若是文件不是被引用的,咱們使用 process.argv 的參數來調用 printInFrame 函數。不然咱們就將 module.exports 對象替換爲 printInFrame 函數。

全部模塊都將被緩存

理解緩存很是重要。下面我用一個簡單的例子來演示一下。

假設你有如下 ascii-art.js 文件,它能打印出一個很酷的標題:

咱們但願在每次 引用 該文件時都顯示這個標題。所以若是咱們引用了兩次該文件,咱們但願標題顯示兩次。

require('./ascii-art') // 顯示標題
require('./ascii-art') // 不顯示標題複製代碼

因爲模塊緩存,第二次的引用將不會顯示標題。Node 會在第一次調用時進行緩存,在第二次調用時再也不加載文件。

咱們能夠經過在第一次引用後打印 require.cache 來查看緩存。管理緩存的是一個對象,它的屬性值分別對應引用過的模塊。這些屬性值即用於各模塊的 module 對象。咱們能夠經過簡單地從 require.cache 對象中刪除一個屬性來令該緩存失效,而後 Node 就會再次加載並緩存該模塊。

然而,這並非應對這種狀況最高效的解決方案。簡單的解決辦法是將 ascii-art.js 中的打印代碼用一個函數封裝起來並導出該函數。經過這種方式,每當咱們引用 ascii-art.js 文件時,咱們就能獲取到一個可執行函數,以供咱們屢次調用打印代碼:

require('./ascii-art')() // 顯示標題
require('./ascii-art')() // 顯示標題複製代碼

以上就是我關於本次主題所要講述的所有內容。回見!

相關文章
相關標籤/搜索