Discuz! Board

 找回密码
 立即注册
搜索
热搜: 活动 交友 discuz
查看: 1738|回复: 3
打印 上一主题 下一主题

异步编程发展历程之Promise的演进史

[复制链接]

1272

主题

2067

帖子

7962

积分

认证用户组

Rank: 5Rank: 5

积分
7962
跳转到指定楼层
楼主
发表于 2022-2-15 22:46:07 | 只看该作者 |只看大图 回帖奖励 |倒序浏览 |阅读模式
https://e.naixuejiaoyu.com/detai ... bdb25/4?fromH5=true





回复

使用道具 举报

1272

主题

2067

帖子

7962

积分

认证用户组

Rank: 5Rank: 5

积分
7962
沙发
 楼主| 发表于 2022-2-15 22:47:54 | 只看该作者
本帖最后由 Qter 于 2022-2-15 22:50 编辑

从头认识JavaScript的事件循环模型



1. JS的运⾏机制介绍众所周知JavaScript是⼀⻔单线程的语⾔,所以在JavaScript的世界中默认的情况下同⼀个时间节点只能做⼀件事情,这样的设定就造成了JavaScript这⻔语⾔的⼀些局限性,⽐如在我们的⻚⾯中加载⼀些远程数据时,如果按照单线程同步的⽅式运⾏,⼀旦有HTTP请求向服务器发送,就会出现等待数据返回之前⽹⻚假死的效果出现。因为JavaScript在同⼀个时间只能做⼀件事,这就导致了⻚⾯渲染和事件的执⾏,在这个过程中⽆法进⾏。显然在实际的开发中我们并没有遇⻅过这种情况。关于同步和异步基于以上的描述,我们知道在JavaScript的世界中,应该存在⼀种解决⽅案,来处理单线程造成的诟病。这就是同步【阻塞】和异步【⾮阻塞】执⾏模式的出现。同步(阻塞):同步的意思是JavaScript会严格按照单线程(从上到下、从左到右的⽅式)执⾏代码逻辑,进⾏代码的解释和运⾏,所以在运⾏代码时,不会出现先运⾏4、5⾏的代码,再回头运⾏1、3⾏的代码这种情况。⽐如下列操作。接下来通过下列的案例升级⼀下代码的运⾏场景:当我们按照顺序执⾏上⾯代码时,我们的代码在解释执⾏到第4⾏时,还是正常的速度执⾏,但是在下⼀⾏就会进⼊⼀个持续的循环中。d2和d1在⾏级间的时间差仅仅是毫秒内的差别,所以在执⾏到while循环的时候d2-d1的值⼀定⽐2000⼩,那么这个循环会执⾏到什么时候呢?由于每次循环时,d2都会获取⼀次当前的时间发⽣变化,直到d2-d1==2000等情况,这时也就是正好过了2秒的时间,我们的程序才能跳出循环,进⽽再输出a+b的结果。那么这段程序的实际执⾏时间⾄少是2秒以上。这就导致了程序阻塞的出现,这也是为什么将同步的代码运⾏机制叫做阻塞式运⾏的原因。var a = 1var b = 2var c = a + b//这个例⼦总c⼀定是3不会出现先执⾏第三⾏然后在执⾏第⼆⾏和第⼀⾏的情况console.log(c)var a = 1var b = 2var d1 = new Date().getTime()var d2 = new Date().getTime()while(d2-d1<2000){ d2 = new Date().getTime()}//这段代码在输出结果之前⽹⻚会进⼊⼀个类似假死的状态console.log(a+b)阻塞式运⾏的代码,在遇到消耗时间的代码⽚段时,之后的代码都必须等待耗时的代码运⾏完毕,才能得到执⾏资源,这就是单线程同步的特点。异步(⾮阻塞):在上⾯的阐述中,我们明⽩了单线程同步模型中的问题所在,接下来引⼊单线程异步模型的介绍。异步的意思就是和同步对⽴,所以异步模式的代码是不会按照默认顺序执⾏的。JavaScript执⾏引擎在⼯作时,仍然是按照从上到下从左到右的⽅式解释和运⾏代码。在解释时,如果遇到异步模式的代码,引擎会将当前的任务“挂起”并略过。也就是先不执⾏这段代码,继续向下运⾏⾮异步模式的代码,那么什么时候来执⾏同步代码呢?直到同步代码全部执⾏完毕后,程序会将之前“挂起”的异步代码按照“特定的顺序”来进⾏执⾏,所以异步代码并不会【阻塞】同步代码的运⾏,并且异步代码并不是代表进⼊新的线程同时执⾏,⽽是等待同步代码执⾏完毕再进⾏⼯作。我们阅读下⾯的代码分析:这段代码的setTimeout定时任务规定了2秒之后执⾏⼀些内容,在运⾏当前程序执⾏到setTimeout时,并不会直接执⾏内部的回调函数,⽽是会先将内部的函数在另外⼀个位置(具体是什么位置下⾯会介绍)保存起来,然后继续执⾏下⾯的console.log进⾏输出,输出之后代码执⾏完毕,然后等待⼤概2秒左右,之前保存的函数再执⾏。⾮阻塞式运⾏的代码,程序运⾏到该代码⽚段时,执⾏引擎会将程序保存到⼀个暂存区,等待所有同步代码全部执⾏完毕后,⾮阻塞式的代码会按照特定的执⾏顺序,分步执⾏。这就是单线程异步的特点。通俗的讲:通俗的讲,同步和异步的关系是这样的:【同步的例⼦】:⽐如我们在核酸检测站,进⾏核酸检测这个流程就是同步的。每个⼈必须按照来的时间,先后进⾏排队,⽽核酸检测⼈员会按照排队⼈的顺序严格的进⾏逐⼀检测,在第⼀个⼈没有检测完成前,第⼆个⼈就得⽆条件等待,这个就是⼀个阻塞流程。如果排队过程中第⼀个⼈在检测时出了问题,如棉签断了需要换棉签,这样更换时间就会追加到这个⼈身上,直到他顺利的检测完毕,第⼆个⼈才能轮到。如果在检测中间棉签没有了,或者是录⼊信息的系统崩溃了,整个队列就进⼊⽆条件挂起状态所有⼈都做不了了。这就是结合⽣活中的同步案例。【异步的例⼦】:还是结合⽣活中,当我们进餐馆吃饭时,这个场景就属于⼀个完美的异步流程场景。每⼀桌来的客⼈会按照他们来的顺序进⾏点单,假设只有⼀个服务员的情况,点单必须按照先后顺序,但是服务员不需要等第⼀桌客⼈点好的菜出锅上菜,就可以直接去收集第⼆桌第三桌客⼈的需求。这样可能在⼗分钟之内,服务员就将所有桌的客⼈点菜的菜单统计出来,并且发送给了后厨。之后的菜也不会按照点餐顾客的课桌顺序,因为后厨收集到菜单之后可能有1,2,3桌的客⼈都点了锅包⾁,那么他可能会先⼀次出三份锅包⾁,这样锅包⾁在上菜的时候1,2,3桌的客⼈都能得到,并且其他的菜也会乱序的逐⼀上菜,这个过程就是异步的。如果按照同步的模式点餐,默认在饭店点菜就会出现饭店在第⼀桌客⼈上满菜之前第⼆桌之后的客⼈就只能等待连单都不能点的状态。总结:JavaScript的运⾏顺序就是完全单线程的异步模型:同步在前,异步在后。所有的异步任务都要等待当前的同步任务执⾏完毕之后才能执⾏。请看下⾯的案例:var a = 1var b = 2setTimeout(function(){ console.log('输出了⼀些内容')},2000)//这段代码会直接输出3并且等待2秒左右的时间在输出function内部的内容console.log(a+b)观察上⾯的程序我们实际运⾏之后就会感受到单线程异步模型的执⾏顺序了,并且这⾥我们会发现setTimeout设置的时间是1000毫秒但是在while的阻塞2000毫秒的循环之后并没有等待1秒⽽是直接输出了我是⼀个异步任务,这是因为setTimout的时间计算是从setTimeout()这个函数执⾏时开始计算的。JS的线程组成上⾯我们通过⼏个简单的例⼦⼤概了解了⼀下JS的运⾏顺序,那么为什么是这个顺序,这个顺序的执⾏原理是什么样的,我们应该如何更好更深的探究真相呢?这⾥需要介绍⼀下浏览器中⼀个Tab⻚⾯的实际线程组成。在了解线程组成前要了解⼀点,虽然浏览器是单线程执⾏JavaScript代码的,但是浏览器实际是以多个线程协助操作来实现单线程异步模型的,具体线程组成如下:1. GUI渲染线程2. JavaScript引擎线程3. 事件触发线程4. 定时器触发线程5. http请求线程6. 其他线程按照真实的浏览器线程组成分析,我们会发现实际上运⾏JavaScript的线程其实并不是⼀个,但是为什么说JavaScript是⼀⻔单线程的语⾔呢?因为这些线程中实际参与代码执⾏的线程并不是所有线程,⽐如GUI渲染线程为什么单独存在,这个是防⽌我们在html⽹⻚渲染⼀半的时候突然执⾏了⼀段阻塞式的JS代码⽽导致⽹⻚卡在⼀半停住这种效果。在JavaScript代码运⾏的过程中实际执⾏程序时同时只存在⼀个活动线程,这⾥实现同步异步就是靠多线程切换的形式来进⾏实现的。所以我们通常分析时,将上⾯的细分线程归纳为下列两条线程:1. 【主线程】:这个线程⽤了执⾏⻚⾯的渲染,JavaScript代码的运⾏,事件的触发等等2. 【⼯作线程】:这个线程是在幕后⼯作的,⽤来处理异步任务的执⾏来实现⾮阻塞的运⾏模式2. JavaScript的运⾏模型var a = 1var b = 2var d1 = new Date().getTime()var d2 = new Date().getTime()setTimeout(function(){ console.log('我是⼀个异步任务')},1000)while(d2-d1<2000){ d2 = new Date().getTime()}//这段代码在输出3之前会进⼊假死状态,'我是⼀个异步任务'⼀定会在3之后输出console.log(a+b)上图是JavaScript运⾏时的⼀个⼯作流程和内存划分的简要描述,我们根据图中可以得知主线程就是我们JavaScript执⾏代码的线程,主线程代码在运⾏时,会按照同步和异步代码将其分成两个去处,如果是同步代码执⾏,就会直接将该任务放在⼀个叫做“函数执⾏栈”的空间进⾏执⾏,执⾏栈是典型的【栈结构】(先进后出),程序在运⾏的时候会将同步代码按顺序⼊栈,将异步代码放到【⼯作线程】中暂时挂起,【⼯作线程】中保存的是定时任务函数、JS的交互事件、JS的⽹络请求等耗时操作。当【主线程】将代码块筛选完毕后,进⼊执⾏栈的函数会按照从外到内的顺序依次运⾏,运⾏中涉及到的对象数据是在堆内存中进⾏保存和管理的。当执⾏栈内的任务全部执⾏完毕后,执⾏栈就会清空。执⾏栈清空后,“事件循环”就会⼯作,“事件循环”会检测【任务队列】中是否有要执⾏的任务,那么这个任务队列的任务来源就是⼯作线程,程序运⾏期间,⼯作线程会把到期的定时任务、返回数据的http任务等【异步任务】按照先后顺序插⼊到【任务队列】中,等执⾏栈清空后,事件循环会访问任务队列,将任务队列中存在的任务,按顺序(先进先出)放在执⾏栈中继续执⾏,直到任务队列清空。从代码⽚段开始分析function task1(){console.log('第⼀个任务')}function task2(){console.log('第⼆个任务')}function task3(){console.log('第三个任务')}function task4(){console.log('第四个任务')}task1()setTimeout(task2,1000)setTimeout(task3,500)刚才的⽂字阅读可能在⼤脑中很难形成⼀个带动画的图形界⾯来帮助我们分析JavaScript的实际运⾏思路,接下来我们将这段代码肢解之后详细的研究⼀下。按照字⾯分析:按照字⾯分析,我们创建了四个函数代表4个任务,函数本身都是同步代码。在执⾏的时候会按照1,2,3,4进⾏解析,解析过程中我们发现任务2和任务3被setTimeout进⾏了定时托管,这样就只能先运⾏任务1和任务4了。当任务1和任务4运⾏完毕之后500毫秒后运⾏任务3,1000毫⽶后运⾏任务2。那么他们在实际运⾏时⼜是经历了怎么样的流程来运⾏的呢?⼤概的流程我们以图解的形式分析⼀下。图解分析:如上图,在上述代码刚开始运⾏的时候我们的主线程即将⼯作,按照顺序从上到下进⾏解释执⾏,此时执⾏栈、⼯作线程、任务队列都是空的,事件循环也没有⼯作。接下来我们分析下⼀个阶段程序做了什么事情。task4()结合上图可以看出程序在主线程执⾏之后就将任务1、4和任务2、3分别放进了两个⽅向,任务1和任务4都是⽴即执⾏任务所以会按照1->4的顺序进栈出栈(这⾥由于任务1和2是平⾏任务所以会先执⾏任务1的进出栈再执⾏任务4的进出栈),⽽任务2和任务3由于是异步任务就会进⼊⼯作线程挂起并开始计时,并不影响主线程运⾏,此时的任务队列还是空置的。我们发现同步任务的执⾏速度是⻜快的,这样⼀下执⾏栈已经空了,⽽任务2和任务3还没有到时间,这样我们的事件循环就会开始⼯作等待任务队列中的任务进⼊,接下来就是执⾏异步任务的时候了。我们发现任务队列并不是⼀下⼦就会将任务2和任务三⼀起放进去,⽽是哪个计时器到时间了哪个放进去,这样我们的事件循环就会发现队列中的任务,并且将任务拿到执⾏栈中进⾏消费,此时会输出任务3的内容。到这就是最后⼀次执⾏,当执⾏完毕后⼯作线程中没有计时任务,任务队列的任务清空程序到此执⾏完毕。总结我们通过图解之后脑⼦⾥就会更清晰的能搞懂异步任务的执⾏⽅式了,这⾥采⽤最简单的任务模型进⾏描绘复杂的任务在内存中的分配和⾛向是⾮常复杂的,我们有了这次的经验之后就可以通过观察代码在⼤脑中先模拟⼀次执⾏,这样可以更清晰的理解JS的运⾏机制。关于执⾏栈执⾏栈是⼀个栈的数据结构,当我们运⾏单层函数时,执⾏栈执⾏的函数进栈后,会出栈销毁然后下⼀个进栈下⼀个出栈,当有函数嵌套调⽤的时候栈中就会堆积栈帧,⽐如我们查看下⾯的例⼦:function task1(){ console.log('task1执⾏') task2() console.log('task2执⾏完毕')}function task2(){我们根据字⾯阅读就能很简单的分析出输出的结果会是那么这种嵌套函数在执⾏栈中的操作流程是什么样的呢? console.log('task2执⾏') task3() console.log('task3执⾏完毕')}function task3(){ console.log('task3执⾏')}task1()console.log('task1执⾏完毕')/*task1执⾏task2执⾏task3执⾏task3执⾏完毕task2执⾏完毕task1执⾏完毕*/第⼀次执⾏的时候调⽤task1函数执⾏到console.log的时候先进⾏输出,接下来会遇到task2函数的调⽤会出现下⾯的情况:执⾏到此时检测到task2中还有调⽤task3的函数,那么就会继续进⼊task3中执⾏,如下图:在执⾏完task3中的输出之后task3内部没有其他代码,那么task3函数就算执⾏完毕那么就会发⽣出栈⼯作。此时我们会发现task3出栈之后程序运⾏⼜会回到task2的函数中继续他的执⾏。接下来会发⽣相同的事情。再之后就剩下task1⾃⼰了,他在task2销毁之后输出task2执⾏完毕后他也会随着出栈⽽销毁。当task1执⾏完毕之后它随着销毁最后⼀⾏输出,就会进⼊执⾏栈执⾏并销毁,销毁之后执⾏栈和主线程清空。这个过程就会出现123321的这个顺序,⽽且我们在打印输出时,也能通过打印的顺序来理解⼊栈和出栈的顺序和流程。关于递归关于上⾯的执⾏栈执⾏逻辑清楚后,我们就顺便学习⼀下递归函数,递归函数是项⽬开发时经常涉及到的场景。我们经常会在未知深度的树形结构,或其他合适的场景中使⽤递归。那么递归在⾯试中也会经常被问到⻛险问题,如果了解了执⾏栈的执⾏逻辑后,递归函数就可以看成是在⼀个函数中嵌套n层执⾏,那么在执⾏过程中会触发⼤量的栈帧堆积,如果处理的数据过⼤,会导致执⾏栈的⾼度不够放置新的栈帧,⽽造成栈溢出的错误。所以我们在做海量数据递归的时候⼀定要注意这个问题。关于执⾏栈的深度:执⾏栈的深度根据不同的浏览器和JS引擎有着不同的区别,我们这⾥就Chrome浏览器为例⼦来尝试⼀下递归的溢出:我们发现在递归了11378次之后会提示超过栈深度的错误,也就是我们⽆法在Chrome或者其他浏览器做太深层的递归操作。如何跨越递归限制发现问题后,我们再考虑如何能通过技术⼿段跨越递归的限制。可以将代码做如下更改,这样就不会出现递归问题了。我们发现只是做了⼀个⼩⼩的改造,这样就不会出现溢出的错误了。这是为什么呢?var i = 0;function task(){ i++ console.log(`递归了${i}次`) task()}task()var i = 0;function task(){ i++ console.log(`递归了${i}次`) //使⽤异步任务来阻⽌递归的溢出 setTimeout(function(){ task() },0)}task()在了解原因之前我们先看控制台的输出,结合控制台输出我们发现确实超过了界限也没有报错。图解原因:这个是因为我们这⾥使⽤了异步任务去调⽤递归中的函数,那么这个函数在执⾏的时候就不只使⽤栈进⾏执⾏了。先看没有异步流程时候的执⾏图例:再看有了异步任务的递归:有了异步任务之后我们的递归就不会叠加栈帧了,因为放⼊⼯作线程之后该函数就结束了,可以出栈销毁,那么在执⾏栈中就永远都是只有⼀个任务在运⾏,这样就防⽌了栈帧的⽆限叠加,从⽽解决了⽆限递归的问题,不过异步递归的过程是⽆法保证运⾏速度的,在实际的⼯作场景中,如果考虑性能问题,还需要使⽤while循环等解决⽅案,来保证运⾏效率的问题,在实际⼯作场景中,尽量避免递归循环,因为递归循环就算控制在有限栈帧的叠加,其性能也远远不及指针循环。3.宏任务和微任务在明确了事件循环模型以及JavaScript的执⾏流程后,我们认识了⼀个叫做任务队列的容器,他的数据结构式队列的结构。所有除同步任务外的代码都会在⼯作线程中,按照他到达的时间节点有序的进⼊任务队列,⽽且任务队列中的异步任务⼜分为【宏任务】和【微任务】。举个例⼦:在了解【宏任务】和【微任务】前,还是哪⽣活中的实际场景举个例⼦:⽐如在去银⾏办理业务时,每个⼈都需要在进⼊银⾏时找到取票机进⾏取票,这个操作会把来办理业务的⼈按照取票的顺序排成⼀个有序的队列。假设银⾏只开通了⼀个办事窗⼝,窗⼝的⼯作⼈员会按照排队的顺序进⾏叫号,到达号码的⼈就可以前往窗⼝办理业务,在第⼀个⼈办理业务的过程中,第⼆个以后的⼈都需要进⾏等待。这个场景与JavaScript的异步任务队列执⾏场景是⼀模⼀样的,如果把每个办业务的⼈当作JavaScript中的每⼀个异步的任务,那么取号就相当于将异步任务放⼊任务队列。银⾏的窗⼝就相当于【函数执⾏栈】,在叫号时代表将当前队列的第⼀个任务放⼊【函数执⾏栈】运⾏。这时可能每个⼈在窗⼝办理的业务内容各不相同,⽐如第⼀个⼈仅仅进⾏开卡的操作,这样银⾏⼯作⼈员就会为其执⾏开卡流程,这就相当于执⾏异步任务内部的代码。如果第⼀个⼈的银⾏卡开通完毕,银⾏的⼯作⼈员不会⽴即叫第⼆个⼈过来,⽽是会询问第⼀个⼈,“您是否需要为刚才开通的卡办理⼀些增值业务,⽐如做个活期储蓄。”,这时相当于在原始开卡的业务流程中临时追加了⼀个新的任务,按照JavaScript的执⾏顺序,这个⼈的新任务应该回到取票机拿取⼀张新的号码,并且在队尾重新排队,这样⼯作的话办事效率就会急剧下降。所以银⾏实际的做法是在叫下⼀个⼈办理业务前,如果前⾯的⼈临时有新的业务要办理,⼯作⼈员会继续为其办理业务,直到这个⼈的所有事情都办理完毕。从取卡到办理追加业务完成的这个过程,就是微任务的实际体现。在JavaScript运⾏环境中,包括主线程代码在内,可以理解为所有的任务内部都存在⼀个微任务队列,在每下⼀个宏任务执⾏前,事件循环系统都会先检测当前的代码块中是否包含已经注册的微任务,并将队列中的微任务优先执⾏完毕,进⽽执⾏下⼀个宏任务。所以实际的任务队列的结构是这样的,如图:宏任务与微任务的介绍由上述内容得知JavaScript中存在两种异步任务,⼀种是宏任务⼀种是微任务,他们的特点如下:宏任务:宏任务是JavaScript中最原始的异步任务,包括setTimeout、setInterVal、AJAX等,在代码执⾏环境中按照同步代码的顺序,逐个进⼊⼯作线程挂起,再按照异步任务到达的时间节点,逐个进⼊异步任务队列,最终按照队列中的顺序进⼊函数执⾏栈进⾏执⾏。微任务:微任务是随着ECMA标准升级提出的新的异步任务,微任务在异步任务队列的基础上增加了【微任务】的概念,每⼀个宏任务执⾏前,程序会先检测中是否有当次事件循环未执⾏的微任务,优先清空本次的微任务后,再执⾏下⼀个宏任务,每⼀个宏任务内部可注册当次任务的微任务队列,再下⼀个宏任务执⾏前运⾏,微任务也是按照进⼊队列的顺序执⾏的。总结:# 浏览器 NodeI/O✅ ✅setTimeoutsetIntervalsetImmediate❌requestAnimationFrame✅ ❌# 浏览器 Nodeprocess.nextTick❌ ✅MutationObserver✅ ❌romise.then catch finally✅在JavaScript的运⾏环境中,代码的执⾏流程是这样的:1. 默认的同步代码按照顺序从上到下,从左到右运⾏,运⾏过程中注册本次的微任务和后续的宏任务:2. 执⾏本次同步代码中注册的微任务,并向任务队列注册微任务中包含的宏任务和微任务3. 将下⼀个宏任务开始前的所有微任务执⾏完毕4. 执⾏最先进⼊队列的宏任务,并注册当次的微任务和后续的宏任务,宏任务会按照当前任务队列的队尾继续向下排列常⻅的宏任务和微任务划分宏任务有些地⽅会列出来 UI Rendering ,说这个也是宏任务,可是在读了HTML规范⽂档以后,发现这很显然是和微任务平⾏的⼀个操作步骤requestAnimationFrame 姑且也算是宏任务吧, requestAnimationFrame 在MDN的定义为,下次⻚⾯重绘前所执⾏的操作,⽽重绘也是作为宏任务的⼀个步骤来存在的,且该步骤晚于微任务的执⾏微任务经典笔试题代码输出顺序问题1setTimeout(function() {console.log('timer1')}, 0)requestAnimationFrame(function(){ console.log('UI update')解析:本案例输出的结果为:猜对我就告诉你,先思考,猜对之后结合运⾏结果分析。按照同步先⾏,异步靠后的原则,阅读代码时,先分析同步代码和异步代码,Promise对象虽然是微任务,但是new Promise时的回调函数是同步执⾏的,所以优先输出promise 1 和 promise 2。在resolve执⾏时Promise对象的状态变更为已完成,所以then函数的回调被注册到微任务事件中,此时并不执⾏,所以接下来应该输出end。同步代码执⾏结束后,观察异步代码的宏任务和微任务,在本次的同步代码块中注册的微任务会优先执⾏,参考上⽂中描述的列表,Promise为微任务,setTimeout和requestAnimationFrame为宏任务,所以Promise的异步任务会在下⼀个宏任务执⾏前执⾏,所以promise then是第四个输出的结果。接下来参考setTimeout和requestAnimationFrame两个宏任务,这⾥的运⾏结果是多种情况。如果三个宏任务都为setTimeout的话会按照代码编写的顺序执⾏宏任务,⽽中间包含了⼀个requestAnimationFrame,这⾥就要学习⼀下他们的执⾏时机了。setTimeout是在程序运⾏到setTimeout时⽴即注册⼀个宏任务,所以两个setTimeout的顺序⼀定是固定的timer1和timer2会按照顺序输出。⽽requestAnimationFrame是请求下⼀次重绘事件,所以他的执⾏频率要参考浏览器的刷新率。参考如下代码:})setTimeout(function() {console.log('timer2')}, 0)new Promise(function executor(resolve) { console.log('promise 1') resolve() console.log('promise 2')}).then(function() { console.log('promise then')})console.log('end')let i = 0;let d = new Date().getTime()let d1 = new Date().getTime()function loop(){ d1 = new Date().getTime() i++ //当间隔时间超过1秒时执⾏ if((d1-d)>=1000){ d = d1 console.log(i) i = 0 console.log('经过了1秒') } requestAnimationFrame(loop)}该代码在浏览器运⾏时,控制台会每间隔1秒进⾏⼀次输出,输出的i就是loop函数执⾏的次数,如下图:这个输出意味着requestAnimationFrame函数的执⾏频率是每秒钟60次左右,他是按照浏览器的刷新率来进⾏执⾏的,也就是当屏幕刷新⼀次时该函数就会触发⼀次,相当于运⾏间隔是16毫秒左右。继续参考下列代码:该代码结构与上⾯的案例类似,循环是采⽤setTimeout进⾏控制的,所以参考运⾏结果,如图:loop()let i = 0;let d = new Date().getTime()let d1 = new Date().getTime()function loop(){ d1 = new Date().getTime() i++ if((d1-d)>=1000){ d = d1 console.log(i) i = 0 console.log('经过了1秒') } setTimeout(loop,0)}loop()根据运⾏结果得知,setTimeout(fn,0)的执⾏频率是每秒执⾏200次左右,所以他的间隔是5毫秒左右。由于这两个异步的宏任务出发时机和执⾏频率不同,会导致三个宏任务的触发结果不同,如果我们打开⽹⻚时,恰好赶上5毫秒内执⾏了⽹⻚的重绘事件,requestAnimationFrame在⼯作线程中就会到达触发时机优先进⼊任务队列,所以此时会输出:UI update->timer1->timer2。⽽当打开⽹⻚时上⼀次的重绘刚结束,下⼀次重绘的触发是16毫秒后,此时setTimeout注册的两个任务在⼯作线程中就会优先到达触发时机,这时输出的结果是:timer1->timer2->UI update。所以此案例的运⾏结果如下2图所示:代码输出顺序问题2解析:仍然是猜对了告诉你哈~,先运⾏⼀下试试吧。这个案例代码简单易懂,但是很容易引起错误答案的出现。由于该事件是直接绑定在document上的,所以点击⽹⻚就会触发该事件,在代码运⾏时相当于按照顺序注册了两个点击事件,两个点击事件会被放在⼯作线程中实时监听触发时机,当元素被点击时,两个事件会按照先后的注册顺序放⼊异步任务队列中进⾏执⾏,所以事件1和事件2会按照代码编写的顺序触发。这⾥就会导致有⼈分析出错误答案:2,4,1,3。为什么不是2,4,1,3呢?由于事件执⾏时并不会阻断JS默认代码的运⾏,所以事件任务也是异步任务,并且是宏任务,所以两个事件相当于按顺序执⾏的两个宏任务。这样就会分出两个运⾏环境,第⼀个事件执⾏时,console.log(2);是第⼀个宏任务中的同步代码,所以他会⽴即执⾏,⽽romise.resolve().then(()=> console.log(1));属于微任务,他会在下⼀个宏任务触发前执⾏,所以这⾥输出2后会直接输出1.⽽下⼀个事件的内容是相同道理,所以输出顺序为:2,1,4,3。总结document.addEventListener('click', function(){ Promise.resolve().then(()=> console.log(1)); console.log(2);})document.addEventListener('click', function(){ Promise.resolve().then(()=> console.log(3)); console.log(4);})关于事件循环模型今天就介绍到这⾥,在NodeJS中的事件循环模型和浏览器中是不⼀样的,本⽂是以浏览器的事件循环模型为基础进⾏介绍,事件循环系统在JavaScript异步编程中占据的⽐重是⾮常⼤的,在⼯作中可使⽤场景也是众多的,掌握了事件循环模型就相当于,异步编程的能⼒上升了⼀个新的⾼度。


回复 支持 反对

使用道具 举报

1272

主题

2067

帖子

7962

积分

认证用户组

Rank: 5Rank: 5

积分
7962
板凳
 楼主| 发表于 2022-2-15 22:55:28 | 只看该作者
FEP02 异步编程发展历程之Promise的演进史

介绍JavaScript是⼀⻔典型的异步编程脚本语⾔,在编程过程中会⼤量的出现异步代码的编写,在JS的整个发展历程中,对异步编程的处理⽅式经历了很多个时代,其中最典型也是现今使⽤最⼴泛的时代,就是Promise对象处理异步编程的时代。那么什么是Promise对象呢?Promise是ES6版本提案中实现的异步处理⽅式,对象代表了未来将要发⽣的事件,⽤来传递异步操作的消息。


为什么使⽤romise对象


举个栗⼦:在过去的编程中JavaScript的主要异步处理⽅式,是采⽤回调函数的⽅式来进⾏处理,想要保证n个步骤的异步编程有序进⾏,会出现如下的代码(以setTimeout为栗⼦)参考上⾯的代码,如果分3秒每间隔1秒运⾏1个任务,这三个任务必须按时间顺序执⾏,并且每个下⼀秒触发前都要先拿到上⼀秒运⾏的结果,那么我们不得不将代码编写为以上案例代码。该写法主要是为了保证代码的严格顺序要求,这样就避免不了⼤量的逻辑在回调函数中不停的进⾏嵌套,这也是我们经常听说的“回调地狱”。再举个栗⼦:在编程中setTimeout的“栗⼦”其实使⽤场景极少,在前端开发过程中使⽤最多的异步流程就是AJAX请求,当系统中要求某个⻚⾯的n个接⼝保证有序调⽤的情况下就会出现下⾯的情况:setTimeout(function(){ //第⼀秒后执⾏的逻辑 console.log('第⼀秒之后发⽣的事情') setTimeout(function(){ //第⼆秒后执⾏的逻辑 console.log('第⼆秒之后发⽣的事情') setTimeout(function(){ //第三秒后执⾏的逻辑 console.log('第三秒之后发⽣的事情') },1000) },1000)},1000)//获取类型数据$.ajax({ url:'/***', success:function(res){ var xxId = res.id //获取该类型的数据集合,必须等待回调执⾏才能进⾏下⼀步 $.ajax({ url:'/***',这种情况在很多⼈的代码中都出现过,如果流程复杂化,在⽹络请求中继续夹杂其他的异步流程,那么这样的代码就会变得难以维护了。其他的“栗⼦”诸如Node中的原始fs模块,操作⽂件系统这种就不举了,所以之所以在ECMA提案中出现Promise解决⽅案,就是因为此类代码导致了JS在开发过程中遇到的实际问题:【回调地狱】。其实解决回调地狱的⽅式还有其他⽅案,本节我们不多做介绍中间的过渡⽅案,核⼼介绍Promise流程控制对象,因为他是解决回调地狱的⾮常好的⽅案。使⽤romise如何解决异步控制问题前⾯的章节仅仅是抛出了问题,并没有针对问题作出⼀个合理的回答。下⾯阐述⼀下如何使⽤romise对象解决回调地狱问题。在阐述之前,我们先对Promise做⼀个简单的介绍:Promise对象的主要⽤途是通过链式调⽤的结构,将原本回调嵌套的异步处理流程,转化成“对象.then().then()...”的链式结构,这样虽然仍离不开回调函数,但是将原本的回调嵌套结构,转化成了连续调⽤的结构,这样就可以在阅读上编程上下左右结构的异步执⾏流程了。接下来看⼀段代码,还是以setTimout为“栗⼦”我们改造第⼀个案例: data:{ xxId:xxId,//使⽤上⼀个请求结果作为参数调⽤下⼀个接⼝ }, success:function(res1){ //得到指定类型集合 ... } }) }})//使⽤romise拆解的setTimeout流程控制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()结合代码案例我们发现使⽤了Promise后的代码,将原来的3个setTimeout的回调嵌套,拆解成了三次then包裹的回调函数,按照上下顺序进⾏编写。这样我们从视觉上就可以按照⼈类的从上到下从左到右的线性思维来阅读代码,这样很容易能查看这段代码的执⾏流程,代价是代码的编写量增加了接近1倍。


Promise介绍


从上⾯的案例介绍得知Promise的作⽤是解决“回调地狱”,他的解决⽅式是将回调嵌套拆成链式调⽤,这样便可以按照上下顺序来进⾏异步代码的流程控制。那么Promise是如何实现这个能⼒的呢?Promise的结构Promise对象是⼀个JavaScript对象,在⽀持ES6语法的运⾏环境中作为全局对象提供,他的初始化⽅式如下:关于回调函数这⾥涉及到⼀个概念:JavaScript语⾔中,有⼀个特殊的函数叫做回调函数。回调函数的特点是把函数作为变量看待,由于JavaScript变量可以作为函数的形参并且函数可以通过声明变量的⽅式匿名创建,所以我们可以在定义函数时将⼀个函数的参数当作函数来执⾏,进⽽在调⽤时在参数的位置编写⼀个执⾏函数,代码如下:上⾯的代码结构,就是JavaScript中典型的回调函数结构。按照我们在事件循环中介绍的JavaScript函数运⾏机制,会发现其实回调函数本身是同步代码,这是⼀个需要【重点理解】的知识点。通常在编写JavaScript代码时,使⽤的回调嵌套的形式⼤多是异步函数,所以⼀些开发者可能会下意识的认为,凡是回调形式的函数都是异步流程。其实并不是这样的,真正的解释是(敲⿊板):JavaScript中的回调函数结构,默认是同步的结构,由于JavaScript单线程异步模型的规则,如果想要编写异步的代码,必须使⽤回调嵌套的形式才能实现,所以回调函数结构不⼀定是异步代码,但是异步代码⼀定是回调函数结构。 },1000) })}).then(function(){ //第三秒后执⾏的逻辑 console.log('第三秒之后发⽣的事情')})//fn:是初始化过程中调⽤的函数他是同步的回调函数var p = new Promise(fn)//把fn当作函数对象那么就可以在test函数中使⽤()执⾏他function test(fn){ fn()}//那么运⾏test的时候fn也会随着执⾏,所以test中传⼊的匿名函数就会运⾏test(function(){ ...})那么为什么异步流程都需要回调函数?依然举个栗⼦:分析下列代码的输出顺序(先不要看答案):分析下列代码的输出顺序(先不要看答案):再思考⼀下,如果我们有⼀个变量a的值为0,想要1秒之后设置他的值为1,并且我们想要在之后得到a的新结果,这个逻辑中如果1秒之后设置a为1采⽤的是setTimeout,那么我们在同步结构⾥能否实现?先参考下⾯的案例:解析上述代码块输出的结果⼀定是0,由于JavaScript单线程异步模型的知识,我们可以得知,当前的代码块中setTimeout的回调函数是⼀个宏任务,会在本次的同步代码执⾏完毕后执⾏,所以声明a=0和输出a的值这两⾏代码会优先执⾏,这时对a设置1的事件还没有发⽣,所以输出的结果就⼀定为0。接下来对代码做如下改造,我们试图使⽤阻塞的⽅式来获取异步代码的结果:function test(fn){ fn()}console.log(1)test(function(){ console.log(2)})console.log(3)//这段代码的输出顺序应该是*、*、*,因为他属于直接进⼊执⾏栈的程序,会按照正常程序解析的流程输出function test(fn){ setTimeout(fn,0)}console.log(1)test(function(){ console.log(2)})console.log(3)//这段代码会输出*、*、*,因为在调⽤test的时候settimeout将fn放到了异步任务队列挂起了,等待主程序执⾏完毕之后才会执⾏var a = 0setTimeout(function(){ a = 1},1000)console.log(a)解析本案例的同步代码会在while循环中阻塞2秒,所以console.log(a)这⾏代码会在2秒之后才能获得执⾏资源,但是最终输出的结果仍然是0。这是为什么呢?这⾥仍然可以通过JavaScript的运⾏模型来进⾏理解,由于单线程异步模型的规则是严格的同步在前异步靠后顺序,本案例的同步代码虽然阻塞了2秒,已经超过了setTimeout的等待时间,但是setTimeout中的宏任务到时间后,仅仅会被从⼯作线程移动到任务队列中进⾏等待。在时间到达1秒时,while循环没有执⾏结束,所以函数执⾏栈会被继续占⽤,直到循环释放并输出a之后,任务队列中的宏任务才能执⾏,所以这⾥就算setTimeout时间到了,也必须等待同步代码执⾏完毕,那么输出a的时候a=1的事件仍然没有发⽣,所以我们采⽤默认的上下结构永远拿不到异步回调中的结果,这也是为什么异步流程都是回调函数的原因。所以想要真正的在2秒后获取a的新结果的代码结构是这样的:到这⾥我们⼤概明⽩了回调函数的意义以及使⽤场景了,那么我们的Promise对象完整的结构是接下来案例中的样⼦,并且他是⼀个及特殊的存在,Promise中既包含同步的回调函数,⼜包含异步的回调函数。Promise案例介绍运⾏该案例并查看解析:var a = 0//依然使⽤setTimeout设置1秒的延迟设置a的值setTimeout(function(){ a = 1},1000)var d = new Date().getTime()var d1 = new Date().getTime()//采⽤while循环配合时间差来阻塞同步代码2秒while(d1-d<2000){ d1 = new Date().getTime()}console.log(a)//我们只有在这个回调函数中才能获取到a改造之后的结果var a = 0setTimeout(function(){ a = 1},1000)//注册⼀个新的宏任务,让他在上⼀个宏任务后执⾏setTimeout(function(){ console.log(a)},2000)参考上⾯的Promise对象结构,⼀个Promise对象包含两部分回调函数,第⼀部分是new Promise时候传⼊的对象,这段回调函数是同步的,⽽.then.catch.finally中的回调函数是异步的,这⾥我们提前记好。接下来可以在html⻚⾯中跑⼀遍程序,会发现这段程序并没有任何输出,然后我们可以将程序继续改造。练习案例:猜输出顺序将这段程序运⾏⼀下会发现输出顺序为:起步->调⽤resolve->结束->执⾏了resolve->then执⾏->finally执⾏再看下⾯的代码://实例化⼀个Promise对象var p = new Promise(function(resolve,reject){ })//通过链式调⽤控制流程p.then(function(){ console.log('then执⾏')}).catch(function(){ console.log('catch执⾏')}).finally(function(){ console.log('finally执⾏')})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('结束')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(res){将这段程序运⾏⼀下会发现输出顺序为:起步->调⽤reject->结束->执⾏了reject->catch执⾏->finally执⾏解读Promise结构经过了上⾯的代码我们可以分析⼀下Promise的运⾏流程和结构,⾸先从运⾏流程上我们发现了new Promise中的回调函数确实是在同步任务中执⾏的,其次是如果这个回调函数内部没有执⾏resolve或者reject那么p对象的后⾯的回调函数内部都不会有输出,⽽运⾏resolve函数之后.then和.finally就会执⾏,运⾏了reject之后.catch和.finally就会执⾏。剖析对象结构Pomise对象相当于⼀个未知状态的对象,他的定义就是声明⼀个等待未来结果的对象,在结果发⽣之前他⼀直是初始状态,在结果发⽣之后他会变成其中⼀种⽬标状态,它的名字Promise中⽂翻译为保证。很多国外的电影台词都会涉及到Promsie这个单词,⽐如⼩明发现了邻居张三的妻⼦出轨了,在某天喝酒的时候⼩明和张三说:I sawyour wife played with other man! I promise ! I saw it!张三当然会说:No !shit!I can not trust you!dame!(语义⾃⾏理解),Promise在英⽂中是绝对保证的意思,所以在编程中Promise对象是⼀个⾮常严谨的对象,⼀定会按照约定执⾏,不会出现任务灵异问题(除使⽤不当外)。那么Promise本身具备三种状态:pending:初始状态,也叫就绪状态,这是在Promise对象定义初期的状态,这时Promise仅仅做了初始化并注册了他对象上所有的任务。fulfilled:已完成,通常代表成功执⾏了某⼀个任务,当初始化函数中的resolve执⾏时,Promise的状态就变更为fulfilled,并且then函数注册的回调函数会开始执⾏,resolve中传递的参数会进⼊回调函数作为形参。rejected:已拒绝,通常代表执⾏了⼀次失败任务,或者流程中断,当调⽤reject函数时,catch注册的回调函数就会触发,并且reject中传递的内容会变成回调函数的形参。三种状态之间的关系:Promise中约定,当对象创建之后同⼀个Promise对象只能从pending状态变更为fulfilled或rejected中的其中⼀种,并且状态⼀旦变更就不会再改变,此时Promise对象的流程执⾏完成并且finally函数执⾏。根据上⾯的分析,结合下⾯的代码案例学习Promise的规则,分析该对象的运⾏结果: console.log(res) console.log('catch执⾏')}).finally(function(){ console.log('finally执⾏')})console.log('结束')通过分析以上的说明我们知道了Promise对象存在三种状态以及他们之间的关系,那么我们在执⾏本段程序的时候会发现这个段代码的输出结果是:then执⾏->finally执⾏接下来查看另⼀个案例:我们在执⾏本段程序的时候会发现这个段代码的输出结果是:catch执⾏->finally执⾏再接下来查看下⾯的案例:我们在执⾏本段程序的时候会发现这个段代码的输出结果是:空new Promise(function(resolve,reject){ resolve() reject()}).then(function(){ console.log('then执⾏')}).catch(function(){ console.log('catch执⾏')}).finally(function(){ console.log('finally执⾏')})new Promise(function(resolve,reject){ reject() resolve()}).then(function(){ console.log('then执⾏')}).catch(function(){ console.log('catch执⾏')}).finally(function(){ console.log('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()这样调⽤,他的原理⼤概是这样的。其实他的本质就是在我们调⽤这些⽀持链式调⽤的函数的结尾时,他⼜返回了⼀个包含他⾃⼰的对象或者是⼀个新的⾃⼰,这些⽅式都可以实现链式调⽤。Promise使⽤注意事项在⽹⻚中运⾏如下代码查看返回结果:控制台上会得到如下内容:[[Prototype]]代表Promise的原型对象[[PromiseState]]代表Promise对象当前的状态[[PromiseResult]]代表Promise对象的值,分别对应resolve或reject传⼊的结果function MyPromise(){ return this}MyPromise.prototype.then = function(){ console.log('触发了then') return this}new MyPromise().then().then().then()var p = new Promise(function(resolve,reject){ resolve('我是Promise的值')})console.log(p)Promise {: '我是Promise的值'}[[Prototype]]: Promise[[PromiseState]]: "fulfilled"[[PromiseResult]]: "我是Promise的值"1. 链式调⽤的注意事项运⾏如下代码并查看结果:控制台上会输出如下结果:根据现象我们可以分析出链式调⽤的基本形式(极其重要):1. 只要有then()并且触发了resolve,整个链条就会执⾏到结尾,这个过程中的第⼀个回调函数的参数是resolve传⼊的值2. 后续每个函数都可以使⽤return返回⼀个结果,如果没有返回结果的话下⼀个then中回调函数的参数就是undefined3. 返回结果如果是普通变量,那么这个值就是下⼀个then中回调函数的参数//通过⼀个超⻓的链式调⽤我们学习⼀下链式调⽤的注意事项var p = new Promise(function(resolve,reject){ resolve('我是Promise的值')})console.log(p)p.then(function(res){ //该res的结果是resolve传递的参数 console.log(res)}).then(function(res){ //该res的结果是undefined console.log(res) return '123'}).then(function(res){ //该res的结果是123 console.log(res) return new Promise(function(resolve){ resolve(456) })}).then(function(res){ //该res的结果是456 console.log(res) return '我是直接返回的结果'}).then() .then('我是字符串') .then(function(res){ //该res的结果是“我是直接返回的结果” console.log(res)})Promise {: '我是Promise的值'}ttt.html:16 我是Promise的值ttt.html:18 undefinedttt.html:21 123ttt.html:26 456ttt.html:31 我是直接返回的结果4. 如果返回的是⼀个Promise对象,那么这个Promise对象resolve的结果会变成下⼀次then中回调的函数的参数5. 如果then中传⼊的不是函数或者未传值,Promise链条并不会中断then的链式调⽤,并且在这之前最后⼀次的返回结果,会直接进⼊离它最近的正确的then中的回调函数作为参数2. 中断链式调⽤链式调⽤可以中断吗?答案是肯定的,我们有两种形式可以让.then的链条中断,如果中断还会触发⼀次.catch的执⾏。查阅下⾯的案例学习:结果如下我们发现中断链式调⽤后会触发catch函数执⾏,并且从中断开始到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的值')})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(err){ console.log(err)})Promise {: '我是Promise的值'}ttt.html:16 我是Promise的值ttt.html:26 我是中断的原因当我们运⾏上⾯的代码时,控制台会出现如下的打印信息:我们会发现返回的p和p1 的状态本身就不⼀样,并且他们的对⽐结果是false,这就代表他们在堆内存中开辟了两个空间,p和p1对象分别保存了两个Promise对象的引⽤地址,所以then函数虽然每次都返回Promise对象,来实现链式调⽤,但是then函数每次返回的都是⼀个新的Promise对象。这样便解释的通了!也就是说每⼀次then函数在执⾏时,我们都可以让本次的结果在下⼀个异步步骤执⾏时,变成不同的状态,⽽且这也不违背Promise对象最初的约定。4. 总结根据以上的分析我们已经掌握了Promise在运⾏时的规则,这样就能解释的通,为什么最初通过Promise控制setTimeout每秒执⾏⼀次的功能可以实现,这是因为当我们使⽤then函数进⾏链式调⽤时,可以利⽤返回⼀个新的Promise对象来执⾏下⼀次then函数,⽽下⼀次then函数的执⾏,必须等待其内部的resolve调⽤。这样我们在new Promise时,放⼊setTimeout来进⾏延时,保证1秒之后让状态变更,这样就能不编写回调嵌套来实现连续的执⾏异步流程了。




Promise常⽤api




Promise.all()当我们在代码中需要使⽤异步流程控制时,可以通过Promise.then来实现让异步流程⼀个接⼀个的执⾏,假设实际案例中,某个模块的⻚⾯需要同时调⽤3个服务端接⼝,并保证三个接⼝的数据全部返回后,才能渲染⻚⾯。这种情况如果a接⼝耗时1s、b接⼝耗时0.8s、c接⼝耗时1.4s,如果只⽤romise.then来执⾏流程控制,可以保证三个接⼝按顺序调⽤结束再渲染⻚⾯,但是如果通过then函数的异步控制,必须等待每个接⼝调⽤完毕才能调⽤下⼀个,这样总耗时就是1+0.8+1.4 = 3.2s。这种累加显然增加了接⼝调⽤的时间消耗,所以Promise提供了⼀个all⽅法来解决这个问题:Promise.all([promise对象,promise对象,...]).then(回调函数)回调函数的参数是⼀个数组,按照第⼀个参数的promise对象的顺序展示每个promise的返回结果。我们可以借助Promise.all来实现,等最慢的接⼝返回数据后,⼀起得到所有接⼝的数据,那么这个耗时将会只会按照最慢接⼝的消耗时间1.4s执⾏,总共节省了1.8s,参考代码如下: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 {: '我是Promise的值'}ttt.html:18 Promise {}ttt.html:19 false//promise.all相当于统⼀处理了Promise.race()race⽅法与all⽅法使⽤格式相同:Promise.race([promise对象,promise对象,...]).then(回调函数)回调函数的参数是前⾯数组中最快⼀个执⾏完毕的promise的返回值。所以使⽤race⽅法主要的使⽤场景是什么样的呢?举个例⼦,假设我们的⽹站有⼀个播放视频的⻚⾯,通常流媒体播放为了保证⽤户可以获得较低的延迟,都会提供多个媒体数据源。我们希望⽤户在进⼊⽹⻚时,优先展示的是这些数据源中针对当前⽤户速度最快的那⼀个,这时便可以使⽤romise.race()来让多个数据源进⾏竞赛,得到竞赛结果后,将延迟最低的数据源⽤于⽤户播放视频的默认数据源,这个场景便是race的⼀个典型使⽤场景。下⾯我们可以参数考代码案例来查看race的介绍://多个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()相当于将传⼊的所有任务//进⾏了⼀个竞争,他们之间最先将状态变成fulfilled的//那⼀个任务就会直接的触发race的.then函数并且将他的值//返回,主要⽤于多个任务之间竞争时使⽤let p1 = new Promise((resolve,reject) => { setTimeout(() => { resolve('第⼀个promise执⾏完毕') },5000)})let p2 = new Promise((resolve,reject) => {Promise的演进在介绍了这么多Promise对象后,我们发现他的能⼒⼗分强⼤,使⽤模式⾮常的⾃由,并且将JavaScript⼀个时代的弊病从此“解套”。这个解套虽然⽐较成功,但是如果直接使⽤then()函数进⾏链式调⽤,我们的代码量仍然是⾮常沉重的,想要开发⼀个⾮常复杂的异步流程,依然需要⼤量的链式调⽤进⾏⽀撑,开发者还是会变得⾮常的难受。按照⼈类的线性思维,虽然JavaScript分同步和异步,但是单线程模式下,如果能完全按照同步代码的编写⽅式来处理异步流程,这才是最奈斯的结果,那么有没有办法让Promise对象能更进⼀步的接近同步代码呢?


Generator函数的介绍




在JavaScript中存在这样⼀种函数,我们先看⼀下这个函数的样⼦ES6 新引⼊了 Generator 函数,可以通过 yield 关键字,把函数的执⾏流挂起,为改变执⾏流程提供了可能,从⽽为异步编程提供解决⽅案。 所以他的存在提供了让函数可以进⾏分步执⾏的能⼒。举个栗⼦: 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)})function * fnName(){ yield *** 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)我们查看程序的注释并且运⾏该程序看控制台的结果:查看结果我们发现a和b的值不⻅了,c也是NaN虽然程序实现了分步执⾏,但是流程却出现了问题。这是因为在分步执⾏过程中,我们是可以在程序中对运⾏的结果进⾏⼈为⼲预的,也就是说yield返回的结果和他左侧变量的值都是我们可以⼲预的。接下来我们改造代码如下:}//获取分步执⾏对象var generator = test()//输出console.log(generator)//步骤1 该程序从起点执⾏到第⼀个yield关键字后,step1的value是yield右侧的结果1var step1 = generator.next()console.log(step1)//步骤2 该程序从var a开始执⾏到第2个yield后,step2的value是yield右侧的结果2var step2 = generator.next()console.log(step2)//由于没有yield该程序从var b开始执⾏到结束var step3 = generator.next()console.log(step3)test {}[[GeneratorLocation]]: ttt.html:10[[Prototype]]:Generator[[GeneratorState]]: "closed"[[GeneratorFunction]]: ƒ * test()[[GeneratorReceiver]]: Windowttt.html:21 {value: 1, done: false}ttt.html:12 undefinedttt.html:23 {value: 2, done: false}ttt.html:14 undefinedttt.html:16 NaNttt.html:25 {value: undefined, done: true}function * test(){ var a = yield 1 console.log(a) var b = yield 2 console.log(b) var c = a+b console.log(c)}var generator = test()console.log(generator)var step1 = generator.next()console.log(step1)var step2 = generator.next(step1.value)console.log(step2)var step3 = generator.next(step2.value)当我们将代码改造成上⾯的结构之后我们发现控制台中的数据就正确了:也就是说next函数执⾏的过程中我们是需要传递参数的,当下⼀次next执⾏的时候我们如果不传递参数,那么本次yield左侧变量的值就变成了undefined,所以我们如果想让yield左侧的变量有值就必须在next中传⼊指定的结果。Generator能控制什么样的流程?⾸先查看下列代码然后查看他的输出结果:console.log(step3)test {}ttt.html:21 {value: 1, done: false}ttt.html:12 1ttt.html:23 {value: 2, done: false}ttt.html:14 2ttt.html:16 3ttt.html:25 {value: undefined, done: true}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 generator = test()console.log(step)var step1 = generator.next()console.log(step1)var step2 = generator.next()console.log(step2)var step3 = generator.next()console.log(step3)var step4 = generator.next()console.log(step4)根据调⽤情况我们可以⾃⾏测试,会发现输出结果时并没有任何的延迟,并且我们观察打印输出会发现普通变量可以直接在value中拿到,setTimeout位置我们拿到的值和回调函数内部的值完全不⼀样,⽽romise对象我们可以拿到它本身。接下来我们展开查看Promise对象:我们发现Promise对象中是可以获取到内部的结果的,那么我们在Generator函数中能确保的就是,在分步过程中,能中使⽤romise和普通对象都能拿到运⾏流程的结果,但是JavaScript中的setTimeout我们还是⽆法直接控制它的流程。实现⽤Generator将Promise的异步流程同步化通过上⾯的观察,我们可以通过递归调⽤的⽅式,来动态的去执⾏⼀个Generator函数,以done属性作为是否结束的依据,通过next来推动函数执⾏,如果过程中遇到了Promise对象我们就等待Promise对象执⾏完毕再进⼊下⼀步,我们这⾥排除异常和对象reject的情况,封装⼀个动态执⾏的函数如下:test {}ttt.html:27 {value: 1, done: false}ttt.html:12 undefinedttt.html:29 {value: 1, done: false}ttt.html:16 undefinedttt.html:31 {value: Promise, done: false}ttt.html:22 undefinedttt.html:33 {value: undefined, done: true}{value: Promise, done: false}done: falsevalue: Promise[[Prototype]]: Promise[[PromiseState]]: "fulfilled"[[PromiseResult]]: 456[[Prototype]]: Object/*** fn:Generator函数对象*/function generatorFunctionRunner(fn){ //定义分步对象 let generator = fn() //执⾏到第⼀个yield let step = generator.next() //定义递归函数 function loop(stepArg,generator){ //获取本次的yield右侧的结果 let value = stepArg.value //判断结果是不是Promise对象 if(value instanceof Promise){ //如果是Promise对象就在then函数的回调中获取本次程序结果 //并且等待回调执⾏的时候进⼊下⼀次递归 value.then(function(promiseValue){有了这个函数之后我们就可以将最初的三个setTimeout转换成如下结构进⾏开发当我们通过上⾯的运⾏⼯具函数之后我们就可以在控制台看⻅每间隔1秒钟就输出⼀次 if(stepArg.done == false){ loop(generator.next(promiseValue),generator) } }) }else{ //判断程序没有执⾏完就将本次的结果传⼊下⼀步进⼊下⼀次递归 if(stepArg.done == false){ loop(generator.next(stepArg.value),generator) } } } //执⾏动态调⽤ loop(step,generator)}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)}generatorFunctionRunner(test)第⼀秒运⾏ttt.html:22 第⼆秒运⾏ttt.html:28 第三秒运⾏经过这个yield修饰符之后我们惊喜的发现,抛去generatorFunctionRunner函数外,我们在Generator函数中已经可以将Promise的.then回调成功的规避了,yield修饰的Promise对象在运⾏到当前⾏时,程序就会进⼊挂起状态直到Promise对象变成完成状态,程序才会向下⼀⾏执⾏。这样我们就通过Generator函数对象成功的将Promise对象同步化了。这也是JavaScript异步编程的⼀个过渡期,通过这个解决⽅案,只需要提前准备好⼯具函数那么编写异步流程可以很轻松的使⽤yield关键字实现同步化。


关于Async和Await




经过了Generator的过渡之后异步代码同步化的需求逐渐成为了主流需求,这个过程在ES7版本中得到了提案,并在ES8版本中进⾏了实现,提案中定义了全新的异步控制流程。查看代码结构之后我们发现他的编写⽅式与Generator函数结构很相似,提案中规定了我们可以使⽤async修饰⼀个函数,这样就能在该函数的直接⼦作⽤域中,使⽤await来⾃动的控制函数的流程,await 右侧可以编写任何变量或对象,当右侧是普通对象的时候函数会⾃动返回右侧的结果并向下执⾏,⽽当await右侧为Promise对象时,如果Promise对象状态没有变成完成,函数就会挂起等待,直到Promise对象变成fulfilled,程序再向下执⾏,并且Promise的值会⾃动返回给await左侧的变量中。async和await需要成对出现,async可以单独修饰函数,但是await只能在被async修饰的函数中使⽤。有了await和async就相当于使⽤了⾃带执⾏函数的Generator函数,这样我们就不再需要单独针对Generator函数进⾏开发了,所以async和await逐渐成为主流异步流程控制的终极解决⽅案。⽽Generator慢慢淡出了业务开发者的舞台,不过Generator函数成为了向下兼容过渡期版本浏览器的候补实现⽅式,虽然在现今的⼤部分项⽬业务中使⽤Generator函数的场景⾮常的少,但是如果查看脚⼿架项⽬中通过babel构建的JavaScript⽣产代码,我们还是能⼤量的发现Generator的应⽤的,他的作⽤就是为了兼容不⽀持async和await的浏览器。认识async函数创建如下函数,执⾏并查看控制台输出:输出控制台如下://提案中定义的函数使⽤成对的修饰符async function test(){ await ... await ...}test()async function test(){ return 1}let res = test()console.log(res)Promise {: 1}[[Prototype]]: Promise[[PromiseState]]: "fulfilled"[[PromiseResult]]: 1根据控制台结果我们发现其实async修饰的函数,本身就是⼀个Promise对象,虽然我们在函数中return的值是1,是使⽤了async修饰之后,这个函数运⾏时并没有直接返回1,⽽是返回了⼀个值为1的Promise对象。接下来我们测试如下流程,先分析运⾏结果,猜测输出的顺序:执⾏该流程之后发现输出的结果是1,3,2。很惊喜是不是!按照Promise对象的执⾏流程function被async修饰之后它本身应该变成异步函数,那么他应该在1和2输出完毕之后在输出3,但是结果却出⼈意料,这⼜⼀次打破了单线程异步模型的概念。别急,冷静⼀下,先回想⼀下Promise对象的结构:我们在介绍Promise对象时,特别介绍了⼀下回调函数,并且强调他是⼀个极少数的既使⽤同步回调流程⼜使⽤了异步的回调流程的对象,所以在new Promise时的function是同步流程。现在介绍这个和刚才的输出有关系吗?当然有,接下来查看下⾯的逻辑,还是先猜测⼀下输出顺序:我们发现奇怪的事情⼜发⽣了,控制台输出的顺序是1,3,2,4按照我们⼀开始以为的流程,test函数应该是同步逻辑,那么3和4应该是连着输出的,他不应该会出现3在2之前4在2之后输出的情况,这个同步逻辑和异步逻辑都说不过去,那么我们将当前的函数翻译⼀下,由于async修饰的函数会被解释成Promise对象,所以我们可以将其翻译成如下结构:async function test(){ console.log(3) return 1}console.log(1)test()console.log(2)new Promise(function(){}).then(function(){})async function test(){ console.log(3) var a = await 4 console.log(a) return 1}console.log(1)test()console.log(2)看到这个Promise对象我们就豁然开朗,由于初始化的回调是同步的所以1,3,2都是同步代码⽽4是在resolve中传⼊的,then代表异步回调所以4应该最后输出。综上所述,async函数中有⼀个最⼤的特点,就是第⼀个await会作为分⽔岭⼀般的存在,在第⼀个await的右侧和上⾯的代码,全部是同步代码区域相当于new Promise的回调,第⼀个await的左侧和下⾯的代码,就变成了异步代码区域相当于then的回调,所以就出现上⾯我们发现的灵异问题。最终的setTimeout解决代码经过了两个时代的变⾰,现在我们可以使⽤如下的⽅式来进⾏流程控制,不再需要依赖⾃⼰定义的流程控制器函数来进⾏分步执⾏,这⼀切的核⼼起源都是Promise对象的规则定义开始的,所以最终我们的解决⽅案如下。总结console.log(1)new Promise(function(resolve){ console.log(3) resolve(4)}).then(function(a){ console.log(a)})console.log(2)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对象本身以及他的发展历程是否有深⼊的了解,同时也是在考察⾯试者对JavaScript的事件循环系统和异步编程的基本功是否⾜够的扎实。Promise和事件循环系统并不是JavaScript中的⾼级知识,⽽是真正的基础知识,所以所有⼈想要在⾏业中更好的发展下去,这些知识都是必备基础,必须扎实掌握。我们未来对⾃⼰的定位是软件开发/研发⼯程师,并不是码农
回复 支持 反对

使用道具 举报

1272

主题

2067

帖子

7962

积分

认证用户组

Rank: 5Rank: 5

积分
7962
地板
 楼主| 发表于 2022-2-18 22:23:25 | 只看该作者
面试宝典(必会面试高频)3(5)(1).docx
面试题.md
面试题专题.doc
外派阿里前端面试题库(1).doc
中高级面试总结.doc


链接:https://pan.baidu.com/s/1_ggvAL90AhGawb5YF7lPnw
提取码:fire

回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

QQ|Archiver|手机版|小黑屋|firemail ( 粤ICP备15085507号-1 )

GMT+8, 2024-11-26 08:02 , Processed in 0.075555 second(s), 21 queries .

Powered by Discuz! X3

© 2001-2013 Comsenz Inc.

快速回复 返回顶部 返回列表