概览
事件循环对我来说是神秘的,捉摸不透的。平常工作中没有涉及或者说涉及了我没察觉到。后面得知其与前端性能优化息息相关,通过阅读书籍与查阅资料。我感觉事件循环与闭包一样重要,现代框架(vue、react)操作dom优化都有事件循环的影子,很多面试题拨开面纱其实也跟事件循环息息相关。下面我们聚焦几个问题,去了解一下事件循环:
- 什么是事件循环
- 基于事件循环去优化dom操作
- 面试题:向页面插入2000条数据
什么是事件循环
对于事件循环,个人建议看阅读《JavaScript忍者秘籍》,其中专门有一章节讲事件循环,而且列举的例子都比较生动易懂。下图就截取自该书。配合该图,列出几个重点。
- 事件循环一般来说有两个队列:宏任务队列微任务队列。
注意区分队列与调用栈 - js同一时间只能执行一个任务,而且执行的那个任务不能终端和插入其他任务。(js是单线程的)
- 一次事件循环,首先会判断宏事件队列是否有任务或者为空,有任务
执行一项任务。没任务,执行微任务。 - 宏任务执行完一项之后,开始执行微任务。根据上图可以看到,一旦执行到微任务这个环节,微任务要执行完,也就是执行
一对的微任务。值得注意的是:执行微任务的时候不会再产生微任务 - 当微任务执行完毕只有,进入ui渲染的环节。
由上可得:一次事件循环经历的阶段: 执行一项宏任务 ---》 执行一对微任务 ---> ui渲染
常见的宏任务与微任务
常见的宏任务:setTimeout、setInterval、script(整体代码)、 I/O 操作、UI 渲染等。
常见微任务:process.nextTick、Promise、MutationObserver
基于事件循环去优化dom操作
个人认为基于事件循环去优化dom操作的一个思路:减少事件循环的次数,一次事件循环能搞定的事情就不要进入下一次循环。基于上诉思路,摆在我们面前有两条路:一是把操作dom的任务(后续称该任务为task)派发成宏任务,或者将task封装成微任务。
有以下场景:任务的内容为:创建一个textNode,将其加入到id为app的div下。我们将task分别封装为宏任务和微任务,分析渲染时机。
<div id="app"></div>
<script>
function task() {
const dom = document.getElementById('app')
const textNode = document.createTextNode('xxxx')
dom.appendChild(textNode)
}
</script>
把task封装成宏任务
<div id="app"></div>
<script>
function task() {}
setTimeout(task, 0)
</script>
上面得代码会经历两次事件循环,分析如下:
- 首先明白
整体script与setTimeout都是宏任务,所我们dom操作经过setTimeout变成了一个宏任务。 - script执行完毕,此时执行了一项
宏任务。将task添加到宏任务队列 - 执行微任务队列的所有任务,此时微任务为空。
- 渲染dom,
第一次事件循环结束 - 进入下一次事件循环,执行task,
执行真正的dom操作 - 渲染dom
- 宏任务、微任务队列都为空,结束循环
结论: 由上可知,我们明明可以再第一次循环干完所有事情,偏偏等到了第二次循环。经历了一次无效的事件循环
把task封装成微任务
<div id="app"></div>
<script>
function task() {}
Promise.resolve().then(task)
</script>
上面得代码会经历一次事件循环,流程前两步与上面一样,所以我们从第三步开始:
- 执行微任务队列的所有任务,
task得到执行,操作dom - 渲染dom
- 宏任务、微任务队列都为空,结束循环
结论: 没有浪费,该做的事情在第一次事件循环都做了。值得注意vue底层也是将渲染任务包装成微任务派发出去。
面试题:向页面插入2000条数据
从上可知,我们性能优化是基于减少事件循环次数。那是不是事件循环越少越好了?,很显然不是的,当我们操作的dom过多,将其放在一次事情循环去处理,很可能造成页面卡顿。有些时候我们需要将一些复杂的dom操作分到几次去处理。就像这道面试题。
<ul id="app"></ul>
<script>
let ul = document.getElementById('app')
for(let i = 0; i < 2000; i++) {
const li = document.createElement('li')
li.innerText = i
ul.appendChild(li)
}
</script>
上面一次性向页面的ul插入2000条li所有的操作都在一次事件循环里完成,明显是不行的。
<ul id="app"></ul>
<script>
let ul = document.getElementById('app')
setTimeout(() => {
for(let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.innerText = i
ul.appendChild(li)
}
},0)
setTimeout(() => {
for(let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.innerText = i + 1000
ul.appendChild(li)
}
},0)
</script>
上面的代码将2000次操作分到了2次循环里面,每次只插入1000个数据。是不是要好很多呀。数据量太大,用户首先关注的首屏,当用户向下滑动的时候,我们早以加载完毕。你也可以根据自己的需求,去决定到底几次事件循环完成整个操作。
感悟
到这里,我对事件循环有了一定的了解。以后写代码特别是涉及到dom操作,性能相关。肯定会想到事件循环,从中作文章!