正文
声明:本文章中所有源码取自Version: 2.5.13的dev分支上的Vue,不保证文章内观点的绝对准确性。文章整理自本周我在小组的内部分享。
文章原地址
我们目前的技术栈主要采用Vue,而工作中我们碰到了一种情况是当传入某些组件内的props被改变时我们需要重置整个组件的生命周期(比如更改IView中datepicker的type,好消息是目前该组件已经可以不用再使用这么愚蠢的方法来切换时间显示器的类型)。为了达成这个目的,于是我们有了如下代码
<template>
<button @click="handleClick">btn</button>
<someComponent v-if="show" />
</template>
<script>
{
data() {
return { show: true }
},
methods: {
handleClick() {
this.show = false
this.show = true
}
}
}
</script>
别笑,我们当然知道这段代码有多愚蠢,不用尝试也确定这是错的,但是凭借react的经验我大概知道将
this.show = true
换成
setTimeout(() => { this.show = true }, 0)
,就应该可以得到想要的结果,果然,组件重置了其生命周期,但是事情还是有点不对头。我们经过几次点击发现组件总是会闪一下。逻辑上这很好理解,组件先销毁后重建有这种情况是很正常的,但是抱歉,我们找到了另一种方式(毕竟谷歌是万能的),将
setTimeout(() => { this.show = true }, 0)
换成
this.$nextTick(() => { this.show = true })
,神奇的事情来了,组件依然重置了其生命周期,但是组件本没没有丝毫的闪动。
为了让亲爱的您感受到我这段虚无缥缈的描述,我为您贴心准备了此
demo
,您可以将handle1依次换为handle2与handle3来体验组件在闪动与不闪动之间徘徊的快感。
如果您体验完快感后仍然选择继续阅读那么我要跟你说的是接下来的内容是会比较长的,因为要想完全弄明白这件事我们必须深入Vue的内部与Javascript的EventLoop两个方面。
导致此问题的主要原因在于Vue默认采用的是的异步更新队列的方式,我们可以从官网上找到以下描述
可能你还没有注意到,Vue 异步执行 DOM 更新。只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个 watcher 被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要。然后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工作。Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MessageChannel,如果执行环境不支持,会采用 setTimeout(fn, 0) 代替。
这段话确实精简的描述了整个流程,但是并不能解决我们的疑惑,接下来是时候展示真正的技术了。需要说明的是以下核心流程如果您没阅读过一些介绍源码的blog或者是阅读过源码,那么您可能一脸懵b。但是没关系在这里我们最终关心的基本上只是第4步,您只需要大概将其记住,然后将这个流程对应我们后面解析的源码就可以了。
Vue的核心流程大体可以分成以下几步
-
遍历属性为其增加get,set方法,在get方法中会收集依赖(
dev.subs.push(watcher)
),而set方法则会调用dev的notify方法,此方法的作用是通知subs中的所有的watcher并调用watcher的update方法,我们可以将此理解为设计模式中的发布与订阅
-
默认情况下update方法被调用后会触发
queueWatcher
函数,此函数的主要功能就是将watcher实例本身加入一个队列中(
queue.push(watcher)
),然后调用
nextTick(flushSchedulerQueue)
-
flushSchedulerQueue
是一个函数,目的是调用queue中所有watcher的
watcher.run
方法,而
run
方法被调用后接下来的操作就是通过新的虚拟dom与老的虚拟dom做diff算法后生成新的真实dom
-
只是此时我们
flushSchedulerQueue
并没有执行,第二步的最终做的只是将
flushSchedulerQueue
又放进一个callbacks队列中(
callbacks.push(flushSchedulerQueue)
),然后
异步
的将callbacks遍历并执行(此为异步更新队列)
-
如上所说
flushSchedulerQueue
在被执行后调用
watcher.run()
,于是你看到了一个新的页面
以上所有流程都在
vue/src/core
文件夹中。
接下来我们按照上面例子中的最后一种情况来分析Vue代码的执行过程,其中一些细节我会有所省略,请记住开始的话,我们这里最关心的只是第四步
当点击按钮后,绑定在按钮上的回调函数被触发,
this.show = false
被执行,触发了属性中的set函数,set函数中,dev的notify方法被调用,导致其subs中每个watcher的update方法都被执行(在本例中subs数组里只有一个watcher~),一起来看下watcher的构造函数
class Watcher {
constructor (vm) {
this.vm = vm
}
update () {
if (this.lazy) {
this.dirty = true
} else if (this.sync) {
this.run()
} else {
queueWatcher(this)
}
}
}
再来看下
queueWatcher
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
let i = queue.length - 1
while (i > index && queue[i].id > watcher.id) {
i--
}
queue.splice(i + 1, 0, watcher)
}
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
这个函数运行后,我们的watcher进入到了queue队列中(本例中queue内部也只被添加这一个watcher),然后调用
nextTick(flushSchedulerQueue)
,这里我们先来看下
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]
id = watcher.id
has[id] = null
watcher.run()
if (process.env.NODE_ENV !== 'production' && has[id] != null) {
circular[id] = (circular[id] || 0) + 1
if (circular[id] > MAX_UPDATE_COUNT) {
warn(
'You may have an infinite update loop ' + (
watcher.user
? `in watcher with expression "${watcher.expression}"`
: `in a component render function.`
),
watcher.vm
)
break
}
}
}
仍然要记得,此时我们的flushSchedulerQueue 还没执行,它只是被当作回调传入了nextTick中,接下来我们就来说说我们本次的重点nextTick,建议您整体的看一下
nextTick
的源码,虽然我也都会解释到
我们首先从next-tick.js中提取出来
withMacroTask
这个函数来说明,很抱歉我把这个函数放到了最后,因为我想让亲爱的您知道,最重要的东西总是要压轴登场的。但是从整体流程来说当我们点击btn的时候,其实第一步应该是调用此函数。
export function withMacroTask (fn: Function): Function {
return fn._withTask || (fn._withTask = function () {
useMacroTask = true
const res = fn.apply(null, arguments)
useMacroTask = false
return res
})
}
没错,其实您绑定在onclick上的回调函数是在这个函数内以apply的形式触发的,请您先去在此处打一个断点来验证。好的,我现在相信您已经证明了我所言非虚,但是其实那不重要,因为重要的是我们在此处立了一个flag,
useMacroTask = true
,这才是很关键的东西,谷歌翻译一下我们可以知道它的具体含义,
用宏任务
OK,这就要从我们文章开头所说的第二部分EventLoop讲起了。
其实这部分内容相信对已经看到这里的您来说早就接触过了,如果还真的不太清除的话推荐您仔细的看一下阮一封老师的
这篇文章
,我们只会大概的做一个总结
-
我们的同步任务的调用形成了一个栈结构
-
除此之外我们还有一个任务队列,当一个异步任务有了结果后会向队列中添加一个任务,每个任务都对应着一个回调函数
-
当我们的栈结构为空时,就会读取任务队列,同时调用其对应的回调函数
-
重复
这个总结目前来说对于我们比较欠缺的信息就是队列中的任务其实是分为两种的,宏任务(macrotask)与微任务(microtask)。
当主线程上执行的所有同步任务结束后会从任务队列中抽取出所有微任务执行,当微任务也执行完毕后一轮事件循环就结束了,然后浏览器会重新渲染(
请谨记这点,因为正是此原因才会导致文章开头所说的问题
)。之后再从队列中取出宏任务继续下一轮的事件循环,值得注意的一点是执行微任务时仍然可以继续产生微任务在本轮事件循环中不停的执行。所以本质上微任务的优先级是高于宏任务的。
如果您想更详细的了解宏任务与微任务那么推荐您阅读
这篇文章
,这或许是东半球关于这个问题解释的最好,最易懂,最详细的文章了。
宏任务与微任务产生的方式并不相同,浏览器环境下setImmediate,MessageChannel,setTimeout会产生宏任务,而MutationObserver ,Promise则会产生微任务。而这也是Vue中采取的异步方式,Vue会根据
useMacroTask
的布尔值来判断是要产生宏任务还是产生微任务来异步更新队列,我们会稍后看到这部分,现在我们还是走回我们原来的逻辑吧。
当fn在withMacroTask函数中被调用后就产生了我们以上所讲的所有步骤,现在是时候来真正看下nextTick函数都干了什么
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
if (useMacroTask) {
macroTimerFunc()
} else {
microTimerFunc()
}
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
执行完以上代码最后只剩下两个结果,调用
macroTimerFunc
或者
microTimerFunc
,本例中到目前为止,会调用
macroTimerFunc
。这两个函数的目的其实都是要以异步的形式去遍历callbacks中的函数,只不过就像我们上文所说的,他们采取的方式并不一样,一个是宏任务达到异步,一个是微任务达到异步。另外我要适时的提醒你引起以上所有流程的原因只是运行了一行代码
this.show = false
而
this.$nextTick(() => { this.show = true })
还没开始执行,不过别绝望,也快轮到它了。好的,回到正题来看看
macroTimerFunc
与
microTimerFunc
吧。
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
macroTimerFunc = () => {
setImmediate(flushCallbacks)
}
} else if (typeof MessageChannel !== 'undefined' && (
isNative(MessageChannel) ||
MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = flushCallbacks
macroTimerFunc = () => {
port.postMessage(1)
}
} else {
macroTimerFunc = () => {
setTimeout(flushCallbacks, 0)
}
}
if (typeof