先记住这个答案
先分清两个问题。爆栈发生在递归调用出现在第一个 await 之前的时候,async 函数这一段是同步执行的,递归层数一多就会抛 RangeError。Promise 链增长发生在每层都 return 下一层调用的时候,第 1 层的 Promise 要等第 2 层兑现,第 2 层又要等第 3 层,任务不结束,这些 Promise 一个都释放不了。尾递归在这里帮不上忙,因为 V8 等主流引擎没有实现尾调用优化。标准解法是把递归改成迭代:用一个数组做待处理队列,在 while 循环里每轮 await 一次,栈深度和额外内存都不会随层数增长。
async函数在第一个await前是同步执行的- 每层
return下一层 Promise,所有层都得留在内存里 - 主流引擎没有尾调用优化,尾递归照样爆栈
- 改成
while循环加队列,栈和内存都是常量级
两种风险分别从哪来
先看两段看起来都「没问题」的代码。
// 风险一:await 之前就递归,async 也救不了调用栈
async function countDown(n) {
if (n === 0) return
return countDown(n - 1) // 同步地一层层调用,n 很大时抛 RangeError
}
// 风险二:每层都 await 过,栈没问题,但每层的 Promise 都在等下一层
async function poll() {
await checkStatus()
await sleep(1000)
return poll() // 永不结束的轮询,Promise 一层等一层,内存只增不减
}很多人以为加了 async 就是异步的,不会爆栈。其实 async 函数被调用时会同步执行,直到遇到第一个 await 才让出调用栈。countDown 里根本没有 await,所以它和普通递归没区别,层数到了一万左右就可能溢出,具体上限取决于引擎和每帧的大小。
poll 的每一层都 await 过,调用栈早就清空了,不会溢出。但它的返回值是下一层 poll() 的 Promise。第 1 层的 Promise 被用第 2 层的 Promise 去 resolve,只能等着;第 2 层又等第 3 层。这就像一串挂在一起的钩子,最底下那个不松手,上面的全都挂着。轮询跑一天,就挂了几万个 Promise。
用循环加队列改写
解决办法是不让函数调用自己。把「接下来要处理什么」放进一个数组,一个 while 循环不停地从数组里取、处理、把新发现的任务放回去。下面模拟按父节点异步加载子节点,层级深度事先不知道。
const tree = { 1: [2, 3], 2: [4], 3: [], 4: [] }
const loadChildren = (id) => new Promise((r) => setTimeout(() => r(tree[id]), 10))
async function expandAll(rootId) {
const queue = [rootId] // 待处理的节点
const visited = []
while (queue.length) {
const id = queue.shift()
visited.push(id)
const children = await loadChildren(id) // 每轮让出一次,栈不会累积
queue.push(...children)
}
return visited
}
expandAll(1).then((ids) => console.log(JSON.stringify(ids)))查看输出与解释
[1,2,3,4]整个过程只有一次 expandAll 调用,也只有一个它返回的 Promise。树再深,调用栈深度不变,内存里只多了一个 queue 数组。用 shift 取是广度优先,换成 pop 就是深度优先。
- 轮询也照这个思路改
poll改成while (!stopped) { await checkStatus(); await sleep(1000) }就行。只有一个函数调用在跑,停止时把stopped设为true,循环自然退出。
容易答错的地方
- 以为写成尾递归就不会爆栈
- 尾调用优化写进了 ES2015 规范,但只有 Safari 的 JavaScriptCore 实现了,Chrome 和 Node.js 用的 V8 没有实现。在 JavaScript 里靠尾递归防爆栈是不成立的,面试时说「用尾递归」要补上这一句,否则会被追问。
- 以为用
setTimeout包一层就万事大吉 setTimeout确实能把递归调用挪到下一个宏任务,调用栈不会累积。但如果还是return一个等待下一层的 Promise,链条问题照样存在。栈和链是两个问题,要分开检查。- 为了快直接全部并发
- 树的节点很多时,每个节点都立刻发请求,会一下子打出几千个请求。可以按层处理,每层用
Promise.all并发,同时限制并发数,比如一次最多 5 个。
面试官还会怎么问?
同步的深层递归,比如遍历很深的 JSON,怎么改?
思路一样,用数组模拟调用栈。把根节点压进数组,循环里弹出一个节点处理,再把它的子节点压进去,直到数组为空。这样深度只受内存限制,不受调用栈限制。
怎么确认 Promise 链是不是在泄漏?
在 Chrome DevTools 的 Memory 面板里间隔一段时间拍两次堆快照,对比 Promise 对象的数量。如果它随着时间一直上涨,而业务上同时在跑的任务数是固定的,基本就是链没释放。
想让展开过程可以中途停止,怎么做?
给函数传一个 AbortSignal,每轮循环开头检查 signal.aborted,是 true 就 break 或抛错。循环写法天然有这样一个检查点,递归写法则要在每一层都判断。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。