通过一个JS动画了解JS中的EventLoop

前言

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

一个 JS 动画

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

  • 调用代码

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

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

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

1.创建 Animate 类

  • 首先需要创建一个 Animate 的方法,在调用时初始化信息
  • 实例化的时候需要传两个参数,head的 element 标签和body的 element 标签
  • 初始化信息包括创建三个 HTML 标签,分别是输出的信息的box输出style的box更新样式的style标签
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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方法,输出文字的公共方法,还有一个输出字体间隔时间的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 添加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 它不会让我们实现的方法轻易地得逞~
测试一下:

1
2
3
4
5
6
7
// 测试
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 方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
    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);
})
}

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

1
2
3
4
5
6
7
// 测试
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 这个对象本身就可以了,这里就不多做解释啦,最终的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
"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;
}

测试:

1
2
3
4
5
6
7
8
9
10
11
"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)
  • 先执行宏任务,后执行微任务
  • 最后,通过这一个小动画例子我们可以利用代码给自己做一个好玩的东西,例如:自动展示的简历 😁