KJ_28头像

取餐小票到等待叫号--从Promise到async/await

JavaScript 异步编程,从 Promise 到 async/await

一、为什么需要异步编程?

在之前的学习中,我们已经了解了 REST API 在 KK Chat 中的作用,以及前端如何通过 HTTP 请求与 FastAPI 后端进行通信。

当用户发送一条消息时,前端需要把消息发送给后端,后端再调用大模型,最后将生成的回复返回给前端。

整个过程大致如下:

用户输入消息
    |
    v
前端发送 HTTP 请求
    |
    v
FastAPI 接收请求
    |
    v
后端调用大模型
    |
    v
等待模型生成回复
    |
    v
后端返回响应
    |
    v
前端展示回复

这里有一个问题:大模型的回复并不会立刻返回,前端需要等待一段时间。

如果程序必须等到每个任务完全结束后才能处理其他事情,那么等待网络响应就可能影响用户的操作体验。

因此,我们需要了解 JavaScript 中的异步编程。

1. 同步与异步

先来看一段简单的代码:

console.log("开始");
console.log("执行任务");
console.log("结束");

这段代码会按照顺序执行:

开始
  |
  v
执行任务
  |
  v
结束

这就是同步执行的基本表现:前面的语句执行完毕后,才会执行后面的语句。

而异步操作通常涉及一些完成时间不确定的任务,例如网络请求和定时器。

console.log("开始");

setTimeout(() => {
    console.log("异步任务完成");
}, 2000);

console.log("结束");

输出结果是:

开始
结束
异步任务完成

setTimeout() 设置了一个定时回调。程序不会一直停在这里等待两秒,而是继续执行后面的代码,回调会在计时达到指定延迟后进入后续执行流程。

需要注意,这个例子并不是说所有异步操作都以同一种方式运行,而是帮助我们理解:异步操作可以让程序在等待某个结果时,继续处理其他任务。

对于 KK Chat 来说,这意味着前端等待模型回复时,浏览器仍然可以处理其他操作,而不必让整个页面停下来。

那么,异步操作还没有完成时,我们如何表示它未来的结果呢?

这就引出了今天的第一个核心概念:Promise。

二、Promise 是什么?

1. 用餐厅取餐小票理解 Promise

我们可以把 Promise 类比成餐厅的取餐小票。

假设你去餐厅点了一碗牛肉面。

下单之后,厨房还没有把面做好,但店员会立刻给你一张取餐小票。

你拿到小票时,并不代表面已经做好了,而是代表这笔订单已经建立。之后,你可以根据订单的处理结果取餐,或者处理无法出餐的情况。

Promise 也是类似的。

const ticket = orderFood("牛肉面");

这里的 orderFood() 是一个假设的点餐函数,它返回一个 Promise。

调用之后,你立刻拿到 ticket,但真正的结果可能还没有出来。

对应关系如下:

餐厅                         JavaScript

点餐                         发起异步操作
  |                             |
  v                             v
拿到取餐小票                 获得 Promise
  |                             |
  v                             v
等待厨房制作                 等待异步结果
  |                             |
  v                             v
做好面 / 无法出餐            操作成功 / 操作失败

Promise 就像取餐小票:现在还没有结果,但它代表一个未来会产生结果的异步操作。

严格来说,Promise 并不是异步任务本身,而是一个用于表示异步操作最终结果及其状态的对象。

小票不会自己做饭,真正制作食物的是厨房;同样,Promise 也不会自动执行所有异步工作,它主要负责表示结果以及让程序处理这个结果。

2. Promise 的三种状态

Promise 有三种状态:

状态餐厅类比含义
pending厨房正在做面结果尚未确定
fulfilled牛肉面做好了操作成功完成
rejected食材不足,无法出餐操作失败

Promise 的状态转换可以表示为:

             操作成功
pending -----------------> fulfilled
   |
   | 操作失败
   v
rejected

其中,fulfilled 和 rejected 都属于已经敲定的状态。

Promise 一旦从 pending 进入这两种状态之一,就不会再变回 pending,也不会从成功变成失败,或者从失败变成成功。

这就像一张已经完成或取消的订单,不会重新变成一张正在处理的订单。

3. 如何创建一个 Promise?

前面我们使用了一个假设的 orderFood() 函数。现在来看,JavaScript 中如何手动创建 Promise。

const ticket = new Promise((resolve, reject) => {
    const success = true;

    if (success) {
        resolve("牛肉面做好了");
    } else {
        reject("食材不足,无法出餐");
    }
});

这里出现了两个新名字:resolve 和 reject。

  • resolve:用于兑现 Promise,表示操作成功完成。
  • reject:用于拒绝 Promise,表示操作失败。

可以这样理解:

创建 Promise
     |
     v
执行传入的函数
     |
     v
判断操作结果
   /       \
成功       失败
 |           |
 v           v
resolve()   reject()
 |           |
 v           v
fulfilled   rejected

这里有一个容易忽略的知识点:传入 new Promise() 的执行函数会立即执行。

例如,上面的 success 判断会在创建 Promise 时执行,而不是等到以后调用 .then() 才执行。

实际项目中,我们并不需要手动为每一个异步操作创建 Promise。像 fetch() 这样的 Web API,本身就会返回 Promise。

三、Promise 的常用方法

理解了 Promise 的含义和状态之后,我们还需要解决两个问题:

  1. 操作成功后,如何处理结果?
  2. 操作失败后,如何处理错误?

此外,如果存在多个异步任务,又应该如何协调它们?

1. .then():处理成功结果

回到餐厅的例子。

牛肉面做好后,我们需要拿到面,再进行后续操作。

ticket.then(food => {
    console.log(food);
});

这里的 .then() 用于注册一个回调函数。当 Promise 成功兑现时,回调就可以接收到对应的结果。

在这个例子中,输出内容为:

牛肉面做好了

如果还有后续操作,也可以继续连接:

ticket
    .then(food => {
        console.log(food);
        return "准备开吃";
    })
    .then(message => {
        console.log(message);
    });

执行逻辑如下:

Promise 成功
     |
     v
第一个 then 接收结果
     |
     v
返回新的结果
     |
     v
第二个 then 接收新结果

这里需要记住一个重要知识点:

.then() 会返回一个新的 Promise。

因此,我们可以将多个处理步骤连接起来,形成 Promise 链。

如果回调返回普通值,新 Promise 会以这个值成功兑现;如果回调返回另一个 Promise,后续链条会等待它的结果。

2. .catch():处理异常

如果厨房发现食材不足,牛肉面无法制作,我们就不能继续正常取餐。

这时可以使用 .catch():

ticket
    .then(food => {
        console.log("取餐成功:", food);
    })
    .catch(error => {
        console.error("取餐失败:", error);
    });

可以把 .catch() 理解成餐厅的异常处理流程:订单出现问题时,进入对应的处理环节。

在 JavaScript 中,.catch() 可以处理 Promise 链中传递下来的拒绝状态,以及前面回调中抛出的异常。

在 KK Chat 中,这种机制同样重要。

例如,网络请求失败、后端服务异常,都可能导致消息无法正常返回。我们需要处理这些情况,而不是让用户一直等待。

3. Promise.all():等待多个任务全部成功

假设你和朋友一起吃饭,分别点了牛肉面、饮料和小菜。

你们希望等所有东西都准备好,再一起开吃。

可以使用 Promise.all():

Promise.all([
    orderFood("牛肉面"),
    orderFood("饮料"),
    orderFood("小菜")
]).then(([noodle, drink, dish]) => {
    console.log(noodle, drink, dish);
});

Promise.all() 接收一组 Promise,并返回一个新的 Promise。

当所有传入的 Promise 都成功完成时,它会将结果按照原来的顺序组合成一个数组。

如果其中任何一个 Promise 被拒绝,Promise.all() 返回的 Promise 就会被拒绝。

可以理解为:

牛肉面订单 ----\
饮料订单 -------+----> Promise.all()
小菜订单 -------/          |
                           v
                    全部成功后一起开吃

这里需要注意,Promise.all() 不会自动取消其他仍在进行的任务。

4. Promise.race():采用最先敲定的结果

假设你同时向两家餐厅下单,想采用最先返回结果的那一家。

Promise.race([
    orderFood("A店牛肉面"),
    orderFood("B店牛肉面")
]).then(food => {
    console.log("最先成功的结果:", food);
}).catch(error => {
    console.error("最先敲定的结果是失败:", error);
});

Promise.race() 会采用最先敲定的 Promise 的结果。

这里的“敲定”既包括成功,也包括失败。因此,如果最先完成的是一个失败的 Promise,整个 Promise.race() 也会失败。

它和 Promise.all() 的区别是:

  • Promise.all():等待所有 Promise 成功。
  • Promise.race():采用最先敲定的 Promise 的结果。

到这里,我们已经掌握了 Promise 的基本含义、状态和常见方法。

但还有一个问题:如果代码中存在很多连续的异步操作,不断使用 .then(),代码可能会越来越长。

有没有更直观的写法?

这就引出了 async/await。

四、为什么需要 async/await?

1. 从 Promise 链式调用说起

先来看一段使用 .then() 的代码:

fetch("/chat")
    .then(response => response.json())
    .then(data => {
        console.log(data.reply);
    })
    .catch(error => {
        console.error("请求失败:", error);
    });

这段代码的执行逻辑是:

发送请求
   |
   v
获得 HTTP 响应
   |
   v
解析 JSON
   |
   v
读取回复
   |
   v
处理异常

代码本身没有问题,但当异步操作变多,或者需要在每个步骤之间加入更多逻辑时,Promise 链可能变得不够直观。

因此,JavaScript 提供了 async/await,让我们可以用更接近顺序执行的形式编写异步代码。

2. async:声明异步函数

例如:

async function sendMessage() {
    console.log("发送消息");
}

这里的 async 表示 sendMessage() 是一个异步函数。

调用异步函数时,它会返回一个 Promise。

即使函数中没有显式写 return,也会返回一个成功兑现为 undefined 的 Promise。

3. await:等待 Promise 的结果

我们继续用餐厅的例子:

async function lunch() {
    const food = await orderFood("牛肉面");
    console.log(food);
}

这里的 await 就像你拿着取餐小票,在柜台前等着取餐。

当 Promise 成功兑现后,food 就会获得对应的结果,然后程序继续执行后面的代码。

这里需要注意:

await 暂停的是当前异步函数后续代码的执行,不是整个浏览器或整个 JavaScript 环境。

就像你在柜台等牛肉面,店员仍然可以服务其他顾客,厨房仍然可以制作其他订单。

因此,异步等待不会自动阻塞整个页面。

await 通常用于 async 函数内部;现代 JavaScript 模块也支持在模块顶层使用 await。

4. 串行等待与并发执行

假设你想点一碗牛肉面和一杯饮料。

如果这样写:

async function lunch() {
    const noodle = await orderFood("牛肉面");
    const drink = await orderFood("饮料");

    console.log(noodle, drink);
}

执行逻辑是:

下单牛肉面
    |
    v
等待牛肉面完成
    |
    v
下单饮料
    |
    v
等待饮料完成
    |
    v
一起开吃

如果饮料订单并不依赖牛肉面订单,就没有必要等牛肉面做好后才开始处理饮料。

可以改成:

async function lunch() {
    const noodlePromise = orderFood("牛肉面");
    const drinkPromise = orderFood("饮料");

    const [noodle, drink] = await Promise.all([
        noodlePromise,
        drinkPromise
    ]);

    console.log(noodle, drink);
}

这段代码先发起两个操作,再等待它们全部成功。

可以简单记住:

  • 任务之间存在依赖关系时,可以按顺序 await。
  • 任务之间没有依赖关系时,可以先发起多个操作,再通过 Promise.all() 一起等待。

这里说的“并发”是指多个异步操作可以在等待期间同时处于进行中,并不意味着 JavaScript 必然为每个操作创建独立线程。

五、在 KK Chat 中使用 async/await

现在回到我们的项目。

在 KK Chat 中,前端需要把用户输入的消息发送给 FastAPI 后端,并等待后端返回结果。

下面是一段简化的示例:

async function sendMessage() {
    try {
        const response = await fetch("/chat", {
            method: "POST",
            headers: {
                "Content-Type": "application/json"
            },
            body: JSON.stringify({
                message: userInput
            })
        });

        if (!response.ok) {
            throw new Error(`HTTP 错误:${response.status}`);
        }

        const data = await response.json();

        console.log(data.reply);

    } catch (error) {
        console.error("发送消息失败:", error);
    }
}

这里不再重复介绍 fetch() 如何发送 HTTP 请求,而是重点看 await 和 Promise 在其中的作用。

1. 第一个 await:等待服务器响应

const response = await fetch("/chat", {
    method: "POST",
    headers: {
        "Content-Type": "application/json"
    },
    body: JSON.stringify({
        message: userInput
    })
});

fetch() 会返回一个 Promise。

可以把它想象成:我们已经向厨房下单,拿到了取餐小票,现在等待订单处理结果。

当 HTTP 响应到达后,response 保存的是一个 Response 对象。

它包含 HTTP 状态码、响应头以及读取响应内容的方法。

2. 第二个 await:等待解析 JSON

const data = await response.json();

response.json() 同样会返回一个 Promise。

因此,我们再次使用 await,等待响应体被读取并解析为 JavaScript 对象。

假设后端返回:

{
    "reply": "你好,我是 KK Chat。"
}

解析后,就可以通过:

console.log(data.reply);

获取模型的回复。

整个过程如下:

发送 HTTP 请求
    |
    v
await fetch()
    |
    v
获得 Response 对象
    |
    v
await response.json()
    |
    v
获得 JavaScript 对象
    |
    v
读取 data.reply
    |
    v
展示模型回复

3. try/catch:处理请求异常

在代码中,我们还使用了:

try {
    // 异步操作
} catch (error) {
    console.error("发送消息失败:", error);
}

这是为了处理请求过程中可能出现的异常。

需要注意,fetch() 遇到 HTTP 404、500 等错误状态码时,通常仍会正常返回 Response 对象,并不会自动进入 catch。

因此,代码中还加入了:

if (!response.ok) {
    throw new Error(`HTTP 错误:${response.status}`);
}

通过 response.ok 判断 HTTP 响应状态是否成功。如果状态不成功,就主动抛出异常,让程序进入 catch。

这也是实际开发中很重要的一点:网络请求成功返回响应,不代表 HTTP 请求一定成功。

另外,这段示例假设后端返回完整的 JSON 响应。如果 KK Chat 使用的是流式输出,就需要另外读取响应流,不能直接用 response.json() 处理整段流式回复。

六、总结

今天,我们从 KK Chat 的前后端通信场景出发,学习了 JavaScript 异步编程的基础知识。

知识点作用
异步操作处理完成时间不确定的任务
Promise表示异步操作的结果及状态
resolve() / reject()兑现或拒绝 Promise
.then()处理成功结果并连接后续操作
.catch()处理 Promise 链中的异常
Promise.all()等待多个 Promise 全部成功
Promise.race()采用最先敲定的 Promise 结果
async声明异步函数
await等待 Promise 的结果
try/catch捕获并处理异常

如果还是觉得 Promise 抽象,可以记住今天的餐厅类比:

Promise 就像取餐小票:下单后立刻拿到小票,代表一个未来会产生结果的订单。

pending 是厨房正在制作,fulfilled 是订单成功完成,rejected 是订单失败。

.then() 是成功后继续取餐和处理,.catch() 是处理订单异常。

Promise.all() 是等待所有订单成功后一起开吃,Promise.race() 是采用最先敲定的订单结果。

async/await 则像拿着小票等叫号:代码写起来像按顺序执行,但你等待的时候,整个餐厅不会停下来。

回到 KK Chat,我们已经了解了前端如何通过 Promise 表示异步操作的结果,以及如何利用 async/await 更直观地处理异步响应。


KJ_28

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/KJ_28/article/details/168439382

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--