JavaScript 中的事件循环
JavaScript 有一个基于事件循环的并发模型,它负责执行代码、收集和处理事件以及执行队列中子任务,它被称为事件循环(Event Loop)。了解事件循环的工作方式和原理对于进行优化非常重要。
运行时概念
栈
函数调用形成了一个有若干帧组成的栈。
栈是一种仅限在表为进行添加和删除的线性数据结构。
堆
对象被分配在堆中。堆是一个用来表示一块非结构化内存区域的计算机术语。
堆是一种利用完全二叉树维护的线性数据结构。
队列
一个 JavaScript 运行时包含了一个待处理消息的消息队列。每一个消息都关联着一个用来处理这个消息的回调函数。
在事件循环的合适时刻,JavaScript 运行时会从最早进入消息队列的消息开始处理消息。被处理的消息会被移出消息队列,并以自身为参数调用与之关联的函数。
函数的处理会一直到执行栈再次为空为止,然后事件循环会处理下一个消息。
事件循环
事件循环的概念非常简单,一个无限的循环:JavaScript 引擎等待任务、执行任务,然后进入休眠状态,等待更多任务。
1 | while (queue.waitForMessage()) { |
queue.waitForMessage() 会同步地等待消息到达。
消息队列中的消息只有在当前消息完全的执行完成后,接下来的消息才会被执行。这个特性会带来一些优秀的特性,包括:当一个函数执行时,它不会被抢占,只有在它运行完毕之后才会去运行任何其他的代码,才能修改这个函数操作的数据。
这个模型的一个缺点在于当一个消息需要太长时间才能处理完毕时,Web应用程序就无法处理与用户的交互。一个良好的习惯是缩短单个消息处理时间,并在可能的情况下将一个消息裁剪成多个消息。
零延迟
零延迟并不意味着回调会立即执行。以 0 为第二参数调用 setTimeout 并不表示在 0 毫秒后就立即调用回调函数,其等待的时间取决于队列里待处理的消息数量。延迟参数是运行时处理请求所需的最小等待时间,但并不保证是准确的等待时间。
运行时通信
一个 web worker 或者一个跨域的 iframe 都有自己的栈、堆和消息队列。两个不同的运行时只能通过 postMessage 方法进行通信。如果另一个运行时侦听 message 事件,则此方法会向该运行时添加消息。
浏览器下的事件循环实现
浏览器中所有任务都会被放到调用栈中等待主线程执行。
调用栈
调用栈是解释器(比如浏览器中的 JavaScript 解释器)追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。
宏任务和微任务
在每一个宏任务执行完成后,浏览器会立即执行所有来自微任务队列的微任务,这是比执行任何其它宏任务或者渲染更具有优先级的操作。
只有当微任务队列中的所有微任务都执行完之后,才会执行其它宏任务,这保证了在微任务执行时应用程序的环境不会改变。
如果想要在渲染或处理新的事件之前运行一个异步函数,可以通过 queueMicrotsk 方法来达成。
- 宏任务 -
scripts/setTimeOut/setInterval/I/O/UI Rendering
- 微任务 -
Promise.then catch finally/MutationObserver
同步任务和异步任务
Javascript 单线程任务被分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。
任务执行逻辑
-> 选择当前要执行的任务队列,选择任务队列中最先进入的任务,如果任务队列为空即
null,则执行跳转到微任务(MicroTask)的执行步骤。-> 将事件循环中的任务设置为已选择任务。
-> 执行任务。
-> 将事件循环中当前运行任务设置为
null。-> 将已经运行完成的任务从任务队列中删除。
-> microtasks 步骤:进入microtask检查点。
-> 更新界面渲染。
-> 返回第一步。
-> 设置
microtask检查点标志为true。-> 当事件循环
microtask执行不为空时:选择一个最先进入的microtask队列的microtask,将事件循环的microtask设置为已选择的microtask,运行microtask,将已经执行完成的microtask为null,移出microtask中的microtask。-> 清理
IndexDB事务-> 设置进入
microtask检查点的标志为false。
事件循环的一些应用
分割占用 CPU 较多的任务
假设我们有一个需要占用非常多 CPU 的任务,当浏览器在处理这个任务时,它就不能做其它 DOM 相关的操作,处理用户事件等,这将导致浏览器变的卡顿甚至崩溃,显然这是不可接受的。但我们可以通过将大任务分割成较小任务的方法来避免这个问题。
比如下面的代码,在执行时会导致浏览器挂起一段不短的时间,这期间用户事件都不会得到处理。
1 | let i = 0; |
如果用 setTimeout 将其分割成较小的任务,如果在小任务执行时有其它任务要执行,会被放入任务队列,当目前的分割小任务完成后立即执行这个任务。
1 | let i = 0; |
永不阻塞
JavaScript 的事件循环模型与许多其他语言不同的一个非常有趣的特性是,它永不阻塞。 处理 I/O 通常通过事件和回调来执行,所以当一个应用正等待一个 IndexedDB 查询返回或者一个 XHR 请求返回时,它仍然可以处理其它事情,比如用户输入。