JavaScript异步编程之promise与async

异步编程

我们知道,JavaScript语言的执行环境是“单线程”。这种模式的坏处是只要有一个任务耗时很长,后面的任务都必须排队等待。常见的浏览器无响应往往就是因为某一段JS代码长时间运行导致整个页面卡在这个地方,其他任务无法执行。

为了解决这个问题,JavaScript语言将任务的执行模式分成:同步(Synchronous)和异步(Asynchronous)。同步模式就是上面讲到的模式。异步模式则是:每一个任务有一个或多个回调函数(callback)。前一个任务结束后,不是执行后一个任务,而是执行回调函数。后一个任务则是不等前一个任务结束就执行。执行顺序与任务的排列顺序是不一致的、异步的。比如Ajax操作,异步执行http请求。

常见的几种“异步模式”编程

回调函数

这是异步编程最基本的方法。如果f1是一个很耗时的任务,可以考虑改写f1,把依赖f1返回结果的f2写成f1的回调函数。如下:

1
2
3
4
5
6
7
8
9
10
11
function f1(callback) {
setTimeout(function() {
// f1 code
callback()
}, 1000)
}
function f2() {
// f2 code
}

f1(f2)

优点:简单、容易理解和部署;
缺点:各个部分高度耦合,流程很混乱;每个任务只能制定一个回调函数。

事件监听

事件监听:任务的执行不取决于代码的顺序,而取决于某个事件是否发生。用这种方法修改上面的写法:

1
2
3
4
5
6
7
8
9
// jQuery的写法
f1.on('done', f2)

function f1() {
setTimeout(() => {
// f1 code
f1.trigger('done')
}, 1000)
}

优点:容易理解;可以绑定多个事件,每个事件可以指定多个回调函数;有利于实现模块化
缺点:整个程序都要变成事件驱动型,运行流程不清晰

发布/订阅

发布/订阅模式(观察者模式):假设存在一个信号中心,某个任务执行完成,就像信号中心发布一个信号(事件),其他任务可以向信号中心订阅这个信号(事件),从而知道什么时候自己可以开始执行。

1
2
3
4
5
6
7
8
9
function f1() {
setTimeout(() => {
// f1 code
jQuery.publish('done') // 发布
}, 1000)
}

jQuery.subscribe('done', f2) // 订阅
// jQuery.unsubscribe('done', f2) // 取消订阅

与事件监听类似,但优于事件监听。优点:可以通过查看信息中心,了解存在多少信号、每个信号有多少订阅者,从而监控程序的运行。

Promise对象

Promises对象是CommonJS工作组提出的一种规范,Es6将其写进了语言标准,原生提供了Promise对象。Promise接受一个函数作为参数,该函数的两个参数分别是resolve和reject。这两个函数就是就是回调函数。

  • resolve函数:在异步操作成功时调用,并将异步操作的结果,作为参数传递出去;
  • reject函数:在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
    Promise实例生成以后,可以用then方法指定resolved状态和reject状态的回调函数。
    如:
    1
    2
    3
    4
    5
    6
    7
    8
    const f1 = new Promise((resolve, reject) => {
    if (/* 异步操作成功 */) {
    resolve('success')
    } else {
    reject('fail')
    }
    })
    f1.then(f2)
    优点:回调函数变成了链式写法,使程序流程变得很清楚;如果一个任务已经完成,再添加回调函数,该回调函数会立即执行,所以不用担心是否错过了某个事件或信号。

Promise基本API和常见问题

.then()

1
2
3
Promise.prototype.then(onFulfilled, onRejected)

promise.then(f2).then(f3)

对Promise添加onFulfilledonRejected回调,并返回的是一个新的Promise实例,且返回值将作为参数传入这个新Promise的resolve函数。因此我们可以使用链式写法。如上面的例子。

.catch()

该方法用于指定发生错误时的回调函数。
reject方法的作用,等同于抛错。promise对象的错误,会一直向后传递,直到被捕获。即错误总会被下一个catch所捕获。

1
2
3
4
5
6
7
Promise.prototype.catch(onRejected)

promise.then(data => {
console.log(data)
}).catch((e) => {
console.log(e)
})

如果没有使用catch方法指定处理错误的回调函数,Promise对象抛出的错误不会传递到外层代码,即不会有任何反应(Chrome会抛错),这是Promise的另一个缺点。

.all()

该方法用于将多个Promise实例,包装成一个新的Promise实例。Promise.all方法接受一个数组(或具有Iterator接口)作参数,数组中的对象(p1、p2、p3)均为promise实例。

1
const p = Promise.all([p1, p2, p3])

  • 当p1, p2, p3状态都变为fulfilled,p的状态才会变为fulfilled,并将三个promise返回的结果,按参数的顺序(而不是 resolved的顺序)存入数组,传给p的回调函数。
  • 当p1, p2, p3其中之一状态变为rejected,p的状态也会变为rejected,并把第一个被reject的promise的返回值,传给p的回调函数。

    .race()

    该方法同样是将多个Promise实例,包装成一个新的Promise实例
    不同于all的是:当p1, p2, p3中有一个实例的状态发生改变(变为fulfilled或rejected),p的状态就跟着改变。并把第一个改变状态的promise的返回值,传给p的回调函数。
    1
    const p = Promise.all([p1, p2, p3])

一些常见的问题

  1. reject 和 catch 的区别
  • promise.then(onFulfilled, onRejected)
    在onFulfilled中发生异常的话,在onRejected中是捕获不到这个异常的。
  • promise.then(onFulfilled).catch(onRejected)
    .then中产生的异常能在.catch中捕获。所以还是建议这种写法
  1. 如果在then中抛错,而没有对错误进行处理(即catch),那么会一直保持reject状态,直到catch了错误。如下面的代码,这样写才能保证taskB正常执行

    1
    2
    3
    4
    5
    6
    promise
    .then(taskA)
    .catch(onRejectedA)
    .then(taskB)
    .catch(onRejectedB)
    .then(finalTask)
  2. 每次调用then都会返回一个新创建的promise对象,而then内部只是返回的数据。
    如下面的代码,第一种方法中,then的调用几乎是同时开始执行的,且传给每个then的value都是100,这种方法应当避免。方法二才是正确的链式调用。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    //方法1:对同一个promise对象同时调用 then 方法
    var p1 = new Promise(function (resolve) {
    resolve(100);
    });
    p1.then(function (value) {
    return value * 2;
    });
    p1.then(function (value) {
    return value * 2;
    });
    p1.then(function (value) {
    console.log("finally: " + value);
    });
    // finally: 100

    //方法2:对 then 进行 promise chain 方式进行调用
    var p2 = new Promise(function (resolve) {
    resolve(100);
    });
    p2.then(function (value) {
    return value * 2;
    }).then(function (value) {
    return value * 2;
    }).then(function (value) {
    console.log("finally: " + value);
    });
    // finally: 400
  3. 在异步回调中抛错,不会被catch到

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var promise = new Promise((resolve, reject) => {
    setTimeout(() => {
    throw 'Uncaught Exception!' // 异步回调中抛错
    }, 1000);
    });

    promise.catch(e => {
    console.log(e); //This is never called
    });
  4. promise状态变为resove或reject,就凝固了,不会再改变

Async/Await

Promise让我们告别回调函数,写出更优雅的异步代码。但在实践过程中,我们发现Promise也有不完美的地方。ES7中提出了Async/Await,它能使得我们在编写异步代码时变得像同步一样的方式来编写。

规则

  • async表示这是一个async函数,而await只能在这个函数里面使用。
  • await表示在这里等待await后面的操作执行完毕,再执行下一句代码。
  • await后面紧跟着的最好是一个耗时的操作或者是一个异步操作(当然非耗时的操作也可以的,但是就失去意义了)

与Promise对比

下面是Axios库向GraphQL服务器发送一个请求的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// promise写法
axios.get(`url`)
.then(response => response.data)
.then(data => {
f1(data)
})
.catch(error => console.log(error))

// Async/Await写法
async fetchData(url) => {
try {
const response = await axios.get(`url`)
const data = response.data
f1(data)
} catch (error) {
console.log(error)
}
}

可以看到,async/await与Promise一样,是非阻塞的。但async/await使得异步代码看起来像同步代码,这正是它的魔力所在。

参考