Vue 异步更新 - nextTick
下文的源码解析都是简化过的伪代码,因为实际代码需要关注的细节太多。
我们可以将和主线知识不相关的细节忽略掉,暂时只关注核心内容。
前置知识
- 浏览器进程 - 线程;
- 事件循环;
- 深入理解 Vue 响应式;
其中关于 浏览器进程 - 线程 和 事件循环 抽取出需要用到的部分大致如下:
在浏览器中,事件循环和渲染的大致顺序是:
宏任务 --> 微任务 --> 渲染 --> 宏任务 --> 微任务 --> 渲染 -->...
注意:有很多人都会有误解,认为不是微任务优先级更高吗,那不是最先应该执行微任务吗?
这里他们大都忽略了一个条件,那就是当前执行JS
脚本本身就算的上是宏任务了。
Vue 2.x
响应式数据变化之后发生了什么
当触发当前的数据更新时,就会触发当前数据的set
,然后就会调用该响应式数据之前收集的依赖(副作用),在Vue2
中,依赖(副作用)一般是Watcher
实例。
接下来从源码的角度来梳理一下响应式数据变化之后的相关逻辑:
defineReactive
:
参考:defineReactive 源码。
源码位置:
Dep
:
参考:dep notify 源码。
这里就会发现,触发set
后,watcher
不会立即调用watcher.run
。
而是在watcher.update
中调用queueWatcher(this)
,将当前待触发的watcher
放入到队列中进行处理延迟调用run
。
接下来来看这个watcher.update
到底是什么?
Watcher
:
参考:watcher update 源码。
这里也许你会很好奇,为什么Watcher
和视图更新有关系?接下来看Vue
是如何关联上两者的。
注册响应式视图更新
参考:lifecycle mountComponent 源码。
结合上文我们知道,new Watcher(vm, updateComponent, noop)
将 updateComponent
作为 getter
被传入到 watcher
中,并且 lazy
为 false
, cb
为一个空函数(noop: () => ()
)。
其实按照常规注册响应式来说,会将 updateComponent
作为 cb
传入,这样就能在响应式数据发生变动时触发cb
。
但是这里却将 updateComponent
作为 getter
传入, 具体原因请看如下解析:
-
updateComponent
作为 getter
, 它会触发当前 vm
实例的 data
上的响应式数据的 getter
,这样就致使这个 watcher
被收集到这些响应式数据的依赖项(副作用)中。
这是因为 updateComponent --> render()
, 而render
函数中会主动去获取渲染到视图上的值。
-
当 watcher
的 getter
被调用时,会触发视图更新,因为当前 watcher
的 lazy
为 false
,因此在 new Watcher()
实例化的时候在构造器中就触发了 this.value = this.lazy ? undefined : this.get() --> getter --> updateComponent
,这里只能解释实例化的时候第一次触发 updateComponent
。
那后续的 updateComponent
该如何触发呢,这里很巧妙的就是:
因为 watcher
的 getter
的触发时机其中一个就是在响应式数据的 set
被触发的时候会获取最新的值这个逻辑(watcher.run() --> const value = this.get())
,那就意味着 updateComponent
函数的调用时机就是在响应式数据的 set
被触发的时候。
讲完Watcher
和视图更新的关联之后,接下来我们来看一看异步更新的主要入口函数 queueWatcher
,我们以queueWatcher
为入口,按照Vue2.5.x
和Vue2.6.x
的版本来分别讲述其中原理。
Vue 2.5.x
源码
参考:queueWatcher 源码。
在Vue 2.5.x
中,queueWatcher
,它的作用是将当前待执行的watcher
加入到queue
中,然后在本轮事件循环尾的微任务队列,或者下一轮事件循环开始的宏任务队列中执行flushSchedulerQueue
:
参考:flushSchedulerQueue 源码。
flushSchedulerQueue
,它的作用就是执行队列:queue: [ watcher1, watcher2, ... ]
,分别调用watcher.run
:
参考:nextTick 源码。
nextTick(cb)
,它的作用就是将cb
添加到本轮事件循环中的微任务队列执行,或者添加到下一轮事件循环开始的宏任务队列中开始执行:
macroTimerFunc
将flushCallback
推入宏任务队列中执行:
microTimerFunc
将flushCallback
推入微任务队列中执行,如果不兼容Promise
,那么最终会降级为将flushCallbacks
推入宏任务队列中执行:
接下来我们结合一下实际案例来进行分析:
示例
初步猜测
结合上面的例子,我们可以大概猜得出来,Vue
的数据变化后,视图并不会同步的发生变化,而且视图变化的时机甚至在Promise.resolve().then
之后,也就是在本轮事件循环的微任务之后。
而按照JS
中事件循环的大概顺序,宏任务 --> 微任务 --> 渲染 --> 宏任务 --> xxx
。
我们可以大胆的猜测,视图发生变化的时机被延迟到了下一轮事件循环的开始的宏任务
。
然后我们发现在nextTick(cb), setTimeout(cb, 0)
中,它们都能获取到视图变化之后的结果,那么可以猜测,nextTick(cb)
中cb
的执行时机是不是和setTimeout
相同呢,它们是不是都是宏任务呢。
$nextTick(cb) 和视图更新时机分析
首先我们知道,根据示例的情况,是@click="handleChangeAByEvent"
中的handleChangeAByEvent
触发导致的视图变化,因此useMacroTask
为true
,而且由于浏览器Chrome
不兼容setImmediate
,因此flushCallbacks
的调用机制为:
postMessage
是宏任务,因此除了第一个setTimeout
的执行不符合预期外,其他的都符合预期。
第一个setTimeout(cb, 0)
应该是在this.a ++
之前就执行了,那么理论上宏任务队列为:
[ cb, postMessage(1) ]
,但是实际执行结果却是先执行的postMessage(1) --> flushCallbacks
,也就是先执行的视图更新,再执行的 cb
。
这里的原因是 setTimeout(cb, timeout)
中的计时是有误差的,cb
进入宏任务队列的时间一般会比timeout
要晚几毫秒。
也就因此postMessage(1) --> onmessage --> flushCallbacks
先一步进入了宏任务队列中了。
响应式数据变化到视图更新流程
因此当一个数据发生变化之后,大概流程是:
this.a ++
;
- 触发
set
;
dep.notify()
;
watcher.update()
;
queueWatcher()
;
nextTick(flushSchedulerQueue)
;
flushSchedulerQueue
被包装之后作为nextTick
的cb
,添加到callbacks
中;
flushCallbacks
的执行时机被延迟,默认情况下是延迟到本轮事件循环中的微任务队列中执行,只有两种情况是延迟到下一轮事件循环开始的宏任务队列中;
- 当
useMacroTask
为true
,也就是当我们@event="method"
中的method
被触发时,method
中的响应式数据发生的变化所导致的nextTick(flushSchedulerQueue)
,或者直接在method
里面手动调用的nextTick(cb)
,其中flushSchedulerQueue, cb
都会被延迟到下一轮事件循环开始的宏任务队列中执行;
- 不兼容
Promise
时,降级方案用的是setTimeout(cb, 0)
;
- 遍历执行
watcher.run()
;
updateComponent
;
rerender --> patch(oldVNode, newVNode)
;
- 更新
DOM
;
Vue 2.6.x
源码
在Vue 2.6.x
中,前面的部分都和Vue.2.5.x
中类似,区别部分是在文件next-tick.js
中。
参考:nextTick 源码。
nextTick(cb)
,它的作用就是将cb
添加到本轮事件循环中的微任务队列执行,或者添加到下一轮事件循环开始的宏任务队列中开始执行,但是和2.5.x
不同的是,2.6.x
默认就是将cb
推入微任务队列执行,只有不兼容微任务相关API
时,才会最终降级为宏任务:
timerFunc
默认情况下将flushCallback
推入微任务队列中执行,如果不兼容Promise, MutationObserver
等微任务相关API
时,才最终会降级为将flushCallbacks
推入宏任务队列中执行:
对比 2.5.x 改动
Vue 2.6.0
对 $nextTick
做了修改,将其全部改为微任务实现,只有实现微任务的API
全部不兼容时,才会采用宏任务做兼容。
https://github.com/vuejs/vue/releases/tag/v2.6.0
接下来我们结合一下实际案例来进行分析:
示例
初步猜测
同步任务没有获取到最新的视图,说明视图的更新依旧是异步的,但是由于微任务和宏任务都获取到了最新的视图,所以我们也无法推测视图更新是在本轮事件循环的微任务队列中还是下一轮事件循环开始的宏任务队列中。
然后$nextTick(cb)
和Promise.resolve().then(cb)
中的cb
都获取到了最新的视图,并且$nextTick(cb)
中的 cb
在Promise.resolve().then(cb)
中的 cb
之前执行,这里说明$nextTick(cb)
是将cb
延迟到本轮事件循环的微任务队列中执行。
$nextTick(cb) 和视图更新时机分析
首先我们知道,根据示例的情况,而且由于浏览器Chrome
兼容Promise
,因此flushCallbacks
的调用机制为:
看下面的例子,你是否会好奇,为什么$nextTick(xx)
中 xx
的执行时机比他前面的Promise.resolve().then(xx)
中的 xx
要早?
这是因为this.$nextTick(cb)
是将cb
加入到callbacks
中,然后在flushCallbacks
中执行,而flushCallbacks
是在this.a ++
的时候就被推入了微任务队列,也就是说flushCallbacks
比Promise.resolve().then(xx)
中的xx
更早进入微任务队列。
当前微任务队列:[ flushCallbacks, xx ]
:
响应式数据变化到视图更新流程
大部分和2.5.x
相同,只有第8
步不同,大概流程是:
this.a ++
;
- 触发
set
;
dep.notify()
;
watcher.update()
;
queueWatcher()
;
nextTick(flushSchedulerQueue)
;
flushSchedulerQueue
被包装之后作为nextTick
的cb
,添加到callbacks
中;
flushCallbacks
的执行时机被延迟,默认情况下是延迟到本轮事件循环中的微任务队列中执行,只有一种情况是延迟到下一轮事件循环开始的宏任务队列中;
- 不兼容
Promise, MutationObserver
时,才最终降级为宏任务;
- 遍历执行
watcher.run()
;
updateComponent
;
rerender --> patch(oldVNode, newVNode)
;
- 更新
DOM
;
Vue 3.x
当前选择版本3.2.x
响应式数据变化之后发生了什么
当触发当前的数据更新时,就会触发当前数据的set
,然后就会调用该响应式数据之前收集的依赖(副作用),在Vue3
中,依赖(副作用)一般是ReactiveEffect
实例。
接下来从源码的角度来梳理一下响应式数据变化之后的相关逻辑:
reactive
:
参考:reactive 源码。
get
:
参考:createGetter 源码。
set
:
参考:createSetter 源码。
trigger
:
参考:trigger 源码。
triggerEffect
:
参考:triggerEffect 源码。
你会发现,触发 set
后,effect
并不一定是直接调用 effect.run
。
而是如果 effect
具有 scheduler
属性作为方法, 那么就调用传入的 scheduler
方法。
这里也许你会很好奇, effect.scheduler
到底是什么?为什么 effect.scheduler
和视图更新有关系?
接下来我们就来结合源码解释一下这两个问题:
注册响应式视图更新
ReactiveEffect
:
参考:ReactiveEffect 源码。
setupRenderEffect
:
参考:setupRenderEffect 源码。
结合上文我们知道,new ReactiveEffect( componentUpdateFn, () => queueJob(instance.update), instance.scope)
将 componentUpdateFn
作为 effect.fn
,并且将 () => queueJob(instance.update)
作为 effect.scheduler
。
这也就解释的上面的其中一个问题,对于视图更新而言,effect.scheduler
为 () => queueJob(instance.update)
,那instance.update
是如何和视图更新扯上关系的呢?
instance.update = effect.run.bind(effect)
,其中 effect.run
执行的就是 componentUpdateFn --> render --> patch
。
它会触发响应式数据的 getter
,这样就致使这个 effect
被收集到这些响应式数据的依赖项(副作用)中。
这是因为 componentUpdateFn --> render()
, 而 render
函数中会主动去获取渲染到视图上的值。
之后视图上的响应式数据只要发生变化就会重复调用componentUpdateFn
,从而触发视图更新。
讲完 ReactiveEffect
和视图更新的关联之后,接下来我们来看一看异步更新的主要入口函数 queueJob
,我们以 queueJob
为入口,来讲述其中原理。
源码
参考:queueJob 源码。
在Vue 2.x
中,queueWatcher
是异步更新的入口,在Vue3.x
中,对应的是queueJob
。
和Vue2.x
不同的是,Vue3.x
不用去兼容一些太低版本的浏览器,因此不用考虑兼容方案,而现在大多数浏览器都支持Promise
特性了。
它的作用是将当前待执行的job
加入到queue
中,然后执行queueFlush
:
参考:queueFlush 源码。
queueFlush
,它的作用就是将flushJobs
方法延迟到本轮事件循环的微任务队列中执行。
参考:flushJobs 源码。
其实这里的flushJobs
可以类比为Vue2.x
中的flushSchedulerQueue, flushCallbacks
的混合。
这里我们发现,一直没有用到nextTick
,我们来看一下实现:
参考:nextTick 源码。
接下来我们结合一下实际案例来进行分析:
示例
初步猜测
同步任务没有获取到最新的视图,说明视图依旧是异步的,但是由于微任务和宏任务都获取到了最新的视图,所以我们也无法推测视图更新是在本轮事件循环的微任务队列中还是下一轮事件循环的宏任务队列中。
然后$nextTick(cb)
和Promise.resolve().then(cb)
中的cb
都获取到了最新的视图。
根据输出结果:$nextTick(cb)
中的 cb
是在 微任务-01, 微任务-02
之后输出,在宏任务-01, 宏任务-02
之前输出,因此这里我们也不确定$nextTick(cb)
到底是将cb
延迟到下一轮事件循环开始的宏任务队列中执行,还是说延迟到本轮事件循环中微任务队列中执行。
这里的$nextTick(cb)
为什么会在微任务-01, 微任务-02
之后呢?
$nextTick(cb) 和视图更新时机分析
在Vue 2.x
中:
flushSchedulerQueue
通过nextTick(cb)
延迟到任务队列中,这里还有一些兼容方案,如果不支持微任务的相关API
,最终会降级为宏任务。
在Vue 3.x
中:
flushJobs
是直接用的Promise.resolve().then(cb)
延迟到微任务队列中。
nextTick(cb)
常规情况下利用的也是Promise.resolve().then(cb)
来实现将cb
扔到微任务队列中,目前的微任务队列为:[ cb ]
。
但是flushJobs
已经在当前微任务队列中了,那么这里的nextTick(cb)
的实现会变为currentFlushPromise.then(cb)
。
这里你是否会疑问,为什么我们的cb
是在上一个Promise
示例的then
后面,却比后来的Promise.resolve().then(xx)
执行更要晚(cb
晚于xx
执行)。
举一个例子:
再结合我们示例代码,简化出伪代码方便我们理解:
也就是说目前的微任务队列为: [ flushJobs, cb02, cb01 ]
,然后从左往右依次执行。
这里就解释了前文中提到的,nextTick(cb)
中的cb
为什么会在微任务-01, 微任务-02
的后面执行的问题。
当前flushJobs
已经被推入微任务队列的的情况下,nextTick(cb)
中的cb
的执行时机会晚于本轮事件循环的其他微任务(同一层级,非链式)。
响应式数据变化到视图更新流程
因此当一个数据发生变化之后,大概流程是:
this.a ++
;
- 触发
set
;
trigger --> triggerEffects(deps)
,触发所有副作用(依赖);
effect.scheduler()
;
() => queueJob(instance.update) --> queue.push(job)
,也就是说这里的instance.update
被当作job
加入队列中;
queueFlush()
,将flushJobs
方法延迟到本轮事件循环的微任务队列中执行;
- 执行
flushJobs
,遍历执行job()
;
instance.update --> effect.run.bind(effect) --> componentUpdateFn
;
rerender --> patch(oldVNode, newVNode)
;
- 更新
DOM
;