JavaScript 异步编程

date
Mar 10, 2018
slug
js-async-programming
status
Published
tags
JavaScript
summary
type
Post

1. JavaScript 天生异步

你说我一个浏览器写写表单验证的,怎么就突然成为如今最流行的编程语言了呢?
JavaScript 设计之初是用于浏览器端 GUI 编程,这就决定了这门语言是单线程、非阻塞的。而 JavaScript 正是通过异步执行任务来实现非阻塞。
关于 JavaScript 异步机制和 Event loop 详细可见:Help, I'm stuck in an event-loop

2. 异步函数的类型

JavaScript 环境本身提供的异步函数通常可以分为两大类:
  1. I/O 函数
  1. 计时函数
如果想在应用中自定义复杂的异步任务,就需要在两类异步函数上构建。

3. 异步解决方案

3.1 回调

一开始,JS 中的异步是通过回调实现的。如果想让某段代码将来执行,可以将它放在一个回调函数中。例如下面的 node 代码,只有在文件读取完毕后,'finished'才会被打印。
const fs = require('fs');
fs.readFile('/etc/passwd', (err, result) => {
    console.log('finished');
})
但是随着应用变得复杂,我们有许多异步事件需要处理,并且需要数据从一个事件传递到下一个事件,那么回调函数就会变得这样
step1(function(result1) {
step2(function(result2) {
        step3(function(result3) {
            // ...
        });
    });
});
这样的代码被称为Callback Hell(回调地狱),回调地狱主要以下有以下几大罪状
  1. 代码丑陋,不符合人类阅读习惯
  1. 异常难以捕获
    1. try/catch是同步代码,上面的 step 函数运行时,try/catch已经执行完毕,异常并不能被捕获。
  1. 代码容易产生冗余
    1. 假设我们还有一个不同的操作需要在 step1 之后完成,那么得再来一段Callback Hell了……

3.2 Pub/Sub

Pub/Sub(发布/订阅)模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。
我觉得 Pub/Sub 模式和蝴蝶效应很像:某个事件被触发,整个应用都受到影响。
Pub/Sub 模式可以很好的解决回调地狱产生的代码冗余的问题。
DOM 事件就是很典型的 Pub/Sub 模式。例如下面的点击事件。
button.addEventListener('click', () => {
   console.log('the button is clicked');
}, false);
这里我们相当于订阅了button上面的点击事件,当用户点击之后,这个按钮就会向订阅者发布这个消息。

3.3 Promise

事件(click, keyup)和 Pub/Sub 模式对于同一对象上发生多次的事情非常有用,但是关系到异步事件执行的成功或者失败,Pub/Sub 模式没有提供一个好的解决方案。Promise 很好的解决了这个问题:
// 假设`ready()`返回一个 Promise.
img.ready().then((result)=> { console.log('success!'); }, (err) => { console.log('failed!'); })
当然,Promise 在异步事件执行方面的优点不仅于此。
Promise 最早由社区提出和实现,常见的 Promise 的第三方库有
而官方则在 ES6 正式支持 Promise,并采用了 Promises/A+ 规范。
Promise 为什么叫 Promise 呢,我觉得 MDN 上面关于 Promise 的中文“翻译”很好的解释了这一点🙃:
**Promise **对象用于一个异步操作的最终完成(或失败)及其结果值的表示。(简单点说就是处理异步请求。我们经常会做些承诺,如果我赢了你就嫁给我,如果输了我就嫁给你之类的诺言。这就是promise的中文含义:诺言,一个成功,一个失败。)
原文:The Promise object represents the eventual completion (or failure) of an asynchronous operation, and its resulting value.
Promise 通过链接多个then()来处理多个异步操作,比回调地狱优雅很多:
aPromiseStuff().then((result) => {
    return doPromiseStuff();
}).then((result) => {
    return doAnotherPromiseStuff();
}).catch((err) => {
    console.log(err);
});
关于 Promise 的更多内容可以查看 MDN 上面的教程:Promise
还有两篇关于 Promise 的文章很值得一读:

3.4 Generator

Generator Function 和 Generator 也是 ES6 引入的新特性。
function*这种声明方式用来定义一个 Generator Function,后者会返回一个 Generator 对象。
当一个 Generator Function 被调用时并不会马上执行;相反,它会返回一个 Generator 对象。每次调用 Generator 对象的next()方法将会执行函数至下一个yield表达式,并返回一个符合迭代器协议的对象,包含valuedone两个属性。value的值为yield表达式的运行结果,函数运行结束时其值为undefineddone的值表示函数是否运行结束。
function* simpleGenerator(){
  yield "first";
  yield "second";
  yield "third";
  for (var i = 0; i < 3; i++)
    yield i;
}
var g = simpleGenerator();
console.log(g.next()); // { value: "first", done: false }
console.log(g.next()); // { value: "second", done: false }
console.log(g.next()); // { value: "third", done: false }
console.log(g.next()); // { value: 0, done: false },
console.log(g.next()); // { value: 1, done: false },
console.log(g.next()); // { value: 1, done: false },
console.log(g.next()); // { value: undefined, done: true },
Generator Function 这种可以暂停执行和恢复执行的特性,使它能够有处理异步任务的能力。可是光有一个 Generator Function 还不够,它还需要有一个执行器来执行它所封装的异步任务:
function doSomething() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve(1)
        }, 500);
    });
}

function* handleAsynchronousStuff() {
  try {
        const val = yield doSomething();
      console.log(val);
    } catch(e) {
        console.log(e);
    }
}
万事具备,只欠一个co:
co:Generator based control flow goodness for nodejs and the browser, using promises, letting you write non-blocking code in a nice-ish way.
const co = require('co');

co(handleAsynchronousStuff) // 1
我们再来看看 co 做了什么事:
 
co 的功能其实不算复杂,总共也就 200 多行代码。它不断递归拆解 generator function 中的 yield 表达式,并返回一个 Promise。它做的事情就是执行用 Generator 封装好的异步任务。

Generator 错误处理

generator 对象有一个 throw 方法,可以在 generator function 外面抛出异常,并且能够在 generator function 中使用try/catch捕获异常,详细内容可见 MDN 文档:Generator.prototype.throw()
那么,使用 Generator 处理异步任务可以优雅的捕获异常吗?答案是肯定的,我们再来啃一啃 co 的核心函数的代码:
function co(gen) {
  var ctx = this;
  var args = slice.call(arguments, 1);

  // we wrap everything in a promise to avoid promise chaining,
  // which leads to memory leak errors.
  // see <https://github.com/tj/co/issues/180>
  return new Promise(function(resolve, reject) {
    if (typeof gen === 'function') gen = gen.apply(ctx, args);
    if (!gen || typeof gen.next !== 'function') return resolve(gen);

    onFulfilled();

    /**
     * @param {Mixed} res
     * @return {Promise}
     * @api private
     */

    function onFulfilled(res) {
      var ret;
      try {
        ret = gen.next(res);
      } catch (e) {
        return reject(e);
      }
      next(ret);
      return null;
    }

    /**
     * @param {Error} err
     * @return {Promise}
     * @api private
     */

    function onRejected(err) {
      var ret;
      try {
        ret = gen.throw(err);
      } catch (e) {
        return reject(e);
      }
      next(ret);
    }

    /**
     * Get the next value in the generator,
     * return a promise.
     *
     * @param {Object} ret
     * @return {Promise}
     * @api private
     */

    function next(ret) {
      // generator function 执行完毕,Promise 状态变为 resolve
      if (ret.done) return resolve(ret.value);
      // value => Promise
      var value = toPromise.call(ctx, ret.value);
      // 如果 value 成功转变为 Promise,则通过`Promise.then()`继续拆解 generator function,并为Promise 添加`onFulfilled`和`onRejected`
      if (value && isPromise(value)) return value.then(onFulfilled, onRejected);
      return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, '
        + 'but the following object was passed: "' + String(ret.value) + '"'));
    }
  });
}
可以看到,它对 generator function 的异常处理封装在了 onRejected()函数当中:如果发生错误,则将返回的 Promise 的状态变为reject,再调用next()继续拆解 generator function。
通过 co 的处理,异步函数中的异常成功通过gen.throw()抛出,那么我们就可以跟同步代码一样使用try/catch捕获异常了:
function doSomething() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            reject('something is wrong')
        }, 500);
    });
}

function* handleAsynchronousStuff() {
  try {
        const val = yield doSomething();
      console.log(val);
    } catch(e) {
        console.log('Error: ', e);
    }
}

co(handleAsynchronousStuff); // Error: something is wrong
嗯……不得不说 Generator 使异步处理的过程更加优雅了。但是 Generator 本身并不是专门用来处理异步任务的,而且在使用 Generator 这种方案时,还得引入第三方模块 co,总觉得有点变扭。
ES7:那就来个语法糖把它们包装一下吧!

async/await

ES7 正式引入async/await,它本质上就是 Generator 解决方案的语法糖,并且内置了执行器,上面的handleAsynchronousStuff()可以这么写:
async function handleAsynchronousStuff() {
    try {
        const val = await doSomething();
      console.log(val);
    } catch(e) {
        console.log('Error: ', e);
    }
}

handleAsynchronousStuff();
我觉得async/await相较于 Promise 的最直观的优点就是代码的可阅读性大大的提高了。在过去,我们需要链式地写then()来处理Promise()resolve值,逻辑一复杂,嵌套的代码就越来越多,而async/await则让我们可以像写同步代码一样来写异步代码。
notion image
关于async/awaitPromise更详细的对比,可以见这篇文章:

小结

说到底,async/await就是基于 PromiseGenerator的,要用好async/await,就必须先理解PromiseGenerator
这篇文章正是在阐述这样一个观点:在使用async/await之前,先理解Promise
说来惭愧,再还没有真正理解PromiseGenerator之前我就已经在跟风使用async/await了,如今正是在恶补 JavaScript 异步解决方案的发展历程。现在再来看async/await,发现 JavaScript 这门语言的发展有很大一部分都是依赖于开源社区的贡献,不得不感叹开源社区的力量之强大。

参考资料

  • 《JavaScript 异步编程:设计快速响应的网络应用》
  • MDN

推荐阅读


© Sytone 2021 - 2024