前景知识
- 调用栈是JavaScript引擎追踪函数执行的一个机制
- 每调用一个函数,JavaScript引擎都会为其创建执行上下文,并把该执行上下文压入调用栈,然后JavaScript引擎开始执行函数代码
- 当前函数执行完毕后,该函数执行上下文会出栈
- 当调用栈空间被占满时,会引发堆栈溢出问题
思考下以下代码
js
function runStack (n) {
if (n === 0) return 100;
return runStack( n- 2);
}
runStack(50000)
每次调用 runStack
,引擎都会创建一个新的执行上下文,压入 调用栈 (call stack) ,因为 JS 没有保证尾调用优化 (TCO),旧的上下文不会被立即释放,所以当 n=50000
时,调用链条太深,超过了栈深限制 → 爆栈
思考方向:
重点:减少调用栈深度!!!
- 减少函数执行上下文创建
- 及时弹出函数执行上下文
- 分解深度
解决方案
改成循环
直接改成循环,这样不会产生递归调用,栈深度始终为1
js
function runStack(n) {
while (n > 0) {
n -= 2;
}
return 100;
}
console.log(runStack(50000));
因为只有一次runStack
调用,while
语句只是 在当前执行上下文里反复执行,不会产生新的函数调用,所以无论循环多少次,调用栈的深度都不会增加
但当函数逐渐复杂起来,该方法也没法适用
尾递归+Trampoline
什么是尾递归?
如果一个函数在最后一步直接返回一个函数调用(没有再做额外计算),那就是尾递归
js
function factorial(n, acc = 1) {
if (n === 1) return acc;
return factorial(n - 1, n * acc);
}
调用 factorial(5)
的展开过程:
js
factorial(5, 1)
= factorial(4, 5)
= factorial(3, 20)
= factorial(2, 60)
= factorial(1, 120)
= 120
这里每次递归调用都直接把结果传下去,函数本身不需要等子函数返回后再做额外运算。
所以只要 JS 引擎支持"尾调用优化 (TCO, Tail Call Optimization) ",编译器就可以复用当前栈帧,不会越递归越深,也就不会栈溢出。
但需要注意:JavaScript 规范 (ES6)确实要求引擎实现尾调用优化,但实际上大多数引擎(比如 V8/Chrome、Node.js)没有实现
规范上,JS应该支持尾调用优化,但实际上大多数引擎没有做,怕影响调试体验,所以,在JS里写尾递归只是一种代码风格,并没有性能优势
什么是trampoline 技术?
trampoline(蹦床)技术 就是专门用来解决 JS 没有尾调用优化 的场景的
在 JS 里递归会导致调用栈过深 → 爆栈
js
function sum(n, acc = 0) {
if (n === 0) return acc;
return sum(n - 1, acc + n);
}
console.log(sum(100000)); // ❌ RangeError: Maximum call stack size exceeded
这本来是个 尾递归,但因为 JS 引擎没做尾调用优化,还是会压栈 → 最终爆栈
Trampoline 技术
核心思想 : 不要让递归函数自己"直接调用自己",而是 返回一个函数;由一个"蹦床函数"统一负责不断调用,直到得到最终结果
这样,每次递归调用都会立即出栈,不会无限压栈
为什么叫 trampoline(蹦床)?
因为递归函数每次返回的"下一步"会交给蹦床函数执行,像在蹦床上一样"弹回去",不会一直往下压栈。
- 普通递归 = 一直往下掉(压栈),最后摔死(爆栈)
- Trampoline = 每次掉下去都被蹦床弹起来(出栈),所以永远不会爆
再回到问题函数,采用尾调用+trampoline解决
实现步骤:
- 将递归函数改写为返回函数
- 写一个trampoline函数,不断执行返回函数,直到拿到结果
js
// 1、将递归函数改为返回函数的形式
function runStackRec() {
if (n <= 0) {
return 100;
}
return () => runStackRec(n - 2);
}
// 2、使用trampoline函数来处理递归调用
function trampoline(n) {
return function tramp(fn) {
let result = fn();
while (typeof result === 'function') {
result = result();
}
return result;
}
}
const runStack = trampoline(runStackRec);
来逐步拆解下,以runStack(6)
为例:
- 进入包装函数
- 栈:
[global → runStack(wrapper)]
- 执行
result = fn(6)
:- 栈:
[global → wrapper → runStackRec(6)]
runStackRec(6)
返回 thunkth1
,立刻 返回到 wrapper。
- 栈:
- 栈恢复为:
[global → wrapper]
while
循环第一次
- 调用
th1()
:- 栈:
[global → wrapper → th1]
th1
内部调用runStackRec(4)
:- 栈:
[global → wrapper → th1 → runStackRec(4)]
- 返回 thunk
th2
,随后th1
返回到wrapper
。
- 栈:
- 栈:
- 栈恢复为:
[global → wrapper]
- 第二次循环
- 调用
th2()
→ 内部runStackRec(2)
→ 返回th3
→ 回到wrapper
。 - 栈峰值仍然是类似的 3~4 层。
- 第三次循环
- 调用
th3()
→ 内部runStackRec(0)
→ 直接返回100
(不是函数)。 while
结束,wrapper
返回100
。
峰值栈深是常数 :最多就
wrapper → thunk → runStackRec
(再加一层全局)。与
n
无关,不会像递归那样随着n
线性增长虽然
runStackRec
被调用很多次,也会创建很多执行上下文,但这些调用是一个接着一个发生的,前一个先返回,栈清空后才进行下一个,所以调用总次数多 ≠ 调用栈深,trampoline 把"深度"换成了"次数",避免栈溢出
分片异步
调用栈始终是同步的,只负责执行当前上下文里的代码,异步函数不会直接进入调用栈,而是进入任务队列,等调用栈清空后,事件循环才会把它推入调用栈执行,利用以上特性,可以将5000拆解为一段一段的,以此来解决栈溢出
js
function runStackAsync(n, callback) {
function step() {
if (n <= 0) {
callback(100);
return;
}
n -= 2;
if (n % 1000 === 0) {
// 每 1000 次让出一次事件循环
setTimeout(step, 0);
} else {
step();
}
}
step();
}
runStackAsync(50000, (res) => {
console.log(res);
});
来逐步拆解下
- 初始调用
- 执行
runStackAsync(50000, callback)
→ 进入函数,调用step()
。 - 调用栈:
[global → runStackAsync → step]
。
- 循环执行
step
n=50000
,大于 0,减 2 →n=49998
。- 因为
49998 % 1000 ≠ 0
,所以递归调用step()
。 - 注意:这时候调用的是 同步递归,会立即创建新的上下文压栈。 所以一开始确实还是像递归一样堆积栈。
- 关键点:
setTimeout
-
当执行到
n % 1000 === 0
时,比如n=49000
,触发:scsssetTimeout(step, 0);
-
发生了什么?
- 当前这层
step
没有继续递归调用 ,而是安排了一个 异步任务(放到任务队列)。 - 这层
step
立即返回 → 调用栈开始清空。
- 当前这层
- 下一轮事件循环
- 当调用栈清空后,事件循环会取出队列里的
step
并执行。 - 相当于"接力",在一个全新的调用栈里继续运行
step()
。 - 又会执行 1000 次同步递归,然后再次
setTimeout
→ 清空栈 → 下一次循环。
这里通过把大任务切分,每次做一部分,然后通过setTimeout
把"下一段递归"放到 事件循环的下一轮 ,每次 setTimeout
触发时,之前的调用栈都已经释放了,所以 栈深度永远有限
🚩 特点总结
✅ 不会栈溢出,因为调用被分散到多轮事件循环
✅ 不会长时间阻塞主线程,UI 有机会在片段之间更新
❌ 多了事件循环切换,性能比纯循环或 trampoline 差一些(但换来更好的"流畅性")
总结
- 普通递归:一次性背着 50000 层楼梯下楼,太重了会崩
- Trampoline:一次背一层,但要走 50000 次
- 分片异步:每次背 1000 层,休息一下(让 UI 更新),再继续。这样既不会爆栈,也不会让人觉得"卡死"