[译] JavaScript 的事件循环

时间:2024-09-07 21:07:02

译者注

本译文基本是按原文的意思来翻译,但对于 JavaScript 的事件循环,个人感觉还是 Philip Roberts 的视频讲解更形象些,思路和本文大致相同,不过他把事件表理解为 Web API,事件队列理解为任务队列或回调队列,童鞋们可以看下(可能需要*):

Philip Roberts: Help, I’m stuck in an event-loop.

Philip Roberts: What the heck is the event loop anyway?

MDN:Concurrency model and Event Loop


原文:What is the JavaScript Event Loop?

简介

也许你和我一样,非常喜欢 JavaScript,我们使用它编写网页,通过它连接世界。是的,它并不完美,但尼玛这世上有完美的编程语言吗?!

JavaScript 的内部原理复杂而难懂,其中之一就是事件循环(Event Loop),可能有不少童鞋写了几百年的 JavaScript 代码,仍搞不清楚事件循环是个什么鬼,希望本文可以给小伙伴们一些帮助。

浏览器里的 JavaScript

说到 JavaScript,我们一般会想到浏览器,事实上,运行一个页面涉及到非常多的东西,例如 JavaScript 引擎(如谷歌浏览器的 V8 引擎)、Web API(如 DOM )、事件循环(Event Loop)和事件队列(Event Queue),看到这么一堆名词,你可能会觉得“卧槽,好复杂的样子……”,嗯,看起来确实挺复杂的,但你很快会看到,它们的工作原理也没想象中那么难懂。

在讨论事件循环之前,我们要先对 JavaScript 引擎有一个基本概念。

JavaScript 引擎

目前最流行的 JavaScript 引擎是谷歌浏览器的 V8 引擎,它不止应用于浏览器,还通过 NodeJs 活跃于服务端。那 JavaScript 引擎到底是干哈的呢?其实非常简单,它的工作就是遍历应用中的每一行 JavaScript 代码,逐行执行。没错,一次一行,这货是单线程的。也就是说,如果有某一行代码需要执行很长长长长长的时间,那么后面的代码就会被阻塞住。这肯定不是我们想要的,想象下你在网页中点了某个按钮,然后页面就懵逼了,尝试做其它操作,发现一点卵反应都没有,4不4瞬间就心中千万只*在奔腾,说好的屠龙宝刀点击就送呢?而这一切的罪魁祸首就是一开始点击的那个按钮触发了一大坨需要执行的代码,导致后面的逻辑全部塞住了,便秘了魂淡。

那么,JavaScript 引擎是如何逐行执行 JavaScript 代码的呢?它使用了一个调用栈(call stack)。可以把调用栈想象成电梯,最先进去的人最后出来,而最后进去的反而最先出来。

举个栗子:

/* main.js */

var firstFunction = function () {
console.log("老子天下第 1!");
}; var secondFunction = function () {
firstFunction();
console.log("老子天下第 2!");
}; secondFunction(); /* 执行结果:
* => 老子天下第 1!
* => 老子天下第 2!
*/

在代码执行过程中,调用栈的变化如下:

  • 执行 Main.js:

[译] JavaScript 的事件循环

  • 调用 secondFunction:

[译] JavaScript 的事件循环

  • 执行 secondFunction,调用 firstFunction:

[译] JavaScript 的事件循环

  • 执行 firstFunction,控制台输出“我是函数 1!”,因为 firstFunction 里没有其它代码需要执行,firstFunction 从调用栈中弹出:

[译] JavaScript 的事件循环

  • 继续执行 secondFuncction,控制台输出“我是函数 2!”,因为 secondFuncction 里没有其它代码需要执行,secondFuncction 从调用栈中弹出:

[译] JavaScript 的事件循环

  • 最后,因为 main.js 里没有其它代码需要执行,main.js 也从调用栈中弹出:

[译] JavaScript 的事件循环

好吧,那我们可以讲事件循环了吗?

现在,我们已经了解了 JavaScript 引擎调用栈的工作原理,但到底要如何避免代码阻塞呢?非常幸运,JavaScript 提供了一种基于异步回调函数(asynchronous callback function)的机制。哇!是不是感觉刚从调用栈里爬出来,又掉到另一个坑里?不用担心,异步回调函数跟我们平时在 JavaScript 里写的其它函数并没有什么不同,只是它不会马上执行,而是在未来某个时间执行。如果你使用过 JavaScript 的 setTimeout 函数,那么你已经邂逅了异步回调函数。

来看个栗子:

/* main.js */

var firstFunction = function () {
console.log("老子天下第 1!");
}; var secondFunction = function () {
setTimeout(firstFunction, 5000);
console.log("老子天下第 2!");
}; secondFunction(); /* 执行结果:
* => 老子天下第 2!
* (5秒钟后)
* => 老子天下第 1!
*/

在代码执行过程中,调用栈的变化如下(我们跳过一些步骤,直接从变化处开始):

  • 在 secondFuncction 进入调用栈后,setTimeout 函数被调用,也进入调用栈:

[译] JavaScript 的事件循环

在 setTimeout 函数执行后,发生了一个特别的事情:引擎把 setTimeout 的回调函数(本例中为 firstFunction)放进了一个事件表(Event Table)。可以把事件表想象成登记台:调用栈告诉事件表登记一个特殊的函数,只有发生特定的事件时才执行。当这个特定事件发生时,事件表会把函数转移到事件队列(Event Queue)。事件队列就像一个集中待命区,函数在这里排队,等待被调用时进入调用栈。

你可能会有疑问:“那事件队列里的函数会在什么时候进入调用栈呢?” JavaScript 引擎遵循一个非常简单的规则:不断地检查调用栈空了没有,如果空了,则检查事件队列中有没有等待被调用的函数,如果有,则队列中第一个函数将被移入调用栈,如果事件队列是空的,则监控进程继续保持运行。看,这他喵的就是所谓的事件循环!

  • 执行 setTimeout 函数,将其回调函数(本例中为 firstFunction)添加到事件表并为其注册一个5秒延迟事件:

[译] JavaScript 的事件循环

  • 见证奇迹的时刻来了!回调函数加入事件表后,没有造成任何阻塞!浏览器没有傻愣愣地等 5 秒,而是紧接着就执行了 secondFuncction 里下一行的 console.log:

[译] JavaScript 的事件循环

  • 事件表会不断监控是否有指定的事件发生,以把相应函数移入事件队列。现在,secondFunction 和 main.js 都执行完成了:

[译] JavaScript 的事件循环

  • 在 firstFunction 加入事件表 5 秒后,事件表将其移入事件队列:

[译] JavaScript 的事件循环

  • 因为事件循环在不断地监控调用栈,现在调用栈空了,firstFunction 将进入调用栈:

[译] JavaScript 的事件循环

  • 在 firstFunction 执行完成后,调用栈为空,事件表没有任何事件需要监控,事件队列也为空:

[译] JavaScript 的事件循环

总结

本文的讲解忽略了 JavaScript 引擎、事件表、事件队列和事件循环的具体实现细节,但对于我们大多数前端开发而言,只需要简单了解 JavaScript 是如何执行一个异步函数就可以了,希望本文可以让小伙伴们对这些概念的基本原理有个比较清晰的理解。