阅读视图

发现新文章,点击刷新页面。

TS异步编程

Gemini生成

第一部分:核心概念 (Why & What)

在编程世界里,代码的执行方式主要分两种:同步 (Synchronous)异步 (Asynchronous)

1. 同步 (Synchronous) —— “死心眼的排队者”

概念: 代码从上到下,一行一行执行。上一行代码没有执行完,下一行代码绝对不会开始。

生活类比: 想象你在银行柜台办理业务。

  1. 你前面有一个人正在办业务(代码行 A)。
  2. 不管他办得有多慢,你(代码行 B)都只能在后面干站着等。
  3. 你不能玩手机,不能去上厕所,只能阻塞 (Block) 在那里,直到他结束。

代码表现:

console.log("1. 开始点餐");
alert("我是同步的弹窗,我不关掉,你什么都做不了!"); // 这里会卡住
console.log("2. 吃饭");

如果不点击弹窗的确定,"2. 吃饭" 永远不会打印出来。这就是“阻塞”。


2. 异步 (Asynchronous) —— “拿着取餐器的食客”

概念: 遇到耗时的任务(比如从网络下载图片、读取文件),程序不会傻等,而是把任务交给“别人”(浏览器或操作系统)去处理,自己继续往下执行后面的代码。等耗时任务做完了,再通知程序回来处理结果。

生活类比: 想象你在奶茶店点单。

  1. 你点了一杯制作很复杂的奶茶(耗时任务)。
  2. 店员没有让你站在柜台前盯着他做,而是给了你一个取餐器(回调/Promise),然后说:“你先去旁边坐着玩手机,好了震动叫你。”
  3. 你找个位置坐下(继续执行后续代码)。
  4. 过了一会儿,奶茶好了,取餐器震动,你去拿奶茶(处理异步结果)。

代码表现:

console.log("1. 点单:我要一杯奶茶");

// 这是一个模拟异步的函数,假设需要 2 秒钟
setTimeout(() => {
    console.log("3. 奶茶好了!(这是异步回来的结果)");
}, 2000);

console.log("2. 找个位置坐下玩手机");

控制台的打印顺序是:

  1. 1. 点单...
  2. 2. 找个位置... (注意:这里直接跳过了等待,先执行了!)
  3. (过了2秒后) 3. 奶茶好了...

3. 为什么 JavaScript/TypeScript 必须要有异步?

你可能会问:“同步多简单啊,逻辑清晰,为什么要搞这么复杂的异步?”

这和 JS 的出身有关:

  1. 单线程 (Single Thread):JavaScript(以及编译后的 TS)是单线程的。也就是说,它只有一个“大脑”,同一时间只能做一件事。它不像 Java 或 C++ 那样可以开启多条线程同时工作。
  2. 浏览器的体验
    • 假设你打开一个网页,它需要去服务器请求“用户列表”。
    • 如果使用同步:在数据请求回来的这 1-2 秒内,网页会完全卡死。你点击按钮没反应,滚动条滚不动,甚至无法关闭网页。这对用户体验是灾难性的。
    • 如果使用异步:请求发出去后,浏览器继续响应你的鼠标点击和滚动,等数据回来了,再悄悄把列表渲染到屏幕上。

总结第一部分:

  • 同步 = 顺序执行,会卡住(阻塞)。
  • 异步 = 不等待,继续往下走,回头再处理结果。
  • TS/JS 的特性 = 单线程,为了不让网页/程序卡死,必须大量使用异步。

第二部分:异步的演进史 (History)

JavaScript/TypeScript 的异步演进史,其实就是一部与“代码可读性”抗争的历史。我们的目标始终未变:让异步代码看起来像同步代码一样简单易懂。

我们分三个阶段来讲:


1. 第一阶段:上古时代 —— 回调函数 (Callback)

在 Promise 出现之前(大约是 2015 年 ES6 标准发布前),我们处理异步只有一种办法:回调函数

什么是回调? 简单来说,就是你定义一个函数,但你自己不调用它,而是把它作为参数传给另一个函数(比如网络请求函数)。你告诉对方:“等你做完你的事,回头(Call back)调用一下我这个函数,把结果传给我。”

场景模拟: 我们要去数据库获取用户信息。

// 定义一个回调函数的类型:接收 string 类型的数据,没有返回值
type MyCallback = (data: string) => void;

function getUserData(callback: MyCallback) {
    console.log("1. 开始向服务器请求数据...");
    
    // 模拟耗时 1 秒
    setTimeout(() => {
        console.log("2. 服务器返回数据了");
        const data = "张三";
        // 关键点:任务做完后,手动调用传进来的函数
        callback(data); 
    }, 1000);
}

// 使用
getUserData((name) => {
    console.log(`3. 拿到用户名:${name}`);
});

问题在哪里? 如果是这一层简单的调用,看起来还不错。但现实往往很残酷。


2.第二阶段:黑暗时代 —— 回调地狱 (Callback Hell)

场景升级: 现在的业务逻辑变成了这样,必须严格按顺序执行:

  1. 先获取用户名(比如 "张三")。
  2. 拿到用户名后,去数据库查他的ID
  3. 拿到 ID 后,去查他的订单

代码会变成什么样?

// 伪代码演示,注意看缩进的形状
getUserName((name) => {
    console.log(`拿到名字: ${name}`);
    
    // 在回调里面嵌套第二个请求
    getUserId(name, (id) => {
        console.log(`拿到ID: ${id}`);
        
        // 在回调里面嵌套第三个请求
        getUserOrders(id, (orders) => {
            console.log(`拿到订单: ${orders}`);
            
            // 如果还有第四步... 屏幕就要炸了
            getOrderDetails(orders[0], (detail) => {
                // ...
            });
        });
    });
});

这就是著名的“回调地狱”(也就是“厄运金字塔”):

  1. 代码横向发展:缩进越来越深,阅读极其困难。
  2. 错误处理灾难:你需要在每一层回调里单独写 if (error) ...,极其容易漏掉。
  3. 维护困难:想调整一下顺序?你要小心翼翼地拆括号,很容易改崩。

3.第三阶段:曙光初现 —— Promise (承诺)

为了解决“回调地狱”,社区提出了一种新的规范,后来被纳入了 ES6 标准,这就是 Promise

什么是 Promise? 它是一个对象,代表了“一个未来才会知道结果的操作”。 你可以把它想象成一张披萨店的取餐小票。 当你拿到这个 Promise(小票)时,披萨还没好,但它承诺未来会给你两个结果中的一个:

  1. Fulfilled (成功):披萨做好了,给你披萨。
  2. Rejected (失败):烤箱炸了,给你一个错误原因。

Promise 最大的贡献:链式调用 (Chaining) 它把“回调地狱”的横向嵌套,拉直成了纵向的链条。

TypeScript 中的 Promise 写法:

我们看看上面的“回调地狱”用 Promise 改写后是什么样:

// 假设这些函数现在返回的是 Promise,而不是接受回调
// getUserName() -> 返回 Promise<string>

getUserName()
    .then((name) => {
        console.log(`拿到名字: ${name}`);
        // 返回下一个异步任务,继续往下传
        return getUserId(name); 
    })
    .then((id) => {
        console.log(`拿到ID: ${id}`);
        return getUserOrders(id);
    })
    .then((orders) => {
        console.log(`拿到订单: ${orders}`);
    })
    .catch((error) => {
        // 重点:这里一个 catch 可以捕获上面任何一步发生的错误!
        console.error("出错了:", error);
    });

完整代码

// 1. 获取用户名的函数
// 返回值类型:Promise<string> -> 承诺未来会给出一个 string
function getUserName(): Promise<string> {
    return new Promise((resolve, reject) => {
        console.log("--- 1. 开始请求用户名 ---");
        
        // 模拟网络耗时 1秒
        setTimeout(() => {
            const isSuccess = true; // 模拟:假设请求成功

            if (isSuccess) {
                // 成功了!调用 resolve,把数据 "张三" 传出去
                // 这个 "张三" 会传给下一个 .then((name) => ...) 里的 name
                resolve("张三"); 
            } else {
                // 失败了!调用 reject
                // 这会跳过后面的 .then,直接进入最后的 .catch
                reject("获取用户名失败:网络连接断开"); 
            }
        }, 1000);
    });
}

// 2. 获取用户ID的函数
// 接收参数 name,返回 Promise<number>
function getUserId(name: string): Promise<number> {
    return new Promise((resolve, reject) => {
        console.log(`--- 2. 正在查 ${name} 的ID ---`);

        setTimeout(() => {
            // 假设我们查到了 ID 是 10086
            resolve(10086);
        }, 1000);
    });
}

// 3. 获取订单的函数
// 接收参数 id,返回 Promise<string[]> (字符串数组)
function getUserOrders(id: number): Promise<string[]> {
    return new Promise((resolve, reject) => {
        console.log(`--- 3. 正在查 ID:${id} 的订单 ---`);

        setTimeout(() => {
            // 返回订单列表
            resolve(["奶茶", "炸鸡", "Switch游戏机"]);
        }, 1000);
    });
}

// --- 实际调用部分(就是你刚才看到的那段代码) ---

console.log("程序启动...");

getUserName()
    .then((name) => {
        // 这里接收到的 name 就是 resolve("张三") 里的 "张三"
        console.log(`✅ 拿到名字: ${name}`);
        
        // 关键点:这里 return 了下一个 Promise 函数的调用
        // 这样下一个 .then 才会等到 getUserId 完成后才执行
        return getUserId(name); 
    })
    .then((id) => {
        // 这里接收到的 id 就是 resolve(10086) 里的 10086
        console.log(`✅ 拿到ID: ${id}`);
        return getUserOrders(id);
    })
    .then((orders) => {
        // 这里接收到的 orders 就是那个数组
        console.log(`✅ 拿到订单: ${orders}`);
    })
    .catch((error) => {
        console.error(`❌ 流程中断: ${error}`);
    })
    .finally(() => {
        // (可选) finally 不管成功失败都会执行
        console.log("--- 流程结束 ---");
    });

Promise 的核心状态(面试常考): 一个 Promise 一定处于以下三种状态之一:

  1. Pending (进行中):刚初始化,还没结果。
  2. Fulfilled / Resolved (已成功):操作成功,调用了 .then
  3. Rejected (已失败):操作失败,调用了 .catch

TS 类型小贴士: 在 TypeScript 中,Promise 是有泛型的。 如果一个异步函数最终返回一个字符串,它的类型是 Promise<string>。 如果返回一个数字,类型是 Promise<number>

// 这是一个返回 Promise 的函数定义示例
function wait(ms: number): Promise<string> {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve("时间到!");
        }, ms);
    });
}

总结第二部分:

  • 回调函数:最原始,容易导致嵌套过深(回调地狱)。
  • Promise:通过 .then() 链式调用,把代码拉直了,解决了缩进问题,并且统一了错误处理(.catch())。

但是……你有没有发现,Promise 虽然比回调好,但还是有很多 .then()?代码里充斥着很多小括号和箭头函数,看起来依然不像我们要的“同步代码”。

这就是为什么我们需要第三部分:Async/Await(终极解决方案)。


第三部分:现代标准写法 (Async/Await)

好的,来到最激动人心的部分了!Async/Await 是现代 JavaScript/TypeScript 开发的标配

学会了这个,你就不再需要写那些繁琐的 .then 链条了。代码会变得像写同步代码(比如 Java 或 Python)一样直观。


1. 什么是 Async/Await?

  • Async/Await 是在 ES2017 (ES8) 引入的新语法。
  • 它本质上是 Promise 的语法糖
    • 也就是说,底层依然在跑 Promise,只是写法变了,机器执行的逻辑没变。
  • async:放在函数定义前,表示“这个函数内部有异步操作”。
  • await:放在 Promise 前面,表示“等一下,直到这个 Promise 出结果(resolve)了,再往下走”。

2. 代码对比:从 Promise 到 Async/Await

让我们用刚才定义的三个函数(getUserName, getUserId, getUserOrders)来演示。它们本身的定义不需要改,只需要改调用的方式

旧写法 (Promise 链式调用)

哪怕逻辑再清晰,依然有很多回调函数嵌套。

function runOldWay() {
    getUserName()
        .then(name => getUserId(name))
        .then(id => getUserOrders(id))
        .then(orders => console.log(orders))
        .catch(err => console.error(err));
}

新写法 (Async/Await)

看!没有回调函数了!全是赋值语句!

// 1. 必须在函数前加 async 关键字
async function runNewWay() {
    try {
        console.log("开始任务...");

        // 2. 使用 await 等待结果,直接赋值给变量
        // JS 引擎运行到这里会暂停,直到 getUserName 里的 resolve 被调用
        const name = await getUserName(); 
        console.log(`拿到名字: ${name}`);

        // 上一行没拿到结果前,这一行绝不会执行
        const id = await getUserId(name);
        console.log(`拿到ID: ${id}`);

        const orders = await getUserOrders(id);
        console.log(`拿到订单: ${orders}`);

    } catch (error) {
        // 3. 错误处理回归原始的 try...catch
        // 只要上面任何一个 await 的 Promise 被 reject,就会跳到这里
        console.error("出错了:", error);
    }
}

// 调用这个异步函数
runNewWay();

3. 深度解析:await 到底做了什么?

当你写下 const name = await getUserName(); 时,发生了什么?

  1. 暂停执行:函数 runNewWay 的执行被暂停在这一行。
  2. 让出线程:虽然 runNewWay 停了,但主线程没有卡死(没有阻塞)。浏览器可以去处理点击事件、渲染动画,或者执行 runNewWay 外面的其他代码。
  3. 等待结果getUserName 在后台跑(比如等待网络请求)。
  4. 恢复执行:一旦 getUserName 完成并 resolve 了结果,runNewWay 会被“唤醒”。结果被赋值给 name,然后继续执行下一行代码。

注意: await 只能用在 async 函数内部。(虽然最新的 TS/JS 支持 Top-level await,但在普通函数里还是不行的)。


4. TypeScript 里的 Async 函数类型

在 TypeScript 中,async 函数的返回值类型永远是 Promise

就算你 return 的是一个普通数字,TS 也会自动帮你不装成 Promise。

// 普通函数
function add(a: number, b: number): number {
    return a + b;
}

// Async 函数
// 虽然看起来 return 3,但 TS 推断出的返回类型是 Promise<number>
async function addAsync(a: number, b: number): Promise<number> {
    return a + b; 
}

// 调用时必须处理 Promise
const result = addAsync(1, 2); // result 是 Promise<number>
// 正确用法:
// await addAsync(1, 2) 或 addAsync(1, 2).then(...)

代码分析

const result = addAsync(1, 2);: 无论有没有加await, async函数都是返回Promise<>对象. 如果没有添加await, 依然会执行该异步函数, 但是不会在这里等待, 会立刻执行下面的函数, 这个addAsync函数就在后台默默执行. 有时候会故意不写await, 比如下面这个场景:

async function initPage() {
    // 发送两个请求,但我不想串行等待(不希望 A 完了才做 B)
    const taskA = getUserInfo(); // 没写 await,请求发出去了
    const taskB = getBanners();  // 没写 await,请求也发出去了
    
    console.log('两个请求都已经发出去了,正在后台跑...');

    // 稍后我再一起等它们的结果
    const user = await taskA;
    const banner = await taskB;
}

总结第三部分

  1. 写法更像同步:用 try...catch 替代 .catch,用赋值替代 .then
  2. 可读性飞跃:代码逻辑从上到下,符合人类阅读习惯。
  3. 调试方便:在 await 这一行打断点,你可以清楚地看到之前的变量状态,这在 .then 链条里是很难做到的。

现在,你已经掌握了最主流的异步写法。

接下来,我们要进入第四部分:TypeScript 里的异步类型。这部分会教你如何在真实的工作中(比如调用后端 API)定义那些复杂的数据接口。这一步是 TS 开发者的日常。


第四部分:TypeScript 里的异步类型 (TS Specifics)

前面的内容其实大都也是 JavaScript 的知识(除了简单的类型标注)。到了这里,我们要讲只有 TypeScript 才能提供的强大功能:如何在异步操作中获得完美的类型提示和安全保障。

这一部分对于前端开发(尤其是对接后端 API)至关重要。


1. Promise 的泛型:Promise<T>

我们在前面的例子里稍微提到了这个。Promise 是一个泛型类。 这就好比 Array<number> 表示“装数字的数组”,Promise<User> 表示“承诺未来给你一个 User 对象”。

基本语法:

// 函数返回值类型
function fetchData(): Promise<TypeOfTheResult> { ... }

实战场景:定义 API 响应结构

假设后端给你这样一个 JSON 数据结构:

// 后端返回的用户数据
{
    "id": 1,
    "username": "admin",
    "isActive": true
}

步骤一:定义 Interface (接口) 我们要先告诉 TS,这个数据长什么样。

interface User {
    id: number;
    username: string;
    isActive: boolean;
    // 甚至可以有可选属性
    avatarUrl?: string; 
}

步骤二:在异步函数中使用

// 这里的返回值类型 Promise<User> 非常重要!
async function fetchCurrentUser(): Promise<User> {
    const response = await fetch('/api/user');
    // 解析 JSON
    const data = await response.json();
    
    // 这里其实有一个类型断言的过程,告诉 TS 这个 data 就是 User
    // 在实际项目中,通常 fetch 封装库(如 axios)会帮我们做泛型传递
    return data as User; 
}

步骤三:享受类型提示

当你调用这个函数时,神奇的事情发生了:

async function main() {
    const user = await fetchCurrentUser();
    
    // 当你敲下 user. 的时候,VS Code 会自动弹窗提示:
    // - id
    // - username
    // - isActive
    // - avatarUrl
    console.log(user.username); 
    
    // 如果你拼写错误,立刻报错!
    console.log(user.usrname); // ❌ 报错:User 类型上不存在 usrname
}

2. 实战技巧:配合 Axios (最常用的请求库)

在真实工作中,我们通常使用 axios 库来发请求。axios 的类型定义非常完善,支持传入泛型。

import axios from 'axios';

// 1. 定义接口
interface Article {
    title: string;
    content: string;
    views: number;
}

// 2. 发送请求
async function getArticle(id: number) {
    // axios.get 是个泛型函数:axios.get<T>(url)
    // 我们传入 <Article>,告诉 axios 返回的数据体 data 是 Article 类型
    const response = await axios.get<Article>(`/api/articles/${id}`);
    
    // response.data 现在的类型就是 Article
    return response.data;
}

// 3. 调用
async function showArticle() {
    const article = await getArticle(101);
    // 此时 article 就是 Article 类型
    console.log(article.title); // ✅ 安全
}

3. 处理“可能是多种类型”的情况

有时候异步操作可能会返回不同的结果,或者可能失败。

场景: 搜索用户,可能找到,也可能没找到(null)。

interface UserInfo {
    name: string;
    age: number;
}

// 返回值类型是 UserInfo 或者 null
async function findUser(name: string): Promise<UserInfo | null> {
    if (name === 'Ghost') {
        return null;
    }
    return { name: 'RealUser', age: 18 };
}

async function check() {
    const user = await findUser('Ghost');
    
    // 这里 user 可能是 null,TS 会强迫你做检查
    // console.log(user.name); // ❌ 报错:user 可能为 null

    if (user) {
        console.log(user.name); // ✅ 现在安全了
    }
}

总结第四部分

  1. 核心思维:写异步函数时,第一件事不是写逻辑,而是先想好返回值类型Promise<T>)。
  2. 接口先行:把后端返回的 JSON 数据结构定义为 interface
  3. 工具库配合:使用 Axios 等支持泛型的库,把 interface 传进去,这样从请求结果里拿到的数据就会自带类型提示。

这一步做好了,你的代码健壮性会提升一个档次,再也不用担心拼错字段名或者不知道后端返回了啥。

准备好进入最后一部分了吗?我们将讨论实战中的错误处理并行技巧(比如怎么让两个请求同时发,而不是一个等一个)。


第五部分:实战与错误处理 (Best Practices)

好,我们进入最后一部分:实战与错误处理 (Best Practices)

这部分是区分“新手”和“熟练工”的分水岭。新手写的异步代码往往在网络正常时能跑,一旦网络抖动或者需要优化性能时就崩了。


1. 优雅的错误处理 (try...catch)

在 Async/Await 模式下,我们使用传统的 try...catch 来捕获异步错误。

基本套路:

async function safeGetData() {
    try {
        // 可能会炸的代码放在 try 里
        const data = await fetchData();
        console.log("成功:", data);
    } catch (error) {
        // 1. 网络断了
        // 2. 服务器 500 了
        // 3. JSON 解析失败了
        // 所有错误都会汇聚到这里
        console.error("出大问题了:", error);
        
        // TS 小坑:catch(error) 这里的 error 默认类型是 unknown 或 any
        // 如果要访问 error.message,最好断言一下
        if (error instanceof Error) {
            console.log("错误信息:", error.message);
        }
    } finally {
        // (可选) 无论成功失败都会执行,适合关闭 loading 动画
        console.log("关闭 Loading 转圈圈");
    }
}

为什么这很重要? 如果不写 try...catch,一旦 await 的 Promise 失败(Rejected),整个函数会抛出异常。如果上层也没人捕获,你的程序可能会崩溃(在 Node.js 中可能会导致进程退出,在前端会导致控制台报红且后续逻辑中断)。


2. 并行处理 (Promise.all) —— 性能优化神器

这是面试和实战中极高频的考点。

场景: 你需要在一个页面同时展示“用户信息”和“最近订单”。这俩接口互不相关。

新手写法 (串行 - 慢): 就像排队,先买奶茶,买完再排队买炸鸡。

async function loadPageSerial() {
    console.time("串行耗时");
    
    const user = await getUser();       // 假设耗时 1s
    const orders = await getOrders();   // 假设耗时 1s
    
    // 总耗时:1s + 1s = 2s
    console.timeEnd("串行耗时");
}

高手写法 (并行 - 快): 我和朋友分头行动,我买奶茶,他买炸鸡,最后一起吃。

async function loadPageParallel() {
    console.time("并行耗时");
    
    // 技巧:Promise.all 接收一个 Promise 数组
    // 它会同时启动数组里的所有任务
    const [user, orders] = await Promise.all([
        getUser(),      // 任务 A
        getOrders()     // 任务 B
    ]);
    
    // 总耗时:max(1s, 1s) = 1s
    // 只有当两个都完成了,await 才会继续往下走
    console.timeEnd("并行耗时");
    
    console.log(user, orders);
}

Promise.all 的特点:

  1. 全成则成:只有数组里所有 Promise 都成功了,它才成功。
  2. 一败则败:只要有一个失败了,整个 Promise.all 直接抛出错误(进入 catch),其他的成功了也没用。

进阶:Promise.allSettled (ES2020) 如果你不希望“一败则败”(比如用户信息挂了,但我还是想展示订单),可以使用 Promise.allSettled。它会等待所有任务结束,不管成功还是失败,并返回每个任务的状态。


3. 一个常见的循环陷阱

需求: 有一个用户 ID 列表 [1, 2, 3],要依次获取他们的详细信息。

错误写法 (forEach):

async function wrongLoop() {
    const ids = [1, 2, 3];
    
    // ❌ 这种写法 await 不生效!forEach 不支持 async 回调
    ids.forEach(async (id) => {
        const user = await getUser(id);
        console.log(user);
    });
    
    console.log("结束了?"); 
    // 实际结果:先打印 "结束了?",然后那 3 个请求才在后台慢慢跑。
}

正确写法 1 (for...of) —— 串行(一个接一个):

async function serialLoop() {
    const ids = [1, 2, 3];
    
    for (const id of ids) {
        // ✅ 能够正确暂停,拿完 id:1 再拿 id:2
        const user = await getUser(id);
        console.log(user);
    }
    console.log("真·结束了");
}

正确写法 2 (map + Promise.all) —— 并行(同时跑):

async function parallelLoop() {
    const ids = [1, 2, 3];
    
    // 1. 先把 ID 数组映射成 Promise 数组
    const promises = ids.map(id => getUser(id));
    
    // 2. 再用 Promise.all 等待它们全部完成
    const users = await Promise.all(promises);
    
    console.log("所有用户都拿到:", users);
}

全文大总结

恭喜你,你已经走完了 TypeScript 异步编程的完整路径!

  1. 核心概念:JS 是单线程的,为了不阻塞,必须用异步。
  2. 演进史:Callback (回调地狱) -> Promise (链式) -> Async/Await (最终形态)
  3. TS 类型:使用 Promise<T> 和接口 (Interface) 来约束异步数据的形状,获得极致的代码提示。
  4. 实战技巧
    • try...catch 兜底错误。
    • Promise.all 做并发优化。
    • 千万别在 forEach 里用 await

\

❌