关于前端的两个面试点:事件代理和任务队列

两个知识点,做业务可能用不到,面试一定会用上.

事件代理,冒泡,捕获

先说冒泡和捕获:

dom中的元素是一层一层的父子结构,从document到body,再到div,再到button之类的.

那么事件呢,先从最外面传递到最里面,这叫捕获.

到了最里面那个元素,然后反过来向外传递,这叫冒泡.

通常用的addEventListener,就是给某个元素加上了冒泡阶段的监听.
那么怎么在父元素上拦截掉这个事件呢?就需要添加捕获阶段的监听.

element.addEventListener(
  'click',
  function (e) {
    e.preventDefault() //阻止默认事件
  },
  true
)

前两个参数就不解释了,第三个为true,就是捕获阶段监听,在捕获阶段可以调用方法阻止事件进一步往下传递,自然后续的冒泡也没有了.

做过android的可以用onInterceptTouchEvent来理解下.一回事.

再来说事件代理,也叫事件委托:

通过在父元素上加事件监听的方式,来处理子元素的事件.

好处是什么呢?

  1. 不用分别给每个子元素添加和删除事件.
  2. 子元素非常多时,可以提升性能.
  3. 后续动态添加的子元素,也可以触发这个事件.

贴段代码,自己理解:

传统方法:

<ul>
    <li v-for="(item, index) in data" @click="handleClick(index)">
        Click Me
    </li>
</ul>

使用事件代理:

        <ul @click="handleClick">
          <li v-for="(item, index) in data" :data-index="index">
            {{ item.text }}
          </li>
        </ul>
        handleClick(e) {
          if (e.target.nodeName.toLowerCase() === 'li') {
            const index = parseInt(e.target.dataset.index)
            this.doSomething(index)
          }
        },

vue中需要使用事件代理吗?

不需要.

事件代理的3个优势:

  1. vue的元素/元素销毁时会自动移除事件,不需要手动操作.
  2. v-for中绑定的都是同一个事件,除非有上千个,否则和事件代理性能差距不大.
  3. vue中已经自动完成.

js中的事件循环机制

同步任务和异步任务

js一开始是设计在浏览器上使用的,在浏览器环境下操作dom,这种UI操作必须是单线程的,否则会带来很复杂的同步问题.

但是由于部分IO操作比较慢,全部串行执行又会阻塞…
于是引入异步任务:

同步任务在主线程排队执行.
异步任务放入任务队列.
同步任务执行完后,从任务队列取出异步任务执行.

这种运行机制称之为事件循环(EventLoop).

EventLoop:不停的从任务队列中取出任务放到主线程.

  1. 选择一个异步任务,执行,
  2. 在浏览器环境下,此时会渲染页面,
  3. 回到步骤1,开始下一轮循环.

一个循环叫做一个tick.

这里存在一个问题:

虽然有任务队列,但是插入任务的时候只能放在尾端.
如果有紧急任务,可能执行时机会比想象中的晚很多,不利于视图的同步.

微任务和宏任务

js引擎在异步任务基础上又实现了微任务.提供微任务队列和产生微任务的方法.
在每个异步任务执行完后,将微任务队列全部执行完(也包括微任务执行过程中新产生的微任务).

以前的异步任务则称之为宏任务.

  • 宏任务:setInterval, setTimeout, DOM事件, Ajax请求
  • 微任务:Promise,async/await

这样如果在某次宏任务中,发起了异步任务(如网络请求),期望在请求结果回来后尽快得到使用,而不是排到异步任务队列的尾部,就可以使用微任务:
将数据处理回调加到微任务队列中,得以在当前周期内完成.

微任务机制在Vue中的应用:

vue使用字典来维护修改数据触发的watcher.使用watcher的id去重.每个周期统一执行一次watcher的run方法.
主要有两个优势:

  • 去重:一个周期内,不管修改多少次数据,同一个watcher最终只触发一次更新.
  • 合并:将一个周期内的多个组件修改在一起执行,配合虚拟dom,减小性能损失.

核心机制:nextTick
nextTick本质上是将传入的回调放入队列,同时在首次执行nextTick方法时,创建一个微任务来处该理队列.这个微任务会在本次周期最后被执行.

根据不同浏览器环境,使用不同机制来生成这个微任务.

使用nextTick更新dom:

  • watcher更新放入字典中,使用nextTick统一执行字典中所有watcher的run方法,run方法中使用虚拟dom进行对比,并更新真实dom(注意:更新dom并不代表渲染dom).
  • 由于更新dom的watcher在微任务中执行,为了能最快获取更新后的dom,vue也提供了nextTick方法给上层使用,只需在改变数据后执行nextTick,就能通过紧随其后的微任务获取到更新后的dom.
      this.content = "改变后的"   //触发watcher.update,对应watcher被加入队列,队列统一处理方法被加入微任务队列.
      console.log("content改变前:",this.$refs.content.innerText)
      this.$nextTick(() => {//继续加入微任务,由于在上面的微任务后面,因此可以得到更新后的dom
        console.log("content改变后:",this.$refs.content.innerText)
      })

注意:dom更新和UI渲染不是一回事.dom更新可以同步进行.UI渲染则要等到一次宏任务执行完毕.

一些常用回调和事件:

requestAnimationFrame

浏览器在下次渲染(UI render)之前调用指定的回调函数更新动画。 该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次渲染之前执行

渲染(UI render)

一轮事件循环执行结束之后,下轮事件循环执行之前开始进行 UI render。 即:macro-task 任务执行完毕,接着执行完所有的 micro-task 任务后, 此时本轮循环结束,如果要执行 UI render,先调用requestAnimationFrame, 然后执行 UI render。UI render 完毕之后接着进行下一轮循环;

requestIdleCallback

requestIdleCallback 会在每次 检查是否要渲染(check) 结束, 发现距离下一帧的刷新还有时间, 就执行一下这个。如果时间不够,就下一帧再说。

总结:

  • 异步任务都是通过队列来完成.不止js,几乎所有GUI程序的渲染主进程都是这样.
  • 为了插队,引入异步任务中的微任务.
  • 宏任务是宿主环境创建的任务,微任务是js引擎创建的任务.
  • js引擎将微任务队列中的任务放在某个循环周期内一次执行完成,提供更加灵活的任务调度机制.
  • vue通过队列将dom更新事件去重与合并,并通过微任务在下次渲染前执行.
  • micro-task 结束 -> requestAnimationFrame -> requestIdleCallback -> UI render

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!