浏览器事件循环

浏览器事件循环

本文探讨的是浏览器的事件循环,如果你对此颇有心得,欢迎指出本文的错误。如果你学过 NodeJs 的事件循环,请暂时忘记,因为浏览器的事件循环和 NodeJs 的事件循环完全不同。

浏览器的进程模型

浏览器是一个多进程多线程的应用程序,内部工作极其复杂(复杂度接近操作系统)。

当我们打开浏览器时,它会自动启动多个进程。使用 Shift + Esc 打开浏览器任务管理器即可查看,其中有:

  • 浏览器进程

    负责界面显示用户交互子进程管理,同时提供存储等功能。浏览器进程内部会启动多个线程来处理不同的任务。

    界面显示是指除标签页以外的界面,包括地址栏、书签栏、前进后退按钮等,这些界面在每个 Tab 页中都是类似的,所以在浏览器进程中统一维护。

  • GPU 进程

    负责 3D 绘制等。

  • 网络进程

    负责网络资源的加载。网络进程内部会启动多个线程来处理不同的网络任务。

  • 插件进程

    负责插件的运行。毕竟插件也可能崩溃,所以需要单独的进程来与渲染进程隔离开来。

  • 渲染进程

    默认情况下一个 Tab 标签页就对应着一个渲染进程,负责页面渲染脚本执行事件处理等。由于进程之间是相互隔离的,所以一个页面的崩溃不会影响其他页面。

    渲染进程内部会有多个线程来处理不同的任务,其中最主要的是渲染主线程

还有一种模式是一个站点对应一个渲染进程,这种模式下,一个站点的所有页面都在同一个渲染进程中。从而减少了进程的数量。

渲染主线程

渲染主线程是渲染进程的主线程,它处理的任务有:

  • 解析 HTML
  • 解析 CSS
  • 计算样式
  • 布局
  • 处理图层
  • 绘制页面(如每秒绘制 60 次页面)
  • 执行 JS 代码
  • 执行事件处理函数
  • 执行定时器回调函数
  • ······

要处理这么多的任务,渲染主线程如何进行任务的调度呢?比如下面问题:

  • 我正在执行一个 JS 代码,突然有一个事件触发了,我应该先处理哪个?
  • 我正在执行一个 JS 代码,突然有一个定时器到期了,我应该先处理哪个?
  • 用户点击了按钮,同时有一个定时器到期了,我应该先处理哪个?

渲染主线程想出一个绝妙的主意来处理这个问题:排队

浏览器事件循环_第1张图片

  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每次循环会检测消息队列是否有任务存在。如果有,则取出第一个任务执行,执行完进行下一个循环;如果没有,则进入休眠状态。
  3. 其他所有线程(包括其他进程的线程)可以随时向消息队列中添加任务。新任务会加入到消息队列的末尾。在添加新任务时,如果渲染主线程正在休眠,则会被唤醒,继续循环拿取任务。

整个过程,称之为事件循环(消息循环)

JS 单线程 & 非阻塞

JS 是一门单线程的语言。 JS 运行在浏览器的渲染主线程中(暂时不考虑 NodeJS),而渲染主线程是一个单线程。

在 JS 刚发明的时候,将 JS 设计成单线程可以简化开发,避免多线程的复杂性。如果 JS 是多线程的,那么两个 JS 线程同时操作 DOM,一个线程删除一个节点,另一个线程又在这个节点上添加一个节点,这样就会出现问题。

随着技术的发展,人们也认识到 JS 单线程的局限性,虽然单线程可以保证程序的执行顺序,但是也限制了程序的执行效率。现在虽然有了 Web Worker,但是 Web Worker 也只是辅助线程,并不能操作 DOM。因此 JS 依然是一门单线程的语言。

JS 是一门非阻塞的语言

非阻塞指的是 JS 在执行的时候,如果遇到了一个异步的任务,比如网络请求、定时器、事件处理函数等,JS 会将这个任务交给浏览器的其他线程去处理,自己继续执行后面的任务。当异步任务完成后,浏览器会将这个任务放到消息队列中,等待渲染主线程调度执行。

JS 是单线程的语言,指的是 JS 代码只能在一个线程中执行,但是 JS 代码执行过程中的某些任务是可以交给其他线程去处理的。

比如setTimeoutsetInterval交给计时线程处理,addEventListener交给交互线程处理等等。

JS 会阻碍渲染

上面不是刚说了 JS 是非阻塞的吗?为什么又说 JS 会阻碍渲染呢?

  • 非阻塞指的是 JS 以异步的方式执行时不会阻塞渲染主线程,因为异步任务是交给其他线程处理的。
  • 而当 JS 以同步的方式执行时,如果执行时间过长,就会阻塞渲染主线程。

下面代码中,当点击按钮时,JS 会执行一个耗时 3s 的死循环,这时候渲染主线程就会被阻塞,导致页面无法响应。

<h1>Hello Worldh1>
<button>点击我,3s后改变上方文字button>
<script>
  const h1 = document.querySelector('h1')
  const btn = document.querySelector('button')

  // 死循环指定的事件
  function delay(ms) {
    const start = Date.now()
    while (Date.now() - start < ms);
  }

  btn.onclick = function () {
    h1.textContent = '谢谢你,Javascript'
    delay(3000)
  }
script>

事件循环

前面简要介绍了事件循环,下面详细介绍它的过程。

前面谈到了消息队列,说渲染主线程会进入一个无限循环,每次循环会检测消息队列是否有任务存在。这个循环的过程就是事件循环。

其实消息队列并不是一个队列,而是多个任务队列的统称,比如:

  • 微队列,存放需要最快执行的任务,优先级【最高】
  • 交互队列,存放交互事件的回调函数,优先级【高】
  • 延时队列,存放定时器到达后的回调函数,优先级 【中】
  • ······

浏览器事件循环_第2张图片

交互队列的优先级比延时队列高是有道理的,因为浏览器认为及时响应用户的交互更重要。

过去曾将任务分为宏任务和微任务,对应的有宏队列和微队列。但随着浏览器复杂度的提升,W3C 不再使用宏队列的说法,而是细分成多个任务队列。微队列依然保留。

微任务有:PromiseMutationObserverprocess.nextTick(NodeJS 中的微任务)等。其他基本上都是宏任务。
例如:

// 立即将一个函数添加到微队列中
Promise.resolve().then(() => {
  console.log('微任务')
})

W3C 规定:

  • 浏览器必须有一个微队列,微队列中的任务优先其他队列中的任务执行
  • 每个任务都有一个任务类型,同一类型的任务必须在一个队列里。不同类型的任务可以分属于不同的队列。

常见面试题

  1. 下面代码的输出是什么?
console.log(1)

setTimeout(() => {
  console.log(2)
})

const observer = new MutationObserver(() => {
  console.log(4)
})
observer.observe(document.body, {
  attributes: true,
})
document.body.setAttribute('id', 'id')

Promise.resolve().then(() => {
  console.log(3)
})

解析:首先渲染主线程执行全局 JS 代码,输出 1。然后遇到 setTimeout,将这个计时任务交给计时线程处理,继续执行后面的代码。遇到 MutationObserver,将这个微任务添加到微队列中。遇到 Promise,将这个微任务添加到微队列中。最后执行完全局 JS 代码后,微队列中有两个任务,微队列中的任务优先级最高,所以先执行微队列中的任务,输出 4,3。同时,计时线程中 setTimeout 的任务到期,计时线程将该任务的回调函数包装成延时任务加入到延时队列中,渲染主线程执行完微队列中的任务后执行该任务,最后输出 2。

参考

  • 渡一机构 —— 大师课
  • 沐华 —— 深入理解浏览器中的进程与线程

你可能感兴趣的:(javascript)