浅谈Javascript单线程和事件循环

单线程

Javascript 是单线程的,意味着不会有其他线程来竞争。为什么是单线程呢?

假设 Javascript 是多线程的,有两个线程,分别对同一个元素进行操作:

function changeValue() {   const e = document.getElementById("ele1");   if (e) {     e.value = "VALUE";   } }  function deleteElement() {   const e = document.getElementById("ele1");   if (e) {     e.remove();   } } 

一个线程将执行 changeValue() 函数,如果元素存在就修改元素的值;一个线程将执行 deleteElement() 函数,如果元素存在就删除元素。此时在多线程的条件下,两个函数同时执行,线程 1 执行,判断元素存在,准备执行修改值的代码 e.value = "VALUE";,此时线程 2 抢占了 CPU,执行了 deleteElement() 函数,完整的执行结束,成功删除了元素,CPU 的控制权回到了线程 1,线程 1 继续执行剩下的代码,也就是将要执行的 e.value = "VALUE";,然而因为这个元素被线程 2 删除了,获取不到元素,修改元素的值失败!

能够发现,浏览器环境下,不管有几个线程,都是共享同一个文档(Document),对 DOM 的频繁操作,多线程将带来极大的不稳定性。如果是单线程,则能够保证对 DOM 的操作是极其稳定和可预见的。你永远不用担心有别的线程抢占了资源,做了什么操作而影响到原来的线程。

由于单线程,JS 一次只能处理一个任务,在该任务处理完成之前,其他任务必须等待。这一点非常重要,在理解下面的事件循环前,首先得明确这个概念。

事件循环

如你所见,因为浏览器执行 Javascript 是单线程,所以一次只能够执行一个任务。那么当出现多个要执行的任务,其他尚未执行的任务在什么地方等待呢?

为了能够让任务有个可以等待执行的地方,浏览器就建立了一个队列,所有的任务都在队列里等待,当要执行任务的时候,就从队列的队头里拿一个任务来执行,执行过程中,其他任务继续等待。当任务执行完之后,再从队列里拿下一个任务来执行。

浅谈Javascript单线程和事件循环

可是,除了开发者编写的 Javascript 代码之外,还有很多事件发生,比如浏览器的点击事件,鼠标移动事件,键盘事件,网络请求等。这些事件也需要执行,而且为了客户体验的流畅,需要尽快执行,以更新页面。我们的队列可能有很多任务正在等待执行,如果把浏览器发生的事件排入队列的队尾,那么在前面的任务执行完成之前,浏览器的页面将一直堵塞住,在用户看在,将是非常卡顿的。

浅谈Javascript单线程和事件循环

为了应对这种问题,浏览器就多加了一个队列,这个队列中的任务,将被尽快执行。为了和前一个队列做区分,前面一个队列就叫宏任务队列吧,这个新加的队列就叫微任务队列吧。宏任务队列的任务叫宏任务,微任务队列里的任务叫微任务。

宏任务队列的执行方式仍不变,还是一次拿一个宏任务来执行。但是在执行完一个宏任务后,就变了,不检查宏任务队列是否为空,而是检查微任务队列是否为空! 如果微任务队列不为空,就执行一个微任务,当前微任务执行完成后,继续检查微任务队列是否为空,如果微任务队列不为空,就再执行一个微任务,直到微任务队列为空。当微任务队列为空后,就渲染浏览器,回到宏任务队列执行,如此循环往复。

浅谈Javascript单线程和事件循环

通过这种模型,浏览器将需要快速响应的 DOM 事件放入微任务队列,以达到快速执行的目的。当微任务队列执行完成后,便按需要重新渲染浏览器,用户就会感觉自己的操作被迅速地响应了。

这种事件执行方式,称为事件循环。浏览器中的事件和代码,就在事件循环模型下执行。

事件循环的应用

通过上图的事件循环模型,我们得知浏览器渲染的顺序,是在执行了一个宏任务和剩下的所有微任务之后,那么为了保证浏览器的渲染顺畅,我们不宜让每一个宏任务的执行事件太长,也不能让清空微任务队列太耗时。一次事件循环中,只执行一个宏任务,那么,对耗时的宏任务需要分解成尽可能小的宏任务,微任务却不同。由于微任务是清空整个微任务队列,所以,在微任务里不要生成新的微任务。毕竟微任务队列的使命就是为了尽可能先处理微任务,然后重新渲染浏览器。

宏任务队列和微任务队列这两者,都是独立于事件循环的,也就是说,在执行 Javascript 代码时,任务队列的添加行为也在发生,即使现在正在清空微任务队列。这是为了避免在执行代码时,发生的事件被忽略。如此可知,即使我们分解一个耗时任务,也不能因为微任务会被优先执行就选择将它分解成多个微任务,这将阻塞浏览器重新渲染。更好的做法是分解成多个宏任务,这样执行一个分解后的宏任务不会太耗时,可以尽快达到让浏览器渲染。

在浏览器的渲染之前,会清空微任务队列,所以,对浏览器 DOM 的修改更新,就适合放到微任务里去执行。

浏览器渲染的次数大概是每秒 60 次,约等于 16ms 一次。在浏览器渲染页面的时候,任何任务都无法再对页面进行修改,这意味着,为了页面的平滑顺畅,我们的代码,单个宏任务和当前微任务队列里所有微任务,都应该在 16ms 内执行完成。否则就会造成页面卡顿。

使用代码来说明

我会用一些简单却有效的代码来说明事件循环如何影响页面效果,以下的代码很少,建议你一起编写,体验一下。

先看下面的代码,我定义了一个 foo() 函数,它将一次性往元素中添加 5 万个子元素,我将在页面加载完成后立即执行它。

function foo() {   const d = document.getElementById("container");   for (let index = 0; index < 50000; index++) {     const e = document.createElement("div");     e.textContent = "NEW";     d.appendChild(e);   } } 

可见这是一个耗时的操作,如果你电脑很好,体验不到卡顿的话,可以换成循环 50 万次。

在一阵时间的卡顿后,页面一次性出现了大量子元素。虽说添加元素的目的达到了,但是元素出现之前的卡顿却不能忍受。根据事件循环,我们能够知道,是因为执行了一个非常耗时的宏任务,导致阻塞了页面的渲染。用下面一张图说明。

浅谈Javascript单线程和事件循环

上面这张图代表着本次事件循环的执行,一开始,浏览器就将 foo() 放进宏任务队列。从 0ms 开始,宏任务队列里有任务,事件循环取出一个宏任务,该宏任务为 foo(),执行,添加 5 万个子元素,执行非常耗时,需要 2000ms(假设的时间),foo() 执行完后,执行微任务,假设我们的清空微任务队列需要执行 5ms,清空后,时间来到了 2005ms,这个时候才能开始重新渲染浏览器。经过了这一次事件循环,竟然耗时了 2015ms!

那么,我们要改善体验,期望是一个平滑的渲染效果。因为浏览器页面的变化,只有在事件循环中重新渲染浏览器这一步才会发生变化,所以我们要做的就是,尽可能快地到事件循环中的渲染浏览器这一步。所以,我们要将这个 foo() 分解成多个宏任务。

为什么不能分解成微任务?因为微任务会在宏任务完成后全部执行。假设我们将添加 5 万 个元素分解成宏任务添加 1000 个,微任务添加 49000 个,那么事件循环还是必须执行完添加 1000 个元素的宏任务后,执行添加 49000 个元素的微任务,才能渲染页面。所以我们要分解成宏任务。

假设我们分解成了 200 个宏任务,每个宏任务都添加 250 个元素,那么,在事件循环执行的时候,任务队列里有 200 个宏任务,取出一个执行,这个宏任务只添加 250 个元素,耗时 10ms。当前宏任务完成后,便清空微任务,耗时 5ms,时间来到了 15ms,就可以渲染浏览器了。这一次事件循环,在渲染浏览器前只耗时 15ms!

接着,渲染浏览器后,页面上出现了 250 个元素,又开始事件循环,从宏任务队列里拿出一个宏任务执行。

浅谈Javascript单线程和事件循环

如上图所示,接连不断的事件循环使浏览器渲染看起来平滑顺畅。

接下来我们便改造我们的代码,让它分解成多个宏任务。

setTimeout()

setTimeout() 函数,用于将一个函数延迟执行,是我们的重点方法。

你应该很熟悉这个函数的用法了,setTimeout() 接收两个参数,第一个是一个回调函数,第二个是数字,用于指示延迟多少时间,以毫秒为单位(ms)。

这里主要介绍的是第二个参数,很多人以为第二个参数是指延迟多少毫秒后执行传进来的函数,但其实,它的真正含义是:延迟多少毫秒后进入宏任务队列

假设如下代码:

setTimeout(() => {   console.log("execute setTimeout()"); }, 10); 

下面我用一张图说明这段代码的执行,图中,上方代表时间轴,下方代表宏任务队列。

浅谈Javascript单线程和事件循环

在 0ms 时,注册 setTimeout 函数,第一个参数里的方法将在 10ms 后加入宏任务队列,此时,宏任务时没有我们代码里的任务的。

其他我们不知道的 JS 代码执行了 10 ms。

到了 10ms 后,setTimeout 到期,第一个参数里的方法加入宏任务队列。

浅谈Javascript单线程和事件循环

上图中,10ms 到了,加入了宏任务队列。但是要注意,事件循环此时可能正在执行一个宏任务,或者正在清空微任务队列,或者正在渲染浏览器,所以不会马上执行新增加的宏任务,只有又一次循环到了执行宏任务的时候,才会从宏任务队列中获取宏任务执行(JS 是单线程的)。假设这段时间耗时了 5ms,那么如下图。

浅谈Javascript单线程和事件循环

如上图所示,在 15ms 的时候,我们才从宏任务队列里取出在 10ms 时放入宏任务队列的宏任务,并执行。和我们的代码对比,尽管 setTimeout 的第二个参数是 10ms,却在 15ms 才执行。

当理解了 setTimeout 的原理之后,便可以使用 setTimeout 将一个耗时的任务分解成多个宏任务,以充分给予浏览器渲染。

我修改了 foo 函数,如下所示:

function foo() {   const d = document.getElementById("container");   const total = 50000;   const size = 250;   const chunk = total / size;   let i = 0;   setTimeout(function render() {     for (let index = 0; index < size; index++) {       const e = document.createElement("div");       e.textContent = "NEW";       d.appendChild(e);     }     i++;     if (i < chunk) {       setTimeout(render, 0);     }   }, 0); } 

foo 方法中,首先获取了要添加子元素的元素,和定义了各种变量。total 表示一共有几个元素要添加,因为我电脑性能差,所以是 5 万,你可以修改成你喜欢的值;size 是指我们分解后每个宏任务要添加几次元素;chunk 是指分解后,一共有几个宏任务,通过简单的计算得到;i 是用于标记执行到了第几个宏任务了。

接下来就是重点了,注册了 setTimeout,在 0ms 后将传入的 render 函数放进宏任务队列里。然后这个 foo 函数就执行结束了,事件循环继续往下执行,清空微任务队列,渲染浏览器。等到下一个事件循环的时候,才会从宏任务队列里拿出由 setTimeout 放入的 render 函数(如果是第一个的话)并执行。

浅谈Javascript单线程和事件循环

如上图所示,当前的事件循环正在执行 foo() 函数,此时 render() 在宏任务队列中等待。

浅谈Javascript单线程和事件循环

假设这次事件循环需要的时间是 10ms,那么到了 10ms 后,事件循环开始了新的一轮,从宏任务队列里获取一个新的宏任务,获取到了 render() 任务并执行。来看 render() 函数里的代码:

function render() {   for (let index = 0; index < size; index++) {     const e = document.createElement("div");     e.textContent = "NEW";     d.appendChild(e);   }   i++;   if (i < chunk) {     setTimeout(render, 0);   } } 

代码执行了 for 循环,添加 size 次数的子元素,在示例中 size 定义为了 250,添加 250 个子元素,数量不多,添加过程会非常快。在执行完 for 循环后,将外部的 i 变量加 1,我们将使用 i 判断所有的子元素是否添加完毕,如果是则结束函数,如果不是,则再次通过 setTimeout 注册一个 render() 函数,然后结束当前函数。

浅谈Javascript单线程和事件循环

如上图,在 15ms 的时候,render() 函数添加了 250 个子元素,然后使用 setTimeout 注册了一个新的宏任务,在 0ms 后进入宏任务队列。注意此时,尽管 render() 函数添加了 250 个子元素,但是事件循环还没有到渲染浏览器这一步,所以页面没有出现 250 个新元素。

事件循环继续执行:

浅谈Javascript单线程和事件循环

到了 15ms,执行微任务队列,假设需要执行 5ms。到了 20 ms,清空了微任务队列,开始渲染浏览器,假设渲染需要 5ms,界面上出现了 250 个新元素。这次,只花费了 15ms,就让页面上渲染出了元素,而不是一开始那样卡顿了 2000ms 后才页面才渲染!

接下来的事件循环就是一直重复 10ms 开始到 25ms 的动作了,直到所有子元素都渲染完毕。

通过改造后的 foo() 函数,我们将卡顿的页面优化成了观感良好顺畅的页面。从新旧 foo() 函数的代码量来看,代码数量的多少跟页面顺畅与否没有太大关系。重点是理解事件循环中发生的事。

思考:劣质的优化

如果我将 foo() 函数改写成如下的形式,会怎么样,亲自试一试,思考执行的事件循环和宏任务队列中发生了什么。

function foo() {   const d = document.getElementById("container");   const size = 1000;   const chunk = 50000 / size;   for (let index = 0; index < chunk; index++) {     setTimeout(() => {       const e = document.createElement("div");       e.textContent = "NEW";       d.appendChild(e);     }, 0);   } } 

发表评论

相关文章