Promise从入门到入门
前言
在了解promise前先来了解一下javascript的几个核心概念。
执行环境单线程
js的执行环境属于单线程,HTML5提出Web Worker标准,允许创建多个线程,但是子线程完全受主线程控制,且不得操作DOM,只是用来操作逻辑复杂和执行时间较长的代码。所以,这个新标准并没有改变单线程的本质。
- 回调队列 与 回调函数
单线程的缺点,在执行时间长的代码块时,页面失去响应,造成页面假死,用户体验极差。
为了改进这种体验,js将执行的任务划分为两种:
一种是同步任务(synchronous)
一种是异步任务(asynchronous)
异步任务?异步任务有哪几种呢?
- DOM事件任务(onclick)
- 定时器事件任务(setTimeout/setInteval);
- XMLHttpRequest事件任务(ajax);
异步任务有什么特点呢??
- 异步任务都是通过异步回调函数完成;
- 异步任务不进入主线程而进入一个叫回调队列的东西;
- 异步任务等主线程空闲时才去执行回调队列中的异步回调函数;
简单的看图来解释一下上面这些的相互关系:
- event loop(事件循环):
js会创建一个类似于while(ture)的循环,每次循环就是查看是否有待处理的事件,有则取出相关事件及回调函数放入到回调队列中由主线程执行,待处理的事件会存储在一个回调队列中。
- callback quene(回调队列):
异步任务会将相关回调函数添加到回调队列中。而不同的异步任务添加到回调队列的时机也不同,这些异步任务是由浏览器内核的 webcore 来执行的,webcore 包含3种 webAPI,分别是 DOM Binding、network、timer模块。
- DOM事件(onclick)由DOM Binding 模块来处理,当事件触发的时候,回调函数会立即添加到回调队列中;
- 定时器事件(setTimeout) 由timer 模块来进行延时处理,当时间到达的时候,才会将回调函数添加到回调队列中;
- XHR(ajax)由network 模块来处理,在网络请求完成返回之后,才将回调函数添加到回调队列中;
- 前言小结
js中一切的异步任务都是要用 回调函数 实现的。
讲了这么多的概念我们来看一个经典的案例,通过这个案例来感受一下js的进步。
题目:三秒后红灯亮一次,再过一秒后绿灯亮一次,再过2后黄灯亮一次;如何让三个灯不断交替亮?
假设在不知道promise的情况下,我们可以这样实现。
|
|
虽然效果实现了,但是代码不是很优雅,下面来一步一步优化它
1、初探Promise的概念
- promise单词的含义:
承诺在将来一定发生的事情。
一个开放、健全且通用的 JavaScript Promise 标准。由开发者制定,供开发者参考。
你也可以自己实现一个promise,但是规范规定它必须是一个拥有 then 方法的对象或函数,其行为符合以下规范;
- 规范摘要:
Promise对象有三种状态:
- 等待态(Pending)
- 执行态(Fulfilled)
- 拒绝态(Rejected)
promise状态可转变,一旦转变状态不再变化,状态转变只有两种:
- “Pending”状态转到“Fulfilled”
- “Pending”状态转到“rejected”
- then方法:
一个promise对象必须提供一个then方法,状态发生改变后调用该方法。
|
|
上面理论有点抽象,来个图看看:
针对以上promise的规范,jQuery等流行的js库参考规范都已经实现了这个对象,es6也实现了这个对象,下面来具体来看看怎么实现的?
2、es6中的promise
ES6正式提出把Promise作为原生对象,原理实现以promise规范为主。
ES6 Promise 先拉出来遛遛 ,新版浏览器中打印一下,es6中利用构造函数实现了promise:
- 基本用法(new , then , resolve , reject)
ES6规定,Promise对象是一个构造函数,用来生成Promise实例。
下面代码创造了一个Promise实例。
|
|
getNumber函数用来异步获取一个数字,2秒后执行完成,如果数字小于等于5,我们认为是“成功”了,调用resolve修改Promise的状态。否则我们认为是“失败”了,调用reject并传递一个参数,作为失败的原因。
then方法可以接受两个参数,第一个对应resolve的回调,第二个对应reject的回调,如果只有一个则是resolve的回调。
运行结果:resolved 2 或者 rejected 数字太大了
- catch用法
它和then的第二个参数一样,用来指定reject的回调,用法是这样:123456789getNumber().then(function(data){console.log('resolved');console.log(data);}).catch(function(reason){console.log('rejected');console.log(reason);});
不过它还有另外一个作用:在执行resolve的回调(也就是上面then中的第一个参数)时,如果抛出异常了(代码出错了),那么并不会报错卡死js,而是会进到这个catch方法中。请看下面的代码:
|
|
- all用法
Promise的all方法提供了并行执行异步操作的能力,并且在所有异步操作执行完后才执行回调。
|
|
all接收一个promise对象的数组参数。三个异步操作是并行执行的,等到它们都执行完后才会进到then里面。那么,三个异步操作返回的数据哪里去了呢?都在then里面呢,all会把所有异步操作的结果放进一个数组中传给then,就是上面的results。
- race的用法
all方法实际上是「谁跑的慢,以谁为准执行回调」
race方法「谁跑的快,以谁为准执行回调」
|
|
- 多级链式调用
then方法返回一个promise对象,另外,在 then 的函数当中的返回值,可以作为后续操作的参数,因此上面的例子也可以写成,可以多级链式调用,按照回调的先后顺序依次执行
- 小结
使用es6中的promise来改写前言中提出的亮灯的问题
|
|
很明显,效果实现是一样的,但是在代码层次方面,优雅了不少,多级回调被串联的方式取代,越复杂的回调越被改进的优雅。
3、es6中 Generator 函数的异步应用参考
上一节中es6中实现的promise对象其实有很多其他的库都实现了类似的功能,与其他库(O.js,bluebird.js等)相比优势并不明显。
Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆then,原来的语义变得很不清楚。
那么,有没有更好的写法呢?
答案肯定是有的,es6中的Generator 函数将 JavaScript 异步编程带入了一个全新的阶段。
- 协程
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做”协程”(coroutine),意思是多个线程互相协作,完成异步任务。
协程有点像函数,又有点像线程。它的运行流程大致如下。
- 第一步,协程A开始执行。
- 第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
- 第三步,(一段时间后)协程B交还执行权。
- 第四步,协程A恢复执行。
上面流程的协程A,就是异步任务,因为它分成两段(或多段)执行。
|
|
上面代码的函数asyncJob是一个协程,它的奥妙就在其中的yield命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield命令是异步两个阶段的分界线。
协程遇到yield命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield命令,简直一模一样。
- 协程的 Generator 函数实现与应用
Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。
整个 Generator 函数就是一个封装的异步任务,或者说是异步任务的容器。异步操作需要暂停的地方,都用yield语句注明。Generator 函数的执行方法如下。
|
|
上面代码中,调用 Generator 函数,会返回一个内部指针(即遍历器)g。这是 Generator 函数不同于普通函数的另一个地方,即执行它不会返回结果,返回的是指针对象。调用指针g的next方法,会移动内部指针(即执行异步任务的第一段),指向第一个遇到的yield语句。
换言之,next方法的作用是分阶段执行Generator函数。每次调用next方法,会返回一个对象,表示当前阶段的信息(value属性和done属性)。value属性是yield语句后面表达式的值,表示当前阶段的值;done属性是一个布尔值,表示 Generator 函数是否执行完毕,即是否还有下一个阶段。
- 小结
上述方法对promise的操作进行了简化,但是其异步操作完全是基于promise实现的异步操作。但是还是感觉有点复杂,不要怕,下面来还有更骚的操作。
4、ES2017 中的async骚操作
- async 函数是什么?
它其实就是 Generator 函数的语法糖。目前promise的最优秀的解决方案。koa2已经全面使用该语法。
用async改写红黄绿灯的案例
|
|
一比较就会发现,async函数就是将 Generator 函数的星号(*)替换成async,将yield替换成await,仅此而已。
发现没有Generator中异步操作执行的时机需要调用g.next(),需要自主控制时机执行,
但是async就不一样了,在调用async函数时,内部的异步操作会按照预先设计好的顺序执行,不用再麻烦的调用g.next()的方法了。
- async函数对 Generator 函数的改进点
1.内置执行器,也就是上面提到的Generator 函数的执行必须靠执行器,而async函数自带执行器。也就是说,async函数的执行,与普通函数一模一样,只要一行。
|
|
- 更好的语义,async(异步)和await(等待),比起星号和yield,语义更清楚了。
- 更广的适用性,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
- 返回值是 Promise,async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。
进一步说,async函数完全可以看作多个异步操作,包装成的一个 Promise 对象,而await命令就是内部then命令的语法糖。
- 基本用法
底层使用到的异步封装也是基于promise实现的。具体的使用方法
- 错误处理机制
async函数的语法规则总体上比较简单,难点是错误处理机制。
async函数内部抛出错误,会导致返回的 Promise 对象变为reject状态。抛出的错误对象会被catch方法回调函数接收到。
只要一个await语句后面的 Promise 变为reject,那么整个async函数都会中断执行。
上面代码中,第二个await语句是不会执行的,因为第一个await语句状态变成了reject。
有时,我们希望即使前一个异步操作失败,也不要中断后面的异步操作。这时可以将第一个await放在try…catch结构里面,这样不管这个异步操作是否成功,第二个await都会执行。
|
|
所以防止出错的方法,也是将其放在try…catch代码块之中。如果有多个await命令,可以统一放在try…catch结构中。
|
|
- 使用async的注意点
- 错误处理,用try…catch捕获
多个await命令后面的异步操作,如果不存在继发关系,最好让它们同时触发。其实就是并发,减少等待时间。
12345678910111213// 这样getBar的执行必须要等到getFoo执行完成后才去执行let foo = await getFoo();let bar = await getBar();// 其实这两函数并没有依赖关系,等着只能浪费时间,可以采用如下的写法// 写法一let [foo, bar] = await Promise.all([getFoo(), getBar()]);// 写法二let fooPromise = getFoo();let barPromise = getBar();let foo = await fooPromise;let bar = await barPromise;3.await和async必须成对出现,await不能用在forEach循环中,偶尔会出错,要使用最好使用for循环代替,forEach的坑我真的踩过
12345678910111213141516171819// badfunction dbFuc(db) { //这里不需要 asynclet docs = [{}, {}, {}];// 可能得到错误结果docs.forEach(async function (doc) {await db.post(doc);});}// bestasync function dbFuc(db) {let docs = [{}, {}, {}];for (let doc of docs) {await db.post(doc);}}
5、最后
通过以上几点,我们大概了解了关于promise的整个发展过程,语法再不断的升级,使我们写的代码越来越优雅,估计在不久的将来还会有更厉害更简洁的语法出现,但是这些语法的基础都是基于promise实现的异步处理实现的,所以建议大家可以多多熟悉promise的规范,接下来会尝试写一关于promise具体实现的文章,帮助自己及大家更深入的了解promise。文章中很多内容大多都是引用其他优秀的文章,其中也加了一些自己的理解,希望大家能多多提意见。
参考链接:
1.Promises/A+规范;
2.es6标准教程-promise-阮一峰;
3.es6标准教程-Generator 函数的异步应用-阮一峰;
4.es6标准教程-async 函数-阮一峰;