Space Cowboy

生死去来 棚头傀儡 一线断时 落落磊磊

0%

浏览器与 Event Loop

引子

如果你使用 JavaScriptTypeScript 开发,那你就不能不去了解运行它们的环境。我们可以笼统的将运行环境区分为浏览器和 Node.js。在这篇文章,我们的讨论范围仅限于浏览器中。

一个在展开讨论前的必要信息是:JavaScript 是一个单线程语言。这意味着在任何时候,都只能有一个主线程来处理任务。事实上这个设计不能怪 Brendan Eich,JavaScript 语言的设计者。他当年捣鼓这个东西的时候,也没有想到互联网能发展成今天这个样子。

如果我们考虑单线程这件事,我们会意识到,这个设计会带来灾难性的后果。比如用户在网站上发起了一个网络请求,这个网络请求所需要的时间可能长达几秒,在这期间,整个网站的渲染线程是被挂起的,从用户的角度来看,网站就像卡住了。这种浏览体验可能是所有用户都不愿意见到的。

说到这,你可能会想:不对啊,我平常上网怎么没遇到过这种情况?的确没有,这就要归功于 Event Loop 了。
简单来说,Event Loop 这个设计,使得浏览器可以将 JavaScript 代码中 同步异步 任务区分开。这种设计是有重大意义的。这使得浏览器可以在处理诸如网络请求这种高延时工作的时候,依然可以为用户提供流畅的渲染体验。

消息队列

在谈论 Event Loop 的具体内容之前,我想先谈论一下 Message Queue 这个主题。为什么要谈论这个看起来毫不相关的主题?这是因为 Event Loop 的设计思想和 Message Queue 是一致的。

在生活中,我们会遇到很多事情。这些事情并不是都需要立刻处理的,而是具有优先级关系的。而在计算机的处理中,这种思想也指导我们必须将各种任务区分开,按照不同的优先级处理。

具体到浏览器中,一些诸如渲染等工作,必须是要立刻处理的。按照术语来说,就是所谓的 同步 任务。当用户点击按钮后,他当然想立即看到反馈。在大多数时候,我们会呈现一个按钮被按下的动画,告诉用户,你的确是按下了这个按钮。而另外的一些工作,比如网络请求,就不一定要立即呈现结果了。网络速度经常是一个很玄学的事情,我们可以保证服务器的反应速度,但却无法控制用户端的网速。所以如果在发出网络请求后,整个浏览器都卡在那里等待请求的返回结果,用户显然不买账。所以我们可以将其区分为 异步 任务,进行特殊处理。

消息队列 实际上就是这种思想的具体实现。直白点说,消息队列 实际上就是一个 TODO 清单。我们将一些耗时短、立即处理的任务,放在主线程处理。而将一些耗时长、需要等待结果的任务放在这份 TODO 清单 中,等到有结果时再处理。

那么,我们如何去处理那些被等待的任务?答案就是——回调函数。当异步任务等待完成时,浏览器可以去调用这些回调函数,从而完成对异步任务的整体处理。

JavaScript 引擎的设计

我们使用如下这张图来展示整个 JavaScript 引擎的设计,这幅图参考了 JSConf 2014 上的演讲。你可以去看一下这个视频,它对 Event Loop 做了非常清晰的解释,除了没有提到 micro task 这个主题。

JavaScript 运行时

引擎会将变量存储在 heap 中。而当代码执行时,引擎会根据方法的调用,生成与其对应的 context。这个上下文中存储着一些方法执行所需要的信息,比如参数变量、上层作用域、this 对象等。同时,方法会被压入执行栈,即图中的 stack 中。方法自执行栈顶部开始执行。若方法调用了新的方法,那么就将那个新方法压入执行栈。若方法执行完毕,那么就将其弹出执行栈。

在实际代码中,引擎会根据变量的不同类型,将其存储于 stackheap 的数据结构中。但这部分不是我们讨论的主题。

那么,我们如何处理异步任务呢?很简单,我们会调用 Webapis,这些需要等待的任务将由浏览器进行管理。一旦任务完成,就将这些任务的回调函数推入 Task Queue。依据异步任务的不同,我们选择性的将其推入 Task 或是 Micro Task 队列。

具体来说,你可以这样区分它们:

  • Task: setInterval()、setTimeout()
  • Micro Task: new Promise()、new MutaionObserver()

当然了,这两个队列肯定不是为了好玩才取两个名字的,它们之间有执行逻辑的区别。
具体来说,当执行完某个宏任务且执行栈为空时,引擎会优先从 Micro Task 中取出一个任务执行,并直到执行完 Micro Task 队列中的所有任务。如果在执行某个微任务时,又加入了新的微任务,那么引擎也会尽职尽责的将队列里的所有 Micro Task 执行完。
如果完成了上面的工作,或是 Micro Task 中不存在任何待处理任务的话,引擎会去 Task 队列中取出一个任务,压入执行栈开始执行。

这个问题在 MDN Web Docs 中也被阐述过:

The difference between the task queue and the microtask queue is simple but very important:

  • When executing tasks from the task queue, the runtime executes each task that is in the queue at the moment a new iteration of the event loop begins. Tasks added to the queue after the iteration begins will not run until the next iteration.
  • Each time a task exits, and the execution context stack is empty, each microtask in the microtask queue is executed, one after another. The difference is that execution of microtasks continues until the queue is empty—even if new ones are scheduled in the interim. In other words, microtasks can enqueue new microtasks and those new microtasks will execute before the next task begins to run, and before the end of the current event loop iteration.

总结

总的来说,Event Loop 的实质就是一个消息队列管理。基于这个设计,JavaScript 这种单线程语言得到了无阻塞的能力。
其次,基于不同的异步任务,我们区分了 TaskMicro Task 两种回调队列。它们的调用策略也是不同的。

也许在其他地方,Task 会被称为 Macro Task

补充资料

也许你可以读一下这些资料,以便加深理解。

  1. What is the event loop anyway? | Phillip Roberts | JSConf EU
  2. Tasks, microtasks, queues and schedules
  3. In depth: Microtasks and the JavaScript runtime environment