深入异步编程:从回调地狱到Promise的优雅解决方案
在计算机科学和软件开发领域,异步编程已成为处理并发和非阻塞操作的关键技术。无论是前端还是后端开发,理解和掌握异步编程对于构建高性能、响应迅速的应用程序至关重要。
本篇文章主要提及异步、回调函数、Promise,暂未提及Async/Await,对于Async/Await会在今后的文章中进行讲解。
异步
异步是什么?
异步(Asynchronous) 是一种编程模式,允许程序在等待某个操作完成的同时继续执行其他任务,而不是阻塞或暂停执行直到该操作完成
为什么会有异步这个概念?
为了简化浏览器环境中的脚本和网页交互的复杂性,JavaScript被设计成单线程执行语言, 一次只能干一件事。
在浏览器里,js主要用来操作文档的对象模型(DOM),并响应用户事件(如点击,滚动等)。如果js是多线程的,那么来自不同线程对DOM的修改可能会产生冲突。
但是单线程执行也有一个明显的缺点:如果某一个任务非常耗时,它会阻塞整个执行栈,导致用户界面冻结,直到任务完成。
为了克服这一限制,js引入了异步编程的概念。
异步机制的核心
异步机制的核心在于事件循环(Event Loop) 和 回调函数(Callback)。
当一个异步操作开始时,比如发起一个网络请求,该操作会被放入一个队列中等待执行,而不是立即执行。
主线程继续执行后续代码,不会被阻塞。
当异步操作完成时,事件循环会检测到这个完成事件,并将相应的回调函数放入任务队列(Task Queue),
然后主线程会在空闲时间(就是没有其他同步代码需要执行时)从任务队列中取出这个回调函数并执行它。
示例1:
function foo() {
setTimeout(() => {//需要耗时的代码,为异步代码
console.log('foo');
}, 1000)
}
function bar() {
console.log('bar');//不需要耗时,为同步代码
}
foo()
bar()
让我们逐步分析这段代码的执行流程:
- 执行
foo()
函数,这会导致一个setTimeout
函数调用,设置了一个延迟1000毫秒的计时器,在计时器超时后,将执行一个箭头函数() => console.log('foo');
。 但是,由于这是一个异步操作,它并不会立即执行,而是 会被添加到事件队列(宏任务队列)中,等待在未来的某个点执行。 - 接着执行
bar()
函数,立即输出字符串'bar'
到控制台,因为它是一个同步操作,没有延迟。 - 此时,
bar()
函数执行完毕,而foo()
中的setTimeout
由于是异步的,还没有被执行。主线程会继续执行接下来的代码,如果有的话。 - 当前没有其他同步代码需要执行,主线程会进入等待状态,直到下一个事件循环的周期。
- 一旦
setTimeout
设置的1000毫秒延迟到期,事件循环会检查是否有任何待处理的异步任务。在这个例子中,setTimeout
的回调函数会被从事件队列中取出,并在下一个事件循环周期中执行。 - 最终,当事件循环再次运行并且没有其他同步代码正在执行时,
setTimeout
的回调函数会被执行,输出'foo'
到控制台。
因此,最终在控制台上输出的顺序是:
bar
(等待1000毫秒)
foo
异步与同步的区别
在同步(Synchronous)模式下,程序会按照代码的顺序执行,每一行代码必须等到前一行代码完成后才会执行。这意味着如果一个操作需要花费很长时间,那么整个程序都会被阻塞,直到该操作完成。
异步模式则相反,它允许程序在发出一个操作请求后立即继续执行,而不必等待该操作的完成。当该操作最终完成时,程序会通过某种机制(如回调函数、事件、Promise等)通知主程序结果。
回调函数
回调函数(Callback Functions)在异步编程中被广泛使用,尤其是在早期的JavaScript中,因为它们提供了一种在异步操作完成时执行特定代码的方式。
回调函数通常作为参数传递给另一个函数,在异步操作完成后由后者调用。
这种方式在一定程度上解决了阻塞问题,但同时也带来了一些挑战,尤其是所谓的 “回调地狱”(Callback Hell)。
回调地狱
当异步操作嵌套得非常深时,代码的可读性和可维护性会大大降低。
这种深度嵌套的回调函数被称为“回调地狱”,它看起来就像金字塔一样,每一层都是一个回调函数,等待前一个异步操作完成。
asyncOperation1(function(err, result1) {
if (err) {
console.error(err);
} else {
asyncOperation2(result1, function(err, result2) {
if (err) {
console.error(err);
} else {
asyncOperation3(result2, function(err, result3) {
// ... 更多的嵌套 ...
});
}
});
}
});
问题
- 代码可读性差:大量的嵌套使得代码难以阅读和理解,尤其是在大型项目中。
- 错误处理复杂:每次异步调用都需要处理错误,这可能导致错误处理逻辑分散且难以跟踪。
- 难以维护:嵌套过深,一旦出现问题很难排查。
解决方案
为了解决回调地狱的问题,社区发展出了几种替代方案:
- Promise
- Async/Await
Promise
Promises 提供了一种更为优雅的方式来处理异步操作,允许使用 .then()
和 .catch()
方法来链接异步操作,而无需嵌套回调函数。
示例:
function breakfast() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('现在是早上');
resolve('吃早饭!')//代表执行成功,2000ms之后调用 reject代表执行失败
}, 2000)
})
}
function lunch() {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('到中午了');
resolve('吃午饭!')
}, 1000)
})
}
function dinner() {
console.log('晚上减肥不吃了!');
}
breakfast()
.then((res) => {
console.log(res);//在 breakfast 的解析后执行,输出 "吃早饭!"
return lunch()//return给then
})
.then((res2) => {
console.log(res2);
dinner()//立即输出,不依赖任何异步操作
})
执行流程解析
- 当
breakfast()
被调用时,它不会立即输出 "现在是早上"。它会返回一个Promise,这个Promise会在2秒后解析,并在解析时输出 "现在是早上" 和 "吃早饭!"。 - 第一个
.then
方法在breakfast
的Promise解析后执行。由于breakfast
的Promise会在2秒后解析,所以第一个.then
方法中的代码将在2秒后开始执行。 - 在第一个
.then
方法中,控制台会输出 "吃早饭!",然后返回lunch()
函数的执行结果。lunch()
同样返回一个Promise,这个Promise会在1秒后解析,并在解析时输出 "到中午了" 和 "吃午饭!"。 - 第二个
.then
方法等待lunch
的Promise解析。由于lunch
的Promise在breakfast
的Promise解析后的1秒解析,所以第二个.then
方法将在breakfast
的Promise解析后的3秒开始执行。 - 在第二个
.then
方法中,控制台会输出 "吃午饭!",然后立即调用dinner()
函数。 dinner()
函数被调用并立即输出 "晚上减肥不吃了!"。
输出顺序
由于 breakfast
和 lunch
都是异步操作,它们的输出将按照Promise解析的顺序发生:
- 2秒后,输出:"现在是早上"
- 紧接着输出:"吃早饭!"
- 再过1秒(即3秒后),输出:"到中午了"
- 紧接着输出:"吃午饭!"
- 立即输出:"晚上减肥不吃了!"
实战
<button id="btn">请求数据</button>
<ul id="ul"></ul>
<script>
function getData(url) {//异步代码请求数据
let xhr = new XMLHttpRequest();
return new Promise(function (resolve, reject) {
xhr.open('GET', url, true);
xhr.send()
xhr.onreadystatechange = function () {
if (xhr.readyState == 4 && xhr.status == 200) {
let movieList = JSON.parse(xhr.responseText).movieList;
resolve(movieList)
}
}
})
}
function renderLi(arr) {//同步代码
//创建li
arr.forEach(item => {
let li = document.createElement('li');
li.innerText = item.nm
document.getElementById('ul').appendChild(li);
})
}
document.getElementById('btn').addEventListener('click', () => { fetch('https://mock.mengxuegu.com/mock/65a91543c4cd67421b34c898/movie/movieList')
.then(res => {
return res.json()
})
.then(data => {
console.log(data);
})
})
</script>
fetch
方法返回一个Promise,当请求成功时,Promise解析为一个 Response
对象。然后,使用 .json()
方法将响应体转换为JSON格式,这也返回一个Promise。最后,数据被处理并打印到控制台。
-
then((res)=>{}) res 是 promise 中 resolve(xx) 出来的值
-
catch((err)=>{}) err 是 promise 中 reject(xx) 出来的值
Promise.race()
比如说有下面的一个场景:
a函数用来获取张三户口本信息,
b函数用来获取张三身份证信息,
c函数用来接收张三的身份证号。
function a() {
return new Promise(function (resolve, reject) {
setTimeout(() => {
console.log('张三户口本');
resolve('a')
}, 1000)
})
}
function b() {
return new Promise(function (resolve, reject) {
setTimeout(() => {
console.log('张三身份证');
resolve('b')
}, 1500)
})
}
function c() {
console.log('请求张三的身份账号');
}
a函数和b函数都能得到身份证号,那咱们不就谁能更快得到就用谁嘛,那我们怎么知道谁更快呢,promise.race()就派上用场了:
Promise.race([a(), b()]).then(() => {//最快的执行完了就运行c,最快的reject就报错
c()
})
注意
Promise.race
不是定义在Promise
的原型 (Promise.prototype
) 上的方法,而是定义在Promise
构造函数本身上的一个静态方法。Promise.race
可以直接通过Promise
构造函数调用,而不需要创建一个Promise
实例.Promise.race
是一个静态方法- 如果先执行的方法有误,比如说没有使用resolve而是使用了reject,那么运行就会报错,他不会去执行相对来讲较慢的那个
调用语句
const promises = [
fetch('https://api.example.com/data1'),
fetch('https://api.example.com/data2'),
// 更多的Promise...
];
Promise.race(promises)
.then(response => {
// 处理最先完成的Promise的结果
console.log(response);
})
.catch(error => {
// 处理最先完成的Promise的错误
console.error(error);
});
Promise.all()
Promise.all()
用于处理多个 Promise。它接收一个 Promise 的可迭代对象(如数组)作为参数,并返回一个新的 Promise这个新的 Promise 在所有输入的 Promise 都解析(resolve)后才解析,解析的值是一个数组,数组中的元素是输入的 Promise 的解析值,顺序与输入的顺序相同
用张三的例子来讲就是现在这个c函数要获取户口本上的旧地址和现在身份证上的新地址 ,那就需要获得a,b两个函数的信息之后才能执行。
function c() {
console.log('请求张三的新旧地址');
}
使用示例
假设你有三个异步操作,每个操作都返回一个 Promise,你可以使用 Promise.all()
来等待所有操作完成:
const promise1 = Promise.resolve(3);
const promise2 = 42;
const promise3 = new Promise((resolve, reject) => {
setTimeout(resolve, 1500, 'foo');
});
Promise.all([promise1, promise2, promise3])
.then(values => {
console.log(values); // 输出: [3, 42, 'foo']
})
.catch(reason => {
console.log(reason); // 如果有任何一个Promise被拒绝,这里会输出拒绝的原因
});
在这个例子中,Promise.all()
等待所有 Promise 都解析后才解析。
注意
promise2
是一个同步值,会被自动包装成一个解析的 Promise。
注意事项
- 如果输入的可迭代对象中包含未解析的 Promise,
Promise.all()
返回的 Promise 会等待所有 Promise 都解析后才解析。 - 如果输入的可迭代对象中包含任何被拒绝的 Promise,
Promise.all()
返回的 Promise 会立即被拒绝,不会等待其他 Promise 完成。 - 如果输入的可迭代对象为空,
Promise.all()
返回的 Promise 会立即解析为一个空数组。
Promise.any()
知道了前两个方法之后这个any方法顾名思义,接收的函数中任意一个跑通了就执行下面的代码
Promise.any([a(), b()]).then(() => {
c();
});
使用示例
这里有三个异步操作,每个操作都返回一个 Promise,可以使用 Promise.any()
来等待任一操作成功:
const promise1 = new Promise((resolve, reject) => {
setTimeout(reject, 50, 'Failed to load resource 1');
});
const promise2 = new Promise((resolve, reject) => {
setTimeout(resolve, 100, 'Loaded resource 2');
});
const promise3 = new Promise((resolve, reject) => {
setTimeout(reject, 20, 'Failed to load resource 3');
});
Promise.any([promise1, promise2, promise3])
.then(value => {
console.log(value); // 输出: 'Loaded resource 2'
})
.catch(error => {
console.log(error); // 如果所有 Promise 都被拒绝,这里会输出 AggregateError
});
在这个例子中,Promise.any()
等待任意一个 Promise 解析后立即解析,解析的值是第一个解析的 Promise 的值。
注意事项
Promise.any()
等待任意一个输入的 Promise 解析后立即解析。- 如果所有输入的 Promise 都被拒绝,
Promise.any()
返回的 Promise 将会被拒绝,并且拒绝的原因是一个包含所有拒绝原因的AggregateError
对象。 - 输入的可迭代对象至少需要包含一个 Promise,否则
Promise.any()
会抛出一个RangeError
错误。
结语
以上就是本文的全部内容,希望对你有所帮助,感谢你的阅读!
转载自:https://juejin.cn/post/7393314542070579209