Vue异步更新队列原理从入门到放弃

声明:本文章中全部源码取自Version: 2.5.13的dev分支上的Vue,不保证文章内观点的绝对准确性。文章整理自本周我在小组的内部分享。javascript

文章原地址html

咱们目前的技术栈主要采用Vue,而工做中咱们碰到了一种状况是当传入某些组件内的props被改变时咱们须要重置整个组件的生命周期(好比更改IView中datepicker的type,好消息是目前该组件已经能够不用再使用这么愚蠢的方法来切换时间显示器的类型)。为了达成这个目的,因而咱们有了以下代码vue

<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 }),神奇的事情来了,组件依然重置了其生命周期,可是组件本没没有丝毫的闪动。html5

为了让亲爱的您感觉到我这段虚无缥缈的描述,我为您贴心准备了此demo,您能够将handle1依次换为handle2与handle3来体验组件在闪动与不闪动之间徘徊的快感。java

若是您体验完快感后仍然选择继续阅读那么我要跟你说的是接下来的内容是会比较长的,由于要想彻底弄明白这件事咱们必须深刻Vue的内部与Javascript的EventLoop两个方面。react

致使此问题的主要缘由在于Vue默认采用的是的异步更新队列的方式,咱们能够从官网上找到如下描述git

可能你尚未注意到,Vue 异步执行 DOM 更新。只要观察到数据变化,Vue 将开启一个队列,并缓冲在同一事件循环中发生的全部数据改变。若是同一个 watcher 被屡次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免没必要要的计算和 DOM 操做上很是重要。而后,在下一个的事件循环“tick”中,Vue 刷新队列并执行实际 (已去重的) 工做。Vue 在内部尝试对异步队列使用原生的 Promise.then 和 MessageChannel,若是执行环境不支持,会采用 setTimeout(fn, 0) 代替。github

这段话确实精简的描述了整个流程,可是并不能解决咱们的疑惑,接下来是时候展现真正的技术了。须要说明的是如下核心流程若是您没阅读过一些介绍源码的blog或者是阅读过源码,那么您可能一脸懵b。可是不要紧在这里咱们最终关心的基本上只是第4步,您只须要大概将其记住,而后将这个流程对应咱们后面解析的源码就能够了。算法

Vue的核心流程大致能够分红如下几步express

  1. 遍历属性为其增长get,set方法,在get方法中会收集依赖(dev.subs.push(watcher)),而set方法则会调用dev的notify方法,此方法的做用是通知subs中的全部的watcher并调用watcher的update方法,咱们能够将此理解为设计模式中的发布与订阅

  2. 默认状况下update方法被调用后会触发queueWatcher函数,此函数的主要功能就是将watcher实例自己加入一个队列中(queue.push(watcher)),而后调用nextTick(flushSchedulerQueue)

  3. flushSchedulerQueue是一个函数,目的是调用queue中全部watcher的watcher.run方法,而run方法被调用后接下来的操做就是经过新的虚拟dom与老的虚拟dom作diff算法后生成新的真实dom

  4. 只是此时咱们flushSchedulerQueue并无执行,第二步的最终作的只是将flushSchedulerQueue又放进一个callbacks队列中(callbacks.push(flushSchedulerQueue)),而后异步的将callbacks遍历并执行(此为异步更新队列)

  5. 如上所说flushSchedulerQueue在被执行后调用watcher.run(),因而你看到了一个新的页面

以上全部流程都在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),而后调用nextTick(flushSchedulerQueue),这里咱们先来看下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讲起了。

其实这部份内容相信对已经看到这里的您来讲早就接触过了,若是还真的不太清除的话推荐您仔细的看一下阮一封老师的这篇文章,咱们只会大概的作一个总结

  1. 咱们的同步任务的调用造成了一个栈结构
  2. 除此以外咱们还有一个任务队列,当一个异步任务有告终果后会向队列中添加一个任务,每一个任务都对应着一个回调函数
  3. 当咱们的栈结构为空时,就会读取任务队列,同时调用其对应的回调函数
  4. 重复

这个总结目前来讲对于咱们比较欠缺的信息就是队列中的任务实际上是分为两种的,宏任务(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 = falsethis.$nextTick(() => { this.show = true })还没开始执行,不过别绝望,也快轮到它了。好的,回到正题来看看macroTimerFuncmicroTimerFunc吧。

/** * 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.show = true })终于要被调用了,感谢老天爷。 当this.$nextTick被调用后() => { this.show = true }一样被当作参数推入了callbacks中,此时能够理解为callbacks长这样[flushSchedulerQueue, () => { this.show = true }],而后在withMacroTask中fn.apply调用完毕useMacroTask被变回false,整个同步任务结束。

此时还记得咱们在eventLoop中所讲的吗,咱们会从任务队列中寻找全部的微任务,而到目前为止任务队列中并无微任务,因而一轮事件循环完成了,浏览器从新渲染,不过此时咱们的dom结构没有发生丝毫变化,因此就算浏览器没从新渲染也并不会有丝毫影响。接下来就是执行任务队列中的宏任务了,它对应的回调就是咱们刚才注册的flushCallbacks。首先执行flushSchedulerQueue,其中的watcher被调用了run方法,因为此时咱们的data中的show被改变成了false,因此新老虚拟dom对比后真实dom中移除掉了绑定v-if="show"的组件。

重点来了,虽然dom中移除掉了该组件,可是其实在浏览器上这个组件是依然显示的,由于咱们的事件循环尚未完成,其中还有剩余的同步任务须要被执行,浏览器并没开始从新绘制。(若是您对此段有疑问,我我的以为您多是没搞懂dom与浏览器上显示的区别,您能够将dom理解成控制台中elements模块内全部的节点,浏览器的中显示的内容不是与其时刻保持一致的)

剩下须要被执行的就是() => { this.show = true },而当执行this.show = true时咱们前文全部的流程又统统执行了一遍,其中只有一些细节是与刚才不一样的,咱们来看一下。

  1. 此函数并无被withMacroTask包装,它是callbacks被flush时被调用的,因此useMacrotask并无被改变依然是其默认值false

  2. 因为第一点缘由咱们再此次执行宏任务macrotask时产生了微任务microtask来处理本次的flushCallbacks(也就是调用了microTimerFunc

因此当本次macrotask结束时,本次的事件循环尚未结束,咱们还留下了微任务须要处理,依然是调用flushSchedulerQueue,而后watcher.run,由于这次show已经为true了,因此对比新老虚拟dom,从新生成该组件,生命周期完成重置。此时,本轮事件循环结束,浏览器从新渲染。但愿您还记得,咱们的浏览器自己如今的状态就是该组件显示在可视区内,从新渲染后该组件依然显示,因此天然不会出现组件闪动的状况。

如今我相信您本身也能想清楚为何咱们的例子中使用setTimeout会有闪动,可是我仍是说一下缘由来看一下您与个人想法是否一致。由于setTimeout产生的是宏任务,当一轮事件循环完成后,宏任务并不会直接处理,中间插入了浏览器的绘制。浏览器从新绘制后会将显示的组件移除掉,因此区域内出现一片空白,紧接着下一次事件循环开始,宏任务被执行组件dom又被从新建立,事件循环结束,浏览器重绘,又在可视区域上将该组件显示。因此在您的视觉效果上,该组件会有闪动,整个过程结束。

终于咱们想说的都说完了,若是您能坚持看到这里,十分感谢您。不过还有几点是咱们依然要考虑的。

  1. Vue干吗要使用异步队列更新,这明明很TM麻烦又很绕

其实文档已经告诉咱们了

这种在缓冲时去除重复数据对于避免没必要要的计算和 DOM 操做上很是重要。

咱们假设flushSchedulerQueue并无经过nextTick而是直接被调用,那么第一种写法this.show = false; this.show = true都会触发watcher.run方法,致使的结果就是这种写法也能够重置组件的生命周期,您能够在Vue源码中注释掉nextTick(flushSchedulerQueue)改用flushSchedulerQueue()打断点来更加明确的体验一下流程。要知道这仅仅是一个简单的例子,实际工做中咱们可能由于这种问题使dom白白被改变了巨屡次,咱们都知道dom的操做是昂贵的,因此Vue帮咱们再框架内优化了该步骤。您不妨再想一下直接flushSchedulerQueue()这种状况下,组件会不会闪动,来巩固咱们刚才讲过的东西。

  1. 既然nextTick的使用的微任务是由Promise.then().resolve()生成的,咱们可不能够直接在回调函数中写this.show = false; Promise.then().resolve(() => { this.show = true })来代替this.$nextTick?很明显我既然这么问了那就是不行的,只是过程您须要本身思考。

最后,感谢阅读~~~

相关文章
相关标签/搜索