跳至正文
来两杯美式
返回

前端核心(三):Promise 与异步编程详解

By 来两杯美式
发布于

想要真正玩转现代前端,搞懂 Promise 的底层机制、链式调用以及宏任务/微任务的执行顺序是必经之路。这篇攻略将带你从零到一彻底吃透 Promise。


一、什么是 Promise?(三大状态与生命周期)

1.0 为什么需要 Promise?

JavaScript 是单线程语言——同一时刻只能做一件事。但现实中的操作往往很慢:请求接口、读取文件、下载图片……如果代码傻等这些操作完成再继续,整个页面就冻住了。

所以 JS 采用了异步编程:发起慢操作后不等待,先往下跑,等结果回来了再处理。

最早的写法是回调函数——把”拿到结果后做什么”作为函数传进去:

// 这些函数内部都是异步的——发网络请求,不等结果就先返回
function getUser(id, callback) {
  var xhr = new XMLHttpRequest();
  xhr.open("GET", "/api/user/" + id);
  xhr.onload = function () {
    if (xhr.status === 200) {
      var user = JSON.parse(xhr.responseText);
      callback(user); // 把 user 传给回调
    }
  };
  xhr.send(); // 发出请求,函数立刻返回,等响应回来再调 callback
}
function getOrders(userId, callback) {
  /* 同理 */
}
function getShipping(orderId, callback) {
  /* 同理 */
}

// 回调写法:先查用户,再查订单,再查物流
getUser(userId, function (user) {
  getOrders(user.id, function (orders) {
    getShipping(orders[0].id, function (shipping) {
      console.log(shipping); // 终于拿到了
    });
  });
});

三层就很难读了,真实项目里五层六层是家常便饭——这就是著名的回调地狱(Callback Hell):代码横向生长、错误处理混乱、逻辑难以追踪。

Promise 就是为了解决回调地狱而生的。 同样的逻辑,用 Promise 链式调用改写:

// 以下 getUser/getOrders/getShipping 是返回 Promise 的新版函数,与上面的回调版签名不同
getUser(userId)
  .then(user => getOrders(user.id))
  .then(orders => getShipping(orders[0].id))
  .then(shipping => console.log(shipping))
  .catch(err => console.error(err)); // 任何一步出错,统一捕获

代码从”向右缩进”变成”向下排列”,逻辑清晰,错误处理也不再四处散落。

后来 ES2017 又推出了 async/await,在 Promise 之上再加一层语法糖,让异步代码看起来和同步代码一模一样:

const user = await getUser(userId);
const orders = await getOrders(user.id);
const shipping = await getShipping(orders[0].id);

所以演进路线是:回调函数 → Promise → async/await。Promise 是承上启下的关键——理解了它,async/await 自然就会了。

async/await 并没有替代 Promise。 它是 Promise 的语法糖——await 后面接的就是 Promise,编译后跑的也是 Promise。两者是搭档,不是替代:

  • async/await 处理串行逻辑:一步接一步,写起来像同步代码,阅读体验好
  • Promise 静态方法处理并发场景Promise.allPromise.racePromise.any 这些并发调度,async/await 没有等价写法,必须直接用 Promise
// 串行写法:一个接一个等,总耗时 = 请求1 + 请求2 + 请求3
const users = await fetchUsers(); // 等2秒
const orders = await fetchOrders(); // 再等3秒
const products = await fetchProducts(); // 再等1秒
// 总共等了 6 秒

// 并发写法:三个请求同时发出,总耗时 = 最慢的那个
const [users, orders, products] = await Promise.all([
  fetchUsers(), // ┐
  fetchOrders(), // ├─ 同时发出,同时等
  fetchProducts(), // ┘
]);
// 总共只等了 3 秒(取最长的那个)

实际开发中的常态是:async/await 为主,Promise 静态方法打配合。两者混着用才完整。

技术定义

PromiseES6(ECMAScript 2015) 中被正式纳入 JavaScript 语言规范。它是内置对象——就像 ArrayObjectDate 一样,只要你在浏览器或 Node.js 环境里写 JS,直接用 new Promise() 就能跑,不需要引入任何第三方库。规范由 TC39(ECMA 国际技术委员会第 39 号)制定,他们决定了 JS 语言每年会长出什么新特性。

抽象来说,Promise 是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。

1.1 核心:三大状态

一个 Promise 必然处于以下三种状态之一,且状态的流转是单向、不可逆的:

铁律:状态只能从 Pending -> FulfilledPending -> Rejected。一旦状态改变,就会永远”凝固”,之后任何操作都无法再改变它的状态。

注意:Fulfilled 和 Rejected 统称为 Settled(已定型)。Settled 不是第三种状态,而是对两种终态的统称——表示 Promise 已经尘埃落定。

1.2 基础语法

const myPromise = new Promise((resolve, reject) => {
  // 执行异步操作
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("这是成功返回的数据"); // 将状态变为 Fulfilled
    } else {
      reject("这是错误信息"); // 将状态变为 Rejected
    }
  }, 1000);
});

二、消费 Promise:实例方法

当 Promise 状态改变后,我们需要通过它的实例方法来编写后续的业务逻辑。

2.1 .then().catch().finally()

穿透特性.finally() 的回调不接收参数,且会穿透原始值或拒因——即 .finally() 后面链的 .then() 拿到的仍然是 .finally() 之前的值,不会被吞掉。

Promise.resolve("hello")
  .finally(() => {
    console.log("清理资源"); // 不接收参数,也不知道前面的值
  })
  .then(val => {
    console.log(val); // "hello" —— 值穿透了 finally
  });

2.2 核心难点:.then() 的链式调用与”返回值规则”

.then() 方法执行后,一定会返回一个新的 Promise 实例,这也是为什么我们可以一直 .then().then() 链式调用的原因。新 Promise 的状态由上一个 .then() 回调函数的返回值决定:

  1. 返回普通值(或不返回):下一个 .then 的新 Promise 会立刻变成 Fulfilled,并将这个值传给下一个 .then
  2. 抛出错误(throw new Error:新 Promise 会立刻变成 Rejected,并被最近的 .catch 捕获。
  3. 返回一个新的 Promise:新 Promise 的状态和结果,完全取决于返回的这个 Promise 的状态(这正是串行异步请求的核心原理)。
// 示例:串行逻辑
fetchUser()
  .then(user => {
    // 规则 3:返回一个新的 Promise
    return fetchUserOrders(user.id);
  })
  .then(orders => {
    // 规则 1:返回普通值
    return orders.length;
  })
  .then(count => {
    console.log(`订单总数:${count}`);
  })
  .catch(err => {
    // 整个链条中任何一个地方报错,都会直接跳到这里
    console.error(err);
  });

三、并发调度:六大静态方法

除了实例方法,Promise 构造函数本身还提供了 6 个极其强大的并发调度工具。

方法核心机制适用场景
Promise.all([p1, p2])全胜才胜,一败俱败。所有人成功才返回结果数组;只要有一个失败,立刻返回该失败。同时请求多个独立接口,全部拿到后才渲染页面。
Promise.allSettled([p1, p2])永不失败。等待所有人结束(无论成败),返回包含每个 Promise 最终状态和结果的数组。多个接口互不影响,只需要知道它们是不是都执行完了(如批量上传)。
Promise.race([p1, p2])速度对决。看谁跑得快,最先改变状态的(无论成败)作为最终结果。异步操作超时控制(让接口请求和超时定时器赛跑)。
Promise.any([p1, p2])一胜即胜,全败才败。只要有一个成功就返回该成功;如果全部失败,抛出 AggregateError。多源检测。比如从三个不同 CDN 镜像下载同一个文件,谁快用谁。
Promise.resolve(val)直接创建一个处于 Fulfilled 状态的 Promise。将现有的同步数据包裹成 Promise,兼容异步接口。
Promise.reject(reason)直接创建一个处于 Rejected 状态的 Promise。快速构建一个拒绝状态,用于测试或中断链条。

注意Promise.all 的”一败俱败”不等于取消其余 Promise。某个 Promise reject 后,Promise.all 返回的 Promise 立即 reject,但其余 Promise 仍然会执行完毕,只是结果被忽略。如果你需要真正取消,应使用 AbortController


四、终极语法糖:async / await

虽然 Promise 解决了回调地狱,但过多的 .then() 依然会让代码横向延伸。ES2017 引入了 async/await,允许我们用同步的写法来写异步代码

4.1 黄金法则

暂停的是函数,不是程序。 await 把当前函数从调用栈上拿下来,事件循环继续跑别的代码(页面不卡、其他函数正常执行)。等 Promise resolve 了,函数从暂停的位置恢复执行。对函数内部来说感觉像同步,对整个程序来说没有任何阻塞。

4.2 错误处理

由于没有了 .catch() 链条,在 async/await 中我们使用传统的 try...catch 来捕获异常:

async function getUserData() {
  try {
    const user = await fetchUser(); // 暂停,等结果
    const orders = await fetchOrders(user.id); // 再暂停,等结果
    return orders;
  } catch (error) {
    console.error("捕获到异步错误:", error);
  }
}

五、底层进阶:Promise 与事件循环(Event Loop)

要想真正精通 Promise,必须明白它在 JavaScript 执行机制中的位置——微任务(Microtask)。而要理解微任务,首先要理解事件循环。

5.0 什么是事件循环

JavaScript 是单线程的——同一时刻只能执行一段代码。但现实中同时有很多事要处理:定时器到了、网络请求回来了、用户点了按钮……这些回调不可能同时执行,得排队。事件循环就是那个”叫号”的机制,它决定谁先跑谁后跑。

用伪代码理解事件循环的本质:

while (true) {
  if (宏任务队列里有任务) {
    取出一个宏任务执行它
    把微任务队列里的任务全执行完
可能渲染页面
  }
}

事件循环是谁提供的?

事件循环不是 JavaScript 语言本身的特性,而是宿主环境提供的。

所以 Promise.then 回调怎么排队,是 ECMAScript 说了算;但什么时候轮到它执行,是宿主环境(浏览器/Node.js)的事件循环说了算。

渲染什么时候发生?

事件循环每一轮末尾,浏览器可能会渲染页面。关键词是”可能”——不是每一轮都渲染:

场景是否渲染
60Hz 屏幕,距上次渲染约 16.7ms渲染(对齐屏幕刷新率)
距上次渲染才过了 5ms跳过,等下一个刷新周期
页面在后台标签页大幅降频(约 4fps),甚至不渲染
没有任何 DOM 变化跳过

这意味着:多个宏任务可能在同一帧内连续执行,只在最后统一渲染一次。

Task 1 → 微任务清空 → Task 2 → 微任务清空 → Task 3 → 微任务清空 → 渲染(一次)
|_________________________ 16.7ms 一帧 _________________________|

这也是为什么 requestAnimationFrame 既不是宏任务也不是微任务——它在渲染阶段执行,在微任务之后、实际绘制之前。

5.1 两种任务

JavaScript 是单线程的。同步代码直接在调用栈上执行,不经过事件循环;而异步回调需要排队等事件循环来调度,这些异步回调被分为宏任务(Macrotask)微任务(Microtask)

术语纠正:社区习惯叫”宏任务”,但规范里的正式术语是 Task(任务),没有 “Macrotask” 这个词——它是社区为了跟 “Microtask” 对称而造的说法。真正的术语对应关系:

社区说法规范正式术语出处
宏任务 / MacrotaskTaskHTML 规范
宏任务队列Task QueueHTML 规范
微任务 / MicrotaskMicrotaskHTML 规范
微任务队列Microtask QueueHTML 规范
Promise 回调的内部队列PromiseJobsECMAScript 规范

两个规范的衔接:HTML 规范定义了事件循环的结构(Task Queue + Microtask Queue),ECMAScript 规范定义了 PromiseJobs。HTML 规范要求浏览器将 ECMAScript 的 EnqueueJob 操作映射到 Microtask Queue——所以 Promise 的 .then/catch/finally 回调就进了微任务队列。

注意requestAnimationFrame 既不是宏任务也不是微任务,它是浏览器渲染流水线中的一步——在微任务执行完之后、浏览器绘制之前执行。requestIdleCallback 则是优先级最低的 Task,只在浏览器空闲时才执行。

5.2 事件循环怎么跑

事件循环的核心规则只有一条:每次只执行一个宏任务,但必须把微任务队列全部执行完,才能进入下一个宏任务。

用一个完整的流程图来理解:

┌─────────────────────────────────────────────────┐
│  事件循环(一轮)                                   │
│                                                   │
│  ① 从宏任务队列取出一个任务,放进调用栈执行            │
│     ↓                                             │
│  ② 执行过程中,可能往微任务队列里塞入新的微任务         │
│     (比如 .then 回调、queueMicrotask)             │
│     ↓                                             │
│  ③ 宏任务执行完毕,调用栈空了                         │
│     ↓                                             │
│  ④ 从微任务队列逐个取出任务执行                       │
│     执行一个微任务时,又可能产生新的微任务               │
│     → 继续取,继续执行,直到微任务队列彻底为空          │
│     ↓                                             │
│  ⑤ 微任务队列空了 → 浏览器可能渲染页面(rAF 在这执行)  │
│     ↓                                             │
│  回到 ①,取下一个宏任务                              │
└─────────────────────────────────────────────────┘

关键理解点:

  1. “清空微任务队列”= 逐个取出来执行,直到没有为止,不是丢弃、不是跳过,是老老实实一个一个跑完
  2. 微任务可以在执行中产生新的微任务(比如 .then 链),新产生的也会在当前这轮被一起执行完
  3. 微任务的优先级高于下一个宏任务——不管宏任务等了多久,只要微任务队列里有东西,就必须先处理

用生活中的例子来理解:

去银行办业务。柜员(主线程)一次只服务一个客户(宏任务),但这个客户可能有好几个小需求(微任务)——存钱、改密码、打印流水。柜员必须把当前客户的所有小需求都办完,才能叫下一个号。中间如果又产生新的小需求(比如改密码时发现要先解锁),也必须在当前客户这里处理完。

5.3 经典面试题测试

你能快速说出以下代码的打印顺序吗?

console.log("1"); // 同步

setTimeout(() => {
  console.log("2"); // 宏任务
}, 0);

Promise.resolve()
  .then(() => {
    console.log("3"); // 微任务
  })
  .then(() => {
    console.log("4"); // 微任务的微任务
  });

console.log("5"); // 同步

逐行推演:

  1. 脚本作为第一个宏任务开始执行。
  2. console.log('1')——同步,直接打印 1
  3. setTimeout(..., 0)——遇到定时器,回调函数丢进宏任务队列,不执行。
  4. Promise.resolve().then(...)——遇到 .then,回调函数丢进微任务队列,不执行。
  5. console.log('5')——同步,直接打印 5
  6. 第一个宏任务(脚本)执行完了,开始逐个执行微任务队列
    • 取出第一个微任务,打印 3。执行时第二个 .then 被触发,新微任务丢进队列。
    • 继续取,打印 4。微任务队列空了。
  7. 微任务队列清空,取下一个宏任务:执行 setTimeout 回调,打印 2

最终顺序1 → 5 → 3 → 4 → 2


六、实战技能:如何用 Promise 解决实际问题?

6.1 实现一个 Promise 超时控制(Promise.race

function timeoutWrapper(requestPromise, timeout = 3000) {
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => reject(new Error("请求超时!")), timeout);
  });

  // 赛跑:谁快听谁的
  return Promise.race([requestPromise, timeoutPromise]);
}

// 使用
timeoutWrapper(fetch("/api/data"), 2000)
  .then(res => console.log(res))
  .catch(err => console.error(err)); // 如果2秒内没返回,触发超时错误

6.2 异步并发限制调度器(高频大厂面试题)

如果有一百个分片上传请求,同时并发会撑爆浏览器。如何限制同一时刻只有 N 个请求在执行?

class ConcurrentScheduler {
  constructor(limit) {
    this.limit = limit; // 最大并发数
    this.runningCount = 0; // 当前正在运行的任务数
    this.queue = []; // 等待队列
  }

  add(task) {
    return new Promise((resolve, reject) => {
      // 将任务包装后放入队列
      this.queue.push({ task, resolve, reject });
      this.runNext();
    });
  }

  runNext() {
    if (this.runningCount >= this.limit || this.queue.length === 0) return;

    this.runningCount++;
    const { task, resolve, reject } = this.queue.shift();

    task()
      .then(resolve)
      .catch(reject)
      .finally(() => {
        this.runningCount--;
        this.runNext(); // 释放位置后,立刻执行下一个
      });
  }
}

// 使用示例
const scheduler = new ConcurrentScheduler(2); // 限制并发数为 2
const timeoutTask = (time, id) => () =>
  new Promise(resolve =>
    setTimeout(() => {
      console.log(`任务 ${id} 完成`);
      resolve(id);
    }, time)
  );

scheduler.add(timeoutTask(1000, "A"));
scheduler.add(timeoutTask(500, "B"));
scheduler.add(timeoutTask(300, "C")); // 会在 B 完成后立刻加入执行

掌握了 Promise 的状态流转链式返回规则静态并发 API 以及事件循环微任务机制,你就拿到了通往现代前端高级架构的入场券。希望这份攻略能帮你彻底扫清异步盲区!


分享这篇文章:
通过邮件分享这篇文章✓ 链接已复制
查看系列全部文章
  1. 01.前端核心(一):DOM 入门
  2. 02.前端核心(二):React 必备 JS 语法速查
  3. 03.前端核心(三):Promise 与异步编程详解
  4. 04.前端核心(四):React 必备 TS 语法速查

上一篇
前端核心(四):React 必备 TS 语法速查
下一篇
前端核心(二):React 必备 JS 语法速查