likes
comments
collection
share

深入异步编程:从回调地狱到Promise的优雅解决方案

作者站长头像
站长
· 阅读数 21

在计算机科学和软件开发领域,异步编程已成为处理并发和非阻塞操作的关键技术。无论是前端还是后端开发,理解和掌握异步编程对于构建高性能、响应迅速的应用程序至关重要。

本篇文章主要提及异步回调函数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()

让我们逐步分析这段代码的执行流程:

  1. 执行 foo() 函数,这会导致一个 setTimeout 函数调用,设置了一个延迟1000毫秒的计时器,在计时器超时后,将执行一个箭头函数 () => console.log('foo');。 但是,由于这是一个异步操作,它并不会立即执行,而是 会被添加到事件队列(宏任务队列)中,等待在未来的某个点执行。
  2. 接着执行 bar() 函数,立即输出字符串 'bar' 到控制台,因为它是一个同步操作,没有延迟。
  3. 此时,bar() 函数执行完毕,而 foo() 中的 setTimeout 由于是异步的,还没有被执行。主线程会继续执行接下来的代码,如果有的话。
  4. 当前没有其他同步代码需要执行,主线程会进入等待状态,直到下一个事件循环的周期。
  5. 一旦 setTimeout 设置的1000毫秒延迟到期,事件循环会检查是否有任何待处理的异步任务。在这个例子中,setTimeout 的回调函数会被从事件队列中取出,并在下一个事件循环周期中执行。
  6. 最终,当事件循环再次运行并且没有其他同步代码正在执行时,setTimeout 的回调函数会被执行,输出 'foo' 到控制台。

因此,最终在控制台上输出的顺序是:

bar
(等待1000毫秒)
foo

深入异步编程:从回调地狱到Promise的优雅解决方案

异步与同步的区别

在同步(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) {
                    // ... 更多的嵌套 ...
                });
            }
        });
    }
});

问题

  1. 代码可读性差:大量的嵌套使得代码难以阅读和理解,尤其是在大型项目中。
  2. 错误处理复杂:每次异步调用都需要处理错误,这可能导致错误处理逻辑分散且难以跟踪。
  3. 难以维护:嵌套过深,一旦出现问题很难排查。

解决方案

为了解决回调地狱的问题,社区发展出了几种替代方案:

  1. Promise
  2. 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()//立即输出,不依赖任何异步操作
  })

执行流程解析

  1. breakfast() 被调用时,它不会立即输出 "现在是早上"。它会返回一个Promise,这个Promise会在2秒后解析,并在解析时输出 "现在是早上" 和 "吃早饭!"。
  2. 第一个 .then 方法在 breakfast 的Promise解析后执行。由于 breakfast 的Promise会在2秒后解析,所以第一个 .then 方法中的代码将在2秒后开始执行。
  3. 在第一个 .then 方法中,控制台会输出 "吃早饭!",然后返回 lunch() 函数的执行结果。lunch() 同样返回一个Promise,这个Promise会在1秒后解析,并在解析时输出 "到中午了" 和 "吃午饭!"。
  4. 第二个 .then 方法等待 lunch 的Promise解析。由于 lunch 的Promise在 breakfast 的Promise解析后的1秒解析,所以第二个 .then 方法将在 breakfast 的Promise解析后的3秒开始执行。
  5. 在第二个 .then 方法中,控制台会输出 "吃午饭!",然后立即调用 dinner() 函数。
  6. dinner() 函数被调用并立即输出 "晚上减肥不吃了!"。

输出顺序

由于 breakfastlunch 都是异步操作,它们的输出将按照Promise解析的顺序发生:

  1. 2秒后,输出:"现在是早上"
  2. 紧接着输出:"吃早饭!"
  3. 再过1秒(即3秒后),输出:"到中午了"
  4. 紧接着输出:"吃午饭!"
  5. 立即输出:"晚上减肥不吃了!"

深入异步编程:从回调地狱到Promise的优雅解决方案

实战

 <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。最后,数据被处理并打印到控制台。

  1. then((res)=>{}) res 是 promise 中 resolve(xx) 出来的值

  2. 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的优雅解决方案

注意

  1. Promise.race不是定义在 Promise 的原型 (Promise.prototype) 上的方法,而是定义在 Promise 构造函数本身上的一个静态方法。Promise.race 可以直接通过 Promise 构造函数调用,而不需要创建一个 Promise 实例.Promise.race 是一个静态方法
  2. 如果先执行的方法有误,比如说没有使用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,你可以使用 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的优雅解决方案

在这个例子中,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,可以使用 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的优雅解决方案

注意事项

  • Promise.any() 等待任意一个输入的 Promise 解析后立即解析。
  • 如果所有输入的 Promise 都被拒绝,Promise.any() 返回的 Promise 将会被拒绝,并且拒绝的原因是一个包含所有拒绝原因的 AggregateError 对象。
  • 输入的可迭代对象至少需要包含一个 Promise,否则 Promise.any() 会抛出一个 RangeError 错误。

结语

以上就是本文的全部内容,希望对你有所帮助,感谢你的阅读!

转载自:https://juejin.cn/post/7393314542070579209
评论
请登录