从回调函数到Promise

0 阅读21分钟

最近在面试中遇到了很多关于 Promise 的问题,因为以前的业务在请求方面并不复杂,多数时候都是在用 async/await,对 Promise 的理解还是有所欠缺,最近重新学习了一下 Promise,尽量避免写成API式的文章,主要还是结合自己的一些理解和思考来整理一下。

为什么要使用 Promise

众所周知,JavaScript 的主线程是单线程执行的,所有的同步代码都是在一个线程中执行的,当遇到一些耗时操作时(比如网络请求、文件读取等),如果采用同步的方式去处理这些操作,就会阻塞主线程,导致页面卡顿,用户体验变差。为了解决这个问题,我们发明了异步编程,最早的异步编程方式是回调函数(Callback),我们先看一个简单的例子:

function add(getX, getY, finalCallback) {
  var x, y;
  getX(function (xVal) {
    x = xVal;
    if (y !== undefined) {
      finalCallback(x + y);
    }
  });

  getY(function (yVal) {
    y = yVal;
    if (x !== undefined) {
      finalCallback(x + y);
    }
  });
}

function fetchX(xCallback) {
  setTimeout(function () {
    xCallback(2);
  }, 1000);
}

function fetchY(yCallback) {
  setTimeout(function () {
    yCallback(3);
  }, 1000);
}

add(fetchX, fetchY, function (sum) {
  console.log("Sum is: " + sum);
});

fetchXfetchY 是两个异步函数,分别模拟从服务器获取数据的过程,我们要进行 x+y 的计算,如果它们中的任何一个还没有准备好,就等待两者都准备好。我们逐步拆解这个过程:

  1. 调用 add 函数,传入 fetchXfetchY 和回调函数。

  2. add 函数内部,调用 getX(即 fetchX),传入一个回调函数。

function (xVal) {
  x = xVal;
  if (y !== undefined) {
    finalCallback(x + y);
  }
}
  1. fetchX 开始执行,经过1秒钟后,调用传入的回调函数(xCallback),将 2 作为参数传递进去。

  2. 回调函数执行,x 被赋值为 2,然后检查 y 是否已经准备好(即 y 是否不为 undefined)。此时 y 还没有准备好,所以不会调用最终的 finalCallback

  3. 同样的过程发生在 getY(即 fetchY)上,经过1秒钟后,y 被赋值为 3,然后检查 x 是否已经准备好。此时 x 已经准备好了(x=2),所以调用 finalCallback,计算出最终的结果 5,并打印出来。

从这个例子中,我们是否能看出使用回调函数来处理异步操作存在一些问题?首先,也许这个思路很巧妙,但是代码很复杂,我在逐步拆解前很难直接理解这个过程。其次,如果有更多的异步操作需要处理,代码会变得更加复杂,难以维护,这就是著名的“回调地狱”问题。

回想我刚上班时,使用的还是 jQuery,jQuery 的 Ajax 请求就是基于回调函数的,代码如下:

$.ajax({
  url: "https://api.example.com/data",
  method: "GET",
  success: function (data) {
    console.log("Data received:", data);
    $.ajax({
      url: "https://api.example.com/more-data",
      method: "GET",
      success: function (moreData) {
        console.log("More data received:", moreData);
        // 继续嵌套更多的回调...
      },
      error: function (err) {
        console.error("Error fetching more data:", err);
      },
    });
  },
  error: function (err) {
    console.error("Error fetching data:", err);
  },
});

显然,随着嵌套层级的增加,代码变得越来越难以阅读和维护,而且错误处理也变得复杂。所以回收这一节的标题,因为用回调函数来处理异步操作确实存在一些问题:

  1. 可读性差:嵌套的回调函数使代码难以理解。
  2. 错误处理复杂:每个回调函数都需要单独处理错误,导致代码冗长。
  3. 控制流困难:管理多个异步操作的顺序和依赖关系变得复杂。

等讲完 Promise 之后我们看下 Promise 是否能解决这些问题。

Promise

是什么

通俗的说,我们可以把 Promise 理解成一个异步操作的代理,它是异步操作的返回值,原本只有同步操作才能有返回值,异步操作只能使用我们上面所说的回调函数嵌套来获得结果。

异步方法不会立即返回最终值,而是返回一个 Promise,以便在将来的某个时间点提供该值。

Promise 的基本用法应该都很熟悉了,我们创建一个 Promise 的例子:

// ES6 原生 Promise
const asyncTask = new Promise((resolve, reject) => {
  // 模拟异步操作(比如接口请求、文件读取)
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("操作成功"); // 成功回调
    } else {
      reject("操作失败"); // 失败回调
    }
  }, 1000);
});

// 调用 Promise
asyncTask
  .then((result) => console.log(result)) // 输出:操作成功
  .catch((error) => console.log(error))
  .finally(() => console.log("操作完成"));

可以看到,我们把异步操作 setTimeout 包装在 Promise 中,然后通过 thencatchfinally 来处理结果和错误,setTimeout 可以是任意异步操作,比如网络请求、文件读取等。

隐藏在这些 API 之下的还有一个参数,一个 Promise 必然处于以下三种状态之一:

  • Pending(进行中):初始状态,既不是成功,也不是失败。
  • Fulfilled(已成功):操作成功完成。
  • Rejected(已失败):操作失败。

这一部分内容可以参考 MDN 的 Promise - JavaScript | MDN,讲得很清楚。

参考这张图,Pending 状态通向两个结果:FulfilledRejected,这个过程是单向不可逆的,一旦状态改变,就会永久保持该状态。当任意一种情况发生时,then 方法注册的回调函数就会被调用,即不再处于"待定"(Pending)状态,称之为"已敲定"(Settled)。

Rejected

我们先看 Rejected 的情况:

// catch
const failedTask = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject("操作失败"); // 失败回调
  }, 1000);
});

failedTask
  .then((result) => console.log(result))
  .catch((error) => console.log(error)) // 输出:操作失败
  .finally(() => console.log("操作完成"));

// then 第二个参数
const anotherFailedTask = new Promise((resolve, reject) => {
  setTimeout(() => {
    reject("操作失败"); // 失败回调
  }, 1000);
});

anotherFailedTask
  .then(
    (result) => console.log("成功:" + result),
    (error) => console.log("失败:" + error),
  ) // 输出:操作失败
  .finally(() => console.log("操作完成"));

有两种方式可以捕获 Promise 的拒绝状态:一种是使用 catch 方法,另一种是将错误处理函数作为 then 方法的第二个参数传入。两种方式都能有效地处理 Promise 的拒绝状态,如果不进行错误处理,未捕获的拒绝会导致未处理的 Promise 拒绝警告。更详细的说明我们后面再聊,这里只看用法。

Fulfilled

在构造器 Promise(..) 中,我们通常用两个回调函数来表示成功和失败的情况,这两个函数的命名并不固定,通常我们使用 resolverejectreject 很清楚地表示失败,并且代表 Promise 进入 Rejected 状态,而成功的回调函数 resolve(决议),它表示 Promise 进入 Fulfilled 状态,这里用 ES6 规范中的回调命名来说明:

myPromise.then((result) => onFulfilled, onRejected)

链式调用

在提到 Promise 时,链式调用是一个非常重要的概念,上面的例子中,我们看到 Promise 对象可以调用 then 方法,而 then 方法又可以调用 catchfinally 方法,因为 then 方法返回的仍然是一个 Promise 对象,而 catchfinally 方法内在内部调用的也是 then 方法,这样它们就可以链式调用。

// Promise.resolve这种写法我们之后讨论
Promise.resolve('第一步结果')
  .then(res => {
    console.log(res); // 打印:第一步结果
    // return 普通值 → 新 Promise 状态为 fulfilled
    return '第二步结果';
  })
  .then(res => {
    console.log(res); // 打印:第二步结果
    // return 新 Promise → 新 Promise 跟随该 Promise 的状态
    return Promise.resolve('第三步结果');
  })
  .then(res => {
    console.log(res); // 打印:第三步结果
  });

通过例子可以看到,链式调用可以将多个异步操作串联起来,每个 then 方法处理上一个 Promise 的结果,这就解决了我们最开始提到的回调地狱问题,使代码更加清晰和易于维护。

这个例子中还有一个细节,在 then 方法中通过 return 来传递值,当使用 return 返回一个普通值时,新的 Promise 会进入 Fulfilled 状态,也可以返回一个新的 Promise 对象,这样新的 Promise 会跟随该 Promise 的状态。值得注意的是,如果返回的是一个 thenable 对象(具有 then 方法的对象),Promise 也会等待该对象解决,这使得 Promise 可以与其他实现了类 Promise 接口的库进行互操作。

大体上我们了解了 Promise 的用法,我们用 Promise 来实现嵌套异步操作:

function getFirstData() {
  // 返回一个 Promise,用 setTimeout 模拟异步
  return new Promise((resolve) => {
    setTimeout(() => {
      const data = "第一个异步操作的结果";
      console.log("Data received:", data);
      // 异步成功,传递结果给下一个 .then()
      resolve(data);
    }, 1000);
  });
}

function getSecondData(prevData) {
  return new Promise((resolve) => {
    setTimeout(() => {
      const moreData = `第二个异步操作的结果(基于上一步:${prevData})`;
      console.log("More data received:", moreData);
      resolve(moreData); // 可选:继续传递结果给后续链式调用
    }, 1000);
  });
}

// 链式调用
getFirstData()
  .then((data) => {
    // 第一个异步成功后,执行第二个异步
    return getSecondData(data);
  })
  .catch((err) => {
    // 统一捕获所有异步操作的错误
    console.error("异步操作出错:", err);
  });

Promise 解决了什么问题

通过这个例子可以看到,我们一开始提出的回调函数的三个问题得到了不同程度的解决:

  1. 可读性提升:通过链式调用,代码结构更加清晰,每个异步操作都在自己的 then 块中处理,避免了嵌套回调的复杂性。
  2. 统一错误处理:使用 catch 方法可以统一捕获所有异步操作的错误,简化了错误处理逻辑。
  3. 控制流简化:通过链式调用,可以更容易地管理多个异步操作的顺序和依赖关系,使代码更易于理解。

这里有点像一种 if 语句的替代写法:

if (condition1) {
  // do something
  if (condition2) {
    // do something
    if (condition3) {
      // do something
    }
  }
}

// 可以改写为:

if(!condition1) return;
// do something
if(!condition2) return;
// do something
if(!condition3) return;
// do something

换个思路,作用相同,但代码的可读性会变高,不过 Promise 要复杂得多,我没有直接使用一开始的回调函数版本来对比,并非做不到,而是涉及了新的知识点,需要用到 Promise 的一些 API,我打算换一种角度来理解,然后我们再回头看这个对比。

Promise 的 API 与原型

Promise 是 ES6(ES2015)引入的一种用于处理异步操作的对象,最近刚写了一篇关于原型的文章:对于原型、原型链和继承的理解,这里就是想从原型和面向对象的角度来加深一下理解,我们还是用前面的例子,分步拆解:

// ES6 原生 Promise
const asyncTask = new Promise((resolve, reject) => {
  // 模拟异步操作(比如接口请求、文件读取)
  setTimeout(() => {
    const success = true;
    if (success) {
      resolve("操作成功"); // 成功回调
    } else {
      reject("操作失败"); // 失败回调
    }
  }, 1000);
});

// 调用 Promise
asyncTask
  .then((result) => console.log(result)) // 输出:操作成功
  .catch((error) => console.log(error))
  .finally(() => console.log("操作完成"));

构造函数 Promise()

先从核心语句说起, new Promise((resolve, reject) => { ... }) 这里事关两个概念:构造函数和 new

Promise() 是一个构造器(Constructor)或者说构造函数,用于创建 Promise 对象。

使用构造函数的形式来创建对象有几个好处:

  1. 封装初始化逻辑Promise 构造函数内部封装了初始化 Promise 对象所需的逻辑,比如设置初始状态(pending)、设置回调函数(resolvereject)。

  2. 共享方法:通过构造函数创建的对象实例可以共享原型上的方法(如 thencatchfinally),避免每个实例都创建一份相同的方法,节省内存。

  3. 立即执行:当我们创建一个新的 Promise 实例时,传入的执行器函数(executor function)会立即执行,这使得我们可以在创建 Promise 的同时开始异步操作。

new 关键字用于创建一个新的对象实例,并将其原型链接到构造函数的原型对象上,也就是让新创建的对象继承构造函数原型上的方法和属性,结合上面的例子就是说我们创建的 asyncTask 对象会继承 Promise.prototype 上的方法,比如 thencatchfinally,这也就是为什么我们可以在 asyncTask 上调用这些方法,以及进行前面所说的链式调用。

要注意的一点是,执行器函数的返回值对 Promise 的影响有限,在 then 方法中我们通过 return 来传递值,但在执行器函数中 return 语句仅影响控制流程,并不会直接改变 Promise 的状态,Promise 的状态只能通过调用 resolvereject 来改变。

const myPromise = new Promise((resolve, reject) => {
  // 一些异步操作
  if (/* 操作成功 */) {
    resolve("成功结果");
  } else {
    reject("失败原因");
  }
  return "这个返回值不会影响 Promise 的状态";
});

入参 (resolve, reject) => { ... }

接下来我们看构造函数的入参 (resolve, reject) => { ... } ,也就是执行器函数(executor function),它会在 Promise 实例创建时立即执行,这个上面说过了。使用 Promise 时,我们不会关注执行器函数,主要是使用这个函数的入参 resolvereject 用来改变 Promise 的状态。

function executor(resolveFunc, rejectFunc) {
  // 通常,`executor` 函数用于封装某些接受回调函数作为参数的异步操作,比如上面的 `setTimeout` 函数
}

当调用 resolvereject 时,Promise 的状态会立即改变,从 pending 变为 fulfilledrejected,然后执行回调函数,这个回调函数就是我们通过 then 方法注册的函数。

const p = new Promise((resolve) => {
  console.log('1. 执行器函数立即执行');
  resolve('成功');
  console.log('2. resolve 调用完成(同步)');
});

console.log('3. Promise 创建完成');

p.then((value) => {
  console.log('5. then 回调执行:', value);
});

console.log('4. then 方法调用完成');

// 输出顺序:
// 1. 执行器函数立即执行
// 2. resolve 调用完成(同步)
// 3. Promise 创建完成
// 4. then 方法调用完成
// 5. then 回调执行: 成功

但我们用到 Promise 时主要还是用于异步任务,then 方法是典型的微任务(microtask),如果 then 方法先执行,里面的回调函数会被放入微任务队列,等待当前宏任务执行完毕后再执行。

对于更细致的执行顺序,之前有写过一篇关于事件循环的文章,刚好是用 Promise 举例,可以参考:有关 JavaScript 事件循环的若干疑问探究

Promise 内部的大致逻辑是这样的:

// Promise 内部简化实现
class MyPromise {
  constructor(executor) {
    this.state = 'pending';           // 状态
    this.value = undefined;           // 结果值
    this.onFulfilledCallbacks = [];   // ← 存储 then 的成功回调
    this.onRejectedCallbacks = [];    // ← 存储 then 的失败回调

    const resolve = (value) => {
      if (this.state === 'pending') {
        this.state = 'fulfilled';
        this.value = value;
        // ← 关键:遍历回调队列,将所有回调加入微任务
        this.onFulfilledCallbacks.forEach(callback => {
          queueMicrotask(() => callback(value));
        });
      }
    };

    const reject = (reason) => {
      if (this.state === 'pending') {
        this.state = 'rejected';
        this.value = reason;
        this.onRejectedCallbacks.forEach(callback => {
          queueMicrotask(() => callback(reason));
        });
      }
    };

    executor(resolve, reject);
  }

  then(onFulfilled, onRejected) {
    // 如果 Promise 还是 pending,就把回调存起来
    if (this.state === 'pending') {
      this.onFulfilledCallbacks.push(onFulfilled);  // ← 存储回调
      this.onRejectedCallbacks.push(onRejected);
    }
    // 如果 Promise 已经 fulfilled,立即将回调加入微任务
    else if (this.state === 'fulfilled') {
      queueMicrotask(() => onFulfilled(this.value));
    }
    // 如果 Promise 已经 rejected
    else if (this.state === 'rejected') {
      queueMicrotask(() => onRejected(this.value));
    }

    return new MyPromise(() => {}); // 简化,实际更复杂
  }
}

所以 then 中的回调函数被执行的前提是 resolvereject 被调用并且 then 方法也被调用,这也是 Promise 能处理异步操作的关键。

静态方法

简单提一下,静态方法是直接挂载在构造函数上的方法,而不是实例对象上,以前面的例子来说,asyncTaskPromise 的一个实例对象,而 Promise.all(..)Promise.resolve(..) 这种则是 Promise 构造函数的一个静态方法。

基于上面的简单例子,Promise 大体上的用法我们已经了解了,但还有很多 API 没有涉及到,我们可以通过打印 Promise 的原型来查看:

console.log(Promise.prototype);

我们可以看到 thencatchfinally 方法都在 Promise.prototype 上,这些方法是实例方法,意味着它们可以被任何 Promise 实例调用。

由于安全机制,直接打印 Promise 本身是看不到原生代码的,我们换一种方式,只需要得到静态方法名就行:

console.log(Object.getOwnPropertyNames(Promise));

// 输出:['length', 'name', 'prototype', 'all', 'allSettled', 'any', 'race', 'resolve', 'reject', 'withResolvers', 'try']

输出结果中有的熟悉有的不熟悉,因为我之前对 Promise 仅停留在会用的层面,所以有些我甚至是第一次知道,但没关系,通过原型再对照 MDN 文档,逐个学习一下。 lengthnameprototype 是函数对象的默认属性,我们主要关注其他的静态方法:

  1. Promise.resolve(..)Promise.reject(..)

这两个方法应该是最常见的了,上面的例子中也用到过,reject 比较简单,返回一个拒绝状态的 Promise 对象,入参就是拒绝的原因:

const promiseReject = Promise.reject(new Error("失败原因"));
promiseReject.catch((reason) => {
  console.log(reason.message);
  // Expected output: 失败原因
});

// 或者
function resolved(result) {
  console.log("Resolved");
}

function rejected(result) {
  console.log("Rejected:", result);
}

const promiseReject2 = Promise.reject("失败原因");
promiseReject2.then(resolved, rejected);

resolve 方法则比较复杂一些,它有两种返回形式:1. 如果入参是一个普通值(非 Promise 对象),则返回一个以该值为结果的已解决(fulfilled)状态的 Promise 对象,这一点与 reject 对应;2. 如果入参是一个 Promise 对象,则返回该 Promise 对象本身。

// 入参是普通值
const promise1 = Promise.resolve(123);

promise1.then((value) => {
  console.log(value);
  // Expected output: 123
});

// 入参是 Promise 对象
const originalPromise = new Promise((resolve) => {
  setTimeout(() => {
    resolve("原始 Promise 结果");
  }, 1000);
});

const promise2 = Promise.resolve(originalPromise);
promise2.then((value) => {
  console.log(value);
  // Expected output: 原始 Promise 结果
});
  1. Promise.all(..)Promise.race(..)Promise.allSettled(..)Promise.any(..)

这四个我觉得可以放一起介绍,它们都是用于处理多个 Promise 对象的静态方法,入参都是一个可迭代对象(通常是数组),包含多个 Promise 对象,返回一个新的 Promise 对象,其他的区别用一张表格来说明:

方法描述返回值
Promise.all(..)全成功才成功,一失败就失败。成功时返回一个包含所有结果的数组,失败时返回第一个失败的原因。
Promise.allSettled(..)等所有完成,无论成败。结果是一个包含每个 Promise 结果状态的数组。
Promise.any(..)一成功就成功,全失败才失败。成功时返回第一个成功的结果,失败时返回一个 AggregateError,包含所有失败的原因。
Promise.race(..)谁先完成(成败均可),就用谁的结果。结果是第一个解决或拒绝的 Promise 的结果或原因。

关于 Promise.all(..) 的应用,我们最开始的回调函数就是一个很好的例子,我们可以用它来重写:

function fetchX() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(2);
    }, 1000);
  });
}
function fetchY() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(3);
    }, 1000);
  });
}

function add() {
  return Promise.all([fetchX(), fetchY()]).then(([x, y]) => x + y);
}

add().then((sum) => {
  console.log("Sum is: " + sum); // 输出:Sum is: 5
});

如果有三个异步操作:

function fetchZ() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(4);
    }, 1000);
  });
}

function addThree() {
  return Promise.all([fetchX(), fetchY(), fetchZ()]).then(([x, y, z]) => x + y + z);
}

MDN上的 Promise.allSettled(..) 例子:

Promise.allSettled([
  Promise.resolve(33),
  new Promise((resolve) => setTimeout(() => resolve(66), 0)),
  99,
  Promise.reject(new Error("一个错误")),
]).then((values) => console.log(values));

// [
//   { status: 'fulfilled', value: 33 },
//   { status: 'fulfilled', value: 66 },
//   { status: 'fulfilled', value: 99 },
//   { status: 'rejected', reason: Error: 一个错误 }
// ]

这里的返回值有些不同,是一个对象数组,每个对象表示对应 Promise 的状态和结果。

Promise.any(..) 的返回值是第一个成功的结果,如果所有 Promise 都失败了,则返回一个 AggregateError,它包含所有失败的原因:

const promiseA = Promise.reject("失败原因 A");
const promiseB = Promise.reject("失败原因 B");

Promise.any([promiseA, promiseB])
  .then((value) => {
    console.log(value);
  })
  .catch((error) => {
    console.log(error);
  });

// 输出:AggregateError: All promises were rejected

与其他三个方法不同,Promise.race(..) 返回的 Promise 状态的敲定总是异步的,前面的三种方法入参的 Promise 数组中有一个甚至多个是已经解决(fulfilled)或拒绝(rejected)的 Promise 对象(简单来说,和上面的大部分例子一样,我们传入一个确定的值而不是异步方法),那么 Promise.all(..)Promise.allSettled(..)Promise.any(..) 会立即返回结果,而 Promise.race(..) 的返回值则是异步的。

MDN 针对每个方法的返回值都有详细的说明,比如说 Promise.all(..) ,如果传入的参数为空,则它的状态会立即变为 已解决(fulfilled 另外两种返回状态则为 异步兑现(asynchronously fulfilled)异步拒绝(asynchronously rejected) ,而 Promise.any(..) 则是相反的,如果传入的参数为空,则它的状态会立即变为 已拒绝(rejected,其他情况都是异步的。

Promise.race(..) 传入一个空的可迭代对象会导致返回的 Promise 永远处于挂起状态(pending),因为没有任何 Promise 可以兑现或拒绝。

const foreverPendingPromise = Promise.race([]);
console.log(foreverPendingPromise);
setTimeout(() => {
  console.log("堆栈现在为空");
  console.log(foreverPendingPromise);
});

// 按顺序打印:
// Promise { <state>: "pending" }
// 堆栈现在为空
// Promise { <state>: "pending" }

Promise.race(..) 的异步性有什么意义呢?假设我们有一个网络请求操作,我们希望在一定时间内获得响应,否则就放弃请求,这时我们可以使用 Promise.race(..) 来实现超时控制:

const data = Promise.race([
  fetch("/api"),
  new Promise((resolve, reject) => {
    // 5 秒后拒绝
    setTimeout(() => reject(new Error("请求超时")), 5000);
  }),
])
  .then((res) => res.json())
  .catch((err) => displayError(err));
  1. Promise.try()

Promise.try() 静态方法接受一个任意类型的回调函数(无论其是同步或异步,返回结果或抛出异常),并将其结果封装成一个 Promise

这是一个截止到目前(2026年2月)仍在提案阶段的 API,在一些现代浏览器和 Node.js 最新版本中已经可以使用,作用类似于 async 函数,可以将同步代码和异步代码统一处理为 Promise 对象:

Promise.try(() => {
  // 这里可以是同步代码
  const result = synchronousFunction();
  return result;
})
  .then((value) => {
    console.log("同步结果:", value);
  })
  .catch((error) => {
    console.error("错误:", error);
  });
// 也可以是异步代码
Promise.try(async () => {
  const result = await asynchronousFunction();
  return result;
})
  .then((value) => {
    console.log("异步结果:", value);
  })
  .catch((error) => {
    console.error("错误:", error);
  });
  1. Promise.withResolvers()

Promise.withResolvers() 静态方法返回一个对象,其包含一个新的 Promise 对象和两个函数,用于解决或拒绝它,对应于传入给 Promise() 构造函数执行器的两个参数。

它完全等价于下面的代码:

let resolve, reject;
const promise = new Promise((res, rej) => {
  resolve = res;
  reject = rej;
});

它的作用是简化创建一个可控的 Promise 对象,我们可以在外部调用 resolvereject 来改变 Promise 的状态:

const { promise, resolve, reject } = Promise.withResolvers();
// 模拟异步操作
setTimeout(() => {
  const success = true;
  if (success) {
    resolve("操作成功");
  } else {
    reject("操作失败");
  }
}, 1000);
promise
  .then((result) => console.log(result))
  .catch((error) => console.log(error))
  .finally(() => console.log("操作完成"));

这个 API 的使用场景比较少见,目前我还不能完全理解它的作用,感兴趣可以到 MDN 上查看。

async/await 与 Promise

最开始就说到 async/await 了,我是先接触到 async/await 这种写法的,然后才了解到它是基于 Promise 的语法糖,个人理解来说,async/await 让异步代码看起来更像同步代码,主要是提高代码的可读性和可维护性,就像 Promise 之于回调函数一样。

在使用上,async/await 的争议集中在是否要使用 try/catch 来处理错误,我之前的处理方式是在请求的封装里使用 try/catch 来捕获错误,调用时正常使用 async/await ,其他地方处理异步操作还是直接使用 Promise。以前其实没有太深入考虑过合理性的问题,在新公司看代码规范时发现他们有针对这个问题讨论过,才意识到这个问题的重要性。关于这个问题争议比较大,而且关于 async/await 完全可以单独写一篇,这篇主要还是针对 Promise 的学习记录,再写下去也有些超篇幅了,之后学习时应该还会再聊到。

缺陷

这个部分对于我来说还是有些超纲了,但也有参考资料,列一下《你不知道的JavaScript》中卷提到的几个缺陷,不过这些纸质书有一定的时代性,内容仅供参考:

  1. 顺序错误处理: 如果构建了一个没有错误处理函数的Promise链,链中后续的 then 仍然会被执行,可能导致错误被忽略或处理不当。
  2. 单一值Promise 只能处理单一值的传递,无法直接处理多个值或复杂的数据结构(可以传递封装的对象,但如果在链中的每一步都进行封装和解封,就有些笨重了)。
  3. 单决议Promise 一旦被解决(fulfilled)或拒绝(rejected),其状态就不能再改变,无法重新解决或拒绝。
  4. 惯性: 时代性的体现,考虑当时的环境 Promise 还未普及,现在应该可以忽略这一点了。
  5. 不可取消: 一旦创建,Promise 就会一直执行,无法取消正在进行的异步操作。这个也有些时代性了,现在有 AbortController 可以配合 fetch 来实现取消请求的功能。
  6. 性能: 相较于回调函数,Promise 在创建和管理状态方面有一定的性能开销,但个人认为这在通常的应用场景中影响不大。

总结

说实话动笔之前就是觉得应该写一篇关于 Promise 的,但开始写之后发现没什么方向,相关资料也是浩如烟海,写这篇耗费了非常多的时间,开始不断地深挖细节后感觉有无穷无尽的问题,好在现在通过 AI 至少可以把这些问题大致理清楚,"大致"理解说明还有很多内容没有涉及,之后在项目中应该会更加注意 Promise 的应用,然后把《你不知道的JavaScript》的相关内容看完结合一下应该还可以再水一篇。