创见博客
# JS三种递归:同步、宏任务、微任务的致命区别
七崽爱吃小饼干2026/09/09阅读 0

很多前端开发者都写过递归代码,但绝大多数人只知道同步无限递归会栈溢出,却搞不懂:同样是无限递归,为什么放到定时器里就没事,放到Promise微任务里就会页面卡死?

本文结合JS调用栈和事件循环机制,一次性讲透同步递归、宏任务递归、微任务递归三种场景的底层差异,彻底解决面试高频难点和线上隐形bug隐患。

一、最基础:同步无限递归——直接栈溢出

1. 代码示例

javascript
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. 代码示例

javascript
function foo() {
  setTimeout(foo, 0)
}
foo()

2. 运行结果

无栈溢出、不报错、页面不卡死,代码会无限循环执行,浏览器可正常渲染页面、响应操作。

3. 底层原理

核心区别:宏任务会主动清空调用栈,彻底规避栈累积问题:

  • 第一次执行 foo(),同步代码执行完毕,将新的 foo 推入宏任务队列,函数执行结束,调用栈完全清空;

  • 主线程空闲后,从宏任务队列取出下一个 foo 执行,重复上述流程;

  • 每一轮递归都是「入栈→执行→出栈→清空栈」,不会叠加栈帧,因此永远不会溢出。

同时事件循环会留出时间执行浏览器渲染、用户交互,所以页面始终可响应。

三、微任务递归:无栈溢出,但永久卡死主线程

这是最容易踩坑的场景:微任务递归既不会栈溢出,也不会报错,但会直接锁死页面。

1. 代码示例

javascript
function foo() {
  Promise.resolve().then(foo)
}
foo()

2. 运行结果

无栈溢出、无报错,但主线程彻底卡死,页面无法点击、无法渲染、所有宏任务全部失效。

3. 核心底层:事件循环执行顺序

想要读懂这个现象,必须记住JS事件循环铁律:

执行完当前所有同步代码 → 一次性清空全部微任务队列 → 才会执行宏任务、渲染页面

微任务递归的执行流程:

  • 初始调用 foo() 为同步代码,执行后将 foo 推入微任务队列,同步代码执行完毕;

  • 引擎开始清空微任务队列:取出队列中的 foo 执行,又会新增一个 foo 放入微任务队列尾部;

  • 新的微任务会在本轮微任务循环中继续执行,队列永远无法清空;

  • 程序永久卡在微任务处理阶段,永远无法进入渲染和宏任务阶段,主线程彻底阻塞。

补充

queueMicrotask 实现递归,效果和 Promise.then 完全一致,同样会卡死主线程。

四、三种递归核心差异对照表

递归类型是否栈溢出主线程状态核心原因
同步递归是直接报错终止栈帧无限累加,无法清空
宏任务递归(setTimeout)否正常循环,页面可响应每次执行完清空调用栈,让出主线程
微任务递归(Promise)否主线程卡死,页面冻结微任务队列无限追加,无法进入下一事件循环阶段

五、总结

  1. 同步递归:栈不停叠加 → 栈溢出报错,程序终止;

  2. 宏任务递归:一轮一清空,事件循环轮转执行 → 无限循环,不影响页面使用;

  3. 微任务递归:本轮无限填充队列,锁死微任务阶段 → 无报错、无溢出,但页面彻底卡死;

  4. 事件循环核心顺序:同步代码优先跑完 → 清空所有微任务 → 渲染页面 → 执行宏任务,不可逆、不插队。

评论
0/100