在 Node.js 中引入模組:你所需要知道的一切都在這裡

鬍子大哈發表於2017-04-14

本文作者:Jacob Beltran

編譯:鬍子大哈

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

英文連線:Requiring modules in Node.js: Everything you need to know

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

  • require 模組。全域性範圍生效,不需要 require('require')
  • module 模組。全域性範圍生效,不需要 require('module')

你可以把 require 當做是命令列,而把 module 當做是所有引入模組的組織者。

在 Node 中引入模組並不是什麼複雜的概念,見下面例子:

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

require 引入的物件主要是函式。當 Node 呼叫 require() 函式,並且傳遞一個檔案路徑給它的時候,Node 會經歷如下幾個步驟:

  • Resolving:找到檔案的絕對路徑;
  • Loading:判斷檔案內容型別;
  • Wrapping:打包,給這個檔案賦予一個私有作用範圍。這是使 requiremodule 模組在本地引用的一種方法;
  • Evaluating:VM 對載入的程式碼進行處理的地方;
  • Caching:當再次需要用這個檔案的時候,不需要重複一遍上面步驟。

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

為了方便大家看文章和理解命令,我首先建立一個目錄,後面的操作都會在這個目錄中進行。

    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 中引入模組:你所需要知道的一切都在這裡

可以清晰地看到 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:

在 Node.js 中引入模組:你所需要知道的一切都在這裡

我們想要在每次 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 感興趣的童鞋,歡迎指點

相關文章