sfsoul / issuesListOfVue

Vue的知识清单
1 stars 0 forks source link

Vue 采用异步渲染的原因 #2

Open sfsoul opened 4 years ago

sfsoul commented 4 years ago

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

场景

在日常项目开发中会遇到一种场景:当我们修改某个属性值后,往往想获得更新后的DOM值,但此时通过 $refs 获取到的DOM的值时更新前旧DOM的值,此时需要使用 vm.$nextTick 方法来异步获取DOM。

Vue.component('update-message', {
        template: `
            <button ref="message" @click="updateMessage">{{message}}</button>
        `,
        data () {
            return {
                message: 'Hello world'
            }
        },
        methods: {
            updateMessage () {
                this.message = '哈哈哈';
                console.log(this.$refs.message.textContent);  // 打印message值为:Hello world
                this.$nextTick(() => {
                    console.log('$nextTick', this.$refs.message.textContent);  // 打印message值为:哈哈哈
                })
            }
        }
})

从上面的 demo 可以看到,如果想要在属性值被改变后立刻获取到DOM中的新值,是需要在 vm.$nextTick 的回调中去获取的。

为什么要异步更新视图

<template>
    <div>
        <div>{{test}}</div>
    </div>
</template>
export default {
    data () {
        return {
            test: 0
        };
    },
    mounted () {
      for(let i = 0; i < 1000; i++) {
        this.test++;
      }
    }
}

如上情况:mounted 的时候属性 test 的值会被 ++ 循环执行1000次。每次++时,都会根据响应式触发 setter => Dep => Watcher => update => patch。 如果此时没有异步更新视图,那么每次++都会直接操作DOM更新视图,这是非常消耗性能的。所以Vue实现了一个 queue 队列,在下一个tick的时候会统一执行queue中Watcher的run。同时拥有相同id的Watcher不会被重复加入到该queue中去,所以不会执行1000次Watcher的run。最终更新视图只会直接将test对应的DOM的0变成1000。保证更新视图操作DOM的动作是在当前栈执行完以后下一个tick的时候调用,大大优化了性能。

Vue 同步执行DOM更新的缺点

假设 Vue 是同步执行DOM更新,来看看会存在什么问题?

this.message = '第一个更新';
this.message = '第二个更新';
this.message = '第三个更新';
this.message = '第四个更新';

上面代码修改了4次 message 的值,所以DOM也会更新4次,DOM的更新是十分消耗性能的。 但其实我们真正需要的也就是最后一次更新的结果(即DOM只需要更新一次),前三次的DOM更新是可以省略的。只需要等所有状态都修改好了之后再进行渲染就可以减少一些无用功。

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

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

updateStatus () {
    this.name = 'zhangjing';
    this.age = 27;   
}

执行 updateStatus 方法,修改了2种状态,但其实Vue只渲染一次。因为 VirtualDOM 只需要一次就可以将整个组件的DOM更新到最新,它根本不会关心这个更新的信号到底是哪个具体的状态发出来的。

何时进行更新渲染操作

将渲染操作推迟到所有状态都修改完毕后进行。

只需要将渲染操作推迟到本轮事件循环的最后或者下一轮事件循环。即只需要在本轮事件循环的最后,等前面更新状态的语句都执行完之后,执行一次渲染操作,它就可以无视前面各种更新状态的语法,无论前面写了多少条更新状态的语句,只在最后渲染一次即可。将渲染推迟到本轮事件循环的最后,执行渲染的时机会比推迟到下一轮快很多,所以Vue优先将渲染操作推迟到本轮事件循环的最后,如果执行环境不支持会降级到下一轮。

如何避免重复渲染

所谓重复渲染指的是:一个组件属性(以 message 为例)的值被改变多次,那么会有多个对应的任务被添加到任务队列中,应该避免这样

Vue 的变化侦测机制决定了它必然会在每次状态发生变化时都会发出渲染的信号,但Vue会在收到信号之后检查队列中是否已经存在这个任务,保证队列中不会有重复。如果队列中不存在则将渲染操作添加到队列中。 之后通过异步的方式延迟执行队列中的所有渲染的操作并清空队列,当同一轮事件循环中反复修改状态时,并不会反复向队列中添加相同的渲染操作。所以在使用Vue时,修改状态后更新DOM都是异步的。

参考文章

sfsoul commented 4 years ago

异步更新队列的优势

this.message = '第一个更新';
this.message = '第二个更新';
this.message = '第三个更新';
this.message = '第四个更新';

假设Vue是同步更新队列,执行 this.message = '第一个更新' 会发生这些事:message更新 ===> 触发 setter ===> 执行 dep.notify,遍历 dep.subs 数组 ===> 触发 watcher 的update ===> 重新调用 render ===> 生成新的 vdom ===> DOM Diff ===> 更新 DOM。 这里的更新 DOM 并不是渲染(布局、绘制、合成等一系列步骤),而是更新内存中的DOM树结构。之后再运行 this.message = '第二个更新',再重复上述步骤,之后的第三次和第四次更新同样会触发相同的流程。等到开始渲染的时候,最新的DOM树中确实只会存在 '第四个更新',所以也只会在页面中渲染成 '第四个更新'。可以看出,前三次对 message 的操作以及Vue内部对它的处理都是无用功。

如果是异步更新队列:运行完 this.message = '第一个更新'后,并不是立即进行上面的流程,而是将对 message 有依赖的 Watcher 都保存在队列中。该队列可能是这样的 [Watcher1, Watcher2...]。

当运行 this.message = '第二个更新'后,同样是将对 message 有依赖的 Watcher 保存到队列中。Vue内部会去做去重判断。第三次和第四次更新也是上面的流程。若此时还修改了该组件中的另一个属性,如:this.name='yh',同样会把对 name 有依赖的 Watcher 添加到异步队列中,因为有重复判断操作,所以这个 Watcher 也只会在队列中存在一次。

等待本次宏任务执行结束后,会进入微任务执行流程。此时会遍历异步更新队列中的每一个 Watcher,触发其 update 方法,然后进行重新调用 render ===> 生成新的vdom ===> DOM Diff ===> 更新 DOM 等流程。这种方式和同步更新队列相比优势在于:不管你操作多少次 message,Vue在内部只会进行一次重新调用 render ===> 生成新的vdom ===> DOM Diff ===> 更新 DOM 的流程。

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