事件循环与前端性能优化

416 阅读4分钟

概览

事件循环对我来说是神秘的,捉摸不透的。平常工作中没有涉及或者说涉及了我没察觉到。后面得知其与前端性能优化息息相关,通过阅读书籍与查阅资料。我感觉事件循环与闭包一样重要,现代框架(vue、react)操作dom优化都有事件循环的影子,很多面试题拨开面纱其实也跟事件循环息息相关。下面我们聚焦几个问题,去了解一下事件循环:

  1. 什么是事件循环
  2. 基于事件循环去优化dom操作
  3. 面试题:向页面插入2000条数据

什么是事件循环

对于事件循环,个人建议看阅读《JavaScript忍者秘籍》,其中专门有一章节讲事件循环,而且列举的例子都比较生动易懂。下图就截取自该书。配合该图,列出几个重点。 image.png

  1. 事件循环一般来说有两个队列:宏任务队列微任务队列。注意区分队列与调用栈
  2. js同一时间只能执行一个任务,而且执行的那个任务不能终端和插入其他任务。(js是单线程的)
  3. 一次事件循环,首先会判断宏事件队列是否有任务或者为空,有任务执行一项任务。没任务,执行微任务。
  4. 宏任务执行完一项之后,开始执行微任务。根据上图可以看到,一旦执行到微任务这个环节,微任务要执行完,也就是执行一对的微任务。值得注意的是:执行微任务的时候不会再产生微任务
  5. 当微任务执行完毕只有,进入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>

上面得代码会经历两次事件循环,分析如下:

  1. 首先明白整体scriptsetTimeout都是宏任务,所我们dom操作经过setTimeout变成了一个宏任务。
  2. script执行完毕,此时执行了一项宏任务将task添加到宏任务队列
  3. 执行微任务队列的所有任务,此时微任务为空。
  4. 渲染dom,第一次事件循环结束
  5. 进入下一次事件循环,执行task,执行真正的dom操作
  6. 渲染dom
  7. 宏任务、微任务队列都为空,结束循环

结论: 由上可知,我们明明可以再第一次循环干完所有事情,偏偏等到了第二次循环。经历了一次无效的事件循环

把task封装成微任务

<div id="app"></div>
<script>
    function task() {}
    Promise.resolve().then(task)
</script>

上面得代码会经历一次事件循环,流程前两步与上面一样,所以我们从第三步开始:

  1. 执行微任务队列的所有任务,task得到执行,操作dom
  2. 渲染dom
  3. 宏任务、微任务队列都为空,结束循环

结论: 没有浪费,该做的事情在第一次事件循环都做了。值得注意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操作,性能相关。肯定会想到事件循环,从中作文章!