阅读 161

浅谈 JS 运行机制——事件循环(Event Loop)

提前知道

单线程

众所周知,JavaScript语言是单线程,即同一个时刻只做一件事。为何?因为,JavaScript作为浏览器脚本语言,如果它同时拥有多个线程,那么,它在操作DOM时,就会造成复杂的同步问题。比如,JavaScript的两个线程对同一个DOM节点进行操作,一个将其背景色改为蓝色,一个将其背景色改为黑色。那么,浏览器该先执行那个操作呢?所以,为了避免出现此种情况,它只能是单线程。

同步任务(synchronous)和 异步任务(asynchronous)

因为是单线程,所以,JavaScript所有任务都要排队。且只有前一个任务结束,才会执行后一个任务。但是,这会面临着一个问题:前一个任务耗时较长时,后一个任务就不得不一直等待。为解决这个问题,JavaScript语言的设计者,将所有任务分成了两种:

  • 同步任务

在主线程上排队执行的任务。且只有前一个任务执行完毕,后一个任务才会执行。

  • 异步任务

不进主线程,而是进任务队列的任务。且仅当任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

任务队列(task queue)

任务队列是一个事件的队列(也可以理解成消息的队列)。它是一个先进先出的数据结构,排在前面的事件,会被主线程优先读取。

在一个事件循环中,至少拥有一个任务队列,且一个任务队列便是一系列有序任务(task)的集合。每个任务都有一个任务源(task source),同一个任务源的任务(task)必须放入同一个任务队列之中,换句话说,就是根据任务源将任务添加到各自相对应的队列之中。例如,setTimeout和Promise这两个API,便可看成是两个不同的任务源,它们注册的任务会依次进入自身对应的队列之中。

事件循环(Event Loop)

直奔主题

循环1.png

1.1:JS运行机制图图1.1:JS运行机制图

注意:图中的旋转图标,可理解为主线程循环不断地读取任务队列。

在上图中,主线程运行时,会产生堆(heap)和栈(stack)。栈中的代码调用各种外部API,它们在任务队列中加入各种事件(click,load,done等)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。这个过程会不断重复(除非你把页面关了),所以这种运行机制又称为事件循环(Event Loop)。总的来说,可归纳为如下几步

  1. 所有同步任务都在主线程上执行,形成一个执行栈

  2. 主线程之外,会存在一个任务队列。一旦异步任务有了运行结果,就在任务队列之中放置一个事件。

  3. 当执行栈中的所有同步任务执行完毕,系统就会读取任务队列,查看有哪些事件。那些对应的异步任务,结束等待状态,进入执行栈并开始执行。

  4. 主线程不断重复上面的第三步。

举例说明

为了方便理解,我们假定有这么一个案例:

页面在加载未完成时(js主线程还未执行完毕),用户快速移动并点击了鼠标,触发了两个事件:一个鼠标移动,一个鼠标点击。

下面是根据上述案例,画的主线程和任务队列的简易流程图。

事件循环2.png

1.2:主线程和任务队列示意图图1.2:主线程和任务队列示意图

在事件处理阶段中,当事件循环去检查队列时,它发现队首有一个鼠标移动事件,然后执行其相应的事件处理器(事件回调)。当该处理器执行完毕后(即最后一行代码执行完毕后),js引擎退出当前事件处理器函数,事件循环再次去检查队列。此时,在队列的最前面,它发现了鼠标单击事件并处理了该事件。等到单击的回调执行完毕后,事件循环会继续循环,等待处理即将到来的新事件。这个循环会一直持续下去,直到用户关闭Web应用。

宏任务(macrotask)和 微任务(microtask)

提示:在ECMAScript中,宏任务和微任务,分别被称为:task 和 jobs。

宏任务

可理解为每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。

主要有:

  1. 主代码块
  2. setTimeout
  3. setInterval
  4. requestAnimationFrame (动画,详细
  5. setImmediate (Node中的api)

微任务

微任务是更小的任务。微任务更新应用程序的状态,但必须在浏览器任务(例如,重新渲染页面的UI)继续执行其他任务之前执行。我们可以将微任务理解成在当前宏任务执行后立即执行的任务,它总是跟在宏任务之后。

主要有:

  1. Promise.then()
  2. catch
  3. finally
  4. Object.observe
  5. MutationObserver(提供了监视对DOM树所做更改的能力,详细
  6. process.nextTick (Node中的api)

我们已知道,同一个任务源(例如,setTimeout/Promise等API)的任务会被放入同一个任务队列之中。所以,在一个事件循环中,宏任务和微任务的事件回调,会被放入对应的宏任务队列和微任务队列之中。这也就向我们说明了一个问题:事件循环通常至少应该包含一个宏任务队列和一个微任务队列。一图胜过前言万语,说了这么多,我们还是直接看图吧。

提示:现在的浏览器和JavaScript执行环境非常之多,你可能会遇见所有任务都在一个队列的事件循环,所以不必惊讶。

事件循环3.png

1.3:包含宏任务和微任务队列的事件循环图1.3:包含宏任务和微任务队列的事件循环

单次循环迭代步骤:

  1. 事件循环会首先检查宏任务队列,若是有宏任务等待,则立即开始执行。直到该任务运行完成(或队列为空),事件循环才移动去处理微任务队列。

  2. 若是有任务在微任务队列中等待,事件循环将依次开始执行,只有当前一个微任务完成后,才执行下一个微任务,直到微任务队列中所有微任务执行完毕。

  3. 当微任务队列中所有微任务执行完成并清空时,事件循环会检查是否需要更新UI渲染,如果否,则当前事件循环结束,并开启新一轮的事件循环;如果是,则会先重新渲染UI视图,然后在开启新一轮的事件循环。

注意点:

  • 事件循环处理宏任务和微任务队列的区别:在单次的循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的所有微任务都会被处理。

  • 两类任务队列均独立于事件循环。这也就是说,任务队列的添加行为,是发生在事件循环之外的。这样设计的目的,是为防止在执行JavaScript代码时,忽略发生的任何事件。(JavaScript是单线程,所以宏任务和微任务都是逐个执行的。当一个任务开始执行且未完成时,中间不会被任何其他任务中断。若是不独立在事件循环外,那就检测不到任务了。)

  • 所有微任务都是在宏任务之后且在下一次渲染之前执行完成,因为微任务要在渲染前更新应用程序的状态。

宏任务和微任务案例

依旧是图1.2时,所举案例:页面在加载未完成时(js主线程还未执行完毕),用户快速移动并点击了鼠标,触发了两个事件:一个鼠标移动,一个鼠标点击。

为了加深对宏任务和微任务的理解,这里,我将此例分为了两种不同的情况:

  1. 只有宏任务。
  2. 宏任务和微任务同时存在。

有关代码部分,不是很严谨,仅做参考

只有宏任务

示例代码

document.addEventListener("mousemove", function firstHandler() {
    for (var i = 0; i < 4; i++) {
        console.log('mousemove——执行开始:' + i);
    }
    console.log('mousemove——执行结束');
});
document.addEventListener("click", function secondHandler() {
    for (var i = 0; i < 2; i++) {
        console.log('click——执行开始:' + i);
    }
    console.log('click——执行结束');
});
const num = 40000;
for (var i = 0; i < num; i++) {
    console.log('js主线程——执行开始:' + i);
}
console.log('js主线程——执行结束');
复制代码

运行结果图

注意:const num = 40000;设置的数值过小,则触发事件的反应速度来不及;数值过大,又容易卡死,40000这个数值其实也不理想,试了好几次,才录下了完好的触发过程(嫌麻烦,没去调一个合适的值)。

宏任务示例.gif

事件监测和添加任务是独立于事件循环的,即使主线程未执行完毕,我们仍然能够向队列添加任务。

从上图我们可以得知,由于js是单线程,所以一个任务一旦开始执行,它就不会被另一个任务中断。当我们在主线程js代码未执行完毕时,移动并单击鼠标(两个事件,有一点时间间隔并不同时触发),它们并没有立即执行对应的处理器(回调函数)。而是依次进入任务队列(先进先出),等主线程执行完毕后,才依次执行。

为了方便理解,我们假设:

  • 主线程js代码需要运行10ms。
  • 鼠标移动事件处理器需要运行8ms。
  • 鼠标单击事件处理器需要运行5ms。
  • 重新渲染页面0ms(是假设,别较真,为画图方便)。

并以此为前提,画一个仅含有宏任务的从左到右的时间轴(单位:毫秒)及在相应时间段执行的部分js代码。因为,没有微任务,所以微任务队列是空的。

宏任务.png

1.4:宏任务图解图1.4:宏任务图解

宏任务和微任务同时存在

示例代码

document.addEventListener("mousemove", function firstHandler() {
    for (var i = 0; i < 4; i++) {
        console.log('mousemove——执行开始:' + i);
    }
    Promise.resolve().then(() => {
         for (var i = 0; i < 2; i++) {
            console.log('Promise微任务——开始:' + i);
         }
         console.log('Promise微任务——结束');
    }); 
    console.log('mousemove——执行结束');
});
document.addEventListener("click", function secondHandler() {
    for (var i = 0; i < 2; i++) {
        console.log('click——执行开始:' + i);
    }
    console.log('click——执行结束');
});
const num = 40000;
for (var i = 0; i < num; i++) {
    console.log('js主线程——执行开始:' + i);
}
console.log('js主线程——执行结束');
复制代码

这段代码同上面宏任务的代码的唯一区别:是在鼠标移动处理器(事件回调)中加入了promise,并添加promise兑现时的处理。

运行结果图

宏任务和微任务.gif

结合代码和上图的运行结果,我们可以得知,鼠标移动在执行期间创建的微任务被放入了微任务队列。等鼠标移动执行完毕后,事件循环检测到微任务队列中含有微任务,所以就优先处理了微任务,而不是早就在队列中等待的宏任务:鼠标单击。结合图1.3看,会更容易理解。

为方便理解,再次假设:

  • 主线程js代码需要运行10ms。
  • 鼠标移动事件处理器需要运行8ms。
  • promise被创建并兑现,需要运行2ms。
  • 鼠标单击事件处理器需要运行5ms。
  • 重新渲染页面0ms(是假设,别较真,为画图方便)。

并以此为前提,画一个同时含有宏任务和微任务的从左到右的时间轴(单位:毫秒)及在相应时间段执行的部分js代码。

宏任务和微任务.png

1.5:宏任务和微任务图解图1.5:宏任务和微任务图解

事件循环中的定时器

setTimeout()和setInterval()这两个定时器,想必大家都有了解,它们的内部运行机制完全一样,区别在于前者指定的代码只会执行一次性,而后者会反复执行。有关它们的详细情况,这里就不做过多介绍,有兴趣的同学可以自己查查。下面,我们直接看例子。

示例代码

setTimeout(function timeoutHandler() {
    console.log('注册:setTimeout');
}, 500);
setInterval(function intervalHandler() {
    console.log('注册:setInterval');
}, 500);
// 为按钮单击事件注册事件处理器
const btn = document.getElementById("myBtn");
btn.addEventListener("click", function clickHandler() {
    console.log('点击事件:click');
});

const num = 200;
for (var i = 0; i < num; i++) {
    console.log('js主线程——执行开始:' + i);
}
console.log('js主线程——执行结束');
复制代码

主线程代码在执行时,会发生3个事件: 鼠标单击事件(主线程未执行完毕时点击)、 setTimeout到期事件和setInterval触发事件。看下图,观察他们的执行顺序。

运行结果图

定时器.gif

setTimeout和setInterval的延迟时间参数,仅是指定计时器添加到队列中的时间,而不是准确的执行时间。

这两个定时器,在主线程执行时,就已经调用,但它们都有各自的延迟执行时间,只有当它们各自的延迟时间到期之后,才会添加其对应的任务到队列中。那主线程执行完毕后,为什么会先执行鼠标单击?我们前面介绍过,这两个定时器都是宏任务。而鼠标单击也是宏任务,是在主线程未执行完成时,且又是在它们的延迟时间到期之前触发的,所以它会被添加到宏任务队列的最顶端即队首。

但是,在将它们添加到队列的过程中,由于setInterval会反复执行,它会被不断地添加到队列(除非清除),且并不是在每次延迟时间到期后都会被添加到队列中。这原因嘛,就是任务的执行需要一定时间,且浏览器不会同时创建两个相同的间隔定时器。什么意思呢?就是说,如果已经有一个setInterval的实例在队列中等待执行,且setInterval又到了触发的时间,那么该触发就会被中止。现在,明白为何说setInterval的延迟有时不准确了吧。不太明白,也没关系,看图,一图解千虑。

以上述案例做假设:

  • 主线程执行520ms。
  • 在0ms时,setTimeout和setInterval均延迟500ms执行。且两者都运行500ms
  • 在6ms时,单击鼠标,运行500ms。
  • 在500ms时,setTimeout定时器到期,setInterval定时器的第一个时间间隔触发。
  • 重新渲染页面0ms(是假设,别较真,为画图方便)。

setTimeout.png

若是,以此图为参考的话,那么第二个setInterval触发事件会在2000ms后。当然不是每次延迟都不准确,在后面的时间间隔中,它会稳定在每500ms执行一次(这取决于,代码执行的时长是否受到干扰)。而原本要在500ms后执行的setTimeout到期事件,却因主线程执和鼠标单击事件,延迟到1020ms后,才能执行。

它们两个都表明了,我们不能确定定时器处理程序会按照我们期望的确切时间去执行。同时,这两个定时器是有区别的:

  • setTimeout的事件回调,至少延迟到其设置的时间执行,这取决于事件队列的状态,其实际等待时间只会大于设置的延迟时间。
  • setInterval会尝试每 N 毫秒(设置的延迟时间)执行回调函数,不会关心它前面的回调函数是否执行。

结束语

文章主要描述了事件循环的原理,并没有做过多的延伸去详细解说(还是能力不够呀)。说错的地方,也请大家不吝赐教。想深入了解机制的同学们,建议阅读(也是本文的主要参考文章和书籍):

  1. 阮一峰JavaScript 运行机制详解:再谈Event Loop

  2. 《javascript忍者秘籍第2版》第十三章——深入事件循环

文章分类
前端
文章标签