声明:本文章中所有源码取自 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 的核心流程大体可以分成以下几步
),而 set 方法则会调用 dev 的 notify 方法,此方法的作用是通知 subs 中的所有的 watcher 并调用 watcher 的 update 方法,我们可以将此理解为设计模式中的发布与订阅
- dev.subs.push(watcher)
- nextTick(flushSchedulerQueue)
),然后异步的将 callbacks 遍历并执行(此为异步更新队列)
- callbacks.push(flushSchedulerQueue)
以上所有流程都在 vue/src/core 文件夹中。
接下来我们按照上面例子中的最后一种情况来分析 Vue 代码的执行过程,其中一些细节我会有所省略,请记住开始的话,我们这里最关心的只是第四步
当点击按钮后,绑定在按钮上的回调函数被触发,this.show = false 被执行,触发了属性中的 set 函数,set 函数中,dev 的 notify 方法被调用,导致其 subs 中每个 watcher 的 update 方法都被执行(在本例中 subs 数组里只有一个 watcher~),一起来看下 watcher 的构造函数
- class Watcher {
- constructor(vm) {
- // 将vue实例绑定在watcher的vm属性上
- this.vm = vm
- }
- update() {
- // 默认情况下都会进入else的分支,同步则直接调用watcher的run方法
- if (this.lazy) {
- this.dirty = true
- } else if (this.sync) {
- this.run()
- } else {
- queueWatcher(this)
- }
- }
- }
再来看下 queueWatcher
- /**
- * 将watcher实例推入queue(一个数组)中,
- * 被has对象标记的watcher不会重复被加入到队列
- */
- export
- function queueWatcher(watcher: Watcher) {
- const id = watcher.id
- // 判断watcher是否被标记过,has为一个对象,此方案类似数组去重时利用object保存数组值
- if (has[id] == null) {
- // 没被标记过的watcher进入分支后被标记上
- has[id] = true
- if (!flushing) {
- // 推入到队列中
- queue.push(watcher)
- } else {
- // 如果是在flush队列时被加入,则根据其watcher的id将其插入正确的位置
- // 如果不幸该watcher已经错过了被调用的时机则会被立即调用
- // 稍后看flushSchedulerQueue这个函数会理解这两段注释的意思
- 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
- // 我们关心的重点nextTick函数,其实我们写的this.$nextTick也是调用的此函数
- nextTick(flushSchedulerQueue)
- }
- }
- }
这个函数运行后,我们的 watcher 进入到了 queue 队列中(本例中 queue 内部也只被添加这一个 watcher),然后调用
,这里我们先来看下 flushSchedulerQueue 函数的源码
- nextTick(flushSchedulerQueue)
- /**
- * flush整个队列,调用watcher
- */
- function flushSchedulerQueue () {
- // 将flush置为true,请联系上文
- flushing = true
- let watcher, id
- // flush队列前先排序
- // 目的是
- // 1.Vue中的组件的创建与更新有点类似于事件捕获,都是从最外层向内层延伸,所以要先
- // 调用父组件的创建与更新
- // 2. userWatcher比renderWatcher创建要早(抱歉并不能给出我的解释,我没理解)
- // 3. 如果父组件的watcher调用run时将父组件干掉了,那其子组件的watcher也就没必要调用了
- queue.sort((a, b) => a.id - b.id)
- // 此处不缓存queue的length,因为在循环过程中queue依然可能被添加watcher导致length长度的改变
- for (index = 0; index < queue.length; index++) {
- // 取出每个watcher
- watcher = queue[index]
- id = watcher.id
- // 清掉标记
- has[id] = null
- // 更新dom走起
- watcher.run()
- // dev环境下,检测是否为死循环
- 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 的时候,其实第一步应该是调用此函数。
- /**
- * 包装参数fn,让其使用marcotask
- * 这里的fn为我们在事件上绑定的回调函数
- */
- 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为一个数组,此处将cb推进数组,本例中此cb为刚才还未执行的flushSchedulerQueue
- callbacks.push(() = >{
- if (cb) {
- try {
- cb.call(ctx)
- } catch(e) {
- handleError(e, ctx, 'nextTick')
- }
- } else if (_resolve) {
- _resolve(ctx)
- }
- })
- // 标记位,保证之后如果有this.$nextTick之类的操作不会再次执行以下代码
- if (!pending) {
- pending = true
- // 用微任务还是用宏任务,此例中运行到现在为止Vue的选择是用宏任务
- // 其实我们可以理解成所有用v-on绑定事件所直接产生的数据变化都是采用宏任务的方式
- // 因为我们绑定的回调都经过了withMacroTask的包装,withMacroTask中会使useMacroTask为true
- if (useMacroTask) {
- macroTimerFunc()
- } else {
- microTimerFunc()
- }
- }
- // $flow-disable-line
- if (!cb && typeof Promise !== 'undefined') {
- return new Promise(resolve = >{
- _resolve = resolve
- })
- }
- }
执行完以上代码最后只剩下两个结果,调用 macroTimerFunc 或者 microTimerFunc,本例中到目前为止,会调用 macroTimerFunc。这两个函数的目的其实都是要以异步的形式去遍历 callbacks 中的函数,只不过就像我们上文所说的,他们采取的方式并不一样,一个是宏任务达到异步,一个是微任务达到异步。另外我要适时的提醒你引起以上所有流程的原因只是运行了一行代码 this.show = false 而
还没开始执行,不过别绝望,也快轮到它了。好的,回到正题来看看 macroTimerFunc 与 microTimerFunc 吧。
- this.$nextTick(() => { this.show = true })
- /**
- * macroTimerFunc
- */
- // 如果当前环境支持setImmediate,就用此来产生宏任务达到异步效果
- if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
- macroTimerFunc = () => {
- setImmediate(flushCallbacks)
- }
- } else if (typeof MessageChannel !== 'undefined' && (
- // 否则MessageChannel
- isNative(MessageChannel) ||
- // PhantomJS
- MessageChannel.toString() === '[object MessageChannelConstructor]'
- )) {
- const channel = new MessageChannel()
- const port = channel.port2
- channel.port1.onmessage = flushCallbacks
- macroTimerFunc = () => {
- port.postMessage(1)
- }
- } else {
- // 再不行的话就只能setTimeout了
- /* istanbul ignore next */
- macroTimerFunc = () => {
- setTimeout(flushCallbacks, 0)
- }
- }
- /**
- * microTimerFunc
- */
- // 如果支持Promise则用Promise来产生微任务
- if (typeof Promise !== 'undefined' && isNative(Promise)) {
- const p = Promise.resolve() microTimerFunc = () = >{
- p.then(flushCallbacks)
- // 对IOS做兼容性处理,(IOS中存在一些问题,具体可以看尤大大自己的解释)
- if (isIOS) setTimeout(noop)
- }
- } else {
- // 降级
- microTimerFunc = macroTimerFunc
- }
截止到目前为止应该有一个比较清晰的认识了,其实 nextTick 最终希望达到的效果就是采用异步的方式去调用 flushCallbacks,至于是用宏任务还是微任务,Vue 内部已经帮我们处理掉了,并不用我们去决定。至于 flushCallbacks 光看名字就知道是循环刚才的 callbacks 并执行。
- function flushCallbacks() {
- pending = false
- // 将callbacks做一次复制
- const copies = callbacks.slice(0)
- // 置空callbacks
- callbacks.length = 0
- // 遍历并执行
- for (let i = 0; i < copies.length; i++) {
- copies[i]()
- }
- }
请注意,虽然我们在这里解释了 flushCallbacks 是干嘛的,但是要记住它是被异步处理的,而当前同步任务还并没有执行完,所以这个函数此时并没有被调用,真正要做的是走完整个同步任务,也就是我们的
终于要被调用了,感谢老天爷。 当 this.$nextTick 被调用后
- this.$nextTick(() => { this.show = true })
同样被当做参数推入了 callbacks 中,此时可以理解为 callbacks 长这样 [flushSchedulerQueue, () => {this.show = true}],然后在 withMacroTask 中 fn.apply 调用完毕 useMacroTask 被变回 false,整个同步任务结束。
- () => { this.show = true }
此时还记得我们在 eventLoop 中所讲的吗,我们会从任务队列中寻找所有的微任务,而到目前为止任务队列中并没有微任务,于是一轮事件循环完成了,浏览器重新渲染,不过此时我们的 dom 结构没有发生丝毫变化,所以就算浏览器没重新渲染也并不会有丝毫影响。接下来就是执行任务队列中的宏任务了,它对应的回调就是我们刚才注册的 flushCallbacks。首先执行 flushSchedulerQueue,其中的 watcher 被调用了 run 方法,由于此时我们的 data 中的 show 被改变成了 false,所以新老虚拟 dom 对比后真实 dom 中移除掉了绑定 v-if="show" 的组件。
重点来了,虽然 dom 中移除掉了该组件,但是其实在浏览器上这个组件是依然显示的,因为我们的事件循环还没有完成,其中还有剩余的同步任务需要被执行,浏览器并没开始重新绘制。(如果您对此段有疑问,我个人觉得您可能是没搞懂 dom 与浏览器上显示的区别,您可以将 dom 理解成控制台中 elements 模块内所有的节点,浏览器的中显示的内容不是与其时刻保持一致的)
剩下需要被执行的就是
,而当执行 this.show = true 时我们前文所有的流程又通通执行了一遍,其中只有一些细节是与刚才不同的,我们来看一下。
- () => { this.show = true }
所以当本次 macrotask 结束时,本次的事件循环还没有结束,我们还留下了微任务需要处理,依然是调用 flushSchedulerQueue,然后 watcher.run,因为此次 show 已经为 true 了,所以对比新老虚拟 dom,重新生成该组件,生命周期完成重置。此时,本轮事件循环结束,浏览器重新渲染。希望您还记得,我们的浏览器本身现在的状态就是该组件显示在可视区内,重新渲染后该组件依然显示,所以自然不会出现组件闪动的情况。
现在我相信您自己也能想清楚为什么我们的例子中使用 setTimeout 会有闪动,但是我还是说一下原因来看一下您与我的想法是否一致。因为 setTimeout 产生的是宏任务,当一轮事件循环完成后,宏任务并不会直接处理,中间插入了浏览器的绘制。浏览器重新绘制后会将显示的组件移除掉,所以区域内出现一片空白,紧接着下一次事件循环开始,宏任务被执行组件 dom 又被重新创建,事件循环结束,浏览器重绘,又在可视区域上将该组件显示。所以在您的视觉效果上,该组件会有闪动,整个过程结束。
终于我们想说的都说完了,如果您能坚持看到这里,十分感谢您。不过还有几点是我们依然要考虑的。
其实文档已经告诉我们了
这种在缓冲时去除重复数据对于避免不必要的计算和 DOM 操作上非常重要。
我们假设 flushSchedulerQueue 并没有通过 nextTick 而是直接被调用,那么第一种写法
都会触发 watcher.run 方法,导致的结果就是这种写法也可以重置组件的生命周期,您可以在 Vue 源码中注释掉
- this.show = false; this.show = true
改用
- nextTick(flushSchedulerQueue)
打断点来更加明确的体验一下流程。要知道这仅仅是一个简单的例子,实际工作中我们可能因为这种问题使 dom 白白被改变了巨多次,我们都知道 dom 的操作是昂贵的,所以 Vue 帮我们再框架内优化了该步骤。您不妨再想一下直接
- flushSchedulerQueue()
这种情况下,组件会不会闪动,来巩固我们刚才讲过的东西。
- flushSchedulerQueue()
来代替 this.$nextTick?很明显我既然这么问了那就是不行的,只是过程您需要自己思考。
- this.show = false; Promise.then().resolve(() => { this.show = true })
来源: https://juejin.im/post/5a45fdeb6fb9a044ff31c9a8