TypeScript/JavaScript 异步原理与用法详解
#TypeScript/JavaScript 异步原理与用法详解
#一、为什么需要异步
JS 是单线程语言,一次只能做一件事。如果所有操作都同步执行,遇到网络请求、文件读写这种耗时操作就会阻塞整个程序,页面卡死、服务器无法响应其他请求。
异步的本质:把"耗时操作"丢给别人(浏览器 API / Node 的 libuv)去做,自己先去做别的事,等结果回来了再处理。
#二、底层原理:事件循环(Event Loop)
JS 运行时由几个部分组成:
┌───────────────────────────┐
│ Call Stack │ ← 当前正在执行的代码(同步代码在这跑)
│ (调用栈,后进先出) │
└───────────────────────────┘
↑ ↓
┌───────────────────────────┐
│ Web APIs / Node APIs │ ← setTimeout, fetch, fs.readFile 等
│ (真正执行异步任务的地方) │ 由浏览器/libuv 线程池处理
└───────────────────────────┘
↓
┌──────────────┬──────────────┐
│ Microtask Q │ Macrotask Q │ ← 任务完成后,回调进入对应队列排队
│ (Promise.then)│ (setTimeout) │
└──────────────┴──────────────┘
↓
事件循环(Event Loop)不断检查:
Call Stack 空了吗?
→ 先清空 Microtask 队列(全部)
→ 再从 Macrotask 队列取一个执行
→ 重复
核心规则:
- 同步代码永远优先,先把调用栈跑空
- 调用栈一空,先把微任务队列全部清空(Promise.then/catch/finally、queueMicrotask、
async函数里await之后的代码) - 微任务清空后,才取一个宏任务执行(setTimeout、setInterval、I/O、UI渲染)
- 循环往复
举例验证:
console.log("1 同步");
setTimeout(() => console.log("2 宏任务"), 0);
Promise.resolve().then(() => console.log("3 微任务"));
console.log("4 同步");
// 输出顺序: 1 → 4 → 3 → 2
为什么?因为 setTimeout 就算延迟是 0,回调也要先进宏任务队列排队;而 Promise.then 的回调进的是微任务队列,优先级更高,同步代码跑完立刻清空微任务,最后才轮到宏任务。
#三、Promise:异步的"承诺对象"
Promise 是对"未来某个值"的包装,有三种状态:pending(进行中)→ fulfilled(成功)或 rejected(失败),状态一旦变化就不可逆。
function delay(ms: number): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (ms < 0) reject(new Error("时间不能为负"));
else resolve();
}, ms);
});
}
delay(1000)
.then(() => console.log("1秒后执行"))
.catch((err) => console.error("出错了:", err))
.finally(() => console.log("无论成功失败都执行"));
常用组合方法:
// 全部并发执行,任何一个失败就整体失败
Promise.all([p1, p2, p3]); // Promise<[T1, T2, T3]>
// 全部并发执行,不管成功失败都等完,拿到每个的状态
Promise.allSettled([p1, p2, p3]); // { status: 'fulfilled'|'rejected', value/reason }[]
// 谁先完成(无论成功失败)就用谁的结果
Promise.race([p1, p2, p3]);
// 谁先"成功"就用谁,除非全部失败
Promise.any([p1, p2, p3]);
#四、async/await:Promise 的"语法糖"
async/await 让异步代码写起来像同步代码,但底层仍然是 Promise + 微任务机制。
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`); // 暂停在这,等 fetch 的 Promise resolve
if (!res.ok) throw new Error("请求失败"); // 用 throw 代替 reject
const data: User = await res.json();
return data; // async 函数返回值会自动包装成 Promise<User>
}
// 调用方式一:继续用 await(必须在 async 函数内,或顶层 await)
async function main() {
try {
const user = await fetchUser("123");
console.log(user);
} catch (err) {
console.error("捕获错误:", err); // await 抛出的错误用 try/catch 捕获
}
}
// 调用方式二:当成普通 Promise 用
fetchUser("123").then(console.log).catch(console.error);
关键原理:
async function一定返回一个 Promise,哪怕函数体里return 42,外部拿到的也是Promise<number>遇到
await xxx,相当于把xxx.then(继续执行剩下的代码)—— 函数在这里"暂停",把控制权交还事件循环,await之后的代码本质上是排进微任务队列的回调await后面的错误(reject 或 throw)可以直接用try/catch捕获,这是它比.then/.catch链更符合直觉的地方
#五、顺序执行 vs 并发执行(最容易踩坑的地方)
#❌ 常见错误:串行等待,浪费时间
async function loadAll() {
const a = await fetchA(); // 等 1s
const b = await fetchB(); // 再等 1s(明明可以和 a 同时发)
const c = await fetchC(); // 再等 1s
return [a, b, c]; // 总耗时 ≈ 3s
}
#✅ 正确:先并发发起,再统一等待
async function loadAll() {
const pA = fetchA(); // 不加 await,立刻发起请求,拿到 Promise
const pB = fetchB();
const pC = fetchC();
const [a, b, c] = await Promise.all([pA, pB, pC]); // 总耗时 ≈ 1s(取最长的那个)
return [a, b, c];
}
#什么时候必须串行?——后一个依赖前一个的结果
// 你之前贴的 for...of + await 就是这种典型场景
for (const prompt of prompts) {
await emit({ type: "message_start", message: prompt });
await emit({ type: "message_end", message: prompt }); // 必须严格按顺序,不能乱序
}
如果这里改成 prompts.forEach(async p => { await emit(...) }) 或 Promise.all(prompts.map(...)),事件顺序就无法保证,甚至 forEach 根本不会等待里面的 async 函数执行完(这是新手最常踩的坑,见下)。
#六、常见陷阱
#1. forEach 不支持 async/await
// ❌ 错的:forEach 不等待,循环会立刻"跑完"(实际上异步任务还在后台跑)
async function bad() {
[1, 2, 3].forEach(async (n) => {
await delay(100);
console.log(n);
});
console.log("done"); // 会最先打印!不符合预期
}
// ✅ 对的:用 for...of
async function good() {
for (const n of [1, 2, 3]) {
await delay(100);
console.log(n);
}
console.log("done"); // 保证最后打印
}
#2. 忘记 await 导致"悬空 Promise"
async function save() {
db.write(data); // ❌ 忘了 await,函数直接往下走,写入是否成功完全不知道
console.log("saved"); // 可能在真正写完之前就打印了
}
TS 配置里打开 no-floating-promises(eslint 规则)可以帮你在编译期就抓出这种问题。
#3. try/catch 只能捕获它包裹范围内 await 抛出的错误
async function bad() {
try {
setTimeout(() => { throw new Error("boom"); }, 0); // ❌ 捕获不到!
} catch (e) {
console.log("caught", e); // 不会执行
}
}
因为 setTimeout 的回调是在未来某个宏任务里独立执行的,和当前 try/catch 所在的调用栈完全无关。
#4. TS 中的类型标注
async function getData(): Promise<string[]> { // 显式标注返回值是 Promise<string[]>
return ["a", "b"]; // TS 自动帮你包装成 Promise
}
// 顶层 await(TS 4.5+ / ESM 模块下支持)
const data = await getData(); // 不需要包在 async 函数里,但要求 module 是 ESNext/NodeNext 且 target 够新
#七、Pi的其中一段代码
export async function runAgentLoop(...): Promise<AgentMessage[]> {
...
await emit({ type: "agent_start" }); // 1. 微任务/宏任务队列排队,等 emit 内部逻辑做完
await emit({ type: "turn_start" }); // 2. 等 1 完全 resolve 后才执行
for (const prompt of prompts) {
await emit({ type: "message_start", message: prompt }); // 3. 严格串行,保证事件顺序
await emit({ type: "message_end", message: prompt });
}
await runLoop(...); // 4. 前面全部 resolve 后才调用,且要等 runLoop 整体跑完
return newMessages; // 5. 函数返回时自动包装成 resolved 的 Promise<AgentMessage[]>
}
这种写法在Agent 事件流里是有意为之的串行——因为下游要按时间顺序消费事件(比如 UI 逐条渲染消息),乱序会导致界面显示错乱。如果换成 Promise.all 并发,顺序就无法保证了。