专栏名称: 王巍达
前端
目录
相关文章推荐
国家外汇管理局  ·  国家外汇管理局公布2025年1月末外汇储备规模数据 ·  19 小时前  
国家外汇管理局  ·  习近平在哈尔滨第九届亚洲冬季运动会开幕式欢迎 ... ·  19 小时前  
NoxInfluencer  ·  万象新启 再踏征程! ·  2 天前  
金色旋风  ·  最后3天,10年最低,买贵倒赔1000!福利 ... ·  2 天前  
金色旋风  ·  最后3天,10年最低,买贵倒赔1000!福利 ... ·  2 天前  
51好读  ›  专栏  ›  王巍达

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

王巍达  · 掘金  ·  · 2017-12-29 09:15

正文

声明:本文章中所有源码取自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的核心流程大体可以分成以下几步

  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 = false this.$nextTick(() => { this.show = true }) 还没开始执行,不过别绝望,也快轮到它了。好的,回到正题来看看 macroTimerFunc microTimerFunc 吧。

/**
 * 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






请到「今天看啥」查看全文


推荐文章
国家外汇管理局  ·  国家外汇管理局公布2025年1月末外汇储备规模数据
19 小时前
NoxInfluencer  ·  万象新启 再踏征程!
2 天前