title: 通过一个JS动画了解JS中的EventLoop date: 2019-11-30 10:22:00 updated: 2019-11-30 10:22:00 photos:

  • https://img.yzmblog.top/blog/eventloop.png tags:
  • 前端
  • Javascript
  • node categories:
  • Javascript excerpt: 上一篇文章说了ES6中的Proxy,现在就来利用proxy一步步实现一个模拟vue的双向绑定。

前言

上一篇文章我们说了如何实现一个Promise,这次我们就通过一个JS 动画了解一下什么是EventLoop

一个 JS 动画

  • 这次需要实现的动画是一个通过 JS 向前端页面添加文本和样式的动画。最终的效果如下图:

  • 调用代码

  • 根据实现结果和调用代码,可以想到的实现思路是:

    • 通过链式调用将数据的向前端一行一行地输出。
    • 主要通过textstyle两个方法向前端输出
    • 输出的每一行都可以有时间控制
    • 通过setTimeout向前端输出文字

基本思路已经有了,下面就开始码吧~~

1.创建 Animate 类

  • 首先需要创建一个 Animate 的方法,在调用时初始化信息
  • 实例化的时候需要传两个参数,head的 element 标签和body的 element 标签
  • 初始化信息包括创建三个 HTML 标签,分别是输出的信息的box输出style的box更新样式的style标签
class Animate {
  constructor(headElement, bodyElement) {
    this.element = {
      headElement,
      bodyElement,
      contentElement: null, // 输出内容的box
      styleElement: null, // 更新样式的style标签
      styleContent: null, // 输出style的box
    };

    this.init(); // 实例化时初始化
  }

  init = () => {
    const styleElement = createElement("style");
    const contentElement = createElement("div", { class: "text_content" });
    const styleContent = createElement("div", { class: "style_content" });

    // 向前端插入节点
    this.element.headElement.appendChild(styleElement);
    this.element.bodyElement.appendChild(contentElement);
    this.element.bodyElement.appendChild(styleContent);

    this.element.contentElement = contentElement;
    this.element.styleElement = styleElement;
    this.element.styleContent = styleContent;
  };
}

// 封装创建Element方法
function createElement(elementName, elementObj = {}, styleObj = {}) {
  const element = document.createElement(elementName);

  for (const option of Object.keys(elementObj)) {
    element.setAttribute(option, elementObj[option]);
  }

  for (const styleName of Object.keys(styleObj)) {
    element.style[styleName] = styleObj[styleName];
  }

  return element;
}

// 调用
const head = document.querySelector("head");
const body = document.querySelector("body");
const animate = new Animate(head, body);

调用后我们需要用来展示和改变样式的节点都已经创建完成了,接下来需要实现往text_content中添加信息

2.往 text 中添加信息

  • 上面已经说了,我们需要用setInterval往内容盒子中添加信息,并且将总输出时间为用户输出的时间,那就需要计算输出每一个字的间隔时间,公式也很简单输出字体间隔时间 = 输出总时间 / 文字总长度

Animate类中添加text方法,输出文字的公共方法,还有一个输出字体间隔时间的方法

    /**
     *  添加text
     *  @param {Element} appendNode 插入文本的节点
     *  @param {string} elName 标签名
     *  @param {Object} elOption 标签设置
     *  @param {Object} styleObj 内联样式
     *  @param {Object} text 输出的文字
     *  @param {number} during 输出文字的总时间
     */
    text = (elName, elOption, styleObj, text, during) => {
        const element = createElement(elName, elOption, styleObj);
        this.element.contentElement.appendChild(element);
        this.printText(element, text, during); // 输出文字
    }

    /**
     * 输出文字公共方法
     * @param {Element} element 输出文字的标签
     * @param {string} text 输出的文字
     * @param {number} during 输出文字的总时间
     */
    printText = (element, text, during) => {
        const len = text.length; // 文字总长度
        const time = this.getTextDuring(len, during); // 每个字输出时间
        let index = 0;
        let timer = null;
        timer = setInterval(() => {
            if (index < len) {
                element.innerHTML += text[index];
                index++;
            } else {
                clearInterval(timer);
                resolve();
            }
        }, time);
    }

    /**
     * 获取输出每个字的时间间隔
     * @param {string} textLen
     * @param {number} during
     */
    getTextDuring(textLen, during) {
        return (during / textLen).toFixed(4);
    }

    // 测试
    const head = document.querySelector('head');
    const body = document.querySelector('body');
    const animate = new Animate(head, body);
    animate.text('p', {}, {color: 'red'}, 'Hello World', 2000);

调用animate.text后可以看到他已经可以输出啦:

3.EventLoop(事件循环机制)

我们的Animate写到这里,它的最基础的输出文字功能已经实现啦。接下来需要实现的是让每一段文字按顺序输出,要实现按顺序地输出必须要让每一个输出方法按顺序地执行。但是 JavaScript 它不会让我们实现的方法轻易地得逞~
测试一下:

// 测试
const head = document.querySelector("head");
const body = document.querySelector("body");
const animate = new Animate(head, body);
animate.text("p", {}, { color: "red" }, "Hello World1", 2000);
animate.text("p", {}, { color: "red" }, "Hello World2", 2000);
animate.text("p", {}, { color: "red" }, "Hello World3", 2000);

输出: 我们理想的状态应该是每一个text都按顺序执行,而不是同时执行。这时因为 Javascript 是单线程的,所以 JS 在执行的时候会有一个执行队列(先进先出),它将所有要执行任务放到执行队列中按序执行,但执行任务又是什么?
JS 中执行任务分为两种:宏任务(macrotask)微任务(microtask),他们在 JS 中的执行顺序是:先执行宏任务后执行微任务

  • 宏任务有:script,setInterval,setTimeout,I/O,requestAnimationFrame,setImmediate(Node.js)
  • 微任务有:Promise.then,MutationObserver,process.nextTick(Node.js)

了解完执行队列,再来看一下刚刚写的测试例子。当调用animate.text时,因输出文字方法中有setIntervalsetInterval不会先执行,而是插入到任务队列中,如下图

当三个animate.text执行完后,任务队列添加了三个setInterval宏任务,当 script 的方法执行完后,第一插入的setInterval开始执行并输出H,然后在指定的时间往任务队列最后面添加插入宏任务(因为setInterval一个循环执行的方法,每隔一段时间会执行,直到计时器被清除)

了解完 JS 的执行队列,回到动画代码中,我们需要怎样才能实现一个一个text方法按顺序执行。
上一篇文章在实现 Promise 的时候,使用了两个运行队列(resolveArr,rejectArr)来装等待状态改变时执行的方法。这里同样也可以这样做,在类的构造器中添加一个函数数组,将所有执行时的方法在script宏任务执行时就添加到数组中,script中的代码执行完后再新建一个宏任务去一个一个执行数组中的方法。
接下来要:

  • 往构造器中添加一个函数数组
  • 添加一个执行函数数组的run方法
  • 添加一个宏任务去执行run
  • text方法中执行的代码放到函数数组中,每次执行完后都调用run方法去执行函数数组中的下一个方法
  • 修改printText方法为 Promise 方法
    constructor() {
        this.runArr = []; // 函数数组


![](https://user-gold-cdn.xitu.io/2019/11/30/16eb9e73100327d0?w=368&h=240&f=gif&s=364207)
    }

    // 执行函数的方法
    run = () => {
        this.runArr.length ? this.runArr.splice(0,1)[0]() : 0;
    }

    /**
     *  添加text
     *  @param {Element} appendNode 插入文本的节点
     *  @param {string} elName 标签名
     *  @param {Object} elOption 标签设置
     *  @param {Object} styleObj 内联样式
     *  @param {Object} text 输出的文字
     *  @param {number} during 输出文字的总时间
     */
    text = (elName, elOption, styleObj, text, during) => {
        this.runArr.push(async () => {
            const element = createElement(elName, elOption, styleObj);
            this.element.contentElement.appendChild(element);
            await this.printText(element, text, during);
            this.run();
        })
    }

    /**
     *
     * @param {Element} element 输出文字的标签
     * @param {string} text 输出的文字
     * @param {number} during 输出文字的总时间
     */
    printText = (element, text, during) => {
        return new Promise(resolve => {
            const len = text.length;
            const time = this.getTextDuring(len, during);
            let index = 0;
            let timer = null;
            timer = setInterval(() => {
                if (index < len) {
                    element.innerHTML += text[index];
                    index++;
                } else {
                    clearInterval(timer);
                    resolve();
                }
            }, time);
        })
    }

修改完后我们再来调用刚刚的测试例子

// 测试
const head = document.querySelector("head");
const body = document.querySelector("body");
const animate = new Animate(head, body);
animate.text("p", {}, { color: "red" }, "Hello World1", 2000);
animate.text("p", {}, { color: "red" }, "Hello World2", 2000);
animate.text("p", {}, { color: "red" }, "Hello World3", 2000);

成功了!现在再来看一下它的执行队列图:

4.最后添加 style 和链式调用

到这里我们的方法已经可以实现按顺序向界面输出文字了,最后需要做的是添加style链式调用,添加style的实现方法和添加文字大致是相同的,链式调用其实就是在每个方法执行后 return 这个对象本身就可以了,这里就不多做解释啦,最终的代码:

"use strict";

class Animate {
  constructor(headElement, bodyElement) {
    this.element = {
      headElement,
      bodyElement,
      styleElement: null,
      contentElement: null,
      styleContent: null,
    };
    this.runArr = []; // 执行函数的组合
    this.init();

    setTimeout(() => {
      console.log(this.runArr);
      this.run();
    }, 0);
  }

  // 创建文本界面
  init = () => {
    const styleElement = createElement("style");
    const contentElement = createElement("div", { class: "text_content" });
    const styleContent = createElement("div", { class: "style_content" });

    this.element.headElement.appendChild(styleElement);
    this.element.bodyElement.appendChild(contentElement);
    this.element.bodyElement.appendChild(styleContent);

    this.element.contentElement = contentElement;
    this.element.styleElement = styleElement;
    this.element.styleContent = styleContent;
  };

  run = () => {
    this.runArr.length ? this.runArr.splice(0, 1)[0]() : 0;
  };

  /**
   *  添加text
   *  @param {Element} appendNode 插入文本的节点
   *  @param {string} elName 标签名
   *  @param {Object} elOption 标签设置
   *  @param {Object} styleObj 内联样式
   *  @param {Object} text 输出的文字
   *  @param {number} during 输出文字的总时间
   */
  text = (elName, elOption, styleObj, text, during) => {
    this.runArr.push(async () => {
      const element = createElement(elName, elOption, styleObj);
      this.element.contentElement.appendChild(element);
      await this.printText(element, text, during);
      this.run();
    });
    return this;
  };

  /**
   * 添加style文件
   * @param {string} selector 选择器名称
   * @param {*} styleObject
   * @param {*} during
   */
  style = (selector, styleObject, during) => {
    this.runArr.push(async () => {
      const parentElement = createElement("ul", { class: "style_row" });
      this.element.styleContent.appendChild(parentElement);
      await this.printStyle(parentElement, selector, styleObject, during);
      this.run();
    });
    return this;
  };

  /**
   *
   * @param {Element} element 输出文字的标签
   * @param {string} text 输出的文字
   * @param {number} during 输出文字的总时间
   */
  printText = (element, text, during) => {
    return new Promise((resolve) => {
      const len = text.length;
      const time = this.getTextDuring(len, during);
      let index = 0;
      let timer = null;
      timer = setInterval(() => {
        if (index < len) {
          element.innerHTML += text[index];
          index++;
        } else {
          clearInterval(timer);
          resolve();
        }
      }, time);
    });
  };

  /**
   * 输出style
   * @param {Element} parentElement 样式的父Element
   * @param {string} selectorName 选择器的文字
   * @param {Object} styleObject 样式对象
   * @param {number} during 输出总时间
   */
  printStyle = (parentElement, selectorName, styleObject, during) => {
    return new Promise(async (resolve) => {
      const styleStr = JSON.stringify(styleObject).length;
      const textLen = selectorName.length + styleStr + 2; // 加 2 是加上左右括号
      const time = this.getTextDuring(textLen, during);

      const list = createElement("li", { class: "selector" }); // <li></li> 列表
      const selector = createElement("span", { class: "selector" }); // <span></span> css选择器
      const bracketsLeft = createElement("span", { class: "style_brackets" }); // <span>{</span> 左大括号
      const bracketsRight = createElement("span", { class: "style_brackets" }); // <span>{</span> 右大括号
      list.appendChild(selector);
      list.appendChild(bracketsLeft);
      parentElement.appendChild(list);

      await this.printText(selector, selectorName, time * selectorName.length);
      await this.printText(bracketsLeft, " { ", time * 3);
      this.element.styleElement.innerHTML += `${selectorName} {\n`;
      for (const style of Object.keys(styleObject)) {
        const el = this.createStyleElement(list);
        await this.printText(el.styleName, style, time * style.length);
        await this.printText(el.colon, ": ", time * 2);
        await this.printText(
          el.style,
          `${styleObject[style]};\n`,
          time * styleObject[style].length
        );
        this.element.styleElement.innerHTML += `${style} : ${styleObject[style]}; \n`;
      }
      list.appendChild(bracketsRight);
      await this.printText(bracketsRight, "}", time);
      this.element.styleElement.innerHTML += `} \n`;
      resolve();
    });
  };

  /**
   * 创建样式element
   */
  createStyleElement = (list) => {
    const p = createElement("p", { class: "style_row" });
    const style = createElement("span", { class: "style" }); // <span></span> 样式
    const styleName = createElement("span", { class: "style_name" }); // <span><span> 样式名
    const colon = createElement("span", { class: "style_colon" }); // <span>:</span> 冒号
    p.appendChild(styleName);
    p.appendChild(colon);
    p.appendChild(style);
    list.appendChild(p);
    return {
      style,
      styleName,
      colon,
    };
  };

  /**
   * 获取输出每个字的时间间隔
   * @param {string} textLen
   * @param {number} during
   */
  getTextDuring(textLen, during) {
    return (during / textLen).toFixed(4);
  }
}

// 创建Element
function createElement(elementName, elementObj = {}, styleObj = {}) {
  const element = document.createElement(elementName);

  for (const option of Object.keys(elementObj)) {
    element.setAttribute(option, elementObj[option]);
  }

  for (const styleName of Object.keys(styleObj)) {
    element.style[styleName] = styleObj[styleName];
  }

  return element;
}

测试:

"use strict";

const head = document.querySelector("head");
const body = document.querySelector("body");
const animate = new Animate(head, body);

animate
  .text("p", { class: "text" }, {}, "hello!", 200)
  .text("p", { class: "text_yellow" }, {}, "我想被变黄", 500)
  .style(".text_yellow", { color: "yellow" }, 1000)
  .text("p", { class: "text" }, {}, "成功啦!", 1000);

小结

  • 源码地址
  • 我们通过一个动画的例子来了解了 JS事件循环的执行机制,代码在浏览器/node 中是如何执行的。
  • 宏任务:script,setInterval,setTimeout,I/O,requestAnimationFrame,setImmediate(Node.js)
  • 微任务:Promise.then,MutationObserver,process.nextTick(Node.js)
  • 先执行宏任务,后执行微任务
  • 最后,通过这一个小动画例子我们可以利用代码给自己做一个好玩的东西,例如:自动展示的简历 😁