Gemini生成
第一部分:核心概念 (Why & What)
在编程世界里,代码的执行方式主要分两种:同步 (Synchronous) 和 异步 (Asynchronous)。
1. 同步 (Synchronous) —— “死心眼的排队者”
概念:
代码从上到下,一行一行执行。上一行代码没有执行完,下一行代码绝对不会开始。
生活类比:
想象你在银行柜台办理业务。
- 你前面有一个人正在办业务(代码行 A)。
- 不管他办得有多慢,你(代码行 B)都只能在后面干站着等。
- 你不能玩手机,不能去上厕所,只能阻塞 (Block) 在那里,直到他结束。
代码表现:
console.log("1. 开始点餐");
alert("我是同步的弹窗,我不关掉,你什么都做不了!"); // 这里会卡住
console.log("2. 吃饭");
如果不点击弹窗的确定,"2. 吃饭" 永远不会打印出来。这就是“阻塞”。
2. 异步 (Asynchronous) —— “拿着取餐器的食客”
概念:
遇到耗时的任务(比如从网络下载图片、读取文件),程序不会傻等,而是把任务交给“别人”(浏览器或操作系统)去处理,自己继续往下执行后面的代码。等耗时任务做完了,再通知程序回来处理结果。
生活类比:
想象你在奶茶店点单。
- 你点了一杯制作很复杂的奶茶(耗时任务)。
- 店员没有让你站在柜台前盯着他做,而是给了你一个取餐器(回调/Promise),然后说:“你先去旁边坐着玩手机,好了震动叫你。”
- 你找个位置坐下(继续执行后续代码)。
- 过了一会儿,奶茶好了,取餐器震动,你去拿奶茶(处理异步结果)。
代码表现:
console.log("1. 点单:我要一杯奶茶");
// 这是一个模拟异步的函数,假设需要 2 秒钟
setTimeout(() => {
console.log("3. 奶茶好了!(这是异步回来的结果)");
}, 2000);
console.log("2. 找个位置坐下玩手机");
控制台的打印顺序是:
1. 点单...
-
2. 找个位置... (注意:这里直接跳过了等待,先执行了!)
- (过了2秒后)
3. 奶茶好了...
3. 为什么 JavaScript/TypeScript 必须要有异步?
你可能会问:“同步多简单啊,逻辑清晰,为什么要搞这么复杂的异步?”
这和 JS 的出身有关:
-
单线程 (Single Thread):JavaScript(以及编译后的 TS)是单线程的。也就是说,它只有一个“大脑”,同一时间只能做一件事。它不像 Java 或 C++ 那样可以开启多条线程同时工作。
-
浏览器的体验:
- 假设你打开一个网页,它需要去服务器请求“用户列表”。
- 如果使用同步:在数据请求回来的这 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)
场景升级:
现在的业务逻辑变成了这样,必须严格按顺序执行:
- 先获取用户名(比如 "张三")。
- 拿到用户名后,去数据库查他的ID。
- 拿到 ID 后,去查他的订单。
代码会变成什么样?
// 伪代码演示,注意看缩进的形状
getUserName((name) => {
console.log(`拿到名字: ${name}`);
// 在回调里面嵌套第二个请求
getUserId(name, (id) => {
console.log(`拿到ID: ${id}`);
// 在回调里面嵌套第三个请求
getUserOrders(id, (orders) => {
console.log(`拿到订单: ${orders}`);
// 如果还有第四步... 屏幕就要炸了
getOrderDetails(orders[0], (detail) => {
// ...
});
});
});
});
这就是著名的“回调地狱”(也就是“厄运金字塔”):
-
代码横向发展:缩进越来越深,阅读极其困难。
-
错误处理灾难:你需要在每一层回调里单独写
if (error) ...,极其容易漏掉。
-
维护困难:想调整一下顺序?你要小心翼翼地拆括号,很容易改崩。
3.第三阶段:曙光初现 —— Promise (承诺)
为了解决“回调地狱”,社区提出了一种新的规范,后来被纳入了 ES6 标准,这就是 Promise。
什么是 Promise?
它是一个对象,代表了“一个未来才会知道结果的操作”。
你可以把它想象成一张披萨店的取餐小票。
当你拿到这个 Promise(小票)时,披萨还没好,但它承诺未来会给你两个结果中的一个:
-
Fulfilled (成功):披萨做好了,给你披萨。
-
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 一定处于以下三种状态之一:
-
Pending (进行中):刚初始化,还没结果。
-
Fulfilled / Resolved (已成功):操作成功,调用了
.then。
-
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(); 时,发生了什么?
-
暂停执行:函数
runNewWay 的执行被暂停在这一行。
-
让出线程:虽然
runNewWay 停了,但主线程没有卡死(没有阻塞)。浏览器可以去处理点击事件、渲染动画,或者执行 runNewWay 外面的其他代码。
-
等待结果:
getUserName 在后台跑(比如等待网络请求)。
-
恢复执行:一旦
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;
}
总结第三部分
-
写法更像同步:用
try...catch 替代 .catch,用赋值替代 .then。
-
可读性飞跃:代码逻辑从上到下,符合人类阅读习惯。
-
调试方便:在
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); // ✅ 现在安全了
}
}
总结第四部分
-
核心思维:写异步函数时,第一件事不是写逻辑,而是先想好返回值类型(
Promise<T>)。
-
接口先行:把后端返回的 JSON 数据结构定义为
interface。
-
工具库配合:使用 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 的特点:
-
全成则成:只有数组里所有 Promise 都成功了,它才成功。
-
一败则败:只要有一个失败了,整个
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 异步编程的完整路径!
-
核心概念:JS 是单线程的,为了不阻塞,必须用异步。
-
演进史:Callback (回调地狱) -> Promise (链式) -> Async/Await (最终形态)。
-
TS 类型:使用
Promise<T> 和接口 (Interface) 来约束异步数据的形状,获得极致的代码提示。
-
实战技巧:
- 用
try...catch 兜底错误。
- 用
Promise.all 做并发优化。
-
千万别在
forEach 里用 await。
\