在 Node.js 中引入模塊:你所須要知道的一切都在這裏

本文做者:Jacob Beltran

編譯:鬍子大哈 javascript

翻譯原文:huziketang.com/blog/posts/…

英文鏈接:Requiring modules in Node.js: Everything you need to knowhtml

Node 中有兩個核心模塊來對模塊依賴進行管理:前端

  • require 模塊。全局範圍生效,不須要 require('require')
  • module 模塊。全局範圍生效,不須要 require('module')

你能夠把 require 當作是命令行,而把 module 當作是全部引入模塊的組織者。java

在 Node 中引入模塊並非什麼複雜的概念,見下面例子:node

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

require 引入的對象主要是函數。當 Node 調用 require() 函數,而且傳遞一個文件路徑給它的時候,Node 會經歷以下幾個步驟:react

  • Resolving:找到文件的絕對路徑;
  • Loading:判斷文件內容類型;
  • Wrapping:打包,給這個文件賦予一個私有做用範圍。這是使 requiremodule 模塊在本地引用的一種方法;
  • Evaluating:VM 對加載的代碼進行處理的地方;
  • Caching:當再次須要用這個文件的時候,不須要重複一遍上面步驟。

本文中,我會用不一樣的例子來解釋上面的各個步驟,而且介紹在 Node 中它們對咱們寫的模塊有什麼樣的影響。json

爲了方便你們看文章和理解命令,我首先建立一個目錄,後面的操做都會在這個目錄中進行。api

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

文章中接下來的部分都會在 ~/learn-node 文件夾下運行。瀏覽器

1. Resolving - 解析本地路徑

首先來爲你介紹 module 對象,能夠先在控制檯中看一下:緩存

~/learn-node $ node
    > module
    Module {
      id: '<repl>',
      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 目錄中執行 require('find-me') 的時候,因爲 learn-node 有本身的 node_modules/find-me.js,這時不會加載 home 目錄下的 find-me.js

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

假設咱們把 learn-node 目錄下的 node_modules 移到 ~/learn-node,再從新執行 require('find-me') 的話,按照上面規定的順序查找文件,這時候 home 目錄下的 node_modules 就會被使用了。

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

require 一個文件夾

模塊不必定非要是文件,也能夠是個文件夾。咱們能夠在 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 文件夾,就不會使用 home 目錄下的 node_modules 了。

當引入一個文件夾的時候,默認會去找 index.js 文件,這也能夠手動控制指定到其餘文件,利用 package.jsonmain 屬性就能夠。例如,咱們執行 require('find-me'),而且要從 find-me 文件夾下的 start.js 文件開始解析,那麼用 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');複製代碼

文件的 parent-child 關係

建立一個文件 lib/util.js 而且寫一行 console.log 在裏面來標識它,固然,這個 console.log 就是模塊自己。

~/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 的 parent 屬性中。而 lib/util 並無被列到 index 的 children 屬性,而是用一個 [Circular] 代替的。這是由於這是個循環引用,若是這裏使用 lib/util 的話,那就變成一個無限循環了。這就是爲何在 index 中使用 [Circular] 來替代 lib/util

那麼重點來了,若是在 lib/util 中引入了 index 模塊會怎麼樣?這就是咱們所謂的模塊循環依賴問題,在 Node 中是容許這樣作的。

可是 Node 如何處理這種狀況呢?爲了更好地理解這一問題,咱們先來了解一下模塊對象的其餘知識。

2. Loading - exports,module.exports,和模塊的同步加載

在全部的模塊中,exports 都是一個特殊的對象。若是你有注意的話,上面咱們每次打印模塊信息的時候,都有一個是空值的 exports 屬性。咱們能夠給這個 exports 對象加任何想加的屬性,例如在 index.jslib/util.js 中給它添加一個 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 對象變成函數,作法以下:

// 在 index.js 的 console.log 前面添加這行
    module.exports = function() {};複製代碼

當你執行 index.js 的時候,你會看到以下信息:

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

這裏注意咱們沒有使用 export = function() {} 來改變 exports 對象。沒有這樣作是由於在模塊中的 exports 變量其實是 module.exports 的一個引用,而 module.exports 纔是控制全部對外屬性的。exportsmodule.exports 指向同一塊內存,若是把 exports 指向一個函數,那麼至關於改變了 exports 的指向,exports 就再也不是引用了。即使你改變了 exportsmodule.exports 也是不變的。

模塊的 module.exports 是一個模塊的對外接口,就是當你使用 require 函數時所返回的東西。例如把 index.js 中的代碼改一下:

const UTIL = require('./lib/util');
    console.log('UTIL:', UTIL);複製代碼

上面的代碼將會捕獲 lib/util 中輸出的屬性,賦值給 UTIL 常量。當執行 index.js 的時候,最後一行將會輸出:

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

接下來聊一下 loaded 屬性。上面咱們每次輸出模塊信息,都能看到一個 loaded 屬性,值是 false

module 模塊使用 loaded 屬性來追蹤哪些模塊已經加載完畢,哪些模塊正在加載。例如咱們能夠調用 setImmediate 來打印 module 對象,用它能夠看到 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' ] }複製代碼

能夠注意到 lib/util.jsindex.js 都已經加載完畢了。

當一個模塊加載完成的時候,exports 對象才完整,整個加載的過程都是同步的。這也是爲何在一個事件循環後全部的模塊都處於徹底加載狀態的緣由。

這也意味着不能異步改變 exports 對象,例如,對任何模塊作下面這樣的事情:

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

模塊循環依賴

咱們如今來回答上面說到的循環依賴的問題:模塊 1 依賴模塊 2,模塊 2 也依賴模塊 1,會發生什麼?

如今來建立兩個文件,lib/module1.jslib/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。這種狀態下,咱們從 exports 對象中能獲得的就是在發生循環依賴以前的這部分。上面代碼中,只有 a 屬性被引入,由於 bc 都須要在引入 module2 以後才能加載進來。

Node 使這個問題簡單化,在一個模塊加載期間開始建立 exports 對象。若是它須要引入其餘模塊,而且有循環依賴,那麼只能部分引入,也就是隻能引入發生循環依賴以前所定義的這部分。

JSON 和 C/C++ 擴展文件

咱們可使用 require 函數本地引入 JSON 文件和 C++ 擴展文件,理論上來說,不須要指定其擴展名。

若是沒有指定擴展名,Node 會先嚐試將其按 .js 文件來解析,若是不是 .js 文件,再嘗試按 .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() 函數,而且函數輸出 「world」。

你可使用 node-gyp 包編譯 .cc 文件,生成 .addon 文件。只須要配置 binding.gyp 文件來告訴 node-gyp 須要作什麼就能夠了。

當你有了 addon.node 文件(名字你能夠在 binding.gyp 中隨意配置)之後,你就能夠在本地像引入其餘模塊同樣引入它了:

const addon = require('./addon');
    console.log(addon.hello());複製代碼

能夠經過 require.extensions 來查看對三種文件的支持狀況:

能夠清晰地看到 Node 對每種擴展名所使用的函數及其操做:對 .js 文件使用 module._compile;對 .json 文件使用 JSON.parse;對 .node 文件使用 process.dlopen

3. Wrapping - 你在 Node 中所寫的全部代碼都會被打包成函數

Node 的打包模塊不是很好理解,首先要先知道 exports / module.exports 的關係。

咱們能夠用 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.複製代碼

在介紹 Node 的打包過程以前先來了解另外一個問題,一般狀況下,在瀏覽器中咱們在腳本中定義一個變量:

var answer = 42;複製代碼

這種方式定義之後,answer 變量就是一個全局變量了。其餘腳本中依然能夠訪問。而 Node 中不是這樣,你在一個模塊中定義一個變量,程序的其餘模塊是不能訪問的。Node 是如何作到的呢?

答案很簡單,在編譯成模塊以前,Node 把模塊代碼都打包成函數,能夠用 modulewrapper 屬性來查看。

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

Node 並不直接執行你所寫的代碼,而是把你的代碼打包成函數後,執行這個函數。這就是爲何一個模塊的頂層變量的做用域依然僅限於本模塊的緣由。

這個打包函數有 5 個參數:exportsrequiremodule__filename__dirname。函數使變量看起來全局生效,但實際上只在模塊內生效。全部的這些參數都在 Node 執行函數時賦值。exports 定義成 module.exports 的引用;requiremodule 都指定爲將要執行的這個函數;__filename__dirname 指這個打包模塊的絕對路徑和目錄路徑。

在腳本的第一行輸入有問題的代碼,就能看到 Node 打包的行爲;

~/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 對象,初始爲空;requiremodule 對象都是即將執行的 index.js 的實例;最後兩個參數是文件路徑和目錄路徑。

打包函數的返回值是 module.exports。在模塊內部,可使用 exports 對象來改變 module.exports 屬性,可是不能對 exports 從新賦值,由於它只是 module.exports 的引用。

至關於以下代碼:

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

若是對 exports 從新賦值(改變整個 exports 對象),那它就不是 module.exports 的引用了。這是 JavaScript 引用的工做原理,不只僅是在這裏是這樣。

4. Evaluating - require 對象

require 沒有什麼特別的,一般做爲一個函數返回 module.exports 對象,函數參數是一個模塊名或者一個路徑。若是你想的話,盡能夠根據本身的邏輯重寫 require 對象。

例如,爲了達到測試的目的,咱們但願全部的 require 都默認返回一個 mock 值來替代真實的模塊返回值。能夠簡單地實現以下:

require = function() {
      return { mocked: true };
    }複製代碼

這樣重寫了 require 之後,每一個 require('something') 調用都會返回一個模擬對象。

require 對象也有本身的屬性。上面已經見過了 resolve 屬性,它的任務是處理引入模塊過程當中的解析步驟,上面還提到過 require.extensions 也是 require 的屬性。還有 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。函數功能很簡單,這裏不贅述。

咱們想用兩種方式使用這個文件:

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) {
      // 這個文件直接執行(不須要 require)
    }複製代碼

繼續演化,可使用不一樣的調用方式來實現最初的需求:

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

當文件不須要被 require 時,直接經過 process.argv 調用 printInFrame 函數便可。不然直接把 module.exports 變成 printInFrame 就能夠了,即模塊接口是 printInFrame

5. Caching - 全部的模塊都會被緩存

對緩存的理解特別重要,我用簡單的例子來解釋緩存。

假設你有一個 ascii-art.js 文件,打印很酷的 header:

咱們想要在每次 require 這個文件的時候,都打印出 header。因此把這個文件引入兩次:

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

第二個 require 不會顯示 header,由於模塊被緩存了。Node 把第一個調用緩存起來,第二次調用的時候就不加載文件了。

能夠在第一次引入文件之後,使用 require.cache 來看一下都緩存了什麼。緩存中其實是一個對象,這個對象中包含了引入模塊的屬性。咱們能夠從 require.cache 中把相應的屬性刪掉,以使緩存失效,這樣 Node 就會從新加載模塊而且將其從新緩存起來。

對於這個問題,這並非最有效的解決方案。最簡單的解決方案是把 ascii-art.js 中的打印代碼打包成一個函數,而且 export 這個函數。這樣當咱們引入 ascii-art.js 文件時,咱們獲取到的是這個函數,因此能夠每次都能打印出想要的內容了:

require('./ascii-art')() // 打印出 header.
require('./ascii-art')() // 也會打印出 header.複製代碼

總結

這就是我所要介紹的內容。回顧一下通篇,分別講述了:

  • Resolving
  • Loading
  • Wrapping
  • Evaluating
  • Caching

即解析、加載、打包、VM功能處理和緩存五大步驟,以及五大步驟中每一個步驟都涉及到了什麼內容。

若是本文對你有幫助,歡迎關注個人專欄-前端大哈,按期發佈高質量前端文章。


我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點

相關文章
相關標籤/搜索