从实现一个for循环进度条来理解js的事件循环

24 阅读4分钟

如何写一个预先不知道长度的数组读取的for循环进度条呢?

首先把进度条的ui样式准备好,代码如下:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>progress</title>
    <style>
      body,
      html {
        margin: 0;
        width: 100%;
      }
      #container {
        width: 90%;
        height: 20px;
        margin: 20px 5%;
        box-sizing: border-box;
        border: 1px solid #aaa;
        border-radius: 3px;
        position: relative;
      }
      #content {
        width: 0;
        height: 20px;
        background: #16b777;
      }
      #text {
        width: 18px;
        height: 20px;
        position: absolute;
        left: calc(50% - 10px);
        right: 0;
        top: 0;
        font-size: 14px;
        line-height: 18px;
        bottom: 0;
      }
    </style>
  </head>

  <body>
    <div id="container">
      <div id="content"></div>
      <div id="text"></div>
    </div>
    <script>
      const container = document.getElementById("container");
      const content = document.getElementById("content");
      const text = document.getElementById("text");
      const width = container.clientWidth;
    </script>
  </body>
</html>

container是进度条的外层容器,content是进度条,text用来显示当前进度。进度条的实现就是通过改变content的长度就可以实现了,那如何改变呢?可能第一个想到的就是:

for(let i = 0; i < arr.length; i ++) {
  content.style.width = i * width / arr.length + 'px';
  text.innerText = (i * 100) / arr.length + "%";
}

但是这样写了之后,发现并没有达到预期的效果,进度条不是递增的,而是直接从0蹦到100%;为什么会这样呢?这要从js语言执行的事件循环机制说起,什么是事件循环呢?先看一张图:

所谓事件循环,就是图片最下层的消息队列,也被称为js的宏任务,js产生宏任务的方式有

  • script(整体代码)
  • setTimeout
  • setInterval
  • I/O
  • UI交互事件
  • postMessage
  • MessageChannel
  • setImmediate
  • UI rendering

宏任务队列会挨个执行,每个宏任务队列都会有自己的执行环境栈,在执行环境栈中每个函数帧执行的时候,有可能会产生微任务,微任务都会添加到当前宏任务的最后,在这个宏任务执行完成后,立即执行微任务,当所有微任务执行完成之后,才会执行下一个宏任务。

js中会产生微任务的方式有:

  • Promise.then
  • Object.observe
  • MutaionObserver
  • process.nextTick

而浏览器会在每个宏任务执行完成(包括当前宏任务所有的微任务)之后,下一个宏任务开始执行之前,对页面进行渲染。

因此,使用上面的方法,没有办法达到进度条的效果,因为对dom元素的修改都是在同一个宏任务中进行的,当浏览器对页面进行渲染时,只能拿到最后一次给dom元素赋的值,所以就会从0直接蹦到100%。因此,如果要产生进度条效果,就需要生成多个宏任务,在每个宏任务中修改dom元素,这样才会有进度条的效果。

有了对这些概念的理解,就可以对代码做一些修改:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>progress</title>
    <style>
      body,
      html {
        margin: 0;
        width: 100%;
      }
      #container {
        width: 90%;
        height: 20px;
        margin: 20px 5%;
        box-sizing: border-box;
        border: 1px solid #aaa;
        border-radius: 3px;
        position: relative;
      }
      #content {
        width: 0;
        height: 20px;
        background: #16b777;
      }
      #text {
        width: 18px;
        height: 20px;
        position: absolute;
        left: calc(50% - 10px);
        right: 0;
        top: 0;
        font-size: 14px;
        line-height: 18px;
        bottom: 0;
      }
    </style>
  </head>

  <body onload="progress()">
    <div id="container">
      <div id="content"></div>
      <div id="text"></div>
    </div>
    <script>
      const container = document.getElementById("container");
      const content = document.getElementById("content");
      const text = document.getElementById("text");
      const count = 10000;
      const width = container.clientWidth;
      function render(i) {
        setTimeout(() => {
          text.innerText = (i * 100) / count + "%";
          content.style.width = (width * i) / count + "px";
        }, 0);
      }
      function progress() {
        for (let i = 0; i <= count; i++) {
          render(i);
        }
      }
    </script>
  </body>
</html>

通过setTimeout产生宏任务,在宏任务中对dom元素做修改,这样就会有动态变化的效果。但是在实际运行过程中会发现,如果循环次数很多的时候,界面会先卡住一段时间,然后才出现进度条的变化。为什么呢?

因为js是单线程执行的,for循环开始执行,就会一直到全部循环完成,这个宏任务才算结束,然后后续的宏任务才会执行。怎样解决单线程执行造成的堵塞呢?可以使用async函数来解决。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>progress</title>
    <style>
      body,
      html {
        margin: 0;
        width: 100%;
      }
      #container {
        width: 90%;
        height: 20px;
        margin: 20px 5%;
        box-sizing: border-box;
        border: 1px solid #aaa;
        border-radius: 3px;
        position: relative;
      }
      #content {
        width: 0;
        height: 20px;
        background: #16b777;
      }
      #text {
        width: 18px;
        height: 20px;
        position: absolute;
        left: calc(50% - 10px);
        right: 0;
        top: 0;
        font-size: 14px;
        line-height: 18px;
        bottom: 0;
      }
    </style>
  </head>

  <body onload="progress()">
    <div id="container">
      <div id="content"></div>
      <div id="text"></div>
    </div>
    <script>
      const container = document.getElementById("container");
      const content = document.getElementById("content");
      const text = document.getElementById("text");
      const count = 10000;
      const width = container.clientWidth;
      function render(i) {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            text.innerText = (i * 100) / count + "%";
            content.style.width = (width * i) / count + "px";
            resolve();
          }, 0);
        });
      }
      async function progress() {
        for (let i = 0; i <= count; i++) {
          await render(i);
        }
      }
    </script>
  </body>
</html>

这样,就实现了一个效果还可以的进度条。