node.js的循环依赖
发布于 8 年前 作者 changchang 15613 次浏览 最后一次编辑是 3 年前

循环依赖,简单点来说就是a文件中require b文件,然后b文件中又反过来require a文件。这个问题我们平时可能并不大注意到,但如果处理不好可能会引起一些让人摸不清的问题。在node中,是如何处理循环依赖的问题的呢? <br/><!–more–> <br/>写个简单的例子来试验一下看吧。 <br/>定义两个文件: <br/>a.js <br/><pre>var b = require(’./b’); <br/>console.log(‘a.js get b:’ + b.b); <br/>module.exports.a = 1;</pre> <br/>b.js <br/><pre>var a = require(’./a’); <br/>console.log(‘b.js get a:’ + a.a); <br/>module.exports.b = 2;</pre> <br/>执行 <br/>node a.js <br/>输出的结果是 <br/> <br/>b.js get a:undefined <br/>a.js get b:2 <br/> <br/>从打印的轨迹上来看,代码执行的流程大致如下: <br/><pre>a.js: b.js: <br/>var b = require(’./b’); <br/> var a = require(’./a’); // a = {} <br/> console.log(‘b.js get a:’ + a.a); <br/> module.exports.b = 2; <br/>// b = {b: 2} <br/>console.log(‘a.js get b:’ + b.b); <br/>module.exports.a = 1;</pre> <br/>node的加载过程,可以在lib/module.js文件中找到。与这个过程相关的代码主要集中在Module._load方法里。可以看到,node会为每个新加载的文件创建一个Module对象(假设为a),这个就是我们在a.js代码中看到的module了。在创建a之后,node会先将a放到cache中,然后再对它进行加载操作。也就是说,如果在加载a的过程中,有其他的代码(假设为b)require a.js的话,那么b可以从cache中直接取到a的module,从而不会引起重复加载的死循环。但带来的代价就是在load过程中,b看到的是不完整的a,也就是为什么前面打印undefined的原因。 <br/> <br/>Module的构造函数 <br/><pre>function Module(id, parent) { <br/> this.id = id; <br/> this.exports = {}; <br/> this.parent = parent; <br/> <br/> this.filename = null; <br/> this.loaded = false; <br/> this.exited = false; <br/> this.children = []; <br/>}</pre> <br/>Module._load方法 <br/><pre>Module._load = function(request, parent, isMain) { <br/> //… <br/> <br/> var module = new Module(id, parent); <br/> <br/> //… <br/> <br/> Module._cache[filename] = module; <br/> try { <br/> module.load(filename); <br/> } catch (err) { <br/> delete Module._cache[filename]; <br/> throw err; <br/> } <br/> <br/> return module.exports; <br/>};</pre> <br/>这个看似简单粗暴的处理手法,但实际上是node作者权衡各方面因素的结果。我们敬爱的npm作者issacs强调说了,这不是bug,而且近期内不会做什么改变。当然,issacs也给出了一些规避这个陷阱的建议(具体可以参考后面给的链接[1])。我总结了一下,主要有两点:一个是在造成循环依赖的require之前把需要的东西exports出去;另一个是不要在load过程中操作未完成的模块。 <br/> <br/>所以上面的例子的一种处理方法就是把各自的exports语句放到require语句前面,然后再运行,可以看到打印了正确的值。 <br/> <br/>从前面的分析来看,循环依赖的陷阱出现的条件比较苛刻:一个是循环依赖,另一个是在load期间调用未加载完成的对象。所以大家平常不怎么会遇到。但我之前就曾华丽丽的邂逅了这个陷阱,在这里拿出来当一下反面教材。。。 <br/>场景简化后大致如下:我有一堆service,每一个service负责消费某一类消息,并且可能会产生新的消息给其他service消费。从消息传递上来看,并没有产生循环依赖。但我为了解耦,定义了一个消息中心center的角色出来进行消息分发。center主要是维护一个type -> service的map来路由消息,这样center就得把所有的service加载进来,于是产生了center->service的依赖。另一面,每个service又需要通过center来分发它们新产生的消息,于是又出现了service->center的依赖,循环依赖就这么出来了。刚好在service加载的过程中,又调用了center的一个方法,就发生了undefined的错误。 <br/> <br/>这个问题查清楚原因以后,解决起来并不困难。 <br/>一种方法就是按前面的方法,在代码层面上规避循环依赖的陷阱; <br/>另外也可以在设计的层面上彻底避免循环依赖的出现。我的场景之所以出现循环依赖,是因为center和service都需要知道对方的存在,即 center <- -> service。如果采用依赖注入的方式,则可以切断这种直接依赖,类似于center <- container -> service。即加入一个container角色,把center和service都先加载进来,然后再用IOC的方法把依赖关系建立好。这样center和service都无须知道对方具体的文件所在了,也就不会循环的require对方了。 <br/> <br/>总的来说,循环依赖的陷阱并不大容易出现,但一旦出现了,在实际的代码中也许还真不好定位。它的存在给我们提了个醒,注意你工程中的依赖关系。哪天node对你抱怨,一个你明明已经exports了的方法undefined,我们就该提醒一下自己:哦,也许是它来了:) <br/> <br/>参考链接 <br/>[1] <a href=“https://github.com/joyent/node/issues/1418”>https://github.com/joyent/node/issues/1418</a> <br/>[2]<a href=“https://github.com/joyent/node/issues/1490”> https://github.com/joyent/node/issues/1490</a>

12 回复

不错,研究的太仔细了

分析的很透彻,赞一个…

跟老赵的一篇文章讲的一个问题:Node.js中相同模块是否会被加载多次?

老赵的那篇文章讲的主要是同一个模块不同版本重复加载的问题,我讲的是模块之间循环依赖而导致的问题,所以还是不大一样的,可以看作是对@老赵和@Jackson的文章的一个小补充吧

我倒觉得其实基本没关系……

@changchang @jeffz 囧了,记错了。我又看了下老赵的那篇,果然不是一个东西。弱弱地问下changchang,这篇文章不是第一次在这里发吧,我记得在哪里确实看过这个问题的分析的,实例和对Module的分析都一样。

@changchang 找了下以前保存的书签,是在之前的社区里第一次出现的呃(原链接),才注意到文章被重新编辑了,我就说以前好像在哪里看过。

@sumory 恩,之前是在老版的cnode上投过,改版后自动迁到这边了 :)

在load期间调用未加载完成的对象这种情况确实不多,有一种情况可能很常见:直接给module.exports对象赋值。这时候如果有循环依赖的话,也很容易出问题。

main.js

var a = require('./a');
a.a();

a.js

var b = require('./b');

module.exports = 
{ 
  a : function(){
    b.b();
  },

  c : function(){
    console.log('ok');    
  }
};

b.js

var a = require('./a');

module.exports = {
  b : function(){
    a.c();      
  }
}

运行node main.js,会报错:

TypeError: Object #<Object> has no method 'c'

是的,谢谢补充:)

这个问题我以前也有遇到过。像在代码中改写module.exports这种写法,我个人觉得是有隐患的,需要注意。

node里加载一个moudle时,其实会在module的代码外面包装一层,类似下面这样:

var fn = function (exports, require, module, __filename, __dirname) { 
  //module source code
};

加载时实际会把当前module的实例当参数传给上面的fn函数,类似于

fn(module.export, require, module, filename, dirname);

所以我们在代码中才可以访问到module, module.exports这些全局变量,然后给exports添加属性和方法,从而完成我们代码的暴露。当一个module被require时,实际上返回的是这个模块在cache中的module实例的exports字段。

上面的例子中,假设a.js最初被加载时的exports是exports1,在a.js中给module.exports所赋的值是exports2。那么,根据前面文章里循环依赖的原理,在b.js require(’./a’)的时候,a.js里实际还没执行到给module.exports赋值的语句,所以这时cache中的a模块的module.exports的值还是exports1,所以b中看到并引用的a其实是exports1。之后a完成初始化后,a变为了exports2,但b并不知道,还是保持着exports1的值。所以,才会出现c方法不存在这样的错误。

具体的代码可以参考

lib/module.js里的Module.prototype._compile

src/node.js里的NativeModule.wrap

@changchang 对了,参考代码的node版本是v0.6.16 :)

解决了,纠结死

回到顶部