📚序言
众所周知, promise
是前端面试中雷打不动的面试题了,面试官都很爱考。周一之前也是知识比较浮于表面,在一些面经上看到了 promise
的实现方式,就只停留在那个层面上。但实际上我发现,如果没有深入其原理去理解,面试官稍微变个法子来考,这道题很容易就把我给问倒了。所以呀,还是老老实实从头到尾研究一遍,这样等遇到了,不管怎么考,万宗不变其一,把原理理解了,就没有那么容易被问倒了。
下面开始进入本文的讲解~🏷️
📰一、js的同步模式和异步模式
1. 单线程💡
大家都知道, js
的设计是基于单线程进行开发的,它原先的目的在于只参与浏览器中DOM节点的操作。
而对于单线程来说,其意味着只能执行一个任务,且所有的任务都会按照队列的模式进行排队。
所以,单线程的缺点就在于,当 js
运行的时候, html
是不会进行渲染的。因此,如果一个任务特别耗时,那么将会很容易造成页面阻塞的局面。
为了解决这个问题, js
提出了同步模式和异步模式的解决方案。
2. 同步模式💡
(1)定义
所谓同步模式,指的就是函数中的调用堆栈,按照代码实现的顺序,一步步进行。
(2)图例
接下来我们来用一段代码,演示 js
中函数调用堆栈的执行情况。具体代码如下:
const func1 = () => { func2(); console.log(3); } const func2 = () => { func3(); console.log(4); } const func3 = () => { console.log(5); } func1(); //5 4 3 复制代码
看到这里,相信很多小伙伴已经在构思其具体的执行顺序。下面用一张图来展示执行效果:
对于栈这个数据结构来说,它遵循后进先出的原则。因此,当 func1
, func2
, func3
依次放进调用栈后, 遵循后进先出原则 ,那么 func3
函数的内容会先被执行,之后是 func2
,最后是 func1
。
因此,对于 js
的同步模式来说,就是类似于上述的函数调用堆栈。
3. 异步模式💡
(1)举例
当程序遇到网络请求或定时任务等问题时,这个时候会有一个等待时间。
假设一个定时器设置 10s
,如果放在同步任务里,同步任务会阻塞代码执行,我们会等待 10s
后才能看到我们想要的结果。1个定时器的等待时间可能还好,如果这个时候是100个定时器呢?我们总不能等待着 1000s
的时间就为了看到我们想要的结果吧,这几乎不太现实。
那么这个时候就需要异步,通过异步来让程序不阻塞代码执行,灵活执行程序。
(2)定义
对于同步模式来说,它只能自上而下地一行一行执行,一行一行进行解析。那与同步模式不同的是,异步模式是按照我们想要的结果进行输出,不会像同步模式一样产生阻塞,以达到让程序可控的效果。
(3)js如何实现异步
相对于同步模式来说,异步模式的结构更为复杂。除了调用栈之外, 它还有消息队列和事件循环这两个额外的机制。所谓事件循环,也称为 event loop
或事件轮询。因为 js
是单线程的,且异步需要基于回调来实现,所以, event loop
就是异步回调的实现原理。
JS在程序中的执行遵循以下规则:
- 从前到后,一行一行执行;
- 如果某一行执行报错,则停止下面代码的执行;
- 先把同步代码执行完,再执行异步。
一起来看一个实例:
console.log('Hi'); setTimeout(function cb1(){ console.log('cb1'); //cb1 即callback回调函数 }, 5000); console.log('Bye'); //打印顺序: //Hi //Bye //cb1 复制代码
从上例代码中可以看到, JS
是先执行同步代码,所以先打印 Hi
和 Bye
,之后执行异步代码,打印出 cb1
。
以此代码为例,下面开始讲解 event loop
的过程。
(4)event loop过程
对于上面这段代码,执行过程如下图所示:
从上图中可以分析出这段代码的运行轨迹。首先 console.log('Hi')
是同步代码,直接执行并打印出 Hi
。接下来继续执行定时器 setTimeout
,定时器是异步代码,所以这个时候浏览器会将它交给 Web APIs
来处理这件事情,因此先把它放到 Web APIs
中,之后继续执行 console.log('Bye')
, console.log('Bye')
是同步代码,在调用堆栈 Call Stack
中执行,打印出 Bye
。
到这里,调用堆栈 Call Stack
里面的内容全部执行完毕,当调用堆栈的内容为空时,浏览器就会开始去 消息队列 Callback Queue 寻找下一个任务,此时消息队列就会去 Web API
里面寻找任务,遵循先进先出原则,找到了定时器,且定时器里面是回调函数 cb1
,于是把回调函数 cb1
传入任务队列中,此时 Web API
也空了,任务队列里面的任务就会传入到调用堆栈里Call Stack
里执行,最终打印出 cb1
。
4. 回调函数💡
早期我们在解决异步问题的时候,基本上都是使用callback回调函数的形式 来调用的。形式如下:
//获取第一份数据 $.get(url1, (data1) => { console.log(data1); //获取第二份数据 $.get(url2, (data2) => { console.log(data2); //获取第三份数据 $.get(url3, (data3) => { console.log(data3); //还可以获取更多数据 }); }); }); 复制代码
从上述代码中可以看到,早期在调用数据的时候,都是一层套一层, callback
调用 callback
,仿佛深陷调用地狱一样,数据也被调用的非常乱七八糟的。所以,因为 callback
对开发如此不友好,也就有了后来的 promise
产生。
promise
由 CommonJS
社区最早提出,之后在2015年的时候, ES6
将其写进语言标准中,统一了它的用法,原生提供了 Promise
对象。 promise
的出现,告别了回调地狱时代,解决了回调地狱callback hell
的问题。
那下面我们就来看看 Promise
的各种神奇用法~
📃二、Promise异步方案
1. Promise的三种状态📂
(1)Promise的三种状态
状态 | 含义 |
pending | 等待状态,即在过程中,还没有结果。比如正在网络请求,或定时器没有到时间。 |
fulfilled | 满足状态,即事件已经解决了,并且成功了;当我们主动回调了 fulfilled 时,就处于该状态,并且会回调 then 函数。 |
rejected | 拒绝状态,即事件已经被拒绝了,也就是失败了;当我们主动回调了 reject 时,就处于该状态,并且会回调 catch 函数。 |
(2)状态解释
对于 Promise
来说,它是一个对象,用来表示一个异步任务在执行结束之后返回的结果,它有 3 种状态: pending
, fulfilled
, rejected
。其执行流程如下:
如果一个异步任务处于 pending
状态时,那么表示这个 promise
中的异步函数还未执行完毕,此时处于等待状态。相反,如果 promise
中的异步函数执行完毕之后,那么它只会走向两个结果:
fulfilled
,表示成功;rejected
,表示失败。
一旦最终状态从 pending
变化为 fulfilled
或者 rejected
后,状态就再也不可逆。
所以,总结来讲,Promise
对象有以下两个特点:
promise
对象的状态不受外界影响,一旦状态被唤起之后,函数就交由web API
去处理,这个时候在函数主体中再执行任何操作都是没有用的;- 只会出现
pending
→fulfilled
,或者pending
→rejected
状态,即要么成功要么失败。即使再对promise
对象添加回调函数,也只会得到同样的结果,即它的状态都不会再发生被改变。
2. 三种状态的变化和表现📂
(1)状态的变化
promise
主要有以上三种状态, pending
、 fulfilled
和 rejected
。当返回一个 pending
状态的 promise
时,不会触发 then
和 catch
。当返回一个 fulfilled
状态时,会触发 then
回调函数。当返回一个 rejected
状态时,会触发 catch
回调函数。那在这几个状态之间,他们是怎么变化的呢?
1)演示1
先来看一段代码:
const p1 = new Promise((resolved, rejected) => { }); console.log('p1', p1); //pending 复制代码
在以上的这段代码中,控制台打印结果如下:
在这段代码中, p1
函数里面没有内容可以执行,所以一直在等待状态,因此是 pending
。
2)演示2
const p2 = new Promise((resolved, rejected) => { setTimeout(() => { resolved(); }); }); console.log('p2', p2); //pending 一开始打印时 setTimeout(() => console.log('p2-setTimeout', p2)); //fulfilled 复制代码
在以上的这段代码中,控制台打印结果如下:
在这段代码中, p2
一开始打印的是 pending
状态,因为它没有执行到 setTimeout
里面。等到后续执行 setTimeout
时,才会触发到 resolved
函数,触发后返回一个 fulfilled
状态 promise
。
3)演示3
const p3 = new Promise((resolved, rejected) => { setTimeout(() => { rejected(); }); }); console.log('p3', p3); setTimeout(() => console.log('p3-setTimeout', p3)); //rejected 复制代码
在以上的这段代码中,控制台打印结果如下。
在这段代码中, p3
一开始打印的是 pending
状态,因为它没有执行到 setTimeout
里面。等到后续执行 setTimeout
时,同样地,会触发到 rejected
函数,触发后返回一个 rejected
状态的 promise
。
看完 promise
状态的变化后,相信大家对 promise
的三种状态分别在什么时候触发会有一定的了解。那么我们接下来继续看 promise
状态的表现。
(2)状态的表现
pending
状态,不会触发then
和catch
。fulfilled
状态,会触发后续的then
回调函数。rejected
状态,会触发后续的catch
回调函数。
我们来演示一下。
1)演示1
const p1 = Promise.resolve(100); //fulfilled console.log('p1', p1); p1.then(data => { console.log('data', data); }).catch(err => { console.error('err', err); }); 复制代码
在以上的这段代码中,控制台打印结果如下:
在这段代码中, p1
调用 promise
中的 resolved
回调函数,此时执行时, p1
属于 fulfilled
状态, fulfilled
状态下,只会触发 .then
回调函数,不会触发 .catch
,所以最终打印出 data 100
。
2)演示2
const p2 = Promise.reject('404'); //rejected console.log('p2', p2); p2.then(data => { console.log('data2', data); }).catch(err => { console.log('err2', err); }) 复制代码
在以上的这段代码中,控制台打印结果如下:
在这段代码中, p2
调用 promise
中的 reject
回调函数,此时执行时, p1
属于 reject
状态, reject
状态下,只会触发 .catch
回调函数,不会触发 .then
,所以最终打印出 err2 404
。
3. Promise的使用案例📂
对三种状态有了基础了解之后,我们用一个案例来精进对 Promise
的使用。现在,我们想要实现的功能是,通过 fs
模块,异步地调用本地的文件。如果文件存在,那么在控制台上输出文件的内容;如果文件不存在,则将抛出异常。实现代码如下:
const fs = require('fs'); const readFile = (filename) => { // 返回一个 promise 实例,以供 then 调用 const promise = new Promise(function(resolve, reject){ // 使用 readFile 去异步地读取文件,异步调用也是 promise 函数的意义 // 注意:下面这个函数的逻辑是错误优先,也就是先err,再data fs.readFile(filename, (err, data) => { // 如果文件读取失败,就调取 reject ,并抛出异常 if(err){ reject(err); }else{ // 如果成功,就调取 resolve ,并返回调用成功的数据 resolve(data); } }); }); return promise; } // 测试代码 // 文件存在逻辑 const existedFile = readFile('./test.txt'); existedFile.then( (data) => { // Buffer.from()方法用于创建包含指定字符串,数组或缓冲区的新缓冲区。 // Buffer.from(data).toString()读出文件里面的内容。文件里面记得写内容!! console.log('content: ', Buffer.from(data).toString()); }, (error) => { console.log(error); } ) // 文件不存在逻辑 const failFile = readFile('./fail.txt'); failFile.then( (data) => { console.log(Buffer.from(data).toString()); }, (err) => { console.log(err); } ); 复制代码
最终控制台的打印结果如下:
[Error: ENOENT: no such file or directory, open 'C:\\promise\\fail.txt'] { errno: -4058, code: 'ENOENT', syscall: 'open', path: 'C:\\promise\\fail.txt' } content: 这是一个测试文件! 复制代码
大家可以看到,当 ./test.txt
文件存在时,那么 existedFile
会去调用后续的 .then
回调函数,因此最终返回调用成功的结果。注意,这是一个测试文件!
这行字就是 test
文件里面的内容。
同时, ./fail.txt
文件不存在,因此 failFile
会调用后续的 .catch
文件,同时将异常抛出。
现在,大家应该对 promise
的使用有了一定的了解,下面我们继续看 promise
中 then
和 catch
对状态的影响。
4. then和catch对状态的影响📂
then
正常返回fulfilled
,里面有报错则返回rejected
;catch
正常返回fulfilled
,里面有报错则返回rejected
。
我们先来看第一条规则:then
正常返回 fulfilled
,里面有报错则返回 rejected
。
1)演示1
const p1 = Promise.resolve().then(() => { return 100; }) console.log('p1', p1); //fulfilled状态,会触发后续的.then回调 p1.then(() => { console.log('123'); }); 复制代码
在以上的这段代码中,控制台打印结果如下。
在这段代码中, p1
调用 promise
中的 resolve
回调函数,此时执行时, p1
正常返回 fulfilled
, 不报错,所以最终打印出 123
。
2)演示2
const p2 = Promise.resolve().then(() => { throw new Error('then error'); }); console.log('p2', p2); //rejected状态,触发后续.catch回调 p2.then(() => { console.log('456'); }).catch(err => { console.error('err404', err); }); 复制代码
在以上的这段代码中,控制台打印结果如下。
在这段代码中, p2
调用 promise
中的 resolve
回调函数,此时执行时, p2
在执行过程中,抛出了一个 Error
,所以,里面有报错则返回 rejected
状态 , 所以最终打印出 err404 Error: then error
的结果。
我们再来看第二条规则: catch
正常返回 fulfilled
,里面有报错则返回 rejected
。
1)演示1(需特别谨慎! !)
const p3 = Promise.reject('my error').catch(err => { console.error(err); }); console.log('p3', p3); //fulfilled状态,注意!触发后续.then回调 p3.then(() => { console.log(100); }); 复制代码
在以上的这段代码中,控制台打印结果如下。
在这段代码中, p3
调用 promise
中的 rejected
回调函数,此时执行时, p3
在执行过程中,正常返回了一个 Error
,这个点需要特别谨慎!!这看起来似乎有点违背常理,但对于 promise
来说,不管时调用 resolved
还是 rejected
,只要是正常返回而没有抛出异常,都是返回 fulfilled
状态。所以,最终 p3
的状态是 fulfilled
状态,且因为是 fulfilled
状态,之后还可以继续调用 .then
函数。
2)演示2
const p4 = Promise.reject('my error').catch(err => { throw new Error('catch err'); }); console.log('p4', p4); //rejected状态,触发.catch回调函数 p4.then(() => { console.log(200); }).catch(() => { console.log('some err'); }); 复制代码
在以上的这段代码中,控制台打印结果如下。
在这段代码中, p4
依然调用 promise
中的 reject
回调函数,此时执行时, p4
在执行过程中,抛出了一个 Error
,所以,里面有报错则返回 rejected
状态 , 此时 p4
的状态为 rejected
,之后触发后续的 .catch
回调函数。所以最终打印出 some err
的结果。
5. Promise的并行执行📂
(1)Promise.all
Promise.all
方法用于将多个 Promise
实例包装成一个新的 Promise
实例。比如:
var p = Promise.all([p1, p2, p3]); 复制代码
p的状态由 p1
、 p2
、 p3
决定,分成两种情况:
- 只有
p1
、p2
、p3
的状态都变为fulfilled
,最终p
的状态才会变为fulfilled
。此时p1
、p2
、p3
的返回值组成一个数组,并返回给p
回调函数。 - 只要
p1
、p2
、p3
这三个参数中有任何一个被rejected
, 那么p
的状态就会变成rejected
。此时第一个被rejected
的实例的返回值将会返回给p
的回调函数。
下面用一个实例来展示 Promise.all
的使用方式。具体代码如下:
//生成一个Promise对象的数组 var promises = [4, 8, 16, 74, 25].map(function (id) { return getJSON('/post/' + id + ".json"); }); Promise.all(promises).then(fucntion (posts) { // ... }).catch(function (reason) { // ... }} 复制代码
大家可以看到,对于以上代码来说, promises
是包含5个Promise实例的数组,只有这5个实例的状态都变成 fulfilled ,或者其中有一个变为 rejected ,那么才会调用 Promise.all
方法后面的回调函数。
这里有一种值得注意的特殊情况是,如果作为参数的 Promise
实例自身定义了 catch
方法,那么它被 rejected
时并不会触发 Promise.all()
的 catch
方法。这样说可能比较抽象,我们用一个实例来展示一下,具体代码如下:
const p1 = new Promise((resolve, reject) => { resolve('hello'); }).then(result => { return result; }).catch(e => { return e; }); const p2 = new Promise((resolve, reject) => { throw new Error('报错了'); }).then(result => { return result; }).catch(e => { return e; }); Promise.all([p1, p2]).then(result => { console.log(result); }).catch(e => { console.log(e); }) 复制代码
在上面的代码中, p1
会 resolve
,之后调用后续的 .then
回调函数。而 p2
会 reject
,因此之后会调用后续的 .catch
回调函数。注意,这里的 p2
有自己的 catch
方法,且该方法返回的时一个新的 Promise
实例,而 p2
实际上指向的就是这个实例。
所以呢,这个实例执行完 catch
方法后也会变成 resolved
。因此, 在 Promise.all()
这个方法中,其参数里面的两个实例就都会 resolved
,所以之后会调用 then
方法指定的回调函数,而不会调用 catch
方法指定的回调函数。
(2)Promise.race
Promise.race
方法同样是将多个 Promise
实例包装成一个新的 Promise
实例。比如:
var p = Promise.race([p1, p2, p3]); 复制代码
我们同样用以上这段代码来进行分析。与 Promise.all()
不同的是,只要 p1
、 p2
、 p3
中有一个实例率先改变状态,那么** p
的状态就会跟着改变**,且那个率先改变的 Promise 实例的返回值就会传递给 p
的回调函数。
所以呀,为什么它叫 race
? race
,顾名思义就是竞赛的意思。在赛场上,第一名永远只有一个。而我们可以把第一名视为第一个 resolve
状态的 promise
,只要第一名出现了,那么结果就是第一名赢了,所以返回的值就是第一个为 resolve
的值。其他人再怎么赛跑都逃不过拿不到第一的现实。
6. 两个有用的附加方法📂
ES6
中 Promise API
并没有提供很多方法,但是我们可以自己来部署一些有用的方法。接下来,我们将来部署两个不在 ES6
中但是却很有用的方法。
(1)done()
无论 Promise
对象的回调链以 then
方法还是 catch
方法结尾,只要最后一个方法抛出错误,那么都有可能出现无法捕捉到的情况。这是为什么呢?原因在于 promise
内部的错误并不会冒泡到全局。因此,我们提供了一个 done
方法。done
方法总是处于回调链的尾端,保证抛出任何可能出现的错误。我们来看下它的使用方式,具体代码如下:
asyncFunc() .then(f1) .catch(r1) .then(f2) .done(); 复制代码
同时呢,它的实现代码也比较简单,我们来看一下。具体代码如下:
Promise.prototype.done = function (onFulfilled, onRejected) { this.then(onFulfilled, onRejected) .catch(function (reason) { //抛出一个全局错误 setTimeout(() => { throw reason; }, 0); }) } 复制代码
由以上代码可知, done
方法可以像 then
方法那样使用,提供 fulfilled
和 rejected
状态的回调函数,也可以不提供任何参数。但是不管如何, done
方法都会捕捉到任何可能出现的错误,并向全局抛出。
(2)finally()
finally
方法用于指定不管 Promise
对象最后状态如何都会执行的操作。它与 done
方法最大的区别在于,它接受一个普通的回调函数作为参数,该函数不管怎样都必须执行。
下面来展示一个例子。假设我们现在有一台服务器,现在让这台服务器使用 Promise
来处理请求,然后使用 finally
方法关掉服务器。具体实现代码如下:
server.listen(0) .then(function () { // run test }).finally(server.stop); 复制代码
同样地,它的实现代码也比较简单,我们来看一下。具体代码如下:
Promise.prototype.finally = function (callback) { let p = this.constructor; return this.then( value => p.resolve(callback()).then(() => { return value; }), reason => p.resolve(callback()).then(() => { throw reason; }) ); }; 复制代码
通过以上代码我们可以了解到,不管前面的 promise
是 fulfilled
还是 rejected
,最终都会执行回调函数 callback
。