tip:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。
【VUE】源码分析 - computed计算属性的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - watch侦听器的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客
前言
在之前,我们分析了vue中实现响应式原理的实现,包括watch,computed,和普通情况下的数据劫持。通过对响应式数据变化的监听,通知到相应的对象,从而对这一次的数据改变,做出一些行为和操作。那么对于这些行为的执行机制,具体又是怎么样的呢?它与vue暴露出来的$nextTick又存在着什么关联呢?本篇博客将带你逐步了解vue的更新时机。文章假设你对浏览器的任务队列机制有充分的认识,对微任务和宏任务有足够的了解。
更新队列
首先我们需要知道的是,vue的更新绝对不是采用同步更新。我们无法预知用户会在一次更新周期中做多少操作。或许很少,或许特别多。如果采用同步更新,那么对于性能的消耗,绝对是不可估量的。那么,最合适的做法就是采用异步更新的机制。也就是说,会将更新周期中的每一次操作都给保存起来,统一放到所有同步操作都执行完成之后,再来执行更新的操作。
而对于一个组件实例来说,更新操作,无非就是刷新页面,或触发某些回调。而需要这些操作的对象,其实都已经被vue包装成了统一的类:Watcher,这也是我们之前的文章中绕不开的角色,经过三篇博客的介绍,大家应该对Watcher有了比较形象的认识。
要统一管理这些watcher实例,实现统一刷新,vue采用的是queue:
update () {
/* istanbul ignore else */
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
上述就是一个实例的更新方法。可以看到,除非是设置了sync选项,会直接调用run方法实现同步更新(异步更新依旧是在异步回调中调用run方法);否则就会将当前watcher实例利用queueWatcher方法,添加至一个全局唯一的queue中。对于lazy在之前的Computed分析中已经介绍过,在此不做赘述。
function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
// if already flushing, splice the watcher based on its id
// if already past its id, it will be run next immediately.
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
// queue the flush
if (!waiting) {
waiting = true
if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}
上面就是queueWatcher的全部代码了。首先明确下面几点:
1,has的作用就是“去重”。如果某个watcher实例已经添加过,那么不做重复添加;
2,flushing,表示当前是否正在进行刷新;
3,waiting,表示是否需要等待触发回调。当其为false的时候会直接执行;
4,nextTick,也就是我们用到的$nextTick。
那么对于上述代码,就很简单了。就只是一个添加queue,然后触发nextTick函数的过程。或许有同学会疑问,flushing和waiting看起来作用很类似,都是与触发状态有关,那么为什么在这里会做区分呢?
首先我们要知道,对于异步函数来说,我们只需要将其加入队列,然后等待执行即可。但是在执行之前,是无法知道其执行时机的,也无法知道会在这个等待执行的期间内做多少操作。而waiting的作用,就是保证每一个更新周期,都只会将watcher的更新添加一次到异步队列中。
而flushing的作用呢,就是在上述的等待执行的过程中,或许会存在很多很多个watcher需要添加至queue中等待统一执行。如果此时是未执行的状态,那么只需要将所添加的watcher实例push进队列即可。而对于已经开始执行异步任务的情况,此时flushing就会置为true,那么就不再是简单添加了,而是通过id,有顺序的添加至合适的位置。具体为什么这么做,在下面会进行介绍。
flushSchedulerQueue(执行刷新)
function flushSchedulerQueue () {
flushing = true
let watcher, id
queue.sort((a, b) => a.id - b.id)
for (index = 0; index < queue.length; index++) {
watcher = queue[index]
if (watcher.before) {
watcher.before()
}
id = watcher.id
has[id] = null
watcher.run()
}
resetSchedulerState()
}
flushSchedulerQueue,就是在queueWatcher中调用nextTick所传递的回调函数。也就是在异步任务中要执行的函数。
其内容也很简单,首先对queue进行排序,然后历遍执行每一个watcher实例的run方法,最后重置flushing,waiting等状态。
在此也就解开了之前的疑惑:为什么没有执行异步回调的时候直接push进queue就行了,在执行之后需要用id去插入到相应的位置。
首先对于一次更新来说,每一次执行都应该是严格按照watcher实例对应的id顺序来执行的。而id呢,对应的就是一个从0开始每一次实例化都会递增的数字。对于computed和watch等属性来说,其初始化一定是在实例的初始化之前的,也就是说id一定会小于当前组件实例的id。
这样做有什么好处呢?比如说在watch属性中如果更改了某一个和组件相关的属性,或者在组件中用到了某个computed属性。那么此时,如果组件先于这二者执行,在其完成执行之后,这二者的执行又会导致依赖触发,从而会在此将执行过一次刷新的组件重新添加至回调队列中,从而导致不必要的重复执行。而若按照顺序执行的话,就不会出现这种情况了。即使会再次触发组件的依赖,但是组件此刻还未执行,用同一次更新就可以完成所有不同时机触发的依赖更新了。至于为什么在这里触发依赖不会添加两个组件watcher至回调队列,大家可以往上翻一翻,在queueWatcher中对相同的id做了过滤处理。
明确了上面这一点之后,我们再来解答这个问题。为了降低时间复杂度,因此对于还未执行异步更新的queue来说,只需要等到执行的那一刻统一排序即可,而不需要每一次都做插入排序。但是对于已经开始执行的queue呢,再插入的实例就无法一并排序了,因此需要自己做插入排序。
对于每一个单独的wathcer呢,只需要调用其run方法,触发更新即可。
以上也就完成了对一次更新周期中watcher刷新的介绍。接下来,我们会对nextTick的具体实现做分析。
nextTick
首先我们来看源码部分:
Vue.prototype.$nextTick = function (fn: Function) {
return nextTick(fn, this);
};
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();
}
// $flow-disable-line
if (!cb && typeof Promise !== "undefined") {
return new Promise((resolve) => {
_resolve = resolve;
});
}
}
可以看到,其实vue暴露出来的$nextTick方法也就是调用的nextTick函数。而这个函数我们在之前已经见过了,即在queueWatcher中进行对flushSchedulerQueue的回调中有使用到。
this.$nextTick(()=>{
this.doSomething()
})
//当然,你也可以直接这么写,是一样的:
this.$nextTick(this.doSomething)
而第二种写法,就是vue中flushSchedulerQueue函数回调的写法。
也就是说,其实vue的刷新机制,就是用暴露出来的$nextTick的同款函数实现的。
再回到nextTick函数中。其内部实现的核心逻辑很简单:
1,如果传入回调函数,则将回调函数添加至callbacks当中:
//调用$nextTick会将callBack添加至callBacks队列中,等待统一执行
this.$nextTick(callBack)
2,如果没有传入回调,则会将本次回调包装一个Promise函数,等到callbacks调用之时,如果_resolve有值(也就是在同步执行中判断cb为空,将Promise的resolve赋值给_resolve变量)那么就会调用resolve回调,也就是将返回的Promise从pedding状态变成了fullfiled状态。
文字表达可能会比较抽象,可以通过代码示例来理解:
async handler(){
await this.$nextTick()
//do something
}
也就是说,你完全可以不通过回调的方式去调用$nextTick,而将其变成一个 异步函数,在await之后在做操作。
而nextTick中的pending属性呢,作用和之前的waiting属性等类似,是为了防止重复的将同一个回调队列添加多次任务。可以结合flushCallbacks来分析(这个函数就是最终调用callBacks的函数,也就是最终添加微任务或宏任务的函数)
function flushCallbacks () {
pending = false
const copies = callbacks.slice(0)
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
函数内部的逻辑,就是拷贝一份callBacks,然后清空callBacks,用拷贝的这一份队列来依次执行回调。
这么做有什么好处呢?
首先,我们需要先明确的一点:一个callBacks队列,实际上就对应一个"统一刷新实际"的任务队列。也就是在同一个微任务或者宏任务调用的队列。
但是,我们其实并不知道使用者会在什么地方什么时机调用$nextTick函数。如果当调用时机发生在了本轮更新周期之间,那么本次回调可以搭上"顺风车",和其他的回调(页面的刷新回调,watch的回调等)在同一个callBacks中执行;但是如果$nextTick发生在更新周期之后,也就是本次的回调队列已经添加任务并执行了,那么此时是不能直接添加到原本的callBacks中的,因为你并不知道本次任务是正在执行,还是已经执行完成。
如果直接添加至callBacks当中,那么对于任务正在执行的情况,的确能将后续添加的回调触发执行。但是如果已经执行完毕了,那么就是无效添加,并且会影响到下一次更新周期(因为后序添加的回调不会被清空,会一直存在于callBacks中),导致时机混乱。
那么vue是如何处理这种情况的呢?其实很简单,如果无法复用本次的任务执行,那么再添加另一个callBacks到任务队列不就可以了么。
因此,在每一个任务开始执行的时候,就将callBacks清空,将pending置为false。如果在这之后再有回调任务添加,那么一份全新的callBacks和置为false的pending,会将本次任务置于任务队列的末尾。当前一个callBacks执行完毕之后,就会开始正确执行。
介绍完nextTick的机制,我们再来看看其具体的代码实现:
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)
}
}
在之前我们一直忽略了一个函数:timeFunc。而这个函数,就是添加任务队列的最终实现。
从代码中可以看到,vue根据不同情况,采取了不同的措施。其优先级是:
Promise > MutationObserver > setImmediate > setTimeout
其中,前两个为微任务,后两个为宏任务。vue会根据执行的环境,来决定用哪种方案执行刷新。而微任务的执行时机先于宏任务,因此vue会优先采取微任务,也就是试图在同步任务执行完毕的最快时机去调用刷新回调。
关于mutationObserver,也同样是一个微任务,它能监视DOM元素的变化,包括属性的改变,子节点的改变等等,会在监听到变化的同时将实例化时传入的回调函数添加为微任务执行。关于具体的细节,大家可以去文档中了解:
MutationObserver - Web API 接口参考 | MDNMutationObserver接口提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver在vue中,$nextTick的底层实现依旧是微任务,或者宏任务,也并不是什么高精尖的黑科技。只是将JS的消息队列机制使用的恰到好处。你完全也可以利用此机制,自己实现一个自动刷新的工具。
现在,你对vue的刷新机制应该有了一个比较全面的认识了吧!
文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。