看完渡一的课后,感觉这块内容确实非常重要,写 JS 的连 JS 的执行原理都不知道可不行。
在写 JS 的时候,你有没有想过 JS 是按照什么顺序执行的?浏览器是怎么执行 JS 代码的?为什么有时候代码没有按照我们认为的顺序执行?JS 作为解释型脚本语言,怎么能用上定时器、回调函数之类的操作?
其实浏览器背后隐藏着一个精密而复杂的机制,那就是事件循环。这个机制使得网页能够响应用户的操作,同时保持了界面的流畅性和高效性。事件循环是现代前端开发中至关重要的概念之一,它负责管理各种异步操作,例如用户输入、网络请求和定时器等。这是浏览器层面的,做前端必须知道的东西。
深入了解浏览器中的事件循环,将使我们能够更好地理解JavaScript在前端开发中的工作原理。本文会详细解析事件循环的内部机制,同时提供实用的示例来帮助你更好地利用这个机制来构建出色的交互体验。
为了理解事件循环的作用域,不得不提到一些操作系统的底层概念——进程、线程,理解不好这些概念,肯定理解不了事件循环。我尽量简略地解释,这里理解得多,事件循环理解得也越快越好。
当然,进程、线程的概念也是极为重要的底层知识,如果你完全不了解这些,最好可以先看看详细解释。
在操作系统中,进程指的是正在运行的程序的实例,它包括了程序的代码、数据以及程序执行时所需要的资源。每个进程都有它自己独立的内存空间,可以同时执行不同的任务。并且,一个程序可以占用多个进程,其中有一个主进程是在程序运行之初就被操作系统启动的,而另外的进程都是这个进程启动,来为他分担别的任务的。它负责管理系统资源、调度任务的执行顺序、以及为程序提供必要的环境。
每个进程都拥有独立的内存空间,它们之间不会直接共享内存,因此彼此之间互相隔离。这也是为什么在一个进程中的变量不能直接被另一个进程所访问的原因;但是他们如果达成了一定约定,双发都同意消息的传递,那么是可以互相通信传送数据的。
线程是程序执行的最小单位,它是进程中的一个独立执行流程。一个进程可以包含多个线程,可以同时执行不同的任务,这些线程共享相同的内存空间和其他资源,所以可以很容易互相通信、相互协调。
为什么要一个页面一个进程?
因为用户是很容易多开很多页面的,如果这么多个页面公用一块内存空间,也就是公用同一个进程,很容易出现,一个页面出 Bug 把内存卡崩了,一整个进程都卡死,整个浏览器都得重启,因为所有页面都用不了了。
但是一个页面一个进程,可以让一个页面的异常不会影响到其他页面。比如你平时使用浏览器,不会因为知乎的网站崩了,把旁边的CSDN也卡死,你照样可以用CSDN,并且只用重新打开知乎。
关于进程和线程就说这么多,因为这个并不是本文的重点。
渲染主线程是工作量最大的线程,需要处理的程序包括但不限于:
思考:为什么浏览器不用多个线程来处理上述任务
这么多的任务,如何调度?
例如:
为了解决上面的一些调度问题,渲染主线程采用了“排队”的方式。
我们把所有要做的事,一件一件统称为任务,渲染主线程的动作可以看做是对一个接一个的任务的响应和执行。当渲染主线程正在执行一个任务的时候,到来的所有需要执行的任务都会进入一个消息队列(事件队列),每到来一个任务,该任务在前一个任务没有执行完的时间中,都会在这个队列中排队等候。
下面是渲染主线程的主要工作:
事件循环(消息循环)的主要步骤就是上面三点,保证了页面能够正常执行事件完成功能。
上面讲述了事件循环的大致概念和步骤,下面解释一些更细节的东西,可以让我们理解得更深入。
前端写 JS ,总是绕不开同步和异步,同步很好理解,就是一步一步,从上到下,一行一行执行 js 代码,那什么是异步?
代码在执行过程中,可能遇到一些没有办法立即执行的任务,例如:
setTimeout
、setInterval
…)如果让渲染主线程等待每个任务执行完,再执行下一个任务,那么可能会浪费大量时间,影响页面正常运行。例如,假如设置了一个一分钟的定时器,在消息队列中取到这个任务的时候,不可能一直等待,直到一分钟后执行完该定时器任务后,才执行下一个任务,这样等待的这一分钟内什么事儿都不干,完全浪费掉了,甚至导致页面卡死。
简单提一下计时器的工作原理:
在计时器开始被调用的时候,计时器会通知计时线程,让计时线程开始计时。主线程和计时线程是并行执行的,同属与页面进程。
如果采用一个接一个,上一个任务全部执行完再执行下一个任务,这种思路就是同步,虽然这样可以保证时间线单一,不混乱,但是如上面的例子所说,问题十分严重。所以渲染主线程并不是这么工作的。
setTimeout(() => {
console.log("计时器结束")
}, 3000)
console.log(1)
如果浏览器是同步执行的,那么 JS 代码是从上到下,上一个代码块执行完,才会执行下一行代码块,那么上面的代码会先输出“计时器结束“,再输出1。如果是异步的,那么打印顺序应该是反过来的:先打印1,再打印“计时器结束”。可以自己试一试上面的测试代码。
实际上,上面的测试代码的运行原理是这样的:主线程触发计时器之后,会立刻获取下一个任务,当计时线程计时结束后,计时线程是不会通知主线程的,而是直接将回调函数加入到消息队列。所以主线程虽然不能直接知道计时器已经结束,但是任然可以从消息队列中知道该何时执行计时器的回调函数。
对上面的知识来个总结概述:
JS 是一门单线程的语言,这是因为他运行在浏览器的渲染主线程中,而渲染主线程只有一个。
主线程承担着许多工作,例如:渲染页面、执行 JS 等等
如果采用同步的方式,极可能会导致主线程产生阻塞,从而导致消息队列中很多任务无法执行,浪费大量时间,甚至导致页面卡顿(无法刷新)、崩溃。
所以浏览器采用异步的方式避免阻塞问题。当某些需要等待的任务发生时,比如计时器、网络、事件监听,主线程将任务交给其他线程去处理,自身立即结束当前任务,进入下一个循环,从消息队列中获取并执行下一个任务。当其他线程完成任务后,将事先传递的回调函数包装成任务(任务是一个对象,不能直接把回调函数加入消息队列)添加到消息队列的队尾,等待主线程执行。
这样,最大限度的保证了单线程的流畅运行。
假如你有一个页面,其中的主要内容如下:
/**
* html:
* hello
*
*/
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'
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)
主要总结两部分
事件循环又叫消息循环,是浏览器渲染主线程的工作方式。
在Chrome的源码中,主线程开启一个死循环for(;;)
,每次循环都会从消息队列中取出第一个任务并执行,而且他线程不需要和主线程通信,只需要将任务添加到消息队列即可让主线程执行对应 JS。
过去把消息队列简单分为宏队列和微队列,但现在已经无法满足复杂的浏览器环境,现在的消息队列有更多的分类。
不能: