从事件循环谈异步操作

22 阅读4分钟

异步的来源

每个高级语言都有它的运行环境,js也一样,它无法直接操作硬件。

在一开始js只能运行在浏览器,直到借助浏览器内核实现的NodeJS出现,才可以作为服务器语言存在,使得每个js开发者都是潜在的全栈开发者。二者在底层上极其相似,搞清楚浏览器环境的事件循环也就明白NodeJS的事件循环了。

现在假设我们是js这门语言的创建者(可以搜索一下js诞生的历史背景)。

我们只需要一个简单的脚本语言,要求是快速可落地,简单操作一下表单按钮可以交互,对于开发者足够简单友好。

多线程不能考虑,对于开发js和js的使用者都太“重”了,那么决定使用单线程了。

但是单纯的单线程也不可能,浏览器注定要发起网络请求的,如果请求一个网址长时间无法返回结果,这段等待的时间,js都无法执行了。所以还是要支持一些异步操作的。像是计时器,事件监听等。

应该怎么实现呢?在异步线程执行代码,上下文怎么办?在主线程执行,阻塞问题怎么办?

经过考量,最后还是决定在主线程执行,但是不会一直等待,而是等待执行条件正确的时候,加入到任务队列。

任务队列

任务队列按照js的书写顺序(同步操作),在代码解析(还没有执行)的时候就确定的队列。按照先进先出的原则,按顺序执行。

出于异步操作的不确定性,只有执行的时候才知道什么时候触发回调。当异步操作要执行回调的时候会将任务添加进入任务队列的末尾。假设一个计时器设定是1s触发回调,但是计时器之后的同步代码耗时超过了1秒,虽然从开发者的角度,应该在1s后立刻触发,但是实际上会在前面的同步操作执行完毕之后执行。

回调地狱

好了,现在我们有任务队列了和异步操作了,应该够用了。

但是任谁也想不到在互联网快速发展的几年,用户对页面的要求越来越高,js的逻辑越来越复杂,面对一些复杂的异步操作,比如点击一个按钮触发一个计时器,只能在事件监听回调里触发计时器回调。也许还不够,我们还想在计时结束后执行网络请求。大量的回调嵌套会降低代码可读性,带来编写和维护上的困难,这就是俗称的“回调地狱”。

我们需要一个可以摆脱大量嵌套的办法!js社区提供了一些想法,在不断完善中,ECMA制定了Promise A+规范。终于,在ES6(2015年),各大浏览器厂商可以通过这个规范实现自己的Promise api。同时期的fetch api也是基于Promise设计。

Promise的出现解决了回调的大量嵌套问题,转而使用链式结构调用,看起来确实清爽很多,但是依然不具有同步代码的可读性,有没有更好的办法来使用异步操作呢?

  • Tip: async/await语法糖:在ES8,一个新的语法糖吸引了广大开发者视野,通过迭代器和promise,异步操作也可以做到和同步操作一样的可读性。只需要在async函数中,在异步函数前使用await,就可以阻塞当前函数,直到promise执行完毕才会执行下一行代码。

好了,到现在,我就已经拥有最现代的js异步语法,但是还有一些有趣的异步操作,我认为需要补充一下。

每一帧的动画 requestAnimationFrame

在浏览器中,为了用户可以第一时间看到界面(而不是苦苦等待所有任务队列的任务执行完毕之后绘制),js执行和界面绘制是交替的(js执行→布局→绘制→合成),在每一个浏览器帧中,先执行js再执行绘制。在每个浏览器帧的末尾会触发requestAnimationFrame的回调,用户可以解决定时器的卡顿(setTimeout有一个4ms左右的最小触发时间,哪怕第二个参数设为0也会有这个限制),优化统一帧率,比如确保不稳定的浏览器帧强制降低为稳定60hz/s,这对于某些动画来说很重要。

每一帧的性能优化 requestIdleCallback

在js执行和绘制结束的一帧可能会出现空闲时间,可以通过这个api利用这些时间,不过要确保使用这个api的回调是低优先级的,否则可能出现长时间不执行的情况。