Vue nextTick彻底理解

前言

含义和使用


nextTick的官方解释:

在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。


啥意思呢,即我们对Vue中data数据的修改会导致界面对应的响应变化,而通过nextTick方法,可以在传入nextTick的回调函数中获取到变化后的DOM,讲起来可能还是有点梦幻,下面我们直接使用nextTick体验一下效果。
比如我们有如下代码:

<template>    <div>       <button @click='update'>更新数据</button>       <span id='content'>{{message}}</span>    </div> </template> <script>   export default{       data:{           message:'hello world'       },       methods:{           update(){               this.message='Hello World'               console.log(document.getElementById('content').textContent);               this.$nextTick(()=>{                   console.log(document.getElementById('content').textContent);               })           }       }   } </script> 复制代码


上述代码第一次输出结果为hello world,第二次结果为更新后的Hello World

hello world Hello World


即我们在update方法中第一行对message的更新,并不是马上同步到span中,而是在完成span的更新之后回调了我们传入nextTick的函数。

// 修改数据 vm.data = 'Hello' //---> DOM 还没有更新  Vue.nextTick(function () {   //---> DOM 更新了 }) 复制代码


这里我们也可以理解为Vue中数据的更新不会同步触发dom元素的更新,也就是说dom更新是异步执行的,并且在更新之后调用了我们传入nextTick的函数。
那么问题来了,Vue为什么需要nextTick呢?nextTick又是如何实现的呢

探索


这里我们就抱着好奇的心态,理解一下nextTick函数的实现原理,加深对Vue底层原理的理解。
要想理解nextTick的设计意图和实现原理我们需要两块的前置知识理解:

  1. Vue响应式原理(理解设计意图)
  2. 浏览器事件循环机制(理解原理)


因此本次行文先简单讲解以上两部分内容,最后将知识整合详细介绍nextTick的实现原理。

响应式原理


Vue响应原理的核心是数据劫持和依赖收集,主要是利用Object.defineProperty()实现对数据存取操作的拦截,我们把这个实现称为数据代理;同时我们通过对数据get方法的拦截,可以获取到对数据的依赖,并将出所有的依赖收集到一个集合中。

 Object.defineProperty(data, key, {     enumerable: true,     configurable: true,     //拦截get,当我们访问data.key时会被这个方法拦截到     get: function reactiveGetter () {         //我们在这里收集依赖         return data[key];     },     //拦截set,当我们为data.key赋值时会被这个方法拦截到     set: function reactiveSetter (newVal) {         //当数据变更时,通知依赖项变更UI     }  }) 复制代码


下面为了更好的理解之后nextTick的实现原理,我们需要先实现一个简化版的Vue。

Vue类


首先我们实现一个Vue类,用于创建Vue对象,它的的构造方法接收一个options参数,用于初始化Vue。

class Vue{     constructor(options){        this.$el=options.el;        this._data=options.data;        this.$data=this._data;        //对data进行响应式处理        new Observe(this._data);    } } //创建Vue对象 new Vue({     el:'#app',     data:{       message:'hello world'     } }) 复制代码


上面的代码中我们首先创建了一个Vue的类,构造函数跟我们平时使用的Vue大致一致,为了容易理解我们这里只处理了参数eldata
我们发现构造函数的最后一行创建了一个Observe类的对象,并传入data作为参数,这里的Observe就是对data数据进行响应式处理的类,接下来我们看一下Observe类的简单实现。

Observe类


我们在Observe类中实现对data的监听,就是通过Object.defineProperty()方法实现的数据劫持,代码如下。

class Observe{     constructor(data){        //如果传入的数据是object        if(typeof data=='object'){            this.walk(data);        }     }     //这个方法遍历对象中的属性,并依次对其进行响应式处理     walk(obj){         //获取所有属性         const keys=Object.keys(obj);         for (let i = 0; i < keys.length; i++) {             //对所有属性进行监听(数据劫持)             this.defineReactive(obj, keys[i])         }     }     defineReactive(obj,key){         if(typeof obj[key]=='object'){             //如果属性是对象,那么那么递归调用walk方法             this.walk(obj[key]);         }         const dep=new Dep();//Dep类用于收集依赖         const val=obj[key];         Object.defineProperty(obj, key, {             enumerable: true,             configurable: true,             //get代理将Dep.target即Watcher对象添加到依赖集合中             get() {               //这里在创建Watcher对象时会给Dep.target赋值               if (Dep.target) {                 dep.addSubs(Dep.target);               }               return val;             },             set(newVal) {                 val=newVal;                 //依赖的变更响应                 dep.notify(newVal)             }            })     } } 复制代码


上述代码中我们使用到了Dep类,我们在劫持到的数据的get方法中收集到的依赖会被放到Dep类中保存。

Dep类


下面代码是Dep类的实现,他有一个subs的数组,用于保存依赖,这里的依赖是我们后面要定义的Watcher,Watcher即观察者,

class Dep{    static target=null    constructor(){        this.subs=[];    }    addSubs(watcher){        this.subs.push(watcher)    }    notify(newVal){        for(let i=0;i<this.subs.length;i++){            this.subs[i].update(newVal);        }    } } 复制代码

Watcher类


观察者类

let uid=0 class Watcher{     //vm即一个Vue对象,key要观察的属性,cb是观测到数据变化后需要做的操作,通常是指DOM变更     constructor(vm,key,cb){        this.vm=vm;        this.uid=uid++;        this.cb=cb;        //调用get触发依赖收集之前,把自身赋值给Dep.taget静态变量        Dep.target=this;        //触发对象上代理的get方法,执行get添加依赖        this.value=vm.$data[key];        //用完即清空        Dep.target=null;     }     //在调用set触发Dep的notify时要执行的update函数,用于响应数据变化执行run函数即dom变更     update(newValue){         //值发生变化才变更         if(this.value!==newValue){             this.value=newValue;             this.run();         }     }     //执行DOM更新等操作     run(){         this.cb(this.value);     } } 复制代码


通过以上的代码我们就实现了一个去除了模板编译的简易版的Vue,我们用简单化模拟dom的变更。

//======测试======= let data={     message:'hello',     num:0 } let app=new Vue({     data:data }); //模拟数据监听 new Watcher(app,'message',function(value){     //模拟dom变更     console.log('message 引起的dom变更--->',value); }) new Watcher(app,'num',function(value){     //模拟dom变更     console.log('num 引起的dom变更--->',value); }) data.message='world'; data.num=100; 复制代码


以上测试代码输出

message 引起的dom变更--->world num 引起的dom变更--->100

为什么要用nextTick


我们仔细观察会发现,按照以上的响应式原理实现,当我们对某项数据进行频繁的更新时会有很严重的性能问题。比如我们对上述的num属性进行修改:

for(let i=0;i<100;i++){     data.num=i;//每次的data数据的变化都会调用Watcher的update去更新DOM } 复制代码


上面的代码会导致num对应的Watcher的回调频繁执行(100次),其对应的就是100次的DOM更新,我们知道,DOM更新的性能成本是昂贵的,我们开发中应当尽量减少Dom操作。
优秀Vue作者肯定也是不允许这种情况发生的,vue就是使用nextTick来优化这个问题的。
简单的说就是每次数据变化之后不是立刻去执行DOM更新,而是要把数据变化的动作缓存起来,在合适的时机只执行一次的dom更新操作。这里就需要要设置一个合适的时间间隔,通过下面要介绍的事件循环机制可以很完美的解决。

事件循环机制


简单理解浏览器事件循环机制,即在js代码中执行中包括两种类型的任务,宏任务和微任务。宏任务即我们编写的顺序执行的代码和诸如setTimeout创建的任务,微任务则为通过诸如Promise.then中回调函数中执行的代码。
事件执行顺序:

  • 宏任务
  • 本次宏任务产生的所有微任务
  • render(视图更新)
  • 下一次宏任务


如此循环反复,为了方便理解,我们举一个简单的例子。

console.log('宏任务1') setTimeout(()=>{     console.log('宏任务2') }) Promise.resolve().then(()=>{     console.log('微任务1') }) Promise.resolve().then(()=>{     console.log('微任务2') }) 复制代码


上面代码的执行结果为:

宏任务1 微任务1 微任务2 宏任务2


这里主要讲nextTick的实现原理,因此只是简单讲一下事件循环的原理,如需想要对事件循环深层的理解可以参考这篇 浏览器与Node的事件循环(Event Loop)有何区别?
聪明的你肯定发现了,我们的数据变化缓存可以依赖事件循环来完成;因为每次事件循环之间都有一次视图渲染,我们只需要在render之前完成对dom的更新即可,因此我们为了避免无效的DOM操作,需要将数据变更缓存起来,只保存最后一次数据最终的变更结果。
这里简单给出两种实现方法:setTimeout和Promise,我们常用的setTimeout会创建一个宏任务,而Promise.then创建一个微任务。
如果使用setTimeout宏任务实现异步更新队列,那么就是本次同步代码执行完成不执行视图更新,而是在下一次宏任务开始清空异步更新队列,处理缓存的DOM更新和开发者添加的nextTick回调。
使用Promise创建的是微任务,微任务会在本次事件循环同步代码执行结束后执行,使用setTimeout创建的是宏任务,同样会在此次同步代码执行完成后执行,区别是在setTimeout代码执行之前会穿插一次无效的视图渲染,因此我们尽量使用Promise创建微任务实现异步更新。

重头戏:nextTick


核心原理及异步更新队列。
说到Vue中nextTick的实现,必须提到一个新概念异步更新队列,这里有两个关键字异步,更新队列。不知道你还记不记得前面我们写的简易版的Vue是如何响应数据并模拟dom更新的,这里我们在整体捋一遍流程:
Observe为数据添加代理,当我们使用到数据时,通过get代理方法我们可以收集到依赖该数据的Watcher对象,并且保存到Dep中作为该数据的依赖,这个过程就是依赖收集;
然后当我们修改数据时,会触发数据的set代理方法,进而执行Dep的notify方法触发所有依赖项的update方法执行更新。
而问题就出在了更新这一步,这里我们触发更新是同步执行的,即立即执行,像前面的for循环会频繁更新n多次,这造成了性能的浪费,尤其对于dom更新来说,一来是dom更新是昂贵的,二来这其中大多数是用户无法观测到的无效更新(因为浏览器事件循环机制中,一次循环中只有一次界面渲染)。
因此这里我们就可以借助浏览器事件循环机制实现异步更新,对发生变化的数据,每次事件循环期间只执行一次dom更新操作。
即在Watcher的update方法中不再直接触发dom更新,而是把变化后的Watcher放入一个更新队列中,在本次事件循环结束时,依次将更新队列中的Watcher出队并执行更新。
因此我们需要改进Watcher的实现,我们先看原来的Watcher中update方法的实现:

 update(newValue){         //值发生变化才变更         if(this.value!==newValue){             this.value=newValue;             this.run();         }     }     //执行DOM更新等操作     run(){         this.cb(this.value);     } 复制代码


这里的update方法中发现数据变更之后是立即执行run方法进行dom更新操作的,我们对它进行修改:

    update(newValue){         //值发生变化才变更         if(this.value!==newValue){             this.value=newValue;             //在异步更新队列中添加Watcher,用于后续更新             updateQueue.push(this);         }     }     //执行DOM更新等操作     run(){         this.cb(this.value);     } 复制代码


上面的代码我们把变更了的Watcher添加到更新队列updateQueque中,用于后续的更新,下面我们编写一个清空更新队列并依次执行更新的函数。

function flushUpdateQueue(){     while(updateQueue.length>0){         updateQueue.shift().run();     } } 复制代码


现在我们有了一个处理更新队列的函数,但是现在还缺少一个很重要的元素,就是执行此函数的时机,这时我们回忆一下我们的更新队列是异步更新队列,这里的异步即使用setTimeout或者Promise实现异步更新,这个实现过程就是nextTick的代码实现了,下面是简化版nextTick函数实现:

let callbacks=[];//事件队列,包含异步dom更新队列和用户添加的异步事件 let pending=false;//控制变量,每次宏任务期间执行一次flushCallbacks清空callbacks funciton nextTick(cb){    callbacks.push(cb);    if(!pending){       pending=true;       //这里也可以使用Promise,Promise创建的是微任务,微任务会在本次事件循环同步代码执行结束后执行,使用setTimeout创建的是宏任务,同样会在此次同步代码执行完成后执行,区别是在setTimeout代码执行之前会穿插一次无效的视图渲染,因此我们尽量使用Promise创建微任务实现异步更新。       if(Promise){           Promise.resovle().then(()=>{               flushCallbacks();           })       }       setTimeout(()=>{           flushCallbacks();       })    } } function flushCallbacks(){     pending=false;//状态重置     callbacks.forEach(cb=>{         callbacks.shift()();     }) } 复制代码


主要做了两件事,创建callbacks数组作为保存事件的队列,我们每次调用nextTick函数就往callbacks事件队列中入队一个事件,然后我们在setTimeout或者Promise.then创建的异步事件中,通过flushCallbacks将异步队列中的函数一次出队并执行。
这里使用pending变量控制本次同步(宏)任务期间不重复创建异步任务(setTimeout或者Promise.then)。
把上述代码添加到Vue类上:

class Vue{     constructor(options){         this.waiting=false         this.$el=options.el;         this._data=options.data;         this.$data=this._data;         this.$nextTick=this.nextTick;         new Observer(this._data);     }     //简易版nextTick     nextTick(cb){          callbacks.push(cb);          if(!pending){//控制变量,控制每次事件循环期间只执行一次flushCallbacks              pending=true;              if(Promise){                   Promise.resovle().then(()=>{                       this.flushCallbacks();                   })               }               setTimeout(()=>{                   this.flushCallbacks();               })          }     }       //清空callbacks     flushCallbacks(){        while(callbacks.length!=0){          callbacks.shift()();       }       pending=false;     }     //清空UpdateQueue队列,更新视图     flushUpdateQueue(){         while(updateQueue.length!=0){            updateQueue.shift().run();         }         has={};         this.waiting=false;     }  } 复制代码


对Watcher进行进一步优化如下:

class Watcher{      constructor(vm,key,cb){         this.vm=vm;         this.key=key;         this.uid=uid++;         this.cb=cb;         //调用get,添加依赖         Dep.target=this;         this.value=vm.$data[key];         Dep.target=null;      }      update(){          if(this.value!==this.vm.$data[this.key]){              this.value=this.vm.$data[this.key];              if(!this.vm.waiting){//控制变量,控制每次事件循环期间只添加一次flushUpdateQueue到callbacks                 this.vm.$nextTick(this.vm.flushUpdateQueue);                  this.vm.waiting=true;              }              //不是立即执行run方法,而是放入updateQueue队列中              if(!has[this.uid]){                  has[this.uid]=true;                  updateQueue.push(this);              }          }      }      run(){          this.cb(this.value);      }  } 复制代码


之前Watcher中的update方法是立即执行的,
观察上面的代码我们发现,update方法不再立即执行更新,得是把变更通过nextTick缓存到updateQueue队列中,这个队列保存了本次事件循环期间发生了变更的Watcher。

完整源码

class Dep{     static target=null     constructor(){         this.subs=[];     }     addSubs(watcher){         this.subs.push(watcher)     }     notify(){         for(let i=0;i<this.subs.length;i++){             this.subs[i].update();         }     }  }  class Observer{      constructor(data){         if(typeof data=='object'){             this.walk(data);         }      }      walk(obj){          const keys=Object.keys(obj);          for (let i = 0; i < keys.length; i++) {              this.defineReactive(obj, keys[i])          }      }      defineReactive(obj,key){          if(typeof obj[key]=='object'){              this.walk(obj[key]);          }          const dep=new Dep();          let val=obj[key];          Object.defineProperty(obj, key, {              enumerable: true,              configurable: true,              //get代理将Dep.target即Watcher对象添加到依赖集合中              get: function reactiveGetter () {                if (Dep.target) {                  dep.addSubs(Dep.target);                }                return val;              },              set: function reactiveSetter (newVal) {                   val=newVal;                   dep.notify()              }             })      }  }  let uid=0  class Watcher{      constructor(vm,key,cb){         this.vm=vm;         this.key=key;         this.uid=uid++;         this.cb=cb;         //调用get,添加依赖         Dep.target=this;         this.value=vm.$data[key];         Dep.target=null;      }      update(){          if(this.value!==this.vm.$data[this.key]){              this.value=this.vm.$data[this.key];              if(!this.vm.waiting){//控制变量,控制每次事件循环期间只添加一次flushUpdateQueue到callbacks                 this.vm.$nextTick(this.vm.flushUpdateQueue);                  this.vm.waiting=true;              }              //不是立即执行run方法,而是放入updateQueue队列中              if(!has[this.uid]){                  has[this.uid]=true;                  updateQueue.push(this);              }          }      }      run(){          this.cb(this.value);      }  }   const updateQueue=[];//异步更新队列   let has={};//控制变更队列中不保存重复的Watcher   const callbacks=[];   let pending=false;  class Vue{     constructor(options){         this.waiting=false         this.$el=options.el;         this._data=options.data;         this.$data=this._data;         this.$nextTick=this.nextTick;         new Observer(this._data);     }     //简易版nextTick     nextTick(cb){          callbacks.push(cb);          if(!pending){//控制变量,控制每次事件循环期间只执行一次flushCallbacks              pending=true;              setTimeout(()=>{                  //会在同步代码(上一次宏任务)执行完成后执行                  this.flushCallbacks();              })          }      }     //清空UpdateQueue队列,更新视图     flushUpdateQueue(){         while(updateQueue.length!=0){            updateQueue.shift().run();         }         has={};         this.waiting=false;     }     //清空callbacks     flushCallbacks(){        while(callbacks.length!=0){          callbacks.shift()();       }       pending=false;     }  }  //======测试======= let data={     message:'hello',     num:0 } let app=new Vue({     data:data }); //模拟数据监听 let w1=new Watcher(app,'message',function(value){     //模拟dom变更     console.log('message 引起的dom变更--->',value); }) //模拟数据监听 let w2=new Watcher(app,'num',function(value){     //模拟dom变更     console.log('num 引起的dom变更--->',value); }) data.message='world'//数据一旦更新,会为nextTick的事件队列callbacks中加入一个flushUpdateQueue回调函数 data.message='world1' data.message='world2'//message的变更push到updateQueue中,只保存最后一次赋值的结果 for(let i=0;i<=100;i++){    data.num=i;//num的变更push到updateQueue中,只保存最后一次赋值的结果 } //开发者为callbacks添加的异步回调事件 app.$nextTick(function(){    console.log('这是dom更新完成后的操作') }) //例子中的执行顺序是,先执行同步代码,其中第一次修改数据data.message='world'会把dom更新回调函数push到callbacks队列,并把dom更新操作的cb回调放入updateQueue,后续对message的变更操作 复制代码

总结


以上就是对Vue中nextTick实现原理的介绍,作为前置知识,也简单介绍了Vue响应式的实现原理以及js事件循环机制。如有收获,多多点,如有不足,还望不吝指出。

您可能还会对下面的文章感兴趣:

最新评论

  1. 发布于:2021-02-19 15:57
    博主,你的test代码能不能 不要搞一行。(没有恶意,笑哭笑哭)