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 队列取一个执行
      → 重复

核心规则:

  1. 同步代码永远优先,先把调用栈跑空
  2. 调用栈一空,先把微任务队列全部清空(Promise.then/catch/finally、queueMicrotask、async函数里 await 之后的代码)
  3. 微任务清空后,才取一个宏任务执行(setTimeout、setInterval、I/O、UI渲染)
  4. 循环往复

举例验证:

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 并发,顺序就无法保证了。