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的设计意图和实现原理我们需要两块的前置知识理解:
- Vue响应式原理(理解设计意图)
- 浏览器事件循环机制(理解原理)
因此本次行文先简单讲解以上两部分内容,最后将知识整合详细介绍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大致一致,为了容易理解我们这里只处理了参数el和data。
我们发现构造函数的最后一行创建了一个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事件循环机制。如有收获,多多点,如有不足,还望不吝指出。
最新评论