全栈工程师修炼指南

打开潘多拉盒子:JavaScript异步编程

2020-11-22  本文已影响0人  码农架构

用 Promise 优化嵌套回调

setTimeout(
  () => {
    console.log(1);
    setTimeout(
      () => {
        console.log(2);
        setTimeout(
          () => {
            console.log(3);
          },
          1000
        );
      },
      1000
    );
  },
  1000
);

们用了 3 次 setTimeout,每次都接受两个参数,第一个参数是一个函数,用以打印当前跑的距离,以及递归调用奔跑逻辑,第二个参数用于模拟奔跑耗时 1000 毫秒。这个问题其实代表了实际编程中一类很常见的 JavaScript 异步编程问题。

抽取相同逻辑:

var run = (steps, callback) => {
  setTimeout(
    () => {
      console.log(steps);
      callback();
    },
    1000
  );
};

run(1, () => {
  run(2, () => {
    run(3, () => {});
  });
});

Promise,就如同字面意思“承诺”一样,定义在当前,但行为发生于未来。它的构造方法中接受一个函数,并且这个函数接受 resolve 和 reject 两个参数,前者在未来的执行成功时会被调用,后者在未来的执行失败时会被调用。

这个 Promise 对象,并不是在程序一开始就初始化的,而是在未来的某一时刻,前一步操作完成之后才会得到执行,这一点非常关键,并且这是一种通过给原始代码添加函数包装的方式实现了这里的“定义、传递、但不执行”的要求。

var run = steps => 
  () => 
    new Promise((resolve, reject) => {
      setTimeout(
        () => {
          console.log(steps);
          resolve(); // 一秒后的未来执行成功,需要调用
        },
        1000
      );
    });

Promise.resolve()
  .then(run(1))
  .then(run(2))
  .then(run(3));

async/await:

var run = async steps => {
  await wait(1000);
  console.log(steps);
}

await run(1);
await run(2);
await run(3);

纵观这个小狗奔跑的问题,我们一步一步把晦涩难懂的嵌套回调代码,优化成了易读、易理解的“假同步”代码。聪明的程序员总在努力地创造各种工具,去改善代码异步调用的表达能力,但是越是深入,就越能发现,最自然的表达,似乎来自于纯粹的同步代码。

用生成器来实现协程

协程,Coroutine,简单说就是一种通用的协作式多任务的子程序,它通过任务执行的挂起与恢复,来实现任务之间的切换。

这里提到的“协作式”,是一种多任务处理的模式,它和“抢占式”相对。如果是协作式,每个任务处理的逻辑必须主动放弃执行权(挂起),将继续执行的资源让出来给别的任务,直到重新获得继续执行的机会(恢复);而抢占式则完全将任务调度交由第三方,比如操作系统,它可以直接剥夺当前执行任务的资源,分配给其它任务。

JavaScript 的协程是通过生成器来实现的,执行的主流程在生成器中可以以 yield 为界,进行协作式的挂起和恢复操作,从而在外部函数和生成器内部逻辑之间跳转,而 JavaScript 引擎会负责管理上下文的切换。

JavaScript 和迭代有关的两个协议:

在 JavaScript 中,生成器对象是由生成器函数 function* 返回,且符合“可迭代协议”和“迭代器协议”两者。function* 和 yield 关键字通常一起使用,yield 用来在生成器的 next() 方法执行时,标识生成器执行中断的位置,并将 yield 右侧表达式的值返回。见下面这个简单的例子:

function* IdGenerator() {
  let index = 1;
  while (true)
    yield index++;
}

var idGenerator = IdGenerator();

console.log(idGenerator.next());
console.log(idGenerator.next());

输出:

{value: 1, done: false}
{value: 2, done: false}

生成器可不是只能往外返回,还能往里传值。具体说,yield 右侧的表达式会返回,但是在调用 next() 方法时,入参会被替代掉 yield 及右侧的表达式而参与代码运算。我们将上面的例子小小地改动一下:

function* IdGenerator() {
  let index = 1, factor = 1;
  while (true) {
    factor = yield index; // 位置①
    index = yield factor * index; // 位置②
  }
}

调用:

var calculate = (idGenerator) => {
  console.log(idGenerator.next());
  console.log(idGenerator.next(1));
  console.log(idGenerator.next(2));
  console.log(idGenerator.next(3));
};

calculate(IdGenerator());
image.png

异步错误处理

Promise 的异常处理

还记得上面介绍的 Promise 吗?它除了支持 resolve 回调以外,还支持 reject 回调,前者用于表示异步调用顺利结束,而后者则表示有异常发生,中断调用链并将异常抛出:

var exe = (flag) =>
  () => new Promise((resolve, reject) => {
    console.log(flag);
    setTimeout(() => { flag ? resolve("yes") : reject("no"); }, 1000);
  });

上面的代码中,flag 参数用来控制流程是顺利执行还是发生错误。在错误发生的时候,no 字符串会被传递给 reject 函数,进一步传递给调用链:

Promise.resolve()
  .then(exe(false))
  .then(exe(true));

你看,上面的调用链,在执行的时候,第二行就传入了参数 false,它就已经失败了,异常抛出了,因此第三行的 exe 实际没有得到执行,你会看到这样的执行结果:

false
Uncaught (in promise) no

但是,有时候我们需要捕获错误,而继续执行后面的逻辑,该怎样做?这种情况下我们就要在调用链中使用 catch 了:

Promise.resolve()
  .then(exe(false))
  .catch((info) => { console.log(info); })
  .then(exe(true));

这种方式下,异常信息被捕获并打印,而调用链的下一步,也就是第四行的 exe(true) 可以继续被执行。我们将看到这样的输出:

var run = async () => {
  try {
    await exe(false)();
    await exe(true)();
  } catch (e) {
    console.log(e);
  }
}

run();

async/await 下的异常处理

利用 async/await 的语法糖,我们可以像处理同步代码的异常一样,来处理异步代码:
简单说明一下 ,定义一个异步方法 run,由于 await 后面需要直接跟 Promise 对象,因此我们通过额外的一个方法调用符号 () 把原有的 exe 方法内部的 Thunk 包装拆掉,即执行 exe(false)() 或 exe(true)() 返回的就是 Promise 对象。在 try 块之后,我们使用 catch 来捕捉。运行代码,我们得到了这样的输出:

false
no
上一篇下一篇

猜你喜欢

热点阅读