前言
今年秋招某节提前批面试被问到这么一个问题:说一说async 函数与Promise的区别是什么?
关于这道题身边的同学和当时的我一样张口就来:async函数以串行写法的形式彻底解决异步回调的问题;async函数的语义比Promise更明确等等...反正就是讲一些用法上的区别或者是浅显的理解。当时面试官一直不太满意。
后来,在babel平台将async函数polyfill后发现:async函数是一种兼顾了基于Promise的实现和「生成器」(也就是ES6的新特性Generator)的同步写法。所以面试官是想听到这个,但是当时的我并不知道async函数的内部是这样的。
本文将从头梳理一遍如何一步一步从Promise和Generator函数转化为async函数,让大家能够彻底明白async函数内部原理。
Promise
1. Promise特点
在《深入理解JavaScript特性》这本书中对Promise的解释是这样的:「保存着一个未来可用的值的代理」。我对这句话的理解是Promise内部可以写异步操作事件,这个操作未来得到的值存在Promise里。也可以在Promise中编写同步代码,但Promise本身是严格按照异步方式执行的。
Promise对象有以下两个特点:(来自阮一峰的ES6教程)
- 对象的状态「不受外界影响」。只有异步操作的结果可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。
- 一旦状态改变就「不会再变」,任何时候都可以得到这个结果。
Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为resolved(已定型)。
2. Promise的用法
//Promise在语义上是一个构造函数,用来生成Promise实例
const p = new Promise((resolve, reject) => {
//...
if(...操作成功){
resolve(value)
}else{
reject(error)
}
})
p.then(value => {
//对未来值的处理
}, error => {
//失败的错误
})
在这篇文章Promise不是主角,更多有关Promise的用法在阮一峰的ES6教程。
生成器函数
生成器函数也就是ES6常说的Generator函数。Generator函数的调用方法与普通函数一样,也是在函数名后面加上一对圆括号。不同的是,调用 Generator 函数后,该函数并不执行,返回的也不是函数运行结果,而是一个指向内部状态的指针对象(迭代器对象)。
下一步,必须调用遍历器对象的next方法,使得指针移向下一个状态。也就是说,Generator 函数是分段执行的,yield表达式是暂停执行的标记,而next方法可以恢复执行。
//生成器函数用法
function* abc(){
yield 'a'
yield 'b'
yield 'c'
}
let g = abc()
console.log(g.next()) //{ value: 'a', done: false }
console.log(g.next()) //{ value: 'b, done: false }
console.log(g.next()) //{ value: 'c, done: false }
console.log(g.next()) //{ value: undefiend, done: true }
如果不调用next的话生成器函数会在yield处挂起,并且返回值是迭代器迭代器{value:..., done: true/false}。这也是async函数会在await处挂起的基础。
下面看一段代码加深对生成器函数yield的理解:
👇
function* ab(){
let a = yield 'a'
console.log(`a:${a}`)
let b = yield 'b'
console.log(`b:${b}`)
}
let g = ab()
console.log(g.next()) // { value: 'a', done: false }
console.log(g.next(1)) // a: 1 { value: 'b', done: false }
console.log(g.next(2)) // b: 2 { value: undefined, done: true }
第一次调用:Generator 函数开始执行,直到遇到第一个yield表达式为止。g.next()返回一个包含yield后的值的对象{ value: 'a', done: false }。此时并不会发生赋值赋值,只会执行yield 'a'就不会执行了。
第二次调用:此时调用g.next(1)后函数会继续向下执行代码直到执行到第二个yield就挂起。这时候就会继续之前的赋值行为, g.next(1)里的1会作为「上一个」yield 'a'的返回值赋值给a,所以此时a=1。
第三次调用:和上一次一样b接收到了g.next(2)里的返回值2。此时g.next()对象里的done: true表示生成器里的yield操作已经结束。
可以看出第一次调用g.next()时传递的参数没有意义,因为它没有可接收的上一个yield。
async函数
async函数类似一种同步写法,上一个await后的异步操作执行完毕之后才能执行下一个await里的操作。
举个例子:如果我们要实现从a.txt中读取出b.txt的路径,然后再从b.txt中取出里面的内容。那么这两个读取操作肯定不能并行,必须串行先拿到a的结果然后才能从b中拿到结果。
let fs = require('fs').promises //让fs里方法都Promise化,不用自己转
//先看async 函数怎么写
async function read(){
let path = awit fs.readFile('a.txt', 'utf-8')
let result = awit fs.readFile(path, 'utf-8')
return result
}
let res = read() //res是一个Promise对象
res.then(data => {
//data就是成功后最后的结果
console.log(data)
})
可以看出async函数返回的是一个Promise对象,并且内部逻辑是串行的。形式看起来和普通函数没有区别。
1. 简单的async函数转换
然后看看不用async函数应该怎么写(将async函数拆分为Promise和生成器)
function* read(){
let path = yield fs.readFile('a.txt', 'utf-8')
let result = yield fs.readFile(path, 'utf-8')
return result
}
const g = read()
let {value, done} = g.next() //拿到第一次yield后的结果解构,此时的value值为从a中拿到的b的路径
Promise.resolve(value).then(data=>{ //如果路径(未来的值)能拿到的话就进行下一步
let {value, done} = g.next(value) //将路径传给上一个yield的返回值也就是赋值给path,然后将b的结果解构
Promise.resolve(value).then(data => {
let {value, done} = g.next(data) //将b的结果传给上一个yield的返回值也就是赋值给result
})
})
这里用了两个Promise嵌套+Generator函数实现了async函数功能,但是嵌套就挺恶心的而且代码复用性极差。
2.通用性的async函数转换
实现一个通用的代码应该怎么考虑?其实我们要实现的功能就是让function* read()这个函数可以自己动🤔而不是使用者去主动调用next方法。所以实现一个spawn辅助函数去让生成器函数read自己动起来。
function asyncExample(){
return spawn(传入generator函数)
}
function spawn(generator){
//将所有代码包装在一个Promise中(因为async函数返回值为Promise)
return new Promise((resolve, reject)=>{
const g = generator()
//运行第一步
step(() => g.next())
function step(nextFn){
//拿到yeild返回值。runNext函数为了捕获错误让函数更完美,如果写成nextFn()也行
const {value, done} = runNext(nextFn)
if(done){ //done: true就成功完成了所有操作,返回value
resolve(value)
return
}else{ //未完成操作就继续递归g.next
Promise.resolve(value).then(
val => step(() => g.next(val)),
err => step(() => g.throw(err))
)
}
}
//捕获错误的函数
function runNext(nextFn){
try{
return nextFn()
}catch(err){
reject(err)
}
}
})
}
👌,这就写完了整个函数。
对于生成器read函数就直接传入即可。
function* read(){
let path = yield fs.readFile('a.txt', 'utf-8')
let result = yield fs.readFile(path, 'utf-8')
return result
}
function asyncExample(){
return spawn(read)
}
//和async函数一样的表现形式
asyncExample().then(data => {
//data成功后最后的结果
console.log(data)
})