[路飞]_你真的弄懂$nextTick了吗?

152 阅读2分钟

「这是我参与2022首次更文挑战的第7天,活动详情查看:2022首次更文挑战

前言

在开讲之前,大家先要保证自己明白什么是js事件循环机制了,即event loop

参考文章:Tasks, microtasks, queues and schedules

作者:Jake

中文的大家自行百度,很多

场景一:

<template>
	<div>
		<p id="p1">{{foo}}</p>
	</div>
</template>
new Vue({
	data(){
		return {
			foo:0
		}
	},
	mounted(){
		this.foo = 1
		console.log('1:' + this.foo);
		this.foo = 2
		console.log('2:' + this.foo);
		this.foo = 3
		console.log('3:' + this.foo); 
		this.$nextTick(() => {
			console.log('p1.innerHTML:' + p1.innerHTML); 
		})
	}
})

// 输出结果
// 1:1
// 2:2
// 3:3
// p1.innerHTML:3

过程分析:

this.foo = 1,触发了哪些事情?如下:

  1. this.foo = 1,修改了foo的值,触发foo响应式数据的set拦截方法
  2. set方法里触发dep.notify()去通知对应的watcher更新视图
  3. 对应的watcher调用自身的update方法
  4. update方法里调用queueWatcher(this)
  5. queueWatcher(this)方法,将watcher自身加入一个watcher队列queue,并执行vue内部的nextTick(flushSchedulerQueue)方法, 让flushSchedulerQueue在未来某个时刻去执行。
    • flushSchedulerQueue是一个任务冲刷函数,逻辑是循环调用watcher队列queue里每个watcher的run()方法去更新视图
  6. nextTick方法将上一步的flushSchedulerQueue函数加入到另一个数组callbacks里,并且执行timerFunc(),启动异步任务刷新流程,且设置变量pending = true,该变量只有等后面开始调用flushcallbacks之后,才会再次置为false。
  7. timerFunc()会根据浏览器环境判断 promise > MutationObserver > setImmediate > setTimeout,优先使用promise去执行promise.resolve().then(flushcallbacks),来将冲刷任务数组的方法flushcallbacks加入到浏览器的微任务队列miroTasks 中, 即miroTasks = [flushcallbacks]
    • flushcallbacks是针对第7点的callbacks数组的冲刷方法,会遍历callbacks数组,执行里面的每一项flushSchedulerQueue。但flushcallbacks在浏览器的微任务队列里,不会马上执行,会等当前这一次的同步代码执行完,才会执行,所以代码会继续往下走

this.foo = 2,触发了哪些事情?如下:

1、2、3、4步同上 5. queueWatcher(this)方法,判断当前watcher已经在任务队列中了,所以不会让watcher如下,后面的步骤不走了

this.foo = 3,触发了哪些事情?如下:

同上

this.$nextTick(cb),触发了哪些事情?如下:

  1. 执行vue提供的$nextTick方法(同上面的nextTick方法,只是一个在内部vue自己调用,一个给用户调用),将cb放入到callbacks数组中。

    • 那么此时callbacks = [flushSchedulerQueue,cb]
  2. 由于我们在this.foo = 2的时候,第6步里面,设置了pending = true,所以这里并不会再次执行timeFunc()

到这里,同步代码执行结束

接下来处理微任务

  1. 浏览器会扫描微任务队列,发现微任务队列miroTasks = [flushcallbacks],
  2. 将微任务队列中的flushcallbacks拿出来执行,
  3. 这时候会去遍历数组callbacks = [flushSchedulerQueue,cb],
  4. 先执行flushSchedulerQueue,遍历watcher数组queue,执行每个watcher的watcher.run()方法。
  5. 这里watcher后面的大该过程不是我们这里讨论的重点,我们只要知道watcher后面执行的逻辑直到dom更新都是同步的,大该流程如下: watcher.run() => watcher.get() => watcher.getter() => 触发 new Watcher(vm,updateComponent)时传入的updateComponent() => vm._render()构建vNode => vm._update => patch比较 => 挂载 => 真正dom更新
  6. 到这里,dom已经更新了。此时callbacks里只剩下cb了,即 callbacks = [cb]
  7. 执行cb,也就是我们用户调用this.$nextTick传入的回调,所以打印出来的p1.innerHTML为dom更新后的值即p1.innerHTML:3

场景二:

<template>
	<div>
		<p id="p1">{{foo}}</p>
	</div>
</template>
new Vue({
	data(){
		return {
			foo:0
		}
	},
	mounted(){
		this.foo = 1
		console.log('1:' + this.foo); 
		this.foo = 2
		console.log('2:' + this.foo);
		this.$nextTick(() => {
			console.log('p1.innerHTML:' + p1.innerHTML);
		})
		this.foo = 3
		console.log('3:' + this.foo);
	}
})

// 输出结果
// 1:1
// 2:2
// 3:3
// p1.innerHTML:3

很多朋友这里可能会说,因为this.$nextTick是异步的,所以回调最后执行,console.log出来的就是dom更新之后的结果, p1.innerHTML:3

这话其实只数对了一半,为什么呢,请看下的例子

场景三:

<template>
	<div>
		<p id="p1">{{foo}}</p>
	</div>
</template>
new Vue({
	data(){
		return {
			foo:0
		}
	},
	mounted(){
		this.$nextTick(() => {
			console.log('p1.innerHTML:' + p1.innerHTML);
		})
		this.foo = 1
		console.log('1:' + this.foo); 
		this.foo = 2
		console.log('2:' + this.foo); 
		this.foo = 3
		console.log('3:' + this.foo); 
	}
})

// 输出结果
// 1:1
// 2:2
// 3:3
// p1.innerHTML:1

不相信的朋友的,可以去自己的vue demo里试一下,看看是不是如我所说。

为什么会这样呢? $nextTick不是会等dom更新再执行吗? 为什么输出的结果dom更新前的呢?

我们来结合场景一的步骤分析以下:

  1. 用户执行this.$nextTick(cb)方法,会将cb放进我们刚才场景一步骤6的callbacks数组里,也就是此时callbacks = [cb]。并且由于此时callbacks没有开始被冲刷,所以pending = false,所以我们会去执行timeFunc(),且将pending置为true。
  2. 执行timeFunc(),将flushCallbacks()函数放进微任务队列里,此时 microTasks = [flushCallbacks]
  3. this.foo = 1,会触发watcher那一系列的操作,从步骤1 -> 步骤6,得到的结果就是,将watcher放入任务队列queue = [watcher],然后将任务冲刷函数flushSchedulerQueue放入callbacks, 也就是此时callbacks = [cb, flushSchedulerQueue],然后由于此时pending = true,所以不会再次执行timeFunc()。
  4. 此时浏览器会继续执行同步任务,直到此轮同步任务执行完,再去执行微任务队列
  5. this.foo = 2和this.foo = 3,我们场景一分析过,queueWatcher会对watcher去重,所以此时watcher并不会放入到任务队列
  6. 同步代码执行完成,开始执行微任务。
  7. 浏览器拿出微任务队列microTasks = [flushCallbacks],执行flushCallbacks
  8. 遍历callbacks = [cb, flushSchedulerQueue],先执行cb
  9. 此时先执行的是cb,而我们知道dom更新是在flushSchedulerQueue任务冲刷里操作的,所以此时dom并没有更新,所以cb打印出来的值是dom更新前的,即p1.innerHTML:1

所以,这里我们可以总结一个很重要的结论

this.$nextTick(cb)会让回调在dom更新之后执行,但是~~

必须在this.$nextTick(cb)之前已经有过对数据修改。

因为这个修改会触发了vue内的一系列操作,让内部的nextTick执行。进而将更新视图的任务冲刷函数flushSchedulerQueue放入到callbacks数组中,以供后序调用。

那么,大家是不是胸有成竹了,我们再来看最后一个例子

场景四:

问promise和nextTick执行顺序是怎样的,输出值多少?

<template>
	<div>
		<p id="p1">{{foo}}</p>
	</div>
</template>
new Vue({
	data(){
		return {
			foo:0
		}
	},
	mounted(){
		this.foo = 1
		console.log('1:' + this.foo);
		this.foo = 2
		console.log('2:' + this.foo);
		this.foo = 3
		console.log('3:' + this.foo);

		Promise.resolve().then(() => {
			console.log('promise:' + p1.innerHTML)
		})
	
		this.$nextTick(() => {
			console.log('p1.innerHTML:' + p1.innerHTML); 
		})
	}
})

// 输出结果
// 1:1
// 2:2
// 3:3
// p1.innerHTML:3
// promise:3

小伙伴们答对了吗? 是不是觉得Promise.resolve().then不是微任务吗? this.$nextTick也是微任务,promise应该先被推入队列去执行才对呀?

我们来看看为什么

  1. this.foo = 1, 触发了一系列操作,包括vue内部的nextTick方法,并执行了timeFunc()。
  2. 此时watcher队列为,queue = [watcher],
  3. callbacks数组为callbacks = [flushSchedulerQueue]
  4. 微任务队列为,microTasks = [flushCallbacks]
  5. this.foo = 2,和this.foo = 3我们就不说了,上面分析过
  6. 执行Promise.resolve().then(promiseCb),将回调函数promiseCb加入到微任务队列中
  7. 此时微任务队列为,microTasks = [flushCallbacks,promiseCb]
  8. 用户执行this.$nextTick(nextTickCb),将回调函数nextTickCb加入到callbacks数组中
  9. 此时callbacks数组为callbacks = [flushSchedulerQueue,nextTickCb]
  10. 微任务队列不变,仍然是microTasks = [flushCallbacks,promiseCb]
  11. 此时,同步代码执行完毕,开始执行微任务队列
  12. 结合microTasks和callbacks,此时的完整的函数执行的队列,应该是这样的,我干脆给它起个名字叫funcQueue好了,funcQueue = [[flushSchedulerQueue,nextTickCb],promiseCb]
  13. 所以,先执行flushSchedulerQueue,更新视图,
  14. 再执行nextTickCb,输出p1.innerHTML:3
  15. 再执行promiseCb,输出promise:3

总结

当然是要总结一点东西的

我们分析类似nextTick问题的时候,我们心中一定要明白几个关键的东西

  • 任务队列queue,存放的是要处理的watcher
  • 冲刷函数flushSchedulerQueue,会遍历queue去处理watcher更新视图
  • callbacks数组,存放的是vue内部执行nextTick和用户执行this.$nextTick,传入的函数。
    • vue内部执行nextTick传入的是冲刷函数flushSchedulerQueue
    • 用户执行this.$nextTick,传入的是回调函数函数。
  • callbacks冲刷函数flushCallbacks,遍历callbacks,处理里面的方法
  • microTasks,微任务队列

搞清楚明白这些,再做类似的分析,慢慢分析,一定可以的。