Hardog's blog

trace forever

Group: 572218159
Email: 1273203953@qq.com
Location: hangzhou·zhejiang
GitHub: https://github.com/hardog

说明 本文基于Nodejs@4.3.0测试!

问题引出

首先大家看看下以下代码有什么问题?

1
let obj = {
	name: 'aName',
	time: ['a:1,b:2'],
	timeHandle: null,
	fnA: function(){
		// A: some code
		this.timeHandle = setInterval(() => {
			// B: do sth
		}, 1000);
	}
};

初看上去貌似并没有什么问题, 尤其是当A处充斥着大量的代码时可能问题更不易被发现. 当尝试执行代码:

1
// 执行fnA函数
obj.fnA();
// 等待2秒为了让this.timeHandle被赋值
setTimeout(() => {
	let stringifiedValue = JSON.stringify(obj);
}, 2000);

抛出的错误如下:

1
let stringifiedValue = JSON.stringify(obj);
	                            ^

TypeError: Converting circular structure to JSON
    at Object.stringify (native)
    at null._onTimeout (/path/to/test-interval.js:19:30)
    at Timer.listOnTimeout (timers.js:92:15)

问题分析

那么为什么会出现上述问题? 或许经验丰富的Coder一眼就能看出来, 但是对于小白来说初次看出问题并不那么容易! 首先根据字面意思,circular structure代表被转化的对象中存在着循环的结构, 也就是说存在着对象的循环引用从而构成一个环形结构, 简单示意图如下所示:

loop.png

假如你定义如下两个对象互相引用当尝试使用JSON.stringify序列化时就会出现如上所示的错误:

1
// 浏览器可运行
let a = {}, b = {};

a.b = b;
b.a = a;

// err circular structure
JSON.stringify(a);

快捷排查问题 当你出现一个类似的错误而一时半会又找不到问题所在时, 或者这时候你可以尝试下Node的util模块, 该模块提供了一个inspect方法可以帮助你快速发现问题, 如本例中如果使用该工具打印obj对象可看到如下输出:

1
{ name: 'aName',
  time: [ 'a:1,b:2' ],
  timeHandle:
   { _called: true,
     _idleTimeout: 1000,
     _idlePrev:
      Timer {
        '0': [Function: listOnTimeout],
        _idleNext: [Circular],
        _idlePrev: [Circular],
        msecs: 1000 },
     _idleNext:
      Timer {
        '0': [Function: listOnTimeout],
        _idleNext: [Circular],
        _idlePrev: [Circular],
        msecs: 1000 },
     _idleStart: 1043,
     _onTimeout: [Function: wrapper],
     _repeat: [Function] },
  fnA: [Function] }

上图很容易看出来, obj对象的timeHandle存在循环引用的情况, 因此沿着这条线索可以排查到是setInterval处出现了问题.

提示 JSON.stringify是根据对象的属性来判断是否存在循环引用的, 对于对象的方法中如果存在循环引用的情况并不会报错, 如下所示代码不会报错.

1
let obj = {
	index: 1,
	str: 'this is test for indirect',
	fnA: function(){
		let c = {}, b = {};
		
		// 循环引用
		c.b = b;
		b.c = c;
	}
};
// no err
JSON.stringify(obj);

更深层次原因

通过翻看Nodejs源码目录lib/timers.js文件可以查询到其实现方式, 以下通过两个问题的方式引出答案.

执行setInterval返回了什么?

从文件中找到setInterval函数, 该函数返回了timertimer又是Timeout的一个实例, 继续往下翻可以看到Timeout的定义如下:

1
function Timeout(after) {
  this._called = false;
  this._idleTimeout = after;
  this._idlePrev = this;
  this._idleNext = this;
  this._idleStart = null;
  this._onTimeout = null;
  this._repeat = null;
}

从代码中可以看出timer对象的_idlePrev&_idleNext都指向自身, 因此简单的从这里就可以看出为什么上述JSON.stringify会报循环引用的错误了.

setTimeoutsetInterval有什么不同?

大家都知道, setTimeout表示指定时间之后执行一次callback, 而setInterval是每隔指定时间都执行一次callback, 这两者实现上的差异也可以从lib/timers.js文件找到, 如下截取了setInterval函数中多出的一段代码, 能够解释该原因:

1
timer._onTimeout = wrapper;
// xxxxxx
function wrapper() {
	timer._repeat();

	// Timer might be closed - no point in restarting it
	if (!timer._repeat)
	  return;

	// If timer is unref'd (or was - it's permanently removed from the list.)
	if (this._handle) {
	  this._handle.start(repeat, 0);
	} else {
	  timer._idleTimeout = repeat;
	  active(timer);
	}
}

从以上代码中可以看出wrapper是用来在指定超时时间之后调用的回调函数, 该函数用来指定该timer是否需要重复执行, 如果需要重复执行则重新active timer, 即往时间的链表队列中插入一个timer来达到多次调用的目的. 而setTimeout在指定的超时时间之后只会调用一次传进去的回调函数.

关于更底层的一些原理大家可参考以下链接:

uv timer源码
关于unref的讨论