深刻浅出JavaScript异步编程
随着移动互联网基础网速的飞速提高和各类设备硬件的革命性升级,人们对web应用功能的期待愈来愈高,浏览器性能因浏览器内核的革命性升级获得飞速提高,受浏览器性能制约的前端技术也迎来飞速发展。正如Atwood定律所言:“凡是能够用 JavaScript 来写的应用,最终都会用 JavaScript 来写。”的确,如今的前端技术涉足领域普遍,有web应用开发、服务端开发、PC桌面程序开发、移动APP开发、IDE开发、CLI工具开发及工程化流程工具开发等。但随着前端技术突飞猛进的发展,JavaScript中的异步编程弊病问题也愈来愈明显地暴露出来,异步编程问题的解决方案也在快速的迭代优化。前端
本文将为你们解答如下疑问:什么是异步编程?为何浏览器下会有异步编程?异步回调有哪些问题?如何解决异步回调问题?浏览器支撑的新方案的原理?web
1.什么是异步编程
异步和同步对应,异步编程即处理异步逻辑的代码,JavaScript中最原始的就是使用回调函数。因此,咱们只要理清同步回调和异步回调的区别,就能够理解什么是异步编程了。ajax
请先看同步回调示例:编程
执行顺序二、一、3,先输出1后输出3,可见,同步回调:回调函数callback是在主函数dowork返回以前执行的。promise
再看异步回调示例:浏览器
先输出3后输出1,可见,异步回调:回调函数并无在主函数内部被调用,而是在主函数外部执行,主函数返回后才执行。babel
2. 为何浏览器下有异步编程
Chrome下的异步编程模型,以下图:架构
浏览器渲染进程中的渲染流水线主线程是单线程的,主线程发起耗时任务,交给其余进程执行,等处理完后,会将该任务添加到渲染进程的消息队列中,并排队等待循环系统的处理。排队结束以后,循环系统会取出消息队列中的任务进行处理,触发相关的回调操做,并将任务交给另外一个进程去处理,这时页面主线程会继续执行消息队列中的任务。并发
浏览器设计时,最初选择了单线程架构,结合事件循环和消息队列的实现方式,咱们在JavaScript开发中,也会常常遇到异步回调。框架
而异步回调,影响了咱们的编码方式,咱们必须直面异步回调中的一些问题。
3. 异步回调有什么问题
若是咱们一直选择使用异步回调编写代码,当面临复杂的应用需求,如遇到有依赖关系的异步逻辑或者发送ajax请求时,则会较为麻烦。
看个示例:
这段代码能够正常执行,可是里面却执行了5次回调。
这么多的回调会致使代码的逻辑不连贯、不线性,很是不符合人的常规思惟,也即异步回调影响到了咱们的编码方式。
遇到这种状况,咱们一般能够封装异步代码,下降处理异步回调次数,让处理流程变得线性,如jQuery的$.ajax就是这么作的。
这样作,虽然在一些简单的场景下运行效果也很是好,但遇到很是复杂的场景时,嵌套了太多的回调函数就很容易使本身陷入回调地狱。
好比:
这是一个典型的多层嵌套ajax请求的场景,这时回调地狱问题就暴露无疑了,由于这段代码逻辑不连续,让人感到凌乱。
此时,总结异步回调问题,以下:
- 嵌套调用,层层嵌套,层次多了代码可读性差了。
- 任务的不肯定性,如上方ajax请求,总会有成功或者失败,每一层的任务都有判断逻辑和错误处理逻辑,这样就让代码更加混乱了。
4. 解决异步回调问题的方案
想解决异步编程问题,要考虑的是:一是消灭回调,二是合并错误判断和处理。
目前较好的解决方案有:Promise和Async/await
Promise示例:
代码清晰了,Promise 使用回调函数延迟绑定解决了回调函数嵌套的问题,如p1.then,p2.then等,这即是同步编码的风格了。
Promise的回调函数返回值有穿透到最外层的性质,具体到错误处理的场景,就是说对象的错误具备“冒泡”的性质,会一直向后传递,直到被 onReject 函数处理或 catch 语句捕获为止,这样就把错误判断和处理逻辑合并了。
Promise方案,虽然实现了同步风格编程,可是里面包含了大量的then函数,让代码仍是不太容易阅读。
如下为Async/await示例:
咱们想要输出2之后再输出3,虽然xs函数是异步的,可是咱们的写法是同步的,代码逻辑是连续的,这样代码就更加清晰可读了。
5. 从浏览器原理分析Promise原理
Promise是V8引擎提供的,因此暂时看不到 Promise 构造函数的细节。V8 在Promise 中使用微任务,来实现回调函数的延迟绑定。
微任务是V8提供的,当前宏任务执行的时候,V8会为其建立一个全局执行上下文,V8引擎也会在内部建立一个微任务队列,宏任务执行过程当中产生的微任务都会放入微任务队列。
当前宏任务中的 JavaScript 快执行完成时,也即在 JavaScript 引擎准备退出全局执行上下文并清空调用栈的时候,JavaScript 引擎会检查全局执行上下文中的微任务队列,而后按照顺序执行队列中的微任务。
若是在执行微任务的过程当中,产生了新的微任务,一样会将该微任务添加到微任务队列中,V8 引擎一直循环执行微任务队列中的任务,直到队列为空才算执行结束。
浏览器执行宏任务、微任务和渲染的循环顺序是,宏任务、该宏任务的微任务队列、渲染,再执行消息队列中下个宏任务、该宏任务的微任务队列、渲染,如此循环执行。
综上可知,从本质和浏览器原理来讲, js实现异步回调的方式能够有两种:
- 把回调函数添加到(消息队列)宏任务队列内,当执行完当前宏任务和它的微任务队列后,等合适的时机或者可执行代码容器空闲时执行。如setTimeout延迟任务和ajax异步请求任务。
- 把回调函数添加到当前宏任务的微任务队列,等待当前宏任务执行结束前,依次执行。
咱们猜想模拟实现个Promise,说明为什么要用微任务。
这里,咱们没有用异步回调,而是同步回调,可是回调函数仍是延迟绑定,这样执行时就会报错,由于咱们同步调用回调时,回调函数还没绑定。
若是此时resolve改成使用宏任务队列的异步回调setTimeout,虽然能够实现功能,可是执行回调的时机会被延迟,代码执行效率则被下降。
因此,v8采用微任务实现promise,是为了在方便开发与执行效之间寻找到一个完美的平衡。
6. 生成器与协程
生成器Generator是v8提供的,生成器函数是一个带星号函数,并且是能够暂停执行和恢复执行的。底层实现机制是协程(Coroutine)。
看个生成器的例子:
执行结果为:
执行生成器函数,并不执行函数内代码,而是返回一个对象引用,可赋值给外部函数的变量。外部函数经过变量对象的next 方法开始执行生成器函数的内部代码;在生成器函数内部执行一段代码时,若是遇到 yield 关键字,那么 JavaScript 引擎将返回关键字后面的内容给外部,并暂停该生成器函数的执行;外部函数经过next().value得到生成器函数的返回值。外部函数能够经过 next 方法再次恢复生成器函数的执行。以此类推执行。
没有 yield时,遇到return时,也暂停生成器函数的执行,这里应该说是回收调用栈,结束函数更准确,而不是暂停。
V8 是如何实现一个函数的暂停和恢复的?
这里涉及到协程的概念,协程比线程更轻量,协程当作是跑在线程上的任务,一个线程上能够存在多个协程,可是在一个线程上同时只能执行一个协程。可是,协程不是被操做系统内核所管理的,而彻底是由程序所控制。这样,性能就有了很大的提高,不会像线程切换那样消耗资源。
yield 和 .next切换生成器函数的暂停和恢复,其实就是在关闭和开启生成器函数对应的子协程,子协程和父协程在主线程上交互执行,并不是并发执行的。在切换父子协程时,关闭前都会先保存当前协程的调用栈信息,以便再次开启时,继续执行。因此,从浏览器角度看,生成器的底层实现是协程。
7. co框架的原理,Promise与生成器的结合
生成器函数能够理解成一个异步操做的容器,它装着一些异步操做,但并不会在实例化后当即执行。而co的思想是在恰当的时候执行这些异步操做。在一个异步操做执行完毕之后通知下一个异步操做开始执行,须要依靠回调函数或者promise来实现。因此,co要求生成器函数里yield的是thunk(回调机制)或者promise。
咱们把执行生成器的代码封装成一个函数,并把这个执行生成器代码的函数称为执行器。co框架就是个执行器。
promise结合生成器函数的实现示例:
run2是执行器,也是co框架的源码里面的promise回调机制实现的原理。
8. 从协程和微任务看Async/await
async/await 技术背后的秘密就是 Promise 和生成器应用,往低层说就是微任务和协程应用。MDN 定义,async 是一个经过异步执行并隐式返回 Promise 做为结果的函数。
可见async 执行完,v8让它返回的是一个Promise。
Async/await在一块儿会发生什么?
先执行3,后输出a和2,这就体现了异步。
上面的await "我会被放入await返回proimse的executor内"会被v8处理为:
可见,其实await 代码,会默认返回promise,若是await后面的是个值,值会直接做为resolve函数的参数内容并调用resolve,返回promise;若是在await后面加个函数,则须要返回promise,如接个async function(){},这就对应了前文,async函数执行默认返回了promise。
同时,把后续代码,做为await 返回promise的回调函数延迟绑定了,由于使用了微任务实现了延迟绑定,因此回调也就是后续代码被放到了微任务队列,因此会异步执行。
咱们从协程角度,分析上面代码的执行原理。
调用hai函数,开启hai函数的子协程;
执行输出1;
遇到await,把后续代码加入promise的回调函数,其实进入了微任务队列。同时,把resolve函数结果值返回给a;
这时,暂停子协程,控制权给主线程;
主线程执行输出3;
主线程执行结束前,查看微任务队列,发现有微任务,也就是上面加入的,执行微任务;
执行微任务,立马恢复子协程,执行输出a和2。
执行完毕,关闭子协程,控制权交给主线程。
因此,才有了上面的执行结果。
综合分析async/await:
输出顺序是:
这里关键点是:
4是在主线程上,属于宏任务内,按顺序先执行;
二、1都是在字协程内执行,其中2所在的协程是1所在协程的父协程,可是都是在当前宏任务阶段执行;这里涉及了主线程、父协程、子协程的关闭交互。
bar 内的await把3加入了微任务队列,因此在当前宏任务执行完后才执行;
6和8 是在主线程上,属于宏任务内,按顺序执行,7在6所在的Promise内的延迟回调内,这时加入了微任务队列,比3加入的晚,因此7晚于3。
执行3时,处于微任务阶段,开启了子协程;
执行7时,处于微任务阶段,又关闭了子协程,控制权在主线程。
5是延迟函数,延迟任务,属于下一个宏任务,因此会在当前微任务执行完,才执行写个宏任务。
9. 总结
浏览器是基于单线程架构实现的,JavaScript编程中常常遇到异步回调,异步回调函数存在回调地狱问题,让代码混乱,可维护性差。
ES新标准推出了Promise来让咱们方便的编写异步回调代码,让代码保持线性同步的风格。
浏览器基于微任务实现了Promise,基于协程实现了生成器。
为更好地优化异步回调的可读性,开发者们尝试了Promise与生成器结合使用的方式。为方便使用这种结合方式,开发者们把执行生成器的代码封装起来做为执行器,著名的co框架就是在这个思路下产生的。
后来,ES7标准规范化了Promise与生成器结合使用的方式,并优化为async/await标准,现代浏览器也陆续按这个规范实现async/await。
目前,来自ES7的标准的async/await是处理JavaScript异步编程的最佳实践,未来会受到全部浏览器的支持,对于不支持async/await的浏览器,可使用babel处理兼容。
async/await是编程领域很是大的一个革新,也是将来的一个主流的编程风格,它能让代码美观整洁,又必定返回promise,其余语言如Python也引入了async/await。
做者:李鑫海
指导老师:杨朋飞
参考书目:
1. Babel · The compiler for next generation JavaScript
2. 极客时间,李兵《浏览器工做原理与实践》
3. Async-Await ≈ Generators + Promises – Hacker Noon
4. Co-实现原理分析 - 柒青衿的博客 - CSDN博客
5. 从协程到状态机–regenerator源码解析(1、二) - 知乎
版权归做者全部,任何形式转载请联系做者。
it_hr@zybank.com.cn