berwin/Blog

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

berwin opened this issue · 31 comments

为什么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。

MutationObserver

@ceemomx 谢谢啦~ 已经把错别字改正啦

彩蛋说的并不对,其实是不完整。不是说数据改变注册的任务就是micro,如果methods中的方法是作为比如click事件的cb运行的,那么flushcallback与获取dom一样都是作为macro加入task里的,settimeout之所以依然能取到更新后的dom本质上是就算都是macro,取出顺序也有先后。vue中对macrofun的实现在Chrome上默认是用messagechannel去注册异步的,取出时是在timeout前的,如果将这部分vue源码默认成settimeout实现就并不能取到更新后的dom

@wangweida 嗯嗯嗯,是的,彩蛋这里的内容没说完整,你说的对,如果标签上注册了事件,同时浏览器既不支持setImmediate 也不支持messagechannel的情况Vue会使用setTimeout来注册更新DOM的任务,这个时候彩蛋里的代码其实就获取不到更新后的DOM了~ 哈哈哈哈

readme 中的 聊聊类型转换 的地址应该是 https://ppt.baomitu.com/d/e6515023

@cinyearchan 哈哈哈哈,非常感谢,我改下~

@berwin 谢谢你的文章,受益良多

@cinyearchan 嘿嘿嘿、应该的

刚刚试了一下,我有一个child component, 更新了id

this.id = 1; this.emit('callParent'); //这边我的parent就会接收到一个event,开始处理我要的东西
当我在parent用
let id = document.getElementById('id').value;

第一次打印出来是没有value的,第二次才有value,就算加了

this.$nextTick(function () { // DOM已经更新 })

还是得到一样的结果,除了 this.emit('callParent', this.id) 这个办法外,还有其他办法可以让我在parent中得到child里的value吗?虽然我在child里更新了value,不过DOM好像没跟着更新,延迟了一些.

@shern91 这个问题不是异步更新队列的问题,而是跨组件通信的问题。所以你这个看起来,用this.emit相对来说还算是比较靠谱的方案~

最近看了源码和一些源码分析,请教个问题,使用同步更新队列,渲染也是发生在两个事件循环之间,为啥vue使用同步更新队列,改变多个属性值时,会多次渲染?求解答,谢谢

@alibabawanted 每一轮Tick中所做的事都是同步的,异步的都在下一轮。如果只考虑同步,那么就是说代码都是在同一轮Tick中执行,那么做个比喻,用户修改数据我们认为是执行一个函数,然后函数里打印个log。那么如果执行两次函数,它就会打印两次log。。

感谢回答,还是不太理解,每一轮Tick应该包括宏任务和微任务,异步不一定都在下轮,渲染发生在两个Tick之间,举个例子:
假设是同步更新队列,
this.message = '更新完成'
this.message = '更新完成2'
this.message = '更新完成3'
this.message = '更新完成4'
�这里同步执行完,这一轮Tick没有微任务,然后开始渲染,应该也只会渲染 '更新完成4' 。没明白为什么会渲染4次dom,这是我的理解,困扰我一个月了,求解答,谢谢

@alibabawanted 可以先学习一下事件循环机制,然后就不会困扰啦~

解决了困扰很久的一个问题。官方文档虽然提到 异步更新队列 ,但看了你这篇才知道其中的内部机制。👏👏

@shern91 这个问题不是异步更新队列的问题,而是跨组件通信的问题。所以你这个看起来,用this.emit相对来说还算是比较靠谱的方案~

我目前是在parent中使用 this.$refs.childcomponent.id = 5; 的办法来更新 id. 不确定会不会有副作用

最近在看 Vue.js 的源码 原来这两种异步任务差别是这样的啊 👏🏻

其实有篇文章讲得更加透彻一些。Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!

非常Cool 原来microTask会执行于renderTask之前

其实有篇文章讲得更加透彻一些。Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心!

非常Cool 原来microTask会执行于renderTask之前

还有一篇文章也非常好,里面的一张图特别好,整个流程都捋得很清晰。。https://github.com/FlyDreame/2m-blog/issues/2。

感谢回答,还是不太理解,每一轮Tick应该包括宏任务和微任务,异步不一定都在下轮,渲染发生在两个Tick之间,举个例子:
假设是同步更新队列,
this.message = '更新完成'
this.message = '更新完成2'
this.message = '更新完成3'
this.message = '更新完成4'
�这里同步执行完,这一轮Tick没有微任务,然后开始渲染,应该也只会渲染 '更新完成4' 。没明白为什么会渲染4次dom,这是我的理解,困扰我一个月了,求解答,谢谢

可以看下vue的响应式原理,每次修改属性,都会触发watcher的update方法,如果watcher的方法不改为异步执行,那么就会执行四次update操作。(个人理解)

new Vue({ // ... methods: { // ... example: function () { // 先使用nextTick注册回调 this.$nextTick(function () { // DOM没有更新 }) // 后修改数据 this.message = 'changed' } } })
就算是先注册nextTick回调后修改数据,在回调中也已经是更新了DOM。https://jsfiddle.net/uz1hp2ks/2/

@EgoYauconsole.log打印DOM,其实你看到的和你打印的那个时刻的内容,是不一样的。打印this.$refs.test.innerHTML再试一下。

@EgoYauconsole.log打印DOM,其实你看到的和你打印的那个时刻的内容,是不一样的。打印this.$refs.test.innerHTML再试一下。

确实是哎,console.log直接打印DOM,是DOM更新后的数据。我试了下先打印DOM,在执行数据更新,日志显示的也是更新后的数据。感觉跟UI渲染队列有关系
https://jsfiddle.net/7fazgq41/2/

感谢回答,还是不太理解,每一轮Tick应该包括宏任务和微任务,异步不一定都在下轮,渲染发生在两个Tick之间,举个例子:
假设是同步更新队列,
this.message = '更新完成'
this.message = '更新完成2'
this.message = '更新完成3'
this.message = '更新完成4'
�这里同步执行完,这一轮Tick没有微任务,然后开始渲染,应该也只会渲染 '更新完成4' 。没明白为什么会渲染4次dom,这是我的理解,困扰我一个月了,求解答,谢谢

别把问题局限在渲染这一块,想想渲染之前发生了什么。假设这里是同步更新队列, this.message = '更新完成'。大致会发生这些事:message更新->触发setter->触发Watcher的update->重新调用render->生成新的vdom->dom-diff->dom更新。这里的dom更新并不是渲染(布局,绘制,合成等一系列步骤),而是更新内存中的DOM树结构。之后再运行this.message = '更新完成2',再重复上述步骤,之后的第三次和第四次更新同样会触发相同的流程。等开始渲染的时候,最新的DOM树中确实只会存在 '更新完成4' ,所以也只会在页面中渲染成 '更新完成4' 。从这里来看,前三次对message的操作以及Vue内部对它的处理都是无用功。

如果是异步更新队列,会是下面的情况。
运行this.message = '更新完成'后,并不是立即进行上面的流程,而是将对message有依赖的Watcher都保存在队列中。该队列可能这样[Watcher1, Watcher2...]。
当运行this.message = '更新完成2'后,同样是将对message有依赖的Watcher保存到队列中。Vue内部会做去重判断,这次操作后,可以认为队列数据没有发生变化。
第三次和第四次更新也是上面的过程。当然,你不可能只对message有操作,你可能对该组件中的另一个属性也有操作,比如this.otherMessage='other message',同样会把对otherMessage有依赖的Watcher添加到异步更新队列中,因为有重复判断操作,这个Watcher也只会在队列中存在一次。

本次宏任务执行结束后,会进入微任务执行流程。其实就是遍历异步更新队列中的每一个Watcher,触发其update,然后进行重新调用render->生成新的vdom->dom-diff->dom更新等流程。但是这种方式和同步更新队列相比,不管你操作多少次message,Vue在内部只会进行一次重新调用render->生成新的vdom->dom-diff->dom更新流程。

所以,异步更新队列不是节省了渲染成本,而是节省了Vue内部计算及DOM树操作的成本。不管采用哪种方式,渲染确实只有一次。

请教一下nextTick中如果用到的是Promise实现的话 里面的回调事件就是作为微任务在本轮UI渲染之前执行了。然后比如我在nextTick回调里去取某个DOM结构,那他拿到的DOM是哪里来的,是vue自己生成的还未进行实际渲染的DOM吗。,放在内存中的DOM树吗,请教各位大佬

感谢回答,还是不太理解,每一轮Tick应该包括宏任务和微任务,异步不一定都在下轮,渲染发生在两个Tick之间,举个例子:
假设是同步更新队列,
this.message = '更新完成'
this.message = '更新完成2'
this.message = '更新完成3'
this.message = '更新完成4'
�这里同步执行完,这一轮Tick没有微任务,然后开始渲染,应该也只会渲染 '更新完成4' 。没明白为什么会渲染4次dom,这是我的理解,困扰我一个月了,求解答,谢谢

@berwin 那位的疑问是:因为 event loops本身在每次循环时对同一个dom作多次修改的时候,就有一个机制:只渲染最后一次dom变化。 所以他才会有疑问,既然如此,那么vue的异步更新队列机制有什么用?所以你们都没有理解而已,面对新手要能充分理解对方的问题的。所以后面有朋友就明白了他的问题,提出渲染虽然只是渲染了一次,但是渲染之前很多的嗯无效工作却做了多次。

请教一下nextTick中如果用到的是Promise实现的话 里面的回调事件就是作为微任务在本轮UI渲染之前执行了。然后比如我在nextTick回调里去取某个DOM结构,那他拿到的DOM是哪里来的,是vue自己生成的还未进行实际渲染的DOM吗。,放在内存中的DOM树吗,请教各位大佬

同问

《PPT:深入浅出Vue.js - VirtualDOM篇》 链接失效了 @berwin

关于文末修改数据和触发nextTick顺序问题,我实际尝试了一下,对结果有点疑问?
比如我下面这个示例,按照楼主的说法,应该输出的是空字符串,但实际却是修改后的message内容,即 mmmmmmmm
请问楼主能解释一下原因吗?

<template>
  <div class="about" ref="about">
    {{ message }}
  </div>
</template>

<script>
export default {
  data () {
    return {
      message: ''
    }
  },
  mounted() {
    this.$nextTick(() => {
      console.log('message:',this.$refs.about.innerHTML);
    });
    this.message = 'mmmmmmmm';
  }
}
</script>

关于文末修改数据和触发nextTick顺序问题,我实际尝试了一下,对结果有点疑问?
比如我下面这个示例,按照楼主的说法,应该输出的是空字符串,但实际却是修改后的message内容,即 mmmmmmmm
请问楼主能解释一下原因吗?

<template>
  <div class="about" ref="about">
    {{ message }}
  </div>
</template>

<script>
export default {
  data () {
    return {
      message: ''
    }
  },
  mounted() {
    this.$nextTick(() => {
      console.log('message:',this.$refs.about.innerHTML);
    });
    this.message = 'mmmmmmmm';
  }
}
</script>

@linghuam 试试我这个2.4.2版本的例子吧,表现跟楼主所说是一样的,你那可能是有优化或者版本问题吧。

<!DOCTYPE html>
<html>
<head>
    <title>Vue</title>
</head>
<body>
    <div id="app"></div>
</body>
<script src="https://cdn.bootcss.com/vue/2.4.2/vue.js"></script>
<script type="text/javascript">
    var vm = new Vue({
        el: "#app",
        data: {
            msg: "Vue"
        },
        template:`
            <div>
                <div ref="msgElement">{{msg}}</div>
                <button @click="updateMsg">updateMsg</button>
            </div>
        `,
        methods:{
            updateMsg: function(){
                this.$nextTick(() => {
                    console.log("DOM未更新:", this.$refs.msgElement.innerHTML)
                })
                this.msg = "Update";
            }
        },

    })
</script>
</html>