一般情况下,仅从代码上看只要不出现死循环,是不会出现堆栈调用溢出的。但是某些情况下列外,比如下面这段代码:

 1 var a = 99;
 2 function b (){
 3     a --;
 4     if (a > 0){
 5         b();
 6     } else {
 7         console.info(a);
 8     }
 9 }
10 b();
11 => 0

这并不是死循环,当变量 a逐渐减少到0时,递归就终止了。乍一看是不会出现任何问题的,但是如果我们把 a增加到一个较大的数值,就会出现问题:

如图所示,一个范围错误的异常抛了出来,我们被告知"超过了最大栈调用大小",哈哈,如果业务代码里出现了针对大量数据的递归,后果可想而知。所有我们有必要知道js调用栈的一些特点。

针对示例中 b函数来说,它的内部应用了外部作用域中的 a变量,形成了闭包,只要符合条件,它会一直被递归调用。而当b函数每一次被调用都会有新的闭包产生,为了记录对外部作用域中的变量引用,上一次因函数调用产生的栈帧不会从栈顶出去,导致栈中的栈帧超过了允许的数量而抛出栈调用溢出的异常。而js引擎(或者是其他计算机语言的解释器)设计这种限制的目的就是在于要控制程序对内存资源的使用量,如果无此限制,一个错误的代码就足以让计算机奔溃。在较为新版的Chrom中,调用大小在13000次左右,FireFox在60000次左右,Node.js在10000次左右。因版本不同可能限制不同,这个可以自行测试。

针对js递归中容易出现栈调用溢出问题,是有解决办法的。

利用js事件循环机制来处理该问题

js最初就是被作为浏览器端语言而开发的,它能够和由排版引擎提供的DOM进行交互,为了表现的一致性,整个页面的排版引擎和js引擎是在唯一的一个线程里面跑的。可以这里理解:打开浏览器,就跑起了浏览器的进程。新建开一个tab、输入一个网址回车,就对应一个web页面,就为这个页面在这个浏览器进程中新起了一个线程,用来跑该页面的排版引擎和js引擎,再开一个tab也是如此,但是tab之前的线程相互独立,互不干扰,每个tab中的线程只负责自己页面的事物。这就是浏览器tab单线程来由,然而为了表现一致性而引入的单线程却带来了另外一个问题:阻塞。

试想如果我在做一个xhr请求,在请求没回来之前,按照单线程阻塞的特点,页面是没有任何反应的,所有排版引擎的回流和重绘等都阻塞了,用户的点击事件也没法相应,动画全部停止,这简直就是噩梦。为了避免这个问题,js在设计之初就拥有一个事件循环(Event Loop)机制来使它的运行是非阻塞的。

 

由上图可以看出在主线程之外其实还维护了一个队列,整个过程由上到下。我们使用setTimeout等异步的操作都被推入了任务队列中,而不是在主线程里直接被运行了。当主线程的C过程中的同步任务被执行完后,在此刻主线程中的任务都被执行完,事件循环器会在任务队列中去查看是有任务需要执行的事件。这个事件的产生就是由任务队列中的任务执行完成后生成出的一个标记。如果任务队列中有需要执行的事件,那么将这个事件所对应的任务推回主线程中进行执行,如上图 A函数,A函数执行完成后或者在执行的过程中,主线程执行栈中又被压入了一个D过程的同步任务,在A函数执行后就开始执行D任务。当然数值都只是打个比方,不可能经过100毫秒、200毫秒就正好可以见缝插针。总之时间循环机制就是不停地定时查看主线程是否空闲,如果空闲,就去队列中找事情到主线中去做。也就是说异步的函数调用是不会阻塞的,除非是主线程同步任务自己阻塞了,比如:

在浏览器中弹出alert,如果不点击确定,console的内容是永远不会出现的。因为alert(1)是在主线程中调用的,如果用户没有在浏览器上有任何点击弹出框确定按钮的动作,该同步任务一直在执行栈中处于挂起状态,主线程是一直阻塞着的,且无法进行下一个同步任务的执行。即便时间循环机制发现了事件队列中有任务到了需要执行的时间点,该任务的执行也会排在主线程阻塞完成之后。

以上算是对js事件循环机制有个初步的概括,那么利用这一机制怎么来解决递归中可能会出现的调用栈溢出情况呢?

通过上面的函数调用栈我们已经知道每次函数的调用如果有对外层内容的引用或依赖,本次函数调用时在调用栈中创建调用帧都会被保留。如果达到的最大调用大小还没有被清除,那么就会抛出异常。但是我们可以在每次调用的时候将对函数的递归调用放到异步方法中去,比如通过setTimeout方法,强行将函数的同步调用放到主线程以外的任务队列中,把主线程对函数调用的控制权交由更上一层的事件循环机制来处理。之前代码片段可以修改为:

 1 var a = 9999;
 2 function b (){
 3     a --;
 4     if (a > 0){
 5         setTimeout(b, 4);
 6     } else {
 7         console.info(a);
 8     }
 9 }
10 b();

大概等了一会儿,控制台输出了0;实际测试中即便将a修改为99999,只要时间等的足够久也是能看到控制台打出东西的。

通过setTimeout异步函数来调用b时,上一次当b函数被调用完成后,主线程的执行栈会清除掉该次调用栈帧,因为到setTimeout这里的时候,主线程执行栈已经知道b在主线的调用已经结束了,不需要为它保存任何记录,它被推入了主线程外的队列中去了,控制权由主线程交到了时间循环机制手里。既然调用栈帧每一次都会被清除,自然也不会出现调用栈达到最大值的异常了。这也解释了为什么setTimeout和setIntervel异步调用的函数内容的this指向的是window对象,因为即便他们是处于某个对象的方法中,他们的调用也就是事件循环机制决定的,并不是主线程一手操控,和他们在被书写时候处于哪个对象内部并没有任何的关系。可以这样理解:其实是js引擎(对于页面作用域来说也就是window对象)调用了它们,而不是代码上的a对象调用的,所有this也自然不会指向a对象:

 

除了这个方法可以处理递归调用可能存在的调用栈溢出问题,还有尾调用优化也能解决,在支持ES6的现代浏览器,只要函数是尾调用并开启 "use strict" 严格模式,就会在执行的时候被优化成循环方式来替换函数递归调用进行优化,避免巨量的调用帧出现且不能被清空的情况发生。在其他程序语言中也有此优化支持。