JavaScript 使用 Promise

本文最后更新于:2021年6月21日 晚上

场景

为什么要使用 Promise?

JavaScript 异步发展史:回调函数 -> Promise -> async/await

传统异步使用回调函数,回调意味着嵌套,当你需要使用很多异步函数时,那你需要非常多的回调函数,可能形成回调地狱。
有问题就有人解决,js 没有多线程,所以天生就是异步的。正是因为异步的广泛性,所以很早之前就有人着力于解决异步回调的问题,github 上有很多已经废弃的库就是用于解决这个问题的。
然而现在,es6 出现了 Promise,它能把嵌套回调压平为一层的链式调用,并且写进了 js 标准里。es7 甚至出现了更加优雅的方式,async/await,能以同步的方式写异步的代码。当然,本质上只是 Promise 的一个语法糖,但其重要性也是不言而喻的——异步回调地狱已经不存在了!
说了这么多,那么平常我们应该怎么使用 Promise 呢?

使用 Promise

一般而言,我们作为使用者是无需创建 Promise 的,支持 Promise 的函数会返回一个 Promise 对象给我们,然后我们使用它的方法 then/catch 即可。

  • then():当前的 JavaScript 已经完成,要进行下一步的同步/异步操作了
  • catch():用于捕获 Promise 链式调用中可能出现的错误

注:then/catch 均返回一个新的 Promise

例如我们有这样一个需求

  1. 等待资源 A 加载完成
  2. 在 A 资源加载完成之后等待 B 资源加载完成

之前使用回调函数,我们的代码可能是这样的

/**
 * 等待指定的时间/等待指定表达式成立
 * @param {Number|Function} param 等待时间/等待条件
 * @param {Function} callback 回调函数
 */
function wait(param, callback) {
  if (typeof param === "number") {
    setTimeout(callback, param);
  } else if (typeof param === "function") {
    var timer = setInterval(() => {
      if (param()) {
        clearInterval(timer);
        callback();
      }
    }, 100);
  } else {
    callback();
  }
}

wait(
  () => document.querySelector("#a"),
  () => {
    wait(
      () => document.querySelector("#b"),
      () => {
        console.log("a, b 两个资源已经全部加载完成");
      }
    );
  }
);
// 结果
// a, b 两个资源已经全部加载完成

可以看到,上面如果还需要等待 c,d,e,f... 资源,那么回调函数的层级将是无法接受的。
现在,我们使用 Promise 改造一下代码

// 先不要管这个函数的具体实现,下面再说如何自己封装 Promise
/**
 * 等待指定的时间/等待指定表达式成立
 * @param {Number|Function} param 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
function wait(param) {
  return new Promise((resolve) => {
    if (typeof param === "number") {
      setTimeout(resolve, param);
    } else if (typeof param === "function") {
      var timer = setInterval(() => {
        if (param()) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    } else {
      resolve();
    }
  });
}

wait(() => document.querySelector("#a"))
  // 注意这里的 wait(() => document.querySelector('#b')) 同样是一个异步函数,返回了一个 Promise
  // 接下来,有趣的地方来了
  // 很明显,wait 是一个异步函数。wait 函数的 then 函数调用了另一个异步函数,然而 then 会等待异步执行完成,才继续执行后面的函数
  .then(() => wait(() => document.querySelector("#b")))
  // 这里仍然会等待上面的 Promise 完成之后才执行下面的内容
  .then(() => console.log("a, b 两个资源已经全部加载完成"));
// 结果
// a, b 两个资源已经全部加载完成

下面我们尝试使用一下 catch

wait(() => document.querySelector("#a"))
  .then(() => wait(() => document.querySelector("#b")))
  .then(() => {
    throw new Error("执行了某些操作发生了异常");
  })
  // 上面抛出了异常并且没有使用 catch 处理的话就会继续找下一个调用,直到找到处理的 catch,或者调用结束为止
  .then(() => console.log("a, b 两个资源已经全部加载完成"))
  // 捕获上面的 then() 发生的异常,保证后面的调用正常执行
  .catch((error) => console.log("使用 catch 捕获的异常: ", error))
  .then(() => console.log("测试异步函数结束"));

// 结果
// 使用 catch 捕获的异常:  Error: 执行了某些操作发生了异常
//     at wait.then.then (<anonymous>:4:11)
// VM272:9 测试异步函数结束

可以参考 MDN 上的教程 使用 Promises

封装 Promise

那么,你是否也对上面自定义的 wait 函数感到好奇呢?我们来详细的了解一下具体如何做到的吧!

/**
 * 等待指定的时间/等待指定表达式成立
 * @param {Number|Function} param 等待时间/等待条件
 * @returns {Promise} Promise 对象
 */
function wait(param) {
  // 这里返回了一个 Promise 对象,Promise 的构造函数要求一个函数参数
  // 函数的参数实际上有两个,resolve 和 reject,分别代表 [已经完成] 和 [出现错误]
  // 注:这个函数是立刻执行的,当 resolve 或 reject 执行时,这个 Promise 算是结束了,将进入下一个 then/catch 调用
  return new Promise((resolve) => {
    if (typeof param === "number") {
      setTimeout(resolve, param);
    } else if (typeof param === "function") {
      var timer = setInterval(() => {
        if (param()) {
          clearInterval(timer);
          // 这里执行了代码,如果有什么结果需要传递给下一个调用,则直接放到 resolve 函数内即可
          resolve();
        }
      }, 100);
    } else {
      resolve();
    }
  });
}

同样的,我们也可以使用 Promise 封装其他函数

  • timeout:一个简单的 setTimeout() 的封装
  • readLocal:读取本地浏览器选择的文件
  • timing:测试函数执行的时间,不管是同步还是异步的(Promise)
/**
 * 使用 Promise 简单封装 setTimeout
 * @param {Number} ms 等待时间
 * @returns {Promise} Promise 对象
 */
const timeout = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
 * 读取本地浏览器选择的文件
 * @param {File} file 选择的文件
 * @param {{String}} init 一些初始选项,目前只有 type 一项
 * @returns {Promise} 返回了读取到的内容(异步)
 */
const readLocal = (() => {
  const result = (file, { type = "readAsDataURL" } = {}) =>
    new Promise((resolve, reject) => {
      if (!file) {
        reject("file not exists");
      }
      const fr = new FileReader();
      fr.onload = (event) => {
        resolve(event.target.result);
      };
      fr.onerror = (error) => {
        reject(error);
      };
      fr[type](file);
    });
  result.DataURL = "readAsDataURL";
  result.Text = "readAsText";
  result.BinaryString = "readAsBinaryString";
  result.ArrayBuffer = "readAsArrayBuffer";
  return result;
})();

/**
 * 测试函数的执行时间
 * 注:如果函数返回 Promise,则该函数也会返回 Promise,否则直接返回执行时间
 * @param {Function} fn 需要测试的函数
 * @returns {Number|Promise} 执行的毫秒数
 */
function timing(fn) {
  const begin = performance.now();
  const result = fn();
  if (!(result instanceof Promise)) {
    return performance.now() - begin;
  }
  return result.then(() => performance.now() - begin);
}

吾辈建议你也可以封装一些常用的异步函数,下面会展示 JavaScript 中如何更简单的使用异步!

使用 async/await

  • async:用于标识一个函数是异步函数,默认这个函数将返回一个 Promise 对象
  • await:用于在 async 函数内部使用的关键字,标识一个返回 Promise 的异步函数需要等待

使用 async/await 重构上面的代码

async function init() {
  // await 等待异步函数执行完成
  await wait(() => document.querySelector("#a"));
  await wait(() => document.querySelector("#b"));
  console.log("a, b 两个资源已经全部加载完成");
}
// 注:init() 函数将返回一个 Promise,我们可以继续追加下一步的操作
init();

是的,就是如此简单,直接在异步函数添加 await 关键字就好了!


最后,如果你要使用这些特性,请务必使用 babel 转换器。毕竟,有太多的人就是不肯升级浏览器。。。

可以参考