深入浅出JavaScript异步编程 - Go语言中文社区

深入浅出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请求的场景,这时回调地狱问题就暴露无疑了,因为这段代码逻辑不连续,让人感到凌乱。

此时,总结异步回调问题,如下:

  1. 嵌套调用,层层嵌套,层次多了代码可读性差了。
  2. 任务的不确定性,如上方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实现异步回调的方式可以有两种:

  1. 把回调函数添加到(消息队列)宏任务队列内,当执行完当前宏任务和它的微任务队列后,等合适的时机或者可执行代码容器空闲时执行。如setTimeout延迟任务和ajax异步请求任务。
  2. 把回调函数添加到当前宏任务的微任务队列,等待当前宏任务执行结束前,依次执行。
    我们猜测模拟实现个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_hr@zybank.com.cn

版权声明:本文来源CSDN,感谢博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/zybank_IT/article/details/109679906
站方申明:本站部分内容来自社区用户分享,若涉及侵权,请联系站方删除。

0 条评论

请先 登录 后评论

官方社群

GO教程

猜你喜欢