众所周知,promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。那么在开始说promise之前,我们先来聊聊异步编程。
所谓单线程,是指在JS引擎中负责解释和执行JavaScript代码的线程只有一个。不妨叫它主线程。 但是实际上还存在其他的线程。例如:处理AJAX请求的线程、处理DOM事件的线程、定时器线程、读写文件的线程(例如在Node.js中)等等。这些线程可能存在于JS引擎之内,也可能存在于JS引擎之外,在此我们不做区分。不妨叫它们工作线程。 单线程在程序执行时,所走的程序路径按照连续顺序排下来,前面的必须处理好,后面的才会执行,而使用异步实现时,多个任务可以并发执行。
下面我们通过一个例子让大家更好地理解关于同步与异步。 假设存在一个函数A:
A(args...);同步:如果在函数A返回的时候,调用者就能够得到预期结果(即拿到了预期的返回值或者看到了预期的效果),那么这个函数就是同步的。 如:
Math.sqrt(2); //第一个函数返回时,就拿到了预期的返回值:2的平方根。 console.log('Hi');异步:如果在函数A返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。
例如:
fs.readFile('foo.txt', 'utf8', function(err, data) { console.log(data); });在上面的代码中,我们希望通过fs.readFile函数读取文件foo.txt中的内容,并打印出来。 但是在fs.readFile函数返回时,我们期望的结果并不会发生,而是要等到文件全部读取完成之后。如果文件很大的话可能要很长时间。
正是由于JavaScript是单线程的,而异步容易实现非阻塞,所以在JavaScript中对于耗时的操作或者时间不确定的操作,使用异步就成了必然的选择。
首先我们来分析一下异步过程。
主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到(异步函数返回);主线程可以继续执行后面的代码,同时工作线程执行异步任务;工作线程完成工作后,通知主线程;主线程收到通知后,执行一定的动作(调用回调函数)。异步函数通常具有以下的形式:
A(args..., callbackFn)它可以叫做异步过程的发起函数,或者叫做异步任务注册函数。args是这个函数需要的参数。callbackFn也是这个函数的参数,但是它比较特殊所以单独列出来。 所以,从主线程的角度看,一个异步过程包括下面两个要素:
发起函数(或叫注册函数)A 回调函数callbackFn它们都是在主线程上调用的,其中注册函数用来发起异步过程,回调函数用来处理结果。
先介绍一下js执行环境中存在的2个结构:
消息队列(message queue),也叫任务队列(task queue):存储待处理消息及对应的回调函数或事件处理程序; 执行栈(execution context stack),也可以叫执行上下文栈:JavaScript执行栈,顾名思义,是由执行上下文组成,当函数调用时,创建并插入一个执行上下文,通常称为执行栈帧(frame),存储着函数参数和局部变量,当该函数执行结束时,弹出该执行栈帧;接着先说一下关于事件循环的定义
js引擎负责解析,执行js代码,但它并不能单独运行,通常都得有一个宿主环境,一般如浏览器或Node服务器,前面说到的单线程是指在这些宿主环境创建单一线程,提供一种机制,调用js引擎完成多个js代码块的调度,执行,这种机制就称为事件循环(Event Loop)。
听起来有点绕,那我们换个角度来解释一下。
上面说到,异步过程中,工作线程在异步操作完成后需要通知主线程。那么这个通知机制是怎样实现的呢?答案是利用消息队列和事件循环。
工作线程将消息放到消息队列,主线程通过事件循环过程去取消息。那么事件循环机制就是主线程从消息队列里面取消息、执行消息,再取消息、再执行的一个过程。取一个消息并执行的过程叫做一次事件循环。
事件循环用代码表示大概是这样的:
while(true) { var message = queue.get(); execute(message); }解释了事件循环,接着我们来说一下事件循环的流程,大致分解如下:
宿主环境为js创建线程时,会创建堆(heap)和栈(stack),堆内存储js对象,栈内存储执行上下文;栈内执行上下文的同步任务按序执行,执行完即退栈,而当异步任务执行时,该异步任务进入等待状态(不入栈),同时通知线程:当触发该事件时(或该异步操作响应返回时),需向消息队列插入一个事件消息;当事件触发或响应返回时,线程向消息队列插入该事件消息(包含事件及回调);当栈内同步任务执行完毕后,线程从消息队列取出一个事件消息,其对应异步任务(函数)入栈,执行回调函数,如果未绑定回调,这个消息会被丢弃,执行完任务后退栈;当线程空闲(即执行栈清空)时继续拉取消息队列下一轮消息(next tick,事件循环流转一次称为一次tick)。用代码描述如下:
var eventLoop = []; var event; var i = eventLoop.length - 1; // 后进先出 while(eventLoop[i]) { event = eventLoop[i--]; if (event) { // 事件回调存在 event(); } // 否则事件消息被丢弃 }我们再用图来表示一下这个过程,更容易理解:
由此,我们可以得出一个结论:
异步过程的回调函数,一定不在当前这一轮事件循环中执行。上文中说的“事件循环”,为什么里面有个事件呢?那是因为:
消息队列中的每条消息实际上都对应着一个事件。举例来说:
var button = document.getElement('#btn'); button.addEventListener('click', function(e) { console.log(); });从事件的角度来看,上述代码表示:在按钮上添加了一个鼠标单击事件的事件监听器;当用户点击按钮时,鼠标单击事件触发,事件监听器函数被调用。
从异步过程的角度看,addEventListener函数就是异步过程的发起函数,事件监听器函数就是异步过程的回调函数。事件触发时,表示异步任务完成,会将事件监听器函数封装成一条消息放到消息队列中,等待主线程执行。
事件的概念实际上并不是必须的,事件机制实际上就是异步过程的通知机制。
另一方面,所有的异步过程也都可以用事件来描述。我们来看看,setTimeout(fn,num),该方法的含义是等待num秒后,往消息队列插入一条定时器事件消息,并将其第一个参数作为回调函数;而当执行栈内同步任务执行完毕时,线程从消息队列读取消息,将该异步任务入栈,执行;线程空闲时再次从消息队列读取消息。 我们来看一个例子:
var start = +new Date(); var arr = []; setTimeout(function(){ console.log('time: ' + (new Date().getTime() - start)); },10); for(var i=0;i<=1000000;i++){ arr.push(i); }执行多次输出如下:
在setTimeout异步回调函数里我们输出了异步任务注册到执行的时间,发现并不等于我们指定的时间,而且两次时间间隔也都不同,考虑以下两点:
- 在读取消息队列的消息时,得等同步任务完成,这个是需要耗费时间的; - 消息队列先进先出原则,读取此异步事件消息之前,可能还存在其他消息,执行也需要耗时;所以异步执行时间不精确是必然的。
前面铺垫了这么久,终于引出了今天的主题——promise。 文章一开始就说过Promise 是异步编程的一种解决方案。ES6将它写进了语言标准中。统一了用法,原生提供了Promise对象。 所谓Promise,简单说就是一个容器,里面保存着某个未来才会结束的事件(通常是一个异步操作)的结果。从语法上说,Promise 是一个对象,从它可以获取异步操作的消息。 Promise对象有2个特点:
对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:Pending(进行中)、Resolved(已完成,又称 Fulfilled)和Rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。一旦状态改变,就不会再变,任何时候都可以得到这个结果。 Promise对象的状态改变,只有两种可能:从Pending变为Resolved和从Pending变为Rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果。如果改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。当然,Promise对象还存在着一些不足,首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于Pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
直接上代码吧:
var promise = new Promise(function(resolve, reject) { // ... some code if (/* 异步操作成功 */){ resolve(value); } else { reject(error); } }); promise.then(function(value) { // success }, function(error) { // failure });Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由JavaScript引擎提供,不用自己部署。
resolve函数的作用是,将Promise对象的状态从“未完成”变为“成功”(即从Pending变为Resolved),在异步操作成功时调用,并将异步操作的结果,作为参数传递出去; reject函数的作用是,将Promise对象的状态从“未完成”变为“失败”(即从Pending变为Rejected),在异步操作失败时调用,并将异步操作报出的错误,作为参数传递出去。
Promise实例生成以后,可以用then方法分别指定Resolved状态和Reject状态的回调函数。第一个回调函数是Promise对象的状态变为Resolved时调用,第二个回调函数是Promise对象的状态变为Reject时调用。其中,第二个函数是可选的,不一定要提供。
Promise新建后就会立即执行。 let promise = new Promise(function(resolve, reject) { console.log('Promise'); resolve(); }); promise.then(function() { console.log('Resolved.'); }); console.log('Hi!'); // Promise // Hi! // Resolved我们来解释一下上述代码的结果,输入Promise时因为前面所说的Promise新建后会立即执行。接着输出Hi!而不是Resolved,是因为Resolved存在于then的回调函数中,而前面我们说过的回调函数是要在当前同步任务都执行完(执行栈空)之后才执行,那么很明显了,console.log('Hi!');在执行栈中,所以结果我们也就可以理解了。
下面是一个用Promise对象实现的Ajax操作的例子。
var getJSON = function(url) { var promise = new Promise(function(resolve, reject){ var client = new XMLHttpRequest(); client.open("GET", url); client.onreadystatechange = handler; client.responseType = "json"; client.setRequestHeader("Accept", "application/json"); client.send(); function handler() { if (this.readyState !== 4) { return; } if (this.status === 200) { resolve(this.response); } else { reject(new Error(this.statusText)); } }; }); return promise; }; getJSON("/posts.json").then(function(json) { console.log('Contents: ' + json); }, function(error) { console.error('出错了', error); });上面代码中,getJSON是对XMLHttpRequest对象的封装,用于发出一个针对JSON数据的HTTP请求,并且返回一个Promise对象。需要注意的是,在getJSON内部,resolve函数和reject函数调用时,都带有参数。 前面我们说过resolve函数可以将操作的结果作为参数传出去,除此之外,参数还可能是另一个Promise实例,表示异步操作的结果有可能是一个值,也有可能是另一个异步操作。 例如:
var p1 = new Promise(function (resolve, reject) { // ... }); var p2 = new Promise(function (resolve, reject) { // ... resolve(p1); })上面代码中,p1和p2都是Promise的实例,但是p2的resolve方法将p1作为参数,即一个异步操作的结果是返回另一个异步操作。
注意,这时p1的状态就会传递给p2,也就是说,p1的状态决定了p2的状态。如果p1的状态是Pending,那么p2的回调函数就会等待p1的状态改变;如果p1的状态已经是Resolved或者Rejected,那么p2的回调函数将会立刻执行。
关于Promise构造函数中的参数resolve和then方法,我其实是看了蛮久才感觉出有点意思的,这有两篇文章,希望可以帮助大家理解。
实现简易 ES6 Promise 功能 (一) JS魔法堂:剖析源码理解Promises/A规范
在这里,我将文章中的一段代码附上
function Promise(func){ // 接收一个函数作为参数 **this.state = 'pending'; // 初始化状态** this.doneList = []; // callback 列表 func(this.resolve.bind(this)); // 顺带绑定this对象 } Promise.prototype = { resolve: function(){ //执行回调列表 while(true){ if( this.doneList.length === 0 ){ this.state = 'done'; //回调列表为空,改变状态** break; } this.doneList.shift().apply(this); } }, then: function(callback){ // 也是接收函数 this.doneList.push(callback); // 加入到回调队列 if( this.state === 'done'){ this.state = 'pneding'; this.resolve(); } return this; // 支持链式调用 } }这里的回调队列,其实也可以理解为消息队列,存储着回调函数。
then方法返回的是一个新的Promise实例(注意,不是原来那个Promise实例)。因此可以采用链式写法,即then方法后面再调用另一个then方法。
getJSON("/post/1.json").then(function(post) { return getJSON(post.commentURL); }).then(function funcA(comments) { console.log("Resolved: ", comments); }, function funcB(err){ console.log("Rejected: ", err); });采用链式的then,可以指定一组按照次序调用的回调函数。这时,前一个回调函数,有可能返回的还是一个Promise对象(即有异步操作),这时后一个回调函数,就会等待该Promise对象的状态发生变化,才会被调用。
Promise.prototype.catch()该方法其实是.then(null, rejection)的别名,用于指定发生错误时的回调函数。
有两点要记住的,一个是Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个catch语句捕获。另一个是reject方法的作用,等同于抛出错误。如果Promise状态已经变成Resolved,再抛出错误是无效的(这个在前面阐述Promise对象的特点时其实已经提过)。
getJSON('/post/1.json').then(function(post) { return getJSON(post.commentURL); }).then(function(comments) { // some code }).catch(function(error) { // 处理前面三个Promise产生的错误 });一般来说,不要在then方法里面定义Reject状态的回调函数(即then的第二个参数),总是使用catch方法。
除了两点要记住的之外,还有一个需要注意的地方,那就是,catch方法返回的还是一个 Promise 对象,因此后面还可以接着调用then方法。
Promise.resolve() .catch(function(error) { console.log('oh no', error); }) .then(function() { console.log('carry on'); }); // carry on上述代码中若是在then方法中再抛出错误,catch也捕获不到了。所以说错误是冒泡的,只能捕获到出现之前的而不能捕获到之后的。
catch方法之中,也能抛出错误。这里就不附上代码了。
这两个方法的功能都是将多个Promise实例包装为一个新的Promise实例。
// 生成一个Promise对象的数组 var promises = [2, 3, 5, 7, 11, 13].map(function (id) { return getJSON("/post/" + id + ".json"); }); Promise.all(promises).then(function (posts) { // ... }).catch(function(reason){ // ... }); const p = Promise.race([ fetch('/resource-that-may-take-a-while'), new Promise(function (resolve, reject) { setTimeout(() => reject(new Error('request timeout')), 5000) }) ]); p.then(response => console.log(response)); p.catch(error => console.log(error));两者的差别在于新的Promise实例状态的确定。 对于all方法而言: p的状态由p1,p2,…pn确定。2条规则,对于resolved状态,只有所有的p都resolved了,p才是resolved,而对于rejected状态,只要子p中有一个rejected了,p就rejected了。前者将p1,p2,…pn的返回值组成数组传递给p的回调函数,后者只将第一个reject的实例的返回值传给回调函数。 对于race方法来说: 只要p1,p2,..pn之中有一个实例率先改变状态,p的状态就跟着改变。那个率先改变的 Promise 实例的返回值,就传递给p的回调函数。
Promise.resolve()resolve方法可以将现有对象转换成Promise对象。
Promise.resolve('foo') // 等价于 new Promise(resolve => resolve('foo'))Promise.resolve方法的参数分成四种情况。
参数是一个Promise实例,原封不动地返回该对象。参数是一个thenable对象。thenable对象是指拥有一个名为then的function的object。resolve方法会将其转换为Promise对象,并且立即执行其中的then方法。参数不是具有then方法的对象,或根本就不是对象。则该方法返回一个Promise对象,其状态为Resolved,且resolve方法中的参数会传给生成的新Promise对象的回调函数。不带有任何参数。直接返回一个Resolved状态的Promise对象。与第三种情况不同的是,回调函数也是无参数。第四种情况有一点要注意的是:立即resolve的Promise对象,是在本轮“事件循环”(event loop)的结束时,而不是在下一轮“事件循环”的开始时。
setTimeout(function () { console.log('three'); }, 0); Promise.resolve().then(function () { console.log('two'); }); console.log('one'); // one // two // three上述代码中的setTimeout(fn,num),在前文中已经说过回调函数一定不在当前事件循环中执行。
Promise.reject(reason)返回一个新的 Promise 实例,该实例的状态为rejected,且回调函数立即执行。
特别注意: Promise.reject()方法的参数,会原封不动地作为reject的理由,变成后续方法的参数。这一点与Promise.resolve方法不一致。
const thenable = { then(resolve, reject) { reject('出错了'); } }; Promise.reject(thenable) .catch(e => { console.log(e === thenable) }) // true上面代码中,Promise.reject方法的参数是一个thenable对象,执行以后,后面catch方法的参数不是reject抛出的“出错了”这个字符串,而是thenable对象。
ES6的Promise API提供的方法不是很多,有些有用的方法可以自己部署。下面介绍如何部署两个不在ES6之中、但很有用的方法。
done()Promise对象的回调链,不管以then方法或catch方法结尾,要是最后一个方法抛出错误,都有可能无法捕捉到(因为Promise内部的错误不会冒泡到全局)。因此,我们可以提供一个done方法,总是处于回调链的尾端,保证抛出任何可能出现的错误。
实现代码如下:
Promise.prototype.done = function (onFulfilled, onRejected) { this.then(onFulfilled, onRejected) .catch(function (reason) { // 抛出一个全局错误 setTimeout(() => { throw reason }, 0); }); };从上面代码可见,done方法的使用,可以像then方法那样用,提供Fulfilled和Rejected状态的回调函数,也可以不提供任何参数。但不管怎样,done都会捕捉到任何可能出现的错误,并向全局抛出。
finally()finally方法用于指定不管Promise对象最后状态如何,都会执行的操作。它与done方法的最大区别,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。
Promise.prototype.finally = function (callback) { let P = this.constructor; return this.then( value => P.resolve(callback()).then(() => value), reason => P.resolve(callback()).then(() => { throw reason }) ); };文章参考链接: ECMAScript6 入门之Promise对象 java script 异步编程实现过程解读 JavaScript:彻底理解同步、异步和事件循环(Event Loop)