都会用 nextTick,也都知道 nextTick 作用是在下次 DOM 更新循环结束之后,执行延迟回调,就可以拿到更新后的 DOM 相关信息
那么它到底是怎么实现的呢,在 Vue2 和 Vue3 中又有什么区别呢?本文将结合案例介绍执行原理再深入源码,全部注释,包你一看就会
在进入 nextTick 实现原理之前先稍微回顾一下 JS 的执行机制,因为这与 nextTick 的实现息息相关
JS 执行机制
我们都知道 JS 是单线程的,一次只能干一件事,即同步,就是说所有的任务都需要排队,后面的任务需要等前面的任务执行完才能执行,如果前面的任务耗时过长,后面的任务就需要一直等,这是非常影响用户体验的,所以才出现了异步的概念
同步任务
:指排队在主线程上依次执行的任务
异步任务
:不进入主线程,而进入任务队列的任务,又分为宏任务和微任务
宏任务
: 渲染事件、请求、script、setTimeout、setInterval、Node 中的 setImmediate 等
微任务
: Promise.then、MutationObserver(监听 DOM)、Node 中的 Process.nextTick 等
当执行栈中的同步任务执行完后,就会去任务队列中拿一个宏任务放到执行栈中执行,执行完该宏任务中的所有微任务,再到任务队列中拿宏任务,即一个宏任务、所有微任务、渲染、一个宏任务、所有微任务、渲染…(不是所有微任务之后都会执行渲染),如此形成循环,即事件循环(EventLoop)
nextTick
就是创建一个异步任务,那么它自然要等到同步任务执行完成后才执行
我们先结合例子弄懂执行原理,再深入源码
Vue2
nextTick 用法
看例子,比如当 DOM 内容改变后,我们需要获取最新的高度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| <template> <div>{{ name }}</div> </template> <script> export default { data() { return { name: "" } }, mounted() { console.log(this.$el.clientHeight) this.name = "沐华" console.log(this.$el.clientHeight) this.$nextTick(() => { console.log(this.$el.clientHeight) }); } }; </script>
|
为什么在 nextTick 里就能拿到最新的 DOM 相关信息?是怎么拿到的,我们来分析一下原理
原理分析
在执行 this.name = '沐华'
的时候,就会触发 Watcher
更新,watcher 会把自己放到一个队列
用队列的原因是比如多个数据变更就更新视图多次的话,性能上就不好了,所以对视图更新做一个异步更新的队列,避免重复计算和不必要的 DOM 操作,在下一轮事件循环的时候刷新队列,并执行已去重的任务(nextTick 的回调函数),更新视图
然后调用 nextTick()
,响应式派发更新的源码在这一块是这样的,地址:src/core/observer/scheduler.js - 164行
1 2 3 4 5
| export function queueWatcher (watcher: Watcher) { ... nextTick(flushSchedulerQueue) }
|
这里参数 flushSchedulerQueue
方法就会被放入事件循环,主线程任务的行完后就会执行这个函数,对 watcher 队列排序、遍历、执行 watcher 对应的 run 方法,然后 render,更新视图
也就是说 this.name = '沐华'
的时候,任务队列可以简单理解成这样 [flushSchedulerQueue]
然后下一行 console.log(...)
,由于会更新视图的任务 flushSchedulerQueue
在任务队列里没有执行,所以无法拿到更新后的视图
然后执行到 this.$nextTick(fn)
的时候,添加一个异步任务,这时的任务队列可以简单理解成这样 [flushSchedulerQueue, fn]
然后同步任务就执行完了,接着按顺序执行任务队列里的任务,第一个任务执行就会更新视图,后面自然能得到更新后的视图了
nextTick 源码剖析
源码版本:2.6.14
,源码地址:src/core/util/next-tick.js
这里整个源码分为两部分,一是判断当前环境能使用的最合适的 API
并保存异步函数,二是调用异步函数 执行回调队列
环境判断
主要是判断用哪个宏任务或微任务,因为宏任务耗费的时间是大于微任务的,所以成先使用微任务,判断顺序如下
Promise
MutationObserver
setImmediate
setTimeout
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| export let isUsingMicroTask = false const callbacks = [] let pending = false
function flushCallbacks() { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } let timerFunc
if (typeof Promise !== "undefined" && isNative(Promise)) { const p = Promise.resolve() timerFunc = () => { p.then(flushCallbacks) if (isIOS) setTimeout(noop) } isUsingMicroTask = true } else if ( !isIE && typeof MutationObserver !== "undefined" && (isNative(MutationObserver) || MutationObserver.toString() === "[object MutationObserverConstructor]") ) { let counter = 1 const observer = new MutationObserver(flushCallbacks) const textNode = document.createTextNode(String(counter)) observer.observe(textNode, { characterData: true, }) timerFunc = () => { counter = (counter + 1) % 2 textNode.data = String(counter) } isUsingMicroTask = true } else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) { timerFunc = () => { setImmediate(flushCallbacks) } } else { timerFunc = () => { setTimeout(flushCallbacks, 0) } }
|
环境判断结束就会得到一个延迟回调函数 timerFunc
然后进入核心的 nextTick
nextTick()
我们用 Vue.nextTick()
或者 this.$nextTick()
都是调用 nextTick()
这个方法
这里代码不多,主要逻辑就是:
- 把传入的回调函数放进回调队列
callbacks
- 执行保存的异步任务
timeFunc
,就会遍历 callbacks
执行相应的回调函数了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| export function nextTick(cb?: Function, ctx?: Object) { let _resolve callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, "nextTick") } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { pending = true timerFunc() } if (!cb && typeof Promise !== "undefined") { return new Promise((resolve) => { _resolve = resolve }) } }
|
可以看到最后有返回一个 Promise
是可以让我们在不传参的时候用的,如下
1
| this.$nextTick().then(()=>{ ... })
|
Vue3
nextTick 用法
先看个例子,点击按钮更新 DOM 内容,并获取最新的 DOM 内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <template> <div ref="test">{{name}}</div> <el-button @click="handleClick">按钮</el-button> </template> <script setup> import { ref, nextTick } from 'vue' const name = ref("沐华") const test = ref(null) async function handleClick(){ name.value = '掘金' console.log(test.value.innerText) await nextTick() console.log(test.value.innerText) } return { name, test, handleClick } </script>
|
Vue3 里这一块有大改,不过事件循环的原理还是一样,只是加了几个专门维护队列的方法,以及关联到 effect
,不过好在这里源码的代码不多,所以不如直接看源码会更容易理解
nextTick 源码剖析
源码版本:3.2.11
,源码地址:packages/runtime-core/src/sheduler.ts
1 2 3 4 5 6 7 8 9 10
| const resolvedPromise: Promise<any> = Promise.resolve() let currentFlushPromise: Promise<void> | null = null
export function nextTick<T = void>( this: T, fn?: (this: T) => void ): Promise<void> { const p = currentFlushPromise || resolvedPromise return fn ? p.then(this ? fn.bind(this) : fn) : p }
|
就一个 Promise,没了
就这!!!
好吧,认真点
可以看出 nextTick 接受一个函数为参数,同时会创建一个微任务
在我们页面调用 nextTick
的时候,会执行该函数,把我们的参数 fn
赋值给 p.then(fn)
,在队列的任务完成后,fn 就执行了
由于加了几个维护队列的方法,所以执行顺序是这样的:
queueJob
-> queueFlush
-> flushJobs
-> nextTick参数的 fn
现在不知道都是干嘛的不要紧,几分钟后你就会清楚了
我们按顺序来,先看一下入口函数 queueJob
是在哪里调用的,看代码
1 2 3 4 5 6 7 8 9 10
| function baseCreateRenderer(){ const setupRenderEffect: SetupRenderEffectFn = (...) => { const effect = new ReactiveEffect( componentUpdateFn, () => queueJob(instance.update), instance.scope ) } }
|
在 ReactiveEffect
这边接收过来的形参就是 scheduler
,最终被用到了下面这里,看过响应式源码的这里就熟悉了,就是派发更新的地方
1 2 3 4 5 6 7 8 9
| export function triggerEffects( ... if (effect.scheduler) { effect.scheduler() } else { effect.run() } }
|
然后是 queueJob
里面干了什么?我们一个一个的来
queueJob()
该方法负责维护主任务队列,接受一个函数作为参数,为待入队任务,会将参数 push
到 queue
队列中,有唯一性判断。会在当前宏任务执行结束后,清空队列
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| const queue: SchedulerJob[] = []
export function queueJob(job: SchedulerJob) { if ( (!queue.length || !queue.includes( job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex )) && job !== currentPreFlushParentJob ) { if (job.id == null) { queue.push(job) } else { queue.splice(findInsertionIndex(job.id), 0, job) } queueFlush() } }
|
queueFlush()
该方法负责尝试创建微任务,等待任务队列执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| let isFlushing = false let isFlushPending = false const resolvedPromise: Promise<any> = Promise.resolve() let currentFlushPromise: Promise<void> | null = null
function queueFlush() { if (!isFlushing && !isFlushPending) { isFlushPending = true currentFlushPromise = resolvedPromise.then(flushJobs) } }
|
flushJobs()
该方法负责处理队列任务,主要逻辑如下:
- 先处理前置任务队列
- 根据
Id
排队队列
- 遍历执行队列任务
- 执行完毕后清空并重置队列
- 执行后置队列任务
- 如果还有就递归继续执行
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| function flushJobs(seen?: CountMap) { isFlushPending = false isFlushing = true if (__DEV__) seen = seen || new Map() flushPreFlushCbs(seen) queue.sort((a, b) => getId(a) - getId(b)) try { for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { const job = queue[flushIndex] if (job && job.active !== false) { if (__DEV__ && checkRecursiveUpdates(seen!, job)) { continue } callWithErrorHandling(job, null, ErrorCodes.SCHEDULER) } } } finally { flushIndex = 0 queue.length = 0 flushPostFlushCbs(seen) isFlushing = false currentFlushPromise = null if ( queue.length || pendingPreFlushCbs.length || pendingPostFlushCbs.length ) { flushJobs(seen) } } }
|
flushPreFlushCbs()
该方法负责执行前置任务队列,说明都写在注释里了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| export function flushPreFlushCbs( seen?: CountMap, parentJob: SchedulerJob | null = null) { if (pendingPreFlushCbs.length) { currentPreFlushParentJob = parentJob activePreFlushCbs = [...new Set(pendingPreFlushCbs)] pendingPreFlushCbs.length = 0 if (__DEV__) { seen = seen || new Map() } for ( preFlushIndex = 0; preFlushIndex < activePreFlushCbs.length; preFlushIndex+ ) { if ( __DEV__ && checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex])) { continue } activePreFlushCbs[preFlushIndex]() } activePreFlushCbs = null preFlushIndex = 0 currentPreFlushParentJob = null flushPreFlushCbs(seen, parentJob) } }
|
flushPostFlushCbs()
该方法负责执行后置任务队列,说明都写在注释里了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| let activePostFlushCbs: SchedulerJob[] | null = null
export function flushPostFlushCbs(seen?: CountMap) { if (pendingPostFlushCbs.length) { const deduped = [...new Set(pendingPostFlushCbs)] pendingPostFlushCbs.length = 0 if (activePostFlushCbs) { activePostFlushCbs.push(...deduped) return } activePostFlushCbs = deduped if (__DEV__) seen = seen || new Map() activePostFlushCbs.sort((a, b) => getId(a) - getId(b)) for ( postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++ ) { if ( __DEV__ && checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex])) { continue } activePostFlushCbs[postFlushIndex]() } activePostFlushCbs = null postFlushIndex = 0 } }
|
整个 nextTick 的源码到这就解析完啦