很多前端开发者都写过递归代码,但绝大多数人只知道同步无限递归会栈溢出,却搞不懂:同样是无限递归,为什么放到定时器里就没事,放到Promise微任务里就会页面卡死?
本文结合JS调用栈和事件循环机制,一次性讲透同步递归、宏任务递归、微任务递归三种场景的底层差异,彻底解决面试高频难点和线上隐形bug隐患。
一、最基础:同步无限递归——直接栈溢出
1. 代码示例
function foo() {
foo()
}
foo()
2. 运行结果
浏览器/Node.js 直接报错:RangeError: Maximum call stack size exceeded(调用栈溢出)。
3. 底层原理
JS 是单线程语言,依靠调用栈执行同步代码,遵循「后进先出」规则:
-
第一次执行
foo(),函数入栈; -
函数内部立即执行
foo(),新函数持续入栈; -
整个过程没有终止条件、没有 return,栈帧只会无限累加,永远不会清空;
-
当栈数量超过JS引擎最大限制(Chrome约1万层),直接触发栈溢出,程序终止。
关键细节
仅定义函数 function foo(){foo()} 不调用,不会报错!只有执行调用操作,才会触发无限递归和栈溢出。
二、宏任务递归:无限循环,不崩溃、不卡死
我们将递归放入 setTimeout 宏任务中,场景会彻底反转。
1. 代码示例
function foo() {
setTimeout(foo, 0)
}
foo()
2. 运行结果
无栈溢出、不报错、页面不卡死,代码会无限循环执行,浏览器可正常渲染页面、响应操作。
3. 底层原理
核心区别:宏任务会主动清空调用栈,彻底规避栈累积问题:
-
第一次执行
foo(),同步代码执行完毕,将新的foo推入宏任务队列,函数执行结束,调用栈完全清空; -
主线程空闲后,从宏任务队列取出下一个
foo执行,重复上述流程; -
每一轮递归都是「入栈→执行→出栈→清空栈」,不会叠加栈帧,因此永远不会溢出。
同时事件循环会留出时间执行浏览器渲染、用户交互,所以页面始终可响应。
三、微任务递归:无栈溢出,但永久卡死主线程
这是最容易踩坑的场景:微任务递归既不会栈溢出,也不会报错,但会直接锁死页面。
1. 代码示例
function foo() {
Promise.resolve().then(foo)
}
foo()
2. 运行结果
无栈溢出、无报错,但主线程彻底卡死,页面无法点击、无法渲染、所有宏任务全部失效。
3. 核心底层:事件循环执行顺序
想要读懂这个现象,必须记住JS事件循环铁律:
执行完当前所有同步代码 → 一次性清空全部微任务队列 → 才会执行宏任务、渲染页面
微任务递归的执行流程:
-
初始调用
foo()为同步代码,执行后将foo推入微任务队列,同步代码执行完毕; -
引擎开始清空微任务队列:取出队列中的
foo执行,又会新增一个foo放入微任务队列尾部; -
新的微任务会在本轮微任务循环中继续执行,队列永远无法清空;
-
程序永久卡在微任务处理阶段,永远无法进入渲染和宏任务阶段,主线程彻底阻塞。
补充
queueMicrotask 实现递归,效果和 Promise.then 完全一致,同样会卡死主线程。
四、三种递归核心差异对照表
| 递归类型 | 是否栈溢出 | 主线程状态 | 核心原因 |
|---|---|---|---|
| 同步递归 | 是 | 直接报错终止 | 栈帧无限累加,无法清空 |
| 宏任务递归(setTimeout) | 否 | 正常循环,页面可响应 | 每次执行完清空调用栈,让出主线程 |
| 微任务递归(Promise) | 否 | 主线程卡死,页面冻结 | 微任务队列无限追加,无法进入下一事件循环阶段 |
五、总结
-
同步递归:栈不停叠加 → 栈溢出报错,程序终止;
-
宏任务递归:一轮一清空,事件循环轮转执行 → 无限循环,不影响页面使用;
-
微任务递归:本轮无限填充队列,锁死微任务阶段 → 无报错、无溢出,但页面彻底卡死;
-
事件循环核心顺序:同步代码优先跑完 → 清空所有微任务 → 渲染页面 → 执行宏任务,不可逆、不插队。