Java教程

万字长文带你了解Promise

本文主要是介绍万字长文带你了解Promise,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

介绍

JavaScript是一门典型的异步编程脚本语言,在编程过程中会大量的出现异步代码的编写,在JS的整个发展历程中对异步编程的处理方式经历了很多个时代,最典型也是现今使用最广泛的时代就是Promise对象处理异步编程。那么什么是Promise对象呢?

Promise是ES6版本提案中实现的异步处理方式,对象代表了未来将要发生的事件,用来传递异步操作的消息。

为什么使用Promise对象

举个栗子:

在过去的编程中JavaScript的主要异步处理方式是采用回调函数的方式来进行处理的所以如果想要实现n个步骤的异步编程会出现如下的代码(以setTimeout为栗子)

setTimeout(function(){
  //第一秒后执行的逻辑
  console.log('第一秒之后发生的事情')
  setTimeout(function(){
    //第二秒后执行的逻辑
    console.log('第二秒之后发生的事情')
    setTimeout(function(){
      //第三秒后执行的逻辑
      console.log('第三秒之后发生的事情')
    },1000)
  },1000)
},1000)

参考上面的代码,如果分3秒每间隔一秒运行一个任务,这三个任务还必须按时间顺序执行,并且每个下一秒触发前都要先拿到上一秒运行的结果,那么我们不得不将代码编程上面的编写方式,主要是为了保证代码的严格顺序要求。这样就避免不了大量的逻辑在回调函数中不停的进行嵌套,这也是我们经常听说的“回调地狱”。

再举个栗子:

在编程中setTimeout的栗子其实使用场景极少,在前端开发过程中使用最多的异步流程就是AJAX请求,当系统中要求某个页面的n个接口保证有序调用的情况下就会出现下面的情况

//获取类型数据
$.ajax({
  url:'/***',
  success:function(res){
    var xxId = res.id
    //获取该类型的数据集合,必须等待回调执行才能进行下一步
    $.ajax({
      url:'/***',
      success:function(res1){
        //得到指定类型集合
      }
    })
  }
})

这种情况在很多人的代码中都出现过,如果流程复杂化,在网络请求中继续夹杂其他的异步流程那么这样的代码就会变得难以维护了。

其他的栗子诸如Node中的原始fs模块操作文件系统这种就不举了,所以之所以在ECMA提案中出现Promise解决方案就是因为如下代码导致了JS在开发过程中遇到的实际问题,回调地狱,其实解决回调地狱的方式还有其他方案这里我们不多做介绍专题介绍Promise,因为他是解决回调地狱的非常好的方式。

使用Promise如何解决异步控制问题

上面仅仅是抛出了问题并没有针对问题作出一个合理的解决方案,下面阐述一下如何使用Promise对象解决回调地狱问题。

在阐述之前我们先对Promise做一个简单的介绍,Promise对象的主要用途是通过链式调用的结构将原本回调嵌套的异步处理流程编程.then().then()的链式结构,这样虽然依然离不开回调函数但是将原本的回调嵌套结构转化成了连续调用的结构这样就可以编程看起来上下顺序的异步执行流程了。

我们看一段代码,还是以setTimout为栗子我们改造第一个案例

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('第三秒之后发生的事情')
})

结合代码案例我们发现使用了Promise之后的代码将原来的三个setTimeout的回调嵌套拆解成了三次.then的回调函数,按照上下顺序进行编写,这样我们从视觉上按照人类的从上到下从左到右的线性思维阅读代码是很容易能看出来这段代码的执行流程的,代价是代码的编写量增加了一倍。

Promise介绍

从上面的案例介绍得知Promise的作用是解决“回调地狱”,他的解决方式是将回调嵌套拆成链式调用,这样便可以按照上下顺序来进行异步代码的流程控制。那么Promise是如何实现这个能力的呢?

Promise的结构

Promise对象是一个JS对象,在支持ES6语法的运行环境中作为全局对象提供,他的初始化方式如下:

//fn:是初始化过程中调用的函数他是同步的回调函数
var p = new Promise(fn)

关于回调函数

这里涉及到一个概念,JS中有一个特殊的函数叫做回调函数,回调函数的特点是把函数作为属性看待那么属性可以作为其他函数的形参,那么我们就可以把一个函数中的参数也写成函数的形式出现如下效果。

//把fn当作函数对象那么就可以在test函数中使用()执行他
function test(fn){
  fn()
}
//那么运行test的时候fn也会随着执行,所以test中传入的匿名函数就会运行
test(function(){
  ...
})

上面的代码结构就是JS中典型的回调函数,按照我们在事件循环中介绍的JS函数运行机制我们会发现其实回调函数本身是同步代码,这个地方是一个重点理解的部分。

因为通常在编写JS代码的时候使用的回调嵌套的形式大多是异步函数所以可能一些开发者会下意识的认为凡是回调形式的函数都是异步流程。但其实并不是这样的。真实的解释是JS中的回调函数结构默认是同步函数,但是由于JS单线程异步模型的规则我们如果想要编写异步的代码必须使用回调嵌套的形式才能实现,所以回调函数不一定是异步代码但是异步代码一定是回调函数。

依然举个栗子:

function test(fn){
  fn()
}
console.log(1)
test(function(){
  console.log(2)
})
console.log(3)
//这段代码的输出顺序应该是1,2,3,因为他属于直接进入执行栈的程序,会按照正常程序解析的流程输出
function test(fn){
  setTimeout(fn,0)
}
console.log(1)
test(function(){
  console.log(2)
})
console.log(3)
//这段代码会输出1,3,2因为在调用test的时候settimeout将fn放到了异步任务队列挂起了,等待主程序执行完毕之后才会执行

再思考一下,如果我们有一个变量a为0,想要1秒之后设置他为1,并且我们想要在2秒之后得到a的新结果,这个逻辑中如果1秒之后设置a为1采用的是setTimeout,那么我们在同步结构里能否实现?

//这段代码的输出一定是0,因为同步在前异步在后
var a = 0
setTimeout(function(){
  a = 1
},1000)
console.log(a)

进而做如下改造

//当我们将程序编程如下结构之后发现运行时页面会假死2秒但实际还是输出0
//因为单线程异步模型的运行流程是先执行主线程的同步代码主线程代码没有执行完毕之前计时挂起的程序就算到时间放到了任务队列那线程占用的情况下任务队列中的任务也无法拿到执行栈中运行
var a = 0
setTimeout(function(){
  a = 1
},1000)
var d = new Date().getTime()
var d1 = new Date().getTime()
while(d1-d<2000){
  d1 = new Date().getTime()
}
console.log(a)

所以最终的结果是这样的

//我们只有在这个回调函数中才能获取到a改造之后的结果
var a = 0
setTimeout(function(){
  a = 1
  console.log(a)
},1000)

到这里大概明白了回调函数的意义以及使用场景了,那么我们的Promise对象完整的结构是如下样子的,并且他就是一个及特殊的既包含同步的回调函数又包含异步的回调函数。

Promise案例介绍

var p = new Promise(function(resolve,reject){
  
})
p.then(function(){
  console.log('then执行')
}).catch(function(){
  console.log('catch执行')
}).finally(function(){
  console.log('finally执行')
})

参考上面的Promise对象编程结构,一个Promise对象包含两部分回调函数第一部分是new Promise时候传入的对象,这段回调函数是同步的,而.then中的回调函数是异步的。这里我们提前记好。接下来可以在html页面中跑一遍程序,会发现这段程序并没有任何输出,然后我们可以将程序继续改造。

console.log('起步')
var p = new Promise(function(resolve,reject){
  console.log('调用resolve')
  resolve('执行了resolve')
})
p.then(function(res){
  console.log(res)
  console.log('then执行')
}).catch(function(){
  console.log('catch执行')
}).finally(function(){
  console.log('finally执行')
})
console.log('结束')

将这段程序运行一下会发现输出顺序为:

起步->调用resolve->结束->执行了resolve->then执行->finally执行

再看下面的代码

console.log('起步')
var p = new Promise(function(resolve,reject){
  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执行')
})
console.log('结束')

将这段程序运行一下会发现输出顺序为:

起步->调用reject->结束->执行了reject->catch执行->finally执行

解读Promise结构

经过了上面的代码我们可以分析一下Promise的运行流程和结构,首先从运行流程上我们发现了new Promise中的回调函数确实是在同步任务中执行的,其次是如果这个回调函数内部没有执行resolve或者reject那么p对象的后面的回调函数内部都不会有输出,而运行resolve函数之后.then和.finally就会执行,运行了reject之后.catch和.finally就会执行。

剖析对象结构

Pomise对象默认是一个未知状态的对象,他的定义就是声明一个未来的结果,在结果发生之前他一直是初始状态,在结果发生之后他会变成其中一种目标状态,他和他的名字Promise一样中文翻译为保证,在很多国外电影中一个人对另一个人发誓的时候都会说I promise!!代表我保证。

那么Promise本身具备三种状态:

  • pending:初始状态,也叫就绪状态,这是在他定义初期的状态这个时候Promise仅仅做了初始化并注册了他对象上所有的任务。

  • fulfilled:已完成,通常代表成功执行了某一个任务,当调用初始化函数中的resolve的时候Promise的状态就变更为fulfilled并且.then函数注册的回调函数会开始执行,resolve中传递的参数会进入回调作为形参。

  • rejected:已拒绝,通常代表执行了一次失败任务,或者流程中断,当调用reject函数的时候catch注册的回调函数就会触发执行并且reject中传递的内容会变成回调函数的形参。

三种状态之间的关系:

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

还是栗子:

new Promise(function(resolve,reject){
  resolve()
  reject()
}).then(function(){
  console.log('then执行')
}).catch(function(){
  console.log('catch执行')
}).finally(function(){
  console.log('finally执行')
})

通过分析以上的说明我们知道了Promise对象存在三种状态以及他们之间的关系,那么我们在执行本段程序的时候会发现这个段代码的输出结果是:

then执行->finally执行

new Promise(function(resolve,reject){
  reject()
  resolve()
}).then(function(){
  console.log('then执行')
}).catch(function(){
  console.log('catch执行')
}).finally(function(){
  console.log('finally执行')
})

我们在执行本段程序的时候会发现这个段代码的输出结果是:

catch执行->finally执行

new Promise(function(resolve,reject){
}).then(function(){
  console.log('then执行')
}).catch(function(){
  console.log('catch执行')
}).finally(function(){
  console.log('finally执行')
})

我们在执行本段程序的时候会发现这个段代码的输出结果是:空

总结

针对分析了对象结构和状态之后我们发现了Promise的异步回调部分如何执行取决于我们在初始化函数中的操作,并且初始化函数中一旦调用了resolve后面再执行reject也不会影响then执行,catch也不会执行,反之同理,而在初始化回调函数中如果不执行任何操作那么promise的状态就仍然是pending所有注册的回调函数都不会执行。

关于链式调用

链式调用这个方式最经典的体现是在JQuery框架上到现在仍然很多语言都在使用这种优雅的语法,所以我们来简单认识一下什么是链式调用,为什么Promise对象可以.then().catch()这样调用。为什么还能.then().then()这样调用,他的原理是这样的。

function MyPromise(){
  return this
}
MyPromise.prototype.then = function(){
  console.log('触发了then')
  return this
}
new MyPromise().then().then().then()

其实他的本质就是在我们调用这些支持链式调用的函数的结尾时他又返回了一个包含他自己的对象或者是一个新的自己这些种方式都可以是先链式调用。

Promise使用注意事项

首先查看一下Promise返回的对象内容

var p = new Promise(function(resolve,reject){
  resolve('我是Promise的值')
})
console.log(p)

控制台上会得到如下内容

Promise {<fulfilled>: '我是Promise的值'}
[[Prototype]]: Promise
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: "我是Promise的值"

[[Prototype]]代表Promise的原型对象

[[PromiseState]]代表Promise对象当前的状态

[[PromiseResult]]代表Promise对象的值,分别对应resolve或reject传入的结果

1. 链式调用的注意事项

//通过一个超长的链式调用我们学习一下链式调用的注意事项
var p = new Promise(function(resolve,reject){
  resolve('我是Promise的值')
})
console.log(p)
p.then(function(res){
  console.log(res)
}).then(function(res){
  console.log(res)
  return '123'
}).then(function(res){
  console.log(res)
  return new Promise(function(resolve){
    resolve(456)
  })
}).then(function(res){
  console.log(res)
  return '我是直接返回的结果'
}).then()
  .then('我是字符串')
  .then(function(res){
  console.log(res)
})

控制台上会输出如下结果

Promise {<fulfilled>: '我是Promise的值'}
ttt.html:16 我是Promise的值
ttt.html:18 undefined
ttt.html:21 123
ttt.html:26 456
ttt.html:31 我是直接返回的结果

根据现象我们可以分析出链式调用的基本形式:

  1. 只要有.then()并且触发了resolve链条就会执行到结尾,这个过程中的第一个回调函数的参数是resolve传入的值

  2. 后续每个函数都可以使用return返回一个对象,如果没有返回对象的话下一个.then中的参数就是undefined

  3. 返回的对象如果是普通变量那么这个值就是下一个回调函数中.then的参数

  4. 如果返回的是一个Promise对象,那么这个Promise对象resolve的结果会变成下一次回调的函数的参数

  5. 如果.then中传入的不是函数或者未传值,并不会中断.then的链式调用并且在这之前最后一次的返回结果会直接进入他下一个正确的.then中

2. 中断链式调用

链式调用可以中断吗?答案是肯定的,我们有两种形式可以让.then的链条中断,如果中断还会触发一次.catch的执行。查阅下面的案例学习

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

结果如下

Promise {<fulfilled>: '我是Promise的值'}
ttt.html:16 我是Promise的值
ttt.html:26 我是中断的原因

我们发现中断链式调用之后会触发.catch。并且从中断开始到最后的.then都不会执行,这样链式调用的流程就结束了,中断的方式可以使用抛出一个异常或者返回一个rejected状态的Promise对象。

3. 中断链式调用是否违背了Promise的精神?

我们在介绍Promise的时候强调了他有保证的意思,并且Promise对象一旦状态变更就不会在发生变化,当我们使用链式调用的时候正常都是.then在连续调用,但是当我们触发中断的时候.catch却执行了,按照约定规则.then执行就代表了Promise对象的状态已经定义为fulfilled了.catch执行的时候Promise对象应该是rejected状态啊!

下面举个例子:

var p = new Promise(function(resolve,reject){
  resolve('我是Promise的值')
})
var p1 = p.then(function(res){
​
})
console.log(p)
console.log(p1)
console.log(p1===p)

当我们运行上面的代码的时候控制台会出现如下的打印信息

Promise {<fulfilled>: '我是Promise的值'}
ttt.html:18 Promise {<pending>}
ttt.html:19 false

我们会发现返回的p和p1 的状态本身就不一样并且他们的对比结果是false,这就代表了他们在内存空间上是两个地址,所以.then虽然每次都是用Promise对象实现链式调用的,但是.then每次返回的都是一个新的Promise对象,这样便解释的通了,也就是说每一次.then在执行的时候我们都可以让本次的结果变成不同的状态而且这也不违背Promise的最初约定。

4. 总结

根据以上的分析我们已经掌握了Promise在运行时的规则,这样就能解释的通为什么最初通过Promise实现的setTimeout1秒执行一次的功能可以实现了,这就是因为当我们使用.then进行链式调用的时候可以利用返回一个新的Promise对象来执行下一次.then而下一次.then的执行必须等待resolve调用,这样我们在new Promise的时候放入setTimeout来进行延时,保证1秒之后让状态变更这样就能不编写回调嵌套来实现连续的执行异步流程了。

Promise常用api

Promise.all()

当我们在代码中需要使用异步流程控制的时候可以通过Promise.then来实现让异步流程一个接一个的执行,假设实际案例中我们在某个模块页面需要同时调用3个接口,并且保证三个接口的数据全部返回之后才能渲染页面,这种情况如果a接口耗时1s,b接口耗时0.8s,c接口耗时1.4s,我们如果只用Promise.then可以实现保证三个接口按顺序调用结束再渲染页面,但是如果通过.then的异步控制必须等待每个接口调用完毕才能调用下一个,总耗时就是1+0.8+1.4 = 3.2s这种累加显然增加了接口调用的时间消耗,所以Promise提供了一个all方法来解决这个问题

Promise.all([promise对象,promise对象,...]).then(回调函数)

回调函数的参数是一个数组,会按照第一个参数的promise对象的顺序展示每个promise的返回结果

我们可以借助Promise.all来实现等最慢的接口返回数据后一起得到所有接口的数据,那么这个耗时将会是1.4s节省了1.8s

//promise.all相当于统一处理了
//多个promise任务,保证处理的这些所有promise
//对象的状态全部变成为fulfilled之后才会出发all的
//.then函数来保证将放置在all中的所有任务的结果返回
let p1 = new Promise((resolve,reject) => {
  setTimeout(() => {
    resolve('第一个promise执行完毕')
  },1000)
})
let p2 = new Promise((resolve,reject) => {
  setTimeout(() => {
    resolve('第二个promise执行完毕')
  },2000)
})
let p3 = new Promise((resolve,reject) => {
  setTimeout(() => {
    resolve('第三个promise执行完毕')
  },3000)
})
Promise.all([p1,p3,p2]).then(res => {
  console.log(res)
}).catch(function(err){
  console.log(err)
})

Promise.race()

race方法与all方法使用格式相同

Promise.race([promise对象,promise对象,...]).then(回调函数)

回调函数的参数是前面数组中最快一个执行完毕的promise的返回值

所以使用race方法主要的使用场景是什么样的呢?举个例子,假设我们的网站有一个播放视频的页面,

这个视频有多个数据源,每个用户最好使用对这个用户而言最快的数据源来播放视频,那么我们就可以

在网页中使用race函数来让所有数据源进行比赛,拿到当前延时最短的一个来播放。

下面我们可以参数考代码案例来查看race的介绍

//promise.race()相当于将传入的所有任务
//进行了一个竞争,他们之间最先将状态变成fulfilled的
//那一个任务就会直接的触发race的.then函数并且将他的值
//返回,主要用于多个任务之间竞争时使用
let p1 = new Promise((resolve,reject) => {
  setTimeout(() => {
    resolve('第一个promise执行完毕')
  },5000)
})
let p2 = new Promise((resolve,reject) => {
  setTimeout(() => {
    reject('第二个promise执行完毕')
  },2000)
})
let p3 = new Promise(resolve => {
  setTimeout(() => {
    resolve('第三个promise执行完毕')
  },3000)
})
Promise.race([p1,p3,p2]).then(res => {
  console.log(res)
}).catch(function(err){
  console.error(err)
})

Promise的演进

在介绍了这么多Promise对象之后我们发现了他的能力十分强大并且使用模式非常的自由,并且将JS一个时代的弊病从此“解套”,这个解套虽然比较成功,但是如果直接使用.then()进行链式调用的话我们的代码依然是非常沉重的,这样如果想要开发一个非常复杂的异步流程依然会变得非常的难受。那么有没有办法让Promise对象能更进一步的接近同步代码呢?

Generator函数的介绍

在JS中存在这样一种函数,我们先看一下这个函数的样子

function * fnName(){
  yield ***
  yield ***
}

ES6 新引入了 Generator 函数,可以通过 yield 关键字,把函数的执行流挂起,为改变执行流程提供了可能,从而为异步编程提供解决方案。 所以他的存在提供了让函数可以进行分不执行的能力。

举个栗子:

//该函数和普通函数不同,在执行的时候函数并不会运行并且会返回一个分步执行对象
//该对象存在next方法用来让程序继续执行,当程序遇到yield关键字的时候会停顿
//next返回的对象中包含value和done两个属性,value代表上一个yield返回的结果
//done代表程序是否执行完毕
function * test(){
  var a = yield 1
  console.log(a)
  var b = yield 2
  console.log(b)
  var c = a+b
  console.log(c)
}
//获取分步执行对象
var step = test()
//输出
console.log(step)
​
var step1 = step.next()
//步骤1 该程序执行到第一个yield,step1的value是yield右侧的结果1
console.log(step1)
​
var step2 = step.next()
//步骤2 该程序执行到第二个yield,step2的value是yield右侧的结果2
console.log(step2)
​
var step3 = step.next()
//由于没有yield了程序执行完毕
console.log(step3)

我们查看程序的注释并且运行该程序看控制台的结果

test {<suspended>}[[GeneratorLocation]]: ttt.html:10[[Prototype]]: Generator[[GeneratorState]]: "closed"[[GeneratorFunction]]: ƒ * test()[[GeneratorReceiver]]: Window
ttt.html:21 {value: 1, done: false}
ttt.html:12 undefined
ttt.html:23 {value: 2, done: false}
ttt.html:14 undefined
ttt.html:16 NaN
ttt.html:25 {value: undefined, done: true}

查看结果我们发现a和b的值不见了,c也是NaN虽然程序中断了但是流程也不对了。

这是因为在中断过程中我们是可以在程序中对运行的结果进行人为干预的,也就是说yield返回的结果和他左侧变量的值都是我们可以干预的。

接下来我们改造代码如下:

function * test(){
  var a = yield 1
  console.log(a)
  var b = yield 2
  console.log(b)
  var c = a+b
  console.log(c)
}
var step = test()
console.log(step)
var step1 = step.next()
console.log(step1)
var step2 = step.next(step1.value)
console.log(step2)
var step3 = step.next(step2.value)
console.log(step3)

当我们将代码改造成上面的结构之后我们发现控制台中的数据就正确了

test {<suspended>}
ttt.html:21 {value: 1, done: false}
ttt.html:12 1
ttt.html:23 {value: 2, done: false}
ttt.html:14 2
ttt.html:16 3
ttt.html:25 {value: undefined, done: true}

也就是说next函数执行的过程中我们是需要传递参数的,当下一次的next执行的时候我们如果不传递参数,那么上一个yield左侧额变量的值就变成了undefined,所以我们如果想让yield左侧的变量有值就必须在next中传入指定的结果。

Generator能控制什么样的流程?

首先查看下列代码

function * test(){
  var a = yield 1
  console.log(a)
  var res = yield setTimeout(function(){
    return 123
  },1000)
  console.log(res)
  var res1 = yield new Promise(function(resolve){
    setTimeout(function(){
      resolve(456)
    },1000)
  })
  console.log(res1)
}
var step = test()
console.log(step)
var step1 = step.next()
console.log(step1)
var step2 = step.next()
console.log(step2)
var step3 = step.next()
console.log(step3)
var step4 = step.next()
console.log(step4)

然后查看他的输出结果

test {<suspended>}
ttt.html:27 {value: 1, done: false}
ttt.html:12 undefined
ttt.html:29 {value: 1, done: false}
ttt.html:16 undefined
ttt.html:31 {value: Promise, done: false}
ttt.html:22 undefined
ttt.html:33 {value: undefined, done: true}

根据调用情况我们可以自行测试,会发现输出结果时并没有任何的延迟,并且我们观察打印输出会发现普通变量可以直接在value中拿到,setTimeout我们拿到的值和回调函数内部的值完全不一样,而Promise对象我们可以拿到。接下来我们展开查看Promise对象

{value: Promise, done: false}
done: false
value: Promise
[[Prototype]]: Promise
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: 456
[[Prototype]]: Object

我们发现Promise对象中是可以获取到内部的结果的,那么我们在Generator函数中能确保的就是在中断过车功能中使用Promise和普通对象都能拿到运行流程的结果,但是JS中的setTimeout我们还是无法直接控制它的流程。

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

通过上面的观察我们可以通过递归调用的方式来动态的去执行一个Generator函数,以done属性作为是否结束的依据,通过next来推动函数执行,如果过程中遇到了Promise对象我们就等待Promise对象执行完毕再进入下一步,我们这里只考虑resolve的情况封装一个动态执行的函数如下

/**
* fn:Generator函数对象
*/
function runGeneratorFunction(fn){
  //定义分步对象
  let step = fn()
  //执行到第一个yield
  let step1 = step.next()
  //定义递归函数
  function loop(stepArg){
    //获取本次的yield右侧的结果
    let value = stepArg.value
    //判断结果是不是Promise对象
    if(value instanceof Promise){
      //如果是Promise对象就在then函数的回调中获取本次程序结果
      //并且等待回调执行的时候进入下一次递归
      value.then(function(res){
        if(stepArg.done == false){
          loop(step.next(res))
        }
      })
    }else{
      //判断程序没有执行完就将本次的结果传入下一步进入下一次递归
      if(stepArg.done == false){
        loop(step.next(stepArg.value))
      }
    }
  }
  //执行动态调用
  loop(step1)
}

有了这个函数之后我们就可以将最初的三个setTimeout转换成如下结构进行开发

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)
}
runGeneratorFunction(test)

当我们通过上面的运行工具函数之后我们就可以在控制台看见每间隔1秒钟就输出一次

第一秒运行
ttt.html:22 第二秒运行
ttt.html:28 第三秒运行

经过这个yield修饰符之后我们惊喜的发现,抛去runGeneratorFunction函数以外我们在Generator函数中已经可以将Promise的.then回调成功的规避了yield修饰的Promise对象在与行的到当前行的时候程序就会进入挂起状态直到Promise对象变成完成状态,程序才会像下一行执行。这样我们就通过Generator函数对象成功的将Promise对象同步化了。这也是JS异步编程的一个过渡期,通过这个解决方案,只需要提前准备好工具函数那么编写异步流程可以很轻松的使用yield关键字实现同步化。

关于Async和Await

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

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

查看代码结构之后我们发现他的编写方式与Generator函数结构很相似,提案中规定了我们可以使用async修饰一个函数,这样就能在该函数的直接子作用域中使用await来自动的控制函数的流程,await 右侧可以编写任何变量或函数,当右侧是普通对象的时候函数会自动以同步方式向下执行,而当await右侧为Promise对象的时候如果Promise对象状态没有变成完成函数就会挂起等待,知道Promise对象变成fulfilled,程序再向下执行,并且Promise的值会自动返回给await左侧的变量中。async和await需要成对出现,async可以单独修饰函数,但是await只能在被async修饰的函数中使用

有了await和async就相当于使用了自带执行函数的Generator函数,这样我们就不再需要单独针对Generator函数进行单独开发了,所以async和await逐渐成为主流异步流程控制的终极解决方案。而Generator慢慢淡出了业务开发者的舞台,但是Generator函数称为了向下兼容过渡期版本浏览器的候补实现方式,比如在babel构建的JS中我们还是能大量的发现Generator的应用的。

认识async函数

创建如下函数:

async function  test(){
  return 1
}
let res = test()
console.log(res)

输出控制台如下

Promise {<fulfilled>: 1}
[[Prototype]]: Promise
[[PromiseState]]: "fulfilled"
[[PromiseResult]]: 1

根据控制台结果我们发现其实await修饰的函数本身就是一个Promise对象,虽然我们在函数中return的值是1

但是使用了await修饰之后这个函数运行之后并没有直接返回1而是返回了一个值为1的Promise对象。

接下来我们测试如下流程:

async function  test(){
  console.log(3)
  return 1
}
console.log(1)
test()
console.log(2)

执行该流程之后发现输出的结果是1,3,2。很惊喜是不是,按照Promise对象的执行流程function被async修饰之后它本身应该变成异步函数那么他应该在1和2输出完毕之后在输出3,但是结果却出人意料,这打破了单线程异步模型的概念。

冷静下来回想一下Promise对象的结构

new Promise(function(){
​
}).then(function(){
​
})

我们在介绍Promise对象的时候特别介绍了一下回调函数并且强调他是一个少数的使用同步回调流程并且同时也使用了异步的回调流程的对象,所以在new Promise时的function是同步流程。现在介绍这个和刚才的输出有关系吗?当然有,接下来查看下面的逻辑:

async function  test(){
  console.log(3)
  var a = await 4
  console.log(a)
  return 1
}
console.log(1)
test()
console.log(2)

我们发现奇怪的事情又发生了,控制台输出的顺序是1,3,2,4

按照我们一开始以为的流程test函数应该是同步逻辑,那么3和4应该是连着输出的他不应该会出现3在2之前4在2之后输出的情况,这个同步逻辑和异步逻辑都说不过去,那么我们将当前的函数翻译一下。

console.log(1)
new Promise(function(resolve){
  console.log(3)
  resolve(4)
}).then(function(a){
  console.log(a)
})
console.log(2)

看到这个Promise对象我们就豁然开朗,由于初始化的回调是同步的所以1,3,2都是同步代码而4是在resolve中传入的then代表异步回调所以4应该最后输出。

综上所述,async函数中有一个最大的特点就是第一个await会作为分水岭一般的存在,在第一个await的右侧和上面全部是同步代码区域相当于new Promise的回调,第一个await的左侧和下面就变成了异步代码区域相当于then的回调。所以就出现了上面我们发现的灵异问题。

最终的setTimeout解决代码

经过了两个时代的变革现在我们可以使用如下的方式来进行流程控制了,而且也不需要依赖自己定义的流程控制器函数来进行分步执行,这一切的核心起源都是Promise对象的规则定义开始的,所以最终我们的解决方案如下。

async function test(){
  var res1 = await new Promise(function(resolve){
    setTimeout(function(){
      resolve('第一秒运行')
    },1000)
  })
  console.log(res1)
  var res2 = await new Promise(function(resolve){
    setTimeout(function(){
      resolve('第二秒运行')
    },1000)
  })
  console.log(res2)
  var res3 = await new Promise(function(resolve){
    setTimeout(function(){
      resolve('第三秒运行')
    },1000)
  })
  console.log(res3)
}
test()

总结

从回调地狱到Promise的链式调用到Generator函数的分步执行再到async和await的自动异步代码同步化机制,经历了很多个年头,所以面试中为什么经常问到Promise并且重点沿着Promise对象深入的挖掘去问你各种问题,主要是考察程序员对Promise对象本身以及他的发展历程是否有深入的了解,这是JS异步编程处理的一个灵魂。

这篇关于万字长文带你了解Promise的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!