JS执行机制 - 调用栈

128 阅读14分钟

当一段JS代码被执行时,JS引擎会先对其进行编译,并创建执行上下文。

  • 当JS执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份;
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁;
  • 当使用eval函数的时候,eval的代码也会被编译,并创建执行上下文(eval 函数可计算字符串,并执行其中的JavaScript代码)。

而JS中有很多函数,经常会出现在一个函数中调用另外一个函数的情况,调用栈就是用来管理函数调用关系的一种数据结构。

函数调用

函数调用就是执行一个函数,具体方式是使用函数名跟着一对小括号。

var a = 2
function add(){
var b = 10
return  a+b
}
add()

如上代码,先是创建了一个add函数,接着在代码的最下面又调用了该函数。

在执行到函数add()之前,JS引擎会为上面这段代码创建全局执行上下文,包含了声明的函数和变量。

Screenshot 2024-03-31 at 22.41.04.png

代码中全局变量和函数都保存在全局执行上下文的变量环境中。

执行上下文准备好后,便开始执行全局代码,当执行到add()时,JS判断这是一个函数调用,那么将执行以下操作:

  • 首先,从全局执行上下文中,取出add函数代码;
  • 其次,对add函数的这段代码进行编译,并创建该函数的执行上下文可执行代码
  • 最后,执行代码,输出结果;

完整流程如下图:

Screenshot 2024-03-31 at 23.17.27.png

就这样,执行到add函数的时候,就有了两个执行上下文--全局执行上下文add函数执行上下文

在执行JS代码时,由于函数调用的关系,可能会存在多个执行上下文,JS引擎就是通过调用栈来管理这些执行上下文的。

JS调用栈

调用栈是一个栈结构,栈中的元素满足先进后出的特点。

关于栈,可以结合这样一个例子来理解:

一条单车道的单行线,一端被堵住了,而另一端入口处没有任何提示信息,堵住之后就只能后进去的车子先出来。

这时这个堵住的单行线就可以被看作是一个栈容器,车子开进单行线的操作叫做入栈,车子倒出去的操作叫做出栈

在车流量较大的场景中,就会发生反复的入栈、栈满、出栈、空栈和再次入栈,一直循环。所以,栈就是类似于一端被堵住的单行线,车子类似于栈中的元素,栈中的元素满足后进先出的特点。

JS引擎正是利用这种栈结构来管理执行上下文的。在执行上下文创建好后,JS引擎会将执行上下文压入栈中,通常把这种用来管理执行上下文的栈称为执行上下文栈,又称调用栈

var a = 2
function add(b,c){
  return b+c
}
function addAll(b,c){
var d = 10
result = add(b,c)
return  a+result+d
}
addAll(3,6)

结合上面的代码,感受下在代码执行过程中调用栈的变化。

  • 第一步,创建全局执行上下文,并将其压入栈底
    变量a、函数addaddAll都保存到了全局执行上下文的变量环境对象中。 Screenshot 2024-03-31 at 23.29.36.png 全局执行上下文压栈后,JS引擎便开始执行全局代码。首先会执行a=2的赋值操作,执行该语句会将全局上下文变量环境中a的值更新为2.设置后的全局上下文的时态图如下:

Screenshot 2024-03-31 at 23.34.22.png

  • 第二步是调用addAll函数
    当调用该函数时,JS引擎会编译该函数,并为其创建一个函数执行上下文,并将该函数执行上下文压栈。 Screenshot 2024-03-31 at 23.36.22.png addAll函数的执行上下文创建好之后,便进入了函数执行阶段,这里先执行的是d=10的赋值操作,执行语句会将addAll函数执行上下文中的d由undefined变成10。

  • 第三步是调用add函数
    同样会为其创建函数执行上下文,并将其压栈。 Screenshot 2024-03-31 at 23.39.41.png

  • 第四步是add函数出栈
    add函数返回时,该函数的执行上下文就从栈顶弹出,并将result的值设置为add函数的返回值,也就是9。

Screenshot 2024-03-31 at 23.41.03.png

  • 第五步是addAll函数出栈
    紧接着addAll函数执行最后一个相加操作后并返回,addAll的执行上下文也会从栈顶出栈,此时调用栈中就只剩下全局上下文了。

Screenshot 2024-03-31 at 23.42.36.png

至此, 整个JS代码流程执行结束。

在开发中,如何利用好调用栈

1. 利用浏览器查看调用栈信息

当执行一段复杂的代码时,可能很难从代码文件中分析其调用关系,这时候你可以在你想要查看的函数中加入断点,然后当执行到该函数时,就可以查看该函数的调用栈了。

以前面代码作为演示,打开开发者工具的“Source”标签,选择相应JS代码的页面,在add函数加断点并刷新页面。当执行到add函数时,执行流程就暂停了,这时可以通过右边“call stack”来查看当前的调用栈的情况,如下图:

Screenshot 2024-04-01 at 09.13.46.png

右边的“call stack”下面显示出来了函数的调用关系:栈的最底部是 anonymous,也就是全局的函数入口;中间是 addAll 函数;顶部是 add 函数。这就清晰地反映了函数的调用关系,所以在分析复杂结构代码,或者检查 Bug 时,调用栈都是非常有用的。

除了通过断点来查看调用栈,还可以使用 console.trace() 来输出当前的函数调用关系,比如在示例代码中的 add 函数里面加上了 console.trace(),你就可以看到控制台输出的结果,如下图:

Screenshot 2024-04-01 at 09.15.43.png

2. 栈溢出(Stack Overflow)

调用栈是有大小的,当入栈的执行上下文超过一定数目,JS 引擎就会报错,我们把这种错误叫做栈溢出

如一个递归的代码。

function division(a,b){
    return division(a,b)
}
console.log(division(1,2))

当执行以上代码时,就会出现栈溢出错误

Screenshot 2024-04-01 at 09.21.21.png

这是因为当 JS 引擎开始执行这段代码时,它首先调用函数 division,并创建执行上下文,压入栈中;然而,这个函数是递归的,并且没有任何终止条件,所以它会一直创建新的函数执行上下文,并反复将其压入栈中,但栈是有容量限制的,超过最大数量后就会出现栈溢出的错误。

理解了栈溢出原因后,就可以使用一些方法来避免或者解决栈溢出的问题,比如

  • 把递归调用的形式改造成其他形式(如循环);
  • 或者使用定时器等异步的方式来把当前递归任务拆分为其他很多小任务,并在事件循环中逐步执行它们。

如下面这段递归代码,当输入一个较大的数时,比如 50000,就会出现栈溢出的问题。

function fibonacciRecursive(n) { 
    // 基本情况:0和1 
    if (n === 0) { 
        return 0; 
    } else if (n === 1) { 
        return 1; 
    } 
    // 递归情况:n大于1 
    return fibonacciRecursive(n - 1) + fibonacciRecursive(n - 2); 
}

console.log(fibonacciRecursive(50000));

那么如何优化下这段代码,以解决栈溢出的问题呢?

第一个方式就是将递归改成循环

function fibonacci(n) {
  // 处理特殊情况,当n为0或1时,直接返回n
  if (n <= 1) {
    return n;
  }

  // 初始化前两个斐波那契数
  let fib = [0, 1];

  // 从2开始,计算斐波那契数列直到所需的位置
  for (let i = 2; i <= n; i++) {
    // 当前斐波那契数是前两个数的和
    fib[i] = fib[i - 1] + fib[i - 2];
  }

  // 返回结果
  return fib[n];
}

// 测试函数
console.log(fibonacci(50000)); 

这种实现方式虽然没有栈溢出,但是它会占用越来越多的内存来存储整个斐波那契数列。

第二个方式是将递归改成迭代

function fibonacciIterative(n) {
  let a = 0;
  let b = 1;
  let result;

  // 如果输入值为0或1,直接返回相应的斐波那契数
  if (n === 0) {
    return a;
  }
  if (n === 1) {
    return b;
  }

  // 迭代计算斐波那契数列
  for (let i = 2; i <= n; i++) {
    result = a + b; // 计算当前斐波那契数
    a = b; // 更新前一个斐波那契数
    b = result; // 更新当前斐波那契数
  }

  return result;
}

// 测试函数
console.log(fibonacciIterative(50000)); 

循环与迭代是在编程中用于重复执行任务的常见方法,在概念上相似,解决问题的原理也相似,但是侧重点不同。循环侧重于重复执行代码块,迭代侧重于访问和处理数据集合的过程。

接下来看为什么加入定时器的递归不会栈溢出?

function foo() {
  setTimeout(foo, 0); // 是否存在堆栈溢出错误?
};    

浏览器的主要组件包括调用堆栈事件循环任务队列Web API。像setTimeoutsetIntervalPromise这样的全局函数并不是JS引擎的一部分,而是 Web API 的一部分。如下JS环境的可视化形式所示:

Screenshot 2024-04-01 at 16.09.45.png

JS引擎调用栈是后进先出的。每次从堆栈中取出一个函数,然后从上到下依次运行代码,每当遇到一些异步代码,如setTimeout 就把它交给 Web API(箭头1)。因此,每当事件被触发时,callback 都会被送到任务队列(箭头2)。

事件循环(Event Loop)不断地监听任务队列(Task Queue)状态,并按它们的顺序一次处理一个回调,每当调用堆栈(stack)为空时,Event Loop 获取回调并将其放入堆栈(stack)进行处理(通过箭头3),如果堆栈不为空,事件循环就不会将任何回调压入堆栈。

基于以上,因此:

  • 调用 foo() 会将 foo 函数压入调用栈;
  • 执行foo函数时,JS引擎遇到setTimeout函数,将foo回调函数传递给 Web API 并从函数返回,调用栈再次为空;
  • 到了计时器预设的时间,事件循环将选择foo回调并其压入调用栈进行处理;
  • 再次重复上述操作,但堆栈不会溢出。

因此,对于前面的斐波那契数列,还有其他解法:

第三种方式:使用setTimeout

递归函数调用转换为异步操作的方法。把重复的函数压栈操作转换到任务队列里执行,可以防止因连续递归而导致的栈溢出。

function fibonacciAsync(n, a = 0, b = 1, callback) {
  if (n === 0) {
    return callback(a);
  }
  if (n === 1) {
    return callback(b);
  }

  // 递归调用,但使用setTimeout来实现异步
  setTimeout(() => {
    fibonacciAsync(n - 1, b, a + b, callback);
  }, 0);
}

// 使用方法:传入回调函数来获取结果
fibonacciAsync(50000, function(result) {
  console.log('斐波那契数列第50000项(异步):', result);
});

虽然此种方式可以避免栈溢出的问题,但是对于资源密集型任务,通常也并不建议使用setTimeout,因为它并没有减少递归的数量,不会真正提高计算效率。甚至可能会导致性能下降。因为setTimeout引入了额外的事件循环和回调处理的开销。由于setTimeout的异步特性,还可能会导致回调函数的执行顺序不确定。

所以,对于本例来讲,迭代还是相对好的解决方式。

总结

代码被执行前, JS引擎会对其进行编译

  • 首先进行全局代码的编译,创建全局执行上下文,含变量环境对象(存储var变量和函数的声明)与词法环境对象(存储let/const变量),并压入调用栈;
  • 开始按顺序执行代码
    • 如果代码中遇到变量赋值,即在全局执行上下文中重新设置变量环境对象中变量的值;
    • 如果遇到函数调用,首先从全局执行上下文中取出函数代码,然后对函数代码进行编译,创建函数的执行上下文及函数自己的变量环境和词法环境,压入调用栈,而后执行代码,代码执行结束后销毁该函数的执行上下文,出栈;
  • 执行上下文全部压入调用栈中,运行结束后销毁上下文并将执行上下文出栈。

消除递归栈溢出的方法都有哪些

  1. 尾递归优化
    如果你的编程环境支持尾递归优化(例如,某些函数式编程语言的解释器),你可以重写递归函数为尾递归形式。尾递归函数是递归调用位于函数的最后一步,没有其他操作。这样,编译器或解释器可以优化递归调用,将其转换为迭代,从而避免增加新的栈帧。
  2. 迭代替代
    将递归逻辑转换为迭代逻辑。迭代通常使用循环结构(如forwhile)来模拟递归过程,这样可以避免栈溢出,因为每次迭代不会创建新的栈帧。
  3. 分块执行
    如果你的递归算法允许,可以将递归调用分解成小块,并在事件循环中逐步执行它们。例如,使用setTimeoutsetInterval函数将递归调用分散到不同的时间点执行。
  4. 使用栈数据结构
    实现递归算法时,可以使用一个栈数据结构来模拟系统栈的行为。这样,你可以在内存中维护一个数据栈,而不是依赖系统栈的调用栈。
  5. 增加栈大小
    在某些环境中,你可以试图增加栈的大小来避免栈溢出。然而,这通常不是一个推荐的方法,因为它可能会导致其他问题,并且可能无法解决根本问题。
  6. 递归深度控制
    在某些递归算法中,你可以限制递归的最大深度。当达到最大深度时,可以切换到其他算法或返回一个默认值。
  7. 并行计算
    如果递归算法可以并行化,可以尝试使用Web Workers或其他并行计算技术来分散计算负载。
  8. 动态规划
    对于某些递归问题,可以使用动态规划(DP)技术来优化。DP通过存储中间结果来避免重复计算,这通常可以转换为迭代实现。
  9. 使用库或框架
    一些库或框架可能已经提供了递归算法的优化实现,可以直接使用这些现成的解决方案。

调用栈相关的一些常见注意事项

  1. 避免堆栈溢出
    堆栈溢出是指调用栈的大小被耗尽,通常由于无限递归或大量的函数嵌套调用引起。确保你的递归算法具有终止条件,避免无限递归。
  2. 优化递归
    如果你需要使用递归,尽量使用尾递归,因为尾递归函数的调用不会增加调用栈的深度。某些 JavaScript 引擎会对尾递归进行优化。
  3. 减少不必要的函数嵌套
    避免深层嵌套的函数调用,因为它们会增加调用栈的深度,降低性能。考虑拆分复杂函数以减少嵌套。
  4. 异步代码和事件循环
    在处理异步代码时,JavaScript 使用事件循环来管理异步操作。了解事件循环的工作原理可以帮助你编写更可靠的异步代码。
  5. 错误处理
    及时捕获和处理异常,避免未处理的异常导致堆栈溢出或应用程序崩溃。
  6. 性能优化
    深层次的函数嵌套和频繁的函数调用可能会降低性能。优化代码以减少函数调用的次数,从而提高性能。
  7. 内存管理
    调用栈中的函数调用结束后,局部变量通常会被销毁。但如果你在闭包中保留对函数作用域的引用,可能会导致内存泄漏。确保及时释放不再需要的资源。
  8. 调试和监控
    了解如何使用浏览器的开发者工具或 Node.js 的调试工具来分析调用栈,以诊断问题和监视性能。
  9. 代码结构
    良好的代码结构和模块化可以减少深层次的函数嵌套,提高代码的可维护性。