JS执行过程与浏览器渲染原理——消息队列与事件循环

看完渡一的课后,感觉这块内容确实非常重要,写 JS 的连 JS 的执行原理都不知道可不行。

事件循环

在写 JS 的时候,你有没有想过 JS 是按照什么顺序执行的?浏览器是怎么执行 JS 代码的?为什么有时候代码没有按照我们认为的顺序执行?JS 作为解释型脚本语言,怎么能用上定时器、回调函数之类的操作?

其实浏览器背后隐藏着一个精密而复杂的机制,那就是事件循环。这个机制使得网页能够响应用户的操作,同时保持了界面的流畅性和高效性。事件循环是现代前端开发中至关重要的概念之一,它负责管理各种异步操作,例如用户输入、网络请求和定时器等。这是浏览器层面的,做前端必须知道的东西。

深入了解浏览器中的事件循环,将使我们能够更好地理解JavaScript在前端开发中的工作原理。本文会详细解析事件循环的内部机制,同时提供实用的示例来帮助你更好地利用这个机制来构建出色的交互体验。

一、进程模型

为了理解事件循环的作用域,不得不提到一些操作系统的底层概念——进程、线程,理解不好这些概念,肯定理解不了事件循环。我尽量简略地解释,这里理解得多,事件循环理解得也越快越好。

当然,进程、线程的概念也是极为重要的底层知识,如果你完全不了解这些,最好可以先看看详细解释。

何为进程

  • 程序运行需要有自己专属的内存空间,可以把这块内存空间简单理解成进程
  • 每个应用至少有一个进程,进程之间相互独立,即使要通信,也要双方同意

在操作系统中,进程指的是正在运行的程序的实例,它包括了程序的代码、数据以及程序执行时所需要的资源。每个进程都有它自己独立的内存空间,可以同时执行不同的任务。并且,一个程序可以占用多个进程,其中有一个主进程是在程序运行之初就被操作系统启动的,而另外的进程都是这个进程启动,来为他分担别的任务的。它负责管理系统资源、调度任务的执行顺序、以及为程序提供必要的环境。

每个进程都拥有独立的内存空间,它们之间不会直接共享内存,因此彼此之间互相隔离。这也是为什么在一个进程中的变量不能直接被另一个进程所访问的原因;但是他们如果达成了一定约定,双发都同意消息的传递,那么是可以互相通信传送数据的。

何为线程

  • 有了进程之后,就可以运行代码了,运行的代码可以成为线程,他在进程环境中运行
  • 一个进程至少有一个线程,进程在开启之后会自动创建一个线程来运行代码,该线程被成为主线程
  • 如果需要同时执行多个代码,即并行去执行多个操作,主线程就会启动更多的线程去执行另外的代码

线程是程序执行的最小单位,它是进程中的一个独立执行流程。一个进程可以包含多个线程,可以同时执行不同的任务,这些线程共享相同的内存空间和其他资源,所以可以很容易互相通信、相互协调。

浏览器的进程和线程

  • 浏览器是一个多进程、多线程的应用程序
  • 为了避免相互影响,启动浏览器之后,会运行多个进程(同时也因为浏览器是一个异常复杂的软件,只用几个进程很难协调好工作)
  • 浏览器有三个重要进程:
    • 浏览器进程:界面显示、用户交互、子进程管理等等,其中会启动多个线程处理不同的任务
    • 网络进程:负责加载网络资源,同样在该进程中会启动多个线程来处理不同的网络任务
    • 渲染进程:1个标签页一个渲染进程
  • 渲染进程:
    • 启动后会开启一个渲染主线程,主线程执行HTML、CSS、JS代码
    • 默认情况下,浏览器会每个标签页开启一个全新的渲染进程,保证不同的标签页之间不会相互影响
    • 后面可能会改变这种模式(有太多进程的时候占用大量资源)

为什么要一个页面一个进程?

因为用户是很容易多开很多页面的,如果这么多个页面公用一块内存空间,也就是公用同一个进程,很容易出现,一个页面出 Bug 把内存卡崩了,一整个进程都卡死,整个浏览器都得重启,因为所有页面都用不了了。

但是一个页面一个进程,可以让一个页面的异常不会影响到其他页面。比如你平时使用浏览器,不会因为知乎的网站崩了,把旁边的CSDN也卡死,你照样可以用CSDN,并且只用重新打开知乎。

关于进程和线程就说这么多,因为这个并不是本文的重点。

二、渲染主线程

如何工作的?

渲染主线程是工作量最大的线程,需要处理的程序包括但不限于:

  • 解析HTML
  • 解析CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒刷新页面60次(渲染帧)
  • 执行全局 JS 代码
  • 执行事件处理函数
  • 执行计时器的回调函数

思考:为什么浏览器不用多个线程来处理上述任务

这么多的任务,如何调度?

例如:

  • 正在执行一个JS函数,执行到一半的时候用户点击了按钮,我该立即去执行点击事件的处理函数吗?
  • 我正在执行一个JS函数,执行到一半的时候某个计时器到达了时间节点,我该立即去执行这个计时器的回调吗?
  • 浏览器进程监听到用户点击了某个按钮,但同时某个计时器也到达了时间节点,我应该处理哪儿一个呢?

为了解决上面的一些调度问题,渲染主线程采用了“排队”的方式。

我们把所有要做的事,一件一件统称为任务,渲染主线程的动作可以看做是对一个接一个的任务的响应和执行。当渲染主线程正在执行一个任务的时候,到来的所有需要执行的任务都会进入一个消息队列事件队列),每到来一个任务,该任务在前一个任务没有执行完的时间中,都会在这个队列中排队等候。

下面是渲染主线程的主要工作:

  1. 在渲染主线程启动的时候都会进入一个无限的循环
  2. 在每一循环中,都会去查询上面提到的消息队列中是否有未执行的任务在等待,如果有,就取出第一个任务,也就是最早到来的任务开始执行,执行完后进入下一个循环,如果没有,则进入休眠状态
  3. 其他所有的线程都能随时往消息队列中添加任务。新任务会添加到消息队列的末尾,如果这个时候主线程是休眠状态,则主线程会被唤醒,开始循环,并在循环中获取任务去执行

事件循环消息循环)的主要步骤就是上面三点,保证了页面能够正常执行事件完成功能。

更深理解

上面讲述了事件循环的大致概念和步骤,下面解释一些更细节的东西,可以让我们理解得更深入。

什么是异步

前端写 JS ,总是绕不开同步和异步,同步很好理解,就是一步一步,从上到下,一行一行执行 js 代码,那什么是异步?

代码在执行过程中,可能遇到一些没有办法立即执行的任务,例如:

  • 计时器结束后触发回调任务(setTimeoutsetInterval…)
  • 网络通信完成后需要执行的任务(向后端发送请求后的操作…)
  • 用户操作后需要执行的任务(addEventListener…)

如果让渲染主线程等待每个任务执行完,再执行下一个任务,那么可能会浪费大量时间,影响页面正常运行。例如,假如设置了一个一分钟的定时器,在消息队列中取到这个任务的时候,不可能一直等待,直到一分钟后执行完该定时器任务后,才执行下一个任务,这样等待的这一分钟内什么事儿都不干,完全浪费掉了,甚至导致页面卡死。

简单提一下计时器的工作原理:

在计时器开始被调用的时候,计时器会通知计时线程,让计时线程开始计时。主线程和计时线程是并行执行的,同属与页面进程。

如果采用一个接一个,上一个任务全部执行完再执行下一个任务,这种思路就是同步,虽然这样可以保证时间线单一,不混乱,但是如上面的例子所说,问题十分严重。所以渲染主线程并不是这么工作的。

setTimeout(() => {
    console.log("计时器结束")
}, 3000)
console.log(1)

如果浏览器是同步执行的,那么 JS 代码是从上到下,上一个代码块执行完,才会执行下一行代码块,那么上面的代码会先输出“计时器结束“,再输出1。如果是异步的,那么打印顺序应该是反过来的:先打印1,再打印“计时器结束”。可以自己试一试上面的测试代码。

实际上,上面的测试代码的运行原理是这样的:主线程触发计时器之后,会立刻获取下一个任务,当计时线程计时结束后,计时线程是不会通知主线程的,而是直接将回调函数加入到消息队列。所以主线程虽然不能直接知道计时器已经结束,但是任然可以从消息队列中知道该何时执行计时器的回调函数。

对上面的知识来个总结概述:

JS 是一门单线程的语言,这是因为他运行在浏览器的渲染主线程中,而渲染主线程只有一个。

主线程承担着许多工作,例如:渲染页面、执行 JS 等等

如果采用同步的方式,极可能会导致主线程产生阻塞,从而导致消息队列中很多任务无法执行,浪费大量时间,甚至导致页面卡顿(无法刷新)、崩溃。

所以浏览器采用异步的方式避免阻塞问题。当某些需要等待的任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束当前任务,进入下一个循环,从消息队列中获取并执行下一个任务。当其他线程完成任务后,将事先传递的回调函数包装成任务(任务是一个对象,不能直接把回调函数加入消息队列)添加到消息队列的队尾,等待主线程执行。

这样,最大限度的保证了单线程的流畅运行。

JS为何会阻碍渲染

假如你有一个页面,其中的主要内容如下:

/**
 * html:
 * <h1>hello</h1>
 * <button>click</button>
 */

const h1 = document.querySelector('h1')
const btn = document.querySelector('button')

const delay = (duration) => {
    const start = Date.now()
    while(Date.now() - start < duration) {}
}

btn.onclick = () => {
    h1.textContent = 'hello world'
    delay(3000)
}

在打开这个页面后,我们通过“事件循环”的角度来分析一下:

  • 首先通过docuemnt.querySelector获取了两个元素实例
  • 设置了一个延时函数
  • 在按钮上绑定了一个事件:渲染主线程将监听的工作交给交互线程去执行,交互线程等待按钮被点击

在页面加载完毕后,我们点击了按钮,会发现一个神奇的现象:标题中的文字内容并没有直接从 hello 变成 helloworld,而是在等待了三秒后才变化。这是为什么?

  • 点击按钮,交互线程监听到了,马上将点击事件的回调包装成任务加入消息队列
  • 主线程执行到了交互线程添加的点击回调任务,开始执行,并执行到h1.textContent = 'hello world'
  • 上面异步执行的代码成功将元素的文本内容修改了,但是修改了不是马上就能被同步到页面的,而是在执行完这一步后,马上生成一个“绘制”任务,添加到消息队列,只有等“绘制”任务执行,页面重绘后才能看见。(但是这个时候html和页面是统一的,并不是html内容改了,但是页面显示有问题)
  • 点击的回调继续执行,调用delay函数延迟三秒。这个delay并没有生成新任务,而是在主线程当前执行的点击回调任务中执行的,所以得等他执行完之后,才能获取下一个任务,也就是第三步生成的“重绘”任务

虽然这个问题浏览器还不能很好解决,但是前端的一些框架已经做出了一定优化,例如 React 会监听一段 JS 的运行时间,不会让某些无用的 JS 持续太长时间。

任务的优先级

任务有没有优先级?有没有加急的任务?

很可惜,任务是不区分优先级的,所有任务都是一视同仁,该排队就得排队。

但是消息队列是有优先级的,队列不是只有一个。最新W3C标准,优化了之前宏任务微任务的架构:

  • 每个任务都有一个任务类型,同一个类型的任务必须都在同一个队列,不同类型的任务可以分属于不同的队列(例如,网络任务和交互任务可以都放在A队列,但是有新的网络任务或者新的交互任务,那么必须放在A队列,而不能放在B队列,注意区分“一个队列只能放同一种任务”这种说法,这种是错误的理解),在一次时间循环中,可以根据实际情况从不同的队列中取出任务(这个就看不同的浏览的不同实现和策略了)

  • 浏览器必须准备好一个微队列,微队列中的任务具有最高的优先级

  • 不再只使用宏队列和微队列,两个队列无法应对当前浏览器的复杂度了

  • 目前 chrome 的实现中,至少包含了下面的队列:

    • 延时队列:用于存放计时器到达后的回调任务,优先级:中
    • 交互队列:用于存放用户操作后产生的事件处理任务,优先级:高
    • 微队列:用户存放需要最快执行的任务,优先级:最高

    添加任务到微队列的主要方式: Promise、MutationObserver

    例如:

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

    一个小题目,输出顺序是什么:

    const a = () => {
    	console.log(1)
    	Promise.resolve().then(() => {
    		console.log(2)
    	})
    }
    
    setTimeout (() => {
    	console.log(3)
    	Promise.resolve().then(a)
    }, 0)
    
    Promise.resolve().then(() => {
    	console.log(4)
    })
    
    console.log(5)
    
    • 浏览器还有很多队列,但是和开发关系弱一些,就不说了

三、总结

主要总结两部分

JS 的事件循环

事件循环又叫消息循环,是浏览器渲染主线程的工作方式。

在Chrome的源码中,主线程开启一个死循环for(;;),每次循环都会从消息队列中取出第一个任务并执行,而且他线程不需要和主线程通信,只需要将任务添加到消息队列即可让主线程执行对应 JS。

过去把消息队列简单分为宏队列和微队列,但现在已经无法满足复杂的浏览器环境,现在的消息队列有更多的分类。

JS 的计时器能精确计时吗

不能:

  • 计算器硬件限制
  • 操作系统本身有时间上的偏差,而 JS 计时器本质上是调用操作系统的时间系统
  • 按照 W3C 的标准,浏览器实现的计时器,如果嵌套层级超过5层,则会带有 4 毫秒的最少时间,导致在计时时间少于 4 毫秒时有一定偏差
  • 事件循环决定了计时器的回调只能在主线程空闲的时候运行,而不能直接打断主线程当前运行的任务
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

沧州刺史

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值