Node.js 中的循環依賴

咱們在寫node的時候有可能會遇到循環依賴的狀況,什麼是循環依賴,怎麼避免或解決循環依賴問題?javascript

先看一段官網給出的循環依賴的代碼:java

a.js:node

console.log('a starting'); 
exports.done = false;
var b = require('./b.js'); // ---> 1
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done') // ---> 4

b.js:c++

console.log('b starting'); 
exports.done = false;
var a = require('./a.js');  // ---> 2
// console.log(a);  ---> {done:false}
console.log('in b, a.done = %j', a.done); // ---> 3
exports.done = true;
console.log('b done');

main.js:git

console.log('main starting'); 
var a = require('./a.js'); // --> 0
var b = require('./b.js');
console.log('in main, a.done=%j, b.done=%j', a.done, b.done);

若是咱們啓動 main.js 會出現什麼狀況? 在 a.js 中加載 b.js,而後在b.js中加載 a.js,而後再在 a.js中加載 b.js 嗎?這樣就會形成循環依賴死循環。github

讓咱們執行看看:shell

$ node main.js

main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done=true, b.done=true

能夠看到程序並無陷入死循環,從上面的執行結果能夠看到 main.js 中先requirea.jsa.js 中執行完了consoleexport.done=fasle以後,轉而去加載b.js,待b.js被load完以後,再返回a.js中執行完剩下的代碼。數組

我在官網的代碼基礎上增長了一些註釋,基本 load 順序就是按照這個0-->1-->2-->3-->4的順序去執行的,而後在第二步下面我打印出了require('./a')的結果,能夠看到是{done:false},能夠猜想在b.jsrequire('./a')的結果是a.js中已經執行到的exports出的值。緩存

上面所說的還只是基於結果基礎上的猜想,沒有什麼說服力,爲了驗證個人猜想是正確的,我把 Node 的源碼稍微翻看了一些,C++ 的代碼看不懂不要緊,能看懂 JS 的部分就能夠了,下面就是 Node 源碼的分析(主要是 module 的分析, Node 源碼在此):閉包

將會分析的主要源碼:

  1. node/src/node.js

  2. node/lib/module.js

啓動 $ node main.js

C++ 的代碼我看不懂,總而言之,在我查了資料以後知道當咱們在shell中輸入node main.js以後,會先執行 node/src/node.cc,而後會執行 node/src/node.js, 因此C++代碼不分析,從分析 node/src/node.js 開始(只會分析和主題相關的代碼)。

node.js 源碼分析

node.js文件主要結構爲

(function(process) {

    this.global = this
    
    function startup() {
      ...
    }
    
    startup()

})

這種閉包代碼很常見,從名字能夠看出,此處爲啓動文件。接下來看看 startup 函數中有一大塊條件語句,我刪除大多數無關代碼,以下:

if (process.argv[1]) {
     // ...
    
    var Module = NativeModule.require('module');
    Module.runMain();
}

我把無關的代碼基本都刪除了。能夠看到這段代碼主要作的事是先經過 Native 引入module模塊,執行 Module.runMain()

不少人都知道 require 核心代碼,如 require('path'),不須要寫全路徑,Node 是怎樣作到的呢?

Node 採用了 V8 附帶的 js2c.py 工具,將全部內置的 JavasSript 代碼( src/node.js 和 lib/*.js) 轉成 c++ 裏面的數組生成 node_navtives.h 頭文件。
在這個過程當中, JavasSript 以字符串的形式存儲在 node 命名空間中, 是不可直接執行的。
在啓動 Node 進程時, JavaScript 代碼直接加載進內存中。

Node 在啓動時,會生成一個全局變量 process, 並提供 binding() 方法來協助加載內建模塊。

上面大段介紹基本引自樸老師的「深刻淺出 Node.js」。大概理解就是在啓動命令的時候,Node 會把 node.jslib/*.js 的內容都放到 process 中傳入當前閉包中,咱們在當前函數就能夠經過process.binding('natives')取出來放到 _source 中,以下代碼所示:

function NativeModule(id) {
    this.filename = id + '.js';
    this.id = id;
    this.exports = {};
    this.loaded = false;
  }

  NativeModule._source = process.binding('natives');
  NativeModule._cache = {};

接下來看看NativeModule.require作了哪些事情:

NativeModule.require = function(id) {
    if (id == 'native_module') {
      return NativeModule;
    }

    var cached = NativeModule.getCached(id);
    if (cached) {
      return cached.exports;
    }

    var nativeModule = new NativeModule(id);

    nativeModule.cache();
    nativeModule.compile();

    return nativeModule.exports;
  };

這上面的代碼代表內建模塊被緩存,就直接返回內建模塊的exports,若是沒有的話,就生成一個核心模塊的實例,而後先把模塊根據id來cache,而後調用nativeModule.compile接口編譯源文件:

NativeModule.getSource = function(id) {
    return NativeModule._source[id];
  };

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

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

  NativeModule.prototype.compile = function() {
    var source = NativeModule.getSource(this.id);
    source = NativeModule.wrap(source);

    var fn = runInThisContext(source, {
      filename: this.filename,
      lineOffset: -1
    });
    fn(this.exports, NativeModule.require, this, this.filename);

    this.loaded = true;
  };

  NativeModule.prototype.cache = function() {
    NativeModule._cache[this.id] = this;
  };

cache 是把實例根據 id 放到 _cache 對象中。先從 _source 中取出對應id的源文件字符串,包上一層
(function (exports, require, module, __filename, __dirname) {\n','\n});。好比main.js最終變成以下JS代碼的字符串:

(function (exports, require, module, __filename, __dirname) {
 // 若是是main.js
     console.log('main starting'); 
    var a = require('./a.js'); // --> 0
    var b = require('./b.js');
    console.log('in main, a.done=%j, b.done=%j', a.done, b.done);
})

runInThisContext是將被包裝後的源字符串轉成可執行函數,(runInThisContext來自contextify 模塊),runInThisContext的做用,相似eval,再執行這個被eval後的函數,就算被 load 完成了,最後把 load 設爲 true。

能夠看到fn的實參爲 this.exports; NativeModule.require; this; this.filename;

因此require('module')的做用是加載/lib/module.js文件。讓咱們再回到 startup 函數,加載完 module.js,緊接着運行 Module.runMain()方法。(估計有人忘了前面的startup函數是幹嗎的,我再放一次,免得再拉回去了)

if (process.argv[1]) {
     // ...
    
    var Module = NativeModule.require('module');
    Module.runMain();
}

module.js源碼分析

上面走完了NatvieModule的加載代碼。再看看module.js是怎樣加載用戶使用的文件的。

function Module(id, parent) {
  this.id = id;
  this.exports = {};
  this.parent = parent;
  if (parent && parent.children) {
    parent.children.push(this);
  }

  this.filename = null;
  this.loaded = false;
  this.children = [];
}
module.exports = Module;

Module._cache = {};
Module._pathCache = {};
Module._extensions = {};
var modulePaths = [];
Module.globalPaths = [];

Module.wrapper = NativeModule.wrapper;
Module.wrap = NativeModule.wrap;

這是Module的構造函數,Module.wrapperModule.wrap,是由NativeModule賦值來的,Module._cache是個空對象,存放全部被 load 後的模塊 id。

node.js文件的 startup 函數中,最後一步走到Module.runMain():

Module.runMain = function() {
  // Load the main module--the command line argument.
  Module._load(process.argv[1], null, true);
  // Handle any nextTicks added in the first tick of the program
  process._tickCallback();
};

runMain方法中調用了_load方法:

Module._load = function(request, parent, isMain) {
  var filename = Module._resolveFilename(request, parent);
  var cachedModule = Module._cache[filename];
  
  if (cachedModule) {
    return cachedModule.exports;
  }

  var module = new Module(filename, parent);
  Module._cache[filename] = module;

  module.load(filename);
  
  return module.exports;
};

上述代碼照例我刪除了一些不是很相關的代碼,從剩下的代碼能夠看出_load函數的主要乾了兩件事(還有一件加載NativeModule的代碼被我刪掉了):

  1. 先判斷當前的源文件有沒有被加載過,若是 _cache 對象中存在,直接返回 _cache 中的exports對象

  2. 若是沒有被加載過,新建這個源文件的 module 的實例,並存放到 _cache 中,而後調用 load 方法。

Module.prototype.load = function(filename) {
  this.filename = filename;
  this.paths = Module._nodeModulePaths(path.dirname(filename));

  var extension = path.extname(filename) || '.js';
  if (!Module._extensions[extension]) extension = '.js';
  Module._extensions[extension](this, filename);
  this.loaded = true;
};

load方法中判斷源文件的擴展名是什麼,默認是'.js',(我這裏也只分析後綴是 .js 的狀況),而後調用 Module._extensions[extension]() 方法,並傳入 this 和 filename;當extension'.js'的時候, 調用Module._extensions['.js']() 方法。

// Native extension for .js
Module._extensions['.js'] = function(module, filename) {
  var content = fs.readFileSync(filename, 'utf8');
  module._compile(internalModule.stripBOM(content), filename);
};

這個方法是讀到源文件的字符串後,調用module._compile方法。

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

  var self = this;

  function require(path) {
    return self.require(path);
  }

  var dirname = path.dirname(filename);
  // create wrapper function
  var wrapper = Module.wrap(content);

  var compiledWrapper = runInThisContext(wrapper,
                                      { filename: filename, lineOffset: -1 });

  var args = [self.exports, require, self, filename, dirname];
  return compiledWrapper.apply(self.exports, args);
};

其實跟NativeModule_complie作的事情差很少。先把源文件content包裝一層(function (exports, require, module, __filename, __dirname) {\n','\n});, 而後經過 runInThisContext 把字符串轉成可執行的函數,最後把
self.exports, require, self, filename, dirname 這幾個實參傳入可執行函數中。

require 方法爲:

Module.prototype.require = function(path) {
  return Module._load(path, this);
};

循環依賴的時候爲何不會無限循環引用

所謂的循環依賴就是在兩個不一樣的文件中互相應用了對方。假設按照最上面官網給出的例子中,

main.js 中:

  1. require('./a.js');此時會調用 self.require(),
    而後會走到module._load,在_load中會判斷./a.js是否被load過,固然運行到這裏,./a.js還沒被 load 過,因此會走完整個load流程,直到_compile

  2. 運行./a.js,運行到 exports.done = false 的時候,給 esports 增長了一個屬性。此時的 exports={done: false}

  3. 運行require('./b.js'),同 第 1 步。

  4. 運行./b.js,到require('./a.js')。此時走到_load函數的時候發現./a.js已經被load過了,因此會直接從_cache中返回。因此此時./a.js尚未運行完,exports = {done.false},那麼返回的結果就是 in b, a.done = false;

  5. ./b.js所有運行完畢,回到./a.js中,繼續向下運行,此時的./b.jsexports={done:true}, 結果天然是in main, a.done=true, b.done=true

Flag

雖然Node.js經過 cache 解決無限循環引用的問題,可是沒有解決循環引用時已加載了模塊,而exports沒有輸出想要的值得問題,據說ES6import已經完美解決這類問題,因此立個死亡 Flag,等我研究完 import ,再寫篇文章分析 import 是怎麼解決這個問題的。 爲何是死亡 Flag 呢?每一個等我 XXX 的時候,我就 OOO 的事情,最後必定不會作。^_^。

原文地址: GitHub

喜歡的點個推薦吧

相關文章
相關標籤/搜索