前言
含义和使用
nextTick的官方解释:
啥意思呢,即我们对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
。
即我们在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;
以上测试代码输出
为什么要用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')
})
上面代码的执行结果为:
这里主要讲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事件循环机制。如有收获,多多点,如有不足,还望不吝指出。
参考文献:Vue运行机制
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!