前端面试100问之JavaScript事件循环机制

事件循环是了解JavaScript最重要的关键点之一。这篇文章用简单的术语解释了相关概念。

简介

事件循环机制是理解 JavaScript 内部原理的关键点之一。

本文旨在解释 JavaScript 如何在单线程中工作,以及它如何处理异步函数的内部细节。

JavaScript 代码运行在单线程中,这意味着一次只能运行一行代码。

这是一个非常有用的限制,因为它简化了很多编程问题,而无需担心任何并发问题。

您只需要注意如何编写代码,避免任何可能阻塞线程的事情,比如同步网络调用或无限循环。

一般来说,在大多数浏览器中,每个浏览器的 Tab 页 都有一个自己的事件循环,这样的意义在于每个进程能够得到隔离,并避免出现某个页面无限循环或有繁重处理的任务来阻塞整个浏览器的运行。

环境管理了多个并发事件循环,例如处理 API 调用。Web Worker 也在自己的事件循环中运行。

您主要需要关注的是,您的代码将运行在一个单一的事件循环上,并在编写代码时考虑到这一点,以避免阻塞线程。

阻塞事件循环

任何需要太长时间才能将控制权返回到事件循环的 JavaScript 代码都会阻止页面中其它代码的执行,甚至会阻止UI线程:用户不能单击、滚动页面等。

JavaScript 中几乎所有的I/O原语都是非阻塞的。网络请求、Node.js 的文件系统操作,等等。实际编程中,很少遇到需要去主动阻塞事件循环机制的情况,这就是为什么 JavaScript 代码大多基于回调机制,而近两年才出现基于 promisesasync/await 的 API。

调用栈

所谓调用栈,就是一个后进先出的队列。当有需要运行的函数时,它会将找到的该函数的调用函数继续添加到调用栈中,并按顺序执行每个函数调用。

您应该在浏览器控制台中看到过类似的错误堆栈跟踪日志,浏览器在调用栈中查找函数名,以通知您哪个函数发起了当前调用:

一个简单的事件循环解释

举个例子:

const bar = () =>console.log('bar')

const baz = () =>console.log('baz')

const foo = () => {

console.log('foo')

bar()

baz()

}

foo()

上述代码的运行结果:

foo

bar

baz

当代码运行的时候,第一个 foo() 函数被调用,在 foo() 里面我们调用了 bar(),然后又调用了 baz() 函数。

它们的调用栈如下所示:

每次迭代的事件循环都会查看调用栈中是否有内容,并执行它:

直到这个栈变为空的为止。

Queuing 函数执行

上面的例子看起来很正常,没有什么特别之处:JavaScript 找到要执行的东西,按顺序运行它们。

让我们看看如何延迟一个函数的执行,直到堆栈清除的时候再去执行它。

setTimeout(() => {}), 0) 的用例是调用函数,但在代码中每隔执行一个函数就执行一次。

举个例子:

const bar = () =>console.log('bar')

const baz = () =>console.log('baz')

const foo = () => {

console.log('foo')

setTimeout(bar, 0)

baz()

}

foo()

这段代码的结果,可能会让人惊讶:

foo

baz

bar

运行此代码时,将调用first foo()。在 foo()中,我们首先调用setTimeout,将bar作为参数传递,并指示它尽快运行,将0作为计时器传递。然后我们调用baz()

此时,调用栈如下所示:

以下是程序中所有函数的执行顺序:

为什么会发生这种情况?

消息队列

调用setTimeout()时,浏览器或Node.js启动计时器。一旦计时器过期,在本例中,当我们将0作为超时值时,回调函数将立即放入消息队列中。

消息队列是用户鼠标点击、键盘敲击、调用 fetch 函数获取响应、DOM 触发 onLoad 事件等这些操作进行排队的地方。

循环将优先权赋予调用栈,它首先处理在调用栈中找到的所有内容,一旦其中没有任何内容,它就去获取消息队列中的内容。

我们不必等待setTimeoutfetch或其他函数来完成它们自己的工作,因为它们是由浏览器提供的,并且它们依赖于自己的线程。例如,如果将setTimeout超时设置为2秒,则不必等待2秒,这个等待发生在其他地方,发生在另外一个线程。

ES6 任务队列

ECMAScript 2015引入了 Job Queue 的概念,Promises 使用了这个概念(也是在ES6/ES2015中引入)。这是一种能够尽快执行异步函数的方法,而不是等待调用栈结束的时候才执行。

在当前函数结束之前就可以返回 resolve 状态的 Promises 将在当前函数运行之后立即执行。

我觉得在游乐园玩过山车的比喻很不错:消息队列把你放在队列的后面,也就是排在所有其他人的后面,只有轮到你了你才能坐,而任务队列是一张快速通行证,你可以在完成前一张后再坐一次。

举个例子:

const bar = () =>console.log('bar')

const baz = () =>console.log('baz')

const foo = () => {

console.log('foo')

setTimeout(bar, 0)

newPromise((resolve, reject) =>

resolve('should be right after baz, before bar')

).then(resolve =>console.log(resolve))

baz()

}

foo()

运行结果:

foo

baz

should be right after baz, before bar

bar

这是 Promises(和基于 Promises 构建的 Async/await)与通过setTimeout()或其他平台api实现的普通异步函数之间的一大区别。

参考

  • The JavaScript Event Loop

以上是 前端面试100问之JavaScript事件循环机制 的全部内容, 来源链接: utcz.com/a/26881.html

回到顶部