为什么Vue使用异步更新队列?

3,451 阅读6分钟
原文链接: github.com

为什么Vue使用异步更新队列?

image

本文假设你已经对Vue的变化侦测和渲染机制有一些了解。
如果不了解请移步《深入浅出 - vue变化侦测原理》《PPT:深入浅出Vue.js - VirtualDOM篇》

异步更新队列指的是当状态发生变化时,Vue异步执行DOM更新。

我们在项目开发中会遇到这样一种场景:当我们将状态改变之后想获取更新后的DOM,往往我们获取到的DOM是更新前的旧DOM,我们需要使用vm.$nextTick方法异步获取DOM,例如:

Vue.component('example', {
  template: '<span>{{ message }}</span>',
  data: function () {
    return {
      message: '没有更新'
    }
  },
  methods: {
    updateMessage: function () {
      this.message = '更新完成'
      console.log(this.$el.textContent) // => '没有更新'
      this.$nextTick(function () {
        console.log(this.$el.textContent) // => '更新完成'
      })
    }
  }
})

我们都知道这样做很麻烦,但为什么Vue还要这样做呢?

首先我们假设Vue是同步执行DOM更新,会有什么问题?

如果同步更新DOM将会有这样一个问题,我们在代码中同步更新数据N次,DOM也会更新N次,伪代码如下:

this.message = '更新完成' // DOM更新一次
this.message = '更新完成2' // DOM更新两次
this.message = '更新完成3' // DOM更新三次
this.message = '更新完成4' // DOM更新四次

但事实上,我们真正想要的其实只是最后一次更新而已,也就是说前三次DOM更新都是可以省略的,我们只需要等所有状态都修改好了之后再进行渲染就可以减少一些无用功。

而这种无用功在Vue2.0开始变得更为重要,Vue2.0开始引入了Virtualdom,每一次状态发生变化后,状态变化的信号会发送给组件,组件内部使用VirtualDOM进行计算得出需要更新的具体的DOM节点,然后对DOM进行更新操作,每次更新状态后的渲染过程需要更多的计算,而这种无用功也将浪费更多的性能,所以异步渲染变得更加至关重要。

组件内部使用VIrtualDOM进行渲染,也就是说,组件内部其实是不关心哪个状态发生了变化,它只需要计算一次就可以得知哪些节点需要更新。也就是说,如果更改了N个状态,其实只需要发送一个信号就可以将DOM更新到最新。例如:

this.message = '更新完成'
this.age =  23
this.name = berwin

代码中我们分三次修改了三种状态,但其实Vue只会渲染一次。因为VIrtualDOM只需要一次就可以将整个组件的DOM更新到最新,它根本不会关心这个更新的信号到底是从哪个具体的状态发出来的。

那如何才能将渲染操作推迟到所有状态都修改完毕呢?很简单,只需要将渲染操作推迟到本轮事件循环的最后或者下一轮事件循环。也就是说,只需要在本轮事件循环的最后,等前面更新状态的语句都执行完之后,执行一次渲染操作,它就可以无视前面各种更新状态的语法,无论前面写了多少条更新状态的语句,只在最后渲染一次就可以了。

将渲染推迟到本轮事件循环的最后执行渲染的时机会比推迟到下一轮快很多,所以Vue优先将渲染操作推迟到本轮事件循环的最后,如果执行环境不支持会降级到下一轮。

当然,Vue的变化侦测机制决定了它必然会在每次状态发生变化时都会发出渲染的信号,但Vue会在收到信号之后检查队列中是否已经存在这个任务,保证队列中不会有重复。如果队列中不存在则将渲染操作添加到队列中。

之后通过异步的方式延迟执行队列中的所有渲染的操作并清空队列,当同一轮事件循环中反复修改状态时,并不会反复向队列中添加相同的渲染操作。

所以我们在使用Vue时,修改状态后更新DOM都是异步的。

说到这里简单介绍下什么是事件循环。

事件循环机制

JS中存在一个叫做执行栈的东西。JS的所有同步代码都在这里执行,当执行一个函数调用时,会创建一个新的执行环境并压到栈中开始执行函数中的代码,当函数中的代码执行完毕后将执行环境从栈中弹出,当栈空了,也就代表执行完毕。

这里有一个问题是代码中不只是同步代码,也会有异步代码。当一个异步任务执行完毕后会将任务添加到任务队列中。例如:

setTimeout(_=>{}, 1000)

代码中setTimeout会在一秒后将回调函数添加到任务队列中。事实上异步队列也分两种类型:微任务、宏任务。

微任务和宏任务的区别是,当执行栈空了,会检查微任务队列中是否有任务,将微任务队列中的任务依次拿出来执行一遍。当微任务队列空了,从宏任务队列中拿出来一个任务去执行,执行完毕后检查微任务队列,微任务队列空了之后再从宏任务队列中拿出来一个任务执行。这样持续的交替执行任务叫做事件循环

属于微任务(microtask)的事件有以下几种:

  • Promise.then
  • MutationObserver
  • Object.observe
  • process.nextTick

属于宏任务(macrotask)的事件有以下几种:

  • setTimeout
  • setInterval
  • setImmediate
  • MessageChannel
  • requestAnimationFrame
  • I/O
  • UI交互事件

彩蛋

通过前面介绍的内容,我们知道Vue的更新操作默认会将执行渲染操作的函数添加到微任务队列中,而微任务的执行时机优先于宏任务。所以有一个很有意思的事情是,我们在代码中如果先使用setTimeout将函数注册到宏任务中,然后再去修改状态,在setTimeout注册的回调中依然可以获取到更新后的DOM,例如:

new Vue({
  // ...
  methods: {
    // ...
    example: function () {
      // 先使用setTimeout向宏任务中注册回调
      setTimeout(_ => {
        // DOM现在更新了
      }, 0)
      // 后修改数据向微任务中注册回调
      this.message = 'changed'
    }
  }
})

之所以会出现这种现象原因前面我们也提到过,是因为修改数据默认会将更新DOM的回调添加到微任务(microtask)队列中,如果我们将获取DOM的操作放到宏任务(macrotask)中,那么注册的位置就变得不那么重要了,无论在哪里注册都是先更新DOM然后再获取DOM。因为微任务(microtask)中的任务比宏任务(macrotask)中的任务先执行。

而如果使用vm.$nextTick向微任务队列中插入任务,则代码中注册的顺序就非常重要,因为渲染操作和使用vm.$nextTick注册的回调都是向微任务队列中添加任务,那么执行回调的顺序就会按照插入队列中的循序去执行,也就是说,先插入队列的先执行。例如:

new Vue({
  // ...
  methods: {
    // ...
    example: function () {
      // 先使用nextTick注册回调
      this.$nextTick(function () {
        // DOM没有更新
      })
      // 后修改数据
      this.message = 'changed'
    }
  }
})

代码中先使用vm.$nextTick注册任务,后修改数据,所以在微任务队列中它比渲染操作的位置更靠前,所以优先执行,所以在回调执行的时候页面中的DOM并没有发生变化。

必须先修改数据后使用vm.$nextTick注册回调才能获取到更新后的DOM,例如:

new Vue({
  // ...
  methods: {
    // ...
    example: function () {
      // 先修改数据
      this.message = 'changed'

      // 后使用nextTick注册回调
      this.$nextTick(function () {
        // DOM已经更新
      })
    }
  }
})

代码中可以看到,先修改数据,后使用vm.$nextTick注册回调,那么在微任务队列中渲染操作比vm.$nextTick注册的回调位置更靠前,所以先执行渲染后,在执行vm.$nextTick注册的回调,所以在回调中可以获取到更新后的DOM。