前端进阶:循环系统和异步编程

456 阅读8分钟

我的第一篇文章中分享了浏览器的大概工作原理,第二篇文章分享了其中渲染进程中的JavaScript引擎的工作原理。本篇文章,我要分享一下在JavaScript中,事件循环系统和异步编程的相关知识。

浅析事件循环

带着问题学习,想要更好的知道什么是事件循环就要知道为什么需要事件循环。

单线程

我假设阅读这篇文章的同学们已经知道了什么是线程或者已经阅读过我的前两篇文章。我们知道JavaScript是单线程的语言。

如果我们在代码中指定了几个任务,当这些任务执行完毕后,线程就关闭了。

此时我们的疑问就出现了,在这之后呢?如果这个时候我们要输入一些文本,点击某些组件怎么办呢?答案是不允许,不支持。

循环的加入

所以这种直线式的任务模型肯定是不可用的。那我们如果想在执行的过程中加入新的任务怎么办呢?

监听,我们可能会想到这个词。这个思路是正确的,接下来我们就需要思考一下怎么能够实现监听了。

其实所有的监听背后都站着循环。所以,要想不间断的处理新的任务就必须让整个线程保持住,循环起来。有了循环之后,还需要一个机制来管理新加入的任务,任务总要分一个优先级的。

消息队列

如果做过后端开发的同学对这个概念应该很熟悉,消息队列是处理高并发场景常用的手段。不熟悉的同学只要知道,是一个队列的数据结构就可以了。

在浏览器中,还有一个IO线程,专门用来接收新的用户或者系统事件,处理成任务然后加入消息队列中。

image.png

到此为止,这个解决思路是不是已经很神奇又完美了呢

事实上不是这样的,有两个问题难住了这个模型

  1. 任务总是要分优先级的,这是我们刚才说过的。但是现在只是将任务简单的按照事件顺序存放在队列中,并没有体现出优先级。
  2. 定时任务这种任务类型根本没办法存放在这个队列中,因为他是定时的。

带着这两个问题,我们在看引擎给出了什么样的解决方案。

定时任务

我们先来看第二个问题,他的解决思路比较简单。既然没法放到消息队列中去,就在创建一个消息队列。这样,两个消息队列并列,交替处理。 如下面的伪代码所示:

queue: common-queue;
queue: delay-queue;
for(;;){
    common-task = common-queue.getTask();
    processTask(common-task);
    delay-task = delay-queue.checkTask();
    if(delay-task){
        processTask(delay-task);
    }
}

这样,通过双队列,相对完美解决了定时任务的问题。但是,毕竟是单线程的硬伤,一旦某个任务处理的太耗时,势必会引起定时任务的延误,甚至页面的卡顿。这也就是为什么后续会出现webWork的原因吧。

现在还剩下第一个问题,如果普通的任务就是需要一个高优先级怎么办呢?首先,把优先级权利下放给用户的话,肯定是别想了,因为这样的话会引起很多不确定的问题,太不安全了。

如果沿用上面的思路,把高优的任务放在一个单独的队列中,是不是也就完美解决了呢?思路是这样,不过有一点问题。高优任务和普通任务有什么区别呢?是不是现在变成了三个队列交替处理了呢?

肯定不可以这样做,高优任务肯定是在普通任务之前处理的。所有的高优任务,都要在普通任务之前。 于是,这些高优先级任务就有了一个特殊的身份,叫做微任务。

微任务

有了微任务,相对的除了微任务之外的任务,不管在哪个队列中,他都叫做宏任务。就好像,实验班只有一个或者几个,而普通班有很多一样。

微任务优先级很高,高到什么程度呢?高到每次处理完一个宏任务,就要赶紧检查是不是微任务队列有新的任务了,如果又了,就要把所有微任务都处理完,在去处理下一个宏任务。如果在处理微任务的同时,又出现了一个微任务,那这个微任务也要插队到下一个宏任务之前。就是只有一个微任务队列,这个队列遇见一次就要清空一次,而且不锁定,随时可以添加任务。

以上就是事件循环的基本原理了

异步编程

异步编程的实现,和循环也是有很大的关系的。

仍然是带着问题出发,为什么要有异步编程呢?举个小🌰,在Ajax出现之前,我们只能通过刷新页面来更新页面内容,这个已经是大家都普遍接受的了。

正是因为循环系统的成熟,使"监控"得以实现。正如第一篇文章中提到的,浏览器将网络进程单独拿了出去。于是,使得网络请求的结果可以作为一个单独的任务而又非阻塞的加入到事件循环队列中。

这就是异步编程的由来和实现思路。

Promise

假设同学们已经都知道了Promise是什么,我们来看一下为什么需要Promise。

我们刚才说的Ajax,已经解决了页面数据异步更新的问题。它很伟大,但是很无奈,他只能符合特定时代的需求。

到了新时代,人们开始像厌恶马车一样厌烦Ajax。至少,回调地狱简直是反人性的。

是时候出现一种新的编程思路,来代替烦人的回调嵌套了。

于是,Promise出现了,它不仅解决了嵌套回调的问题,还有了一个额外的惊喜,统一处理错误机制。

我们一段介绍Promise核心原理的代码:

function Promise(exector){
    var onResolve_ = null;
    var onReject_ = null;
    this.then = function(resolveDl,rejectDL) {
        onResolve_ = resolveDl;
    }
    function resolve(value){
        setTimeout(function(){
        onResolve_(value);
        });
    }
    exector(resolve,null)
}

上述,就是Promise最核心的解决回调函数的实现,通过延迟执行resolve的方式,使得我们可以在callback之外处理这个任务。 可是,我们在本该做处理的地方,却延迟了处理,为了减少影响,就把我们代码中的定时器换成了微任务。

async/await

自从有了Promise,思路也就被打开了。人们又开始挑剔Promise的then调用,破坏了代码总体的逻辑性和可读性。所以又开始寻找更加和谐的处理方式,基于此,JavaScript中出现了一个新的概念,叫做协程。

协程

我们可以这样理解协程,双人自行车,大家应该都见过。这个自行车的比喻虽然不够完美,但是我当下能想到的最好的解释。

假设两个人骑双人自行车参加比赛,其中只允许一个人蹬,不能两个人同时出力。这时候,突然一个人鞋带开了,于是他要去系鞋带,于是小伙伴就要开始骑车了。他很偷懒,他系鞋带系得很慢,左边好了在系右边鞋带。小伙伴知道他偷懒,所以等他完事,就又让他出力了。

大概,就是这样一个双人骑车的模型。

最开始实现这个模型的是生成器函数。

Generator

生成器函数的结构大概长这样:

function* generator(){
    let before = yeild 'before';
    let after = yeild 'after';
}

我们看一下他是如何延迟的

var delay = generator()
console.log(delay.next().value)
console.log('这里不是generator')
console.log(delay.next().value)

这就是生成器的用法了,JavaScript的协程即体现在生成器上。 这样,我们可以把异步函数写在生成器的内部,再通过生成器不断的调用next函数,就实现了在生成器内部‘同步’调用的功能了。也不会影响到其他功能。

但是生成器的next的不断调用,还是需要实现的,这就是执行器。

大家又觉得执行器好麻烦,于是,我们想要说的主角:async/await兄弟就上场了。 我们来看下他们是怎么配合的:

async function foo(){
    console.log(1);
    let a = await 100;
    console.log(a);
    console.log(2);
}
console.log(0);
foo();
console.log(3);

在await执行时,后面的逻辑会被封装成为一个promise,在生成微任务的同时会将线程的调用返回给父协程,然后当微任务被执行的时候,会再次将调用权返回给子协程。

总结

本文我们了解了浏览器的事件循环机制,通过事件循环的逐步深入知道了什么是微任务,在结合事件循环了解了异步编程的逐步优化。最后,微任务和异步编程(协程)十分巧妙的结合在了我们都很喜欢的async/awaitAPi上。

真的佩服发明出这些概念的大佬们。希望大家通过这样一篇比较科普的文章能够查漏补缺。