由LazyMan引发的对js事件循环的思考

网上看过一道JavaScript试题:如何实现一个Lazyman?(本人第一次看到这个题目时心想什么是LazyMan?一脸懵逼~)

题目具体是这样的:
实现一个LazyMan,可以按以下方式调用:
LazyMan(“Hank”)输出:
Hi! This is Hank!
LazyMan(“Hank”).sleep(10).eat(“dinner”)输出
Hi! This is Hank!
//等待10秒..
Wake up after 10
Eat dinner~
LazyMan(“Hank”).eat(“dinner”).eat(“supper”)输出
Hi This is Hank!
Eat dinner~
Eat supper~
LazyMan(“Hank”).sleepFirst(5).eat(“supper”)输出
//等待5秒
Wake up after 5
Hi This is Hank!
Eat supper
以此类推。
这是典型的JavaScript  流程控制。思考下函数的调用过程和输出过程不难发现,该题目主要是考查我们如何实现任务的顺序执行。
根据题目我首先想到了: LazyMan 类似一个构造函数, 返回了一个  LazyMan  对象,这个对象上有一些原型方法,如sleep、eat等,然而没做过多思考便开始写代码,马上便遇到了问题:
function _LazyMan(name){
  this.name = name;
}

_LazyMan.prototype.sleep = function(time){
  var self = this;
  setTimeout(function(){
    console.log(self.name+' Wake up after '+time)
    return self?
  }, time)
}
问题就出现在了 return这里,由于使用了时间控制setTimeout,return就出现了问题,这样任务就无法按照预想的顺序合理的继续下去。在Express有一个类似的东西叫中间件,这个中间件和我们这里的吃饭、睡觉等任务很类似,每一个中间件执行完成后会调用next()函数,这个函数用来调用下一个中间件,对于这个问题,正确的解决方案采用类似的思路来解决,首先创建一个任务队列,然后利用next()函数来控制任务的顺序执行:
function _LazyMan(name) {
    this.tasks = [];   
    var self = this;
    var fn =(function(n){
        var name = n;
        return function(){
            console.log("Hi! This is " + name + "!");
            self.next();
        }
    })(name);
    this.tasks.push(fn);
    setTimeout(function(){
        self.next();
    }, 0); // 在下一个事件循环启动任务
}
/* 事件调度函数 */
_LazyMan.prototype.next = function() { 
    var fn = this.tasks.shift();
    fn && fn();
}
_LazyMan.prototype.eat = function(name) {
    var self = this;
    var fn =(function(name){
        return function(){
            console.log("Eat " + name + "~");
            self.next()
        }
    })(name);
    this.tasks.push(fn);
    return this; // 实现链式调用
}

_LazyMan.prototype.sleep = function(time) {
    var self = this;
    var fn = (function(time){
        return function() {
            setTimeout(function(){
                console.log("Wake up after " + time + "s!");
                self.next();
            }, time * 1000);
        }
    })(time);
    this.tasks.push(fn);
   return this;
}

/* 封装 */
function LazyMan(name){
    return new _LazyMan(name);
}

分析该解决方案本质上是在任务启动时,首先将对象“人”和各个任务进行链接,并将各任务放入task数组里,当所有任务完成放入后,才从第一个任务开始进行顺序执行。明明对象构造函数执行的时候,等待0秒就应该开始执行的next函数,为什么会等到所有任务链接完成后才开始执行呢?这里起到关键作用的依然是setTimeout函数。这里的回调函数的执行是异步的,这种异步机制实际上依赖于浏览器的事件机制:Event Loop(事件循环)。

我们都知道JavaScript是单线程的,并且JavaScript的运用场景是浏览器,但浏览器本身并不是单线程的,并且浏览器是事件驱动的(Event driven)。

引用某知乎大牛的一段话: ECMAScript并没有从语言上约定其异步的特性,我们所探讨的“异步”都是由执行引擎所赋予的。于Firefox,这个引擎是SpiderMonkey,于Node.js这个引擎是V8。而提供这个异步能力的机制,则是我们所谓的Event Loop——事件轮询,而本质上来说就是Reactor(反应堆)模型的一种延伸实现。所以像setTimeout,setInterval这样的函数,实际上并不是由语言本身所约定的,而是浏览器/执行引擎来实现,向JavaScript暴露的、提供的异步入口。
浏览器中有一个线程是单独负责处理JavaScript执行的,对于JavaScript来说就是单线程的,执行过程大概类似于下图所描述:

函数调用的时候,首先执行的函数会先于其他函数进入Stack中,最后被调用的函数将置于Stack的最上方,并且当其返回的时候首先从Stack栈中出栈。而只有当所有Stack栈清空后才开始从消息队列Queue,Queue中取出一条消息开始执行,Queue中的每个个消息与一个函数相关联,处理消息的过程实际上就是执行相关联的函数。整个过程都是单线程顺序执行的。那么Queue里存放的消息来自于何处呢?其实,Queue里面存放的就是一系列异步任务的回调函数,这些异步任务主要包括异步请求、点击事件、Timer函数等。
当一个异步事件发生的时候,它就进入事件队列(由浏览器其他线程专门控制,不同于消息队列)。Event Loop 会轮询事件队列并处理事件。例如,浏览器当前正在忙于处理onclick事件,这时另外一个事件发生了(如:window onSize),这个异步事件就被放入事件队列等待处理,只有前面的处理完毕了,空闲了才会执行这个事件。setTimeout也是一样,当调用的时候,浏览器引擎会启动定时器timer,当定时器时间到,就把该事件放到消息队列等待处理(Stack空且Queue无前置消息的时候才会真正执行)。如果有其它消息,setTimeout 消息必须等待其它消息处理完,因此第二个参数仅仅表示最少的时间 而非确切的时间。

通过以上的分析,已经可以解释“零延迟”现象。在零延迟调用 setTimeout 时,由于setTimeout 的异步机制,浏览器会启用其他线程对其进行异步处理,等待0秒后,若无其他前置异步事件,Event Loop会将其植入消息队列等待处理,这本身就落后了LazyMan其他函数调用的执行。
本文是对事件循环相关事件知识的部分总结,希望能够帮助到同样对浏览器事件执行机制不甚清楚的同学,如果本人描述中有任何疏漏错误之处,也希望大家帮忙指正,十分感谢!

本文来自网易实践者社区,经作者鞠智宽授权发布。