vlambda博客
学习文章列表

js异步编程,详解Promise、Generator、async、await

前言

本文为我原创,如果对同步异步任务的运行机制感兴趣欢迎阅读我上一篇文章 《》;本文如果对你有所帮助,求收藏、点赞、评论,转载请注明出处。

一、同步与异步

1. 同步

首先假设这么一个场景:你们公司要发年终奖了!!!

你们老板和你们公司会计支了个摊,所有员工都去排队领钱,你排第一个,会计开始算你这一年的“考勤”、“绩效”,最后把你应得的金额告诉老板,由老板把钱交到你手上, 你开心的拿着钱走了。
下一个同事来了,会计开始计算,老板给钱;就这么一个一个地发。

这就是典型的 同步,排在后面的同事必须等前一个同事领完钱才能领到钱。
带来的问题就是等所有员工的年终奖都发完,天都黑了,太耗时;如果碰上一个“难缠”的,嫌钱少,跟老板理论上了,那排在后面的同事就得准备在公司过夜了。

具体到 js 中就是 后一个任务必须等待前一个任务执行完毕才会开始执行,碰上“难缠”(耗时长)的任务,就会造成 阻塞。当然,对于 依赖上一个任务执行结果 的任务,同步是程序 正确性 的保障。

2. 异步

继续年终奖的例子。

今年公司有钱了,搞了个 oa 系统,所有员工考勤、福利都放到系统上了。
员工还是那些员工,但是今年的年终奖就不用再排队了,老板说今天开始发年终奖,会计启动系统程序,所有员工的年终奖都由系统自行计算,等到哪个员工的年终奖算出来了,再由会计直接把钱打到员工账户上。

让所有员工的年终奖计算 “同时” 进行,员工不用再排队等,可以去做些自己爱做的事。

回到 js 中,异步允许多个任务同时执行,每个异步任务可以把自己的工作交由其它线程进行,等到执行完毕后,再把执行结果给到主线程继续执行。避免了 阻塞 ,同时也能减少任务等待时间,提高用户体验。

对主线程而言,异步相当于把任务分成了两个阶段,先执行第一段,等到“时机成熟”,再去执行第二段。

二、异步场景

常见的异步场景大致有以下几种

  1. 响应用户操作:对用户的输入做出响应,比如鼠标点击特定位置
  2. 指定时间执行:setTimeout、setInterval、requestAnimationFrame
  3. 大量的运算:复杂、数据规模较大的运算工作;不过一般都放服务器,前端很少处理
  4. 请求服务器数据:使用 XMLHttpRequest 发送异步请求

前两个场景下,浏览器自身实现了异步。

大量的运算工作在以前会是个头疼的问题,不过现在我们可以使用 Web Worker 来进行计算,具体用法可参考这个例子simple-web-worker。

一说起异步,首先想到的便是 ajax 请求、XMLHttpRequest 请求,所以接下来也会将重点放在与后台的通信上。

三、异步请求发展史

异步请求的解决方案经历了大致四个阶段:

  1. 回调函数
  2. Promise
  3. Generator
  4. async/await

早期前端攻城狮们被 回调函数 支配了很长一段时间,直到 ES6 带来了一堆新特性,结束了刀耕火种的 “回调函数时代”。

四、回调函数

回调函数 是 JavaScript 对 异步编程 的实现。
如前文所述,异步可以看作是将任务拆成两段,第一段先执行,第二段等到异步任务有结果了再执行;而 回调函数 就是将第二段写入到函数里,需要执行时再直接调用这个函数。

以下是 node 中的一个回调示例:

function callback(err, data{
  console.log(data);
}
fs.readFile("./test.txt""utf8", callback);

readFile 函数的第三个参数就是一个 回调函数,也就是任务的第二段,等到操作系统成功读取 test.txt 或 出现错误时,callback 函数被执行。
(注:node 中回调函数第一个参数必须是 error 对象,原因是第一段执行完成后,所在的上下文环境已经结束,之后的抛出的错误无法捕捉)

一个回调函数中如果还有异步操作,就会出现回调函数嵌套的情况,如下面的例子:

fs.readFile("./a.txt""utf8"function (err, data{
  console.log("file a", data);
  fs.readFile("./b.txt""utf8"function (err, data{
    console.log("file b", data);
    fs.readFile("./c.txt""utf8"function (err, data{
      console.log("file c", data);
    });
  });
});

a.txt 文件被读取或是报错后再去读取 b.txt 文件,b.txt 文件成功读取或是报错后再去读取 c.txt 文件。这里仅展示了三个回调函数的嵌套,实际项目中会嵌套的更多。

回调函数嵌套不仅影响阅读、后期维护;同时,多个异步操作之间形成强耦合;如果其中一个回调函数有变更,它的前一个回调函数以及后一个回调函数都要跟着修改,也就是出现了所谓的 回调地狱

五、Promise

ES2015(es6)中引入了 Promise,采用 链式调用 的方式,解决 回调地狱 问题。

Promise 是一个 thenable 对象, 可以获取 异步操作 的消息,有三个状态:

  • 待定(pending):Promise 实例创建后的初始态,也表示任务正在执行;
  • 已兑现(fulfilled):任务成功完成。在执行器中调用 resolve 后转变的状态;
  • 已拒绝(rejected):任务执行失败、被拒绝。在执行器中调用 reject 后转变的状态。

Promise 构造函数接收一个 执行器 函数,执行器接受两个参数(resolve 函数与 reject 函数),根据执行器内异步任务的结果再决定使用 resolve 或 reject 来改变 Promise 实例的状态,pending 状态的 Promise,只能转变为 fulfilled 或 rejected。

  • 调用 resolve 将 pending 状态改为 fulfilled 状态
  • 调用 reject 将 pending 状态改为 rejected 状态

当状态改变时,Promise then 方法的 处理函数 就会被调用

  • 转变为 fulfilled 状态时,then 方法绑定的第一个处理函数(第一个参数)被调用
  • 转变为 rejected 状态或报错时,then 方法绑定的第二个处理函数(第二个参数)被调用;若第二个处理函数未绑定,则 catch 方法的处理函数被调用

Promise.prototype.thenPromise.prototype.catch 方法都会返回一个新的 promise 对象, 所以它们可以被链式调用。
还有一个不常用的方法 Promise.prototype.finally,同样是返回一个新的 promise 对象;当前 promise 运行完毕后,无论当前 promise 的状态是完成(fulfilled)还是失败(rejected),finally 的处理函数都会被调用。

静态方法

方法 参数 触发成功 触发失败
Promise.all Promise 对象的集合 所有 promise 都成功 任何一个 promise 失败则立即触发失败
Promise.allSettled Promise 对象的集合 所有 promise 执行完毕,无论什么状态
Promise.any Promise 对象的集合 其中一个 promise 成功了就触发,返回成功的 promise 所有 promise 都被拒绝(rejected),抛出 AggregateError
Promise.race Promise 对象的集合 第一个被执行完的 promise,且状态为 fulfilled 第一个被执行完的 promise,且状态为 rejected
Promise.reject 拒绝理由
被调用
Promise.resolve 成功的 value 被调用

CustomPromise

Promise 常常是前端面试中必问的知识点,下面通过实现一个简易的 Promise 来深入了解一下。

function CustomPromise(executor{
  // value 记录异步任务成功的执行结果
  this.value = null;
  // reason 记录异步任务失败的原因
  this.reason = null;
  // status 记录当前状态,初始化是 pending
  this.status = "pending";
  // 缓存两个队列,维护 resolved 和 rejected 各自对应的处理函数
  this.onResolvedQueue = [];
  this.onRejectedQueue = [];

  var self = this;

  // 定义 resolve 函数
  function resolve(value{
    // 如果是 pending 状态,直接返回
    if (self.status !== "pending") {
      return;
    }
    // 异步任务成功,把结果赋值给 value
    self.value = value;
    // 当前状态切换为 resolved
    self.status = "resolved";
    // 批量执行 resolved 队列里的任务
    self.onResolvedQueue.forEach((resolved) => resolved(self.value));
  }

  // 定义 reject 函数
  function reject(reason{
    // 如果是 pending 状态,直接返回
    if (self.status !== "pending") {
      return;
    }
    // 异步任务失败,把结果赋值给 value
    self.reason = reason;
    // 当前状态切换为 rejected
    self.status = "rejected";
    // 用 setTimeout 延迟队列任务的执行
    // 批量执行 rejected 队列里的任务
    self.onRejectedQueue.forEach((rejected) => rejected(self.reason));
  }

  // 把 resolve 和 reject 能力赋予执行器
  executor(resolve, reject);
}

CustomPromise 接收一个 执行器,实现了 promise 三个状态与 resolve reject 函数,着重注意两个队列:onResolvedQueueonRejectedQueue

// then 方法接收两个函数作为入参(可选)
CustomPromise.prototype.then = function (onResolved, onRejected{
  if (typeof onResolved !== "function") {
    onResolved = function (x{
      return x;
    };
  }
  if (typeof onRejected !== "function") {
    onRejected = function (e{
      throw e;
    };
  }
  var self = this;
  let x;

  // 新 Promise 对象
  var promise2 = new CustomPromise(function (resolve, reject{
    // 判断状态,分配对应的处理函数
    if (self.status === "resolved") {
      // resolve 处理函数
      resolveByStatus(resolve, reject);
    } else if (self.status === "rejected") {
      // reject 处理函数
      rejectByStatus(resolve, reject);
    } else if (self.status === "pending") {
      // 若是 pending ,则将任务推入对应队列
      self.onResolvedQueue.push(function ({
        resolveByStatus(resolve, reject);
      });
      self.onRejectedQueue.push(function ({
        rejectByStatus(resolve, reject);
      });
    }
  });

  // resolve态的处理函数
  function resolveByStatus(resolve, reject{
    // 包装成异步任务,确保决议程序在 then 后执行
    setTimeout(function ({
      try {
        // 返回值赋值给 x
        x = onResolved(self.value);
        // 进入决议程序
        resolutionProcedure(promise2, x, resolve, reject);
      } catch (e) {
        // 如果onResolved或者onRejected抛出异常error,则promise2必须被rejected,用error做reason
        reject(e);
      }
    });
  }
  // reject态的处理函数
  function rejectByStatus(resolve, reject{
    // 包装成异步任务,确保决议程序在 then 后执行
    setTimeout(function ({
      try {
        // 返回值赋值给 x
        x = onRejected(self.reason);
        // 进入决议程序
        resolutionProcedure(promise2, x, resolve, reject);
      } catch (e) {
        reject(e);
      }
    });
  }

  // 把包装好的 promise2 return 掉
  return promise2;
};

then 函数 接收两个 处理函数,先判断两个参数类型是不是函数,不是则直接返回或抛出。
new 一个新的 CustomPromise 对象,判断调用对象的状态

  • 如果状态为 resolved,则调用 resolveByStatus;
  • 如果状态为 rejected,则调用 rejectByStatus;
  • 如果状态仍为 pending,则将 resolveByStatus 推入 onResolvedQueue 队列,将 rejectByStatus 推入 onRejectedQueue 队列。resolveByStatus 与 rejectByStatus 主要逻辑是决议程序(promise/A+标准)
function resolutionProcedure(promise2, x, resolve, reject{
  // 这里 hasCalled 这个标识,是为了确保 resolve、reject 不要被重复执行
  let hasCalled;
  if (x === promise2) {
    // 规范:如果 resolve 结果和 promise2相同则reject,这是为了避免死循环
    return reject(new TypeError("为避免死循环,此处抛错"));
  } else if (x !== null && (typeof x === "object" || typeof x === "function")) {
    // 规范:如果x是一个对象或者函数,则需要额外处理下
    try {
      // 首先是看它有没有 then 方法(是不是 thenable 对象)
      let then = x.then;
      // 如果是 thenable 对象,则将promise的then方法指向x.then。
      if (typeof then === "function") {
        then.call(
          x,
          (y) => {
            // 如果已经被 resolve/reject 过了,那么直接 return
            if (hasCalled) return;
            hasCalled = true;
            // 进入决议程序(递归调用自身)
            resolutionProcedure(promise2, y, resolve, reject);
          },
          (err) => {
            // 这里 hascalled 用法和上面意思一样
            if (hasCalled) return;
            hasCalled = true;
            reject(err);
          }
        );
      } else {
        // 如果then不是function,用x为参数执行promise
        resolve(x);
      }
    } catch (e) {
      if (hasCalled) return;
      hasCalled = true;
      reject(e);
    }
  } else {
    // 如果x不是一个object或者function,用x为参数执行promise
    resolve(x);
  }
}

决议程序 resolutionProcedure 接收四个参数

  • promise2:新的 promise 对象
  • x:被调用对象绑定的 resolve 函数 或 reject 函数 返回的值
  • resolve: promise2 对象 绑定的 resolve 函数
  • reject: promise2 对象 绑定的 reject 函数

判断 x 的类型

  • 等于 promise2:抛出异常
  • thenable 对象:将 this 指向 x,第一个参数为 resolve 处理函数,第二个参数为 reject 处理函数;resolve 处理函数调用自身
  • 其它类型:调用 resolve

示例

继续上面的例子,这次改用 Promise 改进一下

function readFilePromise(filePath{
  return new Promise((resolve, reject) => {
    fs.readFile(filePath, "utf8", (err, data) => {
      if (err) {
        return reject(err);
      }
      resolve(data);
    });
  });
}
// 使用
readFilePromise("./a.txt")
  .then((data) => {
    console.log("文件:", data);
    return readFilePromise("./b.txt");
  })
  .then((data) => {
    console.log("文件:", data);
    return readFilePromise("./c.txt");
  })
  .then((data) => {
    console.log("文件:", data);
  })
  .catch((err) => {
    console.error(err);
  });

这里先使用 Promise 包装了一下 readFile,通过 then 方法的链式调用完成对三个文件的读取,并在末尾通过 catch 方法捕获执行过程中抛出的错误。

相比于 回调函数 的方式,代码结构更加清晰,执行顺序也一目了然;但除此之外并无新意,更像是对回调函数的改进,成了 then 方法的堆积,语义依然不清晰。

那有没有更好的办法呢?答案是肯定的,我们继续往下

六、Generator 函数

Generator 函数(生成器函数) 同样是 es6 中新引入的语法,用以解决异步编程。

通过 function* 声明一个 Generator 函数,调用函数并 不会立即执行,而是返回这个生成器的 迭代器 ( iterator )对象。
(注:es6 中没有规定 function 与 函数名 之间的星号在哪个位置)

返回的 迭代器 ( iterator )对象提供以下方法:

方法名 参数 返回值 描述
Generator.prototype.next() value:向生成器传递的值 {done:已经执行到末尾并返回了则为 true,value:返回的任何 JavaScript 值} 指针向下执行,直到遇到 yieldreturn 为止,返回一个由 yield 表达式生成的值
Generator.prototype.return() value:需要返回的值 返回该函数参数中给定的值 返回给定的值并结束生成器
Generator.prototype.throw() exception:抛出的异常 {done:已经执行到末尾并返回了则为 true,value:返回的任何 JavaScript 值} 向生成器抛出一个错误

Generator 函数 更像是异步任务的容器,因为可以“控制”异步任务的执行,执行中可以被中断也可以再次被唤起。

Generator 函数执行过程中出现以下情况会暂停执行:

  1. yield
    yield 关键字只能在 Generator 函数 中使用。
    Generator 函数 执行中遇到 yield 关键字时执行被中断,直到生成器的 next() 方法被调用,函数才能继续往下执行。
    同时 next() 方法返回一个 IteratorResult 对象,它有两个属性,valuedone

  2. yield*
    yield* 关键字后跟一个 generator 或 可迭代对象。函数执行过程中遇到 yield* 也会暂停执行,并将控制权委托给 yield* 后的对象,通过调用 next() 方法执行 yield* 后跟的 generator 或 可迭代对象。yiel* 是表达式,可以直接将值返回给调用者。

  3. return
    函数执行结束,return 并不会直接将值返回给调用者,仍是需要通过调用 next() 方法,返回的 value 的值由 return 语句指定,并且 done 为 true。

  4. throw
    抛出异常,函数完全停止执行。

  5. 生成器函数的末尾
    函数执行结束,再次调用 next() 方法时返回的 value 等于 undefined, done 为 true。

借助一个例子来理解一下:

functiong1({
  console.log("g1 start");
  yield* [123];
  return "foo";
}

var result, yieldResult;

functiong2({
  console.log("g2 start");
  result = yield* g1();
  yieldResult = yield "g5 yield";
  return "g5 inner";
}

var iterator = g2();

console.log(iterator.next()); // {value: 1, done: false}
console.log(iterator.next()); // {value: 2, done: false}
console.log(iterator.next()); // {value: 3, done: false}

console.log(iterator.next()); // {value: "g5 yield", done: false},这时 result 被赋值 ‘foo’
console.log(iterator.next()); // {value: "g5 inner", done: true}

console.log(result); // foo
console.log(yieldResult); // undefined

这里声明了两个生成器函数 g1、g2,调用 g2 函数,并将返回的 迭代器对象 赋值给 iterator。

  • g2 函数不会立即执行,只能通过调用 next() 方法执行,先打印出 g2 start,继续执行遇到 yield* 表达式。

  • yield* 将控制权委托给 g1 函数,g1 函数开始执行,打印出 g1 start,同时也遇到 yield* 表达式,同样将控制权委托给 可迭代对象 也就是这里的数组 [1, 2, 3],并将第一个元素 1 作为 value 值返回,输出 {value: 1, done: false}

  • 控制权仍在 数组中,因此继续调用 next() 方法仍是读取数组中的元素,依次输出 {value: 2, done: false}{value: 3, done: false}

  • 数组中的元素都读取完了,控制权回到 g2 中,等到下一次 next() 方法被调用,g2 函数继续执行,将 g1 返回的 foo 赋值给 result,并输出 {value: "g5 yield", done: false},yield 与 yield* 不同,没有自己的值,因此 yieldResult 等于 undefined。

  • 再往下执行,遇到了 return,执行被中断,直到下一次 next() 方法被调用,输出 {value: "g5 inner", done: true}

示例

最后再使用 Generator 函数 改进一下上面的例子

function readFilePromise(filePath{
  return new Promise((resolve, reject) => {
    fs.readFile(filePath, "utf8", (err, data) => {
      if (err) {
        return reject(err);
      }
      resolve(data);
    });
  });
}
functionreadFileGen({
  yield readFilePromise("./a.txt");
  yield readFilePromise("./b.txt");
  yield readFilePromise("./c.txt");
}

function run({
  const gen = readFileGen();
  function next({
    const { done, value } = gen.next();
    if (done) {
      // 执行到末尾
      console.log("done", value);
      return value;
    }
    value.then((data) => {
      console.log("文件", data);
      next();
    });
  }
  next();
}

run();

这里用 Generator 函数 重新包装了一下 readFilePromise 函数,依次读取三个文件,还需要调用 next 函数才能执行。

  • run 是个自执行函数,gen 是 Generator 函数返回的对象,通过调用 gen.next() 控制指针往下执行,返回的对象包含 donevalue 两个属性。
  • 通过 done 是否等于 true,判断 Generator 函数 是不是执行到末尾了。
  • 如果 done 为 true,则直接 return value。
  • 如果 done 不为 true,则递归调用 next 方法。

本例中 value 是一个 thenable 对象,所以在 then 的处理函数中打印输出数据并执行 next 方法。

对比一下 promise 的 链式调用,虽然没了 then 的堆积,但却多了对 next 方法的调用,虽然能力更强大了,但怎么感觉更复杂了呢。

七、Async/Await

在 ES2017 标准中引入了 asyncawait 处理异步任务。

async 函数

async 函数 顾名思义就是使用 async 关键字声明的函数,async 函数始终返回一个 promise 对象,也可以说 async 函数就是 Generator 函数 的语法糖。

这个语法糖做了哪些事呢?大致可以分为以下几点:

  1. 内置执行器
    Generator 函数的执行必须靠执行器,就是上一节例子中的 run 函数,而 async 函数 自带执行器,async 函数与普通函数一样,直接调用即可。
  2. 更好的语义
    async 和 await 搭配的语法比起 Generator 函数 的星号和 yield,语义更加清楚;async 表明函数中有异步操作,await 表明跟在后面的表达式需要等待结果。
  3. 返回 Promise
    async 函数始终返回的是一个 Promise 对象,相对于 Generator 函数 返回的 IteratorResult 对象,Promise 方便许多。async 可以看作是将函数内多个操作包装成了一个 Promise。

await

await 关键字一般都搭配 async 使用,也只有在 async 函数中才能使用。
await 后通常跟一个 Promise 对象,如果不是一个 Promise 对象则会被转成一个立即 resolve 的 Promise 对象。

老规矩,来看一个简单的小例子

function promiseFunc({
  return new Promise((rs, rj) => {
    console.log("promise");
    rs("hello world");
  });
}
async function asyncFunc({
  console.log("async start");
  const result = await promiseFunc();
  console.log(result);
}
asyncFunc();
console.log("main thread");

输出

// async start
// promise
// main thread
// hello world

这里疑问较大的应该就是 main thread 为啥会比 hello world 先输出?

  • 在 asyncFunc 执行中,遇到 await 关键字,需要等待 promiseFunc 完成,所以 asyncFunc 函数的执行 被中断 了,将控制权交还给调用者,也就是全局上下文中,继续执行 console.log('main thread'),输出 main thread
  • promiseFunc 有结果了,就继续执行 asyncFunc,输出 hello world

这里涉及到 微任务/宏任务,可以看我上一篇文章。

在这里例子中,async 函数可以看作是将多个异步任务(promise)包装成一个 Promise,await 就像异步操作 then 命令的语法糖。

错误处理

async/await 语法虽好,但是错误处理是个问题, 上面的例子中如果将 promiseFunc 中的 rs('hello world') 换成 rj('hello world'),同样也会输出 “hello world”。

所以处理方法通常是在需要调用 reject 时将值抛出,async 函数 则包在 try...catch 中,以便处理失败、拒绝。

示例

终于,readFile 的最终版本

function readFilePromise(filePath{
  return new Promise((resolve, reject) => {
    fs.readFile(filePath, "utf8", (err, data) => {
      if (err) {
        return reject(err);
      }
      resolve(data);
    });
  });
}
async function run({
  const a = await readFilePromise("./a.txt");
  console.log("文件", a);
  const b = await readFilePromise("./b.txt");
  console.log("文件", b);
  const c = await readFilePromise("./c.txt");
  console.log("文件", c);
}
run();

使用 async/await 写法处理异步任务,看起来更加符合语义,代码也更加清爽。

以上。

后记

由于个人订阅号不具备评论功能,如有其它意见,欢迎前往以下任一站点评论留言:

https://melonfield.club/column/detail/cvWdxxdtFDl(个人独立开发运营网站) 

https://juejin.cn/post/6930633868911476743

参考

  • Promise[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise
  • Generator[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Generator
  • async函数[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/async_function
  • await[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/await
  • yield[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/yield
  • yield*[OL].https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/yield*
  • 阮一峰.ES6标准入门[M].第3版