javascript异步详解2:异步发展历程及Promise演进史

560 阅读13分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。详情

# javascript异步详解1:事件循环机制EventLoop

一、Promise及常用API

认识Promise

promise的出现主要是实现了异步控制:解决回调地狱。

1. promise结构示例

var p = new Promise(fn)
// 【注】fn:是初始化过程中调⽤的函数他是同步的回调函数
console.log('起步')
var p = new Promise(function(resolve,reject){
  // resolve触发 .then
  console.log('调⽤resolve')
  resolve('执⾏了resolve')
  // reject触发 .catch
  // console.log('调⽤reject')
  // reject('执⾏了reject')
})
p.then(function(res){
 console.log(res)
 console.log('then执⾏')
}).catch(function(){
 console.log('catch执⾏')
}).finally(function(){
 console.log('finally执⾏')
})
// 无论是resolve还是reject,finally都会执行
console.log('结束')
// resolve结果: 起步->调⽤resolve->结束->执⾏了resolve->then执⾏->finally执行
// reject结果:  起步->调⽤reject->结束->执⾏了reject->catch执⾏->finally执行

【注】new Promise(fn)中的 fn 是同步执行的,这个回调函数内部没有执行resolve或者reject那么p对象的后⾯的链式回调函数不会触发,运行resolve函数之后.then和.finally就会执行,运行了reject之后.catch和.finally就会执行。

2. Promise的三种状态

pending  // 初始状态
fulfilled  // 已完成
rejected  // 已拒绝

关系:Promise约定,当对象创建之后,只能从pending变为fulfilled或rejected中的一种,且状态一旦变更就不会再变,此时Promise对象的流程执行完成并执行finally函数。

Promise.all()

promise.all相当于统⼀处理了多个promise任务,保证处理的这些所有promise对象的状态全部变成为fulfilled之后才会触发all的.then函数来保证将放置在all中的所有任务的结果返回。

场景:假设页面同时调用3个接口,需要全部获取到返回数据后再渲染页面。Promise.all消耗的时间是这3个异步中最长的时间。

let p1 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('第⼀个promise执⾏完毕')
    // reject('第⼀个promise错误')
  }, 1000)
})
let p2 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('第⼆个promise执⾏完毕')
  }, 2000)
})
let p3 = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('第三个promise执⾏完毕')
    // reject('第三个promise错误')
  }, 3000)
})
Promise.all([p1, p3, p2]).then(res => {
  console.log(res) // ["第⼀个promise执⾏完毕", "第三个promise执⾏完毕", "第⼆个promise执⾏完毕"]
}).catch(function (err) {
  console.log(err)
})

【注】1. Promise.all的then执行时得到的res是个数组,其顺序是调用all时传进入的数组中promise的顺序。2. then函数是必须时传进去的全部的promise都resolve了才会触发执行。当其中有一个reject了,会直接触发catch并返回最先reject的promise的返回值err(err是reject的内容,不是数组),不会触发then也不会得到第二个reject的promise的返回值err。

Promise.race()

使用方法和格式与all相同。区别在于用时是传入的peomise中执行最快的时长;回调函数的参数是最快执行完毕的promise的返回值。

场景:如流媒体播放为了保证⽤户可以获得较低的延迟,都会提供多个媒体数据源。可以优先展示这些数据源中针对当前用户速度最快的一个。

Promise.race([p1, p3, p2]).then(res => {
  console.log(res) // 第⼀个promise执⾏完毕
}).catch(function (err) {
  console.log(err)  // 第⼀个promise错误
})

【注】:只触发执行最快的一个promise的回调函数,最快的是resolve则触发then,是reject则触发catch,只返回最快的。

二、Promise解决回调地狱

为什么使用Promise对象

在以前的编码中JavaScript的主要异步处理方式,是采⽤回调函数的方式来进行处理,想要保证n个步骤的异步编程有序进行,例如在获取到某个接口的数据后再发起一个网络请求,就会出现类似如下的代码:

//获取类型数据
$.ajax({
    url:'/***',
    success:function(res){
        var xxId = res.id //获取该类型的数据集合,必须等待回调执⾏才能进⾏下⼀步 
        $.ajax({
            url:'/***'
            data:{ xxId:xxId,  //使⽤上⼀个请求结果作为参数调⽤下⼀个接⼝ },
            success:function(res1){ //得到指定类型集合 ... }
        })
    }
})

这种情况在很多人的代码中都出现过,如果流程复杂化,在⽹络请求中继续夹杂其他的异步流程,那么这样的代码 就会变得难以维护了。 所以之所以在ECMA提案中出现Promise解决方案,就是因为此类代码导致了JS在开发过程中遇到的实际问题:【回调地狱】。

当我们再遇到类似的需要等待一个异步完成之后再执行下一个异步时就可以用Promise的链式调用:

//使⽤Promise拆解的setTimeout流程控制
var p = new Promise(function(resolve){
    setTimeout(function(){
        resolve()
    },1000)
})
p.then(function(){
    //第⼀秒后执⾏的逻辑
    console.log('第⼀秒之后发⽣的事情')
    return new Promise(function(resolve){
        setTimeout(function(){
            resolve()
        },1000)
    })
}).then(function(){
    //第⼆秒后执⾏的逻辑
    console.log('第⼆秒之后发⽣的事情')
    return new Promise(function(resolve){
        setTimeout(function(){
            resolve()
        },1000)
    })
}).then(function(){
    //第三秒后执⾏的逻辑
    console.log('第三秒之后发⽣的事情')
})

链式调用

通过⼀个超长的链式调⽤我们学习⼀下链式调⽤的注意事项

var p = new Promise(function(resolve,reject){
    resolve('我是Promise的值')
})
console.log(p)  // 结果:Promise {<fulfilled>: '我是Promise的值'}
p.then(function(res){
    //该res的结果是resolve传递的参数
    console.log(res)  // 结果:我是Promise的值
}).then(function(res){
    //该res的结果是undefined
    console.log(res)
    return '123'
}).then(function(res){
    //该res的结果是123
    console.log(res)  // 该res是上一个调用的return结果
    return new Promise(function(resolve){
        resolve(456)
    })
}).then(function(res){
    //该res的结果是456
    console.log(res)
    return '我是直接返回的结果'
}).then()
.then('我是字符串')
.then(function(res){
    //该res的结果是“我是直接返回的结果”,直接跳过上两个.then
    console.log(res)
})

结论:链式调用的基本形式:

  1. 只要有then()并且触发了resolve,整个链条就会执⾏到结尾,这个过程中的第⼀个回调函数的参数(res)是resolve 传⼊的值。
  2. 后续每个函数都可以使⽤return返回⼀个结果,如果没有返回结果的话下⼀个then中回调函数的参数(res)就是undefined。
  3. 返回结果如果是普通变量,那么这个值就是下⼀个then中回调函数的res。
  4. 如果返回的是⼀个Promise对象,那么这个Promise对象resolve的结果会变成下⼀次then中回调的函数的res。
  5. 如果then中传⼊的不是函数或者未传值,Promise链条并不会中断then的链式调⽤,并且在这之前最后⼀次的return结果,会直接进⼊离它最近的正确的then中的回调函数作为参数。

中断链式调用

中断链式调用有两种方法:rejectthrow

var p = new Promise(function(resolve,reject){
    resolve('我是Promise的值')
})
console.log(p)  // Promise {<fulfilled>: '我是Promise的值'}
p.then(function(res){
    console.log(res)  // 我是Promise的值
}).then(function(res){
    //有两种⽅式中断Promise
    // throw('我是中断的原因')
    return Promise.reject('我是中断的原因')
        }).then(function(res){
    console.log(res)
}).then(function(res){
    console.log(res)
}).catch(function(err){
    console.log(err)  // 我是中断的原因
})

以上看出,中断链式调用后会触发catch的执行,中断触发到catch中间的then函数不会执行,这样链式调用流程就结束了。中断方式为:返回一个rejected状态的promise或者抛出异常。

Q:中断链式调用前,链式调用是正常的then调用,状态为fulfilled,但是中断时触发了catch,表明状态为rejected,是否违背了promise的精神?

A:并不违背。因为我们使⽤then函数进⾏链式调⽤时,每次都返回新的Promise对象。也就是说每⼀次then函数在执行时,我们都可以让本次的结果在下⼀个异步步骤执行时,变成不同的状态,而且这也不违背Promise对象最初的约定。

var p = new Promise(function(resolve,reject){
 resolve('我是Promise的值')
})
var p1 = p.then(function(res){
})
console.log(p)  // Promise {<fulfilled>: '我是Promise的值'}
console.log(p1)  // Promise {<pending>}
console.log(p1===p)  // false

结论

Promise对象的主要用途是通过链式调用的结构,将原本回调、嵌套的异步处理流程(如http请求一个数据后根据该数据做参数再请求获取数据),转化成对象.then().then()...的链式结构,这样虽然仍离不开回调函数,但是将原本的回调嵌套结构,转化成了连续调⽤的结构,这样就可以在阅读上编程上下左右结构的异步执⾏流程了,代价是代码量增加。

三、Promise的演进:Generator函数

promise可以使用then进行链式调用,但是其代码量多按照⼈类的线性思维,虽然JavaScript分同步和异步,但是单线程模式下,如果能完全按照同步代码的编写方式来处理异步流程,这才是最nice的结果,那么有没有办法让Promise对象能更进⼀步的接近同步代码呢?

generator函数介绍

ES6 新引⼊了 Generator 函数,可以通过 yield 关键字,把函数的执⾏流挂起,执行函数的时候返回一个分步执行对象。通过next方法让程序继续执行,next返回的对象中包含value和done两个属性,value代表上⼀个yield返回的结果,done代表程序是否执行完毕。

function * test(){
    var a = yield 1
    console.log(a)  // undefined
    var b = yield 2
    console.log(b)  // undefined
    var c = a+b
    console.log(c)  // NaN
}
//获取分步执⾏对象
var generator = test()
console.log(generator)  // test {<suspended>}
//步骤1 该程序从起点执⾏到第⼀个yield关键字后,step1的value是yield右侧的结果1
var step1 = generator.next()
console.log(step1)  // {value: 1, done: false}
//步骤2 该程序从var a开始执⾏到第2个yield后,step2的value是yield右侧的结果2
var step2 = generator.next()
console.log(step2)  // {value: 2, done: false}
//由于没有yield该程序从var b开始执⾏到结束
var step3 = generator.next()
console.log(step3)  // {value: undefined, done: true}

我们发现a、b的值不见了,导致c是NaN,虽然实现了分步执行,但是流程出现了问题。因为next函数执⾏的过程中我们是需要传递参数的,我们如果想让yield左侧的变量有值就必须在next中传⼊指定的结果。

function * test(){
    var a = yield 1
    console.log(a)  // 1
    var b = yield 2
    console.log(b)  // 2
    var c = a+b
    console.log(c)  // 3
}
var generator = test()
console.log(generator)
var step1 = generator.next()
console.log(step1)  // {value: 1, done: false}
var step2 = generator.next(step1.value)
console.log(step2)  // {value: 2, done: false}
var step3 = generator.next(step2.value)
console.log(step3)  // {value: undefined, done: true}

结论:next调用时返回的值的value的yield右侧的值,当下一次调用next时传进去的值就变成了这次yield左侧的值。

generator中的异步流程

以setTimeout和promise为例:

function * test(){
    var a = yield 1
    var res = yield setTimeout(function(){
        return 123
    },1000)
    var res1 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve(456)
        },1000)
    })
}
var generator = test()
console.log(step)  // test {<suspended>}
var step1 = generator.next()
console.log(step1)  // {value: 1, done: false}
var step2 = generator.next()
console.log(step2)  // {value: 1, done: false}
var step3 = generator.next()
console.log(step3)  // {value: Promise, done: false}
var step4 = generator.next()
console.log(step4) // {value: undefined, done: true}
// 展开promise对象可以看到获得的结果
// [[Prototype]]: Promise
// [[PromiseState]]: "fulfilled"
// [[PromiseResult]]: 456
// [[Prototype]]: Object

结论:Generator函数的分步流程中,Promise和普通对象都能拿到运行流程的结果,但是定时的异步流程无法控制。

实现Generator将Promise的异步流程同步化

通过上⾯的观察,我们可以通过递归调⽤的⽅式,来动态的去执⾏⼀个Generator函数,以done属性作为是否结束 的依据,通过next来推动函数执⾏,如果过程中遇到了Promise对象我们就等待Promise对象执⾏完毕再进⼊下⼀ 步,我们这⾥排除异常和对象reject的情况,封装⼀个动态执行generator函数的函数如下:

// fn: generator函数
function generatorFunctionRunner(fn) {
    let generator = fn();  // 获取分步对象
    let step = generator.next();
    // 定义递归函数
    function loop(stepArg, generator) {
        const {value, done} = stepArg
        if (done===false) {
          if (value instanceof Promise) {
            // 如果yield的结果是个promise,在promise的then回调时脱壳获取本次真正的结果
            value.then(function(promiseValue){
                loop(generator.next(promiseValue), generator)
            })
          } else {
            loop(generator.next(value),generator)
          }
        }
    }
    loop(step,generator)
}

例如我们做一个每隔一秒输入一段字符串的需要:

function * test(){
    var res1 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve('第⼀秒运⾏')
        },1000)
    })
    console.log(res1) // 第⼀秒运⾏
    var res2 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve('第⼆秒运⾏')
        },1000)
    })
    console.log(res2) // 第⼆秒运⾏
    var res3 = yield new Promise(function(resolve){
        setTimeout(function(){
            resolve('第三秒运⾏')
        },1000)
    })
    console.log(res3) // 第三秒运⾏
}
generatorFunctionRunner(test)
// 以上执行过程我们可以可以通过工具函数和Generator/yield函数控制了异步过程实现同步化。

我们可以得到预想结果:每隔1秒输出一个结果:

image.png

关于工具函数和Generator/yield函数控制了异步过程实现同步化的其他经典场景:umi中model的effects用法还保留着generator

四、Async和Await

经过Generator的过渡之后异步代码同步化的需求逐渐成为了主流需求,这个过程在ES7版本中得到了提案,并 在ES8版本中进行了实现,提案中定义了全新的异步控制流程。

//提案中定义的函数使⽤成对的修饰符
async function test(){
 await ...
 await ...
}
test()

我们可以通过async修饰一个函数,使用await来自动控制函数流程。当await右侧是promise对象时,会等待到Promise状态变成fulfilled,程序再向下执行,同时Promise的值会自动返回给await左侧的变量中。

async和await需要成对出现,async可以单独修饰函数,但是 await只能在被async修饰的函数中使用。

有了await和async就相当于使⽤了⾃带执⾏函数的Generator函数,这样我们就不再需要单独针对Generator函数进行开发了,所以async和await逐渐成为主流异步流程控制的终极解决⽅案。⽽Generator慢慢淡出了业务开发者的舞台,不过Generator函数成为了向下兼容过渡期版本浏览器的候补实现⽅式,虽然在现今的⼤部分项⽬业务中使⽤Generator函数的场景⾮常的少,但是如果查看脚⼿架项⽬中通过babel构建的JavaScript⽣产代码,我们还是能⼤量的发现Generator的应⽤的,他的作⽤就是为了兼容不⽀持async和await的浏览器。

认识async函数

async function test(){
 return 1
}
let res = test()
console.log(res)
// Promise {<fulfilled>: 1}
// [[Prototype]]: Promise
// [[PromiseState]]: "fulfilled"
// [[PromiseResult]]: 1

我们发现,通过async修饰的函数,返回了一个promise对象,promise的值为函数return的值。

async function test(){
 console.log(2)
 var a = await 4
 console.log(a)
 return 5
}
console.log(1)
test()
console.log(3)
// 输出结果:1 2 3 4

async修饰的函数会被解释成promise对象,我们可以这样理解其过程:

console.log(1)
// test()的解释
new Promise(function(resolve){
  console.log(2)  // 注册promise时传进去的function是同步的
  resolve(4)  // await的操作将4赋值给a
}).then(function(a){
  console.log(a)
  return new Promise(function(resolve){
    resolve(5)  // test函数return的5,包裹一层promise,需要用await调用test去壳。
  })
})
console.log(3)

总结:

从回调地狱到Promise的链式调用到Generator函数的分步执行再到async和await的自动异步代码同步化机制,经历了很多个年头,所以我们要对Promise对象本身以及他的发展历程是否有深入的了解。要扎实的掌握JavaScript的事件循环系统和异步编程知识。