进阶必备!Promise 取消、重试、fake timers 技巧合集

45 阅读5分钟

前言

从 Node 8 到浏览器 ES2017,Promise 早已是前端/全栈的“空气”——无处不在,却又常常让人踩坑:
“并发开多大?”“请求怎么取消?”“失败要不要重试?”“为什么又回调地狱了?”

这篇文章把我过去五年在业务中反复验证的 15 条实用技巧 一次性梳理给你。
读完你可以:

  • 10 秒内写出“并发池 + 超时 + 重试”全套代码;
  • 彻底告别 then 金字塔,用同步思维写异步;
  • 精准定位内存泄漏,单元测试不再“真等 7 秒”。

目录

  1. 回调一键变 Promise:零成本 promisify

  2. 并发限速:p-limit 让后端不崩溃

  3. 请求竞速与取消:AbortController

  4. 超时熔断:Promise.race 的 N 种玩法

  5. 优雅重试:指数退避算法

  6. 无论成败都清理:finally 的正确姿势

  7. 顺序 ≠ Promise.all:for…of 的秘密

  8. 拍平回调地狱:async/await 语法糖

  9. 单元测试 fake timers:毫秒跑完 7 秒重试

  10. 微任务与宏任务:then 和 setTimeout 谁先?

  11. allSettled:批量上报不再 try/catch 满天飞

  12. 可取消 Promise:大文件上传用户点“停”

  13. 链式共享中间值:避免地狱传参

  14. 调试黑科技:Promise 钩子

  15. 并发池到底多大?经验值与压测

1. 回调一键变 Promise:零成本 promisify

场景
老库/Node 核心模块还是 (err, value) => {} 风格。

代码

import { promisify } from 'node:util';
import fs from 'node:fs';

const readFile = promisify(fs.readFile);

const buf = await readFile('package.json'); // 直接 await

要点

  • 不要自己包 new Promise,Node 已内置;

  • 非标准回调先“包装”成标准形式再 promisify

2. 并发限速:p-limit 让后端不崩溃

场景
1000 个接口请求,并发 5,防止把下游打挂。

代码

npm i p-limit
import pLimit from 'p-limit';

const limit = pLimit(5);
const urlList = Array.from({ length: 1000 }, (_, i => `https://api.x.com/${i}`);

const data = await Promise.all(
  urlList.map(u => limit(() => fetch(u).then(r => r.json())))
);

要点

  • 一行包一层 limit(() => ...),代码量≈0;

  • 想带进度条用 p-map,支持 onProgress 回调。

3. 请求竞速与取消:AbortController

场景
用户狂点搜索框,只保留最后一次请求。

代码

let ctrl = new AbortController();

async function search(keyword) {
  ctrl.abort();                 // 取消上一次
  ctrl = new AbortController(); // 新建
  const res = await fetch(`/api/search?q=${keyword}`, { signal: ctrl.signal });
  return res.json();
}

要点

  • fetch 原生支持,无需额外库;

  • 捕获 AbortError 静默处理,UI 不抖。

4. 超时熔断:Promise.race 的 N 种玩法

场景
接口 5 秒没回直接抛错,避免白转圈。

代码

const timeout = (ms, promise) =>
  Promise.race([
    promise,
    new Promise((_, rej) =>
      setTimeout(() => rej(new Error(`Timeout after ${ms}ms`)), ms)
    )
  ]);

await timeout(5000, fetch('/api/slow'));

要点

  • 通用“套壳”,任意 Promise 都能加;

  • finally 里清掉定时器,极端场景防内存泄漏。

5. 优雅重试:指数退避算法

场景
网络抖动,自动重试 3 次,间隔 1s→2s→4s。

代码

async function retry(fn, { times = 3, delay = 1000 } = {}) {
  for (let i = 0; i < times; i++) {
    try {
      return await fn();
    } catch (e) {
      if (i === times - 1) throw e;
      await new Promise(r => setTimeout(r, delay * 2 ** i));
    }
  }
}

const data = await retry(() => fetch('/api/flaky'));

要点

  • 指数退避 2**i 是云厂商推荐策略;

  • fn 写成函数调用,每次都“新建”请求。

6. 无论成败都清理:finally 的正确姿势

场景
loading 动画、释放资源。

代码

spinner.show();
await fetch('/api')
  .finally(() => spinner.hide());

要点

  • finally 拿不到结果/错误,仅做清理;

  • 链式顺序无影响,永远最后执行。

7. 顺序 ≠ Promise.all:for…of 的秘密

场景
批量写入,后端要求串行。

错误

await Promise.all(list.map(item => create(item))); // 并发

正确

for (const item of list) {
  await create(item);
}

要点

  • map + Promise.all 是并发;

  • for…of + await 才是串行,别搞反。

8. 拍平回调地狱:async/await 语法糖

代码对比

// 回调地狱
getUser(uid, (err, user) => {
  if (err) return cb(err);
  getOrder(user.id, (err, order) => {
    if (err) return cb(err);
    getGoods(order.gid, (err, goods) => {
      if (err) return cb(err);
      cb(null, goods);
    });
  });
});

// 拍平后
const user  = await getUserAsync(uid);
const order = await getOrderAsync(user.id);
const goods = await getGoodsAsync(order.gid);

要点

  • 统一 promisify 后,异常用 try/catch 一网打尽;

  • 想再并发,可回退到 Promise.all

9. 单元测试 fake timers:毫秒跑完 7 秒重试

代码

jest.useFakeTimers();
jest.spyOn(global, 'setTimeout');

const pending = retry(fn, { times: 3, delay: 1000 });
for (let i = 0; i < 3; i++) {
  jest.runAllTimers();      // 一口气跑完
  await Promise.resolve();  // 微任务 flush
}

要点

  • 不用真等 7 秒;

  • flush-promises 一行搞定 await Promise.resolve()

10. 微任务与宏任务:then 和 setTimeout 谁先?

代码

console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
console.log(4);
// 输出:1 4 3 2

要点

  • then 属微任务,比宏任务 setTimeout 先执行;

  • 动画、日志、埋点别指望 setTimeout 0 一定比 then 快。

11. allSettled:批量上报不再 try/catch 满天飞

代码

const arr = await Promise.allSettled(
  urls.map(u => fetch(u))
);
arr.forEach(({ status, value, reason }) =>
  status === 'fulfilled'
    ? console.log('成功', value)
    : console.log('失败', reason)
);

要点

  • allSettled 永不抛错,适合批量上报;

  • 老浏览器用 core-js polyfill。

12. 可取消 Promise:大文件上传用户点“停”

代码

npm i p-cancelable
import PCancelable from 'p-cancelable';

const job = new PCancelable((resolve, reject, onCancel) => {
  const xhr = new XMLHttpRequest();
  xhr.open('POST', '/big');
  xhr.send(file);
  onCancel(() => xhr.abort());
  xhr.onload = () => resolve(xhr.responseText);
});

button.onclick = () => job.cancel();
const txt = await job;

要点

  • 取消 ≠ 回滚,已上传需后端配合校验;

  • fetch 一旦 abort() 无法复活,记得新建请求对象。

13. 链式共享中间值:避免地狱传参

代码

const A = await getA();
const [B, C] = await Promise.all([getB(A), getC(A)]);

要点

  • async 函数内变量直接共享,不必像 then 链层层传参。

14. 调试黑科技:Promise 钩子

代码

import { setPromiseHooks } from 'node:async_hooks';
setPromiseHooks({
  init(promise) {
    console.log('创建', promise);
  },
  resolve(promise) {
    console.log('完成', promise);
  }
});

要点

  • 性能分析、内存泄漏定位时打开;

  • 生产环境记得关,避免日志爆炸。

15. 并发池到底多大?经验值与压测

| 场景 | 推荐并发 | | | | | 浏览器同一域名 | 6~10 | | Node CPU 密集 | os.cpus().length * 2 | | Node IO 密集 | 可再翻倍,务必压测 |

速查表(Cheatsheet)

| 需求 | 一句话方案 | | | | | callback → Promise | promisify / new Promise | | 并发上限 | p-limit | | 超时 | Promise.race + setTimeout | | 重试 | for 循环 + 指数退避 | | 取消 | AbortController / PCancelable | | 顺序 | for…of + await | | 全部完成不抛错 | Promise.allSettled | | 不管成败都清理 | .finally() |

结语

掌握这 15 招,你将获得:

  • 代码量 −50%、异常堆栈 −80%
  • 线上超时/重试/取消 零配置 可用;
  • 测试用例 毫秒级 跑完,CI 直接起飞。

把本文点赞收藏,下次写异步不用谷歌,复制即可。
祝你编码愉快,Promise 永不 pending!