详解JavaScript异步编程之Promise

一、前言

JavaScript是⼀⻔典型的异步编程脚本语⾔,在编程过程中会⼤量的出现异步代码的编写,在JS的整个发展历程中,对异步编程的处理⽅式经历了很多个时代,其中最典型也是现今使⽤最⼴泛的时代,就是Promise对象处理异步编程的时代。

异步编程是相对同步编程来说的,开发项目时,开发者总是希望,程序的执行顺利能按照编程的顺序从上至下执行,这样符合人的思维易于理解,但是现实开发中,一般都事与愿违,相信每个开发者或多或少遇到过,程序执行到某些复杂的、耗时的任务时,往往要开启异步任务去执行,这样就不会阻塞当前任务,让当前任务继续执行,当异步任务执行完后,再将异步任务执行完的结果传给当前任务使用。所以异步编程主要为提高程序整体的执行效率。

那么,Promise对象到底是什么呢?

二、上古时期的异步编程模式

长期以来,异步操作一直是JavaScript语言的痛点。在该语言的早期版本中,异步操作仅支持定义回调函数以指示异步操作已完成。异步行为的执行是一个常见的问题,通常可以通过一个充满嵌套回调函数的代码片段来解决,该代码片段通常称为“回调地狱”。

在过去的编程中JavaScript的主要异步处理⽅式,是采⽤回调函数的⽅式来进⾏处理。在前端开发过程中使⽤最多的异步流程就是AJAX请求,当系统中要求某个⻚⾯的n个接⼝保证有序调⽤的情况下就会出现下⾯的情况。看如下代码:

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

图片
看如上代码,这三个任务必须按先后顺序执⾏,并且每一个请求执行前都要先拿到上⼀个请求运⾏的结果,那么我们不得不将代码编写为以上案例代码。该写法主要是为了保证代码的严格顺序要求,这样就避免不了⼤量的逻辑在回调函数中不停的进⾏嵌套,这也是我们经常听说的“回调地狱”。这种情况在很多⼈的代码中都出现过,如果流程复杂化,在⽹络请求中继续夹杂其他的异步流程,那么这样的代码就会变得难以维护了。

再举一个我们在开发业务中常见的例子——省市区三级联动。

// 假如接口和参数如下
省级接口:http://localhost:8080/province
市级接口:http://localhost:8080/city(参数:provinceId)
区级接口:http://localhost:8080/area(参数:cityId)

先请求省级接口,获取某个省的编号,在使用省的编号,请求市级接口,获取某个市的编号,最后使用市的编号,请求区县级接口,获取区县的名称。在没有Promise之前,就会出现如下代码:

// 1.请求省级接口,获取省的编号
axios.get('http://localhost:3000/province').then(res => {
  for (let item of res.data) {
    if (item.provinceId === 'xxx') {
      // 2.使用省的编号,请求市级接口, 获取市的编号
      axios.get(`http://localhost:3000/city?provinceId=${item.provinceId}`).then(res => {
        for (let item of res.data) {
          if (item.cityId === 'xxx') {
            // 3.使用长沙市的编号,请求区级接口,获取岳麓区的名称
            axios.get(`http://localhost:3000/area?cityId=${item.cityId}`).then(res => {
              for (let item of res.data) {
                if (item.countyId == 'xxx') {
                  console.log(item.countyName )
                }
              }
            })
          }
        }
      })
    }
  }
})

在这里插入图片描述

其它的例子诸如Node中的原始fs模块,操作⽂件系统等场景就不再一一列举了。由此,之所以在ECMA提案中出现Promise解决⽅案,就是因为此类代码导致了JS在开发过程中遇到的实际问题:回调地狱。当然解决回调地狱还有其他⽅案,本篇文章核⼼介绍Promise流程控制对象,因为它是解决回调地狱⾮常好的⽅案。

三、回调函数

在介绍Promise前,我们先了解一个重要的概念——回调函数!JavaScript语⾔中,有⼀个特殊的函数叫做回调函数。回调函数的特点是把函数作为变量看待,由于JavaScript变量可以作为函数的形参并且函数可以通过声明变量的⽅式匿名创建,所以我们可以在定义函数时将⼀个函数的参数当作函数来执⾏,进⽽在调⽤时在参数的位置编写⼀个执⾏函数。

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

上⾯的代码结构,就是JavaScript中典型的回调函数结构。按照我们在事件循环中介绍的JavaScript函数运⾏机制,会发现其实回调函数本身是同步代码,这是⼀个需要重点理解的知识点。

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

那么为什么异步流程都需要回调函数?

请看如下代码,想一想输出的顺序是什么?

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的值为1,想要1秒之后设置他的值为2,并且我们想要在之后得到a的新结果,这个逻辑中如果1秒之后设置a为2采⽤的是setTimeout,那么我们在同步结构⾥能否实现?

let a = 1;
setTimeout(() => {
  a = 2
}, 1000)
console.log(a)

上述代码块输出的结果⼀定是1,根据JavaScript单线程异步模型的知识,可以得知,当前的代码块中setTimeout的回调函数是⼀个宏任务,会在本次的同步代码执⾏完毕后执⾏,所以声明a=1和输出a的值这两⾏代码会优先执⾏,这时对a设置为2的事件还没有发⽣,所以输出的结果就⼀定为1。

那么,上述的问题怎么才能实现呢?接下来对代码做如下改造,我们试图使⽤阻塞的⽅式来获取异步代码的结果。看如下代码:

let a = 1;
//依然使⽤setTimeout设置1秒的延迟设置a的值
setTimeout(function(){
  a = 2
},1000)
let d = new Date().getTime()
let d1 = new Date().getTime()
//采⽤while循环配合时间差来阻塞同步代码2秒
while(d1-d<2000){
  d1 = new Date().getTime()
}
console.log(a)

上面的同步代码会在while循环中阻塞2秒,所以console.log(a)这⾏代码会在2秒之后才能获得执⾏资源,但是最终输出的结果仍然是1。这是为什么呢?这⾥仍然可以通过JavaScript的运⾏模型来进⾏理解,由于单线程异步模型的规则是严格的同步在前异步靠后顺序,本案例的同步代码虽然阻塞了2秒,已经超过了setTimeout的等待时间,但是setTimeout中的宏任务到时间后,仅仅会被从⼯作线程移动到任务队列中进⾏等待。在时间到达1秒时,while循环没有执⾏结束,所以函数执⾏栈会被继续占⽤,直到循环释放并输出a之后,任务队列中的宏任务才能执⾏,所以这⾥就算setTimeout时间到了,也必须等待同步代码执⾏完毕,那么输出a的时候a=2的事件仍然没有发⽣,所以我们采⽤默认的上下结构永远拿不到异步回调中的结果,这也是为什么异步流程都是回调函数的原因。

据此我们可以知道想要真正的在2秒后获取a的新结果的代码结构是这样的:

//只有在这个回调函数中才能获取到a改造之后的结果
let a = 1;
setTimeout(function() {
  a = 2;
}, 1000)
//注册一个新的宏任务,让它在上一个宏任务后执
setTimeout(function() {
  console.log(a)
}, 2000)

到这⾥也就⼤概明⽩了回调函数的意义以及使⽤场景了。接下来就详细介绍Promise是什么以及使⽤Promise如何解决异步控制问题,而且Promise它是⼀个及特殊的存在,Promise中既包含同步的回调函数,⼜包含异步的回调函数。

四、什么是Promise

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

MDN上是这样解释的:Promise是一个对象,它代表了一个异步操作的最终完成或者失败。本质上Promise是一个函数返回的对象,我们可以在它上面绑定回调函数,这样我们就不需要一开始把回调函数作为参数传入这个函数了。

1、构造 Promise
Promise对象是⼀个JavaScript对象,在⽀持ES6语法的运⾏环境中作为全局对象提供,初始化⽅式如下:

new Promise(function (resolve, reject) {
  // 要做的事情...
});

我们先对Promise做⼀个简单的介绍:Promise对象的主要⽤途是通过链式调⽤的结构,将原本回调嵌套的异步处理流程,转化成“对象.then().then()…”的链式结构,这样虽然仍离不开回调函数,但是将原本的回调嵌套结构,转化成了连续调⽤的结构,这样就可以在阅读上编程上下左右结构的异步执⾏流程了。

看如下代码:

setTimeout(function(){
//第⼀秒后执⾏的逻辑
console.log('第⼀秒之后发⽣的事情')
  setTimeout(function(){
  //第⼆秒后执⾏的逻辑
  console.log('第⼆秒之后发⽣的事情')
    setTimeout(function(){
      //第三秒后执⾏的逻辑
      console.log('第三秒之后发⽣的事情')
    },1000)
  },1000)
},1000)

上⾯的代码,分3秒每间隔1秒运⾏1个任务,这三个任务必须按时间顺序执⾏,并且每个下⼀秒触发前都要先拿到上⼀秒运⾏的结果。现在用 Promise 来实现同样的功能。

//使⽤Promise拆解的setTimeout流程控制
const 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后的代码,将原来的3个setTimeout的回调嵌套,拆解成了三次then包裹的回调函数,按照上下顺序进⾏编写。这样我们从视觉上就可以按照⼈类的从上到下从左到右的线性思维来阅读代码,这样很容易能查看这段代码的执⾏流程,代价是代码的编写量增加了接近1倍。

2、Promise 的构造函数
Pomise对象相当于⼀个未知状态的对象,它的定义就是声明⼀个等待未来结果的对象,在结果发⽣之前他⼀直是初始状态,在结果发⽣之后他会变成其中⼀种⽬标状态,Promise在英⽂中是绝对保证的意思,所以在编程中Promise对象是⼀个⾮常严谨的对象,⼀定会按照约定执⾏,除使⽤不当外,不会出现任务灵异问题。

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

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

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

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

三种状态之间的关系:

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

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

根据上⾯的分析,结合下⾯的代码实例来加深一下对Promise规则的了解,分析该对象的运⾏结果。

//实例化⼀个Promise对象
const 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/.catch/.finally中的回调函数是异步的,这⾥要记好。实际上,在控制台内会发现这段程序并没有任何输出,我们继续往下看。

console.log('起步')
const 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('起步')
const p = new Promise(function(resolve,reject){
  console.log('调⽤reject')
  reject('执⾏了reject')
})
p.then(function(res){
  console.log(res)
  console.log('then执⾏')
}).catch(function(res){
  console.log(res)
  console.log('catch执⾏')
}).finally(function(){
  console.log('finally执⾏')
})
console.log('结束')

上面这段程序运⾏⼀下会发现输出顺序为: 起步->调⽤reject->结束->执⾏了reject->catch执⾏->finally执⾏。

欲知后文如何,且听下回分解!!!

五、总结

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

写在最后:

一行代码,可能会创造出下一个让人惊叹的产品;一个创新,可能会开启一个全新的科技时代;一份初心,可能会影响到无数人的生活;无论是在大公司工作,还是在小团队奋斗;无论是资深的程序员,还是刚刚入行的新手;每个人的代码,都有力量改变世界。

创作不易,喜欢的老铁们加个关注,点个赞,后面会不定期更新干货和技术相关的资讯,速速收藏,谢谢!你们的一个小小举动就是对小编的认可,更是创作的动力。

  • 22
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值