Skip to content

第 05 章 · 异步编程(Promise / async / 事件循环)

一句话开篇:本章带你从"回调地狱"一路爬到"async/await 优雅天堂",并彻底搞懂 JS 单线程下事件循环、宏任务、微任务的关系——面试必考、工作天天用。


0. 生活类比(先建立直觉)

把 JS 想象成一家"小餐厅",只有一个厨师(主线程)

  • 客人点餐(同步代码):厨师立刻做,做完递出
  • 客人说"做好了叫我"(异步任务):厨师把单子贴在窗口,自己去做下一单
  • **服务员(事件循环)**不停地巡视窗口,谁好了就喊"X 号餐好了"
  • VIP 通道(微任务):插队,每完成一道菜就先把 VIP 全处理完
  • 普通通道(宏任务):排队,VIP 处理完才轮到下一个
JS 概念餐厅类比
主线程一个厨师
同步代码立等的菜
宏任务(setTimeout、setInterval、I/O)普通排队
微任务(Promise.then、queueMicrotask)VIP 插队
事件循环跑堂的服务员
Promise一张写着"我去帮你做"的承诺单
async/await"等这单做完我再继续"

1. 为什么需要异步

JS 是单线程的,如果 IO(网络请求、文件读取、定时器)是同步的,整个页面会卡死。

js
// 假想的同步请求(不存在!)
const data = httpGet('/api'); // 卡 3 秒
render(data);
console.log('永远到不了,页面早卡死了');

异步的本质:先不等结果,把"完成后要做的事"挂在那儿,等结果回来再执行。


2. 异步演进史

2.1 回调函数(Callback)

最古老的方式:把"完成后要做的事"作为参数传给异步 API。

js
fs.readFile('a.txt', (err, data) => {
  if (err) return console.error(err);
  fs.readFile('b.txt', (err, data2) => {
    if (err) return console.error(err);
    fs.readFile('c.txt', (err, data3) => {
      // 回调地狱(Callback Hell)
    });
  });
});

痛点:

  • 嵌套层级深,难读
  • 错误处理麻烦(每层都要判断 err)
  • 控制流复杂(并行/串行/竞速难写)

2.2 Promise(ES6)

Promise 是一个"承诺"对象,代表一个异步操作的最终完成或失败

三种状态:

text
            resolve(value)
pending ──────────────────────► fulfilled
   │                                │
   │  reject(reason)                │  状态一旦改变
   ▼                                │  就不能再变
rejected ◄──────────────────────────┘

核心 API:

js
const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('OK'), 1000);
});

p.then(value => console.log(value))   // OK
 .catch(err => console.error(err))
 .finally(() => console.log('done'));

2.3 async / await(ES2017)

用同步的写法写异步,本质是 Promise 的语法糖。

js
async function getData() {
  try {
    const a = await readFile('a.txt');
    const b = await readFile('b.txt');
    return a + b;
  } catch (err) {
    console.error(err);
  }
}
写法优点缺点
回调简单直接回调地狱、错误难处理
Promise链式调用、统一错误处理仍然有 .then 嵌套
async/await同步语法、try/catch 错误处理易写出串行(性能陷阱)

3. Promise 详解

3.1 Promise 链式调用

.then 永远返回新的 Promise,所以可以链式:

js
fetch('/api')
  .then(res => res.json())     // 返回新 Promise
  .then(data => transform(data))
  .then(result => render(result))
  .catch(err => showError(err));

值穿透:.then 不传参数时,值会"穿透"到下一个 then。

js
Promise.resolve(1).then().then().then(v => console.log(v)); // 1

3.2 静态方法对比

方法行为全部成功任一失败返回
Promise.all([p1,p2,p3])并行等所有完成立即 reject[v1,v2,v3]
Promise.race([p1,p2,p3])并行取最快的取最快的第一个 settle 的值
Promise.allSettled([p1,p2,p3])并行等所有 settle不会 reject[{status,value/reason}]
Promise.any([p1,p2,p3])并行取第一个成功全 reject 才 reject第一个成功的值
js
// all:一个错全错
Promise.all([fetch('/a'), fetch('/b')])
  .then(([a, b]) => render(a, b));

// allSettled:知道每个的结果,不管成败
Promise.allSettled(promises).then(results => {
  results.forEach(r => {
    if (r.status === 'fulfilled') console.log('OK', r.value);
    else console.log('FAIL', r.reason);
  });
});

// race:超时控制
Promise.race([
  fetch('/api'),
  new Promise((_, reject) => setTimeout(() => reject('timeout'), 3000))
]);

3.3 错误处理

js
// ❌ 错:try/catch 抓不到 Promise 错误
try {
  Promise.reject('err');
} catch (e) { /* 进不来 */ }

// ✅ 对:用 .catch 或 await + try/catch
Promise.reject('err').catch(e => console.log(e));

// async/await
async function f() {
  try {
    await Promise.reject('err');
  } catch (e) { console.log(e); }
}

💡 未捕获的 Promise rejection 会触发 unhandledrejection 事件。


4. async / await 深入

4.1 本质

async 函数返回一个 Promise

js
async function f() { return 1; }
// 等价于
function f() { return Promise.resolve(1); }

await 会"暂停"函数执行,等 Promise resolve 后拿到值再继续:

js
async function getUser() {
  const res = await fetch('/api/user');  // 暂停在这儿
  const data = await res.json();         // 暂停在这儿
  return data;
}

4.2 串行 vs 并行

串行(慢):

js
const a = await fetchA();  // 等 1s
const b = await fetchB();  // 又等 1s
// 总共 2s

并行(快):

js
const [a, b] = await Promise.all([fetchA(), fetchB()]);
// 总共 1s(取较慢的一个)

4.3 错误处理

js
async function f() {
  try {
    const a = await mayFail();
  } catch (e) {
    // 捕获 await 中的 reject
  }
}

5. 事件循环(Event Loop)⭐

这是面试必考,也是写 JS 的内功心法。

5.1 浏览器事件循环模型

text
        ┌─────────────────────────┐
        │     Call Stack          │  ← 同步代码
        │   (调用栈)               │
        └─────────────────────────┘

                    ▼ 同步代码执行完
        ┌─────────────────────────┐
        │     Microtask Queue     │  ← 微任务全清空
        │  Promise.then           │
        │  queueMicrotask         │
        │  MutationObserver       │
        └─────────────────────────┘

                    ▼ 微任务清空后
        ┌─────────────────────────┐
        │  Render(浏览器渲染)    │  ← 可能有
        └─────────────────────────┘


        ┌─────────────────────────┐
        │     Macrotask Queue     │  ← 取一个宏任务
        │  setTimeout             │
        │  setInterval            │
        │  I/O / UI 事件          │
        │  MessageChannel         │
        └─────────────────────────┘

                    └─── 循环 ──→

核心规则:

  1. 执行所有同步代码
  2. 清空所有微任务
  3. (可能)渲染
  4. 一个宏任务执行
  5. 重复 2-4

5.2 宏任务 vs 微任务

类型例子
宏任务(macrotask / task)setTimeoutsetIntervalsetImmediate(Node)、UI 事件、I/O、MessageChannel
微任务(microtask)Promise.then/catch/finallyqueueMicrotaskMutationObserverprocess.nextTick(Node,更优先)

5.3 经典面试题

js
console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 输出顺序:1 4 3 2

逐步分析:

text
[同步] console.log('1')                  → 输出 1
[同步] setTimeout 注册宏任务             → 宏任务队列:[task1]
[同步] Promise.then 注册微任务           → 微任务队列:[micro1]
[同步] console.log('4')                  → 输出 4
─────── 同步代码完成 ───────
[微任务] 执行 micro1: console.log('3')   → 输出 3
─────── 微任务清空 ───────
[宏任务] 执行 task1: console.log('2')    → 输出 2

5.4 进阶面试题

js
async function async1() {
  console.log('A1 start');
  await async2();
  console.log('A1 end');
}
async function async2() {
  console.log('A2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
  console.log('promise1');
  resolve();
}).then(() => console.log('promise2'));
console.log('script end');

/*
script start
A1 start
A2
promise1
script end
A1 end       ← await 后是微任务
promise2
setTimeout
*/

💡 await x 等价于 Promise.resolve(x).then(...剩下代码),剩下代码进入微任务。

5.5 Node.js 事件循环(补充)

Node 的事件循环更复杂,分多个阶段:

text
┌───────────────────────────┐
│   timers (setTimeout)     │
├───────────────────────────┤
│   pending callbacks       │
├───────────────────────────┤
│   idle, prepare           │
├───────────────────────────┤
│   poll (I/O)              │
├───────────────────────────┤
│   check (setImmediate)    │
├───────────────────────────┤
│   close callbacks         │
└───────────────────────────┘

每个阶段之间都会清微任务(Node 11+),process.nextTick 比 Promise 更优先。


6. 实战:常见手写题

6.1 手写 Promise.all

js
function myAll(promises) {
  return new Promise((resolve, reject) => {
    const results = [];
    let count = 0;
    promises.forEach((p, i) => {
      Promise.resolve(p).then(
        value => {
          results[i] = value;
          count++;
          if (count === promises.length) resolve(results);
        },
        reject
      );
    });
    if (promises.length === 0) resolve([]);
  });
}

6.2 限制并发请求

js
async function asyncPool(limit, tasks) {
  const results = [];
  const executing = [];
  for (const task of tasks) {
    const p = Promise.resolve().then(() => task());
    results.push(p);
    if (limit <= tasks.length) {
      const e = p.then(() => executing.splice(executing.indexOf(e), 1));
      executing.push(e);
      if (executing.length >= limit) await Promise.race(executing);
    }
  }
  return Promise.all(results);
}

7. ⚠️ 易踩的坑

  • 坑 1:忘记 await,函数原地返回 Promise。 const x = readFile()x 是 Promise,不是数据
  • 坑 2:循环里 await 串行了。 for...of + await 是串行的,需要并行用 Promise.all
  • 坑 3:try/catch 抓不到 Promise reject(除非 await)
  • 坑 4:Promise 状态一旦改变就不能再变。 多次 resolve 只生效第一次
  • 坑 5:setTimeout(fn, 0) 不是 0ms! 浏览器最小约 4ms(嵌套超过 5 层时)
  • 坑 6:await 后是微任务,不是同步。 await async2() 等价于 async2().then(剩下代码)
  • 坑 7:Promise.all 任意失败立即整体失败,残留请求不会取消。AbortController 配合
  • 坑 8:在循环里用 .forEach + async,await 不会等待。要用 for...ofmap + Promise.all

8. 一句话总结

JS 是单线程的,事件循环 + 宏任务/微任务让它能"假装"并发;Promise 是用来"装异步结果的盒子",async/await 是它的同步皮肤;写代码时,"该并行就别串行"。


9. 延伸阅读