深入浅出JavaScript异步编程
深入浅出JavaScript异步编程
随着移动互联网基础网速的飞速提升和各种设备硬件的革命性升级,人们对web应用功能的期待越来越高,浏览器性能因浏览器内核的革命性升级得到飞速提升,受浏览器性能制约的前端技术也迎来飞速发展。正如Atwood定律所言:“凡是可以用 JavaScript 来写的应用,最终都会用 JavaScript 来写。”的确,现在的前端技术涉足领域广泛,有web应用开发、服务端开发、PC桌面程序开发、移动APP开发、IDE开发、CLI工具开发及工程化流程工具开发等。但随着前端技术日新月异的发展,JavaScript中的异步编程弊病问题也越来越明显地暴露出来,异步编程问题的解决方案也在快速的迭代优化。
本文将为大家解答以下疑问:什么是异步编程?为什么浏览器下会有异步编程?异步回调有哪些问题?如何解决异步回调问题?浏览器支撑的新方案的原理?
1.什么是异步编程
异步和同步对应,异步编程即处理异步逻辑的代码,JavaScript中最原始的就是使用回调函数。所以,我们只要理清同步回调和异步回调的区别,就可以理解什么是异步编程了。
请先看同步回调示例:
执行顺序2、1、3,先输出1后输出3,可见,同步回调:回调函数callback是在主函数dowork返回之前执行的。
再看异步回调示例:
先输出3后输出1,可见,异步回调:回调函数并没有在主函数内部被调用,而是在主函数外部执行,主函数返回后才执行。
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是在主线程上,属于宏任务内,按顺序先执行;
2、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源码解析(一、二) - 知乎

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
React入门 | 雪狼逐,雪狼亡,握刀寻鹿终日忙
[TOC] React 了解 React 是一个用于构建用户界面的 JAVASCRIPT库。 React 主要用于构建UI,可以理解为React 是 MVC 中的V(视图)。 React 起源于 Facebook 的内部项目,用来架设 Instagram 的网站。 React 拥有较高的性能,代码逻辑较为简单。 React 特点 1.声明式设计 —React采用声明范式,可以轻松描述应用。 2.高效 —React通过对DOM的模拟,最大限度地减少与DOM的交互 3.灵活 —React可以与已知的库或框架很好地配合。 4.JSX —JSX是 JavaScript 语法的扩展。React开发不一定使用 JSX,官方建议使用。 5.组件 —通过React 构建组件,是的代码更加容易得到复用,能够很好的应用在大项目的开发中。 6.单向响应的数据流 —React实现了单向响应的数据流,从而减少了重复代码,这也是它为什么比传统数据绑定更简单。 快速构建一个React开发环境 1、创建本地文件夹,保存React项目 2、通过控制台输入 npm install -g create-react-app ...
- 下一篇
给我五分钟,带你彻底掌握 MyBatis 缓存的工作原理
前言 在计算机的世界中,缓存无处不在,操作系统有操作系统的缓存,数据库也会有数据库的缓存,各种中间件如Redis也是用来充当缓存的作用,编程语言中又可以利用内存来作为缓存。自然的,作为一款优秀的ORM框架,MyBatis中又岂能少得了缓存,那么本文的目的就是带领大家一起探究一下MyBatis的缓存是如何实现的,只需给我五分钟,带你彻底掌握MyBatis的缓存工作原理。 为什么要缓存 在计算机的世界中,CPU的处理速度可谓是一马当先,远远甩开了其他操作,尤其是I/O操作,除了那种CPU密集型的系统,其余大部分的业务系统性能瓶颈最后或多或少都会出现在I/O操作上,所以为了减少磁盘的I/O次数,那么缓存是必不可少的,通过缓存的使用我们可以大大减少I/O操作次数,从而在一定程度上弥补了I/O操作和CPU处理速度之间的鸿沟。而在我们ORM框架中引入缓存的目的就是为了减少读取数据库的次数,从而提升查询的效率。 MyBatis缓存 MyBatis中的缓存相关类都在cache包下面,而且定义了一个顶级接口Cache,默认只有一个实现类PerpetualCache,PerpetualCache中是内部维...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- CentOS6,CentOS7官方镜像安装Oracle11G
- Hadoop3单机部署,实现最简伪集群
- SpringBoot2整合MyBatis,连接MySql数据库做增删改查操作
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- SpringBoot2整合Redis,开启缓存,提高访问速度
- Windows10,CentOS7,CentOS8安装Nodejs环境
- MySQL8.0.19开启GTID主从同步CentOS8
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长