亲宝软件园·资讯

展开

JS单线程

DvorakChen 人气:0

一、单线程

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

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

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

通过这种模型,浏览器将需要快速响应的 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 万次。

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

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

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

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

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

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

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

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

五、setTimeout()

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

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

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

假设如下代码:

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

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

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

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

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

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

如上图所示,在 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函数(如果是第一个的话)并执行。

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

假设这次事件循环需要的时间是 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()函数,然后结束当前函数。

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

事件循环继续执行:

到了 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);
  }
}

加载全部内容

相关教程
猜你喜欢
用户评论