經過源碼解析 Node.js 中一個文件被 require 後所發生的故事

在 Node.js 中,要說若是有幾乎會在每個文件都要用到的一個全局函數和一個全局對象,那應該是非 requiremodule.exports 莫屬了。它們是 Node.js 模塊機制的基石。你們在使用它們享受模塊化的好處時,有時也不由好奇:node

  • 爲什麼它倆使用起來像是全局函數/對象,卻在 global 對象下訪問不到它們?git

'use strict'
console.log(require) // Function 
console.log(module) // Object 
console.log(global.require) // undefined
console.log(global.module) // undefined
  • 這兩個「類全局」對象是在何時,怎麼生成的?github

  • require 一個目錄時,Node.js 是如何替咱們找到具體該執行的文件的?json

  • 模塊內的代碼具體是以何種方式被執行的?緩存

  • 循環依賴了怎麼辦?app

讓咱們從 Node.js 項目的 lib/module.js 中的代碼裏,細細看一番,一個文件被 require 後,具體發生的故事,從而來解答上面這些問題。模塊化

一個文件被 require 後所發生的故事

當咱們在命令行中敲下:函數

node ./index.js

以後,src/node.cc 中的 node::LoadEnvironment 函數會被調用,在該函數內則會接着調用 src/node.js 中的代碼,並執行 startup 函數:ui

// src/node.js
// ...

function startup() {
  // ...
  Module.runMain();
}

// lib/module.js
// ...

Module.runMain = function() {
  // ...
  Module._load(process.argv[1], null, true);
  // ... 
};

因此,最後會執行到 Module._load(process.argv[1], null, true); 這條語句來加載模塊,不過其實,這個Module._loadrequire函數的代碼中也會被調用:this

// lib/module.js
// ... 

Module.prototype.require = function(path) {
  assert(path, 'missing path');
  assert(typeof path === 'string', 'path must be a string');
  return Module._load(path, this, false);
};

因此說,當咱們在命令行中敲下 node ./index.js,某種意義上,能夠說隨後 Node.js 的表現即爲馬上進行一次 require , 即:

require('./index.js')

隨後的步驟就是 require 一個普通模塊了,讓咱們繼續往下看,Module._load 方法作的第一件事,即是調用內部方法 Module._resolveFilename ,而該內部方法在進行了一些參數預處理後,最終會調用 Module._findPath 方法,來獲得需被導入模塊的完整路徑,讓咱們從代碼中來總結出它的路徑分析規則:

// lib/module.js
// ...

Module._findPath = function(request, paths) {
  // 優先取緩存
  var cacheKey = JSON.stringify({request: request, paths: paths});
  if (Module._pathCache[cacheKey]) {
    return Module._pathCache[cacheKey];
  }

  // ...
  for (var i = 0, PL = paths.length; i < PL; i++) {
    if (!trailingSlash) { 
      const rc = stat(basePath);
      if (rc === 0) {  // 如果文件.
        filename = toRealPath(basePath);
      } else if (rc === 1) {  // 如果目錄
        filename = tryPackage(basePath, exts);
      }

      if (!filename) {
        // 帶上 .js .json .node 後綴進行嘗試
        filename = tryExtensions(basePath, exts);
      }
    }

    if (!filename) {
      filename = tryPackage(basePath, exts);
    }

    if (!filename) {
      // 嘗試 index.js index.json index.node
      filename = tryExtensions(path.resolve(basePath, 'index'), exts);
    }

    if (filename) {
      // ...
      Module._pathCache[cacheKey] = filename;
      return filename;
    }
  }
  return false;
};

function tryPackage(requestPath, exts) {
  var pkg = readPackage(requestPath); // 獲取 package.json 中 main 屬性的值

  // ...
  return tryFile(filename) || tryExtensions(filename, exts) ||
         tryExtensions(path.resolve(filename, 'index'), exts);
}

代碼中的條件判斷十分清晰,讓咱們來總結一下:

  • 若模塊的路徑不以 / 結尾,則先檢查該路徑是否真實存在:

    • 若存在且爲一個文件,則直接返回文件路徑做爲結果。

    • 若存在且爲一個目錄,則嘗試讀取該目錄下的 package.jsonmain 屬性所指向的文件路徑。

      • 判斷該文件路徑是否存在,若存在,則直接做爲結果返回。

      • 嘗試在該路徑後依次加上 .js.json.node 後綴,判斷是否存在,若存在則返回加上後綴後的路徑。

      • 嘗試在該路徑後依次加上 index.jsindex.jsonindex.node,判斷是否存在,若存在則返回拼接後的路徑。

    • 若仍未返回,則爲指定的模塊路徑依次加上 .js.json.node 後綴,判斷是否存在,若存在則返回加上後綴後的路徑。

  • 若模塊以 / 結尾,則嘗試讀取該目錄下的 package.jsonmain 屬性所指向的文件路徑。

    • 判斷該文件路徑是否存在,若存在,則直接做爲結果返回。

    • 嘗試在該路徑後依次加上 .js.json.node 後綴,判斷是否存在,若存在則返回加上後綴後的路徑。

    • 嘗試在該路徑後依次加上 index.jsindex.jsonindex.node,判斷是否存在,若存在則返回拼接後的路徑。

  • 若仍未返回,則爲指定的模塊路徑依次加上 index.jsindex.jsonindex.node,判斷是否存在,若存在則返回拼接後的路徑。

在取得了模塊的完整路徑後,便該是執行模塊了,咱們以執行 .js 後綴的 JavaScript 模塊爲例。首先 Node.js 會經過 fs.readFileSync 方法,以 UTF-8 的格式,將 JavaScript 代碼以字符串的形式讀出,傳遞給內部方法 module._compile,在這個內部方法裏,則會調用 NativeModule.wrap 方法,將咱們的模塊代碼包裹在一個函數中:

// src/node.js
// ...

NativeModule.wrap = function(script) {
  return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};

NativeModule.wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];

因此,這便解答了咱們以前提出的,在 global 對象下取不到它們的問題,由於它們是以包裹在外的函數的參數的形式傳遞進來的。因此順便提一句,咱們日常在文件的頂上寫的 use strict ,其實最終聲明的並非 script-level 的嚴格模式,而都是 function-level 的嚴格模式。

最後一步, Node.js 會使用 vm.runInThisContext 執行這個拼接完畢的字符串,取得一個 JavaScript 函數,最後帶着對應的對象參數執行它們,並將賦值在 module.exports 上的對象返回:

// lib/module.js
// ...

Module.prototype._compile = function(content, filename) {
  // ...

  var compiledWrapper = runInThisContext(wrapper, {
    filename: filename,
    lineOffset: 0,
    displayErrors: true
  });

  // ...
  const args = [this.exports, require, this, filename, dirname];
  
  const result = compiledWrapper.apply(this.exports, args);
  // ...
};

至此,一個同步的 require 操做便圓滿結束啦。

循環依賴

經過上文咱們已經能夠知道,在 Module._load 內部方法裏 Node.js 在加載模塊以前,首先就會把傳模塊內的 module 對象的引用給緩存起來(此時它的 exports 屬性仍是一個空對象),而後執行模塊內代碼,在這個過程當中漸漸爲 module.exports 對象附上該有的屬性。因此當 Node.js 這麼作時,出現循環依賴的時候,僅僅只會讓循環依賴點取到中間值,而不會讓 require 死循環卡住。一個經典的例子:

// a.js
'use strict'
console.log('a starting')
exports.done = false
var b = require('./b')
console.log(`in a, b.done=${b.done}`)
exports.done = true
console.log('a done')
// b.js
'use strict'
console.log('b start')
exports.done = false
let a = require('./a')
console.log(`in b, a.done=${a.done}`)
exports.done = true
console.log('b done')
// main.js
'use strict'
console.log('main start')
let a = require('./a')
let b = require('./b')
console.log(`in main, a.done=${a.done}, b.done=${b.done}`)

執行 node main.js ,打印:

main start
a starting
b start
in b, a.done=false => 循環依賴點取到了中間值
b done
in a, b.done=true
a done
in main, a.done=true, b.done=true

最後

因爲 Node.js 中的模塊導入和 ES6 規範中的不一樣,它的導入過程是同步的。因此實現起來會方便許多,代碼量一樣也很少。十分推薦你們閱讀一下完整的實現。

參考:

相關文章
相關標籤/搜索