这是我参与更文挑战的第 29 天,活动详情查看:更文挑战
尾调用
ES6 规范新增了一项内存管理优化机制,让 JavaScript 引擎在满足条件时可以重用帧栈。这项优化非常有利于「尾调用」形式的代码。
那么什么是尾调用?简单一点来说就是函数在return
的时候执行另一个函数,如下所示:
function innerFunction() {
console.log('inner')
};
function outerFunction() {
console.log('outer');
return innerFunction(); // 尾调用
}
outerFunction();
函数的执行会在内存中形成一个「调用帧」,保存调用位置和内部变量等信息。多个函数的调用帧以栈的形式保存,称为「调用栈」。
在 ES6 优化之前,上述代码在内存中会发生如下操作:
- 执行 outerFunction 函数,该函数调用帧被压入调用栈
- 执行到 return 语句,需要先执行 innerFunction 函数获得返回值
- 执行 innerFunction 函数,该函数调用帧被压入调用栈,此时调用栈内有两个函数调用帧
- 执行完 innerFunction 函数体,返回值传给 outerFunction,outerFuntion 再返回值
- 将调用帧弹出栈外
在 ES6 之后,该段代码在内存中操作如下:
- 执行 outerFunction 函数,该函数调用帧被压入调用栈
- 执行到 return 语句,需要先执行 innerFunction 函数获得返回值
- 引擎发现这时候把第一个调用帧弹出栈外也没问题,因为 innerFunction 返回值也是 outerFunction 返回值
- 将 outerFunction 的调用帧弹出
- 执行 innerFunction 函数,该函数调用帧被压入调用栈,此时调用栈内只有一个函数调用帧
- 执行完 innerFunction 函数体,计算其返回值
- 将 innerFunction 调用帧弹出栈外
那么形成「尾调用」需要哪些条件呢?其实就是要确定外部函数调用帧没必要存在的条件,如下:
- 代码在严格模式下执行。因为在正常模式下,函数内部有两个属性,可以跟踪函数的调用栈。在严格模式下,这两个属性都无法使用。
arguments:返回调用时函数的参数。 func.caller:返回调用当前函数的那个函数。
- 外部函数的返回值是对尾调用函数的调用;
- 尾调用函数返回后不需要执行额外的逻辑;
- 尾调用函数不是引用外部函数作用域中自由变量的闭包。
尾递归
函数调用自身,称为递归。如果尾调用自身,就称为「尾递归」。
递归代码需要同时在内存中保存成千上百个调用帧,很容易发生“栈溢出”错误。那根据上文,如果递归代码能够写成尾调用的形式,就能够提前将外层函数的调用帧弹出栈外,使得调用栈中永远只有一个调用帧存在,从而避免了递归导致“栈溢出”的问题。
function factorial(n) {
if (n === 1) return 1;
return n * factorial(n - 1);
}
factorial(5) // 120
上面这段代码通过递归的方式实现了阶乘,最多需要在内存中同时保存 n 个函数调用帧。而改写成尾递归的方式,只需要保存一个函数调用帧。
function factorial(n, total) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5, 1) // 120