JavaScript 中的回调地狱 (Callback Hell)

2025年3月18日 | 阅读 11 分钟

JavaScript 是一种异步(非阻塞)的单线程编程语言,这意味着一次只能运行一个进程。

在编程语言中,回调地狱通常指一种使用异步调用编写代码的低效方式。它也称为“金字塔的诅咒”。

JavaScript 中的回调地狱指的是一个过度嵌套回调函数被执行的情况。它降低了代码的可读性和可维护性。当处理异步请求操作时,例如进行多个 API 请求或处理具有复杂依赖关系的事件时,通常会出现回调地狱的情况。

为了更好地理解 JavaScript 中的回调地狱,首先需要了解 JavaScript 中的回调和事件循环。

JavaScript 中的回调

JavaScript 将所有内容都视为对象,例如字符串、数组和函数。因此,回调的概念允许我们将函数作为参数传递给另一个函数。回调函数会先完成执行,然后父函数才会执行。

回调函数是异步执行的,它允许代码继续运行,而无需等待异步任务完成。当多个异步任务组合在一起,并且每个任务都依赖于前一个任务时,代码结构就会变得复杂。

让我们来理解回调的用途和重要性。假设我们有一个函数,它接受三个参数:一个字符串和两个数字。我们希望根据具有多个条件的字符串文本得到一些输出。

考虑下面的示例

输出

30
20

上面的代码可以正常工作,但我们需要添加更多任务才能使代码具有可扩展性。条件语句的数量也会不断增加,这将导致代码结构混乱,需要进行优化和提高可读性。

所以,我们可以用更好的方式重写代码,如下所示:

输出

30
20

输出仍然是相同的。但在上面的例子中,我们定义了单独的函数体,并将函数作为回调函数传递给 expectedResult 函数。因此,如果我们想扩展 expected results 的功能,我们可以创建另一个具有不同操作的函数体,并将其用作回调函数,这样更容易理解并提高代码的可读性。

在支持的 JavaScript 功能中,还有其他不同的回调示例。一些常见的例子是事件监听器,以及 map、reduce、filter 等数组函数。

为了更好地理解这一点,我们应该了解 JavaScript 的值传递和引用传递。

JavaScript 支持两种数据类型:原始类型和非原始类型。原始数据类型包括 undefined、null、string 和 boolean,它们不能被更改,或者说相对于而言是不可变的;非原始数据类型包括数组、函数和对象,它们可以被更改,即是可变的。

引用传递传递实体的引用地址,例如函数可以作为参数。因此,如果函数内部的值被更改,它将改变函数外部的原始值。

相比之下,值传递的概念不会改变函数体外部的原始值。相反,它会通过使用它们的内存将值复制到两个不同的位置。JavaScript 通过引用来识别所有对象。

在 JavaScript 中,addEventListener 监听 click、mouseover 和 mouseout 等事件,并将第二个参数作为函数,该函数将在事件触发后执行。此函数使用引用传递概念,并使用不带括号的方式传递。

考虑以下示例;在此示例中,我们将 greet 函数作为参数传递给 addEventListener 作为回调函数。当触发 click 事件时,将调用它。

Test.html

输出

Callback Hell in JavaScript

在上面的示例中,我们将 greet 函数作为参数传递给 addEventListener 作为回调函数。当触发 click 事件时,将调用它。

类似地,filter 也是回调函数的一个例子。如果使用 filter 来迭代数组,它将接受另一个回调函数作为参数来处理数组数据。考虑以下示例;在此示例中,我们使用 greater 函数来打印数组中大于 5 的数字。我们在 filter 方法中使用 isGreater 函数作为回调函数。

输出

[ 10, 6, 7 ]

上面的示例显示 greater 函数在 filter 方法中用作回调函数。

为了更好地理解 JavaScript 中的回调和事件循环,让我们讨论同步和异步 JavaScript。

同步 JavaScript

让我们了解同步编程语言的特点。同步编程具有以下特点:

阻塞执行:同步编程语言支持阻塞执行技术,这意味着它会阻塞后续语句的执行,而现有语句将被执行。因此,它实现了语句的可预测和确定性执行。

顺序流程:同步编程支持顺序执行流程,这意味着每个语句都按顺序执行,一个接一个。语言程序会等待一个语句完成后再进行下一个。

简单性:通常,同步编程被认为易于理解,因为我们可以预测其执行流程的顺序。通常,它是线性的,易于预测。小应用程序在此类语言上开发效果很好,因为它们可以处理关键的操作顺序。

直接错误处理:在同步编程语言中,错误处理非常容易。如果某个语句在执行过程中发生错误,它会抛出一个错误,程序可以捕获它。

总而言之,同步编程有两个核心特征,即一次只执行一个任务,并且只有在当前任务完成后才会处理下一组后续任务。从而,它遵循顺序代码执行。

这种编程行为,当一个语句正在执行时,语言会产生一个阻塞代码的情况,因为每个任务都必须等待前一个任务完成。

但是,当人们谈论 JavaScript 时,它是否是同步的还是异步的,一直是一个令人费解的答案。

在上面讨论的示例中,当我们将一个函数用作 filter 函数中的回调时,它是同步执行的。因此,它被称为同步执行。filter 函数必须等待 greater 函数完成其执行。

因此,回调函数也称为阻塞回调,因为它会阻塞调用它的父函数的执行。

主要是,JavaScript 被认为是单线程同步且阻塞的。但是,使用一些方法,我们可以使其在不同场景下异步工作。

现在,让我们了解异步 JavaScript。

异步 JavaScript

异步编程语言专注于提高应用程序的性能。在这些场景中可以使用回调。我们可以通过以下示例分析 JavaScript 的异步行为:

从上面的示例中,setTimeout 函数以回调和时间(以毫秒为单位)作为参数。回调将在指定时间(此处为 1 秒)后被调用。总而言之,该函数将等待 1 秒执行。现在,看看下面的代码:

输出

first
Second
greet after 1 second

从上面的代码来看,setTimeout 之后的 log 消息将首先被执行,而计时器则会经过。因此,它会花费一秒钟,然后在 1 秒的时间间隔后出现问候消息。

在 JavaScript 中,setTimeout 是一个异步函数。每当我们调用 setTimeout 函数时,它会注册一个回调函数(此处为 greet),以便在指定的延迟后执行。但是,它不会阻塞后续代码的执行。

在上面的示例中,log 消息是同步语句,会立即执行。它们不依赖于 setTimeout 函数。因此,它们会执行并将各自的消息记录到控制台,而无需等待 setTimeout 中指定的延迟。

同时,JavaScript 中的事件循环会处理异步任务。在这种情况下,它会等待指定的延迟(1 秒)过去,并且在该时间过去后,它会获取回调函数(greet)并执行它。

因此,setTimeout 函数之后的其他代码在后台运行时也在执行。这种行为允许 JavaScript 在等待异步操作完成的同时执行其他任务。

我们需要了解调用堆栈和回调队列才能处理 JavaScript 中的异步事件。

考虑下图

Callback Hell in JavaScript

从上图可以看出,典型的 JavaScript 引擎由堆内存和调用堆栈组成。调用堆栈在推送到堆栈时会执行所有代码,而不会等待。

堆内存负责在运行时根据需要为对象和函数分配内存。

现在,我们的浏览器引擎包含多个 Web API,如 DOM、setTimeout、console、fetch 等,引擎可以通过全局 window 对象访问这些 API。在下一步,一些事件循环充当守门员,它从回调队列中获取函数请求并将它们推送到堆栈。这些函数,例如 setTimeout,需要一定的等待时间。

现在,回到我们的示例 setTimeout 函数;当遇到函数时,计时器会在回调队列中注册。之后,其余代码被推送到调用堆栈并执行。一旦函数达到其计时器限制,它就会过期,回调队列会推送具有指定逻辑并注册在超时函数中的回调函数。因此,它将在指定时间后执行。

回调地狱场景

现在,我们已经讨论了回调、同步、异步以及与回调地狱相关的其他主题。让我们了解 JavaScript 中的回调地狱是什么。

当多个回调嵌套在一起时,这种情况被称为回调地狱,因为它的代码形状看起来像一个金字塔,也被称为“金字塔的诅咒”。

回调地狱使代码更难理解和维护。我们通常在 Node.js 中工作时会看到这种情况。例如,考虑以下示例:

在上面的示例中,getUserData 接受一个用户名,该用户名依赖于 article list 或需要从 article 中的 getArticles 响应中提取。getAddress 也有类似的依赖关系,它依赖于 getUserData 的响应。这种情况称为回调地狱。

回调地狱的内部工作可以通过以下示例来理解:

让我们理解我们需要执行任务 A。为了执行任务 A,我们需要从任务 B 获取一些数据。类似地;我们有不同的任务,它们相互依赖并异步执行。因此,它会创建一系列回调函数。

让我们了解 JavaScript 中的 Promise,以及它们如何创建异步操作,从而允许我们避免编写嵌套回调。

JavaScript Promise

在 JavaScript 中,Promise 在 ES6 中引入。它是一个带有语法封装的对象。由于其异步行为,它是避免为异步操作编写回调的另一种方式。如今,fetch() 等 Web API 是使用 Promise 实现的,它提供了访问服务器数据的有效方法。它还提高了代码的可读性,并且是避免编写嵌套回调的一种方式。

现实生活中的 Promise 表示两个人或更多人之间的信任,并保证某事一定会发生。在 JavaScript 中,Promise 是一个对象,它确保在未来(需要时)产生一个单一值。JavaScript 中的 Promise 用于管理和处理异步操作。

Promise 返回一个对象,该对象确保并表示异步操作的完成或失败及其输出。它是值的代理,而无需知道确切的输出。它对于 异步 操作很有用,可以提供最终的成功值或失败原因。因此,异步方法像同步方法一样返回。值。

一般来说,Promise 有以下三种状态:

  • 已fulfilled:已fulfilled 状态是指已应用的操作已成功解析或完成。
  • Pending:Pending 状态是指请求正在处理中,已应用的操作既未解析也未被拒绝,仍处于初始状态。
  • Rejected:Rejected 状态是指已应用的操作已被拒绝,导致所需的操作失败。拒绝的原因可以是任何事,包括服务器停机。

Promise 的语法

以下是编写 Promise 的示例:

这是一个编写 Promise 的示例。

在上面的示例中,我们可以看到如何有效地使用 Promise 从服务器发起请求。我们可以观察到,与回调相比,上面的代码可读性得到了提高。Promise 提供了 .then() 和 .catch() 等方法,允许我们在成功或失败的情况下处理操作状态。我们可以为 Promise 的不同状态指定情况。

JavaScript 中的 Async/Await

这是另一种避免使用嵌套回调的方法。Async/Await 允许我们更有效地使用 Promise。我们可以避免使用 .then() 或 .catch() 方法链。这些方法也依赖于回调函数。

Async/Await 可以与 Promise 精确地一起使用以提高应用程序的性能。它内部解析 Promise 并提供结果。而且,它比 () 或 catch() 方法更具可读性。

我们不能将 Async/Await 与普通的回调函数一起使用。要使用它,我们必须通过在 function 关键字之前编写 async 关键字来使函数异步。但是,它内部也使用链式调用。

以下是 Async/Await 的示例:

要使用 Async/Await,必须使用 async 关键字指定函数,并在函数内部编写 await 关键字。async 将停止其执行,直到 Promise 被解析或拒绝。一旦 Promise 被处理,它就会恢复。解析后,await 表达式的值将被存储在持有它的变量中。

总结

总而言之,我们可以通过使用 Promise 和 async/await 来避免嵌套回调。除此之外,我们还可以遵循其他方法,例如写注释,将代码拆分成单独的组件也可能很有帮助。但是,如今,开发人员更倾向于使用 async/await。

结论

JavaScript 中的回调地狱指的是一个过度嵌套回调函数被执行的情况。它降低了代码的可读性和可维护性。当处理异步请求操作时,例如进行多个 API 请求或处理具有复杂依赖关系的事件时,通常会出现回调地狱的情况。

为了更好地理解 JavaScript 中的回调地狱。

JavaScript 将所有内容都视为对象,例如字符串、数组和函数。因此,回调的概念允许我们将函数作为参数传递给另一个函数。回调函数会先完成执行,然后父函数才会执行。

回调函数是异步执行的,它允许代码继续运行,而无需等待异步任务完成。