JavaScript中的异步编程

说一说同步和异步吧,我们的代码通常是顺序执行的,当前的代码块执行完,才会直接执行下一个代码块。如果一个函数正在执行,后面的代码只能等待它执行完才开始执行,这就是同步的。所以如果这个函数要花很长时间才能执行完,就发生阻塞,程序像陷入一种卡死的状态。如果这个函数把耗时时间长的任务丢出去,立马就返回,后面的代码可以接着运行,那这个函数就是异步的。

那这个被丢出去的任务总有执行完的时候,等它执行结束后可能要做一些操作,比如展示数据到界面上。这时JavaScript要怎样来执行异步任务结束后要做的事呢?随着JavaScript的不断发展,目前有这些方式:

  • 回调函数
  • 发布/订阅
  • Promise
  • Generator
  • async/await

回调函数

1
2
3
4
5
function f1(callback) {
setTimeout(function() {
callback()
}, 1000)
}

丢一个函数给异步操作,等异步操作结束后,它就会去执行这个函数,这个函数就是回调函数。

函数回调的优点是简单,容易理解和部署,缺点是不利于代码阅读和维护,各个部分之间高度耦合,流程会很混乱,而且一个任务只能指定一个回调函数。

发布/订阅

1
2
3
4
5
document.body.addEventListener( 'click', function(){ 
alert(2)
}, false )

document.body.click() // 模拟用户点击

这也算是异步处理的一种方式吧,我们没办法预知用户什么时候会点击,所以订阅document.body上的click事件,当body节点被点击时,body节点便会向订阅者发布这个消息。

通常发布/订阅用来解决一对多的依赖关系,而回调函数是用来解决一对一的依赖关系。

发布/订阅可以做到事件发生一方和处理事件一方的解耦,但如果发布者和订阅者都变多了以后,也因为结构松散导致代码难以理解和维护。

Promise

理解Promise

Promise的出现,让我们不用再写回调函数层层嵌套的代码了,实际上Promise内部也是维护了一个回调函数的数组,等到异步事件结束后,取出数组中的所有回调函数,然后依次执行。当创建一个Promise时,传入的函数就已经执行了,这个函数有两个函数参数resolvereject,至于何时调用resolve,何时调用reject,就是开发者在编码时去确定的了。

1
2
3
4
let p = new Promise(function(resolve, reject) {
// 通常会在这里执行一些异步操作
// 异步操作结束后,如果成功了,调用 resolve( result ) 将结果传出去,如果失败了,调用 reject( error ) 将错误原因传出去
})

Promise内部还维护了一个状态(state),分别是pendingfulfilledrejected。初始时状态为pending,当我们在上面调用了resolve后,就会将Promise的状态改为fulfilled,当调用reject后,状态就变为了rejected。此外,只能从pending变为fulfilled,且只能转变一次。或者从pending变为rejected,且只能转变一次。fulfilledrejected之间是不能相互转换的。

Promise的原型方法有:

1
2
3
4
5
6
7
8
Promise.prototype.then(onFulfilled, onRejected)
// 当调用then的时候,就可以看做是像Promise维护的callback数组中加了一次回调,但是这个回调是不会马上执行的,上面也说了,是执行resolve或reject时才会执行

Promise.prototype.catch(onRejected)
// catch中的回调是用来处理错误或异常的,所以其实相当于在调用this.then(null, onRejected),onRejected也是要放入callback数组中的。

Promise.prototype.finally(onDone)
// finally中的回调是不管成功还是失败都要执行的,onDone也要放入callback数组中,但因为onDone不能改变Promise的状态,所以它不是简单的this.then(onDone, onDone)。

所以可以看到,实际上最关键的是then方法,它返回了一个新的Promise,使得在链式调用时,可以正确拿到上一步返回的结果,如果上一步返回的是Promise,那拿到的就是这个Promise resolve的结果。

Promise的静态方法有:

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.resove(value)
// 通常而言,如果你不知道一个值是否为Promise对象,使用该方法返回一个Promise对象,这样就能将value以Promise形式使用了。

Promise.reject(reason)
// 返回一个失败状态的Promise对象

Promise.all(iterable)
// 传入一组Promise对象,等所有Promise对象都执行完后,按Promise对象的传入顺序返回结果。
// 全部成功时,返回全部执行结果,有一个失败时,就立即返回失败的原因。
// 注意:all只是用来控制了执行结果的顺序,每个异步事件真正地执行是在Promise对象创建的时候。

Promise.race(iterable)
// 跟all差不多,只不过race在有一个成功时就返回了

具体可以看这里查看Promise的实现过程,我加上了很详细的注释。

Promise的应用

Jake Archibald的这篇文章:JavaScript Promise:简介非常详细的讲解了Promise可以怎样来帮助我们写出简洁的代码,推荐阅读。虽然开发中也一直在用Promise,但看过文章后发现,也只是用了个皮毛,下面是阅读文章做的笔记,再加上自己的一些理解。

先来准备一个返回Promise对象的函数:

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
28
29
30
31
let testURL1 = 'https://ghibliapi.herokuapp.com/films/58611129-2dbc-4a81-a72f-77ddfc1b1b49'
let testURL2 = 'https://ghibliapi.herokuapp.com/films/2baf70d1-42bb-4437-b551-e5fed5a87abe'

function get(url) {
// Return a new Promise
return new Promise(function(resolve, reject) {
// Do the usual XHR stuff
var req = new XMLHttpRequest()
req.open('GET', url)

req.onload = function () {
if (req.status === 200) {
resolve(req.response)
} else {
reject(Error(req.statusText))
}
}

req.onerror = function () {
reject(Error('Network Error'))
}

req.send()
})
}

function getJSON(url) {
// JSON.parse正好需要一个参数,返回一个值,所以这里直接将其传入
// 暂时先不用考虑reject的情况,在本文后面会讲到对error的捕获
return get(url).then(JSON.parse)
}

串联多个异步请求

在开发中,经常会有要先请求一个API,再请求一个API的情况,这时就可以用Promise将其串联起来了。

1
2
3
4
5
getJSON(testURL1).then(function(json) {
return getJSON(testURL2)
}).then(function(json2) {
console.log(json2)
})

如果在第1个then的回调中又返回了一个Promise,第2个then会等待这个Promise执行完才执行,而且这个 Promise resolve的结果会传入第2个then的回调中。这样我们就可以将多个有顺序的API串联起来请求了,避免了去写出回调地狱那样的代码。

此外,在开发中如果遇到这样的情况,api_a在请求api_b之前要先去请求一次,在请求api_c之前也需要先去请求一次,但是api_bapi_c的请求顺序是没有关系的,不确定谁先请求,谁后请求,这是我们可以用Promise包装api_a的请求,然后将其缓存下来,像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var reusablePromise
function getReusableData() {
reusablePromise = reusablePromise || getJSON(testURL1)
return reusablePromise
}

getReusableData().then(function(json) {
return getJSON(testURL2)
}).then(function(json2) {
console.log(json2)
})

//补上一个接口,以便于测试
let testURL3 = 'https://ghibliapi.herokuapp.com/people/ba924631-068e-4436-b6de-f3283fa848f0'
getReusableData().then(function(json) {
return getJSON(testURL3)
}).then(function(json2) {
console.log(json2)
})

这样做的好处是,testURL1的数据会在getReusableData被第一次调用的时候才去请求,之后再调用getReusableData时,不会再请求testURL1,运行代码后可以dev tools中观察接口请求情况。

错误处理

异常情况的处理,之前我们可能都放在了reject的回调中,还可以用catch的回调来专门处理错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
getJSON(testURL1).then(function(json) {
console.log('asyncThing1 work!')
return getJSON('/')
})
.then(function() {
console.log('asyncThing2 work!')
})
.catch(function(err) {
console.log('asyncThing2 error!')
// 在asyncThing2发生error,请求另一个接口尝试恢复
return getJSON(testURL2)
})
.then(function(json){
console.log('recovery1 work!')
return getJSON('/')
})
.catch(function(err) {
console.log('asyncThing3 error!')
})
.then(function() {
console.log('All done!')
})

这段的打印结果是:

1
2
3
4
5
asyncThing1 work!
asyncThing2 error!
recovery1 work!
asyncThing3 error!
All done!

所以可以观察到,虽然catch捕获到了error,但是在其后的then回调函数会继续执行。所以实际开发中,可以在catch的回调函数中进行错误处理,比如弹一个错误提示框。最后可以再放一个then回调函数,执行最后的收尾工作,比如隐藏loading条。

此外,Promise构造函数中发生错误,或者then回调中发生错误,都会自动捕获进而拒绝,触发catch回调。

顺序请求多个接口和并行请求多个接口

可以结合Array.prototype.reducePromise.resolve()来解决。

顺序请求多个接口:

1
2
3
4
5
6
7
8
urls.reduce(function(sequence, url) {
return sequence.then(function() {
return getJSON(url)
})
.then(function(json) {
console.log(json)
})
}, Promise.resolve())

并行请求多个接口,然后等待所有请求结束后再处理数据,可使用Promise.all

1
2
3
Promise.all(arrayOfPromises).then(function(arrayOfResults) {
//...
})

并行请求多个接口,但在每个接口返回数据时都处理相应的数据,并且是按照传入的Promise的顺序处理的:

1
2
3
4
5
6
7
8
9
urls.map(getJSON).reduce(function(sequence, promise) {
// 使用sequence将promise有序串联起来,这样就可以按照顺序拿到数据了
return sequence.then(function() {
return promise
})
.then(function(json) {
console.log(json)
})
}, Promise.resolve())

Promise虽然让我们可以用链式调用的方式来处理异步请求回来的结果,但是如果Promise多了的话,一堆的then也会看起来很不友好,而且Promise没办法取消,还必须主动设置处理错误的回调函数,才能知道是否发生了错误或者异常。

generator

ES6规范同时提出了Generator的方式来编写异步程序,它的形式如下:

1
2
3
4
5
6
7
8
function* generatorFunction() {
yield 1
yield 2
yield 3
}

const generator = generatorFunction()
// "Generator { }"

Generator函数会始终返回一个Generator对象,它的原型方法有:

Generator.prototype.next()

返回一个由yield表达式生成的值,该值具有valuedone属性。当迭代到最后一个yield,如果有return语句是迭代到return时,done会变为true。它也可以接受一个参数,然后这个参数会被发送给Generator函数中的下一个yield

1
generator.next()  // { value: 1, done: false }

Generator.prototype.return()

可以调用这个方法随时手动结束生成器,这个方法也接受一个参数,并会将该参数返回。

1
generator.return('over') // { value: 'over', done: false }

Generator.prototype.throw()

像生成器抛出一个error,可以将错误原因作为参数传入。在Generator函数中可以通过try/catch来捕获这个error

咱们来从表面看看Generator有什么有意思的地方,通常函数在调用完就结束了,但是我们还可以在generatorFunction函数调用完之后再向它传值,并且还可以从它再拿到新的返回值,而且当前程序也没有发生阻塞。yield似乎把函数的执行暂停在那里了,等到调用next()时候才启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function* testGenerator() {
const a = yield 'aaaaaaa'
const b = yield 'bbbbbbb'
const c = yield 'ccccccc'
// 下面的代码会等到上面 yield 都执行完才会执行
console.log('a = ' + a)
console.log('b = ' + b)
console.log('c = ' + c)
}

var gen = testGenerator()
var a = gen.next().value // 得到 aaaaaaa
// 将上一个得到的 aaaaaaa 传入,使得testGenerator内的 a 的值变为 aaaaaaa,同时得到 bbbbbbb
var b = gen.next(a).value
// 将上一个得到的 bbbbbbb 传入,使得testGenerator内的 b 的值变为 bbbbbbb,同时得到 ccccccc
var c = gen.next(b).value
// 将上一个得到的 ccccccc 传入,使得testGenerator内的 b 的值变为 ccccccc
// 此时 done = true
gen.next(c)

自己最好在dev tools中运行看看,直观感受一下哦

所以我认为next(val)中的这个参数值主要是为了告诉生成器,上一步yield后面表达式的计算结果,下一步yield的计算也有可能依赖于上一个的结果。当然了,我们可以像next(val)中传递任何值。

Generator可以怎样应用于异步编程呢?

下面看个例子,利用fetchjsonplaceholder请一组mock数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
function* getUsers() {
const response = yield fetch('https://jsonplaceholder.typicode.com/users')
const json = yield response.json()
console.log(json)
}

const gen = getUsers()

gen.next().value.then(response => {
gen.next(response).value.then(response => {
gen.next(response)
})
})

getUsers中虽然有异步请求,但是在代码书写上就像是同步的,非常便于阅读。那你可能会说那不是还需要下面这些额外的工作嘛。但其实完全可以把通过next一步步拿值的这段代码封装起来,写一次就行了,下面是一个简单版的封装:

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
28
29
30
31
function asyncAlt(generatorFunction) {
// 返回一个函数
return function() {
const generator = generatorFunction()

function resolve(next) {
if (next.done) {
return Promise.resolve(next.value)
}

// 如果yield中还有值,就要等待这个异步返回结果,然后传给下一步,再进行resolve,如此循环,直到没有yield了
return Promise.resolve(next.value).then(response => {
return resolve(generator.next(response))
})
}

// 启动generator
return resolve(generator.next())
}
}

// 测试
const getUsers = asyncAlt(function* (){
const response = yield fetch('https://jsonplaceholder.typicode.com/users')
const json = yield response.json()
return json
})

getUsers().then(response => {
console.log(response)
})

Generator允许我们用顺序的形式来进行异步编程,更加方便代码阅读。但是也要自行处理next()取值,或者利用第三方库如Co.js进行配合。

实际上Generator协程在JavaScript中的实现,关于协程,可以看廖雪峰老师的这篇文章更进一步了解。

asyn/await

如何使用asyn/await

好在ES7规范提出了asyn/await,结合Promise,让我们真正可以像写同步代码一样去写异步代码,它的使用形式如下:

1
2
3
4
5
6
7
async function myFirstAsyncFunction() {
try {
const fulfilledValue = await promise
} catch(rejectedValue) {
// ...
}
}

在函数定义前面加上async关键字,就可以在函数内部使用await了,而且await一定是一个Promise对象,之后函数会暂停执行,直到Promise对象产生结果,并且暂停不会阻塞主线程。如果Promise成功,就会返回值,如果Promise拒绝,就会抛出错误。

调用加上了async的函数,始终会返回一个Promise对象,如果函数有返回值,该Promise成功,并返回该值,如果函数抛出了错误,该Promise拒绝,并抛出该错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function wait(ms) {
return new Promise(r => setTimeout(r, ms))
}

async function hello() {
await wait(500)
return 'world'
// 或者
// throw Error('bar')
}

hello().then(val => {
console.log(val) // world
}, err => {
console.log(err) // bar
})

关于并行和并发

在MDN的async function这一节举的例子中,有特意对比并行和并发写法的不同,我将其中Promise的部分拿出来,感兴趣的话,可以跳转到MDN看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var concurrentPromise = function() {
console.log('==CONCURRENT START with Promise.all==');
return Promise.all([resolveAfter2Seconds(), resolveAfter1Second()]).then((messages) => {
console.log(messages[0]); // slow
console.log(messages[1]); // fast
});
}

// This function does not handle errors. See warning below!
var parallelPromise = function() {
console.log('==PARALLEL with Promise.then==');
resolveAfter2Seconds().then((message)=>console.log(message));
resolveAfter1Second().then((message)=>console.log(message));
}

// wait again
setTimeout(concurrentPromise, 7000); // after 2 seconds, logs "slow" and then "fast"

// wait again
setTimeout(parallelPromise, 13000); // truly parallel: after 1 second, logs "fast", then after 1 more second, "slow"

这块我想说的是,不管是哪一种写法,在调用resolveAfter2Seconds()resolveAfter1Second()时异步操作都已经开始了,只所以看到输出结果不同,是因为then被执行的时机不同,Promise.all内部是新生成了一个Promise对象,然后用for循环计数传出的Promise是否都执行完了,都执行完后才调用了新生成的Promiseresolve,我们看到这个then才被调用。下面parallelPromise就不用说啦,异步执行完的时候then就被执行了,所以我们看到的输出结果时机不一样。

我觉得没有必要扯到并行和并发上去,要不总不自觉联想到操作系统那一套东西 =.=

这里也正好提醒我们,虽然async/await可以让我们以同步的方式写异步代码,但是有多个异步操作可以同时进行时,还是要先执行异步操作,然后再对结果进行处理。假如我们想获取一组网址,并尽快按正确的顺序将它们记录到日志中。

🚫不推荐的写法

1
2
3
4
5
6
async function logInOrder(urls) {
for(const url of urls) {
const response = await fetch(url)
console.log(await response.text())
}
}

虽然写法跟我们平时写for的代码一样,但是效率却低很多,要等一个请求拿到结果后,下一个请求才会发出。

✅推荐的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
async function logInOrder(urls) {
// async函数返回的始终是Promise对象,这里将url映射成Promise对象
// 这map的过程中,实际上就有n个async函数被调用了,所以请求是一起发出去的
const textPromises = urls.map(async url => {
const response = await fetch(url)
return response.text()
})

// 最后等所有的请求都执行完毕,再按照顺序打印日志就好了
for (const textPromise of textPromises) {
console.log(await textPromise)
}
}

async/await的用法看起来是不是很想上一节展示的asyncAlt函数,实际上可以把async/await看成是*/yield的语法糖,模拟实现过程可以看这里。有一些浏览器可能还不支持async/await,可以使用Babel进行转译。

参考资料

【1】 Javascript异步编程的4种方法

【2】 JavaScript Promise:简介

【3】 图解 Promise 实现原理(一)—— 基础实现(一到四)

【4】 Understanding Generators in JavaScript

【5】 异步函数 - 提高 Promise 的易用性

本文作者:意林
本文链接:http://shinancao.cn/2019/08/10/JS-Async/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!