JavaScript 回调地狱
JavaScript是一种异步(非阻塞)和单线程的编程语言,意味着一次只能运行一个进程。
在编程语言中,回调地狱通常指的是使用异步调用编写代码的一种低效方式。它也被称为毁灭金字塔。
JavaScript中的回调地狱是指执行过多嵌套的回调函数的情况。它降低了代码的可读性和维护性。回调地狱的情况通常发生在处理异步请求操作时,例如进行多个API请求或处理具有复杂依赖关系的事件。
为了更好地理解JavaScript中的回调地狱,首先要了解JavaScript中的回调函数和事件循环。
JavaScript中的回调函数
JavaScript将所有东西都视为对象,例如字符串、数组和函数。因此,回调的概念允许我们将函数作为参数传递给另一个函数。回调函数将首先完成执行,然后执行父函数。
回调函数以异步方式执行,允许代码在不等待完成异步任务的情况下继续运行。当多个异步任务被组合在一起,并且每个任务都依赖于其前一个任务时,代码结构变得复杂。
让我们了解回调的使用和重要性。假设我们有一个函数,它接受三个参数,一个字符串和两个数字。我们希望根据字符串文本的多个条件获得一些输出。
考虑下面的示例:
function expectedResult(action, x, y){
if(action === "add"){
return x+y
}else if(action === "subtract"){
return x-y
}
}
console.log(expectedResult("add",20,10))
console.log(expectedResult("subtract",30,10))
输出:
30
20
以上的代码可以正常工作,但是我们需要添加更多的任务,使代码具有可扩展性。条件语句的数量也会不断增加,这将导致代码结构混乱,需要进行优化和可读性的改进。
因此,我们可以用更好的方式重新编写代码如下:
function add(x,y){
return x+y
}
function subtract(x,y){
return x-y
}
function expectedResult(callBack, x, y){
return callBack(x,y)
}
console.log(expectedResult(add, 20, 10))
console.log(expectedResult(subtract, 30, 10))
输出:
30
20
尽管如此,输出结果将是相同的。但在上面的示例中,我们定义了它的单独函数体并将函数作为回调函数传递给 expectedResult 函数。因此,如果我们想要扩展预期结果的功能,这样我们可以创建具有不同操作的另一个函数体,并将其用作回调函数,这样可以更容易理解和提高代码的可读性。
在支持的 JavaScript 特性中,还有其他不同的回调示例。一些常见的示例是事件监听器和数组函数,例如 map、reduce、filter 等。
为了更好地理解它,我们应该了解 JavaScript 的传值和传引用。
JavaScript 支持两种数据类型,即原始类型和非原始类型。原始数据类型是 undefined、null、string 和 boolean,它们是不可变的,或者我们可以说是相对不可变的;非原始数据类型是数组、函数和对象,它们是可变的。
按引用传递传递的是实体的引用地址,例如一个函数可以作为参数。因此,如果在那个函数中改变了值,它将改变在函数外部可用的原始值。
相比之下,传值的概念不会改变在函数体外部可用的原始值。而是通过使用它们的内存将值复制到两个不同的位置。JavaScript 根据它们的引用标识所有对象。
在 JavaScript 中,addEventListener 用于侦听事件,例如 click、mouseover 和 mouseout,并将第二个参数作为将在事件触发时执行的函数。此函数使用传引用的概念传递,使用不带括号的方式传递。
请考虑下面的示例;在此示例中,我们将 greet 函数作为参数传递给 addEventListener 作为回调函数。当触发 click 事件时,将调用它:
Test.html:
<!DOCTYPE html>
<html>
<head>
<title>
Javascript Callback Example
</title>
</head>
<body>
<h3>Javascript Callback</h3>
<button id='btn'>Click Here to Console</button>
<script>
const button = document.getElementById('btn');
const greet=()=>{
console.log("Hello, How are you?")
}
button.addEventListener('click', greet)
</script>
</body>
</html>
输出:
在上面的示例中,我们将一个greet函数作为参数传递到addEventListener作为回调函数。当点击事件被触发时,它会被调用。
类似地,filter也是回调函数的一个示例。如果我们使用filter来迭代一个数组,它将接受另一个回调函数作为参数来处理数组数据。考虑下面的示例;在这个示例中,我们使用greater函数来打印数组中大于5的数字。我们在filter方法中使用isGreater函数作为回调函数。
const arr = [3,10,6,7]
const isGreater = num => num > 5
console.log(arr.filter(isGreater))
输出:
[ 10, 6, 7 ]
上面的示例展示了在filter方法中将greater函数作为回调函数使用。
为了更好地理解JavaScript中的回调函数和事件循环,让我们讨论一下同步和异步JavaScript:
同步JavaScript
让我们了解一下同步编程语言具有哪些特点。同步编程具有以下特点:
阻塞执行: 同步编程语言支持阻塞执行技术,这意味着它阻塞了后续语句的执行,只有当前语句执行完毕后才会执行下一个语句。因此,它实现了语句的可预测和确定执行。
顺序流程: 同步编程支持顺序执行流程,这意味着每个语句按照顺序一个接一个地执行。语言程序在继续下一个语句之前等待当前语句完成。
简洁性: 通常情况下,同步编程被认为易于理解,因为我们可以预测其执行流程顺序。一般情况下,它是线性的,易于预测的。小型应用程序适合在这些语言上开发,因为它可以处理关键操作的顺序。
直接错误处理: 在同步编程语言中,错误处理非常容易。如果在执行语句时发生错误,它会抛出一个错误,程序可以捕获它。
简而言之,同步编程具有两个核心特征:一次只执行一个任务,只有当前任务完成后才会进行下一组任务。因此,它遵循顺序代码执行的原则。
当一个语句正在执行时,编程的行为创建了一个阻塞代码的情况,因为每个任务都必须等待上一个任务完成。
但是当人们谈论JavaScript时,是否同步还是异步总是一个令人困惑的问题。
在上述讨论的示例中,当我们在filter函数中使用函数作为回调时,它是同步执行的。因此,它被称为同步执行。filter函数必须等待greater函数完成执行。
因此,回调函数也被称为阻塞回调,因为它阻塞了调用它的父函数的执行。
主要情况下,JavaScript被认为是单线程、同步和阻塞的。但是使用一些方法,我们可以根据不同的场景使其以异步方式工作。
现在,让我们了解一下异步JavaScript。
异步JavaScript
异步编程语言侧重于提高应用程序的性能。可以在这些情况下使用回调函数。我们可以通过以下示例来分析JavaScript的异步行为:
function greet(){
console.log("greet after 1 second")
}
setTimeout(greet, 1000)
从上面的示例中可以看出,setTimeout函数接受回调函数和时间(以毫秒为单位)作为参数。该回调函数在指定的时间(这里是1秒)之后被调用。简而言之,该函数会等待1秒后执行。现在,看一下下面的代码:
function greet(){
console.log("greet after 1 second")
}
setTimeout(greet, 1000)
console.log("first")
console.log("Second")
输出:
first
Second
greet after 1 second
从上述代码中可以看出,setTimeout后面的日志消息将会先执行,而计时器将会经过。因此,结果是在一秒钟之后,然后在一秒钟时间间隔之后才会出现问候消息。
在JavaScript中,setTimeout是一个异步函数。每当我们调用setTimeout函数时,它会注册一个回调函数(在本例中是greet)以在指定的延迟之后执行。然而,它不会阻塞后续代码的执行。
在上面的示例中,日志消息是同步语句,它们会立即执行。它们不依赖于setTimeout函数。因此,它们会执行并将其各自的消息记录到控制台,而不会等待setTimeout指定的延迟。
同时,JavaScript中的事件循环处理异步任务。在这种情况下,它会等待指定的延迟(1秒)过去,然后在经过了该时间后,它会选择回调函数(greet)并执行它。
因此,setTimeout函数后面的其他代码是在后台运行时执行的。这种行为允许JavaScript在等待异步操作完成时执行其他任务。
我们需要理解调用栈和回调队列来处理JavaScript中的异步事件。
请参考下面的图片:
从上图可以看出,一个典型的JavaScript引擎由堆内存和调用栈组成。调用栈在被推入栈中时会立即执行所有代码,不需要等待。
堆内存负责在运行时为对象和函数分配内存,只要它们被需要。
现在,我们的浏览器引擎包括多个Web API,如DOM、setTimeout、console、fetch等,引擎可以使用全局窗口对象访问这些API。在下一步中,一些事件循环扮演着门卫的角色,从回调队列中选取函数请求并将它们推入栈中。这些函数,如setTimeout,需要一定的等待时间。
现在,让我们回到我们的示例,setTimeout函数;当函数被遇到时,定时器在回调队列中注册。在此之后,剩余的代码被推入调用栈并在函数达到计时器限制时执行,计时器过期后,回调队列推入具有指定逻辑并在超时函数中注册的回调函数。因此,它将在指定的时间后执行。
回调地狱场景
现在,我们已经讨论了回调函数、同步、异步以及与回调地狱相关的其他主题。让我们了解一下JavaScript中的回调地狱是什么。
当多个回调嵌套在一起时,就被称为回调地狱,因为它的代码形状看起来像一个金字塔,也被称为”末日金字塔”。
回调地狱使得代码难以理解和维护。我们在使用Node.js时通常会遇到这种情况。例如,考虑下面的示例:
getArticlesData(20, (articles) => {
console.log("article lists", articles);
getUserData(article.username, (name) => {
console.log(name);
getAddress(name, (item) => {
console.log(item);
//This goes on and on...
}
})
在上面的示例中,getUserData接受一个依赖于文章列表的用户名,或者需要从getArticles响应中提取的文章里的用户名。getAddress也有一个类似的依赖关系,依赖于getUserData的响应。这种情况被称为回调地狱。
回调地狱的内部工作方式可以通过下面的示例来理解:
让我们理解一下我们需要执行任务A的情况。要执行任务,我们需要从任务B中获取一些数据。同样地,我们有不同的任务彼此相互依赖,并以异步方式执行。因此,它创建了一系列的回调函数。
让我们了解一下JavaScript中的Promise以及它们如何创建异步操作,使我们能够避免编写嵌套的回调函数。
JavaScript promises
在JavaScript中,Promise是在ES6中引入的。它是一个具有语法外衣的对象。由于它的异步行为,它是一种避免编写异步操作的回调的替代方式。如今,像fetch()这样的Web API是使用Promise来实现的,它提供了一种有效的方法来访问服务器上的数据。它还提高了代码的可读性,是避免编写嵌套回调的一种方式。
Promise在现实生活中表示两个或多个人之间的信任,并确保某事一定会发生。在JavaScript中,Promise是一个保证在将来(当需要时)产生单个值的对象。Promise在JavaScript中用于管理和处理异步操作。
Promise返回一个确保和代表异步操作及其输出的完成或失败的对象。它是一个代表值的代理,而不知道确切的输出结果。它对于异步方法提供最终的成功值或失败原因是很有用的。因此,异步方法像同步方法一样返回值。
通常,Promise有以下三种状态:
- 已完成:已完成状态是当应用的操作已经成功解决或完成时。
- 待定:待定状态是当请求正在处理中,应用的操作既没有被解决也没有被拒绝,仍处于初始状态。
- 拒绝:拒绝状态是当应用的操作已被拒绝,导致所需操作失败。拒绝的原因可以是任何原因,包括服务器宕机。
Promise的语法:
let newPromise = new Promise(function(resolve, reject) {
// asynchronous call is made
//Resolve or reject the data
});
以下是写承诺的示例:
这是写承诺的一个示例。
function getArticleData(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log("Fetching data....");
resolve({ id: id, name: "derik" });
}, 5000);
});
}
getArticleData("10").then(res=> console.log(res))
在上面的示例中,我们可以看到如何高效地使用 promises 来从服务器发送请求。我们可以观察到,与回调函数相比,上面的代码可读性更高。Promise 提供了 .then() 和 .catch() 这样的方法,使我们能够在成功或失败的情况下处理操作状态。我们可以为 promise 的不同状态指定处理方式。
JavaScript 中的 Async/Await
它是另一种避免使用嵌套回调函数的方法。Async/Await 允许我们更高效地使用 promises。我们可以避免使用 .then() 或 .catch() 方法链式调用。这些方法也依赖于回调函数。 Async/Await 可以精确地与 Promise 一起使用,从而提高应用程序的性能。它内部解决了 promises 并提供结果。而且,它比 .then() 或 .catch() 方法更易读。 我们不能将 Async/Await 与普通的回调函数一起使用。要使用它,我们必须在 function 关键字之前写上 async 关键字来使函数异步化。然而,内部它也使用了链式调用。 以下是一个 Async/Await 的示例:
async function displayData() {
try {
const articleData = await getArticle(10);
const placeData = await getPlaces(article.name);
const cityData = await getCity(place)
console.log(city);
} catch (err) {
console.log("Error: ", err.message);
}
}
displayData();
要使用Async/Await,函数必须使用async关键字声明,并且await关键字应写在函数内部。在Promise被解决或拒绝之前,async函数的执行将停止。当Promise被处理时,它将恢复执行。一旦被解决,await表达式的值将存储在持有它的变量中。
总结:简而言之,我们可以通过使用promises和async/await来避免嵌套回调。除此之外,我们还可以采用其他方法,如编写注释,将代码拆分为单独的组件也会有帮助。但是,如今的开发人员更倾向于使用async/await。
结论
JavaScript中的回调地狱指的是执行过多的嵌套回调函数的情况。它降低了代码的可读性和可维护性。回调地狱通常发生在处理异步请求操作时,如进行多个API请求或处理具有复杂依赖关系的事件。
为了更好地理解JavaScript中的回调地狱。
JavaScript将一切都视为对象,如字符串、数组和函数。因此,回调的概念允许我们将函数作为参数传递给另一个函数。回调函数将首先完成执行,然后执行父函数。
回调函数以异步方式执行,允许代码继续运行而无需等待完成异步任务。