首页>>前端>>JavaScript->JavaScript是如何执行的:事件循环(event loop)

JavaScript是如何执行的:事件循环(event loop)

时间:2023-12-01 本站 点击:0

我们都知道JavaScript是一门单线程、非阻塞、异步、解释性的脚本语言。单线程就意味着所有代码按顺序从前往后一步一步执行,但实际 JavaScript 编程中并非如此。此外,非阻塞和异步也意味着一个任务不需要等待前一个任务执行完毕再执行,这看起来与单线程似乎是矛盾的。那 JavaScript 为什么会如此"怪异"呢,其实这都与事件循环(Event Loop) 机制相关,本文就从事件循环的角度来探寻一下 JavaScript 是如何执行的。

在阅读本文之前,建议您先观看视频 What the heck is the event loop 和 In The Loop,会让您对事件循环有个直观的认识,本文也是基于这些视频来展开的。

一、前言

在正式介绍事件循环之前,我们先来了解一些相关的基础知识。

1.1 为什么JavaScript是单线程的

单线程是JavaScript的一大特点,这也意味着同一时间只能做一件事。但是,为什么不像Java等语言一样,拥有多个线程提高执行效率呢? JavaScript之所以是单线程的,这与其用途有关。JavaScript的主要用户是与用户互动,以及操作DOM。如果设计成多线程则会带来很多问题。例如,假设有两个线程,一个线程在DOM节点上添加内容,而另一个线程删除该DOM节点,这样导致操作冲突,浏览器无法确定以哪个操作为准。 所以,为了避免复杂性,JavaScript就是一门单线程语言。

1.2 浏览器架构

关于浏览器是由哪些进程组成的,每个进程中又包含哪些线程,以及它们的作用,大致如下图所示。我的另一篇文章“浏览器的页面渲染流程”中对此有较为详细的介绍。

1.3 JavaScript 的浏览器运行环境(Runtime)

1.3.1 JavaScript引擎

JavaScript 引擎是一个执行 JavaScript 代码的程序或解释器,主要由两个组件组成的:

内存堆(memory heap):内存分配发生的地方,所有的对象变量都以随机方式分配在这里。

调用堆栈(call stack):代码执行时堆栈帧所在的位置(函数调用的地方)。

(1)调用堆栈(call stack)

调用堆栈是一种后进先出(LIFO) 的数据结构,它记录了当前控制流在程序中的位置。调用堆栈中的每个条目称为堆栈帧。

JavaScript是一种单线程编程语言,这就意味着只有一个调用堆栈,因此一次只能做一件事。当控制流进入一个函数,那么就把该函数放在栈顶。若一个函数返回结束,那么就从栈顶弹出该函数。

如下代码:

当JavaScript引擎开始执行代码时,调用堆栈为空。

之后调用printSquare,第一个帧压入堆栈,帧中包含了printSquare的参数和局部变量。

紧接着调用multiply,第二个帧压入堆栈,multiply执行完毕返回时,第二个帧出栈,

紧接着调用console.log,第三个帧压入堆栈,console.log执行完毕时,第三个帧出栈

最后printSquare执行完毕时,第一个帧出栈,调用堆栈就被清空了。

function multiply(x, y) {  return x * y;}function printSquare(x) {  var s = multiply(x, x);  console.log(s);}printSquare(5);

具体可见步骤如下图:

异常堆栈追踪  抛出异常时构建堆栈追踪的方式就是基于调用堆栈的:

Blowing the stack 当达到最大调用堆栈大小时会发生这种情况。例如无限递归:

function foo() {  foo();}foo();

(2)内存堆(memory heap)

对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。

1.3.2 Web API

由浏览器提供的API(例如 DOM、AJAX、setTimeout 等等),让我们能够同时处理多项任务。当完成 Web API 的内部函数后,便将任务传送至任务队列。

在Chrome中,这些 API 的实现并不存在于 V8 引擎源码中,所以在 JavaScript 中只能调用这些API,而且它们的执行也不是在JS引擎上的。例如:调用 setTimeout() 的计时操作是在定时触发器线程上执行的。(其实这也说明了JavaScript为什么可以进行异步编程)

1.3.3 回调队列(Callback Queue)

回调队列,这是一个先进先出(FIFO) 的工作队列,会接收来自 Web API 的任务,并通过事件循环(Event Loop)监控,当调用堆栈中没有执行项目时,便把队列中的任务加入到调用堆栈中执行。

综上所述,JavaScript 的 Runtime 大致如下图:

二、事件循环(event loop)

2.1 事件循环的定义

严格来说,事件循环并不是 JavaScript 本身的机制,而是 JavaScript 运行环境(runtime)里面的机制。(即:浏览器或Node.js)所以,在 EMAScript 中并没有对事件循环的定义,而在 HTML Standard 中对事件循环是这样定义的:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. Each agent has an associated event loop, which is unique to that agent.

即事件循环是为了协调事件、用户交互、脚本、渲染、网络等

2.2 事件循环是什么?

我们知道JavaScript的并不是独立运行的,它的运行依赖于一个宿主环境,例如常见的 Web 浏览器、Node.js 。但实际上,技术发展到现在,JavaScript也可以嵌入到机器人等设备中运行,这些设备就是JavaScript的宿主环境。 这些宿主环境的共同点在于,都有一个称为事件循环的内置机制,会调用JS引擎执行处理程序多个块的执行。这就意味着,JS引擎知识JS代码的执行环境,然而事件调度是周围环境进行的。 那么,事件循环到底是什么呢?我们来看一篇博客中的解释:

The Event Loop has one simple job — to monitor the Call Stack and the Callback Queue. If the Call Stack is empty, it will take the first event from the queue and will push it to the Call Stack, which effectively runs it. —— How JavaScript works: Event loop and the rise of Async programming

翻译过来就是:事件循环负责监控调用堆栈和回调队列。如果调用堆栈为空,事件循环将从队列中取出第一个事件并将其推送到调用堆栈,然后调用堆栈会执行它。

或许这样说还是有点模糊,我们来看How JavaScript works: Event loop and the rise of Async programming中的一段代码:

console.log('Hi');setTimeout(function cb1() {     console.log('cb1');}, 5000);console.log('Bye');// 代码输出如下:// Hi// Bye// cb1

当我们执行这段代码时,浏览器内部是如何工作的呢:

1、初始的时候,调用栈、回调队列均为空,控制台也没有任何输出

2、console.log('Hi');添加到调用栈,然后执行,执行结束后出栈

3、setTimeout(function cb1() { ... })添加到调用堆栈

4、setTimeout(function cb1() { ... })被执行。为了不阻塞代码执行,浏览器会创建一个计时器作为 Web API 的一部分。它将用于处理倒计时。

5、setTimeout(function cb1() { ... })自身完成并从调用堆栈中移除。

6、console.log('Bye')添加到调用堆栈,然后执行,执行结束后出栈。

7、至少 5000 毫秒后,计时器完成并将cb1回调推送到回调队列。

8、事件循环cb1从回调队列中取出并将其推送到调用堆栈

9、cb1执行并添加console.log('cb1')到调用堆栈。

10、console.log('cb1')执行,执行结束后出栈。

11、cb执行结束,出栈 总的来说,当JS引擎执行一段代码时,会创建一个堆(Heap)和一个栈(Stack),会在栈中按顺序执行JS代码,当栈中的代码调用了Web API 时,会通知浏览器开启另外的线程进行相应的操作,操作有了结果后,会向任务队列(Task Queue)中添加相应事件。当栈中的代码执行完毕,即栈空时,事件循环将从队列中取出第一个事件并将其推送到栈,然后执行事件对应的回调函数。 那么哪些情况下会向事件队列中添加一个事件呢,例如:

定时器倒计时结束

异步HTTP请求有了结果,无论请求成功还是失败

用户对DOM的一些操作,例如点击、滚动等 此外,个人觉得事件队列、任务队列、消息队列、回调队列这四个术语应该指的都是同一个东西,向事件队列添加一个事件,其实就是把事件对应的回调函数添加到队列中,在栈中执行异步任务,其实执行的也就是这个异步任务对应的回调函数。

setTimeout(…) 如何工作

setTimeout()并不会自动将对应的回调函数添加到事件队列中。它设置了一个定时器,该定时器的倒计时是在渲染进程的定时触发线程中执行的,当定时器到期时,JS引擎所在的运行环境会将回调函数添加到事件队列中,当事件循环监控到调用堆栈未空时,就会把该回调函数推送到调用堆栈中执行。

setTimeout(myCallback, 1000);

如上代码,我们需要注意的是,myCallback并不会在1000ms后立即执行,而是在1000ms后加入到事件队列中。可能在它之前,队列中还有其他事件,所以必须等待这些事件对应的回调函数执行完毕,myCallback才能执行。所以,myCallback有可能在1000ms后不会执行,这也是为什么setTimeout()会存在偏差的原因。

零延时setTimeout(callback, 0): 可能大家经常会遇见将setTimeout()第二个参数设置为0的情况,其实这就是将回调函数延迟到调用堆栈清空之后再执行。

console.log('Hi');setTimeout(function cb1() {   console.log('cb1');}, 0);console.log('Bye');

例如上述代码,输出顺序为:

HiByecb1

小结

有关浏览器的事件循环,上述介绍的可能不是很清晰,不过我看到最好的解释是:

事件循环负责监控调用堆栈和回调队列。如果调用堆栈为空,事件循环将从队列中取出第一个事件并将其推送到调用堆栈,然后调用堆栈会执行事件对应的回调函数。

三、作业队列(job queue)

3.1 定义

由于ES6中的引入的新特性Promise,Promise 的处理程序(handlers).then.catch 和 .finally 都是异步的。为了对异步任务做适当的管理,对应也引入了作业(job)和作业队列(job queue)的概念。ES6中的定义如下:

作业(job)

A Job is an Abstract Closure with no parameters that initiates an ECMAScript computation when no other ECMAScript computation is currently in progress.

即:作业是一个抽象闭包,在没有其他ECMAScript computation 时启动ECMAScript computation 。

作业队列(job queue)

A Job Queue is a FIFO queue of PendingJob records. Each Job Queue has a name and the full set of available Job Queues are defined by an ECMAScript implementation.

即:作业队列是一个由PendingJob记录组成的FIFO队列

3.2 作业时如何执行的

我们再来看ES6规范中的一段描述:

Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty. A PendingJob is a request for the future execution of a Job. A PendingJob is an internal Record. Once execution of a Job is initiated, the Job always executes to completion. No other Job may be initiated until the currently running Job completes. However, the currently running Job or external events may cause the enqueuing of additional PendingJobs that may be initiated sometime after completion of the currently running Job.

大体意思就是:

只有当没有正在运行的执行上下文且执行上下文栈为空时,才能启动作业的执行

PendingJob是对一个作业的未来执行的请求。PendingJob 是一个内部记录。

一旦开始执行一项作业,该作业总是执行到完成。在当前运行的作业完成之前,不可能启动其他作业。

然而,当前正在运行的作业或外部事件可能会导致排队等候额外的 PendingJob,这些作业可能会在当前运行的作业完成后的某个时候被启动。

每个 ECMAScript 实现都至少有以下事件队列(Job Queues): Name        | Purpose                                                                                                                           | | ----------- | --------------------------------------------------------------------------------------------------------------------------------- | | ScriptJobs  | Jobs that validate and evaluate ECMAScript Script and Module source text.                                 | | PromiseJobs | Jobs that are responses to the settlement of a Promise.

对于Promise来说,其对应的处理程序就是一个作业,会被放到作业队列中。即.then.catch 和 .finally对应的回调函数。

我们来看下面一段代码:

console.log('script start');const promise1 = new Promise((resolve, reject) => {    console.log('promise1');    resolve('success');})promise1.then(() => {    console.log('promise1.then');}).then(() => {    console.log('promise1.then.then');})const promise2 = new Promise((resolve, reject) => {    console.log('promise2');    reject('fail');})promise2.catch(() => {    console.log('promise2.catch')}).finally(() => {    console.log('promise2.finally')})console.log('script end');// script start// promise1// promise2// script end// promise1.then// promise2.catch// promise1.then.then// promise2.finally

为什么输出结果会这样呢,我们来理以下步骤:

开始执行脚本

console.log('script start'); 进入调用堆栈,并执行,输出“script start”,执行完毕后出栈

new Promise()进入调用堆栈,并执行,输出promise1,执行完毕出栈

promise1.then进入作业队列

new Promise()进入调用堆栈,并执行,输出promise2,执行完毕出栈

promise2.then进入作业队列

console.log('script end'); 进入调用堆栈,并执行,输出“script end”,执行完毕后出栈

脚本执行完毕,开始执行作业队列中的作业

执行promise1.then,输出“promise1.then”,执行过程中又产生了另一个微任务promise1.then.then,加入到作业队列中

紧接着执行promise2.catch,输出“promise2.catch”,执行过程中又产生了另一个微任务promise2.finally,加入到作业队列中

执行promise1.then.then,输出“promise1.then.then”,

执行promise2.finally,输出“promise2.finally”

3.3 宏任务(Task) 和 微任务(Microtask)

(1)宏任务(Task)

宏任务(Task)就是我们平时所说的任务,就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调等。 除了使用事件,你还可以使用 setTimeout() 或者 setInterval()来添加任务。

(2)微任务(Microtask)

微任务(Microtask)其实就是上述 ECMAScript 规范中定义的作业(job),微任务仅来自于我们的代码。它们通常是由 promise 创建的:对 .then/catch/finally 处理程序的执行会成为微任务。微任务也被用于 await 的“幕后”,因为它是 promise 处理的另一种形式。

(3)宏任务队列和微任务队列的区别

任务队列和微任务队列的区别很简单,但却很重要:

当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.

每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。

注意:执行JavaScript脚本程序也算是一个任务,微任务要等JavaScript脚本程序执行完毕后才能开始执行。

console.log('script start');Promise.resolve().then(() => {    console.log(1);}).then(() => {    console.log(2);})setTimeout(() => {    Promise.resolve().then(() => {        console.log(3);    })    .then(() => {        console.log(4);    })}, 1000)setTimeout(() => {    console.log(5);}, 0)console.log('script end');// script start// script end// 1// 2// 5// 3haol// 4

最后来看In The Loop 中给出的一个有趣的例子,JS代码如下:

let btn = document.querySelector('.cycle');btn.addEventListener('click', () => {    Promise.resolve().then(() => {console.log('Microtask 1')});    console.log('Listener 1');})btn.addEventListener('click', () => {    Promise.resolve().then(() => {console.log('Microtask 2')});    console.log('Listener 2');})btn.click();

当我们运行这段代码时,根据微任务的运行机制,我们可以很快知道输出的是:Listener 1 -> Listener 2 -> Microtask 1 -> Microtask 2 但是,如果我们通过鼠标去点击该按钮时,输出的顺序又不同了:Listener 1 -> Microtask 1 -> Listener 2 -> Microtask 2 那么为什么会这样呢,这是因为当我们在外部点击按钮时,console.log('Listener 1');执行结束后调用堆栈就清空了,此时微任务就可以开始执行了,但是如果是调用btn.click();console.log('Listener 1');执行结束后,btn.click();还没有执行结束,调用堆栈还没有清空,还轮不到微任务执行。

四、window.requestAnimationFrame

window.requestAnimationFrame()  告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行

这里简单记录一下,三种队列执行的区别。

任务队列中一次只执行一项

Animation Callback 队列中,会把依次所有的项都执行完

微任务队列也会依次把所有的项执行完,但如果某个微任务执行过程中产生新的微任务,那么把新的微任务添加到队尾,继续执行,直到所有微任务执行完毕。 我们再来看看下面四段代码:

while(true)

while(true);

setTiemout 循环

function foo() {  foo();}foo();0

Promise循环

function foo() {  foo();}foo();1

requestAnimationFrame循环

function foo() {  foo();}foo();2

其中,while(true)和Promise循环会导致页面阻塞,因为while(true)会导致执行到该代码时进入死循环,相当于一个任务永远无法执行完毕,无法继续进行后续的页面更新操作。Promise循环会导致不断又新的微任务加入队列,而这些新加入的微任务也会不断执行,相当于一直在执行新任务,导致死循环。 setTiemout 循环只是不断会有新的任务加入到任务队列中,但每次只执行一个任务。所以不会阻塞。requestAnimationFrame循环每一帧加入一个新任务,然后每一帧清空一次队列,也不会阻塞。

可能是自己对浏览器内部还不是很明白,本文写的不是很清晰,很多术语也存在不严谨的地方。仅当个人的一个学习记录啦。也欢迎大佬们给些建议啦。参考中视频和文章都很不错,建议大家阅读和观看哦。

主要参考: [1] What the heck is the event loop [2] In The Loop [3] How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await

原文:https://juejin.cn/post/7097792008269332494


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/JavaScript/6415.html