前言
含义和使用
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的响应式实现原理,已经理解的同学可以跳过。
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类
观察者类,它做的事情就是观察数据的变更,它会调用data中对应属性的get方法触发依赖收集,并在数据变更后执行相应的更新。
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创建一个微任务。
使用Promise创建的是微任务,微任务会在本次事件循环同步代码执行结束后执行,使用setTimeout创建的是宏任务,同样会在此次同步代码执行完成后执行,区别是在setTimeout代码执行之前会穿插一次无效的视图渲染,因此我们尽量使用Promise创建微任务实现异步更新。
重头戏:nextTick
核心原理及异步更新队列。
前置知识回顾
说到Vue中nextTick的实现,必须提到一个新概念异步更新队列
,这里有两个关键字异步,更新队列。这理解这个概念我们就需要回顾一下前面我们写的简易版的Vue是如何响应数据并模拟dom更新的,这里我们在整体捋一遍流程:
Observe为数据添加代理,当我们使用到数据时,通过get代理方法我们可以收集到依赖该数据的Watcher对象,并且保存到Dep中作为该数据的依赖,这个过程就是依赖收集;
然后当我们修改数据时,会触发数据的set代理方法,进而执行Dep的notify方法触发所有依赖项的update方法执行更新。
而问题就出在了更新这一步,这里我们触发更新是同步执行的,即立即执行,像前面的for循环会频繁更新n多次,这造成了性能的浪费,尤其对于dom更新来说,一来是dom更新是昂贵的,二来这其中大多数是用户无法观测到的无效更新(因为浏览器事件循环机制中,一次循环中只有一次界面渲染)。
因此这里我们就可以借助浏览器事件循环机制实现异步更新,对发生变化的数据,每次事件循环期间只执行一次dom更新操作。为了回顾事件循环的知识,这里我们再列一下浏览器事件循环中的任务执行顺序:
- 宏任务
- 本次宏任务产生的所有微任务
- render(视图更新)
- 下一次宏任务
思路整理
好了,有了上面的知识积累,我们就可以先整理一个大致的实现思路,即如何借助浏览器的事件循环机制实现异步更新,每次时间循环期间只执行一次dom变更。
首先我们为每个要观察的数据创建了一个Watcher对象,当数据变更时,会触发Watcher对象的update方法,但是此时我们不再直接在update中触发run方法执行更新,而是把这个变更的Watcher保存到一个待更新的队列(数组实现)中,同时我们为这个待更新的队列创建一个微任务来执行它里面保存的更新,有了这个思路,接下来我们就从Watcher开始进行改造吧。
开始改造
我们先看原来的Watcher中update方法的实现:
update(newValue){
//值发生变化才变更
if(this.value!==newValue){
this.value=newValue;
this.run();
}
}
//执行DOM更新等操作
run(){
this.cb(this.value);
}
复制代码
这里的update方法中发现数据变更之后是立即执行run方法进行dom更新操作的,我们对它进行修改,首先需要创建一个全局的updateQueue数组来作为队列保存当前变更的数据对应的Watcher,之后再update方法中不再直接执行run方法,而是把当前变更的Watcher对象本身添加到更新队列updateQueue中
let updateQueue=[];//注意,这个数组是全局声明,不再Watcher类中
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方法做了进一步的完善,我们通过给nextTick函数传入之前定义好的flushUpdateQueue完成dom更新。
另外,上述代码还添加了一个对象has来确保不添加重复的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'会通过nextTick方法把
flushUpdateQueue
push到callbacks队列,并把message属性对应的Watcher入updateQueue,后续data.message的更新只修改Watcher的值value,不再重复添加到updateQueue; -
之后是num的首次更新,同样会尝试通过nextTick把
flushUpdateQueue
push到callbacks队列,但是会发现这时的waiting是true,不再重复添加flushUpdateQueue
,之后会把num属性对应的Watcher入updateQueue,后续的99次data.num的变更都不会再重复添加到updateQueue; -
接下来我们主动执行vue对象的nextTick方法,添加了一个回调函数到callbacks队列中;
-
同步代码(宏任务)执行完毕,这时轮到nextTick中的异步任务执行了,这里分Promise.then和setTimeout两种情况说一下:
-
Promise.then
在新建的微任务中执行flushCallbacks,依次执行flushUpdateQueue执行UI更新队列和开发者添加的回调函数,微任务执行完成后dom变更也完成了,紧接着是浏览器的视图渲染。
-
setTimeout
在新建的宏任务中执行flushCallbacks,因为两次宏任务期间会有一次浏览器的视图渲染,因此这里会先执行一次无效的视图渲染,之后依次执行callbacks中的flushUpdateQueueUI更新队列和开发者添加的回调函数,dom更新完成,紧接着是下一次的视图渲染。
总结
以上就是对Vue中nextTick实现原理的介绍,作为前置知识,也简单介绍了Vue响应式的实现原理以及js事件循环机制。如有收获,请多多点赞👍,如有不足,还望不吝指出。