你需要了解的 Node.js 模塊

yufuxin 7年前發布 | 20K 次閱讀 Node.js Node.js 開發 C/C++

Node 使用兩個核心模塊來管理模塊依賴:

  • require 模塊,在全局范圍可用——無需 require(‘require’)。
  • module 模塊,在全局范圍可用——無需 require(‘module’)。

你可以將 require 模塊視為命令,將 module 模塊視為所有必需模塊的組織者。

在 Node 中獲取一個模塊并不復雜。

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

由 require 模塊導出的主要對象是一個函數(如上例所用)。 當 Node 使用本地文件路徑作為函數的唯一參數調用該 require() 函數時,Node 將執行以下步驟:

  • 解析 :找到文件的絕對路徑。
  • 加載 :確定文件內容的類型.
  • 封裝 :給文件其私有作用域。 這使得 require 和 module 對象兩者都可以下載我們需要的每個文件。
  • 評估 :這是 VM 對加載的代碼最后需要做的。
  • 緩存 :當我們再次需要這個文件時,不再重復所有的步驟。

在本文中,我將嘗試用示例解釋這些不同的階段,以及它們是如何影響我們在 Node 中編寫模塊的方式的。

先在終端創建一個目錄來保存所有示例:

mkdir ~/learn-node && cd ~/learn-node

本文之后所有命令都在 ~/learn-node 下運行。

解析本地路徑

我現在向你介紹 module 對象。你可以在一個的 REPL(譯者注:Read-Eval-Print-Loop,就是一般控制臺干的事情)會話中很容易地看到它:

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

每個模塊對象都有一個 id 屬性作為標識。這個 id 通常是文件的完整路徑,不過在 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_modules 目錄。它也會包含一些不推薦使用的遺留目錄。

如果 Node 在這些目錄下仍然找不到 find-me.js,它會拋出 “cannot find module error.(不能找到模塊)” 這個錯誤消息。

~/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 文件,例如在 home 目錄下存在 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 目錄也包含 node_modules/find-me.js —— 在這個目錄下 require(‘find-me’),那么 home 目錄下的 find-me.js 根本不會被加載:

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

如果刪除了~/learn-node 目錄下的的 node_modules 目錄,再次嘗試請求 find-me.js,就會使用 home 目錄下 node_modules 目錄中的 find-me.js 了:

~/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.
{}
>

注意如果存在局部模塊,home 下 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 是放在 lib 目錄而不是 node_modules 目錄下,可以這樣請求:

require('./lib/find-me');

文件中的父子關系

創建 lib/util.js 文件并添加一行 console.log 代碼來識別它。console.log 會輸出模塊自身的 module 對象:

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

在 index.js 文件中干同樣的事情,稍后我們會通過 node 命令執行這個文件。讓 index.js 文件請求 lib/util.js:

~/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 模塊對象,它就會陷入一個無限循環。因此這里用 [Circular] 代替了 lib/util 引用。

現在更重要的問題是,如果 lib/util 模塊又請求了 index 模塊,會發生什么事情?這就是我們需要了解的循環依賴,Node 允許這種情況存在。

在理解它之前,我們先來搞明白 module 對象中的另外一些概念。

exports、module.exports 以及同步加載模塊

exports 是每個模塊都有的一個特殊對象。如果你觀察仔細,會發現上面示例中每次打印的模塊對象中都存在一個 exports 屬性,到目前為止它只是個空對象。我們可以給這個特殊的 exports 對象任意添加屬性。例如,我們為 index.js 和 lib/util.js 導出 id 屬性:

// Add the following line at the top of lib/util.js
exports.id = 'lib/util';
// Add the following line at the top of index.js
exports.id = 'index';

現在執行 index.js,我們會看到這些屬性受到 module 對象管理:

~/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 對象變成一個函數,我們會這樣做:

// Add the following line in index.js before the 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。

每個模塊中的 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 屬性。到目前為止,每次我們打印一個模塊對象的時候,都會看到這個對象的 loaded 屬性值為 false。

module 模塊使用 loaded 屬性來跟蹤哪些模塊是加載過的(true值),以及哪些模塊還在加載中(false 值)。比如我們可以通過調用 setImmediate 來打印 modules 對象,在下一事件循環中看看完成加載的 index.js 模塊:

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

注意理解它是如何推遲 console.log,使其在 lib/util.js 和 index.js 加載完成之后再產生輸出的。

Node 完成加載模塊(并標記)之后 exports 對象就完成了。整個請求/加載某個模塊的過程是 同步 的。因此我們可以在一個事件循環周期過后看到模塊已經完成加載。

這也就是說,我們不能異步改變 exports 對象。比如在某個模塊中干這樣的事情:

fs.readFile('/etc/passwd', (err, data) => {
  if (err) throw err;
  exports.data = data; // Will not work.
});

循環依賴模塊

現在來回答關于 Node 循環依賴模塊這個重要的問題:如果模塊1需要模塊2,模塊2也需要模塊1,會發生什么事情?

為了觀察結果,我們在 lib/ 下創建兩個文件,module1.js 和 module2.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,而 module2 在未完全加載時又請求了 module1,那么,在那一時刻,能得到的是在循環依賴之前導出的屬性。只有 a 屬性打印出來了,因為 b 和 c 是在請求了module2 并打印了 module1 之后才導出的。

Node 讓這件事變得簡單。在加載某個模塊的時候,它會創建 exports 對象。你可以在一個模塊加載完成之前請求它,但只會得到部分導出的對象,它只包含到目前為止已經定義的項。

JSON 和 C/C++ addon

我們可以利用 require 函數在本地引入 JSON 文件和 C++ addon 文件。這么做不需要指定文件擴展名。

如果沒有指定文件擴展名,Node 首先要處理 .js 文件。如果找不到 .js 文件,就會嘗試尋找 .json 文件,如果發現為 JSON 文本文件,便將其解析為 .json 文件。 之后,它將嘗試找到一個二進制 .node 文件。為了消除歧義,當需要使用 .js 文件以外的其他格式后綴時,你需要制定一個文件擴展名。

引入 JSON 文件在某些情況下是很有用的,例如,當你在該文件中需要管理的所有內容都是些靜態配置值時,或者你需要定期從某個外部源讀入值時。假設我們有以下 config.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 不能找到 .js 或 .json 文件,它會尋找 .node 文件,它會被認為是編譯好的插件模塊。

Node 文檔中有一個 插件文件示例 ,它是用 C++ 寫的。它只是一個導出了 hello() 函數的簡單模塊,這個 hello 函數輸出 “world”。

你可以使用 node-gyp 包來編譯和構建 .cc 文件,生成 .addon 文件。只需要配置一個 binding.gyp 文件來告訴 node-gyp 做什么。

得到 addon.node (或其它在 binding.gyp 中指定的名稱)文件后,你可以像請求其它模塊一樣請求它:

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

我們可以在 require.extensions 中看到實際支持的三個擴展名:

看看每個擴展名對應的函數,你就清楚 Node 在怎么使用它們。它使用 module._compile 處理 .js 文件,使用 JSON.parse 處理 .json 文件,以及使用 process.dlopen 處理 .node 文件。

在 Node 編寫的所有代碼將封裝到函數中

有人經常誤解 Node 的封裝模塊的用途。讓我們通過 exports/module.exports 之間的關系來了解它。

我們可以使用 exports 對象導出屬性,但是我們不能直接替換 exports 對象,因為它僅是對 module.exports 的引用

exports.id = 42; // This is ok.
exports = { id: 42 }; // This will not work.
module.exports = { id: 42 }; // This is ok.

對于每個模塊而言這個 exports 對象看似是全局的,這和將其定義為 module 對象的引用,那到底什么是 exports 對象呢?

在解釋 Node 的封裝過程之前,讓我再問一個問題。

在瀏覽器中,當我們在腳本中如下所示地聲明一個變量:

var answer = 42;

在定義 answer 變量的腳本之后,該變量將在所有腳本中全局可見。

這在 Node 中根本不是問題。我們在某個模塊中定義的變量,其它模塊是訪問不到的。那么為什么 Node 中變量的作用域這么神奇?

答案很簡單。在編譯模塊之前,Node 會把模塊代碼封裝在一個函數中,我們可以通過 module 模塊的 wrapper 屬性看出來。

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

Node 不會直接執行你寫在文件中的代碼。它執行這個包裝函數,你寫的代碼只是它的函數體。因此所有定義在模塊中的頂層變量都受限于模塊的作用域。

這個包裝函數有5個參數:exports, require, module, __filename 和 __dirname。它們看起來像是全局的,但實際它們在每個模塊內部。

所有這些參數都會在 Node 執行包裝函數的時候獲得值。exports 是 module.exports 的引用。require 和 module 都有特定的功能。__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 文件的實例關聯,并非全局變量。最后 2 個參數是文件的路徑及其所在目錄的路徑。

包裝函數的返回值是 module.exports。在包裝函數的內部我們可以通過改變 module.exports 屬性來改變 exports 對象,但不能直接對 exports 賦值,因為它只是一個引用。

這個事情大致像這樣:

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

如果我們直接改變 exports 對象,它就不再是 module.exports 的引用。JavaScript 在任何地方都是這樣引用對象,并非只是在這個環境中。

require 對象

require 沒什么特別,它主要是作為一個函數來使用,接受模塊名稱或路徑作為參數,返回 module.exports 對象。如果我們想改變 require 對象的邏輯,也很容易。

比如,為了進行測試,我們想讓每個 require 調用都被模擬為返回一個假對象來代替模塊導出的對象。這個簡單的調整就像這樣:

require = function() {
  return { mocked: true };
}

在上面重新對 require 賦值之后,調用 require(‘something’) 就會返回模擬的對象。

require 對象也有自己的屬性。我們已經看到了 resolve 屬性,它也是一個函數,是 require 處理過程中解析路徑的步驟。上面我們還看到了 require.extensions。

還有一個 require.main 可用于檢查代碼是通過請求來運行的還是直接運行的。

再來看個例子,定義在 print-in-frame.js 中的 printInFrame 函數:

// In print-in-frame.js
const printInFrame = (size, header) => {
  console.log('*'.repeat(size));
  console.log(header);
  console.log('*'.repeat(size));
};

這個函數需要一個數值型的參數 size 和一個字符串型的參數 header,它會在打印一個由指定數量的星號生成的框架,并在其中打印 header。

我們希望通過兩種方式來使用這個文件:

  1. 從命令行直接運行:
~/learn-node $ node print-in-frame 8 Hello

在命令行傳入 8 和 Hello 作為參數,它會打印出由 8 個星號組成的框架中的 “Hello”。

2. 通過 require 來使用。假設所需要的模塊會導出 printInFrame 函數,然后就可以這樣做:

const print = require('./print-in-frame');
print(5, 'Hey');

它在由 5 個星號組成的框架中打印 “Hey”。

這是兩種不同的使用方式。我們得想辦法檢測文件是獨立運行的還是由其它腳本請求的。

這里用一個簡單的 if 語句來解決:

if (require.main === module) {
  // The file is being executed directly (not with require)
}

我們可以使用這個條件,以不同的方式調用 printInFrame 來滿足需求:

// In 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')() // 也會顯示標頭。

以上,就是我這次要說的內容!

 

來自:http://web.jobbole.com/90926/

 

 本文由用戶 yufuxin 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!